Skip to content

Commit cb060e1

Browse files
authored
Merge pull request #809 from CodingCatDev/fix/portable-text-link-spacing
fix(sanity): preserve and normalize spacing around Portable Text links and inline marks
2 parents 3ae1e48 + 8d1fed6 commit cb060e1

2 files changed

Lines changed: 144 additions & 23 deletions

File tree

‎scripts/enrich-all-posts-aeo-seo-links.mjs‎

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -250,20 +250,74 @@ export function repairHref(rawHref, currentDoc, validRoutesMap, fallbackRelatedD
250250
return trimmed;
251251
}
252252

253-
function cleanDashes(text) {
253+
function cleanDashes(text, { trim = true } = {}) {
254254
if (!text) return "";
255-
return String(text)
255+
const cleaned = String(text)
256256
.replace(/\s*[—–]\s*/g, ", ")
257257
.replace(/\s+--\s+/g, ", ")
258-
.replace(/\s{2,}/g, " ")
259-
.trim();
258+
.replace(/\s{2,}/g, " ");
259+
return trim ? cleaned.trim() : cleaned;
260260
}
261261

262262
function makeKey(prefix, seed) {
263263
const hash = crypto.createHash("md5").update(String(seed)).digest("hex").slice(0, 10);
264264
return `aeo2026_${prefix}_${hash}`;
265265
}
266266

267+
export function shouldInsertSpaceBetweenSpans(pText, cText) {
268+
if (!pText || !cText) return false;
269+
if (/\s$/.test(pText) || /^\s/.test(cText)) return false;
270+
271+
const lastChar = pText.slice(-1);
272+
const firstChar = cText.slice(0, 1);
273+
274+
if (/^[.,;:!?)\]}’']/.test(firstChar)) return false;
275+
if (/^["”](\s|[.,;:!?)]|$)/.test(cText)) return false;
276+
if (/[(\[{]$/.test(lastChar) && pText.length === 1) return false;
277+
if (/(^|\s)[(\[{"“‘]$/.test(pText)) return false;
278+
if (lastChar === "—" || firstChar === "—" || cText.startsWith("---") || pText.endsWith("---")) return false;
279+
if (pText === "-" || cText === "-") return false;
280+
281+
return true;
282+
}
283+
284+
export function normalizeBlockSpacing(block) {
285+
if (!block || block._type !== "block" || !Array.isArray(block.children) || block.children.length < 2) {
286+
return block;
287+
}
288+
const linkMarkKeys = new Set((block.markDefs || []).map((m) => m._key));
289+
const children = block.children.map((c) => ({
290+
...c,
291+
marks: Array.isArray(c.marks) ? [...c.marks] : [],
292+
}));
293+
294+
const out = [];
295+
for (let i = 0; i < children.length; i++) {
296+
const curr = children[i];
297+
if (out.length > 0) {
298+
const prev = out[out.length - 1];
299+
if (shouldInsertSpaceBetweenSpans(prev.text, curr.text)) {
300+
const prevHasLinkOrCode = prev.marks.some((m) => m === "code" || linkMarkKeys.has(m));
301+
const currHasLinkOrCode = curr.marks.some((m) => m === "code" || linkMarkKeys.has(m));
302+
if (!prevHasLinkOrCode) {
303+
prev.text = `${prev.text} `;
304+
} else if (!currHasLinkOrCode) {
305+
curr.text = ` ${curr.text}`;
306+
} else {
307+
out.push({
308+
_type: "span",
309+
_key: `${prev._key || "sp"}_sep_${i}`,
310+
marks: [],
311+
text: " ",
312+
});
313+
}
314+
}
315+
}
316+
out.push(curr);
317+
}
318+
return { ...block, children: out };
319+
}
320+
267321
function createTextBlock(text, style = "normal", keySeed = text) {
268322
const cleaned = cleanDashes(text);
269323
return {
@@ -287,7 +341,8 @@ function createRichBlock(segments, { style = "normal", listItem = undefined, lev
287341
const children = [];
288342

289343
segments.forEach((seg, i) => {
290-
const rawText = cleanDashes(seg.text || "");
344+
const hasMark = Boolean(seg.href || seg.code || seg.strong);
345+
const rawText = cleanDashes(seg.text || "", { trim: hasMark });
291346
if (!rawText) return;
292347
const marks = [];
293348
if (seg.strong) marks.push("strong");
@@ -320,7 +375,7 @@ function createRichBlock(segments, { style = "normal", listItem = undefined, lev
320375
block.listItem = listItem;
321376
block.level = level || 1;
322377
}
323-
return block;
378+
return normalizeBlockSpacing(block);
324379
}
325380

326381
export function blockToPlainText(block) {
@@ -554,10 +609,11 @@ export function buildEnrichedContent(doc, relatedDocs, rankedTags, validRoutesMa
554609
);
555610

556611
const baseBlocks = rawBaseBlocks.map((block) => {
557-
if (!Array.isArray(block?.markDefs) || block.markDefs.length === 0) return block;
612+
const normalized = normalizeBlockSpacing(block);
613+
if (!Array.isArray(normalized?.markDefs) || normalized.markDefs.length === 0) return normalized;
558614
return {
559-
...block,
560-
markDefs: block.markDefs.map((md) => {
615+
...normalized,
616+
markDefs: normalized.markDefs.map((md) => {
561617
if (md?.href && validRoutesMap) {
562618
return {
563619
...md,
@@ -569,6 +625,11 @@ export function buildEnrichedContent(doc, relatedDocs, rankedTags, validRoutesMa
569625
};
570626
});
571627

628+
// Firebase Archive posts already include their dedicated Web Architecture, Resources, and FAQ sections
629+
if (String(doc?._id || "").startsWith("post-fb-")) {
630+
return baseBlocks;
631+
}
632+
572633
const plainText = baseBlocks.map(blockToPlainText).filter(Boolean).join(" ");
573634
const wordCount = plainText ? plainText.split(/\s+/).length : 0;
574635
const isThin = wordCount < 200;
@@ -762,7 +823,7 @@ export function buildEnrichedContent(doc, relatedDocs, rankedTags, validRoutesMa
762823
createRichBlock(
763824
[
764825
{ text: `${tsLabel}`, href: chUrl, strong: true },
765-
{ text: ` : ${chTitle}` },
826+
{ text: `: ${chTitle}` },
766827
],
767828
{
768829
style: "normal",
@@ -987,7 +1048,7 @@ export function buildEnrichedContent(doc, relatedDocs, rankedTags, validRoutesMa
9871048
{ text: rel.title, href: relUrl, strong: true },
9881049
{
9891050
text: relExcerpt
990-
? ` : ${relExcerpt.slice(0, 160)}${relExcerpt.length > 160 ? "..." : ""}`
1051+
? `: ${relExcerpt.slice(0, 160)}${relExcerpt.length > 160 ? "..." : ""}`
9911052
: ` (${rel._type === "podcast" ? "Podcast Episode" : "Technical Guide"})`,
9921053
},
9931054
],

‎scripts/generate-firebase-archive-posts.mjs‎

Lines changed: 72 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ const maxLimit = limitArg ? Number(limitArg.split("=")[1]) : 200;
1212
const EXCLUDE_SLUG_REGEX =
1313
/(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;
1414

15-
function cleanDashes(text) {
15+
function cleanDashes(text, { trim = true } = {}) {
1616
if (!text) return "";
17-
return String(text)
17+
const cleaned = String(text)
1818
.replace(/\s*[—–]\s*/g, ", ")
1919
.replace(/\s+--\s+/g, ", ")
20-
.replace(/\s{2,}/g, " ")
21-
.trim();
20+
.replace(/\s{2,}/g, " ");
21+
return trim ? cleaned.trim() : cleaned;
2222
}
2323

2424
function decodeHtmlEntities(str) {
@@ -35,14 +35,73 @@ function decodeHtmlEntities(str) {
3535
}
3636

3737
function stripTags(html) {
38-
return decodeHtmlEntities(String(html || "").replace(/<[^>]+>/g, "")).trim();
38+
return decodeHtmlEntities(
39+
String(html || "")
40+
.replace(/<br\s*\/?>/gi, " ")
41+
.replace(/<[^>]+>/g, "")
42+
.replace(/\s+/g, " ")
43+
).trim();
3944
}
4045

4146
function makeKey(prefix, seed) {
4247
const hash = crypto.createHash("md5").update(String(seed)).digest("hex").slice(0, 10);
4348
return `fb_${prefix}_${hash}`;
4449
}
4550

51+
export function shouldInsertSpaceBetweenSpans(pText, cText) {
52+
if (!pText || !cText) return false;
53+
if (/\s$/.test(pText) || /^\s/.test(cText)) return false;
54+
55+
const lastChar = pText.slice(-1);
56+
const firstChar = cText.slice(0, 1);
57+
58+
if (/^[.,;:!?)\]}’']/.test(firstChar)) return false;
59+
if (/^["”](\s|[.,;:!?)]|$)/.test(cText)) return false;
60+
if (/[(\[{]$/.test(lastChar) && pText.length === 1) return false;
61+
if (/(^|\s)[(\[{"“‘]$/.test(pText)) return false;
62+
if (lastChar === "—" || firstChar === "—" || cText.startsWith("---") || pText.endsWith("---")) return false;
63+
if (pText === "-" || cText === "-") return false;
64+
65+
return true;
66+
}
67+
68+
export function normalizeBlockSpacing(block) {
69+
if (!block || block._type !== "block" || !Array.isArray(block.children) || block.children.length < 2) {
70+
return block;
71+
}
72+
const linkMarkKeys = new Set((block.markDefs || []).map((m) => m._key));
73+
const children = block.children.map((c) => ({
74+
...c,
75+
marks: Array.isArray(c.marks) ? [...c.marks] : [],
76+
}));
77+
78+
const out = [];
79+
for (let i = 0; i < children.length; i++) {
80+
const curr = children[i];
81+
if (out.length > 0) {
82+
const prev = out[out.length - 1];
83+
if (shouldInsertSpaceBetweenSpans(prev.text, curr.text)) {
84+
const prevHasLinkOrCode = prev.marks.some((m) => m === "code" || linkMarkKeys.has(m));
85+
const currHasLinkOrCode = curr.marks.some((m) => m === "code" || linkMarkKeys.has(m));
86+
if (!prevHasLinkOrCode) {
87+
prev.text = `${prev.text} `;
88+
} else if (!currHasLinkOrCode) {
89+
curr.text = ` ${curr.text}`;
90+
} else {
91+
out.push({
92+
_type: "span",
93+
_key: `${prev._key || "sp"}_sep_${i}`,
94+
marks: [],
95+
text: " ",
96+
});
97+
}
98+
}
99+
}
100+
out.push(curr);
101+
}
102+
return { ...block, children: out };
103+
}
104+
46105
function createTextBlock(text, style = "normal", keySeed = text) {
47106
return {
48107
_type: "block",
@@ -64,7 +123,8 @@ function createRichBlock(segments, { style = "normal", listItem = undefined, lev
64123
const markDefs = [];
65124
const children = [];
66125
segments.forEach((seg, i) => {
67-
const rawText = cleanDashes(seg.text || "");
126+
const hasMark = Boolean(seg.href || seg.code || seg.strong);
127+
const rawText = cleanDashes(seg.text || "", { trim: hasMark });
68128
if (!rawText) return;
69129
const marks = [];
70130
if (seg.strong) marks.push("strong");
@@ -98,7 +158,7 @@ function createRichBlock(segments, { style = "normal", listItem = undefined, lev
98158
block.listItem = listItem;
99159
block.level = level || 1;
100160
}
101-
return block;
161+
return normalizeBlockSpacing(block);
102162
}
103163

104164
/**
@@ -113,7 +173,7 @@ function parseInlineHtmlToSegments(html, baseUrl = "https://firebase.blog") {
113173
while ((match = tokenRegex.exec(html)) !== null) {
114174
if (match.index > lastIndex) {
115175
const plain = stripTags(html.slice(lastIndex, match.index));
116-
if (plain) segments.push({ text: `${plain} ` });
176+
if (plain) segments.push({ text: plain });
117177
}
118178
const fullToken = match[1];
119179
const hrefAttr = match[2];
@@ -124,19 +184,19 @@ function parseInlineHtmlToSegments(html, baseUrl = "https://firebase.blog") {
124184
try {
125185
resolvedHref = new URL(hrefAttr, baseUrl).toString();
126186
} catch {}
127-
segments.push({ text: ` ${innerText} `, href: resolvedHref, strong: true });
187+
segments.push({ text: innerText, href: resolvedHref, strong: true });
128188
} else if (/^<(?:strong|b)>/i.test(fullToken)) {
129-
segments.push({ text: ` ${innerText} `, strong: true });
189+
segments.push({ text: innerText, strong: true });
130190
} else if (/^<code>/i.test(fullToken)) {
131-
segments.push({ text: ` ${innerText} `, code: true });
191+
segments.push({ text: innerText, code: true });
132192
}
133193
}
134194
lastIndex = tokenRegex.lastIndex;
135195
}
136196

137197
if (lastIndex < html.length) {
138198
const tail = stripTags(html.slice(lastIndex));
139-
if (tail) segments.push({ text: ` ${tail}` });
199+
if (tail) segments.push({ text: tail });
140200
}
141201

142202
if (segments.length === 0) {

0 commit comments

Comments
 (0)