@ox-content/vite-plugin 2.89.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.cjs CHANGED
@@ -109,6 +109,119 @@ function createMarkdownEnvironment(options) {
109
109
  };
110
110
  }
111
111
  //#endregion
112
+ //#region src/highlight-native.ts
113
+ /**
114
+ * Extract text content from a hast node.
115
+ */
116
+ function getTextContent(node) {
117
+ let text = "";
118
+ if ("children" in node) {
119
+ for (const child of node.children) if (child.type === "text") text += child.value;
120
+ else if (child.type === "element") text += getTextContent(child);
121
+ }
122
+ return text;
123
+ }
124
+ function normalizeClassName(className) {
125
+ if (Array.isArray(className)) return className.filter((value) => typeof value === "string");
126
+ if (typeof className === "string" && className) return className.split(/\s+/).filter(Boolean);
127
+ return [];
128
+ }
129
+ /**
130
+ * Highlights with the native tree-sitter engine, or `null` when it has no
131
+ * grammar for `lang`.
132
+ *
133
+ * Parsing once and walking the tree is roughly eight times faster than
134
+ * matching TextMate patterns line by line — 10.5 ms against 81.5 ms over the
135
+ * documentation corpus's code blocks — and it emits the same
136
+ * `--octc-shiki-*` markup, so themes are unaffected.
137
+ */
138
+ function highlightNatively(code, lang) {
139
+ try {
140
+ return require_vitepress.importNapiModuleSync().highlightCodeBlock(code, lang);
141
+ } catch {
142
+ return null;
143
+ }
144
+ }
145
+ /** Whether the native engine claims `lang`. */
146
+ function nativeSupports(lang) {
147
+ try {
148
+ return require_vitepress.importNapiModuleSync().supportsHighlightLanguage(lang);
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
153
+ /**
154
+ * Whether any block in this tree still needs Shiki.
155
+ *
156
+ * Creating a Shiki highlighter parses two dozen TextMate grammars, about
157
+ * 190 ms, so a document whose languages are all covered natively must not
158
+ * touch it at all.
159
+ */
160
+ function treeNeedsShiki(tree, nativeThemeApplies) {
161
+ if (!nativeThemeApplies) return true;
162
+ let needed = false;
163
+ const walk = (node) => {
164
+ if (needed || !("children" in node)) return;
165
+ for (const child of node.children) {
166
+ if (child.type !== "element") continue;
167
+ if (child.tagName === "code") {
168
+ const lang = languageOf(child);
169
+ if (lang !== null && !nativeSupports(lang)) {
170
+ needed = true;
171
+ return;
172
+ }
173
+ }
174
+ walk(child);
175
+ }
176
+ };
177
+ walk(tree);
178
+ return needed;
179
+ }
180
+ /** The `language-*` class on a `<code>` element, if it carries one. */
181
+ function languageOf(codeElement) {
182
+ const className = normalizeClassName(codeElement.properties?.className).find((value) => value.startsWith("language-"));
183
+ return className ? className.slice(9) : null;
184
+ }
185
+ /**
186
+ * Highlights every code block in a rendered document in one native call.
187
+ *
188
+ * Returns the rewritten HTML and the languages it declined, so the caller
189
+ * knows whether Shiki still has to run over the result. Returns `null` when
190
+ * the native module is unavailable.
191
+ *
192
+ * This exists because the plumbing dwarfed the work: walking each page
193
+ * through an HTML parser and serializer to find `<pre>` elements cost 139 ms
194
+ * over the documentation corpus, and re-parsing each highlighted block to
195
+ * splice it back cost another 38 ms, against 14 ms of actual highlighting.
196
+ *
197
+ * It runs off the main thread. The synchronous binding held the event loop
198
+ * for the whole pass, so a build asking for several pages at once still got
199
+ * them one at a time — `Promise.all` over the corpus measured the same as
200
+ * awaiting each page in turn.
201
+ */
202
+ async function highlightDocumentNatively(html) {
203
+ try {
204
+ return await require_vitepress.importNapiModuleSync().highlightHtmlCodeBlocksAsync(html);
205
+ } catch {
206
+ return null;
207
+ }
208
+ }
209
+ /**
210
+ * Splices `replacements` back over the blocks the native pass left pending.
211
+ *
212
+ * Entry `i` is the highlighted `<pre>` for pending block `i`, or an empty
213
+ * string to leave that block alone. This keeps a page that needs one exotic
214
+ * grammar — a Vue SFC, a Mermaid diagram — off the HTML round trip, rather
215
+ * than surrendering the whole document for it.
216
+ */
217
+ function applyPendingHighlights(html, replacements) {
218
+ try {
219
+ return require_vitepress.importNapiModuleSync().applyPendingHighlights(html, replacements);
220
+ } catch {
221
+ return html;
222
+ }
223
+ }
224
+ //#endregion
112
225
  //#region src/shiki-theme.ts
113
226
  /**
114
227
  * Name callers pass as `highlightTheme` to render syntax colors as CSS custom
@@ -233,17 +346,19 @@ function rehypeShikiHighlight(options) {
233
346
  const { theme, langs } = options;
234
347
  return async (tree) => {
235
348
  const { themeName } = normalizeThemeInput(theme);
236
- const highlighter = await getHighlighter(theme, langs);
349
+ const nativeThemeApplies = themeName === CSS_VARIABLES_THEME;
350
+ const highlighter = treeNeedsShiki(tree, nativeThemeApplies) ? await getHighlighter(theme, langs) : void 0;
237
351
  const highlightBlockCode = (codeElement) => {
238
352
  let lang = "text";
239
353
  const langClass = normalizeClassName(codeElement.properties?.className).find((value) => value.startsWith("language-"));
240
354
  if (langClass) lang = langClass.replace("language-", "");
241
355
  const codeText = getTextContent(codeElement);
242
356
  try {
243
- const highlighted = highlighter.codeToHtml(codeText, {
357
+ const highlighted = (nativeThemeApplies ? highlightNatively(codeText, lang) : null) ?? highlighter?.codeToHtml(codeText, {
244
358
  lang,
245
359
  theme: themeName
246
360
  });
361
+ if (highlighted === void 0) return null;
247
362
  const parsed = (0, unified.unified)().use(rehypeParse$3, { fragment: true }).parse(highlighted);
248
363
  if (parsed.children[0]?.type === "element") {
249
364
  const highlightedPre = parsed.children[0];
@@ -262,10 +377,11 @@ function rehypeShikiHighlight(options) {
262
377
  lang = langClass.replace("language-", "");
263
378
  const codeText = getTextContent(codeElement);
264
379
  try {
265
- const highlighted = highlighter.codeToHtml(codeText, {
380
+ const highlighted = (nativeThemeApplies ? highlightNatively(codeText, lang) : null) ?? highlighter?.codeToHtml(codeText, {
266
381
  lang,
267
382
  theme: themeName
268
383
  });
384
+ if (highlighted === void 0) return null;
269
385
  const parsed = (0, unified.unified)().use(rehypeParse$3, { fragment: true }).parse(highlighted);
270
386
  if (parsed.children[0]?.type === "element") {
271
387
  const highlightedCode = parsed.children[0].children.find((child) => child.type === "element" && child.tagName === "code");
@@ -289,7 +405,8 @@ function rehypeShikiHighlight(options) {
289
405
  const child = node.children[i];
290
406
  if (child.type === "element" && child.tagName === "pre") {
291
407
  const codeElement = child.children.find((c) => c.type === "element" && c.tagName === "code");
292
- if (codeElement) {
408
+ const alreadyHighlighted = normalizeClassName(child.properties?.className).includes("shiki");
409
+ if (codeElement && !alreadyHighlighted) {
293
410
  const highlightedPre = highlightBlockCode(codeElement);
294
411
  if (highlightedPre) node.children[i] = highlightedPre;
295
412
  }
@@ -303,22 +420,6 @@ function rehypeShikiHighlight(options) {
303
420
  };
304
421
  }
305
422
  /**
306
- * Extract text content from a hast node.
307
- */
308
- function getTextContent(node) {
309
- let text = "";
310
- if ("children" in node) {
311
- for (const child of node.children) if (child.type === "text") text += child.value;
312
- else if (child.type === "element") text += getTextContent(child);
313
- }
314
- return text;
315
- }
316
- function normalizeClassName(className) {
317
- if (Array.isArray(className)) return className.filter((value) => typeof value === "string");
318
- if (typeof className === "string" && className) return className.split(/\s+/).filter(Boolean);
319
- return [];
320
- }
321
- /**
322
423
  * Apply syntax highlighting to HTML using Shiki.
323
424
  */
324
425
  async function highlightCode(html, theme = CSS_VARIABLES_THEME, langs = []) {
@@ -329,6 +430,83 @@ async function highlightCode(html, theme = CSS_VARIABLES_THEME, langs = []) {
329
430
  return String(result);
330
431
  }
331
432
  //#endregion
433
+ //#region src/highlight-pending.ts
434
+ /**
435
+ * Highlighting the blocks the native pass could not claim.
436
+ *
437
+ * These arrive already named — the native pass reports each one's language —
438
+ * which lets this load exactly the grammars a page needs instead of the whole
439
+ * bundled set, and splice each result back without an HTML parser.
440
+ */
441
+ /**
442
+ * A highlighter that starts with no grammars and gains them on demand.
443
+ *
444
+ * `getHighlighter` loads all two dozen `BUILTIN_LANGS` up front, because the
445
+ * tree walk it serves discovers a page's languages only while rewriting it.
446
+ * The pending list does not have that problem — it names every language it
447
+ * needs — and paying for two dozen TextMate grammars to highlight one Vue
448
+ * block is most of what a page with an exotic block costs.
449
+ */
450
+ const lazyHighlighterCache = /* @__PURE__ */ new Map();
451
+ const loadedLangs = /* @__PURE__ */ new WeakMap();
452
+ async function getLazyHighlighter(theme, customLangs, wanted) {
453
+ const { themeInput } = normalizeThemeInput(theme);
454
+ const cacheKey = JSON.stringify({
455
+ theme: themeInput,
456
+ langs: customLangs
457
+ });
458
+ let highlighterPromise = lazyHighlighterCache.get(cacheKey);
459
+ if (!highlighterPromise) {
460
+ highlighterPromise = (0, shiki.createHighlighter)({
461
+ themes: [themeInput],
462
+ langs: customLangs
463
+ });
464
+ lazyHighlighterCache.set(cacheKey, highlighterPromise);
465
+ }
466
+ const highlighter = await highlighterPromise;
467
+ let loaded = loadedLangs.get(highlighter);
468
+ if (!loaded) {
469
+ loaded = /* @__PURE__ */ new Set();
470
+ loadedLangs.set(highlighter, loaded);
471
+ }
472
+ const missing = [...new Set(wanted.filter((lang) => !loaded.has(lang) && BUILTIN_LANGS.includes(lang)))];
473
+ if (missing.length > 0) await Promise.all(missing.map(async (lang) => {
474
+ try {
475
+ await highlighter.loadLanguage(lang);
476
+ loaded.add(lang);
477
+ } catch {}
478
+ }));
479
+ return highlighter;
480
+ }
481
+ /**
482
+ * Highlight the blocks the native pass left pending, in order.
483
+ *
484
+ * Entry `i` of the result is the `<pre>` for block `i`, or an empty string
485
+ * when Shiki has no grammar for it either — in which case the block is left
486
+ * exactly as it arrived, the same outcome the tree walk reached by keeping the
487
+ * original element.
488
+ *
489
+ * This is the whole point of the pending list: a page whose only unsupported
490
+ * block is a Mermaid diagram used to be handed to the tree walk in full, which
491
+ * re-highlighted every one of its other blocks and paid for a parse and a
492
+ * serialize of the page to do it.
493
+ */
494
+ async function highlightPendingBlocks(blocks, theme = CSS_VARIABLES_THEME, langs = []) {
495
+ if (blocks.length === 0) return [];
496
+ const { themeName } = normalizeThemeInput(theme);
497
+ const highlighter = await getLazyHighlighter(theme, langs, blocks.map((block) => block.language));
498
+ return blocks.map((block) => {
499
+ try {
500
+ return highlighter.codeToHtml(block.source, {
501
+ lang: block.language,
502
+ theme: themeName
503
+ });
504
+ } catch {
505
+ return "";
506
+ }
507
+ });
508
+ }
509
+ //#endregion
332
510
  //#region src/plugins/mermaid.ts
333
511
  /**
334
512
  * Mermaid Plugin - Native Rust renderer via NAPI
@@ -342,21 +520,21 @@ var mermaid_exports = /* @__PURE__ */ require_vitepress.__exportAll({
342
520
  transformMermaidStatic: () => transformMermaidStatic
343
521
  });
344
522
  /** Cached NAPI bindings */
345
- let napiBindings$1 = null;
346
- let napiLoadAttempted$1 = false;
523
+ let napiBindings = null;
524
+ let napiLoadAttempted = false;
347
525
  async function loadNapi() {
348
- if (napiLoadAttempted$1) return napiBindings$1;
349
- napiLoadAttempted$1 = true;
526
+ if (napiLoadAttempted) return napiBindings;
527
+ napiLoadAttempted = true;
350
528
  try {
351
529
  const binding = await require_vitepress.importNapiModule();
352
530
  if (typeof binding.transformMermaid !== "function") {
353
- napiBindings$1 = null;
531
+ napiBindings = null;
354
532
  return null;
355
533
  }
356
- napiBindings$1 = binding;
534
+ napiBindings = binding;
357
535
  return binding;
358
536
  } catch {
359
- napiBindings$1 = null;
537
+ napiBindings = null;
360
538
  return null;
361
539
  }
362
540
  }
@@ -1947,24 +2125,25 @@ function normalizeDiagnostic(diagnostic) {
1947
2125
  //#endregion
1948
2126
  //#region src/transform.ts
1949
2127
  /**
1950
- * Cached NAPI bindings instance.
1951
- * Loaded on first use and reused for subsequent transformations.
2128
+ * The NAPI load, cached as the promise rather than as its result.
2129
+ *
2130
+ * The load yields, and a caller arriving during that yield has to wait for it
2131
+ * rather than read a result that is not there yet. Holding the promise is what
2132
+ * makes every caller wait for the same load; holding an "already attempted"
2133
+ * flag beside an unset result meant the first page to arrive loaded the module
2134
+ * and every page behind it concluded there were no bindings at all.
2135
+ *
1952
2136
  * @internal
1953
2137
  */
1954
- let napiBindings;
1955
- /**
1956
- * Flag to prevent repeated NAPI loading attempts.
1957
- * Set to true after first load attempt (success or failure).
1958
- * @internal
1959
- */
1960
- let napiLoadAttempted = false;
2138
+ let napiLoad;
1961
2139
  /**
1962
2140
  * Lazily loads and caches NAPI bindings.
1963
2141
  *
1964
2142
  * This function uses lazy loading to defer the import of NAPI bindings
1965
2143
  * until they're actually needed. The bindings are loaded only once and
1966
- * cached for subsequent uses. If loading fails (e.g., bindings not built),
1967
- * the failure is cached to avoid repeated load attempts.
2144
+ * cached for subsequent uses, including by callers that ask for them while
2145
+ * that first load is still in flight. If loading fails (e.g., bindings not
2146
+ * built), the failure is cached to avoid repeated load attempts.
1968
2147
  *
1969
2148
  * ## Performance Considerations
1970
2149
  *
@@ -1995,18 +2174,12 @@ let napiLoadAttempted = false;
1995
2174
  *
1996
2175
  * @internal
1997
2176
  */
1998
- async function loadNapiBindings() {
1999
- if (napiLoadAttempted) return napiBindings ?? null;
2000
- napiLoadAttempted = true;
2001
- try {
2002
- const mod = await require_vitepress.importNapiModule();
2003
- napiBindings = mod;
2004
- return mod;
2005
- } catch (error) {
2177
+ function loadNapiBindings() {
2178
+ napiLoad ??= require_vitepress.importNapiModule().catch((error) => {
2006
2179
  if (process.env.DEBUG) console.debug("[ox-content] NAPI bindings load failed:", error);
2007
- napiBindings = null;
2008
2180
  return null;
2009
- }
2181
+ });
2182
+ return napiLoad;
2010
2183
  }
2011
2184
  async function transformMarkdown(source, filePath, options, ssgOptions) {
2012
2185
  const napi = await loadNapiBindings();
@@ -2061,9 +2234,18 @@ async function transformMarkdown(source, filePath, options, ssgOptions) {
2061
2234
  const { html: protectedHtml, svgs } = protectMermaidSvgs(html);
2062
2235
  html = protectedHtml;
2063
2236
  if (options.highlight) {
2064
- const originalHtml = html;
2065
- const highlightedHtml = await highlightCode(html, options.highlightTheme, options.highlightLangs);
2066
- html = napi.mergeHighlightedCodeBlocks(originalHtml, highlightedHtml);
2237
+ const native = options.highlightTheme === void 0 || options.highlightTheme === "css-variables" ? await highlightDocumentNatively(html) : null;
2238
+ if (native && native.skipped.length === 0) {
2239
+ html = native.html;
2240
+ if (native.pending.length > 0) {
2241
+ const replacements = await highlightPendingBlocks(native.pending, options.highlightTheme, options.highlightLangs);
2242
+ html = applyPendingHighlights(html, replacements);
2243
+ }
2244
+ } else {
2245
+ const originalHtml = html;
2246
+ const highlightedHtml = await highlightCode(html, options.highlightTheme, options.highlightLangs);
2247
+ html = napi.mergeHighlightedCodeBlocks(originalHtml, highlightedHtml);
2248
+ }
2067
2249
  }
2068
2250
  html = await transformBuiltinEmbeds(html, options.embeds ?? {
2069
2251
  github: {},
@@ -2177,6 +2359,56 @@ if (import.meta.hot) {
2177
2359
  }
2178
2360
  //#endregion
2179
2361
  //#region src/docs.ts
2362
+ /**
2363
+ * Source Documentation Extraction and Generation
2364
+ *
2365
+ * This module provides comprehensive tools for extracting JSDoc/TSDoc comments
2366
+ * from TypeScript/JavaScript source files and automatically generating Markdown
2367
+ * documentation.
2368
+ *
2369
+ * ## Features
2370
+ *
2371
+ * - **Automatic Extraction**: Parses JSDoc comments from functions, classes, interfaces, and types
2372
+ * - **Flexible Filtering**: Include/exclude patterns for selective documentation
2373
+ * - **Markdown Generation**: Converts extracted docs to organized Markdown files
2374
+ * - **Navigation Generation**: Auto-generates sidebar navigation metadata
2375
+ * - **GitHub Links**: Includes clickable links to source code on GitHub
2376
+ *
2377
+ * ## Supported JSDoc Tags
2378
+ *
2379
+ * - `@param {type} name - description` - Function parameter documentation
2380
+ * - `@returns {type} description` - Return value documentation
2381
+ * - `@example` - Code examples (multi-line blocks)
2382
+ * - `@private` - Mark item as private (excluded from docs if private=false)
2383
+ * - `@default value` - Default parameter value
2384
+ * - Custom tags are preserved in the `tags` field
2385
+ *
2386
+ * ## Usage Flow
2387
+ *
2388
+ * 1. Call `extractDocs()` to parse source files
2389
+ * 2. Call `generateMarkdown()` to create Markdown content
2390
+ * 3. Call `writeDocs()` to write files to output directory
2391
+ * 4. Generated nav.ts can be imported for sidebar navigation
2392
+ *
2393
+ * @example
2394
+ * ```typescript
2395
+ * import { extractDocs, generateMarkdown, writeDocs } from './docs';
2396
+ *
2397
+ * const docsOptions = {
2398
+ * enabled: true,
2399
+ * src: ['./src'],
2400
+ * out: './docs/api',
2401
+ * include: ['**\/*.ts'],
2402
+ * exclude: ['**\/*.test.ts'],
2403
+ * groupBy: 'file',
2404
+ * githubUrl: 'https://github.com/user/project',
2405
+ * };
2406
+ *
2407
+ * const extracted = await extractDocs(['./src'], docsOptions);
2408
+ * const markdown = generateMarkdown(extracted, docsOptions);
2409
+ * await writeDocs(markdown, './docs/api', extracted, docsOptions);
2410
+ * ```
2411
+ */
2180
2412
  const DEFAULT_DOCS_INCLUDE = [
2181
2413
  "**/*.ts",
2182
2414
  "**/*.tsx",
@@ -2317,7 +2549,7 @@ async function writeDocs(docs, outDir, extractedDocs, options) {
2317
2549
  napi.writeGeneratedDocs(docs, outDir, extractedDocs ? toRustDocsModules(extractedDocs) : void 0, {
2318
2550
  generateNav: options?.generateNav ?? false,
2319
2551
  groupBy: options?.groupBy ?? "file",
2320
- generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
2552
+ generatedAt: existingGeneratedAt(outDir) ?? (/* @__PURE__ */ new Date()).toISOString(),
2321
2553
  basePath: options?.basePath,
2322
2554
  pathStrategy: options?.pathStrategy,
2323
2555
  groupOrder: options?.groupOrder,
@@ -2327,6 +2559,15 @@ async function writeDocs(docs, outDir, extractedDocs, options) {
2327
2559
  singleEntryRoot: options?.singleEntryRoot
2328
2560
  });
2329
2561
  }
2562
+ /** Keep `docs.json`'s timestamp stable across regenerations of the same tree. */
2563
+ function existingGeneratedAt(outDir) {
2564
+ try {
2565
+ const parsed = JSON.parse((0, node_fs.readFileSync)(node_path.join(outDir, "docs.json"), "utf8"));
2566
+ return typeof parsed.generatedAt === "string" && parsed.generatedAt.length > 0 ? parsed.generatedAt : void 0;
2567
+ } catch {
2568
+ return;
2569
+ }
2570
+ }
2330
2571
  function toRustDocsModules(docs) {
2331
2572
  return docs.map((doc) => ({
2332
2573
  file: doc.file,