feat(PBI-96/T-1059): add Zod schemas, folder-mapping, slug helpers

- lib/schemas/product-doc.ts: PRODUCT_DOC_FOLDERS/STATUSES + create/update/
  toggle/frontmatter schemas (MAX_PRODUCT_DOC_CONTENT_LEN=100k)
- lib/product-doc-folder.ts: DB UPPER_SNAKE ↔ API lowercase mapper
  (spiegel van lib/task-status.ts)
- lib/product-doc-slug.ts: pure slugify + suggestSlug (dedupe-suffix) +
  ADR-sequence helpers (nextAdrPrefix, parseAdrNumber, suggestAdrSlug)
- lib/schemas/product-doc-frontmatter-defaults.ts: per-folder UI-templates
  voor "Nieuwe doc"-dialog (last_updated weggelaten — server normaliseert
  bij save, zie T-1060)
- __tests__: 37 tests groen (Zod-schemas + slug-helpers); de pre-existing
  worktree-env fail in idea-timeline-merge.test.ts blijft buiten scope

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

View file

@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest'
import {
nextAdrPrefix,
parseAdrNumber,
slugify,
suggestAdrSlug,
suggestSlug,
} from '@/lib/product-doc-slug'
describe('slugify', () => {
it('maakt simpele titels lowercase met koppeltekens', () => {
expect(slugify('Deploy stappen')).toBe('deploy-stappen')
expect(slugify('Hello, World!')).toBe('hello-world')
})
it('stript diakritieken', () => {
expect(slugify('Café écrasé')).toBe('cafe-ecrase')
expect(slugify('Ångström')).toBe('angstrom')
})
it('verwijdert leading/trailing dashes', () => {
expect(slugify(' --- hello --- ')).toBe('hello')
})
it('capt lengte op 80 tekens', () => {
const long = 'a'.repeat(100)
expect(slugify(long).length).toBe(80)
})
it('geeft lege string voor lege/whitespace-only input', () => {
expect(slugify('')).toBe('')
expect(slugify(' ')).toBe('')
expect(slugify('!@#$%')).toBe('')
})
})
describe('suggestSlug', () => {
it('returnt base-slug zonder collision', () => {
expect(suggestSlug('Deploy', [])).toBe('deploy')
})
it('voegt -2 suffix toe bij eerste collision', () => {
expect(suggestSlug('Deploy', ['deploy'])).toBe('deploy-2')
})
it('telt door bij meerdere collisions', () => {
expect(suggestSlug('Deploy', ['deploy', 'deploy-2', 'deploy-3'])).toBe('deploy-4')
})
it('geeft lege string voor lege titel', () => {
expect(suggestSlug('', ['x'])).toBe('')
})
it('respecteert max-len bij toevoegen suffix', () => {
const long80 = 'a'.repeat(80)
const result = suggestSlug(long80, [long80])
expect(result.length).toBeLessThanOrEqual(80)
expect(result.endsWith('-2')).toBe(true)
})
})
describe('nextAdrPrefix', () => {
it('geeft 0001 als er nog geen ADRs zijn', () => {
expect(nextAdrPrefix(null)).toBe('0001')
})
it('telt door op currentMax', () => {
expect(nextAdrPrefix(0)).toBe('0001')
expect(nextAdrPrefix(41)).toBe('0042')
expect(nextAdrPrefix(999)).toBe('1000')
})
it('pad altijd tot minimaal 4 cijfers', () => {
expect(nextAdrPrefix(null)).toMatch(/^\d{4}$/)
expect(nextAdrPrefix(8)).toBe('0009')
})
})
describe('parseAdrNumber', () => {
it('parseert geldig NNNN-prefix', () => {
expect(parseAdrNumber('0001-context')).toBe(1)
expect(parseAdrNumber('0042-some-slug')).toBe(42)
})
it('returns null voor slugs zonder geldig prefix', () => {
expect(parseAdrNumber('context')).toBeNull()
expect(parseAdrNumber('abc-context')).toBeNull()
expect(parseAdrNumber('1-context')).toBeNull()
expect(parseAdrNumber('12345-context')).toBeNull() // 5 cijfers
})
})
describe('suggestAdrSlug', () => {
it('bouwt NNNN-{slug} format', () => {
expect(suggestAdrSlug('Use base-ui not Radix', null)).toBe('0001-use-base-ui-not-radix')
expect(suggestAdrSlug('Use base-ui not Radix', 41)).toBe('0042-use-base-ui-not-radix')
})
it('geeft alleen prefix bij lege titel', () => {
expect(suggestAdrSlug('', null)).toBe('0001')
expect(suggestAdrSlug(' ', 5)).toBe('0006')
})
it('respecteert max-len van 80 tekens', () => {
const longTitle = 'x'.repeat(100)
const slug = suggestAdrSlug(longTitle, null)
expect(slug.length).toBeLessThanOrEqual(80)
expect(slug.startsWith('0001-')).toBe(true)
})
})

View file

@ -0,0 +1,160 @@
import { describe, it, expect } from 'vitest'
import {
PRODUCT_DOC_FOLDERS,
PRODUCT_DOC_STATUSES,
productDocCreateSchema,
productDocFolderToggleSchema,
productDocFrontmatterSchema,
productDocSlugSchema,
productDocUpdateSchema,
} from '@/lib/schemas/product-doc'
const validProductId = 'cmohrysyj0000rd17clnjy4tc'
describe('productDocSlugSchema', () => {
it('accepteert geldige slugs', () => {
expect(productDocSlugSchema.safeParse('deploy').success).toBe(true)
expect(productDocSlugSchema.safeParse('0001-context-decision').success).toBe(true)
expect(productDocSlugSchema.safeParse('a').success).toBe(true)
})
it('weigert hoofdletters, spaties en speciale tekens', () => {
expect(productDocSlugSchema.safeParse('Deploy').success).toBe(false)
expect(productDocSlugSchema.safeParse('deploy stappen').success).toBe(false)
expect(productDocSlugSchema.safeParse('deploy/stappen').success).toBe(false)
})
it('weigert slug die met streepje begint', () => {
expect(productDocSlugSchema.safeParse('-deploy').success).toBe(false)
})
it('weigert slug > 80 tekens', () => {
expect(productDocSlugSchema.safeParse('a'.repeat(81)).success).toBe(false)
expect(productDocSlugSchema.safeParse('a'.repeat(80)).success).toBe(true)
})
})
describe('productDocFrontmatterSchema', () => {
it('accepteert minimaal valide frontmatter', () => {
const r = productDocFrontmatterSchema.safeParse({ title: 'Deploy', status: 'draft' })
expect(r.success).toBe(true)
})
it('weigert ontbrekende title of status', () => {
expect(
productDocFrontmatterSchema.safeParse({ status: 'draft' }).success,
).toBe(false)
expect(
productDocFrontmatterSchema.safeParse({ title: 'Deploy' }).success,
).toBe(false)
})
it('weigert status die niet in de enum zit', () => {
expect(
productDocFrontmatterSchema.safeParse({ title: 'D', status: 'wip' }).success,
).toBe(false)
})
it('accepteert audience als string of array', () => {
expect(
productDocFrontmatterSchema.safeParse({
title: 'D',
status: 'draft',
audience: 'maintainer',
}).success,
).toBe(true)
expect(
productDocFrontmatterSchema.safeParse({
title: 'D',
status: 'draft',
audience: ['maintainer', 'contributor'],
}).success,
).toBe(true)
})
it('weigert oversized title', () => {
expect(
productDocFrontmatterSchema.safeParse({
title: 'x'.repeat(201),
status: 'draft',
}).success,
).toBe(false)
})
})
describe('productDocCreateSchema', () => {
const base = {
product_id: validProductId,
folder: 'runbooks' as const,
slug: 'deploy',
content_md: '---\ntitle: "Deploy"\nstatus: draft\n---\n\nbody',
}
it('accepteert geldige input', () => {
expect(productDocCreateSchema.safeParse(base).success).toBe(true)
})
it('weigert ongeldige folder', () => {
expect(
productDocCreateSchema.safeParse({ ...base, folder: 'wiki' }).success,
).toBe(false)
})
it('weigert ongeldige product_id (geen cuid)', () => {
expect(
productDocCreateSchema.safeParse({ ...base, product_id: 'not-a-cuid' }).success,
).toBe(false)
})
it('weigert leeg of te lang content_md', () => {
expect(productDocCreateSchema.safeParse({ ...base, content_md: '' }).success).toBe(false)
expect(
productDocCreateSchema.safeParse({ ...base, content_md: 'x'.repeat(100_001) }).success,
).toBe(false)
})
})
describe('productDocUpdateSchema', () => {
it('accepteert valide content_md', () => {
expect(
productDocUpdateSchema.safeParse({ content_md: '---\ntitle: "x"\nstatus: draft\n---\n\nbody' })
.success,
).toBe(true)
})
it('weigert leeg content_md', () => {
expect(productDocUpdateSchema.safeParse({ content_md: '' }).success).toBe(false)
})
})
describe('productDocFolderToggleSchema', () => {
it('accepteert valide toggle-input', () => {
expect(
productDocFolderToggleSchema.safeParse({
product_id: validProductId,
folder: 'api',
enabled: false,
}).success,
).toBe(true)
})
it('weigert ontbrekende enabled-vlag', () => {
expect(
productDocFolderToggleSchema.safeParse({
product_id: validProductId,
folder: 'api',
}).success,
).toBe(false)
})
})
describe('PRODUCT_DOC_FOLDERS + STATUSES', () => {
it('bevat exact 8 folders', () => {
expect(PRODUCT_DOC_FOLDERS).toHaveLength(8)
})
it('bevat exact 4 statussen', () => {
expect(PRODUCT_DOC_STATUSES).toHaveLength(4)
})
})

41
lib/product-doc-folder.ts Normal file
View file

@ -0,0 +1,41 @@
// Bidirectionele case-mapper voor de REST API-boundary van ProductDocFolder.
// DB houdt UPPER_SNAKE; API exposeert lowercase. Spiegel van lib/task-status.ts.
import type { ProductDocFolder } from '@prisma/client'
import {
PRODUCT_DOC_FOLDERS,
type ProductDocFolderApi,
} from '@/lib/schemas/product-doc'
const FOLDER_DB_TO_API = {
ADR: 'adr',
ARCHITECTURE: 'architecture',
PATTERNS: 'patterns',
PLANS: 'plans',
RUNBOOKS: 'runbooks',
SPECS: 'specs',
MANUAL: 'manual',
API: 'api',
} as const satisfies Record<ProductDocFolder, ProductDocFolderApi>
const FOLDER_API_TO_DB: Record<string, ProductDocFolder> = {
adr: 'ADR',
architecture: 'ARCHITECTURE',
patterns: 'PATTERNS',
plans: 'PLANS',
runbooks: 'RUNBOOKS',
specs: 'SPECS',
manual: 'MANUAL',
api: 'API',
}
export function productDocFolderToApi(f: ProductDocFolder): ProductDocFolderApi {
return FOLDER_DB_TO_API[f]
}
export function productDocFolderFromApi(s: string): ProductDocFolder | null {
return FOLDER_API_TO_DB[s.toLowerCase()] ?? null
}
export const PRODUCT_DOC_FOLDER_API_VALUES = PRODUCT_DOC_FOLDERS

78
lib/product-doc-slug.ts Normal file
View file

@ -0,0 +1,78 @@
// Slug-helpers voor ProductDoc. Pure functies — geen DB-koppeling.
// Caller (server-action) doet de DB-query om `existing` slugs te leveren.
const MAX_SLUG_LEN = 80
/**
* Genereert een URL-safe slug uit een titel. Diakritieken worden gestript,
* alles buiten [a-z0-9-] wordt vervangen door `-`, en het resultaat wordt
* gecapped op MAX_SLUG_LEN tekens.
*/
export function slugify(input: string): string {
return input
.toLowerCase()
.trim()
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, MAX_SLUG_LEN)
.replace(/-+$/, '')
}
/**
* Suggesteert een unieke slug door, bij conflict, een numeriek suffix
* (`-2`, `-3`, ...) toe te voegen. Returns '' als de titel leeg slugified.
*
* Caller is verantwoordelijk voor het leveren van bestaande slugs binnen
* dezelfde (product_id, folder)-scope.
*/
export function suggestSlug(title: string, existing: readonly string[]): string {
const base = slugify(title)
if (!base) return ''
if (!existing.includes(base)) return base
for (let n = 2; n <= 999; n++) {
const suffix = `-${n}`
const trimmed = base.slice(0, MAX_SLUG_LEN - suffix.length).replace(/-+$/, '')
const candidate = `${trimmed}${suffix}`
if (!existing.includes(candidate)) return candidate
}
throw new Error('Te veel slug-collisions; kies een andere titel')
}
const ADR_PREFIX_RE = /^(\d{4})-/
/**
* Geeft het volgende ADR-nummer als 4-cijferige string (`'0042'`).
* `currentMax` is het hoogste reeds gebruikte ADR-nummer binnen deze
* (product_id, folder='adr')-scope, of `null` als er nog geen ADRs zijn
* (eerste = `'0001'`).
*/
export function nextAdrPrefix(currentMax: number | null): string {
const next = (currentMax ?? 0) + 1
return next.toString().padStart(4, '0')
}
/**
* Parseert het ADR-volgnummer uit een slug die het `NNNN-...` pattern
* volgt. Returns `null` voor slugs zonder geldig prefix.
*/
export function parseAdrNumber(slug: string): number | null {
const m = ADR_PREFIX_RE.exec(slug)
if (!m) return null
const n = parseInt(m[1], 10)
return Number.isFinite(n) ? n : null
}
/**
* Bouwt een ADR-slug `NNNN-{slugified-title}` waarbij `NNNN` volgt op
* `currentMax`. Bij lege titel: alleen het prefix.
*/
export function suggestAdrSlug(title: string, currentMax: number | null): string {
const prefix = nextAdrPrefix(currentMax)
const titleSlug = slugify(title)
if (!titleSlug) return prefix
const maxTitleLen = MAX_SLUG_LEN - prefix.length - 1
return `${prefix}-${titleSlug.slice(0, maxTitleLen).replace(/-+$/, '')}`
}

View file

@ -0,0 +1,70 @@
// Per-folder template-strings voor het "Nieuwe doc"-dialog. UI-only:
// server gebruikt deze niet. `last_updated` wordt bij save door de server
// gezet (zie setProductDocFrontmatterFields), dus laten we het hier weg.
import type { ProductDocFolderApi } from '@/lib/schemas/product-doc'
export interface ProductDocFolderTemplate {
hint: string
template: string
}
function template(body: string): string {
return `---
title: "..."
status: draft
audience: [contributor]
---
${body}`
}
export const PRODUCT_DOC_FOLDER_DEFAULTS: Record<
ProductDocFolderApi,
ProductDocFolderTemplate
> = {
adr: {
hint: 'Context → Beslissing → Gevolgen. Gebruik NNNN-{slug} naamgeving (auto-suggested).',
template: template(
`## Context\n\nWelke situatie of vraag triggerde deze beslissing?\n\n## Beslissing\n\nWat is besloten en waarom?\n\n## Gevolgen\n\nWelke trade-offs neemt het team hiermee?\n`,
),
},
architecture: {
hint: 'Topisch overzicht van een component of subsysteem.',
template: template(
`## Overzicht\n\nWat is het doel van deze component?\n\n## Datastromen\n\nWelke data komt binnen, wat gebeurt ermee?\n\n## Aandachtspunten\n\n- ...\n`,
),
},
patterns: {
hint: 'Herbruikbaar code-pattern met voorbeeld.',
template: template(
`## Wanneer toepassen\n\n## Voorbeeld\n\n\`\`\`ts\n// ...\n\`\`\`\n\n## Valkuilen\n\n- ...\n`,
),
},
plans: {
hint: 'Feature/PBI-plan met scope, breakdown en verificatie.',
template: template(`## Context\n\n## Scope\n\n## Breakdown\n\n## Verificatie\n`),
},
runbooks: {
hint: 'Operationele procedure of incident-flow.',
template: template(
`## Wanneer gebruiken\n\n## Stappen\n\n1. ...\n\n## Verificatie\n\n## Troubleshooting\n`,
),
},
specs: {
hint: 'Functionele specificatie met acceptatiecriteria.',
template: template(
`## Doel\n\n## User flow\n\n## Acceptatiecriteria\n\n- [ ] ...\n`,
),
},
manual: {
hint: 'Onderdeel van de gebruikers- of developer-manual.',
template: template(`## Inleiding\n\n## Stappen\n\n## Veelgestelde vragen\n`),
},
api: {
hint: 'API-endpoint of contract-detail.',
template: template(
`## Endpoint\n\n\`POST /api/...\`\n\n## Request\n\n## Response\n\n## Fouten\n`,
),
},
}

View file

@ -0,0 +1,82 @@
import { z } from 'zod'
// API-laag werkt met lowercase folder-namen en lowercase statuses.
// DB-mapping (UPPER_SNAKE) leeft in `lib/product-doc-folder.ts`.
export const PRODUCT_DOC_FOLDERS = [
'adr',
'architecture',
'patterns',
'plans',
'runbooks',
'specs',
'manual',
'api',
] as const
export const PRODUCT_DOC_STATUSES = [
'draft',
'active',
'deprecated',
'archived',
] as const
export const productDocFolderSchema = z.enum(PRODUCT_DOC_FOLDERS)
export const productDocStatusSchema = z.enum(PRODUCT_DOC_STATUSES)
// Slugs zijn URL-safe: kleine letters, cijfers, koppeltekens; begint met
// letter/cijfer, max 80 tekens (matcht @db.VarChar(80) in schema.prisma).
export const productDocSlugSchema = z
.string()
.regex(
/^[a-z0-9][a-z0-9-]{0,79}$/,
'Slug mag alleen kleine letters, cijfers en koppeltekens bevatten (1-80 tekens, niet starten met streepje)',
)
// Maximum body-grootte gelijk aan `Idea.plan_md` (lib/schemas/idea.ts).
export const MAX_PRODUCT_DOC_CONTENT_LEN = 100_000
// Frontmatter dat in `content_md` als YAML-block staat. `last_updated` is
// optional omdat de server hem bij elke save normaliseert (P2-review-fix);
// `title` en `status` worden bij save naar de gerepliceerde kolommen
// gepushed (zie createProductDocAction in actions/product-docs.ts).
export const productDocFrontmatterSchema = z.object({
title: z
.string()
.min(1, 'Titel is verplicht')
.max(200, 'Maximaal 200 tekens'),
status: productDocStatusSchema,
audience: z.union([z.string(), z.array(z.string())]).optional(),
applies_to: z.union([z.string(), z.array(z.string())]).optional(),
last_updated: z.string().optional(),
})
export const productDocCreateSchema = z.object({
product_id: z.string().cuid('Ongeldig product'),
folder: productDocFolderSchema,
slug: productDocSlugSchema,
content_md: z
.string()
.min(1, 'Inhoud is verplicht')
.max(MAX_PRODUCT_DOC_CONTENT_LEN, `Maximaal ${MAX_PRODUCT_DOC_CONTENT_LEN} tekens`),
})
export const productDocUpdateSchema = z.object({
content_md: z
.string()
.min(1, 'Inhoud is verplicht')
.max(MAX_PRODUCT_DOC_CONTENT_LEN, `Maximaal ${MAX_PRODUCT_DOC_CONTENT_LEN} tekens`),
})
export const productDocFolderToggleSchema = z.object({
product_id: z.string().cuid('Ongeldig product'),
folder: productDocFolderSchema,
enabled: z.boolean(),
})
export type ProductDocFolderApi = z.infer<typeof productDocFolderSchema>
export type ProductDocStatusApi = z.infer<typeof productDocStatusSchema>
export type ProductDocFrontmatter = z.infer<typeof productDocFrontmatterSchema>
export type ProductDocCreateInput = z.infer<typeof productDocCreateSchema>
export type ProductDocUpdateInput = z.infer<typeof productDocUpdateSchema>
export type ProductDocFolderToggleInput = z.infer<typeof productDocFolderToggleSchema>