@julioventura/opensquad 0.1.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +433 -0
- package/_opensquad/config/playwright.config.json +11 -0
- package/_opensquad/core/architect.agent.yaml +112 -0
- package/_opensquad/core/best-practices/_catalog.yaml +126 -0
- package/_opensquad/core/best-practices/blog-post.md +132 -0
- package/_opensquad/core/best-practices/blog-seo.md +127 -0
- package/_opensquad/core/best-practices/brand-resolution-checklist.md +172 -0
- package/_opensquad/core/best-practices/copywriting.md +441 -0
- package/_opensquad/core/best-practices/data-analysis.md +401 -0
- package/_opensquad/core/best-practices/email-newsletter.md +118 -0
- package/_opensquad/core/best-practices/email-sales.md +110 -0
- package/_opensquad/core/best-practices/image-design.md +348 -0
- package/_opensquad/core/best-practices/instagram-feed.md +235 -0
- package/_opensquad/core/best-practices/instagram-reels.md +112 -0
- package/_opensquad/core/best-practices/instagram-stories.md +107 -0
- package/_opensquad/core/best-practices/linkedin-article.md +116 -0
- package/_opensquad/core/best-practices/linkedin-post.md +121 -0
- package/_opensquad/core/best-practices/researching.md +349 -0
- package/_opensquad/core/best-practices/review.md +269 -0
- package/_opensquad/core/best-practices/run-recovery.md +61 -0
- package/_opensquad/core/best-practices/social-networks-publishing.md +327 -0
- package/_opensquad/core/best-practices/squad-creation-checklist.md +32 -0
- package/_opensquad/core/best-practices/strategist.md +344 -0
- package/_opensquad/core/best-practices/technical-writing.md +365 -0
- package/_opensquad/core/best-practices/twitter-post.md +105 -0
- package/_opensquad/core/best-practices/twitter-thread.md +122 -0
- package/_opensquad/core/best-practices/whatsapp-broadcast.md +107 -0
- package/_opensquad/core/best-practices/youtube-script.md +122 -0
- package/_opensquad/core/best-practices/youtube-shorts.md +112 -0
- package/_opensquad/core/defaults/youtube-video-assembly.json +84 -0
- package/_opensquad/core/prompts/build.prompt.md +613 -0
- package/_opensquad/core/prompts/design.prompt.md +606 -0
- package/_opensquad/core/prompts/discovery.prompt.md +377 -0
- package/_opensquad/core/prompts/sherlock-instagram.md +123 -0
- package/_opensquad/core/prompts/sherlock-linkedin.md +73 -0
- package/_opensquad/core/prompts/sherlock-shared.md +684 -0
- package/_opensquad/core/prompts/sherlock-twitter.md +78 -0
- package/_opensquad/core/prompts/sherlock-youtube.md +85 -0
- package/_opensquad/core/runner.pipeline.md +743 -0
- package/_opensquad/core/skills.engine.md +384 -0
- package/bin/opensquad.js +108 -0
- package/dashboard/index.html +15 -0
- package/dashboard/package-lock.json +1964 -0
- package/dashboard/package.json +28 -0
- package/dashboard/public/assets/avatars/Female1_1wave.png +0 -0
- package/dashboard/public/assets/avatars/Female1_2wave.png +0 -0
- package/dashboard/public/assets/avatars/Female1_blink.png +0 -0
- package/dashboard/public/assets/avatars/Female1_talk.png +0 -0
- package/dashboard/public/assets/avatars/Female2_1wave.png +0 -0
- package/dashboard/public/assets/avatars/Female2_2wave.png +0 -0
- package/dashboard/public/assets/avatars/Female2_blink.png +0 -0
- package/dashboard/public/assets/avatars/Female2_talk.png +0 -0
- package/dashboard/public/assets/avatars/Female3_blink.png +0 -0
- package/dashboard/public/assets/avatars/Female3_talk.png +0 -0
- package/dashboard/public/assets/avatars/Female3_wave.png +0 -0
- package/dashboard/public/assets/avatars/Female4_blink.png +0 -0
- package/dashboard/public/assets/avatars/Female4_talk.png +0 -0
- package/dashboard/public/assets/avatars/Female4_wave.png +0 -0
- package/dashboard/public/assets/avatars/Female5_blink.png +0 -0
- package/dashboard/public/assets/avatars/Female5_talk.png +0 -0
- package/dashboard/public/assets/avatars/Female5_wave.png +0 -0
- package/dashboard/public/assets/avatars/Female6_blink.png +0 -0
- package/dashboard/public/assets/avatars/Female6_talk.png +0 -0
- package/dashboard/public/assets/avatars/Female6_wave.png +0 -0
- package/dashboard/public/assets/avatars/Male1_1wave.png +0 -0
- package/dashboard/public/assets/avatars/Male1_2wave.png +0 -0
- package/dashboard/public/assets/avatars/Male1_blink.png +0 -0
- package/dashboard/public/assets/avatars/Male1_talk.png +0 -0
- package/dashboard/public/assets/avatars/Male2_1wave.png +0 -0
- package/dashboard/public/assets/avatars/Male2_2wave.png +0 -0
- package/dashboard/public/assets/avatars/Male2_blink.png +0 -0
- package/dashboard/public/assets/avatars/Male2_talk.png +0 -0
- package/dashboard/public/assets/avatars/Male3_blink.png +0 -0
- package/dashboard/public/assets/avatars/Male3_talk.png +0 -0
- package/dashboard/public/assets/avatars/Male3_wave.png +0 -0
- package/dashboard/public/assets/avatars/Male4_blink.png +0 -0
- package/dashboard/public/assets/avatars/Male4_talk.png +0 -0
- package/dashboard/public/assets/avatars/Male4_wave.png +0 -0
- package/dashboard/public/assets/desks/desktop_set_black_down.png +0 -0
- package/dashboard/public/assets/desks/desktop_set_black_down_coding-1.png +0 -0
- package/dashboard/public/assets/desks/desktop_set_black_down_coding.png +0 -0
- package/dashboard/public/assets/desks/desktop_set_black_up.png +0 -0
- package/dashboard/public/assets/desks/desktop_set_white_down.png +0 -0
- package/dashboard/public/assets/desks/desktop_set_white_down_coding-1.png +0 -0
- package/dashboard/public/assets/desks/desktop_set_white_down_coding.png +0 -0
- package/dashboard/public/assets/desks/desktop_set_white_up.png +0 -0
- package/dashboard/public/assets/furniture/armchair_tan.png +0 -0
- package/dashboard/public/assets/furniture/armchair_tan_down.png +0 -0
- package/dashboard/public/assets/furniture/backpack_blue.png +0 -0
- package/dashboard/public/assets/furniture/backpack_red.png +0 -0
- package/dashboard/public/assets/furniture/blinds.png +0 -0
- package/dashboard/public/assets/furniture/blinds_large_closed_white.png +0 -0
- package/dashboard/public/assets/furniture/bookshelf.png +0 -0
- package/dashboard/public/assets/furniture/bookshelf_purple_tall.png +0 -0
- package/dashboard/public/assets/furniture/bulletin_board.png +0 -0
- package/dashboard/public/assets/furniture/clock.png +0 -0
- package/dashboard/public/assets/furniture/coffee_mug.png +0 -0
- package/dashboard/public/assets/furniture/coffee_mug_blue.png +0 -0
- package/dashboard/public/assets/furniture/coffee_table.png +0 -0
- package/dashboard/public/assets/furniture/coffeepot_right.png +0 -0
- package/dashboard/public/assets/furniture/coffeetable_black_horizontal.png +0 -0
- package/dashboard/public/assets/furniture/couch.png +0 -0
- package/dashboard/public/assets/furniture/couch_tan_down.png +0 -0
- package/dashboard/public/assets/furniture/cushion_blue.png +0 -0
- package/dashboard/public/assets/furniture/cushion_tan.png +0 -0
- package/dashboard/public/assets/furniture/desk_wood.png +0 -0
- package/dashboard/public/assets/furniture/fancy_rug.png +0 -0
- package/dashboard/public/assets/furniture/fancy_rug_wide.png +0 -0
- package/dashboard/public/assets/furniture/flowers1.png +0 -0
- package/dashboard/public/assets/furniture/flowers2.png +0 -0
- package/dashboard/public/assets/furniture/lamp_tan.png +0 -0
- package/dashboard/public/assets/furniture/lantern.png +0 -0
- package/dashboard/public/assets/furniture/monstera.png +0 -0
- package/dashboard/public/assets/furniture/monstera_small.png +0 -0
- package/dashboard/public/assets/furniture/picture_frame.png +0 -0
- package/dashboard/public/assets/furniture/plant1.png +0 -0
- package/dashboard/public/assets/furniture/plant2.png +0 -0
- package/dashboard/public/assets/furniture/plant3.png +0 -0
- package/dashboard/public/assets/furniture/plant_poof.png +0 -0
- package/dashboard/public/assets/furniture/plant_spindly.png +0 -0
- package/dashboard/public/assets/furniture/poster_blue.png +0 -0
- package/dashboard/public/assets/furniture/rug.png +0 -0
- package/dashboard/public/assets/furniture/succulent_blue.png +0 -0
- package/dashboard/public/assets/furniture/succulent_green.png +0 -0
- package/dashboard/public/assets/furniture/treasurechest_closed_gold.png +0 -0
- package/dashboard/public/assets/furniture/water_cooler_better.png +0 -0
- package/dashboard/public/assets/furniture/whiteboard.png +0 -0
- package/dashboard/public/assets/furniture/whiteboard_stand_graph.png +0 -0
- package/dashboard/public/assets/furniture/window_blinds_open.png +0 -0
- package/dashboard/src/App.tsx +46 -0
- package/dashboard/src/components/RunDashboardButton.tsx +92 -0
- package/dashboard/src/components/SquadCard.tsx +49 -0
- package/dashboard/src/components/SquadSelector.tsx +67 -0
- package/dashboard/src/components/StatusBadge.tsx +32 -0
- package/dashboard/src/components/StatusBar.tsx +116 -0
- package/dashboard/src/hooks/useSquadSocket.ts +135 -0
- package/dashboard/src/lib/formatTime.ts +16 -0
- package/dashboard/src/lib/normalizeState.ts +25 -0
- package/dashboard/src/main.tsx +10 -0
- package/dashboard/src/office/AgentSprite.ts +241 -0
- package/dashboard/src/office/OfficeScene.ts +153 -0
- package/dashboard/src/office/PhaserGame.tsx +80 -0
- package/dashboard/src/office/RoomBuilder.ts +190 -0
- package/dashboard/src/office/assetKeys.ts +150 -0
- package/dashboard/src/office/palette.ts +32 -0
- package/dashboard/src/plugin/squadWatcher.ts +397 -0
- package/dashboard/src/store/useSquadStore.ts +56 -0
- package/dashboard/src/styles/globals.css +36 -0
- package/dashboard/src/types/state.ts +63 -0
- package/dashboard/src/vite-env.d.ts +1 -0
- package/dashboard/tsconfig.json +24 -0
- package/dashboard/vite.config.ts +13 -0
- package/package.json +59 -0
- package/public/sfx/slide-transition-sfx.mp3 +0 -0
- package/skills/README.md +84 -0
- package/skills/apify/SKILL.md +55 -0
- package/skills/blotato/SKILL.md +63 -0
- package/skills/canva/SKILL.md +60 -0
- package/skills/higgsfield/SKILL.md +147 -0
- package/skills/image-ai-generator/SKILL.md +124 -0
- package/skills/image-ai-generator/scripts/generate.py +175 -0
- package/skills/image-creator/SKILL.md +166 -0
- package/skills/image-creator/editorial-slide-template.js +645 -0
- package/skills/image-fetcher/SKILL.md +91 -0
- package/skills/imgbb-uploader/SKILL.md +73 -0
- package/skills/imgbb-uploader/scripts/upload.js +125 -0
- package/skills/instagram-publisher/README.md +36 -0
- package/skills/instagram-publisher/SKILL.md +231 -0
- package/skills/instagram-publisher/scripts/publish-playwright.js +418 -0
- package/skills/instagram-publisher/scripts/publish.js +521 -0
- package/skills/opensquad-agent-creator/SKILL.md +192 -0
- package/skills/opensquad-skill-creator/SKILL.md +420 -0
- package/skills/opensquad-skill-creator/agents/analyzer.md +274 -0
- package/skills/opensquad-skill-creator/agents/comparator.md +202 -0
- package/skills/opensquad-skill-creator/agents/grader.md +223 -0
- package/skills/opensquad-skill-creator/assets/eval_review.html +146 -0
- package/skills/opensquad-skill-creator/eval-viewer/generate_review.py +471 -0
- package/skills/opensquad-skill-creator/eval-viewer/viewer.html +1325 -0
- package/skills/opensquad-skill-creator/references/schemas.md +430 -0
- package/skills/opensquad-skill-creator/references/skill-format.md +235 -0
- package/skills/opensquad-skill-creator/scripts/__init__.py +0 -0
- package/skills/opensquad-skill-creator/scripts/aggregate_benchmark.py +401 -0
- package/skills/opensquad-skill-creator/scripts/quick_validate.py +103 -0
- package/skills/opensquad-skill-creator/scripts/run_eval.py +310 -0
- package/skills/opensquad-skill-creator/scripts/utils.py +47 -0
- package/skills/pdf-extractor/SKILL.md +57 -0
- package/skills/pdf-extractor/scripts/extract.py +82 -0
- package/skills/resend/SKILL.md +80 -0
- package/skills/run-dashboard/README.md +93 -0
- package/skills/run-dashboard/SKILL.md +173 -0
- package/skills/run-dashboard/scripts/finalize-state.js +273 -0
- package/skills/run-dashboard/scripts/generate.js +1296 -0
- package/skills/run-dashboard/scripts/serve.js +135 -0
- package/skills/run-dashboard/templates/run-dashboard-simple.template.html +191 -0
- package/skills/run-dashboard/templates/run-dashboard.template.html +1164 -0
- package/skills/smtp-sender/SKILL.md +88 -0
- package/skills/smtp-sender/scripts/send.js +478 -0
- package/skills/template-designer/SKILL.md +201 -0
- package/skills/template-designer/base-templates/model-a.html +27 -0
- package/skills/template-designer/base-templates/model-b.html +31 -0
- package/skills/template-designer/base-templates/model-c.html +42 -0
- package/skills/youtube-publisher/SKILL.md +232 -0
- package/skills/youtube-publisher/scripts/publish.js +2078 -0
- package/src/agents-cli.js +158 -0
- package/src/agents.js +134 -0
- package/src/i18n.js +48 -0
- package/src/init.js +442 -0
- package/src/locales/en.json +79 -0
- package/src/locales/es.json +78 -0
- package/src/locales/pt-BR.json +78 -0
- package/src/logger.js +38 -0
- package/src/prompt.js +46 -0
- package/src/readme/README.md +146 -0
- package/src/runs.js +318 -0
- package/src/skills-cli.js +157 -0
- package/src/skills.js +146 -0
- package/src/supabase-cli.js +584 -0
- package/src/update.js +169 -0
- package/templates/_opensquad/.opensquad-version +1 -0
- package/templates/_opensquad/_investigations/.gitkeep +0 -0
- package/templates/ide-templates/antigravity/.agent/rules/opensquad.md +68 -0
- package/templates/ide-templates/antigravity/.agent/workflows/opensquad.md +102 -0
- package/templates/ide-templates/claude-code/.claude/skills/opensquad/SKILL.md +182 -0
- package/templates/ide-templates/claude-code/.mcp.json +8 -0
- package/templates/ide-templates/claude-code/CLAUDE.md +57 -0
- package/templates/ide-templates/codex/.agents/skills/opensquad/SKILL.md +6 -0
- package/templates/ide-templates/codex/AGENTS.md +120 -0
- package/templates/ide-templates/cursor/.cursor/commands/opensquad.md +9 -0
- package/templates/ide-templates/cursor/.cursor/mcp.json +8 -0
- package/templates/ide-templates/cursor/.cursor/rules/opensquad.mdc +62 -0
- package/templates/ide-templates/cursor/.cursorignore +3 -0
- package/templates/ide-templates/gemini-cli/.gemini/settings.json +8 -0
- package/templates/ide-templates/gemini-cli/.gemini/skills/opensquad/SKILL.md +186 -0
- package/templates/ide-templates/gemini-cli/GEMINI.md +57 -0
- package/templates/ide-templates/opencode/.opencode/commands/opensquad.md +9 -0
- package/templates/ide-templates/opencode/AGENTS.md +120 -0
- package/templates/ide-templates/qwen-code/.qwen/settings.json +8 -0
- package/templates/ide-templates/qwen-code/.qwen/skills/opensquad/SKILL.md +182 -0
- package/templates/ide-templates/qwen-code/QWEN.md +57 -0
- package/templates/ide-templates/trae/.trae/mcp.json +8 -0
- package/templates/ide-templates/trae/.trae/rules/opensquad.md +64 -0
- package/templates/ide-templates/vscode-copilot/.github/copilot-instructions.md +59 -0
- package/templates/ide-templates/vscode-copilot/.github/prompts/opensquad.prompt.md +209 -0
- package/templates/ide-templates/vscode-copilot/.vscode/mcp.json +8 -0
- package/templates/ide-templates/vscode-copilot/.vscode/settings.json +3 -0
- package/templates/package.json +8 -0
- package/templates/squads/.gitkeep +0 -0
|
@@ -0,0 +1,1296 @@
|
|
|
1
|
+
import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { dirname, extname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { Client as FtpClient } from 'basic-ftp';
|
|
8
|
+
import { resolveConfiguredEnv as resolveInstagramEnv, resolveFacebookPageAccessToken } from '../../instagram-publisher/scripts/publish.js';
|
|
9
|
+
import { resolveConfiguredEnv as resolveYouTubeEnv, exchangeRefreshToken } from '../../youtube-publisher/scripts/publish.js';
|
|
10
|
+
import { ensureRunStateFile } from './finalize-state.js';
|
|
11
|
+
|
|
12
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
13
|
+
const __dirname = dirname(__filename);
|
|
14
|
+
|
|
15
|
+
const DEFAULT_ARGS = {
|
|
16
|
+
runDir: '',
|
|
17
|
+
workspaceRoot: process.cwd(),
|
|
18
|
+
outputHtml: '',
|
|
19
|
+
outputJson: '',
|
|
20
|
+
templatePath: join(__dirname, '..', 'templates', 'run-dashboard.template.html'),
|
|
21
|
+
refreshMetrics: false,
|
|
22
|
+
publishStatic: false,
|
|
23
|
+
publishDir: '',
|
|
24
|
+
publishDirEnv: '',
|
|
25
|
+
publishUrlBase: '',
|
|
26
|
+
publishLatestName: '',
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const RUN_FILE_PATHS = {
|
|
30
|
+
state: ['state.json'],
|
|
31
|
+
publishResult: ['publish-result.md', 'v1/publish-result.md'],
|
|
32
|
+
editorialPlan: ['editorial-plan.md', 'v1/editorial-plan.md'],
|
|
33
|
+
researchBrief: ['research-brief.md', 'v1/research-brief.md'],
|
|
34
|
+
contentPackage: ['content-package.md', 'v1/content-package.md'],
|
|
35
|
+
images: ['images.md', 'v1/images.md'],
|
|
36
|
+
review: ['review.md', 'v1/review.md'],
|
|
37
|
+
publishConfig: ['publish-config.md', 'v1/publish-config.md'],
|
|
38
|
+
preImageApproval: ['pre-image-approval.md', 'v1/pre-image-approval.md'],
|
|
39
|
+
newsletterImageManifest: ['newsletter-image-urls.json', 'v1/newsletter-image-urls.json'],
|
|
40
|
+
newsletterHtml: ['newsletter.html', 'v1/newsletter.html'],
|
|
41
|
+
newsletterText: ['newsletter.txt', 'v1/newsletter.txt'],
|
|
42
|
+
newsletterPreview: ['newsletter-preview.md', 'v1/newsletter-preview.md'],
|
|
43
|
+
sendPreview: ['send-preview.md', 'v1/send-preview.md'],
|
|
44
|
+
emailSendResult: ['email-send-result.md', 'v1/email-send-result.md'],
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function parseArgs(argv) {
|
|
48
|
+
const args = { ...DEFAULT_ARGS };
|
|
49
|
+
|
|
50
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
51
|
+
const current = argv[index];
|
|
52
|
+
if (current === '--run-dir' && index + 1 < argv.length) args.runDir = argv[++index];
|
|
53
|
+
else if (current === '--workspace-root' && index + 1 < argv.length) args.workspaceRoot = argv[++index];
|
|
54
|
+
else if (current === '--output-html' && index + 1 < argv.length) args.outputHtml = argv[++index];
|
|
55
|
+
else if (current === '--output-json' && index + 1 < argv.length) args.outputJson = argv[++index];
|
|
56
|
+
else if (current === '--template-path' && index + 1 < argv.length) args.templatePath = argv[++index];
|
|
57
|
+
else if (current === '--no-refresh-metrics') args.refreshMetrics = false;
|
|
58
|
+
else if (current === '--refresh-metrics') args.refreshMetrics = true;
|
|
59
|
+
else if (current === '--publish-static') args.publishStatic = true;
|
|
60
|
+
else if (current === '--publish-dir' && index + 1 < argv.length) args.publishDir = argv[++index];
|
|
61
|
+
else if (current === '--publish-dir-env' && index + 1 < argv.length) args.publishDirEnv = argv[++index];
|
|
62
|
+
else if (current === '--publish-url-base' && index + 1 < argv.length) args.publishUrlBase = argv[++index];
|
|
63
|
+
else if (current === '--publish-latest-name' && index + 1 < argv.length) args.publishLatestName = argv[++index];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return args;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function main() {
|
|
70
|
+
const args = parseArgs(process.argv);
|
|
71
|
+
if (!args.runDir) throw new Error('--run-dir is required');
|
|
72
|
+
|
|
73
|
+
const runDir = resolve(args.runDir);
|
|
74
|
+
const workspaceRoot = resolve(args.workspaceRoot);
|
|
75
|
+
const outputJson = resolve(args.outputJson || join(runDir, 'run-dashboard.data.json'));
|
|
76
|
+
const outputHtml = resolve(args.outputHtml || join(runDir, 'run-dashboard.html'));
|
|
77
|
+
const envVars = await loadEnvVars(workspaceRoot);
|
|
78
|
+
const squadName = inferSquadName(runDir);
|
|
79
|
+
const channelConfig = await loadChannelConfig(workspaceRoot, squadName);
|
|
80
|
+
|
|
81
|
+
const dashboardFiles = await writeDashboardFiles({
|
|
82
|
+
runDir,
|
|
83
|
+
workspaceRoot,
|
|
84
|
+
outputHtml,
|
|
85
|
+
outputJson,
|
|
86
|
+
templatePath: resolve(args.templatePath),
|
|
87
|
+
refreshMetrics: args.refreshMetrics,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const staticPublish = resolveStaticPublishOptions({
|
|
91
|
+
args,
|
|
92
|
+
envVars,
|
|
93
|
+
channelConfig,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
if (staticPublish.enabled) {
|
|
97
|
+
const published = await publishStaticDashboardSnapshot({
|
|
98
|
+
payload: dashboardFiles.payload,
|
|
99
|
+
runDir,
|
|
100
|
+
templatePath: resolve(args.templatePath),
|
|
101
|
+
publishDir: staticPublish.publishDir,
|
|
102
|
+
ftpRemoteDir: staticPublish.ftpRemoteDir,
|
|
103
|
+
publicBaseUrl: staticPublish.publicBaseUrl,
|
|
104
|
+
latestFileName: staticPublish.latestFileName,
|
|
105
|
+
transport: staticPublish.transport,
|
|
106
|
+
ftpConfig: staticPublish.ftpConfig,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
console.log(`Run dashboard latest snapshot published to ${published.latestPath}`);
|
|
110
|
+
console.log(`Run dashboard archive snapshot published to ${published.archivePath}`);
|
|
111
|
+
if (published.latestUrl) console.log(`Run dashboard latest URL: ${published.latestUrl}`);
|
|
112
|
+
if (published.archiveUrl) console.log(`Run dashboard archive URL: ${published.archiveUrl}`);
|
|
113
|
+
if (published.latestYouTubeShareUrl) console.log(`Run dashboard YouTube share URL: ${published.latestYouTubeShareUrl}`);
|
|
114
|
+
if (published.archiveYouTubeShareUrl) console.log(`Run dashboard YouTube share archive URL: ${published.archiveYouTubeShareUrl}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
console.log(`Run dashboard written to ${outputHtml}`);
|
|
118
|
+
console.log(`Run dashboard data written to ${outputJson}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function writeDashboardFiles(options) {
|
|
122
|
+
const payload = await buildRunDashboardPayload(options);
|
|
123
|
+
const html = await renderDashboardHtml({
|
|
124
|
+
templatePath: options.templatePath,
|
|
125
|
+
payload,
|
|
126
|
+
dataFileName: options.outputJson.split(/[/\\]/).pop(),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await writeFile(options.outputJson, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8');
|
|
130
|
+
await writeFile(options.outputHtml, html, 'utf-8');
|
|
131
|
+
|
|
132
|
+
return { payload, html, outputHtml: options.outputHtml, outputJson: options.outputJson };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function buildRunDashboardPayload(options) {
|
|
136
|
+
const { runDir, workspaceRoot, refreshMetrics } = options;
|
|
137
|
+
await ensureRunStateFile({ runDir, workspaceRoot, force: false, strict: false });
|
|
138
|
+
const envVars = await loadEnvVars(workspaceRoot);
|
|
139
|
+
const runFiles = await loadRunFiles(runDir);
|
|
140
|
+
const squadName = inferSquadName(runDir);
|
|
141
|
+
const publish = parsePublishResult(runFiles.publishResult);
|
|
142
|
+
const state = parseState(runFiles.state);
|
|
143
|
+
const runContext = inferRunContext(runDir, { state, publish });
|
|
144
|
+
const runId = runContext.runId;
|
|
145
|
+
const channelConfig = await loadChannelConfig(workspaceRoot, squadName);
|
|
146
|
+
const theme = extractTheme(runFiles.editorialPlan) || extractTheme(runFiles.researchBrief);
|
|
147
|
+
const errors = buildErrorList({ publish, state, images: runFiles.images, review: runFiles.review });
|
|
148
|
+
const metrics = refreshMetrics
|
|
149
|
+
? await collectMetrics({ publish, envVars, channelConfig })
|
|
150
|
+
: [];
|
|
151
|
+
const previews = await buildVisualPreviews(runDir, publish, runFiles.contentPackage);
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
title: theme || squadName,
|
|
155
|
+
subtitle: buildSubtitle({ publish, squadName, runId }),
|
|
156
|
+
squad: squadName,
|
|
157
|
+
runId,
|
|
158
|
+
runContext,
|
|
159
|
+
status: publish.status || state.status || 'unknown',
|
|
160
|
+
startedAt: state.startedAt || null,
|
|
161
|
+
updatedAt: publish.publishedAt || state.completedAt || state.updatedAt || null,
|
|
162
|
+
lastRefreshedAt: new Date().toISOString(),
|
|
163
|
+
checklist: buildChecklist({ state, runFiles, publish, runContext }),
|
|
164
|
+
channels: buildChannels(publish),
|
|
165
|
+
artifacts: buildArtifacts(runDir, runFiles),
|
|
166
|
+
previews,
|
|
167
|
+
metrics,
|
|
168
|
+
metricsNote: buildMetricsNote(metrics, runContext),
|
|
169
|
+
errors,
|
|
170
|
+
refresh: {
|
|
171
|
+
endpoint: '__run_dashboard_refresh__',
|
|
172
|
+
liveModeLabel: 'Use o servidor local do dashboard para regenerar métricas em tempo real.',
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export async function renderDashboardHtml(options) {
|
|
178
|
+
const template = await readFile(options.templatePath, 'utf-8');
|
|
179
|
+
return template
|
|
180
|
+
.replace('__RUN_DASHBOARD_BOOTSTRAP__', escapeScriptJson(JSON.stringify(options.payload, null, 2)))
|
|
181
|
+
.replace('__RUN_DASHBOARD_JSON_PATH__', options.dataFileName || 'run-dashboard.data.json');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export function resolveStaticPublishOptions({ args, envVars, channelConfig }) {
|
|
185
|
+
const publishConfig = channelConfig?.dashboard?.static_publish || {};
|
|
186
|
+
const requested = Boolean(
|
|
187
|
+
args.publishStatic
|
|
188
|
+
|| args.publishDir
|
|
189
|
+
|| args.publishDirEnv
|
|
190
|
+
|| args.publishUrlBase
|
|
191
|
+
|| args.publishLatestName
|
|
192
|
+
|| publishConfig.enabled,
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
if (!requested) {
|
|
196
|
+
return {
|
|
197
|
+
enabled: false,
|
|
198
|
+
transport: 'local',
|
|
199
|
+
publishDir: '',
|
|
200
|
+
publicBaseUrl: '',
|
|
201
|
+
latestFileName: 'index.html',
|
|
202
|
+
ftpConfig: null,
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const publishDirEnvKey = args.publishDirEnv || publishConfig.publish_dir_env || '';
|
|
207
|
+
const rawPublishDir = args.publishDir || (publishDirEnvKey ? envVars[publishDirEnvKey] || '' : '');
|
|
208
|
+
const ftpConfig = resolveStaticPublishFtpConfig({ envVars, publishConfig });
|
|
209
|
+
const configTransport = (publishConfig.transport || '').toLowerCase();
|
|
210
|
+
const transport = configTransport === 'both' ? 'both' : (configTransport === 'ftp' || ftpConfig ? 'ftp' : 'local');
|
|
211
|
+
const publishDir = transport === 'ftp' ? rawPublishDir : resolve(rawPublishDir);
|
|
212
|
+
const ftpRemoteDir = rawPublishDir;
|
|
213
|
+
|
|
214
|
+
if (!publishDir || ((transport === 'local' || transport === 'both') && publishDir === resolve(''))) {
|
|
215
|
+
throw new Error(`Static dashboard publish requested, but no publish directory was resolved. Checked --publish-dir and env key '${publishDirEnvKey || 'not provided'}'.`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
enabled: true,
|
|
220
|
+
transport,
|
|
221
|
+
publishDir,
|
|
222
|
+
ftpRemoteDir,
|
|
223
|
+
publicBaseUrl: normalizePublicBaseUrl(args.publishUrlBase || publishConfig.public_base_url || ''),
|
|
224
|
+
latestFileName: sanitizeFileName(args.publishLatestName || publishConfig.latest_file_name || 'index.html'),
|
|
225
|
+
ftpConfig,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function publishStaticDashboardSnapshot(options) {
|
|
230
|
+
const {
|
|
231
|
+
payload,
|
|
232
|
+
runDir,
|
|
233
|
+
templatePath,
|
|
234
|
+
publishDir,
|
|
235
|
+
ftpRemoteDir = '',
|
|
236
|
+
publicBaseUrl = '',
|
|
237
|
+
latestFileName = 'index.html',
|
|
238
|
+
transport = 'local',
|
|
239
|
+
ftpConfig = null,
|
|
240
|
+
uploadViaFtpImpl = uploadDirectoryViaFtp,
|
|
241
|
+
} = options;
|
|
242
|
+
const runId = sanitizeFileName(payload?.runId || 'snapshot');
|
|
243
|
+
const latestVariant = 'latest';
|
|
244
|
+
const archiveVariant = runId;
|
|
245
|
+
const latestDataFileName = 'run-dashboard.data.json';
|
|
246
|
+
const archiveDataFileName = `${runId}.data.json`;
|
|
247
|
+
const workingPublishDir = transport === 'ftp'
|
|
248
|
+
? await mkdtemp(join(tmpdir(), 'opensquad-dashboard-publish-'))
|
|
249
|
+
: publishDir;
|
|
250
|
+
const latestPayload = await rewritePayloadForStaticPublish({ payload, runDir, publishDir: workingPublishDir, variantName: latestVariant });
|
|
251
|
+
const archivePayload = await rewritePayloadForStaticPublish({ payload, runDir, publishDir: workingPublishDir, variantName: archiveVariant });
|
|
252
|
+
const latestHtml = await renderDashboardHtml({ templatePath, payload: latestPayload, dataFileName: latestDataFileName });
|
|
253
|
+
const archiveHtml = await renderDashboardHtml({ templatePath, payload: archivePayload, dataFileName: archiveDataFileName });
|
|
254
|
+
const latestPath = join(workingPublishDir, latestFileName);
|
|
255
|
+
const archiveFileName = `${runId}.html`;
|
|
256
|
+
const archivePath = join(workingPublishDir, archiveFileName);
|
|
257
|
+
const latestDataPath = join(workingPublishDir, latestDataFileName);
|
|
258
|
+
const archiveDataPath = join(workingPublishDir, archiveDataFileName);
|
|
259
|
+
const latestYouTubeShareFileName = 'youtube.html';
|
|
260
|
+
const archiveYouTubeShareFileName = `${runId}-youtube.html`;
|
|
261
|
+
const latestYouTubeSharePath = join(workingPublishDir, latestYouTubeShareFileName);
|
|
262
|
+
const archiveYouTubeSharePath = join(workingPublishDir, archiveYouTubeShareFileName);
|
|
263
|
+
|
|
264
|
+
await mkdir(dirname(latestPath), { recursive: true });
|
|
265
|
+
await mkdir(dirname(archivePath), { recursive: true });
|
|
266
|
+
await writeFile(latestPath, latestHtml, 'utf-8');
|
|
267
|
+
await writeFile(archivePath, archiveHtml, 'utf-8');
|
|
268
|
+
await writeFile(latestDataPath, `${JSON.stringify(latestPayload, null, 2)}\n`, 'utf-8');
|
|
269
|
+
await writeFile(archiveDataPath, `${JSON.stringify(archivePayload, null, 2)}\n`, 'utf-8');
|
|
270
|
+
|
|
271
|
+
const latestYouTubeSharePage = buildYouTubeSharePage({
|
|
272
|
+
payload: latestPayload,
|
|
273
|
+
ogUrl: publicBaseUrl ? `${publicBaseUrl}${latestYouTubeShareFileName}` : null,
|
|
274
|
+
});
|
|
275
|
+
if (latestYouTubeSharePage) {
|
|
276
|
+
await writeFile(latestYouTubeSharePath, latestYouTubeSharePage, 'utf-8');
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const archiveYouTubeSharePage = buildYouTubeSharePage({
|
|
280
|
+
payload: archivePayload,
|
|
281
|
+
ogUrl: publicBaseUrl ? `${publicBaseUrl}${archiveYouTubeShareFileName}` : null,
|
|
282
|
+
});
|
|
283
|
+
if (archiveYouTubeSharePage) {
|
|
284
|
+
await writeFile(archiveYouTubeSharePath, archiveYouTubeSharePage, 'utf-8');
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (transport === 'ftp' || transport === 'both') {
|
|
288
|
+
if (!ftpConfig) {
|
|
289
|
+
throw new Error('FTP static publish requested, but FTP credentials were not resolved.');
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
try {
|
|
293
|
+
await uploadViaFtpImpl({
|
|
294
|
+
localDir: workingPublishDir,
|
|
295
|
+
remoteDir: transport === 'both' ? ftpRemoteDir : publishDir,
|
|
296
|
+
ftpConfig,
|
|
297
|
+
});
|
|
298
|
+
} finally {
|
|
299
|
+
if (transport === 'ftp') {
|
|
300
|
+
await rm(workingPublishDir, { recursive: true, force: true });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return {
|
|
306
|
+
latestPath: transport === 'ftp' ? join(publishDir, latestFileName) : latestPath,
|
|
307
|
+
archivePath: transport === 'ftp' ? join(publishDir, archiveFileName) : archivePath,
|
|
308
|
+
latestDataPath: transport === 'ftp' ? join(publishDir, latestDataFileName) : latestDataPath,
|
|
309
|
+
archiveDataPath: transport === 'ftp' ? join(publishDir, archiveDataFileName) : archiveDataPath,
|
|
310
|
+
latestUrl: publicBaseUrl ? `${publicBaseUrl}${latestFileName}` : null,
|
|
311
|
+
archiveUrl: publicBaseUrl ? `${publicBaseUrl}${archiveFileName}` : null,
|
|
312
|
+
latestDataUrl: publicBaseUrl ? `${publicBaseUrl}${latestDataFileName}` : null,
|
|
313
|
+
archiveDataUrl: publicBaseUrl ? `${publicBaseUrl}${archiveDataFileName}` : null,
|
|
314
|
+
latestYouTubeSharePath: latestYouTubeSharePage
|
|
315
|
+
? (transport === 'ftp' ? join(publishDir, latestYouTubeShareFileName) : latestYouTubeSharePath)
|
|
316
|
+
: null,
|
|
317
|
+
archiveYouTubeSharePath: archiveYouTubeSharePage
|
|
318
|
+
? (transport === 'ftp' ? join(publishDir, archiveYouTubeShareFileName) : archiveYouTubeSharePath)
|
|
319
|
+
: null,
|
|
320
|
+
latestYouTubeShareUrl: latestYouTubeSharePage && publicBaseUrl ? `${publicBaseUrl}${latestYouTubeShareFileName}` : null,
|
|
321
|
+
archiveYouTubeShareUrl: archiveYouTubeSharePage && publicBaseUrl ? `${publicBaseUrl}${archiveYouTubeShareFileName}` : null,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function buildYouTubeSharePage({ payload, ogUrl }) {
|
|
326
|
+
if (!payload || !Array.isArray(payload.channels)) return null;
|
|
327
|
+
|
|
328
|
+
const youtubeChannel = payload.channels.find((channel) => String(channel?.name || '').toLowerCase() === 'youtube');
|
|
329
|
+
const youtubeLink = youtubeChannel?.links?.find((link) => isYouTubeLink(link?.href))?.href || null;
|
|
330
|
+
if (!youtubeLink) return null;
|
|
331
|
+
|
|
332
|
+
const thumbnail = Array.isArray(payload.previews)
|
|
333
|
+
? payload.previews.find((preview) => String(preview?.type || '').toLowerCase() === 'thumbnail')
|
|
334
|
+
: null;
|
|
335
|
+
|
|
336
|
+
const imageHref = thumbnail?.src || null;
|
|
337
|
+
const title = payload?.title ? `${payload.title} · YouTube` : 'YouTube Video';
|
|
338
|
+
const description = payload?.subtitle
|
|
339
|
+
|| youtubeChannel?.summary
|
|
340
|
+
|| 'Abra o vídeo publicado desta run no YouTube.';
|
|
341
|
+
|
|
342
|
+
return renderOgRedirectHtml({
|
|
343
|
+
title,
|
|
344
|
+
description,
|
|
345
|
+
ogUrl: ogUrl || youtubeLink,
|
|
346
|
+
imageHref,
|
|
347
|
+
redirectUrl: youtubeLink,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function renderOgRedirectHtml({ title, description, ogUrl, imageHref, redirectUrl }) {
|
|
352
|
+
const safeTitle = escapeHtml(title || 'YouTube Video');
|
|
353
|
+
const safeDescription = escapeHtml(description || 'Abra o vídeo publicado desta run no YouTube.');
|
|
354
|
+
const safeOgUrl = escapeHtml(ogUrl || redirectUrl || '');
|
|
355
|
+
const safeImage = imageHref ? escapeHtml(imageHref) : '';
|
|
356
|
+
const safeRedirect = escapeHtml(redirectUrl || '');
|
|
357
|
+
|
|
358
|
+
return `<!DOCTYPE html>
|
|
359
|
+
<html lang="pt-BR">
|
|
360
|
+
<head>
|
|
361
|
+
<meta charset="UTF-8">
|
|
362
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
363
|
+
<title>${safeTitle}</title>
|
|
364
|
+
<meta name="description" content="${safeDescription}">
|
|
365
|
+
<meta property="og:title" content="${safeTitle}">
|
|
366
|
+
<meta property="og:description" content="${safeDescription}">
|
|
367
|
+
<meta property="og:type" content="video.other">
|
|
368
|
+
<meta property="og:url" content="${safeOgUrl}">
|
|
369
|
+
${safeImage ? `<meta property="og:image" content="${safeImage}">` : ''}
|
|
370
|
+
<meta name="twitter:card" content="summary_large_image">
|
|
371
|
+
<meta name="twitter:title" content="${safeTitle}">
|
|
372
|
+
<meta name="twitter:description" content="${safeDescription}">
|
|
373
|
+
${safeImage ? `<meta name="twitter:image" content="${safeImage}">` : ''}
|
|
374
|
+
${safeRedirect ? `<meta http-equiv="refresh" content="0; url=${safeRedirect}">` : ''}
|
|
375
|
+
</head>
|
|
376
|
+
<body>
|
|
377
|
+
<p>Redirecionando para o vídeo no YouTube...</p>
|
|
378
|
+
${safeRedirect ? `<p><a href="${safeRedirect}">Abrir vídeo</a></p>` : ''}
|
|
379
|
+
</body>
|
|
380
|
+
</html>
|
|
381
|
+
`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function isYouTubeLink(value) {
|
|
385
|
+
return /^https?:\/\/(?:www\.)?(?:youtube\.com\/watch\?v=|youtu\.be\/)/i.test(String(value || ''));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function escapeHtml(value) {
|
|
389
|
+
return String(value || '')
|
|
390
|
+
.replace(/&/g, '&')
|
|
391
|
+
.replace(/</g, '<')
|
|
392
|
+
.replace(/>/g, '>')
|
|
393
|
+
.replace(/"/g, '"')
|
|
394
|
+
.replace(/'/g, ''');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function resolveStaticPublishFtpConfig({ envVars, publishConfig }) {
|
|
398
|
+
const ftpSection = publishConfig?.ftp || {};
|
|
399
|
+
const hostKey = ftpSection.host_env || '';
|
|
400
|
+
const portKey = ftpSection.port_env || '';
|
|
401
|
+
const userKey = ftpSection.user_env || '';
|
|
402
|
+
const passKey = ftpSection.pass_env || '';
|
|
403
|
+
|
|
404
|
+
if (!hostKey && !portKey && !userKey && !passKey) return null;
|
|
405
|
+
|
|
406
|
+
const host = hostKey ? envVars[hostKey] || '' : '';
|
|
407
|
+
const port = portKey ? Number(envVars[portKey] || 21) : 21;
|
|
408
|
+
const user = userKey ? envVars[userKey] || '' : '';
|
|
409
|
+
const password = passKey ? envVars[passKey] || '' : '';
|
|
410
|
+
|
|
411
|
+
if (!host || !user) {
|
|
412
|
+
throw new Error(`FTP static publish is configured, but required credentials are missing. Checked env keys '${hostKey}' and '${userKey}'.`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
host,
|
|
417
|
+
port,
|
|
418
|
+
user,
|
|
419
|
+
password,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function uploadDirectoryViaFtp({ localDir, remoteDir, ftpConfig }) {
|
|
424
|
+
const client = new FtpClient();
|
|
425
|
+
|
|
426
|
+
try {
|
|
427
|
+
await client.access({
|
|
428
|
+
host: ftpConfig.host,
|
|
429
|
+
port: Number(ftpConfig.port) || 21,
|
|
430
|
+
user: ftpConfig.user,
|
|
431
|
+
password: ftpConfig.password,
|
|
432
|
+
secure: true,
|
|
433
|
+
secureOptions: { rejectUnauthorized: false },
|
|
434
|
+
});
|
|
435
|
+
await client.ensureDir(String(remoteDir).replace(/\\/g, '/'));
|
|
436
|
+
await client.uploadFromDir(localDir, String(remoteDir).replace(/\\/g, '/'));
|
|
437
|
+
} finally {
|
|
438
|
+
client.close();
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
async function loadRunFiles(runDir) {
|
|
443
|
+
const entries = {};
|
|
444
|
+
for (const [key, relativePaths] of Object.entries(RUN_FILE_PATHS)) {
|
|
445
|
+
entries[key] = await firstExistingFile(runDir, relativePaths);
|
|
446
|
+
}
|
|
447
|
+
return entries;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
async function firstExistingFile(runDir, relativePaths) {
|
|
451
|
+
for (const relativePath of relativePaths) {
|
|
452
|
+
const absolutePath = join(runDir, relativePath);
|
|
453
|
+
if (!existsSync(absolutePath)) continue;
|
|
454
|
+
return {
|
|
455
|
+
path: absolutePath,
|
|
456
|
+
relativePath,
|
|
457
|
+
content: await readFile(absolutePath, 'utf-8'),
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
return null;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function inferSquadName(runDir) {
|
|
464
|
+
const normalized = runDir.replace(/\\/g, '/');
|
|
465
|
+
const parts = normalized.split('/');
|
|
466
|
+
const squadsIndex = parts.lastIndexOf('squads');
|
|
467
|
+
return squadsIndex !== -1 ? parts[squadsIndex + 1] : 'unknown-squad';
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function inferRunContext(runDir, context = {}) {
|
|
471
|
+
const normalized = runDir.replace(/\\/g, '/');
|
|
472
|
+
const parts = normalized.split('/').filter(Boolean);
|
|
473
|
+
const leaf = parts.at(-1) || 'unknown-run';
|
|
474
|
+
const parent = parts.at(-2) || null;
|
|
475
|
+
|
|
476
|
+
if (isTimestampLikeRunId(leaf)) {
|
|
477
|
+
return { runId: leaf, isLegacyOutputRoot: false, source: 'folder-name' };
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (/^v\d+$/i.test(leaf) && parent && isTimestampLikeRunId(parent)) {
|
|
481
|
+
return { runId: parent, isLegacyOutputRoot: false, source: 'version-folder' };
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
if (leaf === 'output' || leaf === 'archive') {
|
|
485
|
+
const derivedFromState = formatRunIdFromDate(context.state?.startedAt || context.state?.completedAt || context.state?.updatedAt);
|
|
486
|
+
if (derivedFromState) {
|
|
487
|
+
return { runId: derivedFromState, isLegacyOutputRoot: true, source: 'state-timestamp' };
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const derivedFromPublish = formatRunIdFromDate(context.publish?.publishedAt);
|
|
491
|
+
if (derivedFromPublish) {
|
|
492
|
+
return { runId: derivedFromPublish, isLegacyOutputRoot: true, source: 'publish-timestamp' };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return { runId: 'legacy-output-root', isLegacyOutputRoot: true, source: 'legacy-output-root' };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return { runId: leaf, isLegacyOutputRoot: false, source: 'folder-name' };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function isTimestampLikeRunId(value) {
|
|
502
|
+
return /^\d{4}-\d{2}-\d{2}-\d{6}$/.test(value || '');
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function formatRunIdFromDate(value) {
|
|
506
|
+
if (!value) return null;
|
|
507
|
+
const isoLikeMatch = String(value).match(/^(\d{4})-(\d{2})-(\d{2})[T\s](\d{2}):(\d{2}):(\d{2})/);
|
|
508
|
+
if (isoLikeMatch) {
|
|
509
|
+
const [, year, month, day, hours, minutes, seconds] = isoLikeMatch;
|
|
510
|
+
return `${year}-${month}-${day}-${hours}${minutes}${seconds}`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const date = new Date(value);
|
|
514
|
+
if (Number.isNaN(date.getTime())) return null;
|
|
515
|
+
|
|
516
|
+
const year = String(date.getFullYear());
|
|
517
|
+
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
518
|
+
const day = String(date.getDate()).padStart(2, '0');
|
|
519
|
+
const hours = String(date.getHours()).padStart(2, '0');
|
|
520
|
+
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
521
|
+
const seconds = String(date.getSeconds()).padStart(2, '0');
|
|
522
|
+
|
|
523
|
+
return `${year}-${month}-${day}-${hours}${minutes}${seconds}`;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function parseState(file) {
|
|
527
|
+
if (!file?.content) return {};
|
|
528
|
+
try {
|
|
529
|
+
return JSON.parse(file.content);
|
|
530
|
+
} catch {
|
|
531
|
+
return {};
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function parsePublishResult(file) {
|
|
536
|
+
const content = file?.content || '';
|
|
537
|
+
return {
|
|
538
|
+
status: extractLabeledValue(content, 'Status'),
|
|
539
|
+
instagramPostId: extractLabeledValue(content, 'Instagram Post ID'),
|
|
540
|
+
instagramUrl: extractLabeledValue(content, 'Instagram URL'),
|
|
541
|
+
facebookPostId: extractLabeledValue(content, 'Facebook Post ID'),
|
|
542
|
+
facebookUrl: extractLabeledValue(content, 'Facebook URL'),
|
|
543
|
+
youtubeVideoId: extractLabeledValue(content, 'YouTube Video ID'),
|
|
544
|
+
youtubeUrl: extractLabeledValue(content, 'YouTube URL'),
|
|
545
|
+
youtubeThumbnail: extractLabeledValue(content, 'YouTube Thumbnail'),
|
|
546
|
+
youtubePlaylist: extractLabeledValue(content, 'YouTube Playlist'),
|
|
547
|
+
publishedAt: extractLabeledValue(content, 'Publicado em'),
|
|
548
|
+
error: extractLabeledValue(content, 'Erro remanescente'),
|
|
549
|
+
newsletterStatus: extractLabeledValue(content, 'Newsletter Status'),
|
|
550
|
+
newsletterMode: extractLabeledValue(content, 'Newsletter Mode'),
|
|
551
|
+
newsletterBrand: extractLabeledValue(content, 'Newsletter Brand'),
|
|
552
|
+
newsletterSubject: extractLabeledValue(content, 'Newsletter Subject'),
|
|
553
|
+
newsletterTo: extractLabeledValue(content, 'Newsletter To'),
|
|
554
|
+
newsletterHtml: extractLabeledValue(content, 'Newsletter HTML'),
|
|
555
|
+
newsletterResult: extractLabeledValue(content, 'Newsletter Result'),
|
|
556
|
+
newsletterNotes: extractLabeledValue(content, 'Newsletter Notes'),
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function extractLabeledValue(content, label) {
|
|
561
|
+
const escapedLabel = label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
562
|
+
const match = content.match(new RegExp(`\\*\\*${escapedLabel}:\\*\\*\\s*(.+)`, 'i'));
|
|
563
|
+
return match ? sanitizeValue(match[1]) : null;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function sanitizeValue(value) {
|
|
567
|
+
if (!value) return null;
|
|
568
|
+
const normalized = value
|
|
569
|
+
.replace(/^\*\*|\*\*$/g, '')
|
|
570
|
+
.replace(/^__|__$/g, '')
|
|
571
|
+
.replace(/^`|`$/g, '')
|
|
572
|
+
.replace(/^<|>$/g, '')
|
|
573
|
+
.trim();
|
|
574
|
+
const markdownLinkMatch = normalized.match(/^\[[^\]]+\]\(([^)]+)\)$/);
|
|
575
|
+
const unwrapped = markdownLinkMatch ? markdownLinkMatch[1].trim() : normalized;
|
|
576
|
+
if (!unwrapped || unwrapped.toLowerCase() === 'null' || unwrapped === 'N/A') return null;
|
|
577
|
+
return unwrapped;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function extractTheme(file) {
|
|
581
|
+
const content = file?.content || '';
|
|
582
|
+
const directPatterns = [
|
|
583
|
+
/\*\*Tema Central:\*\*\s*(.+)/i,
|
|
584
|
+
/\*\*Tema central da semana:\*\*\s*(.+)/i,
|
|
585
|
+
/\*\*Tema:\*\*\s*(.+)/i,
|
|
586
|
+
];
|
|
587
|
+
|
|
588
|
+
for (const pattern of directPatterns) {
|
|
589
|
+
const match = content.match(pattern);
|
|
590
|
+
if (match) return sanitizeValue(match[1]);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
const lines = content.split(/\r?\n/);
|
|
594
|
+
const heading = lines.find((line) => /^#\s+/.test(line.trim()));
|
|
595
|
+
return heading ? sanitizeValue(heading.replace(/^#\s+/, '')) : null;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function buildSubtitle({ publish, squadName, runId }) {
|
|
599
|
+
const parts = [squadName, runId].filter(Boolean);
|
|
600
|
+
if (publish.publishedAt) parts.push(`publicado em ${publish.publishedAt}`);
|
|
601
|
+
return parts.join(' · ');
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function buildChecklist({ state, runFiles, publish, runContext }) {
|
|
605
|
+
const reviewDecision = extractLabeledValue(runFiles.review?.content || '', 'Decision');
|
|
606
|
+
const imagesStatus = extractLabeledValue(runFiles.images?.content || '', 'Status');
|
|
607
|
+
const publishConfigPlatforms = extractLabeledValue(runFiles.publishConfig?.content || '', 'Plataformas');
|
|
608
|
+
const complementaryDistribution = extractLabeledValue(runFiles.publishConfig?.content || '', 'Distribuição complementar');
|
|
609
|
+
const newsletterRecipient = extractLabeledValue(runFiles.publishConfig?.content || '', 'Newsletter destinatário teste');
|
|
610
|
+
const finalStateDescription = state.completedAt || state.failedAt
|
|
611
|
+
? `Finalizado em ${state.completedAt || state.failedAt}`
|
|
612
|
+
: runContext.isLegacyOutputRoot && publish.publishedAt
|
|
613
|
+
? `Run legada sem state.json; usando snapshot publicado em ${publish.publishedAt}.`
|
|
614
|
+
: 'State final não encontrado.';
|
|
615
|
+
const finalStateStatus = state.status ? 'ok' : runContext.isLegacyOutputRoot ? 'warn' : 'warn';
|
|
616
|
+
const finalStateBadge = state.status || (runContext.isLegacyOutputRoot ? 'legacy snapshot' : 'unknown');
|
|
617
|
+
const finalStateIcon = state.status === 'completed' ? 'OK' : runContext.isLegacyOutputRoot ? 'LG' : '??';
|
|
618
|
+
|
|
619
|
+
const checklist = [
|
|
620
|
+
{
|
|
621
|
+
title: 'Revisão de qualidade consolidada',
|
|
622
|
+
description: reviewDecision ? `Veredito da revisão: ${reviewDecision}` : 'Arquivo de review não encontrado ou sem decisão explícita.',
|
|
623
|
+
status: reviewDecision && reviewDecision.toLowerCase() === 'aprovar' ? 'ok' : 'warn',
|
|
624
|
+
badge: reviewDecision && reviewDecision.toLowerCase() === 'aprovar' ? 'approved' : (reviewDecision || 'pending'),
|
|
625
|
+
badgeLabel: reviewDecision && reviewDecision.toLowerCase() === 'aprovar' ? 'APROVADO' : (reviewDecision || 'pending'),
|
|
626
|
+
icon: reviewDecision && reviewDecision.toLowerCase() === 'aprovar' ? 'OK' : '!!',
|
|
627
|
+
},
|
|
628
|
+
{
|
|
629
|
+
title: 'Imagens/render aprovados',
|
|
630
|
+
description: imagesStatus ? `Manifesto de imagens em estado ${imagesStatus}.` : 'Manifesto de imagens não localizado.',
|
|
631
|
+
status: imagesStatus && imagesStatus.toLowerCase() === 'approved' ? 'ok' : 'warn',
|
|
632
|
+
badge: imagesStatus || 'missing',
|
|
633
|
+
badgeLabel: imagesStatus || 'missing',
|
|
634
|
+
icon: imagesStatus && imagesStatus.toLowerCase() === 'approved' ? 'OK' : '!!',
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
title: 'Configuração de publicação registrada',
|
|
638
|
+
description: publishConfigPlatforms ? `Plataformas aprovadas: ${publishConfigPlatforms}` : 'Configuração de publicação não encontrada.',
|
|
639
|
+
status: publishConfigPlatforms ? 'ok' : 'warn',
|
|
640
|
+
badge: publishConfigPlatforms ? 'recorded' : 'missing',
|
|
641
|
+
badgeLabel: publishConfigPlatforms ? 'recorded' : 'missing',
|
|
642
|
+
icon: publishConfigPlatforms ? 'OK' : '!!',
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
title: 'Resultado operacional consolidado',
|
|
646
|
+
description: publish.status ? `Publicação final em estado ${publish.status}.` : 'publish-result.md não encontrado.',
|
|
647
|
+
status: publish.status ? (publish.status.toLowerCase() === 'published' ? 'ok' : publish.status.toLowerCase()) : 'warn',
|
|
648
|
+
badge: publish.status || 'missing',
|
|
649
|
+
badgeLabel: publish.status || 'missing',
|
|
650
|
+
icon: publish.status && publish.status.toLowerCase() === 'published' ? 'OK' : '!!',
|
|
651
|
+
},
|
|
652
|
+
];
|
|
653
|
+
|
|
654
|
+
if ((complementaryDistribution || '').toLowerCase() === 'jornal-matutino' || publish.newsletterStatus) {
|
|
655
|
+
const newsletterStatus = (publish.newsletterStatus || 'pending').toLowerCase();
|
|
656
|
+
checklist.push({
|
|
657
|
+
title: 'Distribuição complementar do jornal-matutino',
|
|
658
|
+
description: publish.newsletterStatus
|
|
659
|
+
? `Resultado complementar em estado ${publish.newsletterStatus}${publish.newsletterTo ? ` para ${publish.newsletterTo}` : ''}.`
|
|
660
|
+
: `Distribuição complementar registrada${newsletterRecipient ? ` para ${newsletterRecipient}` : ''}, aguardando execução do step final.`,
|
|
661
|
+
status: newsletterStatus === 'sent' ? 'ok' : newsletterStatus === 'failed' ? 'error' : 'warn',
|
|
662
|
+
badge: publish.newsletterStatus || complementaryDistribution || 'pending',
|
|
663
|
+
badgeLabel: publish.newsletterStatus || complementaryDistribution || 'pending',
|
|
664
|
+
icon: newsletterStatus === 'sent' ? 'OK' : newsletterStatus === 'failed' ? 'XX' : '!!',
|
|
665
|
+
});
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
checklist.push({
|
|
669
|
+
title: 'Estado final da pipeline registrado',
|
|
670
|
+
description: finalStateDescription,
|
|
671
|
+
status: finalStateStatus,
|
|
672
|
+
badge: finalStateBadge,
|
|
673
|
+
badgeLabel: finalStateBadge,
|
|
674
|
+
icon: finalStateIcon,
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
return checklist;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function buildChannels(publish) {
|
|
681
|
+
return [
|
|
682
|
+
buildChannel('Instagram', publish.instagramUrl, publish.instagramPostId, publish.instagramUrl ? 'published' : publish.status === 'partial' ? 'partial' : 'missing', 'Post publicado no feed ou carrossel do Instagram.'),
|
|
683
|
+
buildChannel('Facebook', publish.facebookUrl, publish.facebookPostId, publish.facebookUrl ? 'published' : publish.status === 'partial' ? 'partial' : 'missing', 'Cross-post ou publicação da Página do Facebook.'),
|
|
684
|
+
buildChannel('YouTube', publish.youtubeUrl, publish.youtubeVideoId, publish.youtubeUrl ? 'published' : 'missing', publish.youtubePlaylist ? `Vídeo adicionado à playlist ${publish.youtubePlaylist}.` : 'Vídeo/slideshow publicado no YouTube.'),
|
|
685
|
+
buildNewsletterChannel(publish),
|
|
686
|
+
].filter(Boolean);
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function buildNewsletterChannel(publish) {
|
|
690
|
+
if (!publish.newsletterStatus && !publish.newsletterSubject && !publish.newsletterTo) return null;
|
|
691
|
+
|
|
692
|
+
const links = [
|
|
693
|
+
publish.newsletterHtml ? { label: 'Abrir newsletter HTML', href: normalizeDashboardPath(publish.newsletterHtml) } : null,
|
|
694
|
+
publish.newsletterResult ? { label: 'Abrir resultado de envio', href: normalizeDashboardPath(publish.newsletterResult) } : null,
|
|
695
|
+
].filter(Boolean);
|
|
696
|
+
|
|
697
|
+
return {
|
|
698
|
+
name: 'Newsletter',
|
|
699
|
+
status: publish.newsletterStatus || 'pending',
|
|
700
|
+
summary: [
|
|
701
|
+
publish.newsletterSubject ? `Assunto: ${publish.newsletterSubject}.` : 'Edição complementar pronta.',
|
|
702
|
+
publish.newsletterTo ? `Destino de teste: ${publish.newsletterTo}.` : null,
|
|
703
|
+
publish.newsletterNotes || null,
|
|
704
|
+
].filter(Boolean).join(' '),
|
|
705
|
+
links,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function buildChannel(name, href, identifier, status, summary) {
|
|
710
|
+
if (!href && !identifier) return null;
|
|
711
|
+
return {
|
|
712
|
+
name,
|
|
713
|
+
status: status || 'unknown',
|
|
714
|
+
summary: [summary, identifier ? `ID: ${identifier}` : null].filter(Boolean).join(' '),
|
|
715
|
+
links: href ? [{ label: `Abrir ${name}`, href }] : [],
|
|
716
|
+
};
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function buildArtifacts(runDir, runFiles) {
|
|
720
|
+
return [
|
|
721
|
+
{ label: 'run-folder', path: runDir, href: './', kind: 'folder' },
|
|
722
|
+
...Object.values(runFiles)
|
|
723
|
+
.filter(Boolean)
|
|
724
|
+
.map((file) => ({
|
|
725
|
+
label: file.relativePath,
|
|
726
|
+
path: file.path,
|
|
727
|
+
href: toRelativeHref(runDir, file.path),
|
|
728
|
+
kind: inferArtifactKind(file.path),
|
|
729
|
+
})),
|
|
730
|
+
];
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
async function buildVisualPreviews(runDir, publish, contentPackageFile) {
|
|
734
|
+
const previews = [];
|
|
735
|
+
const imageDirCandidates = [join(runDir, 'images'), join(runDir, 'v1', 'images')];
|
|
736
|
+
const thumbDirCandidates = [join(runDir, 'thumbs'), join(runDir, 'v1', 'thumbs')];
|
|
737
|
+
const contentPackage = parseContentPackagePreviewData(contentPackageFile?.content || '');
|
|
738
|
+
const imageDir = imageDirCandidates.find((candidate) => existsSync(candidate));
|
|
739
|
+
const thumbDir = thumbDirCandidates.find((candidate) => existsSync(candidate)) || join(runDir, 'thumbs');
|
|
740
|
+
|
|
741
|
+
if (imageDir && existsSync(imageDir)) {
|
|
742
|
+
const entries = await readdir(imageDir, { withFileTypes: true });
|
|
743
|
+
const slideImages = entries
|
|
744
|
+
.filter((entry) => entry.isFile() && /^slide-\d+\.(jpg|png)$/i.test(entry.name))
|
|
745
|
+
.map((entry) => entry.name)
|
|
746
|
+
.sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
|
|
747
|
+
|
|
748
|
+
for (const fileName of slideImages) {
|
|
749
|
+
const baseName = fileName.replace(/\.(jpg|png)$/i, '');
|
|
750
|
+
const slideNumber = Number(baseName.match(/slide-(\d+)/i)?.[1] || '0');
|
|
751
|
+
const htmlPath = join(imageDir, `${baseName}.html`);
|
|
752
|
+
const imagePath = join(imageDir, fileName);
|
|
753
|
+
const title = contentPackage.slideTitles.get(slideNumber)
|
|
754
|
+
|| await extractSlideHeadline(htmlPath)
|
|
755
|
+
|| baseName.replace('slide-', 'Slide ');
|
|
756
|
+
const cta = contentPackage.globalCta
|
|
757
|
+
|| (slideImages.length === slideNumber ? 'Revise a mensagem final e publique com segurança.' : 'Abra o slide e revise o copy antes de publicar.');
|
|
758
|
+
|
|
759
|
+
previews.push({
|
|
760
|
+
label: baseName.replace('slide-', 'Slide '),
|
|
761
|
+
type: 'slide',
|
|
762
|
+
title,
|
|
763
|
+
cta,
|
|
764
|
+
src: toRelativeHref(runDir, imagePath),
|
|
765
|
+
href: existsSync(htmlPath) ? toRelativeHref(runDir, htmlPath) : toRelativeHref(runDir, imagePath),
|
|
766
|
+
meta: existsSync(htmlPath) ? 'Abrir HTML editável' : 'Abrir imagem final',
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const thumbnailCandidates = [
|
|
772
|
+
publish.youtubeThumbnail ? join(runDir, normalizeDashboardPath(publish.youtubeThumbnail)) : null,
|
|
773
|
+
join(thumbDir, 'youtube-thumb-16x9.jpg'),
|
|
774
|
+
].filter(Boolean);
|
|
775
|
+
|
|
776
|
+
const thumbnailPath = thumbnailCandidates.find((candidate) => existsSync(candidate));
|
|
777
|
+
if (thumbnailPath) {
|
|
778
|
+
previews.push({
|
|
779
|
+
label: 'YouTube Thumbnail',
|
|
780
|
+
type: 'thumbnail',
|
|
781
|
+
title: 'Thumbnail do vídeo publicado',
|
|
782
|
+
cta: publish.youtubeUrl ? 'Abra o vídeo final no YouTube.' : 'Revise a thumbnail antes de publicar.',
|
|
783
|
+
src: toRelativeHref(runDir, thumbnailPath),
|
|
784
|
+
href: publish.youtubeUrl || toRelativeHref(runDir, thumbnailPath),
|
|
785
|
+
meta: publish.youtubeUrl ? 'Abrir vídeo publicado' : 'Abrir thumbnail',
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
return previews;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function parseContentPackagePreviewData(content) {
|
|
793
|
+
const slideTitles = new Map();
|
|
794
|
+
const sectionMatches = content.matchAll(/##\s+Slide\s+(\d+)\s+-[^\n]*([\s\S]*?)(?=\n##\s+Slide\s+\d+\s+-|\n##\s+Legenda|\n##\s+Hashtags|\n##\s+YouTube|\n##\s+Notas|\n##\s+Checklist|$)/gi);
|
|
795
|
+
|
|
796
|
+
for (const match of sectionMatches) {
|
|
797
|
+
const slideNumber = Number(match[1]);
|
|
798
|
+
const titleMatch = match[2]?.match(/\*\*T[íi]tulo:\*\*\s*(.+)/i);
|
|
799
|
+
const title = sanitizeValue(titleMatch?.[1]?.split(/\r?\n/)[0] || '');
|
|
800
|
+
if (slideNumber && title) slideTitles.set(slideNumber, title);
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
return {
|
|
804
|
+
slideTitles,
|
|
805
|
+
globalCta: extractLabeledValue(content, 'CTA principal'),
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
async function extractSlideHeadline(htmlPath) {
|
|
810
|
+
if (!existsSync(htmlPath)) return null;
|
|
811
|
+
|
|
812
|
+
try {
|
|
813
|
+
const html = await readFile(htmlPath, 'utf-8');
|
|
814
|
+
const match = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
|
|
815
|
+
if (!match) return null;
|
|
816
|
+
const plainText = match[1]
|
|
817
|
+
.replace(/<[^>]+>/g, ' ')
|
|
818
|
+
.replace(/\s+/g, ' ')
|
|
819
|
+
.trim();
|
|
820
|
+
return plainText || null;
|
|
821
|
+
} catch {
|
|
822
|
+
return null;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
function inferArtifactKind(filePath) {
|
|
827
|
+
const extension = extname(filePath).toLowerCase();
|
|
828
|
+
if (extension === '.md') return 'markdown';
|
|
829
|
+
if (extension === '.json') return 'json';
|
|
830
|
+
if (extension === '.html') return 'html';
|
|
831
|
+
if (extension === '.jpg' || extension === '.jpeg' || extension === '.png' || extension === '.webp') return 'image';
|
|
832
|
+
return 'file';
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function normalizeDashboardPath(value) {
|
|
836
|
+
return String(value).replace(/^output[\\/]/i, '').replace(/[\\/]+/g, '/');
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function toRelativeHref(runDir, absolutePath) {
|
|
840
|
+
const relativePath = relative(runDir, absolutePath).replace(/\\/g, '/');
|
|
841
|
+
if (!relativePath || relativePath === '.') return './';
|
|
842
|
+
return relativePath;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function buildErrorList({ publish, state, images, review }) {
|
|
846
|
+
const items = [];
|
|
847
|
+
|
|
848
|
+
if (publish.error) {
|
|
849
|
+
items.push({ title: 'Erro remanescente de publicação', status: 'warning', message: publish.error });
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
if ((publish.newsletterStatus || '').toLowerCase() === 'failed') {
|
|
853
|
+
items.push({ title: 'Falha na distribuição complementar', status: 'error', message: publish.newsletterNotes || 'A newsletter complementar falhou.' });
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
const blockingIssues = extractNumberedSection(images?.content || '', 'Blocking Issues');
|
|
857
|
+
for (const issue of blockingIssues) {
|
|
858
|
+
if (/nenhum/i.test(issue)) continue;
|
|
859
|
+
items.push({ title: 'Blocking issue — imagens', status: 'warning', message: issue });
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const reviewIssues = extractNumberedSection(review?.content || '', 'Blocking Issues');
|
|
863
|
+
for (const issue of reviewIssues) {
|
|
864
|
+
if (/nenhum/i.test(issue)) continue;
|
|
865
|
+
items.push({ title: 'Blocking issue — review', status: 'warning', message: issue });
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
if ((state.status || '').toLowerCase() === 'failed') {
|
|
869
|
+
items.push({ title: 'Pipeline falhou', status: 'error', message: `Falha registrada em ${state.failedAt || state.updatedAt || 'momento desconhecido'}.` });
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
return items;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function extractNumberedSection(content, heading) {
|
|
876
|
+
if (!content) return [];
|
|
877
|
+
const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
878
|
+
const match = content.match(new RegExp(`##\\s+${escapedHeading}([\\s\\S]*?)(?:\\n##\\s+|$)`, 'i'));
|
|
879
|
+
if (!match) return [];
|
|
880
|
+
return match[1]
|
|
881
|
+
.split(/\r?\n/)
|
|
882
|
+
.map((line) => line.trim())
|
|
883
|
+
.filter((line) => /^\d+\.\s+/.test(line))
|
|
884
|
+
.map((line) => line.replace(/^\d+\.\s+/, '').trim());
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async function collectMetrics({ publish, envVars, channelConfig }) {
|
|
888
|
+
const groups = [];
|
|
889
|
+
const instagramGroup = await collectInstagramMetrics(publish, envVars, channelConfig);
|
|
890
|
+
if (instagramGroup) groups.push(instagramGroup);
|
|
891
|
+
|
|
892
|
+
const facebookGroup = await collectFacebookMetrics(publish, envVars, channelConfig);
|
|
893
|
+
if (facebookGroup) groups.push(facebookGroup);
|
|
894
|
+
|
|
895
|
+
const youtubeGroup = await collectYouTubeMetrics(publish, envVars, channelConfig);
|
|
896
|
+
if (youtubeGroup) groups.push(youtubeGroup);
|
|
897
|
+
|
|
898
|
+
return groups;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
async function collectInstagramMetrics(publish, envVars, channelConfig) {
|
|
902
|
+
if (!publish.instagramPostId) return null;
|
|
903
|
+
|
|
904
|
+
const instagramAliases = channelConfig?.publishing?.instagram?.env_aliases || {};
|
|
905
|
+
const resolved = resolveInstagramEnv(envVars, {
|
|
906
|
+
instagramAccessTokenEnv: instagramAliases.access_token || null,
|
|
907
|
+
instagramUserIdEnv: instagramAliases.user_id || null,
|
|
908
|
+
facebookPageIdEnv: channelConfig?.publishing?.facebook?.env_aliases?.page_id || null,
|
|
909
|
+
facebookPageAccessTokenEnv: channelConfig?.publishing?.facebook?.env_aliases?.page_access_token || null,
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
if (!resolved.instagramAccessToken) {
|
|
913
|
+
return {
|
|
914
|
+
label: 'Instagram',
|
|
915
|
+
description: 'Snapshot disponível sem coleta online porque o access token não foi encontrado.',
|
|
916
|
+
status: 'warning',
|
|
917
|
+
values: [
|
|
918
|
+
{ label: 'Post ID', value: publish.instagramPostId, unit: 'snapshot' },
|
|
919
|
+
{ label: 'Likes', value: 'N/A', unit: 'api unavailable' },
|
|
920
|
+
],
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const media = await fetchJson(`https://graph.facebook.com/v22.0/${encodeURIComponent(publish.instagramPostId)}?fields=like_count&access_token=${encodeURIComponent(resolved.instagramAccessToken)}`);
|
|
925
|
+
const metricsToTry = [
|
|
926
|
+
{ metric: 'views', label: 'Views' },
|
|
927
|
+
{ metric: 'video_views', label: 'Views' },
|
|
928
|
+
{ metric: 'plays', label: 'Plays' },
|
|
929
|
+
{ metric: 'impressions', label: 'Impressions' },
|
|
930
|
+
];
|
|
931
|
+
|
|
932
|
+
let primaryMetric = { label: 'Views', value: 'N/A', unit: 'snapshot' };
|
|
933
|
+
for (const metric of metricsToTry) {
|
|
934
|
+
const insight = await fetchJson(`https://graph.facebook.com/v22.0/${encodeURIComponent(publish.instagramPostId)}/insights?metric=${metric.metric}&access_token=${encodeURIComponent(resolved.instagramAccessToken)}`);
|
|
935
|
+
const value = insight?.data?.[0]?.values?.[0]?.value;
|
|
936
|
+
if (value != null) {
|
|
937
|
+
primaryMetric = { label: metric.label, value, unit: 'api snapshot' };
|
|
938
|
+
break;
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
return {
|
|
943
|
+
label: 'Instagram',
|
|
944
|
+
description: 'Métricas coletadas via Instagram Graph API.',
|
|
945
|
+
status: 'published',
|
|
946
|
+
values: [
|
|
947
|
+
{ label: 'Post ID', value: publish.instagramPostId, unit: 'snapshot' },
|
|
948
|
+
{ label: 'Likes', value: media?.like_count ?? 'N/A', unit: 'api snapshot' },
|
|
949
|
+
primaryMetric,
|
|
950
|
+
{ label: 'Link', value: publish.instagramUrl ? 'ready' : 'N/A', unit: publish.instagramUrl || 'missing' },
|
|
951
|
+
],
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
async function collectFacebookMetrics(publish, envVars, channelConfig) {
|
|
956
|
+
if (!publish.facebookPostId) return null;
|
|
957
|
+
|
|
958
|
+
const facebookAliases = channelConfig?.publishing?.facebook?.env_aliases || {};
|
|
959
|
+
const instagramAliases = channelConfig?.publishing?.instagram?.env_aliases || {};
|
|
960
|
+
const pageId = facebookAliases.page_id ? envVars[facebookAliases.page_id] : envVars.FACEBOOK_PAGE_ID;
|
|
961
|
+
const configuredToken = facebookAliases.page_access_token ? envVars[facebookAliases.page_access_token] : envVars.FACEBOOK_PAGE_ACCESS_TOKEN;
|
|
962
|
+
const fallbackUserToken = instagramAliases.access_token ? envVars[instagramAliases.access_token] : envVars.INSTAGRAM_ACCESS_TOKEN;
|
|
963
|
+
|
|
964
|
+
const tokenResolution = pageId
|
|
965
|
+
? await resolveFacebookPageAccessToken({ pageId, configuredToken, fallbackUserToken })
|
|
966
|
+
: null;
|
|
967
|
+
|
|
968
|
+
if (!tokenResolution?.token) {
|
|
969
|
+
return {
|
|
970
|
+
label: 'Facebook',
|
|
971
|
+
description: 'Post ID disponível, mas não foi possível resolver um token de Página para métricas online.',
|
|
972
|
+
status: 'warning',
|
|
973
|
+
values: [
|
|
974
|
+
{ label: 'Post ID', value: publish.facebookPostId, unit: 'snapshot' },
|
|
975
|
+
{ label: 'Reactions', value: 'N/A', unit: 'api unavailable' },
|
|
976
|
+
],
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const post = await fetchJson(`https://graph.facebook.com/v22.0/${encodeURIComponent(publish.facebookPostId)}?fields=permalink_url,reactions.summary(total_count).limit(0),comments.summary(total_count).limit(0),shares&access_token=${encodeURIComponent(tokenResolution.token)}`);
|
|
981
|
+
|
|
982
|
+
return {
|
|
983
|
+
label: 'Facebook',
|
|
984
|
+
description: `Snapshot da Página via ${tokenResolution.source || 'page token'}.`,
|
|
985
|
+
status: publish.facebookUrl ? 'published' : 'partial',
|
|
986
|
+
values: [
|
|
987
|
+
{ label: 'Post ID', value: publish.facebookPostId, unit: 'snapshot' },
|
|
988
|
+
{ label: 'Reactions', value: post?.reactions?.summary?.total_count ?? 'N/A', unit: 'api snapshot' },
|
|
989
|
+
{ label: 'Comments', value: post?.comments?.summary?.total_count ?? 'N/A', unit: 'api snapshot' },
|
|
990
|
+
{ label: 'Shares', value: post?.shares?.count ?? 'N/A', unit: 'api snapshot' },
|
|
991
|
+
],
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
async function collectYouTubeMetrics(publish, envVars, channelConfig) {
|
|
996
|
+
if (!publish.youtubeVideoId) return null;
|
|
997
|
+
|
|
998
|
+
const aliases = channelConfig?.publishing?.youtube?.env_aliases || {};
|
|
999
|
+
const resolved = resolveYouTubeEnv(envVars, {
|
|
1000
|
+
youtubeClientIdEnv: aliases.client_id || null,
|
|
1001
|
+
youtubeClientSecretEnv: aliases.client_secret || null,
|
|
1002
|
+
youtubeRefreshTokenEnv: aliases.refresh_token || null,
|
|
1003
|
+
youtubeChannelIdEnv: aliases.channel_id || null,
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
if (!resolved.youtubeClientId || !resolved.youtubeClientSecret || !resolved.youtubeRefreshToken) {
|
|
1007
|
+
return {
|
|
1008
|
+
label: 'YouTube',
|
|
1009
|
+
description: 'Vídeo publicado, mas as credenciais OAuth não estão disponíveis para coletar estatísticas.',
|
|
1010
|
+
status: 'warning',
|
|
1011
|
+
values: [
|
|
1012
|
+
{ label: 'Video ID', value: publish.youtubeVideoId, unit: 'snapshot' },
|
|
1013
|
+
{ label: 'Views', value: 'N/A', unit: 'api unavailable' },
|
|
1014
|
+
],
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
const accessToken = await exchangeRefreshToken({
|
|
1019
|
+
clientId: resolved.youtubeClientId,
|
|
1020
|
+
clientSecret: resolved.youtubeClientSecret,
|
|
1021
|
+
refreshToken: resolved.youtubeRefreshToken,
|
|
1022
|
+
});
|
|
1023
|
+
|
|
1024
|
+
const video = await fetchJson(`https://www.googleapis.com/youtube/v3/videos?part=statistics,snippet&id=${encodeURIComponent(publish.youtubeVideoId)}`, {
|
|
1025
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
const stats = video?.items?.[0]?.statistics || {};
|
|
1029
|
+
const snippet = video?.items?.[0]?.snippet || {};
|
|
1030
|
+
|
|
1031
|
+
return {
|
|
1032
|
+
label: 'YouTube',
|
|
1033
|
+
description: 'Estatísticas coletadas via YouTube Data API.',
|
|
1034
|
+
status: 'published',
|
|
1035
|
+
values: [
|
|
1036
|
+
{ label: 'Video ID', value: publish.youtubeVideoId, unit: 'snapshot' },
|
|
1037
|
+
{ label: 'Views', value: stats.viewCount ?? 'N/A', unit: 'api snapshot' },
|
|
1038
|
+
{ label: 'Likes', value: stats.likeCount ?? 'N/A', unit: 'api snapshot' },
|
|
1039
|
+
{ label: 'Comments', value: stats.commentCount ?? 'N/A', unit: 'api snapshot' },
|
|
1040
|
+
{ label: 'Published', value: snippet.publishedAt ?? publish.publishedAt ?? 'N/A', unit: 'timestamp' },
|
|
1041
|
+
],
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function buildMetricsNote(metrics, runContext) {
|
|
1046
|
+
if (!metrics.length) {
|
|
1047
|
+
const legacyNote = runContext.isLegacyOutputRoot ? ' Esta run foi consolidada a partir de uma pasta de output legada.' : '';
|
|
1048
|
+
return `Nenhuma métrica online pôde ser coletada automaticamente. O dashboard ainda preserva os IDs, URLs e o snapshot operacional da run.${legacyNote} Para refresh real, sirva esta pasta com o servidor local do dashboard.`;
|
|
1049
|
+
}
|
|
1050
|
+
return 'O botão Refresh Data tenta primeiro regenerar o snapshot através do servidor local do dashboard. Se esse endpoint não existir, ele apenas relê o JSON atual do mesmo diretório.';
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
async function fetchJson(url, options = {}) {
|
|
1054
|
+
try {
|
|
1055
|
+
const response = await fetch(url, options);
|
|
1056
|
+
const data = await response.json();
|
|
1057
|
+
if (!response.ok || data?.error) return null;
|
|
1058
|
+
return data;
|
|
1059
|
+
} catch {
|
|
1060
|
+
return null;
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
async function loadChannelConfig(workspaceRoot, squadName) {
|
|
1065
|
+
const configPath = join(workspaceRoot, 'squads', squadName, 'pipeline', 'data', 'channel-config.yaml');
|
|
1066
|
+
if (!existsSync(configPath)) return null;
|
|
1067
|
+
const content = await readFile(configPath, 'utf-8');
|
|
1068
|
+
return parseChannelConfig(content);
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function parseChannelConfig(content) {
|
|
1072
|
+
return {
|
|
1073
|
+
publishing: {
|
|
1074
|
+
instagram: {
|
|
1075
|
+
env_aliases: {
|
|
1076
|
+
access_token: matchIndentedValue(content, ['publishing', 'instagram', 'env_aliases'], 'access_token'),
|
|
1077
|
+
user_id: matchIndentedValue(content, ['publishing', 'instagram', 'env_aliases'], 'user_id'),
|
|
1078
|
+
},
|
|
1079
|
+
},
|
|
1080
|
+
facebook: {
|
|
1081
|
+
env_aliases: {
|
|
1082
|
+
page_id: matchIndentedValue(content, ['publishing', 'facebook', 'env_aliases'], 'page_id'),
|
|
1083
|
+
page_access_token: matchIndentedValue(content, ['publishing', 'facebook', 'env_aliases'], 'page_access_token'),
|
|
1084
|
+
},
|
|
1085
|
+
},
|
|
1086
|
+
youtube: {
|
|
1087
|
+
env_aliases: {
|
|
1088
|
+
client_id: matchIndentedValue(content, ['publishing', 'youtube', 'env_aliases'], 'client_id'),
|
|
1089
|
+
client_secret: matchIndentedValue(content, ['publishing', 'youtube', 'env_aliases'], 'client_secret'),
|
|
1090
|
+
refresh_token: matchIndentedValue(content, ['publishing', 'youtube', 'env_aliases'], 'refresh_token'),
|
|
1091
|
+
channel_id: matchIndentedValue(content, ['publishing', 'youtube', 'env_aliases'], 'channel_id'),
|
|
1092
|
+
},
|
|
1093
|
+
},
|
|
1094
|
+
},
|
|
1095
|
+
dashboard: {
|
|
1096
|
+
static_publish: {
|
|
1097
|
+
enabled: matchIndentedValue(content, ['dashboard', 'static_publish'], 'enabled'),
|
|
1098
|
+
transport: matchIndentedValue(content, ['dashboard', 'static_publish'], 'transport'),
|
|
1099
|
+
public_base_url: matchIndentedValue(content, ['dashboard', 'static_publish'], 'public_base_url'),
|
|
1100
|
+
publish_dir_env: matchIndentedValue(content, ['dashboard', 'static_publish'], 'publish_dir_env'),
|
|
1101
|
+
latest_file_name: matchIndentedValue(content, ['dashboard', 'static_publish'], 'latest_file_name'),
|
|
1102
|
+
ftp: {
|
|
1103
|
+
host_env: matchIndentedValue(content, ['dashboard', 'static_publish', 'ftp'], 'host_env'),
|
|
1104
|
+
port_env: matchIndentedValue(content, ['dashboard', 'static_publish', 'ftp'], 'port_env'),
|
|
1105
|
+
user_env: matchIndentedValue(content, ['dashboard', 'static_publish', 'ftp'], 'user_env'),
|
|
1106
|
+
pass_env: matchIndentedValue(content, ['dashboard', 'static_publish', 'ftp'], 'pass_env'),
|
|
1107
|
+
},
|
|
1108
|
+
},
|
|
1109
|
+
},
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
function matchIndentedValue(content, pathSegments, key) {
|
|
1114
|
+
const block = extractIndentedBlock(content, pathSegments);
|
|
1115
|
+
if (!block) return null;
|
|
1116
|
+
const match = block.match(new RegExp(`^\\s*${key}:\\s*([^\\r\\n]+)`, 'm'));
|
|
1117
|
+
return match ? match[1].trim().replace(/^['"]|['"]$/g, '') : null;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
function extractIndentedBlock(content, pathSegments) {
|
|
1121
|
+
const lines = content.split(/\r?\n/);
|
|
1122
|
+
let searchFrom = 0;
|
|
1123
|
+
let currentIndent = -1;
|
|
1124
|
+
|
|
1125
|
+
for (const segment of pathSegments) {
|
|
1126
|
+
let found = false;
|
|
1127
|
+
for (let index = searchFrom; index < lines.length; index += 1) {
|
|
1128
|
+
const line = lines[index];
|
|
1129
|
+
const match = line.match(/^(\s*)([^:#]+):/);
|
|
1130
|
+
if (!match) continue;
|
|
1131
|
+
const indent = match[1].length;
|
|
1132
|
+
const key = match[2].trim();
|
|
1133
|
+
if (indent <= currentIndent) continue;
|
|
1134
|
+
if (key === segment) {
|
|
1135
|
+
currentIndent = indent;
|
|
1136
|
+
searchFrom = index + 1;
|
|
1137
|
+
found = true;
|
|
1138
|
+
break;
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
if (!found) return null;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
const blockLines = [];
|
|
1145
|
+
for (let index = searchFrom; index < lines.length; index += 1) {
|
|
1146
|
+
const line = lines[index];
|
|
1147
|
+
if (!line.trim()) {
|
|
1148
|
+
blockLines.push(line);
|
|
1149
|
+
continue;
|
|
1150
|
+
}
|
|
1151
|
+
const indent = line.match(/^(\s*)/)?.[1].length ?? 0;
|
|
1152
|
+
if (indent <= currentIndent) break;
|
|
1153
|
+
blockLines.push(line.slice(currentIndent + 2));
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
return blockLines.join('\n');
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
async function loadEnvVars(workspaceRoot) {
|
|
1160
|
+
const envVars = { ...process.env };
|
|
1161
|
+
const envPath = join(workspaceRoot, '.env');
|
|
1162
|
+
|
|
1163
|
+
if (!existsSync(envPath)) return envVars;
|
|
1164
|
+
|
|
1165
|
+
const raw = await readFile(envPath, 'utf-8');
|
|
1166
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
1167
|
+
const trimmed = line.trim();
|
|
1168
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
1169
|
+
const separatorIndex = trimmed.indexOf('=');
|
|
1170
|
+
if (separatorIndex === -1) continue;
|
|
1171
|
+
const key = trimmed.slice(0, separatorIndex).trim();
|
|
1172
|
+
const value = trimmed.slice(separatorIndex + 1).trim().replace(/^['"]|['"]$/g, '');
|
|
1173
|
+
if (key && !(key in envVars)) envVars[key] = value;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
return envVars;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
function escapeScriptJson(value) {
|
|
1180
|
+
return value.replace(/<\//g, '<\\/');
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
async function rewritePayloadForStaticPublish({ payload, runDir, publishDir, variantName }) {
|
|
1184
|
+
const publishedPayload = JSON.parse(JSON.stringify(payload || {}));
|
|
1185
|
+
|
|
1186
|
+
if (Array.isArray(publishedPayload.artifacts)) {
|
|
1187
|
+
for (const artifact of publishedPayload.artifacts) {
|
|
1188
|
+
if (artifact.kind === 'folder' || artifact.href === './') {
|
|
1189
|
+
artifact.href = null;
|
|
1190
|
+
artifact.path = `assets/${variantName}/`;
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
const publishedRef = await copyLocalRunReference({
|
|
1195
|
+
runDir,
|
|
1196
|
+
publishDir,
|
|
1197
|
+
variantName,
|
|
1198
|
+
href: artifact.href,
|
|
1199
|
+
absolutePath: artifact.path,
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
if (publishedRef) {
|
|
1203
|
+
artifact.href = publishedRef.href;
|
|
1204
|
+
artifact.path = publishedRef.href;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
if (Array.isArray(publishedPayload.previews)) {
|
|
1210
|
+
for (const preview of publishedPayload.previews) {
|
|
1211
|
+
const publishedSrc = await copyLocalRunReference({
|
|
1212
|
+
runDir,
|
|
1213
|
+
publishDir,
|
|
1214
|
+
variantName,
|
|
1215
|
+
href: preview.src,
|
|
1216
|
+
});
|
|
1217
|
+
if (publishedSrc) preview.src = publishedSrc.href;
|
|
1218
|
+
|
|
1219
|
+
const publishedHref = await copyLocalRunReference({
|
|
1220
|
+
runDir,
|
|
1221
|
+
publishDir,
|
|
1222
|
+
variantName,
|
|
1223
|
+
href: preview.href,
|
|
1224
|
+
});
|
|
1225
|
+
if (publishedHref) preview.href = publishedHref.href;
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
if (Array.isArray(publishedPayload.channels)) {
|
|
1230
|
+
for (const channel of publishedPayload.channels) {
|
|
1231
|
+
if (!Array.isArray(channel.links)) continue;
|
|
1232
|
+
for (const link of channel.links) {
|
|
1233
|
+
const publishedLink = await copyLocalRunReference({
|
|
1234
|
+
runDir,
|
|
1235
|
+
publishDir,
|
|
1236
|
+
variantName,
|
|
1237
|
+
href: link.href,
|
|
1238
|
+
});
|
|
1239
|
+
if (publishedLink) link.href = publishedLink.href;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
return publishedPayload;
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
async function copyLocalRunReference({ runDir, publishDir, variantName, href, absolutePath }) {
|
|
1248
|
+
if (!href || isExternalReference(href)) return null;
|
|
1249
|
+
|
|
1250
|
+
const sourcePath = resolveLocalRunReference({ runDir, href, absolutePath });
|
|
1251
|
+
if (!sourcePath || !existsSync(sourcePath)) return null;
|
|
1252
|
+
|
|
1253
|
+
const relativeSourcePath = relative(runDir, sourcePath).replace(/\\/g, '/');
|
|
1254
|
+
if (!relativeSourcePath || relativeSourcePath.startsWith('..')) return null;
|
|
1255
|
+
|
|
1256
|
+
const targetPath = join(publishDir, 'assets', variantName, ...relativeSourcePath.split('/'));
|
|
1257
|
+
await mkdir(dirname(targetPath), { recursive: true });
|
|
1258
|
+
await copyFile(sourcePath, targetPath);
|
|
1259
|
+
|
|
1260
|
+
return {
|
|
1261
|
+
href: `./assets/${variantName}/${relativeSourcePath}`,
|
|
1262
|
+
sourcePath,
|
|
1263
|
+
targetPath,
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function resolveLocalRunReference({ runDir, href, absolutePath }) {
|
|
1268
|
+
if (absolutePath && typeof absolutePath === 'string' && existsSync(absolutePath)) {
|
|
1269
|
+
return absolutePath;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
if (!href || href === './' || isExternalReference(href)) return null;
|
|
1273
|
+
return resolve(runDir, href);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function isExternalReference(value) {
|
|
1277
|
+
return /^(?:[a-z]+:)?\/\//i.test(String(value || '')) || /^(mailto:|tel:)/i.test(String(value || ''));
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
function normalizePublicBaseUrl(value) {
|
|
1281
|
+
const normalized = String(value || '').trim();
|
|
1282
|
+
if (!normalized) return '';
|
|
1283
|
+
return normalized.endsWith('/') ? normalized : `${normalized}/`;
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
function sanitizeFileName(value) {
|
|
1287
|
+
return String(value || 'index.html').replace(/[\\/:*?"<>|]+/g, '-');
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const isMain = process.argv[1] === fileURLToPath(import.meta.url);
|
|
1291
|
+
if (isMain) {
|
|
1292
|
+
main().catch((error) => {
|
|
1293
|
+
console.error(`❌ ${error.message}`);
|
|
1294
|
+
process.exit(1);
|
|
1295
|
+
});
|
|
1296
|
+
}
|