@helical-design/substrate 0.1.0
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/README.md +23 -0
- package/bin/create-helical.mjs +59 -0
- package/package.json +40 -0
- package/src/build-all.mjs +59 -0
- package/src/build.mjs +31 -0
- package/src/content.mjs +6 -0
- package/src/db/client.mjs +45 -0
- package/src/db/schema.mjs +15 -0
- package/src/db/vector.mjs +10 -0
- package/src/harness.mjs +449 -0
- package/src/index.mjs +19 -0
- package/src/pages.mjs +14 -0
- package/src/palette.mjs +39 -0
- package/src/registry.mjs +29 -0
- package/src/render.mjs +43 -0
- package/src/scaffold.mjs +200 -0
- package/src/scale.mjs +268 -0
- package/src/store.mjs +38 -0
- package/src/tokens.mjs +79 -0
- package/templates/db/_shared/drizzle.config.mjs +9 -0
- package/templates/db/neon/client.mjs +7 -0
- package/templates/db/neon/env.example +6 -0
- package/templates/db/supabase/client.mjs +7 -0
- package/templates/db/supabase/config.toml +11 -0
- package/templates/db/supabase/env.example +8 -0
- package/templates/db/supabase/migrations/0001_init.sql +12 -0
- package/templates/profiles/_base/README.md +22 -0
- package/templates/profiles/_base/assets/base.css +50 -0
- package/templates/profiles/_base/content/about.md +4 -0
- package/templates/profiles/_base/content/site.json +1 -0
- package/templates/profiles/_base/pages.json +4 -0
- package/templates/profiles/_base/templates/layout.html +1 -0
- package/templates/profiles/_base/templates/pages/about.html +2 -0
- package/templates/profiles/_base/templates/pages/home.html +3 -0
- package/templates/profiles/_base/tokens/accent.dark.json +7 -0
- package/templates/profiles/_base/tokens/accent.light.json +7 -0
- package/templates/profiles/_base/tokens/base.json +18 -0
- package/templates/profiles/_base/tokens/breakpoints.json +38 -0
- package/templates/profiles/_base/tokens/palette.dark.json +96 -0
- package/templates/profiles/_base/tokens/palette.light.json +96 -0
- package/templates/profiles/_base/tokens/scale.compact.json +68 -0
- package/templates/profiles/_base/tokens/scale.json +114 -0
- package/templates/profiles/_base/tokens/scale.spacious.json +68 -0
- package/templates/profiles/_base/tokens/semantic.json +14 -0
- package/templates/profiles/app/.keep +0 -0
- package/templates/profiles/marketing/.keep +0 -0
- package/tokens/accent.dark.json +7 -0
- package/tokens/accent.light.json +7 -0
- package/tokens/base.json +18 -0
- package/tokens/breakpoints.json +38 -0
- package/tokens/palette.dark.json +96 -0
- package/tokens/palette.light.json +96 -0
- package/tokens/scale.compact.json +68 -0
- package/tokens/scale.json +114 -0
- package/tokens/scale.spacious.json +68 -0
- package/tokens/semantic.json +14 -0
package/README.md
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# @helical-design/substrate
|
|
2
|
+
|
|
3
|
+
The Helical build substrate: unified, fail-loud, deterministic build for **design tokens** and **content**, both authored as flat-files-by-key.
|
|
4
|
+
|
|
5
|
+
## API
|
|
6
|
+
```js
|
|
7
|
+
import { buildTokens, buildContent, generatePalette } from '@helical-design/substrate';
|
|
8
|
+
|
|
9
|
+
generatePalette('.'); // Leonardo → tokens/palette.{dark,light}.json
|
|
10
|
+
await buildTokens('.'); // DTCG tokens → dist/tokens.css (dark + light)
|
|
11
|
+
buildContent({ // content × templates → dist/*.html (throws on missing key)
|
|
12
|
+
contentDir: 'content', templatesDir: 'templates', outDir: 'dist',
|
|
13
|
+
pages: [
|
|
14
|
+
{ out: 'index.html', template: 'home' },
|
|
15
|
+
{ out: 'journal/:slug.html', template: 'entry', forEach: 'journal', as: 'entry' },
|
|
16
|
+
],
|
|
17
|
+
});
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Guarantees
|
|
21
|
+
- **Flat-files-by-key** - one fact in one place (`tokens/*.json`, `content/*.json|md`).
|
|
22
|
+
- **Fail-loud** - a missing/renamed key breaks the build; never ships a blank or hallucination.
|
|
23
|
+
- **Deterministic** - same inputs -> byte-identical output (golden-snapshot friendly).
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// bin/create-helical.mjs — a thin, non-interactive-capable CLI over scaffold().
|
|
3
|
+
// All real logic (validation, stamping) lives in src/scaffold.mjs; this file
|
|
4
|
+
// only parses args, surfaces errors as plain messages, and prints a success line.
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { scaffold } from '../src/scaffold.mjs';
|
|
7
|
+
|
|
8
|
+
const USAGE = `create-helical — stamp a new Helical project.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
create-helical <name> [options]
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--dir <path> target directory (default ./<name>)
|
|
15
|
+
--profile marketing|app project profile (default marketing)
|
|
16
|
+
--db neon|supabase database for the app profile (default neon)
|
|
17
|
+
--force overwrite a non-empty target dir
|
|
18
|
+
--yes run non-interactively straight from flags
|
|
19
|
+
--help show this help and exit
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
function main() {
|
|
23
|
+
const { values, positionals } = parseArgs({
|
|
24
|
+
allowPositionals: true,
|
|
25
|
+
options: {
|
|
26
|
+
dir: { type: 'string' },
|
|
27
|
+
profile: { type: 'string' },
|
|
28
|
+
db: { type: 'string' },
|
|
29
|
+
force: { type: 'boolean' },
|
|
30
|
+
yes: { type: 'boolean' },
|
|
31
|
+
help: { type: 'boolean' },
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
if (values.help) {
|
|
36
|
+
process.stdout.write(USAGE);
|
|
37
|
+
process.exit(0);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const name = positionals[0];
|
|
41
|
+
const dir = values.dir ?? (name ? `./${name}` : undefined);
|
|
42
|
+
|
|
43
|
+
const out = scaffold({
|
|
44
|
+
name,
|
|
45
|
+
dir,
|
|
46
|
+
profile: values.profile,
|
|
47
|
+
db: values.db,
|
|
48
|
+
force: values.force,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
process.stdout.write(`Stamped ${name} into ${out}\n`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
main();
|
|
56
|
+
} catch (e) {
|
|
57
|
+
console.error(e.message);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@helical-design/substrate",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"create-helical": "bin/create-helical.mjs"
|
|
7
|
+
},
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.mjs",
|
|
10
|
+
"./content": "./src/content.mjs",
|
|
11
|
+
"./harness": "./src/harness.mjs",
|
|
12
|
+
"./db": "./src/db/client.mjs",
|
|
13
|
+
"./db/schema": "./src/db/schema.mjs",
|
|
14
|
+
"./db/vector": "./src/db/vector.mjs"
|
|
15
|
+
},
|
|
16
|
+
"files": ["src", "bin", "templates", "tokens", "README.md"],
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"gen:palette": "node scripts/gen-palette.mjs",
|
|
22
|
+
"gen:scale": "node scripts/gen-scale.mjs",
|
|
23
|
+
"build": "node scripts/build-tokens.mjs && node scripts/build-registry.mjs",
|
|
24
|
+
"test": "node --test"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@adobe/leonardo-contrast-colors": "^1.1.0",
|
|
28
|
+
"@neondatabase/serverless": "^1.1.0",
|
|
29
|
+
"culori": "^4.0.2",
|
|
30
|
+
"drizzle-orm": "^0.45.2",
|
|
31
|
+
"gray-matter": "^4.0.3",
|
|
32
|
+
"marked": "^12.0.2",
|
|
33
|
+
"postgres": "^3.4.9",
|
|
34
|
+
"style-dictionary": "^4.4.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@electric-sql/pglite": "~0.4.6",
|
|
38
|
+
"drizzle-kit": "^0.31.10"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// src/build-all.mjs — the composed builder. buildTokens and buildContent each
|
|
2
|
+
// rmSync their own outDir before writing, so pointing both at one dist/ makes the
|
|
3
|
+
// second clobber the first. This orchestrator builds each into its OWN temp
|
|
4
|
+
// staging dir, then assembles a single coherent outDir from both — HTML + assets
|
|
5
|
+
// from the content stage, plus the combined token CSS dropped at the dist root
|
|
6
|
+
// (so the layout's absolute `/tokens.css` link resolves on a deployed site).
|
|
7
|
+
import {
|
|
8
|
+
readFileSync, existsSync, mkdtempSync, mkdirSync, rmSync, cpSync,
|
|
9
|
+
} from 'node:fs';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { tmpdir } from 'node:os';
|
|
12
|
+
import { buildTokens } from './tokens.mjs';
|
|
13
|
+
import { buildContent } from './build.mjs';
|
|
14
|
+
|
|
15
|
+
export async function build({
|
|
16
|
+
root = '.', outDir = join(root, 'dist'), pages, assetsDir, stylesheet = 'tokens.css',
|
|
17
|
+
} = {}) {
|
|
18
|
+
// Resolve the page manifest: explicit arg wins, else read root/pages.json.
|
|
19
|
+
if (pages === undefined) {
|
|
20
|
+
const manifest = join(root, 'pages.json');
|
|
21
|
+
if (!existsSync(manifest)) {
|
|
22
|
+
throw new Error(`build: no pages provided and no manifest found at ${manifest}`);
|
|
23
|
+
}
|
|
24
|
+
pages = JSON.parse(readFileSync(manifest, 'utf8'));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Default assetsDir to root/assets only when it exists; otherwise omit.
|
|
28
|
+
if (assetsDir === undefined) {
|
|
29
|
+
const candidate = join(root, 'assets');
|
|
30
|
+
if (existsSync(candidate)) assetsDir = candidate;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Two separate staging dirs so neither builder's rmSync wipes the other.
|
|
34
|
+
const tokensStage = mkdtempSync(join(tmpdir(), 'helical-build-tokens-'));
|
|
35
|
+
const contentStage = mkdtempSync(join(tmpdir(), 'helical-build-content-'));
|
|
36
|
+
try {
|
|
37
|
+
await buildTokens(root, tokensStage);
|
|
38
|
+
buildContent({
|
|
39
|
+
contentDir: join(root, 'content'),
|
|
40
|
+
templatesDir: join(root, 'templates'),
|
|
41
|
+
outDir: contentStage,
|
|
42
|
+
pages,
|
|
43
|
+
assetsDir,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Assemble the final dist ONCE from the two stages.
|
|
47
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
48
|
+
mkdirSync(outDir, { recursive: true });
|
|
49
|
+
// Content stage carries the HTML (and its assets/ subdir) — copy it wholesale.
|
|
50
|
+
cpSync(contentStage, outDir, { recursive: true });
|
|
51
|
+
// Combined token CSS lands at the dist root, matching the /tokens.css link.
|
|
52
|
+
cpSync(join(tokensStage, 'tokens.css'), join(outDir, stylesheet));
|
|
53
|
+
|
|
54
|
+
return outDir;
|
|
55
|
+
} finally {
|
|
56
|
+
rmSync(tokensStage, { recursive: true, force: true });
|
|
57
|
+
rmSync(contentStage, { recursive: true, force: true });
|
|
58
|
+
}
|
|
59
|
+
}
|
package/src/build.mjs
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, writeFileSync, mkdirSync, rmSync, cpSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, dirname, basename, extname, resolve, sep } from 'node:path';
|
|
3
|
+
import { loadStore } from './store.mjs';
|
|
4
|
+
import { render } from './render.mjs';
|
|
5
|
+
import { resolvePages } from './pages.mjs';
|
|
6
|
+
|
|
7
|
+
function loadPartials(templatesDir) {
|
|
8
|
+
const dir = join(templatesDir, 'partials');
|
|
9
|
+
const partials = {};
|
|
10
|
+
if (existsSync(dir)) for (const f of readdirSync(dir))
|
|
11
|
+
if (extname(f) === '.html') partials[basename(f, '.html')] = readFileSync(join(dir, f), 'utf8');
|
|
12
|
+
return partials;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function buildContent({ contentDir, templatesDir, outDir, pages, assetsDir }) {
|
|
16
|
+
const store = loadStore(contentDir);
|
|
17
|
+
const partials = loadPartials(templatesDir);
|
|
18
|
+
const layout = readFileSync(join(templatesDir, 'layout.html'), 'utf8');
|
|
19
|
+
rmSync(outDir, { recursive: true, force: true });
|
|
20
|
+
for (const page of resolvePages(pages, store)) {
|
|
21
|
+
const tpl = readFileSync(join(templatesDir, `pages/${page.template}.html`), 'utf8');
|
|
22
|
+
const body = render(tpl, page.context, partials); // throws on missing key
|
|
23
|
+
const html = render(layout, { ...page.context, body: '%%BODY%%' }, partials).replace('%%BODY%%', body);
|
|
24
|
+
const outPath = join(outDir, page.out);
|
|
25
|
+
if (!resolve(outPath).startsWith(resolve(outDir) + sep)) throw new Error(`build: page out escapes outDir: ${page.out}`);
|
|
26
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
27
|
+
writeFileSync(outPath, html);
|
|
28
|
+
}
|
|
29
|
+
if (assetsDir && existsSync(assetsDir)) cpSync(assetsDir, join(outDir, 'assets'), { recursive: true });
|
|
30
|
+
return outDir;
|
|
31
|
+
}
|
package/src/content.mjs
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Content-only entrypoint — the renderer/store/pages primitives a static-site
|
|
2
|
+
// consumer needs, WITHOUT the db/token graph the '.' entrypoint re-exports.
|
|
3
|
+
// Import via '@helical/substrate/content'.
|
|
4
|
+
export { render } from './render.mjs';
|
|
5
|
+
export { loadStore } from './store.mjs';
|
|
6
|
+
export { resolvePages } from './pages.mjs';
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// src/db/client.mjs — provider-swappable Drizzle factory.
|
|
2
|
+
// Neon (neon-http) is the default; Supabase (postgres-js) is opt-in. Both bind
|
|
3
|
+
// the SAME schema — only the connection driver changes. Client construction is
|
|
4
|
+
// lazy: neon()/postgres() do not open a socket here, so createDb() is safe to
|
|
5
|
+
// call without a live database (the wiring is what callers assert).
|
|
6
|
+
import { drizzle as drizzleNeon } from 'drizzle-orm/neon-http';
|
|
7
|
+
import { neon } from '@neondatabase/serverless';
|
|
8
|
+
import { drizzle as drizzlePostgres } from 'drizzle-orm/postgres-js';
|
|
9
|
+
import postgres from 'postgres';
|
|
10
|
+
import * as schema from './schema.mjs';
|
|
11
|
+
|
|
12
|
+
export function createDb({
|
|
13
|
+
provider = process.env.DB_PROVIDER ?? 'neon',
|
|
14
|
+
url = process.env.DATABASE_URL,
|
|
15
|
+
} = {}) {
|
|
16
|
+
switch (provider) {
|
|
17
|
+
case 'neon':
|
|
18
|
+
return drizzleNeon(neon(url), { schema });
|
|
19
|
+
case 'supabase':
|
|
20
|
+
// prepare:false is required for Supabase's transaction-mode pooler (:6543).
|
|
21
|
+
return drizzlePostgres(postgres(url, { prepare: false }), { schema });
|
|
22
|
+
default:
|
|
23
|
+
throw new Error(`unknown provider: ${provider} — expected 'neon' or 'supabase'`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Serverless drivers need a pooled connection string. Flag direct hosts so a
|
|
28
|
+
// misconfigured DATABASE_URL fails loudly at setup rather than under load.
|
|
29
|
+
// A host is "pooled" if its hostname carries a `-pooler` segment (Neon),
|
|
30
|
+
// resolves to the Supabase pooler domain, or uses the :6543 transaction port.
|
|
31
|
+
export function assertPooled(url) {
|
|
32
|
+
let u;
|
|
33
|
+
try {
|
|
34
|
+
u = new URL(url);
|
|
35
|
+
} catch {
|
|
36
|
+
return [`connection string is not a valid URL: ${url} — use the pooled connection string for serverless`];
|
|
37
|
+
}
|
|
38
|
+
const pooled =
|
|
39
|
+
u.hostname.includes('-pooler') ||
|
|
40
|
+
u.hostname.includes('pooler.supabase') ||
|
|
41
|
+
u.port === '6543';
|
|
42
|
+
return pooled
|
|
43
|
+
? []
|
|
44
|
+
: [`connection is not pooled: ${u.host} — use the pooled connection string for serverless`];
|
|
45
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// src/db/schema.mjs — ONE relational+vector schema, shared by every provider.
|
|
2
|
+
// Embedding width is config-driven (EMBEDDING_DIM) so re-skinning the model
|
|
3
|
+
// never means editing the schema. Default 1536 = OpenAI text-embedding-3-small.
|
|
4
|
+
import { pgTable, serial, text, vector, index } from 'drizzle-orm/pg-core';
|
|
5
|
+
|
|
6
|
+
const DIM = Number(process.env.EMBEDDING_DIM ?? 1536);
|
|
7
|
+
|
|
8
|
+
export const documents = pgTable('documents', {
|
|
9
|
+
id: serial('id').primaryKey(),
|
|
10
|
+
body: text('body').notNull(),
|
|
11
|
+
embedding: vector('embedding', { dimensions: DIM }).notNull(),
|
|
12
|
+
}, (t) => ({
|
|
13
|
+
// HNSW + cosine: the index pgvector uses for approximate nearest-neighbour.
|
|
14
|
+
embIdx: index('documents_embedding_idx').using('hnsw', t.embedding.op('vector_cosine_ops')),
|
|
15
|
+
}));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// src/db/vector.mjs — cosine nearest-neighbour query, provider-agnostic.
|
|
2
|
+
// Returns the k rows closest to queryEmbedding, nearest first. Drizzle emits the
|
|
3
|
+
// `<=>` cosine-distance operator, which the HNSW index in schema.mjs accelerates.
|
|
4
|
+
import { cosineDistance, asc } from 'drizzle-orm';
|
|
5
|
+
|
|
6
|
+
export async function nearest(db, table, queryEmbedding, k = 5) {
|
|
7
|
+
const distance = cosineDistance(table.embedding, queryEmbedding);
|
|
8
|
+
return db.select({ id: table.id, body: table.body, distance })
|
|
9
|
+
.from(table).orderBy(asc(distance)).limit(k);
|
|
10
|
+
}
|