Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions .changeset/config.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
31 changes: 31 additions & 0 deletions .changeset/locale-registry.md
Original file line number Diff line number Diff line change
@@ -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 `<select>`, which is a DOM property with no matching content
attribute, so server-rendered markup left the browser to pick `option[0]`.
Every visitor, in every language, saw the first locale as their current one,
and touching the control switched them to it. It now sets `selected` on the
option.
- **`localeAttributes` decided direction by testing for Arabic.** It read
`canonical === "ar" ? "rtl" : "ltr"`, correct only while Arabic was the
family's one RTL language; direction now comes from the locale definition, so
a product adding Urdu or Hebrew gets it right.
21 changes: 21 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
57 changes: 57 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,63 @@

---

## D-019 — SSR에서 선택 상태는 `<option selected>`, `<select value>`가 아니다 (2026-09-05)

**결정.** `SelectLocaleMenu`가 `<select value={...}>` 대신 현재 로케일의
`<option>`에 `selected`를 단다.

**근거.** `<select>`에는 `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`로
Expand Down
37 changes: 34 additions & 3 deletions packages/site-kit/README.ko.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @devslab/site-kit

DevsLab 제품의 공개 웹사이트를 위한 공개 인프라 패키지다. 엄격한 14개 로케일 카탈로그, 로케일 협상, SEO/GEO 문서 생성기, 접근 가능한 SolidJS 2 사이트 셸을 제공한다. 제품명·주장·내비게이션·번역 문구는 항상 소비 앱이 소유한다.
DevsLab 제품의 공개 웹사이트를 위한 공개 인프라 패키지다. 가족 로케일에 대한 엄격한 카탈로그(제품별 확장 가능), 로케일 협상, SEO/GEO 문서 생성기, 접근 가능한 SolidJS 2 사이트 셸을 제공한다. 제품명·주장·내비게이션·번역 문구는 항상 소비 앱이 소유한다.

## 진입점

Expand All @@ -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)`,
`<SiteHeader localeRegistry={…}>`. 안 넘기면 가족 레지스트리이므로
`defineLocaleRegistry`를 부른 적 없는 소비자는 영향이 없다.

`flagCountry`는 이 패키지가 **이미 벤더링한** 나라를 지목해야 한다
(`FLAG_COUNTRY` 참조). 제품은 국기 아트워크를 싣지 않는다 — 라이선스가 있고,
생성되며, 여기서 스캔된다. 그리고 국기는 나라를 가리키지 언어를 가리키지
않으므로 인도 로케일 7개가 정당하게 `in`을 공유한다.

레지스트리는 전부 넘기거나 전혀 안 넘기거나다. 레지스트리로 그린 페이지의
메타데이터를 레지스트리 없이 만들면, 페이지는 타밀어로 렌더되면서 검색
엔진에는 타밀어가 없다고 말한다.

## 로케일 메뉴 variant

`LocaleMenu`는 기본으로 네이티브 `<select>`를 렌더링한다. `variant="flag"`는
Expand Down
37 changes: 34 additions & 3 deletions packages/site-kit/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @devslab/site-kit

Public product-site infrastructure for DevsLab products. It provides strict 14-locale catalogs, locale negotiation, SEO/GEO document builders, and accessible SolidJS 2 site shells. Product names, claims, navigation, and translated copy always remain in the consuming application.
Public product-site infrastructure for DevsLab products. It provides strict catalogs over the family locales (extensible per product), locale negotiation, SEO/GEO document builders, and accessible SolidJS 2 site shells. Product names, claims, navigation, and translated copy always remain in the consuming application.

## Entry points

Expand All @@ -9,14 +9,45 @@ Public product-site infrastructure for DevsLab products. It provides strict 14-l
- `@devslab/site-kit/tanstack-start` — conversion of neutral metadata to TanStack Start head descriptors.
- `@devslab/site-kit/styles.css` — logical-property, RTL-aware shared site styles.

Catalog construction is intentionally strict: all 14 locales must have exactly the same keys and named placeholders. There is no runtime copy fallback.
Catalog construction is intentionally strict: every locale in the registry must have exactly the same keys and named placeholders. There is no runtime copy fallback.

Sitemaps emit 14 locale alternates plus `x-default`. `buildVerifiedJsonLd`
Sitemaps emit one alternate per registry locale plus `x-default`. `buildVerifiedJsonLd`
accepts only the reviewed schema-type and claim allowlists, and every claim leaf
still references the verified-fact registry. `buildRobots` keeps its legacy
environment-only output, while an optional `policies` object can independently
control search indexing, citation crawlers, and model-training crawlers.

## Product locales

`LOCALES` is the family list — the fourteen languages devslab.kr markets in,
and the floor every product gets. It is not every product's list. A product
that sells in languages the family does not carry builds a registry:

```js
import { defineLocaleRegistry } from "@devslab/site-kit";

export const locales = defineLocaleRegistry({
extra: [
{ code: "ta", language: "Tamil", nativeName: "தமிழ்", dir: "ltr", flagCountry: "in" },
],
});
```

Every locale-aware helper takes one: `validateCatalogs(catalogs, "en", { registry })`,
`buildMetadata({ …, registry })`, `buildSitemap({ …, registry })`,
`localizedPath(path, locale, defaultLocale, registry)`, and
`<SiteHeader localeRegistry={…}>`. Omit it and you get the family registry, so
a consumer that never calls `defineLocaleRegistry` is unaffected.

`flagCountry` must name a country this package already vendors — see
`FLAG_COUNTRY` for the list. Products do not ship flag artwork: it is
licensed, generated and scanned here, and a flag names a country, not a
language, so seven Indian locales legitimately share `in`.

Pass the registry everywhere or nowhere. A page built with the registry but
metadata built without it renders in Tamil while telling search engines Tamil
does not exist.

## Locale menu variants

`LocaleMenu` renders a native `<select>` by default. `variant="flag"` renders a `<details>` 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.
53 changes: 45 additions & 8 deletions packages/site-kit/scripts/build-flags.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(/^<svg\b[^>]*>/);
if (!open) throw new Error(`flags/${country}.svg: no <svg> root`);
const viewBox = open[0].match(/viewBox="([^"]+)"/)?.[1];
Expand All @@ -35,33 +42,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<Record<SiteLocale, string>>;
export declare const FLAGS_BY_COUNTRY: Readonly<Record<string, LocaleFlag>>;
export declare const LOCALE_FLAGS: Readonly<Record<SiteLocale, LocaleFlag>>;
export declare function flagFor(locale: SiteLocale): LocaleFlag;
export declare function flagFor(locale: string, registry?: LocaleRegistry<string>): 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)`);
}
10 changes: 7 additions & 3 deletions packages/site-kit/src/core/catalog.d.mts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import type { SiteLocale } from "./locales.mjs";
import type { LocaleRegistry, SiteLocale } from "./locales.mjs";
export type MessageCatalog = Record<string, string>;
export declare class CatalogValidationError extends Error { readonly issues: readonly string[] }
export declare function validateCatalogs(catalogs: Record<SiteLocale, MessageCatalog>, referenceLocale: SiteLocale): true;
export declare function createTranslator(catalog: MessageCatalog, locale: SiteLocale): (key: string, values?: Record<string, string | number>) => string;
export declare function validateCatalogs<Code extends string = SiteLocale>(
catalogs: Record<Code, MessageCatalog>,
referenceLocale: Code,
options?: { registry?: LocaleRegistry<Code> },
): true;
export declare function createTranslator(catalog: MessageCatalog, locale: string): (key: string, values?: Record<string, string | number>) => string;
Loading