fix: webpage translation issue 0b9658 (#2087)

* fix(translation): translate plain-text documents rendered as one <pre>

A text/plain URL reaches the page as a single browser-generated <pre>
wrapping the entire file — Chrome also gives it `white-space: pre-wrap`.
Because PRE is in DONT_WALK_AND_TRANSLATE_TAGS, the walk stopped at that
one element and the page labeled zero paragraphs, so nifty.org story
pages (prose served as text/plain) translated as nothing at all.

Exempt PRE from the tag block when `ownerDocument.contentType` is exactly
`text/plain`. An authored <pre> inside an HTML document still holds code,
logs or ASCII art and stays blocked, and JSON/markdown/XML viewers stay
blocked too. `isDontWalkIntoAndDontTranslateAsChildElement` is the single
place the tag set is read — traversal, the mutation walkability cache,
extractTextContent, unwrapDeepestOnlyHTMLChild and the virtual-paragraph
plan's collectRawSource all route through it — so one exemption covers
every consumer.

PRE is also in FORCE_BLOCK_TAGS, so an exempted viewer labels as a block
paragraph, and its preserved white-space feeds the existing bilingual
virtual-paragraph plan: a story now translates one blank-line paragraph
at a time instead of as one giant request.

The exemption un-blocks what the defaults block, so rule authors keep the
last word: `dontWalkTags.add: ["PRE"]` now wins over it (resolve tracks
explicit adds separately from the merged set), and `excludeSelectors`
remains available either way.

Known gap, unchanged here: translationOnly has no virtual-paragraph plan,
so it still sends such a page as one request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(translate): resolve virtual paragraphs onto whole child nodes

Bilingual mode consumes a virtual paragraph plan by inserting a wrapper at
each unit's boundary, so a unit may end anywhere — mid-Text, inside a
nested span. translationOnly swaps text in place instead, which needs the
opposite shape: each unit as its own run of whole child nodes, so the
ordinary per-run pipeline (attribute protection, alignment, in-place swap,
restore records) can handle one paragraph at a time.

resolveVirtualParagraphUnitEdges decides whether a plan has that shape. A
unit qualifies when both content edges, after the existing edge lifting,
land between two children or strictly inside a top-level Text node that a
splitText can cut. An edge stranded inside a nested element does not
qualify: X puts tweet text in inline spans, so a note tweet's blank lines
sit inside a span and cutting the unit out would split that element and
its styling apart. A unit whose node range holds a textless element (a
twemoji img) is refused too, since shipping that markup would make
alignment depend on the provider echoing the tag back.

materializeVirtualParagraphUnitRuns applies it, cutting only top-level Text
nodes and only at unit edges — the blank lines between units keep their own
nodes. Cuts run backwards through the container, and within a unit the end
edge is cut before the start edge, so every offset computed against the
pre-split DOM is still valid when its turn comes. Node references are
captured before the first cut, since a cut shifts later child indices.
Cuts are recorded as TextSplitRecords (the same shape wrapper insertion
produces, rejoined by restoreTextSplit) and marked extension-driven so the
mutation pipeline does not read our own split as a host edit.

Both live in the plan builder's own modules, so the observation gate and
the translate path can consult one decision and cannot drift. No caller
yet: this only adds the primitives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(translate): let a translationOnly anchor own its virtual generation

Preparation for per-paragraph translationOnly: the container's anchor state
gains the two things a wrapper-less virtual generation needs.

`splitRecords` holds the Text cuts that turned blank-line paragraphs into
whole-node runs, so the anchor that owns the swaps also owns the cuts.
Finalizing rejoins them, and only there — restoreTextSplit compares the
rejoined value against the original, so translated text still in place
would make it refuse. Restoring the swaps first and rejoining second is
therefore the only order that works.

`virtualGeneration` keeps the anchor alive mid-generation. A unit that is
a single element unwraps into that element and registers its record there,
so the container's own swap list is legitimately empty between units;
without the pin the first finalize would rejoin the cuts under the feet of
units still awaiting a provider. It also lets a late response tell whether
its generation is still current.

teardownVirtualTranslationOnlyGeneration undoes a whole generation in the
one order that holds: wrappers first (a wrapper between a cut source and
its tail blocks the rejoin, and the fallback strategy parks original nodes
inside one), then descendant anchors, then the container itself. A
page-wide cleanup needs no changes — it already visits anchors by marker,
and materialization claims the marker up front.

ensureTranslationOnlyAnchorState is applyInPlaceTextSwap's own anchor
registration, extracted so the virtual path can claim the anchor when it
cuts, before any request is sent. Behavior for the existing swap path is
unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* refactor(translate): split translationOnly into a dispatcher and a run

translateNodeTranslationOnlyMode did two jobs: decide what one translation
request covers, and carry it out. Per-paragraph translation needs the first
decision to change while the second stays exactly as it is, so separate
them now, while the split is provably behavior-preserving.

The body moves verbatim to a private translateTranslationOnlyRun; the
exported name becomes the dispatcher and currently just forwards. Its two
internal retries call the run directly: they retry a run whose granularity
was already decided, and re-deciding mid-retry is the observation/translate
drift the virtual-paragraph design exists to avoid.

The run also takes an `isCurrent` predicate, defaulting to the current
always-true behavior, and consults it where the existing in-flight guards
live: the spinner's own check and both batched DOM applies. A caller that
owns several runs can then invalidate the ones still awaiting a provider —
a wrapper surviving the round trip is not proof the run should still land.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(translate): translate one paragraph at a time in translationOnly

A plain-text page reaches translationOnly mode as one container holding the
whole file, so the whole file went out as a single request — past provider
length limits, with no partial progress and nothing to show until the last
paragraph came back. Bilingual mode has segmented such containers by blank
line since #1881; this brings translationOnly to the same granularity.

The dispatcher plans the container, and when every unit maps onto whole
child nodes it cuts them apart and hands each unit to the ordinary run
path. Everything a run already does then applies per paragraph: its own
request and cache entry, its own small-paragraph and target-language
filtering, its own spinner and error UI, its own restore record. A failing
paragraph now costs one paragraph.

Containers that cannot be segmented keep today's behavior, with one
exception that matters: a giant one is translated per labeled paragraph
instead of as a single run. The observer only hands a giant over whole
because the materializability predicate said its units were separable, so
if the host reshaped the DOM in between, falling back to one run would
ship a 22k-char note tweet in one payload and, on a failed alignment,
displace every framework-owned node inside it. Per-paragraph translation
reproduces what per-paragraph observation would have done.

A live generation is torn down and rebuilt rather than patched, so a plan
is never built over half-restored text — that would read our own
translations as source. Toggling off hands the container back with its
cuts rejoined; a container where nothing landed (every unit filtered, or
every translation equal to its source) is left byte-identical. The
generation ends through the same batch its units apply in, since running
before them would read an empty anchor and undo the generation just as its
translations arrive.

Unit requests keep the existing HTML format rather than switching to the
plain one bilingual's virtual units use: it preserves in-place alignment
for units that contain markup, and for a hard-wrapped story the collapsed
line breaks are the desirable outcome — preserving them would translate a
paragraph line by line, severing sentences at the source's wrap column.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(translate): let translationOnly observe segmentable containers whole

The giant-paragraph split guard was bilingual-only, on the grounds that
translationOnly had no virtual-paragraph plan to segment a container with.
It has one now, so the mode condition moves into the guard itself, where
the plan that decides is already built.

The two modes need different things from the same plan. Bilingual can
interleave a wrapper at any boundary, so any container the plan segments
is better observed whole. translationOnly segments by cutting units apart
into whole child nodes, which a plan whose blank lines sit inside an inline
element cannot express — the X note tweet — and observing such a container
whole would cost it the per-span granularity and viewport gating it has
today while gaining nothing. So translationOnly additionally requires the
units to be materializable, decided by the predicate its translate path
uses, keeping observation and translation from drifting apart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(changeset): add changeset for plain-text page translation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(translate): keep virtual translationOnly toggles and cuts safe

Two defects found by an adversarial review of the finished diff, both
introduced by the per-paragraph translationOnly work and both reproduced
against base before fixing.

A generation whose units are whole elements never cuts a Text node, and
each unit registers its swap on the element it unwrapped into, so neither
the container's swaps nor its split records recorded that the container
was segmented at all. The generation marker was the only sign left, and it
was being cleared as soon as the units settled. A later toggle therefore
read the container as untouched, fell through to the single-run path, and
translated its own output: "show original" did nothing, the spans stayed
swapped, and a provider request was spent on translated text.

The marker now lives until a full restore ends the generation, and that
release moves into restoreTranslationOnlySwapsForAnchor, where both
deliberate endings already pass — a toggle and a page-wide cleanup. The
paths that reach the anchor incidentally, like a sibling unit dropping its
records on the way to the fallback wrapper, keep finding the generation
live and leave the cuts alone.

The second defect could delete page content. The fallback strategy detaches
a unit's original nodes into its wrapper's registry, and the first unit's
node is the source every later unit was cut from. restoreTextSplit reads a
detached source as a framework rewrite and removes every tail still holding
its post-split value — correct for that case, but here those tails are the
container's remaining paragraphs. Finalize now skips a cut whose source has
left the document: adjacent Text nodes are invisible, deleting the rest of
the page is not.

Also stop re-reading targetNode.parentElement after the restore-first pass,
which throws when that pass just detached the node; the parent captured and
null-checked earlier is what the lookup wants.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(translate): pin page-wide cleanup against a live virtual generation

The existing cleanup test cleared the generation marker by hand before
stopping the page, so nothing covered the case a reviewer asked about:
stopping translation (or switching modes) while a unit is still awaiting
its provider. Drop the manual clear so the generation stays live, and add
the harsher variant where no unit ever resolved and the container holds
only its marker and its cuts.

Both fail against the cleanup code as it stood before the generation
release moved into restoreTranslationOnlySwapsForAnchor: the container kept
its marker, its dir/lang and its split text nodes, the next walk skipped
the region, and a hung request meant nothing would ever release it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
ananaBMaster
2026-08-15 19:01:30 -07:00
committed by GitHub
parent eb04c6da21
commit c2b528b176
14 changed files with 1423 additions and 52 deletions
@@ -0,0 +1,9 @@
---
"@read-frog/extension": patch
---
fix(translation): translate plain-text pages, one paragraph at a time
Pages served as `text/plain` reach the browser as a single generated `<pre>` holding the whole file, which the walker skipped entirely — nifty.org story pages translated as nothing at all. Such a `<pre>` is now translated, while an authored `<pre>` in an HTML document still keeps its code and logs untouched.
Translation-only mode also gains the per-paragraph granularity bilingual mode has had, so a long page goes out one blank-line paragraph at a time instead of as one oversized request: paragraphs appear as they arrive, and a failed one costs only itself.
@@ -775,19 +775,18 @@ export class PageTranslationManager implements IPageTranslationManager {
observer.observe(element)
return
}
if (
config.pageTranslation.mode === "bilingual" &&
!canSplitParagraphIntoDescendants(element, innerTopLevelParagraphs, config)
) {
if (!canSplitParagraphIntoDescendants(element, innerTopLevelParagraphs, config)) {
// A newline-preserving flow (X note tweet: pre-wrap div of inline
// rich-text <span> paragraphs,
// https://x.com/davidjpark96/status/1789773192435060737) must not be
// split — per-span observation translates each span as one blob at the
// span's end instead of interleaving per blank-line paragraph. Observed
// whole, the div-level virtual-paragraph plan segments it correctly.
// Bilingual only: translationOnly has no virtual-paragraph plan, swaps
// text in place (no blob-at-span-end problem), and would lose viewport
// gating plus batch one giant request if observed whole.
// split when the container-level virtual-paragraph plan can segment it
// instead — per-span observation translates each span as one blob,
// destroying the blank-line paragraph structure.
// Both modes now have such a plan, but they need different things from
// it, which is why the decision lives in canSplitParagraphIntoDescendants
// rather than here: bilingual can interleave a wrapper at any boundary,
// while translationOnly has to cut the units apart into whole nodes and
// therefore keeps per-span observation for the plans it cannot express.
observer.observe(element)
return
}
@@ -2872,6 +2872,205 @@ describe("translate", () => {
})
})
describe("plain-text document viewer", () => {
// A text/plain URL (nifty.org stories, RFC mirrors, raw logs) reaches the
// page as one browser-generated <pre> holding the whole file, with
// Chrome's own `white-space: pre-wrap` inline style. It is the only
// content such a page has, so the blanket PRE block left it untranslated.
const paragraphs = [
"Archive header lines describe the collection, the author\ncontact address and the posting date of the chapter.",
"The first paragraph of the story is hard wrapped across\nseveral short lines the way a usenet posting would be.",
"The closing paragraph of the fixture ends the story here.",
]
const storyText = paragraphs.join("\n\n")
const translations = ["【存档头译文】", "【第一段译文】", "【结尾段译文】"]
function renderPlainTextViewer() {
Object.defineProperty(document, "contentType", {
value: "text/plain",
configurable: true,
})
render(
<pre data-testid="viewer" style={{ whiteSpace: "pre-wrap", wordWrap: "break-word" }}>
{storyText}
</pre>,
)
return screen.getByTestId("viewer")
}
afterEach(() => {
Reflect.deleteProperty(document, "contentType")
vi.mocked(translateTextForPage).mockReset().mockResolvedValue(MOCK_TRANSLATION)
})
it("bilingual mode: translates a text/plain page one blank-line paragraph at a time", async () => {
const translationByParagraph = new Map(
paragraphs.map((paragraph, index) => [paragraph, translations[index]!]),
)
vi.mocked(translateTextForPage).mockImplementation(async (text) => {
const translated = translationByParagraph.get(text)
if (!translated) throw new Error(`Unexpected paragraph: ${JSON.stringify(text)}`)
return translated
})
const viewer = renderPlainTextViewer()
await removeOrShowPageTranslation("bilingual", true)
// One request per blank-line paragraph, never one request for the file.
expect(translateTextForPage).toHaveBeenCalledTimes(paragraphs.length)
paragraphs.forEach((paragraph) => {
expect(translateTextForPage).toHaveBeenCalledWith(
paragraph,
"plain",
PRESERVE_LINE_BREAKS_TRANSLATION_OPTIONS,
)
})
const wrappers = [...viewer.querySelectorAll(`.${CONTENT_WRAPPER_CLASS}`)]
expect(wrappers).toHaveLength(paragraphs.length)
// Each translation lands directly after its own paragraph, and the
// host text is never rewritten.
const renderedText = viewer.textContent ?? ""
let cursor = -1
paragraphs.forEach((paragraph, index) => {
const sourceIndex = renderedText.indexOf(paragraph, cursor + 1)
expect(sourceIndex, `paragraph ${index + 1} kept intact`).toBeGreaterThan(cursor)
const translationIndex = renderedText.indexOf(translations[index]!, sourceIndex)
expect(translationIndex, `translation ${index + 1} follows it`).toBeGreaterThan(
sourceIndex,
)
cursor = translationIndex
})
})
it("keeps the same page untranslated when it is served as html", async () => {
render(
<pre data-testid="html-viewer" style={{ whiteSpace: "pre-wrap" }}>
{storyText}
</pre>,
)
const viewer = screen.getByTestId("html-viewer")
await removeOrShowPageTranslation("bilingual", true)
expect(translateTextForPage).not.toHaveBeenCalled()
expect(viewer.querySelector(`.${CONTENT_WRAPPER_CLASS}`)).toBeFalsy()
expect(viewer.textContent).toBe(storyText)
})
function mockPerParagraphTranslations() {
const translationByParagraph = new Map(
paragraphs.map((paragraph, index) => [paragraph, translations[index]!]),
)
vi.mocked(translateTextForPage).mockImplementation(async (text) => {
const translated = translationByParagraph.get(text)
if (!translated) throw new Error(`Unexpected paragraph: ${JSON.stringify(text)}`)
return translated
})
}
it("translation only mode: swaps one blank-line paragraph at a time", async () => {
mockPerParagraphTranslations()
const viewer = renderPlainTextViewer()
await removeOrShowPageTranslation("translationOnly", true)
// One request per paragraph — never the whole file in one payload.
expect(translateTextForPage).toHaveBeenCalledTimes(paragraphs.length)
expect(viewer.querySelector(`.${CONTENT_WRAPPER_CLASS}`)).toBeFalsy()
expect(viewer.textContent).toBe(translations.join("\n\n"))
expect(viewer).toHaveAttribute(TRANSLATION_ONLY_ATTRIBUTE)
})
it("translation only mode: restores the original single text node on toggle", async () => {
mockPerParagraphTranslations()
const viewer = renderPlainTextViewer()
await removeOrShowPageTranslation("translationOnly", true)
expect(viewer.childNodes.length).toBeGreaterThan(1)
await removeOrShowPageTranslation("translationOnly", true)
// Cuts rejoined: the viewer is one Text node holding the file again.
expect(viewer.textContent).toBe(storyText)
expect(viewer.childNodes).toHaveLength(1)
expect(viewer).not.toHaveAttribute(TRANSLATION_ONLY_ATTRIBUTE)
})
it("translation only mode: toggles off a generation that needed no text cuts", async () => {
// Both paragraphs are whole elements, so the units are carved out
// without a single splitText. Nothing about the container's own swaps
// or split records then records that it was segmented at all — and a
// toggle that cannot tell would translate its own output.
const elementParagraphs = ["First paragraph.", "Second paragraph."]
const elementTranslations = ["【第一段】", "【第二段】"]
vi.mocked(translateTextForPage).mockImplementation(async (text) => {
const index = elementParagraphs.indexOf(text)
if (index === -1) throw new Error(`Unexpected paragraph: ${JSON.stringify(text)}`)
return elementTranslations[index]!
})
Object.defineProperty(document, "contentType", {
value: "text/plain",
configurable: true,
})
render(
<pre data-testid="element-units" style={{ whiteSpace: "pre-wrap" }}>
<span>{elementParagraphs[0]}</span>
{"\n\n"}
<span>{elementParagraphs[1]}</span>
</pre>,
)
const viewer = screen.getByTestId("element-units")
const spans = [...viewer.querySelectorAll("span")]
await removeOrShowPageTranslation("translationOnly", true)
expect(translateTextForPage).toHaveBeenCalledTimes(2)
expect(viewer.textContent).toBe(elementTranslations.join("\n\n"))
await removeOrShowPageTranslation("translationOnly", true)
// Back to the source text, with no extra request spent on translating
// the translation, and every marker gone.
expect(translateTextForPage).toHaveBeenCalledTimes(2)
expect(viewer.textContent).toBe(elementParagraphs.join("\n\n"))
expect(viewer.querySelectorAll(`[${TRANSLATION_ONLY_ATTRIBUTE}]`)).toHaveLength(0)
expect(viewer).not.toHaveAttribute(TRANSLATION_ONLY_ATTRIBUTE)
expect(viewer.querySelector(`.${CONTENT_WRAPPER_CLASS}`)).toBeFalsy()
expect([...viewer.querySelectorAll("span")]).toEqual(spans)
})
it("translation only mode: leaves no trace when every paragraph echoes its source", async () => {
vi.mocked(translateTextForPage).mockImplementation(async (text) => text)
const viewer = renderPlainTextViewer()
await removeOrShowPageTranslation("translationOnly", true)
expect(viewer.textContent).toBe(storyText)
expect(viewer.childNodes).toHaveLength(1)
expect(viewer).not.toHaveAttribute(TRANSLATION_ONLY_ATTRIBUTE)
})
it("translation only mode: keeps a failing paragraph from taking the others down", async () => {
vi.mocked(translateTextForPage).mockImplementation(async (text) => {
if (text === paragraphs[1]) throw new Error("provider exploded")
const index = paragraphs.indexOf(text)
return translations[index]!
})
const viewer = renderPlainTextViewer()
await removeOrShowPageTranslation("translationOnly", true)
expect(viewer.textContent).toContain(translations[0])
expect(viewer.textContent).toContain(translations[2])
// The failed unit keeps its own source text and its error UI.
expect(viewer.textContent).toContain(paragraphs[1])
await waitForTranslationError(viewer)
})
})
describe("github diff table - should not translate review code snippets", () => {
it("bilingual mode: should keep github release attribution in translation source", async () => {
const originalLocation = window.location
@@ -487,3 +487,93 @@ describe("document root notranslate exemption", () => {
}
})
})
describe("plain-text document <pre> exemption", () => {
// A .txt URL renders as one browser-generated <pre> holding the entire file
// (Chrome also gives it `white-space: pre-wrap`), so the blanket PRE block
// leaves such a page with nothing to translate at all.
const STORY_TEXT = "First hard wrapped paragraph.\n\nSecond hard wrapped paragraph."
function withContentType(contentType: string, callback: () => void) {
Object.defineProperty(document, "contentType", { value: contentType, configurable: true })
try {
callback()
} finally {
// Restore the prototype getter jsdom installs (text/html in tests).
Reflect.deleteProperty(document, "contentType")
document.body.innerHTML = ""
}
}
function renderPlainTextViewer(): HTMLElement {
document.body.innerHTML = `<pre id="viewer">${STORY_TEXT}</pre>`
return document.getElementById("viewer")!
}
it("walks and labels the generated <pre> of a text/plain document", () => {
withContentType("text/plain", () => {
const viewer = renderPlainTextViewer()
walkAndLabelElement(document.body, "plain-text-pre", DEFAULT_CONFIG)
expect(viewer).toHaveAttribute(WALKED_ATTRIBUTE)
expect(viewer).toHaveAttribute(PARAGRAPH_ATTRIBUTE)
// PRE is in FORCE_BLOCK_TAGS, so the exempted viewer is a block unit.
expect(viewer).toHaveAttribute(BLOCK_ATTRIBUTE)
expect(extractTextContent(viewer, DEFAULT_CONFIG)).toContain("Second hard wrapped paragraph.")
})
})
it("keeps blocking an authored <pre> in an html document", () => {
const viewer = renderPlainTextViewer()
try {
walkAndLabelElement(document.body, "html-pre", DEFAULT_CONFIG)
expect(document.contentType).toBe("text/html")
expect(viewer).not.toHaveAttribute(WALKED_ATTRIBUTE)
expect(viewer).not.toHaveAttribute(PARAGRAPH_ATTRIBUTE)
expect(extractTextContent(viewer, DEFAULT_CONFIG)).toBe("")
} finally {
document.body.innerHTML = ""
}
})
it("lets a site rule that names PRE explicitly win over the exemption", () => {
// The exemption un-blocks what the defaults block, so an author who wants
// PRE blocked on a plain-text host must be able to say so.
withContentType("text/plain", () => {
const viewer = renderPlainTextViewer()
const config = configWithSiteRule({ "dontWalkTags.add": ["PRE"] })
walkAndLabelElement(document.body, "explicit-add", config)
expect(viewer).not.toHaveAttribute(WALKED_ATTRIBUTE)
expect(viewer).not.toHaveAttribute(PARAGRAPH_ATTRIBUTE)
})
})
it("still honors excludeSelectors on a plain-text document", () => {
withContentType("text/plain", () => {
const viewer = renderPlainTextViewer()
const config = configWithSiteRule({ excludeSelectors: ["pre"] })
walkAndLabelElement(document.body, "exclude-selector", config)
expect(viewer).not.toHaveAttribute(WALKED_ATTRIBUTE)
expect(viewer).not.toHaveAttribute(PARAGRAPH_ATTRIBUTE)
})
})
it("keeps other plain-text-ish document types blocked", () => {
for (const contentType of ["application/json", "text/markdown", "text/xml"]) {
withContentType(contentType, () => {
const viewer = renderPlainTextViewer()
walkAndLabelElement(document.body, `blocked-${contentType}`, DEFAULT_CONFIG)
expect(viewer).not.toHaveAttribute(PARAGRAPH_ATTRIBUTE)
})
}
})
})
+24 -1
View File
@@ -236,6 +236,27 @@ export function isDontWalkIntoButTranslateAsChildElement(
return dontWalkClass || dontWalkTag || dontWalkPreserveText
}
/**
* `PRE` is blocked by default because an authored `<pre>` in an HTML document
* holds code, logs or ASCII art, where translating would corrupt the content.
* A plain-text document is the opposite case: the browser renders a .txt URL as
* a single generated `<pre>` wrapping the whole file, so the blanket block
* leaves the page with no translatable content at all (reported on
* nifty.org story pages, which serve prose as text/plain).
*
* Only the exact `text/plain` type qualifies — JSON, markdown and XML viewers
* stay blocked. A site rule naming PRE in `dontWalkTags.add` still wins, since
* this exemption un-blocks what the defaults block; `excludeSelectors` remains
* available as the per-site escape hatch either way.
*/
function isPlainTextDocumentPre(element: HTMLElement, config: Config): boolean {
if (element.tagName !== "PRE" || element.ownerDocument.contentType !== "text/plain") {
return false
}
const { dontWalkTagsExplicitAdds } = getEffectiveSiteRule(config, window.location.href)
return !dontWalkTagsExplicitAdds?.has("PRE")
}
// https://github.com/mengxi-ream/read-frog/issues/940
function isInsideContentContainer(element: HTMLElement): boolean {
let current: HTMLElement | null = element.parentElement
@@ -255,7 +276,9 @@ export function isDontWalkIntoAndDontTranslateAsChildElement(
// Cheap structural predicates first; the getComputedStyle check runs last
// because it can force a style recalculation, and the full-page walk
// evaluates this predicate for every element (#1881).
const dontWalkInvalidTag = getEffectiveTagSet(config, "dontWalkTags").has(element.tagName)
const dontWalkInvalidTag =
getEffectiveTagSet(config, "dontWalkTags").has(element.tagName) &&
!isPlainTextDocumentPre(element, config)
if (dontWalkInvalidTag) return true
const dontWalkHidden = element.hidden
@@ -6,12 +6,17 @@ import { isSystemProviderRef, resolvePageTranslationProvider } from "@/utils/pro
import {
CONTENT_WRAPPER_CLASS,
NOTRANSLATE_CLASS,
PARAGRAPH_ATTRIBUTE,
TRANSLATION_ERROR_CONTAINER_CLASS,
TRANSLATION_MODE_ATTRIBUTE,
TRANSLATION_ONLY_ATTRIBUTE,
VIRTUAL_PARAGRAPH_ATTRIBUTE,
WALKED_ATTRIBUTE,
} from "../../../constants/dom-labels"
import {
GIANT_PARAGRAPH_SPLIT_MIN_VIEWPORT_PX,
GIANT_PARAGRAPH_SPLIT_VIEWPORT_MULTIPLIER,
} from "../../../constants/translate"
import { batchDOMOperation } from "../../dom/batch-dom"
import {
isBlockTransNode,
@@ -26,6 +31,7 @@ import { getOwnerDocument } from "../../dom/node"
import { extractTextContent } from "../../dom/traversal"
import {
buildVirtualParagraphPlan,
canMaterializeVirtualParagraphUnits,
isNewlinePreservingElement,
moveParagraphInsertionBoundaryAfterTrailingInlineImages,
type VirtualParagraphUnit,
@@ -37,17 +43,22 @@ import {
removeOrphanVirtualParagraphWrappers,
removeTranslatedWrapperWithRestore,
restoreTranslationOnlySwapsForAnchor,
teardownVirtualTranslationOnlyGeneration,
} from "../dom/translation-cleanup"
import { protectTranslationHtmlAttributes } from "../dom/translation-html-attributes"
import { insertTranslatedNodeIntoWrapper } from "../dom/translation-insertion"
import {
applyInPlaceTextSwap,
ensureTranslationOnlyAnchorState,
planInPlaceTextSwap,
snapshotSourceTextNodes,
verifySourceSnapshot,
} from "../dom/translation-text-swap"
import { findPreviousTranslatedWrapperInside } from "../dom/translation-wrapper"
import { insertVirtualParagraphWrappers } from "../dom/virtual-paragraph-insertion"
import {
insertVirtualParagraphWrappers,
materializeVirtualParagraphUnitRuns,
} from "../dom/virtual-paragraph-insertion"
import { shouldFilterSmallParagraph } from "../filter-small-paragraph"
import { isHtmlAttributeMarkerIntegrityError } from "../html-attribute-markers"
import { shouldSkipAsTargetLanguage } from "../target-language-skip"
@@ -82,6 +93,7 @@ import {
} from "./translation-state"
let virtualParagraphGroupSequence = 0
let virtualTranslationOnlyGenerationSequence = 0
const unsupportedDeepLXHtmlAttributeProviders = new Set<string>()
const supportedDeepLXHtmlAttributeProviders = new Set<string>()
type DeepLXHtmlAttributeProbeResult = "supported" | "unsupported" | "unknown"
@@ -701,12 +713,231 @@ function findRunTranslationOnlyWrapper(
return null
}
/**
* Entry point for translationOnly mode. Picks the granularity — one run for
* the whole request, or one run per blank-line paragraph — and leaves the
* translating to `translateTranslationOnlyRun`.
*/
export async function translateNodeTranslationOnlyMode(
nodes: ChildNode[],
walkId: string,
config: Config,
toggle: boolean = false,
forceRetranslation: boolean = false,
): Promise<void> {
const outerTransNodes = nodes.filter(isTransNode)
if (outerTransNodes.length === 0) return
const layoutSource = outerTransNodes[0]!
const isSingleBlockSource =
outerTransNodes.length === 1 && isHTMLElement(layoutSource) && isBlockTransNode(layoutSource)
if (isSingleBlockSource) {
const handled = await maybeTranslateVirtualUnitRuns(
layoutSource,
nodes,
walkId,
config,
toggle,
forceRetranslation,
)
if (handled) return
}
return translateTranslationOnlyRun(nodes, walkId, config, toggle, forceRetranslation)
}
/** A container taller than this is what the observer treats as a giant. */
function isGiantParagraphUnit(element: HTMLElement): boolean {
const maxUnitHeight =
Math.max(window.innerHeight, GIANT_PARAGRAPH_SPLIT_MIN_VIEWPORT_PX) *
GIANT_PARAGRAPH_SPLIT_VIEWPORT_MULTIPLIER
return element.getBoundingClientRect().height > maxUnitHeight
}
/** Labeled paragraphs directly under `container`, with no paragraph in between. */
function collectTopLevelParagraphDescendants(container: HTMLElement): HTMLElement[] {
return [...container.querySelectorAll<HTMLElement>(`[${PARAGRAPH_ATTRIBUTE}]`)].filter(
(paragraph) => {
const ancestor = paragraph.parentElement?.closest(`[${PARAGRAPH_ATTRIBUTE}]`)
return !ancestor || ancestor === container || !container.contains(ancestor)
},
)
}
/**
* A giant container whose plan cannot be materialized must NOT collapse into a
* single request. The observer only hands such a container over whole because
* the same predicate said its units were materializable; if the host reshaped
* the DOM in between, translating it as one run would ship a whole 22k-char
* note tweet in one payload and, on a failed alignment, displace every one of
* its framework-owned nodes into a wrapper. Translating each labeled paragraph
* separately reproduces what per-paragraph observation would have done.
*/
async function translateNonMaterializableGiant(
layoutSource: HTMLElement,
walkId: string,
config: Config,
forceRetranslation: boolean,
): Promise<boolean> {
if (!isGiantParagraphUnit(layoutSource)) return false
const paragraphs = collectTopLevelParagraphDescendants(layoutSource)
if (paragraphs.length === 0) return false
await Promise.allSettled(
paragraphs.map((paragraph) =>
translateTranslationOnlyRun([paragraph], walkId, config, false, forceRetranslation),
),
)
return true
}
/**
* Translate a newline-preserving container one blank-line paragraph at a time,
* matching the granularity bilingual mode gets from its virtual-paragraph plan.
*
* Each unit becomes a run of whole child nodes and goes through the ordinary
* per-run pipeline, so every unit gets its own request, its own small-paragraph
* and target-language filtering, its own spinner and error UI, and its own
* restore record. Returns false when this container is not one to segment, so
* the caller falls back to translating the request as a single run.
*/
async function maybeTranslateVirtualUnitRuns(
layoutSource: HTMLElement,
nodes: ChildNode[],
walkId: string,
config: Config,
toggle: boolean,
forceRetranslation: boolean,
): Promise<boolean> {
const previousState = getTranslationOnlyAnchorState(layoutSource)
const hasPreviousGeneration =
previousState !== undefined &&
(previousState.virtualGeneration !== undefined || (previousState.splitRecords?.length ?? 0) > 0)
if (hasPreviousGeneration) {
// Rebuild from scratch rather than patching a live generation: a plan built
// over half-restored text would read our own translations as source and
// send them back to the provider, and a second round of cuts over the first
// round's tails could no longer be rejoined.
teardownVirtualTranslationOnlyGeneration(layoutSource)
if (toggle) return true
} else if (
previousState !== undefined ||
layoutSource.querySelector(`.${CONTENT_WRAPPER_CLASS}`) !== null
) {
// This container was translated as a single run. Its own path knows how to
// restore or replace that, and segmenting on top of it would translate our
// own output.
return false
}
const plan = buildVirtualParagraphPlan(layoutSource, config)
if (plan.units.length < 2) return false
if (!canMaterializeVirtualParagraphUnits(layoutSource, plan, config)) {
return translateNonMaterializableGiant(layoutSource, walkId, config, forceRetranslation)
}
if (nodes.every((node) => translatingNodes.has(node))) return true
nodes.forEach((node) => translatingNodes.add(node))
try {
// Claim the anchor before cutting anything: from here on the marker is what
// lets a page-wide cleanup find these cuts, even if every unit then fails.
const state = ensureTranslationOnlyAnchorState(
layoutSource,
config,
getTranslationOnlyAnchorState,
)
state.splitRecords ??= []
virtualTranslationOnlyGenerationSequence += 1
const generation = virtualTranslationOnlyGenerationSequence
state.virtualGeneration = generation
const isCurrent = () =>
getTranslationOnlyAnchorState(layoutSource)?.virtualGeneration === generation
const runs = materializeVirtualParagraphUnitRuns(layoutSource, plan, config, state.splitRecords)
if (!runs) {
// The host reshaped the container between planning and cutting. Undo
// whatever was cut and let the single-run path have the request.
teardownVirtualTranslationOnlyGeneration(layoutSource)
return false
}
await Promise.allSettled(
runs.map((run) =>
translateTranslationOnlyRun(
run.nodes,
walkId,
config,
false,
forceRetranslation,
isCurrent,
),
),
)
// Queued, not called: each unit applies its swap through the same batch, so
// running now would read an empty anchor and undo the generation just
// before its own translations land.
batchDOMOperation(() => {
if (isCurrent()) endVirtualTranslationOnlyGeneration(layoutSource)
})
return true
} finally {
nodes.forEach((node) => translatingNodes.delete(node))
}
}
/**
* Close a finished generation. Units that landed keep their own swap records
* (on the container or, for a single-element unit, on that element), so the
* anchor stays. When nothing landed at all — every unit filtered out, or every
* translation equal to its source — the container is handed back exactly as it
* was found, cuts rejoined and marker removed.
*/
function endVirtualTranslationOnlyGeneration(layoutSource: HTMLElement): void {
const state = getTranslationOnlyAnchorState(layoutSource)
if (!state) return
const leftNoTrace =
state.swaps.length === 0 &&
layoutSource.querySelector(`[${TRANSLATION_ONLY_ATTRIBUTE}]`) === null &&
// An error UI still on screen belongs to a unit that has not resolved for
// the user yet; rejoining its cuts would pull the message out from under it.
layoutSource.querySelector(`.${CONTENT_WRAPPER_CLASS}`) === null
if (leftNoTrace) {
// Nothing landed. A full restore clears the generation, rejoins whatever
// was cut and hands the marker back, leaving the container as found.
restoreTranslationOnlySwapsForAnchor(layoutSource)
return
}
// Something landed, so the generation stays recorded. It is the only durable
// sign that this container is segmented: a unit that is a whole element
// registers its record on that element, and such a generation may not have
// cut anything at all, so neither the container's own swaps nor its split
// records can tell a later toggle what this is. Cleared by the full restore
// that ends the generation.
}
/**
* Translate one run of sibling nodes: protect attributes, send the run's HTML
* as a single request, then swap the translation into the site's own text
* nodes (falling back to a wrapper when the response cannot be aligned).
*
* `isCurrent` lets a caller that owns several runs invalidate the ones still
* in flight — the virtual-paragraph path uses it so a unit whose generation
* was torn down mid-request cannot write its response into restored text.
*/
async function translateTranslationOnlyRun(
nodes: ChildNode[],
walkId: string,
config: Config,
toggle: boolean = false,
forceRetranslation: boolean = false,
isCurrent: () => boolean = () => true,
): Promise<void> {
const isTransNodeAndNotTranslatedWrapper = (node: Node): node is TransNode => {
if (isHTMLElement(node) && node.classList.contains(CONTENT_WRAPPER_CLASS)) return false
@@ -749,13 +980,7 @@ export async function translateNodeTranslationOnlyMode(
if (!toggle) {
const retryNodes = restored.filter((node) => node.isConnected)
if (retryNodes.length > 0) {
void translateNodeTranslationOnlyMode(
retryNodes,
walkId,
config,
toggle,
forceRetranslation,
)
void translateTranslationOnlyRun(retryNodes, walkId, config, toggle, forceRetranslation)
}
}
return
@@ -795,9 +1020,10 @@ export async function translateNodeTranslationOnlyMode(
// wrapper is always inserted as a sibling within the run or appended into
// a single-element run — a deep subtree query would steal a NESTED run's
// wrapper (e.g. a li's) and leave this run's state untouched (#1846 review).
const existedTranslatedWrapperOutside = targetNode.parentElement.closest(
`.${CONTENT_WRAPPER_CLASS}`,
)
// Reuse the parent captured above: the restore-first pass may have taken
// targetNode out of the document, and re-reading its parentElement would
// throw on the null.
const existedTranslatedWrapperOutside = parentNode.closest(`.${CONTENT_WRAPPER_CLASS}`)
const finalTranslatedWrapper =
existedTranslatedWrapperOutside ?? findRunTranslationOnlyWrapper(allChildNodes, walkId)
if (finalTranslatedWrapper && isHTMLElement(finalTranslatedWrapper)) {
@@ -816,13 +1042,7 @@ export async function translateNodeTranslationOnlyMode(
? nodes
: restoredNodes.filter((node) => node.isConnected)
if (retryNodes.length > 0) {
void translateNodeTranslationOnlyMode(
retryNodes,
walkId,
config,
toggle,
forceRetranslation,
)
void translateTranslationOnlyRun(retryNodes, walkId, config, toggle, forceRetranslation)
}
return
}
@@ -935,7 +1155,7 @@ export async function translateNodeTranslationOnlyMode(
textContent,
spinner,
translatedWrapperNode,
() => true,
isCurrent,
"html",
translateRequest,
)
@@ -966,7 +1186,9 @@ export async function translateNodeTranslationOnlyMode(
batchDOMOperation(() => {
// Wrapper gone: a global cleanup ran while the provider call was in
// flight, or the host re-rendered the region — leave originals alone.
if (!translatedWrapperNode.isConnected) return
// A superseded run (its generation torn down) is stale for the same
// reason even when its wrapper survived the round trip.
if (!translatedWrapperNode.isConnected || !isCurrent()) return
markExtensionDrivenNodeRemoval(translatedWrapperNode)
translatedWrapperNode.remove()
// Host mutated the run mid-flight: the translation is stale, drop it.
@@ -994,7 +1216,7 @@ export async function translateNodeTranslationOnlyMode(
// Wrapper gone from the document: a global cleanup ran while the provider
// call was in flight, or the host re-rendered the region. The originals
// are the live content — don't remove them to apply a stale translation.
if (!translatedWrapperNode.isConnected) return
if (!translatedWrapperNode.isConnected || !isCurrent()) return
// Insert translated content after the last node
const lastChildNode = allChildNodes.at(-1)!
@@ -111,6 +111,22 @@ export interface TranslationOnlyAnchorState {
// guardedly when the last swap is undone.
attributeAdjustments: { name: string; previousValue: string | null }[]
swaps: TranslationOnlySwapRecord[]
// Set only on a virtual-paragraph anchor: the Text cuts that turned the
// container's blank-line paragraphs into per-unit runs. Rejoined when the
// generation ends, AFTER its swaps are restored — restoreTextSplit compares
// the rejoined value against the original, so translated text still in place
// would make it refuse.
splitRecords?: TextSplitRecord[]
// Present from the moment a virtual-paragraph generation starts until a full
// restore ends it, and bumped for each new one. Three jobs: the anchor must
// not finalize mid-generation (units anchor their own records on descendants,
// so `swaps` is legitimately empty in between); a unit's late provider
// response can tell whether its generation is still the current one; and,
// once the units have settled, this is the only durable sign that the
// container is segmented at all — a generation whose units are whole
// elements registers every record on a descendant and may cut nothing, so
// neither `swaps` nor `splitRecords` can answer that question.
virtualGeneration?: number
}
const translationOnlyAnchorStates = new WeakMap<HTMLElement, TranslationOnlyAnchorState>()
@@ -1,3 +1,4 @@
import type { TextSplitRecord } from "../../core/translation-state"
// @vitest-environment jsdom
import type { Config } from "@/types/config/config"
import { beforeEach, describe, expect, it } from "vitest"
@@ -5,12 +6,15 @@ import { DEFAULT_CONFIG } from "@/utils/constants/config"
import { CONTENT_WRAPPER_CLASS } from "@/utils/constants/dom-labels"
import { walkAndLabelElement } from "@/utils/host/dom/traversal"
import {
buildVirtualParagraphPlan,
buildVirtualParagraphUnits,
canMaterializeVirtualParagraphUnits,
canSplitParagraphIntoDescendants,
liftParagraphInsertionBoundary,
moveParagraphInsertionBoundaryAfterTrailingInlineImages,
type DOMBoundary,
} from "../paragraph-segmentation"
import { materializeVirtualParagraphUnitRuns } from "../virtual-paragraph-insertion"
function setHost(host: string): void {
Object.defineProperty(window, "location", {
@@ -443,4 +447,213 @@ describe("canSplitParagraphIntoDescendants", () => {
expect(canSplitParagraphIntoDescendants(root, spans, DEFAULT_CONFIG)).toBe(true)
})
describe("translation only mode", () => {
const TRANSLATION_ONLY_CONFIG: Config = {
...DEFAULT_CONFIG,
pageTranslation: { ...DEFAULT_CONFIG.pageTranslation, mode: "translationOnly" },
}
it("refuses the split when the units can be cut into whole nodes", () => {
// Observed whole, the container-level plan gives one request per
// paragraph; split per span, the blank-line structure is lost.
const root = walkedFixture(
"<span>First paragraph</span>\n\n<span>Second paragraph</span>",
"pre-wrap",
)
const spans = [...root.querySelectorAll("span")]
expect(canSplitParagraphIntoDescendants(root, spans, TRANSLATION_ONLY_CONFIG)).toBe(false)
})
it("keeps per-span observation when the blank lines sit inside a span", () => {
// The X note tweet: translationOnly cannot cut a unit out of a span, so
// observing the container whole would only cost it the granularity and
// the viewport gating it has today.
const root = walkedFixture(
"<span>First paragraph\n\nSecond paragraph</span><span>Bold heading</span>",
"pre-wrap",
)
const spans = [...root.querySelectorAll("span")]
expect(canSplitParagraphIntoDescendants(root, spans, TRANSLATION_ONLY_CONFIG)).toBe(true)
// Bilingual can interleave a wrapper at any boundary, so it still
// refuses the split for the very same container.
expect(canSplitParagraphIntoDescendants(root, spans, DEFAULT_CONFIG)).toBe(false)
})
})
})
describe("virtual paragraph unit runs", () => {
function planFixture(
build: (root: HTMLElement) => void,
config: Config = DEFAULT_CONFIG,
whiteSpace: string = "pre-wrap",
) {
const root = createRoot(whiteSpace)
build(root)
document.body.appendChild(root)
const plan = buildVirtualParagraphPlan(root, config)
return { root, plan }
}
describe("canMaterializeVirtualParagraphUnits", () => {
it("accepts blank lines in a top-level text node (plain-text document)", () => {
// A text/plain page is exactly this: one Text node holding the file.
const { root, plan } = planFixture((node) => {
node.textContent = "One\n\nTwo\n\nThree"
})
expect(plan.units).toHaveLength(3)
expect(canMaterializeVirtualParagraphUnits(root, plan, DEFAULT_CONFIG)).toBe(true)
})
it("accepts units delimited between elements by a top-level blank line", () => {
const { root, plan } = planFixture((node) => {
node.innerHTML = "<span>First paragraph</span>\n\n<span><b>Bold</b> heading</span>"
})
expect(plan.units).toHaveLength(2)
expect(canMaterializeVirtualParagraphUnits(root, plan, DEFAULT_CONFIG)).toBe(true)
})
it("refuses blank lines nested inside an inline element (X note tweet)", () => {
// https://x.com/davidjpark96/status/1789773192435060737 — cutting a unit
// out here would mean splitting the span and its styling apart.
const { root, plan } = planFixture((node) => {
node.innerHTML = "<span>First paragraph\n\nSecond paragraph</span><span>Bold heading</span>"
})
expect(plan.units).toHaveLength(2)
expect(canMaterializeVirtualParagraphUnits(root, plan, DEFAULT_CONFIG)).toBe(false)
})
it("refuses a unit that ends inside a span with the delimiter trailing it", () => {
// X "show more" shape: the expanded span keeps the blank line at its end
// and the next paragraph arrives as top-level anchors.
const { root, plan } = planFixture((node) => {
node.innerHTML = "<span>Opening line.</span><span>\nMore text.\n\n</span>"
node.append(Object.assign(document.createElement("a"), { textContent: "#Football" }))
})
expect(plan.units.length).toBeGreaterThanOrEqual(2)
expect(canMaterializeVirtualParagraphUnits(root, plan, DEFAULT_CONFIG)).toBe(false)
})
it("refuses a delimiter that crosses an element boundary", () => {
// Half the blank line is top-level, half lives inside the span.
const { root, plan } = planFixture((node) => {
node.append(document.createTextNode("First\n"))
const span = document.createElement("span")
span.textContent = "\nSecond"
node.append(span)
})
expect(plan.units).toHaveLength(2)
expect(canMaterializeVirtualParagraphUnits(root, plan, DEFAULT_CONFIG)).toBe(false)
})
it("refuses a unit whose range contains a textless element", () => {
// The image contributes no text, so shipping it inside the request would
// make alignment depend on the provider echoing the tag back.
const { root, plan } = planFixture((node) => {
node.innerHTML =
'<span>First</span><img alt="emoji" src="e.svg"><span>still first</span>\n\n<span>Second</span>'
})
expect(plan.units).toHaveLength(2)
expect(canMaterializeVirtualParagraphUnits(root, plan, DEFAULT_CONFIG)).toBe(false)
})
it("keeps a textless element between units out of every unit", () => {
const { root, plan } = planFixture((node) => {
node.innerHTML = '<span>First</span><img alt="emoji" src="e.svg">\n\n<span>Second</span>'
})
expect(canMaterializeVirtualParagraphUnits(root, plan, DEFAULT_CONFIG)).toBe(true)
const runs = materializeVirtualParagraphUnitRuns(root, plan, DEFAULT_CONFIG)!
expect(runs).toHaveLength(2)
expect(runs.flatMap((run) => run.nodes)).not.toContain(root.querySelector("img"))
})
})
describe("materializeVirtualParagraphUnitRuns", () => {
it("cuts a top-level text node into one run per paragraph", () => {
const { root, plan } = planFixture((node) => {
node.textContent = "One\n\nTwo\n\nThree"
})
const source = root.firstChild as Text
const splitRecords: TextSplitRecord[] = []
const runs = materializeVirtualParagraphUnitRuns(root, plan, DEFAULT_CONFIG, splitRecords)!
expect(runs.map((run) => run.nodes.map((node) => node.textContent).join(""))).toEqual([
"One",
"Two",
"Three",
])
// The delimiters stay in their own nodes, outside every run.
expect(root.textContent).toBe("One\n\nTwo\n\nThree")
expect([...root.childNodes].map((node) => node.textContent)).toEqual([
"One",
"\n\n",
"Two",
"\n\n",
"Three",
])
// One record for the one source node, tails in final DOM order.
expect(splitRecords).toHaveLength(1)
expect(splitRecords[0]!.source).toBe(source)
expect(splitRecords[0]!.originalValue).toBe("One\n\nTwo\n\nThree")
expect(splitRecords[0]!.createdTails.map((tail) => tail.data)).toEqual([
"\n\n",
"Two",
"\n\n",
"Three",
])
expect(splitRecords[0]!.sourceValueAfterSplit).toBe("One")
})
it("returns element runs without cutting anything", () => {
const { root, plan } = planFixture((node) => {
node.innerHTML = "<span>First paragraph</span>\n\n<span><b>Bold</b> heading</span>"
})
const spans = [...root.querySelectorAll("span")]
const splitRecords: TextSplitRecord[] = []
const runs = materializeVirtualParagraphUnitRuns(root, plan, DEFAULT_CONFIG, splitRecords)!
expect(runs.map((run) => run.nodes)).toEqual([[spans[0]], [spans[1]]])
expect(splitRecords).toHaveLength(0)
// Element identity is untouched, so the rich-text markup survives.
expect(root.querySelector("b")?.textContent).toBe("Bold")
})
it("keeps runs disjoint when one unit starts mid-node and ends at an element", () => {
const { root, plan } = planFixture((node) => {
node.append(document.createTextNode("First\n\nSecond "))
const span = document.createElement("span")
span.textContent = "tail"
node.append(span)
})
const runs = materializeVirtualParagraphUnitRuns(root, plan, DEFAULT_CONFIG)!
expect(runs).toHaveLength(2)
expect(runs[0]!.nodes.map((node) => node.textContent)).toEqual(["First"])
expect(runs[1]!.nodes.map((node) => node.textContent)).toEqual(["Second ", "tail"])
expect(new Set(runs.flatMap((run) => run.nodes)).size).toBe(3)
expect(root.textContent).toBe("First\n\nSecond tail")
})
it("returns null for a plan it cannot express as whole nodes", () => {
const { root, plan } = planFixture((node) => {
node.innerHTML = "<span>First paragraph\n\nSecond paragraph</span>"
})
expect(materializeVirtualParagraphUnitRuns(root, plan, DEFAULT_CONFIG)).toBeNull()
expect(root.innerHTML).toBe("<span>First paragraph\n\nSecond paragraph</span>")
})
})
})
@@ -1,10 +1,18 @@
import type { VirtualParagraphUnit } from "../paragraph-segmentation"
// @vitest-environment jsdom
import type { TransNode } from "@/types/dom"
import { beforeEach, describe, expect, it } from "vitest"
import { CONTENT_WRAPPER_CLASS, NOTRANSLATE_CLASS } from "@/utils/constants/dom-labels"
import { DEFAULT_CONFIG } from "@/utils/constants/config"
import {
CONTENT_WRAPPER_CLASS,
NOTRANSLATE_CLASS,
TRANSLATION_MODE_ATTRIBUTE,
TRANSLATION_ONLY_ATTRIBUTE,
} from "@/utils/constants/dom-labels"
import {
collectSourceTextExcludingWrappers,
getBilingualTranslationStateForSource,
getTranslationOnlyAnchorState,
getVirtualParagraphGroupForSource,
getVirtualParagraphGroupForWrapper,
isBilingualTranslationStateCurrent,
@@ -15,12 +23,20 @@ import {
type BilingualTranslationState,
type VirtualParagraphGroup,
} from "../../core/translation-state"
import { buildVirtualParagraphPlan } from "../paragraph-segmentation"
import {
disposeVirtualParagraphGroup,
dropTranslationOnlySwapRecordsForNodes,
dropVirtualParagraphWrapper,
removeAllTranslatedWrapperNodes,
restoreTranslationOnlySwapsForAnchor,
teardownVirtualTranslationOnlyGeneration,
} from "../translation-cleanup"
import { insertVirtualParagraphWrappers } from "../virtual-paragraph-insertion"
import { applyInPlaceTextSwap, ensureTranslationOnlyAnchorState } from "../translation-text-swap"
import {
insertVirtualParagraphWrappers,
materializeVirtualParagraphUnitRuns,
} from "../virtual-paragraph-insertion"
function unit(id: number, source: Text, offset: number): VirtualParagraphUnit {
return {
@@ -411,6 +427,221 @@ describe("virtual paragraph lifecycle", () => {
disposeVirtualParagraphGroup(group)
})
describe("translationOnly virtual generation teardown", () => {
// A translationOnly generation has no wrapper of its own: each unit is a
// run of whole child nodes whose text was swapped in place, with the Text
// cuts that made them whole nodes owned by the container's anchor state.
function createSwappedGeneration(storyText: string) {
const layoutSource = document.createElement("div")
layoutSource.style.whiteSpace = "pre-wrap"
layoutSource.textContent = storyText
document.body.append(layoutSource)
const plan = buildVirtualParagraphPlan(layoutSource, DEFAULT_CONFIG)
const state = ensureTranslationOnlyAnchorState(
layoutSource,
DEFAULT_CONFIG,
getTranslationOnlyAnchorState,
)
state.splitRecords = []
state.virtualGeneration = 1
const runs = materializeVirtualParagraphUnitRuns(
layoutSource,
plan,
DEFAULT_CONFIG,
state.splitRecords,
)!
return { layoutSource, runs, state }
}
function swapRun(
layoutSource: HTMLElement,
run: { nodes: ChildNode[] },
translation: string,
walkId: string = "generation-1",
) {
const node = run.nodes[0] as Text
applyInPlaceTextSwap(
{ pairs: [{ node, translatedValue: translation }], attributePairs: [], coverage: 1 },
run.nodes as TransNode[],
layoutSource,
walkId,
DEFAULT_CONFIG,
getTranslationOnlyAnchorState,
)
}
it("restores swapped text and rejoins the cuts", () => {
const storyText = "First paragraph.\n\nSecond paragraph."
const { layoutSource, runs } = createSwappedGeneration(storyText)
swapRun(layoutSource, runs[0]!, "【第一段】")
swapRun(layoutSource, runs[1]!, "【第二段】")
expect(layoutSource.textContent).toBe("【第一段】\n\n【第二段】")
teardownVirtualTranslationOnlyGeneration(layoutSource)
expect(layoutSource.textContent).toBe(storyText)
// The cuts are rejoined, so the container is back to a single Text node.
expect(layoutSource.childNodes).toHaveLength(1)
expect(layoutSource).not.toHaveAttribute(TRANSLATION_ONLY_ATTRIBUTE)
expect(getTranslationOnlyAnchorState(layoutSource)).toBeUndefined()
})
it("clears a unit wrapper that would otherwise block the rejoin", () => {
// A unit still awaiting its provider holds a spinner wrapper sitting
// between a cut source and its tail; leaving it there would make
// restoreTextSplit refuse and strand the split nodes forever.
const storyText = "First paragraph.\n\nSecond paragraph."
const { layoutSource, runs } = createSwappedGeneration(storyText)
swapRun(layoutSource, runs[0]!, "【第一段】")
const pendingWrapper = document.createElement("span")
pendingWrapper.className = CONTENT_WRAPPER_CLASS
pendingWrapper.setAttribute(TRANSLATION_MODE_ATTRIBUTE, "translationOnly")
runs[1]!.nodes[0]!.after(pendingWrapper)
teardownVirtualTranslationOnlyGeneration(layoutSource)
expect(pendingWrapper.isConnected).toBe(false)
expect(layoutSource.textContent).toBe(storyText)
expect(layoutSource.childNodes).toHaveLength(1)
})
it("restores a unit whose record lives on a descendant anchor", () => {
// A unit that is a single element unwraps into that element, so its swap
// record anchors there — an ancestor-only lookup would never find it.
const layoutSource = document.createElement("div")
layoutSource.style.whiteSpace = "pre-wrap"
layoutSource.innerHTML = "<span>First paragraph.</span>\n\n<span>Second paragraph.</span>"
document.body.append(layoutSource)
const [firstSpan, secondSpan] = [...layoutSource.querySelectorAll("span")]
const state = ensureTranslationOnlyAnchorState(
layoutSource,
DEFAULT_CONFIG,
getTranslationOnlyAnchorState,
)
state.virtualGeneration = 1
const secondText = secondSpan!.firstChild as Text
applyInPlaceTextSwap(
{
pairs: [{ node: secondText, translatedValue: "【第二段】" }],
attributePairs: [],
coverage: 1,
},
[secondText],
secondSpan!,
"generation-1",
DEFAULT_CONFIG,
getTranslationOnlyAnchorState,
)
expect(secondSpan).toHaveAttribute(TRANSLATION_ONLY_ATTRIBUTE)
teardownVirtualTranslationOnlyGeneration(layoutSource)
expect(secondSpan!.textContent).toBe("Second paragraph.")
expect(secondSpan).not.toHaveAttribute(TRANSLATION_ONLY_ATTRIBUTE)
expect(layoutSource).not.toHaveAttribute(TRANSLATION_ONLY_ATTRIBUTE)
expect(firstSpan!.textContent).toBe("First paragraph.")
})
it("keeps the anchor alive when a sibling run empties it mid-generation", () => {
// Between units the container's own swap list is legitimately empty. A
// unit that falls back to a wrapper drops its records through here, and
// finalizing on that would rejoin the cuts under the pending units' feet.
const { layoutSource, runs, state } = createSwappedGeneration("First.\n\nSecond.")
dropTranslationOnlySwapRecordsForNodes(layoutSource, runs[0]!.nodes)
expect(getTranslationOnlyAnchorState(layoutSource)).toBe(state)
expect(layoutSource).toHaveAttribute(TRANSLATION_ONLY_ATTRIBUTE)
expect(layoutSource.childNodes.length).toBeGreaterThan(1)
teardownVirtualTranslationOnlyGeneration(layoutSource)
expect(getTranslationOnlyAnchorState(layoutSource)).toBeUndefined()
expect(layoutSource.childNodes).toHaveLength(1)
})
it("ends the generation when the whole anchor is restored", () => {
// A restore with nothing held back is the deliberate end of a generation
// (a toggle, or a page-wide cleanup), so it must release the anchor even
// though the generation is still marked as live.
const { layoutSource } = createSwappedGeneration("First.\n\nSecond.")
restoreTranslationOnlySwapsForAnchor(layoutSource)
expect(getTranslationOnlyAnchorState(layoutSource)).toBeUndefined()
expect(layoutSource).not.toHaveAttribute(TRANSLATION_ONLY_ATTRIBUTE)
expect(layoutSource.childNodes).toHaveLength(1)
})
it("never deletes the remaining paragraphs when a cut source was displaced", () => {
// The fallback strategy parks a unit's original nodes inside its wrapper,
// and the first unit's node is the source every later unit was cut from.
// Reading that detached source as a host rewrite would delete every tail
// still holding its post-split value — which here is the rest of the file.
const storyText = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph."
const { layoutSource, runs, state } = createSwappedGeneration(storyText)
const displacedSource = runs[0]!.nodes[0] as Text
const survivingText = "Second paragraph.\n\nThird paragraph."
// Unit 1 could not be aligned: the wrapper takes its place and the
// original node is detached, held only by the restore registry.
const fallbackWrapper = document.createElement("span")
fallbackWrapper.className = CONTENT_WRAPPER_CLASS
fallbackWrapper.setAttribute(TRANSLATION_MODE_ATTRIBUTE, "translationOnly")
layoutSource.insertBefore(fallbackWrapper, displacedSource)
displacedSource.remove()
// Units 2 and 3 failed at the provider, so the container holds no swaps.
expect(state.swaps).toHaveLength(0)
state.virtualGeneration = undefined
restoreTranslationOnlySwapsForAnchor(layoutSource)
expect(layoutSource.textContent).toContain(survivingText)
expect(layoutSource.textContent).toContain("Second paragraph.")
expect(layoutSource.textContent).toContain("Third paragraph.")
})
it("is covered by a page-wide cleanup", () => {
const storyText = "First paragraph.\n\nSecond paragraph."
const { layoutSource, runs, state } = createSwappedGeneration(storyText)
swapRun(layoutSource, runs[0]!, "【第一段】")
swapRun(layoutSource, runs[1]!, "【第二段】")
// The generation is deliberately left LIVE: stopping the page (or
// switching modes) while a unit is still awaiting its provider must
// release the container anyway, or the marker, the dir/lang and the cuts
// outlive the session and the next walk skips the region for good.
expect(state.virtualGeneration).toBeDefined()
removeAllTranslatedWrapperNodes(document)
expect(layoutSource.textContent).toBe(storyText)
expect(layoutSource.childNodes).toHaveLength(1)
expect(layoutSource).not.toHaveAttribute(TRANSLATION_ONLY_ATTRIBUTE)
expect(getTranslationOnlyAnchorState(layoutSource)).toBeUndefined()
})
it("releases a live generation whose units never resolved", () => {
// The harshest version of the same stop: no unit ever swapped, so the
// container holds only the marker and the cuts, and a hung provider
// request means nothing will ever come back to release them.
const storyText = "First paragraph.\n\nSecond paragraph."
const { layoutSource, state } = createSwappedGeneration(storyText)
expect(state.virtualGeneration).toBeDefined()
expect(layoutSource.childNodes.length).toBeGreaterThan(1)
removeAllTranslatedWrapperNodes(document)
expect(layoutSource.textContent).toBe(storyText)
expect(layoutSource.childNodes).toHaveLength(1)
expect(layoutSource).not.toHaveAttribute(TRANSLATION_ONLY_ATTRIBUTE)
expect(getTranslationOnlyAnchorState(layoutSource)).toBeUndefined()
})
})
it("cancels pending groups inside an attached shadow root during document cleanup", () => {
const host = document.createElement("div")
const shadowRoot = host.attachShadow({ mode: "open" })
@@ -51,6 +51,13 @@ export function isNewlinePreservingElement(element: HTMLElement): boolean {
* request — losing viewport gating and risking provider length limits.
* The same plan builder the translate path uses makes the decision, so the
* observation-time judgment cannot drift from translate-time behavior.
*
* translationOnly asks for one thing more. It segments by cutting the units
* apart into whole child nodes, which a plan whose blank lines sit inside an
* inline element cannot express — precisely the X note tweet above. Observing
* such a container whole would gain it nothing and cost it the per-span
* granularity it has today, so the refusal additionally requires the units to
* be materializable, decided by the same predicate the translate path uses.
*/
export function canSplitParagraphIntoDescendants(
element: HTMLElement,
@@ -59,7 +66,13 @@ export function canSplitParagraphIntoDescendants(
): boolean {
if (!isNewlinePreservingElement(element)) return true
if (descendantParagraphs.some((paragraph) => isBlockTransNode(paragraph))) return true
return buildVirtualParagraphPlan(element, config).units.length < 2
const plan = buildVirtualParagraphPlan(element, config)
if (plan.units.length < 2) return true
if (config.pageTranslation.mode === "translationOnly") {
return !canMaterializeVirtualParagraphUnits(element, plan, config)
}
return false
}
const BLANK_LINE_DELIMITER_RE = /(?:\r\n?|\n)[^\S\r\n]*(?:\r\n?|\n)(?:[^\S\r\n]*(?:\r\n?|\n))*/g
@@ -547,3 +560,155 @@ export function buildVirtualParagraphUnits(
): VirtualParagraphUnit[] {
return buildVirtualParagraphPlan(layoutSource, config).units
}
/**
* Where one unit's text starts and ends among the layout source's own children.
* `startOffset` / `endOffset` are non-null only when the edge falls strictly
* inside a top-level Text node, i.e. exactly where a `splitText` is needed to
* turn the unit into whole nodes.
*/
export interface VirtualParagraphUnitEdges {
unit: VirtualParagraphUnit
startNode: ChildNode
startOffset: number | null
endNode: ChildNode
endOffset: number | null
}
function boundaryBeforeNode(node: Node): DOMBoundary | undefined {
const parent = node.parentNode
if (!parent) return undefined
const index = [...parent.childNodes].indexOf(node as ChildNode)
if (index === -1) return undefined
return { container: parent, offset: index }
}
function fragmentEdgeBoundaries(
fragment: VirtualParagraphSourceFragment,
): { start: DOMBoundary; end: DOMBoundary } | undefined {
if (isTextNode(fragment.source)) {
return {
start: { container: fragment.source, offset: fragment.startOffset },
end: { container: fragment.source, offset: fragment.endOffset },
}
}
// An atomic element (a preserve-text mention, <code>, <time>) contributes its
// whole text, so the unit's edge sits beside the element rather than inside it.
const start = boundaryBeforeNode(fragment.source)
const end = boundaryAfterElement(fragment.source)
return start && end ? { start, end } : undefined
}
/**
* An element the raw-source collector treats as a barrier contributes no text,
* so a unit whose node range contains one would ship that element's markup to
* the provider and depend on it echoing the tag back for alignment. Refusing
* such units keeps them on the pre-existing whole-run path.
*/
function contributesNoText(node: ChildNode, config: Config): boolean {
return (
isHTMLElement(node) &&
(isTranslatedWrapperNode(node) ||
isTranslatedContentNode(node) ||
isDontWalkIntoAndDontTranslateAsChildElement(node, config))
)
}
function unitRangeNodes(
startNode: ChildNode,
endNode: ChildNode,
config: Config,
): ChildNode[] | null {
const nodes: ChildNode[] = []
let current: ChildNode | null = startNode
while (current) {
if (contributesNoText(current, config)) return null
nodes.push(current)
if (current === endNode) return nodes
current = current.nextSibling
}
// endNode is not a following sibling of startNode: the unit does not map onto
// one contiguous run of children.
return null
}
/**
* Resolve every unit of `plan` onto whole children of `layoutSource`, or return
* `null` when even one unit cannot be expressed that way.
*
* A unit is resolvable when both of its content edges, after the usual edge
* lifting, land either between two children or strictly inside a top-level Text
* node (which a `splitText` can cut). An edge stuck inside a nested element is
* not: X puts tweet text in inline `<span>`s, so a note tweet's blank lines sit
* *inside* a span, and cutting the unit out would mean splitting that element
* and its styling apart. Those containers keep the pre-existing behavior.
*
* This is the single decision both the observation gate and the translate path
* consult, so what the observer refuses to split is exactly what the translate
* path can segment — the invariant `canSplitParagraphIntoDescendants` documents.
*/
export function resolveVirtualParagraphUnitEdges(
layoutSource: HTMLElement,
plan: VirtualParagraphPlan,
config: Config,
): VirtualParagraphUnitEdges[] | null {
if (plan.units.length < 2) return null
const resolved: VirtualParagraphUnitEdges[] = []
for (const unit of plan.units) {
const firstFragment = unit.sourceFragments[0]
const lastFragment = unit.sourceFragments.at(-1)
if (!firstFragment || !lastFragment) return null
const startBoundaries = fragmentEdgeBoundaries(firstFragment)
const endBoundaries = fragmentEdgeBoundaries(lastFragment)
if (!startBoundaries || !endBoundaries) return null
const start = liftEdgeBoundary(startBoundaries.start, layoutSource)
const end = liftEdgeBoundary(endBoundaries.end, layoutSource)
// Concrete node references, captured before any split: a later split only
// inserts siblings, so a captured node stays valid while a child index
// would silently shift.
const startNode =
start.container === layoutSource
? (layoutSource.childNodes[start.offset] ?? null)
: isTextNode(start.container) && start.container.parentNode === layoutSource
? start.container
: null
const endNode =
end.container === layoutSource
? (layoutSource.childNodes[end.offset - 1] ?? null)
: isTextNode(end.container) && end.container.parentNode === layoutSource
? end.container
: null
if (!startNode || !endNode) return null
const nodes = unitRangeNodes(startNode, endNode, config)
if (!nodes || nodes.length === 0) return null
resolved.push({
unit,
startNode,
// Edge lifting already pulled offset 0 and end-of-node offsets out to the
// parent, so a surviving Text container always needs a real cut.
startOffset: start.container === startNode ? start.offset : null,
endNode,
endOffset: end.container === endNode ? end.offset : null,
})
}
return resolved
}
/**
* True when every unit of `plan` maps onto whole children of `layoutSource`.
* See `resolveVirtualParagraphUnitEdges` for what that requires.
*/
export function canMaterializeVirtualParagraphUnits(
layoutSource: HTMLElement,
plan: VirtualParagraphPlan,
config: Config,
): boolean {
return resolveVirtualParagraphUnitEdges(layoutSource, plan, config) !== null
}
@@ -239,6 +239,24 @@ function restoreSwapRecord(record: TranslationOnlySwapRecord): void {
function finalizeTranslationOnlyAnchorIfEmpty(state: TranslationOnlyAnchorState): void {
if (state.swaps.length > 0) return
// A live virtual generation owns the anchor even with no swaps of its own:
// single-element units register their records on descendant anchors, and
// units still awaiting a provider need the marker and the Text cuts to stay.
if (state.virtualGeneration !== undefined) return
// Swaps are restored by now, so the cuts can be rejoined: restoreTextSplit
// only rejoins when the fragments still add up to the original value.
//
// A cut whose source is no longer in the document is skipped rather than
// rejoined. restoreTextSplit reads a detached source as "the host replaced
// it" and deletes every tail that still holds its post-split value — right
// for a framework rewrite, catastrophic here, because a fallback wrapper
// parks the displaced original while its tails are the container's remaining
// paragraphs. Leaving adjacent Text nodes behind is invisible; deleting the
// rest of the page is not.
state.splitRecords?.forEach((record) => {
if (record.source.isConnected) restoreTextSplit(record)
})
state.splitRecords = undefined
for (const { name, previousValue } of state.attributeAdjustments) {
if (previousValue === null) state.anchor.removeAttribute(name)
else state.anchor.setAttribute(name, previousValue)
@@ -292,6 +310,13 @@ export function restoreTranslationOnlySwapsForAnchor(
return false
}
// A full restore that keeps nothing is the end of a virtual generation, so
// release it here rather than at each call site: the anchor may only finalize
// once no unit can still be waiting on it.
if (!filterNodes && !options?.keepRecords) {
state.virtualGeneration = undefined
}
// Records whose every node the host disconnected are unrestorable debris;
// letting them linger would pin detached subtrees and hold the marker (and
// the walker skip) forever.
@@ -319,6 +344,37 @@ export function restoreTranslationOnlySwapsForAnchor(
return true
}
/**
* Undo a whole virtual-paragraph generation on a translationOnly container:
* the per-unit swaps, the wrappers still holding a spinner or an error, and
* the Text cuts that made the units whole nodes.
*
* Order is load-bearing. Wrappers go first, because a wrapper sitting between
* a cut source and its tail would block the rejoin, and because the fallback
* strategy parks a unit's original nodes inside its wrapper. Descendant
* anchors go next: a unit that is a single element registers its record on
* that element, where an ancestor-only lookup would never find it. The
* container itself goes last, ending the generation so the final restore can
* rejoin the cuts and hand the marker and dir/lang back.
*/
export function teardownVirtualTranslationOnlyGeneration(layoutSource: HTMLElement): void {
for (const wrapper of [
...layoutSource.querySelectorAll<HTMLElement>(`.${CONTENT_WRAPPER_CLASS}`),
]) {
if (wrapper.isConnected) removeTranslatedWrapperWithRestore(wrapper)
}
for (const anchor of [
...layoutSource.querySelectorAll<HTMLElement>(`[${TRANSLATION_ONLY_ATTRIBUTE}]`),
]) {
restoreTranslationOnlySwapsForAnchor(anchor)
}
// A full restore ends the generation and finalizes: cuts rejoined, marker and
// dir/lang handed back.
restoreTranslationOnlySwapsForAnchor(layoutSource)
}
/**
* Remove translated wrapper and restore original content based on translation mode
* @param wrapper - The translated wrapper element to remove
@@ -330,18 +330,32 @@ export function applyInPlaceTextSwap(
}
refreshTranslationOnlySwapRecordExpectedText(record)
const state = ensureTranslationOnlyAnchorState(anchor, config, getAnchorState)
// A retranslation pass replaces its run's previous record — appending would
// leave a dead record whose stale references keep flagging the anchor as
// host-changed.
dropTranslationOnlySwapRecords(
state,
state.swaps.filter((existing) => swapRecordIntersectsNodes(existing, runNodes)),
)
state.swaps.push(record)
}
/**
* The anchor state for `anchor`, marking and registering it on first use.
*
* Separate from `applyInPlaceTextSwap` so the virtual-paragraph path can claim
* the anchor up front, when it cuts the container's Text nodes — the marker
* has to exist from that moment for a page-wide cleanup to find the anchor,
* even if every unit then fails before it ever swaps anything.
*/
export function ensureTranslationOnlyAnchorState(
anchor: HTMLElement,
config: Config,
getAnchorState: (anchor: HTMLElement) => TranslationOnlyAnchorState | undefined,
): TranslationOnlyAnchorState {
const existingState = getAnchorState(anchor)
if (existingState) {
// A retranslation pass replaces its run's previous record — appending
// would leave a dead record whose stale references keep flagging the
// anchor as host-changed.
dropTranslationOnlySwapRecords(
existingState,
existingState.swaps.filter((existing) => swapRecordIntersectsNodes(existing, runNodes)),
)
existingState.swaps.push(record)
return
}
if (existingState) return existingState
const attributeAdjustments = [
{
@@ -353,9 +367,7 @@ export function applyInPlaceTextSwap(
]
anchor.setAttribute(TRANSLATION_ONLY_ATTRIBUTE, "")
setTranslationDirAndLang(anchor, config)
registerTranslationOnlyAnchorState({
anchor,
attributeAdjustments,
swaps: [record],
})
const state: TranslationOnlyAnchorState = { anchor, attributeAdjustments, swaps: [] }
registerTranslationOnlyAnchorState(state)
return state
}
@@ -1,6 +1,9 @@
import type { TextSplitRecord } from "../core/translation-state"
import type { VirtualParagraphUnit } from "./paragraph-segmentation"
import type { VirtualParagraphPlan, VirtualParagraphUnit } from "./paragraph-segmentation"
import type { Config } from "@/types/config/config"
import { isTextNode } from "../../dom/filter"
import { markExtensionDrivenCharacterData } from "../core/translation-state"
import { resolveVirtualParagraphUnitEdges } from "./paragraph-segmentation"
export interface VirtualParagraphWrapperEntry {
unit: VirtualParagraphUnit
@@ -84,3 +87,114 @@ export function insertVirtualParagraphWrappers(
return { inserted: entries, splitRecords: splitRecordTarget }
}
/** One virtual paragraph realized as whole children of the layout source. */
export interface VirtualParagraphUnitRun {
unit: VirtualParagraphUnit
/** Contiguous children of the layout source holding exactly this unit's text. */
nodes: ChildNode[]
}
function splitTopLevelText(
node: Text,
offset: number,
splitRecords: Map<Text, TextSplitRecord>,
splitRecordTarget: TextSplitRecord[],
): Text {
const parent = node.parentNode
if (!parent) {
throw new Error("Virtual paragraph unit source is detached")
}
let splitRecord = splitRecords.get(node)
if (!splitRecord) {
splitRecord = {
source: node,
parent,
originalValue: node.data,
createdTails: [],
sourceValueAfterSplit: node.data,
tailValuesAfterSplit: [],
}
splitRecords.set(node, splitRecord)
splitRecordTarget.push(splitRecord)
}
const tail = node.splitText(offset)
// Cuts are applied in reverse document order, so prepending each new tail
// leaves the record in final DOM order for exact cleanup.
splitRecord.createdTails.unshift(tail)
return tail
}
/**
* Turn each virtual paragraph into its own run of whole child nodes, so
* translationOnly can hand one unit at a time to the ordinary per-run pipeline
* (attribute protection, alignment, in-place swap, restore records) instead of
* swapping the entire container as one blob.
*
* Only top-level Text nodes are cut, and only where a unit's text starts or
* ends inside one; the blank lines between units stay in their own nodes,
* untouched. Returns `null` when the plan cannot be expressed this way — see
* `resolveVirtualParagraphUnitEdges` — leaving the caller on its existing path.
*
* Cuts run from the end of the container towards the beginning, and within a
* unit the end edge is cut before the start edge, so every offset computed
* against the pre-split DOM is still valid when its turn comes: `splitText`
* keeps all earlier content in the original node.
*/
export function materializeVirtualParagraphUnitRuns(
layoutSource: HTMLElement,
plan: VirtualParagraphPlan,
config: Config,
splitRecordTarget: TextSplitRecord[] = [],
): VirtualParagraphUnitRun[] | null {
const edges = resolveVirtualParagraphUnitEdges(layoutSource, plan, config)
if (!edges) return null
const splitRecords = new Map(splitRecordTarget.map((record) => [record.source, record] as const))
const bounds: Array<{ unit: VirtualParagraphUnit; first: ChildNode; last: ChildNode }> = []
for (const edge of [...edges].reverse()) {
let last = edge.endNode
if (edge.endOffset !== null && isTextNode(edge.endNode)) {
// Cut the delimiter loose; the head now ends where the unit's text does.
splitTopLevelText(edge.endNode, edge.endOffset, splitRecords, splitRecordTarget)
}
let first = edge.startNode
if (edge.startOffset !== null && isTextNode(edge.startNode)) {
first = splitTopLevelText(edge.startNode, edge.startOffset, splitRecords, splitRecordTarget)
// Both edges in one node: the tail just cut is the whole unit.
if (edge.startNode === edge.endNode) {
last = first
}
}
bounds.unshift({ unit: edge.unit, first, last })
}
for (const record of splitRecordTarget) {
record.sourceValueAfterSplit = record.source.data
record.tailValuesAfterSplit = record.createdTails.map((tail) => tail.data)
// A cut shortens the source in place. Attribute that character-data change
// to the extension so the mutation pipeline does not read our own split as
// a host edit and flag the run stale.
markExtensionDrivenCharacterData(record.source, record.source.data)
}
const runs: VirtualParagraphUnitRun[] = []
for (const { unit, first, last } of bounds) {
const nodes: ChildNode[] = []
let current: ChildNode | null = first
while (current) {
nodes.push(current)
if (current === last) break
current = current.nextSibling
}
if (nodes.at(-1) !== last) return null
runs.push({ unit, nodes })
}
return runs
}
+22
View File
@@ -24,6 +24,14 @@ export interface ResolvedSiteRule {
* code, rule `.add`/`.remove` deltas applied.
*/
dontWalkTags: ReadonlySet<string> | null
/**
* The tag names a matched rule named in `dontWalkTags.add` (minus any a later
* rule removed), kept apart from the merged set so consumers can tell an
* explicit authoring choice from a shipped default. Read by the plain-text
* `<pre>` exemption in `utils/host/dom/filter`: the exemption un-blocks a tag
* the defaults block, so a rule that names it explicitly must still win.
*/
dontWalkTagsExplicitAdds: ReadonlySet<string> | null
dontWalkButTranslateTags: ReadonlySet<string> | null
mainContentIgnoreTags: ReadonlySet<string> | null
forceBlockTags: ReadonlySet<string> | null
@@ -43,6 +51,7 @@ export const EMPTY_RESOLVED_SITE_RULE: ResolvedSiteRule = {
forceInlineStyleSelector: null,
preserveTextSelector: null,
dontWalkTags: null,
dontWalkTagsExplicitAdds: null,
dontWalkButTranslateTags: null,
mainContentIgnoreTags: null,
forceBlockTags: null,
@@ -161,6 +170,8 @@ function mergeTagSetDelta(
removeKey: keyof SiteRule,
defaults: ReadonlySet<string>,
protectedTags?: ReadonlySet<string>,
/** Filled with the tag names `addKey` named, in the same three casings. */
explicitAddsTarget?: Set<string>,
): ReadonlySet<string> | null {
if (!matched.some((rule) => rule[addKey] !== undefined || rule[removeKey] !== undefined)) {
return null
@@ -186,6 +197,9 @@ function mergeTagSetDelta(
merged.add(tagName)
merged.add(tagName.toUpperCase())
merged.add(tagName.toLowerCase())
explicitAddsTarget?.add(tagName)
explicitAddsTarget?.add(tagName.toUpperCase())
explicitAddsTarget?.add(tagName.toLowerCase())
}
for (const tagName of validTagNames(rule[removeKey])) {
if (protectedTags?.has(tagName.toUpperCase())) {
@@ -195,6 +209,11 @@ function mergeTagSetDelta(
merged.delete(tagName)
merged.delete(tagName.toUpperCase())
merged.delete(tagName.toLowerCase())
// A later rule removing what an earlier one added cancels the explicit
// choice too, so the last word decides.
explicitAddsTarget?.delete(tagName)
explicitAddsTarget?.delete(tagName.toUpperCase())
explicitAddsTarget?.delete(tagName.toLowerCase())
}
}
@@ -229,6 +248,7 @@ export function resolveSiteRule(
let minCharacters: number | null = null
let minWords: number | null = null
const dontWalkTagsExplicitAdds = new Set<string>()
const cssParts: string[] = []
for (const rule of matched) {
if (rule.minCharacters !== undefined) {
@@ -295,7 +315,9 @@ export function resolveSiteRule(
"dontWalkTags.remove",
DEFAULT_TAG_SETS.dontWalkTags,
PROTECTED_DONT_WALK_TAGS,
dontWalkTagsExplicitAdds,
),
dontWalkTagsExplicitAdds: dontWalkTagsExplicitAdds.size > 0 ? dontWalkTagsExplicitAdds : null,
dontWalkButTranslateTags: mergeTagSetDelta(
matched,
"dontWalkButTranslateTags.add",