@apollion-dsi/tokens 4.0.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 +46 -0
- package/lib/build.cjs +6 -0
- package/lib/build.esm.js +6 -0
- package/lib/cli.mjs +9 -0
- package/lib/config-loader.cjs +3 -0
- package/lib/config-loader.esm.js +3 -0
- package/lib/config-schema.cjs +1 -0
- package/lib/config-schema.esm.js +1 -0
- package/lib/runtime/v1.cjs +1 -0
- package/lib/runtime/v1.esm.js +1 -0
- package/package.json +84 -0
- package/src/build.ts +136 -0
- package/src/builders/colors.ts +135 -0
- package/src/builders/foundation.ts +19 -0
- package/src/builders/spacing.ts +27 -0
- package/src/builders/surface.ts +16 -0
- package/src/cli.ts +128 -0
- package/src/config-loader.ts +167 -0
- package/src/config-schema-runtime.ts +64 -0
- package/src/config-schema.ts +110 -0
- package/src/manifest.ts +103 -0
- package/src/renderers/css.ts +65 -0
- package/src/renderers/json.ts +81 -0
- package/src/renderers/ts.ts +46 -0
- package/src/runtime/v1.ts +40 -0
- package/src/theme-factory.ts +80 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `apollion-tokens` bin entry — thin wrapper around `build` + `check`.
|
|
3
|
+
*
|
|
4
|
+
* Shebang `#!/usr/bin/env node` is added by esbuild banner config — not
|
|
5
|
+
* present in source to avoid duplication.
|
|
6
|
+
*
|
|
7
|
+
* **S6:** sandboxed `apollion.config.mjs` loader via `node:vm` + zod
|
|
8
|
+
* schema (T2+E1 threat-model mitigation). Falls back to JSON for
|
|
9
|
+
* `.json` files (test fixtures + S5 backward compat).
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* ```sh
|
|
13
|
+
* apollion-tokens build --config apollion.config.json --out dist
|
|
14
|
+
* apollion-tokens build --check --config apollion.config.json --out dist
|
|
15
|
+
* apollion-tokens build --verbose --config apollion.config.json --out dist
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* Exit codes:
|
|
19
|
+
* - 0 — success (build wrote files, or --check found no drift).
|
|
20
|
+
* - 1 — drift detected (--check), or build error.
|
|
21
|
+
* - 2 — invalid arguments.
|
|
22
|
+
*
|
|
23
|
+
* @see ADR-006 §3.5 + §3.8 B3 (loader sandbox in S6)
|
|
24
|
+
* @see PRD-002 §S5
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { readFile } from 'node:fs/promises';
|
|
28
|
+
import { resolve } from 'node:path';
|
|
29
|
+
|
|
30
|
+
import { build, check } from './build';
|
|
31
|
+
import { loadConfig } from './config-loader';
|
|
32
|
+
import type { ApollionConfig } from './config-schema';
|
|
33
|
+
|
|
34
|
+
interface ParsedArgs {
|
|
35
|
+
command: 'build';
|
|
36
|
+
configPath: string;
|
|
37
|
+
outDir: string;
|
|
38
|
+
check: boolean;
|
|
39
|
+
verbose: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseArgs(argv: string[]): ParsedArgs {
|
|
43
|
+
const args = argv.slice(2);
|
|
44
|
+
const command = args[0];
|
|
45
|
+
if (command !== 'build') {
|
|
46
|
+
fail(
|
|
47
|
+
`Unknown command "${command ?? ''}". Usage: apollion-tokens build [--check] [--verbose] --config <path> [--out <dir>]`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let configPath = 'apollion.config.mjs';
|
|
52
|
+
let outDir = 'dist';
|
|
53
|
+
let checkMode = false;
|
|
54
|
+
let verbose = false;
|
|
55
|
+
|
|
56
|
+
for (let i = 1; i < args.length; i++) {
|
|
57
|
+
const a = args[i];
|
|
58
|
+
switch (a) {
|
|
59
|
+
case '--config':
|
|
60
|
+
configPath = args[++i] ?? fail('--config needs a path');
|
|
61
|
+
break;
|
|
62
|
+
case '--out':
|
|
63
|
+
outDir = args[++i] ?? fail('--out needs a path');
|
|
64
|
+
break;
|
|
65
|
+
case '--check':
|
|
66
|
+
checkMode = true;
|
|
67
|
+
break;
|
|
68
|
+
case '--verbose':
|
|
69
|
+
verbose = true;
|
|
70
|
+
break;
|
|
71
|
+
default:
|
|
72
|
+
fail(`Unknown flag "${a}"`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { command, configPath, outDir, check: checkMode, verbose };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function fail(message: string): never {
|
|
80
|
+
console.error(`apollion-tokens: ${message}`);
|
|
81
|
+
process.exit(2);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function loadConfigFromPath(path: string): Promise<ApollionConfig> {
|
|
85
|
+
const fullPath = resolve(path);
|
|
86
|
+
|
|
87
|
+
// .json keeps S5 backward-compat (test fixtures + simple CI scenarios that
|
|
88
|
+
// don't need a JS expression). .mjs/.js go through the sandboxed loader
|
|
89
|
+
// (ADR-006 §3.8 B3 — node:vm + zod schema; threat-model T2+E1 mitigation).
|
|
90
|
+
if (fullPath.endsWith('.json')) {
|
|
91
|
+
const raw = await readFile(fullPath, 'utf8');
|
|
92
|
+
return JSON.parse(raw) as ApollionConfig;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return loadConfig({ configPath: fullPath });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function main(): Promise<void> {
|
|
99
|
+
const args = parseArgs(process.argv);
|
|
100
|
+
const config = await loadConfigFromPath(args.configPath);
|
|
101
|
+
|
|
102
|
+
if (args.check) {
|
|
103
|
+
const ok = await check({ config, outDir: resolve(args.outDir) });
|
|
104
|
+
if (!ok) {
|
|
105
|
+
console.error(
|
|
106
|
+
`apollion-tokens: dist/ is stale or missing (configHash mismatch). Re-run \`apollion-tokens build\` without --check.`,
|
|
107
|
+
);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
console.log(`apollion-tokens: dist/ is up-to-date with config.`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const { manifest } = await build({
|
|
115
|
+
config,
|
|
116
|
+
outDir: resolve(args.outDir),
|
|
117
|
+
verbose: args.verbose,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
console.log(
|
|
121
|
+
`apollion-tokens: wrote ${manifest.files.length} file(s) — configHash=${manifest.configHash.slice(0, 12)}…`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
main().catch((err) => {
|
|
126
|
+
console.error(`apollion-tokens: ${err instanceof Error ? err.message : String(err)}`);
|
|
127
|
+
process.exit(1);
|
|
128
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandboxed `apollion.config.mjs` loader — threat-model T2+E1 mitigation
|
|
3
|
+
* (ADR-006 §3.8 B3, §8.5 risks T2 + E1).
|
|
4
|
+
*
|
|
5
|
+
* **Contract:** load + validate + return `ApollionConfig`. The config source
|
|
6
|
+
* is treated as UNTRUSTED — even when authored by the project owner, the
|
|
7
|
+
* loader assumes the consumer build server is the attack surface (event-
|
|
8
|
+
* stream / ua-parser-js precedent: malicious config compiled via tampered
|
|
9
|
+
* dependency chain).
|
|
10
|
+
*
|
|
11
|
+
* **Sandbox guarantees:**
|
|
12
|
+
*
|
|
13
|
+
* 1. **Pre-eval static scan:** source must not contain `require(...)` or
|
|
14
|
+
* `import(...)` dynamic calls. Static `import` statements are OK only
|
|
15
|
+
* when the runtime exposes `defineConfig` (see consumer pattern below).
|
|
16
|
+
* For S6 the loader rejects ANY dynamic call; static imports become
|
|
17
|
+
* a future enhancement.
|
|
18
|
+
* 2. **`node:vm` Script + Context with allowlist globals.** Only
|
|
19
|
+
* `console`, `Symbol`, `Number`, `String`, `Boolean`, `Array`, `Object`,
|
|
20
|
+
* `JSON`, `Math` are exposed. NO `process`, `Buffer`, `globalThis`,
|
|
21
|
+
* `eval`, `Function`, `require`, `import`.
|
|
22
|
+
* 3. **Timeout 1000ms.** Infinite loops in config fail-fast.
|
|
23
|
+
* 4. **Zod schema validation.** Output structure is enforced post-eval. Fails
|
|
24
|
+
* fast with `path` + `message` per issue.
|
|
25
|
+
*
|
|
26
|
+
* **Consumer pattern (apollion.config.mjs):**
|
|
27
|
+
*
|
|
28
|
+
* ```js
|
|
29
|
+
* export default {
|
|
30
|
+
* brands: { default: { deepDark: '#000', deepLight: '#FFF', main: '#003750' } },
|
|
31
|
+
* modes: ['light', 'dark'],
|
|
32
|
+
* surfaces: ['positive', 'negative'],
|
|
33
|
+
* dimensions: ['compact', 'normal', 'spacious'],
|
|
34
|
+
* output: { runtime: false, css: true, json: true, ts: true },
|
|
35
|
+
* };
|
|
36
|
+
* ```
|
|
37
|
+
*
|
|
38
|
+
* `defineConfig` helper not strictly required — consumer can export the
|
|
39
|
+
* literal — but recommended for autocomplete + literal-type narrowing.
|
|
40
|
+
*
|
|
41
|
+
* @see ADR-006 §3.8 B3 + §8.5 risks T2/E1
|
|
42
|
+
* @see PRD-002 §S6
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { readFile } from 'node:fs/promises';
|
|
46
|
+
import { resolve } from 'node:path';
|
|
47
|
+
import { createContext, Script } from 'node:vm';
|
|
48
|
+
import { z } from 'zod';
|
|
49
|
+
|
|
50
|
+
import type { ApollionConfig } from './config-schema';
|
|
51
|
+
import { ApollionConfigSchema } from './config-schema-runtime';
|
|
52
|
+
|
|
53
|
+
const FORBIDDEN_PATTERNS = [
|
|
54
|
+
/\brequire\s*\(/,
|
|
55
|
+
/\bimport\s*\(/,
|
|
56
|
+
// Static imports also forbidden in S6 (would require deeper resolver).
|
|
57
|
+
/^\s*import\s+/m,
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const SANDBOX_TIMEOUT_MS = 1000;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Helper exported so consumers get TS autocomplete + literal narrowing.
|
|
64
|
+
* Identity function at runtime; types do the work.
|
|
65
|
+
*/
|
|
66
|
+
export function defineConfig<T extends ApollionConfig>(config: T): T {
|
|
67
|
+
return config;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface LoadConfigOptions {
|
|
71
|
+
configPath: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export class ConfigLoadError extends Error {
|
|
75
|
+
constructor(
|
|
76
|
+
message: string,
|
|
77
|
+
public readonly configPath: string,
|
|
78
|
+
) {
|
|
79
|
+
super(message);
|
|
80
|
+
this.name = 'ConfigLoadError';
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Load + validate `apollion.config.mjs` from disk. Never throws raw — all
|
|
86
|
+
* failures wrap `ConfigLoadError` with the offending path attached.
|
|
87
|
+
*/
|
|
88
|
+
export async function loadConfig(opts: LoadConfigOptions): Promise<ApollionConfig> {
|
|
89
|
+
const configPath = resolve(opts.configPath);
|
|
90
|
+
const source = await readFile(configPath, 'utf8');
|
|
91
|
+
|
|
92
|
+
// Static pre-scan — refuse anything that looks like dynamic module load.
|
|
93
|
+
for (const pattern of FORBIDDEN_PATTERNS) {
|
|
94
|
+
if (pattern.test(source)) {
|
|
95
|
+
throw new ConfigLoadError(
|
|
96
|
+
`apollion.config: dynamic require/import not allowed (matched ${pattern}). Declarative config only — see ADR-006 §3.8 B3.`,
|
|
97
|
+
configPath,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Rewrite `export default <expr>;` → assign to sandbox-visible variable.
|
|
103
|
+
// `node:vm` does NOT support ESM evaluation in Script; we transform to plain
|
|
104
|
+
// expression evaluation. This is intentionally narrow — config is data, not
|
|
105
|
+
// a module with imports.
|
|
106
|
+
const transformed = source.replace(
|
|
107
|
+
/(^|\n)\s*export\s+default\s+/,
|
|
108
|
+
(_match, leading) => `${leading}__apollion_config_export = `,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
if (transformed === source) {
|
|
112
|
+
throw new ConfigLoadError(
|
|
113
|
+
`apollion.config: missing \`export default <object>;\` — config must export a default object literal.`,
|
|
114
|
+
configPath,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const sandbox: Record<string, unknown> = {
|
|
119
|
+
__apollion_config_export: undefined,
|
|
120
|
+
console: makeReadOnlyConsole(),
|
|
121
|
+
Symbol,
|
|
122
|
+
Number,
|
|
123
|
+
String,
|
|
124
|
+
Boolean,
|
|
125
|
+
Array,
|
|
126
|
+
Object,
|
|
127
|
+
JSON,
|
|
128
|
+
Math,
|
|
129
|
+
// Provide defineConfig as a sandbox-injected identity for ergonomics.
|
|
130
|
+
defineConfig: (c: unknown) => c,
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const context = createContext(sandbox);
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
const script = new Script(transformed, { filename: configPath });
|
|
137
|
+
script.runInContext(context, { timeout: SANDBOX_TIMEOUT_MS });
|
|
138
|
+
} catch (err) {
|
|
139
|
+
throw new ConfigLoadError(
|
|
140
|
+
`apollion.config: evaluation failed — ${err instanceof Error ? err.message : String(err)}`,
|
|
141
|
+
configPath,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const exported = sandbox.__apollion_config_export;
|
|
146
|
+
if (exported === undefined) {
|
|
147
|
+
throw new ConfigLoadError(`apollion.config: \`export default\` produced undefined.`, configPath);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const parsed = ApollionConfigSchema.safeParse(exported);
|
|
151
|
+
if (!parsed.success) {
|
|
152
|
+
const issues = parsed.error.issues
|
|
153
|
+
.map((i: z.core.$ZodIssue) => ` ${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
154
|
+
.join('\n');
|
|
155
|
+
throw new ConfigLoadError(`apollion.config: schema validation failed —\n${issues}`, configPath);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return parsed.data as ApollionConfig;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function makeReadOnlyConsole(): Pick<Console, 'log' | 'warn' | 'error'> {
|
|
162
|
+
return {
|
|
163
|
+
log: (...args: unknown[]) => console.log('[apollion.config]', ...args),
|
|
164
|
+
warn: (...args: unknown[]) => console.warn('[apollion.config]', ...args),
|
|
165
|
+
error: (...args: unknown[]) => console.error('[apollion.config]', ...args),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime zod schema for `ApollionConfig` (S6 — pairs with config-schema.ts TS types).
|
|
3
|
+
*
|
|
4
|
+
* Used by `config-loader.ts` to validate `apollion.config.mjs` AFTER vm
|
|
5
|
+
* evaluation. Schema failure → loader throws with `path` + `message` per
|
|
6
|
+
* issue (zod's flatten output) BEFORE any build runs.
|
|
7
|
+
*
|
|
8
|
+
* **Validation surface (intentional, opinionated):**
|
|
9
|
+
* - `brands`: Record<string, ColorsInput> — at least 1 entry; each ColorsInput
|
|
10
|
+
* requires `deepDark` + `deepLight` (used by mixOklch).
|
|
11
|
+
* - `modes`, `surfaces`, `dimensions`: optional arrays; if provided, each entry
|
|
12
|
+
* matches one of the literal sets.
|
|
13
|
+
* - `output`: optional flags object; unknown keys allowed (forward-compat).
|
|
14
|
+
*
|
|
15
|
+
* @see ./config-schema.ts (TypeScript types — single SSOT of shape)
|
|
16
|
+
* @see ./config-loader.ts (runtime use)
|
|
17
|
+
* @see ADR-006 §3.8 B3
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
|
|
22
|
+
const HexColor = z.string().regex(/^#([0-9a-fA-F]{3}){1,2}$|^transparent$|^rgb\(.*\)$|^oklch\(.*\)$/, {
|
|
23
|
+
message: 'Must be hex, rgb(), oklch(), or "transparent"',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const ColorsInputSchema = z
|
|
27
|
+
.object({
|
|
28
|
+
baseDark: z.string().optional(),
|
|
29
|
+
baseLight: z.string().optional(),
|
|
30
|
+
deepDark: HexColor,
|
|
31
|
+
deepLight: HexColor,
|
|
32
|
+
transparent: z.string().optional(),
|
|
33
|
+
main: HexColor.optional(),
|
|
34
|
+
opposite: HexColor.optional(),
|
|
35
|
+
complementary: HexColor.optional(),
|
|
36
|
+
information: HexColor.optional(),
|
|
37
|
+
success: HexColor.optional(),
|
|
38
|
+
danger: HexColor.optional(),
|
|
39
|
+
warning: HexColor.optional(),
|
|
40
|
+
primary: HexColor.optional(),
|
|
41
|
+
secondary: HexColor.optional(),
|
|
42
|
+
tertiary: HexColor.optional(),
|
|
43
|
+
})
|
|
44
|
+
.passthrough();
|
|
45
|
+
|
|
46
|
+
export const ApollionConfigSchema = z
|
|
47
|
+
.object({
|
|
48
|
+
brands: z.record(z.string(), ColorsInputSchema).refine((b) => Object.keys(b).length > 0, {
|
|
49
|
+
message: 'apollion.config.mjs: brands must contain at least 1 entry',
|
|
50
|
+
}),
|
|
51
|
+
modes: z.array(z.enum(['light', 'dark'])).optional(),
|
|
52
|
+
surfaces: z.array(z.enum(['positive', 'negative'])).optional(),
|
|
53
|
+
dimensions: z.array(z.enum(['compact', 'normal', 'spacious'])).optional(),
|
|
54
|
+
output: z
|
|
55
|
+
.object({
|
|
56
|
+
runtime: z.boolean().optional(),
|
|
57
|
+
css: z.boolean().optional(),
|
|
58
|
+
json: z.boolean().optional(),
|
|
59
|
+
ts: z.boolean().optional(),
|
|
60
|
+
})
|
|
61
|
+
.passthrough()
|
|
62
|
+
.optional(),
|
|
63
|
+
})
|
|
64
|
+
.passthrough();
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `apollion.config.mjs` shape — public contract for v4 token generation.
|
|
3
|
+
*
|
|
4
|
+
* **S5 (this file):** TypeScript-only schema definitions consumed by the
|
|
5
|
+
* `apollion-tokens build` CLI. Static validation comes from `.d.ts` types
|
|
6
|
+
* at consumer's tsserver/Vitest/etc.
|
|
7
|
+
*
|
|
8
|
+
* **S6 (later):** Runtime validation via zod + sandboxed `node:vm` loader
|
|
9
|
+
* (mitigation threat-model T2+E1). For now the CLI accepts a config object
|
|
10
|
+
* directly (programmatic API) or via `--config <path>` (CommonJS require).
|
|
11
|
+
*
|
|
12
|
+
* @see ADR-006 §3.4 (feature-flagged output) + §3.8 B3 (sandboxed loader, S6)
|
|
13
|
+
* @see PRD-002 §S5 + §S6
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { ColorsInput } from '@apollion-dsi/core/themes/colors';
|
|
17
|
+
import type { Dimension } from '@apollion-dsi/core/themes/dimension';
|
|
18
|
+
|
|
19
|
+
export type Mode = 'light' | 'dark';
|
|
20
|
+
export type Surface = 'positive' | 'negative';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Output toggles — Strangler Fig (ADR-006 §3.1): E3+E4 opt-in via these flags.
|
|
24
|
+
*
|
|
25
|
+
* - `runtime: true` ships `dist/runtime/v1.{esm,cjs,d.ts}` and triggers
|
|
26
|
+
* `core` to dynamic-import tokens at theme load time.
|
|
27
|
+
* - `css|json|ts: true` ships static dist/{css,json,ts}/*.{css,json,d.ts}.
|
|
28
|
+
* These are framework-agnostic outputs consumed by Vue/Flutter/Tailwind/etc.
|
|
29
|
+
*
|
|
30
|
+
* Default all false: consumer that doesn't opt in receives zero dist files
|
|
31
|
+
* (only the `@apollion-dsi/tokens` package itself, used by core's optional
|
|
32
|
+
* dynamic import path).
|
|
33
|
+
*/
|
|
34
|
+
export interface ApollionOutputFlags {
|
|
35
|
+
runtime?: boolean;
|
|
36
|
+
css?: boolean;
|
|
37
|
+
json?: boolean;
|
|
38
|
+
ts?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ApollionConfig {
|
|
42
|
+
/**
|
|
43
|
+
* Brand registry. Each entry derives a full palette via `mountSingleColor`
|
|
44
|
+
* (OKLch lerp, ADR-006 E1).
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* ```ts
|
|
48
|
+
* brands: {
|
|
49
|
+
* default: { main: '#003750', complementary: '#F6BA20', ... },
|
|
50
|
+
* enterprise: { main: '#1A1A1A', complementary: '#FFD700', ... },
|
|
51
|
+
* }
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
brands: Record<string, ColorsInput>;
|
|
55
|
+
|
|
56
|
+
modes?: Mode[];
|
|
57
|
+
surfaces?: Surface[];
|
|
58
|
+
dimensions?: Dimension[];
|
|
59
|
+
output?: ApollionOutputFlags;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Resolved variant = one cartesian cell of (brand × mode × surface × dimension).
|
|
64
|
+
* Each variant produces ≤ 3 output files (css, json, ts) — depending on flags.
|
|
65
|
+
*/
|
|
66
|
+
export interface Variant {
|
|
67
|
+
brand: string;
|
|
68
|
+
colors: ColorsInput;
|
|
69
|
+
mode: Mode;
|
|
70
|
+
surface: Surface;
|
|
71
|
+
dimension: Dimension;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const DEFAULT_MODES: readonly Mode[] = ['light', 'dark'];
|
|
75
|
+
const DEFAULT_SURFACES: readonly Surface[] = ['positive', 'negative'];
|
|
76
|
+
const DEFAULT_DIMENSIONS: readonly Dimension[] = ['compact', 'normal', 'spacious'];
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Expand config to all (brand × mode × surface × dimension) tuples.
|
|
80
|
+
* Deterministic ordering (alphabetical → declaration → declaration → declaration)
|
|
81
|
+
* — required for idempotent build (PRD-002 §S5 byte-identical rerun).
|
|
82
|
+
*/
|
|
83
|
+
export function expandVariants(config: ApollionConfig): Variant[] {
|
|
84
|
+
const modes = config.modes ?? DEFAULT_MODES;
|
|
85
|
+
const surfaces = config.surfaces ?? DEFAULT_SURFACES;
|
|
86
|
+
const dimensions = config.dimensions ?? DEFAULT_DIMENSIONS;
|
|
87
|
+
|
|
88
|
+
// Sort brands alphabetically for deterministic output ordering.
|
|
89
|
+
const brandEntries = Object.entries(config.brands).sort(([a], [b]) => a.localeCompare(b));
|
|
90
|
+
|
|
91
|
+
const out: Variant[] = [];
|
|
92
|
+
for (const [brand, colors] of brandEntries) {
|
|
93
|
+
for (const mode of modes) {
|
|
94
|
+
for (const surface of surfaces) {
|
|
95
|
+
for (const dimension of dimensions) {
|
|
96
|
+
out.push({ brand, colors, mode, surface, dimension });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Stable variant filename (sem extension).
|
|
106
|
+
* Format: `<brand>.<mode>.<surface>.<dimension>` — kebab where needed.
|
|
107
|
+
*/
|
|
108
|
+
export function variantName(v: Variant): string {
|
|
109
|
+
return `${v.brand}.${v.mode}.${v.surface}.${v.dimension}`;
|
|
110
|
+
}
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build manifest — sha256 audit trail for `apollion-tokens build` output.
|
|
3
|
+
*
|
|
4
|
+
* **Determinism contract (PRD-002 §S5 acceptance):** the manifest must be
|
|
5
|
+
* byte-identical across consecutive runs given the same config + git HEAD
|
|
6
|
+
* + node version. No timestamps, no run-id. configHash is the SSOT of
|
|
7
|
+
* "what was built"; gitCommit + buildEnv are audit fingerprints.
|
|
8
|
+
*
|
|
9
|
+
* **Threat-model fields (ADR-006 §3.9):** `gitCommit` and `buildEnv` give
|
|
10
|
+
* provenance without requiring CI/SLSA-3 (decision Q-G9 — single-user org,
|
|
11
|
+
* sem CI). `signedBy` slot reserved for sigstore in future PRD-003.
|
|
12
|
+
*
|
|
13
|
+
* @see ADR-006 §3.9 + §8.5 risk T1/R2
|
|
14
|
+
* @see PRD-002 §S5
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
|
|
19
|
+
import type { ApollionConfig } from './config-schema';
|
|
20
|
+
|
|
21
|
+
export interface ManifestFileEntry {
|
|
22
|
+
path: string;
|
|
23
|
+
sha256: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface BuildManifest {
|
|
27
|
+
/** sha256 of JSON.stringify(config) — proves "what was the input". */
|
|
28
|
+
configHash: string;
|
|
29
|
+
/** Sorted alphabetically for deterministic output. */
|
|
30
|
+
files: ManifestFileEntry[];
|
|
31
|
+
/** git HEAD short SHA at build time (empty if not in a git repo). */
|
|
32
|
+
gitCommit: string;
|
|
33
|
+
buildEnv: {
|
|
34
|
+
node: string;
|
|
35
|
+
platform: string;
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function sha256(content: string | Buffer): string {
|
|
40
|
+
return createHash('sha256').update(content).digest('hex');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function hashConfig(config: ApollionConfig): string {
|
|
44
|
+
// JSON.stringify with sorted keys for determinism.
|
|
45
|
+
return sha256(stableStringify(config));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Deterministic JSON stringify with sorted object keys.
|
|
50
|
+
* `JSON.stringify` preserves insertion order — different consumer code
|
|
51
|
+
* paths could produce same logical config with different key order →
|
|
52
|
+
* different hash. This normalizes.
|
|
53
|
+
*/
|
|
54
|
+
function stableStringify(value: unknown): string {
|
|
55
|
+
if (value === null || typeof value !== 'object') {
|
|
56
|
+
return JSON.stringify(value);
|
|
57
|
+
}
|
|
58
|
+
if (Array.isArray(value)) {
|
|
59
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
60
|
+
}
|
|
61
|
+
const keys = Object.keys(value as Record<string, unknown>).sort();
|
|
62
|
+
const entries = keys.map((k) => `${JSON.stringify(k)}:${stableStringify((value as Record<string, unknown>)[k])}`);
|
|
63
|
+
return `{${entries.join(',')}}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve git HEAD short SHA. Returns empty string if not in a git repo
|
|
68
|
+
* or git unavailable — never throws (build should succeed outside git).
|
|
69
|
+
*/
|
|
70
|
+
export function detectGitCommit(cwd: string): string {
|
|
71
|
+
try {
|
|
72
|
+
// Avoid spawning child_process if .git/HEAD readable directly.
|
|
73
|
+
|
|
74
|
+
const { execSync } = require('node:child_process');
|
|
75
|
+
const out = execSync('git rev-parse --short HEAD', {
|
|
76
|
+
cwd,
|
|
77
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
78
|
+
encoding: 'utf8',
|
|
79
|
+
}) as string;
|
|
80
|
+
return out.trim();
|
|
81
|
+
} catch {
|
|
82
|
+
return '';
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function buildEnv(): BuildManifest['buildEnv'] {
|
|
87
|
+
return {
|
|
88
|
+
node: process.version,
|
|
89
|
+
platform: `${process.platform}-${process.arch}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function serializeManifest(manifest: BuildManifest): string {
|
|
94
|
+
// Files sorted alphabetically; trailing newline omitted for byte stability.
|
|
95
|
+
const sortedFiles = [...manifest.files].sort((a, b) => a.path.localeCompare(b.path));
|
|
96
|
+
const ordered: BuildManifest = {
|
|
97
|
+
configHash: manifest.configHash,
|
|
98
|
+
files: sortedFiles,
|
|
99
|
+
gitCommit: manifest.gitCommit,
|
|
100
|
+
buildEnv: manifest.buildEnv,
|
|
101
|
+
};
|
|
102
|
+
return `${JSON.stringify(ordered, null, 2)}\n`;
|
|
103
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS renderer — emit Foundation tokens as CSS custom properties.
|
|
3
|
+
*
|
|
4
|
+
* Format: `--apollion-{layer}-{role}-{variant}` per ADR-006 §3.3.
|
|
5
|
+
*
|
|
6
|
+
* **tech-radar R3:** also emits `@property` declarations for typed tokens
|
|
7
|
+
* (color/length) — enables native CSS transitions (View Transitions API
|
|
8
|
+
* friendly). `@property` is browser-supported 84%+ mid-2024; wrapped in
|
|
9
|
+
* `@supports` for graceful degradation.
|
|
10
|
+
*
|
|
11
|
+
* Output is deterministic: keys iterated in insertion order from the
|
|
12
|
+
* FoundationLayer object; no timestamps, no run-id.
|
|
13
|
+
*
|
|
14
|
+
* @see ADR-006 §3.3 + §3.10 + tech-radar R3
|
|
15
|
+
* @see PRD-002 §S5
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { FoundationLayer } from '@apollion-dsi/core/themes/foundation';
|
|
19
|
+
|
|
20
|
+
import type { Variant } from '../config-schema';
|
|
21
|
+
|
|
22
|
+
const HEADER_LINE = '/* Generated by apollion-tokens build — DO NOT EDIT. See apollion.config.mjs. */';
|
|
23
|
+
|
|
24
|
+
export function renderCss(foundation: FoundationLayer, variant: Variant): string {
|
|
25
|
+
const lines: string[] = [HEADER_LINE, ''];
|
|
26
|
+
|
|
27
|
+
// Variant marker as a comment (NOT a var — keep cascade clean).
|
|
28
|
+
lines.push(
|
|
29
|
+
`/* Variant: brand=${variant.brand} mode=${variant.mode} surface=${variant.surface} dimension=${variant.dimension} */`,
|
|
30
|
+
'',
|
|
31
|
+
':root {',
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
// bg layer
|
|
35
|
+
for (const [key, value] of Object.entries(foundation.bg)) {
|
|
36
|
+
lines.push(` --apollion-bg-${key}: ${value};`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// text layer
|
|
40
|
+
for (const [key, value] of Object.entries(foundation.text)) {
|
|
41
|
+
const kebab = key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
|
42
|
+
lines.push(` --apollion-text-${kebab}: ${value};`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// spacing layer
|
|
46
|
+
for (const [key, value] of Object.entries(foundation.spacing)) {
|
|
47
|
+
lines.push(` --apollion-spacing-${key}: ${value};`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
lines.push('}', '');
|
|
51
|
+
|
|
52
|
+
// @property declarations for color tokens — opt-in via @supports
|
|
53
|
+
lines.push('@supports (background: paint(squircle)) or (color: oklch(0% 0 0)) {');
|
|
54
|
+
for (const [key, value] of Object.entries(foundation.bg)) {
|
|
55
|
+
lines.push(` @property --apollion-bg-${key} { syntax: '<color>'; inherits: true; initial-value: ${value}; }`);
|
|
56
|
+
}
|
|
57
|
+
for (const key of Object.keys(foundation.text)) {
|
|
58
|
+
const kebab = key.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
|
59
|
+
const value = (foundation.text as unknown as Record<string, string>)[key];
|
|
60
|
+
lines.push(` @property --apollion-text-${kebab} { syntax: '<color>'; inherits: true; initial-value: ${value}; }`);
|
|
61
|
+
}
|
|
62
|
+
lines.push('}', '');
|
|
63
|
+
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
}
|