rastrillo / fiesta Public

Port codeview and the token parsers, and hold the embedded tree to no axe

internal/codeview is rastrillo's format.go, highlight.go and pieces.go
with their tests, byte for byte except two comments that named the
gallery's formatting drive and ui's tokens.css; call.go stays behind.
All twelve tests passed on their first run.

internal/tokens is designsystem's token reader: declPattern,
colourValue, blockBody, parseTokens, colourGroups, lightHalf and
resolveLight, exported as Structural() and Palette(theme) over
fiesta.TokensCSS() and fiesta.ThemeCSS(). A Group carries a Key
(surfaces-and-lines, type-scale, ...) and no English; the gallery
writes the headings. TestColourValueRecognisesADerivedColour is
rastrillo's header_rule_test.go's, verbatim; two tests of my own hold
lightHalf and the shape of every theme's palette. check/'s own copies
of parseTokens and blockBody are untouched; consolidating them is a
follow-up.

TestTheShippedTreeCarriesNoAxe, untagged, walks fiesta.FS() for
axe-core's banner and refuses internal/axe in the public packages'
dependency graph: the README's claim that nothing a Go program imports
from Fiesta carries the engine is now a test at the embed as well as
at the release.
Human

Prompt

Task 13, Phase A: port codeview and the tokens parts of rastrillo's designsystem into Fiesta, and check what holds 'nothing a Go program imports from Fiesta carries axe' for the embed.

From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.

Summary

Adds internal/codeview (format, highlight, pieces, tests) and internal/tokens (Structural, Palette, tests), and a test in internal/axe holding the embedded tree and the public packages' graph free of the engine.
Agent notes
The tokens package deliberately holds no English: group titles come from the gallery's reviewed copy by Group.Key. The highlight-colour contrast test lives in internal/gallery, where gallery.css is.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 296284f806c25f17d58181740e88a2c90b5b96a1 parent a265414
8 files changed, +1577 −0
  • internal/axe/axe_test.go +59 −0
  • internal/codeview/format.go +465 −0
  • internal/codeview/format_test.go +215 −0
  • internal/codeview/highlight.go +172 −0
  • internal/codeview/highlight_test.go +48 −0
  • internal/codeview/pieces.go +173 −0
  • internal/tokens/tokens.go +328 −0
  • internal/tokens/tokens_test.go +117 −0
diff --git a/internal/axe/axe_test.go b/internal/axe/axe_test.go
index a69c4dc..e406f6e 100644
--- a/internal/axe/axe_test.go
+++ b/internal/axe/axe_test.go
@@ -3,11 +3,15 @@ package axe
import (
"crypto/sha256"
"encoding/hex"
+ "io/fs"
"os"
+ "os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
+
+ "amadan.net/rastrillo/fiesta"
)
// axe-core is vendored here for the accessibility gate. It is test
@@ -54,3 +58,58 @@ func TestVendoredAxeIsThePinnedVersion(t *testing.T) {
t.Errorf("the README pins %s, but the file's banner does not say %q", vm[1], want)
}
}
+
+// TestTheShippedTreeCarriesNoAxe is the other half of "test data": the
+// engine reaches nothing a Go program imports from Fiesta. The root
+// package embeds its tree by directory, so the check is of what those
+// directories hold, by the banner every build of axe-core carries and
+// no hand-written asset does; and the packages a program imports, the
+// root and example, pull this package into no build. The release has
+// the same check over its own files, TestVendoredAxeIsNotAShippedAsset
+// in internal/build.
+func TestTheShippedTreeCarriesNoAxe(t *testing.T) {
+ const marker = "Deque Systems"
+ if !strings.Contains(script, marker) {
+ t.Fatalf("the vendored engine does not carry %q; the check below would be looking for nothing", marker)
+ }
+ files := 0
+ err := fs.WalkDir(fiesta.FS(), ".", func(p string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ files++
+ b, err := fs.ReadFile(fiesta.FS(), p)
+ if err != nil {
+ return err
+ }
+ if strings.Contains(string(b), marker) {
+ t.Errorf("%s carries the vendored axe-core: it is test data and must not ship", p)
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if files == 0 {
+ t.Fatal("the embedded tree holds no files; the walk above checked nothing")
+ }
+
+ out, err := exec.Command("go", "list", "-deps", "amadan.net/rastrillo/fiesta", "amadan.net/rastrillo/fiesta/example").CombinedOutput()
+ if err != nil {
+ t.Fatalf("go list -deps: %v\n%s", err, out)
+ }
+ deps := strings.Split(strings.TrimSpace(string(out)), "\n")
+ listed := false
+ for _, dep := range deps {
+ listed = listed || dep == "amadan.net/rastrillo/fiesta"
+ if strings.HasSuffix(dep, "/internal/axe") {
+ t.Errorf("the public packages import %s; the engine is for the drives alone", dep)
+ }
+ }
+ if !listed {
+ t.Fatalf("go list -deps did not list the root package itself:\n%s", out)
+ }
+}
diff --git a/internal/codeview/format.go b/internal/codeview/format.go
new file mode 100644
index 0000000..e8cbd58
--- /dev/null
+++ b/internal/codeview/format.go
@@ -0,0 +1,465 @@
+package codeview
+
+import (
+ "regexp"
+ "strings"
+)
+
+// blockElements are the elements whose boxes are blocks: whitespace
+// between two tags where one of these is involved renders as nothing,
+// so a line break there moves no pixel. Everything else (a, span,
+// button, label, input, svg) is inline or inline-block, where a space
+// is a space a reader sees, and is broken only where one of the rules
+// in layout says the space cannot show.
+var blockElements = map[string]bool{
+ "address": true, "article": true, "aside": true, "blockquote": true, "details": true,
+ "dialog": true, "dd": true, "div": true, "dl": true, "dt": true, "fieldset": true,
+ "figcaption": true, "figure": true, "footer": true, "form": true, "h1": true, "h2": true,
+ "h3": true, "h4": true, "h5": true, "h6": true, "header": true, "hgroup": true, "hr": true,
+ "legend": true, "li": true, "main": true, "nav": true, "ol": true, "p": true, "pre": true,
+ "search": true, "section": true, "summary": true, "table": true, "tbody": true, "td": true,
+ "tfoot": true, "th": true, "thead": true, "tr": true, "ul": true,
+ // Inside a select, which listElements covers: an option and a
+ // group are drawn by the control, not laid out as text.
+ "optgroup": true, "option": true,
+}
+
+// listElements hold a list the control draws (a select's options, a
+// datalist's suggestions), so nothing between their children renders
+// and each child can have a line. The element itself is inline-level:
+// a select sits in a sentence like a word, and the space after it
+// shows, so it is not a block and is never broken out of its line.
+var listElements = map[string]bool{"select": true, "datalist": true, "optgroup": true}
+
+// rawElements keep every byte inside them. A <pre> or <textarea>
+// renders its whitespace as written, script and style are not markup,
+// an svg's paths are drawing, not structure a reader reads, and a
+// template's content is not rendered at all.
+var rawElements = map[string]bool{"pre": true, "textarea": true, "svg": true, "script": true, "style": true, "template": true}
+
+// preservingStyle matches an inline style that makes whitespace render
+// as written: white-space pre, pre-wrap, pre-line or break-spaces, or the
+// longhand that says the same. Under any of them a line break and an
+// indent are text a reader sees, so the element's whole subtree is left
+// as it is, the way a <pre> is. nowrap is not here: it collapses spaces
+// exactly as normal does and only stops the wrapping.
+var preservingStyle = regexp.MustCompile(`(?i)white-space(?:-collapse)?\s*:\s*(?:pre|pre-wrap|pre-line|break-spaces|preserve|preserve-breaks|preserve-spaces)\b`)
+
+// preservingAttrs are the attributes whose rules in tokens.css preserve
+// whitespace. None do today; one that comes to must be named here, or
+// the formatter will lay its content out as if spaces collapsed.
+var preservingAttrs = map[string]bool{}
+
+// preserves says whether the element opened at p keeps its content's
+// whitespace as written, so its subtree must not be reformatted.
+func preserves(p piece) bool {
+ if rawElements[p.name] {
+ return true
+ }
+ attrs := attrsOf(p.text)
+ for a := range attrs {
+ if preservingAttrs[a] {
+ return true
+ }
+ }
+ return preservingStyle.MatchString(attrs["style"])
+}
+
+// silentElements render nothing, so a space beside one is only seen if
+// something visible is beside it too. layout treats them as absent when
+// it asks whether a gap is at the edge of a line.
+var silentElements = map[string]bool{"script": true, "style": true, "template": true, "link": true, "meta": true}
+
+// ── Flex and grid containers ─────────────────────────────────────────
+//
+// Whitespace between the children of a flex or grid container renders
+// as nothing: a run of only whitespace makes no item, and a run of text
+// is an item of its own whose leading and trailing spaces fall at the
+// edges of its own line. So inside one, every child can have a line of
+// its own. This is the list of the containers css/tokens.css lays out
+// that way, by the attribute that does it, and it is a list
+// rather than a guess on purpose: a container wrongly on it would move
+// a reader's text, and one missing from it only keeps a line long.
+//
+// An entry is here only when the element is flex or grid at every
+// width and in every context the rule names. The shell chrome that
+// switches between none, contents and flex across breakpoints is not,
+// and stays inline. css/tokens.css is the authority. No drive here yet
+// renders every example both ways and compares the boxes, as the
+// gallery this was written for did; until one does, an attribute added
+// to tokens.css as a flex or grid container has to be added here by
+// hand.
+
+// flexAttrs are the attributes that make their element a block-level
+// flex or grid container.
+var flexAttrs = map[string]bool{
+ "rst-page-header": true, "rst-lbar": true, "rst-search": true, "rst-row": true, "rst-row-lead": true,
+ "rst-pagination": true, "rst-stats": true, "rst-stat": true, "rst-person-av": true, "rst-callout": true,
+ "rst-detail": true, "rst-box-head": true, "rst-lrow": true, "rst-choice-cards": true, "rst-seg-tabs": true,
+ "rst-field-row": true, "rst-form-bar": true, "rst-form-actions": true, "rst-form": true, "rst-form-foot": true,
+ "rst-modal-overlay": true, "rst-modal-panel": true, "rst-bulkbar": true, "rst-shell-nav": true,
+ "rst-shell-rail-foot": true, "rst-signin": true, "rst-signin-brand": true, "rst-signin-door": true,
+ "rst-signin-form": true, "rst-signin-passkey": true, "rst-signin-or": true, "rst-signin-providers": true,
+ "rst-stage": true, "rst-error-cta": true, "rst-dtp-pick": true, "rst-dtp-row": true, "rst-cal-head": true,
+ "rst-cal-nav": true, "rst-media": true, "rst-media-name": true,
+}
+
+// inlineFlexAttrs are the attributes that make their element an
+// inline-level flex container: its children may each have a line, but
+// the element itself sits in its parent's line like a word.
+var inlineFlexAttrs = map[string]bool{
+ "rst-btn": true, "rst-search-clear": true, "rst-status": true, "rst-meter": true, "rst-person": true,
+ "rst-callout-ic": true, "rst-caret": true, "rst-ftok": true, "rst-switch": true, "rst-help": true,
+ "rst-bulkbar-close": true, "rst-selbox": true, "rst-shell-avatar": true, "rst-row-action": true,
+ "rst-bulkbar-escalate": true, "rst-modal-close": true, "rst-shell-brand": true,
+}
+
+// flexInContext are the containers tokens.css makes flex or grid only
+// inside another: a dropdown's summary, the modal's rail, a choice
+// card. ancestor is an attribute on the parent, or on any ancestor when
+// deep is set. inline marks the inline-level ones.
+var flexInContext = []struct {
+ tag, attr, ancestor string
+ deep, inline bool
+}{
+ {tag: "summary", ancestor: "rst-dropdown"},
+ {tag: "summary", ancestor: "rst-row-menu"},
+ {tag: "summary", ancestor: "rst-shell-profile", inline: true},
+ {tag: "nav", ancestor: "rst-modal-panel"},
+ {tag: "label", ancestor: "rst-choice-cards", deep: true},
+ {attr: "rst-tblock-head", ancestor: "rst-tblock"},
+ {attr: "rst-page", ancestor: "rst-stage"},
+ {attr: "rst-shell-bar", ancestor: "rst-shell-topbar", deep: true},
+ {attr: "rst-shell-bar", ancestor: "rst-shell-console", deep: true},
+ {tag: "a", ancestor: "rst-ftok", deep: true, inline: true},
+ {tag: "a", ancestor: "rst-back-nav", deep: true, inline: true},
+ {tag: "a", ancestor: "rst-signin-providers", inline: true},
+}
+
+// blockAttrs are the attributes tokens.css makes display: block at
+// every width, on elements that are inline by default: a field's label
+// and its messages, a person's name and address, a choice card's two
+// lines.
+var blockAttrs = map[string]bool{
+ "rst-field-label": true, "rst-field-help": true, "rst-field-error": true, "rst-field-hint": true,
+ "rst-person-name": true, "rst-person-email": true, "rst-choice-title": true, "rst-choice-desc": true,
+ "rst-tblock-title": true, "rst-tblock-desc": true, "rst-row-sub": true, "rst-job-bar": true,
+}
+
+// blockInContext are the elements tokens.css makes blocks only inside
+// another: the links and buttons of a dropdown's or a row menu's panel.
+var blockInContext = []struct{ tag, ancestor string }{
+ {"a", "rst-dropdown-menu"}, {"button", "rst-dropdown-menu"},
+ {"a", "rst-row-menu-panel"}, {"button", "rst-row-menu-panel"},
+}
+
+// elem is one open element and what its box does to whitespace.
+type elem struct {
+ name string
+ attrs map[string]string
+ // flex: its children are flex or grid items.
+ flex bool
+ // ownLines: its content starts a line of its own, so a space at the
+ // start of that content is at the edge of a line (a block, any flex
+ // or grid container, a button).
+ ownLines bool
+ // blockLevel: the element is not part of its parent's line, so
+ // whatever follows it starts a new one.
+ blockLevel bool
+ // elements: it has an element among its children. A flex item's
+ // text gets a line of its own only beside one: a button holding a
+ // label and nothing else reads best as it is, <button>Save</button>,
+ // and a spinner beside that label is two children to list.
+ elements bool
+ // broken: a line break was written inside it, so its end tag goes on
+ // a line of its own too. One that opened and closed on the same line
+ // keeps its end there: <small>AB3PX · <bdi>…</bdi></small>.
+ broken bool
+}
+
+// describeElem works out what an element opened at p does, given the
+// elements it sits in.
+func describeElem(p piece, stack []elem) elem {
+ e := elem{name: p.name, attrs: attrsOf(p.text)}
+ e.blockLevel = blockElements[p.name]
+ inline := false
+ for _, c := range blockInContext {
+ if c.tag == p.name && len(stack) > 0 && has(stack[len(stack)-1].attrs, c.ancestor) {
+ e.blockLevel = true
+ }
+ }
+ for a := range e.attrs {
+ if blockAttrs[a] {
+ e.blockLevel = true
+ }
+ if flexAttrs[a] {
+ e.flex = true
+ }
+ if inlineFlexAttrs[a] {
+ e.flex, inline = true, true
+ }
+ }
+ // A block button is flex, not inline-flex: [rst-btn~="block"].
+ if v, ok := e.attrs["rst-btn"]; ok && hasWord(v, "block") {
+ inline = false
+ }
+ for _, c := range flexInContext {
+ if (c.tag != "" && c.tag != p.name) || (c.attr != "" && !has(e.attrs, c.attr)) {
+ continue
+ }
+ for i := len(stack) - 1; i >= 0; i-- {
+ if has(stack[i].attrs, c.ancestor) {
+ e.flex, inline = true, c.inline
+ break
+ }
+ if !c.deep {
+ break
+ }
+ }
+ }
+ // .rst-nm small: the meta line under a row's name is a block.
+ if p.name == "small" {
+ for i := len(stack) - 1; i >= 0; i-- {
+ if hasWord(stack[i].attrs["class"], "rst-nm") {
+ e.blockLevel = true
+ break
+ }
+ }
+ }
+ if e.flex && !inline {
+ e.blockLevel = true
+ }
+ if listElements[p.name] {
+ e.flex = true
+ }
+ e.ownLines = e.blockLevel || e.flex || p.name == "button"
+ return e
+}
+
+func has(attrs map[string]string, name string) bool { _, ok := attrs[name]; return ok }
+
+func hasWord(list, word string) bool {
+ for _, w := range strings.Fields(list) {
+ if w == word {
+ return true
+ }
+ }
+ return false
+}
+
+// silent says whether the tag at p renders nothing at all.
+func silent(p piece) bool {
+ if silentElements[p.name] {
+ return true
+ }
+ return p.name == "input" && strings.EqualFold(attrsOf(p.text)["type"], "hidden")
+}
+
+// Format lays rendered HTML out for reading: a line break and two
+// spaces of indent per open element, wherever whitespace there cannot
+// change what renders. That is between two tags where one is a block;
+// between the children of a flex or grid container, its text included;
+// and at the edge of a line, where a space is removed when the line is
+// laid out, which is what lets the hidden inputs at the head of a form
+// stand one to a line. Text, inline runs, attribute values and
+// everything inside a raw element or one whose style preserves
+// whitespace are left byte for byte, except the
+// spaces at the two ends of a flex item's text, which never render. No
+// column limit is promised: an inline run stays one line however long
+// it is, and the page soft-wraps it instead.
+func Format(html string) string {
+ return layout(html, func(depth int) string { return "\n" + strings.Repeat(" ", depth) })
+}
+
+// layout walks the markup and writes brk(depth) wherever Format may
+// break, and the original bytes everywhere else.
+func layout(s string, brk func(depth int) string) string {
+ ps := pieces(s)
+ var b strings.Builder
+ var stack []elem
+ parent := func() *elem {
+ if len(stack) == 0 {
+ return nil
+ }
+ return &stack[len(stack)-1]
+ }
+ // edge: nothing visible since the start of the current line. The
+ // fragment starts at one: every sample sits in a block.
+ edge := true
+ // gap: the last thing written was a tag, or a flex item's text, and
+ // only the whitespace in pending has followed it. prevBlock is
+ // whether that tag was a block's.
+ gap, prevBlock := false, false
+ // opened: the last tag written opened the element on top of the
+ // stack. A line break between it and its own end would put
+ // whitespace in an element that had none, and :empty, which the
+ // shells use to hide an empty foot, would stop matching.
+ opened := false
+ pending := ""
+ for i := 0; i < len(ps); i++ {
+ p := ps[i]
+ switch p.kind {
+ case commentPiece:
+ b.WriteString(pending)
+ b.WriteString(p.text)
+ pending, gap, opened = "", false, false
+ continue
+ case textPiece:
+ core := strings.TrimSpace(p.text)
+ if core == "" && gap {
+ pending += p.text
+ continue
+ }
+ if par := parent(); par != nil && par.flex && par.elements && core != "" {
+ // A flex item of its own: the spaces at its two ends fall
+ // at the edges of its line and never render.
+ if gap {
+ par.broken = true
+ b.WriteString(brk(len(stack)))
+ } else {
+ b.WriteString(p.text[:strings.Index(p.text, core)])
+ }
+ b.WriteString(core)
+ pending = p.text[strings.Index(p.text, core)+len(core):]
+ gap, prevBlock, edge, opened = true, false, false, false
+ continue
+ }
+ b.WriteString(pending)
+ b.WriteString(p.text)
+ pending, gap, opened = "", false, false
+ if core != "" {
+ edge = false
+ }
+ continue
+ }
+
+ closes := p.end && parent() != nil && parent().name == p.name
+ inFlex := parent() != nil && parent().flex
+ var e elem
+ if !p.end {
+ e = describeElem(p, stack)
+ }
+ thisBlock := blockElements[p.name] || (!p.end && e.blockLevel) || (closes && parent().blockLevel)
+ // Never into the start of an inline element: its first child on a
+ // line of its own while its end shares a line with its last
+ // reads as a mistake, whatever it renders as.
+ intoInline := opened && !parent().ownLines
+ breakable := gap && !(closes && (opened || !parent().broken)) &&
+ (prevBlock || thisBlock || inFlex || !intoInline && (edge || lineEndsAt(ps, i, stack)))
+ opened = false
+ if closes {
+ stack = stack[:len(stack)-1]
+ }
+ if breakable {
+ if par := parent(); par != nil {
+ par.broken = true
+ }
+ b.WriteString(brk(len(stack)))
+ } else {
+ b.WriteString(pending)
+ }
+ pending = ""
+ b.WriteString(p.text)
+ gap, prevBlock = true, thisBlock
+
+ switch {
+ case p.end:
+ // Past a block the next thing starts a line; past anything
+ // else it shares the line with what just closed.
+ edge = thisBlock
+ case !p.void && preserves(p):
+ j := closing(ps, i)
+ for k := i + 1; k <= j; k++ {
+ b.WriteString(ps[k].text)
+ }
+ i = j
+ if !silent(p) {
+ edge = e.blockLevel
+ }
+ prevBlock = e.blockLevel
+ case p.void:
+ if p.name == "br" {
+ edge = true
+ } else if !silent(p) {
+ edge = false
+ }
+ default:
+ e.elements = hasElementChild(ps, i)
+ stack = append(stack, e)
+ opened = true
+ if e.ownLines {
+ edge = true
+ }
+ }
+ }
+ b.WriteString(pending)
+ return b.String()
+}
+
+// lineEndsAt says whether nothing visible stands between ps[i] and the
+// end of the current line: only whitespace and silent elements, then a
+// block, a line break, the end of a container that has lines of its own,
+// or the end of the fragment. A space there is the last thing on its
+// line and is removed when the line is laid out.
+func lineEndsAt(ps []piece, i int, stack []elem) bool {
+ for k := i; k < len(ps); k++ {
+ q := ps[k]
+ switch {
+ case q.kind == commentPiece:
+ continue
+ case q.kind == textPiece:
+ if strings.TrimSpace(q.text) != "" {
+ return false
+ }
+ continue
+ case q.end:
+ if len(stack) == 0 || stack[len(stack)-1].name != q.name {
+ return blockElements[q.name]
+ }
+ return stack[len(stack)-1].ownLines
+ case silent(q):
+ if rawElements[q.name] && !q.void {
+ k = closing(ps, k)
+ }
+ continue
+ case q.name == "br" || blockElements[q.name]:
+ return true
+ default:
+ return describeElem(q, stack).blockLevel
+ }
+ }
+ return true
+}
+
+// hasElementChild says whether the element opened at ps[i] has a tag
+// anywhere before its end.
+func hasElementChild(ps []piece, i int) bool {
+ end := closing(ps, i)
+ for k := i + 1; k < end; k++ {
+ if ps[k].kind == tagPiece {
+ return true
+ }
+ }
+ return false
+}
+
+// closing is the index of the tag that closes the raw element opened
+// at ps[i], counting nested elements of the same name; the last piece
+// when the markup never closes it.
+func closing(ps []piece, i int) int {
+ name, open := ps[i].name, 1
+ for j := i + 1; j < len(ps); j++ {
+ if ps[j].kind != tagPiece || ps[j].name != name {
+ continue
+ }
+ if ps[j].end {
+ open--
+ if open == 0 {
+ return j
+ }
+ } else if !ps[j].void {
+ open++
+ }
+ }
+ return len(ps) - 1
+}
diff --git a/internal/codeview/format_test.go b/internal/codeview/format_test.go
new file mode 100644
index 0000000..3dd517e
--- /dev/null
+++ b/internal/codeview/format_test.go
@@ -0,0 +1,215 @@
+package codeview
+
+import (
+ "strings"
+ "testing"
+ "unicode"
+)
+
+// squash is markup with every whitespace character taken out. Format
+// may add, move and drop whitespace and must touch nothing else, so the
+// two sides of a formatting agree once squashed. Whether the whitespace
+// it moved could show is the browser's question, not this one's, and
+// no drive here asks it yet (see the note above flexAttrs).
+func squash(s string) string {
+ return strings.Map(func(r rune) rune {
+ if unicode.IsSpace(r) {
+ return -1
+ }
+ return r
+ }, s)
+}
+
+var formatCases = []string{
+ `<section rst-box><form rst-form method="post" action="#"><div rst-field><label rst-field-label for="t">Title</label><input rst-input id="t" name="t"></div></form></section>`,
+ `<div rst-list><div rst-lrow><a class="rst-nm" href="/p/1">Release notes<small>2 August</small></a> <span rst-pill>Draft</span></div></div>`,
+ `<span rst-meter><meter rst-meter-bar value="100" min="0" max="100" aria-hidden="true"></meter><span rst-meter-num>700/500</span></span>`,
+ "<ul>\n <li>One</li>\n\n<li>Two</li></ul><p>After</p>",
+ `<details rst-dropdown name="rst-menus"><summary>Filter<span rst-caret aria-hidden="true"><svg class="icon" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></svg></span></summary><div rst-dropdown-menu><a href="/a">A</a></div></details>`,
+ `<div rst-form-foot><button rst-btn="primary lg" type="submit" aria-busy="true" disabled><span rst-spin aria-hidden="true"></span>Publish</button><a rst-btn="lg" href="/posts">Back</a></div>`,
+ `<form rst-form-actions method="post"><input type="hidden" name="csrf" value="t"><input type="hidden" name="id" value="1"><a rst-btn href="/p">Cancel</a><button rst-btn="danger">Delete</button></form>`,
+}
+
+func TestFormatChangesOnlyWhitespaceAndIsIdempotent(t *testing.T) {
+ for _, in := range formatCases {
+ out := Format(in)
+ if squash(out) != squash(in) {
+ t.Errorf("Format changed more than whitespace:\n in: %s\nout: %s", in, out)
+ }
+ if Format(out) != out {
+ t.Errorf("Format is not idempotent on:\n%s\nsecond pass:\n%s", out, Format(out))
+ }
+ }
+}
+
+// The space between two inline elements, and between text and an
+// inline element, is rendered wherever the container is not one the
+// formatter knows lays its children out as flex or grid, so Format
+// keeps it exactly. An unknown container stays inline.
+func TestFormatKeepsTheSpaceBetweenInlineElements(t *testing.T) {
+ for in, want := range map[string]string{
+ `<div><a href="/a">A</a> <span>B</span></div>`: "<div>\n <a href=\"/a\">A</a> <span>B</span>\n</div>",
+ `<p>Hello <b>world</b> <i>again</i></p>`: "<p>Hello <b>world</b> <i>again</i></p>",
+ `<span rst-a>x</span> <span rst-b>y</span>`: `<span rst-a>x</span> <span rst-b>y</span>`,
+ "<li><a href=\"/x\">X</a>\n<a href=\"/y\">Y</a></li>": "<li>\n <a href=\"/x\">X</a>\n<a href=\"/y\">Y</a>\n</li>",
+ `<p>Total <output name="t">$1</output> due</p>`: `<p>Total <output name="t">$1</output> due</p>`,
+ `<label rst-field-label for="a">Name <span rst-field-required aria-hidden="true">*</span></label>`: `<label rst-field-label for="a">Name <span rst-field-required aria-hidden="true">*</span></label>`,
+ `<div rst-unknown><span>a</span><span>b</span></div>`: "<div rst-unknown>\n <span>a</span><span>b</span>\n</div>",
+ } {
+ if got := Format(in); got != want {
+ t.Errorf("Format(%q)\n got %q\nwant %q", in, got, want)
+ }
+ }
+}
+
+func TestFormatBreaksAtBlockTagsAndIndents(t *testing.T) {
+ got := Format(`<section rst-box><form><div rst-field><label>Title</label><input name="t"></div></form></section>`)
+ want := "<section rst-box>\n <form>\n <div rst-field>\n <label>Title</label><input name=\"t\">\n </div>\n </form>\n</section>"
+ if got != want {
+ t.Errorf("got\n%s\nwant\n%s", got, want)
+ }
+}
+
+// Inside a flex or grid container whitespace between children renders
+// as nothing, so every child gets a line: the busy button's spinner and
+// its label, and the two buttons beside each other. A button holding
+// only its label keeps it inline.
+func TestFormatPutsEachChildOfAFlexContainerOnALine(t *testing.T) {
+ got := Format(formatCases[5])
+ want := "<div rst-form-foot>\n" +
+ " <button rst-btn=\"primary lg\" type=\"submit\" aria-busy=\"true\" disabled>\n" +
+ " <span rst-spin aria-hidden=\"true\"></span>\n" +
+ " Publish\n" +
+ " </button>\n" +
+ " <a rst-btn=\"lg\" href=\"/posts\">Back</a>\n" +
+ "</div>"
+ if got != want {
+ t.Errorf("got\n%s\nwant\n%s", got, want)
+ }
+ got = Format(formatCases[2])
+ want = "<span rst-meter>\n <meter rst-meter-bar value=\"100\" min=\"0\" max=\"100\" aria-hidden=\"true\"></meter>\n <span rst-meter-num>700/500</span>\n</span>"
+ if got != want {
+ t.Errorf("got\n%s\nwant\n%s", got, want)
+ }
+}
+
+// A hidden input renders nothing, so the space beside it shows only if
+// something visible shares its line. At the head of a form, before the
+// first thing drawn, each one gets a line.
+func TestFormatPutsSilentElementsOnLinesOfTheirOwn(t *testing.T) {
+ got := Format(formatCases[6])
+ want := "<form rst-form-actions method=\"post\">\n" +
+ " <input type=\"hidden\" name=\"csrf\" value=\"t\">\n" +
+ " <input type=\"hidden\" name=\"id\" value=\"1\">\n" +
+ " <a rst-btn href=\"/p\">Cancel</a>\n" +
+ " <button rst-btn=\"danger\">Delete</button>\n" +
+ "</form>"
+ if got != want {
+ t.Errorf("got\n%s\nwant\n%s", got, want)
+ }
+ // In a sentence, a hidden input between two words keeps the line it
+ // is on: a break there would be a space between them.
+ in := `<p>one<input type="hidden" name="a">two</p>`
+ if got := Format(in); got != in {
+ t.Errorf("a hidden input inside running text was broken out:\n%s", got)
+ }
+}
+
+// An element with nothing in it stays with nothing in it: :empty is a
+// selector tokens.css uses, and a line break would stop it matching.
+func TestFormatLeavesAnEmptyElementEmpty(t *testing.T) {
+ for _, in := range []string{
+ `<div rst-form-foot><span rst-spin aria-hidden="true"></span></div>`,
+ `<footer rst-shell-foot></footer>`,
+ `<div rst-lrow="head"><span>Order</span><span></span></div>`,
+ } {
+ out := Format(in)
+ for _, empty := range []string{`<span rst-spin aria-hidden="true"></span>`, `<footer rst-shell-foot></footer>`, `<span></span>`} {
+ if strings.Contains(in, empty) && !strings.Contains(out, empty) {
+ t.Errorf("an empty element got content:\n%s", out)
+ }
+ }
+ }
+}
+
+// A <pre> or <textarea> whose content looks like tags,
+// an svg, and an attribute value holding a '>' all pass through byte
+// for byte.
+func TestFormatLeavesRawZonesAndQuotedBrackets(t *testing.T) {
+ for _, raw := range []string{
+ "<pre> <div>\n not a tag </div>\n</pre>",
+ "<textarea name=\"n\"><p>typed</p>\n </textarea>",
+ `<svg viewBox="0 0 2 2"><g><path d="M0 0"/></g></svg>`,
+ } {
+ in := "<div>" + raw + "</div>"
+ out := Format(in)
+ if !strings.Contains(out, raw) {
+ t.Errorf("a raw zone changed:\n in: %s\nout: %s", in, out)
+ }
+ }
+ in := `<div data-tip="a > b"><p title='x <div> y'>t</p></div>`
+ out := Format(in)
+ for _, v := range []string{`data-tip="a > b"`, `title='x <div> y'`} {
+ if !strings.Contains(out, v) {
+ t.Errorf("an attribute value changed: %s", out)
+ }
+ }
+ if strings.Count(out, "\n") != 2 {
+ t.Errorf("a '>' inside a quoted value was taken for the end of a tag:\n%s", out)
+ }
+}
+
+// A select sits in its line like a word: the space after it shows, so
+// the formatter may lay out its option list, where nothing between two
+// options renders, but must not break around the select itself.
+func TestFormatKeepsASelectInItsLine(t *testing.T) {
+ in := `<p>Pick <select><option>A</option></select><span>B</span></p>`
+ if got := Format(in); !strings.Contains(got, "Pick <select>") || !strings.Contains(got, "</select><span>B</span>") {
+ t.Errorf("a select in running text was broken out:\n%s", got)
+ }
+ got := Format(`<div rst-field><select name="s"><optgroup label="E"><option>A</option><option>B</option></optgroup></select></div>`)
+ want := "<div rst-field>\n <select name=\"s\">\n <optgroup label=\"E\">\n <option>A</option>\n <option>B</option>\n </optgroup>\n </select>\n</div>"
+ if got != want {
+ t.Errorf("got\n%s\nwant\n%s", got, want)
+ }
+}
+
+// Under white-space: pre and its kin a line break is a line break and
+// an indent is spaces, whatever the container. An element that says so
+// in its style attribute is left byte for byte, its children included,
+// as a <pre> is.
+func TestFormatLeavesWhitespacePreservingStylesAlone(t *testing.T) {
+ for _, style := range []string{"white-space:pre", "white-space: pre-wrap", "white-space:pre-line", "white-space: break-spaces", "white-space-collapse: preserve"} {
+ button := `<button rst-btn style="` + style + `">Pay <em>now</em></button>`
+ got := Format(`<div rst-form-foot>` + button + `<a rst-btn href="/">Back</a></div>`)
+ if !strings.Contains(got, button) {
+ t.Errorf("%s: the button's content was reformatted:\n%s", style, got)
+ }
+ }
+ // nowrap collapses whitespace like normal, so it changes nothing.
+ got := Format(`<div rst-form-foot><button rst-btn style="white-space: nowrap"><span rst-spin></span>Pay</button></div>`)
+ if !strings.Contains(got, "\n Pay\n") {
+ t.Errorf("white-space: nowrap stopped the formatter:\n%s", got)
+ }
+}
+
+// HTML allows whitespace either side of an attribute's =, and the
+// formatter's reading of a tag has to see the attribute the browser
+// sees: style = "white-space:pre" preserves whitespace exactly as
+// style="white-space:pre" does.
+func TestFormatReadsAnAttributeWithSpacesAroundItsEquals(t *testing.T) {
+ button := "<button rst-btn style = \"white-space:pre\">Pay <em>now</em></button>"
+ got := Format(`<div rst-form-foot>` + button + `<a rst-btn href="/">Back</a></div>`)
+ if !strings.Contains(got, button) {
+ t.Errorf("a style written with spaces around = was not seen:\n%s", got)
+ }
+ attrs := attrsOf("<input type =\n'hidden' name\t= n value=v checked>")
+ for name, want := range map[string]string{"type": "hidden", "name": "n", "value": "v", "checked": ""} {
+ if got, ok := attrs[name]; !ok || got != want {
+ t.Errorf("attrsOf: %s = %q (present %v), want %q", name, got, ok, want)
+ }
+ }
+ if len(attrs) != 4 {
+ t.Errorf("attrsOf read %d attributes, want 4: %v", len(attrs), attrs)
+ }
+}
diff --git a/internal/codeview/highlight.go b/internal/codeview/highlight.go
new file mode 100644
index 0000000..e40bc6b
--- /dev/null
+++ b/internal/codeview/highlight.go
@@ -0,0 +1,172 @@
+package codeview
+
+import (
+ "html/template"
+ "strings"
+)
+
+// Highlight escapes source for an HTML text context and wraps five
+// kinds of token in two-letter custom elements that the gallery's
+// stylesheet colours: <ds-t> a tag's name with its opening bracket,
+// <ds-a> an attribute name, <ds-v> an attribute value with its quotes,
+// <ds-x> a template action's delimiters, keywords and fields, and <ds-s>
+// a string literal inside an action.
+//
+// Custom elements rather than <span class>: a token costs 13 bytes this
+// way and 29 the other, and on the heaviest page that difference was
+// the page budget. An undefined element is inline with no role, so it
+// changes nothing for assistive technology or for textContent.
+//
+// Only &, < and > are escaped. Quotes stay literal, which is valid in
+// text and spares four bytes on every attribute a sample writes.
+func Highlight(src string) template.HTML {
+ var b strings.Builder
+ for i := 0; i < len(src); {
+ switch {
+ case strings.HasPrefix(src[i:], "{{"):
+ end := actionEnd(src, i)
+ action(&b, src[i:end])
+ i = end
+ case src[i] == '<' && tagEnd(src, i) > 0:
+ end := tagEnd(src, i)
+ tag(&b, src[i:end])
+ i = end
+ default:
+ j := i + 1
+ for j < len(src) && !strings.HasPrefix(src[j:], "{{") && !(src[j] == '<' && tagEnd(src, j) > 0) {
+ j++
+ }
+ escape(&b, src[i:j])
+ i = j
+ }
+ }
+ return template.HTML(b.String())
+}
+
+func escape(b *strings.Builder, s string) {
+ for i := 0; i < len(s); i++ {
+ switch s[i] {
+ case '&':
+ b.WriteString("&amp;")
+ case '<':
+ b.WriteString("&lt;")
+ case '>':
+ b.WriteString("&gt;")
+ default:
+ b.WriteByte(s[i])
+ }
+ }
+}
+
+func wrapped(b *strings.Builder, kind, s string) {
+ b.WriteString("<ds-" + kind + ">")
+ escape(b, s)
+ b.WriteString("</ds-" + kind + ">")
+}
+
+// tag writes one tag: its name, each attribute's name and value, and
+// the whitespace and closing bracket between them as plain text.
+func tag(b *strings.Builder, t string) {
+ n := 1
+ if strings.HasPrefix(t, "</") {
+ n = 2
+ }
+ for n < len(t) && !isSpace(t[n]) && t[n] != '>' && t[n] != '/' {
+ n++
+ }
+ wrapped(b, "t", t[:n])
+ for i := n; i < len(t); {
+ c := t[i]
+ switch {
+ case isSpace(c) || c == '/' || c == '>':
+ escape(b, t[i:i+1])
+ i++
+ case c == '=':
+ b.WriteByte('=')
+ i++
+ j := i
+ if j < len(t) && (t[j] == '"' || t[j] == '\'') {
+ q := t[j]
+ j++
+ for j < len(t) && t[j] != q {
+ j++
+ }
+ if j < len(t) {
+ j++
+ }
+ } else {
+ for j < len(t) && !isSpace(t[j]) && t[j] != '>' {
+ j++
+ }
+ }
+ wrapped(b, "v", t[i:j])
+ i = j
+ default:
+ j := i
+ for j < len(t) && !isSpace(t[j]) && t[j] != '=' && t[j] != '>' && !(t[j] == '/' && j+1 < len(t) && t[j+1] == '>') {
+ j++
+ }
+ wrapped(b, "a", t[i:j])
+ i = j
+ }
+ }
+}
+
+// actionEnd returns the index just past the action starting at s[i],
+// treating "}}" inside a string literal as part of the string.
+func actionEnd(s string, i int) int {
+ for j := i + 2; j < len(s); j++ {
+ switch s[j] {
+ case '"':
+ for j++; j < len(s) && s[j] != '"'; j++ {
+ if s[j] == '\\' {
+ j++
+ }
+ }
+ case '`':
+ for j++; j < len(s) && s[j] != '`'; j++ {
+ }
+ case '}':
+ if j+1 < len(s) && s[j+1] == '}' {
+ return j + 2
+ }
+ }
+ }
+ return len(s)
+}
+
+// action writes one {{…}}: the delimiters, identifiers and .Fields as
+// <ds-x>, string literals as <ds-s>, and everything else as text.
+func action(b *strings.Builder, a string) {
+ body, closed := strings.CutSuffix(a[2:], "}}")
+ wrapped(b, "x", "{{")
+ for i := 0; i < len(body); {
+ c := body[i]
+ switch {
+ case c == '"' || c == '`':
+ j := i + 1
+ for j < len(body) && body[j] != c {
+ if c == '"' && body[j] == '\\' {
+ j++
+ }
+ j++
+ }
+ j = min(j+1, len(body))
+ wrapped(b, "s", body[i:j])
+ i = j
+ case isLetter(c) || c == '_' || c == '.':
+ j := i + 1
+ for j < len(body) && (isLetter(body[j]) || body[j] == '_' || body[j] == '.' || body[j] >= '0' && body[j] <= '9') {
+ j++
+ }
+ wrapped(b, "x", body[i:j])
+ i = j
+ default:
+ escape(b, body[i:i+1])
+ i++
+ }
+ }
+ if closed {
+ wrapped(b, "x", "}}")
+ }
+}
diff --git a/internal/codeview/highlight_test.go b/internal/codeview/highlight_test.go
new file mode 100644
index 0000000..60ddeee
--- /dev/null
+++ b/internal/codeview/highlight_test.go
@@ -0,0 +1,48 @@
+package codeview
+
+import (
+ "html"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+var highlightTag = regexp.MustCompile(`</?ds-[tavxs]>`)
+
+// plain is what a reader sees and copies: the highlighted markup with
+// its five elements removed and its entities decoded.
+func plain(h string) string { return html.UnescapeString(highlightTag.ReplaceAllString(h, "")) }
+
+func TestHighlightMarksTheFiveKinds(t *testing.T) {
+ got := string(Highlight(`{{template "meter" dict "Percent" 140 "Items" .Locales}}`))
+ want := `<ds-x>{{</ds-x><ds-x>template</ds-x> <ds-s>"meter"</ds-s> <ds-x>dict</ds-x> <ds-s>"Percent"</ds-s> 140 <ds-s>"Items"</ds-s> <ds-x>.Locales</ds-x><ds-x>}}</ds-x>`
+ if got != want {
+ t.Errorf("call:\n got %s\nwant %s", got, want)
+ }
+ got = string(Highlight(`<a rst-btn href="/x">Go</a><input disabled>`))
+ want = `<ds-t>&lt;a</ds-t> <ds-a>rst-btn</ds-a> <ds-a>href</ds-a>=<ds-v>"/x"</ds-v>&gt;Go<ds-t>&lt;/a</ds-t>&gt;<ds-t>&lt;input</ds-t> <ds-a>disabled</ds-a>&gt;`
+ if got != want {
+ t.Errorf("markup:\n got %s\nwant %s", got, want)
+ }
+}
+
+// The text on screen is the source, whatever is in it: entities, a
+// '>' inside a quoted value, {{ inside an attribute, upper case.
+func TestHighlightRoundTripsAwkwardMarkup(t *testing.T) {
+ for _, src := range []string{
+ `<a href="/x?a=1&b=2" data-tip="a > b">Tom &amp; Jerry &euro; < 3</a>`,
+ `<DIV Title='it"s'>{{x}}</DIV><!-- a comment <b> -->`,
+ `<div title="{{.Name}}">a {{ "}}" }} b</div>`,
+ "{{template \"x\" dict\n \"A\" \"a\\\"b\"\n \"B\" `raw`}}",
+ `a < b > c & d`,
+ `<span rst-stat-num>&minus;3%</span>`,
+ } {
+ h := string(Highlight(src))
+ if got := plain(h); got != src {
+ t.Errorf("round trip lost bytes:\n src: %s\nplain: %s\n html: %s", src, got, h)
+ }
+ if strings.Contains(highlightTag.ReplaceAllString(h, ""), "<") {
+ t.Errorf("an unescaped '<' reached the page: %s", h)
+ }
+ }
+}
diff --git a/internal/codeview/pieces.go b/internal/codeview/pieces.go
new file mode 100644
index 0000000..9bdc0d7
--- /dev/null
+++ b/internal/codeview/pieces.go
@@ -0,0 +1,173 @@
+// Package codeview lays out and colours the source a design-system
+// gallery shows in its HTML and Template tabs. It knows nothing about
+// the gallery: it takes HTML or template source and gives back the same
+// text, broken into lines or marked up for colour, so that the bytes a
+// reader copies are the bytes the source holds.
+package codeview
+
+import "strings"
+
+// piece is one run of markup: a tag, a comment, or the text between.
+type piece struct {
+ text string
+ kind pieceKind
+ name string // lower-case element name, for a tag
+ end bool // a closing tag
+ void bool // opens no element: a void element or a self-closed tag
+}
+
+type pieceKind int
+
+const (
+ textPiece pieceKind = iota
+ tagPiece
+ commentPiece
+)
+
+// voidElements open nothing, so they never deepen the indent.
+var voidElements = map[string]bool{
+ "area": true, "base": true, "br": true, "col": true, "embed": true, "hr": true, "img": true,
+ "input": true, "link": true, "meta": true, "source": true, "track": true, "wbr": true,
+}
+
+// tagEnd returns the index just past the tag that starts at s[i], or -1
+// when s[i] does not open one. A '>' inside a quoted attribute value
+// does not close the tag: data-tip="a > b" is one tag and not two.
+func tagEnd(s string, i int) int {
+ j := i + 1
+ if j < len(s) && s[j] == '/' {
+ j++
+ }
+ if j >= len(s) || !isLetter(s[j]) {
+ return -1
+ }
+ var quote byte
+ for ; j < len(s); j++ {
+ c := s[j]
+ switch {
+ case quote != 0:
+ if c == quote {
+ quote = 0
+ }
+ case c == '"' || c == '\'':
+ quote = c
+ case c == '>':
+ return j + 1
+ }
+ }
+ return -1
+}
+
+// pieces splits markup into tags, comments and text. A '<' that opens
+// no tag ("a < b") stays text.
+func pieces(s string) []piece {
+ var out []piece
+ text := 0
+ flush := func(at int) {
+ if at > text {
+ out = append(out, piece{text: s[text:at]})
+ }
+ }
+ for i := 0; i < len(s); {
+ if s[i] != '<' {
+ i++
+ continue
+ }
+ if strings.HasPrefix(s[i:], "<!--") {
+ end := strings.Index(s[i+4:], "-->")
+ if end < 0 {
+ break
+ }
+ flush(i)
+ j := i + 4 + end + 3
+ out = append(out, piece{text: s[i:j], kind: commentPiece})
+ i, text = j, j
+ continue
+ }
+ j := tagEnd(s, i)
+ if j < 0 {
+ i++
+ continue
+ }
+ flush(i)
+ tag := s[i:j]
+ closing := strings.HasPrefix(tag, "</")
+ n := 1
+ if closing {
+ n = 2
+ }
+ k := n
+ for k < len(tag) && !isSpace(tag[k]) && tag[k] != '>' && tag[k] != '/' {
+ k++
+ }
+ name := strings.ToLower(tag[n:k])
+ out = append(out, piece{
+ text: tag, kind: tagPiece, name: name, end: closing,
+ void: !closing && (voidElements[name] || strings.HasSuffix(tag, "/>")),
+ })
+ i, text = j, j
+ }
+ flush(len(s))
+ return out
+}
+
+func isLetter(c byte) bool { return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' }
+
+func isSpace(c byte) bool { return c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '\f' }
+
+// attrsOf reads a start tag's attributes: names lower-cased, values
+// unquoted, a bare attribute present with an empty value, and spaces
+// around an = allowed, as HTML allows them.
+func attrsOf(tag string) map[string]string {
+ out := map[string]string{}
+ i := 1
+ for i < len(tag) && !isSpace(tag[i]) && tag[i] != '>' && tag[i] != '/' {
+ i++
+ }
+ for i < len(tag) {
+ for i < len(tag) && (isSpace(tag[i]) || tag[i] == '/') {
+ i++
+ }
+ if i >= len(tag) || tag[i] == '>' {
+ break
+ }
+ start := i
+ for i < len(tag) && !isSpace(tag[i]) && tag[i] != '=' && tag[i] != '>' && tag[i] != '/' {
+ i++
+ }
+ name := strings.ToLower(tag[start:i])
+ value := ""
+ // HTML allows whitespace either side of the =: style = "…" is a
+ // style. Without an = after the space, the name was a bare
+ // attribute and the space starts the next one.
+ j := i
+ for j < len(tag) && isSpace(tag[j]) {
+ j++
+ }
+ if j < len(tag) && tag[j] == '=' {
+ i = j + 1
+ for i < len(tag) && isSpace(tag[i]) {
+ i++
+ }
+ if i < len(tag) && (tag[i] == '"' || tag[i] == '\'') {
+ q := tag[i]
+ end := strings.IndexByte(tag[i+1:], q)
+ if end < 0 {
+ end = len(tag) - i - 1
+ }
+ value = tag[i+1 : i+1+end]
+ i += end + 2
+ } else {
+ start := i
+ for i < len(tag) && !isSpace(tag[i]) && tag[i] != '>' {
+ i++
+ }
+ value = tag[start:i]
+ }
+ }
+ if name != "" {
+ out[name] = value
+ }
+ }
+ return out
+}
diff --git a/internal/tokens/tokens.go b/internal/tokens/tokens.go
new file mode 100644
index 0000000..6eeb944
--- /dev/null
+++ b/internal/tokens/tokens.go
@@ -0,0 +1,328 @@
+// Package tokens reads the design system's custom properties out of
+// its own stylesheets, for the gallery's Tokens page: the structure
+// every page shares from css/tokens.css, and one theme's palette from
+// css/themes/<name>.css, each as a list of groups of rows a page can
+// draw a chip, a bar or a line of type beside.
+//
+// The parser is rastrillo's designsystem one, and the same regex family
+// check/ uses; a stylesheet parser small enough to read twice is cheaper
+// than the seam it would take to share one. If the two ever disagree,
+// check/ is the authority: it is the one holding a WCAG floor.
+package tokens
+
+import (
+ "fmt"
+ "html/template"
+ "regexp"
+ "sort"
+ "strings"
+
+ "amadan.net/rastrillo/fiesta"
+)
+
+var declPattern = regexp.MustCompile(`(--rst-[a-z0-9-]+)\s*:\s*([^;]+);`)
+
+// colourValue matches a value that is a colour on its own, so it can be
+// shown as a chip. A shadow (0 8px 24px rgba(…)) is deliberately not
+// one: it contains a colour but is not one, and it gets its own preview.
+//
+// The last two alternatives are for a DERIVED colour: a token whose
+// value names other tokens rather than a literal, which is what
+// --rst-header-rule is in all three themes (color-mix() in day and
+// signal, a bare var() in plain). The chip is painted with
+// var(<the token>), never with the value text, so the browser resolves
+// the derivation for us and a chip is exactly as correct here as it is
+// for a hex. Without these, a derived colour rendered as a row of text
+// with a hole where its swatch should be. Both allow nested parens,
+// which a color-mix() over two var()s always has.
+var colourValue = regexp.MustCompile(`^(#[0-9a-fA-F]{3}|#[0-9a-fA-F]{6}|rgba?\([^()]*\)|hsla?\([^()]*\)|color-mix\(.*\)|oklch\(from .*\)|var\(--rst-[a-z0-9-]+(, .*)?\))$`)
+
+// blockBody returns the brace-matched contents following header (which
+// must end in "{"). Theme files nest at most one level and no value in
+// them contains a brace, so depth counting is enough.
+func blockBody(css, header string) (string, error) {
+ i := strings.Index(css, header)
+ if i < 0 {
+ return "", fmt.Errorf("no %q block", header)
+ }
+ start := i + len(header)
+ depth := 1
+ for j := start; j < len(css); j++ {
+ switch css[j] {
+ case '{':
+ depth++
+ case '}':
+ depth--
+ if depth == 0 {
+ return css[start:j], nil
+ }
+ }
+ }
+ return "", fmt.Errorf("unterminated %q block", header)
+}
+
+// Row is one custom property as the page shows it. Preview is the
+// inline style that makes the token visible: a chip painted with it, a
+// bar as wide as it, a line of text set at it.
+//
+// It is template.CSS because it has to survive html/template's style
+// attribute sanitiser, which would otherwise reject var(--rst-…) as an
+// unrecognised value and write ZgotmplZ into the page. That is safe
+// here and only here: the declaration is built from a token name this
+// package just matched against --rst-[a-z0-9-]+ in the design system's
+// own stylesheet. Nothing a caller supplies reaches it.
+type Row struct {
+ Name string
+ Value string
+ Colour bool // the value is a colour on its own
+ Shadow bool // the value is a shadow: preview it on a card
+ Preview template.CSS
+}
+
+// parseTokens extracts every --rst-* declaration in body, sorted by
+// name. Last declaration of a name wins, matching CSS's own rule.
+func parseTokens(body string) []Row {
+ seen := map[string]string{}
+ for _, m := range declPattern.FindAllStringSubmatch(body, -1) {
+ seen[m[1]] = strings.TrimSpace(m[2])
+ }
+ names := make([]string, 0, len(seen))
+ for name := range seen {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ rows := make([]Row, 0, len(names))
+ for _, name := range names {
+ v := seen[name]
+ row := Row{
+ Name: name,
+ Value: v,
+ Colour: colourValue.MatchString(v),
+ Shadow: strings.HasPrefix(name, "--rst-shadow"),
+ }
+ // Only a value that is a colour gets painted into a chip, and
+ // only a shadow gets worn by one. Anything else, a future token
+ // in the light block that is neither, shows its name and value
+ // with no preview, rather than a chip painted with something
+ // that is not a colour.
+ switch {
+ case row.Shadow:
+ row.Preview = preview("box-shadow", name)
+ case row.Colour:
+ row.Preview = preview("background", name)
+ }
+ rows = append(rows, row)
+ }
+ return rows
+}
+
+// preview builds one CSS declaration naming a token. See Row's comment
+// for why the result is marked safe.
+func preview(property, token string) template.CSS {
+ return template.CSS(property + ": var(" + token + ")")
+}
+
+// Group is a run of related custom properties under one heading. Kind
+// picks the shape of the preview the page draws beside each row:
+// "colour" (the default), "type", "space", "radius" or "font".
+//
+// Key names the group and is the same on every page and in every
+// language, so a link to a group is a link to the same thing on all of
+// them. The gallery writes the heading a reader sees from it; this
+// package holds no English.
+type Group struct {
+ Key string
+ Kind string
+ Rows []Row
+}
+
+// newGroup starts a group. Every Group comes through here, so none can
+// reach a page without a key for its heading and its anchor.
+func newGroup(key, kind string) Group {
+ return Group{Key: key, Kind: kind}
+}
+
+// colourGroups splits a theme's light-block tokens into the seven groups
+// the palette is actually authored in, with anything unrecognised
+// landing in "other" rather than disappearing: a new token shows up on
+// the page the day it is added, in the wrong group at worst.
+func colourGroups(rows []Row) []Group {
+ groups := []struct {
+ Key string
+ Prefixes []string
+ }{
+ {"surfaces-and-lines", []string{"--rst-bg", "--rst-surface", "--rst-line", "--rst-header-rule", "--rst-rail-bg"}},
+ {"text", []string{"--rst-text"}},
+ {"accent", []string{"--rst-accent", "--rst-on-accent"}},
+ {"status-tones", []string{"--rst-tone-"}},
+ // The depth axis: the light, the sheen, the press strengths and
+ // the shadows. A number row draws no chip, by Row's rule.
+ {"depth", []string{"--rst-shadow", "--rst-overlay", "--rst-depth-", "--rst-light-", "--rst-sheen-", "--rst-press-"}},
+ // --rst-row-tint- is listed as well as --rst-tint-: the prefix
+ // match is anchored at the start, so without it the two row
+ // tints fall through to "other".
+ // --rst-switch- is the switch's two size sets, lengths beside
+ // its knob and track colours.
+ {"controls", []string{"--rst-danger-", "--rst-on-danger-", "--rst-control-", "--rst-ghost-", "--rst-summary-", "--rst-knob", "--rst-track-", "--rst-switch-", "--rst-tint-", "--rst-row-tint-", "--rst-focus-", "--rst-nav-current-"}},
+ {"type-choices", []string{"--rst-title-", "--rst-heading-", "--rst-label-"}},
+ }
+ out := make([]Group, 0, len(groups)+1)
+ claimed := map[string]bool{}
+ for _, g := range groups {
+ group := newGroup(g.Key, "colour")
+ for _, row := range rows {
+ for _, prefix := range g.Prefixes {
+ if strings.HasPrefix(row.Name, prefix) && !claimed[row.Name] {
+ claimed[row.Name] = true
+ group.Rows = append(group.Rows, row)
+ break
+ }
+ }
+ }
+ if len(group.Rows) > 0 {
+ out = append(out, group)
+ }
+ }
+ other := newGroup("other", "colour")
+ for _, row := range rows {
+ if !claimed[row.Name] {
+ other.Rows = append(other.Rows, row)
+ }
+ }
+ if len(other.Rows) > 0 {
+ out = append(out, other)
+ }
+ return out
+}
+
+// Structural pulls the type scale and the spacing steps out of
+// css/tokens.css. They are structure, not colour, so they are the same
+// on every page; but they belong beside the palette, because a reader
+// looking up "how big is small" does not care which file the answer
+// lives in.
+func Structural() ([]Group, error) {
+ body, err := blockBody(string(fiesta.TokensCSS()), ":root {")
+ if err != nil {
+ return nil, fmt.Errorf("tokens.css: %w", err)
+ }
+ rows := parseTokens(body)
+ pick := func(prefix, property string) []Row {
+ var out []Row
+ for _, r := range rows {
+ if strings.HasPrefix(r.Name, prefix) {
+ r.Preview = preview(property, r.Name)
+ out = append(out, r)
+ }
+ }
+ return out
+ }
+ // No radius group: the radii live in the themes, so they are part
+ // of the palette below, not of the structure every page shares.
+ scale := newGroup("type-scale", "type")
+ scale.Rows = pick("--rst-fs-", "font-size")
+ space := newGroup("spacing", "space")
+ space.Rows = pick("--rst-sp-", "inline-size")
+ return []Group{scale, space}, nil
+}
+
+// lightHalf resolves a theme value for the light scheme: the first
+// argument of a whole-value light-dark(<light>, <dark>) call, or the
+// value unchanged when it is not one. The split is paren-aware, because
+// both halves are usually rgba() calls carrying commas of their own.
+//
+// A value that merely contains a light-dark() somewhere inside it, a
+// shadow such as "0 8px 24px light-dark(a, b)", is left whole on
+// purpose: the page shows shadows as a shadow worn by a card, not as a
+// colour chip, so the whole declaration is what a reader needs to see.
+func lightHalf(v string) string {
+ const prefix = "light-dark("
+ if !strings.HasPrefix(v, prefix) || !strings.HasSuffix(v, ")") {
+ return v
+ }
+ inner := v[len(prefix) : len(v)-1]
+ depth := 0
+ for i := 0; i < len(inner); i++ {
+ switch inner[i] {
+ case '(':
+ depth++
+ case ')':
+ depth--
+ if depth < 0 {
+ return v
+ }
+ case ',':
+ if depth == 0 {
+ return strings.TrimSpace(inner[:i])
+ }
+ }
+ }
+ return v
+}
+
+// Palette reads one theme's single :root block: the colours resolved
+// to their light halves, the shape tokens as their own group, and the
+// type family. Dark values are not on the page: they live in the same
+// declaration, and the page says so rather than showing a second set
+// of chips nobody can compare side by side anyway.
+//
+// The type family comes back as a one-row group rather than as a Row
+// of its own: a group with one row renders the same bytes (a font
+// stack has no preview to draw) and is one fewer special case for the
+// page.
+func Palette(theme string) ([]Group, error) {
+ raw, ok := fiesta.ThemeCSS(theme)
+ if !ok {
+ return nil, fmt.Errorf("no theme %q", theme)
+ }
+ css := string(raw)
+ // ":root {" cannot match the two toggle rules at the foot of the
+ // file, whose selectors are ":root[data-theme=…] {".
+ body, err := blockBody(css, ":root {")
+ if err != nil {
+ return nil, fmt.Errorf("themes/%s.css: %w", theme, err)
+ }
+
+ var palette []Row
+ var radii []Row
+ var font Row
+ for _, row := range parseTokens(body) {
+ switch {
+ case row.Name == "--rst-font":
+ font = row
+ case strings.HasPrefix(row.Name, "--rst-radius"):
+ row.Preview = preview("border-radius", row.Name)
+ radii = append(radii, row)
+ default:
+ palette = append(palette, resolveLight(row))
+ }
+ }
+ groups := colourGroups(palette)
+ if len(radii) > 0 {
+ radius := newGroup("radius", "radius")
+ radius.Rows = radii
+ groups = append(groups, radius)
+ }
+ if font.Name != "" {
+ family := newGroup("type-family", "font")
+ family.Rows = []Row{font}
+ groups = append(groups, family)
+ }
+ return groups, nil
+}
+
+// resolveLight rewrites one row to the light scheme: the displayed value
+// loses its light-dark() wrapper, and a value that only became a colour
+// once resolved (every palette token) gets its chip back.
+func resolveLight(row Row) Row {
+ row.Value = lightHalf(row.Value)
+ row.Colour = colourValue.MatchString(row.Value)
+ switch {
+ case row.Shadow:
+ row.Preview = preview("box-shadow", row.Name)
+ case row.Colour:
+ row.Preview = preview("background", row.Name)
+ default:
+ row.Preview = ""
+ }
+ return row
+}
diff --git a/internal/tokens/tokens_test.go b/internal/tokens/tokens_test.go
new file mode 100644
index 0000000..8d8b8c4
--- /dev/null
+++ b/internal/tokens/tokens_test.go
@@ -0,0 +1,117 @@
+package tokens
+
+import (
+ "strings"
+ "testing"
+
+ "amadan.net/rastrillo/fiesta"
+)
+
+// A shadow is the interesting negative. It CONTAINS a colour and is not
+// one, it gets a different preview (a card wearing it rather than a
+// chip painted with it), and a pattern loose enough to match one would
+// silently reclassify all four depth tokens.
+func TestColourValueRecognisesADerivedColour(t *testing.T) {
+ for _, v := range []string{
+ "#fff",
+ "#ffffff",
+ "rgb(0, 0, 0)",
+ "rgba(0, 0, 0, 0.45)",
+ "var(--rst-line)",
+ "color-mix(in oklab, var(--rst-accent) 18%, var(--rst-line))",
+ "color-mix(in oklab, var(--rst-accent) 45%, var(--rst-line))",
+ // day's accent derivations: a hue taken from the accent, a mix
+ // whose source carries a fallback, and a bare var() with a
+ // light-dark() fallback.
+ "oklch(from var(--rst-accent, #1f56d6) 0.513 0.21 h)",
+ "color-mix(in srgb, var(--rst-accent, #1f56d6) 4.5%, var(--rst-surface))",
+ "var(--rst-accent, light-dark(#1f56d6, #7aa2ff))",
+ } {
+ if !colourValue.MatchString(v) {
+ t.Errorf("colourValue does not match %q, so the Tokens page would draw its row with no swatch", v)
+ }
+ }
+ for _, v := range []string{
+ "0 8px 24px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5))",
+ "0 1px 2px rgba(0, 0, 0, 0.3)",
+ "8px",
+ "system-ui, -apple-system, sans-serif",
+ "999px",
+ } {
+ if colourValue.MatchString(v) {
+ t.Errorf("colourValue matches %q, which is not a colour on its own; a chip painted with it would preview nothing and a shadow would lose the preview it has", v)
+ }
+ }
+}
+
+// lightHalf takes the first half of a whole-value light-dark() and
+// leaves everything else alone, a shadow carrying one inside it
+// included.
+func TestLightHalfSplitsOnlyAWholeValue(t *testing.T) {
+ for in, want := range map[string]string{
+ "light-dark(#fff, #000)": "#fff",
+ "light-dark(rgba(0, 0, 0, 0.12), rgba(255, 255, 255, 0.5))": "rgba(0, 0, 0, 0.12)",
+ "light-dark(color-mix(in oklab, #fff 10%, #000), #000)": "color-mix(in oklab, #fff 10%, #000)",
+ "0 8px 24px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5))": "0 8px 24px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5))",
+ "#1f56d6": "#1f56d6",
+ "light-dark(#fff": "light-dark(#fff",
+ } {
+ if got := lightHalf(in); got != want {
+ t.Errorf("lightHalf(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+// Every theme yields the groups the page draws, every colour row in
+// them a chip, and nothing lands in "other": a token that does is a
+// token nobody filed under a heading.
+func TestEveryThemeHasAPaletteAndNothingIsOther(t *testing.T) {
+ structure, err := Structural()
+ if err != nil {
+ t.Fatal(err)
+ }
+ keys := map[string]int{}
+ for _, g := range structure {
+ keys[g.Key] = len(g.Rows)
+ }
+ if keys["type-scale"] == 0 || keys["spacing"] == 0 {
+ t.Errorf("Structural: type-scale has %d rows and spacing %d; tokens.css's :root block holds both scales", keys["type-scale"], keys["spacing"])
+ }
+
+ for _, theme := range fiesta.ThemeNames() {
+ groups, err := Palette(theme)
+ if err != nil {
+ t.Fatalf("%s: %v", theme, err)
+ }
+ seen := map[string]bool{}
+ for _, g := range groups {
+ if seen[g.Key] {
+ t.Errorf("%s: the %s group appears twice", theme, g.Key)
+ }
+ seen[g.Key] = true
+ if len(g.Rows) == 0 {
+ t.Errorf("%s: the %s group has no rows", theme, g.Key)
+ }
+ for _, r := range g.Rows {
+ if r.Colour && r.Preview == "" {
+ t.Errorf("%s: %s is a colour with no preview", theme, r.Name)
+ }
+ if strings.HasPrefix(r.Value, "light-dark(") && g.Kind == "colour" {
+ t.Errorf("%s: %s still reads %q; the palette shows the light half", theme, r.Name, r.Value)
+ }
+ }
+ if g.Key == "other" {
+ var names []string
+ for _, r := range g.Rows {
+ names = append(names, r.Name)
+ }
+ t.Errorf("%s: %d tokens fell through to \"other\": %v", theme, len(g.Rows), names)
+ }
+ }
+ for _, want := range []string{"surfaces-and-lines", "text", "accent", "status-tones", "depth", "controls", "radius", "type-family"} {
+ if !seen[want] {
+ t.Errorf("%s: no %s group", theme, want)
+ }
+ }
+ }
+}