Skip to content

slides: clickable web links, * and indented bullets, and a complete shortcut list - #465

Merged
nyblnet merged 10 commits into
mainfrom
slides-links-bullets-help
Sep 14, 2026
Merged

nyblnet merged 10 commits into
mainfrom
slides-links-bullets-help

Conversation

@nyblnet

@nyblnet nyblnet commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Three items from the issue/discussion scan, each small enough for this release. Needs a security pass on the links half before naming — it adds <a href> to the text sanitizer's allowlist and opens external pages from the show.

Clickable links (#421 ← discussions #373, #374; the maintainer promised it on 09-05). An element's link may be an http(s) URL as well as a slide id (Presenting → Web link), and [caption](https://…) in a text box becomes an anchor. One scheme test, isWebUrl (http/https only, bounded, no quote/angle bracket), asked by all four surfaces: the shape gate, the text sanitizer (an <a> keeps its href only when it passes, and is unwrapped to text otherwise — never kept href-less), both markdown converters, and the show before it opens anything. Opening is always window.open(url, '_blank', 'noopener,noreferrer') — never a navigation of the deck; target/rel are decided at click time and never stored. In the editor a link click edits text. The offline switch is honoured with a toast.

Measured in Chrome against the built shell: text anchors and element links open with exactly those flags and the page stays; a javascript: anchor is unwrapped to its text; slide links still jump; the editor never navigates; with offline mode on nothing opens. test-sanitize.ts gains the anchor checks (143/143 in the browser: a web anchor keeps exactly its href and no other attribute; javascript:/data:/attribute-less anchors become text). scripts/test-slides-links.ts (27, registered) pins isWebUrl, both converters, and the four callers by shape.

* and indented bullets (#255, #368). * makes a bullet like - ; two or more leading spaces make a sub-bullet — while typing (autoformat, ⌘Z-revertible as before) and when pasting markdown (indent kept as NBSPs so it survives whitespace collapsing).

Shortcut list (#269). The ? overlay now names B, G, [ ], ⌘B/I/U, the zoom keys, and the arrow keys' two jobs — every one verified against a handler that exists.

Also: 16 strings in all 8 catalogs and all 22 packs (4 of them pre-existing gaps from the code-snippet work — "Add a code snippet", "Source Code", the JSON-copy tooltip, the offline-mode toast; packs now 91.0%); changelog entries; a DECISIONS entry for the link decisions. Typecheck, build:single + splice gate, ci-registered 315, export-secrets, pagination, steps, clipboard 81, i18n, modelkeys all green. No CLAUDE.md/AGENTS.md change. Appends to [Unreleased] — a one-line rebase against #463 whichever lands second.

…hortcut list

Three things from the issue and discussion scan, each small enough for
this release.

Links (#421; discussions #373, #374). An element's `link` may be an
http(s) URL as well as a slide id (Presenting → Web link), and
[caption](https://…) in a text box becomes an <a href>. One scheme test,
isWebUrl in model.ts, is asked by every surface: the shape gate, the text
sanitizer (an <a> keeps its href only when it passes and is unwrapped to
text otherwise), both markdown converters, and the show before it opens
anything — always a NEW tab with noopener,noreferrer, never a navigation
of the deck, target/rel decided at click time and never stored. In the
editor a link click edits text. The offline switch is honoured with a
toast. Measured in Chrome: text and element links open via window.open
with those flags and the deck stays; a javascript: anchor is unwrapped;
slide links still jump; the editor never navigates; with offline mode on
nothing opens. test-sanitize gains the anchor checks (143 in the browser);
scripts/test-slides-links.ts (27) pins isWebUrl, the converters, and by
shape the four callers.

Bullets (#255, #368). "* " makes a bullet like "- ", and two or more
leading spaces make an indented sub-bullet — while typing (autoformat,
undoable with ⌘Z like before) and when pasting markdown (indent kept as
NBSPs).

Shortcuts (#269). The ? overlay names B, G, [ ], ⌘B/I/U, the zoom keys,
and the arrow keys' two jobs.

Sixteen strings in all eight catalogs and all 22 packs (four of them
pre-existing gaps from the code-snippet work); changelog entries;
DECISIONS entry for the link decisions.
auxclick is not click: the browser's default for it on an anchor is
'open in a new tab', straight past the offline gate and the noreferrer
flag. Intercepted on the show's slide host; button 1 opens through
openWeb like a click, anything else is swallowed. Pinned by shape.
@github-actions

github-actions Bot commented Sep 13, 2026

Copy link
Copy Markdown

Build size

main (dcb1ff5) → slides-links-bullets-help (303109f)

app base PR change
bento/slides 701.9 KiB 707.4 KiB +5.6 KiB (+0.79%)
bento/spaces 273.0 KiB 273.0 KiB 0.0 KiB (0.00%)
bento/dash 424.4 KiB 424.4 KiB 0.0 KiB (0.00%)

Updated: 2026-09-14T00:22:40Z

The walk visited a snapshot of a node's children; an unknown element's
children were lifted into place after the snapshot and not visited on
that pass. Walk first, then lift, so lifted children are held to the
same rule as their siblings. One case in test-sanitize: nested markup
is walked like top-level markup, rendered and exercised in the browser.
…are walked before it is unwrapped

rel='noopener noreferrer' is set on the show's anchors when a section is
built — never stored, never in render.ts — so the browser's own routes to
an anchor (context menu, drag), which do not pass through the click
handler, send no referrer on a hosted deck either. The refused-anchor
unwrap now walks its contents first, like the general unwrap. The
nested-markup sanitizer case gains the anchor shape.
@nyblnet

nyblnet commented Sep 13, 2026

Copy link
Copy Markdown
Owner Author

Residual worth stating: the browser's own routes to a visible link — the context menu's open in new tab, dragging the link out — do not pass through the show's click handler, so they bypass the offline gate; they are the viewer's own act on a visible link and are not intercepted. Show anchors now carry rel="noopener noreferrer" set at mount (never stored), so those routes send no referrer either. Middle-click is routed through the same door as a click.

…temporary merge of the fix branch is now redundant
#466 landed an isWebUrl beside the embed element (scheme only); this
branch had a stricter one (scheme, length, no quote or angle bracket).
One definition now, the stricter, where the embed's was; every caller
(embed url gate, live-frame gate, element link, text anchor, markdown)
asks it. The embed rig passes against it unchanged (64/64).
@nyblnet nyblnet closed this Sep 13, 2026
@nyblnet nyblnet reopened this Sep 13, 2026
@nyblnet
nyblnet merged commit c7e3030 into main Sep 14, 2026
2 checks passed
nyblnet added a commit that referenced this pull request Sep 14, 2026
nyblnet added a commit that referenced this pull request Sep 15, 2026
…, formulas matched in text segments only (an href with $ stays intact), formula cache capped at 256

resolveMath split on tags and transformed only the text runs: since #465
a link whose href carried $a$b had a <math> written into the attribute
(dead link, stray markup — nothing author-chosen became an attribute).
The rig now asserts every style value the emitter produces is one of its
constant forms or a bare colour, that a quote in \text reaches the output
as &quot;, the three href cases, and the LRU cap.
lasjoh-toho added a commit to lasjoh-toho/bento that referenced this pull request Sep 15, 2026
…ream, embed element deliberately excluded)

Adopted the links + bullets half of upstream nyblnet/bento's own nyblnet#465
("clickable web links, * and indented bullets, and a complete shortcut
list"). Deliberately did NOT port nyblnet#466 (the embed element) despite the
two sharing an isWebUrl definition upstream — that's a separate,
significantly more involved and security-sensitive feature (sandboxed
live iframes, embedding another deck's full JSON with its own
collaboration secrets needing careful envelope-stripping, a dedicated
~400-line security test suite) that deserves its own focused pass, not
a rushed inclusion here. Defined isWebUrl standalone in this fork's own
model.ts instead — the two features are functionally independent; they
only happened to be developed in an overlapping way upstream. The
"shortcut list" third of the original PR also isn't included — a
distinct, separate discoverability feature, not part of this request.

## Links

An element's `link` may now be an http(s) URL as well as a slide id,
and [caption](https://…) in a text box becomes a clickable <a href> —
both while typing (autoformat) and when pasting markdown text. One
scheme test (isWebUrl: http/https only, bounded to 2048 chars, no
quote or angle bracket — blocking attribute-breakout attempts) is
asked by every surface: the sanitizer (an <a> keeps its href only when
it passes, unwrapped to plain text otherwise), both markdown
converters, the element-link panel UI, and the show itself before it
opens anything.

Opening always goes through a new tab (window.open with
noopener,noreferrer — the deck itself never navigates away, and the
target page can't reach back into this window or see where the click
came from), gated by this fork's own existing offlineEnabled() check
(a toast explains why nothing opened, reusing the existing
flashPresentMsg mechanism). A middle-click (auxclick, which bypasses a
plain 'click' handler and the browser's own default for it on an
anchor is "open immediately, past every one of those safeguards") is
routed through the exact same door. rel="noopener noreferrer" is also
set directly on every anchor at slide-mount time, in the show only,
never persisted to the saved document — closing the gap where the
browser's OWN routes to a link (its context menu, a drag) don't pass
through any click handler at all and would otherwise leak this page's
own location as the referrer.

In the editor itself, clicking a link edits text like any other
content and never navigates away — a new capture-phase click guard on
the canvas stage specifically prevents that.

Found and fixed a real, narrow sanitizer gap while porting this: the
unknown/disallowed-element unwrap path never recursively walked its
own children before lifting them out — meaning any attribute-bearing
content (now including hrefs) nested inside a disallowed wrapper tag
would skip sanitization entirely. Walking first, then lifting, closes
that gap — directly relevant now that anchors carry a security-checked
attribute worth actually validating.

Scope note: this fork has no untrusted.ts (a formal shape-gate
validator for pasted/CRDT-synced content) at all — a pre-existing
architectural difference from upstream, not a new gap introduced by
this specific change. The sanitizer (render.ts) remains the primary,
already-ported defense for link content either way.

## Bullets

"* " now makes a bullet exactly like "- " already did; two or more
leading spaces make an indented, hollow sub-bullet (◦) — both while
typing (autoformat, undoable with ⌘Z like the existing single-level
bullet already was) and when pasting markdown text (indent preserved
as NBSPs so it survives HTML whitespace collapsing). Caught and fixed
a genuine encoding mismatch while porting this: the bullet-replacement
character in this fork's OWN pre-existing code already used a non-
breaking space after the glyph, not a plain space (unlike upstream's
own version) — my first attempt didn't match that and had to be
corrected via precise byte-level inspection (od -c) to stay consistent
with this fork's own established convention rather than silently
introducing a mixed-whitespace inconsistency between the old
single-level bullet and the new sub-bullet.

Verified: tsc -b clean, full build:single succeeds. Unit-tested
isWebUrl directly against ten cases — valid http/https URLs, and every
dangerous one it must reject (javascript:, data:, file:, an attribute-
breakout attempt via an embedded quote, an angle bracket, an
over-length string, a non-string value, and a plain slide id, which is
NOT a URL and must fall through to the existing slide-jump path) — all
ten matched expectations exactly. Also decompressed the actual built
.bento.html to confirm the new German UI strings, the noopener/
noreferrer flags, the offline-mode message, and the sub-bullet glyph
all landed in what ships.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant