@ox-content/vite-plugin 0.6.0 → 0.8.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
@@ -6967,7 +6967,7 @@ async function loadNapiBindings() {
6967
6967
  async function transformMarkdown(source, filePath, options, ssgOptions) {
6968
6968
  const napi = await loadNapiBindings();
6969
6969
  if (!napi) throw new Error("[ox-content] NAPI bindings not available. Please ensure @ox-content/napi is built.");
6970
- const { content: markdownContent, frontmatter } = parseFrontmatter(source);
6970
+ const { content: markdownContent, frontmatter } = parseFrontmatter$1(source);
6971
6971
  const result = napi.transform(markdownContent, {
6972
6972
  gfm: options.gfm,
6973
6973
  footnotes: options.footnotes,
@@ -7002,7 +7002,7 @@ async function transformMarkdown(source, filePath, options, ssgOptions) {
7002
7002
  * Parses YAML frontmatter from Markdown content.
7003
7003
  * Uses proper YAML parser for full nested object support.
7004
7004
  */
7005
- function parseFrontmatter(source) {
7005
+ function parseFrontmatter$1(source) {
7006
7006
  if (!source.startsWith("---")) return {
7007
7007
  content: source,
7008
7008
  frontmatter: {}
@@ -7942,13 +7942,16 @@ function resolveDocsOptions(options) {
7942
7942
  //#endregion
7943
7943
  //#region src/og-image/renderer.ts
7944
7944
  /**
7945
+ * HTML → PNG renderer using Chromium screenshots via Playwright.
7946
+ */
7947
+ /**
7945
7948
  * Wraps template HTML in a minimal document with viewport locked to given dimensions.
7946
7949
  */
7947
- function wrapHtml(bodyHtml, width, height) {
7950
+ function wrapHtml(bodyHtml, width, height, useBaseUrl) {
7948
7951
  return `<!DOCTYPE html>
7949
7952
  <html>
7950
7953
  <head>
7951
- <meta charset="UTF-8">
7954
+ <meta charset="UTF-8">${useBaseUrl ? `\n<base href="http://localhost/">` : ""}
7952
7955
  <style>
7953
7956
  * { margin: 0; padding: 0; box-sizing: border-box; }
7954
7957
  html, body { width: ${width}px; height: ${height}px; overflow: hidden; }
@@ -7964,14 +7967,48 @@ html, body { width: ${width}px; height: ${height}px; overflow: hidden; }
7964
7967
  * @param html - HTML string from template function
7965
7968
  * @param width - Image width
7966
7969
  * @param height - Image height
7970
+ * @param publicDir - Optional public directory for serving local assets (images, fonts, etc.)
7967
7971
  * @returns PNG buffer
7968
7972
  */
7969
- async function renderHtmlToPng(page, html, width, height) {
7973
+ async function renderHtmlToPng(page, html, width, height, publicDir) {
7970
7974
  await page.setViewportSize({
7971
7975
  width,
7972
7976
  height
7973
7977
  });
7974
- const fullHtml = wrapHtml(html, width, height);
7978
+ if (publicDir) {
7979
+ const fs = await import("fs/promises");
7980
+ await page.route("**/*", async (route) => {
7981
+ const url = new URL(route.request().url());
7982
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
7983
+ await route.continue();
7984
+ return;
7985
+ }
7986
+ const filePath = path$1.join(publicDir, url.pathname);
7987
+ try {
7988
+ const body = await fs.readFile(filePath);
7989
+ const ext = path$1.extname(filePath).toLowerCase();
7990
+ await route.fulfill({
7991
+ body,
7992
+ contentType: {
7993
+ ".svg": "image/svg+xml",
7994
+ ".png": "image/png",
7995
+ ".jpg": "image/jpeg",
7996
+ ".jpeg": "image/jpeg",
7997
+ ".gif": "image/gif",
7998
+ ".webp": "image/webp",
7999
+ ".woff": "font/woff",
8000
+ ".woff2": "font/woff2",
8001
+ ".ttf": "font/ttf",
8002
+ ".css": "text/css",
8003
+ ".js": "application/javascript"
8004
+ }[ext] || "application/octet-stream"
8005
+ });
8006
+ } catch {
8007
+ await route.continue();
8008
+ }
8009
+ });
8010
+ }
8011
+ const fullHtml = wrapHtml(html, width, height, !!publicDir);
7975
8012
  await page.setContent(fullHtml, { waitUntil: "networkidle" });
7976
8013
  const screenshot = await page.screenshot({
7977
8014
  type: "png",
@@ -8011,10 +8048,10 @@ async function openBrowser() {
8011
8048
  ]
8012
8049
  });
8013
8050
  return {
8014
- async renderPage(html, width, height) {
8051
+ async renderPage(html, width, height, publicDir) {
8015
8052
  const page = await browser.newPage();
8016
8053
  try {
8017
- return await renderHtmlToPng(page, html, width, height);
8054
+ return await renderHtmlToPng(page, html, width, height, publicDir);
8018
8055
  } finally {
8019
8056
  await page.close();
8020
8057
  }
@@ -8253,10 +8290,26 @@ async function resolveVueTemplate(templatePath, options, root) {
8253
8290
  await bundle.close();
8254
8291
  const Component = (await import(`${outfile}?t=${Date.now()}`)).default;
8255
8292
  if (!Component) throw new Error(`[ox-content:og-image] Vue template must have a default export: ${templatePath}`);
8293
+ let extractedCss = "";
8294
+ try {
8295
+ let compilerSfc;
8296
+ try {
8297
+ compilerSfc = await import("@vue/compiler-sfc");
8298
+ } catch {
8299
+ compilerSfc = null;
8300
+ }
8301
+ if (compilerSfc) {
8302
+ const sfcSource = await fs.readFile(templatePath, "utf-8");
8303
+ const { descriptor } = compilerSfc.parse(sfcSource, { filename: templatePath });
8304
+ for (const style of descriptor.styles) extractedCss += style.content;
8305
+ }
8306
+ } catch {}
8256
8307
  const { createSSRApp } = await import("vue");
8257
8308
  const { renderToString } = await import("vue/server-renderer");
8258
8309
  return async (props) => {
8259
- return renderToString(createSSRApp(Component, props));
8310
+ const html = await renderToString(createSSRApp(Component, props));
8311
+ if (extractedCss) return `<style>${extractedCss}</style>${html}`;
8312
+ return html;
8260
8313
  };
8261
8314
  }
8262
8315
  /**
@@ -8458,10 +8511,11 @@ async function generateOgImages(pages, options, root) {
8458
8511
  error: "Chromium not available"
8459
8512
  }));
8460
8513
  const results = [];
8514
+ const publicDir = path$1.join(root, "public");
8461
8515
  const concurrency = Math.max(1, options.concurrency);
8462
8516
  for (let i = 0; i < pages.length; i += concurrency) {
8463
8517
  const batch = pages.slice(i, i + concurrency);
8464
- const batchResults = await Promise.all(batch.map((entry) => renderSinglePage(entry, templateFn, templateSource, options, cacheDir, session)));
8518
+ const batchResults = await Promise.all(batch.map((entry) => renderSinglePage(entry, templateFn, templateSource, options, cacheDir, session, publicDir)));
8465
8519
  results.push(...batchResults);
8466
8520
  }
8467
8521
  return results;
@@ -8493,7 +8547,7 @@ async function tryServeAllFromCache(pages, templateSource, options, cacheDir) {
8493
8547
  /**
8494
8548
  * Renders a single page to PNG, with cache support.
8495
8549
  */
8496
- async function renderSinglePage(entry, templateFn, templateSource, options, cacheDir, session) {
8550
+ async function renderSinglePage(entry, templateFn, templateSource, options, cacheDir, session, publicDir) {
8497
8551
  const fs = await import("fs/promises");
8498
8552
  try {
8499
8553
  if (options.cache) {
@@ -8508,7 +8562,7 @@ async function renderSinglePage(entry, templateFn, templateSource, options, cach
8508
8562
  }
8509
8563
  }
8510
8564
  const html = await templateFn(entry.props);
8511
- const png = await session.renderPage(html, options.width, options.height);
8565
+ const png = await session.renderPage(html, options.width, options.height, publicDir);
8512
8566
  await fs.mkdir(path$1.dirname(entry.outputPath), { recursive: true });
8513
8567
  await fs.writeFile(entry.outputPath, png);
8514
8568
  if (options.cache) await writeCache(cacheDir, computeCacheKey(templateSource, entry.props, options.width, options.height), png);
@@ -9727,7 +9781,7 @@ function renderTemplate(template, data) {
9727
9781
  /**
9728
9782
  * Extracts title from content or frontmatter.
9729
9783
  */
9730
- function extractTitle(content, frontmatter) {
9784
+ function extractTitle$1(content, frontmatter) {
9731
9785
  if (frontmatter.title && typeof frontmatter.title === "string") return frontmatter.title;
9732
9786
  const h1Match = content.match(/<h1[^>]*>([^<]+)<\/h1>/i);
9733
9787
  if (h1Match) return h1Match[1].trim();
@@ -9812,7 +9866,7 @@ function getOutputPath(inputPath, srcDir, outDir, extension) {
9812
9866
  /**
9813
9867
  * Converts a markdown file path to a relative URL path.
9814
9868
  */
9815
- function getUrlPath(inputPath, srcDir) {
9869
+ function getUrlPath$1(inputPath, srcDir) {
9816
9870
  const baseName = path$1.relative(srcDir, inputPath).replace(/\.(?:md|markdown)$/i, "");
9817
9871
  if (baseName === "index" || baseName.endsWith("/index")) return baseName.replace(/\/?index$/, "") || "/";
9818
9872
  return baseName;
@@ -9821,7 +9875,7 @@ function getUrlPath(inputPath, srcDir) {
9821
9875
  * Converts a markdown file path to an href.
9822
9876
  */
9823
9877
  function getHref(inputPath, srcDir, base, extension) {
9824
- const urlPath = getUrlPath(inputPath, srcDir);
9878
+ const urlPath = getUrlPath$1(inputPath, srcDir);
9825
9879
  if (urlPath === "/" || urlPath === "") return `${base}index${extension}`;
9826
9880
  return `${base}${urlPath}/index${extension}`;
9827
9881
  }
@@ -9841,7 +9895,7 @@ function getOgImagePath(inputPath, srcDir, outDir) {
9841
9895
  * If siteUrl is provided, returns an absolute URL (required for SNS sharing).
9842
9896
  */
9843
9897
  function getOgImageUrl(inputPath, srcDir, base, siteUrl) {
9844
- const urlPath = getUrlPath(inputPath, srcDir);
9898
+ const urlPath = getUrlPath$1(inputPath, srcDir);
9845
9899
  let relativePath;
9846
9900
  if (urlPath === "/" || urlPath === "") relativePath = `${base}og-image.png`;
9847
9901
  else relativePath = `${base}${urlPath}/og-image.png`;
@@ -9895,7 +9949,7 @@ function buildNavItems(markdownFiles, srcDir, base, extension) {
9895
9949
  let groupKey = "";
9896
9950
  if (parts.length > 1) groupKey = parts[0];
9897
9951
  if (!groups.has(groupKey)) groups.set(groupKey, []);
9898
- const urlPath = getUrlPath(file, srcDir);
9952
+ const urlPath = getUrlPath$1(file, srcDir);
9899
9953
  let title;
9900
9954
  if (urlPath === "/" || urlPath === "") title = "Overview";
9901
9955
  else title = getDisplayTitle(file);
@@ -9984,7 +10038,7 @@ async function buildSsg(options, root) {
9984
10038
  transformedHtml = await transformAllPlugins(transformedHtml, pluginOptions);
9985
10039
  if (hasIslands(transformedHtml)) transformedHtml = (await transformIslands(transformedHtml)).html;
9986
10040
  transformedHtml = restoreMermaidSvgs(transformedHtml, mermaidSvgs);
9987
- const title = extractTitle(transformedHtml, result.frontmatter);
10041
+ const title = extractTitle$1(transformedHtml, result.frontmatter);
9988
10042
  const description = result.frontmatter.description;
9989
10043
  pageResults.push({
9990
10044
  inputPath,
@@ -10052,7 +10106,7 @@ async function buildSsg(options, root) {
10052
10106
  content: transformedHtml,
10053
10107
  toc,
10054
10108
  frontmatter,
10055
- path: getUrlPath(inputPath, srcDir),
10109
+ path: getUrlPath$1(inputPath, srcDir),
10056
10110
  href: getHref(inputPath, srcDir, base, ssgOptions.extension),
10057
10111
  entryPage
10058
10112
  }, navItems, siteName, base, pageOgImage, ssgOptions.theme);
@@ -10351,6 +10405,353 @@ export default { search, searchOptions, loadIndex };
10351
10405
  `;
10352
10406
  }
10353
10407
 
10408
+ //#endregion
10409
+ //#region src/og-viewer.ts
10410
+ /**
10411
+ * OG Viewer - Dev tool for previewing Open Graph metadata
10412
+ *
10413
+ * Accessible at /__og-viewer during development.
10414
+ * Shows all pages with their OG metadata, validation warnings,
10415
+ * and social card previews.
10416
+ */
10417
+ function parseFrontmatter(content) {
10418
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
10419
+ if (!match) return {};
10420
+ const yaml = match[1];
10421
+ const result = {};
10422
+ for (const line of yaml.split("\n")) {
10423
+ const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
10424
+ if (!kv) continue;
10425
+ const [, key, rawValue] = kv;
10426
+ let value = rawValue.trim();
10427
+ if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) value = value.slice(1, -1).split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
10428
+ else if (typeof value === "string" && /^['"].*['"]$/.test(value)) value = value.slice(1, -1);
10429
+ else if (value === "true") value = true;
10430
+ else if (value === "false") value = false;
10431
+ result[key] = value;
10432
+ }
10433
+ return result;
10434
+ }
10435
+ function extractTitle(content, frontmatter) {
10436
+ if (typeof frontmatter.title === "string" && frontmatter.title) return frontmatter.title;
10437
+ const match = content.match(/^#\s+(.+)$/m);
10438
+ return match ? match[1].trim() : "";
10439
+ }
10440
+ function getUrlPath(filePath, srcDir) {
10441
+ let rel = path$1.relative(srcDir, filePath).replace(/\\/g, "/");
10442
+ rel = rel.replace(/\.md$/, "");
10443
+ if (rel === "index") return "/";
10444
+ if (rel.endsWith("/index")) rel = rel.slice(0, -6);
10445
+ return "/" + rel;
10446
+ }
10447
+ function computeOgImageUrl(urlPath, base, siteUrl, generateOgImage, staticOgImage) {
10448
+ if (!generateOgImage) return staticOgImage || "";
10449
+ const cleanBase = base.endsWith("/") ? base : base + "/";
10450
+ let relativePath;
10451
+ if (urlPath === "/") relativePath = `${cleanBase}og-image.png`;
10452
+ else relativePath = `${cleanBase}${urlPath.replace(/^\//, "")}/og-image.png`;
10453
+ if (siteUrl) return `${siteUrl.replace(/\/$/, "")}${relativePath}`;
10454
+ return relativePath;
10455
+ }
10456
+ function validatePage(page, options) {
10457
+ const warnings = [];
10458
+ if (!page.title) warnings.push({
10459
+ level: "error",
10460
+ message: "title is missing"
10461
+ });
10462
+ else if (page.title.length > 70) warnings.push({
10463
+ level: "warning",
10464
+ message: `title is too long (${page.title.length}/70)`
10465
+ });
10466
+ if (!page.description) warnings.push({
10467
+ level: "warning",
10468
+ message: "description is missing"
10469
+ });
10470
+ else if (page.description.length > 200) warnings.push({
10471
+ level: "warning",
10472
+ message: `description is too long (${page.description.length}/200)`
10473
+ });
10474
+ if ((options.ogImage || options.ssg.generateOgImage) && !options.ssg.siteUrl) warnings.push({
10475
+ level: "warning",
10476
+ message: "ogImage enabled but siteUrl is not set"
10477
+ });
10478
+ return warnings;
10479
+ }
10480
+ async function collectPages(options, root) {
10481
+ const srcDir = path$1.resolve(root, options.srcDir);
10482
+ const files = await (0, glob.glob)("**/*.md", {
10483
+ cwd: srcDir,
10484
+ absolute: true
10485
+ });
10486
+ const pages = [];
10487
+ const generateOgImage = options.ogImage || options.ssg.generateOgImage;
10488
+ for (const file of files.sort()) {
10489
+ const content = fs.readFileSync(file, "utf-8");
10490
+ const frontmatter = parseFrontmatter(content);
10491
+ if (frontmatter.layout === "entry") continue;
10492
+ const title = extractTitle(content, frontmatter);
10493
+ const description = typeof frontmatter.description === "string" ? frontmatter.description : "";
10494
+ const author = typeof frontmatter.author === "string" ? frontmatter.author : "";
10495
+ const tags = Array.isArray(frontmatter.tags) ? frontmatter.tags : typeof frontmatter.tags === "string" ? [frontmatter.tags] : [];
10496
+ const urlPath = getUrlPath(file, srcDir);
10497
+ const ogImageUrl = computeOgImageUrl(urlPath, options.base, options.ssg.siteUrl, generateOgImage, options.ssg.ogImage);
10498
+ const page = {
10499
+ path: path$1.relative(srcDir, file),
10500
+ urlPath,
10501
+ title,
10502
+ description,
10503
+ author,
10504
+ tags,
10505
+ ogImageUrl,
10506
+ warnings: []
10507
+ };
10508
+ page.warnings = validatePage(page, options);
10509
+ pages.push(page);
10510
+ }
10511
+ return pages;
10512
+ }
10513
+ function renderViewerHtml(pages, options) {
10514
+ const generateOgImage = options.ogImage || options.ssg.generateOgImage;
10515
+ const totalWarnings = pages.reduce((sum, p) => sum + p.warnings.filter((w) => w.level === "warning").length, 0);
10516
+ const totalErrors = pages.reduce((sum, p) => sum + p.warnings.filter((w) => w.level === "error").length, 0);
10517
+ return `<!DOCTYPE html>
10518
+ <html lang="en">
10519
+ <head>
10520
+ <meta charset="UTF-8">
10521
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
10522
+ <title>OG Viewer - ox-content</title>
10523
+ <style>
10524
+ :root {
10525
+ --bg: #ffffff;
10526
+ --bg-card: #f8f9fa;
10527
+ --bg-preview: #ffffff;
10528
+ --text: #1a1a2e;
10529
+ --text-muted: #6b7280;
10530
+ --border: #e5e7eb;
10531
+ --accent: #e8590c;
10532
+ --accent-light: #fff4e6;
10533
+ --error: #dc2626;
10534
+ --error-bg: #fef2f2;
10535
+ --warning: #d97706;
10536
+ --warning-bg: #fffbeb;
10537
+ --success: #16a34a;
10538
+ --tag-bg: #f0f0f0;
10539
+ --shadow: 0 1px 3px rgba(0,0,0,0.08);
10540
+ --radius: 8px;
10541
+ }
10542
+ @media (prefers-color-scheme: dark) {
10543
+ :root {
10544
+ --bg: #0f172a;
10545
+ --bg-card: #1e293b;
10546
+ --bg-preview: #334155;
10547
+ --text: #e2e8f0;
10548
+ --text-muted: #94a3b8;
10549
+ --border: #334155;
10550
+ --accent: #fb923c;
10551
+ --accent-light: #431407;
10552
+ --error: #f87171;
10553
+ --error-bg: #450a0a;
10554
+ --warning: #fbbf24;
10555
+ --warning-bg: #451a03;
10556
+ --success: #4ade80;
10557
+ --tag-bg: #334155;
10558
+ --shadow: 0 1px 3px rgba(0,0,0,0.3);
10559
+ }
10560
+ }
10561
+ * { margin: 0; padding: 0; box-sizing: border-box; }
10562
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); }
10563
+ .header { padding: 16px 24px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; }
10564
+ .header svg { width: 28px; height: 28px; color: var(--accent); }
10565
+ .header h1 { font-size: 18px; font-weight: 600; }
10566
+ .header h1 span { color: var(--text-muted); font-weight: 400; }
10567
+ .header-actions { margin-left: auto; }
10568
+ .btn { padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-card); color: var(--text); cursor: pointer; font-size: 13px; transition: all 0.15s; }
10569
+ .btn:hover { border-color: var(--accent); color: var(--accent); }
10570
+ .summary { padding: 12px 24px; display: flex; gap: 20px; border-bottom: 1px solid var(--border); font-size: 13px; color: var(--text-muted); flex-wrap: wrap; align-items: center; }
10571
+ .summary-item { display: flex; align-items: center; gap: 4px; }
10572
+ .summary-item strong { color: var(--text); }
10573
+ .summary-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
10574
+ .dot-error { background: var(--error); }
10575
+ .dot-warning { background: var(--warning); }
10576
+ .dot-success { background: var(--success); }
10577
+ .toolbar { padding: 12px 24px; display: flex; gap: 8px; border-bottom: 1px solid var(--border); flex-wrap: wrap; align-items: center; }
10578
+ .filter-btn { padding: 4px 12px; border: 1px solid var(--border); border-radius: 16px; background: transparent; color: var(--text-muted); cursor: pointer; font-size: 12px; transition: all 0.15s; }
10579
+ .filter-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
10580
+ .search-input { padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--text); font-size: 13px; flex: 1; min-width: 200px; }
10581
+ .search-input::placeholder { color: var(--text-muted); }
10582
+ .container { padding: 24px; display: flex; flex-direction: column; gap: 20px; max-width: 1200px; margin: 0 auto; }
10583
+ .card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); box-shadow: var(--shadow); overflow: hidden; }
10584
+ .card-header { padding: 16px; border-bottom: 1px solid var(--border); }
10585
+ .card-path { font-size: 12px; color: var(--text-muted); font-family: monospace; margin-bottom: 4px; }
10586
+ .card-title { font-size: 16px; font-weight: 600; }
10587
+ .card-desc { font-size: 13px; color: var(--text-muted); margin-top: 4px; }
10588
+ .card-meta { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; align-items: center; }
10589
+ .tag { padding: 2px 8px; background: var(--tag-bg); border-radius: 4px; font-size: 11px; color: var(--text-muted); }
10590
+ .card-warnings { padding: 8px 16px; display: flex; flex-direction: column; gap: 4px; }
10591
+ .warning-item { font-size: 12px; padding: 4px 8px; border-radius: 4px; }
10592
+ .warning-item.error { background: var(--error-bg); color: var(--error); }
10593
+ .warning-item.warning { background: var(--warning-bg); color: var(--warning); }
10594
+ .card-previews { padding: 16px; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
10595
+ @media (max-width: 768px) { .card-previews { grid-template-columns: 1fr; } }
10596
+ .preview { border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
10597
+ .preview-label { padding: 6px 10px; font-size: 11px; font-weight: 600; color: var(--text-muted); background: var(--bg); border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.5px; }
10598
+ .preview-card { background: var(--bg-preview); }
10599
+ .preview-img { width: 100%; aspect-ratio: 1200/630; background: linear-gradient(135deg, var(--accent-light), var(--bg-card)); display: flex; align-items: center; justify-content: center; color: var(--text-muted); font-size: 12px; overflow: hidden; }
10600
+ .preview-img img { width: 100%; height: 100%; object-fit: cover; }
10601
+ .preview-body { padding: 10px 12px; }
10602
+ .preview-url { font-size: 11px; color: var(--text-muted); margin-bottom: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
10603
+ .preview-title { font-size: 14px; font-weight: 600; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
10604
+ .preview-desc { font-size: 12px; color: var(--text-muted); margin-top: 2px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
10605
+ .empty { text-align: center; padding: 60px; color: var(--text-muted); }
10606
+ .spin { animation: spin 0.6s linear infinite; }
10607
+ @keyframes spin { to { transform: rotate(360deg); } }
10608
+ </style>
10609
+ </head>
10610
+ <body>
10611
+ <div class="header">
10612
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
10613
+ <h1>OG Viewer <span>/ ox-content</span></h1>
10614
+ <div class="header-actions">
10615
+ <button class="btn" id="refresh-btn" onclick="refresh()">Refresh</button>
10616
+ </div>
10617
+ </div>
10618
+ <div class="summary" id="summary">
10619
+ <div class="summary-item"><strong id="s-pages">${pages.length}</strong>&nbsp;pages</div>
10620
+ <div class="summary-item"><span class="summary-dot dot-error"></span>&nbsp;<strong id="s-errors">${totalErrors}</strong>&nbsp;errors</div>
10621
+ <div class="summary-item"><span class="summary-dot dot-warning"></span>&nbsp;<strong id="s-warnings">${totalWarnings}</strong>&nbsp;warnings</div>
10622
+ <div class="summary-item"><span class="summary-dot ${generateOgImage ? "dot-success" : "dot-warning"}"></span>&nbsp;ogImage: <strong>${generateOgImage ? "enabled" : "disabled"}</strong></div>
10623
+ </div>
10624
+ <div class="toolbar">
10625
+ <button class="filter-btn active" data-filter="all" onclick="setFilter('all')">All</button>
10626
+ <button class="filter-btn" data-filter="warnings" onclick="setFilter('warnings')">Warnings</button>
10627
+ <button class="filter-btn" data-filter="errors" onclick="setFilter('errors')">Errors</button>
10628
+ <input class="search-input" type="text" placeholder="Search pages..." oninput="applyFilters()" id="search-input">
10629
+ </div>
10630
+ <div class="container" id="container"></div>
10631
+
10632
+ <script>
10633
+ let pages = ${JSON.stringify(pages)};
10634
+ let currentFilter = 'all';
10635
+
10636
+ function setFilter(f) {
10637
+ currentFilter = f;
10638
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.toggle('active', b.dataset.filter === f));
10639
+ applyFilters();
10640
+ }
10641
+
10642
+ function applyFilters() {
10643
+ const q = document.getElementById('search-input').value.toLowerCase();
10644
+ const filtered = pages.filter(p => {
10645
+ if (currentFilter === 'errors' && !p.warnings.some(w => w.level === 'error')) return false;
10646
+ if (currentFilter === 'warnings' && !p.warnings.length) return false;
10647
+ if (q && !p.path.toLowerCase().includes(q) && !p.title.toLowerCase().includes(q) && !p.description.toLowerCase().includes(q)) return false;
10648
+ return true;
10649
+ });
10650
+ renderCards(filtered);
10651
+ }
10652
+
10653
+ function esc(s) {
10654
+ const d = document.createElement('div');
10655
+ d.textContent = s;
10656
+ return d.innerHTML;
10657
+ }
10658
+
10659
+ function renderCards(list) {
10660
+ const c = document.getElementById('container');
10661
+ if (!list.length) {
10662
+ c.innerHTML = '<div class="empty">No pages match the current filter.</div>';
10663
+ return;
10664
+ }
10665
+ c.innerHTML = list.map(p => {
10666
+ const warnings = p.warnings.map(w =>
10667
+ '<div class="warning-item ' + w.level + '">' + (w.level === 'error' ? '\\u2716' : '\\u26A0') + ' ' + esc(w.message) + '</div>'
10668
+ ).join('');
10669
+ const tags = p.tags.map(t => '<span class="tag">' + esc(t) + '</span>').join('');
10670
+ const author = p.author ? '<span class="tag">by ' + esc(p.author) + '</span>' : '';
10671
+ const imgHtml = p.ogImageUrl
10672
+ ? '<img src="' + esc(p.ogImageUrl) + '" onerror="this.parentNode.innerHTML=\\'No OG image\\'">'
10673
+ : 'No OG image';
10674
+ const siteHost = ${JSON.stringify(options.ssg.siteUrl || "example.com")};
10675
+ return '<div class="card">'
10676
+ + '<div class="card-header">'
10677
+ + '<div class="card-path">' + esc(p.path) + ' &rarr; ' + esc(p.urlPath) + '</div>'
10678
+ + '<div class="card-title">' + (esc(p.title) || '<em style="color:var(--error)">No title</em>') + '</div>'
10679
+ + (p.description ? '<div class="card-desc">' + esc(p.description) + '</div>' : '')
10680
+ + (tags || author ? '<div class="card-meta">' + author + tags + '</div>' : '')
10681
+ + '</div>'
10682
+ + (warnings ? '<div class="card-warnings">' + warnings + '</div>' : '')
10683
+ + '<div class="card-previews">'
10684
+ + '<div class="preview"><div class="preview-label">Twitter (summary_large_image)</div><div class="preview-card"><div class="preview-img">' + imgHtml + '</div><div class="preview-body"><div class="preview-url">' + esc(siteHost) + '</div><div class="preview-title">' + esc(p.title) + '</div><div class="preview-desc">' + esc(p.description) + '</div></div></div></div>'
10685
+ + '<div class="preview"><div class="preview-label">Facebook (Open Graph)</div><div class="preview-card"><div class="preview-img">' + imgHtml + '</div><div class="preview-body"><div class="preview-url">' + esc(siteHost) + '</div><div class="preview-title">' + esc(p.title) + '</div><div class="preview-desc">' + esc(p.description) + '</div></div></div></div>'
10686
+ + '</div>'
10687
+ + '</div>';
10688
+ }).join('');
10689
+ }
10690
+
10691
+ async function refresh() {
10692
+ const btn = document.getElementById('refresh-btn');
10693
+ btn.textContent = 'Refreshing...';
10694
+ btn.disabled = true;
10695
+ try {
10696
+ const res = await fetch('/__og-viewer/api/pages');
10697
+ pages = await res.json();
10698
+ updateSummary();
10699
+ applyFilters();
10700
+ } catch(e) {
10701
+ console.error('Refresh failed:', e);
10702
+ } finally {
10703
+ btn.textContent = 'Refresh';
10704
+ btn.disabled = false;
10705
+ }
10706
+ }
10707
+
10708
+ function updateSummary() {
10709
+ document.getElementById('s-pages').textContent = pages.length;
10710
+ document.getElementById('s-errors').textContent = pages.reduce((s,p) => s + p.warnings.filter(w => w.level === 'error').length, 0);
10711
+ document.getElementById('s-warnings').textContent = pages.reduce((s,p) => s + p.warnings.filter(w => w.level === 'warning').length, 0);
10712
+ }
10713
+
10714
+ renderCards(pages);
10715
+ <\/script>
10716
+ </body>
10717
+ </html>`;
10718
+ }
10719
+ function createOgViewerPlugin(options) {
10720
+ return {
10721
+ name: "ox-content:og-viewer",
10722
+ apply: "serve",
10723
+ configureServer(server) {
10724
+ server.middlewares.use(async (req, res, next) => {
10725
+ if (req.url === "/__og-viewer" || req.url === "/__og-viewer/") {
10726
+ const root = server.config.root || process.cwd();
10727
+ try {
10728
+ const html = renderViewerHtml(await collectPages(options, root), options);
10729
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
10730
+ res.end(html);
10731
+ } catch (err) {
10732
+ res.statusCode = 500;
10733
+ res.end(`OG Viewer error: ${err}`);
10734
+ }
10735
+ return;
10736
+ }
10737
+ if (req.url === "/__og-viewer/api/pages") {
10738
+ const root = server.config.root || process.cwd();
10739
+ try {
10740
+ const pages = await collectPages(options, root);
10741
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
10742
+ res.end(JSON.stringify(pages));
10743
+ } catch (err) {
10744
+ res.statusCode = 500;
10745
+ res.end(JSON.stringify({ error: String(err) }));
10746
+ }
10747
+ return;
10748
+ }
10749
+ next();
10750
+ });
10751
+ }
10752
+ };
10753
+ }
10754
+
10354
10755
  //#endregion
10355
10756
  //#region src/jsx-runtime.ts
10356
10757
  /**
@@ -11018,7 +11419,7 @@ function oxContent(options = {}) {
11018
11419
  }
11019
11420
  };
11020
11421
  let searchIndexJson = "";
11021
- return [
11422
+ const plugins = [
11022
11423
  mainPlugin,
11023
11424
  environmentPlugin,
11024
11425
  docsPlugin,
@@ -11061,6 +11462,8 @@ function oxContent(options = {}) {
11061
11462
  }
11062
11463
  }
11063
11464
  ];
11465
+ if (resolvedOptions.ogViewer) plugins.push(createOgViewerPlugin(resolvedOptions));
11466
+ return plugins;
11064
11467
  }
11065
11468
  /**
11066
11469
  * Resolves plugin options with defaults.
@@ -11086,7 +11489,8 @@ function resolveOptions(options) {
11086
11489
  ogImageOptions: resolveOgImageOptions(options.ogImageOptions),
11087
11490
  transformers: options.transformers ?? [],
11088
11491
  docs: resolveDocsOptions(options.docs),
11089
- search: resolveSearchOptions(options.search)
11492
+ search: resolveSearchOptions(options.search),
11493
+ ogViewer: options.ogViewer ?? true
11090
11494
  };
11091
11495
  }
11092
11496
  /**