@homepages/template-kit 0.3.0 → 0.4.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.
Files changed (109) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +39 -4
  3. package/dist/asset-modules.d.ts +81 -0
  4. package/dist/cli/check/config.js +5 -1
  5. package/dist/cli/check/css.js +27 -13
  6. package/dist/cli/check/loader.js +4 -0
  7. package/dist/cli/check/section-assets.js +58 -0
  8. package/dist/cli/check/stages/size.js +124 -8
  9. package/dist/cli/check/stages/tree.js +26 -27
  10. package/dist/cli/dev/build-css.js +91 -0
  11. package/dist/cli/dev/catalog.js +35 -0
  12. package/dist/cli/dev/compose-template.js +38 -0
  13. package/dist/cli/dev/content-override.js +58 -0
  14. package/dist/cli/dev/discover.js +35 -0
  15. package/dist/cli/dev/fill-state.js +75 -0
  16. package/dist/cli/dev/index.js +32 -0
  17. package/dist/cli/dev/inspect.js +38 -0
  18. package/dist/cli/dev/island-bootstrap.js +38 -0
  19. package/dist/cli/dev/island-map.js +35 -0
  20. package/dist/cli/dev/island-transform.js +31 -0
  21. package/dist/cli/dev/manifest-instances.js +31 -0
  22. package/dist/cli/dev/render-section.js +24 -0
  23. package/dist/cli/dev/screenshot-target.js +24 -0
  24. package/dist/cli/dev/section-page.js +54 -0
  25. package/dist/cli/dev/server.js +480 -0
  26. package/dist/cli/dev/slot-schema.js +12 -0
  27. package/dist/cli/dev/structure-summary.js +118 -0
  28. package/dist/cli/dev/tailwind.js +32 -0
  29. package/dist/cli/dev/vite-server.js +33 -0
  30. package/dist/cli/dev/workspace.js +63 -0
  31. package/dist/cli/link/index.js +74 -0
  32. package/dist/cli/link/overlay.js +59 -0
  33. package/dist/cli/link/watch.js +25 -0
  34. package/dist/cli/new/emit.js +48 -0
  35. package/dist/cli/new/index.js +69 -0
  36. package/dist/cli/new/scaffold/section/_Renderer.tsx +20 -0
  37. package/dist/cli/new/scaffold/section/_fill-spec.ts +18 -0
  38. package/dist/cli/new/scaffold/section/_fixtures.ts +13 -0
  39. package/dist/cli/new/scaffold/section/_schema.ts +24 -0
  40. package/dist/cli/new/scaffold/template/_manifest.json +9 -0
  41. package/dist/cli/new/scaffold/template/sections/hero/ExpandableText.tsx +45 -0
  42. package/dist/cli/new/scaffold/template/sections/hero/Renderer.tsx +40 -0
  43. package/dist/cli/new/scaffold/template/sections/hero/components/Feature.tsx +14 -0
  44. package/dist/cli/new/scaffold/template/sections/hero/fill-spec.ts +29 -0
  45. package/dist/cli/new/scaffold/template/sections/hero/fixtures.ts +41 -0
  46. package/dist/cli/new/scaffold/template/sections/hero/schema.ts +60 -0
  47. package/dist/cli/new/scaffold/template/theme.css +24 -0
  48. package/dist/cli/new/scaffold/template/theme.ts +27 -0
  49. package/dist/cli/new/scaffold-assets.js +20 -0
  50. package/dist/cli/pack/collect.js +44 -0
  51. package/dist/cli/pack/guards.js +57 -0
  52. package/dist/cli/pack/index.js +66 -0
  53. package/dist/cli/pack/manifest.js +15 -0
  54. package/dist/cli/pack/zip.js +86 -0
  55. package/dist/cli.js +80 -14
  56. package/dist/dev-client/assets/index-DfEfrp6P.css +1 -0
  57. package/dist/dev-client/assets/index-bpMP0b0W.js +50 -0
  58. package/dist/dev-client/index.html +13 -0
  59. package/dist/eslint/is-client-file.d.ts +4 -0
  60. package/dist/index.d.ts +1 -0
  61. package/dist/islands/detect.d.ts +16 -0
  62. package/dist/islands/detect.js +22 -0
  63. package/dist/islands/discover.d.ts +5 -0
  64. package/dist/islands/discover.js +19 -0
  65. package/dist/islands/esbuild-plugin.d.ts +13 -0
  66. package/dist/islands/esbuild-plugin.js +39 -0
  67. package/dist/islands/wrap.d.ts +12 -0
  68. package/dist/islands/wrap.js +48 -0
  69. package/dist/package.js +1 -1
  70. package/dist/ssr.d.ts +31 -0
  71. package/dist/ssr.js +46 -0
  72. package/docs/INDEX.md +9 -1
  73. package/docs/assets.md +128 -0
  74. package/docs/check.md +6 -3
  75. package/docs/dev.md +187 -0
  76. package/docs/islands.md +5 -2
  77. package/docs/llms.txt +41 -3
  78. package/docs/new.md +27 -0
  79. package/docs/overview.md +57 -0
  80. package/docs/pack.md +46 -0
  81. package/docs/primitives.md +5 -6
  82. package/docs/quickstart.md +102 -0
  83. package/docs/recipes/INDEX.md +27 -0
  84. package/docs/recipes/bind-property-fact.md +58 -0
  85. package/docs/recipes/collection-slot.md +100 -0
  86. package/docs/recipes/fill-spec-decision.md +66 -0
  87. package/docs/recipes/fixture-states.md +68 -0
  88. package/docs/recipes/image-slot-crop.md +98 -0
  89. package/docs/recipes/interactive-island.md +100 -0
  90. package/docs/recipes/organize-section-folder.md +72 -0
  91. package/docs/recipes/prepare-submission.md +55 -0
  92. package/docs/recipes/second-template.md +49 -0
  93. package/docs/recipes/select-slot.md +59 -0
  94. package/docs/recipes/static-asset.md +103 -0
  95. package/docs/recipes/third-party-package.md +90 -0
  96. package/docs/rules/INDEX.md +4 -2
  97. package/docs/rules/bundle-binary-asset.md +33 -52
  98. package/docs/rules/bundle-incomplete.md +2 -2
  99. package/docs/rules/css-reason.md +3 -3
  100. package/docs/rules/no-bare-css-import.md +8 -8
  101. package/docs/rules/no-css-import-from-render-path.md +7 -8
  102. package/docs/rules/size-assets.md +88 -0
  103. package/docs/rules/size-island-bundle.md +123 -0
  104. package/docs/rules/size-section-css.md +19 -14
  105. package/docs/schema-system.md +28 -28
  106. package/docs/theme-and-css.md +2 -2
  107. package/docs/vocabulary.md +98 -0
  108. package/package.json +12 -3
  109. package/tsconfig.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,89 @@
1
1
  # @homepages/template-kit
2
2
 
3
+ ## 0.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 1e35f40: Fixes `wrapClientModuleSource`/`islandTransformEsbuildPlugin` (the `./ssr` subpath's
8
+ island codemod) throwing `MagicString is not a constructor` under `tsx`. `magic-string`
9
+ was missing from `dependencies`, so tsdown vendored an unpackaged private copy into the
10
+ kit's own `dist/node_modules/` (no `package.json` alongside it) instead of externalizing
11
+ the bare import — that copy's module type depended on ancestor-directory inference, which
12
+ diverges between plain Node and tsx's resolver. `magic-string` is now a declared runtime
13
+ dependency, so the shipped code imports it as a normal bare specifier resolved from the
14
+ consumer's own `node_modules`.
15
+
16
+ ## 0.4.0
17
+
18
+ ### Minor Changes
19
+
20
+ - 94d6458: `template-kit dev`: a full-template published page at `/template/<t>`, scope-aware
21
+ `/api/screenshot` (section or whole template, with `width`), and a new `/api/inspect`
22
+ endpoint returning per-slot fill-state (filled / empty / missing + a render
23
+ cross-check), resolved props, and the null-corpus. Retires `/api/structure-summary`
24
+ (its outline is now `/api/inspect?format=text`).
25
+ - 47dbbc6: Section-folder static assets are now first-class. `check`'s `bundle-binary-asset` rule
26
+ flags only an _unreferenced_ binary in a section folder; an asset imported in a component
27
+ (`import crest from "./assets/crest.svg"` → `<Image src={crest} …>`) or `url()`-referenced
28
+ from a section stylesheet is allowed. A `declare module "*.svg"` (and the other supported
29
+ asset extensions) type shim ships so component imports of assets typecheck.
30
+ - 9a3e0e1: Adds a read-only canvas mirror to `template-kit dev`, at `/canvas/<template>`. It frames
31
+ the **whole template** — every section instance, stacked in one scroll container, exactly
32
+ as the deliverable renders — in an iframe and paints a hover/select overlay on top: click
33
+ an editable slot to select it (a gold ring), hover to preview it (a blue ring);
34
+ non-editable regions select their enclosing section instead. Selection is scoped per
35
+ section instance, so the same slot name in two sections resolves independently. The
36
+ overlay is purely visual — it never alters the rendered markup. A mode toggle switches to
37
+ a clean published render of the full template with no overlay, and the current view —
38
+ template, mode, and the selected instance/slot — is captured in the URL, so a canvas link
39
+ reloads and shares exactly what you see.
40
+
41
+ The `dev` server gains `GET /api/template-schema?template=<t>`, returning each section
42
+ instance's editability map (per slot: its type and whether it is user-editable) for the
43
+ whole template, so a tool can tell which slots are selectable without re-parsing the
44
+ section schemas. (The per-section `/api/render` response also still carries its own
45
+ `slotSchema` map.)
46
+
47
+ - 7f68570: Canvas mirror: exercise slots interactively. Selecting an editable slot opens a
48
+ side panel with a JSON editor for its value; applying a change re-renders that
49
+ section instance live. A per-instance fixture picker drives different content —
50
+ including the empty/missing states from the section's fixture corpus — without
51
+ editing fixture files.
52
+ - 5a8dcd4: Adds the `dev` command: `template-kit dev [template] [--port <n>]`. A local preview
53
+ server for a workspace's templates — it renders every section against its fixtures
54
+ server-side, with the template's theme and compiled Tailwind utilities, exactly the way
55
+ the platform does. A browser UI gives you a picker (template → section → fixture) and a
56
+ grid contact sheet of every fixture at each breakpoint, including empty and null states;
57
+ each section also has a standalone preview at `/sections/<template>/<section>/<fixture>`.
58
+ Editing a section reloads open previews; editing a theme hot-swaps its stylesheet.
59
+
60
+ Endpoints render a section without a browser, for an agent verifying its work from a
61
+ terminal: `/api/inspect` (a low-context per-slot fill-state view as JSON, with
62
+ `?format=text` for the rendered outline) and `/api/screenshot` (a full-page PNG). Like
63
+ `check`, `dev` runs your
64
+ toolchain — Vite and Tailwind resolved from your workspace, not shipped by the kit:
65
+
66
+ ```bash
67
+ npm i -D vite @vitejs/plugin-react tailwindcss @tailwindcss/cli
68
+ npm i -D @playwright/test # optional, for /api/screenshot
69
+ ```
70
+
71
+ - 13c3132: Add `template-kit pack <template>` — the per-template submission zip (template
72
+ source + workspace lockfile + a stamped `pack-manifest.json`), which refuses on a
73
+ failing `check`, an active link overlay, a missing lockfile, or local-protocol
74
+ deps. Add the internal `link`/`unlink`/`status` overlay for developing the kit
75
+ against a real consumer without publishing.
76
+ - bcf6fbb: `@homepages/template-kit/ssr` now also exports `wrapClientModuleSource` (the `"use
77
+ client"` → `IslandMarker` codemod), `findSectionIslands` (recursive `"use client"`
78
+ component discovery under a section directory), and `islandTransformEsbuildPlugin`
79
+ (an esbuild `onLoad` plugin that applies the same codemod across a build graph, keyed
80
+ `islandKey(sectionKey, <file-basename>)`). All three are the same primitives
81
+ `template-kit dev`'s Vite plugin already used internally, now exposed so a build-time
82
+ consumer — the publish renderer bundle's esbuild pass, or a native-import loader hook —
83
+ can wrap island modules identically without duplicating the codemod. `esbuild` stays a
84
+ devDependency of the kit; `islandTransformEsbuildPlugin` imports its `Plugin` type only.
85
+ - b76f822: Add the node-only `@homepages/template-kit/ssr` subpath: the shared section SSR helper (`loadSectionBundle`, `renderSectionHtml`) that renders a section + fixture to the exact HTML the publisher emits, plus island detection (`isClientComponentFile`, `hasClientDirective`) and key derivation (`islandKey`) for a section's `"use client"` components.
86
+
3
87
  ## 0.3.0
4
88
 
5
89
  ### Minor Changes
package/README.md CHANGED
@@ -6,10 +6,26 @@ contract primitives, the theme system, and the `template-kit` CLI.
6
6
  **Status: 0.0.x.** The **schema system** (the section authoring contract and the closed
7
7
  vocabulary it validates against), the **contract primitives**, and the **theme system**
8
8
  are complete — a section is authorable end to end against the kit. The `template-kit`
9
- CLI's `check` and `theme` commands are implemented — see
10
- [Checking a workspace](#checking-a-workspace) and
11
- [Generating a theme](#generating-a-theme) below; its other commands arrive with the
12
- surfaces they drive.
9
+ CLI's `check`, `theme`, `dev`, and `pack` commands are implemented — see
10
+ [Checking a workspace](#checking-a-workspace), [Generating a theme](#generating-a-theme),
11
+ and [Previewing a workspace](#previewing-a-workspace) below and `new` / `new-section`
12
+ scaffold a template or a section into a workspace (see [docs/new.md](docs/new.md)).
13
+
14
+ ## Quick start
15
+
16
+ The fastest way to start is the workspace scaffolder — it creates a workspace with a
17
+ working starter template and the full toolchain already wired:
18
+
19
+ ```bash
20
+ npm create homepages-workspace <name>
21
+ cd <name>
22
+ npm install
23
+ npm run dev # preview the starter; the island hydrates
24
+ ```
25
+
26
+ Add more templates with `npx template-kit new <template>` and sections with
27
+ `npx template-kit new-section <template>/<section>` — see [docs/new.md](docs/new.md).
28
+ Prefer to wire a workspace by hand? See **Install** below.
13
29
 
14
30
  ## Install
15
31
 
@@ -119,6 +135,25 @@ Tailwind entry imports. The output is generated: don't hand-edit it, and re-run
119
135
  command whenever `theme.ts` changes. Full contract:
120
136
  **[docs/theme-and-css.md](docs/theme-and-css.md#generating-themecss)**.
121
137
 
138
+ ## Previewing a workspace
139
+
140
+ ```bash
141
+ template-kit dev # serve every template in the workspace
142
+ template-kit dev <template> # preselect one template in the picker
143
+ template-kit dev --port 4000 # choose the port (default 5180)
144
+ ```
145
+
146
+ `dev` starts a local server that renders every section against its fixtures the same way
147
+ the platform does — with your template's theme and compiled Tailwind — and reloads as you
148
+ edit. A browser UI gives you a picker and a grid contact sheet across breakpoints; each
149
+ section also has a standalone page, and so does the whole template at
150
+ `/template/<template>`. Three endpoints let an agent verify a render from a terminal:
151
+ `/api/inspect` (per-slot fill-state as JSON), `/api/render` (the full structured render
152
+ payload), and `/api/screenshot` (a full-page PNG, section or whole template). Like
153
+ `check`, it runs **your** toolchain — Vite and Tailwind resolved from your workspace,
154
+ installed with `npm i -D vite @vitejs/plugin-react tailwindcss @tailwindcss/cli` — not
155
+ shipped by the kit. Full contract: **[docs/dev.md](docs/dev.md)**.
156
+
122
157
  ## Checking a workspace
123
158
 
124
159
  ```bash
@@ -0,0 +1,81 @@
1
+ //#region src/asset-modules.d.ts
2
+ declare module "*.svg" {
3
+ const src: string;
4
+ export default src;
5
+ }
6
+ declare module "*.png" {
7
+ const src: string;
8
+ export default src;
9
+ }
10
+ declare module "*.jpg" {
11
+ const src: string;
12
+ export default src;
13
+ }
14
+ declare module "*.jpeg" {
15
+ const src: string;
16
+ export default src;
17
+ }
18
+ declare module "*.gif" {
19
+ const src: string;
20
+ export default src;
21
+ }
22
+ declare module "*.webp" {
23
+ const src: string;
24
+ export default src;
25
+ }
26
+ declare module "*.avif" {
27
+ const src: string;
28
+ export default src;
29
+ }
30
+ declare module "*.ico" {
31
+ const src: string;
32
+ export default src;
33
+ }
34
+ declare module "*.woff" {
35
+ const src: string;
36
+ export default src;
37
+ }
38
+ declare module "*.woff2" {
39
+ const src: string;
40
+ export default src;
41
+ }
42
+ declare module "*.ttf" {
43
+ const src: string;
44
+ export default src;
45
+ }
46
+ declare module "*.otf" {
47
+ const src: string;
48
+ export default src;
49
+ }
50
+ declare module "*.eot" {
51
+ const src: string;
52
+ export default src;
53
+ }
54
+ declare module "*.mp4" {
55
+ const src: string;
56
+ export default src;
57
+ }
58
+ declare module "*.webm" {
59
+ const src: string;
60
+ export default src;
61
+ }
62
+ declare module "*.mov" {
63
+ const src: string;
64
+ export default src;
65
+ }
66
+ declare module "*.mp3" {
67
+ const src: string;
68
+ export default src;
69
+ }
70
+ declare module "*.wav" {
71
+ const src: string;
72
+ export default src;
73
+ }
74
+ declare module "*.pdf" {
75
+ const src: string;
76
+ export default src;
77
+ }
78
+ declare module "*.zip" {
79
+ const src: string;
80
+ export default src;
81
+ }
@@ -4,7 +4,11 @@ const CHECK_CONFIG = {
4
4
  /** Gzipped bytes of a section's renderer bundle, React excluded. */
5
5
  rendererBundleGzip: 50 * 1024,
6
6
  /** Gzipped bytes of a section's stylesheet, including CSS pulled in from packages. */
7
- sectionCssGzip: 50 * 1024
7
+ sectionCssGzip: 50 * 1024,
8
+ /** Gzipped bytes of a section's largest island (browser) bundle, React excluded. */
9
+ islandBundleGzip: 50 * 1024,
10
+ /** Raw bytes of a section's static assets (svg/images/fonts). RAW, not gzip — images/fonts are already compressed. */
11
+ assets: 250 * 1024
8
12
  },
9
13
  audit: {
10
14
  /** A vulnerability at or above this severity fails the check. */
@@ -28,18 +28,30 @@ async function sectionCssFiles(dir) {
28
28
  const rest = found.filter((f) => f !== own).sort();
29
29
  return (existsSync(own) ? [own] : []).concat(rest);
30
30
  }
31
- const CSS_ASSET_LOADERS = {
32
- ".svg": "dataurl",
33
- ".png": "dataurl",
34
- ".jpg": "dataurl",
35
- ".jpeg": "dataurl",
36
- ".gif": "dataurl",
37
- ".webp": "dataurl",
38
- ".woff": "dataurl",
39
- ".woff2": "dataurl",
40
- ".ttf": "dataurl",
41
- ".eot": "dataurl"
31
+ const ASSET_LOADERS = {
32
+ ".svg": "file",
33
+ ".png": "file",
34
+ ".jpg": "file",
35
+ ".jpeg": "file",
36
+ ".gif": "file",
37
+ ".webp": "file",
38
+ ".avif": "file",
39
+ ".ico": "file",
40
+ ".woff": "file",
41
+ ".woff2": "file",
42
+ ".ttf": "file",
43
+ ".otf": "file",
44
+ ".eot": "file",
45
+ ".mp4": "file",
46
+ ".webm": "file",
47
+ ".mov": "file",
48
+ ".mp3": "file",
49
+ ".wav": "file",
50
+ ".pdf": "file",
51
+ ".zip": "file"
42
52
  };
53
+ const ASSET_PUBLIC_PATH = "/section-assets";
54
+ const ASSET_NAMES = "[name]-[hash]";
43
55
  /**
44
56
  * A section's third-party package CSS — discovered by the bundler, not
45
57
  * declared. An author writes `import "swiper/css"` in Renderer.tsx exactly
@@ -75,7 +87,9 @@ async function compileSectionPackageCss(workspaceRoot, sectionDir) {
75
87
  "react-dom",
76
88
  "react/jsx-runtime"
77
89
  ],
78
- loader: { ...CSS_ASSET_LOADERS },
90
+ loader: { ...ASSET_LOADERS },
91
+ publicPath: ASSET_PUBLIC_PATH,
92
+ assetNames: ASSET_NAMES,
79
93
  absWorkingDir: workspaceRoot,
80
94
  logLevel: "silent"
81
95
  })).outputFiles?.find((f) => f.path.endsWith(".css"));
@@ -114,4 +128,4 @@ function findImportLine(css) {
114
128
  }
115
129
 
116
130
  //#endregion
117
- export { CSS_ASSET_LOADERS, compileSectionPackageCss, findImportLine, sectionCssFiles };
131
+ export { ASSET_LOADERS, ASSET_NAMES, ASSET_PUBLIC_PATH, compileSectionPackageCss, findImportLine, sectionCssFiles };
@@ -1,4 +1,5 @@
1
1
  import { loadEsbuild } from "./resolve-tool.js";
2
+ import { ASSET_LOADERS, ASSET_NAMES, ASSET_PUBLIC_PATH } from "./css.js";
2
3
  import { join, relative } from "node:path";
3
4
  import { createHash } from "node:crypto";
4
5
  import { mkdir, rm, stat } from "node:fs/promises";
@@ -121,6 +122,9 @@ async function buildAndLoad(section, outdir, workspaceRoot) {
121
122
  jsx: "automatic",
122
123
  logLevel: "silent",
123
124
  plugins: [cssStub],
125
+ loader: { ...ASSET_LOADERS },
126
+ publicPath: ASSET_PUBLIC_PATH,
127
+ assetNames: ASSET_NAMES,
124
128
  absWorkingDir: workspaceRoot
125
129
  });
126
130
  const load = async (source, emitted) => {
@@ -0,0 +1,58 @@
1
+ import { loadEsbuild } from "./resolve-tool.js";
2
+ import { ASSET_LOADERS, ASSET_NAMES, ASSET_PUBLIC_PATH, sectionCssFiles } from "./css.js";
3
+ import { isUnder } from "./paths.js";
4
+ import { extname, join, resolve } from "node:path";
5
+
6
+ //#region src/cli/check/section-assets.ts
7
+ const ASSET_EXTS = new Set(Object.keys(ASSET_LOADERS));
8
+ async function referencedSectionAssets(workspaceRoot, section) {
9
+ const { build } = await loadEsbuild(workspaceRoot);
10
+ const referenced = /* @__PURE__ */ new Set();
11
+ const collect = (inputs) => {
12
+ for (const key of Object.keys(inputs ?? {})) {
13
+ const abs = resolve(workspaceRoot, key);
14
+ if (ASSET_EXTS.has(extname(abs).toLowerCase()) && isUnder(section.dir, abs)) referenced.add(abs);
15
+ }
16
+ };
17
+ collect((await build({
18
+ entryPoints: [join(section.dir, "Renderer.tsx")],
19
+ bundle: true,
20
+ write: false,
21
+ metafile: true,
22
+ format: "esm",
23
+ platform: "browser",
24
+ jsx: "automatic",
25
+ outdir: "dist",
26
+ external: [
27
+ "react",
28
+ "react-dom",
29
+ "react-dom/server",
30
+ "react/jsx-runtime",
31
+ "react/jsx-dev-runtime"
32
+ ],
33
+ loader: { ...ASSET_LOADERS },
34
+ publicPath: ASSET_PUBLIC_PATH,
35
+ assetNames: ASSET_NAMES,
36
+ absWorkingDir: workspaceRoot,
37
+ logLevel: "silent"
38
+ })).metafile?.inputs);
39
+ for (const cssFile of await sectionCssFiles(section.dir)) collect((await build({
40
+ entryPoints: [cssFile],
41
+ bundle: true,
42
+ write: false,
43
+ metafile: true,
44
+ outdir: "dist",
45
+ loader: {
46
+ ".css": "css",
47
+ ...ASSET_LOADERS
48
+ },
49
+ publicPath: ASSET_PUBLIC_PATH,
50
+ assetNames: ASSET_NAMES,
51
+ absWorkingDir: workspaceRoot,
52
+ logLevel: "silent"
53
+ })).metafile?.inputs);
54
+ return referenced;
55
+ }
56
+
57
+ //#endregion
58
+ export { referencedSectionAssets };
@@ -1,8 +1,9 @@
1
1
  import { loadEsbuild } from "../resolve-tool.js";
2
+ import { ASSET_LOADERS, ASSET_NAMES, ASSET_PUBLIC_PATH, compileSectionPackageCss, sectionCssFiles } from "../css.js";
2
3
  import { CHECK_CONFIG } from "../config.js";
3
- import { compileSectionPackageCss, sectionCssFiles } from "../css.js";
4
+ import { findSectionIslands } from "../../../islands/discover.js";
4
5
  import { existsSync } from "node:fs";
5
- import { join, relative } from "node:path";
6
+ import { basename, join, relative } from "node:path";
6
7
  import { readFile } from "node:fs/promises";
7
8
  import { gzipSync } from "node:zlib";
8
9
 
@@ -104,10 +105,10 @@ async function measureRendererBundle(workspaceRoot, section) {
104
105
  /**
105
106
  * Gzipped bytes of a section's stylesheet: its hand-written CSS
106
107
  * (`sectionCssFiles`, published order) concatenated with the package CSS its
107
- * code pulls in (`compileSectionPackageCss`). The package-CSS half is the
108
- * point of this budget a package's `url()`-referenced fonts and icons
109
- * inline as `data:` URIs (see css.ts), so a package shipping a 300 KB font
110
- * shows up here as CSS bytes, and this is the guard against exactly that.
108
+ * code pulls in (`compileSectionPackageCss`). A package's `url()`-referenced
109
+ * fonts and icons no longer inline into this text they resolve to
110
+ * `/section-assets/` references (see css.ts), and their bytes are measured
111
+ * separately by `measureAssetBytes`, the `size-assets` dimension below.
111
112
  */
112
113
  async function measureSectionCss(workspaceRoot, section) {
113
114
  const ownFiles = await sectionCssFiles(section.dir);
@@ -115,6 +116,101 @@ async function measureSectionCss(workspaceRoot, section) {
115
116
  const packageCss = await compileSectionPackageCss(workspaceRoot, section.dir);
116
117
  return gzipSync(Buffer.from(`${own}\n${packageCss}`)).byteLength;
117
118
  }
119
+ /**
120
+ * Raw bytes of a section's static assets — the fourth size dimension. Fonts and
121
+ * images are already compressed, so this is RAW bytes, not gzip. Two esbuild
122
+ * passes with the file loader capture every asset the published section emits:
123
+ * 1. Renderer.tsx (bundled) — assets imported in JS AND assets url()-referenced
124
+ * by the package CSS the renderer pulls in.
125
+ * 2. each hand-written stylesheet — its @font-face / url() assets (the author's
126
+ * own styles.css is read raw by the CSS budget, never bundled, so its assets
127
+ * surface only here).
128
+ * Deduped by emitted (content-hashed) filename, so an asset referenced twice or
129
+ * from both a stylesheet and JS is counted once.
130
+ */
131
+ async function measureAssetBytes(workspaceRoot, section) {
132
+ const { build } = await loadEsbuild(workspaceRoot);
133
+ const byName = /* @__PURE__ */ new Map();
134
+ const collect = (outputFiles) => {
135
+ for (const f of outputFiles) {
136
+ if (/\.(?:js|css|map)$/i.test(f.path)) continue;
137
+ byName.set(basename(f.path), f.contents.byteLength);
138
+ }
139
+ };
140
+ collect((await build({
141
+ entryPoints: [join(section.dir, "Renderer.tsx")],
142
+ bundle: true,
143
+ write: false,
144
+ format: "esm",
145
+ platform: "browser",
146
+ jsx: "automatic",
147
+ outdir: "dist",
148
+ external: [
149
+ "react",
150
+ "react-dom",
151
+ "react-dom/server",
152
+ "react/jsx-runtime",
153
+ "react/jsx-dev-runtime"
154
+ ],
155
+ loader: { ...ASSET_LOADERS },
156
+ publicPath: "/section-assets",
157
+ assetNames: "[name]-[hash]",
158
+ absWorkingDir: workspaceRoot,
159
+ logLevel: "silent"
160
+ })).outputFiles ?? []);
161
+ for (const cssFile of await sectionCssFiles(section.dir)) collect((await build({
162
+ entryPoints: [cssFile],
163
+ bundle: true,
164
+ write: false,
165
+ outdir: "dist",
166
+ loader: {
167
+ ".css": "css",
168
+ ...ASSET_LOADERS
169
+ },
170
+ publicPath: "/section-assets",
171
+ assetNames: "[name]-[hash]",
172
+ absWorkingDir: workspaceRoot,
173
+ logLevel: "silent"
174
+ })).outputFiles ?? []);
175
+ return [...byName.values()].reduce((sum, n) => sum + n, 0);
176
+ }
177
+ /**
178
+ * Gzipped bytes of a section's largest island (browser) bundle. Each "use client"
179
+ * component is bundled as its own browser entry, React external (the runtime chunk
180
+ * provides it), everything else bundled — the shape ci/static-assets.ts uploads.
181
+ * Returns 0 for a section with no islands. Uses `sizeStub` (React-only external),
182
+ * NOT the loader's cssStub — see the file header.
183
+ */
184
+ async function measureIslandBundle(workspaceRoot, section) {
185
+ const islands = await findSectionIslands(section.dir);
186
+ if (islands.length === 0) return 0;
187
+ const { build } = await loadEsbuild(workspaceRoot);
188
+ let max = 0;
189
+ for (const entry of islands) {
190
+ const js = (await build({
191
+ entryPoints: [entry],
192
+ bundle: true,
193
+ minify: true,
194
+ format: "esm",
195
+ platform: "browser",
196
+ jsx: "automatic",
197
+ write: false,
198
+ outfile: "island.js",
199
+ external: [
200
+ "react",
201
+ "react-dom",
202
+ "react-dom/client",
203
+ "react/jsx-runtime",
204
+ "react/jsx-dev-runtime"
205
+ ],
206
+ plugins: [sizeStub],
207
+ absWorkingDir: workspaceRoot,
208
+ logLevel: "silent"
209
+ })).outputFiles.find((f) => f.path.endsWith(".js"));
210
+ if (js) max = Math.max(max, gzipSync(js.contents).byteLength);
211
+ }
212
+ return max;
213
+ }
118
214
  function kb(bytes) {
119
215
  const value = bytes / 1024;
120
216
  return `${Number.isInteger(value) ? value.toString() : value.toFixed(1)} KB`;
@@ -128,9 +224,13 @@ async function sizeStage(workspaceRoot, template) {
128
224
  for (const section of template.sections) {
129
225
  let rendererBytes;
130
226
  let cssBytes;
227
+ let islandBytes;
228
+ let assetBytes;
131
229
  try {
132
230
  rendererBytes = await measureRendererBundle(workspaceRoot, section);
133
231
  cssBytes = await measureSectionCss(workspaceRoot, section);
232
+ islandBytes = await measureIslandBundle(workspaceRoot, section);
233
+ assetBytes = await measureAssetBytes(workspaceRoot, section);
134
234
  } catch {
135
235
  continue;
136
236
  }
@@ -148,11 +248,27 @@ async function sizeStage(workspaceRoot, template) {
148
248
  section: section.name,
149
249
  file: cssDiagnosticFile(workspaceRoot, section),
150
250
  message: `section stylesheet is ${kb(cssBytes)} gzip — over the ${kb(CHECK_CONFIG.size.sectionCssGzip)} budget`,
151
- fix: "Trim the stylesheet. A package that ships a large font or icon set inlines those bytes into your stylesheet — import only what you use."
251
+ fix: "Trim the stylesheet. Pulling in a package's full 'everything' CSS bundle when you use a few of its modules is the usual cause — import only the modules you use. (A url()-referenced font or icon set is measured separately, under size-assets.)"
252
+ });
253
+ if (islandBytes > CHECK_CONFIG.size.islandBundleGzip) diagnostics.push({
254
+ ruleId: "template-kit/size-island-bundle",
255
+ template: template.key,
256
+ section: section.name,
257
+ file: `templates/${template.key}/sections/${section.name}`,
258
+ message: `an island bundle is ${kb(islandBytes)} gzip — over the ${kb(CHECK_CONFIG.size.islandBundleGzip)} budget`,
259
+ fix: "Trim what the client component pulls in. A whole UI/icon library imported for one widget is the usual cause — import only what you use, or split the island."
260
+ });
261
+ if (assetBytes > CHECK_CONFIG.size.assets) diagnostics.push({
262
+ ruleId: "template-kit/size-assets",
263
+ template: template.key,
264
+ section: section.name,
265
+ file: `templates/${template.key}/sections/${section.name}`,
266
+ message: `static assets are ${kb(assetBytes)} — over the ${kb(CHECK_CONFIG.size.assets)} budget`,
267
+ fix: "Shrink or subset the asset (a full font family or an unoptimized image is the usual cause), or drop assets the section does not use."
152
268
  });
153
269
  }
154
270
  return diagnostics;
155
271
  }
156
272
 
157
273
  //#endregion
158
- export { measureRendererBundle, measureSectionCss, sizeStage };
274
+ export { measureAssetBytes, measureIslandBundle, measureRendererBundle, measureSectionCss, sizeStage };
@@ -1,32 +1,12 @@
1
+ import { ASSET_LOADERS, findImportLine, sectionCssFiles } from "../css.js";
1
2
  import { CONTRACT_FILES, loadSection } from "../loader.js";
2
3
  import { flattenLayout } from "../../../schema/rows.js";
3
- import { findImportLine, sectionCssFiles } from "../css.js";
4
+ import { referencedSectionAssets } from "../section-assets.js";
4
5
  import { extname, join, relative } from "node:path";
5
6
  import { readFile, readdir } from "node:fs/promises";
6
7
 
7
8
  //#region src/cli/check/stages/tree.ts
8
- const ASSET_EXTENSIONS = /* @__PURE__ */ new Set([
9
- ".svg",
10
- ".png",
11
- ".jpg",
12
- ".jpeg",
13
- ".gif",
14
- ".webp",
15
- ".avif",
16
- ".ico",
17
- ".woff",
18
- ".woff2",
19
- ".ttf",
20
- ".otf",
21
- ".eot",
22
- ".mp4",
23
- ".webm",
24
- ".mov",
25
- ".mp3",
26
- ".wav",
27
- ".pdf",
28
- ".zip"
29
- ]);
9
+ const ASSET_EXTENSIONS = new Set(Object.keys(ASSET_LOADERS));
30
10
  async function isBinaryFile(file) {
31
11
  return (await readFile(file)).subarray(0, 4096).includes(0);
32
12
  }
@@ -75,17 +55,35 @@ async function checkBundleAnatomy(workspaceRoot, template, section) {
75
55
  fix: "Every section needs Renderer.tsx, schema.ts, fill-spec.ts and fixtures.ts. Add the missing file."
76
56
  });
77
57
  }
78
- for (const abs of await bundleFiles(section.dir)) {
58
+ return out;
59
+ }
60
+ /** A section-folder file that is an asset by extension or by a NUL-byte sniff. */
61
+ async function sectionAssetFiles(dir) {
62
+ const out = [];
63
+ for (const abs of await bundleFiles(dir)) if (ASSET_EXTENSIONS.has(extname(abs).toLowerCase()) || await isBinaryFile(abs)) out.push(abs);
64
+ return out;
65
+ }
66
+ async function checkOrphanAssets(workspaceRoot, template, section) {
67
+ const candidates = await sectionAssetFiles(section.dir);
68
+ if (candidates.length === 0) return [];
69
+ let referenced;
70
+ try {
71
+ referenced = await referencedSectionAssets(workspaceRoot, section);
72
+ } catch {
73
+ return [];
74
+ }
75
+ const out = [];
76
+ for (const abs of candidates) {
77
+ if (referenced.has(abs)) continue;
79
78
  const rel = relative(workspaceRoot, abs);
80
- if (!ASSET_EXTENSIONS.has(extname(abs).toLowerCase()) && !await isBinaryFile(abs)) continue;
81
79
  out.push({
82
80
  ruleId: "template-kit/bundle-binary-asset",
83
81
  template: template.key,
84
82
  section: section.name,
85
83
  file: rel,
86
84
  line: 1,
87
- message: `Static asset \`${rel}\` has no build meaning in a section bundle yet.`,
88
- fix: "Binary assets are not supported in a section folder yet. Inline the graphic as SVG markup."
85
+ message: `Static asset \`${rel}\` sits in the section folder but nothing references it.`,
86
+ fix: "Reference it (import it in Renderer.tsx, or url() it from the section's CSS), or delete it. An unreferenced binary bloats the section's content hash and is never served."
89
87
  });
90
88
  }
91
89
  return out;
@@ -186,6 +184,7 @@ async function treeStage(workspaceRoot, template) {
186
184
  const diagnostics = [];
187
185
  for (const section of template.sections) {
188
186
  diagnostics.push(...await checkBundleAnatomy(workspaceRoot, template, section));
187
+ diagnostics.push(...await checkOrphanAssets(workspaceRoot, template, section));
189
188
  let loaded;
190
189
  try {
191
190
  loaded = await loadSection(workspaceRoot, section);