From 53eee5d40dc089ff34918be00ddc9ce767f16da0 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sat, 5 Sep 2026 12:21:10 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(site-kit):=20=EC=A0=9C=ED=92=88?= =?UTF-8?q?=EC=9D=B4=20=EA=B0=80=EC=A1=B1=20=EB=B0=96=20=EC=96=B8=EC=96=B4?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=80=EC=A7=88=20=EC=88=98=20=EC=9E=88=EA=B2=8C?= =?UTF-8?q?=20(D-018,=20D-019)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `LOCALES`는 devslab.kr이 마케팅하는 언어 목록이지 모든 제품의 목록이 아니다. BookLinq는 인도 살롱에 팔고, 그 어시스턴트는 이미 타밀·텔루구· 벵골·마라티·구자라티·칸나다로 답한다. 그 6개를 가족 목록에 넣으면 AskLinq와 devslab.kr의 피커에 번역 없는 항목 6개가 생기고, 안 넣으면 BookLinq 웹이 제품이 하는 일에 대해 거짓말을 한다. 그래서 가족이 메커니즘을 갖고 제품이 자기 명사를 댄다. `defineLocaleRegistry({ extra })`가 가족 14개 + 제품 언어로 레지스트리를 만들고, 로케일을 다루는 헬퍼가 전부 그것을 받는다 — validateCatalogs · buildMetadata · buildSitemap · localizedPath · localizedUrl · LocaleMenu · SiteHeader. 안 넘기면 가족 레지스트리라 기존 소비자는 무변경이다. 추가 로케일은 국기 아트워크를 싣지 않는다. `flagCountry`로 이 패키지가 이미 벤더링한 나라를 지목하고, 새 `FLAGS_BY_COUNTRY`가 나라로 색인한다. 국기는 나라를 가리키지 언어를 가리키지 않으므로 인도 로케일 7개가 정당하게 `in` 하나를 공유한다. 국기 데이터는 라이선스가 있고 생성되며 여기서 스캔되므로 제품 레포로 복제될 자리가 아니다. ## 같이 고친 버그 둘 **select 변형이 SSR에서 아무 옵션도 선택하지 않았다.** ``에 `value` 없음. 문서: 결정로그 D-018·D-019, README 양어 짝. --- .changeset/locale-registry.md | 31 +++++ docs/decisions.md | 57 +++++++++ packages/site-kit/README.ko.md | 37 +++++- packages/site-kit/README.md | 37 +++++- packages/site-kit/scripts/build-flags.mjs | 44 +++++-- packages/site-kit/src/core/catalog.d.mts | 10 +- packages/site-kit/src/core/catalog.mjs | 21 +++- packages/site-kit/src/core/flags.d.mts | 4 +- packages/site-kit/src/core/flags.mjs | 71 ++++++++--- packages/site-kit/src/core/index.mjs | 2 +- packages/site-kit/src/core/locales.d.mts | 34 ++++++ packages/site-kit/src/core/locales.mjs | 112 ++++++++++++++---- packages/site-kit/src/core/seo.d.mts | 20 ++-- packages/site-kit/src/core/seo.mjs | 40 ++++--- .../site-kit/src/solid/__tests__/ssr.test.tsx | 32 +++++ packages/site-kit/src/solid/chrome.tsx | 8 ++ packages/site-kit/src/solid/locale-menu.tsx | 51 ++++++-- tests/site-kit-core.test.mjs | 96 ++++++++++++++- 18 files changed, 602 insertions(+), 105 deletions(-) create mode 100644 .changeset/locale-registry.md diff --git a/.changeset/locale-registry.md b/.changeset/locale-registry.md new file mode 100644 index 0000000..3e0a498 --- /dev/null +++ b/.changeset/locale-registry.md @@ -0,0 +1,31 @@ +--- +"@devslab/site-kit": minor +--- + +Products can ship languages the family does not carry. + +`defineLocaleRegistry({ extra })` builds the family's fourteen plus a product's +own, and every locale-aware helper accepts one — `validateCatalogs`, +`buildMetadata`, `buildSitemap`, `localizedPath`, `localizedUrl`, `LocaleMenu`, +`SiteHeader`. Omitted, they use the family registry, so existing consumers are +unchanged. An extra locale names a `flagCountry` this package already vendors +rather than shipping artwork, and `flagFor(locale, registry)` resolves it; +`FLAGS_BY_COUNTRY` is the new country-keyed index. + +The motivating case: BookLinq sells to salons in India and its assistant +already answers in Tamil, Telugu, Bengali, Marathi, Gujarati and Kannada. Those +are not family languages and putting them in `LOCALES` would give AskLinq and +devslab.kr six entries they have no copy for. + +Two bugs fixed along the way: + +- **`SelectLocaleMenu` marked no option selected under SSR.** It set + `value` on the ``가 아니다 (2026-09-05) + +**결정.** `SelectLocaleMenu`가 ``에는 `value` 콘텐츠 속성이 **존재하지 않는다** — DOM +프로퍼티일 뿐이다. 브라우저에서는 Solid가 프로퍼티로 대입해 동작하지만, +SSR은 마크업이 문자열이라 `value="en"`이 브라우저가 무시하는 속성으로 +직렬화된다. 결과적으로 **모든 방문자가 모든 언어에서 `option[0]`을 자기 +현재 언어로 봤고**, 그 컨트롤을 건드리면 방금 자기 언어였던 것이 아니라 +첫 번째 언어로 바뀌었다. BookLinq 배포 워커에서 발견 — 유닛/SSR 테스트는 +전부 초록이었다. + +**트레이드오프.** 없다. `selected`는 실제 콘텐츠 속성이고 클라이언트 동작도 +동일하다. + +**재검토 시점.** 없음. 회귀 테스트가 SSR 마크업에 직접 어설션한다 +(`ssr.test.tsx`, "marks the current option selected"). + +--- + +## D-018 — 제품은 가족 밖 언어를 가질 수 있다: 로케일 레지스트리 (2026-09-05) + +**결정.** 로케일을 다루는 site-kit 헬퍼 전부가 **레지스트리**를 받는다. +`defineLocaleRegistry({ extra })`가 가족 14개 + 제품 언어로 레지스트리를 +만들고, `validateCatalogs`·`buildMetadata`·`buildSitemap`·`localizedPath`· +`localizedUrl`·`LocaleMenu`·`SiteHeader`가 이를 옵션으로 받는다. 인자를 +안 주면 가족 레지스트리 — 기존 소비자는 바이트 단위로 동일하다. + +가족 목록(`LOCALES`)은 **넓히지 않는다.** 추가 로케일은 +`flagCountry`로 이 패키지가 이미 벤더링한 국기를 지목한다(`FLAGS_BY_COUNTRY`). + +**근거.** `LOCALES`는 devslab.kr이 마케팅하는 언어 목록이지 모든 제품의 +목록이 아니다. BookLinq는 인도 살롱에 팔고, 그 어시스턴트는 이미 타밀· +텔루구·벵골·마라티·구자라티·칸나다로 답한다. 그 6개를 가족 목록에 넣으면 +AskLinq와 devslab.kr의 피커에 번역이 없는 항목 6개가 생기고, 안 넣으면 +BookLinq 웹이 제품이 하는 일에 대해 거짓말을 한다. 그래서 가족은 +메커니즘을 갖고 제품이 자기 명사를 댄다(D-113 관용구). + +국기를 제품이 싣게 하지 않은 이유: 국기 데이터는 라이선스가 있고, 생성되며, +여기서 보안 스캔을 거친다. 그리고 인도 언어 7개가 정당하게 국기 하나를 +공유한다 — 국기는 나라를 가리키지 언어를 가리키지 않는다. + +**같이 고친 것.** `localeAttributes`가 `dir`을 정의에서 읽는다. 이전 형태는 +`canonical === "ar" ? "rtl" : "ltr"`이었고, 이는 아랍어가 가족의 유일한 RTL +언어인 동안에만 참이었다 — 우르두어나 히브리어를 추가한 제품은 모든 테스트가 +초록인 채로 좌횡서로 렌더됐을 것이다. + +**트레이드오프.** 레지스트리를 안 넘기면 조용히 가족 목록으로 동작한다 — +제품이 `buildMetadata`에 레지스트리를 빠뜨리면 페이지는 타밀어로 그려지면서 +hreflang에는 타밀어가 없다고 말한다. 소비자 쪽 테스트가 잡아야 한다. + +**재검토 시점.** 세 번째 제품이 가족 밖 언어를 요구하면 — 그때는 목록이 +아니라 등록 방식을 다시 본다. + +--- + ## D-017 — 국기는 site-kit 데이터, 아이콘 세트 밖 (2026-09-02) **결정.** 로케일 피커의 국기 14개는 `@devslab/site-kit`가 `src/core/flags.mjs`로 diff --git a/packages/site-kit/README.ko.md b/packages/site-kit/README.ko.md index 3a95145..52e9476 100644 --- a/packages/site-kit/README.ko.md +++ b/packages/site-kit/README.ko.md @@ -1,6 +1,6 @@ # @devslab/site-kit -DevsLab 제품의 공개 웹사이트를 위한 공개 인프라 패키지다. 엄격한 14개 로케일 카탈로그, 로케일 협상, SEO/GEO 문서 생성기, 접근 가능한 SolidJS 2 사이트 셸을 제공한다. 제품명·주장·내비게이션·번역 문구는 항상 소비 앱이 소유한다. +DevsLab 제품의 공개 웹사이트를 위한 공개 인프라 패키지다. 가족 로케일에 대한 엄격한 카탈로그(제품별 확장 가능), 로케일 협상, SEO/GEO 문서 생성기, 접근 가능한 SolidJS 2 사이트 셸을 제공한다. 제품명·주장·내비게이션·번역 문구는 항상 소비 앱이 소유한다. ## 진입점 @@ -9,14 +9,45 @@ DevsLab 제품의 공개 웹사이트를 위한 공개 인프라 패키지다. - `@devslab/site-kit/tanstack-start` — 중립 메타데이터를 TanStack Start head descriptor로 변환 - `@devslab/site-kit/styles.css` — 논리 속성과 RTL을 지원하는 공통 사이트 스타일 -카탈로그 생성은 의도적으로 엄격하다. 14개 로케일 모두 동일한 키와 이름 기반 placeholder를 가져야 하며 런타임 문구 폴백은 없다. +카탈로그 생성은 의도적으로 엄격하다. 레지스트리의 모든 로케일이 동일한 키와 이름 기반 placeholder를 가져야 하며 런타임 문구 폴백은 없다. -사이트맵은 14개 로케일 alternate와 `x-default`를 출력한다. +사이트맵은 레지스트리 로케일마다 alternate 하나와 `x-default`를 출력한다. `buildVerifiedJsonLd`는 검토된 schema type과 claim allowlist만 허용하고 모든 claim leaf가 검증된 사실 레지스트리를 참조하도록 강제한다. `buildRobots`의 기존 environment-only 출력은 유지되며, 선택적 `policies`로 검색 인덱싱, 인용 crawler, 모델 학습 crawler를 각각 제어할 수 있다. +## 제품 로케일 + +`LOCALES`는 **가족 목록**이다 — devslab.kr이 마케팅하는 14개 언어이자 모든 +제품이 기본으로 받는 바닥. 모든 제품의 목록은 아니다. 가족이 갖지 않은 +언어로 파는 제품은 레지스트리를 만든다: + +```js +import { defineLocaleRegistry } from "@devslab/site-kit"; + +export const locales = defineLocaleRegistry({ + extra: [ + { code: "ta", language: "Tamil", nativeName: "தமிழ்", dir: "ltr", flagCountry: "in" }, + ], +}); +``` + +로케일을 다루는 헬퍼는 전부 이것을 받는다: `validateCatalogs(catalogs, "en", { registry })`, +`buildMetadata({ …, registry })`, `buildSitemap({ …, registry })`, +`localizedPath(path, locale, defaultLocale, registry)`, +``. 안 넘기면 가족 레지스트리이므로 +`defineLocaleRegistry`를 부른 적 없는 소비자는 영향이 없다. + +`flagCountry`는 이 패키지가 **이미 벤더링한** 나라를 지목해야 한다 +(`FLAG_COUNTRY` 참조). 제품은 국기 아트워크를 싣지 않는다 — 라이선스가 있고, +생성되며, 여기서 스캔된다. 그리고 국기는 나라를 가리키지 언어를 가리키지 +않으므로 인도 로케일 7개가 정당하게 `in`을 공유한다. + +레지스트리는 전부 넘기거나 전혀 안 넘기거나다. 레지스트리로 그린 페이지의 +메타데이터를 레지스트리 없이 만들면, 페이지는 타밀어로 렌더되면서 검색 +엔진에는 타밀어가 없다고 말한다. + ## 로케일 메뉴 variant `LocaleMenu`는 기본으로 네이티브 `` by default. `variant="flag"` renders a `
` disclosure whose trigger is the current locale's flag and whose rows are flag + native-name links — it works without JavaScript; Solid adds Escape-to-close and the `onLocaleChange(locale, href)` callback. `SiteHeader` forwards `localeVariant`. Flag data (`FLAG_COUNTRY`, `LOCALE_FLAGS`, `flagFor`) is exported from `@devslab/site-kit/flags`, a dedicated subpath — not the runtime-neutral `.` entry — because the vendored artwork is ~110 KB of SVG and most consumers never render a flag menu. The artwork is vendored from flag-icons (MIT, `flags/LICENSE-flag-icons.txt`). Flags are site-kit data, not `dds-icons` entries, because the icon set's contract requires single-colour `currentColor` strokes. diff --git a/packages/site-kit/scripts/build-flags.mjs b/packages/site-kit/scripts/build-flags.mjs index d61c4bf..2125c6e 100644 --- a/packages/site-kit/scripts/build-flags.mjs +++ b/packages/site-kit/scripts/build-flags.mjs @@ -35,33 +35,63 @@ const entries = Object.entries(FLAG_COUNTRY).map(([locale, country]) => { return { locale, country, viewBox, body }; }); +// One entry per country, not per locale: seven Indian languages would +// otherwise inline the same India SVG seven times. +const byCountry = [...new Map(entries.map((e) => [e.country, e])).values()]; + const js = `${HEADER} export const FLAG_COUNTRY = Object.freeze(${JSON.stringify(FLAG_COUNTRY, null, 2)}); +/** + * Flags indexed by country, not by locale. + * + * A locale-keyed map can only answer for languages the family ships. A + * product that adds its own — BookLinq sells in six Indian languages the + * family does not carry — names a country here instead, so nobody vendors + * a second copy of an SVG this package already has. + */ +export const FLAGS_BY_COUNTRY = Object.freeze({ +${byCountry.map((e) => ` ${JSON.stringify(e.country)}: Object.freeze({ country: ${JSON.stringify(e.country)}, viewBox: ${JSON.stringify(e.viewBox)}, body: ${JSON.stringify(e.body)} }),`).join("\n")} +}); + export const LOCALE_FLAGS = Object.freeze({ -${entries.map((e) => ` ${JSON.stringify(e.locale)}: Object.freeze({ country: ${JSON.stringify(e.country)}, viewBox: ${JSON.stringify(e.viewBox)}, body: ${JSON.stringify(e.body)} }),`).join("\n")} +${entries.map((e) => ` ${JSON.stringify(e.locale)}: FLAGS_BY_COUNTRY[${JSON.stringify(e.country)}],`).join("\n")} }); -export function flagFor(locale) { +/** + * @param {string} locale + * @param {{ LOCALES?: ReadonlyArray<{ code: string, flagCountry?: string }> }} [registry] + * A product's locale registry, for a locale the family does not carry. + * Its \`flagCountry\` names one of FLAGS_BY_COUNTRY. + */ +export function flagFor(locale, registry) { const flag = LOCALE_FLAGS[locale]; - if (!flag) throw new RangeError(\`No flag for locale: \${locale}\`); - return flag; + if (flag) return flag; + const extra = registry?.LOCALES?.find((definition) => definition.code === locale); + if (extra?.flagCountry) { + const vendored = FLAGS_BY_COUNTRY[extra.flagCountry]; + if (vendored) return vendored; + throw new RangeError(\`No vendored flag for country: \${extra.flagCountry} (locale \${locale})\`); + } + throw new RangeError(\`No flag for locale: \${locale}\`); } `; const dts = `${HEADER} +import type { LocaleRegistry } from "./locales.mjs"; import type { SiteLocale } from "./locales.mjs"; export interface LocaleFlag { readonly country: string; readonly viewBox: string; readonly body: string } export declare const FLAG_COUNTRY: Readonly>; +export declare const FLAGS_BY_COUNTRY: Readonly>; export declare const LOCALE_FLAGS: Readonly>; -export declare function flagFor(locale: SiteLocale): LocaleFlag; +export declare function flagFor(locale: string, registry?: LocaleRegistry): LocaleFlag; `; const targets = [["src/core/flags.mjs", js], ["src/core/flags.d.mts", dts]]; if (process.argv.includes("--check")) { const stale = targets.filter(([rel, next]) => readFileSync(join(pkg, rel), "utf8") !== next); if (stale.length) { console.error(`flags out of date: ${stale.map(([r]) => r).join(", ")} — run build-flags`); process.exit(1); } - console.log("site-kit flags: 14 locales, generated output in sync"); + console.log(`site-kit flags: ${entries.length} locales over ${byCountry.length} countries, in sync`); } else { for (const [rel, next] of targets) writeFileSync(join(pkg, rel), next); - console.log("site-kit flags: wrote src/core/flags.mjs and flags.d.mts (14 locales)"); + console.log(`site-kit flags: wrote flags.mjs and flags.d.mts (${entries.length} locales, ${byCountry.length} countries)`); } diff --git a/packages/site-kit/src/core/catalog.d.mts b/packages/site-kit/src/core/catalog.d.mts index 3a0f601..48937bd 100644 --- a/packages/site-kit/src/core/catalog.d.mts +++ b/packages/site-kit/src/core/catalog.d.mts @@ -1,5 +1,9 @@ -import type { SiteLocale } from "./locales.mjs"; +import type { LocaleRegistry, SiteLocale } from "./locales.mjs"; export type MessageCatalog = Record; export declare class CatalogValidationError extends Error { readonly issues: readonly string[] } -export declare function validateCatalogs(catalogs: Record, referenceLocale: SiteLocale): true; -export declare function createTranslator(catalog: MessageCatalog, locale: SiteLocale): (key: string, values?: Record) => string; +export declare function validateCatalogs( + catalogs: Record, + referenceLocale: Code, + options?: { registry?: LocaleRegistry }, +): true; +export declare function createTranslator(catalog: MessageCatalog, locale: string): (key: string, values?: Record) => string; diff --git a/packages/site-kit/src/core/catalog.mjs b/packages/site-kit/src/core/catalog.mjs index ee6cc73..a23544d 100644 --- a/packages/site-kit/src/core/catalog.mjs +++ b/packages/site-kit/src/core/catalog.mjs @@ -1,4 +1,4 @@ -import { LOCALES, canonicalLocale } from "./locales.mjs"; +import { FAMILY_LOCALES } from "./locales.mjs"; const placeholders = (message) => [...String(message).matchAll(/\{([A-Za-z][A-Za-z0-9_]*)\}/g)].map((match) => match[1]).sort(); const difference = (left, right) => left.filter((value) => !right.includes(value)); @@ -11,12 +11,25 @@ export class CatalogValidationError extends Error { } } -export function validateCatalogs(catalogs, referenceLocale) { - const reference = canonicalLocale(referenceLocale); +/** + * Every locale the site sells in must carry every key. + * + * `options.registry` says which locales those are. Omitted, it is the + * family's fourteen — which is the right answer for a family site and the + * wrong one for a product that added its own, because a catalog missing + * six languages would validate clean. + * + * @param {object} catalogs + * @param {string} referenceLocale + * @param {{ registry?: { LOCALES: ReadonlyArray<{code: string}>, canonicalLocale: (c: string) => string | undefined } }} [options] + */ +export function validateCatalogs(catalogs, referenceLocale, options = {}) { + const registry = options.registry ?? FAMILY_LOCALES; + const reference = registry.canonicalLocale(referenceLocale); if (!reference || !catalogs[reference]) throw new CatalogValidationError([`reference locale ${referenceLocale} is missing`]); const referenceKeys = Object.keys(catalogs[reference]).sort(); const issues = []; - for (const { code } of LOCALES) { + for (const { code } of registry.LOCALES) { const catalog = catalogs[code]; if (!catalog) { issues.push(`missing locale ${code}`); continue; } const keys = Object.keys(catalog).sort(); diff --git a/packages/site-kit/src/core/flags.d.mts b/packages/site-kit/src/core/flags.d.mts index 3eff400..fdc7c13 100644 --- a/packages/site-kit/src/core/flags.d.mts +++ b/packages/site-kit/src/core/flags.d.mts @@ -1,6 +1,8 @@ /* Generated by scripts/build-flags.mjs — do not edit. Source: flags/*.svg (flag-icons, MIT). Copyright (c) 2013 Panayiotis Lipiridis */ +import type { LocaleRegistry } from "./locales.mjs"; import type { SiteLocale } from "./locales.mjs"; export interface LocaleFlag { readonly country: string; readonly viewBox: string; readonly body: string } export declare const FLAG_COUNTRY: Readonly>; +export declare const FLAGS_BY_COUNTRY: Readonly>; export declare const LOCALE_FLAGS: Readonly>; -export declare function flagFor(locale: SiteLocale): LocaleFlag; +export declare function flagFor(locale: string, registry?: LocaleRegistry): LocaleFlag; diff --git a/packages/site-kit/src/core/flags.mjs b/packages/site-kit/src/core/flags.mjs index e571178..0559f09 100644 --- a/packages/site-kit/src/core/flags.mjs +++ b/packages/site-kit/src/core/flags.mjs @@ -16,25 +16,62 @@ export const FLAG_COUNTRY = Object.freeze({ "ar": "sa" }); +/** + * Flags indexed by country, not by locale. + * + * A locale-keyed map can only answer for languages the family ships. A + * product that adds its own — BookLinq sells in six Indian languages the + * family does not carry — names a country here instead, so nobody vendors + * a second copy of an SVG this package already has. + */ +export const FLAGS_BY_COUNTRY = Object.freeze({ + "kr": Object.freeze({ country: "kr", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n " }), + "us": Object.freeze({ country: "us", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n \r\n " }), + "jp": Object.freeze({ country: "jp", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n " }), + "hk": Object.freeze({ country: "hk", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n " }), + "tw": Object.freeze({ country: "tw", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n " }), + "in": Object.freeze({ country: "in", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n " }), + "vn": Object.freeze({ country: "vn", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n " }), + "id": Object.freeze({ country: "id", viewBox: "0 0 640 480", body: "\r\n " }), + "th": Object.freeze({ country: "th", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n " }), + "br": Object.freeze({ country: "br", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n " }), + "fr": Object.freeze({ country: "fr", viewBox: "0 0 640 480", body: "\r\n \r\n " }), + "de": Object.freeze({ country: "de", viewBox: "0 0 640 480", body: "\r\n \r\n " }), + "es": Object.freeze({ country: "es", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n " }), + "sa": Object.freeze({ country: "sa", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n " }), +}); + export const LOCALE_FLAGS = Object.freeze({ - "ko": Object.freeze({ country: "kr", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n " }), - "en": Object.freeze({ country: "us", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n " }), - "ja": Object.freeze({ country: "jp", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n " }), - "zh-HK": Object.freeze({ country: "hk", viewBox: "0 0 640 480", body: "\n \n \n \n \n " }), - "zh-TW": Object.freeze({ country: "tw", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n " }), - "hi": Object.freeze({ country: "in", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n " }), - "vi": Object.freeze({ country: "vn", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n " }), - "id": Object.freeze({ country: "id", viewBox: "0 0 640 480", body: "\n " }), - "th": Object.freeze({ country: "th", viewBox: "0 0 640 480", body: "\n \n \n \n " }), - "pt-BR": Object.freeze({ country: "br", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n " }), - "fr": Object.freeze({ country: "fr", viewBox: "0 0 640 480", body: "\n \n " }), - "de": Object.freeze({ country: "de", viewBox: "0 0 640 480", body: "\n \n " }), - "es": Object.freeze({ country: "es", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n " }), - "ar": Object.freeze({ country: "sa", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n " }), + "ko": FLAGS_BY_COUNTRY["kr"], + "en": FLAGS_BY_COUNTRY["us"], + "ja": FLAGS_BY_COUNTRY["jp"], + "zh-HK": FLAGS_BY_COUNTRY["hk"], + "zh-TW": FLAGS_BY_COUNTRY["tw"], + "hi": FLAGS_BY_COUNTRY["in"], + "vi": FLAGS_BY_COUNTRY["vn"], + "id": FLAGS_BY_COUNTRY["id"], + "th": FLAGS_BY_COUNTRY["th"], + "pt-BR": FLAGS_BY_COUNTRY["br"], + "fr": FLAGS_BY_COUNTRY["fr"], + "de": FLAGS_BY_COUNTRY["de"], + "es": FLAGS_BY_COUNTRY["es"], + "ar": FLAGS_BY_COUNTRY["sa"], }); -export function flagFor(locale) { +/** + * @param {string} locale + * @param {{ LOCALES?: ReadonlyArray<{ code: string, flagCountry?: string }> }} [registry] + * A product's locale registry, for a locale the family does not carry. + * Its `flagCountry` names one of FLAGS_BY_COUNTRY. + */ +export function flagFor(locale, registry) { const flag = LOCALE_FLAGS[locale]; - if (!flag) throw new RangeError(`No flag for locale: ${locale}`); - return flag; + if (flag) return flag; + const extra = registry?.LOCALES?.find((definition) => definition.code === locale); + if (extra?.flagCountry) { + const vendored = FLAGS_BY_COUNTRY[extra.flagCountry]; + if (vendored) return vendored; + throw new RangeError(`No vendored flag for country: ${extra.flagCountry} (locale ${locale})`); + } + throw new RangeError(`No flag for locale: ${locale}`); } diff --git a/packages/site-kit/src/core/index.mjs b/packages/site-kit/src/core/index.mjs index 410d1f3..ac291e4 100644 --- a/packages/site-kit/src/core/index.mjs +++ b/packages/site-kit/src/core/index.mjs @@ -1,4 +1,4 @@ -export { LOCALES, canonicalLocale, localeAttributes, resolveLocale } from "./locales.mjs"; +export { FAMILY_LOCALES, LOCALES, canonicalLocale, defineLocaleRegistry, localeAttributes, resolveLocale } from "./locales.mjs"; export { CatalogValidationError, createTranslator, validateCatalogs } from "./catalog.mjs"; export { ROBOTS_USER_AGENTS, buildMetadata, buildRobots, buildSitemap, localizedPath, localizedUrl, renderSitemapXml } from "./seo.mjs"; export { VerifiedFactRegistry, buildVerifiedJsonLd, renderLlmsTxt } from "./geo.mjs"; diff --git a/packages/site-kit/src/core/locales.d.mts b/packages/site-kit/src/core/locales.d.mts index 7a0e482..fdea5db 100644 --- a/packages/site-kit/src/core/locales.d.mts +++ b/packages/site-kit/src/core/locales.d.mts @@ -1,7 +1,41 @@ export type SiteLocale = "ko" | "en" | "ja" | "zh-HK" | "zh-TW" | "hi" | "vi" | "id" | "th" | "pt-BR" | "fr" | "de" | "es" | "ar"; export type TextDirection = "ltr" | "rtl"; export interface LocaleDefinition { readonly code: SiteLocale; readonly language: string; readonly nativeName: string; readonly dir: TextDirection } + +/** + * A locale a product sells in that the family does not. `flagCountry` must + * name a country this package already vendors a flag for — `FLAG_COUNTRY` + * in ./flags.mjs is the list. + */ +export interface ExtraLocaleDefinition { + readonly code: Code; + readonly language: string; + readonly nativeName: string; + readonly dir: TextDirection; + readonly flagCountry: string; +} + +export interface LocaleRegistry { + readonly LOCALES: readonly (LocaleDefinition | ExtraLocaleDefinition)[]; + canonicalLocale(candidate: string | undefined | null): Code | undefined; + localeAttributes(locale: string): { lang: Code; dir: TextDirection }; + resolveLocale(input: { pathname?: string; cookie?: string; acceptLanguage?: string; defaultLocale: Code }): { locale: Code; source: "route" | "cookie" | "accept-language" | "default" }; + definitionFor(locale: string): LocaleDefinition | ExtraLocaleDefinition; +} + export declare const LOCALES: readonly LocaleDefinition[]; + +/** + * Build a registry of the family locales plus a product's own. Every + * locale-aware helper in this package accepts one; the bare exports below + * are the family registry, so not calling this changes nothing. + */ +export declare function defineLocaleRegistry(options?: { + extra?: readonly ExtraLocaleDefinition[]; + aliases?: readonly (readonly [string, SiteLocale | Extra])[]; +}): LocaleRegistry; + +export declare const FAMILY_LOCALES: LocaleRegistry; export declare function canonicalLocale(candidate: string | undefined | null): SiteLocale | undefined; export declare function localeAttributes(locale: string): { lang: SiteLocale; dir: TextDirection }; export declare function resolveLocale(input: { pathname?: string; cookie?: string; acceptLanguage?: string; defaultLocale: SiteLocale }): { locale: SiteLocale; source: "route" | "cookie" | "accept-language" | "default" }; diff --git a/packages/site-kit/src/core/locales.mjs b/packages/site-kit/src/core/locales.mjs index 2ce9059..da0cf2e 100644 --- a/packages/site-kit/src/core/locales.mjs +++ b/packages/site-kit/src/core/locales.mjs @@ -15,22 +15,89 @@ export const LOCALES = Object.freeze([ { code: "ar", language: "Arabic", nativeName: "العربية", dir: "rtl" }, ]); -const byLower = new Map(LOCALES.map((locale) => [locale.code.toLowerCase(), locale])); -const aliases = new Map([["zh", "zh-TW"], ["pt", "pt-BR"]]); +const FAMILY_ALIASES = Object.freeze([["zh", "zh-TW"], ["pt", "pt-BR"]]); -export function canonicalLocale(candidate) { - if (!candidate) return undefined; - const exact = byLower.get(String(candidate).trim().toLowerCase()); - if (exact) return exact.code; - const base = String(candidate).trim().toLowerCase().split("-")[0]; - if (aliases.has(base)) return aliases.get(base); - return byLower.get(base)?.code; -} +/** + * The languages a site actually sells in. + * + * The list above is the family's — what devslab.kr markets in, and the + * floor every product gets for free. It is not every product's list. + * BookLinq sells to salons in India and its assistant already answers in + * Tamil, Telugu, Bengali, Marathi, Gujarati and Kannada. Those are not + * family languages and are not going to become family languages: putting + * them in the list above would hand AskLinq and devslab.kr six entries + * they have no copy for. But a BookLinq page that cannot render them is a + * page that lies about what the product does. + * + * So the family owns the mechanism and the product names its own nouns. A + * registry is the family list plus whatever a product adds, and every + * locale-aware helper here can be bound to one. The bare exports at the + * bottom are the family registry, so a consumer that never calls this + * sees exactly what it saw before. + * + * An extra locale carries the same fields as a family one plus + * `flagCountry`, naming a flag this package already vendors (`FLAG_COUNTRY` + * lists them). Products do not ship SVG: flag data is licensed, generated + * and security-scanned here, and seven Indian languages share one flag + * anyway. + * + * @param {{ extra?: ReadonlyArray, aliases?: ReadonlyArray<[string, string]> }} [options] + */ +export function defineLocaleRegistry(options = {}) { + const extra = options.extra ?? []; + const seen = new Set(LOCALES.map(({ code }) => code)); + for (const locale of extra) { + if (!locale?.code) throw new TypeError("every extra locale needs a code"); + if (seen.has(locale.code)) { + throw new RangeError(`${locale.code} is already a family locale — remove it from \`extra\``); + } + if (!locale.nativeName) throw new TypeError(`${locale.code} needs a nativeName — it is what the picker shows`); + if (locale.dir !== "ltr" && locale.dir !== "rtl") throw new RangeError(`${locale.code} needs dir "ltr" or "rtl"`); + seen.add(locale.code); + } + + const locales = Object.freeze([...LOCALES, ...extra.map((locale) => Object.freeze({ ...locale }))]); + const byLower = new Map(locales.map((locale) => [locale.code.toLowerCase(), locale])); + const aliases = new Map([...FAMILY_ALIASES, ...(options.aliases ?? [])]); + + function canonicalLocale(candidate) { + if (!candidate) return undefined; + const exact = byLower.get(String(candidate).trim().toLowerCase()); + if (exact) return exact.code; + const base = String(candidate).trim().toLowerCase().split("-")[0]; + if (aliases.has(base)) return aliases.get(base); + return byLower.get(base)?.code; + } + + function definitionFor(locale) { + const canonical = canonicalLocale(locale); + if (!canonical) throw new RangeError(`Unsupported locale: ${locale}`); + return byLower.get(canonical.toLowerCase()); + } + + function localeAttributes(locale) { + const definition = definitionFor(locale); + // `dir` comes off the definition rather than a test for Arabic. The + // old form read `canonical === "ar" ? "rtl" : "ltr"`, which was right + // only while Arabic stayed the family's one RTL language — a product + // adding Urdu or Hebrew would have had it rendered left-to-right with + // every test still green. + return { lang: definition.code, dir: definition.dir }; + } -export function localeAttributes(locale) { - const canonical = canonicalLocale(locale); - if (!canonical) throw new RangeError(`Unsupported locale: ${locale}`); - return { lang: canonical, dir: canonical === "ar" ? "rtl" : "ltr" }; + function resolveLocale({ pathname = "/", cookie = "", acceptLanguage = "", defaultLocale }) { + const routeLocale = canonicalLocale(pathname.split("/").filter(Boolean)[0]); + if (routeLocale) return { locale: routeLocale, source: "route" }; + const storedLocale = canonicalLocale(cookieValue(cookie, "locale")); + if (storedLocale) return { locale: storedLocale, source: "cookie" }; + const accepted = acceptedLocales(acceptLanguage, canonicalLocale)[0]?.locale; + if (accepted) return { locale: accepted, source: "accept-language" }; + const fallback = canonicalLocale(defaultLocale); + if (!fallback) throw new RangeError(`Unsupported default locale: ${defaultLocale}`); + return { locale: fallback, source: "default" }; + } + + return Object.freeze({ LOCALES: locales, canonicalLocale, localeAttributes, resolveLocale, definitionFor }); } function cookieValue(header, name) { @@ -45,7 +112,7 @@ function cookieValue(header, name) { return undefined; } -function acceptedLocales(header) { +function acceptedLocales(header, canonicalLocale) { return String(header ?? "").split(",").map((part, order) => { const [tag, ...parameters] = part.trim().split(";"); const q = Number(parameters.find((parameter) => parameter.trim().startsWith("q="))?.split("=")[1] ?? 1); @@ -53,14 +120,7 @@ function acceptedLocales(header) { }).filter(({ locale, q }) => locale && q > 0).sort((a, b) => b.q - a.q || a.order - b.order); } -export function resolveLocale({ pathname = "/", cookie = "", acceptLanguage = "", defaultLocale }) { - const routeLocale = canonicalLocale(pathname.split("/").filter(Boolean)[0]); - if (routeLocale) return { locale: routeLocale, source: "route" }; - const storedLocale = canonicalLocale(cookieValue(cookie, "locale")); - if (storedLocale) return { locale: storedLocale, source: "cookie" }; - const accepted = acceptedLocales(acceptLanguage)[0]?.locale; - if (accepted) return { locale: accepted, source: "accept-language" }; - const fallback = canonicalLocale(defaultLocale); - if (!fallback) throw new RangeError(`Unsupported default locale: ${defaultLocale}`); - return { locale: fallback, source: "default" }; -} +/** The family registry — what every helper in this package uses by default. */ +export const FAMILY_LOCALES = defineLocaleRegistry(); + +export const { canonicalLocale, localeAttributes, resolveLocale } = FAMILY_LOCALES; diff --git a/packages/site-kit/src/core/seo.d.mts b/packages/site-kit/src/core/seo.d.mts index 124b618..462cd9d 100644 --- a/packages/site-kit/src/core/seo.d.mts +++ b/packages/site-kit/src/core/seo.d.mts @@ -1,16 +1,16 @@ -import type { SiteLocale, TextDirection } from "./locales.mjs"; -export interface MetadataInput { baseUrl: string; path: string; locale: SiteLocale; defaultLocale: SiteLocale; title: string; description: string; siteName: string; image: string } -export interface SiteMetadata { - html: { lang: SiteLocale; dir: TextDirection }; +import type { LocaleRegistry, SiteLocale, TextDirection } from "./locales.mjs"; +export interface MetadataInput { baseUrl: string; path: string; locale: Code; defaultLocale: Code; title: string; description: string; siteName: string; image: string; registry?: LocaleRegistry } +export interface SiteMetadata { + html: { lang: Code; dir: TextDirection }; title: string; description: string; canonical: string; - alternates: Array<{ hreflang: SiteLocale | "x-default"; href: string }>; - openGraph: { type: "website"; locale: SiteLocale; url: string; siteName: string; title: string; description: string; images: Array<{ url: string }> }; + alternates: Array<{ hreflang: Code | "x-default"; href: string }>; + openGraph: { type: "website"; locale: Code; url: string; siteName: string; title: string; description: string; images: Array<{ url: string }> }; twitter: { card: "summary_large_image"; title: string; description: string; image: string }; } -export declare function localizedPath(path: string, locale: SiteLocale, defaultLocale: SiteLocale): string; -export declare function localizedUrl(baseUrl: string, path: string, locale: SiteLocale, defaultLocale: SiteLocale): string; -export declare function buildMetadata(input: MetadataInput): SiteMetadata; -export declare function buildSitemap(input: { baseUrl: string; routes: string[]; defaultLocale: SiteLocale; lastModified?: string }): Array<{ loc: string; locale: SiteLocale; alternates: Array<{ hreflang: SiteLocale | "x-default"; href: string }>; lastmod?: string }>; +export declare function localizedPath(path: string, locale: Code, defaultLocale: Code, registry?: LocaleRegistry): string; +export declare function localizedUrl(baseUrl: string, path: string, locale: Code, defaultLocale: Code, registry?: LocaleRegistry): string; +export declare function buildMetadata(input: MetadataInput): SiteMetadata; +export declare function buildSitemap(input: { baseUrl: string; routes: string[]; defaultLocale: Code; lastModified?: string; registry?: LocaleRegistry }): Array<{ loc: string; locale: Code; alternates: Array<{ hreflang: Code | "x-default"; href: string }>; lastmod?: string }>; export declare function renderSitemapXml(entries: ReturnType): string; export type RobotsPolicy = "allow" | "disallow"; export declare const ROBOTS_USER_AGENTS: { readonly citation: readonly string[]; readonly modelTraining: readonly string[] }; diff --git a/packages/site-kit/src/core/seo.mjs b/packages/site-kit/src/core/seo.mjs index 534d1d9..76dcc30 100644 --- a/packages/site-kit/src/core/seo.mjs +++ b/packages/site-kit/src/core/seo.mjs @@ -1,4 +1,10 @@ -import { LOCALES, canonicalLocale, localeAttributes } from "./locales.mjs"; +import { FAMILY_LOCALES } from "./locales.mjs"; + +// Every function here that names a locale takes an optional `registry`. +// Omitted it is the family's fourteen, which is what a family site wants. +// A product that added its own languages must pass its registry, or the +// hreflang alternates and the sitemap would quietly omit them — the page +// would render in Tamil while telling search engines Tamil does not exist. const cleanBase = (baseUrl) => String(baseUrl).replace(/\/+$/, ""); const cleanPath = (path) => { @@ -6,27 +12,27 @@ const cleanPath = (path) => { return value === "/" ? "/" : value; }; -export function localizedPath(path, locale, defaultLocale) { - const canonical = canonicalLocale(locale); - const fallback = canonicalLocale(defaultLocale); +export function localizedPath(path, locale, defaultLocale, registry = FAMILY_LOCALES) { + const canonical = registry.canonicalLocale(locale); + const fallback = registry.canonicalLocale(defaultLocale); if (!canonical || !fallback) throw new RangeError("localizedPath received an unsupported locale"); const normalized = cleanPath(path); return canonical === fallback ? normalized : `/${canonical}${normalized === "/" ? "" : normalized}`; } -export function localizedUrl(baseUrl, path, locale, defaultLocale) { - return `${cleanBase(baseUrl)}${localizedPath(path, locale, defaultLocale)}`; +export function localizedUrl(baseUrl, path, locale, defaultLocale, registry = FAMILY_LOCALES) { + return `${cleanBase(baseUrl)}${localizedPath(path, locale, defaultLocale, registry)}`; } -export function buildMetadata({ baseUrl, path, locale, defaultLocale, title, description, siteName, image }) { - const canonicalLocaleCode = canonicalLocale(locale); +export function buildMetadata({ baseUrl, path, locale, defaultLocale, title, description, siteName, image, registry = FAMILY_LOCALES }) { + const canonicalLocaleCode = registry.canonicalLocale(locale); if (!canonicalLocaleCode) throw new RangeError(`Unsupported locale: ${locale}`); - const canonical = localizedUrl(baseUrl, path, canonicalLocaleCode, defaultLocale); - const alternates = LOCALES.map(({ code }) => ({ hreflang: code, href: localizedUrl(baseUrl, path, code, defaultLocale) })); - alternates.push({ hreflang: "x-default", href: localizedUrl(baseUrl, path, defaultLocale, defaultLocale) }); + const canonical = localizedUrl(baseUrl, path, canonicalLocaleCode, defaultLocale, registry); + const alternates = registry.LOCALES.map(({ code }) => ({ hreflang: code, href: localizedUrl(baseUrl, path, code, defaultLocale, registry) })); + alternates.push({ hreflang: "x-default", href: localizedUrl(baseUrl, path, defaultLocale, defaultLocale, registry) }); const imageUrl = new URL(image, `${cleanBase(baseUrl)}/`).href; return { - html: localeAttributes(canonicalLocaleCode), + html: registry.localeAttributes(canonicalLocaleCode), title, description, canonical, @@ -44,13 +50,13 @@ export function buildMetadata({ baseUrl, path, locale, defaultLocale, title, des }; } -export function buildSitemap({ baseUrl, routes, defaultLocale, lastModified }) { - return routes.flatMap((path) => LOCALES.map(({ code }) => ({ - loc: localizedUrl(baseUrl, path, code, defaultLocale), +export function buildSitemap({ baseUrl, routes, defaultLocale, lastModified, registry = FAMILY_LOCALES }) { + return routes.flatMap((path) => registry.LOCALES.map(({ code }) => ({ + loc: localizedUrl(baseUrl, path, code, defaultLocale, registry), locale: code, alternates: [ - ...LOCALES.map(({ code: alternate }) => ({ hreflang: alternate, href: localizedUrl(baseUrl, path, alternate, defaultLocale) })), - { hreflang: "x-default", href: localizedUrl(baseUrl, path, defaultLocale, defaultLocale) }, + ...registry.LOCALES.map(({ code: alternate }) => ({ hreflang: alternate, href: localizedUrl(baseUrl, path, alternate, defaultLocale, registry) })), + { hreflang: "x-default", href: localizedUrl(baseUrl, path, defaultLocale, defaultLocale, registry) }, ], ...(lastModified ? { lastmod: lastModified } : {}), }))); diff --git a/packages/site-kit/src/solid/__tests__/ssr.test.tsx b/packages/site-kit/src/solid/__tests__/ssr.test.tsx index a4ea086..2f25cdb 100644 --- a/packages/site-kit/src/solid/__tests__/ssr.test.tsx +++ b/packages/site-kit/src/solid/__tests__/ssr.test.tsx @@ -3,6 +3,7 @@ import { expect, it } from "vitest"; import { LocaleMenu, MarketingShell } from "../index"; import { LOCALE_FLAGS } from "../../core/flags.mjs"; +import { defineLocaleRegistry } from "../../core/locales.mjs"; import { locale, messages } from "./fixtures"; const Fixture = () => has no value attribute", () => { + // The bug this pins: `value={props.state.locale}` on the , the + // browser ignores it and picks option[0]. Every visitor, in every + // language, saw the first locale as their current one — and touching the + // control switched them to it. Found on a deployed worker, not here. + const html = renderToString(() => ); + // Solid stamps a data-hk between the tag name and our attributes, so the + // assertion matches on the option carrying value="en", not on byte order. + expect(html).toMatch(/]*\svalue="en"[^>]*\sselected[^>]*>/); + expect((html.match(/\sselected/g) ?? []).length).toBe(1); + expect(html).not.toMatch(/]*\svalue=/); +}); + +it("server-renders a product's own languages alongside the family's", () => { + const registry = defineLocaleRegistry({ + extra: [{ code: "ta", language: "Tamil", nativeName: "தமிழ்", dir: "ltr", flagCountry: "in" }], + }); + const html = renderToString(() => ( + + )); + expect((html.match(/class="site-locale-flag__option"/g) ?? []).length).toBe(15); + expect(html).toContain("தமிழ்"); + expect(html).toContain('href="/ta"'); + // Tamil borrows India's vendored flag rather than shipping a second copy. + const indiaPath = LOCALE_FLAGS.hi.body.match(/d="[^"]{20,}"/)?.[0]; + if (!indiaPath) throw new Error('expected a d="…" path fragment in LOCALE_FLAGS.hi.body'); + expect(html).toContain(indiaPath); +}); diff --git a/packages/site-kit/src/solid/chrome.tsx b/packages/site-kit/src/solid/chrome.tsx index c8743ca..10eee5d 100644 --- a/packages/site-kit/src/solid/chrome.tsx +++ b/packages/site-kit/src/solid/chrome.tsx @@ -2,6 +2,7 @@ import { Button, Icon, IconButton } from "@devslab/dds-solid"; import { For, createSignal, onMount, type JSX } from "solid-js"; import { LocaleMenu, type LocaleMenuProps, type LocaleMenuVariant } from "./locale-menu"; +import type { LocaleRegistry } from "../core/locales.mjs"; import type { LocaleState, SiteBrand, SiteLink, SiteMessages, ThemePreference } from "./types"; export type { LocaleMenuProps }; @@ -66,6 +67,12 @@ export interface SiteHeaderProps { theme?: Omit; onLocaleChange?: LocaleMenuProps["onLocaleChange"]; localeVariant?: LocaleMenuVariant; + /** + * The languages the picker offers. Defaults to the family's fourteen; + * pass the registry from `defineLocaleRegistry` if this product sells in + * languages the family does not carry. + */ + localeRegistry?: LocaleRegistry; actions?: JSX.Element; } @@ -87,6 +94,7 @@ export function SiteHeader(props: SiteHeaderProps) { state={props.locale} messages={props.messages} {...(props.localeVariant ? { variant: props.localeVariant } : {})} + {...(props.localeRegistry ? { registry: props.localeRegistry } : {})} {...(props.onLocaleChange ? { onLocaleChange: props.onLocaleChange } : {})} /> {props.theme && } diff --git a/packages/site-kit/src/solid/locale-menu.tsx b/packages/site-kit/src/solid/locale-menu.tsx index d51d5fd..bb71465 100644 --- a/packages/site-kit/src/solid/locale-menu.tsx +++ b/packages/site-kit/src/solid/locale-menu.tsx @@ -1,6 +1,6 @@ import { For, createUniqueId, type JSX } from "solid-js"; -import { LOCALES, type SiteLocale } from "../core/locales.mjs"; +import { FAMILY_LOCALES, type LocaleRegistry, type SiteLocale } from "../core/locales.mjs"; import { flagFor } from "../core/flags.mjs"; import type { LocaleState, SiteMessages } from "./types"; @@ -11,6 +11,12 @@ export interface LocaleMenuProps { messages: SiteMessages; variant?: LocaleMenuVariant; onLocaleChange?: (locale: SiteLocale, href: string) => void; + /** + * The languages to offer. Defaults to the family's fourteen; a product + * that added its own passes the registry it built with + * `defineLocaleRegistry`. + */ + registry?: LocaleRegistry; } function scopeFlagIds(body: string, uid: string): string { @@ -27,9 +33,11 @@ function scopeFlagIds(body: string, uid: string): string { return scoped; } -function Flag(props: { locale: SiteLocale; class?: string }) { +const registryOf = (props: LocaleMenuProps) => props.registry ?? (FAMILY_LOCALES as LocaleRegistry); + +function Flag(props: { locale: string; class?: string; registry: LocaleRegistry }) { const uid = createUniqueId(); - const flag = () => flagFor(props.locale); + const flag = () => flagFor(props.locale, props.registry); return ( registryOf(props); return ( {props.messages.localeLabel} has no `value` content attribute — it exists only as a + DOM property — so under SSR, where the markup is a string, + `value={...}` serialised to an attribute the browser ignores and + every visitor got option[0] as their "current" language + regardless of the page they were reading. Touching the control + then switched them to that first language. `selected` is a real + content attribute and survives the trip through HTML. + */} + {(locale) => ( + + )} @@ -66,7 +88,8 @@ function SelectLocaleMenu(props: LocaleMenuProps) { function FlagLocaleMenu(props: LocaleMenuProps) { let details: HTMLDetailsElement | undefined; let trigger: HTMLElement | undefined; - const current = () => LOCALES.find((entry) => entry.code === props.state.locale)!; + const registry = () => registryOf(props); + const current = () => registry().LOCALES.find((entry) => entry.code === props.state.locale); const close = () => { if (details) details.open = false; }; const onKeyDown: JSX.EventHandler = (event) => { if (event.key !== "Escape" || !details?.open) return; @@ -74,17 +97,21 @@ function FlagLocaleMenu(props: LocaleMenuProps) { close(); trigger?.focus(); }; + const triggerLabel = () => { + const name = current()?.nativeName; + return name ? `${props.messages.localeLabel}: ${name}` : props.messages.localeLabel; + }; return (
- - + +
    - {(entry) => ( + {(entry) => (
  • - + {entry.nativeName}
  • diff --git a/tests/site-kit-core.test.mjs b/tests/site-kit-core.test.mjs index d59685d..8c211eb 100644 --- a/tests/site-kit-core.test.mjs +++ b/tests/site-kit-core.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { LOCALES, createTranslator, + defineLocaleRegistry, localeAttributes, resolveLocale, validateCatalogs, @@ -18,7 +19,7 @@ import { buildVerifiedJsonLd, renderLlmsTxt, } from "../packages/site-kit/src/core/geo.mjs"; -import { FLAG_COUNTRY, LOCALE_FLAGS, flagFor } from "../packages/site-kit/src/core/flags.mjs"; +import { FLAGS_BY_COUNTRY, FLAG_COUNTRY, LOCALE_FLAGS, flagFor } from "../packages/site-kit/src/core/flags.mjs"; const localeCodes = ["ko", "en", "ja", "zh-HK", "zh-TW", "hi", "vi", "id", "th", "pt-BR", "fr", "de", "es", "ar"]; @@ -128,3 +129,96 @@ test("every locale has exactly one flag and the flag data is renderable SVG", () } assert.throws(() => flagFor("xx"), RangeError); }); + +// --- product locales ------------------------------------------------------- +// +// The family list is what devslab.kr markets in. It is not every product's +// list: BookLinq sells to salons in India and its assistant already answers +// in Tamil, Telugu, Bengali, Marathi, Gujarati and Kannada. Those are not +// family languages, and a page that cannot render them is a page that lies +// about what the product does. So the family owns the mechanism and the +// product names its own nouns. + +const BOOKLINQ_EXTRA = [ + { code: "ta", language: "Tamil", nativeName: "தமிழ்", dir: "ltr", flagCountry: "in" }, + { code: "kn", language: "Kannada", nativeName: "ಕನ್ನಡ", dir: "ltr", flagCountry: "in" }, + { code: "bn", language: "Bengali", nativeName: "বাংলা", dir: "ltr", flagCountry: "in" }, +]; + +test("a registry is the family list plus the product's own, in that order", () => { + const registry = defineLocaleRegistry({ extra: BOOKLINQ_EXTRA }); + assert.deepEqual(registry.LOCALES.map(({ code }) => code), [...localeCodes, "ta", "kn", "bn"]); + // The bare exports stay the family's — a consumer that never calls + // defineLocaleRegistry sees exactly what it saw before. + assert.deepEqual(LOCALES.map(({ code }) => code), localeCodes); +}); + +test("a product locale resolves everywhere the family's does", () => { + const registry = defineLocaleRegistry({ extra: BOOKLINQ_EXTRA }); + assert.equal(registry.canonicalLocale("ta"), "ta"); + assert.equal(registry.canonicalLocale("TA"), "ta"); + assert.equal(registry.canonicalLocale("ta-IN"), "ta"); + assert.deepEqual(registry.localeAttributes("bn"), { lang: "bn", dir: "ltr" }); + assert.deepEqual(registry.resolveLocale({ pathname: "/kn/book", defaultLocale: "en" }), { locale: "kn", source: "route" }); + assert.deepEqual(registry.resolveLocale({ pathname: "/book", acceptLanguage: "ta-IN,ta;q=0.9", defaultLocale: "en" }), { locale: "ta", source: "accept-language" }); + // And the family registry still refuses it, so nothing leaks sideways. + assert.throws(() => localeAttributes("ta"), RangeError); +}); + +test("direction comes from the definition, not from a test for Arabic", () => { + // The old form read `canonical === "ar" ? "rtl" : "ltr"`. That was right + // only while Arabic was the family's one RTL language: a product adding + // Urdu or Hebrew would have had it rendered left-to-right with every + // test still green. + const registry = defineLocaleRegistry({ + extra: [{ code: "ur", language: "Urdu", nativeName: "اردو", dir: "rtl", flagCountry: "in" }], + }); + assert.deepEqual(registry.localeAttributes("ur"), { lang: "ur", dir: "rtl" }); + assert.deepEqual(registry.localeAttributes("ar"), { lang: "ar", dir: "rtl" }); + assert.deepEqual(registry.localeAttributes("en"), { lang: "en", dir: "ltr" }); +}); + +test("a registry refuses a definition that would render wrong", () => { + assert.throws(() => defineLocaleRegistry({ extra: [{ code: "en", language: "English", nativeName: "English", dir: "ltr", flagCountry: "gb" }] }), /already a family locale/); + assert.throws(() => defineLocaleRegistry({ extra: [{ code: "ta", language: "Tamil", dir: "ltr", flagCountry: "in" }] }), /nativeName/); + assert.throws(() => defineLocaleRegistry({ extra: [{ code: "ta", language: "Tamil", nativeName: "தமிழ்", dir: "sideways", flagCountry: "in" }] }), /dir/); +}); + +test("product locales reuse a vendored flag rather than shipping a second copy", () => { + const registry = defineLocaleRegistry({ extra: BOOKLINQ_EXTRA }); + // Seven locales legitimately share one flag: a flag names a country, and + // India speaks more than one language. + for (const code of ["ta", "kn", "bn"]) { + assert.equal(flagFor(code, registry), FLAGS_BY_COUNTRY.in); + assert.equal(flagFor(code, registry), flagFor("hi")); + } + assert.throws(() => flagFor("ta"), /No flag for locale/); + const noSuchCountry = defineLocaleRegistry({ extra: [{ code: "cy", language: "Welsh", nativeName: "Cymraeg", dir: "ltr", flagCountry: "wa" }] }); + assert.throws(() => flagFor("cy", noSuchCountry), /No vendored flag for country/); +}); + +test("catalogs and metadata cover the product's languages, not just the family's", () => { + const registry = defineLocaleRegistry({ extra: BOOKLINQ_EXTRA }); + const catalogs = Object.fromEntries(registry.LOCALES.map(({ code }) => [code, { hello: "hi" }])); + + // Validating against the family registry would pass a catalog that is + // missing every product language — the failure mode this argument exists + // to prevent. + delete catalogs.ta; + assert.equal(validateCatalogs(catalogs, "en"), true); + assert.throws(() => validateCatalogs(catalogs, "en", { registry }), /missing locale ta/); + catalogs.ta = { hello: "வணக்கம்" }; + assert.equal(validateCatalogs(catalogs, "en", { registry }), true); + + const metadata = buildMetadata({ + baseUrl: "https://getbooklinq.app", path: "/", locale: "ta", defaultLocale: "en", + title: "t", description: "d", siteName: "BookLinq", image: "/og.png", registry, + }); + assert.deepEqual(metadata.html, { lang: "ta", dir: "ltr" }); + assert.equal(metadata.canonical, "https://getbooklinq.app/ta"); + assert.ok(metadata.alternates.some(({ hreflang }) => hreflang === "ta"), "a page that renders in Tamil must say so in hreflang"); + assert.equal(metadata.alternates.length, registry.LOCALES.length + 1); + + const sitemap = buildSitemap({ baseUrl: "https://getbooklinq.app", routes: ["/"], defaultLocale: "en", registry }); + assert.equal(sitemap.length, registry.LOCALES.length); +}); From d5ea47db313013cecd07f6f0013226bf3bf5901d Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sat, 5 Sep 2026 12:28:44 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(site-kit):=20=EA=B5=AD=EA=B8=B0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EA=B8=B0=EA=B0=80=20=EC=B2=B4=ED=81=AC?= =?UTF-8?q?=EC=95=84=EC=9B=83=EC=9D=98=20=EC=A4=84=EB=B0=94=EA=BF=88?= =?UTF-8?q?=EC=97=90=20=ED=9C=98=EB=91=98=EB=A6=AC=EC=A7=80=20=EC=95=8A?= =?UTF-8?q?=EA=B2=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앞 커밋이 CI를 빨갛게 만들었다: `flags out of date` — Linux에서만. 원인은 생성기가 **읽는 것**이 플랫폼마다 달랐던 것이다. `core.autocrlf`가 켜진 Windows 체크아웃은 `flags/*.svg`를 CRLF로 돌려주고, `JSON.stringify`가 그 CR을 `\r\n` 이스케이프로 성실히 보존한다. 그래서 내가 Windows에서 재생성한 flags.mjs에는 `\r\n` 이스케이프가 715개 들어갔고(HEAD~1은 0개), Linux CI가 LF로 읽어 만든 것과 달라졌다. `--check`가 정확히 이걸 잡는 가드인데, 내 로컬에서는 방금 내가 만든 CRLF 버전과 비교하니 통과했다. 부수 피해도 있었다. 그 이스케이프는 그냥 커밋 노이즈가 아니라 **브라우저로 나가는 SVG 본문 안의 실제 캐리지 리턴**이었다 — 발행 데이터가 2.8KB 늘었다 (113,712 → 112,282바이트로 회복). 두 군데를 고친다. 생성기가 읽은 텍스트의 줄바꿈을 정규화하고(생성기는 읽는 것이 바이트 안정적일 때만 재현 가능하다), `.gitattributes`가 이 레포의 텍스트를 모든 플랫폼에서 LF로 고정한다. 검증: 이번엔 CI가 실제로 돌리는 게이트를 그대로 돌렸다 — verify:source:stage1-2 · stage3-4 · site-kit:i18n · site-kit:ui · site-kit:seo · `pnpm run verify` 전부 exit 0. 재생성된 flag body가 HEAD~1(Linux 생성본)과 일치하는 것도 확인했다. --- .gitattributes | 21 +++++++++++++++++ packages/site-kit/scripts/build-flags.mjs | 9 +++++++- packages/site-kit/src/core/flags.mjs | 28 +++++++++++------------ 3 files changed, 43 insertions(+), 15 deletions(-) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f597733 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,21 @@ +# Text is LF in the repository and LF in the working tree, on every platform. +# +# Without this, a Windows checkout with core.autocrlf=true hands CRLF to +# anything that reads a file — including packages/site-kit/scripts/build-flags.mjs, +# which inlines flags/*.svg into a generated module. JSON.stringify preserved +# those carriage returns as \r\n escapes, so the committed output depended on +# who generated it: --check passed on Windows and failed on Linux CI, and the +# published flag data carried 2.8 KB of stray \r. +* text=auto eol=lf + +# Binary — never touch these. +*.png binary +*.jpg binary +*.jpeg binary +*.webp binary +*.ico binary +*.woff binary +*.woff2 binary +*.ttf binary +*.otf binary +*.pdf binary diff --git a/packages/site-kit/scripts/build-flags.mjs b/packages/site-kit/scripts/build-flags.mjs index 2125c6e..4afa96c 100644 --- a/packages/site-kit/scripts/build-flags.mjs +++ b/packages/site-kit/scripts/build-flags.mjs @@ -21,7 +21,14 @@ const HEADER = "/* Generated by scripts/build-flags.mjs — do not edit. Source: flags/*.svg (flag-icons, MIT). Copyright (c) 2013 Panayiotis Lipiridis */"; const entries = Object.entries(FLAG_COUNTRY).map(([locale, country]) => { - const text = readFileSync(join(pkg, "flags", `${country}.svg`), "utf8").trim(); + // Normalise the line endings we read, not just the ones we write. A + // Windows checkout with core.autocrlf hands these files back as CRLF, + // JSON.stringify faithfully preserves that as \r\n escapes, and the + // generated module then differs from the one CI generates on Linux — + // --check fails there and passes here, and every flag body ships an + // extra 2.8 KB of carriage returns to the browser. A generator is only + // reproducible if what it reads is byte-stable across checkouts. + const text = readFileSync(join(pkg, "flags", `${country}.svg`), "utf8").replaceAll("\r\n", "\n").trim(); const open = text.match(/^]*>/); if (!open) throw new Error(`flags/${country}.svg: no root`); const viewBox = open[0].match(/viewBox="([^"]+)"/)?.[1]; diff --git a/packages/site-kit/src/core/flags.mjs b/packages/site-kit/src/core/flags.mjs index 0559f09..2988ca9 100644 --- a/packages/site-kit/src/core/flags.mjs +++ b/packages/site-kit/src/core/flags.mjs @@ -25,20 +25,20 @@ export const FLAG_COUNTRY = Object.freeze({ * a second copy of an SVG this package already has. */ export const FLAGS_BY_COUNTRY = Object.freeze({ - "kr": Object.freeze({ country: "kr", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n " }), - "us": Object.freeze({ country: "us", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n \r\n " }), - "jp": Object.freeze({ country: "jp", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n " }), - "hk": Object.freeze({ country: "hk", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n " }), - "tw": Object.freeze({ country: "tw", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n " }), - "in": Object.freeze({ country: "in", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n " }), - "vn": Object.freeze({ country: "vn", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n " }), - "id": Object.freeze({ country: "id", viewBox: "0 0 640 480", body: "\r\n " }), - "th": Object.freeze({ country: "th", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n " }), - "br": Object.freeze({ country: "br", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n " }), - "fr": Object.freeze({ country: "fr", viewBox: "0 0 640 480", body: "\r\n \r\n " }), - "de": Object.freeze({ country: "de", viewBox: "0 0 640 480", body: "\r\n \r\n " }), - "es": Object.freeze({ country: "es", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n " }), - "sa": Object.freeze({ country: "sa", viewBox: "0 0 640 480", body: "\r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n \r\n " }), + "kr": Object.freeze({ country: "kr", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n " }), + "us": Object.freeze({ country: "us", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n " }), + "jp": Object.freeze({ country: "jp", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n " }), + "hk": Object.freeze({ country: "hk", viewBox: "0 0 640 480", body: "\n \n \n \n \n " }), + "tw": Object.freeze({ country: "tw", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n " }), + "in": Object.freeze({ country: "in", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n " }), + "vn": Object.freeze({ country: "vn", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n " }), + "id": Object.freeze({ country: "id", viewBox: "0 0 640 480", body: "\n " }), + "th": Object.freeze({ country: "th", viewBox: "0 0 640 480", body: "\n \n \n \n " }), + "br": Object.freeze({ country: "br", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n " }), + "fr": Object.freeze({ country: "fr", viewBox: "0 0 640 480", body: "\n \n " }), + "de": Object.freeze({ country: "de", viewBox: "0 0 640 480", body: "\n \n " }), + "es": Object.freeze({ country: "es", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n " }), + "sa": Object.freeze({ country: "sa", viewBox: "0 0 640 480", body: "\n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n " }), }); export const LOCALE_FLAGS = Object.freeze({ From c97db43d113cc0cafd8a8287c96216cd43e66f48 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sat, 5 Sep 2026 12:56:55 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix(release):=20=EB=B2=84=EC=A0=84=20PR?= =?UTF-8?q?=EC=9D=B4=20=EC=9D=BD=EC=9D=84=20CHANGELOG=EB=A5=BC=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=EB=A1=9C=20=EC=83=9D=EC=84=B1=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #34가 릴리스 트리거를 태그에서 main push로 바꾼 뒤 처음으로 "미소비 changeset 있음 → 버전 PR 생성" 경로가 실행됐고, 거기서 죽었다: ENOENT: open 'packages/dds-css/CHANGELOG.md' `.changeset/config.json`이 `"changelog": false`라 `changeset version`은 CHANGELOG를 쓰지 않는데, changesets/action은 PR 본문을 만들려고 bump된 패키지마다 그 파일을 읽는다. 0.4.0·0.4.1 릴리스 PR은 #34 이전에 손으로 만들어졌고 0.4.1 발행 run은 "미소비 changeset 없음 → publish" 경로였기 때문에 이 조합이 한 번도 돌아본 적이 없었다. 기본 생성기(`@changesets/cli/changelog`, cli에 동봉 — 새 의존성 없음)를 켠다. 로컬 드라이런: `changeset version`이 fixed 그룹 5개를 0.5.0으로 올리고 패키지마다 CHANGELOG.md를 만들며, site-kit 항목이 D-018/D-019 changeset 본문을 그대로 싣는 것을 확인한 뒤 트리를 되돌렸다. --- .changeset/config.json | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/.changeset/config.json b/.changeset/config.json index ee41aa8..71683e8 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -1,13 +1,21 @@ { "$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json", - "changelog": false, + "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [ - ["@devslab/dds-tokens", "@devslab/dds-css", "@devslab/dds-icons", "@devslab/dds-solid", "@devslab/site-kit"] + [ + "@devslab/dds-tokens", + "@devslab/dds-css", + "@devslab/dds-icons", + "@devslab/dds-solid", + "@devslab/site-kit" + ] ], "linked": [], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", - "ignore": ["@devslab/compatibility-canary"] + "ignore": [ + "@devslab/compatibility-canary" + ] }