graphile-i18n 1.1.1 → 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/README.md +127 -60
- package/esm/index.d.ts +37 -0
- package/esm/index.js +38 -5
- package/esm/middleware.d.ts +38 -0
- package/esm/middleware.js +43 -50
- package/esm/plugin.d.ts +31 -0
- package/esm/plugin.js +281 -144
- package/esm/preset.d.ts +34 -0
- package/esm/preset.js +37 -0
- package/esm/types.d.ts +46 -0
- package/esm/types.js +4 -0
- package/index.d.ts +37 -5
- package/index.js +42 -11
- package/middleware.d.ts +36 -24
- package/middleware.js +78 -56
- package/package.json +36 -28
- package/plugin.d.ts +30 -9
- package/plugin.js +283 -148
- package/preset.d.ts +34 -0
- package/preset.js +40 -0
- package/types.d.ts +46 -0
- package/types.js +5 -0
- package/env.d.ts +0 -5
- package/env.js +0 -17
- package/esm/env.js +0 -14
package/README.md
CHANGED
|
@@ -8,91 +8,158 @@
|
|
|
8
8
|
<a href="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml">
|
|
9
9
|
<img height="20" src="https://github.com/constructive-io/constructive/actions/workflows/run-tests.yaml/badge.svg" />
|
|
10
10
|
</a>
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
</a>
|
|
14
|
-
<a href="https://www.npmjs.com/package/graphile-i18n">
|
|
15
|
-
<img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=graphile%2Fgraphile-i18n%2Fpackage.json"/>
|
|
16
|
-
</a>
|
|
11
|
+
<a href="https://github.com/constructive-io/constructive/blob/main/LICENSE"><img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/></a>
|
|
12
|
+
<a href="https://www.npmjs.com/package/graphile-i18n"><img height="20" src="https://img.shields.io/github/package-json/v/constructive-io/constructive?filename=graphile%2Fgraphile-i18n%2Fpackage.json"/></a>
|
|
17
13
|
</p>
|
|
18
14
|
|
|
19
|
-
|
|
15
|
+
PostGraphile v5 i18n plugin — language-aware fields sourced from `@i18n` translation tables with Accept-Language negotiation and configurable fallback chains.
|
|
16
|
+
|
|
17
|
+
## Overview
|
|
18
|
+
|
|
19
|
+
`graphile-i18n` auto-discovers tables tagged with the `@i18n` smart comment, finds the companion translation table, and injects a `localeStrings` field on the base type. The field resolves the best-matching translation row based on the GraphQL context's language codes, falling back to the base table's own values when no translation exists.
|
|
20
20
|
|
|
21
|
-
##
|
|
21
|
+
## Usage
|
|
22
22
|
|
|
23
|
-
```
|
|
24
|
-
|
|
23
|
+
```typescript
|
|
24
|
+
import { I18nPreset } from 'graphile-i18n';
|
|
25
|
+
|
|
26
|
+
const preset = {
|
|
27
|
+
extends: [
|
|
28
|
+
I18nPreset(),
|
|
29
|
+
],
|
|
30
|
+
};
|
|
25
31
|
```
|
|
26
32
|
|
|
27
|
-
|
|
33
|
+
### Custom configuration
|
|
34
|
+
|
|
35
|
+
```typescript
|
|
36
|
+
import { I18nPreset } from 'graphile-i18n';
|
|
28
37
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
38
|
+
const preset = {
|
|
39
|
+
extends: [
|
|
40
|
+
I18nPreset({
|
|
41
|
+
defaultLanguages: ['en', 'es'],
|
|
42
|
+
langCodeColumn: 'lang_code',
|
|
43
|
+
allowedTypes: ['text', 'citext'],
|
|
44
|
+
}),
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
```
|
|
33
48
|
|
|
34
|
-
|
|
49
|
+
### Accept-Language middleware
|
|
35
50
|
|
|
36
|
-
|
|
51
|
+
For production use with Express/PostGraphile, add the Accept-Language context builder:
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import { I18nPreset, makeI18nContext } from 'graphile-i18n';
|
|
55
|
+
|
|
56
|
+
const preset = {
|
|
57
|
+
extends: [I18nPreset()],
|
|
58
|
+
grafast: {
|
|
59
|
+
context: makeI18nContext({
|
|
60
|
+
supportedLanguages: ['en', 'es', 'fr'],
|
|
61
|
+
}),
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Database Setup
|
|
67
|
+
|
|
68
|
+
Tag the base table with a `@i18n` smart comment pointing to its translation table:
|
|
37
69
|
|
|
38
70
|
```sql
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
)
|
|
44
|
-
|
|
71
|
+
COMMENT ON TABLE app_public.posts IS E'@i18n posts_translations';
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
The translation table must have:
|
|
75
|
+
- A FK column referencing the base table's PK (convention: `{table}_id`)
|
|
76
|
+
- A `lang_code` text column (configurable)
|
|
77
|
+
- A `UNIQUE(fk_column, lang_code)` constraint
|
|
78
|
+
- One or more `text`/`citext` columns matching translatable base columns
|
|
45
79
|
|
|
46
|
-
|
|
80
|
+
```sql
|
|
81
|
+
CREATE TABLE app_public.posts_translations (
|
|
47
82
|
id serial PRIMARY KEY,
|
|
48
|
-
|
|
49
|
-
lang_code
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
UNIQUE (
|
|
83
|
+
post_id int NOT NULL REFERENCES app_public.posts(id) ON DELETE CASCADE,
|
|
84
|
+
lang_code text NOT NULL,
|
|
85
|
+
title text NOT NULL,
|
|
86
|
+
body text,
|
|
87
|
+
UNIQUE (post_id, lang_code)
|
|
53
88
|
);
|
|
54
89
|
```
|
|
55
90
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
91
|
+
## GraphQL API
|
|
92
|
+
|
|
93
|
+
Once configured, every tagged table gets a `localeStrings` field:
|
|
94
|
+
|
|
95
|
+
```graphql
|
|
96
|
+
query {
|
|
97
|
+
allPosts {
|
|
98
|
+
nodes {
|
|
99
|
+
title # original base value
|
|
100
|
+
localeStrings {
|
|
101
|
+
langCode # matched language code (null if no translation)
|
|
102
|
+
title # translated or base fallback
|
|
103
|
+
body # translated or base fallback
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Language selection
|
|
111
|
+
|
|
112
|
+
Pass `langCodes` in the GraphQL context (automatically handled by `makeI18nContext` or set manually in tests):
|
|
113
|
+
|
|
114
|
+
```graphql
|
|
115
|
+
# With context: { langCodes: ['es'] }
|
|
116
|
+
query {
|
|
117
|
+
postByRowId(rowId: 1) {
|
|
118
|
+
localeStrings {
|
|
119
|
+
langCode # "es"
|
|
120
|
+
title # "Hola Mundo"
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
76
124
|
```
|
|
77
125
|
|
|
78
|
-
|
|
126
|
+
When the requested language has no translation, the plugin falls back through the `langCodes` array. If no match is found, base table values are returned with `langCode: null`.
|
|
79
127
|
|
|
80
|
-
##
|
|
128
|
+
## Features
|
|
81
129
|
|
|
82
|
-
|
|
130
|
+
- **Smart tag discovery**: Auto-detects `@i18n` tagged tables and their translation companions
|
|
131
|
+
- **Grafast-native resolution**: Uses `lambda` + `object` steps for proper v5 execution
|
|
132
|
+
- **Accept-Language negotiation**: Built-in middleware parses headers and injects context
|
|
133
|
+
- **Fallback chain**: Tries each language in order, falls back to base table values
|
|
134
|
+
- **Convention-based FK**: Discovers FK by `{table}_id` convention or type matching
|
|
135
|
+
- **Type-safe**: Full TypeScript types for options, registry, and field mappings
|
|
83
136
|
|
|
84
|
-
|
|
85
|
-
- `langPluginLanguageCodeGqlField` — exposed GraphQL field name, default `langCode`
|
|
86
|
-
- `langPluginAllowedTypes` — allowed base column types for translation, default `['citext', 'text']`
|
|
87
|
-
- `langPluginDefaultLanguages` — fallback language order, default `['en']`
|
|
137
|
+
## Options
|
|
88
138
|
|
|
89
|
-
|
|
139
|
+
| Option | Default | Description |
|
|
140
|
+
|--------|---------|-------------|
|
|
141
|
+
| `langCodeColumn` | `'lang_code'` | Column name on translation table storing the language code |
|
|
142
|
+
| `langCodeGqlField` | `'langCode'` | GraphQL field name for the language code in the locale object |
|
|
143
|
+
| `allowedTypes` | `['text', 'citext']` | PostgreSQL column types eligible for translation |
|
|
144
|
+
| `defaultLanguages` | `['en']` | Fallback languages when no context is provided |
|
|
90
145
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
146
|
+
## Constructive Integration
|
|
147
|
+
|
|
148
|
+
When used with the Constructive framework, the `DataI18n` node type automates translation table creation:
|
|
149
|
+
|
|
150
|
+
```json
|
|
151
|
+
{
|
|
152
|
+
"nodes": [
|
|
153
|
+
{
|
|
154
|
+
"$type": "DataI18n",
|
|
155
|
+
"data": { "fields": ["name", "description"] }
|
|
156
|
+
}
|
|
157
|
+
]
|
|
158
|
+
}
|
|
94
159
|
```
|
|
95
160
|
|
|
161
|
+
The `i18n_module` provides app-level configuration via `app_settings_i18n` with supported languages, default language, and fallback chain — all manageable via GraphQL mutations.
|
|
162
|
+
|
|
96
163
|
---
|
|
97
164
|
|
|
98
165
|
## Education and Tutorials
|
package/esm/index.d.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* graphile-i18n — PostGraphile v5 i18n Plugin
|
|
3
|
+
*
|
|
4
|
+
* Language-aware fields sourced from @i18n translation tables
|
|
5
|
+
* with Accept-Language negotiation and configurable fallback chains.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { I18nPreset } from 'graphile-i18n';
|
|
10
|
+
*
|
|
11
|
+
* const preset = {
|
|
12
|
+
* extends: [
|
|
13
|
+
* I18nPreset(),
|
|
14
|
+
* ],
|
|
15
|
+
* };
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```typescript
|
|
20
|
+
* import { I18nPreset } from 'graphile-i18n';
|
|
21
|
+
*
|
|
22
|
+
* const preset = {
|
|
23
|
+
* extends: [
|
|
24
|
+
* I18nPreset({
|
|
25
|
+
* defaultLanguages: ['en', 'es'],
|
|
26
|
+
* langCodeColumn: 'lang_code',
|
|
27
|
+
* allowedTypes: ['text', 'citext'],
|
|
28
|
+
* }),
|
|
29
|
+
* ],
|
|
30
|
+
* };
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export { createI18nPlugin, I18nPlugin } from './plugin';
|
|
34
|
+
export { I18nPreset } from './preset';
|
|
35
|
+
export { makeI18nContext, additionalGraphQLContextFromRequest } from './middleware';
|
|
36
|
+
export type { I18nPluginOptions, I18nTableInfo, TranslatableField } from './types';
|
|
37
|
+
export type { I18nMiddlewareOptions } from './middleware';
|
package/esm/index.js
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/**
|
|
2
|
+
* graphile-i18n — PostGraphile v5 i18n Plugin
|
|
3
|
+
*
|
|
4
|
+
* Language-aware fields sourced from @i18n translation tables
|
|
5
|
+
* with Accept-Language negotiation and configurable fallback chains.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```typescript
|
|
9
|
+
* import { I18nPreset } from 'graphile-i18n';
|
|
10
|
+
*
|
|
11
|
+
* const preset = {
|
|
12
|
+
* extends: [
|
|
13
|
+
* I18nPreset(),
|
|
14
|
+
* ],
|
|
15
|
+
* };
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* ```typescript
|
|
20
|
+
* import { I18nPreset } from 'graphile-i18n';
|
|
21
|
+
*
|
|
22
|
+
* const preset = {
|
|
23
|
+
* extends: [
|
|
24
|
+
* I18nPreset({
|
|
25
|
+
* defaultLanguages: ['en', 'es'],
|
|
26
|
+
* langCodeColumn: 'lang_code',
|
|
27
|
+
* allowedTypes: ['text', 'citext'],
|
|
28
|
+
* }),
|
|
29
|
+
* ],
|
|
30
|
+
* };
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
// Plugin
|
|
34
|
+
export { createI18nPlugin, I18nPlugin } from './plugin';
|
|
35
|
+
// Preset
|
|
36
|
+
export { I18nPreset } from './preset';
|
|
37
|
+
// Middleware
|
|
38
|
+
export { makeI18nContext, additionalGraphQLContextFromRequest } from './middleware';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Accept-Language middleware for PostGraphile v5
|
|
3
|
+
*
|
|
4
|
+
* Parses the Accept-Language header and injects `langCodes` into the
|
|
5
|
+
* GraphQL context so the i18n plugin can resolve the best translation.
|
|
6
|
+
*/
|
|
7
|
+
export interface I18nMiddlewareOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Supported languages for Accept-Language negotiation.
|
|
10
|
+
* @default ['en']
|
|
11
|
+
*/
|
|
12
|
+
supportedLanguages?: string[];
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* PostGraphile v5 additionalGraphQLContextFromRequest function.
|
|
16
|
+
*
|
|
17
|
+
* Parses Accept-Language and injects `langCodes` into the GraphQL context.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* ```typescript
|
|
21
|
+
* import { makeI18nContext } from 'graphile-i18n';
|
|
22
|
+
*
|
|
23
|
+
* const preset = {
|
|
24
|
+
* grafast: {
|
|
25
|
+
* context: makeI18nContext({ supportedLanguages: ['en', 'es', 'fr'] }),
|
|
26
|
+
* },
|
|
27
|
+
* };
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare function makeI18nContext(options?: I18nMiddlewareOptions): (req: any, _res: any) => Promise<{
|
|
31
|
+
langCodes: string[];
|
|
32
|
+
}>;
|
|
33
|
+
/**
|
|
34
|
+
* Convenience export matching the v4 API signature.
|
|
35
|
+
*/
|
|
36
|
+
export declare const additionalGraphQLContextFromRequest: (req: any, _res: any) => Promise<{
|
|
37
|
+
langCodes: string[];
|
|
38
|
+
}>;
|
package/esm/middleware.js
CHANGED
|
@@ -1,57 +1,50 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const variationTable = escapeIdentifier(variationsTableName);
|
|
14
|
-
const joinKey = escapeIdentifier(key ?? identifier);
|
|
15
|
-
const fields = coalescedFields.join(', ');
|
|
16
|
-
const baseAlias = [schemaName, baseTable].join('.');
|
|
17
|
-
const variationAlias = [schemaName, variationTable].join('.');
|
|
18
|
-
dataLoader = new DataLoader(async (ids) => {
|
|
19
|
-
const { rows } = await pgClient.query(`
|
|
20
|
-
select *
|
|
21
|
-
from unnest($1::${idType}[]) ids(${identifier})
|
|
22
|
-
inner join lateral (
|
|
23
|
-
select b.${identifier}, v.${sqlField} as "${gqlField}", ${fields}
|
|
24
|
-
from ${baseAlias} b
|
|
25
|
-
left join ${variationAlias} v
|
|
26
|
-
on (v.${joinKey} = b.${identifier} and array_position($2, ${sqlField}) is not null)
|
|
27
|
-
where b.${identifier} = ids.${identifier}
|
|
28
|
-
order by array_position($2, ${sqlField}) asc nulls last
|
|
29
|
-
limit 1
|
|
30
|
-
) tmp on (true)
|
|
31
|
-
`, [ids, languageCodes]);
|
|
32
|
-
return ids.map((id) => rows.find((row) => row?.[identifier] === id));
|
|
33
|
-
});
|
|
34
|
-
cache.set(props, dataLoader);
|
|
35
|
-
}
|
|
36
|
-
return dataLoader;
|
|
37
|
-
};
|
|
38
|
-
};
|
|
39
|
-
const getAcceptLanguageHeader = (req) => {
|
|
1
|
+
/**
|
|
2
|
+
* Accept-Language middleware for PostGraphile v5
|
|
3
|
+
*
|
|
4
|
+
* Parses the Accept-Language header and injects `langCodes` into the
|
|
5
|
+
* GraphQL context so the i18n plugin can resolve the best translation.
|
|
6
|
+
*/
|
|
7
|
+
import * as acceptLanguageParser from 'accept-language-parser';
|
|
8
|
+
/**
|
|
9
|
+
* Extract the Accept-Language header from a request object.
|
|
10
|
+
* Supports Express (req.get) and raw Node.js (req.headers).
|
|
11
|
+
*/
|
|
12
|
+
function getAcceptLanguageHeader(req) {
|
|
40
13
|
if (!req)
|
|
41
14
|
return undefined;
|
|
42
15
|
const header = typeof req.get === 'function'
|
|
43
16
|
? req.get('accept-language')
|
|
44
17
|
: req.headers?.['accept-language'];
|
|
45
18
|
return Array.isArray(header) ? header.join(',') : header;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* PostGraphile v5 additionalGraphQLContextFromRequest function.
|
|
22
|
+
*
|
|
23
|
+
* Parses Accept-Language and injects `langCodes` into the GraphQL context.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* import { makeI18nContext } from 'graphile-i18n';
|
|
28
|
+
*
|
|
29
|
+
* const preset = {
|
|
30
|
+
* grafast: {
|
|
31
|
+
* context: makeI18nContext({ supportedLanguages: ['en', 'es', 'fr'] }),
|
|
32
|
+
* },
|
|
33
|
+
* };
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
36
|
+
export function makeI18nContext(options = {}) {
|
|
37
|
+
const { supportedLanguages = ['en'] } = options;
|
|
38
|
+
return async (req, _res) => {
|
|
39
|
+
const acceptLanguage = getAcceptLanguageHeader(req);
|
|
40
|
+
const picked = acceptLanguage
|
|
41
|
+
? acceptLanguageParser.pick(supportedLanguages, acceptLanguage)
|
|
42
|
+
: null;
|
|
43
|
+
const langCodes = picked ? [picked] : [supportedLanguages[0]];
|
|
44
|
+
return { langCodes };
|
|
55
45
|
};
|
|
56
|
-
}
|
|
57
|
-
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Convenience export matching the v4 API signature.
|
|
49
|
+
*/
|
|
50
|
+
export const additionalGraphQLContextFromRequest = makeI18nContext();
|
package/esm/plugin.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostGraphile v5 i18n Plugin
|
|
3
|
+
*
|
|
4
|
+
* Discovers tables tagged with @i18n and adds a `localeStrings` field to the
|
|
5
|
+
* base type. The field resolves the best-matching translation row based on
|
|
6
|
+
* language codes provided in the GraphQL context, falling back to the base
|
|
7
|
+
* table's own values when no translation exists.
|
|
8
|
+
*
|
|
9
|
+
* Smart tag format:
|
|
10
|
+
* COMMENT ON TABLE app_public.posts IS E'@i18n posts_translations';
|
|
11
|
+
*
|
|
12
|
+
* The value of @i18n is the name of the translation table in the same schema.
|
|
13
|
+
* The translation table must have:
|
|
14
|
+
* - A FK column referencing the base table's PK
|
|
15
|
+
* - A lang_code column (configurable)
|
|
16
|
+
* - UNIQUE(fk_column, lang_code)
|
|
17
|
+
* - One or more text/citext columns matching the base table's columns
|
|
18
|
+
*/
|
|
19
|
+
import 'graphile-build';
|
|
20
|
+
import 'graphile-build-pg';
|
|
21
|
+
import type { GraphileConfig } from 'graphile-config';
|
|
22
|
+
import type { I18nPluginOptions } from './types';
|
|
23
|
+
declare global {
|
|
24
|
+
namespace GraphileConfig {
|
|
25
|
+
interface Plugins {
|
|
26
|
+
I18nPlugin: true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export declare function createI18nPlugin(options?: I18nPluginOptions): GraphileConfig.Plugin;
|
|
31
|
+
export declare const I18nPlugin: GraphileConfig.Plugin;
|