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.
@@ -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
+ }
@@ -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 renderApiDocsHtml({ baseUrl = "" } = {}) {
529
- const document = buildOpenApiDocument({ baseUrl });
530
- const rows = Object.entries(document.paths)
531
- .flatMap(([apiPath, operations]) =>
532
- Object.entries(operations).map(([method, operation]) => ({ method, apiPath, operation }))
533
- )
534
- .sort((left, right) => left.apiPath.localeCompare(right.apiPath) || left.method.localeCompare(right.method));
535
-
536
- const routeSections = rows
537
- .map(({ method, apiPath, operation }) => renderRouteSection(method, apiPath, operation))
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 renderRouteSection(method, apiPath, operation) {
581
- const paramRows = (operation.parameters || [])
582
- .map((param) => `<tr><td>${escapeHtml(param.name)}</td><td>${escapeHtml(param.in)}</td><td>${escapeHtml(param.schema?.type || "string")}</td><td>${param.required ? "yes" : "no"}</td></tr>`)
583
- .join("");
584
- const paramsTable = paramRows
585
- ? `<table class="params"><thead><tr><th>Name</th><th>In</th><th>Type</th><th>Required</th></tr></thead><tbody>${paramRows}</tbody></table>`
586
- : "";
587
- const description = operation.description ? `<p>${escapeHtml(operation.description)}</p>` : "";
588
- const responseCodes = Object.keys(operation.responses || {}).join(", ");
589
- return `<article class="route" id="${escapeHtml(`${method.toLowerCase()}-${apiPath}`)}">
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
- function renderSchemaSection(name, schema) {
599
- return `<section class="schema" id="schema-${escapeHtml(name)}">
600
- <h3>${escapeHtml(name)}</h3>
601
- <pre>${escapeHtml(JSON.stringify(schema, null, 2))}</pre>
602
- </section>`;
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) {