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:
parent
55781e463a
commit
afafbca855
4 changed files with 382 additions and 0 deletions
108
__tests__/lib/product-doc-frontmatter.test.ts
Normal file
108
__tests__/lib/product-doc-frontmatter.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
141
__tests__/lib/product-doc-parser.test.ts
Normal file
141
__tests__/lib/product-doc-parser.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
59
lib/product-doc-frontmatter.ts
Normal file
59
lib/product-doc-frontmatter.ts
Normal 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
74
lib/product-doc-parser.ts
Normal 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() }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue