feat(sitesmith): renderer for Sitesmith responses (repo exporter + Firefox shots); catalog tips for ColumnLayout stacking, Navbar, Footer
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
de240eee75
commit
d83919b89a
3 files changed
+109
-3
No files matched your search
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Render a Sitesmith JSON response (a recorded fixture, or a live one saved to a file) through the
|
||||
* repo's own exporter into HTML, then take Firefox screenshots so a human can compare runs.
|
||||
*
|
||||
* node scripts/render-sitesmith-response.mjs --response resp.json --out outdir [--design design.json]
|
||||
* [--page 0] [--assets dir] [--width 1280] [--mobile 390] [--no-shots]
|
||||
*
|
||||
* --design JSON of SiteDesign overrides (primaryColor, headingFont, ...), i.e. the values the
|
||||
* SITE DESIGN prompt block carried. Defaults to the editor's default design.
|
||||
* --assets directory to expose as ./assets/ next to the HTML (symlinked) so relUrl images load.
|
||||
* Screenshots: Playwright module and Firefox binary come from $SITESMITH_PLAYWRIGHT / $SITESMITH_FIREFOX
|
||||
* (defaults below). Only type=replace responses render (patches need a canvas).
|
||||
*/
|
||||
import { build } from 'esbuild';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const craftRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (name, def) => { const i = args.indexOf('--' + name); return i >= 0 ? args[i + 1] : def; };
|
||||
const flag = (name) => args.includes('--' + name);
|
||||
const respPath = opt('response'), outDir = opt('out');
|
||||
if (!respPath || !outDir) { console.error('usage: render-sitesmith-response.mjs --response resp.json --out dir [--design design.json] [--assets dir]'); process.exit(2); }
|
||||
|
||||
const PLAYWRIGHT = process.env.SITESMITH_PLAYWRIGHT || '/tmp/claude-1000/-workspace/edbe144d-d5fe-40a1-88a3-fb8ed9709df9/scratchpad/repro/node_modules/playwright/index.mjs';
|
||||
const FIREFOX = process.env.SITESMITH_FIREFOX || '/home/claude/.cache/ms-playwright/firefox-1538/firefox/firefox';
|
||||
|
||||
const bundle = await build({
|
||||
entryPoints: [path.join(craftRoot, 'scripts/sitesmith-catalog/render-entry.ts')],
|
||||
bundle: true, write: false, format: 'cjs', platform: 'node', jsx: 'automatic', logLevel: 'error',
|
||||
define: { 'process.env.NODE_ENV': '"production"' },
|
||||
});
|
||||
const tmp = fs.mkdtempSync(path.join(process.env.TMPDIR || '/tmp', 'ssrender-'));
|
||||
fs.writeFileSync(path.join(tmp, 'r.cjs'), bundle.outputFiles[0].text);
|
||||
const { renderResponse } = createRequire(import.meta.url)(path.join(tmp, 'r.cjs'));
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
|
||||
const resp = JSON.parse(fs.readFileSync(respPath, 'utf8'));
|
||||
const design = opt('design') ? JSON.parse(fs.readFileSync(opt('design'), 'utf8')) : {};
|
||||
const html = renderResponse(resp, design, Number(opt('page', 0)));
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const htmlPath = path.join(outDir, 'page.html');
|
||||
fs.writeFileSync(htmlPath, html);
|
||||
if (opt('assets')) {
|
||||
const link = path.join(outDir, 'assets');
|
||||
fs.rmSync(link, { recursive: true, force: true });
|
||||
fs.symlinkSync(path.resolve(opt('assets')), link);
|
||||
}
|
||||
console.log(`html: ${htmlPath} (${html.length} bytes)`);
|
||||
|
||||
if (!flag('no-shots')) {
|
||||
const { firefox } = await import(pathToFileURL(PLAYWRIGHT).href);
|
||||
const browser = await firefox.launch({ executablePath: FIREFOX, headless: true });
|
||||
for (const [label, width, height] of [['desktop', Number(opt('width', 1280)), 900], ['mobile', Number(opt('mobile', 390)), 844]]) {
|
||||
const page = await browser.newPage({ viewport: { width, height } });
|
||||
const errors = [];
|
||||
page.on('pageerror', (e) => errors.push(String(e)));
|
||||
await page.goto(pathToFileURL(htmlPath).href, { waitUntil: 'networkidle', timeout: 60000 }).catch((e) => errors.push(String(e)));
|
||||
await page.evaluate(() => document.fonts && document.fonts.ready);
|
||||
// reveal entrance animations deterministically for the capture
|
||||
await page.addStyleTag({ content: '[data-animation]{opacity:1!important;animation:none!important}' });
|
||||
const shot = path.join(outDir, `${label}.png`);
|
||||
await page.screenshot({ path: shot, fullPage: true });
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth);
|
||||
console.log(`${label}: ${shot} horizontal-overflow-px=${overflow}${errors.length ? ' errors=' + JSON.stringify(errors) : ''}`);
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
}
|
||||
@@ -12,14 +12,14 @@ export const NOTES = {
|
||||
ButtonLink: { purpose: 'Link styled as a button; look comes from style (backgroundColor, color, padding, borderRadius, fontWeight)', tips: 'No variant prop: set style yourself, e.g. primary = {backgroundColor:"var(--wsb-primary)",color:"var(--wsb-bg)",padding:"14px 28px",borderRadius:"var(--wsb-button-radius)",fontWeight:"600"}; secondary = transparent bg + border.' },
|
||||
CTASection: { purpose: 'Centered call-to-action band on a gradient (prop gradient = CSS gradient string); ctas[] buttons', tips: 'Use ctas[], not buttonText/buttonHref.' },
|
||||
CallToAction: { purpose: 'Call-to-action band on colour/gradient/image (bgType+bgValue, overlay); ctas[] buttons', tips: 'Use ctas[], not buttonText/secondaryButton*.' },
|
||||
ColumnLayout: { purpose: 'Row of columns; each direct child becomes one column and stacks on mobile', tips: 'columns is set from the child count; split must be an allowed string. Put style.alignItems:"center" to centre columns vertically.' },
|
||||
ColumnLayout: { purpose: 'Row of columns; each direct child becomes one column and stacks on mobile', tips: 'ALWAYS set style display:"flex" and flexDirection:"row": published pages stack columns on phones ONLY via that inline pair (measured; without it columns stay side by side, squeezed). columns follows the child count; split must be an allowed string; style.alignItems:"center" centres vertically.' },
|
||||
ContactForm: { purpose: 'Complete contact form with fields[], submit button, spam captcha option (no children)', tips: 'Delivery is configured by the owner (recipientEmail / webhook): never invent an address; leave recipientEmail empty.' },
|
||||
Container: { purpose: 'Generic block (flow layout). Children stack vertically; use style for padding/background/maxWidth/flex', tips: 'tag is one of div|section|article|header|footer|main. The page root is a Container.' },
|
||||
ContentSlider: { purpose: 'Auto-playing slider of content/image slides', tips: 'Use sparingly; a static hero is almost always better.' },
|
||||
Countdown: { purpose: 'Countdown to targetDate (YYYY-MM-DD)', tips: 'Only for a real, user-given date.' },
|
||||
Divider: { purpose: 'Horizontal rule' },
|
||||
FeaturesGrid: { purpose: 'Responsive grid of cards {icon,title,description,image?,buttonText?,buttonUrl?}', tips: 'icon is PLAIN TEXT (a single glyph/emoji), NOT Font Awesome: never put fa-* there. For Font Awesome icon cards build ColumnLayout > Container > Icon + Heading + TextBlock instead. An image per feature (image,imageAlt) replaces the icon.' },
|
||||
Footer: { purpose: 'Simple footer text line (copyright). For richer footers compose Container/ColumnLayout/SocialLinks/Menu', tips: 'Emit in the top-level "footer" slot for scope=site.' },
|
||||
Footer: { purpose: 'Simple footer text line (copyright). For richer footers compose Container/ColumnLayout/SocialLinks/Menu', tips: 'Colour comes from style.color (default is dark text: set a light color on a dark footer). Emit in the top-level "footer" slot for scope=site.' },
|
||||
FormButton: { purpose: 'Submit button inside a FormContainer' },
|
||||
FormContainer: { purpose: 'Wrapper for hand-built forms (InputField/TextareaField/FormButton children)', tips: 'Prefer ContactForm.' },
|
||||
Gallery: { purpose: 'Image grid (images[] {src,alt,caption?}), columns, optional lightbox', tips: 'Use the user\'s uploaded images only; no placeholders in galleries.' },
|
||||
@@ -32,7 +32,7 @@ export const NOTES = {
|
||||
Logo: { purpose: 'Text or image logo linking home' },
|
||||
MapEmbed: { purpose: 'Embedded map for an address', tips: 'Only with a real address from the user.' },
|
||||
Menu: { purpose: 'Link list (horizontal/vertical) with optional CTA-styled link' },
|
||||
Navbar: { purpose: 'Top navigation bar: logo (text|image), links[] {text,href,isCta?}, sticky option', tips: 'Emit in the top-level "header" slot. In-page links use href "#anchor"; other pages "/page-slug".' },
|
||||
Navbar: { purpose: 'Top navigation bar: logo (text|image), links[] {text,href,isCta?}, sticky option', tips: 'Set showMobileMenu:true or the links overflow on phones. Emit in the top-level "header" slot. In-page links use href "#anchor"; other pages "/page-slug".' },
|
||||
NumberCounter: { purpose: 'Row of animated stats (counters[] {number,suffix,label})', tips: 'Only real figures supplied by the user.' },
|
||||
OpeningHours: { purpose: 'Opening hours table (rows[] {day,hours,closed})', tips: 'Only real hours supplied by the user.' },
|
||||
PricingTable: { purpose: 'Pricing cards (plans[] {name,price,period,features[],buttonText,buttonHref,isFeatured})', tips: 'Only real prices supplied by the user; otherwise use a "Request a quote" CTA.' },
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Bundled by scripts/render-sitesmith-response.mjs (esbuild, platform node).
|
||||
// Turns a Sitesmith JSON response into the HTML the REPO EXPORTER produces:
|
||||
// the same sanitize -> flatten -> exportBodyHtml path the editor uses.
|
||||
import { sanitizeAiTree, flattenTreeForCraft } from '../../src/utils/craft-tree';
|
||||
import { exportBodyHtml, exportComposedPreviewHtml } from '../../src/utils/html-export';
|
||||
import { DEFAULT_SITE_DESIGN } from '../../src/state/SiteDesignContext';
|
||||
|
||||
function stateFor(tree: any): string {
|
||||
const clean = sanitizeAiTree(tree, new Set(), { n: 0 });
|
||||
if (!clean) throw new Error('response tree root is not a known component');
|
||||
const flat = flattenTreeForCraft(clean);
|
||||
const root = {
|
||||
type: { resolvedName: 'Container' }, isCanvas: true, props: { tag: 'div', style: {} }, displayName: 'Container',
|
||||
custom: {}, hidden: false, parent: null, nodes: [flat.rootNodeId], linkedNodes: {},
|
||||
};
|
||||
flat.nodes[flat.rootNodeId].parent = 'ROOT';
|
||||
return JSON.stringify({ ROOT: root, ...flat.nodes });
|
||||
}
|
||||
|
||||
/** Mirrors SiteBuilderHead.php sb_font_awesome_link(): the backend adds this to published pages. */
|
||||
const FA_LINK = '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/7.3.1/css/all.min.css" crossorigin="anonymous" referrerpolicy="no-referrer">';
|
||||
|
||||
export function renderResponse(resp: any, designOverrides: Record<string, any> = {}, pageIndex = 0) {
|
||||
if (resp?.type !== 'replace') throw new Error(`only type=replace responses can be rendered (got ${resp?.type})`);
|
||||
const design = { ...DEFAULT_SITE_DESIGN, ...designOverrides };
|
||||
const page = resp.pages[pageIndex];
|
||||
const pageState = stateFor(page.tree);
|
||||
const hdr = resp.header ? exportBodyHtml(stateFor(resp.header.tree), 'header').html : '';
|
||||
const ftr = resp.footer ? exportBodyHtml(stateFor(resp.footer.tree), 'footer').html : '';
|
||||
const body = exportBodyHtml(pageState).html;
|
||||
let html = exportComposedPreviewHtml(pageState, hdr + body + ftr, { title: page.name, includeFonts: true, design } as any);
|
||||
if (/class="[^"]*\bfa-/.test(html) && !html.includes('font-awesome')) html = html.replace('</head>', () => `${FA_LINK}\n</head>`);
|
||||
return html;
|
||||
}
|
||||
Reference in new issue
Block a user