@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.
@@ -1,4 +1,9 @@
1
1
  import { createRequire } from "node:module";
2
+ import { existsSync } from "node:fs";
3
+ import { isAbsolute, join, resolve } from "node:path";
4
+ import { mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
5
+ import { createHash } from "node:crypto";
6
+ import { glob } from "glob";
2
7
  //#region src/napi.ts
3
8
  const requireNapi = createRequire(import.meta.url);
4
9
  function getDefaultExport(value) {
@@ -29,6 +34,632 @@ function importNapiModuleSync() {
29
34
  }
30
35
  }
31
36
  //#endregion
37
+ //#region src/theme-fonts-acquire.ts
38
+ /**
39
+ * Resolve self-hosted faces from a local file / `@fontsource` directory or
40
+ * Google Fonts. Downloads are cached; tests inject `fetch` so CI never hits
41
+ * the network.
42
+ */
43
+ const GOOGLE_CSS = "https://fonts.googleapis.com/css2";
44
+ 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";
45
+ const ALLOWED_HOSTS = /* @__PURE__ */ new Set(["fonts.googleapis.com", "fonts.gstatic.com"]);
46
+ function fontMime(fileName) {
47
+ if (fileName.endsWith(".woff")) return "font/woff";
48
+ if (fileName.endsWith(".ttf")) return "font/ttf";
49
+ if (fileName.endsWith(".otf")) return "font/otf";
50
+ return "font/woff2";
51
+ }
52
+ function renderFontFaceCss(faces) {
53
+ return faces.map((face) => {
54
+ const range = face.unicodeRange ? `\n unicode-range: ${face.unicodeRange};` : "";
55
+ const fileName = face.fileName;
56
+ const format = fileName.endsWith(".woff") ? "woff" : fileName.endsWith(".ttf") ? "truetype" : fileName.endsWith(".otf") ? "opentype" : "woff2";
57
+ return `@font-face {
58
+ font-family: ${/^[a-zA-Z_-][\w-]*$/.test(face.family) ? face.family : `"${face.family.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`};
59
+ font-style: ${face.style};
60
+ font-weight: ${face.weight};
61
+ font-display: ${face.display};
62
+ src: url(./${fileName}) format("${format}");${range}
63
+ }`;
64
+ }).join("\n\n");
65
+ }
66
+ function resolveFontCacheDir(root, cacheDir) {
67
+ return cacheDir ?? join(root, "node_modules", ".cache", "ox-content", "fonts");
68
+ }
69
+ async function acquireSelfHostedFaces(faces, options) {
70
+ const cacheDir = resolveFontCacheDir(options.root, options.cacheDir);
71
+ const acquired = [];
72
+ for (const face of faces) acquired.push(face.provider === "local" ? await acquireLocalFace(face, options.root) : await acquireGoogleFace(face, cacheDir, options.fetch ?? fetch));
73
+ return acquired;
74
+ }
75
+ async function acquireLocalFace(face, root) {
76
+ if (!face.path) throw new Error(`Theme font "${face.family}" uses provider "local" but has no path.`);
77
+ if (face.path.includes("\0")) throw new Error(`Theme font "${face.family}" path must not contain NUL.`);
78
+ const resolved = resolveLocalPath(root, face.path);
79
+ const info = await stat(resolved).catch(() => void 0);
80
+ if (!info) throw new Error(`Theme font "${face.family}" was not found at ${resolved}.`);
81
+ const file = info.isDirectory() ? await findDirectoryFont(resolved, face) : resolved;
82
+ return {
83
+ ...face,
84
+ bytes: await readFile(file)
85
+ };
86
+ }
87
+ function resolveLocalPath(root, spec) {
88
+ if (isAbsolute(spec)) return spec;
89
+ if (spec.startsWith("@") || !spec.startsWith(".")) return resolve(root, "node_modules", spec);
90
+ return resolve(root, spec);
91
+ }
92
+ async function findDirectoryFont(dir, face) {
93
+ const filesDir = existsSync(join(dir, "files")) ? join(dir, "files") : dir;
94
+ const names = (await readdir(filesDir)).filter((name) => /\.(woff2|woff|ttf|otf)$/i.test(name));
95
+ const weight = String(face.weight);
96
+ const wantItalic = face.style === "italic";
97
+ const match = names.find((name) => {
98
+ const lower = name.toLowerCase();
99
+ const hasWeight = lower.includes(weight);
100
+ const italic = lower.includes("italic");
101
+ const subset = face.subset === "all" || lower.includes(face.subset.toLowerCase());
102
+ return hasWeight && subset && italic === wantItalic;
103
+ });
104
+ const fallback = names[0];
105
+ const chosen = match ?? (names.length === 1 ? fallback : void 0);
106
+ if (!chosen) throw new Error(`Theme font "${face.family}" has no ${face.weight} ${face.style} ${face.subset} file in ${filesDir}.`);
107
+ return join(filesDir, chosen);
108
+ }
109
+ async function acquireGoogleFace(face, cacheDir, fetchFn) {
110
+ 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));
111
+ if (!parsed) throw new Error(`Google Fonts CSS for "${face.family}" has no ${face.weight} ${face.style} ${face.subset} face.`);
112
+ const bytes = await cachedBytes(parsed.url, cacheDir, fetchFn, ".woff2");
113
+ return {
114
+ ...face,
115
+ bytes,
116
+ unicodeRange: face.unicodeRange ?? parsed.unicodeRange
117
+ };
118
+ }
119
+ function googleCssUrl(face) {
120
+ const italic = face.style === "italic";
121
+ const axis = italic ? "ital,wght" : "wght";
122
+ const spec = italic ? `1,${face.weight}` : `${face.weight}`;
123
+ const family = `${face.family.replace(/ /g, "+")}:${axis}@${spec}`;
124
+ return `${GOOGLE_CSS}?family=${family}&display=${encodeURIComponent(face.display)}`;
125
+ }
126
+ function parseGoogleCss(css) {
127
+ const faces = [];
128
+ const blocks = css.matchAll(/\/\*\s*([a-z0-9-]+)\s*\*\/\s*@font-face\s*\{([^}]+)\}/gi);
129
+ for (const match of blocks) {
130
+ const parsed = parseGoogleBlock(match[2] ?? "", match[1]?.toLowerCase() ?? "");
131
+ if (parsed) faces.push(parsed);
132
+ }
133
+ if (faces.length === 0) for (const match of css.matchAll(/@font-face\s*\{([^}]+)\}/gi)) {
134
+ const parsed = parseGoogleBlock(match[1] ?? "", "");
135
+ if (parsed) faces.push(parsed);
136
+ }
137
+ return faces;
138
+ }
139
+ function parseGoogleBlock(body, subset) {
140
+ const url = body.match(/url\((['"]?)(https?:\/\/[^'")]+)\1\)/)?.[2];
141
+ if (!url || !isAllowedFontUrl(url)) return;
142
+ return {
143
+ subset,
144
+ weight: Number(body.match(/font-weight:\s*(\d+)/i)?.[1] ?? 400),
145
+ style: /font-style:\s*italic/i.test(body) ? "italic" : "normal",
146
+ url,
147
+ unicodeRange: body.match(/unicode-range:\s*([^;]+)/i)?.[1]?.trim()
148
+ };
149
+ }
150
+ function isAllowedFontUrl(url) {
151
+ try {
152
+ const parsed = new URL(url);
153
+ return parsed.protocol === "https:" && ALLOWED_HOSTS.has(parsed.hostname);
154
+ } catch {
155
+ return false;
156
+ }
157
+ }
158
+ async function cachedText(url, cacheDir, fetchFn, ext) {
159
+ const bytes = await cachedBytes(url, cacheDir, fetchFn, ext);
160
+ return new TextDecoder().decode(bytes);
161
+ }
162
+ async function cachedBytes(url, cacheDir, fetchFn, ext) {
163
+ if (!isAllowedFontUrl(url)) throw new Error(`Refusing to download font from ${url}.`);
164
+ await mkdir(cacheDir, { recursive: true });
165
+ const dest = join(cacheDir, `${createHash("sha256").update(url).digest("hex").slice(0, 16)}${ext}`);
166
+ if (existsSync(dest)) return readFile(dest);
167
+ const response = await fetchFn(url, { headers: { "User-Agent": GOOGLE_UA } });
168
+ if (!response.ok) throw new Error(`Failed to download ${url}: ${response.status}`);
169
+ const bytes = new Uint8Array(await response.arrayBuffer());
170
+ await writeFile(dest, bytes);
171
+ return bytes;
172
+ }
173
+ //#endregion
174
+ //#region src/theme-fonts.ts
175
+ /**
176
+ * Opt-in web-font objects for `theme.fonts`, plus SSG self-host emission.
177
+ *
178
+ * NAPI still receives flattened CSS stacks (`JsThemeFonts`). File acquisition
179
+ * and `@font-face` generation stay in TypeScript so other PRs can keep landing
180
+ * NAPI theme-type changes independently.
181
+ */
182
+ const FONT_ASSET_DIR = "__ox_fonts__";
183
+ const FONT_CSS_NAME = "fonts.css";
184
+ const NAMED_FONT_PATTERN = /^[a-z][a-z0-9-]*$/;
185
+ const GENERIC_FOR = {
186
+ sans: "sans-serif",
187
+ mono: "monospace",
188
+ named: "sans-serif"
189
+ };
190
+ function isThemeWebFont(value) {
191
+ return typeof value === "object" && value !== null && typeof value.family === "string";
192
+ }
193
+ /** CSS `font-family` identifier; quotes names that are not a single ident. */
194
+ function cssFamilyName(family) {
195
+ const trimmed = family.trim();
196
+ if (trimmed.startsWith("\"") && trimmed.endsWith("\"") || trimmed.startsWith("'") && trimmed.endsWith("'")) return trimmed;
197
+ if (/^[a-zA-Z_-][\w-]*$/.test(trimmed)) return trimmed;
198
+ return `"${trimmed.replace(/\\/g, "\\\\").replace(/"/g, "\\\"")}"`;
199
+ }
200
+ function flattenThemeFont(value, generic) {
201
+ if (value === void 0) return;
202
+ if (typeof value === "string") return value;
203
+ const fallbacks = value.fallbacks?.length ? value.fallbacks.join(", ") : generic;
204
+ return `${cssFamilyName(value.family)}, ${fallbacks}`;
205
+ }
206
+ /** Flatten object fonts so `JsThemeFonts` stays `{ sans?: string; mono?: string }`. */
207
+ function flattenThemeFonts(fonts) {
208
+ const sans = flattenThemeFont(fonts.sans, GENERIC_FOR.sans);
209
+ const mono = flattenThemeFont(fonts.mono, GENERIC_FOR.mono);
210
+ if (!sans && !mono) return;
211
+ return {
212
+ sans,
213
+ mono
214
+ };
215
+ }
216
+ function namedFontToken(name) {
217
+ 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").`);
218
+ return name;
219
+ }
220
+ /** Extra `--octc-font-*` variables for `fonts.named`. Roles stay in Rust theme CSS. */
221
+ function namedFontVarsCss(fonts) {
222
+ const entries = Object.entries(fonts.named ?? {});
223
+ if (entries.length === 0) return "";
224
+ return `:root {\n${entries.map(([name, value]) => {
225
+ const stack = flattenThemeFont(value, GENERIC_FOR.named);
226
+ return ` --octc-font-${namedFontToken(name)}: ${stack};`;
227
+ }).join("\n")}\n}`;
228
+ }
229
+ function normalizeBasePath(base) {
230
+ if (!base || base === "/") return "/";
231
+ return base.endsWith("/") ? base : `${base}/`;
232
+ }
233
+ function plannedFontFileName(family, weight, style, subset, extension) {
234
+ const ext = extension.startsWith(".") ? extension : `.${extension}`;
235
+ return `${slugify(family)}-${weight}-${style}-${slugify(subset)}${ext}`;
236
+ }
237
+ function plannedFontExtension(font) {
238
+ if (font.provider === "local" && font.path && /\.\w+$/.test(font.path) && !font.path.endsWith("/")) return font.path.match(/(\.\w+)$/)?.[1] ?? ".woff2";
239
+ return ".woff2";
240
+ }
241
+ function planSelfHostedFaces(fonts) {
242
+ const faces = [];
243
+ for (const value of themeFontValues(fonts)) {
244
+ if (!isThemeWebFont(value) || !value.selfHost) continue;
245
+ const font = normalizeWebFont(value);
246
+ const extension = plannedFontExtension(font);
247
+ for (const weight of font.weights) for (const style of font.styles) for (const subset of font.subsets) faces.push({
248
+ family: font.family,
249
+ weight,
250
+ style,
251
+ subset,
252
+ display: font.display,
253
+ preload: shouldPreload(font.preload, weight),
254
+ provider: font.provider,
255
+ path: font.path,
256
+ fileName: plannedFontFileName(font.family, weight, style, subset, extension),
257
+ unicodeRange: font.unicodeRange
258
+ });
259
+ }
260
+ const unique = /* @__PURE__ */ new Map();
261
+ for (const face of faces) {
262
+ const existing = unique.get(face.fileName);
263
+ if (existing) existing.preload ||= face.preload;
264
+ else unique.set(face.fileName, face);
265
+ }
266
+ return [...unique.values()];
267
+ }
268
+ function themeFontHeadHtml(fonts, base) {
269
+ const faces = planSelfHostedFaces(fonts);
270
+ if (faces.length === 0) return "";
271
+ const root = normalizeBasePath(base);
272
+ const tags = [`<link rel="stylesheet" href="${root}${FONT_ASSET_DIR}/${FONT_CSS_NAME}">`];
273
+ for (const face of faces) {
274
+ if (!face.preload) continue;
275
+ tags.push(`<link rel="preload" href="${root}${FONT_ASSET_DIR}/${face.fileName}" as="font" type="${fontMime(face.fileName)}" crossorigin>`);
276
+ }
277
+ return tags.join("\n");
278
+ }
279
+ function withSelfHostedFontHead(embed, fonts, base) {
280
+ const extra = themeFontHeadHtml(fonts, base);
281
+ const keys = Object.keys(embed);
282
+ if (!extra && keys.length === 0) return;
283
+ if (!extra) return embed;
284
+ return {
285
+ ...embed,
286
+ head: embed.head ? `${extra}\n${embed.head}` : extra
287
+ };
288
+ }
289
+ /** Copy self-hosted faces into `outDir` and write `@font-face` CSS. */
290
+ async function writeSelfHostedThemeFonts(options) {
291
+ const faces = planSelfHostedFaces(options.fonts);
292
+ if (faces.length === 0) return [];
293
+ const acquired = await acquireSelfHostedFaces(faces, options);
294
+ const destDir = join(options.outDir, FONT_ASSET_DIR);
295
+ await mkdir(destDir, { recursive: true });
296
+ const written = [];
297
+ for (const face of acquired) {
298
+ const dest = join(destDir, face.fileName);
299
+ await writeFile(dest, face.bytes);
300
+ written.push(dest);
301
+ }
302
+ const cssPath = join(destDir, FONT_CSS_NAME);
303
+ await writeFile(cssPath, renderFontFaceCss(acquired), "utf8");
304
+ written.push(cssPath);
305
+ return written;
306
+ }
307
+ function themeFontValues(fonts) {
308
+ return [
309
+ fonts.sans,
310
+ fonts.mono,
311
+ ...Object.values(fonts.named ?? {})
312
+ ].filter((value) => value !== void 0);
313
+ }
314
+ function normalizeWebFont(font) {
315
+ const provider = font.provider ?? (font.path ? "local" : "google");
316
+ if (provider === "local" && !font.path) throw new Error(`Theme font "${font.family}" uses provider "local" but has no path.`);
317
+ return {
318
+ ...font,
319
+ family: font.family.trim(),
320
+ provider,
321
+ weights: font.weights?.length ? font.weights : [400],
322
+ styles: font.styles?.length ? font.styles : ["normal"],
323
+ subsets: font.subsets?.length ? font.subsets : ["latin"],
324
+ display: font.display ?? "swap"
325
+ };
326
+ }
327
+ function shouldPreload(preload, weight) {
328
+ if (preload === true) return true;
329
+ return Array.isArray(preload) && preload.includes(weight);
330
+ }
331
+ function slugify(value) {
332
+ return value.trim().toLowerCase().replace(/['"]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "font";
333
+ }
334
+ //#endregion
335
+ //#region src/icons-css.ts
336
+ /**
337
+ * Resolve Iconify JSON collections and emit CSS-mask rules.
338
+ *
339
+ * Collections come from installed `@iconify-json/*` or `@iconify/json`.
340
+ * Tests supply fixture JSON under the project `root` — no network.
341
+ */
342
+ function iconCssSelector(prefix, name) {
343
+ return `.icon-\\[${prefix}--${name}\\]`;
344
+ }
345
+ function resolveIconCollectionPath(prefix, root) {
346
+ const files = [join(root, "node_modules", "@iconify-json", prefix, "icons.json"), join(root, "node_modules", "@iconify", "json", "json", `${prefix}.json`)];
347
+ for (const file of files) if (existsSync(file)) return file;
348
+ return resolveViaNode(prefix, root);
349
+ }
350
+ function resolveViaNode(prefix, root) {
351
+ try {
352
+ return createRequire(join(root, "package.json")).resolve(`@iconify-json/${prefix}/icons.json`);
353
+ } catch {
354
+ try {
355
+ return createRequire(join(root, "package.json")).resolve(`@iconify/json/json/${prefix}.json`);
356
+ } catch {
357
+ return;
358
+ }
359
+ }
360
+ }
361
+ async function loadIconCollection(prefix, root) {
362
+ const path = resolveIconCollectionPath(prefix, root);
363
+ if (!path) return;
364
+ const raw = await readFile(path, "utf8");
365
+ return JSON.parse(raw);
366
+ }
367
+ function lookupIcon(collection, name) {
368
+ const fallback = collection.width ?? 16;
369
+ const fallbackH = collection.height ?? fallback;
370
+ const direct = collection.icons[name];
371
+ if (direct) return {
372
+ body: direct.body,
373
+ width: direct.width ?? fallback,
374
+ height: direct.height ?? fallbackH
375
+ };
376
+ const alias = collection.aliases?.[name];
377
+ if (!alias) return;
378
+ const parent = collection.icons[alias.parent];
379
+ if (!parent) return;
380
+ return {
381
+ body: parent.body,
382
+ width: alias.width ?? parent.width ?? fallback,
383
+ height: alias.height ?? parent.height ?? fallbackH
384
+ };
385
+ }
386
+ function isMulticolorIcon(body) {
387
+ return /(?:fill|stroke)=["'](?!currentColor|none)[^"']+["']/i.test(body);
388
+ }
389
+ function renderIconsCss(icons) {
390
+ return `/* ox-content self-hosted Iconify icons */\n${icons.map(renderOneIconCss).join("\n")}\n`;
391
+ }
392
+ function renderOneIconCss(icon) {
393
+ const selector = iconCssSelector(icon.prefix, icon.name);
394
+ const url = svgToDataUrl(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${icon.width} ${icon.height}">${maskBody(icon)}</svg>`);
395
+ 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%}`;
396
+ 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%}`;
397
+ }
398
+ function maskBody(icon) {
399
+ return icon.multicolor ? icon.body : icon.body.replace(/currentColor/g, "black");
400
+ }
401
+ function svgToDataUrl(svg) {
402
+ return `url("data:image/svg+xml,${svg.replace(/"/g, "'").replace(/%/g, "%25").replace(/#/g, "%23").replace(/</g, "%3C").replace(/>/g, "%3E").replace(/\s+/g, " ")}")`;
403
+ }
404
+ //#endregion
405
+ //#region src/icons.ts
406
+ /**
407
+ * Opt-in self-hosted Iconify CSS for used and safelisted icons.
408
+ *
409
+ * Collection lookup stays on disk (`@iconify-json/*` / `@iconify/json`).
410
+ * Theme embed injection composes with self-hosted font `<link>` tags.
411
+ */
412
+ const ICON_ASSET_DIR = "__ox_icons__";
413
+ const ICON_CSS_NAME = "icons.css";
414
+ const URL_SCHEMES = /* @__PURE__ */ new Set([
415
+ "http",
416
+ "https",
417
+ "data",
418
+ "mailto",
419
+ "file",
420
+ "javascript",
421
+ "vscode",
422
+ "tel",
423
+ "blob"
424
+ ]);
425
+ const COLON_ICON = /(?<![A-Za-z0-9_-])([a-z][a-z0-9-]*):([a-z0-9][a-z0-9-]*)/gi;
426
+ const CLASS_ICON = /icon-\[([a-z][a-z0-9-]*)--([a-z0-9][a-z0-9-]*)\]/gi;
427
+ const ICON_FIELD = /(?:^|[\s,{])icon\s*:\s*["']([^"']+)["']/g;
428
+ function resolveIconsOptions(value) {
429
+ if (!value) return {
430
+ enabled: false,
431
+ mode: "css-mask",
432
+ syntax: "unocss",
433
+ include: [],
434
+ safelist: []
435
+ };
436
+ if (value === true) return {
437
+ enabled: true,
438
+ mode: "css-mask",
439
+ syntax: "unocss",
440
+ include: [],
441
+ safelist: []
442
+ };
443
+ return {
444
+ enabled: true,
445
+ mode: value.mode ?? "css-mask",
446
+ syntax: value.syntax ?? "unocss",
447
+ include: value.include ?? [],
448
+ safelist: value.safelist ?? []
449
+ };
450
+ }
451
+ function parseIconName(value) {
452
+ const trimmed = value.trim();
453
+ const classMatch = /^icon-\[(.+)\]$/.exec(trimmed);
454
+ if (classMatch?.[1]) {
455
+ const inner = classMatch[1];
456
+ const sep = inner.indexOf("--");
457
+ if (sep <= 0) return;
458
+ return tokenPair(inner.slice(0, sep), inner.slice(sep + 2));
459
+ }
460
+ const sep = trimmed.indexOf(":");
461
+ if (sep <= 0) return;
462
+ return tokenPair(trimmed.slice(0, sep), trimmed.slice(sep + 1));
463
+ }
464
+ function normalizeIconName(value) {
465
+ const parsed = parseIconName(value);
466
+ return parsed ? `${parsed.prefix}:${parsed.name}` : value;
467
+ }
468
+ function tokenPair(prefix, name) {
469
+ if (!/^[a-z][a-z0-9-]*$/i.test(prefix) || !/^[a-z0-9][a-z0-9-]*$/i.test(name)) return;
470
+ if (URL_SCHEMES.has(prefix.toLowerCase())) return;
471
+ return {
472
+ prefix,
473
+ name
474
+ };
475
+ }
476
+ function collectIconNamesFromText(text, into = /* @__PURE__ */ new Set()) {
477
+ COLON_ICON.lastIndex = 0;
478
+ for (const match of text.matchAll(COLON_ICON)) addParsed(into, match[1], match[2]);
479
+ CLASS_ICON.lastIndex = 0;
480
+ for (const match of text.matchAll(CLASS_ICON)) addParsed(into, match[1], match[2]);
481
+ return into;
482
+ }
483
+ function collectIconFieldNames(text, into = /* @__PURE__ */ new Set()) {
484
+ ICON_FIELD.lastIndex = 0;
485
+ for (const match of text.matchAll(ICON_FIELD)) {
486
+ const parsed = match[1] ? parseIconName(match[1]) : void 0;
487
+ if (parsed) into.add(`${parsed.prefix}:${parsed.name}`);
488
+ }
489
+ return into;
490
+ }
491
+ function collectThemeIconNames(socialLinks) {
492
+ if (!Array.isArray(socialLinks)) return [];
493
+ const names = [];
494
+ for (const link of socialLinks) {
495
+ if (!link || typeof link !== "object") continue;
496
+ const icon = link.icon;
497
+ if (typeof icon === "string" && parseIconName(icon)) names.push(normalizeIconName(icon));
498
+ }
499
+ return names;
500
+ }
501
+ function addParsed(into, prefix, name) {
502
+ if (!prefix || !name) return;
503
+ const parsed = tokenPair(prefix, name);
504
+ if (parsed) into.add(`${parsed.prefix}:${parsed.name}`);
505
+ }
506
+ function iconStylesheetHref(base) {
507
+ return `${normalizeBasePath(base)}${ICON_ASSET_DIR}/${ICON_CSS_NAME}`;
508
+ }
509
+ function iconStylesheetLink(base) {
510
+ return `<link rel="stylesheet" href="${iconStylesheetHref(base)}">`;
511
+ }
512
+ function withSelfHostedIconHead(embed, enabled, base) {
513
+ if (!enabled) return embed;
514
+ const extra = iconStylesheetLink(base);
515
+ if (!embed) return { head: extra };
516
+ return {
517
+ ...embed,
518
+ head: embed.head ? `${extra}\n${embed.head}` : extra
519
+ };
520
+ }
521
+ /** Copy resolved icon CSS into `outDir`. Missing collections or names become errors. */
522
+ async function writeSelfHostedIcons(input) {
523
+ if (!input.options.enabled) return {
524
+ files: [],
525
+ errors: [],
526
+ names: []
527
+ };
528
+ const names = await collectResolvedIconNames(input);
529
+ const { icons, errors } = await resolveIconBodies(names, input.root);
530
+ const destDir = join(input.outDir, ICON_ASSET_DIR);
531
+ await mkdir(destDir, { recursive: true });
532
+ const cssPath = join(destDir, ICON_CSS_NAME);
533
+ await writeFile(cssPath, renderIconsCss(icons), "utf8");
534
+ return {
535
+ files: [cssPath],
536
+ errors,
537
+ names
538
+ };
539
+ }
540
+ async function collectResolvedIconNames(input) {
541
+ const names = /* @__PURE__ */ new Set();
542
+ for (const item of input.options.safelist) addName(names, item);
543
+ for (const item of collectThemeIconNames(input.socialLinks)) names.add(item);
544
+ const { names: includeNames, globs } = partitionInclude(input.options.include);
545
+ for (const item of includeNames) names.add(item);
546
+ for (const pattern of globs) {
547
+ const files = await glob(pattern, {
548
+ cwd: input.root,
549
+ nodir: true,
550
+ absolute: true,
551
+ ignore: ["**/node_modules/**"]
552
+ });
553
+ for (const file of files) collectIconNamesFromText(await readFile(file, "utf8"), names);
554
+ }
555
+ if (input.srcDir) {
556
+ const files = await glob("**/*.{md,mdx,markdown}", {
557
+ cwd: input.srcDir,
558
+ nodir: true,
559
+ absolute: true,
560
+ ignore: ["**/node_modules/**"]
561
+ });
562
+ for (const file of files) collectIconFieldNames(await readFile(file, "utf8"), names);
563
+ }
564
+ return [...names].sort();
565
+ }
566
+ function partitionInclude(include) {
567
+ const names = [];
568
+ const globs = [];
569
+ for (const entry of include) if (parseIconName(entry)) names.push(normalizeIconName(entry));
570
+ else globs.push(entry);
571
+ return {
572
+ names,
573
+ globs
574
+ };
575
+ }
576
+ function addName(into, value) {
577
+ const parsed = parseIconName(value);
578
+ if (parsed) into.add(`${parsed.prefix}:${parsed.name}`);
579
+ }
580
+ async function resolveIconBodies(names, root) {
581
+ const icons = [];
582
+ const errors = [];
583
+ const collections = /* @__PURE__ */ new Map();
584
+ for (const id of names) {
585
+ const parsed = parseIconName(id);
586
+ if (!parsed) continue;
587
+ if (!collections.has(parsed.prefix)) collections.set(parsed.prefix, await loadIconCollection(parsed.prefix, root));
588
+ const collection = collections.get(parsed.prefix);
589
+ if (!collection) {
590
+ errors.push(`[ox-content] icons: missing Iconify collection "${parsed.prefix}". Install @iconify-json/${parsed.prefix} or @iconify/json.`);
591
+ continue;
592
+ }
593
+ const found = lookupIcon(collection, parsed.name);
594
+ if (!found) {
595
+ errors.push(`[ox-content] icons: missing icon "${parsed.prefix}:${parsed.name}" in collection "${parsed.prefix}".`);
596
+ continue;
597
+ }
598
+ icons.push({
599
+ prefix: parsed.prefix,
600
+ name: parsed.name,
601
+ body: found.body,
602
+ width: found.width,
603
+ height: found.height,
604
+ multicolor: isMulticolorIcon(found.body)
605
+ });
606
+ }
607
+ return {
608
+ icons,
609
+ errors
610
+ };
611
+ }
612
+ //#endregion
613
+ //#region src/header-chrome.ts
614
+ /** `false` or omitted stays off. `true` or `{}` enables default flag reading. */
615
+ function resolvePageChromeOption(value) {
616
+ return value === true || typeof value === "object" && value !== null;
617
+ }
618
+ /** Reads hide flags from frontmatter. Non-boolean values are ignored. */
619
+ function parsePageChromeFlags(frontmatter) {
620
+ return {
621
+ sidebar: readBool(frontmatter.sidebar),
622
+ outline: readBool(frontmatter.outline),
623
+ aside: readBool(frontmatter.aside),
624
+ footer: readBool(frontmatter.footer),
625
+ navbar: readBool(frontmatter.navbar),
626
+ lastUpdated: readBool(frontmatter.lastUpdated),
627
+ editLink: readBool(frontmatter.editLink)
628
+ };
629
+ }
630
+ function readBool(value) {
631
+ return typeof value === "boolean" ? value : void 0;
632
+ }
633
+ /**
634
+ * Picks the exact locale, its language, the default locale, then the first
635
+ * non-empty own string in declaration order.
636
+ */
637
+ function resolveLocaleLabel(text, locale, defaultLocale) {
638
+ if (typeof text === "string") return text;
639
+ const candidates = [
640
+ locale,
641
+ locale?.split("-")[0],
642
+ defaultLocale,
643
+ defaultLocale?.split("-")[0]
644
+ ];
645
+ for (const candidate of candidates) {
646
+ if (!candidate || !Object.hasOwn(text, candidate)) continue;
647
+ const value = text[candidate];
648
+ if (typeof value === "string" && value.length > 0) return value;
649
+ }
650
+ for (const value of Object.values(text)) if (typeof value === "string" && value.length > 0) return value;
651
+ return "";
652
+ }
653
+ /** Resolves locale maps so NAPI always receives string labels. */
654
+ function resolveHeaderNavItems(items, locale, defaultLocale) {
655
+ if (!items?.length) return;
656
+ return items.map((item) => ({
657
+ text: resolveLocaleLabel(item.text, locale, defaultLocale),
658
+ link: item.link,
659
+ items: resolveHeaderNavItems(item.items, locale, defaultLocale)
660
+ }));
661
+ }
662
+ //#endregion
32
663
  //#region src/theme-tokens.ts
33
664
  const TOKEN_PREFIX = "--octc-";
34
665
  const TOKEN_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
@@ -61,16 +692,15 @@ function assertTokenName(name) {
61
692
  //#endregion
62
693
  //#region src/theme.ts
63
694
  /**
64
- * Theme API for ox-content SSG
65
- *
66
- * Provides VitePress-like theming with default theme + customization.
67
- */
68
- /**
69
695
  * Default theme configuration.
70
696
  * Based on the current ox-content SSG styles.
71
697
  */
72
698
  const defaultTheme = {
73
699
  name: "default",
700
+ viewTransitions: true,
701
+ aside: false,
702
+ breadcrumbs: false,
703
+ headingPermalink: "hover",
74
704
  colors: {
75
705
  primary: "#4f6fae",
76
706
  primaryHover: "#425f96",
@@ -208,12 +838,18 @@ function resolveTheme(config) {
208
838
  const merged = mergeThemes(...chain.map(withDerivedCodeBackgroundTop));
209
839
  return {
210
840
  name: merged.name ?? "custom",
841
+ viewTransitions: merged.viewTransitions ?? defaultTheme.viewTransitions ?? true,
842
+ aside: merged.aside ?? defaultTheme.aside ?? false,
843
+ breadcrumbs: resolveThemeFlag(merged.breadcrumbs),
844
+ headingPermalink: merged.headingPermalink === "always" ? "always" : "hover",
211
845
  colors: merged.colors ?? defaultTheme.colors,
212
846
  darkColors: merged.darkColors ?? defaultTheme.darkColors,
213
847
  fonts: merged.fonts ?? defaultTheme.fonts,
214
848
  entryPage: merged.entryPage ?? defaultTheme.entryPage,
215
849
  layout: merged.layout ?? defaultTheme.layout,
216
850
  header: merged.header ?? defaultTheme.header,
851
+ nav: merged.nav,
852
+ announcement: merged.announcement,
217
853
  footer: merged.footer ?? defaultTheme.footer,
218
854
  socialLinks: merged.socialLinks ?? defaultTheme.socialLinks,
219
855
  sidebar: merged.sidebar ?? [],
@@ -258,9 +894,13 @@ function withDerivedCodeBackgroundTop(theme) {
258
894
  /**
259
895
  * Converts resolved theme to the format expected by Rust NAPI.
260
896
  */
261
- function themeToNapi(theme) {
897
+ function themeToNapi(theme, locale, base, iconsEnabled = false) {
262
898
  const socialLinks = socialLinksToNapi(theme.socialLinks);
263
899
  return {
900
+ viewTransitions: theme.viewTransitions,
901
+ aside: theme.aside,
902
+ breadcrumbs: theme.breadcrumbs,
903
+ headingPermalink: theme.headingPermalink,
264
904
  colors: theme.colors.primary ? {
265
905
  primary: theme.colors.primary,
266
906
  primaryHover: theme.colors.primaryHover,
@@ -285,10 +925,7 @@ function themeToNapi(theme) {
285
925
  codeBackgroundTop: theme.darkColors.codeBackgroundTop,
286
926
  codeText: theme.darkColors.codeText
287
927
  } : void 0,
288
- fonts: theme.fonts.sans ? {
289
- sans: theme.fonts.sans,
290
- mono: theme.fonts.mono
291
- } : void 0,
928
+ fonts: flattenThemeFonts(theme.fonts),
292
929
  entryPage: theme.entryPage.mode ? { mode: theme.entryPage.mode } : void 0,
293
930
  layout: theme.layout.sidebarWidth ? {
294
931
  sidebarWidth: theme.layout.sidebarWidth,
@@ -303,12 +940,14 @@ function themeToNapi(theme) {
303
940
  logoWidth: theme.header.logoWidth,
304
941
  logoHeight: theme.header.logoHeight
305
942
  } : void 0,
943
+ nav: resolveHeaderNavItems(theme.nav, locale),
944
+ announcement: theme.announcement?.text ? theme.announcement : void 0,
306
945
  footer: theme.footer.message || theme.footer.copyright ? {
307
946
  message: theme.footer.message,
308
947
  copyright: theme.footer.copyright
309
948
  } : void 0,
310
949
  socialLinks,
311
- embed: Object.keys(theme.embed).length > 0 ? theme.embed : void 0,
950
+ embed: withSelfHostedIconHead(withSelfHostedFontHead(theme.embed, theme.fonts, base), iconsEnabled, base),
312
951
  css: themeCss(theme) || void 0,
313
952
  js: theme.js || void 0
314
953
  };
@@ -318,9 +957,9 @@ function themeToNapi(theme) {
318
957
  * land after the typed color variables the Rust renderer emits.
319
958
  */
320
959
  function themeCss(theme) {
321
- const tokenCss = tokensToCss(theme.tokens, theme.darkTokens);
322
- if (!tokenCss) return theme.css;
323
- return theme.css ? `${tokenCss}\n${theme.css}` : tokenCss;
960
+ const prefix = [tokensToCss(theme.tokens, theme.darkTokens), namedFontVarsCss(theme.fonts)].filter(Boolean).join("\n");
961
+ if (!prefix) return theme.css;
962
+ return theme.css ? `${prefix}\n${theme.css}` : prefix;
324
963
  }
325
964
  function socialLinksToNapi(links) {
326
965
  if (Array.isArray(links)) {
@@ -340,6 +979,9 @@ function socialLinksToNapi(links) {
340
979
  discord: links.discord
341
980
  } : void 0;
342
981
  }
982
+ function resolveThemeFlag(value) {
983
+ return value === true || typeof value === "object" && value !== null;
984
+ }
343
985
  //#endregion
344
986
  //#region src/vitepress.ts
345
987
  function isRecord(value) {
@@ -595,6 +1237,6 @@ function normalizeVitePressFrontmatter(frontmatter) {
595
1237
  return importNapiModuleSync().normalizeVitePressFrontmatter(frontmatter);
596
1238
  }
597
1239
  //#endregion
598
- export { normalizeVitePressFrontmatter as a, mergeThemes as c, importNapiModule as d, importNapiModuleSync as f, generateVitePressMigrationConfig as i, resolveTheme as l, convertVitePressSidebar as n, defaultTheme as o, fromVitePressConfig as r, defineTheme as s, convertVitePressNav as t, themeToNapi as u };
1240
+ export { writeSelfHostedIcons as _, normalizeVitePressFrontmatter as a, importNapiModuleSync as b, mergeThemes as c, parsePageChromeFlags as d, resolveHeaderNavItems as f, withSelfHostedIconHead as g, resolveIconsOptions as h, generateVitePressMigrationConfig as i, resolveTheme as l, resolvePageChromeOption as m, convertVitePressSidebar as n, defaultTheme as o, resolveLocaleLabel as p, fromVitePressConfig as r, defineTheme as s, convertVitePressNav as t, themeToNapi as u, writeSelfHostedThemeFonts as v, importNapiModule as y };
599
1241
 
600
1242
  //# sourceMappingURL=vitepress.mjs.map