jamdesk 1.1.196 → 1.1.197

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.
@@ -0,0 +1,482 @@
1
+ /**
2
+ * remark-style-string-to-object
3
+ *
4
+ * MDX AST plugin that rewrites a string `style="…"` attribute on intrinsic JSX
5
+ * elements into a React style OBJECT.
6
+ *
7
+ * WHY: MDX has no raw HTML — everything angle-bracketed is parsed as JSX. So an
8
+ * embed pasted from a third-party site keeps its HTML `style` attribute and MDX
9
+ * hands React a plain string, which React refuses:
10
+ *
11
+ * The `style` prop expects a mapping from style properties to values, not a
12
+ * string. (minified: React error #62)
13
+ *
14
+ * That throw happens at RENDER, not at compile, so nothing that guards the
15
+ * compile step sees it: `render-doc-page.tsx` catches compile failures only,
16
+ * and the editor's `compileMdx` try/catch likewise. In the editor the throw
17
+ * escaped all the way to the dashboard's root error boundary and unmounted the
18
+ * whole app ("Something went wrong"); in production it breaks the page render.
19
+ * Reported by a customer whose iframe carried a scribehow.com embed's
20
+ * `style="aspect-ratio: 1 / 1; border: 0; min-height: 480px"`.
21
+ *
22
+ * Markdown-generated HTML does NOT have this problem — hast elements go through
23
+ * `hast-util-to-jsx-runtime`, which already converts `style` via `style-to-js`.
24
+ * The gap is specific to MDX JSX nodes, which bypass hast entirely, so the fix
25
+ * belongs at the mdast/JSX layer — here.
26
+ *
27
+ * Two AST shapes carry a `style` attribute, mirroring remark-svg-namespace-attrs
28
+ * (the same problem class: HTML-shaped attributes pasted into MDX):
29
+ * 1. mdast JSX nodes (`mdxJsxFlowElement`, `mdxJsxTextElement`) — the pasted
30
+ * `<iframe style="…">` in the doc body. The attribute value is a string.
31
+ * 2. estree JSX nodes inside expression containers
32
+ * (`mdxJsxAttributeValueExpression` for `<Card icon={<svg style="…"/>}>`,
33
+ * `mdxFlowExpression` / `mdxTextExpression` for `{<svg …/>}` blocks,
34
+ * `mdxjsEsm` for `export const Icon = () => <svg style="…"/>`). There the
35
+ * value is a `JSXAttribute` with a string `Literal`.
36
+ *
37
+ * SCOPE — intrinsic elements only. React throws for DOM elements; a custom
38
+ * component may legitimately declare `style?: string`, and rewriting its prop
39
+ * would break it. Element names are therefore converted only when they look
40
+ * intrinsic (lowercase first letter, no dot). Same rule React itself uses to
41
+ * decide between a DOM tag and a component.
42
+ *
43
+ * VALUES stay verbatim strings. React appends `px` to a bare NUMBER on most
44
+ * properties, so coercing `line-height: 2` to the number 2 would silently
45
+ * produce `line-height: 2px`. Keeping the source string also preserves
46
+ * `!important` (which React's SSR serializer emits as written).
47
+ */
48
+
49
+ import type { Root } from 'mdast';
50
+ import type { MdxJsxFlowElement, MdxJsxTextElement } from 'mdast-util-mdx-jsx';
51
+ import type { MdxFlowExpression, MdxTextExpression } from 'mdast-util-mdx-expression';
52
+ import type { MdxjsEsm } from 'mdast-util-mdxjs-esm';
53
+ import { visit } from 'unist-util-visit';
54
+ import { visit as visitEstree } from 'estree-util-visit';
55
+ import type {
56
+ Program,
57
+ JSXOpeningElement,
58
+ JSXIdentifier,
59
+ JSXMemberExpression,
60
+ JSXNamespacedName,
61
+ ObjectExpression,
62
+ Property,
63
+ } from 'estree-jsx';
64
+
65
+ /** One parsed CSS declaration: the React property key and its verbatim value. */
66
+ interface Declaration {
67
+ key: string;
68
+ value: string;
69
+ }
70
+
71
+ /**
72
+ * Does the quote opened at `open` ever close?
73
+ *
74
+ * An UNMATCHED quote must not put a scanner into string mode. If it does, every
75
+ * later `;` and `:` looks quoted, so the rest of the attribute folds into the
76
+ * current value and those declarations vanish with no error:
77
+ *
78
+ * font-family: Kim's Handwriting; color: red; border: 0
79
+ *
80
+ * — one apostrophe in a font name and the author silently loses `color` and
81
+ * `border`. A stray quote is far likelier than a deliberate unterminated string,
82
+ * so it is treated as an ordinary character and the remaining declarations
83
+ * survive.
84
+ */
85
+ function quoteCloses(s: string, open: number, quote: string): boolean {
86
+ for (let i = open + 1; i < s.length; i += 1) {
87
+ if (s[i] === '\\') {
88
+ i += 1;
89
+ continue;
90
+ }
91
+ if (s[i] === quote) return true;
92
+ }
93
+ return false;
94
+ }
95
+
96
+ /** True when a quote at `i` should open a string rather than be literal text. */
97
+ function opensString(s: string, i: number): boolean {
98
+ const ch = s[i];
99
+ return (ch === '"' || ch === "'") && quoteCloses(s, i, ch);
100
+ }
101
+
102
+ /**
103
+ * Index just past the `)` closing a `url(` whose body starts at `from`.
104
+ * Quotes are honoured so `url("a)b.png")` is not cut at the inner paren.
105
+ * An unclosed `url(` runs to the end of the value.
106
+ */
107
+ function endOfUrlToken(s: string, from: number): number {
108
+ let quote: string | null = null;
109
+ for (let i = from; i < s.length; i += 1) {
110
+ const ch = s[i];
111
+ if (quote) {
112
+ if (ch === '\\') i += 1;
113
+ else if (ch === quote) quote = null;
114
+ continue;
115
+ }
116
+ if (opensString(s, i)) quote = ch;
117
+ else if (ch === ')') return i + 1;
118
+ }
119
+ return s.length;
120
+ }
121
+
122
+ /**
123
+ * Split a `style` attribute into declarations.
124
+ *
125
+ * Hand-rolled rather than pulling in `style-to-object`: that package is present
126
+ * only TRANSITIVELY (via @mdx-js/mdx → hast-util-to-jsx-runtime → style-to-js),
127
+ * and this file is vendored verbatim into both `builder/cli/vendored/` and
128
+ * `dashboard/hosting/vendored/builder/`, where a direct dependency would have to
129
+ * be added to three package trees and the CLI's generated workspace lockfile.
130
+ *
131
+ * The grammar that actually matters here is small, but the two structures below
132
+ * are NOT optional — both appear in real pasted embeds:
133
+ * - parentheses: `url(data:image/png;base64,…)` contains BOTH `;` and `:`
134
+ * - quotes: `content: "a;b"`, `font-family: 'A;B'`
135
+ * so the scanner tracks quote state and paren depth and only breaks on a
136
+ * top-level, unquoted delimiter. Escapes inside quotes (`"\""`) are honoured so
137
+ * a backslash-escaped quote does not end the string early.
138
+ */
139
+ function splitDeclarations(style: string): Declaration[] {
140
+ const out: Declaration[] = [];
141
+ style = stripCssComments(style);
142
+ let depth = 0;
143
+ let quote: string | null = null;
144
+ let start = 0;
145
+
146
+ const flush = (end: number): void => {
147
+ const decl = style.slice(start, end);
148
+ start = end + 1;
149
+ const parsed = parseDeclaration(decl);
150
+ if (parsed) out.push(parsed);
151
+ };
152
+
153
+ for (let i = 0; i < style.length; i += 1) {
154
+ const ch = style[i];
155
+ if (quote) {
156
+ // A backslash escapes the next character inside a quoted string, so `\"`
157
+ // does not terminate it. Skip the escaped character outright.
158
+ if (ch === '\\') i += 1;
159
+ else if (ch === quote) quote = null;
160
+ continue;
161
+ }
162
+ if (opensString(style, i)) quote = ch;
163
+ else if (ch === '(') depth += 1;
164
+ else if (ch === ')') depth = Math.max(0, depth - 1);
165
+ else if (ch === ';' && depth === 0) flush(i);
166
+ }
167
+ flush(style.length);
168
+ return out;
169
+ }
170
+
171
+ /**
172
+ * Remove `/* … *\/` comments, which are legal anywhere in a style attribute and
173
+ * are NOT legal in a React style value.
174
+ *
175
+ * This is a silent-corruption guard, not tidiness. `color: red /* note *\/`
176
+ * would otherwise become the value `"red /* note *\/"`, and React assigns that
177
+ * straight to `style.color`, where it is invalid and the browser DROPS the whole
178
+ * declaration — the author's colour vanishes with no error anywhere. A leading
179
+ * comment is worse: `/* note *\/ color: red` parses the comment into the
180
+ * PROPERTY NAME and emits a garbage key.
181
+ *
182
+ * Quote-aware, because `content: "/*"` is a string, not a comment. An
183
+ * unterminated `/*` runs to the end of the value, matching the CSS spec.
184
+ *
185
+ * Also url-aware. An UNQUOTED `url(…)` is a single URL token and CSS does not
186
+ * recognise comments inside it, so a `/*` in a path — `url(https://cdn/a/*b.png)`
187
+ * — would otherwise open a comment that never closes and take the rest of the
188
+ * style with it, truncating the URL mid-string and deleting every later
189
+ * declaration. The token is copied through verbatim instead.
190
+ */
191
+ function stripCssComments(style: string): string {
192
+ if (!style.includes('/*')) return style;
193
+ let out = '';
194
+ let quote: string | null = null;
195
+ for (let i = 0; i < style.length; i += 1) {
196
+ const ch = style[i];
197
+ if (quote) {
198
+ if (ch === '\\') {
199
+ out += ch + (style[i + 1] ?? '');
200
+ i += 1;
201
+ continue;
202
+ }
203
+ if (ch === quote) quote = null;
204
+ out += ch;
205
+ continue;
206
+ }
207
+ if (opensString(style, i)) {
208
+ quote = ch;
209
+ out += ch;
210
+ continue;
211
+ }
212
+ if ((ch === 'u' || ch === 'U') && /^url\(/i.test(style.slice(i, i + 4))) {
213
+ const end = endOfUrlToken(style, i + 4);
214
+ out += style.slice(i, end);
215
+ i = end - 1;
216
+ continue;
217
+ }
218
+ if (ch === '/' && style[i + 1] === '*') {
219
+ const end = style.indexOf('*/', i + 2);
220
+ // Unterminated comment consumes the rest, per CSS.
221
+ if (end === -1) return out;
222
+ i = end + 1;
223
+ // A comment is a token separator, so leave whitespace behind rather than
224
+ // gluing `border/*x*\/0` into `border0`.
225
+ out += ' ';
226
+ continue;
227
+ }
228
+ out += ch;
229
+ }
230
+ return out;
231
+ }
232
+
233
+ /**
234
+ * Split one declaration on its FIRST top-level colon. `background: url(a:b)`
235
+ * and `content: "a:b"` both carry later colons that are part of the value.
236
+ */
237
+ function parseDeclaration(decl: string): Declaration | null {
238
+ let depth = 0;
239
+ let quote: string | null = null;
240
+ for (let i = 0; i < decl.length; i += 1) {
241
+ const ch = decl[i];
242
+ if (quote) {
243
+ if (ch === '\\') i += 1;
244
+ else if (ch === quote) quote = null;
245
+ continue;
246
+ }
247
+ if (opensString(decl, i)) quote = ch;
248
+ else if (ch === '(') depth += 1;
249
+ else if (ch === ')') depth = Math.max(0, depth - 1);
250
+ else if (ch === ':' && depth === 0) {
251
+ const name = decl.slice(0, i).trim();
252
+ const value = decl.slice(i + 1).trim();
253
+ // A declaration needs both halves. `color:` (no value) and `: red` (no
254
+ // property) are dropped rather than emitted as an empty-keyed property,
255
+ // which would render as a stray `:value` in the style attribute.
256
+ if (!name || !value) return null;
257
+ return { key: toReactProperty(name), value };
258
+ }
259
+ }
260
+ // No colon at all — `novalue`. Not a declaration.
261
+ return null;
262
+ }
263
+
264
+ /**
265
+ * Convert a CSS property name to the key React expects.
266
+ *
267
+ * aspect-ratio → aspectRatio
268
+ * -webkit-line-clamp → WebkitLineClamp (React capitalises the vendor prefix)
269
+ * -moz-appearance → MozAppearance
270
+ * -ms-flex → msFlex (the documented -ms- exception:
271
+ * lowercase `ms`, unlike every other
272
+ * vendor prefix — matches React's own
273
+ * hyphenation table)
274
+ * --brand-color → --brand-color (custom property, passed through:
275
+ * React sets these with
276
+ * style.setProperty and camelCasing
277
+ * would break the lookup)
278
+ *
279
+ * A kebab key is not merely untidy — React renders it but logs "Unsupported
280
+ * style property aspect-ratio. Did you mean aspectRatio?" on every render, which
281
+ * is the exact noise remark-svg-namespace-attrs was written to eliminate.
282
+ */
283
+ function toReactProperty(name: string): string {
284
+ // CSS custom property. Two leading dashes, case-sensitive, never camelCased.
285
+ if (name.startsWith('--')) return name;
286
+
287
+ const lower = name.toLowerCase();
288
+ // `-ms-` is the one prefix React lowercases; every other `-x-` prefix gets an
289
+ // upper-cased first letter. Strip the leading dash first so the generic
290
+ // kebab→camel pass below sees a normal name.
291
+ if (lower.startsWith('-ms-')) return camelCase(lower.slice(1));
292
+ if (lower.startsWith('-')) {
293
+ const camel = camelCase(lower.slice(1));
294
+ return camel.charAt(0).toUpperCase() + camel.slice(1);
295
+ }
296
+ return camelCase(lower);
297
+ }
298
+
299
+ function camelCase(name: string): string {
300
+ return name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase());
301
+ }
302
+
303
+ /** Build the estree ObjectExpression React will receive as the `style` prop. */
304
+ function buildObjectExpression(declarations: Declaration[]): ObjectExpression {
305
+ const properties: Property[] = declarations.map((d) => ({
306
+ type: 'Property',
307
+ method: false,
308
+ shorthand: false,
309
+ computed: false,
310
+ // Always a string-literal key: custom properties (`--brand`) and vendor
311
+ // forms are not valid bare identifiers, and a quoted key is valid for the
312
+ // camelCase ones too, so one shape covers every case.
313
+ key: { type: 'Literal', value: d.key },
314
+ value: { type: 'Literal', value: d.value },
315
+ kind: 'init',
316
+ }));
317
+ return { type: 'ObjectExpression', properties };
318
+ }
319
+
320
+ /**
321
+ * Serialize declarations back to a JS object literal source string.
322
+ *
323
+ * MDX carries both an estree (`data.estree`) and the raw `value` source for an
324
+ * attribute expression, and downstream tooling reads either one, so both are
325
+ * emitted and must agree. JSON.stringify gives correctly escaped keys and
326
+ * values for the Literal nodes above.
327
+ */
328
+ function toSource(declarations: Declaration[]): string {
329
+ const body = declarations
330
+ .map((d) => `${JSON.stringify(d.key)}: ${JSON.stringify(d.value)}`)
331
+ .join(', ');
332
+ return `{${body}}`;
333
+ }
334
+
335
+ /**
336
+ * React treats a lowercase-initial, dot-free tag as a DOM element and anything
337
+ * else as a component. Only DOM elements throw on a string `style`, and only
338
+ * they are safe to rewrite.
339
+ */
340
+ function isIntrinsic(name: string | null | undefined): boolean {
341
+ if (!name) return false;
342
+ if (name.includes('.')) return false;
343
+ const first = name.charAt(0);
344
+ return first === first.toLowerCase() && first !== first.toUpperCase();
345
+ }
346
+
347
+ /**
348
+ * Rewrite `style="…"` on an mdast JSX element.
349
+ *
350
+ * An unparseable style (`style=""`, `style=";;"`, `style="novalue"`) has its
351
+ * attribute REMOVED, not blanked. A JSX attribute carrying no value is
352
+ * `style={true}`, and React's guard is `styles != null && typeof styles !==
353
+ * 'object'` — `true` fails it and throws error #62 exactly like the string did.
354
+ * `rewriteJsxOpening` drops it for the same reason.
355
+ */
356
+ function rewriteMdastAttrs(node: MdxJsxFlowElement | MdxJsxTextElement): void {
357
+ if (!isIntrinsic(node.name)) return;
358
+ const kept: typeof node.attributes = [];
359
+ for (const attr of node.attributes) {
360
+ // Only a literal string `style`. An existing `style={{…}}` arrives as an
361
+ // mdxJsxAttributeValueExpression and is already correct.
362
+ if (
363
+ attr.type !== 'mdxJsxAttribute' ||
364
+ attr.name !== 'style' ||
365
+ typeof attr.value !== 'string'
366
+ ) {
367
+ kept.push(attr);
368
+ continue;
369
+ }
370
+ const declarations = splitDeclarations(attr.value);
371
+ if (!declarations.length) continue; // drop, per the note above
372
+ attr.value = {
373
+ type: 'mdxJsxAttributeValueExpression',
374
+ value: toSource(declarations),
375
+ data: {
376
+ estree: {
377
+ type: 'Program',
378
+ sourceType: 'module',
379
+ comments: [],
380
+ body: [
381
+ {
382
+ type: 'ExpressionStatement',
383
+ expression: buildObjectExpression(declarations),
384
+ },
385
+ ],
386
+ },
387
+ },
388
+ };
389
+ kept.push(attr);
390
+ }
391
+ node.attributes = kept;
392
+ }
393
+
394
+ function jsxName(
395
+ nameNode: JSXIdentifier | JSXMemberExpression | JSXNamespacedName,
396
+ ): string {
397
+ if (nameNode.type === 'JSXIdentifier') return nameNode.name;
398
+ if (nameNode.type === 'JSXNamespacedName') {
399
+ return `${nameNode.namespace.name}:${nameNode.name.name}`;
400
+ }
401
+ return jsxName(nameNode.object) + '.' + jsxName(nameNode.property);
402
+ }
403
+
404
+ /** Rewrite `style="…"` on a single estree JSX opening element. */
405
+ function rewriteJsxOpening(opening: JSXOpeningElement): void {
406
+ if (!isIntrinsic(jsxName(opening.name))) return;
407
+ const kept: typeof opening.attributes = [];
408
+ for (const attr of opening.attributes) {
409
+ // Only a bare string literal on a `style` attribute — `style={{…}}` is a
410
+ // JSXExpressionContainer and is already correct.
411
+ if (
412
+ attr.type !== 'JSXAttribute' ||
413
+ attr.name.type !== 'JSXIdentifier' ||
414
+ attr.name.name !== 'style' ||
415
+ !attr.value ||
416
+ attr.value.type !== 'Literal' ||
417
+ typeof attr.value.value !== 'string'
418
+ ) {
419
+ kept.push(attr);
420
+ continue;
421
+ }
422
+ const declarations = splitDeclarations(attr.value.value);
423
+ if (!declarations.length) continue; // drop, per the note on rewriteMdastAttrs
424
+ attr.value = {
425
+ type: 'JSXExpressionContainer',
426
+ expression: buildObjectExpression(declarations),
427
+ };
428
+ kept.push(attr);
429
+ }
430
+ opening.attributes = kept;
431
+ }
432
+
433
+ function rewriteEstreeProgram(estree: Program | null | undefined): void {
434
+ if (!estree) return;
435
+ visitEstree(estree, (node) => {
436
+ if (node.type === 'JSXElement') {
437
+ rewriteJsxOpening(node.openingElement);
438
+ }
439
+ });
440
+ }
441
+
442
+ export function remarkStyleStringToObject() {
443
+ return (tree: Root) => {
444
+ visit(
445
+ tree,
446
+ [
447
+ 'mdxJsxFlowElement',
448
+ 'mdxJsxTextElement',
449
+ 'mdxFlowExpression',
450
+ 'mdxTextExpression',
451
+ 'mdxjsEsm',
452
+ ],
453
+ (node) => {
454
+ if (node.type === 'mdxJsxFlowElement' || node.type === 'mdxJsxTextElement') {
455
+ const jsxNode = node as MdxJsxFlowElement | MdxJsxTextElement;
456
+ rewriteMdastAttrs(jsxNode);
457
+ // Also walk estree subtrees inside attribute value expressions —
458
+ // `<Card icon={<svg style="…"/>}>`. Runs regardless of whether the
459
+ // OUTER element was intrinsic: the inner element is judged on its own.
460
+ for (const attr of jsxNode.attributes) {
461
+ if (attr.type !== 'mdxJsxAttribute') continue;
462
+ const value = attr.value;
463
+ if (
464
+ value &&
465
+ typeof value === 'object' &&
466
+ value.type === 'mdxJsxAttributeValueExpression'
467
+ ) {
468
+ rewriteEstreeProgram(value.data?.estree);
469
+ }
470
+ }
471
+ } else {
472
+ // mdxFlowExpression / mdxTextExpression — body-level `{…}` blocks.
473
+ // mdxjsEsm — `export const Icon = () => <svg style="…"/>`, whose JSX
474
+ // exists only in estree.
475
+ rewriteEstreeProgram(
476
+ (node as MdxFlowExpression | MdxTextExpression | MdxjsEsm).data?.estree,
477
+ );
478
+ }
479
+ },
480
+ );
481
+ };
482
+ }
@@ -40,6 +40,7 @@ import { MARKDOWN_BLOCK_STRUCTURE } from '@/components/mdx/markdown-classes';
40
40
  import { rehypeCodeMeta, rehypeRestoreDataTitle } from '@/lib/rehype-code-meta';
41
41
  import { rehypeClassToClassName } from '@/lib/rehype-class-to-classname';
42
42
  import { remarkSvgNamespaceAttrs } from '@/lib/remark-svg-namespace-attrs';
43
+ import { remarkStyleStringToObject } from '@/lib/remark-style-string-to-object';
43
44
  import { rehypeNoZoomToData } from '@/lib/rehype-nozoom-to-data';
44
45
  import { rehypeUnwrapNestedAnchors } from './rehype-unwrap-nested-anchors';
45
46
  import { loadSnippetsAndInlineComponents } from './render-doc-page-parallel-helpers';
@@ -139,6 +140,7 @@ function getCommonMdxOptions(
139
140
  remarkGfm,
140
141
  [remarkVisibility, { audience: 'humans' }],
141
142
  remarkSvgNamespaceAttrs,
143
+ remarkStyleStringToObject,
142
144
  ...getTypographyRemarkPlugins(config),
143
145
  ...getLatexRemarkPlugins(config),
144
146
  ],
@@ -17,6 +17,8 @@ import { extractSnippetImports, normalizeSnippetPath } from './preprocess-mdx';
17
17
  import { filterVisibility } from './visibility-filter';
18
18
  import { injectPromptSources } from './inject-prompt-source';
19
19
  import { mdxSecurityOptions } from './mdx-security-options';
20
+ import { remarkSvgNamespaceAttrs } from './remark-svg-namespace-attrs';
21
+ import { remarkStyleStringToObject } from './remark-style-string-to-object';
20
22
  import {
21
23
  mdxEvalGuardPlugin,
22
24
  guardPropertyKey,
@@ -271,7 +273,17 @@ async function compilePlainMdxSnippet(
271
273
  const { content } = await compileMDX({
272
274
  source,
273
275
  components: builtInComponents,
274
- options: { ...mdxSecurityOptions, parseFrontmatter: true },
276
+ options: {
277
+ ...mdxSecurityOptions,
278
+ parseFrontmatter: true,
279
+ // Both HTML-shaped-attribute repairs, matching every other compile site
280
+ // (pinned by remark-svg-namespace-attrs-coverage.test.ts). A pasted
281
+ // `<iframe style="…">` throws React error #62 at render here just as it
282
+ // does on a page, and a snippet crash takes down every page that includes
283
+ // it. The REHYPE pipeline is still deliberately omitted — see the note
284
+ // above about D2 fences.
285
+ mdxOptions: { remarkPlugins: [remarkSvgNamespaceAttrs, remarkStyleStringToObject] },
286
+ },
275
287
  });
276
288
  const PlainMdxSnippet: AnyComponent = () => content as React.ReactElement;
277
289
  PlainMdxSnippet.displayName = 'PlainMdxSnippet';
@@ -207,17 +207,21 @@ function preprocessFrontmatter(content) {
207
207
  const body = content.slice(frontmatterEnd);
208
208
 
209
209
  const lines = frontmatter.split('\n');
210
+ // Lines belonging to a previous key's multi-line value are VALUE TEXT, not
211
+ // key/value pairs. Mirror of lib/frontmatter-utils.ts.
212
+ const isContinuation = markContinuationLines(lines);
210
213
  const fixedLines = lines.map((line, i) => {
211
214
  if (!line.trim() || line.trim().startsWith('#')) return line;
215
+ if (isContinuation[i]) return line;
212
216
 
213
217
  // Key may contain colon-segments (og:title, twitter:card) — split on the
214
218
  // first colon followed by whitespace/EOL. Mirror of lib/frontmatter-utils.ts.
215
- const match = line.match(/^(\s*)([a-zA-Z_][a-zA-Z0-9_.-]*(?::[a-zA-Z0-9_.-]+)*):\s*(.*)$/);
219
+ const match = line.match(KEY_LINE);
216
220
  if (!match) return line;
217
221
 
218
222
  const [, indent, key, value] = match;
219
223
 
220
- if (!value || value.startsWith('"') || value.startsWith("'") || /^[|>][-+]?$/.test(value)) {
224
+ if (!value || value.startsWith('"') || value.startsWith("'") || isBlockScalar(value)) {
221
225
  return line;
222
226
  }
223
227
 
@@ -240,6 +244,38 @@ function preprocessFrontmatter(content) {
240
244
  return '---\n' + fixedLines.join('\n') + endMatch[0] + body;
241
245
  }
242
246
 
247
+ /** Mirror of KEY_LINE in lib/frontmatter-utils.ts. */
248
+ const KEY_LINE =
249
+ /^(\s*)([a-zA-Z_][a-zA-Z0-9_.-]*(?::[a-zA-Z0-9_.-]+)*):\s*(.*)$/;
250
+
251
+ /** Mirror of isBlockScalar in lib/frontmatter-utils.ts. */
252
+ function isBlockScalar(value) {
253
+ return /^[|>][-+]?\d*$/.test(value.trim());
254
+ }
255
+
256
+ /**
257
+ * Mirror of markContinuationLines in lib/frontmatter-utils.ts. Without it the
258
+ * search index rebuilt a wrapped value's continuation as `key: value` — the
259
+ * added space landed in a customer's indexed description, and where the line
260
+ * was a plain (non-block) wrap it made js-yaml reject the page outright.
261
+ */
262
+ function markContinuationLines(lines) {
263
+ const out = new Array(lines.length).fill(false);
264
+ for (let i = 0; i < lines.length; i++) {
265
+ if (out[i]) continue;
266
+ const match = lines[i].match(KEY_LINE);
267
+ if (!match) continue;
268
+ const [, indent, , value] = match;
269
+ if (!isBlockScalar(value) && !hasFoldedContinuation(lines, i)) continue;
270
+ for (let j = i + 1; j < lines.length; j++) {
271
+ if (!lines[j].trim()) { out[j] = true; continue; }
272
+ if (lines[j].search(/\S/) <= indent.length) break;
273
+ out[j] = true;
274
+ }
275
+ }
276
+ return out;
277
+ }
278
+
243
279
  /** Mirror of hasFoldedContinuation in lib/frontmatter-utils.ts. */
244
280
  function hasFoldedContinuation(lines, i) {
245
281
  for (let j = i + 1; j < lines.length; j++) {