explicai 0.0.0-stage → 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.
Files changed (138) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/LICENSE +21 -0
  3. package/README.md +113 -2
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +51 -0
  6. package/dist/core/annotations.d.ts +7 -0
  7. package/dist/core/annotations.js +110 -0
  8. package/dist/core/assets.d.ts +17 -0
  9. package/dist/core/assets.js +116 -0
  10. package/dist/core/errors.d.ts +9 -0
  11. package/dist/core/errors.js +14 -0
  12. package/dist/core/export/annotationCss.d.ts +5 -0
  13. package/dist/core/export/annotationCss.js +26 -0
  14. package/dist/core/export/annotationShapes.d.ts +37 -0
  15. package/dist/core/export/annotationShapes.js +38 -0
  16. package/dist/core/export/callout.d.ts +11 -0
  17. package/dist/core/export/callout.js +26 -0
  18. package/dist/core/export/collect.d.ts +27 -0
  19. package/dist/core/export/collect.js +49 -0
  20. package/dist/core/export/color.d.ts +11 -0
  21. package/dist/core/export/color.js +33 -0
  22. package/dist/core/export/fonts.d.ts +35 -0
  23. package/dist/core/export/fonts.js +40 -0
  24. package/dist/core/export/headings.d.ts +7 -0
  25. package/dist/core/export/headings.js +17 -0
  26. package/dist/core/export/html.d.ts +4 -0
  27. package/dist/core/export/html.js +242 -0
  28. package/dist/core/export/imageSize.d.ts +9 -0
  29. package/dist/core/export/imageSize.js +72 -0
  30. package/dist/core/export/index.d.ts +29 -0
  31. package/dist/core/export/index.js +151 -0
  32. package/dist/core/export/labels.d.ts +17 -0
  33. package/dist/core/export/labels.js +31 -0
  34. package/dist/core/export/logo.d.ts +10 -0
  35. package/dist/core/export/logo.js +29 -0
  36. package/dist/core/export/markdown.d.ts +8 -0
  37. package/dist/core/export/markdown.js +83 -0
  38. package/dist/core/export/pdf.d.ts +6 -0
  39. package/dist/core/export/pdf.js +68 -0
  40. package/dist/core/export/refs.d.ts +24 -0
  41. package/dist/core/export/refs.js +66 -0
  42. package/dist/core/export/textColors.d.ts +7 -0
  43. package/dist/core/export/textColors.js +18 -0
  44. package/dist/core/export/theme.d.ts +28 -0
  45. package/dist/core/export/theme.js +140 -0
  46. package/dist/core/frontmatter.d.ts +5 -0
  47. package/dist/core/frontmatter.js +17 -0
  48. package/dist/core/fs.d.ts +13 -0
  49. package/dist/core/fs.js +45 -0
  50. package/dist/core/index.d.ts +14 -0
  51. package/dist/core/index.js +15 -0
  52. package/dist/core/manuals.d.ts +23 -0
  53. package/dist/core/manuals.js +86 -0
  54. package/dist/core/pages.d.ts +43 -0
  55. package/dist/core/pages.js +179 -0
  56. package/dist/core/redact.d.ts +9 -0
  57. package/dist/core/redact.js +107 -0
  58. package/dist/core/text.d.ts +5 -0
  59. package/dist/core/text.js +28 -0
  60. package/dist/core/trash.d.ts +25 -0
  61. package/dist/core/trash.js +84 -0
  62. package/dist/core/tree.d.ts +13 -0
  63. package/dist/core/tree.js +28 -0
  64. package/dist/core/types.d.ts +111 -0
  65. package/dist/core/types.js +7 -0
  66. package/dist/core/versions.d.ts +27 -0
  67. package/dist/core/versions.js +82 -0
  68. package/dist/server/errorPage.d.ts +9 -0
  69. package/dist/server/errorPage.js +122 -0
  70. package/dist/server/http.d.ts +31 -0
  71. package/dist/server/http.js +132 -0
  72. package/dist/server/index.d.ts +4 -0
  73. package/dist/server/index.js +60 -0
  74. package/dist/server/router.d.ts +30 -0
  75. package/dist/server/router.js +28 -0
  76. package/dist/server/routes/assets.d.ts +2 -0
  77. package/dist/server/routes/assets.js +63 -0
  78. package/dist/server/routes/export.d.ts +2 -0
  79. package/dist/server/routes/export.js +43 -0
  80. package/dist/server/routes/fonts.d.ts +3 -0
  81. package/dist/server/routes/fonts.js +24 -0
  82. package/dist/server/routes/manuals.d.ts +2 -0
  83. package/dist/server/routes/manuals.js +40 -0
  84. package/dist/server/routes/pages.d.ts +2 -0
  85. package/dist/server/routes/pages.js +79 -0
  86. package/dist/server/routes/versions.d.ts +2 -0
  87. package/dist/server/routes/versions.js +43 -0
  88. package/dist/server/routes/web.d.ts +3 -0
  89. package/dist/server/routes/web.js +29 -0
  90. package/dist/server/security.d.ts +6 -0
  91. package/dist/server/security.js +17 -0
  92. package/dist/server/workspace.d.ts +5 -0
  93. package/dist/server/workspace.js +17 -0
  94. package/dist/web/assets/archivo-latin-ext-wdth-normal-7khWdh9v.woff2 +0 -0
  95. package/dist/web/assets/archivo-latin-wdth-normal-DY7AcnAa.woff2 +0 -0
  96. package/dist/web/assets/archivo-vietnamese-wdth-normal-rJmnGBSt.woff2 +0 -0
  97. package/dist/web/assets/atkinson-hyperlegible-next-latin-400-normal-DPp7N0cD.woff +0 -0
  98. package/dist/web/assets/atkinson-hyperlegible-next-latin-400-normal-FfmJh7DR.woff2 +0 -0
  99. package/dist/web/assets/atkinson-hyperlegible-next-latin-700-normal-C2qZD_Q9.woff +0 -0
  100. package/dist/web/assets/atkinson-hyperlegible-next-latin-700-normal-Dpiyiu63.woff2 +0 -0
  101. package/dist/web/assets/atkinson-hyperlegible-next-latin-ext-400-normal-BalHKn7d.woff2 +0 -0
  102. package/dist/web/assets/atkinson-hyperlegible-next-latin-ext-400-normal-qxMiU51n.woff +0 -0
  103. package/dist/web/assets/atkinson-hyperlegible-next-latin-ext-700-normal-292QVhS1.woff +0 -0
  104. package/dist/web/assets/atkinson-hyperlegible-next-latin-ext-700-normal-CBw-mJf_.woff2 +0 -0
  105. package/dist/web/assets/index-B7Gr83D6.js +262 -0
  106. package/dist/web/assets/index-DFBrhkII.css +1 -0
  107. package/dist/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  108. package/dist/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  109. package/dist/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  110. package/dist/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  111. package/dist/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  112. package/dist/web/favicon.svg +1 -0
  113. package/dist/web/index.html +23 -0
  114. package/fonts/OFL-archivo.txt +93 -0
  115. package/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
  116. package/fonts/OFL-inter.txt +93 -0
  117. package/fonts/OFL-jetbrains-mono.txt +93 -0
  118. package/fonts/OFL-lora.txt +93 -0
  119. package/fonts/OFL-montserrat.txt +93 -0
  120. package/fonts/OFL-playfair-display.txt +93 -0
  121. package/fonts/OFL-source-sans-3.txt +93 -0
  122. package/fonts/OFL-source-serif-4.txt +93 -0
  123. package/fonts/archivo-latin-wght-normal.woff2 +0 -0
  124. package/fonts/atkinson-hyperlegible-next-latin-400-italic.woff2 +0 -0
  125. package/fonts/atkinson-hyperlegible-next-latin-400-normal.woff2 +0 -0
  126. package/fonts/atkinson-hyperlegible-next-latin-700-normal.woff2 +0 -0
  127. package/fonts/inter-latin-wght-italic.woff2 +0 -0
  128. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  129. package/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  130. package/fonts/lora-latin-wght-italic.woff2 +0 -0
  131. package/fonts/lora-latin-wght-normal.woff2 +0 -0
  132. package/fonts/montserrat-latin-wght-normal.woff2 +0 -0
  133. package/fonts/playfair-display-latin-wght-normal.woff2 +0 -0
  134. package/fonts/source-sans-3-latin-wght-italic.woff2 +0 -0
  135. package/fonts/source-sans-3-latin-wght-normal.woff2 +0 -0
  136. package/fonts/source-serif-4-latin-wght-italic.woff2 +0 -0
  137. package/fonts/source-serif-4-latin-wght-normal.woff2 +0 -0
  138. package/package.json +124 -4
@@ -0,0 +1,31 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ export declare class HttpError extends Error {
3
+ readonly status: number;
4
+ /** Identifica o erro para a interface traduzir (a mensagem fica em português para curl e MCP). */
5
+ readonly code?: string | undefined;
6
+ constructor(status: number, message: string,
7
+ /** Identifica o erro para a interface traduzir (a mensagem fica em português para curl e MCP). */
8
+ code?: string | undefined);
9
+ }
10
+ /** O que um handler devolve. O servidor transforma em resposta HTTP. */
11
+ export type Reply = {
12
+ kind: "json";
13
+ status: number;
14
+ body: unknown;
15
+ } | {
16
+ kind: "file";
17
+ path: string;
18
+ cache: string;
19
+ untrusted: boolean;
20
+ };
21
+ export declare const json: (body: unknown, status?: number) => Reply;
22
+ /** `untrusted`: arquivo do manual (/files/), que pode ser um SVG com script; sai com CSP e sandbox. */
23
+ export declare const file: (filePath: string, cache?: string, untrusted?: boolean) => Reply;
24
+ export declare function send(res: ServerResponse, reply: Reply, req?: IncomingMessage): Promise<void>;
25
+ export declare function sendJson(res: ServerResponse, status: number, body: unknown): void;
26
+ export declare function readBody(req: IncomingMessage, limit: number): Promise<Uint8Array>;
27
+ export declare function readJson(req: IncomingMessage): Promise<Record<string, unknown>>;
28
+ /** Status HTTP para um erro vindo do core: "não encontrado" e arquivo inexistente viram 404, o resto 400. */
29
+ export declare function statusOf(err: unknown): number;
30
+ /** Texto do erro para a resposta: erro de arquivo do Node traz o caminho absoluto, então vira frase fixa. */
31
+ export declare function publicMessage(err: unknown): string;
@@ -0,0 +1,132 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { pipeline } from "node:stream/promises";
5
+ import { ExplicaiError } from "../core/errors.js";
6
+ export class HttpError extends Error {
7
+ status;
8
+ code;
9
+ constructor(status, message,
10
+ /** Identifica o erro para a interface traduzir (a mensagem fica em português para curl e MCP). */
11
+ code) {
12
+ super(message);
13
+ this.status = status;
14
+ this.code = code;
15
+ }
16
+ }
17
+ export const json = (body, status = 200) => ({ kind: "json", status, body });
18
+ /** `untrusted`: arquivo do manual (/files/), que pode ser um SVG com script; sai com CSP e sandbox. */
19
+ export const file = (filePath, cache = "no-cache", untrusted = false) => ({
20
+ kind: "file",
21
+ path: filePath,
22
+ cache,
23
+ untrusted,
24
+ });
25
+ const RASTER = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp"]);
26
+ const UNTRUSTED_CSP = "default-src 'none'; style-src 'unsafe-inline'; sandbox";
27
+ const MIME = {
28
+ ".html": "text/html; charset=utf-8",
29
+ ".js": "text/javascript; charset=utf-8",
30
+ ".css": "text/css; charset=utf-8",
31
+ ".json": "application/json; charset=utf-8",
32
+ ".svg": "image/svg+xml",
33
+ ".png": "image/png",
34
+ ".jpg": "image/jpeg",
35
+ ".jpeg": "image/jpeg",
36
+ ".gif": "image/gif",
37
+ ".webp": "image/webp",
38
+ ".woff2": "font/woff2",
39
+ ".pdf": "application/pdf",
40
+ ".md": "text/markdown; charset=utf-8",
41
+ };
42
+ export async function send(res, reply, req) {
43
+ if (reply.kind === "json")
44
+ return sendJson(res, reply.status, reply.body);
45
+ const info = await stat(reply.path); // ENOENT vira 404 no chamador, antes de qualquer cabeçalho
46
+ if (!info.isFile())
47
+ throw new HttpError(404, "Arquivo não encontrado.");
48
+ const ext = path.extname(reply.path).toLowerCase();
49
+ // ETag fraco por tamanho e mtime: barato, sem ler o arquivo.
50
+ const etag = `W/"${info.size.toString(16)}-${Math.floor(info.mtimeMs).toString(16)}"`;
51
+ const headers = {
52
+ etag,
53
+ "cache-control": reply.cache,
54
+ "x-content-type-options": "nosniff",
55
+ ...(reply.untrusted && !RASTER.has(ext) ? { "content-security-policy": UNTRUSTED_CSP } : {}),
56
+ };
57
+ // shortcut: só um valor em If-None-Match (o do próprio navegador), sem lista nem "*"; Range/HEAD não tratados.
58
+ if (req?.headers["if-none-match"] === etag) {
59
+ res.writeHead(304, headers);
60
+ res.end();
61
+ return;
62
+ }
63
+ res.writeHead(200, { "content-type": MIME[ext] ?? "application/octet-stream", "content-length": info.size, ...headers });
64
+ // Erro no meio do envio ou cliente que abortou: o pipeline destrói os dois lados; os cabeçalhos já saíram.
65
+ await pipeline(createReadStream(reply.path), res).catch(() => { });
66
+ }
67
+ export function sendJson(res, status, body) {
68
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
69
+ res.end(JSON.stringify(body));
70
+ }
71
+ export async function readBody(req, limit) {
72
+ const chunks = [];
73
+ let size = 0;
74
+ for await (const chunk of req) {
75
+ size += chunk.length;
76
+ // Passou do limite: continua lendo sem guardar. Parar no meio derruba a conexão e o navegador
77
+ // nunca recebe o 413. shortcut: lê o envio inteiro; tudo bem só porque o servidor é local.
78
+ if (size <= limit)
79
+ chunks.push(chunk);
80
+ }
81
+ if (size > limit)
82
+ throw new HttpError(413, `Arquivo maior que ${limit / 1024 / 1024} MB.`);
83
+ return Buffer.concat(chunks);
84
+ }
85
+ export async function readJson(req) {
86
+ const raw = await readBody(req, 5 * 1024 * 1024);
87
+ // Um POST text/plain de outro site não dispara preflight; exigir JSON fecha essa brecha. Corpo vazio não lê nada.
88
+ if (raw.length > 0 && !/^application\/json\s*(;|$)/i.test(req.headers["content-type"] ?? "")) {
89
+ throw new HttpError(415, "Envie o corpo como JSON, com o cabeçalho content-type: application/json.", "unsupported-media-type");
90
+ }
91
+ let text;
92
+ try {
93
+ // fatal: um cliente em Latin-1/cp1252 (curl no Windows) mandaria "í" como 0xED, e o decode comum o trocaria por U+FFFD gravado em disco.
94
+ text = new TextDecoder("utf-8", { fatal: true }).decode(raw);
95
+ }
96
+ catch {
97
+ throw new HttpError(400, "O corpo da requisição não está em UTF-8 válido. Envie o JSON em UTF-8.", "invalid-utf8");
98
+ }
99
+ let data;
100
+ try {
101
+ data = text ? JSON.parse(text) : {};
102
+ }
103
+ catch {
104
+ throw new HttpError(400, "O corpo da requisição não é um JSON válido.");
105
+ }
106
+ if (typeof data !== "object" || data === null || Array.isArray(data)) {
107
+ throw new HttpError(400, "O corpo da requisição deve ser um objeto JSON.");
108
+ }
109
+ return data;
110
+ }
111
+ /** Status HTTP para um erro vindo do core: "não encontrado" e arquivo inexistente viram 404, o resto 400. */
112
+ export function statusOf(err) {
113
+ if (err instanceof HttpError)
114
+ return err.status;
115
+ if (err instanceof ExplicaiError && err.code === "disk-conflict")
116
+ return 409;
117
+ const missing = (err instanceof Error && /não encontrad/i.test(err.message)) ||
118
+ ["ENOENT", "EISDIR"].includes(err?.code ?? "");
119
+ return missing ? 404 : 400;
120
+ }
121
+ const FILE_ERRORS = {
122
+ ENOENT: "Arquivo não encontrado.",
123
+ EISDIR: "Arquivo não encontrado.",
124
+ EACCES: "Sem permissão para acessar o arquivo.",
125
+ EPERM: "Sem permissão para acessar o arquivo.",
126
+ };
127
+ /** Texto do erro para a resposta: erro de arquivo do Node traz o caminho absoluto, então vira frase fixa. */
128
+ export function publicMessage(err) {
129
+ const fixed = FILE_ERRORS[err?.code ?? ""];
130
+ return fixed ?? (err instanceof Error ? err.message : String(err));
131
+ }
132
+ //# sourceMappingURL=http.js.map
@@ -0,0 +1,4 @@
1
+ import { type Server } from "node:http";
2
+ import { type StudioOptions } from "./router.js";
3
+ export type { StudioOptions } from "./router.js";
4
+ export declare function createStudioServer(opts: StudioOptions): Server;
@@ -0,0 +1,60 @@
1
+ import { createServer } from "node:http";
2
+ import { ExplicaiError } from "../core/errors.js";
3
+ import { errorPage, pickLang } from "./errorPage.js";
4
+ import { HttpError, publicMessage, send, sendJson, statusOf } from "./http.js";
5
+ import { matchRoute } from "./router.js";
6
+ import { assetRoutes } from "./routes/assets.js";
7
+ import { exportRoutes } from "./routes/export.js";
8
+ import { fontRoutes } from "./routes/fonts.js";
9
+ import { manualRoutes } from "./routes/manuals.js";
10
+ import { pageRoutes } from "./routes/pages.js";
11
+ import { versionRoutes } from "./routes/versions.js";
12
+ import { webRoutes } from "./routes/web.js";
13
+ import { assertLocalRequest } from "./security.js";
14
+ /** A ordem importa: a rota genérica do editor (`/*`) fica por último. */
15
+ const routes = [
16
+ ...manualRoutes,
17
+ ...pageRoutes,
18
+ ...versionRoutes,
19
+ ...exportRoutes,
20
+ ...fontRoutes,
21
+ ...assetRoutes,
22
+ ...webRoutes,
23
+ ];
24
+ /** Um `%` malformado no endereço é erro de quem pediu (400 com código estável), não falha do servidor. */
25
+ function decodeSegment(segment) {
26
+ try {
27
+ return decodeURIComponent(segment);
28
+ }
29
+ catch {
30
+ throw new HttpError(400, "O endereço pedido tem um % malformado. Confira o endereço e tente de novo.", "malformed-url");
31
+ }
32
+ }
33
+ export function createStudioServer(opts) {
34
+ return createServer(async (req, res) => {
35
+ try {
36
+ assertLocalRequest(req);
37
+ const url = new URL(req.url ?? "/", "http://localhost");
38
+ const segments = url.pathname.split("/").filter(Boolean).map(decodeSegment);
39
+ const method = req.method ?? "GET";
40
+ const match = matchRoute(routes, method, segments);
41
+ if (!match)
42
+ throw new HttpError(segments[0] === "api" ? 405 : 404, "Rota não encontrada.");
43
+ await send(res, await match.route.handle({ req, url, params: match.params, rest: match.rest, opts }), req);
44
+ }
45
+ catch (err) {
46
+ const { code, params } = err instanceof ExplicaiError ? err : {};
47
+ const errorCode = code ?? (err instanceof HttpError ? err.code : undefined);
48
+ const status = statusOf(err);
49
+ // Quem abre o Studio no navegador recebe uma página legível; a API e os arquivos continuam em JSON.
50
+ const [first] = (req.url ?? "/").split("?")[0]?.split("/").filter(Boolean) ?? [];
51
+ if (first !== "api" && first !== "files" && req.headers.accept?.includes("text/html")) {
52
+ const html = errorPage(status, errorCode ?? (status === 404 ? "not-found" : "error"), pickLang(req.headers["accept-language"]));
53
+ res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
54
+ return void res.end(html);
55
+ }
56
+ sendJson(res, status, { error: publicMessage(err), code: errorCode, params });
57
+ }
58
+ });
59
+ }
60
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,30 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ import type { Reply } from "./http.js";
3
+ export interface StudioOptions {
4
+ /** Pasta onde ficam os manuais. */
5
+ workspace: string;
6
+ /** Pasta com o build do editor (index.html e assets). Sem ela, só a API responde. */
7
+ webDir?: string;
8
+ }
9
+ export interface RouteContext {
10
+ req: IncomingMessage;
11
+ url: URL;
12
+ /** Segmentos `:nome` do padrão da rota, já decodificados. */
13
+ params: Record<string, string>;
14
+ /** O que veio depois de um `*` no fim do padrão. */
15
+ rest: string;
16
+ opts: StudioOptions;
17
+ }
18
+ export interface Route {
19
+ method: string;
20
+ /** Ex.: `/api/manuals/:slug/pages/:id`. Um `*` no fim aceita o resto do caminho. */
21
+ path: string;
22
+ handle: (ctx: RouteContext) => Promise<Reply> | Reply;
23
+ }
24
+ export type Match = {
25
+ route: Route;
26
+ params: Record<string, string>;
27
+ rest: string;
28
+ };
29
+ /** Primeira rota cujo método e padrão batem. A ordem da tabela define a prioridade. */
30
+ export declare function matchRoute(routes: Route[], method: string, segments: string[]): Match | null;
@@ -0,0 +1,28 @@
1
+ /** Primeira rota cujo método e padrão batem. A ordem da tabela define a prioridade. */
2
+ export function matchRoute(routes, method, segments) {
3
+ for (const route of routes) {
4
+ if (route.method !== method)
5
+ continue;
6
+ const match = matchPath(route.path, segments);
7
+ if (match)
8
+ return { route, ...match };
9
+ }
10
+ return null;
11
+ }
12
+ function matchPath(pattern, segments) {
13
+ const parts = pattern.split("/").filter(Boolean);
14
+ const params = {};
15
+ for (const [i, part] of parts.entries()) {
16
+ if (part === "*")
17
+ return { params, rest: segments.slice(i).join("/") };
18
+ const seg = segments[i];
19
+ if (seg === undefined)
20
+ return null;
21
+ if (part.startsWith(":"))
22
+ params[part.slice(1)] = seg;
23
+ else if (part !== seg)
24
+ return null;
25
+ }
26
+ return parts.length === segments.length ? { params, rest: "" } : null;
27
+ }
28
+ //# sourceMappingURL=router.js.map
@@ -0,0 +1,2 @@
1
+ import type { Route } from "../router.js";
2
+ export declare const assetRoutes: Route[];
@@ -0,0 +1,63 @@
1
+ import { cleanUnusedAssets, findUnusedAssets, insideGitRepo, readAnnotations, redactAsset, resolveInsideReal, saveAsset, writeAnnotations, } from "../../core/index.js";
2
+ import { file, json, readBody, readJson } from "../http.js";
3
+ import { manualDir } from "../workspace.js";
4
+ const MAX_UPLOAD = 20 * 1024 * 1024;
5
+ export const assetRoutes = [
6
+ {
7
+ method: "POST",
8
+ path: "/api/manuals/:slug/assets",
9
+ handle: async (ctx) => {
10
+ const name = ctx.url.searchParams.get("name") ?? "imagem.png";
11
+ const data = await readBody(ctx.req, MAX_UPLOAD);
12
+ return json({ path: await saveAsset(manualDir(ctx), name, data) }, 201);
13
+ },
14
+ },
15
+ {
16
+ method: "GET",
17
+ path: "/api/manuals/:slug/annotations",
18
+ handle: async (ctx) => json(await readAnnotations(manualDir(ctx), ctx.url.searchParams.get("asset") ?? "")),
19
+ },
20
+ {
21
+ method: "PUT",
22
+ path: "/api/manuals/:slug/annotations",
23
+ handle: async (ctx) => json(await writeAnnotations(manualDir(ctx), ctx.url.searchParams.get("asset") ?? "", await readJson(ctx.req))),
24
+ },
25
+ {
26
+ // Troca o print pela versão com as tarjas já pintadas (o navegador desenha) e apaga o original.
27
+ method: "POST",
28
+ path: "/api/manuals/:slug/assets/redact",
29
+ handle: async (ctx) => {
30
+ const asset = ctx.url.searchParams.get("asset") ?? "";
31
+ await redactAsset(manualDir(ctx), asset, await readBody(ctx.req, MAX_UPLOAD));
32
+ return json({ path: asset });
33
+ },
34
+ },
35
+ {
36
+ method: "GET",
37
+ path: "/api/manuals/:slug/git",
38
+ handle: async (ctx) => json({ git: await insideGitRepo(manualDir(ctx)) }),
39
+ },
40
+ {
41
+ // Imagens e marcações que nenhuma página, versão ou item da lixeira usa.
42
+ method: "GET",
43
+ path: "/api/manuals/:slug/assets/unused",
44
+ handle: async (ctx) => json({ count: (await findUnusedAssets(manualDir(ctx))).length }),
45
+ },
46
+ {
47
+ // Move as sem uso para .lixeira/imagens-<data>/. Nada é apagado.
48
+ method: "POST",
49
+ path: "/api/manuals/:slug/assets/clean",
50
+ handle: async (ctx) => json(await cleanUnusedAssets(manualDir(ctx))),
51
+ },
52
+ {
53
+ // Imagens do manual, usadas pelo editor: /files/<manual>/assets/<arquivo>
54
+ method: "GET",
55
+ path: "/files/:slug/*",
56
+ // Só raster de assets/ (nome único, gravado uma vez) fica em cache; manual.json, logo e .md revalidam por ETag.
57
+ handle: async (ctx) => {
58
+ const cacheable = /^assets\/[^/]+\.(png|jpe?g|gif|webp)$/i.test(ctx.rest);
59
+ return file(await resolveInsideReal(manualDir(ctx), ctx.rest), cacheable ? "private, max-age=3600" : "no-cache", true);
60
+ },
61
+ },
62
+ ];
63
+ //# sourceMappingURL=assets.js.map
@@ -0,0 +1,2 @@
1
+ import type { Route } from "../router.js";
2
+ export declare const exportRoutes: Route[];
@@ -0,0 +1,43 @@
1
+ import { EXPORT_FORMATS, exportManual, logoSizeError, setManualLogo, setManualTheme, } from "../../core/index.js";
2
+ import { HttpError, json, readBody, readJson } from "../http.js";
3
+ import { manualDir, optionalString } from "../workspace.js";
4
+ export const exportRoutes = [
5
+ {
6
+ // { format: "html" | "pdf" | "markdown", onlyApproved?: true }. Devolve o arquivo gerado e o endereço para abrir no navegador.
7
+ method: "POST",
8
+ path: "/api/manuals/:slug/export",
9
+ handle: async (ctx) => {
10
+ const body = await readJson(ctx.req);
11
+ const format = optionalString(body.format);
12
+ if (!format || !EXPORT_FORMATS.includes(format)) {
13
+ throw new HttpError(400, `Formato de exportação inválido. Use ${EXPORT_FORMATS.join(", ")}.`);
14
+ }
15
+ const result = await exportManual(manualDir(ctx), format, { onlyApproved: body.onlyApproved === true });
16
+ return json({ ...result, url: `/files/${encodeURIComponent(ctx.params.slug ?? "")}/${result.file}` });
17
+ },
18
+ },
19
+ {
20
+ // Muda só os campos enviados: { accent?, text?, highlight?, headings?: { cover?, page?, h1?, h2?, h3? } },
21
+ // cada um em hexadecimal ou null para voltar à cor da marca. O core confere formato e contraste (400).
22
+ method: "PUT",
23
+ path: "/api/manuals/:slug/theme",
24
+ handle: async (ctx) => {
25
+ await setManualTheme(manualDir(ctx), await readJson(ctx.req));
26
+ return json({ ok: true });
27
+ },
28
+ },
29
+ {
30
+ // Corpo = o arquivo do logo (PNG, JPG ou SVG, até 1 MB), nome em ?name=. Guarda em assets/ e põe no tema.
31
+ // O limite de leitura é maior que o do logo para o core recusar com o motivo.
32
+ method: "POST",
33
+ path: "/api/manuals/:slug/logo",
34
+ handle: async (ctx) => {
35
+ const name = ctx.url.searchParams.get("name") ?? "logo.png";
36
+ const data = await readBody(ctx.req, 2 * 1024 * 1024).catch((err) => {
37
+ throw err instanceof HttpError && err.status === 413 ? logoSizeError() : err;
38
+ });
39
+ return json({ path: await setManualLogo(manualDir(ctx), name, data) }, 201);
40
+ },
41
+ },
42
+ ];
43
+ //# sourceMappingURL=export.js.map
@@ -0,0 +1,3 @@
1
+ import type { Route } from "../router.js";
2
+ /** As fontes que o manual exportado pode usar, para o Studio mostrar a prévia de cada uma. */
3
+ export declare const fontRoutes: Route[];
@@ -0,0 +1,24 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { FONTS, FONTS_DIR, fontFiles } from "../../core/index.js";
3
+ import { file, HttpError, json } from "../http.js";
4
+ const FILES = new Set(FONTS.flatMap((f) => fontFiles(f).map((x) => x.file)));
5
+ /** As fontes que o manual exportado pode usar, para o Studio mostrar a prévia de cada uma. */
6
+ export const fontRoutes = [
7
+ {
8
+ method: "GET",
9
+ path: "/api/fonts",
10
+ handle: () => json(FONTS.map((f) => ({ id: f.id, family: f.family, kind: f.kind, italic: f.italic, files: fontFiles(f) }))),
11
+ },
12
+ {
13
+ method: "GET",
14
+ path: "/api/fonts/:file",
15
+ handle: ({ params }) => {
16
+ const name = params.file ?? "";
17
+ // Só os arquivos da lista: o nome vem da URL e não vira caminho de disco.
18
+ if (!FILES.has(name))
19
+ throw new HttpError(404, "Fonte não encontrada.");
20
+ return file(fileURLToPath(new URL(name, FONTS_DIR)), "max-age=86400");
21
+ },
22
+ },
23
+ ];
24
+ //# sourceMappingURL=fonts.js.map
@@ -0,0 +1,2 @@
1
+ import type { Route } from "../router.js";
2
+ export declare const manualRoutes: Route[];
@@ -0,0 +1,40 @@
1
+ import path from "node:path";
2
+ import { createManual, listManuals, readManual, readPageTree } from "../../core/index.js";
3
+ import { json, readJson } from "../http.js";
4
+ import { manualDir, optionalString } from "../workspace.js";
5
+ const summary = (m) => ({ slug: path.basename(m.dir), title: m.meta.title, language: m.meta.language });
6
+ export const manualRoutes = [
7
+ {
8
+ method: "GET",
9
+ path: "/api/manuals",
10
+ handle: async ({ opts }) => json((await listManuals(opts.workspace)).map(summary)),
11
+ },
12
+ {
13
+ method: "POST",
14
+ path: "/api/manuals",
15
+ handle: async ({ opts, req }) => {
16
+ const body = await readJson(req);
17
+ const manual = await createManual(opts.workspace, {
18
+ title: optionalString(body.title) ?? "",
19
+ language: optionalString(body.language) ?? "pt-BR",
20
+ });
21
+ return json(summary(manual), 201);
22
+ },
23
+ },
24
+ {
25
+ method: "GET",
26
+ path: "/api/manuals/:slug",
27
+ handle: async (ctx) => {
28
+ const dir = manualDir(ctx);
29
+ const meta = await readManual(dir);
30
+ return json({
31
+ slug: ctx.params.slug,
32
+ title: meta.title,
33
+ language: meta.language,
34
+ theme: meta.theme ?? {},
35
+ pages: await readPageTree(dir),
36
+ });
37
+ },
38
+ },
39
+ ];
40
+ //# sourceMappingURL=manuals.js.map
@@ -0,0 +1,2 @@
1
+ import type { Route } from "../router.js";
2
+ export declare const pageRoutes: Route[];
@@ -0,0 +1,79 @@
1
+ import { addPage, listTrash, movePage, readPage, removePage, restorePage, updatePage, } from "../../core/index.js";
2
+ import { json, readJson } from "../http.js";
3
+ import { manualDir, optionalNumber, optionalString } from "../workspace.js";
4
+ const PAGE = "/api/manuals/:slug/pages/:id";
5
+ export const pageRoutes = [
6
+ {
7
+ method: "POST",
8
+ path: "/api/manuals/:slug/pages",
9
+ handle: async (ctx) => {
10
+ const body = await readJson(ctx.req);
11
+ const text = optionalString(body.body);
12
+ const index = optionalNumber(body.index);
13
+ const page = await addPage(manualDir(ctx), {
14
+ title: optionalString(body.title) ?? "",
15
+ parentId: optionalString(body.parentId) ?? null,
16
+ ...(text === undefined ? {} : { body: text }),
17
+ ...(index === undefined ? {} : { index }),
18
+ });
19
+ return json(page, 201);
20
+ },
21
+ },
22
+ {
23
+ method: "GET",
24
+ path: PAGE,
25
+ handle: async (ctx) => json(await readPage(manualDir(ctx), ctx.params.id ?? "")),
26
+ },
27
+ {
28
+ method: "PUT",
29
+ path: PAGE,
30
+ handle: async (ctx) => {
31
+ const body = await readJson(ctx.req);
32
+ const title = optionalString(body.title);
33
+ const text = optionalString(body.body);
34
+ const status = optionalString(body.status);
35
+ const patch = {
36
+ ...(title === undefined ? {} : { title }),
37
+ ...(text === undefined ? {} : { body: text }),
38
+ ...(status === undefined ? {} : { status }),
39
+ };
40
+ const fileName = body.fileName === "keep" || body.fileName === "sync" ? body.fileName : undefined;
41
+ const ifMatch = ctx.req.headers["if-match"];
42
+ return json(await updatePage(manualDir(ctx), ctx.params.id ?? "", patch, { fileName, ifMatch }));
43
+ },
44
+ },
45
+ {
46
+ method: "DELETE",
47
+ path: PAGE,
48
+ handle: async (ctx) => {
49
+ // O trashId permite desfazer: POST /api/manuals/:slug/trash/:trashId/restore
50
+ return json(await removePage(manualDir(ctx), ctx.params.id ?? ""));
51
+ },
52
+ },
53
+ {
54
+ method: "POST",
55
+ path: `${PAGE}/move`,
56
+ handle: async (ctx) => {
57
+ const body = await readJson(ctx.req);
58
+ await movePage(manualDir(ctx), ctx.params.id ?? "", {
59
+ parentId: optionalString(body.parentId) ?? null,
60
+ index: optionalNumber(body.index) ?? 0,
61
+ });
62
+ return json({ ok: true });
63
+ },
64
+ },
65
+ {
66
+ method: "GET",
67
+ path: "/api/manuals/:slug/trash",
68
+ handle: async (ctx) => json(await listTrash(manualDir(ctx))),
69
+ },
70
+ {
71
+ method: "POST",
72
+ path: "/api/manuals/:slug/trash/:trashId/restore",
73
+ handle: async (ctx) => {
74
+ await restorePage(manualDir(ctx), ctx.params.trashId ?? "");
75
+ return json({ ok: true });
76
+ },
77
+ },
78
+ ];
79
+ //# sourceMappingURL=pages.js.map
@@ -0,0 +1,2 @@
1
+ import type { Route } from "../router.js";
2
+ export declare const versionRoutes: Route[];
@@ -0,0 +1,43 @@
1
+ import { listVersions, publishVersion, readVersion, restoreVersion, saveVersion } from "../../core/index.js";
2
+ import { HttpError, json, readJson } from "../http.js";
3
+ import { manualDir, optionalString } from "../workspace.js";
4
+ const VERSIONS = "/api/manuals/:slug/pages/:id/versions";
5
+ export const versionRoutes = [
6
+ {
7
+ method: "GET",
8
+ path: VERSIONS,
9
+ handle: async (ctx) => json(await listVersions(manualDir(ctx), ctx.params.id ?? "")),
10
+ },
11
+ {
12
+ method: "POST",
13
+ path: VERSIONS,
14
+ handle: async (ctx) => {
15
+ const body = await readJson(ctx.req);
16
+ return json(await saveVersion(manualDir(ctx), ctx.params.id ?? "", optionalString(body.note) ?? ""), 201);
17
+ },
18
+ },
19
+ {
20
+ method: "GET",
21
+ path: `${VERSIONS}/:versionId`,
22
+ handle: async (ctx) => json(await readVersion(manualDir(ctx), ctx.params.id ?? "", ctx.params.versionId ?? "")),
23
+ },
24
+ {
25
+ method: "POST",
26
+ path: `${VERSIONS}/:versionId/restore`,
27
+ handle: async (ctx) => json(await restoreVersion(manualDir(ctx), ctx.params.id ?? "", ctx.params.versionId ?? "")),
28
+ },
29
+ {
30
+ // { versionId: "<id>" } escolhe a versão do manual final; { versionId: null } volta ao texto atual.
31
+ method: "PUT",
32
+ path: "/api/manuals/:slug/pages/:id/published",
33
+ handle: async (ctx) => {
34
+ const body = await readJson(ctx.req);
35
+ const versionId = body.versionId === null ? null : optionalString(body.versionId);
36
+ if (versionId === undefined)
37
+ throw new HttpError(400, "Informe versionId (texto ou null).");
38
+ await publishVersion(manualDir(ctx), ctx.params.id ?? "", versionId);
39
+ return json({ ok: true });
40
+ },
41
+ },
42
+ ];
43
+ //# sourceMappingURL=versions.js.map
@@ -0,0 +1,3 @@
1
+ import type { Route } from "../router.js";
2
+ /** Arquivos do editor. Caminho que não é arquivo cai no index.html (aplicação de página única). */
3
+ export declare const webRoutes: Route[];
@@ -0,0 +1,29 @@
1
+ import { stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { resolveInside } from "../../core/index.js";
4
+ import { file, HttpError } from "../http.js";
5
+ /** Arquivos do editor. Caminho que não é arquivo cai no index.html (aplicação de página única). */
6
+ export const webRoutes = [
7
+ {
8
+ method: "GET",
9
+ path: "/*",
10
+ handle: async ({ opts, rest }) => {
11
+ // Chamada de API errada não pode receber o index.html do editor como resposta.
12
+ if (!opts.webDir || rest === "api" || rest.startsWith("api/"))
13
+ throw new HttpError(404, "Não encontrado.");
14
+ const target = resolveInside(opts.webDir, rest || "index.html");
15
+ const isFile = await stat(target).then((s) => s.isFile(), () => false);
16
+ // Só os arquivos com hash no nome (assets/) podem ficar em cache; o index.html muda a cada build.
17
+ if (isFile && rest.startsWith("assets/"))
18
+ return file(target, "max-age=31536000, immutable");
19
+ if (isFile)
20
+ return file(target);
21
+ const index = path.join(opts.webDir, "index.html");
22
+ const hasIndex = await stat(index).then((s) => s.isFile(), () => false);
23
+ if (!hasIndex)
24
+ throw new HttpError(500, "O editor não foi encontrado: rode npm run build.", "web-missing");
25
+ return file(index);
26
+ },
27
+ },
28
+ ];
29
+ //# sourceMappingURL=web.js.map
@@ -0,0 +1,6 @@
1
+ import type { IncomingMessage } from "node:http";
2
+ /**
3
+ * O Studio só atende o próprio computador. Checar Host e Origin (que deve ser igual ao Host) impede que um site aberto
4
+ * no navegador use o Studio (DNS rebinding e requisições de outra origem).
5
+ */
6
+ export declare function assertLocalRequest(req: IncomingMessage): void;