3
0
mirror of https://github.com/larvit/adf-codec.git synced 2026-11-12 11:48:29 +01:00

Answer stability round 1: 0 🔴, 5 🟡 (0 misread); derive the entity tables from one map, drop a dead branch, pin two refusals' places

This commit is contained in:
2026-10-31 02:13:10 +01:00
committed by Lillem4n
parent f68e2365e9
commit b9991fff59
5 changed files with 25 additions and 27 deletions
+11 -14
View File
@@ -1,14 +1,5 @@
export type Spelling = 'attribute' | 'text'
const escapable = /[&<>"]/g
const escapes: ReadonlyMap<string, string> = new Map([
['"', '&quot;'],
['&', '&amp;'],
['<', '&lt;'],
['>', '&gt;'],
])
const writtenEntities = [...escapes.values()]
const entity = /&(?:amp|gt|lt|nbsp|quot);/g
const entityValues: ReadonlyMap<string, string> = new Map([
['&amp;', '&'],
['&gt;', '>'],
@@ -16,16 +7,22 @@ const entityValues: ReadonlyMap<string, string> = new Map([
['&nbsp;', '\u00a0'],
['&quot;', '"'],
])
const entityNames = [...entityValues.keys()].map((spelled) => spelled.slice(1, -1)).join('|')
const entity = new RegExp(`&(?:${entityNames});`, 'g')
// `adfToHtml` writes every character of `entityValues` as its entity but the no-break space.
const escapes: ReadonlyMap<string, string> = new Map([...entityValues].filter(([, character]) => character !== '\u00a0').map(([spelled, character]): [string, string] => [character, spelled]))
const escapable = new RegExp(`[${[...escapes.keys()].join('')}]`, 'g')
const writtenEntities = [...escapes.values()]
// What HTML text and attribute values cannot hold (WHATWG HTML §13.1.3): any control but a tab, line feed or form feed, a carriage return among them, since a parser reads it as a line feed; a noncharacter; and a lone surrogate, which UTF-8 cannot encode.
const unwritable = /(?![\t\n\f ])[\p{Cc}\p{Cs}\p{Noncharacter_Code_Point}]/u
const unwritables = new RegExp(unwritable.source, 'gu')
const strayAmpersand = '&(?!(?:amp|gt|lt|nbsp|quot);)'
// What reads wrongly in a value: `&` outside an entity, a character HTML cannot hold, and in text a `<` or `>`. An attribute value may hold `<` and `>` raw, as older browsers write it, and text may hold `"`.
const strayAmpersand = `&(?!(?:${entityNames});)`
// An attribute value may hold a raw `<` or `>`, as older browsers write them; text may hold a raw `"`.
const misspellings: Readonly<Record<Spelling, RegExp>> = {
attribute: new RegExp(`${strayAmpersand}|${unwritable.source}`, 'u'),
text: new RegExp(`${strayAmpersand}|[<>]|${unwritable.source}`, 'u'),
}
// What `adfToHtml` spells with an entity, a browser (WHATWG HTML §13.3) spells raw in text, or in an attribute; a no-break space it spells `&nbsp;` in both.
// For each spelling, the other way a browser writes what `adfToHtml` writes as the key (WHATWG HTML §13.3).
const browserSpellings: Readonly<Record<Spelling, ReadonlyMap<string, string>>> = {
attribute: new Map([['&gt;', '>'], ['&lt;', '<'], ['\u00a0', '&nbsp;']]),
text: new Map([['&quot;', '"'], ['\u00a0', '&nbsp;']]),
@@ -56,14 +53,14 @@ export function shownValue(value: string, lineFeed: string): string {
return escapeHtml(value.replace(unwritables, '')).replaceAll('\n', lineFeed)
}
// Where `html` reads as `shown` from `index`: `whole`, and the index after it, or where it first reads otherwise. `shown` is spelled as `adfToHtml` writes it, `html` as a browser may too.
// Whether `html` holds `shown` from `index` (`whole`), and the index where the match ends or first fails. `shown` is as `adfToHtml` writes it; `html` may spell it as a browser does.
export function readsAsShown(html: string, index: number, shown: string): { at: number; whole: boolean } {
if (html.startsWith(shown, index)) return { at: index + shown.length, whole: true }
let at = index
let spelling: Spelling = 'text'
for (let next = 0; next < shown.length; ) {
const token = writtenEntities.find((spelled) => shown.startsWith(spelled, next)) ?? shown.charAt(next)
// Only a tag holds a raw `<` or `>`, and only its attribute values an escape.
// A shown `<` opens a tag, whose attribute values may spell `<` and `>` raw; a shown `>` ends it.
if (token === '<') spelling = 'attribute'
if (token === '>') spelling = 'text'
const read = [token, browserSpellings[spelling].get(token)].find((spelled) => spelled !== undefined && html.startsWith(spelled, at))
+1 -1
View File
@@ -20,7 +20,7 @@ export function readCarry(html: string, tag: StartTag, element: 'div' | 'span',
const shown = `${carryContent(node.value, element)}</${element}>`
const read = readsAsShown(html, tag.end, shown)
if (read.whole) return success({ end: read.at, node: node.value })
return refused('unmappable-html', `a carry element shows exactly what adfToHtml writes for its node, then </${element}>; to change the node, edit its ADF and write the HTML again: ${foundAt(html, read.at)}`, html, read.at, place)
return refused('unmappable-html', `a carry element shows what adfToHtml writes for its node, as it or a browser spells it, then </${element}>; to change the node, edit its ADF and write the HTML again: ${foundAt(html, read.at)}`, html, read.at, place)
}
function heldNode(json: string, levels: number, html: string, jsonAt: number): Read<AdfNode> {
+6 -4
View File
@@ -327,14 +327,16 @@ test('refuses a tag of three million attributes, past what one pattern over them
const messages: readonly (readonly [string, string])[] = [
['<p>a<b>b</b></p>\n', '<b> as written here is no inline element of the dialect: found "<b>b</b></p>\\n"'],
['<p>a > b</p>\n', 'text escapes & as &amp;, < as &lt; and > as &gt;, and holds no character HTML cannot hold: found "> b</p>\\n"'],
['<p><a href="a"b">a</a></p>\n', '<a> holds its attributes as name="value", each value escaped as text is, then >: found "b\\">a</a></p>\\n"'],
['<p><a href="a"b">a</a></p>\n', '<a> holds its attributes as name="value", each value escaping & as &amp; and " as &quot;, then >: found "b\\">a</a></p>\\n"'],
['<p>&b</p>\n', 'text escapes & as &amp;, < as &lt; and > as &gt;, and holds no character HTML cannot hold: found "&b</p>\\n"'],
['<pre><code>a&b</code></pre>\n', 'text escapes & as &amp;, < as &lt; and > as &gt;, and holds no character HTML cannot hold: found "&b</code></pre>\\n"'],
['<p>a&nbsp</p>\n', 'text escapes & as &amp;, < as &lt; and > as &gt;, and holds no character HTML cannot hold: found "&nbsp</p>\\n"'],
['<pre><code>a</code></pre\n', 'a code block\'s text ends at </code></pre>: found "</code></pre\\n"'],
['<pre><code class="hljs language-js">x</code></pre>\n', '<code> as written here is no code element of the dialect, which writes <code> or <code class="language-…">: found "<code class=\\"hljs langua"…'],
['<ul>\n<li>a</ul>\n', 'an item\'s line ends at </li>, or at a line feed before the item\'s next block, and its text spells < as &lt;: found "</ul>\\n"'],
['<ul>\n<li>a<b>b</b></li>\n</ul>\n', '<b> as written here is no inline element of the dialect: found "<b>b</b></li>\\n</ul>\\n"'],
['<ul>\n<li>if a < b</li>\n</ul>\n', 'an item\'s line ends at </li>, or at a line feed before the item\'s next block, and its text spells < as &lt;: found "< b</li>\\n</ul>\\n"'],
['<ul>\n<li>see <a href="https://x.test/?a=1&b=2">x</a></li>\n</ul>\n', '<a> holds its attributes as name="value", each value escaped as text is, then >: found "&b=2\\">x</a></li>\\n</ul>\\n"'],
['<ul>\n<li>see <a href="https://x.test/?a=1&b=2">x</a></li>\n</ul>\n', '<a> holds its attributes as name="value", each value escaping & as &amp; and " as &quot;, then >: found "&b=2\\">x</a></li>\\n</ul>\\n"'],
['<ul>\n<li><p>a</p><strong>x</strong></li>\n</ul>\n', 'an item\'s line ends at </li>, or at a line feed before the item\'s next block: found "<strong>x</strong></li>\\n"…'],
['<ul>\n<li>Bolt<ul>\n<li>a</li>\n</ul></li>\n</ul>\n', 'an item\'s line ends at </li>, or at a line feed before the item\'s next block: found "<ul>\\n<li>a</li>\\n</ul></l"…'],
['<ul>\n<li>a<li>b</li>\n</ul>\n', 'an item\'s line ends at </li>, or at a line feed before the item\'s next block: found "<li>b</li>\\n</ul>\\n"'],
@@ -342,8 +344,8 @@ const messages: readonly (readonly [string, string])[] = [
['<p><strong><b>x</b></strong></p>\n', '<b> as written here is no mark element of the dialect, and a mark element holds text, or one other mark element around text: found "<b>x</b></strong></p>\\n"'],
['<p><strong><br>x</strong></p>\n', '<br> as written here is no mark element of the dialect, and a mark element holds text, or one other mark element around text: found "<br>x</strong></p>\\n"'],
['<p><em></em></p>\n', 'a mark element holds text, or one other mark element around text: found "</em></p>\\n"'],
['<p><strong><a href="https://x.test/?a=1&b=2">x</a></strong></p>\n', '<a> holds its attributes as name="value", each value escaped as text is, then >: found "&b=2\\">x</a></strong></p>"…'],
[`<p>a</p>\n${carry('div', panel, '<p>Check the <b>collation</b>.</p>')}\n`, 'a carry element shows exactly what adfToHtml writes for its node, then </div>; to change the node, edit its ADF and write the HTML again: found "<b>collation</b>.</p></d"…'],
['<p><strong><a href="https://x.test/?a=1&b=2">x</a></strong></p>\n', '<a> holds its attributes as name="value", each value escaping & as &amp; and " as &quot;, then >: found "&b=2\\">x</a></strong></p>"…'],
[`<p>a</p>\n${carry('div', panel, '<p>Check the <b>collation</b>.</p>')}\n`, 'a carry element shows what adfToHtml writes for its node, as it or a browser spells it, then </div>; to change the node, edit its ADF and write the HTML again: found "<b>collation</b>.</p></d"…'],
['<p><span>a</span></p>\n', 'a <span> of the dialect is a carry element, holding class="adf-carry" and then data-adf alone: found "<span>a</span></p>\\n"'],
['<div class="adf-carry" data-adf="{ &quot;type&quot;:&quot;x&quot;}"></div>\n', 'a carry element spells its node\'s JSON canonically: compact, keys sorted, each character HTML cannot hold spelled as a JSON escape: found "{ &quot;type&quot;:&quot"…'],
[`${documentCarry}\n<hr>\n`, 'a carry element holding the document stands alone, as the whole document: found "\\n<hr>\\n"'],
+4 -5
View File
@@ -51,7 +51,7 @@ const formRefusals: Readonly<Record<FirstParagraphForm, string>> = {
element: "an item's first paragraph holding an attribute stands as its <p> on the item's line, straight after <li>",
}
// Reads back exactly what `adfToHtml` writes.
// Reads back what `adfToHtml` writes, as it or a browser spells it.
export function htmlToAdf(html: string): Result<AdfDocument, ParseError> {
return positioned(
withinEngineLimits(() => readHtml(html)),
@@ -59,15 +59,14 @@ export function htmlToAdf(html: string): Result<AdfDocument, ParseError> {
)
}
// A form post ends each line in `\r\n`, which reads as the line feed `adfToHtml` writes; a refusal's position still counts the HTML as handed in.
// A form post ends each line in `\r\n`, which reads as the line feed `adfToHtml` writes.
function readHtml(html: string): Result<AdfDocument> {
if (typeof html !== 'string') return notAString(html, 'HTML')
const read = readLineFeeds(html.replaceAll('\r\n', '\n'))
if (read.ok || read.error.position === undefined) return read
return { error: { ...read.error, position: positionIn(html, read.error.position) }, ok: false }
return read.ok ? read : { error: { ...read.error, position: positionIn(html, read.error.position) }, ok: false }
}
function readLineFeeds(html: string): Result<AdfDocument> {
function readLineFeeds(html: string): Result<AdfDocument, ParseError> {
if (html === '') return success({ content: [], type: 'doc', version: 1 })
return readDocumentCarry(html) ?? readBlocks(html)
}
+3 -3
View File
@@ -7,7 +7,7 @@ export type Place = { index: number; parent: Place | undefined }
export type ReadNode = Result<{ end: number; node: AdfNode }, ParseError>
// A start tag as `adfToHtml` writes it, its attributes' values unescaped.
// A start tag as `adfToHtml` or a browser writes it, its attributes' values unescaped.
export type StartTag = { attributes: (readonly [string, string])[]; end: number; name: string; start: number }
// A tag whose name reads, its attributes or its `>` not: `stopped` is where reading them stopped.
@@ -34,7 +34,7 @@ export function misread(html: string, index: number, expected: string, unexpecte
const tag = scanStartTag(html, index)
if (tag === undefined) return { at: index, message: `${expected}: ${foundAt(html, index)}` }
if ('end' in tag) return { at: index, message: `<${tag.name}> as written here is ${unexpected}: ${foundAt(html, index)}` }
return { at: tag.stopped, message: `<${tag.name}> holds its attributes as name="value", each value escaped as text is, then >: ${foundAt(html, tag.stopped)}` }
return { at: tag.stopped, message: `<${tag.name}> holds its attributes as name="value", each value escaping & as &amp; and " as &quot;, then >: ${foundAt(html, tag.stopped)}` }
}
// What stands at `index`, for a refusal's message, a character no one sees spelled as its escape.
@@ -45,7 +45,7 @@ export function foundAt(html: string, index: number): string {
return `found ${spelled}${index + found.length < html.length ? '…' : ''}`
}
// The start of the same line in `html`, where the position was counted after its `\r\n` became line feeds.
// The position of the start of `line` in `html` as handed in, for a line counted after each `\r\n` became a line feed.
export function positionIn(html: string, { line }: SourcePosition): SourcePosition {
let offset = 0
for (let passed = 1; passed < line; passed += 1) offset = html.indexOf('\n', offset) + 1