1
0
Fork 0
forked from jan/psslides
psslides/src/slides/compile.ts
navi eeabb70855 fix: scope every rule in a slide's own style block to that slide
Binding only the `:thisSlide` token still left every untokened rule in a
slide's tag compiling deck-wide, so one slide's styles kept applying to
all slides. Wrap each slide's whole style block in its section selector
instead, with `:thisSlide` resolving to `&` — Less hoists `@keyframes`/
`@font-face` out of the wrapper itself and keeps `@media` nested inside,
so only rules end up scoped. The global block stays the shared layer and
the deck still compiles exactly once.
2026-09-28 15:40:31 +02:00

258 lines
9 KiB
TypeScript

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.
*/
export function slideId(index: number): string {
return `slide-${index}`
}
function slideSelector(id: string): string {
return `[data-ps-slide="${id}"]`
}
/** What `:thisSlide` means inside the global slide's styles: any slide. */
const ANY_SLIDE_SELECTOR = '[data-ps-slide]'
const THIS_SLIDE = ':thisSlide'
// `<?= … ?>` is a bogus comment to the HTML parser, so a placeholder would not
// survive the DOM round-trip inside extractStyles. Hide the placeholders as
// plain text while parsing, put them back afterwards.
const HTML_SENTINEL = '__PSHTML__'
const CSS_SENTINEL = '__PSCSS__'
export interface CompiledSlide {
/** Stable id, used as the value of the slide's `data-ps-slide` attribute. */
id: string
index: number
html: string
}
export interface CompileInput {
/** HTML source of every slide, in presentation order. */
slides: string[]
/**
* The global slide: either markup shared by every slide, or a template
* carrying `<?= html ?>` / `<?= css ?>` for the runtime to fill in.
*/
global?: string
}
export interface CompiledPresentation {
slides: CompiledSlide[]
css: string
/**
* The global slide, ready for the runtime. A template (`<?= html ?>`
* inside) is rendered once as the scene; anything else was already merged
* into every slide and the runtime ignores it.
*/
template: string
}
/**
* Pull `<style>` tags out of a fragment.
*
* The markup stays HTML and the tag contents become the project's Less, so
* authors write styles right in the slide they are editing. The tags are
* removed from the markup because their raw Less must never reach the DOM —
* the compiled CSS is injected separately, at `<?= css ?>`. A style tag that
* *is* that injection point is left alone.
*
* Parsed as a fragment rather than a full document: as a document a leading
* `<style>` would land in `<head>` and be lost from the body output, taking
* the injection point with it.
*/
export function extractStyles(markup: string): { html: string; style: string } {
if (!markup.includes('<style')) {
return { html: markup, style: '' }
}
const fragment = document.createElement('div')
fragment.innerHTML = markup
.split(HTML_PLACEHOLDER)
.join(HTML_SENTINEL)
.split(CSS_PLACEHOLDER)
.join(CSS_SENTINEL)
const blocks: string[] = []
for (const element of Array.from(fragment.querySelectorAll('style'))) {
const source = element.textContent ?? ''
if (source.includes(CSS_SENTINEL)) {
// This tag is where the compiled CSS gets injected, not authoring.
continue
}
blocks.push(source)
element.remove()
}
const html = fragment.innerHTML
.split(HTML_SENTINEL)
.join('<?= html ?>')
.split(CSS_SENTINEL)
.join('<?= css ?>')
return { html, style: blocks.join('\n') }
}
function buildSlideHtml(markup: string, global: string): string {
return global ? `${global}\n${markup}` : markup
}
/**
* The global slide is a template when it carries `<?= html ?>`: the runtime
* renders it once and mounts the slides at the placeholder, so its markup must
* not be copied into every slide.
*/
function isGlobalTemplate(global: string): boolean {
return HTML_PLACEHOLDER.test(global)
}
/**
* Compile a project into runtime-ready data.
*
* Every `<style>` tag in the deck contributes to one project stylesheet (the
* global slide first, then each slide in order). The global block is the
* shared layer and compiles deck-wide; each slide's block is *wrapped* in its
* section selector before the blocks are joined, so a slide's stylesheet
* styles that slide and nothing else — token or not — with `:thisSlide`
* resolving to the section itself. The joined source compiles exactly once:
* Less stays Less, the output never depends on which slide is visible, and
* one slide's rules can never reach its neighbours.
*/
export async function compilePresentation(input: CompileInput): Promise<CompiledPresentation> {
const global = extractStyles(input.global ?? '')
const globalIsTemplate = isGlobalTemplate(global.html)
const extracted = input.slides.map((markup) => extractStyles(markup))
const slides: CompiledSlide[] = extracted.map((slide, index) => ({
id: slideId(index),
index,
html: globalIsTemplate ? slide.html : buildSlideHtml(slide.html, global.html)
}))
// Scope each block while it still belongs to exactly one slide: the
// global block means "everywhere", a slide block means that slide. Only
// rewriting the `:thisSlide` token (the previous fix) still left every
// untokened rule deck-wide — the whole block now carries the selector.
const sources = [
bindThisSlide(global.style, ANY_SLIDE_SELECTOR),
...extracted.map((slide, index) => scopeBlock(slide.style, slideSelector(slideId(index))))
]
const source = sources.join('\n')
const css = source.trim() ? await renderLess(source) : ''
return { slides, css, template: global.html }
}
/** Rewrite the `:thisSlide` authoring token to a concrete selector. */
function bindThisSlide(source: string, selector: string): string {
return source.split(THIS_SLIDE).join(selector)
}
/**
* Scope a slide's whole style block to its section: wrap the block in the
* slide's selector and let `:thisSlide` become `&` — the section itself.
* Less hoists `@keyframes`/`@font-face` out of the wrapper on its own and
* keeps `@media` nested inside it, so only rules end up scoped.
*/
function scopeBlock(source: string, selector: string): string {
if (!source.trim()) return ''
return `${selector} {\n${source.split(THIS_SLIDE).join('&')}\n}`
}