mirror of
https://github.com/jacobinwwey/obsidian-NotEMD.git
synced 2026-07-22 05:48:27 +00:00
721 lines
25 KiB
JavaScript
721 lines
25 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Maintainer verification for the real NoteMD Slidev export workflow.
|
|
*
|
|
* This script exercises the production source-preparation and Slidev export
|
|
* modules without starting Obsidian. It intentionally writes the same vault
|
|
* artifacts the UI writes so maintainers can inspect the generated deck and
|
|
* standalone HTML after the command completes.
|
|
*/
|
|
|
|
const childProcess = require('child_process');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const esbuild = require('esbuild');
|
|
|
|
const LAYOUT_AUDIT_CONFIG = {
|
|
minReadableScale: 0.24,
|
|
maxAutoPatchPasses: 6,
|
|
};
|
|
|
|
function parseArgs(argv) {
|
|
const args = {
|
|
vault: 'docs',
|
|
source: 'architecture.zh-CN.md',
|
|
format: 'html',
|
|
htmlMode: 'standalone',
|
|
outputSubfolder: 'export',
|
|
theme: 'default',
|
|
timeoutMs: 180000,
|
|
requireNativeStandalone: false,
|
|
playwright: true,
|
|
screenshots: true,
|
|
sampleSlides: null,
|
|
json: false,
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index++) {
|
|
const arg = argv[index];
|
|
if (arg === '--vault' && argv[index + 1]) {
|
|
args.vault = argv[++index];
|
|
} else if (arg === '--source' && argv[index + 1]) {
|
|
args.source = argv[++index];
|
|
} else if (arg === '--format' && argv[index + 1]) {
|
|
args.format = argv[++index];
|
|
} else if (arg === '--html-mode' && argv[index + 1]) {
|
|
args.htmlMode = argv[++index];
|
|
} else if (arg === '--output-subfolder' && argv[index + 1]) {
|
|
args.outputSubfolder = argv[++index];
|
|
} else if (arg === '--theme' && argv[index + 1]) {
|
|
args.theme = argv[++index];
|
|
} else if (arg === '--timeout-ms' && argv[index + 1]) {
|
|
args.timeoutMs = Number(argv[++index]);
|
|
} else if (arg === '--require-native-standalone') {
|
|
args.requireNativeStandalone = true;
|
|
} else if (arg === '--sample-slides' && argv[index + 1]) {
|
|
const rawValue = argv[++index].trim().toLowerCase();
|
|
args.sampleSlides = rawValue === 'all'
|
|
? []
|
|
: rawValue.split(',').map(value => Number(value.trim())).filter(Number.isFinite);
|
|
} else if (arg === '--no-playwright') {
|
|
args.playwright = false;
|
|
} else if (arg === '--no-screenshots') {
|
|
args.screenshots = false;
|
|
} else if (arg === '--json') {
|
|
args.json = true;
|
|
} else if (arg === '--help' || arg === '-h') {
|
|
printHelp();
|
|
process.exit(0);
|
|
} else {
|
|
throw new Error(`Unknown argument: ${arg}`);
|
|
}
|
|
}
|
|
|
|
if (!['html', 'pdf', 'png', 'mp4'].includes(args.format)) {
|
|
throw new Error(`Unsupported --format ${args.format}`);
|
|
}
|
|
if (!['standalone', 'server-script'].includes(args.htmlMode)) {
|
|
throw new Error(`Unsupported --html-mode ${args.htmlMode}`);
|
|
}
|
|
if (!Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0) {
|
|
throw new Error('--timeout-ms must be a positive number');
|
|
}
|
|
if (args.requireNativeStandalone && (args.format !== 'html' || args.htmlMode !== 'standalone')) {
|
|
throw new Error('--require-native-standalone requires --format html --html-mode standalone');
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log([
|
|
'Usage: node scripts/verify-slidev-export-workflow.cjs [options]',
|
|
'',
|
|
'Options:',
|
|
' --vault <path> Vault root, default: docs',
|
|
' --source <path> Vault-relative source Markdown, default: architecture.zh-CN.md',
|
|
' --format <html|pdf|png|mp4> Export format, default: html',
|
|
' --html-mode <standalone|server-script> HTML mode, default: standalone',
|
|
' --output-subfolder <path> Vault-relative output folder, default: export',
|
|
' --theme <name> Slidev theme, default: default',
|
|
' --require-native-standalone Fail if HTML export falls back from native standalone',
|
|
' --sample-slides <list|all> Comma-separated slide numbers for Playwright, default: all slides',
|
|
' --no-playwright Skip browser rendering checks',
|
|
' --no-screenshots Do not write Playwright screenshots',
|
|
' --json Print only the final JSON report',
|
|
].join('\n'));
|
|
}
|
|
|
|
async function bundleSlideExportModules() {
|
|
const result = await esbuild.build({
|
|
stdin: {
|
|
contents: [
|
|
"export { probeEnvironment } from './src/slideExport/environmentProber';",
|
|
"export { prepareSlidevExportSource } from './src/slideExport/slidevSourcePreparer';",
|
|
"export { exportSlidevHtml, exportSlidevHtmlWithOutcome, exportSlidevPdf, exportSlidevPng } from './src/slideExport/slidevExporter';",
|
|
"export { convergeSlidevDeckLayout } from './src/slideExport/slidevLayoutWorkflow';",
|
|
"export { exportVideoMp4 } from './src/slideExport/videoExporter';",
|
|
"export { analyzeRenderedSlideMeasurement, summarizeLayoutAudits, patchDeckWithLayoutAudit, countSlideDeckSlides } from './src/slideExport/slidevLayoutAudit';",
|
|
"export { resolveWorkspaceHomeCandidates } from './src/slideExport/platformUtils';",
|
|
"export { startLocalServer, stopLocalServer } from './src/slideExport/localServer';",
|
|
].join('\n'),
|
|
resolveDir: process.cwd(),
|
|
sourcefile: 'notemd-slidev-workflow-entry.ts',
|
|
loader: 'ts',
|
|
},
|
|
bundle: true,
|
|
platform: 'node',
|
|
format: 'cjs',
|
|
write: false,
|
|
logLevel: 'silent',
|
|
plugins: [obsidianShimPlugin()],
|
|
});
|
|
|
|
const code = result.outputFiles[0].text;
|
|
const bundledModule = { exports: {} };
|
|
const evaluate = new Function('require', 'module', 'exports', code);
|
|
evaluate(require, bundledModule, bundledModule.exports);
|
|
return bundledModule.exports;
|
|
}
|
|
|
|
function obsidianShimPlugin() {
|
|
return {
|
|
name: 'notemd-obsidian-shim',
|
|
setup(build) {
|
|
build.onResolve({ filter: /^obsidian$/ }, () => ({
|
|
path: 'obsidian-shim',
|
|
namespace: 'notemd-obsidian-shim',
|
|
}));
|
|
build.onLoad({ filter: /.*/, namespace: 'notemd-obsidian-shim' }, () => ({
|
|
loader: 'js',
|
|
contents: [
|
|
'export const Platform = { isDesktopApp: true };',
|
|
'export class Notice { constructor(message) { this.message = message; } }',
|
|
'export class Modal { constructor(app) { this.app = app; this.contentEl = { empty() {}, createEl() { return this; }, createDiv() { return this; }, appendChild() {}, addClass() {}, setText() {} }; } open() {} close() {} }',
|
|
'export class Setting { constructor() {} setName() { return this; } setDesc() { return this; } addButton() { return this; } addText() { return this; } addDropdown() { return this; } addToggle() { return this; } }',
|
|
'export class Plugin {}',
|
|
'export class PluginSettingTab {}',
|
|
'export class ItemView {}',
|
|
'export class TFile {}',
|
|
'export class TFolder {}',
|
|
'export class MarkdownView {}',
|
|
'export class WorkspaceLeaf {}',
|
|
'export class ButtonComponent {}',
|
|
'export class TextAreaComponent {}',
|
|
'export const requestUrl = async () => { throw new Error("requestUrl unavailable in Slidev export workflow verification"); };',
|
|
'export const getLanguage = () => "en";',
|
|
'export const loadPdfJs = async () => null;',
|
|
].join('\n'),
|
|
}));
|
|
},
|
|
};
|
|
}
|
|
|
|
function createApp(vaultRoot) {
|
|
const adapter = {
|
|
basePath: vaultRoot,
|
|
getBasePath: () => vaultRoot,
|
|
exists: async vaultPath => fs.existsSync(path.join(vaultRoot, vaultPath)),
|
|
mkdir: async vaultPath => fs.mkdirSync(path.join(vaultRoot, vaultPath), { recursive: true }),
|
|
read: async vaultPath => fs.readFileSync(path.join(vaultRoot, vaultPath), 'utf8'),
|
|
list: async vaultPath => {
|
|
const absolutePath = path.join(vaultRoot, vaultPath);
|
|
const entries = fs.existsSync(absolutePath) ? fs.readdirSync(absolutePath, { withFileTypes: true }) : [];
|
|
return {
|
|
files: entries.filter(entry => entry.isFile()).map(entry => path.join(vaultPath, entry.name).replace(/\\/g, '/')),
|
|
folders: entries.filter(entry => entry.isDirectory()).map(entry => path.join(vaultPath, entry.name).replace(/\\/g, '/')),
|
|
};
|
|
},
|
|
stat: async vaultPath => {
|
|
const absolutePath = path.join(vaultRoot, vaultPath);
|
|
if (!fs.existsSync(absolutePath)) {
|
|
return null;
|
|
}
|
|
return fs.statSync(absolutePath);
|
|
},
|
|
write: async (vaultPath, content) => {
|
|
const absolutePath = path.join(vaultRoot, vaultPath);
|
|
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
|
|
fs.writeFileSync(absolutePath, content, 'utf8');
|
|
},
|
|
};
|
|
|
|
return {
|
|
vault: {
|
|
adapter,
|
|
read: async file => fs.readFileSync(path.join(vaultRoot, file.path), 'utf8'),
|
|
},
|
|
};
|
|
}
|
|
|
|
function createSourceFile(vaultRoot, sourcePath) {
|
|
const normalizedSource = sourcePath.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
const absolutePath = path.join(vaultRoot, normalizedSource);
|
|
if (!fs.existsSync(absolutePath)) {
|
|
throw new Error(`Source file does not exist: ${absolutePath}`);
|
|
}
|
|
|
|
const extension = path.extname(normalizedSource);
|
|
return {
|
|
path: normalizedSource,
|
|
name: path.basename(normalizedSource),
|
|
basename: path.basename(normalizedSource, extension),
|
|
extension: extension.replace(/^\./, ''),
|
|
};
|
|
}
|
|
|
|
function createConfig(args) {
|
|
return {
|
|
format: args.format,
|
|
withClicks: false,
|
|
outputSubfolder: args.outputSubfolder,
|
|
ffmpegFps: 1,
|
|
ffmpegCrf: 23,
|
|
slidevTheme: args.theme,
|
|
timeoutMs: args.timeoutMs,
|
|
htmlMode: args.htmlMode,
|
|
};
|
|
}
|
|
|
|
function collectDeckSummary(deckPath) {
|
|
if (!deckPath || !fs.existsSync(deckPath)) {
|
|
return null;
|
|
}
|
|
const deck = fs.readFileSync(deckPath, 'utf8');
|
|
return {
|
|
path: deckPath,
|
|
bytes: fs.statSync(deckPath).size,
|
|
theme: (deck.match(/^theme:\s*(.+)$/m) || [])[1] || null,
|
|
mermaidBlocks: (deck.match(/```mermaid/g) || []).length,
|
|
zoomLines: [...deck.matchAll(/^zoom:\s*(.+)$/gm)].map(match => match[1]),
|
|
containsKnownStaleText: deck.includes('快速定位'),
|
|
containsMissingTheme: deck.includes('seriph'),
|
|
};
|
|
}
|
|
|
|
async function collectRenderedSlideMeasurement(page, slide) {
|
|
return page.evaluate(slotZoneAttr => {
|
|
const toRect = rect => ({
|
|
left: rect.left,
|
|
top: rect.top,
|
|
right: rect.right,
|
|
bottom: rect.bottom,
|
|
width: rect.width,
|
|
height: rect.height,
|
|
});
|
|
const toTextPreview = value => value
|
|
.replace(/\s+/g, ' ')
|
|
.trim()
|
|
.slice(0, 160);
|
|
const overlaps = (a, b) => !(a.right <= b.left || a.left >= b.right || a.bottom <= b.top || a.top >= b.bottom);
|
|
const unionRects = rects => rects.reduce((acc, rect) => {
|
|
if (!acc) {
|
|
return { ...rect };
|
|
}
|
|
return {
|
|
left: Math.min(acc.left, rect.left),
|
|
top: Math.min(acc.top, rect.top),
|
|
right: Math.max(acc.right, rect.right),
|
|
bottom: Math.max(acc.bottom, rect.bottom),
|
|
width: 0,
|
|
height: 0,
|
|
};
|
|
}, null);
|
|
const pickVisibleLargest = selector => Array.from(document.querySelectorAll(selector))
|
|
.filter(element => element instanceof Element)
|
|
.map(element => ({ element, rect: element.getBoundingClientRect(), style: window.getComputedStyle(element) }))
|
|
.filter(entry => entry.style.display !== 'none' && entry.style.visibility !== 'hidden' && Number(entry.style.opacity || '1') > 0 && entry.rect.width > 2 && entry.rect.height > 2)
|
|
.sort((left, right) => (right.rect.width * right.rect.height) - (left.rect.width * left.rect.height))[0]?.element ?? null;
|
|
|
|
const slideRoot = pickVisibleLargest('.slidev-page')
|
|
|| pickVisibleLargest('.slidev-layout')
|
|
|| pickVisibleLargest('.slidev-slide-content')
|
|
|| document.querySelector('#app');
|
|
if (!slideRoot) {
|
|
return {
|
|
slide: null,
|
|
slideRoot: null,
|
|
safeRect: null,
|
|
contentBounds: null,
|
|
pageScale: null,
|
|
elements: [],
|
|
slotZones: [],
|
|
errors: ['Slide root not found'],
|
|
};
|
|
}
|
|
|
|
const slideRootRect = toRect(slideRoot.getBoundingClientRect());
|
|
const safeInsetX = slideRootRect.width * 0.04;
|
|
const safeInsetY = slideRootRect.height * 0.06;
|
|
const safeRect = {
|
|
left: slideRootRect.left + safeInsetX,
|
|
top: slideRootRect.top + safeInsetY,
|
|
right: slideRootRect.right - safeInsetX,
|
|
bottom: slideRootRect.bottom - safeInsetY,
|
|
width: slideRootRect.width - safeInsetX * 2,
|
|
height: slideRootRect.height - safeInsetY * 2,
|
|
};
|
|
|
|
const selectors = [
|
|
['mermaid', '.mermaid, [class*="mermaid"]'],
|
|
['table', 'table'],
|
|
['code', 'pre, .shiki'],
|
|
['image', 'img, svg'],
|
|
['text', 'h1, h2, h3, h4, p, li, blockquote'],
|
|
['other', 'div, section, article, aside, span'],
|
|
];
|
|
const seen = new Set();
|
|
const measuredElements = [];
|
|
|
|
for (const [kind, selector] of selectors) {
|
|
for (const element of slideRoot.querySelectorAll(selector)) {
|
|
if (!(element instanceof Element) || seen.has(element)) {
|
|
continue;
|
|
}
|
|
seen.add(element);
|
|
|
|
const style = window.getComputedStyle(element);
|
|
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') === 0) {
|
|
continue;
|
|
}
|
|
|
|
const rect = toRect(element.getBoundingClientRect());
|
|
const ownerElement = element.closest(`[${slotZoneAttr}]`);
|
|
if (rect.width < 2 || rect.height < 2 || (!ownerElement && !overlaps(rect, slideRootRect))) {
|
|
continue;
|
|
}
|
|
|
|
const textLength = (element.textContent || '').trim().length;
|
|
if (kind === 'other') {
|
|
const directTextLength = Array.from(element.childNodes)
|
|
.filter(node => node.nodeType === Node.TEXT_NODE)
|
|
.map(node => node.textContent || '')
|
|
.join(' ')
|
|
.trim()
|
|
.length;
|
|
if (directTextLength === 0 || textLength === 0) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
measuredElements.push({
|
|
ownerElement,
|
|
measured: {
|
|
kind,
|
|
selector,
|
|
slotZone: ownerElement?.getAttribute(slotZoneAttr) || undefined,
|
|
slotOwner: element.hasAttribute(slotZoneAttr),
|
|
textLength,
|
|
textPreview: textLength > 0 ? toTextPreview(element.textContent || '') : undefined,
|
|
scrollWidth: element.scrollWidth || rect.width,
|
|
scrollHeight: element.scrollHeight || rect.height,
|
|
clientWidth: element.clientWidth || rect.width,
|
|
clientHeight: element.clientHeight || rect.height,
|
|
rect,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
const elements = measuredElements.map(entry => entry.measured);
|
|
const slotZones = Array.from(slideRoot.querySelectorAll(`[${slotZoneAttr}]`))
|
|
.filter(element => element instanceof Element)
|
|
.map(ownerElement => {
|
|
const ownerRect = toRect(ownerElement.getBoundingClientRect());
|
|
if (ownerRect.width < 2 || ownerRect.height < 2 || !overlaps(ownerRect, slideRootRect)) {
|
|
return null;
|
|
}
|
|
|
|
const style = window.getComputedStyle(ownerElement);
|
|
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity || '1') === 0) {
|
|
return null;
|
|
}
|
|
|
|
const zoneName = ownerElement.getAttribute(slotZoneAttr);
|
|
if (!zoneName) {
|
|
return null;
|
|
}
|
|
|
|
const zoneElementRects = measuredElements
|
|
.filter(entry => entry.ownerElement === ownerElement && !entry.measured.slotOwner)
|
|
.map(entry => entry.measured.rect);
|
|
const contentBounds = unionRects(zoneElementRects.length > 0 ? zoneElementRects : [ownerRect]);
|
|
if (contentBounds) {
|
|
contentBounds.width = contentBounds.right - contentBounds.left;
|
|
contentBounds.height = contentBounds.bottom - contentBounds.top;
|
|
}
|
|
|
|
const textValue = (ownerElement.textContent || '').trim();
|
|
return {
|
|
name: zoneName,
|
|
textLength: textValue.length,
|
|
textPreview: textValue.length > 0 ? toTextPreview(textValue) : undefined,
|
|
ownerRect,
|
|
contentBounds,
|
|
scrollWidth: ownerElement.scrollWidth || ownerRect.width,
|
|
scrollHeight: ownerElement.scrollHeight || ownerRect.height,
|
|
clientWidth: ownerElement.clientWidth || ownerRect.width,
|
|
clientHeight: ownerElement.clientHeight || ownerRect.height,
|
|
};
|
|
})
|
|
.filter(Boolean);
|
|
|
|
const contentBounds = unionRects(elements.map(element => element.rect));
|
|
if (contentBounds) {
|
|
contentBounds.width = contentBounds.right - contentBounds.left;
|
|
contentBounds.height = contentBounds.bottom - contentBounds.top;
|
|
}
|
|
|
|
const zoomRaw = window.getComputedStyle(slideRoot).getPropertyValue('--slidev-slide-zoom-scale').trim()
|
|
|| window.getComputedStyle(slideRoot).scale
|
|
|| '1';
|
|
const pageScale = Number.parseFloat(zoomRaw);
|
|
|
|
return {
|
|
slideRoot: slideRootRect,
|
|
safeRect,
|
|
contentBounds,
|
|
pageScale: Number.isFinite(pageScale) ? pageScale : null,
|
|
elements,
|
|
slotZones,
|
|
errors: [],
|
|
};
|
|
}, 'data-notemd-slot-zone');
|
|
}
|
|
|
|
function configurePlaywrightBrowserPath(slideExport) {
|
|
if (process.env.PLAYWRIGHT_BROWSERS_PATH) {
|
|
return;
|
|
}
|
|
|
|
for (const home of slideExport.resolveWorkspaceHomeCandidates()) {
|
|
const candidate = path.join(home, '.cache', 'ms-playwright');
|
|
try {
|
|
if (fs.existsSync(candidate)) {
|
|
process.env.PLAYWRIGHT_BROWSERS_PATH = candidate;
|
|
return;
|
|
}
|
|
} catch {
|
|
// Try the next candidate.
|
|
}
|
|
}
|
|
}
|
|
|
|
async function runPlaywrightChecks(htmlPath, sampleSlides, writeScreenshots, slideExport) {
|
|
configurePlaywrightBrowserPath(slideExport);
|
|
const { chromium } = require('playwright');
|
|
const browser = await chromium.launch({ headless: true });
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 720 } });
|
|
const checks = [];
|
|
const layoutAudits = [];
|
|
let serverDirectory = null;
|
|
let baseUrl = null;
|
|
|
|
try {
|
|
if (htmlPath.endsWith('/index.html')) {
|
|
serverDirectory = path.dirname(htmlPath);
|
|
const port = await slideExport.startLocalServer(serverDirectory);
|
|
baseUrl = `http://localhost:${port}/index.html`;
|
|
}
|
|
|
|
for (const slide of sampleSlides) {
|
|
const errors = [];
|
|
page.removeAllListeners('pageerror');
|
|
page.removeAllListeners('console');
|
|
const keepError = text => !/Wake Lock permission request denied/i.test(text);
|
|
page.on('pageerror', error => {
|
|
if (keepError(error.message)) errors.push(error.message);
|
|
});
|
|
page.on('console', message => {
|
|
if (message.type() === 'error' && keepError(message.text())) errors.push(message.text());
|
|
});
|
|
|
|
const targetUrl = baseUrl ? `${baseUrl}#/${slide}` : `file://${htmlPath}#/${slide}`;
|
|
await openSlideForAudit(page, targetUrl);
|
|
await page.waitForTimeout(1000);
|
|
const text = await page.locator('body').innerText({ timeout: 5000 }).catch(() => '');
|
|
const measurement = await collectRenderedSlideMeasurement(page, slide);
|
|
measurement.slide = slide;
|
|
const layoutAudit = slideExport.analyzeRenderedSlideMeasurement(measurement, LAYOUT_AUDIT_CONFIG);
|
|
let screenshotPath = null;
|
|
if (writeScreenshots) {
|
|
screenshotPath = path.join(path.dirname(htmlPath), `slide-${String(slide).padStart(2, '0')}-workflow.png`);
|
|
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
}
|
|
checks.push({
|
|
slide,
|
|
failed: errors.length > 0 || !text.includes(`${slide} /`),
|
|
errors,
|
|
screenshotPath,
|
|
preview: text.split('\n').filter(Boolean).slice(0, 4).join(' | '),
|
|
layoutFindingCount: layoutAudit.findings.length,
|
|
});
|
|
layoutAudits.push(layoutAudit);
|
|
}
|
|
} finally {
|
|
await browser.close();
|
|
if (serverDirectory) {
|
|
slideExport.stopLocalServer(serverDirectory);
|
|
}
|
|
}
|
|
|
|
return { checks, layoutAudits };
|
|
}
|
|
|
|
async function openSlideForAudit(page, targetUrl) {
|
|
await page.goto(targetUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
|
if (typeof page.waitForLoadState === 'function') {
|
|
await page.waitForLoadState('networkidle', { timeout: 10000 }).catch(() => undefined);
|
|
}
|
|
if (typeof page.waitForFunction === 'function') {
|
|
await page.waitForFunction(
|
|
() => {
|
|
const root = document.querySelector('.slidev-page, .slidev-layout, .slidev-slide-content, #app');
|
|
return Boolean(root && (root.textContent || '').trim().length > 0);
|
|
},
|
|
null,
|
|
{ timeout: 15000 }
|
|
).catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
function resolveSlidesToAudit(sampleSlides, deckMarkdown, slideExport) {
|
|
const deckSlideCount = deckMarkdown ? slideExport.countSlideDeckSlides(deckMarkdown) : 0;
|
|
const allSlides = Array.from({ length: Math.max(deckSlideCount, 1) }, (_, index) => index + 1);
|
|
if (!Array.isArray(sampleSlides) || sampleSlides.length === 0) {
|
|
return allSlides;
|
|
}
|
|
|
|
const filteredSlides = sampleSlides.filter(slide => Number.isFinite(slide) && slide >= 1 && slide <= deckSlideCount);
|
|
return filteredSlides.length > 0 ? filteredSlides : allSlides;
|
|
}
|
|
|
|
function checkIgnored(pathsToCheck) {
|
|
const existingPaths = pathsToCheck.filter(Boolean).filter(filePath => fs.existsSync(filePath));
|
|
if (existingPaths.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
const relativePaths = existingPaths.map(filePath => path.relative(process.cwd(), filePath));
|
|
const result = childProcess.spawnSync('git', ['check-ignore', '-v', ...relativePaths], {
|
|
cwd: process.cwd(),
|
|
encoding: 'utf8',
|
|
});
|
|
|
|
return result.stdout
|
|
.split(/\r?\n/)
|
|
.map(line => line.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function printProgress(enabled, phase, detail) {
|
|
if (!enabled) return;
|
|
console.log(detail ? `${phase}: ${detail}` : phase);
|
|
}
|
|
|
|
async function main() {
|
|
const args = parseArgs(process.argv.slice(2));
|
|
const vaultRoot = path.resolve(args.vault);
|
|
const app = createApp(vaultRoot);
|
|
const sourceFile = createSourceFile(vaultRoot, args.source);
|
|
const config = createConfig(args);
|
|
const slideExport = await bundleSlideExportModules();
|
|
const progress = [];
|
|
const onProgress = (phase, detail) => {
|
|
progress.push({ phase, detail: detail || '' });
|
|
printProgress(!args.json, phase, detail);
|
|
};
|
|
|
|
printProgress(!args.json, 'slidev-workflow', `Vault: ${vaultRoot}`);
|
|
printProgress(!args.json, 'slidev-workflow', `Source: ${sourceFile.path}`);
|
|
|
|
const environment = await slideExport.probeEnvironment();
|
|
const slideSource = await slideExport.prepareSlidevExportSource(app, sourceFile, config, {}, onProgress);
|
|
let layoutConvergence = null;
|
|
if (args.playwright) {
|
|
layoutConvergence = await slideExport.convergeSlidevDeckLayout(app, slideSource, config, onProgress, {
|
|
sampleSlides: args.sampleSlides,
|
|
writeScreenshots: args.screenshots,
|
|
auditConfig: LAYOUT_AUDIT_CONFIG,
|
|
});
|
|
}
|
|
|
|
let exportPath = layoutConvergence?.exportPath;
|
|
let htmlExport = layoutConvergence?.htmlExport ?? null;
|
|
let htmlExportHistory = layoutConvergence?.htmlExportHistory ?? [];
|
|
if (args.format === 'pdf') {
|
|
exportPath = await slideExport.exportSlidevPdf(app, slideSource, config, onProgress);
|
|
} else if (args.format === 'png') {
|
|
exportPath = await slideExport.exportSlidevPng(app, slideSource, config, onProgress);
|
|
} else if (args.format === 'mp4') {
|
|
const pngDirectory = await slideExport.exportSlidevPng(app, slideSource, config, onProgress);
|
|
exportPath = await slideExport.exportVideoMp4(app, pngDirectory, slideSource.outputBasename, config, onProgress);
|
|
} else if (!exportPath) {
|
|
htmlExport = await slideExport.exportSlidevHtmlWithOutcome(app, slideSource, config, onProgress);
|
|
htmlExportHistory = [htmlExport];
|
|
exportPath = htmlExport.path;
|
|
}
|
|
|
|
let absoluteExportPath = path.join(vaultRoot, exportPath);
|
|
const absoluteDeckPath = slideSource.preparedDeckPath ? path.join(vaultRoot, slideSource.preparedDeckPath) : null;
|
|
let deckSummary = collectDeckSummary(absoluteDeckPath);
|
|
let playwrightChecks = layoutConvergence?.checks ?? [];
|
|
let layoutAudits = layoutConvergence?.layoutAudits ?? [];
|
|
let layoutAuditSummary = layoutConvergence?.layoutAuditSummary ?? slideExport.summarizeLayoutAudits([], 0);
|
|
let layoutPatchAttempts = layoutConvergence?.layoutPatchAttempts ?? [];
|
|
let auditedSlides = layoutConvergence?.auditedSlides ?? [];
|
|
|
|
const ignoredOutputs = checkIgnored([
|
|
absoluteDeckPath,
|
|
absoluteExportPath,
|
|
htmlExport?.standaloneAttempt?.preservedFailurePath
|
|
? path.join(vaultRoot, htmlExport.standaloneAttempt.preservedFailurePath)
|
|
: null,
|
|
htmlExport?.standaloneAttempt?.outputPath
|
|
? path.join(vaultRoot, htmlExport.standaloneAttempt.outputPath)
|
|
: null,
|
|
htmlExport?.fallbackPath
|
|
? path.join(vaultRoot, htmlExport.fallbackPath)
|
|
: null,
|
|
...playwrightChecks.map(check => check.screenshotPath),
|
|
]);
|
|
const hasLayoutFailures = layoutAudits.some(audit => audit.findings.length > 0);
|
|
const nativeStandalonePassed = htmlExport?.actualMode === 'standalone'
|
|
&& htmlExport?.standaloneAttempt?.accepted === true
|
|
&& fs.existsSync(path.join(vaultRoot, htmlExport.path));
|
|
const standaloneGate = {
|
|
required: args.requireNativeStandalone,
|
|
passed: !args.requireNativeStandalone || nativeStandalonePassed,
|
|
reason: args.requireNativeStandalone && !nativeStandalonePassed
|
|
? `Expected native standalone HTML but actual mode was ${htmlExport?.actualMode || 'unavailable'}`
|
|
: null,
|
|
};
|
|
|
|
const report = {
|
|
ok: playwrightChecks.every(check => !check.failed)
|
|
&& environment.capabilities[args.format] === true
|
|
&& fs.existsSync(absoluteExportPath)
|
|
&& ignoredOutputs.length === 0
|
|
&& !hasLayoutFailures
|
|
&& (!deckSummary || (!deckSummary.containsKnownStaleText && !deckSummary.containsMissingTheme))
|
|
&& standaloneGate.passed,
|
|
source: {
|
|
vaultRoot,
|
|
sourcePath: sourceFile.path,
|
|
},
|
|
environment: {
|
|
node: environment.node,
|
|
slidev: environment.slidev,
|
|
playwright: environment.playwright,
|
|
ffmpeg: environment.ffmpeg,
|
|
capabilities: environment.capabilities,
|
|
},
|
|
slideSource: {
|
|
inputFilePath: slideSource.inputFilePath,
|
|
outputBasename: slideSource.outputBasename,
|
|
preparedDeckPath: slideSource.preparedDeckPath || null,
|
|
skillRootPath: slideSource.skillRootPath || null,
|
|
skillReferenceCount: slideSource.skillReferencePaths?.length || 0,
|
|
},
|
|
output: {
|
|
format: args.format,
|
|
path: absoluteExportPath,
|
|
bytes: fs.existsSync(absoluteExportPath) && fs.statSync(absoluteExportPath).isFile()
|
|
? fs.statSync(absoluteExportPath).size
|
|
: null,
|
|
},
|
|
htmlExport,
|
|
htmlExportHistory,
|
|
standaloneGate,
|
|
deck: deckSummary,
|
|
playwright: playwrightChecks,
|
|
playwrightSlides: auditedSlides,
|
|
layoutAudit: layoutAudits,
|
|
layoutAuditSummary,
|
|
layoutPatchAttempts,
|
|
ignoredOutputs,
|
|
progress,
|
|
};
|
|
|
|
if (args.json) {
|
|
console.log(JSON.stringify(report, null, 2));
|
|
} else {
|
|
console.log('\n=== Slidev workflow verification report ===');
|
|
console.log(JSON.stringify(report, null, 2));
|
|
}
|
|
|
|
if (!report.ok) {
|
|
process.exitCode = 1;
|
|
}
|
|
|
|
if (typeof esbuild.stop === 'function') {
|
|
await esbuild.stop();
|
|
}
|
|
|
|
process.exit(process.exitCode || 0);
|
|
}
|
|
|
|
main().catch(error => {
|
|
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
|
process.exit(1);
|
|
});
|