explicai 0.1.0 → 0.2.1

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 (131) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/LICENSE +21 -21
  3. package/README.md +87 -27
  4. package/dist/cli.js +30 -11
  5. package/dist/core/annotations.js +2 -0
  6. package/dist/core/assets.d.ts +2 -0
  7. package/dist/core/assets.js +51 -16
  8. package/dist/core/errors.d.ts +9 -0
  9. package/dist/core/errors.js +14 -0
  10. package/dist/core/export/annotationCss.d.ts +5 -0
  11. package/dist/core/export/annotationCss.js +26 -0
  12. package/dist/core/export/annotationShapes.d.ts +37 -0
  13. package/dist/core/export/annotationShapes.js +38 -0
  14. package/dist/core/export/callout.d.ts +11 -0
  15. package/dist/core/export/callout.js +26 -0
  16. package/dist/core/export/collect.d.ts +2 -2
  17. package/dist/core/export/collect.js +2 -2
  18. package/dist/core/export/color.d.ts +11 -0
  19. package/dist/core/export/color.js +33 -0
  20. package/dist/core/export/fonts.d.ts +35 -0
  21. package/dist/core/export/fonts.js +40 -0
  22. package/dist/core/export/headings.d.ts +7 -0
  23. package/dist/core/export/headings.js +17 -0
  24. package/dist/core/export/html.d.ts +1 -3
  25. package/dist/core/export/html.js +86 -39
  26. package/dist/core/export/imageSize.js +20 -3
  27. package/dist/core/export/index.d.ts +12 -6
  28. package/dist/core/export/index.js +106 -26
  29. package/dist/core/export/labels.d.ts +1 -0
  30. package/dist/core/export/labels.js +2 -0
  31. package/dist/core/export/logo.d.ts +10 -0
  32. package/dist/core/export/logo.js +29 -0
  33. package/dist/core/export/markdown.js +20 -11
  34. package/dist/core/export/pdf.js +2 -1
  35. package/dist/core/export/refs.d.ts +2 -2
  36. package/dist/core/export/refs.js +22 -9
  37. package/dist/core/export/textColors.d.ts +7 -0
  38. package/dist/core/export/textColors.js +18 -0
  39. package/dist/core/export/theme.d.ts +28 -0
  40. package/dist/core/export/theme.js +140 -0
  41. package/dist/core/fs.d.ts +4 -0
  42. package/dist/core/fs.js +13 -1
  43. package/dist/core/index.d.ts +5 -2
  44. package/dist/core/index.js +5 -2
  45. package/dist/core/manuals.d.ts +7 -1
  46. package/dist/core/manuals.js +29 -9
  47. package/dist/core/pages.d.ts +5 -0
  48. package/dist/core/pages.js +56 -15
  49. package/dist/core/redact.d.ts +9 -0
  50. package/dist/core/redact.js +107 -0
  51. package/dist/core/text.d.ts +2 -0
  52. package/dist/core/text.js +9 -0
  53. package/dist/core/trash.js +13 -8
  54. package/dist/core/types.d.ts +26 -1
  55. package/dist/core/types.js +2 -0
  56. package/dist/core/versions.js +3 -3
  57. package/dist/server/errorPage.d.ts +9 -0
  58. package/dist/server/errorPage.js +122 -0
  59. package/dist/server/http.d.ts +11 -3
  60. package/dist/server/http.js +65 -11
  61. package/dist/server/index.js +35 -5
  62. package/dist/server/routes/assets.js +21 -2
  63. package/dist/server/routes/export.js +18 -8
  64. package/dist/server/routes/fonts.d.ts +3 -0
  65. package/dist/server/routes/fonts.js +24 -0
  66. package/dist/server/routes/pages.js +3 -1
  67. package/dist/server/routes/web.js +7 -1
  68. package/dist/server/security.d.ts +1 -1
  69. package/dist/server/security.js +7 -6
  70. package/dist/web/assets/index-BlWnNe8d.js +262 -0
  71. package/dist/web/assets/index-DFBrhkII.css +1 -0
  72. package/dist/web/index.html +2 -3
  73. package/fonts/OFL-archivo.txt +93 -0
  74. package/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
  75. package/fonts/OFL-inter.txt +93 -0
  76. package/fonts/OFL-jetbrains-mono.txt +93 -0
  77. package/fonts/OFL-lora.txt +93 -0
  78. package/fonts/OFL-montserrat.txt +93 -0
  79. package/fonts/OFL-playfair-display.txt +93 -0
  80. package/fonts/OFL-source-sans-3.txt +93 -0
  81. package/fonts/OFL-source-serif-4.txt +93 -0
  82. package/fonts/archivo-latin-wght-normal.woff2 +0 -0
  83. package/fonts/atkinson-hyperlegible-next-latin-400-italic.woff2 +0 -0
  84. package/fonts/atkinson-hyperlegible-next-latin-400-normal.woff2 +0 -0
  85. package/fonts/atkinson-hyperlegible-next-latin-700-normal.woff2 +0 -0
  86. package/fonts/inter-latin-wght-italic.woff2 +0 -0
  87. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  88. package/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  89. package/fonts/lora-latin-wght-italic.woff2 +0 -0
  90. package/fonts/lora-latin-wght-normal.woff2 +0 -0
  91. package/fonts/montserrat-latin-wght-normal.woff2 +0 -0
  92. package/fonts/playfair-display-latin-wght-normal.woff2 +0 -0
  93. package/fonts/source-sans-3-latin-wght-italic.woff2 +0 -0
  94. package/fonts/source-sans-3-latin-wght-normal.woff2 +0 -0
  95. package/fonts/source-serif-4-latin-wght-italic.woff2 +0 -0
  96. package/fonts/source-serif-4-latin-wght-normal.woff2 +0 -0
  97. package/package.json +41 -7
  98. package/dist/cli.js.map +0 -1
  99. package/dist/core/annotations.js.map +0 -1
  100. package/dist/core/assets.js.map +0 -1
  101. package/dist/core/export/collect.js.map +0 -1
  102. package/dist/core/export/html.js.map +0 -1
  103. package/dist/core/export/imageSize.js.map +0 -1
  104. package/dist/core/export/index.js.map +0 -1
  105. package/dist/core/export/labels.js.map +0 -1
  106. package/dist/core/export/markdown.js.map +0 -1
  107. package/dist/core/export/pdf.js.map +0 -1
  108. package/dist/core/export/refs.js.map +0 -1
  109. package/dist/core/frontmatter.js.map +0 -1
  110. package/dist/core/fs.js.map +0 -1
  111. package/dist/core/index.js.map +0 -1
  112. package/dist/core/manuals.js.map +0 -1
  113. package/dist/core/pages.js.map +0 -1
  114. package/dist/core/text.js.map +0 -1
  115. package/dist/core/trash.js.map +0 -1
  116. package/dist/core/tree.js.map +0 -1
  117. package/dist/core/types.js.map +0 -1
  118. package/dist/core/versions.js.map +0 -1
  119. package/dist/server/http.js.map +0 -1
  120. package/dist/server/index.js.map +0 -1
  121. package/dist/server/router.js.map +0 -1
  122. package/dist/server/routes/assets.js.map +0 -1
  123. package/dist/server/routes/export.js.map +0 -1
  124. package/dist/server/routes/manuals.js.map +0 -1
  125. package/dist/server/routes/pages.js.map +0 -1
  126. package/dist/server/routes/versions.js.map +0 -1
  127. package/dist/server/routes/web.js.map +0 -1
  128. package/dist/server/security.js.map +0 -1
  129. package/dist/server/workspace.js.map +0 -1
  130. package/dist/web/assets/index-BXkBk-2Z.css +0 -1
  131. package/dist/web/assets/index-C02XKPHS.js +0 -260
@@ -1,43 +1,59 @@
1
- import { copyFile, mkdir, rm } from "node:fs/promises";
1
+ import { randomUUID } from "node:crypto";
2
+ import { copyFile, mkdir, rename, rm } from "node:fs/promises";
2
3
  import path from "node:path";
3
- import { atomicWrite, relativePosix, resolveInside } from "../fs.js";
4
+ import { saveAsset } from "../assets.js";
5
+ import { ExplicaiError } from "../errors.js";
6
+ import { atomicWrite, relativePosix, resolveInside, resolveInsideReal } from "../fs.js";
4
7
  import { updateManual } from "../manuals.js";
5
8
  import { slugify } from "../text.js";
6
9
  import { collectPages } from "./collect.js";
10
+ import { FONTS_DIR, fontFiles } from "./fonts.js";
7
11
  import { renderHtml } from "./html.js";
12
+ import { checkLogoFile, checkLogoPath, LOGO_EXT, LOGO_MAX_BYTES, logoSizeError, logoTypeError } from "./logo.js";
8
13
  import { renderMarkdown } from "./markdown.js";
9
14
  import { printPdf } from "./pdf.js";
10
15
  import { loadImages } from "./refs.js";
16
+ import { mergeTheme, usedFonts } from "./theme.js";
17
+ export { logoSizeError } from "./logo.js";
11
18
  export { findBrowser } from "./pdf.js";
12
19
  /** Pasta da exportação, dentro do manual. Cada exportação substitui a anterior do mesmo formato. */
13
20
  export const EXPORT_DIR = "exportacao";
14
21
  export const EXPORT_FORMATS = ["html", "pdf", "markdown"];
15
22
  /**
16
23
  * Exporta o manual: HTML (um arquivo com sumário, para publicar ou abrir no navegador), PDF (impresso
17
- * a partir desse HTML) ou Markdown (um arquivo só, para quem lê com IA). Usa a versão marcada
18
- * "No manual" de cada página.
24
+ * a partir desse HTML) ou Markdown (um arquivo só, para quem lê com IA). Usa a Versão do manual
25
+ * de cada página.
19
26
  */
20
27
  export async function exportManual(dir, format, opts = {}) {
21
28
  const collected = await collectPages(dir, opts);
22
- const images = await loadImages(dir, collected.pages);
29
+ if (opts.onlyApproved && collected.pages.length === 0) {
30
+ throw new ExplicaiError("no-approved-pages", "Nenhuma página está aprovada ou publicada. Aprove uma página ou exporte todas.");
31
+ }
32
+ const missing = [];
33
+ const broken = [];
34
+ const images = await loadImages(dir, collected.pages, missing, broken);
35
+ const warnings = [...collected.warnings, ...missing.map((asset) => `Imagem não encontrada: ${asset}`), ...broken];
23
36
  const root = resolveInside(dir, EXPORT_DIR);
24
37
  const result = (file) => ({
25
38
  format,
26
39
  file: relativePosix(dir, file),
27
40
  pages: collected.pages.length,
28
- warnings: collected.warnings,
41
+ warnings,
29
42
  });
30
43
  if (format === "markdown") {
31
- const out = await freshDir(path.join(root, "markdown"));
32
- await copyImages(dir, images, out);
33
- const file = path.join(out, "manual.md");
34
- await atomicWrite(file, renderMarkdown(collected, images));
35
- return result(file);
44
+ const out = await buildThenSwap(path.join(root, "markdown"), async (tmp) => {
45
+ await copyImages(dir, images, tmp);
46
+ await atomicWrite(path.join(tmp, "manual.md"), renderMarkdown(collected, images));
47
+ });
48
+ return result(path.join(out, "manual.md"));
36
49
  }
37
- const out = await freshDir(path.join(root, "html"));
38
- await copyImages(dir, images, out);
50
+ const out = await buildThenSwap(path.join(root, "html"), async (tmp) => {
51
+ await copyImages(dir, images, tmp);
52
+ await copyFonts(tmp, collected.meta.theme);
53
+ const logo = await copyLogo(dir, tmp, collected.meta.theme, warnings);
54
+ await atomicWrite(path.join(tmp, "index.html"), renderHtml(collected, images, logo));
55
+ });
39
56
  const html = path.join(out, "index.html");
40
- await atomicWrite(html, renderHtml(collected, images));
41
57
  if (format === "html")
42
58
  return result(html);
43
59
  const pdf = path.join(root, `${slugify(collected.meta.title) || "manual"}.pdf`);
@@ -45,27 +61,91 @@ export async function exportManual(dir, format, opts = {}) {
45
61
  await printPdf(html, pdf);
46
62
  return result(pdf);
47
63
  }
48
- /** Cor do manual exportado. Só aceita hexadecimal de 6 dígitos; sem cor, volta à da marca. */
49
- export async function setManualTheme(dir, theme) {
50
- if (theme.accent !== undefined && !/^#[0-9a-f]{6}$/i.test(theme.accent)) {
51
- throw new Error("Cor inválida: use o formato #2b4fe0.");
52
- }
64
+ /**
65
+ * Muda a aparência do manual exportado. Só os campos de `patch` mudam (`null` apaga o campo);
66
+ * cores sem contraste suficiente são recusadas.
67
+ */
68
+ export async function setManualTheme(dir, patch) {
69
+ if (typeof patch.logo === "string")
70
+ await checkLogoFile(dir, checkLogoPath(patch.logo));
53
71
  await updateManual(dir, (meta) => {
54
- if (theme.accent)
55
- meta.theme = { ...meta.theme, accent: theme.accent.toLowerCase() };
72
+ const theme = mergeTheme(meta.theme, patch);
73
+ if (Object.keys(theme).length)
74
+ meta.theme = theme;
56
75
  else
57
76
  delete meta.theme;
58
77
  });
59
78
  }
60
- async function freshDir(dir) {
61
- await rm(dir, { recursive: true, force: true });
62
- await mkdir(dir, { recursive: true });
63
- return dir;
79
+ /** Grava o logo em `assets/` (PNG, JPG ou SVG, até 1 MB) e o põe no tema. Devolve o caminho. */
80
+ export async function setManualLogo(dir, fileName, data) {
81
+ if (!LOGO_EXT.test(path.extname(fileName)))
82
+ throw logoTypeError();
83
+ if (data.byteLength > LOGO_MAX_BYTES)
84
+ throw logoSizeError();
85
+ const logo = await saveAsset(dir, fileName, data);
86
+ try {
87
+ await setManualTheme(dir, { logo });
88
+ }
89
+ catch (err) {
90
+ await rm(resolveInside(dir, logo), { force: true }); // não deixa o arquivo novo órfão
91
+ throw err;
92
+ }
93
+ return logo;
94
+ }
95
+ /** Gera em pasta temporária ao lado e só no fim troca pela `final`: se algo falhar, a exportação anterior fica intacta. */
96
+ async function buildThenSwap(final, build) {
97
+ const tmp = path.join(path.dirname(final), `.${path.basename(final)}-${randomUUID()}`);
98
+ await mkdir(tmp, { recursive: true });
99
+ try {
100
+ await build(tmp);
101
+ // Antigo vai para o lado, não para o lixo: se o rename falhar (EBUSY no Windows), ele volta.
102
+ const old = `${tmp}.old`;
103
+ const hadOld = await rename(final, old).then(() => true, () => false);
104
+ try {
105
+ await rename(tmp, final);
106
+ }
107
+ catch (err) {
108
+ if (hadOld)
109
+ await rename(old, final).catch(() => undefined);
110
+ throw err;
111
+ }
112
+ if (hadOld)
113
+ await rm(old, { recursive: true, force: true });
114
+ }
115
+ catch (err) {
116
+ await rm(tmp, { recursive: true, force: true });
117
+ throw err;
118
+ }
119
+ return final;
64
120
  }
65
121
  /** Copia só as imagens usadas, mantendo `assets/` para os caminhos do HTML e do Markdown valerem. */
66
122
  async function copyImages(dir, images, out) {
67
123
  await mkdir(path.join(out, "assets"), { recursive: true });
68
124
  for (const asset of images.keys())
69
- await copyFile(resolveInside(dir, asset), path.join(out, asset));
125
+ await copyFile(await resolveInsideReal(dir, asset), path.join(out, asset));
126
+ }
127
+ /** Copia o logo do tema. Sumiu do disco: avisa e o manual sai sem ele. */
128
+ async function copyLogo(dir, out, theme, warnings) {
129
+ if (!theme?.logo)
130
+ return null;
131
+ try {
132
+ const logo = checkLogoPath(theme.logo);
133
+ await copyFile(await resolveInsideReal(dir, logo), path.join(out, logo));
134
+ return logo;
135
+ }
136
+ catch (err) {
137
+ const missing = err.code === "ENOENT";
138
+ warnings.push(missing ? `Logo não encontrado: ${theme.logo}` : `Logo não copiado (${theme.logo}): ${err.message}`);
139
+ return null;
140
+ }
141
+ }
142
+ /** Só as fontes que o tema usa vão com o HTML (e a licença OFL de cada uma), para o manual abrir igual sem internet. */
143
+ async function copyFonts(out, theme) {
144
+ await mkdir(path.join(out, "fonts"), { recursive: true });
145
+ for (const font of usedFonts(theme)) {
146
+ for (const name of [...fontFiles(font).map((f) => f.file), `OFL-${font.id}.txt`]) {
147
+ await copyFile(new URL(name, FONTS_DIR), path.join(out, "fonts", name));
148
+ }
149
+ }
70
150
  }
71
151
  //# sourceMappingURL=index.js.map
@@ -11,6 +11,7 @@ export interface Labels {
11
11
  hotspotPage: (title: string) => string;
12
12
  removedPage: string;
13
13
  untitled: string;
14
+ callouts: Record<string, string>;
14
15
  }
15
16
  /** Manual em português (pt, pt-BR, pt-PT) recebe os textos em português; os outros, em inglês. */
16
17
  export declare const labelsFor: (language: string) => Labels;
@@ -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 === decodeURI(file));
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
- /** Desce os títulos da página para ficarem abaixo do título dela (`level`). Ignora blocos de código. */
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
- return body
64
- .split("\n")
65
- .map((line) => {
70
+ lines.forEach((line, at) => {
66
71
  if (/^\s*(```|~~~)/.test(line))
67
72
  fence = !fence;
68
- if (fence)
69
- return line;
70
- return line.replace(/^(#{1,6}) /, (_, hashes) => `${"#".repeat(Math.min(hashes.length + level, 6))} `);
71
- })
72
- .join("\n");
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
@@ -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 Error("Para gerar o PDF, o Explicaí usa o Chrome, o Edge ou o Chromium da sua máquina, e nenhum foi encontrado. " +
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. */
@@ -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", decodeURI(src)));
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
- images.set(asset, { asset, size: await imageSize(abs), annotations: await readAnnotations(dir, asset) });
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 === decodeURI(m[1] ?? ""));
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, decodeURI(m[2])) : pageAnchor(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++) {