1import { readFile, readdir } from 'node:fs/promises'2import { execFileSync } from 'node:child_process'3import { basename, join, relative, resolve } from 'node:path'4import { fileURLToPath } from 'node:url'5import { diagramTypeIds, diagramTypes } from '../data/diagram-types.mjs'6import { diagramSkillIds, diagramSkills, skillGroups, standardsSupport } from '../../../skills/catalog.mjs'78const site = join(fileURLToPath(new URL('..', import.meta.url)))9const docs = join(site, 'docs')10const repo = join(site, '..', '..')11const publicFiles = [join(site, 'static', 'llms.txt'), join(site, 'static', 'llms-full.txt'), join(site, 'static', 'diagram-types.json'), join(site, 'static', 'diagram-skills.json')]12const examples = join(site, 'static', 'examples')13const skills = join(repo, 'skills')14const publicSkills = join(site, 'static', 'skills')15const errors = []16const source = (path) => readFile(path, 'utf8')1718function assert(condition, message) { if (!condition) errors.push(message) }19function idsFromNames(names) { return names.filter((name) => name.endsWith('.md')).map((name) => name.slice(0, -3)).sort() }20async function filesBelow(directory) {21 const entries = await readdir(directory, { withFileTypes: true })22 const nested = await Promise.all(entries.map((entry) => {23 const path = join(directory, entry.name)24 return entry.isDirectory() ? filesBelow(path) : [path]25 }))26 return nested.flat()27}2829function skillFrontmatter(content) {30 const raw = content.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1]31 if (!raw) return undefined32 const value = (key) => {33 const rawValue = raw.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1]?.trim()34 return rawValue?.replace(/^(?:"([\s\S]*)"|'([\s\S]*)')$/, '$1$2')35 }36 return { raw, name: value('name'), description: value('description'), disableModelInvocation: value('disable-model-invocation') }37}3839function relativeMarkdownLinks(content) {40 return [...content.matchAll(/\[[^\]]*\]\(([^)]+\.md(?:#[^)]*)?)\)/g)]41 .map((match) => match[1].split('#')[0])42 .filter((target) => !/^[a-z]+:/i.test(target) && !target.startsWith('/'))43}4445function proseBlocks(markdown) {46 const blocks = []47 let paragraph = []48 let inCode = false49 let inFrontmatter = markdown.startsWith('---\n')50 const flush = () => {51 if (paragraph.length) blocks.push(paragraph.join(' '))52 paragraph = []53 }54 for (const sourceLine of markdown.split('\n')) {55 const line = sourceLine.trim()56 if (inFrontmatter) {57 if (line === '---' && blocks.frontmatterStarted) inFrontmatter = false58 blocks.frontmatterStarted = true59 continue60 }61 if (line.startsWith('```')) { flush(); inCode = !inCode; continue }62 if (inCode || line.startsWith('|') || line.startsWith('import ') || line.startsWith('<')) continue63 if (!line || line.startsWith('#')) { flush(); continue }64 if (/^(?:[-*]|\d+\.)\s/.test(line)) { flush(); blocks.push(line.replace(/^(?:[-*]|\d+\.)\s+/, '')); continue }65 paragraph.push(line)66 }67 flush()68 delete blocks.frontmatterStarted69 return blocks70}7172function plainText(value) {73 return value74 .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')75 .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')76 .replace(/`[^`]+`/g, ' technical-value ')77 .replace(/[>*_{}]/g, ' ')78}7980function checkProse(content, name) {81 const contraction = /\b(?:can't|couldn't|doesn't|don't|isn't|it's|won't|you'll|you're|we're|they're|wasn't|weren't|shouldn't|mustn't)\b/i82 for (const block of proseBlocks(content)) {83 const text = plainText(block)84 assert(!contraction.test(text), `Contraction found in ${name}: ${block}`)85 for (const sentence of text.split(/(?<=[.!?])\s+/)) {86 const words = sentence.match(/[A-Za-z0-9][A-Za-z0-9.-]*/g) ?? []87 assert(words.length <= 25, `Sentence has ${words.length} words in ${name}: ${sentence}`)88 }89 }90}9192function sourceIds(text) {93 const ids = [...text.matchAll(/['`]([a-z][a-z0-9]*)['`]/gi)].map((match) => match[1].toLowerCase())94 return [...new Set(ids.filter((id) => expectedSet.has(id)))].sort()95}9697const expectedSet = new Set(diagramTypeIds)98const editorSource = await source(join(repo, 'apps', 'editor', 'src', 'diagram-types.js'))99const rendererSource = await source(join(repo, 'renderers', 'shared', 'engines.ts'))100assert(JSON.stringify(sourceIds(editorSource)) === JSON.stringify([...expectedSet].sort()), 'Editor diagram type IDs do not match the documentation catalog.')101assert(JSON.stringify(sourceIds(rendererSource)) === JSON.stringify([...expectedSet].sort()), 'Renderer diagram type IDs do not match the documentation catalog.')102103const createNames = await readdir(join(docs, 'create', 'types'))104const styleNames = await readdir(join(docs, 'style', 'types'))105const exampleNames = await readdir(examples)106const expected = [...diagramTypeIds].sort()107assert(createNames.length === expected.length, `Expected ${expected.length} creation pages, found ${createNames.length}.`)108assert(styleNames.length === expected.length, `Expected ${expected.length} style pages, found ${styleNames.length}.`)109assert(JSON.stringify(idsFromNames(createNames)) === JSON.stringify(expected), 'Creation page IDs do not match the diagram type catalog.')110assert(JSON.stringify(idsFromNames(styleNames)) === JSON.stringify(expected), 'Style page IDs do not match the diagram type catalog.')111assert(exampleNames.length === expected.length, `Expected ${expected.length} rendered examples, found ${exampleNames.length}.`)112assert(JSON.stringify(exampleNames.filter((name) => name.endsWith('.json')).map((name) => name.slice(0, -5)).sort()) === JSON.stringify(expected), 'Rendered example IDs do not match the diagram type catalog.')113114const catalogRaw = await source(join(site, 'static', 'diagram-types.json'))115let catalog116try { catalog = JSON.parse(catalogRaw) } catch { errors.push('static/diagram-types.json is not valid JSON.') }117assert(catalog?.diagramTypes?.length === expected.length, 'Machine catalog does not contain all diagram types.')118assert(JSON.stringify((catalog?.diagramTypes ?? []).map(({ id }) => id).sort()) === JSON.stringify(expected), 'Machine catalog IDs do not match the diagram type catalog.')119const trnCatalog = (catalog?.diagramTypes ?? []).find(({ id }) => id === 'trn')120assert(Array.isArray(trnCatalog?.authoringContract), 'Machine catalog does not expose the TRN public authoring contract.')121assert(trnCatalog?.authoringContract?.some((item) => item.includes('Do not edit generated SVG')), 'Machine catalog does not protect the TRN output boundary.')122123const llms = await source(join(site, 'static', 'llms.txt'))124const llmsFull = await source(join(site, 'static', 'llms-full.txt'))125assert(llms.includes('https://docs.diagram.zip/create/'), 'llms.txt does not link the editor workspace guide.')126assert(llms.includes('https://docs.diagram.zip/style/presentation/'), 'llms.txt does not link the Details presentation guide.')127assert(llms.includes('TRN stands for Tabular Recipe Notation'), 'llms.txt does not expand TRN.')128assert(llms.includes('Michael Chu at Cooking for Engineers'), 'llms.txt does not credit the origin of TRN.')129assert(llms.includes('Generated SVG and renderer internals are output, not authoring controls.'), 'llms.txt does not protect the public authoring boundary.')130assert(llmsFull.includes('# General presentation settings'), 'llms-full.txt does not include the presentation guide.')131assert(llmsFull.includes('# Working state and published state'), 'llms-full.txt does not include the working-state guide.')132assert(llmsFull.includes('TRN originates with [Michael Chu]'), 'llms-full.txt does not credit the origin of TRN.')133assert(llmsFull.includes('## Public authoring contract'), 'llms-full.txt does not include the public authoring contract.')134assert(llmsFull.includes('Presentation appearance changes palette and canvas treatment.'), 'llms-full.txt does not separate TRN layout from appearance.')135assert(llmsFull.includes('Do not edit generated SVG, CSS classes, data attributes, viewBox values, or renderer implementation files.'), 'llms-full.txt does not protect the TRN output boundary.')136assert(llmsFull.includes('`data-dz-appearance` value selects raw, light, dark, or automatic palette rules.'), 'llms-full.txt does not explain packed SVG palette selection.')137assert(llmsFull.includes('CSS cannot change SVG bounds.'), 'llms-full.txt does not preserve the packed SVG geometry boundary.')138139const diagrammingSkill = await source(join(site, 'static', 'skills', 'diagramming', 'SKILL.md'))140assert(diagrammingSkill.includes('synchronizes that single'), 'Diagramming skill does not explain packed SVG appearance synchronization.')141assert(diagrammingSkill.includes('report a mismatch as a contract violation'), 'Diagramming skill does not tell agents how to handle a packed SVG appearance mismatch.')142143const expectedSkillIds = [...diagramSkillIds].sort()144const skillEntries = await readdir(skills, { withFileTypes: true })145const actualSkillIds = skillEntries.filter((entry) => entry.isDirectory()).map(({ name }) => name).sort()146const publicSkillIds = (await readdir(publicSkills, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map(({ name }) => name).sort()147assert(diagramSkillIds.length === 39, `Expected 39 semantic skills, found ${diagramSkillIds.length} in the catalog.`)148assert(new Set(diagramSkillIds).size === diagramSkillIds.length, 'Semantic skill catalog contains duplicate IDs.')149assert(JSON.stringify(actualSkillIds) === JSON.stringify(expectedSkillIds), 'Source skill directories do not match the semantic skill catalog.')150assert(JSON.stringify(publicSkillIds) === JSON.stringify(expectedSkillIds), 'Published skill directories do not match the semantic skill catalog.')151152const skillCatalogRaw = await source(join(site, 'static', 'diagram-skills.json'))153let skillCatalog154try { skillCatalog = JSON.parse(skillCatalogRaw) } catch { errors.push('static/diagram-skills.json is not valid JSON.') }155assert(skillCatalog?.version === 1, 'Semantic skill catalog has the wrong version.')156assert(JSON.stringify(skillCatalog?.groups ?? []) === JSON.stringify(skillGroups), 'Published semantic skill groups do not match the source catalog.')157assert(JSON.stringify((skillCatalog?.skills ?? []).map(({ id }) => id).sort()) === JSON.stringify(expectedSkillIds), 'Published semantic skill IDs do not match the source catalog.')158assert(JSON.stringify(skillCatalog?.standards ?? []) === JSON.stringify(standardsSupport), 'Published standards support data does not match the source catalog.')159for (const standard of standardsSupport) {160 assert(standard.relatedSkills.every((id) => diagramSkillIds.includes(id)), `Standard ${standard.id} references an unknown semantic skill.`)161 assert(['source-supported', 'visual-subset', 'visual-conventions', 'unsupported'].includes(standard.status), `Standard ${standard.id} has an unknown support status.`)162}163164for (const entry of diagramSkills) {165 const skillFile = join(skills, entry.id, 'SKILL.md')166 const content = await source(skillFile)167 const metadata = skillFrontmatter(content)168 assert(metadata, `Skill ${entry.id} has no valid frontmatter.`)169 assert(metadata?.name === entry.id, `Skill ${entry.id} frontmatter name does not match its directory.`)170 assert(Boolean(metadata?.description), `Skill ${entry.id} has no description.`)171 assert((metadata?.description?.length ?? 0) <= 1024, `Skill ${entry.id} description exceeds 1024 characters.`)172 assert(content.split('\n').length <= 500, `Skill ${entry.id} exceeds 500 lines.`)173 assert(metadata?.disableModelInvocation !== 'false', `Skill ${entry.id} must omit disable-model-invocation instead of setting it false.`)174 assert((metadata?.disableModelInvocation === 'true') === (entry.id === 'diagram-workshop'), `Skill ${entry.id} has the wrong invocation policy.`)175 assert(!/kroki/i.test(content), `Forbidden project name found in skills/${entry.id}/SKILL.md.`)176177 const referenceDir = join(skills, entry.id, 'references')178 const packageFiles = await filesBelow(join(skills, entry.id))179 const referenceFiles = packageFiles.filter((path) => path.startsWith(`${referenceDir}/`) && path.endsWith('.md'))180 for (const path of referenceFiles) {181 assert(content.includes(relative(join(skills, entry.id), path)), `Skill ${entry.id} does not link reference ${relative(referenceDir, path)}.`)182 }183 for (const target of relativeMarkdownLinks(content)) {184 const resolved = resolve(join(skills, entry.id), target)185 assert(packageFiles.includes(resolved), `Skill ${entry.id} links missing file ${target}.`)186 }187 for (const path of packageFiles) {188 const packageContent = await source(path)189 assert(!/kroki/i.test(packageContent), `Forbidden project name found in ${relative(repo, path)}.`)190 }191192 const publishedFiles = (await filesBelow(join(publicSkills, entry.id))).map((path) => relative(join(publicSkills, entry.id), path)).sort()193 const sourceFiles = packageFiles.map((path) => relative(join(skills, entry.id), path)).sort()194 assert(JSON.stringify(publishedFiles) === JSON.stringify(sourceFiles), `Published package ${entry.id} does not match its source files.`)195}196197const workshopOpenAI = await source(join(skills, 'diagram-workshop', 'agents', 'openai.yaml'))198assert(/allow_implicit_invocation:\s*false/.test(workshopOpenAI), 'Diagram workshop must disable implicit OpenAI invocation.')199const publishedDiagramming = await source(join(publicSkills, 'diagramming', 'SKILL.md'))200assert(publishedDiagramming.includes('Use Tabular Recipe Notation (`trn`) for recipes'), 'Published diagramming skill does not expand or route TRN.')201assert(publishedDiagramming.includes('Michael Chu at Cooking for Engineers'), 'Published diagramming skill does not credit the origin of TRN.')202203for (const id of expected) {204 const type = diagramTypes.find((item) => item.id === id)205 const create = await source(join(docs, 'create', 'types', `${id}.md`))206 const style = await source(join(docs, 'style', 'types', `${id}.md`))207 assert(/^---\n(?:.*\n)*?id: create-/.test(create), `Creation page ${id} has invalid frontmatter.`)208 assert(/^---\n(?:.*\n)*?id: style-/.test(style), `Style page ${id} has invalid frontmatter.`)209 assert(create.includes(`/style/types/${id}`), `Creation page ${id} has no paired style link.`)210 assert(create.includes(`<DiagramExample engine="${id}"`), `Creation page ${id} has no rendered example.`)211 assert(style.includes(`/create/types/${id}`), `Style page ${id} has no paired creation link.`)212 assert(style.includes('## Source controls'), `Style page ${id} has no source controls.`)213 assert(style.includes('/style/svg-normalization'), `Style page ${id} has no SVG normalization link.`)214 assert(style.includes('## Renderer options') === Boolean(type.rendererOptions?.length), `Style page ${id} has the wrong renderer options section.`)215 const renderedExample = JSON.parse(await source(join(examples, `${id}.json`)))216 assert(renderedExample.engine === id, `Rendered example ${id} has the wrong engine.`)217 assert(typeof renderedExample.source === 'string' && renderedExample.source.trim(), `Rendered example ${id} has no source.`)218 assert(!/kroki/i.test(renderedExample.source), `Forbidden project name found in rendered example ${id}.`)219 checkProse(create, `create/types/${id}.md`)220 checkProse(style, `style/types/${id}.md`)221}222223const documentationFiles = (await filesBelow(docs)).filter((path) => /\.mdx?$/.test(path))224for (const path of [...documentationFiles, ...publicFiles]) {225 const content = await source(path)226 assert(!/kroki/i.test(content), `Forbidden project name found in ${relative(site, path)}.`)227 if (/\.mdx?$/.test(path)) checkProse(content, relative(docs, path))228}229230const trackedFiles = execFileSync('git', ['ls-files', '-z'], { cwd: repo, encoding: 'utf8' })231 .split('\0')232 .filter(Boolean)233const firstPartyTextFiles = trackedFiles234 .filter((path) => /\.(?:cjs|js|json|jsonc|md|mdx|mjs|py|toml|ts|tsx|yaml|yml)$/.test(path))235 .filter((path) => !path.startsWith('vendor/') && !path.includes('/vendor/'))236const legacyPath = /(?:ci\/tests\/diagrams|diagramzip-(?:api|client-units|docs|python-units|render|shell|svg)\/|diagrams\.net\/assets|diagramzip\/(?:PERSISTENCE\.md|dist\/|src\/|test\/))/237const segmentedLegacyCorpus = /(?:['"]ci['"]\s*,\s*['"]tests['"]\s*,\s*['"]diagrams['"]|['"]ci['"]\s*\/\s*['"]tests['"]\s*\/\s*['"]diagrams['"])/238for (const path of firstPartyTextFiles) {239 const content = await source(join(repo, path))240 assert(!legacyPath.test(content) && !segmentedLegacyCorpus.test(content), `Legacy repository path found in ${path}.`)241}242const repositoryProseFiles = trackedFiles243 .filter((path) => basename(path) === 'README.md' || (path.startsWith('docs/') && /\.mdx?$/.test(path)))244 .filter((path) => !path.startsWith('vendor/') && !path.includes('/vendor/'))245for (const path of repositoryProseFiles) {246 checkProse(await source(join(repo, path)), path)247}248249if (errors.length) { console.error(errors.map((error) => `ERROR: ${error}`).join('\n')); process.exitCode = 1 } else console.log(`Documentation checks passed for ${expected.length} renderer types, ${expectedSkillIds.length} semantic skills, and ${repositoryProseFiles.length} repository prose files.`)