sagadeck 1.8.0 → 1.10.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 (107) hide show
  1. package/README.md +25 -8
  2. package/SKILL.md +10 -3
  3. package/bin/sagadeck.js +23 -0
  4. package/docs/ANDAMENTO.md +482 -7
  5. package/docs/PLANO-MELHORIAS.md +180 -0
  6. package/docs/REFERENCIA.md +178 -111
  7. package/package.json +35 -4
  8. package/src/ai/commands.js +2 -2
  9. package/src/ai/context.js +7 -2
  10. package/src/ai/deck-ai.js +4 -2
  11. package/src/ai/ia-check.js +127 -0
  12. package/src/ai/ia-config.js +2 -2
  13. package/src/ai/llm.js +5 -1
  14. package/src/ai/map-analysis-run.js +1 -1
  15. package/src/ai/quality.js +1 -1
  16. package/src/ai/video-generation.js +70 -25
  17. package/src/api-client.js +42 -10
  18. package/src/build.js +46 -14
  19. package/src/code-highlight.js +19 -2
  20. package/src/code-language.js +13 -0
  21. package/src/elements.js +39 -1
  22. package/src/export/pptx.js +7 -2
  23. package/src/export/shots.js +7 -2
  24. package/src/fiscal/normalize.js +19 -0
  25. package/src/fs-retry.js +37 -2
  26. package/src/import/office.js +5 -4
  27. package/src/layouts.js +104 -99
  28. package/src/lessons.js +1 -1
  29. package/src/library.js +7 -7
  30. package/src/map-analysis.js +4 -1
  31. package/src/map-config.js +10 -4
  32. package/src/map.js +21 -8
  33. package/src/mcp/server.js +17 -0
  34. package/src/motion.js +20 -6
  35. package/src/purpose.js +1 -1
  36. package/src/runtime/api-core.js +122 -11
  37. package/src/runtime/api-ui.js +2 -1
  38. package/src/runtime/base.css +88 -332
  39. package/src/runtime/fit.js +3 -2
  40. package/src/runtime/fonts/amatic-sc.css +3 -0
  41. package/src/runtime/fonts/bebas-neue.css +2 -0
  42. package/src/runtime/fonts/cabin-sketch.css +2 -0
  43. package/src/runtime/fonts/fraunces.css +3 -0
  44. package/src/runtime/fonts/gelasio.css +3 -0
  45. package/src/runtime/fonts/ibm-plex-mono.css +4 -0
  46. package/src/runtime/fonts/index.json +10 -1
  47. package/src/runtime/fonts/itim.css +2 -0
  48. package/src/runtime/fonts/montserrat.css +2 -0
  49. package/src/runtime/fonts/space-grotesk.css +2 -0
  50. package/src/runtime/map-background.js +91 -0
  51. package/src/runtime/map.js +41 -9
  52. package/src/runtime/runtime.js +81 -100
  53. package/src/runtime/skins/aurora-dev.css +39 -0
  54. package/src/runtime/skins/caderno.css +33 -0
  55. package/src/runtime/skins/cartaz.css +27 -0
  56. package/src/runtime/skins/infografico.css +29 -0
  57. package/src/runtime/skins/lousa.css +22 -0
  58. package/src/runtime/skins/musgo.css +42 -0
  59. package/src/runtime/skins/terminal-pro.css +33 -0
  60. package/src/runtime/vendor/MAPLIBRE-LEAFLET-LICENSE.txt +15 -0
  61. package/src/runtime/vendor/MAPLIBRE-LICENSE.txt +116 -0
  62. package/src/runtime/vendor/geo-contornos.json +1 -1
  63. package/src/runtime/vendor/maplibre-gl.css +1 -0
  64. package/src/runtime/vendor/maplibre-gl.js +825 -0
  65. package/src/runtime/vendor/maplibre-leaflet.js +234 -0
  66. package/src/runtime/vendor/maplibre-worker.js +10 -0
  67. package/src/studio/ai-chat-route.js +26 -7
  68. package/src/studio/assets/imagens/tour-mapa-botao.jpg +0 -0
  69. package/src/studio/assets/imagens/tour-mapa-editor.jpg +0 -0
  70. package/src/studio/command-approval.js +2 -3
  71. package/src/studio/demo-decks.js +4 -2
  72. package/src/studio/developer-starters.js +46 -0
  73. package/src/studio/export-jobs.js +86 -0
  74. package/src/studio/exporting.js +36 -18
  75. package/src/studio/feature-demos.js +144 -0
  76. package/src/studio/ia-routes.js +9 -0
  77. package/src/studio/layout-samples.js +9 -18
  78. package/src/studio/library-routes.js +7 -9
  79. package/src/studio/project.js +1 -0
  80. package/src/studio/public/about.js +68 -0
  81. package/src/studio/public/ai-settings.js +2 -2
  82. package/src/studio/public/app.js +43 -52
  83. package/src/studio/public/chat.js +5 -1
  84. package/src/studio/public/developer-authoring.css +12 -0
  85. package/src/studio/public/developer-authoring.js +306 -0
  86. package/src/studio/public/export-status.js +154 -0
  87. package/src/studio/public/generation-guide.js +42 -0
  88. package/src/studio/public/index.html +31 -10
  89. package/src/studio/public/library.css +48 -3
  90. package/src/studio/public/library.html +5 -1
  91. package/src/studio/public/library.js +150 -41
  92. package/src/studio/public/map-data.js +7 -3
  93. package/src/studio/public/map-editor.js +625 -236
  94. package/src/studio/public/map-settings.js +3 -1
  95. package/src/studio/public/slide-form.js +27 -29
  96. package/src/studio/public/style.css +56 -9
  97. package/src/studio/public/ui-icons.js +1 -1
  98. package/src/studio/server.js +29 -14
  99. package/src/studio/snapshot.js +8 -4
  100. package/src/studio/template-collections.js +1 -1
  101. package/src/studio/tour.js +89 -7
  102. package/src/themes.js +202 -0
  103. package/templates/cenario-e-ciencia.yaml +1 -21
  104. package/templates/ensaio-api.yaml +3 -3
  105. package/templates/cenario/Texto no cen/303/241rio.yaml" +0 -171
  106. package/templates/cenario/imagens/cidade.svg +0 -30
  107. package/templates/cenario/imagens/pessoa.svg +0 -15
package/src/api-client.js CHANGED
@@ -362,12 +362,43 @@ export class ApiEnvironments {
362
362
  return out.filter((s) => typeof s === "string" && s.length >= 6);
363
363
  }
364
364
 
365
- maskText(env, text) {
365
+ maskText(env, text, contextual = false) {
366
366
  let s = String(text);
367
- for (const secret of this.secrets(env)) s = s.split(secret).join(C.mask(secret));
367
+ if (!contextual) { for (const secret of this.secrets(env)) s = s.split(secret).join(C.mask(secret)); return s; }
368
+ // Pedidos importados codificam segredos em JSON/query/form. A resposta e o relatório podem
369
+ // devolver essas representações; nenhuma delas deve revelar o valor, mesmo quando curto.
370
+ const replacements = new Map();
371
+ for (const secret of [...this.secrets(env), ...Object.values(this.secretVars(env))].filter(Boolean)) {
372
+ const masked = C.mask(secret);
373
+ for (const [raw, safe] of [[secret, masked], [encodeURIComponent(secret), encodeURIComponent(masked)], [JSON.stringify(secret).slice(1, -1), JSON.stringify(masked).slice(1, -1)]]) if (!replacements.has(raw)) replacements.set(raw, safe);
374
+ }
375
+ for (const [raw, safe] of [...replacements].sort((a, b) => b[0].length - a[0].length)) s = s.split(raw).join(safe);
368
376
  return s;
369
377
  }
370
378
 
379
+ maskValue(env, value, depth = 0) {
380
+ if (typeof value === "string") {
381
+ const masked = this.maskText(env, value, true);
382
+ if (depth > 64 || !/^\s*[\[{\"]/.test(value) || Object.values(this.secretVars(env)).includes(value)) return masked;
383
+ try { JSON.parse(value); } catch { return masked; }
384
+ // APIs podem devolver JSON dentro de strings de logs. Decodifica cada string e mascara
385
+ // recursivamente; troca apenas esses spans para preservar números grandes e formatação.
386
+ let result = "", last = 0;
387
+ for (let at = 0; at < value.length; at++) {
388
+ if (value[at] !== '"') continue;
389
+ const from = at++;
390
+ while (at < value.length) { if (value[at] === "\\") at += 2; else if (value[at++] === '"') break; }
391
+ const decoded = JSON.parse(value.slice(from, at)), safe = this.maskValue(env, decoded, depth + 1);
392
+ if (safe !== decoded) { result += value.slice(last, from) + JSON.stringify(safe); last = at; }
393
+ at--;
394
+ }
395
+ return result + value.slice(last);
396
+ }
397
+ if (Array.isArray(value)) return value.map(item => this.maskValue(env, item, depth + 1));
398
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, this.maskValue(env, item, depth + 1)]));
399
+ return value;
400
+ }
401
+
371
402
  async token(env, { renew = false } = {}) {
372
403
  const t = env.token;
373
404
  if (!t || !t.url) return null;
@@ -420,13 +451,13 @@ export class ApiEnvironments {
420
451
  });
421
452
  throw new ApiError(`Ambiente "${env.name}" não tem ${names.join(", ")} configurado em secrets:`, "config");
422
453
  }
423
- req = { ...req, url: C.render(req.url, sv), headers: C.render(req.headers || {}, sv), body: C.render(req.body, sv) };
454
+ req = { ...req, url: C.renderURL(req.url, sv, req.urlEncoding), headers: C.render(req.headers || {}, sv), body: C.renderBody(req.body, sv, req.bodyEncoding) };
424
455
  const headers = { Accept: "application/json, text/event-stream, */*", ...C.render(env.headers || {}, sv), ...(req.headers || {}) };
425
456
  let body = req.body;
426
457
  const file = req.file || null; // { name, type, data: Buffer } — o arquivo do slide (upload ou base64)
427
458
  const needsFile = (req.form && Object.values(req.form).includes("@file")) || /\{\{\s*file\./.test(JSON.stringify(body ?? ""));
428
459
  if (needsFile && !file) throw new ApiError("Este slide precisa de um arquivo: arraste um para o slide ou defina file: no YAML.", "config");
429
- if (file && body != null) body = C.render(body, { "file.base64": file.data.toString("base64"), "file.name": file.name, "file.type": file.type || "application/octet-stream" });
460
+ if (file && body != null) body = C.renderBody(body, { "file.base64": file.data.toString("base64"), "file.name": file.name, "file.type": file.type || "application/octet-stream" }, req.bodyEncoding);
430
461
  if (req.form) {
431
462
  // multipart/form-data, montado à mão (sem dependências)
432
463
  const boundary = "----sagadeck" + Math.random().toString(16).slice(2);
@@ -453,8 +484,9 @@ export class ApiEnvironments {
453
484
  const tok = await this.token(env, { renew });
454
485
  return tok ? { ...headers, [authHeader]: (t.prefix ?? "Bearer ") + tok } : headers;
455
486
  };
456
- const sent = (h) => ({ method, url: this.maskText(env, req.url), headers: JSON.parse(this.maskText(env, JSON.stringify(h))), ...(file ? { file: { name: file.name, type: file.type, size: file.data.length } } : {}) });
457
- return { env, method, body, withToken, sent, timeout: (Number(env.timeout) || 60) * 1000 };
487
+ const contextual = !!(req.bodyEncoding || req.urlEncoding);
488
+ const sent = (h) => ({ method, url: this.maskText(env, req.url, contextual), headers: contextual ? this.maskValue(env, h) : JSON.parse(this.maskText(env, JSON.stringify(h))), ...(file ? { file: { name: file.name, type: file.type, size: file.data.length } } : {}) });
489
+ return { env, method, url: req.url, body, withToken, sent, contextual, timeout: (Number(env.timeout) || 60) * 1000 };
458
490
  }
459
491
 
460
492
  // Pedido completo: a resposta volta inteira (JSON ou texto), com os segredos mascarados.
@@ -463,7 +495,7 @@ export class ApiEnvironments {
463
495
  const run = async (renew) => {
464
496
  const headers = await p.withToken(renew);
465
497
  const t0 = Date.now();
466
- const res = await open({ method: p.method, url: req.url, headers, body: p.body, ca: this.ca(p.env), insecure: !!p.env.insecure, timeout: p.timeout });
498
+ const res = await open({ method: p.method, url: p.url, headers, body: p.body, ca: this.ca(p.env), insecure: !!p.env.insecure, timeout: p.timeout });
467
499
  const buf = await readAll(res);
468
500
  return { res, buf, headers, ms: Date.now() - t0 };
469
501
  };
@@ -483,7 +515,7 @@ export class ApiEnvironments {
483
515
  }
484
516
  // áudio (TTS): vai inteiro, em base64, para o slide tocar
485
517
  const body = /^audio\//i.test(type) ? { _audio: true, type: type.split(";")[0], base64: r.buf.toString("base64") }
486
- : parseBody(Buffer.from(this.maskText(p.env, r.buf.toString("utf8"))), type);
518
+ : p.contextual ? this.maskValue(p.env, parseBody(r.buf, type)) : parseBody(Buffer.from(this.maskText(p.env, r.buf.toString("utf8"))), type);
487
519
  return {
488
520
  ok: r.res.statusCode < 400, status: r.res.statusCode, statusText: r.res.statusMessage || "", ms: r.ms, size: r.buf.length,
489
521
  type, body, sent: p.sent(r.headers), env: p.env.name, token: this.tokenInfo(p.env.name), ...(jwt ? { jwt } : {}),
@@ -610,7 +642,7 @@ export class ApiEnvironments {
610
642
  async open(req) {
611
643
  const p = await this.prepare(req);
612
644
  const headers = await p.withToken(false);
613
- const res = await open({ method: p.method, url: req.url, headers, body: p.body, ca: this.ca(p.env), insecure: !!p.env.insecure, timeout: p.timeout });
614
- return { res, env: p.env, sent: p.sent(headers), mask: (s) => this.maskText(p.env, s) };
645
+ const res = await open({ method: p.method, url: p.url, headers, body: p.body, ca: this.ca(p.env), insecure: !!p.env.insecure, timeout: p.timeout });
646
+ return { res, env: p.env, sent: p.sent(headers), mask: (s) => this.maskText(p.env, s, p.contextual) };
615
647
  }
616
648
  }
package/src/build.js CHANGED
@@ -8,9 +8,9 @@ import fs from "node:fs";
8
8
  import path from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import YAML from "yaml";
11
- import { resolveTheme, themeCSS, scopedThemeCSS } from "./themes.js";
11
+ import { resolveTheme, themeCSS, scopedThemeCSS, THEMES } from "./themes.js";
12
12
  import { identityFor, identitiesFile } from "./identity.js";
13
- import { LAYOUTS, SCENES, fullAsFigure } from "./layouts.js";
13
+ import { LAYOUTS, fullAsFigure } from "./layouts.js";
14
14
  import { setDeckLocale } from "./locale.js";
15
15
  import { applyVisualEdits } from "./visual-edits.js";
16
16
  import { el, imageSrc } from "./elements.js";
@@ -24,6 +24,12 @@ import { loadMapConfig, mapaFile, tilesForDeck } from "./map-config.js";
24
24
 
25
25
  const HERE = path.dirname(fileURLToPath(import.meta.url));
26
26
  const read = (p) => fs.readFileSync(path.join(HERE, p), "utf8");
27
+ let mapBackgroundAssets;
28
+ const embeddedMapBackground = (spec, tiles) => {
29
+ if (!spec.slides.some((s) => s.layout === "map" && tiles?.[s.basemap || spec.defaults?.basemap]?.tipo === "vetorial")) return "";
30
+ mapBackgroundAssets ||= JSON.stringify(Object.fromEntries(Object.entries({ js: "maplibre-gl.js", css: "maplibre-gl.css", worker: "maplibre-worker.js", adapter: "maplibre-leaflet.js" }).map(([key, file]) => [key, fs.readFileSync(path.join(HERE, "runtime/vendor", file)).toString("base64")])));
31
+ return `<script type="application/json" id="map-background-assets">${mapBackgroundAssets}</script>`;
32
+ };
27
33
 
28
34
  export function loadSpec(file) {
29
35
  const src = fs.readFileSync(file, "utf8");
@@ -59,7 +65,7 @@ export function inferLayout(s) {
59
65
  }
60
66
 
61
67
  const DEFAULT_TONE = { section: "accent", cover: "light", end: "dark" };
62
- const NO_FOOTER = new Set(["cover", "section", "end", "image", "canvas", "full", "headline", "kinetic", "scenography"]);
68
+ const NO_FOOTER = new Set(["cover", "section", "end", "image", "canvas", "full", "headline"]);
63
69
 
64
70
  export function wordCount(s) {
65
71
  const txt = [];
@@ -81,7 +87,11 @@ export function wordCount(s) {
81
87
  // identidade do deck (fontes da empresa, do arquivo local; ver src/identity.js)
82
88
  const identityOf = (spec) => (spec.identity ? identityFor(spec.identity) : null);
83
89
  const slideTheme = (s, deckTheme, spec) => (s.theme || s.palette ? resolveTheme(s.theme || spec.theme, s.palette ?? spec.palette, identityOf(spec)) : deckTheme);
84
- const skinCSS = (name) => { try { return read(`runtime/skins/${name}.css`); } catch { return ""; } };
90
+ // tema irmão (skin: "musgo" no wine): usa a pele do outro, com o seletor trocado para o nome dele
91
+ const skinCSS = (name) => {
92
+ const base = THEMES[name]?.skin;
93
+ try { return base ? read(`runtime/skins/${base}.css`).replaceAll(`.th-${base}`, `.th-${name}`) : read(`runtime/skins/${name}.css`); } catch { return ""; }
94
+ };
85
95
  export function deckThemeCSS(spec, deckTheme) {
86
96
  const looks = new Map(), names = new Set([deckTheme.name]);
87
97
  for (const raw of spec.slides || []) {
@@ -145,7 +155,7 @@ export function buildHTML(rawSpec, opts = {}) {
145
155
  const layout = inferLayout(s);
146
156
  const fn = LAYOUTS[layout];
147
157
  if (!fn) throw new Error(`Slide ${i + 1}: layout "${layout}" não existe. Use: ${Object.keys(LAYOUTS).join(", ")}`);
148
- const tone = s.tone || (layout === "scenography" && SCENES[s.scene || "stage"]?.tone) || DEFAULT_TONE[layout] || spec.tone || "light";
158
+ const tone = s.tone || DEFAULT_TONE[layout] || spec.tone || "light";
149
159
  let inner;
150
160
  const th = slideTheme(s, theme, spec), sctx = th === theme ? ctx : { ...ctx, theme: th };
151
161
  try { inner = fn(s, sctx); } catch (e) { throw new Error(`Slide ${i + 1} (${layout}${s.title ? `: ${plain(s.title).slice(0, 40)}` : ""}): ${e.message}`); }
@@ -234,7 +244,7 @@ ${spec.slides.some((s) => s.layout === "diagram") ? `<script>${read("runtime/ven
234
244
  ${spec.slides.some(s => s.layout === "science") ? `<script>${read("runtime/vendor/plotly.min.js").replace(/<\/script/gi,"<\\/script")}</script><script>${read("runtime/formula.js")}</script><script>${read("runtime/science.js")}</script>` : ""}
235
245
  ${spec.slides.some(s => s.layout === "calc") ? `${spec.slides.some(s => s.layout === "science") ? "" : `<script>${read("runtime/formula.js")}</script>`}<script>${read("runtime/calc.js")}</script>` : ""}
236
246
  ${spec.slides.some(s=>s.layout==="portal") ? `<script>${read("runtime/portal-scene.js")}</script>` : ""}
237
- ${spec.slides.some((s) => s.layout === "map") ? `<style>${read("runtime/vendor/leaflet.css")}</style><script>${read("runtime/vendor/leaflet.js").replace(/<\/script/gi, "<\\/script")}</script><script>${read("runtime/map.js")}</script>` : ""}
247
+ ${spec.slides.some((s) => s.layout === "map") ? `${embeddedMapBackground(spec, mapTiles)}<style>${read("runtime/vendor/leaflet.css")}</style><script>${read("runtime/vendor/leaflet.js").replace(/<\/script/gi, "<\\/script")}</script><script>${read("runtime/map-background.js")}</script><script>${read("runtime/map.js")}</script>` : ""}
238
248
  ${JSON.stringify(spec.slides || []).includes('"points"') ? `<script>${read("runtime/points.js")}</script>` : ""}
239
249
  ${spec.slides.some(s=>s.layout==="playground") ? `<script>${read("runtime/playground.js")}</script>` : ""}
240
250
  ${hasApi ? `${apiScripts}\n<script>${read("runtime/api-ui.js")}</script><script>${read("runtime/api-collection-ui.js")}</script>` : ""}
@@ -258,21 +268,41 @@ export function setFitDefaults(d = {}) {
258
268
  export const fitDefaults = () => ({ ...FIT_DEFAULTS });
259
269
 
260
270
  // eco ambiente: camada viva discreta atrás do conteúdo (só no HTML; PPTX/PDF usam o quadro parado)
261
- const AMBIENT = ["pontos", "grade"];
262
- function ambientOf(s, i, ctx) {
263
- if (s.ambient == null || s.ambient === "") return "";
271
+ // (rede: nós à deriva ligados por proximidade, num canvas animado pelo runtime; o tema pode pô-la sozinha na capa,
272
+ // coverAmbient, e ambient: false tira)
273
+ const AMBIENT = ["pontos", "grade", "rede"];
274
+ function ambientOf(s, i, ctx, theme, layout) {
275
+ if (s.ambient === false) return "";
276
+ if (s.ambient == null || s.ambient === "") return layout === "cover" && theme?.coverAmbient ? theme.coverAmbient : "";
264
277
  if (!AMBIENT.includes(s.ambient)) { ctx.warnings?.push(`slide ${i + 1}: ambiente "${s.ambient}" desconhecido (vale: ${AMBIENT.join(", ")})`); return ""; }
265
278
  return s.ambient;
266
279
  }
280
+ // faixa que corre na capa: `ticker: [..]` no slide, ou automática nos temas com ticker (musgo), com os títulos das
281
+ // seções (sem seções suficientes, os dos slides). `ticker: false` desliga. Só no HTML; parada sem movimento/exportação.
282
+ function tickerOf(s, spec, theme, layout) {
283
+ if (s.ticker === false || layout !== "cover") return null;
284
+ const clean = (list) => [...new Set(list.map((x) => plain(String(x ?? "")).trim()).filter(Boolean))].slice(0, 16);
285
+ if (Array.isArray(s.ticker)) return clean(s.ticker);
286
+ if (!theme.ticker && s.ticker !== true) return null;
287
+ const others = (spec.slides || []).filter((x) => x && typeof x === "object");
288
+ let items = clean(others.filter((x) => inferLayout(x) === "section").map((x) => x.title || x.kicker));
289
+ if (items.length < 3) items = clean(others.filter((x) => !isCoverLayout(inferLayout(x))).map((x) => x.title || x.kicker));
290
+ return items.length ? items : null;
291
+ }
292
+ function tickerHTML(items) {
293
+ const run = items.map((t) => `<span class="tk-it t f-mono">${esc(t)}</span><span class="tk-sep"></span>`).join("");
294
+ const dur = Math.max(24, Math.round(items.join("").length * 0.22));
295
+ return `<div class="ticker" aria-hidden="true"><div class="tk-track" style="--tk-dur:${dur}s">${run}${run}</div></div>`;
296
+ }
267
297
  // <section> de um slide: tom, textura, estilo do destaque, fundo, área, cabeçalho e rodapé.
268
298
  // Única montagem para o Studio (renderSlide) e para a apresentação/exportação (buildHTML).
269
299
  function slideShell({ s, i, spec, theme, ctx, layout, tone, inner: innerIn, current = false }) {
270
300
  let inner = innerIn;
271
- const ambient = ambientOf(s, i, ctx);
301
+ const ambient = ambientOf(s, i, ctx, theme, layout);
272
302
  const deco = s.deco ?? theme.deco;
273
303
  const style = (s.bg ? `--bg:#${String(s.bg).replace("#", "")};` : "") + (s.fg ? `--fg:#${String(s.fg).replace("#", "")};` : "");
274
304
  const bars = s.footer !== false && (s.footer === true || !NO_FOOTER.has(layout));
275
- const area = layout === "canvas" || layout === "full" || layout === "kinetic" || layout === "scenography" ? "free" : "safe";
305
+ const area = layout === "canvas" || layout === "full" ? "free" : "safe";
276
306
  const motionAccent = area === 'safe' && s.motionAccent && typeof s.motionAccent === 'object';
277
307
  // estilo do ==destaque== (marca-texto | sublinhado | cor | negrito | nenhum), no deck ou por slide
278
308
  const markStyle = s.markStyle || spec.markStyle;
@@ -293,7 +323,7 @@ function slideShell({ s, i, spec, theme, ctx, layout, tone, inner: innerIn, curr
293
323
  if (hd) { bandTitle = `<div class="master-title">${hd[0].replace(/<[a-z0-9]+ [^>]*class="[^"]*\bttl\b[^"]*"[^>]*/, (tag) => `${tag}${/\sdata-fit[\s=>]|\sdata-fit$/.test(tag) ? "" : " data-fit"} data-fit-self`)}</div>`; inner = inner.replace(hd[0], ""); }
294
324
  }
295
325
  let html = `<section class="slide${current ? " current" : ""}${withMaster ? ` has-master${isCoverLayout(layout) ? " master-cover" : ""}${band ? " master-band" : ""}` : ""} th-${theme.name} lk-${theme.key} tone-${tone} ${deco && deco !== "none" ? "deco-" + deco : ""} ${markStyle && markStyle !== "marca-texto" ? "ms-" + markStyle : ""} L-${layout}-slide${["compact", "dense"].includes(s.density) ? " density-" + s.density : ""}" data-idx="${i}"${s.uid ? ` data-uid="${esc(String(s.uid))}"` : ""} data-layout="${layout}" data-tr="${s.transition || "fade"}"${ambient ? ` data-ambient="${ambient}"` : ""}${nav}${fitAttrs}${!Array.isArray(s.steps) && Number.isFinite(Number(s.steps)) && Number(s.steps) > 0 ? ` data-steps="${Number(s.steps)}"` : ""}${style ? ` style="${style}"` : ""}>`;
296
- if (ambient) html += `<div class="ambient" aria-hidden="true"></div>`;
326
+ if (ambient) html += `<div class="ambient" aria-hidden="true">${ambient === "rede" ? "<canvas></canvas>" : ""}</div>`;
297
327
  if (s.background) html += `<div class="bgfig" style="${s.backgroundStyle || ""}">${el(s.background, ctx, 1920, slideSize(spec).h)}</div>`;
298
328
  // mestre do deck (logos, faixas, número da página: src/master.js), atrás do conteúdo
299
329
  if (withMaster) html += `<div class="master" aria-hidden="true">${masterElements(spec, layout, i).map((e) => el(e, ctx)).join("")}</div>`;
@@ -302,7 +332,9 @@ function slideShell({ s, i, spec, theme, ctx, layout, tone, inner: innerIn, curr
302
332
  if (bars && s.header !== false) html += barHTML("header", spec, i, total);
303
333
  if (bandTitle) html += applyVisualEdits(bandTitle, s.visualEdits, { src: (p) => imageSrc(p, ctx) || p });
304
334
  html += `<div class="${area}${motionAccent ? ' with-motion-accent' : ''}">${applyVisualEdits(inner, s.visualEdits, { src: (p) => imageSrc(p, ctx) || p })}</div>`;
305
- if (motionAccent) {const fg=s.fg||theme.tones[tone]?.fg||'ink';html += `<div class="motion-accent" aria-hidden="true">${el({motion:{background:'transparent',foreground:theme.colors[fg]||fg,...s.motionAccent}},ctx,320,90)}</div>`;}
335
+ const ticker = tickerOf(s, spec, theme, layout);
336
+ if (ticker) html += tickerHTML(ticker);
337
+ if (motionAccent) {const fg=s.fg||theme.tones[tone]?.fg||'ink';html +=`<div class="motion-accent" aria-hidden="true">${el({motion:{background:'transparent',foreground:theme.colors[fg]||fg,...s.motionAccent}},ctx,320,90)}</div>`;}
306
338
  if (bars) html += barHTML("footer", spec, i, total);
307
339
  if (s.back != null && s.back !== "") {
308
340
  const alvo = (spec.slides || []).find((x) => x && String(x.id) === String(s.back));
@@ -321,7 +353,7 @@ export function renderSlide(raw, i = 0, spec = {}) {
321
353
  const layout = inferLayout(s);
322
354
  const fn = LAYOUTS[layout];
323
355
  if (!fn) throw new Error(`Slide ${i + 1}: layout "${layout}" não existe.`);
324
- const tone = s.tone || (layout === "scenography" && SCENES[s.scene || "stage"]?.tone) || DEFAULT_TONE[layout] || spec.tone || "light";
356
+ const tone = s.tone || DEFAULT_TONE[layout] || spec.tone || "light";
325
357
  const inner = fn(s, ctx);
326
358
  const deco = s.deco ?? theme.deco;
327
359
  const html = slideShell({ s, i, spec, theme, ctx, layout, tone, inner, current: true });
@@ -6,6 +6,9 @@ const KEYWORDS = {
6
6
  JavaScript: new Set("async await break case catch class const continue debugger default delete do else export extends finally for from function if import in instanceof let new of return static super switch this throw try typeof var void while with yield".split(" ")),
7
7
  TypeScript: new Set("abstract any as asserts async await bigint boolean break case catch class const constructor continue declare default delete do else enum export extends finally for from function get if implements import in infer instanceof interface is keyof let namespace never new number object of package private protected public readonly return set static string super switch symbol this throw try type typeof undefined unique unknown var void while with yield".split(" ")),
8
8
  "C#": new Set("abstract as async await base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof stackalloc static string struct switch this throw try typeof uint ulong unchecked unsafe ushort using virtual void volatile while".split(" ")),
9
+ Bash: new Set("if then else elif fi for in do done while until case esac function select time export local readonly return exit source".split(" ")),
10
+ JSON: new Set(),
11
+ SQL: new Set("SELECT FROM WHERE JOIN INNER LEFT RIGHT FULL OUTER CROSS ON AS AND OR NOT IN IS NULL DISTINCT GROUP BY ORDER HAVING LIMIT OFFSET ASC DESC INSERT INTO VALUES UPDATE SET DELETE CREATE TABLE INDEX VIEW DROP ALTER WITH UNION ALL EXISTS CASE WHEN THEN ELSE END BETWEEN LIKE RETURNING BEGIN COMMIT ROLLBACK PRIMARY KEY REFERENCES DEFAULT".split(" ")),
9
12
  };
10
13
 
11
14
  const TYPES = {
@@ -14,11 +17,19 @@ const TYPES = {
14
17
  JavaScript: new Set("Array Boolean Date Map Number Object Promise Set String".split(" ")),
15
18
  TypeScript: new Set("Array any bigint boolean never number object string symbol unknown void".split(" ")),
16
19
  "C#": new Set("bool byte char DateTime decimal double float int List long object short string Task uint ulong var void".split(" ")),
20
+ Bash: new Set(),
21
+ JSON: new Set(),
22
+ SQL: new Set("INT INTEGER BIGINT TEXT VARCHAR BOOLEAN FLOAT DOUBLE DECIMAL NUMERIC DATE TIMESTAMP JSON JSONB".split(" ")),
17
23
  };
18
24
 
25
+ const SHELL_COMMANDS = new Set("curl echo printf git npm node python python3 pip pytest jq cat grep sed awk ls cd pwd mkdir rm cp mv sleep read test find sort head tail".split(" "));
26
+
19
27
  const CONSTANTS = new Set(["False", "None", "True", "false", "null", "true", "undefined", "NaN", "Infinity"]);
20
28
 
21
29
  function tokenPattern(language) {
30
+ if (language === "Bash") return /(?<comment>(?<!\S)#[^\n]*)|(?<string>'[^']*'|"(?:\\[\s\S]|[^"\\])*")|(?<variable>\$(?:\{[^}\n]+\}|[A-Za-z_][\w]*|[\d?@#*!$-]))|(?<flag>(?<!\S)--?[A-Za-z][\w-]*(?:=[^\s]+)?)|(?<number>\b\d+(?:\.\d+)?\b)|(?<identifier>[A-Za-z_][\w]*)|(?<operator>[|&;<>]+)|(?<punctuation>[{}()\[\]])/g;
31
+ if (language === "JSON") return /(?<comment>\/\/[^\n]*|\/\*[\s\S]*?\*\/)|(?<string>"(?:\\.|[^"\\])*")|(?<number>\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|(?<identifier>true|false|null)|(?<punctuation>[{}\[\],:])/g;
32
+ if (language === "SQL") return /(?<comment>--[^\n]*|\/\*[\s\S]*?\*\/)|(?<string>'(?:''|[^'])*'|"(?:""|[^"])*"|`[^`]*`)|(?<number>\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|(?<identifier>[A-Za-z_][\w$]*)|(?<operator>[-+*/%=&|^!<>~]+)|(?<punctuation>[{}()\[\].,;])/g;
22
33
  const comments = language === "Python"
23
34
  ? "#[^\\n]*"
24
35
  : "\\/\\/[^\\n]*|\\/\\*[\\s\\S]*?\\*\\/";
@@ -32,13 +43,18 @@ function tokenPattern(language) {
32
43
  function classify(match, language, source, offset) {
33
44
  const groups = match.groups;
34
45
  if (groups.comment) return "comment";
46
+ if (groups.variable) return "constant";
47
+ if (groups.flag) return "keyword";
48
+ if (groups.string && language === "JSON" && /^\s*:/.test(source.slice(offset + match[0].length))) return "type";
35
49
  if (groups.string) return "string";
36
50
  if (groups.number) return "number";
37
51
  if (groups.identifier) {
38
52
  const word = groups.identifier;
39
- if (KEYWORDS[language].has(word)) return "keyword";
53
+ const normalized = language === "SQL" ? word.toUpperCase() : word;
54
+ if (KEYWORDS[language].has(normalized)) return "keyword";
40
55
  if (CONSTANTS.has(word)) return "constant";
41
- if (TYPES[language].has(word)) return "type";
56
+ if (TYPES[language].has(normalized)) return "type";
57
+ if (language === "Bash" && SHELL_COMMANDS.has(word)) return "function";
42
58
  if (/^\s*\(/.test(source.slice(offset + match[0].length))) return "function";
43
59
  }
44
60
  if (groups.operator) return "operator";
@@ -47,6 +63,7 @@ function classify(match, language, source, offset) {
47
63
  }
48
64
 
49
65
  export function highlightCode(source, language) {
66
+ if (!KEYWORDS[language]) return source.split("\n").map(esc);
50
67
  const pattern = tokenPattern(language);
51
68
  const lines = [""];
52
69
  const append = (value, kind = "") => {
@@ -11,6 +11,11 @@ const EXTENSIONS = new Map([
11
11
  [".mts", "TypeScript"],
12
12
  [".cts", "TypeScript"],
13
13
  [".cs", "C#"],
14
+ [".sh", "Bash"],
15
+ [".bash", "Bash"],
16
+ [".json", "JSON"],
17
+ [".jsonc", "JSON"],
18
+ [".sql", "SQL"],
14
19
  ]);
15
20
 
16
21
  const ALIASES = new Map([
@@ -26,6 +31,13 @@ const ALIASES = new Map([
26
31
  ["c#", "C#"],
27
32
  ["csharp", "C#"],
28
33
  ["cs", "C#"],
34
+ ["bash", "Bash"],
35
+ ["shell", "Bash"],
36
+ ["sh", "Bash"],
37
+ ["curl", "Bash"],
38
+ ["json", "JSON"],
39
+ ["jsonc", "JSON"],
40
+ ["sql", "SQL"],
29
41
  ]);
30
42
 
31
43
  export function inferCodeLanguage(filename) {
@@ -48,5 +60,6 @@ export function resolveCodeLanguage(language, filename) {
48
60
 
49
61
  export function displayCodeLanguage(language, filename) {
50
62
  const requested = String(language || "").trim();
63
+ if (requested.toLowerCase() === "curl") return "cURL";
51
64
  return normalizeCodeLanguage(requested) || requested || inferCodeLanguage(filename);
52
65
  }
package/src/elements.js CHANGED
@@ -9,7 +9,7 @@ import katex from "katex";
9
9
  import { formatNumber, decimalSeparator } from "./locale.js";
10
10
  import fs from "node:fs";
11
11
  import path from "node:path";
12
- import { md, esc } from "./markup.js";
12
+ import { md, esc, plain } from "./markup.js";
13
13
  import { iconSVG } from "./figures/icons.js";
14
14
  import { picto } from "./figures/pictos.js";
15
15
  import { diagram } from "./figures/diagrams.js";
@@ -258,6 +258,7 @@ export function el(e, ctx, w, h) {
258
258
  if (e.poll) return poll(e, ctx);
259
259
  if (e.list) return list(e);
260
260
  if (e.cards) return cards(e, ctx);
261
+ if (e.pop) return popEl(e);
261
262
  if (e.stats || e.kpis) return stats(e, ctx);
262
263
  if (e.steps || e.process || e.flow) return steps(e, ctx);
263
264
  if (e.progress != null) return progress(e);
@@ -478,6 +479,7 @@ export function cards(e, ctx) {
478
479
  if (c.check === true || c.status === "pro") extraWidgets += `<div class="check-pill pro">${iconSVG("check", { size: 26, stroke: 2.4 })} ${c.checkText ? md(c.checkText) : "Recomendado"}</div>`;
479
480
  if (c.check === false || c.status === "con") extraWidgets += `<div class="check-pill con">${iconSVG("x", { size: 26, stroke: 2.4 })} ${c.checkText ? md(c.checkText) : "Evitar"}</div>`;
480
481
 
482
+ if (c.flip != null && c.flip !== "") return flipCard(c, step, top, badgeHtml, extraWidgets);
481
483
  return `<div${attrs({ ...c, step, card: c.hl ? "hi" : true }, "cd")}>
482
484
  <div style="display:flex;justify-content:space-between;align-items:center;width:100%;">
483
485
  ${top}
@@ -493,6 +495,42 @@ export function cards(e, ctx) {
493
495
  return `<div${attrs(e, "cards", `grid-template-columns:repeat(${cols},1fr);`)}>${body}</div>`;
494
496
  }
495
497
 
498
+ // Cartão que vira (flip: no cartão): clicar gira e mostra o verso (flipTitle + flip; "back" já é o Voltar da navegação). Frente e verso ficam no
499
+ // mesmo lugar da grade; o mais alto dita a altura. Na exportação e no PPTX vale a frente.
500
+ function flipCard(c, step, top, badgeHtml, extraWidgets) {
501
+ const face = `card${c.hl ? " card-hi" : ""} flip-face`;
502
+ const back = typeof c.flip === "object" ? c.flip : { text: c.flip };
503
+ return `<div${attrs({ ...c, step, card: false }, "cd flip")} data-flip tabindex="0" role="button" aria-pressed="false" aria-label="${esc(plain(String(c.title || "Cartão")))}: virar">
504
+ <div class="flip-in">
505
+ <div class="${face} flip-front">
506
+ <div style="display:flex;justify-content:space-between;align-items:center;width:100%;">${top}${badgeHtml}</div>
507
+ ${c.title ? `<div class="cd-title t f-heading">${md(c.title)}</div>` : ""}
508
+ ${c.text ? `<div class="cd-text t f-body">${md(c.text)}</div>` : ""}
509
+ ${extraWidgets}
510
+ ${c.foot ? `<div class="cd-foot t f-label">${md(c.foot)}</div>` : ""}
511
+ <span class="flip-hint" aria-hidden="true">${iconSVG("rotate-ccw", { size: 28, stroke: 2.2 })}</span>
512
+ </div>
513
+ <div class="${face} flip-back" aria-hidden="true">
514
+ ${back.title || c.flipTitle ? `<div class="cd-title t f-heading">${md(back.title || c.flipTitle)}</div>` : ""}
515
+ <div class="cd-text t f-body">${md(String(back.text ?? ""))}</div>
516
+ <span class="flip-hint" aria-hidden="true">${iconSVG("rotate-ccw", { size: 28, stroke: 2.2 })}</span>
517
+ </div>
518
+ </div>
519
+ </div>`;
520
+ }
521
+
522
+ // Botão + com balão (pop): clicar abre um balãozinho com título e texto e escurece o resto do slide; clicar de novo,
523
+ // fora dele ou trocar de slide fecha. side: above (padrão) | below | left | right. Na exportação, fechado.
524
+ export function popHTML(more, side = "above") {
525
+ const m = typeof more === "object" && more ? more : { text: more };
526
+ const sd = ["above", "below", "left", "right"].includes(m.side || side) ? m.side || side : "above";
527
+ return `<span class="pop pop-${sd}" data-pop><button type="button" class="pop-btn" aria-expanded="false" aria-label="${esc(plain(String(m.title || "Ver mais")))}">${iconSVG("plus", { size: 30, stroke: 2.6 })}</button><span class="pop-card" role="note">${m.title ? `<span class="pop-title t f-heading">${md(String(m.title))}</span>` : ""}<span class="pop-text t f-body">${md(String(m.text ?? ""))}</span></span></span>`;
528
+ }
529
+ function popEl(e) {
530
+ const m = typeof e.pop === "object" ? e.pop : { text: e.pop, title: e.title, side: e.side };
531
+ return `<span${attrs(e, "pop-el")}>${popHTML(m)}</span>`;
532
+ }
533
+
496
534
  export function stats(e, ctx) {
497
535
  const items = e.stats || e.kpis || [];
498
536
  const cols = e.cols || Math.min(items.length, 4) || 3;
@@ -48,6 +48,8 @@ function collectInPage([idx, nativeCharts]) {
48
48
  const isLeaf = (e) => {
49
49
  const d = getComputedStyle(e).display;
50
50
  if ((d.includes("flex") || d.includes("grid")) && e.children.length) return false;
51
+ // Na régua da calculadora, cada faixa e cada rótulo têm caixas próprias: agrupar perderia posições e fundos.
52
+ if (e.classList.contains("calc-track") || e.classList.contains("calc-band")) return false;
51
53
  let has = false;
52
54
  for (const c of e.childNodes) {
53
55
  if (c.nodeType === 3) { if (c.textContent.trim()) has = true; }
@@ -168,7 +170,7 @@ function collectInPage([idx, nativeCharts]) {
168
170
  return;
169
171
  }
170
172
  // coisas que viram imagem
171
- if (tag.toLowerCase() === "svg" || tag === "IMG" || tag === "CANVAS" || tag === "VIDEO" ||
173
+ if (tag.toLowerCase() === "svg" || tag === "IMG" || tag === "CANVAS" || tag === "VIDEO" || (tag === "INPUT" && e.type === "range") ||
172
174
  e.classList.contains("katex") || (isLeaf(e) && e.querySelector(".katex")) || e.classList.contains("widget") || e.classList.contains("timer") || e.classList.contains("code") || e.classList.contains("raster") || transformed(cs)) {
173
175
  const vr = visualRect(e);
174
176
  e.setAttribute("data-pid", String(++pid));
@@ -327,7 +329,7 @@ function materializePseudos() {
327
329
  }
328
330
 
329
331
  // notes: false tira as notas do apresentador (mandar a apresentação para alguém sem a "cola")
330
- export async function exportPptx(htmlFile, outFile, { theme, meta, nativeCharts = false, notes = true, fontFallback, log = () => {} } = {}) {
332
+ export async function exportPptx(htmlFile, outFile, { theme, meta, nativeCharts = false, notes = true, fontFallback, log = () => {}, onProgress = () => {} } = {}) {
331
333
  const notesOpt = notes !== false;
332
334
  const { browser, page, errors } = await openDeck(htmlFile, { scale: 2 });
333
335
  try {
@@ -351,6 +353,7 @@ export async function exportPptx(htmlFile, outFile, { theme, meta, nativeCharts
351
353
  const animBySlide = [];
352
354
 
353
355
  for (let i = 0; i < n; i++) {
356
+ onProgress({ phase: 'slides', completed: i, total: n, slide: i + 1 });
354
357
  const S = await page.evaluate((j) => window.sagadeck.steps(j), i);
355
358
  await page.evaluate(([j, k]) => window.sagadeck.goto(j, k, true), [i, S]);
356
359
  await settleSlide(page);
@@ -432,8 +435,10 @@ export async function exportPptx(htmlFile, outFile, { theme, meta, nativeCharts
432
435
  if (notes && withNotes) slide.addNotes(notesPlain(notes));
433
436
  animBySlide.push({ anims, tr: data.tr });
434
437
  log(` slide ${i + 1}/${n}: ${data.items.length} objetos`);
438
+ onProgress({ phase: 'slides', completed: i + 1, total: n, slide: i + 1 });
435
439
  }
436
440
  // pós-processamento: animações + transições
441
+ onProgress({ phase: 'packing', completed: n, total: n });
437
442
  const buf = await pres.write({ outputType: "nodebuffer" });
438
443
  const zip = await JSZip.loadAsync(buf);
439
444
  for (let i = 0; i < animBySlide.length; i++) {
@@ -4,7 +4,7 @@ import path from "node:path";
4
4
  import { openDeck, waitForResources, settleSlide } from "./browser.js";
5
5
 
6
6
  // Estado final de cada slide (todos os cliques revelados)
7
- export async function shots(htmlFile, outDir, { steps = false, scale = 1, only, jpeg = false } = {}) {
7
+ export async function shots(htmlFile, outDir, { steps = false, scale = 1, only, jpeg = false, onProgress = () => {} } = {}) {
8
8
  fs.mkdirSync(outDir, { recursive: true });
9
9
  for (const f of fs.readdirSync(outDir)) if (/^slide-\d+.*\.(png|jpg)$|^folha-\d+\.png$/.test(f)) fs.unlinkSync(path.join(outDir, f));
10
10
  const { browser, page, errors } = await openDeck(htmlFile, { scale });
@@ -13,6 +13,7 @@ export async function shots(htmlFile, outDir, { steps = false, scale = 1, only,
13
13
  const files = [];
14
14
  for (let i = 0; i < n; i++) {
15
15
  if (only && !only.includes(i + 1)) continue;
16
+ onProgress({ phase: 'slides', completed: i, total: n, slide: i + 1 });
16
17
  const S = await page.evaluate((j) => window.sagadeck.steps(j), i);
17
18
  const list = steps ? Array.from({ length: S + 1 }, (_, k) => k) : [S];
18
19
  for (const k of list) {
@@ -23,6 +24,7 @@ export async function shots(htmlFile, outDir, { steps = false, scale = 1, only,
23
24
  await page.screenshot(jpeg ? { path: f, type: "jpeg", quality: 82 } : { path: f });
24
25
  files.push(f);
25
26
  }
27
+ onProgress({ phase: 'slides', completed: i + 1, total: n, slide: i + 1 });
26
28
  }
27
29
  return { files, errors };
28
30
  } finally { await browser.close(); }
@@ -49,19 +51,22 @@ export async function contactSheet(files, outDir, { perSheet = 12, cols = 3 } =
49
51
  } finally { await browser.close(); }
50
52
  }
51
53
 
52
- export async function pdf(htmlFile, outFile) {
54
+ export async function pdf(htmlFile, outFile, { onProgress = () => {} } = {}) {
53
55
  const { browser, page } = await openDeck(htmlFile, { scale: 1 });
54
56
  try {
55
57
  const n = await page.evaluate(() => window.sagadeck.n);
56
58
  const imgs = [], links = [];
57
59
  for (let i = 0; i < n; i++) {
60
+ onProgress({ phase: 'slides', completed: i, total: n, slide: i + 1 });
58
61
  const S = await page.evaluate((j) => window.sagadeck.steps(j), i);
59
62
  await page.evaluate(([j, k]) => window.sagadeck.goto(j, k), [i, S]);
60
63
  await settleSlide(page);
61
64
  await page.waitForTimeout(50);
62
65
  imgs.push((await page.screenshot({ type: "jpeg", quality: 92 })).toString("base64"));
63
66
  links.push(await page.evaluate(gotoRects, i));
67
+ onProgress({ phase: 'slides', completed: i + 1, total: n, slide: i + 1 });
64
68
  }
69
+ onProgress({ phase: 'packing', completed: n, total: n });
65
70
  // cada página é a foto do slide; por cima, áreas clicáveis nos itens com goto (o PDF pula para a página do destino)
66
71
  const hit = (l) => `<a href="#p${l.to}" style="position:absolute;left:${l.x}px;top:${l.y}px;width:${l.w}px;height:${l.h}px"></a>`;
67
72
  const { w: W, h: H } = await page.evaluate(() => window.sagadeck.size || { w: 1920, h: 1080 }); // deck.aspect
@@ -52,6 +52,25 @@ export function normalizeSpec(rawSpec) { if (!rawSpec || typeof rawSpec !== "ob
52
52
  spec.slides = spec.slides.map((s) => {
53
53
  if (!s || typeof s !== "object") return { layout: "statement", text: String(s) };
54
54
 
55
+ // Tipografia cinética saiu (Narumi: "se ele não consegue ser melhor, remova completamente"): o deck que a usava
56
+ // vira uma frase em destaque com as mesmas frases, uma por clique; a cena de fundo não tinha onde ficar
57
+ if (s.layout === "kinetic") {
58
+ const beats = (Array.isArray(s.beats) ? s.beats : []).map((b) => (typeof b === "string" ? b : b?.text)).filter((t) => typeof t === "string" && t.trim());
59
+ const phrases = beats.length ? beats : [s.title || s.text, s.subtitle].filter((t) => typeof t === "string" && t.trim());
60
+ const { beats: _b, autoplay: _a, interval: _i, figure: _f, layout: _l, title: _t, subtitle: _s, text: _x, ...rest } = s;
61
+ s = phrases.length > 1 ? { ...rest, layout: "statement", lines: phrases, build: true } : { ...rest, layout: "statement", text: phrases[0] || "" };
62
+ }
63
+
64
+ // Texto no cenário saiu (Narumi: "ele é muito limitado e dá para fazer coisas melhores pedindo pra IA desenhar"):
65
+ // com imagem, vira imagem de página inteira com o título por cima; sem imagem, frase em destaque
66
+ if (s.layout === "scenography") {
67
+ const { scene: _sc, foreground: _fg, imageOpacity: _op, titleSize: _ts, subtitle, caption, title, image, kicker, ...rest } = s;
68
+ const detail = [subtitle, caption].filter((v) => v != null && String(v).trim()).map(String).join("\n\n");
69
+ s = image
70
+ ? { ...rest, layout: "full", image, ...(kicker ? { kicker } : {}), ...(title ? { title } : {}), ...(detail ? { caption: detail } : {}), overlay: "left" }
71
+ : { ...rest, layout: "statement", ...(kicker ? { kicker } : {}), text: String(title || "").replace(/\n/g, " ").trim(), ...(detail ? { by: detail } : {}) };
72
+ }
73
+
55
74
  // Termos em português para campos básicos
56
75
  if (s.titulo && !s.title) { s.title = s.titulo; delete s.titulo; }
57
76
  if (s.subtitulo && !s.subtitle) { s.subtitle = s.subtitulo; delete s.subtitulo; }
package/src/fs-retry.js CHANGED
@@ -2,18 +2,53 @@
2
2
  // enquanto sincroniza (e também o antivírus ou o indexador), o arquivo fica preso por um instante e o rename falha
3
3
  // com EPERM/EBUSY/EACCES. Em vez de perder o salvamento da pessoa, tenta de novo com espera curta e crescente
4
4
  // (~1 s no total). Qualquer outro erro (destino inexistente, disco cheio…) sobe na hora.
5
+ //
6
+ // Copiar e remover pasta NÃO usam fs.cpSync/fs.rmSync recursivos: o código nativo deles (std::filesystem) no
7
+ // Windows quebra o processo inteiro (0xC0000409, sem exceção para pegar) ou "faz nada" quando o caminho tem
8
+ // acento ou caractere fora do ASCII (nodejs/node#54476, #61878) — "Relatório (cópia)" da biblioteca é o caso da
9
+ // vida real. A cópia/remoção caminha na mão por readdir/copyFile/unlink/rmdir (libuv), que lida com acento, e
10
+ // cada arquivo ainda tolera o travamento de um instante (EPERM/EBUSY/EACCES), como o rename.
5
11
  import fs from "node:fs";
12
+ import path from "node:path";
6
13
 
7
14
  const LOCKED = new Set(["EPERM", "EBUSY", "EACCES"]);
8
15
  export const RENAME_DELAYS = [20, 40, 80, 150, 250, 450];
9
16
 
10
17
  const pause = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
11
18
 
12
- export function renameRetry(from, to, { delays = RENAME_DELAYS } = {}) {
19
+ // tenta de novo enquanto o erro for "travado por um instante"; o resto sobe na hora
20
+ function retry(delays, fn) {
13
21
  for (let i = 0; ; i++) {
14
- try { return fs.renameSync(from, to); } catch (e) {
22
+ try { return fn(); } catch (e) {
15
23
  if (!LOCKED.has(e?.code) || i >= delays.length) throw e;
16
24
  pause(delays[i]);
17
25
  }
18
26
  }
19
27
  }
28
+
29
+ export function renameRetry(from, to, { delays = RENAME_DELAYS } = {}) {
30
+ return retry(delays, () => fs.renameSync(from, to));
31
+ }
32
+
33
+ // arquivo ou pasta inteira (com imagens): duplicar uma apresentação é o caso — no Windows o antivírus segura o
34
+ // arquivo recém-criado por um instante e a cópia falhava com EPERM (era o flake do CI no "mover (pasta inteira…)").
35
+ export function copyRetry(from, to, { delays = RENAME_DELAYS } = {}) {
36
+ const st = fs.statSync(from);
37
+ if (!st.isDirectory()) return retry(delays, () => fs.copyFileSync(from, to));
38
+ fs.mkdirSync(to, { recursive: true });
39
+ for (const e of fs.readdirSync(from, { withFileTypes: true })) {
40
+ copyRetry(path.join(from, e.name), path.join(to, e.name), { delays });
41
+ }
42
+ }
43
+
44
+ // pasta inteira ou arquivo: a lixeira apaga de vez e o import limpa a área de trabalho; no Windows um arquivo
45
+ // travado derrubava a operação e o rmSync recursivo ainda errava com caminho acentuado (apagava nada, em silêncio).
46
+ export function removeRetry(target, { delays = RENAME_DELAYS } = {}) {
47
+ let st;
48
+ try { st = fs.lstatSync(target); } catch (e) { if (e?.code === "ENOENT") return; throw e; }
49
+ if (st.isDirectory()) {
50
+ for (const name of fs.readdirSync(target)) removeRetry(path.join(target, name), { delays });
51
+ return retry(delays, () => fs.rmdirSync(target));
52
+ }
53
+ return retry(delays, () => fs.unlinkSync(target));
54
+ }