Merged in feat/syncDefaultMessage (pull request #3022)

Sync defaultMessage from lokalise

* Enhance translation sync functionality and tests

- Added logging for found component files during sync.
- Introduced tests for handling complex components with replacements.
- Updated regex in syncIntlFormatMessage to support optional second arguments.
- Removed unused test files.

* feat(syncDefaultMessage): add script for syncing default message with lokalise

* feat(syncDefaultMessage): add script for syncing default message with lokalise


Approved-by: Matilda Landström
This commit is contained in:
Joakim Jäderberg
2025-10-30 08:38:50 +00:00
parent 3962ecd858
commit bf6ed7778e
48 changed files with 316 additions and 197 deletions

View File

@@ -16,7 +16,8 @@
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui", "test:e2e:ui": "playwright test --ui",
"test:e2e:debug": "playwright test --debug", "test:e2e:debug": "playwright test --debug",
"include:shared": "jiti ../../scripts/copyFiles.ts ../../shared/ public/_static/shared" "include:shared": "jiti ../../scripts/copyFiles.ts ../../shared/ public/_static/shared",
"format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@formatjs/intl": "^3.1.6", "@formatjs/intl": "^3.1.6",

View File

@@ -1,8 +0,0 @@
module.exports = {
semi: false,
trailingComma: "es5",
singleQuote: false,
printWidth: 80,
tabWidth: 2,
endOfLine: "lf",
}

View File

@@ -0,0 +1,14 @@
/**
* @see https://prettier.io/docs/configuration
* @type {import("prettier").Config}
*/
const config = {
semi: false,
trailingComma: "es5",
singleQuote: false,
printWidth: 80,
tabWidth: 2,
endOfLine: "lf",
}
export default config

View File

@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"dev": "bun --watch src/index.ts | pino-pretty -o '{if module}[{module}] {end}{msg}' -i pid,hostname", "dev": "bun --watch src/index.ts | pino-pretty -o '{if module}[{module}] {end}{msg}' -i pid,hostname",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0 && tsc", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0 && tsc",
"lint:fix": "eslint . --ext ts,tsx --fix --report-unused-disable-directives --max-warnings 0 && tsc" "lint:fix": "eslint . --ext ts,tsx --fix --report-unused-disable-directives --max-warnings 0 && tsc",
"format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@elysiajs/server-timing": "^1.3.0", "@elysiajs/server-timing": "^1.3.0",
@@ -28,7 +29,6 @@
"eslint": "^9", "eslint": "^9",
"eslint-plugin-simple-import-sort": "^10.0.0", "eslint-plugin-simple-import-sort": "^10.0.0",
"pino-pretty": "^13.0.0", "pino-pretty": "^13.0.0",
"prettier": "^3.5.3",
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"prettier": { "prettier": {

View File

@@ -9,7 +9,11 @@
const maskChar = "*"; const maskChar = "*";
export function mask( export function mask(
value: string, value: string,
options?: { visibleStart?: number; visibleEnd?: number; maxLength?: number }, options?: {
visibleStart?: number;
visibleEnd?: number;
maxLength?: number;
},
): string { ): string {
if (!value) return ""; if (!value) return "";

View File

@@ -0,0 +1 @@
netlify/functions/data/*.json

View File

@@ -1,12 +1,12 @@
import { createReadStream } from "fs"; import { createReadStream } from "fs"
import { join } from "path"; import { join } from "path"
export default async (req: Request) => { export default async (req: Request) => {
try { try {
const body = await req.json(); const body = await req.json()
if (body.lang && body.pathname) { if (body.lang && body.pathname) {
const filePath = join(import.meta.dirname, `./data/${body.lang}.json`); const filePath = join(import.meta.dirname, `./data/${body.lang}.json`)
const redirectUrl = await new Promise<string | null>( const redirectUrl = await new Promise<string | null>(
(resolve, reject) => { (resolve, reject) => {
@@ -14,58 +14,58 @@ export default async (req: Request) => {
emitClose: false, emitClose: false,
encoding: "utf-8", encoding: "utf-8",
highWaterMark: 1024, highWaterMark: 1024,
}); })
const data: string[] = []; const data: string[] = []
stream.on("data", (chunk) => { stream.on("data", (chunk) => {
if (data.length === 3) { if (data.length === 3) {
data.shift(); data.shift()
} }
data.push(chunk.toString()); data.push(chunk.toString())
// Since we strip trailing slash (in the trailingSlash middleware) before entering this middleware, // Since we strip trailing slash (in the trailingSlash middleware) before entering this middleware,
// we need check matching paths both including and excluding trailing slash. // we need check matching paths both including and excluding trailing slash.
const re = new RegExp(`"${body.pathname}\/?":"([^"]+)"`); const re = new RegExp(`"${body.pathname}\/?":"([^"]+)"`)
const match = data.join("").match(re); const match = data.join("").match(re)
if (match?.[1]) { if (match?.[1]) {
stream.destroy(); stream.destroy()
resolve(match[1]); resolve(match[1])
} }
}); })
stream.on("error", (err) => { stream.on("error", (err) => {
console.error("Stream error:", err); console.error("Stream error:", err)
stream.destroy(); stream.destroy()
reject(err); reject(err)
}); })
stream.on("end", () => { stream.on("end", () => {
stream.destroy(); stream.destroy()
resolve(null); // No match found resolve(null) // No match found
}); })
} }
); )
if (redirectUrl) { if (redirectUrl) {
// Make sure to exclude trailing slash in the redirectUrl to avoid an extra middleware roundtrip // Make sure to exclude trailing slash in the redirectUrl to avoid an extra middleware roundtrip
const redirectUrlWithoutTrailingSlash = redirectUrl.endsWith("/") const redirectUrlWithoutTrailingSlash = redirectUrl.endsWith("/")
? redirectUrl.slice(0, -1) ? redirectUrl.slice(0, -1)
: redirectUrl; : redirectUrl
if (redirectUrlWithoutTrailingSlash === body.pathname) { if (redirectUrlWithoutTrailingSlash === body.pathname) {
console.log( console.log(
`[scandic-redirect] recieved ${body.pathname}, found ${redirectUrlWithoutTrailingSlash}, no-op` `[scandic-redirect] recieved ${body.pathname}, found ${redirectUrlWithoutTrailingSlash}, no-op`
); )
return new Response("Not Found", { status: 404 }); return new Response("Not Found", { status: 404 })
} }
console.log( console.log(
`[scandic-redirect] recieved ${body.pathname}, return ${redirectUrlWithoutTrailingSlash}, success` `[scandic-redirect] recieved ${body.pathname}, return ${redirectUrlWithoutTrailingSlash}, success`
); )
return new Response(redirectUrlWithoutTrailingSlash); return new Response(redirectUrlWithoutTrailingSlash)
} }
} }
console.log(`[scandic-redirect] recieved ${body.pathname}, not found`); console.log(`[scandic-redirect] recieved ${body.pathname}, not found`)
return new Response("Not Found", { status: 404 }); return new Response("Not Found", { status: 404 })
} catch (error) { } catch (error) {
return new Response("Bad request", { status: 400 }); return new Response("Bad request", { status: 400 })
}
} }
};

View File

@@ -6,7 +6,8 @@
"scripts": { "scripts": {
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"generate": "jiti ./scripts/generateRedirectFile/index.ts" "generate": "jiti ./scripts/generateRedirectFile/index.ts",
"format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@netlify/functions": "^3.0.0" "@netlify/functions": "^3.0.0"

View File

@@ -2,11 +2,14 @@ import styles from "./page.module.css"
import type { LangParams, LayoutArgs, StatusParams } from "@/types/params" import type { LangParams, LayoutArgs, StatusParams } from "@/types/params"
export default async function MiddlewareError(props: LayoutArgs<LangParams & StatusParams>) { export default async function MiddlewareError(
const params = await props.params; props: LayoutArgs<LangParams & StatusParams>
) {
const params = await props.params
return ( return (
// eslint-disable-next-line formatjs/no-literal-string-in-jsx // eslint-disable-next-line formatjs/no-literal-string-in-jsx
<div className={styles.layout}>Middleware error {params.lang} {params.status} <div className={styles.layout}>
Middleware error {params.lang} {params.status}
</div> </div>
); )
} }

View File

@@ -6,8 +6,10 @@ import { Receipt } from "@/components/HotelReservation/MyStay/Receipt"
import type { LangParams, PageArgs } from "@/types/params" import type { LangParams, PageArgs } from "@/types/params"
export default async function ReceiptPage(props: PageArgs<LangParams, { RefId?: string }>) { export default async function ReceiptPage(
const searchParams = await props.searchParams; props: PageArgs<LangParams, { RefId?: string }>
) {
const searchParams = await props.searchParams
if (!searchParams.RefId) { if (!searchParams.RefId) {
notFound() notFound()
} }

View File

@@ -54,7 +54,7 @@ export default async function CampaignHotelListing({
{ {
label: intl.formatMessage({ label: intl.formatMessage({
id: "common.tripAdvisorRating", id: "common.tripAdvisorRating",
defaultMessage: "TripAdvisor rating", defaultMessage: "Tripadvisor rating",
}), }),
value: HotelSortOption.TripAdvisorRating, value: HotelSortOption.TripAdvisorRating,
}, },

View File

@@ -37,7 +37,7 @@ export default function Row({ transaction }: RowProps) {
transaction.nights === 0 transaction.nights === 0
? intl.formatMessage({ ? intl.formatMessage({
id: "earnAndBurn.journeyTable.pointsActivity", id: "earnAndBurn.journeyTable.pointsActivity",
defaultMessage: "Points activity", defaultMessage: "Point activity",
}) })
: transaction.hotelName && transaction.city : transaction.hotelName && transaction.city
? `${transaction.hotelName}, ${transaction.city} ${nightsMsg}` ? `${transaction.hotelName}, ${transaction.city} ${nightsMsg}`

View File

@@ -85,7 +85,7 @@ export default async function DestinationCityPage({
{ {
label: intl.formatMessage({ label: intl.formatMessage({
id: "common.tripAdvisorRating", id: "common.tripAdvisorRating",
defaultMessage: "TripAdvisor rating", defaultMessage: "Tripadvisor rating",
}), }),
value: HotelSortOption.TripAdvisorRating, value: HotelSortOption.TripAdvisorRating,
}, },

View File

@@ -235,7 +235,7 @@ export default function GuestDetails({
<span> <span>
{intl.formatMessage({ {intl.formatMessage({
id: "myStay.guestDetails.modifyGuestDetails", id: "myStay.guestDetails.modifyGuestDetails",
defaultMessage: "Modify guest details", defaultMessage: "Edit guest details",
})} })}
</span> </span>
</Typography> </Typography>
@@ -265,7 +265,7 @@ export default function GuestDetails({
<span> <span>
{intl.formatMessage({ {intl.formatMessage({
id: "myStay.guestDetails.modifyGuestDetails", id: "myStay.guestDetails.modifyGuestDetails",
defaultMessage: "Modify guest details", defaultMessage: "Edit guest details",
})} })}
</span> </span>
</Typography> </Typography>
@@ -280,7 +280,7 @@ export default function GuestDetails({
<Dialog <Dialog
aria-label={intl.formatMessage({ aria-label={intl.formatMessage({
id: "myStay.guestDetails.modifyGuestDetails", id: "myStay.guestDetails.modifyGuestDetails",
defaultMessage: "Modify guest details", defaultMessage: "Edit guest details",
})} })}
> >
{({ close }) => ( {({ close }) => (
@@ -288,7 +288,7 @@ export default function GuestDetails({
<ModalContentWithActions <ModalContentWithActions
title={intl.formatMessage({ title={intl.formatMessage({
id: "myStay.guestDetails.modifyGuestDetails", id: "myStay.guestDetails.modifyGuestDetails",
defaultMessage: "Modify guest details", defaultMessage: "Edit guest details",
})} })}
onClose={() => setIsModifyGuestDetailsOpen(false)} onClose={() => setIsModifyGuestDetailsOpen(false)}
content={ content={

View File

@@ -301,7 +301,7 @@ export default function Room({ booking, roomNr, user }: RoomProps) {
<p> <p>
{intl.formatMessage({ {intl.formatMessage({
id: "myStay.modifyBy", id: "myStay.modifyBy",
defaultMessage: "Modify By", defaultMessage: "Modify by",
})} })}
</p> </p>
</Typography> </Typography>

View File

@@ -36,7 +36,7 @@ export default async function LevelProgressCard({
<h2 id="level-progress-card-title" className={styles.title}> <h2 id="level-progress-card-title" className={styles.title}>
{intl.formatMessage({ {intl.formatMessage({
id: "myPages.yourLevelProgress", id: "myPages.yourLevelProgress",
defaultMessage: "Your Level Progress", defaultMessage: "Your level progress",
})} })}
</h2> </h2>
</Typography> </Typography>

View File

@@ -293,7 +293,7 @@ export default function BookedRoomSidePeekContent({
<p> <p>
{intl.formatMessage({ {intl.formatMessage({
id: "myStay.modifyBy", id: "myStay.modifyBy",
defaultMessage: "Modify By", defaultMessage: "Modify by",
})} })}
</p> </p>
</Typography> </Typography>

View File

@@ -85,7 +85,7 @@ export function translateSeatingType(type: string, intl: IntlShape) {
case SeatingType.Theatre: case SeatingType.Theatre:
return intl.formatMessage({ return intl.formatMessage({
id: "meetingRoomCard.theatre", id: "meetingRoomCard.theatre",
defaultMessage: "Theatre", defaultMessage: "Theater",
}) })
case SeatingType.UShape: case SeatingType.UShape:
return intl.formatMessage({ return intl.formatMessage({

View File

@@ -20,7 +20,8 @@
"ci:build": "yarn lint && yarn test && yarn build", "ci:build": "yarn lint && yarn test && yarn build",
"clean": "rm -rf .next", "clean": "rm -rf .next",
"check-types": "tsc --noEmit", "check-types": "tsc --noEmit",
"include:shared": "jiti ../../scripts/copyFiles.ts ../../shared public/_static/shared" "include:shared": "jiti ../../scripts/copyFiles.ts ../../shared public/_static/shared",
"format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@contentstack/live-preview-utils": "^3.2.1", "@contentstack/live-preview-utils": "^3.2.1",
@@ -127,7 +128,6 @@
"json-sort-cli": "^4.0.9", "json-sort-cli": "^4.0.9",
"lint-staged": "^15.5.2", "lint-staged": "^15.5.2",
"netlify-plugin-cypress": "^2.2.1", "netlify-plugin-cypress": "^2.2.1",
"prettier": "^3.5.3",
"schema-dts": "^1.1.5", "schema-dts": "^1.1.5",
"start-server-and-test": "^2.0.11", "start-server-and-test": "^2.0.11",
"ts-morph": "^25.0.1", "ts-morph": "^25.0.1",

View File

@@ -1,8 +0,0 @@
module.exports = {
semi: false,
trailingComma: "es5",
singleQuote: false,
printWidth: 80,
tabWidth: 2,
endOfLine: "lf",
}

View File

@@ -0,0 +1,14 @@
/**
* @see https://prettier.io/docs/configuration
* @type {import("prettier").Config}
*/
const config = {
semi: false,
trailingComma: "es5",
singleQuote: false,
printWidth: 80,
tabWidth: 2,
endOfLine: "lf",
}
export default config

View File

@@ -11,6 +11,7 @@
"dev:ds": "turbo run dev --filter=@scandic-hotels/design-system --output-logs new-only", "dev:ds": "turbo run dev --filter=@scandic-hotels/design-system --output-logs new-only",
"dev:sas": "turbo run dev --filter=@scandic-hotels/partner-sas --output-logs new-only", "dev:sas": "turbo run dev --filter=@scandic-hotels/partner-sas --output-logs new-only",
"test": "turbo run test", "test": "turbo run test",
"format": "turbo run format",
"postinstall": "husky", "postinstall": "husky",
"icons:update": "jiti scripts/material-symbols-update.mts", "icons:update": "jiti scripts/material-symbols-update.mts",
"check-types": "turbo run check-types", "check-types": "turbo run check-types",
@@ -25,7 +26,8 @@
"i18n:distribute": "jiti scripts/i18n/distribute.ts scandic-web partner-sas", "i18n:distribute": "jiti scripts/i18n/distribute.ts scandic-web partner-sas",
"i18n:push": "yarn i18n:extract && yarn i18n:upload", "i18n:push": "yarn i18n:extract && yarn i18n:upload",
"i18n:pull": "yarn i18n:download && yarn i18n:compile && yarn i18n:distribute", "i18n:pull": "yarn i18n:download && yarn i18n:compile && yarn i18n:distribute",
"i18n:sync": "yarn i18n:push && yarn i18n:pull" "i18n:sync": "yarn i18n:push && yarn i18n:pull",
"i18n:syncDefaultMessage": "yarn i18n:download && bun scripts/i18n/syncDefaultMessage/index.ts scripts/i18n/translations/en.json '{apps,packages}/**/*.{tsx,ts}' && yarn format --force"
}, },
"workspaces": [ "workspaces": [
"apps/*", "apps/*",
@@ -41,6 +43,7 @@
"husky": "^9.1.7", "husky": "^9.1.7",
"jiti": "^1.21.0", "jiti": "^1.21.0",
"lint-staged": "^15.2.2", "lint-staged": "^15.2.2",
"prettier": "^3.6.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"turbo": "^2.5.2" "turbo": "^2.5.2"
}, },

View File

@@ -38,7 +38,7 @@ export function Promos({ booking }: PromosProps) {
<Promo <Promo
buttonText={intl.formatMessage({ buttonText={intl.formatMessage({
id: "bookingConfirmation.promos.buyAddonsButtonText", id: "bookingConfirmation.promos.buyAddonsButtonText",
defaultMessage: "View and buy add-ons", defaultMessage: "View and buy extras",
})} })}
href={myStayURL} href={myStayURL}
text={intl.formatMessage({ text={intl.formatMessage({

View File

@@ -96,7 +96,7 @@ export default function FilterAndSortModal({
{ {
label: intl.formatMessage({ label: intl.formatMessage({
id: "common.tripAdvisorRating", id: "common.tripAdvisorRating",
defaultMessage: "TripAdvisor rating", defaultMessage: "Tripadvisor rating",
}), }),
value: SortOrder.TripAdvisorRating, value: SortOrder.TripAdvisorRating,
}, },

View File

@@ -78,7 +78,7 @@ export default function HotelSorter({ discreet }: HotelSorterProps) {
{ {
label: intl.formatMessage({ label: intl.formatMessage({
id: "common.tripAdvisorRating", id: "common.tripAdvisorRating",
defaultMessage: "TripAdvisor rating", defaultMessage: "Tripadvisor rating",
}), }),
value: SortOrder.TripAdvisorRating, value: SortOrder.TripAdvisorRating,
}, },

View File

@@ -63,7 +63,8 @@ export default function BreakfastAccordionItem({
<p> <p>
{intl.formatMessage({ {intl.formatMessage({
id: "breakfastAccordion.allDayBreakfast", id: "breakfastAccordion.allDayBreakfast",
defaultMessage: "All-day breakfast", defaultMessage:
"Grab our Brekkie Deal for 69 SEK coffee & sandwich, available 6 AMnoon.",
})} })}
</p> </p>
</Typography> </Typography>

View File

@@ -8,7 +8,8 @@
"lint": "eslint . --max-warnings 0 && tsc --noEmit", "lint": "eslint . --max-warnings 0 && tsc --noEmit",
"lint:fix": "eslint . --fix && tsc --noEmit", "lint:fix": "eslint . --fix && tsc --noEmit",
"test": "vitest run --passWithNoTests", "test": "vitest run --passWithNoTests",
"test:watch": "vitest" "test:watch": "vitest",
"format": "prettier --write ."
}, },
"exports": { "exports": {
"./BookingFlowConfig": "./lib/bookingFlowConfig/bookingFlowConfig.tsx", "./BookingFlowConfig": "./lib/bookingFlowConfig/bookingFlowConfig.tsx",

View File

@@ -1,8 +0,0 @@
module.exports = {
semi: false,
trailingComma: "es5",
singleQuote: false,
printWidth: 80,
tabWidth: 2,
endOfLine: "lf",
}

View File

@@ -0,0 +1,14 @@
/**
* @see https://prettier.io/docs/configuration
* @type {import("prettier").Config}
*/
const config = {
semi: false,
trailingComma: "es5",
singleQuote: false,
printWidth: 80,
tabWidth: 2,
endOfLine: "lf",
}
export default config

View File

@@ -8,7 +8,8 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"check-types": "tsc --noEmit", "check-types": "tsc --noEmit",
"lint": "eslint . --max-warnings 0 && tsc --noEmit" "lint": "eslint . --max-warnings 0 && tsc --noEmit",
"format": "prettier --write ."
}, },
"exports": { "exports": {
"./polyfills": "./polyfills/index.ts", "./polyfills": "./polyfills/index.ts",

View File

@@ -269,7 +269,6 @@
"lint-staged": "^15.5.2", "lint-staged": "^15.5.2",
"motion": "^12.10.0", "motion": "^12.10.0",
"playwright": "^1.54.2", "playwright": "^1.54.2",
"prettier": "^3.5.3",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"rollup": "^4.40.2", "rollup": "^4.40.2",

View File

@@ -8,7 +8,8 @@
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"check-types": "tsc --noEmit", "check-types": "tsc --noEmit",
"lint": "eslint . --max-warnings 0 && tsc --noEmit" "lint": "eslint . --max-warnings 0 && tsc --noEmit",
"format": "prettier --write ."
}, },
"exports": { "exports": {
"./*": "./lib/*.ts", "./*": "./lib/*.ts",

View File

@@ -8,7 +8,8 @@
"lint": "eslint . --max-warnings 0 && tsc --noEmit", "lint": "eslint . --max-warnings 0 && tsc --noEmit",
"lint:fix": "eslint . --fix && tsc --noEmit", "lint:fix": "eslint . --fix && tsc --noEmit",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest" "test:watch": "vitest",
"format": "prettier --write ."
}, },
"exports": { "exports": {
".": "./lib/index.ts", ".": "./lib/index.ts",

View File

@@ -1,8 +0,0 @@
module.exports = {
semi: false,
trailingComma: "es5",
singleQuote: false,
printWidth: 80,
tabWidth: 2,
endOfLine: "lf",
};

View File

@@ -0,0 +1,14 @@
/**
* @see https://prettier.io/docs/configuration
* @type {import("prettier").Config}
*/
const config = {
semi: false,
trailingComma: "es5",
singleQuote: false,
printWidth: 80,
tabWidth: 2,
endOfLine: "lf",
}
export default config

View File

@@ -31,8 +31,11 @@ Examples:
const isDryRun = args.includes("--dry-run"); const isDryRun = args.includes("--dry-run");
const globPattern = args[1]; const globPattern = args[1];
// Find all component files // Find all component files
const componentFiles = glob.sync(globPattern);
const componentFiles = glob.sync(globPattern);
console.log(
`Found ${componentFiles.length} files to sync using ${globPattern}`
);
let filesUpdated = 0; let filesUpdated = 0;
for (const filePath of componentFiles) { for (const filePath of componentFiles) {

View File

@@ -74,9 +74,32 @@ describe("syncFile", () => {
}); });
expect(fsMock.readFileSync).toHaveBeenCalledWith("file.ts", "utf-8"); expect(fsMock.readFileSync).toHaveBeenCalledWith("file.ts", "utf-8");
expect(fsMock.writeFileSync).toHaveBeenCalled(); // expect(fsMock.writeFileSync).toHaveBeenCalled();
expect(result).toEqual(createMockComponent("myKey", "old message")); expect(result).toEqual(createMockComponent("myKey", "old message"));
}); });
it("updates complex components with replacements", async () => {
const fsMock = (await import("fs")) as any;
fsMock.existsSync.mockReturnValue(true);
fsMock.readFileSync.mockReturnValue(
createComplexMockComponent(
"complexKey",
"Yes, I accept the general <termsAndConditionsLink>Booking & Cancellation Terms</termsAndConditionsLink>, and understand that Scandic will process my personal data in accordance with <privacyPolicyLink>Scandic's Privacy policy</privacyPolicyLink>."
)
);
const { syncFile } = await import("./syncFile");
const { fileContent: result } = syncFile({
path: "file.ts",
translations: { complexKey: "replace this text" },
});
expect(fsMock.readFileSync).toHaveBeenCalledWith("file.ts", "utf-8");
// expect(fsMock.writeFileSync).toHaveBeenCalled();
expect(result).toContain("replace this text");
});
}); });
function createMockComponent(translationId: string, defaultMessage: string) { function createMockComponent(translationId: string, defaultMessage: string) {
@@ -90,3 +113,25 @@ function createMockComponent(translationId: string, defaultMessage: string) {
return <div>{message}</div>; return <div>{message}</div>;
}`; }`;
} }
function createComplexMockComponent(
translationId: string,
defaultMessage: string
) {
return `export function TestComponent() {
const intl = useIntl();
return (
<div>
{intl.formatMessage(
{
id: "${translationId}",
defaultMessage: "${defaultMessage}",
},
{
replacement: (str) => <a href="#">{str}</a>,
}
)}
</div>
);
}`;
}

View File

@@ -99,4 +99,38 @@ describe("syncIntlFormatMessage", () => {
fileContent, fileContent,
}); });
}); });
it("handles formatMessage with replacements", () => {
const fileContent =
'intl.formatMessage({ id: "myKey", defaultMessage: "<stuff>old message</stuff>" }, { stuff: (str) => <span>{str}</span> } })';
expect(
syncIntlFormatMessage({
fileContent,
translations: {
myKey: "new message",
},
})
).toEqual({
updated: true,
fileContent:
'intl.formatMessage({ id: "myKey", defaultMessage: "new message" }, { stuff: (str) => <span>{str}</span> } })',
});
});
it("handles formatMessage with replacements", () => {
const fileContent =
'intl.formatMessage({ id: "myKey", defaultMessage: "<stuff>old message</stuff>" }, { stuff: (str) => <span>{str}</span> } })';
expect(
syncIntlFormatMessage({
fileContent,
translations: {
myKey: "<stuff>new message</stuff>",
},
})
).toEqual({
updated: true,
fileContent:
'intl.formatMessage({ id: "myKey", defaultMessage: "<stuff>new message</stuff>" }, { stuff: (str) => <span>{str}</span> } })',
});
});
}); });

View File

@@ -11,15 +11,17 @@ export function syncIntlFormatMessage({
for (const [messageId, messageValue] of entries) { for (const [messageId, messageValue] of entries) {
const escapedId = messageId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const escapedId = messageId.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
// Find intl.formatMessage({...}) blocks that contain the specific id // Find intl.formatMessage({...}) or intl.formatMessage({...}, secondArg) blocks that contain the specific id
const outerRegex = new RegExp( const outerRegex = new RegExp(
`intl\\.formatMessage\\(\\s*\\{([^}]*?\\bid\\s*:\\s*['"]${escapedId}['"][^}]*?)\\}\\s*\\)`, // group 1 = inner object content (without surrounding braces)
// group 2 = optional second argument (anything until the closing parenthesis, non-greedy)
`intl\\.formatMessage\\(\\s*\\{([^}]*?\\bid\\s*:\\s*['"]${escapedId}['"][^}]*?)\\}\\s*(?:,\\s*([^)]*?))?\\s*\\)`,
"gs" "gs"
); );
fileContent = fileContent.replace( fileContent = fileContent.replace(
outerRegex, outerRegex,
(fullMatch, innerObject) => { (fullMatch: string, innerObject: string, secondArg?: string) => {
// Find defaultMessage: '...' or "..." // Find defaultMessage: '...' or "..."
const dmRegex = const dmRegex =
/defaultMessage\s*:\s*(['"])((?:\\.|[\s\S])*?)\1/; /defaultMessage\s*:\s*(['"])((?:\\.|[\s\S])*?)\1/;
@@ -38,7 +40,9 @@ export function syncIntlFormatMessage({
); );
updated = true; updated = true;
return `intl.formatMessage({${newInner}})`; // Preserve secondArg if present
const secondArgPart = secondArg ? `, ${secondArg}` : "";
return `intl.formatMessage({${newInner}}${secondArgPart})`;
} }
); );
} }

View File

@@ -1,3 +0,0 @@
export function TestComponent() {
return <div>Test</div>;
}

View File

@@ -1,6 +0,0 @@
export function TestComponent() {
const intl = useIntl();
return (
<div>{intl.formatMessage({ id: "myKey", defaultMessage: "Test" })}</div>
);
}

View File

@@ -12,6 +12,7 @@
}, },
"lint": { "dependsOn": [] }, "lint": { "dependsOn": [] },
"test": {}, "test": {},
"check-types": {} "check-types": {},
"format": { "dependsOn": [] }
} }
} }

View File

@@ -5987,7 +5987,6 @@ __metadata:
lint-staged: "npm:^15.5.2" lint-staged: "npm:^15.5.2"
motion: "npm:^12.10.0" motion: "npm:^12.10.0"
playwright: "npm:^1.54.2" playwright: "npm:^1.54.2"
prettier: "npm:^3.5.3"
react: "npm:^19.1.0" react: "npm:^19.1.0"
react-dom: "npm:^19.1.0" react-dom: "npm:^19.1.0"
rollup: "npm:^4.40.2" rollup: "npm:^4.40.2"
@@ -6084,7 +6083,6 @@ __metadata:
ioredis: "npm:^5.6.1" ioredis: "npm:^5.6.1"
pino: "npm:^9.6.0" pino: "npm:^9.6.0"
pino-pretty: "npm:^13.0.0" pino-pretty: "npm:^13.0.0"
prettier: "npm:^3.5.3"
typescript: "npm:^5.7.2" typescript: "npm:^5.7.2"
languageName: unknown languageName: unknown
linkType: soft linkType: soft
@@ -6192,7 +6190,6 @@ __metadata:
netlify-plugin-cypress: "npm:^2.2.1" netlify-plugin-cypress: "npm:^2.2.1"
next: "npm:15.3.4" next: "npm:15.3.4"
next-auth: "npm:5.0.0-beta.29" next-auth: "npm:5.0.0-beta.29"
prettier: "npm:^3.5.3"
react: "npm:19.1.0" react: "npm:19.1.0"
react-aria-components: "npm:^1.8.0" react-aria-components: "npm:^1.8.0"
react-day-picker: "npm:^9.6.7" react-day-picker: "npm:^9.6.7"
@@ -16714,12 +16711,12 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"prettier@npm:^3.5.3": "prettier@npm:^3.6.2":
version: 3.5.3 version: 3.6.2
resolution: "prettier@npm:3.5.3" resolution: "prettier@npm:3.6.2"
bin: bin:
prettier: bin/prettier.cjs prettier: bin/prettier.cjs
checksum: 10c0/3880cb90b9dc0635819ab52ff571518c35bd7f15a6e80a2054c05dbc8a3aa6e74f135519e91197de63705bcb38388ded7e7230e2178432a1468005406238b877 checksum: 10c0/488cb2f2b99ec13da1e50074912870217c11edaddedeadc649b1244c749d15ba94e846423d062e2c4c9ae683e2d65f754de28889ba06e697ac4f988d44f45812
languageName: node languageName: node
linkType: hard linkType: hard
@@ -18012,6 +18009,7 @@ __metadata:
husky: "npm:^9.1.7" husky: "npm:^9.1.7"
jiti: "npm:^1.21.0" jiti: "npm:^1.21.0"
lint-staged: "npm:^15.2.2" lint-staged: "npm:^15.2.2"
prettier: "npm:^3.6.2"
ts-node: "npm:^10.9.2" ts-node: "npm:^10.9.2"
turbo: "npm:^2.5.2" turbo: "npm:^2.5.2"
languageName: unknown languageName: unknown