@brandon_m_behring/book-scaffold-astro 5.1.0 → 5.2.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.mjs CHANGED
@@ -1277,8 +1277,8 @@ function corpusCollectionEntryId(corpus, entry, data, options = {}) {
1277
1277
  }
1278
1278
  function corpusBookIdFromPath(corpus, pathname, baseUrl = "/") {
1279
1279
  const normalizedBase2 = baseUrl === "/" ? "/" : `/${baseUrl.replace(/^\/+|\/+$/g, "")}/`;
1280
- const relative = pathname.startsWith(normalizedBase2) ? pathname.slice(normalizedBase2.length) : pathname.replace(/^\/+/, "");
1281
- const segments = relative.split("/").filter(Boolean);
1280
+ const relative2 = pathname.startsWith(normalizedBase2) ? pathname.slice(normalizedBase2.length) : pathname.replace(/^\/+/, "");
1281
+ const segments = relative2.split("/").filter(Boolean);
1282
1282
  const candidate = segments[0] === "chapters" ? segments[1] : segments[0];
1283
1283
  return candidate && corpus.books.some((book) => book.id === candidate) ? candidate : null;
1284
1284
  }
@@ -1781,6 +1781,877 @@ function bookScaffoldIntegration(opts) {
1781
1781
  return integration;
1782
1782
  }
1783
1783
 
1784
+ // src/lib/og-cards.ts
1785
+ import { createHash } from "crypto";
1786
+ import {
1787
+ lstat,
1788
+ mkdir,
1789
+ readFile,
1790
+ readdir,
1791
+ realpath,
1792
+ stat,
1793
+ unlink,
1794
+ writeFile
1795
+ } from "fs/promises";
1796
+ import { extname, join as join2, relative, sep } from "path";
1797
+ import { fileURLToPath as fileURLToPath2 } from "url";
1798
+ import { parse } from "parse5";
1799
+ var CARD_WIDTH = 1200;
1800
+ var CARD_HEIGHT = 630;
1801
+ var TEMPLATE_VERSION = 1;
1802
+ var TITLE_LIMIT = 96;
1803
+ var DESCRIPTION_LIMIT = 180;
1804
+ var BOOK_TITLE_LIMIT = 72;
1805
+ var HOSTNAME_LIMIT = 80;
1806
+ var ALLOWED_CONFIG_KEYS = /* @__PURE__ */ new Set(["enabled", "exclude"]);
1807
+ var UNSUPPORTED_GLOB_TOKENS = /[?\[\]{}]/u;
1808
+ var PROFILE_THEMES = Object.freeze({
1809
+ academic: Object.freeze({ background: "#1A1816", accent: "#7297BB" }),
1810
+ tools: Object.freeze({ background: "#26231F", accent: "#AB80A5" }),
1811
+ minimal: Object.freeze({ background: "#1A1816", accent: "#D2B575" }),
1812
+ "course-notes": Object.freeze({ background: "#1A1816", accent: "#7DA275" }),
1813
+ "research-portfolio": Object.freeze({ background: "#26231F", accent: "#D29287" })
1814
+ });
1815
+ function normalizeOgCardsConfig(value) {
1816
+ if (value === void 0 || value === false) return null;
1817
+ if (value === true) return Object.freeze({ enabled: true, exclude: Object.freeze([]) });
1818
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
1819
+ throw new Error(
1820
+ "seo.ogCards must be true, false, or an object with enabled and exclude fields."
1821
+ );
1822
+ }
1823
+ const input = value;
1824
+ const unknown = Object.keys(input).filter((key) => !ALLOWED_CONFIG_KEYS.has(key));
1825
+ if (unknown.length > 0) {
1826
+ throw new Error(`seo.ogCards contains unknown ${unknown.length === 1 ? "field" : "fields"}: ${unknown.join(", ")}.`);
1827
+ }
1828
+ if (input.enabled !== void 0 && typeof input.enabled !== "boolean") {
1829
+ throw new Error("seo.ogCards.enabled must be a boolean.");
1830
+ }
1831
+ if (input.exclude !== void 0 && !Array.isArray(input.exclude)) {
1832
+ throw new Error("seo.ogCards.exclude must be an array of route patterns.");
1833
+ }
1834
+ const exclude = input.exclude ?? [];
1835
+ const normalized = Array.from(exclude, (pattern, index) => validateExcludePattern(pattern, index));
1836
+ if (new Set(normalized).size !== normalized.length) {
1837
+ throw new Error("seo.ogCards.exclude must not contain duplicate patterns.");
1838
+ }
1839
+ if (input.enabled === false) return null;
1840
+ return Object.freeze({ enabled: true, exclude: Object.freeze(normalized) });
1841
+ }
1842
+ function validateExcludePattern(value, index) {
1843
+ const label = `seo.ogCards.exclude[${index}]`;
1844
+ if (typeof value !== "string" || value.length === 0) {
1845
+ throw new Error(`${label} must be a non-empty string.`);
1846
+ }
1847
+ if (!value.startsWith("/")) {
1848
+ throw new Error(`${label} must be base-relative and start with "/".`);
1849
+ }
1850
+ if (value.startsWith("//") || value.includes("\\") || /[\u0000-\u001F\u007F]/u.test(value)) {
1851
+ throw new Error(`${label} must be a local route pattern without a host, backslash, or control character.`);
1852
+ }
1853
+ if (value.includes("?") || value.includes("#")) {
1854
+ throw new Error(`${label} must not contain a query string or fragment.`);
1855
+ }
1856
+ if (UNSUPPORTED_GLOB_TOKENS.test(value)) {
1857
+ throw new Error(`${label} supports only literal segments plus whole "*" and "**" segments.`);
1858
+ }
1859
+ const segments = value.slice(1).split("/");
1860
+ for (let segmentIndex = 0; segmentIndex < segments.length; segmentIndex += 1) {
1861
+ const segment = segments[segmentIndex];
1862
+ const trailingSlash = segmentIndex === segments.length - 1 && segment === "";
1863
+ if (!trailingSlash && segment === "") {
1864
+ throw new Error(`${label} must not contain an empty route segment.`);
1865
+ }
1866
+ if (segment === "." || segment === "..") {
1867
+ throw new Error(`${label} must not contain "." or ".." route segments.`);
1868
+ }
1869
+ if (segment.includes("*") && segment !== "*" && segment !== "**") {
1870
+ throw new Error(`${label}: "*" and "**" must occupy a complete route segment.`);
1871
+ }
1872
+ }
1873
+ return value;
1874
+ }
1875
+ function escapeRegExp(value) {
1876
+ return value.replace(/[|\\{}()[\]^$+?.-]/g, "\\$&");
1877
+ }
1878
+ function exclusionMatcher(pattern) {
1879
+ const segments = pattern.split("/");
1880
+ let source = "^";
1881
+ for (let index = 1; index < segments.length; index += 1) {
1882
+ const segment = segments[index];
1883
+ const isLast = index === segments.length - 1;
1884
+ if (segment === "" && isLast) {
1885
+ source += "/";
1886
+ continue;
1887
+ }
1888
+ if (segment === "**") {
1889
+ source += isLast ? "(?:/.*)?" : "(?:/[^/]+)*";
1890
+ continue;
1891
+ }
1892
+ if (segment === "*") {
1893
+ source += "/[^/]+";
1894
+ continue;
1895
+ }
1896
+ source += `/${escapeRegExp(segment)}`;
1897
+ }
1898
+ return new RegExp(`${source}$`, "u");
1899
+ }
1900
+ function normalizeBase(base) {
1901
+ const trimmed = (base ?? "/").trim();
1902
+ if (!trimmed || trimmed === "/") return "/";
1903
+ return `/${trimmed.replace(/^\/+|\/+$/g, "")}/`;
1904
+ }
1905
+ function cardUrl(site, base, hash) {
1906
+ const url = new URL(site.origin);
1907
+ url.pathname = `${normalizeBase(base)}_og/${hash}.png`.replace(/\/{2,}/g, "/");
1908
+ return url.toString();
1909
+ }
1910
+ function resolveStaticImageUrl(value, site, base) {
1911
+ const trimmed = value.trim();
1912
+ if (!trimmed) throw new Error("OG card static image must not be blank.");
1913
+ if (/^https?:\/\//iu.test(trimmed)) {
1914
+ return absoluteHttpUrl(trimmed, "static OG image", "(config)").toString();
1915
+ }
1916
+ if (trimmed.startsWith("//")) {
1917
+ if (trimmed.startsWith("///")) {
1918
+ throw new Error(`OG card static image has an invalid protocol-relative URL: ${JSON.stringify(value)}.`);
1919
+ }
1920
+ return absoluteHttpUrl(`${site.protocol}${trimmed}`, "static OG image", "(config)").toString();
1921
+ }
1922
+ if (/^[a-z][a-z\d+.-]*:/iu.test(trimmed)) {
1923
+ throw new Error(
1924
+ `OG card static image must be local, protocol-relative, or http(s) (got ${JSON.stringify(value)}).`
1925
+ );
1926
+ }
1927
+ if (trimmed.includes("\\") || /[\u0000-\u001F\u007F]/u.test(trimmed)) {
1928
+ throw new Error("OG card local static image must not contain a backslash or control character.");
1929
+ }
1930
+ const rawPath = trimmed.split(/[?#]/u, 1)[0];
1931
+ if (!rawPath) throw new Error("OG card local static image must contain a path.");
1932
+ for (const rawSegment of rawPath.split("/")) {
1933
+ let decoded = rawSegment;
1934
+ try {
1935
+ for (let pass = 0; pass < 4; pass += 1) {
1936
+ const next = decodeURIComponent(decoded);
1937
+ if (next === decoded) break;
1938
+ decoded = next;
1939
+ }
1940
+ } catch {
1941
+ throw new Error(`OG card local static image contains invalid percent encoding: ${JSON.stringify(value)}.`);
1942
+ }
1943
+ if (decoded === "." || decoded === ".." || decoded.includes("/") || decoded.includes("\\") || /[\u0000-\u001F\u007F]/u.test(decoded)) {
1944
+ throw new Error(`OG card local static image must not contain encoded path traversal: ${JSON.stringify(value)}.`);
1945
+ }
1946
+ }
1947
+ const localPath = `/${rawPath.replace(/^\/+/, "")}`;
1948
+ const suffix = trimmed.slice(rawPath.length);
1949
+ const normalizedBase2 = normalizeBase(base);
1950
+ const baseWithoutTrailingSlash = normalizedBase2.replace(/\/+$/u, "");
1951
+ const alreadyPrefixed = normalizedBase2 === "/" || localPath === baseWithoutTrailingSlash || localPath.startsWith(normalizedBase2);
1952
+ const resolvedPath = alreadyPrefixed ? localPath : `${normalizedBase2}${localPath.slice(1)}`;
1953
+ let url;
1954
+ try {
1955
+ url = new URL(`${resolvedPath}${suffix}`, site.origin);
1956
+ } catch {
1957
+ throw new Error(`OG card static image ${JSON.stringify(value)} is not a valid URL.`);
1958
+ }
1959
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
1960
+ throw new Error(`OG card static image must resolve to an http(s) URL (got ${JSON.stringify(value)}).`);
1961
+ }
1962
+ return url.toString();
1963
+ }
1964
+ function walk(node, visit) {
1965
+ visit(node);
1966
+ for (const child of node.childNodes ?? []) walk(child, visit);
1967
+ }
1968
+ function attr(node, name) {
1969
+ const found = node.attrs?.find((candidate) => candidate.name.toLowerCase() === name);
1970
+ return found?.value ?? null;
1971
+ }
1972
+ function elements(document, tagName) {
1973
+ const found = [];
1974
+ walk(document, (node) => {
1975
+ if (node.tagName?.toLowerCase() === tagName) found.push(node);
1976
+ });
1977
+ return found;
1978
+ }
1979
+ function textContent(node) {
1980
+ if (node.nodeName === "#text") return node.value ?? "";
1981
+ return (node.childNodes ?? []).map(textContent).join("");
1982
+ }
1983
+ function metas(document, attribute, value) {
1984
+ const normalizedValue = value.toLowerCase();
1985
+ return elements(document, "meta").filter(
1986
+ (node) => attr(node, attribute)?.trim().toLowerCase() === normalizedValue
1987
+ );
1988
+ }
1989
+ function contentOfUniqueMeta(document, attribute, value, file) {
1990
+ const matches = metas(document, attribute, value);
1991
+ if (matches.length > 1) {
1992
+ throw new Error(`${file}: duplicate <meta ${attribute}=${JSON.stringify(value)}> tags are ambiguous.`);
1993
+ }
1994
+ if (matches.length === 0) return null;
1995
+ const content = attr(matches[0], "content")?.trim() ?? "";
1996
+ if (!content) {
1997
+ throw new Error(`${file}: <meta ${attribute}=${JSON.stringify(value)}> requires non-empty content.`);
1998
+ }
1999
+ return content;
2000
+ }
2001
+ function absoluteHttpUrl(value, label, file) {
2002
+ let url;
2003
+ try {
2004
+ url = new URL(value);
2005
+ } catch {
2006
+ throw new Error(`${file}: ${label} must be an absolute http(s) URL.`);
2007
+ }
2008
+ if (url.protocol !== "http:" && url.protocol !== "https:" || !url.hostname) {
2009
+ throw new Error(`${file}: ${label} must be an absolute http(s) URL.`);
2010
+ }
2011
+ return url;
2012
+ }
2013
+ function parsePage(source, file, route) {
2014
+ const document = parse(source, { sourceCodeLocationInfo: true });
2015
+ const html = elements(document, "html");
2016
+ const heads = elements(document, "head");
2017
+ if (html.length !== 1 || heads.length !== 1) {
2018
+ throw new Error(`${file}: generated OG cards require exactly one <html> and one <head> element.`);
2019
+ }
2020
+ const head = heads[0];
2021
+ return { document, head, route, file };
2022
+ }
2023
+ function isBuiltInErrorRoute(route) {
2024
+ return /(?:^|\/)(?:404|500)(?:\/|\.html)$/u.test(route);
2025
+ }
2026
+ function isRedirect(document) {
2027
+ return elements(document, "meta").some(
2028
+ (node) => attr(node, "http-equiv")?.trim().toLowerCase() === "refresh"
2029
+ );
2030
+ }
2031
+ function isNoIndex(document) {
2032
+ return elements(document, "meta").some((node) => {
2033
+ const name = attr(node, "name")?.trim().toLowerCase();
2034
+ if (name !== "robots" && name !== "googlebot") return false;
2035
+ const directives = (attr(node, "content") ?? "").toLowerCase().split(/[\s,]+/u);
2036
+ return directives.includes("noindex") || directives.includes("none");
2037
+ });
2038
+ }
2039
+ function existingAuthoredOgImage(document, file) {
2040
+ const value = contentOfUniqueMeta(document, "property", "og:image", file);
2041
+ if (value === null) return null;
2042
+ absoluteHttpUrl(value, "og:image content", file);
2043
+ return value;
2044
+ }
2045
+ function uniqueMarker(document, name, file) {
2046
+ const values = [];
2047
+ walk(document, (node) => {
2048
+ const value = attr(node, name);
2049
+ if (value !== null) values.push(value.trim());
2050
+ });
2051
+ if (values.length > 1) {
2052
+ throw new Error(`${file}: duplicate ${name} identity markers are ambiguous.`);
2053
+ }
2054
+ if (values.length === 0) return null;
2055
+ if (!values[0]) throw new Error(`${file}: ${name} identity marker must not be empty.`);
2056
+ return values[0];
2057
+ }
2058
+ function pagefindIdentities(document) {
2059
+ const books = /* @__PURE__ */ new Set();
2060
+ let corpusSurface = false;
2061
+ walk(document, (node) => {
2062
+ const filter = attr(node, "data-pagefind-filter");
2063
+ if (filter === null) return;
2064
+ for (const token of filter.split(/[;,\s]+/u)) {
2065
+ if (token.startsWith("book:") && token.length > 5) books.add(token.slice(5));
2066
+ if (token === "surface:corpus") corpusSurface = true;
2067
+ }
2068
+ });
2069
+ return { books: [...books].sort(), corpusSurface };
2070
+ }
2071
+ function resolvePageIdentity(document, file, corpus) {
2072
+ const markerBook = uniqueMarker(document, "data-book-scaffold-book", file);
2073
+ const markerSurface = uniqueMarker(document, "data-book-scaffold-surface", file);
2074
+ const fallback = pagefindIdentities(document);
2075
+ if (markerSurface !== null && markerSurface !== "corpus") {
2076
+ throw new Error(`${file}: unknown data-book-scaffold-surface ${JSON.stringify(markerSurface)}.`);
2077
+ }
2078
+ if (fallback.books.length > 1) {
2079
+ throw new Error(`${file}: multiple Pagefind book identities are ambiguous.`);
2080
+ }
2081
+ const fallbackBook = fallback.books[0] ?? null;
2082
+ if (markerBook && fallbackBook && markerBook !== fallbackBook) {
2083
+ throw new Error(`${file}: book identity marker ${JSON.stringify(markerBook)} disagrees with Pagefind ${JSON.stringify(fallbackBook)}.`);
2084
+ }
2085
+ const bookId = markerBook ?? fallbackBook;
2086
+ const corpusSurface = markerSurface === "corpus" || fallback.corpusSurface;
2087
+ if (bookId && corpusSurface) {
2088
+ throw new Error(`${file}: a page cannot carry both book and corpus-surface identity.`);
2089
+ }
2090
+ if (!corpus) {
2091
+ if (bookId || corpusSurface) {
2092
+ throw new Error(`${file}: corpus identity markers were rendered without a configured corpus.`);
2093
+ }
2094
+ return { book: null, surface: null };
2095
+ }
2096
+ if (!bookId) return { book: null, surface: corpusSurface ? "corpus" : null };
2097
+ const book = corpus.books.find((candidate) => candidate.id === bookId) ?? null;
2098
+ if (!book) {
2099
+ throw new Error(
2100
+ `${file}: unknown rendered corpus book ${JSON.stringify(bookId)}; expected ${corpus.books.map(({ id }) => id).join(" | ")}.`
2101
+ );
2102
+ }
2103
+ return { book, surface: null };
2104
+ }
2105
+ function uniqueTitle(document, file) {
2106
+ const ogTitle = contentOfUniqueMeta(document, "property", "og:title", file);
2107
+ if (ogTitle) return ogTitle;
2108
+ const titles = elements(document, "title");
2109
+ if (titles.length > 1) throw new Error(`${file}: duplicate <title> elements are ambiguous.`);
2110
+ if (titles.length === 0) return null;
2111
+ const title = textContent(titles[0]).trim();
2112
+ if (!title) throw new Error(`${file}: <title> must not be empty.`);
2113
+ return title;
2114
+ }
2115
+ function pageDescription(document, file) {
2116
+ return contentOfUniqueMeta(document, "property", "og:description", file) ?? contentOfUniqueMeta(document, "name", "description", file);
2117
+ }
2118
+ function canonicalUrl(document, file) {
2119
+ const canonicals = elements(document, "link").filter(
2120
+ (node) => (attr(node, "rel") ?? "").toLowerCase().split(/\s+/u).includes("canonical")
2121
+ );
2122
+ if (canonicals.length !== 1) {
2123
+ throw new Error(`${file}: generated OG cards require exactly one rendered canonical link.`);
2124
+ }
2125
+ const href = attr(canonicals[0], "href")?.trim() ?? "";
2126
+ return absoluteHttpUrl(href, "canonical href", file);
2127
+ }
2128
+ function clampText(value, limit) {
2129
+ const normalized = value.replace(/\s+/gu, " ").trim();
2130
+ const points = Array.from(normalized);
2131
+ if (points.length <= limit) return normalized;
2132
+ const budget = limit - 1;
2133
+ const prefix = points.slice(0, budget).join("");
2134
+ const boundary = prefix.lastIndexOf(" ");
2135
+ const wordSafe = boundary >= Math.floor(budget * 0.6) ? prefix.slice(0, boundary) : prefix;
2136
+ return `${wordSafe.replace(/[\s,.;:!?\-–—]+$/u, "")}\u2026`;
2137
+ }
2138
+ function payloadForPage(page, options, identity) {
2139
+ const rawTitle = uniqueTitle(page.document, page.file);
2140
+ if (!rawTitle) throw new Error(`${page.file}: generated OG cards require og:title or <title>.`);
2141
+ const canonical = canonicalUrl(page.document, page.file);
2142
+ const rawBookTitle = identity.book?.title ?? options.title ?? "book-scaffold-astro";
2143
+ const rawDescription = pageDescription(page.document, page.file) ?? identity.book?.description ?? options.description ?? "";
2144
+ return {
2145
+ templateVersion: TEMPLATE_VERSION,
2146
+ width: CARD_WIDTH,
2147
+ height: CARD_HEIGHT,
2148
+ profile: options.profile,
2149
+ bookId: identity.book?.id ?? null,
2150
+ bookTitle: clampText(rawBookTitle, BOOK_TITLE_LIMIT),
2151
+ title: clampText(rawTitle, TITLE_LIMIT),
2152
+ description: clampText(rawDescription, DESCRIPTION_LIMIT),
2153
+ hostname: clampText(canonical.hostname.toLowerCase(), HOSTNAME_LIMIT)
2154
+ };
2155
+ }
2156
+ function stablePayloadJson(payload) {
2157
+ return JSON.stringify(payload);
2158
+ }
2159
+ function shortHash(payloadJson) {
2160
+ return createHash("sha256").update(payloadJson, "utf8").digest("hex").slice(0, 16);
2161
+ }
2162
+ function htmlEscape(value) {
2163
+ return value.replaceAll("&", "&amp;").replaceAll('"', "&quot;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
2164
+ }
2165
+ function insertIntoHead(page, source, tags) {
2166
+ if (tags.length === 0) return source;
2167
+ const insertionOffset = page.head.sourceCodeLocation?.endTag?.startOffset;
2168
+ if (insertionOffset === void 0) {
2169
+ throw new Error(`${page.file}: cannot locate </head> for OG metadata insertion.`);
2170
+ }
2171
+ const block = `${tags.map((tag) => ` ${tag}`).join("\n")}
2172
+ `;
2173
+ return `${source.slice(0, insertionOffset)}${block}${source.slice(insertionOffset)}`;
2174
+ }
2175
+ function ensureNoDuplicateImageMetadata(document, file) {
2176
+ for (const [attribute, value] of [
2177
+ ["property", "og:image:width"],
2178
+ ["property", "og:image:height"],
2179
+ ["property", "og:image:type"],
2180
+ ["name", "twitter:image"]
2181
+ ]) {
2182
+ const matches = metas(document, attribute, value);
2183
+ if (matches.length > 1) {
2184
+ throw new Error(`${file}: duplicate ${value} metadata is ambiguous.`);
2185
+ }
2186
+ if (matches.length === 1 && !(attr(matches[0], "content") ?? "").trim()) {
2187
+ throw new Error(`${file}: ${value} metadata requires non-empty content.`);
2188
+ }
2189
+ }
2190
+ }
2191
+ function staticImageTags(page, imageUrl) {
2192
+ ensureNoDuplicateImageMetadata(page.document, page.file);
2193
+ const tags = [
2194
+ `<meta property="og:image" content="${htmlEscape(imageUrl)}">`
2195
+ ];
2196
+ if (metas(page.document, "name", "twitter:image").length === 0) {
2197
+ tags.push(`<meta name="twitter:image" content="${htmlEscape(imageUrl)}">`);
2198
+ }
2199
+ return tags;
2200
+ }
2201
+ function generatedImageTags(page, imageUrl) {
2202
+ ensureNoDuplicateImageMetadata(page.document, page.file);
2203
+ const expected = [
2204
+ { attribute: "property", name: "og:image:width", content: String(CARD_WIDTH) },
2205
+ { attribute: "property", name: "og:image:height", content: String(CARD_HEIGHT) },
2206
+ { attribute: "property", name: "og:image:type", content: "image/png" },
2207
+ { attribute: "name", name: "twitter:image", content: imageUrl }
2208
+ ];
2209
+ const tags = [`<meta property="og:image" content="${htmlEscape(imageUrl)}">`];
2210
+ for (const item of expected) {
2211
+ const matches = metas(page.document, item.attribute, item.name);
2212
+ if (matches.length === 1) {
2213
+ const actual = attr(matches[0], "content")?.trim() ?? "";
2214
+ if (actual !== item.content) {
2215
+ throw new Error(
2216
+ `${page.file}: existing ${item.name}=${JSON.stringify(actual)} conflicts with generated value ${JSON.stringify(item.content)}.`
2217
+ );
2218
+ }
2219
+ continue;
2220
+ }
2221
+ tags.push(
2222
+ `<meta ${item.attribute}="${item.name}" content="${htmlEscape(item.content)}">`
2223
+ );
2224
+ }
2225
+ return tags;
2226
+ }
2227
+ function routeForHtml(outputDir, file) {
2228
+ const local = relative(outputDir, file).split(sep).join("/");
2229
+ if (local === "index.html") return "/";
2230
+ if (local.endsWith("/index.html")) return `/${local.slice(0, -"index.html".length)}`;
2231
+ return `/${local}`;
2232
+ }
2233
+ async function htmlFiles(outputDir) {
2234
+ const files = [];
2235
+ async function visit(directory) {
2236
+ const entries = await readdir(directory, { withFileTypes: true });
2237
+ entries.sort((left, right) => left.name.localeCompare(right.name, "en"));
2238
+ for (const entry of entries) {
2239
+ const target = join2(directory, entry.name);
2240
+ if (entry.isDirectory()) await visit(target);
2241
+ else if (entry.isFile() && extname(entry.name).toLowerCase() === ".html") files.push(target);
2242
+ }
2243
+ }
2244
+ await visit(outputDir);
2245
+ return files.sort(
2246
+ (left, right) => routeForHtml(outputDir, left).localeCompare(routeForHtml(outputDir, right), "en")
2247
+ );
2248
+ }
2249
+ function cardTree(payload, theme) {
2250
+ const bookLength = Array.from(payload.bookTitle).length;
2251
+ const titleLength = Array.from(payload.title).length;
2252
+ const descriptionLength = Array.from(payload.description).length;
2253
+ const hostnameLength = Array.from(payload.hostname).length;
2254
+ const children = [
2255
+ {
2256
+ type: "div",
2257
+ key: "og-book",
2258
+ props: {
2259
+ style: {
2260
+ display: "flex",
2261
+ alignItems: "center",
2262
+ width: "100%",
2263
+ maxWidth: 1056,
2264
+ flexShrink: 0,
2265
+ fontSize: bookLength > 56 ? 22 : 25,
2266
+ fontWeight: 700,
2267
+ lineHeight: 1.15,
2268
+ letterSpacing: "-0.01em",
2269
+ wordBreak: "break-word",
2270
+ color: "#F7F5F0"
2271
+ },
2272
+ children: payload.bookTitle
2273
+ }
2274
+ },
2275
+ {
2276
+ type: "div",
2277
+ key: "og-title",
2278
+ props: {
2279
+ style: {
2280
+ display: "flex",
2281
+ width: "100%",
2282
+ marginTop: 28,
2283
+ maxWidth: 1040,
2284
+ flexShrink: 0,
2285
+ fontSize: titleLength > 80 ? 42 : titleLength > 60 ? 48 : titleLength > 40 ? 54 : 62,
2286
+ fontWeight: 700,
2287
+ lineHeight: 1.08,
2288
+ letterSpacing: "-0.035em",
2289
+ wordBreak: "break-word",
2290
+ color: "#FDFCF9"
2291
+ },
2292
+ children: payload.title
2293
+ }
2294
+ }
2295
+ ];
2296
+ if (payload.description) {
2297
+ children.push({
2298
+ type: "div",
2299
+ key: "og-description",
2300
+ props: {
2301
+ style: {
2302
+ display: "flex",
2303
+ width: "100%",
2304
+ marginTop: 22,
2305
+ maxWidth: 1010,
2306
+ flexShrink: 0,
2307
+ fontSize: descriptionLength > 140 ? 20 : descriptionLength > 90 ? 22 : 25,
2308
+ lineHeight: 1.3,
2309
+ wordBreak: "break-word",
2310
+ color: "#E8E5DD"
2311
+ },
2312
+ children: payload.description
2313
+ }
2314
+ });
2315
+ }
2316
+ children.push({
2317
+ type: "div",
2318
+ key: "og-footer",
2319
+ props: {
2320
+ style: {
2321
+ display: "flex",
2322
+ width: "100%",
2323
+ flexShrink: 0,
2324
+ justifyContent: "space-between",
2325
+ alignItems: "flex-end",
2326
+ gap: 32,
2327
+ marginTop: "auto",
2328
+ lineHeight: 1.15,
2329
+ color: "#E8E5DD"
2330
+ },
2331
+ children: [
2332
+ {
2333
+ type: "div",
2334
+ key: "og-hostname",
2335
+ props: {
2336
+ style: {
2337
+ display: "flex",
2338
+ width: 640,
2339
+ minWidth: 0,
2340
+ flexShrink: 1,
2341
+ fontSize: hostnameLength > 60 ? 16 : hostnameLength > 40 ? 18 : 20,
2342
+ lineHeight: 1.15,
2343
+ wordBreak: "break-all"
2344
+ },
2345
+ children: payload.hostname
2346
+ }
2347
+ },
2348
+ {
2349
+ type: "div",
2350
+ key: "og-profile",
2351
+ props: {
2352
+ style: {
2353
+ display: "flex",
2354
+ width: 384,
2355
+ maxWidth: 384,
2356
+ flexShrink: 0,
2357
+ justifyContent: "flex-end",
2358
+ fontSize: 17,
2359
+ fontWeight: 700,
2360
+ lineHeight: 1.15,
2361
+ letterSpacing: "0.08em",
2362
+ textAlign: "right",
2363
+ textTransform: "uppercase",
2364
+ wordBreak: "break-word",
2365
+ color: "#F7F5F0"
2366
+ },
2367
+ children: `BOOK SCAFFOLD \xB7 ${payload.profile.replaceAll("-", " ")}`
2368
+ }
2369
+ }
2370
+ ]
2371
+ }
2372
+ });
2373
+ return {
2374
+ type: "div",
2375
+ key: "og-root",
2376
+ props: {
2377
+ style: {
2378
+ display: "flex",
2379
+ flexDirection: "column",
2380
+ width: CARD_WIDTH,
2381
+ height: CARD_HEIGHT,
2382
+ padding: "66px 72px 58px",
2383
+ borderTop: `14px solid ${theme.accent}`,
2384
+ backgroundColor: theme.background,
2385
+ color: "#FDFCF9",
2386
+ fontFamily: "Inter"
2387
+ },
2388
+ children
2389
+ }
2390
+ };
2391
+ }
2392
+ function validateCardLayout(boxes) {
2393
+ const required = ["og-root", "og-book", "og-title", "og-footer", "og-hostname", "og-profile"];
2394
+ for (const role of required) {
2395
+ if (!boxes.has(role)) throw new Error(`OG renderer did not report the ${role} layout box.`);
2396
+ }
2397
+ const epsilon = 0.01;
2398
+ for (const [role, box] of boxes) {
2399
+ if (!Number.isFinite(box.left) || !Number.isFinite(box.top) || !Number.isFinite(box.width) || !Number.isFinite(box.height) || box.left < -epsilon || box.top < -epsilon || box.left + box.width > CARD_WIDTH + epsilon || box.top + box.height > CARD_HEIGHT + epsilon) {
2400
+ throw new Error(`OG renderer placed ${role} outside the ${CARD_WIDTH}x${CARD_HEIGHT} card.`);
2401
+ }
2402
+ }
2403
+ const footer = boxes.get("og-footer");
2404
+ const contentBottom = Math.max(
2405
+ boxes.get("og-book").top + boxes.get("og-book").height,
2406
+ boxes.get("og-title").top + boxes.get("og-title").height,
2407
+ ...boxes.has("og-description") ? [boxes.get("og-description").top + boxes.get("og-description").height] : []
2408
+ );
2409
+ if (contentBottom > footer.top + epsilon) {
2410
+ throw new Error("OG renderer content overlaps the footer; shorten or resize the card template.");
2411
+ }
2412
+ const hostname = boxes.get("og-hostname");
2413
+ const profile = boxes.get("og-profile");
2414
+ if (hostname.left + hostname.width > profile.left + epsilon) {
2415
+ throw new Error("OG renderer hostname overlaps the profile mark.");
2416
+ }
2417
+ }
2418
+ async function readFont(name) {
2419
+ const candidates = [
2420
+ new URL(`../../assets/og-fonts/${name}`, import.meta.url),
2421
+ new URL(`../assets/og-fonts/${name}`, import.meta.url)
2422
+ ];
2423
+ for (const candidate of candidates) {
2424
+ try {
2425
+ return await readFile(candidate);
2426
+ } catch (error) {
2427
+ if (error.code !== "ENOENT") throw error;
2428
+ }
2429
+ }
2430
+ throw new Error(`Cannot locate package-owned OG font asset ${name}.`);
2431
+ }
2432
+ async function renderCard(payload) {
2433
+ const [{ default: satori }, { Resvg }, regular, bold] = await Promise.all([
2434
+ import("satori"),
2435
+ loadResvg(),
2436
+ readFont("Inter-Regular.ttf"),
2437
+ readFont("Inter-Bold.ttf")
2438
+ ]);
2439
+ const boxes = /* @__PURE__ */ new Map();
2440
+ const svg = await satori(cardTree(payload, PROFILE_THEMES[payload.profile]), {
2441
+ width: CARD_WIDTH,
2442
+ height: CARD_HEIGHT,
2443
+ fonts: [
2444
+ { name: "Inter", data: regular, weight: 400, style: "normal" },
2445
+ { name: "Inter", data: bold, weight: 700, style: "normal" }
2446
+ ],
2447
+ onNodeDetected(node) {
2448
+ if (typeof node.key !== "string" || !node.key.startsWith("og-")) return;
2449
+ if (boxes.has(node.key)) throw new Error(`OG renderer reported duplicate ${node.key} layout boxes.`);
2450
+ boxes.set(node.key, {
2451
+ left: node.left,
2452
+ top: node.top,
2453
+ width: node.width,
2454
+ height: node.height
2455
+ });
2456
+ }
2457
+ });
2458
+ validateCardLayout(boxes);
2459
+ const png = new Resvg(svg, {
2460
+ fitTo: { mode: "width", value: CARD_WIDTH }
2461
+ }).render().asPng();
2462
+ return Buffer.from(png);
2463
+ }
2464
+ async function loadResvg() {
2465
+ try {
2466
+ return await import("@resvg/resvg-js");
2467
+ } catch (error) {
2468
+ throw new Error(
2469
+ "Generated OG cards could not load the @resvg/resvg-js platform binding. Reinstall dependencies for this platform without `--omit=optional` and rebuild.",
2470
+ { cause: error }
2471
+ );
2472
+ }
2473
+ }
2474
+ async function writeCollisionSafeImage(file, bytes) {
2475
+ let info;
2476
+ try {
2477
+ info = await lstat(file);
2478
+ } catch (error) {
2479
+ if (error.code !== "ENOENT") throw error;
2480
+ await writeFile(file, bytes, { flag: "wx" });
2481
+ return;
2482
+ }
2483
+ if (!info.isFile() || info.isSymbolicLink()) {
2484
+ throw new Error(`${file}: OG card output must be a regular file, never a symlink.`);
2485
+ }
2486
+ const existing = await readFile(file);
2487
+ if (!existing.equals(bytes)) {
2488
+ throw new Error(`${file}: OG content hash collision produced different PNG bytes.`);
2489
+ }
2490
+ }
2491
+ async function safeOgDirectory(outputDir, create) {
2492
+ const ogDir = join2(outputDir, "_og");
2493
+ let info;
2494
+ try {
2495
+ info = await lstat(ogDir);
2496
+ } catch (error) {
2497
+ if (error.code !== "ENOENT") throw error;
2498
+ if (!create) return null;
2499
+ await mkdir(ogDir, { recursive: true });
2500
+ info = await lstat(ogDir);
2501
+ }
2502
+ if (!info.isDirectory() || info.isSymbolicLink()) {
2503
+ throw new Error(`${ogDir}: OG card output must be a real directory, never a symlink.`);
2504
+ }
2505
+ const [outputReal, ogReal] = await Promise.all([realpath(outputDir), realpath(ogDir)]);
2506
+ if (ogReal !== join2(outputReal, "_og")) {
2507
+ throw new Error(`${ogDir}: OG card output resolved outside the Astro output directory.`);
2508
+ }
2509
+ return ogDir;
2510
+ }
2511
+ async function pruneStaleCards(ogDir, expectedHashes) {
2512
+ if (ogDir === null) return 0;
2513
+ const entries = await readdir(ogDir, { withFileTypes: true });
2514
+ let removed = 0;
2515
+ entries.sort((left, right) => left.name.localeCompare(right.name, "en"));
2516
+ for (const entry of entries) {
2517
+ const match = /^([a-f\d]{16})\.png$/u.exec(entry.name);
2518
+ if (!match || expectedHashes.has(match[1])) continue;
2519
+ if (entry.isSymbolicLink()) {
2520
+ throw new Error(`${join2(ogDir, entry.name)}: scaffold-owned OG filenames must not be symlinks.`);
2521
+ }
2522
+ if (!entry.isFile()) continue;
2523
+ await unlink(join2(ogDir, entry.name));
2524
+ removed += 1;
2525
+ }
2526
+ return removed;
2527
+ }
2528
+ async function processBuild(outputDir, site, base, options) {
2529
+ const matchers = options.ogCards.exclude.map(exclusionMatcher);
2530
+ const generatedPlans = [];
2531
+ const staticPlans = [];
2532
+ let skipped = 0;
2533
+ for (const file of await htmlFiles(outputDir)) {
2534
+ const route = routeForHtml(outputDir, file);
2535
+ if (isBuiltInErrorRoute(route) || matchers.some((matcher) => matcher.test(route))) {
2536
+ skipped += 1;
2537
+ continue;
2538
+ }
2539
+ const source = await readFile(file, "utf8");
2540
+ const page = parsePage(source, file, route);
2541
+ if (isRedirect(page.document) || isNoIndex(page.document)) {
2542
+ skipped += 1;
2543
+ continue;
2544
+ }
2545
+ if (existingAuthoredOgImage(page.document, file)) {
2546
+ skipped += 1;
2547
+ continue;
2548
+ }
2549
+ const identity = resolvePageIdentity(page.document, file, options.corpus);
2550
+ const staticImage = identity.book?.image ?? options.staticOgImage;
2551
+ if (staticImage !== void 0) {
2552
+ staticPlans.push({
2553
+ page,
2554
+ source,
2555
+ imageUrl: resolveStaticImageUrl(staticImage, site, base)
2556
+ });
2557
+ continue;
2558
+ }
2559
+ const payload = payloadForPage(page, options, identity);
2560
+ const payloadJson = stablePayloadJson(payload);
2561
+ const hash = shortHash(payloadJson);
2562
+ generatedPlans.push({
2563
+ page,
2564
+ source,
2565
+ payload,
2566
+ payloadJson,
2567
+ hash,
2568
+ imageUrl: cardUrl(site, base, hash)
2569
+ });
2570
+ }
2571
+ const payloadByHash = /* @__PURE__ */ new Map();
2572
+ for (const plan of generatedPlans) {
2573
+ const previous = payloadByHash.get(plan.hash);
2574
+ if (previous !== void 0 && previous !== plan.payloadJson) {
2575
+ throw new Error(`OG payload hash collision at ${plan.hash}; refusing to overwrite card output.`);
2576
+ }
2577
+ payloadByHash.set(plan.hash, plan.payloadJson);
2578
+ }
2579
+ const ogDir = await safeOgDirectory(outputDir, generatedPlans.length > 0);
2580
+ const rendered = /* @__PURE__ */ new Map();
2581
+ let reused = 0;
2582
+ for (const plan of generatedPlans) {
2583
+ if (rendered.has(plan.hash)) {
2584
+ reused += 1;
2585
+ continue;
2586
+ }
2587
+ const bytes = await renderCard(plan.payload);
2588
+ if (bytes.length === 0) throw new Error(`${plan.page.file}: OG renderer returned an empty PNG.`);
2589
+ await writeCollisionSafeImage(join2(ogDir, `${plan.hash}.png`), bytes);
2590
+ rendered.set(plan.hash, bytes);
2591
+ }
2592
+ const pruned = await pruneStaleCards(ogDir, new Set(generatedPlans.map(({ hash }) => hash)));
2593
+ for (const plan of staticPlans) {
2594
+ const tags = staticImageTags(plan.page, plan.imageUrl);
2595
+ await writeFile(plan.page.file, insertIntoHead(plan.page, plan.source, tags), "utf8");
2596
+ }
2597
+ for (const plan of generatedPlans) {
2598
+ const tags = generatedImageTags(plan.page, plan.imageUrl);
2599
+ await writeFile(plan.page.file, insertIntoHead(plan.page, plan.source, tags), "utf8");
2600
+ }
2601
+ return {
2602
+ generated: rendered.size,
2603
+ reused,
2604
+ staticImages: staticPlans.length,
2605
+ skipped,
2606
+ pruned
2607
+ };
2608
+ }
2609
+ function createOgCardsIntegration(options) {
2610
+ if (!options.ogCards?.enabled) {
2611
+ throw new Error("createOgCardsIntegration requires an enabled normalized ogCards config.");
2612
+ }
2613
+ let site = null;
2614
+ let base = "/";
2615
+ let output = null;
2616
+ return {
2617
+ name: "book-scaffold-og-cards",
2618
+ hooks: {
2619
+ "astro:config:done": ({ config }) => {
2620
+ output = config.output;
2621
+ base = normalizeBase(config.base);
2622
+ if (output !== "static") {
2623
+ throw new Error(
2624
+ `Generated OG cards require Astro output "static" (received ${JSON.stringify(output)}).`
2625
+ );
2626
+ }
2627
+ if (!config.site) {
2628
+ throw new Error("Generated OG cards require an absolute http(s) Astro site URL.");
2629
+ }
2630
+ site = new URL(config.site);
2631
+ if (site.protocol !== "http:" && site.protocol !== "https:" || !site.hostname) {
2632
+ throw new Error("Generated OG cards require an absolute http(s) Astro site URL.");
2633
+ }
2634
+ },
2635
+ "astro:build:done": async ({ dir, logger }) => {
2636
+ if (output !== "static" || !site) {
2637
+ throw new Error(
2638
+ "Generated OG cards were not initialized with a supported static Astro site configuration."
2639
+ );
2640
+ }
2641
+ const outputDir = fileURLToPath2(dir);
2642
+ const info = await stat(outputDir);
2643
+ if (!info.isDirectory()) {
2644
+ throw new Error(`Generated OG card output is not a directory: ${outputDir}`);
2645
+ }
2646
+ const result = await processBuild(outputDir, site, base, options);
2647
+ logger.info(
2648
+ `OG cards: ${result.generated} generated, ${result.reused} reused, ${result.staticImages} static-precedence, ${result.skipped} skipped, ${result.pruned} stale pruned.`
2649
+ );
2650
+ }
2651
+ }
2652
+ };
2653
+ }
2654
+
1784
2655
  // src/config.ts
1785
2656
  var BRANDON_PORTFOLIO_DEFAULT = {
1786
2657
  url: "https://brandon-behring.dev",
@@ -1911,6 +2782,7 @@ async function defineBookConfig(opts) {
1911
2782
  const sitemapOptions = {};
1912
2783
  if (sitemapFilter) sitemapOptions.filter = sitemapFilter;
1913
2784
  if (sitemapCustomPages) sitemapOptions.customPages = sitemapCustomPages;
2785
+ const ogCards = normalizeOgCardsConfig(opts.seo?.ogCards);
1914
2786
  const integrations = [
1915
2787
  mdx(),
1916
2788
  preact(),
@@ -1964,7 +2836,17 @@ async function defineBookConfig(opts) {
1964
2836
  apparatusRoutes: opts.apparatusRoutes
1965
2837
  }
1966
2838
  }),
1967
- ...mergedExtraIntegrations
2839
+ ...mergedExtraIntegrations,
2840
+ // Post-render card generation must observe the final output from every
2841
+ // consumer/style integration, so its build:done hook is installed last.
2842
+ ...ogCards ? [createOgCardsIntegration({
2843
+ profile,
2844
+ corpus,
2845
+ title: opts.title,
2846
+ description: opts.description,
2847
+ staticOgImage: opts.seo?.ogImage,
2848
+ ogCards
2849
+ })] : []
1968
2850
  ];
1969
2851
  const finalRemark = [
1970
2852
  ...remarkPlugins,
@@ -2218,13 +3100,13 @@ function resolveBookHref(siblingBooks, book, to) {
2218
3100
  }
2219
3101
 
2220
3102
  // src/lib/nav-href.ts
2221
- function normalizeBase(baseUrl) {
3103
+ function normalizeBase2(baseUrl) {
2222
3104
  return (baseUrl || "/").replace(/\/*$/, "/");
2223
3105
  }
2224
3106
  function baseNoSlash(baseUrl) {
2225
3107
  return (baseUrl || "/").replace(/\/+$/, "");
2226
3108
  }
2227
- var normBase = normalizeBase;
3109
+ var normBase = normalizeBase2;
2228
3110
  function fillTokens(pattern, tokens) {
2229
3111
  return pattern.replace(/:(book|slug|route|id)\b/g, (_m, k) => tokens[k] ?? "");
2230
3112
  }
@@ -2552,7 +3434,7 @@ export {
2552
3434
  localCorpusEntryId,
2553
3435
  minimalChapterSchema,
2554
3436
  minimalStyle,
2555
- normalizeBase,
3437
+ normalizeBase2 as normalizeBase,
2556
3438
  normalizeFrontmatterConfig,
2557
3439
  originUrlFromGitConfig,
2558
3440
  parseRepoSlug,