@navipartner/demo-video 0.5.0 → 0.6.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/pick.mjs CHANGED
@@ -1,27 +1,23 @@
1
- import fs from "node:fs";
2
1
  import path from "node:path";
3
- import { fileURLToPath } from "node:url";
4
2
 
5
3
  import { listStories } from "./args.mjs";
6
4
 
7
5
  // `demo-video pick`: turns a `/demo-video …` request into one of the
8
- // already-made stories and a brand. No AI and nothing written: stories are a
9
- // developer's to make (locally, with the skill); a request only chooses.
6
+ // already-made stories. No AI and nothing written: stories are a developer's
7
+ // to make (locally, with the skill); a request only chooses. Every video is
8
+ // in NaviPartner's brand: a customer's is researched by @Linear for one video
9
+ // (the skill's linear.md), never kept in a repo.
10
10
  //
11
- // /demo-video → the primary story (one app)
12
- // /demo-video help → the menu
13
- // /demo-video checkout in acme branding → story "checkout", brand "acme"
14
- // /demo-video retail checkout → the app whose folder says retail
11
+ // /demo-video → the primary story (one app)
12
+ // /demo-video help → the menu
13
+ // /demo-video checkout → story "checkout"
14
+ // /demo-video checkout in danish → its translation "checkout-da"
15
+ // /demo-video retail checkout → the app whose folder says retail
15
16
  //
16
17
  // Anything it can't settle on one story is answered with the menu.
17
18
 
18
- const engineDir = path.resolve(
19
- path.dirname(fileURLToPath(import.meta.url)),
20
- "..",
21
- );
22
-
23
19
  const STOP = new Set(
24
- "a an and as at by for from in into make me of on or our please show story the this to video with branding brand marketing demo film render".split(
20
+ "a an and as at by for from in into make me of on or our please show story the this to video with branding brand marketing demo film render navipartner på".split(
25
21
  " ",
26
22
  ),
27
23
  );
@@ -31,44 +27,46 @@ const words = (text) =>
31
27
  (w) => [w, ...(w.includes("-") ? w.split("-") : [])],
32
28
  );
33
29
 
34
- const brandNames = (dir) =>
35
- fs.existsSync(dir)
36
- ? fs
37
- .readdirSync(dir)
38
- .filter((f) => f.endsWith(".json"))
39
- .map((f) => f.slice(0, -".json".length))
40
- : [];
41
-
42
- // The adapter may keep its brands elsewhere (`brands: "../brands"`). Read
43
- // from demo.config.ts's text: a literal path, as the field is documented.
44
- const adapterBrandsDir = (appDir) => {
45
- const config = path.join(appDir, "demo", "demo.config.ts");
46
- const text = fs.existsSync(config) ? fs.readFileSync(config, "utf8") : "";
47
- const dir = /\bbrands:\s*["'`]([^"'`]+)["'`]/.exec(text)?.[1] ?? "./brands";
48
- return path.resolve(appDir, "demo", dir);
30
+ // A translation is a story named for its language (checkout-da): "danish"
31
+ // and "dansk" say its "da" too.
32
+ const languageNames = (part) => {
33
+ if (!/^[a-z]{2}$/.test(part)) {
34
+ return [];
35
+ }
36
+ try {
37
+ return [
38
+ new Intl.DisplayNames(["en"], { type: "language" }).of(part),
39
+ new Intl.DisplayNames([part], { type: "language" }).of(part),
40
+ ]
41
+ .filter((name) => name && name.toLowerCase() !== part)
42
+ .map((name) => name.toLowerCase());
43
+ } catch {
44
+ return [];
45
+ }
49
46
  };
50
47
 
51
- /** What an app offers: its stories, and its brands plus the engine's. */
48
+ /** What an app offers: its stories. */
52
49
  export const appMenu = (appDir) => ({
53
50
  dir: appDir,
54
51
  name: path.basename(appDir),
55
52
  stories: listStories(path.join(appDir, "demo")),
56
- brands: [
57
- ...new Set([
58
- ...brandNames(adapterBrandsDir(appDir)),
59
- ...brandNames(path.join(engineDir, "brands")),
60
- ]),
61
- ],
62
53
  });
63
54
 
55
+ const LINEAR =
56
+ "A customer's branding isn't kept in a repo: in Linear, ask @Linear for the video, and it researches their brand for that one video.";
57
+
64
58
  /**
65
- * Picks from `apps` (appMenu results). Returns { app, story, brand } (brand
66
- * null = the adapter's default) or { menu: true, reason }.
59
+ * Picks from `apps` (appMenu results). Returns { app, story } or
60
+ * { menu: true, reason }.
67
61
  */
68
62
  export const pick = (request, apps) => {
69
63
  // A bare /demo-video films the primary story; this asks what there is.
70
64
  if (/^\s*(help|menu|list)\s*$/i.test(request)) {
71
- return { menu: true, reason: "The stories and brands:" };
65
+ return { menu: true, reason: "The stories:" };
66
+ }
67
+ const branded = /(?:^|\s)in\s+([\w-]+)\s+brand(?:ing)?\b/i.exec(request);
68
+ if (branded && branded[1].toLowerCase() !== "navipartner") {
69
+ return { menu: true, reason: LINEAR };
72
70
  }
73
71
  return pickStory(request, apps);
74
72
  };
@@ -76,22 +74,10 @@ export const pick = (request, apps) => {
76
74
  const pickStory = (request, apps) => {
77
75
  const said = words(request);
78
76
  const has = (word) => said.includes(word.toLowerCase());
77
+ const saysPart = (part) => has(part) || languageNames(part).some(has);
79
78
 
80
- // The brand: any offered brand named in the request.
81
- const allBrands = [...new Set(apps.flatMap((a) => a.brands))];
82
- const brands = allBrands.filter(has);
83
- if (brands.length > 1) {
84
- return {
85
- menu: true,
86
- reason: `Name one brand, not ${brands.join(" and ")}.`,
87
- };
88
- }
89
- const brand = brands[0] ?? null;
90
-
91
- // What's left of the request once the brand and filler are gone.
92
- const content = said.filter(
93
- (w) => !STOP.has(w) && !brands.includes(w) && !/^\d+$/.test(w),
94
- );
79
+ // What's left of the request once the filler is gone.
80
+ const content = said.filter((w) => !STOP.has(w) && !/^\d+$/.test(w));
95
81
 
96
82
  // The app: named by its folder, else by a part of it no other app's
97
83
  // folder has ("retail", not "self"), else the only one.
@@ -114,23 +100,18 @@ const pickStory = (request, apps) => {
114
100
  }
115
101
  }
116
102
 
117
- // Score every story: its name (or a part of it) said outright counts most,
103
+ // Score every story: its name (or its parts) said outright counts most,
118
104
  // then words shared with its description.
119
- // Variants aren't scored: the base story plus the brand finds them.
120
105
  const scored = candidates.flatMap((app) =>
121
- app.stories
122
- .filter((story) => !story.extends)
123
- .map((story) => {
124
- const nameParts = story.name.split("-");
125
- // "checkout express" says the story checkout-express in full.
126
- let score = has(story.name) || nameParts.every((p) => has(p)) ? 10 : 0;
127
- score += nameParts.filter((p) => p.length > 2 && has(p)).length * 3;
128
- const description = new Set(words(story.description ?? ""));
129
- score += content.filter(
130
- (w) => w.length > 2 && description.has(w),
131
- ).length;
132
- return { app, story, score };
133
- }),
106
+ app.stories.map((story) => {
107
+ const nameParts = story.name.split("-");
108
+ // "checkout express" says the story checkout-express in full.
109
+ let score = has(story.name) || nameParts.every(saysPart) ? 10 : 0;
110
+ score += nameParts.filter(saysPart).length * 3;
111
+ const description = new Set(words(story.description ?? ""));
112
+ score += content.filter((w) => w.length > 2 && description.has(w)).length;
113
+ return { app, story, score };
114
+ }),
134
115
  );
135
116
  const best = Math.max(0, ...scored.map((s) => s.score));
136
117
  const top = scored.filter((s) => s.score === best);
@@ -147,7 +128,7 @@ const pickStory = (request, apps) => {
147
128
  .map((app) => ({ app, story: app.stories.find((s) => s.primary) }))
148
129
  .filter((p) => p.story);
149
130
  if (noFeature && candidates.length === 1 && primaries.length === 1) {
150
- return withBrand(primaries[0].app, primaries[0].story.name, brand);
131
+ return { app: primaries[0].app.dir, story: primaries[0].story.name };
151
132
  }
152
133
  return {
153
134
  menu: true,
@@ -163,24 +144,7 @@ const pickStory = (request, apps) => {
163
144
  reason: `That matches ${top.map((t) => `\`${t.story.name}\``).join(", ")}. Pick one by name:`,
164
145
  };
165
146
  }
166
- return withBrand(top[0].app, top[0].story.name, brand);
167
- };
168
-
169
- // The brand must be one the chosen app has: another app's brand would fail
170
- // at render time with a worse message. A story with a variant for that brand
171
- // (checkout + acme → checkout.acme) films the variant: its words are the
172
- // customer's.
173
- const withBrand = (app, story, brand) => {
174
- if (brand && !app.brands.includes(brand)) {
175
- return {
176
- menu: true,
177
- reason: `No brand \`${brand}\` in ${app.name}. Pick one:`,
178
- apps: [app],
179
- };
180
- }
181
- const variant =
182
- brand && app.stories.find((s) => s.extends === story && s.brand === brand);
183
- return { app: app.dir, story: variant ? variant.name : story, brand };
147
+ return { app: top[0].app.dir, story: top[0].story.name };
184
148
  };
185
149
 
186
150
  /** The menu as Markdown, for the PR reply. */
@@ -191,36 +155,30 @@ export const menuMarkdown = (apps, reason) => {
191
155
  if (app.stories.length === 0) {
192
156
  lines.push("- no stories yet");
193
157
  }
194
- for (const s of app.stories.filter((x) => !x.extends)) {
158
+ for (const s of app.stories) {
195
159
  lines.push(
196
160
  `- \`${s.name}\`${s.primary ? " (primary)" : ""}${s.description ? `: ${s.description}` : ""}`,
197
161
  );
198
- for (const v of app.stories.filter((x) => x.extends === s.name)) {
199
- lines.push(` - in \`${v.brand}\` branding: \`${v.name}\``);
200
- }
201
162
  }
202
- lines.push(`- brands: ${app.brands.map((b) => `\`${b}\``).join(", ")}`, "");
163
+ lines.push("");
203
164
  }
204
- const example = apps.find((a) => a.stories.some((s) => !s.extends));
165
+ const example = apps.find((a) => a.stories.length > 0);
205
166
  if (example) {
206
- lines.push(
207
- `For example: \`/demo-video ${example.stories.find((s) => !s.extends).name}${example.brands.length > 1 ? ` in ${example.brands.find((b) => b !== "navipartner") ?? example.brands[0]} branding` : ""}\``,
208
- );
209
- lines.push(
210
- "",
211
- "A customer without a brand here: ask @Linear for a pitch draft (see the demo-video README).",
212
- );
167
+ lines.push(`For example: \`/demo-video ${example.stories[0].name}\``);
168
+ }
169
+ if (reason !== LINEAR) {
170
+ lines.push("", LINEAR);
213
171
  }
214
172
  return lines.join("\n");
215
173
  };
216
174
 
217
175
  /**
218
- * The form's version (Actions → Run workflow): app, story and brand by name,
219
- * no guessing. Empty story = the primary story, empty brand = the adapter's
220
- * default, empty app = the only app. A name that doesn't exist gets the menu,
221
- * so a typo never films something else. `apps` in a menu result narrows it.
176
+ * The form's version (Actions → Run workflow): app and story by name, no
177
+ * guessing. Empty story = the primary story, empty app = the only app. A
178
+ * name that doesn't exist gets the menu, so a typo never films something
179
+ * else. `apps` in a menu result narrows it.
222
180
  */
223
- export const pickExact = (apps, { app = "", story = "", brand = "" }) => {
181
+ export const pickExact = (apps, { app = "", story = "" }) => {
224
182
  const slashes = (p) => p.split("\\").join("/");
225
183
  const wanted = slashes(app).replace(/\/+$/, "");
226
184
  const candidates = wanted
@@ -250,16 +208,5 @@ export const pickExact = (apps, { app = "", story = "", brand = "" }) => {
250
208
  apps: [chosen],
251
209
  };
252
210
  }
253
- if (found.extends) {
254
- // A variant is made for one brand.
255
- if (brand && brand !== found.brand) {
256
- return {
257
- menu: true,
258
- reason: `\`${found.name}\` is made for \`${found.brand}\`. Leave Brand empty, or pick \`${found.extends}\`:`,
259
- apps: [chosen],
260
- };
261
- }
262
- return { app: chosen.dir, story: found.name, brand: found.brand };
263
- }
264
- return withBrand(chosen, found.name, brand || null);
211
+ return { app: chosen.dir, story: found.name };
265
212
  };
package/bin/pitch.mjs CHANGED
@@ -75,7 +75,7 @@ const proposalSchema = z
75
75
  })
76
76
  .strict();
77
77
 
78
- /** "Hotel Royal Århus" → "pitch-hotel-royal-arhus". */
78
+ /** "Northwind Århus" → "pitch-northwind-arhus". */
79
79
  export const pitchName = (customer) => {
80
80
  const slug = customer
81
81
  .toLowerCase()
@@ -152,13 +152,6 @@ interface DemoAdapter {
152
152
  setup(page: Page, ctx: {
153
153
  brand: Brand;
154
154
  }): Promise<void>;
155
- /**
156
- * The app's own brands folder, relative to demo/ (default "./brands").
157
- * Searched before the brands shipped with the engine (navipartner, ...).
158
- */
159
- brands?: string;
160
- /** Default "navipartner". */
161
- defaultBrand?: string;
162
155
  /**
163
156
  * What this app takes from a brand, each with what it is and where it
164
157
  * shows ("full-screen photo behind the menu, landscape"). Apps differ: one
@@ -502,76 +502,15 @@ var parseStoryboard = (raw, adapter, source = "storyboard") => {
502
502
  throw new StoryboardError(`Invalid ${source}:
503
503
  ${lines.join("\n")}`);
504
504
  };
505
- var cardChange = z.object({
506
- eyebrow: cardFields.eyebrow,
507
- heading: cardFields.heading,
508
- sub: cardFields.sub,
509
- logo: cardFields.logo,
510
- style: z.enum(["pale", "brand"]).optional(),
511
- seconds: z.number().min(0.5).max(10).optional()
512
- }).strict();
513
- var variantSchema = z.object({
514
- extends: z.string().regex(/^[\w-]+$/, "the base story's name"),
515
- brand: z.string().regex(/^[\w-]+$/, "a brand name"),
516
- description: z.string().min(1).max(280).optional(),
517
- title: cardChange.optional(),
518
- closing: cardChange.optional(),
519
- cards: z.array(cardChange).optional()
520
- }).strict();
521
505
  var readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
522
- var isVariant = (raw) => typeof raw === "object" && raw !== null && "extends" in raw;
523
- var loadVariant = (raw, file, adapter) => {
524
- const name = path.basename(file);
525
- const result = variantSchema.safeParse(raw);
526
- if (!result.success) {
527
- const lines = result.error.issues.map(
528
- (i) => ` ${i.path.join(".") || "(root)"}: ${i.message}`
529
- );
530
- throw new StoryboardError(`Invalid ${name}:
531
- ${lines.join("\n")}`);
532
- }
533
- const variant = result.data;
534
- const baseFile = path.join(path.dirname(file), `${variant.extends}.json`);
535
- if (!fs.existsSync(baseFile)) {
536
- throw new StoryboardError(
537
- `${name} extends "${variant.extends}", but there is no ${variant.extends}.json beside it`
538
- );
539
- }
540
- const base = readJson(baseFile);
541
- if (isVariant(base)) {
542
- throw new StoryboardError(
543
- `${name} extends ${variant.extends}.json, which is a variant itself; extend the story it extends`
544
- );
545
- }
546
- const steps = Array.isArray(base.steps) ? base.steps : [];
547
- const cardAt = steps.flatMap((s, i) => s.do === "card" ? [i] : []);
548
- if (variant.cards && variant.cards.length !== cardAt.length) {
506
+ var loadStoryboard = (file, adapter) => {
507
+ const raw = readJson(file);
508
+ if (typeof raw === "object" && raw !== null && ("extends" in raw || "brand" in raw)) {
549
509
  throw new StoryboardError(
550
- `${name} gives ${variant.cards.length} card${variant.cards.length === 1 ? "" : "s"}; ${variant.extends}.json has ${cardAt.length}. A variant gives every card of its story, in order.`
510
+ `${path.basename(file)} names a brand or extends a story: stories hold neither. A customer's brand is researched by @Linear for one video; a translation is a story of its own (checkout-da.json).`
551
511
  );
552
512
  }
553
- const change = (card, over) => over ? { ...card, ...over } : card;
554
- const merged = steps.map(
555
- (step, i) => variant.cards ? change(step, variant.cards[cardAt.indexOf(i)]) : step
556
- );
557
- const rest = { ...base };
558
- delete rest.primary;
559
- const story = parseStoryboard(
560
- {
561
- ...rest,
562
- description: variant.description ?? base.description,
563
- title: change(base.title, variant.title),
564
- closing: change(base.closing, variant.closing),
565
- steps: merged
566
- },
567
- adapter,
568
- `${name} (on ${variant.extends}.json)`
569
- );
570
- return { ...story, brand: variant.brand, variantOf: variant.extends };
571
- };
572
- var loadStoryboard = (file, adapter) => {
573
- const raw = readJson(file);
574
- return isVariant(raw) ? loadVariant(raw, file, adapter) : parseStoryboard(raw, adapter, path.basename(file));
513
+ return parseStoryboard(raw, adapter, path.basename(file));
575
514
  };
576
515
  var MIN_ZOOM = MIN_SCALE;
577
516
  var MAX_ZOOM = MAX_SCALE;
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
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';
1
+ import { D as DemoAdapter } from './adapter-CDOXdUZI.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-CDOXdUZI.js';
3
3
  import { Page } from '@playwright/test';
4
4
  import { z } from 'zod';
5
5
  export { z } from 'zod';
@@ -236,10 +236,6 @@ declare const cardSchema: z.ZodEffects<z.ZodObject<{
236
236
  type Card = z.infer<typeof cardSchema>;
237
237
  interface Storyboard {
238
238
  app: string;
239
- /** A variant's brand (see loadStoryboard): it is filmed with this one only. */
240
- brand?: string;
241
- /** The story a variant extends. */
242
- variantOf?: string;
243
239
  /** What the story shows, for people choosing one (and the menu). */
244
240
  description?: string;
245
241
  /** The app's primary journey: the story filmed when none is named. One per app. */
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  StoryboardError,
3
3
  parseStoryboard
4
- } from "./chunk-BYAFDWPA.js";
4
+ } from "./chunk-D7UYGNSF.js";
5
5
 
6
6
  // src/adapter.ts
7
7
  var defineDemo = (adapter) => adapter;
@@ -1,5 +1,5 @@
1
1
  import { PlaywrightTestConfig, Page } from '@playwright/test';
2
- import { D as DemoAdapter } from '../adapter-D_iU2dh2.js';
2
+ import { D as DemoAdapter } from '../adapter-CDOXdUZI.js';
3
3
  import 'zod';
4
4
 
5
5
  /**
@@ -30,7 +30,7 @@ import {
30
30
  unionRect,
31
31
  visibleRect,
32
32
  wideShot
33
- } from "../chunk-BYAFDWPA.js";
33
+ } from "../chunk-D7UYGNSF.js";
34
34
 
35
35
  // src/runner/config.ts
36
36
  import path from "path";
@@ -135,10 +135,17 @@ var engineRoot = () => {
135
135
  }
136
136
  };
137
137
  var sharedBrandsDir = () => path2.join(engineRoot(), "brands");
138
- var brandDirs = (demoDir, brands = "./brands") => [
139
- path2.resolve(demoDir, brands),
138
+ var brandDirs = (pitchDir) => [
139
+ ...pitchDir ? [pitchDir] : [],
140
140
  sharedBrandsDir()
141
141
  ];
142
+ var refuseRepoBrands = (demoDir) => {
143
+ if (fs.existsSync(path2.join(demoDir, "brands"))) {
144
+ throw new Error(
145
+ `${path2.join(demoDir, "brands")} isn't used: repos keep no brands. NaviPartner's ships with the engine; a customer's is researched by @Linear for one video and never saved. Delete the folder.`
146
+ );
147
+ }
148
+ };
142
149
  var brandNames = (dir) => fs.existsSync(dir) ? fs.readdirSync(dir).filter((f) => f.endsWith(".json")).map((f) => f.slice(0, -".json".length)) : [];
143
150
  var loadBrand = (dirs, name) => {
144
151
  if (!NAME_RE.test(name)) {
@@ -1196,17 +1203,10 @@ var registerDemoTests = (test, adapter, demoDir) => {
1196
1203
  ].join("\n")
1197
1204
  );
1198
1205
  }
1199
- if (storyboard.brand && env.brand && env.brand !== storyboard.brand) {
1200
- throw new StoryboardError(
1201
- `${path4.basename(env.storyboardPath, ".json")} is ${storyboard.variantOf} for the brand ${storyboard.brand}. Render it without --brand, or render ${storyboard.variantOf} with --brand ${env.brand}.`
1202
- );
1203
- }
1206
+ refuseRepoBrands(demoDir);
1204
1207
  const brand = loadBrand(
1205
- [
1206
- ...env.brandsDir ? [env.brandsDir] : [],
1207
- ...brandDirs(demoDir, adapter.brands)
1208
- ],
1209
- env.brand ?? storyboard.brand ?? adapter.defaultBrand ?? "navipartner"
1208
+ brandDirs(env.brandsDir),
1209
+ env.brand ?? "navipartner"
1210
1210
  );
1211
1211
  const missing = missingNeeds(brand, adapter.brandNeeds);
1212
1212
  if (missing.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@navipartner/demo-video",
3
- "version": "0.5.0",
3
+ "version": "0.6.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": {