@millstone/synapse-site 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +194 -0
- package/decap/build-config.ts +435 -0
- package/decap/build-vault-index.ts +178 -0
- package/decap/validate-browser.ts +638 -0
- package/package.json +67 -0
- package/plugins/CustomHeaderFooter.ts +387 -0
- package/quartz.config.ts +107 -0
- package/quartz.layout.ts +124 -0
- package/setup-quartz.ts +151 -0
- package/static/edit/body-grammars/index.json +1532 -0
- package/static/edit/config.yml +1393 -0
- package/static/edit/index.html +402 -0
- package/static/edit/schemas/index.json +1477 -0
- package/static/edit/validate.bundle.js +22720 -0
- package/static/edit/vault-index.json +78 -0
- package/sync-plugins.ts +126 -0
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-compatible validation for Decap CMS
|
|
3
|
+
*
|
|
4
|
+
* This module provides client-side validation that mirrors the server-side
|
|
5
|
+
* `synapse validate` command, running entirely in the browser.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import Ajv from 'ajv';
|
|
9
|
+
import addFormats from 'ajv-formats';
|
|
10
|
+
import { unified } from 'unified';
|
|
11
|
+
import remarkParse from 'remark-parse';
|
|
12
|
+
import remarkGfm from 'remark-gfm';
|
|
13
|
+
import type { Root, Heading, Content, List, PhrasingContent } from 'mdast';
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Types
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
export const DOC_TYPES = [
|
|
20
|
+
'adr', 'agreement', 'capability', 'meeting', 'policy', 'prd',
|
|
21
|
+
'process', 'reference', 'runbook', 'scorecard', 'sop', 'sow',
|
|
22
|
+
'standard', 'system', 'tdd'
|
|
23
|
+
] as const;
|
|
24
|
+
|
|
25
|
+
export type DocType = typeof DOC_TYPES[number];
|
|
26
|
+
|
|
27
|
+
export function isDocType(value: string): value is DocType {
|
|
28
|
+
return DOC_TYPES.includes(value as DocType);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ValidationIssue {
|
|
32
|
+
type: 'error' | 'warning';
|
|
33
|
+
code: string;
|
|
34
|
+
message: string;
|
|
35
|
+
field?: string;
|
|
36
|
+
sectionId?: string;
|
|
37
|
+
line?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ValidationResult {
|
|
41
|
+
success: boolean;
|
|
42
|
+
issues: ValidationIssue[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ValidationContext {
|
|
46
|
+
/** All valid wikilink targets (file basenames without .md) */
|
|
47
|
+
vaultIndex: string[];
|
|
48
|
+
/** JSON schemas keyed by document type */
|
|
49
|
+
schemas: Record<string, any>;
|
|
50
|
+
/** Body grammars keyed by document type */
|
|
51
|
+
bodyGrammars: Record<string, any>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface SectionNode {
|
|
55
|
+
id: string;
|
|
56
|
+
title: string;
|
|
57
|
+
depth: number;
|
|
58
|
+
line: number;
|
|
59
|
+
column: number;
|
|
60
|
+
content: Content[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ============================================================================
|
|
64
|
+
// Schema Validation
|
|
65
|
+
// ============================================================================
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Validates frontmatter against a JSON schema
|
|
69
|
+
*/
|
|
70
|
+
export function validateSchema(
|
|
71
|
+
frontmatter: Record<string, any>,
|
|
72
|
+
schema: any
|
|
73
|
+
): ValidationIssue[] {
|
|
74
|
+
const issues: ValidationIssue[] = [];
|
|
75
|
+
|
|
76
|
+
// Clean schema for Ajv compatibility
|
|
77
|
+
const cleanSchema = { ...schema };
|
|
78
|
+
delete cleanSchema.$schema;
|
|
79
|
+
delete cleanSchema.$id;
|
|
80
|
+
|
|
81
|
+
const ajv = new Ajv({
|
|
82
|
+
allErrors: true,
|
|
83
|
+
strict: false,
|
|
84
|
+
validateSchema: false
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
addFormats(ajv);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const validate = ajv.compile(cleanSchema);
|
|
91
|
+
const valid = validate(frontmatter);
|
|
92
|
+
|
|
93
|
+
if (!valid && validate.errors) {
|
|
94
|
+
for (const error of validate.errors) {
|
|
95
|
+
const path = error.instancePath || '/';
|
|
96
|
+
let message = error.message || 'validation error';
|
|
97
|
+
|
|
98
|
+
if (error.keyword === 'required') {
|
|
99
|
+
const missingProp = (error.params as any).missingProperty;
|
|
100
|
+
message = `missing required property '${missingProp}'`;
|
|
101
|
+
} else if (error.keyword === 'type') {
|
|
102
|
+
const expectedType = (error.params as any).type;
|
|
103
|
+
message = `must be ${expectedType}`;
|
|
104
|
+
} else if (error.keyword === 'const') {
|
|
105
|
+
const expectedValue = (error.params as any).allowedValue;
|
|
106
|
+
message = `must be equal to '${expectedValue}'`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
issues.push({
|
|
110
|
+
type: 'error',
|
|
111
|
+
code: 'SCHEMA_VALIDATION_ERROR',
|
|
112
|
+
message: `${path} ${message}`,
|
|
113
|
+
field: path.replace(/^\//, '') || undefined
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
issues.push({
|
|
119
|
+
type: 'error',
|
|
120
|
+
code: 'SCHEMA_COMPILATION_ERROR',
|
|
121
|
+
message: `Schema error: ${(error as Error).message}`
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return issues;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ============================================================================
|
|
129
|
+
// Markdown Parsing
|
|
130
|
+
// ============================================================================
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Parses markdown into AST
|
|
134
|
+
*/
|
|
135
|
+
function parseMarkdown(body: string): Root {
|
|
136
|
+
const processor = unified().use(remarkParse).use(remarkGfm);
|
|
137
|
+
return processor.parse(body) as Root;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Extracts heading text from AST node
|
|
142
|
+
*/
|
|
143
|
+
function extractHeadingText(heading: Heading): string {
|
|
144
|
+
let text = '';
|
|
145
|
+
for (const child of heading.children) {
|
|
146
|
+
if (child.type === 'text') {
|
|
147
|
+
text += child.value;
|
|
148
|
+
} else if (child.type === 'html') {
|
|
149
|
+
continue;
|
|
150
|
+
} else if ('children' in child) {
|
|
151
|
+
text += extractInlineText(child.children as PhrasingContent[]);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return text.trim();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function extractInlineText(children: PhrasingContent[]): string {
|
|
158
|
+
let text = '';
|
|
159
|
+
for (const child of children) {
|
|
160
|
+
if (child.type === 'text') {
|
|
161
|
+
text += child.value;
|
|
162
|
+
} else if ('children' in child) {
|
|
163
|
+
text += extractInlineText(child.children as PhrasingContent[]);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return text;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Normalizes section title to ID
|
|
171
|
+
*/
|
|
172
|
+
function normalizeSectionId(title: string): string {
|
|
173
|
+
return title
|
|
174
|
+
.toLowerCase()
|
|
175
|
+
.trim()
|
|
176
|
+
.replace(/[^\w\s-]/g, '')
|
|
177
|
+
.replace(/\s+/g, '-')
|
|
178
|
+
.replace(/-+/g, '-')
|
|
179
|
+
.replace(/^-+|-+$/g, '');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Finds all H2 sections in the AST
|
|
184
|
+
*/
|
|
185
|
+
function findSections(ast: Root): SectionNode[] {
|
|
186
|
+
const sections: SectionNode[] = [];
|
|
187
|
+
let currentSection: SectionNode | null = null;
|
|
188
|
+
|
|
189
|
+
for (const node of ast.children) {
|
|
190
|
+
if (node.type === 'heading' && node.depth === 2) {
|
|
191
|
+
if (currentSection) {
|
|
192
|
+
sections.push(currentSection);
|
|
193
|
+
}
|
|
194
|
+
const title = extractHeadingText(node);
|
|
195
|
+
currentSection = {
|
|
196
|
+
id: normalizeSectionId(title),
|
|
197
|
+
title,
|
|
198
|
+
depth: node.depth,
|
|
199
|
+
line: node.position?.start.line || 0,
|
|
200
|
+
column: node.position?.start.column || 0,
|
|
201
|
+
content: []
|
|
202
|
+
};
|
|
203
|
+
} else if (currentSection) {
|
|
204
|
+
currentSection.content.push(node);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (currentSection) {
|
|
209
|
+
sections.push(currentSection);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return sections;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ============================================================================
|
|
216
|
+
// Body Grammar Validation
|
|
217
|
+
// ============================================================================
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Validates body structure against grammar rules
|
|
221
|
+
*/
|
|
222
|
+
export function validateBodyGrammar(
|
|
223
|
+
body: string,
|
|
224
|
+
grammar: any,
|
|
225
|
+
docType: DocType
|
|
226
|
+
): ValidationIssue[] {
|
|
227
|
+
const issues: ValidationIssue[] = [];
|
|
228
|
+
|
|
229
|
+
if (!grammar || !grammar.sections) {
|
|
230
|
+
return issues; // No grammar defined, skip
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const ast = parseMarkdown(body);
|
|
234
|
+
const sections = findSections(ast);
|
|
235
|
+
|
|
236
|
+
// Check required sections
|
|
237
|
+
for (const rule of grammar.sections) {
|
|
238
|
+
const found = sections.find(s =>
|
|
239
|
+
s.id === rule.id ||
|
|
240
|
+
s.title.toLowerCase() === rule.title.toLowerCase()
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
if (rule.required && !found) {
|
|
244
|
+
issues.push({
|
|
245
|
+
type: 'error',
|
|
246
|
+
code: 'MISSING_REQUIRED_SECTION',
|
|
247
|
+
message: `Missing required section. Add this H2 heading to your document:\n\n## ${rule.title}`,
|
|
248
|
+
sectionId: rule.id
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Validate section shape if present
|
|
253
|
+
if (found && rule.shape) {
|
|
254
|
+
const shapeIssues = validateSectionShape(found, rule);
|
|
255
|
+
issues.push(...shapeIssues);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Check section order
|
|
260
|
+
const ruleOrder = new Map(grammar.sections.map((r: any) => [r.id, r.order]));
|
|
261
|
+
const ruleById = new Map(grammar.sections.map((r: any) => [r.id, r]));
|
|
262
|
+
let lastOrder = 0;
|
|
263
|
+
let lastSectionTitle = '';
|
|
264
|
+
for (const section of sections) {
|
|
265
|
+
const order = ruleOrder.get(section.id);
|
|
266
|
+
if (order !== undefined && order < lastOrder) {
|
|
267
|
+
// Find what section should come before this one
|
|
268
|
+
const expectedBefore = grammar.sections
|
|
269
|
+
.filter((r: any) => r.order < order)
|
|
270
|
+
.sort((a: any, b: any) => b.order - a.order)[0];
|
|
271
|
+
|
|
272
|
+
issues.push({
|
|
273
|
+
type: 'error',
|
|
274
|
+
code: 'SECTION_OUT_OF_ORDER',
|
|
275
|
+
message: `Section "## ${section.title}" is out of order. It should appear ${expectedBefore ? `after "## ${expectedBefore.title}"` : 'earlier in the document'}, not after "## ${lastSectionTitle}".`,
|
|
276
|
+
sectionId: section.id,
|
|
277
|
+
line: section.line
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
if (order !== undefined) {
|
|
281
|
+
lastOrder = order;
|
|
282
|
+
lastSectionTitle = section.title;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return issues;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Validates section content shape
|
|
291
|
+
*/
|
|
292
|
+
function validateSectionShape(section: SectionNode, rule: any): ValidationIssue[] {
|
|
293
|
+
const issues: ValidationIssue[] = [];
|
|
294
|
+
const shape = rule.shape;
|
|
295
|
+
|
|
296
|
+
if (shape.type === 'list') {
|
|
297
|
+
const lists = section.content.filter(n => n.type === 'list') as List[];
|
|
298
|
+
const listTypeExample = shape.ordered
|
|
299
|
+
? '1. First item\n2. Second item\n3. Third item'
|
|
300
|
+
: '- First item\n- Second item\n- Third item';
|
|
301
|
+
|
|
302
|
+
if (lists.length === 0 && shape.minItems && shape.minItems > 0) {
|
|
303
|
+
issues.push({
|
|
304
|
+
type: 'error',
|
|
305
|
+
code: 'MISSING_LIST',
|
|
306
|
+
message: `Section "## ${rule.title}" requires a ${shape.ordered ? 'numbered' : 'bulleted'} list. Add items like:\n\n${listTypeExample}`,
|
|
307
|
+
sectionId: rule.id
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
for (const list of lists) {
|
|
312
|
+
if (shape.ordered !== undefined && list.ordered !== shape.ordered) {
|
|
313
|
+
const currentType = list.ordered ? 'numbered (1. 2. 3.)' : 'bulleted (-)';
|
|
314
|
+
const expectedType = shape.ordered ? 'numbered (1. 2. 3.)' : 'bulleted (-)';
|
|
315
|
+
issues.push({
|
|
316
|
+
type: 'error',
|
|
317
|
+
code: 'WRONG_LIST_TYPE',
|
|
318
|
+
message: `Section "## ${rule.title}" has a ${currentType} list but requires a ${expectedType} list. Change to:\n\n${listTypeExample}`,
|
|
319
|
+
sectionId: rule.id
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
if (shape.minItems && list.children.length < shape.minItems) {
|
|
323
|
+
issues.push({
|
|
324
|
+
type: 'error',
|
|
325
|
+
code: 'INSUFFICIENT_LIST_ITEMS',
|
|
326
|
+
message: `Section "## ${rule.title}" needs at least ${shape.minItems} list items (currently has ${list.children.length}).`,
|
|
327
|
+
sectionId: rule.id
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (shape.type === 'table') {
|
|
334
|
+
const tables = section.content.filter(n => n.type === 'table');
|
|
335
|
+
if (tables.length === 0) {
|
|
336
|
+
issues.push({
|
|
337
|
+
type: 'error',
|
|
338
|
+
code: 'MISSING_TABLE',
|
|
339
|
+
message: `Section "## ${rule.title}" requires a table. Add a markdown table like:\n\n| Column 1 | Column 2 |\n|----------|----------|\n| Data | Data |`,
|
|
340
|
+
sectionId: rule.id
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (shape.type === 'flow' && shape.allowedNodes) {
|
|
346
|
+
const nodeTypeNames: Record<string, string> = {
|
|
347
|
+
'paragraph': 'paragraphs',
|
|
348
|
+
'list': 'lists',
|
|
349
|
+
'table': 'tables',
|
|
350
|
+
'code': 'code blocks',
|
|
351
|
+
'blockquote': 'blockquotes',
|
|
352
|
+
'heading': 'headings'
|
|
353
|
+
};
|
|
354
|
+
for (const node of section.content) {
|
|
355
|
+
if (!shape.allowedNodes.includes(node.type)) {
|
|
356
|
+
const nodeName = nodeTypeNames[node.type] || node.type;
|
|
357
|
+
const allowedNames = shape.allowedNodes.map((t: string) => nodeTypeNames[t] || t).join(', ');
|
|
358
|
+
issues.push({
|
|
359
|
+
type: 'error',
|
|
360
|
+
code: 'DISALLOWED_NODE_TYPE',
|
|
361
|
+
message: `Section "## ${rule.title}" does not allow ${nodeName}. Only ${allowedNames} are permitted.`,
|
|
362
|
+
sectionId: rule.id,
|
|
363
|
+
line: node.position?.start.line
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return issues;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// ============================================================================
|
|
373
|
+
// Wikilink Validation
|
|
374
|
+
// ============================================================================
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Extracts wikilinks from markdown content
|
|
378
|
+
*/
|
|
379
|
+
export function extractWikilinks(content: string): string[] {
|
|
380
|
+
const wikilinks: string[] = [];
|
|
381
|
+
const regex = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
|
|
382
|
+
let match;
|
|
383
|
+
|
|
384
|
+
while ((match = regex.exec(content)) !== null) {
|
|
385
|
+
const target = match[1].trim();
|
|
386
|
+
if (target && !wikilinks.includes(target)) {
|
|
387
|
+
wikilinks.push(target);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return wikilinks;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* Validates that wikilinks exist in the vault
|
|
396
|
+
*/
|
|
397
|
+
export function validateWikilinks(
|
|
398
|
+
body: string,
|
|
399
|
+
vaultIndex: string[]
|
|
400
|
+
): ValidationIssue[] {
|
|
401
|
+
const issues: ValidationIssue[] = [];
|
|
402
|
+
const wikilinks = extractWikilinks(body);
|
|
403
|
+
|
|
404
|
+
// Normalize vault index for matching
|
|
405
|
+
const normalizedIndex = new Set(
|
|
406
|
+
vaultIndex.map(f => f.toLowerCase().replace(/\s+/g, '-'))
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
for (const link of wikilinks) {
|
|
410
|
+
const normalized = link.toLowerCase().replace(/\s+/g, '-');
|
|
411
|
+
|
|
412
|
+
// Check various matching patterns
|
|
413
|
+
const exists = normalizedIndex.has(normalized) ||
|
|
414
|
+
normalizedIndex.has(normalized.replace(/\.md$/, '')) ||
|
|
415
|
+
[...normalizedIndex].some(f =>
|
|
416
|
+
f.endsWith(`/${normalized}`) ||
|
|
417
|
+
f.endsWith(`-${normalized}`) ||
|
|
418
|
+
f === normalized
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
if (!exists) {
|
|
422
|
+
issues.push({
|
|
423
|
+
type: 'error',
|
|
424
|
+
code: 'BROKEN_LINK',
|
|
425
|
+
message: `Broken link: [[${link}]] - This document doesn't exist. Check the spelling or create the document first.`
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return issues;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ============================================================================
|
|
434
|
+
// Cross-Link Validation (Type-Specific Rules)
|
|
435
|
+
// ============================================================================
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Validates type-specific cross-link requirements
|
|
439
|
+
*/
|
|
440
|
+
export function validateCrossLinks(
|
|
441
|
+
frontmatter: Record<string, any>,
|
|
442
|
+
body: string,
|
|
443
|
+
vaultIndex: string[]
|
|
444
|
+
): ValidationIssue[] {
|
|
445
|
+
const issues: ValidationIssue[] = [];
|
|
446
|
+
const docType = frontmatter.type as DocType;
|
|
447
|
+
|
|
448
|
+
if (!docType || !isDocType(docType)) {
|
|
449
|
+
return issues;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
switch (docType) {
|
|
453
|
+
case 'process':
|
|
454
|
+
// Process must reference at least one standard
|
|
455
|
+
if (!frontmatter.related_standards ||
|
|
456
|
+
!Array.isArray(frontmatter.related_standards) ||
|
|
457
|
+
frontmatter.related_standards.length === 0) {
|
|
458
|
+
issues.push({
|
|
459
|
+
type: 'error',
|
|
460
|
+
code: 'MISSING_REQUIRED_LINK',
|
|
461
|
+
message: 'Process documents must reference at least one standard in related_standards',
|
|
462
|
+
field: 'related_standards'
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
break;
|
|
466
|
+
|
|
467
|
+
case 'sop':
|
|
468
|
+
// SOP must reference a parent process
|
|
469
|
+
if (!frontmatter.related_process ||
|
|
470
|
+
typeof frontmatter.related_process !== 'string' ||
|
|
471
|
+
frontmatter.related_process.trim() === '') {
|
|
472
|
+
issues.push({
|
|
473
|
+
type: 'error',
|
|
474
|
+
code: 'MISSING_REQUIRED_LINK',
|
|
475
|
+
message: 'SOP must reference a parent process in related_process',
|
|
476
|
+
field: 'related_process'
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
break;
|
|
480
|
+
|
|
481
|
+
case 'runbook':
|
|
482
|
+
// Runbook must reference a system in ## Service section
|
|
483
|
+
const serviceMatch = body.match(/##\s+Service\s*\n+([^\n#]+)/);
|
|
484
|
+
const serviceContent = serviceMatch ? serviceMatch[1].trim() : '';
|
|
485
|
+
const serviceLinks = extractWikilinks(serviceContent);
|
|
486
|
+
|
|
487
|
+
if (!serviceContent || serviceLinks.length === 0) {
|
|
488
|
+
issues.push({
|
|
489
|
+
type: 'error',
|
|
490
|
+
code: 'MISSING_REQUIRED_LINK',
|
|
491
|
+
message: 'Runbook must reference a system/service in the ## Service section',
|
|
492
|
+
field: 'service'
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
break;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return issues;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ============================================================================
|
|
502
|
+
// Naming Validation
|
|
503
|
+
// ============================================================================
|
|
504
|
+
|
|
505
|
+
const TEMPLATE_REGISTRY: Record<DocType, { folder: string }> = {
|
|
506
|
+
'adr': { folder: '90_Architecture/ADRs' },
|
|
507
|
+
'agreement': { folder: '120_Legal/agreements' },
|
|
508
|
+
'capability': { folder: '110_Capabilities' },
|
|
509
|
+
'meeting': { folder: '60_Meetings' },
|
|
510
|
+
'policy': { folder: '10_Policies' },
|
|
511
|
+
'prd': { folder: '100_Products/PRDs' },
|
|
512
|
+
'process': { folder: '30_Processes' },
|
|
513
|
+
'reference': { folder: '200_References' },
|
|
514
|
+
'runbook': { folder: '50_Runbooks' },
|
|
515
|
+
'scorecard': { folder: '80_Scorecards' },
|
|
516
|
+
'sop': { folder: '40_SOPs' },
|
|
517
|
+
'sow': { folder: '120_Legal/SOWs' },
|
|
518
|
+
'standard': { folder: '20_Standards' },
|
|
519
|
+
'system': { folder: '70_Systems' },
|
|
520
|
+
'tdd': { folder: '90_Architecture/TDDs' }
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Validates document is in correct folder
|
|
525
|
+
*/
|
|
526
|
+
export function validateNaming(
|
|
527
|
+
filePath: string,
|
|
528
|
+
frontmatter: Record<string, any>
|
|
529
|
+
): ValidationIssue[] {
|
|
530
|
+
const issues: ValidationIssue[] = [];
|
|
531
|
+
const docType = frontmatter.type as DocType;
|
|
532
|
+
|
|
533
|
+
if (!docType || !isDocType(docType)) {
|
|
534
|
+
return issues;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Check folder placement
|
|
538
|
+
const expectedFolder = TEMPLATE_REGISTRY[docType].folder;
|
|
539
|
+
if (!filePath.includes(expectedFolder)) {
|
|
540
|
+
issues.push({
|
|
541
|
+
type: 'error',
|
|
542
|
+
code: 'WRONG_FOLDER',
|
|
543
|
+
message: `Document of type '${docType}' should be in ${expectedFolder}`,
|
|
544
|
+
field: 'path'
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Validate filename for ADRs
|
|
549
|
+
if (docType === 'adr') {
|
|
550
|
+
const filename = filePath.split('/').pop()?.replace('.md', '') || '';
|
|
551
|
+
const adrPattern = /^ADR-\d{4,}-[a-z0-9-]+$/;
|
|
552
|
+
if (!adrPattern.test(filename)) {
|
|
553
|
+
issues.push({
|
|
554
|
+
type: 'error',
|
|
555
|
+
code: 'INVALID_ADR_FILENAME',
|
|
556
|
+
message: 'ADR filename must match pattern ADR-####-slug.md',
|
|
557
|
+
field: 'filename'
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return issues;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ============================================================================
|
|
566
|
+
// Main Validation Function
|
|
567
|
+
// ============================================================================
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Validates a document for Decap CMS
|
|
571
|
+
*
|
|
572
|
+
* @param frontmatter - The document frontmatter
|
|
573
|
+
* @param body - The document body (markdown)
|
|
574
|
+
* @param filePath - The file path within the vault
|
|
575
|
+
* @param context - Validation context (vault index, schemas, grammars)
|
|
576
|
+
* @returns Validation result with any issues
|
|
577
|
+
*/
|
|
578
|
+
export function validateDocument(
|
|
579
|
+
frontmatter: Record<string, any>,
|
|
580
|
+
body: string,
|
|
581
|
+
filePath: string,
|
|
582
|
+
context: ValidationContext
|
|
583
|
+
): ValidationResult {
|
|
584
|
+
const issues: ValidationIssue[] = [];
|
|
585
|
+
|
|
586
|
+
const docType = frontmatter.type;
|
|
587
|
+
|
|
588
|
+
// Validate type is present
|
|
589
|
+
if (!docType) {
|
|
590
|
+
issues.push({
|
|
591
|
+
type: 'error',
|
|
592
|
+
code: 'MISSING_TYPE',
|
|
593
|
+
message: 'Document type is required',
|
|
594
|
+
field: 'type'
|
|
595
|
+
});
|
|
596
|
+
return { success: false, issues };
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (!isDocType(docType)) {
|
|
600
|
+
issues.push({
|
|
601
|
+
type: 'error',
|
|
602
|
+
code: 'INVALID_TYPE',
|
|
603
|
+
message: `Invalid document type: '${docType}'`,
|
|
604
|
+
field: 'type'
|
|
605
|
+
});
|
|
606
|
+
return { success: false, issues };
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// 1. Schema validation
|
|
610
|
+
const schema = context.schemas[docType];
|
|
611
|
+
if (schema) {
|
|
612
|
+
issues.push(...validateSchema(frontmatter, schema));
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// 2. Body grammar validation
|
|
616
|
+
const grammar = context.bodyGrammars[docType];
|
|
617
|
+
if (grammar) {
|
|
618
|
+
issues.push(...validateBodyGrammar(body, grammar, docType));
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// 3. Wikilink validation
|
|
622
|
+
issues.push(...validateWikilinks(body, context.vaultIndex));
|
|
623
|
+
|
|
624
|
+
// 4. Cross-link validation (type-specific rules)
|
|
625
|
+
issues.push(...validateCrossLinks(frontmatter, body, context.vaultIndex));
|
|
626
|
+
|
|
627
|
+
// 5. Naming validation
|
|
628
|
+
issues.push(...validateNaming(filePath, frontmatter));
|
|
629
|
+
|
|
630
|
+
const errors = issues.filter(i => i.type === 'error');
|
|
631
|
+
|
|
632
|
+
return {
|
|
633
|
+
success: errors.length === 0,
|
|
634
|
+
issues
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
export default validateDocument;
|
package/package.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@millstone/synapse-site",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Quartz 4 configuration for Synapse documentation site",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist/**/*",
|
|
8
|
+
"plugins/**/*",
|
|
9
|
+
"decap/**/*",
|
|
10
|
+
"static/**/*",
|
|
11
|
+
"*.ts",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"scripts": {
|
|
15
|
+
"setup": "tsx setup-quartz.ts",
|
|
16
|
+
"sync-plugins": "tsx sync-plugins.ts",
|
|
17
|
+
"build:decap-config": "tsx decap/build-config.ts",
|
|
18
|
+
"build:decap": "npm run build:decap-config && tsx decap/build-vault-index.ts && npm run build:validate-bundle",
|
|
19
|
+
"build:validate-bundle": "esbuild decap/validate-browser.ts --bundle --format=esm --outfile=static/edit/validate.bundle.js --platform=browser --target=es2020",
|
|
20
|
+
"build": "npm run sync-plugins && npm run build:decap && cd quartz && npx quartz build && npm run copy:decap",
|
|
21
|
+
"copy:decap": "cp -r static/edit quartz/quartz/static/",
|
|
22
|
+
"serve": "npm run sync-plugins && npm run build:decap && cd quartz && npx quartz build --serve",
|
|
23
|
+
"dev": "npm run sync-plugins && npm run build:decap && npm run copy:decap && cd quartz && npx quartz build --serve --port 8080",
|
|
24
|
+
"update-submodule": "git submodule update --remote --merge",
|
|
25
|
+
"clean": "rm -rf quartz/public quartz/.quartz-cache static/edit/*.json static/edit/schemas static/edit/body-grammars static/edit/validate.bundle.js"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"repository": {
|
|
31
|
+
"type": "git",
|
|
32
|
+
"url": "https://github.com/millstonehq/synapse.git",
|
|
33
|
+
"directory": "packages/site"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/millstonehq/synapse#readme",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/millstonehq/synapse/issues"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@millstone/synapse-cli": ">=0.1.0"
|
|
41
|
+
},
|
|
42
|
+
"keywords": [
|
|
43
|
+
"synapse",
|
|
44
|
+
"quartz",
|
|
45
|
+
"documentation",
|
|
46
|
+
"static-site"
|
|
47
|
+
],
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^20.0.0",
|
|
50
|
+
"chalk": "^5.3.0",
|
|
51
|
+
"esbuild": "^0.20.0",
|
|
52
|
+
"tsx": "^4.0.0",
|
|
53
|
+
"typescript": "^5.3.0"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"ajv": "^8.12.0",
|
|
57
|
+
"ajv-formats": "^2.1.1",
|
|
58
|
+
"fast-glob": "^3.3.2",
|
|
59
|
+
"js-yaml": "^4.1.1",
|
|
60
|
+
"remark-gfm": "^4.0.0",
|
|
61
|
+
"remark-parse": "^11.0.0",
|
|
62
|
+
"unified": "^11.0.0"
|
|
63
|
+
},
|
|
64
|
+
"engines": {
|
|
65
|
+
"node": ">=20.0.0"
|
|
66
|
+
}
|
|
67
|
+
}
|