artifacty 0.15.0 → 0.16.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.ko.md +50 -8
- package/README.md +51 -9
- package/THIRD_PARTY_NOTICES.md +53 -0
- package/docs/README.md +6 -0
- package/docs/ko/README.md +6 -0
- package/docs/ko/language-packs.md +40 -0
- package/docs/ko/smtp.md +27 -0
- package/docs/language-packs.md +38 -0
- package/docs/roadmap-design.md +31 -2
- package/docs/smtp.md +25 -0
- package/package.json +10 -1
- package/scripts/build-ui.mjs +16 -0
- package/src/cli.js +4 -4
- package/src/client/api-docs.js +32 -0
- package/src/client/auto-apply.js +20 -0
- package/src/client/sidebar.js +31 -0
- package/src/client/theme.js +42 -0
- package/src/client/ui-controls.css +2 -0
- package/src/client/viewer.js +17 -6
- package/src/lib/events.js +4 -4
- package/src/lib/i18n.js +497 -11
- package/src/lib/language-settings.js +114 -0
- package/src/lib/openapi.js +93 -74
- package/src/lib/render.js +551 -397
- package/src/lib/smtp-client.js +206 -0
- package/src/lib/smtp-settings.js +109 -0
- package/src/lib/storage.js +71 -17
- package/src/lib/ui-icons.js +32 -0
- package/src/mcp-server.js +3 -3
- package/src/server.js +213 -16
- package/src/styles/ui-controls.scss +63 -0
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { TRANSLATIONS } from "./i18n.js";
|
|
2
|
+
import { ensureStore, openDatabase, transaction, insertAuditRow } from "./storage.js";
|
|
3
|
+
|
|
4
|
+
const BUILTINS = ["en", "ko"];
|
|
5
|
+
// Bound individual uploads and the combined persisted catalog separately.
|
|
6
|
+
const MAX_PACK_BYTES = 512 * 1024;
|
|
7
|
+
const MAX_SETTINGS_BYTES = 4 * 1024 * 1024;
|
|
8
|
+
const invalid = (message) => Object.assign(new Error(message), { code: "INVALID_LANGUAGE_SETTINGS", statusCode: 400 });
|
|
9
|
+
const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value) && [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
|
10
|
+
|
|
11
|
+
function localeCode(value) {
|
|
12
|
+
if (typeof value !== "string" || value.length > 35 || !value) throw invalid("A valid locale of at most 35 characters is required");
|
|
13
|
+
try { return Intl.getCanonicalLocales(value)[0].toLowerCase(); }
|
|
14
|
+
catch { throw invalid("Invalid language locale"); }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function byteLimit(value, maximum) {
|
|
18
|
+
let text;
|
|
19
|
+
try { text = JSON.stringify(value); } catch { throw invalid("Language settings must be JSON data"); }
|
|
20
|
+
if (!text || Buffer.byteLength(text, "utf8") > maximum) throw invalid("Language data exceeds the size limit");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function placeholders(text) {
|
|
24
|
+
return [...new Set([...text.matchAll(/\{([^{}]+)\}/g)].map((match) => match[1]))].sort().join("\0");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function validatePack(pack, { stored = false } = {}) {
|
|
28
|
+
if (!object(pack) || Object.keys(pack).some((key) => !["locale", "nativeName", "messages"].includes(key))) throw invalid("A language pack requires locale, nativeName and messages");
|
|
29
|
+
byteLimit(pack, MAX_PACK_BYTES);
|
|
30
|
+
const locale = localeCode(pack.locale);
|
|
31
|
+
if (BUILTINS.includes(locale)) throw invalid("Built-in languages cannot be replaced");
|
|
32
|
+
if (typeof pack.nativeName !== "string" || !pack.nativeName.trim() || pack.nativeName.length > 80) throw invalid("Language name must contain 1 to 80 characters");
|
|
33
|
+
if (!object(pack.messages)) throw invalid("Language messages must be a JSON object");
|
|
34
|
+
const messages = {};
|
|
35
|
+
for (const [key, value] of Object.entries(pack.messages)) {
|
|
36
|
+
if (typeof value !== "string" || !value.trim() || value.length > 4000) throw invalid(`Translation must contain 1 to 4000 characters: ${key}`);
|
|
37
|
+
// Catalogs can outlive the application dictionary. Ignore obsolete entries
|
|
38
|
+
// on reads, so changed keys/placeholders fall back to the current English.
|
|
39
|
+
// Imports stay strict to catch mistakes in newly submitted packs.
|
|
40
|
+
if (!Object.hasOwn(TRANSLATIONS.en, key)) {
|
|
41
|
+
if (stored) continue;
|
|
42
|
+
throw invalid(`Unknown translation key: ${key}`);
|
|
43
|
+
}
|
|
44
|
+
if (placeholders(value) !== placeholders(TRANSLATIONS.en[key])) {
|
|
45
|
+
if (stored) continue;
|
|
46
|
+
throw invalid(`Translation placeholders must match English: ${key}`);
|
|
47
|
+
}
|
|
48
|
+
messages[key] = value;
|
|
49
|
+
}
|
|
50
|
+
return { locale, nativeName: pack.nativeName.trim(), messages };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function validateSettings(value, options = {}) {
|
|
54
|
+
if (!object(value) || !Array.isArray(value.packs) || value.packs.length > 16) throw invalid("At most 16 custom language packs are allowed");
|
|
55
|
+
const packs = value.packs.map((pack) => validatePack(pack, options));
|
|
56
|
+
const installed = new Set([...BUILTINS, ...packs.map((pack) => pack.locale)]);
|
|
57
|
+
if (installed.size !== BUILTINS.length + packs.length) throw invalid("Duplicate language packs are not allowed");
|
|
58
|
+
if (!Array.isArray(value.enabledLocales) || !value.enabledLocales.length) throw invalid("Enable at least one language");
|
|
59
|
+
const enabledLocales = value.enabledLocales.map(localeCode);
|
|
60
|
+
if (new Set(enabledLocales).size !== enabledLocales.length || enabledLocales.some((locale) => !installed.has(locale))) throw invalid("Enabled languages must be installed and unique");
|
|
61
|
+
const defaultLocale = localeCode(value.defaultLocale);
|
|
62
|
+
if (!enabledLocales.includes(defaultLocale)) throw invalid("The default language must be enabled");
|
|
63
|
+
const settings = { defaultLocale, enabledLocales, packs };
|
|
64
|
+
byteLimit(settings, MAX_SETTINGS_BYTES);
|
|
65
|
+
return settings;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function readSettings(db) {
|
|
69
|
+
const row = db.prepare("SELECT value FROM meta WHERE key = 'language_settings'").get();
|
|
70
|
+
if (!row) {
|
|
71
|
+
const configured = String(process.env.ARTIFACTY_LOCALE || "").toLowerCase();
|
|
72
|
+
return { defaultLocale: BUILTINS.includes(configured) ? configured : "en", enabledLocales: [...BUILTINS], packs: [] };
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
if (Buffer.byteLength(row.value, "utf8") > MAX_SETTINGS_BYTES) throw invalid("Stored language data exceeds the size limit");
|
|
76
|
+
return validateSettings(JSON.parse(row.value), { stored: true });
|
|
77
|
+
}
|
|
78
|
+
catch (cause) { throw Object.assign(new Error("Stored language settings are invalid; repair or restore them before making changes", { cause }), { code: "CORRUPT_LANGUAGE_SETTINGS", statusCode: 500 }); }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function withDatabase(store, action) {
|
|
82
|
+
await ensureStore(store);
|
|
83
|
+
const db = openDatabase(store);
|
|
84
|
+
try { return action(db); } finally { db.close(); }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function getLanguageSettings(store) {
|
|
88
|
+
return withDatabase(store, readSettings);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function save(db, settings, action, audit, metadata) {
|
|
92
|
+
db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('language_settings', ?)").run(JSON.stringify(settings));
|
|
93
|
+
insertAuditRow(db, { action, artifactId: "", audit, metadata });
|
|
94
|
+
return settings;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function saveLanguageSettings(store, input, { audit } = {}) {
|
|
98
|
+
if (!object(input)) throw invalid("Language settings must be an object");
|
|
99
|
+
return withDatabase(store, (db) => transaction(db, () => {
|
|
100
|
+
const current = readSettings(db);
|
|
101
|
+
const next = validateSettings({ defaultLocale: input.defaultLocale, enabledLocales: input.enabledLocales, packs: current.packs });
|
|
102
|
+
return save(db, next, "language-settings-change", audit, { defaultLocale: next.defaultLocale, enabledLocales: next.enabledLocales });
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function importLanguagePack(store, input, { audit } = {}) {
|
|
107
|
+
const pack = validatePack(input);
|
|
108
|
+
return withDatabase(store, (db) => transaction(db, () => {
|
|
109
|
+
const current = readSettings(db);
|
|
110
|
+
const packs = current.packs.filter((entry) => entry.locale !== pack.locale);
|
|
111
|
+
const next = validateSettings({ ...current, packs: [...packs, pack] });
|
|
112
|
+
return save(db, next, "language-pack-import", audit, { locale: pack.locale, messageCount: Object.keys(pack.messages).length });
|
|
113
|
+
}));
|
|
114
|
+
}
|
package/src/lib/openapi.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// server.js for its route literals and cross-checks them against this table
|
|
8
8
|
// so the two cannot silently drift apart.
|
|
9
9
|
import { inlineSchema, schemas } from "./schemas.js";
|
|
10
|
+
import { createI18n, availableLanguages, defaultLocale } from "./i18n.js";
|
|
10
11
|
|
|
11
12
|
const OPENAPI_VERSION = "3.1.0";
|
|
12
13
|
|
|
@@ -104,7 +105,7 @@ export const routeTable = [
|
|
|
104
105
|
path: "/api/artifacts/{id}/visibility",
|
|
105
106
|
auth: "write",
|
|
106
107
|
summary: "Change an artifact's visibility",
|
|
107
|
-
description: "Set visibility to 'team' or 'private'. Requires the artifact's owner or an admin.",
|
|
108
|
+
description: "Set visibility to 'public', 'team', or 'private'. Requires the artifact's owner or an admin. Public permits anonymous reading only in public server mode; private server mode still enforces sharing permissions.",
|
|
108
109
|
parameters: [
|
|
109
110
|
{ name: "id", in: "path", required: true, schema: { type: "string" } }
|
|
110
111
|
],
|
|
@@ -391,6 +392,46 @@ export const routeTable = [
|
|
|
391
392
|
}
|
|
392
393
|
];
|
|
393
394
|
|
|
395
|
+
// Request examples describe accepted inputs, independently of response records.
|
|
396
|
+
const requestExamples = {
|
|
397
|
+
"POST /api/artifacts": { title: "Release notes", content: "# Release notes\nFirst release.", format: "markdown", tags: ["release"], visibility: "private" },
|
|
398
|
+
"POST /api/import": { agent: "generic", title: "Imported notes", content: "# Notes", format: "markdown" },
|
|
399
|
+
"POST /api/artifacts/{id}": { content: "# Release notes\nUpdated release.", format: "markdown", expectedVersion: 1 },
|
|
400
|
+
"POST /api/artifacts/{id}/visibility": { visibility: "private" },
|
|
401
|
+
"POST /api/artifacts/{id}/owner": { ownerUserId: "user-example" },
|
|
402
|
+
"POST /api/artifacts/{id}/relations": { toId: "artifact-related", relation: "references" },
|
|
403
|
+
"POST /api/artifacts/{id}/comments": { body: "Please review this section.", version: 1 },
|
|
404
|
+
"POST /api/artifacts/{id}/review-status": { status: "approved" },
|
|
405
|
+
"POST /api/webhooks": { url: "https://example.com/artifacty-webhook", eventTypes: ["artifact.created"] },
|
|
406
|
+
"POST /api/views": { name: "Release notes", filters: { tag: "release" }, shared: false },
|
|
407
|
+
"PUT /api/admin/retention": { archiveAfterDays: { default: null, byType: {} }, purgeArchivedAfterDays: null, auditRetentionDays: null, eventRetentionRows: null, keepTags: ["keep"] },
|
|
408
|
+
"POST /api/admin/retention/run": { dryRun: true },
|
|
409
|
+
"POST /mcp": { jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
function exampleInputSchema(value) {
|
|
413
|
+
if (value === null) return { type: ["number", "null"] };
|
|
414
|
+
if (Array.isArray(value)) return { type: "array", items: value.length ? exampleInputSchema(value[0]) : {} };
|
|
415
|
+
if (typeof value === "object") return { type: "object", properties: Object.fromEntries(Object.entries(value).map(([key, item]) => [key, exampleInputSchema(item)])) };
|
|
416
|
+
return { type: typeof value === "number" ? "integer" : typeof value };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export function schemaExample(schema, definitions = schemas, name = "", depth = 0) {
|
|
420
|
+
if (!schema || depth > 12) return null;
|
|
421
|
+
if (schema.example !== undefined) return schema.example;
|
|
422
|
+
if (schema.$ref) return schemaExample(definitions[schema.$ref.split("/").pop()], definitions, name, depth + 1);
|
|
423
|
+
if (schema.allOf) return Object.assign({}, ...schema.allOf.map((part) => schemaExample(part, definitions, name, depth + 1)));
|
|
424
|
+
if (schema.enum) return schema.enum[0];
|
|
425
|
+
const type = Array.isArray(schema.type) ? schema.type.find((item) => item !== "null") : schema.type;
|
|
426
|
+
if (type === "object" || schema.properties) return Object.fromEntries((schema.required || []).map((key) => [key, schemaExample(schema.properties?.[key] || {}, definitions, key, depth + 1)]));
|
|
427
|
+
if (type === "array") return [];
|
|
428
|
+
if (type === "boolean") return false;
|
|
429
|
+
if (type === "number" || type === "integer") return schema.minimum ?? (/(version|from|to)/i.test(name) ? 1 : 0);
|
|
430
|
+
if (schema.format === "date-time") return "2026-01-01T00:00:00.000Z";
|
|
431
|
+
if (schema.format === "uri") return "https://example.com/artifact";
|
|
432
|
+
return ({ id: "artifact-example", title: "Release notes", sourceAgent: "generic", content: "# Release notes", body: "Please review this section.", authorLabel: "Example user" })[name] || "example";
|
|
433
|
+
}
|
|
434
|
+
|
|
394
435
|
export function listDocumentedRoutes() {
|
|
395
436
|
return routeTable.map(({ method, path }) => ({ method, path }));
|
|
396
437
|
}
|
|
@@ -426,13 +467,17 @@ export function buildOpenApiDocument({ baseUrl = "http://127.0.0.1:8787" } = {})
|
|
|
426
467
|
description: "Personal or global API token as an `x-artifacty-token` header. A `token` query parameter is also accepted for browser links."
|
|
427
468
|
}
|
|
428
469
|
},
|
|
429
|
-
schemas
|
|
470
|
+
schemas: { ...schemas, ArtifactRecord: { allOf: [{ $ref: "#/components/schemas/ArtifactSummary" }, { type: "object", properties: { versions: { type: "array", items: { $ref: "#/components/schemas/ArtifactVersion" } } }, required: ["versions"] }] } }
|
|
430
471
|
},
|
|
431
472
|
paths
|
|
432
473
|
};
|
|
433
474
|
}
|
|
434
475
|
|
|
435
476
|
function buildOperation(route) {
|
|
477
|
+
route = { ...route };
|
|
478
|
+
if (route.method !== "GET" && route.responseSchema === "ArtifactWithContent") route.responseSchema = "ArtifactRecord";
|
|
479
|
+
if (route.method === "DELETE" && route.path.includes("/relations/")) { route.responseStatus = 200; route.responseSchema = "RelationEntry"; }
|
|
480
|
+
if (route.path.startsWith("/api/webhooks")) route.auth = "admin";
|
|
436
481
|
const responses = {};
|
|
437
482
|
const successStatus = String(route.responseStatus || 200);
|
|
438
483
|
responses[successStatus] = {
|
|
@@ -478,11 +523,14 @@ function buildOperation(route) {
|
|
|
478
523
|
}
|
|
479
524
|
};
|
|
480
525
|
}
|
|
526
|
+
const requestExample = requestExamples[`${route.method} ${route.path}`];
|
|
527
|
+
if (requestExample) operation.requestBody = { required: true, content: { "application/json": { schema: route.requestSchema === "RetentionPolicy" ? { $ref: "#/components/schemas/RetentionPolicy" } : { ...exampleInputSchema(requestExample), description: "Properties used in this request example. This is not an exhaustive list of accepted optional fields." }, example: requestExample } } };
|
|
481
528
|
if (route.auth === "admin") {
|
|
482
529
|
operation.responses = { ...responses, 403: errorResponse("Admin privileges required.") };
|
|
483
530
|
} else {
|
|
484
531
|
operation.responses = responses;
|
|
485
532
|
}
|
|
533
|
+
operation["x-artifacty-access"] = route.auth || "read";
|
|
486
534
|
return operation;
|
|
487
535
|
}
|
|
488
536
|
|
|
@@ -507,6 +555,9 @@ function operationId(route) {
|
|
|
507
555
|
}
|
|
508
556
|
|
|
509
557
|
function tagFor(path) {
|
|
558
|
+
if (path.startsWith("/api/webhooks")) return "Webhooks";
|
|
559
|
+
if (path.startsWith("/api/views")) return "Views";
|
|
560
|
+
if (path.startsWith("/api/events")) return "Events";
|
|
510
561
|
if (path.startsWith("/api/admin")) {
|
|
511
562
|
return "Admin";
|
|
512
563
|
}
|
|
@@ -525,81 +576,49 @@ function tagFor(path) {
|
|
|
525
576
|
return "Artifacts";
|
|
526
577
|
}
|
|
527
578
|
|
|
528
|
-
export function
|
|
529
|
-
const
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
.join("\n");
|
|
539
|
-
|
|
540
|
-
const schemaSections = Object.entries(document.components.schemas)
|
|
541
|
-
.map(([name, schema]) => renderSchemaSection(name, schema))
|
|
542
|
-
.join("\n");
|
|
543
|
-
|
|
544
|
-
return `<!doctype html>
|
|
545
|
-
<html lang="en">
|
|
546
|
-
<head>
|
|
547
|
-
<meta charset="utf-8">
|
|
548
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
549
|
-
<title>Artifacty API reference</title>
|
|
550
|
-
<style>
|
|
551
|
-
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; margin: 0; padding: 2rem; max-width: 960px; color: #1a1a1a; background: #fff; }
|
|
552
|
-
h1 { margin-bottom: 0.25rem; }
|
|
553
|
-
.lede { color: #555; margin-top: 0; }
|
|
554
|
-
.route { border: 1px solid #ddd; border-radius: 8px; padding: 1rem 1.25rem; margin: 1rem 0; }
|
|
555
|
-
.route h3 { margin: 0 0 0.25rem; font-family: ui-monospace, monospace; }
|
|
556
|
-
.method { display: inline-block; padding: 0.1rem 0.5rem; border-radius: 4px; font-size: 0.8rem; font-weight: bold; color: #fff; margin-right: 0.5rem; }
|
|
557
|
-
.method-get { background: #2563eb; }
|
|
558
|
-
.method-post { background: #16a34a; }
|
|
559
|
-
.method-delete { background: #dc2626; }
|
|
560
|
-
.params { width: 100%; border-collapse: collapse; margin-top: 0.5rem; font-size: 0.9rem; }
|
|
561
|
-
.params th, .params td { text-align: left; border-bottom: 1px solid #eee; padding: 0.25rem 0.5rem; }
|
|
562
|
-
pre { background: #f6f6f6; padding: 0.75rem; border-radius: 6px; overflow-x: auto; font-size: 0.85rem; }
|
|
563
|
-
nav a { margin-right: 0.75rem; }
|
|
564
|
-
section.schema { margin: 0.75rem 0; }
|
|
565
|
-
</style>
|
|
566
|
-
</head>
|
|
567
|
-
<body>
|
|
568
|
-
<h1>Artifacty API reference</h1>
|
|
569
|
-
<p class="lede">${escapeHtml(document.info.description)} Machine-readable spec: <a href="/openapi.json">/openapi.json</a>.</p>
|
|
570
|
-
<nav><a href="#routes">Routes</a><a href="#schemas">Schemas</a></nav>
|
|
571
|
-
<h2 id="routes">Routes</h2>
|
|
572
|
-
${routeSections}
|
|
573
|
-
<h2 id="schemas">Schemas</h2>
|
|
574
|
-
${schemaSections}
|
|
575
|
-
</body>
|
|
576
|
-
</html>
|
|
577
|
-
`;
|
|
579
|
+
export function buildCurlExample({ baseUrl, apiPath, method, operation }) {
|
|
580
|
+
const quote = (value) => "'" + String(value).replaceAll("'", "'\\''") + "'";
|
|
581
|
+
let url = (baseUrl || "http://127.0.0.1:8787").replace(/\/$/, "") + apiPath.replace(/\{([^}]+)\}/g, (_, name) => encodeURIComponent(name === "id" ? "artifact-example" : `${name}-example`));
|
|
582
|
+
const query = (operation.parameters || []).filter((parameter) => parameter.in === "query" && parameter.required).map((parameter) => `${encodeURIComponent(parameter.name)}=${encodeURIComponent(schemaExample(parameter.schema))}`);
|
|
583
|
+
if (query.length) url += "?" + query.join("&");
|
|
584
|
+
const lines = [`curl --request ${method.toUpperCase()} ${quote(url)}`, ` --header 'Authorization: Bearer YOUR_API_TOKEN'`];
|
|
585
|
+
const example = operation.requestBody?.content?.["application/json"]?.example;
|
|
586
|
+
if (example !== undefined) lines.push(" --header 'Content-Type: application/json'", ` --data-raw ${quote(JSON.stringify(example))}`);
|
|
587
|
+
if (apiPath === "/api/admin/backup/import") lines.push(" --header 'Content-Type: application/json'", " --data-binary '@backup.json'");
|
|
588
|
+
return lines.join(" \\\n");
|
|
578
589
|
}
|
|
579
590
|
|
|
580
|
-
function
|
|
581
|
-
const
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
const
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
<h3><span class="method method-${escapeHtml(method.toLowerCase())}">${escapeHtml(method)}</span>${escapeHtml(apiPath)}</h3>
|
|
591
|
-
<p>${escapeHtml(operation.summary || "")}</p>
|
|
592
|
-
${description}
|
|
593
|
-
${paramsTable}
|
|
594
|
-
<p><strong>Responses:</strong> ${escapeHtml(responseCodes)}</p>
|
|
595
|
-
</article>`;
|
|
596
|
-
}
|
|
591
|
+
export function renderApiDocsHtml({ baseUrl = "", locale = defaultLocale(), currentPath = "/docs/api" } = {}) {
|
|
592
|
+
const document = buildOpenApiDocument({ baseUrl });
|
|
593
|
+
const { t, locale: language } = createI18n(locale);
|
|
594
|
+
const label = (key) => escapeHtml(t(`apiDocs.${key}`));
|
|
595
|
+
const current = new URL(currentPath, "http://artifacty.local");
|
|
596
|
+
const preserved = [...current.searchParams.entries()].filter(([name]) => name !== "lang")
|
|
597
|
+
.map(([name, value]) => `<input type="hidden" name="${escapeHtml(name)}" value="${escapeHtml(value)}">`).join("");
|
|
598
|
+
const languageOptions = availableLanguages().filter((item) => item.enabled)
|
|
599
|
+
.map((item) => `<option value="${escapeHtml(item.locale)}"${item.locale === language ? " selected" : ""}>${escapeHtml(item.nativeName)}</option>`).join("");
|
|
600
|
+
const languageSelector = `<form class="language-switcher" data-auto-apply="all" method="get" action="${escapeHtml(`${current.pathname}${current.hash}`)}">${preserved}<label>${escapeHtml(t("language.label"))} <select class="form-select" name="lang">${languageOptions}</select></label><button class="btn btn-outline-secondary" type="submit">${escapeHtml(t("language.apply"))}</button></form>`;
|
|
597
601
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
602
|
+
const code = (value) => `<pre><code>${escapeHtml(typeof value === "string" ? value : JSON.stringify(value, null, 2))}</code></pre>`;
|
|
603
|
+
const snippet = (value) => `<div class="snippet"><button class="btn btn-outline-secondary btn-sm" type="button" data-copy hidden>${label("copy")}</button>${code(value)}</div>`;
|
|
604
|
+
const rows = Object.entries(document.paths).flatMap(([apiPath, operations]) => Object.entries(operations).map(([method, operation]) => ({ apiPath, method, operation })));
|
|
605
|
+
const tags = [...new Set(rows.map(({ operation }) => operation.tags[0]))];
|
|
606
|
+
const render = ({ apiPath, method, operation }) => {
|
|
607
|
+
const request = operation.requestBody?.content?.["application/json"];
|
|
608
|
+
const parameters = (operation.parameters || []).map((parameter) => `<tr><th scope="row">${escapeHtml(parameter.name)}</th><td>${escapeHtml(parameter.in)}</td><td>${escapeHtml(parameter.schema?.type || "string")}${parameter.required ? ` · ${label("required")}` : ""}</td><td>${escapeHtml(parameter.description || "")}${parameter.schema?.enum ? code(parameter.schema.enum) : ""}</td></tr>`).join("");
|
|
609
|
+
const responses = Object.entries(operation.responses).map(([status, response]) => {
|
|
610
|
+
const media = response.content?.["application/json"];
|
|
611
|
+
const example = media ? media.example ?? schemaExample(media.schema, document.components.schemas) : undefined;
|
|
612
|
+
return `<details class="response"><summary>${escapeHtml(status)} · ${escapeHtml(response.description)}</summary>${example !== undefined ? `<h4>${label("responseExample")}</h4>${snippet(example)}<details><summary>${label("schema")}</summary>${code(media.schema)}</details>` : `<p>${label("noSchema")}</p>`}</details>`;
|
|
613
|
+
}).join("");
|
|
614
|
+
return `<details class="operation" id="${escapeHtml(operation.operationId)}" data-operation data-search="${escapeHtml(`${method} ${apiPath} ${operation.summary} ${operation.tags.join(" ")}`.toLowerCase())}"><summary><span class="method ${method}">${method.toUpperCase()}</span><code class="path">${escapeHtml(apiPath)}</code><span class="summary-text">${escapeHtml(operation.summary)}</span></summary><div class="operation-body"><p>${escapeHtml(operation.description || operation.summary)}</p><p class="muted">${label("access")}: ${escapeHtml(operation["x-artifacty-access"])}</p>${parameters ? `<h3>${label("parameters")}</h3><div class="table-scroll"><table><thead><tr><th>${label("name")}</th><th>${label("location")}</th><th>${label("type")}</th><th>${label("description")}</th></tr></thead><tbody>${parameters}</tbody></table></div>` : ""}${request ? `<h3>${label("requestExample")}</h3>${snippet(request.example ?? schemaExample(request.schema, document.components.schemas))}<details><summary>${label("schema")}</summary>${code(request.schema)}</details>` : ""}<h3>curl</h3>${snippet(buildCurlExample({ baseUrl, apiPath, method, operation }))}<h3>${label("responses")}</h3>${responses}</div></details>`;
|
|
615
|
+
};
|
|
616
|
+
return `<!doctype html><html lang="${escapeHtml(language)}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Artifacty ${label("title")}</title><script src="/assets/theme.js"></script><script type="module" src="/assets/auto-apply.js"></script><style>
|
|
617
|
+
:root{color-scheme:light;--bg:#f5f6f8;--panel:#fff;--text:#20242b;--muted:#58616e;--line:#dce0e6;--accent:#315ca8;--code:#f0f2f5}
|
|
618
|
+
:root[data-theme="dark"]{color-scheme:dark;--bg:#15171b;--panel:#1c1f24;--text:#edf0f3;--muted:#adb5c0;--line:#363c45;--accent:#9cbcf5;--code:#15171b}
|
|
619
|
+
@media(prefers-color-scheme:dark){:root:not([data-theme="light"]){color-scheme:dark;--bg:#15171b;--panel:#1c1f24;--text:#edf0f3;--muted:#adb5c0;--line:#363c45;--accent:#9cbcf5;--code:#15171b}}
|
|
620
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.6 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}a{color:var(--accent)}header,main{max-width:1200px;margin:auto;padding:24px 32px}header{border-bottom:1px solid var(--line)}h1{font-size:30px;letter-spacing:-.03em;margin:14px 0 4px}h2{font-size:21px;margin-top:32px}h3{font-size:16px;margin:24px 0 8px}h4{margin-bottom:8px}.muted{color:var(--muted)}nav{display:flex;align-items:center;gap:16px;flex-wrap:wrap}.language-switcher{display:flex;align-items:center;gap:8px}.language-switcher label{display:flex;align-items:center;gap:8px}input{display:block;width:100%;padding:12px;border:1px solid var(--line);background:var(--panel);color:var(--text);border-radius:6px;font:inherit;margin:8px 0 24px}.operation{background:var(--panel);border:1px solid var(--line);border-radius:6px;margin:8px 0;scroll-margin-top:16px}summary{cursor:pointer;padding:12px;overflow-wrap:anywhere}.operation>summary{display:flex;align-items:center;gap:12px;flex-wrap:wrap}.operation>summary::before{content:"▸";color:var(--muted)}.operation[open]>summary::before{content:"▾"}.method{font-size:12px;font-weight:750;min-width:58px;color:var(--accent)}.delete{color:#bd4545}.path{font-size:13px}.summary-text{color:var(--muted);margin-left:auto}.operation-body{padding:4px 20px 20px;border-top:1px solid var(--line)}pre{background:var(--code);padding:16px;overflow:auto;border-radius:4px;font-size:12px;line-height:1.7;max-height:420px}code{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}.snippet{position:relative}.snippet button{float:right;margin:8px}.table-scroll{overflow:auto}table{border-collapse:collapse;width:100%;text-align:left;font-size:13px}th,td{padding:10px;border-bottom:1px solid var(--line);vertical-align:top}.response{border-top:1px solid var(--line)}[hidden]{display:none!important}:focus-visible{outline:2px solid var(--accent);outline-offset:3px}@media(max-width:650px){header,main{padding:20px 16px}.summary-text{width:100%;margin-left:28px}.operation-body{padding:4px 12px 16px}.path{font-size:12px}h1{font-size:25px}}
|
|
621
|
+
</style><link rel="stylesheet" href="/assets/ui-controls.css"><script type="module" src="/assets/api-docs.js"></script></head><body data-copy-success="${label("copied")}" data-copy-failure="${label("copyFailed")}"><header><nav><a href="/?lang=${escapeHtml(language)}">Artifacty</a><a class="btn btn-outline-secondary" href="/openapi.json" download="artifacty-openapi.json">${label("download")}</a>${languageSelector}</nav><h1>${label("title")}</h1><p class="muted">${label("intro")}</p><nav>${tags.map((tag) => `<a href="#tag-${tag}">${tag}</a>`).join("")}<a href="#schemas">${label("schemas")}</a></nav></header><main><div data-search-controls hidden><label for="api-search">${label("search")}</label><input class="form-control" id="api-search" type="search" placeholder="${label("searchPlaceholder")}" autocomplete="off"></div><p data-no-results hidden role="status">${label("noResults")}</p><p class="muted">${label("sampleNote")}</p>${tags.map((tag) => `<section data-tag-group id="tag-${tag}"><h2>${tag}</h2>${rows.filter(({ operation }) => operation.tags[0] === tag).map(render).join("")}</section>`).join("")}<section id="schemas"><h2>${label("schemas")}</h2>${Object.entries(document.components.schemas).map(([name, schema]) => `<details class="operation"><summary>${escapeHtml(name)}</summary>${code(schema)}</details>`).join("")}</section><p role="status" aria-live="polite" data-copy-status></p></main></body></html>`;
|
|
603
622
|
}
|
|
604
623
|
|
|
605
624
|
function escapeHtml(value) {
|