graphile-i18n 1.1.1 → 1.2.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/README.md CHANGED
@@ -8,90 +8,170 @@
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
+
21
+ ## Usage
22
+
23
+ ```typescript
24
+ import { I18nPreset } from 'graphile-i18n';
20
25
 
21
- ## 🚀 Installation
26
+ const preset = {
27
+ extends: [
28
+ I18nPreset(),
29
+ ],
30
+ };
31
+ ```
32
+
33
+ ### Custom configuration
34
+
35
+ ```typescript
36
+ import { I18nPreset } from 'graphile-i18n';
22
37
 
23
- ```bash
24
- pnpm add graphile-i18n
38
+ const preset = {
39
+ extends: [
40
+ I18nPreset({
41
+ defaultLanguages: ['en', 'es'],
42
+ langCodeColumn: 'lang_code',
43
+ allowedTypes: ['text', 'citext'],
44
+ }),
45
+ ],
46
+ };
25
47
  ```
26
48
 
27
- ## Features
49
+ ### Accept-Language middleware
28
50
 
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
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
+ ```
33
65
 
34
- ## 📦 Usage
66
+ ## Database Setup
35
67
 
36
- 1. Add a translation table and tag the base table with `@i18n`:
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
+ ```
45
73
 
46
- CREATE TABLE app_public.project_language_variations (
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
79
+
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
+ }
76
108
  ```
77
109
 
78
- Requests with `Accept-Language` headers receive the closest translation; fields fall back to the base table values when a translation is missing.
110
+ ### Language selection
79
111
 
80
- ## 🧰 Configuration Options
112
+ Pass `langCodes` in the GraphQL context (automatically handled by `makeI18nContext` or set manually in tests):
81
113
 
82
- All options are provided through `graphileBuildOptions`:
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
+ }
124
+ ```
83
125
 
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']`
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`.
127
+
128
+ ## Features
129
+
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
136
+
137
+ ## Options
138
+
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 |
145
+
146
+ ## Constructive Integration
147
+
148
+ When used with the Constructive framework, the `DataI18n` node type automates translation table creation and optionally composes with `SearchFullText` for multilingual full-text search:
149
+
150
+ ```json
151
+ {
152
+ "nodes": [
153
+ {
154
+ "$type": "DataI18n",
155
+ "data": {
156
+ "fields": ["name", "description"],
157
+ "search": {
158
+ "field_name": "search",
159
+ "source_fields": [
160
+ { "field": "name", "weight": "A" },
161
+ { "field": "description", "weight": "B" }
162
+ ]
163
+ }
164
+ }
165
+ }
166
+ ]
167
+ }
168
+ ```
88
169
 
89
- ## 🧪 Testing
170
+ When `search` is provided, the tsvector is created on the **translations table** with dynamic per-row language stemming — each row is stemmed using its own `lang_code` value (e.g., `'spanish'` → Spanish stemmer, `'french'` → French stemmer). PostgreSQL ships with 30+ built-in text search configurations, so multilingual search works out of the box with no per-language configuration.
90
171
 
91
- ```sh
92
- # requires a local Postgres available (defaults to postgres/password@localhost:5432)
93
- pnpm --filter graphile-i18n test
94
- ```
172
+ The `i18n_module` provides app-level configuration via `app_settings_i18n` with supported languages, default language, and fallback chain — all manageable via GraphQL mutations.
173
+
174
+ For the full i18n guide (blueprint patterns, ORM queries, SQL search examples), see the [constructive-sdk-i18n skill](https://github.com/constructive-io/constructive-skills/tree/main/.agents/skills/constructive-sdk-i18n).
95
175
 
96
176
  ---
97
177
 
@@ -141,6 +221,10 @@ Common issues and solutions for pgpm, PostgreSQL, and testing.
141
221
  * [@pgsql/types](https://www.npmjs.com/package/@pgsql/types): **📝 Type definitions** for PostgreSQL AST nodes in TypeScript.
142
222
  * [@pgsql/utils](https://www.npmjs.com/package/@pgsql/utils): **🛠️ AST utilities** for constructing and transforming PostgreSQL syntax trees.
143
223
 
224
+ ### 📚 Documentation & Skills
225
+
226
+ * [constructive-skills](https://github.com/constructive-io/constructive-skills): **📖 Platform documentation and AI agent skills** — feature catalog, blueprint reference, SDK guides (i18n, billing, limits, events, uploads, security, entities, search, AI), and deployment guides.
227
+
144
228
  ## Credits
145
229
 
146
230
  **🛠 Built by the [Constructive](https://constructive.io) team — creators of modular Postgres tooling for secure, composable backends. If you like our work, contribute on [GitHub](https://github.com/constructive-io).**
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;