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
114 changes: 114 additions & 0 deletions src/lib/ingest-sanitize.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,117 @@ 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("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\./)
})
})
72 changes: 72 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,68 @@ 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 the frontmatter, collapse embedded newlines in
* string values to a single space, then re-serialise with
* `lineWidth: -1` so js-yaml never re-introduces block scalars.
*
* 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 (!/^\s*[\w-]+\s*:\s*[>|][-+]?\s*$/m.test(payload)) return content

let parsed: unknown
try {
parsed = yaml.load(payload, { schema: yaml.JSON_SCHEMA })
} catch {
// Malformed YAML — leave it for the existing repair steps.
return content
}

if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return content

// Collapse embedded newlines in scalar values so yaml.dump never
// re-introduces block scalars (which only appear when values contain \n).
const normalized: Record<string, unknown> = {}
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
if (typeof v === "string") {
normalized[k] = v.replace(/\s*\n\s*/g, " ").trim()
} else if (Array.isArray(v)) {
normalized[k] = v.map((item) =>
typeof item === "string" ? item.replace(/\s*\n\s*/g, " ").trim() : item,
)
} else {
normalized[k] = v
}
}

// Re-serialise. lineWidth: -1 disables line folding so long strings
// stay on one line instead of becoming block scalars.
const newPayload = yaml
.dump(normalized, { lineWidth: -1, noRefs: true })
.trimEnd() // yaml.dump always appends a trailing \n

// Reconstruct with the exact original fences, including CRLF and whitespace.
return m[1] + newPayload + m[3] + content.slice(m[0].length)
}
Loading