@techninja/clearstack 0.3.36 → 0.3.38

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
@@ -5,7 +5,9 @@
5
5
  * Usage:
6
6
  * clearstack init [-y] [--static|--fullstack] [--port 3000]
7
7
  * clearstack update [--force]
8
- * clearstack check [code|docs|imports|lint|lint es|format|types|audit|all]
8
+ * clearstack build [og|og-images|all] → generate OG pages and/or images
9
+ * clearstack check [code|docs|imports|lint|format|types|audit|all]
10
+ * clearstack report --json → structured JSON output for tooling
9
11
  * clearstack → interactive menu
10
12
  */
11
13
 
@@ -35,7 +37,8 @@ async function interactive() {
35
37
  { name: 'Initialize a new project', value: 'init' },
36
38
  { name: 'Update spec docs + configs', value: 'update' },
37
39
  { name: 'Run spec compliance check', value: 'check' },
38
- { name: 'Build OG metadata pages', value: 'build' },
40
+ { name: 'Entropy report (drift summary)', value: 'report' },
41
+ { name: 'Build OG pages + images', value: 'build' },
39
42
  ],
40
43
  });
41
44
  await run(action);
@@ -60,15 +63,32 @@ async function run(action) {
60
63
  const subs = args.filter((a) => a !== cmd && !a.startsWith('-'));
61
64
  const { check } = await import('../lib/check.js');
62
65
  await check(process.cwd(), subs.join(' ') || undefined);
66
+ } else if (action === 'report') {
67
+ const { report } = await import('../lib/report.js');
68
+ report(process.cwd(), { json: !!flags.json });
63
69
  } else if (action === 'build') {
64
70
  const sub = args.find((a) => a !== 'build' && !a.startsWith('-'));
65
- const { buildOG } = await import('../lib/build-og.js');
66
- if (!sub || sub === 'og') {
71
+ if (sub === 'og-images' || sub === 'images') {
72
+ const mod = await import('../lib/build-og-images.js');
73
+ const common = { projectDir: process.cwd(), outDir: flags.out || 'dist', logo: flags.logo || '', siteName: flags.site || '' };
74
+ if (flags.slug) await mod.buildOneOGImage({ ...common, slug: flags.slug });
75
+ else await mod.buildOGImages(common);
76
+ } else {
77
+ const { buildOG } = await import('../lib/build-og.js');
67
78
  buildOG({
68
79
  projectDir: process.cwd(),
69
80
  outDir: flags.out || 'dist',
70
81
  baseUrl: flags.url || '',
71
82
  });
83
+ if (sub === 'all') {
84
+ const { buildOGImages } = await import('../lib/build-og-images.js');
85
+ await buildOGImages({
86
+ projectDir: process.cwd(),
87
+ outDir: flags.out || 'dist',
88
+ logo: flags.logo || '',
89
+ siteName: flags.site || '',
90
+ });
91
+ }
72
92
  }
73
93
  } else {
74
94
  console.log('Usage: clearstack [init|update|check|build] [-y]');
@@ -8,73 +8,120 @@ only read static HTML — they don't execute JavaScript. Since Clearstack apps a
8
8
  SPAs with a single `index.html`, every shared link shows the same generic
9
9
  metadata regardless of the actual page content.
10
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
11
+ ## Solution
12
+
13
+ Clearstack generates two things from the route config:
14
+
15
+ 1. **Static HTML shells** with OG meta tags (for crawlers)
16
+ 2. **Dynamic OG images** (1200×630 PNGs rendered via Playwright)
17
+
18
+ Both are driven by the same `clearstack.routes.json` config and data sources.
19
+
20
+ ## Route Config
21
+
22
+ ```json
23
+ {
24
+ "/trait/:slug": {
25
+ "title": "{slug.name}",
26
+ "description": "{slug.description}",
27
+ "image": "{slug.cover_image.url}",
28
+ "data": "src/data/trait_manifest.json:traits",
29
+ "ogTemplate": "trait"
30
+ },
31
+ "/shop/product/:sku": {
32
+ "title": "{sku.name} | {store.name}",
33
+ "description": "{sku.description}",
34
+ "image": "{store.url}{sku.images.0}",
35
+ "data": "src/data/products.json",
36
+ "ogTemplate": "product"
37
+ }
38
+ }
39
+ ```
15
40
 
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.
41
+ ### Fields
20
42
 
21
- ## How it should work
43
+ | Field | Required | Description |
44
+ | -------------- | -------- | ------------------------------------------------------- |
45
+ | `title` | yes | Template string with `{path.to.data}` interpolation |
46
+ | `description` | yes | Template string for meta description |
47
+ | `image` | no | Template string for og:image URL |
48
+ | `data` | no | Data source — `filepath:jsonPath` (supports arrays/objects) |
49
+ | `ogTemplate` | no | Template name → resolves to `src/og-templates/{name}.html` |
22
50
 
23
- 1. **Spec defines routes with metadata:**
51
+ ### Data Sources
24
52
 
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
- ```
53
+ - **Arrays** — each item becomes a page
54
+ - **Objects** — entries become arrays with the key set as `slug`
55
+ - **JSON path** — dot notation into nested structures (e.g. `manifest.json:traits`)
33
56
 
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
57
+ ## OG Image Templates
38
58
 
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)
59
+ Templates are HTML files at `src/og-templates/` rendered by Playwright at
60
+ 1200×630 and screenshotted to PNG.
44
61
 
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
62
+ ### Resolution Order
48
63
 
49
- ## Requirements
64
+ 1. `src/og-templates/{ogTemplate}.html` (route-specific)
65
+ 2. `src/og-templates/default.html` (project default)
66
+ 3. Built-in default (gradient card with title/description)
50
67
 
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)
68
+ ### Template Conventions
56
69
 
57
- ## Prior art
70
+ - Use `{tokenName}` interpolation for data (word chars + dots only)
71
+ - Local assets (`src="/logo.svg"`) are inlined as data URIs automatically
72
+ - Remote images (`https://...`) load via Playwright's network
73
+ - CSS custom properties from project tokens are injected into context
74
+ - Available computed fields: `{variantsFormatted}`, `{uniqueFormatted}`
58
75
 
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
76
+ ### Available Context Variables
62
77
 
63
- ## Implementation notes
78
+ | Variable | Source |
79
+ | -------------------- | ----------------------------------- |
80
+ | `{title}` | Resolved + truncated route title |
81
+ | `{description}` | Resolved + truncated description |
82
+ | `{image}` | Resolved image URL |
83
+ | `{emoji}` | From item data |
84
+ | `{bg}`, `{primary}` | From project CSS tokens |
85
+ | `{variantsFormatted}`| Formatted number (K/M suffix) |
86
+ | All item fields | Spread directly (e.g. `{name}`, `{pgs_count}`) |
64
87
 
65
- This could be a `clearstack build` subcommand or a plugin:
88
+ ## CLI Commands
66
89
 
67
90
  ```bash
68
- clearstack build og # Generate OG pages from spec
69
- clearstack build # Full build including OG
91
+ # Generate OG HTML pages (for crawlers)
92
+ clearstack build og --url=https://mysite.com --out=dist
93
+
94
+ # Generate OG images (all routes)
95
+ clearstack build og-images --site=MySite --out=dist
96
+
97
+ # Generate a single image (fast iteration)
98
+ clearstack build og-images --slug=EFO_0004279 --site=MySite --out=dist/og
99
+
100
+ # Generate both HTML + images
101
+ clearstack build all --url=https://mysite.com --site=MySite --out=dist
70
102
  ```
71
103
 
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.
104
+ ### Flags
105
+
106
+ | Flag | Description |
107
+ | ---------- | ------------------------------------ |
108
+ | `--out` | Output directory (default: `dist`) |
109
+ | `--url` | Base URL for og:url tags |
110
+ | `--site` | Site name for badge/branding |
111
+ | `--logo` | Logo path or URL |
112
+ | `--slug` | Single item slug (fast iteration) |
113
+
114
+ ## Requirements
115
+
116
+ - Zero config for basic cases (route title + description → OG tags)
117
+ - Custom templates per page type via `ogTemplate` field
118
+ - Design tokens (CSS variables) automatically available in templates
119
+ - Local assets inlined as data URIs (no file:// security issues)
120
+ - Fast iteration: `--slug` flag renders one image in ~2s
121
+ - Full batch: 64 pages in <60s
122
+ - Works with any static deploy target (Cloudflare Pages, Netlify, Vercel)
75
123
 
76
- ## Open questions
124
+ ## Dependencies
77
125
 
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?
126
+ - **Playwright** (devDependency) for OG image rendering
127
+ - No runtime dependencies generated files are static PNGs + HTML
@@ -0,0 +1,116 @@
1
+ # Spec Workflow: LLM Sessions & Compliance
2
+
3
+ ## The Problem
4
+
5
+ LLM coding sessions move fast. Features emerge through rapid iteration,
6
+ and stopping to run `npm run spec` after every file edit creates friction
7
+ that kills creative velocity. But ignoring the spec entirely leads to
8
+ a costly batch refactor at the end — splitting files, fixing lint, and
9
+ chasing type errors across dozens of changes.
10
+
11
+ ## The Balance: Feature Checkpoints
12
+
13
+ Don't run spec after every change. Don't ignore it until deploy. Instead,
14
+ run spec at **natural feature boundaries**:
15
+
16
+ ```
17
+ Feature unit complete → npm run spec code
18
+ Section complete → npm run spec all
19
+ Pre-deploy → npm run spec all + types
20
+ ```
21
+
22
+ A "feature unit" is a logical chunk: "media grid view works," "portfolio
23
+ section complete," "name correction system done." Not every file save.
24
+
25
+ ## Project Rules for LLM Context
26
+
27
+ The biggest spec compliance gap is **the LLM not knowing the rules exist.**
28
+ Spec docs live in `docs/` but aren't automatically loaded into session
29
+ context. Fix this with a project-level rules file:
30
+
31
+ ```
32
+ .amazonq/rules/clearstack.md # Loaded every session automatically
33
+ ```
34
+
35
+ This file should state:
36
+
37
+ - The 150-line limit and what to do at 120 lines
38
+ - Decomposition patterns (what to extract when)
39
+ - Code style enforcement (so the LLM writes compliant code from the start)
40
+ - The assumption that spec watch may be running
41
+
42
+ When an LLM knows the constraint exists, it naturally writes smaller
43
+ files, adds `SPLIT CANDIDATE` comments, and avoids patterns that will
44
+ need refactoring later.
45
+
46
+ ## The SPLIT CANDIDATE Pattern
47
+
48
+ When a file passes ~120 lines during development:
49
+
50
+ ```javascript
51
+ // SPLIT CANDIDATE: keyboard handler → utils/mediaKeys.js
52
+ // SPLIT CANDIDATE: carousel template → utils/mediaCarousel.js
53
+ ```
54
+
55
+ These comments serve two purposes:
56
+
57
+ 1. Signal to the current session where to split if the file goes over
58
+ 2. Signal to a future session exactly how to decompose without re-reading
59
+
60
+ ## Spec Watch Mode (Proposed)
61
+
62
+ A dashboard that runs continuously during development:
63
+
64
+ ```bash
65
+ npm run spec --watch
66
+ ```
67
+
68
+ Displays a compact status board:
69
+
70
+ ```
71
+ ✅ code (67 files)
72
+ ✅ lint (44 files)
73
+ ⚠️ types (1 warning)
74
+ ✅ docs (12 files)
75
+ ```
76
+
77
+ When a violation occurs, it shows the error inline and provides a
78
+ copy-pasteable block the developer can drop into their LLM session:
79
+
80
+ ```
81
+ ❌ code: src/pages/media/media-detail-view.js (167 lines, max 150)
82
+ Split candidates: L45 data loading, L89 carousel template, L140 related grid
83
+ ```
84
+
85
+ This gives the LLM everything it needs to fix the issue without running
86
+ the check itself, re-reading the file, or guessing at the structure.
87
+
88
+ ## Automated Ignore Configuration
89
+
90
+ The spec checker should manage ignore paths centrally rather than
91
+ requiring manual duplication across eslint, prettier, stylelint, and
92
+ jsconfig. A single `SPEC_IGNORE_DIRS` in `.env` should propagate to
93
+ all tools automatically, or the spec checker should inject ignores
94
+ when running each tool.
95
+
96
+ Current pain: adding `src/space/` required editing 5 different config
97
+ files. It should require editing one.
98
+
99
+ ## Decomposition Patterns for Views
100
+
101
+ View components (page-level) are the most common files to exceed 150
102
+ lines because they combine: imports, data loading, state management,
103
+ keyboard handlers, and large template literals.
104
+
105
+ Extraction priority (most reusable → most specific):
106
+
107
+ | What to extract | Where to put it | When |
108
+ |----------------|-----------------|------|
109
+ | Data loading/caching | `src/utils/{feature}Loader.js` | >20 lines of fetch/transform |
110
+ | Keyboard/event handlers | `src/utils/{feature}Keys.js` | >10 lines of handler logic |
111
+ | Repeated template fragments | `src/utils/{feature}Template.js` | Used in 2+ views |
112
+ | Render sub-sections | `src/utils/{feature}Render.js` | Single view, but >30 lines |
113
+ | Shared UI (header, breadcrumb) | `src/components/` | Used site-wide |
114
+
115
+ The goal isn't to have zero logic in view files — it's to keep each
116
+ file answerable with "what does this do?" in one pass.
@@ -0,0 +1,80 @@
1
+ # Spec Watch Dashboard Mode
2
+
3
+ ## Summary
4
+
5
+ A continuous-running spec compliance dashboard for development that
6
+ displays real-time status and produces LLM-friendly output on violations.
7
+
8
+ ## Command
9
+
10
+ ```bash
11
+ clearstack check --watch # or: npm run spec --watch
12
+ ```
13
+
14
+ ## Display
15
+
16
+ Compact terminal dashboard, updates on file changes:
17
+
18
+ ```
19
+ ┌─ Clearstack Spec Watch ─────────────────────────┐
20
+ │ ✅ code 67 files all ≤150 lines │
21
+ │ ✅ lint 44 files 0 errors │
22
+ │ ✅ types 44 files 0 errors │
23
+ │ ✅ docs 12 files all ≤500 lines │
24
+ │ │
25
+ │ watching src/ scripts/ docs/ │
26
+ │ last check: 2s ago │
27
+ └──────────────────────────────────────────────────┘
28
+ ```
29
+
30
+ On violation:
31
+
32
+ ```
33
+ ┌─ Clearstack Spec Watch ─────────────────────────┐
34
+ │ ❌ code 1 violation │
35
+ │ ✅ lint 44 files │
36
+ │ ✅ types 44 files │
37
+ │ ✅ docs 12 files │
38
+ │ │
39
+ │ ─── Copy below into your LLM session ─── │
40
+ │ │
41
+ │ src/pages/media/media-detail-view.js │
42
+ │ 167 lines (max 150, +17 over) │
43
+ │ Split candidates: │
44
+ │ L15-42: data loading → utils/mediaLoader.js │
45
+ │ L58-89: carousel render → utils/carousel.js │
46
+ │ L140-167: related grid → utils/related.js │
47
+ │ │
48
+ └──────────────────────────────────────────────────┘
49
+ ```
50
+
51
+ ## Key Design Decisions
52
+
53
+ - **LLM-friendly output**: The violation block is designed to be
54
+ copy-pasted into a chat session. It contains file path, line numbers,
55
+ exact overage, and actionable split suggestions.
56
+
57
+ - **Split candidate detection**: Reads `// SPLIT CANDIDATE:` comments
58
+ from the file. If none exist, uses heuristics (comment headers,
59
+ export boundaries, function declarations) to suggest seams.
60
+
61
+ - **Debounced**: Doesn't re-check on every keystroke. Waits 500ms after
62
+ last file change, then runs only the relevant check (code check for
63
+ .js/.css changes, lint for .js changes, etc).
64
+
65
+ - **No blocking**: Never prevents saves or interrupts workflow. It's
66
+ purely informational — a dashboard you glance at.
67
+
68
+ ## Implementation Notes
69
+
70
+ - Use `fs.watch` or chokidar for file watching
71
+ - Re-run only affected checks (file changed → which check cares?)
72
+ - Terminal UI via basic ANSI escape codes (no heavy TUI library)
73
+ - Exit with Ctrl+C, summary of current state on exit
74
+
75
+ ## Integration with IDE
76
+
77
+ If the user's IDE has a terminal panel, this runs there. The
78
+ "copy below" block is the key UX innovation — it bridges the gap
79
+ between the spec checker (which knows the problem) and the LLM
80
+ (which can fix it but needs context).
@@ -0,0 +1,122 @@
1
+ /**
2
+ * OG image generator — renders HTML templates with Playwright, saves PNGs.
3
+ * Generates 1200×630 social preview images per route from the same data
4
+ * sources used by the OG HTML page builder.
5
+ * @module lib/build-og-images
6
+ */
7
+
8
+ import { mkdirSync } from 'node:fs';
9
+ import { resolve, dirname } from 'node:path';
10
+ import { loadRoutes, resolveDataSource } from './build-og.js';
11
+ import { resolveTemplate, renderTemplate, buildContext, loadTokens, WIDTH, HEIGHT } from './og-image-template.js';
12
+
13
+ /**
14
+ * @typedef {Object} BuildOGImagesOptions
15
+ * @property {string} projectDir
16
+ * @property {string} [outDir] - Default: "dist"
17
+ * @property {string} [logo] - Path or URL to logo
18
+ * @property {string} [siteName] - Site name for badge
19
+ * @property {Record<string, unknown>} [context] - Extra interpolation data
20
+ */
21
+
22
+ /**
23
+ * Generate OG images for all configured routes.
24
+ * @param {BuildOGImagesOptions} opts
25
+ * @returns {Promise<{ images: number, routes: number }>}
26
+ */
27
+ export async function buildOGImages(opts) {
28
+ const { projectDir, outDir = 'dist', logo, siteName, context = {} } = opts;
29
+ const routes = loadRoutes(projectDir);
30
+ if (!routes) {
31
+ console.log('⚠ No routes config found. Create clearstack.routes.json');
32
+ return { images: 0, routes: 0 };
33
+ }
34
+
35
+ const tokens = loadTokens(projectDir);
36
+ const site = { tokens, logo, siteName };
37
+ const start = performance.now();
38
+
39
+ const { chromium } = await import('playwright');
40
+ const browser = await chromium.launch();
41
+ const page = await browser.newPage({ viewport: { width: WIDTH, height: HEIGHT } });
42
+ const out = resolve(projectDir, outDir);
43
+ let images = 0;
44
+
45
+ for (const [pattern, config] of Object.entries(routes)) {
46
+ const template = resolveTemplate(projectDir, config.ogTemplate);
47
+ const isDynamic = pattern.includes(':');
48
+
49
+ if (!isDynamic) {
50
+ const data = { app: { name: siteName }, ...context };
51
+ const ctx = buildContext(config, data, site);
52
+ await screenshot(page, renderTemplate(template, ctx, projectDir), out, pattern);
53
+ images++;
54
+ } else if (config.data) {
55
+ const items = resolveDataSource(config.data, projectDir);
56
+ const paramName = pattern.match(/:(\w+)/)?.[1] || 'id';
57
+ for (const item of items) {
58
+ const slug = item.slug || item.id || item.sku || item[paramName];
59
+ if (!slug) continue;
60
+ const path = pattern.replace(`:${paramName}`, String(slug));
61
+ const data = { [paramName]: item, item, app: { name: siteName }, ...context };
62
+ const ctx = buildContext(config, data, site);
63
+ await screenshot(page, renderTemplate(template, ctx, projectDir), out, path);
64
+ images++;
65
+ }
66
+ }
67
+ }
68
+
69
+ await browser.close();
70
+ const elapsed = ((performance.now() - start) / 1000).toFixed(1);
71
+ console.log(`✅ Generated ${images} OG images from ${Object.keys(routes).length} route(s) (${elapsed}s)`);
72
+ return { images, routes: Object.keys(routes).length };
73
+ }
74
+
75
+ /**
76
+ * Build a single OG image for one slug (fast iteration).
77
+ * @param {BuildOGImagesOptions & { slug: string }} opts
78
+ * @returns {Promise<string>} Path to generated image
79
+ */
80
+ export async function buildOneOGImage(opts) {
81
+ const { projectDir, outDir = 'dist', logo, siteName, slug, context = {} } = opts;
82
+ const routes = loadRoutes(projectDir);
83
+ if (!routes) throw new Error('No routes config found');
84
+
85
+ const tokens = loadTokens(projectDir);
86
+ const site = { tokens, logo, siteName };
87
+ const { chromium } = await import('playwright');
88
+ const browser = await chromium.launch();
89
+ const page = await browser.newPage({ viewport: { width: WIDTH, height: HEIGHT } });
90
+ const out = resolve(projectDir, outDir);
91
+ let result = '';
92
+
93
+ for (const [pattern, config] of Object.entries(routes)) {
94
+ if (!pattern.includes(':') || !config.data) continue;
95
+ const template = resolveTemplate(projectDir, config.ogTemplate);
96
+ const items = resolveDataSource(config.data, projectDir);
97
+ const paramName = pattern.match(/:(\w+)/)?.[1] || 'id';
98
+ const item = items.find((i) => (i.slug || i.id || i.sku) === slug);
99
+ if (!item) continue;
100
+ const path = pattern.replace(`:${paramName}`, String(slug));
101
+ const data = { [paramName]: item, item, app: { name: siteName }, ...context };
102
+ const ctx = buildContext(config, data, site);
103
+ await screenshot(page, renderTemplate(template, ctx, projectDir), out, path);
104
+ result = resolve(out, path.slice(1) + '.png');
105
+ break;
106
+ }
107
+
108
+ await browser.close();
109
+ if (result) console.log(`✅ ${result}`);
110
+ else console.log(`⚠ Slug "${slug}" not found in any route data`);
111
+ return result;
112
+ }
113
+
114
+ /** Render HTML content and save screenshot to disk. */
115
+ async function screenshot(page, html, outDir, routePath) {
116
+ await page.setContent(html, { waitUntil: 'networkidle' });
117
+ const imgPath = routePath === '/'
118
+ ? resolve(outDir, 'og-image.png')
119
+ : resolve(outDir, routePath.slice(1) + '.png');
120
+ mkdirSync(dirname(imgPath), { recursive: true });
121
+ await page.screenshot({ path: imgPath, type: 'png' });
122
+ }
package/lib/build-og.js CHANGED
@@ -10,26 +10,17 @@ import { injectMeta, interpolate } from './og-template.js';
10
10
 
11
11
  /**
12
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"
13
+ * @property {string} title @property {string} description
14
+ * @property {string} [image] @property {string} [data] @property {string} [ogTemplate]
17
15
  */
18
16
 
19
17
  /**
20
18
  * @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
- * @property {Record<string, unknown>} [context] - Extra data for interpolation
19
+ * @property {string} projectDir @property {string} [outDir] @property {string} [baseUrl]
20
+ * @property {string} [htmlPath] @property {Record<string, unknown>} [context]
26
21
  */
27
22
 
28
- /**
29
- * Load route config from clearstack.routes.json or package.json.
30
- * @param {string} projectDir
31
- * @returns {Record<string, RouteConfig> | null}
32
- */
23
+ /** Load route config from clearstack.routes.json or package.json. */
33
24
  export function loadRoutes(projectDir) {
34
25
  const routesFile = resolve(projectDir, 'clearstack.routes.json');
35
26
  if (existsSync(routesFile)) {
@@ -45,10 +36,7 @@ export function loadRoutes(projectDir) {
45
36
 
46
37
  /**
47
38
  * Resolve a data source reference like "data/traits.json:traits".
48
- * Format: "filepath:jsonPath" where jsonPath is dot-notation into the file.
49
- * @param {string} ref
50
- * @param {string} projectDir
51
- * @returns {unknown[]}
39
+ * Supports arrays and objects (objects become arrays with key as slug).
52
40
  */
53
41
  export function resolveDataSource(ref, projectDir) {
54
42
  const [filePart, ...pathParts] = ref.split(':');
@@ -56,16 +44,15 @@ export function resolveDataSource(ref, projectDir) {
56
44
  const filePath = resolve(projectDir, filePart);
57
45
  if (!existsSync(filePath)) return [];
58
46
  const content = JSON.parse(readFileSync(filePath, 'utf-8'));
59
- if (!jsonPath) return Array.isArray(content) ? content : [content];
60
- const data = jsonPath.split('.').reduce((obj, key) => obj?.[key], content);
61
- return Array.isArray(data) ? data : [];
47
+ const data = jsonPath ? jsonPath.split('.').reduce((obj, key) => obj?.[key], content) : content;
48
+ if (Array.isArray(data)) return data;
49
+ if (data && typeof data === 'object') {
50
+ return Object.entries(data).map(([key, val]) => ({ slug: key, ...val }));
51
+ }
52
+ return [data];
62
53
  }
63
54
 
64
- /**
65
- * Generate OG pages for all configured routes.
66
- * @param {BuildOGOptions} opts
67
- * @returns {{ pages: number, routes: number }}
68
- */
55
+ /** Generate OG pages for all configured routes. */
69
56
  export function buildOG(opts) {
70
57
  const { projectDir, outDir = 'dist', baseUrl = '', htmlPath } = opts;
71
58
  const routes = loadRoutes(projectDir);
@@ -112,13 +99,7 @@ export function buildOG(opts) {
112
99
  return { pages, routes: Object.keys(routes).length };
113
100
  }
114
101
 
115
- /**
116
- * Resolve meta from config + data context.
117
- * @param {RouteConfig} config
118
- * @param {Record<string, unknown>} data
119
- * @param {string} baseUrl
120
- * @param {string} path
121
- */
102
+ /** Resolve meta from config + data context. */
122
103
  function resolveMeta(config, data, baseUrl, path) {
123
104
  return {
124
105
  title: interpolate(config.title, data),
@@ -128,17 +109,16 @@ function resolveMeta(config, data, baseUrl, path) {
128
109
  };
129
110
  }
130
111
 
131
- /**
132
- * Write a single HTML page to the output directory.
133
- * @param {string} outDir
134
- * @param {string} routePath
135
- * @param {string} shell
136
- * @param {import('./og-template.js').OGMeta} meta
137
- */
112
+ /** Write a single HTML page to the output directory. */
138
113
  function writePage(outDir, routePath, shell, meta) {
139
- const filePath = routePath.endsWith('/')
140
- ? resolve(outDir, routePath.slice(1), 'index.html')
141
- : resolve(outDir, routePath.slice(1), 'index.html');
114
+ let filePath;
115
+ if (routePath === '/') {
116
+ filePath = resolve(outDir, 'index.html');
117
+ } else {
118
+ // Write as flat file (e.g. /dna-sources → dna-sources.html)
119
+ // Cloudflare Pages serves dna-sources.html for /dna-sources without redirect
120
+ filePath = resolve(outDir, routePath.slice(1) + '.html');
121
+ }
142
122
  mkdirSync(dirname(filePath), { recursive: true });
143
123
  writeFileSync(filePath, injectMeta(shell, meta));
144
124
  }