@navipartner/demo-video 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/pitch.mjs ADDED
@@ -0,0 +1,261 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ import { z } from "zod";
5
+
6
+ // The brand half of `demo-video pitch <proposal.json>`: turns a pitch
7
+ // research's proposal into a brand folder for one render. The proposal comes
8
+ // from an AI that read the open web, so nothing in it is trusted: colours,
9
+ // fonts and config values are checked, and assets are downloaded here (not
10
+ // by the AI), over https, as images only. An SVG with script in it is
11
+ // refused. What goes back into the workflow and the reply (the customer,
12
+ // links, notes) is one line each, and plain text.
13
+
14
+ const MAX_ASSET_BYTES = 8 * 1024 * 1024;
15
+ // Sites behind a bot challenge (Cloudflare) refuse a bare fetch.
16
+ const HEADERS = {
17
+ "user-agent":
18
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0 Safari/537.36",
19
+ accept: "image/avif,image/webp,image/svg+xml,image/*,*/*;q=0.8",
20
+ };
21
+
22
+ const hex = z.string().regex(/^#[0-9a-fA-F]{6}$/, "a #rrggbb colour");
23
+ // A new line would start another workflow output.
24
+ const line = z.string().refine(
25
+ // eslint-disable-next-line no-control-regex -- they are what it rejects
26
+ (v) => !/[\u0000-\u001f\u007f]/.test(v),
27
+ "one line, no control characters",
28
+ );
29
+ const httpsUrl = line
30
+ .pipe(z.string().max(2000))
31
+ .refine((v) => {
32
+ try {
33
+ return new URL(v).protocol === "https:";
34
+ } catch {
35
+ return false;
36
+ }
37
+ }, "an https URL")
38
+ // Normalised, so it can't close the <…> it's printed in.
39
+ .transform((v) => new URL(v).href);
40
+ const font = z
41
+ .string()
42
+ .regex(/^[\w .-]{1,64}$/, "a font name: letters, digits, spaces, . and -");
43
+ // Values go into the app's own branding API mock: no markup, no CSS breakout.
44
+ const configValue = z.union([
45
+ z
46
+ .string()
47
+ .max(200)
48
+ .refine((v) => !/[<>{};`]/.test(v), "no < > { } ; or ` in a value"),
49
+ z.number(),
50
+ z.boolean(),
51
+ ]);
52
+
53
+ const proposalSchema = z
54
+ .object({
55
+ // The brand's tenantName takes 64 at most.
56
+ customer: line.pipe(z.string().min(1).max(64)),
57
+ website: httpsUrl,
58
+ sources: z.array(httpsUrl).min(1).max(20),
59
+ config: z.record(z.string().regex(/^\w{1,64}$/), configValue).default({}),
60
+ assets: z
61
+ .record(z.string().regex(/^[A-Za-z]{1,32}$/), httpsUrl)
62
+ .default({}),
63
+ video: z
64
+ .object({
65
+ primary: hex,
66
+ ink: hex,
67
+ accent: hex,
68
+ text: hex,
69
+ headingFont: font,
70
+ bodyFont: font,
71
+ })
72
+ .strict(),
73
+ logoColor: hex.optional(),
74
+ notes: line.pipe(z.string().max(1000)).optional(),
75
+ })
76
+ .strict();
77
+
78
+ /** "Hotel Royal Århus" → "pitch-hotel-royal-arhus". */
79
+ export const pitchName = (customer) => {
80
+ const slug = customer
81
+ .toLowerCase()
82
+ .replace(/æ/g, "ae")
83
+ .replace(/ø/g, "o")
84
+ .replace(/ß/g, "ss")
85
+ .normalize("NFKD")
86
+ .replace(/[̀-ͯ]/g, "")
87
+ .replace(/[^a-z0-9]+/g, "-")
88
+ .replace(/^-+|-+$/g, "")
89
+ .slice(0, 40)
90
+ .replace(/-+$/, "");
91
+ return `pitch-${slug || "customer"}`;
92
+ };
93
+
94
+ /** Text for the reply: no links, markup or mentions a web page slipped in. */
95
+ export const plainText = (text) =>
96
+ text
97
+ .replace(/\b[a-z][\w+.-]*:\/\/\S+/gi, "(link removed)")
98
+ .replace(/\bwww\.\S+/gi, "(link removed)")
99
+ .replace(/[[\]<>`*_~|\\]/g, "")
100
+ // A # starts a heading or an issue link, except in a colour (#1F5494).
101
+ .replace(/#(?![0-9a-f]{6}\b|[0-9a-f]{3}\b)/gi, "")
102
+ .replace(/@/g, "@\u200b")
103
+ .trim();
104
+
105
+ const SVG_DANGER =
106
+ /<script|<foreignObject|<iframe|<embed|<object|\son\w+\s*=|javascript:/i;
107
+
108
+ /** The image type of a download, by its bytes, or null. */
109
+ export const imageType = (bytes) => {
110
+ const b = bytes;
111
+ if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e) {
112
+ return ".png";
113
+ }
114
+ if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
115
+ return ".jpg";
116
+ }
117
+ if (
118
+ b.length >= 12 &&
119
+ b.toString("latin1", 0, 4) === "RIFF" &&
120
+ b.toString("latin1", 8, 12) === "WEBP"
121
+ ) {
122
+ return ".webp";
123
+ }
124
+ // An SVG file starts with <svg, after an XML declaration, a doctype or
125
+ // comments. A web page with an inline icon doesn't.
126
+ const head = b
127
+ .toString("utf8", 0, Math.min(b.length, 4096))
128
+ .replace(/^\uFEFF/, "")
129
+ .replace(/^(\s+|<\?xml[\s\S]*?\?>|<!--[\s\S]*?-->|<!DOCTYPE[^>]*>)*/i, "");
130
+ if (/^<svg[\s/>]/i.test(head) && !head.includes("\u0000")) {
131
+ return ".svg";
132
+ }
133
+ return null;
134
+ };
135
+
136
+ const download = async (url, fetchImpl) => {
137
+ const parsed = new URL(url);
138
+ if (parsed.protocol !== "https:") {
139
+ throw new Error(`${url}: only https downloads`);
140
+ }
141
+ const response = await fetchImpl(url, {
142
+ headers: HEADERS,
143
+ redirect: "follow",
144
+ signal: AbortSignal.timeout(20_000),
145
+ });
146
+ if (!response.ok) {
147
+ throw new Error(`${url}: HTTP ${response.status}`);
148
+ }
149
+ if (response.url && new URL(response.url).protocol !== "https:") {
150
+ throw new Error(`${url}: redirected off https`);
151
+ }
152
+ // The usual mistake: the page the logo is on, not the logo's own URL.
153
+ if (/text\/html/i.test(response.headers.get("content-type") ?? "")) {
154
+ throw new Error(`${url}: a web page, not an image file`);
155
+ }
156
+ const length = Number(response.headers.get("content-length") ?? 0);
157
+ if (length > MAX_ASSET_BYTES) {
158
+ throw new Error(`${url}: larger than 8 MB`);
159
+ }
160
+ const bytes = Buffer.from(await response.arrayBuffer());
161
+ if (bytes.length > MAX_ASSET_BYTES) {
162
+ throw new Error(`${url}: larger than 8 MB`);
163
+ }
164
+ const ext = imageType(bytes);
165
+ if (!ext) {
166
+ throw new Error(`${url}: not an SVG, PNG, JPEG or WebP image`);
167
+ }
168
+ if (ext === ".svg" && SVG_DANGER.test(bytes.toString("utf8"))) {
169
+ throw new Error(`${url}: an SVG with script or embedded content`);
170
+ }
171
+ return { bytes, ext };
172
+ };
173
+
174
+ // The video loads its fonts from Google Fonts: a name it doesn't know would
175
+ // silently fall back to a system font.
176
+ const checkGoogleFont = async (family, fetchImpl) => {
177
+ const url = `https://fonts.googleapis.com/css2?family=${encodeURIComponent(family).replace(/%20/g, "+")}`;
178
+ const response = await fetchImpl(url, {
179
+ signal: AbortSignal.timeout(20_000),
180
+ });
181
+ if (!response.ok) {
182
+ throw new Error(
183
+ `"${family}" is not a Google Fonts family (fonts.googleapis.com answered ${response.status})`,
184
+ );
185
+ }
186
+ };
187
+
188
+ // WCAG contrast of a #rrggbb colour against white.
189
+ export const contrastOnWhite = (colour) => {
190
+ const n = parseInt(colour.slice(1), 16);
191
+ const channel = (c) => {
192
+ const v = c / 255;
193
+ return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
194
+ };
195
+ const l =
196
+ 0.2126 * channel((n >> 16) & 255) +
197
+ 0.7152 * channel((n >> 8) & 255) +
198
+ 0.0722 * channel(n & 255);
199
+ return 1.05 / (l + 0.05);
200
+ };
201
+
202
+ /**
203
+ * Builds `<outDir>/<name>.json` and `<outDir>/<name>/` from a proposal.
204
+ * Returns what the reply names: the brand, the customer and the sources.
205
+ */
206
+ export const buildPitchBrand = async (
207
+ proposal,
208
+ outDir,
209
+ { fetch: fetchImpl = globalThis.fetch } = {},
210
+ ) => {
211
+ const result = proposalSchema.safeParse(proposal);
212
+ if (!result.success) {
213
+ throw new Error(
214
+ `The proposal isn't a brand: ${result.error.issues
215
+ .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
216
+ .join("; ")}`,
217
+ );
218
+ }
219
+ const p = result.data;
220
+ const name = pitchName(p.customer);
221
+ // Headings and text sit on pale cards.
222
+ for (const key of ["ink", "text"]) {
223
+ const ratio = contrastOnWhite(p.video[key]);
224
+ if (ratio < 4.5) {
225
+ throw new Error(
226
+ `video.${key} ${p.video[key]} doesn't read on white (contrast ${ratio.toFixed(1)}, needs 4.5)`,
227
+ );
228
+ }
229
+ }
230
+ await checkGoogleFont(p.video.headingFont, fetchImpl);
231
+ if (p.video.bodyFont !== p.video.headingFont) {
232
+ await checkGoogleFont(p.video.bodyFont, fetchImpl);
233
+ }
234
+ const assetDir = path.join(outDir, name);
235
+ fs.rmSync(assetDir, { recursive: true, force: true });
236
+ fs.mkdirSync(assetDir, { recursive: true });
237
+ const assets = {};
238
+ for (const [slot, url] of Object.entries(p.assets)) {
239
+ const { bytes, ext } = await download(url, fetchImpl);
240
+ fs.writeFileSync(path.join(assetDir, `${slot}${ext}`), bytes);
241
+ assets[slot] = `${name}/${slot}${ext}`;
242
+ }
243
+ const brand = {
244
+ tenantName: p.customer,
245
+ config: p.config,
246
+ assets,
247
+ video: p.video,
248
+ ...(p.logoColor ? { logoColor: p.logoColor } : {}),
249
+ };
250
+ fs.writeFileSync(
251
+ path.join(outDir, `${name}.json`),
252
+ JSON.stringify(brand, null, 2),
253
+ );
254
+ return {
255
+ brand: name,
256
+ customer: plainText(p.customer),
257
+ website: p.website,
258
+ sources: p.sources,
259
+ notes: plainText(p.notes ?? ""),
260
+ };
261
+ };
@@ -112,6 +112,17 @@ interface DemoAction<S extends z.ZodRawShape = z.ZodRawShape> {
112
112
  params: z.ZodObject<S>;
113
113
  run(d: DirectorApi, params: z.infer<z.ZodObject<S>>): Promise<void>;
114
114
  }
115
+ /** One thing a brand gives the app: what it is and where it shows. */
116
+ type BrandNeed = string | {
117
+ what: string;
118
+ optional?: boolean;
119
+ };
120
+ interface BrandNeeds {
121
+ /** `brand.config` keys that `setup` applies. */
122
+ config?: Record<string, BrandNeed>;
123
+ /** Asset slots that `setup` uses (`brand.assetUrls[slot]`). */
124
+ assets?: Record<string, BrandNeed>;
125
+ }
115
126
  interface DemoAdapter {
116
127
  /** Must equal the storyboard's `app`. */
117
128
  app: string;
@@ -148,6 +159,14 @@ interface DemoAdapter {
148
159
  brands?: string;
149
160
  /** Default "navipartner". */
150
161
  defaultBrand?: string;
162
+ /**
163
+ * What this app takes from a brand, each with what it is and where it
164
+ * shows ("full-screen photo behind the menu, landscape"). Apps differ: one
165
+ * shows a background image, another a secondary colour. Whoever makes a
166
+ * brand reads this (`demo-video brand-needs`), and a brand without a need
167
+ * that isn't `optional` fails `check`.
168
+ */
169
+ brandNeeds?: BrandNeeds;
151
170
  /** App-specific steps, used in storyboards as { "do": "<name>", …params }. */
152
171
  actions?: Record<string, DemoAction<any>>;
153
172
  }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { D as DemoAdapter } from './adapter-CWqMoNdw.js';
2
- export { B as Brand, C as CardSpec, a as DemoAction, b as DirectorApi, T as Target, V as VideoBrand, d as defineAction, c as defineDemo } from './adapter-CWqMoNdw.js';
1
+ import { D as DemoAdapter } from './adapter-D_iU2dh2.js';
2
+ export { B as Brand, C as CardSpec, a as DemoAction, b as DirectorApi, T as Target, V as VideoBrand, d as defineAction, c as defineDemo } from './adapter-D_iU2dh2.js';
3
3
  import { Page } from '@playwright/test';
4
4
  import { z } from 'zod';
5
5
  export { z } from 'zod';
@@ -240,7 +240,7 @@ interface Storyboard {
240
240
  brand?: string;
241
241
  /** The story a variant extends. */
242
242
  variantOf?: string;
243
- /** What the story shows, for people (and the Linear agent) choosing one. */
243
+ /** What the story shows, for people choosing one (and the menu). */
244
244
  description?: string;
245
245
  /** The app's primary journey: the story filmed when none is named. One per app. */
246
246
  primary?: boolean;
@@ -1,5 +1,5 @@
1
1
  import { PlaywrightTestConfig, Page } from '@playwright/test';
2
- import { D as DemoAdapter } from '../adapter-CWqMoNdw.js';
2
+ import { D as DemoAdapter } from '../adapter-D_iU2dh2.js';
3
3
  import 'zod';
4
4
 
5
5
  /**
@@ -7,7 +7,7 @@ import 'zod';
7
7
  * `defineConfig` and loads no Playwright of its own. `demoDir` is the app's
8
8
  * demo/ folder; `runnerDir` holds the spec the CLI generated.
9
9
  */
10
- declare const createDemoConfig: (adapter: DemoAdapter, demoDir: string, runnerDir: string) => PlaywrightTestConfig;
10
+ declare const createDemoConfig: (adapter: DemoAdapter, demoDir: string, runnerDir: string, mode?: string | undefined) => PlaywrightTestConfig;
11
11
 
12
12
  /** The app's own Playwright `test`, passed in so only one Playwright loads. */
13
13
  type DemoTest = (title: string, body: (args: {
@@ -34,7 +34,7 @@ import {
34
34
 
35
35
  // src/runner/config.ts
36
36
  import path from "path";
37
- var createDemoConfig = (adapter, demoDir, runnerDir) => ({
37
+ var createDemoConfig = (adapter, demoDir, runnerDir, mode = process.env.DEMO_MODE) => ({
38
38
  testDir: runnerDir,
39
39
  testMatch: ["demo.spec.mjs"],
40
40
  // Beside the runner, in the run's work folder: kept only on failure.
@@ -55,7 +55,7 @@ var createDemoConfig = (adapter, demoDir, runnerDir) => ({
55
55
  video: "off",
56
56
  screenshot: "off"
57
57
  },
58
- webServer: {
58
+ webServer: mode === "needs" ? void 0 : {
59
59
  command: adapter.webServer.command,
60
60
  // demo/ sits in the app's root, where its dev script lives.
61
61
  cwd: path.dirname(demoDir),
@@ -213,6 +213,34 @@ var routeBrandAssets = async (page, brand) => {
213
213
  );
214
214
  }
215
215
  };
216
+ var VIDEO_NEEDS = {
217
+ tenantName: "The customer's name, as the app shows it.",
218
+ "assets.logo": "The customer's logo: SVG, or a PNG with a transparent background. Shown on the video's cards.",
219
+ "video.primary": "The main brand colour (#rrggbb): full-bleed cards, the emphasis word, the background tint.",
220
+ "video.ink": "A dark brand colour for headings on pale cards. Must read well on white.",
221
+ "video.accent": "A lighter brand colour for the soft background bloom.",
222
+ "video.text": "Body text on pale cards: near-black.",
223
+ "video.headingFont": "A Google Fonts family for headings.",
224
+ "video.bodyFont": "A Google Fonts family for sub-lines.",
225
+ logoColor: "Optional: replaces white fills in an SVG logo, so a white logo shows on pale cards."
226
+ };
227
+ var needOf = (need) => typeof need === "string" ? { what: need, optional: false } : { what: need.what, optional: need.optional === true };
228
+ var describeNeeds = (needs = {}) => ({
229
+ config: Object.fromEntries(
230
+ Object.entries(needs.config ?? {}).map(([k, v]) => [k, needOf(v)])
231
+ ),
232
+ assets: Object.fromEntries(
233
+ Object.entries(needs.assets ?? {}).map(([k, v]) => [k, needOf(v)])
234
+ )
235
+ });
236
+ var missingNeeds = (brand, needs = {}) => {
237
+ const { config, assets } = describeNeeds(needs);
238
+ const empty = (value) => value === void 0 || value === null || value === "";
239
+ return [
240
+ ...Object.entries(config).filter(([key, need]) => !need.optional && empty(brand.config[key])).map(([key, need]) => `config.${key}: ${need.what}`),
241
+ ...Object.entries(assets).filter(([slot, need]) => !need.optional && !brand.assets[slot]).map(([slot, need]) => `assets.${slot}: ${need.what}`)
242
+ ];
243
+ };
216
244
 
217
245
  // src/changes.ts
218
246
  var watchChanges = (target) => target.evaluate((el) => {
@@ -1075,6 +1103,7 @@ var readRunEnv = (env = process.env) => {
1075
1103
  outDir,
1076
1104
  workDir: env.DEMO_WORK || `${outDir}/.work`,
1077
1105
  brand: env.DEMO_BRAND || void 0,
1106
+ brandsDir: env.DEMO_BRANDS || void 0,
1078
1107
  draft: env.DEMO_DRAFT === "1"
1079
1108
  };
1080
1109
  };
@@ -1124,6 +1153,23 @@ var writeJson = (file, value) => {
1124
1153
  fs3.writeFileSync(file, JSON.stringify(value, null, 2));
1125
1154
  };
1126
1155
  var registerDemoTests = (test, adapter, demoDir) => {
1156
+ if (process.env.DEMO_MODE === "needs") {
1157
+ test("demo", async () => {
1158
+ writeJson(path4.join(process.env.DEMO_WORK ?? demoDir, "needs.json"), {
1159
+ app: adapter.app,
1160
+ viewport: adapter.viewport ?? DEFAULT_VIEWPORT,
1161
+ video: VIDEO_NEEDS,
1162
+ appNeeds: describeNeeds(adapter.brandNeeds),
1163
+ example: JSON.parse(
1164
+ fs3.readFileSync(
1165
+ path4.join(sharedBrandsDir(), "navipartner.json"),
1166
+ "utf8"
1167
+ )
1168
+ )
1169
+ });
1170
+ });
1171
+ return;
1172
+ }
1127
1173
  test("demo", async ({ page }) => {
1128
1174
  const env = readRunEnv();
1129
1175
  if (env.mode === "check") {
@@ -1156,9 +1202,21 @@ var registerDemoTests = (test, adapter, demoDir) => {
1156
1202
  );
1157
1203
  }
1158
1204
  const brand = loadBrand(
1159
- brandDirs(demoDir, adapter.brands),
1205
+ [
1206
+ ...env.brandsDir ? [env.brandsDir] : [],
1207
+ ...brandDirs(demoDir, adapter.brands)
1208
+ ],
1160
1209
  env.brand ?? storyboard.brand ?? adapter.defaultBrand ?? "navipartner"
1161
1210
  );
1211
+ const missing = missingNeeds(brand, adapter.brandNeeds);
1212
+ if (missing.length > 0) {
1213
+ throw new Error(
1214
+ [
1215
+ `Brand "${brand.name}" lacks what ${adapter.app} needs (the adapter's brandNeeds):`,
1216
+ ...missing.map((m) => ` ${m}`)
1217
+ ].join("\n")
1218
+ );
1219
+ }
1162
1220
  await adapter.setup(page, { brand });
1163
1221
  await routeBrandAssets(page, brand);
1164
1222
  await page.route(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@navipartner/demo-video",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Marketing demo videos of a React app, filmed by Playwright against the app's own e2e mocks.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {