@krizaka/i18n 0.0.0-stage → 0.1.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,100 @@
1
+ import { PluralForms } from '@krizaka/intl/plural';
2
+
3
+ /**
4
+ * The types of a message catalogue. `en.json` (the reference) gives the shape; every other locale has the same one.
5
+ * A key is the dotted path to a leaf (`"auction.phase.SOLD"`); a leaf is a message string or a set of plural forms.
6
+ */
7
+
8
+ /** A value a message may hold: a string, a plural set, a nested branch, or a list (long structured content). */
9
+ type MessageValue = string | Dictionary | readonly MessageValue[];
10
+ /** A message catalogue, as `import en from "./messages/en.json"` types it. */
11
+ interface Dictionary {
12
+ readonly [key: string]: MessageValue;
13
+ }
14
+ /** A value a placeholder accepts. */
15
+ type PlaceholderValue = string | number;
16
+ /** The values handed to `format` / `t`: `{ count: 3, name: "Ada" }`. */
17
+ type Values = Readonly<Record<string, PlaceholderValue | null | undefined>>;
18
+ /** The plural categories a plural set may carry (`zero` is the explicit "exactly 0" form). */
19
+ type PluralCategory = keyof PluralForms;
20
+ /** A plural set as it sits in a catalogue: `{ "one": "{count} bid", "other": "{count} bids" }`. */
21
+ type PluralMessage = PluralForms<string>;
22
+ type Join<P extends string, K extends string> = P extends "" ? K : `${P}.${K}`;
23
+ /** True when T is a plural set: an object whose keys are plural categories, `other` included. */
24
+ type IsPlural<T> = T extends {
25
+ other: string;
26
+ } ? Exclude<keyof T, PluralCategory> extends never ? true : false : false;
27
+ /**
28
+ * Every key of a catalogue: the dotted path to each string and each plural set. Lists are not keys (read them as a
29
+ * branch with `getDictionary`). `MessageKey<typeof en>` → `"home.title" | "auction.bids" | …`.
30
+ */
31
+ type MessageKey<D, P extends string = ""> = D extends readonly unknown[] ? never : {
32
+ [K in keyof D & string]: D[K] extends string ? Join<P, K> : IsPlural<D[K]> extends true ? Join<P, K> : D[K] extends readonly unknown[] ? never : D[K] extends object ? MessageKey<D[K], Join<P, K>> : never;
33
+ }[keyof D & string];
34
+ /** The message at a key: `MessageAt<typeof en, "home.title">`. */
35
+ type MessageAt<D, K extends string> = K extends `${infer H}.${infer R}` ? H extends keyof D ? MessageAt<D[H], R> : never : K extends keyof D ? D[K] : never;
36
+ /** The `{placeholders}` of a literal message type: `Placeholders<"{count} of {total}">` → `"count" | "total"`. */
37
+ type Placeholders<S extends string> = S extends `${string}{${infer N}}${infer R}` ? N extends `${string}${" " | "{"}${string}` ? Placeholders<R> : N | Placeholders<R> : never;
38
+ /**
39
+ * The arguments `t` takes after the key. A message typed as a literal (a catalogue written `as const`) requires its
40
+ * placeholders; a plural set requires `count`; a message typed `string` (a JSON import widens literals) takes any values.
41
+ */
42
+ type ValuesArgs<M> = IsPlural<M> extends true ? [values: Values & {
43
+ readonly count: number;
44
+ }] : M extends string ? string extends M ? [values?: Values] : [Placeholders<M>] extends [never] ? [values?: Values] : [values: Values & {
45
+ readonly [N in Placeholders<M>]: PlaceholderValue;
46
+ }] : [values?: Values];
47
+ /** A translator bound to one locale: `t("auction.bids", { count: 3 })`. */
48
+ type Translate<D> = <K extends MessageKey<D>>(key: K, ...values: ValuesArgs<MessageAt<D, K>>) => string;
49
+
50
+ /** The plural categories, in CLDR order (`zero` first: the explicit "exactly 0" form). */
51
+ declare const PLURAL_CATEGORIES: readonly ["zero", "one", "two", "few", "many", "other"];
52
+ /**
53
+ * Replaces the `{name}` placeholders of a message: `format("{count} repositories", { count: 23 })` → `"23 repositories"`.
54
+ * A placeholder without a value (missing, `null` or `undefined`) stays as written, so a gap is visible, never silent.
55
+ */
56
+ declare function format(message: string, values?: Values): string;
57
+ /** The placeholder names of a message, sorted and without duplicates: `"{b} {a} {b}"` → `["a", "b"]`. */
58
+ declare function placeholdersOf(message: string): string[];
59
+ /** True when a catalogue value is a plural set: an object of plural categories with an `other` string. */
60
+ declare function isPluralMessage(value: unknown): value is PluralMessage;
61
+
62
+ interface CreateI18nOptions<L extends string> {
63
+ /** The reference locale: its catalogue gives the shape, and anything unknown falls back to it. */
64
+ defaultLocale: L;
65
+ }
66
+ interface I18n<D extends Dictionary, L extends string = string> {
67
+ /** The supported locales, in the order the catalogues were given. */
68
+ readonly locales: readonly L[];
69
+ readonly defaultLocale: L;
70
+ /** True when the value is a supported locale. */
71
+ isLocale(value: unknown): value is L;
72
+ /** Narrows any value (a route segment, a cookie) to a supported locale: the default one for anything else. */
73
+ asLocale(value: unknown): L;
74
+ /** The whole catalogue of a locale (the default one for an unknown locale) — for Server Components and branches. */
75
+ getDictionary(locale: unknown): D;
76
+ /** The translator bound to a locale: `translator("fr")("auction.bids", { count: 3 })`. */
77
+ translator(locale: unknown): Translate<D>;
78
+ /** The translator of the default locale. */
79
+ t: Translate<D>;
80
+ /** `{placeholder}` replacement, the same function as the package's `format`. */
81
+ format: typeof format;
82
+ }
83
+ /**
84
+ * The i18n engine of a Krizaka app, bound to its catalogues:
85
+ *
86
+ * ```ts
87
+ * import en from "@/messages/en.json";
88
+ * import fr from "@/messages/fr.json";
89
+ * export const i18n = createI18n<typeof en, "en" | "fr">({ en, fr }, { defaultLocale: "en" });
90
+ * i18n.t("auction.phase.SOLD"); // typed key
91
+ * i18n.translator("fr")("auction.bids", { count: 2 }); // plural set → @krizaka/intl's plural rules
92
+ * ```
93
+ *
94
+ * Giving the type arguments makes every catalogue type-check against the reference: a key missing from `fr.json`
95
+ * fails to compile. A key missing at run time falls back to the default locale, then to the key itself (visible,
96
+ * never an empty string).
97
+ */
98
+ declare function createI18n<D extends Dictionary, L extends string = string>(dictionaries: Readonly<Record<L, D>>, options: CreateI18nOptions<NoInfer<L>>): I18n<D, L>;
99
+
100
+ export { type CreateI18nOptions, type Dictionary, type I18n, type MessageAt, type MessageKey, type MessageValue, PLURAL_CATEGORIES, type PlaceholderValue, type Placeholders, type PluralCategory, type PluralMessage, type Translate, type Values, type ValuesArgs, createI18n, format, isPluralMessage, placeholdersOf };
package/dist/index.js ADDED
@@ -0,0 +1,59 @@
1
+ import {
2
+ PLURAL_CATEGORIES,
3
+ format,
4
+ isPluralMessage,
5
+ placeholdersOf
6
+ } from "./chunk-62MK7X4K.js";
7
+
8
+ // src/create.ts
9
+ import { plural } from "@krizaka/intl/plural";
10
+ function lookup(dictionary, key) {
11
+ let node = dictionary;
12
+ for (const part of key.split(".")) {
13
+ if (!node || typeof node !== "object" || Array.isArray(node) || !Object.hasOwn(node, part)) return void 0;
14
+ node = node[part];
15
+ }
16
+ return node;
17
+ }
18
+ function createI18n(dictionaries, options) {
19
+ const { defaultLocale } = options;
20
+ const locales = Object.keys(dictionaries);
21
+ if (!locales.includes(defaultLocale)) {
22
+ throw new RangeError(`createI18n: no catalogue for the default locale "${defaultLocale}".`);
23
+ }
24
+ const isLocale = (value) => typeof value === "string" && locales.includes(value);
25
+ const asLocale = (value) => isLocale(value) ? value : defaultLocale;
26
+ const getDictionary = (locale) => dictionaries[asLocale(locale)];
27
+ const translator = (locale) => {
28
+ const current = asLocale(locale);
29
+ const translate = (key, values) => {
30
+ let message = lookup(dictionaries[current], key);
31
+ if (message === void 0 && current !== defaultLocale) message = lookup(dictionaries[defaultLocale], key);
32
+ if (typeof message === "string") return format(message, values);
33
+ if (isPluralMessage(message)) {
34
+ const count = Number(values?.count);
35
+ return format(Number.isFinite(count) ? plural(count, current, message) : message.other, values);
36
+ }
37
+ return key;
38
+ };
39
+ return translate;
40
+ };
41
+ return {
42
+ locales,
43
+ defaultLocale,
44
+ isLocale,
45
+ asLocale,
46
+ getDictionary,
47
+ translator,
48
+ t: translator(defaultLocale),
49
+ format
50
+ };
51
+ }
52
+ export {
53
+ PLURAL_CATEGORIES,
54
+ createI18n,
55
+ format,
56
+ isPluralMessage,
57
+ placeholdersOf
58
+ };
59
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/create.ts"],"sourcesContent":["import { plural } from \"@krizaka/intl/plural\";\n\nimport { format, isPluralMessage } from \"./format\";\nimport type { Dictionary, Translate, Values } from \"./types\";\n\nexport interface CreateI18nOptions<L extends string> {\n /** The reference locale: its catalogue gives the shape, and anything unknown falls back to it. */\n defaultLocale: L;\n}\n\nexport interface I18n<D extends Dictionary, L extends string = string> {\n /** The supported locales, in the order the catalogues were given. */\n readonly locales: readonly L[];\n readonly defaultLocale: L;\n /** True when the value is a supported locale. */\n isLocale(value: unknown): value is L;\n /** Narrows any value (a route segment, a cookie) to a supported locale: the default one for anything else. */\n asLocale(value: unknown): L;\n /** The whole catalogue of a locale (the default one for an unknown locale) — for Server Components and branches. */\n getDictionary(locale: unknown): D;\n /** The translator bound to a locale: `translator(\"fr\")(\"auction.bids\", { count: 3 })`. */\n translator(locale: unknown): Translate<D>;\n /** The translator of the default locale. */\n t: Translate<D>;\n /** `{placeholder}` replacement, the same function as the package's `format`. */\n format: typeof format;\n}\n\nfunction lookup(dictionary: Dictionary, key: string): unknown {\n let node: unknown = dictionary;\n for (const part of key.split(\".\")) {\n if (!node || typeof node !== \"object\" || Array.isArray(node) || !Object.hasOwn(node, part)) return undefined;\n node = (node as Record<string, unknown>)[part];\n }\n return node;\n}\n\n/**\n * The i18n engine of a Krizaka app, bound to its catalogues:\n *\n * ```ts\n * import en from \"@/messages/en.json\";\n * import fr from \"@/messages/fr.json\";\n * export const i18n = createI18n<typeof en, \"en\" | \"fr\">({ en, fr }, { defaultLocale: \"en\" });\n * i18n.t(\"auction.phase.SOLD\"); // typed key\n * i18n.translator(\"fr\")(\"auction.bids\", { count: 2 }); // plural set → @krizaka/intl's plural rules\n * ```\n *\n * Giving the type arguments makes every catalogue type-check against the reference: a key missing from `fr.json`\n * fails to compile. A key missing at run time falls back to the default locale, then to the key itself (visible,\n * never an empty string).\n */\nexport function createI18n<D extends Dictionary, L extends string = string>(\n dictionaries: Readonly<Record<L, D>>,\n options: CreateI18nOptions<NoInfer<L>>,\n): I18n<D, L> {\n const { defaultLocale } = options;\n const locales = Object.keys(dictionaries) as L[];\n if (!locales.includes(defaultLocale)) {\n throw new RangeError(`createI18n: no catalogue for the default locale \"${defaultLocale}\".`);\n }\n\n const isLocale = (value: unknown): value is L => typeof value === \"string\" && locales.includes(value as L);\n const asLocale = (value: unknown): L => (isLocale(value) ? value : defaultLocale);\n const getDictionary = (locale: unknown): D => dictionaries[asLocale(locale)];\n\n const translator = (locale: unknown): Translate<D> => {\n const current = asLocale(locale);\n const translate = (key: string, values?: Values): string => {\n let message = lookup(dictionaries[current], key);\n if (message === undefined && current !== defaultLocale) message = lookup(dictionaries[defaultLocale], key);\n if (typeof message === \"string\") return format(message, values);\n if (isPluralMessage(message)) {\n const count = Number(values?.count);\n return format(Number.isFinite(count) ? plural(count, current, message) : message.other, values);\n }\n return key;\n };\n return translate as Translate<D>;\n };\n\n return {\n locales,\n defaultLocale,\n isLocale,\n asLocale,\n getDictionary,\n translator,\n t: translator(defaultLocale),\n format,\n };\n}\n"],"mappings":";;;;;;;;AAAA,SAAS,cAAc;AA4BvB,SAAS,OAAO,YAAwB,KAAsB;AAC5D,MAAI,OAAgB;AACpB,aAAW,QAAQ,IAAI,MAAM,GAAG,GAAG;AACjC,QAAI,CAAC,QAAQ,OAAO,SAAS,YAAY,MAAM,QAAQ,IAAI,KAAK,CAAC,OAAO,OAAO,MAAM,IAAI,EAAG,QAAO;AACnG,WAAQ,KAAiC,IAAI;AAAA,EAC/C;AACA,SAAO;AACT;AAiBO,SAAS,WACd,cACA,SACY;AACZ,QAAM,EAAE,cAAc,IAAI;AAC1B,QAAM,UAAU,OAAO,KAAK,YAAY;AACxC,MAAI,CAAC,QAAQ,SAAS,aAAa,GAAG;AACpC,UAAM,IAAI,WAAW,oDAAoD,aAAa,IAAI;AAAA,EAC5F;AAEA,QAAM,WAAW,CAAC,UAA+B,OAAO,UAAU,YAAY,QAAQ,SAAS,KAAU;AACzG,QAAM,WAAW,CAAC,UAAuB,SAAS,KAAK,IAAI,QAAQ;AACnE,QAAM,gBAAgB,CAAC,WAAuB,aAAa,SAAS,MAAM,CAAC;AAE3E,QAAM,aAAa,CAAC,WAAkC;AACpD,UAAM,UAAU,SAAS,MAAM;AAC/B,UAAM,YAAY,CAAC,KAAa,WAA4B;AAC1D,UAAI,UAAU,OAAO,aAAa,OAAO,GAAG,GAAG;AAC/C,UAAI,YAAY,UAAa,YAAY,cAAe,WAAU,OAAO,aAAa,aAAa,GAAG,GAAG;AACzG,UAAI,OAAO,YAAY,SAAU,QAAO,OAAO,SAAS,MAAM;AAC9D,UAAI,gBAAgB,OAAO,GAAG;AAC5B,cAAM,QAAQ,OAAO,QAAQ,KAAK;AAClC,eAAO,OAAO,OAAO,SAAS,KAAK,IAAI,OAAO,OAAO,SAAS,OAAO,IAAI,QAAQ,OAAO,MAAM;AAAA,MAChG;AACA,aAAO;AAAA,IACT;AACA,WAAO;AAAA,EACT;AAEA,SAAO;AAAA,IACL;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA,GAAG,WAAW,aAAa;AAAA,IAC3B;AAAA,EACF;AACF;","names":[]}
@@ -0,0 +1,43 @@
1
+ import * as react from 'react';
2
+ import { ReactNode } from 'react';
3
+ import { Dictionary, Translate, format, I18n } from './index.js';
4
+ import '@krizaka/intl/plural';
5
+
6
+ /** What `useI18n()` returns. */
7
+ interface I18nContextValue<D extends Dictionary, L extends string> {
8
+ /** The active locale. */
9
+ locale: L;
10
+ /** The whole catalogue of the active locale: `messages.home.title`. */
11
+ messages: D;
12
+ /** The translator of the active locale: `t("auction.bids", { count: 3 })`. */
13
+ t: Translate<D>;
14
+ /** `{placeholder}` replacement. */
15
+ format: typeof format;
16
+ /** Asks the app to switch locale (what it does — a route, a cookie — is the app's): a no-op unless given. */
17
+ setLocale: (locale: L) => void;
18
+ }
19
+ interface I18nProviderProps<L extends string> {
20
+ locale: L;
21
+ /** How the app switches locale (push a route, write a cookie). */
22
+ setLocale?: (locale: L) => void;
23
+ children?: ReactNode;
24
+ }
25
+ /**
26
+ * The React side of an i18n engine, bound to it once in the app:
27
+ *
28
+ * ```tsx
29
+ * "use client";
30
+ * export const { I18nProvider, useI18n } = createI18nReact(i18n);
31
+ * ```
32
+ *
33
+ * `I18nProvider` takes the locale the server resolved (and how to switch it); `useI18n()` gives the active locale, its
34
+ * catalogue, its translator and `format`. Outside a provider, `useI18n()` reads the default locale. Server Components
35
+ * do not use the context: they call `i18n.getDictionary(locale)` / `i18n.translator(locale)`.
36
+ */
37
+ declare function createI18nReact<D extends Dictionary, L extends string>(i18n: I18n<D, L>): {
38
+ I18nProvider: ({ locale, setLocale, children }: I18nProviderProps<L>) => react.JSX.Element;
39
+ useI18n: () => I18nContextValue<D, L>;
40
+ I18nContext: react.Context<I18nContextValue<D, L>>;
41
+ };
42
+
43
+ export { type I18nContextValue, type I18nProviderProps, createI18nReact };
package/dist/react.js ADDED
@@ -0,0 +1,42 @@
1
+ "use client";
2
+
3
+ // src/react.tsx
4
+ import { createContext, useContext, useMemo } from "react";
5
+
6
+ // src/format.ts
7
+ var PLACEHOLDER = /\{(\w+)\}/g;
8
+ function format(message, values = {}) {
9
+ return message.replace(PLACEHOLDER, (match, name) => {
10
+ const value = Object.hasOwn(values, name) ? values[name] : void 0;
11
+ return value === void 0 || value === null ? match : String(value);
12
+ });
13
+ }
14
+
15
+ // src/react.tsx
16
+ import { jsx } from "react/jsx-runtime";
17
+ var noop = () => {
18
+ };
19
+ function createI18nReact(i18n) {
20
+ const valueOf = (locale, setLocale) => ({
21
+ locale,
22
+ messages: i18n.getDictionary(locale),
23
+ t: i18n.translator(locale),
24
+ format,
25
+ setLocale
26
+ });
27
+ const Context = createContext(valueOf(i18n.defaultLocale, noop));
28
+ Context.displayName = "I18nContext";
29
+ function I18nProvider({ locale, setLocale = noop, children }) {
30
+ const current = i18n.asLocale(locale);
31
+ const value = useMemo(() => valueOf(current, setLocale), [current, setLocale]);
32
+ return /* @__PURE__ */ jsx(Context.Provider, { value, children });
33
+ }
34
+ function useI18n() {
35
+ return useContext(Context);
36
+ }
37
+ return { I18nProvider, useI18n, I18nContext: Context };
38
+ }
39
+ export {
40
+ createI18nReact
41
+ };
42
+ //# sourceMappingURL=react.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/react.tsx","../src/format.ts"],"sourcesContent":["import { createContext, type ReactNode, useContext, useMemo } from \"react\";\n\nimport type { I18n } from \"./create\";\nimport { format } from \"./format\";\nimport type { Dictionary, Translate } from \"./types\";\n\n/** What `useI18n()` returns. */\nexport interface I18nContextValue<D extends Dictionary, L extends string> {\n /** The active locale. */\n locale: L;\n /** The whole catalogue of the active locale: `messages.home.title`. */\n messages: D;\n /** The translator of the active locale: `t(\"auction.bids\", { count: 3 })`. */\n t: Translate<D>;\n /** `{placeholder}` replacement. */\n format: typeof format;\n /** Asks the app to switch locale (what it does — a route, a cookie — is the app's): a no-op unless given. */\n setLocale: (locale: L) => void;\n}\n\nexport interface I18nProviderProps<L extends string> {\n locale: L;\n /** How the app switches locale (push a route, write a cookie). */\n setLocale?: (locale: L) => void;\n children?: ReactNode;\n}\n\nconst noop = (): void => {};\n\n/**\n * The React side of an i18n engine, bound to it once in the app:\n *\n * ```tsx\n * \"use client\";\n * export const { I18nProvider, useI18n } = createI18nReact(i18n);\n * ```\n *\n * `I18nProvider` takes the locale the server resolved (and how to switch it); `useI18n()` gives the active locale, its\n * catalogue, its translator and `format`. Outside a provider, `useI18n()` reads the default locale. Server Components\n * do not use the context: they call `i18n.getDictionary(locale)` / `i18n.translator(locale)`.\n */\nexport function createI18nReact<D extends Dictionary, L extends string>(i18n: I18n<D, L>) {\n const valueOf = (locale: L, setLocale: (locale: L) => void): I18nContextValue<D, L> => ({\n locale,\n messages: i18n.getDictionary(locale),\n t: i18n.translator(locale),\n format,\n setLocale,\n });\n\n const Context = createContext<I18nContextValue<D, L>>(valueOf(i18n.defaultLocale, noop));\n Context.displayName = \"I18nContext\";\n\n function I18nProvider({ locale, setLocale = noop, children }: I18nProviderProps<L>) {\n const current = i18n.asLocale(locale);\n const value = useMemo(() => valueOf(current, setLocale), [current, setLocale]);\n return <Context.Provider value={value}>{children}</Context.Provider>;\n }\n\n function useI18n(): I18nContextValue<D, L> {\n return useContext(Context);\n }\n\n return { I18nProvider, useI18n, I18nContext: Context };\n}\n","import type { PluralMessage, Values } from \"./types\";\n\n/** A `{name}` placeholder: letters, digits and `_`. */\nexport const PLACEHOLDER = /\\{(\\w+)\\}/g;\n\n/** The plural categories, in CLDR order (`zero` first: the explicit \"exactly 0\" form). */\nexport const PLURAL_CATEGORIES = [\"zero\", \"one\", \"two\", \"few\", \"many\", \"other\"] as const;\n\n/**\n * Replaces the `{name}` placeholders of a message: `format(\"{count} repositories\", { count: 23 })` → `\"23 repositories\"`.\n * A placeholder without a value (missing, `null` or `undefined`) stays as written, so a gap is visible, never silent.\n */\nexport function format(message: string, values: Values = {}): string {\n return message.replace(PLACEHOLDER, (match, name: string) => {\n const value = Object.hasOwn(values, name) ? values[name] : undefined;\n return value === undefined || value === null ? match : String(value);\n });\n}\n\n/** The placeholder names of a message, sorted and without duplicates: `\"{b} {a} {b}\"` → `[\"a\", \"b\"]`. */\nexport function placeholdersOf(message: string): string[] {\n return [...new Set([...message.matchAll(PLACEHOLDER)].map((m) => m[1] as string))].sort();\n}\n\n/** True when a catalogue value is a plural set: an object of plural categories with an `other` string. */\nexport function isPluralMessage(value: unknown): value is PluralMessage {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) return false;\n const keys = Object.keys(value);\n return (\n typeof (value as { other?: unknown }).other === \"string\" &&\n keys.every((k) => (PLURAL_CATEGORIES as readonly string[]).includes(k) && typeof (value as Record<string, unknown>)[k] === \"string\")\n );\n}\n"],"mappings":";;;AAAA,SAAS,eAA+B,YAAY,eAAe;;;ACG5D,IAAM,cAAc;AASpB,SAAS,OAAO,SAAiB,SAAiB,CAAC,GAAW;AACnE,SAAO,QAAQ,QAAQ,aAAa,CAAC,OAAO,SAAiB;AAC3D,UAAM,QAAQ,OAAO,OAAO,QAAQ,IAAI,IAAI,OAAO,IAAI,IAAI;AAC3D,WAAO,UAAU,UAAa,UAAU,OAAO,QAAQ,OAAO,KAAK;AAAA,EACrE,CAAC;AACH;;;ADuCW;AA7BX,IAAM,OAAO,MAAY;AAAC;AAcnB,SAAS,gBAAwD,MAAkB;AACxF,QAAM,UAAU,CAAC,QAAW,eAA4D;AAAA,IACtF;AAAA,IACA,UAAU,KAAK,cAAc,MAAM;AAAA,IACnC,GAAG,KAAK,WAAW,MAAM;AAAA,IACzB;AAAA,IACA;AAAA,EACF;AAEA,QAAM,UAAU,cAAsC,QAAQ,KAAK,eAAe,IAAI,CAAC;AACvF,UAAQ,cAAc;AAEtB,WAAS,aAAa,EAAE,QAAQ,YAAY,MAAM,SAAS,GAAyB;AAClF,UAAM,UAAU,KAAK,SAAS,MAAM;AACpC,UAAM,QAAQ,QAAQ,MAAM,QAAQ,SAAS,SAAS,GAAG,CAAC,SAAS,SAAS,CAAC;AAC7E,WAAO,oBAAC,QAAQ,UAAR,EAAiB,OAAe,UAAS;AAAA,EACnD;AAEA,WAAS,UAAkC;AACzC,WAAO,WAAW,OAAO;AAAA,EAC3B;AAEA,SAAO,EAAE,cAAc,SAAS,aAAa,QAAQ;AACvD;","names":[]}
package/dist/rich.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ import { ReactNode } from 'react';
2
+
3
+ /** What `renderLink` receives for each `<a>…</a>` of a message, in order of appearance. */
4
+ interface RichLink {
5
+ /** The href given for this link (`href[index]`, or `href` itself when it is a string), if any. */
6
+ href: string | undefined;
7
+ /** The position of the link in the message: 0 for the first `<a>`. */
8
+ index: number;
9
+ children: ReactNode;
10
+ }
11
+ interface RichProps {
12
+ /** The message: plain text with `<b>…</b>` emphasis and `<a>…</a>` links, the only markup messages may carry. */
13
+ text: string;
14
+ /** The target of each `<a>`, in order (a string for a single link). The words stay in the message, the URL in code. */
15
+ href?: string | readonly string[];
16
+ /** Renders a link (e.g. with `next/link`). Default: `<a href>`, or the plain words when the link has no href. */
17
+ renderLink?: (link: RichLink) => ReactNode;
18
+ }
19
+ /**
20
+ * Renders a message with its markup — `<b>` → `<strong>`, `<a>` → a link — and everything else as text (never HTML:
21
+ * no `dangerouslySetInnerHTML`, an unknown or unbalanced tag is shown as written). `<b>` and `<a>` may hold each other.
22
+ * No hook, no context: usable in a Server Component.
23
+ *
24
+ * ```tsx
25
+ * <Rich text={t.home.lead} />
26
+ * <Rich text="Read <a>the docs</a>, <b>now</b>." href="/docs" renderLink={({ href, children }) => <Link href={href!}>{children}</Link>} />
27
+ * ```
28
+ */
29
+ declare function Rich({ text, href, renderLink }: RichProps): ReactNode;
30
+
31
+ export { Rich, type RichLink, type RichProps, Rich as default };
package/dist/rich.js ADDED
@@ -0,0 +1,39 @@
1
+ // src/rich.tsx
2
+ import { Fragment } from "react";
3
+ import { Fragment as Fragment2, jsx } from "react/jsx-runtime";
4
+ var TAG = /<(b|a)>([\s\S]*?)<\/\1>/g;
5
+ function defaultLink({ href, children }) {
6
+ return href === void 0 ? children : /* @__PURE__ */ jsx("a", { href, children });
7
+ }
8
+ function Rich({ text, href, renderLink = defaultLink }) {
9
+ const hrefs = typeof href === "string" ? [href] : href ?? [];
10
+ let links = 0;
11
+ const render = (source, prefix) => {
12
+ const out = [];
13
+ let last = 0;
14
+ for (const match of source.matchAll(TAG)) {
15
+ const [whole, tag, inner] = match;
16
+ const at = match.index;
17
+ const key = `${prefix}${out.length}`;
18
+ if (at > last) out.push(/* @__PURE__ */ jsx(Fragment, { children: source.slice(last, at) }, `${key}t`));
19
+ if (tag === "b") {
20
+ out.push(/* @__PURE__ */ jsx("strong", { children: render(inner, `${key}.`) }, key));
21
+ } else {
22
+ const index = links++;
23
+ out.push(
24
+ /* @__PURE__ */ jsx(Fragment, { children: renderLink({ href: hrefs[index], index, children: render(inner, `${key}.`) }) }, key)
25
+ );
26
+ }
27
+ last = at + whole.length;
28
+ }
29
+ if (last < source.length) out.push(/* @__PURE__ */ jsx(Fragment, { children: source.slice(last) }, `${prefix}end`));
30
+ return out;
31
+ };
32
+ return /* @__PURE__ */ jsx(Fragment2, { children: render(text, "") });
33
+ }
34
+ var rich_default = Rich;
35
+ export {
36
+ Rich,
37
+ rich_default as default
38
+ };
39
+ //# sourceMappingURL=rich.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/rich.tsx"],"sourcesContent":["import { Fragment, type ReactNode } from \"react\";\n\n/** What `renderLink` receives for each `<a>…</a>` of a message, in order of appearance. */\nexport interface RichLink {\n /** The href given for this link (`href[index]`, or `href` itself when it is a string), if any. */\n href: string | undefined;\n /** The position of the link in the message: 0 for the first `<a>`. */\n index: number;\n children: ReactNode;\n}\n\nexport interface RichProps {\n /** The message: plain text with `<b>…</b>` emphasis and `<a>…</a>` links, the only markup messages may carry. */\n text: string;\n /** The target of each `<a>`, in order (a string for a single link). The words stay in the message, the URL in code. */\n href?: string | readonly string[];\n /** Renders a link (e.g. with `next/link`). Default: `<a href>`, or the plain words when the link has no href. */\n renderLink?: (link: RichLink) => ReactNode;\n}\n\nconst TAG = /<(b|a)>([\\s\\S]*?)<\\/\\1>/g;\n\nfunction defaultLink({ href, children }: RichLink): ReactNode {\n return href === undefined ? children : <a href={href}>{children}</a>;\n}\n\n/**\n * Renders a message with its markup — `<b>` → `<strong>`, `<a>` → a link — and everything else as text (never HTML:\n * no `dangerouslySetInnerHTML`, an unknown or unbalanced tag is shown as written). `<b>` and `<a>` may hold each other.\n * No hook, no context: usable in a Server Component.\n *\n * ```tsx\n * <Rich text={t.home.lead} />\n * <Rich text=\"Read <a>the docs</a>, <b>now</b>.\" href=\"/docs\" renderLink={({ href, children }) => <Link href={href!}>{children}</Link>} />\n * ```\n */\nexport function Rich({ text, href, renderLink = defaultLink }: RichProps): ReactNode {\n const hrefs = typeof href === \"string\" ? [href] : (href ?? []);\n let links = 0;\n\n // The match is lazy: inside <b>…</b> there is no </b>, so only an <a> can match there (and the other way round).\n const render = (source: string, prefix: string): ReactNode[] => {\n const out: ReactNode[] = [];\n let last = 0;\n for (const match of source.matchAll(TAG)) {\n const [whole, tag, inner] = match as unknown as [string, \"a\" | \"b\", string];\n const at = match.index as number;\n const key = `${prefix}${out.length}`;\n if (at > last) out.push(<Fragment key={`${key}t`}>{source.slice(last, at)}</Fragment>);\n if (tag === \"b\") {\n out.push(<strong key={key}>{render(inner, `${key}.`)}</strong>);\n } else {\n const index = links++;\n out.push(\n <Fragment key={key}>{renderLink({ href: hrefs[index], index, children: render(inner, `${key}.`) })}</Fragment>,\n );\n }\n last = at + whole.length;\n }\n if (last < source.length) out.push(<Fragment key={`${prefix}end`}>{source.slice(last)}</Fragment>);\n return out;\n };\n\n return <>{render(text, \"\")}</>;\n}\n\nexport default Rich;\n"],"mappings":";AAAA,SAAS,gBAAgC;AAuBA,SAwChC,YAAAA,WAxCgC;AAHzC,IAAM,MAAM;AAEZ,SAAS,YAAY,EAAE,MAAM,SAAS,GAAwB;AAC5D,SAAO,SAAS,SAAY,WAAW,oBAAC,OAAE,MAAa,UAAS;AAClE;AAYO,SAAS,KAAK,EAAE,MAAM,MAAM,aAAa,YAAY,GAAyB;AACnF,QAAM,QAAQ,OAAO,SAAS,WAAW,CAAC,IAAI,IAAK,QAAQ,CAAC;AAC5D,MAAI,QAAQ;AAGZ,QAAM,SAAS,CAAC,QAAgB,WAAgC;AAC9D,UAAM,MAAmB,CAAC;AAC1B,QAAI,OAAO;AACX,eAAW,SAAS,OAAO,SAAS,GAAG,GAAG;AACxC,YAAM,CAAC,OAAO,KAAK,KAAK,IAAI;AAC5B,YAAM,KAAK,MAAM;AACjB,YAAM,MAAM,GAAG,MAAM,GAAG,IAAI,MAAM;AAClC,UAAI,KAAK,KAAM,KAAI,KAAK,oBAAC,YAA0B,iBAAO,MAAM,MAAM,EAAE,KAAjC,GAAG,GAAG,GAA6B,CAAW;AACrF,UAAI,QAAQ,KAAK;AACf,YAAI,KAAK,oBAAC,YAAkB,iBAAO,OAAO,GAAG,GAAG,GAAG,KAA7B,GAA+B,CAAS;AAAA,MAChE,OAAO;AACL,cAAM,QAAQ;AACd,YAAI;AAAA,UACF,oBAAC,YAAoB,qBAAW,EAAE,MAAM,MAAM,KAAK,GAAG,OAAO,UAAU,OAAO,OAAO,GAAG,GAAG,GAAG,EAAE,CAAC,KAAlF,GAAoF;AAAA,QACrG;AAAA,MACF;AACA,aAAO,KAAK,MAAM;AAAA,IACpB;AACA,QAAI,OAAO,OAAO,OAAQ,KAAI,KAAK,oBAAC,YAA+B,iBAAO,MAAM,IAAI,KAAlC,GAAG,MAAM,KAA2B,CAAW;AACjG,WAAO;AAAA,EACT;AAEA,SAAO,oBAAAA,WAAA,EAAG,iBAAO,MAAM,EAAE,GAAE;AAC7B;AAEA,IAAO,eAAQ;","names":["Fragment"]}
package/package.json CHANGED
@@ -1,6 +1,128 @@
1
1
  {
2
2
  "name": "@krizaka/i18n",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0-beta.1",
4
+ "description": "The i18n engine of every Krizaka app: typed message keys and placeholders from en.json, format(), plurals through @krizaka/intl, <Rich> (<b>, <a>), a React provider, and the krizaka-i18n CLI that checks catalogues and finds hard-coded strings.",
5
+ "keywords": [
6
+ "krizaka",
7
+ "i18n",
8
+ "translation",
9
+ "messages",
10
+ "typed-keys",
11
+ "plural",
12
+ "react",
13
+ "cli"
14
+ ],
15
+ "homepage": "https://github.com/krizaka/krizaka-ui/tree/main/packages/i18n#readme",
16
+ "bugs": "https://github.com/krizaka/krizaka-ui/issues",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/krizaka/krizaka-ui.git",
20
+ "directory": "packages/i18n"
21
+ },
22
+ "license": "Apache-2.0",
23
+ "author": "Krizaka (https://www.krizaka.com)",
24
+ "type": "module",
25
+ "main": "./dist/index.js",
26
+ "module": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "exports": {
29
+ ".": {
30
+ "types": "./dist/index.d.ts",
31
+ "import": "./dist/index.js"
32
+ },
33
+ "./rich": {
34
+ "types": "./dist/rich.d.ts",
35
+ "import": "./dist/rich.js"
36
+ },
37
+ "./react": {
38
+ "types": "./dist/react.d.ts",
39
+ "import": "./dist/react.js"
40
+ },
41
+ "./check": {
42
+ "types": "./dist/check.d.ts",
43
+ "import": "./dist/check.js"
44
+ },
45
+ "./package.json": "./package.json"
46
+ },
47
+ "bin": {
48
+ "krizaka-i18n": "./dist/bin.js"
49
+ },
50
+ "files": [
51
+ "dist",
52
+ "README.md",
53
+ "LICENSE"
54
+ ],
55
+ "sideEffects": false,
56
+ "dependencies": {
57
+ "@krizaka/intl": "^0.1.0-beta.0"
58
+ },
59
+ "peerDependencies": {
60
+ "react": ">=18",
61
+ "typescript": "^5"
62
+ },
63
+ "peerDependenciesMeta": {
64
+ "react": {
65
+ "optional": true
66
+ },
67
+ "typescript": {
68
+ "optional": true
69
+ }
70
+ },
71
+ "devDependencies": {
72
+ "@arethetypeswrong/cli": "^0.18.5",
73
+ "@size-limit/preset-small-lib": "^14.2.0",
74
+ "@types/node": "^22.15.0",
75
+ "@types/react": "^19.2.0",
76
+ "@types/react-dom": "^19.2.0",
77
+ "@vitest/coverage-v8": "4.1.11",
78
+ "eslint": "^9.39.0",
79
+ "publint": "^0.3.25",
80
+ "react": "^19.2.0",
81
+ "react-dom": "^19.2.0",
82
+ "size-limit": "^14.2.0",
83
+ "tsup": "^8.5.1",
84
+ "typescript": "^5.9.0",
85
+ "vitest": "^4.1.11",
86
+ "@krizaka/config": "0.1.0"
87
+ },
88
+ "engines": {
89
+ "node": ">=20"
90
+ },
91
+ "publishConfig": {
92
+ "access": "public"
93
+ },
94
+ "size-limit": [
95
+ {
96
+ "name": ".",
97
+ "path": "dist/index.js",
98
+ "limit": "1.5 kB",
99
+ "gzip": true
100
+ },
101
+ {
102
+ "name": "./rich",
103
+ "path": "dist/rich.js",
104
+ "limit": "1 kB",
105
+ "gzip": true,
106
+ "ignore": [
107
+ "react"
108
+ ]
109
+ },
110
+ {
111
+ "name": "./react",
112
+ "path": "dist/react.js",
113
+ "limit": "2 kB",
114
+ "gzip": true,
115
+ "ignore": [
116
+ "react"
117
+ ]
118
+ }
119
+ ],
120
+ "scripts": {
121
+ "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsup",
122
+ "lint": "eslint . --max-warnings 0 && tsc --noEmit --noUnusedLocals --noUnusedParameters --noFallthroughCasesInSwitch --noImplicitReturns",
123
+ "typecheck": "tsc --noEmit",
124
+ "test": "vitest run --coverage",
125
+ "size": "size-limit",
126
+ "publint": "publint && attw --pack . --profile esm-only && tsc -p test/consumer/tsconfig.json"
127
+ }
6
128
  }