@ox-content/vite-plugin 0.6.0 → 0.7.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,6 +7981,9 @@ 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
7989
  function wrapHtml(bodyHtml, width, height) {
@@ -8003,13 +8006,47 @@ 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
  });
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
+ }
8013
8050
  const fullHtml = wrapHtml(html, width, height);
8014
8051
  await page.setContent(fullHtml, { waitUntil: "networkidle" });
8015
8052
  const screenshot = await page.screenshot({
@@ -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,35 @@ 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
+ const scopeId = Component.__scopeId;
8344
+ for (const style of descriptor.styles) if (style.scoped && scopeId) {
8345
+ const result = compilerSfc.compileStyle({
8346
+ id: scopeId,
8347
+ source: style.content,
8348
+ scoped: true,
8349
+ filename: templatePath
8350
+ });
8351
+ if (!result.errors.length) extractedCss += result.code;
8352
+ } else extractedCss += style.content;
8353
+ }
8354
+ } catch {}
8295
8355
  const { createSSRApp } = await import("vue");
8296
8356
  const { renderToString } = await import("vue/server-renderer");
8297
8357
  return async (props) => {
8298
- return renderToString(createSSRApp(Component, props));
8358
+ const html = await renderToString(createSSRApp(Component, props));
8359
+ if (extractedCss) return `<style>${extractedCss}</style>${html}`;
8360
+ return html;
8299
8361
  };
8300
8362
  }
8301
8363
  /**
@@ -8497,10 +8559,11 @@ async function generateOgImages(pages, options, root) {
8497
8559
  error: "Chromium not available"
8498
8560
  }));
8499
8561
  const results = [];
8562
+ const publicDir = path.join(root, "public");
8500
8563
  const concurrency = Math.max(1, options.concurrency);
8501
8564
  for (let i = 0; i < pages.length; i += concurrency) {
8502
8565
  const batch = pages.slice(i, i + concurrency);
8503
- const batchResults = await Promise.all(batch.map((entry) => renderSinglePage(entry, templateFn, templateSource, options, cacheDir, session)));
8566
+ const batchResults = await Promise.all(batch.map((entry) => renderSinglePage(entry, templateFn, templateSource, options, cacheDir, session, publicDir)));
8504
8567
  results.push(...batchResults);
8505
8568
  }
8506
8569
  return results;
@@ -8532,7 +8595,7 @@ async function tryServeAllFromCache(pages, templateSource, options, cacheDir) {
8532
8595
  /**
8533
8596
  * Renders a single page to PNG, with cache support.
8534
8597
  */
8535
- async function renderSinglePage(entry, templateFn, templateSource, options, cacheDir, session) {
8598
+ async function renderSinglePage(entry, templateFn, templateSource, options, cacheDir, session, publicDir) {
8536
8599
  const fs = await import("fs/promises");
8537
8600
  try {
8538
8601
  if (options.cache) {
@@ -8547,7 +8610,7 @@ async function renderSinglePage(entry, templateFn, templateSource, options, cach
8547
8610
  }
8548
8611
  }
8549
8612
  const html = await templateFn(entry.props);
8550
- const png = await session.renderPage(html, options.width, options.height);
8613
+ const png = await session.renderPage(html, options.width, options.height, publicDir);
8551
8614
  await fs.mkdir(path.dirname(entry.outputPath), { recursive: true });
8552
8615
  await fs.writeFile(entry.outputPath, png);
8553
8616
  if (options.cache) await writeCache(cacheDir, computeCacheKey(templateSource, entry.props, options.width, options.height), png);
@@ -9766,7 +9829,7 @@ function renderTemplate(template, data) {
9766
9829
  /**
9767
9830
  * Extracts title from content or frontmatter.
9768
9831
  */
9769
- function extractTitle(content, frontmatter) {
9832
+ function extractTitle$1(content, frontmatter) {
9770
9833
  if (frontmatter.title && typeof frontmatter.title === "string") return frontmatter.title;
9771
9834
  const h1Match = content.match(/<h1[^>]*>([^<]+)<\/h1>/i);
9772
9835
  if (h1Match) return h1Match[1].trim();
@@ -9851,7 +9914,7 @@ function getOutputPath(inputPath, srcDir, outDir, extension) {
9851
9914
  /**
9852
9915
  * Converts a markdown file path to a relative URL path.
9853
9916
  */
9854
- function getUrlPath(inputPath, srcDir) {
9917
+ function getUrlPath$1(inputPath, srcDir) {
9855
9918
  const baseName = path.relative(srcDir, inputPath).replace(/\.(?:md|markdown)$/i, "");
9856
9919
  if (baseName === "index" || baseName.endsWith("/index")) return baseName.replace(/\/?index$/, "") || "/";
9857
9920
  return baseName;
@@ -9860,7 +9923,7 @@ function getUrlPath(inputPath, srcDir) {
9860
9923
  * Converts a markdown file path to an href.
9861
9924
  */
9862
9925
  function getHref(inputPath, srcDir, base, extension) {
9863
- const urlPath = getUrlPath(inputPath, srcDir);
9926
+ const urlPath = getUrlPath$1(inputPath, srcDir);
9864
9927
  if (urlPath === "/" || urlPath === "") return `${base}index${extension}`;
9865
9928
  return `${base}${urlPath}/index${extension}`;
9866
9929
  }
@@ -9880,7 +9943,7 @@ function getOgImagePath(inputPath, srcDir, outDir) {
9880
9943
  * If siteUrl is provided, returns an absolute URL (required for SNS sharing).
9881
9944
  */
9882
9945
  function getOgImageUrl(inputPath, srcDir, base, siteUrl) {
9883
- const urlPath = getUrlPath(inputPath, srcDir);
9946
+ const urlPath = getUrlPath$1(inputPath, srcDir);
9884
9947
  let relativePath;
9885
9948
  if (urlPath === "/" || urlPath === "") relativePath = `${base}og-image.png`;
9886
9949
  else relativePath = `${base}${urlPath}/og-image.png`;
@@ -9934,7 +9997,7 @@ function buildNavItems(markdownFiles, srcDir, base, extension) {
9934
9997
  let groupKey = "";
9935
9998
  if (parts.length > 1) groupKey = parts[0];
9936
9999
  if (!groups.has(groupKey)) groups.set(groupKey, []);
9937
- const urlPath = getUrlPath(file, srcDir);
10000
+ const urlPath = getUrlPath$1(file, srcDir);
9938
10001
  let title;
9939
10002
  if (urlPath === "/" || urlPath === "") title = "Overview";
9940
10003
  else title = getDisplayTitle(file);
@@ -10023,7 +10086,7 @@ async function buildSsg(options, root) {
10023
10086
  transformedHtml = await transformAllPlugins(transformedHtml, pluginOptions);
10024
10087
  if (hasIslands(transformedHtml)) transformedHtml = (await transformIslands(transformedHtml)).html;
10025
10088
  transformedHtml = restoreMermaidSvgs(transformedHtml, mermaidSvgs);
10026
- const title = extractTitle(transformedHtml, result.frontmatter);
10089
+ const title = extractTitle$1(transformedHtml, result.frontmatter);
10027
10090
  const description = result.frontmatter.description;
10028
10091
  pageResults.push({
10029
10092
  inputPath,
@@ -10091,7 +10154,7 @@ async function buildSsg(options, root) {
10091
10154
  content: transformedHtml,
10092
10155
  toc,
10093
10156
  frontmatter,
10094
- path: getUrlPath(inputPath, srcDir),
10157
+ path: getUrlPath$1(inputPath, srcDir),
10095
10158
  href: getHref(inputPath, srcDir, base, ssgOptions.extension),
10096
10159
  entryPage
10097
10160
  }, navItems, siteName, base, pageOgImage, ssgOptions.theme);
@@ -10390,6 +10453,353 @@ export default { search, searchOptions, loadIndex };
10390
10453
  `;
10391
10454
  }
10392
10455
 
10456
+ //#endregion
10457
+ //#region src/og-viewer.ts
10458
+ /**
10459
+ * OG Viewer - Dev tool for previewing Open Graph metadata
10460
+ *
10461
+ * Accessible at /__og-viewer during development.
10462
+ * Shows all pages with their OG metadata, validation warnings,
10463
+ * and social card previews.
10464
+ */
10465
+ function parseFrontmatter(content) {
10466
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
10467
+ if (!match) return {};
10468
+ const yaml = match[1];
10469
+ const result = {};
10470
+ for (const line of yaml.split("\n")) {
10471
+ const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
10472
+ if (!kv) continue;
10473
+ const [, key, rawValue] = kv;
10474
+ let value = rawValue.trim();
10475
+ if (typeof value === "string" && value.startsWith("[") && value.endsWith("]")) value = value.slice(1, -1).split(",").map((s) => s.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
10476
+ else if (typeof value === "string" && /^['"].*['"]$/.test(value)) value = value.slice(1, -1);
10477
+ else if (value === "true") value = true;
10478
+ else if (value === "false") value = false;
10479
+ result[key] = value;
10480
+ }
10481
+ return result;
10482
+ }
10483
+ function extractTitle(content, frontmatter) {
10484
+ if (typeof frontmatter.title === "string" && frontmatter.title) return frontmatter.title;
10485
+ const match = content.match(/^#\s+(.+)$/m);
10486
+ return match ? match[1].trim() : "";
10487
+ }
10488
+ function getUrlPath(filePath, srcDir) {
10489
+ let rel = path.relative(srcDir, filePath).replace(/\\/g, "/");
10490
+ rel = rel.replace(/\.md$/, "");
10491
+ if (rel === "index") return "/";
10492
+ if (rel.endsWith("/index")) rel = rel.slice(0, -6);
10493
+ return "/" + rel;
10494
+ }
10495
+ function computeOgImageUrl(urlPath, base, siteUrl, generateOgImage, staticOgImage) {
10496
+ if (!generateOgImage) return staticOgImage || "";
10497
+ const cleanBase = base.endsWith("/") ? base : base + "/";
10498
+ let relativePath;
10499
+ if (urlPath === "/") relativePath = `${cleanBase}og-image.png`;
10500
+ else relativePath = `${cleanBase}${urlPath.replace(/^\//, "")}/og-image.png`;
10501
+ if (siteUrl) return `${siteUrl.replace(/\/$/, "")}${relativePath}`;
10502
+ return relativePath;
10503
+ }
10504
+ function validatePage(page, options) {
10505
+ const warnings = [];
10506
+ if (!page.title) warnings.push({
10507
+ level: "error",
10508
+ message: "title is missing"
10509
+ });
10510
+ else if (page.title.length > 70) warnings.push({
10511
+ level: "warning",
10512
+ message: `title is too long (${page.title.length}/70)`
10513
+ });
10514
+ if (!page.description) warnings.push({
10515
+ level: "warning",
10516
+ message: "description is missing"
10517
+ });
10518
+ else if (page.description.length > 200) warnings.push({
10519
+ level: "warning",
10520
+ message: `description is too long (${page.description.length}/200)`
10521
+ });
10522
+ if ((options.ogImage || options.ssg.generateOgImage) && !options.ssg.siteUrl) warnings.push({
10523
+ level: "warning",
10524
+ message: "ogImage enabled but siteUrl is not set"
10525
+ });
10526
+ return warnings;
10527
+ }
10528
+ async function collectPages(options, root) {
10529
+ const srcDir = path.resolve(root, options.srcDir);
10530
+ const files = await glob("**/*.md", {
10531
+ cwd: srcDir,
10532
+ absolute: true
10533
+ });
10534
+ const pages = [];
10535
+ const generateOgImage = options.ogImage || options.ssg.generateOgImage;
10536
+ for (const file of files.sort()) {
10537
+ const content = fs$1.readFileSync(file, "utf-8");
10538
+ const frontmatter = parseFrontmatter(content);
10539
+ if (frontmatter.layout === "entry") continue;
10540
+ const title = extractTitle(content, frontmatter);
10541
+ const description = typeof frontmatter.description === "string" ? frontmatter.description : "";
10542
+ const author = typeof frontmatter.author === "string" ? frontmatter.author : "";
10543
+ const tags = Array.isArray(frontmatter.tags) ? frontmatter.tags : typeof frontmatter.tags === "string" ? [frontmatter.tags] : [];
10544
+ const urlPath = getUrlPath(file, srcDir);
10545
+ const ogImageUrl = computeOgImageUrl(urlPath, options.base, options.ssg.siteUrl, generateOgImage, options.ssg.ogImage);
10546
+ const page = {
10547
+ path: path.relative(srcDir, file),
10548
+ urlPath,
10549
+ title,
10550
+ description,
10551
+ author,
10552
+ tags,
10553
+ ogImageUrl,
10554
+ warnings: []
10555
+ };
10556
+ page.warnings = validatePage(page, options);
10557
+ pages.push(page);
10558
+ }
10559
+ return pages;
10560
+ }
10561
+ function renderViewerHtml(pages, options) {
10562
+ const generateOgImage = options.ogImage || options.ssg.generateOgImage;
10563
+ const totalWarnings = pages.reduce((sum, p) => sum + p.warnings.filter((w) => w.level === "warning").length, 0);
10564
+ const totalErrors = pages.reduce((sum, p) => sum + p.warnings.filter((w) => w.level === "error").length, 0);
10565
+ return `<!DOCTYPE html>
10566
+ <html lang="en">
10567
+ <head>
10568
+ <meta charset="UTF-8">
10569
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
10570
+ <title>OG Viewer - ox-content</title>
10571
+ <style>
10572
+ :root {
10573
+ --bg: #ffffff;
10574
+ --bg-card: #f8f9fa;
10575
+ --bg-preview: #ffffff;
10576
+ --text: #1a1a2e;
10577
+ --text-muted: #6b7280;
10578
+ --border: #e5e7eb;
10579
+ --accent: #e8590c;
10580
+ --accent-light: #fff4e6;
10581
+ --error: #dc2626;
10582
+ --error-bg: #fef2f2;
10583
+ --warning: #d97706;
10584
+ --warning-bg: #fffbeb;
10585
+ --success: #16a34a;
10586
+ --tag-bg: #f0f0f0;
10587
+ --shadow: 0 1px 3px rgba(0,0,0,0.08);
10588
+ --radius: 8px;
10589
+ }
10590
+ @media (prefers-color-scheme: dark) {
10591
+ :root {
10592
+ --bg: #0f172a;
10593
+ --bg-card: #1e293b;
10594
+ --bg-preview: #334155;
10595
+ --text: #e2e8f0;
10596
+ --text-muted: #94a3b8;
10597
+ --border: #334155;
10598
+ --accent: #fb923c;
10599
+ --accent-light: #431407;
10600
+ --error: #f87171;
10601
+ --error-bg: #450a0a;
10602
+ --warning: #fbbf24;
10603
+ --warning-bg: #451a03;
10604
+ --success: #4ade80;
10605
+ --tag-bg: #334155;
10606
+ --shadow: 0 1px 3px rgba(0,0,0,0.3);
10607
+ }
10608
+ }
10609
+ * { margin: 0; padding: 0; box-sizing: border-box; }
10610
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; background: var(--bg); color: var(--text); }
10611
+ .header { padding: 16px 24px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 12px; }
10612
+ .header svg { width: 28px; height: 28px; color: var(--accent); }
10613
+ .header h1 { font-size: 18px; font-weight: 600; }
10614
+ .header h1 span { color: var(--text-muted); font-weight: 400; }
10615
+ .header-actions { margin-left: auto; }
10616
+ .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; }
10617
+ .btn:hover { border-color: var(--accent); color: var(--accent); }
10618
+ .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; }
10619
+ .summary-item { display: flex; align-items: center; gap: 4px; }
10620
+ .summary-item strong { color: var(--text); }
10621
+ .summary-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
10622
+ .dot-error { background: var(--error); }
10623
+ .dot-warning { background: var(--warning); }
10624
+ .dot-success { background: var(--success); }
10625
+ .toolbar { padding: 12px 24px; display: flex; gap: 8px; border-bottom: 1px solid var(--border); flex-wrap: wrap; align-items: center; }
10626
+ .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; }
10627
+ .filter-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
10628
+ .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; }
10629
+ .search-input::placeholder { color: var(--text-muted); }
10630
+ .container { padding: 24px; display: flex; flex-direction: column; gap: 20px; max-width: 1200px; margin: 0 auto; }
10631
+ .card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); box-shadow: var(--shadow); overflow: hidden; }
10632
+ .card-header { padding: 16px; border-bottom: 1px solid var(--border); }
10633
+ .card-path { font-size: 12px; color: var(--text-muted); font-family: monospace; margin-bottom: 4px; }
10634
+ .card-title { font-size: 16px; font-weight: 600; }
10635
+ .card-desc { font-size: 13px; color: var(--text-muted); margin-top: 4px; }
10636
+ .card-meta { display: flex; gap: 8px; margin-top: 8px; flex-wrap: wrap; align-items: center; }
10637
+ .tag { padding: 2px 8px; background: var(--tag-bg); border-radius: 4px; font-size: 11px; color: var(--text-muted); }
10638
+ .card-warnings { padding: 8px 16px; display: flex; flex-direction: column; gap: 4px; }
10639
+ .warning-item { font-size: 12px; padding: 4px 8px; border-radius: 4px; }
10640
+ .warning-item.error { background: var(--error-bg); color: var(--error); }
10641
+ .warning-item.warning { background: var(--warning-bg); color: var(--warning); }
10642
+ .card-previews { padding: 16px; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
10643
+ @media (max-width: 768px) { .card-previews { grid-template-columns: 1fr; } }
10644
+ .preview { border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
10645
+ .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; }
10646
+ .preview-card { background: var(--bg-preview); }
10647
+ .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; }
10648
+ .preview-img img { width: 100%; height: 100%; object-fit: cover; }
10649
+ .preview-body { padding: 10px 12px; }
10650
+ .preview-url { font-size: 11px; color: var(--text-muted); margin-bottom: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
10651
+ .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; }
10652
+ .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; }
10653
+ .empty { text-align: center; padding: 60px; color: var(--text-muted); }
10654
+ .spin { animation: spin 0.6s linear infinite; }
10655
+ @keyframes spin { to { transform: rotate(360deg); } }
10656
+ </style>
10657
+ </head>
10658
+ <body>
10659
+ <div class="header">
10660
+ <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>
10661
+ <h1>OG Viewer <span>/ ox-content</span></h1>
10662
+ <div class="header-actions">
10663
+ <button class="btn" id="refresh-btn" onclick="refresh()">Refresh</button>
10664
+ </div>
10665
+ </div>
10666
+ <div class="summary" id="summary">
10667
+ <div class="summary-item"><strong id="s-pages">${pages.length}</strong>&nbsp;pages</div>
10668
+ <div class="summary-item"><span class="summary-dot dot-error"></span>&nbsp;<strong id="s-errors">${totalErrors}</strong>&nbsp;errors</div>
10669
+ <div class="summary-item"><span class="summary-dot dot-warning"></span>&nbsp;<strong id="s-warnings">${totalWarnings}</strong>&nbsp;warnings</div>
10670
+ <div class="summary-item"><span class="summary-dot ${generateOgImage ? "dot-success" : "dot-warning"}"></span>&nbsp;ogImage: <strong>${generateOgImage ? "enabled" : "disabled"}</strong></div>
10671
+ </div>
10672
+ <div class="toolbar">
10673
+ <button class="filter-btn active" data-filter="all" onclick="setFilter('all')">All</button>
10674
+ <button class="filter-btn" data-filter="warnings" onclick="setFilter('warnings')">Warnings</button>
10675
+ <button class="filter-btn" data-filter="errors" onclick="setFilter('errors')">Errors</button>
10676
+ <input class="search-input" type="text" placeholder="Search pages..." oninput="applyFilters()" id="search-input">
10677
+ </div>
10678
+ <div class="container" id="container"></div>
10679
+
10680
+ <script>
10681
+ let pages = ${JSON.stringify(pages)};
10682
+ let currentFilter = 'all';
10683
+
10684
+ function setFilter(f) {
10685
+ currentFilter = f;
10686
+ document.querySelectorAll('.filter-btn').forEach(b => b.classList.toggle('active', b.dataset.filter === f));
10687
+ applyFilters();
10688
+ }
10689
+
10690
+ function applyFilters() {
10691
+ const q = document.getElementById('search-input').value.toLowerCase();
10692
+ const filtered = pages.filter(p => {
10693
+ if (currentFilter === 'errors' && !p.warnings.some(w => w.level === 'error')) return false;
10694
+ if (currentFilter === 'warnings' && !p.warnings.length) return false;
10695
+ if (q && !p.path.toLowerCase().includes(q) && !p.title.toLowerCase().includes(q) && !p.description.toLowerCase().includes(q)) return false;
10696
+ return true;
10697
+ });
10698
+ renderCards(filtered);
10699
+ }
10700
+
10701
+ function esc(s) {
10702
+ const d = document.createElement('div');
10703
+ d.textContent = s;
10704
+ return d.innerHTML;
10705
+ }
10706
+
10707
+ function renderCards(list) {
10708
+ const c = document.getElementById('container');
10709
+ if (!list.length) {
10710
+ c.innerHTML = '<div class="empty">No pages match the current filter.</div>';
10711
+ return;
10712
+ }
10713
+ c.innerHTML = list.map(p => {
10714
+ const warnings = p.warnings.map(w =>
10715
+ '<div class="warning-item ' + w.level + '">' + (w.level === 'error' ? '\\u2716' : '\\u26A0') + ' ' + esc(w.message) + '</div>'
10716
+ ).join('');
10717
+ const tags = p.tags.map(t => '<span class="tag">' + esc(t) + '</span>').join('');
10718
+ const author = p.author ? '<span class="tag">by ' + esc(p.author) + '</span>' : '';
10719
+ const imgHtml = p.ogImageUrl
10720
+ ? '<img src="' + esc(p.ogImageUrl) + '" onerror="this.parentNode.innerHTML=\\'No OG image\\'">'
10721
+ : 'No OG image';
10722
+ const siteHost = ${JSON.stringify(options.ssg.siteUrl || "example.com")};
10723
+ return '<div class="card">'
10724
+ + '<div class="card-header">'
10725
+ + '<div class="card-path">' + esc(p.path) + ' &rarr; ' + esc(p.urlPath) + '</div>'
10726
+ + '<div class="card-title">' + (esc(p.title) || '<em style="color:var(--error)">No title</em>') + '</div>'
10727
+ + (p.description ? '<div class="card-desc">' + esc(p.description) + '</div>' : '')
10728
+ + (tags || author ? '<div class="card-meta">' + author + tags + '</div>' : '')
10729
+ + '</div>'
10730
+ + (warnings ? '<div class="card-warnings">' + warnings + '</div>' : '')
10731
+ + '<div class="card-previews">'
10732
+ + '<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>'
10733
+ + '<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>'
10734
+ + '</div>'
10735
+ + '</div>';
10736
+ }).join('');
10737
+ }
10738
+
10739
+ async function refresh() {
10740
+ const btn = document.getElementById('refresh-btn');
10741
+ btn.textContent = 'Refreshing...';
10742
+ btn.disabled = true;
10743
+ try {
10744
+ const res = await fetch('/__og-viewer/api/pages');
10745
+ pages = await res.json();
10746
+ updateSummary();
10747
+ applyFilters();
10748
+ } catch(e) {
10749
+ console.error('Refresh failed:', e);
10750
+ } finally {
10751
+ btn.textContent = 'Refresh';
10752
+ btn.disabled = false;
10753
+ }
10754
+ }
10755
+
10756
+ function updateSummary() {
10757
+ document.getElementById('s-pages').textContent = pages.length;
10758
+ document.getElementById('s-errors').textContent = pages.reduce((s,p) => s + p.warnings.filter(w => w.level === 'error').length, 0);
10759
+ document.getElementById('s-warnings').textContent = pages.reduce((s,p) => s + p.warnings.filter(w => w.level === 'warning').length, 0);
10760
+ }
10761
+
10762
+ renderCards(pages);
10763
+ <\/script>
10764
+ </body>
10765
+ </html>`;
10766
+ }
10767
+ function createOgViewerPlugin(options) {
10768
+ return {
10769
+ name: "ox-content:og-viewer",
10770
+ apply: "serve",
10771
+ configureServer(server) {
10772
+ server.middlewares.use(async (req, res, next) => {
10773
+ if (req.url === "/__og-viewer" || req.url === "/__og-viewer/") {
10774
+ const root = server.config.root || process.cwd();
10775
+ try {
10776
+ const html = renderViewerHtml(await collectPages(options, root), options);
10777
+ res.setHeader("Content-Type", "text/html; charset=utf-8");
10778
+ res.end(html);
10779
+ } catch (err) {
10780
+ res.statusCode = 500;
10781
+ res.end(`OG Viewer error: ${err}`);
10782
+ }
10783
+ return;
10784
+ }
10785
+ if (req.url === "/__og-viewer/api/pages") {
10786
+ const root = server.config.root || process.cwd();
10787
+ try {
10788
+ const pages = await collectPages(options, root);
10789
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
10790
+ res.end(JSON.stringify(pages));
10791
+ } catch (err) {
10792
+ res.statusCode = 500;
10793
+ res.end(JSON.stringify({ error: String(err) }));
10794
+ }
10795
+ return;
10796
+ }
10797
+ next();
10798
+ });
10799
+ }
10800
+ };
10801
+ }
10802
+
10393
10803
  //#endregion
10394
10804
  //#region src/jsx-runtime.ts
10395
10805
  /**
@@ -11057,7 +11467,7 @@ function oxContent(options = {}) {
11057
11467
  }
11058
11468
  };
11059
11469
  let searchIndexJson = "";
11060
- return [
11470
+ const plugins = [
11061
11471
  mainPlugin,
11062
11472
  environmentPlugin,
11063
11473
  docsPlugin,
@@ -11100,6 +11510,8 @@ function oxContent(options = {}) {
11100
11510
  }
11101
11511
  }
11102
11512
  ];
11513
+ if (resolvedOptions.ogViewer) plugins.push(createOgViewerPlugin(resolvedOptions));
11514
+ return plugins;
11103
11515
  }
11104
11516
  /**
11105
11517
  * Resolves plugin options with defaults.
@@ -11125,7 +11537,8 @@ function resolveOptions(options) {
11125
11537
  ogImageOptions: resolveOgImageOptions(options.ogImageOptions),
11126
11538
  transformers: options.transformers ?? [],
11127
11539
  docs: resolveDocsOptions(options.docs),
11128
- search: resolveSearchOptions(options.search)
11540
+ search: resolveSearchOptions(options.search),
11541
+ ogViewer: options.ogViewer ?? true
11129
11542
  };
11130
11543
  }
11131
11544
  /**