Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
6902132
feat(markdown): support Markdown-It 13 hosts
CodeinScrubs Aug 13, 2026
5986057
test(markdown): retain compatibility diagnostics
CodeinScrubs Aug 13, 2026
d6f0fca
ci(markdown): upload cross-version diagnostics
CodeinScrubs Aug 13, 2026
debfa98
fix(ci): decode compatibility output safely
CodeinScrubs Aug 13, 2026
2fa1ff1
docs: record protected compatibility gate
CodeinScrubs Aug 13, 2026
834c4f9
docs: record n8n maintainer guidance
CodeinScrubs Aug 13, 2026
df7ca7c
test(markdown): identify compatibility differences
CodeinScrubs Aug 13, 2026
7ad20be
docs: draft n8n renderer proposal
CodeinScrubs Aug 13, 2026
169e72e
test: cover mixed AI medical Markdown boundaries
CodeinScrubs Aug 23, 2026
77e3221
feat(markdown): verify Markdown-It 15 hosts
CodeinScrubs Aug 23, 2026
f93f5e0
ci: label Markdown-It 15 compatibility gate
CodeinScrubs Aug 23, 2026
a4aa253
fix(markdown): remove Markdown-It private type coupling
CodeinScrubs Aug 23, 2026
a8d60ec
chore: run Markdown-It verification on v15
CodeinScrubs Aug 23, 2026
e83cbd1
test(markdown): preserve caller text alignment
CodeinScrubs Aug 23, 2026
70562c0
docs: reconcile upstream integration status
CodeinScrubs Aug 23, 2026
4ed63c9
docs(unicode): document Unicode 18 beta boundary
CodeinScrubs Aug 23, 2026
0d6fa02
docs: refresh current verification metrics
CodeinScrubs Aug 23, 2026
872199c
fix(security): scope bidi controls to paragraph boundaries
CodeinScrubs Aug 23, 2026
396fac7
style(rust): apply rustfmt layout
CodeinScrubs Aug 24, 2026
c9468b9
docs(outreach): record August 24 audit
CodeinScrubs Aug 24, 2026
2e3ae95
docs(outreach): clarify audited commit
CodeinScrubs Aug 24, 2026
8386684
fix(bidi): align paragraph boundaries with Unicode
CodeinScrubs Aug 24, 2026
2f509fe
docs(outreach): point audit to Unicode boundary fix
CodeinScrubs Aug 24, 2026
24aba81
ci(dependabot): group CodeQL action updates
CodeinScrubs Aug 24, 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
8 changes: 8 additions & 0 deletions .changeset/complete-unicode-paragraphs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@bidilens/core': patch
'@bidilens/react': patch
'@bidilens/cli': patch
---

Treat every Unicode 17 `Bidi_Class=Paragraph_Separator` character as a default
batch and streaming boundary, with matching React, Android, and Rust behavior.
7 changes: 7 additions & 0 deletions .changeset/finite-option-guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@bidilens/core': patch
'@bidilens/markdown': patch
---

Reject non-finite detection and stream options instead of allowing NaN to
silently disable direction decisions or stream locking.
14 changes: 14 additions & 0 deletions .changeset/healthy-geese-compare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@bidilens/markdown': patch
---

Expand the optional Markdown-It peer range to cover 13.x, 14.x, and the current
15.x host line without exposing any parser's types through the public API. The
adapter now uses a version-neutral structural runtime boundary instead of
private `markdown-it/lib/*` declarations, and its own source build runs against
Markdown-It 15. A packed strict TypeScript/peer consumer gate runs all 932
canonical cases plus host-structure fixtures through all three parser lines.
Markdown-It 13 and 14 require identical full reports; Markdown-It 15's upstream
linkifier is allowed its documented HTML difference while BidiLens block,
security, and isolation reports remain identical. Each host also passes the
adapter's finalized batch/stream-equivalence check.
7 changes: 7 additions & 0 deletions .changeset/quiet-marks-detect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@bidilens/core': patch
---

Keep Unicode combining marks attached at isolation boundaries and recognize
compact technical labels such as `CN X`, `CN IX`, and `HR/BP` without changing
ordinary natural-language evidence.
6 changes: 6 additions & 0 deletions .changeset/secure-paragraph-boundaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@bidilens/core': patch
---

Scope bidi-control balancing to Unicode paragraph boundaries so a PDF or PDI
cannot close an embedding or isolate opened in an earlier paragraph.
12 changes: 10 additions & 2 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ updates:
- version-update:semver-major
# markdown-it 15 changes its TypeScript surface: the constructor no
# longer serves as the instance type and renderer callbacks lose the
# contextual types used by our adapter. Adopt that major only alongside
# a deliberate compatibility patch and consumer/type-layout validation.
# contextual types used by older adapters. BidiLens now uses a
# version-neutral structural boundary; keep future major updates paired
# with the source-build and packed consumer compatibility gates.
- dependency-name: "markdown-it"
update-types:
- version-update:semver-major
Expand All @@ -46,6 +47,13 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
# CodeQL init and analyze must move together. Keeping them in separate
# PRs can create a transient configuration mismatch where one action
# runs a newer CodeQL version against the other action's older config.
codeql-action:
patterns:
- "github/codeql-action/*"

- package-ecosystem: gradle
directory: /android
Expand Down
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,33 @@ jobs:
- run: pnpm run packages:types
- run: pnpm run release:check

markdown-it-compat:
name: Markdown-It 13, 14, and 15 compatibility
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
with:
version: 10.27.0
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22.12.0
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: pnpm run markdown-it:compat
env:
BIDILENS_MARKDOWN_IT_REPORT_DIR: markdown-it-compat-reports
- if: ${{ failure() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: markdown-it-compat-reports
path: markdown-it-compat-reports
if-no-files-found: error
retention-days: 7

android:
name: Android libraries and sample
runs-on: ubuntu-latest
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
- run: pnpm exec playwright install --with-deps chromium firefox webkit
- run: pnpm run test:visual
- run: pnpm run packages:types
- run: pnpm run markdown-it:compat
- run: pnpm run deps:audit
- run: pnpm run release:check
- run: pnpm run sbom
Expand Down
1 change: 1 addition & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ Changesets entry for a public API or behavior change. Before a release run:
pnpm run check
pnpm run test:visual
pnpm run packages:types
pnpm run markdown-it:compat
pnpm run deps:audit
pnpm run release:check
pnpm run sbom
Expand Down
19 changes: 12 additions & 7 deletions IMPACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@
- 932 schema-validated direction fixtures with numbered logical words;
- 0 fixtures currently certified by a native-language reviewer;
- property-based stream/source/range checks;
- 396 unit/property/action tests with 94.80% overall and 95.81% core line coverage,
including multilingual strict-security false-positive regressions;
- 24 browser/visual checks spanning Chromium, Firefox, and WebKit, including
- 424 unit/property/action tests with 92.23% statements, 86.23% branches,
94.84% functions, and 95.05% lines overall (96.34% core lines), including
paragraph-boundary security, isolate balancing, and non-finite-option
regressions;
- 30 Playwright browser/visual tests spanning Chromium, Firefox, and WebKit, including
structured Markdown, real Chromium clipboard verification, and no-build
standalone Web Component loading, plus the full offline bilingual playground
controls, corpus, copy invariant, theme, and export flow;
- a self-contained 196,766-byte Node 24 GitHub Action bundle with source tests
- a self-contained 199,733-byte Node 24 GitHub Action bundle with source tests
and built-artifact safe/strict-failure probes;
- reproducible Unicode 17.0.0 source and generated tables;
- clean tarball installation, strict consumer type-check, runtime imports, and
Expand All @@ -28,9 +30,12 @@ the [build report](docs/V1_BUILD_REPORT.md).

## Not yet measured

There are no claimed users, downloads, stars, external contributors, merged
upstream patches, production deployments, accessibility lab results, security
audit, grants, or sponsorships. Those outcomes require real external evidence.
There are no claimed users, downloads, stars, external contributors, production
deployments, accessibility lab results, independent security audit, grants, or
sponsorships. One focused native implementation based on the BidiLens policy
merged upstream in Streamdown; that is integration evidence, not proof of
BidiLens dependency adoption or continued production use. All other outcomes
require real external evidence.

## Intended impact

Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ the immutable logical source, and isolates the technical identifier.
|---|---|
| ![The flagship sentence rendered with the incorrect automatic LTR base](tests/visual/__screenshots__/chromium/flagship-auto.png) | ![The same logical sentence rendered with the correct RTL base and isolated React token](tests/visual/__screenshots__/chromium/flagship-toolkit.png) |

The medical Markdown, abbreviation, combining-mark, quote, list, and table
cases transcribed from mixed AI output are documented in the
[screenshot regression cases](docs/SCREENSHOT_CASES.md).

## Why this is more than an RTL stylesheet

| Common approach | Limitation | BidiLens behavior |
Expand Down Expand Up @@ -146,7 +150,7 @@ reordering and shaping; BidiLens supplies the application structure they need.
| `@bidilens/core` | Analysis, evidence, isolation, streaming, and security |
| `@bidilens/dom` | DOM annotation, restoration, CSS policy, and observation |
| `@bidilens/html` | XSS-safe semantic HTML serialization |
| `@bidilens/markdown` | unified/remark/rehype and Markdown-It plugins plus rich Markdown-It streaming |
| `@bidilens/markdown` | unified/remark/rehype and Markdown-It 13/14/15 plugins plus rich Markdown-It streaming |
| `@bidilens/playwright` | Rendering, isolation, selection, clipboard, and geometry assertions |
| `@bidilens/react` | SSR-safe components and streaming hooks |
| `@bidilens/spec` | Versioned cross-language JSON Schemas and schema registry |
Expand Down Expand Up @@ -289,7 +293,8 @@ cargo clippy --manifest-path rust/Cargo.toml --all-targets --all-features -- -D
```

`verify:production` runs the complete quality, three-browser visual, package
type, dependency-audit, SBOM, and clean-release sequence. `release:check`
type, packed Markdown-It 13/14/15 compatibility, dependency-audit, SBOM, and
clean-release sequence. `release:check`
builds and packs every public package, inspects tarball contents, installs all
tarballs into a temporary consumer, compiles with `skipLibCheck: false`, and
executes adapter/runtime assertions—including the pure-LTR non-interference
Expand Down
63 changes: 54 additions & 9 deletions action/dist/index.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -6047,6 +6047,18 @@ function classifyCharacter(character) {
return isInRanges(codePoint, NATURAL_LETTER_RANGES) ? classifyBidiStrongCharacter(character) : "neutral";
}

// packages/core/src/options.ts
function boundedNumberOption(name, value, defaultValue, minimum, maximum) {
const resolved = value ?? defaultValue;
if (!Number.isFinite(resolved)) {
throw new RangeError(`${name} must be a finite number.`);
}
return Math.min(maximum, Math.max(minimum, resolved));
}

// packages/core/src/paragraph.ts
var DEFAULT_PARAGRAPH_SEPARATOR_SOURCE = "\\r\\n|\\n|\\r|\\u0085|[\\u001C-\\u001E]|\\u2029";

// packages/core/src/detect.ts
var DEFAULT_OPTIONS = {
strategy: "content-majority",
Expand Down Expand Up @@ -6127,8 +6139,20 @@ function normalizeOptions(options = {}) {
strategy,
fallback: options.fallback ?? options.inheritedDirection ?? DEFAULT_OPTIONS.fallback,
inheritedDirection: options.inheritedDirection ?? DEFAULT_OPTIONS.inheritedDirection,
minimumStrongCharacters: Math.max(1, options.minimumStrongCharacters ?? DEFAULT_OPTIONS.minimumStrongCharacters),
majorityThreshold: Math.min(1, Math.max(0.5, options.majorityThreshold ?? DEFAULT_OPTIONS.majorityThreshold)),
minimumStrongCharacters: boundedNumberOption(
"minimumStrongCharacters",
options.minimumStrongCharacters,
DEFAULT_OPTIONS.minimumStrongCharacters,
1,
Number.POSITIVE_INFINITY
),
majorityThreshold: boundedNumberOption(
"majorityThreshold",
options.majorityThreshold,
DEFAULT_OPTIONS.majorityThreshold,
0.5,
1
),
// Compatibility/strict first-strong modes must see the real first strong
// character (including a leading technical identifier), like dir="auto".
excludeTechnicalTokens: options.excludeTechnicalTokens ?? majorityStrategy,
Expand Down Expand Up @@ -6403,6 +6427,9 @@ function findTechnicalTokenRanges(text, technicalIdentifiers = []) {
addMatches(text, ranges, /\bv?\d+(?:\.\d+){1,}\b/gu, "version");
addMatches(text, ranges, /\b[0-9a-f]{7,40}\b/giu, "hash");
addMatches(text, ranges, /(?<![\p{L}\p{N}_])[+-]?(?:\d+(?:[.,]\d+)?|[\u0660-\u0669]+(?:[\u066B\u066C][\u0660-\u0669]+)?|[\u06F0-\u06F9]+(?:[.,][\u06F0-\u06F9]+)?)(?![\p{L}\p{N}_])/gu, "number");
addMatches(text, ranges, /\b[A-Z]{1,4}\s+(?:[IVXLCDM]{1,8}|\d{1,3})\b/gu, "identifier");
addMatches(text, ranges, /\b[A-Z]{1,4}\/[A-Z]{1,4}\b/gu, "identifier");
addMatches(text, ranges, /\b[A-Z]\b(?=\s*(?:=|:|→|->))/gu, "identifier");
const words = /\b[A-Za-z][A-Za-z0-9_.-]*\b/gu;
const customIdentifiers = customTechnicalIdentifiers(technicalIdentifiers);
const uppercaseProse = usesUppercaseProse(text);
Expand Down Expand Up @@ -6478,7 +6505,7 @@ function firstBidiStrongCharacter(text) {
}
function splitParagraphs(text) {
const paragraphs = [];
const separator = /\r\n|\n|\r|\u2029/gu;
const separator = new RegExp(DEFAULT_PARAGRAPH_SEPARATOR_SOURCE, "gu");
let start = 0;
let match;
while ((match = separator.exec(text)) !== null) {
Expand Down Expand Up @@ -6550,6 +6577,10 @@ function analyzeText(text, options = {}) {
}

// packages/core/src/security.ts
var UAX9_PARAGRAPH_SEPARATOR = new RegExp(
`\\r\\n|\\n|\\r|\\u0085|[${String.fromCodePoint(28)}-${String.fromCodePoint(30)}]|\\u2029`,
"gu"
);
var CONTROL_METADATA = /* @__PURE__ */ new Map([
[1564, { name: "ARABIC LETTER MARK", risk: "low", category: "mark" }],
[8206, { name: "LEFT-TO-RIGHT MARK", risk: "low", category: "mark" }],
Expand Down Expand Up @@ -6638,7 +6669,7 @@ function lastFrameIndex(stack, kind) {
}
return -1;
}
function balanceFindings(controls) {
function balanceParagraph(controls, boundary) {
const findings = [];
const stack = [];
for (const control of controls) {
Expand Down Expand Up @@ -6699,14 +6730,28 @@ function balanceFindings(controls) {
findings.push({
code: frame.kind === "isolate" ? "BIDI_UNCLOSED_ISOLATE" : "BIDI_UNCLOSED_EMBEDDING",
severity: "high",
message: `${frame.control.name} is not terminated before the end of the text.`,
message: `${frame.control.name} is not terminated before ${boundary === "paragraph" ? "the paragraph boundary" : "the end of the text"}.`,
sourceRange: rangeFor(frame.control),
remediation: frame.kind === "isolate" ? "Add the matching PDI or remove the isolate opener." : "Add the matching PDF or remove the embedding/override opener.",
control: frame.control
});
}
return findings;
}
function balanceFindings(text, controls) {
const findings = [];
let controlIndex = 0;
for (const match of text.matchAll(UAX9_PARAGRAPH_SEPARATOR)) {
const paragraphControls = [];
while (controlIndex < controls.length && controls[controlIndex].index < match.index) {
paragraphControls.push(controls[controlIndex]);
controlIndex += 1;
}
findings.push(...balanceParagraph(paragraphControls, "paragraph"));
}
findings.push(...balanceParagraph(controls.slice(controlIndex), "text"));
return findings;
}
function isAsciiIdentifierCharacter(value) {
return value !== void 0 && /^[A-Za-z0-9_$]$/u.test(value);
}
Expand Down Expand Up @@ -6767,7 +6812,7 @@ function scanBidiSecurity(text, options = {}) {
const controls = findBidiControls(text);
const findings = [
...controls.map(controlFinding),
...balanceFindings(controls),
...balanceFindings(text, controls),
...invisibleCharacterFindings(text)
].sort((a, b) => a.sourceRange.utf16.start - b.sourceRange.utf16.start || a.code.localeCompare(b.code));
const hasHigh = findings.some((finding) => finding.severity === "high");
Expand Down Expand Up @@ -6904,12 +6949,12 @@ function mergeAdjacent(runs) {
function trimNeutralBoundaries(text, start, end) {
while (start < end) {
const character = text.slice(start).match(/^./su)?.[0];
if (!character || classifyCharacter(character) !== "neutral") break;
if (!character || classifyCharacter(character) !== "neutral" || new RegExp("^\\p{M}$", "u").test(character)) break;
start += character.length;
}
while (end > start) {
const character = text.slice(0, end).match(/.$/su)?.[0];
if (!character || classifyCharacter(character) !== "neutral") break;
if (!character || classifyCharacter(character) !== "neutral" || new RegExp("^\\p{M}$", "u").test(character)) break;
end -= character.length;
}
return { start, end };
Expand Down Expand Up @@ -7437,7 +7482,7 @@ function highestFindingRisk(findings) {
function sourcePosition(text, utf16Offset) {
let lineNumber = 1;
let lineStart = 0;
const newline = /\r\n|\n|\r/gu;
const newline = new RegExp(`${DEFAULT_PARAGRAPH_SEPARATOR_SOURCE}|\\u2028`, "gu");
let match;
while ((match = newline.exec(text)) !== null && match.index < utf16Offset) {
lineNumber += 1;
Expand Down
6 changes: 3 additions & 3 deletions action/dist/index.cjs.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ pnpm run android:check
Current executable evidence includes:

- all 932 canonical direction fixtures and declared isolation plans in Kotlin;
- 23 core, 9 Views/Robolectric, and 8 Compose JVM tests;
- 29 core, 11 Views/Robolectric, and 9 Compose JVM tests;
- 3 Views and 3 Compose UI tests on an Android 16/API 36.1 emulator;
- release AAR assembly, sample APK assembly, and Android lint;
- an isolated consumer build against the generated Maven-local coordinates;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,10 +121,12 @@ private fun analyzeParagraph(
)
}

private val defaultParagraphSeparator = Regex("\\r\\n|\\n|\\r|\\u0085|[\\u001C-\\u001E]|\\u2029")

private fun splitParagraphs(text: String): List<Pair<String, Int>> {
val result = mutableListOf<Pair<String, Int>>()
var start = 0
for (match in Regex("\\r\\n|\\n|\\r|\\u2029").findAll(text)) {
for (match in defaultParagraphSeparator.findAll(text)) {
result += text.substring(start, match.range.first) to start
start = match.range.last + 1
}
Expand Down
Loading