@techninja/clearstack 0.3.32 → 0.3.34

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
@@ -35,6 +35,7 @@ async function interactive() {
35
35
  { name: 'Initialize a new project', value: 'init' },
36
36
  { name: 'Update spec docs + configs', value: 'update' },
37
37
  { name: 'Run spec compliance check', value: 'check' },
38
+ { name: 'Build OG metadata pages', value: 'build' },
38
39
  ],
39
40
  });
40
41
  await run(action);
@@ -59,8 +60,18 @@ async function run(action) {
59
60
  const subs = args.filter((a) => a !== cmd && !a.startsWith('-'));
60
61
  const { check } = await import('../lib/check.js');
61
62
  await check(process.cwd(), subs.join(' ') || undefined);
63
+ } else if (action === 'build') {
64
+ const sub = args.find((a) => a !== 'build' && !a.startsWith('-'));
65
+ const { buildOG } = await import('../lib/build-og.js');
66
+ if (!sub || sub === 'og') {
67
+ buildOG({
68
+ projectDir: process.cwd(),
69
+ outDir: flags.out || 'dist',
70
+ baseUrl: flags.url || '',
71
+ });
72
+ }
62
73
  } else {
63
- console.log('Usage: clearstack [init|update|check] [-y]');
74
+ console.log('Usage: clearstack [init|update|check|build] [-y]');
64
75
  }
65
76
  }
66
77
 
@@ -0,0 +1,80 @@
1
+ # Spec-Driven OG Metadata Generation
2
+
3
+ ## Problem
4
+
5
+ Every Clearstack site shares links that look blank in messaging apps. No title,
6
+ no description, no image. Social crawlers (Slack, Discord, Twitter, iMessage)
7
+ only read static HTML — they don't execute JavaScript. Since Clearstack apps are
8
+ SPAs with a single `index.html`, every shared link shows the same generic
9
+ metadata regardless of the actual page content.
10
+
11
+ This is a baseline expectation for any web project in 2025. If your link preview
12
+ looks sketchy, people don't click.
13
+
14
+ ## Proposal
15
+
16
+ Clearstack already requires routes to be defined in the app spec with metadata
17
+ (title, description). The build system should use this to generate static HTML
18
+ shells for each route — just enough for crawlers to read OG tags, while the SPA
19
+ still handles rendering for real users.
20
+
21
+ ## How it should work
22
+
23
+ 1. **Spec defines routes with metadata:**
24
+
25
+ ```yaml
26
+ routes:
27
+ /traits/:id:
28
+ title: '{trait.emoji} {trait.name} | {app.name}'
29
+ description: '{trait.description}'
30
+ image: '{trait.cover_image.url}'
31
+ data: trait_manifest.traits
32
+ ```
33
+
34
+ 2. **Build reads the spec + data sources:**
35
+ - For static routes (`/about`, `/pricing`): generate one HTML file
36
+ - For dynamic routes (`/traits/:id`): iterate over the data source, generate
37
+ one HTML file per entry
38
+
39
+ 3. **Generated HTML contains:**
40
+ - Correct `<title>` and `<meta name="description">`
41
+ - `og:title`, `og:description`, `og:image`, `og:url`
42
+ - `twitter:card`, `twitter:title`, `twitter:description`, `twitter:image`
43
+ - The same `<body>` as index.html (SPA takes over client-side)
44
+
45
+ 4. **Deploy serves these files:**
46
+ - Cloudflare Pages / Netlify / Vercel serve the static file if it exists
47
+ - Falls back to `404.html` (SPA) for routes without generated pages
48
+
49
+ ## Requirements
50
+
51
+ - Zero config for basic cases (route title + description → OG tags)
52
+ - Opt-in image support (from data source or static asset)
53
+ - Works with any deploy target that supports static files + SPA fallback
54
+ - Doesn't require a server or edge function
55
+ - Build is fast (64 pages in <1s, 648 pages in <5s)
56
+
57
+ ## Prior art
58
+
59
+ - Asili's `scripts/build-og.js` — generates 64 trait pages from manifest data
60
+ - Next.js `generateMetadata` — server-side, requires Node runtime
61
+ - Astro content collections — static generation from markdown frontmatter
62
+
63
+ ## Implementation notes
64
+
65
+ This could be a `clearstack build` subcommand or a plugin:
66
+
67
+ ```bash
68
+ clearstack build og # Generate OG pages from spec
69
+ clearstack build # Full build including OG
70
+ ```
71
+
72
+ The spec parser already knows all routes. The data source connection is the new
73
+ piece — reading from JSON manifests, markdown frontmatter, or API responses at
74
+ build time.
75
+
76
+ ## Open questions
77
+
78
+ - Should this live in core clearstack or as an optional plugin?
79
+ - How to handle routes that need auth/user data (skip them? generic fallback?)
80
+ - Image generation (dynamic OG images with text overlay) — future scope?
@@ -0,0 +1,141 @@
1
+ /**
2
+ * OG metadata page generator — reads route config, resolves data sources,
3
+ * and writes static HTML files with OG tags for social crawlers.
4
+ * @module lib/build-og
5
+ */
6
+
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
+ import { dirname, resolve } from 'node:path';
9
+ import { injectMeta, interpolate } from './og-template.js';
10
+
11
+ /**
12
+ * @typedef {Object} RouteConfig
13
+ * @property {string} title - Template string, e.g. "{trait.name} | My App"
14
+ * @property {string} description - Template string
15
+ * @property {string} [image] - Template string for og:image
16
+ * @property {string} [data] - Dot-path to JSON data source, e.g. "traits.json:traits"
17
+ */
18
+
19
+ /**
20
+ * @typedef {Object} BuildOGOptions
21
+ * @property {string} projectDir - Project root
22
+ * @property {string} [outDir] - Output directory (default: "dist")
23
+ * @property {string} [baseUrl] - Site base URL for og:url
24
+ * @property {string} [htmlPath] - Path to index.html shell
25
+ */
26
+
27
+ /**
28
+ * Load route config from clearstack.routes.json or package.json.
29
+ * @param {string} projectDir
30
+ * @returns {Record<string, RouteConfig> | null}
31
+ */
32
+ export function loadRoutes(projectDir) {
33
+ const routesFile = resolve(projectDir, 'clearstack.routes.json');
34
+ if (existsSync(routesFile)) {
35
+ return JSON.parse(readFileSync(routesFile, 'utf-8'));
36
+ }
37
+ const pkgPath = resolve(projectDir, 'package.json');
38
+ if (existsSync(pkgPath)) {
39
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
40
+ if (pkg.clearstack?.routes) return pkg.clearstack.routes;
41
+ }
42
+ return null;
43
+ }
44
+
45
+ /**
46
+ * Resolve a data source reference like "data/traits.json:traits".
47
+ * Format: "filepath:jsonPath" where jsonPath is dot-notation into the file.
48
+ * @param {string} ref
49
+ * @param {string} projectDir
50
+ * @returns {unknown[]}
51
+ */
52
+ export function resolveDataSource(ref, projectDir) {
53
+ const [filePart, ...pathParts] = ref.split(':');
54
+ const jsonPath = pathParts.join(':');
55
+ const filePath = resolve(projectDir, filePart);
56
+ if (!existsSync(filePath)) return [];
57
+ const content = JSON.parse(readFileSync(filePath, 'utf-8'));
58
+ if (!jsonPath) return Array.isArray(content) ? content : [content];
59
+ const data = jsonPath.split('.').reduce((obj, key) => obj?.[key], content);
60
+ return Array.isArray(data) ? data : [];
61
+ }
62
+
63
+ /**
64
+ * Generate OG pages for all configured routes.
65
+ * @param {BuildOGOptions} opts
66
+ * @returns {{ pages: number, routes: number }}
67
+ */
68
+ export function buildOG(opts) {
69
+ const { projectDir, outDir = 'dist', baseUrl = '', htmlPath } = opts;
70
+ const routes = loadRoutes(projectDir);
71
+ if (!routes) {
72
+ console.log('⚠ No routes config found. Create clearstack.routes.json');
73
+ return { pages: 0, routes: 0 };
74
+ }
75
+
76
+ const shellPath = htmlPath || resolve(projectDir, 'src/index.html');
77
+ if (!existsSync(shellPath)) {
78
+ console.log(`⚠ HTML shell not found: ${shellPath}`);
79
+ return { pages: 0, routes: 0 };
80
+ }
81
+ const shell = readFileSync(shellPath, 'utf-8');
82
+ const out = resolve(projectDir, outDir);
83
+ let pages = 0;
84
+
85
+ for (const [pattern, config] of Object.entries(routes)) {
86
+ const isDynamic = pattern.includes(':');
87
+
88
+ if (!isDynamic) {
89
+ const data = { app: { name: baseUrl } };
90
+ const meta = resolveMeta(config, data, baseUrl, pattern);
91
+ writePage(out, pattern, shell, meta);
92
+ pages++;
93
+ } else if (config.data) {
94
+ const items = resolveDataSource(config.data, projectDir);
95
+ const paramName = pattern.match(/:(\w+)/)?.[1] || 'id';
96
+ for (const item of items) {
97
+ const slug = item.slug || item.id || item[paramName];
98
+ if (!slug) continue;
99
+ const path = pattern.replace(`:${paramName}`, String(slug));
100
+ const data = { [paramName]: item, item, app: { name: baseUrl } };
101
+ const meta = resolveMeta(config, data, baseUrl, path);
102
+ writePage(out, path, shell, meta);
103
+ pages++;
104
+ }
105
+ }
106
+ }
107
+
108
+ console.log(`✅ Generated ${pages} OG pages from ${Object.keys(routes).length} routes`);
109
+ return { pages, routes: Object.keys(routes).length };
110
+ }
111
+
112
+ /**
113
+ * Resolve meta from config + data context.
114
+ * @param {RouteConfig} config
115
+ * @param {Record<string, unknown>} data
116
+ * @param {string} baseUrl
117
+ * @param {string} path
118
+ */
119
+ function resolveMeta(config, data, baseUrl, path) {
120
+ return {
121
+ title: interpolate(config.title, data),
122
+ description: interpolate(config.description, data),
123
+ url: `${baseUrl}${path}`,
124
+ image: config.image ? interpolate(config.image, data) : undefined,
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Write a single HTML page to the output directory.
130
+ * @param {string} outDir
131
+ * @param {string} routePath
132
+ * @param {string} shell
133
+ * @param {import('./og-template.js').OGMeta} meta
134
+ */
135
+ function writePage(outDir, routePath, shell, meta) {
136
+ const filePath = routePath.endsWith('/')
137
+ ? resolve(outDir, routePath.slice(1), 'index.html')
138
+ : resolve(outDir, routePath.slice(1), 'index.html');
139
+ mkdirSync(dirname(filePath), { recursive: true });
140
+ writeFileSync(filePath, injectMeta(shell, meta));
141
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * OG metadata HTML template generator.
3
+ * Injects Open Graph + Twitter Card meta tags into an HTML shell.
4
+ * @module lib/og-template
5
+ */
6
+
7
+ /**
8
+ * @typedef {Object} OGMeta
9
+ * @property {string} title
10
+ * @property {string} description
11
+ * @property {string} url
12
+ * @property {string} [image]
13
+ * @property {string} [siteName]
14
+ */
15
+
16
+ /**
17
+ * Build the meta tag block for a page.
18
+ * @param {OGMeta} meta
19
+ * @returns {string}
20
+ */
21
+ export function buildMetaTags(meta) {
22
+ const tags = [
23
+ `<title>${meta.title}</title>`,
24
+ `<meta name="description" content="${esc(meta.description)}">`,
25
+ `<meta property="og:title" content="${esc(meta.title)}">`,
26
+ `<meta property="og:description" content="${esc(meta.description)}">`,
27
+ `<meta property="og:url" content="${esc(meta.url)}">`,
28
+ `<meta property="og:type" content="website">`,
29
+ `<meta name="twitter:card" content="summary_large_image">`,
30
+ `<meta name="twitter:title" content="${esc(meta.title)}">`,
31
+ `<meta name="twitter:description" content="${esc(meta.description)}">`,
32
+ ];
33
+ if (meta.siteName) {
34
+ tags.push(`<meta property="og:site_name" content="${esc(meta.siteName)}">`);
35
+ }
36
+ if (meta.image) {
37
+ tags.push(`<meta property="og:image" content="${esc(meta.image)}">`);
38
+ tags.push(`<meta name="twitter:image" content="${esc(meta.image)}">`);
39
+ }
40
+ return tags.join('\n ');
41
+ }
42
+
43
+ /**
44
+ * Inject OG meta tags into an HTML shell (replaces <title> and adds meta).
45
+ * @param {string} html - The base index.html content
46
+ * @param {OGMeta} meta
47
+ * @returns {string}
48
+ */
49
+ export function injectMeta(html, meta) {
50
+ const tags = buildMetaTags(meta);
51
+ // Replace existing <title>...</title> with full meta block
52
+ const withMeta = html.replace(/<title>[^<]*<\/title>/, tags);
53
+ return withMeta;
54
+ }
55
+
56
+ /**
57
+ * Interpolate template strings like "{trait.name}" against a data object.
58
+ * Supports dot notation: "{trait.emoji}" → data.trait.emoji
59
+ * @param {string} template
60
+ * @param {Record<string, unknown>} data
61
+ * @returns {string}
62
+ */
63
+ export function interpolate(template, data) {
64
+ return template.replace(/\{([^}]+)\}/g, (_, path) => {
65
+ const val = path.split('.').reduce((obj, key) => obj?.[key], data);
66
+ return val !== null && val !== undefined ? String(val) : '';
67
+ });
68
+ }
69
+
70
+ /** @param {string} str */
71
+ function esc(str) {
72
+ return str
73
+ .replace(/&/g, '&amp;')
74
+ .replace(/"/g, '&quot;')
75
+ .replace(/</g, '&lt;')
76
+ .replace(/>/g, '&gt;');
77
+ }
package/lib/update.js CHANGED
@@ -14,13 +14,14 @@ import { detectPlatforms, updatePlatform } from './platform.js';
14
14
  * @param {string} src
15
15
  * @param {string} dest
16
16
  * @param {string} label
17
- * @param {{ skipExisting?: boolean, onlyExisting?: boolean }} [opts]
17
+ * @param {{ skipExisting?: boolean, onlyExisting?: boolean, exclude?: string[] }} [opts]
18
18
  * @returns {number} count of updated files
19
19
  */
20
20
  function syncDir(src, dest, label, opts = {}) {
21
21
  if (!existsSync(src)) return 0;
22
22
  mkdirSync(dest, { recursive: true });
23
- const files = readdirSync(src).filter((f) => !f.startsWith('.spec'));
23
+ const exclude = new Set(opts.exclude || []);
24
+ const files = readdirSync(src).filter((f) => !f.startsWith('.spec') && !exclude.has(f));
24
25
  let updated = 0;
25
26
 
26
27
  for (const file of files) {
@@ -84,11 +85,12 @@ export async function update(pkgRoot, opts = {}) {
84
85
  }
85
86
 
86
87
  // Scripts: sync clearstack-owned scripts (only update existing files)
88
+ // setup.js and build-icons.js excluded — platforms extend them
87
89
  total += syncDir(
88
90
  resolve(templateShared, 'scripts'),
89
91
  resolve(process.cwd(), 'scripts'),
90
92
  'scripts',
91
- { onlyExisting: true },
93
+ { onlyExisting: true, exclude: ['setup.js', 'build-icons.js'] },
92
94
  );
93
95
 
94
96
  // Write spec version marker
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@techninja/clearstack",
3
- "version": "0.3.32",
3
+ "version": "0.3.34",
4
4
  "type": "module",
5
5
  "description": "A no-build web component framework specification — scaffold, validate, and evolve spec-compliant projects",
6
6
  "bin": {
@@ -0,0 +1,80 @@
1
+ # Spec-Driven OG Metadata Generation
2
+
3
+ ## Problem
4
+
5
+ Every Clearstack site shares links that look blank in messaging apps. No title,
6
+ no description, no image. Social crawlers (Slack, Discord, Twitter, iMessage)
7
+ only read static HTML — they don't execute JavaScript. Since Clearstack apps are
8
+ SPAs with a single `index.html`, every shared link shows the same generic
9
+ metadata regardless of the actual page content.
10
+
11
+ This is a baseline expectation for any web project in 2025. If your link preview
12
+ looks sketchy, people don't click.
13
+
14
+ ## Proposal
15
+
16
+ Clearstack already requires routes to be defined in the app spec with metadata
17
+ (title, description). The build system should use this to generate static HTML
18
+ shells for each route — just enough for crawlers to read OG tags, while the SPA
19
+ still handles rendering for real users.
20
+
21
+ ## How it should work
22
+
23
+ 1. **Spec defines routes with metadata:**
24
+
25
+ ```yaml
26
+ routes:
27
+ /traits/:id:
28
+ title: '{trait.emoji} {trait.name} | {app.name}'
29
+ description: '{trait.description}'
30
+ image: '{trait.cover_image.url}'
31
+ data: trait_manifest.traits
32
+ ```
33
+
34
+ 2. **Build reads the spec + data sources:**
35
+ - For static routes (`/about`, `/pricing`): generate one HTML file
36
+ - For dynamic routes (`/traits/:id`): iterate over the data source, generate
37
+ one HTML file per entry
38
+
39
+ 3. **Generated HTML contains:**
40
+ - Correct `<title>` and `<meta name="description">`
41
+ - `og:title`, `og:description`, `og:image`, `og:url`
42
+ - `twitter:card`, `twitter:title`, `twitter:description`, `twitter:image`
43
+ - The same `<body>` as index.html (SPA takes over client-side)
44
+
45
+ 4. **Deploy serves these files:**
46
+ - Cloudflare Pages / Netlify / Vercel serve the static file if it exists
47
+ - Falls back to `404.html` (SPA) for routes without generated pages
48
+
49
+ ## Requirements
50
+
51
+ - Zero config for basic cases (route title + description → OG tags)
52
+ - Opt-in image support (from data source or static asset)
53
+ - Works with any deploy target that supports static files + SPA fallback
54
+ - Doesn't require a server or edge function
55
+ - Build is fast (64 pages in <1s, 648 pages in <5s)
56
+
57
+ ## Prior art
58
+
59
+ - Asili's `scripts/build-og.js` — generates 64 trait pages from manifest data
60
+ - Next.js `generateMetadata` — server-side, requires Node runtime
61
+ - Astro content collections — static generation from markdown frontmatter
62
+
63
+ ## Implementation notes
64
+
65
+ This could be a `clearstack build` subcommand or a plugin:
66
+
67
+ ```bash
68
+ clearstack build og # Generate OG pages from spec
69
+ clearstack build # Full build including OG
70
+ ```
71
+
72
+ The spec parser already knows all routes. The data source connection is the new
73
+ piece — reading from JSON manifests, markdown frontmatter, or API responses at
74
+ build time.
75
+
76
+ ## Open questions
77
+
78
+ - Should this live in core clearstack or as an optional plugin?
79
+ - How to handle routes that need auth/user data (skip them? generic fallback?)
80
+ - Image generation (dynamic OG images with text overlay) — future scope?