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>
8 files changed,
+1577
−0
internal/axe/axe_test.go+59 −0internal/codeview/format.go+465 −0internal/codeview/format_test.go+215 −0internal/codeview/highlight.go+172 −0internal/codeview/highlight_test.go+48 −0internal/codeview/pieces.go+173 −0internal/tokens/tokens.go+328 −0internal/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("&") |
| + case '<': |
| + b.WriteString("<") |
| + case '>': |
| + b.WriteString(">") |
| + 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><a</ds-t> <ds-a>rst-btn</ds-a> <ds-a>href</ds-a>=<ds-v>"/x"</ds-v>>Go<ds-t></a</ds-t>><ds-t><input</ds-t> <ds-a>disabled</ds-a>>` |
| + 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 & Jerry € < 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>−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) |
| + } |
| + } |
| + } |
| +} |