Skip to content

Commit 84c8dae

Browse files
TMHSDigitalclaude
andcommitted
Render the guides as site pages
Each guide in config.guides is built to guide/<id>/ with the site's head, nav, breadcrumb, footer and Article plus BreadcrumbList structured data, and an "Edit this guide on GitHub" link. A separate renderer (site/lib/guide-render.mjs) handles the tables, fenced code, blockquotes, numbered lists and ### headings guides use, with GitHub-compatible heading ids so existing #anchors keep working; entry bodies keep the strict renderer. Links to entries go to entry pages and links to another guide go to its page. The homepage Guides list, footers ("Use the data"), sitemap.xml and llms.txt point at the pages, and the entry facts panel links the licence to the licence guide. All 11 guide pages pass the page checks; cross-guide anchors resolve; checked in Chromium at 1280 and 375 px with no horizontal scroll. Closes #159 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent e983cf9 commit 84c8dae

13 files changed

Lines changed: 366 additions & 14 deletions

‎site/build.mjs‎

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import path from "node:path";
1616
import { fileURLToPath } from "node:url";
1717
import { baseHeadHtml, commercialLabel, headAssetsHtml, esc, latestAllowedDate, OG_CARD_NAME, PAGE_MODULES, sponsorButtonHtml } from "./lib/shared.mjs";
1818
import { filmstripHtml, groupedEntriesHtml } from "./lib/cards.mjs";
19+
import { guidePageHtml } from "./lib/guide-page.mjs";
20+
import { renderGuide } from "./lib/guide-render.mjs";
1921
import { categoryPageHtml } from "./lib/category-page.mjs";
2022
import { filmstripPick, isAttributionLicence, previewCredit, thumbRelPath } from "./lib/thumbs.mjs";
2123
import { parseFrontmatter, summaryFromBody } from "./lib/frontmatter.mjs";
@@ -203,7 +205,7 @@ function guideRowsHtml(guides, repo) {
203205
return (guides || [])
204206
.map(
205207
(g) =>
206-
`<li><a href="${esc(`${repo}/blob/main/${g.path}`)}" rel="noopener noreferrer">${esc(g.title)}<span>${esc(g.path)}</span></a></li>`
208+
`<li><a href="guide/${esc(g.id)}/">${esc(g.title)}<span>${esc(g.path)}</span></a></li>`
207209
)
208210
.join("\n");
209211
}
@@ -484,6 +486,44 @@ function writeCategoryPages({ entries, config, stats, stamp, hasCard, now }) {
484486
return { written };
485487
}
486488

489+
/**
490+
* docs/ guides as guide/<id>/ pages. Links to entries go to their pages,
491+
* links to another guide go to its page, anything else to GitHub.
492+
*/
493+
function writeGuidePages({ entries, config, stats, stamp, hasCard }) {
494+
const written = [];
495+
const errors = [];
496+
const resolverFor = linkResolvers(entries, config, "../../entry/");
497+
const guideByPath = new Map((config.guides || []).map((g) => [g.path, g.id]));
498+
for (const guide of config.guides || []) {
499+
const inner = resolverFor({ path: guide.path });
500+
const resolveLink = (raw) => {
501+
const m = String(raw).match(/^([^?#]*)((?:\?[^#]*)?(?:#.*)?)$/);
502+
if (m && m[1] && !/^[a-z][a-z0-9+.-]*:/i.test(raw)) {
503+
let repoPath = null;
504+
try {
505+
repoPath = path.posix.normalize(path.posix.join(path.posix.dirname(guide.path), decodeURI(m[1])));
506+
} catch {
507+
// A malformed link falls through to the entry resolver, which reports it.
508+
}
509+
if (repoPath && guideByPath.has(repoPath)) {
510+
const id = guideByPath.get(repoPath);
511+
return { href: id === guide.id ? m[2] || "#content" : `../${id}/${m[2]}`, external: false };
512+
}
513+
}
514+
return inner(raw);
515+
};
516+
try {
517+
const rendered = renderGuide(fs.readFileSync(path.join(ROOT, guide.path), "utf8"), { file: guide.path, resolveLink });
518+
writeOut(path.join("guide", guide.id, "index.html"), guidePageHtml({ guide, rendered, site: config.site, stamp, total: stats.total, hasCard }));
519+
written.push(`guide/${guide.id}/index.html`);
520+
} catch (err) {
521+
errors.push(err.message);
522+
}
523+
}
524+
return { written, errors };
525+
}
526+
487527
/** Runs the page checks over dist-relative files. */
488528
function checkPages(files) {
489529
// Link targets resolve against what this build produced, not what is on
@@ -637,19 +677,21 @@ function main() {
637677
const categoriesInSitemap = Object.keys(config.categories)
638678
.filter((cat) => visible.some((e) => e.category === cat))
639679
.map((id) => ({ id, lastmod: stamp }));
640-
writeOut("sitemap.xml", sitemapXml({ site: config.site, entries: visible, stacks, stamp, modified: lastCommitDates(), categories: categoriesInSitemap }));
680+
writeOut("sitemap.xml", sitemapXml({ site: config.site, entries: visible, stacks, stamp, modified: lastCommitDates(), categories: categoriesInSitemap, guides: config.guides }));
641681
writeOut("robots.txt", robotsTxt(config.site));
642682
writeOut("404.html", notFoundHtml(config.site));
643683
writeOut(path.join("freshness", "index.html"), freshnessPageHtml({ stats: fresh, site: config.site, stamp, total: stats.total, hasCard }));
644684

645685
const pages = writeEntryPages({ entries, bodies, config, stats, stamp, hasCard, now, usedIn });
646686
const stackPages = writeStackPages({ stacks, entries, config, stats, stamp, hasCard, now });
647687
const categoryPages = writeCategoryPages({ entries: visible, config, stats, stamp, hasCard, now });
688+
const guidePages = writeGuidePages({ entries, config, stats, stamp, hasCard });
648689
const pageErrors = [
649690
...stackErrors,
650691
...pages.errors,
651692
...stackPages.errors,
652-
...checkPages(["index.html", "freshness/index.html", ...pages.written, ...stackPages.written, ...categoryPages.written]),
693+
...guidePages.errors,
694+
...checkPages(["index.html", "freshness/index.html", ...pages.written, ...stackPages.written, ...categoryPages.written, ...guidePages.written]),
653695
];
654696
if (pageErrors.length) {
655697
console.error(`Page build failed (${pageErrors.length}):`);
@@ -660,7 +702,7 @@ function main() {
660702
// After the page gate: every body link has resolved by now, and the llms
661703
// files read links with the same LINK_RE, so they cannot hit a link error
662704
// of their own.
663-
writeOut("llms.txt", llmsTxt({ entries, site: config.site, categories: config.categories, stacks }));
705+
writeOut("llms.txt", llmsTxt({ entries, site: config.site, categories: config.categories, stacks, guides: config.guides }));
664706
writeOut(
665707
"llms-full.txt",
666708
llmsFullTxt({
@@ -681,7 +723,7 @@ function main() {
681723
`Built ${entries.length} entries → site/dist (${stats.active} active, ${stats.commercialOk} commercial-ok, ${stats.commercialVaries} per-file)`
682724
);
683725
console.log(
684-
`Prerendered ${visible.length} entry rows into index.html; wrote ${pages.written.length} entry pages, ${stackPages.written.length} stack pages, ${categoryPages.written.length} category pages, sitemap.xml, robots.txt, 404.html, freshness/index.html`
726+
`Prerendered ${visible.length} entry rows into index.html; wrote ${pages.written.length} entry pages, ${stackPages.written.length} stack pages, ${categoryPages.written.length} category pages, ${guidePages.written.length} guide pages, sitemap.xml, robots.txt, 404.html, freshness/index.html`
685727
);
686728
console.log(`${writes} of ${produced.size} output files changed${clean ? " (clean build)" : ""}; ${((Date.now() - started) / 1000).toFixed(1)}s`);
687729
}

‎site/lib/category-page.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ ${grid}
7878
<footer class="footer">
7979
<p>Catalog metadata is CC0. Linked assets keep their own licences. This is the catalog's reading of each licence, not legal advice: re-check the live source before shipping.</p>
8080
<p class="footer-credit">${PREVIEW_CREDIT_LINE}</p>
81-
<p><a href="../../freshness/">Licence freshness</a> · <a href="../../data.json">data.json</a> · <a href="../../catalog.csv">CSV</a> · <a href="../../feed.xml">feed</a> · <a href="https://github.com/TMHSDigital/Free-Game-Dev-Assets/blob/main/docs/data.md">Use the data</a></p>
81+
<p><a href="../../freshness/">Licence freshness</a> · <a href="../../data.json">data.json</a> · <a href="../../catalog.csv">CSV</a> · <a href="../../feed.xml">feed</a> · <a href="../../guide/data/">Use the data</a></p>
8282
<p class="footer-sponsor">${sponsorButtonHtml()}</p>
8383
<p class="footer-stamp">Built ${esc(stamp)} from ${total} catalog entries.</p>
8484
</footer>

‎site/lib/entry-page.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml,
8383
const rows = [
8484
// Usable in one engine only: the first thing a reader must know.
8585
entry.engine_lock ? ["Engine lock", esc(ENGINE_LOCK_NOTES[entry.engine_lock] || entry.engine_lock)] : null,
86-
["Licence", esc(licence)],
86+
["Licence", `${esc(licence)} <a class="facts-guide" href="../../guide/licenses/">what it means</a>`],
8787
["Commercial use", esc(commercialLabel(entry.commercial))],
8888
["Credit", esc(c.value)],
8989
["Status", `${esc(entry.status)}: ${esc(STATUS_NOTES[entry.status] || "")}`],
@@ -177,7 +177,7 @@ export function entryPageHtml({ entry, leadHtml, restHtml, deprecatedReasonHtml,
177177
<footer class="footer">
178178
<p>Catalog metadata is CC0. Linked assets keep their own licences. This is the catalog's reading of each licence, not legal advice: re-check the live source before shipping.</p>
179179
<p class="footer-credit">${PREVIEW_CREDIT_LINE}</p>
180-
<p><a href="../../freshness/">Licence freshness</a> · <a href="../../data.json">data.json</a> · <a href="../../catalog.csv">CSV</a> · <a href="../../feed.xml">feed</a> · <a href="https://github.com/TMHSDigital/Free-Game-Dev-Assets/blob/main/docs/data.md">Use the data</a></p>
180+
<p><a href="../../freshness/">Licence freshness</a> · <a href="../../data.json">data.json</a> · <a href="../../catalog.csv">CSV</a> · <a href="../../feed.xml">feed</a> · <a href="../../guide/data/">Use the data</a></p>
181181
<p class="footer-sponsor">${sponsorButtonHtml()}</p>
182182
<p class="footer-stamp">Built ${esc(stamp)} from ${total} catalog entries.</p>
183183
</footer>

‎site/lib/exports.mjs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,15 @@ import { esc } from "./shared.mjs";
1212
* catalog date (`verified`, `walked`) and the file's last commit date from
1313
* `modified` (repo path => YYYY-MM-DD), falling back to the build date.
1414
*/
15-
export function sitemapXml({ site, entries, stacks = [], stamp, modified = new Map(), categories = [] }) {
15+
export function sitemapXml({ site, entries, stacks = [], stamp, modified = new Map(), categories = [], guides = [] }) {
1616
const base = String(site.siteUrl).replace(/\/+$/, "");
1717
const latest = (...dates) => dates.filter(Boolean).sort().pop() || stamp;
1818
const url = (loc, lastmod) => ` <url><loc>${esc(loc)}</loc><lastmod>${esc(lastmod)}</lastmod></url>`;
1919
const urls = [
2020
url(`${base}/`, stamp),
2121
url(`${base}/freshness/`, stamp),
2222
...categories.map((c) => url(`${base}/category/${c.id}/`, c.lastmod || stamp)),
23+
...guides.map((g) => url(`${base}/guide/${g.id}/`, modified.get(g.path) || stamp)),
2324
...stacks.map((s) => url(`${base}/stack/${s.meta.id}/`, latest(s.meta.walked, modified.get(s.rel)))),
2425
...entries.map((e) => url(`${base}/entry/${e.id}/`, latest(e.verified, modified.get(e.path)))),
2526
];

‎site/lib/freshness.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ export function freshnessPageHtml({ stats, site, stamp, total, hasCard }) {
137137
</main>
138138
<footer class="footer">
139139
<p>Catalog metadata is CC0. Linked assets keep their own licences. This is the catalog's reading of each licence, not legal advice: re-check the live source before shipping.</p>
140-
<p><a href="../data.json">data.json</a> · <a href="../catalog.csv">CSV</a> · <a href="../feed.xml">feed</a> · <a href="https://github.com/TMHSDigital/Free-Game-Dev-Assets/blob/main/docs/data.md">Use the data</a></p>
140+
<p><a href="../data.json">data.json</a> · <a href="../catalog.csv">CSV</a> · <a href="../feed.xml">feed</a> · <a href="../guide/data/">Use the data</a></p>
141141
<p class="footer-sponsor">${sponsorButtonHtml()}</p>
142142
<p class="footer-stamp">Built ${esc(stamp)} from ${total} catalog entries.</p>
143143
</footer>

‎site/lib/guide-page.mjs‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/** A guide from docs/ as a site page: the site's head, nav, breadcrumb and footer around the rendered guide. Pure. */
2+
import { baseHeadHtml, breadcrumbLd, clipText, esc, SEO_DESCRIPTION_MAX, SEO_TITLE_MAX, sponsorButtonHtml } from "./shared.mjs";
3+
import { scriptJson } from "./entry-page.mjs";
4+
import { plainText } from "./guide-render.mjs";
5+
6+
export function guidePageUrl(site, id) {
7+
return `${String(site.siteUrl).replace(/\/+$/, "")}/guide/${id}/`;
8+
}
9+
10+
export function guidePageHtml({ guide, rendered, site, stamp, total, hasCard }) {
11+
const url = guidePageUrl(site, guide.id);
12+
const title = `${guide.title} | ${site.title}`;
13+
const description = clipText(plainText(rendered.firstParagraph) || guide.title, SEO_DESCRIPTION_MAX);
14+
const edit = `${site.repo}/blob/main/${guide.path}`;
15+
const ld = {
16+
"@context": "https://schema.org",
17+
"@type": "Article",
18+
headline: plainText(rendered.title),
19+
url,
20+
isPartOf: { "@type": "WebSite", name: site.title, url: site.siteUrl },
21+
breadcrumb: breadcrumbLd([
22+
{ name: site.title, url: site.siteUrl },
23+
{ name: "Guides", url: `${String(site.siteUrl).replace(/\/+$/, "")}/#guides` },
24+
{ name: guide.title, url },
25+
]),
26+
};
27+
return `<!DOCTYPE html>
28+
<html lang="en">
29+
<head>
30+
<meta charset="utf-8" />
31+
<meta name="viewport" content="width=device-width, initial-scale=1" />
32+
<title>${esc(clipText(title, SEO_TITLE_MAX))}</title>
33+
<meta name="description" content="${esc(description)}" />
34+
${baseHeadHtml({ site, title, description, url, type: "article", hasCard, root: "../../" })}
35+
<script type="application/ld+json">${scriptJson(ld)}</script>
36+
</head>
37+
<body>
38+
<a class="skip-link" href="#content">Skip to the guide</a>
39+
<header class="topbar">
40+
<a class="brand" href="../../">Free Game Dev Assets</a>
41+
<nav class="topnav" aria-label="Primary">
42+
<a href="../../#catalog">Catalog</a>
43+
<a href="../../#stacks">Stacks</a>
44+
<a href="../../#starters">Starters</a>
45+
<a href="../../#guides">Guides</a>
46+
<a href="${esc(site.repo)}" rel="noopener noreferrer">GitHub</a>
47+
<span class="nav-sponsor">${sponsorButtonHtml()}</span>
48+
</nav>
49+
</header>
50+
<main class="section entry-page guide-page" id="content">
51+
<nav class="breadcrumb" aria-label="Breadcrumb">
52+
<a href="../../#catalog">Catalog</a> <span aria-hidden="true">/</span>
53+
<a href="../../#guides">Guides</a> <span aria-hidden="true">/</span>
54+
<span aria-current="page">${esc(guide.title)}</span>
55+
</nav>
56+
<h1>${esc(plainText(rendered.title))}</h1>
57+
<div class="entry-body guide-body">
58+
${rendered.html}
59+
</div>
60+
<p class="page-actions"><a class="btn-ghost" href="${esc(edit)}" rel="noopener noreferrer">Edit this guide on GitHub</a></p>
61+
</main>
62+
<footer class="footer">
63+
<p>Catalog metadata is CC0. Linked assets keep their own licences. This is the catalog's reading of each licence, not legal advice: re-check the live source before shipping.</p>
64+
<p><a href="../../freshness/">Licence freshness</a> · <a href="../../data.json">data.json</a> · <a href="../../catalog.csv">CSV</a> · <a href="../../feed.xml">feed</a> · <a href="../data/">Use the data</a></p>
65+
<p class="footer-sponsor">${sponsorButtonHtml()}</p>
66+
<p class="footer-stamp">Built ${esc(stamp)} from ${total} catalog entries.</p>
67+
</footer>
68+
</body>
69+
</html>
70+
`;
71+
}

‎site/lib/guide-render.mjs‎

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/**
2+
* Renders a guide in docs/ for its site page. Guides use more markdown than
3+
* entry bodies (tables, fenced code, blockquotes, numbered lists, ### headings),
4+
* so they get their own renderer; renderBlocks stays strict for entries.
5+
* Inline markup and links go through renderInline and the caller's resolver.
6+
* Images and raw HTML still fail the build, as on entry pages. Pure.
7+
*/
8+
import { esc } from "./shared.mjs";
9+
import { MarkdownError, renderInline } from "./markdown.mjs";
10+
11+
/** GitHub's heading anchor, so links written for GitHub (licenses.md#cc-by) land here too. */
12+
export function headingSlug(text) {
13+
return String(text)
14+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
15+
.replace(/<[^>]+>/g, "")
16+
.toLowerCase()
17+
.replace(/[^\p{L}\p{N}\s_-]/gu, "")
18+
.replace(/ /g, "-");
19+
}
20+
21+
/** Table cells, split on pipes outside code spans; a leading and trailing pipe are dropped. */
22+
function cells(line) {
23+
const out = [];
24+
let cur = "";
25+
let code = false;
26+
const s = line.trim().replace(/^\|/, "").replace(/\|$/, "");
27+
for (let i = 0; i < s.length; i += 1) {
28+
const ch = s[i];
29+
if (ch === "\\" && s[i + 1] === "|") {
30+
cur += "|";
31+
i += 1;
32+
} else if (ch === "`") {
33+
code = !code;
34+
cur += ch;
35+
} else if (ch === "|" && !code) {
36+
out.push(cur.trim());
37+
cur = "";
38+
} else cur += ch;
39+
}
40+
out.push(cur.trim());
41+
return out;
42+
}
43+
44+
const isSeparator = (line) => /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/.test(line);
45+
46+
/**
47+
* `{ title, html, firstParagraph }`. The first `# ` heading is the page
48+
* title (the template prints it as the h1); later `#` headings are refused.
49+
*/
50+
export function renderGuide(md, { file, resolveLink }) {
51+
const ls = String(md).replace(/\r\n?/g, "\n").split("\n");
52+
const inline = (t) => renderInline(t, resolveLink);
53+
const html = [];
54+
const seen = new Map();
55+
let title = "";
56+
let firstParagraph = "";
57+
let i = 0;
58+
const fail = (n, what) => {
59+
throw new MarkdownError(`${file}:${n + 1}: ${what} is not supported on guide pages`);
60+
};
61+
const isBlockStart = (l) => /^\s*(```|>|\||#{1,6}\s|[-*]\s|\d+[.)]\s)/.test(l);
62+
63+
while (i < ls.length) {
64+
const line = ls[i];
65+
if (!line.trim()) {
66+
i += 1;
67+
continue;
68+
}
69+
if (/^\s*!\[/.test(line)) fail(i, "an image");
70+
if (/^\s*<(?!https?:\/\/)[A-Za-z!/]/.test(line)) fail(i, "raw HTML");
71+
72+
// Fenced code: literal, escaped.
73+
if (/^\s*```/.test(line)) {
74+
const lang = line.trim().slice(3).trim();
75+
const body = [];
76+
i += 1;
77+
while (i < ls.length && !/^\s*```/.test(ls[i])) body.push(ls[i++]);
78+
if (i >= ls.length) fail(i - 1, "an unclosed code fence");
79+
i += 1;
80+
html.push(`<pre class="guide-code"${lang ? ` data-lang="${esc(lang)}"` : ""}><code>${esc(body.join("\n"))}</code></pre>`);
81+
continue;
82+
}
83+
84+
const h = line.match(/^(#{1,6})\s+(.*?)\s*#*\s*$/);
85+
if (h) {
86+
const level = h[1].length;
87+
if (level === 1) {
88+
if (title) fail(i, "a second top-level heading");
89+
title = h[2];
90+
i += 1;
91+
continue;
92+
}
93+
const base = headingSlug(h[2]);
94+
const n = seen.get(base) || 0;
95+
seen.set(base, n + 1);
96+
const id = n ? `${base}-${n}` : base;
97+
const tag = `h${Math.min(level, 4)}`;
98+
html.push(`<${tag} id="${esc(id)}">${inline(h[2])}</${tag}>`);
99+
i += 1;
100+
continue;
101+
}
102+
103+
if (/^\s*\|/.test(line)) {
104+
const rows = [];
105+
while (i < ls.length && /^\s*\|/.test(ls[i])) rows.push(ls[i++]);
106+
if (rows.length < 2 || !isSeparator(rows[1])) fail(i - rows.length, "a table without a header separator");
107+
const head = cells(rows[0]);
108+
const body = rows.slice(2).map(cells);
109+
html.push(
110+
`<div class="table-wrap"><table class="guide-table"><thead><tr>${head.map((c) => `<th scope="col">${inline(c)}</th>`).join("")}</tr></thead><tbody>` +
111+
body.map((r) => `<tr>${head.map((_, k) => `<td>${inline(r[k] || "")}</td>`).join("")}</tr>`).join("") +
112+
`</tbody></table></div>`
113+
);
114+
continue;
115+
}
116+
117+
if (/^\s*>/.test(line)) {
118+
const quote = [];
119+
while (i < ls.length && /^\s*>/.test(ls[i])) quote.push(ls[i++].replace(/^\s*>\s?/, ""));
120+
const paras = quote.join("\n").split(/\n\s*\n/).map((p) => `<p>${inline(p.replace(/\n/g, " ").trim())}</p>`);
121+
html.push(`<blockquote>${paras.join("")}</blockquote>`);
122+
continue;
123+
}
124+
125+
const listRe = /^\s*([-*]|\d+[.)])\s+(.*)$/;
126+
const li = line.match(listRe);
127+
if (li) {
128+
const ordered = /\d/.test(li[1]);
129+
const items = [];
130+
while (i < ls.length) {
131+
const m = ls[i].match(listRe);
132+
if (m && /\d/.test(m[1]) === ordered) {
133+
items.push(m[2]);
134+
i += 1;
135+
} else if (items.length && ls[i].trim() && /^\s+/.test(ls[i]) && !isBlockStart(ls[i])) {
136+
items[items.length - 1] += ` ${ls[i].trim()}`;
137+
i += 1;
138+
} else break;
139+
}
140+
const tag = ordered ? "ol" : "ul";
141+
html.push(`<${tag}>${items.map((t) => `<li>${inline(t)}</li>`).join("")}</${tag}>`);
142+
continue;
143+
}
144+
145+
const para = [];
146+
while (i < ls.length && ls[i].trim() && !isBlockStart(ls[i])) para.push(ls[i++].trim());
147+
if (!para.length) {
148+
// A line that looks like a block start but matched nothing above.
149+
para.push(ls[i++].trim());
150+
}
151+
const text = para.join(" ");
152+
if (!firstParagraph) firstParagraph = text;
153+
html.push(`<p>${inline(text)}</p>`);
154+
}
155+
if (!title) throw new MarkdownError(`${file}:1: a guide needs a "# " title`);
156+
return { title, html: html.join("\n"), firstParagraph };
157+
}
158+
159+
/** Markdown inline marks and links reduced to plain text, for a meta description. */
160+
export function plainText(md) {
161+
return String(md)
162+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
163+
.replace(/[*_`]/g, "")
164+
.replace(/\s+/g, " ")
165+
.trim();
166+
}

0 commit comments

Comments
 (0)