diff --git a/CHANGELOG.md b/CHANGELOG.md
index d5cc88f..b484099 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,8 +15,10 @@
content, links included. Every URL is written as the ADF holds it,
`javascript:` included: sanitize HTML from an author you do not trust before showing it.
- `htmlToAdf` reads `adfToHtml`'s HTML back to the document it came from, a carry element from its
- `data-adf`. It refuses any other HTML, including an edit to what a carry element shows, with the
- codes README §The calls lists.
+ `data-adf`, also as a browser's `innerHTML` writes it (a raw `"` in text, ` `, a raw `<` or
+ `>` in an attribute value) and as a form post sends it (`\r\n` for each line feed). It refuses
+ any other HTML, including an edit to what a carry element shows, with the codes README §The calls
+ lists.
- **Breaking:** `losslessMarkdownToAdf` gives a `date`, `emoji`, `inlineCard`, `mention`, `status`
or `mediaInline` only the marks Atlassian's schema lets it hold: `**!adf:mention[@A]{id=…}**` now
reads as a plain mention. A link or directive mark around only nodes that cannot take it is
diff --git a/README.md b/README.md
index 2158364..09b91ae 100644
--- a/README.md
+++ b/README.md
@@ -540,8 +540,10 @@ content. No stylesheet ships. The complete dialect:
`htmlToAdf` reads exactly that HTML back. It reads a carry element's node from its `data-adf`, and
what the element shows must be what `adfToHtml` shows for that node: an edit there would be lost, so
-it is refused. Other HTML is refused as `unmappable-html`: another element, attribute or escape,
-such as ``, ` a < b && c > "d" \'e\'\tf\ng\fh < a "b" "c" d e a\nb b Check the collation. a\r a a a\r\r\nb a a b cd a b ${carry('span', said, shown)} ${carry('span', underlined, `${opener}a<b
` or `'`, and HTML missing its final line feed or ending a line in `\r\n`.
+it is refused. It also reads the HTML as a browser's `innerHTML` writes it back and as a form post
+sends it: a raw `"` in text, ` ` for a no-break space, a raw `<` or `>` in an attribute value,
+and `\r\n` for a line feed. Other HTML is refused as `unmappable-html`: another element, attribute or
+escape, such as ``, `
` or `'`, a lone `\r`, and HTML missing its final line feed.
A few causes, such as a `data-*` attribute its element does not take, are `unsupported-node-shape`
instead, and nesting past 500 levels is `unsupported-nesting-depth`. [Either
direction](#either-direction) lists every cause of both.
diff --git a/browser-tests/convert-corpus.js b/browser-tests/convert-corpus.js
index 6ca2490..7b9886d 100644
--- a/browser-tests/convert-corpus.js
+++ b/browser-tests/convert-corpus.js
@@ -2,14 +2,23 @@ try {
const { adfToHtml, adfToLosslessMarkdown, htmlToAdf, isAdfDocument, losslessMarkdownToAdf } = await import('/dist/index.js')
// WebDriver's JSON reads -0 back as 0, so a document crosses as JSON text with -0 tagged; run.js revives it.
const spelled = (result) => (result.ok ? { ok: true, value: JSON.stringify(result.value, (_, value) => (Object.is(value, -0) ? '\u0000-0' : value)) } : result)
+ // The page's own innerHTML is what a viewer/editor app posts back, so htmlToAdf reads what the browser writes.
+ const throughInnerHtml = (adf) => {
+ const html = adfToHtml(adf)
+ if (!html.ok) return { parsed: html }
+ const page = document.createElement('div')
+ page.innerHTML = html.value
+ return { parsed: spelled(htmlToAdf(page.innerHTML)) }
+ }
window.convertCorpus = (corpus) => ({
+ browserDocuments: corpus.browserDocuments.map(({ json, name }) => ({ name, ...throughInnerHtml(JSON.parse(json)) })),
errors: corpus.errors.map(({ markdown, name }) => ({ name, parsed: losslessMarkdownToAdf(markdown) })),
normalization: corpus.normalization.map(({ markdown, name }) => ({ name, parsed: spelled(losslessMarkdownToAdf(markdown)) })),
realPayloads: corpus.realPayloads.map(({ json, name }) => {
const adf = JSON.parse(json)
const emitted = adfToLosslessMarkdown(adf)
const html = adfToHtml(adf)
- return { emitted, html, htmlParsed: html.ok ? spelled(htmlToAdf(html.value)) : undefined, isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? spelled(losslessMarkdownToAdf(emitted.value)) : undefined }
+ return { emitted, html, htmlParsed: html.ok ? spelled(htmlToAdf(html.value)) : undefined, innerHtml: throughInnerHtml(adf), isDocument: isAdfDocument(adf), name, parsed: emitted.ok ? spelled(losslessMarkdownToAdf(emitted.value)) : undefined }
}),
roundTrip: corpus.roundTrip.map(({ json, markdown, name }) => {
const adf = JSON.parse(json)
diff --git a/browser-tests/run.js b/browser-tests/run.js
index 64bffa0..c6fc9cf 100644
--- a/browser-tests/run.js
+++ b/browser-tests/run.js
@@ -70,7 +70,25 @@ async function whenDriverAnswers() {
}
}
+// Text the browser's innerHTML spells other than adfToHtml does (WHATWG HTML §13.3): a raw ", , and in an attribute < and > escaped or not by version.
+const text = (value, ...marks) => (marks.length === 0 ? { text: value, type: 'text' } : { marks, text: value, type: 'text' })
+const browserDocuments = [
+ {
+ json: JSON.stringify({
+ content: [
+ { content: [text('Say "hi"\u00a0now & `, or that holds
a value outside its attribute's kind or not written as compact JSON, is `unsupported-node-shape`.
- A carry element's `data-adf` holding invalid JSON is `unmappable-html`. JSON that is no ADF node,
diff --git a/src/html/escaping.ts b/src/html/escaping.ts
index a39db0b..75e91c9 100644
--- a/src/html/escaping.ts
+++ b/src/html/escaping.ts
@@ -1,17 +1,35 @@
-const entities: ReadonlyMap
\n')), document({ content: [text('say "a" b')], type: 'codeBlock' }))
+ const link: AdfMark = { attrs: { href: 'https://x.test/?ac&d', title: 'a b' }, type: 'link' }
+ assert.deepEqual(read(htmlToAdf('\n')), document(paragraph(text('x', link))))
+ assert.deepEqual(read(htmlToAdf('\n')), document(paragraph(text('x', link))))
+ assert.deepEqual(read(htmlToAdf('say "a" b
\n')), document({ attrs: { style: 'a "b' }, type: 'rule' }))
+})
+
+test('reads a line ending of CRLF as a line feed wherever the dialect has one, and keeps a lone carriage return refused', () => {
+ const crlf = (html: string): string => html.replaceAll('\n', '\r\n')
+ assert.deepEqual(read(htmlToAdf(crlf('
\n
\n'))), document(paragraph(text('a\nb')), { type: 'rule' }, { content: [text('c\n d')], type: 'codeBlock' }))
+ assert.deepEqual(read(htmlToAdf(crlf('c\n d\n
\n'))), document({ content: [{ content: [item(paragraph(text('Bolt')), paragraph(text('b')))], type: 'bulletList' }], type: 'blockquote' }))
+ assert.deepEqual(read(htmlToAdf(crlf(`${carry('div', panel, '\n
\n
\n')), document({ attrs: { style: 'a\nb' }, type: 'rule' }))
+ for (const html of ['
A "quote" here
')}\r\n`)), document(block)) +}) + +test('refuses an edit to what a carry element shows even where a browser would spell the same text so', () => { + const said: AdfNode = { attrs: { id: ids.mention, text: 'Say "hi"' }, type: 'mention' } + const underlined: AdfNode = text('a', { type: 'underline' }, { attrs: { href: 'x', title: 'T"' }, type: 'link' }) + assert.equal(refusal(htmlToAdf(`${carry('span', said, 'Say "hi')}
\n`)), 'unmappable-html at /content/0/content/0 line 1 offset 0') + assert.equal(refusal(htmlToAdf(`${carry('span', said, 'Say "hi"')}
\n`)), 'unmappable-html at /content/0/content/0 line 1 offset 0') + assert.equal(refusal(htmlToAdf(`${carry('span', underlined, 'a')}
\n`)), 'unmappable-html at /content/0/content/0 line 1 offset 0') + assert.equal(refusal(htmlToAdf(`${carry('span', underlined, 'a')}
\n`)), 'read') +}) + test('reads lists, an item\'s first paragraph on its line and its other blocks a line each', () => { assert.deepEqual(read(htmlToAdf('ab
\n', ' as written here is no inline element of the dialect: found "b\\n"'], - ['a > b
\n', 'text escapes &, <, > and " as &, <, > and ", and holds no character HTML cannot hold: found "> b\\n"'], + ['a > b
\n', 'text escapes & as &, < as < and > as >, and holds no character HTML cannot hold: found "> b\\n"'], ['\n', ' holds its attributes as name="value", each value escaped as text is, then >: found "b\\">a\\n"'], - ['a \u00a0
\n', 'text escapes &, <, > and " as &, <, > and ", and holds no character HTML cannot hold: found " \\u00a0\\n"'], + ['a 
\n', 'text escapes & as &, < as < and > as >, and holds no character HTML cannot hold: found " \\n"'], ['a: found "x\n', ' as written here is no code element of the dialect, which writes or : found "\na\n', 'an item\'s line ends at , or at a line feed before the item\'s next block, and its text spells < as <: found "\\n"'],
diff --git a/src/html/parse/html-to-adf.ts b/src/html/parse/html-to-adf.ts
index 2b891b4..ad59120 100644
--- a/src/html/parse/html-to-adf.ts
+++ b/src/html/parse/html-to-adf.ts
@@ -7,7 +7,7 @@ import { carryContent } from '../carry-content.ts'
import { centeredImage, externalMedia } from '../../adf/plain-image.ts'
import { containerElements, headingElements, headingLevel, readListStart, type ContainerType } from '../block-elements.ts'
import { firstParagraphForm } from '../first-paragraph.ts'
-import { foundAt, misread, readStartTag, refused, startTagName, type Place, type ReadNode, type StartTag } from './source.ts'
+import { foundAt, misread, positionIn, readStartTag, refused, startTagName, type Place, type ReadNode, type StartTag } from './source.ts'
import { largestNesting } from '../../nesting.ts'
import { misspelledAt, unescapedValue } from '../escaping.ts'
import { misspelledText, readInline } from './inline.ts'
@@ -59,8 +59,15 @@ export function htmlToAdf(html: string): Result {
)
}
+// 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.
function readHtml(html: string): Result {
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 }
+}
+
+function readLineFeeds(html: string): Result {
if (html === '') return success({ content: [], type: 'doc', version: 1 })
return readDocumentCarry(html) ?? readBlocks(html)
}
@@ -191,8 +198,8 @@ function readCodeBlock(html: string, tag: StartTag, place: Place): Result(html: string, index: number, place: Place): Result {
- return refused('unmappable-html', `text escapes &, <, > and " as &, <, > and ", and holds no character HTML cannot hold: ${foundAt(html, index)}`, html, index, place)
+ return refused('unmappable-html', `text escapes & as &, < as < and > as >, and holds no character HTML cannot hold: ${foundAt(html, index)}`, html, index, place)
}
function markOf(tag: StartTag): AdfMark | undefined {
diff --git a/src/html/parse/source.ts b/src/html/parse/source.ts
index 5a8f8bf..20d7607 100644
--- a/src/html/parse/source.ts
+++ b/src/html/parse/source.ts
@@ -45,6 +45,13 @@ 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.
+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
+ return { line, offset }
+}
+
// At the start of the line `index` stands on.
export function refused(code: ConvertErrorCode, message: string, html: string, index: number, place: Place | undefined): Result {
return { error: { code, message, path: pathOf(place), position: lineStart(html, index) }, ok: false }
@@ -61,14 +68,14 @@ function scanStartTag(html: string, index: number): CutTag | StartTag | undefine
const match = attribute.exec(html)
if (match === null) return html.startsWith('>', at) ? { attributes, end: at + 1, name, start: index } : { name, stopped: at }
const [whole, key = '', spelled = ''] = match
- const value = unescapedValue(spelled)
- if (value === undefined) return { name, stopped: at + whole.length - 1 - spelled.length + misspelledAt(spelled) }
+ const value = unescapedValue(spelled, 'attribute')
+ if (value === undefined) return { name, stopped: at + whole.length - 1 - spelled.length + misspelledAt(spelled, 'attribute') }
attributes.push([key, value])
at += whole.length
}
}
-// Only a line feed ends a line: the reader refuses a carriage return wherever it stands.
+// Only a line feed ends a line: readHtml has made each \r\n one, and a lone carriage return is refused.
function lineStart(html: string, index: number): SourcePosition {
let line = 1
let offset = 0
diff --git a/todo.md b/todo.md
index 812c74f..df0e772 100644
--- a/todo.md
+++ b/todo.md
@@ -12,13 +12,12 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
-| 150 | 0.3.0 | | decision | **Read `adfToHtml`'s HTML in `htmlToAdf` as a browser serializes it too — a raw `"` in text, ` `, `\r\n` line endings — so a page's `innerHTML` or a form post reads back.** | 2 | 2 | 2 | 4 | 4 | 6 by 5 | 5.0 |
| 115 | 0.3.0 | | defect | **Build text holding no `border` mark in `losslessMarkdownToAdf`, and have `adfToLosslessMarkdown` carry text holding one.** | 2 | 1 | 2 | 3 | 3 | 4 by 3 | 4.6 |
| 91 | 0.3.0 | | | **Spell an emphasis once across the code spans, an emphasis or link once across the hard breaks, dates, emoji, inline cards, mentions and statuses between two nodes it marks, and an emphasis across inline media, in both writers.** | 3 | 2 | 2 | 5 | 5 | 6 by 6 | 3.6 |
| 133 | 0.3.0 | | decision | **Write mentions, emoji, dates, statuses and inline cards as semantic HTML in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 4 | 6 by 3 | 3.0 |
| 132 | 0.3.0 | | | **Write tables as `` in `adfToHtml` and read them back in `htmlToAdf`, in place of the carry element.** | 2 | 2 | 2 | 4 | 5 | 6 by 4 | 2.4 |
-| 73 | 0.3.0 | 91, 115, 132, 133, 150 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 |
-| 72 | 0.3.0 | 91, 115, 132, 133, 150 | decision | **Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.** | 1 | 2 | 1 | 2 | 1 | 3 by 1 | 2.5 |
+| 73 | 0.3.0 | 91, 115, 132, 133 | | **Announce the package where someone needing an ADF converter already reads: JRACLOUD-77436, the Atlassian developer community and Stack Overflow's ADF-to-markdown questions.** | 1 | 2 | 2 | 3 | 2 | 3 by 8 | 16.9 |
+| 72 | 0.3.0 | 91, 115, 132, 133 | decision | **Re-create `gitea.larvit.se/larvit/adf-codec` as a read-only pull mirror of GitHub, its issues and Actions off and its description pointing at GitHub.** | 1 | 2 | 1 | 2 | 1 | 3 by 1 | 2.5 |
| 153 | 0.4.0 | | question | **Decide whether `src/markdown/` root may import `emit/`, `parse/` or `portable/`, and hold the answer in `.oxlintrc.json` and §The source parts by ADF and format.** | 1 | 2 | 2 | 3 | 2 | code health by 3 | 26.2 |
| 156 | 0.4.0 | | | **Stop a leg's container when its script is interrupted, so Ctrl-C ends `ci.sh` or `mutation-tests/run.sh` at once.** | 1 | 2 | 1 | 2 | 1 | code health by 2 | 20.9 |
| 128 | 0.4.0 | | | **Pin the portable flavour's block reading and writing with tests: callout titles past one line, a link beside other marks in a title, the nesting limit of callouts and task lists, refusals inside tasks and their paths, and empty or non-paragraph tasks.** | 2 | 1 | 1 | 2 | 3 | code health by 3 | 19.6 |
@@ -69,16 +68,6 @@
## Details
-### 150. Read `adfToHtml`'s HTML in `htmlToAdf` as a browser serializes it too — a raw `"` in text, ` `, `\r\n` line endings — so a page's `innerHTML` or a form post reads back.
-
-`htmlToAdf` refuses all three today. A browser's `innerHTML` writes text's `"` raw and a no-break
-space as ` `, and a form post turns every line feed into `\r\n`, so a viewer/editor app
-sending its page back gets `unmappable-html`. `docs/decisions.md` §`htmlToAdf` also reads the
-dialect as a browser serializes it records the maintainer's yes. Take every difference from WHATWG
-HTML §Serializing HTML fragments, not only these three, and check them against a browser's own
-`innerHTML`. README §HTML and `spec/html.md` say `\r\n` is refused; update both. The comprehension
-panel found `misspelling` in `src/html/escaping.ts` hard to read: make it easier.
-
### 115. Build text holding no `border` mark in `losslessMarkdownToAdf`, and have `adfToLosslessMarkdown` carry text holding one.
`!adf:border[x]{color="#091e42" size=2}` builds text holding `border`, which