From 67680eccadeafa59acecd50b5a7049a96887fea0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:54:26 +0000 Subject: [PATCH 1/2] Bump prettier from 3.8.3 to 3.9.6 in the npm-minor-and-patch group Bumps the npm-minor-and-patch group with 1 update: [prettier](https://github.com/prettier/prettier). Updates `prettier` from 3.8.3 to 3.9.6 - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.3...3.9.6) --- updated-dependencies: - dependency-name: prettier dependency-version: 3.9.6 dependency-type: direct:development update-type: version-update:semver-minor dependency-group: npm-minor-and-patch ... Signed-off-by: dependabot[bot] --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9f39d64..ed4666a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@types/node": "24.12.2", "@types/react": "19.2.18", "eslint": "10.9.1", - "prettier": "3.8.3", + "prettier": "3.9.6", "typescript": "5.9.3", "typescript-eslint": "8.68.0", "vitest": "4.1.11" @@ -2342,9 +2342,9 @@ } }, "node_modules/prettier": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", - "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" diff --git a/package.json b/package.json index e676b2c..418daf7 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "@types/node": "24.12.2", "@types/react": "19.2.18", "eslint": "10.9.1", - "prettier": "3.8.3", + "prettier": "3.9.6", "typescript": "5.9.3", "typescript-eslint": "8.68.0", "vitest": "4.1.11" From 2a99f8c6b74be2d490702f037381a43fde7c7c47 Mon Sep 17 00:00:00 2001 From: Plamen Hadzhiev <116190592+plmn95@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:04:04 +0300 Subject: [PATCH 2/2] Add brand-aware rendering, guided CLI and refreshed showcase --- README.md | 93 +++++-- docs/brand-settings.md | 123 +++++++++ docs/showcase/README.md | 4 + docs/showcase/branding/README.md | 61 +++++ docs/showcase/branding/blue-desktop.jpg | Bin 0 -> 69751 bytes docs/showcase/branding/blue.json | 10 + docs/showcase/branding/campaign.json | 57 +++++ docs/showcase/branding/dark-mobile.jpg | Bin 0 -> 52954 bytes docs/showcase/branding/dark.json | 10 + src/brand/colour.ts | 63 +++++ src/brand/resolve-brand.ts | 76 ++++++ src/brand/settings.ts | 106 ++++++++ src/cli/arguments.ts | 247 +++++++++---------- src/cli/bin.ts | 8 + src/cli/cli-error.ts | 16 ++ src/cli/execute-command.ts | 120 +++++++++ src/cli/flags.ts | 91 +++++++ src/cli/guide-brand.ts | 124 ++++++++++ src/cli/guide-command.ts | 95 +++++++ src/cli/guide-fields.ts | 129 ++++++++++ src/cli/help.ts | 17 +- src/cli/io.ts | 61 +++++ src/cli/local-files.ts | 97 ++++++++ src/cli/preview-result.ts | 80 ++++++ src/cli/run-cli.ts | 66 ++--- src/cli/terminal.ts | 51 ++++ src/core/generate-campaign.ts | 45 ++-- src/core/render-campaign.ts | 68 +++++ src/core/run-campaign-pipeline.ts | 3 + src/core/schemas/input.ts | 2 + src/extraction/brand-styles.ts | 21 +- src/extraction/contracts.ts | 8 + src/extraction/extract-brand.ts | 1 + src/extraction/extract-generation-context.ts | 60 ++++- src/extraction/style-role-candidates.ts | 172 +++++++++++++ src/index.ts | 11 + src/output/artifact-builder.ts | 4 + src/output/contracts.ts | 3 + src/output/filesystem-safety.ts | 53 ++++ src/output/write-output.ts | 85 ++----- src/rendering/blocks/body-paragraph.tsx | 4 +- src/rendering/blocks/cta-block.tsx | 35 +-- src/rendering/blocks/discount-code.tsx | 11 +- src/rendering/blocks/header-standard.tsx | 9 +- src/rendering/blocks/heading.tsx | 9 +- src/rendering/blocks/hero-stacked.tsx | 16 +- src/rendering/blocks/product-feature.tsx | 17 +- src/rendering/blocks/product-grid.tsx | 4 +- src/rendering/blocks/shared.tsx | 32 +-- src/rendering/brand-theme.ts | 82 ++++++ src/rendering/commerce-styles.ts | 185 ++++++++------ src/rendering/email-document.tsx | 21 +- src/rendering/render-campaign-html.tsx | 20 +- src/rendering/render-style-context.tsx | 49 ++++ src/rendering/safe-inline-markdown.tsx | 23 +- src/rendering/styles.ts | 201 +++++++++------ src/validation/render-style-validation.ts | 44 +--- tests/brand/brand-rendering.test.ts | 147 +++++++++++ tests/cli/brand-files-render.test.ts | 232 +++++++++++++++++ tests/cli/guide.test.ts | 141 +++++++++++ tests/extraction/brand-review.test.ts | 82 ++++++ tests/extraction/brand-style-roles.test.ts | 64 +++++ 62 files changed, 3208 insertions(+), 561 deletions(-) create mode 100644 docs/brand-settings.md create mode 100644 docs/showcase/branding/README.md create mode 100644 docs/showcase/branding/blue-desktop.jpg create mode 100644 docs/showcase/branding/blue.json create mode 100644 docs/showcase/branding/campaign.json create mode 100644 docs/showcase/branding/dark-mobile.jpg create mode 100644 docs/showcase/branding/dark.json create mode 100644 src/brand/colour.ts create mode 100644 src/brand/resolve-brand.ts create mode 100644 src/brand/settings.ts create mode 100644 src/cli/cli-error.ts create mode 100644 src/cli/execute-command.ts create mode 100644 src/cli/flags.ts create mode 100644 src/cli/guide-brand.ts create mode 100644 src/cli/guide-command.ts create mode 100644 src/cli/guide-fields.ts create mode 100644 src/cli/io.ts create mode 100644 src/cli/local-files.ts create mode 100644 src/cli/preview-result.ts create mode 100644 src/cli/terminal.ts create mode 100644 src/core/render-campaign.ts create mode 100644 src/extraction/style-role-candidates.ts create mode 100644 src/output/filesystem-safety.ts create mode 100644 src/rendering/brand-theme.ts create mode 100644 src/rendering/render-style-context.tsx create mode 100644 tests/brand/brand-rendering.test.ts create mode 100644 tests/cli/brand-files-render.test.ts create mode 100644 tests/cli/guide.test.ts create mode 100644 tests/extraction/brand-review.test.ts create mode 100644 tests/extraction/brand-style-roles.test.ts diff --git a/README.md b/README.md index 770f102..81871b1 100644 --- a/README.md +++ b/README.md @@ -11,8 +11,9 @@ Punch turns one brand website and one to six product pages into a grounded, responsive ecommerce email. It extracts evidence, asks Claude for a semantic -campaign, checks product and claim associations deterministically, then writes -standalone HTML and machine-readable validation artifacts. +campaign, checks product and claim associations deterministically, then renders +it with configurable brand colours and fonts. The result is standalone HTML +and machine-readable validation artifacts. It is an engine and CLI, not an ESP. Punch does not manage contacts, send messages or hide unsupported claims behind a confidence score. @@ -39,28 +40,40 @@ the useful generative part while making commerce facts inspectable: - unknown or conflicted critical facts cannot be promoted to truth; - availability, promotion and selected high-risk claims require source support; - Claude produces semantic blocks, never raw layout HTML; +- website style roles inform a validated brand theme, with explicit overrides + and readable fallbacks; - final HTML passes deterministic accessibility, geometry, resource and compliance-placeholder checks; and - public fetching rejects local/private networks, unsafe redirects, oversized responses and credential-bearing URLs. -## Live showcase - -

- A live Punch campaign for the fictional Northstar Goods brand -

- -This campaign was generated live with Claude Sonnet 5 from two public, newly -fictional product pages. The `sales` safety policy was combined with a custom -desk-reset brief; both supplied products remained grounded and all ten campaign -and render checks passed. - -[Inspect the brief, source commit and validation record](https://github.com/plmn95/punch/blob/main/docs/showcase/README.md). +## One campaign, different looks + +Change colours and fonts without changing the products, copy or links, or +making another AI call. These screenshots show the same fictional campaign +rendered with two brand profiles, on desktop and mobile. Click either image +to inspect it at full resolution. + + + + + + + + + + +
Blue · desktopDark · mobile
Soft Orbit campaign with blue accents and Verdana headings on desktopThe same Soft Orbit campaign with a dark background, lime accents and monospace headings on mobile
+ +These are renderer examples, not fresh AI generations or automatic brand-detection +results. [Reproduce them without an API key](docs/showcase/branding/README.md). +For an end-to-end generation with source evidence and a validation record, +see the [recorded Northstar Goods live run](docs/showcase/README.md). ## Quick start -Punch currently ships from source. Node.js 24 or newer and an Anthropic API key -are required. +Punch currently ships from source. Node.js 24 or newer is required; an Anthropic +API key is needed for generation, but not for rendering an existing campaign. ```bash git clone https://github.com/plmn95/punch.git @@ -91,6 +104,51 @@ Add `--trace` for redacted structured stage artifacts or `--json` for exactly one terminal JSON result on stdout. Run `node dist/cli/bin.js --help` for the complete explicit interface. +### Guided input and brand settings + +Run `node dist/cli/bin.js` in a terminal to start the optional guide. It collects +website/product URLs and the campaign brief, shows detected colours and fonts, +then asks for confirmation **before any AI call**. Keep the detected settings +with Enter, change individual six-digit hex colours or font families, preview +the actual email in a browser, and export when ready. + +Complete commands stay prompt-free. Add `--interactive` to request review even +with complete inputs. `--json`, `--no-interactive`, CI and non-TTY input/output +always disable prompting. + +```bash +node dist/cli/bin.js generate \ + --website "https://example.com" \ + --product "https://example.com/products/first-product" \ + --goal "sales" \ + --primary-colour "#2563EB" \ + --heading-font "Verdana" \ + --save-brand "./brand.json" \ + --output "./campaign" +``` + +Use `--brand ./brand.json` to reuse a saved profile. Explicit flags override +the profile; supplied settings override website detection. Output paths and +profile filenames must be new; Punch never overwrites an existing profile. + +### Restyle without another AI call + +The guide's **adjust branding** action re-renders the same campaign without +changing its copy or spending more model tokens. Saved campaigns can also be +restyled without an API key: + +```bash +node dist/cli/bin.js render \ + --campaign "./campaign/campaign.json" \ + --primary-colour "#006644" \ + --output "./campaign-green" +``` + +Render-only output is explicitly labelled `render-only` in its validation +metadata: it checks the HTML, not current product facts or source grounding. +Saved campaign settings are retained unless overridden. See +[brand settings and CLI behaviour](docs/brand-settings.md) for the full contract. + ## Custom campaign briefs Punch has three fixed goal policies and open-ended campaign direction. Keep the @@ -186,6 +244,9 @@ artifact is not automatically ready for lawful sending. ## Current limits - Anthropic is the only supported provider. +- Brand detection is conservative, not a pixel-perfect website clone. Colours + and fonts can be overridden; custom font files are not downloaded or embedded. +- Browser checks are not certification across all email clients. - Input is one website plus one to six explicit product URLs. - Punch does not discover products or crawl a catalogue. - Safe forced directory replacement is unavailable in `0.1.0`; choose a fresh diff --git a/docs/brand-settings.md b/docs/brand-settings.md new file mode 100644 index 0000000..bf70d59 --- /dev/null +++ b/docs/brand-settings.md @@ -0,0 +1,123 @@ +# Brand settings and the CLI + +Punch keeps email layout in controlled React blocks. Branding is a separate, +validated input to those blocks, never model-written CSS or HTML. + +## Five settings + +| Setting | CLI flag | Meaning | +| ------------------ | --------------------- | ----------------------------------------- | +| `primaryColour` | `--primary-colour` | Main action colour and decorative accents | +| `backgroundColour` | `--background-colour` | Main email surface | +| `textColour` | `--text-colour` | Readable content ink | +| `headingFont` | `--heading-font` | One heading font family | +| `bodyFont` | `--body-font` | One body font family | + +Colours use `#RRGGBB`. Font names are bounded plain family names, not CSS stacks, +URLs or font files. The renderer supplies fallback stacks. It does not download +or embed custom fonts; naming one does not guarantee the recipient has it. + +The renderer derives cards, borders and supporting surfaces. It preserves the +primary colour on buttons and chooses readable button text. Links may use +readable ink instead of a low-contrast brand colour. Explicit text must meet +4.5:1 contrast against the background; an invalid manual combination is refused. +The guide offers a correction that the user must accept. Unreadable detected +text receives a labelled fallback. + +## Conservative website detection + +The existing bounded fetcher supplies HTML and same-origin CSS. Role extraction +recognises explicit root tokens such as `--primary`, `--color-primary`, +`--background`, `--text`, `--font-heading` and `--font-body`, plus unconditional +body, heading and button rules. It supports opaque hex/integer RGB values and +short local variable chains. Explicit tokens take priority over semantic rules. +Conflicting top-ranked candidates are omitted, not selected by stylesheet order. + +This is not a browser-computed cascade or a pixel-perfect website clone. It does +not evaluate JavaScript, follow CSS imports, infer roles from arbitrary class +names, or choose between conditional/hover/dark-mode rules. Missing roles use +defaults. A website that provides only ambiguous signals may retain neutral +styling until the caller supplies overrides. + +`result.brand` contains the resolved settings, per-slot `website`/`manual`/ +`fallback` origins and warnings. These also live in `campaign.json`, independent +of optional traces. They contain no raw stylesheet or provider payload. + +## Reusable profiles + +```json +{ + "version": "1", + "settings": { + "primaryColour": "#2563EB", + "backgroundColour": "#FFFFFF", + "textColour": "#172033", + "headingFont": "Verdana", + "bodyFont": "Arial" + } +} +``` + +Profiles may specify a subset of settings. `--brand` reads a bounded, regular +JSON file; symlinks, hardlinks and linked parent paths are refused. `--save-brand` +and the guide's save action require a new filename in an existing real parent +directory. There is no global profile, credential store or automatic saving. +The campaign and an external profile are separate saves: if the latter fails, +the CLI reports that the campaign was saved and leaves it intact. + +Precedence is explicit flags → loaded profile → detected website roles → +fallbacks. During restyling, saved campaign settings replace website detection. + +## Guided and automated use + +Bare `punch` and incomplete `punch generate` invocations guide only when both +stdin and stdout are TTYs, no CI environment is detected, and prompting has not +been disabled. Complete commands bypass the guide unless `--interactive` is +present. `--json` and `--no-interactive` always win over `--interactive`. +Unknown flags and duplicate scalar flags fail before any question. + +The guide collects sources and the brief, fetches the pages, then reviews brand +settings before optional voice inference or campaign generation. Source-fetch +resources are released before waiting for input. Ctrl-C, Ctrl-D and a declined +generation confirmation cancel without publishing an output bundle. + +After generation: `p` opens a temporary browser preview, `b` adjusts branding, +`s` chooses a profile filename, and Enter exports. Temporary previews are removed +when the session ends. The final HTML remains in the chosen output directory. +Brand-only changes reuse the same semantic campaign and its generation usage. + +`punch render --campaign --output ` works without +credentials or AI calls. It accepts a saved Punch campaign document or a canonical +semantic campaign. It does not reuse prior grounding claims: validation is +explicitly `render-only`, with zero model usage. Neither rendering mode sends +email or resolves the caller-owned compliance placeholders. + +## TypeScript integration + +```ts +import { generateCampaign, restyleCampaign, renderCampaign } from "punch-email"; + +const result = await generateCampaign( + { website, products, goal: "sales", brand: { primaryColour: "#2563EB" } }, + { provider }, +); + +// In-memory restyling preserves the existing generation proof and usage. +const green = await restyleCampaign(result, { primaryColour: "#006644" }); + +// Independent rendering makes only render-validation claims. +const preview = await renderCampaign(result.campaign, result.brand?.settings); +``` + +A platform can supply its existing brand settings through the same `brand` +input; no CLI or platform-specific connector is required. An optional +`reviewBrand` callback can return overrides before model work begins. Ordinary +API calls remain non-interactive. + +## Verification boundary + +Tests cover role selection and ambiguity, strict settings, contrast, concurrent +theme isolation, single/six-product rendering, profile safety, TTY/CI gating, +manual correction, cancellation, preview cleanup and render-only reproducibility. +Browser fixtures check desktop/mobile layout. Browser proof is not certification +across Gmail, Outlook, Apple Mail or every installed font. diff --git a/docs/showcase/README.md b/docs/showcase/README.md index a51a0dd..a044fbc 100644 --- a/docs/showcase/README.md +++ b/docs/showcase/README.md @@ -1,5 +1,9 @@ # Live Northstar Goods showcase +This is the original recorded generation, captured before brand-aware rendering +was added. Its neutral theme is retained as historical evidence. For the current +configurable renderer, see the [blue and dark brand examples](branding/README.md). + ![Generated Northstar Goods campaign](northstar-campaign.png) This is a real Punch generation from public HTTP input through Claude Sonnet 5, diff --git a/docs/showcase/branding/README.md b/docs/showcase/branding/README.md new file mode 100644 index 0000000..70398b6 --- /dev/null +++ b/docs/showcase/branding/README.md @@ -0,0 +1,61 @@ +# One campaign, two brand profiles + +These are real browser captures of Punch's HTML renderer, not image-generated +mockups. Both use the same fictional Soft Orbit campaign. Only the brand +settings and viewport differ; the product, copy, price and destination links +are unchanged. + +| Preview | Brand settings | Capture | Maximum README display width | +| -------------------------------- | ---------------------- | ------------- | ---------------------------- | +| [Blue desktop](blue-desktop.jpg) | [blue.json](blue.json) | 820 × 1000 px | 520 px | +| [Dark mobile](dark-mobile.jpg) | [dark.json](dark.json) | 390 × 1000 px | 250 px | + +The JPEGs are original browser captures with no extra compression or upscaling. +They show the upper part of each email; the complete HTML includes the closing +CTA and Punch's compliance placeholders. The mobile layout is a real responsive +render, not a resized desktop screenshot. Installed fonts may affect line breaks +on another machine. + +## Reproduce the HTML without an API key + +From the repository root, after installing dependencies: + +```bash +npm run build + +node dist/cli/bin.js render \ + --campaign docs/showcase/branding/campaign.json \ + --brand docs/showcase/branding/blue.json \ + --output ./showcase-blue + +node dist/cli/bin.js render \ + --campaign docs/showcase/branding/campaign.json \ + --brand docs/showcase/branding/dark.json \ + --output ./showcase-dark +``` + +Both output directories must be new. Open each `email.html` in a browser, or +add `--interactive` to review settings and open a preview through the CLI. +To try a different accent, add `--primary-colour "#006644"`; explicit flags +override the selected profile. + +## Provenance and limits + +[`campaign.json`](campaign.json) is the public +[single-product renderer fixture](../../../tests/fixtures/checkpoint-4/single-product.json) +with the placeholder product image omitted. It intentionally demonstrates +Punch's image-free rendering and requires no external image requests. Its +reserved `.example.com` links are fictional, not working shop destinations. + +The screenshots were captured from the same content with local fixture links +for browser interaction checks. The committed campaign keeps the original +reserved-domain destinations; this does not change the visible rendering. + +The profiles are explicit inputs, not evidence of automatic website detection. +No AI call, fresh source fetch or product-grounding claim is made by this +showcase. The render command produces `render-only` validation and zero model +usage. Browser verification is not certification across all email clients. + +The separate [Northstar Goods live-generation record](../README.md) retains the +original end-to-end generation evidence. See [brand settings](../../brand-settings.md) +for detection behaviour, contrast checks and the TypeScript integration. diff --git a/docs/showcase/branding/blue-desktop.jpg b/docs/showcase/branding/blue-desktop.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fffa67a5ac3ade6909ae168a8e85ec108267debb GIT binary patch literal 69751 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

Fe?gtC1gDAC>VeD-Y#dQ?`FR2aOh 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 literal 0 HcmV?d00001 diff --git a/docs/showcase/branding/blue.json b/docs/showcase/branding/blue.json new file mode 100644 index 0000000..167e6c3 --- /dev/null +++ b/docs/showcase/branding/blue.json @@ -0,0 +1,10 @@ +{ + "version": "1", + "settings": { + "primaryColour": "#2563EB", + "backgroundColour": "#FFFFFF", + "textColour": "#172033", + "headingFont": "Verdana", + "bodyFont": "Arial" + } +} diff --git a/docs/showcase/branding/campaign.json b/docs/showcase/branding/campaign.json new file mode 100644 index 0000000..2a024c4 --- /dev/null +++ b/docs/showcase/branding/campaign.json @@ -0,0 +1,57 @@ +{ + "schemaVersion": "0.1.0", + "goal": "product-launch", + "subject": "Meet the Pebble Weekender", + "preheader": "A soft-sided overnight bag with thoughtful space for the essentials.", + "blocks": [ + { + "type": "header-standard", + "brandName": "Soft Orbit", + "homeUrl": "https://soft-orbit.example.com/", + "id": "block-01" + }, + { + "type": "hero-stacked", + "eyebrow": "New for short escapes", + "heading": "Pack one more good day", + "body": "Meet the Pebble Weekender, arranged for overnight plans and easy unpacking.", + "id": "block-02" + }, + { + "type": "product-feature", + "eyebrow": "The new arrival", + "productId": "product-01", + "name": "Pebble Weekender", + "description": "A soft-sided overnight bag with a wide zip opening, two interior pockets, and an adjustable woven strap.", + "price": { "amount": "148.00", "currency": "USD", "display": "$148" }, + "cta": { + "label": "Explore the Pebble Weekender", + "href": "https://soft-orbit.example.com/products/pebble-weekender" + }, + "id": "block-03" + }, + { + "type": "heading", + "level": 2, + "text": "Small details, easier departures", + "id": "block-04" + }, + { + "type": "body-paragraph", + "markdown": "A wide zip opening keeps the main compartment easy to scan, while **two interior pockets** separate smaller essentials.", + "id": "block-05" + }, + { + "type": "cta-block", + "heading": "Ready for the next overnight?", + "body": "See the complete bag details before you pack.", + "actions": [ + { + "label": "View the Pebble Weekender", + "href": "https://soft-orbit.example.com/products/pebble-weekender" + } + ], + "id": "block-06" + } + ] +} diff --git a/docs/showcase/branding/dark-mobile.jpg b/docs/showcase/branding/dark-mobile.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5d45d21e510c2e883fb5a720bff2f7e1bc97cd58 GIT binary patch literal 52954 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%0AU5z8H*o)ByEGqG(+-9mqr?iGme1Sdr9 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 L(%Ihpv+#caieyT% literal 0 HcmV?d00001 diff --git a/docs/showcase/branding/dark.json b/docs/showcase/branding/dark.json new file mode 100644 index 0000000..1145cb2 --- /dev/null +++ b/docs/showcase/branding/dark.json @@ -0,0 +1,10 @@ +{ + "version": "1", + "settings": { + "primaryColour": "#C7F36B", + "backgroundColour": "#111827", + "textColour": "#F9FAFB", + "headingFont": "Courier New", + "bodyFont": "Verdana" + } +} diff --git a/src/brand/colour.ts b/src/brand/colour.ts new file mode 100644 index 0000000..25f52de --- /dev/null +++ b/src/brand/colour.ts @@ -0,0 +1,63 @@ +/** Parses one opaque six-digit hexadecimal colour. */ +function parseHex(value: unknown): [number, number, number] | undefined { + if (typeof value !== "string" || !/^#[\da-f]{6}$/iu.test(value)) + return undefined; + return [1, 3, 5].map((offset) => + Number.parseInt(value.slice(offset, offset + 2), 16), + ) as [number, number, number]; +} + +/** Converts one sRGB channel to relative luminance. */ +function linearise(value: number): number { + const channel = value / 255; + return channel <= 0.04045 + ? channel / 12.92 + : ((channel + 0.055) / 1.055) ** 2.4; +} + +/** Computes the relative luminance of a supported opaque colour. */ +function luminance([red, green, blue]: [number, number, number]): number { + return ( + 0.2126 * linearise(red) + + 0.7152 * linearise(green) + + 0.0722 * linearise(blue) + ); +} + +/** Returns the contrast ratio for supported opaque foreground/background colours. */ +export function contrastRatio( + foreground: unknown, + background: unknown, +): number | undefined { + const front = parseHex(foreground); + const back = parseHex(background); + if (!front || !back) return undefined; + const first = luminance(front); + const second = luminance(back); + return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05); +} + +/** Selects the higher-contrast black or white text for a validated background. */ +export function readableInk(background: string): string { + return (contrastRatio("#000000", background) ?? 0) >= + (contrastRatio("#FFFFFF", background) ?? 0) + ? "#000000" + : "#FFFFFF"; +} + +/** Mixes a bounded proportion of a validated colour into a background. */ +export function tint( + background: string, + colour: string, + amount: number, +): string { + const base = parseHex(background)!; + const target = parseHex(colour)!; + return `#${base + .map((channel, index) => + Math.round(channel * (1 - amount) + target[index]! * amount) + .toString(16) + .padStart(2, "0"), + ) + .join("")}`.toUpperCase(); +} diff --git a/src/brand/resolve-brand.ts b/src/brand/resolve-brand.ts new file mode 100644 index 0000000..a1fbd79 --- /dev/null +++ b/src/brand/resolve-brand.ts @@ -0,0 +1,76 @@ +import { contrastRatio, readableInk } from "./colour.js"; +import { + BRAND_KEYS, + BrandStyleEvidenceSchema, + BrandStyleError, + CompleteBrandSettingsSchema, + DEFAULT_BRAND_SETTINGS, + parseBrandSettings, + ResolvedBrandSchema, + type BrandStyleEvidence, + type CompleteBrandSettings, + type ResolvedBrand, +} from "./settings.js"; + +/** Resolves role-aware website evidence and explicit overrides independently per run. */ +export function resolveBrand( + evidence: BrandStyleEvidence = {}, + overrides: unknown = {}, +): ResolvedBrand { + const facts = BrandStyleEvidenceSchema.parse(evidence); + const manual = parseBrandSettings(overrides); + const settings = { ...DEFAULT_BRAND_SETTINGS }; + const sources = Object.fromEntries( + BRAND_KEYS.map((key) => [key, "fallback"]), + ) as ResolvedBrand["sources"]; + for (const key of BRAND_KEYS) { + const candidate = CompleteBrandSettingsSchema.shape[key].safeParse( + facts[key]?.value, + ); + if (candidate.success) { + settings[key] = candidate.data; + sources[key] = "website"; + } + if (manual[key] !== undefined) { + settings[key] = manual[key]; + sources[key] = "manual"; + } + } + const warnings: ResolvedBrand["warnings"] = []; + if ( + (contrastRatio(settings.textColour, settings.backgroundColour) ?? 0) < 4.5 + ) { + if (manual.textColour !== undefined) { + throw new BrandStyleError( + `Text and background need at least 4.5:1 contrast. Suggested text: ${readableInk(settings.backgroundColour)}.`, + ); + } + settings.textColour = readableInk(settings.backgroundColour); + sources.textColour = "fallback"; + warnings.push("text-contrast-fallback"); + } + warnings.push(...styleWarnings(settings)); + return ResolvedBrandSchema.parse({ settings, sources, warnings }); +} + +/** Explains safe link/font substitutions without changing caller-owned settings. */ +function styleWarnings( + settings: CompleteBrandSettings, +): ResolvedBrand["warnings"] { + const warnings: ResolvedBrand["warnings"] = []; + if ( + (contrastRatio(settings.primaryColour, settings.backgroundColour) ?? 0) < + 4.5 + ) + warnings.push("accessible-link-colour"); + if ( + [settings.bodyFont, settings.headingFont].some( + (font) => + !/^(Arial|Georgia|Helvetica|Verdana|Tahoma|Times New Roman|Courier New|Trebuchet MS|serif|sans-serif|monospace)$/iu.test( + font, + ), + ) + ) + warnings.push("font-fallbacks"); + return warnings; +} diff --git a/src/brand/settings.ts b/src/brand/settings.ts new file mode 100644 index 0000000..405b812 --- /dev/null +++ b/src/brand/settings.ts @@ -0,0 +1,106 @@ +import { z } from "zod"; + +import { HttpUrlSchema } from "../core/schemas/primitives.js"; + +export const BRAND_KEYS = [ + "primaryColour", + "backgroundColour", + "textColour", + "headingFont", + "bodyFont", +] as const; + +export const HexColourSchema = z + .string() + .trim() + .regex(/^#[\da-f]{6}$/iu, "Use a six-digit hex colour, for example #2563EB") + .transform((value) => value.toUpperCase()); + +export const FontFamilySchema = z + .string() + .trim() + .min(1) + .max(80) + .regex(/^[a-z][a-z\d -]*$/iu, "Use a single font family name, without CSS") + .refine( + (value) => + !["inherit", "initial", "unset", "revert", "revert-layer"].includes( + value.toLowerCase(), + ), + ); + +export const CompleteBrandSettingsSchema = z.strictObject({ + primaryColour: HexColourSchema, + backgroundColour: HexColourSchema, + textColour: HexColourSchema, + headingFont: FontFamilySchema, + bodyFont: FontFamilySchema, +}); +export const BrandSettingsSchema = CompleteBrandSettingsSchema.partial(); +export const BrandProfileSchema = z.strictObject({ + version: z.literal("1"), + settings: BrandSettingsSchema, +}); +export const BrandStyleEvidenceSchema = z.partialRecord( + z.enum(BRAND_KEYS), + z.strictObject({ + value: z.string().max(80), + confidence: z.enum(["explicit", "semantic"]), + evidence: z.strictObject({ + url: HttpUrlSchema, + field: z.string().max(120), + }), + }), +); +export const ResolvedBrandSchema = z.strictObject({ + settings: CompleteBrandSettingsSchema, + sources: z.record( + z.enum(BRAND_KEYS), + z.enum(["manual", "website", "fallback"]), + ), + warnings: z + .array( + z.enum([ + "text-contrast-fallback", + "accessible-link-colour", + "font-fallbacks", + ]), + ) + .max(3), +}); + +export type BrandSettings = z.infer; +export type CompleteBrandSettings = z.infer; +export type BrandSettingKey = (typeof BRAND_KEYS)[number]; +export type BrandStyleEvidence = z.infer; +export type ResolvedBrand = z.infer; +export type BrandReviewer = (brand: ResolvedBrand) => Promise; + +export const DEFAULT_BRAND_SETTINGS: Readonly = + Object.freeze({ + primaryColour: "#9A5137", + backgroundColour: "#FFFDF9", + textColour: "#2F251F", + headingFont: "Georgia", + bodyFont: "Arial", + }); + +/** Safe brand failure that never echoes untrusted CSS or file contents. */ +export class BrandStyleError extends Error { + readonly code = "invalid-brand"; + readonly retryable = false; + + constructor( + message = "Brand settings are invalid. Check hex colours and font family names.", + ) { + super(message); + this.name = "BrandStyleError"; + } +} + +/** Validates caller-owned overrides without retaining unsafe input in errors. */ +export function parseBrandSettings(value: unknown): BrandSettings { + const parsed = BrandSettingsSchema.safeParse(value); + if (!parsed.success) throw new BrandStyleError(); + return parsed.data; +} diff --git a/src/cli/arguments.ts b/src/cli/arguments.ts index e0c9146..008a9ce 100644 --- a/src/cli/arguments.ts +++ b/src/cli/arguments.ts @@ -2,179 +2,158 @@ import { GenerateCampaignInputSchema, type GenerateCampaignInput, } from "../core/schemas/index.js"; +import type { BrandSettings } from "../brand/settings.js"; +import { CliArgumentError } from "./cli-error.js"; +import { brandFlags, flagValue, readFlags, type CliFlags } from "./flags.js"; +import { localPath } from "./local-files.js"; -const VALUE_FLAGS = new Set([ - "--website", - "--product", - "--goal", - "--output", - "--instructions", - "--offer", - "--discount-code", - "--offer-ends-at", -]); -const BOOLEAN_FLAGS = new Set([ - "--trace", - "--json", - "--force", - "--no-interactive", -]); +export { CliArgumentError } from "./cli-error.js"; +export type { CliArgumentErrorCode } from "./cli-error.js"; -export type GenerateCommand = Readonly<{ - kind: "generate"; - input: GenerateCampaignInput; +export type CommonCommand = Readonly<{ output: string; - trace: boolean; json: boolean; force: boolean; + brandPath?: string; + saveBrandPath?: string; }>; - +export type GenerateCommand = CommonCommand & + Readonly<{ + kind: "generate"; + input: GenerateCampaignInput; + trace: boolean; + }>; +export type RenderCommand = CommonCommand & + Readonly<{ + kind: "render"; + campaignPath: string; + brand: BrandSettings; + }>; export type CliCommand = | GenerateCommand + | RenderCommand | Readonly<{ kind: "help" }> | Readonly<{ kind: "version" }>; -export type CliArgumentErrorCode = - | "invalid-arguments" - | "missing-arguments" - | "unknown-command"; - -/** Stable usage error without echoing user-supplied values. */ -export class CliArgumentError extends Error { - readonly code: CliArgumentErrorCode; - - constructor(code: CliArgumentErrorCode, message: string) { - super(message); - this.name = "CliArgumentError"; - this.code = code; - } -} - -/** Parses the canonical non-interactive Punch command. */ +/** Parses explicit invocations without reading files, credentials or terminal state. */ export function parseCliArguments(argv: readonly string[]): CliCommand { - if (argv.length === 0 || argv[0] === "--help" || argv[0] === "-h") { + if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) return { kind: "help" }; - } - if (argv[0] === "--version" || argv[0] === "-v") { - return { kind: "version" }; - } - if (argv[0] !== "generate") { + if (argv[0] === "--version" || argv[0] === "-v") return { kind: "version" }; + if (argv[0] !== "generate" && argv[0] !== "render") throw new CliArgumentError( "unknown-command", "Unknown command. Use punch --help.", ); - } - return parseGenerate(argv.slice(1)); -} - -/** Parses generate flags without prompting or accepting positional input. */ -function parseGenerate(argv: readonly string[]): GenerateCommand { - const values = new Map(); - const booleans = new Set(); - for (let index = 0; index < argv.length; index += 1) { - const flag = argv[index]!; - if (BOOLEAN_FLAGS.has(flag)) { - booleans.add(flag); - continue; - } - if (!VALUE_FLAGS.has(flag)) { - throw new CliArgumentError( - "invalid-arguments", - "Unknown or misplaced generate flag.", - ); - } - const value = argv[index + 1]; - if (value === undefined || value.startsWith("--")) { - throw new CliArgumentError( - "missing-arguments", - "A generate flag is missing its value.", - ); - } - values.set(flag, [...(values.get(flag) ?? []), value]); - index += 1; - } - return buildGenerateCommand(values, booleans); + const flags = readFlags(argv.slice(1)); + return argv[0] === "render" ? parseRender(flags) : parseGenerate(flags); } -/** Validates multiplicity and builds the public campaign input. */ -function buildGenerateCommand( - values: ReadonlyMap, - booleans: ReadonlySet, -): GenerateCommand { - const website = singleValue(values, "--website"); - const goal = singleValue(values, "--goal"); - const output = singleValue(values, "--output"); - const products = values.get("--product") ?? []; - if (!website || !goal || !output || products.length === 0) { +/** Collects shared options while refusing unsafe local path spellings. */ +function commonCommand(flags: CliFlags): CommonCommand { + const output = flagValue(flags, "--output"); + if (!output) throw new CliArgumentError( "missing-arguments", - "Generate requires --website, --product, --goal and --output.", + "Choose an --output directory.", ); - } - const input = campaignInput(values, website, products, goal); + localPath(output); + const brandPath = flagValue(flags, "--brand"); + const saveBrandPath = flagValue(flags, "--save-brand"); return { - kind: "generate", - input: GenerateCampaignInputSchema.parse(input), output, - trace: booleans.has("--trace"), - json: booleans.has("--json"), - force: booleans.has("--force"), + json: flags.booleans.has("--json"), + force: flags.booleans.has("--force"), + ...(brandPath ? { brandPath: localPath(brandPath) } : {}), + ...(saveBrandPath ? { saveBrandPath: localPath(saveBrandPath) } : {}), }; } -/** Returns one scalar flag and rejects accidental duplicates. */ -function singleValue( - values: ReadonlyMap, - flag: string, -): string | undefined { - const entries = values.get(flag); - if (entries && entries.length > 1) { +/** Validates generation flags through the canonical public input contract. */ +function parseGenerate(flags: CliFlags): GenerateCommand { + if (flags.values.has("--campaign")) throw new CliArgumentError( "invalid-arguments", - "A scalar flag was supplied more than once.", + "--campaign belongs to punch render.", ); - } - return entries?.[0]; -} - -/** Maps CLI offer flags to the discriminated public input schema. */ -function campaignInput( - values: ReadonlyMap, - website: string, - products: readonly string[], - goal: string, -): unknown { - const instructions = singleValue(values, "--instructions"); - const offerDescription = singleValue(values, "--offer"); - const code = singleValue(values, "--discount-code"); - const endsAt = singleValue(values, "--offer-ends-at"); - const base = { + const website = flagValue(flags, "--website"); + const goal = flagValue(flags, "--goal"); + const products = flags.values.get("--product") ?? []; + if (!website || !goal || products.length === 0) + throw new CliArgumentError( + "missing-arguments", + "Generate requires --website, --product, --goal and --output.", + ); + const offer = offerFlags(flags, goal); + const instructions = flagValue(flags, "--instructions"); + const brand = brandFlags(flags); + const input = GenerateCampaignInputSchema.parse({ website, products, goal, ...(instructions ? { instructions } : {}), + ...(Object.keys(brand).length ? { brand } : {}), + ...(offer ? { offer } : {}), + }); + return { + ...commonCommand(flags), + kind: "generate", + input, + trace: flags.booleans.has("--trace"), }; - if (goal !== "promotion") { - if (offerDescription || code || endsAt) { - throw new CliArgumentError( - "invalid-arguments", - "Offer flags require --goal promotion.", - ); - } - return base; - } - if (!offerDescription) { +} + +/** Keeps offer requirements identical in explicit and guided generation. */ +function offerFlags(flags: CliFlags, goal: string) { + const description = flagValue(flags, "--offer"); + const code = flagValue(flags, "--discount-code"); + const endsAt = flagValue(flags, "--offer-ends-at"); + if (goal !== "promotion" && (description || code || endsAt)) + throw new CliArgumentError( + "invalid-arguments", + "Offer flags require --goal promotion.", + ); + if (goal === "promotion" && !description) throw new CliArgumentError( "missing-arguments", "Promotion requires --offer.", ); - } + return goal === "promotion" + ? { description, ...(code ? { code } : {}), ...(endsAt ? { endsAt } : {}) } + : undefined; +} + +/** Parses a render-only invocation that never requires an API key. */ +function parseRender(flags: CliFlags): RenderCommand { + const allowed = new Set([ + "--campaign", + "--output", + "--brand", + "--save-brand", + "--primary-colour", + "--background-colour", + "--text-colour", + "--heading-font", + "--body-font", + ]); + if ( + [...flags.values.keys()].some((key) => !allowed.has(key)) || + flags.booleans.has("--trace") + ) + throw new CliArgumentError( + "invalid-arguments", + "Generation-only flags cannot be used with punch render.", + ); + const campaignPath = flagValue(flags, "--campaign"); + if (!campaignPath) + throw new CliArgumentError( + "missing-arguments", + "Render requires --campaign and --output.", + ); return { - ...base, - offer: { - description: offerDescription, - ...(code ? { code } : {}), - ...(endsAt ? { endsAt } : {}), - }, + ...commonCommand(flags), + kind: "render", + campaignPath: localPath(campaignPath), + brand: brandFlags(flags), }; } diff --git a/src/cli/bin.ts b/src/cli/bin.ts index d755f05..e19d1a0 100644 --- a/src/cli/bin.ts +++ b/src/cli/bin.ts @@ -1,19 +1,27 @@ #!/usr/bin/env node import { runCli } from "./run-cli.js"; +import { createTerminalPrompts, openPreview } from "./terminal.js"; const controller = new AbortController(); const cancel = (): void => controller.abort(); process.once("SIGINT", cancel); process.once("SIGTERM", cancel); +const prompts = createTerminalPrompts(controller); const exitCode = await runCli(process.argv.slice(2), { stdout: (value) => process.stdout.write(value), stderr: (value) => process.stderr.write(value), env: process.env, signal: controller.signal, + stdinIsTTY: Boolean(process.stdin.isTTY), + stdoutIsTTY: Boolean(process.stdout.isTTY), + ask: prompts.ask, + openPreview, }); +prompts.close(); + process.removeListener("SIGINT", cancel); process.removeListener("SIGTERM", cancel); process.exitCode = exitCode; diff --git a/src/cli/cli-error.ts b/src/cli/cli-error.ts new file mode 100644 index 0000000..bbc1c98 --- /dev/null +++ b/src/cli/cli-error.ts @@ -0,0 +1,16 @@ +export type CliArgumentErrorCode = + | "invalid-arguments" + | "missing-arguments" + | "unknown-command" + | "invalid-file" + | "cancelled"; + +/** Stable usage failure without echoing untrusted input or filesystem contents. */ +export class CliArgumentError extends Error { + readonly code: CliArgumentErrorCode; + constructor(code: CliArgumentErrorCode, message: string) { + super(message); + this.name = "CliArgumentError"; + this.code = code; + } +} diff --git a/src/cli/execute-command.ts b/src/cli/execute-command.ts new file mode 100644 index 0000000..52febb0 --- /dev/null +++ b/src/cli/execute-command.ts @@ -0,0 +1,120 @@ +import { z } from "zod"; + +import { ResolvedBrandSchema } from "../brand/settings.js"; +import { + generateCampaign, + type GenerateCampaignResult, +} from "../core/generate-campaign.js"; +import { renderCampaign } from "../core/render-campaign.js"; +import { CampaignSchema } from "../core/schemas/campaign.js"; +import { + assertOutputAvailable, + writeCampaignOutput, +} from "../output/write-output.js"; +import { createAnthropicProvider } from "../providers/anthropic.js"; +import type { GenerateCommand, RenderCommand } from "./arguments.js"; +import { CliArgumentError } from "./cli-error.js"; +import { editBrand } from "./guide-brand.js"; +import { confirm, type CliIo } from "./io.js"; +import { + readBrandProfile, + readLocalJson, + saveBrandProfile, +} from "./local-files.js"; +import { reviewResult } from "./preview-result.js"; + +const SavedCampaignSchema = z.object({ + generator: z.literal("punch"), + campaign: CampaignSchema, + brand: ResolvedBrandSchema.optional(), +}); + +/** Executes a validated command with optional human review around the unchanged engine. */ +export async function executeCommand( + command: GenerateCommand | RenderCommand, + io: CliIo, + guided: boolean, +): Promise { + await assertOutputAvailable(command.output, command.force); + const initial = + command.kind === "generate" + ? await generate(command, io, guided) + : await renderSaved(command); + const reviewed = guided + ? await reviewResult(io, initial) + : { result: initial }; + if (io.signal.aborted) + throw new CliArgumentError("cancelled", "Cancelled before saving."); + const output = await writeCampaignOutput(reviewed.result, command.output, { + force: command.force, + }); + const profile = + reviewed.saveBrandPath === undefined + ? command.saveBrandPath + : reviewed.saveBrandPath; + if (profile && reviewed.result.brand) { + try { + await saveBrandProfile(profile, reviewed.result.brand.settings); + } catch { + throw new CliArgumentError( + "invalid-file", + `Campaign saved in ${output}, but the profile could not be saved. Choose a new profile filename.`, + ); + } + } + return output; +} + +/** Runs generation only after credentials exist and the guided brand review is confirmed. */ +async function generate( + command: GenerateCommand, + io: CliIo, + guided: boolean, +): Promise { + const apiKey = io.env.ANTHROPIC_API_KEY?.trim(); + if (!apiKey) + throw new CliArgumentError( + "missing-arguments", + "ANTHROPIC_API_KEY is required.", + ); + const profile = command.brandPath + ? await readBrandProfile(command.brandPath) + : {}; + return generateCampaign( + { ...command.input, brand: { ...profile, ...command.input.brand } }, + { + provider: createAnthropicProvider({ apiKey }), + signal: io.signal, + trace: command.trace, + ...(guided + ? { + reviewBrand: async (brand) => { + const changes = await editBrand(io, brand); + await confirm(io, "Generate this campaign using AI?"); + io.stderr("Generating and validating the campaign…\n"); + return changes; + }, + } + : {}), + }, + ); +} + +/** Loads semantic content and saved settings but does not trust prior validation claims. */ +async function renderSaved( + command: RenderCommand, +): Promise { + const data = await readLocalJson(command.campaignPath, 1_000_000); + const saved = SavedCampaignSchema.safeParse(data); + const campaign = saved.success + ? saved.data.campaign + : CampaignSchema.parse(data); + const profile = command.brandPath + ? await readBrandProfile(command.brandPath) + : {}; + return renderCampaign(campaign, { + ...(saved.success ? saved.data.brand?.settings : {}), + ...profile, + ...command.brand, + }); +} diff --git a/src/cli/flags.ts b/src/cli/flags.ts new file mode 100644 index 0000000..730eab3 --- /dev/null +++ b/src/cli/flags.ts @@ -0,0 +1,91 @@ +import { + parseBrandSettings, + type BrandSettings, + type BrandSettingKey, +} from "../brand/settings.js"; +import { CliArgumentError } from "./cli-error.js"; + +export const BRAND_FLAGS: Readonly> = { + "--primary-colour": "primaryColour", + "--background-colour": "backgroundColour", + "--text-colour": "textColour", + "--heading-font": "headingFont", + "--body-font": "bodyFont", +}; +const VALUE_FLAGS = new Set([ + "--website", + "--product", + "--goal", + "--output", + "--instructions", + "--offer", + "--discount-code", + "--offer-ends-at", + "--brand", + "--save-brand", + "--campaign", + ...Object.keys(BRAND_FLAGS), +]); +const BOOLEAN_FLAGS = new Set([ + "--trace", + "--json", + "--force", + "--no-interactive", + "--interactive", +]); +export type CliFlags = { values: Map; booleans: Set }; + +/** Parses flags first, so unknown flags and duplicate scalars fail before prompting. */ +export function readFlags(argv: readonly string[]): CliFlags { + const values = new Map(); + const booleans = new Set(); + for (let index = 0; index < argv.length; index += 1) { + const flag = argv[index]!; + if (BOOLEAN_FLAGS.has(flag)) { + booleans.add(flag); + continue; + } + if (!VALUE_FLAGS.has(flag)) + throw new CliArgumentError( + "invalid-arguments", + "Unknown or misplaced flag.", + ); + const value = argv[++index]; + if (value === undefined || value.startsWith("--")) + throw new CliArgumentError( + "invalid-arguments", + "A flag is missing its value.", + ); + if (flag !== "--product" && values.has(flag)) + throw new CliArgumentError( + "invalid-arguments", + "A scalar flag was supplied more than once.", + ); + values.set(flag, [...(values.get(flag) ?? []), value]); + } + return { values, booleans }; +} + +/** Reads a single already-checked flag value. */ +export function flagValue(flags: CliFlags, flag: string): string | undefined { + return flags.values.get(flag)?.[0]; +} + +/** Parses overrides through the same schema used by the engine and wizard. */ +export function brandFlags(flags: CliFlags): BrandSettings { + return parseBrandSettings( + Object.fromEntries( + Object.entries(BRAND_FLAGS).flatMap(([flag, key]) => { + const value = flagValue(flags, flag); + return value === undefined ? [] : [[key, value]]; + }), + ), + ); +} + +/** Serialises a draft back through the canonical explicit parser. */ +export function flagsToArgv(flags: CliFlags): string[] { + return [...flags.values] + .flatMap(([key, values]) => values.flatMap((value) => [key, value])) + .concat([...flags.booleans]); +} diff --git a/src/cli/guide-brand.ts b/src/cli/guide-brand.ts new file mode 100644 index 0000000..dd24974 --- /dev/null +++ b/src/cli/guide-brand.ts @@ -0,0 +1,124 @@ +import { readableInk } from "../brand/colour.js"; +import { resolveBrand } from "../brand/resolve-brand.js"; +import { + BRAND_KEYS, + BrandStyleError, + CompleteBrandSettingsSchema, + type BrandSettings, + type ResolvedBrand, +} from "../brand/settings.js"; +import { ask, type CliIo } from "./io.js"; + +const LABELS = [ + "Primary colour", + "Background", + "Text colour", + "Heading font", + "Body font", +]; +const WARNINGS = { + "text-contrast-fallback": + "Detected text was unreadable on the background; a readable fallback is shown.", + "accessible-link-colour": + "The primary colour stays on buttons; links use readable ink.", + "font-fallbacks": + "Custom fonts are named, not downloaded. Email-safe fallback fonts are included.", +}; + +/** Displays validated settings and their origins, with optional terminal swatches. */ +function showBrand(io: CliIo, brand: ResolvedBrand): void { + io.stderr("\n3. Review branding — Enter keeps these settings\n"); + BRAND_KEYS.forEach((key, index) => { + const value = brand.settings[key]; + const swatch = + key.endsWith("Colour") && io.stdoutIsTTY && !io.env.NO_COLOR + ? colourSwatch(value) + : ""; + io.stderr( + ` ${index + 1}. ${LABELS[index]}: ${swatch}${value} (${brand.sources[key]})\n`, + ); + }); + brand.warnings.forEach((warning) => + io.stderr(` Note: ${WARNINGS[warning]}\n`), + ); +} + +/** Produces a colour swatch only from a validated six-digit hexadecimal value. */ +function colourSwatch(hex: string): string { + const channels = [1, 3, 5].map((offset) => + Number.parseInt(hex.slice(offset, offset + 2), 16), + ); + return `\u001b[48;2;${channels.join(";")}m \u001b[0m `; +} + +/** Lets the user correct individual slots while retaining untouched detection provenance. */ +export async function editBrand( + io: CliIo, + initial: ResolvedBrand, +): Promise { + let changes: BrandSettings = {}; + let current = initial; + for (;;) { + showBrand(io, current); + const choice = await ask( + io, + "Change 1–5, reset changes (r), or Enter to continue: ", + ); + if (!choice) return changes; + if (choice === "r") { + changes = {}; + current = initial; + continue; + } + const index = Number(choice) - 1; + const key = BRAND_KEYS[index]; + if (!key) { + io.stderr("Choose a number from 1 to 5.\n"); + continue; + } + const value = await ask( + io, + `${LABELS[index]} (${key.endsWith("Colour") ? "#RRGGBB" : "one font family name"}): `, + ); + const parsed = CompleteBrandSettingsSchema.shape[key].safeParse(value); + if (!parsed.success) { + io.stderr( + "Use six-digit hex colours or a single plain font family name.\n", + ); + continue; + } + const candidate = { ...changes, [key]: parsed.data }; + const accepted = await validateEdit(io, initial, candidate); + if (accepted) { + changes = accepted.changes; + current = accepted.brand; + } + } +} + +/** Offers an explicit text correction instead of silently changing a manual colour. */ +async function validateEdit( + io: CliIo, + initial: ResolvedBrand, + changes: BrandSettings, +): Promise<{ changes: BrandSettings; brand: ResolvedBrand } | undefined> { + try { + const brand = resolveBrand({}, { ...initial.settings, ...changes }); + for (const key of BRAND_KEYS) + if (changes[key] === undefined) brand.sources[key] = initial.sources[key]; + return { changes, brand }; + } catch (error) { + if (!(error instanceof BrandStyleError)) throw error; + const suggested = readableInk( + changes.backgroundColour ?? initial.settings.backgroundColour, + ); + io.stderr( + `That text/background combination is unreadable. Suggested text: ${suggested}.\n`, + ); + if ( + !/^y(?:es)?$/iu.test(await ask(io, "Apply that text correction? [y/N] ")) + ) + return undefined; + return validateEdit(io, initial, { ...changes, textColour: suggested }); + } +} diff --git a/src/cli/guide-command.ts b/src/cli/guide-command.ts new file mode 100644 index 0000000..08db3cb --- /dev/null +++ b/src/cli/guide-command.ts @@ -0,0 +1,95 @@ +import { z } from "zod"; +import { HttpUrlSchema } from "../core/schemas/index.js"; +import { parseCliArguments, type CliCommand } from "./arguments.js"; +import { CliArgumentError } from "./cli-error.js"; +import { flagsToArgv, readFlags, type CliFlags } from "./flags.js"; +import { promptBrief, promptField, promptProducts } from "./guide-fields.js"; +import { confirm, interactiveAllowed, type CliIo } from "./io.js"; + +const PathSchema = z + .string() + .trim() + .min(1) + .max(4096) + .regex(/^[^\u0000-\u001f\u007f]+$/u); + +/** Chooses explicit mode or a terminal-only guide, without prompting after unknown flags. */ +export async function resolveInvocation( + argv: readonly string[], + io: CliIo, +): Promise<{ command: CliCommand; guided: boolean }> { + if ( + !interactiveAllowed(argv, io) || + argv.some((arg) => ["--help", "-h", "--version", "-v"].includes(arg)) + ) { + return { command: parseCliArguments(argv), guided: false }; + } + const kind = argv[0] ?? "generate"; + if (kind !== "generate" && kind !== "render") + return { command: parseCliArguments(argv), guided: false }; + const flags = readFlags(argv.slice(1)); + if (argv.length && !flags.booleans.has("--interactive")) { + try { + return { command: parseCliArguments(argv), guided: false }; + } catch (error) { + if ( + !(error instanceof CliArgumentError) || + error.code !== "missing-arguments" + ) + throw error; + } + } + return { command: await guideCommand(kind, flags, io), guided: true }; +} + +/** Collects missing inputs then reparses the same flags used by automation. */ +async function guideCommand( + kind: "generate" | "render", + flags: CliFlags, + io: CliIo, +): Promise { + io.stderr( + `\nPunch — ${kind === "generate" ? "guided generation" : "restyle an existing campaign"}\n\n1. Sources\n`, + ); + if (kind === "generate") { + await promptField(io, flags, "--website", "Brand website", HttpUrlSchema); + await promptProducts(io, flags); + await promptBrief(io, flags); + } else { + await promptField( + io, + flags, + "--campaign", + "Existing campaign.json", + PathSchema, + ); + } + await promptField( + io, + flags, + "--brand", + "Saved brand profile (optional)", + PathSchema, + "", + true, + ); + await promptField( + io, + flags, + "--output", + "New output directory", + PathSchema, + kind === "render" ? "./campaign-restyled" : "./campaign", + ); + const command = parseCliArguments([kind, ...flagsToArgv(flags)]); + if (command.kind === "generate") { + io.stderr( + `\nWebsite: ${command.input.website}\nProducts: ${command.input.products.length}\nGoal: ${command.input.goal}\nOutput: ${command.output}\n`, + ); + await confirm( + io, + "Fetch these pages and review the detected branding? No AI call yet.", + ); + } + return command; +} diff --git a/src/cli/guide-fields.ts b/src/cli/guide-fields.ts new file mode 100644 index 0000000..cc72eef --- /dev/null +++ b/src/cli/guide-fields.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; + +import { CampaignGoalSchema, HttpUrlSchema } from "../core/schemas/index.js"; +import { + LongTextSchema, + ShortTextSchema, + CodeTextSchema, +} from "../core/schemas/primitives.js"; +import { flagValue, type CliFlags } from "./flags.js"; +import { ask, type CliIo } from "./io.js"; + +/** Fills one missing flag through its canonical schema, retrying invalid answers. */ +export async function promptField( + io: CliIo, + flags: CliFlags, + flag: string, + label: string, + schema: z.ZodType, + fallback = "", + optional = false, +): Promise { + const existing = flagValue(flags, flag); + if (existing !== undefined) { + schema.parse(existing); + return; + } + for (;;) { + const answer = + (await ask(io, `${label}${fallback ? ` [${fallback}]` : ""}: `)) || + fallback; + if (!answer && optional) return; + const parsed = schema.safeParse(answer); + if (parsed.success) { + flags.values.set(flag, [parsed.data]); + return; + } + io.stderr("That value is not valid. Please try again.\n"); + } +} + +/** Collects an ordered, unique product list with explicit add/remove controls. */ +export async function promptProducts( + io: CliIo, + flags: CliFlags, +): Promise { + const products = (flags.values.get("--product") ?? []).map((url) => + HttpUrlSchema.parse(url), + ); + for (;;) { + io.stderr( + products.length + ? `Products:\n${products.map((url, index) => ` ${index + 1}. ${url}`).join("\n")}\n` + : "Add at least one product page.\n", + ); + const answer = await ask( + io, + "Product URL, 'remove 1', or Enter to continue: ", + ); + if ( + !answer && + products.length >= 1 && + products.length <= 6 && + new Set(products).size === products.length + ) + break; + const remove = /^remove ([1-6])$/u.exec(answer); + if (remove) { + products.splice(Number(remove[1]) - 1, 1); + continue; + } + const parsed = HttpUrlSchema.safeParse(answer); + if ( + !parsed.success || + products.length >= 6 || + products.includes(parsed.data) + ) { + io.stderr( + "Use one to six unique HTTP(S) product URLs. Remove a product to correct the list.\n", + ); + continue; + } + products.push(parsed.data); + } + flags.values.set("--product", products); +} + +/** Collects the campaign safety policy and its optional human brief. */ +export async function promptBrief(io: CliIo, flags: CliFlags): Promise { + io.stderr( + "\n2. Campaign brief\n sales: sell existing products\n product-launch: introduce products\n promotion: promote an explicit offer\n", + ); + await promptField(io, flags, "--goal", "Goal", CampaignGoalSchema, "sales"); + if (flagValue(flags, "--goal") === "promotion") { + await promptField( + io, + flags, + "--offer", + "Offer description", + ShortTextSchema, + ); + await promptField( + io, + flags, + "--discount-code", + "Discount code (optional)", + CodeTextSchema, + "", + true, + ); + await promptField( + io, + flags, + "--offer-ends-at", + "Expiry with timezone (optional)", + z.iso.datetime({ offset: true }), + "", + true, + ); + } + await promptField( + io, + flags, + "--instructions", + "Campaign direction (optional)", + LongTextSchema, + "", + true, + ); +} diff --git a/src/cli/help.ts b/src/cli/help.ts index bdec798..5016fd8 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -1,7 +1,9 @@ export const CLI_HELP = `Punch — grounded ecommerce email generation Usage: + punch Start the guide in an interactive terminal punch generate --website --product ... --goal --output + punch render --campaign --output Required: --website Brand website @@ -10,6 +12,14 @@ Required: --output New output directory Optional: + --brand Load saved brand settings + --save-brand Save the final settings as a reusable JSON profile + --primary-colour Override the primary colour (#RRGGBB) + --background-colour Override the email background + --text-colour Override text (must pass contrast checks) + --heading-font Override one heading font family + --body-font Override one body font family + --interactive Review branding and preview even with complete inputs --instructions Additional bounded campaign direction --offer Required with the promotion goal --discount-code Structured promotion code @@ -17,10 +27,15 @@ Optional: --trace Write redacted structured stage traces --json Emit exactly one JSON result on stdout --force Fail closed unless atomic replacement is supported - --no-interactive Explicitly disable guided input + --no-interactive Never prompt (also implied by --json, CI or piped input) --help Show this help --version Show the installed version Environment: ANTHROPIC_API_KEY Required for generation; never written to output + +Render reuses existing campaign copy and needs no API key or AI call. +Its validation covers rendering, not fresh product or claim grounding. +Manual flags override a saved profile, which overrides detected website styles. +Custom fonts are named with fallbacks; font files are not downloaded or embedded. `; diff --git a/src/cli/io.ts b/src/cli/io.ts new file mode 100644 index 0000000..fb32af5 --- /dev/null +++ b/src/cli/io.ts @@ -0,0 +1,61 @@ +import { ExtractionError } from "../extraction/extraction-error.js"; + +export type CliIo = Readonly<{ + stdout: (value: string) => void; + stderr: (value: string) => void; + env: Readonly>; + signal: AbortSignal; + stdinIsTTY?: boolean; + stdoutIsTTY?: boolean; + ask?: (question: string) => Promise; + openPreview?: (path: string) => Promise; +}>; + +/** Reads one bounded answer and converts EOF, interruption and cancellation to a safe failure. */ +export async function ask(io: CliIo, question: string): Promise { + if (io.signal.aborted || !io.ask) + throw new ExtractionError("cancelled", false); + let answer: string; + try { + answer = await io.ask(question); + } catch { + throw new ExtractionError("cancelled", false); + } + if (io.signal.aborted) throw new ExtractionError("cancelled", false); + if ( + answer.length > 4096 || + /[\u0000-\u0008\u000b-\u001f\u007f]/u.test(answer) + ) { + io.stderr("Please enter a shorter value without control characters.\n"); + return ask(io, question); + } + return answer.trim(); +} + +/** Requires explicit agreement before paid generation or final publication. */ +export async function confirm(io: CliIo, question: string): Promise { + const answer = await ask(io, `${question} [y/N] `); + if (!/^y(?:es)?$/iu.test(answer)) + throw new ExtractionError("cancelled", false); +} + +/** Allows guided input only in a real interactive terminal, never automation. */ +export function interactiveAllowed( + argv: readonly string[], + io: CliIo, +): boolean { + const ci = [ + "CI", + "CONTINUOUS_INTEGRATION", + "GITHUB_ACTIONS", + "BUILD_NUMBER", + ].some((key) => Boolean(io.env[key])); + return Boolean( + io.stdinIsTTY && + io.stdoutIsTTY && + io.ask && + !ci && + !argv.includes("--json") && + !argv.includes("--no-interactive"), + ); +} diff --git a/src/cli/local-files.ts b/src/cli/local-files.ts new file mode 100644 index 0000000..ff32d68 --- /dev/null +++ b/src/cli/local-files.ts @@ -0,0 +1,97 @@ +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { link, open, unlink } from "node:fs/promises"; +import { basename, dirname, join, resolve } from "node:path"; + +import { BrandProfileSchema, type BrandSettings } from "../brand/settings.js"; +import { + assertSameDirectory, + safeParentIdentity, +} from "../output/filesystem-safety.js"; +import { CliArgumentError } from "./cli-error.js"; + +/** Rejects empty paths and terminal-control characters before filesystem use. */ +export function localPath(value: string): string { + if (!value.trim() || /[\u0000-\u001f\u007f]/u.test(value)) throw fileError(); + return resolve(value); +} + +/** Reads bounded regular JSON through a no-follow descriptor. */ +export async function readLocalJson( + path: string, + limit: number, +): Promise { + const target = localPath(path); + try { + const identity = await safeParentIdentity(dirname(target)); + const file = await open( + target, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK, + ); + try { + const before = await file.stat(); + if (!before.isFile() || before.nlink !== 1 || before.size > limit) + throw fileError(); + const buffer = Buffer.alloc(limit + 1); + const { bytesRead } = await file.read(buffer, 0, buffer.length, 0); + const after = await file.stat(); + if ( + bytesRead > limit || + bytesRead !== before.size || + after.size !== before.size || + after.mtimeMs !== before.mtimeMs + ) + throw fileError(); + await assertSameDirectory(dirname(target), identity); + return JSON.parse(buffer.subarray(0, bytesRead).toString("utf8")); + } finally { + await file.close(); + } + } catch { + throw fileError(); + } +} + +/** Reads only the versioned, strict brand-profile format. */ +export async function readBrandProfile(path: string): Promise { + const parsed = BrandProfileSchema.safeParse(await readLocalJson(path, 8192)); + if (!parsed.success) throw fileError(); + return parsed.data.settings; +} + +/** Atomically creates a profile without overwriting any existing file or symlink. */ +export async function saveBrandProfile( + path: string, + settings: BrandSettings, +): Promise { + const target = localPath(path); + const profile = BrandProfileSchema.parse({ version: "1", settings }); + const parent = dirname(target); + const staging = join(parent, `.${basename(target)}.punch-${randomUUID()}`); + let created = false; + try { + const identity = await safeParentIdentity(parent); + const file = await open(staging, "wx", 0o600); + created = true; + try { + await file.writeFile(`${JSON.stringify(profile, null, 2)}\n`); + await file.sync(); + } finally { + await file.close(); + } + await assertSameDirectory(parent, identity); + await link(staging, target); + } catch { + throw fileError(); + } finally { + if (created) await unlink(staging).catch(() => undefined); + } +} + +/** Creates a safe error for malformed, oversized, linked or occupied local files. */ +function fileError(): CliArgumentError { + return new CliArgumentError( + "invalid-file", + "Use a bounded regular JSON file and an existing real parent directory. Saving requires a new filename; linked files are refused.", + ); +} diff --git a/src/cli/preview-result.ts b/src/cli/preview-result.ts new file mode 100644 index 0000000..6f76fed --- /dev/null +++ b/src/cli/preview-result.ts @@ -0,0 +1,80 @@ +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { resolveBrand } from "../brand/resolve-brand.js"; +import type { GenerateCampaignResult } from "../core/generate-campaign.js"; +import { restyleCampaign } from "../core/render-campaign.js"; +import { writeCampaignOutput } from "../output/write-output.js"; +import { editBrand } from "./guide-brand.js"; +import { ask, type CliIo } from "./io.js"; +import { localPath } from "./local-files.js"; + +/** Reviews the actual result and restyles it without rerunning extraction or generation. */ +export async function reviewResult( + io: CliIo, + initial: GenerateCampaignResult, +): Promise<{ result: GenerateCampaignResult; saveBrandPath?: string | null }> { + let result = initial; + let saveBrandPath: string | null | undefined; + let temporary: string | undefined; + let revision = 0; + try { + for (;;) { + const choice = await ask( + io, + "\n4. Preview: (p) open email, (b) adjust branding, (s) save profile, Enter to export: ", + ); + if (!choice) + return { + result, + ...(saveBrandPath !== undefined ? { saveBrandPath } : {}), + }; + if (choice === "b") { + const changes = await editBrand(io, result.brand ?? resolveBrand()); + result = await restyleCampaign(result, changes); + io.stderr( + "Re-rendered the same campaign. No AI call or copy change.\n", + ); + } else if (choice === "s") { + const path = await ask( + io, + "New profile filename (blank cancels saving): ", + ); + saveBrandPath = path ? localPath(path) : null; + } else if (choice === "p") { + temporary ??= await mkdtemp( + join(await realpath(tmpdir()), "punch-preview-"), + ); + await openResultPreview( + io, + result, + join(temporary, `revision-${++revision}`), + ); + } else io.stderr("Choose p, b, s, or Enter.\n"); + } + } finally { + if (temporary) await rm(temporary, { recursive: true, force: true }); + } +} + +/** Writes one fresh preview and opens it only after the user selected that action. */ +async function openResultPreview( + io: CliIo, + result: GenerateCampaignResult, + destination: string, +): Promise { + const output = await writeCampaignOutput(result, destination); + const path = join(output, "email.html"); + io.stderr( + `Preview: ${path}\nTemporary previews are removed when this session ends.\n`, + ); + if (io.openPreview) + await io + .openPreview(path) + .catch(() => + io.stderr( + "Could not open a browser automatically. Open the preview path above.\n", + ), + ); +} diff --git a/src/cli/run-cli.ts b/src/cli/run-cli.ts index 28b8d85..e1af69f 100644 --- a/src/cli/run-cli.ts +++ b/src/cli/run-cli.ts @@ -1,22 +1,19 @@ import { ZodError } from "zod"; -import { generateCampaign } from "../core/generate-campaign.js"; import { ExtractionError } from "../extraction/extraction-error.js"; import { GenerationError } from "../generation/generation-error.js"; -import { OutputError, writeCampaignOutput } from "../output/index.js"; -import { createAnthropicProvider } from "../providers/anthropic.js"; -import { CliArgumentError, parseCliArguments } from "./arguments.js"; +import { OutputError } from "../output/index.js"; +import { BrandStyleError } from "../brand/settings.js"; +import { PublicFetchError } from "../extraction/http/index.js"; +import { CliArgumentError } from "./arguments.js"; +import { resolveInvocation } from "./guide-command.js"; +import { executeCommand } from "./execute-command.js"; +import type { CliIo } from "./io.js"; +export type { CliIo } from "./io.js"; import { CLI_HELP } from "./help.js"; export const PUNCH_VERSION = "0.1.0"; -export type CliIo = Readonly<{ - stdout: (value: string) => void; - stderr: (value: string) => void; - env: Readonly>; - signal: AbortSignal; -}>; - type CliFailure = Readonly<{ code: string; message: string; @@ -30,7 +27,7 @@ export async function runCli( ): Promise { let json = argv.includes("--json"); try { - const command = parseCliArguments(argv); + const { command, guided } = await resolveInvocation(argv, io); if (command.kind === "help") { io.stdout(CLI_HELP); return 0; @@ -40,22 +37,8 @@ export async function runCli( return 0; } json = command.json; - const apiKey = io.env.ANTHROPIC_API_KEY?.trim(); - if (!apiKey) { - throw new CliArgumentError( - "missing-arguments", - "ANTHROPIC_API_KEY is required.", - ); - } - const result = await generateCampaign(command.input, { - provider: createAnthropicProvider({ apiKey }), - signal: io.signal, - trace: command.trace, - }); - const output = await writeCampaignOutput(result, command.output, { - force: command.force, - }); - writeSuccess(io, json, output); + const output = await executeCommand(command, io, guided); + writeSuccess(io, json, output, command.kind); return 0; } catch (error) { const failure = normaliseCliFailure(error); @@ -65,12 +48,21 @@ export async function runCli( } /** Writes one stable success result without mixing stdout modes. */ -function writeSuccess(io: CliIo, json: boolean, output: string): void { +function writeSuccess( + io: CliIo, + json: boolean, + output: string, + kind: "generate" | "render", +): void { if (json) { - io.stdout(`${JSON.stringify({ ok: true, status: "valid", output })}\n`); + io.stdout( + `${JSON.stringify({ ok: true, status: "valid", output, validationScope: kind === "render" ? "render-only" : "generation-and-render" })}\n`, + ); return; } - io.stdout(`Generated a validated campaign in ${output}\n`); + io.stdout( + `${kind === "render" ? "Rendered" : "Generated"} a validated campaign in ${output}\n`, + ); } /** Writes exactly one JSON failure or one plain stderr diagnostic. */ @@ -94,11 +86,19 @@ function normaliseCliFailure(error: unknown): CliFailure { retryable: false, }; } - if (error instanceof ExtractionError || error instanceof GenerationError) { + if ( + error instanceof ExtractionError || + error instanceof GenerationError || + error instanceof BrandStyleError || + error instanceof PublicFetchError + ) { return { code: error.code, message: error.message, - retryable: error.retryable, + retryable: + "retryable" in error + ? error.retryable + : ["network", "timeout", "dns-failure"].includes(error.code), }; } if (error instanceof OutputError) { diff --git a/src/cli/terminal.ts b/src/cli/terminal.ts new file mode 100644 index 0000000..86d08ae --- /dev/null +++ b/src/cli/terminal.ts @@ -0,0 +1,51 @@ +import { spawn } from "node:child_process"; +import { createInterface, type Interface } from "node:readline/promises"; +import { pathToFileURL } from "node:url"; + +/** Lazily owns readline only when the CLI has selected interactive mode. */ +export function createTerminalPrompts(controller: AbortController) { + let readline: Interface | undefined; + let pending = false; + return { + ask: async (question: string): Promise => { + if (!readline) { + readline = createInterface({ + input: process.stdin, + output: process.stderr, + terminal: Boolean(process.stdin.isTTY && process.stderr.isTTY), + }); + readline.on("SIGINT", () => controller.abort()); + readline.on("close", () => { + if (pending) controller.abort(); + }); + } + pending = true; + try { + return await readline.question(question, { signal: controller.signal }); + } finally { + pending = false; + } + }, + close: (): void => readline?.close(), + }; +} + +/** Opens only a caller-requested, generated preview using arguments rather than a shell. */ +export async function openPreview(path: string): Promise { + const command = + process.platform === "darwin" + ? "open" + : process.platform === "win32" + ? "explorer.exe" + : "xdg-open"; + await new Promise((resolve, reject) => { + const child = spawn(command, [pathToFileURL(path).href], { + shell: false, + stdio: "ignore", + }); + child.once("error", reject); + child.once("exit", (code) => + code === 0 ? resolve() : reject(new Error("Preview opener failed.")), + ); + }); +} diff --git a/src/core/generate-campaign.ts b/src/core/generate-campaign.ts index e8b7030..8d23326 100644 --- a/src/core/generate-campaign.ts +++ b/src/core/generate-campaign.ts @@ -4,13 +4,18 @@ import type { GenerateCampaignInput, ProductEvidence, } from "./schemas/index.js"; -import { runCampaignPipeline } from "./run-campaign-pipeline.js"; +import { + runCampaignPipeline, + type CampaignPipelineRun, +} from "./run-campaign-pipeline.js"; import type { PunchProvider } from "../providers/anthropic.js"; import { renderCampaignHtml } from "../rendering/index.js"; import { validateRenderedCampaign } from "../validation/index.js"; import type { GenerationUsage } from "../providers/index.js"; +import type { BrandReviewer, ResolvedBrand } from "../brand/settings.js"; export type GenerateCampaignOptions = Readonly<{ + reviewBrand?: BrandReviewer; provider: PunchProvider; signal?: AbortSignal; trace?: boolean; @@ -19,6 +24,7 @@ export type GenerateCampaignOptions = Readonly<{ }>; export type CampaignValidation = Readonly<{ + scope?: "generation-and-render" | "render-only"; valid: true; checks: ReadonlyArray>; }>; @@ -33,6 +39,7 @@ export type CampaignTrace = Readonly<{ }>; export type GenerateCampaignResult = Readonly<{ + brand?: ResolvedBrand; campaign: Campaign; html: string; validation: CampaignValidation; @@ -46,6 +53,7 @@ export async function generateCampaign( options: GenerateCampaignOptions, ): Promise { const run = await runCampaignPipeline(input, { + ...(options.reviewBrand ? { reviewBrand: options.reviewBrand } : {}), model: options.provider.textModel, ...(options.signal ? { signal: options.signal } : {}), ...(options.callTimeoutMs !== undefined @@ -56,7 +64,10 @@ export async function generateCampaign( : {}), }); const campaign = run.generation.finalCampaign; - const html = await renderCampaignHtml(campaign); + const html = await renderCampaignHtml( + campaign, + run.extraction.brand?.settings, + ); const rendered = validateRenderedCampaign(campaign, html); const checks = [ { id: "campaign-grounding", passed: true as const }, @@ -69,22 +80,24 @@ export async function generateCampaign( return { campaign, + ...(run.extraction.brand ? { brand: run.extraction.brand } : {}), html, - validation: { valid: true, checks }, + validation: { valid: true, scope: "generation-and-render", checks }, usage: run.generation.usage, - ...(options.trace - ? { - trace: { - brandProfile: run.extraction.context.brand, - productProfiles: run.extraction.context.products, - draft: run.generation.draft, - critique: run.generation.critique, - ...(run.generation.revisedCampaign - ? { revisedCampaign: run.generation.revisedCampaign } - : {}), - promptVersions: run.generation.promptVersions, - }, - } + ...(options.trace ? { trace: campaignTrace(run) } : {}), + }; +} + +/** Selects only the approved redacted fields for an opt-in generation trace. */ +function campaignTrace(run: CampaignPipelineRun): CampaignTrace { + return { + brandProfile: run.extraction.context.brand, + productProfiles: run.extraction.context.products, + draft: run.generation.draft, + critique: run.generation.critique, + ...(run.generation.revisedCampaign + ? { revisedCampaign: run.generation.revisedCampaign } : {}), + promptVersions: run.generation.promptVersions, }; } diff --git a/src/core/render-campaign.ts b/src/core/render-campaign.ts new file mode 100644 index 0000000..3787149 --- /dev/null +++ b/src/core/render-campaign.ts @@ -0,0 +1,68 @@ +import { resolveBrand } from "../brand/resolve-brand.js"; +import { + BRAND_KEYS, + parseBrandSettings, + type BrandSettings, +} from "../brand/settings.js"; +import { aggregateModelUsage } from "../providers/model-usage.js"; +import { renderCampaignHtml } from "../rendering/render-campaign-html.js"; +import { validateRenderedCampaign } from "../validation/render-validation.js"; +import type { GenerateCampaignResult } from "./generate-campaign.js"; +import { CampaignSchema } from "./schemas/campaign.js"; + +/** Renders existing semantic content without a model, network fetch, or grounding claim. */ +export async function renderCampaign( + input: unknown, + settings: BrandSettings = {}, +): Promise { + const campaign = CampaignSchema.parse(input); + const brand = resolveBrand({}, settings); + const html = await renderCampaignHtml(campaign, brand.settings); + const rendered = validateRenderedCampaign(campaign, html); + return { + campaign, + brand, + html, + validation: { + valid: true, + scope: "render-only", + checks: rendered.checks.map((check) => ({ + id: `render-${check.id}`, + passed: true, + })), + }, + usage: aggregateModelUsage([]), + }; +} + +/** Restyles the same in-memory generated campaign, retaining its generation proof and usage. */ +export async function restyleCampaign( + result: GenerateCampaignResult, + settings: BrandSettings, +): Promise { + const changes = parseBrandSettings(settings); + const rendered = await renderCampaign(result.campaign, { + ...result.brand?.settings, + ...changes, + }); + if (rendered.brand && result.brand) { + for (const key of BRAND_KEYS) { + if (changes[key] === undefined) + rendered.brand.sources[key] = result.brand.sources[key]; + } + } + return { + ...result, + html: rendered.html, + brand: rendered.brand!, + validation: { + ...result.validation, + checks: [ + ...result.validation.checks.filter( + (check) => !check.id.startsWith("render-"), + ), + ...rendered.validation.checks, + ], + }, + }; +} diff --git a/src/core/run-campaign-pipeline.ts b/src/core/run-campaign-pipeline.ts index 754d323..e0990a6 100644 --- a/src/core/run-campaign-pipeline.ts +++ b/src/core/run-campaign-pipeline.ts @@ -5,9 +5,11 @@ import { import { runGeneration, type GenerationRun } from "../generation/index.js"; import type { TextModel } from "../providers/index.js"; import type { PublicFetchSession } from "../extraction/http/index.js"; +import type { BrandReviewer } from "../brand/settings.js"; /** Internal dependencies for extraction followed by semantic generation. */ export type CampaignPipelineOptions = Readonly<{ + reviewBrand?: BrandReviewer; model: TextModel; signal?: AbortSignal; fetchSession?: PublicFetchSession; @@ -28,6 +30,7 @@ export async function runCampaignPipeline( ): Promise { const signal = options.signal ?? new AbortController().signal; const extraction = await extractGenerationContext(input, { + ...(options.reviewBrand ? { reviewBrand: options.reviewBrand } : {}), model: options.model, signal, ...(options.fetchSession ? { fetchSession: options.fetchSession } : {}), diff --git a/src/core/schemas/input.ts b/src/core/schemas/input.ts index 204fb78..e7ff1fd 100644 --- a/src/core/schemas/input.ts +++ b/src/core/schemas/input.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { BrandSettingsSchema } from "../../brand/settings.js"; import { CodeTextSchema, @@ -20,6 +21,7 @@ export const OfferInputSchema = z.strictObject({ }); const generateCampaignInputBase = { + brand: BrandSettingsSchema.optional(), website: HttpUrlSchema, products: z.array(HttpUrlSchema).min(1).max(6), instructions: LongTextSchema.optional(), diff --git a/src/extraction/brand-styles.ts b/src/extraction/brand-styles.ts index a4509b3..c7f3539 100644 --- a/src/extraction/brand-styles.ts +++ b/src/extraction/brand-styles.ts @@ -1,4 +1,11 @@ import postcss from "postcss"; +import type { BrandStyleEvidence } from "../brand/settings.js"; +import { + collectStyleRoles, + resolveStyleRoles, + type SourcedStyleCandidate, + type StyleRoleCandidate, +} from "./style-role-candidates.js"; import type { EvidenceRef } from "../core/schemas/index.js"; import type { CssSource, HtmlSource } from "./contracts.js"; @@ -38,6 +45,7 @@ const MAX_DECLARATION_VALUE_BYTES = 4_096; const MAX_STYLE_VALUES_PER_SOURCE = 32; export type BrandStyles = Readonly<{ + roles: BrandStyleEvidence; colours: string[]; fonts: string[]; colourEvidence: EvidenceRef[]; @@ -45,6 +53,7 @@ export type BrandStyles = Readonly<{ }>; type SourceStyles = Readonly<{ + roles: StyleRoleCandidate[]; colours: string[]; fonts: string[]; reference: EvidenceRef; @@ -65,6 +74,7 @@ function extractStyles(sources: readonly CssSource[]): BrandStyles { const fonts: string[] = []; const colourEvidence: EvidenceRef[] = []; const fontEvidence: EvidenceRef[] = []; + const candidates: SourcedStyleCandidate[] = []; for (const source of sources) { const extracted = extractSourceStyles(source); if (!extracted) { @@ -72,6 +82,12 @@ function extractStyles(sources: readonly CssSource[]): BrandStyles { } colours.push(...extracted.colours); fonts.push(...extracted.fonts); + candidates.push( + ...extracted.roles.map((role) => ({ + ...role, + evidence: { url: source.url, field: source.field }, + })), + ); if (extracted.colours.length > 0) { colourEvidence.push(extracted.reference); } @@ -80,6 +96,7 @@ function extractStyles(sources: readonly CssSource[]): BrandStyles { } } return { + roles: resolveStyleRoles(candidates), colours: unique(colours).slice(0, 8), fonts: unique(fonts).slice(0, 8), colourEvidence: uniqueByJson(colourEvidence).slice(0, 8), @@ -130,7 +147,7 @@ function collectStyleValues(css: string): Omit { appendBounded(fonts, extractFonts(declaration.value)); } }); - return { colours, fonts }; + return { colours, fonts, roles: collectStyleRoles(root) }; } /** Reports whether a declaration value is safe for bounded token extraction. */ @@ -178,7 +195,7 @@ function inlineCssSources( if (style) { sources.push({ url: source.finalUrl, - css: `x{${style}}`, + css: `${elementName(element)}{${style}}`, field: styleField(sources.length), }); } diff --git a/src/extraction/contracts.ts b/src/extraction/contracts.ts index caecbeb..7066ee9 100644 --- a/src/extraction/contracts.ts +++ b/src/extraction/contracts.ts @@ -6,6 +6,11 @@ import type { } from "../core/schemas/index.js"; import type { ModelUsage, TextModel } from "../providers/index.js"; import type { PublicFetchSession } from "./http/index.js"; +import type { + BrandReviewer, + BrandStyleEvidence, + ResolvedBrand, +} from "../brand/settings.js"; export type ExtractionModelCall = Readonly<{ stage: "extract-brand"; @@ -18,11 +23,13 @@ export type ExtractionUsage = Readonly<{ }>; export type ExtractionResult = Readonly<{ + brand?: ResolvedBrand; context: GenerationContext; usage: ExtractionUsage; }>; export type ExtractionOptions = Readonly<{ + reviewBrand?: BrandReviewer; model?: TextModel; signal?: AbortSignal; fetchSession?: PublicFetchSession; @@ -52,6 +59,7 @@ export type CssSource = Readonly<{ }>; export type DeterministicBrandExtraction = Readonly<{ + styleRoles: BrandStyleEvidence; evidence: BrandEvidence; segments: readonly SourceSegment[]; stylesheetUrls: readonly string[]; diff --git a/src/extraction/extract-brand.ts b/src/extraction/extract-brand.ts index ff0878c..b005df6 100644 --- a/src/extraction/extract-brand.ts +++ b/src/extraction/extract-brand.ts @@ -72,6 +72,7 @@ export function extractBrand( return { evidence, + styleRoles: styles.roles, segments: buildSourceSegments(document), stylesheetUrls: discoverStylesheetUrls(document, source.finalUrl), }; diff --git a/src/extraction/extract-generation-context.ts b/src/extraction/extract-generation-context.ts index 19b400e..880d970 100644 --- a/src/extraction/extract-generation-context.ts +++ b/src/extraction/extract-generation-context.ts @@ -37,6 +37,12 @@ import { type PublicFetchSession, } from "./http/index.js"; import { applyBrandFallback } from "./model-fallback.js"; +import { resolveBrand } from "../brand/resolve-brand.js"; +import { + BrandStyleError, + parseBrandSettings, + type ResolvedBrand, +} from "../brand/settings.js"; type ProductSlot = Readonly<{ productId: ProductId; @@ -60,12 +66,15 @@ export async function extractGenerationContext( try { return await runExtraction(parsed, options, session, signal); } catch (error) { - if (error instanceof ExtractionError || error instanceof PublicFetchError) { + if (signal.aborted) throw new ExtractionError("cancelled", false); + if ( + error instanceof ExtractionError || + error instanceof PublicFetchError || + error instanceof BrandStyleError + ) { throw error; } throw new ExtractionError("invalid-source", false); - } finally { - session.dispose(); } } @@ -76,15 +85,17 @@ async function runExtraction( session: PublicFetchSession, signal: AbortSignal, ): Promise { - const deterministic = await extractDeterministicSources( - parsed, - session, - signal, - ); + const deterministic = await readAndDisposeSources(parsed, session, signal); const productEvidence = deterministic.products.map( (extraction) => extraction.evidence, ); assertMinimumProductEvidence(productEvidence); + const resolvedBrand = await reviewBrandStyles( + deterministic.brand, + parsed, + options, + signal, + ); const calls: ExtractionModelCall[] = []; const brand = await applyBrandFallback( deterministic.brand.evidence, @@ -94,7 +105,38 @@ async function runExtraction( calls, ); const context = parseContext(parsed, brand, productEvidence); - return { context, usage: { total: aggregateUsage(calls), calls } }; + return { + context, + brand: resolvedBrand, + usage: { total: aggregateUsage(calls), calls }, + }; +} + +/** Releases network timers exactly once before human review or model work begins. */ +async function readAndDisposeSources( + input: GenerateCampaignInput, + session: PublicFetchSession, + signal: AbortSignal, +): Promise { + try { + return await extractDeterministicSources(input, session, signal); + } finally { + session.dispose(); + } +} + +/** Reviews deterministic style evidence before any optional model call. */ +async function reviewBrandStyles( + brand: DeterministicBrandExtraction, + input: GenerateCampaignInput, + options: ExtractionOptions, + signal: AbortSignal, +): Promise { + const resolved = resolveBrand(brand.styleRoles, input.brand); + if (!options.reviewBrand) return resolved; + const overrides = parseBrandSettings(await options.reviewBrand(resolved)); + assertExtractionNotAborted(signal); + return resolveBrand(brand.styleRoles, { ...input.brand, ...overrides }); } /** Fetches and parses all deterministic brand and product evidence. */ diff --git a/src/extraction/style-role-candidates.ts b/src/extraction/style-role-candidates.ts new file mode 100644 index 0000000..ba66229 --- /dev/null +++ b/src/extraction/style-role-candidates.ts @@ -0,0 +1,172 @@ +import type { Declaration, Root, Rule } from "postcss"; + +import { + CompleteBrandSettingsSchema, + type BrandSettingKey, + type BrandStyleEvidence, +} from "../brand/settings.js"; + +export type StyleRoleCandidate = { + key: BrandSettingKey; + value: string; + rank: number; +}; +export type SourcedStyleCandidate = StyleRoleCandidate & { + evidence: { url: string; field: string }; +}; + +const VARIABLE_ROLES: readonly [BrandSettingKey, RegExp][] = [ + [ + "primaryColour", + /^--(?:(?:brand|color|colour)-)?(?:primary|accent)(?:-color|-colour)?$/iu, + ], + [ + "backgroundColour", + /^--(?:(?:color|colour)-)?(?:background|bg)(?:-color|-colour)?$/iu, + ], + [ + "textColour", + /^--(?:(?:color|colour)-)?(?:text|foreground)(?:-color|-colour)?$/iu, + ], + [ + "headingFont", + /^--(?:font(?:-family)?-heading|heading-font(?:-family)?)$/iu, + ], + ["bodyFont", /^--(?:font(?:-family)?-body|body-font(?:-family)?)$/iu], +]; + +/** Returns only unconditional CSS rules; viewport/hover/dark-mode variants are not guesses. */ +function plainRule(declaration: Declaration): Rule | undefined { + const parent = declaration.parent; + return parent?.type === "rule" && + parent.parent?.type === "root" && + !/:(?!root\b)/iu.test(parent.selector) + ? parent + : undefined; +} + +/** Resolves a short local variable chain without following imports or evaluating CSS. */ +function resolveValue( + value: string, + variables: ReadonlyMap, +): string { + let current = value.trim(); + for (let depth = 0; depth < 4; depth += 1) { + const variable = /^var\((--[a-z\d_-]+)\)$/iu.exec(current); + if (!variable) return current; + current = variables.get(variable[1]!)?.trim() ?? ""; + } + return ""; +} + +/** Normalises only complete opaque hex or integer RGB colour declarations. */ +function cssColour(value: string): string { + const short = /^#([\da-f]{3})$/iu.exec(value); + if (short) + return `#${[...short[1]!].map((digit) => digit.repeat(2)).join("")}`; + const rgb = + /^rgb\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*\)$/iu.exec( + value, + ); + if (!rgb) return value; + const channels = rgb.slice(1).map(Number); + return channels.every((channel) => channel <= 255) + ? `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}` + : ""; +} + +/** Retains a validated family or colour, never executable CSS. */ +function roleValue(key: BrandSettingKey, value: string): string | undefined { + const candidate = key.endsWith("Font") + ? value + .split(",")[0]! + .trim() + .replace(/^(['"])(.*)\1$/u, "$2") + : cssColour(value); + const parsed = CompleteBrandSettingsSchema.shape[key].safeParse(candidate); + return parsed.success ? parsed.data : undefined; +} + +/** Assigns semantic roles only to explicit tokens and recognisable page elements. */ +function declarationRole( + declaration: Declaration, + rule: Rule, +): [BrandSettingKey, number] | undefined { + const property = declaration.prop.toLowerCase(); + const root = /^(?:\s*(?::root|html)\s*,?)+$/u.test(rule.selector); + const variable = root + ? VARIABLE_ROLES.find(([, pattern]) => pattern.test(property)) + : undefined; + if (variable) return [variable[0], 3]; + const page = /^(?:\s*(?:body|html|:root)\s*,?)+$/u.test(rule.selector); + const heading = /^(?:\s*h[1-6]\s*,?)+$/u.test(rule.selector); + const action = + /^(?:\s*(?:button|\.btn|\.button|\.cta|\.button-primary|\.btn-primary)\s*,?)+$/u.test( + rule.selector, + ); + if (page && ["background", "background-color"].includes(property)) + return ["backgroundColour", 2]; + if (page && property === "color") return ["textColour", 2]; + if (action && ["background", "background-color"].includes(property)) + return ["primaryColour", 2]; + if (heading && property === "font-family") return ["headingFont", 2]; + if (page && property === "font-family") return ["bodyFont", 2]; + return undefined; +} + +/** Collects bounded role candidates from the already-parsed inert stylesheet. */ +export function collectStyleRoles(root: Root): StyleRoleCandidate[] { + const variables = new Map(); + root.walkDecls((declaration) => { + const rule = plainRule(declaration); + if ( + rule && + /^(?:\s*(?::root|html)\s*,?)+$/u.test(rule.selector) && + declaration.prop.startsWith("--") && + declaration.value.length <= 4096 && + variables.size < 128 + ) { + variables.set(declaration.prop, declaration.value); + } + }); + const candidates: StyleRoleCandidate[] = []; + root.walkDecls((declaration) => { + const rule = plainRule(declaration); + if (!rule || declaration.value.length > 4096 || candidates.length >= 64) + return; + const role = declarationRole(declaration, rule); + if (!role) return; + const value = roleValue( + role[0], + resolveValue(declaration.value, variables), + ); + if (value !== undefined) + candidates.push({ key: role[0], value, rank: role[1] }); + }); + return candidates; +} + +/** Omits conflicting top-ranked roles instead of picking a colour by source order. */ +export function resolveStyleRoles( + candidates: readonly SourcedStyleCandidate[], +): BrandStyleEvidence { + const result: BrandStyleEvidence = {}; + for (const key of Object.keys( + CompleteBrandSettingsSchema.shape, + ) as BrandSettingKey[]) { + const matching = candidates.filter((candidate) => candidate.key === key); + const rank = Math.max(...matching.map((candidate) => candidate.rank)); + const best = matching.filter((candidate) => candidate.rank === rank); + if ( + best.length === 0 || + new Set(best.map((candidate) => candidate.value)).size !== 1 + ) + continue; + result[key] = { + value: best[0]!.value, + evidence: best[0]!.evidence, + confidence: rank >= 3 ? "explicit" : "semantic", + }; + } + return result; +} diff --git a/src/index.ts b/src/index.ts index 047ec9a..b4e0c4c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -67,6 +67,17 @@ export { } from "./output/index.js"; export { renderCampaignHtml } from "./rendering/index.js"; +export { renderCampaign, restyleCampaign } from "./core/render-campaign.js"; +export { resolveBrand } from "./brand/resolve-brand.js"; +export { + BrandSettingsSchema, + BrandProfileSchema, + ResolvedBrandSchema, + BrandStyleError, + type BrandSettings, + type CompleteBrandSettings, + type ResolvedBrand, +} from "./brand/settings.js"; export { CAMPAIGN_CLAIM_ISSUE_CODES, diff --git a/src/output/artifact-builder.ts b/src/output/artifact-builder.ts index 2e91124..4603d46 100644 --- a/src/output/artifact-builder.ts +++ b/src/output/artifact-builder.ts @@ -50,6 +50,10 @@ function campaignDocument(result: GenerateCampaignResult): CampaignDocument { goal: result.campaign.goal, productIds: [...new Set(productIds)], campaign: result.campaign, + ...(result.brand ? { brand: result.brand } : {}), + ...(result.validation.scope + ? { validationScope: result.validation.scope } + : {}), }; } diff --git a/src/output/contracts.ts b/src/output/contracts.ts index af7a374..4084806 100644 --- a/src/output/contracts.ts +++ b/src/output/contracts.ts @@ -5,6 +5,7 @@ import type { } from "../core/generate-campaign.js"; import type { Campaign, ProductId } from "../core/schemas/index.js"; import type { GenerationUsage } from "../providers/index.js"; +import type { ResolvedBrand } from "../brand/settings.js"; export const ARTIFACT_SCHEMA_VERSION = "0.1.0"; export const TRACE_SCHEMA_VERSION = "0.1.0"; @@ -25,6 +26,8 @@ export type ArtifactDescriptor = Readonly<{ }>; export type CampaignDocument = Readonly<{ + brand?: ResolvedBrand; + validationScope?: "generation-and-render" | "render-only"; generator: "punch"; artifactSchemaVersion: string; status: "valid"; diff --git a/src/output/filesystem-safety.ts b/src/output/filesystem-safety.ts new file mode 100644 index 0000000..d553ef7 --- /dev/null +++ b/src/output/filesystem-safety.ts @@ -0,0 +1,53 @@ +import { lstat, realpath, stat } from "node:fs/promises"; +import { join, parse, relative, sep } from "node:path"; +import { OutputError } from "./output-error.js"; + +/** Resolves a real parent and rejects a symlink as its final component. */ +export async function safeParentIdentity( + parent: string, +): Promise> { + try { + await assertNoLinkedAncestors(parent); + const info = await lstat(parent, { bigint: true }); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new OutputError("unsafe-output-path"); + } + const real = await realpath(parent); + const actual = await stat(real, { bigint: true }); + return { real, dev: actual.dev, ino: actual.ino }; + } catch (error) { + if (error instanceof OutputError) { + throw error; + } + throw new OutputError("invalid-output-path"); + } +} + +/** Rejects a symlink or non-directory anywhere in the existing parent chain. */ +async function assertNoLinkedAncestors(parent: string): Promise { + const root = parse(parent).root; + const segments = relative(root, parent).split(sep).filter(Boolean); + let current = root; + for (const segment of segments) { + current = join(current, segment); + const info = await lstat(current); + if (!info.isDirectory() || info.isSymbolicLink()) { + throw new OutputError("unsafe-output-path"); + } + } +} + +/** Rechecks parent identity immediately before atomic publication. */ +export async function assertSameDirectory( + parent: string, + expected: Readonly<{ real: string; dev: bigint; ino: bigint }>, +): Promise { + const actual = await safeParentIdentity(parent); + if ( + actual.real !== expected.real || + actual.dev !== expected.dev || + actual.ino !== expected.ino + ) { + throw new OutputError("unsafe-output-path"); + } +} diff --git a/src/output/write-output.ts b/src/output/write-output.ts index 97b0bee..d39e3e4 100644 --- a/src/output/write-output.ts +++ b/src/output/write-output.ts @@ -1,23 +1,10 @@ -import { randomUUID } from "node:crypto"; -import { - lstat, - mkdir, - realpath, - rename, - rm, - stat, - writeFile, -} from "node:fs/promises"; import { - basename, - dirname, - isAbsolute, - join, - parse, - relative, - resolve, - sep, -} from "node:path"; + safeParentIdentity, + assertSameDirectory, +} from "./filesystem-safety.js"; +import { randomUUID } from "node:crypto"; +import { lstat, mkdir, rename, rm, writeFile } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path"; import type { GenerateCampaignResult } from "../core/generate-campaign.js"; import { buildOutputBundle } from "./artifact-builder.js"; @@ -29,6 +16,16 @@ export type WriteOutputOptions = Readonly<{ cwd?: string; }>; +/** Checks an intended destination without creating files or reserving the path. */ +export async function assertOutputAvailable( + output: string, + force = false, +): Promise { + const destination = resolveOutput(output, process.cwd()); + await safeParentIdentity(dirname(destination)); + await assertDestinationMissing(destination, force); +} + /** Atomically publishes a complete result into one previously absent directory. */ export async function writeCampaignOutput( result: GenerateCampaignResult, @@ -75,41 +72,6 @@ function resolveOutput(output: string, cwd: string): string { return destination; } -/** Resolves a real parent and rejects a symlink as its final component. */ -async function safeParentIdentity( - parent: string, -): Promise> { - try { - await assertNoLinkedAncestors(parent); - const info = await lstat(parent, { bigint: true }); - if (!info.isDirectory() || info.isSymbolicLink()) { - throw new OutputError("unsafe-output-path"); - } - const real = await realpath(parent); - const actual = await stat(real, { bigint: true }); - return { real, dev: actual.dev, ino: actual.ino }; - } catch (error) { - if (error instanceof OutputError) { - throw error; - } - throw new OutputError("invalid-output-path"); - } -} - -/** Rejects a symlink or non-directory anywhere in the existing parent chain. */ -async function assertNoLinkedAncestors(parent: string): Promise { - const root = parse(parent).root; - const segments = relative(root, parent).split(sep).filter(Boolean); - let current = root; - for (const segment of segments) { - current = join(current, segment); - const info = await lstat(current); - if (!info.isDirectory() || info.isSymbolicLink()) { - throw new OutputError("unsafe-output-path"); - } - } -} - /** Rejects every existing destination and fails closed for forced replacement. */ async function assertDestinationMissing( destination: string, @@ -160,21 +122,6 @@ function safeRelativePath(value: string): boolean { ); } -/** Rechecks parent identity immediately before atomic publication. */ -async function assertSameDirectory( - parent: string, - expected: Readonly<{ real: string; dev: bigint; ino: bigint }>, -): Promise { - const actual = await safeParentIdentity(parent); - if ( - actual.real !== expected.real || - actual.dev !== expected.dev || - actual.ino !== expected.ino - ) { - throw new OutputError("unsafe-output-path"); - } -} - /** Removes only the unguessable staging directory owned by this invocation. */ async function removeStaging(staging: string): Promise { await rm(staging, { recursive: true, force: true }).catch(() => undefined); diff --git a/src/rendering/blocks/body-paragraph.tsx b/src/rendering/blocks/body-paragraph.tsx index 2484d31..2709dd2 100644 --- a/src/rendering/blocks/body-paragraph.tsx +++ b/src/rendering/blocks/body-paragraph.tsx @@ -1,6 +1,7 @@ +import { useRenderStyles } from "../render-style-context.js"; import type { BodyParagraphBlock } from "../../core/schemas/index.js"; import { renderSafeInlineMarkdown } from "../safe-inline-markdown.js"; -import { bodySectionCellStyle, bodyTextStyle } from "../styles.js"; + import { BlockFrame } from "./shared.js"; type BodyParagraphProps = { @@ -9,6 +10,7 @@ type BodyParagraphProps = { /** Renders one paragraph through Punch's restricted inline Markdown path. */ export function BodyParagraph({ block }: BodyParagraphProps) { + const { bodySectionCellStyle, bodyTextStyle } = useRenderStyles(); return ( + <> {block.heading === undefined ? null : (

{block.heading} @@ -33,6 +25,21 @@ export function ClosingCta({ block }: CtaBlockProps) { {block.body}

)} + + ); +} + +/** Renders a closing region with one or two schema-validated actions. */ +export function ClosingCta({ block }: CtaBlockProps) { + const { centeredSectionCellStyle } = useRenderStyles(); + const actionWidth = `${100 / block.actions.length}%`; + return ( + + {block.heading === undefined ? null : ( @@ -62,6 +60,7 @@ function DiscountContent({ block }: DiscountCodeProps) { /** Renders only the explicit fields carried by a promotion-code block. */ export function DiscountCode({ block }: DiscountCodeProps) { + const { sectionCellStyle, discountPanelStyle } = useRenderStyles(); return (
@@ -76,6 +69,7 @@ function ProductFeatureCopy({ block }: ProductFeatureProps) { /** Renders one complete featured-product presentation table. */ function ProductFeaturePanel({ block }: ProductFeatureProps) { + const { imageFreeFeaturePanelStyle, featurePanelStyle } = useRenderStyles(); return ( ) { + const { cardContentStyle } = useRenderStyles(); const copyHeight = EMAIL_THEME.geometry.productCopyHeight[columns]; return ( @@ -231,6 +234,7 @@ export function ProductCard({ imageWidth, product, }: ProductCardProps) { + const { imageFreeCardStyle, cardStyle } = useRenderStyles(); return (
{ + const candidate = tint(canvas, accent, amount); + return (contrastRatio(ink, candidate) ?? 0) >= 4.5 ? candidate : canvas; + }; + const card = surface(0.04); + const promotion = surface(0.08); + const link = [canvas, card, promotion].every( + (bg) => (contrastRatio(accent, bg) ?? 0) >= 4.5, + ) + ? accent + : ink; + return { + accent, + link, + canvas, + card, + promotion, + code: canvas, + primary: ink, + body: ink, + compliance: ink, + page: tint(canvas, ink, 0.05), + border: tint(canvas, ink, 0.18), + promotionBorder: tint(canvas, accent, 0.3), + buttonText: readableInk(accent), + }; +} + +/** Creates an isolated render theme; no global style state is changed. */ +export function createBrandTheme(settings: CompleteBrandSettings): RenderTheme { + return { + colours: brandColours(settings), + fonts: { + body: fontStack(settings.bodyFont), + display: fontStack(settings.headingFont), + }, + geometry: EMAIL_THEME.geometry, + typography: EMAIL_THEME.typography, + }; +} diff --git a/src/rendering/commerce-styles.ts b/src/rendering/commerce-styles.ts index 538f644..8fa4a09 100644 --- a/src/rendering/commerce-styles.ts +++ b/src/rendering/commerce-styles.ts @@ -1,134 +1,175 @@ import type { CSSProperties } from "react"; -import { EMAIL_THEME } from "./render-theme.js"; +import type { RenderTheme } from "./brand-theme.js"; -export const productNameStyle = { - color: EMAIL_THEME.colours.primary, - fontFamily: EMAIL_THEME.fonts.display, - fontSize: `${EMAIL_THEME.typography.product}px`, +/** Creates productNameStyle without changing shared render state. */ +export const productNameStyle = (theme: RenderTheme): CSSProperties => ({ + color: theme.colours.primary, + fontFamily: theme.fonts.display, + fontSize: `${theme.typography.product}px`, fontWeight: 700, lineHeight: "28px", margin: "0 0 10px", -} satisfies CSSProperties; +}); -export const productPriceStyle = { - color: EMAIL_THEME.colours.primary, - fontSize: `${EMAIL_THEME.typography.body}px`, +/** Creates productPriceStyle without changing shared render state. */ +export const productPriceStyle = (theme: RenderTheme): CSSProperties => ({ + color: theme.colours.primary, + fontSize: `${theme.typography.body}px`, fontWeight: 700, lineHeight: "22px", margin: "12px 0 0", -} satisfies CSSProperties; +}); -export const cardStyle = { - backgroundColor: EMAIL_THEME.colours.card, - border: `1px solid ${EMAIL_THEME.colours.border}`, +/** Creates cardStyle without changing shared render state. */ +export const cardStyle = (theme: RenderTheme): CSSProperties => ({ + backgroundColor: theme.colours.card, + border: `1px solid ${theme.colours.border}`, borderCollapse: "separate", borderRadius: "12px", overflow: "hidden", width: "100%", -} satisfies CSSProperties; +}); -export const imageFreeCardStyle = { - ...cardStyle, - borderTop: `4px solid ${EMAIL_THEME.colours.accent}`, -} satisfies CSSProperties; +/** Creates imageFreeCardStyle without changing shared render state. */ +export const imageFreeCardStyle = (theme: RenderTheme): CSSProperties => ({ + ...cardStyle(theme), + borderTop: `4px solid ${theme.colours.accent}`, +}); -export const cardContentStyle = { +/** Creates cardContentStyle without changing shared render state. */ +export const cardContentStyle = (): CSSProperties => ({ padding: "18px", -} satisfies CSSProperties; +}); -export const productCopyCellStyle = { +/** Creates productCopyCellStyle without changing shared render state. */ +export const productCopyCellStyle = (): CSSProperties => ({ verticalAlign: "top", -} satisfies CSSProperties; +}); -export const featurePanelStyle = { - backgroundColor: EMAIL_THEME.colours.card, - border: `1px solid ${EMAIL_THEME.colours.border}`, +/** Creates featurePanelStyle without changing shared render state. */ +export const featurePanelStyle = (theme: RenderTheme): CSSProperties => ({ + backgroundColor: theme.colours.card, + border: `1px solid ${theme.colours.border}`, borderCollapse: "separate", borderRadius: "12px", overflow: "hidden", width: "100%", -} satisfies CSSProperties; - -export const imageFreeFeaturePanelStyle = { - ...featurePanelStyle, - borderTop: `5px solid ${EMAIL_THEME.colours.accent}`, -} satisfies CSSProperties; - -export const buttonTableStyle = { +}); + +/** Creates imageFreeFeaturePanelStyle without changing shared render state. */ +export const imageFreeFeaturePanelStyle = ( + theme: RenderTheme, +): CSSProperties => ({ + ...featurePanelStyle(theme), + borderTop: `5px solid ${theme.colours.accent}`, +}); + +/** Creates buttonTableStyle without changing shared render state. */ +export const buttonTableStyle = (): CSSProperties => ({ borderCollapse: "separate", margin: "20px auto 0", -} satisfies CSSProperties; +}); -export const buttonCellStyle = { - backgroundColor: EMAIL_THEME.colours.accent, +/** Creates buttonCellStyle without changing shared render state. */ +export const buttonCellStyle = (theme: RenderTheme): CSSProperties => ({ + backgroundColor: theme.colours.accent, borderRadius: "8px", - height: `${EMAIL_THEME.geometry.ctaHeight}px`, + height: `${theme.geometry.ctaHeight}px`, textAlign: "center", -} satisfies CSSProperties; +}); -export const buttonLinkStyle = { +/** Creates buttonLinkStyle without changing shared render state. */ +export const buttonLinkStyle = (theme: RenderTheme): CSSProperties => ({ boxSizing: "border-box", - color: EMAIL_THEME.colours.buttonText, + color: theme.colours.buttonText, display: "inline-block", - fontSize: `${EMAIL_THEME.typography.button}px`, + fontSize: `${theme.typography.button}px`, fontWeight: 700, - lineHeight: `${EMAIL_THEME.geometry.ctaLineHeight}px`, - minHeight: `${EMAIL_THEME.geometry.ctaHeight}px`, - padding: `${EMAIL_THEME.geometry.ctaVerticalPadding}px 22px`, + lineHeight: `${theme.geometry.ctaLineHeight}px`, + minHeight: `${theme.geometry.ctaHeight}px`, + padding: `${theme.geometry.ctaVerticalPadding}px 22px`, textDecoration: "none", -} satisfies CSSProperties; +}); -export const compactButtonTableStyle = { - ...buttonTableStyle, +/** Creates compactButtonTableStyle without changing shared render state. */ +export const compactButtonTableStyle = (): CSSProperties => ({ + ...buttonTableStyle(), width: "100%", -} satisfies CSSProperties; +}); -export const compactButtonLinkStyle = { - ...buttonLinkStyle, +/** Creates compactButtonLinkStyle without changing shared render state. */ +export const compactButtonLinkStyle = (theme: RenderTheme): CSSProperties => ({ + ...buttonLinkStyle(theme), paddingLeft: "12px", paddingRight: "12px", width: "100%", -} satisfies CSSProperties; +}); -export const discountPanelStyle = { - backgroundColor: EMAIL_THEME.colours.promotion, - border: `1px solid ${EMAIL_THEME.colours.promotionBorder}`, +/** Creates discountPanelStyle without changing shared render state. */ +export const discountPanelStyle = (theme: RenderTheme): CSSProperties => ({ + backgroundColor: theme.colours.promotion, + border: `1px solid ${theme.colours.promotionBorder}`, borderCollapse: "separate", borderRadius: "12px", width: "100%", -} satisfies CSSProperties; +}); -export const discountCodeStyle = { - backgroundColor: EMAIL_THEME.colours.code, - border: `1px dashed ${EMAIL_THEME.colours.accent}`, +/** Creates discountCodeStyle without changing shared render state. */ +export const discountCodeStyle = (theme: RenderTheme): CSSProperties => ({ + backgroundColor: theme.colours.code, + border: `1px dashed ${theme.colours.accent}`, borderRadius: "6px", - color: EMAIL_THEME.colours.primary, + color: theme.colours.primary, display: "inline-block", - fontSize: `${EMAIL_THEME.typography.discountCode}px`, + fontSize: `${theme.typography.discountCode}px`, fontWeight: 700, letterSpacing: "2px", lineHeight: "26px", marginTop: "16px", padding: "10px 16px", -} satisfies CSSProperties; +}); -export const complianceStyle = { - borderTop: `1px solid ${EMAIL_THEME.colours.border}`, - color: EMAIL_THEME.colours.compliance, - fontSize: `${EMAIL_THEME.typography.compliance}px`, +/** Creates complianceStyle without changing shared render state. */ +export const complianceStyle = (theme: RenderTheme): CSSProperties => ({ + borderTop: `1px solid ${theme.colours.border}`, + color: theme.colours.compliance, + fontSize: `${theme.typography.compliance}px`, lineHeight: "18px", padding: "24px 40px 32px", textAlign: "center", -} satisfies CSSProperties; +}); -export const complianceParagraphStyle = { +/** Creates complianceParagraphStyle without changing shared render state. */ +export const complianceParagraphStyle = (): CSSProperties => ({ margin: "0 0 8px", -} satisfies CSSProperties; +}); -export const complianceLinkStyle = { - color: EMAIL_THEME.colours.compliance, - fontSize: `${EMAIL_THEME.typography.compliance}px`, +/** Creates complianceLinkStyle without changing shared render state. */ +export const complianceLinkStyle = (theme: RenderTheme): CSSProperties => ({ + color: theme.colours.compliance, + fontSize: `${theme.typography.compliance}px`, lineHeight: "18px", textDecoration: "underline", -} satisfies CSSProperties; +}); + +export const commerceStyleFactories = { + productNameStyle, + productPriceStyle, + cardStyle, + imageFreeCardStyle, + cardContentStyle, + productCopyCellStyle, + featurePanelStyle, + imageFreeFeaturePanelStyle, + buttonTableStyle, + buttonCellStyle, + buttonLinkStyle, + compactButtonTableStyle, + compactButtonLinkStyle, + discountPanelStyle, + discountCodeStyle, + complianceStyle, + complianceParagraphStyle, + complianceLinkStyle, +}; diff --git a/src/rendering/email-document.tsx b/src/rendering/email-document.tsx index ce8e047..b04e3ea 100644 --- a/src/rendering/email-document.tsx +++ b/src/rendering/email-document.tsx @@ -1,10 +1,7 @@ +import { useRenderStyles } from "./render-style-context.js"; import type { Campaign } from "../core/schemas/index.js"; import { DispatchBlock } from "./dispatch-block.js"; -import { - complianceLinkStyle, - complianceParagraphStyle, - complianceStyle, -} from "./commerce-styles.js"; + import { COMPLIANCE_VERSION, EMAIL_WIDTH, @@ -12,14 +9,7 @@ import { RENDER_VERSION, UNSUBSCRIBE_PLACEHOLDER, } from "./render-contract.js"; -import { - containerStyle, - outerTableStyle, - pageStyle, - preheaderStyle, - RESPONSIVE_CSS, - shellCellStyle, -} from "./styles.js"; +import { RESPONSIVE_CSS } from "./styles.js"; type EmailDocumentProps = { readonly campaign: Campaign; @@ -27,6 +17,8 @@ type EmailDocumentProps = { /** Renders Punch-owned compliance chrome after all generated blocks. */ function ComplianceFooter() { + const { complianceStyle, complianceParagraphStyle, complianceLinkStyle } = + useRenderStyles(); return (
@@ -49,6 +41,7 @@ function ComplianceFooter() { /** Renders the fixed-width campaign table and owned compliance footer. */ function CampaignContainer({ campaign }: EmailDocumentProps) { + const { containerStyle } = useRenderStyles(); return ( diff --git a/src/rendering/render-campaign-html.tsx b/src/rendering/render-campaign-html.tsx index 7aba9bc..ec0fa9c 100644 --- a/src/rendering/render-campaign-html.tsx +++ b/src/rendering/render-campaign-html.tsx @@ -4,15 +4,27 @@ import { CampaignSchema } from "../core/schemas/campaign.js"; import { assertRenderedCampaign } from "../validation/render-validation.js"; import { EmailDocument } from "./email-document.js"; import { assertNoReservedPlaceholders } from "./render-contract.js"; +import { resolveBrand } from "../brand/resolve-brand.js"; +import type { BrandSettings } from "../brand/settings.js"; +import { BrandStyleProvider } from "./render-style-context.js"; /** Validates unknown campaign input and renders standalone HTML in memory. */ -export async function renderCampaignHtml(input: unknown): Promise { +export async function renderCampaignHtml( + input: unknown, + brand: BrandSettings = {}, +): Promise { const campaign = CampaignSchema.parse(input); + const resolved = resolveBrand({}, brand); assertNoReservedPlaceholders(campaign); - const html = await render(, { - pretty: false, - }); + const html = await render( + + + , + { + pretty: false, + }, + ); assertRenderedCampaign(campaign, html); return html; } diff --git a/src/rendering/render-style-context.tsx b/src/rendering/render-style-context.tsx new file mode 100644 index 0000000..1f41deb --- /dev/null +++ b/src/rendering/render-style-context.tsx @@ -0,0 +1,49 @@ +import { + createContext, + useContext, + type CSSProperties, + type ReactNode, +} from "react"; + +import { + DEFAULT_BRAND_SETTINGS, + type CompleteBrandSettings, +} from "../brand/settings.js"; +import { createBrandTheme } from "./brand-theme.js"; +import { baseStyleFactories } from "./styles.js"; +import { commerceStyleFactories } from "./commerce-styles.js"; + +const factories = { ...baseStyleFactories, ...commerceStyleFactories }; +type RenderStyles = Readonly<{ [K in keyof typeof factories]: CSSProperties }>; + +/** Creates the style set once for this document, never in process-global mutable state. */ +function createStyles(settings: CompleteBrandSettings): RenderStyles { + const theme = createBrandTheme(settings); + return Object.fromEntries( + Object.entries(factories).map(([key, factory]) => [key, factory(theme)]), + ) as RenderStyles; +} + +const RenderStyleContext = createContext( + createStyles(DEFAULT_BRAND_SETTINGS), +); + +/** Supplies isolated styles to all React email blocks in one render. */ +export function BrandStyleProvider({ + settings, + children, +}: { + settings: CompleteBrandSettings; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +/** Reads the current document's style set without recomputing its theme. */ +export function useRenderStyles(): RenderStyles { + return useContext(RenderStyleContext); +} diff --git a/src/rendering/safe-inline-markdown.tsx b/src/rendering/safe-inline-markdown.tsx index 1ea5809..43c177c 100644 --- a/src/rendering/safe-inline-markdown.tsx +++ b/src/rendering/safe-inline-markdown.tsx @@ -4,7 +4,17 @@ import { tokeniseSafeInlineMarkdown, type SafeInlineMarkdownToken, } from "../core/inline-markdown.js"; -import { inlineLinkStyle } from "./styles.js"; +import { useRenderStyles } from "./render-style-context.js"; + +/** Renders a safe Markdown link with this document's accessible brand colour. */ +function InlineLink({ href, text }: { href: string; text: string }) { + const { inlineLinkStyle } = useRenderStyles(); + return ( + + {text} + + ); +} /** Renders one restricted inline Markdown token without raw HTML. */ function renderToken(token: SafeInlineMarkdownToken, key: number): ReactNode { @@ -17,16 +27,7 @@ function renderToken(token: SafeInlineMarkdownToken, key: number): ReactNode { if (token.kind === "text") { return token.text; } - return ( - - {token.text} - - ); + return ; } /** Converts the supported inline Markdown subset to safely escaped React nodes. */ diff --git a/src/rendering/styles.ts b/src/rendering/styles.ts index a647950..7468789 100644 --- a/src/rendering/styles.ts +++ b/src/rendering/styles.ts @@ -1,6 +1,6 @@ import type { CSSProperties } from "react"; -import { EMAIL_THEME } from "./render-theme.js"; +import type { RenderTheme } from "./brand-theme.js"; export const RESPONSIVE_CSS = ` body, table, td, a { @@ -50,38 +50,43 @@ body, table, td, a { } `; -export const pageStyle = { +/** Creates pageStyle without changing shared render state. */ +export const pageStyle = (theme: RenderTheme): CSSProperties => ({ WebkitTextSizeAdjust: "100%", - backgroundColor: EMAIL_THEME.colours.page, - color: EMAIL_THEME.colours.primary, - fontFamily: EMAIL_THEME.fonts.body, + backgroundColor: theme.colours.page, + color: theme.colours.primary, + fontFamily: theme.fonts.body, margin: "0", padding: "0", textSizeAdjust: "100%", -} satisfies CSSProperties; +}); -export const outerTableStyle = { - backgroundColor: EMAIL_THEME.colours.page, +/** Creates outerTableStyle without changing shared render state. */ +export const outerTableStyle = (theme: RenderTheme): CSSProperties => ({ + backgroundColor: theme.colours.page, borderCollapse: "collapse", width: "100%", -} satisfies CSSProperties; +}); -export const shellCellStyle = { +/** Creates shellCellStyle without changing shared render state. */ +export const shellCellStyle = (): CSSProperties => ({ padding: "32px 16px", -} satisfies CSSProperties; +}); -export const containerStyle = { - backgroundColor: EMAIL_THEME.colours.canvas, - border: `1px solid ${EMAIL_THEME.colours.border}`, +/** Creates containerStyle without changing shared render state. */ +export const containerStyle = (theme: RenderTheme): CSSProperties => ({ + backgroundColor: theme.colours.canvas, + border: `1px solid ${theme.colours.border}`, borderCollapse: "separate", borderRadius: "14px", boxShadow: "0 12px 32px rgba(47, 37, 31, 0.08)", maxWidth: "600px", overflow: "hidden", width: "100%", -} satisfies CSSProperties; +}); -export const preheaderStyle = { +/** Creates preheaderStyle without changing shared render state. */ +export const preheaderStyle = (): CSSProperties => ({ color: "transparent", display: "none", fontSize: "1px", @@ -90,121 +95,167 @@ export const preheaderStyle = { maxWidth: "0", opacity: 0, overflow: "hidden", -} satisfies CSSProperties; +}); -export const sectionCellStyle = { +/** Creates sectionCellStyle without changing shared render state. */ +export const sectionCellStyle = (): CSSProperties => ({ padding: "24px 40px", -} satisfies CSSProperties; +}); -export const compactSectionCellStyle = { +/** Creates compactSectionCellStyle without changing shared render state. */ +export const compactSectionCellStyle = (): CSSProperties => ({ padding: "24px 40px", textAlign: "center", -} satisfies CSSProperties; +}); -export const centeredSectionCellStyle = { +/** Creates centeredSectionCellStyle without changing shared render state. */ +export const centeredSectionCellStyle = (): CSSProperties => ({ padding: "36px 40px 40px", textAlign: "center", -} satisfies CSSProperties; +}); -export const heroSectionCellStyle = { - backgroundColor: EMAIL_THEME.colours.card, +/** Creates heroSectionCellStyle without changing shared render state. */ +export const heroSectionCellStyle = (theme: RenderTheme): CSSProperties => ({ + backgroundColor: theme.colours.card, padding: "44px 40px", textAlign: "center", -} satisfies CSSProperties; +}); -export const headingSectionCellStyle = { +/** Creates headingSectionCellStyle without changing shared render state. */ +export const headingSectionCellStyle = (): CSSProperties => ({ padding: "32px 40px 8px", -} satisfies CSSProperties; +}); -export const bodySectionCellStyle = { +/** Creates bodySectionCellStyle without changing shared render state. */ +export const bodySectionCellStyle = (): CSSProperties => ({ padding: "0 40px 24px", -} satisfies CSSProperties; +}); -export const productSectionCellStyle = { +/** Creates productSectionCellStyle without changing shared render state. */ +export const productSectionCellStyle = (): CSSProperties => ({ padding: "16px 40px 24px", -} satisfies CSSProperties; +}); -export const wordmarkStyle = { - color: EMAIL_THEME.colours.primary, - fontFamily: EMAIL_THEME.fonts.display, - fontSize: `${EMAIL_THEME.typography.wordmark}px`, +/** Creates wordmarkStyle without changing shared render state. */ +export const wordmarkStyle = (theme: RenderTheme): CSSProperties => ({ + color: theme.colours.primary, + fontFamily: theme.fonts.display, + fontSize: `${theme.typography.wordmark}px`, fontWeight: 700, lineHeight: "32px", textDecoration: "none", -} satisfies CSSProperties; +}); -export const imageStyle = { +/** Creates imageStyle without changing shared render state. */ +export const imageStyle = (): CSSProperties => ({ border: "0", display: "block", height: "auto", maxWidth: "100%", outline: "none", textDecoration: "none", -} satisfies CSSProperties; +}); -export const fullWidthImageStyle = { - ...imageStyle, +/** Creates fullWidthImageStyle without changing shared render state. */ +export const fullWidthImageStyle = (): CSSProperties => ({ + ...imageStyle(), width: "100%", -} satisfies CSSProperties; +}); -export const heroImageStyle = { - ...fullWidthImageStyle, +/** Creates heroImageStyle without changing shared render state. */ +export const heroImageStyle = (): CSSProperties => ({ + ...fullWidthImageStyle(), borderRadius: "10px", marginBottom: "26px", -} satisfies CSSProperties; +}); -export const eyebrowStyle = { - color: EMAIL_THEME.colours.accent, - fontSize: `${EMAIL_THEME.typography.eyebrow}px`, +/** Creates eyebrowStyle without changing shared render state. */ +export const eyebrowStyle = (theme: RenderTheme): CSSProperties => ({ + color: theme.colours.link, + fontSize: `${theme.typography.eyebrow}px`, fontWeight: 700, letterSpacing: "1.2px", lineHeight: "18px", margin: "0 0 10px", textTransform: "uppercase", -} satisfies CSSProperties; +}); -export const heroHeadingStyle = { - color: EMAIL_THEME.colours.primary, - fontFamily: EMAIL_THEME.fonts.display, - fontSize: `${EMAIL_THEME.typography.hero}px`, +/** Creates heroHeadingStyle without changing shared render state. */ +export const heroHeadingStyle = (theme: RenderTheme): CSSProperties => ({ + color: theme.colours.primary, + fontFamily: theme.fonts.display, + fontSize: `${theme.typography.hero}px`, fontWeight: 700, lineHeight: "44px", margin: "0 0 16px", -} satisfies CSSProperties; +}); -export const headingTwoStyle = { - color: EMAIL_THEME.colours.primary, - fontFamily: EMAIL_THEME.fonts.display, - fontSize: `${EMAIL_THEME.typography.heading}px`, +/** Creates headingTwoStyle without changing shared render state. */ +export const headingTwoStyle = (theme: RenderTheme): CSSProperties => ({ + color: theme.colours.primary, + fontFamily: theme.fonts.display, + fontSize: `${theme.typography.heading}px`, fontWeight: 700, lineHeight: "34px", margin: "0", -} satisfies CSSProperties; +}); -export const headingThreeStyle = { - color: EMAIL_THEME.colours.primary, - fontFamily: EMAIL_THEME.fonts.display, - fontSize: `${EMAIL_THEME.typography.subheading}px`, +/** Creates headingThreeStyle without changing shared render state. */ +export const headingThreeStyle = (theme: RenderTheme): CSSProperties => ({ + color: theme.colours.primary, + fontFamily: theme.fonts.display, + fontSize: `${theme.typography.subheading}px`, fontWeight: 700, lineHeight: "28px", margin: "0", -} satisfies CSSProperties; +}); -export const bodyTextStyle = { - color: EMAIL_THEME.colours.body, - fontSize: `${EMAIL_THEME.typography.body}px`, +/** Creates bodyTextStyle without changing shared render state. */ +export const bodyTextStyle = (theme: RenderTheme): CSSProperties => ({ + color: theme.colours.body, + fontSize: `${theme.typography.body}px`, lineHeight: "25px", margin: "0", -} satisfies CSSProperties; +}); -export const bodyTextWithTopMarginStyle = { - ...bodyTextStyle, +/** Creates bodyTextWithTopMarginStyle without changing shared render state. */ +export const bodyTextWithTopMarginStyle = ( + theme: RenderTheme, +): CSSProperties => ({ + ...bodyTextStyle(theme), margin: "12px 0 0", -} satisfies CSSProperties; +}); -export const inlineLinkStyle = { - color: EMAIL_THEME.colours.accent, - fontSize: `${EMAIL_THEME.typography.body}px`, +/** Creates inlineLinkStyle without changing shared render state. */ +export const inlineLinkStyle = (theme: RenderTheme): CSSProperties => ({ + color: theme.colours.link, + fontSize: `${theme.typography.body}px`, lineHeight: "25px", textDecoration: "underline", -} satisfies CSSProperties; +}); + +export const baseStyleFactories = { + pageStyle, + outerTableStyle, + shellCellStyle, + containerStyle, + preheaderStyle, + sectionCellStyle, + compactSectionCellStyle, + centeredSectionCellStyle, + heroSectionCellStyle, + headingSectionCellStyle, + bodySectionCellStyle, + productSectionCellStyle, + wordmarkStyle, + imageStyle, + fullWidthImageStyle, + heroImageStyle, + eyebrowStyle, + heroHeadingStyle, + headingTwoStyle, + headingThreeStyle, + bodyTextStyle, + bodyTextWithTopMarginStyle, + inlineLinkStyle, +}; diff --git a/src/validation/render-style-validation.ts b/src/validation/render-style-validation.ts index d0966d4..19750f4 100644 --- a/src/validation/render-style-validation.ts +++ b/src/validation/render-style-validation.ts @@ -1,3 +1,5 @@ +import { contrastRatio } from "../brand/colour.js"; +export { contrastRatio } from "../brand/colour.js"; import { MIN_COMPLIANCE_FONT_SIZE, MIN_CONTENT_FONT_SIZE, @@ -27,48 +29,6 @@ const REQUIRED_ROLES = [ const COMPLIANCE_ROLES = new Set(["compliance", "compliance-link"]); -/** Parses one opaque six-digit hexadecimal colour. */ -function parseHexColour(value: unknown): [number, number, number] | undefined { - if (typeof value !== "string" || !/^#[\da-f]{6}$/iu.test(value)) { - return undefined; - } - return [1, 3, 5].map((offset) => - Number.parseInt(value.slice(offset, offset + 2), 16), - ) as [number, number, number]; -} - -/** Converts one sRGB channel to relative luminance. */ -function lineariseChannel(value: number): number { - const channel = value / 255; - return channel <= 0.04045 - ? channel / 12.92 - : ((channel + 0.055) / 1.055) ** 2.4; -} - -/** Returns the relative luminance for one opaque colour tuple. */ -function luminance([red, green, blue]: [number, number, number]): number { - return ( - 0.2126 * lineariseChannel(red) + - 0.7152 * lineariseChannel(green) + - 0.0722 * lineariseChannel(blue) - ); -} - -/** Returns the WCAG contrast ratio for two supported opaque colours. */ -export function contrastRatio( - foreground: unknown, - background: unknown, -): number | undefined { - const foregroundRgb = parseHexColour(foreground); - const backgroundRgb = parseHexColour(background); - if (foregroundRgb === undefined || backgroundRgb === undefined) { - return undefined; - } - const first = luminance(foregroundRgb); - const second = luminance(backgroundRgb); - return (Math.max(first, second) + 0.05) / (Math.min(first, second) + 0.05); -} - /** Parses a non-negative pixel value from one rendered style property. */ export function stylePixels(value: unknown): number | undefined { if (typeof value === "number") { diff --git a/tests/brand/brand-rendering.test.ts b/tests/brand/brand-rendering.test.ts new file mode 100644 index 0000000..d87f69e --- /dev/null +++ b/tests/brand/brand-rendering.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; + +import { resolveBrand } from "../../src/brand/resolve-brand.js"; +import { + BrandSettingsSchema, + DEFAULT_BRAND_SETTINGS, +} from "../../src/brand/settings.js"; +import { + renderCampaign, + restyleCampaign, +} from "../../src/core/render-campaign.js"; +import { renderCampaignHtml } from "../../src/rendering/render-campaign-html.js"; +import { validateRenderedCampaign } from "../../src/validation/render-validation.js"; +import { FIXED_CAMPAIGN } from "../rendering/support.js"; +import { CampaignSchema } from "../../src/core/schemas/campaign.js"; +import six from "../fixtures/checkpoint-4/six-product.json" with { type: "json" }; +import single from "../fixtures/checkpoint-4/single-product.json" with { type: "json" }; + +describe("brand settings and isolated rendering", () => { + it.each([ + "red", + "#abc", + "#123456;background:red", + "url(https://example.com)", + ])("rejects unsafe or ambiguous colour %s", (colour) => { + expect( + BrandSettingsSchema.safeParse({ primaryColour: colour }).success, + ).toBe(false); + }); + + it.each([ + "Arial; color:red", + 'A";background:url(x)', + "var(--font)", + "inherit", + "A\u001b[31m", + ])("rejects executable or control-bearing font %s", (font) => { + expect(BrandSettingsSchema.safeParse({ bodyFont: font }).success).toBe( + false, + ); + }); + + it("resolves manual, website and fallback slots separately", () => { + const brand = resolveBrand( + { + primaryColour: { + value: "#006644", + confidence: "explicit", + evidence: { + url: "https://grove.example.com/", + field: "styles.inline-01", + }, + }, + }, + { bodyFont: "Verdana" }, + ); + expect(brand.sources).toEqual({ + primaryColour: "website", + backgroundColour: "fallback", + textColour: "fallback", + headingFont: "fallback", + bodyFont: "manual", + }); + expect(brand.settings.primaryColour).toBe("#006644"); + expect(resolveBrand().settings).toEqual(DEFAULT_BRAND_SETTINGS); + }); + + it("preserves manual primary colours but refuses unreadable manual text", async () => { + const html = await renderCampaignHtml(FIXED_CAMPAIGN, { + primaryColour: "#FFFF00", + }); + expect(html).toContain("background-color:#FFFF00"); + expect(validateRenderedCampaign(FIXED_CAMPAIGN, html).valid).toBe(true); + expect(() => + resolveBrand({}, { backgroundColour: "#FFFFFF", textColour: "#EEEEEE" }), + ).toThrow("4.5:1"); + const dark = resolveBrand({}, { backgroundColour: "#111111" }); + expect(dark.settings.textColour).toBe("#FFFFFF"); + expect(dark.warnings).toContain("text-contrast-fallback"); + }); + + it("renders distinct brands concurrently without leaking colours or fonts", async () => { + const before = JSON.stringify(FIXED_CAMPAIGN); + const [blue, dark, baseline] = await Promise.all([ + renderCampaignHtml(FIXED_CAMPAIGN, { + primaryColour: "#2563EB", + headingFont: "Verdana", + }), + renderCampaignHtml(FIXED_CAMPAIGN, { + primaryColour: "#F0ABFC", + backgroundColour: "#111827", + textColour: "#F9FAFB", + headingFont: "Courier New", + }), + renderCampaignHtml(FIXED_CAMPAIGN), + ]); + expect(blue).toContain("#2563EB"); + expect(blue).not.toContain("#F0ABFC"); + expect(dark).toContain("#F0ABFC"); + expect(dark).not.toContain("#2563EB"); + expect(baseline).not.toContain("#F0ABFC"); + expect(blue).toContain("Verdana"); + expect(dark).toContain("Courier New"); + expect(await renderCampaignHtml(FIXED_CAMPAIGN)).toBe(baseline); + expect(JSON.stringify(FIXED_CAMPAIGN)).toBe(before); + }); + + it.each([single, six])( + "retains render checks across diverse light/dark palettes", + async (fixture) => { + const campaign = CampaignSchema.parse(fixture); + for (const backgroundColour of [ + "#FFFFFF", + "#101010", + "#808080", + "#FDF6E3", + ]) { + for (const primaryColour of [ + "#FFDD00", + "#2563EB", + "#000000", + "#FFFFFF", + ]) { + const html = await renderCampaignHtml(campaign, { + primaryColour, + backgroundColour, + bodyFont: "Verdana", + headingFont: "Georgia", + }); + expect(validateRenderedCampaign(campaign, html).valid).toBe(true); + } + } + }, + ); + + it("restyles identical copy with zero provider usage and explicit render-only scope", async () => { + const result = await renderCampaign(FIXED_CAMPAIGN); + const next = await restyleCampaign(result, { primaryColour: "#006644" }); + expect(next.campaign).toEqual(result.campaign); + expect(next.html).not.toEqual(result.html); + expect(next.usage.calls).toHaveLength(0); + expect(next.validation.scope).toBe("render-only"); + expect( + next.validation.checks.every((check) => check.id.startsWith("render-")), + ).toBe(true); + }); +}); diff --git a/tests/cli/brand-files-render.test.ts b/tests/cli/brand-files-render.test.ts new file mode 100644 index 0000000..a92bebb --- /dev/null +++ b/tests/cli/brand-files-render.test.ts @@ -0,0 +1,232 @@ +import { + mkdtemp, + readFile, + readdir, + realpath, + rm, + symlink, + writeFile, + link, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + readBrandProfile, + saveBrandProfile, +} from "../../src/cli/local-files.js"; +import { runCli } from "../../src/cli/run-cli.js"; +import { reviewResult } from "../../src/cli/preview-result.js"; +import { renderCampaign } from "../../src/core/render-campaign.js"; +import { FIXED_CAMPAIGN } from "../rendering/support.js"; +import type { CliIo } from "../../src/cli/io.js"; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +/** Creates one canonical temporary parent owned by this test. */ +async function temporaryParent() { + const path = await mkdtemp( + join(await realpath(tmpdir()), "punch-brand-test-"), + ); + directories.push(path); + return path; +} + +describe("reusable brand profiles and render-only CLI", () => { + it("round-trips profiles and refuses overwrite, symlinks, hardlinks and oversized JSON", async () => { + const parent = await temporaryParent(); + const path = join(parent, "brand.json"); + await saveBrandProfile(path, { primaryColour: "#2563eb" }); + expect(await readBrandProfile(path)).toEqual({ primaryColour: "#2563EB" }); + const before = await readFile(path, "utf8"); + await expect( + saveBrandProfile(path, { primaryColour: "#006644" }), + ).rejects.toMatchObject({ code: "invalid-file" }); + expect(await readFile(path, "utf8")).toBe(before); + await symlink(path, join(parent, "linked.json")); + await expect( + readBrandProfile(join(parent, "linked.json")), + ).rejects.toMatchObject({ code: "invalid-file" }); + await expect( + saveBrandProfile(join(parent, "linked.json"), {}), + ).rejects.toMatchObject({ code: "invalid-file" }); + await link(path, join(parent, "hardlinked.json")); + await expect(readBrandProfile(path)).rejects.toMatchObject({ + code: "invalid-file", + }); + await writeFile(join(parent, "large.json"), " ".repeat(8193)); + await expect( + readBrandProfile(join(parent, "large.json")), + ).rejects.toMatchObject({ code: "invalid-file" }); + await expect(readBrandProfile(parent)).rejects.toMatchObject({ + code: "invalid-file", + }); + expect( + (await readdir(parent)).some((name) => name.includes(".punch-")), + ).toBe(false); + }); + + it("refuses linked parent directories and arbitrary profile properties", async () => { + const parent = await temporaryParent(); + const alias = `${parent}-alias`; + await symlink(parent, alias); + directories.push(alias); + await expect( + saveBrandProfile(join(alias, "new.json"), {}), + ).rejects.toMatchObject({ code: "invalid-file" }); + const path = join(parent, "invalid.json"); + await writeFile( + path, + JSON.stringify({ version: "1", settings: { css: "body{}" } }), + ); + await expect(readBrandProfile(path)).rejects.toMatchObject({ + code: "invalid-file", + }); + }); + + it("renders without a key, applies explicit flags over profiles and saves reproducible settings", async () => { + const parent = await temporaryParent(); + const campaign = join(parent, "source.json"); + const profile = join(parent, "brand.json"); + const savedProfile = join(parent, "saved-brand.json"); + await writeFile(campaign, JSON.stringify(FIXED_CAMPAIGN)); + await saveBrandProfile(profile, { + primaryColour: "#006644", + bodyFont: "Verdana", + }); + const stdout: string[] = []; + const stderr: string[] = []; + const io: CliIo = { + stdout: (value) => stdout.push(value), + stderr: (value) => stderr.push(value), + env: {}, + signal: new AbortController().signal, + ask: vi.fn(), + }; + const first = join(parent, "first"); + const code = await runCli( + [ + "render", + "--campaign", + campaign, + "--brand", + profile, + "--primary-colour", + "#2563EB", + "--save-brand", + savedProfile, + "--output", + first, + "--json", + ], + io, + ); + expect(code).toBe(0); + expect(stdout).toHaveLength(1); + expect(stderr).toEqual([]); + expect(io.ask).not.toHaveBeenCalled(); + expect(JSON.parse(stdout[0]!).validationScope).toBe("render-only"); + const document = JSON.parse( + await readFile(join(first, "campaign.json"), "utf8"), + ); + expect(document.campaign).toEqual(FIXED_CAMPAIGN); + expect(document.brand.settings.primaryColour).toBe("#2563EB"); + expect((await readBrandProfile(savedProfile)).bodyFont).toBe("Verdana"); + const second = join(parent, "second"); + expect( + await runCli( + [ + "render", + "--campaign", + join(first, "campaign.json"), + "--output", + second, + ], + io, + ), + ).toBe(0); + expect(await readFile(join(second, "email.html"), "utf8")).toBe( + await readFile(join(first, "email.html"), "utf8"), + ); + const validation = JSON.parse( + await readFile(join(second, "validation.json"), "utf8"), + ); + expect(validation.usage.total.inputTokens).toBe(0); + expect(validation.validation.scope).toBe("render-only"); + }); + + it("lets the guide cancel a profile save requested on the command line", async () => { + const parent = await temporaryParent(); + const campaign = join(parent, "source.json"); + const profile = join(parent, "cancelled-brand.json"); + await writeFile(campaign, JSON.stringify(FIXED_CAMPAIGN)); + const answers = ["", "s", "", ""]; + const code = await runCli( + [ + "render", + "--campaign", + campaign, + "--output", + join(parent, "output"), + "--save-brand", + profile, + "--interactive", + ], + { + stdout: vi.fn(), + stderr: vi.fn(), + env: {}, + signal: new AbortController().signal, + stdinIsTTY: true, + stdoutIsTTY: true, + ask: async () => { + const answer = answers.shift(); + if (answer === undefined) throw new Error("Unexpected question"); + return answer; + }, + }, + ); + expect(code).toBe(0); + expect(answers).toEqual([]); + await expect(readFile(profile)).rejects.toMatchObject({ code: "ENOENT" }); + }); + + it("previews two revisions, preserves copy, and removes owned temporary previews", async () => { + const answers = ["p", "b", "1", "#2563EB", "", "p", ""]; + const previews: string[] = []; + const html: string[] = []; + const initial = await renderCampaign(FIXED_CAMPAIGN); + const reviewed = await reviewResult( + { + stdout: vi.fn(), + stderr: vi.fn(), + env: {}, + signal: new AbortController().signal, + ask: async () => { + const value = answers.shift(); + if (value === undefined) throw new Error("Unexpected question"); + return value; + }, + openPreview: async (path) => { + previews.push(path); + html.push(await readFile(path, "utf8")); + }, + }, + initial, + ); + expect(html).toHaveLength(2); + expect(html[0]).not.toBe(html[1]); + expect(reviewed.result.campaign).toEqual(initial.campaign); + expect(reviewed.result.usage).toEqual(initial.usage); + for (const path of previews) + await expect(readFile(path)).rejects.toMatchObject({ code: "ENOENT" }); + }); +}); diff --git a/tests/cli/guide.test.ts b/tests/cli/guide.test.ts new file mode 100644 index 0000000..3b18ad9 --- /dev/null +++ b/tests/cli/guide.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it, vi } from "vitest"; +import { resolveInvocation } from "../../src/cli/guide-command.js"; +import { editBrand } from "../../src/cli/guide-brand.js"; +import { interactiveAllowed, type CliIo } from "../../src/cli/io.js"; +import { resolveBrand } from "../../src/brand/resolve-brand.js"; + +const complete = [ + "generate", + "--website", + "https://grove.example.com", + "--product", + "https://grove.example.com/mug", + "--goal", + "sales", + "--output", + "campaign", +]; + +/** Creates a finite terminal script that fails on any unexpected extra prompt. */ +function terminal(answers: string[] = [], extra: Partial = {}) { + const questions: string[] = []; + const io: CliIo = { + stdinIsTTY: true, + stdoutIsTTY: true, + env: { NO_COLOR: "1" }, + signal: new AbortController().signal, + stdout: vi.fn(), + stderr: vi.fn(), + ask: vi.fn(async (question) => { + questions.push(question); + const answer = answers.shift(); + if (answer === undefined) throw new Error("Unexpected prompt"); + return answer; + }), + ...extra, + }; + return { io, questions }; +} + +describe("terminal-only guided input", () => { + it.each([ + { stdinIsTTY: false }, + { stdoutIsTTY: false }, + { env: { CI: "true" } }, + { env: { GITHUB_ACTIONS: "true" } }, + ])("does not guide unsafe terminal state %j", async (state) => { + const { io } = terminal([], state); + expect(interactiveAllowed([], io)).toBe(false); + await expect(resolveInvocation(["generate"], io)).rejects.toThrow(); + expect(io.ask).not.toHaveBeenCalled(); + }); + + it.each(["--json", "--no-interactive"])( + "never prompts with %s even when interactive was requested", + async (flag) => { + const { io } = terminal(); + const invocation = await resolveInvocation( + [...complete, "--interactive", flag], + io, + ); + expect(invocation.guided).toBe(false); + expect(io.ask).not.toHaveBeenCalled(); + }, + ); + + it("leaves complete commands prompt-free and rejects typos before questions", async () => { + const { io } = terminal(); + expect((await resolveInvocation(complete, io)).guided).toBe(false); + await expect( + resolveInvocation( + ["generate", "--webiste", "https://grove.example.com"], + io, + ), + ).rejects.toThrow("Unknown"); + expect(io.ask).not.toHaveBeenCalled(); + }); + + it("guides a bare invocation, retries a URL, and supports product removal", async () => { + const { io, questions } = terminal([ + "bad-url", + "https://grove.example.com", + "https://grove.example.com/mug", + "https://grove.example.com/bowl", + "remove 1", + "", + "", + "A gift campaign", + "", + "", + "y", + ]); + const result = await resolveInvocation([], io); + expect(result.guided).toBe(true); + expect(result.command).toMatchObject({ + kind: "generate", + input: { + products: ["https://grove.example.com/bowl"], + instructions: "A gift campaign", + goal: "sales", + }, + }); + expect( + questions.filter((question) => question.startsWith("Brand website")), + ).toHaveLength(2); + }); + + it("edits hex colours with an explicit contrast repair and supports reset", async () => { + const { io } = terminal([ + "1", + "red", + "1", + "#2563eb", + "2", + "#111111", + "y", + "", + ]); + const changed = await editBrand(io, resolveBrand()); + expect(changed).toEqual({ + primaryColour: "#2563EB", + backgroundColour: "#111111", + textColour: "#FFFFFF", + }); + expect( + await editBrand(terminal(["1", "#2563eb", "r", ""]).io, resolveBrand()), + ).toEqual({}); + }); + + it("treats EOF and abort as cancellation without retries", async () => { + await expect(resolveInvocation([], terminal().io)).rejects.toMatchObject({ + code: "cancelled", + }); + const controller = new AbortController(); + controller.abort(); + const { io } = terminal([], { signal: controller.signal }); + await expect(resolveInvocation([], io)).rejects.toMatchObject({ + code: "cancelled", + }); + expect(io.ask).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/extraction/brand-review.test.ts b/tests/extraction/brand-review.test.ts new file mode 100644 index 0000000..3c364d4 --- /dev/null +++ b/tests/extraction/brand-review.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; + +import { extractGenerationContext } from "../../src/extraction/extract-generation-context.js"; +import { ExtractionError } from "../../src/extraction/extraction-error.js"; +import type { + FetchedResource, + PublicFetchSession, +} from "../../src/extraction/http/index.js"; +import { + QueuedTextModel, + modelResponse, +} from "../support/queued-text-model.js"; + +const website = "https://grove.example.com/"; +const product = `${website}products/mug`; +const input = { website, products: [product], goal: "sales" }; + +/** Creates bounded fictional resources without touching a real website. */ +function resource(url: string, html: string): FetchedResource { + const body = new TextEncoder().encode(html); + return { + requestedUrl: url, + finalUrl: url, + mediaType: "text/html", + charset: "utf-8", + body, + compressedBytes: body.length, + decompressedBytes: body.length, + redirectCount: 0, + }; +} + +/** Supplies one brand and one observed product to the real extraction path. */ +function session(): PublicFetchSession { + return { + fetchHtml: async (url) => + resource( + url, + url === website + ? "

Quiet goods for your home.

" + : ``, + ), + fetchStylesheet: vi.fn(), + dispose: vi.fn(), + }; +} + +describe("brand review before paid model work", () => { + it("reviews deterministic styles after disposing fetch resources and before voice inference", async () => { + const fetchSession = session(); + const model = new QueuedTextModel([modelResponse("{}")]); + const result = await extractGenerationContext(input, { + fetchSession, + model, + reviewBrand: async (brand) => { + expect(model.requests).toHaveLength(0); + expect(fetchSession.dispose).toHaveBeenCalled(); + expect(brand.settings.primaryColour).toBe("#006644"); + return { primaryColour: "#2563EB" }; + }, + }); + expect(result.brand?.settings.primaryColour).toBe("#2563EB"); + expect(result.brand?.sources.primaryColour).toBe("manual"); + expect(result.brand?.sources.bodyFont).toBe("website"); + expect(model.requests).toHaveLength(1); + expect(JSON.stringify(result.context)).not.toContain("#2563EB"); + }); + + it("cancels review without spending tokens or returning a campaign", async () => { + const model = new QueuedTextModel([]); + await expect( + extractGenerationContext(input, { + fetchSession: session(), + model, + reviewBrand: async () => { + throw new ExtractionError("cancelled", false); + }, + }), + ).rejects.toMatchObject({ code: "cancelled" }); + expect(model.requests).toHaveLength(0); + }); +}); diff --git a/tests/extraction/brand-style-roles.test.ts b/tests/extraction/brand-style-roles.test.ts new file mode 100644 index 0000000..9b4c6d5 --- /dev/null +++ b/tests/extraction/brand-style-roles.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { extractBrand } from "../../src/extraction/extract-brand.js"; + +const url = "https://grove.example.com/"; + +/** Extracts only fictional styles for deterministic role tests. */ +function roles(css: string) { + return extractBrand({ finalUrl: url, html: `` }) + .styleRoles; +} + +describe("role-aware brand style extraction", () => { + it("keeps semantic roles rather than choosing the first colour", () => { + const styles = roles( + `.error{color:#ff0000} :root{--primary:#2563eb;--background:#ffffff;--text:#111827;--font-heading:"Grove Serif";--font-body:Verdana} button{background:#ffff00}`, + ); + expect(styles.primaryColour?.value).toBe("#2563EB"); + expect(styles.backgroundColour?.value).toBe("#FFFFFF"); + expect(styles.textColour?.value).toBe("#111827"); + expect(styles.headingFont?.value).toBe("Grove Serif"); + expect(styles.bodyFont?.value).toBe("Verdana"); + expect(styles.primaryColour?.evidence.url).toBe(url); + }); + + it("resolves local variables, RGB, shorthand hex and body/heading roles", () => { + const styles = roles( + ':root{--ink:#123;--action:#006644} body{background:rgb(255, 255, 255);color:var(--ink);font-family:Arial,sans-serif} h1,h2{font-family:"Grove Serif",serif} .button{background-color:var(--action)}', + ); + expect(styles.textColour?.value).toBe("#112233"); + expect(styles.backgroundColour?.value).toBe("#FFFFFF"); + expect(styles.primaryColour?.value).toBe("#006644"); + expect(styles.headingFont?.value).toBe("Grove Serif"); + expect(styles.bodyFont?.value).toBe("Arial"); + }); + + it("retains element roles for inline styles", () => { + const result = extractBrand({ + finalUrl: url, + html: '

Grove

', + }); + expect(result.styleRoles.primaryColour?.value).toBe("#006644"); + expect(result.styleRoles.headingFont?.value).toBe("Georgia"); + }); + + it("omits ambiguous, conditional, unrecognised and cyclic values", () => { + expect( + roles("button{background:#123456}.button{background:#654321}") + .primaryColour, + ).toBeUndefined(); + expect( + roles( + "@media(prefers-color-scheme:dark){body{background:#111}} button:hover{background:#123456} .alert{color:#abcdef}", + ), + ).toEqual({}); + expect( + roles(":root{--a:var(--b);--b:var(--a);--primary:var(--a)}"), + ).toEqual({}); + expect( + roles( + "body{background:url(https://outside.example.com/x);font-family:var(--missing)}", + ), + ).toEqual({}); + }); +});