Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 36 additions & 3 deletions CLAY-VITE.md
Original file line number Diff line number Diff line change
Expand Up @@ -442,10 +442,42 @@ absent on the next deploy β†’ old path activates. No code changes needed in the
| Aspect | `clay compile` | `clay vite` |
|---|---|---|
| **How scripts are resolved** | `getDependencies()` reads `_registry.json` | `resolveModuleScripts()` reads `_manifest.json` |
| **Edit mode scripts** | All `_deps-*.js` + `_models-*.js` + `_kiln-*.js` + templates | Single `_kiln-edit-init` bundle + templates |
| **Edit mode scripts** | All `_deps-*.js` + `_models-*.js` + `_kiln-*.js` + templates | `vite-bootstrap-no-mount` + `_kiln-edit-init` bundle + templates |
| **View mode scripts** | Numeric IDs β†’ individual dep files | `vite-bootstrap` + `_globals-init` + shared chunks (typically 3–5 files) |
| **Component `client.js` in edit mode** | Never runs β€” the `edit` branch of `getDependencies()` ships no client bundle and no `_client-init.js` | Never runs β€” edit mode loads the no-mount bootstrap (see below) |
| **Global scripts** | Individual files per registry entry (70–100 requests) | All `global/js/*.js` in one `_globals-init.js` (1 request) |

#### Component `client.js` does not run in edit mode

Under `clay compile`, component controllers only ran on rendered pages: the `edit` branch of
`getDependencies()` resolves `model.js`, `kiln.js` and kiln plugins, but neither the client
dependency graph nor `_client-init.js` β€” the runtime that mounts controllers. Editors therefore
never executed component `client.js` inside Kiln.

`clay vite` preserves that. Two bootstrap entries are generated from the same initializer prelude:

| Entry | Loaded in | Contents |
|---|---|---|
| `.clay/vite-bootstrap.js` | view mode | prelude + `_clayClientModules` map + `mountComponentModules()` |
| `.clay/vite-bootstrap-no-mount.js` | edit mode | prelude only |

The prelude (the `window.modules` stub, `_env-init.js`, `_globals-init.js` and the sticky-events
shim) is required in both modes β€” Kiln's preloader reads `window.modules`, and every `model.js`
and kiln plugin reads env through the object `_env-init.js` hydrates. Only the mount runtime is
view-mode-specific, and it runs at module scope, so serving the view bootstrap in edit mode
executes every on-page component's `client.js`. That fires analytics, ad calls (GPT injects an
`<iframe>` per slot), comment embeds and other third-party scripts inside the editing surface,
where they mutate the DOM Kiln is trying to decorate.

There is no option to opt back in. Mounting components in edit mode has no legitimate use β€” it
was never possible under `clay compile`, and a component that needs its `client.js` output visible
while editing should provide it through `kiln.js` rather than by running every ad and analytics
script on the page inside the editor.

If `public/js` was built by a claycli that predates the no-mount entry, `resolveModuleScripts()`
falls back to the mounting bootstrap β€” serving no initializers at all would break Kiln outright,
so a stale manifest degrades rather than fails. Re-run `clay vite` to pick up the fix.

## 6. Configuration

Both commands read the same `claycli.config.js` but use **separate config keys**:
Expand Down Expand Up @@ -652,7 +684,7 @@ RUN if [ "$CLAYCLI_VITE_ENABLED" = "true" ]; then \
| Module | File | Purpose |
|---|---|---|
| Orchestrator | [`lib/cmd/vite/scripts.js`](./lib/cmd/vite/scripts.js) | Main build + watch orchestration; `getViteConfig`, `baseViteConfig`, `buildAll`, `watch` |
| Bootstrap generator | [`lib/cmd/vite/generate-bootstrap.js`](./lib/cmd/vite/generate-bootstrap.js) | Generates `.clay/vite-bootstrap.js` with component mount runtime |
| Bootstrap generator | [`lib/cmd/vite/generate-bootstrap.js`](./lib/cmd/vite/generate-bootstrap.js) | Generates `.clay/vite-bootstrap.js` (with component mount runtime) and `.clay/vite-bootstrap-no-mount.js` (edit mode, prelude only) |
| Globals init generator | [`lib/cmd/vite/generate-globals-init.js`](./lib/cmd/vite/generate-globals-init.js) | Generates `.clay/_globals-init.js` |
| Kiln edit generator | [`lib/cmd/vite/generate-kiln-edit.js`](./lib/cmd/vite/generate-kiln-edit.js) | Generates `.clay/_kiln-edit-init.js` |
| CSS compilation | [`lib/cmd/vite/styles.js`](./lib/cmd/vite/styles.js) | PostCSS pipeline; `buildStyles`, `SRC_GLOBS` |
Expand All @@ -678,7 +710,8 @@ RUN if [ "$CLAYCLI_VITE_ENABLED" = "true" ]; then \
| File | Generated by | Purpose |
|---|---|---|
| `public/js/_manifest.json` | `lib/cmd/vite/scripts.js` (`buildManifest`/`writeManifest`) | Entry β†’ file + chunks map. Replaces `_registry.json` + `_ids.json`. |
| `.clay/vite-bootstrap.js` | `generate-bootstrap.js` | Imports every `client.js`; mounts via dynamic `import()` on DOM presence |
| `.clay/vite-bootstrap.js` | `generate-bootstrap.js` | View mode. Imports every `client.js`; mounts via dynamic `import()` on DOM presence |
| `.clay/vite-bootstrap-no-mount.js` | `generate-bootstrap.js` | Edit mode. Same initializers, no mount runtime β€” component `client.js` never runs in Kiln |
| `.clay/_kiln-edit-init.js` | `generate-kiln-edit.js` | Imports every `model.js` + `kiln.js`; registers on `window.kiln.componentModels` |
| `.clay/_globals-init.js` | `generate-globals-init.js` | Imports all `global/js/*.js` into one non-splitting entry |
| `client-env.json` | `createClientEnvCollector` (Rollup plugin) | JSON array of `process.env.VAR_NAME` identifiers for `amphora-html` |
Expand Down
122 changes: 87 additions & 35 deletions lib/cmd/vite/generate-bootstrap.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ const { generateViteEnvInit } = require('./generate-env-init');
const CWD = process.cwd();
const CLAY_DIR = path.join(CWD, '.clay');
const VITE_BOOTSTRAP_FILE = path.join(CLAY_DIR, 'vite-bootstrap.js');
const VITE_BOOTSTRAP_NO_MOUNT_FILE = path.join(CLAY_DIR, 'vite-bootstrap-no-mount.js');
const GLOBALS_INIT_FILE = path.join(CLAY_DIR, '_globals-init.js');

const VITE_BOOTSTRAP_KEY = '.clay/vite-bootstrap';
const VITE_BOOTSTRAP_NO_MOUNT_KEY = '.clay/vite-bootstrap-no-mount';

/**
* Component mount runtime injected into the bootstrap.
Expand Down Expand Up @@ -204,37 +206,17 @@ mountComponentModules().catch(console.error);
`;

/**
* Generate .clay/vite-bootstrap.js β€” the single ESM entry point for view mode.
* Build the initializer prelude shared by both bootstrap variants.
*
* Contains:
* 1. Static import of _globals-init.js (runs synchronously before any
* component dynamic import, ensuring window.DS etc. are available).
* 2. Sticky custom-event shim (when stickyEvents is configured).
* 3. _clayClientModules map β€” one lazy import() per component/layout client.js.
* 4. mountComponentModules() runtime β€” scans DOM and mounts components.
* Everything in here has to run in edit mode as well as view mode: kiln's
* preloader reads `window.modules`, and kiln plugins / model.js files read env
* through the object _env-init.js hydrates. Component mounting is the only
* view-mode-specific part of the bootstrap, which is why it lives in
* MOUNT_RUNTIME rather than here.
*
* @returns {Promise<string>} absolute path to the written bootstrap file
* @returns {Promise<string[]>} content lines, in evaluation order
*/
async function generateViteBootstrap() {
await generateViteEnvInit();

const clientFiles = [
...globSync(path.join(CWD, 'components', '**', 'client.js')),
...globSync(path.join(CWD, 'layouts', '**', 'client.js')),
];

const toRel = absPath => {
const rel = path.relative(CLAY_DIR, absPath).replace(/\\/g, '/');

return rel.startsWith('.') ? rel : `./${rel}`;
};

const moduleEntries = clientFiles.map(f => {
const key = path.relative(CWD, f).replace(/\\/g, '/');

return ` ${JSON.stringify(key)}: () => import(${JSON.stringify(toRel(f))})`;
}).join(',\n');

async function buildInitPrelude() {
// ── Sticky events shim ───────────────────────────────────────────────────
const stickyEvents = getConfigValue('stickyEvents') || [];
const stickyListeners = stickyEvents
Expand Down Expand Up @@ -279,33 +261,103 @@ ${stickyListeners}
// this path but pay no cost for the empty object.
const kilnCompatStub = 'window.modules = window.modules || {};\n';

return [kilnCompatStub, envInitImport, globalsImport, stickyShimBlock];
}

/**
* Generate the two ESM bootstrap entry points.
*
* .clay/vite-bootstrap.js β€” view mode. Contains:
* 1. The initializer prelude (see buildInitPrelude).
* 2. _clayClientModules map β€” one lazy import() per component/layout client.js.
* 3. mountComponentModules() runtime β€” scans DOM and mounts components.
*
* .clay/vite-bootstrap-no-mount.js β€” edit mode. The prelude only.
*
* ── Why a second entry exists ────────────────────────────────────────────────
*
* The legacy `clay compile` pipeline resolved component client.js (and
* _client-init.js, which mounts it) for VIEW mode only β€” see the `edit` branch
* of getDependencies() in lib/cmd/compile/get-script-dependencies.js, which
* ships model.js, kiln.js and kiln plugins but no client bundle. Editors
* therefore never ran component controllers while in Kiln.
*
* Serving the view bootstrap in edit mode broke that contract: it calls
* mountComponentModules() at module scope, so every component's client.js
* executes as soon as the module evaluates. That runs analytics, ad calls
* (GPT injects an <iframe> per slot), comment embeds and other third-party
* scripts inside the editing surface, where they mutate the DOM that Kiln is
* trying to decorate and edit.
*
* Edit mode still needs the prelude, so the fix is a second entry rather than
* simply dropping the bootstrap: same initializers, no mounting.
*
* @returns {Promise<string>} absolute path to the written view bootstrap file
*/
async function generateViteBootstrap() {
await generateViteEnvInit();

const prelude = await buildInitPrelude();

const clientFiles = [
...globSync(path.join(CWD, 'components', '**', 'client.js')),
...globSync(path.join(CWD, 'layouts', '**', 'client.js')),
];

const toRel = absPath => {
const rel = path.relative(CLAY_DIR, absPath).replace(/\\/g, '/');

return rel.startsWith('.') ? rel : `./${rel}`;
};

const moduleEntries = clientFiles.map(f => {
const key = path.relative(CWD, f).replace(/\\/g, '/');

return ` ${JSON.stringify(key)}: () => import(${JSON.stringify(toRel(f))})`;
}).join(',\n');

const timestamp = new Date().toISOString();

const content = [
'// AUTO-GENERATED β€” clay vite bootstrap (do not edit)',
`// ${new Date().toISOString()}`,
'// This file is the single ESM entry point injected into every page via',
`// ${timestamp}`,
'// This file is the ESM entry point injected into view-mode pages via',
'// <script type="module">. It runs the global initializers synchronously,',
'// then scans the DOM and dynamically imports only the component modules',
'// present on the current page β€” keeping initial parse cost low.',
'',
kilnCompatStub,
envInitImport,
globalsImport,
stickyShimBlock,
...prelude,
'const _clayClientModules = {',
moduleEntries,
'};',
'',
MOUNT_RUNTIME,
].join('\n');

const noMountContent = [
'// AUTO-GENERATED β€” clay vite bootstrap, no-mount variant (do not edit)',
`// ${timestamp}`,
'// This file is the ESM entry point injected into EDIT-mode pages. It runs',
'// the same global initializers as vite-bootstrap.js but omits the',
'// component-mounting runtime, so component client.js files do not execute',
'// while an editor is in Kiln β€” matching the legacy clay compile pipeline.',
'',
...prelude,
].join('\n');

await fs.ensureDir(CLAY_DIR);
await fs.writeFile(VITE_BOOTSTRAP_FILE, content, 'utf8');
await Promise.all([
fs.writeFile(VITE_BOOTSTRAP_FILE, content, 'utf8'),
fs.writeFile(VITE_BOOTSTRAP_NO_MOUNT_FILE, noMountContent, 'utf8'),
]);

return VITE_BOOTSTRAP_FILE;
}

module.exports = {
generateViteBootstrap,
VITE_BOOTSTRAP_FILE,
VITE_BOOTSTRAP_NO_MOUNT_FILE,
VITE_BOOTSTRAP_KEY,
VITE_BOOTSTRAP_NO_MOUNT_KEY,
};
39 changes: 39 additions & 0 deletions lib/cmd/vite/generators.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,45 @@ describe('generate-vite env/bootstrap/kiln generators', () => {
expect(content).toContain('// no global/js β€” skipping _globals-init');
expect(content).not.toContain('clayViteStickyEvents');
});

it('writes a no-mount variant with the same initializers but no mount runtime', async () => {
await setupTmpDir('claycli-vite-bootstrap-no-mount-');

await fs.ensureDir(path.join(tmpDir, '.clay'));
await fs.writeFile(path.join(tmpDir, '.clay', '_globals-init.js'), '// globals');
await fs.ensureDir(path.join(tmpDir, 'components', 'article'));
await fs.writeFile(path.join(tmpDir, 'components', 'article', 'client.js'), 'module.exports = function() {};');

jest.doMock('./generate-env-init', () => ({
generateViteEnvInit: jest.fn().mockResolvedValue(path.join(tmpDir, '.clay', '_env-init.js')),
}));
jest.doMock('../../config-file-helpers', () => ({
getConfigValue: jest.fn().mockReturnValue(['auth:init']),
}));

const {
generateViteBootstrap,
VITE_BOOTSTRAP_NO_MOUNT_FILE,
VITE_BOOTSTRAP_NO_MOUNT_KEY,
} = require('./generate-bootstrap');

await generateViteBootstrap();
const content = await fs.readFile(VITE_BOOTSTRAP_NO_MOUNT_FILE, 'utf8');

expect(VITE_BOOTSTRAP_NO_MOUNT_KEY).toBe('.clay/vite-bootstrap-no-mount');

// Kiln's preloader reads window.modules, and every model.js / kiln.js env
// read goes through _env-init.js, so edit mode still needs the prelude.
expect(content).toContain("import './_env-init.js';");
expect(content).toContain("import './_globals-init.js';");
expect(content).toContain('window.modules = window.modules || {};');
expect(content).toContain('fired["auth:init"] = ev.detail;');

// The point of the variant: no component client.js runs in edit mode.
expect(content).not.toContain('mountComponentModules');
expect(content).not.toContain('_clayClientModules');
expect(content).not.toContain('components/article/client.js');
});
});

describe('generate-kiln-edit', () => {
Expand Down
Loading
Loading