diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 81188d0c..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,144 +0,0 @@ ---- -version: 2 - -references: - - filter_all: &filter_all - filters: - branches: - only: /.*/ - tags: - only: /.*/ - - filter_stg: &filter_head - filters: - branches: - only: master - tags: - only: stg - - filter_prd: &filter_release - filters: - branches: - ignore: /.*/ - tags: - only: /v[0-9]+\.[0-9]+\.[0-9]+(-[0-9]+)?/ - -jobs: - - test_node10: - docker: - - image: circleci/node:10 - working_directory: ~/src - steps: - - checkout - - restore_cache: - keys: - - v2-node10-dependencies-{{ checksum "package.json" }} - - v2-node10-dependencies- - - run: npm install - - save_cache: - key: v2-node10-dependencies-{{ checksum "package.json" }} - paths: - - node_modules - - run: npm test - - test_node12: - docker: - - image: circleci/node:12 - working_directory: ~/src - steps: - - checkout - - restore_cache: - keys: - - v2-node12-dependencies-{{ checksum "package.json" }} - - v2-node12-dependencies- - - run: npm install - - save_cache: - key: v2-node12-dependencies-{{ checksum "package.json" }} - paths: - - node_modules - - run: npm test - - run: cat coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js - - test_node14: - docker: - - image: circleci/node:14 - working_directory: ~/src - steps: - - checkout - - restore_cache: - keys: - - v2-node14-dependencies-{{ checksum "package.json" }} - - v2-node14-dependencies- - - run: npm install - - save_cache: - key: v2-node14-dependencies-{{ checksum "package.json" }} - paths: - - node_modules - - run: npm test - - deploy_docs: - docker: - - image: circleci/node:14 - working_directory: ~/src - steps: - - checkout - - restore_cache: - key: v1-website-dependencies-{{ checksum "website/package.json" }} - - run: - name: Build - command: | - sudo apt-get -y install awscli - bash ./.circleci/scripts/deploy-docs.sh - - save_cache: - key: v1-website-dependencies-{{ checksum "website/package.json" }} - paths: - - website/node_modules - - deploy_package: - docker: - - image: circleci/node:12 - working_directory: ~/repo - steps: - - checkout - - restore_cache: - keys: - - v2-node12-dependencies-{{ checksum "package.json" }} - - v2-node12-dependencies- - - run: npm install - - run: | - echo "$NPMRC" > ~/.npmrc - chmod 600 ~/.npmrc - if [[ "$CIRCLE_TAG" = *-* ]]; then - npm publish --tag=prerelease - else - npm publish - fi - -workflows: - version: 2 - test: - jobs: - - test_node10: - <<: *filter_all - - test_node12: - <<: *filter_all - - test_node14: - <<: *filter_all - - deploy_docs: - <<: *filter_head - context: - - Documentation - requires: - - test_node10 - - test_node12 - - test_node14 - - deploy_package: - <<: *filter_release - context: - - npm-publish - requires: - - test_node10 - - test_node12 - - test_node14 diff --git a/.circleci/scripts/deploy-docs.sh b/.circleci/scripts/deploy-docs.sh deleted file mode 100644 index 85fd8a85..00000000 --- a/.circleci/scripts/deploy-docs.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/bin/bash -set -e -o pipefail - -BUCKET=docs.clayplatform.io - -green(){ - printf "\e[32m$1\e[39m\n" -} - -red(){ - >&2 printf "\e[31m$1\e[39m\n" -} - -export PROJECT_NAME="${PROJECT_NAME:-$CIRCLE_PROJECT_REPONAME}" -if [[ -z "$PROJECT_NAME" ]]; then - red "PROJECT_NAME not set and could not be derived from CIRCLE_PROJECT_REPONAME" - exit 1 -fi -green "PROJECT_NAME: $PROJECT_NAME" - -export BUILD_DIR="${BUILD_DIR:-website}" -green "BUILD_DIR: $BUILD_DIR" - -green "Building documentation..." -cd "$BUILD_DIR" -npm install --quiet -npm run build - -green "Uploading documentation to $BUCKET..." -aws s3 sync --delete --acl=public-read "build/$PROJECT_NAME/" "s3://$BUCKET/$PROJECT_NAME/" - -green "Documentation updated." diff --git a/.circleci/scripts/release.sh b/.circleci/scripts/release.sh deleted file mode 100755 index 9f04adf2..00000000 --- a/.circleci/scripts/release.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/bash - -# This script will increment the package version in package.json. -# It will then tag the HEAD of the master branch with the version number -# and push the tag to the origin (GitHub) - -set -e - -OPTIONS="(prepatch|patch|preminor|minor|premajor|major)" -USAGE="usage: npm release $OPTIONS" -SEMVAR="$1" - -# Releases should only be cut from the master branch. -# They can be manually cut from other branches, but that should be a very -# intentional process. -BRANCH="$(git rev-parse --abbrev-ref HEAD)" -if [[ "$BRANCH" != "master" ]]; then - >&2 echo "ERROR: Not on the master branch, will not release." - exit 1 -fi - -case $SEMVAR in - minor) - ;; - major) - ;; - patch) - ;; - premajor) - ;; - preminor) - ;; - prepatch) - ;; - *) - SEMVAR=prepatch - >&2 echo "WARNING: No $OPTIONS provided, defaulting to PREPATCH." - >&2 echo "$USAGE" - ;; -esac - -git pull --rebase origin master -version="$(npm version $SEMVAR)" -git push origin master -git push origin "tags/$version" diff --git a/.github/main.workflow b/.github/main.workflow deleted file mode 100644 index f982d661..00000000 --- a/.github/main.workflow +++ /dev/null @@ -1,28 +0,0 @@ -workflow "Deploy to GitHub Pages" { - on = "push" - resolves = ["Build and push docs"] -} - -action "Filter branch" { - uses = "actions/bin/filter/@master" - args = "branch master" -} - -action "Install" { - needs = ["Filter branch"] - uses = "actions/npm@master" - args = "install --prefix ./website" -} - -action "Update version" { - needs = ["Install"] - uses = "clay/docusaurus-github-action@master" - args = "version" -} - -action "Build and push docs" { - needs = ["Update version"] - uses = "clay/docusaurus-github-action@master" - args = "deploy" - secrets = ["DEPLOY_SSH_KEY", "ALGOLIA_API_KEY"] -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..1e3f8624 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: ['**'] + pull_request: + branches: ['**'] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [20.x] + + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run tests + run: npm test diff --git a/.gitignore b/.gitignore index a4e323dc..a6a10357 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ website/build/ website/yarn.lock website/node_modules website/i18n/* +*.tgz diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/CLAY-BUILD.md b/CLAY-BUILD.md new file mode 100644 index 00000000..c7f21fd7 --- /dev/null +++ b/CLAY-BUILD.md @@ -0,0 +1,1496 @@ +# clay build — New Asset Pipeline + +> This document covers the **`clay build`** command introduced in claycli 5.1. It explains what changed from the legacy `clay compile` command, why, how they compare, and how to run both side-by-side. + +## Table of Contents + +1. [Why We Changed It](#1-why-we-changed-it) +2. [Commands At a Glance](#2-commands-at-a-glance) +3. [Architecture: Old vs New](#3-architecture-old-vs-new) +4. [Pipeline Comparison Diagrams](#4-pipeline-comparison-diagrams) +5. [Feature-by-Feature Comparison](#5-feature-by-feature-comparison) +6. [Configuration](#6-configuration) +7. [Running Both Side-by-Side](#7-running-both-side-by-side) +8. [Code References](#8-code-references) +9. [Performance](#9-performance) +10. [Learning Curve](#10-learning-curve) +11. [For Product Managers](#11-for-product-managers) +12. [Tests](#12-tests) +13. [Migration Guide](#13-migration-guide) _(includes optional per-site rollout strategy)_ +14. [amphora-html Changes](#14-amphora-html-changes) +15. [Bundler Comparison: esbuild vs Webpack vs Vite](#15-bundler-comparison-esbuild-vs-webpack-vs-vite) + +## 1. Why We Changed It + +The legacy `clay compile` pipeline was built on **Browserify + Gulp**, tools designed for the 2014–2018 JavaScript ecosystem. Over time these became pain points: + +| Problem | Impact | +|---|---| +| Browserify megabundle (all components in one file per alpha-bucket) | Any change = full rebuild of all component JS, slow watch mode | +| Gulp orchestration with 20+ plugins | Complex dependency chain, hard to debug, slow npm install | +| Sequential compilation steps | CSS, JS, templates all ran in series — total time = sum of all steps | +| No shared chunk extraction | If two components shared a dependency, each dragged it in separately via the Browserify registry | +| No tree shaking | Browserify bundled entire CJS modules regardless of how much was used; no support for ESM dependency tree shaking | +| No source maps | Build errors in production pointed to minified line numbers, not source | +| No content-hashed filenames | Static filenames (`article.client.js`) forced full cache invalidation on every deploy | +| Babelify transpilation overhead | Slow even on small changes | +| `_registry.json` + `_ids.json` numeric module graph | Opaque, hard to inspect or extend | +| `_prelude.js` / `_postlude.js` custom runtime | Browserify's own module system loaded on every page, adding baseline overhead | +| `browserify-cache.json` stale cache risk | Corrupted/out-of-sync cache produced builds where old module code was silently served | +| 20+ npm dependencies just for bundling | Large attack surface, slow installs, difficult version management | + +The new `clay build` pipeline replaces Browserify/Gulp with **esbuild + PostCSS 8**: + +- esbuild bundles JS/Vue in **milliseconds** (not seconds) with native code-splitting and tree shaking for ESM dependencies +- PostCSS 8's programmatic API replaces Gulp's stream-based CSS pipeline +- All build steps (JS, CSS, fonts, templates, vendor, media) run **in parallel** +- A human-readable `_manifest.json` replaces the numeric `_registry.json`/`_ids.json` pair +- Watch mode starts instantly — no initial build, only rebuilds what changed +- **Source maps** generated automatically — errors point to exact source file, line, and column +- **Content-hashed filenames** (`article/client-A1B2C3.js`) — browsers and CDNs cache files forever; only changed files get new URLs on deploy +- **Native ESM** output — no custom `window.require()` runtime, browsers handle imports natively +- **Build-time `process.env.NODE_ENV`** — dead branches like `if (process.env.NODE_ENV !== 'production')` are eliminated at compile time, not runtime +- Dependency footprint reduced from 20+ bundler packages to a handful + +## 2. Commands At a Glance + +Both commands co-exist. You choose which pipeline to use. + +### Legacy pipeline (Browserify + Gulp) + +```bash +# One-shot compile +clay compile + +# Watch mode +clay compile --watch +``` + +### New pipeline (esbuild + PostCSS 8) + +```bash +# One-shot build +clay build + +# Aliases (backward-compatible) +clay b +clay pn # ← kept so existing Makefiles don't break +clay pack-next # ← kept for the same reason + +# Watch mode +clay build --watch + +# Minified production build +clay build --minify +``` + +Both commands read **`claycli.config.js`** in the root of your Clay instance, but they look at **different config keys** so they never conflict (see [Configuration](#6-configuration)). + +## 3. Architecture: Old vs New + +### Old: `clay compile` (Browserify + Gulp) + +``` +clay compile +│ +├── scripts.js ← Browserify megabundler +│ ├── Each component client.js → {name}.client.js (individual file) +│ ├── Each component model.js → {name}.model.js + _models-{a-d}.js (bucket in minified mode) +│ ├── Each component kiln.js → {name}.kiln.js + _kiln-{a-d}.js (bucket in minified mode) +│ ├── Shared deps → {number}.js + _deps-{a-d}.js (bucket in minified mode) +│ ├── _prelude.js / _postlude.js ← Browserify custom module runtime (window.require, window.modules) +│ ├── _registry.json ← numeric module ID graph (e.g. { "12": ["4","7"] }) +│ ├── _ids.json ← module ID to filename map +│ └── _client-init.js ← runtime that calls window.require() on each .client module +│ +├── styles.js ← Gulp + PostCSS 7 +│ └── styleguides/**/*.css → public/css/{component}.{styleguide}.css +│ +├── templates.js← Gulp + Handlebars precompile +│ └── components/**/template.hbs → public/js/*.template.js +│ +├── fonts.js ← Gulp copy + CSS concat +│ └── styleguides/*/fonts/* → public/fonts/ + public/css/_linked-fonts.*.css +│ +└── media.js ← Gulp copy + └── components/**/media/* → public/media/ +``` + +**Key runtime behaviour:** `getDependencies()` in view mode walks `_registry.json` for only the components amphora placed on the page — it is page-specific. `_client-init.js` then calls `window.require(key)` for every `.client` key in `window.modules`, which is populated only by the scripts that were served. The subtle issue is that it mounts every loaded `.client` module regardless of whether that component's DOM element is actually present on the page. + +### New: `clay build` (esbuild + PostCSS 8) + +``` +clay build +│ +├── scripts.js ← esbuild (JS + Vue SFCs, code-split) +│ ├── Entry points: every components/**/client.js, model.js, kiln.js +│ │ (global/js/*.js excluded — bundled into _globals-init) +│ ├── Code-split chunks: shared dependencies extracted automatically +│ ├── _manifest.json ← human-readable entry→file+chunks map +│ ├── .clay/_view-init.js ← generated bootstrap (mounts components, sticky events) +│ ├── .clay/_kiln-edit-init.js ← generated edit-mode aggregator (models + kiln.js) +│ │ built with splitting:false — single self-contained file +│ └── .clay/_globals-init.js ← generated globals bundle (all global/js/*.js) +│ built with splitting:false — single self-contained file, +│ avoids the 70-100 tiny chunks esbuild would otherwise produce +│ +├── styles.js ← PostCSS 8 programmatic API (parallel, p-limit 50) +│ └── styleguides/**/*.css → public/css/{component}.{styleguide}.css +│ +├── templates.js← Handlebars precompile (parallel, p-limit 20, progress-tracked) +│ └── components/**/template.hbs → public/js/*.template.js +│ +├── fonts.js ← fs-extra copy + CSS concat +│ └── styleguides/*/fonts/* → public/fonts/ + public/css/_linked-fonts.*.css +│ +├── vendor.js ← fs-extra copy +│ └── clay-kiln/dist/*.js → public/js/ +│ +├── media.js ← fs-extra copy +│ ├── components/**/media/* → public/media/components/ +│ ├── layouts/**/media/* → public/media/layouts/ +│ ├── styleguides/**/media/* → public/media/styleguides/ +│ └── sites/**/media/* → public/media/sites/ ← site-level SVGs, logos, etc. +│ +└── client-env.json ← generated by generateClientEnv() + └── scans source files for process.env.VAR references → client-env.json + (required by amphora-html's addEnvVars() at render time) +``` + +**Key runtime behaviour:** `_view-init.js` loads a component's `client.js` **only when that component's element exists in the DOM**. When `stickyEvents` is configured, a sticky-event shim ensures those events are received even by late subscribers. + +## 4. Pipeline Comparison Diagrams + +Both pipelines share the same source files and produce the same `public/` output. The differences are in *how* the steps are wired together, *how* the JS module system works at runtime, and *how* scripts are resolved and served per page. + +### 4a. Build step execution (what runs and in what order) + +The most immediately visible difference: sequential vs parallel execution. + +**🕐 Legacy — `clay compile` (Browserify + Gulp, ~90s)** + +```mermaid +%%{init: {'flowchart': {'nodeSpacing': 60, 'rankSpacing': 70, 'padding': 20}}}%% +flowchart LR + SRC(["📁 Source Files"]):::src + + L1["📦 JS Bundle
Browserify + Babel
30–60 s"]:::slow + L2["🎨 CSS
Gulp + PostCSS 7
15–30 s"]:::slow + L3["📄 Templates
Gulp + Handlebars
10–20 s"]:::med + L4["🔤 Fonts + 🖼 Media
Gulp copy · 2–5 s"]:::fast + + OUT(["📂 public/"]):::out + + SRC --> L1 -->|"waits"| L2 -->|"waits"| L3 -->|"waits"| L4 --> OUT + + classDef src fill:#1e293b,color:#94a3b8,stroke:#334155 + classDef out fill:#1e293b,color:#94a3b8,stroke:#334155 + classDef slow fill:#7f1d1d,color:#fca5a5,stroke:#991b1b + classDef med fill:#78350f,color:#fcd34d,stroke:#92400e + classDef fast fill:#14532d,color:#86efac,stroke:#166534 +``` + +**⚡ New — `clay build` (esbuild + PostCSS 8, ~33s)** + +```mermaid +%%{init: {'flowchart': {'nodeSpacing': 60, 'rankSpacing': 70, 'padding': 20}}}%% +flowchart LR + SRC(["📁 Source Files"]):::src + + N0["🖼 Media
fs-extra · ~0.7 s"]:::fast + N1["📦 JS + Vue
esbuild · ~3 s"]:::vfast + N2["🎨 CSS
PostCSS 8 · ~32 s"]:::slow + N3["📄 Templates
Handlebars · ~16 s"]:::med + N4["🔤 Fonts + 📚 Vendor
fs-extra · ~1 s"]:::fast + + OUT(["📂 public/"]):::out + + SRC --> N0 -->|"all at once"| N1 & N2 & N3 & N4 --> OUT + + classDef src fill:#1e293b,color:#94a3b8,stroke:#334155 + classDef out fill:#1e293b,color:#94a3b8,stroke:#334155 + classDef slow fill:#7f1d1d,color:#fca5a5,stroke:#991b1b + classDef med fill:#78350f,color:#fcd34d,stroke:#92400e + classDef fast fill:#14532d,color:#86efac,stroke:#166534 + classDef vfast fill:#052e16,color:#4ade80,stroke:#166534 +``` + +**Color guide:** 🔴 slow (>15s) · 🟡 medium (10–20s) · 🟢 fast (<5s) · 🌿 very fast (<3s) + +| | `clay compile` | `clay build` | Δ | +|---|---|---|---| +| **Total time** | ~60–120s | ~33s | **~2–3× faster** | +| **Execution** | Sequential — each step waits for the one before it | Parallel — all steps run simultaneously after media | ⚠️ Different shape; same end result | +| **JS tool** | Browserify + Babel (megabundles) | esbuild (code-split per component) | 🔄 Replaced; esbuild is ~10–20× faster than Browserify | +| **CSS tool** | Gulp + PostCSS 7 | PostCSS 8 programmatic API | 🔄 Replaced; same PostCSS plugin ecosystem, newer API | +| **Module graph** | `_registry.json` + `_ids.json` | `_manifest.json` (human-readable) | ⚠️ Different format; same purpose (maps components → files) | +| **Component loader** | `_client-init.js` — mounts every loaded `.client` module, even if its DOM element is absent | `.clay/_view-init.js` — mounts only components whose DOM element is present | ✅ Better; avoids executing component code when the component isn't on the page | +| **JS output** | Per-component files + individual dep files, page-scoped via registry walk | Per-component files + `chunks/` (shared deps extracted once) | ✅ Better; shared deps are downloaded once even when multiple components use them | + +### 4b. JS module system architecture (the core architectural shift) + +This is the diagram that explains *why* so many other things had to change. The entire difference in `resolve-media.js`, `_view-init`, `_kiln-edit-init`, and `_globals-init` flows from this single architectural difference. + +**🕐 Legacy — `clay compile` (Browserify runtime module registry)** + +```mermaid +%%{init: {'flowchart': {'nodeSpacing': 60, 'rankSpacing': 70, 'padding': 20}}}%% +flowchart TB + OS["Source files
components/**/client.js · model.js · kiln.js
global/js/*.js"]:::src + + OB["Browserify megabundler + Babel
_prelude.js / _postlude.js
custom window.require runtime"]:::tool + + OR["_registry.json — numeric dep graph
_ids.json — module ID → filename map"]:::artifact + + OI["_client-init.js
calls window.require(key) for every .client
regardless of DOM presence"]:::loader + + OG["_deps-a.js _deps-b.js … (alpha-bucketed shared deps)
_models-a.js _kiln-a.js … (alpha-bucketed edit files)"]:::output + + OS -->|"one big bundle per alpha bucket"| OB + OB -->|"writes"| OR + OB -->|"generates"| OI + OB -->|"outputs"| OG + + classDef src fill:#1e3a5f,color:#93c5fd,stroke:#1d4ed8 + classDef tool fill:#3b1f6e,color:#c4b5fd,stroke:#7c3aed + classDef artifact fill:#422006,color:#fcd34d,stroke:#b45309 + classDef loader fill:#1c2b4a,color:#93c5fd,stroke:#2563eb + classDef output fill:#14532d,color:#86efac,stroke:#166534 +``` + +**⚡ New — `clay build` (esbuild static module graph)** + +```mermaid +%%{init: {'flowchart': {'nodeSpacing': 60, 'rankSpacing': 70, 'padding': 20}}}%% +flowchart TB + NS["Source files
components/**/client.js · model.js · kiln.js
global/js/*.js"]:::src + + NG["Pre-generated entry points
.clay/_view-init.js
.clay/_kiln-edit-init.js
.clay/_globals-init.js"]:::gen + + NE["esbuild — native code splitting
no transpile · no runtime registry
ESM import/export wiring"]:::tool + + NM["_manifest.json
{ 'components/article/client':
{ file: 'client-A1B2.js', imports: ['chunks/shared-C3D4.js'] } }"]:::artifact + + NV["_view-init-[hash].js
mounts client.js via dynamic import()
only when the component's DOM element exists"]:::loader + + NK["_kiln-edit-init-[hash].js
registers all model.js + kiln.js
on window.kiln.componentModels
splitting:false — single self-contained file"]:::output + + NGL["_globals-init-[hash].js
all global/js/*.js in one file
splitting:false — 1 request instead of 70–100"]:::output + + NC["public/js/chunks/
content-hashed shared chunks
cacheable forever"]:::output + + NS -->|"entry points"| NG + NG -->|"feeds"| NE + NE -->|"writes"| NM + NE -->|"outputs"| NV + NE -->|"outputs"| NK + NE -->|"outputs"| NGL + NE -->|"extracts shared deps"| NC + + classDef src fill:#1e3a5f,color:#93c5fd,stroke:#1d4ed8 + classDef gen fill:#1f3b2a,color:#6ee7b7,stroke:#059669 + classDef tool fill:#3b1f6e,color:#c4b5fd,stroke:#7c3aed + classDef artifact fill:#422006,color:#fcd34d,stroke:#b45309 + classDef loader fill:#1c2b4a,color:#93c5fd,stroke:#2563eb + classDef output fill:#14532d,color:#86efac,stroke:#166534 +``` + +**🔁 Same in both pipelines:** CSS (PostCSS plugins → `public/css/`) · Templates (Handlebars precompile → `public/js/*.template.js`) · Fonts (copy + concat → `public/fonts/`) · Media (copy → `public/media/`) + +**What this diagram shows:** + +| Concern | `clay compile` | `clay build` | Why it matters | +|---|---|---|---| +| **Module registry** | Runtime: `window.modules` populated as scripts evaluate on every page load | Build-time: `_manifest.json` written once; no runtime registry | Old: any file could call `window.require('components/article/model')` at any time. New: wiring is static — esbuild traces it at build time. | +| **Component mounting** | `_client-init.js` calls `window.require()` for every `.client` module whose script was served — runs regardless of DOM | `_view-init.js` scans the DOM first; only `import()`s a component if its element exists | New: component code never runs for components not on the page | +| **Shared dependency handling** | Alpha-bucketed dep files (`_deps-a.js`…) — all or nothing per bucket, static filenames | Named content-hashed chunks in `public/js/chunks/` — exact code extracted by the static graph | New: unchanged shared chunks stay cached across deploys | +| **Edit mode aggregator** | `window.modules` was the aggregator — clay-kiln called `window.require('components/article/model')` at any time | `_kiln-edit-init.js` pre-registers all model/kiln files on `window.kiln.componentModels` at page load; also shims `window.modules = window.modules \|\| {}` for clay-kiln compatibility | New: explicit pre-wiring replaces implicit runtime lookup; `window.modules` shim ensures any published clay-kiln version works without modification | +| **Global scripts** | Individual `