@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 ADDED
@@ -0,0 +1,194 @@
1
+ # Synapse Documentation Site (Quartz 4)
2
+
3
+ This directory contains configuration files for Quartz 4 static site generator, which is integrated as a git submodule for the Synapse Documentation Framework.
4
+
5
+ ## Architecture
6
+
7
+ Quartz 4 is included as a git submodule at `site/quartz/`. This approach:
8
+ - Keeps Quartz updates manageable via git submodule commands
9
+ - Maintains clear separation between Synapse and Quartz code
10
+ - Allows version pinning to specific Quartz releases
11
+ - Enables easy rollback if needed
12
+
13
+ ## Setup
14
+
15
+ ### Prerequisites
16
+ - Node.js v20+ (v22+ preferred)
17
+ - npm v10+
18
+ - Git
19
+
20
+ ### Initial Setup
21
+
22
+ From the project root:
23
+
24
+ ```bash
25
+ # Install site dependencies
26
+ cd site
27
+ npm install
28
+
29
+ # Run the setup script (TypeScript)
30
+ npm run setup
31
+ ```
32
+
33
+ The setup script will:
34
+ 1. Initialize the Quartz submodule if not present
35
+ 2. Install Quartz dependencies
36
+ 3. Copy our configuration files into Quartz
37
+ 4. Create a symlink from `quartz/content` to `../../content`
38
+
39
+ ### Manual Setup (if needed)
40
+
41
+ ```bash
42
+ # From project root
43
+ git submodule add -b v4 https://github.com/jackyzha0/quartz.git site/quartz
44
+ cd site/quartz
45
+ npm install
46
+
47
+ # Copy configurations
48
+ cp ../quartz.config.ts .
49
+ cp ../quartz.layout.ts .
50
+
51
+ # Link content
52
+ rm -rf content
53
+ ln -s ../../content content
54
+ ```
55
+
56
+ ## Development
57
+
58
+ ### Start Development Server
59
+
60
+ ```bash
61
+ # From site directory
62
+ npm run dev
63
+
64
+ # Or directly from quartz directory
65
+ cd site/quartz
66
+ npx quartz build --serve
67
+ ```
68
+
69
+ The site will be available at `http://localhost:8080` with hot reload enabled.
70
+
71
+ ### Available Scripts
72
+
73
+ From the `site/` directory:
74
+
75
+ - `npm run setup` - Initialize and configure Quartz submodule
76
+ - `npm run build` - Build the static site
77
+ - `npm run serve` - Build and serve locally
78
+ - `npm run dev` - Development server with hot reload
79
+ - `npm run update-submodule` - Update Quartz to latest version
80
+ - `npm run clean` - Clean build artifacts
81
+
82
+ ## Configuration Files
83
+
84
+ ### `quartz.config.ts`
85
+ Main Quartz configuration with:
86
+ - Site title and metadata
87
+ - Theme and color scheme
88
+ - Plugin configuration
89
+ - Graph, backlinks, and tags enabled
90
+ - Obsidian-flavored markdown support
91
+
92
+ ### `quartz.layout.ts`
93
+ Layout configuration defining:
94
+ - Graph component in right sidebar (interactive, zoomable)
95
+ - Backlinks below the graph
96
+ - Tags in article headers
97
+ - Search and explorer in left sidebar
98
+ - Table of contents positioning
99
+
100
+ ### `custom.scss` (optional)
101
+ Custom styles for:
102
+ - Document type badges
103
+ - Status indicators
104
+ - Enhanced graph styling
105
+ - Responsive design adjustments
106
+
107
+ ## Features Configured
108
+
109
+ ✅ **Graph Visualization**
110
+ - Local graph: 2-level depth from current document
111
+ - Global graph: Full documentation network
112
+ - Interactive drag, zoom, and hover
113
+ - Tags shown on nodes
114
+
115
+ ✅ **Automatic Backlinks**
116
+ - Shows documents that reference the current page
117
+ - Positioned below the graph in right sidebar
118
+
119
+ ✅ **Tag Navigation**
120
+ - Tags displayed in article headers
121
+ - Dedicated tag index pages
122
+ - Tag-based content discovery
123
+
124
+ ✅ **Additional Features**
125
+ - Full-text search
126
+ - Dark/light theme toggle
127
+ - Breadcrumb navigation
128
+ - Table of contents
129
+ - Obsidian wikilink support
130
+
131
+ ## Building for Production
132
+
133
+ ```bash
134
+ # Build the static site
135
+ cd site
136
+ npm run build
137
+
138
+ # Output will be in site/quartz/public/
139
+ ```
140
+
141
+ The `public/` directory can be deployed to any static hosting service:
142
+ - GitHub Pages
143
+ - Netlify
144
+ - Vercel
145
+ - AWS S3 + CloudFront
146
+ - Any web server
147
+
148
+ ## Updating Quartz
149
+
150
+ To update to the latest Quartz v4:
151
+
152
+ ```bash
153
+ cd site
154
+ npm run update-submodule
155
+
156
+ # Re-run setup to apply our configurations
157
+ npm run setup
158
+ ```
159
+
160
+ ## Troubleshooting
161
+
162
+ ### Submodule not initialized
163
+ ```bash
164
+ git submodule update --init --recursive
165
+ ```
166
+
167
+ ### Content not appearing
168
+ - Check symlink: `ls -la site/quartz/content`
169
+ - Should point to `../../content`
170
+ - Verify files have proper YAML frontmatter
171
+
172
+ ### Build errors
173
+ - Ensure Node v20+ with `node --version`
174
+ - Run `npm install` in both `site/` and `site/quartz/`
175
+ - Check frontmatter syntax is valid YAML
176
+
177
+ ### Permission errors on setup
178
+ ```bash
179
+ chmod +x site/setup-quartz.ts
180
+ ```
181
+
182
+ ## Integration with Synapse
183
+
184
+ The Quartz site automatically processes all content in the Synapse vault:
185
+ - Documents in numbered folders (10_Policies, 20_Standards, etc.)
186
+ - Templates and schemas (excluded from build)
187
+ - Examples generated from YAML seeds
188
+ - All cross-references via wikilinks
189
+
190
+ The graph visualization will show the relationships between:
191
+ - Processes and their standards
192
+ - SOPs and their parent processes
193
+ - Runbooks and their systems
194
+ - ADRs and their related documents
@@ -0,0 +1,435 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Generate Decap CMS config.yml from JSON schemas and body grammars
4
+ *
5
+ * This ensures the CMS config stays in sync with schema definitions.
6
+ */
7
+
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import { fileURLToPath } from 'url';
11
+ import * as yaml from 'js-yaml';
12
+
13
+ const __filename = fileURLToPath(import.meta.url);
14
+ const __dirname = path.dirname(__filename);
15
+
16
+ const PROJECT_ROOT = path.resolve(__dirname, '../../..');
17
+ const SCHEMAS_DIR = path.join(PROJECT_ROOT, 'schemas/frontmatter');
18
+ const GRAMMARS_DIR = path.join(PROJECT_ROOT, 'schemas/body-grammars');
19
+ const OUTPUT_PATH = path.join(__dirname, '../static/edit/config.yml');
20
+
21
+ // Collection configuration - maps doc types to their CMS settings
22
+ const COLLECTIONS: Record<string, {
23
+ name: string;
24
+ label: string;
25
+ labelSingular: string;
26
+ folder: string;
27
+ slug?: string;
28
+ statusOptions?: string[];
29
+ }> = {
30
+ policy: {
31
+ name: 'policies',
32
+ label: '📜 Policies',
33
+ labelSingular: 'Policy',
34
+ folder: 'content/10_Policies',
35
+ },
36
+ standard: {
37
+ name: 'standards',
38
+ label: '📐 Standards',
39
+ labelSingular: 'Standard',
40
+ folder: 'content/20_Standards',
41
+ },
42
+ process: {
43
+ name: 'processes',
44
+ label: '🔄 Processes',
45
+ labelSingular: 'Process',
46
+ folder: 'content/30_Processes',
47
+ },
48
+ sop: {
49
+ name: 'sops',
50
+ label: '📋 SOPs',
51
+ labelSingular: 'SOP',
52
+ folder: 'content/40_SOPs',
53
+ },
54
+ runbook: {
55
+ name: 'runbooks',
56
+ label: '🔧 Runbooks',
57
+ labelSingular: 'Runbook',
58
+ folder: 'content/50_Runbooks',
59
+ },
60
+ meeting: {
61
+ name: 'meetings',
62
+ label: '📅 Meetings',
63
+ labelSingular: 'Meeting',
64
+ folder: 'content/60_Meetings',
65
+ slug: '{{year}}-{{month}}-{{day}}-{{slug}}',
66
+ },
67
+ system: {
68
+ name: 'systems',
69
+ label: '🖥️ Systems',
70
+ labelSingular: 'System',
71
+ folder: 'content/75_Systems',
72
+ },
73
+ scorecard: {
74
+ name: 'scorecards',
75
+ label: '📊 Scorecards',
76
+ labelSingular: 'Scorecard',
77
+ folder: 'content/80_Scorecards',
78
+ },
79
+ adr: {
80
+ name: 'adrs',
81
+ label: '🏗️ ADRs',
82
+ labelSingular: 'ADR',
83
+ folder: 'content/90_Architecture/ADRs',
84
+ slug: 'ADR-{{fields.adr_number}}-{{slug}}',
85
+ statusOptions: ['proposed', 'accepted', 'deprecated', 'superseded'],
86
+ },
87
+ tdd: {
88
+ name: 'tdds',
89
+ label: '📐 TDDs',
90
+ labelSingular: 'TDD',
91
+ folder: 'content/90_Architecture/TDDs',
92
+ },
93
+ prd: {
94
+ name: 'prds',
95
+ label: '📦 PRDs',
96
+ labelSingular: 'PRD',
97
+ folder: 'content/100_Products/PRDs',
98
+ },
99
+ capability: {
100
+ name: 'capabilities',
101
+ label: '🎯 Capabilities',
102
+ labelSingular: 'Capability',
103
+ folder: 'content/110_Capabilities',
104
+ },
105
+ reference: {
106
+ name: 'references',
107
+ label: '📚 References',
108
+ labelSingular: 'Reference',
109
+ folder: 'content/200_References',
110
+ },
111
+ };
112
+
113
+ interface SchemaProperty {
114
+ type?: string;
115
+ enum?: string[];
116
+ const?: string;
117
+ format?: string;
118
+ items?: { type: string };
119
+ minLength?: number;
120
+ }
121
+
122
+ interface Schema {
123
+ properties: Record<string, SchemaProperty>;
124
+ required?: string[];
125
+ allOf?: Array<{ $ref: string }>;
126
+ }
127
+
128
+ interface BodyGrammarSection {
129
+ id: string;
130
+ title: string;
131
+ required: boolean;
132
+ order: number;
133
+ shape: {
134
+ type: string;
135
+ ordered?: boolean;
136
+ minItems?: number;
137
+ allowedNodes?: string[];
138
+ };
139
+ }
140
+
141
+ interface BodyGrammar {
142
+ type: string;
143
+ displayName: string;
144
+ sections: BodyGrammarSection[];
145
+ }
146
+
147
+ interface DecapField {
148
+ label: string;
149
+ name: string;
150
+ widget: string;
151
+ required?: boolean;
152
+ default?: string | boolean;
153
+ hint?: string;
154
+ options?: string[];
155
+ }
156
+
157
+ // Map JSON schema types to Decap widget types
158
+ function schemaTypeToWidget(prop: SchemaProperty, name: string): string {
159
+ if (prop.enum) return 'select';
160
+ if (prop.const) return 'hidden';
161
+ if (prop.format === 'date-time') return 'datetime';
162
+ if (prop.type === 'array') return 'list';
163
+ if (prop.type === 'boolean') return 'boolean';
164
+ if (name === 'summary') return 'text';
165
+ return 'string';
166
+ }
167
+
168
+ // Generate placeholder content based on section shape
169
+ function generateSectionPlaceholder(section: BodyGrammarSection): string {
170
+ const shape = section.shape;
171
+
172
+ if (shape.type === 'list') {
173
+ if (shape.ordered) {
174
+ return '1. First item\n2. Second item';
175
+ }
176
+ return '- Item one\n- Item two';
177
+ }
178
+
179
+ if (shape.type === 'table') {
180
+ return '| Column 1 | Column 2 |\n|----------|----------|\n| Data | Data |';
181
+ }
182
+
183
+ return '[Describe here...]';
184
+ }
185
+
186
+ // Generate default body template from body grammar
187
+ function generateDefaultBody(grammar: BodyGrammar): string {
188
+ if (!grammar || !grammar.sections) return '';
189
+
190
+ const sections = grammar.sections
191
+ .filter(s => s.required)
192
+ .sort((a, b) => a.order - b.order);
193
+
194
+ return sections.map(section => {
195
+ const placeholder = generateSectionPlaceholder(section);
196
+ return `## ${section.title}\n\n${placeholder}`;
197
+ }).join('\n\n');
198
+ }
199
+
200
+ // Generate hint showing required sections
201
+ function generateBodyHint(grammar: BodyGrammar): string {
202
+ if (!grammar || !grammar.sections) return '';
203
+
204
+ const required = grammar.sections
205
+ .filter(s => s.required)
206
+ .sort((a, b) => a.order - b.order)
207
+ .map(s => {
208
+ let hint = s.title;
209
+ if (s.shape.type === 'list') {
210
+ hint += s.shape.ordered ? ' (numbered list)' : ' (bullet list)';
211
+ }
212
+ return hint;
213
+ });
214
+
215
+ if (required.length === 0) return '';
216
+
217
+ return `Required sections: ${required.join(', ')}`;
218
+ }
219
+
220
+ // Load and merge schema with base
221
+ function loadSchema(type: string): Schema {
222
+ const schemaPath = path.join(SCHEMAS_DIR, `${type}.schema.json`);
223
+ const basePath = path.join(SCHEMAS_DIR, 'base.schema.json');
224
+
225
+ const baseSchema: Schema = JSON.parse(fs.readFileSync(basePath, 'utf-8'));
226
+
227
+ if (!fs.existsSync(schemaPath)) {
228
+ return baseSchema;
229
+ }
230
+
231
+ const typeSchema: Schema = JSON.parse(fs.readFileSync(schemaPath, 'utf-8'));
232
+
233
+ // Merge properties
234
+ return {
235
+ properties: {
236
+ ...baseSchema.properties,
237
+ ...typeSchema.properties,
238
+ },
239
+ required: [
240
+ ...(baseSchema.required || []),
241
+ ...(typeSchema.required || []),
242
+ ],
243
+ };
244
+ }
245
+
246
+ // Load body grammar
247
+ function loadBodyGrammar(type: string): BodyGrammar | null {
248
+ const grammarPath = path.join(GRAMMARS_DIR, `${type}.body-grammar.json`);
249
+
250
+ if (!fs.existsSync(grammarPath)) {
251
+ return null;
252
+ }
253
+
254
+ return JSON.parse(fs.readFileSync(grammarPath, 'utf-8'));
255
+ }
256
+
257
+ // Generate Decap fields from schema
258
+ function generateFields(
259
+ type: string,
260
+ schema: Schema,
261
+ grammar: BodyGrammar | null,
262
+ collectionConfig: typeof COLLECTIONS[string]
263
+ ): DecapField[] {
264
+ const fields: DecapField[] = [];
265
+ const required = new Set(schema.required || []);
266
+
267
+ // Define field order (important fields first)
268
+ const fieldOrder = [
269
+ 'adr_number', // ADR-specific
270
+ 'id',
271
+ 'title',
272
+ 'type',
273
+ 'status',
274
+ 'owner',
275
+ 'created',
276
+ 'updated',
277
+ 'tags',
278
+ 'summary',
279
+ ];
280
+
281
+ // Get all property names and sort by order
282
+ const propNames = Object.keys(schema.properties);
283
+ const sortedProps = [
284
+ ...fieldOrder.filter(f => propNames.includes(f)),
285
+ ...propNames.filter(f => !fieldOrder.includes(f) && f !== 'example'),
286
+ ];
287
+
288
+ for (const name of sortedProps) {
289
+ const prop = schema.properties[name];
290
+ if (!prop) continue;
291
+
292
+ // Skip 'example' field - internal use only
293
+ if (name === 'example') continue;
294
+
295
+ const widget = schemaTypeToWidget(prop, name);
296
+ const field: DecapField = {
297
+ label: formatLabel(name),
298
+ name,
299
+ widget,
300
+ required: required.has(name),
301
+ };
302
+
303
+ // Handle special cases
304
+ if (widget === 'hidden') {
305
+ field.default = prop.const || type;
306
+ }
307
+
308
+ if (widget === 'select' && prop.enum) {
309
+ // Use collection-specific status options if defined
310
+ if (name === 'status' && collectionConfig.statusOptions) {
311
+ field.options = collectionConfig.statusOptions;
312
+ field.default = collectionConfig.statusOptions[0];
313
+ } else {
314
+ field.options = prop.enum;
315
+ field.default = prop.enum[0];
316
+ }
317
+ }
318
+
319
+ fields.push(field);
320
+ }
321
+
322
+ // Add body field with hint and default
323
+ // Raw mode only - the preview pane provides visual feedback
324
+ // and raw mode is more predictable for structured docs with required sections
325
+ const bodyField: Record<string, any> = {
326
+ label: 'Body',
327
+ name: 'body',
328
+ widget: 'markdown',
329
+ required: true,
330
+ modes: ['raw'],
331
+ };
332
+
333
+ if (grammar) {
334
+ const hint = generateBodyHint(grammar);
335
+ if (hint) {
336
+ bodyField.hint = hint;
337
+ }
338
+
339
+ const defaultBody = generateDefaultBody(grammar);
340
+ if (defaultBody) {
341
+ bodyField.default = defaultBody;
342
+ }
343
+ }
344
+
345
+ fields.push(bodyField);
346
+
347
+ return fields;
348
+ }
349
+
350
+ // Format field name as label
351
+ function formatLabel(name: string): string {
352
+ return name
353
+ .split('_')
354
+ .map(word => word.charAt(0).toUpperCase() + word.slice(1))
355
+ .join(' ');
356
+ }
357
+
358
+ // Generate full config
359
+ function generateConfig(): object {
360
+ const collections = [];
361
+
362
+ for (const [type, config] of Object.entries(COLLECTIONS)) {
363
+ const schema = loadSchema(type);
364
+ const grammar = loadBodyGrammar(type);
365
+ const fields = generateFields(type, schema, grammar, config);
366
+
367
+ const collection: Record<string, any> = {
368
+ name: config.name,
369
+ label: config.label,
370
+ label_singular: config.labelSingular,
371
+ folder: config.folder,
372
+ create: true,
373
+ nested: { depth: 10 },
374
+ slug: config.slug || '{{slug}}',
375
+ extension: 'md',
376
+ format: 'yaml-frontmatter',
377
+ fields,
378
+ };
379
+
380
+ collections.push(collection);
381
+ }
382
+
383
+ return {
384
+ backend: {
385
+ name: process.env.DECAP_BACKEND || 'github',
386
+ repo: process.env.DECAP_REPO || 'OWNER/REPO',
387
+ branch: process.env.DECAP_BRANCH || 'main',
388
+ auth_type: 'pkce',
389
+ app_id: process.env.DECAP_APP_ID || '',
390
+ api_root: process.env.DECAP_API_ROOT || 'https://api.github.com',
391
+ base_url: process.env.DECAP_BASE_URL || 'https://github.com',
392
+ },
393
+ media_folder: 'content/_assets',
394
+ public_folder: '/_assets',
395
+ local_backend: true,
396
+ site_url: '/',
397
+ display_url: '/',
398
+ logo_url: '/static/logo.png',
399
+ collections,
400
+ };
401
+ }
402
+
403
+ // Main
404
+ function main() {
405
+ console.log('Generating Decap CMS config from schemas...');
406
+
407
+ const config = generateConfig();
408
+
409
+ // Convert to YAML
410
+ const yamlContent = yaml.dump(config, {
411
+ indent: 2,
412
+ lineWidth: 120,
413
+ noRefs: true,
414
+ quotingType: '"',
415
+ forceQuotes: false,
416
+ });
417
+
418
+ // Add header comment
419
+ const output = `# Decap CMS Configuration - AUTO-GENERATED
420
+ # DO NOT EDIT MANUALLY - Run 'npm run build:decap-config' to regenerate
421
+ # Source: schemas/frontmatter/*.schema.json, schemas/body-grammars/*.body-grammar.json
422
+
423
+ ${yamlContent}`;
424
+
425
+ // Ensure output directory exists
426
+ const outputDir = path.dirname(OUTPUT_PATH);
427
+ if (!fs.existsSync(outputDir)) {
428
+ fs.mkdirSync(outputDir, { recursive: true });
429
+ }
430
+
431
+ fs.writeFileSync(OUTPUT_PATH, output);
432
+ console.log(`✓ Generated config.yml with ${Object.keys(COLLECTIONS).length} collections`);
433
+ }
434
+
435
+ main();