explicai 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +72 -0
- package/LICENSE +21 -21
- package/README.md +87 -27
- package/dist/core/annotations.js +2 -0
- package/dist/core/assets.d.ts +2 -0
- package/dist/core/assets.js +51 -16
- package/dist/core/errors.d.ts +9 -0
- package/dist/core/errors.js +14 -0
- package/dist/core/export/annotationCss.d.ts +5 -0
- package/dist/core/export/annotationCss.js +26 -0
- package/dist/core/export/annotationShapes.d.ts +37 -0
- package/dist/core/export/annotationShapes.js +38 -0
- package/dist/core/export/callout.d.ts +11 -0
- package/dist/core/export/callout.js +26 -0
- package/dist/core/export/collect.d.ts +2 -2
- package/dist/core/export/collect.js +2 -2
- package/dist/core/export/color.d.ts +11 -0
- package/dist/core/export/color.js +33 -0
- package/dist/core/export/fonts.d.ts +35 -0
- package/dist/core/export/fonts.js +40 -0
- package/dist/core/export/headings.d.ts +7 -0
- package/dist/core/export/headings.js +17 -0
- package/dist/core/export/html.d.ts +1 -3
- package/dist/core/export/html.js +86 -39
- package/dist/core/export/imageSize.js +20 -3
- package/dist/core/export/index.d.ts +12 -6
- package/dist/core/export/index.js +106 -26
- package/dist/core/export/labels.d.ts +1 -0
- package/dist/core/export/labels.js +2 -0
- package/dist/core/export/logo.d.ts +10 -0
- package/dist/core/export/logo.js +29 -0
- package/dist/core/export/markdown.js +20 -11
- package/dist/core/export/pdf.js +2 -1
- package/dist/core/export/refs.d.ts +2 -2
- package/dist/core/export/refs.js +22 -9
- package/dist/core/export/textColors.d.ts +7 -0
- package/dist/core/export/textColors.js +18 -0
- package/dist/core/export/theme.d.ts +28 -0
- package/dist/core/export/theme.js +140 -0
- package/dist/core/fs.d.ts +4 -0
- package/dist/core/fs.js +13 -1
- package/dist/core/index.d.ts +5 -2
- package/dist/core/index.js +5 -2
- package/dist/core/manuals.d.ts +7 -1
- package/dist/core/manuals.js +29 -9
- package/dist/core/pages.d.ts +5 -0
- package/dist/core/pages.js +56 -15
- package/dist/core/redact.d.ts +9 -0
- package/dist/core/redact.js +107 -0
- package/dist/core/text.d.ts +2 -0
- package/dist/core/text.js +9 -0
- package/dist/core/trash.js +13 -8
- package/dist/core/types.d.ts +26 -1
- package/dist/core/types.js +2 -0
- package/dist/core/versions.js +3 -3
- package/dist/server/errorPage.d.ts +9 -0
- package/dist/server/errorPage.js +122 -0
- package/dist/server/http.d.ts +11 -3
- package/dist/server/http.js +65 -11
- package/dist/server/index.js +35 -5
- package/dist/server/routes/assets.js +21 -2
- package/dist/server/routes/export.js +18 -8
- package/dist/server/routes/fonts.d.ts +3 -0
- package/dist/server/routes/fonts.js +24 -0
- package/dist/server/routes/pages.js +3 -1
- package/dist/server/routes/web.js +7 -1
- package/dist/server/security.d.ts +1 -1
- package/dist/server/security.js +7 -6
- package/dist/web/assets/index-B7Gr83D6.js +262 -0
- package/dist/web/assets/index-DFBrhkII.css +1 -0
- package/dist/web/index.html +2 -3
- package/fonts/OFL-archivo.txt +93 -0
- package/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
- package/fonts/OFL-inter.txt +93 -0
- package/fonts/OFL-jetbrains-mono.txt +93 -0
- package/fonts/OFL-lora.txt +93 -0
- package/fonts/OFL-montserrat.txt +93 -0
- package/fonts/OFL-playfair-display.txt +93 -0
- package/fonts/OFL-source-sans-3.txt +93 -0
- package/fonts/OFL-source-serif-4.txt +93 -0
- package/fonts/archivo-latin-wght-normal.woff2 +0 -0
- package/fonts/atkinson-hyperlegible-next-latin-400-italic.woff2 +0 -0
- package/fonts/atkinson-hyperlegible-next-latin-400-normal.woff2 +0 -0
- package/fonts/atkinson-hyperlegible-next-latin-700-normal.woff2 +0 -0
- package/fonts/inter-latin-wght-italic.woff2 +0 -0
- package/fonts/inter-latin-wght-normal.woff2 +0 -0
- package/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
- package/fonts/lora-latin-wght-italic.woff2 +0 -0
- package/fonts/lora-latin-wght-normal.woff2 +0 -0
- package/fonts/montserrat-latin-wght-normal.woff2 +0 -0
- package/fonts/playfair-display-latin-wght-normal.woff2 +0 -0
- package/fonts/source-sans-3-latin-wght-italic.woff2 +0 -0
- package/fonts/source-sans-3-latin-wght-normal.woff2 +0 -0
- package/fonts/source-serif-4-latin-wght-italic.woff2 +0 -0
- package/fonts/source-serif-4-latin-wght-normal.woff2 +0 -0
- package/package.json +41 -7
- package/dist/cli.js.map +0 -1
- package/dist/core/annotations.js.map +0 -1
- package/dist/core/assets.js.map +0 -1
- package/dist/core/export/collect.js.map +0 -1
- package/dist/core/export/html.js.map +0 -1
- package/dist/core/export/imageSize.js.map +0 -1
- package/dist/core/export/index.js.map +0 -1
- package/dist/core/export/labels.js.map +0 -1
- package/dist/core/export/markdown.js.map +0 -1
- package/dist/core/export/pdf.js.map +0 -1
- package/dist/core/export/refs.js.map +0 -1
- package/dist/core/frontmatter.js.map +0 -1
- package/dist/core/fs.js.map +0 -1
- package/dist/core/index.js.map +0 -1
- package/dist/core/manuals.js.map +0 -1
- package/dist/core/pages.js.map +0 -1
- package/dist/core/text.js.map +0 -1
- package/dist/core/trash.js.map +0 -1
- package/dist/core/tree.js.map +0 -1
- package/dist/core/types.js.map +0 -1
- package/dist/core/versions.js.map +0 -1
- package/dist/server/http.js.map +0 -1
- package/dist/server/index.js.map +0 -1
- package/dist/server/router.js.map +0 -1
- package/dist/server/routes/assets.js.map +0 -1
- package/dist/server/routes/export.js.map +0 -1
- package/dist/server/routes/manuals.js.map +0 -1
- package/dist/server/routes/pages.js.map +0 -1
- package/dist/server/routes/versions.js.map +0 -1
- package/dist/server/routes/web.js.map +0 -1
- package/dist/server/security.js.map +0 -1
- package/dist/server/workspace.js.map +0 -1
- package/dist/web/assets/index-BXkBk-2Z.css +0 -1
- package/dist/web/assets/index-C02XKPHS.js +0 -260
package/dist/core/pages.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import {
|
|
2
|
+
import { rm, stat } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { ExplicaiError } from "./errors.js";
|
|
4
5
|
import { joinFrontmatter, splitFrontmatter } from "./frontmatter.js";
|
|
5
|
-
import { atomicWrite, relativePosix, resolveInside, uniquePath } from "./fs.js";
|
|
6
|
-
import { PAGES_DIR, readManual, updateManual } from "./manuals.js";
|
|
6
|
+
import { atomicWrite, readText, relativePosix, resolveInside, uniquePath } from "./fs.js";
|
|
7
|
+
import { inQueue, PAGES_DIR, readManual, updateManual } from "./manuals.js";
|
|
7
8
|
import { slugify } from "./text.js";
|
|
8
9
|
import { childrenOf, clampIndex, findIn, findNode, flatten } from "./tree.js";
|
|
9
10
|
import { PAGE_STATUSES } from "./types.js";
|
|
@@ -17,16 +18,35 @@ export async function addPage(dir, opts) {
|
|
|
17
18
|
body: opts.body ?? "",
|
|
18
19
|
file: await newPageFile(dir, title),
|
|
19
20
|
};
|
|
20
|
-
|
|
21
|
-
const siblings = childrenOf(meta.pages, opts.parentId);
|
|
22
|
-
siblings.splice(clampIndex(opts.index, siblings.length), 0, { id: page.id, file: page.file, children: [] });
|
|
23
|
-
});
|
|
21
|
+
// Arquivo primeiro: se gravar falhar, a árvore não aponta para o que não existe. Se a árvore falhar, desfaz o arquivo.
|
|
24
22
|
await writePageFile(dir, page);
|
|
23
|
+
page.version = await fileVersion(dir, page.file);
|
|
24
|
+
try {
|
|
25
|
+
await updateManual(dir, (meta) => {
|
|
26
|
+
const siblings = childrenOf(meta.pages, opts.parentId);
|
|
27
|
+
siblings.splice(clampIndex(opts.index, siblings.length), 0, { id: page.id, file: page.file, children: [] });
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
await rm(resolveInside(dir, page.file), { force: true });
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
25
34
|
return page;
|
|
26
35
|
}
|
|
27
36
|
export async function readPage(dir, id) {
|
|
28
37
|
const { node } = findNode((await readManual(dir)).pages, id);
|
|
29
|
-
return { id, ...(await readPageFile(dir, node.file)), file: node.file };
|
|
38
|
+
return { id, ...(await readPageFile(dir, node.file)), file: node.file, version: await fileVersion(dir, node.file) };
|
|
39
|
+
}
|
|
40
|
+
/** Versão do arquivo: mtime + tamanho. Basta para saber se alguém mexeu nele desde a última leitura. */
|
|
41
|
+
async function fileVersion(dir, file) {
|
|
42
|
+
const s = await stat(resolveInside(dir, file));
|
|
43
|
+
return `${s.mtimeMs}-${s.size}`;
|
|
44
|
+
}
|
|
45
|
+
/** Dentro da fila da pasta: recusa a gravação se o arquivo mudou no disco depois de `expected`. */
|
|
46
|
+
async function assertVersion(dir, file, expected) {
|
|
47
|
+
if ((await fileVersion(dir, file)) === expected)
|
|
48
|
+
return;
|
|
49
|
+
throw new ExplicaiError("disk-conflict", "Esta página foi alterada fora do Studio depois que você a abriu. Recarregue do disco ou mantenha a sua versão.");
|
|
30
50
|
}
|
|
31
51
|
/**
|
|
32
52
|
* Árvore do manual com o título de cada página (que fica no frontmatter). Lê o manual.json uma vez.
|
|
@@ -54,7 +74,7 @@ export async function readPageTree(dir) {
|
|
|
54
74
|
async function readPageFile(dir, file) {
|
|
55
75
|
let text;
|
|
56
76
|
try {
|
|
57
|
-
text = await
|
|
77
|
+
text = await readText(resolveInside(dir, file));
|
|
58
78
|
}
|
|
59
79
|
catch (err) {
|
|
60
80
|
if (err.code === "ENOENT") {
|
|
@@ -75,8 +95,10 @@ async function readPageFile(dir, file) {
|
|
|
75
95
|
/**
|
|
76
96
|
* Quando o título muda, o arquivo é renomeado para acompanhar, para a pasta continuar legível
|
|
77
97
|
* para pessoas e IAs. O id no frontmatter não muda, e é ele que as áreas clicáveis usam.
|
|
98
|
+
* `fileName`: "keep" grava o título no mesmo arquivo (digitação em andamento); "sync" renomeia
|
|
99
|
+
* se o nome do arquivo não combina com o título (ao parar de digitar ou sair da página).
|
|
78
100
|
*/
|
|
79
|
-
export async function updatePage(dir, id, patch) {
|
|
101
|
+
export async function updatePage(dir, id, patch, opts = {}) {
|
|
80
102
|
const page = await readPage(dir, id);
|
|
81
103
|
if (patch.status !== undefined && !PAGE_STATUSES.includes(patch.status)) {
|
|
82
104
|
throw new Error(`Status inválido: ${patch.status}. Use ${PAGE_STATUSES.join(", ")}.`);
|
|
@@ -87,10 +109,23 @@ export async function updatePage(dir, id, patch) {
|
|
|
87
109
|
...(patch.title === undefined ? {} : { title: patch.title.trim() }),
|
|
88
110
|
...(patch.body === undefined ? {} : { body: patch.body }),
|
|
89
111
|
};
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
112
|
+
const sameName = opts.fileName === "keep" ||
|
|
113
|
+
(opts.fileName === "sync" ? fileMatchesTitle(page.file, next.title) : slugify(next.title) === slugify(page.title));
|
|
114
|
+
const { ifMatch } = opts;
|
|
115
|
+
if (sameName) {
|
|
116
|
+
if (ifMatch === undefined)
|
|
117
|
+
await writePageFile(dir, next);
|
|
118
|
+
else {
|
|
119
|
+
await inQueue(dir, async () => {
|
|
120
|
+
await assertVersion(dir, page.file, ifMatch);
|
|
121
|
+
await writePageFile(dir, next);
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return { ...next, version: await fileVersion(dir, next.file) };
|
|
93
125
|
}
|
|
126
|
+
// shortcut: com renomeio, a checagem e a gravação não são atômicas (janela de milissegundos); upgrade se aparecer na prática.
|
|
127
|
+
if (ifMatch !== undefined)
|
|
128
|
+
await inQueue(dir, () => assertVersion(dir, page.file, ifMatch));
|
|
94
129
|
next.file = await newPageFile(dir, next.title);
|
|
95
130
|
await writePageFile(dir, next);
|
|
96
131
|
const others = await updateManual(dir, (meta) => {
|
|
@@ -99,7 +134,13 @@ export async function updatePage(dir, id, patch) {
|
|
|
99
134
|
});
|
|
100
135
|
await rm(resolveInside(dir, page.file), { force: true });
|
|
101
136
|
await relink(dir, others.map((n) => n.file), page.file, next.file);
|
|
102
|
-
return next;
|
|
137
|
+
return { ...next, version: await fileVersion(dir, next.file) };
|
|
138
|
+
}
|
|
139
|
+
/** O arquivo já tem o nome do título: `titulo.md` ou `titulo-2.md` (sufixo que `uniquePath` põe). */
|
|
140
|
+
function fileMatchesTitle(file, title) {
|
|
141
|
+
const base = path.posix.basename(file, ".md");
|
|
142
|
+
const want = slugify(title);
|
|
143
|
+
return base === want || (base.startsWith(`${want}-`) && /^\d+$/.test(base.slice(want.length + 1)));
|
|
103
144
|
}
|
|
104
145
|
/** Links entre páginas são `./arquivo.md`. Quando um arquivo muda de nome, as outras páginas acompanham. */
|
|
105
146
|
async function relink(dir, files, from, to) {
|
|
@@ -110,7 +151,7 @@ async function relink(dir, files, from, to) {
|
|
|
110
151
|
const pattern = new RegExp(`(\\]\\()(?:\\./)?${escaped}(?=[)#])`, "g");
|
|
111
152
|
for (const file of files) {
|
|
112
153
|
const abs = resolveInside(dir, file);
|
|
113
|
-
const text = await
|
|
154
|
+
const text = await readText(abs);
|
|
114
155
|
const updated = text.replace(pattern, `$1./${name(to)}`);
|
|
115
156
|
if (updated !== text)
|
|
116
157
|
await atomicWrite(abs, updated);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Troca o arquivo de `assets/` pela versão com as tarjas já queimadas (o navegador desenha e envia).
|
|
3
|
+
* Valida o formato e que a imagem abre, grava num temporário e renomeia por cima do caminho de sempre,
|
|
4
|
+
* então Markdown e marcações seguem valendo. O original não vai para a lixeira: some, junto com
|
|
5
|
+
* qualquer cópia idêntica dele na pasta de imagens sem uso.
|
|
6
|
+
*/
|
|
7
|
+
export declare function redactAsset(dir: string, assetPath: string, data: Uint8Array): Promise<void>;
|
|
8
|
+
/** A pasta do manual está dentro de um repositório git? Procura `.git` na pasta e nas de cima. */
|
|
9
|
+
export declare function insideGitRepo(dir: string): Promise<boolean>;
|
|
@@ -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
|
package/dist/core/text.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export declare function slugify(title: string): string;
|
|
2
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
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;
|
package/dist/core/text.js
CHANGED
|
@@ -16,4 +16,13 @@ export function required(value, what) {
|
|
|
16
16
|
throw new Error(`O ${what} não pode ficar vazio.`);
|
|
17
17
|
return v;
|
|
18
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
|
+
}
|
|
19
28
|
//# sourceMappingURL=text.js.map
|
package/dist/core/trash.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdir, readdir,
|
|
2
|
+
import { mkdir, readdir, rename, rm } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { splitFrontmatter } from "./frontmatter.js";
|
|
5
|
-
import { atomicWrite, exists, relativePosix, resolveInside, uniquePath } from "./fs.js";
|
|
5
|
+
import { atomicWrite, exists, readText, relativePosix, resolveInside, uniquePath } from "./fs.js";
|
|
6
6
|
import { PAGES_DIR, updateManual } from "./manuals.js";
|
|
7
7
|
import { childrenOf, clampIndex, findIn, findNode, flatten } from "./tree.js";
|
|
8
8
|
/** Páginas excluídas ficam aqui até serem restauradas ou apagadas à mão. */
|
|
@@ -17,11 +17,16 @@ export async function listTrash(dir) {
|
|
|
17
17
|
const entryFile = path.join(root, trashId, "entry.json");
|
|
18
18
|
if (!(await exists(entryFile)))
|
|
19
19
|
continue;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
+
}
|
|
25
30
|
}
|
|
26
31
|
return items.sort((a, b) => b.deletedAt.localeCompare(a.deletedAt));
|
|
27
32
|
}
|
|
@@ -53,7 +58,7 @@ export async function removePage(dir, id) {
|
|
|
53
58
|
*/
|
|
54
59
|
export async function restorePage(dir, trashId) {
|
|
55
60
|
const trash = resolveInside(path.join(dir, TRASH_DIR), trashId);
|
|
56
|
-
const entry = JSON.parse(await
|
|
61
|
+
const entry = JSON.parse(await readText(path.join(trash, "entry.json")));
|
|
57
62
|
for (const n of flatten([entry.node])) {
|
|
58
63
|
const from = path.join(trash, n.file);
|
|
59
64
|
let to = resolveInside(dir, n.file);
|
package/dist/core/types.d.ts
CHANGED
|
@@ -18,9 +18,29 @@ export interface ManualMeta {
|
|
|
18
18
|
theme?: ManualTheme;
|
|
19
19
|
pages: PageNode[];
|
|
20
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
|
+
*/
|
|
21
28
|
export interface ManualTheme {
|
|
22
|
-
/**
|
|
29
|
+
/** Destaque: links, números dos passos e marcações sobre os prints. */
|
|
23
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;
|
|
24
44
|
}
|
|
25
45
|
export interface Manual {
|
|
26
46
|
/** Caminho absoluto da pasta do manual. */
|
|
@@ -37,6 +57,8 @@ export interface Page {
|
|
|
37
57
|
/** Conteúdo em Markdown, sem o frontmatter. */
|
|
38
58
|
body: string;
|
|
39
59
|
file: string;
|
|
60
|
+
/** Impressão do arquivo no disco (mtime+tamanho). Muda quando alguém edita fora do Studio. */
|
|
61
|
+
version?: string;
|
|
40
62
|
}
|
|
41
63
|
/** Coordenadas normalizadas de 0 a 1, relativas à largura e à altura da imagem. */
|
|
42
64
|
export interface Point {
|
|
@@ -76,9 +98,12 @@ export interface Hotspot extends Rect {
|
|
|
76
98
|
id: string;
|
|
77
99
|
target: HotspotTarget;
|
|
78
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";
|
|
79
103
|
/** Conteúdo do JSON que fica ao lado de cada imagem (`assets/foto.png.json`). */
|
|
80
104
|
export interface Annotations {
|
|
81
105
|
version: typeof FORMAT_VERSION;
|
|
106
|
+
display?: ImageDisplay;
|
|
82
107
|
markers: Marker[];
|
|
83
108
|
highlights: Highlight[];
|
|
84
109
|
arrows: Arrow[];
|
package/dist/core/types.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Formato em disco de um manual. Ver docs/manual-format.md. */
|
|
2
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"];
|
|
3
5
|
/** Etapa da página no fluxo de aprovação. Sem status no arquivo, a página é rascunho. */
|
|
4
6
|
export const PAGE_STATUSES = ["rascunho", "revisao", "aprovado", "publicado"];
|
|
5
7
|
//# sourceMappingURL=types.js.map
|
package/dist/core/versions.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { mkdir, readdir
|
|
1
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { joinFrontmatter, splitFrontmatter } from "./frontmatter.js";
|
|
4
|
-
import { atomicWrite, exists, resolveInside, uniquePath } from "./fs.js";
|
|
4
|
+
import { atomicWrite, exists, readText, resolveInside, uniquePath } from "./fs.js";
|
|
5
5
|
import { readManual, updateManual } from "./manuals.js";
|
|
6
6
|
import { readPage, updatePage } from "./pages.js";
|
|
7
7
|
import { findNode } from "./tree.js";
|
|
@@ -67,7 +67,7 @@ export async function publishVersion(dir, pageId, versionId) {
|
|
|
67
67
|
async function readVersionFile(file, published) {
|
|
68
68
|
let text;
|
|
69
69
|
try {
|
|
70
|
-
text = await
|
|
70
|
+
text = await readText(file);
|
|
71
71
|
}
|
|
72
72
|
catch (err) {
|
|
73
73
|
if (err.code === "ENOENT")
|
|
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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
|
package/dist/server/http.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
export declare class HttpError extends Error {
|
|
3
3
|
readonly status: number;
|
|
4
|
-
|
|
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);
|
|
5
9
|
}
|
|
6
10
|
/** O que um handler devolve. O servidor transforma em resposta HTTP. */
|
|
7
11
|
export type Reply = {
|
|
@@ -12,12 +16,16 @@ export type Reply = {
|
|
|
12
16
|
kind: "file";
|
|
13
17
|
path: string;
|
|
14
18
|
cache: string;
|
|
19
|
+
untrusted: boolean;
|
|
15
20
|
};
|
|
16
21
|
export declare const json: (body: unknown, status?: number) => Reply;
|
|
17
|
-
|
|
18
|
-
export declare
|
|
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>;
|
|
19
25
|
export declare function sendJson(res: ServerResponse, status: number, body: unknown): void;
|
|
20
26
|
export declare function readBody(req: IncomingMessage, limit: number): Promise<Uint8Array>;
|
|
21
27
|
export declare function readJson(req: IncomingMessage): Promise<Record<string, unknown>>;
|
|
22
28
|
/** Status HTTP para um erro vindo do core: "não encontrado" e arquivo inexistente viram 404, o resto 400. */
|
|
23
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;
|