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
|
@@ -10,6 +10,7 @@ const PT = {
|
|
|
10
10
|
hotspotPage: (title) => `Área clicável: leva à página "${title}"`,
|
|
11
11
|
removedPage: "removida",
|
|
12
12
|
untitled: "Sem título",
|
|
13
|
+
callouts: { tip: "Dica", warning: "Atenção", caution: "Erro", note: "Nota" },
|
|
13
14
|
};
|
|
14
15
|
const EN = {
|
|
15
16
|
toc: "Contents",
|
|
@@ -23,6 +24,7 @@ const EN = {
|
|
|
23
24
|
hotspotPage: (title) => `Clickable area: goes to the page "${title}"`,
|
|
24
25
|
removedPage: "removed",
|
|
25
26
|
untitled: "Untitled",
|
|
27
|
+
callouts: { tip: "Tip", warning: "Warning", caution: "Error", note: "Note" },
|
|
26
28
|
};
|
|
27
29
|
/** Manual em português (pt, pt-BR, pt-PT) recebe os textos em português; os outros, em inglês. */
|
|
28
30
|
export const labelsFor = (language) => (language.toLowerCase().startsWith("pt") ? PT : EN);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { ExplicaiError } from "../errors.js";
|
|
2
|
+
/** Tamanho máximo do logo: um logo é pequeno, e arquivo grande pesa em todo HTML exportado. */
|
|
3
|
+
export declare const LOGO_MAX_BYTES: number;
|
|
4
|
+
export declare const LOGO_EXT: RegExp;
|
|
5
|
+
export declare const logoTypeError: () => ExplicaiError;
|
|
6
|
+
export declare const logoSizeError: () => ExplicaiError;
|
|
7
|
+
/** O caminho vira `assets/<arquivo>`: sem pasta, sem `..`, sem barra invertida, só PNG, JPG ou SVG. */
|
|
8
|
+
export declare function checkLogoPath(value: unknown): string;
|
|
9
|
+
/** Confere o arquivo no disco: existe e cabe em 1 MB. */
|
|
10
|
+
export declare function checkLogoFile(dir: string, logo: string): Promise<void>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ExplicaiError } from "../errors.js";
|
|
4
|
+
import { exists, resolveInside } from "../fs.js";
|
|
5
|
+
/** Tamanho máximo do logo: um logo é pequeno, e arquivo grande pesa em todo HTML exportado. */
|
|
6
|
+
export const LOGO_MAX_BYTES = 1024 * 1024;
|
|
7
|
+
export const LOGO_EXT = /^\.(png|jpe?g|svg)$/i;
|
|
8
|
+
const LOGO_TYPE_MSG = "O logo precisa ser PNG, JPG ou SVG.";
|
|
9
|
+
const LOGO_SIZE_MSG = "O logo passa de 1 MB. Reduza o arquivo e envie de novo.";
|
|
10
|
+
export const logoTypeError = () => new ExplicaiError("logo-type", LOGO_TYPE_MSG);
|
|
11
|
+
export const logoSizeError = () => new ExplicaiError("logo-size", LOGO_SIZE_MSG, { max: 1 });
|
|
12
|
+
/** O caminho vira `assets/<arquivo>`: sem pasta, sem `..`, sem barra invertida, só PNG, JPG ou SVG. */
|
|
13
|
+
export function checkLogoPath(value) {
|
|
14
|
+
if (typeof value !== "string" || !/^assets\/[^/\\]+$/.test(value) || value.includes("..")) {
|
|
15
|
+
throw new ExplicaiError("logo-path", "O logo precisa estar em assets/ (assets/logo.png).");
|
|
16
|
+
}
|
|
17
|
+
if (!LOGO_EXT.test(path.posix.extname(value)))
|
|
18
|
+
throw logoTypeError();
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
/** Confere o arquivo no disco: existe e cabe em 1 MB. */
|
|
22
|
+
export async function checkLogoFile(dir, logo) {
|
|
23
|
+
const abs = resolveInside(dir, logo);
|
|
24
|
+
if (!(await exists(abs)))
|
|
25
|
+
throw new ExplicaiError("logo-missing", `O logo ${logo} não existe no manual.`, { logo });
|
|
26
|
+
if ((await stat(abs)).size > LOGO_MAX_BYTES)
|
|
27
|
+
throw logoSizeError();
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=logo.js.map
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { slugify } from "../text.js";
|
|
1
|
+
import { decodeUri, slugify } from "../text.js";
|
|
2
|
+
import { exportLevels } from "./headings.js";
|
|
2
3
|
import { labelsFor } from "./labels.js";
|
|
3
4
|
import { assetFromSrc } from "./refs.js";
|
|
5
|
+
import { TEXT_COLORS } from "./textColors.js";
|
|
4
6
|
const IMAGE_LINE = /!\[([^\]]*)\]\(([^)\s]+)\)/g;
|
|
7
|
+
/** Cor do texto: no Markdown para IA fica só o texto. */
|
|
8
|
+
const COLOR_SPAN = /<span data-color="([a-z]+)">([\s\S]*?)<\/span>/g;
|
|
5
9
|
const PAGE_LINK = /\]\((?:\.\/)?([^/)#\s]+\.md)(#[^)\s]*)?\)/g;
|
|
6
10
|
/**
|
|
7
11
|
* O manual num Markdown só, pensado para quem lê com IA: sumário, uma seção por página na ordem da
|
|
@@ -16,8 +20,9 @@ export function renderMarkdown(collected, images) {
|
|
|
16
20
|
const sections = pages.map((page) => {
|
|
17
21
|
const level = Math.min(page.depth + 2, 6);
|
|
18
22
|
const body = shiftHeadings(page.body, level)
|
|
23
|
+
.replace(COLOR_SPAN, (all, name, text) => (Object.hasOwn(TEXT_COLORS, name) ? text : all))
|
|
19
24
|
.replace(PAGE_LINK, (all, file, hash) => {
|
|
20
|
-
const target = pages.find((p) => p.fileName ===
|
|
25
|
+
const target = pages.find((p) => p.fileName === decodeUri(file));
|
|
21
26
|
return target ? `](#${hash ? hash.slice(1) : anchor(target)})` : all;
|
|
22
27
|
})
|
|
23
28
|
.split("\n")
|
|
@@ -57,18 +62,22 @@ function imageNotes(info, pages, L) {
|
|
|
57
62
|
}));
|
|
58
63
|
return lines;
|
|
59
64
|
}
|
|
60
|
-
/**
|
|
65
|
+
/** Põe os títulos da página abaixo do título dela (`level`), sem pular nível. Ignora blocos de código. */
|
|
61
66
|
function shiftHeadings(body, level) {
|
|
67
|
+
const lines = body.split("\n");
|
|
68
|
+
const found = [];
|
|
62
69
|
let fence = false;
|
|
63
|
-
|
|
64
|
-
.split("\n")
|
|
65
|
-
.map((line) => {
|
|
70
|
+
lines.forEach((line, at) => {
|
|
66
71
|
if (/^\s*(```|~~~)/.test(line))
|
|
67
72
|
fence = !fence;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
+
const m = fence ? null : /^(#{1,6}) /.exec(line);
|
|
74
|
+
if (m?.[1])
|
|
75
|
+
found.push({ at, hashes: m[1].length });
|
|
76
|
+
});
|
|
77
|
+
const levels = exportLevels(found.map((f) => f.hashes), level);
|
|
78
|
+
found.forEach((f, i) => {
|
|
79
|
+
lines[f.at] = `${"#".repeat(levels[i] ?? 6)} ${(lines[f.at] ?? "").slice(f.hashes + 1)}`;
|
|
80
|
+
});
|
|
81
|
+
return lines.join("\n");
|
|
73
82
|
}
|
|
74
83
|
//# sourceMappingURL=markdown.js.map
|
package/dist/core/export/pdf.js
CHANGED
|
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
|
+
import { ExplicaiError } from "../errors.js";
|
|
7
8
|
import { exists } from "../fs.js";
|
|
8
9
|
const run = promisify(execFile);
|
|
9
10
|
/** Onde costumam ficar Chrome, Edge e Chromium. EXPLICAI_BROWSER aponta outro. */
|
|
@@ -42,7 +43,7 @@ export async function findBrowser() {
|
|
|
42
43
|
export async function printPdf(htmlFile, pdfFile) {
|
|
43
44
|
const browser = await findBrowser();
|
|
44
45
|
if (!browser) {
|
|
45
|
-
throw new
|
|
46
|
+
throw new ExplicaiError("no-browser", "Para gerar o PDF, o Explicaí usa o Chrome, o Edge ou o Chromium da sua máquina, e nenhum foi encontrado. " +
|
|
46
47
|
"Instale um deles ou informe o caminho na variável EXPLICAI_BROWSER.");
|
|
47
48
|
}
|
|
48
49
|
const profile = await mkdtemp(path.join(tmpdir(), "explicai-pdf-"));
|
|
@@ -9,8 +9,8 @@ export interface ImageInfo {
|
|
|
9
9
|
}
|
|
10
10
|
/** `../assets/tela.png` (como está na página) → `assets/tela.png`. Imagem de fora do manual: null. */
|
|
11
11
|
export declare function assetFromSrc(src: string): string | null;
|
|
12
|
-
/** Marcações e tamanho de cada imagem do manual usada nas páginas. Imagem apagada fica de fora
|
|
13
|
-
export declare function loadImages(dir: string, pages: ExportPage[]): Promise<Map<string, ImageInfo>>;
|
|
12
|
+
/** Marcações e tamanho de cada imagem do manual usada nas páginas. Imagem apagada fica de fora e vai para `missing`. */
|
|
13
|
+
export declare function loadImages(dir: string, pages: ExportPage[], missing?: string[], warnings?: string[]): Promise<Map<string, ImageInfo>>;
|
|
14
14
|
/** Id da seção de uma página no manual exportado. */
|
|
15
15
|
export declare const pageAnchor: (pageId: string) => string;
|
|
16
16
|
/** Id de um título dentro de uma página: `#secao` de um link vira este id. */
|
package/dist/core/export/refs.js
CHANGED
|
@@ -1,28 +1,41 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
import { emptyAnnotations } from "../annotations.js";
|
|
2
3
|
import { readAnnotations } from "../assets.js";
|
|
4
|
+
import { ExplicaiError } from "../errors.js";
|
|
3
5
|
import { exists, resolveInside } from "../fs.js";
|
|
4
|
-
import { slugify } from "../text.js";
|
|
6
|
+
import { decodeUri, slugify } from "../text.js";
|
|
5
7
|
import { imageSize } from "./imageSize.js";
|
|
6
8
|
const IMAGE = /!\[[^\]]*\]\(([^)\s]+)/g;
|
|
7
9
|
/** `../assets/tela.png` (como está na página) → `assets/tela.png`. Imagem de fora do manual: null. */
|
|
8
10
|
export function assetFromSrc(src) {
|
|
9
11
|
if (/^[a-z][a-z0-9+.-]*:/i.test(src))
|
|
10
12
|
return null;
|
|
11
|
-
const p = path.posix.normalize(path.posix.join("pages",
|
|
13
|
+
const p = path.posix.normalize(path.posix.join("pages", decodeUri(src)));
|
|
12
14
|
return p.startsWith("assets/") ? p : null;
|
|
13
15
|
}
|
|
14
|
-
/** Marcações e tamanho de cada imagem do manual usada nas páginas. Imagem apagada fica de fora
|
|
15
|
-
export async function loadImages(dir, pages) {
|
|
16
|
+
/** Marcações e tamanho de cada imagem do manual usada nas páginas. Imagem apagada fica de fora e vai para `missing`. */
|
|
17
|
+
export async function loadImages(dir, pages, missing = [], warnings = []) {
|
|
16
18
|
const images = new Map();
|
|
17
19
|
for (const page of pages) {
|
|
18
20
|
for (const [, src] of page.body.matchAll(IMAGE)) {
|
|
19
21
|
const asset = src && assetFromSrc(src);
|
|
20
|
-
if (!asset || images.has(asset))
|
|
22
|
+
if (!asset || images.has(asset) || missing.includes(asset))
|
|
21
23
|
continue;
|
|
22
24
|
const abs = resolveInside(dir, asset);
|
|
23
|
-
if (!(await exists(abs)))
|
|
25
|
+
if (!(await exists(abs))) {
|
|
26
|
+
missing.push(asset);
|
|
24
27
|
continue;
|
|
25
|
-
|
|
28
|
+
}
|
|
29
|
+
let annotations;
|
|
30
|
+
try {
|
|
31
|
+
annotations = await readAnnotations(dir, asset);
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
if (!(err instanceof ExplicaiError))
|
|
35
|
+
throw err;
|
|
36
|
+
warnings.push(err.message); // imagem pura, sem marcações
|
|
37
|
+
}
|
|
38
|
+
images.set(asset, { asset, size: await imageSize(abs), annotations: annotations ?? emptyAnnotations() });
|
|
26
39
|
}
|
|
27
40
|
}
|
|
28
41
|
return images;
|
|
@@ -39,10 +52,10 @@ export function pageLink(href, pages) {
|
|
|
39
52
|
const m = /^(?:\.\/)?([^/#?]+\.md)(?:#(.+))?$/.exec(href);
|
|
40
53
|
if (!m)
|
|
41
54
|
return null;
|
|
42
|
-
const page = pages.find((p) => p.fileName ===
|
|
55
|
+
const page = pages.find((p) => p.fileName === decodeUri(m[1] ?? ""));
|
|
43
56
|
if (!page)
|
|
44
57
|
return null;
|
|
45
|
-
return `#${m[2] ? headingAnchor(page.id,
|
|
58
|
+
return `#${m[2] ? headingAnchor(page.id, decodeUri(m[2])) : pageAnchor(page.id)}`;
|
|
46
59
|
}
|
|
47
60
|
/** Só endereços seguros saem como link no manual exportado. */
|
|
48
61
|
export function safeHref(href) {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cor do texto no manual exportado (fundo branco), pelo nome salvo em `<span data-color="…">`.
|
|
3
|
+
* Os mesmos nomes estão no editor (web/src/features/editor/textColor.ts), com a cor de cada tema em tokens.css.
|
|
4
|
+
*/
|
|
5
|
+
export declare const TEXT_COLORS: Record<string, string>;
|
|
6
|
+
/** `<span data-color="red">`, só com nome da paleta. Devolve o nome ou null. */
|
|
7
|
+
export declare function colorSpanOpen(html: string): string | null;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cor do texto no manual exportado (fundo branco), pelo nome salvo em `<span data-color="…">`.
|
|
3
|
+
* Os mesmos nomes estão no editor (web/src/features/editor/textColor.ts), com a cor de cada tema em tokens.css.
|
|
4
|
+
*/
|
|
5
|
+
export const TEXT_COLORS = {
|
|
6
|
+
gray: "#5c6478",
|
|
7
|
+
red: "#c3261b",
|
|
8
|
+
orange: "#b04a00",
|
|
9
|
+
green: "#17793a",
|
|
10
|
+
blue: "#1f58d6",
|
|
11
|
+
purple: "#7a3cc8",
|
|
12
|
+
};
|
|
13
|
+
/** `<span data-color="red">`, só com nome da paleta. Devolve o nome ou null. */
|
|
14
|
+
export function colorSpanOpen(html) {
|
|
15
|
+
const name = /^<span data-color="([a-z]+)">$/.exec(html)?.[1];
|
|
16
|
+
return name && Object.hasOwn(TEXT_COLORS, name) ? name : null;
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=textColors.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type HeadingSlot, type ManualTheme } from "../types.js";
|
|
2
|
+
import { type FontSpec } from "./fonts.js";
|
|
3
|
+
/** Contraste mínimo do texto com o fundo (WCAG AA). */
|
|
4
|
+
export declare const MIN_CONTRAST = 4.5;
|
|
5
|
+
/** Valores de hoje: o manual sem `theme` exporta com estes. */
|
|
6
|
+
export declare const DEFAULT_TEXT = "#151a33";
|
|
7
|
+
export declare const DEFAULT_ACCENT = "#2b4fe0";
|
|
8
|
+
export declare const DEFAULT_HIGHLIGHT = "#ffd43b";
|
|
9
|
+
/** Mudança no tema: campo ausente fica como está, `null` apaga o campo. */
|
|
10
|
+
export type ThemePatch = Partial<Record<"accent" | "text" | "highlight", string | null>> & {
|
|
11
|
+
headings?: Partial<Record<HeadingSlot, string | null>> | null;
|
|
12
|
+
fonts?: Partial<Record<"text" | "headings", string | null>> | null;
|
|
13
|
+
/** Caminho do logo em `assets/`; `null` tira o logo. */
|
|
14
|
+
logo?: string | null;
|
|
15
|
+
};
|
|
16
|
+
/** As fontes que o tema usa (as da marca, onde não houve escolha). */
|
|
17
|
+
export declare function themeFonts(theme: ManualTheme | undefined): {
|
|
18
|
+
text: FontSpec;
|
|
19
|
+
headings: FontSpec;
|
|
20
|
+
};
|
|
21
|
+
/** Aplica `patch` sobre o tema atual e confere tudo. Devolve o tema novo; o que não mudou continua igual. */
|
|
22
|
+
export declare function mergeTheme(current: ManualTheme | undefined, patch: ThemePatch): ManualTheme;
|
|
23
|
+
/** Variáveis CSS do tema, só as que mudam. Vazio quando o manual usa as cores da marca. */
|
|
24
|
+
export declare function themeCss(theme: ManualTheme | undefined): string;
|
|
25
|
+
/** As fontes que vão no export: texto, títulos (sem repetir) e a de código. */
|
|
26
|
+
export declare function usedFonts(theme: ManualTheme | undefined): FontSpec[];
|
|
27
|
+
/** `@font-face` das fontes usadas, no mesmo formato do export. */
|
|
28
|
+
export declare const fontFacesCss: (theme: ManualTheme | undefined) => string;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { ExplicaiError } from "../errors.js";
|
|
2
|
+
import { HEADING_SLOTS } from "../types.js";
|
|
3
|
+
import { contrast, contrastWithWhite, HEX, muted, overWhite, withAlpha } from "./color.js";
|
|
4
|
+
import { DEFAULT_HEADINGS_FONT, DEFAULT_TEXT_FONT, FONTS, fontById, fontFaceCss, fontStack, MONO, } from "./fonts.js";
|
|
5
|
+
import { checkLogoPath } from "./logo.js";
|
|
6
|
+
/** Contraste mínimo do texto com o fundo (WCAG AA). */
|
|
7
|
+
export const MIN_CONTRAST = 4.5;
|
|
8
|
+
/** Valores de hoje: o manual sem `theme` exporta com estes. */
|
|
9
|
+
export const DEFAULT_TEXT = "#151a33";
|
|
10
|
+
export const DEFAULT_ACCENT = "#2b4fe0";
|
|
11
|
+
export const DEFAULT_HIGHLIGHT = "#ffd43b";
|
|
12
|
+
/** Opacidade do marca-texto sobre o papel (a mesma do CSS do export). */
|
|
13
|
+
const HIGHLIGHT_ALPHA = 0.55;
|
|
14
|
+
const SLOT_NAMES = {
|
|
15
|
+
cover: "título da capa",
|
|
16
|
+
page: "título da página",
|
|
17
|
+
h1: "título de nível 1",
|
|
18
|
+
h2: "título de nível 2",
|
|
19
|
+
h3: "título de nível 3",
|
|
20
|
+
};
|
|
21
|
+
const num = (n) => n.toFixed(1).replace(".", ",");
|
|
22
|
+
function hexOf(value, label) {
|
|
23
|
+
if (value === null)
|
|
24
|
+
return null;
|
|
25
|
+
if (typeof value !== "string" || !HEX.test(value))
|
|
26
|
+
throw new Error(`Cor inválida em ${label}: use o formato #2b4fe0.`);
|
|
27
|
+
return value.toLowerCase();
|
|
28
|
+
}
|
|
29
|
+
function readable(hex, label) {
|
|
30
|
+
const c = contrastWithWhite(hex);
|
|
31
|
+
if (c < MIN_CONTRAST) {
|
|
32
|
+
throw new ExplicaiError("low-contrast", `A cor de ${label} tem contraste ${num(c)}:1 com o branco; o mínimo é ${num(MIN_CONTRAST)}:1. Escolha uma cor mais escura.`, { label, ratio: c, min: MIN_CONTRAST });
|
|
33
|
+
}
|
|
34
|
+
return hex;
|
|
35
|
+
}
|
|
36
|
+
/** A fonte da lista com esse nome. O texto precisa de uma com itálico empacotado. */
|
|
37
|
+
function fontOf(id, role) {
|
|
38
|
+
const font = typeof id === "string" ? fontById(id) : undefined;
|
|
39
|
+
const valid = FONTS.filter((f) => role === "headings" || f.italic);
|
|
40
|
+
if (!font || !valid.includes(font)) {
|
|
41
|
+
throw new Error(`Fonte fora da lista: ${String(id)}. Use uma destas: ${valid.map((f) => f.id).join(", ")}.`);
|
|
42
|
+
}
|
|
43
|
+
return font;
|
|
44
|
+
}
|
|
45
|
+
/** As fontes que o tema usa (as da marca, onde não houve escolha). */
|
|
46
|
+
export function themeFonts(theme) {
|
|
47
|
+
return {
|
|
48
|
+
text: fontById(theme?.fonts?.text ?? DEFAULT_TEXT_FONT),
|
|
49
|
+
headings: fontById(theme?.fonts?.headings ?? DEFAULT_HEADINGS_FONT),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Aplica `patch` sobre o tema atual e confere tudo. Devolve o tema novo; o que não mudou continua igual. */
|
|
53
|
+
export function mergeTheme(current, patch) {
|
|
54
|
+
const next = { ...current, headings: { ...current?.headings }, fonts: { ...current?.fonts } };
|
|
55
|
+
const set = (key, label, check) => {
|
|
56
|
+
if (patch[key] === undefined)
|
|
57
|
+
return;
|
|
58
|
+
const hex = hexOf(patch[key], label);
|
|
59
|
+
if (hex === null)
|
|
60
|
+
delete next[key];
|
|
61
|
+
else
|
|
62
|
+
next[key] = check ? readable(hex, label) : hex;
|
|
63
|
+
};
|
|
64
|
+
set("accent", "destaque", true);
|
|
65
|
+
set("text", "texto", true);
|
|
66
|
+
set("highlight", "realce", false);
|
|
67
|
+
if (patch.headings === null)
|
|
68
|
+
next.headings = {};
|
|
69
|
+
else if (patch.headings) {
|
|
70
|
+
for (const slot of HEADING_SLOTS) {
|
|
71
|
+
const value = patch.headings[slot];
|
|
72
|
+
if (value === undefined)
|
|
73
|
+
continue;
|
|
74
|
+
const hex = hexOf(value, SLOT_NAMES[slot]);
|
|
75
|
+
if (hex === null)
|
|
76
|
+
delete next.headings?.[slot];
|
|
77
|
+
else if (next.headings)
|
|
78
|
+
next.headings[slot] = readable(hex, SLOT_NAMES[slot]);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (patch.fonts === null)
|
|
82
|
+
next.fonts = {};
|
|
83
|
+
else if (patch.fonts) {
|
|
84
|
+
for (const role of ["text", "headings"]) {
|
|
85
|
+
const id = patch.fonts[role];
|
|
86
|
+
if (id === undefined)
|
|
87
|
+
continue;
|
|
88
|
+
if (id === null)
|
|
89
|
+
delete next.fonts?.[role];
|
|
90
|
+
else if (next.fonts)
|
|
91
|
+
next.fonts[role] = fontOf(id, role).id;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (patch.logo === null)
|
|
95
|
+
delete next.logo;
|
|
96
|
+
else if (patch.logo !== undefined)
|
|
97
|
+
next.logo = checkLogoPath(patch.logo);
|
|
98
|
+
// O realce é fundo: o texto por cima precisa continuar legível.
|
|
99
|
+
if (next.highlight) {
|
|
100
|
+
const c = contrast(overWhite(next.highlight, HIGHLIGHT_ALPHA), next.text ?? DEFAULT_TEXT);
|
|
101
|
+
if (c < MIN_CONTRAST) {
|
|
102
|
+
throw new ExplicaiError("low-contrast-highlight", `O texto sobre o realce tem contraste ${num(c)}:1; o mínimo é ${num(MIN_CONTRAST)}:1. Escolha um realce mais claro.`, { ratio: c, min: MIN_CONTRAST });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!Object.keys(next.headings ?? {}).length)
|
|
106
|
+
delete next.headings;
|
|
107
|
+
if (!Object.keys(next.fonts ?? {}).length)
|
|
108
|
+
delete next.fonts;
|
|
109
|
+
return next;
|
|
110
|
+
}
|
|
111
|
+
/** Variáveis CSS do tema, só as que mudam. Vazio quando o manual usa as cores da marca. */
|
|
112
|
+
export function themeCss(theme) {
|
|
113
|
+
if (!theme)
|
|
114
|
+
return "";
|
|
115
|
+
const v = [];
|
|
116
|
+
if (theme.accent)
|
|
117
|
+
v.push(`--accent:${theme.accent}`, `--anotacao:${theme.accent}`, `--anotacao-fundo:${withAlpha(theme.accent, 0.08)}`);
|
|
118
|
+
if (theme.text)
|
|
119
|
+
v.push(`--tinta:${theme.text}`, `--grafite:${muted(theme.text)}`);
|
|
120
|
+
if (theme.highlight) {
|
|
121
|
+
v.push(`--marca:${theme.highlight}`, `--marca-faixa:${withAlpha(theme.highlight, HIGHLIGHT_ALPHA)}`, `--marca-halo:${withAlpha(theme.highlight, 0.3)}`);
|
|
122
|
+
}
|
|
123
|
+
for (const slot of HEADING_SLOTS)
|
|
124
|
+
if (theme.headings?.[slot])
|
|
125
|
+
v.push(`--t-${slot}:${theme.headings[slot]}`);
|
|
126
|
+
const fonts = themeFonts(theme);
|
|
127
|
+
if (theme.fonts?.text)
|
|
128
|
+
v.push(`--texto:${fontStack(fonts.text)}`);
|
|
129
|
+
if (theme.fonts?.headings)
|
|
130
|
+
v.push(`--display:${fontStack(fonts.headings)}`);
|
|
131
|
+
return v.length ? `:root{${v.join(";")}}` : "";
|
|
132
|
+
}
|
|
133
|
+
/** As fontes que vão no export: texto, títulos (sem repetir) e a de código. */
|
|
134
|
+
export function usedFonts(theme) {
|
|
135
|
+
const { text, headings } = themeFonts(theme);
|
|
136
|
+
return [...new Set([text, headings, MONO])];
|
|
137
|
+
}
|
|
138
|
+
/** `@font-face` das fontes usadas, no mesmo formato do export. */
|
|
139
|
+
export const fontFacesCss = (theme) => usedFonts(theme).map(fontFaceCss).join("\n");
|
|
140
|
+
//# sourceMappingURL=theme.js.map
|
package/dist/core/fs.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/** Grava num arquivo temporário e renomeia, para um travamento no meio não deixar o arquivo pela metade. */
|
|
2
2
|
export declare function atomicWrite(file: string, content: string): Promise<void>;
|
|
3
|
+
/** Lê UTF-8 sem o BOM que o Bloco de Notas e alguns editores do Windows põem no começo: ele quebraria o JSON e o frontmatter. */
|
|
4
|
+
export declare function readText(file: string): Promise<string>;
|
|
3
5
|
/** Barreira de confiança: caminhos vêm do Agent e do navegador, então nada pode sair da pasta base. */
|
|
4
6
|
export declare function resolveInside(dir: string, relative: string): string;
|
|
7
|
+
/** Como `resolveInside`, mas segue links simbólicos: um link dentro do manual que aponta para fora é recusado. O arquivo precisa existir. */
|
|
8
|
+
export declare function resolveInsideReal(dir: string, relative: string): Promise<string>;
|
|
5
9
|
/** `base.ext`, ou `base-2.ext`, `base-3.ext`… se já existir. */
|
|
6
10
|
export declare function uniquePath(parent: string, base: string, ext: string): Promise<string>;
|
|
7
11
|
export declare function exists(p: string): Promise<boolean>;
|
package/dist/core/fs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { rename, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { readFile, realpath, rename, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
/** Grava num arquivo temporário e renomeia, para um travamento no meio não deixar o arquivo pela metade. */
|
|
5
5
|
export async function atomicWrite(file, content) {
|
|
@@ -7,6 +7,10 @@ export async function atomicWrite(file, content) {
|
|
|
7
7
|
await writeFile(tmp, content, "utf8");
|
|
8
8
|
await rename(tmp, file);
|
|
9
9
|
}
|
|
10
|
+
/** Lê UTF-8 sem o BOM que o Bloco de Notas e alguns editores do Windows põem no começo: ele quebraria o JSON e o frontmatter. */
|
|
11
|
+
export async function readText(file) {
|
|
12
|
+
return (await readFile(file, "utf8")).replace(/^/, "");
|
|
13
|
+
}
|
|
10
14
|
/** Barreira de confiança: caminhos vêm do Agent e do navegador, então nada pode sair da pasta base. */
|
|
11
15
|
export function resolveInside(dir, relative) {
|
|
12
16
|
const root = path.resolve(dir);
|
|
@@ -15,6 +19,14 @@ export function resolveInside(dir, relative) {
|
|
|
15
19
|
throw new Error(`Caminho fora do manual: ${relative}`);
|
|
16
20
|
return abs;
|
|
17
21
|
}
|
|
22
|
+
/** Como `resolveInside`, mas segue links simbólicos: um link dentro do manual que aponta para fora é recusado. O arquivo precisa existir. */
|
|
23
|
+
export async function resolveInsideReal(dir, relative) {
|
|
24
|
+
const abs = resolveInside(dir, relative);
|
|
25
|
+
const [real, root] = await Promise.all([realpath(abs), realpath(path.resolve(dir))]);
|
|
26
|
+
if (real !== root && !real.startsWith(root + path.sep))
|
|
27
|
+
throw new Error(`Caminho fora do manual: ${relative}`);
|
|
28
|
+
return abs;
|
|
29
|
+
}
|
|
18
30
|
/** `base.ext`, ou `base-2.ext`, `base-3.ext`… se já existir. */
|
|
19
31
|
export async function uniquePath(parent, base, ext) {
|
|
20
32
|
for (let i = 1;; i++) {
|
package/dist/core/index.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
export { emptyAnnotations, parseAnnotations } from "./annotations.js";
|
|
2
2
|
export { cleanUnusedAssets, findUnusedAssets, readAnnotations, saveAsset, writeAnnotations } from "./assets.js";
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
3
|
+
export { ExplicaiError } from "./errors.js";
|
|
4
|
+
export { FONTS, FONTS_DIR, type FontSpec, fontFiles } from "./export/fonts.js";
|
|
5
|
+
export { EXPORT_DIR, EXPORT_FORMATS, type ExportFormat, type ExportResult, exportManual, findBrowser, logoSizeError, setManualLogo, setManualTheme, } from "./export/index.js";
|
|
6
|
+
export { resolveInside, resolveInsideReal } from "./fs.js";
|
|
5
7
|
export { createManual, listManuals, readManual } from "./manuals.js";
|
|
6
8
|
export { addPage, movePage, type PageTreeNode, readPage, readPageTree, updatePage } from "./pages.js";
|
|
9
|
+
export { insideGitRepo, redactAsset } from "./redact.js";
|
|
7
10
|
export { slugify } from "./text.js";
|
|
8
11
|
export { listTrash, removePage, restorePage, TRASH_DIR, type TrashItem } from "./trash.js";
|
|
9
12
|
export { flatten } from "./tree.js";
|
package/dist/core/index.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
export { emptyAnnotations, parseAnnotations } from "./annotations.js";
|
|
2
2
|
export { cleanUnusedAssets, findUnusedAssets, readAnnotations, saveAsset, writeAnnotations } from "./assets.js";
|
|
3
|
-
export {
|
|
4
|
-
export {
|
|
3
|
+
export { ExplicaiError } from "./errors.js";
|
|
4
|
+
export { FONTS, FONTS_DIR, fontFiles } from "./export/fonts.js";
|
|
5
|
+
export { EXPORT_DIR, EXPORT_FORMATS, exportManual, findBrowser, logoSizeError, setManualLogo, setManualTheme, } from "./export/index.js";
|
|
6
|
+
export { resolveInside, resolveInsideReal } from "./fs.js";
|
|
5
7
|
export { createManual, listManuals, readManual } from "./manuals.js";
|
|
6
8
|
export { addPage, movePage, readPage, readPageTree, updatePage } from "./pages.js";
|
|
9
|
+
export { insideGitRepo, redactAsset } from "./redact.js";
|
|
7
10
|
export { slugify } from "./text.js";
|
|
8
11
|
export { listTrash, removePage, restorePage, TRASH_DIR } from "./trash.js";
|
|
9
12
|
export { flatten } from "./tree.js";
|
package/dist/core/manuals.d.ts
CHANGED
|
@@ -13,5 +13,11 @@ export declare function createManual(workspace: string, opts: {
|
|
|
13
13
|
export declare function listManuals(workspace: string): Promise<Manual[]>;
|
|
14
14
|
export declare function readManual(dir: string): Promise<ManualMeta>;
|
|
15
15
|
export declare function writeManual(dir: string, meta: ManualMeta): Promise<void>;
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* Lê o manual, aplica a mudança e grava de volta. Concentra o ciclo ler-alterar-gravar da árvore.
|
|
18
|
+
* Chamadas da mesma pasta entram numa fila, senão duas leituras simultâneas se atropelam e a última
|
|
19
|
+
* gravação apaga a mudança da outra. `change` não pode chamar updateManual da mesma pasta (travaria).
|
|
20
|
+
*/
|
|
17
21
|
export declare function updateManual<T>(dir: string, change: (meta: ManualMeta) => T): Promise<T>;
|
|
22
|
+
/** Roda `task` na fila da pasta (a mesma do updateManual). `task` não pode entrar na fila da mesma pasta de novo. */
|
|
23
|
+
export declare function inQueue<T>(dir: string, task: () => Promise<T>): Promise<T>;
|
package/dist/core/manuals.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { mkdir, readdir
|
|
1
|
+
import { mkdir, readdir } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { atomicWrite, exists, uniquePath } from "./fs.js";
|
|
3
|
+
import { atomicWrite, exists, readText, uniquePath } from "./fs.js";
|
|
4
4
|
import { required, slugify } from "./text.js";
|
|
5
5
|
import { FORMAT_VERSION } from "./types.js";
|
|
6
6
|
const META_FILE = "manual.json";
|
|
@@ -41,7 +41,7 @@ export async function listManuals(workspace) {
|
|
|
41
41
|
export async function readManual(dir) {
|
|
42
42
|
let meta;
|
|
43
43
|
try {
|
|
44
|
-
meta = JSON.parse(await
|
|
44
|
+
meta = JSON.parse(await readText(path.join(dir, META_FILE)));
|
|
45
45
|
}
|
|
46
46
|
catch (err) {
|
|
47
47
|
if (err instanceof SyntaxError)
|
|
@@ -56,11 +56,31 @@ export async function readManual(dir) {
|
|
|
56
56
|
export async function writeManual(dir, meta) {
|
|
57
57
|
await atomicWrite(path.join(dir, META_FILE), `${JSON.stringify(meta, null, 2)}\n`);
|
|
58
58
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
59
|
+
const queues = new Map();
|
|
60
|
+
/**
|
|
61
|
+
* Lê o manual, aplica a mudança e grava de volta. Concentra o ciclo ler-alterar-gravar da árvore.
|
|
62
|
+
* Chamadas da mesma pasta entram numa fila, senão duas leituras simultâneas se atropelam e a última
|
|
63
|
+
* gravação apaga a mudança da outra. `change` não pode chamar updateManual da mesma pasta (travaria).
|
|
64
|
+
*/
|
|
65
|
+
export function updateManual(dir, change) {
|
|
66
|
+
return inQueue(dir, async () => {
|
|
67
|
+
const meta = await readManual(dir);
|
|
68
|
+
const result = change(meta);
|
|
69
|
+
await writeManual(dir, meta);
|
|
70
|
+
return result;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
/** Roda `task` na fila da pasta (a mesma do updateManual). `task` não pode entrar na fila da mesma pasta de novo. */
|
|
74
|
+
export function inQueue(dir, task) {
|
|
75
|
+
const key = path.resolve(dir);
|
|
76
|
+
const run = (queues.get(key) ?? Promise.resolve()).catch(() => { }).then(task);
|
|
77
|
+
queues.set(key, run);
|
|
78
|
+
// Fila vazia: tira a pasta do Map. Falha já chega a quem chamou por `run`.
|
|
79
|
+
const clean = () => {
|
|
80
|
+
if (queues.get(key) === run)
|
|
81
|
+
queues.delete(key);
|
|
82
|
+
};
|
|
83
|
+
run.then(clean, clean);
|
|
84
|
+
return run;
|
|
65
85
|
}
|
|
66
86
|
//# sourceMappingURL=manuals.js.map
|
package/dist/core/pages.d.ts
CHANGED
|
@@ -26,11 +26,16 @@ export declare function readPageTree(dir: string): Promise<PageTreeNode[]>;
|
|
|
26
26
|
/**
|
|
27
27
|
* Quando o título muda, o arquivo é renomeado para acompanhar, para a pasta continuar legível
|
|
28
28
|
* para pessoas e IAs. O id no frontmatter não muda, e é ele que as áreas clicáveis usam.
|
|
29
|
+
* `fileName`: "keep" grava o título no mesmo arquivo (digitação em andamento); "sync" renomeia
|
|
30
|
+
* se o nome do arquivo não combina com o título (ao parar de digitar ou sair da página).
|
|
29
31
|
*/
|
|
30
32
|
export declare function updatePage(dir: string, id: string, patch: {
|
|
31
33
|
title?: string;
|
|
32
34
|
body?: string;
|
|
33
35
|
status?: PageStatus;
|
|
36
|
+
}, opts?: {
|
|
37
|
+
fileName?: "keep" | "sync" | undefined;
|
|
38
|
+
ifMatch?: string | undefined;
|
|
34
39
|
}): Promise<Page>;
|
|
35
40
|
export declare function movePage(dir: string, id: string, to: {
|
|
36
41
|
parentId: string | null;
|