Merge branch 'main' of git.k6n.net:mats/cms-onpageeditor

This commit is contained in:
mats committed 2026-11-01 12:11:50 +01:00
commit 2d35df4c7f
15 files changed
+294 -15

No files matched your search

+1
View File
@@ -2,3 +2,4 @@ node_modules
dist dist
.env .env
.env.local .env.local
.cms-editor/
+13 -3
View File
@@ -146,9 +146,19 @@ schema contract: [docs/styling.md](docs/styling.md).
## Deploying ## Deploying
Serve `dist/onpage-editor.js` at `/editor/onpage-editor.js` and let the existing loader do Part of the ecommerce monorepo as the `cms-onpageeditor` submodule. Each instance image (`tornberg`, `bygglagret`,
`if (document.cookie.includes('cms_preview=1')) import('/editor/onpage-editor.js')`. The bundle `k6n`) and `Dockerfile.nginx` build it in an `onpage` stage (named context `onpageeditor`) and ship two files in `/editor/`:
re-checks the cookie, so loading it for a visitor is a no-op.
- `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 edit mode on a deployed site: open **`/edit`** or **`/edit/<path>`** (the edge, `deploy/nginx.conf`, sets the cookie and redirects
to the page). 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 ## Not done / next
+6 -2
View File
@@ -64,8 +64,12 @@ and every block with a numeric `columns`.
## Open points / suggestions for the CMS side ## 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 1. **Responsive spacing** (done): `$defs/breakpoint` declares `gap`, `padding(Top|Bottom)`, `margin(Top|Bottom)`, `align`, `justify`; the k6n
fields to `$defs/breakpoint` and emit e.g. `k6n-gap-md@t` classes from `layout.gohtml`; the editor picks them up unchanged. 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 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. `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). 3. `InsertOp` in `@k6n/cms-editor` lacks the optional `id` the server accepts (the editor casts for the wrap batch).
+9 -2
View File
@@ -53,6 +53,9 @@ export const schemas: Record<string, any> = {
properties: { properties: {
flow: { 'x-style': { kind: 'flow' }, type: 'string', enum: ['row', 'stack', 'actions'] }, flow: { 'x-style': { kind: 'flow' }, type: 'string', enum: ['row', 'stack', 'actions'] },
columns: { 'x-style': { kind: 'columns' }, type: 'integer', minimum: 1, maximum: 12 }, 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, additionalProperties: false,
}, },
@@ -193,8 +196,12 @@ export function render(g: Graph, id: string): string {
.map(([k, c]) => ` k6n-${c}-${d[k]}`) .map(([k, c]) => ` k6n-${c}-${d[k]}`)
.join('') .join('')
const r = d.responsive ?? {} const r = d.responsive ?? {}
const resp = [['mobile', 'm'], ['tablet', 't'], ['desktop', 'd']] // Mirrors data/k6n/templates/layout.gohtml: tablet flow class only when it differs; spacing as k6n-<t|m>-<prop>-<size>.
.flatMap(([bp, c]) => [r[bp]?.flow ? ` k6n-layout--${c}-${r[bp].flow}` : '', r[bp]?.columns ? ` k6n-layout--${c}c-${r[bp].columns}` : '']) const norm = (f: string) => ({ vertical: 'stack', horizontal: 'row', grid: 'row' } as Record<string, string>)[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('') .join('')
const tone = d.tone && d.tone !== 'none' ? ` k6n-${d.tone}` : '' const tone = d.tone && d.tone !== 'none' ? ` k6n-${d.tone}` : ''
const cells = g.children(id).map((e) => `<div class="k6n-layout__cell">${render(g, e.to)}</div>`).join('\n') const cells = g.children(id).map((e) => `<div class="k6n-layout__cell">${render(g, e.to)}</div>`).join('\n')
+2 -1
View File
@@ -7,7 +7,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:mock": "CMS_MODE=mock 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", "preview": "vite preview",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run", "test": "vitest run",
+49
View File
@@ -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<Location, 'href' | 'protocol' | 'replace'>
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<unknown>
}
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'
}
+8
View File
@@ -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),
})
+3 -1
View File
@@ -2,10 +2,12 @@
// own loader only when the preview cookie is set (docs/onpage-editor.md §2). // 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. // It re-checks, so loading it by mistake never shows editor UI to visitors.
import { boot } from './boot' import { boot } from './boot'
import { PREVIEW_FRAME_NAME } from './style/DevicePreview'
export const previewEnabled = () => /(?:^|;\s*)cms_preview=1/.test(document.cookie) 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 { boot }
export * from './registry' export * from './registry'
+65
View File
@@ -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<Breakpoint | 'base', number> = { 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<HTMLIFrameElement>(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 (
<div
className="cms-devicepreview"
style={{ position: 'fixed', left: 16, bottom: 16, zIndex: 2147483000, background: '#161821', border: '1px solid #2a2d3a', borderRadius: 10, boxShadow: '0 8px 30px #0008', padding: 6, color: '#c3c6d4', font: '12px system-ui' }}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '0 4px 6px' }}>
<span>
{bp ?? 'desktop'} preview · {width}px
</span>
<button type="button" aria-label="Close preview" onClick={onClose} style={{ background: 'none', border: 0, color: 'inherit', cursor: 'pointer' }}>
✕
</button>
</div>
<div style={{ width: width * scale, height: HEIGHT, overflow: 'hidden', borderRadius: 6, background: '#fff' }}>
<iframe
ref={ref}
name={PREVIEW_FRAME_NAME}
title="Device preview"
src={url}
style={{ width, height: HEIGHT / scale, border: 0, transform: `scale(${scale})`, transformOrigin: 'top left' }}
/>
</div>
</div>
)
}
+23 -1
View File
@@ -8,6 +8,7 @@ import { pxOf } from './tokens'
import type { Scale } from './tokens' import type { Scale } from './tokens'
import { Icon } from '../ui/Icon' import { Icon } from '../ui/Icon'
import { describeStyle, fieldForSide } from './model' import { describeStyle, fieldForSide } from './model'
import { DevicePreview } from './DevicePreview'
type Data = Record<string, any> type Data = Record<string, any>
@@ -35,6 +36,7 @@ export function StylePanel({ node, schema, ctrl }: EditorProps) {
const fields = useMemo(() => describeStyle(schema), [schema]) const fields = useMemo(() => describeStyle(schema), [schema])
const [data, setData] = useState<Data>(node.data ?? {}) const [data, setData] = useState<Data>(node.data ?? {})
const [bp, setBp] = useState<Breakpoint | undefined>() const [bp, setBp] = useState<Breakpoint | undefined>()
const [preview, setPreview] = useState(false)
const rev = useEditorState((s) => s.editRev) const rev = useEditorState((s) => s.editRev)
useEffect(() => { useEffect(() => {
if (rev) void ctrl.node(node.id, true).then((n) => setData(n.data ?? {})).catch(() => {}) if (rev) void ctrl.node(node.id, true).then((n) => setData(n.data ?? {})).catch(() => {})
@@ -64,7 +66,19 @@ export function StylePanel({ node, schema, ctrl }: EditorProps) {
))} ))}
</div> </div>
)} )}
{bp && <p className="cms-muted">Overrides for {bp === 'mobile' ? 'small screens' : bp === 'tablet' ? 'tablets' : 'desktops'}; unset values inherit from “All sizes”.</p>} <div className="cms-style__preview">
<button type="button" className={`cms-chip${preview ? ' is-on' : ''}`} aria-pressed={preview} onClick={() => setPreview((p) => !p)}>
{preview ? 'Hide' : 'Show'} {bp ?? 'desktop'} preview
</button>
</div>
{preview && <DevicePreview bp={bp} onClose={() => setPreview(false)} />}
{bp && (
<p className="cms-muted">
Overrides for {bp === 'mobile' ? 'small screens (≤620px)' : bp === 'tablet' ? 'tablets (≤900px)' : 'desktops'}; unset values inherit from “All sizes”.
{bp === 'tablet' && ' Default: at most 2 columns.'}
{bp === 'mobile' && ' Default: one column.'}
</p>
)}
{of('flow', 'columns', 'align', 'justify').length > 0 && ( {of('flow', 'columns', 'align', 'justify').length > 0 && (
<Section title="Layout"> <Section title="Layout">
@@ -85,6 +99,14 @@ export function StylePanel({ node, schema, ctrl }: EditorProps) {
</Section> </Section>
)} )}
{bp && of('gap', 'spacing').length > 0 && (
<Section title="Spacing">
{of('gap', 'spacing').map((f) => (
<Control key={f.name} f={f} value={get(f)} onChange={(v) => set(f, v)} />
))}
</Section>
)}
{of('tone', 'border').length > 0 && ( {of('tone', 'border').length > 0 && (
<Section title="Surface"> <Section title="Surface">
{of('tone', 'border').map((f) => ( {of('tone', 'border').map((f) => (
+49
View File
@@ -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')
})
})
+27 -1
View File
@@ -84,7 +84,6 @@ describe('Style panel (schema-driven)', () => {
await select('l1') await select('l1')
await h.press('Style') await h.press('Style')
await act(async () => void [...h.shadow.querySelectorAll<HTMLElement>('[role=tab]')].find((t) => t.textContent === 'Tablet')!.click()) await act(async () => void [...h.shadow.querySelectorAll<HTMLElement>('[role=tab]')].find((t) => t.textContent === 'Tablet')!.click())
expect(h.shadow.querySelector('[data-style-field="gap"]')).toBeNull() // spacing has no per-breakpoint form
await act(async () => void chip('flow', 'Stack').click()) await act(async () => void chip('flow', 'Stack').click())
await h.settle() await h.settle()
expect(data('l1').responsive).toEqual({ tablet: { flow: 'stack' } }) expect(data('l1').responsive).toEqual({ tablet: { flow: 'stack' } })
@@ -95,6 +94,33 @@ describe('Style panel (schema-driven)', () => {
expect('responsive' in data('l1')).toBe(false) expect('responsive' in data('l1')).toBe(false)
}) })
it('breakpoints can override gap, which renders as a k6n-<bp>-gap class', async () => {
h = await setup()
await h.settle()
await select('l1')
await h.press('Style')
await act(async () => void [...h.shadow.querySelectorAll<HTMLElement>('[role=tab]')].find((t) => t.textContent === 'Mobile')!.click())
await act(async () => void chip('gap', 'xs').click())
await h.settle()
expect(data('l1').responsive).toEqual({ mobile: { gap: 'xs' } })
expect(data('l1').gap).not.toBe('xs')
expect(byId('l1').className).toContain('k6n-m-gap-xs')
})
it('the device preview is an iframe named so the editor does not boot inside it', async () => {
h = await setup()
await h.settle()
await select('l1')
await h.press('Style')
await act(async () => void [...h.shadow.querySelectorAll<HTMLElement>('[role=tab]')].find((t) => t.textContent === 'Mobile')!.click())
await act(async () => void [...h.shadow.querySelectorAll<HTMLElement>('.cms-style__preview .cms-chip')][0].click())
const f = h.shadow.querySelector('iframe')!
expect(f.name).toBe('cms-device-preview')
expect(f.style.width).toBe('390px')
await act(async () => void h.shadow.querySelector<HTMLElement>('[aria-label="Close preview"]')!.click())
expect(h.shadow.querySelector('iframe')).toBeNull()
})
it('rapid clicks on one field coalesce into a single undo step', async () => { it('rapid clicks on one field coalesce into a single undo step', async () => {
h = await setup() h = await setup()
await h.settle() await h.settle()
+2 -1
View File
@@ -25,7 +25,8 @@ describe('describeStyle', () => {
expect(byName('paddingTop')).toMatchObject({ kind: 'spacing', box: 'padding', side: 'top', overrides: 'padding', token: '--k6n-sp-{value}', inferred: false }) expect(byName('paddingTop')).toMatchObject({ kind: 'spacing', box: 'padding', side: 'top', overrides: 'padding', token: '--k6n-sp-{value}', inferred: false })
expect(byName('gap')).toMatchObject({ kind: 'gap', inferred: false }) expect(byName('gap')).toMatchObject({ kind: 'gap', inferred: false })
expect(byName('flow').responsive).toEqual(['mobile', 'tablet', 'desktop']) expect(byName('flow').responsive).toEqual(['mobile', 'tablet', 'desktop'])
expect(byName('gap').responsive).toEqual([]) // spacing is not a breakpoint override (the template has no classes for it) expect(byName('gap').responsive).toEqual(['mobile', 'tablet', 'desktop'])
expect(byName('padding').responsive).toEqual([]) // only declared per-side in the breakpoint def
expect(isWrapperSchema(layout)).toBe(true) expect(isWrapperSchema(layout)).toBe(true)
}) })
+7 -1
View File
@@ -13,7 +13,13 @@
"noEmit": true, "noEmit": true,
"types": ["vite/client", "node"], "types": ["vite/client", "node"],
"baseUrl": ".", "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"] "include": ["src", "mock", "vite", "vite.config.ts", "test"]
} }
+30 -2
View File
@@ -1,12 +1,24 @@
import { defineConfig, loadEnv, type UserConfig } from 'vite' import { defineConfig, loadEnv, type UserConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import path from 'node:path' import path from 'node:path'
import { existsSync } from 'node:fs'
import { cmsLivePlugin } from './vite/live' import { cmsLivePlugin } from './vite/live'
import { cmsMockPlugin } from './vite/mock' import { cmsMockPlugin } from './vite/mock'
export default defineConfig(({ command, mode }): UserConfig => { export default defineConfig(({ command, mode }): UserConfig => {
const env = { ...loadEnv(mode, process.cwd(), ''), ...process.env } as Record<string, string> const env = { ...loadEnv(mode, process.cwd(), ''), ...process.env } as Record<string, string>
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 = { const common: UserConfig = {
plugins: [react()], plugins: [react()],
resolve: { resolve: {
@@ -23,6 +35,21 @@ export default defineConfig(({ command, mode }): UserConfig => {
}, },
} as 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') { if (command === 'build') {
// The artifact served at /editor/onpage-editor.js. One self-contained file; // The artifact served at /editor/onpage-editor.js. One self-contained file;
// only loaded when the preview cookie is present (see src/main.ts). // only loaded when the preview cookie is present (see src/main.ts).
@@ -30,7 +57,8 @@ export default defineConfig(({ command, mode }): UserConfig => {
...common, ...common,
define: { 'process.env.NODE_ENV': '"production"' }, define: { 'process.env.NODE_ENV': '"production"' },
build: { build: {
outDir: 'dist', outDir,
emptyOutDir,
lib: { entry: 'src/main.ts', formats: ['es'], fileName: () => 'onpage-editor.js' }, lib: { entry: 'src/main.ts', formats: ['es'], fileName: () => 'onpage-editor.js' },
sourcemap: true, sourcemap: true,
cssCodeSplit: false, cssCodeSplit: false,