@terrafirmagreg-team/translate 0.0.0-stage → 1.2.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 +46 -4
- package/src/components/provider.tsx +66 -0
- package/src/components/runtime.tsx +9 -0
- package/src/config.ts +15 -0
- package/src/create.ts +64 -0
- package/src/hooks/index.ts +11 -0
- package/src/hooks/use-locale-registry.ts +12 -0
- package/src/hooks/use-resolved-lang.ts +37 -0
- package/src/hooks/use-translations.ts +24 -0
- package/src/lang.ts +29 -0
- package/src/lib/code.ts +51 -0
- package/src/lib/content.ts +53 -0
- package/src/lib/coverage.ts +71 -0
- package/src/lib/flatten.ts +17 -0
- package/src/lib/key-index.ts +21 -0
- package/src/lib/locales.ts +24 -0
- package/src/lib/registry.server.ts +59 -0
- package/src/lib/slug.ts +30 -0
- package/src/lib/tree.ts +249 -0
- package/src/lib/url.ts +92 -0
- package/src/server.ts +7 -0
- package/src/types/content.ts +5 -0
- package/src/types/locale.ts +10 -0
- package/src/types/tree.ts +56 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terrafirmagreg-team/translate",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Localization runtime and catalogs",
|
|
6
|
+
"license": "LGPL-3.0-only",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/components/runtime.tsx",
|
|
9
|
+
"./provider": "./src/components/provider.tsx",
|
|
10
|
+
"./lang": "./src/lang.ts",
|
|
11
|
+
"./create": "./src/create.ts",
|
|
12
|
+
"./config": "./src/config.ts",
|
|
13
|
+
"./server": "./src/server.ts",
|
|
14
|
+
"./hooks": "./src/hooks/index.ts",
|
|
15
|
+
"./hooks/*": [
|
|
16
|
+
"./src/hooks/*.ts",
|
|
17
|
+
"./src/hooks/*/index.ts"
|
|
18
|
+
],
|
|
19
|
+
"./lib/*": [
|
|
20
|
+
"./src/lib/*.ts",
|
|
21
|
+
"./src/lib/*/index.ts"
|
|
22
|
+
],
|
|
23
|
+
"./types/*": "./src/types/*.ts",
|
|
24
|
+
"./runtime": "./src/components/runtime.tsx"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src"
|
|
28
|
+
],
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@fuma-translate/react": "1.0.2"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"react": "catalog:"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@types/node": "catalog:",
|
|
37
|
+
"@types/react": "catalog:",
|
|
38
|
+
"@types/react-dom": "catalog:"
|
|
39
|
+
},
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "https://github.com/TerraFirmaGreg-Team/Web-Foundation.git",
|
|
43
|
+
"directory": "packages/translate"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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 '@terrafirmagreg-team/translate/hooks/use-resolved-lang';
|
|
11
|
+
import type { LangProviderProps, LocaleItem } from '@terrafirmagreg-team/translate/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
|
+
}
|
|
66
|
+
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
}
|
|
15
|
+
|
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
|
+
export function localeItems(codes: readonly string[]): LocaleItem[] {
|
|
10
|
+
return codes.map((code) => ({
|
|
11
|
+
locale: code,
|
|
12
|
+
name: nativeLanguageName(code) ?? code,
|
|
13
|
+
}));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function messagesFor(
|
|
17
|
+
lang: Lang,
|
|
18
|
+
registry: LocaleRegistry,
|
|
19
|
+
fallback: LocaleMessages,
|
|
20
|
+
): LocaleMessages & { displayName: string } {
|
|
21
|
+
const locale = registry.messages[lang];
|
|
22
|
+
const merged = { ...fallback, ...locale };
|
|
23
|
+
const displayName = nativeLanguageName(lang) ?? lang;
|
|
24
|
+
const { 'language.name': _name, displayName: _dn, ...rest } = merged;
|
|
25
|
+
return { ...rest, displayName };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createLocaleSet(registry: LocaleRegistry) {
|
|
29
|
+
const fallback = {
|
|
30
|
+
...(registry.messages[registry.defaultLanguage] ?? {}),
|
|
31
|
+
};
|
|
32
|
+
const bundles: Record<string, LocaleMessages & { displayName: string }> = {};
|
|
33
|
+
for (const lang of registry.languages) {
|
|
34
|
+
bundles[lang] = messagesFor(lang, registry, fallback);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const config: LangConfig = {
|
|
38
|
+
defaultLanguage: registry.defaultLanguage,
|
|
39
|
+
languages: [...registry.languages],
|
|
40
|
+
fallbackLanguage: registry.defaultLanguage,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
fumadocs: config,
|
|
45
|
+
translations: bundles,
|
|
46
|
+
languages: registry.languages,
|
|
47
|
+
defaultLanguage: registry.defaultLanguage,
|
|
48
|
+
providerProps(lang: Lang): LangProviderProps {
|
|
49
|
+
const locale = lang ?? registry.defaultLanguage;
|
|
50
|
+
return {
|
|
51
|
+
locale,
|
|
52
|
+
defaultLanguage: registry.defaultLanguage,
|
|
53
|
+
translations: bundles[locale] ?? bundles[registry.defaultLanguage],
|
|
54
|
+
locales: registry.languages.map((code) => ({
|
|
55
|
+
locale: code,
|
|
56
|
+
name: bundles[code]?.displayName ?? code,
|
|
57
|
+
})),
|
|
58
|
+
};
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type LocaleSet = ReturnType<typeof createLocaleSet>;
|
|
64
|
+
|
|
@@ -0,0 +1,11 @@
|
|
|
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';
|
|
11
|
+
|
|
@@ -0,0 +1,12 @@
|
|
|
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
|
+
}
|
|
12
|
+
|
|
@@ -0,0 +1,37 @@
|
|
|
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
|
+
}
|
|
37
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useMemo } from 'react';
|
|
3
|
+
import { useTranslations as useFumaTranslations } from '@terrafirmagreg-team/translate/runtime';
|
|
4
|
+
|
|
5
|
+
export function useTranslations<K extends string = string>(options?: {
|
|
6
|
+
note?: string;
|
|
7
|
+
}) {
|
|
8
|
+
const base = useFumaTranslations(options);
|
|
9
|
+
return useMemo(
|
|
10
|
+
() =>
|
|
11
|
+
((key: K, opts?: {
|
|
12
|
+
note?: string;
|
|
13
|
+
variables?: Record<string, string>;
|
|
14
|
+
}) => {
|
|
15
|
+
const value = base(key, opts);
|
|
16
|
+
return opts?.note && value === key ? base(key) : value;
|
|
17
|
+
}) as (key: K, opts?: {
|
|
18
|
+
note?: string;
|
|
19
|
+
variables?: Record<string, string>;
|
|
20
|
+
}) => string,
|
|
21
|
+
[base],
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
package/src/lang.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
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
|
+
}
|
|
29
|
+
|
package/src/lib/code.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
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
|
+
}
|
|
51
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import type { ReactNode } from 'react';
|
|
3
|
+
import { loadLocaleRegistry } from './registry.server';
|
|
4
|
+
import type { Lang } from '@terrafirmagreg-team/translate/types/locale';
|
|
5
|
+
import type { LangContent } from '@terrafirmagreg-team/translate/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 primary = resolve(slug, defaultLanguage);
|
|
21
|
+
if (primary && !primary.isFallback) {
|
|
22
|
+
return {
|
|
23
|
+
lang: defaultLanguage,
|
|
24
|
+
node: render(primary.page, defaultLanguage),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
for (const locale of Object.keys(messages)) {
|
|
28
|
+
const resolved = resolve(slug, locale as Lang);
|
|
29
|
+
if (!resolved || resolved.isFallback) continue;
|
|
30
|
+
return { lang: locale as Lang, node: render(resolved.page, locale as Lang) };
|
|
31
|
+
}
|
|
32
|
+
return primary
|
|
33
|
+
? { lang: defaultLanguage, node: render(primary.page, defaultLanguage) }
|
|
34
|
+
: null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function firstLocalizedPage<P>(
|
|
38
|
+
slug: string[] | undefined,
|
|
39
|
+
getPage: Get<P>,
|
|
40
|
+
initial?: P,
|
|
41
|
+
): P | undefined {
|
|
42
|
+
if (initial) return initial;
|
|
43
|
+
const { languages, defaultLanguage } = loadLocaleRegistry();
|
|
44
|
+
const page = getPage(slug, defaultLanguage);
|
|
45
|
+
if (page) return page;
|
|
46
|
+
for (const locale of languages) {
|
|
47
|
+
if (locale === defaultLanguage) continue;
|
|
48
|
+
const found = getPage(slug, locale as Lang);
|
|
49
|
+
if (found) return found;
|
|
50
|
+
}
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
@@ -0,0 +1,71 @@
|
|
|
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
|
+
}
|
|
71
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
}
|
|
17
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const NOTE_KEY = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/;
|
|
2
|
+
|
|
3
|
+
export function buildKeyIndex(
|
|
4
|
+
messages: Partial<Record<string, string>>,
|
|
5
|
+
): Map<string, string> {
|
|
6
|
+
const index = new Map<string, string>();
|
|
7
|
+
for (const key of Object.keys(messages)) {
|
|
8
|
+
const end = key.lastIndexOf('(');
|
|
9
|
+
if (!key.endsWith(')') || end < 0) continue;
|
|
10
|
+
const note = key.slice(end + 1, -1);
|
|
11
|
+
if (NOTE_KEY.test(note)) index.set(note, key);
|
|
12
|
+
}
|
|
13
|
+
return index;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function stripKeyNote(full: string, key: string): string {
|
|
17
|
+
return full !== key && full.endsWith(`(${key})`)
|
|
18
|
+
? full.slice(0, -(key.length + 2))
|
|
19
|
+
: full;
|
|
20
|
+
}
|
|
21
|
+
|
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
}
|
|
11
|
+
|
|
12
|
+
export function createLocaleCatalogs(
|
|
13
|
+
modules: Record<string, unknown>,
|
|
14
|
+
): Record<string, Record<string, unknown>> {
|
|
15
|
+
const catalogs: Record<string, Record<string, unknown>> = {};
|
|
16
|
+
for (const [path, messages] of Object.entries(modules)) {
|
|
17
|
+
const dir = path.slice(0, path.lastIndexOf('/'));
|
|
18
|
+
const catalog = dir.slice(dir.lastIndexOf('/') + 1);
|
|
19
|
+
const lang = path.slice(path.lastIndexOf('/') + 1, -'.json'.length);
|
|
20
|
+
(catalogs[catalog] ??= {})[lang] = messages;
|
|
21
|
+
}
|
|
22
|
+
return catalogs;
|
|
23
|
+
}
|
|
24
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
import { createLocaleSet, type LocaleSet } from '@terrafirmagreg-team/translate/create';
|
|
3
|
+
import { translateConfig } from '@terrafirmagreg-team/translate/config';
|
|
4
|
+
import { flattenMessages } from './flatten';
|
|
5
|
+
import { sortLangCodes } from './code';
|
|
6
|
+
import type { LocaleMessages, LocaleRegistry } from '@terrafirmagreg-team/translate/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
|
+
}
|
|
59
|
+
|
package/src/lib/slug.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
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
|
+
}
|
|
30
|
+
|
package/src/lib/tree.ts
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { cloneElement, isValidElement } from 'react';
|
|
2
|
+
import type * as PageTree from '@terrafirmagreg-team/translate/types/tree';
|
|
3
|
+
import type { Lang } from '@terrafirmagreg-team/translate/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
|
+
}
|
|
249
|
+
|
package/src/lib/url.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
}
|
|
92
|
+
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
}
|
|
56
|
+
|
package/README.md
DELETED