@terrafirmagreg-team/translate 0.0.0-stage → 0.1.0

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.
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@terrafirmagreg-team/translate",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/runtime.tsx",
7
+ "./provider": "./src/provider.tsx",
8
+ "./lang": "./src/lang.ts",
9
+ "./create": "./src/create.ts",
10
+ "./config": "./src/config.ts",
11
+ "./server": "./src/server.ts",
12
+ "./hooks": "./src/hooks/index.ts",
13
+ "./hooks/*": "./src/hooks/*.ts",
14
+ "./lib/*": "./src/lib/*.ts",
15
+ "./types/*": "./src/types/*.ts",
16
+ "./package.json": "./package.json",
17
+ "./runtime": "./src/runtime.tsx"
18
+ },
19
+ "files": [
20
+ "src",
21
+ "styles",
22
+ "styles.css"
23
+ ],
24
+ "peerDependencies": {
25
+ "react": ">=19"
26
+ },
27
+ "devDependencies": {
28
+ "@types/node": "^26.6.5",
29
+ "@types/react": "^19.3.0",
30
+ "@types/react-dom": "^19.3.0"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/TerraFirmaGreg-Team/Web-Foundation.git",
35
+ "directory": "packages/translate"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "scripts": {}
6
41
  }
package/src/config.ts ADDED
@@ -0,0 +1,14 @@
1
+ export type TranslateConfig = {
2
+
3
+ locales?: Record<string, unknown> | Record<string, unknown>[];
4
+ };
5
+
6
+ let config: TranslateConfig = {};
7
+
8
+ export function registerTranslateConfig(next: TranslateConfig): void {
9
+ config = { ...config, ...next };
10
+ }
11
+
12
+ export function translateConfig(): TranslateConfig {
13
+ return config;
14
+ }
package/src/create.ts ADDED
@@ -0,0 +1,64 @@
1
+ import type {
2
+ Lang,
3
+ LocaleMessages,
4
+ LocaleRegistry,
5
+ } from './types/locale';
6
+ import type { LangConfig, LangProviderProps, LocaleItem } from './lang';
7
+ import { nativeLanguageName } from './lib/code';
8
+
9
+
10
+ export function localeItems(codes: readonly string[]): LocaleItem[] {
11
+ return codes.map((code) => ({
12
+ locale: code,
13
+ name: nativeLanguageName(code) ?? code,
14
+ }));
15
+ }
16
+
17
+ function messagesFor(
18
+ lang: Lang,
19
+ registry: LocaleRegistry,
20
+ fallback: LocaleMessages,
21
+ ): LocaleMessages & { displayName: string } {
22
+ const locale = registry.messages[lang];
23
+ const merged = { ...fallback, ...locale };
24
+ const displayName = nativeLanguageName(lang) ?? lang;
25
+ const { 'language.name': _name, displayName: _dn, ...rest } = merged;
26
+ return { ...rest, displayName };
27
+ }
28
+
29
+ export function createLocaleSet(registry: LocaleRegistry) {
30
+ const fallback = {
31
+ ...(registry.messages[registry.defaultLanguage] ?? {}),
32
+ };
33
+ const bundles: Record<string, LocaleMessages & { displayName: string }> = {};
34
+ for (const lang of registry.languages) {
35
+ bundles[lang] = messagesFor(lang, registry, fallback);
36
+ }
37
+
38
+ const config: LangConfig = {
39
+ defaultLanguage: registry.defaultLanguage,
40
+ languages: [...registry.languages],
41
+ fallbackLanguage: registry.defaultLanguage,
42
+ };
43
+
44
+ return {
45
+ fumadocs: config,
46
+ translations: bundles,
47
+ languages: registry.languages,
48
+ defaultLanguage: registry.defaultLanguage,
49
+ providerProps(lang: Lang): LangProviderProps {
50
+ const locale = lang ?? registry.defaultLanguage;
51
+ return {
52
+ locale,
53
+ defaultLanguage: registry.defaultLanguage,
54
+ translations: bundles[locale] ?? bundles[registry.defaultLanguage],
55
+ locales: registry.languages.map((code) => ({
56
+ locale: code,
57
+ name: bundles[code]?.displayName ?? code,
58
+ })),
59
+ };
60
+ },
61
+ };
62
+ }
63
+
64
+ export type LocaleSet = ReturnType<typeof createLocaleSet>;
@@ -0,0 +1,10 @@
1
+ export { useTranslations } from './use-translations';
2
+ export {
3
+ ResolvedLangContext,
4
+ useResolvedLang,
5
+ type ResolvedLangValue,
6
+ } from './use-resolved-lang';
7
+ export {
8
+ LocaleRegistryContext,
9
+ useLocaleRegistry,
10
+ } from './use-locale-registry';
@@ -0,0 +1,11 @@
1
+ 'use client';
2
+ import { createContext, useContext } from 'react';
3
+ import type { LocaleRegistry } from '@terrafirmagreg-team/translate/types/locale';
4
+
5
+ export const LocaleRegistryContext = createContext<LocaleRegistry | null>(null);
6
+
7
+ export function useLocaleRegistry(): LocaleRegistry {
8
+ const registry = useContext(LocaleRegistryContext);
9
+ if (!registry) throw new Error('Missing LocaleRegistryContext');
10
+ return registry;
11
+ }
@@ -0,0 +1,36 @@
1
+ 'use client';
2
+ import { createContext, useContext } from 'react';
3
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
4
+
5
+ export type ResolvedLangValue = {
6
+ lang: Lang;
7
+
8
+ defaultLang: Lang;
9
+ setLang: (lang: Lang) => void;
10
+ };
11
+
12
+ export const ResolvedLangContext = createContext<ResolvedLangValue | null>(
13
+ null,
14
+ );
15
+
16
+ export type LangScopeValue = {
17
+ langs: readonly Lang[] | null;
18
+ setLangs: (langs: readonly Lang[] | null) => void;
19
+ };
20
+
21
+ export const LangScopeContext = createContext<LangScopeValue>({
22
+ langs: null,
23
+ setLangs: () => {},
24
+ });
25
+
26
+ export function useResolvedLang(): Lang {
27
+ const ctx = useContext(ResolvedLangContext);
28
+ if (!ctx) throw new Error('Missing ResolvedLangContext');
29
+ return ctx.lang;
30
+ }
31
+
32
+ export function useDefaultLang(): Lang {
33
+ const ctx = useContext(ResolvedLangContext);
34
+ if (!ctx) throw new Error('Missing ResolvedLangContext');
35
+ return ctx.defaultLang;
36
+ }
@@ -0,0 +1,12 @@
1
+ 'use client';
2
+ import { useTranslations as useFumaTranslations } from '@terrafirmagreg-team/translate/runtime';
3
+
4
+ export function useTranslations<K extends string = string>(options?: {
5
+ note?: string;
6
+ }) {
7
+ const base = useFumaTranslations(options);
8
+ return base as (key: K, opts?: {
9
+ note?: string;
10
+ variables?: Record<string, string>;
11
+ }) => string;
12
+ }
package/src/lang.ts ADDED
@@ -0,0 +1,28 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ export interface LangConfig<Languages extends string = string> {
4
+
5
+ languages: Languages[];
6
+
7
+ defaultLanguage: NoInfer<Languages>;
8
+
9
+ hideLocale?: 'always' | 'default-locale' | 'never';
10
+
11
+ parser?: 'dot' | 'dir' | 'none';
12
+
13
+ fallbackLanguage?: NoInfer<Languages> | null;
14
+ }
15
+
16
+ export interface LocaleItem {
17
+ name: string;
18
+ locale: string;
19
+ }
20
+
21
+ export interface LangProviderProps
22
+ extends Partial<Pick<LangConfig, 'defaultLanguage' | 'hideLocale'>> {
23
+ locale?: string;
24
+ onLocaleChange?: (v: string) => void;
25
+ translations?: Partial<Record<string, string>>;
26
+ locales?: LocaleItem[];
27
+ children?: ReactNode;
28
+ }
@@ -0,0 +1,50 @@
1
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
2
+
3
+ export function sortLangCodes(codes: readonly string[]): string[] {
4
+ return [...codes].sort((a, b) => {
5
+ if (a === 'en_us') return -1;
6
+ if (b === 'en_us') return 1;
7
+ return a.localeCompare(b);
8
+ });
9
+ }
10
+
11
+ export function isLang(
12
+ value: string,
13
+ languages: readonly string[],
14
+ ): value is Lang {
15
+ return languages.includes(value);
16
+ }
17
+
18
+ export function resolveLang(
19
+ value: string | string[] | undefined | null,
20
+ languages: readonly string[],
21
+ defaultLanguage: string,
22
+ ): Lang {
23
+ const raw = Array.isArray(value) ? value[0] : value;
24
+ if (raw && isLang(raw, languages)) return raw;
25
+ return defaultLanguage;
26
+ }
27
+
28
+ export function htmlLang(lang: Lang): string {
29
+ return lang.split('_')[0] || lang;
30
+ }
31
+
32
+ function bcp47(lang: Lang): string {
33
+ const [primary, region] = lang.split('_');
34
+ if (!primary) return lang;
35
+ return region ? `${primary}-${region.toUpperCase()}` : primary;
36
+ }
37
+
38
+ export function nativeLanguageName(lang: Lang): string | undefined {
39
+ try {
40
+ const tag = bcp47(lang);
41
+ const name = new Intl.DisplayNames([tag], {
42
+ type: 'language',
43
+ languageDisplay: 'standard',
44
+ }).of(tag);
45
+ if (!name) return undefined;
46
+ return name.charAt(0).toLocaleUpperCase(tag) + name.slice(1);
47
+ } catch {
48
+ return undefined;
49
+ }
50
+ }
@@ -0,0 +1,50 @@
1
+ import 'server-only';
2
+ import type { ReactNode } from 'react';
3
+ import { loadLocaleRegistry } from './registry.server';
4
+ import type { Lang } from '../types/locale';
5
+ import type { LangContent } from '../types/content';
6
+
7
+ type Resolve<P> = (
8
+ slug: string[] | undefined,
9
+ lang: Lang,
10
+ ) => { page: P; isFallback: boolean } | undefined;
11
+
12
+ type Get<P> = (slug: string[] | undefined, lang: Lang) => P | undefined;
13
+
14
+ export function langContent<P>(
15
+ slug: string[] | undefined,
16
+ resolve: Resolve<P>,
17
+ render: (page: P, lang: Lang) => ReactNode,
18
+ ): LangContent | null {
19
+ const { messages, defaultLanguage } = loadLocaleRegistry();
20
+ const content: LangContent = {};
21
+ for (const locale of Object.keys(messages)) {
22
+ const resolved = resolve(slug, locale as Lang);
23
+ if (!resolved || resolved.isFallback) continue;
24
+ content[locale] = { node: render(resolved.page, locale as Lang) };
25
+ }
26
+ if (!Object.keys(content).length) {
27
+ const resolved = resolve(slug, defaultLanguage);
28
+ if (resolved) {
29
+ content[defaultLanguage] = { node: render(resolved.page, defaultLanguage) };
30
+ }
31
+ }
32
+ return Object.keys(content).length ? content : null;
33
+ }
34
+
35
+ export function firstLocalizedPage<P>(
36
+ slug: string[] | undefined,
37
+ getPage: Get<P>,
38
+ initial?: P,
39
+ ): P | undefined {
40
+ if (initial) return initial;
41
+ const { languages, defaultLanguage } = loadLocaleRegistry();
42
+ const page = getPage(slug, defaultLanguage);
43
+ if (page) return page;
44
+ for (const locale of languages) {
45
+ if (locale === defaultLanguage) continue;
46
+ const found = getPage(slug, locale as Lang);
47
+ if (found) return found;
48
+ }
49
+ return undefined;
50
+ }
@@ -0,0 +1,70 @@
1
+ import 'server-only';
2
+ import { readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ export type TranslationState = 'untranslated' | 'partial' | 'ok';
6
+
7
+ function proseLines(text: string): string[] {
8
+ const out: string[] = [];
9
+ let fence = false;
10
+ let frontmatter = false;
11
+ let body = false;
12
+ for (const raw of text.split('\n')) {
13
+ const line = raw.trim();
14
+ if (!body) {
15
+ if (!line) continue;
16
+ body = true;
17
+ if (line === '---') {
18
+ frontmatter = true;
19
+ continue;
20
+ }
21
+ }
22
+ if (frontmatter) {
23
+ if (line === '---' || line === '...') frontmatter = false;
24
+ continue;
25
+ }
26
+ if (line.startsWith('```')) {
27
+ fence = !fence;
28
+ continue;
29
+ }
30
+ if (fence || !line) continue;
31
+ if (
32
+ line.startsWith('import ') ||
33
+ line.startsWith('export ') ||
34
+ line.startsWith('<') ||
35
+ line.startsWith(':::')
36
+ ) {
37
+ continue;
38
+ }
39
+ out.push(line);
40
+ }
41
+ return out;
42
+ }
43
+
44
+ export function translationState(src: string, ref: string): TranslationState {
45
+ if (src === ref) return 'untranslated';
46
+ const lines = proseLines(src);
47
+ if (lines.length < 5) return 'ok';
48
+ const refSet = new Set(proseLines(ref));
49
+ const shared = lines.filter((line) => refSet.has(line)).length;
50
+ return shared / lines.length >= 0.7 ? 'partial' : 'ok';
51
+ }
52
+
53
+ export function readPageSource(
54
+ contentDir: string,
55
+ pagePath: string,
56
+ ): string | undefined {
57
+ try {
58
+ return readFileSync(
59
+ join(
60
+ process.cwd(),
61
+ 'content',
62
+ contentDir.replace(/^content[\\/]?/, ''),
63
+ pagePath.replace(/\\/g, '/'),
64
+ ),
65
+ 'utf8',
66
+ );
67
+ } catch {
68
+ return undefined;
69
+ }
70
+ }
@@ -0,0 +1,16 @@
1
+ export function flattenMessages(
2
+ value: unknown,
3
+ file: string,
4
+ prefix = '',
5
+ ): Record<string, string> {
6
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
7
+ throw new Error(`Locale ${file}: ${prefix || '<root>'} must be an object`);
8
+ }
9
+ const out: Record<string, string> = {};
10
+ for (const [key, entry] of Object.entries(value)) {
11
+ const path = prefix ? `${prefix}.${key}` : key;
12
+ if (typeof entry === 'string') out[path] = entry;
13
+ else Object.assign(out, flattenMessages(entry, file, path));
14
+ }
15
+ return out;
16
+ }
@@ -0,0 +1,10 @@
1
+ export function createLocaleCatalog(
2
+ modules: Record<string, unknown>,
3
+ ): Record<string, unknown> {
4
+ return Object.fromEntries(
5
+ Object.entries(modules).map(([path, messages]) => [
6
+ path.slice(path.lastIndexOf('/') + 1, -'.json'.length),
7
+ messages,
8
+ ]),
9
+ );
10
+ }
@@ -0,0 +1,58 @@
1
+ import 'server-only';
2
+ import { createLocaleSet, type LocaleSet } from '../create';
3
+ import { translateConfig } from '../config';
4
+ import { flattenMessages } from './flatten';
5
+ import { sortLangCodes } from './code';
6
+ import type { LocaleMessages, LocaleRegistry } from '../types/locale';
7
+
8
+ export { sortLangCodes };
9
+
10
+ const LANG_PATTERN = /^[a-z]{2}(_[a-z0-9]+)?$/i;
11
+
12
+ let cached: LocaleRegistry | undefined;
13
+ let cachedSet: LocaleSet | undefined;
14
+
15
+ function flattenAll(
16
+ raw: Record<string, unknown>,
17
+ origin: string,
18
+ ): Record<string, LocaleMessages> {
19
+ const messages: Record<string, LocaleMessages> = {};
20
+ for (const [code, value] of Object.entries(raw)) {
21
+ if (!LANG_PATTERN.test(code)) {
22
+ throw new Error(`Invalid locale code "${code}" in ${origin}`);
23
+ }
24
+ messages[code] = flattenMessages(value, `${origin}/${code}.json`);
25
+ }
26
+ return messages;
27
+ }
28
+
29
+ function discoverLocaleRegistry(): LocaleRegistry {
30
+ const catalogs = [translateConfig().locales ?? []].flat();
31
+
32
+ const messages: Record<string, LocaleMessages> = {};
33
+ for (const catalog of catalogs) {
34
+ const flattened = flattenAll(catalog, 'locales');
35
+ for (const [lang, catalogMessages] of Object.entries(flattened)) {
36
+ messages[lang] = { ...messages[lang], ...catalogMessages };
37
+ }
38
+ }
39
+
40
+ const languages = sortLangCodes(Object.keys(messages));
41
+ if (languages.length === 0) {
42
+ throw new Error('No locale messages registered');
43
+ }
44
+ const defaultLanguage = languages.includes('en_us')
45
+ ? 'en_us'
46
+ : languages[0]!;
47
+ return { languages, defaultLanguage, messages };
48
+ }
49
+
50
+ export function loadLocaleRegistry(): LocaleRegistry {
51
+ cached ??= discoverLocaleRegistry();
52
+ return cached;
53
+ }
54
+
55
+ export function loadLocaleSet(): LocaleSet {
56
+ cachedSet ??= createLocaleSet(loadLocaleRegistry());
57
+ return cachedSet;
58
+ }
@@ -0,0 +1,29 @@
1
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
2
+
3
+ export function stripLangFromUrl(
4
+ url: string,
5
+ languages: readonly string[],
6
+ ): string {
7
+ const langPathPattern = new RegExp(
8
+ `\\/(${languages.map((l) => l.replace(/[.*+?^${}()|[\]\\]/g, '\$&')).join('|')})(?=\\/|$)`,
9
+ );
10
+ return url.replace(langPathPattern, '');
11
+ }
12
+
13
+ export function toInternalSlugs(
14
+ slug: string[] | undefined,
15
+ lang: Lang,
16
+ ): string[] | undefined {
17
+ if (!slug?.length) return undefined;
18
+ const [dataset, ...rest] = slug;
19
+ return [dataset, lang, ...rest];
20
+ }
21
+
22
+ export function toPublicSlug(
23
+ slugs: string[],
24
+ languages: readonly string[],
25
+ ): string[] {
26
+ const [dataset, maybeLang, ...rest] = slugs;
27
+ if (!dataset || !languages.includes(maybeLang)) return slugs;
28
+ return [dataset, ...rest];
29
+ }
@@ -0,0 +1,248 @@
1
+ import { cloneElement, isValidElement } from 'react';
2
+ import type * as PageTree from '../types/tree';
3
+ import type { Lang } from '../types/locale';
4
+ import { isLang } from './code';
5
+ import { loadLocaleRegistry } from './registry.server';
6
+ import { stripLangFromUrl } from './slug';
7
+
8
+ type LocaleTreeOptions = {
9
+ languages: readonly string[];
10
+ fallbackLanguage: string;
11
+ };
12
+
13
+ function localeTreeOptions(): LocaleTreeOptions {
14
+ const { languages, defaultLanguage } = loadLocaleRegistry();
15
+ return { languages, fallbackLanguage: defaultLanguage };
16
+ }
17
+
18
+ function folderLang(
19
+ node: PageTree.Folder,
20
+ languages: readonly string[],
21
+ ): Lang | undefined {
22
+ for (const lang of languages) {
23
+ if (typeof node.name === 'string' && node.name === lang) return lang;
24
+
25
+ const id = node.$id?.split('/').filter(Boolean).at(-1);
26
+ if (id === lang) return lang;
27
+ }
28
+ return undefined;
29
+ }
30
+
31
+ function rewritePage(
32
+ page: PageTree.Item,
33
+ languages: readonly string[],
34
+ ): PageTree.Item {
35
+ return { ...page, url: stripLangFromUrl(page.url, languages) };
36
+ }
37
+
38
+ function rewriteNodes(
39
+ nodes: PageTree.Node[],
40
+ languages: readonly string[],
41
+ ): PageTree.Node[] {
42
+ return nodes.map((node) => {
43
+ if (node.type === 'page') return rewritePage(node, languages);
44
+ if (node.type === 'folder') {
45
+ return {
46
+ ...node,
47
+ index: node.index ? rewritePage(node.index, languages) : undefined,
48
+ children: rewriteNodes(node.children, languages),
49
+ };
50
+ }
51
+ return node;
52
+ });
53
+ }
54
+
55
+ function nodeKey(
56
+ node: PageTree.Node,
57
+ languages: readonly string[],
58
+ ): string | undefined {
59
+ if (node.type === 'page') return stripLangFromUrl(node.url, languages);
60
+ if (node.type === 'folder') {
61
+ if (node.$id) return stripLangFromUrl(`/${node.$id}`, languages);
62
+ if (node.index) return stripLangFromUrl(node.index.url, languages);
63
+ return `folder:${String(node.name)}`;
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ function mergeWithFallback(
69
+ active: PageTree.Node[],
70
+ fallback: PageTree.Node[],
71
+ languages: readonly string[],
72
+ ): PageTree.Node[] {
73
+ const out: PageTree.Node[] = active.map((node) => {
74
+ if (node.type !== 'folder') return node;
75
+ const key = nodeKey(node, languages);
76
+ const fb = fallback.find(
77
+ (candidate) =>
78
+ candidate.type === 'folder' && nodeKey(candidate, languages) === key,
79
+ );
80
+ if (!fb || fb.type !== 'folder') return node;
81
+ return {
82
+ ...node,
83
+ children: mergeWithFallback(node.children, fb.children, languages),
84
+ };
85
+ });
86
+
87
+ const keys = new Set(
88
+ out
89
+ .map((node) => nodeKey(node, languages))
90
+ .filter((key): key is string => Boolean(key)),
91
+ );
92
+
93
+ for (const node of fallback) {
94
+ if (node.type === 'separator') continue;
95
+ const key = nodeKey(node, languages);
96
+ if (!key || keys.has(key)) continue;
97
+ keys.add(key);
98
+ out.push(node);
99
+ }
100
+
101
+ return out;
102
+ }
103
+
104
+ function promoteLangFolderChildren(
105
+ folder: PageTree.Folder,
106
+ lang: Lang,
107
+ options: LocaleTreeOptions,
108
+ ) {
109
+ const children = folder.children.filter(
110
+ (c) => !(c.type === 'page' && /\/index\.mdx?$/.test(c.$id ?? '')),
111
+ );
112
+ return rewriteNodes(promoteLang(children, lang, options), options.languages);
113
+ }
114
+
115
+ function promoteLang(
116
+ nodes: PageTree.Node[],
117
+ lang: Lang,
118
+ options: LocaleTreeOptions,
119
+ ): PageTree.Node[] {
120
+ const { languages, fallbackLanguage } = options;
121
+ const out: PageTree.Node[] = [];
122
+
123
+ for (const node of nodes) {
124
+ if (node.type !== 'folder') {
125
+ if (node.type === 'page') {
126
+ const parts = node.url.split('/').filter(Boolean);
127
+ const locked = isLang(parts[2] ?? '', languages) ? parts[2] : undefined;
128
+ if (locked && locked !== lang) continue;
129
+ out.push(rewritePage(node, languages));
130
+ } else {
131
+ out.push(node);
132
+ }
133
+ continue;
134
+ }
135
+
136
+ const langOf = folderLang(node, languages);
137
+ if (langOf) {
138
+ if (langOf !== lang) continue;
139
+ out.push(...promoteLangFolderChildren(node, lang, options));
140
+ continue;
141
+ }
142
+
143
+ const activeLangFolder = node.children.find(
144
+ (child): child is PageTree.Folder =>
145
+ child.type === 'folder' && folderLang(child, languages) === lang,
146
+ );
147
+ const fallbackLangFolder =
148
+ lang !== fallbackLanguage
149
+ ? node.children.find(
150
+ (child): child is PageTree.Folder =>
151
+ child.type === 'folder' &&
152
+ folderLang(child, languages) === fallbackLanguage,
153
+ )
154
+ : undefined;
155
+
156
+ const rest = node.children.filter((child) => {
157
+ if (child.type !== 'folder') return true;
158
+ return folderLang(child, languages) === undefined;
159
+ });
160
+
161
+ let promoted: PageTree.Node[] = [];
162
+ if (activeLangFolder) {
163
+ promoted = promoteLangFolderChildren(activeLangFolder, lang, options);
164
+ }
165
+ if (fallbackLangFolder) {
166
+ const fallbackNodes = promoteLangFolderChildren(
167
+ fallbackLangFolder,
168
+ fallbackLanguage,
169
+ options,
170
+ );
171
+ const stamped = rewriteNodes(fallbackNodes, languages);
172
+ promoted = mergeWithFallback(promoted, stamped, languages);
173
+ }
174
+ promoted.push(...promoteLang(rest, lang, options));
175
+
176
+ const indexPage = activeLangFolder?.index
177
+ ? rewritePage(activeLangFolder.index, languages)
178
+ : fallbackLangFolder?.index
179
+ ? rewritePage(fallbackLangFolder.index, languages)
180
+ : node.index
181
+ ? rewritePage(node.index, languages)
182
+ : undefined;
183
+
184
+ const indexUrl = indexPage?.url;
185
+ const withoutIndex = promoted.filter(
186
+ (child) => !(child.type === 'page' && indexUrl && child.url === indexUrl),
187
+ );
188
+
189
+ out.push({
190
+ ...node,
191
+ index: indexPage,
192
+ children:
193
+ indexPage && node.root ? [indexPage, ...withoutIndex] : withoutIndex,
194
+ });
195
+ }
196
+
197
+ return out;
198
+ }
199
+
200
+ function withIconKey<T extends PageTree.Node>(node: T, key: string): T {
201
+ if (node.type === 'separator') return node;
202
+ if (!isValidElement(node.icon) || node.icon.key != null) return node;
203
+ return { ...node, icon: cloneElement(node.icon, { key }) };
204
+ }
205
+
206
+ export function keyTreeNode(node: PageTree.Node): PageTree.Node {
207
+ switch (node.type) {
208
+ case 'page':
209
+ return withIconKey(node, node.url);
210
+ case 'folder':
211
+ return {
212
+ ...withIconKey(node, node.$id ?? node.index?.url ?? String(node.name)),
213
+ index: node.index ? withIconKey(node.index, node.index.url) : node.index,
214
+ children: node.children.map(keyTreeNode),
215
+ };
216
+ case 'separator':
217
+ return node;
218
+ default: {
219
+ const _never: never = node;
220
+ return _never;
221
+ }
222
+ }
223
+ }
224
+
225
+ export function localizePageTree(
226
+ tree: PageTree.Root,
227
+ lang: Lang,
228
+ options?: LocaleTreeOptions,
229
+ ): PageTree.Root {
230
+ const opts = options ?? localeTreeOptions();
231
+ const children = promoteLang(tree.children, lang, opts);
232
+ const merged =
233
+ lang === opts.fallbackLanguage
234
+ ? children
235
+ : mergeWithFallback(
236
+ children,
237
+ rewriteNodes(
238
+ promoteLang(tree.children, opts.fallbackLanguage, opts),
239
+ opts.languages,
240
+ ),
241
+ opts.languages,
242
+ );
243
+ return {
244
+ ...tree,
245
+ $id: `${tree.$id ?? 'root'}:${lang}`,
246
+ children: merged.map(keyTreeNode),
247
+ };
248
+ }
package/src/lib/url.ts ADDED
@@ -0,0 +1,91 @@
1
+ import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
2
+
3
+ const QUERY_SAFE = /[A-Za-z0-9\-._~:/[\]@!$'()*{},;|]/u;
4
+
5
+ export function encodeQueryValue(value: string): string {
6
+ return Array.from(value, (ch) =>
7
+ QUERY_SAFE.test(ch) ? ch : encodeURIComponent(ch),
8
+ ).join('');
9
+ }
10
+
11
+ export function encodeQuery(
12
+ entries: Iterable<readonly [string, string | number | undefined]>,
13
+ ): string {
14
+ const parts: string[] = [];
15
+ for (const [key, value] of entries) {
16
+ if (value === undefined || value === '') continue;
17
+ parts.push(`${encodeQueryValue(key)}=${encodeQueryValue(String(value))}`);
18
+ }
19
+ return parts.join('&');
20
+ }
21
+
22
+ const toQuery = (params: URLSearchParams): string => {
23
+ const parts: string[] = [];
24
+ for (const [key, value] of params) {
25
+ parts.push(`${encodeQueryValue(key)}=${encodeQueryValue(value)}`);
26
+ }
27
+ return parts.join('&');
28
+ };
29
+
30
+ const splitFragment = (fragment: string): { anchor: string; params: URLSearchParams } => {
31
+ const sep = fragment.indexOf('&');
32
+ if (sep === -1) return { anchor: fragment, params: new URLSearchParams() };
33
+ return {
34
+ anchor: fragment.slice(0, sep),
35
+ params: new URLSearchParams(fragment.slice(sep + 1)),
36
+ };
37
+ };
38
+
39
+ export function langFromFragment(hash: string): string | undefined {
40
+ if (!hash.includes('&')) return undefined;
41
+ return splitFragment(hash.startsWith('#') ? hash.slice(1) : hash).params.get('lang') ?? undefined;
42
+ }
43
+
44
+ let effectiveDefault: Lang | undefined;
45
+
46
+ export function publishDefaultLang(lang: Lang | undefined): void {
47
+ effectiveDefault = lang;
48
+ }
49
+
50
+ export function hrefWithLang(href: string, lang: Lang, fallback?: Lang): string {
51
+ if (!href || href.startsWith('//')) return href;
52
+ const hashIndex = href.indexOf('#');
53
+ const path = hashIndex === -1 ? href : href.slice(0, hashIndex);
54
+ const fragment = hashIndex === -1 ? '' : href.slice(hashIndex + 1);
55
+ const qIndex = path.indexOf('?');
56
+ const qs = qIndex === -1 ? '' : path.slice(qIndex + 1);
57
+ const existing =
58
+ new URLSearchParams(qs).get('lang') ?? langFromFragment(fragment) ?? null;
59
+ const def = fallback ?? effectiveDefault;
60
+ if (existing !== null) {
61
+
62
+ if (existing === def) return withLang(href, def, fallback);
63
+ return href;
64
+ }
65
+ return withLang(href, lang, fallback);
66
+ }
67
+
68
+ export function withLang(href: string, lang: Lang, fallback?: Lang): string {
69
+ const isDefault = lang === (fallback ?? effectiveDefault);
70
+ const hashIndex = href.indexOf('#');
71
+ const path = hashIndex === -1 ? href : href.slice(0, hashIndex);
72
+ const fragment = hashIndex === -1 ? '' : href.slice(hashIndex + 1);
73
+ const qIndex = path.indexOf('?');
74
+ const pathname = qIndex === -1 ? path : path.slice(0, qIndex);
75
+ const existing = qIndex === -1 ? '' : path.slice(qIndex + 1);
76
+ const params = new URLSearchParams(existing);
77
+ if (!fragment) {
78
+ if (isDefault) params.delete('lang');
79
+ else params.set('lang', lang);
80
+ const q = toQuery(params);
81
+ return `${pathname}${q ? `?${q}` : ''}`;
82
+ }
83
+ const { anchor, params: fragParams } = splitFragment(fragment);
84
+ params.delete('lang');
85
+ if (isDefault) fragParams.delete('lang');
86
+ else fragParams.set('lang', lang);
87
+ const fragQuery = toQuery(fragParams);
88
+ const q = toQuery(params);
89
+ const frag = fragQuery ? `${anchor}&${fragQuery}` : anchor;
90
+ return `${pathname}${q ? `?${q}` : ''}#${frag}`;
91
+ }
@@ -0,0 +1,65 @@
1
+ 'use client';
2
+ import {
3
+ createContext,
4
+ use,
5
+ useLayoutEffect,
6
+ useMemo,
7
+ type ReactNode,
8
+ } from 'react';
9
+ import { TranslationProvider } from './runtime';
10
+ import { LangScopeContext } from './hooks/use-resolved-lang';
11
+ import type { LangProviderProps, LocaleItem } from './lang';
12
+
13
+ export interface LangContextValue {
14
+ locale?: string;
15
+ locales?: LocaleItem[];
16
+ onLocaleChange?: (v: string) => void;
17
+ }
18
+
19
+ const LangContext = createContext<LangContextValue>({});
20
+
21
+ export function LangProvider({
22
+ locale,
23
+ translations,
24
+ locales,
25
+ onLocaleChange,
26
+ children,
27
+ }: LangProviderProps) {
28
+ const value = useMemo(
29
+ () => ({ locale, locales, onLocaleChange }),
30
+ [locale, locales, onLocaleChange],
31
+ );
32
+ return (
33
+ <LangContext value={value}>
34
+ <TranslationProvider translations={translations ?? {}}>
35
+ {children}
36
+ </TranslationProvider>
37
+ </LangContext>
38
+ );
39
+ }
40
+
41
+ export function useLang(): LangContextValue {
42
+ return use(LangContext);
43
+ }
44
+
45
+ export function LangLocalesScope({
46
+ locales,
47
+ children,
48
+ }: {
49
+ locales: readonly LocaleItem[];
50
+ children: ReactNode;
51
+ }) {
52
+ const base = useLang();
53
+ const { setLangs } = use(LangScopeContext);
54
+ const scopeKey = locales.map((item) => item.locale).join(',');
55
+ useLayoutEffect(() => {
56
+ const langs = scopeKey ? scopeKey.split(',') : null;
57
+ setLangs(langs);
58
+ return () => setLangs(null);
59
+ }, [scopeKey, setLangs]);
60
+ const value = useMemo(
61
+ () => ({ ...base, locales: [...locales] }),
62
+ [base, locales],
63
+ );
64
+ return <LangContext value={value}>{children}</LangContext>;
65
+ }
@@ -0,0 +1,247 @@
1
+ "use client";
2
+ import {
3
+ cloneElement,
4
+ createContext,
5
+ isValidElement,
6
+ ReactElement,
7
+ use,
8
+ useMemo,
9
+ type ReactNode,
10
+ } from "react";
11
+
12
+ const Context = createContext<Partial<Record<string, string>>>({});
13
+
14
+
15
+ export function TranslationProvider({
16
+ translations,
17
+ children,
18
+ }: {
19
+ translations: Partial<Record<string, string>>;
20
+ children: ReactNode;
21
+ }) {
22
+ const parent = use(Context);
23
+ return (
24
+ <Context value={useMemo(() => ({ ...parent, ...translations }), [parent, translations])}>
25
+ {children}
26
+ </Context>
27
+ );
28
+ }
29
+
30
+ type GetVariables<T extends string> = T extends `${infer Before}{${infer K}}${infer After}`
31
+ ? (Before extends `${string}\\` ? never : K) | GetVariables<After>
32
+ : never;
33
+
34
+ type GetTags<T extends string> = T extends `${infer Before}<${infer K}>${infer After}`
35
+ ?
36
+ | (Before extends `${string}\\`
37
+ ? never
38
+ : K extends `/${string}`
39
+ ? never
40
+ : K extends `${infer Tag}/`
41
+ ? Tag
42
+ : K)
43
+ | GetTags<After>
44
+ : never;
45
+
46
+ interface HookOptions {
47
+
48
+ note?: string;
49
+ }
50
+
51
+ export interface TranslationsHook {
52
+ translations: Partial<Record<string, string>>;
53
+
54
+ <Text extends string>(
55
+ text: Text,
56
+ opts?: {
57
+
58
+ note?: string;
59
+ variables?: Record<GetVariables<Text>, string>;
60
+ },
61
+ ): string;
62
+
63
+ jsx<Text extends string>(
64
+ text: Text,
65
+ opts?: {
66
+
67
+ note?: string;
68
+ variables?: Record<GetVariables<Text>, ReactNode>;
69
+ tags?: Record<GetTags<Text>, TagRenderer>;
70
+ },
71
+ ): ReactNode;
72
+ }
73
+
74
+ const REGEX_VAR = /\\?\{([^}]+)\}/g;
75
+
76
+ export function useTranslations({ note }: HookOptions = {}): TranslationsHook {
77
+ const translations = use(Context);
78
+ return useMemo(() => fromTranslations(translations, { note }), [translations, note]);
79
+ }
80
+
81
+
82
+ export function fromTranslations(
83
+ translations: Partial<Record<string, string>>,
84
+ { note: hookNote }: HookOptions = {},
85
+ ): TranslationsHook {
86
+ const res: TranslationsHook = (rawText, opts = {}) => {
87
+ const { note, variables } = opts;
88
+ const notes: string[] = [];
89
+ if (hookNote) notes.push(hookNote);
90
+ if (note) notes.push(note);
91
+ const k = encodeKey(rawText, notes);
92
+ let text = translations[k] ?? rawText;
93
+
94
+ if (variables) {
95
+ text = text.replaceAll(REGEX_VAR, (m, name: string) => {
96
+ if (m[0] === "\\") return m.slice(1);
97
+ if (name in variables) return variables[name as never];
98
+ return m;
99
+ });
100
+ }
101
+
102
+ return text;
103
+ };
104
+
105
+ res.jsx = (rawText, opts = {}) => {
106
+ const { note, variables, tags } = opts;
107
+ const notes: string[] = [];
108
+ if (hookNote) notes.push(hookNote);
109
+ if (note) notes.push(note);
110
+ const k = encodeKey(rawText, notes);
111
+ const text = translations[k] ?? rawText;
112
+ if (tags) {
113
+ return onJsx(text, variables, tags);
114
+ }
115
+ if (variables) {
116
+ return onJsxVariables(text, variables);
117
+ }
118
+ return text;
119
+ };
120
+
121
+ res.translations = translations;
122
+ return res;
123
+ }
124
+
125
+ function encodeKey(text: string, notes: string[]): string {
126
+ return text + notes.map((n) => `(${n})`).join("");
127
+ }
128
+
129
+ function onJsxVariables(text: string, variables: Record<string, ReactNode>): ReactNode {
130
+ let idx = 0;
131
+ const out: ReactNode[] = [];
132
+ for (const match of text.matchAll(REGEX_VAR)) {
133
+ const [s, name] = match as unknown as [string, string];
134
+ if (idx < match.index) {
135
+ out.push(text.slice(idx, match.index));
136
+ }
137
+ idx = match.index + s.length;
138
+
139
+ if (s[0] === "\\") {
140
+ out.push(s.slice(1));
141
+ } else if (name in variables) {
142
+ out.push(enforceElementKey(variables[name], idx));
143
+ } else {
144
+ out.push(s);
145
+ }
146
+ }
147
+ out.push(text.slice(idx));
148
+ return out;
149
+ }
150
+
151
+ const REGEX_TAG = /\\?<([^>]+)>/g;
152
+
153
+ function onJsx(
154
+ text: string,
155
+ variables: Record<string, ReactNode> | undefined,
156
+ tags: Record<string, TagRenderer>,
157
+ ) {
158
+ const stack: { children: ReactNode[]; tag?: string }[] = [{ children: [] }];
159
+ let idx = 0;
160
+ const closeTag = () => {
161
+ const current = stack.pop()!;
162
+
163
+ stack[stack.length - 1]!.children.push(
164
+ enforceElementKey(
165
+ current.tag && tags[current.tag]
166
+ ? renderTag(tags[current.tag]!, current.children)
167
+ : current.children,
168
+ idx,
169
+ ),
170
+ );
171
+ };
172
+
173
+ for (const match of text.matchAll(REGEX_TAG)) {
174
+ const current = stack[stack.length - 1]!;
175
+ const [s, content] = match as unknown as [string, string];
176
+
177
+ if (idx < match.index) {
178
+ const str = text.slice(idx, match.index);
179
+ current.children.push(variables ? onJsxVariables(str, variables) : str);
180
+ }
181
+ idx = match.index + s.length;
182
+
183
+ if (s[0] === "\\") {
184
+ const str = s.slice(1);
185
+ current.children.push(variables ? onJsxVariables(str, variables) : str);
186
+ } else if (content[0] === "/") {
187
+ const name = content.slice(1);
188
+
189
+ if (current.tag !== name) {
190
+ current.children.push(variables ? onJsxVariables(s, variables) : s);
191
+ } else {
192
+ closeTag();
193
+ }
194
+ } else if (content.at(-1) === "/") {
195
+ const name = content.slice(0, -1);
196
+
197
+ if (tags[name]) {
198
+ current.children.push(enforceElementKey(renderTag(tags[name]), idx));
199
+ }
200
+ } else {
201
+ stack.push({ children: [], tag: content });
202
+ }
203
+ }
204
+
205
+ if (idx < text.length) {
206
+ const str = text.slice(idx);
207
+ stack[stack.length - 1]!.children.push(variables ? onJsxVariables(str, variables) : str);
208
+ }
209
+
210
+ while (stack.length > 1) closeTag();
211
+ return stack[0]!.children;
212
+ }
213
+
214
+ interface TProps<Text extends string> {
215
+ text: Text;
216
+
217
+ note?: string;
218
+ variables?: Record<GetVariables<Text>, ReactNode>;
219
+ tags?: Record<GetTags<Text>, TagRenderer>;
220
+ }
221
+
222
+ export function T<const Text extends string>({
223
+ text,
224
+ note,
225
+ variables,
226
+ tags,
227
+ }: TProps<Text>): ReactNode {
228
+ const translations = use(Context)!;
229
+ return useMemo(
230
+ () => fromTranslations(translations).jsx(text, { note, variables, tags }),
231
+ [translations, text, note, variables, tags],
232
+ );
233
+ }
234
+
235
+
236
+ type TagRenderer = ReactElement | ((children: ReactNode) => ReactNode);
237
+
238
+ function renderTag(renderer: TagRenderer, child?: ReactNode) {
239
+ if (isValidElement(renderer))
240
+ return child === undefined ? renderer : cloneElement(renderer, undefined, child);
241
+ return renderer(child);
242
+ }
243
+
244
+ function enforceElementKey(value: ReactNode, key: number | string): ReactNode {
245
+ if (isValidElement(value)) return cloneElement(value, { key });
246
+ return value;
247
+ }
package/src/server.ts ADDED
@@ -0,0 +1,6 @@
1
+ import 'server-only';
2
+
3
+ export * from './lib/content';
4
+ export * from './lib/coverage';
5
+ export * from './lib/registry.server';
6
+ export * from './lib/tree';
@@ -0,0 +1,4 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { Lang } from './locale';
3
+
4
+ export type LangContent = Partial<Record<Lang, { node: ReactNode }>>;
@@ -0,0 +1,9 @@
1
+ export type Lang = string;
2
+
3
+ export type LocaleMessages = Record<string, string>;
4
+
5
+ export type LocaleRegistry = {
6
+ languages: string[];
7
+ defaultLanguage: string;
8
+ messages: Record<string, LocaleMessages>;
9
+ };
@@ -0,0 +1,55 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ interface ID {
4
+
5
+ $id?: string;
6
+ }
7
+
8
+ export interface Root extends ID {
9
+
10
+ $ref?: Folder['$ref'];
11
+ type?: 'root';
12
+ name: ReactNode;
13
+ description?: ReactNode;
14
+ children: Node[];
15
+
16
+ fallback?: Root;
17
+ }
18
+
19
+ export type Node = Item | Separator | Folder;
20
+
21
+ export interface Item extends ID {
22
+
23
+ $ref?: string;
24
+ type: 'page';
25
+ name: ReactNode;
26
+ url: string;
27
+
28
+ external?: boolean;
29
+ description?: ReactNode;
30
+ icon?: ReactNode;
31
+ }
32
+
33
+ export interface Separator extends ID {
34
+ type: 'separator';
35
+ name?: ReactNode;
36
+ icon?: ReactNode;
37
+ }
38
+
39
+ export interface Folder extends ID {
40
+
41
+ $ref?: {
42
+ meta?: string;
43
+ folder: string;
44
+ };
45
+ type: 'folder';
46
+ name: ReactNode;
47
+ description?: ReactNode;
48
+
49
+ root?: boolean | string;
50
+ defaultOpen?: boolean;
51
+ collapsible?: boolean;
52
+ index?: Item;
53
+ icon?: ReactNode;
54
+ children: Node[];
55
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.