@techninja/clearstack 0.3.30 → 0.3.33
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 +12 -1
- package/docs/OG_METADATA_SPEC.md +80 -0
- package/lib/build-og.js +141 -0
- package/lib/og-template.js +77 -0
- package/lib/update.js +5 -3
- package/package.json +1 -1
- package/templates/shared/docs/clearstack/OG_METADATA_SPEC.md +80 -0
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?
|
package/lib/build-og.js
ADDED
|
@@ -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, '&')
|
|
74
|
+
.replace(/"/g, '"')
|
|
75
|
+
.replace(/</g, '<')
|
|
76
|
+
.replace(/>/g, '>');
|
|
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
|
|
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
|
@@ -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?
|