@agentvibes/site 0.0.0-stage → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +4 -2
  3. package/bin/agentvibes-site.mjs +7 -0
  4. package/package.json +87 -3
  5. package/src/app-mode.tsx +110 -0
  6. package/src/catalog.ts +80 -0
  7. package/src/cli.ts +51 -0
  8. package/src/client.ts +5 -0
  9. package/src/components/changelog-entry-view.tsx +11 -0
  10. package/src/components/doctor-throw/doctor-throw.tsx +15 -0
  11. package/src/components/errors-init.tsx +14 -0
  12. package/src/components/hero/hero-action-link.tsx +15 -0
  13. package/src/components/hero/hero.tsx +31 -0
  14. package/src/components/home-shell.tsx +19 -0
  15. package/src/components/jsdoc-view/group-view.tsx +14 -0
  16. package/src/components/jsdoc-view/item-view.tsx +24 -0
  17. package/src/components/jsdoc-view/jsdoc-view.tsx +16 -0
  18. package/src/components/jsdoc-view/member-view.tsx +15 -0
  19. package/src/components/module-cards/module-card.tsx +13 -0
  20. package/src/components/module-cards/module-cards.tsx +15 -0
  21. package/src/components/page-actions/copy-button.tsx +40 -0
  22. package/src/components/page-actions/page-actions.tsx +53 -0
  23. package/src/components/page-footer.tsx +20 -0
  24. package/src/components/page-link/page-link.tsx +19 -0
  25. package/src/components/site-error-boundary/error-panel.tsx +16 -0
  26. package/src/components/site-error-boundary/site-error-boundary.tsx +12 -0
  27. package/src/components/site-error-boundary/site-global-error-boundary.tsx +19 -0
  28. package/src/components/site-footer/footer-column-view.tsx +15 -0
  29. package/src/components/site-footer/footer-link.tsx +20 -0
  30. package/src/components/site-footer/site-footer.tsx +32 -0
  31. package/src/components/site-video/site-video.tsx +25 -0
  32. package/src/components/telemetry-scripts.tsx +18 -0
  33. package/src/config.ts +9 -0
  34. package/src/context.ts +68 -0
  35. package/src/data.ts +64 -0
  36. package/src/docs-title.ts +8 -0
  37. package/src/doctor-routes.tsx +44 -0
  38. package/src/index.ts +39 -0
  39. package/src/instrumentation.ts +53 -0
  40. package/src/jsdoc.ts +38 -0
  41. package/src/layout-options.ts +18 -0
  42. package/src/layouts.tsx +63 -0
  43. package/src/mdx-config.d.ts +2 -0
  44. package/src/mdx-config.js +7 -0
  45. package/src/mdx.tsx +18 -0
  46. package/src/nav.ts +97 -0
  47. package/src/og-route.tsx +29 -0
  48. package/src/output.ts +90 -0
  49. package/src/pages.tsx +186 -0
  50. package/src/prepare/assets.ts +98 -0
  51. package/src/prepare/author-next-config.ts +46 -0
  52. package/src/prepare/changelog.ts +17 -0
  53. package/src/prepare/files.ts +95 -0
  54. package/src/prepare/finish-static.ts +122 -0
  55. package/src/prepare/index.ts +291 -0
  56. package/src/prepare/jsdoc-extract.ts +182 -0
  57. package/src/prepare/release.ts +12 -0
  58. package/src/prepare/templates.ts +415 -0
  59. package/src/prepare/unimplemented.ts +32 -0
  60. package/src/proxy.ts +57 -0
  61. package/src/routes.ts +158 -0
  62. package/src/runtime.tsx +165 -0
  63. package/src/theme.ts +59 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Writing the files `prepare` owns. Every generated file carries the marker,
3
+ * so a rerun may replace or delete it; a file without the marker belongs to
4
+ * the author and is never overwritten.
5
+ */
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+
9
+ export const GENERATED_MARKER = "generated by agentvibes-site prepare";
10
+ const NOTICE = `${GENERATED_MARKER}: do not edit; change site.config.ts or content/ and rerun it.`;
11
+
12
+ export type CommentStyle = "slash" | "css" | "hash" | "xml";
13
+
14
+ export function withHeader(style: CommentStyle, body: string): string {
15
+ const header = {
16
+ slash: `// ${NOTICE}`,
17
+ css: `/* ${NOTICE} */`,
18
+ hash: `# ${NOTICE}`,
19
+ xml: `<!-- ${NOTICE} -->`
20
+ }[style];
21
+ return `${header}\n${body}`;
22
+ }
23
+
24
+ export type GeneratedFile = {
25
+ /** Path relative to the site directory, with forward slashes. */
26
+ readonly path: string;
27
+ readonly content: string;
28
+ };
29
+
30
+ export class GeneratedFileConflictError extends Error {
31
+ override readonly name = "GeneratedFileConflictError";
32
+ }
33
+
34
+ export function isGenerated(file: string): boolean {
35
+ if (!fs.existsSync(file) || !fs.statSync(file).isFile()) return false;
36
+ const fd = fs.openSync(file, "r");
37
+ try {
38
+ const buffer = Buffer.alloc(512);
39
+ const read = fs.readSync(fd, buffer, 0, buffer.length, 0);
40
+ return buffer.subarray(0, read).toString("utf8").includes(GENERATED_MARKER);
41
+ } finally {
42
+ fs.closeSync(fd);
43
+ }
44
+ }
45
+
46
+ function walk(dir: string): string[] {
47
+ if (!fs.existsSync(dir)) return [];
48
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
49
+ const full = path.join(dir, entry.name);
50
+ return entry.isDirectory() ? walk(full) : [full];
51
+ });
52
+ }
53
+
54
+ function removeEmptyDirs(dir: string, stop: string): void {
55
+ let current = dir;
56
+ while (current.startsWith(stop) && current !== stop && fs.existsSync(current) && fs.readdirSync(current).length === 0) {
57
+ fs.rmdirSync(current);
58
+ current = path.dirname(current);
59
+ }
60
+ }
61
+
62
+ export type WriteReport = { readonly written: string[]; readonly unchanged: string[]; readonly removed: string[] };
63
+
64
+ /**
65
+ * Write `files` under `siteDir`, then delete generated files in `ownedDirs`
66
+ * (and generated top-level files) that are no longer produced.
67
+ */
68
+ export function writeGeneratedFiles(siteDir: string, files: readonly GeneratedFile[], ownedDirs: readonly string[]): WriteReport {
69
+ const report: WriteReport = { written: [], unchanged: [], removed: [] };
70
+ for (const file of files) {
71
+ const target = path.join(siteDir, file.path);
72
+ if (fs.existsSync(target) && !isGenerated(target)) {
73
+ throw new GeneratedFileConflictError(
74
+ `${file.path} exists and was not generated by agentvibes-site; move it away (or delete it) so prepare can own it.`
75
+ );
76
+ }
77
+ if (fs.existsSync(target) && fs.readFileSync(target, "utf8") === file.content) {
78
+ report.unchanged.push(file.path);
79
+ continue;
80
+ }
81
+ fs.mkdirSync(path.dirname(target), { recursive: true });
82
+ fs.writeFileSync(target, file.content);
83
+ report.written.push(file.path);
84
+ }
85
+ const keep = new Set(files.map((file) => path.join(siteDir, file.path)));
86
+ const topLevel = fs.readdirSync(siteDir).map((name) => path.join(siteDir, name));
87
+ const candidates = [...topLevel, ...ownedDirs.flatMap((dir) => walk(path.join(siteDir, dir)))];
88
+ for (const candidate of candidates) {
89
+ if (keep.has(candidate) || !isGenerated(candidate)) continue;
90
+ fs.rmSync(candidate);
91
+ removeEmptyDirs(path.dirname(candidate), siteDir);
92
+ report.removed.push(path.relative(siteDir, candidate).split(path.sep).join("/"));
93
+ }
94
+ return report;
95
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `agentvibes-site finish-static`: what `next build` cannot do for a static
3
+ * export, run on `out/` right after it. It moves the Markdown twins next to
4
+ * their pages (`out/docs/init.md`), and turns the author's `redirects()`, which
5
+ * Next ignores in an export, into pages that send the visitor on.
6
+ */
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+
10
+ import { loadSiteConfigFile } from "@agentvibes/site-config/node";
11
+
12
+ import { ASSETS_DIR, ASSETS_MARKER_FILE } from "./assets";
13
+ import { loadAuthorNextConfig } from "./author-next-config";
14
+ import { findConfig } from "./index";
15
+
16
+ export class FinishStaticError extends Error {
17
+ override readonly name = "FinishStaticError";
18
+ }
19
+
20
+ const MARKDOWN_DIR = "llms.mdx";
21
+
22
+ export type StaticRedirect = { readonly source: string; readonly destination: string };
23
+
24
+ function moveInto(from: string, to: string): void {
25
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
26
+ const source = path.join(from, entry.name);
27
+ const target = path.join(to, entry.name);
28
+ if (entry.isDirectory()) {
29
+ fs.mkdirSync(target, { recursive: true });
30
+ moveInto(source, target);
31
+ } else {
32
+ fs.renameSync(source, target);
33
+ }
34
+ }
35
+ fs.rmdirSync(from);
36
+ }
37
+
38
+ function walkFiles(dir: string): string[] {
39
+ return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
40
+ const full = path.join(dir, entry.name);
41
+ return entry.isDirectory() ? walkFiles(full) : [full];
42
+ });
43
+ }
44
+
45
+ function escapeRegExp(text: string): string {
46
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
47
+ }
48
+
49
+ /** A matcher for the two forms static sites use: `:name` and `:name(a|b)`. Anything else cannot be expanded to files. */
50
+ export function redirectMatcher(source: string): RegExp {
51
+ const pattern = source.replace(/:([A-Za-z0-9_]+)(\([^)]*\))?|[^:]+/g, (part, name: string | undefined, choices: string | undefined) => {
52
+ if (name === undefined) {
53
+ if (/[*+?()]/.test(part)) throw new FinishStaticError(`redirect source ${source}: only :name and :name(a|b) can become static pages`);
54
+ return escapeRegExp(part);
55
+ }
56
+ return `(?<${name}>${choices === undefined ? "[^/]+" : choices.slice(1, -1)})`;
57
+ });
58
+ return new RegExp(`^${pattern}$`);
59
+ }
60
+
61
+ /** The redirects that apply to the exported pages: `/docs/agent/jsdoc` → `/docs/agent/api`. */
62
+ export function expandRedirects(redirects: readonly StaticRedirect[], pagePaths: readonly string[]): StaticRedirect[] {
63
+ return redirects.flatMap((redirect) => {
64
+ const matcher = redirectMatcher(redirect.source);
65
+ return pagePaths.flatMap((page) => {
66
+ const found = matcher.exec(page);
67
+ if (found === null) return [];
68
+ const destination = redirect.destination.replace(/:([A-Za-z0-9_]+)/g, (_all, name: string) => found.groups?.[name] ?? `:${name}`);
69
+ return [{ source: page, destination }];
70
+ });
71
+ });
72
+ }
73
+
74
+ /** A page whose only job is to send the visitor on: no server can answer 308 on a static host. */
75
+ export function redirectPage(url: string, lang: string): string {
76
+ const attribute = url.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
77
+ return `<!doctype html><html lang="${lang}"><head><meta charset="utf-8"><title>Moved</title><link rel="icon" href="data:,"><meta name="robots" content="noindex"><link rel="canonical" href="${attribute}"><meta http-equiv="refresh" content="0;url=${attribute}"></head><body><a href="${attribute}">Moved here</a></body></html>\n`;
78
+ }
79
+
80
+ async function authorRedirects(siteDir: string): Promise<StaticRedirect[]> {
81
+ return (await loadAuthorNextConfig(siteDir)).redirects();
82
+ }
83
+
84
+ export type FinishReport = {
85
+ readonly markdownTwins: number;
86
+ readonly redirects: readonly StaticRedirect[];
87
+ readonly files: number;
88
+ readonly bytes: number;
89
+ readonly largest: { readonly file: string; readonly bytes: number };
90
+ };
91
+
92
+ export async function finishStatic(siteDir: string, basePath: string): Promise<FinishReport> {
93
+ const out = path.join(path.resolve(siteDir), "out");
94
+ if (!fs.existsSync(path.join(out, "index.html"))) throw new FinishStaticError(`${out}/index.html is missing: run next build with static output first`);
95
+
96
+ // prepare's bookkeeping in public/_av: not content, and that .gitignore would hide the fonts from whoever commits out/.
97
+ const assets = path.join(out, path.relative("public", ASSETS_DIR));
98
+ for (const file of [ASSETS_MARKER_FILE, ".gitignore"]) fs.rmSync(path.join(assets, file), { force: true });
99
+
100
+ const twinsDir = path.join(out, MARKDOWN_DIR);
101
+ const markdownTwins = fs.existsSync(twinsDir) ? walkFiles(twinsDir).length : 0;
102
+ if (markdownTwins > 0) moveInto(twinsDir, out);
103
+
104
+ const pages = walkFiles(out)
105
+ .filter((file) => path.basename(file) === "index.html")
106
+ .map((file) => `/${path.relative(out, path.dirname(file)).split(path.sep).join("/")}`.replace(/\/$/, "") || "/");
107
+ const redirects = expandRedirects(await authorRedirects(siteDir), pages);
108
+ // The page language comes from site.config; a site with no redirect has no page that needs it.
109
+ const lang = redirects.length === 0 ? "en" : (await loadSiteConfigFile(findConfig(path.resolve(siteDir)))).config.lang;
110
+ for (const redirect of redirects) {
111
+ const dir = path.join(out, redirect.source);
112
+ // The whole directory: Next's client router would otherwise read the old page's data files instead of redirecting.
113
+ fs.rmSync(dir, { recursive: true });
114
+ fs.mkdirSync(dir, { recursive: true });
115
+ const target = /^[a-z]+:\/\//i.test(redirect.destination) ? redirect.destination : `${basePath}${redirect.destination.replace(/\/?$/, "/")}`;
116
+ fs.writeFileSync(path.join(dir, "index.html"), redirectPage(target, lang));
117
+ }
118
+
119
+ const sizes = walkFiles(out).map((file) => ({ file: path.relative(out, file), bytes: fs.statSync(file).size }));
120
+ const largest = sizes.reduce((best, item) => (item.bytes > best.bytes ? item : best), { file: "", bytes: 0 });
121
+ return { markdownTwins, redirects, files: sizes.length, bytes: sizes.reduce((sum, item) => sum + item.bytes, 0), largest };
122
+ }
@@ -0,0 +1,291 @@
1
+ /**
2
+ * `agentvibes-site prepare`: reads site.config.ts, the preset, project.toml,
3
+ * the npm modules' sources and CHANGELOG.md files, and writes the Next app
4
+ * (routes, layout, config files, baked data). Run before `next build`/`next dev`.
5
+ */
6
+ import { execFileSync } from "node:child_process";
7
+ import fs from "node:fs";
8
+ import path from "node:path";
9
+
10
+ import { warnDegraded } from "@agentvibes/kit/diagnostics";
11
+ import { appColors } from "@agentvibes/site-app";
12
+ import { monogramSvg, renderFavicon } from "@agentvibes/site-app/icons";
13
+ import { unsupportedBecause, type SiteConfig, type SiteTheme } from "@agentvibes/site-config";
14
+ import { findProjectToml, loadSiteConfigFile, loadSitePreset, readProjectToml } from "@agentvibes/site-config/node";
15
+ import { parseTerminalFrame } from "@agentvibes/site-mdx/media";
16
+ import { PACKAGE_FONTS_DIR } from "@agentvibes/site-mdx/node";
17
+
18
+ import type { GeneratedSiteData, JsdocData, MediaData, VideoEntry } from "../data";
19
+ import { hasChangelog, hasDocs } from "../nav";
20
+ import { resolveOutput, withBasePath, type OutputFlags, type SiteOutput } from "../output";
21
+ import { siteTheme, themeCss } from "../theme";
22
+ import { writeAssets } from "./assets";
23
+ import { readChangelogs } from "./changelog";
24
+ import { isGenerated, writeGeneratedFiles, type WriteReport } from "./files";
25
+ import { extractApi, resolveSourceEntry } from "./jsdoc-extract";
26
+ import { releaseOf } from "./release";
27
+ import { unimplementedConfig } from "./unimplemented";
28
+ import { loadAuthorNextConfig } from "./author-next-config";
29
+ import { AUTHOR_CHROME, AUTHOR_CSS, AUTHOR_ENTRIES, AUTHOR_NEXT_CONFIG, AUTHOR_PROXY, siteFiles } from "./templates";
30
+
31
+ const CONFIG_NAMES = ["site.config.ts", "site.config.mts", "site.config.mjs", "site.config.js"];
32
+
33
+ /** Packages of the Site Factory that ship TypeScript sources and so must be compiled by Next. */
34
+ const FACTORY_PACKAGES = [
35
+ "@agentvibes/site",
36
+ "@agentvibes/site-app",
37
+ "@agentvibes/site-config",
38
+ "@agentvibes/site-llms",
39
+ "@agentvibes/site-mdx",
40
+ "@agentvibes/site-seo",
41
+ "@agentvibes/site-telemetry",
42
+ "@agentvibes/theme-public"
43
+ ];
44
+
45
+ export class PrepareError extends Error {
46
+ override readonly name = "PrepareError";
47
+ }
48
+
49
+ function git(cwd: string, args: readonly string[]): string | undefined {
50
+ try {
51
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
52
+ } catch (error) {
53
+ warnDegraded("site.prepare.git", `git ${args.join(" ")} failed in ${cwd}: ${error instanceof Error ? error.message : String(error)}`);
54
+ return undefined;
55
+ }
56
+ }
57
+
58
+
59
+ export function findConfig(siteDir: string): string {
60
+ const found = CONFIG_NAMES.map((name) => path.join(siteDir, name)).find((file) => fs.existsSync(file));
61
+ if (found === undefined) throw new PrepareError(`No site.config.ts in ${siteDir} (looked for ${CONFIG_NAMES.join(", ")}).`);
62
+ return found;
63
+ }
64
+
65
+ function collectJsdoc(config: SiteConfig, repoRoot: string): JsdocData {
66
+ const modules: Record<string, { entry: string | null; items: ReturnType<typeof extractApi> }> = {};
67
+ for (const mod of config.sections.npm?.modules ?? []) {
68
+ const entry = resolveSourceEntry(path.resolve(repoRoot, mod.folder.packageDir));
69
+ if (entry === undefined) {
70
+ warnDegraded("site.prepare.jsdoc", `${mod.npm.name}: no TypeScript source entry under ${mod.folder.packageDir}; its API page is empty`);
71
+ modules[mod.id] = { entry: null, items: [] };
72
+ continue;
73
+ }
74
+ modules[mod.id] = { entry: path.relative(repoRoot, entry).split(path.sep).join("/"), items: extractApi(entry) };
75
+ }
76
+ return { modules };
77
+ }
78
+
79
+ /** Real directory of an installed package, found by walking node_modules up from `fromDir`. */
80
+ function packageDir(name: string, fromDir: string): string {
81
+ let dir = fromDir;
82
+ for (;;) {
83
+ const candidate = path.join(dir, "node_modules", ...name.split("/"));
84
+ if (fs.existsSync(path.join(candidate, "package.json"))) return fs.realpathSync(candidate);
85
+ const parent = path.dirname(dir);
86
+ if (parent === dir) throw new PrepareError(`Cannot find the package ${name} from ${fromDir}; is it installed?`);
87
+ dir = parent;
88
+ }
89
+ }
90
+
91
+ function tailwindSources(siteDir: string): string[] {
92
+ const appDir = path.join(siteDir, "app");
93
+ const rel = (target: string) => path.relative(appDir, target).split(path.sep).join("/");
94
+ const site = packageDir("@agentvibes/site", siteDir);
95
+ const mdx = packageDir("@agentvibes/site-mdx", site);
96
+ const app = packageDir("@agentvibes/site-app", site);
97
+ const ui = packageDir("fumadocs-ui", siteDir);
98
+ return [`${rel(path.join(ui, "dist"))}/**/*.js`, ...[site, mdx, app].map((dir) => `${rel(path.join(dir, "src"))}/**/*.tsx`)];
99
+ }
100
+
101
+ /**
102
+ * Writes `.source/` (fumadocs-mdx's typed collections, which `app/_site/runtime.ts`
103
+ * imports) by running fumadocs-mdx's own generator in the site directory, so
104
+ * `tsc` works on a clean checkout before the first `next build`.
105
+ */
106
+ function generateContentCollections(siteDir: string): void {
107
+ const dir = packageDir("fumadocs-mdx", siteDir);
108
+ const manifest: unknown = JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
109
+ const bin = typeof manifest === "object" && manifest !== null && "bin" in manifest && typeof manifest.bin === "string" ? manifest.bin : undefined;
110
+ if (bin === undefined) throw new PrepareError(`fumadocs-mdx in ${dir} has no "bin" entry; cannot generate .source/`);
111
+ execFileSync(process.execPath, [path.join(dir, bin)], { cwd: siteDir, stdio: "inherit" });
112
+ }
113
+
114
+ /** Root-level generated files the repository should not track. */
115
+ function warnUntrackedIgnores(siteDir: string, files: readonly string[]): void {
116
+ const notIgnored = files.filter((file) => {
117
+ try {
118
+ execFileSync("git", ["check-ignore", "-q", file], { cwd: siteDir, stdio: "ignore" });
119
+ return false;
120
+ } catch {
121
+ return true;
122
+ }
123
+ });
124
+ if (notIgnored.length > 0) {
125
+ warnDegraded("site.prepare.gitignore", `generated files are not git-ignored in ${siteDir}: ${notIgnored.join(", ")}; add them to a .gitignore`);
126
+ }
127
+ }
128
+
129
+ /** kind "app": the app's home is the author's `app/page.tsx`, which prepare must not generate or delete. */
130
+ function requireAuthorHome(siteDir: string): void {
131
+ const home = path.join(siteDir, "app", "page.tsx");
132
+ if (!fs.existsSync(home) || isGenerated(home)) {
133
+ throw new PrepareError(`kind "app" needs your own app/page.tsx (the app's home screen) in ${siteDir}; prepare generates the landing page only for kind "docs" and "landing".`);
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Checks `sections.media.tui.frames` at build time: one `*` in the file name,
139
+ * and every matching file is a frame or a recording.
140
+ */
141
+ function checkFrames(siteDir: string, glob: string): void {
142
+ const dir = path.resolve(siteDir, path.dirname(glob));
143
+ const pattern = path.basename(glob);
144
+ if (glob.split("*").length !== 2 || !pattern.includes("*")) {
145
+ throw new PrepareError(`sections.media.tui.frames must have exactly one "*", in the file name (got ${JSON.stringify(glob)})`);
146
+ }
147
+ if (!fs.existsSync(dir)) throw new PrepareError(`sections.media.tui.frames: ${dir} does not exist`);
148
+ const [before = "", after = ""] = pattern.split("*");
149
+ for (const file of fs.readdirSync(dir).filter((name) => name.startsWith(before) && name.endsWith(after))) {
150
+ try {
151
+ parseTerminalFrame(fs.readFileSync(path.join(dir, file), "utf8"));
152
+ } catch (error) {
153
+ throw new PrepareError(`sections.media.tui.frames: ${path.join(dir, file)}: ${error instanceof Error ? error.message : String(error)}`);
154
+ }
155
+ }
156
+ }
157
+
158
+ /** Next's icon file names under `app/`: an author file there is the site's icon, and prepare draws none. */
159
+ const AUTHOR_APP_ICONS = ["icon.svg", "icon.png", "icon.ico", "icon.jpg", "icon.jpeg", "icon.tsx", "icon.ts", "icon.js"];
160
+ const MONOGRAM_FONT = "JetBrainsMono-Bold.woff2";
161
+
162
+ function authorFile(file: string): boolean {
163
+ return fs.existsSync(file) && fs.statSync(file).isFile() && !isGenerated(file);
164
+ }
165
+
166
+ /**
167
+ * The site's icon, for every kind of site: the SVG app mode uses
168
+ * (`app.pwa.icon`), else `icon.svg` beside site.config, else the first
169
+ * letter of the name on the theme's primary colour. `/favicon.ico` is
170
+ * rendered from the same SVG unless the author serves their own.
171
+ */
172
+ async function siteIcon(dir: string, config: SiteConfig, theme: SiteTheme): Promise<{ svg: string | undefined; favicon: string | undefined }> {
173
+ if (AUTHOR_APP_ICONS.some((name) => authorFile(path.join(dir, "app", name)))) return { svg: undefined, favicon: undefined };
174
+ const file = path.resolve(dir, config.app?.pwa?.icon ?? "icon.svg");
175
+ const svg = fs.existsSync(file)
176
+ ? fs.readFileSync(file, "utf8")
177
+ : monogramSvg({ name: config.name, background: theme.light.primary, foreground: theme.light.primaryForeground, fontFile: path.join(PACKAGE_FONTS_DIR, MONOGRAM_FONT) });
178
+ const ownFavicon = authorFile(path.join(dir, "app", "favicon.ico")) || fs.existsSync(path.join(dir, "public", "favicon.ico"));
179
+ return { svg, favicon: ownFavicon ? undefined : (await renderFavicon(Buffer.from(svg))).toString("base64") };
180
+ }
181
+
182
+ function hasContentFile(dir: string): boolean {
183
+ if (!fs.existsSync(dir)) return false;
184
+ return fs.readdirSync(dir, { withFileTypes: true }).some((entry) => (entry.isDirectory() ? hasContentFile(path.join(dir, entry.name)) : /\.mdx?$/.test(entry.name)));
185
+ }
186
+
187
+ /** kind "landing" builds no /docs route, so pages under content/docs would be left out of the build without a word. */
188
+ function warnLandingDocs(dir: string, config: SiteConfig): void {
189
+ if (config.kind !== "landing" || !hasContentFile(path.join(dir, "content", "docs"))) return;
190
+ warnDegraded(
191
+ "site.prepare.landing-docs",
192
+ `kind "landing" builds no /docs route, so the pages under content/docs are not built. For a landing page plus docs use kind: "docs" and put the landing page in content/pages/index.mdx.`
193
+ );
194
+ }
195
+
196
+ /** What a static host cannot do, rejected here rather than dropped from the site without a word. */
197
+ async function requireStaticSupport(dir: string, config: SiteConfig): Promise<void> {
198
+ const fix = 'set output: "server" in site.config (and drop --static)';
199
+ if (fs.existsSync(path.join(dir, AUTHOR_PROXY))) {
200
+ throw new PrepareError(`${unsupportedBecause("static", "requestGuard")} ${AUTHOR_PROXY} is in ${dir}: remove it or ${fix}.`);
201
+ }
202
+ if (hasDocs(config) && !hasContentFile(path.join(dir, "content", "docs"))) {
203
+ throw new PrepareError(`A static export needs at least one page under content/docs in ${dir}: add one, or set sections: { docs: false } in site.config for a site without docs.`);
204
+ }
205
+ if (config.app?.pwa !== undefined) {
206
+ throw new PrepareError(`${unsupportedBecause("static", "pwa")} Remove app.pwa or ${fix}.`);
207
+ }
208
+ if (fs.existsSync(path.join(dir, AUTHOR_NEXT_CONFIG)) && (await (await loadAuthorNextConfig(dir)).headers()).length > 0) {
209
+ throw new PrepareError(`${unsupportedBecause("static", "responseHeaders")} ${AUTHOR_NEXT_CONFIG} in ${dir} returns headers(): remove them and set the headers at the host, or ${fix}.`);
210
+ }
211
+ }
212
+
213
+ /** Plain URLs in the baked media manifest: Next adds the base path to its own links only. */
214
+ function mediaUnder(output: SiteOutput, media: MediaData): MediaData {
215
+ const entry = (video: VideoEntry): VideoEntry =>
216
+ Object.fromEntries(Object.entries(video).map(([key, value]) => [key, typeof value === "string" ? withBasePath(output, value) : value])) as VideoEntry;
217
+ return { videos: Object.fromEntries(Object.entries(media.videos).map(([name, video]) => [name, entry(video)])) };
218
+ }
219
+
220
+ /** Config the schema accepts and nothing implements fails the build now, not after it has produced a site without the feature. */
221
+ function requireImplemented(config: SiteConfig, configFile: string): void {
222
+ const problems = unimplementedConfig(config);
223
+ if (problems.length > 0) throw new PrepareError(`${configFile} declares features this version does not implement:\n- ${problems.join("\n- ")}`);
224
+ }
225
+
226
+ export type PrepareResult = { readonly siteDir: string; readonly report: WriteReport; readonly migrations: readonly string[] };
227
+
228
+ /** A ready `SiteOutput` (library callers) or the command-line flags (resolved against site.config's `output`). */
229
+ export async function prepare(siteDir: string, env: Record<string, string | undefined> = process.env, outputOrFlags: SiteOutput | OutputFlags = {}): Promise<PrepareResult> {
230
+ const dir = path.resolve(siteDir);
231
+ const configFile = findConfig(dir);
232
+ const { config, applied } = await loadSiteConfigFile(configFile);
233
+ requireImplemented(config, configFile);
234
+ const output = "kind" in outputOrFlags ? outputOrFlags : resolveOutput(config.output, config.siteUrl, outputOrFlags);
235
+ warnLandingDocs(dir, config);
236
+ const repoRoot = git(dir, ["rev-parse", "--show-toplevel"]) ?? dir;
237
+ const tomlFile = findProjectToml(dir);
238
+ const preset = config.preset === undefined ? undefined : await loadSitePreset(configFile, config.preset);
239
+ const theme = siteTheme(preset);
240
+ const changelogDirs = config.sections.changelog?.packages ?? (config.sections.npm?.modules ?? []).map((mod) => mod.folder.packageDir);
241
+ const authorHome = config.kind === "app";
242
+ if (output.kind === "static") await requireStaticSupport(dir, config);
243
+ if (authorHome) requireAuthorHome(dir);
244
+ const framesGlob = config.sections.media?.tui?.frames;
245
+ if (framesGlob !== undefined) checkFrames(dir, framesGlob);
246
+ const pwa = config.app?.pwa;
247
+ const media = await writeAssets(dir, {
248
+ icon: pwa === undefined ? undefined : { svgFile: path.resolve(dir, pwa.icon), background: appColors(pwa, theme.light).background },
249
+ videosDir: config.sections.media?.videos === undefined ? undefined : path.resolve(dir, config.sections.media.videos)
250
+ });
251
+ const generated: GeneratedSiteData = {
252
+ output,
253
+ release: releaseOf(env, () => git(dir, ["rev-parse", "HEAD"])),
254
+ projectToml: tomlFile === undefined ? null : readProjectToml(tomlFile),
255
+ jsdoc: collectJsdoc(config, repoRoot),
256
+ changelog: hasChangelog(config) ? readChangelogs(repoRoot, changelogDirs) : [],
257
+ media: mediaUnder(output, media)
258
+ };
259
+ const icon = await siteIcon(dir, config, theme);
260
+ const files = siteFiles({
261
+ output,
262
+ indexed: config.index,
263
+ lang: config.lang,
264
+ presetPackage: config.preset,
265
+ generated,
266
+ themeCss: themeCss(theme),
267
+ tailwindSources: tailwindSources(dir),
268
+ transpilePackages: [...FACTORY_PACKAGES, ...(config.preset === undefined ? [] : [config.preset])],
269
+ docs: hasDocs(config),
270
+ jsdoc: (config.sections.npm?.modules.length ?? 0) > 0,
271
+ changelog: hasChangelog(config),
272
+ icon: icon.svg,
273
+ favicon: icon.favicon,
274
+ app: config.app,
275
+ authorHome,
276
+ monoFallback: theme.fonts?.mono,
277
+ framesGlob,
278
+ authorNextConfig: fs.existsSync(path.join(dir, AUTHOR_NEXT_CONFIG)),
279
+ authorCss: fs.existsSync(path.join(dir, AUTHOR_CSS)),
280
+ authorEntries: fs.existsSync(path.join(dir, AUTHOR_ENTRIES)),
281
+ authorProxy: fs.existsSync(path.join(dir, AUTHOR_PROXY)),
282
+ authorChrome: fs.existsSync(path.join(dir, AUTHOR_CHROME))
283
+ });
284
+ const report = writeGeneratedFiles(dir, files, ["app"]);
285
+ generateContentCollections(dir);
286
+ warnUntrackedIgnores(
287
+ dir,
288
+ files.filter((file) => !file.path.startsWith("app/")).map((file) => file.path)
289
+ );
290
+ return { siteDir: dir, report, migrations: applied.map((step) => `${step.from} → ${step.to}: ${step.notes.join("; ")}`) };
291
+ }