feat: expose mobile localization contracts

This commit is contained in:
poyrazavsever
2026-07-19 03:07:54 +03:00
parent c2eeb0ff92
commit 178f285191
14 changed files with 692 additions and 5 deletions
+31
View File
@@ -0,0 +1,31 @@
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
const distDir = path.join(process.cwd(), ".next", "i18n-phase8-smoke-dist");
execFileSync(process.execPath, ["scripts/phase9-api-boundary.mjs"], {
cwd: process.cwd(),
stdio: "inherit",
});
execFileSync("./node_modules/.bin/tsc", ["-p", "tsconfig.i18n-phase8-smoke.json"], {
cwd: process.cwd(),
stdio: "inherit",
});
const serverOnlyStubDir = path.join(distDir, "node_modules", "server-only");
fs.mkdirSync(serverOnlyStubDir, { recursive: true });
fs.writeFileSync(path.join(serverOnlyStubDir, "index.js"), "\n");
const aliasScopeDir = path.join(distDir, "node_modules", "@");
fs.mkdirSync(aliasScopeDir, { recursive: true });
const serverAlias = path.join(aliasScopeDir, "server");
if (!fs.existsSync(serverAlias)) {
fs.symlinkSync(path.join(distDir, "server"), serverAlias, "dir");
}
execFileSync(process.execPath, [path.join(distDir, "scripts", "i18n-phase8-smoke.js")], {
cwd: process.cwd(),
stdio: "inherit",
});
+110
View File
@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import {
buildLocalizationContract,
negotiateLocale,
parseAcceptLanguage,
UNSUPPORTED_LOCALE_CODE,
type ApiLocalizationMetadata,
} from "../server/api/v1/localization";
import {
NETA_CAPABILITIES,
type NetaLocalizedResponse,
type NetaTranslationMutationShape,
} from "../server/api/v1/contracts";
import { DomainError } from "../server/domain/errors";
const metadata: ApiLocalizationMetadata = {
defaultLocale: "tr",
supportedLocales: [
{
code: "tr",
name: "Turkish",
nativeName: "Türkçe",
status: "active",
fallbackLocale: null,
textDirection: "ltr",
builtIn: true,
},
{
code: "en",
name: "English",
nativeName: "English",
status: "active",
fallbackLocale: "tr",
textDirection: "ltr",
builtIn: true,
},
{
code: "fr",
name: "French",
nativeName: "Français",
status: "draft",
fallbackLocale: "en",
textDirection: "ltr",
builtIn: false,
},
],
fallbacks: {
en: "tr",
fr: "en",
},
catalogVersion: 7,
};
assert.deepEqual(parseAcceptLanguage("fr-FR, en-US;q=0.9, tr;q=0.7"), ["fr-FR", "en-US", "tr"]);
assert.deepEqual(parseAcceptLanguage("en;q=0.4, tr;q=0.9"), ["tr", "en"]);
const queryLocale = negotiateLocale({
metadata,
requestedLocale: "en",
acceptLanguage: "tr;q=0.9",
});
assert.equal(queryLocale.locale, "en");
assert.equal(queryLocale.source, "query");
assert.deepEqual(queryLocale.fallbackChain, ["en", "tr"]);
const baseLanguageMatch = negotiateLocale({
metadata,
acceptLanguage: "en-US,en;q=0.9",
});
assert.equal(baseLanguageMatch.locale, "en");
assert.equal(baseLanguageMatch.requestedLocale, "en-US");
assert.equal(baseLanguageMatch.source, "accept-language");
assert.throws(
() => negotiateLocale({ metadata, requestedLocale: "fr" }),
(error) =>
error instanceof DomainError &&
error.code === UNSUPPORTED_LOCALE_CODE &&
error.message === "Unsupported locale." &&
error.details?.messageKey === "validation.unsupportedLocale" &&
error.details?.requestedLocale === "fr",
);
const contract = buildLocalizationContract(metadata);
assert.equal(contract.negotiator.queryParam, "locale");
assert.equal(contract.negotiator.header, "Accept-Language");
assert.equal(contract.negotiator.unsupportedLocaleCode, UNSUPPORTED_LOCALE_CODE);
assert.equal(contract.responseContract.localizedResourceField, "localized");
assert.equal(contract.responseContract.translationsField, "translations");
assert.equal(
NETA_CAPABILITIES.some((capability) => capability.id === "instance.localization" && capability.status === "available"),
true,
);
const localizedResponse: NetaLocalizedResponse<{ title: string }> = {
resource: { title: "Marka sitesi" },
localized: { title: "Brand website" },
locale: "en",
fallbackChain: ["en", "tr"],
};
assert.equal(localizedResponse.localized.title, "Brand website");
const mutationShape: NetaTranslationMutationShape = {
tr: { title: "Marka sitesi" },
en: { title: "Brand website", description: null },
};
assert.equal(mutationShape.en.title, "Brand website");
console.log("I18n phase 8 mobile API localization smoke passed.");
+50
View File
@@ -18,16 +18,32 @@ for (const value of [
'NETA_PROTOCOL = "neta"',
"NETA_DISCOVERY_VERSION = 1",
'NETA_API_VERSION = "1"',
'"instance.localization"',
'"auth.device-pairing"',
'status: "planned"',
"minimumSupportedVersion",
"workspaceName",
"metaTitle",
"faviconUrl",
"NetaLocalizedResponse",
"NetaTranslationMutationShape",
"ownerMutationTranslations",
"absoluteUrl",
]) {
assert.ok(contracts.includes(value), `Missing API contract marker: ${value}`);
}
const localization = read("server/api/v1/localization.ts");
for (const value of [
"parseAcceptLanguage",
"negotiateLocale",
"UNSUPPORTED_LOCALE",
"Accept-Language",
"exact-or-base-language",
]) {
assert.ok(localization.includes(value), `Missing localization contract marker: ${value}`);
}
const instanceService = read("server/instance/service.ts");
assert.doesNotMatch(
instanceService,
@@ -46,6 +62,39 @@ assert.match(
/getUserPreferences/,
"Authenticated mobile metadata must expose the persisted user color mode",
);
assert.match(
read("app/api/v1/me/route.ts"),
/resolvedLocale/,
"Authenticated mobile metadata must expose resolved localization state",
);
assert.match(
read("app/api/v1/me/route.ts"),
/portalLocale/,
"Authenticated mobile metadata must expose client portal locale when available",
);
assert.match(
read("app/api/v1/meta/route.ts"),
/stale-while-revalidate=300/,
"Meta endpoint must keep a public revalidation cache contract",
);
const domainErrors = read("server/domain/errors.ts");
assert.match(
domainErrors,
/UNSUPPORTED_LOCALE/,
"Unsupported locale must have a stable API error code",
);
for (const fixture of [
"docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-tr.json",
"docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-en.json",
"docs/self-hosted-redesign/i18n-phase-8-fixtures/api-v1-locale-fr.json",
]) {
const parsed = JSON.parse(read(fixture));
assert.ok(parsed.request, `Missing request fixture in ${fixture}`);
assert.ok(parsed.expected, `Missing expected fixture in ${fixture}`);
}
for (const route of requiredRoutes.slice(1)) {
const content = read(route);
@@ -66,6 +115,7 @@ for (const futureRoute of [
const runtimeFiles = [
...requiredRoutes,
"server/api/v1/contracts.ts",
"server/api/v1/localization.ts",
"server/api/v1/responses.ts",
"server/api/v1/runtime.ts",
"server/instance/service.ts",