explicai 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/LICENSE +21 -0
  3. package/README.md +113 -2
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +51 -0
  6. package/dist/core/annotations.d.ts +7 -0
  7. package/dist/core/annotations.js +110 -0
  8. package/dist/core/assets.d.ts +17 -0
  9. package/dist/core/assets.js +116 -0
  10. package/dist/core/errors.d.ts +9 -0
  11. package/dist/core/errors.js +14 -0
  12. package/dist/core/export/annotationCss.d.ts +5 -0
  13. package/dist/core/export/annotationCss.js +26 -0
  14. package/dist/core/export/annotationShapes.d.ts +37 -0
  15. package/dist/core/export/annotationShapes.js +38 -0
  16. package/dist/core/export/callout.d.ts +11 -0
  17. package/dist/core/export/callout.js +26 -0
  18. package/dist/core/export/collect.d.ts +27 -0
  19. package/dist/core/export/collect.js +49 -0
  20. package/dist/core/export/color.d.ts +11 -0
  21. package/dist/core/export/color.js +33 -0
  22. package/dist/core/export/fonts.d.ts +35 -0
  23. package/dist/core/export/fonts.js +40 -0
  24. package/dist/core/export/headings.d.ts +7 -0
  25. package/dist/core/export/headings.js +17 -0
  26. package/dist/core/export/html.d.ts +4 -0
  27. package/dist/core/export/html.js +242 -0
  28. package/dist/core/export/imageSize.d.ts +9 -0
  29. package/dist/core/export/imageSize.js +72 -0
  30. package/dist/core/export/index.d.ts +29 -0
  31. package/dist/core/export/index.js +151 -0
  32. package/dist/core/export/labels.d.ts +17 -0
  33. package/dist/core/export/labels.js +31 -0
  34. package/dist/core/export/logo.d.ts +10 -0
  35. package/dist/core/export/logo.js +29 -0
  36. package/dist/core/export/markdown.d.ts +8 -0
  37. package/dist/core/export/markdown.js +83 -0
  38. package/dist/core/export/pdf.d.ts +6 -0
  39. package/dist/core/export/pdf.js +68 -0
  40. package/dist/core/export/refs.d.ts +24 -0
  41. package/dist/core/export/refs.js +66 -0
  42. package/dist/core/export/textColors.d.ts +7 -0
  43. package/dist/core/export/textColors.js +18 -0
  44. package/dist/core/export/theme.d.ts +28 -0
  45. package/dist/core/export/theme.js +140 -0
  46. package/dist/core/frontmatter.d.ts +5 -0
  47. package/dist/core/frontmatter.js +17 -0
  48. package/dist/core/fs.d.ts +13 -0
  49. package/dist/core/fs.js +45 -0
  50. package/dist/core/index.d.ts +14 -0
  51. package/dist/core/index.js +15 -0
  52. package/dist/core/manuals.d.ts +23 -0
  53. package/dist/core/manuals.js +86 -0
  54. package/dist/core/pages.d.ts +43 -0
  55. package/dist/core/pages.js +179 -0
  56. package/dist/core/redact.d.ts +9 -0
  57. package/dist/core/redact.js +107 -0
  58. package/dist/core/text.d.ts +5 -0
  59. package/dist/core/text.js +28 -0
  60. package/dist/core/trash.d.ts +25 -0
  61. package/dist/core/trash.js +84 -0
  62. package/dist/core/tree.d.ts +13 -0
  63. package/dist/core/tree.js +28 -0
  64. package/dist/core/types.d.ts +111 -0
  65. package/dist/core/types.js +7 -0
  66. package/dist/core/versions.d.ts +27 -0
  67. package/dist/core/versions.js +82 -0
  68. package/dist/server/errorPage.d.ts +9 -0
  69. package/dist/server/errorPage.js +122 -0
  70. package/dist/server/http.d.ts +31 -0
  71. package/dist/server/http.js +132 -0
  72. package/dist/server/index.d.ts +4 -0
  73. package/dist/server/index.js +60 -0
  74. package/dist/server/router.d.ts +30 -0
  75. package/dist/server/router.js +28 -0
  76. package/dist/server/routes/assets.d.ts +2 -0
  77. package/dist/server/routes/assets.js +63 -0
  78. package/dist/server/routes/export.d.ts +2 -0
  79. package/dist/server/routes/export.js +43 -0
  80. package/dist/server/routes/fonts.d.ts +3 -0
  81. package/dist/server/routes/fonts.js +24 -0
  82. package/dist/server/routes/manuals.d.ts +2 -0
  83. package/dist/server/routes/manuals.js +40 -0
  84. package/dist/server/routes/pages.d.ts +2 -0
  85. package/dist/server/routes/pages.js +79 -0
  86. package/dist/server/routes/versions.d.ts +2 -0
  87. package/dist/server/routes/versions.js +43 -0
  88. package/dist/server/routes/web.d.ts +3 -0
  89. package/dist/server/routes/web.js +29 -0
  90. package/dist/server/security.d.ts +6 -0
  91. package/dist/server/security.js +17 -0
  92. package/dist/server/workspace.d.ts +5 -0
  93. package/dist/server/workspace.js +17 -0
  94. package/dist/web/assets/archivo-latin-ext-wdth-normal-7khWdh9v.woff2 +0 -0
  95. package/dist/web/assets/archivo-latin-wdth-normal-DY7AcnAa.woff2 +0 -0
  96. package/dist/web/assets/archivo-vietnamese-wdth-normal-rJmnGBSt.woff2 +0 -0
  97. package/dist/web/assets/atkinson-hyperlegible-next-latin-400-normal-DPp7N0cD.woff +0 -0
  98. package/dist/web/assets/atkinson-hyperlegible-next-latin-400-normal-FfmJh7DR.woff2 +0 -0
  99. package/dist/web/assets/atkinson-hyperlegible-next-latin-700-normal-C2qZD_Q9.woff +0 -0
  100. package/dist/web/assets/atkinson-hyperlegible-next-latin-700-normal-Dpiyiu63.woff2 +0 -0
  101. package/dist/web/assets/atkinson-hyperlegible-next-latin-ext-400-normal-BalHKn7d.woff2 +0 -0
  102. package/dist/web/assets/atkinson-hyperlegible-next-latin-ext-400-normal-qxMiU51n.woff +0 -0
  103. package/dist/web/assets/atkinson-hyperlegible-next-latin-ext-700-normal-292QVhS1.woff +0 -0
  104. package/dist/web/assets/atkinson-hyperlegible-next-latin-ext-700-normal-CBw-mJf_.woff2 +0 -0
  105. package/dist/web/assets/index-B7Gr83D6.js +262 -0
  106. package/dist/web/assets/index-DFBrhkII.css +1 -0
  107. package/dist/web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
  108. package/dist/web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
  109. package/dist/web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
  110. package/dist/web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
  111. package/dist/web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
  112. package/dist/web/favicon.svg +1 -0
  113. package/dist/web/index.html +23 -0
  114. package/fonts/OFL-archivo.txt +93 -0
  115. package/fonts/OFL-atkinson-hyperlegible-next.txt +93 -0
  116. package/fonts/OFL-inter.txt +93 -0
  117. package/fonts/OFL-jetbrains-mono.txt +93 -0
  118. package/fonts/OFL-lora.txt +93 -0
  119. package/fonts/OFL-montserrat.txt +93 -0
  120. package/fonts/OFL-playfair-display.txt +93 -0
  121. package/fonts/OFL-source-sans-3.txt +93 -0
  122. package/fonts/OFL-source-serif-4.txt +93 -0
  123. package/fonts/archivo-latin-wght-normal.woff2 +0 -0
  124. package/fonts/atkinson-hyperlegible-next-latin-400-italic.woff2 +0 -0
  125. package/fonts/atkinson-hyperlegible-next-latin-400-normal.woff2 +0 -0
  126. package/fonts/atkinson-hyperlegible-next-latin-700-normal.woff2 +0 -0
  127. package/fonts/inter-latin-wght-italic.woff2 +0 -0
  128. package/fonts/inter-latin-wght-normal.woff2 +0 -0
  129. package/fonts/jetbrains-mono-latin-wght-normal.woff2 +0 -0
  130. package/fonts/lora-latin-wght-italic.woff2 +0 -0
  131. package/fonts/lora-latin-wght-normal.woff2 +0 -0
  132. package/fonts/montserrat-latin-wght-normal.woff2 +0 -0
  133. package/fonts/playfair-display-latin-wght-normal.woff2 +0 -0
  134. package/fonts/source-sans-3-latin-wght-italic.woff2 +0 -0
  135. package/fonts/source-sans-3-latin-wght-normal.woff2 +0 -0
  136. package/fonts/source-serif-4-latin-wght-italic.woff2 +0 -0
  137. package/fonts/source-serif-4-latin-wght-normal.woff2 +0 -0
  138. package/package.json +124 -4
@@ -0,0 +1,29 @@
1
+ import { type CollectOptions } from "./collect.js";
2
+ import { type ThemePatch } from "./theme.js";
3
+ export { logoSizeError } from "./logo.js";
4
+ export { findBrowser } from "./pdf.js";
5
+ /** Pasta da exportação, dentro do manual. Cada exportação substitui a anterior do mesmo formato. */
6
+ export declare const EXPORT_DIR = "exportacao";
7
+ export type ExportFormat = "html" | "pdf" | "markdown";
8
+ export declare const EXPORT_FORMATS: ExportFormat[];
9
+ export interface ExportResult {
10
+ format: ExportFormat;
11
+ /** Arquivo principal, relativo à pasta do manual (`exportacao/html/index.html`). */
12
+ file: string;
13
+ pages: number;
14
+ /** Páginas que ficaram de fora e imagens que não existem, com o motivo. */
15
+ warnings: string[];
16
+ }
17
+ /**
18
+ * Exporta o manual: HTML (um arquivo com sumário, para publicar ou abrir no navegador), PDF (impresso
19
+ * a partir desse HTML) ou Markdown (um arquivo só, para quem lê com IA). Usa a Versão do manual
20
+ * de cada página.
21
+ */
22
+ export declare function exportManual(dir: string, format: ExportFormat, opts?: CollectOptions): Promise<ExportResult>;
23
+ /**
24
+ * Muda a aparência do manual exportado. Só os campos de `patch` mudam (`null` apaga o campo);
25
+ * cores sem contraste suficiente são recusadas.
26
+ */
27
+ export declare function setManualTheme(dir: string, patch: ThemePatch): Promise<void>;
28
+ /** Grava o logo em `assets/` (PNG, JPG ou SVG, até 1 MB) e o põe no tema. Devolve o caminho. */
29
+ export declare function setManualLogo(dir: string, fileName: string, data: Uint8Array): Promise<string>;
@@ -0,0 +1,151 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { copyFile, mkdir, rename, rm } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { saveAsset } from "../assets.js";
5
+ import { ExplicaiError } from "../errors.js";
6
+ import { atomicWrite, relativePosix, resolveInside, resolveInsideReal } from "../fs.js";
7
+ import { updateManual } from "../manuals.js";
8
+ import { slugify } from "../text.js";
9
+ import { collectPages } from "./collect.js";
10
+ import { FONTS_DIR, fontFiles } from "./fonts.js";
11
+ import { renderHtml } from "./html.js";
12
+ import { checkLogoFile, checkLogoPath, LOGO_EXT, LOGO_MAX_BYTES, logoSizeError, logoTypeError } from "./logo.js";
13
+ import { renderMarkdown } from "./markdown.js";
14
+ import { printPdf } from "./pdf.js";
15
+ import { loadImages } from "./refs.js";
16
+ import { mergeTheme, usedFonts } from "./theme.js";
17
+ export { logoSizeError } from "./logo.js";
18
+ export { findBrowser } from "./pdf.js";
19
+ /** Pasta da exportação, dentro do manual. Cada exportação substitui a anterior do mesmo formato. */
20
+ export const EXPORT_DIR = "exportacao";
21
+ export const EXPORT_FORMATS = ["html", "pdf", "markdown"];
22
+ /**
23
+ * Exporta o manual: HTML (um arquivo com sumário, para publicar ou abrir no navegador), PDF (impresso
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.
26
+ */
27
+ export async function exportManual(dir, format, opts = {}) {
28
+ const collected = await collectPages(dir, opts);
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];
36
+ const root = resolveInside(dir, EXPORT_DIR);
37
+ const result = (file) => ({
38
+ format,
39
+ file: relativePosix(dir, file),
40
+ pages: collected.pages.length,
41
+ warnings,
42
+ });
43
+ if (format === "markdown") {
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"));
49
+ }
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
+ });
56
+ const html = path.join(out, "index.html");
57
+ if (format === "html")
58
+ return result(html);
59
+ const pdf = path.join(root, `${slugify(collected.meta.title) || "manual"}.pdf`);
60
+ await rm(pdf, { force: true });
61
+ await printPdf(html, pdf);
62
+ return result(pdf);
63
+ }
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));
71
+ await updateManual(dir, (meta) => {
72
+ const theme = mergeTheme(meta.theme, patch);
73
+ if (Object.keys(theme).length)
74
+ meta.theme = theme;
75
+ else
76
+ delete meta.theme;
77
+ });
78
+ }
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;
120
+ }
121
+ /** Copia só as imagens usadas, mantendo `assets/` para os caminhos do HTML e do Markdown valerem. */
122
+ async function copyImages(dir, images, out) {
123
+ await mkdir(path.join(out, "assets"), { recursive: true });
124
+ for (const asset of images.keys())
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
+ }
150
+ }
151
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,17 @@
1
+ /** Textos que o Explicaí escreve no manual exportado. Seguem o idioma do manual, não o da interface. */
2
+ export interface Labels {
3
+ toc: string;
4
+ pages: (n: number) => string;
5
+ colophon: string;
6
+ missingImage: string;
7
+ goTo: string;
8
+ legend: string;
9
+ noDescription: string;
10
+ hotspotUrl: (href: string) => string;
11
+ hotspotPage: (title: string) => string;
12
+ removedPage: string;
13
+ untitled: string;
14
+ callouts: Record<string, string>;
15
+ }
16
+ /** Manual em português (pt, pt-BR, pt-PT) recebe os textos em português; os outros, em inglês. */
17
+ export declare const labelsFor: (language: string) => Labels;
@@ -0,0 +1,31 @@
1
+ const PT = {
2
+ toc: "Sumário",
3
+ pages: (n) => `${n} ${n === 1 ? "página" : "páginas"}`,
4
+ colophon: "Manual feito com o Explicaí Studio.",
5
+ missingImage: "imagem não encontrada",
6
+ goTo: "Ir para",
7
+ legend: "Legenda da imagem",
8
+ noDescription: "(sem descrição)",
9
+ hotspotUrl: (href) => `Área clicável: leva a ${href}`,
10
+ hotspotPage: (title) => `Área clicável: leva à página "${title}"`,
11
+ removedPage: "removida",
12
+ untitled: "Sem título",
13
+ callouts: { tip: "Dica", warning: "Atenção", caution: "Erro", note: "Nota" },
14
+ };
15
+ const EN = {
16
+ toc: "Contents",
17
+ pages: (n) => `${n} ${n === 1 ? "page" : "pages"}`,
18
+ colophon: "Manual made with Explicaí Studio.",
19
+ missingImage: "image not found",
20
+ goTo: "Go to",
21
+ legend: "Image legend",
22
+ noDescription: "(no description)",
23
+ hotspotUrl: (href) => `Clickable area: goes to ${href}`,
24
+ hotspotPage: (title) => `Clickable area: goes to the page "${title}"`,
25
+ removedPage: "removed",
26
+ untitled: "Untitled",
27
+ callouts: { tip: "Tip", warning: "Warning", caution: "Error", note: "Note" },
28
+ };
29
+ /** Manual em português (pt, pt-BR, pt-PT) recebe os textos em português; os outros, em inglês. */
30
+ export const labelsFor = (language) => (language.toLowerCase().startsWith("pt") ? PT : EN);
31
+ //# sourceMappingURL=labels.js.map
@@ -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
@@ -0,0 +1,8 @@
1
+ import type { Collected } from "./collect.js";
2
+ import { type ImageInfo } from "./refs.js";
3
+ /**
4
+ * O manual num Markdown só, pensado para quem lê com IA: sumário, uma seção por página na ordem da
5
+ * árvore, títulos um nível abaixo do da página, e depois de cada print a legenda e as áreas clicáveis
6
+ * em texto (a IA não vê a imagem, mas lê o que ela aponta).
7
+ */
8
+ export declare function renderMarkdown(collected: Collected, images: Map<string, ImageInfo>): string;
@@ -0,0 +1,83 @@
1
+ import { decodeUri, slugify } from "../text.js";
2
+ import { exportLevels } from "./headings.js";
3
+ import { labelsFor } from "./labels.js";
4
+ import { assetFromSrc } from "./refs.js";
5
+ import { TEXT_COLORS } from "./textColors.js";
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;
9
+ const PAGE_LINK = /\]\((?:\.\/)?([^/)#\s]+\.md)(#[^)\s]*)?\)/g;
10
+ /**
11
+ * O manual num Markdown só, pensado para quem lê com IA: sumário, uma seção por página na ordem da
12
+ * árvore, títulos um nível abaixo do da página, e depois de cada print a legenda e as áreas clicáveis
13
+ * em texto (a IA não vê a imagem, mas lê o que ela aponta).
14
+ */
15
+ export function renderMarkdown(collected, images) {
16
+ const { meta, pages } = collected;
17
+ const L = labelsFor(meta.language);
18
+ const anchor = (p) => slugify(`${p.number} ${p.title}`);
19
+ const toc = pages.map((p) => `${" ".repeat(p.depth)}- [${p.number} ${p.title}](#${anchor(p)})`).join("\n");
20
+ const sections = pages.map((page) => {
21
+ const level = Math.min(page.depth + 2, 6);
22
+ const body = shiftHeadings(page.body, level)
23
+ .replace(COLOR_SPAN, (all, name, text) => (Object.hasOwn(TEXT_COLORS, name) ? text : all))
24
+ .replace(PAGE_LINK, (all, file, hash) => {
25
+ const target = pages.find((p) => p.fileName === decodeUri(file));
26
+ return target ? `](#${hash ? hash.slice(1) : anchor(target)})` : all;
27
+ })
28
+ .split("\n")
29
+ .map((line) => {
30
+ // A legenda vai depois da linha do print, com a mesma indentação (o print pode estar num passo).
31
+ const indent = /^\s*/.exec(line)?.[0] ?? "";
32
+ const notes = [];
33
+ const out = line.replace(IMAGE_LINE, (all, alt, src) => {
34
+ const asset = assetFromSrc(src);
35
+ const info = asset ? images.get(asset) : undefined;
36
+ if (!info)
37
+ return all;
38
+ notes.push(...imageNotes(info, pages, L));
39
+ return `![${alt}](${info.asset})`;
40
+ });
41
+ if (!notes.length)
42
+ return out;
43
+ return `${out}\n\n${indent}> ${L.legend}:\n${notes.map((n) => `${indent}> ${n}`).join("\n")}`;
44
+ })
45
+ .join("\n");
46
+ return `${"#".repeat(level)} ${page.number} ${page.title}\n\n${body.trim()}`;
47
+ });
48
+ return `# ${meta.title}\n\n## ${L.toc}\n\n${toc}\n\n${sections.join("\n\n")}\n`;
49
+ }
50
+ /** Legenda e áreas clicáveis do print, em texto, uma por linha. */
51
+ function imageNotes(info, pages, L) {
52
+ const { markers, hotspots } = info.annotations;
53
+ const lines = [...markers]
54
+ .sort((a, b) => a.n - b.n)
55
+ .map((m) => `${m.n}. ${m.label ?? L.noDescription}`)
56
+ .concat(hotspots.map((h) => {
57
+ if (h.target.type === "url")
58
+ return L.hotspotUrl(h.target.href);
59
+ const id = h.target.pageId;
60
+ const page = pages.find((p) => p.id === id);
61
+ return L.hotspotPage(page?.title ?? L.removedPage);
62
+ }));
63
+ return lines;
64
+ }
65
+ /** Põe os títulos da página abaixo do título dela (`level`), sem pular nível. Ignora blocos de código. */
66
+ function shiftHeadings(body, level) {
67
+ const lines = body.split("\n");
68
+ const found = [];
69
+ let fence = false;
70
+ lines.forEach((line, at) => {
71
+ if (/^\s*(```|~~~)/.test(line))
72
+ fence = !fence;
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");
82
+ }
83
+ //# sourceMappingURL=markdown.js.map
@@ -0,0 +1,6 @@
1
+ export declare function findBrowser(): Promise<string | null>;
2
+ /**
3
+ * Imprime o HTML exportado em PDF com o Chrome (ou Edge) da máquina, sem janela.
4
+ * Usa um perfil temporário: com o perfil normal, um Chrome já aberto "engolia" o pedido.
5
+ */
6
+ export declare function printPdf(htmlFile: string, pdfFile: string): Promise<void>;
@@ -0,0 +1,68 @@
1
+ import { execFile } from "node:child_process";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { pathToFileURL } from "node:url";
6
+ import { promisify } from "node:util";
7
+ import { ExplicaiError } from "../errors.js";
8
+ import { exists } from "../fs.js";
9
+ const run = promisify(execFile);
10
+ /** Onde costumam ficar Chrome, Edge e Chromium. EXPLICAI_BROWSER aponta outro. */
11
+ function candidates() {
12
+ const env = process.env;
13
+ const custom = env.EXPLICAI_BROWSER ? [env.EXPLICAI_BROWSER] : [];
14
+ if (process.platform === "win32") {
15
+ const roots = [env.PROGRAMFILES, env["PROGRAMFILES(X86)"], env.LOCALAPPDATA].filter(Boolean);
16
+ return custom.concat(roots.flatMap((r) => [
17
+ path.join(r, "Google", "Chrome", "Application", "chrome.exe"),
18
+ path.join(r, "Microsoft", "Edge", "Application", "msedge.exe"),
19
+ path.join(r, "Chromium", "Application", "chrome.exe"),
20
+ ]));
21
+ }
22
+ if (process.platform === "darwin") {
23
+ return custom.concat([
24
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
25
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
26
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
27
+ ]);
28
+ }
29
+ const dirs = (env.PATH ?? "").split(path.delimiter).filter(Boolean);
30
+ const names = ["google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge"];
31
+ return custom.concat(names.flatMap((n) => dirs.map((d) => path.join(d, n))));
32
+ }
33
+ export async function findBrowser() {
34
+ for (const file of candidates())
35
+ if (await exists(file))
36
+ return file;
37
+ return null;
38
+ }
39
+ /**
40
+ * Imprime o HTML exportado em PDF com o Chrome (ou Edge) da máquina, sem janela.
41
+ * Usa um perfil temporário: com o perfil normal, um Chrome já aberto "engolia" o pedido.
42
+ */
43
+ export async function printPdf(htmlFile, pdfFile) {
44
+ const browser = await findBrowser();
45
+ if (!browser) {
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. " +
47
+ "Instale um deles ou informe o caminho na variável EXPLICAI_BROWSER.");
48
+ }
49
+ const profile = await mkdtemp(path.join(tmpdir(), "explicai-pdf-"));
50
+ try {
51
+ await run(browser, [
52
+ "--headless=new",
53
+ "--disable-gpu",
54
+ "--no-first-run",
55
+ "--no-default-browser-check",
56
+ `--user-data-dir=${profile}`,
57
+ "--no-pdf-header-footer",
58
+ `--print-to-pdf=${pdfFile}`,
59
+ pathToFileURL(htmlFile).href,
60
+ ], { timeout: 120_000 });
61
+ }
62
+ finally {
63
+ await rm(profile, { recursive: true, force: true }).catch(() => { });
64
+ }
65
+ if (!(await exists(pdfFile)))
66
+ throw new Error("O navegador terminou sem gerar o PDF.");
67
+ }
68
+ //# sourceMappingURL=pdf.js.map
@@ -0,0 +1,24 @@
1
+ import type { Annotations } from "../types.js";
2
+ import type { ExportPage } from "./collect.js";
3
+ import { type Size } from "./imageSize.js";
4
+ export interface ImageInfo {
5
+ /** Caminho no manual: `assets/tela.png`. */
6
+ asset: string;
7
+ size: Size | null;
8
+ annotations: Annotations;
9
+ }
10
+ /** `../assets/tela.png` (como está na página) → `assets/tela.png`. Imagem de fora do manual: null. */
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 e vai para `missing`. */
13
+ export declare function loadImages(dir: string, pages: ExportPage[], missing?: string[], warnings?: string[]): Promise<Map<string, ImageInfo>>;
14
+ /** Id da seção de uma página no manual exportado. */
15
+ export declare const pageAnchor: (pageId: string) => string;
16
+ /** Id de um título dentro de uma página: `#secao` de um link vira este id. */
17
+ export declare const headingAnchor: (pageId: string, text: string) => string;
18
+ /**
19
+ * Link entre páginas (`./arquivo.md` ou `./arquivo.md#secao`) → âncora dentro do manual exportado.
20
+ * Outro tipo de link: null (fica como está).
21
+ */
22
+ export declare function pageLink(href: string, pages: ExportPage[]): string | null;
23
+ /** Só endereços seguros saem como link no manual exportado. */
24
+ export declare function safeHref(href: string): string | null;
@@ -0,0 +1,66 @@
1
+ import path from "node:path";
2
+ import { emptyAnnotations } from "../annotations.js";
3
+ import { readAnnotations } from "../assets.js";
4
+ import { ExplicaiError } from "../errors.js";
5
+ import { exists, resolveInside } from "../fs.js";
6
+ import { decodeUri, slugify } from "../text.js";
7
+ import { imageSize } from "./imageSize.js";
8
+ const IMAGE = /!\[[^\]]*\]\(([^)\s]+)/g;
9
+ /** `../assets/tela.png` (como está na página) → `assets/tela.png`. Imagem de fora do manual: null. */
10
+ export function assetFromSrc(src) {
11
+ if (/^[a-z][a-z0-9+.-]*:/i.test(src))
12
+ return null;
13
+ const p = path.posix.normalize(path.posix.join("pages", decodeUri(src)));
14
+ return p.startsWith("assets/") ? p : null;
15
+ }
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 = []) {
18
+ const images = new Map();
19
+ for (const page of pages) {
20
+ for (const [, src] of page.body.matchAll(IMAGE)) {
21
+ const asset = src && assetFromSrc(src);
22
+ if (!asset || images.has(asset) || missing.includes(asset))
23
+ continue;
24
+ const abs = resolveInside(dir, asset);
25
+ if (!(await exists(abs))) {
26
+ missing.push(asset);
27
+ continue;
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() });
39
+ }
40
+ }
41
+ return images;
42
+ }
43
+ /** Id da seção de uma página no manual exportado. */
44
+ export const pageAnchor = (pageId) => `p-${pageId}`;
45
+ /** Id de um título dentro de uma página: `#secao` de um link vira este id. */
46
+ export const headingAnchor = (pageId, text) => `p-${pageId}--${slugify(text)}`;
47
+ /**
48
+ * Link entre páginas (`./arquivo.md` ou `./arquivo.md#secao`) → âncora dentro do manual exportado.
49
+ * Outro tipo de link: null (fica como está).
50
+ */
51
+ export function pageLink(href, pages) {
52
+ const m = /^(?:\.\/)?([^/#?]+\.md)(?:#(.+))?$/.exec(href);
53
+ if (!m)
54
+ return null;
55
+ const page = pages.find((p) => p.fileName === decodeUri(m[1] ?? ""));
56
+ if (!page)
57
+ return null;
58
+ return `#${m[2] ? headingAnchor(page.id, decodeUri(m[2])) : pageAnchor(page.id)}`;
59
+ }
60
+ /** Só endereços seguros saem como link no manual exportado. */
61
+ export function safeHref(href) {
62
+ if (/^(#|mailto:|https?:\/\/)/i.test(href))
63
+ return href;
64
+ return null;
65
+ }
66
+ //# sourceMappingURL=refs.js.map
@@ -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;