stitch2 0.0.0-stage → 0.7.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 -0
- package/README.md +208 -2
- package/config.schema.json +109 -0
- package/dist/assets.d.ts +1 -0
- package/dist/assets.js +59 -0
- package/dist/audit.d.ts +10 -0
- package/dist/audit.js +259 -0
- package/dist/build-design.d.ts +39 -0
- package/dist/build-design.js +337 -0
- package/dist/build-elements.d.ts +1 -0
- package/dist/build-elements.js +33 -0
- package/dist/canvas.html +689 -0
- package/dist/check.d.ts +1 -0
- package/dist/check.js +86 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +73 -0
- package/dist/colours.d.ts +2 -0
- package/dist/colours.js +57 -0
- package/dist/compare.d.ts +1 -0
- package/dist/compare.js +185 -0
- package/dist/config.d.ts +53 -0
- package/dist/config.js +38 -0
- package/dist/consistency.d.ts +40 -0
- package/dist/consistency.js +336 -0
- package/dist/design-md.d.ts +27 -0
- package/dist/design-md.js +49 -0
- package/dist/design-page.d.ts +1 -0
- package/dist/design-page.js +127 -0
- package/dist/elements.d.ts +32 -0
- package/dist/elements.js +69 -0
- package/dist/exports.d.ts +25 -0
- package/dist/exports.js +86 -0
- package/dist/extract.d.ts +1 -0
- package/dist/extract.js +197 -0
- package/dist/init.d.ts +5 -0
- package/dist/init.js +83 -0
- package/dist/list.d.ts +1 -0
- package/dist/list.js +17 -0
- package/dist/pick.d.ts +10 -0
- package/dist/pick.js +38 -0
- package/dist/report.d.ts +1 -0
- package/dist/report.js +110 -0
- package/dist/sandbox.d.ts +1 -0
- package/dist/sandbox.js +183 -0
- package/dist/screens.d.ts +34 -0
- package/dist/screens.js +85 -0
- package/dist/serve.d.ts +4 -0
- package/dist/serve.js +181 -0
- package/dist/setup.d.ts +1 -0
- package/dist/setup.js +214 -0
- package/dist/tokens.d.ts +1 -0
- package/dist/tokens.js +128 -0
- package/dist/type.d.ts +1 -0
- package/dist/type.js +60 -0
- package/dist/typecheck.d.ts +35 -0
- package/dist/typecheck.js +112 -0
- package/package.json +53 -4
- package/skills/stitch2/SKILL.md +80 -0
- package/skills/stitch2/references/components.md +59 -0
- package/skills/stitch2/references/guides.md +20 -0
- package/skills/stitch2/references/principles.md +80 -0
- package/skills/stitch2/references/screens.md +70 -0
- package/skills/stitch2/references/typography.md +106 -0
- package/skills/stitch2-app-sync/SKILL.md +65 -0
- package/skills/stitch2-consistency/SKILL.md +59 -0
- package/skills/stitch2-design-md/SKILL.md +64 -0
- package/skills/stitch2-import-from-google-stitch/SKILL.md +47 -0
- package/skills/stitch2-import-from-google-stitch/references/how-stitch-works.md +36 -0
- package/skills/stitch2-variants/SKILL.md +45 -0
package/dist/sandbox.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sandboxes: isolated folders where fresh agents design from a brief without seeing the project's other
|
|
3
|
+
* versions, so their work is not anchored to what exists ("fresh eyes"), and where changes to the skills can
|
|
4
|
+
* be tested on output that only the rules produced.
|
|
5
|
+
*
|
|
6
|
+
* stitch2 sandbox create <dir> <brief.md>… [--device mobile|desktop] [--context <screen>…]
|
|
7
|
+
* The project's config, its design folder without screens (DESIGN.md, tokens, components, the catalog and
|
|
8
|
+
* templates), the skills from the config, a copy of stitch2 and the briefs (one screen each, named after the
|
|
9
|
+
* file). --context adds the approved versions of other screens, to stay consistent with them.
|
|
10
|
+
* stitch2 sandbox import <dir> [--label "…"]
|
|
11
|
+
* Brings every screen designed there back as the next version of its screen, status review, with a note
|
|
12
|
+
* saying where it came from.
|
|
13
|
+
*/
|
|
14
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, symlinkSync, writeFileSync, } from 'node:fs';
|
|
15
|
+
import { basename, dirname, join, relative, resolve } from 'node:path';
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { BASE, CONFIG_NAME, config } from './config.js';
|
|
18
|
+
import { listScreens, meta, ROOT, tag, writeMeta } from './screens.js';
|
|
19
|
+
const argv = process.argv.slice(2);
|
|
20
|
+
const sub = argv[0] === 'create' || argv[0] === 'import' ? argv.shift() : 'create';
|
|
21
|
+
const flag = (name) => {
|
|
22
|
+
const i = argv.indexOf(`--${name}`);
|
|
23
|
+
if (i < 0)
|
|
24
|
+
return undefined;
|
|
25
|
+
const values = [];
|
|
26
|
+
for (let j = i + 1; j < argv.length && !argv[j].startsWith('--'); j++)
|
|
27
|
+
values.push(argv[j]);
|
|
28
|
+
argv.splice(i, values.length + 1);
|
|
29
|
+
return values;
|
|
30
|
+
};
|
|
31
|
+
const appDir = (c) => c.screenDirs.filter((d) => d !== c.stitchDir).at(-1) ?? 'screens';
|
|
32
|
+
if (sub === 'create') {
|
|
33
|
+
const device = flag('device')?.[0] ?? 'mobile';
|
|
34
|
+
const context = flag('context') ?? [];
|
|
35
|
+
const [dir, ...briefs] = argv;
|
|
36
|
+
if (!dir || !briefs.length) {
|
|
37
|
+
console.error('usage: stitch2 sandbox create <dir> <brief.md>… [--device mobile|desktop] [--context <screen>…]');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const out = resolve(dir);
|
|
41
|
+
if (existsSync(out) && readdirSync(out).length) {
|
|
42
|
+
console.error(`${out} is not empty`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
// The running code (src/*.ts from a checkout, dist/*.js from an install) and the node_modules it resolves from.
|
|
46
|
+
const code = dirname(fileURLToPath(import.meta.url));
|
|
47
|
+
const codeDir = basename(code);
|
|
48
|
+
const ext = import.meta.url.endsWith('.ts') ? 'ts' : 'js';
|
|
49
|
+
// stitch2's own dependencies: its node_modules in a checkout or workspace, the folder it sits in when
|
|
50
|
+
// installed (npm puts dependencies next to it, pnpm in its store folder next to it).
|
|
51
|
+
let modules = '';
|
|
52
|
+
for (let d = join(code, '..'); !modules && dirname(d) !== d; d = dirname(d)) {
|
|
53
|
+
if (existsSync(join(d, 'node_modules', 'yaml')))
|
|
54
|
+
modules = join(d, 'node_modules');
|
|
55
|
+
else if (basename(d) === 'node_modules' && existsSync(join(d, 'yaml')))
|
|
56
|
+
modules = d;
|
|
57
|
+
}
|
|
58
|
+
// The design folder minus every screen, except the catalog and templates (files starting with _).
|
|
59
|
+
const generated = new Set(['renders', 'checks.json', 'type-audit.json', 'consistency']);
|
|
60
|
+
cpSync(ROOT, join(out, 'design'), {
|
|
61
|
+
recursive: true,
|
|
62
|
+
filter: (src) => {
|
|
63
|
+
const rel = relative(ROOT, src);
|
|
64
|
+
if (!rel)
|
|
65
|
+
return true;
|
|
66
|
+
if (generated.has(rel.split('/')[0]) ||
|
|
67
|
+
config.sandboxExclude?.some((x) => rel === x || rel.startsWith(`${x}/`)))
|
|
68
|
+
return false;
|
|
69
|
+
const screenDir = config.screenDirs.find((d) => rel === d || rel.startsWith(`${d}/`));
|
|
70
|
+
if (!screenDir || rel === screenDir)
|
|
71
|
+
return true;
|
|
72
|
+
if (rel === config.catalogDir ||
|
|
73
|
+
rel.startsWith(`${config.catalogDir}/`) ||
|
|
74
|
+
config.catalogDir.startsWith(`${rel}/`))
|
|
75
|
+
return true;
|
|
76
|
+
return basename(rel).startsWith('_');
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
// Approved versions of the context screens (never of the screens being designed).
|
|
80
|
+
const designing = new Set(briefs.map((b) => basename(b).replace(/\.md$/, '')));
|
|
81
|
+
const copied = listScreens().filter((s) => s.status === 'approved' && context.includes(s.screen) && !designing.has(s.screen));
|
|
82
|
+
for (const s of copied) {
|
|
83
|
+
mkdirSync(dirname(join(out, 'design', s.path)), { recursive: true });
|
|
84
|
+
cpSync(join(ROOT, s.path), join(out, 'design', s.path));
|
|
85
|
+
const assets = join(ROOT, dirname(s.path), 'assets');
|
|
86
|
+
if (existsSync(assets))
|
|
87
|
+
cpSync(assets, join(out, 'design', dirname(s.path), 'assets'), { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
// Skills are often symlinks into a package; dereference so nothing points back out of the sandbox.
|
|
90
|
+
for (const s of config.skills)
|
|
91
|
+
cpSync(resolve(BASE, s), join(out, s), { recursive: true, dereference: true });
|
|
92
|
+
writeFileSync(join(out, CONFIG_NAME), `${JSON.stringify({ ...config, designDir: 'design', screenDirs: config.screenDirs.filter((d) => d !== config.stitchDir), archiveDirs: [] }, null, 2)}\n`);
|
|
93
|
+
cpSync(code, join(out, 'stitch2', codeDir), { recursive: true });
|
|
94
|
+
cpSync(join(code, '..', 'package.json'), join(out, 'stitch2/package.json'));
|
|
95
|
+
symlinkSync(modules, join(out, 'stitch2/node_modules'));
|
|
96
|
+
mkdirSync(join(out, 'briefs'), { recursive: true });
|
|
97
|
+
for (const b of briefs)
|
|
98
|
+
cpSync(resolve(b), join(out, 'briefs', basename(b)));
|
|
99
|
+
const p = config.prefix;
|
|
100
|
+
const target = (name) => `design/${appDir(config)}/${name}/${device}-v1.html`;
|
|
101
|
+
writeFileSync(join(out, 'AGENTS.md'), `# Design sandbox
|
|
102
|
+
|
|
103
|
+
This folder is the whole project for this task. Read and write files only inside it; do not open anything
|
|
104
|
+
outside it (no other checkouts, no git history, no web searches for this project's existing screens). Fetching
|
|
105
|
+
public design guides listed in the skills is fine.
|
|
106
|
+
|
|
107
|
+
- Skills: ${config.skills.map((s) => `${s}/SKILL.md`).join(', ')} (follow their workflow)
|
|
108
|
+
- Design system: design/DESIGN.md, with its generated tokens next to it; components in the catalog,
|
|
109
|
+
design/${config.catalogDir}${copied.length ? `\n- Approved screens to stay consistent with: ${copied.map((s) => `design/${s.path}`).join(', ')}` : ''}
|
|
110
|
+
- Briefs, one screen each: ${briefs.map((b) => `briefs/${basename(b)} → ${target(basename(b).replace(/\.md$/, ''))}`).join('; ')}.
|
|
111
|
+
Start from the template in design/${appDir(config)}/; set <meta name="${p}-screen"> to the brief's name,
|
|
112
|
+
${p}-device to ${device} and ${p}-status to review when done.
|
|
113
|
+
- stitch2, from this folder: node stitch2/${codeDir}/cli.${ext} check --shots once, then
|
|
114
|
+
node stitch2/${codeDir}/cli.${ext} check <path> --shots --strict (renders in design/renders/),
|
|
115
|
+
node stitch2/${codeDir}/cli.${ext} type <path>, node stitch2/${codeDir}/cli.${ext} canvas (PORT=4410)
|
|
116
|
+
`);
|
|
117
|
+
writeFileSync(join(out, 'sandbox.json'), `${JSON.stringify({ project: BASE, created: new Date().toISOString(), device, briefs: briefs.map((b) => basename(b)), context: copied.map((s) => s.path) }, null, 2)}\n`);
|
|
118
|
+
console.log(`sandbox ready: ${out} (${briefs.length} brief(s)${copied.length ? `, ${copied.length} approved screen(s) for context` : ''})`);
|
|
119
|
+
console.log(` run a fresh agent there (no shared conversation), then: stitch2 sandbox import ${out}`);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
const label = flag('label')?.join(' ');
|
|
123
|
+
const [dir] = argv;
|
|
124
|
+
if (!dir) {
|
|
125
|
+
console.error('usage: stitch2 sandbox import <dir> [--label "…"]');
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
const box = resolve(dir);
|
|
129
|
+
const boxConfig = JSON.parse(readFileSync(join(box, CONFIG_NAME), 'utf8'));
|
|
130
|
+
const boxRoot = join(box, boxConfig.designDir);
|
|
131
|
+
const files = [];
|
|
132
|
+
const walk = (d) => {
|
|
133
|
+
if (!existsSync(d))
|
|
134
|
+
return;
|
|
135
|
+
for (const e of readdirSync(d)) {
|
|
136
|
+
const f = join(d, e);
|
|
137
|
+
if (statSync(f).isDirectory()) {
|
|
138
|
+
if (e !== 'assets')
|
|
139
|
+
walk(f);
|
|
140
|
+
}
|
|
141
|
+
else if (e.endsWith('.html') && !e.startsWith('_'))
|
|
142
|
+
files.push(f);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
for (const d of boxConfig.screenDirs)
|
|
146
|
+
walk(join(boxRoot, d));
|
|
147
|
+
const versions = listScreens();
|
|
148
|
+
const next = (screen, device) => {
|
|
149
|
+
const taken = versions
|
|
150
|
+
.filter((s) => s.screen === screen && s.device === device)
|
|
151
|
+
.map((s) => Number(/^v(\d+)$/.exec(s.version)?.[1] ?? 1));
|
|
152
|
+
return Math.max(0, ...taken) + 1;
|
|
153
|
+
};
|
|
154
|
+
let imported = 0;
|
|
155
|
+
for (const f of files) {
|
|
156
|
+
const rel = relative(boxRoot, f);
|
|
157
|
+
if (rel.startsWith(`${boxConfig.catalogDir}/`))
|
|
158
|
+
continue;
|
|
159
|
+
const html = readFileSync(f, 'utf8');
|
|
160
|
+
if (meta(html, tag('status')) === 'approved')
|
|
161
|
+
continue; // context copied in from the project
|
|
162
|
+
const screen = meta(html, tag('screen')) ?? basename(dirname(f));
|
|
163
|
+
const device = meta(html, tag('device')) ?? 'mobile';
|
|
164
|
+
const n = next(screen, device);
|
|
165
|
+
const path = `${appDir(config)}/${screen}/${device}-v${n}.html`;
|
|
166
|
+
mkdirSync(dirname(join(ROOT, path)), { recursive: true });
|
|
167
|
+
cpSync(f, join(ROOT, path));
|
|
168
|
+
const assets = join(dirname(f), 'assets');
|
|
169
|
+
if (existsSync(assets))
|
|
170
|
+
cpSync(assets, join(ROOT, dirname(path), 'assets'), { recursive: true });
|
|
171
|
+
writeMeta(path, {
|
|
172
|
+
[tag('screen')]: screen,
|
|
173
|
+
[tag('device')]: device,
|
|
174
|
+
[tag('status')]: 'review',
|
|
175
|
+
[tag('source')]: 'lab',
|
|
176
|
+
[tag('note')]: `Fresh-eyes version from sandbox ${basename(box)}${label ? ` (${label})` : ''}: designed without seeing the other versions.`,
|
|
177
|
+
});
|
|
178
|
+
versions.push({ ...versions[0], screen, device: device, version: `v${n}` });
|
|
179
|
+
console.log(` ${screen} ${device} v${n} (${config.designDir}/${path})`);
|
|
180
|
+
imported++;
|
|
181
|
+
}
|
|
182
|
+
console.log(`imported ${imported} screen(s) from ${box}; review them on the canvas`);
|
|
183
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { ROOT } from './config.ts';
|
|
2
|
+
export { ROOT };
|
|
3
|
+
/** A lab meta tag name: <prefix>-screen, <prefix>-status… (the prefix comes from the config). */
|
|
4
|
+
export declare const tag: (name: string) => string;
|
|
5
|
+
export type Status = 'draft' | 'review' | 'approved' | 'archived';
|
|
6
|
+
export declare const STATUSES: Status[];
|
|
7
|
+
export interface Screen {
|
|
8
|
+
/** Path under design/, e.g. "stitch/home.html" — also the URL path on the lab server. */
|
|
9
|
+
path: string;
|
|
10
|
+
/** The folder, e.g. "stitch" or "screens/home". */
|
|
11
|
+
group: string;
|
|
12
|
+
/** File name without .html. */
|
|
13
|
+
name: string;
|
|
14
|
+
title: string;
|
|
15
|
+
/** Frame width in px: <meta name="<prefix>-frame-width" content="390">, default 390 (phone). */
|
|
16
|
+
width: number;
|
|
17
|
+
mtime: number;
|
|
18
|
+
/** "screen", or "components" for the component catalog. */
|
|
19
|
+
kind: 'screen' | 'components';
|
|
20
|
+
/** Which app screen this file is a version of (<meta name="<prefix>-screen">), e.g. "home". */
|
|
21
|
+
screen: string;
|
|
22
|
+
device: 'mobile' | 'desktop';
|
|
23
|
+
/** Where the version came from: stitch, lab or trial. */
|
|
24
|
+
source: 'stitch' | 'lab' | 'trial';
|
|
25
|
+
/** Short version label: "v3", "Stitch", "Trial 2". */
|
|
26
|
+
version: string;
|
|
27
|
+
status: Status;
|
|
28
|
+
/** The reviewer's note (<meta name="<prefix>-note">). */
|
|
29
|
+
note: string;
|
|
30
|
+
}
|
|
31
|
+
export declare const meta: (html: string, name: string) => string | undefined;
|
|
32
|
+
export declare function listScreens(): Screen[];
|
|
33
|
+
/** Sets (or removes, with undefined) <prefix>-* meta tags in a screen file, keeping the rest of it untouched. */
|
|
34
|
+
export declare function writeMeta(path: string, values: Record<string, string | undefined>): void;
|
package/dist/screens.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join, relative } from 'node:path';
|
|
3
|
+
import { config, ROOT } from './config.js';
|
|
4
|
+
export { ROOT };
|
|
5
|
+
/** A lab meta tag name: <prefix>-screen, <prefix>-status… (the prefix comes from the config). */
|
|
6
|
+
export const tag = (name) => `${config.prefix}-${name}`;
|
|
7
|
+
export const STATUSES = ['draft', 'review', 'approved', 'archived'];
|
|
8
|
+
function* htmlFiles(dir) {
|
|
9
|
+
let entries;
|
|
10
|
+
try {
|
|
11
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
17
|
+
if (e.name.startsWith('_') || e.name.startsWith('.') || e.name === 'assets')
|
|
18
|
+
continue;
|
|
19
|
+
const p = join(dir, e.name);
|
|
20
|
+
if (e.isDirectory())
|
|
21
|
+
yield* htmlFiles(p);
|
|
22
|
+
else if (e.name.endsWith('.html'))
|
|
23
|
+
yield p;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export const meta = (html, name) => new RegExp(`<meta\\s+name="${name}"\\s+content="([^"]*)"`, 'i').exec(html)?.[1];
|
|
27
|
+
export function listScreens() {
|
|
28
|
+
const out = [];
|
|
29
|
+
const under = (path, dir) => path.startsWith(`${dir}/`);
|
|
30
|
+
for (const dir of config.screenDirs) {
|
|
31
|
+
for (const file of htmlFiles(join(ROOT, dir))) {
|
|
32
|
+
const html = readFileSync(file, 'utf8');
|
|
33
|
+
const path = relative(ROOT, file);
|
|
34
|
+
const name = path
|
|
35
|
+
.split('/')
|
|
36
|
+
.pop()
|
|
37
|
+
.replace(/\.html$/, '');
|
|
38
|
+
const group = relative(ROOT, join(file, '..'));
|
|
39
|
+
const width = Number(meta(html, tag('frame-width')) ?? 390);
|
|
40
|
+
const archive = config.archiveDirs.find((d) => under(path, d));
|
|
41
|
+
const source = (meta(html, tag('source')) ??
|
|
42
|
+
(under(path, config.stitchDir) ? 'stitch' : archive ? 'trial' : 'lab'));
|
|
43
|
+
// design/screens/<screen>/<device>-v<N>.html, or older files named after the screen.
|
|
44
|
+
const versioned = /^(mobile|desktop)-v(\d+)$/.exec(name);
|
|
45
|
+
out.push({
|
|
46
|
+
path,
|
|
47
|
+
group,
|
|
48
|
+
name,
|
|
49
|
+
title: /<title>([^<]*)<\/title>/i.exec(html)?.[1]?.trim() ?? '',
|
|
50
|
+
width,
|
|
51
|
+
mtime: statSync(file).mtimeMs,
|
|
52
|
+
kind: under(path, config.catalogDir) ? 'components' : 'screen',
|
|
53
|
+
screen: meta(html, tag('screen')) ?? (versioned ? group.split('/').pop() : name),
|
|
54
|
+
device: (meta(html, tag('device')) ??
|
|
55
|
+
(versioned?.[1] || (width >= 768 ? 'desktop' : 'mobile'))),
|
|
56
|
+
source,
|
|
57
|
+
version: meta(html, tag('version')) ??
|
|
58
|
+
(versioned
|
|
59
|
+
? `v${versioned[2]}`
|
|
60
|
+
: source === 'stitch'
|
|
61
|
+
? 'Stitch'
|
|
62
|
+
: (archive?.split('/').pop() ?? 'v1')),
|
|
63
|
+
status: (meta(html, tag('status')) ?? (archive ? 'archived' : 'review')),
|
|
64
|
+
note: meta(html, tag('note'))?.replaceAll('"', '"').replaceAll('&', '&') ?? '',
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
/** Sets (or removes, with undefined) <prefix>-* meta tags in a screen file, keeping the rest of it untouched. */
|
|
71
|
+
export function writeMeta(path, values) {
|
|
72
|
+
const file = join(ROOT, path);
|
|
73
|
+
let html = readFileSync(file, 'utf8');
|
|
74
|
+
for (const [name, value] of Object.entries(values)) {
|
|
75
|
+
const re = new RegExp(`[ \\t]*<meta\\s+name="${name}"\\s+content="[^"]*"\\s*/?>\\n?`, 'i');
|
|
76
|
+
html = html.replace(re, '');
|
|
77
|
+
if (value === undefined || value === '')
|
|
78
|
+
continue;
|
|
79
|
+
const tag = `<meta name="${name}" content="${value.replaceAll('&', '&').replaceAll('"', '"').replaceAll('\n', ' ')}" />\n`;
|
|
80
|
+
html = /<head[^>]*>\n?/i.test(html)
|
|
81
|
+
? html.replace(/<head[^>]*>\n?/i, (m) => `${m.endsWith('\n') ? m : `${m}\n`}${tag}`)
|
|
82
|
+
: tag + html;
|
|
83
|
+
}
|
|
84
|
+
writeFileSync(file, html);
|
|
85
|
+
}
|
package/dist/serve.d.ts
ADDED
package/dist/serve.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The stitch2 server: the canvas at /, DESIGN.md drawn at /design, the screen list at /api/screens
|
|
3
|
+
* (POST /api/screens/meta sets a version's status or note), check results at /api/checks, live-reload events
|
|
4
|
+
* at /events, and every file under design/ by its path. Local only (127.0.0.1).
|
|
5
|
+
* Usage: stitch2 canvas (PORT=4400 by default)
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, readFileSync, statSync, watch } from 'node:fs';
|
|
8
|
+
import { createServer } from 'node:http';
|
|
9
|
+
import { dirname, extname, join, normalize } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { CONFIG_FILE, config } from './config.js';
|
|
12
|
+
import { designPage } from './design-page.js';
|
|
13
|
+
import { annotate } from './pick.js';
|
|
14
|
+
/**
|
|
15
|
+
* Inlines <link rel="stylesheet" type="text/tailwindcss" href="…">: the Tailwind v4 browser build reads @theme and
|
|
16
|
+
* @utility only from inline <style type="text/tailwindcss"> blocks. On one line, so source line numbers hold.
|
|
17
|
+
*/
|
|
18
|
+
function inlineTailwind(html, file) {
|
|
19
|
+
return html.replace(/<link\b[^>]*\btype="text\/tailwindcss"[^>]*>/g, (tag) => {
|
|
20
|
+
const href = /\bhref="([^"]+)"/.exec(tag)?.[1];
|
|
21
|
+
const css = href && (href.startsWith('/') ? join(ROOT, href) : join(dirname(file), href));
|
|
22
|
+
if (!css || !existsSync(css))
|
|
23
|
+
return `<!-- stitch2: ${href} not found -->`;
|
|
24
|
+
return `<style type="text/tailwindcss">${readFileSync(css, 'utf8').replace(/\s*\n\s*/g, ' ')}</style>`;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
import { listScreens, ROOT, STATUSES, tag, writeMeta } from './screens.js';
|
|
28
|
+
const CANVAS = fileURLToPath(new URL('./canvas.html', import.meta.url));
|
|
29
|
+
const TYPES = {
|
|
30
|
+
'.html': 'text/html; charset=utf-8',
|
|
31
|
+
'.css': 'text/css; charset=utf-8',
|
|
32
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
33
|
+
'.json': 'application/json',
|
|
34
|
+
'.png': 'image/png',
|
|
35
|
+
'.svg': 'image/svg+xml',
|
|
36
|
+
'.md': 'text/markdown; charset=utf-8',
|
|
37
|
+
};
|
|
38
|
+
export function startServer(port) {
|
|
39
|
+
const clients = new Set();
|
|
40
|
+
const changed = new Set();
|
|
41
|
+
let timer;
|
|
42
|
+
// Editors write files in bursts; one event per quiet moment is enough.
|
|
43
|
+
const watcher = watch(ROOT, { recursive: true }, (_event, file) => {
|
|
44
|
+
if (!file || file.startsWith('renders'))
|
|
45
|
+
return;
|
|
46
|
+
changed.add(file.toString());
|
|
47
|
+
clearTimeout(timer);
|
|
48
|
+
timer = setTimeout(() => {
|
|
49
|
+
const data = `data: ${JSON.stringify({ paths: [...changed] })}\n\n`;
|
|
50
|
+
changed.clear();
|
|
51
|
+
for (const c of clients)
|
|
52
|
+
c.write(data);
|
|
53
|
+
}, 150);
|
|
54
|
+
});
|
|
55
|
+
const keepAlive = setInterval(() => {
|
|
56
|
+
for (const c of clients)
|
|
57
|
+
c.write(': ping\n\n');
|
|
58
|
+
}, 25_000);
|
|
59
|
+
const server = createServer((req, res) => {
|
|
60
|
+
const url = new URL(req.url ?? '/', 'http://lab');
|
|
61
|
+
const json = (v) => {
|
|
62
|
+
res.writeHead(200, { 'content-type': TYPES['.json'], 'cache-control': 'no-store' });
|
|
63
|
+
res.end(JSON.stringify(v));
|
|
64
|
+
};
|
|
65
|
+
if (url.pathname === '/') {
|
|
66
|
+
res.writeHead(200, { 'content-type': TYPES['.html'], 'cache-control': 'no-store' });
|
|
67
|
+
return res.end(readFileSync(CANVAS));
|
|
68
|
+
}
|
|
69
|
+
if (url.pathname === '/design') {
|
|
70
|
+
res.writeHead(200, { 'content-type': TYPES['.html'], 'cache-control': 'no-store' });
|
|
71
|
+
return res.end(designPage());
|
|
72
|
+
}
|
|
73
|
+
if (url.pathname === '/compare/index.html' && !existsSync(join(ROOT, 'compare/index.html'))) {
|
|
74
|
+
res.writeHead(200, { 'content-type': TYPES['.html'], 'cache-control': 'no-store' });
|
|
75
|
+
return res.end('<body style="background:#0f0f11;color:#a1a1aa;font:15px system-ui;padding:32px">No comparison yet: run <code>stitch2 compare</code> with the app running.</body>');
|
|
76
|
+
}
|
|
77
|
+
if (url.pathname === '/consistency/index.html' && !existsSync(join(ROOT, 'consistency/index.html'))) {
|
|
78
|
+
res.writeHead(200, { 'content-type': TYPES['.html'], 'cache-control': 'no-store' });
|
|
79
|
+
return res.end('<body style="background:#0f0f11;color:#a1a1aa;font:15px system-ui;padding:32px">No consistency report yet: run <code>stitch2 consistency</code>.</body>');
|
|
80
|
+
}
|
|
81
|
+
if (url.pathname === '/api/screens/meta' && req.method === 'POST') {
|
|
82
|
+
let body = '';
|
|
83
|
+
req.on('data', (c) => {
|
|
84
|
+
body += c;
|
|
85
|
+
});
|
|
86
|
+
req.on('end', () => {
|
|
87
|
+
try {
|
|
88
|
+
const { path, status, note } = JSON.parse(body);
|
|
89
|
+
const all = listScreens();
|
|
90
|
+
const target = all.find((s) => s.path === path);
|
|
91
|
+
if (!target || (status && !STATUSES.includes(status)))
|
|
92
|
+
throw new Error('unknown screen or status');
|
|
93
|
+
const values = {};
|
|
94
|
+
if (status)
|
|
95
|
+
values[tag('status')] = status;
|
|
96
|
+
if (note !== undefined)
|
|
97
|
+
values[tag('note')] = note.trim() || undefined;
|
|
98
|
+
writeMeta(path, values);
|
|
99
|
+
// One approved version per screen and device: approving archives the previous one.
|
|
100
|
+
if (status === 'approved')
|
|
101
|
+
for (const s of all)
|
|
102
|
+
if (s.path !== path &&
|
|
103
|
+
s.screen === target.screen &&
|
|
104
|
+
s.device === target.device &&
|
|
105
|
+
s.status === 'approved')
|
|
106
|
+
writeMeta(s.path, { [tag('status')]: 'archived' });
|
|
107
|
+
json(listScreens());
|
|
108
|
+
}
|
|
109
|
+
catch (e) {
|
|
110
|
+
res.writeHead(400, { 'content-type': 'text/plain' });
|
|
111
|
+
res.end(String(e));
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
if (url.pathname === '/api/config')
|
|
117
|
+
return json(config);
|
|
118
|
+
if (url.pathname === '/api/screens')
|
|
119
|
+
return json(listScreens());
|
|
120
|
+
if (url.pathname === '/api/checks') {
|
|
121
|
+
const file = join(ROOT, 'checks.json');
|
|
122
|
+
return json(existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : { screens: {} });
|
|
123
|
+
}
|
|
124
|
+
if (url.pathname === '/events') {
|
|
125
|
+
res.writeHead(200, {
|
|
126
|
+
'content-type': 'text/event-stream',
|
|
127
|
+
'cache-control': 'no-store',
|
|
128
|
+
connection: 'keep-alive',
|
|
129
|
+
});
|
|
130
|
+
res.write(': connected\n\n');
|
|
131
|
+
clients.add(res);
|
|
132
|
+
req.on('close', () => clients.delete(res));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
const file = normalize(join(ROOT, decodeURIComponent(url.pathname)));
|
|
136
|
+
if (!file.startsWith(ROOT) || !existsSync(file) || !statSync(file).isFile()) {
|
|
137
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
138
|
+
return res.end('not found');
|
|
139
|
+
}
|
|
140
|
+
res.writeHead(200, {
|
|
141
|
+
'content-type': TYPES[extname(file)] ?? 'application/octet-stream',
|
|
142
|
+
'cache-control': 'no-store',
|
|
143
|
+
});
|
|
144
|
+
// The canvas loads screens with ?pick: their elements carry source lines for picking.
|
|
145
|
+
if (extname(file) === '.html') {
|
|
146
|
+
const html = inlineTailwind(readFileSync(file, 'utf8'), file);
|
|
147
|
+
res.end(url.searchParams.has('pick') ? annotate(html) : html);
|
|
148
|
+
}
|
|
149
|
+
else
|
|
150
|
+
res.end(readFileSync(file));
|
|
151
|
+
});
|
|
152
|
+
return new Promise((resolve) => {
|
|
153
|
+
// Loopback only, on both IPv4 and IPv6: port forwarders often reach "localhost" as ::1. HOST overrides it
|
|
154
|
+
// (HOST=0.0.0.0 to open the canvas to the network).
|
|
155
|
+
const host = process.env.HOST ?? '127.0.0.1';
|
|
156
|
+
let v6;
|
|
157
|
+
server.listen(port, host, () => {
|
|
158
|
+
const address = server.address();
|
|
159
|
+
const actual = typeof address === 'object' && address ? address.port : port;
|
|
160
|
+
if (!process.env.HOST) {
|
|
161
|
+
v6 = createServer((req, res) => server.emit('request', req, res));
|
|
162
|
+
v6.on('error', () => { }).listen(actual, '::1');
|
|
163
|
+
}
|
|
164
|
+
resolve({
|
|
165
|
+
port: actual,
|
|
166
|
+
close: () => {
|
|
167
|
+
watcher.close();
|
|
168
|
+
clearInterval(keepAlive);
|
|
169
|
+
for (const c of clients)
|
|
170
|
+
c.end();
|
|
171
|
+
server.close();
|
|
172
|
+
v6?.close();
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
179
|
+
const { port } = await startServer(Number(process.env.PORT ?? 4400));
|
|
180
|
+
console.log(`${config.name}: http://localhost:${port} (${CONFIG_FILE ?? 'no stitch2.config.json, defaults'}; screens from ${config.screenDirs.join(', ')})`);
|
|
181
|
+
}
|
package/dist/setup.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|