Skip to content

Commit 4ab78c2

Browse files
committed
Deduplicate gallery: load widgets from spec examples via prebuild script
Replace 58 hand-written gallery widget files with a prebuild script that generates them from the canonical spec examples: - specification/v0_8/json/catalogs/basic/examples/ (29 widgets) - specification/v0_9/json/catalogs/basic/examples/ (33 widgets) The script (scripts/sync-gallery.mjs) runs automatically before build and dev via package.json pre-hooks. Generated files are gitignored — CI regenerates them from the spec on each build. Includes v0.8 sanitization for values the renderer doesn't support (e.g. textFieldType 'obscured' mapped to 'shortText').
1 parent 435f19b commit 4ab78c2

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

63 files changed

+234
-10599
lines changed

tools/composer/.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,7 @@ __pycache__
4949

5050
.playwright-mcp
5151
RENDERER_ISSUES.md
52+
53+
# Auto-generated gallery data (from scripts/sync-gallery.mjs)
54+
src/data/gallery/v08/generated.ts
55+
src/data/gallery/v09/generated.ts

tools/composer/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,10 @@
33
"version": "0.1.0",
44
"private": true,
55
"scripts": {
6+
"sync-gallery": "node scripts/sync-gallery.mjs",
7+
"predev": "node scripts/sync-gallery.mjs",
68
"dev": "next dev --turbopack --port 3001",
9+
"prebuild": "node scripts/sync-gallery.mjs",
710
"build": "next build",
811
"start": "next start",
912
"lint": "eslint",
Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
/**
2+
* Copyright 2026 Google LLC
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* Syncs gallery widget data from the canonical spec examples.
19+
*
20+
* Reads JSON examples from specification/v0_8 and v0_9 catalogs and
21+
* generates TypeScript files that the gallery page imports.
22+
*
23+
* Run: node scripts/sync-gallery.mjs
24+
* Runs automatically via prebuild/predev hooks in package.json.
25+
*/
26+
27+
import { readFileSync, writeFileSync, readdirSync } from 'fs';
28+
import { join, dirname } from 'path';
29+
import { fileURLToPath } from 'url';
30+
31+
const __dirname = dirname(fileURLToPath(import.meta.url));
32+
const ROOT = join(__dirname, '../../..'); // monorepo root
33+
const V08_EXAMPLES = join(ROOT, 'specification/v0_8/json/catalogs/basic/examples');
34+
const V09_EXAMPLES = join(ROOT, 'specification/v0_9/json/catalogs/basic/examples');
35+
const V08_OUT = join(__dirname, '../src/data/gallery/v08/generated.ts');
36+
const V09_OUT = join(__dirname, '../src/data/gallery/v09/generated.ts');
37+
38+
const LICENSE = `/**
39+
* Copyright 2026 Google LLC
40+
*
41+
* Licensed under the Apache License, Version 2.0 (the "License");
42+
* you may not use this file except in compliance with the License.
43+
* You may obtain a copy of the License at
44+
*
45+
* http://www.apache.org/licenses/LICENSE-2.0
46+
*
47+
* Unless required by applicable law or agreed to in writing, software
48+
* distributed under the License is distributed on an "AS IS" BASIS,
49+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
50+
* See the License for the specific language governing permissions and
51+
* limitations under the License.
52+
*/`;
53+
54+
// Gallery card heights per slug
55+
const HEIGHTS = {
56+
'flight-status': 280, 'email-compose': 420, 'calendar-day': 380,
57+
'weather-current': 320, 'product-card': 380, 'music-player': 400,
58+
'task-card': 200, 'user-profile': 420, 'login-form': 340,
59+
'notification-permission': 240, 'purchase-complete': 380, 'chat-message': 380,
60+
'coffee-order': 420, 'sports-player': 380, 'account-balance': 280,
61+
'workout-summary': 320, 'event-detail': 320, 'track-list': 380,
62+
'software-purchase': 380, 'restaurant-card': 340, 'shipping-status': 380,
63+
'credit-card': 280, 'step-counter': 320, 'recipe-card': 380,
64+
'contact-card': 400, 'podcast-episode': 300, 'stats-card': 240,
65+
'countdown-timer': 260, 'movie-card': 380,
66+
};
67+
const DEFAULT_HEIGHT = 340;
68+
69+
function slugToName(slug) {
70+
return slug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
71+
}
72+
73+
/**
74+
* Convert v0.8 ValueMap[] to plain JS object.
75+
*/
76+
function valueMapToObject(contents) {
77+
const result = {};
78+
for (const item of contents) {
79+
if ('valueString' in item) result[item.key] = item.valueString;
80+
else if ('valueNumber' in item) result[item.key] = item.valueNumber;
81+
else if ('valueBoolean' in item) result[item.key] = item.valueBoolean;
82+
else if ('valueMap' in item) result[item.key] = valueMapToObject(item.valueMap);
83+
else result[item.key] = null;
84+
}
85+
return result;
86+
}
87+
88+
/**
89+
* Sanitize v0.8 components to fix values the renderer doesn't support.
90+
*/
91+
const V08_TEXTFIELD_TYPES = new Set(['shortText', 'number', 'date', 'longText']);
92+
const V08_ALIGNMENTS = new Set(['start', 'center', 'end', 'stretch']);
93+
94+
function sanitizeV08Components(components) {
95+
return components.map(comp => {
96+
const copy = JSON.parse(JSON.stringify(comp));
97+
const inner = copy.component;
98+
if (!inner) return copy;
99+
100+
for (const [type, props] of Object.entries(inner)) {
101+
// Fix unsupported textFieldType values
102+
if (type === 'TextField' && props.textFieldType && !V08_TEXTFIELD_TYPES.has(props.textFieldType)) {
103+
props.textFieldType = 'shortText';
104+
}
105+
// Fix unsupported alignment values
106+
if ((type === 'Row' || type === 'Column') && props.alignment && !V08_ALIGNMENTS.has(props.alignment)) {
107+
props.alignment = 'center';
108+
}
109+
// Ensure action is an object, not a string
110+
if (type === 'Button' && typeof props.action === 'string') {
111+
props.action = { name: props.action };
112+
}
113+
}
114+
return copy;
115+
});
116+
}
117+
118+
/**
119+
* Extract widget data from v0.8 messages (bare array).
120+
*/
121+
function parseV08(messages, slug) {
122+
let components = [];
123+
let data = {};
124+
let root = 'root';
125+
126+
for (const msg of messages) {
127+
if (msg.surfaceUpdate) components = sanitizeV08Components(msg.surfaceUpdate.components);
128+
if (msg.dataModelUpdate?.contents) data = valueMapToObject(msg.dataModelUpdate.contents);
129+
if (msg.beginRendering?.root) root = msg.beginRendering.root;
130+
}
131+
132+
return {
133+
id: `gallery-${slug}`,
134+
name: slugToName(slug),
135+
specVersion: '0.8',
136+
root,
137+
components,
138+
data,
139+
};
140+
}
141+
142+
/**
143+
* Extract widget data from v0.9 messages ({ name, description, messages }).
144+
*/
145+
function parseV09(example, slug) {
146+
let components = [];
147+
let data = {};
148+
149+
for (const msg of example.messages) {
150+
if (msg.updateComponents) components = msg.updateComponents.components;
151+
if (msg.updateDataModel?.value) data = msg.updateDataModel.value;
152+
}
153+
154+
return {
155+
id: `gallery-v09-${slug}`,
156+
name: example.name || slugToName(slug),
157+
description: example.description || '',
158+
specVersion: '0.9',
159+
root: 'root',
160+
components,
161+
data,
162+
};
163+
}
164+
165+
function generateFile(examplesDir, version, parser) {
166+
const files = readdirSync(examplesDir).filter(f => f.endsWith('.json')).sort();
167+
const widgets = [];
168+
169+
for (const file of files) {
170+
const slug = file.replace(/^\d+_/, '').replace('.json', '');
171+
const raw = JSON.parse(readFileSync(join(examplesDir, file), 'utf-8'));
172+
const parsed = parser(raw, slug);
173+
const height = HEIGHTS[slug] || DEFAULT_HEIGHT;
174+
widgets.push({ slug, parsed, height });
175+
}
176+
177+
const json = (obj) => JSON.stringify(obj, null, 2).replace(/\n/g, '\n ');
178+
179+
const entries = widgets.map(({ slug, parsed, height }) => {
180+
const constName = slug.toUpperCase().replace(/-/g, '_');
181+
return `
182+
const ${constName} = {
183+
widget: {
184+
id: '${parsed.id}',
185+
name: ${JSON.stringify(parsed.name)},${parsed.description ? `\n description: ${JSON.stringify(parsed.description)},` : ''}
186+
createdAt: new Date('2024-01-01'),
187+
updatedAt: new Date('2024-01-01'),
188+
specVersion: '${parsed.specVersion}' as const,
189+
root: '${parsed.root}',
190+
components: ${json(parsed.components)},
191+
dataStates: [{ name: 'default', data: ${json(parsed.data)} }],
192+
},
193+
height: ${height},
194+
};`;
195+
});
196+
197+
const arrayName = version === '0.8' ? 'V08_GALLERY_WIDGETS' : 'V09_GALLERY_WIDGETS';
198+
const arrayEntries = widgets.map(({ slug }) =>
199+
` ${slug.toUpperCase().replace(/-/g, '_')},`
200+
).join('\n');
201+
202+
return `${LICENSE}
203+
204+
// AUTO-GENERATED by scripts/sync-gallery.mjs — do not edit manually.
205+
// Source: specification/${version === '0.8' ? 'v0_8' : 'v0_9'}/json/catalogs/basic/examples/
206+
207+
import type { Widget } from '@/types/widget';
208+
209+
type GalleryEntry = { widget: Widget; height: number };
210+
${entries.join('\n')}
211+
212+
export const ${arrayName}: GalleryEntry[] = [
213+
${arrayEntries}
214+
];
215+
`;
216+
}
217+
218+
// Generate both versions
219+
const v08 = generateFile(V08_EXAMPLES, '0.8', parseV08);
220+
writeFileSync(V08_OUT, v08);
221+
console.log(`[sync-gallery] v0.8: wrote ${V08_OUT}`);
222+
223+
const v09 = generateFile(V09_EXAMPLES, '0.9', parseV09);
224+
writeFileSync(V09_OUT, v09);
225+
console.log(`[sync-gallery] v0.9: wrote ${V09_OUT}`);

tools/composer/src/data/gallery/v08/account-balance.ts

Lines changed: 0 additions & 152 deletions
This file was deleted.

0 commit comments

Comments
 (0)