oficina / slides Public

Guard shared error pages and estilo tokens

Paul Campbell pushed by paul@keymail.dev b19fbda8000e52acdc8875c617739e5cbba9d2fc parent 5e0bb77
4 files changed, +135 −3
  • internal/slides/static/editor.css +1 −1
  • internal/slides/templates/present_notes.html +2 −2
  • internal/slidestest/index_test.go +76 −0
  • internal/slidestest/vendored_test.go +56 −0

CI passed — run details

CI log
=== gate ===
=== rust: fmt
=== rust: clippy
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.22s
=== wasm: the artifact the host embeds
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.06s
    Finished `release` profile [optimized] target(s) in 0.13s
core/target/wasm32-unknown-unknown/release/slides_core.wasm (1228229 bytes)
=== the embedded wasm must match a fresh build
=== rust: test
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.05s
     Running unittests src/lib.rs (core/target/debug/deps/slides_core-929bfceb2edd4a94)

running 30 tests
test check::tests::the_empty_document_passes ... ok
test layout::tests::aside_region_rect ... ok
test check::tests::a_non_paragraph_child_is_reported ... ok
test check::tests::an_element_inside_a_paragraph_is_reported ... ok
test check::tests::what_the_ops_write_passes ... ok
test check::tests::an_unknown_formatting_attribute_is_reported ... ok
test check::tests::y_prosemirror_shapes_pass ... ok
test check::tests::bare_text_at_fragment_level_is_reported ... ok
test layout::tests::body_region_rect_narrows_for_an_aside ... ok
test layout::tests::to_flow_is_a_no_op_when_already_in_order ... ok
test layout::tests::to_canvas_splits_a_shared_region_equally_and_sets_z_by_index ... ok
test markdown::tests::inline_marks ... ok
test markdown::tests::marks_nest_and_links_carry_marks ... ok
test layout::tests::to_canvas_narrows_body_when_an_aside_is_present ... ok
test layout::tests::default_region_by_kind ... ok
test schema::tests::href_written_as_a_plain_string_still_reads_back ... ok
test layout::tests::heading_region_rect ... ok
test layout::tests::media_region_rect ... ok
test schema::tests::an_empty_map_reads_as_a_set_mark ... ok
test layout::tests::to_flow_classifies_by_position_and_kind ... ok
test schema::tests::an_href_map_without_a_string_url_reads_as_no_link ... ok
test view::tests::assets_are_typed_or_skipped ... ok
test view::tests::malformed_background_is_the_default ... ok
test layout::tests::to_flow_keeps_a_concurrent_text_edit ... ok
test markdown::tests::empty_input_is_no_paragraphs_and_lines_keep_blanks ... ok
test markdown::tests::paragraphs_split_on_blank_lines_and_list_markers ... ok
test schema::tests::href_is_written_as_a_one_key_map_and_reads_back ... ok
test view::tests::malformed_box_is_null_and_phone_keeps_only_typed_fields ... ok
test layout::tests::to_flow_orders_y_then_x ... ok
test view::tests::malformed_fonts_are_null ... ok

test result: ok. 30 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.02s

     Running tests/no_host_imports.rs (core/target/debug/deps/no_host_imports-184c0d8bf00e14f9)

running 1 test
test no_host_imports ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

     Running tests/ops.rs (core/target/debug/deps/ops-256addce27f366d1)

running 12 tests
test remove_block_drops_it_from_columns_children ... ok
test ops_are_atomic ... ok
test layout_switch_assigns_boxes ... ok
test move_slide_keeps_a_concurrent_text_edit ... ok
test columns_children_have_one_parent_and_no_cycles ... ok
test columns_children_must_name_blocks_on_the_slide ... ok
test move_block_keeps_a_concurrent_text_edit ... ok
test add_slide_then_view_lists_it ... ok
test duplicate_slide_remaps_columns_children ... ok
test add_block_with_markdown_produces_runs ... ok
test update_roundtrip ... ok
test remaining_ops_roundtrip ... ok

test result: ok. 12 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s

   Doc-tests slides_core

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

=== rust: fmt (pdf)
=== rust: clippy (pdf)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s
=== wasm: the pdf artifact the host embeds
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.06s
    Finished `release` profile [optimized] target(s) in 0.07s
core/pdf/target/wasm32-unknown-unknown/release/slides_pdf.wasm (5301002 bytes)
=== the embedded pdf wasm must match a fresh build
=== rust: test (pdf)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.03s
     Running unittests src/lib.rs (core/pdf/target/debug/deps/slides_pdf-891fafaa44f9fd64)

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

     Running tests/no_host_imports.rs (core/pdf/target/debug/deps/no_host_imports-17ccbb1d62120093)

running 1 test
test no_host_imports ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

     Running tests/render.rs (core/pdf/target/debug/deps/render-2e1d7e8b19d78595)

running 6 tests
test a_null_zero_length_pair_is_no_frames_not_a_dereference ... ok
test text_with_no_fonts_at_all_still_renders_the_shapes ... ok
test raster_image_is_embedded ... ok
test abi_frames_round_trip ... ok
test two_svgs_make_two_pages ... ok
test renders_text_as_embedded_glyphs ... ok

test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

   Doc-tests slides_pdf

running 0 tests

test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

=== fixtures: node dependencies
v24.19.0
11.17.0
=== fixtures: npm ci (needs the registry — see .amadan/README.md)

added 19 packages, and audited 20 packages in 547ms

5 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
npm warn allow-scripts 1 package has install scripts not yet covered by allowScripts:
npm warn allow-scripts   esbuild@0.28.2 (postinstall: node install.js)
npm warn allow-scripts
npm warn allow-scripts Run `npm approve-scripts --allow-scripts-pending` to review, or `npm approve-scripts <pkg>` to allow.
=== fixtures: the JS side's own tests
✔ one finished upload is register_asset plus the block that uses it, in one batch (11.558892ms)
✔ a record missing its numbers still makes an op the core accepts (1.663661ms)
✔ unusedAssets is what no slide refers to, and remove_asset drops one (3.129974ms)
✔ the Unused images list reconciles by id and names each Remove button (1.1142ms)
✔ an upload refusal is said in words the person can act on (1.121915ms)
✔ clampBox keeps the box inside the frame and no smaller than the minimum (1.866437ms)
✔ fixBox writes four decimals (0.364938ms)
✔ guides are the thirds plus every other block edge (0.327158ms)
✔ snapMove pulls the nearest edge onto a guide within one per cent, and leaves a far one (0.63004ms)
✔ snapResize moves only the edges the handle drags (0.295499ms)
✔ moveBox and resizeBox by handle (0.316999ms)
✔ nudge: arrows move by half a per cent, Shift by two, Alt resizes (0.296992ms)
✔ rectBox reads a block rectangle against its frame (0.232462ms)
✔ typing into a block is a text change on its slide only (9.307341ms)
✔ a block's props changing is structure on its slide (1.85195ms)
✔ adding, moving or removing a block is structure on its slide (2.788831ms)
✔ the notes are text on their slide (1.411903ms)
✔ a slide added, removed or moved is an order change (2.284685ms)
✔ one transaction touching several things reports each once (1.15727ms)
✔ what the ops write passes, and so does an empty document (10.318219ms)
✔ the shapes y-prosemirror and older documents write pass (1.966062ms)
✔ an unknown formatting attribute is reported with the core's message (2.45987ms)
✔ an element inside a paragraph, a non-paragraph child and bare text are reported (4.259423ms)
✔ the unknownmark poison shape is what the check refuses (2.262714ms)
✔ y-prosemirror 1.3.7 deletes the words under an unknown mark; sanitising first keeps them (5.379512ms)
✔ the vocabulary is the five marks and nothing else (0.16134ms)
✔ a reload reuses the id this tab stored for the deck (1.253168ms)
✔ two tabs, and two decks in one tab, get different ids (1.653562ms)
✔ a stored value that is not an id, or storage that throws, gives a fresh id (0.395455ms)
✔ a duplicated tab gives up the copied id; the original keeps it (9.583482ms)
✔ a reload finds no rival and keeps its id (2.586264ms)
✔ presence switches id: beats carry the new one and the old one is somebody else (2.155263ms)
✔ an empty body is no frames (1.664221ms)
✔ frames come back in order with their exact bytes (0.40353ms)
✔ a length prefix is big-endian (0.216542ms)
✔ a truncated frame throws rather than returning a short update (1.752205ms)
✔ deck controls are set_meta (13.310517ms)
✔ slide controls are set_slide, and background keeps its colour (4.697377ms)
✔ move up and down are move_slide with the right after (2.786417ms)
✔ block props: value kinds, region, a shape label (2.812184ms)
✔ box fields are set_block on the desktop and set_phone in the phone view (7.183955ms)
✔ move block and the layer buttons (1.987992ms)
✔ add block: region after the selection on flow, a default box on canvas, a heading level that fits (13.682458ms)
✔ a block is named by its type and its first words (1.406273ms)
✔ blockChange: a control element to its op (1.258097ms)
✔ the Blocks list reconciles by id and keeps its buttons (0.96366ms)
✔ open resolves to the button that closed it and the trimmed field (1.786529ms)
✔ a second open while the dialog is up joins the first (0.217554ms)
✔ a missing dialog mounts as nothing (0.127146ms)
✔ a tab advances its own cursor only when its POST is the very next row (3.432817ms)
✔ the cursor never moves backwards when a response is overtaken by this tab's own write (1.104612ms)
✔ a later response still carries the cursor forward (0.326807ms)
✔ a burst of notifications collapses into one fetch at a time (0.356632ms)
✔ an event naming a seq the tab already has fetches nothing (0.211893ms)
✔ a stopped subscriber drops a response that arrives late (0.299196ms)
✔ stop closes the stream, and start opens exactly one (0.669173ms)
✔ a hidden tab closes its stream on a shortened deadline, and activity reopens it (1.179982ms)
✔ switching away and back inside the grace never drops the stream, so nothing has to be caught up (0.280201ms)
✔ a visible tab that is only being READ closes when idle and comes back on any activity (1.420118ms)
✔ activity while streaming defers the idle close rather than reopening anything (0.208477ms)
✔ a stream failure is never reported as a save failure, and heals on activity (0.2801ms)
✔ an empty response advances the cursor without applying anything (2.358832ms)
✔ a failed fetch leaves the cursor alone so the next notification retries it (0.900853ms)
✔ a decode failure retries once through the raw log, then gives up (1.917472ms)
✔ updatesURL spells the query string exactly, in particular raw as the literal raw=1 (0.197026ms)
✔ empty_doc_views_as_defaults (7.666362ms)
✔ set_meta (3.236042ms)
✔ slides: add, move, remove, set_slide, set_notes (7.092004ms)
✔ duplicate_slide (3.971788ms)
✔ blocks: add, move, remove, set_block (4.749493ms)
✔ set_phone (1.314772ms)
✔ table cells follow rows and cols (1.776439ms)
✔ canvas blocks: box defaults from the region, z from the index (1.400922ms)
✔ assets: register and remove (1.202494ms)
✔ markdown: every mark, code blocks are plain lines (5.123979ms)
✔ batch_is_atomic (2.544046ms)
✔ a batch applies in one transaction with the given origin (0.84547ms)
✔ columns_children_validated (1.856479ms)
✔ layout_switch_round_trip (2.332483ms)
✔ view_matches_snapshot_fixtures (15.680929ms)
✔ columns children have one parent and no cycles (codex 7) (1.418935ms)
✔ canvas-to-flow keeps a concurrent text edit (codex 4) (3.422738ms)
✔ move_block keeps a concurrent text edit, and later inserts land where after says (codex 4) (2.963515ms)
✔ move_slide keeps a concurrent text edit, and later inserts land where after says (codex 4 at slide level) (2.290375ms)
✔ format: {name} and {n} interpolate; an unknown key is left as text (0.875005ms)
✔ a beat on connect, then one every twenty seconds (3.296825ms)
✔ a move posts once, 200ms after the last of a burst, and re-arms the beat (1.884611ms)
✔ a hidden tab stops beating; coming back posts at once (0.564799ms)
✔ applySet leaves this tab out and answers what changed (0.744703ms)
✔ a set with rubbish in it is read for what it does carry (0.29087ms)
✔ initials, and a client id per tab (7.699102ms)
✔ a resync that fails is retried with backoff until it succeeds, without ever running twice at once (2.895959ms)
✔ the browser's online event retries immediately, not just on the next backoff tick (0.401496ms)
✔ reportDivergence before any failure runs the first attempt without a caller ever calling online (1.703855ms)
✔ a permanent 4xx drops its batch instead of retrying it, and does not poison the next edit (85.716761ms)
✔ a network failure or a 5xx is retried, not dropped (60.877559ms)
✔ a 500 is retried the same way a network failure is, never dropped (60.504989ms)
✔ the core-written structure reads as ProseMirror marks (7.743776ms)
✔ a y-prosemirror round trip preserves bold and the link (2.851367ms)
✔ y-prosemirror writes href as the one-key map and marks as {} (0.870988ms)
✔ a plain-string href, written before the map shape, still reads (0.859377ms)
✔ textFragment writes the same shape as writeText (0.605014ms)
✔ safeHref is the server allowlist, and a disallowed href never draws an anchor (0.547727ms)
✔ parseHash splits the slide and the block, and survives a stray % (1.688797ms)
✔ destroy takes the listeners off, and a second mount leaves one of each (0.732771ms)
✔ formatHash is the inverse (1.697353ms)
✔ textBlocks finds the text-bearing blocks and skips the rest (1.392597ms)
✔ mountTarget is the element whose children are the paragraphs (0.207896ms)
✔ paragraphTag keeps the renderer's own markup per type (0.130803ms)
✔ blockFragment finds a block's text by slide and block id (6.434313ms)
✔ notesFragment finds the slide's notes (0.875998ms)
✔ blockIDs and slideIDs are the orders on the stage and the strip (0.852534ms)
✔ cellFragments are a table's cells, row-major (1.262656ms)
✔ a load that fails leaves the stage on the slide it was showing and says so (1.142592ms)
✔ slideAfter is the slide to show once one is gone (0.197757ms)
✔ the queue words map to savestatus states, failed staying failed (0.978207ms)
✔ a dropped batch outranks the queue, for the rest of the session (0.197567ms)
✔ the editor status: the save line and the load error are two lines (0.274139ms)
✔ mountSaveStatus loads estilo's module and mounts it on the line (0.992073ms)
✔ a save-status module that will not load says so and refuses to mount (0.439486ms)
✔ dropTarget: before puts it ahead of the item, after behind it, null means first (1.158712ms)
✔ a drop that leaves the order as it is is no move (0.243022ms)
✔ dropOp is the move_slide (0.843447ms)
✔ labelFor is the slide's first heading, else the numbered fallback (4.428918ms)
✔ flush re-points every dirty thumbnail and relabels it (1.546954ms)
✔ destroy takes the item-menu listener off; a second mount leaves one (0.246358ms)
✔ plan says what to add, drop and where each item goes (0.706693ms)
✔ sync moves only the items out of place and keeps focus (1.343816ms)
✔ desktop view: box and size are the block; hidden and order are always the phone map (1.382668ms)
✔ phone view: everything goes to set_phone, carrying the rest of the map (0.346754ms)
✔ the ops it makes are ones the document takes (8.523263ms)
✔ phoneMap reads only the four keys, and only well-typed (0.18331ms)
✔ the stored view is read defensively (0.263119ms)
✔ linkAt reads the link under the cursor and nothing elsewhere (1.990608ms)
✔ setLink marks the selection (1.796187ms)
✔ setLink on a collapsed cursor inside a link replaces that link (0.500159ms)
✔ setLink with nothing selected and no link does nothing (0.19349ms)
✔ removeLink clears the link under the cursor (0.340462ms)
✔ the keymap binds every documented shortcut (0.202305ms)
✔ Mod-b toggles bold through the keymap (0.505429ms)
✔ setLink refuses an address outside the allowlist and writes nothing (0.448062ms)
✔ the state machine runs choose → sign → put → register → op (3.976266ms)
✔ a file the deck will not take never reaches the network (1.072072ms)
✔ alt text is required before the bytes move, unless the image is decorative (0.338479ms)
✔ a step that fails stops the ones after it and reports its own code (0.561123ms)
✔ a failure that is not ours still becomes an UploadError (0.447631ms)
✔ a second pick while one is in flight is refused, not raced (0.53851ms)
✔ an unreadable size is zero rather than a failure (0.316728ms)
✔ a per-call applyOp wins, so an upload lands on the block it was chosen for (0.228244ms)
✔ checkFile takes exactly the four types (0.31745ms)
✔ sha256Hex is the hex digest of the bytes (8.741639ms)
✔ refusal reads this app's JSON error shape, and survives one that is not (0.354188ms)
✔ malformed fonts are null (2.922649ms)
✔ a malformed background is the theme default (1.364735ms)
✔ a malformed box is null (0.666789ms)
✔ the phone layer keeps only its typed fields, order as a whole number (0.625872ms)
✔ assets are typed or skipped (1.369374ms)
✔ every poison update is deterministic bytes that apply to any deck (6.507489ms)
ℹ tests 158
ℹ suites 0
ℹ pass 158
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 303.885595
=== the vectors must match a fresh generation
canvas: 22 ops, 3715 bytes
columns: 23 ops, 3134 bytes
every-block: 36 ops, 3180 bytes
table: 14 ops, 1509 bytes
title-only: 6 ops, 1136 bytes
=== the editor bundle must match a fresh build
wrote internal/slides/static/editor.0463180baf5aab9f.js (376416 bytes)
=== go: vet, gofmt, test, migration check
go vet ./...
go test ./...
?   	amadan.net/oficina/slides/cmd/slides	[no test files]
ok  	amadan.net/oficina/slides/internal/slides	11.663s
ok  	amadan.net/oficina/slides/internal/slides/core	2.691s
ok  	amadan.net/oficina/slides/internal/slides/deck	(cached)
ok  	amadan.net/oficina/slides/internal/slides/fonts	(cached)
?   	amadan.net/oficina/slides/internal/slides/icons	[no test files]
ok  	amadan.net/oficina/slides/internal/slides/layout	(cached)
ok  	amadan.net/oficina/slides/internal/slides/live	(cached)
ok  	amadan.net/oficina/slides/internal/slides/pdfcore	(cached)
ok  	amadan.net/oficina/slides/internal/slides/present	(cached)
ok  	amadan.net/oficina/slides/internal/slides/render	(cached)
ok  	amadan.net/oficina/slides/internal/slides/svg	(cached)
ok  	amadan.net/oficina/slides/internal/slidestest	40.023s
go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
python3 scripts/credits.py --check
credits: checked
gate: ok
diff --git a/internal/slides/static/editor.css b/internal/slides/static/editor.css
index 190f142..9fde166 100644
--- a/internal/slides/static/editor.css
+++ b/internal/slides/static/editor.css
@@ -332,7 +332,7 @@
margin: var(--rst-sp-1) 0 0;
}
[slides-asset-status][data-state="error"] {
- color: var(--rst-danger, var(--rst-text));
+ color: var(--rst-tone-negative-fg);
}
[slides-unused-assets] {
border: 0;
diff --git a/internal/slides/templates/present_notes.html b/internal/slides/templates/present_notes.html
index b1517ce..8c7b1b8 100644
--- a/internal/slides/templates/present_notes.html
+++ b/internal/slides/templates/present_notes.html
@@ -46,7 +46,7 @@
gap: var(--rst-sp-3);
}
[slides-clock-now] {
- font-size: var(--rst-text-2xl);
+ font-size: var(--rst-fs-lg);
font-variant-numeric: tabular-nums;
margin: 0;
}
@@ -71,7 +71,7 @@
gap: var(--rst-sp-2);
}
[slides-code] {
- font-size: var(--rst-text-2xl);
+ font-size: var(--rst-fs-lg);
letter-spacing: 0.25em;
margin: 0;
}
diff --git a/internal/slidestest/index_test.go b/internal/slidestest/index_test.go
index 0ce1a20..7391c4f 100644
--- a/internal/slidestest/index_test.go
+++ b/internal/slidestest/index_test.go
@@ -3,6 +3,8 @@ package slidestest
import (
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"regexp"
"strings"
"testing"
@@ -82,3 +84,77 @@ func TestErrorPageRendersFrameworkPartial(t *testing.T) {
t.Errorf("ErrorPage body missing the ref:\n%s", body)
}
}
+
+// Every page failure a browser can navigate to is rendered inside Slides'
+// layout and bar. A fetch that did not ask for HTML remains plain text; the
+// split is by Accept rather than by an accident of which route failed.
+func TestNavigableErrorsUseTheSharedErrorPage(t *testing.T) {
+ a := newApp(t)
+ signedIn := signedIn(t, a, allowedAddress)
+ // The user row is created on the first guarded request; optional chrome
+ // deliberately will not create it while merely resolving an error page.
+ mustGet(t, a, "/", http.StatusOK, signedIn)
+ type errorCase struct {
+ name, path, title string
+ cookies []*http.Cookie
+ }
+ for _, tc := range []errorCase{
+ {"unknown route, signed out", "/no/such/page", "Page not found · Slides", nil},
+ {"unknown route, signed in", "/no/such/page", "Page not found · Slides", signedIn},
+ {"post-only route, signed out", "/signout", "Page not found · Slides", nil},
+ {"post-only route, signed in", "/signout", "Page not found · Slides", signedIn},
+ {"missing deck", "/d/23456789ab", "Deck not available · Slides", signedIn},
+ {"missing share link", "/s/doesnotexist", "Deck not available · Slides", nil},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ req := httptest.NewRequest(http.MethodGet, tc.path, nil)
+ req.Header.Set("Accept", "text/html,application/xhtml+xml")
+ for _, c := range tc.cookies {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ a.ServeHTTP(rec, req)
+ body := rec.Body.String()
+ if rec.Code != http.StatusNotFound || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/html") {
+ t.Fatalf("status %d, Content-Type %q; want HTML 404", rec.Code, rec.Header().Get("Content-Type"))
+ }
+ if title := regexp.MustCompile(`<title>([^<]*)</title>`).FindStringSubmatch(body); title == nil || title[1] != tc.title {
+ t.Fatalf("title %v, want %q", title, tc.title)
+ }
+ if !strings.Contains(body, "<header estilo-bar") || !strings.Contains(body, "<div rst-error>") {
+ t.Fatal("error is outside the shared layout and bar")
+ }
+ if account := strings.Contains(body, "<details estilo-account"); account != (tc.cookies != nil) {
+ t.Fatalf("account menu present = %v, want %v", account, tc.cookies != nil)
+ }
+ })
+ }
+ plain := get(t, a, "/no/such/page", signedIn...)
+ if plain.Code != http.StatusNotFound || strings.HasPrefix(plain.Header().Get("Content-Type"), "text/html") || strings.Contains(plain.Body.String(), "<html") {
+ t.Fatalf("non-HTML failure = %d %q, want plain 404", plain.Code, plain.Header().Get("Content-Type"))
+ }
+}
+
+// The only callout vocabulary is estilo's real tones. A valued rst-callout
+// or the old rst-notice spelling looks plausible but receives no callout
+// treatment, so scan every authored template rather than waiting for a path
+// to happen to render one.
+func TestTemplatesUseOnlyCalloutTones(t *testing.T) {
+ files, err := filepath.Glob(filepath.Join("..", "slides", "templates", "*.html"))
+ if err != nil || len(files) == 0 {
+ t.Fatal("find templates", err)
+ }
+ stripComments := regexp.MustCompile(`(?s)\{\{/\*.*?\*/\}\}`)
+ for _, file := range files {
+ body, err := os.ReadFile(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ source := stripComments.ReplaceAllString(string(body), "")
+ for _, bad := range []string{"rst-notice", `rst-callout="`, `rst-tone="warn"`} {
+ if strings.Contains(source, bad) {
+ t.Errorf("%s uses %q", file, bad)
+ }
+ }
+ }
+}
diff --git a/internal/slidestest/vendored_test.go b/internal/slidestest/vendored_test.go
index d08acbd..2fdf046 100644
--- a/internal/slidestest/vendored_test.go
+++ b/internal/slidestest/vendored_test.go
@@ -2,10 +2,14 @@ package slidestest
import (
"bytes"
+ "fmt"
"os"
"path/filepath"
+ "regexp"
+ "sort"
"testing"
+ "amadan.net/oficina/estilo.git/theme"
"amadan.net/rastrillo/rastrillo/ui"
)
@@ -52,3 +56,55 @@ func TestVendoredAssetsMatchTheLibrary(t *testing.T) {
}
}
}
+
+// TestEveryRastrilloTokenSlidesUsesExists makes a misspelled design token a
+// test failure instead of a browser falling back silently. The vocabulary is
+// the committed tokens.css; usages include both authored templates and CSS.
+func TestEveryRastrilloTokenSlidesUsesExists(t *testing.T) {
+ declared := map[string]bool{}
+ definitions := append(append([]byte{}, ui.TokensCSS()...), theme.CSS()...)
+ for _, match := range regexp.MustCompile(`--(rst-[a-z0-9-]+)\s*:`).FindAllSubmatch(definitions, -1) {
+ declared[string(match[1])] = true
+ }
+ if len(declared) == 0 {
+ t.Fatal("tokens.css declares no --rst-* tokens")
+ }
+ files, err := filepath.Glob(filepath.Join("..", "slides", "templates", "*.html"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ css, err := filepath.Glob(filepath.Join("..", "slides", "static", "*.css"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, file := range css {
+ // tokens.css is rastrillo's vendored component implementation;
+ // TestVendoredAssetsMatchTheLibrary pins it. This gate is for
+ // Slides-authored CSS and inline template styles.
+ if filepath.Base(file) != "tokens.css" {
+ files = append(files, file)
+ }
+ }
+ used := map[string]bool{}
+ pattern := regexp.MustCompile(`var\(--(rst-[a-z0-9-]+)`)
+ comments := regexp.MustCompile(`(?s)/\*.*?\*/`)
+ for _, file := range files {
+ body, err := os.ReadFile(file)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, match := range pattern.FindAllSubmatch(comments.ReplaceAll(body, nil), -1) {
+ used[string(match[1])] = true
+ }
+ }
+ var missing []string
+ for token := range used {
+ if !declared[token] {
+ missing = append(missing, token)
+ }
+ }
+ sort.Strings(missing)
+ for _, token := range missing {
+ t.Error(fmt.Sprintf("Slides uses --%s, but tokens.css does not declare it", token))
+ }
+}