@saykit/format-json 0.0.0 → 0.3.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/README.md +80 -0
- package/dist/formatter.d.mts +26 -0
- package/dist/formatter.mjs +79 -0
- package/package.json +45 -3
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# @saykit/format-json
|
|
2
|
+
|
|
3
|
+
> JSON file formatter for [SayKit](https://saykit.js.org).
|
|
4
|
+
|
|
5
|
+
[](https://codecov.io/gh/k0d13/saykit?flags%5B0%5D=format-json)
|
|
6
|
+
|
|
7
|
+
Writes one JSON catalogue per locale — the de facto format for web i18n
|
|
8
|
+
(react-intl, FormatJS, i18next, …). Reach for this instead of
|
|
9
|
+
[`@saykit/format-po`](https://github.com/k0d13/saykit/tree/main/packages/format-po)
|
|
10
|
+
when you want plain JSON bundles with no Gettext tooling.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
pnpm add -D @saykit/format-json
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```ts title="saykit.config.ts"
|
|
21
|
+
import json from '@saykit/format-json';
|
|
22
|
+
|
|
23
|
+
// inside a bucket:
|
|
24
|
+
formatter: json();
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Each catalogue is a pretty-printed, flat map keyed by the message id (falling
|
|
28
|
+
back to a stable content hash when a message has no id — the same key the
|
|
29
|
+
runtime resolves), with the translation as the value:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"greeting": "Hello"
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
ICU MessageFormat strings are stored verbatim as JSON string values — JSON
|
|
38
|
+
escaping is handled for you.
|
|
39
|
+
|
|
40
|
+
### Dialects
|
|
41
|
+
|
|
42
|
+
The plain layout above is just `{ key: value }`, so it drops comments, context,
|
|
43
|
+
and source references. Pass `dialect` to keep them using a richer — but still
|
|
44
|
+
standard — JSON layout:
|
|
45
|
+
|
|
46
|
+
- `dialect: 'arb'` — [Application Resource Bundle](https://github.com/google/app-resource-bundle)
|
|
47
|
+
(Flutter/Dart `intl`). Strings stay flat; each key's metadata lives in a
|
|
48
|
+
sibling `@key` object.
|
|
49
|
+
- `dialect: 'webextension'` — the Chrome/WebExtension `messages.json` shape,
|
|
50
|
+
where each key maps to a `{ message, description }` object.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
formatter: json({ dialect: 'arb' });
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```json title="ARB"
|
|
57
|
+
{
|
|
58
|
+
"@@locale": "fr",
|
|
59
|
+
"greeting": "Bonjour",
|
|
60
|
+
"@greeting": {
|
|
61
|
+
"description": "A friendly hello",
|
|
62
|
+
"x-saykit-context": "formal",
|
|
63
|
+
"x-saykit-references": ["src/app.ts:10"]
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Both formats carry translator comments in their native `description` field.
|
|
69
|
+
Context and source references have no standard slot, so SayKit round-trips them
|
|
70
|
+
through `x-saykit-context` / `x-saykit-references` extension fields — other
|
|
71
|
+
tooling reads the `description` and safely ignores the rest.
|
|
72
|
+
|
|
73
|
+
> [!NOTE]
|
|
74
|
+
> JSON catalogues are keyed by message id, so give your messages explicit ids to
|
|
75
|
+
> get stable, human-readable keys. Unlike the PO formatter, JSON does not carry
|
|
76
|
+
> source references, comments, or contexts — it is a lean runtime format.
|
|
77
|
+
|
|
78
|
+
## Documentation
|
|
79
|
+
|
|
80
|
+
See [saykit.js.org/core-concepts/formats](https://saykit.js.org/core-concepts/formats).
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Formatter } from "@saykit/config";
|
|
2
|
+
|
|
3
|
+
//#region src/formatter.d.ts
|
|
4
|
+
type Dialect = 'arb' | 'webextension';
|
|
5
|
+
interface FormatterOptions {
|
|
6
|
+
/**
|
|
7
|
+
* The JSON dialect to write. A dialect other than the default plain
|
|
8
|
+
* `{ key: value }` map carries message metadata (comments, context, source
|
|
9
|
+
* references) using a richer — but still standard — layout.
|
|
10
|
+
*
|
|
11
|
+
* - `'arb'` — Application Resource Bundle (Flutter/Dart `intl`). Strings stay
|
|
12
|
+
* flat and each key's metadata lives in a sibling `@key` object.
|
|
13
|
+
* - `'webextension'` — the Chrome/WebExtension `messages.json` shape, where
|
|
14
|
+
* each key maps to a `{ message, description }` object.
|
|
15
|
+
*
|
|
16
|
+
* Both carry translator comments in their native `description` field. Context
|
|
17
|
+
* and source references have no standard slot, so they round-trip through
|
|
18
|
+
* `x-saykit-context` / `x-saykit-references` extension fields that other
|
|
19
|
+
* tooling safely ignores. Omit for a plain flat catalogue, which carries no
|
|
20
|
+
* metadata.
|
|
21
|
+
*/
|
|
22
|
+
dialect?: Dialect;
|
|
23
|
+
}
|
|
24
|
+
declare function createJsonFormatter(options?: FormatterOptions): Formatter;
|
|
25
|
+
//#endregion
|
|
26
|
+
export { type Dialect, type FormatterOptions, createJsonFormatter as default };
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { generateHash } from "@saykit/config/features/messages";
|
|
2
|
+
//#region src/formatter.ts
|
|
3
|
+
/**
|
|
4
|
+
* Derive a catalogue key the same way the runtime and the rest of SayKit do:
|
|
5
|
+
* an explicit id when present, otherwise a stable hash of the message and its
|
|
6
|
+
* context. Keying by the raw message text instead would not match the hashed
|
|
7
|
+
* key the runtime looks up, and would collide for same-text/different-context
|
|
8
|
+
* messages.
|
|
9
|
+
*/
|
|
10
|
+
function messageKey(message) {
|
|
11
|
+
return message.id ?? generateHash(message.message, message.context);
|
|
12
|
+
}
|
|
13
|
+
const CONTEXT_FIELD = "x-saykit-context";
|
|
14
|
+
const REFERENCES_FIELD = "x-saykit-references";
|
|
15
|
+
function isRecord(value) {
|
|
16
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
/** Build the metadata attributes shared by the ARB and WebExtension layouts. */
|
|
19
|
+
function toAttributes(message) {
|
|
20
|
+
const attributes = {};
|
|
21
|
+
if (message.comments.length) attributes.description = message.comments.join("\n");
|
|
22
|
+
if (message.context) attributes[CONTEXT_FIELD] = message.context;
|
|
23
|
+
if (message.references.length) attributes[REFERENCES_FIELD] = message.references;
|
|
24
|
+
return attributes;
|
|
25
|
+
}
|
|
26
|
+
/** Reconstruct a {@link Message} from a key, its value, and optional metadata. */
|
|
27
|
+
function toMessage(key, value, attributes) {
|
|
28
|
+
const description = attributes && typeof attributes.description === "string" ? attributes.description : "";
|
|
29
|
+
const context = attributes && typeof attributes[CONTEXT_FIELD] === "string" ? attributes[CONTEXT_FIELD] : void 0;
|
|
30
|
+
const references = attributes && Array.isArray(attributes[REFERENCES_FIELD]) ? attributes[REFERENCES_FIELD].filter((r) => typeof r === "string") : [];
|
|
31
|
+
return {
|
|
32
|
+
id: key,
|
|
33
|
+
message: value,
|
|
34
|
+
translation: value,
|
|
35
|
+
context,
|
|
36
|
+
comments: description ? description.split("\n") : [],
|
|
37
|
+
references
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function createJsonFormatter(options = {}) {
|
|
41
|
+
return {
|
|
42
|
+
extension: ".json",
|
|
43
|
+
parse(content) {
|
|
44
|
+
const data = JSON.parse(content);
|
|
45
|
+
if (options.dialect === "arb") return Object.entries(data).filter(([key, value]) => !key.startsWith("@") && typeof value === "string").map(([key, value]) => toMessage(key, value, data[`@${key}`]));
|
|
46
|
+
if (options.dialect === "webextension") return Object.entries(data).filter(([, value]) => isRecord(value) && typeof value.message === "string").map(([key, value]) => {
|
|
47
|
+
const entry = value;
|
|
48
|
+
return toMessage(key, entry.message, entry);
|
|
49
|
+
});
|
|
50
|
+
return Object.entries(data).filter(([, value]) => typeof value === "string").map(([key, value]) => toMessage(key, value));
|
|
51
|
+
},
|
|
52
|
+
stringify(messages, { locale }) {
|
|
53
|
+
const sorted = [...messages].sort((a, b) => messageKey(a).localeCompare(messageKey(b)));
|
|
54
|
+
if (options.dialect === "arb") {
|
|
55
|
+
const catalogue = { "@@locale": locale };
|
|
56
|
+
for (const message of sorted) {
|
|
57
|
+
const key = messageKey(message);
|
|
58
|
+
catalogue[key] = message.translation ?? "";
|
|
59
|
+
const attributes = toAttributes(message);
|
|
60
|
+
if (Object.keys(attributes).length) catalogue[`@${key}`] = attributes;
|
|
61
|
+
}
|
|
62
|
+
return `${JSON.stringify(catalogue, null, 2)}\n`;
|
|
63
|
+
}
|
|
64
|
+
if (options.dialect === "webextension") {
|
|
65
|
+
const catalogue = {};
|
|
66
|
+
for (const message of sorted) catalogue[messageKey(message)] = {
|
|
67
|
+
message: message.translation ?? "",
|
|
68
|
+
...toAttributes(message)
|
|
69
|
+
};
|
|
70
|
+
return `${JSON.stringify(catalogue, null, 2)}\n`;
|
|
71
|
+
}
|
|
72
|
+
const catalogue = {};
|
|
73
|
+
for (const message of sorted) catalogue[messageKey(message)] = message.translation ?? "";
|
|
74
|
+
return `${JSON.stringify(catalogue, null, 2)}\n`;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
//#endregion
|
|
79
|
+
export { createJsonFormatter as default };
|
package/package.json
CHANGED
|
@@ -1,4 +1,46 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@saykit/format-json",
|
|
3
|
-
"version": "0.
|
|
1
|
+
{
|
|
2
|
+
"name": "@saykit/format-json",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "JSON file formatter for saykit",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"formatter",
|
|
7
|
+
"i18n",
|
|
8
|
+
"json",
|
|
9
|
+
"saykit"
|
|
10
|
+
],
|
|
11
|
+
"homepage": "https://github.com/k0d13/saykit#readme",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/k0d13/saykit/issues"
|
|
14
|
+
},
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git+https://github.com/k0d13/saykit.git",
|
|
19
|
+
"directory": "packages/format-json"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"!dist/**/*.map"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/formatter.d.mts",
|
|
29
|
+
"default": "./dist/formatter.mjs"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public",
|
|
34
|
+
"provenance": true
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@saykit/config": "^0.3.0"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@saykit/config": "*"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"check": "tsc --noEmit",
|
|
44
|
+
"build": "tsdown"
|
|
45
|
+
}
|
|
4
46
|
}
|