From 8d1fed6d5113a3d926ed7382e80f09ba0abe9cf2 Mon Sep 17 00:00:00 2001 From: Alex Patterson Date: Sun, 25 Oct 2026 23:33:53 -0400 Subject: [PATCH] fix(sanity): preserve and normalize spacing around Portable Text links and inline marks --- scripts/enrich-all-posts-aeo-seo-links.mjs | 83 +++++++++++++++++--- scripts/generate-firebase-archive-posts.mjs | 84 ++++++++++++++++++--- 2 files changed, 144 insertions(+), 23 deletions(-) diff --git a/scripts/enrich-all-posts-aeo-seo-links.mjs b/scripts/enrich-all-posts-aeo-seo-links.mjs index 200c8959..a458d17a 100644 --- a/scripts/enrich-all-posts-aeo-seo-links.mjs +++ b/scripts/enrich-all-posts-aeo-seo-links.mjs @@ -250,13 +250,13 @@ export function repairHref(rawHref, currentDoc, validRoutesMap, fallbackRelatedD return trimmed; } -function cleanDashes(text) { +function cleanDashes(text, { trim = true } = {}) { if (!text) return ""; - return String(text) + const cleaned = String(text) .replace(/\s*[—–]\s*/g, ", ") .replace(/\s+--\s+/g, ", ") - .replace(/\s{2,}/g, " ") - .trim(); + .replace(/\s{2,}/g, " "); + return trim ? cleaned.trim() : cleaned; } function makeKey(prefix, seed) { @@ -264,6 +264,60 @@ function makeKey(prefix, seed) { return `aeo2026_${prefix}_${hash}`; } +export function shouldInsertSpaceBetweenSpans(pText, cText) { + if (!pText || !cText) return false; + if (/\s$/.test(pText) || /^\s/.test(cText)) return false; + + const lastChar = pText.slice(-1); + const firstChar = cText.slice(0, 1); + + if (/^[.,;:!?)\]}’']/.test(firstChar)) return false; + if (/^["”](\s|[.,;:!?)]|$)/.test(cText)) return false; + if (/[(\[{]$/.test(lastChar) && pText.length === 1) return false; + if (/(^|\s)[(\[{"“‘]$/.test(pText)) return false; + if (lastChar === "—" || firstChar === "—" || cText.startsWith("---") || pText.endsWith("---")) return false; + if (pText === "-" || cText === "-") return false; + + return true; +} + +export function normalizeBlockSpacing(block) { + if (!block || block._type !== "block" || !Array.isArray(block.children) || block.children.length < 2) { + return block; + } + const linkMarkKeys = new Set((block.markDefs || []).map((m) => m._key)); + const children = block.children.map((c) => ({ + ...c, + marks: Array.isArray(c.marks) ? [...c.marks] : [], + })); + + const out = []; + for (let i = 0; i < children.length; i++) { + const curr = children[i]; + if (out.length > 0) { + const prev = out[out.length - 1]; + if (shouldInsertSpaceBetweenSpans(prev.text, curr.text)) { + const prevHasLinkOrCode = prev.marks.some((m) => m === "code" || linkMarkKeys.has(m)); + const currHasLinkOrCode = curr.marks.some((m) => m === "code" || linkMarkKeys.has(m)); + if (!prevHasLinkOrCode) { + prev.text = `${prev.text} `; + } else if (!currHasLinkOrCode) { + curr.text = ` ${curr.text}`; + } else { + out.push({ + _type: "span", + _key: `${prev._key || "sp"}_sep_${i}`, + marks: [], + text: " ", + }); + } + } + } + out.push(curr); + } + return { ...block, children: out }; +} + function createTextBlock(text, style = "normal", keySeed = text) { const cleaned = cleanDashes(text); return { @@ -287,7 +341,8 @@ function createRichBlock(segments, { style = "normal", listItem = undefined, lev const children = []; segments.forEach((seg, i) => { - const rawText = cleanDashes(seg.text || ""); + const hasMark = Boolean(seg.href || seg.code || seg.strong); + const rawText = cleanDashes(seg.text || "", { trim: hasMark }); if (!rawText) return; const marks = []; if (seg.strong) marks.push("strong"); @@ -320,7 +375,7 @@ function createRichBlock(segments, { style = "normal", listItem = undefined, lev block.listItem = listItem; block.level = level || 1; } - return block; + return normalizeBlockSpacing(block); } export function blockToPlainText(block) { @@ -554,10 +609,11 @@ export function buildEnrichedContent(doc, relatedDocs, rankedTags, validRoutesMa ); const baseBlocks = rawBaseBlocks.map((block) => { - if (!Array.isArray(block?.markDefs) || block.markDefs.length === 0) return block; + const normalized = normalizeBlockSpacing(block); + if (!Array.isArray(normalized?.markDefs) || normalized.markDefs.length === 0) return normalized; return { - ...block, - markDefs: block.markDefs.map((md) => { + ...normalized, + markDefs: normalized.markDefs.map((md) => { if (md?.href && validRoutesMap) { return { ...md, @@ -569,6 +625,11 @@ export function buildEnrichedContent(doc, relatedDocs, rankedTags, validRoutesMa }; }); + // Firebase Archive posts already include their dedicated Web Architecture, Resources, and FAQ sections + if (String(doc?._id || "").startsWith("post-fb-")) { + return baseBlocks; + } + const plainText = baseBlocks.map(blockToPlainText).filter(Boolean).join(" "); const wordCount = plainText ? plainText.split(/\s+/).length : 0; const isThin = wordCount < 200; @@ -762,7 +823,7 @@ export function buildEnrichedContent(doc, relatedDocs, rankedTags, validRoutesMa createRichBlock( [ { text: `${tsLabel}`, href: chUrl, strong: true }, - { text: ` : ${chTitle}` }, + { text: `: ${chTitle}` }, ], { style: "normal", @@ -987,7 +1048,7 @@ export function buildEnrichedContent(doc, relatedDocs, rankedTags, validRoutesMa { text: rel.title, href: relUrl, strong: true }, { text: relExcerpt - ? ` : ${relExcerpt.slice(0, 160)}${relExcerpt.length > 160 ? "..." : ""}` + ? `: ${relExcerpt.slice(0, 160)}${relExcerpt.length > 160 ? "..." : ""}` : ` (${rel._type === "podcast" ? "Podcast Episode" : "Technical Guide"})`, }, ], diff --git a/scripts/generate-firebase-archive-posts.mjs b/scripts/generate-firebase-archive-posts.mjs index bf6be43f..94bcc40f 100644 --- a/scripts/generate-firebase-archive-posts.mjs +++ b/scripts/generate-firebase-archive-posts.mjs @@ -12,13 +12,13 @@ const maxLimit = limitArg ? Number(limitArg.split("=")[1]) : 200; const EXCLUDE_SLUG_REGEX = /(fcm-on-android|ai-logic-unity-androidxr|fpnv-|firebase-pnv-|crashlytics-android|crashlytics-mcp|crashlytics-export|dart-functions-exp|apple-foundation-models|build-ios-app|firebase-analytics-outage|brazil-roundup|ios-sdk|android-sdk|cocoapods|swiftpm|gradle|wear-os|tvos|watchos|flutter-only)/i; -function cleanDashes(text) { +function cleanDashes(text, { trim = true } = {}) { if (!text) return ""; - return String(text) + const cleaned = String(text) .replace(/\s*[—–]\s*/g, ", ") .replace(/\s+--\s+/g, ", ") - .replace(/\s{2,}/g, " ") - .trim(); + .replace(/\s{2,}/g, " "); + return trim ? cleaned.trim() : cleaned; } function decodeHtmlEntities(str) { @@ -35,7 +35,12 @@ function decodeHtmlEntities(str) { } function stripTags(html) { - return decodeHtmlEntities(String(html || "").replace(/<[^>]+>/g, "")).trim(); + return decodeHtmlEntities( + String(html || "") + .replace(//gi, " ") + .replace(/<[^>]+>/g, "") + .replace(/\s+/g, " ") + ).trim(); } function makeKey(prefix, seed) { @@ -43,6 +48,60 @@ function makeKey(prefix, seed) { return `fb_${prefix}_${hash}`; } +export function shouldInsertSpaceBetweenSpans(pText, cText) { + if (!pText || !cText) return false; + if (/\s$/.test(pText) || /^\s/.test(cText)) return false; + + const lastChar = pText.slice(-1); + const firstChar = cText.slice(0, 1); + + if (/^[.,;:!?)\]}’']/.test(firstChar)) return false; + if (/^["”](\s|[.,;:!?)]|$)/.test(cText)) return false; + if (/[(\[{]$/.test(lastChar) && pText.length === 1) return false; + if (/(^|\s)[(\[{"“‘]$/.test(pText)) return false; + if (lastChar === "—" || firstChar === "—" || cText.startsWith("---") || pText.endsWith("---")) return false; + if (pText === "-" || cText === "-") return false; + + return true; +} + +export function normalizeBlockSpacing(block) { + if (!block || block._type !== "block" || !Array.isArray(block.children) || block.children.length < 2) { + return block; + } + const linkMarkKeys = new Set((block.markDefs || []).map((m) => m._key)); + const children = block.children.map((c) => ({ + ...c, + marks: Array.isArray(c.marks) ? [...c.marks] : [], + })); + + const out = []; + for (let i = 0; i < children.length; i++) { + const curr = children[i]; + if (out.length > 0) { + const prev = out[out.length - 1]; + if (shouldInsertSpaceBetweenSpans(prev.text, curr.text)) { + const prevHasLinkOrCode = prev.marks.some((m) => m === "code" || linkMarkKeys.has(m)); + const currHasLinkOrCode = curr.marks.some((m) => m === "code" || linkMarkKeys.has(m)); + if (!prevHasLinkOrCode) { + prev.text = `${prev.text} `; + } else if (!currHasLinkOrCode) { + curr.text = ` ${curr.text}`; + } else { + out.push({ + _type: "span", + _key: `${prev._key || "sp"}_sep_${i}`, + marks: [], + text: " ", + }); + } + } + } + out.push(curr); + } + return { ...block, children: out }; +} + function createTextBlock(text, style = "normal", keySeed = text) { return { _type: "block", @@ -64,7 +123,8 @@ function createRichBlock(segments, { style = "normal", listItem = undefined, lev const markDefs = []; const children = []; segments.forEach((seg, i) => { - const rawText = cleanDashes(seg.text || ""); + const hasMark = Boolean(seg.href || seg.code || seg.strong); + const rawText = cleanDashes(seg.text || "", { trim: hasMark }); if (!rawText) return; const marks = []; if (seg.strong) marks.push("strong"); @@ -98,7 +158,7 @@ function createRichBlock(segments, { style = "normal", listItem = undefined, lev block.listItem = listItem; block.level = level || 1; } - return block; + return normalizeBlockSpacing(block); } /** @@ -113,7 +173,7 @@ function parseInlineHtmlToSegments(html, baseUrl = "https://firebase.blog") { while ((match = tokenRegex.exec(html)) !== null) { if (match.index > lastIndex) { const plain = stripTags(html.slice(lastIndex, match.index)); - if (plain) segments.push({ text: `${plain} ` }); + if (plain) segments.push({ text: plain }); } const fullToken = match[1]; const hrefAttr = match[2]; @@ -124,11 +184,11 @@ function parseInlineHtmlToSegments(html, baseUrl = "https://firebase.blog") { try { resolvedHref = new URL(hrefAttr, baseUrl).toString(); } catch {} - segments.push({ text: ` ${innerText} `, href: resolvedHref, strong: true }); + segments.push({ text: innerText, href: resolvedHref, strong: true }); } else if (/^<(?:strong|b)>/i.test(fullToken)) { - segments.push({ text: ` ${innerText} `, strong: true }); + segments.push({ text: innerText, strong: true }); } else if (/^/i.test(fullToken)) { - segments.push({ text: ` ${innerText} `, code: true }); + segments.push({ text: innerText, code: true }); } } lastIndex = tokenRegex.lastIndex; @@ -136,7 +196,7 @@ function parseInlineHtmlToSegments(html, baseUrl = "https://firebase.blog") { if (lastIndex < html.length) { const tail = stripTags(html.slice(lastIndex)); - if (tail) segments.push({ text: ` ${tail}` }); + if (tail) segments.push({ text: tail }); } if (segments.length === 0) {