@cloudhal/cli 0.3.7 → 0.3.9

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.
Files changed (44) hide show
  1. package/README.md +1 -1
  2. package/dist/assets/starters/blog/README.md +3 -1
  3. package/dist/assets/starters/blog/package.json +4 -4
  4. package/dist/assets/starters/blog/public/_headers +5 -0
  5. package/dist/assets/starters/blog/src/fonts.ts +22 -6
  6. package/dist/assets/starters/blog/src/pages/[...path].astro +7 -0
  7. package/dist/assets/starters/blog/src/styles/features/tokens.css +24 -12
  8. package/dist/assets/starters/blog/src/styles/font-fallbacks.css +53 -0
  9. package/dist/assets/starters/blog/src/styles/fonts.css +12 -12
  10. package/dist/assets/starters/blog/src/styles/global.css +1 -0
  11. package/dist/src/cli/commands-local.js +4 -0
  12. package/dist/src/cli/commands-remote.js +10 -6
  13. package/dist/src/cli/help.d.ts +1 -1
  14. package/dist/src/cli/help.js +1 -1
  15. package/dist/src/cli.d.ts +4 -0
  16. package/dist/src/cli.js +1 -0
  17. package/dist/src/cloudflare/config.d.ts +34 -0
  18. package/dist/src/cloudflare/config.js +7 -1
  19. package/dist/src/commands/deploy/prepare.js +1 -0
  20. package/dist/src/commands/domain.d.ts +6 -2
  21. package/dist/src/commands/domain.js +51 -10
  22. package/dist/src/commands/snapshot.d.ts +10 -2
  23. package/dist/src/commands/snapshot.js +26 -3
  24. package/dist/src/config.d.ts +3 -1
  25. package/dist/src/config.js +24 -11
  26. package/package.json +11 -11
  27. /package/dist/assets/starters/blog/public/fonts/{fraunces-OFL.txt → v1/fraunces-OFL.txt} +0 -0
  28. /package/dist/assets/starters/blog/public/fonts/{fraunces-latin-ext-wght-normal.woff2 → v1/fraunces-latin-ext-wght-normal.woff2} +0 -0
  29. /package/dist/assets/starters/blog/public/fonts/{fraunces-latin-wght-normal.woff2 → v1/fraunces-latin-wght-normal.woff2} +0 -0
  30. /package/dist/assets/starters/blog/public/fonts/{inter-OFL.txt → v1/inter-OFL.txt} +0 -0
  31. /package/dist/assets/starters/blog/public/fonts/{inter-latin-ext-wght-normal.woff2 → v1/inter-latin-ext-wght-normal.woff2} +0 -0
  32. /package/dist/assets/starters/blog/public/fonts/{inter-latin-wght-normal.woff2 → v1/inter-latin-wght-normal.woff2} +0 -0
  33. /package/dist/assets/starters/blog/public/fonts/{jetbrains-mono-OFL.txt → v1/jetbrains-mono-OFL.txt} +0 -0
  34. /package/dist/assets/starters/blog/public/fonts/{jetbrains-mono-latin-ext-wght-normal.woff2 → v1/jetbrains-mono-latin-ext-wght-normal.woff2} +0 -0
  35. /package/dist/assets/starters/blog/public/fonts/{jetbrains-mono-latin-wght-normal.woff2 → v1/jetbrains-mono-latin-wght-normal.woff2} +0 -0
  36. /package/dist/assets/starters/blog/public/fonts/{source-sans-3-OFL.txt → v1/source-sans-3-OFL.txt} +0 -0
  37. /package/dist/assets/starters/blog/public/fonts/{source-sans-3-latin-ext-wght-normal.woff2 → v1/source-sans-3-latin-ext-wght-normal.woff2} +0 -0
  38. /package/dist/assets/starters/blog/public/fonts/{source-sans-3-latin-wght-normal.woff2 → v1/source-sans-3-latin-wght-normal.woff2} +0 -0
  39. /package/dist/assets/starters/blog/public/fonts/{source-serif-4-OFL.txt → v1/source-serif-4-OFL.txt} +0 -0
  40. /package/dist/assets/starters/blog/public/fonts/{source-serif-4-latin-ext-wght-normal.woff2 → v1/source-serif-4-latin-ext-wght-normal.woff2} +0 -0
  41. /package/dist/assets/starters/blog/public/fonts/{source-serif-4-latin-wght-normal.woff2 → v1/source-serif-4-latin-wght-normal.woff2} +0 -0
  42. /package/dist/assets/starters/blog/public/fonts/{space-grotesk-OFL.txt → v1/space-grotesk-OFL.txt} +0 -0
  43. /package/dist/assets/starters/blog/public/fonts/{space-grotesk-latin-ext-wght-normal.woff2 → v1/space-grotesk-latin-ext-wght-normal.woff2} +0 -0
  44. /package/dist/assets/starters/blog/public/fonts/{space-grotesk-latin-wght-normal.woff2 → v1/space-grotesk-latin-wght-normal.woff2} +0 -0
package/README.md CHANGED
@@ -163,7 +163,7 @@ Locally, `cloudhal studio` reads the project's SQLite (`analytics_events`), roll
163
163
 
164
164
  **From Git (Workers Builds).** With `WORKERS_CI=1`, which Cloudflare sets in [Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/), `cloudhal deploy` works from a fresh clone. It takes the names from the committed `wrangler.jsonc`, seeds the starter site when the checkout has none, keeps the secrets on the Workers and does not print the setup link. This is what the Deploy to Cloudflare button runs: see [docs/guides/deploy-button.md](../../docs/guides/deploy-button.md).
165
165
 
166
- **Custom domain.** `cloudhal domain set news.example.com --studio studio.example.com` records the hostnames in `cloudhal.json`; the next `cloudhal deploy` routes them to the Workers as [Cloudflare Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) (the zone must be in the same Cloudflare account — Cloudflare creates the DNS record and certificate) and makes the site's one its primary hostname, so preview links use it (canonical URLs in the starter are not rendered yet). The workers.dev addresses keep working. `cloudhal domain remove` and a deploy undo it.
166
+ **Custom domain.** `cloudhal domain set news.example.com --studio studio.example.com` records the hostnames in `cloudhal.json`; the next `cloudhal deploy` routes them to the Workers as [Cloudflare Custom Domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) (the zone must be in the same Cloudflare account — Cloudflare creates the DNS record and certificate) and makes the site's one its primary hostname on the deployment. `domain set` makes it primary in the local store right away, so `cloudhal dev`, `render` and `build` write it into canonical links, the sitemap and feeds before any deploy. The workers.dev addresses keep working. `cloudhal domain remove` and a deploy undo it. `--redirect www.news.example.com` (repeatable) adds hosts that answer with a 301 to the site's domain, path and query kept: the next deploy routes them to the site Worker as Custom Domains too, so Cloudflare creates their DNS records and certificates. `cloudhal domain remove redirects` drops them.
167
167
 
168
168
  **Email (RFC-0011).** Add `"mail": { "from": "studio@example.com", "fromName": "Example Studio" }` to `cloudhal.json` and the next `cloudhal deploy` gives the admin Worker a [Cloudflare Email Service](https://developers.cloudflare.com/email-service/) `send_email` binding locked to that address. Invites can then be emailed, and staff can reset a forgotten password from the sign-in screen. Onboard the address's domain in Email Service first. Sending to any recipient needs Workers Paid (3,000 emails a month included); on Free, Email Service delivers only to verified destination addresses of your account. Locally, `cloudhal studio` prints these emails in the terminal instead of sending them.
169
169
 
@@ -22,7 +22,9 @@ Everything but `.gitignore`'s list. `cloudhal.json` holds the project's settings
22
22
  - `search.json.ts` — the search modal's answers (RFC-0029), where the deployment answers searches (`cloudhal dev`, the Cloudflare Site Worker). `components/SearchDialog.astro` is the modal's markup and words; Cloudhal's `/_cloudhal/search.1.js` wires it, and without it the header form opens `/search`.
23
23
 
24
24
  ## Styles (`src/styles`)
25
- `global.css` (imported by `layouts/Base.astro`) loads Tailwind, `fonts.css` and one file per feature from `styles/features/` (`tokens.css` with `@theme`, `prose.css`, `surfaces.css`, `motion.css`, `chrome.css`, `carousel.css` …) in cascade order. The rules are unlayered and later files override earlier ones, so keep the order; add a feature as a new file and import it from `global.css`. `source("..")` on the Tailwind import is relative to `global.css`, so that import stays there.
25
+ `global.css` (imported by `layouts/Base.astro`) loads Tailwind, `fonts.css`, `font-fallbacks.css` and one file per feature from `styles/features/` (`tokens.css` with `@theme`, `prose.css`, `surfaces.css`, `motion.css`, `chrome.css`, `carousel.css` …) in cascade order. The rules are unlayered and later files override earlier ones, so keep the order; add a feature as a new file and import it from `global.css`. `source("..")` on the Tailwind import is relative to `global.css`, so that import stays there.
26
+
27
+ Fonts are self-hosted under `public/fonts/v1/` and cached for a year (`public/_headers`): files never change under a path, so a new font set goes in `public/fonts/v2/`. Each web font in the pairing stacks (`tokens.css`) is followed by a metric-matched fallback from `font-fallbacks.css` (a system font scaled to the web font's width, ascent and descent), so the swap when the web font arrives does not move the text. That file is generated: after changing a font, run `pnpm fonts:fallbacks` in the monorepo.
26
28
 
27
29
  ## Theme settings (`src/theme.ts`)
28
30
  - `ARCHIVE_PAGE_SIZE` — posts per archive page (20).
@@ -14,18 +14,18 @@
14
14
  "astro": "astro"
15
15
  },
16
16
  "dependencies": {
17
- "@cloudhal/astro-renderer": "0.3.7",
18
- "@cloudhal/core-ts": "0.3.7",
17
+ "@cloudhal/astro-renderer": "0.3.9",
18
+ "@cloudhal/core-ts": "0.3.9",
19
19
  "@tailwindcss/vite": "^4.3.3",
20
20
  "astro": "7.3.6",
21
21
  "better-sqlite3": "13.0.3",
22
22
  "tailwindcss": "^4.3.3",
23
23
  "@astrojs/cloudflare": "14.3.4",
24
- "@cloudhal/cli": "0.3.7"
24
+ "@cloudhal/cli": "0.3.9"
25
25
  },
26
26
  "devDependencies": {
27
27
  "wrangler": "^4.148.0",
28
- "@cloudhal/cloudflare": "0.3.7"
28
+ "@cloudhal/cloudflare": "0.3.9"
29
29
  },
30
30
  "trustedDependencies": [
31
31
  "better-sqlite3",
@@ -0,0 +1,5 @@
1
+ # Font files never change under a path: a new font set gets a new directory (/fonts/v2/…).
2
+ # Cached for a year, the browser uses them on every reload without asking the server, so the
3
+ # text is drawn in its font from the first paint instead of swapping in later.
4
+ /fonts/v1/*
5
+ Cache-Control: public, max-age=31536000, immutable
@@ -4,12 +4,28 @@
4
4
  * (Turkish and other accented letters) load on demand through fonts.css's unicode ranges.
5
5
  */
6
6
  export const PAIRING_PRELOADS: Readonly<Record<string, readonly string[]>> = {
7
- editorial: ["/fonts/fraunces-latin-wght-normal.woff2", "/fonts/inter-latin-wght-normal.woff2"],
8
- modern: ["/fonts/space-grotesk-latin-wght-normal.woff2", "/fonts/inter-latin-wght-normal.woff2"],
9
- humanist: ["/fonts/source-sans-3-latin-wght-normal.woff2"],
7
+ editorial: [
8
+ "/fonts/v1/fraunces-latin-wght-normal.woff2",
9
+ "/fonts/v1/inter-latin-wght-normal.woff2",
10
+ ],
11
+ modern: [
12
+ "/fonts/v1/space-grotesk-latin-wght-normal.woff2",
13
+ "/fonts/v1/inter-latin-wght-normal.woff2",
14
+ ],
15
+ humanist: ["/fonts/v1/source-sans-3-latin-wght-normal.woff2"],
10
16
  technical: [
11
- "/fonts/jetbrains-mono-latin-wght-normal.woff2",
12
- "/fonts/inter-latin-wght-normal.woff2",
17
+ "/fonts/v1/jetbrains-mono-latin-wght-normal.woff2",
18
+ "/fonts/v1/inter-latin-wght-normal.woff2",
13
19
  ],
14
- classic: ["/fonts/source-serif-4-latin-wght-normal.woff2"],
20
+ classic: ["/fonts/v1/source-serif-4-latin-wght-normal.woff2"],
15
21
  };
22
+
23
+ /**
24
+ * The same preloads as a `Link` response header. On a Cloudflare zone with Early Hints on, the
25
+ * edge sends it as a 103 before the page, so the fonts load while the Worker is still rendering.
26
+ */
27
+ export function fontLinkHeader(hrefs: readonly string[]): string {
28
+ return hrefs
29
+ .map((h) => `<${h}>; rel=preload; as=font; type="font/woff2"; crossorigin`)
30
+ .join(", ");
31
+ }
@@ -51,6 +51,7 @@ import {
51
51
  relatedEntries,
52
52
  termAncestors,
53
53
  } from "@cloudhal/astro-renderer/taxonomy";
54
+ import { themeVariables } from "@cloudhal/astro-renderer/theme-settings";
54
55
  import type { ContentEntry } from "@cloudhal/core-contracts";
55
56
  /**
56
57
  * One route for the whole graph (RFC-0002 §7 order): a Page answers at its `path`; an article at
@@ -73,8 +74,10 @@ import NotFound from "../components/NotFound.astro";
73
74
  import PostTerms from "../components/PostTerms.astro";
74
75
  import RelatedPosts from "../components/RelatedPosts.astro";
75
76
  import Search from "../components/Search.astro";
77
+ import { fontLinkHeader, PAIRING_PRELOADS } from "../fonts.ts";
76
78
  import { iconSet } from "../icons.ts";
77
79
  import Base from "../layouts/Base.astro";
80
+ import { themeSettings } from "../settings.ts";
78
81
  import {
79
82
  ARCHIVE_PAGE_SIZE,
80
83
  blocks,
@@ -354,6 +357,10 @@ const localized = localizeHead(head, site, {
354
357
  });
355
358
  const meta = fallback || notFound ? { ...localized, robots: "noindex,follow" } : localized;
356
359
  Astro.response.headers.set("content-language", shownIn);
360
+ // The pairing's fonts as a Link header too (Early Hints), not only as <link rel=preload> in <head>.
361
+ const pairing = themeVariables(site, themeSettings).attributes["data-ch-type-pairing"];
362
+ const preloads = PAIRING_PRELOADS[pairing ?? ""] ?? [];
363
+ if (preloads.length) Astro.response.headers.set("link", fontLinkHeader(preloads));
357
364
  // The reader's notice (RFC-0033 §4): machine output until a person reviews it (or always, when
358
365
  // the site asks), a page shown in its original language, each with a link to the original.
359
366
  const original = alternates.find((a) => a.original && !a.current);
@@ -55,29 +55,41 @@
55
55
  font-family: ui-monospace, "Cascadia Code", "Source Code Pro", Menlo, Consolas, monospace;
56
56
  }
57
57
  /*
58
- * Font pairings (RFC-0023 §2): the families come from fonts.css and load only when used. They win
58
+ * Font pairings (RFC-0023 §2): the families come from fonts.css and load only when used; each is
59
+ * followed by its metric-matched fallback (font-fallbacks.css), so the swap does not move the
60
+ * text. They win
59
61
  * over the older typeface setting; `system` keeps it.
60
62
  */
61
63
  [data-ch-type-pairing="editorial"] {
62
- --ch-font-display: "Fraunces Variable", ui-serif, Georgia, serif;
63
- --ch-font-text: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
64
+ --ch-font-display: "Fraunces Variable", "Fraunces Variable Fallback", ui-serif, Georgia, serif;
65
+ --ch-font-text: "Inter Variable", "Inter Variable Fallback", ui-sans-serif, system-ui, sans-serif;
64
66
  }
65
67
  [data-ch-type-pairing="modern"] {
66
- --ch-font-display: "Space Grotesk Variable", ui-sans-serif, system-ui, sans-serif;
67
- --ch-font-text: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
68
+ --ch-font-display:
69
+ "Space Grotesk Variable", "Space Grotesk Variable Fallback", ui-sans-serif, system-ui,
70
+ sans-serif;
71
+ --ch-font-text: "Inter Variable", "Inter Variable Fallback", ui-sans-serif, system-ui, sans-serif;
68
72
  }
69
73
  [data-ch-type-pairing="humanist"] {
70
- --ch-font-display: "Source Sans 3 Variable", ui-sans-serif, system-ui, sans-serif;
71
- --ch-font-text: "Source Sans 3 Variable", ui-sans-serif, system-ui, sans-serif;
74
+ --ch-font-display:
75
+ "Source Sans 3 Variable", "Source Sans 3 Variable Fallback", ui-sans-serif, system-ui,
76
+ sans-serif;
77
+ --ch-font-text:
78
+ "Source Sans 3 Variable", "Source Sans 3 Variable Fallback", ui-sans-serif, system-ui,
79
+ sans-serif;
72
80
  }
73
81
  [data-ch-type-pairing="technical"] {
74
- --ch-font-display: "JetBrains Mono Variable", ui-monospace, monospace;
75
- --ch-font-text: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
76
- --ch-font-code: "JetBrains Mono Variable", ui-monospace, monospace;
82
+ --ch-font-display:
83
+ "JetBrains Mono Variable", "JetBrains Mono Variable Fallback", ui-monospace, monospace;
84
+ --ch-font-text: "Inter Variable", "Inter Variable Fallback", ui-sans-serif, system-ui, sans-serif;
85
+ --ch-font-code:
86
+ "JetBrains Mono Variable", "JetBrains Mono Variable Fallback", ui-monospace, monospace;
77
87
  }
78
88
  [data-ch-type-pairing="classic"] {
79
- --ch-font-display: "Source Serif 4 Variable", ui-serif, Georgia, serif;
80
- --ch-font-text: "Source Serif 4 Variable", ui-serif, Georgia, serif;
89
+ --ch-font-display:
90
+ "Source Serif 4 Variable", "Source Serif 4 Variable Fallback", ui-serif, Georgia, serif;
91
+ --ch-font-text:
92
+ "Source Serif 4 Variable", "Source Serif 4 Variable Fallback", ui-serif, Georgia, serif;
81
93
  }
82
94
  [data-ch-type-pairing]:not([data-ch-type-pairing="system"]) .ch-body {
83
95
  font-family: var(--ch-font-text);
@@ -0,0 +1,53 @@
1
+ /*
2
+ * Generated by scripts/font-fallbacks.ts: do not edit. Metric-matched fallback faces for the
3
+ * pairings' web fonts (tokens.css lists each right after its web font), so the swap to the web
4
+ * font does not move the text.
5
+ */
6
+ @font-face {
7
+ font-family: "Fraunces Variable Fallback";
8
+ src: local("Times New Roman"), local("TimesNewRomanPSMT");
9
+ size-adjust: 113.62%;
10
+ ascent-override: 86.08%;
11
+ descent-override: 22.44%;
12
+ line-gap-override: 0.00%;
13
+ }
14
+ @font-face {
15
+ font-family: "Inter Variable Fallback";
16
+ src: local("Arial"), local("ArialMT");
17
+ size-adjust: 107.12%;
18
+ ascent-override: 90.44%;
19
+ descent-override: 22.52%;
20
+ line-gap-override: 0.00%;
21
+ }
22
+ @font-face {
23
+ font-family: "Space Grotesk Variable Fallback";
24
+ src: local("Arial"), local("ArialMT");
25
+ size-adjust: 109.47%;
26
+ ascent-override: 89.89%;
27
+ descent-override: 26.67%;
28
+ line-gap-override: 0.00%;
29
+ }
30
+ @font-face {
31
+ font-family: "Source Sans 3 Variable Fallback";
32
+ src: local("Arial"), local("ArialMT");
33
+ size-adjust: 92.42%;
34
+ ascent-override: 110.80%;
35
+ descent-override: 43.28%;
36
+ line-gap-override: 0.00%;
37
+ }
38
+ @font-face {
39
+ font-family: "Source Serif 4 Variable Fallback";
40
+ src: local("Times New Roman"), local("TimesNewRomanPSMT");
41
+ size-adjust: 117.91%;
42
+ ascent-override: 87.87%;
43
+ descent-override: 28.41%;
44
+ line-gap-override: 0.00%;
45
+ }
46
+ @font-face {
47
+ font-family: "JetBrains Mono Variable Fallback";
48
+ src: local("Courier New"), local("CourierNewPSMT");
49
+ size-adjust: 99.98%;
50
+ ascent-override: 102.02%;
51
+ descent-override: 30.00%;
52
+ line-gap-override: 0.00%;
53
+ }
@@ -8,7 +8,7 @@
8
8
  font-style: normal;
9
9
  font-display: swap;
10
10
  font-weight: 100 900;
11
- src: url(/fonts/fraunces-latin-ext-wght-normal.woff2) format("woff2-variations");
11
+ src: url(/fonts/v1/fraunces-latin-ext-wght-normal.woff2) format("woff2-variations");
12
12
  unicode-range:
13
13
  U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
14
14
  }
@@ -17,7 +17,7 @@
17
17
  font-style: normal;
18
18
  font-display: swap;
19
19
  font-weight: 100 900;
20
- src: url(/fonts/fraunces-latin-wght-normal.woff2) format("woff2-variations");
20
+ src: url(/fonts/v1/fraunces-latin-wght-normal.woff2) format("woff2-variations");
21
21
  unicode-range:
22
22
  U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
23
23
  }
@@ -26,7 +26,7 @@
26
26
  font-style: normal;
27
27
  font-display: swap;
28
28
  font-weight: 100 900;
29
- src: url(/fonts/inter-latin-ext-wght-normal.woff2) format("woff2-variations");
29
+ src: url(/fonts/v1/inter-latin-ext-wght-normal.woff2) format("woff2-variations");
30
30
  unicode-range:
31
31
  U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
32
32
  }
@@ -35,7 +35,7 @@
35
35
  font-style: normal;
36
36
  font-display: swap;
37
37
  font-weight: 100 900;
38
- src: url(/fonts/inter-latin-wght-normal.woff2) format("woff2-variations");
38
+ src: url(/fonts/v1/inter-latin-wght-normal.woff2) format("woff2-variations");
39
39
  unicode-range:
40
40
  U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
41
41
  }
@@ -44,7 +44,7 @@
44
44
  font-style: normal;
45
45
  font-display: swap;
46
46
  font-weight: 100 800;
47
- src: url(/fonts/jetbrains-mono-latin-ext-wght-normal.woff2) format("woff2-variations");
47
+ src: url(/fonts/v1/jetbrains-mono-latin-ext-wght-normal.woff2) format("woff2-variations");
48
48
  unicode-range:
49
49
  U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
50
50
  }
@@ -53,7 +53,7 @@
53
53
  font-style: normal;
54
54
  font-display: swap;
55
55
  font-weight: 100 800;
56
- src: url(/fonts/jetbrains-mono-latin-wght-normal.woff2) format("woff2-variations");
56
+ src: url(/fonts/v1/jetbrains-mono-latin-wght-normal.woff2) format("woff2-variations");
57
57
  unicode-range:
58
58
  U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
59
59
  }
@@ -62,7 +62,7 @@
62
62
  font-style: normal;
63
63
  font-display: swap;
64
64
  font-weight: 200 900;
65
- src: url(/fonts/source-sans-3-latin-ext-wght-normal.woff2) format("woff2-variations");
65
+ src: url(/fonts/v1/source-sans-3-latin-ext-wght-normal.woff2) format("woff2-variations");
66
66
  unicode-range:
67
67
  U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
68
68
  }
@@ -71,7 +71,7 @@
71
71
  font-style: normal;
72
72
  font-display: swap;
73
73
  font-weight: 200 900;
74
- src: url(/fonts/source-sans-3-latin-wght-normal.woff2) format("woff2-variations");
74
+ src: url(/fonts/v1/source-sans-3-latin-wght-normal.woff2) format("woff2-variations");
75
75
  unicode-range:
76
76
  U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
77
77
  }
@@ -80,7 +80,7 @@
80
80
  font-style: normal;
81
81
  font-display: swap;
82
82
  font-weight: 200 900;
83
- src: url(/fonts/source-serif-4-latin-ext-wght-normal.woff2) format("woff2-variations");
83
+ src: url(/fonts/v1/source-serif-4-latin-ext-wght-normal.woff2) format("woff2-variations");
84
84
  unicode-range:
85
85
  U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF;
86
86
  }
@@ -89,7 +89,7 @@
89
89
  font-style: normal;
90
90
  font-display: swap;
91
91
  font-weight: 200 900;
92
- src: url(/fonts/source-serif-4-latin-wght-normal.woff2) format("woff2-variations");
92
+ src: url(/fonts/v1/source-serif-4-latin-wght-normal.woff2) format("woff2-variations");
93
93
  unicode-range:
94
94
  U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
95
95
  }
@@ -98,7 +98,7 @@
98
98
  font-style: normal;
99
99
  font-display: swap;
100
100
  font-weight: 300 700;
101
- src: url(/fonts/space-grotesk-latin-ext-wght-normal.woff2) format("woff2-variations");
101
+ src: url(/fonts/v1/space-grotesk-latin-ext-wght-normal.woff2) format("woff2-variations");
102
102
  unicode-range:
103
103
  U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
104
104
  }
@@ -107,7 +107,7 @@
107
107
  font-style: normal;
108
108
  font-display: swap;
109
109
  font-weight: 300 700;
110
- src: url(/fonts/space-grotesk-latin-wght-normal.woff2) format("woff2-variations");
110
+ src: url(/fonts/v1/space-grotesk-latin-wght-normal.woff2) format("woff2-variations");
111
111
  unicode-range:
112
112
  U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
113
113
  }
@@ -1,6 +1,7 @@
1
1
  /* Scan `src/` only: automatic detection may walk up the file system (docs/design/starter-references.md §6). */
2
2
  @import "tailwindcss" source("..");
3
3
  @import "./fonts.css";
4
+ @import "./font-fallbacks.css";
4
5
 
5
6
  /*
6
7
  * One file per feature (docs/engineering/code-structure.md §8), in cascade order. Unlayered on
@@ -45,6 +45,10 @@ const snapshotCommand = async (_rest, values, { projectDir }) => {
45
45
  ...(values.label ? { label: values.label } : {}),
46
46
  ...(values.publish ? { publish: true } : {}),
47
47
  });
48
+ if (snap.unchanged) {
49
+ console.log(`nothing changed since ${snap.id} (live); no new snapshot`);
50
+ return 0;
51
+ }
48
52
  console.log(
49
53
  `${snap.id} nodes=${snap.nodes.length} +${snap.summary.added} ~${snap.summary.changed} -${snap.summary.removed}${values.publish ? " (published)" : ""}`,
50
54
  );
@@ -60,23 +60,27 @@ const deployCommand = async (_rest, values, { projectDir }) => {
60
60
  function changeDomain(rest , values , projectDir ) {
61
61
  const [action, arg] = rest;
62
62
  if (action === "set") {
63
- if (!arg && !values.studio)
64
- throw new Error("usage: cloudhal domain set <hostname> [--studio <hostname>]");
63
+ const redirects = (values.redirect ?? []).flatMap((v) => v.split(","));
64
+ if (!arg && !values.studio && redirects.length === 0)
65
+ throw new Error(
66
+ "usage: cloudhal domain set <hostname> [--studio <hostname>] [--redirect <hostname>]…",
67
+ );
65
68
  return domain({
66
69
  projectDir,
67
70
  set: {
68
71
  ...(arg ? { site: arg } : {}),
69
72
  ...(values.studio ? { studio: values.studio } : {}),
73
+ ...(redirects.length > 0 ? { redirects } : {}),
70
74
  },
71
75
  });
72
76
  }
73
77
  if (action === "remove") {
74
78
  const which = arg ?? "all";
75
- if (!["site", "studio", "all"].includes(which))
76
- throw new Error("usage: cloudhal domain remove [site|studio|all]");
79
+ if (!["site", "studio", "redirects", "all"].includes(which))
80
+ throw new Error("usage: cloudhal domain remove [site|studio|redirects|all]");
77
81
  return domain({
78
82
  projectDir,
79
- remove: which === "all" ? ["site", "studio"] : [which ],
83
+ remove: which === "all" ? ["site", "studio", "redirects"] : [which ],
80
84
  });
81
85
  }
82
86
  if (action === undefined) return domain({ projectDir });
@@ -87,7 +91,7 @@ const domainCommand = async (rest, values, { projectDir }) => {
87
91
  const result = await changeDomain(rest, values, projectDir);
88
92
  console.log(
89
93
  result
90
- ? `site: ${result.site ? `https://${result.site}` : "(workers.dev)"}\nstudio: ${result.studio ? `https://${result.studio}` : "(workers.dev)"}`
94
+ ? `site: ${result.site ? `https://${result.site}` : "(workers.dev)"}\nstudio: ${result.studio ? `https://${result.studio}` : "(workers.dev)"}${result.redirects ? `\n301: ${result.redirects.join(", ")} → https://${result.site}` : ""}`
91
95
  : "no custom domains; the Workers answer on workers.dev",
92
96
  );
93
97
  if (rest[0])
@@ -1,2 +1,2 @@
1
1
  /** `cloudhal --help`; every command it names has an entry in `COMMANDS` (cli.ts). */
2
- export declare const HELP = "cloudhal \u2014 agent-native sites\n\nUsage:\n cloudhal init <dir> [--name <name>] [--starter blog] create a site project with a seeded local store\n cloudhal dev [--port <n>] [--host] live preview (drafts visible)\n cloudhal snapshot [--label <text>] [--publish] freeze the current graph (optionally publish)\n cloudhal build [--snapshot <id>] static HTML from the published/latest snapshot\n cloudhal studio [--port <n>] [--no-auth] API + Studio UI for this project (default :8787)\n cloudhal mcp [--mode plan|manual|edits|auto|bypass] [--scopes a,b] [--skills <dir>] [--preview-url <url>]\n MCP server over stdio for this project\n cloudhal deploy [--name <base>] [--dry-run] [--jurisdiction eu] [--scheduler cron|alarm] [--plan free|paid]\n [--force-downgrade] [--no-build-studio]\n site + admin Workers on Cloudflare (uses wrangler login);\n --jurisdiction eu keeps a newly created D1 database\n in the EU (cannot be changed later); --scheduler picks\n the minute cron trigger or the Durable Object alarm;\n --plan sizes scheduler ticks for the account's Workers\n plan (default free; remembered);\n --force-downgrade deploys older schedules code over\n open releases and time gates; from the monorepo a\n stale apps/studio/dist is rebuilt first\n (--no-build-studio: fail instead)\n cloudhal domain [set <hostname>] [--studio <hostname>] | remove [site|studio|all]\n custom domains for the next deploy\n cloudhal push [--replace] [--cancel-schedules] [--admin-url <url>]\n local site \u2192 deployment (refused while releases are\n open unless --cancel-schedules cancels them)\n cloudhal pull [--replace] [--admin-url <url>] deployment \u2192 local site\n cloudhal owner transfer --to <email> [--admin-url <url>]\n make a member the deployment's Owner\n (lockout recovery; uses the deployment token)\n cloudhal site list | create --name <name> --slug <slug> [--locale <tag>] [--project <id>] [--theme <name>] [--from <siteId>]\n | delete <siteId> --confirm <slug> [--remote] [--admin-url <url>]\n the installation's sites (RFC-0034): one Studio,\n many isolated sites; nothing is published\n cloudhal migrate tags-to-terms [--dry-run] [--revert <id>]\n move entry tags into tag terms (RFC-0002);\n resumable; --revert <id> undoes a run\n cloudhal media import-urls [--dry-run] [--include-markdown] [--allow-host <host>]\u2026 | --revert <id>\n copy linked images into the media library\n (RFC-0004); revertible\n cloudhal media verify [<assetId>] recompute media checksums\n cloudhal media gc [--dry-run] delete unreferenced media bytes (> 24 h old)\n cloudhal media placeholders [--remote] [--all] [--dry-run]\n draw missing image placeholders in local Chrome\n cloudhal analytics status [--remote] site analytics: settings, daily totals, budget\n cloudhal analytics rollup [--days <n>] [--remote] [--redo]\n compute daily totals (--remote backfills the\n deployment from WAE, up to 92 days)\n cloudhal analytics seed [--days <n>] [--views <n>] demo traffic in the local SQLite only\n cloudhal analytics export [--from <date> --to <date>] [--remote] [--out <file>]\n daily totals as CSV (default: 30 days)\n cloudhal comments status [--remote] reader comments: counts, pending, daily cap, purges\n cloudhal comments export [--remote] [--with-personal-data --out <file>]\n comments as JSON lines (stdout or --out)\n cloudhal comments import --in <file> [--remote] JSON lines back in; idempotent, safe to rerun\n cloudhal schedules status [--remote] the scheduler's health, open releases, gated pages\n cloudhal schedules list [--remote] [--limit <n>] the site's releases\n cloudhal schedules run-due [--remote] one scheduler tick now (self-hosting: run it from\n the system's cron every minute)\n cloudhal ai build-eval [--remote] [--models a,b] [--briefs landing,faq] [--out <file>]\n [--design] [--look] [--judge <model>|none]\n compare models on Build chat's turn, in memory\n (real model calls; RFC-0020 open question 7);\n --design renders each result: failed checks and\n a vision model's score (RFC-0021 21c); --look lets\n the model see its render first, as Build chat does\n cloudhal ai judge-check [--remote] [--judge <model>] score the reference pages (empty \u2192 strong) with the\n design judge; fails when it does not rank them\n cloudhal render <path|url> [--width 1440,390] [--scheme dark] [--motion static|reduce|full]\n [--frames <n>] [--base <url>] [--out <dir>]\n screenshots and render_page's checks for a page\n (of cloudhal dev by default; RFC-0024)\n cloudhal motion audit [--path <p>] the motion each page asks for, against its budgets\n cloudhal tokens export [--format dtcg] [--out <file>] the site's design tokens as a W3C Design Tokens file\n (Figma, Style Dictionary; RFC-0023)\n cloudhal chrome get [--file <f>] | set --file <f> the site's header, footer and brand as JSON\n (RFC-0025)\n cloudhal search reindex [--remote] rebuild the search index from the live snapshot\n (publishing keeps it current; for restored backups)\n cloudhal --help\n";
2
+ export declare const HELP = "cloudhal \u2014 agent-native sites\n\nUsage:\n cloudhal init <dir> [--name <name>] [--starter blog] create a site project with a seeded local store\n cloudhal dev [--port <n>] [--host] live preview (drafts visible)\n cloudhal snapshot [--label <text>] [--publish] freeze the current graph (optionally publish)\n cloudhal build [--snapshot <id>] static HTML from the published/latest snapshot\n cloudhal studio [--port <n>] [--no-auth] API + Studio UI for this project (default :8787)\n cloudhal mcp [--mode plan|manual|edits|auto|bypass] [--scopes a,b] [--skills <dir>] [--preview-url <url>]\n MCP server over stdio for this project\n cloudhal deploy [--name <base>] [--dry-run] [--jurisdiction eu] [--scheduler cron|alarm] [--plan free|paid]\n [--force-downgrade] [--no-build-studio]\n site + admin Workers on Cloudflare (uses wrangler login);\n --jurisdiction eu keeps a newly created D1 database\n in the EU (cannot be changed later); --scheduler picks\n the minute cron trigger or the Durable Object alarm;\n --plan sizes scheduler ticks for the account's Workers\n plan (default free; remembered);\n --force-downgrade deploys older schedules code over\n open releases and time gates; from the monorepo a\n stale apps/studio/dist is rebuilt first\n (--no-build-studio: fail instead)\n cloudhal domain [set <hostname>] [--studio <hostname>] [--redirect <hostname>]\u2026 | remove [site|studio|redirects|all]\n custom domains for the next deploy\n cloudhal push [--replace] [--cancel-schedules] [--admin-url <url>]\n local site \u2192 deployment (refused while releases are\n open unless --cancel-schedules cancels them)\n cloudhal pull [--replace] [--admin-url <url>] deployment \u2192 local site\n cloudhal owner transfer --to <email> [--admin-url <url>]\n make a member the deployment's Owner\n (lockout recovery; uses the deployment token)\n cloudhal site list | create --name <name> --slug <slug> [--locale <tag>] [--project <id>] [--theme <name>] [--from <siteId>]\n | delete <siteId> --confirm <slug> [--remote] [--admin-url <url>]\n the installation's sites (RFC-0034): one Studio,\n many isolated sites; nothing is published\n cloudhal migrate tags-to-terms [--dry-run] [--revert <id>]\n move entry tags into tag terms (RFC-0002);\n resumable; --revert <id> undoes a run\n cloudhal media import-urls [--dry-run] [--include-markdown] [--allow-host <host>]\u2026 | --revert <id>\n copy linked images into the media library\n (RFC-0004); revertible\n cloudhal media verify [<assetId>] recompute media checksums\n cloudhal media gc [--dry-run] delete unreferenced media bytes (> 24 h old)\n cloudhal media placeholders [--remote] [--all] [--dry-run]\n draw missing image placeholders in local Chrome\n cloudhal analytics status [--remote] site analytics: settings, daily totals, budget\n cloudhal analytics rollup [--days <n>] [--remote] [--redo]\n compute daily totals (--remote backfills the\n deployment from WAE, up to 92 days)\n cloudhal analytics seed [--days <n>] [--views <n>] demo traffic in the local SQLite only\n cloudhal analytics export [--from <date> --to <date>] [--remote] [--out <file>]\n daily totals as CSV (default: 30 days)\n cloudhal comments status [--remote] reader comments: counts, pending, daily cap, purges\n cloudhal comments export [--remote] [--with-personal-data --out <file>]\n comments as JSON lines (stdout or --out)\n cloudhal comments import --in <file> [--remote] JSON lines back in; idempotent, safe to rerun\n cloudhal schedules status [--remote] the scheduler's health, open releases, gated pages\n cloudhal schedules list [--remote] [--limit <n>] the site's releases\n cloudhal schedules run-due [--remote] one scheduler tick now (self-hosting: run it from\n the system's cron every minute)\n cloudhal ai build-eval [--remote] [--models a,b] [--briefs landing,faq] [--out <file>]\n [--design] [--look] [--judge <model>|none]\n compare models on Build chat's turn, in memory\n (real model calls; RFC-0020 open question 7);\n --design renders each result: failed checks and\n a vision model's score (RFC-0021 21c); --look lets\n the model see its render first, as Build chat does\n cloudhal ai judge-check [--remote] [--judge <model>] score the reference pages (empty \u2192 strong) with the\n design judge; fails when it does not rank them\n cloudhal render <path|url> [--width 1440,390] [--scheme dark] [--motion static|reduce|full]\n [--frames <n>] [--base <url>] [--out <dir>]\n screenshots and render_page's checks for a page\n (of cloudhal dev by default; RFC-0024)\n cloudhal motion audit [--path <p>] the motion each page asks for, against its budgets\n cloudhal tokens export [--format dtcg] [--out <file>] the site's design tokens as a W3C Design Tokens file\n (Figma, Style Dictionary; RFC-0023)\n cloudhal chrome get [--file <f>] | set --file <f> the site's header, footer and brand as JSON\n (RFC-0025)\n cloudhal search reindex [--remote] rebuild the search index from the live snapshot\n (publishing keeps it current; for restored backups)\n cloudhal --help\n";
@@ -21,7 +21,7 @@ Usage:
21
21
  open releases and time gates; from the monorepo a
22
22
  stale apps/studio/dist is rebuilt first
23
23
  (--no-build-studio: fail instead)
24
- cloudhal domain [set <hostname>] [--studio <hostname>] | remove [site|studio|all]
24
+ cloudhal domain [set <hostname>] [--studio <hostname>] [--redirect <hostname>]… | remove [site|studio|redirects|all]
25
25
  custom domains for the next deploy
26
26
  cloudhal push [--replace] [--cancel-schedules] [--admin-url <url>]
27
27
  local site → deployment (refused while releases are
package/dist/src/cli.d.ts CHANGED
@@ -53,6 +53,10 @@ export declare const OPTIONS: {
53
53
  readonly studio: {
54
54
  readonly type: "string";
55
55
  };
56
+ readonly redirect: {
57
+ readonly type: "string";
58
+ readonly multiple: true;
59
+ };
56
60
  readonly revert: {
57
61
  readonly type: "string";
58
62
  };
package/dist/src/cli.js CHANGED
@@ -26,6 +26,7 @@ export const OPTIONS = {
26
26
  replace: { type: "boolean" },
27
27
  "admin-url": { type: "string" },
28
28
  studio: { type: "string" },
29
+ redirect: { type: "string", multiple: true },
29
30
  revert: { type: "string" },
30
31
  to: { type: "string" },
31
32
  "include-markdown": { type: "boolean" },
@@ -102,6 +102,8 @@ export declare function siteWorkerConfig(n: DeploymentNames & {
102
102
  domain?: string;
103
103
  /** RFC-0034 §5: every other site's hostnames, one Custom Domain route each. */
104
104
  domains?: readonly string[];
105
+ /** Hosts that answer with a 301 to `domain` (#49), each routed as a Custom Domain too. */
106
+ redirects?: readonly string[];
105
107
  analytics?: AnalyticsDeployment;
106
108
  comments?: CommentsDeployment;
107
109
  /** Extra hostnames the site answers on (custom domains): analytics and comments check them. */
@@ -150,6 +152,8 @@ export declare function siteWorkerConfig(n: DeploymentNames & {
150
152
  CLOUDHAL_ANALYTICS?: string;
151
153
  CLOUDHAL_ANALYTICS_DEFAULTS?: string;
152
154
  CLOUDHAL_SITE_HOSTS?: string;
155
+ CLOUDHAL_REDIRECT_HOSTS?: string;
156
+ CLOUDHAL_REDIRECT_TO?: string;
153
157
  } | {
154
158
  CLOUDHAL_SEARCH_PROVIDER?: never;
155
159
  CLOUDHAL_SEARCH_MONTHLY_LIMIT?: never;
@@ -157,6 +161,8 @@ export declare function siteWorkerConfig(n: DeploymentNames & {
157
161
  CLOUDHAL_ANALYTICS?: string;
158
162
  CLOUDHAL_ANALYTICS_DEFAULTS?: string;
159
163
  CLOUDHAL_SITE_HOSTS?: string;
164
+ CLOUDHAL_REDIRECT_HOSTS?: string;
165
+ CLOUDHAL_REDIRECT_TO?: string;
160
166
  } | {
161
167
  CLOUDHAL_SEARCH_PROVIDER: string;
162
168
  CLOUDHAL_SEARCH_MONTHLY_LIMIT: string;
@@ -164,6 +170,8 @@ export declare function siteWorkerConfig(n: DeploymentNames & {
164
170
  CLOUDHAL_ANALYTICS?: string;
165
171
  CLOUDHAL_ANALYTICS_DEFAULTS?: string;
166
172
  CLOUDHAL_SITE_HOSTS?: string;
173
+ CLOUDHAL_REDIRECT_HOSTS?: string;
174
+ CLOUDHAL_REDIRECT_TO?: string;
167
175
  } | {
168
176
  CLOUDHAL_SEARCH_PROVIDER?: never;
169
177
  CLOUDHAL_SEARCH_MONTHLY_LIMIT?: never;
@@ -171,6 +179,8 @@ export declare function siteWorkerConfig(n: DeploymentNames & {
171
179
  CLOUDHAL_ANALYTICS?: string;
172
180
  CLOUDHAL_ANALYTICS_DEFAULTS?: string;
173
181
  CLOUDHAL_SITE_HOSTS?: string;
182
+ CLOUDHAL_REDIRECT_HOSTS?: string;
183
+ CLOUDHAL_REDIRECT_TO?: string;
174
184
  };
175
185
  cache: {
176
186
  enabled: boolean;
@@ -223,6 +233,8 @@ export declare function siteWorkerConfig(n: DeploymentNames & {
223
233
  CLOUDHAL_ANALYTICS?: string;
224
234
  CLOUDHAL_ANALYTICS_DEFAULTS?: string;
225
235
  CLOUDHAL_SITE_HOSTS?: string;
236
+ CLOUDHAL_REDIRECT_HOSTS?: string;
237
+ CLOUDHAL_REDIRECT_TO?: string;
226
238
  } | {
227
239
  CLOUDHAL_SEARCH_PROVIDER?: never;
228
240
  CLOUDHAL_SEARCH_MONTHLY_LIMIT?: never;
@@ -230,6 +242,8 @@ export declare function siteWorkerConfig(n: DeploymentNames & {
230
242
  CLOUDHAL_ANALYTICS?: string;
231
243
  CLOUDHAL_ANALYTICS_DEFAULTS?: string;
232
244
  CLOUDHAL_SITE_HOSTS?: string;
245
+ CLOUDHAL_REDIRECT_HOSTS?: string;
246
+ CLOUDHAL_REDIRECT_TO?: string;
233
247
  } | {
234
248
  CLOUDHAL_SEARCH_PROVIDER: string;
235
249
  CLOUDHAL_SEARCH_MONTHLY_LIMIT: string;
@@ -237,6 +251,8 @@ export declare function siteWorkerConfig(n: DeploymentNames & {
237
251
  CLOUDHAL_ANALYTICS?: string;
238
252
  CLOUDHAL_ANALYTICS_DEFAULTS?: string;
239
253
  CLOUDHAL_SITE_HOSTS?: string;
254
+ CLOUDHAL_REDIRECT_HOSTS?: string;
255
+ CLOUDHAL_REDIRECT_TO?: string;
240
256
  } | {
241
257
  CLOUDHAL_SEARCH_PROVIDER?: never;
242
258
  CLOUDHAL_SEARCH_MONTHLY_LIMIT?: never;
@@ -244,6 +260,8 @@ export declare function siteWorkerConfig(n: DeploymentNames & {
244
260
  CLOUDHAL_ANALYTICS?: string;
245
261
  CLOUDHAL_ANALYTICS_DEFAULTS?: string;
246
262
  CLOUDHAL_SITE_HOSTS?: string;
263
+ CLOUDHAL_REDIRECT_HOSTS?: string;
264
+ CLOUDHAL_REDIRECT_TO?: string;
247
265
  };
248
266
  cache: {
249
267
  enabled: boolean;
@@ -296,6 +314,8 @@ export declare function siteWorkerConfig(n: DeploymentNames & {
296
314
  CLOUDHAL_ANALYTICS?: string;
297
315
  CLOUDHAL_ANALYTICS_DEFAULTS?: string;
298
316
  CLOUDHAL_SITE_HOSTS?: string;
317
+ CLOUDHAL_REDIRECT_HOSTS?: string;
318
+ CLOUDHAL_REDIRECT_TO?: string;
299
319
  } | {
300
320
  CLOUDHAL_SEARCH_PROVIDER?: never;
301
321
  CLOUDHAL_SEARCH_MONTHLY_LIMIT?: never;
@@ -303,6 +323,8 @@ export declare function siteWorkerConfig(n: DeploymentNames & {
303
323
  CLOUDHAL_ANALYTICS?: string;
304
324
  CLOUDHAL_ANALYTICS_DEFAULTS?: string;
305
325
  CLOUDHAL_SITE_HOSTS?: string;
326
+ CLOUDHAL_REDIRECT_HOSTS?: string;
327
+ CLOUDHAL_REDIRECT_TO?: string;
306
328
  } | {
307
329
  CLOUDHAL_SEARCH_PROVIDER: string;
308
330
  CLOUDHAL_SEARCH_MONTHLY_LIMIT: string;
@@ -310,6 +332,8 @@ export declare function siteWorkerConfig(n: DeploymentNames & {
310
332
  CLOUDHAL_ANALYTICS?: string;
311
333
  CLOUDHAL_ANALYTICS_DEFAULTS?: string;
312
334
  CLOUDHAL_SITE_HOSTS?: string;
335
+ CLOUDHAL_REDIRECT_HOSTS?: string;
336
+ CLOUDHAL_REDIRECT_TO?: string;
313
337
  } | {
314
338
  CLOUDHAL_SEARCH_PROVIDER?: never;
315
339
  CLOUDHAL_SEARCH_MONTHLY_LIMIT?: never;
@@ -317,6 +341,8 @@ export declare function siteWorkerConfig(n: DeploymentNames & {
317
341
  CLOUDHAL_ANALYTICS?: string;
318
342
  CLOUDHAL_ANALYTICS_DEFAULTS?: string;
319
343
  CLOUDHAL_SITE_HOSTS?: string;
344
+ CLOUDHAL_REDIRECT_HOSTS?: string;
345
+ CLOUDHAL_REDIRECT_TO?: string;
320
346
  };
321
347
  cache: {
322
348
  enabled: boolean;
@@ -366,6 +392,8 @@ export declare function siteWorkerConfig(n: DeploymentNames & {
366
392
  CLOUDHAL_ANALYTICS?: string;
367
393
  CLOUDHAL_ANALYTICS_DEFAULTS?: string;
368
394
  CLOUDHAL_SITE_HOSTS?: string;
395
+ CLOUDHAL_REDIRECT_HOSTS?: string;
396
+ CLOUDHAL_REDIRECT_TO?: string;
369
397
  } | {
370
398
  CLOUDHAL_SEARCH_PROVIDER?: never;
371
399
  CLOUDHAL_SEARCH_MONTHLY_LIMIT?: never;
@@ -373,6 +401,8 @@ export declare function siteWorkerConfig(n: DeploymentNames & {
373
401
  CLOUDHAL_ANALYTICS?: string;
374
402
  CLOUDHAL_ANALYTICS_DEFAULTS?: string;
375
403
  CLOUDHAL_SITE_HOSTS?: string;
404
+ CLOUDHAL_REDIRECT_HOSTS?: string;
405
+ CLOUDHAL_REDIRECT_TO?: string;
376
406
  } | {
377
407
  CLOUDHAL_SEARCH_PROVIDER: string;
378
408
  CLOUDHAL_SEARCH_MONTHLY_LIMIT: string;
@@ -380,6 +410,8 @@ export declare function siteWorkerConfig(n: DeploymentNames & {
380
410
  CLOUDHAL_ANALYTICS?: string;
381
411
  CLOUDHAL_ANALYTICS_DEFAULTS?: string;
382
412
  CLOUDHAL_SITE_HOSTS?: string;
413
+ CLOUDHAL_REDIRECT_HOSTS?: string;
414
+ CLOUDHAL_REDIRECT_TO?: string;
383
415
  } | {
384
416
  CLOUDHAL_SEARCH_PROVIDER?: never;
385
417
  CLOUDHAL_SEARCH_MONTHLY_LIMIT?: never;
@@ -387,6 +419,8 @@ export declare function siteWorkerConfig(n: DeploymentNames & {
387
419
  CLOUDHAL_ANALYTICS?: string;
388
420
  CLOUDHAL_ANALYTICS_DEFAULTS?: string;
389
421
  CLOUDHAL_SITE_HOSTS?: string;
422
+ CLOUDHAL_REDIRECT_HOSTS?: string;
423
+ CLOUDHAL_REDIRECT_TO?: string;
390
424
  };
391
425
  cache: {
392
426
  enabled: boolean;
@@ -180,6 +180,8 @@ export function siteWorkerConfig(
180
180
 
181
181
   
182
182
 
183
+
184
+
183
185
 
184
186
 
185
187
 
@@ -191,6 +193,7 @@ export function siteWorkerConfig(
191
193
  const a = n.analytics;
192
194
  const c = n.comments?.available ? n.comments : undefined;
193
195
  const hosts = [...new Set([...(n.siteHosts ?? []), ...(a?.siteHosts ?? [])])];
196
+ const redirects = n.domain ? (n.redirects ?? []) : [];
194
197
  const ratelimits = [
195
198
  // Optional cross-isolate beacon limit (RFC-0006 §5.5): one key per client (an HMAC, never the IP).
196
199
  ...(a?.rateLimit
@@ -237,8 +240,11 @@ export function siteWorkerConfig(
237
240
  : {}),
238
241
  // RFC-0006 §5.2 / RFC-0007 §6.2 step 2: the same-origin checks accept these hosts too.
239
242
  ...((a || c) && hosts.length > 0 ? { CLOUDHAL_SITE_HOSTS: hosts.join(",") } : {}),
243
+ ...(n.domain && redirects.length > 0
244
+ ? { CLOUDHAL_REDIRECT_HOSTS: redirects.join(","), CLOUDHAL_REDIRECT_TO: n.domain }
245
+ : {}),
240
246
  },
241
- ...customDomain(n.domain, n.domains),
247
+ ...customDomain(n.domain, [...(n.domains ?? []), ...redirects]),
242
248
  // Workers Cache: published pages are cached until a publish purges `cloudhal-site-<id>`.
243
249
  cache: { enabled: true },
244
250
  observability: observabilitySettings(n.observability),
@@ -292,6 +292,7 @@ export async function buildSite(
292
292
  ...(served.siteIds ? { siteIds: served.siteIds } : {}),
293
293
  ...(served.hosts.length > 0 ? { domains: served.hosts } : {}),
294
294
  ...(cfg.domains?.site ? { domain: cfg.domains.site, siteHosts: [cfg.domains.site] } : {}),
295
+ ...(cfg.domains?.redirects ? { redirects: cfg.domains.redirects } : {}),
295
296
  ...(input.analytics ? { analytics: input.analytics } : {}),
296
297
  comments: input.comments,
297
298
  observability: input.observability,
@@ -1,11 +1,15 @@
1
1
  import { type ProjectConfig } from "../config.js";
2
- export type DomainTarget = "site" | "studio";
2
+ export type DomainTarget = "site" | "studio" | "redirects";
3
3
  export interface DomainOptions {
4
4
  readonly projectDir: string;
5
- /** Hostnames to set (others stay as they are). */
5
+ /**
6
+ * Hostnames to set (others stay as they are). `redirects` replaces the list of hosts that
7
+ * answer with a 301 to the site's domain (#49).
8
+ */
6
9
  readonly set?: {
7
10
  readonly site?: string;
8
11
  readonly studio?: string;
12
+ readonly redirects?: readonly string[];
9
13
  };
10
14
  /** Domains to forget. */
11
15
  readonly remove?: readonly DomainTarget[];
@@ -2,16 +2,28 @@
2
2
  * `cloudhal domain` — the custom domains `cloudhal deploy` routes to this project's Workers
3
3
  * (Cloudflare Custom Domains: the zone must be in the same Cloudflare account; wrangler creates
4
4
  * the DNS record and certificate). Kept in cloudhal.json so the setting travels with the project.
5
+ * The site's own hostname also becomes its primary domain in the local store, so `cloudhal build`
6
+ * writes it into canonical links, the sitemap and feeds before any deploy (#48); `deploy` does the
7
+ * same on the deployment.
5
8
  */
6
- import { isHostname, normalizeHostname } from "@cloudhal/core-ts";
9
+ import path from "node:path";
10
+ import { isHostname, normalizeHostname, withPrimaryDomain } from "@cloudhal/core-ts";
11
+ import { openLocalStore } from "@cloudhal/core-ts/local";
7
12
  import { readProjectConfig, writeProjectConfig } from "../config.js";
8
13
 
9
-
14
+
10
15
 
11
16
 
12
17
 
13
-
14
-
18
+
19
+
20
+
21
+
22
+
23
+
24
+
25
+
26
+
15
27
 
16
28
 
17
29
 
@@ -27,18 +39,47 @@ export function checkHostname(input ) {
27
39
 
28
40
  export async function domain(options ) {
29
41
  const cfg = await readProjectConfig(options.projectDir);
30
- const next = { ...cfg.domains };
42
+ const next = {
43
+ ...cfg.domains,
44
+ };
31
45
  for (const target of options.remove ?? []) delete next[target];
46
+ // redirects go to the site's domain: without it they have nowhere to go
47
+ if (options.remove?.includes("site")) delete next.redirects;
32
48
  if (options.set?.site) next.site = checkHostname(options.set.site);
33
49
  if (options.set?.studio) next.studio = checkHostname(options.set.studio);
50
+ if (options.set?.redirects)
51
+ next.redirects = [...new Set(options.set.redirects.map(checkHostname))];
34
52
  if (next.site && next.site === next.studio)
35
53
  throw new Error("the site and Studio need different hostnames");
54
+ checkRedirects(next);
55
+ if (!next.redirects?.length) delete next.redirects;
56
+ const any = next.site || next.studio || next.redirects;
36
57
  if (options.set || options.remove) {
37
58
  const { domains: _old, ...rest } = cfg;
38
- await writeProjectConfig(options.projectDir, {
39
- ...rest,
40
- ...(next.site || next.studio ? { domains: next } : {}),
41
- });
59
+ await writeProjectConfig(options.projectDir, { ...rest, ...(any ? { domains: next } : {}) });
60
+ }
61
+ if (next.site && options.set?.site) await makePrimary(options.projectDir, cfg, next.site);
62
+ return any ? next : undefined;
63
+ }
64
+
65
+ /** Redirect hosts need a site domain to go to, and are neither it nor Studio's. */
66
+ function checkRedirects(d ) {
67
+ if (!d.redirects?.length) return;
68
+ if (!d.site)
69
+ throw new Error("redirects need the site's own domain: cloudhal domain set <hostname>");
70
+ for (const host of d.redirects)
71
+ if (host === d.site || host === d.studio)
72
+ throw new Error(`${host} is already the site's or Studio's domain; it cannot redirect`);
73
+ }
74
+
75
+ /** `hostname` becomes the local site's primary domain (other domains stay, as secondary). */
76
+ async function makePrimary(projectDir , cfg , hostname ) {
77
+ const local = await openLocalStore(path.join(projectDir, cfg.dataDir));
78
+ try {
79
+ const site = await local.store.getSite(cfg.siteId);
80
+ if (site && site.domains.find((d) => d.primary)?.hostname !== hostname)
81
+ await local.store.updateSite({ ...site, domains: withPrimaryDomain(site.domains, hostname) });
82
+ } finally {
83
+ await local.close();
42
84
  }
43
- return next.site || next.studio ? next : undefined;
44
85
  }
@@ -4,7 +4,15 @@ export interface SnapshotOptions {
4
4
  readonly label?: string;
5
5
  readonly publish?: boolean;
6
6
  }
7
- /** Creates a snapshot of the live site (and optionally publishes it). */
8
- export declare function snapshot(options: SnapshotOptions): Promise<Snapshot>;
7
+ /** A snapshot, or (`unchanged`) the live one when publishing would change nothing. */
8
+ export type SnapshotResult = Snapshot & {
9
+ readonly unchanged?: true;
10
+ };
11
+ /**
12
+ * Creates a snapshot of the live site (and optionally publishes it). Publishing a site whose
13
+ * graph matches its live snapshot makes no new one (#51): re-running a setup script does not pad
14
+ * the history with empty snapshots.
15
+ */
16
+ export declare function snapshot(options: SnapshotOptions): Promise<SnapshotResult>;
9
17
  /** The snapshot `cloudhal build` renders: the published one, else the latest, else a fresh one. */
10
18
  export declare function resolveBuildSnapshot(projectDir: string): Promise<Snapshot>;
@@ -1,6 +1,6 @@
1
1
  import path from "node:path";
2
2
 
3
- import { openLocalStore } from "@cloudhal/core-ts/local";
3
+ import { openLocalStore } from "@cloudhal/core-ts/local";
4
4
  import { readProjectConfig } from "../config.js";
5
5
 
6
6
 
@@ -9,11 +9,22 @@ import { readProjectConfig } from "../config.js";
9
9
 
10
10
 
11
11
 
12
- /** Creates a snapshot of the live site (and optionally publishes it). */
13
- export async function snapshot(options ) {
12
+ /** A snapshot, or (`unchanged`) the live one when publishing would change nothing. */
13
+
14
+
15
+ /**
16
+ * Creates a snapshot of the live site (and optionally publishes it). Publishing a site whose
17
+ * graph matches its live snapshot makes no new one (#51): re-running a setup script does not pad
18
+ * the history with empty snapshots.
19
+ */
20
+ export async function snapshot(options ) {
14
21
  const cfg = await readProjectConfig(options.projectDir);
15
22
  const local = await openLocalStore(path.join(options.projectDir, cfg.dataDir));
16
23
  try {
24
+ if (options.publish) {
25
+ const live = await unchangedLive(local.store, cfg.siteId);
26
+ if (live) return { ...live, unchanged: true };
27
+ }
17
28
  const snap = await local.store.createSnapshot(cfg.siteId, {
18
29
  actor: { kind: "human", id: "user_localcli0001", name: "cloudhal CLI" },
19
30
  reason: options.publish ? "publish" : "manual",
@@ -30,6 +41,18 @@ export async function snapshot(options ) {
30
41
  }
31
42
  }
32
43
 
44
+ /** The live snapshot when the site has one and nothing is pending against it. */
45
+ async function unchangedLive(
46
+ store ,
47
+ siteId ,
48
+ ) {
49
+ const site = await store.getSite(siteId);
50
+ if (!site?.publishedSnapshotId) return undefined;
51
+ const d = await store.pendingChanges(siteId);
52
+ if (d.added.length || d.removed.length || d.changed.length) return undefined;
53
+ return (await store.getSnapshot(site.publishedSnapshotId)) ?? undefined;
54
+ }
55
+
33
56
  /** The snapshot `cloudhal build` renders: the published one, else the latest, else a fresh one. */
34
57
  export async function resolveBuildSnapshot(projectDir ) {
35
58
  const cfg = await readProjectConfig(projectDir);
@@ -104,11 +104,13 @@ export interface ProjectConfig {
104
104
  readonly dataDir: string;
105
105
  /**
106
106
  * Custom domains `cloudhal deploy` routes to the Workers (Cloudflare Custom Domains; the zone
107
- * must be in the same account). The workers.dev addresses keep working.
107
+ * must be in the same account). The workers.dev addresses keep working. `redirects` answer with
108
+ * a 301 to `site` (#49), e.g. `www.example.com`.
108
109
  */
109
110
  readonly domains?: {
110
111
  readonly site?: string;
111
112
  readonly studio?: string;
113
+ readonly redirects?: readonly string[];
112
114
  };
113
115
  /** Site analytics (RFC-0006); absent = off. */
114
116
  readonly analytics?: AnalyticsConfig;
@@ -117,9 +117,14 @@ const MAIL_ADDRESS = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/;
117
117
 
118
118
 
119
119
 
120
-
120
+
121
+
121
122
 
122
-
123
+
124
+
125
+
126
+
127
+
123
128
 
124
129
 
125
130
 
@@ -167,6 +172,21 @@ const OWN_KEYS = new Set([
167
172
 
168
173
  const VITALS = [0, 1, 2, 5, 10, 20];
169
174
 
175
+ /** `domains`, leniently: values of the wrong type are left out. */
176
+ function parseDomains(input ) {
177
+ if (!input || typeof input !== "object") return undefined;
178
+ const d = input ;
179
+ const redirects = Array.isArray(d.redirects)
180
+ ? d.redirects.filter((h) => typeof h === "string" && h !== "")
181
+ : [];
182
+ const out = {
183
+ ...(typeof d.site === "string" && d.site ? { site: d.site } : {}),
184
+ ...(typeof d.studio === "string" && d.studio ? { studio: d.studio } : {}),
185
+ ...(redirects.length > 0 ? { redirects } : {}),
186
+ };
187
+ return Object.keys(out).length > 0 ? out : undefined;
188
+ }
189
+
170
190
  /** Leniently parsed, like `domains`: values of the wrong type are left out. */
171
191
  export function parseAnalyticsConfig(input ) {
172
192
  if (!input || typeof input !== "object" || Array.isArray(input)) return undefined;
@@ -297,7 +317,7 @@ export async function readProjectConfig(projectDir )
297
317
  }
298
318
  const cfg = JSON.parse(raw) ;
299
319
  if (typeof cfg.siteId !== "string") throw new Error(`${CONFIG_FILE}: missing siteId`);
300
- const domains = cfg.domains && typeof cfg.domains === "object" ? cfg.domains : undefined;
320
+ const domains = parseDomains(cfg.domains);
301
321
  const analytics = parseAnalyticsConfig(cfg.analytics);
302
322
  const comments = parseCommentsConfig(cfg.comments);
303
323
  const schedules = parseSchedulesConfig(cfg.schedules);
@@ -314,14 +334,7 @@ export async function readProjectConfig(projectDir )
314
334
  ? { installation: cfg.installation }
315
335
  : {}),
316
336
  dataDir: cfg.dataDir ?? ".cloudhal",
317
- ...(domains && (domains.site || domains.studio)
318
- ? {
319
- domains: {
320
- ...(typeof domains.site === "string" ? { site: domains.site } : {}),
321
- ...(typeof domains.studio === "string" ? { studio: domains.studio } : {}),
322
- },
323
- }
324
- : {}),
337
+ ...(domains ? { domains } : {}),
325
338
  ...(analytics ? { analytics } : {}),
326
339
  ...(comments ? { comments } : {}),
327
340
  ...(schedules ? { schedules } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudhal/cli",
3
- "version": "0.3.7",
3
+ "version": "0.3.9",
4
4
  "description": "cloudhal — create, run and build Cloudhal sites locally",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://cloudhal.dev",
@@ -27,16 +27,16 @@
27
27
  "README.md"
28
28
  ],
29
29
  "dependencies": {
30
- "@cloudhal/agent-runtime": "0.3.7",
31
- "@cloudhal/analytics": "0.3.7",
32
- "@cloudhal/api": "0.3.7",
33
- "@cloudhal/cloudflare": "0.3.7",
34
- "@cloudhal/comments": "0.3.7",
35
- "@cloudhal/core-contracts": "0.3.7",
36
- "@cloudhal/core-ts": "0.3.7",
37
- "@cloudhal/mcp-server": "0.3.7",
38
- "@cloudhal/schedules": "0.3.7",
39
- "@cloudhal/storage": "0.3.7",
30
+ "@cloudhal/agent-runtime": "0.3.9",
31
+ "@cloudhal/analytics": "0.3.9",
32
+ "@cloudhal/api": "0.3.9",
33
+ "@cloudhal/cloudflare": "0.3.9",
34
+ "@cloudhal/comments": "0.3.9",
35
+ "@cloudhal/core-contracts": "0.3.9",
36
+ "@cloudhal/core-ts": "0.3.9",
37
+ "@cloudhal/mcp-server": "0.3.9",
38
+ "@cloudhal/schedules": "0.3.9",
39
+ "@cloudhal/storage": "0.3.9",
40
40
  "@hono/node-server": "^2.1.4",
41
41
  "better-sqlite3": "13.0.3"
42
42
  },