@techninja/clearstack 0.3.43 → 0.3.45
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/bin/cli.js +11 -1
- package/lib/build-modulepreload.js +105 -0
- package/lib/build-sitemap.js +101 -0
- package/lib/og-image-template.js +6 -4
- package/package.json +1 -1
- package/templates/shared/src/index.html +17 -0
package/bin/cli.js
CHANGED
|
@@ -68,7 +68,13 @@ async function run(action) {
|
|
|
68
68
|
report(process.cwd(), { json: !!flags.json });
|
|
69
69
|
} else if (action === 'build') {
|
|
70
70
|
const sub = args.find((a) => a !== 'build' && !a.startsWith('-'));
|
|
71
|
-
if (sub === '
|
|
71
|
+
if (sub === 'sitemap') {
|
|
72
|
+
const { buildSitemap } = await import('../lib/build-sitemap.js');
|
|
73
|
+
buildSitemap({ projectDir: process.cwd(), outDir: flags.out || 'dist', baseUrl: flags.url || '' });
|
|
74
|
+
} else if (sub === 'modulepreload' || sub === 'preload') {
|
|
75
|
+
const { buildModulePreload } = await import('../lib/build-modulepreload.js');
|
|
76
|
+
buildModulePreload({ projectDir: process.cwd(), outDir: flags.out || 'dist' });
|
|
77
|
+
} else if (sub === 'og-images' || sub === 'images') {
|
|
72
78
|
const mod = await import('../lib/build-og-images.js');
|
|
73
79
|
const common = { projectDir: process.cwd(), outDir: flags.out || 'dist', logo: flags.logo || '', siteName: flags.site || '' };
|
|
74
80
|
if (flags.slug) await mod.buildOneOGImage({ ...common, slug: flags.slug });
|
|
@@ -80,7 +86,11 @@ async function run(action) {
|
|
|
80
86
|
outDir: flags.out || 'dist',
|
|
81
87
|
baseUrl: flags.url || '',
|
|
82
88
|
});
|
|
89
|
+
const { buildSitemap } = await import('../lib/build-sitemap.js');
|
|
90
|
+
buildSitemap({ projectDir: process.cwd(), outDir: flags.out || 'dist', baseUrl: flags.url || '' });
|
|
83
91
|
if (sub === 'all') {
|
|
92
|
+
const { buildModulePreload } = await import('../lib/build-modulepreload.js');
|
|
93
|
+
buildModulePreload({ projectDir: process.cwd(), outDir: flags.out || 'dist' });
|
|
84
94
|
const { buildOGImages } = await import('../lib/build-og-images.js');
|
|
85
95
|
await buildOGImages({
|
|
86
96
|
projectDir: process.cwd(),
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Module preload builder — statically crawls ES module imports and injects
|
|
3
|
+
* <link rel="modulepreload"> tags into index.html so the browser fetches all
|
|
4
|
+
* modules in parallel instead of chaining waterfall requests.
|
|
5
|
+
* @module lib/build-modulepreload
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { resolve, dirname, relative, extname } from 'node:path';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} srcDir - The src/ directory to crawl
|
|
13
|
+
* @param {string} entryFile - Relative path to entry (e.g. 'router/index.js')
|
|
14
|
+
* @param {string[]} ignoreDirs - Dirs to skip (e.g. ['vendor', 'deps'])
|
|
15
|
+
* @returns {string[]} Ordered list of module paths (entry last)
|
|
16
|
+
*/
|
|
17
|
+
export function crawlModules(srcDir, entryFile, ignoreDirs = ['vendor', 'deps']) {
|
|
18
|
+
const visited = new Set();
|
|
19
|
+
const order = [];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
*
|
|
23
|
+
*/
|
|
24
|
+
function crawl(relPath) {
|
|
25
|
+
if (visited.has(relPath)) return;
|
|
26
|
+
visited.add(relPath);
|
|
27
|
+
if (ignoreDirs.some((d) => relPath.includes(`/${d}/`) || relPath.startsWith(`${d}/`))) return;
|
|
28
|
+
|
|
29
|
+
const absPath = resolve(srcDir, relPath);
|
|
30
|
+
if (!existsSync(absPath) || extname(absPath) !== '.js') return;
|
|
31
|
+
|
|
32
|
+
const src = readFileSync(absPath, 'utf-8');
|
|
33
|
+
const importRe = /(?:^|\n)\s*import\s[^'"]*['"]([^'"]+)['"]/g;
|
|
34
|
+
let m;
|
|
35
|
+
while ((m = importRe.exec(src)) !== null) {
|
|
36
|
+
const spec = m[1];
|
|
37
|
+
if (spec.startsWith('#')) {
|
|
38
|
+
// Import map alias — resolve via known prefixes
|
|
39
|
+
const mapped = resolveAlias(spec, relPath);
|
|
40
|
+
if (mapped) crawl(mapped);
|
|
41
|
+
} else if (spec.startsWith('./') || spec.startsWith('../')) {
|
|
42
|
+
const abs = resolve(dirname(resolve(srcDir, relPath)), spec);
|
|
43
|
+
const rel = relative(srcDir, abs.endsWith('.js') ? abs : abs + '.js');
|
|
44
|
+
crawl(rel);
|
|
45
|
+
}
|
|
46
|
+
// Skip bare specifiers (vendor/hybrids handled separately)
|
|
47
|
+
}
|
|
48
|
+
order.push(relPath);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
crawl(entryFile);
|
|
52
|
+
return order;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Resolve #alias/ import map specifiers to relative paths. */
|
|
56
|
+
function resolveAlias(spec, fromFile) {
|
|
57
|
+
const aliases = {
|
|
58
|
+
'#store/': 'store/',
|
|
59
|
+
'#utils/': 'utils/',
|
|
60
|
+
'#atoms/': 'components/atoms/',
|
|
61
|
+
'#molecules/': 'components/molecules/',
|
|
62
|
+
'#organisms/': 'components/organisms/',
|
|
63
|
+
'#templates/': 'components/templates/',
|
|
64
|
+
'#pages/': 'pages/',
|
|
65
|
+
};
|
|
66
|
+
for (const [prefix, dir] of Object.entries(aliases)) {
|
|
67
|
+
if (spec.startsWith(prefix)) return dir + spec.slice(prefix.length);
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Inject modulepreload tags into index.html.
|
|
74
|
+
* @param {{ projectDir: string, srcDir?: string, outDir?: string, entry?: string, ignore?: string[] }} opts
|
|
75
|
+
* @returns {{ modules: number }}
|
|
76
|
+
*/
|
|
77
|
+
export function buildModulePreload(opts) {
|
|
78
|
+
const {
|
|
79
|
+
projectDir,
|
|
80
|
+
srcDir = resolve(projectDir, 'src'),
|
|
81
|
+
outDir = resolve(projectDir, 'dist'),
|
|
82
|
+
entry = 'router/index.js',
|
|
83
|
+
ignore = ['vendor', 'deps'],
|
|
84
|
+
} = opts;
|
|
85
|
+
|
|
86
|
+
const indexPath = resolve(outDir, 'index.html');
|
|
87
|
+
if (!existsSync(indexPath)) {
|
|
88
|
+
console.log('⚠ dist/index.html not found — run build first');
|
|
89
|
+
return { modules: 0 };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const modules = crawlModules(srcDir, entry, ignore);
|
|
93
|
+
const tags = modules.map((m) => ` <link rel="modulepreload" href="/${m}">`).join('\n');
|
|
94
|
+
|
|
95
|
+
let html = readFileSync(indexPath, 'utf-8');
|
|
96
|
+
if (html.includes('rel="modulepreload"')) {
|
|
97
|
+
// Remove existing preload tags before re-injecting
|
|
98
|
+
html = html.replace(/\s*<link rel="modulepreload"[^>]*>/g, '');
|
|
99
|
+
}
|
|
100
|
+
html = html.replace('</head>', `${tags}\n</head>`);
|
|
101
|
+
writeFileSync(indexPath, html);
|
|
102
|
+
|
|
103
|
+
console.log(`✅ Modulepreload: ${modules.length} modules → dist/index.html`);
|
|
104
|
+
return { modules: modules.length };
|
|
105
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sitemap generator — builds sitemap.xml from clearstack.routes.json.
|
|
3
|
+
* Static routes get one URL each; dynamic routes iterate their data source.
|
|
4
|
+
* @module lib/build-sitemap
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
import { loadRoutes, resolveDataSource } from './build-og.js';
|
|
10
|
+
import { interpolate } from './og-template.js';
|
|
11
|
+
|
|
12
|
+
/** @param {string} s */
|
|
13
|
+
const esc = (s) => s.replace(/&/g, '&').replace(/'/g, ''');
|
|
14
|
+
|
|
15
|
+
/** Default priority by route type. */
|
|
16
|
+
function defaultPriority(pattern) {
|
|
17
|
+
if (pattern === '/') return '1.0';
|
|
18
|
+
if (pattern.includes(':')) return '0.5';
|
|
19
|
+
return '0.7';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Default changefreq by route type. */
|
|
23
|
+
function defaultChangefreq(pattern) {
|
|
24
|
+
if (pattern === '/') return 'daily';
|
|
25
|
+
if (pattern.includes(':')) return 'weekly';
|
|
26
|
+
return 'monthly';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {string} date - ISO date string or empty
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
function fmtDate(date) {
|
|
34
|
+
if (!date) return '';
|
|
35
|
+
try { return new Date(date).toISOString().slice(0, 10); } catch { return ''; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build sitemap.xml from route config.
|
|
40
|
+
* @param {{ projectDir: string, outDir?: string, baseUrl: string, context?: Record<string, unknown> }} opts
|
|
41
|
+
* @returns {{ urls: number }}
|
|
42
|
+
*/
|
|
43
|
+
export function buildSitemap(opts) {
|
|
44
|
+
const { projectDir, outDir = 'dist', baseUrl, context = {} } = opts;
|
|
45
|
+
const routes = loadRoutes(projectDir);
|
|
46
|
+
if (!routes) {
|
|
47
|
+
console.log('⚠ No routes config found. Create clearstack.routes.json');
|
|
48
|
+
return { urls: 0 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const urls = [];
|
|
52
|
+
|
|
53
|
+
for (const [pattern, config] of Object.entries(routes)) {
|
|
54
|
+
const sm = config.sitemap || {};
|
|
55
|
+
const priority = sm.priority ?? defaultPriority(pattern);
|
|
56
|
+
const changefreq = sm.changefreq ?? defaultChangefreq(pattern);
|
|
57
|
+
const isDynamic = pattern.includes(':');
|
|
58
|
+
|
|
59
|
+
if (!isDynamic) {
|
|
60
|
+
const lastmod = fmtDate(sm.lastmod || '');
|
|
61
|
+
urls.push(entry(`${baseUrl}${pattern}`, changefreq, String(priority), lastmod));
|
|
62
|
+
} else if (config.data) {
|
|
63
|
+
const items = resolveDataSource(config.data, projectDir);
|
|
64
|
+
const paramName = pattern.match(/:(\w+)/)?.[1] || 'id';
|
|
65
|
+
for (const item of items) {
|
|
66
|
+
const slug = item.slug || item.id || item.sku || item[paramName];
|
|
67
|
+
if (!slug) continue;
|
|
68
|
+
const path = pattern.replace(`:${paramName}`, String(slug));
|
|
69
|
+
const data = { [paramName]: item, item, ...context };
|
|
70
|
+
const resolvedPath = interpolate(path, data);
|
|
71
|
+
const lastmod = fmtDate(item.date || item.modified || item.created || '');
|
|
72
|
+
urls.push(entry(`${baseUrl}${resolvedPath}`, changefreq, String(priority), lastmod));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const xml = [
|
|
78
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
79
|
+
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
|
80
|
+
...urls,
|
|
81
|
+
'</urlset>',
|
|
82
|
+
].join('\n');
|
|
83
|
+
|
|
84
|
+
const out = resolve(projectDir, outDir);
|
|
85
|
+
mkdirSync(out, { recursive: true });
|
|
86
|
+
writeFileSync(resolve(out, 'sitemap.xml'), xml);
|
|
87
|
+
console.log(`✅ Sitemap: ${urls.length} URLs → ${outDir}/sitemap.xml`);
|
|
88
|
+
return { urls: urls.length };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Build a single <url> entry. */
|
|
92
|
+
function entry(loc, changefreq, priority, lastmod) {
|
|
93
|
+
return [
|
|
94
|
+
' <url>',
|
|
95
|
+
` <loc>${esc(loc)}</loc>`,
|
|
96
|
+
lastmod ? ` <lastmod>${lastmod}</lastmod>` : '',
|
|
97
|
+
` <changefreq>${changefreq}</changefreq>`,
|
|
98
|
+
` <priority>${priority}</priority>`,
|
|
99
|
+
' </url>',
|
|
100
|
+
].filter(Boolean).join('\n');
|
|
101
|
+
}
|
package/lib/og-image-template.js
CHANGED
|
@@ -53,10 +53,12 @@ export function loadTokens(projectDir) {
|
|
|
53
53
|
const full = resolve(projectDir, p);
|
|
54
54
|
if (!existsSync(full)) continue;
|
|
55
55
|
const css = readFileSync(full, 'utf-8');
|
|
56
|
+
/** @type {Record<string, string>} */
|
|
56
57
|
const vars = {};
|
|
57
58
|
for (const m of css.matchAll(/--([^:]+):\s*([^;]+)/g)) vars[m[1].trim()] = m[2].trim();
|
|
58
59
|
if (Object.keys(vars).length) return vars;
|
|
59
60
|
}
|
|
61
|
+
/** @type {Record<string, string>} */
|
|
60
62
|
return {};
|
|
61
63
|
}
|
|
62
64
|
|
|
@@ -67,15 +69,15 @@ export function loadTokens(projectDir) {
|
|
|
67
69
|
* @param {{ tokens?: Record<string, string>, logo?: string, siteName?: string }} site
|
|
68
70
|
*/
|
|
69
71
|
export function buildContext(config, itemData, site) {
|
|
70
|
-
const t = site.tokens || {};
|
|
72
|
+
const t = /** @type {Record<string, string>} */ (site.tokens || {});
|
|
71
73
|
const title = interpolate(config.title, itemData);
|
|
72
74
|
const desc = config.description ? interpolate(config.description, itemData) : '';
|
|
73
75
|
const image = config.image ? interpolate(config.image, itemData) : '';
|
|
74
76
|
const baseUrl = itemData.store?.url || '';
|
|
75
77
|
const resolvedImage = image && !image.startsWith('http') ? `${baseUrl}${image}` : image;
|
|
76
|
-
const item = itemData.item || {};
|
|
77
|
-
const emoji = item.emoji || itemData.emoji || '';
|
|
78
|
-
const tags = item.tags || itemData.tags || [];
|
|
78
|
+
const item = /** @type {Record<string, unknown>} */ (itemData.item || {});
|
|
79
|
+
const emoji = /** @type {string} */ (item.emoji || itemData.emoji || '');
|
|
80
|
+
const tags = /** @type {string[]} */ (item.tags || itemData.tags || []);
|
|
79
81
|
const tagsHtml = tags.map((t) => `<div class="tag">#${t}</div>`).join('');
|
|
80
82
|
const postTitle = item.title || item.caption || item.name || '';
|
|
81
83
|
const logoSrc = item.logo?.wordmark || item.logo?.mark || '';
|
package/package.json
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>{{name}}</title>
|
|
7
|
+
<!-- FOUC prevention — matches --color-bg in tokens.css -->
|
|
8
|
+
<style>html,body{background:#0f172a;color:#e2e8f0;margin:0}</style>
|
|
7
9
|
<link rel="stylesheet" href="/styles/reset.css" />
|
|
8
10
|
<link rel="stylesheet" href="/styles/tokens.css" />
|
|
9
11
|
<link rel="stylesheet" href="/styles/shared.css" />
|
|
@@ -27,7 +29,22 @@
|
|
|
27
29
|
</script>
|
|
28
30
|
</head>
|
|
29
31
|
<body>
|
|
32
|
+
<noscript>
|
|
33
|
+
<div style="font-family:system-ui;padding:2rem;max-width:40rem;margin:4rem auto;text-align:center">
|
|
34
|
+
<h1>JavaScript required</h1>
|
|
35
|
+
<p>{{name}} requires JavaScript to run. Please enable it in your browser settings.</p>
|
|
36
|
+
</div>
|
|
37
|
+
</noscript>
|
|
30
38
|
<app-router></app-router>
|
|
31
39
|
<script type="module" src="/router/index.js"></script>
|
|
40
|
+
<script>
|
|
41
|
+
// Global error boundary — catches unhandled promise rejections and JS errors
|
|
42
|
+
window.addEventListener('unhandledrejection', (e) => {
|
|
43
|
+
console.error('[app] Unhandled rejection:', e.reason);
|
|
44
|
+
});
|
|
45
|
+
window.addEventListener('error', (e) => {
|
|
46
|
+
console.error('[app] Uncaught error:', e.message, e.filename, e.lineno);
|
|
47
|
+
});
|
|
48
|
+
</script>
|
|
32
49
|
</body>
|
|
33
50
|
</html>
|