Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f164cde
web-next: Add /[lang] article route to display translations
dahlia May 2, 2026
9e64a35
web-next: Emit canonical/og:url/og:locale:alternate on article pages
dahlia May 2, 2026
63d9152
Include in-progress translations on Article.contents includeBeingTran…
dahlia May 2, 2026
7d2b65a
Add requestArticleTranslation GraphQL mutation
dahlia May 2, 2026
8f5bb8d
web-next: Plumb includeBeingTranslated through article fragments
dahlia May 2, 2026
32c5f78
web-next: Render translating placeholder on /lang for in-progress rows
dahlia May 2, 2026
6256f5b
web-next: Auto-request LLM translation on /lang when content missing
dahlia May 2, 2026
47bc22a
web-next: Poll for translation completion every 30 seconds
dahlia May 2, 2026
121669b
web-next: List viewer's preferred locales in article language switcher
dahlia May 2, 2026
89786b4
web-next: Translate "Translation request failed" toast
dahlia May 2, 2026
c8df66f
web-next: Switch /lang translation polling to fetchQuery
dahlia May 2, 2026
66f0025
web-next: Add spinner and explanatory copy to translating placeholder
dahlia May 2, 2026
c2d2772
web-next: Make /lang translation polling more robust
dahlia May 2, 2026
3edafbe
web-next: Dedup viewer locales in the article switcher by language su…
dahlia May 2, 2026
c431ab4
Reject unsupported target locales in requestArticleTranslation
dahlia May 2, 2026
696ced4
web-next: Re-fire /lang translation request when placeholder vanishes
dahlia May 2, 2026
38a5b07
web-next: Include in-progress translations in article allContents
dahlia May 2, 2026
9e8b3b3
web-next: Show a retry UI when a translation request fails
dahlia May 2, 2026
ee4f1fb
web-next: Stabilize article content picker, h1, and meta encoding
dahlia May 2, 2026
bea46b8
web-next: Harden /lang stale-translation handling for guests and bad …
dahlia May 2, 2026
e560bcc
Reject same-family target locales in requestArticleTranslation
dahlia May 2, 2026
6fd01e8
web-next: Detect quick translator failures in /lang mutation onCompleted
dahlia May 2, 2026
ad6b6c7
web-next: Filter switcher's extra locale links through normalizeLocale
dahlia May 2, 2026
d48abf6
web-next: Don't cancel an in-flight /lang poll when the next tick fires
dahlia May 2, 2026
ad497b7
Allow cross-script SAME_LANGUAGE targets in requestArticleTranslation
dahlia May 2, 2026
6d3364d
web-next: Treat cross-script locales as distinct in /lang and switcher
dahlia May 2, 2026
df4978f
web-next: Skip in-progress translations in og:locale:alternate
dahlia May 2, 2026
6080de3
web-next: Stop auto-retrying /lang translation after a failure
dahlia May 2, 2026
24ba16b
web-next: Fall back to /lang URL for switcher links missing a server URL
dahlia May 2, 2026
8b5ff17
Skip enqueueing in requestArticleTranslation when a completed transla…
dahlia May 2, 2026
61793d7
web-next: Reset /lang local state on route param navigation
dahlia May 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
592 changes: 591 additions & 1 deletion graphql/post.more.test.ts

Large diffs are not rendered by default.

132 changes: 126 additions & 6 deletions graphql/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { getAvatarUrl } from "@hackerspub/models/account";
import {
createArticle,
deleteArticleDraft,
getOriginalArticleContent,
LanguageChangeWithTranslationsError,
startArticleContentTranslation,
updateArticle,
updateArticleDraft,
} from "@hackerspub/models/article";
Expand All @@ -17,7 +19,7 @@ import {
} from "@hackerspub/models/bookmark";
import { isReactionEmoji, renderCustomEmojis } from "@hackerspub/models/emoji";
import { addExternalLinkTargets, stripHtml } from "@hackerspub/models/html";
import { negotiateLocale } from "@hackerspub/models/i18n";
import { negotiateLocale, normalizeLocale } from "@hackerspub/models/i18n";
import { renderMarkup } from "@hackerspub/models/markup";
import { createNote } from "@hackerspub/models/note";
import {
Expand Down Expand Up @@ -65,17 +67,39 @@ class SharedPostDeletionNotAllowedError extends Error {
}
}

type LlmTranslationNotAllowedReason = "DISABLED" | "SAME_LANGUAGE";

class LlmTranslationNotAllowedError extends Error {
public constructor(public readonly reason: LlmTranslationNotAllowedReason) {
super(`LLM translation not allowed: ${reason}`);
}
}

export const PostType = builder.enumType("PostType", {
values: ["ARTICLE", "NOTE", "QUESTION"],
});

const LlmTranslationNotAllowedReasonRef = builder.enumType(
"LlmTranslationNotAllowedReason",
{
values: ["DISABLED", "SAME_LANGUAGE"] as const,
},
);

builder.objectType(SharedPostDeletionNotAllowedError, {
name: "SharedPostDeletionNotAllowedError",
fields: (t) => ({
inputPath: t.expose("inputPath", { type: "String" }),
}),
});

builder.objectType(LlmTranslationNotAllowedError, {
name: "LlmTranslationNotAllowedError",
fields: (t) => ({
reason: t.expose("reason", { type: LlmTranslationNotAllowedReasonRef }),
}),
});

export const Post = builder.drizzleInterface("postTable", {
variant: "Post",
interfaces: [Reactable, Node],
Expand Down Expand Up @@ -301,11 +325,9 @@ export const Article = builder.drizzleNode("postTable", {
with: {
articleSource: {
with: {
contents: {
where: {
beingTranslated: args.includeBeingTranslated ?? false,
},
},
contents: args.includeBeingTranslated
? {}
: { where: { beingTranslated: false } },
Comment thread
dahlia marked this conversation as resolved.
},
},
},
Expand Down Expand Up @@ -1775,6 +1797,104 @@ builder.relayMutationField(
},
);

builder.relayMutationField(
"requestArticleTranslation",
{
inputFields: (t) => ({
articleId: t.globalID({ for: [Article], required: true }),
targetLanguage: t.field({ type: "Locale", required: true }),
}),
},
{
errors: {
types: [
NotAuthenticatedError,
InvalidInputError,
LlmTranslationNotAllowedError,
],
},
async resolve(_root, args, ctx) {
const session = await ctx.session;
if (session == null || ctx.account == null) {
throw new NotAuthenticatedError();
}

const post = await ctx.db.query.postTable.findFirst({
where: { id: args.input.articleId.id },
with: {
actor: {
with: {
followers: true,
blockees: true,
blockers: true,
},
Comment thread
dahlia marked this conversation as resolved.
},
mentions: true,
articleSource: {
with: { contents: true },
},
},
Comment thread
dahlia marked this conversation as resolved.
});
if (
post == null ||
post.type !== "Article" ||
post.articleSource == null ||
!isPostVisibleTo(post, ctx.account.actor)
) {
throw new InvalidInputError("articleId");
}
if (!post.articleSource.allowLlmTranslation) {
throw new LlmTranslationNotAllowedError("DISABLED");
}
const original = getOriginalArticleContent(post.articleSource);
if (original == null) {
throw new InvalidInputError("articleId");
}
// The `Locale` scalar accepts any syntactically valid BCP 47
// tag, but the `[lang]` route only serves locales that pass
// `normalizeLocale` (i.e. the `POSSIBLE_LOCALES` whitelist
// used across the project). Run the same check here so an
// API client cannot enqueue a translation for a tag the
// canonical article URL flow will never display.
const targetLanguage = normalizeLocale(
args.input.targetLanguage.baseName,
);
Comment thread
dahlia marked this conversation as resolved.
Comment thread
dahlia marked this conversation as resolved.
if (targetLanguage == null) {
throw new InvalidInputError("targetLanguage");
}
// Reject not just exact matches (`en` -> `en`) but also any
// request whose target shares the source's BCP 47 language
// subtag (`en` -> `en-US`, `ko` -> `ko-KR`). `Article.contents`
// negotiates among available locales rather than requiring an
// exact tag, so allowing a same-language variant would create
// a redundant placeholder row whose canonical URL would
// negotiate back to the existing source content and leave the
// newly inserted row unreachable.
const targetSubtag = new Intl.Locale(targetLanguage).language;
const originalSubtag = new Intl.Locale(original.language).language;
if (targetSubtag === originalSubtag) {
throw new LlmTranslationNotAllowedError("SAME_LANGUAGE");
Comment thread
dahlia marked this conversation as resolved.
Outdated
Comment thread
dahlia marked this conversation as resolved.
Outdated
}

await startArticleContentTranslation(ctx.fedCtx, {
content: original,
targetLanguage,
requester: ctx.account,
});
Comment thread
dahlia marked this conversation as resolved.
Outdated

return post;
},
},
{
outputFields: (t) => ({
article: t.field({
type: Article,
resolve: (post) => post,
}),
}),
},
);

interface UploadMediaResult {
url: string;
width: number;
Expand Down
23 changes: 23 additions & 0 deletions graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,15 @@ The `JSON` scalar type represents JSON values as specified by [ECMA-404](http://
"""
scalar JSON

type LlmTranslationNotAllowedError {
reason: LlmTranslationNotAllowedReason!
}

enum LlmTranslationNotAllowedReason {
DISABLED
SAME_LANGUAGE
}

"""A BCP 47-compliant language tag."""
scalar Locale

Expand Down Expand Up @@ -766,6 +775,7 @@ type Mutation {
registerFcmDeviceToken(input: RegisterFcmDeviceTokenInput!): RegisterFcmDeviceTokenResult!
removeFollower(input: RemoveFollowerInput!): RemoveFollowerResult!
removeReactionFromPost(input: RemoveReactionFromPostInput!): RemoveReactionFromPostResult!
requestArticleTranslation(input: RequestArticleTranslationInput!): RequestArticleTranslationResult!
revokePasskey(passkeyId: ID!): ID

"""Revoke a session by its ID."""
Expand Down Expand Up @@ -1426,6 +1436,19 @@ type ReplyNotification implements Node & Notification {
uuid: UUID!
}

input RequestArticleTranslationInput {
articleId: ID!
clientMutationId: ID
targetLanguage: Locale!
}

type RequestArticleTranslationPayload {
article: Article!
clientMutationId: ID
}

union RequestArticleTranslationResult = InvalidInputError | LlmTranslationNotAllowedError | NotAuthenticatedError | RequestArticleTranslationPayload

input SaveArticleDraftInput {
clientMutationId: ID
content: Markdown!
Expand Down
Loading
Loading