@techninja/clearstack 0.3.42 → 0.3.44

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 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 === 'og-images' || sub === 'images') {
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
+ }
@@ -17,6 +17,8 @@ import { resolveTemplate, renderTemplate, buildContext, loadTokens, WIDTH, HEIGH
17
17
  * @property {string} [logo] - Path or URL to logo
18
18
  * @property {string} [siteName] - Site name for badge
19
19
  * @property {Record<string, unknown>} [context] - Extra interpolation data
20
+ * @property {(slug: string) => boolean} [filter] - Only render matching slugs
21
+ * @property {(slug: string, n: number, total: number) => void} [onProgress]
20
22
  */
21
23
 
22
24
  /**
@@ -25,7 +27,7 @@ import { resolveTemplate, renderTemplate, buildContext, loadTokens, WIDTH, HEIGH
25
27
  * @returns {Promise<{ images: number, routes: number }>}
26
28
  */
27
29
  export async function buildOGImages(opts) {
28
- const { projectDir, outDir = 'dist', logo, siteName, context = {} } = opts;
30
+ const { projectDir, outDir = 'dist', logo, siteName, context = {}, filter, onProgress } = opts;
29
31
  const routes = loadRoutes(projectDir);
30
32
  if (!routes) {
31
33
  console.log('⚠ No routes config found. Create clearstack.routes.json');
@@ -37,7 +39,8 @@ export async function buildOGImages(opts) {
37
39
  const start = performance.now();
38
40
 
39
41
  const { chromium } = await import('playwright');
40
- const browser = await chromium.launch();
42
+ const browser = await chromium.launch({ executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH });
43
+ console.log(' Browser ready, rendering...');
41
44
  const page = await browser.newPage({ viewport: { width: WIDTH, height: HEIGHT } });
42
45
  const out = resolve(projectDir, outDir);
43
46
  let images = 0;
@@ -47,6 +50,7 @@ export async function buildOGImages(opts) {
47
50
  const isDynamic = pattern.includes(':');
48
51
 
49
52
  if (!isDynamic) {
53
+ if (filter) continue;
50
54
  const data = { app: { name: siteName }, ...context };
51
55
  const ctx = buildContext(config, data, site);
52
56
  await screenshot(page, renderTemplate(template, ctx, projectDir), out, pattern);
@@ -57,11 +61,13 @@ export async function buildOGImages(opts) {
57
61
  for (const item of items) {
58
62
  const slug = item.slug || item.id || item.sku || item[paramName];
59
63
  if (!slug) continue;
64
+ if (filter && !filter(String(slug))) continue;
60
65
  const path = pattern.replace(`:${paramName}`, String(slug));
61
66
  const data = { [paramName]: item, item, app: { name: siteName }, ...context };
62
67
  const ctx = buildContext(config, data, site);
63
68
  await screenshot(page, renderTemplate(template, ctx, projectDir), out, path);
64
69
  images++;
70
+ if (onProgress) onProgress(String(slug), images, -1);
65
71
  }
66
72
  }
67
73
  }
@@ -85,7 +91,7 @@ export async function buildOneOGImage(opts) {
85
91
  const tokens = loadTokens(projectDir);
86
92
  const site = { tokens, logo, siteName };
87
93
  const { chromium } = await import('playwright');
88
- const browser = await chromium.launch();
94
+ const browser = await chromium.launch({ executablePath: process.env.PLAYWRIGHT_EXECUTABLE_PATH });
89
95
  const page = await browser.newPage({ viewport: { width: WIDTH, height: HEIGHT } });
90
96
  const out = resolve(projectDir, outDir);
91
97
  let result = '';
@@ -113,7 +119,8 @@ export async function buildOneOGImage(opts) {
113
119
 
114
120
  /** Render HTML content and save screenshot to disk. */
115
121
  async function screenshot(page, html, outDir, routePath) {
116
- await page.setContent(html, { waitUntil: 'networkidle' });
122
+ await page.setContent(html, { waitUntil: 'domcontentloaded' });
123
+ await page.waitForTimeout(html.includes('fonts.googleapis') ? 2500 : 800);
117
124
  const imgPath = routePath === '/'
118
125
  ? resolve(outDir, 'og-image.png')
119
126
  : resolve(outDir, routePath.slice(1) + '.png');
@@ -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, '&amp;').replace(/'/g, '&apos;');
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
+ }
@@ -75,6 +75,14 @@ export function buildContext(config, itemData, site) {
75
75
  const resolvedImage = image && !image.startsWith('http') ? `${baseUrl}${image}` : image;
76
76
  const item = itemData.item || {};
77
77
  const emoji = item.emoji || itemData.emoji || '';
78
+ const tags = item.tags || itemData.tags || [];
79
+ const tagsHtml = tags.map((t) => `<div class="tag">#${t}</div>`).join('');
80
+ const postTitle = item.title || item.caption || item.name || '';
81
+ const logoSrc = item.logo?.wordmark || item.logo?.mark || '';
82
+ const itemLogoHtml = logoSrc ? `<img src="${logoSrc}" alt="${postTitle}">` : '';
83
+ const dateFormatted = item.date ? new Date(item.date).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '';
84
+ const mediaUrl = resolvedImage || (item.files?.[0] ? `https://data.tn42.com/assets-media/${item.files[0]}` : '');
85
+ const isVideo = item.type === 'video' || (item.files?.[0] || '').endsWith('.mp4');
78
86
 
79
87
  return {
80
88
  ...itemData, ...item,
@@ -87,12 +95,18 @@ export function buildContext(config, itemData, site) {
87
95
  text: t['color-text'] || '#e2e8f0',
88
96
  textMuted: t['color-text-muted'] || '#a8b8cc',
89
97
  emoji, image: resolvedImage,
98
+ tagsHtml, postTitle, dateFormatted, mediaUrl,
99
+ logoHtml: itemLogoHtml,
100
+ noLogoMark: itemLogoHtml ? '' : '<div class="mark">42</div>',
101
+ mediaHtml: isVideo
102
+ ? `<video src="${mediaUrl}" autoplay muted playsinline style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover"></video>`
103
+ : mediaUrl ? `<img src="${mediaUrl}" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover">` : '',
90
104
  variantsFormatted: formatNum(item.expected_variants),
91
105
  uniqueFormatted: formatNum(item.estimated_unique_variants),
92
106
  priceFormatted: item.price ? `$${(item.price / 100).toFixed(2)}` : '',
93
107
  imageHtml: resolvedImage ? `<img class="hero" src="${resolvedImage}">` : '',
94
108
  cardClass: resolvedImage ? 'card-with-image' : '',
95
- logoHtml: site.logo ? `<img class="logo" src="${site.logo}">` : '',
109
+ siteLogo: site.logo ? `<img class="logo" src="${site.logo}">` : '',
96
110
  badgeHtml: site.siteName ? `<div class="badge">${site.siteName}</div>` : '',
97
111
  emojiHtml: emoji ? `<div class="emoji">${emoji}</div>` : '',
98
112
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techninja/clearstack",
3
- "version": "0.3.42",
3
+ "version": "0.3.44",
4
4
  "type": "module",
5
5
  "description": "A no-build web component framework specification — scaffold, validate, and evolve spec-compliant projects",
6
6
  "bin": {
@@ -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>