Triage: the list fits in 320px, and three silent corruptions are closed
An overnight triage-and-fix pass over the seven open discussions
(#7, #8, #9, #11, #14, #16, #20). Discussion #26 is the branch record;
#7, #8 and #9 are closed as finished, #20 is untouched and blocked on
two rulings, and what was left of #11 and #14 is here.
## A live WCAG 2.2 SC 1.4.10 failure on main
The document list scrolled sideways at 320px: scrollWidth 356 against a
320 viewport, with form[docs-search-form] the innermost offender at
x=356. AGENTS.md allows no exemption from that width.
#16 reported it and shipped reflow-320, which never landed. Nine commits
later that branch was stale, so this re-measures rather than merges it —
and the re-measurement is the point. The DOCUMENT page had since been
fixed independently, so merging the branch would have re-landed a fix
for a bug that no longer exists, under a comment describing a
measurement that was no longer true. The list page half was still real.
[docs-search-form] held its field at a fixed 11rem beside a button whose
label does not wrap, under a comment claiming the group wrapping kept
320px safe. It never had: max-inline-size: 100% resolves against a form
sized by its own content, so the form set a min-content floor of ~334px
and stood wider than the 288px column. The field now takes what is left
after the button, and the button keeps its whole label because that
label is also the field's accessible name.
The second offender was not a second bug. [docs-create-form] is written
correctly; it overflowed because a wrapping flex container is at least
as wide as its widest item's min-content, so the search form's floor
widened the line the create form had to itself. Both went green on the
one fix, which is the evidence rather than the guess.
## Three defects that changed or hid a person's words
- **A newline in a text run injected Markdown into an export.**
escapeMarkdown asked whether the byte index was 0 — the start of the
RUN, not of a line — so after an embedded newline "#" became a
heading, "- " a bullet, "1." an ordered list. Reachable without a
hostile client: POST /d/{sqid}/updates takes bytes that never met
ProseMirror's schema. Fixed twice: the first fix handled only column
zero, and CommonMark allows a marker three spaces in. "=" (a setext
heading, which promotes the paragraph ABOVE it) and "~" (a fence that
swallows the rest of the file) are escaped at line start too.
- **Search could not find a title typed in the other Unicode form.**
"café" as U+00E9 and as e + combining acute compared as bytes, so a
title typed on a machine producing decomposed text was unfindable
from one producing precomposed text. NFC inside normaliseForSearch,
so both sides go through the same code. This does NOT touch the
accent-folding decision, which is still open as branch task 21.
- **hard_break** — the other half of #11's task 23 — was already fixed
by the typography branch. Verified against origin/main rather than
taken from the thread's own note.
## The checks that were not looking
The 320px failure reached main past a Fable whole-branch review and a
22-check browser drive, because nothing in the repo looked at that
width. The drive now sets a real 320px viewport, walks both pages, and
names the innermost element whose box crosses the edge.
The jump-to palette's checks are folded in from #14's scratch driver
(branch task 22), and folding them found that FOUR checks on this work
could not fail:
- "focus is no longer held in the field" passed with the focus-return
call deleted, because closing the palette hides the input either
way;
- "Up moves back to the first" passed with the arrow handler gutted,
because the first option is selected when nothing moves;
- "a query matching nothing says so" passed when the search was
BROKEN, because it only asked whether some text appeared and the
status element carries the failure copy too;
- the new 320px checks never asserted the viewport WAS 320, so losing
a viewport meta on a module upgrade would have them pass while
measuring a 980px page.
All four are rewritten and each confirmed red against a deliberate
break. Groups run through group() so an aborted group is a named
failure rather than a silently smaller total — proven necessary when a
break ended a run at 31 checks instead of 44 with the reflow checks
absent from the output rather than reported.
Drive: 22 checks to 44.
## Not done, deliberately
The rest of #14's task 24 — a "more matched" signal on the 50-result cap
and a "sign in again" message for an expired session — each need a
user-visible string, and every one here is a catalog key across twelve
locales. They wait for the copy review with the strings already queued
behind it.
Reviewed as a single diff by Fable per AGENTS.md, which found the
incomplete Markdown fix and two of the four blind checks above. Full CI
gate green; browser drive 44/44.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WxucgadpWLE9XNWvfHBsnb
7 files changed,
+781
−30
fixtures/browser-drive.mjs+443 −1go.mod+1 −1internal/docs/search.go+19 −1internal/docs/search_test.go+47 −0internal/docs/views/list.html+63 −10internal/export/markdown.go+65 −4internal/export/markdown_test.go+143 −13
CI passed — run details
Intent
Attributed to this commit: 96.9 g CO₂e (9147425 tokens). This is an attribution, not a measurement — usage counted from this commit's own marker forward to the next one on land-triage, never backward from it. A turn that produced more than one commit books all of its tokens to the last one, and a concurrent session on this branch can add to the same figure. Some of it was priced using the shared default rate, because no model-specific factor row exists yet for at least one model involved. The Carbon view has the repo total, which is never derived from this attribution.
diff --git a/fixtures/browser-drive.mjs b/fixtures/browser-drive.mjs| index f9d1cf3..0c107e7 100644 |
| --- a/fixtures/browser-drive.mjs |
| +++ b/fixtures/browser-drive.mjs |
| @@ -97,6 +97,35 @@ async function editorHTML() { |
| return evaluate(`document.querySelector('[docs-editor]').innerHTML`) |
| } |
| +// pressKey dispatches one key the way a keyboard does. |
| +// |
| +// rawKeyDown rather than keyDown for the non-printing keys: a keyDown |
| +// carrying no text is what CDP turns into a char event, and a stray |
| +// char is how an Escape ends up inserting nothing visible while the |
| +// handler that should have run never did. Printing keys pass `text` |
| +// and get the char event they need. |
| +// |
| +// modifiers is CDP's bitfield — 2 is Control, which is the one the |
| +// palette is bound to. |
| +async function pressKey(key, { code, vk = 0, modifiers = 0, text } = {}) { |
| + const base = { key, code: code || key, windowsVirtualKeyCode: vk, modifiers } |
| + await send('Input.dispatchKeyEvent', { type: text ? 'keyDown' : 'rawKeyDown', ...base, ...(text ? { text } : {}) }) |
| + await send('Input.dispatchKeyEvent', { type: 'keyUp', ...base }) |
| +} |
| + |
| +// typeText types a string one character at a time, with a real gap |
| +// between them. The palette debounces its query, so a burst delivered |
| +// instantly is not the input it was written for. |
| +async function typeText(text) { |
| + for (const ch of text) { |
| + await send('Input.dispatchKeyEvent', { type: 'keyDown', text: ch, key: ch }) |
| + await send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch }) |
| + await sleep(20) |
| + } |
| +} |
| + |
| +const CONTROL = 2 |
| + |
| // The editor writes through the CRDT and re-renders from it, so a |
| // change is not in the DOM the instant a command returns. settle waits |
| // for a condition rather than for a fixed delay, which is what keeps |
| @@ -168,6 +197,21 @@ function check(name, ok, detail) { |
| console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok || !detail ? '' : ' — ' + detail}`) |
| } |
| +// group runs a set of checks so that one throwing does not delete the |
| +// ones that come after it. |
| +// |
| +// Without this, a group that dies partway through simply stops, the |
| +// later groups never run, and the summary line reports a smaller total |
| +// that looks like a smaller suite rather than like missing coverage. |
| +// The abort becomes its own named failure instead. |
| +async function group(name, fn) { |
| + try { |
| + await fn() |
| + } catch (err) { |
| + check(`${name} ran to completion`, false, err.message) |
| + } |
| +} |
| + |
| // caretInfo reports where the caret is, read from the DOM selection |
| // rather than from ProseMirror's own state. |
| // |
| @@ -359,11 +403,409 @@ async function run() { |
| check('undo reverses the last change', false, err.message) |
| } |
| - await caretChecks() |
| + // Each of these is wrapped so that a failure inside one cannot take |
| + // the checks after it down with it. Proven necessary rather than |
| + // assumed: breaking the palette's Control-K binding threw on the |
| + // first null it produced, and the run ended at 31 checks instead of |
| + // 44 — with the 320px reflow checks never reached and simply absent |
| + // from the output rather than reported as anything. A check that |
| + // silently removes other checks when it fails is the same "cannot |
| + // tell no from I can't see it" shape this file exists to avoid, so |
| + // an aborted group is now itself a failed check that names the group. |
| + // |
| + // NOT yet true of the toolbar section above, which is inline in this |
| + // function and throws deliberately in two places ("could not select |
| + // a word to format"). Those aborts are correct in themselves — there |
| + // is no point continuing without a selection — but they still cost |
| + // every group below, and the fix is to extract that section the way |
| + // these three are extracted. Left as a follow-up rather than done |
| + // here, because moving a hundred lines of working checks at the same |
| + // time as fixing them is how a harness stops being trustworthy. |
| + await group('the caret-placement checks', caretChecks) |
| + await group('the jump-to palette checks', paletteChecks) |
| + await group('the 320px reflow checks', reflowChecks) |
| check('the page reported no JavaScript errors', pageProblems.length === 0, pageProblems.join(' | ')) |
| } |
| +// The jump-to palette (Control-K), and the plain search URL under it. |
| +// |
| +// These are the seventeen checks discussion #14 ran and could not |
| +// commit: the harness they belonged in was on another branch at the |
| +// time, and shipping a second harness to avoid waiting would have been |
| +// worse than waiting. Both branches have landed, so this is that fold — |
| +// branch task 22. What protected the palette's keyboard contract until |
| +// now was one person having run a scratch script once, which is exactly |
| +// what this repo does not count as a gate. |
| +// |
| +// Two things are different from the scratch version, both because a |
| +// check that depends on the fixture data is a check that fails for |
| +// reasons that have nothing to do with the code: |
| +// |
| +// - The queries are DERIVED from the titles actually on the list, |
| +// not typed in as "s" and "waterworks". Those worked against the |
| +// six seeded documents and would go red the day somebody edits |
| +// seeddata — reporting "the palette is broken" when it is not, |
| +// which is the failure this file's own header warns about. |
| +// - The single-result case queries a whole title, so the exact-match |
| +// rank guarantees which row lands first. Asserting on "the first |
| +// result" after a query that merely matches something is asserting |
| +// on the ranker's tie-break by accident. |
| +// |
| +// It runs before reflowChecks because that one leaves a phone-sized |
| +// viewport behind, and a 320px palette is a different check from this |
| +// one. |
| +async function paletteChecks() { |
| + // Where the palette matters most: inside a document, which is where |
| + // the run() above has already left us. A shortcut that works on the |
| + // list and not in the editor is the case worth catching, because the |
| + // editor is the page with a keymap of its own to collide with. |
| + await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL }) |
| + await sleep(400) |
| + check('Control-K opens the palette inside a document', await evaluate(`!document.querySelector('[docs-jumpto]').hidden`)) |
| + await pressKey('Escape', { vk: 27 }) |
| + await sleep(300) |
| + |
| + // Everything else from the list. |
| + await send('Page.navigate', { url: base + '/' }) |
| + await sleep(1500) |
| + |
| + // The titles this instance actually has, with the hrefs, so every |
| + // query below is derived rather than assumed. |
| + const docs = await evaluate(`(() => { |
| + return Array.from(document.querySelectorAll('a[href^="/d/"]')).map(a => ({ |
| + href: a.getAttribute('href'), |
| + title: a.textContent.trim(), |
| + })).filter(d => d.title.length > 0) |
| + })()`) |
| + if (!docs || docs.length === 0) throw new Error('the document list has no titles to search for') |
| + |
| + // A query that matches more than one, found rather than guessed: the |
| + // shortest single letter contained in at least two titles. The arrow |
| + // keys need two options to move between, and "there are two" is a |
| + // property of the data that this asks about instead of assuming. |
| + const multi = await evaluate(`(() => { |
| + const titles = ${JSON.stringify(docs.map((d) => d.title.toLowerCase()))} |
| + for (const ch of 'etaoinsrhldcumfpgwybvkxjqz') { |
| + if (titles.filter(t => t.includes(ch)).length >= 2) return ch |
| + } |
| + return null |
| + })()`) |
| + |
| + // --- The no-JS path. Search is a plain page at a plain URL, and |
| + // that is what lets the palette be an addition rather than the only |
| + // way in. Asserted with the palette's own bundle irrelevant. |
| + const one = docs[0] |
| + const others = docs.slice(1).map((d) => d.title) |
| + await send('Page.navigate', { url: base + '/?q=' + encodeURIComponent(one.title) }) |
| + await sleep(1200) |
| + const searched = await evaluate(`document.body.innerText`) |
| + check('a search URL renders the matching document', searched.includes(one.title), searched.slice(0, 160)) |
| + // Only meaningful if some other title would have shown on an |
| + // unfiltered list — with one document there is nothing to hide, and |
| + // a check that cannot fail is not a check. |
| + const hidden = others.filter((t) => !searched.includes(t)) |
| + check( |
| + 'and hides the ones that do not match', |
| + others.length === 0 || hidden.length > 0, |
| + `none of the other ${others.length} titles were filtered out`, |
| + ) |
| + |
| + await send('Page.navigate', { url: base + '/' }) |
| + await sleep(1500) |
| + |
| + check( |
| + 'the palette is on the page but hidden', |
| + await evaluate(`(()=>{const e=document.querySelector('[docs-jumpto]');return !!e && e.hidden})()`), |
| + ) |
| + |
| + await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL }) |
| + await sleep(400) |
| + check('Control-K opens the palette from the list', await evaluate(`!document.querySelector('[docs-jumpto]').hidden`)) |
| + check( |
| + 'and focus lands in the field', |
| + await evaluate(`document.activeElement === document.querySelector('[docs-jumpto-input]')`), |
| + ) |
| + check( |
| + 'aria-expanded says it is open', |
| + (await evaluate(`document.querySelector('[docs-jumpto-input]').getAttribute('aria-expanded')`)) === 'true', |
| + ) |
| + |
| + let options = 0 |
| + if (multi) { |
| + await typeText(multi) |
| + try { |
| + options = await settleOn( |
| + 'results', |
| + () => evaluate(`document.querySelectorAll('[docs-jumpto-list] li').length`), |
| + (n) => n > 0, |
| + ) |
| + check('typing produces results', true) |
| + } catch (err) { |
| + check('typing produces results', false, err.message) |
| + } |
| + } else { |
| + check('typing produces results', false, 'no letter is shared by two titles — cannot form a query') |
| + } |
| + |
| + // Read through a null-safe probe rather than dereferencing the first |
| + // <li> directly. With no results that dereference throws, and a throw |
| + // here used to end the whole run — so the failure of one check |
| + // deleted every check after it instead of reporting itself. |
| + const firstOption = await evaluate(`(() => { |
| + const li = document.querySelector('[docs-jumpto-list] li') |
| + const input = document.querySelector('[docs-jumpto-input]') |
| + return { |
| + exists: !!li, |
| + selected: li ? li.getAttribute('aria-selected') : null, |
| + id: li ? li.id : null, |
| + activeDescendant: input ? input.getAttribute('aria-activedescendant') : null, |
| + } |
| + })()`) |
| + check( |
| + 'the first result is the active option', |
| + firstOption.selected === 'true', |
| + firstOption.exists ? `aria-selected is ${firstOption.selected}` : 'there are no results at all', |
| + ) |
| + check( |
| + 'aria-activedescendant names the active option', |
| + firstOption.exists && firstOption.activeDescendant === firstOption.id, |
| + `aria-activedescendant is ${firstOption.activeDescendant}, the first option's id is ${firstOption.id}`, |
| + ) |
| + |
| + // Which option is selected, as an index — one probe, so a movement |
| + // is asserted as "it moved from here to there" rather than as two |
| + // separate facts about two separate elements. |
| + const selectedIndex = () => |
| + evaluate( |
| + `Array.from(document.querySelectorAll('[docs-jumpto-list] li')).findIndex(l => l.getAttribute('aria-selected') === 'true')`, |
| + ) |
| + |
| + if (options > 1) { |
| + await pressKey('ArrowDown', { vk: 40 }) |
| + await sleep(250) |
| + const afterDown = await selectedIndex() |
| + check('Down moves to the second result', afterDown === 1, `the selected option is ${afterDown}`) |
| + |
| + await pressKey('ArrowUp', { vk: 38 }) |
| + await sleep(250) |
| + const afterUp = await selectedIndex() |
| + // Guarded on Down having actually moved. "The first option is |
| + // selected" is true both when Up walked back to it and when |
| + // nothing moved at all, so on its own this check passes against an |
| + // arrow-key handler that does nothing — verified: breaking move() |
| + // turned Down red and left this one green. It is only evidence |
| + // when there was somewhere to come back FROM. |
| + check( |
| + 'Up moves back to the first', |
| + afterDown === 1 && afterUp === 0, |
| + afterDown !== 1 |
| + ? `Down never moved (selected ${afterDown}), so returning to 0 proves nothing` |
| + : `the selected option is ${afterUp}`, |
| + ) |
| + } else { |
| + check('Down moves to the second result', false, `only ${options} result(s) to move through`) |
| + check('Up moves back to the first', false, `only ${options} result(s) to move through`) |
| + } |
| + |
| + // Escape closes and hands focus back. Tab is deliberately NOT a |
| + // cycle here — the panel holds two focusable things, and a general |
| + // focus trap is where a palette usually strands somebody. |
| + await pressKey('Escape', { vk: 27 }) |
| + await sleep(400) |
| + check('Escape closes the palette', await evaluate(`document.querySelector('[docs-jumpto]').hidden`)) |
| + |
| + // Focus returns to WHERE IT CAME FROM, asserted by naming that |
| + // element rather than by observing that it is no longer in the |
| + // field. |
| + // |
| + // "activeElement is not the palette input" was the obvious way to |
| + // write this and it is worthless: closing the palette hides the |
| + // input, so focus leaves it whether or not anything put focus back, |
| + // and the check passes either way. Verified — deleting the |
| + // returnFocusTo.focus() call left it green at 44/44, which is a |
| + // check that cannot tell "no" from "I can't see it", and this file |
| + // exists to not do that. |
| + // |
| + // So focus a known element first, open from there, and assert that |
| + // Escape brings focus back to that exact element. A keyboard user |
| + // who opens the palette from the search field and dismisses it |
| + // should be back in the search field, not at the top of the page. |
| + const returned = await evaluate(`(() => { |
| + const f = document.querySelector('[docs-search-input]') |
| + if (!f) return null |
| + f.focus() |
| + return document.activeElement === f |
| + })()`) |
| + if (returned) { |
| + await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL }) |
| + await sleep(400) |
| + await pressKey('Escape', { vk: 27 }) |
| + await sleep(400) |
| + check( |
| + 'and focus goes back to whatever opened it', |
| + await evaluate(`document.activeElement === document.querySelector('[docs-search-input]')`), |
| + `focus is on ${await evaluate(`document.activeElement ? document.activeElement.tagName + (document.activeElement.className || '') : 'nothing'`)}`, |
| + ) |
| + } else { |
| + check('and focus goes back to whatever opened it', false, 'no [docs-search-input] to open the palette from') |
| + } |
| + |
| + // A query matching nothing says so rather than showing an empty box. |
| + // |
| + // Compared against the status element's own data-empty, NOT merely |
| + // "some text appeared". The palette writes two different strings |
| + // into that element — data-empty when nothing matched, data-failed |
| + // when the fetch threw — so "text is non-empty" passes just as well |
| + // when the search is BROKEN and the person is being told so. That is |
| + // the wrong answer under a check whose name claims to be about the |
| + // empty case. |
| + await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL }) |
| + await sleep(300) |
| + await typeText('zzzznothingmatchesthis') |
| + try { |
| + const said = await settleOn( |
| + 'the empty-result message', |
| + () => evaluate(`(()=>{const s=document.querySelector('[docs-jumpto-status]');return s.hidden?'':s.textContent})()`), |
| + (t) => t.length > 0, |
| + ) |
| + const expected = await evaluate( |
| + `document.querySelector('[docs-jumpto-status]').getAttribute('data-empty')`, |
| + ) |
| + check( |
| + 'a query matching nothing says so', |
| + said.trim() === (expected || '').trim(), |
| + `it says ${JSON.stringify(said)}; the empty-result copy is ${JSON.stringify(expected)}`, |
| + ) |
| + } catch (err) { |
| + check('a query matching nothing says so', false, err.message) |
| + } |
| + |
| + // Closing on that message and reopening must not still be saying it. |
| + // |
| + // This one is a regression check rather than a contract check: the |
| + // Fable review of #14 found the palette reopening over an EMPTY query |
| + // still showing "No documents match", and because the status is a |
| + // live region it announced the stale answer again to a screen reader |
| + // — an answer to a question nobody had asked yet. |
| + await pressKey('Escape', { vk: 27 }) |
| + await sleep(300) |
| + await pressKey('k', { code: 'KeyK', vk: 75, modifiers: CONTROL }) |
| + await sleep(400) |
| + const stale = await evaluate( |
| + `(()=>{const s=document.querySelector('[docs-jumpto-status]');return s.hidden?'':s.textContent.trim()})()`, |
| + ) |
| + check('reopening it does not repeat the last empty-result message', stale === '', `it still says ${JSON.stringify(stale)}`) |
| + check( |
| + 'and it reopens with an empty field', |
| + (await evaluate(`document.querySelector('[docs-jumpto-input]').value`)) === '', |
| + ) |
| + |
| + // Enter opens the chosen document. Queried by a whole title, so the |
| + // exact-match rank decides which row is first rather than the |
| + // tie-break happening to. |
| + await typeText(one.title) |
| + try { |
| + await settleOn( |
| + 'a result for a whole title', |
| + () => evaluate(`document.querySelectorAll('[docs-jumpto-list] li').length`), |
| + (n) => n > 0, |
| + ) |
| + const target = await evaluate( |
| + `(()=>{const l=document.querySelector('[docs-jumpto-list] li');return l?l.dataset.href:null})()`, |
| + ) |
| + check('the exact title ranks first', target === one.href, `first result was ${target}, wanted ${one.href}`) |
| + await pressKey('Enter', { vk: 13 }) |
| + await sleep(2000) |
| + const url = await evaluate(`location.pathname`) |
| + check('Enter opens the chosen document', url === target, `went to ${url}, wanted ${target}`) |
| + } catch (err) { |
| + check('the exact title ranks first', false, err.message) |
| + check('Enter opens the chosen document', false, err.message) |
| + } |
| +} |
| + |
| +// 320px reflow — WCAG 2.2 SC 1.4.10, which AGENTS.md takes with no |
| +// exemptions. |
| +// |
| +// Last, and it changes the viewport, so nothing above it may depend on |
| +// the window size afterwards. The criterion is binary: six pixels of |
| +// horizontal scroll fails it exactly as a hundred would, and six is |
| +// what the formatting bar shipped with — three selects in a group that |
| +// could not break inside itself, ending at x=326 in a 320px viewport. |
| +// It reached main, because nothing here was looking. |
| +// |
| +// The check names the offending element rather than reporting a |
| +// number, because "something overflows" sends the next person hunting |
| +// through a stylesheet and "input[docs-search-input] ends at 356" does |
| +// not. |
| +// |
| +// Both pages, not just the document. The list page's own overflow — |
| +// 36px, from a search form that claimed a fixed field width inside a |
| +// row it had told not to wrap — was found only because this walks |
| +// every page rather than the one that was being worked on. |
| +async function reflowChecks() { |
| + await send('Emulation.setDeviceMetricsOverride', { |
| + width: 320, height: 720, deviceScaleFactor: 1, mobile: true, |
| + }) |
| + try { |
| + for (const [name, path] of [['the document list', '/'], ['a document', null]]) { |
| + if (path) { |
| + await send('Page.navigate', { url: base + path }) |
| + await sleep(1500) |
| + } else { |
| + const href = await evaluate( |
| + `(()=>{const a=document.querySelector('a[href^="/d/"]');return a?a.getAttribute('href'):null})()`, |
| + ) |
| + await send('Page.navigate', { url: base + href }) |
| + await sleep(2500) |
| + } |
| + const found = await evaluate(`(() => { |
| + const vw = document.documentElement.clientWidth |
| + const over = [] |
| + for (const el of document.querySelectorAll('*')) { |
| + const r = el.getBoundingClientRect() |
| + if (r.width === 0 && r.height === 0) continue |
| + if (r.right > vw + 0.5 || r.left < -0.5) { |
| + let depth = 0 |
| + for (let n = el; n.parentElement; n = n.parentElement) depth++ |
| + over.push({ |
| + d: depth, |
| + what: el.tagName.toLowerCase() + |
| + (el.getAttributeNames().filter(a => a.startsWith('docs-')).map(a => '[' + a + ']').join('') || ''), |
| + right: Math.round(r.right), |
| + }) |
| + } |
| + } |
| + over.sort((a, b) => b.d - a.d) |
| + return { scrollWidth: document.documentElement.scrollWidth, viewport: vw, worst: over.slice(0, 3) } |
| + })()`) |
| + // The viewport is asserted to BE 320, not merely used as the |
| + // yardstick. Everything here measures against |
| + // documentElement.clientWidth, and that reads 320 only because |
| + // rastrillo's vendored topbar layout emits |
| + // <meta name="viewport" content="width=device-width"> — nothing |
| + // in this app does. Lose that meta on a module upgrade and |
| + // Chrome's mobile emulation lays out at 980px, clientWidth reads |
| + // 980, and both of these checks pass while measuring a |
| + // desktop-width page: a green result that has stopped looking at |
| + // the thing it is named for. |
| + check( |
| + `${name} does not scroll sideways at 320px`, |
| + found.viewport === 320 && found.scrollWidth <= found.viewport && found.worst.length === 0, |
| + found.viewport !== 320 |
| + ? `the page laid out at ${found.viewport}px, not 320 — check the viewport meta tag; this check measured nothing` |
| + : `scrollWidth ${found.scrollWidth} vs viewport ${found.viewport}; ` + |
| + found.worst.map((o) => `${o.what} ends at ${o.right}`).join(', '), |
| + ) |
| + } |
| + } finally { |
| + // Always put the viewport back, even if a check threw — a later |
| + // reader running this by hand should not inherit a phone. |
| + await send('Emulation.clearDeviceMetricsOverride') |
| + } |
| +} |
| + |
| // The caret-placement checks. |
| // |
| // These cover fd1d21a (clicking the page margins places the caret, and |
diff --git a/go.mod b/go.mod| index 5c58304..4f54fbd 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -7,6 +7,7 @@ require ( |
| github.com/go-chi/chi/v5 v5.3.2 |
| github.com/google/uuid v1.6.0 |
| github.com/tetratelabs/wazero v1.12.0 |
| + golang.org/x/text v0.20.0 |
| gorm.io/gorm v1.31.2 |
| ) |
| @@ -19,7 +20,6 @@ require ( |
| github.com/ncruces/go-strftime v1.0.0 // indirect |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| golang.org/x/sys v0.46.0 // indirect |
| - golang.org/x/text v0.20.0 // indirect |
| gorm.io/plugin/dbresolver v1.6.2 // indirect |
| modernc.org/libc v1.74.1 // indirect |
| modernc.org/mathutil v1.7.1 // indirect |
diff --git a/internal/docs/search.go b/internal/docs/search.go| index b835b54..32fe3f5 100644 |
| --- a/internal/docs/search.go |
| +++ b/internal/docs/search.go |
| @@ -15,6 +15,7 @@ import ( |
| "unicode" |
| "github.com/carlosframework/rastrillo" |
| + "golang.org/x/text/unicode/norm" |
| ) |
| // maxSearchResults caps what one query returns. |
| @@ -69,11 +70,28 @@ func rankTitle(title, query string) searchRank { |
| // straight one. Deliberately NOT a full Unicode fold: "café" and "cafe" |
| // stay different, because guessing at accent equivalence across twelve |
| // locales (render.go's Locales) is a design and not a detail. |
| +// |
| +// It DOES normalise to NFC first, and that is a different question from |
| +// the folding above rather than a softening of it. Folding is about two |
| +// different strings a person might want treated as one. NFC is about |
| +// one string with two encodings — "café" as U+00E9, and "café" as "e" |
| +// followed by U+0301 — which nobody has ever wanted treated as two. |
| +// Without it a title typed on a machine that produces decomposed text |
| +// (macOS's filesystem, several IMEs) is unfindable from a machine that |
| +// produces precomposed text, and the person searching sees an empty |
| +// result for a title they are looking straight at. |
| +// |
| +// Applied here rather than at either call site so the title and the |
| +// query go through the same function — normalising only one side fixes |
| +// exactly one direction and looks fixed from whichever side you tested. |
| +// NFC and not NFD because it is the shorter form and the web's own |
| +// default; the choice does not matter as long as it is the same on both |
| +// sides, which sharing this function is what guarantees. |
| func normaliseForSearch(s string) string { |
| var b strings.Builder |
| b.Grow(len(s)) |
| lastSpace := true |
| - for _, r := range strings.ToLower(s) { |
| + for _, r := range strings.ToLower(norm.NFC.String(s)) { |
| switch { |
| case unicode.IsSpace(r) || r == '—' || r == '–' || r == '-' || r == '_': |
| if !lastSpace { |
diff --git a/internal/docs/search_test.go b/internal/docs/search_test.go| index 533baba..8eca7f2 100644 |
| --- a/internal/docs/search_test.go |
| +++ b/internal/docs/search_test.go |
| @@ -110,6 +110,53 @@ func TestSearchDoesNotFoldAccents(t *testing.T) { |
| } |
| } |
| +// The same text in its two Unicode encodings is the same text, and this |
| +// is a different question from the accent folding above — which is why |
| +// it gets its own test rather than a case in that one. |
| +// |
| +// TestSearchDoesNotFoldAccents is about "café" and "cafe": two |
| +// DIFFERENT strings, which a person may or may not want treated as one, |
| +// and Paul's call (branch task 21). This is about "café" and "café": |
| +// the SAME string, once as U+00E9 and once as "e" + U+0301, which |
| +// nobody has ever wanted treated as two. A macOS filesystem hands over |
| +// the decomposed form, several IMEs produce it, and a title typed on |
| +// one machine and searched for on another can differ by nothing else. |
| +// |
| +// Both directions, because normalising only the query or only the title |
| +// fixes exactly one of them and looks fixed from whichever side the |
| +// author happened to test. |
| +func TestSearchNormalisesUnicodeForm(t *testing.T) { |
| + app, err := NewForTest(t) |
| + if err != nil { |
| + t.Fatalf("NewForTest: %v", err) |
| + } |
| + ctx := context.Background() |
| + |
| + const precomposed = "Café notes" // café, one rune |
| + const decomposed = "Café notes" // café, e + combining acute |
| + if precomposed == decomposed { |
| + t.Fatal("the two forms are byte-identical — this test proves nothing") |
| + } |
| + |
| + owner := seedTitles(t, app, "writer@example.com", precomposed) |
| + if got, _ := app.Store().Search(ctx, owner, decomposed); len(got) != 1 { |
| + t.Errorf("decomposed query against a precomposed title matched %d, want 1", len(got)) |
| + } |
| + |
| + // And the other way round: a title stored decomposed, queried |
| + // precomposed. |
| + other := seedTitles(t, app, "second@example.com", decomposed) |
| + if got, _ := app.Store().Search(ctx, other, precomposed); len(got) != 1 { |
| + t.Errorf("precomposed query against a decomposed title matched %d, want 1", len(got)) |
| + } |
| + |
| + // Normalising must not have quietly folded the accent away as a |
| + // side effect — that is task 21's decision, not this change's. |
| + if got, _ := app.Store().Search(ctx, owner, "cafe notes"); len(got) != 0 { |
| + t.Errorf(`"cafe notes" matched %d, want 0 — NFC must not fold accents`, len(got)) |
| + } |
| +} |
| + |
| // "No query" is not "match nothing", and it is not "match everything" |
| // either. The caller decides what an empty query shows; Search says |
| // nothing. |
diff --git a/internal/docs/views/list.html b/internal/docs/views/list.html| index 5c997ae..929e38a 100644 |
| --- a/internal/docs/views/list.html |
| +++ b/internal/docs/views/list.html |
| @@ -112,8 +112,9 @@ |
| /* [docs-header-actions]: the header's one flex child opposite the |
| titles — [rst-page-header]'s space-between rule places it there |
| with no layout rule of its own beyond this; everything it holds |
| - (the create form, the sign-out form) lays out as one inline |
| - group inside it. */ |
| + (the search form and the create form — sign-out moved into the |
| + shell's account control, see the note at the top of this file) |
| + lays out as one inline group inside it. */ |
| [docs-header-actions] { |
| display: flex; |
| flex-wrap: wrap; |
| @@ -172,32 +173,84 @@ |
| max-inline-size: 14rem; |
| } |
| /* [docs-search-form] is the same inline row as the create form, with |
| - one difference: it does NOT wrap. The header now carries five |
| + one difference: it does NOT wrap. The header carries several |
| controls, and with wrapping allowed the search button dropped onto |
| a line of its own under its own field while the create form stayed |
| whole — which reads as two rows of unrelated chrome rather than as |
| - one field and its button. The whole group still wraps at |
| - [docs-header-actions] above, so a 320px viewport (§10.3's reflow |
| - width) puts each form on its own line intact rather than breaking |
| - one of them in half. */ |
| + one field and its button. |
| + |
| + Staying on one row is only safe because the FIELD shrinks, and |
| + that is the part this rule got wrong for a while. It previously |
| + carried a comment asserting that the group wrapping at |
| + [docs-header-actions] meant "a 320px viewport puts each form on |
| + its own line intact rather than breaking one of them in half". |
| + That was never measured and was wrong. The field held a fixed |
| + 11rem, its button holds a label that does not wrap, and |
| + max-inline-size: 100% resolves against a form whose own width is |
| + its content — so the form set a min-content floor of about 334px |
| + and simply stood wider than the 288px column. Measured on |
| + origin/main at 7507a33 with scripts/browser-drive.sh: the list |
| + page scrolled sideways at 320px, scrollWidth 356 against a 320 |
| + viewport, with this form's button the innermost offender at |
| + x=356. |
| + |
| + That is a WCAG 2.2 SC 1.4.10 failure, which AGENTS.md allows no |
| + exemption from, traded for a desktop layout nicety by a comment |
| + that sounded like it had been checked. |
| + |
| + Note what the floor did to its NEIGHBOUR. [docs-create-form] was |
| + measured over the edge too, at x=350, and it is correctly written |
| + — it shrinks exactly as it should. It overflowed because a |
| + wrapping flex container is at least as wide as its widest item's |
| + min-content, so this form's 334px floor widened every line in the |
| + group, including the one the create form had to itself. Fixing |
| + the field below fixes both; there was never a second bug. Worth |
| + knowing before someone "fixes" the create form as well. |
| + |
| + The fix is that the field takes what is left after the button |
| + rather than claiming a width: flex 1 1 with a small basis, and |
| + min-inline-size 0 to lift a flex item's automatic minimum, which |
| + is its content size and is what an <input> otherwise refuses to |
| + go below. The 11rem cap keeps the desktop header exactly as it |
| + was. */ |
| [docs-search-form] { |
| display: flex; |
| flex-wrap: nowrap; |
| align-items: center; |
| gap: var(--rst-sp-2); |
| + max-inline-size: 100%; |
| } |
| [docs-search-form] [rst-input] { |
| - inline-size: 11rem; |
| - max-inline-size: 100%; |
| + flex: 1 1 6rem; |
| + min-inline-size: 0; |
| + max-inline-size: 11rem; |
| } |
| /* The submit button is for a pointer and for a screen reader; a |
| keyboard user submits with Enter from the field, which a |
| single-input form does natively. It stays visible rather than |
| becoming an icon because "Search documents" is also the field's |
| accessible name and the two agreeing is worth more than the |
| - width. */ |
| + width. |
| + flex: 0 0 auto for the same reason: the whole shortfall comes out |
| + of the field, so the button keeps its whole label. It is the |
| + words that are load-bearing here, not the box. |
| + The locale caveat, stated as the sequence it actually is rather |
| + than as a single threshold. Because the field gives way first and |
| + the button never shrinks, a wider label eats the FIELD long |
| + before anything overflows: a ~200px label leaves about 80px of |
| + field, ~270px leaves a sliver, and only past ~280px — the 288px |
| + column less the gap — does anything cross the edge. So the first |
| + failure a wide catalog produces is a search box too narrow to |
| + read what you typed, which is a usability problem and not a |
| + 1.4.10 one; the overflow comes later. English is 131px and is the |
| + only catalog today. The fix when it arrives is a wrap allowance |
| + keyed to the label, not a smaller field. |
| + ([docs-create-form]'s own comment above states the same caveat as |
| + a bare "wider than about 270px overflows", which is the second |
| + half of this sequence and not the part that bites first.) */ |
| [docs-search-form] button { |
| white-space: nowrap; |
| + flex: 0 0 auto; |
| } |
| /* The way back out of a result page, under the results rather than |
| in the header: it is about the list being shown, not about the |
diff --git a/internal/export/markdown.go b/internal/export/markdown.go| index a3e2532..d89e7f8 100644 |
| --- a/internal/export/markdown.go |
| +++ b/internal/export/markdown.go |
| @@ -302,32 +302,93 @@ func codeSpan(s string) string { |
| // over-escaped file renders correctly and reads slightly noisily in |
| // source, while an under-escaped one renders as something the author |
| // did not write. |
| +// "The start of a line" is tracked rather than inferred from index 0, |
| +// and it means the first NON-SPACE on a line rather than the first |
| +// character. |
| +// |
| +// Two rounds of this were wrong, in the same direction, and both are |
| +// worth naming because the second looked like a fix. |
| +// |
| +// It first asked whether i == 0, which is the start of the RUN and not |
| +// the start of a line. A run can contain a literal newline — nothing |
| +// stops one: POST /d/{sqid}/updates accepts Yjs bytes that never met |
| +// ProseMirror's schema, and a paste can carry one — so everything |
| +// after a newline was treated as mid-sentence, and "#" became a |
| +// heading, "- " a bullet, "1." an ordered list, mid-paragraph. |
| +// |
| +// Tracking lineStart fixed exactly the unindented case and no more. |
| +// CommonMark lets a block marker carry up to THREE spaces of |
| +// indentation (§4.2 for ATX headings, §5.2 for lists) and all of them |
| +// interrupt a paragraph, so " # x" one space in is still a heading |
| +// while `i == lineStart` says it is mid-line. Caught in review, with |
| +// the seven inputs now in the test table. |
| +// |
| +// So the state is "have we seen anything but spaces on this line yet", |
| +// which is what atLineStart means below. Any run of spaces is treated |
| +// as still-at-line-start rather than counting to three: over-escaping |
| +// renders correctly and reads slightly noisily in source, which is the |
| +// trade this function's own header already chose. |
| +// |
| +// Two characters are escaped that a first reading would not expect: |
| +// |
| +// - '=' because a line of them is a setext heading, which underlines |
| +// the paragraph ABOVE it — so it silently promotes text that is |
| +// nowhere near it. ('-' is already escaped for the bullet case, |
| +// which covers setext H2 as a side effect.) |
| +// - '~' because "~~~" opens a code fence that interrupts a paragraph |
| +// and then swallows every following line until a closing fence. It |
| +// does not restyle a line, it eats the rest of the document. |
| +// |
| +// Both only at the start of a line, so "a = b" is left alone. |
| +// |
| +// KNOWN AND NOT FIXED HERE: four or more leading spaces after a |
| +// newline make an indented code block, and no amount of escaping |
| +// changes that — the block is made by the whitespace, not by any |
| +// character this function could put a backslash in front of. The text |
| +// survives verbatim and is rendered as code. Fixing it means altering |
| +// the author's own leading whitespace, which is a different decision |
| +// from escaping and not one to take in passing. |
| +// TestMarkdownIndentedRunLinesBecomeCodeBlocks records the current |
| +// behaviour so it stays a decision rather than becoming a discovery. |
| func escapeMarkdown(s string) string { |
| var b strings.Builder |
| b.Grow(len(s)) |
| + lineStart := 0 // byte index where the current line begins |
| + atLineStart := true // nothing but spaces seen on this line yet |
| for i, r := range s { |
| switch r { |
| + case '\n': |
| + b.WriteRune(r) |
| + lineStart = i + 1 |
| + atLineStart = true |
| + continue |
| + case ' ', '\t': |
| + // Indentation does not end the line's start. |
| + b.WriteRune(r) |
| + continue |
| case '\\', '`', '*', '_', '[', ']', '<', '>': |
| b.WriteByte('\\') |
| b.WriteRune(r) |
| - case '#', '+', '-': |
| + case '#', '+', '-', '=', '~': |
| // Only special at the start of a line. Inside a sentence |
| // they are ordinary punctuation, and escaping every hyphen |
| // in a document would be unreadable. |
| - if i == 0 { |
| + if atLineStart { |
| b.WriteByte('\\') |
| } |
| b.WriteRune(r) |
| case '.', ')': |
| // "1." and "1)" start an ordered list, but only after |
| - // digits at the start of a line. |
| - if i > 0 && startsWithDigits(s[:i]) { |
| + // digits at the start of a line — so the digits are counted |
| + // from the line's first non-space, not from the run. |
| + if !atLineStart && startsWithDigits(strings.TrimLeft(s[lineStart:i], " \t")) { |
| b.WriteByte('\\') |
| } |
| b.WriteRune(r) |
| default: |
| b.WriteRune(r) |
| } |
| + atLineStart = false |
| } |
| return b.String() |
| } |
diff --git a/internal/export/markdown_test.go b/internal/export/markdown_test.go| index eae167f..3e185d0 100644 |
| --- a/internal/export/markdown_test.go |
| +++ b/internal/export/markdown_test.go |
| @@ -1,6 +1,7 @@ |
| package export_test |
| import ( |
| + "encoding/json" |
| "strings" |
| "testing" |
| @@ -118,6 +119,72 @@ func TestMarkdownEscapesTextThatWouldBecomeMarkup(t *testing.T) { |
| // A hyphen mid-sentence is ordinary punctuation and must NOT |
| // be escaped — over-escaping every one of them is unreadable. |
| {"well-known", "well-known"}, |
| + |
| + // A LITERAL newline inside one text run starts a new line in |
| + // the output, and everything the cases above assert about the |
| + // start of a line has to hold there too. |
| + // |
| + // escapeMarkdown decided "start of a line" by asking whether |
| + // the index was 0 — true only for the start of the whole run — |
| + // so a "#" or a "- " or a "1." after an embedded newline was |
| + // written through unescaped and became a heading, a bullet or |
| + // an ordered list in the exported file. The document then says |
| + // something its author did not write, which is the one thing |
| + // this module's own rule says it must never do. |
| + // |
| + // Reachable without a hostile client: POST /d/{sqid}/updates |
| + // takes arbitrary Yjs bytes that never met ProseMirror's |
| + // schema, and a paste can carry one too. |
| + {"intro\n# not a heading", "intro\n\\# not a heading"}, |
| + {"intro\n- not a bullet", "intro\n\\- not a bullet"}, |
| + {"intro\n+ not a bullet", "intro\n\\+ not a bullet"}, |
| + {"intro\n1. not a list", "intro\n1\\. not a list"}, |
| + {"intro\n1) not a list", "intro\n1\\) not a list"}, |
| + |
| + // CommonMark allows a block marker up to THREE spaces of |
| + // indentation (§4.2 ATX headings, §5.2 lists), and all of them |
| + // interrupt a paragraph. So "at the start of a line" has to |
| + // mean "first non-space on a line", not "first character on a |
| + // line" — the first version of this fix escaped only the |
| + // latter, and every one of these went through untouched. |
| + {"intro\n # one space", "intro\n \\# one space"}, |
| + {"intro\n # three spaces", "intro\n \\# three spaces"}, |
| + {"intro\n - one space", "intro\n \\- one space"}, |
| + {"intro\n 1. one space", "intro\n 1\\. one space"}, |
| + |
| + // A setext heading underlines the line ABOVE it, so a run of |
| + // "=" on its own line silently promotes the preceding |
| + // paragraph to an H1. "-" was already escaped for the bullet |
| + // case, which covers setext H2 as a side effect. |
| + // |
| + // Only the FIRST character needs the backslash, and that is |
| + // the whole escape rather than a partial one: a setext |
| + // underline must be a line of nothing but "=", and a line |
| + // beginning with an escape is not that. Escaping all three |
| + // would be three backslashes to buy what one already bought. |
| + {"intro\n===", "intro\n\\==="}, |
| + |
| + // "~~~" opens a code fence that interrupts a paragraph and |
| + // then swallows every following line until a closing fence — |
| + // so this one does not merely restyle a line, it eats the rest |
| + // of the document. Same first-character-only reasoning: an |
| + // escaped tilde is not a fence opener. |
| + // |
| + // Escaped at the start of a line in any position, including |
| + // the start of the run, which was a hole even before newlines |
| + // were considered. |
| + {"intro\n~~~\nswallowed", "intro\n\\~~~\nswallowed"}, |
| + {"~~~ at the run start", "\\~~~ at the run start"}, |
| + |
| + // And the flip side: the same characters mid-line, after a |
| + // newline or not, are ordinary punctuation. A fix that escapes |
| + // its way out of the problem would make every hyphen and every |
| + // equals sign in every export unreadable, and would have |
| + // passed a test that only checked the bad cases. |
| + {"intro\nwell-known", "intro\nwell-known"}, |
| + {"intro\nversion 1.2", "intro\nversion 1.2"}, |
| + {"a = b and c ~ d", "a = b and c ~ d"}, |
| + {"intro\na = b", "intro\na = b"}, |
| } |
| for _, c := range cases { |
| js := `{"type":"doc","content":[{"type":"paragraph","content":[{"type":"text","text":` + jsonString(c.text) + `}]}]}` |
| @@ -229,21 +296,19 @@ func TestParseRejectsSomethingThatIsNotADocument(t *testing.T) { |
| // jsonString quotes s the way a JSON encoder would, for building the |
| // fixtures above. |
| +// |
| +// It asks the JSON encoder rather than reimplementing it. The hand- |
| +// rolled version handled only " and \, so a fixture containing a |
| +// literal newline produced invalid JSON and the test failed inside |
| +// Parse with "invalid character '\n' in string literal" — a fixture |
| +// bug wearing the costume of a parser bug, and one that made a control |
| +// character impossible to write a test for at all. |
| func jsonString(s string) string { |
| - var b strings.Builder |
| - b.WriteByte('"') |
| - for _, r := range s { |
| - switch r { |
| - case '"': |
| - b.WriteString(`\"`) |
| - case '\\': |
| - b.WriteString(`\\`) |
| - default: |
| - b.WriteRune(r) |
| - } |
| + b, err := json.Marshal(s) |
| + if err != nil { |
| + panic(err) // a string always marshals |
| } |
| - b.WriteByte('"') |
| - return b.String() |
| + return string(b) |
| } |
| // Shift+Enter's line break, in the export that has two spellings for |
| @@ -331,3 +396,68 @@ func TestMarkdownIndentsTheLineAfterAHardBreak(t *testing.T) { |
| t.Errorf("got:\n%q\nwant:\n%q", got, want) |
| } |
| } |
| + |
| +// A literal newline inside a list item or a blockquote must not walk |
| +// the rest of the line out of the block it is in. |
| +// |
| +// Same defect class as the escaping above and worth its own check, |
| +// because the fix for one does not imply the fix for the other: the |
| +// escaper decides what the characters MEAN, and these two paths decide |
| +// what prefix each line CARRIES. A newline that arrives without its |
| +// indent or its "> " leaves the second half of somebody's sentence |
| +// outside the list. |
| +func TestMarkdownKeepsALiteralNewlineInsideItsBlock(t *testing.T) { |
| + js := `{"type":"doc","content":[ |
| + {"type":"bullet_list","content":[ |
| + {"type":"list_item","content":[{"type":"paragraph","content":[ |
| + {"type":"text","text":"one\ntwo"} |
| + ]}]} |
| + ]}, |
| + {"type":"blockquote","content":[{"type":"paragraph","content":[ |
| + {"type":"text","text":"three\nfour"} |
| + ]}]} |
| + ]}` |
| + got := export.Markdown(mustParse(t, "", js)) |
| + want := strings.Join([]string{ |
| + "- one", |
| + " two", |
| + "", |
| + "> three", |
| + "> four", |
| + "", |
| + }, "\n") |
| + if got != want { |
| + t.Errorf("got:\n%q\nwant:\n%q", got, want) |
| + } |
| +} |
| + |
| +// Four or more leading spaces on a line make an indented code block, |
| +// and escaping cannot prevent it: the block is made by the whitespace, |
| +// not by any character a backslash could go in front of. |
| +// |
| +// So this records what currently happens rather than asserting what |
| +// should. The text survives verbatim — which is the module's actual |
| +// promise, never lose the words — but a reader sees it as code. The |
| +// fix, if it is ever wanted, means rewriting the author's own leading |
| +// whitespace, and that is a different decision from escaping, not one |
| +// to take in passing. |
| +// |
| +// Here so the next person to find it finds a decision rather than a |
| +// surprise, and so anyone who changes the behaviour has to change a |
| +// test that says why. |
| +func TestMarkdownIndentedRunLinesBecomeCodeBlocks(t *testing.T) { |
| + js := `{"type":"doc","content":[{"type":"paragraph","content":[ |
| + {"type":"text","text":"intro\n four spaces in"} |
| + ]}]}` |
| + got := strings.TrimSpace(export.Markdown(mustParse(t, "", js))) |
| + want := "intro\n four spaces in" |
| + if got != want { |
| + t.Errorf("got %q, want %q", got, want) |
| + } |
| + // The words are all still there, which is the guarantee that does |
| + // hold. Asserted separately so a change to the spacing still has |
| + // to keep this true. |
| + if !strings.Contains(got, "four spaces in") { |
| + t.Errorf("the text was lost: %q", got) |
| + } |
| +} |