pageproof 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/THIRD_PARTY_NOTICES.md +74 -0
  4. package/assets/SKILL.md +58 -0
  5. package/assets/_paged.css +82 -0
  6. package/assets/chicago.csl +6006 -0
  7. package/assets/default.html +496 -0
  8. package/assets/doublespaced.css +8 -0
  9. package/assets/european-journal-of-international-law.csl +404 -0
  10. package/assets/favicon.svg +5 -0
  11. package/assets/footnotes-inline.lua +56 -0
  12. package/assets/latex.css +142 -0
  13. package/assets/msword.css +100 -0
  14. package/assets/numbered.css +75 -0
  15. package/assets/vendor/mathjax/LICENSE +202 -0
  16. package/assets/vendor/mathjax/sre/mathmaps/af.json +146 -0
  17. package/assets/vendor/mathjax/sre/mathmaps/base.json +140 -0
  18. package/assets/vendor/mathjax/sre/mathmaps/ca.json +140 -0
  19. package/assets/vendor/mathjax/sre/mathmaps/da.json +140 -0
  20. package/assets/vendor/mathjax/sre/mathmaps/de.json +146 -0
  21. package/assets/vendor/mathjax/sre/mathmaps/en.json +158 -0
  22. package/assets/vendor/mathjax/sre/mathmaps/es.json +140 -0
  23. package/assets/vendor/mathjax/sre/mathmaps/euro.json +32 -0
  24. package/assets/vendor/mathjax/sre/mathmaps/fr.json +146 -0
  25. package/assets/vendor/mathjax/sre/mathmaps/hi.json +146 -0
  26. package/assets/vendor/mathjax/sre/mathmaps/it.json +146 -0
  27. package/assets/vendor/mathjax/sre/mathmaps/ko.json +146 -0
  28. package/assets/vendor/mathjax/sre/mathmaps/nb.json +146 -0
  29. package/assets/vendor/mathjax/sre/mathmaps/nemeth.json +125 -0
  30. package/assets/vendor/mathjax/sre/mathmaps/nn.json +146 -0
  31. package/assets/vendor/mathjax/sre/mathmaps/sv.json +146 -0
  32. package/assets/vendor/mathjax/sre/speech-worker.js +1 -0
  33. package/assets/vendor/mathjax/tex-mml-chtml-nofont.js +18 -0
  34. package/assets/vendor/mathjax-fonts/LICENSE +202 -0
  35. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-b.woff2 +0 -0
  36. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-bi.woff2 +0 -0
  37. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-brk.woff2 +0 -0
  38. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-c.woff2 +0 -0
  39. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-cb.woff2 +0 -0
  40. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-f.woff2 +0 -0
  41. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-fb.woff2 +0 -0
  42. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-i.woff2 +0 -0
  43. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-lo.woff2 +0 -0
  44. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-m.woff2 +0 -0
  45. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-mi.woff2 +0 -0
  46. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-n.woff2 +0 -0
  47. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-ob.woff2 +0 -0
  48. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-os.woff2 +0 -0
  49. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-s3.woff2 +0 -0
  50. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-s4.woff2 +0 -0
  51. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-so.woff2 +0 -0
  52. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-ss.woff2 +0 -0
  53. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-ssb.woff2 +0 -0
  54. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-ssi.woff2 +0 -0
  55. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-v.woff2 +0 -0
  56. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml/woff2/mjx-tex-zero.woff2 +0 -0
  57. package/assets/vendor/mathjax-fonts/mathjax-tex-font/chtml.js +1 -0
  58. package/assets/vendor/mathjax-fonts/mathjax-tex-font/package.json +88 -0
  59. package/assets/vendor/paged.polyfill.js +33251 -0
  60. package/bin/mdpreview.js +8 -0
  61. package/bin/pageproof.js +8 -0
  62. package/package.json +42 -0
  63. package/src/assets.js +246 -0
  64. package/src/cli.js +166 -0
  65. package/src/lifecycle.js +445 -0
  66. package/src/pandoc.js +346 -0
  67. package/src/server.js +228 -0
  68. package/src/util.js +43 -0
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from '../src/cli.js';
4
+
5
+ runCli(process.argv.slice(2)).catch((error) => {
6
+ console.error(error?.message || String(error));
7
+ process.exitCode = 1;
8
+ });
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from '../src/cli.js';
4
+
5
+ runCli(process.argv.slice(2)).catch((error) => {
6
+ console.error(error?.message || String(error));
7
+ process.exitCode = 1;
8
+ });
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "pageproof",
3
+ "version": "0.1.0",
4
+ "description": "Live Pandoc Markdown preview with paginated browser output.",
5
+ "type": "module",
6
+ "author": "Damon Wischik <djw1005@cam.ac.uk>",
7
+ "keywords": [
8
+ "markdown",
9
+ "preview",
10
+ "pandoc",
11
+ "pagedjs",
12
+ "citations",
13
+ "footnotes",
14
+ "cli"
15
+ ],
16
+ "bin": {
17
+ "pageproof": "./bin/pageproof.js"
18
+ },
19
+ "files": [
20
+ "assets/",
21
+ "bin/",
22
+ "src/",
23
+ "README.md",
24
+ "LICENSE",
25
+ "THIRD_PARTY_NOTICES.md"
26
+ ],
27
+ "engines": {
28
+ "node": ">=20"
29
+ },
30
+ "scripts": {
31
+ "test": "node --test",
32
+ "prepublishOnly": "node --test"
33
+ },
34
+ "dependencies": {
35
+ "chokidar": "^4.0.3",
36
+ "yaml": "^2.9.0"
37
+ },
38
+ "license": "SEE LICENSE IN THIRD_PARTY_NOTICES.md",
39
+ "devDependencies": {
40
+ "playwright": "^1.60.0"
41
+ }
42
+ }
package/src/assets.js ADDED
@@ -0,0 +1,246 @@
1
+ import fs from 'node:fs/promises';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { parse as parseYaml } from 'yaml';
5
+ import { packageRoot, pathExists } from './util.js';
6
+
7
+ export const mandatoryBaseStyles = ['_paged'];
8
+ export const defaultBaseStyles = ['msword'];
9
+ export const defaultCitationStyle = 'european-journal-of-international-law';
10
+
11
+ const selectors = {
12
+ style: { ext: 'css' },
13
+ citations: { ext: 'csl', defaultName: defaultCitationStyle, runtimeName: 'citations.csl' }
14
+ };
15
+
16
+ export function defaultSelections() {
17
+ return {
18
+ styles: [],
19
+ citations: null
20
+ };
21
+ }
22
+
23
+ export async function resolveAsset(kind, name, options = {}) {
24
+ const selector = selectors[kind];
25
+ if (!selector) throw new Error(`Unknown asset type: ${kind}`);
26
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) {
27
+ throw new Error(`Invalid ${kind} asset name: ${name}`);
28
+ }
29
+
30
+ const filename = `${name}.${selector.ext}`;
31
+ const homeDir = options.homeDir ?? os.homedir();
32
+ const candidates = [
33
+ path.join(homeDir, '.mdpreview', filename),
34
+ path.join(packageRoot, 'assets', filename)
35
+ ];
36
+
37
+ for (const candidate of candidates) {
38
+ if (await pathExists(candidate)) return candidate;
39
+ }
40
+
41
+ throw new Error(
42
+ `Missing ${kind} asset "${name}". Looked for ${candidates.join(' and ')}.`
43
+ );
44
+ }
45
+
46
+ export async function resolveAssets(selections, options = {}) {
47
+ const homeDir = options.homeDir ?? os.homedir();
48
+ const config = await loadConfig(homeDir);
49
+ const baseStyles = options.baseStyles ?? configBaseStyles(config);
50
+ const commandStyles = styleSelections(selections);
51
+ const styleNames = [...mandatoryBaseStyles, ...baseStyles, ...commandStyles];
52
+ const citationName =
53
+ selections.citations ?? configCitationStyle(config) ?? selectors.citations.defaultName;
54
+
55
+ return {
56
+ styles: await Promise.all(
57
+ styleNames.map(async (name) => ({
58
+ name,
59
+ path: await resolveAsset('style', name, { ...options, homeDir })
60
+ }))
61
+ ),
62
+ template: path.join(packageRoot, 'assets', 'default.html'),
63
+ citations: await resolveAsset('citations', citationName, { ...options, homeDir }),
64
+ favicon: path.join(packageRoot, 'assets', 'favicon.svg'),
65
+ luaFilter: path.join(packageRoot, 'assets', 'footnotes-inline.lua'),
66
+ paged: path.join(packageRoot, 'assets', 'vendor', 'paged.polyfill.js'),
67
+ mathjax: path.join(packageRoot, 'assets', 'vendor', 'mathjax'),
68
+ mathjaxFonts: path.join(packageRoot, 'assets', 'vendor', 'mathjax-fonts')
69
+ };
70
+ }
71
+
72
+ async function loadConfig(homeDir) {
73
+ const configPath = path.join(homeDir, '.mdpreview', 'config.yaml');
74
+ if (!(await pathExists(configPath))) return { path: configPath, data: {} };
75
+ try {
76
+ return { path: configPath, data: parseYaml(await fs.readFile(configPath, 'utf8')) ?? {} };
77
+ } catch (error) {
78
+ throw new Error(`Invalid mdpreview config ${configPath}: ${error?.message || String(error)}`);
79
+ }
80
+ }
81
+
82
+ function configBaseStyles(config) {
83
+ const base = config.data?.styles;
84
+ if (base === undefined) return [...defaultBaseStyles];
85
+ if (!Array.isArray(base) || base.some((item) => typeof item !== 'string')) {
86
+ throw new Error(`Invalid mdpreview config ${config.path}: styles must be a list of style names.`);
87
+ }
88
+ return base;
89
+ }
90
+
91
+ function configCitationStyle(config) {
92
+ const value = config.data?.citations;
93
+ if (value === undefined) return null;
94
+ if (typeof value !== 'string') {
95
+ throw new Error(`Invalid mdpreview config ${config.path}: citations must be a string.`);
96
+ }
97
+ return value;
98
+ }
99
+
100
+ export async function configuredBaseStyles(homeDir = os.homedir()) {
101
+ return configBaseStyles(await loadConfig(homeDir));
102
+ }
103
+
104
+ export async function configuredCitationStyle(homeDir = os.homedir()) {
105
+ return configCitationStyle(await loadConfig(homeDir)) ?? selectors.citations.defaultName;
106
+ }
107
+
108
+ export async function availableAssets(options = {}) {
109
+ const homeDir = options.homeDir ?? os.homedir();
110
+ const [styles, citations] = await Promise.all([
111
+ listAvailableAssets('style', homeDir),
112
+ listAvailableAssets('citations', homeDir)
113
+ ]);
114
+ return { styles, citations };
115
+ }
116
+
117
+ async function listAvailableAssets(kind, homeDir) {
118
+ const selector = selectors[kind];
119
+ if (!selector) throw new Error(`Unknown asset type: ${kind}`);
120
+ const discovered = new Map();
121
+ const roots = [
122
+ { source: 'user', dir: path.join(homeDir, '.mdpreview') },
123
+ { source: 'bundled', dir: path.join(packageRoot, 'assets') }
124
+ ];
125
+
126
+ for (const root of roots) {
127
+ let files;
128
+ try {
129
+ files = await fs.readdir(root.dir, { withFileTypes: true });
130
+ } catch (error) {
131
+ if (error?.code === 'ENOENT') continue;
132
+ throw error;
133
+ }
134
+
135
+ for (const file of files) {
136
+ if (!file.isFile() || path.extname(file.name) !== `.${selector.ext}`) continue;
137
+ const name = path.basename(file.name, `.${selector.ext}`);
138
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) continue;
139
+ const existing = discovered.get(name) ?? {
140
+ name,
141
+ path: path.join(root.dir, file.name),
142
+ sources: []
143
+ };
144
+ existing.sources.push(root.source);
145
+ if (root.source === 'user') existing.path = path.join(root.dir, file.name);
146
+ discovered.set(name, existing);
147
+ }
148
+ }
149
+
150
+ const entries = await Promise.all(
151
+ [...discovered.values()].map(async (entry) => ({
152
+ ...entry,
153
+ description: await describeAsset(kind, entry.path)
154
+ }))
155
+ );
156
+ return entries.sort((a, b) => a.name.localeCompare(b.name));
157
+ }
158
+
159
+ async function describeAsset(kind, assetPath) {
160
+ const text = await fs.readFile(assetPath, 'utf8').catch(() => '');
161
+ if (kind === 'style') return cssDescription(text);
162
+ if (kind === 'citations') return cslDescription(text);
163
+ return '';
164
+ }
165
+
166
+ function cssDescription(text) {
167
+ const match = text.match(/^\s*\/\*\s*mdpreview-style:\s*([^*]+?)\s*\*\//i);
168
+ return match ? collapseWhitespace(match[1]) : '';
169
+ }
170
+
171
+ function cslDescription(text) {
172
+ return (
173
+ xmlElementText(text, 'title') ||
174
+ xmlElementText(text, 'summary') ||
175
+ xmlElementText(text, 'title-short')
176
+ );
177
+ }
178
+
179
+ function xmlElementText(text, elementName) {
180
+ const match = text.match(new RegExp(`<${elementName}(?:\\s[^>]*)?>([\\s\\S]*?)</${elementName}>`, 'i'));
181
+ return match ? decodeXml(collapseWhitespace(match[1].replace(/<[^>]*>/g, ''))) : '';
182
+ }
183
+
184
+ function collapseWhitespace(value) {
185
+ return String(value).replace(/\s+/g, ' ').trim();
186
+ }
187
+
188
+ function decodeXml(value) {
189
+ return value
190
+ .replaceAll('&amp;', '&')
191
+ .replaceAll('&lt;', '<')
192
+ .replaceAll('&gt;', '>')
193
+ .replaceAll('&quot;', '"')
194
+ .replaceAll('&apos;', "'");
195
+ }
196
+
197
+ function styleSelections(selections = {}) {
198
+ if (Array.isArray(selections.styles)) return selections.styles;
199
+ if (selections.style) return [selections.style];
200
+ return [];
201
+ }
202
+
203
+ export async function materializeAssets(resolvedAssets, tempDir) {
204
+ const copies = [
205
+ ...resolvedAssets.styles.map((style, index) => [
206
+ style.path,
207
+ styleRuntimeName(style.name, index)
208
+ ]),
209
+ [resolvedAssets.template, 'template.html'],
210
+ [resolvedAssets.citations, 'citations.csl'],
211
+ [resolvedAssets.favicon, 'favicon.svg'],
212
+ [resolvedAssets.luaFilter, 'footnotes-inline.lua'],
213
+ [resolvedAssets.paged, 'paged.polyfill.js']
214
+ ];
215
+ const directoryCopies = [
216
+ [resolvedAssets.mathjax, 'mathjax'],
217
+ [resolvedAssets.mathjaxFonts, 'mathjax-fonts']
218
+ ];
219
+
220
+ for (const [source, target] of copies) {
221
+ if (!(await pathExists(source))) throw new Error(`Missing required asset: ${source}`);
222
+ await fs.copyFile(source, path.join(tempDir, target));
223
+ }
224
+ for (const [source, target] of directoryCopies) {
225
+ if (!(await pathExists(source))) throw new Error(`Missing required asset: ${source}`);
226
+ await fs.cp(source, path.join(tempDir, target), { recursive: true });
227
+ }
228
+
229
+ return {
230
+ cssFiles: resolvedAssets.styles.map((style, index) => styleRuntimeName(style.name, index))
231
+ };
232
+ }
233
+
234
+ export function watchedAssetPaths(resolvedAssets) {
235
+ return [
236
+ ...resolvedAssets.styles.map((style) => style.path),
237
+ resolvedAssets.template,
238
+ resolvedAssets.citations,
239
+ resolvedAssets.favicon,
240
+ resolvedAssets.luaFilter
241
+ ];
242
+ }
243
+
244
+ function styleRuntimeName(name, index) {
245
+ return `style-${String(index).padStart(2, '0')}-${name}.css`;
246
+ }
package/src/cli.js ADDED
@@ -0,0 +1,166 @@
1
+ import fs from 'node:fs';
2
+ import fsp from 'node:fs/promises';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+ import {
7
+ availableAssets,
8
+ configuredBaseStyles,
9
+ configuredCitationStyle,
10
+ defaultSelections
11
+ } from './assets.js';
12
+ import { runSession, waitForReadyFile } from './lifecycle.js';
13
+ import { packageRoot } from './util.js';
14
+
15
+ export function parseArgs(argv) {
16
+ const options = {
17
+ detached: false,
18
+ debug: false,
19
+ session: false,
20
+ selections: defaultSelections()
21
+ };
22
+ const files = [];
23
+
24
+ for (let index = 0; index < argv.length; index += 1) {
25
+ const arg = argv[index];
26
+ if (arg === '--help') options.help = true;
27
+ else if (arg === '--styles') options.styles = true;
28
+ else if (arg === '--show-skill') options.showSkill = true;
29
+ else if (arg === '--detached') options.detached = true;
30
+ else if (arg === '--debug') options.debug = true;
31
+ else if (arg === '--session') options.session = true;
32
+ else if (arg.startsWith('--ready-file=')) options.readyFile = arg.slice('--ready-file='.length);
33
+ else if (arg.startsWith('--log-file=')) options.logFile = arg.slice('--log-file='.length);
34
+ else if (arg.startsWith('--style=')) options.selections.styles.push(arg.slice('--style='.length));
35
+ else if (arg.startsWith('--citations=')) options.selections.citations = arg.slice('--citations='.length);
36
+ else if (arg.startsWith('-')) throw new Error(`Unknown option: ${arg}`);
37
+ else files.push(arg);
38
+ }
39
+
40
+ if (options.styles && files.length) throw new Error('--styles does not accept a Markdown file.');
41
+ if (options.showSkill && files.length) throw new Error('--show-skill does not accept a Markdown file.');
42
+ if (!options.help && !options.styles && !options.showSkill) {
43
+ if (files.length !== 1) throw new Error('Expected exactly one Markdown file.');
44
+ options.file = path.resolve(files[0]);
45
+ }
46
+ return options;
47
+ }
48
+
49
+ export async function runCli(argv) {
50
+ const options = parseArgs(argv);
51
+ if (options.help) {
52
+ console.log(helpText());
53
+ return;
54
+ }
55
+ if (options.styles) {
56
+ console.log(await stylesText());
57
+ return;
58
+ }
59
+ if (options.showSkill) {
60
+ process.stdout.write(await skillText());
61
+ return;
62
+ }
63
+
64
+ if (options.detached && !options.session) {
65
+ await runDetached(options);
66
+ return;
67
+ }
68
+
69
+ await runSession(options);
70
+ }
71
+
72
+ export async function runDetached(options) {
73
+ const tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'mdpreview-session-'));
74
+ const readyFile = path.join(tempDir, 'ready.json');
75
+ const childStdout = path.join(tempDir, 'child.stdout');
76
+ const childStderr = path.join(tempDir, 'child.stderr');
77
+ const out = fs.openSync(childStdout, 'a');
78
+ const err = fs.openSync(childStderr, 'a');
79
+ const childArgs = [
80
+ path.join(packageRoot, 'bin', 'pageproof.js'),
81
+ '--session',
82
+ `--ready-file=${readyFile}`,
83
+ ...options.selections.styles.map((style) => `--style=${style}`),
84
+ ...(options.selections.citations ? [`--citations=${options.selections.citations}`] : []),
85
+ ...(options.debug ? ['--debug'] : []),
86
+ options.file
87
+ ];
88
+ const child = spawn(process.execPath, childArgs, {
89
+ detached: true,
90
+ stdio: ['ignore', out, err]
91
+ });
92
+ fs.closeSync(out);
93
+ fs.closeSync(err);
94
+ child.unref();
95
+
96
+ let ready;
97
+ try {
98
+ ready = await waitForReadyFile(readyFile);
99
+ } catch (error) {
100
+ const stderr = await fsp.readFile(childStderr, 'utf8').catch(() => '');
101
+ if (stderr.trim()) process.stderr.write(stderr);
102
+ throw error;
103
+ }
104
+ const startup = await fsp.readFile(childStdout, 'utf8').catch(() => '');
105
+ if (startup.trim()) process.stdout.write(startup);
106
+ console.log(`Preview server is running as PID ${ready.pid} -- use this to check if it's alive`);
107
+ console.log(`To check for any document errors, fetch ${ready.url}status.json`);
108
+ }
109
+
110
+ export function helpText() {
111
+ return `Usage:
112
+ pageproof FILE.md
113
+ pageproof --detached FILE.md
114
+ pageproof --style=NAME [--style=OTHER] FILE.md
115
+ pageproof --citations=NAME FILE.md
116
+ pageproof --styles
117
+ pageproof --show-skill
118
+
119
+ Options:
120
+ --citations=NAME Select a CSL citation style.
121
+ --style=NAME Add a document extra style. Repeatable.
122
+ --styles List available document and citation styles.
123
+ --show-skill Print the bundled agent skill file.`;
124
+ }
125
+
126
+ export async function skillText() {
127
+ return fsp.readFile(path.join(packageRoot, 'assets', 'SKILL.md'), 'utf8');
128
+ }
129
+
130
+ export async function stylesText(options = {}) {
131
+ const assets = await availableAssets(options);
132
+ const baseStyles = await configuredBaseStyles(options.homeDir);
133
+ const citationDefault = await configuredCitationStyle(options.homeDir);
134
+ const visibleStyles = assets.styles.filter((entry) => !entry.name.startsWith('_'));
135
+ return [
136
+ formatAssetSection('Document styles', visibleStyles, baseStyles),
137
+ '',
138
+ formatAssetSection('Citation styles', assets.citations, [citationDefault]),
139
+ '',
140
+ configHelpText()
141
+ ].join('\n');
142
+ }
143
+
144
+ function configHelpText() {
145
+ return `Additional styles (NAME.css) and citation styles (NAME.csl) can be placed in ~/.mdpreview/.
146
+ Defaults can be overridden in ~/.mdpreview/config.yaml, e.g.
147
+ styles: [msword]
148
+ citations: european-journal-of-international-law`;
149
+ }
150
+
151
+ function formatAssetSection(title, entries, defaults) {
152
+ const header = `${title} (default=${defaults.join(', ') || 'none'}):`;
153
+ if (!entries.length) return `${header}\n (none)`;
154
+ const nameWidth = Math.max(...entries.map((entry) => displayName(entry).length));
155
+ const lines = entries.map((entry) => {
156
+ const name = displayName(entry).padEnd(nameWidth);
157
+ const description = entry.description ? ` ${entry.description}` : '';
158
+ return ` ${name}${description}`;
159
+ });
160
+ return `${header}\n${lines.join('\n')}`;
161
+ }
162
+
163
+ function displayName(entry) {
164
+ const isUser = entry.sources.includes('user') && !entry.sources.includes('bundled');
165
+ return isUser ? `${entry.name} [user]` : entry.name;
166
+ }