Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 107 additions & 96 deletions apps/docs/api-gen.plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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 {
Expand Down Expand Up @@ -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<string>();
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';
Expand Down
41 changes: 40 additions & 1 deletion apps/docs/plugin-utils.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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<string, SiteRoute>();
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 `''`
Expand Down Expand Up @@ -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.
Expand Down
43 changes: 7 additions & 36 deletions apps/docs/sitemap.plugin.ts
Original file line number Diff line number Diff line change
@@ -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 `<lastmod>`, 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 `<lastmod>`, falling back to mtime
* for uncommitted files.
*
* Versioning is per-deployment (each docs version is its own site under the
Expand Down Expand Up @@ -47,29 +39,8 @@ export function sitemapPlugin(opts: {siteUrl: string}): Plugin {
},
generateBundle() {
const entries = new Map<string, string>();

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()]
Expand Down
5 changes: 4 additions & 1 deletion apps/docs/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -90,6 +90,9 @@ export default defineConfig(async () => ({
searchIndexPlugin(),
apiGenPlugin(),
analog({
prerender: {
routes: async () => prerenderRoutes(import.meta.dirname),
},
apiPrefix: '_server',
content: {
highlighter: 'shiki',
Expand Down
Loading