feat: render invalid ICU placeholders as plain text#10
Conversation
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
left a comment
There was a problem hiding this comment.
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.
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.
|
Thanks for the thorough review @Anty0 — all points addressed in 03d894a.
The guards were only needed before Behavior note: any closed 168 tests pass; tsc / eslint / build clean. |
Anty0
left a comment
There was a problem hiding this comment.
Seems like there are still some problems. Some introduced as part of the fix :3
| /[\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`. |
There was a problem hiding this comment.
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 — andgetInvalidPlaceholders.test.tsdeliberately 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.
There was a problem hiding this comment.
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).
| // 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 = |
There was a problem hiding this comment.
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);There was a problem hiding this comment.
Done, extracted as followsRtlContent.
| ? isolateRTL | ||
| : isolateLTR; | ||
| set.add(node.from, node.to, isolate); | ||
| } else if (node.name === "TextRoot" || node.name === "Text") { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| return Direction.LTR; | ||
| } | ||
|
|
||
| function isInvalidExpression(node: SyntaxNodeRef) { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Done, helpers moved below computeIsolates.
| }) | ||
| .parse(input); | ||
| } catch (e) { | ||
| return null; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 = { |
There was a problem hiding this comment.
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'.
There was a problem hiding this comment.
Done, moved next to Placeholder in types.ts.
| return null; | ||
| } | ||
|
|
||
| const result: InvalidPlaceholder[] = []; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| @@ -1,4 +1,6 @@ | |||
| export { getPlaceholders } from "./parser/placeholders/getPlaceholders"; | |||
| export { getInvalidPlaceholders } from "./parser/placeholders/getInvalidPlaceholders"; | |||
| export type { InvalidPlaceholder } from "./parser/placeholders/getInvalidPlaceholders"; | |||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Done, covered by the existing export * from ./parser/types.
| expect(() => tolgee(text)).toThrow(); | ||
| } | ||
|
|
||
| // Invalid ICU the tolgee parser still accepts leniently (kept as one Expression |
There was a problem hiding this comment.
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.
… 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.
Problem
Invalid ICU expressions like
{placeholder:space}(a colon isn't valid ICU) parsed as a partial{placeholderExpressionplus trailing:space}text. In an RTL editor,htmlIsolatesPluginputs theExpressionin 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 singleExpressioncontaining anInvalidExpressionBody(noParam, no styled tokens):getPlaceholdersskips it (inner node isn'tFormatExpression).Param/styled child tokens.{...}stays one node, so RTL bidi isolation keeps it intact.Mechanism: an
extend: trueexternal tokenizer forks the parse, andInvalidExpressionBody's@dynamicPrecedence=-1makes 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
getInvalidPlaceholderssuite.tsc,eslint, andvite buildare clean.