Skip to content

feat: render invalid ICU placeholders as plain text#10

Open
bdshadow wants to merge 4 commits into
mainfrom
bdshadow/rtl-incomplete-expression
Open

feat: render invalid ICU placeholders as plain text#10
bdshadow wants to merge 4 commits into
mainfrom
bdshadow/rtl-incomplete-expression

Conversation

@bdshadow

@bdshadow bdshadow commented Jul 1, 2026

Copy link
Copy Markdown
Member

Problem

Invalid ICU expressions like {placeholder:space} (a colon isn't valid ICU) parsed as a partial {placeholder Expression plus trailing :space} text. In an RTL editor, htmlIsolatesPlugin puts the Expression in an LTR isolate and the trailing text in an RTL isolate, splitting the single token across two bidi boundaries and scrambling it.

Fix

Invalid {...} contents now parse leniently as a single Expression containing an InvalidExpressionBody (no Param, no styled tokens):

  • No chip in placeholder mode — getPlaceholders skips it (inner node isn't FormatExpression).
  • No highlighting in syntax mode — there are no Param/styled child tokens.
  • Not split — the whole {...} stays one node, so RTL bidi isolation keeps it intact.
  • Valid placeholders / plurals / selects are unchanged.

Mechanism: an extend: true external tokenizer forks the parse, and InvalidExpressionBody's @dynamicPrecedence=-1 makes a real format/select parse win whenever one exists. ICU validity itself is still reported by the formatjs-based linter.

Adds getInvalidPlaceholders(text, nested?) to detect such expressions (used by the platform to warn the user).

Tests

166 tests pass, including new parser cases and a getInvalidPlaceholders suite. tsc, eslint, and vite build are clean.

Invalid ICU expressions like `{placeholder:space}` used to parse as a
partial `{placeholder` Expression plus trailing text. In RTL editors the
two halves ended up in different bidi-isolation runs, scrambling the token.

They now parse leniently as a single Expression with no styled child
tokens, so they render as plain text — no chip, no highlighting — and stay
intact in RTL. Valid placeholders are unchanged. Adds `getInvalidPlaceholders`
to detect them.

@Anty0 Anty0 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not very familiar with the lezer, but I tried my best to understand the patch's intentions. Most of the points are from the AI review, but the one about keeping invalid placeholders bidi is my idea.

Comment thread src/parser/lezer/tokenizer/tokenizer.ts Outdated
Comment thread src/parser/lezer/tokenizer/tokenizer.ts Outdated
Comment thread src/parser/lezer/tokenizer/tokenizer.ts Outdated
Comment thread src/parser/placeholders/getInvalidPlaceholders.ts Outdated
Comment thread src/parser/lezer/tolgee.grammar Outdated
Comment thread src/parser/lezer/tolgee.grammar Outdated
Address review on the invalid-placeholder handling:

- Capture comma-containing (`{a:b, c}`) and bidi-mark-leading invalid
  bodies as a single Expression too. The tokenizer no longer stops at
  `,` or bails on leading whitespace/bidi marks; extend + the negative
  dynamicPrecedence still let a valid format/select parse win.
- Isolate an invalid expression by its own content direction instead of
  forcing LTR, so RTL invalid content (e.g. `{هذا نص}`) is no longer
  scrambled by htmlIsolatesPlugin.
- Trim duplicated/redundant comments.
@bdshadow

bdshadow commented Jul 7, 2026

Copy link
Copy Markdown
Member Author

Thanks for the thorough review @Anty0 — all points addressed in 03d894a.

  1. Comma-containing invalid bodies split — fixed. The tokenizer no longer stops at ,; it scans to the closing brace, so {placeholder:space, foo} parses as a single Expression → InvalidExpressionBody.
  2. Leading LRM/RLM split — fixed. Dropped the isWhiteSpace guard, so a bidi-mark-leading body is captured whole (test added with U+200F).
  3. Invalid expr forced LTR (the important one) — fixed. htmlIsolatesPlugin now isolates an invalid Expression by its own content direction (first strong character, à la unicode-bidi: plaintext) instead of forcing LTR. Valid placeholders stay LTR; {هذا نص عربي} → RTL isolate and renders correctly.
  4. Duplicated terminator set — gone; there's a single scan loop and no leading-char guard now.
  5. getInvalidPlaceholders comment — dropped.
  6. Grammar comment duplication — reduced to just the @dynamicPrecedence rationale, pointing at the tokenizer as the canonical home.

The guards were only needed before extend: true; with the fork in place, InvalidExpressionBody's @dynamicPrecedence=-1 alone keeps every valid format/select parse winning, so the tokenizer is now a plain scan-to-brace.

Behavior note: any closed {...} that isn't valid ICU is now a single lenient node, so { , }, { yo, }, { }, etc. render as plain text + the "invalid" warning instead of throwing — I updated those parser tests. Only empty {} and unclosed { still error.

168 tests pass; tsc / eslint / build clean.

@Anty0 Anty0 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like there are still some problems. Some introduced as part of the fix :3

Comment thread src/parser/htmlIsolatesPlugin.ts Outdated
/[\p{Script=Hebrew}\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}]/u;
const STRONG_LETTER = /\p{L}/u;

// Direction of a run's first strong character, à la `unicode-bidi: plaintext`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

contentDirection is documented as first-strong / unicode-bidi: plaintext, but it detects strength only via \p{L} plus a hardcoded 5-script RTL_SCRIPT list. Two gaps reintroduce the scramble this PR set out to fix:

  • Explicit bidi marks aren't \p{L}, so they're skipped: RLM (U+200F) and ALM (U+061C) are strong-RTL, LRM (U+200E) strong-LTR. An RLM-leading invalid body therefore infers LTR — and getInvalidPlaceholders.test.ts deliberately feeds RLM-leading input, so this is an expected case.
  • RTL scripts outside the 5 (Adlam, Mandaic, Samaritan, Hanifi Rohingya, …) match \p{L} first and are classified LTR.

Could we use library for this? Feels like a bad idea to implement custom detector for this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced with bidi-js in a81a540. Its getEmbeddingLevels trips on surrogate pairs, but its per-character class data is complete (RLM/ALM as R/AL, Adlam R, Hanifi Rohingya AL), so contentDirection now iterates code points and applies the UAX#9 P2 rule over bidi-js classes. All Unicode data comes from the library; only the 3-line first-strong rule remains here. Bundled into dist like entities, so consumers need no new dependency (~8KB gzip).

Comment thread src/parser/htmlIsolatesPlugin.ts Outdated
// A valid placeholder is markup and stays LTR. An invalid one is
// arbitrary user text, so it follows its own content's direction
// instead of being forced LTR (which would scramble RTL content).
const isolate =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: pull the compound condition into a named boolean so the ternary reads plainly:

const followsRtlContent =
  isInvalidExpression(node) &&
  contentDirection(view.state.doc.sliceString(node.from, node.to)) === Direction.RTL;
set.add(node.from, node.to, followsRtlContent ? isolateRTL : isolateLTR);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, extracted as followsRtlContent.

? isolateRTL
: isolateLTR;
set.add(node.from, node.to, isolate);
} else if (node.name === "TextRoot" || node.name === "Text") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design note: enter now runs three direction policies — tags & valid Expression forced LTR, Text/TextRoot forced RTL, and invalid Expression content-derived (first-strong). By this PR's own reasoning an invalid expression is 'arbitrary user text' — yet the visually-identical Text node is forced RTL while the invalid Expression uses plaintext direction. Two rules for the same kind of content, and a direction boundary between an invalid expression and the text around it. Worth considering one content-direction policy for all user text (bigger blast radius, needs RTL regression tests) instead of a special case — and deriving direction from a maintained bidi utility rather than a bespoke 5-script regex in the view layer.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept the special case deliberately. This plugin only mounts when the language itself is RTL, so forcing Text to RTL is the point: a Latin-only fragment of an Arabic translation must still sit in the RTL paragraph flow. Switching Text to first-strong would relayout every Latin-heavy RTL translation. The invalid expression genuinely cannot reuse that policy: forcing it RTL renders Latin content with the braces flipped outward (}placeholder:space{ ), which is the neutral-brackets problem this PR fixes. The bespoke-regex half of the concern is gone now that bidi-js owns the data. If we later want one plaintext policy for all user text, I would do that as its own change with RTL regression tests.

Comment thread src/parser/htmlIsolatesPlugin.ts Outdated
return Direction.LTR;
}

function isInvalidExpression(node: SyntaxNodeRef) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (step-down): contentDirection and isInvalidExpression (plus their RTL_SCRIPT / STRONG_LETTER consts) are only called from computeIsolates but sit above it, while the rest of the file reads top-down (htmlIsolatesPlugin above computeIsolates). Consider moving these helpers below computeIsolates to keep the top-down flow.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, helpers moved below computeIsolates.

})
.parse(input);
} catch (e) {
return null;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This returns null whenever the parse throws. But the lenient tokenizer only rescues {...} bodies with at least one non-brace char — empty {} produces no token and still throws, as does truly unparseable input (unclosed {). So a realistic string like {good:bad} {} or {good:bad} more {unclosed returns null and suppresses the warning for the clearly-invalid {good:bad} too — precisely the messy input this warning API exists to catch. Consider making it error-tolerant (collect InvalidExpressionBody spans from a non-strict parse, skipping error nodes), or document that null means 'entire string unparseable' and confirm the platform consumer falls back accordingly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a81a540. It now parses non-strict (never throws), collects InvalidExpressionBody expressions and skips unclosed ones (last child must be ExpressionClose), and always returns an array. {good:bad} {} and {good:bad} more {unclosed both report {good:bad} now, and the walk descends into select variants so an invalid placeholder nested in a plural is found too. Tests added for all three.

import type { Tree } from "@lezer/common";
import { Position } from "../types";

export type InvalidPlaceholder = {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Convention: placeholder public types live in src/parser/types.ts (Position, Placeholder, TolgeeFormat), and getPlaceholders imports Placeholder from there. InvalidPlaceholder — a direct sibling of Placeholder that already reuses Position from types.ts — is defined inline here instead, which forces the bespoke export type { InvalidPlaceholder } line in tolgee-editor.ts. Suggest moving it into types.ts next to Placeholder so it re-exports via the existing export * from './parser/types'.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, moved next to Placeholder in types.ts.

return null;
}

const result: InvalidPlaceholder[] = [];

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design note: this re-parses the input and runs a tree.cursor() walk that closely mirrors getPlaceholders.ts (same configure({strict, top}).parse, same null-on-throw, same firstChild?.nextSibling unwrap). The existing Placeholder model already carries problematic placeholders via its error? channel, emitted by getPlaceholders in a single walk. Folding invalid expressions into getPlaceholders (e.g. error: 'invalid_expression') would give consumers valid + invalid placeholders from one parse in document order, instead of two functions that parse the same string twice and return two disjoint lists. If a separate entry point must stay, at least factor the shared parse+walk into one helper.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with the fallback option: kept the separate entry point and factored the shared parse into parseTolgee. Folding into getPlaceholders via the error channel would fight the feature itself: PlaceholderPlugin and the platform render chips for everything getPlaceholders returns (error-flagged tags render as error chips), while invalid placeholders must render as plain text, so every consumer would need to filter. And after the non-strict change the contracts genuinely differ: getPlaceholders is strict and null-on-failure (PlaceholderPlugin relies on that null for its shiftByChanges fallback), getInvalidPlaceholders is non-strict and never null.

Comment thread src/tolgee-editor.ts Outdated
@@ -1,4 +1,6 @@
export { getPlaceholders } from "./parser/placeholders/getPlaceholders";
export { getInvalidPlaceholders } from "./parser/placeholders/getInvalidPlaceholders";
export type { InvalidPlaceholder } from "./parser/placeholders/getInvalidPlaceholders";

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If InvalidPlaceholder moves to src/parser/types.ts (see the comment on its definition), this explicit export type line can be dropped — it'd be covered by the existing export * from './parser/types' below.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, covered by the existing export * from ./parser/types.

Comment thread src/parser/lezer/tolgeeParser.test.ts Outdated
expect(() => tolgee(text)).toThrow();
}

// Invalid ICU the tolgee parser still accepts leniently (kept as one Expression

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment restates the leniency rationale that already lives (authoritatively) in the invalidExpression tokenizer comment, plus 'validity is reported by the linter' — behavior owned elsewhere. It duplicates rationale that rots independently. The helper name expectIcuOnlyThrows and its two assertions already say what it does; suggest dropping the comment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, dropped.

bdshadow added 2 commits July 9, 2026 13:00
… scan error-tolerant

Address second review round:

- Detect content direction via bidi-js character classes (UAX#9 P2)
  instead of a hand-rolled 5-script regex, covering bidi marks
  (RLM/ALM/LRM) and all RTL scripts including astral ones (Adlam,
  Hanifi Rohingya). Bundled into dist like `entities`.
- getInvalidPlaceholders now parses non-strict and skips unclosed
  expressions, so unrelated syntax errors elsewhere in the string no
  longer suppress the warnings; it always returns an array. Also
  detects invalid placeholders nested in plural variants.
- Move InvalidPlaceholder to types.ts, factor the shared parse into
  parseTolgee, tidy htmlIsolatesPlugin ordering, drop redundant
  comments.
@bdshadow
bdshadow requested a review from Anty0 July 15, 2026 08:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants