feat: compiled-style thumbnails and Less compilation in a worker

- thumbnails: compile the project stylesheet once per rebuild and feed it
  into the layout sandbox, compose each tile like the runtime does
  (<section data-ps-slide> around the slide), and show example HTML on
  the global tile instead of another real slide's content.
- worker: less compiles off the main thread in less-worker; a side-effect
  module shims window/document and disables the browser build's page
  auto-init, and compilation falls back inline if the worker can't start.
- code-to-element: wrap styles[] entries in <style> blocks - raw CSS text
  in the iframe head fell into <body> and rendered as literal garbage.
This commit is contained in:
navi 2026-09-28 13:05:42 +02:00
parent 14916e6416
commit 97ae327415
5 changed files with 305 additions and 25 deletions

View file

@ -3,6 +3,7 @@ import { domToSvgDataUrl } from './dom-to-image'
export interface CodeToElementOptions {
title?: string
allowScripts?: boolean
/** Plain CSS strings — each becomes a `<style>` block in the iframe head. */
styles?: string[]
}
@ -18,7 +19,8 @@ export function codeToElement(code: string, options: CodeToElementOptions = {}):
iframe.title = options.title ?? 'Rendered code'
iframe.setAttribute('sandbox', Array.from(sandbox).join(' '))
iframe.srcdoc = `<!doctype html><html><head><meta charset="UTF-8"><style>html,body{margin:0;min-height:100%;}</style>${(options.styles ?? []).join('\n')}</head><body>${code}</body></html>`
const headStyles = (options.styles ?? []).map((css) => `<style>${css}</style>`).join('\n')
iframe.srcdoc = `<!doctype html><html><head><meta charset="UTF-8"><style>html,body{margin:0;min-height:100%;}</style>${headStyles}</head><body>${code}</body></html>`
return iframe
}

View file

@ -1,7 +1,13 @@
import './style.css'
import { createEditor } from './editor'
import { createCodeThumbnail } from './code-to-element'
import { compilePresentation, extractStyles } from './slides/compile'
import {
compilePresentation,
extractStyles,
slideId,
type CompiledPresentation
} from './slides/compile'
import { CSS_PLACEHOLDER, HTML_PLACEHOLDER } from './slides/placeholders'
import { createRuntime, type Runtime } from './slides/runtime'
interface Slide {
@ -51,6 +57,46 @@ const STARTER_STYLE = `// Project styles. This is ordinary Less: variables, nest
}
`
// Shown in the global-slide tile while the global slide is empty: an example
// frame around a sample slide, so the tile explains what the global slide is
// for instead of rendering a blank box. Template mode mounts this same sample
// in the author's own frame.
const EXAMPLE_SECTION = `<section data-ps-slide="${slideId(0)}" data-ps-state="current"><h1>Example slide</h1><p>The global slide wraps every slide like this.</p></section>`
const EXAMPLE_FRAME = `<style>
.ps-example-frame {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
gap: 1rem;
padding: 2rem;
background: #161a22;
color: #dbe4f0;
font-family: system-ui, sans-serif;
}
.ps-example-frame header {
font-size: 1.75rem;
font-weight: 650;
opacity: 0.85;
}
.ps-example-frame .ps-example-viewport {
position: relative;
flex: 1;
border-radius: 0.75rem;
overflow: hidden;
}
.ps-example-frame footer {
font-size: 1.25rem;
opacity: 0.55;
}
</style>
<div class="ps-example-frame">
<header>Example deck</header>
<div class="ps-example-viewport">${EXAMPLE_SECTION}</div>
<footer>Global slide — wraps every slide</footer>
</div>`
let globalSlide = ''
const slides: Slide[] = [
@ -71,6 +117,12 @@ let globalSlideElement: HTMLElement | null = null
const slideElements = new Map<Slide, HTMLElement>()
const thumbnailRequests = new Map<number, number>()
let thumbnailDebounceTimer: number | undefined
// The last good compile: thumbnails read the stylesheet from here, so a tile
// is rendered exactly as the presentation renders it.
let lastResult: CompiledPresentation | null = null
// Bumped by every full list rebuild; async thumbnail builders check it so a
// stale build can never commit over a newer one.
let listGeneration = 0
// The compiled stylesheet is the runtime's business: it lands at the global
// slide's `<?= css ?>`, or in a `data-ps-slides` sheet in the head when the
@ -157,6 +209,15 @@ async function rebuildPreview(): Promise<void> {
if (currentSlide >= 0) {
runtime.goTo(currentSlide)
}
// Tiles embed the stylesheet (and, in template mode, the frame), so a
// change to either makes every tile stale. The first compile builds
// the list itself; render() only builds it when that compile failed.
const previous = lastResult
lastResult = result
if (!previous || previous.css !== result.css || previous.template !== result.template) {
void setSlideList()
}
} catch (error) {
// Half-typed Less should not blank the preview: keep the last good
// output on screen and report what broke.
@ -164,6 +225,44 @@ async function rebuildPreview(): Promise<void> {
}
}
/**
* Compose a tile exactly as the runtime composes the real thing: the slide in
* its `<section data-ps-slide>` with state `current` (so the compiled state
* rules show the settled look), inside the global template when there is one,
* with the shared markup prepended when there is not. The global tile mounts
* an example slide in the frame instead — it must not show another real
* slide's content, which would go stale whenever that slide is edited.
*/
function buildThumbnailHtml(index: number, code: string): string {
const global = extractStyles(globalSlide).html
const isTemplate = HTML_PLACEHOLDER.test(global)
if (index === -1) {
if (!global.trim()) {
return EXAMPLE_FRAME
}
if (isTemplate) {
return global
.split(HTML_PLACEHOLDER)
.join(EXAMPLE_SECTION)
.split(CSS_PLACEHOLDER)
.join('')
}
return global
}
const extracted = extractStyles(code).html
let inner = extracted
if (!isTemplate && global) {
inner = `${global}\n${extracted}`
}
const section = `<section data-ps-slide="${slideId(index)}" data-ps-state="current">${inner}</section>`
if (isTemplate) {
return global.split(HTML_PLACEHOLDER).join(section).split(CSS_PLACEHOLDER).join('')
}
return section
}
async function generateSlideThumbnail(slide: Slide, i: number) {
const slideElement = document.createElement('div')
slideElement.classList.add('slide-list-item')
@ -172,7 +271,6 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
slideElement.classList.add('global-slide')
} else {
slideElement.draggable = true
slideElements.set(slide, slideElement)
}
const slideInList = document.createElement('button')
@ -187,9 +285,11 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
slideNumber.classList.add('slide-number')
slideNumber.textContent = i === -1 ? 'G' : `${i + 1}`
// Style tags are the project stylesheet, never slide content: the
// thumbnail renders markup only, exactly like the presentation does.
const thumbnail = await createCodeThumbnail(extractStyles(slide.code).html)
// The tile mirrors the presentation: composed markup, plus the compiled
// stylesheet injected into the thumbnail document's head.
const thumbnail = await createCodeThumbnail(buildThumbnailHtml(i, slide.code), {
styles: lastResult?.css ? [lastResult.css] : []
})
slideInList.append(slideNumber, thumbnail)
slideElement.append(slideInList)
@ -205,10 +305,6 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
slideElement.append(deleteButton)
}
if (i === -1) {
globalSlideElement = slideElement
}
if (i === currentSlide) {
slideElement.classList.add('selected')
}
@ -219,9 +315,12 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
async function refreshThumbnail(index: number, slide: Slide) {
const request = (thumbnailRequests.get(index) ?? 0) + 1
thumbnailRequests.set(index, request)
const generation = listGeneration
const oldSlideElement = index === -1 ? globalSlideElement : slideElements.get(slide)
const slideElement = await generateSlideThumbnail(slide, index)
if (thumbnailRequests.get(index) !== request) {
// A newer refresh or a full rebuild superseded this one while it rendered;
// a stale tile must never replace a fresh one.
if (generation !== listGeneration || thumbnailRequests.get(index) !== request) {
return
}
@ -257,15 +356,31 @@ async function mapWithConcurrency<T, R>(
}
async function setSlideList() {
const generation = ++listGeneration
const allSlides = [{ code: globalSlide }, ...slides]
slideList.replaceChildren()
slideElements.clear()
globalSlideElement = null
const elements = await mapWithConcurrency(allSlides, THUMBNAIL_CONCURRENCY, (slide, index) =>
generateSlideThumbnail(slide, index - 1)
)
slideList.append(...elements)
// Commit atomically: the old list stays interactive while tiles render.
const deckUnchanged =
allSlides.length === slides.length + 1 &&
allSlides[0].code === globalSlide &&
allSlides.slice(1).every((slide, offset) => slide === slides[offset])
if (generation !== listGeneration) {
return
}
if (!deckUnchanged) {
// A slide was added, removed or moved mid-render; this snapshot is
// stale, and no other rebuild is coming — start a fresh one.
void setSlideList()
return
}
slideList.replaceChildren(...elements)
slideElements.clear()
globalSlideElement = elements[0] ?? null
elements.slice(1).forEach((element, offset) => {
slideElements.set(allSlides[offset + 1], element)
})
updateSelection()
}
@ -340,11 +455,18 @@ function deleteSlide(index: number) {
addSlideButton.addEventListener('click', async () => {
const slide = { code: '' }
slides.push(slide)
const slideElement = await generateSlideThumbnail(slide, slides.length - 1)
slideList.append(slideElement)
updateSlideOrder()
setSlide(slides.length - 1)
scheduleCompile()
const generation = listGeneration
const slideElement = await generateSlideThumbnail(slide, slides.length - 1)
// A full rebuild started meanwhile; its snapshot already has this slide.
if (generation !== listGeneration) {
return
}
slideElements.set(slide, slideElement)
slideList.append(slideElement)
updateSlideOrder()
})
presentButton.addEventListener('click', () => {
@ -420,9 +542,13 @@ editor.onDidChangeModelContent(() => {
})
async function render() {
await setSlideList()
setSlide(currentSlide)
await rebuildPreview()
// A successful first compile builds the list itself; this is only the
// fallback when even the boot compile failed.
if (!lastResult) {
await setSlideList()
}
}
render()

View file

@ -1,11 +1,107 @@
import less from 'less'
import LessWorker from './less-worker?worker'
import { CSS_PLACEHOLDER, HTML_PLACEHOLDER } from './placeholders'
/**
* Less runs in a worker so a heavy stylesheet never blocks the editor. The
* worker is an optimisation, not a requirement: if it fails to start or dies
* mid-render, everything still in flight (and every later render) falls back
* to an on-demand `import('less')` on the main thread — which also keeps the
* big Less bundle out of the initial page load.
*/
interface RenderTask {
source: string
resolve: (css: string) => void
reject: (error: Error) => void
}
/** Reply the worker posts back: either the CSS or the failure message. */
interface RenderReply {
id: number
css?: string
error?: string
}
const renderTasks = new Map<number, RenderTask>()
let nextRenderTask = 0
/** `undefined` until first use, then the worker — or `null` once degraded. */
let lessWorker: Worker | null | undefined
function renderInline(source: string): Promise<string> {
// The same auto-init as in the worker: pre-set `onReady: false` (the init
// copies `window.less` before its own defaults) so importing less does
// not hide `<body>` or rescan the app's stylesheets just because we only
// wanted its compiler.
if (typeof window !== 'undefined' && !('less' in window)) {
;(window as unknown as { less?: unknown }).less = { onReady: false }
}
return import('less').then(async (module) => {
const { css } = await module.default.render(source)
return css
})
}
/** The worker is gone: re-run everything pending on the main thread. */
function degradeWorker(): void {
if (!lessWorker) {
return
}
lessWorker.terminate()
lessWorker = null
const pending = Array.from(renderTasks.values())
renderTasks.clear()
for (const task of pending) {
renderInline(task.source).then(task.resolve).catch(task.reject)
}
}
function getLessWorker(): Worker | null {
if (lessWorker !== undefined) {
return lessWorker
}
try {
const worker = new LessWorker()
worker.addEventListener('message', (event: MessageEvent<RenderReply>) => {
const task = renderTasks.get(event.data.id)
if (!task) {
return
}
renderTasks.delete(event.data.id)
if (event.data.error !== undefined) {
task.reject(new Error(event.data.error))
} else {
task.resolve(event.data.css ?? '')
}
})
// Uncaught worker failure (bundle load, environment): drop to inline.
worker.addEventListener('error', (event) => {
event.preventDefault()
degradeWorker()
})
lessWorker = worker
} catch {
lessWorker = null
}
return lessWorker
}
function renderLess(source: string): Promise<string> {
const worker = getLessWorker()
if (!worker) {
return renderInline(source)
}
return new Promise<string>((resolve, reject) => {
const id = ++nextRenderTask
renderTasks.set(id, { source, resolve, reject })
// oxlint-disable-next-line unicorn/require-post-message-target-origin
worker.postMessage({ id, source })
})
}
/**
* A slide's stable id. Deterministic for a given position, so recompiling the
* project (on every keystroke) never renames a slide underneath the runtime.
*/
function slideId(index: number): string {
export function slideId(index: number): string {
return `slide-${index}`
}
@ -145,15 +241,14 @@ async function compileStyles(source: string, ids: string[]): Promise<string> {
// Fast path: without `:thisSlide` every slide resolves to the same CSS, so
// compile once instead of once per slide.
if (!source.includes(THIS_SLIDE)) {
const { css } = await less.render(source)
return css
return renderLess(source)
}
const perSlide = await Promise.all(
ids.map(async (id) => {
const selector = slideSelector(id)
const scoped = source.split(THIS_SLIDE).join(selector)
const { css } = await less.render(scoped)
const css = await renderLess(scoped)
return `/* ${selector} */\n${css}`
})
)

43
src/slides/less-worker.ts Normal file
View file

@ -0,0 +1,43 @@
// Worker `postMessage` takes no targetOrigin; that rule only targets windows.
/* oxlint-disable unicorn/require-post-message-target-origin */
// Side-effect import first: it shims `window`/`document` for less' auto-init,
// which reads them while the less module evaluates — a static import would
// hoist above any code in this file, hence the separate module.
import './worker-globals'
import less from 'less'
/**
* Renders one Less source per message so the main thread never blocks on a
* big stylesheet. Replies carry the task id back with either the CSS or the
* error message — a Less syntax error is an ordinary reply, not a crash.
*/
interface RenderRequest {
id: number
source: string
}
interface RenderReply {
id: number
css?: string
error?: string
}
// Typed narrowly on purpose: this file runs as a worker, but the program is
// typed with the DOM libs (no WebWorker lib), so `self` here is the scope.
const scope = self as unknown as {
addEventListener(type: 'message', listener: (event: MessageEvent<RenderRequest>) => void): void
postMessage(message: RenderReply): void
}
scope.addEventListener('message', (event) => {
const { id, source } = event.data
less.render(source)
.then(({ css }) => scope.postMessage({ id, css }))
.catch((error: unknown) => {
scope.postMessage({
id,
error: error instanceof Error ? error.message : String(error)
})
})
})

View file

@ -0,0 +1,14 @@
// less' browser build runs a page auto-init while its module loads: it
// reaches for `window`, copies any `window.less` config, then — unless told
// otherwise — hides `<body>` and rescans the document's stylesheets. A worker
// has none of that, so bind `window` to the worker scope, stub the single
// `document` probe the init makes (null-safe downstream), and pre-set
// `onReady: false` so init stops right after building the compiler.
// The worker imports this for side effects before `less`, so ESM evaluation
// order guarantees the shim exists by the time the auto-init runs.
if (typeof window === 'undefined') {
const worker = globalThis as unknown as Record<string, unknown>
worker.window = worker
worker.document = { getElementsByTagName: () => [] }
worker.less = { onReady: false }
}