forked from jan/psslides
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:
parent
14916e6416
commit
97ae327415
5 changed files with 305 additions and 25 deletions
|
|
@ -3,6 +3,7 @@ import { domToSvgDataUrl } from './dom-to-image'
|
||||||
export interface CodeToElementOptions {
|
export interface CodeToElementOptions {
|
||||||
title?: string
|
title?: string
|
||||||
allowScripts?: boolean
|
allowScripts?: boolean
|
||||||
|
/** Plain CSS strings — each becomes a `<style>` block in the iframe head. */
|
||||||
styles?: string[]
|
styles?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -18,7 +19,8 @@ export function codeToElement(code: string, options: CodeToElementOptions = {}):
|
||||||
|
|
||||||
iframe.title = options.title ?? 'Rendered code'
|
iframe.title = options.title ?? 'Rendered code'
|
||||||
iframe.setAttribute('sandbox', Array.from(sandbox).join(' '))
|
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
|
return iframe
|
||||||
}
|
}
|
||||||
|
|
|
||||||
164
src/main.ts
164
src/main.ts
|
|
@ -1,7 +1,13 @@
|
||||||
import './style.css'
|
import './style.css'
|
||||||
import { createEditor } from './editor'
|
import { createEditor } from './editor'
|
||||||
import { createCodeThumbnail } from './code-to-element'
|
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'
|
import { createRuntime, type Runtime } from './slides/runtime'
|
||||||
|
|
||||||
interface Slide {
|
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 = ''
|
let globalSlide = ''
|
||||||
|
|
||||||
const slides: Slide[] = [
|
const slides: Slide[] = [
|
||||||
|
|
@ -71,6 +117,12 @@ let globalSlideElement: HTMLElement | null = null
|
||||||
const slideElements = new Map<Slide, HTMLElement>()
|
const slideElements = new Map<Slide, HTMLElement>()
|
||||||
const thumbnailRequests = new Map<number, number>()
|
const thumbnailRequests = new Map<number, number>()
|
||||||
let thumbnailDebounceTimer: number | undefined
|
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
|
// 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
|
// 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) {
|
if (currentSlide >= 0) {
|
||||||
runtime.goTo(currentSlide)
|
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) {
|
} catch (error) {
|
||||||
// Half-typed Less should not blank the preview: keep the last good
|
// Half-typed Less should not blank the preview: keep the last good
|
||||||
// output on screen and report what broke.
|
// 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) {
|
async function generateSlideThumbnail(slide: Slide, i: number) {
|
||||||
const slideElement = document.createElement('div')
|
const slideElement = document.createElement('div')
|
||||||
slideElement.classList.add('slide-list-item')
|
slideElement.classList.add('slide-list-item')
|
||||||
|
|
@ -172,7 +271,6 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
|
||||||
slideElement.classList.add('global-slide')
|
slideElement.classList.add('global-slide')
|
||||||
} else {
|
} else {
|
||||||
slideElement.draggable = true
|
slideElement.draggable = true
|
||||||
slideElements.set(slide, slideElement)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const slideInList = document.createElement('button')
|
const slideInList = document.createElement('button')
|
||||||
|
|
@ -187,9 +285,11 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
|
||||||
slideNumber.classList.add('slide-number')
|
slideNumber.classList.add('slide-number')
|
||||||
slideNumber.textContent = i === -1 ? 'G' : `${i + 1}`
|
slideNumber.textContent = i === -1 ? 'G' : `${i + 1}`
|
||||||
|
|
||||||
// Style tags are the project stylesheet, never slide content: the
|
// The tile mirrors the presentation: composed markup, plus the compiled
|
||||||
// thumbnail renders markup only, exactly like the presentation does.
|
// stylesheet injected into the thumbnail document's head.
|
||||||
const thumbnail = await createCodeThumbnail(extractStyles(slide.code).html)
|
const thumbnail = await createCodeThumbnail(buildThumbnailHtml(i, slide.code), {
|
||||||
|
styles: lastResult?.css ? [lastResult.css] : []
|
||||||
|
})
|
||||||
slideInList.append(slideNumber, thumbnail)
|
slideInList.append(slideNumber, thumbnail)
|
||||||
slideElement.append(slideInList)
|
slideElement.append(slideInList)
|
||||||
|
|
||||||
|
|
@ -205,10 +305,6 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
|
||||||
slideElement.append(deleteButton)
|
slideElement.append(deleteButton)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (i === -1) {
|
|
||||||
globalSlideElement = slideElement
|
|
||||||
}
|
|
||||||
|
|
||||||
if (i === currentSlide) {
|
if (i === currentSlide) {
|
||||||
slideElement.classList.add('selected')
|
slideElement.classList.add('selected')
|
||||||
}
|
}
|
||||||
|
|
@ -219,9 +315,12 @@ async function generateSlideThumbnail(slide: Slide, i: number) {
|
||||||
async function refreshThumbnail(index: number, slide: Slide) {
|
async function refreshThumbnail(index: number, slide: Slide) {
|
||||||
const request = (thumbnailRequests.get(index) ?? 0) + 1
|
const request = (thumbnailRequests.get(index) ?? 0) + 1
|
||||||
thumbnailRequests.set(index, request)
|
thumbnailRequests.set(index, request)
|
||||||
|
const generation = listGeneration
|
||||||
const oldSlideElement = index === -1 ? globalSlideElement : slideElements.get(slide)
|
const oldSlideElement = index === -1 ? globalSlideElement : slideElements.get(slide)
|
||||||
const slideElement = await generateSlideThumbnail(slide, index)
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -257,15 +356,31 @@ async function mapWithConcurrency<T, R>(
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setSlideList() {
|
async function setSlideList() {
|
||||||
|
const generation = ++listGeneration
|
||||||
const allSlides = [{ code: globalSlide }, ...slides]
|
const allSlides = [{ code: globalSlide }, ...slides]
|
||||||
slideList.replaceChildren()
|
|
||||||
slideElements.clear()
|
|
||||||
globalSlideElement = null
|
|
||||||
|
|
||||||
const elements = await mapWithConcurrency(allSlides, THUMBNAIL_CONCURRENCY, (slide, index) =>
|
const elements = await mapWithConcurrency(allSlides, THUMBNAIL_CONCURRENCY, (slide, index) =>
|
||||||
generateSlideThumbnail(slide, index - 1)
|
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()
|
updateSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -340,11 +455,18 @@ function deleteSlide(index: number) {
|
||||||
addSlideButton.addEventListener('click', async () => {
|
addSlideButton.addEventListener('click', async () => {
|
||||||
const slide = { code: '' }
|
const slide = { code: '' }
|
||||||
slides.push(slide)
|
slides.push(slide)
|
||||||
const slideElement = await generateSlideThumbnail(slide, slides.length - 1)
|
|
||||||
slideList.append(slideElement)
|
|
||||||
updateSlideOrder()
|
|
||||||
setSlide(slides.length - 1)
|
setSlide(slides.length - 1)
|
||||||
scheduleCompile()
|
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', () => {
|
presentButton.addEventListener('click', () => {
|
||||||
|
|
@ -420,9 +542,13 @@ editor.onDidChangeModelContent(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
async function render() {
|
async function render() {
|
||||||
await setSlideList()
|
|
||||||
setSlide(currentSlide)
|
setSlide(currentSlide)
|
||||||
await rebuildPreview()
|
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()
|
render()
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,107 @@
|
||||||
import less from 'less'
|
import LessWorker from './less-worker?worker'
|
||||||
import { CSS_PLACEHOLDER, HTML_PLACEHOLDER } from './placeholders'
|
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
|
* A slide's stable id. Deterministic for a given position, so recompiling the
|
||||||
* project (on every keystroke) never renames a slide underneath the runtime.
|
* 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}`
|
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
|
// Fast path: without `:thisSlide` every slide resolves to the same CSS, so
|
||||||
// compile once instead of once per slide.
|
// compile once instead of once per slide.
|
||||||
if (!source.includes(THIS_SLIDE)) {
|
if (!source.includes(THIS_SLIDE)) {
|
||||||
const { css } = await less.render(source)
|
return renderLess(source)
|
||||||
return css
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const perSlide = await Promise.all(
|
const perSlide = await Promise.all(
|
||||||
ids.map(async (id) => {
|
ids.map(async (id) => {
|
||||||
const selector = slideSelector(id)
|
const selector = slideSelector(id)
|
||||||
const scoped = source.split(THIS_SLIDE).join(selector)
|
const scoped = source.split(THIS_SLIDE).join(selector)
|
||||||
const { css } = await less.render(scoped)
|
const css = await renderLess(scoped)
|
||||||
return `/* ${selector} */\n${css}`
|
return `/* ${selector} */\n${css}`
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
|
||||||
43
src/slides/less-worker.ts
Normal file
43
src/slides/less-worker.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
14
src/slides/worker-globals.ts
Normal file
14
src/slides/worker-globals.ts
Normal 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 }
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue