graphile-i18n 1.1.0 → 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 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
- <a href="https://github.com/constructive-io/constructive/blob/main/LICENSE">
12
- <img height="20" src="https://img.shields.io/badge/license-MIT-blue.svg"/>
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
- **`graphile-i18n`** is a TypeScript rewrite of the Graphile/PostGraphile i18n plugin. It adds language-aware fields sourced from translation tables declared via smart comments and respects `Accept-Language` with sensible fallbacks.
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
- ## 🚀 Installation
21
+ ## Usage
22
22
 
23
- ```bash
24
- pnpm add graphile-i18n
23
+ ```typescript
24
+ import { I18nPreset } from 'graphile-i18n';
25
+
26
+ const preset = {
27
+ extends: [
28
+ I18nPreset(),
29
+ ],
30
+ };
25
31
  ```
26
32
 
27
- ## Features
33
+ ### Custom configuration
34
+
35
+ ```typescript
36
+ import { I18nPreset } from 'graphile-i18n';
28
37
 
29
- - Smart comments (`@i18n`) to wire translation tables
30
- - `Accept-Language` detection with graceful fallback to base values
31
- - Works with PostGraphile context via `additionalGraphQLContextFromRequest`
32
- - TypeScript-first implementation
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
- ## 📦 Usage
49
+ ### Accept-Language middleware
35
50
 
36
- 1. Add a translation table and tag the base table with `@i18n`:
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
- CREATE TABLE app_public.projects (
40
- id serial PRIMARY KEY,
41
- name citext,
42
- description citext
43
- );
44
- COMMENT ON TABLE app_public.projects IS E'@i18n project_language_variations';
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
- CREATE TABLE app_public.project_language_variations (
80
+ ```sql
81
+ CREATE TABLE app_public.posts_translations (
47
82
  id serial PRIMARY KEY,
48
- project_id int NOT NULL REFERENCES app_public.projects(id),
49
- lang_code citext,
50
- name citext,
51
- description citext,
52
- UNIQUE (project_id, lang_code)
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
- 2. Register the plugin:
57
-
58
- ```ts
59
- import express from 'express';
60
- import { postgraphile } from 'postgraphile';
61
- import {
62
- LangPlugin,
63
- additionalGraphQLContextFromRequest,
64
- } from 'graphile-i18n';
65
-
66
- const app = express();
67
- app.use(
68
- postgraphile(process.env.DATABASE_URL, ['app_public'], {
69
- appendPlugins: [LangPlugin],
70
- graphileBuildOptions: {
71
- langPluginDefaultLanguages: ['en'],
72
- },
73
- additionalGraphQLContextFromRequest,
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
- Requests with `Accept-Language` headers receive the closest translation; fields fall back to the base table values when a translation is missing.
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
- ## 🧰 Configuration Options
128
+ ## Features
81
129
 
82
- All options are provided through `graphileBuildOptions`:
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
- - `langPluginLanguageCodeColumn` — translation table column name, default `lang_code`
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
- ## 🧪 Testing
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
- ```sh
92
- # requires a local Postgres available (defaults to postgres/password@localhost:5432)
93
- pnpm --filter graphile-i18n test
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
- import LangPlugin from './plugin';
2
- export { LangPlugin } from './plugin';
3
- export { additionalGraphQLContextFromRequest, makeLanguageDataLoaderForTable } from './middleware';
4
- export { env } from './env';
5
- export default LangPlugin;
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
- import DataLoader from 'dataloader';
2
- import langParser from 'accept-language-parser';
3
- import env from './env';
4
- const escapeIdentifier = (str) => `"${str.replace(/"/g, '""')}"`;
5
- export const makeLanguageDataLoaderForTable = (_req) => {
6
- const cache = new Map();
7
- return (props, pgClient, languageCodes, identifier, idType, sqlField, gqlField) => {
8
- let dataLoader = cache.get(props);
9
- if (!dataLoader) {
10
- const { table, coalescedFields, variationsTableName, key } = props;
11
- const schemaName = escapeIdentifier(table.namespaceName);
12
- const baseTable = escapeIdentifier(table.name);
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
- export const additionalGraphQLContextFromRequest = async (req, _res) => {
48
- const acceptLanguage = getAcceptLanguageHeader(req);
49
- const language = langParser.pick(env.ACCEPTED_LANGUAGES, acceptLanguage) ??
50
- env.ACCEPTED_LANGUAGES[0];
51
- const langCodes = language ? [language] : env.ACCEPTED_LANGUAGES;
52
- return {
53
- langCodes,
54
- getLanguageDataLoader: makeLanguageDataLoaderForTable(req)
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
- export default additionalGraphQLContextFromRequest;
46
+ }
47
+ /**
48
+ * Convenience export matching the v4 API signature.
49
+ */
50
+ export const additionalGraphQLContextFromRequest = makeI18nContext();
@@ -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;