feat(slide-deck): present imported PPTX decks in the page - #825
Merged
Merged
Conversation
Adds the front end half of #2947. The import route landing in haxcms-nodejs#26 writes files/decks/<name>/deck.json plus the original .pptx; this is the element that consumes it: <slide-deck source="files/decks/my-talk/deck.json"></slide-deck> Content is layered so a deck degrades rather than breaks. The manifest's per slide text and speaker notes always render and are what assistive technology and site search read. When the manifest names a pptx, the real file is painted over that text by @aiden0z/pptx-renderer (Apache-2.0), imported dynamically only once the deck intersects the viewport, so a deck further down a page costs nothing until it is looked at. If the renderer cannot run the text layer simply stays visible. Only the renderer's low level API is used (parseZip -> buildPresentation -> renderSlide) so all navigation and chrome belongs to the element, not the library. Includes slide and grid modes, speaker notes, prev/next with arrow key, Home and End support, per deck #<deck-id>-slide-<n> deep links, copy link, and fullscreen present mode. The painted surface is aria-hidden; the manifest text is the accessibility contract. Two details worth flagging: - pdfjs is deliberately left unconfigured. The renderer declares it as a peer dependency but only uses it for EMF embedded PDF fallbacks, loaded from a runtime URL. Wiring it would mean fetching a library at runtime, so that narrow case falls back to slide text instead. - Manifest media paths are relative to deck.json, not to the page, so they are resolved against the manifest URL. Resolving against location would break every image on a nested page route. Verified with a 6 slide deck covering text, preset autoshapes, a table, an embedded image and a chart: 6/6 slides rendered with no errors, ~430ms cold including the renderer import. Element suite is 7/7. Refs #2947 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
@SanikaA3 is attempting to deploy a commit to the HAXTheWeb Team on Vercel. A member of the Team first needs to authorize it. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
There was a problem hiding this comment.
🟡 Changes recommended
URL resolution, unsafe manifest rendering, stale asynchronous state, missing notes, and renderer lifecycle issues currently affect correctness and security.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds a HAX-compatible slide-deck component for accessible, lazily rendered PPTX presentations.
Changes:
- Implements slide/grid navigation, deep links, fullscreen mode, and fallback text.
- Integrates the PPTX renderer with lazy loading.
- Adds tests, localization, HAX metadata, documentation, and project scaffolding.
File summaries
| File | Description |
|---|---|
elements/slide-deck/slide-deck.js |
Implements the presentation component. |
elements/slide-deck/lib/slide-deck-renderer.js |
Wraps the PPTX rendering API. |
elements/slide-deck/lib/slide-deck.haxProperties.json |
Defines HAX configuration. |
elements/slide-deck/test/slide-deck.test.js |
Tests loading, navigation, accessibility, and errors. |
elements/slide-deck/demo/index.html |
Provides the component demo. |
elements/slide-deck/demo/deck.json |
Provides a sample manifest. |
elements/slide-deck/README.md |
Documents usage and properties. |
elements/slide-deck/package.json |
Defines package metadata and dependencies. |
elements/slide-deck/locales/slide-deck.ar.json |
Adds Arabic UI translations. |
elements/slide-deck/locales/slide-deck.ar.haxProperties.json |
Adds Arabic HAX translations. |
elements/slide-deck/locales/slide-deck.bn.json |
Adds Bengali UI translations. |
elements/slide-deck/locales/slide-deck.bn.haxProperties.json |
Adds Bengali HAX translations. |
elements/slide-deck/locales/slide-deck.es.json |
Adds Spanish UI translations. |
elements/slide-deck/locales/slide-deck.es.haxProperties.json |
Adds Spanish HAX translations. |
elements/slide-deck/locales/slide-deck.fr.json |
Adds French UI translations. |
elements/slide-deck/locales/slide-deck.fr.haxProperties.json |
Adds French HAX translations. |
elements/slide-deck/locales/slide-deck.hi.json |
Adds Hindi UI translations. |
elements/slide-deck/locales/slide-deck.hi.haxProperties.json |
Adds Hindi HAX translations. |
elements/slide-deck/locales/slide-deck.ja.json |
Adds Japanese UI translations. |
elements/slide-deck/locales/slide-deck.ja.haxProperties.json |
Adds Japanese HAX translations. |
elements/slide-deck/locales/slide-deck.pt.json |
Adds Portuguese UI translations. |
elements/slide-deck/locales/slide-deck.pt.haxProperties.json |
Adds Portuguese HAX translations. |
elements/slide-deck/locales/slide-deck.ru.json |
Adds Russian UI translations. |
elements/slide-deck/locales/slide-deck.ru.haxProperties.json |
Adds Russian HAX translations. |
elements/slide-deck/locales/slide-deck.zh.json |
Adds Chinese UI translations. |
elements/slide-deck/locales/slide-deck.zh.haxProperties.json |
Adds Chinese HAX translations. |
elements/slide-deck/LICENSE |
Adds the Apache 2.0 license. |
elements/slide-deck/index.html |
Redirects to component documentation. |
elements/slide-deck/gulpfile.cjs |
Adds development watch tasks. |
elements/slide-deck/.github/workflows/main.yml |
Adds build and deployment automation. |
elements/slide-deck/.travis.yml |
Adds legacy test configuration. |
elements/slide-deck/.surgeignore |
Configures Surge deployment files. |
elements/slide-deck/.npmignore |
Excludes dependencies from publication. |
elements/slide-deck/.nojekyll |
Disables Jekyll processing. |
elements/slide-deck/.gitignore |
Defines ignored generated files. |
elements/slide-deck/.editorconfig |
Defines editor formatting rules. |
elements/slide-deck/.dddignore |
Configures DDD audit exclusions. |
Review details
Suppressed comments (4)
elements/slide-deck/slide-deck.js:108
- Changing
sourcecan leave the component in an inconsistent state:loadDeck()neither invalidates an earlier fetch nor disposes_renderer. A previously painted deck will therefore render the new manifest with the old PPTX, and a slower old request can overwrite a newer source; the retained slide number can also be out of range. Version/cancel loads and reset the renderer and slide when beginning a new source.
elements/slide-deck/slide-deck.js:446 - The backend's
notesvalue is raw PPTX text rather than trusted HTML, sounsafeHTML(slide.notes)turns literal markup in speaker notes into executable DOM. Render notes through normal Lit interpolation (or sanitize them if markup becomes part of the contract).
elements/slide-deck/slide-deck.js:474 - Slide mode has another unsanitized
slide.htmlsink. Even if the current importer escapes PPTX text,sourceaccepts any absolute URL, so the component cannot treat every fetched manifest as trusted; sanitize before passing the value tounsafeHTML.
elements/slide-deck/lib/slide-deck-renderer.js:84 - The scale is calculated only when a slide is rendered. When the host width changes—especially on entering/exiting the advertised fullscreen mode—the stage resizes but this fixed transform does not, leaving the slide undersized or clipped until navigation triggers another render. Observe stage resizing and recompute the transform (and disconnect that observer during disposal).
handle.element.style.transformOrigin = "top left";
handle.element.style.transform = `scale(${target.clientWidth / this.presentation.width})`;
- Files reviewed: 36/37 changed files
- Comments generated: 8
- Review effort level: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| > | ||
| <div> | ||
| <strong>${this.t.slide} ${slide.number}</strong> | ||
| ${unsafeHTML(this._resolveMedia(slide.html))} |
| if (!slide) { | ||
| return; | ||
| } | ||
| this.dispose(index); |
| presenting: { type: Boolean, reflect: true }, | ||
| deck: { type: Object }, | ||
| status: { type: String, reflect: true }, | ||
| rendered: { type: Boolean }, |
| this.status = "loading"; | ||
| this.rendered = false; | ||
| try { | ||
| const manifestUrl = new URL(this.source, globalThis.location.href); |
Comment on lines
+144
to
+145
| // media inside the manifest is stored beside it, not beside the page | ||
| this._base = manifestUrl; |
Comment on lines
+189
to
+193
| if (!this._renderer) { | ||
| const { DeckRenderer } = await import("./lib/slide-deck-renderer.js"); | ||
| this._renderer = await DeckRenderer.load( | ||
| new URL(this.deck.pptx, this._base).href, | ||
| ); |
Comment on lines
+251
to
+254
| _readHash() { | ||
| const match = new RegExp(`^#${this.hashPrefix}-slide-(\\d+)$`).exec( | ||
| globalThis.location.hash, | ||
| ); |
Comment on lines
+472
to
+475
| <div class="text"> | ||
| <h3>${current.title}</h3> | ||
| ${unsafeHTML(this._resolveMedia(current.html))} | ||
| </div>`} |
The PPTX import writes site relative paths: source is files/decks/<name>/deck.json, pptx is files/decks/<name>/original.pptx, and slide media in slides[].html points into files/decks/<name>/ too. The element resolved source against location.href and everything inside the manifest against the manifest's own URL. On a HAXcms page below the site root the manifest 404'd, and where it did load, the pptx and media paths doubled up as files/decks/<name>/files/decks/<name>/... Resolve against document.baseURI instead, which is the <base href> HAXcms points at the site root. Slide media then resolves on its own, so _resolveMedia and the stored manifest base are removed. Addresses Copilot review on haxtheweb#825. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Only one slide is ever on stage, but the renderer kept every slide handle it had painted in a Map and only disposed a handle when that same index was painted again. Navigating 1 -> 2 -> 3 left slides 1 and 2, and their chart instances, alive until the element disconnected. Hold a single handle and release it before painting the next slide. The blob URL cache stays, since embedded media is shared across slides. The scale was also computed once per paint, so entering full screen or resizing the window left the slide undersized or clipped until the next navigation. A ResizeObserver on the stage now refits it, and is disconnected whenever the slide is released. Addresses Copilot review on haxtheweb#825. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
slides[].html went straight to unsafeHTML. source accepts any URL, so a manifest cannot be treated as trusted, and an event handler or javascript: URL inside it would run in the page. Pass it through sanitizeHTMLString from @haxtheweb/utils first, as other elements do for the same sink. Speaker notes are not markup at all: the import route stores the PPTX notes text joined with newlines, unescaped. Rendering them with unsafeHTML turned literal markup in a note into live DOM. Interpolate them as text instead, keeping their line breaks with pre-line. Notes also only appeared in grid mode, although the element documents them as part of the text layer that assistive technology and search read. They now render in the slide view as well. The demo manifest's notes are now plain text to match what the import writes. Addresses Copilot review on haxtheweb#825. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rendered was not reflected, so :host([rendered]) never matched and the fallback text stayed visible beneath every painted slide. Changing source left the element inconsistent. A slower earlier manifest could overwrite a newer one, the previous deck's renderer stayed around to paint the old pptx under the new manifest, and the current slide could fall outside the new deck, crashing render on a null slide. Each load now releases the renderer, ignores responses from superseded loads, and clamps the slide into range. The renderer was also only stored after its import, fetch and parse had finished, so navigating in that window started another load per call (three fetches of the pptx for 1 -> 2 -> 3) with paints racing each other. The pending load is now shared, and a paint overtaken by newer navigation, a mode change or a new source is dropped. Addresses Copilot review on haxtheweb#825. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
deck-id, or the deck title, was interpolated into a RegExp. An id such as "draft[1" threw while loading and left the deck unavailable, and an id containing spaces never matched because the browser percent-encodes the hash. The id is now URI-encoded once and matched as a plain prefix. Syncing the hash also passed a bare "#fragment" to history.replaceState, which resolves against the document base. On a HAXcms page that base is the site root, so every slide change rewrote /site/my-page to /site/#... The URL is now built from location.href, as copyLink already does. Addresses Copilot review on haxtheweb#825. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Refs #2947. This is the front end half — the element that consumes the
deck.jsonwritten by the import route in haxtheweb/haxcms-nodejs#26.How it renders
Content is layered so a deck degrades rather than breaks:
pptx, the original file is painted over that text by@aiden0z/pptx-renderer(Apache-2.0), dynamically imported only once the deck intersects the viewport. A deck further down a page costs nothing until it is looked at, and if the renderer cannot run the text layer simply stays visible.Only the renderer's low level API is used (
parseZip→buildPresentation→renderSlide), so all navigation and chrome belongs to the element rather than the library. The painted surface isaria-hidden; the manifest text is the accessibility contract.What's included
Slide and grid modes (grid pairs each slide with its speaker notes), prev/next with arrow key,
HomeandEndsupport, per deck#<deck-id>-slide-<n>deep links, copy link, and fullscreen present mode. All twelve UI strings are translated across the nine scaffolded locales.Two decisions worth flagging
pdfjs is deliberately left unconfigured. The renderer declares
pdfjs-distas a peer dependency, but only uses it for EMF embedded PDF fallbacks, loaded from a runtime URL. Wiring that up would mean fetching a library at runtime, so that narrow case falls back to slide text instead. Happy to revisit as a vendored opt-in if it turns out to matter.Import paths resolve against the site base.
source,pptxand thefiles/decks/<name>/image paths the import route rewrites intoslides[].htmlare all site relative, so they resolve againstdocument.baseURI— the<base href>HAXcms points at the site root. Resolving them againstlocationor againstdeck.jsonbreaks them on nested page routes.Verification
Built a six slide deck covering text, bullets, preset autoshapes, a table, an embedded raster image and a chart, and rendered it through the same low level path the element uses:
Element test suite is 7/7, covering manifest load, navigation clamping, grid mode, the text layer staying available when nothing is painted, and an unreadable manifest reporting rather than throwing.
The renderer path itself was verified separately against a real
.pptxfixture — slide painted over the text layer, repaint on navigation, and aspect ratio derived from the deck. Those fixtures were temporary and are not part of this PR; a mutation check (pointing the manifest at a missing.pptx) confirmed the assertions were real, and incidentally confirmed the degradation path: the renderer tests failed while all element tests still passed.Scope
This PR is the element only. The drag a
.pptxintohax-body→ "Render as presentation" workflow and the Merlin import program from the plan are not included, since they touchhax-store/hax-tray-uploadand the site editor. #2947 should stay open for those and for the PHP backend parity.🤖 Generated with Claude Code