-
Notifications
You must be signed in to change notification settings - Fork 7
Detect a feed card's language on the server instead of in every browser #1618
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| import { postBodySummary } from "@ecency/render-helper"; | ||
| import type { Entry } from "@/entities"; | ||
| import { MIN_DETECT_CHARS, francToIso1 } from "@/features/shared/entry-translate/iso639"; | ||
|
|
||
| /** | ||
| * Server-side content-language hint for slim feed rows (#1597). | ||
| * | ||
| * The Translate chip on a card needs the content language. Until now every | ||
| * visitor's browser detected it: one markdown render per card plus the | ||
| * franc-min detector chunk (~47 KB gzipped), all on idle after every feed and | ||
| * post view, for the majority of readers whose language matches the content. | ||
| * | ||
| * The slim step already derives each card's summary on the server, so the | ||
| * server detects the language from that same summary once per fetch and ships | ||
| * the answer as `slim.lang`: an ISO-639-1 code, or `null` when the text is too | ||
| * short or the detector is unsure (which the chip treats as "nothing to | ||
| * offer"). The client gate reads the hint and skips both the render and the | ||
| * chunk. Rows fetched by the browser itself (later pages of an infinite feed) | ||
| * carry no hint and keep the on-idle path; `undefined` means "not checked". | ||
| * | ||
| * Server only: the detector is imported lazily inside the server branch, so it | ||
| * never reaches a client bundle. The summary used is the one the client gate | ||
| * would have used, so the decision is the same one, made earlier and once. | ||
| */ | ||
| export type LangHint = string | null; | ||
|
|
||
| let detector: Promise<((text: string) => string) | null> | null = null; | ||
|
|
||
| function loadDetector(): Promise<((text: string) => string) | null> { | ||
| if (!detector) { | ||
| detector = import("franc-min") | ||
| .then((m) => m.franc) | ||
| .catch(() => { | ||
| // Let the next fetch try again rather than pinning "no detector" for | ||
| // the life of the process. | ||
| detector = null; | ||
| return null; | ||
| }); | ||
| } | ||
| return detector; | ||
| } | ||
|
|
||
| // The same bounds the client gate applies before detecting: render the sample | ||
| // to plain text (an author-written description can be a bare image link or | ||
| // markup, which must count as no text, not as a language) and cap the input. | ||
| const RAW_SAMPLE_CHARS = 2000; | ||
| const SAMPLE_CHARS = 600; | ||
|
|
||
| export function detectionSample(text: string): string { | ||
| return postBodySummary(text.slice(0, RAW_SAMPLE_CHARS), 0).slice(0, SAMPLE_CHARS).trim(); | ||
| } | ||
|
|
||
| export function isServerRuntime(): boolean { | ||
| return typeof window === "undefined"; | ||
| } | ||
|
|
||
| export function hintFor( | ||
| franc: (text: string) => string, | ||
| entry: Pick<Entry, "json_metadata" | "body"> | ||
| ): LangHint { | ||
| try { | ||
| // json_metadata is author-written: description may be missing or not a string. | ||
| const description = entry.json_metadata?.description; | ||
| const raw = entry.body || typeof description !== "string" ? "" : description.trim(); | ||
| if (raw.length < MIN_DETECT_CHARS) return null; | ||
| const text = detectionSample(raw); | ||
| if (text.length < MIN_DETECT_CHARS) return null; | ||
| return francToIso1(franc(text)); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Merge a freshly polled row over a cached one without losing the hint: rows | ||
| * the browser polls itself carry no `slim.lang`, and a plain spread would | ||
| * replace the server's answer with "not checked". | ||
| */ | ||
| export function mergePreservingHint<T extends Entry>(item: T, updated: T): T { | ||
| const merged: T = { ...item, ...updated }; | ||
| const lang = item.slim?.lang; | ||
| if (lang !== undefined && updated.slim && updated.slim.lang === undefined) { | ||
| merged.slim = { ...updated.slim, lang }; | ||
| } | ||
| // A cross-post card reads from its nested original, which the poll replaces | ||
| // wholesale; give it the same treatment. | ||
| if (item.original_entry && updated.original_entry) { | ||
| merged.original_entry = mergePreservingHint(item.original_entry, updated.original_entry); | ||
| } | ||
| return merged; | ||
| } | ||
|
|
||
| /** | ||
| * Annotate a slim page in place with `slim.lang`. Never throws: a missing or | ||
| * failing detector leaves the rows without a hint and the client detects as | ||
| * before. Not applied on the client (see module comment). | ||
| */ | ||
| export async function annotateLanguageHints<T>(page: T): Promise<T> { | ||
| if (!isServerRuntime() || !Array.isArray(page)) return page; | ||
| const franc = await loadDetector(); | ||
| if (!franc) return page; | ||
| for (const item of page as Entry[]) { | ||
| annotate(franc, item); | ||
| } | ||
| return page; | ||
| } | ||
|
|
||
| function annotate(franc: (text: string) => string, entry: Entry | null | undefined): void { | ||
| try { | ||
| if (!entry || typeof entry !== "object" || !entry.slim || entry.slim.lang !== undefined) return; | ||
| entry.slim = { ...entry.slim, lang: hintFor(franc, entry) }; | ||
| if (entry.original_entry) annotate(franc, entry.original_entry); | ||
| } catch { | ||
| // One malformed row must not cost the page; it simply carries no hint. | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
22 changes: 22 additions & 0 deletions
22
apps/web/src/specs/core/entries/language-hint-client.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { describe, expect, it } from "vitest"; | ||
| import { annotateLanguageHints, isServerRuntime } from "@/core/entries/language-hint"; | ||
| import { mockEntry } from "@/specs/test-utils"; | ||
| import type { Entry } from "@/entities"; | ||
|
|
||
| // jsdom: the browser path. Rows the browser fetches itself must stay without a | ||
| // hint so the gate keeps detecting on idle, and the detector must not load. | ||
| describe("language hint in the browser (#1597)", () => { | ||
| it("is a no-op on the client", async () => { | ||
| expect(isServerRuntime()).toBe(false); | ||
| const slim = { | ||
| ...mockEntry({ permlink: "client-row", body: "" }), | ||
| slim: { ext_link: false } | ||
| } as Entry; | ||
| slim.json_metadata = { | ||
| description: | ||
| "Today I want to share with you a story about the community and the work we do together every week." | ||
| }; | ||
| const [out] = await annotateLanguageHints([slim]); | ||
| expect(out.slim?.lang).toBeUndefined(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| // @vitest-environment node | ||
| import { describe, expect, it, vi } from "vitest"; | ||
| import { annotateLanguageHints, detectionSample, hintFor, isServerRuntime, mergePreservingHint } from "@/core/entries/language-hint"; | ||
| import { withSlimEntries } from "@/core/entries/slim-entry"; | ||
| import { mockEntry } from "@/specs/test-utils"; | ||
| import type { Entry } from "@/entities"; | ||
|
|
||
| const SPANISH = | ||
| "Hoy quiero compartir con ustedes una historia sobre la comunidad y el trabajo que hacemos juntos cada semana en la ciudad."; | ||
| const ENGLISH = | ||
| "Today I want to share with you a story about the community and the work we do together every week in the city."; | ||
|
|
||
| function row(body: string, overrides: Partial<Entry> = {}): Entry { | ||
| return mockEntry({ permlink: `hint-${Math.random().toString(36).slice(2)}`, body, ...overrides }); | ||
| } | ||
|
|
||
| describe("server-side language hint (#1597)", () => { | ||
| it("runs on the server in this environment", () => { | ||
| expect(isServerRuntime()).toBe(true); | ||
| }); | ||
|
|
||
| it("detects the summary language of slim rows and ships it as slim.lang", async () => { | ||
| const options = withSlimEntries({ | ||
| queryKey: ["hint"], | ||
| queryFn: async () => [row(SPANISH), row(ENGLISH)] | ||
| }); | ||
| const page = (await (options.queryFn as () => Promise<Entry[]>)()) as Entry[]; | ||
| expect(page[0].body).toBe(""); | ||
| expect(page[0].slim?.lang).toBe("es"); | ||
| expect(page[1].slim?.lang).toBe("en"); | ||
| }); | ||
|
|
||
| it("marks a row whose summary is too short as checked with nothing to offer (null)", async () => { | ||
| const options = withSlimEntries({ queryKey: ["hint"], queryFn: async () => [row("Hola.")] }); | ||
| const page = (await (options.queryFn as () => Promise<Entry[]>)()) as Entry[]; | ||
| expect(page[0].slim?.lang).toBeNull(); | ||
| }); | ||
|
|
||
| it("reaches the nested original of a cross-post", async () => { | ||
| const original = row(SPANISH); | ||
| const options = withSlimEntries({ | ||
| queryKey: ["hint"], | ||
| queryFn: async () => [row(ENGLISH, { original_entry: original } as Partial<Entry>)] | ||
| }); | ||
| const page = (await (options.queryFn as () => Promise<Entry[]>)()) as Entry[]; | ||
| expect(page[0].original_entry?.slim?.lang).toBe("es"); | ||
| }); | ||
|
|
||
| it("leaves rows without the slim marker and non-array pages alone", async () => { | ||
| const full = row(ENGLISH); | ||
| full.body = ""; // looks slim but never went through slimEntry | ||
| expect((await annotateLanguageHints([full]))[0].slim).toBeUndefined(); | ||
| expect(await annotateLanguageHints({ items: [] })).toEqual({ items: [] }); | ||
| expect(await annotateLanguageHints(undefined)).toBeUndefined(); | ||
| }); | ||
|
|
||
| it("treats a missing or non-string description as no text", () => { | ||
| const franc = vi.fn(() => "eng"); | ||
| for (const description of [undefined, null, 42, { text: ENGLISH }, [ENGLISH]]) { | ||
| expect(hintFor(franc, { body: "", json_metadata: { description } as never })).toBeNull(); | ||
| } | ||
| expect(franc).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it("never throws out of the queryFn when the detector fails", () => { | ||
| const boom = () => { | ||
| throw new Error("boom"); | ||
| }; | ||
| expect(hintFor(boom, { body: "", json_metadata: { description: ENGLISH } })).toBeNull(); | ||
| }); | ||
|
|
||
| it("does not second-guess a row that already carries a hint", async () => { | ||
| const slim = { ...row(ENGLISH), body: "", slim: { ext_link: false, lang: "de" as string | null } }; | ||
| slim.json_metadata = { description: ENGLISH }; | ||
| const [out] = await annotateLanguageHints([slim]); | ||
| expect(out.slim?.lang).toBe("de"); | ||
| }); | ||
|
|
||
| it("treats an author description that is only a link or markup as no text (#1597 review)", () => { | ||
| const franc = vi.fn(() => "por"); | ||
| for (const description of [ | ||
| "", | ||
| "https://3speak.tv/watch?v=someone/abcdefgh https://images.ecency.com/p/abcdefghijklmnop.png", | ||
| "<center><img src='https://images.ecency.com/DQmabcdefghijklmnopqrstuvwxyz/x.png'></center>", | ||
| "[Source](https://www.example.com/some/long/path/that/keeps/going/and/going/x)" | ||
| ]) { | ||
| expect(hintFor(franc, { body: "", json_metadata: { description } })).toBeNull(); | ||
| } | ||
| expect(franc).not.toHaveBeenCalled(); | ||
| // Plain summaries are passed through unchanged, so the server and the | ||
| // client decide on the same text. | ||
| expect(detectionSample(ENGLISH)).toBe(ENGLISH); | ||
| }); | ||
|
|
||
| it("keeps the server hint when a browser-polled row is merged over a cached one", () => { | ||
| const cached = { ...row(ENGLISH), body: "", slim: { ext_link: false, lang: "en" as string | null } }; | ||
| const polled = { ...cached, slim: { ext_link: true }, stats: { total_votes: 9 } } as Entry; | ||
| const merged = mergePreservingHint(cached, polled); | ||
| expect(merged.slim).toEqual({ ext_link: true, lang: "en" }); | ||
| expect(merged.stats?.total_votes).toBe(9); | ||
| // A row the server did check wins over the cached answer. | ||
| const rechecked = { ...polled, slim: { ext_link: true, lang: "de" as string | null } }; | ||
| expect(mergePreservingHint(cached, rechecked).slim?.lang).toBe("de"); | ||
| // Nothing to preserve: plain merge. | ||
| const unhinted = { ...cached, slim: { ext_link: false } } as Entry; | ||
| expect(mergePreservingHint(unhinted, polled).slim).toEqual({ ext_link: true }); | ||
| // A cross-post's nested original keeps its hint too. | ||
| const original = { ...row(SPANISH), body: "", slim: { ext_link: false, lang: "es" as string | null } }; | ||
| const crossCached = { ...cached, original_entry: original } as Entry; | ||
| const crossPolled = { | ||
| ...polled, | ||
| original_entry: { ...original, slim: { ext_link: false }, stats: { total_votes: 3 } } | ||
| } as Entry; | ||
| const crossMerged = mergePreservingHint(crossCached, crossPolled); | ||
| expect(crossMerged.original_entry?.slim).toEqual({ ext_link: false, lang: "es" }); | ||
| expect(crossMerged.original_entry?.stats?.total_votes).toBe(3); | ||
| }); | ||
|
|
||
| it("leaves the post page's full bodies to the client (hints are for slim rows)", () => { | ||
| const franc = vi.fn(() => "spa"); | ||
| expect(hintFor(franc, { body: SPANISH, json_metadata: { description: SPANISH } })).toBeNull(); | ||
| expect(franc).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.