@struq/skill 0.5.0 → 0.5.1

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/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  <h1 align="center">
2
- <a href="https://struq.nl/skill">
2
+ <a href="https://struq.app/skill">
3
3
  <picture>
4
- <source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/banner-dark.svg">
5
- <img alt="Struq skill: studio-grade sites from the AI you already use." src="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/banner-light.svg" width="100%">
4
+ <source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/banner-dark.svg">
5
+ <img alt="Struq skill: studio-grade sites from the AI you already use." src="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/banner-light.svg" width="100%">
6
6
  </picture>
7
7
  </a>
8
8
  </h1>
@@ -12,13 +12,13 @@
12
12
  </p>
13
13
 
14
14
  <p align="center">
15
- <a href="https://struq.nl/skill">Website</a>
15
+ <a href="https://struq.app/skill">Website</a>
16
16
  &nbsp;&middot;&nbsp;
17
17
  <a href="https://www.npmjs.com/package/@struq/skill">npm</a>
18
18
  &nbsp;&middot;&nbsp;
19
19
  <a href="skills/struq/blocks/CATALOG.md">The blocks</a>
20
20
  &nbsp;&middot;&nbsp;
21
- <a href="https://struq.nl/iconen">Struq Icons</a>
21
+ <a href="https://struq.app/icons">Struq Icons</a>
22
22
  &nbsp;&middot;&nbsp;
23
23
  <a href="CHANGELOG.md">Changelog</a>
24
24
  </p>
@@ -45,7 +45,7 @@ It plans the site, stops once to show you the plan, and builds only after you sa
45
45
 
46
46
  <br>
47
47
 
48
- ## <picture><source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/h/agents-dark.svg"><img src="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/h/agents-light.svg" width="26" height="26" alt=""></picture> Same brief, two agents
48
+ ## <picture><source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/h/agents-dark.svg"><img src="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/h/agents-light.svg" width="26" height="26" alt=""></picture> Same brief, two agents
49
49
 
50
50
  Three briefs, each built twice by the same model with the same materials: once as a plain coding agent, once with `/struq`. Look at the left column first.
51
51
 
@@ -53,7 +53,7 @@ Three briefs, each built twice by the same model with the same materials: once a
53
53
 
54
54
  | Plain agent | With the Struq skill |
55
55
  |---|---|
56
- | ![Tidewater, built by a plain agent](https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/docs/compare/saas-plain.webp) | ![Tidewater, built with the Struq skill](https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/docs/compare/saas-struq.webp) |
56
+ | ![Tidewater, built by a plain agent](https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/docs/compare/saas-plain.webp) | ![Tidewater, built with the Struq skill](https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/docs/compare/saas-struq.webp) |
57
57
 
58
58
  <details>
59
59
  <summary><b>Seen the left one before?</b></summary>
@@ -67,7 +67,7 @@ You have. It is the dark developer-tool template, item for item: the green glow,
67
67
 
68
68
  | Plain agent | With the Struq skill |
69
69
  |---|---|
70
- | ![Ana Ferreira, built by a plain agent](https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/docs/compare/ceramics-plain.webp) | ![Ana Ferreira, built with the Struq skill](https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/docs/compare/ceramics-struq.webp) |
70
+ | ![Ana Ferreira, built by a plain agent](https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/docs/compare/ceramics-plain.webp) | ![Ana Ferreira, built with the Struq skill](https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/docs/compare/ceramics-struq.webp) |
71
71
 
72
72
  <details>
73
73
  <summary><b>Where did "12 restaurants" come from?</b></summary>
@@ -81,7 +81,7 @@ Nowhere. The brief gave no figures, no quote and no bio, and the plain agent wro
81
81
 
82
82
  | Plain agent | With the Struq skill |
83
83
  |---|---|
84
- | ![Vela Mono, built by a plain agent](https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/docs/compare/ebike-plain.webp) | ![Vela Mono, built with the Struq skill](https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/docs/compare/ebike-struq.webp) |
84
+ | ![Vela Mono, built by a plain agent](https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/docs/compare/ebike-plain.webp) | ![Vela Mono, built with the Struq skill](https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/docs/compare/ebike-struq.webp) |
85
85
 
86
86
  <details>
87
87
  <summary><b>Both built a pinned 3D bike. So what is the difference?</b></summary>
@@ -95,13 +95,13 @@ Where the Struq skill still loses: craft. The plain builds aim lower and break l
95
95
 
96
96
  <br>
97
97
 
98
- ## <picture><source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/h/layers-dark.svg"><img src="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/h/layers-light.svg" width="26" height="26" alt=""></picture> The blocks carry the motion
98
+ ## <picture><source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/h/layers-dark.svg"><img src="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/h/layers-light.svg" width="26" height="26" alt=""></picture> The blocks carry the motion
99
99
 
100
100
  Your agent picks the direction, sets the order and writes the copy. The motion comes from Struq's own catalogue: 44 admitted blocks, each a real React file with three variants, working in light and dark.
101
101
 
102
102
  <picture>
103
- <source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/blocks-dark.webp">
104
- <img alt="Six Struq blocks playing at once: a preloader, a hero, a page transition, a manifesto scrub, an image growing to full bleed and a WebGL background." src="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/blocks-light.webp" width="100%">
103
+ <source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/blocks-dark.webp">
104
+ <img alt="Six Struq blocks playing at once: a preloader, a hero, a page transition, a manifesto scrub, an image growing to full bleed and a WebGL background." src="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/blocks-light.webp" width="100%">
105
105
  </picture>
106
106
 
107
107
  <details>
@@ -126,11 +126,11 @@ Each one with its files, packages, props and variants: [the catalogue](skills/st
126
126
 
127
127
  <br>
128
128
 
129
- ## <picture><source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/h/brush-dark.svg"><img src="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/h/brush-light.svg" width="26" height="26" alt=""></picture> What is in the skill
129
+ ## <picture><source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/h/brush-dark.svg"><img src="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/h/brush-light.svg" width="26" height="26" alt=""></picture> What is in the skill
130
130
 
131
131
  <picture>
132
- <source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/kit-dark.svg">
133
- <img alt="Six parts: blocks, direction, photos, craft, a check, and the Struq MCP." src="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/kit-light.svg" width="100%">
132
+ <source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/kit-dark.svg">
133
+ <img alt="Six parts: blocks, direction, photos, craft, a check, and the Struq MCP." src="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/kit-light.svg" width="100%">
134
134
  </picture>
135
135
 
136
136
  <details>
@@ -152,7 +152,7 @@ Stack: Next.js App Router and Tailwind v4 (Vite by hand, see `references/stack-t
152
152
 
153
153
  <br>
154
154
 
155
- ## <picture><source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/h/compass-dark.svg"><img src="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/h/compass-light.svg" width="26" height="26" alt=""></picture> How a build runs
155
+ ## <picture><source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/h/compass-dark.svg"><img src="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/h/compass-light.svg" width="26" height="26" alt=""></picture> How a build runs
156
156
 
157
157
  - **Direction.** The site is planned as one film: a motion language (weight, cut or curtain), one idea, one motif, and a storyboard of scenes with the handovers between them. Then a palette, a type pairing and a photo pack from Struq's canon. All of it in one `STRUQ.md`, and no block goes in until that plan passes its own check.
158
158
  - **One stop.** It shows you that plan and asks once: approve it, and send a logo, photos or real numbers if you have them. It does not ask again.
@@ -170,7 +170,7 @@ In Codex the command is `$struq`; as a Claude Code plugin it is `/struq:struq`.
170
170
 
171
171
  <br>
172
172
 
173
- ## <picture><source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/h/download-dark.svg"><img src="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/h/download-light.svg" width="26" height="26" alt=""></picture> Install
173
+ ## <picture><source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/h/download-dark.svg"><img src="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/h/download-light.svg" width="26" height="26" alt=""></picture> Install
174
174
 
175
175
  ```bash
176
176
  npx @struq/skill@latest
@@ -188,13 +188,13 @@ npx @struq/skill@latest --claude
188
188
  npx @struq/skill@latest --project
189
189
 
190
190
  # with the open skills CLI, which knows 70+ agents
191
- npx skills add struqnl/struq-design -g
191
+ npx skills add struqapp/struq-design -g
192
192
  ```
193
193
 
194
194
  As a Claude Code plugin (the command is then `/struq:struq`):
195
195
 
196
196
  ```text
197
- /plugin marketplace add struqnl/struq-design
197
+ /plugin marketplace add struqapp/struq-design
198
198
  /plugin install struq@struq
199
199
  ```
200
200
 
@@ -204,11 +204,11 @@ The screenshot step uses whatever browser tool your agent has (Playwright or sim
204
204
 
205
205
  <br>
206
206
 
207
- ## <picture><source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/h/palette-dark.svg"><img src="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/h/palette-light.svg" width="26" height="26" alt=""></picture> The icons it reaches for
207
+ ## <picture><source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/h/palette-dark.svg"><img src="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/h/palette-light.svg" width="26" height="26" alt=""></picture> The icons it reaches for
208
208
 
209
- Struq builds use [Struq Icons](https://struq.nl/iconen) by default: 182 icons in two tones, and each of the 118 full icons makes one small move under the cursor that says what it does. Every icon on this page is one of them.
209
+ Struq builds use [Struq Icons](https://struq.app/icons) by default: 182 icons in two tones, and each of the 118 full icons makes one small move under the cursor that says what it does. Every icon on this page is one of them.
210
210
 
211
- <a href="https://struq.nl/iconen">
211
+ <a href="https://struq.app/icons">
212
212
  <picture>
213
213
  <source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/npm/@struq/icons@1/readme/motion-dark.svg">
214
214
  <img alt="Eight Struq icons playing their hover motion: lock, delete, settings, mail, history, sound, hint, bug." src="https://cdn.jsdelivr.net/npm/@struq/icons@1/readme/motion-light.svg" width="100%">
@@ -221,18 +221,18 @@ npm install @struq/icons
221
221
 
222
222
  <br>
223
223
 
224
- ## <picture><source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/h/shield-dark.svg"><img src="https://cdn.jsdelivr.net/gh/struqnl/struq-design@main/assets/readme/h/shield-light.svg" width="26" height="26" alt=""></picture> What it fetches and sends
224
+ ## <picture><source media="(prefers-color-scheme: dark)" srcset="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/h/shield-dark.svg"><img src="https://cdn.jsdelivr.net/gh/struqapp/struq-design@main/assets/readme/h/shield-light.svg" width="26" height="26" alt=""></picture> What it fetches and sends
225
225
 
226
226
  The Struq skill is instructions, the block files and five small Node scripts (`blocks.mjs`, `canon.mjs`, `images.mjs`, `check.mjs`, `log.mjs`) that your agent runs in your project. They read and write files there, including a build log, `STRUQ-LOG.md`.
227
227
 
228
228
  - **Blocks and canon** ship inside the skill. Nothing is fetched to install a block or write the tokens.
229
229
  - **Fonts** load from Google Fonts (or Fontshare, for a face that lives there) in the visitor's browser, as on any site with web fonts.
230
- - **Photos**, for every slot your own photos do not fill: `images.mjs` reads the public pack list at `https://struq.nl/api/media/packs` and downloads the chosen images from `https://struq.nl/media/packs/`. Offline it writes plates graded to your palette instead.
231
- - **The MCP**, if you connect it: your agent calls Struq's tools with your brief. Nothing else leaves your machine.
230
+ - **Photos**, for every slot your own photos do not fill: `images.mjs` reads the public pack list at `https://struq.app/api/media/packs` and downloads the chosen images from `https://struq.app/media/packs/`, or from the same paths on the old domain when struq.app does not answer. Offline it writes plates graded to your palette instead.
231
+ - **The MCP** (`https://struq.app/api/mcp`), if you connect it: your agent calls Struq's tools with your brief. Nothing else leaves your machine.
232
232
  - **The installer** downloads the package from npm, looks for your AI tools' folders in your home folder and copies the skill into them. It sends nothing.
233
233
 
234
234
  <br>
235
235
 
236
236
  ## License
237
237
 
238
- MIT. Made by [Struq](https://struq.nl).
238
+ MIT. Made by [Struq](https://struq.app).
package/cli/struq.mjs CHANGED
@@ -34,7 +34,7 @@ Usage
34
34
  npx @struq/skill@latest remove remove the skill again (takes the same flags)
35
35
  npx @struq/skill@latest --help show this
36
36
 
37
- Run it again at any time to update. More: https://struq.nl/skill
37
+ Run it again at any time to update. More: https://struq.app/skill
38
38
  `;
39
39
 
40
40
  function parse(argv) {
@@ -119,7 +119,7 @@ function run(argv) {
119
119
  for (const tool of new Map(list.map(({ tool }) => [tool.label, tool])).values()) {
120
120
  out.push(` ${tool.label.padEnd(15)}${tool.use}`);
121
121
  }
122
- out.push('', 'Run this again at any time to update. More: https://struq.nl/skill');
122
+ out.push('', 'Run this again at any time to update. More: https://struq.app/skill');
123
123
  }
124
124
  process.stdout.write(`${out.join('\n')}\n`);
125
125
  }
package/package.json CHANGED
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "name": "@struq/skill",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Install the free Struq skill into Claude Code, Cursor, Codex and other AI coding tools: websites at the level of a design studio, built by your own AI.",
5
5
  "license": "MIT",
6
- "author": "Struq (https://struq.nl)",
7
- "homepage": "https://struq.nl/skill",
6
+ "author": "Struq (https://struq.app)",
7
+ "homepage": "https://struq.app/skill",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+https://github.com/struqnl/struq-design.git"
10
+ "url": "git+https://github.com/struqapp/struq-design.git"
11
11
  },
12
- "bugs": "https://github.com/struqnl/struq-design/issues",
12
+ "bugs": "https://github.com/struqapp/struq-design/issues",
13
13
  "keywords": [
14
14
  "agent-skill",
15
15
  "skills",
@@ -1,6 +1,6 @@
1
1
  # Block catalogue
2
2
 
3
- Generated by scripts/release-blocks.mjs from Struq origin/main at 95e54a2. Do not edit: run the release script again.
3
+ Generated by scripts/release-blocks.mjs from Struq origin/main at a538491. Do not edit: run the release script again.
4
4
  44 admitted blocks. Install one with `node <skill-dir>/scripts/blocks.mjs add <slug>`; read its file for the props.
5
5
  Every block works light and dark; a variant is a different composition of the same idea, passed as `variant`.
6
6
 
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "Struq blocks/",
4
4
  "ref": "origin/main",
5
- "commit": "95e54a2"
5
+ "commit": "a538491"
6
6
  },
7
7
  "blocks": [
8
8
  {
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "Struq blocks/film/arcs.ts",
4
4
  "ref": "origin/main",
5
- "commit": "95e54a2"
5
+ "commit": "a538491"
6
6
  },
7
7
  "ARC_RULES": [
8
8
  "A loud scene moves with the scroll (a pin, a scrub, a reveal to full bleed); a quiet scene is type and a photo that enter once and sit still. Tag a scene (loud) when the block's energy is 2 or more, (quiet) at 1 or less or for an own scene.",
@@ -2,7 +2,7 @@
2
2
  "source": {
3
3
  "repo": "Struq lib/canon",
4
4
  "ref": "origin/main",
5
- "commit": "95e54a2"
5
+ "commit": "a538491"
6
6
  },
7
7
  "palettes": [
8
8
  {
@@ -1,7 +1,7 @@
1
1
  # Icons
2
2
 
3
3
  Struq Icons is the default: `npm i @struq/icons` (MIT), the Iconify set `struq` for non-React stacks, browsable at
4
- [struq.nl/iconen](https://struq.nl/iconen). A two-tone set on one 24 grid, the base tone for the shape and the accent
4
+ [struq.app/icons](https://struq.app/icons). A two-tone set on one 24 grid, the base tone for the shape and the accent
5
5
  on the part that carries the meaning, each icon with its own hover motion.
6
6
 
7
7
  ## Whether an icon earns its place
@@ -159,7 +159,7 @@ const plan = read("STRUQ.md");
159
159
  const pack = mod.match(/imagePack = "([^"]+)"/)?.[1] ?? "?";
160
160
  if (imageFit === "none" && !srcs.length) add(G, "PASS", `No photo of pack "${pack}" fits this brief (imageFit none): the opener carries the first screen, and the approval stop asks for photos`);
161
161
  else if (missing.length) add(G, "FAIL", `${missing.length} image(s) in ${libFile} are not on disk. Run images.mjs ${pack} again.`, missing);
162
- else if (plates.length === srcs.length) add(G, "WARN", `struq.nl was unreachable: all ${srcs.length} images are fallback plates. Run images.mjs ${pack} again when online.`);
162
+ else if (plates.length === srcs.length) add(G, "WARN", `Struq was unreachable: all ${srcs.length} images are fallback plates. Run images.mjs ${pack} again when online.`);
163
163
  else if (plates.length) add(G, "WARN", `${plates.length} fallback plate(s) among the pack images. Run images.mjs ${pack} again when online.`, plates);
164
164
  else add(G, "PASS", `${srcs.length} beeldbank images from pack "${pack}", each a verified image file`);
165
165
  // An off-topic photo is worse than no photo: a pack the brief does not point at must show
@@ -234,13 +234,14 @@ const plan = read("STRUQ.md");
234
234
  { scope: own, exclude: /placeholder=["'{]/ },
235
235
  );
236
236
  const emptyFrames = hits(/<(div|figure|span)\b[^>]*(aspect-|aspectRatio)[^>]*(\/>|>\s*<\/(div|figure|span)>)/, { scope: own });
237
- const hotlinks = hits(/https?:\/\/(www\.)?struq\.nl\/media\//, { scope: own });
237
+ // Both domains: struq.app is home, and struq.nl still serves the media for older installs.
238
+ const hotlinks = hits(/https?:\/\/(www\.)?struq\.(app|nl)\/media\//, { scope: own });
238
239
  if (placeholders.length || emptyFrames.length) {
239
240
  add(G, "FAIL", "Placeholder or empty frames. A Struq site never ships an empty frame: put a MediaFrame with a pack image in each.", [...placeholders, ...emptyFrames]);
240
241
  } else add(G, "PASS", "No placeholder or empty image frames");
241
- if (hotlinks.length) add(G, "FAIL", "Hotlinked struq.nl images: download them with images.mjs and use the local /images/ path.", hotlinks);
242
+ if (hotlinks.length) add(G, "FAIL", "Hotlinked Struq images: download them with images.mjs and use the local /images/ path.", hotlinks);
242
243
 
243
- const credit = uses(/imageCredit|struq\.nl\/beeldbank/);
244
+ const credit = uses(/imageCredit|struq\.app\/photos|struq\.nl\/beeldbank/);
244
245
  if (libFile && !credit) add(G, "WARN", "No image credit. Put imageCredit (\"Beeld: Struq\") in the footer as one quiet text-small link.");
245
246
  }
246
247
 
@@ -11,11 +11,12 @@
11
11
  // given, and photos whose alt names a subject the client does not do are left out, best fit first.
12
12
  //
13
13
  // What it does, in order:
14
- // 1. Reads the pack from https://struq.nl/api/media/packs/<slug> (public, no key, CC0 images).
14
+ // 1. Reads the pack from https://struq.app/api/media/packs/<slug> (public, no key, CC0 images),
15
+ // or from https://struq.nl when struq.app does not answer.
15
16
  // 2. Downloads all 24 images to public/images/<slot>-<n>.webp and checks each one is a real image.
16
17
  // 3. Writes lib/struq-images.ts (src/lib/ when the project uses src/): every image with its alt,
17
18
  // size and description, grouped by slot, plus the credit line. Components import from it.
18
- // 4. If struq.nl cannot be reached, or a file fails its check, it writes a graded fallback plate
19
+ // 4. If Struq cannot be reached, or a file fails its check, it writes a graded fallback plate
19
20
  // in the site palette for that slot instead, so no frame is ever empty, and says so.
20
21
  //
21
22
  // Node 18 or later, no dependencies. Safe to run again: a file is kept only when the last run
@@ -26,12 +27,21 @@ import { join, resolve } from "node:path";
26
27
  import { appendLog } from "./log.mjs";
27
28
  import { fitImages, packForBrief, packReason } from "./packs.mjs";
28
29
 
29
- // STRUQ_API_BASE points a test run at another Struq environment (for example https://staging.struq.nl).
30
- // Real users never set it: the default is production.
31
- const BASE = (process.env.STRUQ_API_BASE || "https://struq.nl").replace(/\/+$/, "");
32
- const API = `${BASE}/api/media/packs`;
33
- /** The download URL, on the same base as the API (directPath), falling back to the absolute publicUrl. */
34
- const imageUrl = (img) => (img.directPath ? `${BASE}${img.directPath}` : img.publicUrl);
30
+ // Struq lives at struq.app. The old domain struq.nl keeps answering the pack API and the media for
31
+ // installed skills, so a run tries struq.app first and falls back to struq.nl when struq.app gives a
32
+ // network error, a non-2xx answer or something that is not the pack.
33
+ // STRUQ_API_BASE points a test run at another Struq environment (for example https://staging.struq.app),
34
+ // with no fallback. Real users never set it: the default is production.
35
+ const DEFAULT_BASES = ["https://struq.app", "https://struq.nl"];
36
+ const BASES = process.env.STRUQ_API_BASE ? [process.env.STRUQ_API_BASE.replace(/\/+$/, "")] : DEFAULT_BASES;
37
+ const PACKS = "/api/media/packs";
38
+ const HOSTS = BASES.map((b) => b.replace(/^https?:\/\//, "")).join(" and ");
39
+ /** The base that last answered the pack API; image downloads try it first. */
40
+ let activeBase = BASES[0];
41
+ /** The bases in the order to try: the one that answered first, then the rest. */
42
+ const basesInOrder = () => [activeBase, ...BASES.filter((b) => b !== activeBase)];
43
+ /** Download URLs on the same bases as the API (directPath), or the absolute publicUrl. */
44
+ const imageUrls = (img) => (img.directPath ? basesInOrder().map((b) => `${b}${img.directPath}`) : [img.publicUrl]);
35
45
  const SLOTS = ["hero", "band", "card", "column", "detail", "backdrop"];
36
46
  const FALLBACK_COUNTS = { hero: 3, band: 2, card: 8, column: 4, detail: 4, backdrop: 3 };
37
47
  const FALLBACK_SIZE = {
@@ -53,10 +63,22 @@ const brief = flag("--brief");
53
63
  const slug = args.find((a, i) => !a.startsWith("--") && args[i - 1] !== "--dir" && args[i - 1] !== "--brief") ?? (brief ? packForBrief(brief) : undefined);
54
64
  const why = brief && !args.find((a, i) => !a.startsWith("--") && args[i - 1] !== "--dir" && args[i - 1] !== "--brief") ? packReason(brief) : "named on the command line";
55
65
 
56
- async function getJson(url) {
57
- const res = await fetch(url, { signal: AbortSignal.timeout(20000), headers: { accept: "application/json" } });
58
- if (!res.ok) throw new Error(`${url} answered ${res.status}`);
59
- return res.json();
66
+ /** GET a path of the pack API from each base in turn; the first JSON answer wins and sets activeBase. */
67
+ async function getJson(path) {
68
+ const errors = [];
69
+ for (const base of basesInOrder()) {
70
+ const url = `${base}${path}`;
71
+ try {
72
+ const res = await fetch(url, { signal: AbortSignal.timeout(20000), headers: { accept: "application/json" } });
73
+ if (!res.ok) throw new Error(`${url} answered ${res.status}`);
74
+ const json = await res.json();
75
+ activeBase = base;
76
+ return json;
77
+ } catch (error) {
78
+ errors.push(error.message.startsWith(url) ? error.message : `${url}: ${error.message}`);
79
+ }
80
+ }
81
+ throw new Error(errors.join("; "));
60
82
  }
61
83
 
62
84
  function isImage(buf) {
@@ -77,20 +99,23 @@ function fileIsImage(path) {
77
99
  }
78
100
  }
79
101
 
80
- async function download(url, path) {
81
- for (let attempt = 0; attempt < 2; attempt++) {
82
- try {
83
- const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
84
- if (!res.ok) continue;
85
- const buf = Buffer.from(await res.arrayBuffer());
86
- if (!isImage(buf)) continue;
87
- writeFileSync(path, buf);
88
- return true;
89
- } catch {
90
- // retry once, then fall back
102
+ /** Tries each URL twice in turn; returns the URL that gave a real image, or null. */
103
+ async function download(urls, path) {
104
+ for (const url of urls) {
105
+ for (let attempt = 0; attempt < 2; attempt++) {
106
+ try {
107
+ const res = await fetch(url, { signal: AbortSignal.timeout(30000) });
108
+ if (!res.ok) continue;
109
+ const buf = Buffer.from(await res.arrayBuffer());
110
+ if (!isImage(buf)) continue;
111
+ writeFileSync(path, buf);
112
+ return url;
113
+ } catch {
114
+ // retry once, then the next base, then fall back
115
+ }
91
116
  }
92
117
  }
93
- return false;
118
+ return null;
94
119
  }
95
120
 
96
121
  function readPalette() {
@@ -161,7 +186,7 @@ function writeModule(entries, packSlug, fallback, imageFit = "full") {
161
186
  return ` ${s}: [\n${rows}\n ],`;
162
187
  }).join("\n");
163
188
  const source = `// Written by the Struq skill (scripts/images.mjs). Do not hand-edit the list: run the script again
164
- // to change pack. Pack: ${packSlug}${fallback ? " (FALLBACK PLATES: struq.nl was unreachable, run the script again when online)" : ""}.
189
+ // to change pack. Pack: ${packSlug}${fallback ? " (FALLBACK PLATES: Struq was unreachable, run the script again when online)" : ""}.
165
190
  // Beeldbank images are CC0. Translate the alt texts when the site is not in Dutch.
166
191
 
167
192
  export type StruqImage = {
@@ -192,7 +217,7 @@ export const imagePack = ${JSON.stringify(packSlug)};
192
217
  export const imageFit: "full" | "partial" | "none" = ${JSON.stringify(imageFit)};
193
218
 
194
219
  /** The credit line for the footer: one quiet text-small link, never a badge. */
195
- export const imageCredit = { label: "Beeld: Struq", href: "https://struq.nl/beeldbank" };
220
+ export const imageCredit = { label: "Beeld: Struq", href: "https://struq.app/photos" };
196
221
 
197
222
  export const images: Record<ImageSlot, StruqImage[]> = {
198
223
  ${body}
@@ -226,7 +251,7 @@ function removeStale(outDir, manifest) {
226
251
  }
227
252
 
228
253
  async function list() {
229
- const index = await getJson(API);
254
+ const index = await getJson(PACKS);
230
255
  console.log(`${index.packCount} packs, ${index.totalImages} images, ${index.license}\n`);
231
256
  for (const p of index.packs) console.log(`${p.slug.padEnd(10)} ${p.description}`);
232
257
  }
@@ -250,11 +275,11 @@ async function main() {
250
275
  let droppedImages = [];
251
276
  let imageFit = "full";
252
277
  try {
253
- pack = await getJson(`${API}/${encodeURIComponent(slug)}`);
278
+ pack = await getJson(`${PACKS}/${encodeURIComponent(slug)}`);
254
279
  if (!Array.isArray(pack?.images) || pack.images.length === 0) throw new Error("pack has no images");
255
280
  } catch (error) {
256
281
  try {
257
- const index = await getJson(API);
282
+ const index = await getJson(PACKS);
258
283
  const known = index.packs.map((p) => p.slug);
259
284
  if (!known.includes(slug)) {
260
285
  console.error(`Unknown pack "${slug}". Packs: ${known.join(", ")}`);
@@ -263,7 +288,7 @@ async function main() {
263
288
  } catch {
264
289
  // the API itself is down: fall through to the plates
265
290
  }
266
- console.warn(`WARN struq.nl unreachable (${error.message}). Writing graded fallback plates instead.`);
291
+ console.warn(`WARN ${HOSTS} unreachable (${error.message}). Writing graded fallback plates instead.`);
267
292
  pack = null;
268
293
  }
269
294
 
@@ -293,10 +318,12 @@ async function main() {
293
318
  while (cursor < jobs.length) {
294
319
  const job = jobs[cursor++];
295
320
  const target = join(outDir, `${job.name}.webp`);
296
- const source = imageUrl(job.img);
297
- const kept = previous.get(`public/images/${job.name}.webp`) === source && fileIsImage(target);
298
- const ok = kept || (await download(source, target));
299
- if (ok) {
321
+ const urls = imageUrls(job.img);
322
+ const last = previous.get(`public/images/${job.name}.webp`);
323
+ // The same photo from either base counts as the same source.
324
+ const kept = urls.includes(last) && fileIsImage(target);
325
+ const source = kept ? last : await download(urls, target);
326
+ if (source) {
300
327
  real++;
301
328
  entries.push({
302
329
  slot: job.img.slot,
@@ -345,7 +372,7 @@ async function main() {
345
372
  width: size[0],
346
373
  height: size[1],
347
374
  ratio: `${size[0]}:${size[1]}`,
348
- description: "Fallback plate in the site palette (struq.nl was unreachable).",
375
+ description: "Fallback plate in the site palette (Struq was unreachable).",
349
376
  unoptimized: true,
350
377
  });
351
378
  manifest.push({ file: `public/images/${slot}-${n}.svg`, id: null, slot, source: "fallback" });
@@ -372,13 +399,13 @@ async function main() {
372
399
  console.log(`${`images.${e.slot}[${e.order - 1}]`.padEnd(18)} ${e.ratio.padEnd(5)} ${e.description.slice(0, 110)}`);
373
400
  }
374
401
  if (plates > 0) {
375
- console.log(`\nWARN ${plates} slot(s) use fallback plates. Run this script again when struq.nl answers.`);
402
+ console.log(`\nWARN ${plates} slot(s) use fallback plates. Run this script again when Struq answers.`);
376
403
  }
377
404
  appendLog(root, "Step 4 Images", [
378
405
  `images.mjs: pack ${slug} (${why}), ${real} images downloaded and verified, ${plates} fallback plates.`,
379
406
  ...(droppedImages.length ? [`Left out ${droppedImages.length} photos that do not fit this client: ${[...new Set(droppedImages.map((d) => d.reason))].join("; ")}.`] : []),
380
407
  ...(imageFit !== "full" ? [`Image fit: ${imageFit}${imageFit === "none" ? ", the opener is typographic or WebGL, and the approval stop asks for photos" : ""}.`] : []),
381
- `Files in public/images, list in ${module.replace(root, ".")}${BASE !== "https://struq.nl" ? `, from ${BASE}` : ""}.`,
408
+ `Files in public/images, list in ${module.replace(root, ".")}${activeBase !== DEFAULT_BASES[0] ? `, from ${activeBase}` : ""}.`,
382
409
  ]);
383
410
  console.log(imageFit === "none" ? "\nOK no fitting photos: build the opener without them" : real > 0 ? "\nOK images ready" : "\nOK fallback plates ready (no frame is empty)");
384
411
  }