@ox-content/vite-plugin 2.88.0 → 2.90.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.
package/dist/index.mjs CHANGED
@@ -8,7 +8,7 @@ import { unified } from "unified";
8
8
  import rehypeParsePlugin from "rehype-parse";
9
9
  import rehypeStringifyPlugin from "rehype-stringify";
10
10
  import { createCssVariablesTheme, createHighlighter } from "shiki";
11
- import { existsSync } from "node:fs";
11
+ import { existsSync, readFileSync } from "node:fs";
12
12
  import * as path$1 from "node:path";
13
13
  import path, { dirname, join } from "node:path";
14
14
  import * as fs from "node:fs/promises";
@@ -102,6 +102,119 @@ function createMarkdownEnvironment(options) {
102
102
  };
103
103
  }
104
104
  //#endregion
105
+ //#region src/highlight-native.ts
106
+ /**
107
+ * Extract text content from a hast node.
108
+ */
109
+ function getTextContent(node) {
110
+ let text = "";
111
+ if ("children" in node) {
112
+ for (const child of node.children) if (child.type === "text") text += child.value;
113
+ else if (child.type === "element") text += getTextContent(child);
114
+ }
115
+ return text;
116
+ }
117
+ function normalizeClassName(className) {
118
+ if (Array.isArray(className)) return className.filter((value) => typeof value === "string");
119
+ if (typeof className === "string" && className) return className.split(/\s+/).filter(Boolean);
120
+ return [];
121
+ }
122
+ /**
123
+ * Highlights with the native tree-sitter engine, or `null` when it has no
124
+ * grammar for `lang`.
125
+ *
126
+ * Parsing once and walking the tree is roughly eight times faster than
127
+ * matching TextMate patterns line by line — 10.5 ms against 81.5 ms over the
128
+ * documentation corpus's code blocks — and it emits the same
129
+ * `--octc-shiki-*` markup, so themes are unaffected.
130
+ */
131
+ function highlightNatively(code, lang) {
132
+ try {
133
+ return importNapiModuleSync().highlightCodeBlock(code, lang);
134
+ } catch {
135
+ return null;
136
+ }
137
+ }
138
+ /** Whether the native engine claims `lang`. */
139
+ function nativeSupports(lang) {
140
+ try {
141
+ return importNapiModuleSync().supportsHighlightLanguage(lang);
142
+ } catch {
143
+ return false;
144
+ }
145
+ }
146
+ /**
147
+ * Whether any block in this tree still needs Shiki.
148
+ *
149
+ * Creating a Shiki highlighter parses two dozen TextMate grammars, about
150
+ * 190 ms, so a document whose languages are all covered natively must not
151
+ * touch it at all.
152
+ */
153
+ function treeNeedsShiki(tree, nativeThemeApplies) {
154
+ if (!nativeThemeApplies) return true;
155
+ let needed = false;
156
+ const walk = (node) => {
157
+ if (needed || !("children" in node)) return;
158
+ for (const child of node.children) {
159
+ if (child.type !== "element") continue;
160
+ if (child.tagName === "code") {
161
+ const lang = languageOf(child);
162
+ if (lang !== null && !nativeSupports(lang)) {
163
+ needed = true;
164
+ return;
165
+ }
166
+ }
167
+ walk(child);
168
+ }
169
+ };
170
+ walk(tree);
171
+ return needed;
172
+ }
173
+ /** The `language-*` class on a `<code>` element, if it carries one. */
174
+ function languageOf(codeElement) {
175
+ const className = normalizeClassName(codeElement.properties?.className).find((value) => value.startsWith("language-"));
176
+ return className ? className.slice(9) : null;
177
+ }
178
+ /**
179
+ * Highlights every code block in a rendered document in one native call.
180
+ *
181
+ * Returns the rewritten HTML and the languages it declined, so the caller
182
+ * knows whether Shiki still has to run over the result. Returns `null` when
183
+ * the native module is unavailable.
184
+ *
185
+ * This exists because the plumbing dwarfed the work: walking each page
186
+ * through an HTML parser and serializer to find `<pre>` elements cost 139 ms
187
+ * over the documentation corpus, and re-parsing each highlighted block to
188
+ * splice it back cost another 38 ms, against 14 ms of actual highlighting.
189
+ *
190
+ * It runs off the main thread. The synchronous binding held the event loop
191
+ * for the whole pass, so a build asking for several pages at once still got
192
+ * them one at a time — `Promise.all` over the corpus measured the same as
193
+ * awaiting each page in turn.
194
+ */
195
+ async function highlightDocumentNatively(html) {
196
+ try {
197
+ return await importNapiModuleSync().highlightHtmlCodeBlocksAsync(html);
198
+ } catch {
199
+ return null;
200
+ }
201
+ }
202
+ /**
203
+ * Splices `replacements` back over the blocks the native pass left pending.
204
+ *
205
+ * Entry `i` is the highlighted `<pre>` for pending block `i`, or an empty
206
+ * string to leave that block alone. This keeps a page that needs one exotic
207
+ * grammar — a Vue SFC, a Mermaid diagram — off the HTML round trip, rather
208
+ * than surrendering the whole document for it.
209
+ */
210
+ function applyPendingHighlights(html, replacements) {
211
+ try {
212
+ return importNapiModuleSync().applyPendingHighlights(html, replacements);
213
+ } catch {
214
+ return html;
215
+ }
216
+ }
217
+ //#endregion
105
218
  //#region src/shiki-theme.ts
106
219
  /**
107
220
  * Name callers pass as `highlightTheme` to render syntax colors as CSS custom
@@ -226,17 +339,19 @@ function rehypeShikiHighlight(options) {
226
339
  const { theme, langs } = options;
227
340
  return async (tree) => {
228
341
  const { themeName } = normalizeThemeInput(theme);
229
- const highlighter = await getHighlighter(theme, langs);
342
+ const nativeThemeApplies = themeName === CSS_VARIABLES_THEME;
343
+ const highlighter = treeNeedsShiki(tree, nativeThemeApplies) ? await getHighlighter(theme, langs) : void 0;
230
344
  const highlightBlockCode = (codeElement) => {
231
345
  let lang = "text";
232
346
  const langClass = normalizeClassName(codeElement.properties?.className).find((value) => value.startsWith("language-"));
233
347
  if (langClass) lang = langClass.replace("language-", "");
234
348
  const codeText = getTextContent(codeElement);
235
349
  try {
236
- const highlighted = highlighter.codeToHtml(codeText, {
350
+ const highlighted = (nativeThemeApplies ? highlightNatively(codeText, lang) : null) ?? highlighter?.codeToHtml(codeText, {
237
351
  lang,
238
352
  theme: themeName
239
353
  });
354
+ if (highlighted === void 0) return null;
240
355
  const parsed = unified().use(rehypeParse$3, { fragment: true }).parse(highlighted);
241
356
  if (parsed.children[0]?.type === "element") {
242
357
  const highlightedPre = parsed.children[0];
@@ -255,10 +370,11 @@ function rehypeShikiHighlight(options) {
255
370
  lang = langClass.replace("language-", "");
256
371
  const codeText = getTextContent(codeElement);
257
372
  try {
258
- const highlighted = highlighter.codeToHtml(codeText, {
373
+ const highlighted = (nativeThemeApplies ? highlightNatively(codeText, lang) : null) ?? highlighter?.codeToHtml(codeText, {
259
374
  lang,
260
375
  theme: themeName
261
376
  });
377
+ if (highlighted === void 0) return null;
262
378
  const parsed = unified().use(rehypeParse$3, { fragment: true }).parse(highlighted);
263
379
  if (parsed.children[0]?.type === "element") {
264
380
  const highlightedCode = parsed.children[0].children.find((child) => child.type === "element" && child.tagName === "code");
@@ -282,7 +398,8 @@ function rehypeShikiHighlight(options) {
282
398
  const child = node.children[i];
283
399
  if (child.type === "element" && child.tagName === "pre") {
284
400
  const codeElement = child.children.find((c) => c.type === "element" && c.tagName === "code");
285
- if (codeElement) {
401
+ const alreadyHighlighted = normalizeClassName(child.properties?.className).includes("shiki");
402
+ if (codeElement && !alreadyHighlighted) {
286
403
  const highlightedPre = highlightBlockCode(codeElement);
287
404
  if (highlightedPre) node.children[i] = highlightedPre;
288
405
  }
@@ -296,22 +413,6 @@ function rehypeShikiHighlight(options) {
296
413
  };
297
414
  }
298
415
  /**
299
- * Extract text content from a hast node.
300
- */
301
- function getTextContent(node) {
302
- let text = "";
303
- if ("children" in node) {
304
- for (const child of node.children) if (child.type === "text") text += child.value;
305
- else if (child.type === "element") text += getTextContent(child);
306
- }
307
- return text;
308
- }
309
- function normalizeClassName(className) {
310
- if (Array.isArray(className)) return className.filter((value) => typeof value === "string");
311
- if (typeof className === "string" && className) return className.split(/\s+/).filter(Boolean);
312
- return [];
313
- }
314
- /**
315
416
  * Apply syntax highlighting to HTML using Shiki.
316
417
  */
317
418
  async function highlightCode(html, theme = CSS_VARIABLES_THEME, langs = []) {
@@ -322,6 +423,83 @@ async function highlightCode(html, theme = CSS_VARIABLES_THEME, langs = []) {
322
423
  return String(result);
323
424
  }
324
425
  //#endregion
426
+ //#region src/highlight-pending.ts
427
+ /**
428
+ * Highlighting the blocks the native pass could not claim.
429
+ *
430
+ * These arrive already named — the native pass reports each one's language —
431
+ * which lets this load exactly the grammars a page needs instead of the whole
432
+ * bundled set, and splice each result back without an HTML parser.
433
+ */
434
+ /**
435
+ * A highlighter that starts with no grammars and gains them on demand.
436
+ *
437
+ * `getHighlighter` loads all two dozen `BUILTIN_LANGS` up front, because the
438
+ * tree walk it serves discovers a page's languages only while rewriting it.
439
+ * The pending list does not have that problem — it names every language it
440
+ * needs — and paying for two dozen TextMate grammars to highlight one Vue
441
+ * block is most of what a page with an exotic block costs.
442
+ */
443
+ const lazyHighlighterCache = /* @__PURE__ */ new Map();
444
+ const loadedLangs = /* @__PURE__ */ new WeakMap();
445
+ async function getLazyHighlighter(theme, customLangs, wanted) {
446
+ const { themeInput } = normalizeThemeInput(theme);
447
+ const cacheKey = JSON.stringify({
448
+ theme: themeInput,
449
+ langs: customLangs
450
+ });
451
+ let highlighterPromise = lazyHighlighterCache.get(cacheKey);
452
+ if (!highlighterPromise) {
453
+ highlighterPromise = createHighlighter({
454
+ themes: [themeInput],
455
+ langs: customLangs
456
+ });
457
+ lazyHighlighterCache.set(cacheKey, highlighterPromise);
458
+ }
459
+ const highlighter = await highlighterPromise;
460
+ let loaded = loadedLangs.get(highlighter);
461
+ if (!loaded) {
462
+ loaded = /* @__PURE__ */ new Set();
463
+ loadedLangs.set(highlighter, loaded);
464
+ }
465
+ const missing = [...new Set(wanted.filter((lang) => !loaded.has(lang) && BUILTIN_LANGS.includes(lang)))];
466
+ if (missing.length > 0) await Promise.all(missing.map(async (lang) => {
467
+ try {
468
+ await highlighter.loadLanguage(lang);
469
+ loaded.add(lang);
470
+ } catch {}
471
+ }));
472
+ return highlighter;
473
+ }
474
+ /**
475
+ * Highlight the blocks the native pass left pending, in order.
476
+ *
477
+ * Entry `i` of the result is the `<pre>` for block `i`, or an empty string
478
+ * when Shiki has no grammar for it either — in which case the block is left
479
+ * exactly as it arrived, the same outcome the tree walk reached by keeping the
480
+ * original element.
481
+ *
482
+ * This is the whole point of the pending list: a page whose only unsupported
483
+ * block is a Mermaid diagram used to be handed to the tree walk in full, which
484
+ * re-highlighted every one of its other blocks and paid for a parse and a
485
+ * serialize of the page to do it.
486
+ */
487
+ async function highlightPendingBlocks(blocks, theme = CSS_VARIABLES_THEME, langs = []) {
488
+ if (blocks.length === 0) return [];
489
+ const { themeName } = normalizeThemeInput(theme);
490
+ const highlighter = await getLazyHighlighter(theme, langs, blocks.map((block) => block.language));
491
+ return blocks.map((block) => {
492
+ try {
493
+ return highlighter.codeToHtml(block.source, {
494
+ lang: block.language,
495
+ theme: themeName
496
+ });
497
+ } catch {
498
+ return "";
499
+ }
500
+ });
501
+ }
502
+ //#endregion
325
503
  //#region src/plugins/mermaid.ts
326
504
  /**
327
505
  * Mermaid Plugin - Native Rust renderer via NAPI
@@ -335,21 +513,21 @@ var mermaid_exports = /* @__PURE__ */ __exportAll({
335
513
  transformMermaidStatic: () => transformMermaidStatic
336
514
  });
337
515
  /** Cached NAPI bindings */
338
- let napiBindings$1 = null;
339
- let napiLoadAttempted$1 = false;
516
+ let napiBindings = null;
517
+ let napiLoadAttempted = false;
340
518
  async function loadNapi() {
341
- if (napiLoadAttempted$1) return napiBindings$1;
342
- napiLoadAttempted$1 = true;
519
+ if (napiLoadAttempted) return napiBindings;
520
+ napiLoadAttempted = true;
343
521
  try {
344
522
  const binding = await importNapiModule();
345
523
  if (typeof binding.transformMermaid !== "function") {
346
- napiBindings$1 = null;
524
+ napiBindings = null;
347
525
  return null;
348
526
  }
349
- napiBindings$1 = binding;
527
+ napiBindings = binding;
350
528
  return binding;
351
529
  } catch {
352
- napiBindings$1 = null;
530
+ napiBindings = null;
353
531
  return null;
354
532
  }
355
533
  }
@@ -1940,24 +2118,25 @@ function normalizeDiagnostic(diagnostic) {
1940
2118
  //#endregion
1941
2119
  //#region src/transform.ts
1942
2120
  /**
1943
- * Cached NAPI bindings instance.
1944
- * Loaded on first use and reused for subsequent transformations.
1945
- * @internal
1946
- */
1947
- let napiBindings;
1948
- /**
1949
- * Flag to prevent repeated NAPI loading attempts.
1950
- * Set to true after first load attempt (success or failure).
2121
+ * The NAPI load, cached as the promise rather than as its result.
2122
+ *
2123
+ * The load yields, and a caller arriving during that yield has to wait for it
2124
+ * rather than read a result that is not there yet. Holding the promise is what
2125
+ * makes every caller wait for the same load; holding an "already attempted"
2126
+ * flag beside an unset result meant the first page to arrive loaded the module
2127
+ * and every page behind it concluded there were no bindings at all.
2128
+ *
1951
2129
  * @internal
1952
2130
  */
1953
- let napiLoadAttempted = false;
2131
+ let napiLoad;
1954
2132
  /**
1955
2133
  * Lazily loads and caches NAPI bindings.
1956
2134
  *
1957
2135
  * This function uses lazy loading to defer the import of NAPI bindings
1958
2136
  * until they're actually needed. The bindings are loaded only once and
1959
- * cached for subsequent uses. If loading fails (e.g., bindings not built),
1960
- * the failure is cached to avoid repeated load attempts.
2137
+ * cached for subsequent uses, including by callers that ask for them while
2138
+ * that first load is still in flight. If loading fails (e.g., bindings not
2139
+ * built), the failure is cached to avoid repeated load attempts.
1961
2140
  *
1962
2141
  * ## Performance Considerations
1963
2142
  *
@@ -1988,18 +2167,12 @@ let napiLoadAttempted = false;
1988
2167
  *
1989
2168
  * @internal
1990
2169
  */
1991
- async function loadNapiBindings() {
1992
- if (napiLoadAttempted) return napiBindings ?? null;
1993
- napiLoadAttempted = true;
1994
- try {
1995
- const mod = await importNapiModule();
1996
- napiBindings = mod;
1997
- return mod;
1998
- } catch (error) {
2170
+ function loadNapiBindings() {
2171
+ napiLoad ??= importNapiModule().catch((error) => {
1999
2172
  if (process.env.DEBUG) console.debug("[ox-content] NAPI bindings load failed:", error);
2000
- napiBindings = null;
2001
2173
  return null;
2002
- }
2174
+ });
2175
+ return napiLoad;
2003
2176
  }
2004
2177
  async function transformMarkdown(source, filePath, options, ssgOptions) {
2005
2178
  const napi = await loadNapiBindings();
@@ -2054,9 +2227,18 @@ async function transformMarkdown(source, filePath, options, ssgOptions) {
2054
2227
  const { html: protectedHtml, svgs } = protectMermaidSvgs(html);
2055
2228
  html = protectedHtml;
2056
2229
  if (options.highlight) {
2057
- const originalHtml = html;
2058
- const highlightedHtml = await highlightCode(html, options.highlightTheme, options.highlightLangs);
2059
- html = napi.mergeHighlightedCodeBlocks(originalHtml, highlightedHtml);
2230
+ const native = options.highlightTheme === void 0 || options.highlightTheme === "css-variables" ? await highlightDocumentNatively(html) : null;
2231
+ if (native && native.skipped.length === 0) {
2232
+ html = native.html;
2233
+ if (native.pending.length > 0) {
2234
+ const replacements = await highlightPendingBlocks(native.pending, options.highlightTheme, options.highlightLangs);
2235
+ html = applyPendingHighlights(html, replacements);
2236
+ }
2237
+ } else {
2238
+ const originalHtml = html;
2239
+ const highlightedHtml = await highlightCode(html, options.highlightTheme, options.highlightLangs);
2240
+ html = napi.mergeHighlightedCodeBlocks(originalHtml, highlightedHtml);
2241
+ }
2060
2242
  }
2061
2243
  html = await transformBuiltinEmbeds(html, options.embeds ?? {
2062
2244
  github: {},
@@ -2170,6 +2352,56 @@ if (import.meta.hot) {
2170
2352
  }
2171
2353
  //#endregion
2172
2354
  //#region src/docs.ts
2355
+ /**
2356
+ * Source Documentation Extraction and Generation
2357
+ *
2358
+ * This module provides comprehensive tools for extracting JSDoc/TSDoc comments
2359
+ * from TypeScript/JavaScript source files and automatically generating Markdown
2360
+ * documentation.
2361
+ *
2362
+ * ## Features
2363
+ *
2364
+ * - **Automatic Extraction**: Parses JSDoc comments from functions, classes, interfaces, and types
2365
+ * - **Flexible Filtering**: Include/exclude patterns for selective documentation
2366
+ * - **Markdown Generation**: Converts extracted docs to organized Markdown files
2367
+ * - **Navigation Generation**: Auto-generates sidebar navigation metadata
2368
+ * - **GitHub Links**: Includes clickable links to source code on GitHub
2369
+ *
2370
+ * ## Supported JSDoc Tags
2371
+ *
2372
+ * - `@param {type} name - description` - Function parameter documentation
2373
+ * - `@returns {type} description` - Return value documentation
2374
+ * - `@example` - Code examples (multi-line blocks)
2375
+ * - `@private` - Mark item as private (excluded from docs if private=false)
2376
+ * - `@default value` - Default parameter value
2377
+ * - Custom tags are preserved in the `tags` field
2378
+ *
2379
+ * ## Usage Flow
2380
+ *
2381
+ * 1. Call `extractDocs()` to parse source files
2382
+ * 2. Call `generateMarkdown()` to create Markdown content
2383
+ * 3. Call `writeDocs()` to write files to output directory
2384
+ * 4. Generated nav.ts can be imported for sidebar navigation
2385
+ *
2386
+ * @example
2387
+ * ```typescript
2388
+ * import { extractDocs, generateMarkdown, writeDocs } from './docs';
2389
+ *
2390
+ * const docsOptions = {
2391
+ * enabled: true,
2392
+ * src: ['./src'],
2393
+ * out: './docs/api',
2394
+ * include: ['**\/*.ts'],
2395
+ * exclude: ['**\/*.test.ts'],
2396
+ * groupBy: 'file',
2397
+ * githubUrl: 'https://github.com/user/project',
2398
+ * };
2399
+ *
2400
+ * const extracted = await extractDocs(['./src'], docsOptions);
2401
+ * const markdown = generateMarkdown(extracted, docsOptions);
2402
+ * await writeDocs(markdown, './docs/api', extracted, docsOptions);
2403
+ * ```
2404
+ */
2173
2405
  const DEFAULT_DOCS_INCLUDE = [
2174
2406
  "**/*.ts",
2175
2407
  "**/*.tsx",
@@ -2310,7 +2542,7 @@ async function writeDocs(docs, outDir, extractedDocs, options) {
2310
2542
  napi.writeGeneratedDocs(docs, outDir, extractedDocs ? toRustDocsModules(extractedDocs) : void 0, {
2311
2543
  generateNav: options?.generateNav ?? false,
2312
2544
  groupBy: options?.groupBy ?? "file",
2313
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2545
+ generatedAt: existingGeneratedAt(outDir) ?? (/* @__PURE__ */ new Date()).toISOString(),
2314
2546
  basePath: options?.basePath,
2315
2547
  pathStrategy: options?.pathStrategy,
2316
2548
  groupOrder: options?.groupOrder,
@@ -2320,6 +2552,15 @@ async function writeDocs(docs, outDir, extractedDocs, options) {
2320
2552
  singleEntryRoot: options?.singleEntryRoot
2321
2553
  });
2322
2554
  }
2555
+ /** Keep `docs.json`'s timestamp stable across regenerations of the same tree. */
2556
+ function existingGeneratedAt(outDir) {
2557
+ try {
2558
+ const parsed = JSON.parse(readFileSync(path$1.join(outDir, "docs.json"), "utf8"));
2559
+ return typeof parsed.generatedAt === "string" && parsed.generatedAt.length > 0 ? parsed.generatedAt : void 0;
2560
+ } catch {
2561
+ return;
2562
+ }
2563
+ }
2323
2564
  function toRustDocsModules(docs) {
2324
2565
  return docs.map((doc) => ({
2325
2566
  file: doc.file,
@@ -2760,6 +3001,45 @@ async function resolveTemplate(options, root) {
2760
3001
  }
2761
3002
  }
2762
3003
  /**
3004
+ * Matches this package and every subpath it exports.
3005
+ *
3006
+ * A template's natural runtime is whatever renders it, and for the
3007
+ * framework-less kinds that is this package: `renderToString`, `raw`, `when`
3008
+ * and `each` live at its root, and the JSX runtime under `./jsx-runtime`.
3009
+ * Inlining them instead drags the entire plugin — chokidar, fsevents and all
3010
+ * — into the template bundle, which is what made importing it fail outright.
3011
+ */
3012
+ const OX_CONTENT_PACKAGE = /^@ox-content\/vite-plugin(\/.*)?$/;
3013
+ /**
3014
+ * Whether `id` is a bare specifier, and so resolvable at runtime rather than
3015
+ * something the template bundle has to inline.
3016
+ *
3017
+ * Template bundles are written to `<root>/.cache/og-images/` and imported
3018
+ * from there, so Node resolves anything left external against the project's
3019
+ * own `node_modules`. Relative and absolute imports still bundle, which is
3020
+ * what a template actually needs — its own components travel with it.
3021
+ */
3022
+ function isBareSpecifier(id) {
3023
+ if (id.startsWith(".") || id.startsWith("/") || id.startsWith("\0")) return false;
3024
+ return !/^[a-zA-Z]:[\\/]/.test(id);
3025
+ }
3026
+ /**
3027
+ * Rolldown input options for a `.ts` template bundle.
3028
+ *
3029
+ * A `.ts` template is the framework-less kind, so it has no single runtime to
3030
+ * externalize the way the `.vue`, `.svelte` and `.tsx` paths do — anything
3031
+ * from `node_modules` is better resolved at import time than inlined. Nothing
3032
+ * on this path has a compiler plugin, so nothing here needed bundling to be
3033
+ * loadable in the first place.
3034
+ */
3035
+ function tsTemplateBundleOptions(templatePath) {
3036
+ return {
3037
+ input: templatePath,
3038
+ platform: "node",
3039
+ external: (id) => isBareSpecifier(id)
3040
+ };
3041
+ }
3042
+ /**
2763
3043
  * Resolves a plain TypeScript template (existing behavior).
2764
3044
  */
2765
3045
  async function resolveTsTemplate(templatePath, options, root) {
@@ -2768,10 +3048,7 @@ async function resolveTsTemplate(templatePath, options, root) {
2768
3048
  const cacheDir = path$2.join(root, ".cache", "og-images");
2769
3049
  await fs.mkdir(cacheDir, { recursive: true });
2770
3050
  const outfile = path$2.join(cacheDir, "_template.mjs");
2771
- const bundle = await rolldown({
2772
- input: templatePath,
2773
- platform: "node"
2774
- });
3051
+ const bundle = await rolldown(tsTemplateBundleOptions(templatePath));
2775
3052
  await bundle.write({
2776
3053
  file: outfile,
2777
3054
  format: "esm"
@@ -2793,11 +3070,16 @@ async function resolveVueTemplate(templatePath, options, root) {
2793
3070
  const cacheDir = path$2.join(root, ".cache", "og-images");
2794
3071
  await fs.mkdir(cacheDir, { recursive: true });
2795
3072
  const outfile = path$2.join(cacheDir, "_template_vue.mjs");
3073
+ const plugins = options.vuePlugin === "vizejs" ? await getVizejsPlugin() : [createVueCompilerPlugin()];
2796
3074
  const bundle = await rolldown({
2797
3075
  input: templatePath,
2798
3076
  platform: "node",
2799
- external: ["vue", "vue/server-renderer"],
2800
- plugins: options.vuePlugin === "vizejs" ? await getVizejsPlugin() : [createVueCompilerPlugin()]
3077
+ external: [
3078
+ "vue",
3079
+ "vue/server-renderer",
3080
+ OX_CONTENT_PACKAGE
3081
+ ],
3082
+ plugins
2801
3083
  });
2802
3084
  await bundle.write({
2803
3085
  file: outfile,
@@ -2899,7 +3181,8 @@ async function resolveSvelteTemplate(templatePath, root) {
2899
3181
  "svelte",
2900
3182
  "svelte/server",
2901
3183
  "svelte/internal",
2902
- "svelte/internal/server"
3184
+ "svelte/internal/server",
3185
+ OX_CONTENT_PACKAGE
2903
3186
  ],
2904
3187
  plugins: [createSvelteCompilerPlugin()]
2905
3188
  });
@@ -2958,7 +3241,8 @@ async function resolveReactTemplate(templatePath, root) {
2958
3241
  "react/jsx-runtime",
2959
3242
  "react/jsx-dev-runtime",
2960
3243
  "react-dom",
2961
- "react-dom/server"
3244
+ "react-dom/server",
3245
+ OX_CONTENT_PACKAGE
2962
3246
  ],
2963
3247
  transform: { jsx: "react-jsx" }
2964
3248
  });
@@ -3290,311 +3574,676 @@ initIslands((el, props) => {
3290
3574
  `;
3291
3575
  }
3292
3576
  //#endregion
3293
- //#region src/ssg.ts
3294
- /**
3295
- * SSG (Static Site Generation) module for ox-content
3296
- */
3297
- /**
3298
- * Deprecated compatibility export for consumers that imported the former
3299
- * TypeScript SSG template. HTML generation is Rust-backed now.
3300
- *
3301
- * @deprecated Use `generateHtmlPage`/`buildSsg` instead.
3302
- */
3303
- const DEFAULT_HTML_TEMPLATE = "<!-- ox-content default HTML template is Rust-backed -->";
3577
+ //#region src/page-context.ts
3578
+ var page_context_exports = /* @__PURE__ */ __exportAll({
3579
+ clearRenderContext: () => clearRenderContext,
3580
+ generateFrontmatterTypes: () => generateFrontmatterTypes,
3581
+ inferType: () => inferType,
3582
+ setRenderContext: () => setRenderContext,
3583
+ useIsActive: () => useIsActive,
3584
+ useNav: () => useNav,
3585
+ usePageProps: () => usePageProps,
3586
+ useRenderContext: () => useRenderContext,
3587
+ useSiteConfig: () => useSiteConfig
3588
+ });
3304
3589
  /**
3305
- * Resolves SSG options with defaults.
3590
+ * Sets the current render context.
3591
+ * Called internally during page rendering.
3592
+ * @internal
3306
3593
  */
3307
- function resolveSsgOptions(ssg) {
3308
- if (ssg === false) return {
3309
- enabled: false,
3310
- extension: ".html",
3311
- clean: false,
3312
- bare: false,
3313
- generateOgImage: false,
3314
- lastUpdated: false
3315
- };
3316
- if (ssg === true || ssg === void 0) return {
3317
- enabled: true,
3318
- extension: ".html",
3319
- clean: false,
3320
- bare: false,
3321
- generateOgImage: false,
3322
- lastUpdated: false,
3323
- theme: resolveTheme(void 0)
3324
- };
3325
- return {
3326
- enabled: ssg.enabled ?? true,
3327
- extension: ssg.extension ?? ".html",
3328
- clean: ssg.clean ?? false,
3329
- bare: ssg.bare ?? false,
3330
- siteName: ssg.siteName,
3331
- ogImage: ssg.ogImage,
3332
- generateOgImage: ssg.generateOgImage ?? false,
3333
- lastUpdated: ssg.lastUpdated ?? false,
3334
- siteUrl: ssg.siteUrl,
3335
- theme: resolveTheme(ssg.theme),
3336
- navigation: ssg.navigation
3337
- };
3594
+ function setRenderContext(ctx) {
3595
+ currentContext = ctx;
3338
3596
  }
3339
3597
  /**
3340
- * Extracts title from content or frontmatter.
3598
+ * Clears the current render context.
3599
+ * Called internally after page rendering.
3600
+ * @internal
3341
3601
  */
3342
- function extractTitle$1(content, frontmatter) {
3343
- return importNapiModuleSync().extractSsgTitle(content, typeof frontmatter.title === "string" ? frontmatter.title : void 0);
3602
+ function clearRenderContext() {
3603
+ currentContext = null;
3344
3604
  }
3345
3605
  /**
3346
- * Generates bare HTML page (no navigation, no styles).
3606
+ * Gets the current page props.
3607
+ *
3608
+ * @returns The current page props
3609
+ * @throws Error if called outside of a render context
3610
+ *
3611
+ * @example
3612
+ * ```tsx
3613
+ * function PageTitle() {
3614
+ * const page = usePageProps();
3615
+ * return <h1>{page.title}</h1>;
3616
+ * }
3617
+ * ```
3347
3618
  */
3348
- function generateBareHtmlPage(content, title) {
3349
- return importNapiModuleSync().generateSsgBareHtml(content, title);
3619
+ function usePageProps() {
3620
+ if (!currentContext) throw new Error("[ox-content] usePageProps() must be called during page rendering. Make sure you are using it inside a theme component.");
3621
+ return currentContext.page;
3350
3622
  }
3351
3623
  /**
3352
- * Per-build cache for the Rust-facing nav conversion. `navGroups` is the same
3353
- * `context.navItems` reference for every page in a build, so the deep recursive
3354
- * copy below only needs to run once per build instead of once per page.
3355
- */
3356
- const navGroupsForRustCache = /* @__PURE__ */ new WeakMap();
3357
- function toRustNavItem(item) {
3358
- return {
3359
- title: item.title,
3360
- path: item.path,
3361
- href: item.href,
3362
- children: item.children?.map(toRustNavItem),
3363
- collapsed: item.collapsed,
3364
- stickyCollapsed: item.stickyCollapsed
3365
- };
3366
- }
3367
- function convertNavGroupsForRust(navGroups) {
3368
- const cached = navGroupsForRustCache.get(navGroups);
3369
- if (cached) return cached;
3370
- const converted = navGroups.map((group) => ({
3371
- title: group.title,
3372
- collapsed: group.collapsed,
3373
- stickyCollapsed: group.stickyCollapsed,
3374
- items: group.items.map(toRustNavItem)
3375
- }));
3376
- navGroupsForRustCache.set(navGroups, converted);
3377
- return converted;
3624
+ * Gets the site configuration.
3625
+ *
3626
+ * @returns The site configuration
3627
+ * @throws Error if called outside of a render context
3628
+ *
3629
+ * @example
3630
+ * ```tsx
3631
+ * function SiteHeader() {
3632
+ * const site = useSiteConfig();
3633
+ * return <header>{site.name}</header>;
3634
+ * }
3635
+ * ```
3636
+ */
3637
+ function useSiteConfig() {
3638
+ if (!currentContext) throw new Error("[ox-content] useSiteConfig() must be called during page rendering. Make sure you are using it inside a theme component.");
3639
+ return currentContext.site;
3378
3640
  }
3379
3641
  /**
3380
- * Converts a `TocEntry` tree into the plain shape the Rust binding expects.
3381
- * Hoisted to module scope so it isn't reallocated for every page; the
3382
- * per-page `.map` over `pageData.toc` still runs since the TOC is page-specific.
3642
+ * Gets the full render context.
3643
+ *
3644
+ * @returns The complete render context
3645
+ * @throws Error if called outside of a render context
3646
+ *
3647
+ * @example
3648
+ * ```tsx
3649
+ * function Layout({ children }) {
3650
+ * const ctx = useRenderContext();
3651
+ * return (
3652
+ * <html>
3653
+ * <head><title>{ctx.page.title} - {ctx.site.name}</title></head>
3654
+ * <body>{children}</body>
3655
+ * </html>
3656
+ * );
3657
+ * }
3658
+ * ```
3383
3659
  */
3384
- function toRustTocEntry(entry) {
3385
- return {
3386
- depth: entry.depth,
3387
- text: entry.text,
3388
- slug: entry.slug,
3389
- children: entry.children?.map(toRustTocEntry) ?? []
3390
- };
3660
+ function useRenderContext() {
3661
+ if (!currentContext) throw new Error("[ox-content] useRenderContext() must be called during page rendering. Make sure you are using it inside a theme component.");
3662
+ return currentContext;
3391
3663
  }
3392
3664
  /**
3393
- * Per-build cache for the Rust-facing locale list. `i18n.locales` is the same
3394
- * reference for every page in a build, so this mapping (and the `?? "ltr"`
3395
- * default) only runs once per build instead of once per page.
3665
+ * Gets the navigation groups.
3666
+ *
3667
+ * @example
3668
+ * ```tsx
3669
+ * function Sidebar() {
3670
+ * const nav = useNav();
3671
+ * return (
3672
+ * <nav>
3673
+ * {each(nav, (group) => (
3674
+ * <div>
3675
+ * <h3>{group.title}</h3>
3676
+ * <ul>
3677
+ * {each(group.items, (item) => (
3678
+ * <li><a href={item.href}>{item.title}</a></li>
3679
+ * ))}
3680
+ * </ul>
3681
+ * </div>
3682
+ * ))}
3683
+ * </nav>
3684
+ * );
3685
+ * }
3686
+ * ```
3396
3687
  */
3397
- const rustLocalesCache = /* @__PURE__ */ new WeakMap();
3398
- function toRustLocales(locales) {
3399
- const cached = rustLocalesCache.get(locales);
3400
- if (cached) return cached;
3401
- const converted = locales.map((locale) => ({
3402
- code: locale.code,
3403
- name: locale.name,
3404
- dir: locale.dir ?? "ltr"
3405
- }));
3406
- rustLocalesCache.set(locales, converted);
3407
- return converted;
3688
+ function useNav() {
3689
+ return useSiteConfig().nav;
3408
3690
  }
3409
3691
  /**
3410
- * Per-build cache for the locale-code list passed to `getSsgPageLocale`. The
3411
- * `i18n.locales` reference is stable across a build, so the `.map` to codes
3412
- * runs once instead of once per page.
3692
+ * Checks if the given path is the current page.
3693
+ *
3694
+ * @example
3695
+ * ```tsx
3696
+ * function NavLink({ href, children }) {
3697
+ * const isActive = useIsActive(href);
3698
+ * return <a href={href} class={isActive ? 'active' : ''}>{children}</a>;
3699
+ * }
3700
+ * ```
3413
3701
  */
3414
- const localeCodesCache = /* @__PURE__ */ new WeakMap();
3415
- function localeCodesFor(locales) {
3416
- const cached = localeCodesCache.get(locales);
3417
- if (cached) return cached;
3418
- const codes = locales.map((locale) => locale.code);
3419
- localeCodesCache.set(locales, codes);
3420
- return codes;
3702
+ function useIsActive(path) {
3703
+ const page = usePageProps();
3704
+ return page.path === path || page.url === path;
3421
3705
  }
3422
3706
  /**
3423
- * Generates HTML page with navigation using Rust NAPI bindings.
3707
+ * Infers TypeScript types from frontmatter values.
3424
3708
  */
3425
- async function generateHtmlPage(pageData, navGroups, siteName, base, ogImage, theme, locale, availableLocales) {
3426
- const mod = await importNapiModule();
3427
- const tocForRust = pageData.toc.map(toRustTocEntry);
3428
- const navGroupsForRust = convertNavGroupsForRust(navGroups);
3429
- const themeForRust = theme ? themeToNapi(theme) : void 0;
3430
- const entryPageForRust = pageData.entryPage ? {
3431
- hero: pageData.entryPage.hero ? {
3432
- name: pageData.entryPage.hero.name,
3433
- text: pageData.entryPage.hero.text,
3434
- tagline: pageData.entryPage.hero.tagline,
3435
- notice: pageData.entryPage.hero.notice ? {
3436
- title: pageData.entryPage.hero.notice.title,
3437
- body: pageData.entryPage.hero.notice.body
3438
- } : void 0,
3439
- image: pageData.entryPage.hero.image ? {
3440
- src: pageData.entryPage.hero.image.src,
3441
- lightSrc: pageData.entryPage.hero.image.lightSrc,
3442
- darkSrc: pageData.entryPage.hero.image.darkSrc,
3443
- alt: pageData.entryPage.hero.image.alt,
3444
- width: pageData.entryPage.hero.image.width,
3445
- height: pageData.entryPage.hero.image.height
3446
- } : void 0,
3447
- actions: pageData.entryPage.hero.actions?.map((a) => ({
3448
- theme: a.theme,
3449
- text: a.text,
3450
- link: a.link
3451
- }))
3452
- } : void 0,
3453
- features: pageData.entryPage.features?.map((f) => ({
3454
- icon: f.icon,
3455
- title: f.title,
3456
- details: f.details,
3457
- link: f.link,
3458
- linkText: f.linkText
3459
- }))
3460
- } : void 0;
3461
- return mod.generateSsgHtml({
3462
- title: pageData.title,
3463
- description: pageData.description,
3464
- content: pageData.content,
3465
- toc: tocForRust,
3466
- lastUpdated: pageData.lastUpdated,
3467
- path: pageData.path,
3468
- entryPage: entryPageForRust
3469
- }, navGroupsForRust, {
3470
- siteName,
3471
- base,
3472
- ogImage,
3473
- theme: themeForRust,
3474
- locale,
3475
- availableLocales: availableLocales ? toRustLocales(availableLocales) : void 0
3476
- });
3477
- }
3478
- async function externalizeSharedPageAssets(pages, outDir, base) {
3479
- const optimized = (await importNapiModule()).externalizeSsgAssets(pages, outDir, base);
3480
- await Promise.all(optimized.assets.map(async (asset) => {
3481
- await fs$2.mkdir(path$2.dirname(asset.outputPath), { recursive: true });
3482
- await fs$2.writeFile(asset.outputPath, asset.content, "utf-8");
3483
- }));
3484
- return {
3485
- pages: optimized.pages,
3486
- assets: optimized.assets.map((asset) => asset.outputPath)
3487
- };
3709
+ function inferType(value) {
3710
+ if (value === null) return "null";
3711
+ if (value === void 0) return "undefined";
3712
+ if (typeof value === "string") return "string";
3713
+ if (typeof value === "number") return "number";
3714
+ if (typeof value === "boolean") return "boolean";
3715
+ if (Array.isArray(value)) {
3716
+ if (value.length === 0) return "unknown[]";
3717
+ const itemTypes = [...new Set(value.map(inferType))];
3718
+ if (itemTypes.length === 1) return `${itemTypes[0]}[]`;
3719
+ return `(${itemTypes.join(" | ")})[]`;
3720
+ }
3721
+ if (typeof value === "object") {
3722
+ const entries = Object.entries(value);
3723
+ if (entries.length === 0) return "Record<string, unknown>";
3724
+ return `{ ${entries.map(([k, v]) => `${k}: ${inferType(v)}`).join("; ")} }`;
3725
+ }
3726
+ return "unknown";
3488
3727
  }
3489
3728
  /**
3490
- * Converts a markdown file path to a relative URL path.
3729
+ * Generates TypeScript interface from frontmatter samples.
3491
3730
  */
3492
- function getUrlPath$1(inputPath, srcDir) {
3493
- return importNapiModuleSync().getSsgUrlPath(inputPath, srcDir);
3731
+ function generateFrontmatterTypes(samples, interfaceName = "PageFrontmatter") {
3732
+ const fields = /* @__PURE__ */ new Map();
3733
+ for (const sample of samples) for (const [key, value] of Object.entries(sample)) {
3734
+ const existing = fields.get(key) ?? {
3735
+ types: /* @__PURE__ */ new Set(),
3736
+ count: 0
3737
+ };
3738
+ existing.types.add(inferType(value));
3739
+ existing.count++;
3740
+ fields.set(key, existing);
3741
+ }
3742
+ const lines = [
3743
+ "/**",
3744
+ " * Auto-generated frontmatter type based on your pages.",
3745
+ " * DO NOT EDIT - this file is generated by ox-content.",
3746
+ " */",
3747
+ "",
3748
+ `export interface ${interfaceName} {`
3749
+ ];
3750
+ for (const [name, { types, count }] of fields) {
3751
+ const isOptional = count < samples.length;
3752
+ const typeStr = [...types].join(" | ");
3753
+ const optionalMark = isOptional ? "?" : "";
3754
+ lines.push(` ${name}${optionalMark}: ${typeStr};`);
3755
+ }
3756
+ lines.push("}");
3757
+ lines.push("");
3758
+ lines.push(`export type PageProps = import('@ox-content/vite-plugin').PageProps<${interfaceName}>;`);
3759
+ lines.push("");
3760
+ return lines.join("\n");
3494
3761
  }
3762
+ var currentContext;
3763
+ var init_page_context = __esmMin((() => {
3764
+ currentContext = null;
3765
+ }));
3766
+ //#endregion
3767
+ //#region src/theme-renderer.ts
3495
3768
  /**
3496
- * Resolves manual navigation config to the format used by the built-in SSG renderer.
3769
+ * Theme Renderer for Static HTML Generation
3770
+ *
3771
+ * Renders JSX theme components to static HTML strings.
3772
+ * No client-side JavaScript is included by default.
3497
3773
  */
3498
- function resolveNavigationGroups(navigation, base, extension) {
3499
- if (!navigation) return;
3500
- return importNapiModuleSync().resolveSsgNavigationGroups(navigation, base, extension);
3501
- }
3502
- function getPageLocale(urlPath, i18n) {
3503
- if (!i18n) return void 0;
3504
- return importNapiModuleSync().getSsgPageLocale(urlPath, i18n.defaultLocale, localeCodesFor(i18n.locales)) ?? void 0;
3505
- }
3506
- function getRoutePaths(inputPath, srcDir, outDir, base, extension, siteUrl) {
3507
- return importNapiModuleSync().resolveSsgRoutePaths(inputPath, srcDir, outDir, base, extension, siteUrl);
3774
+ init_page_context();
3775
+ /**
3776
+ * Renders a page using the theme component.
3777
+ *
3778
+ * @param page - Page data to render
3779
+ * @param options - Theme render options
3780
+ * @returns Rendered HTML string
3781
+ */
3782
+ function renderPage(page, options) {
3783
+ const { theme, siteName, base, nav, pages } = options;
3784
+ setRenderContext({
3785
+ page: {
3786
+ title: page.title,
3787
+ description: page.description,
3788
+ html: page.html,
3789
+ toc: page.toc,
3790
+ lastUpdated: page.lastUpdated,
3791
+ path: page.path,
3792
+ url: page.url,
3793
+ frontmatter: page.frontmatter,
3794
+ layout: page.layout
3795
+ },
3796
+ site: {
3797
+ name: siteName,
3798
+ base,
3799
+ nav,
3800
+ pages: pages.map((p) => ({
3801
+ title: p.title,
3802
+ description: p.description,
3803
+ html: p.html,
3804
+ toc: p.toc,
3805
+ lastUpdated: p.lastUpdated,
3806
+ path: p.path,
3807
+ url: p.url,
3808
+ frontmatter: p.frontmatter,
3809
+ layout: p.layout
3810
+ }))
3811
+ }
3812
+ });
3813
+ try {
3814
+ const result = theme({ children: raw(page.html) });
3815
+ const html = renderToString(result);
3816
+ if (!html.trimStart().toLowerCase().startsWith("<!doctype")) return `<!DOCTYPE html>\n${html}`;
3817
+ return html;
3818
+ } finally {
3819
+ clearRenderContext();
3820
+ }
3508
3821
  }
3509
3822
  /**
3510
- * Formats a file/dir name as a title.
3823
+ * Renders all pages and generates type definitions.
3824
+ *
3825
+ * @param pages - All pages to render
3826
+ * @param options - Theme render options
3827
+ * @returns Map of output paths to rendered HTML
3511
3828
  */
3512
- function formatTitle(name) {
3513
- return importNapiModuleSync().formatSsgTitle(name);
3829
+ async function renderAllPages(pages, options) {
3830
+ const results = /* @__PURE__ */ new Map();
3831
+ for (const page of pages) {
3832
+ const html = renderPage(page, {
3833
+ ...options,
3834
+ pages
3835
+ });
3836
+ results.set(page.url, html);
3837
+ }
3838
+ if (options.typesOutDir) await generateTypes(pages, options.typesOutDir);
3839
+ return results;
3514
3840
  }
3515
3841
  /**
3516
- * Collects all markdown files from the source directory.
3842
+ * Generates TypeScript type definitions from page frontmatter.
3843
+ *
3844
+ * @param pages - All pages
3845
+ * @param outDir - Output directory for types
3517
3846
  */
3518
- async function collectMarkdownFiles(srcDir, extensions = DEFAULT_MARKDOWN_EXTENSIONS) {
3519
- return importNapiModuleSync().collectSsgMarkdownFiles(srcDir, [...extensions]);
3847
+ async function generateTypes(pages, outDir) {
3848
+ const types = generateFrontmatterTypes(pages.map((p) => p.frontmatter));
3849
+ const typesPath = join(outDir, "page-props.d.ts");
3850
+ await mkdir(dirname(typesPath), { recursive: true });
3851
+ await writeFile(typesPath, types, "utf-8");
3520
3852
  }
3521
3853
  /**
3522
- * Builds navigation items from markdown files, grouped by directory.
3854
+ * Default theme component.
3855
+ * A minimal theme that renders page content with basic styling.
3523
3856
  */
3524
- function buildNavItems(markdownFiles, srcDir, base, extension) {
3525
- return importNapiModuleSync().buildSsgNavItems(markdownFiles, srcDir, base, extension);
3857
+ function DefaultTheme({ children }) {
3858
+ const { usePageProps, useSiteConfig } = (init_page_context(), __toCommonJS(page_context_exports));
3859
+ const page = usePageProps();
3860
+ const site = useSiteConfig();
3861
+ return { __html: `<!DOCTYPE html>
3862
+ <html lang="en">
3863
+ <head>
3864
+ <meta charset="UTF-8">
3865
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
3866
+ <title>${escapeHtml(page.title)} - ${escapeHtml(site.name)}</title>
3867
+ ${page.description ? `<meta name="description" content="${escapeHtml(page.description)}">` : ""}
3868
+ <style>
3869
+ :root {
3870
+ --octc-color-primary: #4f6fae;
3871
+ --octc-color-text: #131a30;
3872
+ --octc-color-bg: #ffffff;
3873
+ --octc-color-bg-alt: #f5f7fb;
3874
+ --octc-color-text-muted: #4f607b;
3875
+ --octc-color-border: #d2dbea;
3876
+ }
3877
+ body {
3878
+ font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI Variable", "Segoe UI", sans-serif;
3879
+ line-height: 1.7;
3880
+ color: var(--octc-color-text);
3881
+ background: var(--octc-color-bg);
3882
+ max-width: 800px;
3883
+ margin: 0 auto;
3884
+ padding: 2rem;
3885
+ }
3886
+ a { color: var(--octc-color-primary); }
3887
+ </style>
3888
+ </head>
3889
+ <body>
3890
+ <header>
3891
+ <h1>${escapeHtml(site.name)}</h1>
3892
+ </header>
3893
+ <main>
3894
+ ${children.__html}
3895
+ </main>
3896
+ </body>
3897
+ </html>` };
3898
+ }
3899
+ function escapeHtml(str) {
3900
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
3526
3901
  }
3527
3902
  /**
3528
- * Builds navigation items from an explicit theme sidebar tree.
3903
+ * Creates a theme with layout switching support.
3904
+ *
3905
+ * @example
3906
+ * ```tsx
3907
+ * import { createTheme } from '@ox-content/vite-plugin';
3908
+ * import { DefaultLayout } from './layouts/Default';
3909
+ * import { EntryLayout } from './layouts/Entry';
3910
+ *
3911
+ * export default createTheme({
3912
+ * layouts: {
3913
+ * default: DefaultLayout,
3914
+ * entry: EntryLayout,
3915
+ * },
3916
+ * });
3917
+ * ```
3529
3918
  */
3530
- function buildThemeNavItems(sidebar, base, extension) {
3531
- return importNapiModuleSync().buildSsgThemeNavItems(sidebar, base, extension);
3919
+ function createTheme(config) {
3920
+ const { layouts, defaultLayout = "default" } = config;
3921
+ return function ThemeWithLayouts({ children }) {
3922
+ const layoutName = usePageProps().layout ?? defaultLayout;
3923
+ const Layout = layouts[layoutName] ?? layouts[defaultLayout];
3924
+ if (!Layout) throw new Error(`[ox-content] Layout "${layoutName}" not found. Available layouts: ${Object.keys(layouts).join(", ")}`);
3925
+ return Layout({ children });
3926
+ };
3532
3927
  }
3928
+ //#endregion
3929
+ //#region src/ssg.ts
3533
3930
  /**
3534
- * Builds all markdown files to static HTML.
3931
+ * SSG (Static Site Generation) module for ox-content
3535
3932
  */
3536
- async function buildSsg(options, root) {
3537
- const ssgOptions = options.ssg;
3538
- if (!ssgOptions.enabled) return {
3539
- files: [],
3540
- errors: []
3933
+ /**
3934
+ * Deprecated compatibility export for consumers that imported the former
3935
+ * TypeScript SSG template. HTML generation is Rust-backed now.
3936
+ *
3937
+ * @deprecated Use `generateHtmlPage`/`buildSsg` instead.
3938
+ */
3939
+ const DEFAULT_HTML_TEMPLATE = "<!-- ox-content default HTML template is Rust-backed -->";
3940
+ /**
3941
+ * Resolves SSG options with defaults.
3942
+ */
3943
+ function resolveSsgOptions(ssg) {
3944
+ if (ssg === false) return {
3945
+ enabled: false,
3946
+ extension: ".html",
3947
+ clean: false,
3948
+ bare: false,
3949
+ generateOgImage: false,
3950
+ lastUpdated: false
3541
3951
  };
3542
- const srcDir = path$2.resolve(root, options.srcDir);
3543
- const outDir = path$2.resolve(root, options.outDir);
3544
- const generatedFiles = [];
3545
- const errors = [];
3546
- await cleanOutputDirectory(ssgOptions, outDir);
3547
- const markdownFiles = await collectMarkdownFiles(srcDir, options.extensions);
3548
- const context = await createBuildSsgContext(options, root, srcDir, outDir, markdownFiles);
3549
- const collected = await collectPageResults(context, markdownFiles);
3550
- errors.push(...collected.errors);
3551
- await generateOgImageAssets(context, collected, generatedFiles, errors);
3552
- await writeGeneratedPages(await generateHtmlPages(context, collected.pageResults, collected, errors), context, generatedFiles);
3553
- return {
3554
- files: generatedFiles,
3555
- errors
3952
+ if (ssg === true || ssg === void 0) return {
3953
+ enabled: true,
3954
+ extension: ".html",
3955
+ clean: false,
3956
+ bare: false,
3957
+ generateOgImage: false,
3958
+ lastUpdated: false,
3959
+ theme: resolveTheme(void 0)
3556
3960
  };
3557
- }
3558
- async function cleanOutputDirectory(ssgOptions, outDir) {
3559
- if (!ssgOptions.clean) return;
3560
- try {
3561
- await fs$2.rm(outDir, {
3562
- recursive: true,
3563
- force: true
3564
- });
3565
- } catch {}
3566
- }
3567
- async function createBuildSsgContext(options, root, srcDir, outDir, markdownFiles) {
3568
- const ssgOptions = options.ssg;
3569
- const base = options.base.endsWith("/") ? options.base : options.base + "/";
3570
3961
  return {
3571
- options,
3572
- ssgOptions,
3573
- root,
3574
- srcDir,
3575
- outDir,
3576
- base,
3577
- navItems: resolveNavigationGroups(ssgOptions.navigation, base, ssgOptions.extension) ?? (ssgOptions.theme?.sidebar.length ? buildThemeNavItems(ssgOptions.theme.sidebar, base, ssgOptions.extension) : buildNavItems(markdownFiles, srcDir, base, ssgOptions.extension)),
3578
- siteName: await resolveSiteName$1(root, ssgOptions),
3579
- shouldGenerateOgImages: shouldGenerateOgImages(options),
3580
- napi: ssgOptions.lastUpdated ? await importNapiModule() : void 0
3962
+ enabled: ssg.enabled ?? true,
3963
+ extension: ssg.extension ?? ".html",
3964
+ clean: ssg.clean ?? false,
3965
+ bare: ssg.bare ?? false,
3966
+ render: ssg.render,
3967
+ lang: ssg.lang,
3968
+ head: ssg.head,
3969
+ bodyStart: ssg.bodyStart,
3970
+ bodyEnd: ssg.bodyEnd,
3971
+ siteName: ssg.siteName,
3972
+ ogImage: ssg.ogImage,
3973
+ generateOgImage: ssg.generateOgImage ?? false,
3974
+ lastUpdated: ssg.lastUpdated ?? false,
3975
+ siteUrl: ssg.siteUrl,
3976
+ theme: resolveTheme(ssg.theme),
3977
+ navigation: ssg.navigation
3581
3978
  };
3582
3979
  }
3583
3980
  /**
3584
- * Whether this build emits one Open Graph image per page.
3981
+ * Extracts title from content or frontmatter.
3982
+ */
3983
+ function extractTitle$1(content, frontmatter) {
3984
+ return importNapiModuleSync().extractSsgTitle(content, typeof frontmatter.title === "string" ? frontmatter.title : void 0);
3985
+ }
3986
+ /**
3987
+ * Generates a bare HTML page carrying head metadata and injected markup.
3585
3988
  *
3586
- * `ssg.bare` deliberately does not turn this off. Bare mode only drops the
3587
- * generated page shell, and bringing your own shell is exactly the case where
3588
- * per-page OG images are still wanted the images are written to the output
3589
- * tree and the consumer injects the `<meta>` tags itself. Nothing in the bare
3590
- * HTML references them, because bare output has no `<head>` to put them in.
3989
+ * Bare mode leaves the shell to the consumer, but the metadata here is
3990
+ * already computed for the themed page and cannot be recovered afterwards
3991
+ * the generated OG image in particular was only discoverable by guessing at
3992
+ * the output directory. A page with none of it set renders exactly what bare
3993
+ * mode emitted before, which keeps the no-JS size baseline honest.
3591
3994
  */
3592
- function shouldGenerateOgImages(options) {
3593
- return options.ogImage || options.ssg.generateOgImage;
3995
+ function generateBarePage(page) {
3996
+ return importNapiModuleSync().generateSsgBarePage(page);
3594
3997
  }
3595
- async function resolveSiteName$1(root, ssgOptions) {
3596
- if (ssgOptions.siteName) return ssgOptions.siteName;
3597
- try {
3998
+ /**
3999
+ * Per-build cache for the Rust-facing nav conversion. `navGroups` is the same
4000
+ * `context.navItems` reference for every page in a build, so the deep recursive
4001
+ * copy below only needs to run once per build instead of once per page.
4002
+ */
4003
+ const navGroupsForRustCache = /* @__PURE__ */ new WeakMap();
4004
+ function toRustNavItem(item) {
4005
+ return {
4006
+ title: item.title,
4007
+ path: item.path,
4008
+ href: item.href,
4009
+ children: item.children?.map(toRustNavItem),
4010
+ collapsed: item.collapsed,
4011
+ stickyCollapsed: item.stickyCollapsed
4012
+ };
4013
+ }
4014
+ function convertNavGroupsForRust(navGroups) {
4015
+ const cached = navGroupsForRustCache.get(navGroups);
4016
+ if (cached) return cached;
4017
+ const converted = navGroups.map((group) => ({
4018
+ title: group.title,
4019
+ collapsed: group.collapsed,
4020
+ stickyCollapsed: group.stickyCollapsed,
4021
+ items: group.items.map(toRustNavItem)
4022
+ }));
4023
+ navGroupsForRustCache.set(navGroups, converted);
4024
+ return converted;
4025
+ }
4026
+ /**
4027
+ * Converts a `TocEntry` tree into the plain shape the Rust binding expects.
4028
+ * Hoisted to module scope so it isn't reallocated for every page; the
4029
+ * per-page `.map` over `pageData.toc` still runs since the TOC is page-specific.
4030
+ */
4031
+ function toRustTocEntry(entry) {
4032
+ return {
4033
+ depth: entry.depth,
4034
+ text: entry.text,
4035
+ slug: entry.slug,
4036
+ children: entry.children?.map(toRustTocEntry) ?? []
4037
+ };
4038
+ }
4039
+ /**
4040
+ * Per-build cache for the Rust-facing locale list. `i18n.locales` is the same
4041
+ * reference for every page in a build, so this mapping (and the `?? "ltr"`
4042
+ * default) only runs once per build instead of once per page.
4043
+ */
4044
+ const rustLocalesCache = /* @__PURE__ */ new WeakMap();
4045
+ function toRustLocales(locales) {
4046
+ const cached = rustLocalesCache.get(locales);
4047
+ if (cached) return cached;
4048
+ const converted = locales.map((locale) => ({
4049
+ code: locale.code,
4050
+ name: locale.name,
4051
+ dir: locale.dir ?? "ltr"
4052
+ }));
4053
+ rustLocalesCache.set(locales, converted);
4054
+ return converted;
4055
+ }
4056
+ /**
4057
+ * Per-build cache for the locale-code list passed to `getSsgPageLocale`. The
4058
+ * `i18n.locales` reference is stable across a build, so the `.map` to codes
4059
+ * runs once instead of once per page.
4060
+ */
4061
+ const localeCodesCache = /* @__PURE__ */ new WeakMap();
4062
+ function localeCodesFor(locales) {
4063
+ const cached = localeCodesCache.get(locales);
4064
+ if (cached) return cached;
4065
+ const codes = locales.map((locale) => locale.code);
4066
+ localeCodesCache.set(locales, codes);
4067
+ return codes;
4068
+ }
4069
+ /**
4070
+ * Generates HTML page with navigation using Rust NAPI bindings.
4071
+ */
4072
+ async function generateHtmlPage(pageData, navGroups, siteName, base, ogImage, theme, locale, availableLocales) {
4073
+ const mod = await importNapiModule();
4074
+ const tocForRust = pageData.toc.map(toRustTocEntry);
4075
+ const navGroupsForRust = convertNavGroupsForRust(navGroups);
4076
+ const themeForRust = theme ? themeToNapi(theme) : void 0;
4077
+ const entryPageForRust = pageData.entryPage ? {
4078
+ hero: pageData.entryPage.hero ? {
4079
+ name: pageData.entryPage.hero.name,
4080
+ text: pageData.entryPage.hero.text,
4081
+ tagline: pageData.entryPage.hero.tagline,
4082
+ notice: pageData.entryPage.hero.notice ? {
4083
+ title: pageData.entryPage.hero.notice.title,
4084
+ body: pageData.entryPage.hero.notice.body
4085
+ } : void 0,
4086
+ image: pageData.entryPage.hero.image ? {
4087
+ src: pageData.entryPage.hero.image.src,
4088
+ lightSrc: pageData.entryPage.hero.image.lightSrc,
4089
+ darkSrc: pageData.entryPage.hero.image.darkSrc,
4090
+ alt: pageData.entryPage.hero.image.alt,
4091
+ width: pageData.entryPage.hero.image.width,
4092
+ height: pageData.entryPage.hero.image.height
4093
+ } : void 0,
4094
+ actions: pageData.entryPage.hero.actions?.map((a) => ({
4095
+ theme: a.theme,
4096
+ text: a.text,
4097
+ link: a.link
4098
+ }))
4099
+ } : void 0,
4100
+ features: pageData.entryPage.features?.map((f) => ({
4101
+ icon: f.icon,
4102
+ title: f.title,
4103
+ details: f.details,
4104
+ link: f.link,
4105
+ linkText: f.linkText
4106
+ }))
4107
+ } : void 0;
4108
+ return mod.generateSsgHtml({
4109
+ title: pageData.title,
4110
+ description: pageData.description,
4111
+ content: pageData.content,
4112
+ toc: tocForRust,
4113
+ lastUpdated: pageData.lastUpdated,
4114
+ path: pageData.path,
4115
+ entryPage: entryPageForRust
4116
+ }, navGroupsForRust, {
4117
+ siteName,
4118
+ base,
4119
+ ogImage,
4120
+ theme: themeForRust,
4121
+ locale,
4122
+ availableLocales: availableLocales ? toRustLocales(availableLocales) : void 0
4123
+ });
4124
+ }
4125
+ async function externalizeSharedPageAssets(pages, outDir, base) {
4126
+ const optimized = (await importNapiModule()).externalizeSsgAssets(pages, outDir, base);
4127
+ await Promise.all(optimized.assets.map(async (asset) => {
4128
+ await fs$2.mkdir(path$2.dirname(asset.outputPath), { recursive: true });
4129
+ await fs$2.writeFile(asset.outputPath, asset.content, "utf-8");
4130
+ }));
4131
+ return {
4132
+ pages: optimized.pages,
4133
+ assets: optimized.assets.map((asset) => asset.outputPath)
4134
+ };
4135
+ }
4136
+ /**
4137
+ * Converts a markdown file path to a relative URL path.
4138
+ */
4139
+ function getUrlPath$1(inputPath, srcDir) {
4140
+ return importNapiModuleSync().getSsgUrlPath(inputPath, srcDir);
4141
+ }
4142
+ /**
4143
+ * Resolves manual navigation config to the format used by the built-in SSG renderer.
4144
+ */
4145
+ function resolveNavigationGroups(navigation, base, extension) {
4146
+ if (!navigation) return;
4147
+ return importNapiModuleSync().resolveSsgNavigationGroups(navigation, base, extension);
4148
+ }
4149
+ function getPageLocale(urlPath, i18n) {
4150
+ if (!i18n) return void 0;
4151
+ return importNapiModuleSync().getSsgPageLocale(urlPath, i18n.defaultLocale, localeCodesFor(i18n.locales)) ?? void 0;
4152
+ }
4153
+ function getRoutePaths(inputPath, srcDir, outDir, base, extension, siteUrl) {
4154
+ return importNapiModuleSync().resolveSsgRoutePaths(inputPath, srcDir, outDir, base, extension, siteUrl);
4155
+ }
4156
+ /**
4157
+ * Formats a file/dir name as a title.
4158
+ */
4159
+ function formatTitle(name) {
4160
+ return importNapiModuleSync().formatSsgTitle(name);
4161
+ }
4162
+ /**
4163
+ * Collects all markdown files from the source directory.
4164
+ */
4165
+ async function collectMarkdownFiles(srcDir, extensions = DEFAULT_MARKDOWN_EXTENSIONS) {
4166
+ return importNapiModuleSync().collectSsgMarkdownFiles(srcDir, [...extensions]);
4167
+ }
4168
+ /**
4169
+ * Builds navigation items from markdown files, grouped by directory.
4170
+ */
4171
+ function buildNavItems(markdownFiles, srcDir, base, extension) {
4172
+ return importNapiModuleSync().buildSsgNavItems(markdownFiles, srcDir, base, extension);
4173
+ }
4174
+ /**
4175
+ * Builds navigation items from an explicit theme sidebar tree.
4176
+ */
4177
+ function buildThemeNavItems(sidebar, base, extension) {
4178
+ return importNapiModuleSync().buildSsgThemeNavItems(sidebar, base, extension);
4179
+ }
4180
+ /**
4181
+ * Builds all markdown files to static HTML.
4182
+ */
4183
+ async function buildSsg(options, root) {
4184
+ const ssgOptions = options.ssg;
4185
+ if (!ssgOptions.enabled) return {
4186
+ files: [],
4187
+ errors: [],
4188
+ ogImages: {}
4189
+ };
4190
+ const srcDir = path$2.resolve(root, options.srcDir);
4191
+ const outDir = path$2.resolve(root, options.outDir);
4192
+ const generatedFiles = [];
4193
+ const errors = [];
4194
+ await cleanOutputDirectory(ssgOptions, outDir);
4195
+ const markdownFiles = await collectMarkdownFiles(srcDir, options.extensions);
4196
+ const context = await createBuildSsgContext(options, root, srcDir, outDir, markdownFiles);
4197
+ const collected = await collectPageResults(context, markdownFiles);
4198
+ errors.push(...collected.errors);
4199
+ await generateOgImageAssets(context, collected, generatedFiles, errors);
4200
+ await writeGeneratedPages(await generateHtmlPages(context, collected.pageResults, collected, errors), context, generatedFiles);
4201
+ return {
4202
+ files: generatedFiles,
4203
+ errors,
4204
+ ogImages: Object.fromEntries(collected.ogImageUrlMap)
4205
+ };
4206
+ }
4207
+ async function cleanOutputDirectory(ssgOptions, outDir) {
4208
+ if (!ssgOptions.clean) return;
4209
+ try {
4210
+ await fs$2.rm(outDir, {
4211
+ recursive: true,
4212
+ force: true
4213
+ });
4214
+ } catch {}
4215
+ }
4216
+ async function createBuildSsgContext(options, root, srcDir, outDir, markdownFiles) {
4217
+ const ssgOptions = options.ssg;
4218
+ const base = options.base.endsWith("/") ? options.base : options.base + "/";
4219
+ return {
4220
+ options,
4221
+ ssgOptions,
4222
+ root,
4223
+ srcDir,
4224
+ outDir,
4225
+ base,
4226
+ navItems: resolveNavigationGroups(ssgOptions.navigation, base, ssgOptions.extension) ?? (ssgOptions.theme?.sidebar.length ? buildThemeNavItems(ssgOptions.theme.sidebar, base, ssgOptions.extension) : buildNavItems(markdownFiles, srcDir, base, ssgOptions.extension)),
4227
+ siteName: await resolveSiteName$1(root, ssgOptions),
4228
+ shouldGenerateOgImages: shouldGenerateOgImages(options),
4229
+ napi: ssgOptions.lastUpdated ? await importNapiModule() : void 0
4230
+ };
4231
+ }
4232
+ /**
4233
+ * Whether this build emits one Open Graph image per page.
4234
+ *
4235
+ * `ssg.bare` deliberately does not turn this off. Bare mode only drops the
4236
+ * generated page shell, and bringing your own shell is exactly the case where
4237
+ * per-page OG images are still wanted — the images are written to the output
4238
+ * tree and the consumer injects the `<meta>` tags itself. Nothing in the bare
4239
+ * HTML references them, because bare output has no `<head>` to put them in.
4240
+ */
4241
+ function shouldGenerateOgImages(options) {
4242
+ return options.ogImage || options.ssg.generateOgImage;
4243
+ }
4244
+ async function resolveSiteName$1(root, ssgOptions) {
4245
+ if (ssgOptions.siteName) return ssgOptions.siteName;
4246
+ try {
3598
4247
  const pkgPath = path$2.join(root, "package.json");
3599
4248
  const pkg = JSON.parse(await fs$2.readFile(pkgPath, "utf-8"));
3600
4249
  return pkg.name ? formatTitle(pkg.name) : "Documentation";
@@ -3714,7 +4363,7 @@ async function generateHtmlPages(context, pageResults, collected, errors) {
3714
4363
  generatedPages.push({
3715
4364
  inputPath: pageResult.inputPath,
3716
4365
  outputPath: pageResult.routePaths.outputPath,
3717
- html: await renderSsgPage(context, pageResult, collected.ogImageUrlMap)
4366
+ html: await renderSsgPage(context, pageResult, collected, pageResults)
3718
4367
  });
3719
4368
  } catch (err) {
3720
4369
  const errorMessage = err instanceof Error ? err.message : String(err);
@@ -3722,16 +4371,61 @@ async function generateHtmlPages(context, pageResults, collected, errors) {
3722
4371
  }
3723
4372
  return generatedPages;
3724
4373
  }
3725
- async function renderSsgPage(context, pageResult, ogImageUrlMap) {
3726
- if (context.ssgOptions.bare) return generateBareHtmlPage(pageResult.transformedHtml, pageResult.title);
3727
- const pageData = createSsgPageData(pageResult);
4374
+ async function renderSsgPage(context, pageResult, collected, allPageResults) {
4375
+ const { ogImageUrlMap } = collected;
3728
4376
  const pageOgImage = context.shouldGenerateOgImages && ogImageUrlMap.has(pageResult.inputPath) ? ogImageUrlMap.get(pageResult.inputPath) : context.ssgOptions.ogImage;
4377
+ if (context.ssgOptions.render) return renderPage(toThemePageData(pageResult), {
4378
+ theme: context.ssgOptions.render,
4379
+ siteName: context.siteName,
4380
+ base: context.base,
4381
+ nav: context.navItems,
4382
+ pages: allPageResults.map(toThemePageData)
4383
+ });
4384
+ if (context.ssgOptions.bare) return generateBarePage({
4385
+ title: pageResult.title,
4386
+ content: pageResult.transformedHtml,
4387
+ lang: context.ssgOptions.lang ?? getPageLocale(pageResult.routePaths.urlPath, context.options.i18n),
4388
+ description: pageResult.description,
4389
+ canonicalUrl: canonicalPageUrl(context, pageResult.routePaths.urlPath),
4390
+ siteName: context.ssgOptions.siteName,
4391
+ ogImage: pageOgImage,
4392
+ head: context.ssgOptions.head,
4393
+ bodyStart: context.ssgOptions.bodyStart,
4394
+ bodyEnd: context.ssgOptions.bodyEnd
4395
+ });
4396
+ const pageData = createSsgPageData(pageResult);
3729
4397
  return generateHtmlPage(pageData, context.navItems, context.siteName, context.base, pageOgImage, context.ssgOptions.theme, getPageLocale(pageData.path, context.options.i18n), context.options.i18n ? context.options.i18n.locales : void 0);
3730
4398
  }
3731
- function createSsgPageData(pageResult) {
3732
- const { frontmatter } = pageResult;
3733
- const entryPage = frontmatter.layout === "entry" ? {
3734
- hero: frontmatter.hero,
4399
+ /** Maps an internal page result onto the theme renderer's page shape. */
4400
+ function toThemePageData(pageResult) {
4401
+ return {
4402
+ title: pageResult.title,
4403
+ description: pageResult.description,
4404
+ html: pageResult.transformedHtml,
4405
+ toc: pageResult.toc,
4406
+ lastUpdated: pageResult.lastUpdated,
4407
+ path: pageResult.inputPath,
4408
+ url: pageResult.routePaths.href,
4409
+ frontmatter: pageResult.frontmatter,
4410
+ layout: typeof pageResult.frontmatter.layout === "string" ? pageResult.frontmatter.layout : void 0
4411
+ };
4412
+ }
4413
+ /**
4414
+ * Absolute URL of a page, or `undefined` when `ssg.siteUrl` is not set.
4415
+ *
4416
+ * Built the same way `get_og_image_url` builds the image URL next to it, so
4417
+ * the canonical link and `og:image` always agree about where the page lives.
4418
+ */
4419
+ function canonicalPageUrl(context, urlPath) {
4420
+ const siteUrl = context.ssgOptions.siteUrl?.replace(/\/+$/, "");
4421
+ if (!siteUrl) return;
4422
+ if (urlPath === "/" || urlPath === "") return `${siteUrl}${context.base}`;
4423
+ return `${siteUrl}${context.base}${urlPath}/`;
4424
+ }
4425
+ function createSsgPageData(pageResult) {
4426
+ const { frontmatter } = pageResult;
4427
+ const entryPage = frontmatter.layout === "entry" ? {
4428
+ hero: frontmatter.hero,
3735
4429
  features: frontmatter.features
3736
4430
  } : void 0;
3737
4431
  return {
@@ -5746,359 +6440,6 @@ function createEmptyLintResult() {
5746
6440
  };
5747
6441
  }
5748
6442
  //#endregion
5749
- //#region src/page-context.ts
5750
- var page_context_exports = /* @__PURE__ */ __exportAll({
5751
- clearRenderContext: () => clearRenderContext,
5752
- generateFrontmatterTypes: () => generateFrontmatterTypes,
5753
- inferType: () => inferType,
5754
- setRenderContext: () => setRenderContext,
5755
- useIsActive: () => useIsActive,
5756
- useNav: () => useNav,
5757
- usePageProps: () => usePageProps,
5758
- useRenderContext: () => useRenderContext,
5759
- useSiteConfig: () => useSiteConfig
5760
- });
5761
- /**
5762
- * Sets the current render context.
5763
- * Called internally during page rendering.
5764
- * @internal
5765
- */
5766
- function setRenderContext(ctx) {
5767
- currentContext = ctx;
5768
- }
5769
- /**
5770
- * Clears the current render context.
5771
- * Called internally after page rendering.
5772
- * @internal
5773
- */
5774
- function clearRenderContext() {
5775
- currentContext = null;
5776
- }
5777
- /**
5778
- * Gets the current page props.
5779
- *
5780
- * @returns The current page props
5781
- * @throws Error if called outside of a render context
5782
- *
5783
- * @example
5784
- * ```tsx
5785
- * function PageTitle() {
5786
- * const page = usePageProps();
5787
- * return <h1>{page.title}</h1>;
5788
- * }
5789
- * ```
5790
- */
5791
- function usePageProps() {
5792
- if (!currentContext) throw new Error("[ox-content] usePageProps() must be called during page rendering. Make sure you are using it inside a theme component.");
5793
- return currentContext.page;
5794
- }
5795
- /**
5796
- * Gets the site configuration.
5797
- *
5798
- * @returns The site configuration
5799
- * @throws Error if called outside of a render context
5800
- *
5801
- * @example
5802
- * ```tsx
5803
- * function SiteHeader() {
5804
- * const site = useSiteConfig();
5805
- * return <header>{site.name}</header>;
5806
- * }
5807
- * ```
5808
- */
5809
- function useSiteConfig() {
5810
- if (!currentContext) throw new Error("[ox-content] useSiteConfig() must be called during page rendering. Make sure you are using it inside a theme component.");
5811
- return currentContext.site;
5812
- }
5813
- /**
5814
- * Gets the full render context.
5815
- *
5816
- * @returns The complete render context
5817
- * @throws Error if called outside of a render context
5818
- *
5819
- * @example
5820
- * ```tsx
5821
- * function Layout({ children }) {
5822
- * const ctx = useRenderContext();
5823
- * return (
5824
- * <html>
5825
- * <head><title>{ctx.page.title} - {ctx.site.name}</title></head>
5826
- * <body>{children}</body>
5827
- * </html>
5828
- * );
5829
- * }
5830
- * ```
5831
- */
5832
- function useRenderContext() {
5833
- if (!currentContext) throw new Error("[ox-content] useRenderContext() must be called during page rendering. Make sure you are using it inside a theme component.");
5834
- return currentContext;
5835
- }
5836
- /**
5837
- * Gets the navigation groups.
5838
- *
5839
- * @example
5840
- * ```tsx
5841
- * function Sidebar() {
5842
- * const nav = useNav();
5843
- * return (
5844
- * <nav>
5845
- * {each(nav, (group) => (
5846
- * <div>
5847
- * <h3>{group.title}</h3>
5848
- * <ul>
5849
- * {each(group.items, (item) => (
5850
- * <li><a href={item.href}>{item.title}</a></li>
5851
- * ))}
5852
- * </ul>
5853
- * </div>
5854
- * ))}
5855
- * </nav>
5856
- * );
5857
- * }
5858
- * ```
5859
- */
5860
- function useNav() {
5861
- return useSiteConfig().nav;
5862
- }
5863
- /**
5864
- * Checks if the given path is the current page.
5865
- *
5866
- * @example
5867
- * ```tsx
5868
- * function NavLink({ href, children }) {
5869
- * const isActive = useIsActive(href);
5870
- * return <a href={href} class={isActive ? 'active' : ''}>{children}</a>;
5871
- * }
5872
- * ```
5873
- */
5874
- function useIsActive(path) {
5875
- const page = usePageProps();
5876
- return page.path === path || page.url === path;
5877
- }
5878
- /**
5879
- * Infers TypeScript types from frontmatter values.
5880
- */
5881
- function inferType(value) {
5882
- if (value === null) return "null";
5883
- if (value === void 0) return "undefined";
5884
- if (typeof value === "string") return "string";
5885
- if (typeof value === "number") return "number";
5886
- if (typeof value === "boolean") return "boolean";
5887
- if (Array.isArray(value)) {
5888
- if (value.length === 0) return "unknown[]";
5889
- const itemTypes = [...new Set(value.map(inferType))];
5890
- if (itemTypes.length === 1) return `${itemTypes[0]}[]`;
5891
- return `(${itemTypes.join(" | ")})[]`;
5892
- }
5893
- if (typeof value === "object") {
5894
- const entries = Object.entries(value);
5895
- if (entries.length === 0) return "Record<string, unknown>";
5896
- return `{ ${entries.map(([k, v]) => `${k}: ${inferType(v)}`).join("; ")} }`;
5897
- }
5898
- return "unknown";
5899
- }
5900
- /**
5901
- * Generates TypeScript interface from frontmatter samples.
5902
- */
5903
- function generateFrontmatterTypes(samples, interfaceName = "PageFrontmatter") {
5904
- const fields = /* @__PURE__ */ new Map();
5905
- for (const sample of samples) for (const [key, value] of Object.entries(sample)) {
5906
- const existing = fields.get(key) ?? {
5907
- types: /* @__PURE__ */ new Set(),
5908
- count: 0
5909
- };
5910
- existing.types.add(inferType(value));
5911
- existing.count++;
5912
- fields.set(key, existing);
5913
- }
5914
- const lines = [
5915
- "/**",
5916
- " * Auto-generated frontmatter type based on your pages.",
5917
- " * DO NOT EDIT - this file is generated by ox-content.",
5918
- " */",
5919
- "",
5920
- `export interface ${interfaceName} {`
5921
- ];
5922
- for (const [name, { types, count }] of fields) {
5923
- const isOptional = count < samples.length;
5924
- const typeStr = [...types].join(" | ");
5925
- const optionalMark = isOptional ? "?" : "";
5926
- lines.push(` ${name}${optionalMark}: ${typeStr};`);
5927
- }
5928
- lines.push("}");
5929
- lines.push("");
5930
- lines.push(`export type PageProps = import('@ox-content/vite-plugin').PageProps<${interfaceName}>;`);
5931
- lines.push("");
5932
- return lines.join("\n");
5933
- }
5934
- var currentContext;
5935
- var init_page_context = __esmMin((() => {
5936
- currentContext = null;
5937
- }));
5938
- //#endregion
5939
- //#region src/theme-renderer.ts
5940
- /**
5941
- * Theme Renderer for Static HTML Generation
5942
- *
5943
- * Renders JSX theme components to static HTML strings.
5944
- * No client-side JavaScript is included by default.
5945
- */
5946
- init_page_context();
5947
- /**
5948
- * Renders a page using the theme component.
5949
- *
5950
- * @param page - Page data to render
5951
- * @param options - Theme render options
5952
- * @returns Rendered HTML string
5953
- */
5954
- function renderPage(page, options) {
5955
- const { theme, siteName, base, nav, pages } = options;
5956
- setRenderContext({
5957
- page: {
5958
- title: page.title,
5959
- description: page.description,
5960
- html: page.html,
5961
- toc: page.toc,
5962
- lastUpdated: page.lastUpdated,
5963
- path: page.path,
5964
- url: page.url,
5965
- frontmatter: page.frontmatter,
5966
- layout: page.layout
5967
- },
5968
- site: {
5969
- name: siteName,
5970
- base,
5971
- nav,
5972
- pages: pages.map((p) => ({
5973
- title: p.title,
5974
- description: p.description,
5975
- html: p.html,
5976
- toc: p.toc,
5977
- lastUpdated: p.lastUpdated,
5978
- path: p.path,
5979
- url: p.url,
5980
- frontmatter: p.frontmatter,
5981
- layout: p.layout
5982
- }))
5983
- }
5984
- });
5985
- try {
5986
- const result = theme({ children: raw(page.html) });
5987
- const html = renderToString(result);
5988
- if (!html.trimStart().toLowerCase().startsWith("<!doctype")) return `<!DOCTYPE html>\n${html}`;
5989
- return html;
5990
- } finally {
5991
- clearRenderContext();
5992
- }
5993
- }
5994
- /**
5995
- * Renders all pages and generates type definitions.
5996
- *
5997
- * @param pages - All pages to render
5998
- * @param options - Theme render options
5999
- * @returns Map of output paths to rendered HTML
6000
- */
6001
- async function renderAllPages(pages, options) {
6002
- const results = /* @__PURE__ */ new Map();
6003
- for (const page of pages) {
6004
- const html = renderPage(page, {
6005
- ...options,
6006
- pages
6007
- });
6008
- results.set(page.url, html);
6009
- }
6010
- if (options.typesOutDir) await generateTypes(pages, options.typesOutDir);
6011
- return results;
6012
- }
6013
- /**
6014
- * Generates TypeScript type definitions from page frontmatter.
6015
- *
6016
- * @param pages - All pages
6017
- * @param outDir - Output directory for types
6018
- */
6019
- async function generateTypes(pages, outDir) {
6020
- const types = generateFrontmatterTypes(pages.map((p) => p.frontmatter));
6021
- const typesPath = join(outDir, "page-props.d.ts");
6022
- await mkdir(dirname(typesPath), { recursive: true });
6023
- await writeFile(typesPath, types, "utf-8");
6024
- }
6025
- /**
6026
- * Default theme component.
6027
- * A minimal theme that renders page content with basic styling.
6028
- */
6029
- function DefaultTheme({ children }) {
6030
- const { usePageProps, useSiteConfig } = (init_page_context(), __toCommonJS(page_context_exports));
6031
- const page = usePageProps();
6032
- const site = useSiteConfig();
6033
- return { __html: `<!DOCTYPE html>
6034
- <html lang="en">
6035
- <head>
6036
- <meta charset="UTF-8">
6037
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6038
- <title>${escapeHtml(page.title)} - ${escapeHtml(site.name)}</title>
6039
- ${page.description ? `<meta name="description" content="${escapeHtml(page.description)}">` : ""}
6040
- <style>
6041
- :root {
6042
- --octc-color-primary: #4f6fae;
6043
- --octc-color-text: #131a30;
6044
- --octc-color-bg: #ffffff;
6045
- --octc-color-bg-alt: #f5f7fb;
6046
- --octc-color-text-muted: #4f607b;
6047
- --octc-color-border: #d2dbea;
6048
- }
6049
- body {
6050
- font-family: "IBM Plex Sans", "Avenir Next", "Segoe UI Variable", "Segoe UI", sans-serif;
6051
- line-height: 1.7;
6052
- color: var(--octc-color-text);
6053
- background: var(--octc-color-bg);
6054
- max-width: 800px;
6055
- margin: 0 auto;
6056
- padding: 2rem;
6057
- }
6058
- a { color: var(--octc-color-primary); }
6059
- </style>
6060
- </head>
6061
- <body>
6062
- <header>
6063
- <h1>${escapeHtml(site.name)}</h1>
6064
- </header>
6065
- <main>
6066
- ${children.__html}
6067
- </main>
6068
- </body>
6069
- </html>` };
6070
- }
6071
- function escapeHtml(str) {
6072
- return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
6073
- }
6074
- /**
6075
- * Creates a theme with layout switching support.
6076
- *
6077
- * @example
6078
- * ```tsx
6079
- * import { createTheme } from '@ox-content/vite-plugin';
6080
- * import { DefaultLayout } from './layouts/Default';
6081
- * import { EntryLayout } from './layouts/Entry';
6082
- *
6083
- * export default createTheme({
6084
- * layouts: {
6085
- * default: DefaultLayout,
6086
- * entry: EntryLayout,
6087
- * },
6088
- * });
6089
- * ```
6090
- */
6091
- function createTheme(config) {
6092
- const { layouts, defaultLayout = "default" } = config;
6093
- return function ThemeWithLayouts({ children }) {
6094
- const { usePageProps } = (init_page_context(), __toCommonJS(page_context_exports));
6095
- const layoutName = usePageProps().layout ?? defaultLayout;
6096
- const Layout = layouts[layoutName] ?? layouts[defaultLayout];
6097
- if (!Layout) throw new Error(`[ox-content] Layout "${layoutName}" not found. Available layouts: ${Object.keys(layouts).join(", ")}`);
6098
- return Layout({ children });
6099
- };
6100
- }
6101
- //#endregion
6102
6443
  //#region src/index.ts
6103
6444
  /**
6104
6445
  * Vite Plugin for Ox Content