slides-grab 1.0.0 → 1.1.2

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.
@@ -0,0 +1,222 @@
1
+ import { dirname, join, resolve } from 'node:path';
2
+
3
+ const ABSOLUTE_FILESYSTEM_PATH_RE = /^(file:\/\/|\/Users\/|\/home\/|\/var\/|\/tmp\/|\/private\/|\/Volumes\/|[A-Za-z]:[\\/]|\\\\)/i;
4
+ const SCHEME_RE = /^[a-z][a-z0-9+\-.]*:/i;
5
+ const CSS_URL_RE = /url\(\s*(['"]?)(.*?)\1\s*\)/gi;
6
+
7
+ export const LOCAL_ASSET_PREFIX = './assets/';
8
+
9
+ export function looksLikeAbsoluteFilesystemPath(value) {
10
+ return ABSOLUTE_FILESYSTEM_PATH_RE.test((value || '').trim());
11
+ }
12
+
13
+ export function extractCssUrls(value) {
14
+ const input = typeof value === 'string' ? value : '';
15
+ const matches = [];
16
+ let match;
17
+ while ((match = CSS_URL_RE.exec(input)) !== null) {
18
+ const candidate = (match[2] || '').trim();
19
+ if (candidate) {
20
+ matches.push(candidate);
21
+ }
22
+ }
23
+ return matches;
24
+ }
25
+
26
+ export function classifyImageSource(source) {
27
+ const value = typeof source === 'string' ? source.trim() : '';
28
+
29
+ if (!value) return { kind: 'empty' };
30
+ if (value.startsWith('data:')) return { kind: 'data-url' };
31
+ if (value.startsWith('https://')) return { kind: 'remote-url' };
32
+ if (value.startsWith('http://')) return { kind: 'remote-url-insecure' };
33
+ if (looksLikeAbsoluteFilesystemPath(value)) return { kind: 'absolute-filesystem-path' };
34
+ if (value.startsWith(LOCAL_ASSET_PREFIX)) return { kind: 'local-asset-path' };
35
+ if (value.startsWith('/')) return { kind: 'root-relative-path' };
36
+ if (SCHEME_RE.test(value)) return { kind: 'other-scheme' };
37
+ return { kind: 'noncanonical-relative-path' };
38
+ }
39
+
40
+ export function resolveSlideSourcePath(slidePath, source) {
41
+ return resolve(dirname(slidePath), source);
42
+ }
43
+
44
+ function injectIntoHead(html, snippet) {
45
+ if (/<head\b[^>]*>/i.test(html)) {
46
+ return html.replace(/<head\b[^>]*>/i, (match) => `${match}\n${snippet}`);
47
+ }
48
+
49
+ if (/<html\b[^>]*>/i.test(html)) {
50
+ return html.replace(/<html\b[^>]*>/i, (match) => `${match}\n<head>\n${snippet}\n</head>`);
51
+ }
52
+
53
+ return `${snippet}\n${html}`;
54
+ }
55
+
56
+ export function buildImageContractReport({ slideFile, sources = [] }) {
57
+ const issues = [];
58
+
59
+ for (const entry of sources) {
60
+ const source = typeof entry?.source === 'string' ? entry.source.trim() : '';
61
+ const classification = classifyImageSource(source);
62
+
63
+ if (classification.kind === 'empty' || classification.kind === 'data-url') {
64
+ continue;
65
+ }
66
+
67
+ if (classification.kind === 'remote-url') {
68
+ issues.push({
69
+ severity: 'warning',
70
+ code: 'remote-image-url',
71
+ message: 'Remote image URL is best-effort only and may break deterministic exports.',
72
+ slide: slideFile,
73
+ ...entry,
74
+ });
75
+ continue;
76
+ }
77
+
78
+ if (classification.kind === 'remote-url-insecure') {
79
+ issues.push({
80
+ severity: 'warning',
81
+ code: 'remote-image-url-insecure',
82
+ message: 'Insecure http:// image URL is discouraged. Prefer ./assets/<file> or data: URLs.',
83
+ slide: slideFile,
84
+ ...entry,
85
+ });
86
+ continue;
87
+ }
88
+
89
+ if (classification.kind === 'absolute-filesystem-path') {
90
+ issues.push({
91
+ severity: 'critical',
92
+ code: 'absolute-filesystem-image-path',
93
+ message: 'Absolute filesystem paths are unsupported. Use ./assets/<file> instead.',
94
+ slide: slideFile,
95
+ ...entry,
96
+ });
97
+ continue;
98
+ }
99
+
100
+ if (classification.kind === 'root-relative-path') {
101
+ issues.push({
102
+ severity: 'critical',
103
+ code: 'root-relative-image-path',
104
+ message: 'Root-relative image paths are unsupported. Use ./assets/<file> instead.',
105
+ slide: slideFile,
106
+ ...entry,
107
+ });
108
+ continue;
109
+ }
110
+
111
+ if (classification.kind === 'noncanonical-relative-path') {
112
+ issues.push({
113
+ severity: 'warning',
114
+ code: 'noncanonical-relative-image-path',
115
+ message: 'Use ./assets/<file> for portable local assets.',
116
+ slide: slideFile,
117
+ ...entry,
118
+ });
119
+ }
120
+ }
121
+
122
+ return issues;
123
+ }
124
+
125
+ export function buildSlideRuntimeHtml(html, { baseHref, slideFile }) {
126
+ const snippets = [];
127
+
128
+ if (baseHref && !/<base\b/i.test(html)) {
129
+ snippets.push(`<base href="${baseHref}">`);
130
+ }
131
+
132
+ const script = `<script>
133
+ (() => {
134
+ const slideFile = ${JSON.stringify(slideFile)};
135
+ const localAssetPrefix = ${JSON.stringify(LOCAL_ASSET_PREFIX)};
136
+ const absolutePathRe = ${ABSOLUTE_FILESYSTEM_PATH_RE.toString()};
137
+ const prefix = '[slides-grab:image]';
138
+
139
+ function describeElement(element) {
140
+ if (!element || element.nodeType !== Node.ELEMENT_NODE) return '';
141
+ if (element === document.body) return 'body';
142
+
143
+ const parts = [];
144
+ let current = element;
145
+ while (current && current.nodeType === Node.ELEMENT_NODE && current !== document.body) {
146
+ let part = current.tagName.toLowerCase();
147
+ if (current.id) {
148
+ part += '#' + current.id;
149
+ parts.unshift(part);
150
+ break;
151
+ }
152
+ if (current.classList.length > 0) {
153
+ part += '.' + Array.from(current.classList).slice(0, 2).join('.');
154
+ }
155
+ parts.unshift(part);
156
+ current = current.parentElement;
157
+ }
158
+ return 'body > ' + parts.join(' > ');
159
+ }
160
+
161
+ function warn(message, detail) {
162
+ console.warn(prefix + ' ' + slideFile + ': ' + message, detail);
163
+ }
164
+
165
+ function fail(message, detail) {
166
+ console.error(prefix + ' ' + slideFile + ': ' + message, detail);
167
+ }
168
+
169
+ window.addEventListener('error', (event) => {
170
+ const target = event.target;
171
+ if (!(target instanceof HTMLImageElement)) return;
172
+ const src = (target.getAttribute('src') || target.currentSrc || '').trim();
173
+ if (!src || src.startsWith('data:')) return;
174
+ if (src.startsWith(localAssetPrefix)) {
175
+ fail('missing local asset', { src });
176
+ return;
177
+ }
178
+ fail('image failed to load', { src });
179
+ }, true);
180
+
181
+ window.addEventListener('DOMContentLoaded', () => {
182
+ for (const image of document.querySelectorAll('img[src]')) {
183
+ const src = (image.getAttribute('src') || '').trim();
184
+ if (!src || src.startsWith('data:')) continue;
185
+ if (src.startsWith('https://')) {
186
+ warn('remote image is best-effort only', { src });
187
+ continue;
188
+ }
189
+ if (src.startsWith('http://')) {
190
+ warn('insecure remote image is discouraged', { src });
191
+ continue;
192
+ }
193
+ if (absolutePathRe.test(src) || src.startsWith('/')) {
194
+ fail('non-portable image path is unsupported', { src });
195
+ continue;
196
+ }
197
+ if (!src.startsWith(localAssetPrefix)) {
198
+ warn('noncanonical local image path should use ./assets/<file>', { src });
199
+ }
200
+ }
201
+
202
+ for (const element of document.body.querySelectorAll('*')) {
203
+ if (element === document.body) continue;
204
+ const backgroundImage = window.getComputedStyle(element).backgroundImage;
205
+ if (!backgroundImage || backgroundImage === 'none' || !backgroundImage.includes('url(')) continue;
206
+ fail('non-body background-image is not supported for slide content', {
207
+ element: describeElement(element),
208
+ backgroundImage,
209
+ });
210
+ }
211
+ });
212
+ })();
213
+ </script>`;
214
+
215
+ snippets.push(script);
216
+
217
+ return injectIntoHead(html, snippets.join('\n'));
218
+ }
219
+
220
+ export function resolveLocalAssetPath(slidePath, source) {
221
+ return join(dirname(slidePath), source.replace(/^\.\//, ''));
222
+ }
@@ -0,0 +1,97 @@
1
+ export const DEFAULT_SLIDES_DIR = 'slides';
2
+ export const DEFAULT_VALIDATE_FORMAT = 'concise';
3
+ export const VALIDATE_FORMATS = ['concise', 'json', 'json-full'];
4
+
5
+ function readOptionValue(args, index, optionName) {
6
+ const next = args[index + 1];
7
+ if (!next || next.startsWith('-')) {
8
+ throw new Error(`Missing value for ${optionName}.`);
9
+ }
10
+ return next;
11
+ }
12
+
13
+ export function parseValidateCliArgs(args) {
14
+ const options = {
15
+ slidesDir: DEFAULT_SLIDES_DIR,
16
+ format: DEFAULT_VALIDATE_FORMAT,
17
+ help: false,
18
+ slides: [],
19
+ };
20
+
21
+ for (let i = 0; i < args.length; i += 1) {
22
+ const arg = args[i];
23
+
24
+ if (arg === '-h' || arg === '--help') {
25
+ options.help = true;
26
+ continue;
27
+ }
28
+
29
+ if (arg === '--slides-dir') {
30
+ options.slidesDir = readOptionValue(args, i, '--slides-dir');
31
+ i += 1;
32
+ continue;
33
+ }
34
+
35
+ if (arg.startsWith('--slides-dir=')) {
36
+ options.slidesDir = arg.slice('--slides-dir='.length);
37
+ continue;
38
+ }
39
+
40
+ if (arg === '--format') {
41
+ options.format = readOptionValue(args, i, '--format');
42
+ i += 1;
43
+ continue;
44
+ }
45
+
46
+ if (arg.startsWith('--format=')) {
47
+ options.format = arg.slice('--format='.length);
48
+ continue;
49
+ }
50
+
51
+ if (arg === '--slide') {
52
+ options.slides.push(readOptionValue(args, i, '--slide'));
53
+ i += 1;
54
+ continue;
55
+ }
56
+
57
+ if (arg.startsWith('--slide=')) {
58
+ options.slides.push(arg.slice('--slide='.length));
59
+ continue;
60
+ }
61
+
62
+ throw new Error(`Unknown option: ${arg}`);
63
+ }
64
+
65
+ if (typeof options.slidesDir !== 'string' || options.slidesDir.trim() === '') {
66
+ throw new Error('--slides-dir must be a non-empty string.');
67
+ }
68
+
69
+ if (typeof options.format !== 'string' || options.format.trim() === '') {
70
+ throw new Error('--format must be a non-empty string.');
71
+ }
72
+
73
+ options.slidesDir = options.slidesDir.trim();
74
+ options.format = options.format.trim();
75
+
76
+ if (!VALIDATE_FORMATS.includes(options.format)) {
77
+ throw new Error(`Unknown --format value: ${options.format}. Expected one of: ${VALIDATE_FORMATS.join(', ')}`);
78
+ }
79
+
80
+ options.slides = options.slides
81
+ .map((slide) => String(slide).trim())
82
+ .filter(Boolean);
83
+
84
+ return options;
85
+ }
86
+
87
+ export function getValidateUsage() {
88
+ return [
89
+ 'Usage: node scripts/validate-slides.js [options]',
90
+ '',
91
+ 'Options:',
92
+ ` --slides-dir <path> Slide directory (default: ${DEFAULT_SLIDES_DIR})`,
93
+ ` --format <format> Output format: ${VALIDATE_FORMATS.join(', ')} (default: ${DEFAULT_VALIDATE_FORMAT})`,
94
+ ' --slide <file> Validate only the named slide file (repeatable)',
95
+ ' -h, --help Show this help message',
96
+ ].join('\n');
97
+ }