create-imagine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +40 -0
- package/bin/create-imagine.js +407 -0
- package/package.json +19 -0
- package/templates/blank/README.md +96 -0
- package/templates/blank/gitignore +6 -0
- package/templates/blank/index.html +13 -0
- package/templates/blank/package.json +42 -0
- package/templates/blank/projects/example/figures/hello-world.tsx +45 -0
- package/templates/blank/projects/example/manifest.ts +44 -0
- package/templates/blank/projects/example/project.ts +16 -0
- package/templates/blank/projects/example/props.json +4 -0
- package/templates/blank/scripts/list.ts +46 -0
- package/templates/blank/scripts/projects.ts +43 -0
- package/templates/blank/scripts/render.ts +288 -0
- package/templates/blank/scripts/server.ts +117 -0
- package/templates/blank/src/core/__tests__/controls.test.ts +14 -0
- package/templates/blank/src/core/__tests__/manifest.test.ts +45 -0
- package/templates/blank/src/core/__tests__/props.test.ts +9 -0
- package/templates/blank/src/core/controls.ts +14 -0
- package/templates/blank/src/core/manifest.ts +134 -0
- package/templates/blank/src/core/props.ts +7 -0
- package/templates/blank/src/framework/Figure.tsx +34 -0
- package/templates/blank/src/framework/__tests__/sizing.test.ts +29 -0
- package/templates/blank/src/framework/charts/Axes.tsx +103 -0
- package/templates/blank/src/framework/charts/GridLines.tsx +59 -0
- package/templates/blank/src/framework/charts/Legend.tsx +31 -0
- package/templates/blank/src/framework/charts/Series.tsx +50 -0
- package/templates/blank/src/framework/charts/scales.ts +19 -0
- package/templates/blank/src/framework/diagrams/primitives.tsx +134 -0
- package/templates/blank/src/framework/layout/PanelGrid.tsx +60 -0
- package/templates/blank/src/framework/math/MathSvg.tsx +35 -0
- package/templates/blank/src/framework/math/mathjax.ts +64 -0
- package/templates/blank/src/framework/sizing.ts +28 -0
- package/templates/blank/src/framework/theme.ts +35 -0
- package/templates/blank/src/framework/types.ts +42 -0
- package/templates/blank/src/main.tsx +11 -0
- package/templates/blank/src/studio/StudioApp.tsx +130 -0
- package/templates/blank/src/studio/StudioRoot.tsx +14 -0
- package/templates/blank/src/studio/base64url.ts +8 -0
- package/templates/blank/src/studio/figureLoader.ts +30 -0
- package/templates/blank/src/studio/projectLoader.ts +40 -0
- package/templates/blank/src/studio/propsApi.ts +26 -0
- package/templates/blank/src/studio/routes/FigureView.tsx +365 -0
- package/templates/blank/src/studio/routes/ProjectHome.tsx +107 -0
- package/templates/blank/src/studio/routes/ProjectsHome.tsx +63 -0
- package/templates/blank/src/studio/routes/RenderView.tsx +123 -0
- package/templates/blank/src/studio/studio.css +540 -0
- package/templates/blank/src/studio/useProjectProps.ts +129 -0
- package/templates/blank/src/vite-env.d.ts +2 -0
- package/templates/blank/tsconfig.json +20 -0
- package/templates/blank/vite.config.ts +82 -0
- package/templates/blank/vitest.config.ts +8 -0
- package/templates/example/README.md +96 -0
- package/templates/example/gitignore +6 -0
- package/templates/example/index.html +13 -0
- package/templates/example/package.json +42 -0
- package/templates/example/projects/example/figures/ai-agent-architecture.tsx +133 -0
- package/templates/example/projects/example/figures/equation.tsx +29 -0
- package/templates/example/projects/example/figures/hello-world.tsx +45 -0
- package/templates/example/projects/example/figures/line-chart.tsx +80 -0
- package/templates/example/projects/example/figures/multi-panel.tsx +51 -0
- package/templates/example/projects/example/figures/pipeline-diagram.tsx +51 -0
- package/templates/example/projects/example/manifest.ts +161 -0
- package/templates/example/projects/example/project.ts +31 -0
- package/templates/example/projects/example/props.json +10 -0
- package/templates/example/public/projects/example/previews/ai-agent-architecture--default.png +0 -0
- package/templates/example/public/projects/example/previews/equation--default.png +0 -0
- package/templates/example/public/projects/example/previews/hello-world--default.png +0 -0
- package/templates/example/public/projects/example/previews/line-chart--default.png +0 -0
- package/templates/example/public/projects/example/previews/multi-panel--default.png +0 -0
- package/templates/example/public/projects/example/previews/pipeline-diagram--default.png +0 -0
- package/templates/example/scripts/list.ts +46 -0
- package/templates/example/scripts/projects.ts +43 -0
- package/templates/example/scripts/render.ts +288 -0
- package/templates/example/scripts/server.ts +117 -0
- package/templates/example/src/core/__tests__/controls.test.ts +14 -0
- package/templates/example/src/core/__tests__/manifest.test.ts +45 -0
- package/templates/example/src/core/__tests__/props.test.ts +9 -0
- package/templates/example/src/core/controls.ts +14 -0
- package/templates/example/src/core/manifest.ts +134 -0
- package/templates/example/src/core/props.ts +7 -0
- package/templates/example/src/framework/Figure.tsx +34 -0
- package/templates/example/src/framework/__tests__/sizing.test.ts +29 -0
- package/templates/example/src/framework/charts/Axes.tsx +103 -0
- package/templates/example/src/framework/charts/GridLines.tsx +59 -0
- package/templates/example/src/framework/charts/Legend.tsx +31 -0
- package/templates/example/src/framework/charts/Series.tsx +50 -0
- package/templates/example/src/framework/charts/scales.ts +19 -0
- package/templates/example/src/framework/diagrams/primitives.tsx +134 -0
- package/templates/example/src/framework/layout/PanelGrid.tsx +60 -0
- package/templates/example/src/framework/math/MathSvg.tsx +35 -0
- package/templates/example/src/framework/math/mathjax.ts +64 -0
- package/templates/example/src/framework/sizing.ts +28 -0
- package/templates/example/src/framework/theme.ts +35 -0
- package/templates/example/src/framework/types.ts +42 -0
- package/templates/example/src/main.tsx +11 -0
- package/templates/example/src/studio/StudioApp.tsx +130 -0
- package/templates/example/src/studio/StudioRoot.tsx +14 -0
- package/templates/example/src/studio/base64url.ts +8 -0
- package/templates/example/src/studio/figureLoader.ts +30 -0
- package/templates/example/src/studio/projectLoader.ts +40 -0
- package/templates/example/src/studio/propsApi.ts +26 -0
- package/templates/example/src/studio/routes/FigureView.tsx +365 -0
- package/templates/example/src/studio/routes/ProjectHome.tsx +107 -0
- package/templates/example/src/studio/routes/ProjectsHome.tsx +63 -0
- package/templates/example/src/studio/routes/RenderView.tsx +123 -0
- package/templates/example/src/studio/studio.css +540 -0
- package/templates/example/src/studio/useProjectProps.ts +129 -0
- package/templates/example/src/vite-env.d.ts +2 -0
- package/templates/example/tsconfig.json +20 -0
- package/templates/example/vite.config.ts +82 -0
- package/templates/example/vitest.config.ts +8 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import type { FigureManifestItem } from '../../src/core/manifest';
|
|
2
|
+
|
|
3
|
+
export const figures: FigureManifestItem[] = [
|
|
4
|
+
{
|
|
5
|
+
id: 'hello-world',
|
|
6
|
+
title: 'Hello world',
|
|
7
|
+
moduleKey: 'hello-world',
|
|
8
|
+
size: { unit: 'px', width: 900, height: 520 },
|
|
9
|
+
variants: [
|
|
10
|
+
{
|
|
11
|
+
id: 'default',
|
|
12
|
+
title: 'Default',
|
|
13
|
+
background: 'white',
|
|
14
|
+
props: {
|
|
15
|
+
heading: 'Imagine',
|
|
16
|
+
subtitle: 'React components → scientific figures (PNG + SVG)',
|
|
17
|
+
tipHeading: 'Tips',
|
|
18
|
+
tip1: 'Edit the figure component and watch this update live.',
|
|
19
|
+
tip2: 'Use the Controls panel to adjust text, then export via `npm run render`.'
|
|
20
|
+
},
|
|
21
|
+
controls: [
|
|
22
|
+
{ kind: 'text', key: 'heading', label: 'Heading' },
|
|
23
|
+
{ kind: 'text', key: 'subtitle', label: 'Subtitle' },
|
|
24
|
+
{ kind: 'text', key: 'tipHeading', label: 'Tips heading' },
|
|
25
|
+
{ kind: 'text', key: 'tip1', label: 'Tip #1' },
|
|
26
|
+
{ kind: 'text', key: 'tip2', label: 'Tip #2' }
|
|
27
|
+
]
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: 'transparent',
|
|
31
|
+
title: 'Transparent',
|
|
32
|
+
background: 'transparent',
|
|
33
|
+
props: {
|
|
34
|
+
heading: 'Imagine',
|
|
35
|
+
subtitle: 'Transparent background variant',
|
|
36
|
+
tipHeading: 'Notes',
|
|
37
|
+
tip1: 'Checkerboard mode helps preview transparency.',
|
|
38
|
+
tip2: 'PNG export uses omitBackground for transparency.'
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
]
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'line-chart',
|
|
45
|
+
title: 'Line chart',
|
|
46
|
+
moduleKey: 'line-chart',
|
|
47
|
+
size: { unit: 'mm', width: 85, height: 60, dpi: 600 },
|
|
48
|
+
variants: [
|
|
49
|
+
{
|
|
50
|
+
id: 'default',
|
|
51
|
+
title: 'Default',
|
|
52
|
+
background: 'white',
|
|
53
|
+
props: {
|
|
54
|
+
title: 'Example: signal over time',
|
|
55
|
+
xLabel: 'Time (a.u.)',
|
|
56
|
+
yLabel: 'Response',
|
|
57
|
+
seriesALabel: 'Condition A',
|
|
58
|
+
seriesBLabel: 'Condition B'
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
]
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
id: 'pipeline-diagram',
|
|
65
|
+
title: 'Pipeline diagram',
|
|
66
|
+
moduleKey: 'pipeline-diagram',
|
|
67
|
+
size: { unit: 'px', width: 1000, height: 380 },
|
|
68
|
+
variants: [
|
|
69
|
+
{
|
|
70
|
+
id: 'default',
|
|
71
|
+
title: 'Default',
|
|
72
|
+
background: 'white',
|
|
73
|
+
props: {
|
|
74
|
+
title: 'Example: analysis pipeline',
|
|
75
|
+
subtitle: 'Pure-SVG boxes/arrows + theme tokens',
|
|
76
|
+
step1: 'Ingest',
|
|
77
|
+
step2: 'Process',
|
|
78
|
+
step3: 'Publish',
|
|
79
|
+
callout: 'Edit labels in Controls'
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
id: 'multi-panel',
|
|
86
|
+
title: 'Multi-panel layout',
|
|
87
|
+
moduleKey: 'multi-panel',
|
|
88
|
+
size: { unit: 'px', width: 1100, height: 650 },
|
|
89
|
+
variants: [
|
|
90
|
+
{
|
|
91
|
+
id: 'default',
|
|
92
|
+
title: 'Default',
|
|
93
|
+
background: 'white',
|
|
94
|
+
props: {
|
|
95
|
+
title: 'Example: multi-panel figure',
|
|
96
|
+
subtitle: 'Use PanelGrid to compose sub-panels (a, b, c…)',
|
|
97
|
+
panelA: 'Panel: raw',
|
|
98
|
+
panelB: 'Panel: processed',
|
|
99
|
+
panelC: 'Panel: ablation',
|
|
100
|
+
panelD: 'Panel: summary'
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
id: 'equation',
|
|
107
|
+
title: 'Equation (MathJax SVG)',
|
|
108
|
+
moduleKey: 'equation',
|
|
109
|
+
size: { unit: 'px', width: 900, height: 260 },
|
|
110
|
+
variants: [
|
|
111
|
+
{
|
|
112
|
+
id: 'default',
|
|
113
|
+
title: 'Default',
|
|
114
|
+
background: 'white',
|
|
115
|
+
props: {
|
|
116
|
+
title: 'Example: equation (MathJax SVG)',
|
|
117
|
+
subtitle: 'Uses MathJax tex2svg (pure SVG; no foreignObject)',
|
|
118
|
+
tex: String.raw`\\hat{\\beta}=\\arg\\min_{\\beta}\\;\\|y-X\\beta\\|_2^2+\\lambda\\|\\beta\\|_1`
|
|
119
|
+
},
|
|
120
|
+
controls: [{ kind: 'text', key: 'tex', label: 'LaTeX', multiline: true }]
|
|
121
|
+
}
|
|
122
|
+
]
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
id: 'ai-agent-architecture',
|
|
126
|
+
title: 'AI Agent Architecture',
|
|
127
|
+
moduleKey: 'ai-agent-architecture',
|
|
128
|
+
size: { unit: 'px', width: 1100, height: 700 },
|
|
129
|
+
variants: [
|
|
130
|
+
{
|
|
131
|
+
id: 'default',
|
|
132
|
+
title: 'Default',
|
|
133
|
+
background: 'white',
|
|
134
|
+
props: {
|
|
135
|
+
title: 'AI Agent Architecture',
|
|
136
|
+
subtitle: 'Multi-agent system with tool use and memory',
|
|
137
|
+
userInput: 'User Input',
|
|
138
|
+
orchestrator: 'Orchestrator',
|
|
139
|
+
memory: 'Memory / Context',
|
|
140
|
+
llm: 'LLM / Model',
|
|
141
|
+
tools1: 'Tool A',
|
|
142
|
+
tools2: 'Tool B',
|
|
143
|
+
tools3: 'Tool C',
|
|
144
|
+
output: 'Response',
|
|
145
|
+
notes: 'Components communicate via function calling'
|
|
146
|
+
},
|
|
147
|
+
controls: [
|
|
148
|
+
{ kind: 'text', key: 'userInput', label: 'User Input Label' },
|
|
149
|
+
{ kind: 'text', key: 'orchestrator', label: 'Orchestrator Label' },
|
|
150
|
+
{ kind: 'text', key: 'llm', label: 'LLM Label' },
|
|
151
|
+
{ kind: 'text', key: 'memory', label: 'Memory Label' },
|
|
152
|
+
{ kind: 'text', key: 'tools1', label: 'Tool 1 Label' },
|
|
153
|
+
{ kind: 'text', key: 'tools2', label: 'Tool 2 Label' },
|
|
154
|
+
{ kind: 'text', key: 'tools3', label: 'Tool 3 Label' },
|
|
155
|
+
{ kind: 'text', key: 'output', label: 'Output Label' }
|
|
156
|
+
]
|
|
157
|
+
}
|
|
158
|
+
]
|
|
159
|
+
}
|
|
160
|
+
];
|
|
161
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ProjectDefinition } from '../../src/core/manifest';
|
|
2
|
+
import { validateProject } from '../../src/core/manifest';
|
|
3
|
+
import { figures } from './manifest';
|
|
4
|
+
|
|
5
|
+
const project: ProjectDefinition = {
|
|
6
|
+
id: 'example',
|
|
7
|
+
title: 'Example project',
|
|
8
|
+
description: 'Starter figures (charts/diagrams/layout/math) with editable text controls.',
|
|
9
|
+
examples: [
|
|
10
|
+
{
|
|
11
|
+
figureId: 'hello-world',
|
|
12
|
+
variantId: 'default',
|
|
13
|
+
src: '/projects/example/previews/hello-world--default.png',
|
|
14
|
+
caption: 'Hello world'
|
|
15
|
+
},
|
|
16
|
+
{ figureId: 'line-chart', variantId: 'default', src: '/projects/example/previews/line-chart--default.png', caption: 'Line chart' },
|
|
17
|
+
{
|
|
18
|
+
figureId: 'pipeline-diagram',
|
|
19
|
+
variantId: 'default',
|
|
20
|
+
src: '/projects/example/previews/pipeline-diagram--default.png',
|
|
21
|
+
caption: 'Pipeline diagram'
|
|
22
|
+
},
|
|
23
|
+
{ figureId: 'multi-panel', variantId: 'default', src: '/projects/example/previews/multi-panel--default.png', caption: 'Multi-panel' },
|
|
24
|
+
{ figureId: 'equation', variantId: 'default', src: '/projects/example/previews/equation--default.png', caption: 'Equation' }
|
|
25
|
+
],
|
|
26
|
+
figures
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
validateProject(project);
|
|
30
|
+
|
|
31
|
+
export default project;
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { loadAllProjects, loadProjectDefinition } from './projects';
|
|
2
|
+
|
|
3
|
+
function hasFlag(name: string) {
|
|
4
|
+
return process.argv.includes(name);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function getFlag(name: string): string | undefined {
|
|
8
|
+
const idx = process.argv.findIndex((a) => a === name || a.startsWith(`${name}=`));
|
|
9
|
+
if (idx === -1) return undefined;
|
|
10
|
+
const a = process.argv[idx]!;
|
|
11
|
+
if (a.startsWith(`${name}=`)) return a.slice(name.length + 1);
|
|
12
|
+
return process.argv[idx + 1];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (hasFlag('--json')) {
|
|
16
|
+
const projectId = getFlag('--project');
|
|
17
|
+
if (projectId) {
|
|
18
|
+
const project = await loadProjectDefinition(projectId);
|
|
19
|
+
process.stdout.write(JSON.stringify({ project }, null, 2) + '\n');
|
|
20
|
+
process.exit(0);
|
|
21
|
+
} else {
|
|
22
|
+
const projects = await loadAllProjects();
|
|
23
|
+
process.stdout.write(JSON.stringify({ projects }, null, 2) + '\n');
|
|
24
|
+
process.exit(0);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const projectId = getFlag('--project');
|
|
29
|
+
if (!projectId) {
|
|
30
|
+
const projects = await loadAllProjects();
|
|
31
|
+
for (const p of projects) {
|
|
32
|
+
// eslint-disable-next-line no-console
|
|
33
|
+
console.log(`${p.id} — ${p.title} (${p.figures.length} figure${p.figures.length === 1 ? '' : 's'})`);
|
|
34
|
+
}
|
|
35
|
+
process.exit(0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const project = await loadProjectDefinition(projectId);
|
|
39
|
+
for (const fig of project.figures) {
|
|
40
|
+
// eslint-disable-next-line no-console
|
|
41
|
+
console.log(`${fig.id} — ${fig.title} (${fig.variants.length} variant${fig.variants.length === 1 ? '' : 's'})`);
|
|
42
|
+
for (const v of fig.variants) {
|
|
43
|
+
// eslint-disable-next-line no-console
|
|
44
|
+
console.log(` - ${v.id}${v.title ? ` — ${v.title}` : ''}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { pathToFileURL } from 'node:url';
|
|
4
|
+
import type { ProjectDefinition } from '../src/core/manifest';
|
|
5
|
+
import { PROJECT_ID_RE } from '../src/core/manifest';
|
|
6
|
+
|
|
7
|
+
async function fileExists(filePath: string): Promise<boolean> {
|
|
8
|
+
return fs
|
|
9
|
+
.stat(filePath)
|
|
10
|
+
.then((s) => s.isFile())
|
|
11
|
+
.catch(() => false);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function discoverProjectIds(): Promise<string[]> {
|
|
15
|
+
const projectsDir = path.resolve(process.cwd(), 'projects');
|
|
16
|
+
const entries = await fs.readdir(projectsDir, { withFileTypes: true }).catch(() => []);
|
|
17
|
+
const ids: string[] = [];
|
|
18
|
+
|
|
19
|
+
for (const e of entries) {
|
|
20
|
+
if (!e.isDirectory()) continue;
|
|
21
|
+
if (!PROJECT_ID_RE.test(e.name)) continue;
|
|
22
|
+
const p = path.join(projectsDir, e.name, 'project.ts');
|
|
23
|
+
if (await fileExists(p)) ids.push(e.name);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return ids.sort();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function loadProjectDefinition(projectId: string): Promise<ProjectDefinition> {
|
|
30
|
+
if (!PROJECT_ID_RE.test(projectId)) throw new Error(`Invalid project id: ${projectId}`);
|
|
31
|
+
const projectPath = path.resolve(process.cwd(), 'projects', projectId, 'project.ts');
|
|
32
|
+
const mod = await import(pathToFileURL(projectPath).href);
|
|
33
|
+
const project = (mod as any).default as ProjectDefinition | undefined;
|
|
34
|
+
if (!project) throw new Error(`Project did not default-export a ProjectDefinition: ${projectPath}`);
|
|
35
|
+
return project;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function loadAllProjects(): Promise<ProjectDefinition[]> {
|
|
39
|
+
const ids = await discoverProjectIds();
|
|
40
|
+
const projects = await Promise.all(ids.map((id) => loadProjectDefinition(id)));
|
|
41
|
+
return projects.slice().sort((a, b) => a.title.localeCompare(b.title));
|
|
42
|
+
}
|
|
43
|
+
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs/promises';
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { chromium } from 'playwright';
|
|
5
|
+
import type { FigureManifestItem, FigureVariant } from '../src/core/manifest';
|
|
6
|
+
import { resolveSize } from '../src/framework/sizing';
|
|
7
|
+
import { startStaticServer } from './server';
|
|
8
|
+
import { emptyPropsFile, validatePropsFileV1 } from '../src/core/manifest';
|
|
9
|
+
import { loadProjectDefinition } from './projects';
|
|
10
|
+
|
|
11
|
+
type Mode = 'build' | 'dev';
|
|
12
|
+
|
|
13
|
+
function platformNpmBin() {
|
|
14
|
+
return process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function base64UrlEncode(input: string): string {
|
|
18
|
+
return Buffer.from(input, 'utf8')
|
|
19
|
+
.toString('base64')
|
|
20
|
+
.replace(/\+/g, '-')
|
|
21
|
+
.replace(/\//g, '_')
|
|
22
|
+
.replace(/=+$/g, '');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function run(cmd: string, args: string[], opts: { cwd?: string } = {}) {
|
|
26
|
+
return new Promise<void>((resolve, reject) => {
|
|
27
|
+
const child = spawn(cmd, args, {
|
|
28
|
+
cwd: opts.cwd ?? process.cwd(),
|
|
29
|
+
stdio: 'inherit',
|
|
30
|
+
env: process.env
|
|
31
|
+
});
|
|
32
|
+
child.on('exit', (code) => {
|
|
33
|
+
if (code === 0) resolve();
|
|
34
|
+
else reject(new Error(`${cmd} ${args.join(' ')} exited with code ${code}`));
|
|
35
|
+
});
|
|
36
|
+
child.on('error', reject);
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function splitList(value: string | undefined): string[] {
|
|
41
|
+
if (!value) return [];
|
|
42
|
+
return value
|
|
43
|
+
.split(',')
|
|
44
|
+
.map((s) => s.trim())
|
|
45
|
+
.filter(Boolean);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function collectFlagValues(name: string): string[] {
|
|
49
|
+
const out: string[] = [];
|
|
50
|
+
for (let i = 0; i < process.argv.length; i++) {
|
|
51
|
+
const a = process.argv[i]!;
|
|
52
|
+
if (a === name) {
|
|
53
|
+
const v = process.argv[i + 1];
|
|
54
|
+
out.push(...splitList(v));
|
|
55
|
+
i += 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (a.startsWith(`${name}=`)) {
|
|
59
|
+
out.push(...splitList(a.slice(name.length + 1)));
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getFlag(name: string): string | undefined {
|
|
66
|
+
const idx = process.argv.findIndex((a) => a === name || a.startsWith(`${name}=`));
|
|
67
|
+
if (idx === -1) return undefined;
|
|
68
|
+
const a = process.argv[idx]!;
|
|
69
|
+
if (a.startsWith(`${name}=`)) return a.slice(name.length + 1);
|
|
70
|
+
return process.argv[idx + 1];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function hasFlag(name: string): boolean {
|
|
74
|
+
return process.argv.includes(name);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function safeFilePart(input: string): string {
|
|
78
|
+
return input.replace(/[^a-zA-Z0-9_-]+/g, '-');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function usage() {
|
|
82
|
+
const text = `
|
|
83
|
+
Imagine renderer
|
|
84
|
+
|
|
85
|
+
Usage:
|
|
86
|
+
npm run render -- [options]
|
|
87
|
+
|
|
88
|
+
Options:
|
|
89
|
+
--project <id> Project id. Defaults to "example".
|
|
90
|
+
--fig <id> Limit to figure id(s). Repeatable or comma-separated.
|
|
91
|
+
--variant <id> Limit to variant id(s). Repeatable or comma-separated.
|
|
92
|
+
--formats png,svg Defaults to "png,svg".
|
|
93
|
+
--out <dir> Defaults to "out/<projectId>".
|
|
94
|
+
--mode build|dev Defaults to "build".
|
|
95
|
+
--url <url> Dev server URL (dev mode). Defaults to http://localhost:5173
|
|
96
|
+
--props-file <path> Defaults to "projects/<projectId>/props.json" (if exists).
|
|
97
|
+
--no-props Ignore props overrides file.
|
|
98
|
+
--no-manifest Do not write out/manifest.json.
|
|
99
|
+
--help
|
|
100
|
+
`.trim();
|
|
101
|
+
// eslint-disable-next-line no-console
|
|
102
|
+
console.log(text);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
type RenderTarget = {
|
|
106
|
+
figure: FigureManifestItem;
|
|
107
|
+
variant: FigureVariant;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
function selectTargets({
|
|
111
|
+
figures,
|
|
112
|
+
figureIds,
|
|
113
|
+
variantIds
|
|
114
|
+
}: {
|
|
115
|
+
figures: FigureManifestItem[];
|
|
116
|
+
figureIds: string[];
|
|
117
|
+
variantIds: string[];
|
|
118
|
+
}): RenderTarget[] {
|
|
119
|
+
const figSet = new Set(figureIds);
|
|
120
|
+
const varSet = new Set(variantIds);
|
|
121
|
+
|
|
122
|
+
const selectedFigures = figureIds.length ? figures.filter((f) => figSet.has(f.id)) : figures;
|
|
123
|
+
const out: RenderTarget[] = [];
|
|
124
|
+
|
|
125
|
+
for (const f of selectedFigures) {
|
|
126
|
+
const variants = variantIds.length ? f.variants.filter((v) => varSet.has(v.id)) : f.variants;
|
|
127
|
+
for (const v of variants) out.push({ figure: f, variant: v });
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function main() {
|
|
133
|
+
if (hasFlag('--help') || hasFlag('-h')) {
|
|
134
|
+
usage();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const projectId = getFlag('--project') ?? 'example';
|
|
139
|
+
const figureIds = collectFlagValues('--fig');
|
|
140
|
+
const variantIds = collectFlagValues('--variant');
|
|
141
|
+
const formats = new Set(splitList(getFlag('--formats') ?? 'png,svg').map((s) => s.toLowerCase()));
|
|
142
|
+
const outDir = path.resolve(process.cwd(), getFlag('--out') ?? path.join('out', projectId));
|
|
143
|
+
const mode = ((getFlag('--mode') ?? 'build') as Mode) satisfies Mode;
|
|
144
|
+
const url = getFlag('--url') ?? 'http://localhost:5173';
|
|
145
|
+
const noProps = hasFlag('--no-props');
|
|
146
|
+
const noManifest = hasFlag('--no-manifest');
|
|
147
|
+
const propsFilePath = getFlag('--props-file') ?? path.join('projects', projectId, 'props.json');
|
|
148
|
+
|
|
149
|
+
if (mode !== 'build' && mode !== 'dev') {
|
|
150
|
+
throw new Error(`Invalid --mode: ${mode}`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (![...formats].every((f) => f === 'png' || f === 'svg')) {
|
|
154
|
+
throw new Error(`Invalid --formats: ${[...formats].join(',')}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const project = await loadProjectDefinition(projectId);
|
|
158
|
+
const targets = selectTargets({ figures: project.figures, figureIds, variantIds });
|
|
159
|
+
if (!targets.length) {
|
|
160
|
+
throw new Error(`No render targets matched. --fig=${figureIds.join(',')} --variant=${variantIds.join(',')}`);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
await fs.mkdir(outDir, { recursive: true });
|
|
164
|
+
|
|
165
|
+
const propsFile = noProps
|
|
166
|
+
? emptyPropsFile()
|
|
167
|
+
: await fs
|
|
168
|
+
.readFile(propsFilePath, 'utf8')
|
|
169
|
+
.then((raw) => validatePropsFileV1(JSON.parse(raw)))
|
|
170
|
+
.catch(() => emptyPropsFile());
|
|
171
|
+
|
|
172
|
+
let baseUrl = url;
|
|
173
|
+
let closeServer: (() => Promise<void>) | null = null;
|
|
174
|
+
|
|
175
|
+
if (mode === 'build') {
|
|
176
|
+
await run(platformNpmBin(), ['run', 'build']);
|
|
177
|
+
const srv = await startStaticServer({ rootDir: 'dist', port: 0 });
|
|
178
|
+
baseUrl = srv.url;
|
|
179
|
+
closeServer = srv.close;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const browser = await chromium.launch();
|
|
183
|
+
const results: Array<{
|
|
184
|
+
figureId: string;
|
|
185
|
+
variantId: string;
|
|
186
|
+
size: { width: number; height: number };
|
|
187
|
+
mm?: { width: number; height: number; dpi: number };
|
|
188
|
+
outputs: { png?: string; svg?: string };
|
|
189
|
+
}> = [];
|
|
190
|
+
|
|
191
|
+
try {
|
|
192
|
+
for (const t of targets) {
|
|
193
|
+
const effectiveSize = t.variant.size ?? t.figure.size;
|
|
194
|
+
const resolved = resolveSize(effectiveSize);
|
|
195
|
+
const bg = t.variant.background ?? 'white';
|
|
196
|
+
const overrides = (propsFile.overrides[t.figure.id]?.[t.variant.id] ?? {}) as Record<string, unknown>;
|
|
197
|
+
const propsParam =
|
|
198
|
+
overrides && typeof overrides === 'object' && Object.keys(overrides).length
|
|
199
|
+
? `?props=${encodeURIComponent(base64UrlEncode(JSON.stringify(overrides)))}`
|
|
200
|
+
: '';
|
|
201
|
+
const route = `/#/render/${encodeURIComponent(projectId)}/${encodeURIComponent(t.figure.id)}/${encodeURIComponent(t.variant.id)}${propsParam}`;
|
|
202
|
+
const pageUrl = `${baseUrl}${route}`;
|
|
203
|
+
|
|
204
|
+
// eslint-disable-next-line no-console
|
|
205
|
+
console.log(`Rendering ${t.figure.id}/${t.variant.id} (${resolved.width}×${resolved.height}px) …`);
|
|
206
|
+
|
|
207
|
+
const context = await browser.newContext({
|
|
208
|
+
viewport: { width: resolved.width, height: resolved.height },
|
|
209
|
+
deviceScaleFactor: 1
|
|
210
|
+
});
|
|
211
|
+
const page = await context.newPage();
|
|
212
|
+
page.on('pageerror', (err) => {
|
|
213
|
+
// eslint-disable-next-line no-console
|
|
214
|
+
console.error(`[pageerror] ${t.figure.id}/${t.variant.id}:`, err);
|
|
215
|
+
});
|
|
216
|
+
page.on('console', (msg) => {
|
|
217
|
+
if (msg.type() !== 'error') return;
|
|
218
|
+
// eslint-disable-next-line no-console
|
|
219
|
+
console.error(`[console.${msg.type()}] ${t.figure.id}/${t.variant.id}: ${msg.text()}`);
|
|
220
|
+
});
|
|
221
|
+
await page.goto(pageUrl, { waitUntil: 'domcontentloaded' });
|
|
222
|
+
await page.waitForFunction(() => (window as any).__IMAGINE_READY__ === true, null, { timeout: 30_000 });
|
|
223
|
+
await page.locator('#figure-root').waitFor({ state: 'attached', timeout: 30_000 });
|
|
224
|
+
|
|
225
|
+
const outputs: { png?: string; svg?: string } = {};
|
|
226
|
+
const baseName = `${safeFilePart(t.figure.id)}--${safeFilePart(t.variant.id)}`;
|
|
227
|
+
|
|
228
|
+
if (formats.has('png')) {
|
|
229
|
+
const outPath = path.join(outDir, `${baseName}.png`);
|
|
230
|
+
const locator = page.locator('#figure-root');
|
|
231
|
+
await locator.screenshot({
|
|
232
|
+
path: outPath,
|
|
233
|
+
omitBackground: bg === 'transparent',
|
|
234
|
+
animations: 'disabled'
|
|
235
|
+
});
|
|
236
|
+
outputs.png = path.relative(outDir, outPath);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (formats.has('svg')) {
|
|
240
|
+
const outPath = path.join(outDir, `${baseName}.svg`);
|
|
241
|
+
const svg = await page.$eval('#figure-root svg', (el) => (el as SVGElement).outerHTML);
|
|
242
|
+
await fs.writeFile(outPath, `${svg}\n`, 'utf8');
|
|
243
|
+
outputs.svg = path.relative(outDir, outPath);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
results.push({
|
|
247
|
+
figureId: t.figure.id,
|
|
248
|
+
variantId: t.variant.id,
|
|
249
|
+
size: { width: resolved.width, height: resolved.height },
|
|
250
|
+
mm: resolved.mm && resolved.dpi ? { width: resolved.mm.width, height: resolved.mm.height, dpi: resolved.dpi } : undefined,
|
|
251
|
+
outputs
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
await context.close();
|
|
255
|
+
}
|
|
256
|
+
} finally {
|
|
257
|
+
await browser.close();
|
|
258
|
+
if (closeServer) await closeServer();
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const manifestPath = path.join(outDir, 'manifest.json');
|
|
262
|
+
if (!noManifest) {
|
|
263
|
+
await fs.writeFile(
|
|
264
|
+
manifestPath,
|
|
265
|
+
JSON.stringify(
|
|
266
|
+
{
|
|
267
|
+
generatedAt: new Date().toISOString(),
|
|
268
|
+
projectId,
|
|
269
|
+
mode,
|
|
270
|
+
baseUrl,
|
|
271
|
+
results
|
|
272
|
+
},
|
|
273
|
+
null,
|
|
274
|
+
2
|
|
275
|
+
) + '\n',
|
|
276
|
+
'utf8'
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
// eslint-disable-next-line no-console
|
|
280
|
+
console.log(`Wrote ${manifestPath}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
main().catch((err) => {
|
|
285
|
+
// eslint-disable-next-line no-console
|
|
286
|
+
console.error(err);
|
|
287
|
+
process.exitCode = 1;
|
|
288
|
+
});
|