@ox-content/vite-plugin 2.90.0 → 3.0.0-alpha.10

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.
@@ -37,8 +37,15 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
37
37
  enumerable: true
38
38
  }) : target, mod));
39
39
  var __toCommonJS = (mod) => __hasOwnProp.call(mod, "module.exports") ? mod["module.exports"] : __copyProps(__defProp({}, "__esModule", { value: true }), mod);
40
+ //#endregion
41
+ let node_module = require("node:module");
42
+ let node_fs = require("node:fs");
43
+ let node_path = require("node:path");
44
+ let node_fs_promises = require("node:fs/promises");
45
+ let node_crypto = require("node:crypto");
46
+ let glob = require("glob");
40
47
  //#region src/napi.ts
41
- const requireNapi = (0, require("node:module").createRequire)(require("url").pathToFileURL(__filename).href);
48
+ const requireNapi = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href);
42
49
  function getDefaultExport(value) {
43
50
  if (!value || typeof value !== "object" || !("default" in value)) return;
44
51
  const defaultExport = value.default;
@@ -67,6 +74,632 @@ function importNapiModuleSync() {
67
74
  }
68
75
  }
69
76
  //#endregion
77
+ //#region src/theme-fonts-acquire.ts
78
+ /**
79
+ * Resolve self-hosted faces from a local file / `@fontsource` directory or
80
+ * Google Fonts. Downloads are cached; tests inject `fetch` so CI never hits
81
+ * the network.
82
+ */
83
+ const GOOGLE_CSS = "https://fonts.googleapis.com/css2";
84
+ const GOOGLE_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36";
85
+ const ALLOWED_HOSTS = /* @__PURE__ */ new Set(["fonts.googleapis.com", "fonts.gstatic.com"]);
86
+ function fontMime(fileName) {
87
+ if (fileName.endsWith(".woff")) return "font/woff";
88
+ if (fileName.endsWith(".ttf")) return "font/ttf";
89
+ if (fileName.endsWith(".otf")) return "font/otf";
90
+ return "font/woff2";
91
+ }
92
+ function renderFontFaceCss(faces) {
93
+ return faces.map((face) => {
94
+ const range = face.unicodeRange ? `\n unicode-range: ${face.unicodeRange};` : "";
95
+ const fileName = face.fileName;
96
+ const format = fileName.endsWith(".woff") ? "woff" : fileName.endsWith(".ttf") ? "truetype" : fileName.endsWith(".otf") ? "opentype" : "woff2";
97
+ return `@font-face {
98
+ font-family: ${/^[a-zA-Z_-][\w-]*$/.test(face.family) ? face.family : `"${face.family.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`};
99
+ font-style: ${face.style};
100
+ font-weight: ${face.weight};
101
+ font-display: ${face.display};
102
+ src: url(./${fileName}) format("${format}");${range}
103
+ }`;
104
+ }).join("\n\n");
105
+ }
106
+ function resolveFontCacheDir(root, cacheDir) {
107
+ return cacheDir ?? (0, node_path.join)(root, "node_modules", ".cache", "ox-content", "fonts");
108
+ }
109
+ async function acquireSelfHostedFaces(faces, options) {
110
+ const cacheDir = resolveFontCacheDir(options.root, options.cacheDir);
111
+ const acquired = [];
112
+ for (const face of faces) acquired.push(face.provider === "local" ? await acquireLocalFace(face, options.root) : await acquireGoogleFace(face, cacheDir, options.fetch ?? fetch));
113
+ return acquired;
114
+ }
115
+ async function acquireLocalFace(face, root) {
116
+ if (!face.path) throw new Error(`Theme font "${face.family}" uses provider "local" but has no path.`);
117
+ if (face.path.includes("\0")) throw new Error(`Theme font "${face.family}" path must not contain NUL.`);
118
+ const resolved = resolveLocalPath(root, face.path);
119
+ const info = await (0, node_fs_promises.stat)(resolved).catch(() => void 0);
120
+ if (!info) throw new Error(`Theme font "${face.family}" was not found at ${resolved}.`);
121
+ const file = info.isDirectory() ? await findDirectoryFont(resolved, face) : resolved;
122
+ return {
123
+ ...face,
124
+ bytes: await (0, node_fs_promises.readFile)(file)
125
+ };
126
+ }
127
+ function resolveLocalPath(root, spec) {
128
+ if ((0, node_path.isAbsolute)(spec)) return spec;
129
+ if (spec.startsWith("@") || !spec.startsWith(".")) return (0, node_path.resolve)(root, "node_modules", spec);
130
+ return (0, node_path.resolve)(root, spec);
131
+ }
132
+ async function findDirectoryFont(dir, face) {
133
+ const filesDir = (0, node_fs.existsSync)((0, node_path.join)(dir, "files")) ? (0, node_path.join)(dir, "files") : dir;
134
+ const names = (await (0, node_fs_promises.readdir)(filesDir)).filter((name) => /\.(woff2|woff|ttf|otf)$/i.test(name));
135
+ const weight = String(face.weight);
136
+ const wantItalic = face.style === "italic";
137
+ const match = names.find((name) => {
138
+ const lower = name.toLowerCase();
139
+ const hasWeight = lower.includes(weight);
140
+ const italic = lower.includes("italic");
141
+ const subset = face.subset === "all" || lower.includes(face.subset.toLowerCase());
142
+ return hasWeight && subset && italic === wantItalic;
143
+ });
144
+ const fallback = names[0];
145
+ const chosen = match ?? (names.length === 1 ? fallback : void 0);
146
+ if (!chosen) throw new Error(`Theme font "${face.family}" has no ${face.weight} ${face.style} ${face.subset} file in ${filesDir}.`);
147
+ return (0, node_path.join)(filesDir, chosen);
148
+ }
149
+ async function acquireGoogleFace(face, cacheDir, fetchFn) {
150
+ const parsed = parseGoogleCss(await cachedText(googleCssUrl(face), cacheDir, fetchFn, ".css")).find((entry) => entry.weight === face.weight && entry.style === face.style && (entry.subset === face.subset || !entry.subset));
151
+ if (!parsed) throw new Error(`Google Fonts CSS for "${face.family}" has no ${face.weight} ${face.style} ${face.subset} face.`);
152
+ const bytes = await cachedBytes(parsed.url, cacheDir, fetchFn, ".woff2");
153
+ return {
154
+ ...face,
155
+ bytes,
156
+ unicodeRange: face.unicodeRange ?? parsed.unicodeRange
157
+ };
158
+ }
159
+ function googleCssUrl(face) {
160
+ const italic = face.style === "italic";
161
+ const axis = italic ? "ital,wght" : "wght";
162
+ const spec = italic ? `1,${face.weight}` : `${face.weight}`;
163
+ const family = `${face.family.replace(/ /g, "+")}:${axis}@${spec}`;
164
+ return `${GOOGLE_CSS}?family=${family}&display=${encodeURIComponent(face.display)}`;
165
+ }
166
+ function parseGoogleCss(css) {
167
+ const faces = [];
168
+ const blocks = css.matchAll(/\/\*\s*([a-z0-9-]+)\s*\*\/\s*@font-face\s*\{([^}]+)\}/gi);
169
+ for (const match of blocks) {
170
+ const parsed = parseGoogleBlock(match[2] ?? "", match[1]?.toLowerCase() ?? "");
171
+ if (parsed) faces.push(parsed);
172
+ }
173
+ if (faces.length === 0) for (const match of css.matchAll(/@font-face\s*\{([^}]+)\}/gi)) {
174
+ const parsed = parseGoogleBlock(match[1] ?? "", "");
175
+ if (parsed) faces.push(parsed);
176
+ }
177
+ return faces;
178
+ }
179
+ function parseGoogleBlock(body, subset) {
180
+ const url = body.match(/url\((['"]?)(https?:\/\/[^'")]+)\1\)/)?.[2];
181
+ if (!url || !isAllowedFontUrl(url)) return;
182
+ return {
183
+ subset,
184
+ weight: Number(body.match(/font-weight:\s*(\d+)/i)?.[1] ?? 400),
185
+ style: /font-style:\s*italic/i.test(body) ? "italic" : "normal",
186
+ url,
187
+ unicodeRange: body.match(/unicode-range:\s*([^;]+)/i)?.[1]?.trim()
188
+ };
189
+ }
190
+ function isAllowedFontUrl(url) {
191
+ try {
192
+ const parsed = new URL(url);
193
+ return parsed.protocol === "https:" && ALLOWED_HOSTS.has(parsed.hostname);
194
+ } catch {
195
+ return false;
196
+ }
197
+ }
198
+ async function cachedText(url, cacheDir, fetchFn, ext) {
199
+ const bytes = await cachedBytes(url, cacheDir, fetchFn, ext);
200
+ return new TextDecoder().decode(bytes);
201
+ }
202
+ async function cachedBytes(url, cacheDir, fetchFn, ext) {
203
+ if (!isAllowedFontUrl(url)) throw new Error(`Refusing to download font from ${url}.`);
204
+ await (0, node_fs_promises.mkdir)(cacheDir, { recursive: true });
205
+ const dest = (0, node_path.join)(cacheDir, `${(0, node_crypto.createHash)("sha256").update(url).digest("hex").slice(0, 16)}${ext}`);
206
+ if ((0, node_fs.existsSync)(dest)) return (0, node_fs_promises.readFile)(dest);
207
+ const response = await fetchFn(url, { headers: { "User-Agent": GOOGLE_UA } });
208
+ if (!response.ok) throw new Error(`Failed to download ${url}: ${response.status}`);
209
+ const bytes = new Uint8Array(await response.arrayBuffer());
210
+ await (0, node_fs_promises.writeFile)(dest, bytes);
211
+ return bytes;
212
+ }
213
+ //#endregion
214
+ //#region src/theme-fonts.ts
215
+ /**
216
+ * Opt-in web-font objects for `theme.fonts`, plus SSG self-host emission.
217
+ *
218
+ * NAPI still receives flattened CSS stacks (`JsThemeFonts`). File acquisition
219
+ * and `@font-face` generation stay in TypeScript so other PRs can keep landing
220
+ * NAPI theme-type changes independently.
221
+ */
222
+ const FONT_ASSET_DIR = "__ox_fonts__";
223
+ const FONT_CSS_NAME = "fonts.css";
224
+ const NAMED_FONT_PATTERN = /^[a-z][a-z0-9-]*$/;
225
+ const GENERIC_FOR = {
226
+ sans: "sans-serif",
227
+ mono: "monospace",
228
+ named: "sans-serif"
229
+ };
230
+ function isThemeWebFont(value) {
231
+ return typeof value === "object" && value !== null && typeof value.family === "string";
232
+ }
233
+ /** CSS `font-family` identifier; quotes names that are not a single ident. */
234
+ function cssFamilyName(family) {
235
+ const trimmed = family.trim();
236
+ if (trimmed.startsWith("\"") && trimmed.endsWith("\"") || trimmed.startsWith("'") && trimmed.endsWith("'")) return trimmed;
237
+ if (/^[a-zA-Z_-][\w-]*$/.test(trimmed)) return trimmed;
238
+ return `"${trimmed.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
239
+ }
240
+ function flattenThemeFont(value, generic) {
241
+ if (value === void 0) return;
242
+ if (typeof value === "string") return value;
243
+ const fallbacks = value.fallbacks?.length ? value.fallbacks.join(", ") : generic;
244
+ return `${cssFamilyName(value.family)}, ${fallbacks}`;
245
+ }
246
+ /** Flatten object fonts so `JsThemeFonts` stays `{ sans?: string; mono?: string }`. */
247
+ function flattenThemeFonts(fonts) {
248
+ const sans = flattenThemeFont(fonts.sans, GENERIC_FOR.sans);
249
+ const mono = flattenThemeFont(fonts.mono, GENERIC_FOR.mono);
250
+ if (!sans && !mono) return;
251
+ return {
252
+ sans,
253
+ mono
254
+ };
255
+ }
256
+ function namedFontToken(name) {
257
+ if (!NAMED_FONT_PATTERN.test(name)) throw new Error(`Invalid theme font name: ${JSON.stringify(name)}. Named fonts are lowercase kebab-case (e.g. "code").`);
258
+ return name;
259
+ }
260
+ /** Extra `--octc-font-*` variables for `fonts.named`. Roles stay in Rust theme CSS. */
261
+ function namedFontVarsCss(fonts) {
262
+ const entries = Object.entries(fonts.named ?? {});
263
+ if (entries.length === 0) return "";
264
+ return `:root {\n${entries.map(([name, value]) => {
265
+ const stack = flattenThemeFont(value, GENERIC_FOR.named);
266
+ return ` --octc-font-${namedFontToken(name)}: ${stack};`;
267
+ }).join("\n")}\n}`;
268
+ }
269
+ function normalizeBasePath(base) {
270
+ if (!base || base === "/") return "/";
271
+ return base.endsWith("/") ? base : `${base}/`;
272
+ }
273
+ function plannedFontFileName(family, weight, style, subset, extension) {
274
+ const ext = extension.startsWith(".") ? extension : `.${extension}`;
275
+ return `${slugify(family)}-${weight}-${style}-${slugify(subset)}${ext}`;
276
+ }
277
+ function plannedFontExtension(font) {
278
+ if (font.provider === "local" && font.path && /\.\w+$/.test(font.path) && !font.path.endsWith("/")) return font.path.match(/(\.\w+)$/)?.[1] ?? ".woff2";
279
+ return ".woff2";
280
+ }
281
+ function planSelfHostedFaces(fonts) {
282
+ const faces = [];
283
+ for (const value of themeFontValues(fonts)) {
284
+ if (!isThemeWebFont(value) || !value.selfHost) continue;
285
+ const font = normalizeWebFont(value);
286
+ const extension = plannedFontExtension(font);
287
+ for (const weight of font.weights) for (const style of font.styles) for (const subset of font.subsets) faces.push({
288
+ family: font.family,
289
+ weight,
290
+ style,
291
+ subset,
292
+ display: font.display,
293
+ preload: shouldPreload(font.preload, weight),
294
+ provider: font.provider,
295
+ path: font.path,
296
+ fileName: plannedFontFileName(font.family, weight, style, subset, extension),
297
+ unicodeRange: font.unicodeRange
298
+ });
299
+ }
300
+ const unique = /* @__PURE__ */ new Map();
301
+ for (const face of faces) {
302
+ const existing = unique.get(face.fileName);
303
+ if (existing) existing.preload ||= face.preload;
304
+ else unique.set(face.fileName, face);
305
+ }
306
+ return [...unique.values()];
307
+ }
308
+ function themeFontHeadHtml(fonts, base) {
309
+ const faces = planSelfHostedFaces(fonts);
310
+ if (faces.length === 0) return "";
311
+ const root = normalizeBasePath(base);
312
+ const tags = [`<link rel="stylesheet" href="${root}${FONT_ASSET_DIR}/${FONT_CSS_NAME}">`];
313
+ for (const face of faces) {
314
+ if (!face.preload) continue;
315
+ tags.push(`<link rel="preload" href="${root}${FONT_ASSET_DIR}/${face.fileName}" as="font" type="${fontMime(face.fileName)}" crossorigin>`);
316
+ }
317
+ return tags.join("\n");
318
+ }
319
+ function withSelfHostedFontHead(embed, fonts, base) {
320
+ const extra = themeFontHeadHtml(fonts, base);
321
+ const keys = Object.keys(embed);
322
+ if (!extra && keys.length === 0) return;
323
+ if (!extra) return embed;
324
+ return {
325
+ ...embed,
326
+ head: embed.head ? `${extra}\n${embed.head}` : extra
327
+ };
328
+ }
329
+ /** Copy self-hosted faces into `outDir` and write `@font-face` CSS. */
330
+ async function writeSelfHostedThemeFonts(options) {
331
+ const faces = planSelfHostedFaces(options.fonts);
332
+ if (faces.length === 0) return [];
333
+ const acquired = await acquireSelfHostedFaces(faces, options);
334
+ const destDir = (0, node_path.join)(options.outDir, FONT_ASSET_DIR);
335
+ await (0, node_fs_promises.mkdir)(destDir, { recursive: true });
336
+ const written = [];
337
+ for (const face of acquired) {
338
+ const dest = (0, node_path.join)(destDir, face.fileName);
339
+ await (0, node_fs_promises.writeFile)(dest, face.bytes);
340
+ written.push(dest);
341
+ }
342
+ const cssPath = (0, node_path.join)(destDir, FONT_CSS_NAME);
343
+ await (0, node_fs_promises.writeFile)(cssPath, renderFontFaceCss(acquired), "utf8");
344
+ written.push(cssPath);
345
+ return written;
346
+ }
347
+ function themeFontValues(fonts) {
348
+ return [
349
+ fonts.sans,
350
+ fonts.mono,
351
+ ...Object.values(fonts.named ?? {})
352
+ ].filter((value) => value !== void 0);
353
+ }
354
+ function normalizeWebFont(font) {
355
+ const provider = font.provider ?? (font.path ? "local" : "google");
356
+ if (provider === "local" && !font.path) throw new Error(`Theme font "${font.family}" uses provider "local" but has no path.`);
357
+ return {
358
+ ...font,
359
+ family: font.family.trim(),
360
+ provider,
361
+ weights: font.weights?.length ? font.weights : [400],
362
+ styles: font.styles?.length ? font.styles : ["normal"],
363
+ subsets: font.subsets?.length ? font.subsets : ["latin"],
364
+ display: font.display ?? "swap"
365
+ };
366
+ }
367
+ function shouldPreload(preload, weight) {
368
+ if (preload === true) return true;
369
+ return Array.isArray(preload) && preload.includes(weight);
370
+ }
371
+ function slugify(value) {
372
+ return value.trim().toLowerCase().replace(/['"]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "font";
373
+ }
374
+ //#endregion
375
+ //#region src/icons-css.ts
376
+ /**
377
+ * Resolve Iconify JSON collections and emit CSS-mask rules.
378
+ *
379
+ * Collections come from installed `@iconify-json/*` or `@iconify/json`.
380
+ * Tests supply fixture JSON under the project `root` — no network.
381
+ */
382
+ function iconCssSelector(prefix, name) {
383
+ return `.icon-\\[${prefix}--${name}\\]`;
384
+ }
385
+ function resolveIconCollectionPath(prefix, root) {
386
+ const files = [(0, node_path.join)(root, "node_modules", "@iconify-json", prefix, "icons.json"), (0, node_path.join)(root, "node_modules", "@iconify", "json", "json", `${prefix}.json`)];
387
+ for (const file of files) if ((0, node_fs.existsSync)(file)) return file;
388
+ return resolveViaNode(prefix, root);
389
+ }
390
+ function resolveViaNode(prefix, root) {
391
+ try {
392
+ return (0, node_module.createRequire)((0, node_path.join)(root, "package.json")).resolve(`@iconify-json/${prefix}/icons.json`);
393
+ } catch {
394
+ try {
395
+ return (0, node_module.createRequire)((0, node_path.join)(root, "package.json")).resolve(`@iconify/json/json/${prefix}.json`);
396
+ } catch {
397
+ return;
398
+ }
399
+ }
400
+ }
401
+ async function loadIconCollection(prefix, root) {
402
+ const path = resolveIconCollectionPath(prefix, root);
403
+ if (!path) return;
404
+ const raw = await (0, node_fs_promises.readFile)(path, "utf8");
405
+ return JSON.parse(raw);
406
+ }
407
+ function lookupIcon(collection, name) {
408
+ const fallback = collection.width ?? 16;
409
+ const fallbackH = collection.height ?? fallback;
410
+ const direct = collection.icons[name];
411
+ if (direct) return {
412
+ body: direct.body,
413
+ width: direct.width ?? fallback,
414
+ height: direct.height ?? fallbackH
415
+ };
416
+ const alias = collection.aliases?.[name];
417
+ if (!alias) return;
418
+ const parent = collection.icons[alias.parent];
419
+ if (!parent) return;
420
+ return {
421
+ body: parent.body,
422
+ width: alias.width ?? parent.width ?? fallback,
423
+ height: alias.height ?? parent.height ?? fallbackH
424
+ };
425
+ }
426
+ function isMulticolorIcon(body) {
427
+ return /(?:fill|stroke)=["'](?!currentColor|none)[^"']+["']/i.test(body);
428
+ }
429
+ function renderIconsCss(icons) {
430
+ return `/* ox-content self-hosted Iconify icons */\n${icons.map(renderOneIconCss).join("\n")}\n`;
431
+ }
432
+ function renderOneIconCss(icon) {
433
+ const selector = iconCssSelector(icon.prefix, icon.name);
434
+ const url = svgToDataUrl(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${icon.width} ${icon.height}">${maskBody(icon)}</svg>`);
435
+ if (icon.multicolor) return `${selector}{display:inline-block;width:1em;height:1em;background-color:transparent;background-image:${url};background-repeat:no-repeat;background-size:100% 100%}`;
436
+ return `${selector}{display:inline-block;width:1em;height:1em;background-color:currentColor;-webkit-mask-image:${url};mask-image:${url};-webkit-mask-repeat:no-repeat;mask-repeat:no-repeat;-webkit-mask-size:100% 100%;mask-size:100% 100%}`;
437
+ }
438
+ function maskBody(icon) {
439
+ return icon.multicolor ? icon.body : icon.body.replace(/currentColor/g, "black");
440
+ }
441
+ function svgToDataUrl(svg) {
442
+ return `url("data:image/svg+xml,${svg.replace(/"/g, "'").replace(/%/g, "%25").replace(/#/g, "%23").replace(/</g, "%3C").replace(/>/g, "%3E").replace(/\s+/g, " ")}")`;
443
+ }
444
+ //#endregion
445
+ //#region src/icons.ts
446
+ /**
447
+ * Opt-in self-hosted Iconify CSS for used and safelisted icons.
448
+ *
449
+ * Collection lookup stays on disk (`@iconify-json/*` / `@iconify/json`).
450
+ * Theme embed injection composes with self-hosted font `<link>` tags.
451
+ */
452
+ const ICON_ASSET_DIR = "__ox_icons__";
453
+ const ICON_CSS_NAME = "icons.css";
454
+ const URL_SCHEMES = /* @__PURE__ */ new Set([
455
+ "http",
456
+ "https",
457
+ "data",
458
+ "mailto",
459
+ "file",
460
+ "javascript",
461
+ "vscode",
462
+ "tel",
463
+ "blob"
464
+ ]);
465
+ const COLON_ICON = /(?<![A-Za-z0-9_-])([a-z][a-z0-9-]*):([a-z0-9][a-z0-9-]*)/gi;
466
+ const CLASS_ICON = /icon-\[([a-z][a-z0-9-]*)--([a-z0-9][a-z0-9-]*)\]/gi;
467
+ const ICON_FIELD = /(?:^|[\s,{])icon\s*:\s*["']([^"']+)["']/g;
468
+ function resolveIconsOptions(value) {
469
+ if (!value) return {
470
+ enabled: false,
471
+ mode: "css-mask",
472
+ syntax: "unocss",
473
+ include: [],
474
+ safelist: []
475
+ };
476
+ if (value === true) return {
477
+ enabled: true,
478
+ mode: "css-mask",
479
+ syntax: "unocss",
480
+ include: [],
481
+ safelist: []
482
+ };
483
+ return {
484
+ enabled: true,
485
+ mode: value.mode ?? "css-mask",
486
+ syntax: value.syntax ?? "unocss",
487
+ include: value.include ?? [],
488
+ safelist: value.safelist ?? []
489
+ };
490
+ }
491
+ function parseIconName(value) {
492
+ const trimmed = value.trim();
493
+ const classMatch = /^icon-\[(.+)\]$/.exec(trimmed);
494
+ if (classMatch?.[1]) {
495
+ const inner = classMatch[1];
496
+ const sep = inner.indexOf("--");
497
+ if (sep <= 0) return;
498
+ return tokenPair(inner.slice(0, sep), inner.slice(sep + 2));
499
+ }
500
+ const sep = trimmed.indexOf(":");
501
+ if (sep <= 0) return;
502
+ return tokenPair(trimmed.slice(0, sep), trimmed.slice(sep + 1));
503
+ }
504
+ function normalizeIconName(value) {
505
+ const parsed = parseIconName(value);
506
+ return parsed ? `${parsed.prefix}:${parsed.name}` : value;
507
+ }
508
+ function tokenPair(prefix, name) {
509
+ if (!/^[a-z][a-z0-9-]*$/i.test(prefix) || !/^[a-z0-9][a-z0-9-]*$/i.test(name)) return;
510
+ if (URL_SCHEMES.has(prefix.toLowerCase())) return;
511
+ return {
512
+ prefix,
513
+ name
514
+ };
515
+ }
516
+ function collectIconNamesFromText(text, into = /* @__PURE__ */ new Set()) {
517
+ COLON_ICON.lastIndex = 0;
518
+ for (const match of text.matchAll(COLON_ICON)) addParsed(into, match[1], match[2]);
519
+ CLASS_ICON.lastIndex = 0;
520
+ for (const match of text.matchAll(CLASS_ICON)) addParsed(into, match[1], match[2]);
521
+ return into;
522
+ }
523
+ function collectIconFieldNames(text, into = /* @__PURE__ */ new Set()) {
524
+ ICON_FIELD.lastIndex = 0;
525
+ for (const match of text.matchAll(ICON_FIELD)) {
526
+ const parsed = match[1] ? parseIconName(match[1]) : void 0;
527
+ if (parsed) into.add(`${parsed.prefix}:${parsed.name}`);
528
+ }
529
+ return into;
530
+ }
531
+ function collectThemeIconNames(socialLinks) {
532
+ if (!Array.isArray(socialLinks)) return [];
533
+ const names = [];
534
+ for (const link of socialLinks) {
535
+ if (!link || typeof link !== "object") continue;
536
+ const icon = link.icon;
537
+ if (typeof icon === "string" && parseIconName(icon)) names.push(normalizeIconName(icon));
538
+ }
539
+ return names;
540
+ }
541
+ function addParsed(into, prefix, name) {
542
+ if (!prefix || !name) return;
543
+ const parsed = tokenPair(prefix, name);
544
+ if (parsed) into.add(`${parsed.prefix}:${parsed.name}`);
545
+ }
546
+ function iconStylesheetHref(base) {
547
+ return `${normalizeBasePath(base)}${ICON_ASSET_DIR}/${ICON_CSS_NAME}`;
548
+ }
549
+ function iconStylesheetLink(base) {
550
+ return `<link rel="stylesheet" href="${iconStylesheetHref(base)}">`;
551
+ }
552
+ function withSelfHostedIconHead(embed, enabled, base) {
553
+ if (!enabled) return embed;
554
+ const extra = iconStylesheetLink(base);
555
+ if (!embed) return { head: extra };
556
+ return {
557
+ ...embed,
558
+ head: embed.head ? `${extra}\n${embed.head}` : extra
559
+ };
560
+ }
561
+ /** Copy resolved icon CSS into `outDir`. Missing collections or names become errors. */
562
+ async function writeSelfHostedIcons(input) {
563
+ if (!input.options.enabled) return {
564
+ files: [],
565
+ errors: [],
566
+ names: []
567
+ };
568
+ const names = await collectResolvedIconNames(input);
569
+ const { icons, errors } = await resolveIconBodies(names, input.root);
570
+ const destDir = (0, node_path.join)(input.outDir, ICON_ASSET_DIR);
571
+ await (0, node_fs_promises.mkdir)(destDir, { recursive: true });
572
+ const cssPath = (0, node_path.join)(destDir, ICON_CSS_NAME);
573
+ await (0, node_fs_promises.writeFile)(cssPath, renderIconsCss(icons), "utf8");
574
+ return {
575
+ files: [cssPath],
576
+ errors,
577
+ names
578
+ };
579
+ }
580
+ async function collectResolvedIconNames(input) {
581
+ const names = /* @__PURE__ */ new Set();
582
+ for (const item of input.options.safelist) addName(names, item);
583
+ for (const item of collectThemeIconNames(input.socialLinks)) names.add(item);
584
+ const { names: includeNames, globs } = partitionInclude(input.options.include);
585
+ for (const item of includeNames) names.add(item);
586
+ for (const pattern of globs) {
587
+ const files = await (0, glob.glob)(pattern, {
588
+ cwd: input.root,
589
+ nodir: true,
590
+ absolute: true,
591
+ ignore: ["**/node_modules/**"]
592
+ });
593
+ for (const file of files) collectIconNamesFromText(await (0, node_fs_promises.readFile)(file, "utf8"), names);
594
+ }
595
+ if (input.srcDir) {
596
+ const files = await (0, glob.glob)("**/*.{md,mdx,markdown}", {
597
+ cwd: input.srcDir,
598
+ nodir: true,
599
+ absolute: true,
600
+ ignore: ["**/node_modules/**"]
601
+ });
602
+ for (const file of files) collectIconFieldNames(await (0, node_fs_promises.readFile)(file, "utf8"), names);
603
+ }
604
+ return [...names].sort();
605
+ }
606
+ function partitionInclude(include) {
607
+ const names = [];
608
+ const globs = [];
609
+ for (const entry of include) if (parseIconName(entry)) names.push(normalizeIconName(entry));
610
+ else globs.push(entry);
611
+ return {
612
+ names,
613
+ globs
614
+ };
615
+ }
616
+ function addName(into, value) {
617
+ const parsed = parseIconName(value);
618
+ if (parsed) into.add(`${parsed.prefix}:${parsed.name}`);
619
+ }
620
+ async function resolveIconBodies(names, root) {
621
+ const icons = [];
622
+ const errors = [];
623
+ const collections = /* @__PURE__ */ new Map();
624
+ for (const id of names) {
625
+ const parsed = parseIconName(id);
626
+ if (!parsed) continue;
627
+ if (!collections.has(parsed.prefix)) collections.set(parsed.prefix, await loadIconCollection(parsed.prefix, root));
628
+ const collection = collections.get(parsed.prefix);
629
+ if (!collection) {
630
+ errors.push(`[ox-content] icons: missing Iconify collection "${parsed.prefix}". Install @iconify-json/${parsed.prefix} or @iconify/json.`);
631
+ continue;
632
+ }
633
+ const found = lookupIcon(collection, parsed.name);
634
+ if (!found) {
635
+ errors.push(`[ox-content] icons: missing icon "${parsed.prefix}:${parsed.name}" in collection "${parsed.prefix}".`);
636
+ continue;
637
+ }
638
+ icons.push({
639
+ prefix: parsed.prefix,
640
+ name: parsed.name,
641
+ body: found.body,
642
+ width: found.width,
643
+ height: found.height,
644
+ multicolor: isMulticolorIcon(found.body)
645
+ });
646
+ }
647
+ return {
648
+ icons,
649
+ errors
650
+ };
651
+ }
652
+ //#endregion
653
+ //#region src/header-chrome.ts
654
+ /** `false` or omitted stays off. `true` or `{}` enables default flag reading. */
655
+ function resolvePageChromeOption(value) {
656
+ return value === true || typeof value === "object" && value !== null;
657
+ }
658
+ /** Reads hide flags from frontmatter. Non-boolean values are ignored. */
659
+ function parsePageChromeFlags(frontmatter) {
660
+ return {
661
+ sidebar: readBool(frontmatter.sidebar),
662
+ outline: readBool(frontmatter.outline),
663
+ aside: readBool(frontmatter.aside),
664
+ footer: readBool(frontmatter.footer),
665
+ navbar: readBool(frontmatter.navbar),
666
+ lastUpdated: readBool(frontmatter.lastUpdated),
667
+ editLink: readBool(frontmatter.editLink)
668
+ };
669
+ }
670
+ function readBool(value) {
671
+ return typeof value === "boolean" ? value : void 0;
672
+ }
673
+ /**
674
+ * Picks the exact locale, its language, the default locale, then the first
675
+ * non-empty own string in declaration order.
676
+ */
677
+ function resolveLocaleLabel(text, locale, defaultLocale) {
678
+ if (typeof text === "string") return text;
679
+ const candidates = [
680
+ locale,
681
+ locale?.split("-")[0],
682
+ defaultLocale,
683
+ defaultLocale?.split("-")[0]
684
+ ];
685
+ for (const candidate of candidates) {
686
+ if (!candidate || !Object.hasOwn(text, candidate)) continue;
687
+ const value = text[candidate];
688
+ if (typeof value === "string" && value.length > 0) return value;
689
+ }
690
+ for (const value of Object.values(text)) if (typeof value === "string" && value.length > 0) return value;
691
+ return "";
692
+ }
693
+ /** Resolves locale maps so NAPI always receives string labels. */
694
+ function resolveHeaderNavItems(items, locale, defaultLocale) {
695
+ if (!items?.length) return;
696
+ return items.map((item) => ({
697
+ text: resolveLocaleLabel(item.text, locale, defaultLocale),
698
+ link: item.link,
699
+ items: resolveHeaderNavItems(item.items, locale, defaultLocale)
700
+ }));
701
+ }
702
+ //#endregion
70
703
  //#region src/theme-tokens.ts
71
704
  const TOKEN_PREFIX = "--octc-";
72
705
  const TOKEN_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
@@ -99,16 +732,15 @@ function assertTokenName(name) {
99
732
  //#endregion
100
733
  //#region src/theme.ts
101
734
  /**
102
- * Theme API for ox-content SSG
103
- *
104
- * Provides VitePress-like theming with default theme + customization.
105
- */
106
- /**
107
735
  * Default theme configuration.
108
736
  * Based on the current ox-content SSG styles.
109
737
  */
110
738
  const defaultTheme = {
111
739
  name: "default",
740
+ viewTransitions: true,
741
+ aside: false,
742
+ breadcrumbs: false,
743
+ headingPermalink: "hover",
112
744
  colors: {
113
745
  primary: "#4f6fae",
114
746
  primaryHover: "#425f96",
@@ -246,12 +878,18 @@ function resolveTheme(config) {
246
878
  const merged = mergeThemes(...chain.map(withDerivedCodeBackgroundTop));
247
879
  return {
248
880
  name: merged.name ?? "custom",
881
+ viewTransitions: merged.viewTransitions ?? defaultTheme.viewTransitions ?? true,
882
+ aside: merged.aside ?? defaultTheme.aside ?? false,
883
+ breadcrumbs: resolveThemeFlag(merged.breadcrumbs),
884
+ headingPermalink: merged.headingPermalink === "always" ? "always" : "hover",
249
885
  colors: merged.colors ?? defaultTheme.colors,
250
886
  darkColors: merged.darkColors ?? defaultTheme.darkColors,
251
887
  fonts: merged.fonts ?? defaultTheme.fonts,
252
888
  entryPage: merged.entryPage ?? defaultTheme.entryPage,
253
889
  layout: merged.layout ?? defaultTheme.layout,
254
890
  header: merged.header ?? defaultTheme.header,
891
+ nav: merged.nav,
892
+ announcement: merged.announcement,
255
893
  footer: merged.footer ?? defaultTheme.footer,
256
894
  socialLinks: merged.socialLinks ?? defaultTheme.socialLinks,
257
895
  sidebar: merged.sidebar ?? [],
@@ -296,9 +934,13 @@ function withDerivedCodeBackgroundTop(theme) {
296
934
  /**
297
935
  * Converts resolved theme to the format expected by Rust NAPI.
298
936
  */
299
- function themeToNapi(theme) {
937
+ function themeToNapi(theme, locale, base, iconsEnabled = false) {
300
938
  const socialLinks = socialLinksToNapi(theme.socialLinks);
301
939
  return {
940
+ viewTransitions: theme.viewTransitions,
941
+ aside: theme.aside,
942
+ breadcrumbs: theme.breadcrumbs,
943
+ headingPermalink: theme.headingPermalink,
302
944
  colors: theme.colors.primary ? {
303
945
  primary: theme.colors.primary,
304
946
  primaryHover: theme.colors.primaryHover,
@@ -323,10 +965,7 @@ function themeToNapi(theme) {
323
965
  codeBackgroundTop: theme.darkColors.codeBackgroundTop,
324
966
  codeText: theme.darkColors.codeText
325
967
  } : void 0,
326
- fonts: theme.fonts.sans ? {
327
- sans: theme.fonts.sans,
328
- mono: theme.fonts.mono
329
- } : void 0,
968
+ fonts: flattenThemeFonts(theme.fonts),
330
969
  entryPage: theme.entryPage.mode ? { mode: theme.entryPage.mode } : void 0,
331
970
  layout: theme.layout.sidebarWidth ? {
332
971
  sidebarWidth: theme.layout.sidebarWidth,
@@ -341,12 +980,14 @@ function themeToNapi(theme) {
341
980
  logoWidth: theme.header.logoWidth,
342
981
  logoHeight: theme.header.logoHeight
343
982
  } : void 0,
983
+ nav: resolveHeaderNavItems(theme.nav, locale),
984
+ announcement: theme.announcement?.text ? theme.announcement : void 0,
344
985
  footer: theme.footer.message || theme.footer.copyright ? {
345
986
  message: theme.footer.message,
346
987
  copyright: theme.footer.copyright
347
988
  } : void 0,
348
989
  socialLinks,
349
- embed: Object.keys(theme.embed).length > 0 ? theme.embed : void 0,
990
+ embed: withSelfHostedIconHead(withSelfHostedFontHead(theme.embed, theme.fonts, base), iconsEnabled, base),
350
991
  css: themeCss(theme) || void 0,
351
992
  js: theme.js || void 0
352
993
  };
@@ -356,9 +997,9 @@ function themeToNapi(theme) {
356
997
  * land after the typed color variables the Rust renderer emits.
357
998
  */
358
999
  function themeCss(theme) {
359
- const tokenCss = tokensToCss(theme.tokens, theme.darkTokens);
360
- if (!tokenCss) return theme.css;
361
- return theme.css ? `${tokenCss}\n${theme.css}` : tokenCss;
1000
+ const prefix = [tokensToCss(theme.tokens, theme.darkTokens), namedFontVarsCss(theme.fonts)].filter(Boolean).join("\n");
1001
+ if (!prefix) return theme.css;
1002
+ return theme.css ? `${prefix}\n${theme.css}` : prefix;
362
1003
  }
363
1004
  function socialLinksToNapi(links) {
364
1005
  if (Array.isArray(links)) {
@@ -378,6 +1019,9 @@ function socialLinksToNapi(links) {
378
1019
  discord: links.discord
379
1020
  } : void 0;
380
1021
  }
1022
+ function resolveThemeFlag(value) {
1023
+ return value === true || typeof value === "object" && value !== null;
1024
+ }
381
1025
  //#endregion
382
1026
  //#region src/vitepress.ts
383
1027
  function isRecord(value) {
@@ -717,6 +1361,36 @@ Object.defineProperty(exports, "normalizeVitePressFrontmatter", {
717
1361
  return normalizeVitePressFrontmatter;
718
1362
  }
719
1363
  });
1364
+ Object.defineProperty(exports, "parsePageChromeFlags", {
1365
+ enumerable: true,
1366
+ get: function() {
1367
+ return parsePageChromeFlags;
1368
+ }
1369
+ });
1370
+ Object.defineProperty(exports, "resolveHeaderNavItems", {
1371
+ enumerable: true,
1372
+ get: function() {
1373
+ return resolveHeaderNavItems;
1374
+ }
1375
+ });
1376
+ Object.defineProperty(exports, "resolveIconsOptions", {
1377
+ enumerable: true,
1378
+ get: function() {
1379
+ return resolveIconsOptions;
1380
+ }
1381
+ });
1382
+ Object.defineProperty(exports, "resolveLocaleLabel", {
1383
+ enumerable: true,
1384
+ get: function() {
1385
+ return resolveLocaleLabel;
1386
+ }
1387
+ });
1388
+ Object.defineProperty(exports, "resolvePageChromeOption", {
1389
+ enumerable: true,
1390
+ get: function() {
1391
+ return resolvePageChromeOption;
1392
+ }
1393
+ });
720
1394
  Object.defineProperty(exports, "resolveTheme", {
721
1395
  enumerable: true,
722
1396
  get: function() {
@@ -729,5 +1403,23 @@ Object.defineProperty(exports, "themeToNapi", {
729
1403
  return themeToNapi;
730
1404
  }
731
1405
  });
1406
+ Object.defineProperty(exports, "withSelfHostedIconHead", {
1407
+ enumerable: true,
1408
+ get: function() {
1409
+ return withSelfHostedIconHead;
1410
+ }
1411
+ });
1412
+ Object.defineProperty(exports, "writeSelfHostedIcons", {
1413
+ enumerable: true,
1414
+ get: function() {
1415
+ return writeSelfHostedIcons;
1416
+ }
1417
+ });
1418
+ Object.defineProperty(exports, "writeSelfHostedThemeFonts", {
1419
+ enumerable: true,
1420
+ get: function() {
1421
+ return writeSelfHostedThemeFonts;
1422
+ }
1423
+ });
732
1424
 
733
1425
  //# sourceMappingURL=vitepress.cjs.map