@bendyline/squisq-formats 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 (61) hide show
  1. package/dist/chunk-532L4D5D.js +21 -0
  2. package/dist/chunk-532L4D5D.js.map +1 -0
  3. package/dist/chunk-67KIJHV2.js +21 -0
  4. package/dist/chunk-67KIJHV2.js.map +1 -0
  5. package/dist/chunk-KAK4V57E.js +387 -0
  6. package/dist/chunk-KAK4V57E.js.map +1 -0
  7. package/dist/chunk-MQHCXI56.js +1035 -0
  8. package/dist/chunk-MQHCXI56.js.map +1 -0
  9. package/dist/chunk-TBPD5PCU.js +1136 -0
  10. package/dist/chunk-TBPD5PCU.js.map +1 -0
  11. package/dist/chunk-ULLIPBEJ.js +271 -0
  12. package/dist/chunk-ULLIPBEJ.js.map +1 -0
  13. package/dist/docx/index.d.ts +108 -0
  14. package/dist/docx/index.js +14 -0
  15. package/dist/docx/index.js.map +1 -0
  16. package/dist/html/index.d.ts +166 -0
  17. package/dist/html/index.js +17 -0
  18. package/dist/html/index.js.map +1 -0
  19. package/dist/index.d.ts +7 -0
  20. package/dist/index.js +54 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/ooxml/index.d.ts +351 -0
  23. package/dist/ooxml/index.js +99 -0
  24. package/dist/ooxml/index.js.map +1 -0
  25. package/dist/pdf/index.d.ts +130 -0
  26. package/dist/pdf/index.js +15 -0
  27. package/dist/pdf/index.js.map +1 -0
  28. package/dist/pptx/index.d.ts +58 -0
  29. package/dist/pptx/index.js +13 -0
  30. package/dist/pptx/index.js.map +1 -0
  31. package/dist/xlsx/index.d.ts +58 -0
  32. package/dist/xlsx/index.js +13 -0
  33. package/dist/xlsx/index.js.map +1 -0
  34. package/package.json +85 -0
  35. package/src/__tests__/docxExport.test.ts +457 -0
  36. package/src/__tests__/docxImport.test.ts +410 -0
  37. package/src/__tests__/html.test.ts +295 -0
  38. package/src/__tests__/ooxml.test.ts +197 -0
  39. package/src/__tests__/pdfExport.test.ts +322 -0
  40. package/src/__tests__/pdfImport.test.ts +290 -0
  41. package/src/__tests__/roundTrip.test.ts +201 -0
  42. package/src/docx/export.ts +978 -0
  43. package/src/docx/import.ts +909 -0
  44. package/src/docx/index.ts +26 -0
  45. package/src/docx/styles.ts +145 -0
  46. package/src/html/htmlTemplate.ts +307 -0
  47. package/src/html/imageUtils.ts +66 -0
  48. package/src/html/index.ts +168 -0
  49. package/src/index.ts +51 -0
  50. package/src/ooxml/index.ts +87 -0
  51. package/src/ooxml/namespaces.ts +160 -0
  52. package/src/ooxml/reader.ts +218 -0
  53. package/src/ooxml/types.ts +104 -0
  54. package/src/ooxml/writer.ts +321 -0
  55. package/src/ooxml/xmlUtils.ts +123 -0
  56. package/src/pdf/export.ts +1029 -0
  57. package/src/pdf/import.ts +835 -0
  58. package/src/pdf/index.ts +29 -0
  59. package/src/pdf/styles.ts +180 -0
  60. package/src/pptx/index.ts +78 -0
  61. package/src/xlsx/index.ts +78 -0
@@ -0,0 +1,26 @@
1
+ /**
2
+ * @bendyline/squisq-formats DOCX Module
3
+ *
4
+ * Import and export squisq documents (MarkdownDocument / Doc)
5
+ * to/from Microsoft Word .docx files (Office Open XML WordprocessingML).
6
+ *
7
+ * All operations run in the browser — no server required.
8
+ *
9
+ * @example
10
+ * ```ts
11
+ * import {
12
+ * markdownDocToDocx,
13
+ * docToDocx,
14
+ * docxToMarkdownDoc,
15
+ * docxToDoc,
16
+ * } from '@bendyline/squisq-formats/docx';
17
+ * ```
18
+ */
19
+
20
+ // Export
21
+ export { markdownDocToDocx, docToDocx } from './export.js';
22
+ export type { DocxExportOptions } from './export.js';
23
+
24
+ // Import
25
+ export { docxToMarkdownDoc, docxToDoc } from './import.js';
26
+ export type { DocxImportOptions } from './import.js';
@@ -0,0 +1,145 @@
1
+ /**
2
+ * DOCX Constants & Style Definitions
3
+ *
4
+ * Shared constants for DOCX import and export: built-in style IDs,
5
+ * numbering format mappings, content type strings, and default
6
+ * formatting values.
7
+ */
8
+
9
+ // ============================================
10
+ // Built-in Style IDs
11
+ // ============================================
12
+
13
+ /**
14
+ * Word built-in paragraph style IDs → heading depth.
15
+ * These are the default style IDs in Word's Normal.dotm template.
16
+ * Import uses these to detect heading levels from pStyle values.
17
+ */
18
+ export const HEADING_STYLE_MAP: Record<string, number> = {
19
+ Heading1: 1,
20
+ Heading2: 2,
21
+ Heading3: 3,
22
+ Heading4: 4,
23
+ Heading5: 5,
24
+ Heading6: 6,
25
+ heading1: 1,
26
+ heading2: 2,
27
+ heading3: 3,
28
+ heading4: 4,
29
+ heading5: 5,
30
+ heading6: 6,
31
+ // Some localized/legacy variants
32
+ Title: 1,
33
+ Subtitle: 2,
34
+ };
35
+
36
+ /**
37
+ * Heading depth → Word style ID mapping (for export).
38
+ */
39
+ export const DEPTH_TO_STYLE_ID: Record<number, string> = {
40
+ 1: 'Heading1',
41
+ 2: 'Heading2',
42
+ 3: 'Heading3',
43
+ 4: 'Heading4',
44
+ 5: 'Heading5',
45
+ 6: 'Heading6',
46
+ };
47
+
48
+ /**
49
+ * Paragraph style IDs that map to blockquotes on import.
50
+ */
51
+ export const QUOTE_STYLE_IDS = new Set(['Quote', 'IntenseQuote', 'quote', 'BlockQuote']);
52
+
53
+ /**
54
+ * Paragraph style ID for code blocks.
55
+ */
56
+ export const CODE_STYLE_IDS = new Set(['Code', 'CodeBlock', 'HTMLCode', 'PlainText']);
57
+
58
+ /**
59
+ * Character style IDs that indicate inline code.
60
+ */
61
+ export const INLINE_CODE_STYLE_IDS = new Set(['CodeChar', 'HTMLCodeChar', 'VerbatimChar']);
62
+
63
+ // ============================================
64
+ // Numbering Formats
65
+ // ============================================
66
+
67
+ /**
68
+ * Word numbering format values that indicate an unordered (bullet) list.
69
+ */
70
+ export const BULLET_NUM_FORMATS = new Set(['bullet', 'none']);
71
+
72
+ /**
73
+ * Word numbering format values that indicate an ordered (numbered) list.
74
+ */
75
+ export const ORDERED_NUM_FORMATS = new Set([
76
+ 'decimal',
77
+ 'upperRoman',
78
+ 'lowerRoman',
79
+ 'upperLetter',
80
+ 'lowerLetter',
81
+ 'ordinal',
82
+ 'cardinalText',
83
+ 'ordinalText',
84
+ ]);
85
+
86
+ // ============================================
87
+ // Default Formatting
88
+ // ============================================
89
+
90
+ /** Default font family for document body text */
91
+ export const DEFAULT_FONT = 'Calibri';
92
+
93
+ /** Default font family for headings */
94
+ export const DEFAULT_HEADING_FONT = 'Calibri Light';
95
+
96
+ /** Default font size in half-points (22 = 11pt) */
97
+ export const DEFAULT_FONT_SIZE_HALF_POINTS = 22;
98
+
99
+ /** Default code font */
100
+ export const DEFAULT_CODE_FONT = 'Consolas';
101
+
102
+ /** Default code font size in half-points (20 = 10pt) */
103
+ export const DEFAULT_CODE_FONT_SIZE = 20;
104
+
105
+ /**
106
+ * Heading font sizes in half-points (Word default sizes).
107
+ */
108
+ export const HEADING_FONT_SIZES: Record<number, number> = {
109
+ 1: 32, // 16pt
110
+ 2: 26, // 13pt
111
+ 3: 24, // 12pt
112
+ 4: 22, // 11pt
113
+ 5: 22, // 11pt
114
+ 6: 22, // 11pt
115
+ };
116
+
117
+ /** Hyperlink color (standard Word blue) */
118
+ export const HYPERLINK_COLOR = '0563C1';
119
+
120
+ // ============================================
121
+ // EMU (English Metric Units) Helpers
122
+ // ============================================
123
+
124
+ /**
125
+ * Convert inches to EMUs (English Metric Units).
126
+ * 1 inch = 914400 EMUs.
127
+ */
128
+ export function inchesToEmu(inches: number): number {
129
+ return Math.round(inches * 914400);
130
+ }
131
+
132
+ /**
133
+ * Convert points to half-points (Word's internal unit for font sizes).
134
+ */
135
+ export function pointsToHalfPoints(points: number): number {
136
+ return Math.round(points * 2);
137
+ }
138
+
139
+ /**
140
+ * Convert points to twentieths of a point (twips), used for spacing/margins.
141
+ * 1 point = 20 twips.
142
+ */
143
+ export function pointsToTwips(points: number): number {
144
+ return Math.round(points * 20);
145
+ }
@@ -0,0 +1,307 @@
1
+ /**
2
+ * HTML Template Generation for SquisqPlayer Exports
3
+ *
4
+ * Generates complete, self-contained HTML documents that load the SquisqPlayer
5
+ * IIFE bundle and render a Doc as either an interactive slideshow or a static
6
+ * scrollable document.
7
+ *
8
+ * Two variants:
9
+ * 1. **Inline** — All JS, CSS, and images are embedded in the HTML (single file).
10
+ * 2. **External** — JS is referenced via `<script src>`, images via relative paths.
11
+ */
12
+
13
+ import type { Doc, Layer, Block } from '@bendyline/squisq/schemas';
14
+ import { arrayBufferToBase64DataUrl, inferMimeType } from './imageUtils.js';
15
+
16
+ // ── Types ──────────────────────────────────────────────────────────
17
+
18
+ export interface HtmlExportOptions {
19
+ /** The IIFE player bundle source code (from @bendyline/squisq-react/standalone-source) */
20
+ playerScript: string;
21
+
22
+ /**
23
+ * Map of relative image paths (as they appear in the Doc) to binary image data.
24
+ * For inline HTML export, these are converted to base64 data URIs.
25
+ * For ZIP export, these are written as separate files.
26
+ */
27
+ images?: Map<string, ArrayBuffer>;
28
+
29
+ /**
30
+ * Map of audio segment identifiers to binary audio data.
31
+ * Keys should match the audio segment `name` or `url` fields in the Doc.
32
+ * Only used in ZIP exports — single HTML uses timer-based playback.
33
+ */
34
+ audio?: Map<string, ArrayBuffer>;
35
+
36
+ /** Rendering mode: 'slideshow' (interactive, default) or 'static' (scrollable) */
37
+ mode?: 'slideshow' | 'static';
38
+
39
+ /** HTML page title (default: 'Squisq Document') */
40
+ title?: string;
41
+
42
+ /** Auto-play slideshow on load (default: false) */
43
+ autoPlay?: boolean;
44
+ }
45
+
46
+ // ── Image Path Collection ──────────────────────────────────────────
47
+
48
+ /**
49
+ * Collect all relative image paths referenced in a Doc's layers and template blocks.
50
+ * Returns a Set of unique relative paths that need to be resolved.
51
+ */
52
+ export function collectImagePaths(doc: Doc): Set<string> {
53
+ const paths = new Set<string>();
54
+
55
+ function addIfRelative(src: string | undefined) {
56
+ if (
57
+ !src ||
58
+ src.startsWith('data:') ||
59
+ src.startsWith('blob:') ||
60
+ src.startsWith('http://') ||
61
+ src.startsWith('https://')
62
+ ) {
63
+ return;
64
+ }
65
+ paths.add(src);
66
+ }
67
+
68
+ function scanLayers(layers: Layer[] | undefined) {
69
+ if (!layers) return;
70
+ for (const layer of layers) {
71
+ if (layer.type === 'image') addIfRelative(layer.content.src);
72
+ if (layer.type === 'video') {
73
+ addIfRelative(layer.content.src);
74
+ addIfRelative(layer.content.posterSrc);
75
+ }
76
+ if (layer.type === 'map') addIfRelative(layer.content.staticSrc);
77
+ }
78
+ }
79
+
80
+ function scanBlock(block: Block) {
81
+ scanLayers(block.layers);
82
+ if (block.children) block.children.forEach(scanBlock);
83
+ }
84
+
85
+ // Scan all blocks
86
+ doc.blocks.forEach(scanBlock);
87
+
88
+ // Scan start block hero
89
+ if (doc.startBlock?.heroSrc) addIfRelative(doc.startBlock.heroSrc);
90
+
91
+ // Scan persistent layers
92
+ if (doc.persistentLayers) {
93
+ for (const pl of doc.persistentLayers.bottomLayers ?? []) {
94
+ if ('src' in pl && typeof pl.src === 'string') addIfRelative(pl.src);
95
+ }
96
+ for (const pl of doc.persistentLayers.topLayers ?? []) {
97
+ if ('src' in pl && typeof pl.src === 'string') addIfRelative(pl.src);
98
+ }
99
+ }
100
+
101
+ // Scan template block fields that reference images
102
+ for (const block of doc.blocks) {
103
+ scanTemplateImageFields(block as unknown as Record<string, unknown>, paths);
104
+ }
105
+
106
+ return paths;
107
+ }
108
+
109
+ /**
110
+ * Recursively scan any object for known image-bearing field names.
111
+ */
112
+ function scanTemplateImageFields(obj: Record<string, unknown>, paths: Set<string>): void {
113
+ if (!obj || typeof obj !== 'object') return;
114
+
115
+ const imageFieldNames = [
116
+ 'imageSrc',
117
+ 'src',
118
+ 'heroSrc',
119
+ 'backgroundImage',
120
+ 'posterSrc',
121
+ 'staticSrc',
122
+ 'videoSrc',
123
+ 'thumbnailSrc',
124
+ ];
125
+
126
+ for (const key of imageFieldNames) {
127
+ const val = obj[key];
128
+ if (typeof val === 'string' && val && !val.startsWith('data:') && !val.startsWith('http')) {
129
+ paths.add(val);
130
+ }
131
+ // Handle nested objects like { src: '...' }
132
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
133
+ scanTemplateImageFields(val as Record<string, unknown>, paths);
134
+ }
135
+ }
136
+
137
+ // Handle arrays (e.g., images: [{ src: '...' }])
138
+ if (Array.isArray(obj)) {
139
+ for (const item of obj) {
140
+ if (item && typeof item === 'object') {
141
+ scanTemplateImageFields(item as Record<string, unknown>, paths);
142
+ }
143
+ }
144
+ }
145
+
146
+ // Recurse into known array/object fields
147
+ for (const key of ['images', 'accentImage', 'backgroundVideo', 'children', 'blocks']) {
148
+ const val = obj[key];
149
+ if (Array.isArray(val)) {
150
+ for (const item of val) {
151
+ if (item && typeof item === 'object') {
152
+ scanTemplateImageFields(item as Record<string, unknown>, paths);
153
+ }
154
+ }
155
+ } else if (val && typeof val === 'object') {
156
+ scanTemplateImageFields(val as Record<string, unknown>, paths);
157
+ }
158
+ }
159
+ }
160
+
161
+ // ── HTML Generation ────────────────────────────────────────────────
162
+
163
+ /**
164
+ * Escape a string for safe embedding in a `<script>` tag.
165
+ * Prevents `</script>` from closing the tag prematurely.
166
+ */
167
+ function escapeForScript(str: string): string {
168
+ return str.replace(/<\/(script)/gi, '<\\/$1');
169
+ }
170
+
171
+ /**
172
+ * Generate a complete inline HTML document with all JS and images embedded.
173
+ *
174
+ * @param doc - The Doc to render
175
+ * @param options - Export options
176
+ * @returns Complete HTML string
177
+ */
178
+ export function generateInlineHtml(doc: Doc, options: HtmlExportOptions): string {
179
+ const {
180
+ playerScript,
181
+ images,
182
+ mode = 'slideshow',
183
+ title = 'Squisq Document',
184
+ autoPlay = false,
185
+ } = options;
186
+
187
+ // Build base64 image map
188
+ const imageMap: Record<string, string> = {};
189
+ if (images) {
190
+ for (const [path, buffer] of images.entries()) {
191
+ const mimeType = inferMimeType(path);
192
+ imageMap[path] = arrayBufferToBase64DataUrl(buffer, mimeType);
193
+ }
194
+ }
195
+
196
+ const docJson = escapeForScript(JSON.stringify(doc));
197
+ const imageMapJson = escapeForScript(JSON.stringify(imageMap));
198
+
199
+ return `<!DOCTYPE html>
200
+ <html lang="en">
201
+ <head>
202
+ <meta charset="UTF-8">
203
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
204
+ <title>${escapeHtml(title)}</title>
205
+ <style>
206
+ *,*::before,*::after{box-sizing:border-box}
207
+ html,body{margin:0;padding:0;height:100%;background:#1a1a2e;color:#e0e0e0;font-family:system-ui,-apple-system,sans-serif}
208
+ #squisq-root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
209
+ ${mode === 'static' ? '#squisq-root{align-items:flex-start;overflow-y:auto;background:#fff;color:#1f2937}' : ''}
210
+ </style>
211
+ </head>
212
+ <body>
213
+ <div id="squisq-root"></div>
214
+ <script>${escapeForScript(playerScript)}</script>
215
+ <script>
216
+ (function(){
217
+ var doc = JSON.parse(${JSON.stringify(docJson)});
218
+ var images = JSON.parse(${JSON.stringify(imageMapJson)});
219
+ SquisqPlayer.mount(document.getElementById("squisq-root"), doc, {
220
+ mode: ${JSON.stringify(mode)},
221
+ images: images,
222
+ autoPlay: ${JSON.stringify(autoPlay)},
223
+ basePath: "."
224
+ });
225
+ })();
226
+ </script>
227
+ </body>
228
+ </html>`;
229
+ }
230
+
231
+ /**
232
+ * Generate an HTML document that references external JS and image files.
233
+ * Used for ZIP exports where files sit alongside the HTML.
234
+ *
235
+ * @param doc - The Doc to render (image/audio paths should already be rewritten to relative)
236
+ * @param options - Export options (playerScript is not embedded, referenced via src)
237
+ * @returns Complete HTML string
238
+ */
239
+ export function generateExternalHtml(
240
+ doc: Doc,
241
+ options: Pick<HtmlExportOptions, 'mode' | 'title' | 'autoPlay'> & {
242
+ /** Relative path to the player JS file (e.g., 'squisq-player.js') */
243
+ playerScriptPath: string;
244
+ /** Map of original image paths to their rewritten relative paths in the ZIP */
245
+ imagePathMap?: Record<string, string>;
246
+ /** Map of audio segment IDs to their rewritten relative paths in the ZIP */
247
+ audioPathMap?: Record<string, string>;
248
+ },
249
+ ): string {
250
+ const {
251
+ playerScriptPath,
252
+ imagePathMap,
253
+ audioPathMap,
254
+ mode = 'slideshow',
255
+ title = 'Squisq Document',
256
+ autoPlay = false,
257
+ } = options;
258
+
259
+ const docJson = escapeForScript(JSON.stringify(doc));
260
+ const imageMapJson = imagePathMap ? escapeForScript(JSON.stringify(imagePathMap)) : '{}';
261
+ const audioMapJson = audioPathMap ? escapeForScript(JSON.stringify(audioPathMap)) : 'null';
262
+
263
+ return `<!DOCTYPE html>
264
+ <html lang="en">
265
+ <head>
266
+ <meta charset="UTF-8">
267
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
268
+ <title>${escapeHtml(title)}</title>
269
+ <style>
270
+ *,*::before,*::after{box-sizing:border-box}
271
+ html,body{margin:0;padding:0;height:100%;background:#1a1a2e;color:#e0e0e0;font-family:system-ui,-apple-system,sans-serif}
272
+ #squisq-root{width:100%;height:100%;display:flex;align-items:center;justify-content:center}
273
+ ${mode === 'static' ? '#squisq-root{align-items:flex-start;overflow-y:auto;background:#fff;color:#1f2937}' : ''}
274
+ </style>
275
+ </head>
276
+ <body>
277
+ <div id="squisq-root"></div>
278
+ <script src="${escapeHtml(playerScriptPath)}"></script>
279
+ <script>
280
+ (function(){
281
+ var doc = JSON.parse(${JSON.stringify(docJson)});
282
+ var images = JSON.parse(${JSON.stringify(imageMapJson)});
283
+ var audio = ${audioMapJson};
284
+ SquisqPlayer.mount(document.getElementById("squisq-root"), doc, {
285
+ mode: ${JSON.stringify(mode)},
286
+ images: images,
287
+ audio: audio,
288
+ autoPlay: ${JSON.stringify(autoPlay)},
289
+ basePath: "."
290
+ });
291
+ })();
292
+ </script>
293
+ </body>
294
+ </html>`;
295
+ }
296
+
297
+ /**
298
+ * Escape HTML special characters to prevent injection.
299
+ */
300
+ function escapeHtml(str: string): string {
301
+ return str
302
+ .replace(/&/g, '&amp;')
303
+ .replace(/</g, '&lt;')
304
+ .replace(/>/g, '&gt;')
305
+ .replace(/"/g, '&quot;')
306
+ .replace(/'/g, '&#39;');
307
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Image Utilities for HTML Export
3
+ *
4
+ * Browser-compatible helpers for converting image data to base64 data URIs
5
+ * and inferring MIME types from filenames.
6
+ */
7
+
8
+ /** Map of file extensions to MIME types */
9
+ const MIME_MAP: Record<string, string> = {
10
+ jpg: 'image/jpeg',
11
+ jpeg: 'image/jpeg',
12
+ png: 'image/png',
13
+ gif: 'image/gif',
14
+ webp: 'image/webp',
15
+ svg: 'image/svg+xml',
16
+ ico: 'image/x-icon',
17
+ bmp: 'image/bmp',
18
+ avif: 'image/avif',
19
+ mp3: 'audio/mpeg',
20
+ wav: 'audio/wav',
21
+ ogg: 'audio/ogg',
22
+ mp4: 'video/mp4',
23
+ webm: 'video/webm',
24
+ };
25
+
26
+ /**
27
+ * Infer a MIME type from a filename's extension.
28
+ * Returns 'application/octet-stream' for unknown types.
29
+ */
30
+ export function inferMimeType(filename: string): string {
31
+ const ext = filename.split('.').pop()?.toLowerCase() ?? '';
32
+ return MIME_MAP[ext] ?? 'application/octet-stream';
33
+ }
34
+
35
+ /**
36
+ * Convert an ArrayBuffer to a base64-encoded data URI string.
37
+ *
38
+ * @param buffer - The binary image data
39
+ * @param mimeType - MIME type (e.g., 'image/jpeg'). If not provided, defaults to
40
+ * 'application/octet-stream'.
41
+ * @returns A `data:` URI string
42
+ */
43
+ export function arrayBufferToBase64DataUrl(buffer: ArrayBuffer, mimeType: string): string {
44
+ const bytes = new Uint8Array(buffer);
45
+ let binary = '';
46
+ for (let i = 0; i < bytes.length; i++) {
47
+ binary += String.fromCharCode(bytes[i]);
48
+ }
49
+ const base64 = btoa(binary);
50
+ return `data:${mimeType};base64,${base64}`;
51
+ }
52
+
53
+ /**
54
+ * Extract the filename from a path or URL (strips directory and query).
55
+ *
56
+ * @example
57
+ * extractFilename('images/hero.jpg') // 'hero.jpg'
58
+ * extractFilename('https://example.com/photo.png?v=2') // 'photo.png'
59
+ */
60
+ export function extractFilename(path: string): string {
61
+ // Strip query/hash
62
+ const clean = path.split('?')[0].split('#')[0];
63
+ // Get last segment
64
+ const parts = clean.split('/');
65
+ return parts[parts.length - 1] || path;
66
+ }