@nebutra/design-sync 0.1.0 → 0.1.2
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/LICENSE +21 -676
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +189 -0
- package/dist/detect.d.ts +21 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +81 -0
- package/dist/factory.d.ts +42 -0
- package/dist/factory.d.ts.map +1 -0
- package/dist/factory.js +94 -0
- package/dist/figma-config/index.d.ts +37 -0
- package/dist/figma-config/index.d.ts.map +1 -0
- package/dist/figma-config/index.js +12 -0
- package/dist/figma-config/tokens-studio.config.json +34 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +38 -0
- package/dist/io.d.ts +26 -0
- package/dist/io.d.ts.map +1 -0
- package/dist/io.js +119 -0
- package/dist/providers/design-md.d.ts +49 -0
- package/dist/providers/design-md.d.ts.map +1 -0
- package/dist/providers/design-md.js +264 -0
- package/dist/providers/figma.d.ts +21 -0
- package/dist/providers/figma.d.ts.map +1 -0
- package/dist/providers/figma.js +179 -0
- package/dist/providers/git-only.d.ts +11 -0
- package/dist/providers/git-only.d.ts.map +1 -0
- package/dist/providers/git-only.js +104 -0
- package/dist/providers/memory.d.ts +17 -0
- package/dist/providers/memory.d.ts.map +1 -0
- package/dist/providers/memory.js +65 -0
- package/dist/providers/penpot.d.ts +21 -0
- package/dist/providers/penpot.d.ts.map +1 -0
- package/dist/providers/penpot.js +137 -0
- package/dist/serialize/from-design-md.d.ts +80 -0
- package/dist/serialize/from-design-md.d.ts.map +1 -0
- package/dist/serialize/from-design-md.js +1329 -0
- package/dist/serialize/to-brand-package.d.ts +37 -0
- package/dist/serialize/to-brand-package.d.ts.map +1 -0
- package/dist/serialize/to-brand-package.js +87 -0
- package/dist/serialize/to-design-md.d.ts +42 -0
- package/dist/serialize/to-design-md.d.ts.map +1 -0
- package/dist/serialize/to-design-md.js +114 -0
- package/dist/serialize/to-design-md.prose.d.ts +55 -0
- package/dist/serialize/to-design-md.prose.d.ts.map +1 -0
- package/dist/serialize/to-design-md.prose.js +127 -0
- package/dist/serialize/to-design-md.resolve.d.ts +36 -0
- package/dist/serialize/to-design-md.resolve.d.ts.map +1 -0
- package/dist/serialize/to-design-md.resolve.js +248 -0
- package/dist/serialize/to-preview-html.d.ts +42 -0
- package/dist/serialize/to-preview-html.d.ts.map +1 -0
- package/dist/serialize/to-preview-html.js +250 -0
- package/dist/serialize/to-preview-html.template.d.ts +75 -0
- package/dist/serialize/to-preview-html.template.d.ts.map +1 -0
- package/dist/serialize/to-preview-html.template.js +267 -0
- package/dist/types.d.ts +191 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +16 -0
- package/package.json +22 -7
- package/src/cli/index.ts +36 -3
package/dist/io.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative, sep } from "node:path";
|
|
3
|
+
import { DesignTokenLeafSchema } from "./types";
|
|
4
|
+
// =============================================================================
|
|
5
|
+
// DTCG Filesystem I/O
|
|
6
|
+
// =============================================================================
|
|
7
|
+
// Shared helpers used by every provider. Reading and writing the canonical
|
|
8
|
+
// W3C DTCG JSON tree on disk is identical across Figma / Penpot / git-only —
|
|
9
|
+
// the only thing that differs is what the provider does AFTER it has the data.
|
|
10
|
+
// =============================================================================
|
|
11
|
+
const TOKEN_FILE_EXT = ".json";
|
|
12
|
+
/**
|
|
13
|
+
* Recursively list every `.json` file beneath `root`. Skips dotfiles.
|
|
14
|
+
*/
|
|
15
|
+
async function listJsonFiles(root) {
|
|
16
|
+
const out = [];
|
|
17
|
+
async function walk(dir) {
|
|
18
|
+
let entries;
|
|
19
|
+
try {
|
|
20
|
+
entries = await readdir(dir);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
if (entry.startsWith("."))
|
|
27
|
+
continue;
|
|
28
|
+
const full = join(dir, entry);
|
|
29
|
+
const info = await stat(full);
|
|
30
|
+
if (info.isDirectory()) {
|
|
31
|
+
await walk(full);
|
|
32
|
+
}
|
|
33
|
+
else if (info.isFile() && entry.endsWith(TOKEN_FILE_EXT)) {
|
|
34
|
+
out.push(full);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
await walk(root);
|
|
39
|
+
return out.sort();
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Validate that every leaf in a DTCG tree carries `$value` and `$type`.
|
|
43
|
+
* Returns a list of dot-paths that violate the contract.
|
|
44
|
+
*/
|
|
45
|
+
export function validateDtcgTree(tree, pathPrefix = "") {
|
|
46
|
+
const errors = [];
|
|
47
|
+
if (tree === null || typeof tree !== "object" || Array.isArray(tree)) {
|
|
48
|
+
return errors;
|
|
49
|
+
}
|
|
50
|
+
const node = tree;
|
|
51
|
+
// A leaf has `$value` (or `value` for legacy Tokens Studio v1).
|
|
52
|
+
const hasValue = "$value" in node || "value" in node;
|
|
53
|
+
if (hasValue) {
|
|
54
|
+
const parsed = DesignTokenLeafSchema.safeParse(node);
|
|
55
|
+
if (!parsed.success) {
|
|
56
|
+
errors.push(`${pathPrefix || "<root>"}: ${parsed.error.message}`);
|
|
57
|
+
}
|
|
58
|
+
return errors;
|
|
59
|
+
}
|
|
60
|
+
for (const [key, value] of Object.entries(node)) {
|
|
61
|
+
if (key.startsWith("$"))
|
|
62
|
+
continue;
|
|
63
|
+
const childPath = pathPrefix ? `${pathPrefix}.${key}` : key;
|
|
64
|
+
errors.push(...validateDtcgTree(value, childPath));
|
|
65
|
+
}
|
|
66
|
+
return errors;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Read all DTCG token files under `tokensDir` into memory.
|
|
70
|
+
* Each file becomes one `DesignTokenSet`.
|
|
71
|
+
*/
|
|
72
|
+
export async function readTokenSets(tokensDir) {
|
|
73
|
+
const files = await listJsonFiles(tokensDir);
|
|
74
|
+
const sets = [];
|
|
75
|
+
for (const file of files) {
|
|
76
|
+
const raw = await readFile(file, "utf8");
|
|
77
|
+
let parsed;
|
|
78
|
+
try {
|
|
79
|
+
parsed = JSON.parse(raw);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
throw new Error(`[design-sync] Failed to parse DTCG file ${file}: ${error.message}`);
|
|
83
|
+
}
|
|
84
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
85
|
+
throw new Error(`[design-sync] DTCG file ${file} must contain an object at the root.`);
|
|
86
|
+
}
|
|
87
|
+
const relativePath = relative(tokensDir, file).split(sep).join("/");
|
|
88
|
+
const name = relativePath.replace(/\.json$/u, "");
|
|
89
|
+
sets.push({
|
|
90
|
+
name,
|
|
91
|
+
relativePath,
|
|
92
|
+
tokens: parsed,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return sets;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Write a `DesignTokenSet` back to disk under `tokensDir`. Creates
|
|
99
|
+
* parent directories as needed and uses 2-space indentation with a
|
|
100
|
+
* trailing newline (matches `.tokens-studio/config.json#format`).
|
|
101
|
+
*/
|
|
102
|
+
export async function writeTokenSet(tokensDir, set) {
|
|
103
|
+
const target = join(tokensDir, set.relativePath);
|
|
104
|
+
await mkdir(dirname(target), { recursive: true });
|
|
105
|
+
const serialized = `${JSON.stringify(set.tokens, null, 2)}\n`;
|
|
106
|
+
await writeFile(target, serialized, "utf8");
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Default tokens directory for the Nebutra-Sailor monorepo.
|
|
110
|
+
*/
|
|
111
|
+
export function defaultTokensDir(cwd = process.cwd()) {
|
|
112
|
+
return join(cwd, "packages", "design", "design-tokens", "tokens");
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Default Tokens Studio metadata directory for the Nebutra-Sailor monorepo.
|
|
116
|
+
*/
|
|
117
|
+
export function defaultTokensStudioDir(cwd = process.cwd()) {
|
|
118
|
+
return join(cwd, ".tokens-studio");
|
|
119
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { LintReport } from "@google/design.md/linter";
|
|
2
|
+
import type { DesignMdProviderConfig, DesignSyncProvider, HealthStatus, PullOptions, PullResult, PushOptions, PushResult } from "../types";
|
|
3
|
+
export { type ImportReport, importFromDesignMd } from "../serialize/from-design-md";
|
|
4
|
+
/**
|
|
5
|
+
* Derive the preview file path from the DESIGN.md path.
|
|
6
|
+
*
|
|
7
|
+
* Replaces a trailing `.md` extension with `.preview.html`;
|
|
8
|
+
* if the path does not end in `.md`, appends `.preview.html`.
|
|
9
|
+
*
|
|
10
|
+
* Examples:
|
|
11
|
+
* "/repo/DESIGN.md" → "/repo/DESIGN.preview.html"
|
|
12
|
+
* "/repo/design/tokens.md" → "/repo/design/tokens.preview.html"
|
|
13
|
+
* "/repo/DESIGN" → "/repo/DESIGN.preview.html"
|
|
14
|
+
*/
|
|
15
|
+
export declare function previewHtmlPathFor(mdPath: string): string;
|
|
16
|
+
/**
|
|
17
|
+
* Throw if the LintReport contains any error-severity findings.
|
|
18
|
+
* Warnings do not throw — they are surfaced in the push summary.
|
|
19
|
+
* Used as the fail-closed gate before writing a DESIGN.md.
|
|
20
|
+
*
|
|
21
|
+
* Fail-closed on either signal: error-severity findings in `findings[]`
|
|
22
|
+
* OR a non-zero `summary.errors` count.
|
|
23
|
+
*
|
|
24
|
+
* @param report - The LintReport returned by `lint(content)`.
|
|
25
|
+
* @param source - A human-readable label for the file (used in the error message).
|
|
26
|
+
* @throws When error-severity findings are present or `summary.errors > 0`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function assertLintClean(report: LintReport, source: string): void;
|
|
29
|
+
/**
|
|
30
|
+
* Dynamically imports the @google/design.md linter and runs it on `content`.
|
|
31
|
+
* Extracted as a named export so tests can verify the `[design-md]`-prefixed
|
|
32
|
+
* error thrown when the dynamic import or lint call itself fails.
|
|
33
|
+
*
|
|
34
|
+
* @throws When the linter import or invocation throws — error is wrapped with a
|
|
35
|
+
* `[design-md] Lint gate failed to run on <label>:` prefix.
|
|
36
|
+
*/
|
|
37
|
+
export declare function runLintGate(content: string, label: string): Promise<LintReport>;
|
|
38
|
+
export declare class DesignMdProvider implements DesignSyncProvider {
|
|
39
|
+
readonly name: "design-md";
|
|
40
|
+
private readonly tokensDir;
|
|
41
|
+
private readonly designMdPath;
|
|
42
|
+
private readonly designSystemName;
|
|
43
|
+
private readonly designSystemDescription;
|
|
44
|
+
constructor(config: DesignMdProviderConfig);
|
|
45
|
+
pull(options?: PullOptions): Promise<PullResult>;
|
|
46
|
+
push(options?: PushOptions): Promise<PushResult>;
|
|
47
|
+
healthcheck(): Promise<HealthStatus>;
|
|
48
|
+
}
|
|
49
|
+
//# sourceMappingURL=design-md.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"design-md.d.ts","sourceRoot":"","sources":["../../src/providers/design-md.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAM3D,OAAO,KAAK,EACV,sBAAsB,EACtB,kBAAkB,EAClB,YAAY,EACZ,WAAW,EACX,UAAU,EACV,WAAW,EACX,UAAU,EACX,MAAM,UAAU,CAAC;AAElB,OAAO,EAAE,KAAK,YAAY,EAAE,kBAAkB,EAAE,MAAM,6BAA6B,CAAC;AAuBpF;;;;;;;;;;GAUG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAKzD;AAID;;;;;;;;;;;GAWG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,GAAG,IAAI,CAUxE;AAID;;;;;;;GAOG;AACH,wBAAsB,WAAW,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CASrF;AAID,qBAAa,gBAAiB,YAAW,kBAAkB;IACzD,QAAQ,CAAC,IAAI,EAAG,WAAW,CAAU;IAErC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,gBAAgB,CAAqB;IACtD,OAAO,CAAC,QAAQ,CAAC,uBAAuB,CAAqB;gBAEjD,MAAM,EAAE,sBAAsB;IAcpC,IAAI,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,UAAU,CAAC;IAuDpD,IAAI,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,UAAU,CAAC;IAoEpD,WAAW,IAAI,OAAO,CAAC,YAAY,CAAC;CAyD3C"}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { access, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, relative } from "node:path";
|
|
3
|
+
import { logger } from "@nebutra/logger";
|
|
4
|
+
import { defaultTokensDir, readTokenSets, writeTokenSet } from "../io";
|
|
5
|
+
import { importFromDesignMd } from "../serialize/from-design-md";
|
|
6
|
+
import { serializeToDesignMd } from "../serialize/to-design-md";
|
|
7
|
+
import { serializeToPreviewHtml } from "../serialize/to-preview-html";
|
|
8
|
+
export { importFromDesignMd } from "../serialize/from-design-md";
|
|
9
|
+
// =============================================================================
|
|
10
|
+
// DesignMd Provider — AI-native DESIGN.md format (@google/design.md)
|
|
11
|
+
// =============================================================================
|
|
12
|
+
// The "design tool" here is a DESIGN.md file in the repo root (or a
|
|
13
|
+
// configurable path). Push = repo DTCG → DESIGN.md (with official lint gate).
|
|
14
|
+
// Pull = DESIGN.md → a `themes/<brand>.json` DTCG file in tokensDir.
|
|
15
|
+
//
|
|
16
|
+
// Why DESIGN.md?
|
|
17
|
+
// - Zero external dependencies at runtime (just a file in git)
|
|
18
|
+
// - AI-native: models can read/write DESIGN.md directly
|
|
19
|
+
// - Google's @google/design.md spec mandates lint rules (broken-ref, contrast)
|
|
20
|
+
// - Bridges the gap for indie hackers who want typed tokens without a design tool
|
|
21
|
+
// =============================================================================
|
|
22
|
+
/**
|
|
23
|
+
* Default DESIGN.md path: <cwd>/DESIGN.md
|
|
24
|
+
*/
|
|
25
|
+
function defaultDesignMdPath(cwd = process.cwd()) {
|
|
26
|
+
return join(cwd, "DESIGN.md");
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Derive the preview file path from the DESIGN.md path.
|
|
30
|
+
*
|
|
31
|
+
* Replaces a trailing `.md` extension with `.preview.html`;
|
|
32
|
+
* if the path does not end in `.md`, appends `.preview.html`.
|
|
33
|
+
*
|
|
34
|
+
* Examples:
|
|
35
|
+
* "/repo/DESIGN.md" → "/repo/DESIGN.preview.html"
|
|
36
|
+
* "/repo/design/tokens.md" → "/repo/design/tokens.preview.html"
|
|
37
|
+
* "/repo/DESIGN" → "/repo/DESIGN.preview.html"
|
|
38
|
+
*/
|
|
39
|
+
export function previewHtmlPathFor(mdPath) {
|
|
40
|
+
if (mdPath.endsWith(".md")) {
|
|
41
|
+
return `${mdPath.slice(0, -3)}.preview.html`;
|
|
42
|
+
}
|
|
43
|
+
return `${mdPath}.preview.html`;
|
|
44
|
+
}
|
|
45
|
+
// ─── Lint Gate ────────────────────────────────────────────────────────────────
|
|
46
|
+
/**
|
|
47
|
+
* Throw if the LintReport contains any error-severity findings.
|
|
48
|
+
* Warnings do not throw — they are surfaced in the push summary.
|
|
49
|
+
* Used as the fail-closed gate before writing a DESIGN.md.
|
|
50
|
+
*
|
|
51
|
+
* Fail-closed on either signal: error-severity findings in `findings[]`
|
|
52
|
+
* OR a non-zero `summary.errors` count.
|
|
53
|
+
*
|
|
54
|
+
* @param report - The LintReport returned by `lint(content)`.
|
|
55
|
+
* @param source - A human-readable label for the file (used in the error message).
|
|
56
|
+
* @throws When error-severity findings are present or `summary.errors > 0`.
|
|
57
|
+
*/
|
|
58
|
+
export function assertLintClean(report, source) {
|
|
59
|
+
const errorFindings = report.findings.filter((f) => f.severity === "error");
|
|
60
|
+
if (errorFindings.length === 0 && report.summary.errors === 0)
|
|
61
|
+
return;
|
|
62
|
+
const count = errorFindings.length || report.summary.errors;
|
|
63
|
+
const detail = errorFindings
|
|
64
|
+
.map((f) => (f.path ? ` ${f.path}: ${f.message}` : ` ${f.message}`))
|
|
65
|
+
.join("\n");
|
|
66
|
+
throw new Error(`[design-md] Lint failed for ${source} — ${count} error(s):\n${detail}`);
|
|
67
|
+
}
|
|
68
|
+
// ─── Lint Runner ─────────────────────────────────────────────────────────────
|
|
69
|
+
/**
|
|
70
|
+
* Dynamically imports the @google/design.md linter and runs it on `content`.
|
|
71
|
+
* Extracted as a named export so tests can verify the `[design-md]`-prefixed
|
|
72
|
+
* error thrown when the dynamic import or lint call itself fails.
|
|
73
|
+
*
|
|
74
|
+
* @throws When the linter import or invocation throws — error is wrapped with a
|
|
75
|
+
* `[design-md] Lint gate failed to run on <label>:` prefix.
|
|
76
|
+
*/
|
|
77
|
+
export async function runLintGate(content, label) {
|
|
78
|
+
try {
|
|
79
|
+
const { lint } = await import("@google/design.md/linter");
|
|
80
|
+
return lint(content);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
throw new Error(`[design-md] Lint gate failed to run on ${label}: ${err?.message ?? String(err)}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// ─── Provider ────────────────────────────────────────────────────────────────
|
|
87
|
+
export class DesignMdProvider {
|
|
88
|
+
name = "design-md";
|
|
89
|
+
tokensDir;
|
|
90
|
+
designMdPath;
|
|
91
|
+
designSystemName;
|
|
92
|
+
designSystemDescription;
|
|
93
|
+
constructor(config) {
|
|
94
|
+
this.tokensDir = config.tokensDir ?? defaultTokensDir();
|
|
95
|
+
this.designMdPath = config.designMdPath ?? process.env.DESIGN_MD_PATH ?? defaultDesignMdPath();
|
|
96
|
+
this.designSystemName = config.name;
|
|
97
|
+
this.designSystemDescription = config.description;
|
|
98
|
+
logger.info("[design-sync:design-md] Provider initialised", {
|
|
99
|
+
tokensDir: this.tokensDir,
|
|
100
|
+
designMdPath: this.designMdPath,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
// ── Pull: DESIGN.md → tokensDir/themes/<brand>.json ──────────────────────
|
|
104
|
+
async pull(options = {}) {
|
|
105
|
+
// Graceful handling: if DESIGN.md does not exist, return an empty result.
|
|
106
|
+
let content;
|
|
107
|
+
try {
|
|
108
|
+
content = await readFile(this.designMdPath, "utf8");
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
logger.warn("[design-sync:design-md] DESIGN.md not found, returning empty pull result", {
|
|
112
|
+
designMdPath: this.designMdPath,
|
|
113
|
+
});
|
|
114
|
+
return {
|
|
115
|
+
sets: [],
|
|
116
|
+
written: false,
|
|
117
|
+
provider: "design-md",
|
|
118
|
+
pulledAt: new Date().toISOString(),
|
|
119
|
+
summary: `design-md: DESIGN.md not found at ${this.designMdPath} — no sets pulled`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
const { set, report } = importFromDesignMd(content);
|
|
123
|
+
if (report.warnings.length > 0 || report.missingRequired.length > 0) {
|
|
124
|
+
logger.warn("[design-sync:design-md] imported DESIGN.md has gaps", {
|
|
125
|
+
warnings: report.warnings.length,
|
|
126
|
+
missingRequired: report.missingRequired,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const dryRun = options.dryRun ?? false;
|
|
130
|
+
if (!dryRun) {
|
|
131
|
+
await writeTokenSet(this.tokensDir, set);
|
|
132
|
+
logger.info("[design-sync:design-md] pull wrote token set", {
|
|
133
|
+
relativePath: set.relativePath,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
const missingCount = report.missingRequired.length;
|
|
137
|
+
const unmappedCount = report.unmapped.length;
|
|
138
|
+
const reportSuffix = missingCount > 0 || unmappedCount > 0
|
|
139
|
+
? ` (missingRequired: ${missingCount}, unmapped: ${unmappedCount})`
|
|
140
|
+
: "";
|
|
141
|
+
return {
|
|
142
|
+
sets: [set],
|
|
143
|
+
written: !dryRun,
|
|
144
|
+
provider: "design-md",
|
|
145
|
+
pulledAt: new Date().toISOString(),
|
|
146
|
+
summary: dryRun
|
|
147
|
+
? `design-md: dry-run — would write ${set.relativePath}${reportSuffix}`
|
|
148
|
+
: `design-md: wrote ${set.relativePath}${reportSuffix}`,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
// ── Push: tokensDir → DESIGN.md (with official lint gate) ────────────────
|
|
152
|
+
async push(options = {}) {
|
|
153
|
+
const all = await readTokenSets(this.tokensDir);
|
|
154
|
+
// Serialize all sets into DESIGN.md content.
|
|
155
|
+
const content = serializeToDesignMd(all, {
|
|
156
|
+
...(this.designSystemName !== undefined ? { name: this.designSystemName } : {}),
|
|
157
|
+
...(this.designSystemDescription !== undefined
|
|
158
|
+
? { description: this.designSystemDescription }
|
|
159
|
+
: {}),
|
|
160
|
+
});
|
|
161
|
+
// Compute a consistent, human-readable path label (relative preferred).
|
|
162
|
+
const mdLabel = relative(process.cwd(), this.designMdPath) || this.designMdPath;
|
|
163
|
+
// Run the official lint gate — fail closed on any error finding.
|
|
164
|
+
const lintReport = await runLintGate(content, mdLabel);
|
|
165
|
+
// Collect warnings for the summary (they don't block the write).
|
|
166
|
+
const warnings = lintReport.findings
|
|
167
|
+
.filter((f) => f.severity === "warning")
|
|
168
|
+
.map((f) => (f.path ? `${f.path}: ${f.message}` : f.message));
|
|
169
|
+
// Throw if any error-severity findings are present.
|
|
170
|
+
assertLintClean(lintReport, mdLabel);
|
|
171
|
+
// Compute the sibling preview path (before the write, so it's always defined)
|
|
172
|
+
const previewPath = previewHtmlPathFor(this.designMdPath);
|
|
173
|
+
const dryRun = options.dryRun ?? false;
|
|
174
|
+
if (!dryRun) {
|
|
175
|
+
await mkdir(dirname(this.designMdPath), { recursive: true });
|
|
176
|
+
await writeFile(this.designMdPath, content, "utf8");
|
|
177
|
+
logger.info("[design-sync:design-md] push wrote DESIGN.md", {
|
|
178
|
+
designMdPath: this.designMdPath,
|
|
179
|
+
sets: all.length,
|
|
180
|
+
});
|
|
181
|
+
// Generate and write the sibling visual preview
|
|
182
|
+
const previewHtml = serializeToPreviewHtml(all, {
|
|
183
|
+
...(this.designSystemName !== undefined ? { name: this.designSystemName } : {}),
|
|
184
|
+
});
|
|
185
|
+
await writeFile(previewPath, previewHtml, "utf8");
|
|
186
|
+
logger.info("[design-sync:design-md] push wrote preview", {
|
|
187
|
+
previewPath,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
logger.info("[design-sync:design-md] push dry-run — lint passed, files NOT written", {
|
|
192
|
+
designMdPath: this.designMdPath,
|
|
193
|
+
previewPath,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const warnSuffix = warnings.length > 0 ? ` (${warnings.length} warning(s))` : "";
|
|
197
|
+
return {
|
|
198
|
+
pushed: !dryRun,
|
|
199
|
+
sets: dryRun ? [this.designMdPath] : [this.designMdPath, previewPath],
|
|
200
|
+
provider: "design-md",
|
|
201
|
+
pushedAt: new Date().toISOString(),
|
|
202
|
+
summary: dryRun
|
|
203
|
+
? `design-md: dry-run — lint passed, would write ${mdLabel}${warnSuffix}`
|
|
204
|
+
: `design-md: wrote ${mdLabel} from ${all.length} DTCG set(s)${warnSuffix}`,
|
|
205
|
+
dryRun,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
// ── Healthcheck ───────────────────────────────────────────────────────────
|
|
209
|
+
async healthcheck() {
|
|
210
|
+
const detected = [];
|
|
211
|
+
const missing = [];
|
|
212
|
+
// 1. Check tokensDir exists.
|
|
213
|
+
let tokensDirOk = false;
|
|
214
|
+
try {
|
|
215
|
+
const info = await stat(this.tokensDir);
|
|
216
|
+
if (info.isDirectory()) {
|
|
217
|
+
tokensDirOk = true;
|
|
218
|
+
detected.push("tokensDir");
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
missing.push("tokensDir (not a directory)");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
missing.push("tokensDir (does not exist)");
|
|
226
|
+
}
|
|
227
|
+
// 2. Check the @google/design.md lib is importable (it's a dep, should always be).
|
|
228
|
+
let lintLibOk = false;
|
|
229
|
+
try {
|
|
230
|
+
await import("@google/design.md/linter");
|
|
231
|
+
lintLibOk = true;
|
|
232
|
+
detected.push("@google/design.md");
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
missing.push("@google/design.md (lib not importable)");
|
|
236
|
+
}
|
|
237
|
+
// 3. List DESIGN_MD_PATH in detectedEnv when it's set (it's optional, not required).
|
|
238
|
+
const envPath = process.env.DESIGN_MD_PATH;
|
|
239
|
+
if (envPath) {
|
|
240
|
+
detected.push(`DESIGN_MD_PATH=${envPath}`);
|
|
241
|
+
}
|
|
242
|
+
// 4. Check the parent directory of designMdPath is accessible.
|
|
243
|
+
let mdDirOk = false;
|
|
244
|
+
const mdDir = dirname(this.designMdPath);
|
|
245
|
+
try {
|
|
246
|
+
await access(mdDir);
|
|
247
|
+
mdDirOk = true;
|
|
248
|
+
detected.push("designMdPath.parent");
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
missing.push("designMdPath.parent (not accessible)");
|
|
252
|
+
}
|
|
253
|
+
const ok = tokensDirOk && lintLibOk && mdDirOk;
|
|
254
|
+
return {
|
|
255
|
+
ok,
|
|
256
|
+
provider: "design-md",
|
|
257
|
+
message: ok
|
|
258
|
+
? `design-md: ready — tokensDir found, lint lib present, designMdPath=${this.designMdPath}`
|
|
259
|
+
: `design-md: not ready — missing: ${missing.join(", ")}`,
|
|
260
|
+
detectedEnv: detected,
|
|
261
|
+
missingEnv: missing,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { DesignSyncProvider, FigmaProviderConfig, HealthStatus, PullOptions, PullResult, PushOptions, PushResult } from "../types";
|
|
2
|
+
export declare class FigmaProvider implements DesignSyncProvider {
|
|
3
|
+
readonly name: "figma";
|
|
4
|
+
private readonly tokensDir;
|
|
5
|
+
private readonly tokensStudioDir;
|
|
6
|
+
private readonly personalAccessToken;
|
|
7
|
+
private readonly fileId;
|
|
8
|
+
private readonly githubRepo;
|
|
9
|
+
private readonly githubBranch;
|
|
10
|
+
constructor(config: FigmaProviderConfig);
|
|
11
|
+
pull(options?: PullOptions): Promise<PullResult>;
|
|
12
|
+
push(options?: PushOptions): Promise<PushResult>;
|
|
13
|
+
healthcheck(): Promise<HealthStatus>;
|
|
14
|
+
/**
|
|
15
|
+
* Assert that `.tokens-studio/{config,metadata,themes}.json` exist + parse.
|
|
16
|
+
* The plugin refuses to load the design system if any of these are missing
|
|
17
|
+
* or malformed; surfacing the failure here prevents silent drift.
|
|
18
|
+
*/
|
|
19
|
+
private assertTokensStudioMetadata;
|
|
20
|
+
}
|
|
21
|
+
//# sourceMappingURL=figma.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"figma.d.ts","sourceRoot":"","sources":["../../src/providers/figma.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EACV,kBAAkB,EAClB,mBAAmB,EACnB,YAAY,EACZ,WAAW,EACX,UAAU,EACV,WAAW,EACX,UAAU,EACX,MAAM,UAAU,CAAC;AAyBlB,qBAAa,aAAc,YAAW,kBAAkB;IACtD,QAAQ,CAAC,IAAI,EAAG,OAAO,CAAU;IAEjC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;IACzC,OAAO,CAAC,QAAQ,CAAC,mBAAmB,CAAqB;IACzD,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAqB;IAC5C,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAqB;IAChD,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;gBAE1B,MAAM,EAAE,mBAAmB;IAgBjC,IAAI,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,UAAU,CAAC;IAiBpD,IAAI,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,UAAU,CAAC;IAiDpD,WAAW,IAAI,OAAO,CAAC,YAAY,CAAC;IAwC1C;;;;OAIG;YACW,0BAA0B;CA+BzC"}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { logger } from "@nebutra/logger";
|
|
4
|
+
import { defaultTokensDir, defaultTokensStudioDir, readTokenSets, validateDtcgTree } from "../io";
|
|
5
|
+
// =============================================================================
|
|
6
|
+
// Figma Provider — Figma + Tokens Studio
|
|
7
|
+
// =============================================================================
|
|
8
|
+
// Architecture (matches the legacy `.tokens-studio/config.json` setup):
|
|
9
|
+
//
|
|
10
|
+
// Figma (Tokens Studio plugin) ──► GitHub branch ──► repo (DTCG JSON)
|
|
11
|
+
// ▲ │
|
|
12
|
+
// └─── pull from main ──────────┘
|
|
13
|
+
//
|
|
14
|
+
// The plugin owns the git transport. This provider:
|
|
15
|
+
// - validates that the Tokens Studio config + DTCG tree are well-formed
|
|
16
|
+
// - "pull" reads the DTCG files the plugin already wrote to git
|
|
17
|
+
// - "push" is a dry-run scaffold for the Figma Variables REST API
|
|
18
|
+
// (PATCH /v1/files/:file_key/variables) — the user must opt in by
|
|
19
|
+
// providing FIGMA_PERSONAL_ACCESS_TOKEN + FIGMA_FILE_ID and removing
|
|
20
|
+
// the early-exit guard.
|
|
21
|
+
//
|
|
22
|
+
// All Tokens Studio metadata that previously lived in `.tokens-studio/` is
|
|
23
|
+
// still consumed from disk so the existing designer onboarding does not break.
|
|
24
|
+
// =============================================================================
|
|
25
|
+
const FIGMA_API_ROOT = "https://api.figma.com";
|
|
26
|
+
export class FigmaProvider {
|
|
27
|
+
name = "figma";
|
|
28
|
+
tokensDir;
|
|
29
|
+
tokensStudioDir;
|
|
30
|
+
personalAccessToken;
|
|
31
|
+
fileId;
|
|
32
|
+
githubRepo;
|
|
33
|
+
githubBranch;
|
|
34
|
+
constructor(config) {
|
|
35
|
+
this.tokensDir = config.tokensDir ?? defaultTokensDir();
|
|
36
|
+
this.tokensStudioDir = config.tokensStudioDir ?? defaultTokensStudioDir();
|
|
37
|
+
this.personalAccessToken =
|
|
38
|
+
config.personalAccessToken ?? process.env.FIGMA_PERSONAL_ACCESS_TOKEN ?? undefined;
|
|
39
|
+
this.fileId = config.fileId ?? process.env.FIGMA_FILE_ID ?? undefined;
|
|
40
|
+
this.githubRepo = config.githubRepo ?? process.env.FIGMA_GITHUB_REPO ?? undefined;
|
|
41
|
+
this.githubBranch = config.githubBranch ?? process.env.FIGMA_GITHUB_BRANCH ?? "main";
|
|
42
|
+
logger.info("[design-sync:figma] Provider initialised", {
|
|
43
|
+
tokensDir: this.tokensDir,
|
|
44
|
+
hasToken: Boolean(this.personalAccessToken),
|
|
45
|
+
hasFileId: Boolean(this.fileId),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
async pull(options = {}) {
|
|
49
|
+
// Tokens Studio plugin already wrote DTCG files to git on `pull`.
|
|
50
|
+
// We just re-read them from disk and (optionally) validate Tokens
|
|
51
|
+
// Studio metadata so the result mirrors what the plugin would emit.
|
|
52
|
+
const sets = await readTokenSets(this.tokensDir);
|
|
53
|
+
const filtered = filterSets(sets, options.themes);
|
|
54
|
+
await this.assertTokensStudioMetadata();
|
|
55
|
+
return {
|
|
56
|
+
sets: filtered,
|
|
57
|
+
written: false,
|
|
58
|
+
provider: "figma",
|
|
59
|
+
pulledAt: new Date().toISOString(),
|
|
60
|
+
summary: `figma: read ${filtered.length} DTCG token set(s) (Tokens Studio plugin owns the git transport)`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
async push(options = {}) {
|
|
64
|
+
// Pre-flight: validate DTCG before attempting any remote call.
|
|
65
|
+
const all = await readTokenSets(this.tokensDir);
|
|
66
|
+
const sets = filterSets(all, options.themes);
|
|
67
|
+
for (const set of sets) {
|
|
68
|
+
const errors = validateDtcgTree(set.tokens);
|
|
69
|
+
if (errors.length > 0) {
|
|
70
|
+
throw new Error(`[design-sync:figma] DTCG validation failed for ${set.relativePath}:\n - ${errors.join("\n - ")}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const credsReady = Boolean(this.personalAccessToken && this.fileId);
|
|
74
|
+
const explicitDryRun = options.dryRun ?? false;
|
|
75
|
+
const dryRun = explicitDryRun || !credsReady;
|
|
76
|
+
if (dryRun) {
|
|
77
|
+
logger.warn("[design-sync:figma] push skipped (dry-run scaffold)", {
|
|
78
|
+
reason: credsReady ? "explicit dryRun" : "missing credentials",
|
|
79
|
+
sets: sets.length,
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
pushed: false,
|
|
83
|
+
sets: sets.map((s) => s.relativePath),
|
|
84
|
+
provider: "figma",
|
|
85
|
+
pushedAt: new Date().toISOString(),
|
|
86
|
+
summary: credsReady
|
|
87
|
+
? `figma: dry-run — would PATCH ${sets.length} set(s) to ${FIGMA_API_ROOT}/v1/files/${this.fileId}/variables`
|
|
88
|
+
: "figma: dry-run — FIGMA_PERSONAL_ACCESS_TOKEN or FIGMA_FILE_ID not set",
|
|
89
|
+
dryRun: true,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
// Real push placeholder — kept guarded until the user opts in.
|
|
93
|
+
// The integration will call the Figma Variables REST API:
|
|
94
|
+
// PATCH /v1/files/:file_key/variables
|
|
95
|
+
// X-Figma-Token: <personalAccessToken>
|
|
96
|
+
// See https://www.figma.com/developers/api#variables
|
|
97
|
+
throw new Error("[design-sync:figma] live push to Figma Variables REST API is not yet implemented. " +
|
|
98
|
+
"Use { dryRun: true } or unset FIGMA_PERSONAL_ACCESS_TOKEN until the integration is wired up. " +
|
|
99
|
+
"See packages/design/design-sync/DESIGN.md for the rollout plan.");
|
|
100
|
+
}
|
|
101
|
+
async healthcheck() {
|
|
102
|
+
const detected = [];
|
|
103
|
+
const missing = [];
|
|
104
|
+
if (this.personalAccessToken)
|
|
105
|
+
detected.push("FIGMA_PERSONAL_ACCESS_TOKEN");
|
|
106
|
+
else
|
|
107
|
+
missing.push("FIGMA_PERSONAL_ACCESS_TOKEN");
|
|
108
|
+
if (this.fileId)
|
|
109
|
+
detected.push("FIGMA_FILE_ID");
|
|
110
|
+
else
|
|
111
|
+
missing.push("FIGMA_FILE_ID");
|
|
112
|
+
if (this.githubRepo)
|
|
113
|
+
detected.push("FIGMA_GITHUB_REPO");
|
|
114
|
+
if (this.githubBranch)
|
|
115
|
+
detected.push(`FIGMA_GITHUB_BRANCH=${this.githubBranch}`);
|
|
116
|
+
let tokensStudioOk = true;
|
|
117
|
+
let tokensStudioMessage = "";
|
|
118
|
+
try {
|
|
119
|
+
await this.assertTokensStudioMetadata();
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
tokensStudioOk = false;
|
|
123
|
+
tokensStudioMessage = error.message;
|
|
124
|
+
missing.push("tokens-studio metadata");
|
|
125
|
+
}
|
|
126
|
+
const ok = missing.length === 0 && tokensStudioOk;
|
|
127
|
+
return {
|
|
128
|
+
ok,
|
|
129
|
+
provider: "figma",
|
|
130
|
+
message: ok
|
|
131
|
+
? "figma: credentials present + Tokens Studio metadata valid"
|
|
132
|
+
: `figma: not ready — ${[
|
|
133
|
+
missing.length > 0 ? `missing ${missing.join(", ")}` : "",
|
|
134
|
+
tokensStudioMessage,
|
|
135
|
+
]
|
|
136
|
+
.filter(Boolean)
|
|
137
|
+
.join("; ")}`,
|
|
138
|
+
detectedEnv: detected,
|
|
139
|
+
missingEnv: missing,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Assert that `.tokens-studio/{config,metadata,themes}.json` exist + parse.
|
|
144
|
+
* The plugin refuses to load the design system if any of these are missing
|
|
145
|
+
* or malformed; surfacing the failure here prevents silent drift.
|
|
146
|
+
*/
|
|
147
|
+
async assertTokensStudioMetadata() {
|
|
148
|
+
const required = ["config.json", "metadata.json", "themes.json"];
|
|
149
|
+
const errors = [];
|
|
150
|
+
try {
|
|
151
|
+
const info = await stat(this.tokensStudioDir);
|
|
152
|
+
if (!info.isDirectory()) {
|
|
153
|
+
throw new Error(`${this.tokensStudioDir} is not a directory`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
throw new Error(`[design-sync:figma] Tokens Studio metadata directory missing at ${this.tokensStudioDir}: ${error.message}`);
|
|
158
|
+
}
|
|
159
|
+
for (const file of required) {
|
|
160
|
+
const path = join(this.tokensStudioDir, file);
|
|
161
|
+
try {
|
|
162
|
+
const raw = await readFile(path, "utf8");
|
|
163
|
+
JSON.parse(raw);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
errors.push(`${path}: ${error.message}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (errors.length > 0) {
|
|
170
|
+
throw new Error(`[design-sync:figma] Tokens Studio metadata invalid:\n - ${errors.join("\n - ")}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function filterSets(sets, themes) {
|
|
175
|
+
if (!themes || themes.length === 0)
|
|
176
|
+
return sets;
|
|
177
|
+
const wanted = new Set(themes);
|
|
178
|
+
return sets.filter((s) => wanted.has(s.name) || wanted.has(s.relativePath));
|
|
179
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DesignSyncProvider, GitOnlyProviderConfig, HealthStatus, PullOptions, PullResult, PushOptions, PushResult } from "../types";
|
|
2
|
+
export declare class GitOnlyProvider implements DesignSyncProvider {
|
|
3
|
+
readonly name: "git-only";
|
|
4
|
+
private readonly tokensDir;
|
|
5
|
+
private readonly tokensStudioDir;
|
|
6
|
+
constructor(config: GitOnlyProviderConfig);
|
|
7
|
+
pull(options?: PullOptions): Promise<PullResult>;
|
|
8
|
+
push(options?: PushOptions): Promise<PushResult>;
|
|
9
|
+
healthcheck(): Promise<HealthStatus>;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=git-only.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"git-only.d.ts","sourceRoot":"","sources":["../../src/providers/git-only.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EACV,kBAAkB,EAClB,qBAAqB,EACrB,YAAY,EACZ,WAAW,EACX,UAAU,EACV,WAAW,EACX,UAAU,EACX,MAAM,UAAU,CAAC;AAalB,qBAAa,eAAgB,YAAW,kBAAkB;IACxD,QAAQ,CAAC,IAAI,EAAG,UAAU,CAAU;IAEpC,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,eAAe,CAAS;gBAE7B,MAAM,EAAE,qBAAqB;IAQnC,IAAI,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,UAAU,CAAC;IAapD,IAAI,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,UAAU,CAAC;IAmCpD,WAAW,IAAI,OAAO,CAAC,YAAY,CAAC;CAmC3C"}
|