opencode-wiki-historian 0.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/LICENSE +21 -0
- package/README.md +381 -0
- package/dist/chronology.d.ts +36 -0
- package/dist/chronology.js +67 -0
- package/dist/config.d.ts +112 -0
- package/dist/config.js +158 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +136 -0
- package/dist/jsonc.d.ts +17 -0
- package/dist/jsonc.js +131 -0
- package/dist/map.d.ts +58 -0
- package/dist/map.js +196 -0
- package/dist/migrate-apply.d.ts +40 -0
- package/dist/migrate-apply.js +144 -0
- package/dist/migrate-score.d.ts +29 -0
- package/dist/migrate-score.js +267 -0
- package/dist/migrate-store.d.ts +52 -0
- package/dist/migrate-store.js +77 -0
- package/dist/migrate.d.ts +65 -0
- package/dist/migrate.js +111 -0
- package/dist/templates/genres.d.ts +65 -0
- package/dist/templates/genres.js +228 -0
- package/dist/templates/skeletons.d.ts +48 -0
- package/dist/templates/skeletons.js +558 -0
- package/dist/tools/create.d.ts +9 -0
- package/dist/tools/create.js +77 -0
- package/dist/tools/local.d.ts +10 -0
- package/dist/tools/local.js +107 -0
- package/dist/tools/mutate.d.ts +11 -0
- package/dist/tools/mutate.js +157 -0
- package/dist/tools/read.d.ts +9 -0
- package/dist/tools/read.js +104 -0
- package/dist/tools/shared.d.ts +52 -0
- package/dist/tools/shared.js +87 -0
- package/dist/tools/write.d.ts +10 -0
- package/dist/tools/write.js +148 -0
- package/dist/tools.d.ts +23 -0
- package/dist/tools.js +43 -0
- package/dist/translate.d.ts +44 -0
- package/dist/translate.js +207 -0
- package/dist/wiki/assets.d.ts +42 -0
- package/dist/wiki/assets.js +91 -0
- package/dist/wiki/client.d.ts +67 -0
- package/dist/wiki/client.js +221 -0
- package/dist/wiki/locale.d.ts +66 -0
- package/dist/wiki/locale.js +154 -0
- package/dist/wiki/pages.d.ts +7 -0
- package/dist/wiki/pages.js +7 -0
- package/dist/wiki/pages.read.d.ts +114 -0
- package/dist/wiki/pages.read.js +114 -0
- package/dist/wiki/pages.write.d.ts +109 -0
- package/dist/wiki/pages.write.js +201 -0
- package/package.json +36 -0
- package/skills/historian/SKILL.md +294 -0
- package/skills/historian/references/adapting-your-own-wiki.md +53 -0
- package/skills/historian/references/genres.md +160 -0
- package/skills/historian/references/rules.md +30 -0
- package/skills/historian/references/style.md +84 -0
- package/skills/historian/references/wikijs-guide.md +87 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wiki.js GraphQL client with three-layer error discrimination.
|
|
3
|
+
*
|
|
4
|
+
* Every failure from `gql` surfaces as one of four named, structured errors —
|
|
5
|
+
* never a raw fetch/parse exception:
|
|
6
|
+
*
|
|
7
|
+
* 1. HTTP status outside 2xx -> HttpError (401/403 -> PermissionError)
|
|
8
|
+
* 2. Body that is not parseable JSON -> HttpError ('non-json response')
|
|
9
|
+
* 3. Top-level GraphQL `errors[]` -> GraphQLError
|
|
10
|
+
* 4. Payload `responseResult.succeeded === false` -> WikiError (permission-ish
|
|
11
|
+
* errorCode/message -> PermissionError, the class the plan's R-b contract
|
|
12
|
+
* refers to). The responseResult lookup is field-name agnostic: wiki.js
|
|
13
|
+
* wraps every pages.* operation's result key, so we inspect the first key
|
|
14
|
+
* of `data` (pitfall #3).
|
|
15
|
+
*
|
|
16
|
+
* The api key is resolved eagerly at createClient time (ConfigError
|
|
17
|
+
* propagates) and retained in a module-private WeakMap — never an enumerable
|
|
18
|
+
* client field, never embedded in any thrown message (bodies are redacted
|
|
19
|
+
* before they become snippets or GraphQL error text).
|
|
20
|
+
*
|
|
21
|
+
* The responseResult lookup is field-name agnostic — the operation name is
|
|
22
|
+
* never hardcoded. Two nesting shapes occur: the live instance nests it as
|
|
23
|
+
* `data.pages.<op>.responseResult` (PageMutation -> PageResponse), and a flat
|
|
24
|
+
* variant sits as `data.<root-field>.responseResult`. Both are covered.
|
|
25
|
+
*/
|
|
26
|
+
import { readWikiApiKey } from '../config.js';
|
|
27
|
+
import { isRecord } from '../jsonc.js';
|
|
28
|
+
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
29
|
+
/** Transport/parse-layer failure: non-2xx status, unparseable or malformed
|
|
30
|
+
* bodies, timeouts (status 0 = no HTTP response was received). */
|
|
31
|
+
export class HttpError extends Error {
|
|
32
|
+
status;
|
|
33
|
+
bodySnippet;
|
|
34
|
+
url;
|
|
35
|
+
constructor(message, status, bodySnippet, url) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'HttpError';
|
|
38
|
+
this.status = status;
|
|
39
|
+
this.bodySnippet = bodySnippet;
|
|
40
|
+
this.url = url;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** GraphQL-layer failure: the server answered 2xx but reported errors[]. */
|
|
44
|
+
export class GraphQLError extends Error {
|
|
45
|
+
rawErrors;
|
|
46
|
+
constructor(message, rawErrors) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = 'GraphQLError';
|
|
49
|
+
this.rawErrors = rawErrors;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Wiki.js payload-layer failure: responseResult.succeeded === false. */
|
|
53
|
+
export class WikiError extends Error {
|
|
54
|
+
errorCode;
|
|
55
|
+
slug;
|
|
56
|
+
constructor(errorCode, slug, message) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.name = 'WikiError';
|
|
59
|
+
this.errorCode = errorCode;
|
|
60
|
+
this.slug = slug;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Permission failures from either layer — HTTP 401/403 or a permission-ish
|
|
64
|
+
* payload errorCode/message. Named per plan R-b (wiki token scope shortage). */
|
|
65
|
+
export class PermissionError extends WikiError {
|
|
66
|
+
constructor(errorCode, slug, message) {
|
|
67
|
+
super(errorCode, slug, message);
|
|
68
|
+
this.name = 'PermissionError';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Api keys live here, keyed by client identity — never on the client object
|
|
72
|
+
* (which would make them enumerable/loggable) and never in messages. */
|
|
73
|
+
const keys = new WeakMap();
|
|
74
|
+
export function createClient(options, deps) {
|
|
75
|
+
const client = {
|
|
76
|
+
baseUrl: options.baseUrl,
|
|
77
|
+
fetchImpl: deps?.fetchImpl ?? fetch,
|
|
78
|
+
timeoutMs: deps?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
79
|
+
};
|
|
80
|
+
keys.set(client, readWikiApiKey(options, deps?.env, deps?.homeDir));
|
|
81
|
+
return client;
|
|
82
|
+
}
|
|
83
|
+
// --- Helpers ----------------------------------------------------------------
|
|
84
|
+
const PERMISSION_PATTERN = /permission|forbidden|denied|unauthorized|not\.authorized|access/i;
|
|
85
|
+
function isAbortError(err) {
|
|
86
|
+
return (err !== null &&
|
|
87
|
+
typeof err === 'object' &&
|
|
88
|
+
err.name === 'AbortError');
|
|
89
|
+
}
|
|
90
|
+
function errorMessageOf(err) {
|
|
91
|
+
return err instanceof Error ? err.message : String(err);
|
|
92
|
+
}
|
|
93
|
+
/** Replaces the secret with a placeholder so body-derived text can never leak
|
|
94
|
+
* it into errors. Applied before any snippet or GraphQL message is built. */
|
|
95
|
+
function redact(text, key) {
|
|
96
|
+
return key === '' || !text.includes(key) ? text : text.split(key).join('<redacted>');
|
|
97
|
+
}
|
|
98
|
+
function snippetOf(text) {
|
|
99
|
+
const MAX = 200;
|
|
100
|
+
return text.length <= MAX ? text : `${text.slice(0, MAX)}…`;
|
|
101
|
+
}
|
|
102
|
+
async function readTextSafely(res) {
|
|
103
|
+
try {
|
|
104
|
+
return await res.text();
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
return '';
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function asString(value) {
|
|
111
|
+
return typeof value === 'string' ? value : '';
|
|
112
|
+
}
|
|
113
|
+
function toRawError(value) {
|
|
114
|
+
if (isRecord(value)) {
|
|
115
|
+
const message = asString(value.message) || 'graphql error';
|
|
116
|
+
const path = Array.isArray(value.path)
|
|
117
|
+
? value.path.filter((p) => typeof p === 'string' || typeof p === 'number')
|
|
118
|
+
: undefined;
|
|
119
|
+
return path === undefined ? { message } : { message, path };
|
|
120
|
+
}
|
|
121
|
+
return { message: JSON.stringify(value) };
|
|
122
|
+
}
|
|
123
|
+
/** wiki.js wraps every pages.* operation result in a `responseResult` object
|
|
124
|
+
* (pitfall #3); the operation name varies, so neither the root field nor the
|
|
125
|
+
* operation field is hardcoded. Two nesting shapes are checked:
|
|
126
|
+
* `data.<root>.<op>.responseResult` (live instance: PageMutation ->
|
|
127
|
+
* PageResponse) and the flat `data.<root>.responseResult` wrapper. Plain
|
|
128
|
+
* query results (e.g. `data.pages.list` arrays) expose no responseResult and
|
|
129
|
+
* pass through untouched. */
|
|
130
|
+
function findResponseResult(data) {
|
|
131
|
+
const firstKey = Object.keys(data)[0];
|
|
132
|
+
if (firstKey === undefined)
|
|
133
|
+
return undefined;
|
|
134
|
+
const value = data[firstKey];
|
|
135
|
+
if (!isRecord(value))
|
|
136
|
+
return undefined;
|
|
137
|
+
// Shape 1: data.<root>.responseResult
|
|
138
|
+
const direct = value.responseResult;
|
|
139
|
+
if (isRecord(direct))
|
|
140
|
+
return direct;
|
|
141
|
+
// Shape 2: data.<root>.<op>.responseResult
|
|
142
|
+
const secondKey = Object.keys(value)[0];
|
|
143
|
+
if (secondKey === undefined)
|
|
144
|
+
return undefined;
|
|
145
|
+
const nested = value[secondKey];
|
|
146
|
+
if (!isRecord(nested))
|
|
147
|
+
return undefined;
|
|
148
|
+
const viaNested = nested.responseResult;
|
|
149
|
+
return isRecord(viaNested) ? viaNested : undefined;
|
|
150
|
+
}
|
|
151
|
+
function isPermissionish(errorCode, message) {
|
|
152
|
+
return PERMISSION_PATTERN.test(errorCode) || PERMISSION_PATTERN.test(message);
|
|
153
|
+
}
|
|
154
|
+
function transportFailure(err, key, url, timeoutMs) {
|
|
155
|
+
if (isAbortError(err)) {
|
|
156
|
+
return new HttpError(`graphql request to ${url} timed out after ${timeoutMs}ms`, 0, '', url);
|
|
157
|
+
}
|
|
158
|
+
return new HttpError(`graphql request to ${url} failed: ${redact(errorMessageOf(err), key)}`, 0, '', url);
|
|
159
|
+
}
|
|
160
|
+
// --- gql --------------------------------------------------------------------
|
|
161
|
+
export async function gql(client, query, vars) {
|
|
162
|
+
const url = `${client.baseUrl}/graphql`;
|
|
163
|
+
const key = keys.get(client);
|
|
164
|
+
if (key === undefined) {
|
|
165
|
+
throw new Error('gql: client is not bound to a wiki api key (create it via createClient)');
|
|
166
|
+
}
|
|
167
|
+
let res;
|
|
168
|
+
try {
|
|
169
|
+
res = await client.fetchImpl(url, {
|
|
170
|
+
method: 'POST',
|
|
171
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
|
|
172
|
+
body: JSON.stringify({ query, variables: vars }),
|
|
173
|
+
signal: AbortSignal.timeout(client.timeoutMs),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
throw transportFailure(err, key, url, client.timeoutMs);
|
|
178
|
+
}
|
|
179
|
+
const rawBody = redact(await readTextSafely(res), key);
|
|
180
|
+
// Layer 1: HTTP status.
|
|
181
|
+
if (res.status < 200 || res.status >= 300) {
|
|
182
|
+
const snippet = snippetOf(rawBody);
|
|
183
|
+
if (res.status === 401 || res.status === 403) {
|
|
184
|
+
throw new PermissionError(`http-${res.status}`, '', `HTTP ${res.status} from ${url}: ${snippet === '' ? 'permission denied' : snippet}`);
|
|
185
|
+
}
|
|
186
|
+
throw new HttpError(`HTTP ${res.status} from ${url}`, res.status, snippet, url);
|
|
187
|
+
}
|
|
188
|
+
// Layer 2: parseable JSON body.
|
|
189
|
+
let parsed;
|
|
190
|
+
try {
|
|
191
|
+
parsed = JSON.parse(rawBody);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
throw new HttpError('non-json response', res.status, snippetOf(rawBody), url);
|
|
195
|
+
}
|
|
196
|
+
if (!isRecord(parsed)) {
|
|
197
|
+
throw new HttpError('response body is not a JSON object', res.status, snippetOf(rawBody), url);
|
|
198
|
+
}
|
|
199
|
+
// Layer 3: top-level GraphQL errors.
|
|
200
|
+
const errors = parsed.errors;
|
|
201
|
+
if (Array.isArray(errors) && errors.length > 0) {
|
|
202
|
+
const rawErrors = errors.map(toRawError);
|
|
203
|
+
throw new GraphQLError(rawErrors.map((e) => e.message).join(' | '), rawErrors);
|
|
204
|
+
}
|
|
205
|
+
// Layer 4: wiki.js payload convention (pitfall #3).
|
|
206
|
+
const data = parsed.data;
|
|
207
|
+
if (!isRecord(data)) {
|
|
208
|
+
throw new HttpError('response missing data field', res.status, snippetOf(rawBody), url);
|
|
209
|
+
}
|
|
210
|
+
const responseResult = findResponseResult(data);
|
|
211
|
+
if (responseResult !== undefined && responseResult.succeeded === false) {
|
|
212
|
+
const errorCode = asString(responseResult.errorCode);
|
|
213
|
+
const slug = asString(responseResult.slug);
|
|
214
|
+
const message = asString(responseResult.message);
|
|
215
|
+
if (isPermissionish(errorCode, message)) {
|
|
216
|
+
throw new PermissionError(errorCode, slug, message || `permission denied (${errorCode})`);
|
|
217
|
+
}
|
|
218
|
+
throw new WikiError(errorCode, slug, message || `operation failed (${errorCode})`);
|
|
219
|
+
}
|
|
220
|
+
return data;
|
|
221
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path validation + locale URL model.
|
|
3
|
+
*
|
|
4
|
+
* This module is the boundary parser for wiki.js URLs: every path segment
|
|
5
|
+
* must be safe for the wiki.js router (no path traversal, no reserved words
|
|
6
|
+
* that collide with wiki.js's own endpoints, no locale-shaped first segments
|
|
7
|
+
* that wiki.js `parsePath` would reinterpret as a namespace prefix — pitfall
|
|
8
|
+
* #9). Errors are thrown as `PathValidationError` with ACTIONABLE messages
|
|
9
|
+
* naming the offending segment + the rule, so callers (pages.ts, tools.ts)
|
|
10
|
+
* surface the diagnostic directly to the user.
|
|
11
|
+
*/
|
|
12
|
+
export declare class PathValidationError extends Error {
|
|
13
|
+
constructor(message: string);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Reject paths that wiki.js would misinterpret or that are unsafe:
|
|
17
|
+
*
|
|
18
|
+
* 1. empty / whitespace-only
|
|
19
|
+
* 2. absolute (leading `/`)
|
|
20
|
+
* 3. contains `..` (path traversal)
|
|
21
|
+
* 4. contains space, backslash, or `//` (wiki.js URL-unsafe)
|
|
22
|
+
* 5. first segment matches the locale shape (pitfall #9)
|
|
23
|
+
* 6. any segment is length 1 (wiki.js rejects single-char path components)
|
|
24
|
+
* 7. any segment contains characters outside `[A-Za-z0-9._-]`
|
|
25
|
+
* 8. any segment is a reserved word (wiki.js endpoint collision)
|
|
26
|
+
*
|
|
27
|
+
* Order matters: cheap substring checks first, then per-segment rules.
|
|
28
|
+
* Every rejection names the offending segment + the rule in the message.
|
|
29
|
+
*/
|
|
30
|
+
export declare function validatePath(p: string): void;
|
|
31
|
+
/**
|
|
32
|
+
* Map a locale input to the instance's active locale set (en | zh).
|
|
33
|
+
*
|
|
34
|
+
* Whitelist (case-insensitive):
|
|
35
|
+
* - 'en', 'en-<region>' -> 'en'
|
|
36
|
+
* - 'zh', 'zh-cn' -> 'zh'
|
|
37
|
+
*
|
|
38
|
+
* Everything else (including 'zh-tw', 'zh-hant', 'de', '') throws
|
|
39
|
+
* PathValidationError naming the input. Silent mapping of unsupported
|
|
40
|
+
* variants (e.g. zh-tw -> zh) would hide config drift, so it is forbidden.
|
|
41
|
+
*/
|
|
42
|
+
export declare function normalizeLocale(l: string): 'en' | 'zh';
|
|
43
|
+
/**
|
|
44
|
+
* Compose `<baseUrl>/<locale>/<path>` with a single slash between each part.
|
|
45
|
+
* Trailing slashes on baseUrl are stripped. Caller must pass a normalized
|
|
46
|
+
* locale (run normalizeLocale first); path is validated via validatePath.
|
|
47
|
+
*/
|
|
48
|
+
export declare function localeUrl(baseUrl: string, locale: 'en' | 'zh', path: string): string;
|
|
49
|
+
/**
|
|
50
|
+
* Return the other locale of the (en, zh) pair. Exhaustive switch — no
|
|
51
|
+
* default leg; the TS compiler enforces completeness at compile time.
|
|
52
|
+
*/
|
|
53
|
+
export declare function twinOf(locale: 'en' | 'zh'): 'en' | 'zh';
|
|
54
|
+
export interface LocalePair {
|
|
55
|
+
readonly path: string;
|
|
56
|
+
readonly locale: 'en' | 'zh';
|
|
57
|
+
readonly url: string;
|
|
58
|
+
readonly twinLocale: 'en' | 'zh';
|
|
59
|
+
readonly twinUrl: string;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Build the (url, twinUrl) pair for a path at the given locale. Used by
|
|
63
|
+
* pages.ts to return both the canonical URL and its twin in every tool
|
|
64
|
+
* result (plan contract: every page op returns both URLs).
|
|
65
|
+
*/
|
|
66
|
+
export declare function assertLocalePair(path: string, locale: 'en' | 'zh', baseUrl: string): LocalePair;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Path validation + locale URL model.
|
|
3
|
+
*
|
|
4
|
+
* This module is the boundary parser for wiki.js URLs: every path segment
|
|
5
|
+
* must be safe for the wiki.js router (no path traversal, no reserved words
|
|
6
|
+
* that collide with wiki.js's own endpoints, no locale-shaped first segments
|
|
7
|
+
* that wiki.js `parsePath` would reinterpret as a namespace prefix — pitfall
|
|
8
|
+
* #9). Errors are thrown as `PathValidationError` with ACTIONABLE messages
|
|
9
|
+
* naming the offending segment + the rule, so callers (pages.ts, tools.ts)
|
|
10
|
+
* surface the diagnostic directly to the user.
|
|
11
|
+
*/
|
|
12
|
+
// --- Error ------------------------------------------------------------------
|
|
13
|
+
export class PathValidationError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = 'PathValidationError';
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// --- Constants --------------------------------------------------------------
|
|
20
|
+
const RESERVED_WORDS = new Set([
|
|
21
|
+
'home',
|
|
22
|
+
'login',
|
|
23
|
+
'register',
|
|
24
|
+
'graphql',
|
|
25
|
+
'healthz',
|
|
26
|
+
'_assets',
|
|
27
|
+
'favicon',
|
|
28
|
+
]);
|
|
29
|
+
// wiki.js parsePath treats ANY 2-letter (or 2-letter + dash + 2-letter) first
|
|
30
|
+
// segment as a locale candidate. Rejecting shape-matches (not a fixed list) is
|
|
31
|
+
// the only way to stop wiki.js from silently reinterpreting `zh/foo` as
|
|
32
|
+
// "locale=zh, path=foo" and creating a duplicate namespace. This applies
|
|
33
|
+
// equally to `xy/foo` — the shape is the danger, not the specific letters.
|
|
34
|
+
const LOCALE_SHAPE = /^[A-Za-z]{2}(-[A-Za-z]{2})?$/;
|
|
35
|
+
const SEGMENT_CHARS = /^[A-Za-z0-9._-]+$/;
|
|
36
|
+
// --- validatePath -----------------------------------------------------------
|
|
37
|
+
/**
|
|
38
|
+
* Reject paths that wiki.js would misinterpret or that are unsafe:
|
|
39
|
+
*
|
|
40
|
+
* 1. empty / whitespace-only
|
|
41
|
+
* 2. absolute (leading `/`)
|
|
42
|
+
* 3. contains `..` (path traversal)
|
|
43
|
+
* 4. contains space, backslash, or `//` (wiki.js URL-unsafe)
|
|
44
|
+
* 5. first segment matches the locale shape (pitfall #9)
|
|
45
|
+
* 6. any segment is length 1 (wiki.js rejects single-char path components)
|
|
46
|
+
* 7. any segment contains characters outside `[A-Za-z0-9._-]`
|
|
47
|
+
* 8. any segment is a reserved word (wiki.js endpoint collision)
|
|
48
|
+
*
|
|
49
|
+
* Order matters: cheap substring checks first, then per-segment rules.
|
|
50
|
+
* Every rejection names the offending segment + the rule in the message.
|
|
51
|
+
*/
|
|
52
|
+
export function validatePath(p) {
|
|
53
|
+
if (p.trim() === '') {
|
|
54
|
+
throw new PathValidationError(`path is empty or whitespace-only (got ${JSON.stringify(p)})`);
|
|
55
|
+
}
|
|
56
|
+
if (p.startsWith('/')) {
|
|
57
|
+
throw new PathValidationError(`path must not be absolute (got leading '/'; drop the leading slash)`);
|
|
58
|
+
}
|
|
59
|
+
if (p.includes('..')) {
|
|
60
|
+
throw new PathValidationError(`path contains '..' (path traversal; remove any '..' segment)`);
|
|
61
|
+
}
|
|
62
|
+
if (p.includes(' ')) {
|
|
63
|
+
throw new PathValidationError(`path contains a space character (spaces are URL-unsafe in wiki.js paths)`);
|
|
64
|
+
}
|
|
65
|
+
if (p.includes('\\')) {
|
|
66
|
+
throw new PathValidationError(`path contains a backslash (use '/' as the segment separator)`);
|
|
67
|
+
}
|
|
68
|
+
if (p.includes('//')) {
|
|
69
|
+
throw new PathValidationError(`path contains '//' (empty segment between two slashes; remove the duplicate separator)`);
|
|
70
|
+
}
|
|
71
|
+
const segments = p.split('/');
|
|
72
|
+
const first = segments[0];
|
|
73
|
+
if (first === undefined) {
|
|
74
|
+
throw new PathValidationError(`path has no segments (got ${JSON.stringify(p)})`);
|
|
75
|
+
}
|
|
76
|
+
if (LOCALE_SHAPE.test(first)) {
|
|
77
|
+
throw new PathValidationError(`first segment '${first}' matches the locale shape (2 letters, optionally +dash+2 letters); ` +
|
|
78
|
+
`wiki.js would reinterpret it as a namespace prefix — use a longer or digit-bearing name`);
|
|
79
|
+
}
|
|
80
|
+
for (const seg of segments) {
|
|
81
|
+
if (seg.length === 1) {
|
|
82
|
+
throw new PathValidationError(`segment '${seg}' has length 1 (wiki.js rejects single-character path segments)`);
|
|
83
|
+
}
|
|
84
|
+
if (!SEGMENT_CHARS.test(seg)) {
|
|
85
|
+
throw new PathValidationError(`segment '${seg}' contains invalid characters (allowed: [A-Za-z0-9._-])`);
|
|
86
|
+
}
|
|
87
|
+
if (RESERVED_WORDS.has(seg.toLowerCase())) {
|
|
88
|
+
throw new PathValidationError(`segment '${seg}' is a reserved wiki.js word (home|login|register|graphql|healthz|_assets|favicon)`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// --- normalizeLocale --------------------------------------------------------
|
|
93
|
+
/**
|
|
94
|
+
* Map a locale input to the instance's active locale set (en | zh).
|
|
95
|
+
*
|
|
96
|
+
* Whitelist (case-insensitive):
|
|
97
|
+
* - 'en', 'en-<region>' -> 'en'
|
|
98
|
+
* - 'zh', 'zh-cn' -> 'zh'
|
|
99
|
+
*
|
|
100
|
+
* Everything else (including 'zh-tw', 'zh-hant', 'de', '') throws
|
|
101
|
+
* PathValidationError naming the input. Silent mapping of unsupported
|
|
102
|
+
* variants (e.g. zh-tw -> zh) would hide config drift, so it is forbidden.
|
|
103
|
+
*/
|
|
104
|
+
export function normalizeLocale(l) {
|
|
105
|
+
const lower = l.toLowerCase();
|
|
106
|
+
if (lower === 'en')
|
|
107
|
+
return 'en';
|
|
108
|
+
if (lower === 'zh')
|
|
109
|
+
return 'zh';
|
|
110
|
+
if (lower === 'zh-cn')
|
|
111
|
+
return 'zh';
|
|
112
|
+
if (lower.startsWith('en-'))
|
|
113
|
+
return 'en';
|
|
114
|
+
throw new PathValidationError(`unsupported locale '${l}' (instance whitelist: en, zh, en-<region>, zh-cn)`);
|
|
115
|
+
}
|
|
116
|
+
// --- localeUrl --------------------------------------------------------------
|
|
117
|
+
/**
|
|
118
|
+
* Compose `<baseUrl>/<locale>/<path>` with a single slash between each part.
|
|
119
|
+
* Trailing slashes on baseUrl are stripped. Caller must pass a normalized
|
|
120
|
+
* locale (run normalizeLocale first); path is validated via validatePath.
|
|
121
|
+
*/
|
|
122
|
+
export function localeUrl(baseUrl, locale, path) {
|
|
123
|
+
validatePath(path);
|
|
124
|
+
const base = baseUrl.replace(/\/+$/, '');
|
|
125
|
+
return `${base}/${locale}/${path}`;
|
|
126
|
+
}
|
|
127
|
+
// --- twinOf ----------------------------------------------------------------
|
|
128
|
+
/**
|
|
129
|
+
* Return the other locale of the (en, zh) pair. Exhaustive switch — no
|
|
130
|
+
* default leg; the TS compiler enforces completeness at compile time.
|
|
131
|
+
*/
|
|
132
|
+
export function twinOf(locale) {
|
|
133
|
+
switch (locale) {
|
|
134
|
+
case 'en':
|
|
135
|
+
return 'zh';
|
|
136
|
+
case 'zh':
|
|
137
|
+
return 'en';
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Build the (url, twinUrl) pair for a path at the given locale. Used by
|
|
142
|
+
* pages.ts to return both the canonical URL and its twin in every tool
|
|
143
|
+
* result (plan contract: every page op returns both URLs).
|
|
144
|
+
*/
|
|
145
|
+
export function assertLocalePair(path, locale, baseUrl) {
|
|
146
|
+
const twin = twinOf(locale);
|
|
147
|
+
return {
|
|
148
|
+
path,
|
|
149
|
+
locale,
|
|
150
|
+
url: localeUrl(baseUrl, locale, path),
|
|
151
|
+
twinLocale: twin,
|
|
152
|
+
twinUrl: localeUrl(baseUrl, twin, path),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page operations facade — re-exports the read and write halves so callers
|
|
3
|
+
* import a single module. The split (pages.read.ts / pages.write.ts) keeps
|
|
4
|
+
* every source file under the 250-LOC ceiling.
|
|
5
|
+
*/
|
|
6
|
+
export * from './pages.read.js';
|
|
7
|
+
export * from './pages.write.js';
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page operations facade — re-exports the read and write halves so callers
|
|
3
|
+
* import a single module. The split (pages.read.ts / pages.write.ts) keeps
|
|
4
|
+
* every source file under the 250-LOC ceiling.
|
|
5
|
+
*/
|
|
6
|
+
export * from './pages.read.js';
|
|
7
|
+
export * from './pages.write.js';
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page READ operations (readPage, searchPages) + the shared page types.
|
|
3
|
+
*
|
|
4
|
+
* Split from pages.ts (write ops live in pages.write.ts) to keep every source
|
|
5
|
+
* file under the 250-LOC ceiling. Field names are LIVE-INTROSPECTED on the
|
|
6
|
+
* running wiki.js instance (anonymous __schema), never guessed:
|
|
7
|
+
*
|
|
8
|
+
* - singleByPath(path, locale) — locale is REQUIRED (pitfall #9)
|
|
9
|
+
* - a missing page answers with a top-level GraphQL error whose message
|
|
10
|
+
* contains 'does not exist' (live-verified) → surfaced as a null read
|
|
11
|
+
* - Page.tag is a nested PageTag object { tag, ... } → mapped to strings
|
|
12
|
+
*/
|
|
13
|
+
import { type GqlClient } from './client.js';
|
|
14
|
+
export type Locale = 'en' | 'zh';
|
|
15
|
+
/** Port for todo 8 (real translation engine); pages.write injects it. */
|
|
16
|
+
export type TranslateFn = (text: string, from: Locale, to: Locale) => Promise<string>;
|
|
17
|
+
export interface PageRecord {
|
|
18
|
+
readonly id: number;
|
|
19
|
+
readonly path: string;
|
|
20
|
+
readonly locale: Locale;
|
|
21
|
+
readonly title: string;
|
|
22
|
+
readonly description: string;
|
|
23
|
+
readonly content: string;
|
|
24
|
+
readonly isPublished: boolean;
|
|
25
|
+
readonly isPrivate: boolean;
|
|
26
|
+
readonly contentType: string;
|
|
27
|
+
readonly tags: readonly string[];
|
|
28
|
+
readonly createdAt: string;
|
|
29
|
+
readonly updatedAt: string;
|
|
30
|
+
}
|
|
31
|
+
export interface SearchOptions {
|
|
32
|
+
readonly path?: string;
|
|
33
|
+
readonly locale?: Locale;
|
|
34
|
+
}
|
|
35
|
+
export interface PageSearchResult {
|
|
36
|
+
readonly id: string;
|
|
37
|
+
readonly title: string;
|
|
38
|
+
readonly description: string;
|
|
39
|
+
readonly path: string;
|
|
40
|
+
readonly locale: string;
|
|
41
|
+
}
|
|
42
|
+
export interface PageSearchResponse {
|
|
43
|
+
readonly results: readonly PageSearchResult[];
|
|
44
|
+
readonly suggestions: readonly string[];
|
|
45
|
+
readonly totalHits: number;
|
|
46
|
+
}
|
|
47
|
+
/** One pages.list row (live-introspected: tags arrive as flat strings, unlike
|
|
48
|
+
* Page's nested PageTag objects; privateNS is null unless the page lives in a
|
|
49
|
+
* private namespace). */
|
|
50
|
+
export interface PageListItem {
|
|
51
|
+
readonly id: number;
|
|
52
|
+
readonly path: string;
|
|
53
|
+
readonly locale: Locale;
|
|
54
|
+
readonly title: string;
|
|
55
|
+
readonly description: string;
|
|
56
|
+
readonly contentType: string;
|
|
57
|
+
readonly isPublished: boolean;
|
|
58
|
+
readonly isPrivate: boolean;
|
|
59
|
+
readonly privateNS: string | null;
|
|
60
|
+
readonly createdAt: string;
|
|
61
|
+
readonly updatedAt: string;
|
|
62
|
+
readonly tags: readonly string[];
|
|
63
|
+
}
|
|
64
|
+
export interface ListPagesOptions {
|
|
65
|
+
readonly locale?: Locale;
|
|
66
|
+
readonly tags?: readonly string[];
|
|
67
|
+
}
|
|
68
|
+
interface RawPageShape {
|
|
69
|
+
readonly id: unknown;
|
|
70
|
+
readonly path: unknown;
|
|
71
|
+
readonly locale: unknown;
|
|
72
|
+
readonly title: unknown;
|
|
73
|
+
readonly description: unknown;
|
|
74
|
+
readonly content: unknown;
|
|
75
|
+
readonly isPublished: unknown;
|
|
76
|
+
readonly isPrivate: unknown;
|
|
77
|
+
readonly contentType: unknown;
|
|
78
|
+
readonly tags: unknown;
|
|
79
|
+
readonly createdAt: unknown;
|
|
80
|
+
readonly updatedAt: unknown;
|
|
81
|
+
}
|
|
82
|
+
export declare function str(v: unknown): string;
|
|
83
|
+
export declare function bool(v: unknown): boolean;
|
|
84
|
+
export declare function num(v: unknown): number;
|
|
85
|
+
/** PageTag { tag, ... } → plain string[] (live read type nests tags). */
|
|
86
|
+
export declare function mapTags(v: unknown): readonly string[];
|
|
87
|
+
export declare function mapPage(raw: RawPageShape): PageRecord;
|
|
88
|
+
interface RawListPageShape {
|
|
89
|
+
readonly id: unknown;
|
|
90
|
+
readonly path: unknown;
|
|
91
|
+
readonly locale: unknown;
|
|
92
|
+
readonly title: unknown;
|
|
93
|
+
readonly description: unknown;
|
|
94
|
+
readonly contentType: unknown;
|
|
95
|
+
readonly isPublished: unknown;
|
|
96
|
+
readonly isPrivate: unknown;
|
|
97
|
+
readonly privateNS: unknown;
|
|
98
|
+
readonly createdAt: unknown;
|
|
99
|
+
readonly updatedAt: unknown;
|
|
100
|
+
readonly tags: unknown;
|
|
101
|
+
}
|
|
102
|
+
export declare function mapListItem(raw: RawListPageShape): PageListItem;
|
|
103
|
+
/** Read a page by (path, locale). A missing page — wiki.js answers with a
|
|
104
|
+
* top-level GraphQL error containing 'does not exist' — is a null read;
|
|
105
|
+
* every other failure rethrows as-is. */
|
|
106
|
+
export declare function readPage(client: GqlClient, path: string, locale: Locale): Promise<PageRecord | null>;
|
|
107
|
+
/** Full unbounded fetch of pages.list, optionally scoped to one locale and/or
|
|
108
|
+
* tags (the list query has NO responseResult — failures surface as top-level
|
|
109
|
+
* errors and are rethrown by the gql layer). */
|
|
110
|
+
export declare function listPages(client: GqlClient, opts?: ListPagesOptions): Promise<readonly PageListItem[]>;
|
|
111
|
+
/** Pass-through of pages.search — the response already carries a per-result
|
|
112
|
+
* locale and the gql layer leaves it untouched. */
|
|
113
|
+
export declare function searchPages(client: GqlClient, query: string, opts?: SearchOptions): Promise<PageSearchResponse>;
|
|
114
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Page READ operations (readPage, searchPages) + the shared page types.
|
|
3
|
+
*
|
|
4
|
+
* Split from pages.ts (write ops live in pages.write.ts) to keep every source
|
|
5
|
+
* file under the 250-LOC ceiling. Field names are LIVE-INTROSPECTED on the
|
|
6
|
+
* running wiki.js instance (anonymous __schema), never guessed:
|
|
7
|
+
*
|
|
8
|
+
* - singleByPath(path, locale) — locale is REQUIRED (pitfall #9)
|
|
9
|
+
* - a missing page answers with a top-level GraphQL error whose message
|
|
10
|
+
* contains 'does not exist' (live-verified) → surfaced as a null read
|
|
11
|
+
* - Page.tag is a nested PageTag object { tag, ... } → mapped to strings
|
|
12
|
+
*/
|
|
13
|
+
import { gql, GraphQLError } from './client.js';
|
|
14
|
+
import { normalizeLocale, validatePath } from './locale.js';
|
|
15
|
+
// --- GraphQL shapes (introspection-verified) --------------------------------
|
|
16
|
+
const READ_FIELDS = 'id path locale title description content isPublished isPrivate contentType tags { tag } createdAt updatedAt';
|
|
17
|
+
const READ_QUERY = `query r($path: String!, $locale: String!) { pages { singleByPath(path: $path, locale: $locale) { ${READ_FIELDS} } } }`;
|
|
18
|
+
const SEARCH_QUERY = `query q($query: String!, $path: String, $locale: String) { pages { search(query: $query, path: $path, locale: $locale) { results { id title description path locale } suggestions totalHits } } }`;
|
|
19
|
+
const LIST_FIELDS = 'id path locale title description contentType isPublished isPrivate privateNS createdAt updatedAt tags';
|
|
20
|
+
const LIST_QUERY = `query l($locale: String, $tags: [String!]) { pages { list(locale: $locale, tags: $tags) { ${LIST_FIELDS} } } }`;
|
|
21
|
+
export function str(v) {
|
|
22
|
+
return typeof v === 'string' ? v : '';
|
|
23
|
+
}
|
|
24
|
+
export function bool(v) {
|
|
25
|
+
return v === true;
|
|
26
|
+
}
|
|
27
|
+
export function num(v) {
|
|
28
|
+
return typeof v === 'number' ? v : Number(str(v));
|
|
29
|
+
}
|
|
30
|
+
/** PageTag { tag, ... } → plain string[] (live read type nests tags). */
|
|
31
|
+
export function mapTags(v) {
|
|
32
|
+
if (!Array.isArray(v))
|
|
33
|
+
return [];
|
|
34
|
+
return v.map((t) => {
|
|
35
|
+
if (t !== null && typeof t === 'object' && 'tag' in t) {
|
|
36
|
+
return str(t.tag);
|
|
37
|
+
}
|
|
38
|
+
return str(t);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export function mapPage(raw) {
|
|
42
|
+
return {
|
|
43
|
+
id: num(raw.id),
|
|
44
|
+
path: str(raw.path),
|
|
45
|
+
locale: normalizeLocale(str(raw.locale)),
|
|
46
|
+
title: str(raw.title),
|
|
47
|
+
description: str(raw.description),
|
|
48
|
+
content: str(raw.content),
|
|
49
|
+
isPublished: bool(raw.isPublished),
|
|
50
|
+
isPrivate: bool(raw.isPrivate),
|
|
51
|
+
contentType: str(raw.contentType),
|
|
52
|
+
tags: mapTags(raw.tags),
|
|
53
|
+
createdAt: str(raw.createdAt),
|
|
54
|
+
updatedAt: str(raw.updatedAt),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function mapListItem(raw) {
|
|
58
|
+
const privateNS = raw.privateNS;
|
|
59
|
+
return {
|
|
60
|
+
id: num(raw.id),
|
|
61
|
+
path: str(raw.path),
|
|
62
|
+
locale: normalizeLocale(str(raw.locale)),
|
|
63
|
+
title: str(raw.title),
|
|
64
|
+
description: str(raw.description),
|
|
65
|
+
contentType: str(raw.contentType),
|
|
66
|
+
isPublished: bool(raw.isPublished),
|
|
67
|
+
isPrivate: bool(raw.isPrivate),
|
|
68
|
+
privateNS: typeof privateNS === 'string' && privateNS !== '' ? privateNS : null,
|
|
69
|
+
createdAt: str(raw.createdAt),
|
|
70
|
+
updatedAt: str(raw.updatedAt),
|
|
71
|
+
tags: mapTags(raw.tags),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
// --- readPage ---------------------------------------------------------------
|
|
75
|
+
/** Read a page by (path, locale). A missing page — wiki.js answers with a
|
|
76
|
+
* top-level GraphQL error containing 'does not exist' — is a null read;
|
|
77
|
+
* every other failure rethrows as-is. */
|
|
78
|
+
export async function readPage(client, path, locale) {
|
|
79
|
+
validatePath(path);
|
|
80
|
+
try {
|
|
81
|
+
const data = await gql(client, READ_QUERY, {
|
|
82
|
+
path,
|
|
83
|
+
locale,
|
|
84
|
+
});
|
|
85
|
+
return data.pages.singleByPath === null ? null : mapPage(data.pages.singleByPath);
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
if (err instanceof GraphQLError && err.message.includes('does not exist'))
|
|
89
|
+
return null;
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// --- listPages --------------------------------------------------------------
|
|
94
|
+
/** Full unbounded fetch of pages.list, optionally scoped to one locale and/or
|
|
95
|
+
* tags (the list query has NO responseResult — failures surface as top-level
|
|
96
|
+
* errors and are rethrown by the gql layer). */
|
|
97
|
+
export async function listPages(client, opts) {
|
|
98
|
+
const data = await gql(client, LIST_QUERY, {
|
|
99
|
+
locale: opts?.locale ?? null,
|
|
100
|
+
tags: opts?.tags !== undefined && opts.tags.length > 0 ? [...opts.tags] : null,
|
|
101
|
+
});
|
|
102
|
+
return data.pages.list.map(mapListItem);
|
|
103
|
+
}
|
|
104
|
+
// --- searchPages ------------------------------------------------------------
|
|
105
|
+
/** Pass-through of pages.search — the response already carries a per-result
|
|
106
|
+
* locale and the gql layer leaves it untouched. */
|
|
107
|
+
export async function searchPages(client, query, opts) {
|
|
108
|
+
const data = await gql(client, SEARCH_QUERY, {
|
|
109
|
+
query,
|
|
110
|
+
path: opts?.path ?? null,
|
|
111
|
+
locale: opts?.locale ?? null,
|
|
112
|
+
});
|
|
113
|
+
return data.pages.search;
|
|
114
|
+
}
|