From cb7a7a1fabdc0f8a6c71734abd34017f06305b3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Sat, 31 Oct 2026 22:40:20 +0100 Subject: [PATCH 1/3] Loader (cms-client.js), submodule-aware editor source path, build:watch Co-Authored-By: Claude Sonnet 5.5 --- .gitignore | 1 + README.md | 15 +++++++++++--- package.json | 3 ++- src/loader-core.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++ src/loader.ts | 8 ++++++++ test/loader.test.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 8 +++++++- vite.config.ts | 32 +++++++++++++++++++++++++++-- 8 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 src/loader-core.ts create mode 100644 src/loader.ts create mode 100644 test/loader.test.ts diff --git a/.gitignore b/.gitignore index b1640b5..359d8f5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules dist .env .env.local +.cms-editor/ diff --git a/README.md b/README.md index ff10859..9fbaa32 100644 --- a/README.md +++ b/README.md @@ -146,9 +146,18 @@ schema contract: [docs/styling.md](docs/styling.md). ## Deploying -Serve `dist/onpage-editor.js` at `/editor/onpage-editor.js` and let the existing loader do -`if (document.cookie.includes('cms_preview=1')) import('/editor/onpage-editor.js')`. The bundle -re-checks the cookie, so loading it for a visitor is a no-op. +Part of the ecommerce monorepo as the `cms-onpageeditor` submodule. Each instance image (`tornberg`, `bygglagret`, +`k6n`) and `Dockerfile.nginx` build it in an `onpage` stage (named context `onpageeditor`) and ship two files in `/editor/`: + +- `cms-client.js` - the tiny loader (`src/loader-core.ts`, ~0.5 kB gz) that page templates already reference. It replaces the old + hand-written `cms-client` in every instance. With the `cms_preview=1` cookie it imports `onpage-editor.js` from the same directory; + otherwise it does nothing. +- `onpage-editor.js` - the editor. + +Entering/leaving edit mode: open any page with `?cms_preview=1` (sets the cookie, strips the param, reloads so the edge serves the +live render) and `?cms_preview=0` to leave. Dev: `make dev` rebuilds both into the instance's `public/` (`npm run build:watch`, +`CMS_ONPAGE_OUT`). The shared `@k6n/cms-editor` sources are found at `../backoffice/web/packages/cms-editor/src` (submodule layout), +`../ecommerce/...` (standalone checkout), `.cms-editor/` (Docker) or `CMS_EDITOR_SRC`. ## Not done / next diff --git a/package.json b/package.json index d4bdbd2..225c79a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,8 @@ "scripts": { "dev": "vite", "dev:mock": "CMS_MODE=mock vite", - "build": "tsc --noEmit && vite build --mode lib", + "build": "tsc --noEmit && vite build --mode lib && vite build --mode loader", + "build:watch": "vite build --mode lib --watch & vite build --mode loader --watch & wait", "preview": "vite preview", "typecheck": "tsc --noEmit", "test": "vitest run", diff --git a/src/loader-core.ts b/src/loader-core.ts new file mode 100644 index 0000000..294c6a9 --- /dev/null +++ b/src/loader-core.ts @@ -0,0 +1,49 @@ +// The tiny script pages reference as /editor/cms-client.js (the URL the templates +// already use). Visitors pay for this file only; the editor bundle is fetched when +// the cms_preview cookie is set. +// +// ?cms_preview=1 sets the cookie, drops the param and reloads, so the edge +// renders the live (preview) page and the editor boots +// ?cms_preview=0 clears the cookie and reloads the published page + +export const COOKIE = 'cms_preview' +export const DEFAULT_BUNDLE = '/editor/onpage-editor.js' + +export interface LoaderEnv { + location: Pick + cookie: { get(): string; set(v: string): void } + /** URL of this script, used to find the editor bundle next to it. */ + scriptSrc?: string + load(url: string): Promise +} + +export const hasPreviewCookie = (cookie: string) => new RegExp(`(?:^|;\\s*)${COOKIE}=1`).test(cookie) + +export function bundleUrl(scriptSrc?: string): string { + if (!scriptSrc) return DEFAULT_BUNDLE + try { + return new URL('onpage-editor.js', scriptSrc).href + } catch { + return DEFAULT_BUNDLE + } +} + +/** Returns what it did, for tests and the console. */ +export function runLoader(env: LoaderEnv): 'redirected' | 'loaded' | 'idle' { + const url = new URL(env.location.href) + const flag = url.searchParams.get(COOKIE) + if (flag === '1' || flag === '0') { + const secure = env.location.protocol === 'https:' ? '; Secure' : '' + env.cookie.set( + flag === '1' + ? `${COOKIE}=1; path=/; SameSite=Lax${secure}` + : `${COOKIE}=; path=/; max-age=0; SameSite=Lax${secure}`, + ) + url.searchParams.delete(COOKIE) + env.location.replace(url.toString()) + return 'redirected' + } + if (!hasPreviewCookie(env.cookie.get())) return 'idle' + void env.load(bundleUrl(env.scriptSrc)).catch((e) => console.error('[cms] could not load the on-page editor', e)) + return 'loaded' +} diff --git a/src/loader.ts b/src/loader.ts new file mode 100644 index 0000000..636f8dd --- /dev/null +++ b/src/loader.ts @@ -0,0 +1,8 @@ +import { runLoader } from './loader-core' + +runLoader({ + location, + cookie: { get: () => document.cookie, set: (v) => void (document.cookie = v) }, + scriptSrc: (document.currentScript as HTMLScriptElement | null)?.src, + load: (url) => import(/* @vite-ignore */ url), +}) diff --git a/test/loader.test.ts b/test/loader.test.ts new file mode 100644 index 0000000..77b1277 --- /dev/null +++ b/test/loader.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest' +import { bundleUrl, hasPreviewCookie, runLoader, type LoaderEnv } from '../src/loader-core' + +function env(href: string, cookie = '', scriptSrc?: string) { + const jar = { value: cookie } + const e = { + location: { href, protocol: new URL(href).protocol, replace: vi.fn() }, + cookie: { get: () => jar.value, set: (v: string) => void (jar.value = v) }, + scriptSrc, + load: vi.fn(() => Promise.resolve({})), + } satisfies LoaderEnv + return { e, jar } +} + +describe('cms-client loader', () => { + it('does nothing for visitors', () => { + const { e } = env('https://k6n.net/') + expect(runLoader(e)).toBe('idle') + expect(e.load).not.toHaveBeenCalled() + }) + + it('loads the editor bundle next to the script when the preview cookie is set', () => { + const { e } = env('https://k6n.net/', 'a=b; cms_preview=1', 'https://k6n.net/editor/cms-client.js') + expect(runLoader(e)).toBe('loaded') + expect(e.load).toHaveBeenCalledWith('https://k6n.net/editor/onpage-editor.js') + }) + + it('?cms_preview=1 sets the cookie, strips the param and reloads', () => { + const { e, jar } = env('https://k6n.net/a?x=1&cms_preview=1') + expect(runLoader(e)).toBe('redirected') + expect(jar.value).toMatch(/^cms_preview=1; path=\/; SameSite=Lax; Secure$/) + expect(e.location.replace).toHaveBeenCalledWith('https://k6n.net/a?x=1') + expect(e.load).not.toHaveBeenCalled() + }) + + it('?cms_preview=0 clears the cookie (no Secure on http)', () => { + const { e, jar } = env('http://localhost/a?cms_preview=0', 'cms_preview=1') + runLoader(e) + expect(jar.value).toBe('cms_preview=; path=/; max-age=0; SameSite=Lax') + expect(e.location.replace).toHaveBeenCalledWith('http://localhost/a') + }) + + it('helpers', () => { + expect(hasPreviewCookie('cms_preview=1')).toBe(true) + expect(hasPreviewCookie('xcms_preview=1')).toBe(false) + expect(hasPreviewCookie('cms_preview=0')).toBe(false) + expect(bundleUrl()).toBe('/editor/onpage-editor.js') + }) +}) diff --git a/tsconfig.json b/tsconfig.json index c1b512a..6c285dd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,7 +13,13 @@ "noEmit": true, "types": ["vite/client", "node"], "baseUrl": ".", - "paths": { "@k6n/cms-editor": ["../ecommerce/backoffice/web/packages/cms-editor/src/index.ts"] } + "paths": { + "@k6n/cms-editor": [ + "../backoffice/web/packages/cms-editor/src/index.ts", + "../ecommerce/backoffice/web/packages/cms-editor/src/index.ts", + "./.cms-editor/src/index.ts" + ] + } }, "include": ["src", "mock", "vite", "vite.config.ts", "test"] } diff --git a/vite.config.ts b/vite.config.ts index 7655711..1b504b5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,24 @@ import { defineConfig, loadEnv, type UserConfig } from 'vite' import react from '@vitejs/plugin-react' import path from 'node:path' +import { existsSync } from 'node:fs' import { cmsLivePlugin } from './vite/live' import { cmsMockPlugin } from './vite/mock' export default defineConfig(({ command, mode }): UserConfig => { const env = { ...loadEnv(mode, process.cwd(), ''), ...process.env } as Record - const editorSrc = path.resolve(env.CMS_EDITOR_SRC || '../ecommerce/backoffice/web/packages/cms-editor/src') + // Shared @k6n/cms-editor sources. As a submodule of the ecommerce monorepo the + // backoffice is a sibling; a standalone checkout sits next to ../ecommerce. + const editorSrc = path.resolve( + env.CMS_EDITOR_SRC || + ['../backoffice/web/packages/cms-editor/src', '../ecommerce/backoffice/web/packages/cms-editor/src', './.cms-editor/src'].find((p) => + existsSync(path.resolve(p)), + ) || + '../backoffice/web/packages/cms-editor/src', + ) + // CMS_ONPAGE_OUT: build straight into an instance's public/ (dev launcher --watch). + const outDir = env.CMS_ONPAGE_OUT ? path.resolve(env.CMS_ONPAGE_OUT) : 'dist' + const emptyOutDir = !env.CMS_ONPAGE_OUT const common: UserConfig = { plugins: [react()], resolve: { @@ -23,6 +35,21 @@ export default defineConfig(({ command, mode }): UserConfig => { }, } as UserConfig + if (command === 'build' && mode === 'loader') { + // /editor/cms-client.js: the stable URL page templates reference. Classic + // script (IIFE), tiny; imports the editor bundle only for editors. + return { + ...common, + define: { 'process.env.NODE_ENV': '"production"' }, + build: { + outDir, + emptyOutDir: false, + lib: { entry: 'src/loader.ts', name: 'CMSClient', formats: ['iife'], fileName: () => 'cms-client.js' }, + sourcemap: true, + }, + } + } + if (command === 'build') { // The artifact served at /editor/onpage-editor.js. One self-contained file; // only loaded when the preview cookie is present (see src/main.ts). @@ -30,7 +57,8 @@ export default defineConfig(({ command, mode }): UserConfig => { ...common, define: { 'process.env.NODE_ENV': '"production"' }, build: { - outDir: 'dist', + outDir, + emptyOutDir, lib: { entry: 'src/main.ts', formats: ['es'], fileName: () => 'onpage-editor.js' }, sourcemap: true, cssCodeSplit: false, From ec4212c113d6c4c6d25edd9b93eeaa245837eb5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Sun, 1 Nov 2026 11:02:13 +0100 Subject: [PATCH 2/3] Device preview + responsive spacing in the Style panel Style panel gets a real-viewport iframe preview (390/820/1280px) tied to the breakpoint tab, reloaded after each edit, and spacing/gap controls per breakpoint. The editor does not boot inside the preview frame. Mock and tests mirror the new k6n layout template. Co-Authored-By: Claude Sonnet 5.5 --- docs/styling.md | 8 +++-- mock/fixtures.ts | 13 ++++++-- src/main.ts | 4 ++- src/style/DevicePreview.tsx | 65 +++++++++++++++++++++++++++++++++++++ src/style/StylePanel.tsx | 24 +++++++++++++- test/style-editor.test.tsx | 28 +++++++++++++++- test/style.test.ts | 3 +- 7 files changed, 136 insertions(+), 9 deletions(-) create mode 100644 src/style/DevicePreview.tsx diff --git a/docs/styling.md b/docs/styling.md index 6cbbd87..403d61c 100644 --- a/docs/styling.md +++ b/docs/styling.md @@ -64,8 +64,12 @@ and every block with a numeric `columns`. ## Open points / suggestions for the CMS side -1. **Responsive spacing** is not possible today: no `gap/padding/margin` classes per breakpoint. If wanted: add `x-style` spacing - fields to `$defs/breakpoint` and emit e.g. `k6n-gap-md@t` classes from `layout.gohtml`; the editor picks them up unchanged. +1. **Responsive spacing** (done): `$defs/breakpoint` declares `gap`, `padding(Top|Bottom)`, `margin(Top|Bottom)`, `align`, `justify`; the k6n + layout template emits `k6n-t-gap-md` / `k6n-m-pt-sm` classes (tablet ≤900px, mobile ≤620px). Columns are resolved in the template into + `--k6n-cols` / `--k6n-cols-t` / `--k6n-cols-m` (defaults: tablet ≤2, mobile 1); cards/quotes/cases/projects/people read the same variables. + **Device preview**: the Style panel's *Show … preview* opens the page in an iframe at 390/820/1280px (real viewport, so media queries apply), + reloaded after each edit; the editor does not boot inside it (`window.name`). Only the k6n content root has this so far; the shared + `data/templates/layout.gohtml` still uses the old class scheme. 2. **Left/right padding and margin** have no fields (`paddingTop/Bottom` only), so those grips edit the all-sides field. Adding `paddingX/marginX` (`side: "x"`) gives them their own handles with no editor change. 3. `InsertOp` in `@k6n/cms-editor` lacks the optional `id` the server accepts (the editor casts for the wrap batch). diff --git a/mock/fixtures.ts b/mock/fixtures.ts index dce2b27..a1e5dd6 100644 --- a/mock/fixtures.ts +++ b/mock/fixtures.ts @@ -53,6 +53,9 @@ export const schemas: Record = { properties: { flow: { 'x-style': { kind: 'flow' }, type: 'string', enum: ['row', 'stack', 'actions'] }, columns: { 'x-style': { kind: 'columns' }, type: 'integer', minimum: 1, maximum: 12 }, + gap: { 'x-style': { kind: 'gap', token: '--k6n-sp-{value}' }, type: 'string', enum: ['none', 'xs', 'sm', 'md', 'lg', 'xl'] }, + paddingTop: { 'x-style': { kind: 'spacing', box: 'padding', side: 'top', overrides: 'padding', token: '--k6n-sp-{value}' }, type: 'string', enum: ['none', 'xs', 'sm', 'md', 'lg', 'xl'] }, + paddingBottom: { 'x-style': { kind: 'spacing', box: 'padding', side: 'bottom', overrides: 'padding', token: '--k6n-sp-{value}' }, type: 'string', enum: ['none', 'xs', 'sm', 'md', 'lg', 'xl'] }, }, additionalProperties: false, }, @@ -193,9 +196,13 @@ export function render(g: Graph, id: string): string { .map(([k, c]) => ` k6n-${c}-${d[k]}`) .join('') const r = d.responsive ?? {} - const resp = [['mobile', 'm'], ['tablet', 't'], ['desktop', 'd']] - .flatMap(([bp, c]) => [r[bp]?.flow ? ` k6n-layout--${c}-${r[bp].flow}` : '', r[bp]?.columns ? ` k6n-layout--${c}c-${r[bp].columns}` : '']) - .join('') + // Mirrors data/k6n/templates/layout.gohtml: tablet flow class only when it differs; spacing as k6n---. + const norm = (f: string) => ({ vertical: 'stack', horizontal: 'row', grid: 'row' } as Record)[f] ?? f + const tf = r.tablet?.flow ? norm(r.tablet.flow) : flow + const resp = (tf !== flow ? ` k6n-layout--t-${tf}` : '') + + [['tablet', 't'], ['mobile', 'm']] + .flatMap(([bp, c]) => [['gap', 'gap'], ['padding', 'p'], ['paddingTop', 'pt'], ['paddingBottom', 'pb']].filter(([k]) => r[bp]?.[k]).map(([k, n]) => ` k6n-${c}-${n}-${r[bp][k]}`)) + .join('') const tone = d.tone && d.tone !== 'none' ? ` k6n-${d.tone}` : '' const cells = g.children(id).map((e) => `
${render(g, e.to)}
`).join('\n') return `
\n${cells}\n
` diff --git a/src/main.ts b/src/main.ts index 76b010f..af0922a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,10 +2,12 @@ // own loader only when the preview cookie is set (docs/onpage-editor.md §2). // It re-checks, so loading it by mistake never shows editor UI to visitors. import { boot } from './boot' +import { PREVIEW_FRAME_NAME } from './style/DevicePreview' export const previewEnabled = () => /(?:^|;\s*)cms_preview=1/.test(document.cookie) -if (previewEnabled()) boot() +// The device-preview iframe shows the page only; it must not boot a second editor. +if (previewEnabled() && window.name !== PREVIEW_FRAME_NAME) boot() export { boot } export * from './registry' diff --git a/src/style/DevicePreview.tsx b/src/style/DevicePreview.tsx new file mode 100644 index 0000000..071329c --- /dev/null +++ b/src/style/DevicePreview.tsx @@ -0,0 +1,65 @@ +import { useEffect, useRef } from 'react' +import { useEditorState } from '../ui/hooks' +import type { Breakpoint } from './model' + +/** Real viewport widths: media queries only answer to an actual window, hence an iframe. */ +export const DEVICE_WIDTH: Record = { mobile: 390, tablet: 820, desktop: 1280, base: 1280 } +/** Marks the preview frame so the editor does not boot inside it (see main.ts). */ +export const PREVIEW_FRAME_NAME = 'cms-device-preview' + +const SHOWN = 380 // px the frame occupies on screen at most +const HEIGHT = 520 + +/** + * The page at the width of the selected breakpoint, floating bottom-left. It is the same URL, so it + * renders the live store (cms_preview cookie) and reloads shortly after every edit. Because the + * viewport is real, `@media` rules - including the layout's tablet/mobile column variables - apply. + */ +export function DevicePreview({ bp, onClose }: { bp?: Breakpoint; onClose(): void }) { + const ref = useRef(null) + const rev = useEditorState((s) => s.editRev) + const first = useRef(true) + const width = DEVICE_WIDTH[bp ?? 'base'] + const scale = Math.min(1, SHOWN / width) + + useEffect(() => { + if (first.current) { + first.current = false + return + } + const t = setTimeout(() => { + try { + ref.current?.contentWindow?.location.reload() + } catch { + /* cross-origin: nothing to reload */ + } + }, 450) + return () => clearTimeout(t) + }, [rev]) + + const url = location.pathname + location.search + return ( +
+
+ + {bp ?? 'desktop'} preview · {width}px + + +
+
+