@techninja/clearstack 0.3.37 → 0.3.39
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 +24 -4
- package/docs/OG_METADATA_SPEC.md +98 -51
- package/docs/SPEC_WORKFLOW.md +116 -0
- package/docs/app-spec/SPEC_WATCH_PROPOSAL.md +80 -0
- package/lib/build-og-images.js +122 -0
- package/lib/build-og.js +17 -41
- package/lib/check.js +10 -10
- package/lib/og-default-template.js +48 -0
- package/lib/og-image-template.js +111 -0
- package/lib/og-template.js +2 -2
- package/lib/report.js +106 -0
- package/lib/spec-config.js +7 -3
- package/lib/spec-utils.js +38 -36
- package/package.json +3 -3
- package/templates/shared/docs/clearstack/OG_METADATA_SPEC.md +98 -51
- package/templates/shared/docs/clearstack/SPEC_WORKFLOW.md +116 -0
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
|
|
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: '
|
|
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
|
-
|
|
66
|
-
|
|
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]');
|
package/docs/OG_METADATA_SPEC.md
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
51
|
+
### Data Sources
|
|
24
52
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
88
|
+
## CLI Commands
|
|
66
89
|
|
|
67
90
|
```bash
|
|
68
|
-
|
|
69
|
-
clearstack build
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
##
|
|
124
|
+
## Dependencies
|
|
77
125
|
|
|
78
|
-
-
|
|
79
|
-
-
|
|
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
|
|
14
|
-
* @property {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
|
|
22
|
-
* @property {string} [
|
|
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
|
-
*
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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,29 +99,18 @@ 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) {
|
|
104
|
+
const imgField = config.ogImage || config.image;
|
|
123
105
|
return {
|
|
124
106
|
title: interpolate(config.title, data),
|
|
125
107
|
description: interpolate(config.description, data),
|
|
126
108
|
url: `${baseUrl}${path}`,
|
|
127
|
-
image:
|
|
109
|
+
image: imgField ? interpolate(imgField, data) : undefined,
|
|
128
110
|
};
|
|
129
111
|
}
|
|
130
112
|
|
|
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
|
-
*/
|
|
113
|
+
/** Write a single HTML page to the output directory. */
|
|
138
114
|
function writePage(outDir, routePath, shell, meta) {
|
|
139
115
|
let filePath;
|
|
140
116
|
if (routePath === '/') {
|