1
0
Fork 0
forked from jan/psslides

fix: bind :thisSlide per style block instead of per compile pass

Every slide's :thisSlide rules leaked into every other slide: the whole
joined source was recompiled once per slide with all occurrences rebound,
so each pass attached every slide's scoped rules to that pass's slide and
the concatenated output applied them all to everyone. Bind the token while
each block still belongs to one slide (global block -> any slide) and
compile the project once - also drops the O(n) duplicated CSS output.
This commit is contained in:
navi 2026-09-28 14:29:37 +02:00
parent 97ae327415
commit a05c860c6e

View file

@ -109,6 +109,9 @@ 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
@ -206,52 +209,40 @@ function isGlobalTemplate(global: string): boolean {
* 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). That stylesheet is compiled
* once per slide, with `:thisSlide` replaced by the selector of the slide being
* compiled — so Less keeps behaving like Less, and the output never depends on
* which slide happens to be visible.
* global slide first, then each slide in order), and `:thisSlide` is rewritten
* per style block *before* the blocks are joined: the global block to any
* slide, each slide's block to that slide's own section. The joined source
* then compiles exactly once — so Less keeps behaving like Less, the output
* never depends on which slide is visible, and one slide's scoped rules can
* never bind to its neighbours.
*/
export async function compilePresentation(input: CompileInput): Promise<CompiledPresentation> {
const global = extractStyles(input.global ?? '')
const globalIsTemplate = isGlobalTemplate(global.html)
const sources: string[] = [global.style]
const extracted = input.slides.map((markup) => extractStyles(markup))
const slides: CompiledSlide[] = input.slides.map((markup, index) => {
const extracted = extractStyles(markup)
sources.push(extracted.style)
return {
id: slideId(index),
index,
html: globalIsTemplate ? extracted.html : buildSlideHtml(extracted.html, global.html)
}
})
const slides: CompiledSlide[] = extracted.map((slide, index) => ({
id: slideId(index),
index,
html: globalIsTemplate ? slide.html : buildSlideHtml(slide.html, global.html)
}))
const css = await compileStyles(
sources.join('\n'),
slides.map((slide) => slide.id)
)
// Bind `:thisSlide` while each block still belongs to exactly one slide:
// the global block means "whoever is showing", a slide block means that
// slide. Rebinding after the join (the old per-slide passes) rebound every
// block on every pass, so each slide's scoped rules matched all slides.
const sources = [
bindThisSlide(global.style, ANY_SLIDE_SELECTOR),
...extracted.map((slide, index) =>
bindThisSlide(slide.style, slideSelector(slideId(index)))
)
]
const source = sources.join('\n')
const css = source.trim() ? await renderLess(source) : ''
return { slides, css, template: global.html }
}
async function compileStyles(source: string, ids: string[]): Promise<string> {
if (!source.trim()) {
return ''
}
// Fast path: without `:thisSlide` every slide resolves to the same CSS, so
// compile once instead of once per slide.
if (!source.includes(THIS_SLIDE)) {
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 renderLess(scoped)
return `/* ${selector} */\n${css}`
})
)
return perSlide.join('\n')
/** Rewrite the `:thisSlide` authoring token to a concrete selector. */
function bindThisSlide(source: string, selector: string): string {
return source.split(THIS_SLIDE).join(selector)
}