feat(PBI-96/T-1060): add frontmatter parser + serializer (P2-fix)

- lib/product-doc-parser.ts: parseProductDocMd(md) → {ok, frontmatter, body}
  | {ok:false, errors[]} met line-info bij YAML-fouten. Pattern gespiegeld
  uit lib/idea-plan-parser.ts.
- lib/product-doc-frontmatter.ts: setProductDocFrontmatterFields(md, patch)
  laat de server `last_updated` server-side normaliseren (P2-review-fix
  uit docs/recommendations/product-docs-storage-system-review-2026-05-16).
  Gebruikt yaml.parseDocument om field-ordering best-effort te behouden.
- todayIsoDate() helper voor 'yyyy-mm-dd' string.
- __tests__: 19 nieuwe tests groen — parse-success/fail-paden, en
  expliciete P2-coverage (vervangen + toevoegen last_updated, behoud
  overige velden + body).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-16 11:42:03 +02:00
parent 55781e463a
commit afafbca855
4 changed files with 382 additions and 0 deletions

View file

@ -0,0 +1,108 @@
import { describe, it, expect } from 'vitest'
import { parseProductDocMd } from '@/lib/product-doc-parser'
import {
setProductDocFrontmatterFields,
todayIsoDate,
} from '@/lib/product-doc-frontmatter'
const baseMd = `---
title: "Deploy"
status: draft
audience: maintainer
last_updated: 2020-01-01
---
# Body
inhoud
`
describe('setProductDocFrontmatterFields — P2-coverage', () => {
it('vervangt bestaand last_updated', () => {
const out = setProductDocFrontmatterFields(baseMd, {
last_updated: '2026-05-16',
})
const parsed = parseProductDocMd(out)
expect(parsed.ok).toBe(true)
if (!parsed.ok) return
expect(parsed.frontmatter.last_updated).toBe('2026-05-16')
})
it('voegt last_updated toe als afwezig', () => {
const md = `---
title: "Deploy"
status: draft
---
body
`
const out = setProductDocFrontmatterFields(md, {
last_updated: '2026-05-16',
})
const parsed = parseProductDocMd(out)
expect(parsed.ok).toBe(true)
if (!parsed.ok) return
expect(parsed.frontmatter.last_updated).toBe('2026-05-16')
})
it('behoudt overige frontmatter-velden', () => {
const out = setProductDocFrontmatterFields(baseMd, {
last_updated: '2026-05-16',
})
const parsed = parseProductDocMd(out)
expect(parsed.ok).toBe(true)
if (!parsed.ok) return
expect(parsed.frontmatter.title).toBe('Deploy')
expect(parsed.frontmatter.status).toBe('draft')
expect(parsed.frontmatter.audience).toBe('maintainer')
})
it('behoudt body-inhoud onveranderd', () => {
const out = setProductDocFrontmatterFields(baseMd, {
last_updated: '2026-05-16',
})
expect(out).toContain('# Body')
expect(out).toContain('inhoud')
})
it('kan meerdere velden tegelijk patchen', () => {
const out = setProductDocFrontmatterFields(baseMd, {
last_updated: '2026-05-16',
status: 'active',
})
const parsed = parseProductDocMd(out)
expect(parsed.ok).toBe(true)
if (!parsed.ok) return
expect(parsed.frontmatter.status).toBe('active')
expect(parsed.frontmatter.last_updated).toBe('2026-05-16')
})
it('throwed bij ontbrekende frontmatter', () => {
expect(() =>
setProductDocFrontmatterFields('# alleen body', { last_updated: 'x' }),
).toThrow(/yaml-frontmatter/i)
})
it('throwed bij broken yaml', () => {
const broken = `---
title: "open quote
status: draft
---
body`
expect(() =>
setProductDocFrontmatterFields(broken, { last_updated: 'x' }),
).toThrow()
})
})
describe('todayIsoDate', () => {
it('returnt yyyy-mm-dd format', () => {
expect(todayIsoDate()).toMatch(/^\d{4}-\d{2}-\d{2}$/)
})
it('respecteert de meegegeven Date', () => {
expect(todayIsoDate(new Date('2026-05-16T12:34:56Z'))).toBe('2026-05-16')
})
})

View file

@ -0,0 +1,141 @@
import { describe, it, expect } from 'vitest'
import { parseProductDocMd } from '@/lib/product-doc-parser'
const minimalValid = `---
title: "Deploy stappen"
status: draft
---
# Body
stappen hier
`
describe('parseProductDocMd — succes', () => {
it('parseert minimaal valide doc', () => {
const r = parseProductDocMd(minimalValid)
expect(r.ok).toBe(true)
if (!r.ok) return
expect(r.frontmatter.title).toBe('Deploy stappen')
expect(r.frontmatter.status).toBe('draft')
expect(r.body.startsWith('# Body')).toBe(true)
})
it('accepteert optionele velden (audience, applies_to, last_updated)', () => {
const md = `---
title: "Doc"
status: active
audience: [maintainer, contributor]
applies_to: PBI-96
last_updated: 2026-05-16
---
body
`
const r = parseProductDocMd(md)
expect(r.ok).toBe(true)
if (!r.ok) return
expect(r.frontmatter.audience).toEqual(['maintainer', 'contributor'])
expect(r.frontmatter.applies_to).toBe('PBI-96')
expect(r.frontmatter.last_updated).toBe('2026-05-16')
})
it('accepteert audience als single string', () => {
const md = `---
title: "Doc"
status: draft
audience: maintainer
---
body`
const r = parseProductDocMd(md)
expect(r.ok).toBe(true)
if (!r.ok) return
expect(r.frontmatter.audience).toBe('maintainer')
})
it('trimt leading whitespace van body', () => {
const md = `---
title: "x"
status: draft
---
body
`
const r = parseProductDocMd(md)
expect(r.ok).toBe(true)
if (!r.ok) return
expect(r.body.startsWith('body')).toBe(true)
})
})
describe('parseProductDocMd — fouten', () => {
it('weigert doc zonder frontmatter (regel 1 error)', () => {
const r = parseProductDocMd('# alleen body')
expect(r.ok).toBe(false)
if (r.ok) return
expect(r.errors[0].line).toBe(1)
expect(r.errors[0].message).toMatch(/yaml-frontmatter/i)
})
it('weigert doc zonder afsluitende `---`', () => {
const md = `---
title: "x"
status: draft
body
`
const r = parseProductDocMd(md)
expect(r.ok).toBe(false)
})
it('weigert frontmatter zonder title', () => {
const md = `---
status: draft
---
body`
const r = parseProductDocMd(md)
expect(r.ok).toBe(false)
if (r.ok) return
expect(r.errors.some((e) => e.message.includes('title'))).toBe(true)
})
it('weigert frontmatter zonder status', () => {
const md = `---
title: "x"
---
body`
const r = parseProductDocMd(md)
expect(r.ok).toBe(false)
if (r.ok) return
expect(r.errors.some((e) => e.message.includes('status'))).toBe(true)
})
it('weigert status buiten enum-set', () => {
const md = `---
title: "x"
status: wip
---
body`
const r = parseProductDocMd(md)
expect(r.ok).toBe(false)
})
it('geeft line-info bij bad yaml', () => {
const md = `---
title: "x
status: draft
---
body`
const r = parseProductDocMd(md)
expect(r.ok).toBe(false)
if (r.ok) return
expect(r.errors[0].line).toBeGreaterThan(0)
})
})

View file

@ -0,0 +1,59 @@
// Server-side serializer die individuele frontmatter-velden bijwerkt in
// een al-gevalideerde markdown-doc. P2-review-fix uit
// docs/recommendations/product-docs-storage-system-review-2026-05-16.md
// (last_updated moet door de server worden gezet, niet door de user).
//
// Caller MOET parseProductDocMd al hebben aangeroepen voor pre-validatie
// — deze functie throwed bij parse-fouten.
import { parseDocument } from 'yaml'
const FRONTMATTER_RE =
/^(---\r?\n)([\s\S]*?)(\r?\n---\r?\n?)([\s\S]*)$/
/**
* Mutates de YAML-frontmatter van `md` met de gegeven `patch`-keys
* (bv. `{ last_updated: '2026-05-16' }`) en geeft de nieuwe markdown
* terug. Behoudt body en frontmatter-delimiters; overige velden blijven
* staan (best-effort op ordering en whitespace via yaml-lib).
*/
export function setProductDocFrontmatterFields(
md: string,
patch: Record<string, unknown>,
): string {
const match = md.match(FRONTMATTER_RE)
if (!match) {
throw new Error(
'setProductDocFrontmatterFields: input mist yaml-frontmatter (geen `---` opener gevonden)',
)
}
const [, openMarker, frontmatterRaw, closeMarker, body] = match
const doc = parseDocument(frontmatterRaw)
if (doc.errors.length > 0) {
throw new Error(
`setProductDocFrontmatterFields: yaml parse-error op regel ${
doc.errors[0].linePos?.[0]?.line ?? '?'
}: ${doc.errors[0].message}`,
)
}
for (const [key, value] of Object.entries(patch)) {
doc.set(key, value)
}
// yaml.Document.toString() voegt vaak een trailing newline toe —
// strippen voorkomt dubbele newlines vóór de afsluitende `---`.
const newFrontmatter = doc.toString().replace(/\r?\n$/, '')
return `${openMarker}${newFrontmatter}${closeMarker}${body}`
}
/**
* ISO-date (yyyy-mm-dd) van vandaag handige helper voor de server om
* `last_updated` mee te zetten bij elke save.
*/
export function todayIsoDate(now: Date = new Date()): string {
return now.toISOString().slice(0, 10)
}

74
lib/product-doc-parser.ts Normal file
View file

@ -0,0 +1,74 @@
// Parser voor de product_doc markdown. Format: yaml-frontmatter (gevalideerd
// via productDocFrontmatterSchema) + markdown-body. Synchroon — geen LLM.
//
// Wordt door alle Product Doc server-actions (create/update) gebruikt om
// frontmatter te valideren vóór save. Bij parse-fout: 422 met line-info.
// Pattern gespiegeld uit lib/idea-plan-parser.ts.
import { parse as parseYaml, YAMLParseError } from 'yaml'
import {
productDocFrontmatterSchema,
type ProductDocFrontmatter,
} from '@/lib/schemas/product-doc'
export type ProductDocParseError = {
line?: number
message: string
hint?: string
}
export type ProductDocParseResult =
| { ok: true; frontmatter: ProductDocFrontmatter; body: string }
| { ok: false; errors: ProductDocParseError[] }
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
export function parseProductDocMd(md: string): ProductDocParseResult {
const match = md.match(FRONTMATTER_RE)
if (!match) {
return {
ok: false,
errors: [
{
line: 1,
message:
'Doc mist yaml-frontmatter. Eerste regel moet `---` zijn, gevolgd door de frontmatter en een afsluitende `---`.',
},
],
}
}
const [, frontmatterRaw, body] = match
let parsed: unknown
try {
parsed = parseYaml(frontmatterRaw)
} catch (err) {
if (err instanceof YAMLParseError) {
const yamlLine = err.linePos?.[0]?.line
// +1 voor de openende `---`-regel (frontmatterRaw start op regel 2)
const fileLine = yamlLine != null ? yamlLine + 1 : undefined
return {
ok: false,
errors: [{ line: fileLine, message: err.message }],
}
}
return {
ok: false,
errors: [{ message: err instanceof Error ? err.message : String(err) }],
}
}
const validation = productDocFrontmatterSchema.safeParse(parsed)
if (!validation.success) {
return {
ok: false,
errors: validation.error.issues.map((iss) => ({
message: `${iss.path.join('.') || '<root>'}: ${iss.message}`,
})),
}
}
return { ok: true, frontmatter: validation.data, body: body.trimStart() }
}