@pagenary/publisher 2026.5.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.
Files changed (147) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +337 -0
  3. package/bin/pagenary.mjs +116 -0
  4. package/build.config.json +5 -0
  5. package/package.json +66 -0
  6. package/scripts/build-site.js +87 -0
  7. package/scripts/build-tenants.js +3569 -0
  8. package/scripts/build.js +99 -0
  9. package/scripts/generate-sections.js +41 -0
  10. package/scripts/lib/seo-generator.js +558 -0
  11. package/scripts/lint-content.js +62 -0
  12. package/scripts/seo-smoke.js +94 -0
  13. package/scripts/serve.js +142 -0
  14. package/site/app.js +1 -0
  15. package/site/index.html +57 -0
  16. package/site/lib/categories.js +1 -0
  17. package/site/lib/export.js +1 -0
  18. package/site/lib/manifest-utils.js +1 -0
  19. package/site/lib/router.js +1 -0
  20. package/site/lib/search.js +1 -0
  21. package/site/llms.txt +22 -0
  22. package/site/manifest.js +132 -0
  23. package/site/mermaid-init.js +1 -0
  24. package/site/pages/api.html +339 -0
  25. package/site/pages/architecture.html +303 -0
  26. package/site/pages/deployment.html +282 -0
  27. package/site/pages/developer-guide.html +157 -0
  28. package/site/pages/extending.html +135 -0
  29. package/site/pages/quickstart.html +318 -0
  30. package/site/pages/seo-strategy.html +121 -0
  31. package/site/pages/tenant-config.html +519 -0
  32. package/site/pages/welcome.html +116 -0
  33. package/site/robots.txt +10 -0
  34. package/site/sections/api.js +3 -0
  35. package/site/sections/architecture.js +3 -0
  36. package/site/sections/deployment.js +3 -0
  37. package/site/sections/developer-guide.js +3 -0
  38. package/site/sections/extending.js +3 -0
  39. package/site/sections/quickstart.js +3 -0
  40. package/site/sections/section-templates.js +1 -0
  41. package/site/sections/seo-strategy.js +3 -0
  42. package/site/sections/tenant-config.js +3 -0
  43. package/site/sections/welcome.js +3 -0
  44. package/site/seo.js +1 -0
  45. package/site/sitemap.xml +63 -0
  46. package/site/styles.css +1982 -0
  47. package/site/syntax-highlight.js +1 -0
  48. package/src/app.js +988 -0
  49. package/src/index.html +56 -0
  50. package/src/lib/categories.js +55 -0
  51. package/src/lib/export.js +195 -0
  52. package/src/lib/manifest-utils.js +69 -0
  53. package/src/lib/router.js +44 -0
  54. package/src/lib/search.js +151 -0
  55. package/src/manifest.js +246 -0
  56. package/src/mermaid-init.js +207 -0
  57. package/src/sections/archive-future-roadmap.js +7 -0
  58. package/src/sections/archive-initiative-alpha.js +7 -0
  59. package/src/sections/archive-milestone-records.js +7 -0
  60. package/src/sections/archive-timeline-overview.js +7 -0
  61. package/src/sections/core-technology-compliance-frameworks.js +7 -0
  62. package/src/sections/core-technology-coordination-model.js +7 -0
  63. package/src/sections/core-technology-data-definitions.js +7 -0
  64. package/src/sections/core-technology-hardware-integration.js +7 -0
  65. package/src/sections/core-technology-integrity-controls.js +7 -0
  66. package/src/sections/core-technology-network-topology.js +7 -0
  67. package/src/sections/core-technology-operator-requirements.js +7 -0
  68. package/src/sections/core-technology-overview.js +7 -0
  69. package/src/sections/core-technology-service-interfaces.js +7 -0
  70. package/src/sections/core-technology-synchronization-strategy.js +7 -0
  71. package/src/sections/core-technology-system-foundation.js +7 -0
  72. package/src/sections/developers-api-credentials.js +7 -0
  73. package/src/sections/developers-api-operations.js +7 -0
  74. package/src/sections/developers-api-reference.js +7 -0
  75. package/src/sections/developers-api-websocket.js +7 -0
  76. package/src/sections/developers-automation-blueprints.js +7 -0
  77. package/src/sections/developers-automation-modules.js +7 -0
  78. package/src/sections/developers-automation-patterns.js +7 -0
  79. package/src/sections/developers-deployment-playbook.js +7 -0
  80. package/src/sections/developers-overview.js +7 -0
  81. package/src/sections/developers-scheduling-patterns.js +7 -0
  82. package/src/sections/developers-sdk-go.js +7 -0
  83. package/src/sections/developers-sdk-javascript.js +7 -0
  84. package/src/sections/developers-sdk-python.js +7 -0
  85. package/src/sections/developers-sdk-rust.js +7 -0
  86. package/src/sections/developers-sdks.js +7 -0
  87. package/src/sections/developers-solution-examples.js +7 -0
  88. package/src/sections/developers-testing-framework.js +7 -0
  89. package/src/sections/getting-started-architecture-basics.js +7 -0
  90. package/src/sections/getting-started-introduction.js +7 -0
  91. package/src/sections/getting-started-performance-overview.js +7 -0
  92. package/src/sections/governance-community-initiatives.js +7 -0
  93. package/src/sections/governance-dao-overview.js +7 -0
  94. package/src/sections/governance-multi-token.js +7 -0
  95. package/src/sections/governance-overview.js +7 -0
  96. package/src/sections/governance-proposal-process.js +7 -0
  97. package/src/sections/governance-proposals.js +7 -0
  98. package/src/sections/governance-structure.js +7 -0
  99. package/src/sections/governance-token-distribution.js +7 -0
  100. package/src/sections/governance-treasury.js +7 -0
  101. package/src/sections/operations-environment-prep.js +7 -0
  102. package/src/sections/operations-getting-started.js +7 -0
  103. package/src/sections/operations-incentives-guide.js +7 -0
  104. package/src/sections/operations-incentives-strategies.js +7 -0
  105. package/src/sections/operations-incentives.js +7 -0
  106. package/src/sections/operations-infrastructure.js +7 -0
  107. package/src/sections/operations-monitoring.js +7 -0
  108. package/src/sections/operations-overview.js +7 -0
  109. package/src/sections/operations-performance.js +7 -0
  110. package/src/sections/operations-power-infrastructure.js +7 -0
  111. package/src/sections/operations-setup-guide.js +7 -0
  112. package/src/sections/operations-sync-setup.js +7 -0
  113. package/src/sections/products-flagship-solution.js +7 -0
  114. package/src/sections/products-solution-library.js +7 -0
  115. package/src/sections/resources-brand-assets.js +7 -0
  116. package/src/sections/resources-faq.js +7 -0
  117. package/src/sections/resources-glossary.js +7 -0
  118. package/src/sections/resources-research-papers.js +7 -0
  119. package/src/sections/section-templates.js +873 -0
  120. package/src/sections/security-audits.js +7 -0
  121. package/src/sections/security-best-practices.js +7 -0
  122. package/src/sections/security-bug-bounty.js +7 -0
  123. package/src/sections/security-incident-response.js +7 -0
  124. package/src/sections/security-overview.js +7 -0
  125. package/src/sections/technical-architecture.js +7 -0
  126. package/src/sections/technical-whitepaper.js +7 -0
  127. package/src/sections/tutorial-automation-bot.js +7 -0
  128. package/src/sections/tutorial-build-first-integration.js +7 -0
  129. package/src/sections/tutorial-deploy-automation.js +7 -0
  130. package/src/sections/tutorial-event-driven-experience.js +7 -0
  131. package/src/sections/tutorial-operations-onboarding.js +7 -0
  132. package/src/sections/tutorial-systems-integration.js +7 -0
  133. package/src/sections/tutorials-overview.js +7 -0
  134. package/src/sections/use-case-connected-devices.js +7 -0
  135. package/src/sections/use-case-digital-auctions.js +7 -0
  136. package/src/sections/use-case-financial-automation.js +7 -0
  137. package/src/sections/use-case-interactive-media.js +7 -0
  138. package/src/sections/use-case-realtime-execution.js +7 -0
  139. package/src/sections/use-case-research-analytics.js +7 -0
  140. package/src/sections/use-case-supply-operations.js +7 -0
  141. package/src/sections/use-cases-overview.js +7 -0
  142. package/src/sections/welcome-overview.js +7 -0
  143. package/src/seo.js +90 -0
  144. package/src/styles.css +1982 -0
  145. package/src/syntax-highlight.js +90 -0
  146. package/tenants.json.example +68 -0
  147. package/tenants.schema.json +231 -0
@@ -0,0 +1,3569 @@
1
+ #!/usr/bin/env node
2
+ /* Build tenant-specific bundles with optional branding overrides */
3
+ import fs from 'fs';
4
+ import fsp from 'fs/promises';
5
+ import path from 'path';
6
+ import { spawn, execSync } from 'child_process';
7
+ import { createHash } from 'crypto';
8
+ import os from 'os';
9
+ import { generateSeoArtifacts } from './lib/seo-generator.js';
10
+
11
+ const root = process.cwd();
12
+ const DEFAULT_TENANTS_DIR = path.join(root, 'tenants');
13
+ const DEFAULT_DIST_DIR = path.join(root, 'dist');
14
+ const DEFAULT_REGISTRY_PATH = path.join(root, 'tenants.json');
15
+ const DEFAULT_CONTENT_DIR = 'content';
16
+ const TENANT_MANIFEST = 'manifest.json';
17
+ const DIRECTORY_MANIFEST = '_manifest.json';
18
+ const MAX_CONTENT_DEPTH = 10;
19
+
20
+ // Content file extensions for link transformation
21
+ const CONTENT_EXTENSIONS = ['.md', '.markdown', '.html', '.htm', '.js', '.mjs'];
22
+
23
+ // Common abbreviations to preserve in title humanization
24
+ const ABBREVIATIONS = new Set(['api', 'sdk', 'cli', 'ui', 'ux', 'http', 'https', 'html', 'css', 'js', 'json', 'xml', 'sql', 'nosql', 'jwt', 'oauth', 'ocp', 'tap', 'ieee', 'ptp', 'ntp', 'mev', 'dao', 'nft', 'defi', 'faq', 'id', 'ids', 'url', 'urls', 'uri', 'uris']);
25
+
26
+ // Git defaults
27
+ const DEFAULT_GIT_DEPTH = 1;
28
+ const DEFAULT_GIT_TIMEOUT = 5 * 60 * 1000; // 5 minutes
29
+ const DEFAULT_GIT_RETRIES = 3;
30
+ const DEFAULT_CACHE_DIR = path.join(root, '.cache', 'git');
31
+
32
+ /**
33
+ * Parse CLI arguments
34
+ * Usage: node build-tenants.js [tenant1 tenant2 ...] [options]
35
+ */
36
+ function parseArgs(argv) {
37
+ const args = argv.slice(2);
38
+ const result = {
39
+ tenants: [],
40
+ registry: null,
41
+ target: null,
42
+ list: false,
43
+ help: false,
44
+ // Git options
45
+ cacheDir: process.env.GIT_CACHE_DIR || null,
46
+ keepCache: false,
47
+ cleanCache: false,
48
+ gitDepth: parseInt(process.env.GIT_CLONE_DEPTH, 10) || null,
49
+ noSparse: false,
50
+ // Incremental build options
51
+ incremental: false,
52
+ diffOnly: false,
53
+ // Direct file targeting
54
+ files: []
55
+ };
56
+
57
+ let i = 0;
58
+ while (i < args.length) {
59
+ const arg = args[i];
60
+ if (arg === '--target' || arg === '-t') {
61
+ result.target = args[i + 1];
62
+ if (!result.target) {
63
+ console.error('Error: --target requires a path argument');
64
+ process.exit(1);
65
+ }
66
+ i += 2;
67
+ } else if (arg === '--registry' || arg === '-r') {
68
+ result.registry = args[i + 1];
69
+ if (!result.registry) {
70
+ console.error('Error: --registry requires a path argument');
71
+ process.exit(1);
72
+ }
73
+ i += 2;
74
+ } else if (arg === '--cache-dir') {
75
+ result.cacheDir = args[i + 1];
76
+ if (!result.cacheDir) {
77
+ console.error('Error: --cache-dir requires a path argument');
78
+ process.exit(1);
79
+ }
80
+ i += 2;
81
+ } else if (arg === '--git-depth') {
82
+ result.gitDepth = parseInt(args[i + 1], 10);
83
+ if (isNaN(result.gitDepth) || result.gitDepth < 1) {
84
+ console.error('Error: --git-depth requires a positive integer');
85
+ process.exit(1);
86
+ }
87
+ i += 2;
88
+ } else if (arg === '--keep-cache') {
89
+ result.keepCache = true;
90
+ i++;
91
+ } else if (arg === '--clean-cache') {
92
+ result.cleanCache = true;
93
+ i++;
94
+ } else if (arg === '--no-sparse') {
95
+ result.noSparse = true;
96
+ i++;
97
+ } else if (arg === '--incremental' || arg === '-i') {
98
+ result.incremental = true;
99
+ i++;
100
+ } else if (arg === '--diff-only') {
101
+ result.diffOnly = true;
102
+ result.incremental = true; // diff-only implies incremental
103
+ i++;
104
+ } else if (arg === '--files' || arg === '-f') {
105
+ const filesArg = args[i + 1];
106
+ if (!filesArg) {
107
+ console.error('Error: --files requires a comma-separated list of files');
108
+ process.exit(1);
109
+ }
110
+ result.files = filesArg.split(',').map(f => f.trim()).filter(Boolean);
111
+ result.incremental = true; // --files implies incremental mode
112
+ i += 2;
113
+ } else if (arg === '--list' || arg === '-l') {
114
+ result.list = true;
115
+ i++;
116
+ } else if (arg === '--help' || arg === '-h') {
117
+ result.help = true;
118
+ i++;
119
+ } else if (arg.startsWith('-')) {
120
+ console.error(`Error: Unknown option ${arg}`);
121
+ process.exit(1);
122
+ } else {
123
+ result.tenants.push(arg);
124
+ i++;
125
+ }
126
+ }
127
+
128
+ return result;
129
+ }
130
+
131
+ function printHelp() {
132
+ console.log(`
133
+ Usage: node build-tenants.js [tenant...] [options]
134
+
135
+ Build tenant-specific documentation bundles.
136
+
137
+ Arguments:
138
+ tenant One or more tenant IDs to build (default: all tenants)
139
+
140
+ Options:
141
+ -r, --registry Path to tenant registry JSON file (default: tenants.json)
142
+ -t, --target Override target directory for all tenants
143
+ -l, --list List available tenants and exit
144
+ -h, --help Show this help message
145
+
146
+ Git Source Options:
147
+ --cache-dir Directory for caching git clones (default: .cache/git/)
148
+ --keep-cache Preserve git cache after build (default: clean up)
149
+ --clean-cache Force fresh git clones, ignoring cache
150
+ --git-depth Override clone depth for all git sources (default: 1)
151
+ --no-sparse Disable sparse checkout for monorepo paths
152
+
153
+ Incremental Build Options:
154
+ -i, --incremental Enable incremental builds (only rebuild changed content)
155
+ --diff-only Show changed files without building (implies --incremental)
156
+ -f, --files Comma-separated list of content files to rebuild (implies --incremental)
157
+
158
+ Environment Variables:
159
+ TENANT_REGISTRY Path to tenant registry (alternative to --registry)
160
+ GIT_CACHE_DIR Default cache directory for git clones
161
+ GIT_CLONE_DEPTH Default clone depth (default: 1)
162
+ GIT_TERMINAL_PROMPT Set to 0 to disable interactive git prompts (recommended for CI)
163
+ GIT_SSH_COMMAND Custom SSH command (e.g., "ssh -i ~/.ssh/deploy_key")
164
+ GIT_CREDENTIALS HTTPS credentials in "username:token" format (not logged)
165
+
166
+ Registry Format (tenants.json):
167
+ {
168
+ "tenants": [
169
+ {
170
+ "id": "tenant-alpha",
171
+ "enabled": true,
172
+ "source": { "type": "local", "path": "/path/to/source" },
173
+ "target": { "type": "local", "path": "/path/to/target" },
174
+ "domains": ["alpha.example.com"],
175
+ "config": { "title": "Alpha Docs" }
176
+ },
177
+ {
178
+ "id": "tenant-beta",
179
+ "source": {
180
+ "type": "git",
181
+ "url": "https://github.com/org/docs.git",
182
+ "ref": "main",
183
+ "path": "tenant-beta/"
184
+ }
185
+ }
186
+ ]
187
+ }
188
+
189
+ Git Source Properties:
190
+ type "git" (required)
191
+ url Git repository URL (HTTPS or SSH)
192
+ ref Branch, tag, or commit SHA (default: "main")
193
+ path Subdirectory within repo (default: repo root)
194
+ sparse Use sparse checkout for path (default: false)
195
+ depth Clone depth (default: 1)
196
+
197
+ Examples:
198
+ node build-tenants.js Build all tenants
199
+ node build-tenants.js tenant-alpha Build only tenant-alpha
200
+ node build-tenants.js --registry /etc/tenants.json Use external registry
201
+ node build-tenants.js tenant-alpha -t /var/www Build one, override target
202
+ node build-tenants.js --clean-cache Force fresh git clones
203
+ node build-tenants.js --list Show available tenants
204
+ node build-tenants.js --incremental --keep-cache Pull updates, rebuild only changed
205
+ node build-tenants.js --diff-only Show what changed without building
206
+ node build-tenants.js tenant-alpha -f launch-checklist.md Rebuild single file
207
+ `);
208
+ }
209
+
210
+ async function pathExists(target) {
211
+ try {
212
+ await fsp.access(target, fs.constants.F_OK);
213
+ return true;
214
+ } catch {
215
+ return false;
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Expand environment variables in a path string
221
+ * Supports $VAR and ${VAR} syntax
222
+ */
223
+ function expandEnvVars(pathStr) {
224
+ if (!pathStr) return pathStr;
225
+ return pathStr
226
+ .replace(/\$\{([^}]+)\}/g, (_, name) => process.env[name] || '')
227
+ .replace(/\$([A-Z_][A-Z0-9_]*)/gi, (_, name) => process.env[name] || '');
228
+ }
229
+
230
+ /**
231
+ * Resolve a path relative to publisher root, expanding env vars
232
+ */
233
+ function resolvePath(pathStr, base = root) {
234
+ if (!pathStr) return null;
235
+ const expanded = expandEnvVars(pathStr);
236
+ if (path.isAbsolute(expanded)) {
237
+ return expanded;
238
+ }
239
+ return path.resolve(base, expanded);
240
+ }
241
+
242
+ /**
243
+ * Check if git is available
244
+ */
245
+ function isGitAvailable() {
246
+ try {
247
+ execSync('git --version', { stdio: 'pipe' });
248
+ return true;
249
+ } catch {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ /**
255
+ * Execute a command with timeout and retry
256
+ */
257
+ async function execWithRetry(command, options = {}) {
258
+ const {
259
+ cwd = root,
260
+ timeout = DEFAULT_GIT_TIMEOUT,
261
+ retries = DEFAULT_GIT_RETRIES,
262
+ env = process.env
263
+ } = options;
264
+
265
+ let lastError;
266
+ for (let attempt = 1; attempt <= retries; attempt++) {
267
+ try {
268
+ return await new Promise((resolve, reject) => {
269
+ const proc = spawn('sh', ['-c', command], {
270
+ cwd,
271
+ stdio: ['pipe', 'pipe', 'pipe'],
272
+ env: { ...env, GIT_TERMINAL_PROMPT: '0' },
273
+ timeout
274
+ });
275
+
276
+ let stdout = '';
277
+ let stderr = '';
278
+
279
+ proc.stdout.on('data', (data) => { stdout += data.toString(); });
280
+ proc.stderr.on('data', (data) => { stderr += data.toString(); });
281
+
282
+ proc.on('close', (code) => {
283
+ if (code === 0) {
284
+ resolve({ stdout, stderr });
285
+ } else {
286
+ reject(new Error(`Command failed (exit ${code}): ${stderr || stdout}`));
287
+ }
288
+ });
289
+
290
+ proc.on('error', (err) => {
291
+ reject(new Error(`Command error: ${err.message}`));
292
+ });
293
+ });
294
+ } catch (err) {
295
+ lastError = err;
296
+ // Check if this is a retryable error (network issues)
297
+ const isRetryable = err.message.includes('Could not resolve host') ||
298
+ err.message.includes('Connection refused') ||
299
+ err.message.includes('Connection timed out') ||
300
+ err.message.includes('Network is unreachable');
301
+
302
+ if (!isRetryable || attempt === retries) {
303
+ break;
304
+ }
305
+
306
+ // Exponential backoff
307
+ const delay = Math.pow(2, attempt) * 1000;
308
+ console.log(` ↳ Retry ${attempt}/${retries} in ${delay / 1000}s...`);
309
+ await new Promise(resolve => setTimeout(resolve, delay));
310
+ }
311
+ }
312
+ throw lastError;
313
+ }
314
+
315
+ /**
316
+ * Generate cache key for git source
317
+ */
318
+ function getCacheKey(source) {
319
+ const { url, ref = 'main', path: subPath = '' } = source;
320
+ const hash = createHash('sha256')
321
+ .update(`${url}:${ref}:${subPath}`)
322
+ .digest('hex')
323
+ .slice(0, 12);
324
+ return `git-${hash}`;
325
+ }
326
+
327
+ /**
328
+ * Check if a ref is immutable (tag or commit SHA)
329
+ */
330
+ function isImmutableRef(ref) {
331
+ // Commit SHA is 40 hex characters
332
+ if (/^[0-9a-f]{40}$/i.test(ref)) return true;
333
+ // Short SHA is 7+ hex characters (less reliable)
334
+ if (/^[0-9a-f]{7,39}$/i.test(ref)) return true;
335
+ // Version tags typically start with v
336
+ if (/^v?\d+\.\d+/.test(ref)) return true;
337
+ return false;
338
+ }
339
+
340
+ /**
341
+ * Get the current HEAD commit SHA
342
+ */
343
+ async function getHeadCommit(repoDir) {
344
+ try {
345
+ const { stdout } = await execWithRetry(`git -C "${repoDir}" rev-parse HEAD`, { cwd: root, retries: 1 });
346
+ return stdout.trim();
347
+ } catch {
348
+ return null;
349
+ }
350
+ }
351
+
352
+ /**
353
+ * Get list of changed files between two commits
354
+ * Returns { added: [], modified: [], deleted: [] }
355
+ */
356
+ async function getChangedFiles(repoDir, oldCommit, newCommit, subPath = '.') {
357
+ const changes = { added: [], modified: [], deleted: [] };
358
+
359
+ if (!oldCommit || !newCommit || oldCommit === newCommit) {
360
+ return changes;
361
+ }
362
+
363
+ try {
364
+ // Use --name-status to get file status (A=added, M=modified, D=deleted)
365
+ const { stdout } = await execWithRetry(
366
+ `git -C "${repoDir}" diff --name-status ${oldCommit} ${newCommit}`,
367
+ { cwd: root, retries: 1 }
368
+ );
369
+
370
+ const lines = stdout.trim().split('\n').filter(Boolean);
371
+ for (const line of lines) {
372
+ const [status, ...pathParts] = line.split('\t');
373
+ const filePath = pathParts.join('\t'); // Handle filenames with tabs
374
+
375
+ // Filter to subPath if specified
376
+ if (subPath !== '.' && !filePath.startsWith(subPath + '/') && filePath !== subPath) {
377
+ continue;
378
+ }
379
+
380
+ // Normalize path relative to subPath
381
+ const relativePath = subPath === '.' ? filePath : filePath.slice(subPath.length + 1);
382
+
383
+ if (!relativePath) continue;
384
+
385
+ switch (status.charAt(0)) {
386
+ case 'A':
387
+ changes.added.push(relativePath);
388
+ break;
389
+ case 'M':
390
+ changes.modified.push(relativePath);
391
+ break;
392
+ case 'D':
393
+ changes.deleted.push(relativePath);
394
+ break;
395
+ case 'R': // Renamed - treat as add + delete
396
+ changes.deleted.push(relativePath);
397
+ if (pathParts[1]) {
398
+ const newPath = subPath === '.' ? pathParts[1] : pathParts[1].slice(subPath.length + 1);
399
+ if (newPath) changes.added.push(newPath);
400
+ }
401
+ break;
402
+ }
403
+ }
404
+ } catch (err) {
405
+ console.warn(` ↳ could not compute diff: ${err.message}`);
406
+ }
407
+
408
+ return changes;
409
+ }
410
+
411
+ /**
412
+ * Create a change result object
413
+ */
414
+ function createChangeResult(sourcePath, type, oldCommit = null, newCommit = null, files = null) {
415
+ return {
416
+ sourcePath,
417
+ changes: {
418
+ type, // 'full' | 'incremental' | 'none'
419
+ oldCommit,
420
+ newCommit,
421
+ files: files || { added: [], modified: [], deleted: [] }
422
+ }
423
+ };
424
+ }
425
+
426
+ /**
427
+ * Clone or update a git repository with change tracking
428
+ * Returns { sourcePath, changes: { type, oldCommit, newCommit, files } }
429
+ */
430
+ async function cloneGitSource(source, cacheDir, options = {}) {
431
+ const { url, ref = 'main', path: subPath = '.', sparse = false, depth = DEFAULT_GIT_DEPTH } = source;
432
+ const { cleanCache = false, noSparse = false, gitDepth = null, incremental = false } = options;
433
+
434
+ const effectiveDepth = gitDepth || depth;
435
+ const effectiveSparse = sparse && !noSparse && subPath !== '.';
436
+
437
+ const cacheKey = getCacheKey(source);
438
+ const cloneDir = path.join(cacheDir, cacheKey);
439
+
440
+ // Sanitize URL for logging (hide credentials)
441
+ const safeUrl = url.replace(/\/\/[^@]+@/, '//***@');
442
+
443
+ console.log(` ↳ git source: ${safeUrl}`);
444
+ console.log(` ↳ ref: ${ref}, path: ${subPath || '(root)'}`);
445
+
446
+ // Resolve the final source path
447
+ const resolvedPath = subPath === '.' ? cloneDir : path.join(cloneDir, subPath);
448
+
449
+ // Check cache
450
+ const cacheExists = await pathExists(cloneDir);
451
+ let oldCommit = null;
452
+ let newCommit = null;
453
+ let wasCloned = false;
454
+
455
+ if (cacheExists && !cleanCache) {
456
+ const isImmutable = isImmutableRef(ref);
457
+
458
+ if (isImmutable) {
459
+ console.log(` ↳ using cached clone (immutable ref)`);
460
+ // For immutable refs with cache, nothing changed
461
+ newCommit = await getHeadCommit(cloneDir);
462
+
463
+ if (!(await pathExists(resolvedPath))) {
464
+ throw new Error(`Subdirectory '${subPath}' not found in repository ${safeUrl}`);
465
+ }
466
+
467
+ return createChangeResult(resolvedPath, 'none', newCommit, newCommit);
468
+ } else {
469
+ // Mutable ref - need to fetch and check for changes
470
+ oldCommit = await getHeadCommit(cloneDir);
471
+ console.log(` ↳ updating cached clone...`);
472
+ console.log(` ↳ current HEAD: ${oldCommit ? oldCommit.slice(0, 7) : 'unknown'}`);
473
+
474
+ try {
475
+ await execWithRetry(`git -C "${cloneDir}" fetch origin ${ref} --depth ${effectiveDepth}`, { cwd: root });
476
+ await execWithRetry(`git -C "${cloneDir}" checkout FETCH_HEAD`, { cwd: root });
477
+ newCommit = await getHeadCommit(cloneDir);
478
+ console.log(` ↳ updated HEAD: ${newCommit ? newCommit.slice(0, 7) : 'unknown'}`);
479
+ } catch (err) {
480
+ console.warn(` ↳ fetch failed, will re-clone: ${err.message}`);
481
+ await fsp.rm(cloneDir, { recursive: true, force: true });
482
+ oldCommit = null; // Force full rebuild
483
+ }
484
+ }
485
+ }
486
+
487
+ // Clone if not cached or cache was cleared
488
+ if (!(await pathExists(cloneDir))) {
489
+ console.log(` ↳ cloning repository...`);
490
+ await fsp.mkdir(cacheDir, { recursive: true });
491
+ wasCloned = true;
492
+
493
+ // Build clone command
494
+ let cloneCmd = `git clone --depth ${effectiveDepth}`;
495
+
496
+ // Add branch/ref
497
+ // For commits, we can't use --branch, need to fetch after
498
+ if (!isImmutableRef(ref) || /^v?\d+\.\d+/.test(ref)) {
499
+ cloneCmd += ` --branch ${ref}`;
500
+ }
501
+
502
+ // Sparse checkout preparation
503
+ if (effectiveSparse) {
504
+ cloneCmd += ' --filter=blob:none --sparse';
505
+ }
506
+
507
+ cloneCmd += ` "${url}" "${cloneDir}"`;
508
+
509
+ try {
510
+ await execWithRetry(cloneCmd, { cwd: root });
511
+
512
+ // If ref is a commit SHA, checkout after clone
513
+ if (/^[0-9a-f]{7,40}$/i.test(ref) && !/^v?\d+\.\d+/.test(ref)) {
514
+ await execWithRetry(`git -C "${cloneDir}" fetch --depth ${effectiveDepth} origin ${ref}`, { cwd: root });
515
+ await execWithRetry(`git -C "${cloneDir}" checkout ${ref}`, { cwd: root });
516
+ }
517
+
518
+ // Set up sparse checkout if needed
519
+ if (effectiveSparse) {
520
+ await execWithRetry(`git -C "${cloneDir}" sparse-checkout set "${subPath}"`, { cwd: root });
521
+ }
522
+
523
+ newCommit = await getHeadCommit(cloneDir);
524
+ console.log(` ↳ cloned at: ${newCommit ? newCommit.slice(0, 7) : 'unknown'}`);
525
+ } catch (err) {
526
+ // Clean up partial clone on failure
527
+ await fsp.rm(cloneDir, { recursive: true, force: true }).catch(() => {});
528
+
529
+ // Provide helpful error message
530
+ if (err.message.includes('Authentication failed') ||
531
+ err.message.includes('could not read Username')) {
532
+ throw new Error(`Git authentication failed for ${safeUrl}. Check SSH keys or GIT_CREDENTIALS env var.`);
533
+ }
534
+ if (err.message.includes('not found') || err.message.includes('does not exist')) {
535
+ throw new Error(`Git ref '${ref}' not found in ${safeUrl}`);
536
+ }
537
+ throw err;
538
+ }
539
+ }
540
+
541
+ // Validate the path exists
542
+ if (!(await pathExists(resolvedPath))) {
543
+ throw new Error(`Subdirectory '${subPath}' not found in repository ${safeUrl}`);
544
+ }
545
+
546
+ // Determine change type and compute diff if incremental
547
+ if (wasCloned || !oldCommit) {
548
+ // Fresh clone - full build required
549
+ return createChangeResult(resolvedPath, 'full', null, newCommit);
550
+ }
551
+
552
+ if (oldCommit === newCommit) {
553
+ // No changes
554
+ console.log(` ↳ no changes detected`);
555
+ return createChangeResult(resolvedPath, 'none', oldCommit, newCommit);
556
+ }
557
+
558
+ // Changes detected - compute diff if incremental mode
559
+ if (incremental) {
560
+ console.log(` ↳ computing changes...`);
561
+ const files = await getChangedFiles(cloneDir, oldCommit, newCommit, subPath);
562
+ const totalChanges = files.added.length + files.modified.length + files.deleted.length;
563
+ console.log(` ↳ ${totalChanges} file(s) changed (${files.added.length} added, ${files.modified.length} modified, ${files.deleted.length} deleted)`);
564
+ return createChangeResult(resolvedPath, 'incremental', oldCommit, newCommit, files);
565
+ }
566
+
567
+ // Not incremental mode - treat as full build
568
+ return createChangeResult(resolvedPath, 'full', oldCommit, newCommit);
569
+ }
570
+
571
+ /**
572
+ * Resolve source to a local path (handles git cloning)
573
+ * Returns { sourcePath, changes } for git sources, or { sourcePath, changes: null } for local
574
+ */
575
+ async function resolveSource(source, cacheDir, options = {}) {
576
+ if (source.type === 'git') {
577
+ return await cloneGitSource(source, cacheDir, options);
578
+ }
579
+ // Local source - no change tracking
580
+ return {
581
+ sourcePath: source.resolvedPath,
582
+ changes: null // Local sources don't track changes
583
+ };
584
+ }
585
+
586
+ /**
587
+ * Clean up git cache
588
+ */
589
+ async function cleanupCache(cacheDir, keep = false) {
590
+ if (keep) {
591
+ console.log(`Git cache preserved at: ${cacheDir}`);
592
+ return;
593
+ }
594
+ if (await pathExists(cacheDir)) {
595
+ await fsp.rm(cacheDir, { recursive: true, force: true });
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Load tenant registry from JSON file or fall back to directory scan
601
+ */
602
+ async function loadRegistry(registryPath) {
603
+ const effectivePath = registryPath || process.env.TENANT_REGISTRY || DEFAULT_REGISTRY_PATH;
604
+
605
+ // Try to load registry file
606
+ if (await pathExists(effectivePath)) {
607
+ try {
608
+ const raw = await fsp.readFile(effectivePath, 'utf8');
609
+ const registry = JSON.parse(raw);
610
+ console.log(`Loaded registry from ${effectivePath}`);
611
+ return normalizeRegistry(registry);
612
+ } catch (err) {
613
+ console.error(`Error loading registry ${effectivePath}: ${err.message}`);
614
+ process.exit(1);
615
+ }
616
+ }
617
+
618
+ // Fall back to scanning tenants/ directory
619
+ if (await pathExists(DEFAULT_TENANTS_DIR)) {
620
+ console.log('No registry found, scanning tenants/ directory...');
621
+ return await scanTenantsDirectory();
622
+ }
623
+
624
+ // No registry and no tenants directory
625
+ return { tenants: [], defaults: getDefaultConfig() };
626
+ }
627
+
628
+ /**
629
+ * Get default source/target configuration
630
+ */
631
+ function getDefaultConfig() {
632
+ return {
633
+ source: { type: 'local', path: './tenants' },
634
+ target: { type: 'local', path: './dist' }
635
+ };
636
+ }
637
+
638
+ /**
639
+ * Normalize registry structure and apply defaults
640
+ */
641
+ function normalizeRegistry(registry) {
642
+ const defaults = { ...getDefaultConfig(), ...(registry.defaults || {}) };
643
+ const tenants = (registry.tenants || []).map(tenant => normalizeTenant(tenant, defaults));
644
+ return { tenants, defaults };
645
+ }
646
+
647
+ /**
648
+ * Normalize a single tenant entry with defaults
649
+ */
650
+ function normalizeTenant(tenant, defaults) {
651
+ const id = tenant.id;
652
+ if (!id) {
653
+ throw new Error('Tenant entry missing required "id" field');
654
+ }
655
+
656
+ // Source resolution
657
+ let source = tenant.source || {};
658
+ const sourceType = source.type || 'local';
659
+
660
+ if (sourceType === 'git') {
661
+ // Git source - validate required fields
662
+ if (!source.url) {
663
+ throw new Error(`Tenant ${id}: git source requires 'url' field`);
664
+ }
665
+ source = {
666
+ type: 'git',
667
+ url: source.url,
668
+ ref: source.ref || 'main',
669
+ path: source.path || '.',
670
+ sparse: source.sparse || false,
671
+ depth: source.depth || DEFAULT_GIT_DEPTH
672
+ };
673
+ } else {
674
+ // Local source
675
+ if (!source.path) {
676
+ const basePath = defaults.source?.path || './tenants';
677
+ source = { type: 'local', path: path.join(basePath, id) };
678
+ }
679
+ source.type = 'local';
680
+ source.resolvedPath = resolvePath(source.path);
681
+ }
682
+
683
+ // Target resolution
684
+ let target = tenant.target || {};
685
+ if (!target.path) {
686
+ const basePath = defaults.target?.path || './dist';
687
+ target = { type: 'local', path: path.join(basePath, id) };
688
+ }
689
+ target.type = target.type || 'local';
690
+ target.resolvedPath = resolvePath(target.path);
691
+
692
+ return {
693
+ id,
694
+ enabled: tenant.enabled !== false,
695
+ source,
696
+ target,
697
+ domains: tenant.domains || [],
698
+ config: tenant.config || {},
699
+ strictLinks: tenant.strictLinks,
700
+ followLinks: tenant.followLinks || false
701
+ };
702
+ }
703
+
704
+ /**
705
+ * Scan tenants/ directory and create registry entries for each subdirectory
706
+ */
707
+ async function scanTenantsDirectory() {
708
+ const defaults = getDefaultConfig();
709
+ const entries = await fsp.readdir(DEFAULT_TENANTS_DIR, { withFileTypes: true });
710
+ const tenants = entries
711
+ .filter(entry => entry.isDirectory())
712
+ .map(entry => normalizeTenant({ id: entry.name }, defaults));
713
+
714
+ return { tenants, defaults };
715
+ }
716
+
717
+ function escapeHtml(value) {
718
+ return String(value)
719
+ .replace(/&/g, '&amp;')
720
+ .replace(/</g, '&lt;')
721
+ .replace(/>/g, '&gt;')
722
+ .replace(/"/g, '&quot;')
723
+ .replace(/'/g, '&#39;');
724
+ }
725
+
726
+ function escapeAttr(value) {
727
+ return escapeHtml(value).replace(/`/g, '&#96;');
728
+ }
729
+
730
+ function escapeAttribute(value) {
731
+ return String(value)
732
+ .replace(/&/g, '&amp;')
733
+ .replace(/"/g, '&quot;')
734
+ .replace(/'/g, '&#39;')
735
+ .replace(/</g, '&lt;')
736
+ .replace(/>/g, '&gt;');
737
+ }
738
+
739
+ async function runBuild(buildOutput) {
740
+ return new Promise((resolve, reject) => {
741
+ const proc = spawn(process.execPath, [path.join('scripts', 'build.js')], {
742
+ cwd: root,
743
+ stdio: 'inherit',
744
+ env: { ...process.env, BUILD_OUTPUT: buildOutput }
745
+ });
746
+ proc.on('exit', (code) => {
747
+ if (code === 0) return resolve();
748
+ reject(new Error(`Build failed for ${buildOutput} (exit ${code})`));
749
+ });
750
+ proc.on('error', reject);
751
+ });
752
+ }
753
+
754
+ async function copyDirectory(from, to) {
755
+ const entries = await fsp.readdir(from, { withFileTypes: true });
756
+ await fsp.mkdir(to, { recursive: true });
757
+ for (const entry of entries) {
758
+ const sourcePath = path.join(from, entry.name);
759
+ const destPath = path.join(to, entry.name);
760
+ if (entry.isDirectory()) {
761
+ await copyDirectory(sourcePath, destPath);
762
+ } else if (entry.isFile()) {
763
+ await fsp.mkdir(path.dirname(destPath), { recursive: true });
764
+ await fsp.copyFile(sourcePath, destPath);
765
+ }
766
+ }
767
+ }
768
+
769
+ /**
770
+ * Copy static assets from .public/ directory to dist
771
+ * Assets are copied to dist/assets/ and favicon files are also copied to dist root
772
+ *
773
+ * @param {string} sourceDir - Tenant source directory
774
+ * @param {string} distDir - Build output directory
775
+ * @param {string} tenantId - Tenant identifier
776
+ */
777
+ async function copyPublicAssets(sourceDir, distDir, tenantId) {
778
+ const publicDir = path.join(sourceDir, '.public');
779
+
780
+ // Check if .public directory exists
781
+ if (!(await pathExists(publicDir))) {
782
+ return;
783
+ }
784
+
785
+ const assetsDir = path.join(distDir, 'assets');
786
+ await fsp.mkdir(assetsDir, { recursive: true });
787
+
788
+ let assetCount = 0;
789
+ const faviconFiles = [];
790
+
791
+ /**
792
+ * Recursively copy files from source to destination
793
+ */
794
+ async function copyAssets(srcDir, destDir) {
795
+ const entries = await fsp.readdir(srcDir, { withFileTypes: true });
796
+
797
+ for (const entry of entries) {
798
+ const srcPath = path.join(srcDir, entry.name);
799
+ const destPath = path.join(destDir, entry.name);
800
+
801
+ if (entry.isDirectory()) {
802
+ await fsp.mkdir(destPath, { recursive: true });
803
+ await copyAssets(srcPath, destPath);
804
+ } else if (entry.isFile()) {
805
+ await fsp.copyFile(srcPath, destPath);
806
+ assetCount++;
807
+
808
+ // Track favicon files for root copy
809
+ const lowerName = entry.name.toLowerCase();
810
+ if (lowerName === 'favicon.ico' || lowerName === 'favicon.png' || lowerName === 'favicon.svg') {
811
+ faviconFiles.push({ srcPath, filename: entry.name });
812
+ }
813
+ }
814
+ }
815
+ }
816
+
817
+ // Copy all assets to dist/assets/
818
+ await copyAssets(publicDir, assetsDir);
819
+
820
+ // Also copy favicon files to dist root for browser auto-detection
821
+ for (const { srcPath, filename } of faviconFiles) {
822
+ const rootDestPath = path.join(distDir, filename);
823
+ await fsp.copyFile(srcPath, rootDestPath);
824
+ }
825
+
826
+ if (assetCount > 0) {
827
+ console.log(` ↳ copied ${assetCount} asset(s) from .public/`);
828
+ }
829
+ }
830
+
831
+ /**
832
+ * Read and base64-encode a logo file from .public directory
833
+ * @param {string} publicDir - Path to .public directory
834
+ * @param {string} [logoPath] - Specific logo filename or auto-detect
835
+ * @returns {Promise<string|null>} Data URI or null if not found
836
+ */
837
+ async function embedLogo(publicDir, logoPath = null) {
838
+ const candidates = logoPath
839
+ ? [logoPath]
840
+ : ['logo.png', 'logo.svg', 'logo.jpg', 'favicon.png', 'favicon.svg'];
841
+
842
+ for (const filename of candidates) {
843
+ const fullPath = path.join(publicDir, filename);
844
+ if (await pathExists(fullPath)) {
845
+ const buffer = await fsp.readFile(fullPath);
846
+ const ext = path.extname(filename).toLowerCase();
847
+ const mimeTypes = {
848
+ '.png': 'image/png',
849
+ '.jpg': 'image/jpeg',
850
+ '.jpeg': 'image/jpeg',
851
+ '.svg': 'image/svg+xml',
852
+ '.gif': 'image/gif'
853
+ };
854
+ const mime = mimeTypes[ext] || 'image/png';
855
+ const sizeKB = Math.round(buffer.length / 1024);
856
+ if (sizeKB > 500) {
857
+ console.warn(` ⚠ Logo ${filename} is ${sizeKB}KB (recommend < 500KB)`);
858
+ }
859
+ return `data:${mime};base64,${buffer.toString('base64')}`;
860
+ }
861
+ }
862
+ return null;
863
+ }
864
+
865
+ /**
866
+ * Build export configuration from tenant config
867
+ * @param {object} config - Tenant config.json contents
868
+ * @param {string} sourceDir - Tenant source directory
869
+ * @returns {Promise<object>} Export configuration for manifest.js
870
+ */
871
+ async function buildExportConfig(config, sourceDir) {
872
+ const exportSettings = config.export || {};
873
+ const publicDir = path.join(sourceDir, '.public');
874
+
875
+ let logo = null;
876
+ const logoMode = exportSettings.logo !== undefined ? exportSettings.logo : 'embed';
877
+
878
+ if (logoMode === 'embed') {
879
+ logo = await embedLogo(publicDir, exportSettings.logoPath);
880
+ } else if (logoMode === 'reference' && exportSettings.logoPath) {
881
+ logo = `./assets/${exportSettings.logoPath}`;
882
+ }
883
+
884
+ return {
885
+ title: config.title || 'Documentation',
886
+ brandMark: config.brandMark || 'Docs',
887
+ brandSub: config.brandSub || '',
888
+ tagline: config.tagline || '',
889
+ logo,
890
+ showTagline: exportSettings.showTagline !== false,
891
+ showDate: exportSettings.showDate !== false
892
+ };
893
+ }
894
+
895
+ async function applyBranding(distDir, config, tenantId) {
896
+ const indexPath = path.join(distDir, 'index.html');
897
+ if (!(await pathExists(indexPath))) return;
898
+ let html = await fsp.readFile(indexPath, 'utf8');
899
+ let mutated = false;
900
+
901
+ if (config.title) {
902
+ html = html.replace(/<title>[^<]*<\/title>/, `<title>${escapeHtml(config.title)}</title>`);
903
+ mutated = true;
904
+ }
905
+ if (config.description) {
906
+ html = html.replace(
907
+ /<meta name="description" content="[^"]*" \/>/,
908
+ `<meta name="description" content="${escapeAttr(config.description)}" />`
909
+ );
910
+ mutated = true;
911
+ }
912
+ if (config.brandMark) {
913
+ html = html.replace(
914
+ /class="brand-mark">[^<]*</,
915
+ `class="brand-mark">${escapeHtml(config.brandMark)}<`
916
+ );
917
+ mutated = true;
918
+ }
919
+ if (config.brandSub) {
920
+ html = html.replace(
921
+ /class="brand-sub">[^<]*</,
922
+ `class="brand-sub">${escapeHtml(config.brandSub)}<`
923
+ );
924
+ mutated = true;
925
+ }
926
+ if (config.tagline) {
927
+ html = html.replace(
928
+ /<span>Reusable patterns for multi-tenant services\.<\/span>/,
929
+ `<span>${escapeHtml(config.tagline)}</span>`
930
+ );
931
+ mutated = true;
932
+ }
933
+ if (config.copyright) {
934
+ html = html.replace(
935
+ /Modular Documentation Toolkit/,
936
+ escapeHtml(config.copyright)
937
+ );
938
+ mutated = true;
939
+ }
940
+
941
+ if (mutated) {
942
+ await fsp.writeFile(indexPath, html, 'utf8');
943
+ console.log(` ↳ applied branding to ${tenantId}`);
944
+ }
945
+ }
946
+
947
+ function hexToRgb(hex) {
948
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
949
+ if (!result) return null;
950
+ return `${parseInt(result[1], 16)}, ${parseInt(result[2], 16)}, ${parseInt(result[3], 16)}`;
951
+ }
952
+
953
+ /**
954
+ * Theme presets for light and dark modes
955
+ * All CSS variables that need to be set for a complete theme
956
+ */
957
+ const THEME_PRESETS = {
958
+ light: {
959
+ colorScheme: 'light',
960
+ surface: '#ffffff',
961
+ ink: '#0b0b0b',
962
+ muted: '#5a5a5a',
963
+ accent: '#111111',
964
+ gridLine: 'rgba(0, 0, 0, 0.08)',
965
+ highlightBg: 'rgba(255, 214, 126, 0.45)',
966
+ highlightBorder: 'rgba(0, 0, 0, 0.35)',
967
+ // Hardcoded values in CSS that need dark mode overrides
968
+ codeBackground: 'rgba(0, 0, 0, 0.04)',
969
+ codeBorder: 'rgba(0, 0, 0, 0.08)',
970
+ hoverBackground: 'rgba(0, 0, 0, 0.03)',
971
+ activeBackground: 'rgba(0, 0, 0, 0.05)',
972
+ inputBackground: 'rgba(0, 0, 0, 0.02)',
973
+ tableHeaderBg: 'rgba(0, 0, 0, 0.04)',
974
+ tableBorder: 'rgba(0, 0, 0, 0.12)',
975
+ blockquoteBorder: 'rgba(0, 0, 0, 0.2)',
976
+ sidebarBg: 'white',
977
+ modalBg: 'white',
978
+ modalBorder: '#000'
979
+ },
980
+ dark: {
981
+ colorScheme: 'dark',
982
+ surface: '#0a0a0e',
983
+ ink: '#e0e0e0',
984
+ muted: '#888888',
985
+ accent: '#22d3ee',
986
+ gridLine: 'rgba(255, 255, 255, 0.08)',
987
+ highlightBg: 'rgba(34, 211, 238, 0.15)',
988
+ highlightBorder: 'rgba(34, 211, 238, 0.4)',
989
+ // Dark mode specific overrides
990
+ codeBackground: 'rgba(255, 255, 255, 0.05)',
991
+ codeBorder: 'rgba(255, 255, 255, 0.1)',
992
+ hoverBackground: 'rgba(255, 255, 255, 0.05)',
993
+ activeBackground: 'rgba(255, 255, 255, 0.08)',
994
+ inputBackground: 'rgba(255, 255, 255, 0.03)',
995
+ tableHeaderBg: 'rgba(255, 255, 255, 0.05)',
996
+ tableBorder: 'rgba(255, 255, 255, 0.12)',
997
+ blockquoteBorder: 'rgba(255, 255, 255, 0.2)',
998
+ sidebarBg: '#151518',
999
+ modalBg: '#1a1a1e',
1000
+ modalBorder: '#333'
1001
+ },
1002
+ matrix: {
1003
+ colorScheme: 'dark',
1004
+ surface: '#050509',
1005
+ ink: '#00ff00',
1006
+ muted: '#00cc00',
1007
+ accent: '#00ff00',
1008
+ gridLine: 'rgba(0, 255, 0, 0.15)',
1009
+ highlightBg: 'rgba(0, 255, 0, 0.15)',
1010
+ highlightBorder: 'rgba(0, 255, 0, 0.4)',
1011
+ // Matrix-specific overrides (green-on-black terminal style)
1012
+ codeBackground: 'rgba(0, 255, 0, 0.05)',
1013
+ codeBorder: 'rgba(0, 255, 0, 0.15)',
1014
+ hoverBackground: 'rgba(0, 255, 0, 0.08)',
1015
+ activeBackground: 'rgba(0, 255, 0, 0.12)',
1016
+ inputBackground: 'rgba(0, 255, 0, 0.03)',
1017
+ tableHeaderBg: 'rgba(0, 255, 0, 0.08)',
1018
+ tableBorder: 'rgba(0, 255, 0, 0.2)',
1019
+ blockquoteBorder: 'rgba(0, 255, 0, 0.3)',
1020
+ sidebarBg: '#0a0f0a',
1021
+ modalBg: '#0f150f',
1022
+ modalBorder: '#00cc00'
1023
+ }
1024
+ };
1025
+
1026
+ /**
1027
+ * Apply theme configuration to styles.css
1028
+ * Supports:
1029
+ * - theme: "light" | "dark" (presets)
1030
+ * - theme: { ...custom colors } (full customization)
1031
+ * - Legacy: accentColor, surfaceColor (backwards compatible)
1032
+ * - Fonts: fontBody, fontMono
1033
+ */
1034
+ async function applyThemeColors(distDir, config, tenantId) {
1035
+ const stylesPath = path.join(distDir, 'styles.css');
1036
+ if (!(await pathExists(stylesPath))) return;
1037
+
1038
+ let css = await fsp.readFile(stylesPath, 'utf8');
1039
+ let modified = false;
1040
+
1041
+ // Determine theme settings
1042
+ let theme = {};
1043
+
1044
+ // Start with light preset as base
1045
+ Object.assign(theme, THEME_PRESETS.light);
1046
+
1047
+ // Apply preset if specified
1048
+ if (config.theme === 'dark') {
1049
+ Object.assign(theme, THEME_PRESETS.dark);
1050
+ } else if (config.theme === 'matrix') {
1051
+ Object.assign(theme, THEME_PRESETS.matrix);
1052
+ } else if (typeof config.theme === 'object' && config.theme !== null) {
1053
+ // Custom theme object - merge with current base
1054
+ Object.assign(theme, config.theme);
1055
+ }
1056
+
1057
+ // Legacy support: individual color overrides
1058
+ if (config.accentColor) theme.accent = config.accentColor;
1059
+ if (config.surfaceColor) theme.surface = config.surfaceColor;
1060
+ if (config.inkColor) theme.ink = config.inkColor;
1061
+ if (config.mutedColor) theme.muted = config.mutedColor;
1062
+ if (config.gridLineColor) theme.gridLine = config.gridLineColor;
1063
+
1064
+ // Font overrides
1065
+ if (config.fontBody) theme.fontBody = config.fontBody;
1066
+ if (config.fontMono) theme.fontMono = config.fontMono;
1067
+
1068
+ // Check if any customization is needed
1069
+ const hasCustomization = config.theme || config.accentColor || config.surfaceColor ||
1070
+ config.inkColor || config.mutedColor || config.gridLineColor ||
1071
+ config.fontBody || config.fontMono;
1072
+
1073
+ if (!hasCustomization) return;
1074
+
1075
+ // Apply CSS variable replacements
1076
+ const replacements = [
1077
+ { pattern: /(color-scheme:\s*)([^;]+);/, value: theme.colorScheme },
1078
+ { pattern: /(--surface:\s*)([^;]+);/, value: theme.surface },
1079
+ { pattern: /(--ink:\s*)([^;]+);/, value: theme.ink },
1080
+ { pattern: /(--muted:\s*)([^;]+);/, value: theme.muted },
1081
+ { pattern: /(--accent:\s*)([^;]+);/, value: theme.accent },
1082
+ { pattern: /(--grid-line:\s*)([^;]+);/, value: theme.gridLine },
1083
+ { pattern: /(--highlight-bg:\s*)([^;]+);/, value: theme.highlightBg },
1084
+ { pattern: /(--highlight-border:\s*)([^;]+);/, value: theme.highlightBorder }
1085
+ ];
1086
+
1087
+ // Apply font replacements if specified
1088
+ if (theme.fontBody) {
1089
+ replacements.push({ pattern: /(--font-body:\s*)([^;]+);/, value: theme.fontBody });
1090
+ }
1091
+ if (theme.fontMono) {
1092
+ replacements.push({ pattern: /(--font-mono:\s*)([^;]+);/, value: theme.fontMono });
1093
+ }
1094
+
1095
+ for (const { pattern, value } of replacements) {
1096
+ if (value) {
1097
+ const updated = css.replace(pattern, `$1${value};`);
1098
+ if (updated !== css) {
1099
+ css = updated;
1100
+ modified = true;
1101
+ }
1102
+ }
1103
+ }
1104
+
1105
+ // Update --surface-rgb for rgba() usage
1106
+ if (theme.surface) {
1107
+ const rgb = hexToRgb(theme.surface);
1108
+ if (rgb) {
1109
+ const updated = css.replace(/(--surface-rgb:\s*)([^;]+);/, `$1${rgb};`);
1110
+ if (updated !== css) {
1111
+ css = updated;
1112
+ modified = true;
1113
+ }
1114
+ }
1115
+ }
1116
+
1117
+ // For dark/matrix themes, apply additional hardcoded color overrides
1118
+ const isDarkMode = config.theme === 'dark' || config.theme === 'matrix' ||
1119
+ (typeof config.theme === 'object' && config.theme?.colorScheme === 'dark');
1120
+ if (isDarkMode) {
1121
+ const darkOverrides = [
1122
+ // Code blocks
1123
+ { pattern: /background:\s*rgba\(0,\s*0,\s*0,\s*0\.04\)/g, value: `background: ${theme.codeBackground}` },
1124
+ { pattern: /border:\s*1px solid rgba\(0,\s*0,\s*0,\s*0\.08\)/g, value: `border: 1px solid ${theme.codeBorder}` },
1125
+ // Hover states
1126
+ { pattern: /background:\s*rgba\(0,\s*0,\s*0,\s*0\.03\)/g, value: `background: ${theme.hoverBackground}` },
1127
+ { pattern: /background:\s*rgba\(0,\s*0,\s*0,\s*0\.05\)/g, value: `background: ${theme.activeBackground}` },
1128
+ // Input backgrounds
1129
+ { pattern: /background:\s*rgba\(0,\s*0,\s*0,\s*0\.02\)/g, value: `background: ${theme.inputBackground}` },
1130
+ // Table styling
1131
+ { pattern: /border:\s*1px solid rgba\(0,\s*0,\s*0,\s*0\.12\)/g, value: `border: 1px solid ${theme.tableBorder}` },
1132
+ // Blockquote border
1133
+ { pattern: /border-left:\s*3px solid rgba\(0,\s*0,\s*0,\s*0\.2\)/g, value: `border-left: 3px solid ${theme.blockquoteBorder}` },
1134
+ // Sidebar background (mobile)
1135
+ { pattern: /background:\s*white;/g, value: `background: ${theme.sidebarBg};` },
1136
+ // Modal backgrounds
1137
+ { pattern: /background:\s*white;\s*\n\s*border:\s*2px solid #000;/g, value: `background: ${theme.modalBg};\n border: 2px solid ${theme.modalBorder};` }
1138
+ ];
1139
+
1140
+ for (const { pattern, value } of darkOverrides) {
1141
+ const updated = css.replace(pattern, value);
1142
+ if (updated !== css) {
1143
+ css = updated;
1144
+ modified = true;
1145
+ }
1146
+ }
1147
+ }
1148
+
1149
+ if (modified) {
1150
+ await fsp.writeFile(stylesPath, css, 'utf8');
1151
+ const themeLabel = config.theme === 'dark' ? 'dark theme' :
1152
+ config.theme === 'matrix' ? 'matrix theme' :
1153
+ config.theme === 'light' ? 'light theme' :
1154
+ typeof config.theme === 'object' ? 'custom theme' : 'theme colors';
1155
+ console.log(` ↳ applied ${themeLabel} for ${tenantId}`);
1156
+ }
1157
+ }
1158
+
1159
+ /**
1160
+ * Apply nav position configuration to styles.css
1161
+ * Supports: navPosition: "left" (default) | "right"
1162
+ */
1163
+ async function applyNavPosition(distDir, config, tenantId) {
1164
+ if (config.navPosition !== 'right') return;
1165
+
1166
+ const stylesPath = path.join(distDir, 'styles.css');
1167
+ if (!(await pathExists(stylesPath))) return;
1168
+
1169
+ let css = await fsp.readFile(stylesPath, 'utf8');
1170
+
1171
+ // CSS rules to flip the layout for right-side nav
1172
+ const navRightCSS = `
1173
+ /* ─────────────────────────────────────────────────────────
1174
+ Nav Position: Right
1175
+ ───────────────────────────────────────────────────────── */
1176
+
1177
+ .layout {
1178
+ grid-template-columns: minmax(0, 1fr) minmax(15rem, 20rem);
1179
+ }
1180
+
1181
+ .sidebar {
1182
+ order: 2;
1183
+ border-right: none;
1184
+ border-left: 1px solid var(--grid-line);
1185
+ }
1186
+
1187
+ .canvas {
1188
+ order: 1;
1189
+ }
1190
+
1191
+ /* Mobile adjustments for right nav */
1192
+ @media (max-width: 960px) {
1193
+ .sidebar {
1194
+ left: auto;
1195
+ right: -100%;
1196
+ border-left: 2px solid var(--ink);
1197
+ border-right: none;
1198
+ }
1199
+
1200
+ .sidebar.mobile-open {
1201
+ left: auto;
1202
+ right: 0;
1203
+ }
1204
+ }
1205
+ `;
1206
+
1207
+ // Append the nav position CSS at the end
1208
+ css += navRightCSS;
1209
+ await fsp.writeFile(stylesPath, css, 'utf8');
1210
+ console.log(` ↳ applied right-side nav position for ${tenantId}`);
1211
+ }
1212
+
1213
+ function renderList(items, tag) {
1214
+ if (!Array.isArray(items) || items.length === 0) return '';
1215
+ const lis = items.map((item) => ` <li>${escapeHtml(item)}</li>`).join('\n');
1216
+ return `<${tag}>\n${lis}\n </${tag}>`;
1217
+ }
1218
+
1219
+ function renderLinks(links) {
1220
+ if (!Array.isArray(links) || links.length === 0) return '';
1221
+ const lis = links
1222
+ .map((link) => {
1223
+ if (!link || typeof link !== 'object') return '';
1224
+ const href = link.href ? escapeAttr(link.href) : '#';
1225
+ const label = escapeHtml(link.label || 'Link');
1226
+ return ` <li><a href="${href}">${label}</a></li>`;
1227
+ })
1228
+ .filter(Boolean)
1229
+ .join('\n');
1230
+ return `<ul>\n${lis}\n </ul>`;
1231
+ }
1232
+
1233
+ async function applyWelcome(distDir, config, tenantId) {
1234
+ if (!config.welcome) return;
1235
+ const welcomePath = path.join(distDir, 'sections', 'welcome-overview.js');
1236
+ if (!(await pathExists(welcomePath))) return;
1237
+ const welcome = config.welcome;
1238
+ const eyebrow = escapeHtml(welcome.eyebrow || 'Welcome');
1239
+ const headline = escapeHtml(welcome.headline || 'Welcome');
1240
+ const lead = escapeHtml(welcome.lead || 'Tailor this intro per tenant.');
1241
+ const pillars = renderList(welcome.pillars, 'ul');
1242
+ const checklist = renderList(welcome.checklist, 'ol');
1243
+ const links = renderLinks(welcome.quickLinks);
1244
+ const quote = welcome.quote ? escapeHtml(welcome.quote) : null;
1245
+
1246
+ const sections = [
1247
+ ' <div class="doc-content">',
1248
+ ' <header>',
1249
+ ` <p class="eyebrow">${eyebrow}</p>`,
1250
+ ` <h1>${headline}</h1>`,
1251
+ ` <p class="lead">${lead}</p>`,
1252
+ ' </header>'
1253
+ ];
1254
+
1255
+ if (pillars) {
1256
+ sections.push(' <div>', ' <h2>Value pillars</h2>', ` ${pillars}`, ' </div>');
1257
+ }
1258
+ if (checklist) {
1259
+ sections.push(' <div>', ' <h2>Launch checklist</h2>', ` ${checklist}`, ' </div>');
1260
+ }
1261
+ if (links) {
1262
+ sections.push(' <aside>', ' <h3>Quick links</h3>', ` ${links}`, ' </aside>');
1263
+ }
1264
+ if (quote) {
1265
+ sections.push(' <blockquote>', ` <p>${quote}</p>`, ' </blockquote>');
1266
+ }
1267
+ sections.push(' </div>');
1268
+
1269
+ const html = [
1270
+ '<section class="section doc" data-template="welcome">',
1271
+ ...sections,
1272
+ '</section>'
1273
+ ].join('\n');
1274
+
1275
+ const moduleSource = `export async function load() {\n return { html: ${JSON.stringify(html)} };\n}\n`;
1276
+ await fsp.writeFile(welcomePath, moduleSource, 'utf8');
1277
+ console.log(` ↳ customized welcome section for ${tenantId}`);
1278
+ }
1279
+
1280
+ /**
1281
+ * Parse inline markdown elements (links, bold, italic)
1282
+ *
1283
+ * @param {string} input - Raw markdown text
1284
+ * @param {object} [linkContext] - Optional context for link transformation
1285
+ * @param {string} linkContext.currentPath - Current file path relative to content root
1286
+ * @param {Map} linkContext.sectionIndex - Section ID map for validation
1287
+ * @param {Array} linkContext.linkWarnings - Array to collect warnings
1288
+ * @returns {string} HTML string
1289
+ */
1290
+ function parseInlineMarkdown(input, linkContext = null) {
1291
+ if (!input) return '';
1292
+ let output = input.replace(/\r\n/g, '\n');
1293
+
1294
+ // @-mention links: @docs/path/file.md -> [title](#section-id)
1295
+ // Resolved against sectionIndex before regular link processing.
1296
+ // @.aiwg/path/file.md references are outside the docsite — rendered as inline code.
1297
+ if (linkContext && linkContext.sectionIndex) {
1298
+ output = output.replace(/@(docs\/|\.aiwg\/)([^\s,);>\]`'"]+)/g, (match, prefix, ref) => {
1299
+ if (prefix === 'docs/') {
1300
+ // Strip trailing punctuation that is unlikely part of the path
1301
+ const cleanRef = ref.replace(/[.,;:!?]+$/, '');
1302
+ const sectionId = pathToSectionId(cleanRef);
1303
+ if (!sectionId) return match;
1304
+ const sectionIdLower = sectionId.toLowerCase();
1305
+ for (const [id, info] of linkContext.sectionIndex) {
1306
+ if (!id) continue;
1307
+ if (id.toLowerCase() === sectionIdLower) {
1308
+ const title = (info && info.title) ? info.title : id;
1309
+ // Produce a regular markdown link — picked up by the link regex below
1310
+ return `[${title}](#${id})`;
1311
+ }
1312
+ }
1313
+ // Unresolved @docs/ reference — render as inline code
1314
+ return `\`${match}\``;
1315
+ }
1316
+ // @.aiwg/ references — outside docsite, render as inline code
1317
+ return `\`@${prefix}${ref}\``;
1318
+ });
1319
+ }
1320
+
1321
+ // Images: ![alt](src) - must be processed before links
1322
+ output = output.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, src) => {
1323
+ return `<img src="${escapeAttribute(src)}" alt="${escapeAttribute(alt)}">`;
1324
+ });
1325
+ // Links: [label](href)
1326
+ output = output.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, label, href) => {
1327
+ // Transform internal links if context is provided
1328
+ const resolvedHref = linkContext
1329
+ ? transformInternalLink(href, linkContext)
1330
+ : href;
1331
+ // External links open in new tab by default
1332
+ const isExternal = /^https?:\/\//i.test(resolvedHref);
1333
+ const attrs = isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
1334
+ // Don't escape label here - it will be escaped by the final escapeHtml call
1335
+ return `<a href="${escapeAttribute(resolvedHref)}"${attrs}>${label}</a>`;
1336
+ });
1337
+ // Bold: **text**
1338
+ output = output.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
1339
+ // Italic: *text* (single asterisks, after bold replacement)
1340
+ output = output.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>');
1341
+ // Italic: _text_ (only at word boundaries, not inside HTML attributes like target="_blank")
1342
+ output = output.replace(/(?<!["\w])_([^_]+)_(?!["\w])/g, '<em>$1</em>');
1343
+ return escapeHtml(output)
1344
+ .replace(/&lt;strong&gt;([^]*?)&lt;\/strong&gt;/g, '<strong>$1</strong>')
1345
+ .replace(/&lt;em&gt;([^]*?)&lt;\/em&gt;/g, '<em>$1</em>')
1346
+ .replace(/&lt;img src=&quot;([^&]*)&quot; alt=&quot;([^&]*)&quot;&gt;/g, '<img src="$1" alt="$2">')
1347
+ .replace(/&lt;a href=&quot;([^&]*)&quot;(?: target=&quot;_blank&quot; rel=&quot;noopener noreferrer&quot;)?&gt;([^]*?)&lt;\/a&gt;/g, (_, href, text) => {
1348
+ const isExternal = /^https?:\/\//i.test(href);
1349
+ const attrs = isExternal ? ' target="_blank" rel="noopener noreferrer"' : '';
1350
+ return `<a href="${href}"${attrs}>${text}</a>`;
1351
+ })
1352
+ // Inline span with title attribute for tooltips: <span title="...">text</span>
1353
+ .replace(/&lt;span title=&quot;([^&]*)&quot;&gt;([^]*?)&lt;\/span&gt;/g, '<span title="$1">$2</span>');
1354
+ }
1355
+
1356
+ /**
1357
+ * Convert markdown content to HTML
1358
+ *
1359
+ * @param {string} markdown - Raw markdown content
1360
+ * @param {object} [linkContext] - Optional context for link transformation
1361
+ * @returns {string} HTML string
1362
+ */
1363
+ function markdownToHtml(markdown, linkContext = null) {
1364
+ const lines = markdown.replace(/\r\n/g, '\n').split('\n');
1365
+ const chunks = [];
1366
+ let inList = false;
1367
+ let inBlockquote = false;
1368
+ let inCodeBlock = false;
1369
+ let codeBlockContent = [];
1370
+ let codeBlockLang = '';
1371
+ let inTable = false;
1372
+ let tableRows = [];
1373
+ const headingIds = new Map(); // Track heading IDs for uniqueness
1374
+
1375
+ function closeList() {
1376
+ if (inList) {
1377
+ chunks.push('</ul>');
1378
+ inList = false;
1379
+ }
1380
+ }
1381
+
1382
+ function openBlockquote() {
1383
+ if (!inBlockquote) {
1384
+ chunks.push('<blockquote>');
1385
+ inBlockquote = true;
1386
+ }
1387
+ }
1388
+
1389
+ function closeBlockquote() {
1390
+ if (inBlockquote) {
1391
+ chunks.push('</blockquote>');
1392
+ inBlockquote = false;
1393
+ }
1394
+ }
1395
+
1396
+ function closeTable() {
1397
+ if (inTable && tableRows.length > 0) {
1398
+ // Parse table rows
1399
+ const parseRow = (row) => {
1400
+ // Split by | but handle escaped pipes and trim cells
1401
+ return row.replace(/^\||\|$/g, '').split('|').map(cell => cell.trim());
1402
+ };
1403
+
1404
+ // Check if second row is separator (determines if first row is header)
1405
+ const isSeparator = (row) => /^\|?[\s\-:]+\|/.test(row);
1406
+
1407
+ let headerRow = null;
1408
+ let bodyRows = [];
1409
+ let alignments = [];
1410
+
1411
+ if (tableRows.length > 1 && isSeparator(tableRows[1])) {
1412
+ headerRow = parseRow(tableRows[0]);
1413
+ // Parse alignments from separator
1414
+ const sepCells = parseRow(tableRows[1]);
1415
+ alignments = sepCells.map(cell => {
1416
+ const left = cell.startsWith(':');
1417
+ const right = cell.endsWith(':');
1418
+ if (left && right) return 'center';
1419
+ if (right) return 'right';
1420
+ return 'left';
1421
+ });
1422
+ bodyRows = tableRows.slice(2).map(parseRow);
1423
+ } else {
1424
+ bodyRows = tableRows.map(parseRow);
1425
+ }
1426
+
1427
+ let tableHtml = '<table>';
1428
+
1429
+ if (headerRow) {
1430
+ tableHtml += '<thead><tr>';
1431
+ headerRow.forEach((cell, i) => {
1432
+ const align = alignments[i] ? ` style="text-align: ${alignments[i]}"` : '';
1433
+ tableHtml += `<th${align}>${parseInlineMarkdown(cell, linkContext)}</th>`;
1434
+ });
1435
+ tableHtml += '</tr></thead>';
1436
+ }
1437
+
1438
+ if (bodyRows.length > 0) {
1439
+ tableHtml += '<tbody>';
1440
+ bodyRows.forEach(row => {
1441
+ tableHtml += '<tr>';
1442
+ row.forEach((cell, i) => {
1443
+ const align = alignments[i] ? ` style="text-align: ${alignments[i]}"` : '';
1444
+ tableHtml += `<td${align}>${parseInlineMarkdown(cell, linkContext)}</td>`;
1445
+ });
1446
+ tableHtml += '</tr>';
1447
+ });
1448
+ tableHtml += '</tbody>';
1449
+ }
1450
+
1451
+ tableHtml += '</table>';
1452
+ chunks.push(tableHtml);
1453
+
1454
+ inTable = false;
1455
+ tableRows = [];
1456
+ }
1457
+ }
1458
+
1459
+ /**
1460
+ * Generate unique heading ID
1461
+ * If slug already exists, append -2, -3, etc.
1462
+ */
1463
+ function getUniqueHeadingId(text) {
1464
+ const baseSlug = generateSlug(text);
1465
+ if (!baseSlug) return null;
1466
+
1467
+ let slug = baseSlug;
1468
+ let counter = 1;
1469
+ while (headingIds.has(slug)) {
1470
+ counter++;
1471
+ slug = `${baseSlug}-${counter}`;
1472
+ }
1473
+ headingIds.set(slug, true);
1474
+ return slug;
1475
+ }
1476
+
1477
+ for (const rawLine of lines) {
1478
+ const line = rawLine.trim();
1479
+
1480
+ // Handle code blocks
1481
+ if (line.startsWith('```')) {
1482
+ if (inCodeBlock) {
1483
+ // End code block
1484
+ const escapedCode = escapeHtml(codeBlockContent.join('\n'));
1485
+
1486
+ // Check for special block types
1487
+ if (codeBlockLang.startsWith('box')) {
1488
+ // Box/panel block: ```box or ```box:Title
1489
+ const titleMatch = codeBlockLang.match(/^box(?::(.+))?$/);
1490
+ const title = titleMatch && titleMatch[1] ? titleMatch[1].trim() : '';
1491
+ const titleHtml = title ? `<div class="box-title">${escapeHtml(title)}</div>` : '';
1492
+ chunks.push(`<div class="content-box">${titleHtml}<pre class="box-content">${escapedCode}</pre></div>`);
1493
+ } else if (codeBlockLang.startsWith('html')) {
1494
+ // Raw HTML block: ```html - renders HTML directly (use with caution)
1495
+ // Note: We don't escape here to allow HTML rendering
1496
+ chunks.push(`<div class="html-block">${codeBlockContent.join('\n')}</div>`);
1497
+ } else {
1498
+ // Standard code block
1499
+ const langAttr = codeBlockLang ? ` class="language-${escapeAttribute(codeBlockLang)}"` : '';
1500
+ chunks.push(`<pre><code${langAttr}>${escapedCode}</code></pre>`);
1501
+ }
1502
+ inCodeBlock = false;
1503
+ codeBlockContent = [];
1504
+ codeBlockLang = '';
1505
+ } else {
1506
+ // Start code block
1507
+ closeList();
1508
+ closeBlockquote();
1509
+ inCodeBlock = true;
1510
+ codeBlockLang = line.slice(3).trim();
1511
+ }
1512
+ continue;
1513
+ }
1514
+
1515
+ if (inCodeBlock) {
1516
+ codeBlockContent.push(rawLine); // Preserve original indentation
1517
+ continue;
1518
+ }
1519
+
1520
+ if (!line) {
1521
+ closeList();
1522
+ closeBlockquote();
1523
+ continue;
1524
+ }
1525
+
1526
+ const headingMatch = /^(#{1,6})\s+(.*)$/.exec(line);
1527
+ if (headingMatch) {
1528
+ closeList();
1529
+ closeBlockquote();
1530
+ closeTable();
1531
+ const level = Math.min(headingMatch[1].length, 6);
1532
+ const rawText = headingMatch[2].trim();
1533
+ const text = parseInlineMarkdown(rawText, linkContext);
1534
+ const headingId = getUniqueHeadingId(rawText);
1535
+ if (headingId) {
1536
+ chunks.push(`<h${level} id="${headingId}">${text}</h${level}>`);
1537
+ } else {
1538
+ chunks.push(`<h${level}>${text}</h${level}>`);
1539
+ }
1540
+ continue;
1541
+ }
1542
+
1543
+ const listMatch = /^[-*+]\s+(.*)$/.exec(line);
1544
+ if (listMatch) {
1545
+ closeBlockquote();
1546
+ closeTable();
1547
+ if (!inList) {
1548
+ chunks.push('<ul>');
1549
+ inList = true;
1550
+ }
1551
+ const item = parseInlineMarkdown(listMatch[1].trim(), linkContext);
1552
+ chunks.push(`<li>${item}</li>`);
1553
+ continue;
1554
+ }
1555
+
1556
+ const quoteMatch = /^>\s?(.*)$/.exec(line);
1557
+ if (quoteMatch) {
1558
+ closeList();
1559
+ closeTable();
1560
+ openBlockquote();
1561
+ const quoteLine = parseInlineMarkdown(quoteMatch[1], linkContext);
1562
+ chunks.push(`<p>${quoteLine}</p>`);
1563
+ continue;
1564
+ }
1565
+
1566
+ // Table row: starts with |
1567
+ if (line.startsWith('|')) {
1568
+ closeList();
1569
+ closeBlockquote();
1570
+ inTable = true;
1571
+ tableRows.push(line);
1572
+ continue;
1573
+ }
1574
+
1575
+ // If we were in a table and hit a non-table line, close it
1576
+ if (inTable) {
1577
+ closeTable();
1578
+ }
1579
+
1580
+ // Horizontal rule: ---, ***, or ___
1581
+ if (/^[-*_]{3,}$/.test(line)) {
1582
+ closeList();
1583
+ closeBlockquote();
1584
+ chunks.push('<hr>');
1585
+ continue;
1586
+ }
1587
+
1588
+ closeList();
1589
+ closeBlockquote();
1590
+ const paragraph = parseInlineMarkdown(line, linkContext);
1591
+ chunks.push(`<p>${paragraph}</p>`);
1592
+ }
1593
+
1594
+ // Close any open code block
1595
+ if (inCodeBlock) {
1596
+ const escapedCode = escapeHtml(codeBlockContent.join('\n'));
1597
+ const langAttr = codeBlockLang ? ` class="language-${escapeAttribute(codeBlockLang)}"` : '';
1598
+ chunks.push(`<pre><code${langAttr}>${escapedCode}</code></pre>`);
1599
+ }
1600
+
1601
+ closeList();
1602
+ closeBlockquote();
1603
+ closeTable();
1604
+
1605
+ const body = chunks.join('\n');
1606
+ // Note: No indentation added - content goes into JSON anyway, and indentation breaks <pre> blocks
1607
+ return `<section class="section doc markdown">
1608
+ <div class="doc-content">
1609
+ ${body}
1610
+ </div>
1611
+ </section>`;
1612
+ }
1613
+
1614
+ async function pruneSectionsDirectory(sectionsDir, keepFiles) {
1615
+ if (!(await pathExists(sectionsDir))) return;
1616
+ const entries = await fsp.readdir(sectionsDir, { withFileTypes: true });
1617
+ await Promise.all(entries.map(async (entry) => {
1618
+ if (keepFiles.has(entry.name)) return;
1619
+ const target = path.join(sectionsDir, entry.name);
1620
+ if (entry.isDirectory()) {
1621
+ await fsp.rm(target, { recursive: true, force: true });
1622
+ } else {
1623
+ await fsp.rm(target, { force: true });
1624
+ }
1625
+ }));
1626
+ }
1627
+
1628
+ async function ensureHtmlModule(sourcePath, targetPath) {
1629
+ const html = await fsp.readFile(sourcePath, 'utf8');
1630
+ const moduleSource = `export async function load() {\n return { html: ${JSON.stringify(html)} };\n}\n`;
1631
+ await fsp.mkdir(path.dirname(targetPath), { recursive: true });
1632
+ await fsp.writeFile(targetPath, moduleSource, 'utf8');
1633
+ }
1634
+
1635
+ /**
1636
+ * Convert markdown file to JS module
1637
+ *
1638
+ * @param {string} sourcePath - Absolute path to markdown file
1639
+ * @param {string} targetPath - Absolute path to output JS module
1640
+ * @param {object} [linkContext] - Optional context for link transformation
1641
+ */
1642
+ async function ensureMarkdownModule(sourcePath, targetPath, linkContext = null) {
1643
+ const raw = await fsp.readFile(sourcePath, 'utf8');
1644
+ const html = markdownToHtml(raw, linkContext);
1645
+ const moduleSource = `export async function load() {\n return { html: ${JSON.stringify(html)} };\n}\n`;
1646
+ await fsp.mkdir(path.dirname(targetPath), { recursive: true });
1647
+ await fsp.writeFile(targetPath, moduleSource, 'utf8');
1648
+ }
1649
+
1650
+ async function ensureJavascriptModule(sourcePath, targetPath) {
1651
+ await fsp.mkdir(path.dirname(targetPath), { recursive: true });
1652
+ await fsp.copyFile(sourcePath, targetPath);
1653
+ }
1654
+
1655
+ // ============================================================================
1656
+ // Internal Link Transformation (ADR-011)
1657
+ // ============================================================================
1658
+
1659
+ /**
1660
+ * Check if a link is external (HTTP, mailto, tel, protocol-relative, etc.)
1661
+ * External links are not transformed.
1662
+ */
1663
+ function isExternalLink(href) {
1664
+ if (!href) return true;
1665
+ // Protocol-based links (http:, https:, mailto:, tel:, data:, javascript:, etc.)
1666
+ if (/^[a-z][a-z0-9+.-]*:/i.test(href)) {
1667
+ return true;
1668
+ }
1669
+ // Protocol-relative URLs (//example.com)
1670
+ if (href.startsWith('//')) {
1671
+ return true;
1672
+ }
1673
+ return false;
1674
+ }
1675
+
1676
+ /**
1677
+ * Check if a link is fragment-only (#anchor)
1678
+ * Fragment-only links are not transformed.
1679
+ */
1680
+ function isFragmentOnly(href) {
1681
+ return href && href.startsWith('#');
1682
+ }
1683
+
1684
+ /**
1685
+ * Check if a path points to a markdown file (for link transformation)
1686
+ * Only .md files get transformed; .html and .js links are preserved.
1687
+ */
1688
+ function isMarkdownLink(href) {
1689
+ if (!href) return false;
1690
+ // Extract pathname (before any # or ?)
1691
+ const pathname = href.split('#')[0].split('?')[0];
1692
+ const ext = path.extname(pathname).toLowerCase();
1693
+ // Only transform .md and .markdown links
1694
+ return ext === '.md' || ext === '.markdown';
1695
+ }
1696
+
1697
+ /**
1698
+ * Normalize a relative path for consistent resolution
1699
+ * - Converts backslashes to forward slashes (Windows compatibility)
1700
+ * - Removes redundant slashes
1701
+ * - Handles ./ and ../ prefixes
1702
+ */
1703
+ function normalizeLinkPath(linkPath) {
1704
+ if (!linkPath) return '';
1705
+ return linkPath
1706
+ .replace(/\\/g, '/') // Windows backslashes
1707
+ .replace(/\/+/g, '/') // Multiple slashes
1708
+ .replace(/\/\.$/, '') // Trailing /.
1709
+ .replace(/\/+$/, ''); // Trailing slashes
1710
+ }
1711
+
1712
+ /**
1713
+ * Resolve a relative link path to an absolute path within content root
1714
+ *
1715
+ * @param {string} linkPath - The href path from markdown (e.g., ./file.md, ../other/file.md)
1716
+ * @param {string} currentFilePath - Current file path relative to content root (e.g., getting-started/intro.md)
1717
+ * @returns {string} Resolved path relative to content root
1718
+ */
1719
+ function resolveLinkPath(linkPath, currentFilePath) {
1720
+ // Get directory containing current file
1721
+ const currentDir = path.dirname(currentFilePath);
1722
+
1723
+ let resolvedPath;
1724
+
1725
+ if (linkPath.startsWith('/')) {
1726
+ // Absolute path from content root
1727
+ resolvedPath = linkPath.slice(1);
1728
+ } else if (linkPath.startsWith('./')) {
1729
+ // Relative to current directory
1730
+ resolvedPath = path.join(currentDir, linkPath.slice(2));
1731
+ } else if (linkPath.startsWith('../')) {
1732
+ // Relative to parent
1733
+ resolvedPath = path.join(currentDir, linkPath);
1734
+ } else {
1735
+ // Implicit relative (no prefix)
1736
+ resolvedPath = path.join(currentDir, linkPath);
1737
+ }
1738
+
1739
+ // Normalize path separators and remove leading/trailing slashes
1740
+ return path.normalize(resolvedPath).replace(/\\/g, '/').replace(/^\/|\/$/g, '');
1741
+ }
1742
+
1743
+ /**
1744
+ * Convert a resolved file path to a section ID
1745
+ * - Strips content file extensions
1746
+ * - Handles index files (getting-started/index -> getting-started)
1747
+ *
1748
+ * @param {string} resolvedPath - Path relative to content root
1749
+ * @returns {string} Section ID for hash-based navigation
1750
+ */
1751
+ function pathToSectionId(resolvedPath) {
1752
+ if (!resolvedPath) return '';
1753
+
1754
+ // Strip content file extensions (case-insensitive)
1755
+ let withoutExt = resolvedPath;
1756
+ for (const ext of CONTENT_EXTENSIONS) {
1757
+ if (resolvedPath.toLowerCase().endsWith(ext)) {
1758
+ withoutExt = resolvedPath.slice(0, -ext.length);
1759
+ break;
1760
+ }
1761
+ }
1762
+
1763
+ // Handle index files: getting-started/index -> getting-started
1764
+ const normalized = withoutExt.replace(/\/index$/i, '');
1765
+
1766
+ // Return empty string if this resolves to root
1767
+ return normalized || '';
1768
+ }
1769
+
1770
+ /**
1771
+ * Generate a URL-safe slug from text for heading IDs
1772
+ * - Lowercase
1773
+ * - Replace spaces and special chars with hyphens
1774
+ * - Remove consecutive hyphens
1775
+ * - Trim leading/trailing hyphens
1776
+ */
1777
+ function generateSlug(text) {
1778
+ if (!text) return '';
1779
+ return text
1780
+ .toLowerCase()
1781
+ .replace(/[^\w\s-]/g, '') // Remove special chars except hyphens
1782
+ .replace(/[\s_]+/g, '-') // Replace spaces and underscores with hyphens
1783
+ .replace(/-+/g, '-') // Remove consecutive hyphens
1784
+ .replace(/^-|-$/g, ''); // Trim leading/trailing hyphens
1785
+ }
1786
+
1787
+ /**
1788
+ * Transform an internal markdown link to a hash-based section link
1789
+ *
1790
+ * @param {string} href - Original href from markdown link
1791
+ * @param {object} context - Transformation context
1792
+ * @param {string} context.currentPath - Current file path relative to content root
1793
+ * @param {string} context.contentRoot - Absolute path to content root
1794
+ * @param {Map<string,object>} context.sectionIndex - Map of section IDs to section info (for validation)
1795
+ * @param {object} context.linkWarnings - Array to collect link warnings
1796
+ * @param {boolean} context.strictLinks - Whether to error on broken links (default: true)
1797
+ * @returns {string} Transformed href (hash-based) or original href if not transformable
1798
+ */
1799
+ function transformInternalLink(href, context) {
1800
+ // Skip external links
1801
+ if (isExternalLink(href)) {
1802
+ return href;
1803
+ }
1804
+
1805
+ // Skip fragment-only links
1806
+ if (isFragmentOnly(href)) {
1807
+ return href;
1808
+ }
1809
+
1810
+ // Only transform markdown links
1811
+ if (!isMarkdownLink(href)) {
1812
+ return href;
1813
+ }
1814
+
1815
+ // Parse href to extract path and fragment
1816
+ const hashIndex = href.indexOf('#');
1817
+ const pathname = hashIndex >= 0 ? href.slice(0, hashIndex) : href;
1818
+ const fragment = hashIndex >= 0 ? href.slice(hashIndex) : '';
1819
+
1820
+ // Normalize the link path
1821
+ const normalizedPath = normalizeLinkPath(pathname);
1822
+
1823
+ // Resolve relative path to absolute (relative to content root)
1824
+ const resolvedPath = resolveLinkPath(normalizedPath, context.currentPath);
1825
+
1826
+ // Convert to section ID
1827
+ const sectionId = pathToSectionId(resolvedPath);
1828
+
1829
+ // Validate section exists (if index available)
1830
+ if (context.sectionIndex && sectionId) {
1831
+ // Case-insensitive lookup
1832
+ const sectionIdLower = sectionId.toLowerCase();
1833
+ let found = false;
1834
+ let actualId = sectionId;
1835
+
1836
+ for (const [id] of context.sectionIndex) {
1837
+ // Skip entries with undefined/null IDs
1838
+ if (!id) continue;
1839
+ if (id.toLowerCase() === sectionIdLower) {
1840
+ found = true;
1841
+ actualId = id;
1842
+ // Warn if case doesn't match exactly
1843
+ if (id !== sectionId && context.linkWarnings) {
1844
+ context.linkWarnings.push({
1845
+ type: 'case-mismatch',
1846
+ source: context.currentPath,
1847
+ href: href,
1848
+ expected: id,
1849
+ actual: sectionId
1850
+ });
1851
+ }
1852
+ break;
1853
+ }
1854
+ }
1855
+
1856
+ if (!found && context.linkWarnings) {
1857
+ context.linkWarnings.push({
1858
+ type: 'broken',
1859
+ source: context.currentPath,
1860
+ href: href,
1861
+ targetId: sectionId
1862
+ });
1863
+ // Preserve original link on validation failure (graceful degradation)
1864
+ return href;
1865
+ }
1866
+
1867
+ // Use the correctly-cased section ID
1868
+ if (found && actualId !== sectionId) {
1869
+ return fragment ? `#${actualId}${fragment}` : `#${actualId}`;
1870
+ }
1871
+ }
1872
+
1873
+ // Compose hash-based URL
1874
+ // Double hash for section + anchor: #section-id#anchor
1875
+ return fragment ? `#${sectionId}${fragment}` : `#${sectionId}`;
1876
+ }
1877
+
1878
+ /**
1879
+ * Print link validation warnings
1880
+ */
1881
+ function printLinkWarnings(warnings, tenantId, strictLinks = true) {
1882
+ if (!warnings || warnings.length === 0) return;
1883
+
1884
+ const brokenLinks = warnings.filter(w => w.type === 'broken');
1885
+ const caseMismatches = warnings.filter(w => w.type === 'case-mismatch');
1886
+
1887
+ if (brokenLinks.length > 0) {
1888
+ const level = strictLinks ? 'ERROR' : 'WARN';
1889
+ console.log(` ↳ [${level}] ${brokenLinks.length} broken link(s) found:`);
1890
+ for (const w of brokenLinks.slice(0, 10)) { // Show first 10
1891
+ console.log(` ${w.source}: [link](${w.href}) → target "${w.targetId}" not found`);
1892
+ }
1893
+ if (brokenLinks.length > 10) {
1894
+ console.log(` ... and ${brokenLinks.length - 10} more`);
1895
+ }
1896
+ }
1897
+
1898
+ if (caseMismatches.length > 0) {
1899
+ console.log(` ↳ [WARN] ${caseMismatches.length} case mismatch(es) found (resolved, but may break on Linux):`);
1900
+ for (const w of caseMismatches.slice(0, 5)) { // Show first 5
1901
+ console.log(` ${w.source}: expected "${w.expected}", got "${w.actual}"`);
1902
+ }
1903
+ if (caseMismatches.length > 5) {
1904
+ console.log(` ... and ${caseMismatches.length - 5} more`);
1905
+ }
1906
+ }
1907
+ }
1908
+
1909
+ // ============================================================================
1910
+ // End Internal Link Transformation
1911
+ // ============================================================================
1912
+
1913
+ // ============================================================================
1914
+ // Nested Content Directory Support (ADR-010)
1915
+ // ============================================================================
1916
+
1917
+ /**
1918
+ * Check if a file/directory name should be excluded from content discovery
1919
+ * Excludes: dot-prefixed (.), underscore-prefixed (_), and 'overrides' directory
1920
+ */
1921
+ function isExcludedName(name) {
1922
+ return name.startsWith('.') || name.startsWith('_') || name === 'overrides';
1923
+ }
1924
+
1925
+ /**
1926
+ * Check if a file is a content file based on extension
1927
+ */
1928
+ function isContentFile(filename) {
1929
+ const ext = path.extname(filename).toLowerCase();
1930
+ return ['.md', '.markdown', '.html', '.htm', '.js', '.mjs'].includes(ext);
1931
+ }
1932
+
1933
+ /**
1934
+ * Convert filename or directory name to human-readable title
1935
+ * - Strips extension
1936
+ * - Replaces hyphens/underscores with spaces
1937
+ * - Title cases words (preserving abbreviations)
1938
+ */
1939
+ function humanizeTitle(name) {
1940
+ // Strip extension if present
1941
+ const stem = name.replace(/\.[^.]+$/, '');
1942
+
1943
+ // Replace hyphens and underscores with spaces
1944
+ const words = stem.replace(/[-_]+/g, ' ').trim().split(/\s+/);
1945
+
1946
+ // Title case each word, preserving abbreviations
1947
+ return words.map(word => {
1948
+ const lower = word.toLowerCase();
1949
+ if (ABBREVIATIONS.has(lower)) {
1950
+ return lower.toUpperCase();
1951
+ }
1952
+ return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase();
1953
+ }).join(' ');
1954
+ }
1955
+
1956
+ /**
1957
+ * Derive section ID from filesystem path
1958
+ * - index.md -> parent directory ID
1959
+ * - file.md -> parentId/filename (without extension)
1960
+ */
1961
+ function deriveSectionId(parentId, name, isIndex = false) {
1962
+ const stem = name.replace(/\.[^.]+$/, ''); // Strip extension
1963
+
1964
+ if (isIndex || stem === 'index') {
1965
+ // index.md resolves to parent directory ID
1966
+ return parentId || stem;
1967
+ }
1968
+
1969
+ if (parentId) {
1970
+ return `${parentId}/${stem}`;
1971
+ }
1972
+ return stem;
1973
+ }
1974
+
1975
+ /**
1976
+ * Encode section ID for use in output filename
1977
+ * Replaces / with -- to create flat output structure
1978
+ */
1979
+ function encodePathForFilename(sectionId) {
1980
+ return sectionId.replace(/\//g, '--');
1981
+ }
1982
+
1983
+ /**
1984
+ * Load optional _manifest.json from a directory
1985
+ * Returns manifest data or null if not present/invalid
1986
+ */
1987
+ async function loadDirectoryManifest(dirPath) {
1988
+ const manifestPath = path.join(dirPath, DIRECTORY_MANIFEST);
1989
+ if (!(await pathExists(manifestPath))) {
1990
+ return null;
1991
+ }
1992
+
1993
+ try {
1994
+ const raw = await fsp.readFile(manifestPath, 'utf8');
1995
+ return JSON.parse(raw);
1996
+ } catch (err) {
1997
+ console.warn(` ↳ warning: unable to parse ${DIRECTORY_MANIFEST} in ${dirPath}: ${err.message}`);
1998
+ return null;
1999
+ }
2000
+ }
2001
+
2002
+ /**
2003
+ * Determine content root type and path for a tenant source directory
2004
+ * Returns: { type: 'nested' | 'flat' | 'root' | 'none', basePath: string }
2005
+ *
2006
+ * Priority:
2007
+ * 1. If content/ directory exists -> 'flat' (legacy mode)
2008
+ * 2. If subdirectories contain content files -> 'nested'
2009
+ * 3. If root has content files -> 'root'
2010
+ * 4. Otherwise -> 'none'
2011
+ */
2012
+ async function findContentRoot(sourceDir) {
2013
+ // Check for traditional content/ subdirectory FIRST (backward compatibility)
2014
+ const contentDir = path.join(sourceDir, DEFAULT_CONTENT_DIR);
2015
+ const hasContentDir = await pathExists(contentDir);
2016
+
2017
+ if (hasContentDir) {
2018
+ return { type: 'flat', basePath: contentDir };
2019
+ }
2020
+
2021
+ // Check for content files directly in root (excluding special files)
2022
+ const rootEntries = await fsp.readdir(sourceDir, { withFileTypes: true });
2023
+ const rootContentFiles = rootEntries.filter(e =>
2024
+ e.isFile() && isContentFile(e.name) && !isExcludedName(e.name)
2025
+ );
2026
+
2027
+ // Check for nested directories with content
2028
+ const subdirs = rootEntries.filter(e =>
2029
+ e.isDirectory() && !isExcludedName(e.name)
2030
+ );
2031
+
2032
+ let hasNestedContent = false;
2033
+ for (const dir of subdirs) {
2034
+ const dirPath = path.join(sourceDir, dir.name);
2035
+ const dirEntries = await fsp.readdir(dirPath, { withFileTypes: true });
2036
+ if (dirEntries.some(e => e.isFile() && isContentFile(e.name) && !isExcludedName(e.name))) {
2037
+ hasNestedContent = true;
2038
+ break;
2039
+ }
2040
+ }
2041
+
2042
+ // Nested structure: subdirectories contain content files
2043
+ if (hasNestedContent) {
2044
+ return { type: 'nested', basePath: sourceDir };
2045
+ }
2046
+
2047
+ // Root-level content: files directly in source root
2048
+ if (rootContentFiles.length > 0) {
2049
+ return { type: 'root', basePath: sourceDir };
2050
+ }
2051
+
2052
+ return { type: 'none', basePath: null };
2053
+ }
2054
+
2055
+ /**
2056
+ * Recursively scan a directory for content files and subdirectories
2057
+ * Returns array of manifest entries with proper section IDs
2058
+ */
2059
+ async function scanContentDirectory(dirPath, parentId, context, depth = 0) {
2060
+ if (depth > MAX_CONTENT_DEPTH) {
2061
+ console.warn(` ↳ warning: maximum content depth (${MAX_CONTENT_DEPTH}) exceeded at ${dirPath}`);
2062
+ return [];
2063
+ }
2064
+
2065
+ const sections = [];
2066
+ const manifest = await loadDirectoryManifest(dirPath);
2067
+
2068
+ // Build exclude set from manifest
2069
+ const excludeSet = new Set(manifest?.exclude || []);
2070
+ const isManifestExcluded = (name) => {
2071
+ const stem = name.replace(/\.[^.]+$/, '');
2072
+ return excludeSet.has(name) || excludeSet.has(stem);
2073
+ };
2074
+
2075
+ // Read directory contents
2076
+ const entries = await fsp.readdir(dirPath, { withFileTypes: true });
2077
+
2078
+ // Separate files and directories, applying both standard and manifest exclusions
2079
+ const files = entries.filter(e =>
2080
+ e.isFile() && isContentFile(e.name) && !isExcludedName(e.name) && !isManifestExcluded(e.name)
2081
+ );
2082
+ const subdirs = entries.filter(e =>
2083
+ e.isDirectory() && !isExcludedName(e.name) && !isManifestExcluded(e.name)
2084
+ );
2085
+
2086
+ // Process index file first (if exists)
2087
+ const indexFile = files.find(f => /^index\.(md|markdown|html|htm|js|mjs)$/i.test(f.name));
2088
+ if (indexFile) {
2089
+ const sectionId = parentId || path.basename(dirPath);
2090
+ const title = manifest?.title || humanizeTitle(path.basename(dirPath));
2091
+ const summary = manifest?.summary || '';
2092
+
2093
+ // Calculate relative path from content root
2094
+ const relPath = path.relative(context.contentRoot, path.join(dirPath, indexFile.name));
2095
+
2096
+ sections.push({
2097
+ id: sectionId,
2098
+ title,
2099
+ summary,
2100
+ file: relPath,
2101
+ _isIndex: true
2102
+ });
2103
+ }
2104
+
2105
+ // Process other content files
2106
+ for (const file of files) {
2107
+ if (indexFile && file.name === indexFile.name) continue; // Skip index, already processed
2108
+
2109
+ const sectionId = deriveSectionId(parentId, file.name);
2110
+ const manifestEntry = manifest?.sections?.find(s =>
2111
+ s.id === sectionId || s.file === file.name || s.id === file.name.replace(/\.[^.]+$/, '')
2112
+ );
2113
+
2114
+ const title = manifestEntry?.title || humanizeTitle(file.name);
2115
+ const summary = manifestEntry?.summary || '';
2116
+ const type = manifestEntry?.type || null;
2117
+ const relPath = path.relative(context.contentRoot, path.join(dirPath, file.name));
2118
+
2119
+ const sectionEntry = {
2120
+ id: sectionId,
2121
+ title,
2122
+ summary,
2123
+ file: relPath
2124
+ };
2125
+ if (type) sectionEntry.type = type;
2126
+ sections.push(sectionEntry);
2127
+ }
2128
+
2129
+ // Process subdirectories
2130
+ for (const subdir of subdirs) {
2131
+ const subdirPath = path.join(dirPath, subdir.name);
2132
+ const subdirId = deriveSectionId(parentId, subdir.name);
2133
+ const subdirManifest = await loadDirectoryManifest(subdirPath);
2134
+
2135
+ // Look up metadata from parent manifest's sections array
2136
+ const parentEntry = manifest?.sections?.find(s =>
2137
+ s.id === subdir.name || s.id === subdirId
2138
+ );
2139
+
2140
+ const title = subdirManifest?.title || parentEntry?.title || humanizeTitle(subdir.name);
2141
+ const summary = subdirManifest?.summary || parentEntry?.summary || '';
2142
+ const collapsed = subdirManifest?.collapsed ?? parentEntry?.collapsed ?? false;
2143
+
2144
+ const subsections = await scanContentDirectory(subdirPath, subdirId, context, depth + 1);
2145
+
2146
+ if (subsections.length > 0) {
2147
+ // Check if there's an index page that should be the group's landing
2148
+ const indexEntry = subsections.find(s => s._isIndex);
2149
+
2150
+ if (indexEntry) {
2151
+ // Remove index from subsections, it becomes the group itself
2152
+ const otherSections = subsections.filter(s => !s._isIndex);
2153
+ const entry = {
2154
+ id: subdirId,
2155
+ title,
2156
+ summary,
2157
+ file: indexEntry.file,
2158
+ subsections: otherSections.length > 0 ? otherSections : undefined
2159
+ };
2160
+ if (collapsed) entry.collapsed = true;
2161
+ sections.push(entry);
2162
+ } else {
2163
+ // No index, just a group with subsections
2164
+ const entry = {
2165
+ id: subdirId,
2166
+ title,
2167
+ summary,
2168
+ subsections
2169
+ };
2170
+ if (collapsed) entry.collapsed = true;
2171
+ sections.push(entry);
2172
+ }
2173
+ }
2174
+ }
2175
+
2176
+ // Add external links from manifest (entries with url property)
2177
+ if (manifest?.sections) {
2178
+ for (const entry of manifest.sections) {
2179
+ if (entry.url) {
2180
+ sections.push({
2181
+ title: entry.title,
2182
+ summary: entry.summary || '',
2183
+ url: entry.url
2184
+ });
2185
+ }
2186
+ }
2187
+ }
2188
+
2189
+ // Apply ordering from manifest or sort alphabetically
2190
+ if (manifest?.order && Array.isArray(manifest.order)) {
2191
+ const orderMap = new Map(manifest.order.map((id, idx) => [id, idx]));
2192
+ sections.sort((a, b) => {
2193
+ const aOrder = a.id ? (orderMap.get(a.id) ?? orderMap.get(a.id.split('/').pop()) ?? 999) : 999;
2194
+ const bOrder = b.id ? (orderMap.get(b.id) ?? orderMap.get(b.id.split('/').pop()) ?? 999) : 999;
2195
+ return aOrder - bOrder;
2196
+ });
2197
+ } else {
2198
+ // Alphabetical sort, but index always first
2199
+ sections.sort((a, b) => {
2200
+ if (a._isIndex) return -1;
2201
+ if (b._isIndex) return 1;
2202
+ return a.title.localeCompare(b.title);
2203
+ });
2204
+ }
2205
+
2206
+ return sections;
2207
+ }
2208
+
2209
+ // ============================================================================
2210
+ // @-Mention Link Following & Manifest Expansion
2211
+ // ============================================================================
2212
+
2213
+ /**
2214
+ * Extract @docs/ references from markdown content.
2215
+ * Returns an array of file paths relative to the content root.
2216
+ *
2217
+ * @param {string} content - Raw markdown text
2218
+ * @returns {string[]} Array of relative file paths (e.g., 'research/glossary.md')
2219
+ */
2220
+ function extractAtMentionRefs(content) {
2221
+ const refs = [];
2222
+ const pattern = /@docs\/([^\s,);>\]`'"]+)/g;
2223
+ let match;
2224
+ while ((match = pattern.exec(content)) !== null) {
2225
+ let ref = match[1].replace(/[.,;:!?]+$/, '');
2226
+ // Only include references to content files (skip PDFs, images, etc.)
2227
+ const ext = path.extname(ref).toLowerCase();
2228
+ if (ext && !CONTENT_EXTENSIONS.includes(ext)) continue;
2229
+ // If no extension, try appending .md
2230
+ if (!ext) ref += '.md';
2231
+ refs.push(ref);
2232
+ }
2233
+ return refs;
2234
+ }
2235
+
2236
+ /**
2237
+ * Build a flat index of all sections by their file paths.
2238
+ * Used to check which files are already in the manifest.
2239
+ *
2240
+ * @param {object[]} sections - Array of section objects (with subsections)
2241
+ * @param {Set<string>} [fileSet] - Set to populate
2242
+ * @returns {Set<string>} Set of file paths already included
2243
+ */
2244
+ function indexSectionFiles(sections, fileSet = new Set()) {
2245
+ for (const s of sections) {
2246
+ if (s.file) fileSet.add(s.file);
2247
+ if (s.subsections) indexSectionFiles(s.subsections, fileSet);
2248
+ }
2249
+ return fileSet;
2250
+ }
2251
+
2252
+ /**
2253
+ * Expand the section tree by following @docs/ references in content files.
2254
+ * Uses BFS to discover referenced documents up to maxDepth levels deep.
2255
+ * Only markdown/content files that exist on disk and are NOT already in the
2256
+ * section tree get added. Discovered sections are grouped under a collapsed
2257
+ * "Referenced" nav group, or merged into their parent directory group if one exists.
2258
+ *
2259
+ * @param {object[]} sections - Scanned section tree
2260
+ * @param {string} contentRoot - Absolute path to content root
2261
+ * @param {object} opts - followLinks configuration
2262
+ * @param {number} [opts.maxDepth=3] - Maximum recursion depth
2263
+ * @returns {Promise<{ sections: object[], discoveredCount: number }>}
2264
+ */
2265
+ async function expandLinkedSections(sections, contentRoot, opts = {}) {
2266
+ const maxDepth = (opts && opts.maxDepth) || 3;
2267
+
2268
+ // Index files already in the section tree
2269
+ const knownFiles = indexSectionFiles(sections);
2270
+
2271
+ // Flat list of all sections for BFS seeding (including nested)
2272
+ function flattenSections(arr) {
2273
+ const result = [];
2274
+ for (const s of arr) {
2275
+ result.push(s);
2276
+ if (s.subsections) result.push(...flattenSections(s.subsections));
2277
+ }
2278
+ return result;
2279
+ }
2280
+
2281
+ // BFS queue: { filePath (relative to contentRoot), depth }
2282
+ const queue = [];
2283
+ const visited = new Set(); // file paths we've already read for refs
2284
+ const discovered = []; // newly discovered section objects
2285
+
2286
+ // Seed queue with all existing sections that have files
2287
+ for (const s of flattenSections(sections)) {
2288
+ if (s.file) {
2289
+ queue.push({ filePath: s.file, depth: 0 });
2290
+ visited.add(s.file);
2291
+ }
2292
+ }
2293
+
2294
+ while (queue.length > 0) {
2295
+ const { filePath, depth } = queue.shift();
2296
+ if (depth >= maxDepth) continue;
2297
+
2298
+ // Read the file and extract @docs/ references
2299
+ const absPath = path.join(contentRoot, filePath);
2300
+ let content;
2301
+ try {
2302
+ content = await fsp.readFile(absPath, 'utf8');
2303
+ } catch {
2304
+ continue; // file unreadable, skip
2305
+ }
2306
+
2307
+ const refs = extractAtMentionRefs(content);
2308
+
2309
+ for (const ref of refs) {
2310
+ // Normalize path separators
2311
+ const normalizedRef = ref.replace(/\\/g, '/');
2312
+
2313
+ // Skip if already known or already visited
2314
+ if (knownFiles.has(normalizedRef) || visited.has(normalizedRef)) continue;
2315
+ visited.add(normalizedRef);
2316
+
2317
+ // Check the file exists on disk
2318
+ const refAbsPath = path.join(contentRoot, normalizedRef);
2319
+ try {
2320
+ await fsp.access(refAbsPath, fsp.constants?.R_OK ?? 4);
2321
+ } catch {
2322
+ continue; // file doesn't exist, skip
2323
+ }
2324
+
2325
+ // Build a section entry for this discovered file
2326
+ const stem = normalizedRef.replace(/\.[^.]+$/, '');
2327
+ const sectionId = stem.replace(/\\/g, '/');
2328
+ const baseName = path.basename(stem);
2329
+ const title = humanizeTitle(baseName);
2330
+
2331
+ const newSection = {
2332
+ id: sectionId,
2333
+ title,
2334
+ summary: '',
2335
+ file: normalizedRef,
2336
+ _autoDiscovered: true
2337
+ };
2338
+
2339
+ knownFiles.add(normalizedRef);
2340
+ discovered.push(newSection);
2341
+
2342
+ // Enqueue for further following at next depth
2343
+ queue.push({ filePath: normalizedRef, depth: depth + 1 });
2344
+ }
2345
+ }
2346
+
2347
+ if (discovered.length === 0) {
2348
+ return { sections, discoveredCount: 0 };
2349
+ }
2350
+
2351
+ // Group discovered sections by directory, merging into existing groups
2352
+ // or placing under a collapsed "Referenced" top-level group
2353
+ const sectionById = new Map();
2354
+ function indexById(arr) {
2355
+ for (const s of arr) {
2356
+ if (s.id) sectionById.set(s.id, s);
2357
+ if (s.subsections) indexById(s.subsections);
2358
+ }
2359
+ }
2360
+ indexById(sections);
2361
+
2362
+ const ungrouped = [];
2363
+
2364
+ for (const ds of discovered) {
2365
+ const dirId = path.dirname(ds.id);
2366
+
2367
+ // Try to find an existing parent group to merge into
2368
+ if (dirId && dirId !== '.' && sectionById.has(dirId)) {
2369
+ const parent = sectionById.get(dirId);
2370
+ if (!parent.subsections) parent.subsections = [];
2371
+ parent.subsections.push(ds);
2372
+ } else {
2373
+ ungrouped.push(ds);
2374
+ }
2375
+ }
2376
+
2377
+ // Any ungrouped sections go into a collapsed "Referenced" group
2378
+ if (ungrouped.length > 0) {
2379
+ const existingReferenced = sectionById.get('referenced');
2380
+ if (existingReferenced) {
2381
+ if (!existingReferenced.subsections) existingReferenced.subsections = [];
2382
+ existingReferenced.subsections.push(...ungrouped);
2383
+ } else {
2384
+ sections.push({
2385
+ id: 'referenced',
2386
+ title: 'Referenced',
2387
+ summary: 'Auto-discovered documents referenced from other pages',
2388
+ collapsed: true,
2389
+ subsections: ungrouped
2390
+ });
2391
+ }
2392
+ }
2393
+
2394
+ return { sections, discoveredCount: discovered.length };
2395
+ }
2396
+
2397
+ /**
2398
+ * Build section index from scanned sections for link validation
2399
+ * Returns a Map of section ID -> { id, title, file }
2400
+ */
2401
+ function buildSectionIndex(sections, index = new Map()) {
2402
+ for (const section of sections) {
2403
+ const { id, title, file, subsections } = section;
2404
+ // Only index sections with valid IDs
2405
+ if (id) {
2406
+ index.set(id, { id, title, file });
2407
+ }
2408
+
2409
+ if (subsections && subsections.length > 0) {
2410
+ buildSectionIndex(subsections, index);
2411
+ }
2412
+ }
2413
+ return index;
2414
+ }
2415
+
2416
+ /**
2417
+ * Process scanned sections into built modules
2418
+ * Materializes content files and builds manifest entries
2419
+ */
2420
+ async function materializeScannedSections(sections, context) {
2421
+ const processed = [];
2422
+
2423
+ for (const section of sections) {
2424
+ const { id, title, summary, file, subsections, url, type, collapsed } = section;
2425
+
2426
+ // Pass through external links without processing
2427
+ if (url) {
2428
+ processed.push({ title, summary, url });
2429
+ continue;
2430
+ }
2431
+
2432
+ // Process subsections recursively
2433
+ let processedSubsections;
2434
+ if (subsections && subsections.length > 0) {
2435
+ processedSubsections = await materializeScannedSections(subsections, context);
2436
+ }
2437
+
2438
+ // If this section has a file, materialize it
2439
+ if (file) {
2440
+ const sourcePath = path.join(context.contentRoot, file);
2441
+ if (!(await pathExists(sourcePath))) {
2442
+ console.warn(` ↳ ${context.tenantId}: missing content file ${file}`);
2443
+ continue;
2444
+ }
2445
+
2446
+ const ext = path.extname(sourcePath).toLowerCase();
2447
+ const outFile = `${encodePathForFilename(id)}.js`;
2448
+ const targetPath = path.join(context.sectionsDir, outFile);
2449
+ context.keepFiles.add(outFile);
2450
+
2451
+ try {
2452
+ if (ext === '.md' || ext === '.markdown') {
2453
+ // Create link context for this file
2454
+ const linkContext = {
2455
+ currentPath: file,
2456
+ contentRoot: context.contentRoot,
2457
+ sectionIndex: context.sectionIndex,
2458
+ linkWarnings: context.linkWarnings,
2459
+ strictLinks: context.strictLinks
2460
+ };
2461
+ await ensureMarkdownModule(sourcePath, targetPath, linkContext);
2462
+ } else if (ext === '.html' || ext === '.htm') {
2463
+ await ensureHtmlModule(sourcePath, targetPath);
2464
+ } else if (ext === '.js' || ext === '.mjs') {
2465
+ await ensureJavascriptModule(sourcePath, targetPath);
2466
+ } else {
2467
+ console.warn(` ↳ ${context.tenantId}: unsupported extension ${ext} for ${file}`);
2468
+ continue;
2469
+ }
2470
+ } catch (err) {
2471
+ console.error(` ↳ ${context.tenantId}: failed to generate module for ${file} (${err.message})`);
2472
+ continue;
2473
+ }
2474
+
2475
+ context.leafOrder.push(id);
2476
+
2477
+ if (processedSubsections && processedSubsections.length > 0) {
2478
+ const entry = {
2479
+ id,
2480
+ title,
2481
+ summary,
2482
+ module: `./sections/${outFile}`,
2483
+ subsections: processedSubsections
2484
+ };
2485
+ if (type) entry.type = type;
2486
+ if (collapsed) entry.collapsed = true;
2487
+ processed.push(entry);
2488
+ } else {
2489
+ const entry = { id, title, summary, module: `./sections/${outFile}` };
2490
+ if (type) entry.type = type;
2491
+ if (collapsed) entry.collapsed = true;
2492
+ processed.push(entry);
2493
+ }
2494
+ } else if (processedSubsections && processedSubsections.length > 0) {
2495
+ // Group without its own content
2496
+ const entry = { id, title, summary, subsections: processedSubsections };
2497
+ if (type) entry.type = type;
2498
+ if (collapsed) entry.collapsed = true;
2499
+ processed.push(entry);
2500
+ }
2501
+ }
2502
+
2503
+ return processed;
2504
+ }
2505
+
2506
+ /**
2507
+ * Apply manifest-declared hierarchy to scanned sections.
2508
+ *
2509
+ * When the root _manifest.json has a `sections` array with `parent` fields,
2510
+ * the flat filesystem scan is restructured into the declared tree hierarchy.
2511
+ * Virtual groups (sections without files) become navigation groups.
2512
+ * Only sections listed in the manifest appear in navigation when parent
2513
+ * fields are present; unlisted scanned items are appended at the end.
2514
+ *
2515
+ * @param {Array} scannedSections - Filesystem-based scan result
2516
+ * @param {object} rootManifest - Root _manifest.json content
2517
+ * @returns {Array} Restructured sections tree
2518
+ */
2519
+ function applyManifestHierarchy(scannedSections, rootManifest) {
2520
+ if (!rootManifest?.sections?.length) return scannedSections;
2521
+
2522
+ // Only activate when at least one section uses parent fields
2523
+ const hasParentFields = rootManifest.sections.some(s => s.parent);
2524
+ if (!hasParentFields) return scannedSections;
2525
+
2526
+ // Build flat index of all scanned sections by ID (recursive)
2527
+ const scannedById = new Map();
2528
+ function indexScanned(sections) {
2529
+ for (const s of sections) {
2530
+ if (s.id) scannedById.set(s.id, s);
2531
+ if (s.subsections) indexScanned(s.subsections);
2532
+ }
2533
+ }
2534
+ indexScanned(scannedSections);
2535
+
2536
+ // Build parent->children map from manifest sections
2537
+ const childrenOf = new Map(); // parentId (or null for root) -> [manifest entries]
2538
+ const manifestById = new Map();
2539
+
2540
+ for (const entry of rootManifest.sections) {
2541
+ if (!entry.id) continue; // skip external links
2542
+ manifestById.set(entry.id, entry);
2543
+ const parent = entry.parent || null;
2544
+ if (!childrenOf.has(parent)) childrenOf.set(parent, []);
2545
+ childrenOf.get(parent).push(entry);
2546
+ }
2547
+
2548
+ // Recursively build a tree node from a manifest entry
2549
+ function buildNode(mEntry) {
2550
+ const scanned = scannedById.get(mEntry.id);
2551
+ const children = childrenOf.get(mEntry.id) || [];
2552
+
2553
+ const node = {
2554
+ id: mEntry.id,
2555
+ title: mEntry.title || scanned?.title || mEntry.id,
2556
+ summary: mEntry.summary || scanned?.summary || ''
2557
+ };
2558
+
2559
+ if (mEntry.collapsed) node.collapsed = true;
2560
+ if (mEntry.type) node.type = mEntry.type;
2561
+
2562
+ // Use file path from manifest declaration or scanned discovery
2563
+ if (mEntry.file) {
2564
+ node.file = mEntry.file;
2565
+ } else if (scanned?.file) {
2566
+ node.file = scanned.file;
2567
+ }
2568
+
2569
+ // Collect subsections: manifest-declared children first
2570
+ const subsections = [];
2571
+ for (const child of children) {
2572
+ subsections.push(buildNode(child));
2573
+ }
2574
+
2575
+ // Keep scanned subsections not remapped by manifest
2576
+ if (scanned?.subsections) {
2577
+ const childIds = new Set(children.map(c => c.id));
2578
+ for (const sub of scanned.subsections) {
2579
+ if (!sub.id) continue;
2580
+ // Skip if manifest declares a different parent for this item
2581
+ const mDef = manifestById.get(sub.id);
2582
+ if (mDef && mDef.parent && mDef.parent !== mEntry.id) continue;
2583
+ // Skip if already included as a manifest child
2584
+ if (childIds.has(sub.id)) continue;
2585
+ subsections.push(sub);
2586
+ }
2587
+ }
2588
+
2589
+ if (subsections.length > 0) node.subsections = subsections;
2590
+
2591
+ return node;
2592
+ }
2593
+
2594
+ // Build root-level entries (manifest sections with no parent)
2595
+ const rootChildren = childrenOf.get(null) || [];
2596
+ const result = [];
2597
+
2598
+ for (const entry of rootChildren) {
2599
+ result.push(buildNode(entry));
2600
+ }
2601
+
2602
+ // Add external links from manifest
2603
+ for (const entry of rootManifest.sections) {
2604
+ if (entry.url) {
2605
+ result.push({ title: entry.title, summary: entry.summary || '', url: entry.url });
2606
+ }
2607
+ }
2608
+
2609
+ console.log(` ↳ applied manifest hierarchy (${manifestById.size} entries, ${rootChildren.length} top-level groups)`);
2610
+ return result;
2611
+ }
2612
+
2613
+ /**
2614
+ * Process tenant content using nested directory scanning (ADR-010)
2615
+ *
2616
+ * @param {string} sourceDir - Tenant source directory
2617
+ * @param {string} distDir - Build output directory
2618
+ * @param {string} tenantId - Tenant identifier
2619
+ * @param {object} contentRoot - Content root info from findContentRoot()
2620
+ * @param {object} [options] - Build options
2621
+ * @param {boolean} [options.strictLinks=true] - Whether to error on broken links
2622
+ */
2623
+ async function processNestedContent(sourceDir, distDir, tenantId, contentRoot, options = {}, config = {}) {
2624
+ const sectionsDir = path.join(distDir, 'sections');
2625
+ const keepFiles = new Set(['section-templates.js']);
2626
+ await pruneSectionsDirectory(sectionsDir, keepFiles);
2627
+
2628
+ // Default to strict links (error on broken links)
2629
+ const strictLinks = options.strictLinks !== false;
2630
+ const linkWarnings = [];
2631
+
2632
+ // Load root _manifest.json for site configuration
2633
+ const rootManifest = await loadDirectoryManifest(contentRoot.basePath);
2634
+ const siteConfig = {
2635
+ bottomNav: rootManifest?.bottomNav || 'mobile',
2636
+ bottomNavSections: rootManifest?.bottomNavSections || [],
2637
+ // Pass SEO-relevant config to SPA for dynamic meta tag updates
2638
+ siteTitle: config.title || '',
2639
+ siteUrl: config.seo?.siteUrl || ''
2640
+ };
2641
+
2642
+ // Build export branding configuration
2643
+ const exportConfig = await buildExportConfig(config, sourceDir);
2644
+
2645
+ const context = {
2646
+ contentRoot: contentRoot.basePath,
2647
+ sectionsDir,
2648
+ tenantId,
2649
+ keepFiles,
2650
+ leafOrder: [],
2651
+ siteConfig,
2652
+ // Link transformation context (populated after scan)
2653
+ sectionIndex: null,
2654
+ linkWarnings,
2655
+ strictLinks
2656
+ };
2657
+
2658
+ // Scan content directory tree
2659
+ const scannedSections = await scanContentDirectory(contentRoot.basePath, null, context);
2660
+
2661
+ if (scannedSections.length === 0) {
2662
+ console.warn(` ↳ ${tenantId}: no content found in ${contentRoot.basePath}`);
2663
+ return { success: false };
2664
+ }
2665
+
2666
+ // Apply manifest-declared hierarchy (reparent sections via parent fields)
2667
+ const structuredSections = applyManifestHierarchy(scannedSections, rootManifest);
2668
+
2669
+ // Follow @docs/ links to auto-discover referenced documents (opt-in)
2670
+ const followLinksConfig = options.followLinks || rootManifest?.followLinks || false;
2671
+ if (followLinksConfig) {
2672
+ const flOpts = typeof followLinksConfig === 'object' ? followLinksConfig : {};
2673
+ const { discoveredCount } = await expandLinkedSections(
2674
+ structuredSections, contentRoot.basePath, flOpts
2675
+ );
2676
+ if (discoveredCount > 0) {
2677
+ console.log(` ↳ link-following: ${discoveredCount} new sections auto-discovered`);
2678
+ }
2679
+ }
2680
+
2681
+ // Build section index for link validation (pass 1)
2682
+ context.sectionIndex = buildSectionIndex(structuredSections);
2683
+
2684
+ // Materialize all sections with link transformation (pass 2)
2685
+ const processedManifest = await materializeScannedSections(structuredSections, context);
2686
+
2687
+ if (processedManifest.length === 0) {
2688
+ console.warn(` ↳ ${tenantId}: no sections materialized`);
2689
+ return { success: false };
2690
+ }
2691
+
2692
+ // Print link warnings
2693
+ if (linkWarnings.length > 0) {
2694
+ printLinkWarnings(linkWarnings, tenantId, strictLinks);
2695
+
2696
+ // Error on broken links if strict mode
2697
+ const brokenLinks = linkWarnings.filter(w => w.type === 'broken');
2698
+ if (strictLinks && brokenLinks.length > 0) {
2699
+ console.error(` ↳ [ERROR] ${tenantId}: Build failed due to ${brokenLinks.length} broken link(s). Use strictLinks: false to warn instead.`);
2700
+ return { success: false, brokenLinks: brokenLinks.length };
2701
+ }
2702
+ }
2703
+
2704
+ // Determine default section
2705
+ const defaultSection = context.leafOrder[0];
2706
+
2707
+ // Generate manifest.js with site configuration and export branding
2708
+ const manifestModule = buildManifestModuleSource(processedManifest, defaultSection, context.siteConfig, exportConfig);
2709
+ await fsp.writeFile(path.join(distDir, 'manifest.js'), manifestModule, 'utf8');
2710
+ console.log(` ↳ applied nested content structure for ${tenantId} (${context.leafOrder.length} sections)`);
2711
+
2712
+ return { success: true, sectionsCount: context.leafOrder.length };
2713
+ }
2714
+
2715
+ // ============================================================================
2716
+ // End Nested Content Directory Support
2717
+ // ============================================================================
2718
+
2719
+ async function processManifestEntries(entries, context) {
2720
+ const processed = [];
2721
+ for (const entry of entries) {
2722
+ if (!entry || typeof entry !== 'object') continue;
2723
+ const id = entry.id;
2724
+ if (!id) {
2725
+ console.warn(' ↳ manifest entry missing id, skipping');
2726
+ continue;
2727
+ }
2728
+ const title = entry.title || id;
2729
+ const summary = entry.summary || '';
2730
+ const type = entry.type || null; // Support content type (e.g., 'press-release')
2731
+ if (Array.isArray(entry.sections) && entry.sections.length) {
2732
+ const subsections = await processManifestEntries(entry.sections, context);
2733
+ const groupEntry = { id, title, summary, subsections };
2734
+ if (type) groupEntry.type = type;
2735
+ processed.push(groupEntry);
2736
+ continue;
2737
+ }
2738
+
2739
+ const modulePath = await materializeSectionModule(entry, context);
2740
+ if (modulePath) {
2741
+ context.leafOrder.push(id);
2742
+ const leafEntry = { id, title, summary, module: modulePath };
2743
+ if (type) leafEntry.type = type;
2744
+ processed.push(leafEntry);
2745
+ }
2746
+ }
2747
+ return processed;
2748
+ }
2749
+
2750
+ async function materializeSectionModule(entry, context) {
2751
+ const id = entry.id;
2752
+ const relPath = entry.file || `${id}.md`;
2753
+ const sourcePath = path.join(context.contentDir, relPath);
2754
+ if (!(await pathExists(sourcePath))) {
2755
+ console.warn(` ↳ ${context.tenantId}: missing content file ${relPath}`);
2756
+ return null;
2757
+ }
2758
+
2759
+ const ext = path.extname(sourcePath).toLowerCase();
2760
+ const outFile = `${id}.js`;
2761
+ const targetPath = path.join(context.sectionsDir, outFile);
2762
+ context.keepFiles.add(outFile);
2763
+
2764
+ try {
2765
+ if (ext === '.md' || ext === '.markdown') {
2766
+ // Create link context for this file
2767
+ const linkContext = context.sectionIndex ? {
2768
+ currentPath: relPath,
2769
+ contentRoot: context.contentDir,
2770
+ sectionIndex: context.sectionIndex,
2771
+ linkWarnings: context.linkWarnings,
2772
+ strictLinks: context.strictLinks
2773
+ } : null;
2774
+ await ensureMarkdownModule(sourcePath, targetPath, linkContext);
2775
+ } else if (ext === '.html' || ext === '.htm') {
2776
+ await ensureHtmlModule(sourcePath, targetPath);
2777
+ } else if (ext === '.js' || ext === '.mjs') {
2778
+ await ensureJavascriptModule(sourcePath, targetPath);
2779
+ } else {
2780
+ console.warn(` ↳ ${context.tenantId}: unsupported extension ${ext} for ${relPath}`);
2781
+ return null;
2782
+ }
2783
+ } catch (err) {
2784
+ console.error(` ↳ ${context.tenantId}: failed to generate module for ${relPath} (${err.message})`);
2785
+ return null;
2786
+ }
2787
+
2788
+ return `./sections/${outFile}`;
2789
+ }
2790
+
2791
+ function buildManifestModuleSource(manifestEntries, defaultSection, siteConfig = {}, exportConfig = {}) {
2792
+ const manifestJson = JSON.stringify(manifestEntries, null, 2);
2793
+ const defaultJson = JSON.stringify(defaultSection || null);
2794
+ const configJson = JSON.stringify({
2795
+ bottomNav: siteConfig.bottomNav || 'mobile', // 'always' | 'mobile' | 'never'
2796
+ bottomNavSections: siteConfig.bottomNavSections || [], // Section prefixes to always show nav
2797
+ ...siteConfig
2798
+ }, null, 2);
2799
+ const exportJson = JSON.stringify(exportConfig, null, 2);
2800
+ return `export const MANIFEST = ${manifestJson};
2801
+ export const DEFAULT_SECTION = ${defaultJson};
2802
+
2803
+ const SECTION_INDEX = new Map();
2804
+
2805
+ function registerEntry(entry, parentId = null) {
2806
+ if (parentId) entry.parentId = parentId;
2807
+ SECTION_INDEX.set(entry.id, entry);
2808
+ if (Array.isArray(entry.subsections)) {
2809
+ entry.subsections.forEach((child) => registerEntry(child, entry.id));
2810
+ }
2811
+ }
2812
+
2813
+ MANIFEST.forEach((entry) => registerEntry(entry));
2814
+
2815
+ export function findSection(id) {
2816
+ return SECTION_INDEX.get(id) || null;
2817
+ }
2818
+
2819
+ // Build flat list of navigable sections for prev/next navigation
2820
+ function buildFlatNav() {
2821
+ const flat = [];
2822
+ MANIFEST.forEach((entry) => {
2823
+ if (entry.subsections && entry.subsections.length) {
2824
+ entry.subsections.forEach((sub) => flat.push(sub));
2825
+ } else {
2826
+ flat.push(entry);
2827
+ }
2828
+ });
2829
+ return flat;
2830
+ }
2831
+
2832
+ const FLAT_NAV = buildFlatNav();
2833
+
2834
+ /**
2835
+ * Get previous and next sections for bottom navigation
2836
+ * @param {string} currentId - Current section ID
2837
+ * @returns {{ prev: object|null, next: object|null }}
2838
+ */
2839
+ export function getAdjacentSections(currentId) {
2840
+ const index = FLAT_NAV.findIndex((s) => s.id === currentId);
2841
+ if (index === -1) return { prev: null, next: null };
2842
+ return {
2843
+ prev: index > 0 ? FLAT_NAV[index - 1] : null,
2844
+ next: index < FLAT_NAV.length - 1 ? FLAT_NAV[index + 1] : null
2845
+ };
2846
+ }
2847
+
2848
+ // Site configuration (from tenant _manifest.json)
2849
+ export const SITE_CONFIG = ${configJson};
2850
+
2851
+ // Export document branding configuration
2852
+ export const EXPORT_CONFIG = ${exportJson};
2853
+ `;
2854
+ }
2855
+
2856
+ /**
2857
+ * Main entry point for processing tenant content
2858
+ * Detects content structure type and delegates to appropriate processor
2859
+ *
2860
+ * @param {string} sourceDir - Tenant source directory
2861
+ * @param {string} distDir - Build output directory
2862
+ * @param {string} tenantId - Tenant identifier
2863
+ * @param {object} [options] - Build options
2864
+ * @param {boolean} [options.strictLinks=true] - Whether to error on broken links
2865
+ * @param {object} [config={}] - Tenant configuration from config.json
2866
+ */
2867
+ async function processTenantContent(sourceDir, distDir, tenantId, options = {}, config = {}) {
2868
+ // Detect content structure type
2869
+ const contentRoot = await findContentRoot(sourceDir);
2870
+
2871
+ // Check for explicit manifest.json (legacy or hybrid mode)
2872
+ const manifestPath = path.join(sourceDir, TENANT_MANIFEST);
2873
+ const hasManifest = await pathExists(manifestPath);
2874
+
2875
+ if (contentRoot.type === 'none' && !hasManifest) {
2876
+ console.warn(` ↳ ${tenantId}: no content found`);
2877
+ return;
2878
+ }
2879
+
2880
+ // If nested structure detected, use new processing
2881
+ if (contentRoot.type === 'nested' || contentRoot.type === 'root') {
2882
+ // Check if manifest exists and has explicit file mappings (hybrid mode)
2883
+ if (hasManifest) {
2884
+ try {
2885
+ const raw = await fsp.readFile(manifestPath, 'utf8');
2886
+ const manifestData = JSON.parse(raw);
2887
+ const entries = Array.isArray(manifestData) ? manifestData : manifestData.sections;
2888
+
2889
+ // Check if any entry uses type: "directory" or lacks explicit file mappings
2890
+ const hasDirectoryType = entries?.some(e => e.type === 'directory');
2891
+ const hasExplicitFiles = entries?.every(e => e.file || e.sections);
2892
+
2893
+ if (hasDirectoryType || !hasExplicitFiles) {
2894
+ // Use nested content processing
2895
+ await processNestedContent(sourceDir, distDir, tenantId, contentRoot, options, config);
2896
+ return;
2897
+ }
2898
+ } catch {
2899
+ // Fall through to nested processing if manifest is invalid
2900
+ }
2901
+ }
2902
+
2903
+ // No manifest or manifest doesn't fully define structure - use nested scanning
2904
+ await processNestedContent(sourceDir, distDir, tenantId, contentRoot, options, config);
2905
+ return;
2906
+ }
2907
+
2908
+ // Flat content/ structure with manifest - use legacy processing
2909
+ if (hasManifest && contentRoot.type === 'flat') {
2910
+ await processTenantManifestLegacy(sourceDir, distDir, tenantId, options);
2911
+ return;
2912
+ }
2913
+
2914
+ // Fallback: try legacy processing
2915
+ if (hasManifest) {
2916
+ await processTenantManifestLegacy(sourceDir, distDir, tenantId, options);
2917
+ }
2918
+ }
2919
+
2920
+ /**
2921
+ * Legacy manifest processing for flat content/ structure
2922
+ * Preserved for backward compatibility with existing tenants
2923
+ *
2924
+ * @param {string} sourceDir - Tenant source directory
2925
+ * @param {string} distDir - Build output directory
2926
+ * @param {string} tenantId - Tenant identifier
2927
+ * @param {object} [options] - Build options
2928
+ */
2929
+ async function processTenantManifestLegacy(sourceDir, distDir, tenantId, options = {}) {
2930
+ const manifestPath = path.join(sourceDir, TENANT_MANIFEST);
2931
+ if (!(await pathExists(manifestPath))) return;
2932
+ const contentDir = path.join(sourceDir, DEFAULT_CONTENT_DIR);
2933
+ if (!(await pathExists(contentDir))) {
2934
+ console.warn(` ↳ ${tenantId}: manifest found but no content directory`);
2935
+ return;
2936
+ }
2937
+
2938
+ let manifestData;
2939
+ try {
2940
+ const raw = await fsp.readFile(manifestPath, 'utf8');
2941
+ manifestData = JSON.parse(raw);
2942
+ } catch (err) {
2943
+ console.warn(` ↳ ${tenantId}: unable to parse manifest.json (${err.message})`);
2944
+ return;
2945
+ }
2946
+
2947
+ const entries = Array.isArray(manifestData) ? manifestData : manifestData.sections;
2948
+ if (!Array.isArray(entries) || entries.length === 0) {
2949
+ console.warn(` ↳ ${tenantId}: manifest has no sections`);
2950
+ return;
2951
+ }
2952
+
2953
+ const sectionsDir = path.join(distDir, 'sections');
2954
+ const keepFiles = new Set(['section-templates.js']);
2955
+ await pruneSectionsDirectory(sectionsDir, keepFiles);
2956
+
2957
+ // Build section index for link validation
2958
+ const sectionIndex = new Map();
2959
+ function collectSectionIds(sectionEntries) {
2960
+ for (const entry of sectionEntries) {
2961
+ if (entry.id) {
2962
+ sectionIndex.set(entry.id, { id: entry.id, file: entry.file });
2963
+ }
2964
+ if (Array.isArray(entry.sections)) {
2965
+ collectSectionIds(entry.sections);
2966
+ }
2967
+ }
2968
+ }
2969
+ collectSectionIds(entries);
2970
+
2971
+ const linkWarnings = [];
2972
+ const strictLinks = options.strictLinks !== false;
2973
+
2974
+ // Extract site configuration from manifest
2975
+ const siteConfig = {
2976
+ bottomNav: manifestData.bottomNav || 'mobile',
2977
+ bottomNavSections: manifestData.bottomNavSections || []
2978
+ };
2979
+
2980
+ const context = {
2981
+ contentDir,
2982
+ sectionsDir,
2983
+ tenantId,
2984
+ keepFiles,
2985
+ leafOrder: [],
2986
+ siteConfig,
2987
+ // Link transformation context
2988
+ sectionIndex,
2989
+ linkWarnings,
2990
+ strictLinks
2991
+ };
2992
+
2993
+ const processedManifest = await processManifestEntries(entries, context);
2994
+ if (!processedManifest.length) {
2995
+ console.warn(` ↳ ${tenantId}: manifest did not produce any sections`);
2996
+ return;
2997
+ }
2998
+
2999
+ // Print link warnings
3000
+ if (linkWarnings.length > 0) {
3001
+ printLinkWarnings(linkWarnings, tenantId, strictLinks);
3002
+ }
3003
+
3004
+ const defaultSection = manifestData.default || manifestData.defaultSection || context.leafOrder[0];
3005
+ const manifestModule = buildManifestModuleSource(processedManifest, defaultSection, context.siteConfig);
3006
+ await fsp.writeFile(path.join(distDir, 'manifest.js'), manifestModule, 'utf8');
3007
+ console.log(` ↳ applied manifest-driven content for ${tenantId}`);
3008
+ }
3009
+
3010
+ /**
3011
+ * Print change summary for diff-only mode
3012
+ */
3013
+ function printChangeSummary(tenantId, changes) {
3014
+ if (!changes) {
3015
+ console.log(` ${tenantId}: local source (no change tracking)`);
3016
+ return;
3017
+ }
3018
+
3019
+ const { type, oldCommit, newCommit, files } = changes;
3020
+
3021
+ if (type === 'none') {
3022
+ console.log(` ${tenantId}: no changes (${newCommit?.slice(0, 7) || 'unknown'})`);
3023
+ return;
3024
+ }
3025
+
3026
+ if (type === 'full') {
3027
+ console.log(` ${tenantId}: full rebuild required`);
3028
+ if (newCommit) {
3029
+ console.log(` commit: ${newCommit.slice(0, 7)}`);
3030
+ }
3031
+ return;
3032
+ }
3033
+
3034
+ // Incremental changes
3035
+ console.log(` ${tenantId}: ${oldCommit?.slice(0, 7)} → ${newCommit?.slice(0, 7)}`);
3036
+
3037
+ if (files.added.length > 0) {
3038
+ console.log(` added (${files.added.length}):`);
3039
+ files.added.forEach(f => console.log(` + ${f}`));
3040
+ }
3041
+ if (files.modified.length > 0) {
3042
+ console.log(` modified (${files.modified.length}):`);
3043
+ files.modified.forEach(f => console.log(` ~ ${f}`));
3044
+ }
3045
+ if (files.deleted.length > 0) {
3046
+ console.log(` deleted (${files.deleted.length}):`);
3047
+ files.deleted.forEach(f => console.log(` - ${f}`));
3048
+ }
3049
+ }
3050
+
3051
+ /**
3052
+ * Build a single tenant using registry entry
3053
+ * Returns { success, changes } where changes contains git diff info if applicable
3054
+ */
3055
+ async function buildTenant(tenant, targetOverride, cacheDir, buildOptions) {
3056
+ const tenantId = tenant.id;
3057
+ const targetDir = targetOverride
3058
+ ? path.join(resolvePath(targetOverride), tenantId)
3059
+ : tenant.target.resolvedPath;
3060
+
3061
+ // Resolve source (handles git cloning with change tracking)
3062
+ let sourceResult;
3063
+ try {
3064
+ sourceResult = await resolveSource(tenant.source, cacheDir, buildOptions);
3065
+ } catch (err) {
3066
+ console.error(` ↳ Failed to resolve source: ${err.message}`);
3067
+ return { success: false, changes: null };
3068
+ }
3069
+
3070
+ const { sourcePath: sourceDir, changes } = sourceResult;
3071
+
3072
+ // Handle diff-only mode - just return the changes
3073
+ if (buildOptions.diffOnly) {
3074
+ printChangeSummary(tenantId, changes);
3075
+ return { success: true, changes };
3076
+ }
3077
+
3078
+ // Check for explicit file targeting (--files option)
3079
+ const hasExplicitFiles = buildOptions.files && buildOptions.files.length > 0;
3080
+
3081
+ // Handle incremental mode with no changes (only if not explicitly targeting files)
3082
+ if (!hasExplicitFiles && buildOptions.incremental && changes?.type === 'none') {
3083
+ console.log(`Skipping ${tenantId}: no changes detected`);
3084
+ return { success: true, changes };
3085
+ }
3086
+
3087
+ // Validate source exists
3088
+ if (!(await pathExists(sourceDir))) {
3089
+ console.warn(`Skipping ${tenantId}: source directory not found at ${sourceDir}`);
3090
+ return { success: false, changes };
3091
+ }
3092
+
3093
+ // Load config from registry or source directory
3094
+ let config = { ...tenant.config };
3095
+ const configPath = path.join(sourceDir, 'config.json');
3096
+ if (await pathExists(configPath)) {
3097
+ try {
3098
+ const raw = await fsp.readFile(configPath, 'utf8');
3099
+ const fileConfig = JSON.parse(raw);
3100
+ // Registry config takes precedence over file config
3101
+ config = { ...fileConfig, ...config };
3102
+ } catch (err) {
3103
+ console.warn(` ↳ ${tenantId}: unable to parse config.json (${err.message})`);
3104
+ }
3105
+ }
3106
+
3107
+ // Build to a temporary location in dist/ first, then copy to target
3108
+ const buildOutput = path.join('dist', tenantId);
3109
+ const distDir = path.join(root, 'dist', tenantId);
3110
+
3111
+ console.log(`Building tenant ${tenantId}`);
3112
+ console.log(` source: ${sourceDir}`);
3113
+ console.log(` target: ${targetDir}`);
3114
+
3115
+ // Determine build mode
3116
+ // Priority: explicit files > git incremental > full build
3117
+ const isExplicitFileBuild = hasExplicitFiles;
3118
+ const isGitIncrementalBuild = !hasExplicitFiles && buildOptions.incremental && changes?.type === 'incremental';
3119
+ const isIncrementalBuild = isExplicitFileBuild || isGitIncrementalBuild;
3120
+
3121
+ // Content processing options (strictLinks defaults to true unless explicitly set to false)
3122
+ // Check both tenant registry and config.json for strictLinks setting
3123
+ const strictLinksSetting = tenant.strictLinks !== undefined ? tenant.strictLinks : config.strictLinks;
3124
+ const followLinksSetting = tenant.followLinks !== undefined ? tenant.followLinks : config.followLinks;
3125
+ const contentOptions = {
3126
+ strictLinks: strictLinksSetting !== false,
3127
+ followLinks: followLinksSetting || false
3128
+ };
3129
+
3130
+ if (isExplicitFileBuild) {
3131
+ // Explicit file targeting - create synthetic change set
3132
+ const explicitFiles = {
3133
+ added: [],
3134
+ modified: buildOptions.files.map(f => f.startsWith('content/') ? f : `content/${f}`),
3135
+ deleted: []
3136
+ };
3137
+ console.log(` mode: targeted (${explicitFiles.modified.length} file(s) specified)`);
3138
+
3139
+ // For targeted builds, we need the base build to exist
3140
+ if (!(await pathExists(distDir))) {
3141
+ console.log(` ↳ no existing build found, performing full build first`);
3142
+ await runBuild(buildOutput);
3143
+ await processTenantContent(sourceDir, distDir, tenantId, contentOptions, config);
3144
+ }
3145
+
3146
+ // Process only the specified files
3147
+ await processIncrementalManifest(sourceDir, distDir, tenantId, explicitFiles, contentOptions, config);
3148
+ } else if (isGitIncrementalBuild) {
3149
+ console.log(` mode: incremental (${changes.files.added.length + changes.files.modified.length} files to process)`);
3150
+
3151
+ // For incremental builds, we need the base build to exist
3152
+ if (!(await pathExists(distDir))) {
3153
+ console.log(` ↳ no existing build found, performing full build`);
3154
+ await runBuild(buildOutput);
3155
+ }
3156
+
3157
+ // Process only changed content files
3158
+ await processIncrementalManifest(sourceDir, distDir, tenantId, changes.files, contentOptions, config);
3159
+ } else {
3160
+ console.log(` mode: full`);
3161
+ await runBuild(buildOutput);
3162
+
3163
+ // Process full manifest from source directory
3164
+ await processTenantContent(sourceDir, distDir, tenantId, contentOptions, config);
3165
+ }
3166
+
3167
+ // Apply file overrides from source FIRST (before branding/theme modifications)
3168
+ const overridesDir = path.join(sourceDir, 'overrides');
3169
+ if (await pathExists(overridesDir)) {
3170
+ await copyDirectory(overridesDir, distDir);
3171
+ console.log(` ↳ applied file overrides for ${tenantId}`);
3172
+ }
3173
+
3174
+ // Apply branding and config (on top of overrides)
3175
+ if (Object.keys(config).length > 0) {
3176
+ await applyBranding(distDir, config, tenantId);
3177
+ await applyThemeColors(distDir, config, tenantId);
3178
+ await applyNavPosition(distDir, config, tenantId);
3179
+ await applyWelcome(distDir, config, tenantId);
3180
+ }
3181
+
3182
+ // Copy static assets from .public/ directory
3183
+ await copyPublicAssets(sourceDir, distDir, tenantId);
3184
+
3185
+ // Generate SEO artifacts (sitemap.xml, robots.txt, static pages)
3186
+ await generateSeoArtifacts(distDir, config);
3187
+
3188
+ // Copy to final target if different from dist
3189
+ if (targetDir !== distDir) {
3190
+ // Ensure target parent exists
3191
+ await fsp.mkdir(path.dirname(targetDir), { recursive: true });
3192
+
3193
+ // For incremental builds, sync changes rather than full copy
3194
+ if (isIncrementalBuild && (await pathExists(targetDir))) {
3195
+ await syncChangesToTarget(distDir, targetDir, changes.files);
3196
+ console.log(` ↳ synced changes to ${targetDir}`);
3197
+ } else {
3198
+ // Remove existing target if it exists
3199
+ if (await pathExists(targetDir)) {
3200
+ await fsp.rm(targetDir, { recursive: true, force: true });
3201
+ }
3202
+ await copyDirectory(distDir, targetDir);
3203
+ console.log(` ↳ deployed to ${targetDir}`);
3204
+ }
3205
+ }
3206
+
3207
+ console.log(`Tenant ${tenantId} ready`);
3208
+ return { success: true, changes };
3209
+ }
3210
+
3211
+ /**
3212
+ * Sync only changed files to target directory
3213
+ */
3214
+ async function syncChangesToTarget(srcDir, targetDir, files) {
3215
+ // Handle added and modified files
3216
+ const toCopy = [...files.added, ...files.modified];
3217
+ for (const relPath of toCopy) {
3218
+ // Map content file to section JS module
3219
+ const sectionId = path.basename(relPath, path.extname(relPath));
3220
+ const srcFile = path.join(srcDir, 'sections', `${sectionId}.js`);
3221
+ const destFile = path.join(targetDir, 'sections', `${sectionId}.js`);
3222
+
3223
+ if (await pathExists(srcFile)) {
3224
+ await fsp.mkdir(path.dirname(destFile), { recursive: true });
3225
+ await fsp.copyFile(srcFile, destFile);
3226
+ }
3227
+ }
3228
+
3229
+ // Handle deleted files
3230
+ for (const relPath of files.deleted) {
3231
+ const sectionId = path.basename(relPath, path.extname(relPath));
3232
+ const destFile = path.join(targetDir, 'sections', `${sectionId}.js`);
3233
+
3234
+ if (await pathExists(destFile)) {
3235
+ await fsp.rm(destFile, { force: true });
3236
+ }
3237
+ }
3238
+
3239
+ // Always sync manifest.js as it may have changed
3240
+ const manifestSrc = path.join(srcDir, 'manifest.js');
3241
+ const manifestDest = path.join(targetDir, 'manifest.js');
3242
+ if (await pathExists(manifestSrc)) {
3243
+ await fsp.copyFile(manifestSrc, manifestDest);
3244
+ }
3245
+ }
3246
+
3247
+ /**
3248
+ * Process only changed content files for incremental builds
3249
+ *
3250
+ * @param {string} sourceDir - Tenant source directory
3251
+ * @param {string} distDir - Build output directory
3252
+ * @param {string} tenantId - Tenant identifier
3253
+ * @param {object} changedFiles - Changed files { added, modified, deleted }
3254
+ * @param {object} [options] - Build options
3255
+ * @param {object} [config={}] - Tenant configuration
3256
+ */
3257
+ async function processIncrementalManifest(sourceDir, distDir, tenantId, changedFiles, options = {}, config = {}) {
3258
+ const contentDir = path.join(sourceDir, DEFAULT_CONTENT_DIR);
3259
+ const sectionsDir = path.join(distDir, 'sections');
3260
+
3261
+ // Ensure sections directory exists
3262
+ await fsp.mkdir(sectionsDir, { recursive: true });
3263
+
3264
+ // Build file-to-sectionId map from manifest
3265
+ const fileToSectionId = await buildFileToSectionMap(sourceDir);
3266
+
3267
+ // Build section index for link validation (from existing manifest.js if available)
3268
+ // Note: For full link validation in incremental mode, we'd need to scan all content
3269
+ // For now, we provide a partial context (without full section index)
3270
+ const contentRoot = await findContentRoot(sourceDir);
3271
+ const linkWarnings = [];
3272
+ const sectionIndex = new Map();
3273
+
3274
+ // Add known sections from fileToSectionId map
3275
+ for (const [file, id] of fileToSectionId.entries()) {
3276
+ sectionIndex.set(id, { id, file });
3277
+ }
3278
+
3279
+ // Filter to content files only
3280
+ const contentFiles = [...changedFiles.added, ...changedFiles.modified]
3281
+ .filter(f => f.startsWith('content/') || f.startsWith(DEFAULT_CONTENT_DIR + '/'))
3282
+ .map(f => f.replace(/^content\//, ''));
3283
+
3284
+ console.log(` ↳ processing ${contentFiles.length} changed content file(s)`);
3285
+
3286
+ for (const relPath of contentFiles) {
3287
+ const sourcePath = path.join(contentDir, relPath);
3288
+
3289
+ if (!(await pathExists(sourcePath))) {
3290
+ console.warn(` ↳ ${tenantId}: changed file not found: ${relPath}`);
3291
+ continue;
3292
+ }
3293
+
3294
+ const ext = path.extname(sourcePath).toLowerCase();
3295
+ // Use manifest section ID if available, otherwise fall back to filename
3296
+ const sectionId = fileToSectionId.get(relPath) || path.basename(relPath, ext);
3297
+ const targetPath = path.join(sectionsDir, `${sectionId}.js`);
3298
+
3299
+ try {
3300
+ if (ext === '.md' || ext === '.markdown') {
3301
+ // Create link context for this file
3302
+ const linkContext = {
3303
+ currentPath: relPath,
3304
+ contentRoot: contentRoot.basePath || contentDir,
3305
+ sectionIndex,
3306
+ linkWarnings,
3307
+ strictLinks: options.strictLinks !== false
3308
+ };
3309
+ await ensureMarkdownModule(sourcePath, targetPath, linkContext);
3310
+ console.log(` ↳ updated: ${sectionId} (markdown)`);
3311
+ } else if (ext === '.html' || ext === '.htm') {
3312
+ await ensureHtmlModule(sourcePath, targetPath);
3313
+ console.log(` ↳ updated: ${sectionId} (html)`);
3314
+ } else if (ext === '.js' || ext === '.mjs') {
3315
+ await ensureJavascriptModule(sourcePath, targetPath);
3316
+ console.log(` ↳ updated: ${sectionId} (js)`);
3317
+ }
3318
+ } catch (err) {
3319
+ console.error(` ↳ ${tenantId}: failed to update ${relPath}: ${err.message}`);
3320
+ }
3321
+ }
3322
+
3323
+ // Print link warnings
3324
+ if (linkWarnings.length > 0) {
3325
+ printLinkWarnings(linkWarnings, tenantId, options.strictLinks !== false);
3326
+ }
3327
+
3328
+ // Handle deleted content files
3329
+ const deletedContent = changedFiles.deleted
3330
+ .filter(f => f.startsWith('content/') || f.startsWith(DEFAULT_CONTENT_DIR + '/'))
3331
+ .map(f => f.replace(/^content\//, ''));
3332
+
3333
+ for (const relPath of deletedContent) {
3334
+ const ext = path.extname(relPath);
3335
+ // Use manifest section ID if available, otherwise fall back to filename
3336
+ const sectionId = fileToSectionId.get(relPath) || path.basename(relPath, ext);
3337
+ const targetPath = path.join(sectionsDir, `${sectionId}.js`);
3338
+
3339
+ if (await pathExists(targetPath)) {
3340
+ await fsp.rm(targetPath, { force: true });
3341
+ console.log(` ↳ removed: ${sectionId}`);
3342
+ }
3343
+ }
3344
+
3345
+ // Check if manifest.json was modified
3346
+ const manifestChanged = changedFiles.added.includes('manifest.json') ||
3347
+ changedFiles.modified.includes('manifest.json');
3348
+
3349
+ if (manifestChanged) {
3350
+ console.log(` ↳ manifest.json changed, regenerating manifest.js`);
3351
+ await processTenantContent(sourceDir, distDir, tenantId, options, config);
3352
+ }
3353
+ }
3354
+
3355
+ /**
3356
+ * Build a map from content file paths to manifest section IDs
3357
+ * This allows incremental builds to use the correct output filename
3358
+ */
3359
+ async function buildFileToSectionMap(sourceDir) {
3360
+ const fileToId = new Map();
3361
+ const manifestPath = path.join(sourceDir, TENANT_MANIFEST);
3362
+
3363
+ if (!(await pathExists(manifestPath))) {
3364
+ return fileToId;
3365
+ }
3366
+
3367
+ try {
3368
+ const raw = await fsp.readFile(manifestPath, 'utf8');
3369
+ const manifestData = JSON.parse(raw);
3370
+ const entries = Array.isArray(manifestData) ? manifestData : manifestData.sections;
3371
+
3372
+ if (Array.isArray(entries)) {
3373
+ collectFileToIdMappings(entries, fileToId);
3374
+ }
3375
+ } catch {
3376
+ // If manifest can't be read, return empty map (will fall back to filename)
3377
+ }
3378
+
3379
+ return fileToId;
3380
+ }
3381
+
3382
+ /**
3383
+ * Recursively collect file → section ID mappings from manifest entries
3384
+ */
3385
+ function collectFileToIdMappings(entries, map) {
3386
+ for (const entry of entries) {
3387
+ if (!entry || typeof entry !== 'object') continue;
3388
+
3389
+ if (Array.isArray(entry.sections) && entry.sections.length) {
3390
+ collectFileToIdMappings(entry.sections, map);
3391
+ } else if (entry.id && entry.file) {
3392
+ // Map the file path to the section ID
3393
+ map.set(entry.file, entry.id);
3394
+ }
3395
+ }
3396
+ }
3397
+
3398
+ async function main() {
3399
+ const args = parseArgs(process.argv);
3400
+
3401
+ // Handle --help
3402
+ if (args.help) {
3403
+ printHelp();
3404
+ return;
3405
+ }
3406
+
3407
+ // Load registry
3408
+ const registry = await loadRegistry(args.registry);
3409
+
3410
+ // Check for git sources and validate git availability
3411
+ const hasGitSources = registry.tenants.some(t => t.source.type === 'git');
3412
+ if (hasGitSources && !isGitAvailable()) {
3413
+ console.error('Error: Git sources configured but git is not available. Please install git.');
3414
+ process.exit(1);
3415
+ }
3416
+
3417
+ // Handle --list
3418
+ if (args.list) {
3419
+ if (registry.tenants.length === 0) {
3420
+ console.log('No tenants found.');
3421
+ } else {
3422
+ console.log('Available tenants:');
3423
+ registry.tenants.forEach((t) => {
3424
+ const status = t.enabled ? '' : ' (disabled)';
3425
+ console.log(` - ${t.id}${status}`);
3426
+ if (t.source.type === 'git') {
3427
+ const safeUrl = t.source.url.replace(/\/\/[^@]+@/, '//***@');
3428
+ console.log(` source: git ${safeUrl}`);
3429
+ console.log(` ref: ${t.source.ref}, path: ${t.source.path || '(root)'}`);
3430
+ } else {
3431
+ console.log(` source: ${t.source.resolvedPath}`);
3432
+ }
3433
+ console.log(` target: ${t.target.resolvedPath}`);
3434
+ if (t.domains.length > 0) {
3435
+ console.log(` domains: ${t.domains.join(', ')}`);
3436
+ }
3437
+ });
3438
+ }
3439
+ return;
3440
+ }
3441
+
3442
+ if (registry.tenants.length === 0) {
3443
+ console.log('No tenant configurations detected.');
3444
+ return;
3445
+ }
3446
+
3447
+ // Determine which tenants to build
3448
+ let tenantsToBuild = registry.tenants.filter(t => t.enabled);
3449
+ if (args.tenants.length > 0) {
3450
+ const allIds = registry.tenants.map(t => t.id);
3451
+ const invalid = args.tenants.filter(t => !allIds.includes(t));
3452
+ if (invalid.length > 0) {
3453
+ console.error(`Error: Unknown tenant(s): ${invalid.join(', ')}`);
3454
+ console.error(`Available tenants: ${allIds.join(', ')}`);
3455
+ process.exit(1);
3456
+ }
3457
+ tenantsToBuild = registry.tenants.filter(t => args.tenants.includes(t.id));
3458
+ }
3459
+
3460
+ if (tenantsToBuild.length === 0) {
3461
+ console.log('No tenants to build (all may be disabled).');
3462
+ return;
3463
+ }
3464
+
3465
+ // Validate target override if specified
3466
+ if (args.target) {
3467
+ const targetPath = resolvePath(args.target);
3468
+ const targetParent = path.dirname(targetPath);
3469
+ if (!(await pathExists(targetParent))) {
3470
+ console.error(`Error: Target parent directory does not exist: ${targetParent}`);
3471
+ process.exit(1);
3472
+ }
3473
+ if (!(await pathExists(targetPath))) {
3474
+ await fsp.mkdir(targetPath, { recursive: true });
3475
+ console.log(`Created target directory: ${targetPath}`);
3476
+ }
3477
+ }
3478
+
3479
+ // Set up cache directory
3480
+ const cacheDir = args.cacheDir || DEFAULT_CACHE_DIR;
3481
+
3482
+ // Clean cache if requested
3483
+ if (args.cleanCache && await pathExists(cacheDir)) {
3484
+ console.log(`Cleaning git cache at ${cacheDir}...`);
3485
+ await fsp.rm(cacheDir, { recursive: true, force: true });
3486
+ }
3487
+
3488
+ // Build options for all tenants
3489
+ const buildOptions = {
3490
+ cleanCache: args.cleanCache,
3491
+ noSparse: args.noSparse,
3492
+ gitDepth: args.gitDepth,
3493
+ incremental: args.incremental,
3494
+ diffOnly: args.diffOnly,
3495
+ files: args.files
3496
+ };
3497
+
3498
+ // Handle diff-only mode header
3499
+ if (args.diffOnly) {
3500
+ console.log(`Checking ${tenantsToBuild.length} tenant(s) for changes...`);
3501
+ console.log('');
3502
+ } else {
3503
+ // Build tenants header
3504
+ let mode = 'full';
3505
+ if (args.files.length > 0) {
3506
+ mode = `files: ${args.files.join(', ')}`;
3507
+ } else if (args.incremental) {
3508
+ mode = 'incremental';
3509
+ }
3510
+ console.log(`Building ${tenantsToBuild.length} tenant(s) [${mode}]: ${tenantsToBuild.map(t => t.id).join(', ')}`);
3511
+ console.log('');
3512
+ }
3513
+
3514
+ let successCount = 0;
3515
+ let failCount = 0;
3516
+ let skippedCount = 0;
3517
+ const results = [];
3518
+
3519
+ for (const tenant of tenantsToBuild) {
3520
+ try {
3521
+ // eslint-disable-next-line no-await-in-loop
3522
+ const result = await buildTenant(tenant, args.target, cacheDir, buildOptions);
3523
+
3524
+ if (result.success) {
3525
+ successCount++;
3526
+ // Track skipped (no changes) vs actually built
3527
+ if (args.incremental && result.changes?.type === 'none') {
3528
+ skippedCount++;
3529
+ }
3530
+ } else {
3531
+ failCount++;
3532
+ }
3533
+
3534
+ results.push({ tenantId: tenant.id, ...result });
3535
+ } catch (err) {
3536
+ console.error(`Error building ${tenant.id}: ${err.message}`);
3537
+ failCount++;
3538
+ results.push({ tenantId: tenant.id, success: false, changes: null, error: err.message });
3539
+ }
3540
+ console.log('');
3541
+ }
3542
+
3543
+ // Clean up cache (keep if incremental or diffOnly to preserve state)
3544
+ const shouldKeepCache = args.keepCache || args.incremental || args.diffOnly;
3545
+ await cleanupCache(cacheDir, shouldKeepCache);
3546
+
3547
+ // Summary
3548
+ if (args.diffOnly) {
3549
+ console.log('Change detection complete.');
3550
+ } else {
3551
+ const builtCount = successCount - skippedCount;
3552
+ let summary = `Build complete. Built: ${builtCount}`;
3553
+ if (skippedCount > 0) summary += `, Skipped (no changes): ${skippedCount}`;
3554
+ if (failCount > 0) summary += `, Failed: ${failCount}`;
3555
+ console.log(summary);
3556
+
3557
+ if (args.target) {
3558
+ console.log(`Tenant files deployed to: ${resolvePath(args.target)}`);
3559
+ }
3560
+ }
3561
+
3562
+ // Return results for programmatic use (if this script is imported)
3563
+ return results;
3564
+ }
3565
+
3566
+ main().catch((err) => {
3567
+ console.error(err);
3568
+ process.exit(1);
3569
+ });