@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.js CHANGED
@@ -7006,7 +7006,7 @@ async function loadNapiBindings() {
7006
7006
  async function transformMarkdown(source, filePath, options, ssgOptions) {
7007
7007
  const napi = await loadNapiBindings();
7008
7008
  if (!napi) throw new Error("[ox-content] NAPI bindings not available. Please ensure @ox-content/napi is built.");
7009
- const { content: markdownContent, frontmatter } = parseFrontmatter(source);
7009
+ const { content: markdownContent, frontmatter } = parseFrontmatter$1(source);
7010
7010
  const result = napi.transform(markdownContent, {
7011
7011
  gfm: options.gfm,
7012
7012
  footnotes: options.footnotes,
@@ -7041,7 +7041,7 @@ async function transformMarkdown(source, filePath, options, ssgOptions) {
7041
7041
  * Parses YAML frontmatter from Markdown content.
7042
7042
  * Uses proper YAML parser for full nested object support.
7043
7043
  */
7044
- function parseFrontmatter(source) {
7044
+ function parseFrontmatter$1(source) {
7045
7045
  if (!source.startsWith("---")) return {
7046
7046
  content: source,
7047
7047
  frontmatter: {}
@@ -7981,13 +7981,16 @@ function resolveDocsOptions(options) {
7981
7981
  //#endregion
7982
7982
  //#region src/og-image/renderer.ts
7983
7983
  /**
7984
+ * HTML → PNG renderer using Chromium screenshots via Playwright.
7985
+ */
7986
+ /**
7984
7987
  * Wraps template HTML in a minimal document with viewport locked to given dimensions.
7985
7988
  */
7986
- function wrapHtml(bodyHtml, width, height) {
7989
+ function wrapHtml(bodyHtml, width, height, useBaseUrl) {
7987
7990
  return `<!DOCTYPE html>
7988
7991
  <html>
7989
7992
  <head>
7990
- <meta charset="UTF-8">
7993
+ <meta charset="UTF-8">${useBaseUrl ? `\n<base href="http://localhost/">` : ""}
7991
7994
  <style>
7992
7995
  * { margin: 0; padding: 0; box-sizing: border-box; }
7993
7996
  html, body { width: ${width}px; height: ${height}px; overflow: hidden; }
@@ -8003,14 +8006,48 @@ html, body { width: ${width}px; height: ${height}px; overflow: hidden; }
8003
8006
  * @param html - HTML string from template function
8004
8007
  * @param width - Image width
8005
8008
  * @param height - Image height
8009
+ * @param publicDir - Optional public directory for serving local assets (images, fonts, etc.)
8006
8010
  * @returns PNG buffer
8007
8011
  */
8008
- async function renderHtmlToPng(page, html, width, height) {
8012
+ async function renderHtmlToPng(page, html, width, height, publicDir) {
8009
8013
  await page.setViewportSize({
8010
8014
  width,
8011
8015
  height
8012
8016
  });
8013
- const fullHtml = wrapHtml(html, width, height);
8017
+ if (publicDir) {
8018
+ const fs = await import("fs/promises");
8019
+ await page.route("**/*", async (route) => {
8020
+ const url = new URL(route.request().url());
8021
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
8022
+ await route.continue();
8023
+ return;
8024
+ }
8025
+ const filePath = path.join(publicDir, url.pathname);
8026
+ try {
8027
+ const body = await fs.readFile(filePath);
8028
+ const ext = path.extname(filePath).toLowerCase();
8029
+ await route.fulfill({
8030
+ body,
8031
+ contentType: {
8032
+ ".svg": "image/svg+xml",
8033
+ ".png": "image/png",
8034
+ ".jpg": "image/jpeg",
8035
+ ".jpeg": "image/jpeg",
8036
+ ".gif": "image/gif",
8037
+ ".webp": "image/webp",
8038
+ ".woff": "font/woff",
8039
+ ".woff2": "font/woff2",
8040
+ ".ttf": "font/ttf",
8041
+ ".css": "text/css",
8042
+ ".js": "application/javascript"
8043
+ }[ext] || "application/octet-stream"
8044
+ });
8045
+ } catch {
8046
+ await route.continue();
8047
+ }
8048
+ });
8049
+ }
8050
+ const fullHtml = wrapHtml(html, width, height, !!publicDir);
8014
8051
  await page.setContent(fullHtml, { waitUntil: "networkidle" });
8015
8052
  const screenshot = await page.screenshot({
8016
8053
  type: "png",
@@ -8050,10 +8087,10 @@ async function openBrowser() {
8050
8087
  ]
8051
8088
  });
8052
8089
  return {
8053
- async renderPage(html, width, height) {
8090
+ async renderPage(html, width, height, publicDir) {
8054
8091
  const page = await browser.newPage();
8055
8092
  try {
8056
- return await renderHtmlToPng(page, html, width, height);
8093
+ return await renderHtmlToPng(page, html, width, height, publicDir);
8057
8094
  } finally {
8058
8095
  await page.close();
8059
8096
  }
@@ -8292,10 +8329,26 @@ async function resolveVueTemplate(templatePath, options, root) {
8292
8329
  await bundle.close();
8293
8330
  const Component = (await import(`${outfile}?t=${Date.now()}`)).default;
8294
8331
  if (!Component) throw new Error(`[ox-content:og-image] Vue template must have a default export: ${templatePath}`);
8332
+ let extractedCss = "";
8333
+ try {
8334
+ let compilerSfc;
8335
+ try {
8336
+ compilerSfc = await import("@vue/compiler-sfc");
8337
+ } catch {
8338
+ compilerSfc = null;
8339
+ }
8340
+ if (compilerSfc) {
8341
+ const sfcSource = await fs.readFile(templatePath, "utf-8");
8342
+ const { descriptor } = compilerSfc.parse(sfcSource, { filename: templatePath });
8343
+ for (const style of descriptor.styles) extractedCss += style.content;
8344
+ }
8345
+ } catch {}
8295
8346
  const { createSSRApp } = await import("vue");
8296
8347
  const { renderToString } = await import("vue/server-renderer");
8297
8348
  return async (props) => {
8298
- return renderToString(createSSRApp(Component, props));
8349
+ const html = await renderToString(createSSRApp(Component, props));
8350
+ if (extractedCss) return `<style>${extractedCss}</style>${html}`;
8351
+ return html;
8299
8352
  };
8300
8353
  }
8301
8354
  /**
@@ -8497,10 +8550,11 @@ async function generateOgImages(pages, options, root) {
8497
8550
  error: "Chromium not available"
8498
8551
  }));
8499
8552
  const results = [];
8553
+ const publicDir = path.join(root, "public");
8500
8554
  const concurrency = Math.max(1, options.concurrency);
8501
8555
  for (let i = 0; i < pages.length; i += concurrency) {
8502
8556
  const batch = pages.slice(i, i + concurrency);
8503
- const batchResults = await Promise.all(batch.map((entry) => renderSinglePage(entry, templateFn, templateSource, options, cacheDir, session)));
8557
+ const batchResults = await Promise.all(batch.map((entry) => renderSinglePage(entry, templateFn, templateSource, options, cacheDir, session, publicDir)));
8504
8558
  results.push(...batchResults);
8505
8559
  }
8506
8560
  return results;
@@ -8532,7 +8586,7 @@ async function tryServeAllFromCache(pages, templateSource, options, cacheDir) {
8532
8586
  /**
8533
8587
  * Renders a single page to PNG, with cache support.
8534
8588
  */
8535
- async function renderSinglePage(entry, templateFn, templateSource, options, cacheDir, session) {
8589
+ async function renderSinglePage(entry, templateFn, templateSource, options, cacheDir, session, publicDir) {
8536
8590
  const fs = await import("fs/promises");
8537
8591
  try {
8538
8592
  if (options.cache) {
@@ -8547,7 +8601,7 @@ async function renderSinglePage(entry, templateFn, templateSource, options, cach
8547
8601
  }
8548
8602
  }
8549
8603
  const html = await templateFn(entry.props);
8550
- const png = await session.renderPage(html, options.width, options.height);
8604
+ const png = await session.renderPage(html, options.width, options.height, publicDir);
8551
8605
  await fs.mkdir(path.dirname(entry.outputPath), { recursive: true });
8552
8606
  await fs.writeFile(entry.outputPath, png);
8553
8607
  if (options.cache) await writeCache(cacheDir, computeCacheKey(templateSource, entry.props, options.width, options.height), png);
@@ -9766,7 +9820,7 @@ function renderTemplate(template, data) {
9766
9820
  /**
9767
9821
  * Extracts title from content or frontmatter.
9768
9822
  */
9769
- function extractTitle(content, frontmatter) {
9823
+ function extractTitle$1(content, frontmatter) {
9770
9824
  if (frontmatter.title && typeof frontmatter.title === "string") return frontmatter.title;
9771
9825
  const h1Match = content.match(/<h1[^>]*>([^<]+)<\/h1>/i);
9772
9826
  if (h1Match) return h1Match[1].trim();
@@ -9851,7 +9905,7 @@ function getOutputPath(inputPath, srcDir, outDir, extension) {
9851
9905
  /**
9852
9906
  * Converts a markdown file path to a relative URL path.
9853
9907
  */
9854
- function getUrlPath(inputPath, srcDir) {
9908
+ function getUrlPath$1(inputPath, srcDir) {
9855
9909
  const baseName = path.relative(srcDir, inputPath).replace(/\.(?:md|markdown)$/i, "");
9856
9910
  if (baseName === "index" || baseName.endsWith("/index")) return baseName.replace(/\/?index$/, "") || "/";
9857
9911
  return baseName;
@@ -9860,7 +9914,7 @@ function getUrlPath(inputPath, srcDir) {
9860
9914
  * Converts a markdown file path to an href.
9861
9915
  */
9862
9916
  function getHref(inputPath, srcDir, base, extension) {
9863
- const urlPath = getUrlPath(inputPath, srcDir);
9917
+ const urlPath = getUrlPath$1(inputPath, srcDir);
9864
9918
  if (urlPath === "/" || urlPath === "") return `${base}index${extension}`;
9865
9919
  return `${base}${urlPath}/index${extension}`;
9866
9920
  }
@@ -9880,7 +9934,7 @@ function getOgImagePath(inputPath, srcDir, outDir) {
9880
9934
  * If siteUrl is provided, returns an absolute URL (required for SNS sharing).
9881
9935
  */
9882
9936
  function getOgImageUrl(inputPath, srcDir, base, siteUrl) {
9883
- const urlPath = getUrlPath(inputPath, srcDir);
9937
+ const urlPath = getUrlPath$1(inputPath, srcDir);
9884
9938
  let relativePath;
9885
9939
  if (urlPath === "/" || urlPath === "") relativePath = `${base}og-image.png`;
9886
9940
  else relativePath = `${base}${urlPath}/og-image.png`;
@@ -9934,7 +9988,7 @@ function buildNavItems(markdownFiles, srcDir, base, extension) {
9934
9988
  let groupKey = "";
9935
9989
  if (parts.length > 1) groupKey = parts[0];
9936
9990
  if (!groups.has(groupKey)) groups.set(groupKey, []);
9937
- const urlPath = getUrlPath(file, srcDir);
9991
+ const urlPath = getUrlPath$1(file, srcDir);
9938
9992
  let title;
9939
9993
  if (urlPath === "/" || urlPath === "") title = "Overview";
9940
9994
  else title = getDisplayTitle(file);
@@ -10023,7 +10077,7 @@ async function buildSsg(options, root) {
10023
10077
  transformedHtml = await transformAllPlugins(transformedHtml, pluginOptions);
10024
10078
  if (hasIslands(transformedHtml)) transformedHtml = (await transformIslands(transformedHtml)).html;
10025
10079
  transformedHtml = restoreMermaidSvgs(transformedHtml, mermaidSvgs);
10026
- const title = extractTitle(transformedHtml, result.frontmatter);
10080
+ const title = extractTitle$1(transformedHtml, result.frontmatter);
10027
10081
  const description = result.frontmatter.description;
10028
10082
  pageResults.push({
10029
10083
  inputPath,
@@ -10091,7 +10145,7 @@ async function buildSsg(options, root) {
10091
10145
  content: transformedHtml,
10092
10146
  toc,
10093
10147
  frontmatter,
10094
- path: getUrlPath(inputPath, srcDir),
10148
+ path: getUrlPath$1(inputPath, srcDir),
10095
10149
  href: getHref(inputPath, srcDir, base, ssgOptions.extension),
10096
10150
  entryPage
10097
10151
  }, navItems, siteName, base, pageOgImage, ssgOptions.theme);
@@ -10390,6 +10444,353 @@ export default { search, searchOptions, loadIndex };
10390
10444
  `;
10391
10445
  }
10392
10446
 
10447
+ //#endregion
10448
+ //#region src/og-viewer.ts
10449
+ /**
10450
+ * OG Viewer - Dev tool for previewing Open Graph metadata
10451
+ *
10452
+ * Accessible at /__og-viewer during development.
10453
+ * Shows all pages with their OG metadata, validation warnings,
10454
+ * and social card previews.
10455
+ */
10456
+ function parseFrontmatter(content) {
10457
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
10458
+ if (!match) return {};
10459
+ const yaml = match[1];
10460
+ const result = {};
10461
+ for (const line of yaml.split("\n")) {
10462
+ const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
10463
+ if (!kv) continue;
10464
+ const [, key, rawValue] = kv;
10465
+ let value = rawValue.trim();
10466
+ if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) value = value.slice(1, -1).split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
10467
+ else if (typeof value === "string" && /^['"].*['"]$/.test(value)) value = value.slice(1, -1);
10468
+ else if (value === "true") value = true;
10469
+ else if (value === "false") value = false;
10470
+ result[key] = value;
10471
+ }
10472
+ return result;
10473
+ }
10474
+ function extractTitle(content, frontmatter) {
10475
+ if (typeof frontmatter.title === "string" && frontmatter.title) return frontmatter.title;
10476
+ const match = content.match(/^#\s+(.+)$/m);
10477
+ return match ? match[1].trim() : "";
10478
+ }
10479
+ function getUrlPath(filePath, srcDir) {
10480
+ let rel = path.relative(srcDir, filePath).replace(/\\/g, "/");
10481
+ rel = rel.replace(/\.md$/, "");
10482
+ if (rel === "index") return "/";
10483
+ if (rel.endsWith("/index")) rel = rel.slice(0, -6);
10484
+ return "/" + rel;
10485
+ }
10486
+ function computeOgImageUrl(urlPath, base, siteUrl, generateOgImage, staticOgImage) {
10487
+ if (!generateOgImage) return staticOgImage || "";
10488
+ const cleanBase = base.endsWith("/") ? base : base + "/";
10489
+ let relativePath;
10490
+ if (urlPath === "/") relativePath = `${cleanBase}og-image.png`;
10491
+ else relativePath = `${cleanBase}${urlPath.replace(/^\//, "")}/og-image.png`;
10492
+ if (siteUrl) return `${siteUrl.replace(/\/$/, "")}${relativePath}`;
10493
+ return relativePath;
10494
+ }
10495
+ function validatePage(page, options) {
10496
+ const warnings = [];
10497
+ if (!page.title) warnings.push({
10498
+ level: "error",
10499
+ message: "title is missing"
10500
+ });
10501
+ else if (page.title.length > 70) warnings.push({
10502
+ level: "warning",
10503
+ message: `title is too long (${page.title.length}/70)`
10504
+ });
10505
+ if (!page.description) warnings.push({
10506
+ level: "warning",
10507
+ message: "description is missing"
10508
+ });
10509
+ else if (page.description.length > 200) warnings.push({
10510
+ level: "warning",
10511
+ message: `description is too long (${page.description.length}/200)`
10512
+ });
10513
+ if ((options.ogImage || options.ssg.generateOgImage) && !options.ssg.siteUrl) warnings.push({
10514
+ level: "warning",
10515
+ message: "ogImage enabled but siteUrl is not set"
10516
+ });
10517
+ return warnings;
10518
+ }
10519
+ async function collectPages(options, root) {
10520
+ const srcDir = path.resolve(root, options.srcDir);
10521
+ const files = await glob("**/*.md", {
10522
+ cwd: srcDir,
10523
+ absolute: true
10524
+ });
10525
+ const pages = [];
10526
+ const generateOgImage = options.ogImage || options.ssg.generateOgImage;
10527
+ for (const file of files.sort()) {
10528
+ const content = fs$1.readFileSync(file, "utf-8");
10529
+ const frontmatter = parseFrontmatter(content);
10530
+ if (frontmatter.layout === "entry") continue;
10531
+ const title = extractTitle(content, frontmatter);
10532
+ const description = typeof frontmatter.description === "string" ? frontmatter.description : "";
10533
+ const author = typeof frontmatter.author === "string" ? frontmatter.author : "";
10534
+ const tags = Array.isArray(frontmatter.tags) ? frontmatter.tags : typeof frontmatter.tags === "string" ? [frontmatter.tags] : [];
10535
+ const urlPath = getUrlPath(file, srcDir);
10536
+ const ogImageUrl = computeOgImageUrl(urlPath, options.base, options.ssg.siteUrl, generateOgImage, options.ssg.ogImage);
10537
+ const page = {
10538
+ path: path.relative(srcDir, file),
10539
+ urlPath,
10540
+ title,
10541
+ description,
10542
+ author,
10543
+ tags,
10544
+ ogImageUrl,
10545
+ warnings: []
10546
+ };
10547
+ page.warnings = validatePage(page, options);
10548
+ pages.push(page);
10549
+ }
10550
+ return pages;
10551
+ }
10552
+ function renderViewerHtml(pages, options) {
10553
+ const generateOgImage = options.ogImage || options.ssg.generateOgImage;
10554
+ const totalWarnings = pages.reduce((sum, p) => sum + p.warnings.filter((w) => w.level === "warning").length, 0);
10555
+ const totalErrors = pages.reduce((sum, p) => sum + p.warnings.filter((w) => w.level === "error").length, 0);
10556
+ return `<!DOCTYPE html>
10557
+ <html lang="en">
10558
+ <head>
10559
+ <meta charset="UTF-8">
10560
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
10561
+ <title>OG Viewer - ox-content</title>
10562
+ <style>
10563
+ :root {
10564
+ --bg: #ffffff;
10565
+ --bg-card: #f8f9fa;
10566
+ --bg-preview: #ffffff;
10567
+ --text: #1a1a2e;
10568
+ --text-muted: #6b7280;
10569
+ --border: #e5e7eb;
10570
+ --accent: #e8590c;
10571
+ --accent-light: #fff4e6;
10572
+ --error: #dc2626;
10573
+ --error-bg: #fef2f2;
10574
+ --warning: #d97706;
10575
+ --warning-bg: #fffbeb;
10576
+ --success: #16a34a;
10577
+ --tag-bg: #f0f0f0;
10578
+ --shadow: 0 1px 3px rgba(0,0,0,0.08);
10579
+ --radius: 8px;
10580
+ }
10581
+ @media (prefers-color-scheme: dark) {
10582
+ :root {
10583
+ --bg: #0f172a;
10584
+ --bg-card: #1e293b;
10585
+ --bg-preview: #334155;
10586
+ --text: #e2e8f0;
10587
+ --text-muted: #94a3b8;
10588
+ --border: #334155;
10589
+ --accent: #fb923c;
10590
+ --accent-light: #431407;
10591
+ --error: #f87171;
10592
+ --error-bg: #450a0a;
10593
+ --warning: #fbbf24;
10594
+ --warning-bg: #451a03;
10595
+ --success: #4ade80;
10596
+ --tag-bg: #334155;
10597
+ --shadow: 0 1px 3px rgba(0,0,0,0.3);
10598
+ }
10599
+ }
10600
+ * { margin: 0; padding: 0; box-sizing: border-box; }
10601
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); }
10602
+ .header { padding: 16px 24px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; }
10603
+ .header svg { width: 28px; height: 28px; color: var(--accent); }
10604
+ .header h1 { font-size: 18px; font-weight: 600; }
10605
+ .header h1 span { color: var(--text-muted); font-weight: 400; }
10606
+ .header-actions { margin-left: auto; }
10607
+ .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; }
10608
+ .btn:hover { border-color: var(--accent); color: var(--accent); }
10609
+ .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; }
10610
+ .summary-item { display: flex; align-items: center; gap: 4px; }
10611
+ .summary-item strong { color: var(--text); }
10612
+ .summary-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
10613
+ .dot-error { background: var(--error); }
10614
+ .dot-warning { background: var(--warning); }
10615
+ .dot-success { background: var(--success); }
10616
+ .toolbar { padding: 12px 24px; display: flex; gap: 8px; border-bottom: 1px solid var(--border); flex-wrap: wrap; align-items: center; }
10617
+ .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; }
10618
+ .filter-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
10619
+ .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; }
10620
+ .search-input::placeholder { color: var(--text-muted); }
10621
+ .container { padding: 24px; display: flex; flex-direction: column; gap: 20px; max-width: 1200px; margin: 0 auto; }
10622
+ .card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); box-shadow: var(--shadow); overflow: hidden; }
10623
+ .card-header { padding: 16px; border-bottom: 1px solid var(--border); }
10624
+ .card-path { font-size: 12px; color: var(--text-muted); font-family: monospace; margin-bottom: 4px; }
10625
+ .card-title { font-size: 16px; font-weight: 600; }
10626
+ .card-desc { font-size: 13px; color: var(--text-muted); margin-top: 4px; }
10627
+ .card-meta { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; align-items: center; }
10628
+ .tag { padding: 2px 8px; background: var(--tag-bg); border-radius: 4px; font-size: 11px; color: var(--text-muted); }
10629
+ .card-warnings { padding: 8px 16px; display: flex; flex-direction: column; gap: 4px; }
10630
+ .warning-item { font-size: 12px; padding: 4px 8px; border-radius: 4px; }
10631
+ .warning-item.error { background: var(--error-bg); color: var(--error); }
10632
+ .warning-item.warning { background: var(--warning-bg); color: var(--warning); }
10633
+ .card-previews { padding: 16px; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
10634
+ @media (max-width: 768px) { .card-previews { grid-template-columns: 1fr; } }
10635
+ .preview { border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
10636
+ .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; }
10637
+ .preview-card { background: var(--bg-preview); }
10638
+ .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; }
10639
+ .preview-img img { width: 100%; height: 100%; object-fit: cover; }
10640
+ .preview-body { padding: 10px 12px; }
10641
+ .preview-url { font-size: 11px; color: var(--text-muted); margin-bottom: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
10642
+ .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; }
10643
+ .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; }
10644
+ .empty { text-align: center; padding: 60px; color: var(--text-muted); }
10645
+ .spin { animation: spin 0.6s linear infinite; }
10646
+ @keyframes spin { to { transform: rotate(360deg); } }
10647
+ </style>
10648
+ </head>
10649
+ <body>
10650
+ <div class="header">
10651
+ <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>
10652
+ <h1>OG Viewer <span>/ ox-content</span></h1>
10653
+ <div class="header-actions">
10654
+ <button class="btn" id="refresh-btn" onclick="refresh()">Refresh</button>
10655
+ </div>
10656
+ </div>
10657
+ <div class="summary" id="summary">
10658
+ <div class="summary-item"><strong id="s-pages">${pages.length}</strong>&nbsp;pages</div>
10659
+ <div class="summary-item"><span class="summary-dot dot-error"></span>&nbsp;<strong id="s-errors">${totalErrors}</strong>&nbsp;errors</div>
10660
+ <div class="summary-item"><span class="summary-dot dot-warning"></span>&nbsp;<strong id="s-warnings">${totalWarnings}</strong>&nbsp;warnings</div>
10661
+ <div class="summary-item"><span class="summary-dot ${generateOgImage ? "dot-success" : "dot-warning"}"></span>&nbsp;ogImage: <strong>${generateOgImage ? "enabled" : "disabled"}</strong></div>
10662
+ </div>
10663
+ <div class="toolbar">
10664
+ <button class="filter-btn active" data-filter="all" onclick="setFilter('all')">All</button>
10665
+ <button class="filter-btn" data-filter="warnings" onclick="setFilter('warnings')">Warnings</button>
10666
+ <button class="filter-btn" data-filter="errors" onclick="setFilter('errors')">Errors</button>
10667
+ <input class="search-input" type="text" placeholder="Search pages..." oninput="applyFilters()" id="search-input">
10668
+ </div>
10669
+ <div class="container" id="container"></div>
10670
+
10671
+ <script>
10672
+ let pages = ${JSON.stringify(pages)};
10673
+ let currentFilter = 'all';
10674
+
10675
+ function setFilter(f) {
10676
+ currentFilter = f;
10677
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.toggle('active', b.dataset.filter === f));
10678
+ applyFilters();
10679
+ }
10680
+
10681
+ function applyFilters() {
10682
+ const q = document.getElementById('search-input').value.toLowerCase();
10683
+ const filtered = pages.filter(p => {
10684
+ if (currentFilter === 'errors' && !p.warnings.some(w => w.level === 'error')) return false;
10685
+ if (currentFilter === 'warnings' && !p.warnings.length) return false;
10686
+ if (q && !p.path.toLowerCase().includes(q) && !p.title.toLowerCase().includes(q) && !p.description.toLowerCase().includes(q)) return false;
10687
+ return true;
10688
+ });
10689
+ renderCards(filtered);
10690
+ }
10691
+
10692
+ function esc(s) {
10693
+ const d = document.createElement('div');
10694
+ d.textContent = s;
10695
+ return d.innerHTML;
10696
+ }
10697
+
10698
+ function renderCards(list) {
10699
+ const c = document.getElementById('container');
10700
+ if (!list.length) {
10701
+ c.innerHTML = '<div class="empty">No pages match the current filter.</div>';
10702
+ return;
10703
+ }
10704
+ c.innerHTML = list.map(p => {
10705
+ const warnings = p.warnings.map(w =>
10706
+ '<div class="warning-item ' + w.level + '">' + (w.level === 'error' ? '\\u2716' : '\\u26A0') + ' ' + esc(w.message) + '</div>'
10707
+ ).join('');
10708
+ const tags = p.tags.map(t => '<span class="tag">' + esc(t) + '</span>').join('');
10709
+ const author = p.author ? '<span class="tag">by ' + esc(p.author) + '</span>' : '';
10710
+ const imgHtml = p.ogImageUrl
10711
+ ? '<img src="' + esc(p.ogImageUrl) + '" onerror="this.parentNode.innerHTML=\\'No OG image\\'">'
10712
+ : 'No OG image';
10713
+ const siteHost = ${JSON.stringify(options.ssg.siteUrl || "example.com")};
10714
+ return '<div class="card">'
10715
+ + '<div class="card-header">'
10716
+ + '<div class="card-path">' + esc(p.path) + ' &rarr; ' + esc(p.urlPath) + '</div>'
10717
+ + '<div class="card-title">' + (esc(p.title) || '<em style="color:var(--error)">No title</em>') + '</div>'
10718
+ + (p.description ? '<div class="card-desc">' + esc(p.description) + '</div>' : '')
10719
+ + (tags || author ? '<div class="card-meta">' + author + tags + '</div>' : '')
10720
+ + '</div>'
10721
+ + (warnings ? '<div class="card-warnings">' + warnings + '</div>' : '')
10722
+ + '<div class="card-previews">'
10723
+ + '<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>'
10724
+ + '<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>'
10725
+ + '</div>'
10726
+ + '</div>';
10727
+ }).join('');
10728
+ }
10729
+
10730
+ async function refresh() {
10731
+ const btn = document.getElementById('refresh-btn');
10732
+ btn.textContent = 'Refreshing...';
10733
+ btn.disabled = true;
10734
+ try {
10735
+ const res = await fetch('/__og-viewer/api/pages');
10736
+ pages = await res.json();
10737
+ updateSummary();
10738
+ applyFilters();
10739
+ } catch(e) {
10740
+ console.error('Refresh failed:', e);
10741
+ } finally {
10742
+ btn.textContent = 'Refresh';
10743
+ btn.disabled = false;
10744
+ }
10745
+ }
10746
+
10747
+ function updateSummary() {
10748
+ document.getElementById('s-pages').textContent = pages.length;
10749
+ document.getElementById('s-errors').textContent = pages.reduce((s,p) => s + p.warnings.filter(w => w.level === 'error').length, 0);
10750
+ document.getElementById('s-warnings').textContent = pages.reduce((s,p) => s + p.warnings.filter(w => w.level === 'warning').length, 0);
10751
+ }
10752
+
10753
+ renderCards(pages);
10754
+ <\/script>
10755
+ </body>
10756
+ </html>`;
10757
+ }
10758
+ function createOgViewerPlugin(options) {
10759
+ return {
10760
+ name: "ox-content:og-viewer",
10761
+ apply: "serve",
10762
+ configureServer(server) {
10763
+ server.middlewares.use(async (req, res, next) => {
10764
+ if (req.url === "/__og-viewer" || req.url === "/__og-viewer/") {
10765
+ const root = server.config.root || process.cwd();
10766
+ try {
10767
+ const html = renderViewerHtml(await collectPages(options, root), options);
10768
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
10769
+ res.end(html);
10770
+ } catch (err) {
10771
+ res.statusCode = 500;
10772
+ res.end(`OG Viewer error: ${err}`);
10773
+ }
10774
+ return;
10775
+ }
10776
+ if (req.url === "/__og-viewer/api/pages") {
10777
+ const root = server.config.root || process.cwd();
10778
+ try {
10779
+ const pages = await collectPages(options, root);
10780
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
10781
+ res.end(JSON.stringify(pages));
10782
+ } catch (err) {
10783
+ res.statusCode = 500;
10784
+ res.end(JSON.stringify({ error: String(err) }));
10785
+ }
10786
+ return;
10787
+ }
10788
+ next();
10789
+ });
10790
+ }
10791
+ };
10792
+ }
10793
+
10393
10794
  //#endregion
10394
10795
  //#region src/jsx-runtime.ts
10395
10796
  /**
@@ -11057,7 +11458,7 @@ function oxContent(options = {}) {
11057
11458
  }
11058
11459
  };
11059
11460
  let searchIndexJson = "";
11060
- return [
11461
+ const plugins = [
11061
11462
  mainPlugin,
11062
11463
  environmentPlugin,
11063
11464
  docsPlugin,
@@ -11100,6 +11501,8 @@ function oxContent(options = {}) {
11100
11501
  }
11101
11502
  }
11102
11503
  ];
11504
+ if (resolvedOptions.ogViewer) plugins.push(createOgViewerPlugin(resolvedOptions));
11505
+ return plugins;
11103
11506
  }
11104
11507
  /**
11105
11508
  * Resolves plugin options with defaults.
@@ -11125,7 +11528,8 @@ function resolveOptions(options) {
11125
11528
  ogImageOptions: resolveOgImageOptions(options.ogImageOptions),
11126
11529
  transformers: options.transformers ?? [],
11127
11530
  docs: resolveDocsOptions(options.docs),
11128
- search: resolveSearchOptions(options.search)
11531
+ search: resolveSearchOptions(options.search),
11532
+ ogViewer: options.ogViewer ?? true
11129
11533
  };
11130
11534
  }
11131
11535
  /**