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,107 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { assetFile } from "./assets.js";
5
+ import { ExplicaiError } from "./errors.js";
6
+ import { imageSize } from "./export/imageSize.js";
7
+ import { exists } from "./fs.js";
8
+ import { ASSETS_DIR } from "./manuals.js";
9
+ const FORMATS = { ".png": "png", ".jpg": "jpeg", ".jpeg": "jpeg", ".webp": "webp" };
10
+ const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
11
+ /** Blocos do PNG que carregam metadados (EXIF e texto): não entram no arquivo tarjado. */
12
+ const PNG_METADATA = new Set(["eXIf", "tEXt", "zTXt", "iTXt"]);
13
+ const invalid = (why) => new ExplicaiError("redact-invalid", `A imagem tarjada não pôde ser gravada: ${why}.`, { why });
14
+ /** PNG sem metadados e sem nenhum byte depois do IEND (o erro do aCropalypse). */
15
+ function cleanPng(b) {
16
+ if (!b.subarray(0, 8).equals(PNG_SIGNATURE))
17
+ throw invalid("o conteúdo não é um PNG");
18
+ const kept = [PNG_SIGNATURE];
19
+ for (let i = 8; i + 12 <= b.length;) {
20
+ const end = i + 12 + b.readUInt32BE(i);
21
+ if (end > b.length)
22
+ break;
23
+ const type = b.toString("ascii", i + 4, i + 8);
24
+ if (!PNG_METADATA.has(type))
25
+ kept.push(b.subarray(i, end));
26
+ if (type === "IEND")
27
+ return Buffer.concat(kept);
28
+ i = end;
29
+ }
30
+ throw invalid("o PNG está incompleto");
31
+ }
32
+ // shortcut: JPEG e WebP vêm do canvas do navegador, que não grava EXIF; aqui só recusa o que trouxer.
33
+ // Quando outro cliente puder enviar, troque a recusa por limpeza dos segmentos APP1/EXIF.
34
+ function cleanJpeg(b) {
35
+ const end = b.lastIndexOf(Buffer.from([0xff, 0xd9]));
36
+ if (b[0] !== 0xff || b[1] !== 0xd8 || end < 0)
37
+ throw invalid("o conteúdo não é um JPEG");
38
+ const clean = b.subarray(0, end + 2);
39
+ if (clean.includes("Exif\0\0", 0, "latin1"))
40
+ throw invalid("o arquivo traz EXIF");
41
+ return clean;
42
+ }
43
+ function cleanWebp(b) {
44
+ if (b.length < 12 || b.toString("ascii", 0, 4) !== "RIFF" || b.toString("ascii", 8, 12) !== "WEBP") {
45
+ throw invalid("o conteúdo não é um WebP");
46
+ }
47
+ const total = b.readUInt32LE(4) + 8;
48
+ if (total > b.length)
49
+ throw invalid("o WebP está incompleto");
50
+ const clean = b.subarray(0, total);
51
+ if (clean.includes("EXIF", 12, "latin1"))
52
+ throw invalid("o arquivo traz EXIF");
53
+ return clean;
54
+ }
55
+ /**
56
+ * Troca o arquivo de `assets/` pela versão com as tarjas já queimadas (o navegador desenha e envia).
57
+ * Valida o formato e que a imagem abre, grava num temporário e renomeia por cima do caminho de sempre,
58
+ * então Markdown e marcações seguem valendo. O original não vai para a lixeira: some, junto com
59
+ * qualquer cópia idêntica dele na pasta de imagens sem uso.
60
+ */
61
+ export async function redactAsset(dir, assetPath, data) {
62
+ const target = assetFile(dir, assetPath);
63
+ const format = FORMATS[path.extname(target).toLowerCase()];
64
+ if (!format)
65
+ throw new ExplicaiError("redact-type", "Só dá para ocultar dados em PNG, JPEG ou WebP.");
66
+ if (!(await exists(target)))
67
+ throw new ExplicaiError("redact-missing", `A imagem ${assetPath} não existe.`, { asset: assetPath });
68
+ const original = await readFile(target);
69
+ const clean = { png: cleanPng, jpeg: cleanJpeg, webp: cleanWebp }[format](Buffer.from(data));
70
+ const tmp = `${target}.${randomUUID()}.tmp`;
71
+ try {
72
+ await writeFile(tmp, clean);
73
+ const after = await imageSize(tmp);
74
+ // tamanho livre: recortar e reduzir resolução (T-118, T-119) reusam esta troca
75
+ if (!after)
76
+ throw invalid("a imagem não pôde ser lida");
77
+ await rename(tmp, target);
78
+ }
79
+ finally {
80
+ await rm(tmp, { force: true });
81
+ }
82
+ await dropTrashedCopies(dir, path.basename(target), original);
83
+ }
84
+ /** Cópias idênticas ao original em `.lixeira/imagens-*` (imagens sem uso) também são apagadas. */
85
+ async function dropTrashedCopies(dir, name, original) {
86
+ const trash = path.join(dir, ".lixeira");
87
+ if (!(await exists(trash)))
88
+ return;
89
+ for (const folder of await readdir(trash)) {
90
+ const copy = path.join(trash, folder, ASSETS_DIR, name);
91
+ if (folder.startsWith("imagens-") &&
92
+ (await stat(copy).catch(() => null))?.isFile() &&
93
+ (await readFile(copy)).equals(original)) {
94
+ await rm(copy);
95
+ }
96
+ }
97
+ }
98
+ /** A pasta do manual está dentro de um repositório git? Procura `.git` na pasta e nas de cima. */
99
+ export async function insideGitRepo(dir) {
100
+ for (let cur = path.resolve(dir);; cur = path.dirname(cur)) {
101
+ if (await exists(path.join(cur, ".git")))
102
+ return true;
103
+ if (path.dirname(cur) === cur)
104
+ return false;
105
+ }
106
+ }
107
+ //# sourceMappingURL=redact.js.map
@@ -0,0 +1,5 @@
1
+ export declare function slugify(title: string): string;
2
+ /** Texto obrigatório, sem espaços nas pontas. `what` entra na mensagem: "O título da página não pode ficar vazio." */
3
+ export declare function required(value: string, what: string): string;
4
+ /** decodeURI que não lança: um `%` solto num link ou numa imagem (`100%.png`) é texto comum, não motivo para a exportação falhar. */
5
+ export declare function decodeUri(text: string): string;
@@ -0,0 +1,28 @@
1
+ export function slugify(title) {
2
+ const slug = title
3
+ .normalize("NFD")
4
+ .replace(/[̀-ͯ]/g, "")
5
+ .toLowerCase()
6
+ .replace(/[^a-z0-9]+/g, "-")
7
+ .replace(/^-+|-+$/g, "")
8
+ .slice(0, 60)
9
+ .replace(/-+$/, "");
10
+ return slug || "sem-titulo";
11
+ }
12
+ /** Texto obrigatório, sem espaços nas pontas. `what` entra na mensagem: "O título da página não pode ficar vazio." */
13
+ export function required(value, what) {
14
+ const v = value.trim();
15
+ if (!v)
16
+ throw new Error(`O ${what} não pode ficar vazio.`);
17
+ return v;
18
+ }
19
+ /** decodeURI que não lança: um `%` solto num link ou numa imagem (`100%.png`) é texto comum, não motivo para a exportação falhar. */
20
+ export function decodeUri(text) {
21
+ try {
22
+ return decodeURI(text);
23
+ }
24
+ catch {
25
+ return text;
26
+ }
27
+ }
28
+ //# sourceMappingURL=text.js.map
@@ -0,0 +1,25 @@
1
+ /** Páginas excluídas ficam aqui até serem restauradas ou apagadas à mão. */
2
+ export declare const TRASH_DIR = ".lixeira";
3
+ export interface TrashItem {
4
+ trashId: string;
5
+ /** Id da página principal, para abrir de novo depois de restaurar. */
6
+ pageId: string;
7
+ title: string;
8
+ /** Quantas páginas voltam ao restaurar (a página mais as subpáginas). */
9
+ pages: number;
10
+ deletedAt: string;
11
+ }
12
+ /** O que está na lixeira, da exclusão mais recente para a mais antiga. */
13
+ export declare function listTrash(dir: string): Promise<TrashItem[]>;
14
+ /**
15
+ * Tira a página (e as subpáginas) da árvore e move os arquivos para a lixeira do manual.
16
+ * Devolve o id da lixeira, usado para desfazer. Imagens ficam em assets/, porque outras páginas podem usar.
17
+ */
18
+ export declare function removePage(dir: string, id: string): Promise<{
19
+ trashId: string;
20
+ }>;
21
+ /**
22
+ * Devolve a página ao lugar de onde saiu. Se o pai também foi excluído, ela volta para a raiz.
23
+ * Se outro arquivo ocupou o nome nesse meio tempo, ela ganha um nome novo.
24
+ */
25
+ export declare function restorePage(dir: string, trashId: string): Promise<void>;
@@ -0,0 +1,84 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdir, readdir, rename, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { splitFrontmatter } from "./frontmatter.js";
5
+ import { atomicWrite, exists, readText, relativePosix, resolveInside, uniquePath } from "./fs.js";
6
+ import { PAGES_DIR, updateManual } from "./manuals.js";
7
+ import { childrenOf, clampIndex, findIn, findNode, flatten } from "./tree.js";
8
+ /** Páginas excluídas ficam aqui até serem restauradas ou apagadas à mão. */
9
+ export const TRASH_DIR = ".lixeira";
10
+ /** O que está na lixeira, da exclusão mais recente para a mais antiga. */
11
+ export async function listTrash(dir) {
12
+ const root = path.join(dir, TRASH_DIR);
13
+ if (!(await exists(root)))
14
+ return [];
15
+ const items = [];
16
+ for (const trashId of await readdir(root)) {
17
+ const entryFile = path.join(root, trashId, "entry.json");
18
+ if (!(await exists(entryFile)))
19
+ continue;
20
+ try {
21
+ const entry = JSON.parse(await readText(entryFile));
22
+ const pageFile = path.join(root, trashId, entry.node.file);
23
+ const data = (await exists(pageFile)) ? splitFrontmatter(await readText(pageFile)).data : {};
24
+ const title = typeof data.title === "string" ? data.title : "";
25
+ items.push({ trashId, pageId: entry.node.id, title, pages: flatten([entry.node]).length, deletedAt: entry.deletedAt });
26
+ }
27
+ catch {
28
+ // entry.json quebrado ou de forma errada: pula o item em vez de esconder a lixeira toda
29
+ }
30
+ }
31
+ return items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
32
+ }
33
+ /**
34
+ * Tira a página (e as subpáginas) da árvore e move os arquivos para a lixeira do manual.
35
+ * Devolve o id da lixeira, usado para desfazer. Imagens ficam em assets/, porque outras páginas podem usar.
36
+ */
37
+ export async function removePage(dir, id) {
38
+ const entry = await updateManual(dir, (meta) => {
39
+ const { node, siblings } = findNode(meta.pages, id);
40
+ const index = siblings.indexOf(node);
41
+ siblings.splice(index, 1);
42
+ return { node, parentId: parentOf(meta.pages, siblings), index, deletedAt: new Date().toISOString() };
43
+ });
44
+ const trashId = randomUUID();
45
+ const trash = path.join(dir, TRASH_DIR, trashId);
46
+ await mkdir(path.join(trash, PAGES_DIR), { recursive: true });
47
+ for (const n of flatten([entry.node])) {
48
+ const from = resolveInside(dir, n.file);
49
+ if (await exists(from))
50
+ await rename(from, path.join(trash, n.file));
51
+ }
52
+ await atomicWrite(path.join(trash, "entry.json"), `${JSON.stringify(entry, null, 2)}\n`);
53
+ return { trashId };
54
+ }
55
+ /**
56
+ * Devolve a página ao lugar de onde saiu. Se o pai também foi excluído, ela volta para a raiz.
57
+ * Se outro arquivo ocupou o nome nesse meio tempo, ela ganha um nome novo.
58
+ */
59
+ export async function restorePage(dir, trashId) {
60
+ const trash = resolveInside(path.join(dir, TRASH_DIR), trashId);
61
+ const entry = JSON.parse(await readText(path.join(trash, "entry.json")));
62
+ for (const n of flatten([entry.node])) {
63
+ const from = path.join(trash, n.file);
64
+ let to = resolveInside(dir, n.file);
65
+ if (await exists(to)) {
66
+ to = await uniquePath(path.join(dir, PAGES_DIR), path.basename(n.file, ".md"), ".md");
67
+ n.file = relativePosix(dir, to);
68
+ }
69
+ if (await exists(from))
70
+ await rename(from, to);
71
+ }
72
+ await updateManual(dir, (meta) => {
73
+ const parentId = entry.parentId && findIn(meta.pages, entry.parentId) ? entry.parentId : null;
74
+ const siblings = childrenOf(meta.pages, parentId);
75
+ siblings.splice(clampIndex(entry.index, siblings.length), 0, entry.node);
76
+ });
77
+ await rm(trash, { recursive: true, force: true });
78
+ }
79
+ function parentOf(nodes, siblings) {
80
+ if (siblings === nodes)
81
+ return null;
82
+ return flatten(nodes).find((n) => n.children === siblings)?.id ?? null;
83
+ }
84
+ //# sourceMappingURL=trash.js.map
@@ -0,0 +1,13 @@
1
+ import type { PageNode } from "./types.js";
2
+ export interface Located {
3
+ node: PageNode;
4
+ /** O array que contém o nó, para inserir ou remover sem buscar de novo. */
5
+ siblings: PageNode[];
6
+ }
7
+ export declare function findNode(nodes: PageNode[], id: string): Located;
8
+ export declare function findIn(nodes: PageNode[], id: string): Located | undefined;
9
+ /** Lista de filhos onde inserir: os da página `parentId`, ou a raiz quando é null. */
10
+ export declare function childrenOf(nodes: PageNode[], parentId: string | null | undefined): PageNode[];
11
+ /** Percorre a árvore em ordem de leitura (pai antes dos filhos). */
12
+ export declare function flatten(nodes: PageNode[]): PageNode[];
13
+ export declare function clampIndex(index: number | undefined, length: number): number;
@@ -0,0 +1,28 @@
1
+ export function findNode(nodes, id) {
2
+ const found = findIn(nodes, id);
3
+ if (!found)
4
+ throw new Error(`Página não encontrada: ${id}`);
5
+ return found;
6
+ }
7
+ export function findIn(nodes, id) {
8
+ for (const node of nodes) {
9
+ if (node.id === id)
10
+ return { node, siblings: nodes };
11
+ const inner = findIn(node.children, id);
12
+ if (inner)
13
+ return inner;
14
+ }
15
+ return undefined;
16
+ }
17
+ /** Lista de filhos onde inserir: os da página `parentId`, ou a raiz quando é null. */
18
+ export function childrenOf(nodes, parentId) {
19
+ return parentId ? findNode(nodes, parentId).node.children : nodes;
20
+ }
21
+ /** Percorre a árvore em ordem de leitura (pai antes dos filhos). */
22
+ export function flatten(nodes) {
23
+ return nodes.flatMap((n) => [n, ...flatten(n.children)]);
24
+ }
25
+ export function clampIndex(index, length) {
26
+ return index === undefined ? length : Math.max(0, Math.min(index, length));
27
+ }
28
+ //# sourceMappingURL=tree.js.map
@@ -0,0 +1,111 @@
1
+ /** Formato em disco de um manual. Ver docs/manual-format.md. */
2
+ export declare const FORMAT_VERSION = 1;
3
+ /** Nó da árvore de páginas guardada em manual.json. O título fica só no frontmatter da página. */
4
+ export interface PageNode {
5
+ id: string;
6
+ /** Caminho relativo à pasta do manual, sempre em `pages/`. */
7
+ file: string;
8
+ /** Versão escolhida para o manual final (arquivo em `.versoes/<id>/`). Sem ela, vale o texto atual. */
9
+ published?: string;
10
+ children: PageNode[];
11
+ }
12
+ export interface ManualMeta {
13
+ version: typeof FORMAT_VERSION;
14
+ title: string;
15
+ /** Tag BCP 47, como `pt-BR` ou `en-US`. */
16
+ language: string;
17
+ /** Aparência do manual exportado. Sem ela, vale a cor da marca. */
18
+ theme?: ManualTheme;
19
+ pages: PageNode[];
20
+ }
21
+ /** Onde o manual exportado tem título: a capa, o título da página e os níveis 1 a 3 do editor. */
22
+ export declare const HEADING_SLOTS: readonly ["cover", "page", "h1", "h2", "h3"];
23
+ export type HeadingSlot = (typeof HEADING_SLOTS)[number];
24
+ /**
25
+ * Cores do manual exportado, todas em hexadecimal (#2b4fe0). Campo ausente = cor da marca.
26
+ * Texto, títulos e destaque precisam de contraste de 4,5:1 com o branco.
27
+ */
28
+ export interface ManualTheme {
29
+ /** Destaque: links, números dos passos e marcações sobre os prints. */
30
+ accent?: string;
31
+ /** Texto do manual. */
32
+ text?: string;
33
+ /** Marca-texto: capa, "Resultado:" e destaque das imagens. */
34
+ highlight?: string;
35
+ /** Cor de cada tipo de título. Sem escolha, saem pretos. */
36
+ headings?: Partial<Record<HeadingSlot, string>>;
37
+ /** Fontes do manual, pelo nome da lista de `export/fonts.ts` (`inter`, `lora`…). Sem escolha: Atkinson e Archivo. */
38
+ fonts?: {
39
+ text?: string;
40
+ headings?: string;
41
+ };
42
+ /** Logo do cliente na capa e no topo do sumário: `assets/<arquivo>`, PNG, JPG ou SVG de até 1 MB. */
43
+ logo?: string;
44
+ }
45
+ export interface Manual {
46
+ /** Caminho absoluto da pasta do manual. */
47
+ dir: string;
48
+ meta: ManualMeta;
49
+ }
50
+ /** Etapa da página no fluxo de aprovação. Sem status no arquivo, a página é rascunho. */
51
+ export declare const PAGE_STATUSES: readonly ["rascunho", "revisao", "aprovado", "publicado"];
52
+ export type PageStatus = (typeof PAGE_STATUSES)[number];
53
+ export interface Page {
54
+ id: string;
55
+ title: string;
56
+ status: PageStatus;
57
+ /** Conteúdo em Markdown, sem o frontmatter. */
58
+ body: string;
59
+ file: string;
60
+ /** Impressão do arquivo no disco (mtime+tamanho). Muda quando alguém edita fora do Studio. */
61
+ version?: string;
62
+ }
63
+ /** Coordenadas normalizadas de 0 a 1, relativas à largura e à altura da imagem. */
64
+ export interface Point {
65
+ x: number;
66
+ y: number;
67
+ }
68
+ export interface Rect extends Point {
69
+ w: number;
70
+ h: number;
71
+ }
72
+ export interface Marker extends Point {
73
+ id: string;
74
+ /** Número exibido no marcador e na legenda. */
75
+ n: number;
76
+ label?: string;
77
+ }
78
+ export interface Highlight extends Rect {
79
+ id: string;
80
+ }
81
+ export interface Arrow {
82
+ id: string;
83
+ from: Point;
84
+ to: Point;
85
+ }
86
+ export type HotspotTarget = {
87
+ type: "page";
88
+ pageId: string;
89
+ } | {
90
+ type: "anchor";
91
+ pageId: string;
92
+ anchor: string;
93
+ } | {
94
+ type: "url";
95
+ href: string;
96
+ };
97
+ export interface Hotspot extends Rect {
98
+ id: string;
99
+ target: HotspotTarget;
100
+ }
101
+ /** Tamanho de exibição da imagem: pequena, média ou larga. Sem o campo, a imagem aparece como sempre. */
102
+ export type ImageDisplay = "s" | "m" | "full";
103
+ /** Conteúdo do JSON que fica ao lado de cada imagem (`assets/foto.png.json`). */
104
+ export interface Annotations {
105
+ version: typeof FORMAT_VERSION;
106
+ display?: ImageDisplay;
107
+ markers: Marker[];
108
+ highlights: Highlight[];
109
+ arrows: Arrow[];
110
+ hotspots: Hotspot[];
111
+ }
@@ -0,0 +1,7 @@
1
+ /** Formato em disco de um manual. Ver docs/manual-format.md. */
2
+ export const FORMAT_VERSION = 1;
3
+ /** Onde o manual exportado tem título: a capa, o título da página e os níveis 1 a 3 do editor. */
4
+ export const HEADING_SLOTS = ["cover", "page", "h1", "h2", "h3"];
5
+ /** Etapa da página no fluxo de aprovação. Sem status no arquivo, a página é rascunho. */
6
+ export const PAGE_STATUSES = ["rascunho", "revisao", "aprovado", "publicado"];
7
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1,27 @@
1
+ import type { Page } from "./types.js";
2
+ /** Versões guardadas de cada página: `.versoes/<id da página>/<data>.md`, uma cópia completa por versão. */
3
+ export declare const VERSIONS_DIR = ".versoes";
4
+ export interface VersionInfo {
5
+ id: string;
6
+ title: string;
7
+ /** Nome que a pessoa deu ao guardar ("Revisado pelo suporte"). Pode ser vazio. */
8
+ note: string;
9
+ savedAt: string;
10
+ /** É a versão escolhida para o manual final. */
11
+ published: boolean;
12
+ }
13
+ export interface Version extends VersionInfo {
14
+ body: string;
15
+ }
16
+ /** Guarda o texto atual da página como uma versão nova. */
17
+ export declare function saveVersion(dir: string, pageId: string, note?: string): Promise<VersionInfo>;
18
+ /** Versões da página, da mais recente para a mais antiga. */
19
+ export declare function listVersions(dir: string, pageId: string): Promise<VersionInfo[]>;
20
+ export declare function readVersion(dir: string, pageId: string, versionId: string): Promise<Version>;
21
+ /**
22
+ * Volta a página para o texto da versão. O texto que estava antes vira uma versão
23
+ * ("Antes de restaurar"), para restaurar nunca apagar nada.
24
+ */
25
+ export declare function restoreVersion(dir: string, pageId: string, versionId: string): Promise<Page>;
26
+ /** Escolhe a versão que vai para o manual final. `null` volta a valer o texto atual da página. */
27
+ export declare function publishVersion(dir: string, pageId: string, versionId: string | null): Promise<void>;
@@ -0,0 +1,82 @@
1
+ import { mkdir, readdir } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { joinFrontmatter, splitFrontmatter } from "./frontmatter.js";
4
+ import { atomicWrite, exists, readText, resolveInside, uniquePath } from "./fs.js";
5
+ import { readManual, updateManual } from "./manuals.js";
6
+ import { readPage, updatePage } from "./pages.js";
7
+ import { findNode } from "./tree.js";
8
+ /** Versões guardadas de cada página: `.versoes/<id da página>/<data>.md`, uma cópia completa por versão. */
9
+ export const VERSIONS_DIR = ".versoes";
10
+ const pageVersionsDir = (dir, pageId) => resolveInside(dir, path.posix.join(VERSIONS_DIR, pageId));
11
+ const versionFile = (dir, pageId, versionId) => resolveInside(pageVersionsDir(dir, pageId), `${versionId}.md`);
12
+ /** Guarda o texto atual da página como uma versão nova. */
13
+ export async function saveVersion(dir, pageId, note = "") {
14
+ const page = await readPage(dir, pageId);
15
+ const savedAt = new Date().toISOString();
16
+ const folder = pageVersionsDir(dir, pageId);
17
+ await mkdir(folder, { recursive: true });
18
+ // A data vira o nome do arquivo: a pasta fica em ordem e legível sem o Studio.
19
+ const file = await uniquePath(folder, savedAt.replace(/[:.]/g, "-"), ".md");
20
+ const clean = note.trim().slice(0, 200);
21
+ await atomicWrite(file, joinFrontmatter({ id: page.id, title: page.title, savedAt, note: clean }, page.body));
22
+ return { id: path.basename(file, ".md"), title: page.title, note: clean, savedAt, published: false };
23
+ }
24
+ /** Versões da página, da mais recente para a mais antiga. */
25
+ export async function listVersions(dir, pageId) {
26
+ const { node } = findNode((await readManual(dir)).pages, pageId);
27
+ const folder = pageVersionsDir(dir, pageId);
28
+ if (!(await exists(folder)))
29
+ return [];
30
+ const list = [];
31
+ for (const name of await readdir(folder)) {
32
+ if (!name.endsWith(".md"))
33
+ continue;
34
+ const { body: _, ...info } = await readVersionFile(path.join(folder, name), node.published);
35
+ list.push(info);
36
+ }
37
+ return list.sort((a, b) => b.savedAt.localeCompare(a.savedAt));
38
+ }
39
+ export async function readVersion(dir, pageId, versionId) {
40
+ const { node } = findNode((await readManual(dir)).pages, pageId);
41
+ return readVersionFile(versionFile(dir, pageId, versionId), node.published);
42
+ }
43
+ /**
44
+ * Volta a página para o texto da versão. O texto que estava antes vira uma versão
45
+ * ("Antes de restaurar"), para restaurar nunca apagar nada.
46
+ */
47
+ export async function restoreVersion(dir, pageId, versionId) {
48
+ const version = await readVersion(dir, pageId, versionId);
49
+ const current = await readPage(dir, pageId);
50
+ if (current.title !== version.title || current.body !== version.body) {
51
+ await saveVersion(dir, pageId, "Antes de restaurar");
52
+ }
53
+ return updatePage(dir, pageId, { title: version.title, body: version.body });
54
+ }
55
+ /** Escolhe a versão que vai para o manual final. `null` volta a valer o texto atual da página. */
56
+ export async function publishVersion(dir, pageId, versionId) {
57
+ if (versionId !== null)
58
+ await readVersion(dir, pageId, versionId);
59
+ await updateManual(dir, (meta) => {
60
+ const { node } = findNode(meta.pages, pageId);
61
+ if (versionId === null)
62
+ delete node.published;
63
+ else
64
+ node.published = versionId;
65
+ });
66
+ }
67
+ async function readVersionFile(file, published) {
68
+ let text;
69
+ try {
70
+ text = await readText(file);
71
+ }
72
+ catch (err) {
73
+ if (err.code === "ENOENT")
74
+ throw new Error(`Versão não encontrada: ${path.basename(file, ".md")}`);
75
+ throw err;
76
+ }
77
+ const { data, body } = splitFrontmatter(text);
78
+ const id = path.basename(file, ".md");
79
+ const str = (v) => (typeof v === "string" ? v : "");
80
+ return { id, title: str(data.title), note: str(data.note), savedAt: str(data.savedAt), published: id === published, body };
81
+ }
82
+ //# sourceMappingURL=versions.js.map
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Página de erro para quem abre o Studio no navegador e o servidor não consegue entregar o editor.
3
+ * Sem dependências: mesma composição do ErrorPage do editor (web/src/components/ErrorPage.tsx); mexeu lá, alinhe aqui.
4
+ * O trecho entre asteriscos no título leva o marca-texto.
5
+ */
6
+ type Lang = "pt" | "en";
7
+ export declare function pickLang(acceptLanguage: string | undefined): Lang;
8
+ export declare function errorPage(status: number, code: string, lang: Lang): string;
9
+ export {};
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Página de erro para quem abre o Studio no navegador e o servidor não consegue entregar o editor.
3
+ * Sem dependências: mesma composição do ErrorPage do editor (web/src/components/ErrorPage.tsx); mexeu lá, alinhe aqui.
4
+ * O trecho entre asteriscos no título leva o marca-texto.
5
+ */
6
+ import { FONTS, fontFaceCss } from "../core/export/fonts.js";
7
+ const TEXTS = {
8
+ "web-missing": {
9
+ pt: [
10
+ "O editor *não foi encontrado*",
11
+ "O servidor do Studio está no ar, mas a pasta do editor está vazia ou não existe. Rode npm run build (ou instale o pacote de novo) e abra esta página outra vez.",
12
+ ],
13
+ en: [
14
+ "The editor *was not found*",
15
+ "The Studio server is running, but the editor folder is empty or missing. Run npm run build (or reinstall the package) and open this page again.",
16
+ ],
17
+ },
18
+ "forbidden-host": {
19
+ pt: ["Acesso só *deste computador*", "O Studio só aceita pedidos deste computador. Abra-o por localhost ou 127.0.0.1."],
20
+ en: [
21
+ "Access from *this computer* only",
22
+ "The Studio only accepts requests from this computer. Open it through localhost or 127.0.0.1.",
23
+ ],
24
+ },
25
+ "forbidden-origin": {
26
+ pt: ["Acesso só *deste computador*", "O Studio só aceita pedidos deste computador. Abra-o por localhost ou 127.0.0.1."],
27
+ en: [
28
+ "Access from *this computer* only",
29
+ "The Studio only accepts requests from this computer. Open it through localhost or 127.0.0.1.",
30
+ ],
31
+ },
32
+ "malformed-url": {
33
+ pt: ["Endereço *malformado*", "Este endereço tem um % que o Studio não entende. Volte para o início e escolha o manual."],
34
+ en: ["Malformed *address*", "This address has a % the Studio cannot read. Go back to the start and pick the manual."],
35
+ },
36
+ "not-found": {
37
+ pt: ["Página *não encontrada*", "Este endereço não existe no Studio. Volte para o início e escolha o manual."],
38
+ en: ["Page *not found*", "This address does not exist in the Studio. Go back to the start and pick the manual."],
39
+ },
40
+ error: {
41
+ pt: [
42
+ "O Studio *não conseguiu responder*",
43
+ "Algo deu errado no servidor. Recarregue a página; se continuar, feche o comando explicai no terminal e rode de novo.",
44
+ ],
45
+ en: [
46
+ "The Studio *could not respond*",
47
+ "Something went wrong on the server. Reload the page; if it keeps happening, stop the explicai command in the terminal and run it again.",
48
+ ],
49
+ },
50
+ };
51
+ const esc = (s) => s.replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c] ?? c);
52
+ export function pickLang(acceptLanguage) {
53
+ return /^\s*pt/i.test(acceptLanguage ?? "") ? "pt" : "en";
54
+ }
55
+ const UI = {
56
+ pt: { home: "Ir para o início", reload: "Recarregar", copy: "Copiar código", copied: "Copiado" },
57
+ en: { home: "Go to the start", reload: "Reload", copy: "Copy code", copied: "Copied" },
58
+ };
59
+ // As fontes da marca vêm da rota /api/fonts do próprio servidor (sem CDN); se não carregarem, cai na do sistema.
60
+ const FACES = FONTS.filter((f) => f.id === "atkinson-hyperlegible-next" || f.id === "archivo")
61
+ .map((f) => fontFaceCss(f).replaceAll("url(fonts/", "url(/api/fonts/"))
62
+ .join("");
63
+ // Mesmos valores de web/src/styles/tokens.css e do wordmark de base.css.
64
+ const CSS = `${FACES}
65
+ :root{color-scheme:light dark;--p:#f5f6fa;--t:#151a33;--g:#575e78;--c:#2b4fe0;--s:#fff;--l:#dce0eb;--m:rgba(255,212,59,.55);--a:#2b4fe0}
66
+ @media(prefers-color-scheme:dark){:root{--p:#0e1120;--t:#eceef7;--g:#9ca3bd;--c:#7088ff;--s:#0e1120;--l:#2a3050;--m:rgba(255,212,59,.3);--a:#ffd43b}}
67
+ *{box-sizing:border-box}
68
+ body{margin:0;min-height:100vh;display:flex;flex-direction:column;background:var(--p);color:var(--t);font:17px/1.5 "Atkinson Hyperlegible Next","Segoe UI",system-ui,sans-serif}
69
+ header,main{width:100%;max-width:64rem;margin:0 auto}
70
+ header{padding:1.25rem 1.25rem}
71
+ .brand{display:inline-flex;align-items:center;gap:.5rem;color:var(--t);text-decoration:none;min-height:44px}
72
+ .wordmark{font:800 21px/1 Archivo,"Arial Narrow",system-ui,sans-serif;font-stretch:112%;letter-spacing:-.035em}
73
+ .wordmark i{position:relative;display:inline-block;font-style:normal}
74
+ .wordmark i:after{content:"";position:absolute;left:.06em;bottom:.77em;width:.115em;height:.19em;background:var(--a);transform:skewX(-32deg);transform-origin:bottom left}
75
+ .sub{font:12px ui-monospace,Consolas,monospace;color:var(--g)}
76
+ main{margin:auto;padding:3rem 1.5rem;display:grid;justify-items:start;align-content:start;gap:1.25rem}
77
+ main>*{max-width:min(42rem,100%)}
78
+ @media(min-width:640px){header{padding-inline:2.25rem}main{padding-inline:2.5rem}}
79
+ h1{margin:0;font:800 clamp(2.25rem,7vw,3rem)/1.08 Archivo,"Arial Narrow",system-ui,sans-serif;font-stretch:108%;letter-spacing:-.025em;text-wrap:balance;outline:none}
80
+ mark{background:var(--m);color:inherit;padding:0 .25rem;margin:0 -.25rem;border-radius:2px;-webkit-box-decoration-break:clone;box-decoration-break:clone}
81
+ p.text{text-wrap:pretty;margin:0;max-width:65ch;font-size:1.125rem;color:var(--g)}
82
+ .actions{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1.25rem;padding-top:.25rem}
83
+ .btn{display:inline-flex;align-items:center;min-height:44px;padding:0 1.25rem;border:0;border-radius:3px;background:var(--c);color:var(--s);font:700 1rem "Atkinson Hyperlegible Next","Segoe UI",system-ui,sans-serif;text-decoration:none;cursor:pointer}
84
+ .link{min-height:44px;padding:0 .25rem;border:0;background:none;color:var(--c);font:700 1rem "Atkinson Hyperlegible Next","Segoe UI",system-ui,sans-serif;cursor:pointer}
85
+ .link:hover{text-decoration:underline;text-underline-offset:4px}
86
+ .btn:focus-visible,.link:focus-visible,.brand:focus-visible{outline:3px solid var(--t);outline-offset:2px}
87
+ .code{display:flex;align-items:center;gap:.25rem;max-width:100%;margin-top:.5rem;padding:.125rem .25rem .125rem .75rem;border:1px dashed var(--l);border-radius:3px;font:12px ui-monospace,Consolas,monospace;color:var(--g)}
88
+ .code code{min-width:0;padding:.375rem 0;overflow-wrap:anywhere}
89
+ .code button{flex:none;min-height:44px;padding:0 .5rem;border:0;background:none;color:var(--c);font:700 12px "Atkinson Hyperlegible Next","Segoe UI",system-ui,sans-serif;cursor:pointer}
90
+ .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)}
91
+ `;
92
+ const LOGO = `<svg width="26" height="26" viewBox="0 0 64 64" aria-hidden="true"><rect width="64" height="64" rx="16" fill="#2B4FE0"/><rect x="26" y="27" width="11" height="25" rx="2.5" fill="#fff"/><path d="M27.5 22 35.5 22 44.5 9.5 36.5 9.5Z" fill="#FFD43B" stroke="#FFD43B" stroke-width="1.5" stroke-linejoin="round"/></svg>`;
93
+ export function errorPage(status, code, lang) {
94
+ const [title, text] = (TEXTS[code] ?? TEXTS.error)?.[lang] ?? ["", ""];
95
+ const ui = UI[lang];
96
+ const plain = title.replaceAll("*", "");
97
+ const marked = esc(title).replace(/\*([^*]+)\*/g, "<mark>$1</mark>");
98
+ return `<!doctype html>
99
+ <html lang="${lang === "pt" ? "pt-BR" : "en"}">
100
+ <head>
101
+ <meta charset="utf-8">
102
+ <meta name="viewport" content="width=device-width, initial-scale=1">
103
+ <title>${esc(plain)} · Explicaí Studio</title>
104
+ <style>${CSS}</style>
105
+ </head>
106
+ <body>
107
+ <header><a class="brand" href="/" aria-label="Explicaí Studio">${LOGO}<span class="wordmark" aria-hidden="true">explica<i>ı</i></span><span class="sub" aria-hidden="true">studio</span></a></header>
108
+ <main role="alert">
109
+ <h1 id="t" tabindex="-1">${marked}</h1>
110
+ <p class="text">${esc(text)}</p>
111
+ <div class="actions"><a class="btn" href="/">${esc(ui.home)}</a><button class="link" type="button" onclick="location.reload()">${esc(ui.reload)}</button></div>
112
+ <div class="code"><code>HTTP ${status} ${esc(code)}</code><button type="button" id="c">${esc(ui.copy)}</button><span class="sr" role="status" id="s"></span></div>
113
+ </main>
114
+ <script>
115
+ document.getElementById("t").focus();
116
+ document.getElementById("c").onclick=function(){var b=this;navigator.clipboard&&navigator.clipboard.writeText("HTTP ${status} ${esc(code)}").then(function(){b.textContent=${JSON.stringify(ui.copied)};document.getElementById("s").textContent=${JSON.stringify(ui.copied)}})};
117
+ </script>
118
+ </body>
119
+ </html>
120
+ `;
121
+ }
122
+ //# sourceMappingURL=errorPage.js.map