mnfst-render 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # mnfst-render
2
+
3
+ Static renderer for Manifest projects.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npx mnfst-render --root .
9
+ ```
10
+
11
+ The command reads `manifest.json` and writes rendered pages to `manifest.prerender.output` (default `website`).
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import '../manifest.render.mjs';
@@ -0,0 +1,1020 @@
1
+ #!/usr/bin/env node
2
+
3
+ /* Manifest Render */
4
+
5
+ import { readFileSync, mkdirSync, writeFileSync, existsSync, rmSync, statSync, readdirSync, cpSync } from 'node:fs';
6
+ import { join, resolve, dirname, relative, basename } from 'node:path';
7
+ import { createServer } from 'node:http';
8
+ import { fileURLToPath } from 'node:url';
9
+
10
+ const __dirname = dirname(fileURLToPath(import.meta.url));
11
+
12
+ // --- Config ------------------------------------------------------------------
13
+
14
+ function parseArgs() {
15
+ const args = process.argv.slice(2);
16
+ const out = {};
17
+ for (let i = 0; i < args.length; i++) {
18
+ if (args[i] === '--base' && args[i + 1]) { out.baseUrl = args[++i]; continue; }
19
+ if (args[i] === '--local' && args[i + 1]) { out.localUrl = args[++i]; continue; }
20
+ if (args[i] === '--live' && args[i + 1]) { out.liveUrl = args[++i]; continue; }
21
+ if (args[i] === '--out' && args[i + 1]) { out.output = args[++i]; continue; }
22
+ if (args[i] === '--root' && args[i + 1]) { out.root = args[++i]; continue; }
23
+ if (args[i] === '--serve') { out.serve = true; continue; }
24
+ if (args[i] === '--wait' && args[i + 1]) { out.wait = parseInt(args[++i], 10); continue; }
25
+ if (args[i] === '--wait-after-idle' && args[i + 1]) { out.waitAfterIdle = parseInt(args[++i], 10); continue; }
26
+ if (args[i] === '--concurrency' && args[i + 1]) { out.concurrency = parseInt(args[++i], 10); continue; }
27
+ if (args[i] === '--dry-run') { out.dryRun = true; continue; }
28
+ }
29
+ return out;
30
+ }
31
+
32
+ function loadConfig(rootDir) {
33
+ const manifestPath = join(rootDir, 'manifest.json');
34
+ if (!existsSync(manifestPath)) {
35
+ return { prerender: {} };
36
+ }
37
+ const raw = readFileSync(manifestPath, 'utf8');
38
+ let manifest;
39
+ try {
40
+ manifest = JSON.parse(raw);
41
+ } catch {
42
+ return { prerender: {} };
43
+ }
44
+ return manifest;
45
+ }
46
+
47
+ function resolveConfig() {
48
+ const cli = parseArgs();
49
+ const cwd = process.cwd();
50
+ const root = resolve(cwd, cli.root ?? '.');
51
+ const manifest = loadConfig(root);
52
+ const pre = manifest.prerender ?? {};
53
+
54
+ const localUrl = (cli.localUrl ?? cli.baseUrl ?? process.env.PRERENDER_BASE ?? pre.localUrl ?? pre.baseUrl)?.replace(/\/$/, '');
55
+ const serve = cli.localUrl ? false : (cli.serve !== undefined ? !!cli.serve : true);
56
+ if (!serve && !localUrl) {
57
+ console.error('prerender: localUrl is required when not using built-in server. Set manifest.prerender.localUrl or use --local.');
58
+ process.exit(1);
59
+ }
60
+ const liveUrl = (cli.liveUrl ?? process.env.PRERENDER_LIVE ?? manifest.live_url ?? manifest.liveUrl ?? pre.live_url ?? pre.liveUrl ?? localUrl ?? '')?.replace(/\/$/, '');
61
+
62
+ return {
63
+ localUrl: localUrl ?? '',
64
+ liveUrl,
65
+ serve,
66
+ output: resolve(root, cli.output ?? pre.output ?? 'website'),
67
+ root,
68
+ routerBase: pre.routerBase ?? null,
69
+ locales: pre.locales,
70
+ redirects: Array.isArray(pre.redirects) ? pre.redirects : [],
71
+ wait: cli.wait ?? pre.wait ?? null,
72
+ waitAfterIdle: Math.max(0, cli.waitAfterIdle ?? pre.waitAfterIdle ?? 0),
73
+ concurrency: Math.max(1, cli.concurrency ?? pre.concurrency ?? 6),
74
+ dryRun: !!cli.dryRun,
75
+ };
76
+ }
77
+
78
+ // --- Discovery: locales from manifest.data -----------------------------------
79
+ // Picks up (1) object keys that are locale codes (e.g. "en", "fr" in data.features)
80
+ // and (2) "locales" properties that point to CSV (or array of CSVs); locale codes from CSV header row.
81
+
82
+ const LOCALE_CODE_RE = /^[a-z]{2}(-[A-Z]{2})?$/i;
83
+
84
+ function localeCodesFromCsvHeader(rootDir, filePath) {
85
+ const fullPath = join(rootDir, filePath.startsWith('/') ? filePath.slice(1) : filePath);
86
+ if (!existsSync(fullPath)) return [];
87
+ const text = readFileSync(fullPath, 'utf8');
88
+ const lines = text.split(/\r?\n/).filter((l) => l.trim());
89
+ if (lines.length === 0) return [];
90
+ const header = splitCsvLine(lines[0]);
91
+ if (header.length < 2) return [];
92
+ // First column is key; rest are locale columns (per localization docs)
93
+ return header.slice(1).filter((col) => LOCALE_CODE_RE.test(String(col).trim())).map((c) => String(c).trim().toLowerCase());
94
+ }
95
+
96
+ function discoverLocales(manifest, rootDir) {
97
+ const codes = new Set();
98
+ const data = manifest.data;
99
+ if (!data || typeof data !== 'object') return [];
100
+ for (const v of Object.values(data)) {
101
+ if (!v || typeof v !== 'object' || Array.isArray(v)) continue;
102
+ // Object keys that are locale codes (JSON/YAML per-locale files)
103
+ for (const k of Object.keys(v)) {
104
+ if (LOCALE_CODE_RE.test(k)) codes.add(k.toLowerCase());
105
+ }
106
+ // "locales" → single CSV path or array of CSV paths; locale codes from CSV headers
107
+ const localesRef = v.locales;
108
+ if (localesRef != null) {
109
+ const files = Array.isArray(localesRef) ? localesRef : [localesRef];
110
+ for (const filePath of files) {
111
+ if (typeof filePath !== 'string') continue;
112
+ localeCodesFromCsvHeader(rootDir, filePath).forEach((c) => codes.add(c));
113
+ }
114
+ }
115
+ }
116
+ return [...codes];
117
+ }
118
+
119
+ // --- Discovery: x-route from HTML --------------------------------------------
120
+
121
+ function extractXRouteConditions(html) {
122
+ const conditions = new Set();
123
+ const re = /x-route\s*=\s*["']([^"']+)["']/gi;
124
+ let m;
125
+ while ((m = re.exec(html)) !== null) {
126
+ m[1].split(',').forEach((c) => {
127
+ const t = c.trim();
128
+ if (t && !t.startsWith('!')) conditions.add(t);
129
+ });
130
+ }
131
+ return conditions;
132
+ }
133
+
134
+ function conditionsToPaths(conditions) {
135
+ const paths = new Set();
136
+ paths.add('/');
137
+ for (const c of conditions) {
138
+ if (c === '/' || c === '') continue;
139
+ if (c.startsWith('/')) {
140
+ paths.add(c === '/' ? '/' : c.replace(/^\//, '').replace(/\/$/, '') || '/');
141
+ } else {
142
+ paths.add('/' + c.replace(/\/$/, ''));
143
+ }
144
+ }
145
+ return paths;
146
+ }
147
+
148
+ // --- Discovery: data-driven paths (docs-style YAML group/items[].path) ------
149
+
150
+ function parseYamlPaths(filePath) {
151
+ if (!existsSync(filePath)) return [];
152
+ const text = readFileSync(filePath, 'utf8');
153
+ const paths = [];
154
+ let currentGroup = '';
155
+ const lines = text.split(/\r?\n/);
156
+ for (const line of lines) {
157
+ const groupMatch = line.match(/^group:\s*["']?([^"'\n]+)["']?/);
158
+ if (groupMatch) {
159
+ currentGroup = groupMatch[1].trim().toLowerCase().replace(/\s+/g, '-');
160
+ continue;
161
+ }
162
+ const pathMatch = line.match(/path:\s*["']?([^"'\n]+)["']?/);
163
+ if (pathMatch && currentGroup) {
164
+ const segment = pathMatch[1].trim();
165
+ paths.push(`${currentGroup}/${segment}`);
166
+ }
167
+ }
168
+ return paths;
169
+ }
170
+
171
+ function parseJsonPaths(filePath, sourceKey) {
172
+ if (!existsSync(filePath)) return [];
173
+ const raw = readFileSync(filePath, 'utf8');
174
+ let data;
175
+ try {
176
+ data = JSON.parse(raw);
177
+ } catch {
178
+ return [];
179
+ }
180
+ const paths = [];
181
+ function collectPathSlug(obj) {
182
+ if (!obj || typeof obj !== 'object') return;
183
+ if (Array.isArray(obj)) {
184
+ obj.forEach((item) => {
185
+ if (item && typeof item === 'object') {
186
+ if (typeof item.path === 'string') paths.push(item.path);
187
+ else if (typeof item.slug === 'string') paths.push(item.slug);
188
+ if (item.group && Array.isArray(item.items)) {
189
+ const group = String(item.group).toLowerCase().replace(/\s+/g, '-');
190
+ item.items.forEach((i) => {
191
+ if (i && typeof i.path === 'string') paths.push(`${group}/${i.path}`);
192
+ });
193
+ }
194
+ }
195
+ });
196
+ return;
197
+ }
198
+ for (const v of Object.values(obj)) collectPathSlug(v);
199
+ }
200
+ collectPathSlug(data);
201
+ return paths;
202
+ }
203
+
204
+ function splitCsvLine(line) {
205
+ const out = [];
206
+ let cur = '';
207
+ let inQuotes = false;
208
+ for (let i = 0; i < line.length; i++) {
209
+ const c = line[i];
210
+ if (c === '"') inQuotes = !inQuotes;
211
+ else if (c === ',' && !inQuotes) {
212
+ out.push(cur.trim().replace(/^["']|["']$/g, ''));
213
+ cur = '';
214
+ } else cur += c;
215
+ }
216
+ out.push(cur.trim().replace(/^["']|["']$/g, ''));
217
+ return out;
218
+ }
219
+
220
+ function parseCsvPaths(filePath) {
221
+ if (!existsSync(filePath)) return [];
222
+ const text = readFileSync(filePath, 'utf8');
223
+ const lines = text.split(/\r?\n/).filter((l) => l.trim());
224
+ if (lines.length < 2) return [];
225
+ const paths = [];
226
+ const header = splitCsvLine(lines[0]).map((c) => c.toLowerCase());
227
+ const pathIdx = header.indexOf('path');
228
+ const slugIdx = header.indexOf('slug');
229
+ const keyIdx = header.indexOf('key');
230
+ const valIdx = header.indexOf('value');
231
+ if (pathIdx >= 0 || slugIdx >= 0) {
232
+ const col = pathIdx >= 0 ? pathIdx : slugIdx;
233
+ for (let i = 1; i < lines.length; i++) {
234
+ const row = splitCsvLine(lines[i]);
235
+ const v = row[col];
236
+ if (v) paths.push(v);
237
+ }
238
+ }
239
+ if (keyIdx >= 0 && valIdx >= 0) {
240
+ for (let i = 1; i < lines.length; i++) {
241
+ const row = splitCsvLine(lines[i]);
242
+ const key = row[keyIdx];
243
+ const val = row[valIdx];
244
+ if (key && (key === 'path' || key.endsWith('.path')) && val) paths.push(val);
245
+ }
246
+ }
247
+ return paths;
248
+ }
249
+
250
+ function discoverDataPaths(manifest, rootDir) {
251
+ const paths = new Set();
252
+ const data = manifest.data;
253
+ if (!data || typeof data !== 'object') return paths;
254
+
255
+ function addFilePaths(value) {
256
+ if (typeof value !== 'string' || !value.startsWith('/')) return;
257
+ const filePath = join(rootDir, value.slice(1));
258
+ if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) {
259
+ parseYamlPaths(filePath).forEach((p) => paths.add('/' + p));
260
+ } else if (filePath.endsWith('.json')) {
261
+ parseJsonPaths(filePath).forEach((p) => paths.add(p.startsWith('/') ? p : '/' + p));
262
+ } else if (filePath.endsWith('.csv')) {
263
+ parseCsvPaths(filePath).forEach((p) => paths.add(p.startsWith('/') ? p : '/' + p));
264
+ }
265
+ }
266
+
267
+ for (const value of Object.values(data)) {
268
+ if (typeof value === 'string') addFilePaths(value);
269
+ else if (value && typeof value === 'object') {
270
+ for (const v of Object.values(value)) {
271
+ if (typeof v === 'string') addFilePaths(v);
272
+ }
273
+ }
274
+ }
275
+ return paths;
276
+ }
277
+
278
+ // --- Collect all paths from index + components -------------------------------
279
+
280
+ function discoverRoutes(manifest, rootDir) {
281
+ const pathSet = new Set();
282
+ pathSet.add('/');
283
+
284
+ const indexPath = join(rootDir, 'index.html');
285
+ if (existsSync(indexPath)) {
286
+ const indexHtml = readFileSync(indexPath, 'utf8');
287
+ const conditions = extractXRouteConditions(indexHtml);
288
+ conditionsToPaths(conditions).forEach((p) => pathSet.add(p));
289
+ }
290
+
291
+ const componentDirs = [
292
+ ...(manifest.preloadedComponents || []),
293
+ ...(manifest.components || []),
294
+ ];
295
+ for (const rel of componentDirs) {
296
+ const compPath = join(rootDir, rel);
297
+ if (existsSync(compPath)) {
298
+ const html = readFileSync(compPath, 'utf8');
299
+ extractXRouteConditions(html).forEach((c) => {
300
+ if (c.startsWith('/')) pathSet.add(c === '/' ? '/' : c.replace(/^\//, '').replace(/\/$/, '') || '/');
301
+ else if (c) pathSet.add('/' + c);
302
+ });
303
+ }
304
+ }
305
+
306
+ discoverDataPaths(manifest, rootDir).forEach((p) => pathSet.add(p));
307
+
308
+ const arr = [...pathSet].map((p) => (p === '/' ? '' : p.replace(/^\//, '').replace(/\/$/, '') || ''));
309
+ return arr.includes('') ? arr : ['', ...arr.filter(Boolean)];
310
+ }
311
+
312
+ // --- Normalize path to file path (no leading slash, empty = index) -----------
313
+
314
+ function pathToFileSegments(pathname) {
315
+ const normalized = pathname.replace(/^\//, '').replace(/\/$/, '') || '';
316
+ return normalized ? normalized.split('/') : [];
317
+ }
318
+
319
+ // --- Strip dev-only injected content (e.g. browser-sync) so dist works under any server -
320
+
321
+ function stripDevOnlyContent(html) {
322
+ let out = html
323
+ .replace(/<script[^>]*id=["']__bs_script__["'][^>]*>[\s\S]*?<\/script>/gi, '')
324
+ .replace(/<script[^>]*src=["'][^"']*browser-sync[^"']*["'][^>]*>\s*<\/script>/gi, '');
325
+ return out;
326
+ }
327
+
328
+ // --- Strip CDN-injected plugin scripts from snapshot so only the loader remains ---
329
+ // When the static page loads, the loader runs once and adds plugins; avoids duplicate script execution.
330
+ function stripInjectedPluginScripts(html) {
331
+ const pluginPattern =
332
+ /<script[^>]*\ssrc=["'][^"']*manifest\.(?:components|router|utilities|data|icons|localization|markdown|code|themes|toasts|tooltips|dropdowns|tabs|slides|resize|tailwind|appwrite\.(?:auth|data|presence))[^"']*\.min\.js["'][^>]*>\s*<\/script>/gi;
333
+ let out = html.replace(pluginPattern, '');
334
+ const runtimePattern =
335
+ /<script[^>]*\ssrc=["'][^"']*(?:alpinejs\/dist\/cdn\.min\.js|papaparse@[^"']*\/papaparse\.min\.js|marked\/marked\.min\.js|highlightjs\/cdn-release@[^"']*\/highlight\.min\.js)[^"']*["'][^>]*>\s*<\/script>/gi;
336
+ out = out.replace(runtimePattern, '');
337
+ return out;
338
+ }
339
+
340
+ // --- (Removed) We used to strip x-text containing product. / feature. to avoid wrong-scope errors
341
+ // on duplicated x-for output, but that also stripped legitimate loop body bindings (e.g. product
342
+ // search results), breaking reactivity. If "product/feature is not defined" appears again, fix
343
+ // the duplicate structure or scope in the template instead of neutering all such x-text.
344
+ function stripDuplicatedLoopDirectives(html) {
345
+ return html;
346
+ }
347
+
348
+ // --- Strip x-text and x-html that reference $x when static/SEO (content already in snapshot).
349
+ // Do NOT strip when expression is user-driven: $route(, $search, $query. Those stay so Alpine can update.
350
+ // Same rule for :attr in stripPrerenderDynamicBindings: bindings with $x are kept (content stays for SEO).
351
+ function stripPrerenderedXDataDirectives(html) {
352
+ function isStatic(expr) {
353
+ if (expr.includes('$route(')) return false;
354
+ if (expr.includes('$search') || expr.includes('$query')) return false;
355
+ return true;
356
+ }
357
+ let out = html.replace(/\s+x-text="([^"]*\$x[^"]*)"/g, (match, expr) => (isStatic(expr) ? '' : match));
358
+ out = out.replace(/\s+x-html="([^"]*\$x[^"]*)"/g, (match, expr) => (isStatic(expr) ? '' : match));
359
+ return out;
360
+ }
361
+
362
+ // --- Don't bake Alpine-only state into the snapshot; only $x-driven content should be prerendered.
363
+ // For any :attr or x-bind:attr whose expression does NOT contain $x, remove the literal attr from the tag
364
+ // so Alpine re-evaluates on load. Bindings that use $x are left as-is (content stays for SEO).
365
+ // Use (?<!:) so we only strip literal attr=, not :attr= (e.g. class= not :class=).
366
+ // Never touch <script> tags (loader + injected plugins must be preserved; static HTML still runs them).
367
+ function stripPrerenderDynamicBindings(html) {
368
+ return html.replace(/<(\w+)([^>]*)>/g, (match, tagName, attrsStr) => {
369
+ if (tagName.toLowerCase() === 'script') return match;
370
+ const toStrip = new Set();
371
+ const bindingRegex = /(?:^|\s)(?::|x-bind:)(\w+)=(?:"([^"]*)"|'([^']*)')/g;
372
+ let m;
373
+ while ((m = bindingRegex.exec(attrsStr)) !== null) {
374
+ const val = (m[2] !== undefined ? m[2] : m[3]) || '';
375
+ if (val.indexOf('$x') === -1) toStrip.add(m[1].toLowerCase());
376
+ }
377
+ if (toStrip.size === 0) return match;
378
+ let newAttrs = attrsStr;
379
+ for (const attr of toStrip) {
380
+ const esc = attr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
381
+ newAttrs = newAttrs.replace(new RegExp(`\\s*(?<!:)${esc}="[^"]*"`, 'gi'), '');
382
+ newAttrs = newAttrs.replace(new RegExp(`\\s*(?<!:)${esc}='[^']*'`, 'gi'), '');
383
+ }
384
+ newAttrs = newAttrs.trim();
385
+ if (newAttrs) newAttrs = ' ' + newAttrs;
386
+ return `<${tagName}${newAttrs}>`;
387
+ });
388
+ }
389
+
390
+ // --- Rewrite asset URLs: depth = segments from this HTML file up to output root (website). ----
391
+ // All project assets are copied into output, so root-relative paths become relative within output.
392
+ // Do NOT rewrite href on <a> tags (navigation links); only rewrite link/script/img so router gets clean paths.
393
+
394
+ function rewriteHtmlAssetPaths(html, depthWithinOutput) {
395
+ const prefix = depthWithinOutput > 0 ? '../'.repeat(depthWithinOutput) : '';
396
+ if (!prefix) return html;
397
+ function isAnchorTag(htmlBeforeMatch) {
398
+ const lastOpen = htmlBeforeMatch.lastIndexOf('<');
399
+ if (lastOpen === -1) return false;
400
+ const tag = htmlBeforeMatch.slice(lastOpen + 1).match(/^(\w+)/);
401
+ return tag && tag[1].toLowerCase() === 'a';
402
+ }
403
+ let out = html.replace(/(\s(href|src)=["'])\/(?!\/)/g, (match, lead, attr, offset, fullString) => {
404
+ if (isAnchorTag(fullString.slice(0, offset))) return match;
405
+ return lead + prefix;
406
+ });
407
+ out = out.replace(/(\s(href|src)=["'])(\.\.\/)+/g, (match, lead, attr, dots, offset, fullString) => {
408
+ if (isAnchorTag(fullString.slice(0, offset))) return match;
409
+ return lead + prefix;
410
+ });
411
+ return out;
412
+ }
413
+
414
+ // --- Canonical and hreflang (per-page injection) ---
415
+
416
+ function buildCanonicalAndHreflang(pathSeg, locales, defaultLocale, base) {
417
+ const baseClean = base.replace(/\/$/, '');
418
+ const defaultLoc = defaultLocale || locales[0];
419
+ const isDefaultLocalePrefixed =
420
+ defaultLoc && (pathSeg === defaultLoc || pathSeg.startsWith(defaultLoc + '/'));
421
+ const canonicalPath =
422
+ isDefaultLocalePrefixed
423
+ ? pathSeg === defaultLoc
424
+ ? ''
425
+ : pathSeg.slice(defaultLoc.length + 1)
426
+ : pathSeg;
427
+ const canonicalHref = canonicalPath === '' ? `${baseClean}/` : `${baseClean}/${canonicalPath}`;
428
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;');
429
+ let out = `<link rel="canonical" href="${esc(canonicalHref)}">\n`;
430
+ if (locales.length > 1) {
431
+ const currentLocale = locales.find((l) => pathSeg === l || pathSeg.startsWith(l + '/')) || defaultLoc;
432
+ const logicalRoute =
433
+ currentLocale === defaultLoc
434
+ ? pathSeg === defaultLoc
435
+ ? ''
436
+ : pathSeg.startsWith(defaultLoc + '/')
437
+ ? pathSeg.slice(defaultLoc.length + 1)
438
+ : pathSeg
439
+ : pathSeg === currentLocale
440
+ ? ''
441
+ : pathSeg.slice(currentLocale.length + 1);
442
+ locales.forEach((loc) => {
443
+ const seg = loc === defaultLoc ? logicalRoute : (logicalRoute ? `${loc}/${logicalRoute}` : loc);
444
+ const href = baseClean + (seg ? `/${seg}` : '');
445
+ const hreflang = loc === defaultLoc ? 'x-default' : loc;
446
+ out += ` <link rel="alternate" hreflang="${esc(hreflang)}" href="${esc(href)}">\n`;
447
+ });
448
+ }
449
+ return out;
450
+ }
451
+
452
+ function buildOgLocale(pathSeg, locales, defaultLocale) {
453
+ if (locales.length <= 1) return '';
454
+ const defaultLoc = defaultLocale || locales[0];
455
+ const currentLocale = locales.find((l) => pathSeg === l || pathSeg.startsWith(l + '/')) || defaultLoc;
456
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/"/g, '&quot;');
457
+ const toOgLocale = (loc) => (loc.indexOf('-') !== -1 ? loc.replace(/-/g, '_').toLowerCase() : loc.toLowerCase());
458
+ let out = `<meta property="og:locale" content="${esc(toOgLocale(currentLocale))}">\n`;
459
+ locales.forEach((loc) => {
460
+ if (loc !== currentLocale) out += ` <meta property="og:locale:alternate" content="${esc(toOgLocale(loc))}">\n`;
461
+ });
462
+ return out;
463
+ }
464
+
465
+ function stripOgLocaleFromHead(html) {
466
+ return html.replace(/\s*<meta[^>]*property="og:locale(?::alternate)?"[^>]*>\s*/gi, '');
467
+ }
468
+
469
+ function hasOtherOgMeta(html) {
470
+ return /<meta[^>]*property="og:(?!locale(?::alternate)?")[^"]*"[^>]*>/i.test(html);
471
+ }
472
+
473
+ // --- Resolve $x bindings in <head> (data-head meta/link are injected with :attr="$x.path" but never evaluated) ---
474
+
475
+ function loadContentForPrerender(manifest, rootDir, locale) {
476
+ const data = manifest?.data?.content;
477
+ if (!data) return {};
478
+ const loc = locale || 'en';
479
+ let content = {};
480
+ if (typeof data === 'string' && data.endsWith('.csv')) {
481
+ content = parseCsvToKeyValue(join(rootDir, data.slice(1)), loc);
482
+ } else if (data && typeof data === 'object' && data.locales && typeof data.locales === 'string') {
483
+ content = parseCsvToKeyValue(join(rootDir, data.locales.slice(1)), loc);
484
+ }
485
+ if (manifest.description !== undefined && content.description === undefined) {
486
+ content.description = manifest.description;
487
+ }
488
+ return content;
489
+ }
490
+
491
+ function parseCsvToKeyValue(filePath, valueLocale) {
492
+ if (!existsSync(filePath)) return {};
493
+ const text = readFileSync(filePath, 'utf8');
494
+ const lines = text.split(/\r?\n/).filter((l) => l.trim());
495
+ if (lines.length < 2) return {};
496
+ const header = splitCsvLine(lines[0]);
497
+ const keyCol = header[0];
498
+ const valueCol = header.includes(valueLocale) ? valueLocale : (header[1] || header[0]);
499
+ const keyIdx = 0;
500
+ const valueIdx = header.indexOf(valueCol);
501
+ if (valueIdx === -1) return {};
502
+ const result = {};
503
+ for (let i = 1; i < lines.length; i++) {
504
+ const row = splitCsvLine(lines[i]);
505
+ const key = row[keyIdx];
506
+ const value = row[valueIdx];
507
+ if (key == null) continue;
508
+ setNestedKey(result, key.trim(), value != null ? String(value).trim() : '');
509
+ }
510
+ return result;
511
+ }
512
+
513
+ function setNestedKey(obj, path, value) {
514
+ const parts = path.split('.');
515
+ let cur = obj;
516
+ for (let i = 0; i < parts.length - 1; i++) {
517
+ const p = parts[i];
518
+ if (!(p in cur) || typeof cur[p] !== 'object') cur[p] = {};
519
+ cur = cur[p];
520
+ }
521
+ cur[parts[parts.length - 1]] = value;
522
+ }
523
+
524
+ function getXPath(obj, path) {
525
+ const parts = path.replace(/^\.+/, '').split('.');
526
+ let cur = obj;
527
+ for (const p of parts) {
528
+ if (cur == null || typeof cur !== 'object') return undefined;
529
+ cur = cur[p];
530
+ }
531
+ return cur;
532
+ }
533
+
534
+ function resolveHeadXBindings(html, xData) {
535
+ const esc = (s) => String(s ?? '')
536
+ .replace(/&/g, '&amp;')
537
+ .replace(/</g, '&lt;')
538
+ .replace(/>/g, '&gt;')
539
+ .replace(/"/g, '&quot;');
540
+ return html.replace(/<head>([\s\S]*?)<\/head>/i, (_, headContent) => {
541
+ let out = headContent.replace(
542
+ /(\s)(?::|x-bind:)(\w+)=["'](\$x\.[^"']+)["']/g,
543
+ (_, space, attr, expr) => {
544
+ const path = expr.replace(/^\$x\./, '').trim();
545
+ const value = getXPath(xData, path);
546
+ if (value === undefined) return _;
547
+ return `${space}${attr}="${esc(value)}"`;
548
+ }
549
+ );
550
+ return `<head>${out}</head>`;
551
+ });
552
+ }
553
+
554
+ // --- SEO: robots.txt and sitemap.xml (written to output, use liveUrl for crawlers) ---
555
+
556
+ function writeSeoFiles(outputDir, pathList, liveUrl) {
557
+ const base = liveUrl.replace(/\/$/, '');
558
+ const today = new Date().toISOString().slice(0, 10);
559
+
560
+ writeFileSync(
561
+ join(outputDir, 'robots.txt'),
562
+ `User-agent: *
563
+ Disallow:
564
+
565
+ Sitemap: ${base}/sitemap.xml
566
+ `,
567
+ 'utf8'
568
+ );
569
+
570
+ const urlEntries = pathList.map((pathSeg) => {
571
+ const path = pathSeg === '' ? '' : '/' + pathSeg.replace(/\/$/, '');
572
+ const loc = path ? `${base}${path}` : base + '/';
573
+ const escaped = loc.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&apos;');
574
+ return ` <url>
575
+ <loc>${escaped}</loc>
576
+ <lastmod>${today}</lastmod>
577
+ <changefreq>monthly</changefreq>
578
+ <priority>${path === '' ? '1.0' : '0.8'}</priority>
579
+ </url>`;
580
+ });
581
+
582
+ writeFileSync(
583
+ join(outputDir, 'sitemap.xml'),
584
+ `<?xml version="1.0" encoding="UTF-8"?>
585
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
586
+ ${urlEntries.join('\n')}
587
+ </urlset>
588
+ `,
589
+ 'utf8'
590
+ );
591
+ }
592
+
593
+ // --- Static server for --serve ------------------------------------------------
594
+
595
+ const MIME = {
596
+ '.html': 'text/html',
597
+ '.css': 'text/css',
598
+ '.js': 'application/javascript',
599
+ '.json': 'application/json',
600
+ '.ico': 'image/x-icon',
601
+ '.svg': 'image/svg+xml',
602
+ '.png': 'image/png',
603
+ '.jpg': 'image/jpeg',
604
+ '.jpeg': 'image/jpeg',
605
+ '.woff2': 'font/woff2',
606
+ '.woff': 'font/woff',
607
+ };
608
+
609
+ function startStaticServer(rootDir) {
610
+ const rootResolved = resolve(rootDir);
611
+ const server = createServer((req, res) => {
612
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
613
+ res.writeHead(405);
614
+ res.end();
615
+ return;
616
+ }
617
+ const pathname = (req.url || '/').replace(/\?.*$/, '') || '/';
618
+ const segments = pathname.split('/').filter(Boolean);
619
+ const safeSegments = segments.filter((s) => s !== '..' && s !== '');
620
+ const filePath = join(rootResolved, ...safeSegments);
621
+ let resolvedPath;
622
+ try {
623
+ resolvedPath = resolve(filePath);
624
+ if (!resolvedPath.startsWith(rootResolved)) {
625
+ res.writeHead(403);
626
+ res.end();
627
+ return;
628
+ }
629
+ } catch {
630
+ sendIndex();
631
+ return;
632
+ }
633
+ function sendIndex() {
634
+ const indexFile = join(rootResolved, 'index.html');
635
+ if (!existsSync(indexFile)) {
636
+ res.writeHead(404);
637
+ res.end();
638
+ return;
639
+ }
640
+ const html = readFileSync(indexFile, 'utf8');
641
+ res.writeHead(200, { 'Content-Type': 'text/html' });
642
+ res.end(html);
643
+ }
644
+ if (!existsSync(resolvedPath)) {
645
+ sendIndex();
646
+ return;
647
+ }
648
+ const stat = statSync(resolvedPath);
649
+ if (stat.isDirectory()) {
650
+ const indexInDir = join(resolvedPath, 'index.html');
651
+ if (existsSync(indexInDir)) {
652
+ const html = readFileSync(indexInDir, 'utf8');
653
+ res.writeHead(200, { 'Content-Type': 'text/html' });
654
+ res.end(html);
655
+ return;
656
+ }
657
+ sendIndex();
658
+ return;
659
+ }
660
+ const ext = (resolvedPath.match(/\.[^.]+$/) || [])[0] || '';
661
+ const contentType = MIME[ext] || 'application/octet-stream';
662
+ const body = readFileSync(resolvedPath);
663
+ res.writeHead(200, { 'Content-Type': contentType });
664
+ res.end(body);
665
+ });
666
+ return new Promise((resolvePromise, reject) => {
667
+ server.listen(0, '127.0.0.1', () => {
668
+ const port = server.address().port;
669
+ resolvePromise({ server, url: `http://127.0.0.1:${port}` });
670
+ });
671
+ server.on('error', reject);
672
+ });
673
+ }
674
+
675
+ // --- Copy project into output so website is self-contained (e.g. for Appwrite). ---
676
+ const COPY_EXCLUDE = new Set([
677
+ 'node_modules', '.git', 'package.json', 'package-lock.json',
678
+ 'index.html', 'prerender.mjs', 'prerender.js',
679
+ ]);
680
+
681
+ function copyProjectIntoDist(rootResolved, outputResolved) {
682
+ const outputDirName = basename(outputResolved);
683
+ COPY_EXCLUDE.add(outputDirName);
684
+ const entries = readdirSync(rootResolved, { withFileTypes: true });
685
+ for (const ent of entries) {
686
+ const name = ent.name;
687
+ if (COPY_EXCLUDE.has(name) || name.startsWith('.')) continue;
688
+ const src = join(rootResolved, name);
689
+ const dest = join(outputResolved, name);
690
+ cpSync(src, dest, { recursive: true });
691
+ }
692
+ COPY_EXCLUDE.delete(outputDirName);
693
+ }
694
+
695
+ // --- Main --------------------------------------------------------------------
696
+
697
+ async function main() {
698
+ const config = resolveConfig();
699
+ let staticServer = null;
700
+ if (config.serve) {
701
+ const { server, url } = await startStaticServer(config.root);
702
+ staticServer = server;
703
+ config.localUrl = url;
704
+ }
705
+ try {
706
+ await runPrerender(config);
707
+ } finally {
708
+ if (staticServer) {
709
+ await new Promise((res) => staticServer.close(res));
710
+ }
711
+ }
712
+ }
713
+
714
+ async function runPrerender(config) {
715
+ const manifest = loadConfig(config.root);
716
+ const localesConfig = config.locales;
717
+
718
+ let locales = [];
719
+ if (localesConfig !== false) {
720
+ const discovered = discoverLocales(manifest, config.root);
721
+ if (Array.isArray(localesConfig) && localesConfig.length > 0) {
722
+ locales = localesConfig.filter((c) => discovered.includes(c));
723
+ } else {
724
+ locales = discovered;
725
+ }
726
+ }
727
+
728
+ const defaultLocale = locales[0] ?? null;
729
+ const routeSegments = discoverRoutes(manifest, config.root);
730
+ const paths = new Set();
731
+ paths.add('');
732
+
733
+ for (const seg of routeSegments) {
734
+ paths.add(seg);
735
+ }
736
+ for (const locale of locales.slice(1)) {
737
+ paths.add(locale);
738
+ for (const seg of routeSegments) {
739
+ paths.add(`${locale}/${seg}`);
740
+ }
741
+ }
742
+ // Default locale also under its slug (e.g. /en/, /en/page-1) so linking is symmetric; canonical points to root
743
+ if (defaultLocale) {
744
+ paths.add(defaultLocale);
745
+ for (const seg of routeSegments) {
746
+ if (seg !== '') paths.add(`${defaultLocale}/${seg}`);
747
+ }
748
+ }
749
+
750
+ const NOT_FOUND_PATH = '__prerender_404__'; // URL path that matches no route so router shows x-route="!*" (404)
751
+ const pathList = [...paths, NOT_FOUND_PATH];
752
+ if (config.dryRun) {
753
+ return;
754
+ }
755
+
756
+ const outputResolved = resolve(config.output);
757
+ const rootResolved = resolve(config.root);
758
+ // Router base = URL pathname to the app root. When dist is deployed as site root (e.g. Appwrite), use "".
759
+ // Set manifest.prerender.routerBase only when the app is served from a subpath (e.g. /app).
760
+ let routerBasePath = null;
761
+ if (config.routerBase != null && String(config.routerBase).trim() !== '') {
762
+ const trimmed = String(config.routerBase).replace(/^\/+|\/+$/g, '').trim();
763
+ routerBasePath = trimmed ? '/' + trimmed : '';
764
+ } else {
765
+ routerBasePath = '';
766
+ }
767
+
768
+ if (existsSync(outputResolved)) {
769
+ rmSync(outputResolved, { recursive: true });
770
+ }
771
+ mkdirSync(outputResolved, { recursive: true });
772
+ copyProjectIntoDist(rootResolved, outputResolved);
773
+
774
+ let browser;
775
+ try {
776
+ const chromium = await import('@sparticuz/chromium');
777
+ const pptr = await import('puppeteer-core');
778
+ const executablePath = await chromium.default.executablePath();
779
+ browser = await pptr.default.launch({
780
+ args: chromium.default.args,
781
+ defaultViewport: chromium.default.defaultViewport ?? null,
782
+ executablePath,
783
+ headless: chromium.default.headless ?? true,
784
+ ignoreHTTPSErrors: true,
785
+ });
786
+ } catch (serverlessErr) {
787
+ let puppeteer;
788
+ try {
789
+ puppeteer = await import('puppeteer');
790
+ } catch {
791
+ console.error('prerender: install puppeteer (local) or puppeteer-core + @sparticuz/chromium.');
792
+ process.exit(1);
793
+ }
794
+ browser = await puppeteer.default.launch({ headless: true });
795
+ }
796
+
797
+ const timeout = config.wait ?? 15000;
798
+ const concurrency = config.concurrency;
799
+ const pathTotal = pathList.length;
800
+ process.stdout.write(`Prerendering ${pathTotal} path(s)...\n`);
801
+
802
+ async function processPath(pathSeg, pathIndex) {
803
+ const is404 = pathSeg === NOT_FOUND_PATH;
804
+ const pathname = is404 ? `/${NOT_FOUND_PATH}` : (pathSeg ? `/${pathSeg}` : '/');
805
+ const displayPath = pathSeg === '' ? '/' : pathname;
806
+ process.stdout.write(` [ ${pathIndex + 1}/${pathTotal} ] ${displayPath}\n`);
807
+ const url = `${config.localUrl}${pathname}`;
808
+ const fileSegments = is404 ? [] : pathToFileSegments(pathSeg ? `/${pathSeg}` : '/');
809
+ const outDir = is404 ? config.output : join(config.output, ...fileSegments);
810
+ const outFile = is404 ? join(config.output, '404.html') : join(outDir, 'index.html');
811
+
812
+ const page = await browser.newPage();
813
+ try {
814
+ await page.goto(url, {
815
+ waitUntil: 'domcontentloaded',
816
+ timeout: Math.min(timeout, 30000),
817
+ });
818
+
819
+ await Promise.race([
820
+ page.evaluate(() => {
821
+ return new Promise((resolve) => {
822
+ const done = () => resolve();
823
+ const t = setTimeout(done, 6000);
824
+ window.addEventListener(
825
+ 'manifest:routing-ready',
826
+ () => {
827
+ clearTimeout(t);
828
+ setTimeout(done, 2000);
829
+ },
830
+ { once: true }
831
+ );
832
+ });
833
+ }),
834
+ new Promise((_, rej) => setTimeout(() => rej(new Error('ready timeout')), timeout)),
835
+ ]).catch(() => { });
836
+
837
+ // Ensure manifest.min.js (dynamic loader) has run and injected plugin scripts before snapshot.
838
+ // Static output still runs the loader and Alpine; we just capture the DOM after they've set up.
839
+ await page.evaluate(() => {
840
+ return new Promise((resolve) => {
841
+ const check = () => document.querySelectorAll('script[src*="manifest"]').length >= 2;
842
+ if (check()) return resolve();
843
+ const deadline = Date.now() + 5000;
844
+ const t = setInterval(() => {
845
+ if (check() || Date.now() >= deadline) {
846
+ clearInterval(t);
847
+ resolve();
848
+ }
849
+ }, 50);
850
+ });
851
+ }).catch(() => { });
852
+
853
+ await page.waitForNetworkIdle({ idleTime: 1500, timeout: 10000 }).catch(() => { });
854
+
855
+ await page.evaluate(() => {
856
+ return new Promise((resolve) => {
857
+ const observer = new MutationObserver(() => {
858
+ clearTimeout(stable);
859
+ stable = setTimeout(resolve, 800);
860
+ });
861
+ observer.observe(document.documentElement, { childList: true, subtree: true, characterData: true });
862
+ let stable = setTimeout(() => {
863
+ observer.disconnect();
864
+ resolve();
865
+ }, 800);
866
+ });
867
+ }).catch(() => { });
868
+
869
+ // Optional extra delay so in-page async (e.g. fetch() in x-init for client logos) can complete before snapshot.
870
+ if (config.waitAfterIdle > 0) {
871
+ await new Promise((r) => setTimeout(r, config.waitAfterIdle));
872
+ }
873
+
874
+ // Wait for async content in static lists: elements with x-init (fetch) + x-html should have content (e.g. inline SVG) before snapshot.
875
+ const asyncContentTimeout = 5000;
876
+ const asyncContentInterval = 100;
877
+ const asyncStart = Date.now();
878
+ for (; ;) {
879
+ const { pending, total } = await page.evaluate(() => {
880
+ const els = document.querySelectorAll('[x-init][x-html]');
881
+ const withFetch = Array.from(els).filter((el) => (el.getAttribute('x-init') || '').includes('fetch'));
882
+ const stillEmpty = withFetch.filter((el) => !el.querySelector('svg') && !el.textContent.trim());
883
+ return { pending: stillEmpty.length, total: withFetch.length };
884
+ });
885
+ if (pending === 0 || total === 0 || Date.now() - asyncStart >= asyncContentTimeout) {
886
+ break;
887
+ }
888
+ await new Promise((r) => setTimeout(r, asyncContentInterval));
889
+ }
890
+
891
+ // Strip x-init, x-data, x-html from elements that already have content (e.g. inline SVG from fetch).
892
+ // Keeps the baked-in content as static HTML; Alpine won't re-fetch or overwrite on load.
893
+ await page.evaluate(() => {
894
+ document.querySelectorAll('[x-init][x-html]').forEach((el) => {
895
+ if (!el.querySelector('svg') && !el.textContent.trim()) return;
896
+ el.removeAttribute('x-init');
897
+ el.removeAttribute('x-data');
898
+ el.removeAttribute('x-html');
899
+ });
900
+ });
901
+
902
+ // x-for lists: keep static lists in the HTML for SEO; collapse only dynamic lists so Alpine re-renders.
903
+ // Explicit: data-dynamic or data-prerender="dynamic"|"skip". Inferred: x-for uses $search/$query,
904
+ // $url, $auth, or iterates over getter names (filtered*, results, searchResults). See docs prerender + local.data.
905
+ await page.evaluate(() => {
906
+ document.querySelectorAll('template[x-for]').forEach((tpl) => {
907
+ const xFor = (tpl.getAttribute('x-for') || '').trim();
908
+ const prerender = (tpl.getAttribute('data-prerender') || '').toLowerCase();
909
+ const hasDataDynamic = tpl.hasAttribute('data-dynamic');
910
+ const explicit = hasDataDynamic || prerender === 'dynamic' || prerender === 'skip';
911
+ const inferred = xFor.includes('$search') || xFor.includes('$query') ||
912
+ xFor.includes('$url') || xFor.includes('$auth') ||
913
+ /\bin\s+(filtered\w*|results|searchResults)\b/.test(xFor);
914
+ const forceCollapse = explicit || inferred;
915
+ if (!forceCollapse) return; // keep prerendered list for SEO
916
+ const first = tpl.content?.firstElementChild;
917
+ if (!first) return;
918
+ const tag = first.tagName;
919
+ const cls = first.getAttribute('class') || '';
920
+ let next = tpl.nextElementSibling;
921
+ while (next) {
922
+ const sameTag = next.tagName === tag;
923
+ const sameClass = (next.getAttribute('class') || '') === cls;
924
+ const isLikelyClone = sameTag && sameClass;
925
+ const toRemove = next;
926
+ next = next.nextElementSibling;
927
+ if (isLikelyClone) toRemove.remove();
928
+ else break;
929
+ }
930
+ });
931
+ });
932
+
933
+ // Remove elements marked data-dynamic (so they are not in static HTML; client will render them).
934
+ // Skip <template> since we only collapse those above; other elements and their subtree are removed.
935
+ await page.evaluate(() => {
936
+ const toRemove = Array.from(document.querySelectorAll('[data-dynamic]')).filter((el) => el.tagName !== 'TEMPLATE');
937
+ const depth = (el) => { let d = 0; let n = el; while (n && n !== document.body) { d++; n = n.parentElement; } return d; };
938
+ toRemove.sort((a, b) => depth(a) - depth(b));
939
+ toRemove.forEach((el) => { if (document.contains(el)) el.remove(); });
940
+ });
941
+
942
+ // Remove route-hidden content ([x-route] with inline style display:none) so each prerendered page contains only that route's HTML.
943
+ await page.evaluate(() => {
944
+ const reDisplayNone = /\bdisplay\s*:\s*none\b/i;
945
+ const candidates = document.querySelectorAll('[x-route][style*="display"]');
946
+ const toRemove = Array.from(candidates).filter((el) => reDisplayNone.test(el.getAttribute('style') || ''));
947
+ const depth = (el) => { let d = 0; let n = el; while (n && n !== document.body) { d++; n = n.parentElement; } return d; };
948
+ toRemove.sort((a, b) => depth(a) - depth(b)); // remove outer first so subtrees go in one go
949
+ toRemove.forEach((el) => { if (document.contains(el)) el.remove(); });
950
+ });
951
+
952
+ let html = await page.evaluate(() => document.documentElement.outerHTML);
953
+ html = stripDevOnlyContent(html);
954
+ html = stripInjectedPluginScripts(html);
955
+ html = stripDuplicatedLoopDirectives(html);
956
+ html = stripPrerenderedXDataDirectives(html);
957
+ const currentLocale =
958
+ pathSeg && locales.length > 0
959
+ ? locales.includes(pathSeg.split('/')[0])
960
+ ? pathSeg.split('/')[0]
961
+ : defaultLocale || 'en'
962
+ : defaultLocale || 'en';
963
+ const content = loadContentForPrerender(manifest, config.root, currentLocale);
964
+ const xData = { manifest, content };
965
+ html = resolveHeadXBindings(html, xData);
966
+ html = stripPrerenderDynamicBindings(html);
967
+ html = rewriteHtmlAssetPaths(html, fileSegments.length);
968
+ const liveBase = config.liveUrl.replace(/\/$/, '');
969
+ const canonicalHreflang = buildCanonicalAndHreflang(is404 ? '' : pathSeg, locales, defaultLocale, liveBase);
970
+ const ogLocale = buildOgLocale(is404 ? '' : pathSeg, locales, defaultLocale);
971
+ const injectOgLocale = ogLocale && hasOtherOgMeta(html);
972
+ if (injectOgLocale) html = stripOgLocaleFromHead(html);
973
+ const baseMeta = routerBasePath !== null ? `<meta name="manifest:router-base" content="${String(routerBasePath).replace(/"/g, '&quot;')}">\n` : '';
974
+ const routeDepth = fileSegments.length;
975
+ const prerenderedMeta = `<meta name="manifest:prerendered" content="1">\n`;
976
+ html = html.replace('</head>', `${canonicalHreflang}${injectOgLocale ? ogLocale : ''}${baseMeta}${prerenderedMeta}<meta name="manifest:router-base-depth" content="${routeDepth}">\n</head>`);
977
+ mkdirSync(outDir, { recursive: true });
978
+ writeFileSync(outFile, html, 'utf8');
979
+ } catch (err) {
980
+ // path failed (swallowed to allow other paths to complete)
981
+ } finally {
982
+ await page.close();
983
+ }
984
+ }
985
+
986
+ try {
987
+ let index = 0;
988
+ async function worker() {
989
+ while (true) {
990
+ const i = index++;
991
+ if (i >= pathList.length) return;
992
+ await processPath(pathList[i], i);
993
+ }
994
+ }
995
+ await Promise.all(
996
+ Array.from({ length: Math.min(concurrency, pathList.length) }, () => worker())
997
+ );
998
+ } finally {
999
+ await browser.close();
1000
+ }
1001
+
1002
+ writeSeoFiles(config.output, pathList.filter((p) => p !== NOT_FOUND_PATH), config.liveUrl);
1003
+
1004
+ if (config.redirects.length > 0) {
1005
+ const lines = config.redirects.map((r) => {
1006
+ if (typeof r === 'string') return r;
1007
+ const from = r.from ?? r.fromPath ?? '';
1008
+ const to = r.to ?? r.toPath ?? r.redirect ?? '';
1009
+ const status = r.status ?? r.force ?? 301;
1010
+ return `${from} ${to} ${status}`;
1011
+ });
1012
+ writeFileSync(join(config.output, '_redirects'), lines.join('\n'), 'utf8');
1013
+ }
1014
+
1015
+ }
1016
+
1017
+ main().catch((err) => {
1018
+ console.error('prerender:', err);
1019
+ process.exit(1);
1020
+ });
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "mnfst-render",
3
+ "version": "0.1.0",
4
+ "description": "Render Manifest sites to static HTML for SEO",
5
+ "type": "module",
6
+ "bin": {
7
+ "mnfst-render": "./bin/mnfst-render.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "manifest.render.mjs",
12
+ "README.md"
13
+ ],
14
+ "scripts": {
15
+ "prepare:source": "node ./scripts/sync-render.mjs",
16
+ "prepack": "npm run prepare:source"
17
+ },
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "keywords": [
22
+ "manifest",
23
+ "mnfst",
24
+ "prerender",
25
+ "render",
26
+ "seo"
27
+ ],
28
+ "author": "Andrew Matlock",
29
+ "license": "MIT",
30
+ "repository": {
31
+ "type": "git",
32
+ "url": "git+https://github.com/andrewmatlock/Manifest.git",
33
+ "directory": "packages/render"
34
+ }
35
+ }