From c7e1c2364dd917e4088a8c3e0efea44bb6c31ef4 Mon Sep 17 00:00:00 2001 From: Kam Date: Fri, 2 Oct 2026 16:46:20 +0300 Subject: [PATCH] fix(docs): prerender every docs page The docs site prerendered only the home page, so opening or refreshing any other URL on a host with a fallback to index.html showed the home page until Angular loaded and swapped in the real page. It now prerenders every page and a 404.html from the route list the sitemap uses, taken from the same fix in NgMd. --- apps/docs/api-gen.plugin.ts | 203 +++++++++++++++++++----------------- apps/docs/plugin-utils.ts | 41 +++++++- apps/docs/sitemap.plugin.ts | 43 ++------ apps/docs/vite.config.ts | 5 +- 4 files changed, 158 insertions(+), 134 deletions(-) diff --git a/apps/docs/api-gen.plugin.ts b/apps/docs/api-gen.plugin.ts index 38cb331..072e17c 100644 --- a/apps/docs/api-gen.plugin.ts +++ b/apps/docs/api-gen.plugin.ts @@ -37,106 +37,14 @@ export function apiGenPlugin(): Plugin { let configMemo: ApiConfig | null | undefined; function loadConfig(): ApiConfig | null { - if (configMemo === undefined) configMemo = readConfig(); + if (configMemo === undefined) configMemo = readApiConfig(root); return configMemo; } - function readConfig(): ApiConfig | null { - const path = configPath(); - if (!existsSync(path)) return null; - try { - const proj = new Project({ - compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext}, - }); - const sourceFile = proj.addSourceFileAtPath(path); - // We can't trivially evaluate the TS without a runtime; instead lift - // the literal passed to `defineApi(...)` via AST traversal. For the - // skeleton, every supported field is read as a literal so static - // extraction is enough. Read the `export default` expression directly - // rather than the first `CallExpression` in the file — otherwise any - // helper call before the default export (even a harmless one) would - // be parsed as the config. - const exportAssignment = sourceFile.getExportAssignment((ea) => !ea.isExportEquals()); - if (!exportAssignment) return null; - const callExpr = exportAssignment.getExpression().asKind(ts.SyntaxKind.CallExpression); - if (!callExpr) return null; - const literal = callExpr.getArguments()[0]; - if (!literal || !literal.asKind(ts.SyntaxKind.ObjectLiteralExpression)) return null; - return parseLiteralAsConfig(literal as never); - } catch (err) { - console.warn('[ngmd-api-gen] failed to load ngmd.api.ts:', err); - return null; - } - } - - function ensureProject(config: ApiConfig): Project { - if (project) return project; - project = new Project({ - tsConfigFilePath: existsSync(join(root, 'tsconfig.json')) - ? join(root, 'tsconfig.json') - : undefined, - skipAddingFilesFromTsConfig: true, - }); - project.addSourceFilesAtPaths([ - ...config.scope.map((pattern) => posix.join(root, pattern)), - ...(config.exclude ?? []).map((pattern) => '!' + posix.join(root, pattern)), - ]); - return project; - } - function extractRecords(config: ApiConfig): SymbolRecord[] { - if (recordsMemo) return recordsMemo; - const proj = ensureProject(config); - const records: SymbolRecord[] = []; - const seen = new Set(); - const badgeTags = new Set(config.badgesFromJsDoc ?? []); - - for (const sourceFile of proj.getSourceFiles()) { - for (const [exportName, declarations] of sourceFile.getExportedDeclarations()) { - const decls = declarations.filter((d) => symbolKindOf(d)); - const first = decls[0]; - if (!first) continue; - const declFile = first.getSourceFile(); - if (declFile.isInNodeModules() || declFile.isDeclarationFile()) continue; - const kind = symbolKindOf(first)!; - const name = - exportName === 'default' - ? ((first as {getName?: () => string | undefined}).getName?.() ?? exportName) - : exportName; - const filePath = posix.relative(root, declFile.getFilePath()); - const key = `${filePath}:${first.getStart()}:${name}`; - if (seen.has(key)) continue; - seen.add(key); - - const jsDocsPerDecl = decls.map((d) => { - const host = jsDocHostFor(d); - return Node.isJSDocable(host) ? host.getJsDocs() : []; - }); - const jsDocs = jsDocsPerDecl.flat(); - const description = - jsDocsPerDecl - .find((docs) => docs.length) - ?.at(-1) - ?.getDescription() - .trim() ?? ''; - const tags = jsDocs.flatMap((d) => d.getTags().map((t) => t.getTagName())); - const badges = [...new Set(tags.filter((t) => badgeTags.has(t)))]; - - records.push({ - kind, - name, - filePath, - line: first.getStartLineNumber(), - signature: signatureOf(decls), - description, - badges, - group: groupNameFor(filePath, config.groupBy ?? 'directory', kind), - }); - } - } - - recordsMemo = records; - return records; + project ??= createApiProject(root, config); + recordsMemo ??= extractApiRecords(root, config, project); + return recordsMemo; } return { @@ -194,6 +102,109 @@ export function apiGenPlugin(): Plugin { } } +function readApiConfig(root: string): ApiConfig | null { + const path = posix.join(root, 'ngmd.api.ts'); + if (!existsSync(path)) return null; + try { + const proj = new Project({ + compilerOptions: {target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext}, + }); + const sourceFile = proj.addSourceFileAtPath(path); + // We can't trivially evaluate the TS without a runtime; instead lift + // the literal passed to `defineApi(...)` via AST traversal. For the + // skeleton, every supported field is read as a literal so static + // extraction is enough. Read the `export default` expression directly + // rather than the first `CallExpression` in the file — otherwise any + // helper call before the default export (even a harmless one) would + // be parsed as the config. + const exportAssignment = sourceFile.getExportAssignment((ea) => !ea.isExportEquals()); + if (!exportAssignment) return null; + const callExpr = exportAssignment.getExpression().asKind(ts.SyntaxKind.CallExpression); + if (!callExpr) return null; + const literal = callExpr.getArguments()[0]; + if (!literal || !literal.asKind(ts.SyntaxKind.ObjectLiteralExpression)) return null; + return parseLiteralAsConfig(literal as never); + } catch (err) { + console.warn('[ngmd-api-gen] failed to load ngmd.api.ts:', err); + return null; + } +} + +function createApiProject(root: string, config: ApiConfig): Project { + const project = new Project({ + tsConfigFilePath: existsSync(join(root, 'tsconfig.json')) + ? join(root, 'tsconfig.json') + : undefined, + skipAddingFilesFromTsConfig: true, + }); + project.addSourceFilesAtPaths([ + ...config.scope.map((pattern) => posix.join(root, pattern)), + ...(config.exclude ?? []).map((pattern) => '!' + posix.join(root, pattern)), + ]); + return project; +} + +export function apiRoutes(root: string): Array<{route: string; file: string}> { + const config = readApiConfig(root); + if (!config) return []; + return extractApiRecords(root, config, createApiProject(root, config)).map((record) => ({ + route: `/api/${record.group}/${record.name}`, + file: record.filePath, + })); +} + +function extractApiRecords(root: string, config: ApiConfig, proj: Project): SymbolRecord[] { + const records: SymbolRecord[] = []; + const seen = new Set(); + const badgeTags = new Set(config.badgesFromJsDoc ?? []); + + for (const sourceFile of proj.getSourceFiles()) { + for (const [exportName, declarations] of sourceFile.getExportedDeclarations()) { + const decls = declarations.filter((d) => symbolKindOf(d)); + const first = decls[0]; + if (!first) continue; + const declFile = first.getSourceFile(); + if (declFile.isInNodeModules() || declFile.isDeclarationFile()) continue; + const kind = symbolKindOf(first)!; + const name = + exportName === 'default' + ? ((first as {getName?: () => string | undefined}).getName?.() ?? exportName) + : exportName; + const filePath = posix.relative(root, declFile.getFilePath()); + const key = `${filePath}:${first.getStart()}:${name}`; + if (seen.has(key)) continue; + seen.add(key); + + const jsDocsPerDecl = decls.map((d) => { + const host = jsDocHostFor(d); + return Node.isJSDocable(host) ? host.getJsDocs() : []; + }); + const jsDocs = jsDocsPerDecl.flat(); + const description = + jsDocsPerDecl + .find((docs) => docs.length) + ?.at(-1) + ?.getDescription() + .trim() ?? ''; + const tags = jsDocs.flatMap((d) => d.getTags().map((t) => t.getTagName())); + const badges = [...new Set(tags.filter((t) => badgeTags.has(t)))]; + + records.push({ + kind, + name, + filePath, + line: first.getStartLineNumber(), + signature: signatureOf(decls), + description, + badges, + group: groupNameFor(filePath, config.groupBy ?? 'directory', kind), + }); + } + } + + return records; +} + function symbolKindOf(decl: Node): SymbolKind | null { if (Node.isClassDeclaration(decl)) return 'class'; if (Node.isInterfaceDeclaration(decl)) return 'interface'; diff --git a/apps/docs/plugin-utils.ts b/apps/docs/plugin-utils.ts index 2f499b0..4ff2775 100644 --- a/apps/docs/plugin-utils.ts +++ b/apps/docs/plugin-utils.ts @@ -1,7 +1,8 @@ import {execFileSync} from 'node:child_process'; -import {readdirSync, realpathSync, statSync} from 'node:fs'; +import {readFileSync, readdirSync, realpathSync, statSync} from 'node:fs'; import {isAbsolute, join, relative, resolve} from 'node:path'; import frontMatter from 'front-matter'; +import {apiRoutes} from './api-gen.plugin.ts'; export {createSlugger, headingText, slugify} from './src/app/utils/heading-slug.ts'; @@ -87,6 +88,42 @@ function pageRouteSegments(rel: string): string[] | null { return trimmed.split(/[/.]/).filter((s) => s !== 'index' && !/^\(.*\)$/.test(s)); } +export interface SiteRoute { + route: string; + file: string; + noIndex: boolean; +} + +export function siteRoutes(root: string): SiteRoute[] { + const routes = new Map(); + try { + for (const file of walkPageFiles(join(root, 'src/app/pages'), root)) { + const route = routeFromPagePath(file); + if (route) routes.set(route, {route, file, noIndex: false}); + } + } catch { + // src/app/pages missing + } + try { + for (const [file, route] of walkContentFiles(join(root, 'src/content'), root)) { + const noIndex = isNoIndex( + parseFrontmatter(readFileSync(join(root, file), 'utf8')).attributes, + ); + if (!noIndex || !routes.has(route)) routes.set(route, {route, file, noIndex}); + } + } catch { + // src/content missing + } + for (const {route, file} of apiRoutes(root)) { + if (!routes.has(route)) routes.set(route, {route, file, noIndex: false}); + } + return [...routes.values()].sort((a, b) => a.route.localeCompare(b.route)); +} + +export function prerenderRoutes(root: string): string[] { + return [...siteRoutes(root).map((r) => r.route), '/404.html']; +} + /** * Last-commit date for `file` (YYYY-MM-DD), via `git log -1 --format=%cs`. * Falls back to file mtime when the file is uncommitted, and to `''` @@ -154,6 +191,8 @@ export function withoutCode(markdown: string): string { .replace(/(`+)[^\n]*?\1/g, ' '); } +export const siteRoot = import.meta.dirname; + /** * Resolve `path` against `root`, following symlinks, and throw when the * real target lies outside the real root. diff --git a/apps/docs/sitemap.plugin.ts b/apps/docs/sitemap.plugin.ts index 674ab5e..1be3ca9 100644 --- a/apps/docs/sitemap.plugin.ts +++ b/apps/docs/sitemap.plugin.ts @@ -1,21 +1,13 @@ -import {readFileSync, statSync} from 'node:fs'; -import {join} from 'node:path'; import type {Plugin} from 'vite'; -import { - gitDate, - isNoIndex, - parseFrontmatter, - routeFromPagePath, - walkContentFiles, - walkPageFiles, -} from './plugin-utils.ts'; +import {gitDate, siteRoutes} from './plugin-utils.ts'; /** * Emits `sitemap.xml` and `robots.txt` into the client build output. * - * Discovery mirrors the page-meta plugin: walks `src/app/pages/*.page.ts` - * and `src/content/**\/*.md`, pulls each file's last commit date via - * `git log -1 --format=%cs` to populate ``, falls back to mtime + * Routes come from `siteRoutes` in plugin-utils, the same list the build + * prerenders: `src/app/pages/*.page.ts`, `src/content/**\/*.md` and the API + * symbol pages, minus `noIndex` pages. Each file's last commit date via + * `git log -1 --format=%cs` populates ``, falling back to mtime * for uncommitted files. * * Versioning is per-deployment (each docs version is its own site under the @@ -47,29 +39,8 @@ export function sitemapPlugin(opts: {siteUrl: string}): Plugin { }, generateBundle() { const entries = new Map(); - - try { - const pageFiles = walkPageFiles(join(root, 'src/app/pages'), root); - for (const rel of pageFiles) { - const route = routeFromPagePath(rel); - if (!route) continue; - entries.set(route, gitDate(rel, root, today)); - } - } catch { - // src/app/pages missing — fine - } - - const contentDir = join(root, 'src/content'); - try { - statSync(contentDir); - for (const [rel, route] of walkContentFiles(contentDir, root)) { - if (isNoIndex(parseFrontmatter(readFileSync(join(root, rel), 'utf8')).attributes)) { - continue; - } - entries.set(route, gitDate(rel, root, today)); - } - } catch { - // src/content missing — skip + for (const {route, file, noIndex} of siteRoutes(root)) { + if (!noIndex) entries.set(route, gitDate(file, root, today)); } const urls = [...entries.entries()] diff --git a/apps/docs/vite.config.ts b/apps/docs/vite.config.ts index b5b3090..3d37904 100644 --- a/apps/docs/vite.config.ts +++ b/apps/docs/vite.config.ts @@ -13,7 +13,7 @@ import {searchIndexPlugin} from './search-index.plugin.ts'; import {rawMdPlugin} from './raw-md.plugin.ts'; import {varsPlugin} from './vars.plugin.ts'; import {apiGenPlugin} from './api-gen.plugin.ts'; -import {withoutCode} from './plugin-utils.ts'; +import {prerenderRoutes, withoutCode} from './plugin-utils.ts'; import config from './src/ngmd.config.ts'; /** @@ -90,6 +90,9 @@ export default defineConfig(async () => ({ searchIndexPlugin(), apiGenPlugin(), analog({ + prerender: { + routes: async () => prerenderRoutes(import.meta.dirname), + }, apiPrefix: '_server', content: { highlighter: 'shiki',