forked from jan/psslides
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.
258 lines
9 KiB
TypeScript
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}`
|
|
}
|