@lunmark/vokativ-next-intl 0.0.0-stage → 0.0.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,14 @@
1
+ # Changelog
2
+
3
+ ## 0.0.1
4
+
5
+ First release.
6
+
7
+ - `createVokativNextIntlPlugin(options, nextIntlSetting)` in place of next-intl's plugin, and
8
+ `withVokativ(config)` after it: `<voc>{name}</voc>` in a message puts the name in the Serbian
9
+ vocative, with no change at call sites.
10
+ - `languages`: `auto` for every Serbian locale, or per locale with `"latin"`, `"cyrillic"`,
11
+ `true` or `false`; the most specific key wins. A misspelled setting stops the build.
12
+ - A `gender` value in the message decides surnames.
13
+ - Works with next-intl's precompiled messages, Turbopack and webpack.
14
+ - `vokativNextIntl()` for Vitest.
package/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Lunmark
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ---
24
+
25
+ The name data in data/ and the exception lists derived from it are based on
26
+ "Vokativi" by Startit (https://github.com/startitrs/vokativi), licensed under
27
+ CC BY 4.0 (https://creativecommons.org/licenses/by/4.0/); see data/LICENSE.md.
28
+ Changes: typo corrections (data/errata.csv), and only the names the rules
29
+ don't cover are shipped.
package/README.md CHANGED
@@ -1,3 +1,127 @@
1
- # Temporary Holding Version
1
+ # @lunmark/vokativ-next-intl
2
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.
3
+ The Serbian vocative for [next-intl](https://next-intl.dev): mark where a Serbian message
4
+ addresses someone, and the name is put in the vocative.
5
+
6
+ ```jsonc
7
+ // messages/sr.json
8
+ "greeting": "Dobar dan, <voc>{name}</voc>!" // → "Dobar dan, Ivane!"
9
+ // messages/en.json, unchanged
10
+ "greeting": "Good afternoon, {name}!" // → "Good afternoon, Ivan!"
11
+ ```
12
+
13
+ ```tsx
14
+ t("greeting", { name: user.firstName }) // no change at any call site
15
+ ```
16
+
17
+ It works everywhere next-intl formats a message: server and client components, `t()`,
18
+ `t.rich()`, `t.markup()` and `getTranslations()`, with or without precompiled messages.
19
+
20
+ ## Setup
21
+
22
+ ```bash
23
+ pnpm add @lunmark/vokativ-next-intl
24
+ ```
25
+
26
+ In `next.config.ts`, use it in place of next-intl's plugin. next-intl's own setting goes
27
+ second, unchanged:
28
+
29
+ ```ts
30
+ import createVokativNextIntlPlugin from "@lunmark/vokativ-next-intl/plugin"
31
+
32
+ const withNextIntl = createVokativNextIntlPlugin(
33
+ { languages: { auto: true } },
34
+ "./i18n/request.ts"
35
+ )
36
+
37
+ export default withNextIntl(nextConfig)
38
+ ```
39
+
40
+ Already wrapping your config another way? `withVokativ(withNextIntl(nextConfig))` does the
41
+ same after next-intl's plugin:
42
+
43
+ ```ts
44
+ import { withVokativ } from "@lunmark/vokativ-next-intl/plugin"
45
+ ```
46
+
47
+ Then put `<voc>…</voc>` around the name in your Serbian messages. That's all.
48
+
49
+ ## Languages
50
+
51
+ `languages` decides which locales get the vocative, and in which script the name is written.
52
+
53
+ ```ts
54
+ { languages: { auto: true } } // the default
55
+ { languages: { "sr-Latn": "latin", sr: "cyrillic" } } // explicit
56
+ { languages: { auto: true, "sr-ME": false } } // all Serbian but one
57
+ ```
58
+
59
+ - **`auto: true`** covers every Serbian locale (`sr`, `sr-*`). A locale code that names a
60
+ script gets it (`sr-Latn-RS` → Latin, `sr-Cyrl` → Cyrillic); a bare `sr` keeps the name's
61
+ own script.
62
+ - **A locale key** covers that locale and the ones under it (`"sr-Latn"` covers
63
+ `sr-Latn-RS`); the most specific key wins. `"latin"` or `"cyrillic"` writes the name in that
64
+ script (Ivan → Иване on a Cyrillic page), `true` keeps the name's own script, `false` turns
65
+ the vocative off.
66
+ - A locale code that names its own script keeps it when the matching key doesn't name one:
67
+ with `{ sr: "cyrillic" }`, `sr-Latn` is still Latin.
68
+ - Everywhere else, `<voc>` leaves the name as it is, so a tag that slips into another
69
+ language does no harm.
70
+
71
+ A misspelled setting (`"cyrilic"`) stops the build with an error rather than doing nothing.
72
+
73
+ ## Gender
74
+
75
+ If the message also gets a `gender` value (`"m"`/`"male"` or `"f"`/`"female"`), which Serbian
76
+ messages often need anyway for "Dragi/Draga", surnames are declined for men:
77
+
78
+ ```jsonc
79
+ "dear": "{gender, select, f {Draga <voc>{name}</voc>} other {Dragi <voc>{name}</voc>}}"
80
+ // { name: "Marko Janković", gender: "m" } → "Dragi Marko Jankoviću"
81
+ // { name: "Ana Janković", gender: "f" } → "Draga Ana Janković"
82
+ ```
83
+
84
+ Without one, only the first name is declined; a surname is never wrong left as written.
85
+
86
+ ## Tests (Vitest)
87
+
88
+ Vitest doesn't run `next.config`, so add the same swap there, or a message with `<voc>`
89
+ renders as its key:
90
+
91
+ ```ts
92
+ // vitest.config.ts
93
+ import { vokativNextIntl } from "@lunmark/vokativ-next-intl/vitest"
94
+
95
+ export default defineConfig({
96
+ plugins: [vokativNextIntl({ languages: { auto: true } })],
97
+ })
98
+ ```
99
+
100
+ ## How it works
101
+
102
+ Every message next-intl formats goes through one function in use-intl,
103
+ `use-intl/format-message`. The plugin points that import at a small stand-in, the way
104
+ next-intl itself swaps it when it precompiles messages. The stand-in adds a `voc` handler and
105
+ calls the original formatter.
106
+
107
+ - A message without `<voc>` goes straight through: about 20 ns extra.
108
+ - A `voc` you pass yourself always wins.
109
+ - Names longer than 100 characters are left as written.
110
+ - The declension is [`@lunmark/vokativ`](https://www.npmjs.com/package/@lunmark/vokativ),
111
+ about 3 KB gzipped with this package.
112
+
113
+ ## Good to know
114
+
115
+ - **Requirements:** next-intl 4, and Next.js 15.3 or later, with Turbopack or webpack.
116
+ - **Typed messages** generated from your Serbian file make `t.rich()` and `t.markup()` ask for
117
+ a `voc` handler, since the tag is in their type. Plain `t()` doesn't. Most apps type their
118
+ messages from English, where there's no tag.
119
+ - **`t.markup()` doesn't escape values**, with or without `<voc>`; the name comes out exactly
120
+ as `{name}` would. `t()` and `t.rich()` render through React, which escapes.
121
+ - `use-intl/format-message` is a public entry point, but swapping it isn't a documented
122
+ next-intl API. This package pins next-intl 4 and is tested against it.
123
+
124
+ ## License
125
+
126
+ MIT. Name data: "[Vokativi](https://github.com/startitrs/vokativi)" by Startit,
127
+ [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/), modified.
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Stands in for `use-intl/format-message` in the app's bundles (the plugin points the import
3
+ * here) and hands every message to the original formatter, adding the `voc` tag.
4
+ */
5
+ import { type FormatMessage } from "./with-vokativ-tag.js";
6
+ declare const _default: FormatMessage;
7
+ export default _default;
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Stands in for `use-intl/format-message` in the app's bundles (the plugin points the import
3
+ * here) and hands every message to the original formatter, adding the `voc` tag.
4
+ */
5
+ // The formatter next-intl chose (the runtime compiler, or the precompiled-message formatter);
6
+ // the plugin points this specifier at it.
7
+ import formatMessage from "@lunmark/vokativ-next-intl/original-format-message";
8
+ import { DEFAULT_LANGUAGES } from "./languages.js";
9
+ import { withVokativTag } from "./with-vokativ-tag.js";
10
+ export default withVokativTag(formatMessage, languages());
11
+ /** The setting the plugin put in the bundle's env, as JSON. */
12
+ function languages() {
13
+ try {
14
+ // Written out in full so the bundler can replace it.
15
+ const json = process.env._vokativ_languages;
16
+ if (json)
17
+ return JSON.parse(json);
18
+ }
19
+ catch {
20
+ // No `process` in this environment: the default applies.
21
+ }
22
+ return DEFAULT_LANGUAGES;
23
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Which locales get the vocative, and in which script. Shared by the Next.js plugin (which checks
3
+ * the setting) and the runtime (which reads it for every message).
4
+ */
5
+ export type Script = "latin" | "cyrillic";
6
+ /**
7
+ * `auto: true` covers every Serbian locale (sr, sr-*). A locale key covers that locale and the
8
+ * ones under it ("sr-Latn" covers sr-Latn-RS): `"latin"` or `"cyrillic"` writes the name in that
9
+ * script, `true` keeps the name's own script, `false` turns the vocative off.
10
+ */
11
+ export interface Languages {
12
+ auto?: boolean;
13
+ [locale: string]: Script | boolean | undefined;
14
+ }
15
+ /** What a locale gets: the name written in a script, or "keep" for the name's own script. */
16
+ export type Target = Script | "keep";
17
+ export declare const DEFAULT_LANGUAGES: Languages;
18
+ /**
19
+ * The target for a locale, or undefined when the vocative is off for it. The most specific key
20
+ * wins (sr-Latn-RS tries sr-Latn-RS, sr-Latn, sr). A locale that names its own script keeps it
21
+ * when the key that matched doesn't name one: with `{ sr: "cyrillic" }`, sr-Latn is still Latin.
22
+ */
23
+ export declare function resolveLanguage(locale: string, languages: Languages): Target | undefined;
24
+ /** Throws on a setting that would silently do nothing, like a misspelled script. */
25
+ export declare function checkLanguages(languages: Languages): void;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Which locales get the vocative, and in which script. Shared by the Next.js plugin (which checks
3
+ * the setting) and the runtime (which reads it for every message).
4
+ */
5
+ export const DEFAULT_LANGUAGES = { auto: true };
6
+ /**
7
+ * The target for a locale, or undefined when the vocative is off for it. The most specific key
8
+ * wins (sr-Latn-RS tries sr-Latn-RS, sr-Latn, sr). A locale that names its own script keeps it
9
+ * when the key that matched doesn't name one: with `{ sr: "cyrillic" }`, sr-Latn is still Latin.
10
+ */
11
+ export function resolveLanguage(locale, languages) {
12
+ const subtags = locale.split(/[-_]/);
13
+ const ownScript = scriptOf(subtags);
14
+ const settings = new Map(Object.entries(languages)
15
+ .filter(([key]) => key !== "auto")
16
+ .map(([key, setting]) => [normalize(key), setting]));
17
+ for (let length = subtags.length; length > 0; length--) {
18
+ const key = normalize(subtags.slice(0, length).join("-"));
19
+ const setting = settings.get(key);
20
+ if (setting === undefined)
21
+ continue;
22
+ if (setting === false)
23
+ return undefined;
24
+ if (ownScript && !scriptOf(key.split("-")))
25
+ return ownScript;
26
+ return setting === true ? "keep" : setting;
27
+ }
28
+ if (languages.auto && subtags[0]?.toLowerCase() === "sr") {
29
+ return ownScript ?? "keep";
30
+ }
31
+ return undefined;
32
+ }
33
+ /** Throws on a setting that would silently do nothing, like a misspelled script. */
34
+ export function checkLanguages(languages) {
35
+ for (const [key, setting] of Object.entries(languages)) {
36
+ if (key === "auto") {
37
+ if (typeof setting !== "boolean") {
38
+ throw new Error(`vokativ: languages.auto must be true or false.`);
39
+ }
40
+ }
41
+ else if (setting !== true &&
42
+ setting !== false &&
43
+ setting !== "latin" &&
44
+ setting !== "cyrillic") {
45
+ throw new Error(`vokativ: languages[${JSON.stringify(key)}] is ${JSON.stringify(setting)}; use "latin", "cyrillic", true or false.`);
46
+ }
47
+ }
48
+ }
49
+ function scriptOf(subtags) {
50
+ for (const subtag of subtags) {
51
+ const lower = subtag.toLowerCase();
52
+ if (lower === "latn")
53
+ return "latin";
54
+ if (lower === "cyrl")
55
+ return "cyrillic";
56
+ }
57
+ return undefined;
58
+ }
59
+ function normalize(locale) {
60
+ return locale.toLowerCase().replace(/_/g, "-");
61
+ }
@@ -0,0 +1,11 @@
1
+ /** Where the pieces live on disk, for the bundler aliases. Node only (next.config, vitest.config). */
2
+ /** The import every message goes through, and the name the original is reached by. */
3
+ export declare const FORMAT_MESSAGE = "use-intl/format-message";
4
+ export declare const ORIGINAL_FORMAT_MESSAGE = "@lunmark/vokativ-next-intl/original-format-message";
5
+ /** Our stand-in for `use-intl/format-message`: built (.js), or the source in our own tests. */
6
+ export declare function shimPath(): string;
7
+ /**
8
+ * The formatter next-intl uses when it doesn't precompile messages, from the app's own next-intl.
9
+ * Outside a production build, its development build, which has the clearer error messages.
10
+ */
11
+ export declare function defaultFormatMessage(): string;
package/dist/paths.js ADDED
@@ -0,0 +1,41 @@
1
+ /** Where the pieces live on disk, for the bundler aliases. Node only (next.config, vitest.config). */
2
+ import { existsSync } from "node:fs";
3
+ import { createRequire } from "node:module";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ /** The import every message goes through, and the name the original is reached by. */
7
+ export const FORMAT_MESSAGE = "use-intl/format-message";
8
+ export const ORIGINAL_FORMAT_MESSAGE = "@lunmark/vokativ-next-intl/original-format-message";
9
+ /** Our stand-in for `use-intl/format-message`: built (.js), or the source in our own tests. */
10
+ export function shimPath() {
11
+ const js = fileURLToPath(new URL("./format-message.js", import.meta.url));
12
+ return existsSync(js) ? js : js.replace(/\.js$/, ".ts");
13
+ }
14
+ /**
15
+ * The formatter next-intl uses when it doesn't precompile messages, from the app's own next-intl.
16
+ * Outside a production build, its development build, which has the clearer error messages.
17
+ */
18
+ export function defaultFormatMessage() {
19
+ const production = fromNextIntl().resolve(FORMAT_MESSAGE);
20
+ if (process.env.NODE_ENV !== "production") {
21
+ const development = production.replace(`${path.sep}production${path.sep}`, `${path.sep}development${path.sep}`);
22
+ if (existsSync(development))
23
+ return development;
24
+ }
25
+ return production;
26
+ }
27
+ /** Resolves from where the app's next-intl is, which knows its own use-intl. */
28
+ function fromNextIntl() {
29
+ for (const base of [
30
+ path.join(process.cwd(), "package.json"),
31
+ import.meta.url,
32
+ ]) {
33
+ try {
34
+ return createRequire(createRequire(base).resolve("next-intl/plugin"));
35
+ }
36
+ catch {
37
+ // Not resolvable from here; try the next place.
38
+ }
39
+ }
40
+ throw new Error("vokativ: next-intl isn't installed.");
41
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The Next.js side: next-intl's plugin, plus the Serbian vocative. In a Serbian message,
3
+ * `<voc>{name}</voc>` addresses the person by name ("Zdravo, <voc>{name}</voc>!" → "Zdravo,
4
+ * Ivane!"), with every `t()` call left as it is.
5
+ *
6
+ * It works by pointing `use-intl/format-message` (the one function every message is formatted
7
+ * by) at a stand-in that adds the `voc` tag and calls the original, the way next-intl itself
8
+ * swaps that import when it precompiles messages.
9
+ */
10
+ import type { NextConfig } from "next";
11
+ import createNextIntlPlugin from "next-intl/plugin";
12
+ import { type Languages } from "./languages.js";
13
+ export type { Languages, Script } from "./languages.js";
14
+ export interface VokativOptions {
15
+ /** Which locales get the vocative; every Serbian one by default (`{ auto: true }`). */
16
+ languages?: Languages;
17
+ }
18
+ /** next-intl's own plugin setting: the request config path, or its options. */
19
+ type NextIntlPluginConfig = Parameters<typeof createNextIntlPlugin>[0];
20
+ /**
21
+ * Use in place of next-intl's `createNextIntlPlugin`; its setting goes second, unchanged:
22
+ *
23
+ * ```ts
24
+ * const withVokativNextIntl = createVokativNextIntlPlugin(
25
+ * { languages: { auto: true } },
26
+ * "./i18n/request.ts"
27
+ * )
28
+ * export default withVokativNextIntl(nextConfig)
29
+ * ```
30
+ */
31
+ export default function createVokativNextIntlPlugin(options?: VokativOptions, nextIntl?: NextIntlPluginConfig): (config?: NextConfig) => NextConfig;
32
+ /**
33
+ * Adds the vocative to a config next-intl's plugin has already been applied to, for an app that
34
+ * keeps calling `createNextIntlPlugin` itself: `withVokativ(withNextIntl(nextConfig))`.
35
+ */
36
+ export declare function withVokativ(config: NextConfig, { languages }?: VokativOptions): NextConfig;
package/dist/plugin.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The Next.js side: next-intl's plugin, plus the Serbian vocative. In a Serbian message,
3
+ * `<voc>{name}</voc>` addresses the person by name ("Zdravo, <voc>{name}</voc>!" → "Zdravo,
4
+ * Ivane!"), with every `t()` call left as it is.
5
+ *
6
+ * It works by pointing `use-intl/format-message` (the one function every message is formatted
7
+ * by) at a stand-in that adds the `voc` tag and calls the original, the way next-intl itself
8
+ * swaps that import when it precompiles messages.
9
+ */
10
+ import path from "node:path";
11
+ import createNextIntlPlugin from "next-intl/plugin";
12
+ import { checkLanguages, DEFAULT_LANGUAGES, } from "./languages.js";
13
+ import { defaultFormatMessage, FORMAT_MESSAGE, ORIGINAL_FORMAT_MESSAGE, shimPath, } from "./paths.js";
14
+ /**
15
+ * Use in place of next-intl's `createNextIntlPlugin`; its setting goes second, unchanged:
16
+ *
17
+ * ```ts
18
+ * const withVokativNextIntl = createVokativNextIntlPlugin(
19
+ * { languages: { auto: true } },
20
+ * "./i18n/request.ts"
21
+ * )
22
+ * export default withVokativNextIntl(nextConfig)
23
+ * ```
24
+ */
25
+ export default function createVokativNextIntlPlugin(options = {}, nextIntl) {
26
+ const withNextIntl = createNextIntlPlugin(nextIntl);
27
+ return function withVokativNextIntl(config = {}) {
28
+ return withVokativ(withNextIntl(config), options);
29
+ };
30
+ }
31
+ /**
32
+ * Adds the vocative to a config next-intl's plugin has already been applied to, for an app that
33
+ * keeps calling `createNextIntlPlugin` itself: `withVokativ(withNextIntl(nextConfig))`.
34
+ */
35
+ export function withVokativ(config, { languages = DEFAULT_LANGUAGES } = {}) {
36
+ checkLanguages(languages);
37
+ const shim = shimPath();
38
+ // next-intl already points the import at its precompiled-message formatter when asked to.
39
+ const chosen = config.turbopack?.resolveAlias?.[FORMAT_MESSAGE];
40
+ return {
41
+ ...config,
42
+ env: { ...config.env, _vokativ_languages: JSON.stringify(languages) },
43
+ turbopack: {
44
+ ...config.turbopack,
45
+ resolveAlias: {
46
+ ...config.turbopack?.resolveAlias,
47
+ [FORMAT_MESSAGE]: turbopackPath(shim),
48
+ [ORIGINAL_FORMAT_MESSAGE]: typeof chosen === "string"
49
+ ? chosen
50
+ : turbopackPath(defaultFormatMessage()),
51
+ },
52
+ },
53
+ webpack(webpackConfig, context) {
54
+ const result = config.webpack
55
+ ? config.webpack(webpackConfig, context)
56
+ : webpackConfig;
57
+ result.resolve ??= {};
58
+ result.resolve.alias ??= {};
59
+ const alias = result.resolve.alias;
60
+ const original = alias[FORMAT_MESSAGE];
61
+ delete alias[FORMAT_MESSAGE];
62
+ alias[`${FORMAT_MESSAGE}$`] = shim;
63
+ alias[`${ORIGINAL_FORMAT_MESSAGE}$`] =
64
+ typeof original === "string" ? original : defaultFormatMessage();
65
+ return result;
66
+ },
67
+ };
68
+ }
69
+ /** Turbopack takes aliases relative to the project, starting with "./" (as next-intl does). */
70
+ function turbopackPath(file) {
71
+ const relative = path.relative(process.cwd(), file).replace(/\\/g, "/");
72
+ return relative.startsWith(".") ? relative : `./${relative}`;
73
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The same swap for Vitest, which doesn't run next.config: without it, a message with
3
+ * `<voc>{name}</voc>` renders as its key in tests.
4
+ *
5
+ * ```ts
6
+ * // vitest.config.ts
7
+ * import { vokativNextIntl } from "@lunmark/vokativ-next-intl/vitest"
8
+ * export default defineConfig({ plugins: [vokativNextIntl()] })
9
+ * ```
10
+ */
11
+ import { type Languages } from "./languages.js";
12
+ export type { Languages, Script } from "./languages.js";
13
+ /** A Vite/Vitest plugin; typed loosely so this package needn't depend on Vite. */
14
+ export declare function vokativNextIntl({ languages, }?: {
15
+ languages?: Languages;
16
+ }): {
17
+ name: string;
18
+ config(): {
19
+ resolve: {
20
+ alias: ({
21
+ find: RegExp;
22
+ replacement: string;
23
+ } | {
24
+ find: string;
25
+ replacement: string;
26
+ })[];
27
+ };
28
+ test: {
29
+ server: {
30
+ deps: {
31
+ inline: string[];
32
+ };
33
+ };
34
+ };
35
+ };
36
+ };
package/dist/vitest.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The same swap for Vitest, which doesn't run next.config: without it, a message with
3
+ * `<voc>{name}</voc>` renders as its key in tests.
4
+ *
5
+ * ```ts
6
+ * // vitest.config.ts
7
+ * import { vokativNextIntl } from "@lunmark/vokativ-next-intl/vitest"
8
+ * export default defineConfig({ plugins: [vokativNextIntl()] })
9
+ * ```
10
+ */
11
+ import { checkLanguages, DEFAULT_LANGUAGES, } from "./languages.js";
12
+ import { defaultFormatMessage, FORMAT_MESSAGE, ORIGINAL_FORMAT_MESSAGE, shimPath, } from "./paths.js";
13
+ /** A Vite/Vitest plugin; typed loosely so this package needn't depend on Vite. */
14
+ export function vokativNextIntl({ languages = DEFAULT_LANGUAGES, } = {}) {
15
+ checkLanguages(languages);
16
+ const json = JSON.stringify(languages);
17
+ return {
18
+ name: "vokativ-next-intl",
19
+ config() {
20
+ // Test workers inherit it; the stand-in reads it at runtime.
21
+ process.env._vokativ_languages = json;
22
+ return {
23
+ resolve: {
24
+ alias: [
25
+ {
26
+ find: new RegExp(`^${FORMAT_MESSAGE}$`),
27
+ replacement: shimPath(),
28
+ },
29
+ {
30
+ find: ORIGINAL_FORMAT_MESSAGE,
31
+ replacement: defaultFormatMessage(),
32
+ },
33
+ ],
34
+ },
35
+ test: {
36
+ // Run these through Vite, so the alias reaches use-intl's own import.
37
+ server: {
38
+ deps: {
39
+ inline: ["use-intl", "next-intl", "@lunmark/vokativ-next-intl"],
40
+ },
41
+ },
42
+ },
43
+ };
44
+ },
45
+ };
46
+ }
@@ -0,0 +1,17 @@
1
+ import { type Languages } from "./languages.js";
2
+ type Values = Record<string, unknown>;
3
+ /**
4
+ * use-intl's message formatter, `use-intl/format-message`: every `t()`, `t.rich()`, `t.markup()`
5
+ * and `getTranslations()` call ends here, with the message (an ICU string, or a precompiled one)
6
+ * and the locale. Not a documented API, so only what we rely on is typed.
7
+ */
8
+ export type FormatMessage = (key: string, message: unknown, values: Values | undefined, options: {
9
+ locale: string;
10
+ }) => unknown;
11
+ /**
12
+ * Wraps use-intl's formatter so a message can address someone: `<voc>{name}</voc>` declines the
13
+ * name for the locales `languages` turns on, and passes it through unchanged elsewhere. A message
14
+ * without the tag, or a call that brings its own `voc`, goes straight through.
15
+ */
16
+ export declare function withVokativTag<F extends FormatMessage>(formatMessage: F, languages: Languages): F;
17
+ export {};
@@ -0,0 +1,68 @@
1
+ import { vokativ } from "@lunmark/vokativ";
2
+ import { resolveLanguage } from "./languages.js";
3
+ /**
4
+ * Wraps use-intl's formatter so a message can address someone: `<voc>{name}</voc>` declines the
5
+ * name for the locales `languages` turns on, and passes it through unchanged elsewhere. A message
6
+ * without the tag, or a call that brings its own `voc`, goes straight through.
7
+ */
8
+ export function withVokativTag(formatMessage, languages) {
9
+ const targets = new Map();
10
+ /** The locale lookup, once per locale: an app has a handful. */
11
+ function targetFor(locale) {
12
+ // Without a locale (use-intl changed how it calls us), pass names through rather than fail.
13
+ if (typeof locale !== "string")
14
+ return undefined;
15
+ if (!targets.has(locale)) {
16
+ // A bound, in case an app formats with locales it doesn't control.
17
+ if (targets.size >= 100)
18
+ targets.clear();
19
+ targets.set(locale, resolveLanguage(locale, languages));
20
+ }
21
+ return targets.get(locale);
22
+ }
23
+ const wrapped = ((key, message, values, options) => {
24
+ if (!mentionsVoc(message) || (values && Object.hasOwn(values, "voc"))) {
25
+ return formatMessage(key, message, values, options);
26
+ }
27
+ const target = targetFor(options?.locale);
28
+ const voc = (chunks) => decline(chunks, target, values);
29
+ return formatMessage(key, message, { ...values, voc }, options);
30
+ });
31
+ // use-intl reads flags off the formatter (`formatMessage.raw`).
32
+ return Object.assign(wrapped, formatMessage);
33
+ }
34
+ /** The name inside the tag, declined; anything but plain text (a React element) is left alone. */
35
+ function decline(chunks, target, values) {
36
+ if (!chunks.every((chunk) => typeof chunk === "string"))
37
+ return chunks;
38
+ const name = chunks.join("");
39
+ if (target === undefined)
40
+ return name;
41
+ const gender = genderOf(values?.gender);
42
+ return vokativ(name, {
43
+ ...(gender && { gender }),
44
+ ...(target !== "keep" && { script: target }),
45
+ });
46
+ }
47
+ /** The `gender` value a message often already gets, for "Dragi/Draga": decides surnames. */
48
+ function genderOf(value) {
49
+ if (value === "m" || value === "male")
50
+ return "m";
51
+ if (value === "f" || value === "female")
52
+ return "f";
53
+ return undefined;
54
+ }
55
+ const scanned = new WeakMap();
56
+ /** Whether a message uses the tag: as written, or precompiled, where it's `["voc", …]`. */
57
+ function mentionsVoc(message) {
58
+ if (typeof message === "string")
59
+ return message.includes("<voc>");
60
+ if (typeof message !== "object" || message === null)
61
+ return false;
62
+ let found = scanned.get(message);
63
+ if (found === undefined) {
64
+ found = JSON.stringify(message).includes('["voc"');
65
+ scanned.set(message, found);
66
+ }
67
+ return found;
68
+ }
package/package.json CHANGED
@@ -1,6 +1,89 @@
1
1
  {
2
2
  "name": "@lunmark/vokativ-next-intl",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.1",
4
+ "description": "Serbian vocative for next-intl: write <voc>{name}</voc> in a Serbian message and the name is addressed properly.",
5
+ "keywords": [
6
+ "serbian",
7
+ "srpski",
8
+ "vokativ",
9
+ "vocative",
10
+ "next-intl",
11
+ "next.js",
12
+ "i18n"
13
+ ],
14
+ "license": "MIT",
15
+ "author": "Lunmark",
16
+ "homepage": "https://gitlab.com/lunmark/vokativ/-/tree/main/vokativ-next-intl#readme",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://gitlab.com/lunmark/vokativ.git",
20
+ "directory": "vokativ-next-intl"
21
+ },
22
+ "bugs": {
23
+ "url": "https://gitlab.com/lunmark/vokativ/-/issues"
24
+ },
25
+ "type": "module",
26
+ "sideEffects": false,
27
+ "exports": {
28
+ "./plugin": {
29
+ "types": "./dist/plugin.d.ts",
30
+ "default": "./dist/plugin.js"
31
+ },
32
+ "./vitest": {
33
+ "types": "./dist/vitest.d.ts",
34
+ "default": "./dist/vitest.js"
35
+ },
36
+ "./format-message": {
37
+ "types": "./dist/format-message.d.ts",
38
+ "default": "./dist/format-message.js"
39
+ }
40
+ },
41
+ "files": [
42
+ "dist",
43
+ "CHANGELOG.md"
44
+ ],
45
+ "dependencies": {
46
+ "@lunmark/vokativ": "^0.1.0"
47
+ },
48
+ "peerDependencies": {
49
+ "next": "^15.3.0 || ^16.0.0",
50
+ "next-intl": "^4.0.0"
51
+ },
52
+ "devDependencies": {
53
+ "@biomejs/biome": "2.5.14",
54
+ "@types/node": "^24",
55
+ "@types/react": "^19.3.0",
56
+ "icu-minify": "^4.14.9",
57
+ "next": "^16.4.2",
58
+ "next-intl": "^4.14.9",
59
+ "react": "^19.2.0",
60
+ "react-dom": "^19.2.0",
61
+ "typescript": "^6.0.3",
62
+ "use-intl": "^4.14.9",
63
+ "vitest": "^5.0.2"
64
+ },
65
+ "engines": {
66
+ "node": ">=20"
67
+ },
68
+ "devEngines": {
69
+ "packageManager": {
70
+ "name": "pnpm",
71
+ "version": "12.14.0",
72
+ "onFail": "download"
73
+ },
74
+ "runtime": {
75
+ "name": "node",
76
+ "version": ">=24"
77
+ }
78
+ },
79
+ "publishConfig": {
80
+ "access": "public"
81
+ },
82
+ "scripts": {
83
+ "build": "tsc -p tsconfig.build.json && node scripts/fix-declarations.ts",
84
+ "check": "biome check",
85
+ "fix": "biome check --write",
86
+ "typecheck": "tsc --noEmit",
87
+ "test": "vitest run"
88
+ }
6
89
  }