@beltype/cli 0.1.3 → 0.1.5
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 +32 -2
- package/assets/warmup/OFL-SourceSans3.txt +93 -0
- package/assets/warmup/SourceSans3-Variable.woff2 +0 -0
- package/dist/src/check.js +60 -0
- package/dist/src/chromium.js +22 -0
- package/dist/src/cli.js +5 -26
- package/dist/src/compare.js +91 -0
- package/dist/src/flags.js +63 -0
- package/dist/src/in-page.js +37 -8
- package/dist/src/main.js +82 -0
- package/dist/src/print.js +111 -104
- package/dist/src/run.js +157 -0
- package/dist/src/serve.js +272 -0
- package/package.json +8 -7
package/dist/src/print.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `beltype print`: a finished HTML document to a PDF, through Cicero and Caster, in Chromium's headless shell.
|
|
3
3
|
*
|
|
4
4
|
* @remarks
|
|
5
|
-
* **
|
|
5
|
+
* **Two printers.** Cicero paginates, then Caster prints the pages Cicero presented (the default), or `--printer chromium` has Chromium's `page.pdf()` print them. Under Caster the bare headless shell is enough: where Cicero cuts is layout, the same Blink in the shell as in the full binary.
|
|
6
6
|
*
|
|
7
7
|
* **Every request is answered from disk, on one made-up origin.** The browser context's `route` answers the document at its own name, the document's folder at the root, and `/_beltype/` from the companions' built files. A request for anything else is refused and named, and a run that refused anything writes no file, because the print would not be the document.
|
|
8
8
|
*
|
|
@@ -12,120 +12,127 @@ import fs from 'node:fs';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { chromium } from 'playwright-core';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const VIEWPORT = { width: 1200, height: 1600 };
|
|
22
|
-
/** Deterministic text and timers that run while nobody watches the page. */
|
|
23
|
-
const ARGS = ['--font-render-hinting=none', '--force-device-scale-factor=1', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows'];
|
|
15
|
+
import { ciceroFlags } from '@beltype/cicero/flags';
|
|
16
|
+
import { cliFlags, tableOf } from './flags.js';
|
|
17
|
+
import { checkPages } from './check.js';
|
|
18
|
+
import { ARGS, VIEWPORT, documentAssets, servedHtml, routeCompanions, routeDocument, printOnPage, refusal } from './run.js';
|
|
19
|
+
export { spliceHead } from './run.js';
|
|
20
|
+
const round = (ms) => Math.round(ms * 10) / 10;
|
|
24
21
|
/**
|
|
25
22
|
* Print `input`, an HTML file, to `output`.
|
|
26
23
|
*
|
|
27
24
|
* @throws {BuildError} naming what the document asked for and could not be given, or the step that refused.
|
|
28
25
|
*/
|
|
29
|
-
export async function print(input, output) {
|
|
26
|
+
export async function print(input, output, options = {}) {
|
|
30
27
|
const started = performance.now();
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
const cicero = ciceroEngine.vendorAssets();
|
|
37
|
-
const assets = {
|
|
38
|
-
files: cicero.files,
|
|
39
|
-
dirs: { ...cicero.dirs, caster: distOf('@beltype/caster', 1), common: distOf('@beltype/common/errors', 0) },
|
|
28
|
+
// Caster's own defaults are resolved in the page by `caster.print`, so the given values are all the page is handed; its table is loaded for the trace alone, at the end.
|
|
29
|
+
const flags = {
|
|
30
|
+
caster: options.given?.caster ?? {},
|
|
31
|
+
cicero: ciceroFlags.resolve(options.given?.cicero),
|
|
32
|
+
beltype: cliFlags.resolve(options.given?.beltype)
|
|
40
33
|
};
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
34
|
+
const trace = { process: {}, cicero: [], caster: null, versions: {}, flags: {} };
|
|
35
|
+
let mark = started;
|
|
36
|
+
const lap = (key) => {
|
|
37
|
+
const now = performance.now();
|
|
38
|
+
trace.process[key] = round(now - mark);
|
|
39
|
+
mark = now;
|
|
40
|
+
};
|
|
41
|
+
let step = 'launch';
|
|
42
|
+
let chromiumVersion = 'unknown';
|
|
46
43
|
try {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
if ('refused' in printed)
|
|
57
|
-
throw refusal(missing, printed);
|
|
58
|
-
await browser.close();
|
|
59
|
-
if (missing.size)
|
|
60
|
-
throw refusal(missing, null);
|
|
61
|
-
for (const warning of printed.warnings)
|
|
62
|
-
console.warn(typeof warning === 'string' ? warning : JSON.stringify(warning));
|
|
63
|
-
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
|
|
64
|
-
fs.writeFileSync(output, Buffer.from(printed.base64, 'base64'));
|
|
65
|
-
return { output, pages: printed.pages, words: printed.words, ms: performance.now() - started };
|
|
66
|
-
}
|
|
67
|
-
finally {
|
|
68
|
-
await browser.close();
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
/** The run's refusal: what it could not answer comes first, because a missing file's name says more than the step it broke. */
|
|
72
|
-
function refusal(missing, answer) {
|
|
73
|
-
if (missing.size)
|
|
74
|
-
return new BuildError(`beltype print: the document asked for what the run cannot give it, so the print would not be the document:\n${[...missing].map((m) => ` ${m}`).join('\n')}`);
|
|
75
|
-
return new BuildError(`beltype print: the ${answer?.at ?? 'run'} step refused:\n${answer?.refused || '(the refusal carried no message)'}`);
|
|
76
|
-
}
|
|
77
|
-
/** `fragment` at the top of the document's `<head>`, ahead of the author's own sheets and scripts. */
|
|
78
|
-
export function spliceHead(html, fragment) {
|
|
79
|
-
const head = /<head\b[^>]*>/i.exec(html);
|
|
80
|
-
if (head)
|
|
81
|
-
return html.slice(0, head.index + head[0].length) + `\n ${fragment}` + html.slice(head.index + head[0].length);
|
|
82
|
-
const before = /^\s*(<!doctype[^>]*>\s*)?(<html\b[^>]*>)?/i.exec(html)?.[0] ?? '';
|
|
83
|
-
return `${before}<head>\n ${fragment}\n</head>${html.slice(before.length)}`;
|
|
84
|
-
}
|
|
85
|
-
// ponytail: routes only. A dedicated or shared worker's WebSocket and fetches pass no route, so a document that opens one is not refused; the monorepo's `cast` closes that with a proxy the run owns (packages/cli/src/cast.js), to be brought over when a document needs it.
|
|
86
|
-
async function answerFromDisk(context, missing, o) {
|
|
87
|
-
await context.routeWebSocket(/.*/, (ws) => {
|
|
88
|
-
missing.add(`${ws.url()} (a WebSocket; print opens none)`);
|
|
89
|
-
return ws.close({ code: 1008, reason: 'beltype print reaches no network' });
|
|
90
|
-
});
|
|
91
|
-
await context.route('**/*', async (route) => {
|
|
92
|
-
const url = new URL(route.request().url());
|
|
93
|
-
if (url.origin !== ORIGIN) {
|
|
94
|
-
missing.add(`${url.href} (another origin; print fetches nothing from a network)`);
|
|
95
|
-
return route.abort('blockedbyclient');
|
|
96
|
-
}
|
|
97
|
-
let pathname;
|
|
44
|
+
const html = fs.readFileSync(input, 'utf8');
|
|
45
|
+
const root = path.dirname(path.resolve(input));
|
|
46
|
+
const docPath = `/${encodeURIComponent(path.basename(input))}`;
|
|
47
|
+
const assets = documentAssets();
|
|
48
|
+
const served = servedHtml(html);
|
|
49
|
+
const missing = new Set();
|
|
50
|
+
const browser = await chromium.launch({ headless: true, args: ARGS });
|
|
51
|
+
chromiumVersion = browser.version();
|
|
52
|
+
lap('launch');
|
|
98
53
|
try {
|
|
99
|
-
|
|
54
|
+
step = 'context';
|
|
55
|
+
const context = await browser.newContext({ viewport: VIEWPORT, serviceWorkers: 'block' });
|
|
56
|
+
await routeCompanions(context, missing, assets);
|
|
57
|
+
const page = await context.newPage();
|
|
58
|
+
await routeDocument(page, missing, { root, docPath, served });
|
|
59
|
+
lap('context');
|
|
60
|
+
const printed = await printOnPage(page, missing, flags, {
|
|
61
|
+
docPath,
|
|
62
|
+
measures: options.trace !== undefined,
|
|
63
|
+
...(options.created !== undefined ? { created: options.created } : {}),
|
|
64
|
+
progress: { step: (name) => { step = name; }, lap, cicero: (measures) => { trace.cicero = measures; } }
|
|
65
|
+
});
|
|
66
|
+
if (printed.printMs !== undefined) {
|
|
67
|
+
trace.process['print'] = round(printed.printMs);
|
|
68
|
+
trace.process['transfer'] = round(performance.now() - mark - printed.printMs);
|
|
69
|
+
mark = performance.now();
|
|
70
|
+
trace.caster = printed.caster;
|
|
71
|
+
for (const warning of printed.warnings)
|
|
72
|
+
console.warn(typeof warning === 'string' ? warning : JSON.stringify(warning));
|
|
73
|
+
}
|
|
74
|
+
else
|
|
75
|
+
lap('print');
|
|
76
|
+
const { bytes, pages, words, screens } = printed;
|
|
77
|
+
step = 'close';
|
|
78
|
+
await browser.close();
|
|
79
|
+
lap('close');
|
|
80
|
+
if (missing.size)
|
|
81
|
+
throw refusal(missing, null);
|
|
82
|
+
step = 'write';
|
|
83
|
+
fs.mkdirSync(path.dirname(path.resolve(output)), { recursive: true });
|
|
84
|
+
fs.writeFileSync(output, bytes);
|
|
85
|
+
lap('write');
|
|
86
|
+
let check;
|
|
87
|
+
if (flags.beltype.check) {
|
|
88
|
+
step = 'check';
|
|
89
|
+
check = await checkPages(screens, bytes, path.resolve(output).replace(/\.pdf$/i, '') + '.check', flags.beltype.printer);
|
|
90
|
+
lap('check');
|
|
91
|
+
}
|
|
92
|
+
return { output, pages, words, ms: performance.now() - started, ...(check ? { check } : {}) };
|
|
100
93
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
94
|
+
finally {
|
|
95
|
+
await browser.close();
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch (error) {
|
|
99
|
+
trace.refused = { step, message: error instanceof Error ? error.message : String(error) };
|
|
100
|
+
throw error;
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
if (options.trace !== undefined) {
|
|
104
|
+
trace.process['total'] = round(performance.now() - started);
|
|
105
|
+
trace.versions = versions(chromiumVersion);
|
|
106
|
+
// After the total: importing Caster's table here costs about 40 ms in Node, which is not the run's.
|
|
107
|
+
trace.flags = { ...flags, caster: (await tableOf('caster')).resolve(flags.caster) };
|
|
108
|
+
fs.mkdirSync(path.dirname(path.resolve(options.trace)), { recursive: true });
|
|
109
|
+
fs.writeFileSync(options.trace, JSON.stringify(trace, null, 2));
|
|
104
110
|
}
|
|
105
|
-
if (url.pathname === o.docPath)
|
|
106
|
-
return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: o.served });
|
|
107
|
-
const file = pathname.startsWith('/_beltype/') ? fromAssets(o.assets, pathname.slice('/_beltype/'.length)) : within(o.root, `.${pathname}`);
|
|
108
|
-
if (file)
|
|
109
|
-
return route.fulfill({ status: 200, path: file, contentType: contentType(file) });
|
|
110
|
-
if (pathname !== '/favicon.ico')
|
|
111
|
-
missing.add(pathname.startsWith('/_beltype/') ? `${pathname} (no companion holds it)` : `${pathname} (not in ${o.root})`);
|
|
112
|
-
return route.fulfill({ status: 404, body: '' });
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
function fromAssets(assets, assetPath) {
|
|
116
|
-
const file = assets.files[assetPath];
|
|
117
|
-
if (file)
|
|
118
|
-
return isFile(file) ? file : null;
|
|
119
|
-
for (const [prefix, dir] of Object.entries(assets.dirs)) {
|
|
120
|
-
if (assetPath.startsWith(prefix + '/'))
|
|
121
|
-
return within(dir, assetPath.slice(prefix.length + 1));
|
|
122
111
|
}
|
|
123
|
-
return null;
|
|
124
112
|
}
|
|
125
|
-
/**
|
|
126
|
-
function
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
113
|
+
/** The version of each package the run used, read from the `package.json` above the module that `specifier` resolves to. */
|
|
114
|
+
function versions(chromiumVersion) {
|
|
115
|
+
const of = (url, name) => {
|
|
116
|
+
let dir = path.dirname(fileURLToPath(url));
|
|
117
|
+
for (;;) {
|
|
118
|
+
const file = path.join(dir, 'package.json');
|
|
119
|
+
if (fs.existsSync(file)) {
|
|
120
|
+
const pkg = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
121
|
+
if (pkg.name === name)
|
|
122
|
+
return pkg.version ?? 'unknown';
|
|
123
|
+
}
|
|
124
|
+
const up = path.dirname(dir);
|
|
125
|
+
if (up === dir)
|
|
126
|
+
return 'unknown';
|
|
127
|
+
dir = up;
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
return {
|
|
131
|
+
'@beltype/cli': of(import.meta.url, '@beltype/cli'),
|
|
132
|
+
'@beltype/cicero': of(import.meta.resolve('@beltype/cicero'), '@beltype/cicero'),
|
|
133
|
+
'@beltype/caster': of(import.meta.resolve('@beltype/caster'), '@beltype/caster'),
|
|
134
|
+
'@beltype/common': of(import.meta.resolve('@beltype/common/errors'), '@beltype/common'),
|
|
135
|
+
'playwright-core': of(import.meta.resolve('playwright-core'), 'playwright-core'),
|
|
136
|
+
chromium: chromiumVersion
|
|
137
|
+
};
|
|
130
138
|
}
|
|
131
|
-
const isFile = (file) => fs.existsSync(file) && fs.statSync(file).isFile();
|
package/dist/src/run.js
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The steps of a print as functions of a page, so `beltype print` and a server that keeps a browser alive print the same way.
|
|
3
|
+
*
|
|
4
|
+
* @remarks
|
|
5
|
+
* **Every request is answered from disk, on one made-up origin.** {@link routeCompanions} answers on the browser context what no document owns: a WebSocket and another origin are refused and named, `/_beltype/` is answered from the companions' built files. {@link routeDocument} answers on a page the document at its own name and its folder at the root, and lets everything else fall back to the context's routes (Playwright runs a page's routes before its context's). {@link printOnPage} then loads, cuts, screenshots when asked and prints, and throws the refusals `print` has always thrown.
|
|
6
|
+
*
|
|
7
|
+
* @packageDocumentation
|
|
8
|
+
*/
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { ciceroEngine } from '@beltype/cicero/engine';
|
|
13
|
+
import { BuildError } from '@beltype/common/errors';
|
|
14
|
+
import { contentType } from '@beltype/common/mime';
|
|
15
|
+
import { cutInPage, printInPage, pageSizesInPage, chromiumSheetInPage } from './in-page.js';
|
|
16
|
+
import { chromiumSheet } from './chromium.js';
|
|
17
|
+
import { screenshotPages } from './check.js';
|
|
18
|
+
/** `.invalid` is reserved (RFC 2606), so nothing on a network can ever answer it instead. */
|
|
19
|
+
export const ORIGIN = 'https://print.invalid';
|
|
20
|
+
export const VIEWPORT = { width: 1200, height: 1600 };
|
|
21
|
+
/** Deterministic text and timers that run while nobody watches the page. */
|
|
22
|
+
export const ARGS = ['--font-render-hinting=none', '--force-device-scale-factor=1', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', '--disable-backgrounding-occluded-windows'];
|
|
23
|
+
/** The companions' built folders and files, each folder found from a module it exports: common exports no `package.json`. */
|
|
24
|
+
export function documentAssets() {
|
|
25
|
+
const distOf = (specifier, up) => path.resolve(path.dirname(fileURLToPath(import.meta.resolve(specifier))), ...Array(up).fill('..'));
|
|
26
|
+
const cicero = ciceroEngine.vendorAssets();
|
|
27
|
+
return { files: cicero.files, dirs: { ...cicero.dirs, caster: distOf('@beltype/caster', 1), common: distOf('@beltype/common/errors', 0) } };
|
|
28
|
+
}
|
|
29
|
+
/** The document as the page is served it: Caster's import map and Cicero's head ahead of the author's own. */
|
|
30
|
+
export function servedHtml(html) {
|
|
31
|
+
// Caster's modules import common by its package names, which a page resolves only through an import map.
|
|
32
|
+
const imports = Object.fromEntries(['errors', 'data-beltype', 'flags', 'mime'].map((m) => [`@beltype/common/${m}`, `/_beltype/common/${m}.js`]));
|
|
33
|
+
return spliceHead(html, `<script type="importmap">${JSON.stringify({ imports })}</script>\n ${ciceroEngine.headHtml('/')}`);
|
|
34
|
+
}
|
|
35
|
+
/** `fragment` at the top of the document's `<head>`, ahead of the author's own sheets and scripts. */
|
|
36
|
+
export function spliceHead(html, fragment) {
|
|
37
|
+
const head = /<head\b[^>]*>/i.exec(html);
|
|
38
|
+
if (head)
|
|
39
|
+
return html.slice(0, head.index + head[0].length) + `\n ${fragment}` + html.slice(head.index + head[0].length);
|
|
40
|
+
const before = /^\s*(<!doctype[^>]*>\s*)?(<html\b[^>]*>)?/i.exec(html)?.[0] ?? '';
|
|
41
|
+
return `${before}<head>\n ${fragment}\n</head>${html.slice(before.length)}`;
|
|
42
|
+
}
|
|
43
|
+
// ponytail: routes only. A dedicated or shared worker's WebSocket and fetches pass no route, so a document that opens one is not refused; the monorepo's `cast` closes that with a proxy the run owns (packages/cli/src/cast.js), to be brought over when a document needs it.
|
|
44
|
+
/** What no document owns, on the context: refuse a WebSocket or another origin by name, and answer `/_beltype/` from the companions. */
|
|
45
|
+
export async function routeCompanions(context, missing, assets = documentAssets()) {
|
|
46
|
+
await context.routeWebSocket(/.*/, (ws) => {
|
|
47
|
+
missing.add(`${ws.url()} (a WebSocket; print opens none)`);
|
|
48
|
+
return ws.close({ code: 1008, reason: 'beltype print reaches no network' });
|
|
49
|
+
});
|
|
50
|
+
await context.route('**/*', async (route) => {
|
|
51
|
+
const url = new URL(route.request().url());
|
|
52
|
+
if (url.origin !== ORIGIN) {
|
|
53
|
+
missing.add(`${url.href} (another origin; print fetches nothing from a network)`);
|
|
54
|
+
return route.abort('blockedbyclient');
|
|
55
|
+
}
|
|
56
|
+
const pathname = decoded(url, missing);
|
|
57
|
+
if (pathname === null)
|
|
58
|
+
return route.fulfill({ status: 400, body: '' });
|
|
59
|
+
const file = pathname.startsWith('/_beltype/') ? fromAssets(assets, pathname.slice('/_beltype/'.length)) : null;
|
|
60
|
+
if (file)
|
|
61
|
+
return route.fulfill({ status: 200, path: file, contentType: contentType(file) });
|
|
62
|
+
missing.add(`${pathname} (${pathname.startsWith('/_beltype/') ? 'no companion holds it' : 'no document answered it'})`);
|
|
63
|
+
return route.fulfill({ status: 404, body: '' });
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
/** The document's own files on a page: the document at `docPath`, its folder `root` at the root. Another origin and `/_beltype/` fall back to the context's routes. */
|
|
67
|
+
export async function routeDocument(page, missing, o) {
|
|
68
|
+
await page.route('**/*', async (route) => {
|
|
69
|
+
const url = new URL(route.request().url());
|
|
70
|
+
if (url.origin !== ORIGIN)
|
|
71
|
+
return route.fallback();
|
|
72
|
+
const pathname = decoded(url, missing);
|
|
73
|
+
if (pathname === null)
|
|
74
|
+
return route.fulfill({ status: 400, body: '' });
|
|
75
|
+
if (pathname.startsWith('/_beltype/'))
|
|
76
|
+
return route.fallback();
|
|
77
|
+
if (url.pathname === o.docPath)
|
|
78
|
+
return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: o.served });
|
|
79
|
+
const file = within(o.root, `.${pathname}`);
|
|
80
|
+
if (file)
|
|
81
|
+
return route.fulfill({ status: 200, path: file, contentType: contentType(file) });
|
|
82
|
+
if (pathname !== '/favicon.ico')
|
|
83
|
+
missing.add(`${pathname} (not in ${o.root})`);
|
|
84
|
+
return route.fulfill({ status: 404, body: '' });
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Load the document on `page`, cut it, screenshot its pages when `--check` asks, and print it under the flags' printer.
|
|
89
|
+
*
|
|
90
|
+
* @remarks
|
|
91
|
+
* What the run could not answer after the print (`missing`) is the caller's to test with {@link refusal}: `print` tests it after closing the browser, as it always has.
|
|
92
|
+
* @throws {BuildError} where the cut or the print refused, or the document asked for what the run could not give it.
|
|
93
|
+
*/
|
|
94
|
+
export async function printOnPage(page, missing, flags, o) {
|
|
95
|
+
const { progress } = o;
|
|
96
|
+
page.on('pageerror', (error) => console.warn(`page error: ${error.message}`));
|
|
97
|
+
progress?.step('load');
|
|
98
|
+
await page.goto(ORIGIN + o.docPath, { waitUntil: 'load' });
|
|
99
|
+
progress?.lap('load');
|
|
100
|
+
progress?.step('cut');
|
|
101
|
+
const cut = await page.evaluate(cutInPage, { cicero: flags.cicero, measures: o.measures === true });
|
|
102
|
+
progress?.lap('cut');
|
|
103
|
+
const measures = 'refused' in cut ? [] : cut.measures;
|
|
104
|
+
progress?.cicero?.(measures);
|
|
105
|
+
if ('refused' in cut || missing.size)
|
|
106
|
+
throw refusal(missing, 'refused' in cut ? cut : null);
|
|
107
|
+
let screens = [];
|
|
108
|
+
if (flags.beltype.check) {
|
|
109
|
+
progress?.step('check');
|
|
110
|
+
screens = await screenshotPages(page);
|
|
111
|
+
progress?.lap('check');
|
|
112
|
+
}
|
|
113
|
+
progress?.step('print');
|
|
114
|
+
if (flags.beltype.printer === 'chromium') {
|
|
115
|
+
const sheet = chromiumSheet(await page.evaluate(pageSizesInPage));
|
|
116
|
+
await page.evaluate(chromiumSheetInPage, sheet);
|
|
117
|
+
const bytes = await page.pdf({ preferCSSPageSize: true, printBackground: true });
|
|
118
|
+
return { bytes, pages: await page.locator('beltype-page').count(), words: 0, warnings: [], caster: null, cicero: measures, screens };
|
|
119
|
+
}
|
|
120
|
+
const printed = await page.evaluate(printInPage, { options: flags.caster, ...(o.created !== undefined ? { created: o.created } : {}), ...(o.sharedWriter ? { sharedWriter: true } : {}) });
|
|
121
|
+
if ('refused' in printed)
|
|
122
|
+
throw refusal(missing, printed);
|
|
123
|
+
return { bytes: Buffer.from(printed.base64, 'base64'), pages: printed.pages, words: printed.words, warnings: printed.warnings, caster: printed.caster, cicero: measures, screens, printMs: printed.printMs };
|
|
124
|
+
}
|
|
125
|
+
/** The run's refusal: what it could not answer comes first, because a missing file's name says more than the step it broke. */
|
|
126
|
+
export function refusal(missing, answer) {
|
|
127
|
+
if (missing.size)
|
|
128
|
+
return new BuildError(`beltype print: the document asked for what the run cannot give it, so the print would not be the document:\n${[...missing].map((m) => ` ${m}`).join('\n')}`);
|
|
129
|
+
return new BuildError(`beltype print: the ${answer?.at ?? 'run'} step refused:\n${answer?.refused || '(the refusal carried no message)'}`);
|
|
130
|
+
}
|
|
131
|
+
/** The percent-decoded path of `url`, or null (and the name of what it asked) where the escape is malformed. */
|
|
132
|
+
function decoded(url, missing) {
|
|
133
|
+
try {
|
|
134
|
+
return decodeURIComponent(url.pathname);
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
missing.add(`${url.pathname} (a malformed percent escape, which names no file)`);
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function fromAssets(assets, assetPath) {
|
|
142
|
+
const file = assets.files[assetPath];
|
|
143
|
+
if (file)
|
|
144
|
+
return isFile(file) ? file : null;
|
|
145
|
+
for (const [prefix, dir] of Object.entries(assets.dirs)) {
|
|
146
|
+
if (assetPath.startsWith(prefix + '/'))
|
|
147
|
+
return within(dir, assetPath.slice(prefix.length + 1));
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
/** `rel` under `dir`, if it is a file there and does not climb out. */
|
|
152
|
+
function within(dir, rel) {
|
|
153
|
+
const file = path.resolve(dir, rel);
|
|
154
|
+
const inside = path.relative(dir, file);
|
|
155
|
+
return !inside.startsWith('..') && !path.isAbsolute(inside) && isFile(file) ? file : null;
|
|
156
|
+
}
|
|
157
|
+
const isFile = (file) => fs.existsSync(file) && fs.statSync(file).isFile();
|