stringist 0.1.0-alpha.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 +137 -0
- package/dist/api.js +44 -0
- package/dist/catalogs.js +115 -0
- package/dist/cli.js +133 -0
- package/dist/generated/client/client.gen.js +216 -0
- package/dist/generated/client/index.js +6 -0
- package/dist/generated/client/types.gen.js +2 -0
- package/dist/generated/client/utils.gen.js +228 -0
- package/dist/generated/client.gen.js +3 -0
- package/dist/generated/core/auth.gen.js +14 -0
- package/dist/generated/core/bodySerializer.gen.js +57 -0
- package/dist/generated/core/params.gen.js +109 -0
- package/dist/generated/core/pathSerializer.gen.js +106 -0
- package/dist/generated/core/queryKeySerializer.gen.js +92 -0
- package/dist/generated/core/serverSentEvents.gen.js +132 -0
- package/dist/generated/core/types.gen.js +2 -0
- package/dist/generated/core/utils.gen.js +87 -0
- package/dist/generated/index.js +2 -0
- package/dist/generated/sdk.gen.js +13 -0
- package/dist/generated/types.gen.js +2 -0
- package/dist/settings.js +55 -0
- package/dist/upload.js +33 -0
- package/package.json +49 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// This file is auto-generated by @hey-api/openapi-ts
|
|
2
|
+
export function createSseClient({ onRequest, onSseError, onSseEvent, responseTransformer, responseValidator, sseDefaultRetryDelay, sseMaxRetryAttempts, sseMaxRetryDelay, sseSleepFn, url, ...options }) {
|
|
3
|
+
let lastEventId;
|
|
4
|
+
const sleep = sseSleepFn ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
5
|
+
const createStream = async function* () {
|
|
6
|
+
let retryDelay = sseDefaultRetryDelay ?? 3000;
|
|
7
|
+
let attempt = 0;
|
|
8
|
+
const signal = options.signal ?? new AbortController().signal;
|
|
9
|
+
while (true) {
|
|
10
|
+
if (signal.aborted)
|
|
11
|
+
break;
|
|
12
|
+
attempt++;
|
|
13
|
+
const headers = options.headers instanceof Headers
|
|
14
|
+
? options.headers
|
|
15
|
+
: new Headers(options.headers);
|
|
16
|
+
if (lastEventId !== undefined) {
|
|
17
|
+
headers.set('Last-Event-ID', lastEventId);
|
|
18
|
+
}
|
|
19
|
+
try {
|
|
20
|
+
const requestInit = {
|
|
21
|
+
redirect: 'follow',
|
|
22
|
+
...options,
|
|
23
|
+
body: options.serializedBody,
|
|
24
|
+
headers,
|
|
25
|
+
signal,
|
|
26
|
+
};
|
|
27
|
+
let request = new Request(url, requestInit);
|
|
28
|
+
if (onRequest) {
|
|
29
|
+
request = await onRequest(url, requestInit);
|
|
30
|
+
}
|
|
31
|
+
// fetch must be assigned here, otherwise it would throw the error:
|
|
32
|
+
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
|
|
33
|
+
const _fetch = options.fetch ?? globalThis.fetch;
|
|
34
|
+
const response = await _fetch(request);
|
|
35
|
+
if (!response.ok)
|
|
36
|
+
throw new Error(`SSE failed: ${response.status} ${response.statusText}`);
|
|
37
|
+
if (!response.body)
|
|
38
|
+
throw new Error('No body in SSE response');
|
|
39
|
+
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
40
|
+
let buffer = '';
|
|
41
|
+
const abortHandler = () => {
|
|
42
|
+
try {
|
|
43
|
+
reader.cancel();
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// noop
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
signal.addEventListener('abort', abortHandler);
|
|
50
|
+
try {
|
|
51
|
+
while (true) {
|
|
52
|
+
const { done, value } = await reader.read();
|
|
53
|
+
if (done)
|
|
54
|
+
break;
|
|
55
|
+
buffer += value;
|
|
56
|
+
buffer = buffer.replace(/\r\n?/g, '\n'); // normalize line endings
|
|
57
|
+
const chunks = buffer.split('\n\n');
|
|
58
|
+
buffer = chunks.pop() ?? '';
|
|
59
|
+
for (const chunk of chunks) {
|
|
60
|
+
const lines = chunk.split('\n');
|
|
61
|
+
const dataLines = [];
|
|
62
|
+
let eventName;
|
|
63
|
+
for (const line of lines) {
|
|
64
|
+
if (line.startsWith('data:')) {
|
|
65
|
+
dataLines.push(line.replace(/^data:\s*/, ''));
|
|
66
|
+
}
|
|
67
|
+
else if (line.startsWith('event:')) {
|
|
68
|
+
eventName = line.replace(/^event:\s*/, '');
|
|
69
|
+
}
|
|
70
|
+
else if (line.startsWith('id:')) {
|
|
71
|
+
lastEventId = line.replace(/^id:\s*/, '');
|
|
72
|
+
}
|
|
73
|
+
else if (line.startsWith('retry:')) {
|
|
74
|
+
const parsed = Number.parseInt(line.replace(/^retry:\s*/, ''), 10);
|
|
75
|
+
if (!Number.isNaN(parsed)) {
|
|
76
|
+
retryDelay = parsed;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
let data;
|
|
81
|
+
let parsedJson = false;
|
|
82
|
+
if (dataLines.length) {
|
|
83
|
+
const rawData = dataLines.join('\n');
|
|
84
|
+
try {
|
|
85
|
+
data = JSON.parse(rawData);
|
|
86
|
+
parsedJson = true;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
data = rawData;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (parsedJson) {
|
|
93
|
+
if (responseValidator) {
|
|
94
|
+
await responseValidator(data);
|
|
95
|
+
}
|
|
96
|
+
if (responseTransformer) {
|
|
97
|
+
data = await responseTransformer(data);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
onSseEvent?.({
|
|
101
|
+
data,
|
|
102
|
+
event: eventName,
|
|
103
|
+
id: lastEventId,
|
|
104
|
+
retry: retryDelay,
|
|
105
|
+
});
|
|
106
|
+
if (dataLines.length) {
|
|
107
|
+
yield data;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
finally {
|
|
113
|
+
signal.removeEventListener('abort', abortHandler);
|
|
114
|
+
reader.releaseLock();
|
|
115
|
+
}
|
|
116
|
+
break; // exit loop on normal completion
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
// connection failed or aborted; retry after delay
|
|
120
|
+
onSseError?.(error);
|
|
121
|
+
if (sseMaxRetryAttempts !== undefined && attempt >= sseMaxRetryAttempts) {
|
|
122
|
+
break; // stop after firing error
|
|
123
|
+
}
|
|
124
|
+
// exponential backoff: double retry each attempt, cap at 30s
|
|
125
|
+
const backoff = Math.min(retryDelay * 2 ** (attempt - 1), sseMaxRetryDelay ?? 30000);
|
|
126
|
+
await sleep(backoff);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const stream = createStream();
|
|
131
|
+
return { stream };
|
|
132
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// This file is auto-generated by @hey-api/openapi-ts
|
|
2
|
+
import { serializeArrayParam, serializeObjectParam, serializePrimitiveParam, } from './pathSerializer.gen.js';
|
|
3
|
+
export const PATH_PARAM_RE = /\{[^{}]+\}/g;
|
|
4
|
+
export const defaultPathSerializer = ({ path, url: _url }) => {
|
|
5
|
+
let url = _url;
|
|
6
|
+
const matches = _url.match(PATH_PARAM_RE);
|
|
7
|
+
if (matches) {
|
|
8
|
+
for (const match of matches) {
|
|
9
|
+
let explode = false;
|
|
10
|
+
let name = match.substring(1, match.length - 1);
|
|
11
|
+
let style = 'simple';
|
|
12
|
+
if (name.endsWith('*')) {
|
|
13
|
+
explode = true;
|
|
14
|
+
name = name.substring(0, name.length - 1);
|
|
15
|
+
}
|
|
16
|
+
if (name.startsWith('.')) {
|
|
17
|
+
name = name.substring(1);
|
|
18
|
+
style = 'label';
|
|
19
|
+
}
|
|
20
|
+
else if (name.startsWith(';')) {
|
|
21
|
+
name = name.substring(1);
|
|
22
|
+
style = 'matrix';
|
|
23
|
+
}
|
|
24
|
+
const value = path[name];
|
|
25
|
+
if (value === undefined || value === null) {
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (Array.isArray(value)) {
|
|
29
|
+
url = url.replace(match, serializeArrayParam({ explode, name, style, value }));
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (typeof value === 'object') {
|
|
33
|
+
url = url.replace(match, serializeObjectParam({
|
|
34
|
+
explode,
|
|
35
|
+
name,
|
|
36
|
+
style,
|
|
37
|
+
value: value,
|
|
38
|
+
valueOnly: true,
|
|
39
|
+
}));
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (style === 'matrix') {
|
|
43
|
+
url = url.replace(match, `;${serializePrimitiveParam({
|
|
44
|
+
name,
|
|
45
|
+
value: value,
|
|
46
|
+
})}`);
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const replaceValue = encodeURIComponent(style === 'label' ? `.${value}` : value);
|
|
50
|
+
url = url.replace(match, replaceValue);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return url;
|
|
54
|
+
};
|
|
55
|
+
export const getUrl = ({ baseUrl, path, query, querySerializer, url: _url, }) => {
|
|
56
|
+
const pathUrl = _url.startsWith('/') ? _url : `/${_url}`;
|
|
57
|
+
let url = (baseUrl ?? '') + pathUrl;
|
|
58
|
+
if (path) {
|
|
59
|
+
url = defaultPathSerializer({ path, url });
|
|
60
|
+
}
|
|
61
|
+
let search = query ? querySerializer(query) : '';
|
|
62
|
+
if (search.startsWith('?')) {
|
|
63
|
+
search = search.substring(1);
|
|
64
|
+
}
|
|
65
|
+
if (search) {
|
|
66
|
+
url += `?${search}`;
|
|
67
|
+
}
|
|
68
|
+
return url;
|
|
69
|
+
};
|
|
70
|
+
export function getValidRequestBody(options) {
|
|
71
|
+
const hasBody = options.body !== undefined;
|
|
72
|
+
const isSerializedBody = hasBody && options.bodySerializer;
|
|
73
|
+
if (isSerializedBody) {
|
|
74
|
+
if ('serializedBody' in options) {
|
|
75
|
+
const hasSerializedBody = options.serializedBody !== undefined && options.serializedBody !== '';
|
|
76
|
+
return hasSerializedBody ? options.serializedBody : null;
|
|
77
|
+
}
|
|
78
|
+
// not all clients implement a serializedBody property (i.e., client-axios)
|
|
79
|
+
return options.body !== '' ? options.body : null;
|
|
80
|
+
}
|
|
81
|
+
// plain/text body
|
|
82
|
+
if (hasBody) {
|
|
83
|
+
return options.body;
|
|
84
|
+
}
|
|
85
|
+
// no body was provided
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// This file is auto-generated by @hey-api/openapi-ts
|
|
2
|
+
import { client } from './client.gen.js';
|
|
3
|
+
/**
|
|
4
|
+
* Import string catalogs as a complete app version
|
|
5
|
+
*/
|
|
6
|
+
export const postV1ProjectsByProjectIdAppsByAppIdImportsXcstrings = (options) => (options.client ?? client).post({
|
|
7
|
+
url: '/v1/projects/{projectId}/apps/{appId}/imports/xcstrings',
|
|
8
|
+
...options,
|
|
9
|
+
headers: {
|
|
10
|
+
'Content-Type': 'application/json',
|
|
11
|
+
...options.headers
|
|
12
|
+
}
|
|
13
|
+
});
|
package/dist/settings.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
export function slug(value, label) {
|
|
4
|
+
if (typeof value !== 'string' || value.length > 80 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)) {
|
|
5
|
+
throw new Error(`${label} must be a lowercase slug (letters, numbers, and single hyphens, at most 80 characters).`);
|
|
6
|
+
}
|
|
7
|
+
return value;
|
|
8
|
+
}
|
|
9
|
+
export function project(value) {
|
|
10
|
+
if (typeof value !== 'string' || value.split('/').length !== 2) {
|
|
11
|
+
throw new Error('Project must have the format org/proj, for example acme/mobile.');
|
|
12
|
+
}
|
|
13
|
+
const [org, name] = value.split('/');
|
|
14
|
+
slug(org, 'Organization');
|
|
15
|
+
slug(name, 'Project');
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
export function sourceFormat(value) {
|
|
19
|
+
if (value !== 'xcstrings')
|
|
20
|
+
throw new Error('sourceFormat must be xcstrings.');
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
export function parseSettings(value) {
|
|
24
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
25
|
+
throw new Error('Settings must be a JSON object.');
|
|
26
|
+
}
|
|
27
|
+
const data = value;
|
|
28
|
+
return { project: project(data.project), app: slug(data.app, 'App'), sourceFormat: sourceFormat(data.sourceFormat) };
|
|
29
|
+
}
|
|
30
|
+
export const settingsPath = (cwd) => join(cwd, '.stringist', 'settings.json');
|
|
31
|
+
export async function readSettings(cwd) {
|
|
32
|
+
const path = settingsPath(cwd);
|
|
33
|
+
try {
|
|
34
|
+
return parseSettings(JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, '')));
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error.code === 'ENOENT') {
|
|
38
|
+
throw new Error(`No ${path}. Run stringist init from this directory first.`);
|
|
39
|
+
}
|
|
40
|
+
throw new Error(`Cannot read ${path}: ${error.message}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
export async function writeSettings(cwd, settings) {
|
|
44
|
+
const path = settingsPath(cwd);
|
|
45
|
+
await mkdir(join(cwd, '.stringist'), { recursive: true });
|
|
46
|
+
try {
|
|
47
|
+
await writeFile(path, `${JSON.stringify(parseSettings(settings), null, 2)}\n`, { flag: 'wx' });
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code === 'EEXIST') {
|
|
51
|
+
throw new Error(`${path} already exists. Edit it to change the destination.`);
|
|
52
|
+
}
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
package/dist/upload.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { collectCatalogs, MAX_BYTES } from './catalogs.js';
|
|
4
|
+
import { readSettings } from './settings.js';
|
|
5
|
+
export async function gitRef(cwd) {
|
|
6
|
+
try {
|
|
7
|
+
const { stdout } = await promisify(execFile)('git', ['rev-parse', '--verify', 'HEAD'], { cwd, timeout: 5_000, maxBuffer: 4096 });
|
|
8
|
+
return stdout.trim() || undefined;
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return undefined;
|
|
12
|
+
} // Git is optional, including repositories without commits.
|
|
13
|
+
}
|
|
14
|
+
export async function upload(cwd, options, api, log) {
|
|
15
|
+
const settings = await readSettings(cwd);
|
|
16
|
+
const files = await collectCatalogs(cwd, options.languages);
|
|
17
|
+
const vcsRef = options.vcsRef ?? await gitRef(cwd);
|
|
18
|
+
const body = {
|
|
19
|
+
files,
|
|
20
|
+
languages: options.languages,
|
|
21
|
+
...(options.version === undefined ? {} : { name: options.version }),
|
|
22
|
+
...(vcsRef === undefined ? {} : { vcsRef }),
|
|
23
|
+
};
|
|
24
|
+
if (Buffer.byteLength(JSON.stringify(body)) > MAX_BYTES)
|
|
25
|
+
throw new Error('Request exceeds the API’s 20 MiB upload limit.');
|
|
26
|
+
log(`Uploading ${files.length} catalog(s) to ${settings.project}/${settings.app} (${options.languages.length ? `translations: ${options.languages.join(', ')}` : 'keys only'}).`);
|
|
27
|
+
const result = await api.importCatalogs(settings, body);
|
|
28
|
+
if (!result?.version || !Number.isSafeInteger(result.version.id))
|
|
29
|
+
throw new Error('Upload returned an invalid result. Check the app’s versions before uploading again.');
|
|
30
|
+
log(`Uploaded version ${result.version.id}${options.version ? ` (${options.version})` : ''}.`);
|
|
31
|
+
const counts = ['added', 'updated', 'removed', 'translations'];
|
|
32
|
+
log(counts.filter((key) => typeof result[key] === 'number').map((key) => `${result[key]} ${key}`).join(', '));
|
|
33
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "stringist",
|
|
3
|
+
"version": "0.1.0-alpha.0",
|
|
4
|
+
"description": "The Stringist CLI for uploading localization catalogs",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"stringist": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=22.0.0"
|
|
15
|
+
},
|
|
16
|
+
"license": "UNLICENSED",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"stringist",
|
|
19
|
+
"localization",
|
|
20
|
+
"i18n",
|
|
21
|
+
"xcstrings",
|
|
22
|
+
"cli"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public",
|
|
26
|
+
"registry": "https://registry.npmjs.org/"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"generate:api": "openapi-ts",
|
|
30
|
+
"update:api": "node scripts/update-openapi.mjs && pnpm run generate:api",
|
|
31
|
+
"prepare:release": "node scripts/prepare-release.mjs",
|
|
32
|
+
"build": "tsc && node scripts/executable.mjs",
|
|
33
|
+
"check": "tsc --noEmit",
|
|
34
|
+
"test": "pnpm run build && node --test test/*.test.mjs",
|
|
35
|
+
"test:package": "pnpm run build && node scripts/test-package.mjs",
|
|
36
|
+
"prepack": "pnpm run build",
|
|
37
|
+
"prepublishOnly": "pnpm run check && pnpm test"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@hey-api/openapi-ts": "0.99.0",
|
|
41
|
+
"@types/node": "22.20.1",
|
|
42
|
+
"typescript": "5.9.3"
|
|
43
|
+
},
|
|
44
|
+
"repository": {
|
|
45
|
+
"type": "git",
|
|
46
|
+
"url": "git+https://github.com/UInt8Co/stringist-sdk.git",
|
|
47
|
+
"directory": "packages/cli"
|
|
48
|
+
}
|
|
49
|
+
}
|