Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
dcd1474
Drop application-state <meta> values from front matter; first non-bla…
morisil Sep 28, 2026
dbb1fe0
Judge <meta> application state by parsing JSON; resolve title candida…
morisil Sep 28, 2026
c0aa3b6
Keep flat word/number arrays in <meta>; merge case-variant meta names…
morisil Sep 28, 2026
ed1727c
Read <meta> names ASCII case-insensitively everywhere; keep long pros…
morisil Sep 28, 2026
ce5d90b
Share head metadata rules between simplifyHtml and wrapInHtmlDocument…
morisil Sep 28, 2026
b4bef68
Align title judging between ensureFrontmatterTitle and wrapInHtmlDocu…
morisil Sep 28, 2026
124cac8
Skip blank <meta>/front matter values in head metadata; respell title…
morisil Sep 28, 2026
5582f95
Fix review findings on meta state detection and title derivation (#82)
morisil Sep 28, 2026
424475c
Add review-fix-loop script automating the code-review → fix → commit …
morisil Sep 28, 2026
5e0bf39
Fix Native-illegal test name and untracked-file check in review-fix-l…
morisil Sep 28, 2026
31a9187
Guard review-fix-loop against committed yarn.lock and a dirty tree at…
morisil Sep 29, 2026
3a1d5e3
Resolve duplicate head metadata keys per source format; keep front ma…
morisil Sep 29, 2026
2afddbc
Remove review-fix-loop script
morisil Sep 29, 2026
86d3305
Treat blank text before front matter alike; keep a usable title after…
morisil Sep 29, 2026
4772c9e
Trim edge NBSP from titles; place a derived title after a shadowing o…
morisil Sep 29, 2026
2ef66f0
Keep a single title entry; judge blanks by HTML whitespace (#82)
morisil Sep 29, 2026
66255c2
Share one front matter entry reader; keep nested titles (#82)
morisil Sep 29, 2026
2764ee0
Undo nested encodings of meta state; drop empty titles without an h1 …
morisil Sep 29, 2026
e3738c0
Keep nested titles beside a variant; share one decoding budget (#82)
morisil Sep 29, 2026
aaac6fa
Drop a frontmatter emptied of its titles; judge an unclosed one (#82)
morisil Sep 30, 2026
2f94205
Keep only the read title beside a nested one; cap parsed arrays (#82)
morisil Sep 30, 2026
958a487
Balance held output; treat invisible text as blank; keep URL lists (#82)
morisil Sep 30, 2026
47f22f8
Keep frontmatter detectable; tighten state and blank checks (#82)
morisil Sep 30, 2026
826f6a0
Keep verbatim titles and continuation lines; strict JSON state (#82)
morisil Sep 30, 2026
957692d
Read no value from front matter entries continued by verbatim lines (…
morisil Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -409,13 +409,16 @@ If the fix is bounded (one inline construct, one or two lines of lookahead), it'
The operator **requires** the DISPLAY annotation to be accurate (captured dumps); it is suppressed inside `<pre>`/`<code>`/`<textarea>`.
- `simplifyHtml` drops **technical-noise `<meta name>`** values from the frontmatter via a denylist (`isNoiseMetaName` in `SimplifyHtml.kt`: `viewport`, `generator`, `theme-color`, `robots`, `msapplication-*`, `apple-*`, `*-verification`, …).
A denylist (not an allowlist) is deliberate so unknown-but-useful names (`og:*`, `article:*`, custom) survive; extend the denylist as new noise names appear.
Value-shaped noise (single-page-app state, issue #82) belongs in `isApplicationStateMeta`, not in the name list.
`renderDumpMarkdown` (Gradle task, `RenderDumpMarkdown.kt` in jvmTest) runs the full `transformHtmlToMarkdown` pipeline over every dump to `build/renderedMarkdown/<name>.md` — the canonical way to regenerate the per-dump golden strings (`dumps/OpenjurTest`, `dumps/HackerNewsTest`) after a pipeline change.
- A `Regex` used with `matches()` in `commonMain` must be **explicitly anchored** (`^(?:a|b)$`) when it contains a top-level alternation: Kotlin/JS resolves `matches` through the leftmost `find`, so the first branch wins on a *prefix* and the whole-input check fails (`0x1F` matched `[-+]?[0-9]+|0x[0-9a-fA-F]+` as `0`; a full timestamp matched the date-only branch) — JVM backtracks across the branches and never shows it.
The YAML scalar typing in `markanywhere-yaml` hit exactly this: green on `jvmTest`, red on `jsBrowserTest`. Dev builds run the JS tests for `yaml`/`parse`/`render`/`html`, so a JVM-only test run is not enough evidence for a regex change.
- A `commonMain` `Regex` applied to untrusted input must not **repeat a group** (`(?:_?[0-9])*`, `(?::[0-9])+`): `java.util.regex` matches each repetition of a group one stack frame deeper, so a few thousand repetitions throw `StackOverflowError` on the JVM (an `Error`, which nothing catches — it takes the whole pipeline down).
Repeat a character class instead (`[0-9_]*`) and check what the group expressed by hand (`YamlScalarType.kt`).
- Facts of the HTML standard needed by more than one of `parse` / `render` / `html` — HTML whitespace (`isHtmlWhitespace` / `isHtmlBlank`), void and raw text elements, `Mark.classList` — live in the leaf module `markanywhere-html-spec` (depends only on `api`), never in `markanywhere-api` (which describes only the event model) and never as private copies.
Only spec facts go there, not project policy (e.g. which tags render as blocks in Markdown); the parser's `isFlankWhitespace` is deliberately a different set (CommonMark "Unicode whitespace", which includes NBSP).
- kotlinx `Json.parseToJsonElement` accepts a **bare word as a value** even with `isLenient = false` (`[[Wiki]]`, `{"a": tru}` parse; only keys must be quoted), so "it parses as JSON" is not evidence that a value is serialised data.
`ApplicationStateMeta.kt` checks the parsed tree with `isStrictJson` before judging a `<meta>` value as state — otherwise bracketed prose like `[foo, [bar]]` was dropped as a nested array.
- `MutableMap.putIfAbsent` is **JVM-only** — not in the common stdlib, so it can't be used in `commonMain`.
For first-wins ("put only if key absent") semantics across all KMP targets, prefer **`getOrPutIfMissing(key) { value }`** (Kotlin 2.4, `@ExperimentalStdlibApi`): it computes & stores the default *only when the key is genuinely absent*.
Do NOT reach for plain `getOrPut` as the `putIfAbsent` replacement — it (and its explicit alias `getOrPutIfNull`) also recompute when the stored value is **null**, conflating "missing" with "null" (the footgun [KEEP-0457](https://github.com/Kotlin/KEEP/blob/main/proposals/stdlib/KEEP-0457-alternative-behavior-for-map-getOrElse.md) fixes).
Expand Down Expand Up @@ -557,9 +560,11 @@ If the fix is bounded (one inline construct, one or two lines of lookahead), it'
Key typing is not value typing: go-yaml v2 decodes a **top-level** front matter key into a string but a nested one as `interface{}`, so `y:` must stay plain at the top level (it was once rewritten to `"y":`) and be quoted below it (`isTypedPlainKey`).
The writer's plain-key rule (`isIdentifierKey`) and the detector share their character rules in `YamlKeyLine.kt`, and `YamlWriterTest` "should write every key as a line the front matter detector accepts" pins the two — widen one without the other and every document whose *first* entry has such a key silently loses its front matter on re-parse.
**DIVERGENCE (no round-trip)**: a `frontmatter` whose root is a **sequence** (top-level `item`s) renders as `---\n- a\n…`, which line 2 rejects — Markdown reads `---` + `- a` as a thematic break + list, and no front matter consumer (Jekyll, Hugo, `wrapInHtmlDocument`) accepts a non-mapping — so the shape is pinned as such in `FrontMatterRoundTripTest` rather than widened into detection.
- `wrapInHtmlDocument` and `ensureFrontmatterTitle` read the front matter by **depth** (a direct child `entry` is depth 1 / 2 respectively, counted from the `frontmatter` mark): only top-level scalar entries become `<meta>`/`<title>`, only a top-level `entry key=title` counts as an existing title.
Neither contains any YAML parsing — that was the ~300-line `Frontmatter.kt` codec this representation replaced; do not reintroduce a text-level reader.
The two must agree on what a *usable* title is, because `wrapInHtmlDocument` skips a `type=null` entry and emits an empty `<title>` for a blank one: `ensureFrontmatterTitle` therefore treats a top-level `title` with blank text or `type=null` (a bare `title:` line) as missing and **replaces it in place** with the derived one (a `title` holding nested marks is left alone — replacing it would lose content), and it holds any blank text seen *before* the first `h1` so a synthesized `frontmatter` is the **first** event, the only position `wrapInHtmlDocument` reads it from.
- `wrapInHtmlDocument` and `ensureFrontmatterTitle` read the front matter through **one** reader, `FrontMatterEntryReader`, and must agree on which title entry is read and what a usable one is — hand-rolled copies of the reader in each operator drifted apart once and let a leading `title:` hide a later `Title: Real`, so the derived `h1` title displaced a real one.
Do not reintroduce a text-level YAML reader (the ~300-line `Frontmatter.kt` this replaced) or a private copy of the entry reader.
Duplicate keys deliberately resolve differently per direction (`simplifyHtml` first `<meta>` wins as in HTML, `wrapInHtmlDocument` later entry wins as in Psych/PyYAML): each side emits one entry per name, so neither sees the other's duplicates — "aligning" them once made `wrapInHtmlDocument` show a title no front matter reader shows.
`ensureFrontmatterTitle` never leaves duplicate title entries rather than reordering them for later-wins readers, because js-yaml (gray-matter: Eleventy, Astro, Gatsby) rejects a duplicate key and loses the whole front matter.
The one exception is a nested title entry (localized titles): it is content, so a usable variant beside it is kept as found rather than collapsing the two into one.

## Test conventions

Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,10 @@ Will print:

### Wrapping parsed Markdown in a complete HTML document

`wrapInHtmlDocument()` (in `markanywhere-html`) wraps the event stream in an `html`/`head`/`body` structure, populating the `head` from a leading front matter block: `title` becomes `<title>`, `lang` becomes the `<html lang>` attribute, and every other flat key becomes a `<meta name content>` — the exact inverse of `simplifyHtml`'s `<head>`-to-front-matter extraction, so the two round-trip.
`wrapInHtmlDocument()` (in `markanywhere-html`) wraps the event stream in an `html`/`head`/`body` structure, populating the `head` from a leading front matter block: `title` becomes `<title>`, `lang` becomes the `<html lang>` attribute, and every other flat key becomes a `<meta name content>` — the inverse of `simplifyHtml`'s `<head>`-to-front-matter extraction.
Keys are read ASCII case-insensitively, as HTML reads `<meta>` names: a `Title` key is the title.
Of duplicate keys the later non-blank one wins, as front matter readers (Jekyll, PyYAML) resolve them, except that the lowercase spelling beats a variant in another letter case — `simplifyHtml` instead keeps the first of duplicate `<meta>` names, as HTML does.
The two round-trip up to what they normalise or discard as carrying nothing for a reader: blank, nested and null values, technical noise names (`viewport`, `robots`, …), and application state (a JSON object, an opaque over-long blob) are dropped, case-variant keys are merged, `title` and `lang` come back spelled in lowercase, the title's whitespace is collapsed as `document.title` reads it, and a typed scalar comes back as a string.

```kotlin
val document = """
Expand Down
2 changes: 2 additions & 0 deletions markanywhere-html-spec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ so code that reads a semantic event stream carrying HTML attributes can use it w
|---------------------------------------------------|------------------------------------------------------------------------------------------------|
| `HTML_WHITESPACE_CHARS`, `Char.isHtmlWhitespace()` | HTML "ASCII whitespace": TAB, LF, FF, CR, SPACE — not NBSP, which HTML treats as content |
| `String.isHtmlBlank()` | Empty or HTML whitespace only |
| `String.stripAndCollapseHtmlWhitespace()` | Trimmed of HTML whitespace, inner runs collapsed to one space, as `document.title` reads it |
| `Char.asciiLowercase()`, `String.asciiLowercase()` | ASCII lowercase: only `A`–`Z` fold, as HTML compares names "ASCII case-insensitively" |
| `HTML_VOID_ELEMENTS` | Elements with no content and no closing tag, including the obsolete `keygen` and `param` |
| `HTML_RAW_TEXT_ELEMENTS` | `script` and `style`, whose content is neither escaped nor parsed as markup |
| `SemanticEvent.Mark.classList` | The distinct class names of a mark's `class` attribute, like the DOM's `classList` |
Expand Down
6 changes: 6 additions & 0 deletions markanywhere-html-spec/api/markanywhere-html-spec.api
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
public final class com/xemantic/markanywhere/html/spec/AsciiCaseKt {
public static final fun asciiLowercase (C)C
public static final fun asciiLowercase (Ljava/lang/String;)Ljava/lang/String;
}

public final class com/xemantic/markanywhere/html/spec/HtmlElementsKt {
public static final fun getHTML_RAW_TEXT_ELEMENTS ()Ljava/util/Set;
public static final fun getHTML_VOID_ELEMENTS ()Ljava/util/Set;
Expand All @@ -6,6 +11,7 @@ public final class com/xemantic/markanywhere/html/spec/HtmlElementsKt {
public final class com/xemantic/markanywhere/html/spec/HtmlWhitespaceKt {
public static final fun isHtmlBlank (Ljava/lang/String;)Z
public static final fun isHtmlWhitespace (C)Z
public static final fun stripAndCollapseHtmlWhitespace (Ljava/lang/String;)Ljava/lang/String;
}

public final class com/xemantic/markanywhere/html/spec/MarkClassListKt {
Expand Down
39 changes: 39 additions & 0 deletions markanywhere-html-spec/src/commonMain/kotlin/AsciiCase.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* Copyright 2026 Kazimierz Pogoda / Xemantic
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.xemantic.markanywhere.html.spec

/**
* This character with an ASCII upper alpha (`A`–`Z`) replaced by its lowercase
* counterpart — the WHATWG Infra "ASCII lowercase", the folding HTML applies
* wherever it matches names "ASCII case-insensitively" (element names,
* `<meta name>`, URL schemes).
*
* Deliberately narrower than [Char.lowercaseChar]: every other character,
* including non-ASCII letters, is returned unchanged.
*/
public fun Char.asciiLowercase(): Char =
if (this in 'A'..'Z') this + ('a' - 'A') else this

/**
* This string with every ASCII upper alpha replaced by its lowercase
* counterpart (see [Char.asciiLowercase]) — unlike [String.lowercase], which
* folds some non-ASCII letters into ASCII ones: `"\u212Aey".lowercase()`
* (a Kelvin sign `K`, U+212A) is `"key"`, while this leaves it unchanged, so
* it never matches a `key` name.
*/
public fun String.asciiLowercase(): String =
if (none { it in 'A'..'Z' }) this else CharArray(length) { this[it].asciiLowercase() }.concatToString()
19 changes: 19 additions & 0 deletions markanywhere-html-spec/src/commonMain/kotlin/HtmlWhitespace.kt
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,22 @@ public fun Char.isHtmlWhitespace(): Boolean = this in HTML_WHITESPACE_CHARS
* whitespace.
*/
public fun String.isHtmlBlank(): Boolean = all { it.isHtmlWhitespace() }

/**
* This string with leading and trailing [HTML whitespace][isHtmlWhitespace]
* removed and every inner run of it replaced by a single space — the WHATWG
* Infra "strip and collapse ASCII whitespace", the normalisation behind
* `document.title`. NBSP is content and stays.
*/
public fun String.stripAndCollapseHtmlWhitespace(): String = buildString(length) {
var pendingSpace = false
for (c in this@stripAndCollapseHtmlWhitespace) {
if (c.isHtmlWhitespace()) {
pendingSpace = isNotEmpty()
} else {
if (pendingSpace) append(' ')
pendingSpace = false
append(c)
}
}
}
60 changes: 60 additions & 0 deletions markanywhere-html-spec/src/commonTest/kotlin/AsciiCaseTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/*
* Copyright 2026 Kazimierz Pogoda / Xemantic
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/


package com.xemantic.markanywhere.html.spec

import com.xemantic.kotlin.test.assert
import com.xemantic.kotlin.test.sameAs
import kotlin.test.Test

class AsciiCaseTest {

@Test
fun `should lowercase ASCII upper alphas only`() {
// when
val lowered = "Og:TITLE-1_x".asciiLowercase()

// then
lowered sameAs "og:title-1_x"
}

@Test
fun `should leave non-ASCII letters unchanged`() {
// when — Unicode case folding would map these
val dotless = "TıTLE".asciiLowercase()
val umlaut = "ÄRGER".asciiLowercase()
val kelvin = 'K'.asciiLowercase()

// then
dotless sameAs "tıtle"
umlaut sameAs "Ärger"
assert(kelvin == 'K')
}

@Test
fun `should not fold a Kelvin sign into an ASCII k as lowercase does`() {
// given
val kelvinKey = "\u212Aey"

// when
val lowered = kelvinKey.asciiLowercase()

// then
kelvinKey.lowercase() sameAs "key"
lowered sameAs kelvinKey
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
package com.xemantic.markanywhere.html.spec

import com.xemantic.kotlin.test.assert
import com.xemantic.kotlin.test.sameAs
import kotlin.test.Test

class HtmlWhitespaceTest {
Expand Down Expand Up @@ -70,4 +71,31 @@ class HtmlWhitespaceTest {
assert(!"text".isHtmlBlank())
}

}
@Test
fun `should strip and collapse HTML whitespace`() {
// when
val result = "\n Foo\t\r\n Bar \u000C".stripAndCollapseHtmlWhitespace()

// then
result sameAs "Foo Bar"
}

@Test
fun `should keep NBSP when stripping and collapsing HTML whitespace`() {
// when
val result = "\u00A0 a \u00A0b ".stripAndCollapseHtmlWhitespace()

// then
result sameAs "\u00A0 a \u00A0b"
}

@Test
fun `should strip and collapse a blank string to empty`() {
// when
val result = " \t\n".stripAndCollapseHtmlWhitespace()

// then
result sameAs ""
}

}
2 changes: 2 additions & 0 deletions markanywhere-html/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,9 @@ kotlin {
api(project(":markanywhere-transform"))
implementation(project(":markanywhere-dump"))
implementation(project(":markanywhere-html-spec"))
implementation(project(":markanywhere-yaml"))
api(libs.kotlinx.coroutines.core)
implementation(libs.kotlinx.serialization.json)
implementation(libs.xemantic.kotlin.core)
}
}
Expand Down
Loading
Loading