@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.
- package/LICENSE +661 -0
- package/README.md +337 -0
- package/bin/pagenary.mjs +116 -0
- package/build.config.json +5 -0
- package/package.json +66 -0
- package/scripts/build-site.js +87 -0
- package/scripts/build-tenants.js +3569 -0
- package/scripts/build.js +99 -0
- package/scripts/generate-sections.js +41 -0
- package/scripts/lib/seo-generator.js +558 -0
- package/scripts/lint-content.js +62 -0
- package/scripts/seo-smoke.js +94 -0
- package/scripts/serve.js +142 -0
- package/site/app.js +1 -0
- package/site/index.html +57 -0
- package/site/lib/categories.js +1 -0
- package/site/lib/export.js +1 -0
- package/site/lib/manifest-utils.js +1 -0
- package/site/lib/router.js +1 -0
- package/site/lib/search.js +1 -0
- package/site/llms.txt +22 -0
- package/site/manifest.js +132 -0
- package/site/mermaid-init.js +1 -0
- package/site/pages/api.html +339 -0
- package/site/pages/architecture.html +303 -0
- package/site/pages/deployment.html +282 -0
- package/site/pages/developer-guide.html +157 -0
- package/site/pages/extending.html +135 -0
- package/site/pages/quickstart.html +318 -0
- package/site/pages/seo-strategy.html +121 -0
- package/site/pages/tenant-config.html +519 -0
- package/site/pages/welcome.html +116 -0
- package/site/robots.txt +10 -0
- package/site/sections/api.js +3 -0
- package/site/sections/architecture.js +3 -0
- package/site/sections/deployment.js +3 -0
- package/site/sections/developer-guide.js +3 -0
- package/site/sections/extending.js +3 -0
- package/site/sections/quickstart.js +3 -0
- package/site/sections/section-templates.js +1 -0
- package/site/sections/seo-strategy.js +3 -0
- package/site/sections/tenant-config.js +3 -0
- package/site/sections/welcome.js +3 -0
- package/site/seo.js +1 -0
- package/site/sitemap.xml +63 -0
- package/site/styles.css +1982 -0
- package/site/syntax-highlight.js +1 -0
- package/src/app.js +988 -0
- package/src/index.html +56 -0
- package/src/lib/categories.js +55 -0
- package/src/lib/export.js +195 -0
- package/src/lib/manifest-utils.js +69 -0
- package/src/lib/router.js +44 -0
- package/src/lib/search.js +151 -0
- package/src/manifest.js +246 -0
- package/src/mermaid-init.js +207 -0
- package/src/sections/archive-future-roadmap.js +7 -0
- package/src/sections/archive-initiative-alpha.js +7 -0
- package/src/sections/archive-milestone-records.js +7 -0
- package/src/sections/archive-timeline-overview.js +7 -0
- package/src/sections/core-technology-compliance-frameworks.js +7 -0
- package/src/sections/core-technology-coordination-model.js +7 -0
- package/src/sections/core-technology-data-definitions.js +7 -0
- package/src/sections/core-technology-hardware-integration.js +7 -0
- package/src/sections/core-technology-integrity-controls.js +7 -0
- package/src/sections/core-technology-network-topology.js +7 -0
- package/src/sections/core-technology-operator-requirements.js +7 -0
- package/src/sections/core-technology-overview.js +7 -0
- package/src/sections/core-technology-service-interfaces.js +7 -0
- package/src/sections/core-technology-synchronization-strategy.js +7 -0
- package/src/sections/core-technology-system-foundation.js +7 -0
- package/src/sections/developers-api-credentials.js +7 -0
- package/src/sections/developers-api-operations.js +7 -0
- package/src/sections/developers-api-reference.js +7 -0
- package/src/sections/developers-api-websocket.js +7 -0
- package/src/sections/developers-automation-blueprints.js +7 -0
- package/src/sections/developers-automation-modules.js +7 -0
- package/src/sections/developers-automation-patterns.js +7 -0
- package/src/sections/developers-deployment-playbook.js +7 -0
- package/src/sections/developers-overview.js +7 -0
- package/src/sections/developers-scheduling-patterns.js +7 -0
- package/src/sections/developers-sdk-go.js +7 -0
- package/src/sections/developers-sdk-javascript.js +7 -0
- package/src/sections/developers-sdk-python.js +7 -0
- package/src/sections/developers-sdk-rust.js +7 -0
- package/src/sections/developers-sdks.js +7 -0
- package/src/sections/developers-solution-examples.js +7 -0
- package/src/sections/developers-testing-framework.js +7 -0
- package/src/sections/getting-started-architecture-basics.js +7 -0
- package/src/sections/getting-started-introduction.js +7 -0
- package/src/sections/getting-started-performance-overview.js +7 -0
- package/src/sections/governance-community-initiatives.js +7 -0
- package/src/sections/governance-dao-overview.js +7 -0
- package/src/sections/governance-multi-token.js +7 -0
- package/src/sections/governance-overview.js +7 -0
- package/src/sections/governance-proposal-process.js +7 -0
- package/src/sections/governance-proposals.js +7 -0
- package/src/sections/governance-structure.js +7 -0
- package/src/sections/governance-token-distribution.js +7 -0
- package/src/sections/governance-treasury.js +7 -0
- package/src/sections/operations-environment-prep.js +7 -0
- package/src/sections/operations-getting-started.js +7 -0
- package/src/sections/operations-incentives-guide.js +7 -0
- package/src/sections/operations-incentives-strategies.js +7 -0
- package/src/sections/operations-incentives.js +7 -0
- package/src/sections/operations-infrastructure.js +7 -0
- package/src/sections/operations-monitoring.js +7 -0
- package/src/sections/operations-overview.js +7 -0
- package/src/sections/operations-performance.js +7 -0
- package/src/sections/operations-power-infrastructure.js +7 -0
- package/src/sections/operations-setup-guide.js +7 -0
- package/src/sections/operations-sync-setup.js +7 -0
- package/src/sections/products-flagship-solution.js +7 -0
- package/src/sections/products-solution-library.js +7 -0
- package/src/sections/resources-brand-assets.js +7 -0
- package/src/sections/resources-faq.js +7 -0
- package/src/sections/resources-glossary.js +7 -0
- package/src/sections/resources-research-papers.js +7 -0
- package/src/sections/section-templates.js +873 -0
- package/src/sections/security-audits.js +7 -0
- package/src/sections/security-best-practices.js +7 -0
- package/src/sections/security-bug-bounty.js +7 -0
- package/src/sections/security-incident-response.js +7 -0
- package/src/sections/security-overview.js +7 -0
- package/src/sections/technical-architecture.js +7 -0
- package/src/sections/technical-whitepaper.js +7 -0
- package/src/sections/tutorial-automation-bot.js +7 -0
- package/src/sections/tutorial-build-first-integration.js +7 -0
- package/src/sections/tutorial-deploy-automation.js +7 -0
- package/src/sections/tutorial-event-driven-experience.js +7 -0
- package/src/sections/tutorial-operations-onboarding.js +7 -0
- package/src/sections/tutorial-systems-integration.js +7 -0
- package/src/sections/tutorials-overview.js +7 -0
- package/src/sections/use-case-connected-devices.js +7 -0
- package/src/sections/use-case-digital-auctions.js +7 -0
- package/src/sections/use-case-financial-automation.js +7 -0
- package/src/sections/use-case-interactive-media.js +7 -0
- package/src/sections/use-case-realtime-execution.js +7 -0
- package/src/sections/use-case-research-analytics.js +7 -0
- package/src/sections/use-case-supply-operations.js +7 -0
- package/src/sections/use-cases-overview.js +7 -0
- package/src/sections/welcome-overview.js +7 -0
- package/src/seo.js +90 -0
- package/src/styles.css +1982 -0
- package/src/syntax-highlight.js +90 -0
- package/tenants.json.example +68 -0
- 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, '&')
|
|
720
|
+
.replace(/</g, '<')
|
|
721
|
+
.replace(/>/g, '>')
|
|
722
|
+
.replace(/"/g, '"')
|
|
723
|
+
.replace(/'/g, ''');
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function escapeAttr(value) {
|
|
727
|
+
return escapeHtml(value).replace(/`/g, '`');
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function escapeAttribute(value) {
|
|
731
|
+
return String(value)
|
|
732
|
+
.replace(/&/g, '&')
|
|
733
|
+
.replace(/"/g, '"')
|
|
734
|
+
.replace(/'/g, ''')
|
|
735
|
+
.replace(/</g, '<')
|
|
736
|
+
.replace(/>/g, '>');
|
|
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:  - 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(/<strong>([^]*?)<\/strong>/g, '<strong>$1</strong>')
|
|
1345
|
+
.replace(/<em>([^]*?)<\/em>/g, '<em>$1</em>')
|
|
1346
|
+
.replace(/<img src="([^&]*)" alt="([^&]*)">/g, '<img src="$1" alt="$2">')
|
|
1347
|
+
.replace(/<a href="([^&]*)"(?: target="_blank" rel="noopener noreferrer")?>([^]*?)<\/a>/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(/<span title="([^&]*)">([^]*?)<\/span>/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
|
+
});
|