Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions src/lib/ingest-sanitize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,146 @@ describe("sanitizeIngestedFileContent", () => {
)
})
})

describe("normalizeBlockScalarsInFrontmatter (via sanitizeIngestedFileContent)", () => {
it("normalises a folded block scalar (>-) to a plain inline string", () => {
const input = [
"---",
"type: entity",
"description: >-",
" A long description",
" that the LLM folded.",
"---",
"",
"# Body",
].join("\n")
const out = sanitizeIngestedFileContent(input)
expect(out).toMatch(/^---\n/)
expect(out).toMatch(/description: A long description that the LLM folded\./)
// Block scalar indicator must be gone
expect(out).not.toMatch(/description: >-/)
expect(out).toMatch(/---\n\n# Body$/)
})

it("normalises a literal block scalar (|-) collapsing embedded newlines to spaces", () => {
const input = [
"---",
"type: entity",
"description: |-",
" Line one.",
" Line two.",
"---",
"",
"# Body",
].join("\n")
const out = sanitizeIngestedFileContent(input)
expect(out).not.toMatch(/\|-/)
// Both lines collapsed to a single space-separated string
expect(out).toMatch(/description:.*Line one\..*Line two\./)
})

it("normalises a folded block scalar without chomping (>)", () => {
const input = "---\ntitle: >\n Folded Title\n---\n\n# Body"
const out = sanitizeIngestedFileContent(input)
expect(out).not.toMatch(/title: >/)
expect(out).toMatch(/title:.*Folded Title/)
})


it("preserves CRLF and whitespace-padded frontmatter fences", () => {
const input = [
"--- ",
"type: entity",
"description: >-",
" A Windows-line-ending description.",
"--- ",
"",
"# Body",
].join("\r\n")
const out = sanitizeIngestedFileContent(input)
expect(out).toMatch(/^--- \r\n/)
expect(out).toContain("description: A Windows-line-ending description.")
expect(out).toMatch(/\r\n--- \r\n\r\n# Body$/)
})

it("changes only the block scalar while preserving surrounding YAML formatting", () => {
const input = [
"--- ",
"# keep this comment",
"type: entity",
"title: 'Quoted: title'",
"description: >-",
" A folded description",
" with two lines.",
"tags: [foo, \"bar\"] # keep inline comment",
"--- ",
"",
"# Body",
].join("\r\n")
const expected = [
"--- ",
"# keep this comment",
"type: entity",
"title: 'Quoted: title'",
"description: A folded description with two lines.",
"tags: [foo, \"bar\"] # keep inline comment",
"--- ",
"",
"# Body",
].join("\r\n")

expect(sanitizeIngestedFileContent(input)).toBe(expected)
})

it("is a no-op when frontmatter has no block scalar indicators", () => {
const input = "---\ntype: entity\ntitle: Short\ndescription: A plain description.\n---\n\n# Body"
expect(sanitizeIngestedFileContent(input)).toBe(input)
})

it("is a no-op when there is no frontmatter", () => {
const input = "# Just a heading\n\nsome body text"
expect(sanitizeIngestedFileContent(input)).toBe(input)
})

it("preserves non-description fields when normalising a block scalar", () => {
const input = [
"---",
"type: entity",
"title: My Title",
"description: >-",
" Block scalar value.",
"tags:",
" - foo",
" - bar",
"---",
"",
"# Body",
].join("\n")
const out = sanitizeIngestedFileContent(input)
expect(out).toMatch(/type: entity/)
expect(out).toMatch(/title: My Title/)
expect(out).toMatch(/description:.*Block scalar value\./)
// Tags array must survive
expect(out).toMatch(/foo/)
expect(out).toMatch(/bar/)
expect(out).not.toMatch(/>-/)
})

it("composes block scalar normalisation with code-fence stripping", () => {
const input = [
"```yaml",
"---",
"type: entity",
"description: >-",
" Fenced and folded.",
"---",
"",
"# Body",
"```",
].join("\n")
const out = sanitizeIngestedFileContent(input)
expect(out).not.toMatch(/```/)
expect(out).not.toMatch(/>-/)
expect(out).toMatch(/description:.*Fenced and folded\./)
})
})
76 changes: 76 additions & 0 deletions src/lib/ingest-sanitize.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import yaml from "js-yaml"

/**
* Clean up an LLM-generated wiki page body before it hits disk.
*
Expand Down Expand Up @@ -85,6 +87,11 @@ export function sanitizeIngestedFileContent(content: string): string {
// link transform applied at read time.
cleaned = repairWikilinkListsInFrontmatter(cleaned)

// (4) Normalise YAML block scalars (>-, |-, etc.) to plain inline
// values. Runs after the wikilink repair so that step's quoted
// values are already in place when we re-serialise.
cleaned = normalizeBlockScalarsInFrontmatter(cleaned)

return cleaned
}

Expand Down Expand Up @@ -179,3 +186,72 @@ function repairWikilinkListsInFrontmatter(content: string): string {
// corrupt both the opening fence and the payload boundary.
return m[1] + repairedPayload + m[4] + content.slice(m[0].length)
}

/**
* Normalise YAML block scalars in frontmatter to plain inline values.
*
* LLMs sometimes emit folded (`>-`) or literal (`|-`) block scalars
* for string fields, e.g.:
*
* description: >-
* A long description that the model
* decided to fold across lines.
*
* This is valid YAML but is not supported by LLM Wiki's frontmatter
* renderer and causes display/parse issues in downstream tooling.
* The fix: parse each top-level block scalar in isolation, collapse
* embedded newlines to a single space, and replace only that scalar.
* Unrelated YAML formatting, comments, quoting, and key order remain
* byte-for-byte unchanged.
*
* The function is a no-op when the frontmatter contains no block
* scalar indicators, so it adds negligible overhead to clean pages.
*/
function normalizeBlockScalarsInFrontmatter(content: string): string {
const fmRe = /^(---[ \t]*\r?\n)([\s\S]*?)(\r?\n---[ \t]*(?:\r?\n|$))/
const m = content.match(fmRe)
if (!m) return content

const payload = m[2]
// Fast exit: no block scalar indicator on any value line.
// A block scalar always appears as the sole value after `key: `, e.g.
// `description: >-` with nothing else on that line.
if (!/^[\w-]+\s*:\s*[>|](?:[-+]?[1-9]?|[1-9]?[-+]?)\s*(?:#.*)?$/m.test(payload)) {
return content
}

const lineEnding = m[1].endsWith("\r\n") ? "\r\n" : "\n"
const lines = payload.split(/\r?\n/)
const indicator =
/^([\w-]+)(\s*:\s*)[>|](?:[-+]?[1-9]?|[1-9]?[-+]?)\s*(#.*)?$/

for (let index = 0; index < lines.length; index += 1) {
const match = lines[index].match(indicator)
if (!match) continue

let end = index + 1
while (end < lines.length && (lines[end].trim() === "" || /^\s/.test(lines[end]))) {
end += 1
}

let parsed: unknown
try {
parsed = yaml.load(lines.slice(index, end).join(lineEnding), {
schema: yaml.JSON_SCHEMA,
})
} catch {
continue
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue

const value = (parsed as Record<string, unknown>)[match[1]]
if (typeof value !== "string") continue
const normalized = value.replace(/\s*\n\s*/g, " ").trim()
const inlineValue = yaml.dump(normalized, { lineWidth: -1 }).trimEnd()
const comment = match[3] ? ` ${match[3]}` : ""
lines.splice(index, end - index, `${match[1]}${match[2]}${inlineValue}${comment}`)
}

const newPayload = lines.join(lineEnding)
return m[1] + newPayload + m[3] + content.slice(m[0].length)
}
Loading