@adminforth/markdown 1.5.0 → 1.6.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.
package/build.log CHANGED
@@ -10,5 +10,5 @@ custom/package-lock.json
10
10
  custom/package.json
11
11
  custom/tsconfig.json
12
12
 
13
- sent 25,820 bytes received 115 bytes 51,870.00 bytes/sec
14
- total size is 25,388 speedup is 0.98
13
+ sent 29,778 bytes received 115 bytes 59,786.00 bytes/sec
14
+ total size is 29,346 speedup is 0.98
@@ -240,6 +240,8 @@ let removePasteListener: (() => void) | null = null;
240
240
  let removePasteListenerSecondary: (() => void) | null = null;
241
241
  let removeGlobalPasteListener: (() => void) | null = null;
242
242
  let removeGlobalKeydownListener: (() => void) | null = null;
243
+ let removeDragOverListener: (() => void) | null = null;
244
+ let removeDropListener: (() => void) | null = null;
243
245
 
244
246
  type MarkdownImageRef = {
245
247
  lineNumber: number;
@@ -387,6 +389,62 @@ function fileFromClipboardImage(blob: Blob): File {
387
389
  return new File([blob], filename, { type });
388
390
  }
389
391
 
392
+ function escapeMarkdownLinkText(text: string): string {
393
+ return text.replace(/[\[\]\\]/g, '\\$&');
394
+ }
395
+
396
+ function showAdminforthError(message: string) {
397
+ const api = (window as any).adminforth;
398
+ if (api && typeof api.alert === 'function') {
399
+ api.alert({
400
+ message,
401
+ variant: 'danger',
402
+ timeout: 30,
403
+ });
404
+ return;
405
+ }
406
+ console.error('[adminforth-markdown]', message);
407
+ }
408
+
409
+ function extractErrorMessage(error: any): string {
410
+ if (!error) return 'Upload failed';
411
+ if (typeof error === 'string') return error;
412
+ if (typeof error?.error === 'string') return error.error;
413
+ if (typeof error?.message === 'string') return error.message;
414
+ try {
415
+ return JSON.stringify(error);
416
+ } catch {
417
+ return 'Upload failed';
418
+ }
419
+ }
420
+
421
+ function markdownForUploadedFile(file: File, url: string): string {
422
+ if (file.type?.startsWith('image/')) {
423
+ return `![](${url})`;
424
+ }
425
+
426
+ if (file.type?.startsWith('video/')) {
427
+ const mediaType = file.type || 'video/mp4';
428
+ return `<video width="400">\n<!-- For gif-like videos use: <video width="400" autoplay loop muted playsinline> -->\n <source src="${url}" type="${mediaType}">\n</video>`;
429
+ }
430
+
431
+ // alert that file cant be uploaded
432
+ showAdminforthError(`Uploaded file "${file.name}" is not an image or video and cannot be embedded. It has been uploaded and can be accessed at: ${url}`);
433
+ }
434
+
435
+ async function uploadFileAndGetMarkdownTag(file: File): Promise<string | undefined> {
436
+ try {
437
+ const url = await uploadFileToS3(file);
438
+ if (!url) return;
439
+ return markdownForUploadedFile(file, url);
440
+ } catch (error) {
441
+ const message = extractErrorMessage(error);
442
+ showAdminforthError(message);
443
+ console.error('[adminforth-markdown] upload failed', error);
444
+ return;
445
+ }
446
+ }
447
+
390
448
  onMounted(async () => {
391
449
  if (!editorContainer.value) return;
392
450
  try {
@@ -433,6 +491,59 @@ onMounted(async () => {
433
491
  const noopPaste = () => {};
434
492
  domNode.addEventListener('paste', noopPaste, true);
435
493
  removePasteListener = () => domNode.removeEventListener('paste', noopPaste, true);
494
+
495
+ const onDragOver = (e: DragEvent) => {
496
+ if (!e.dataTransfer) return;
497
+ if (!Array.from(e.dataTransfer.types).includes('Files')) return;
498
+ e.preventDefault();
499
+ e.dataTransfer.dropEffect = 'copy';
500
+ };
501
+
502
+ const onDrop = async (e: DragEvent) => {
503
+ const dt = e.dataTransfer;
504
+ if (!dt) return;
505
+ if (!dt.files || !dt.files.length) return;
506
+
507
+ e.preventDefault();
508
+ e.stopPropagation();
509
+
510
+ if (!editor) return;
511
+ editor.focus();
512
+
513
+ const target = editor.getTargetAtClientPoint(e.clientX, e.clientY);
514
+ const dropPosition = target?.position || target?.range?.getStartPosition?.();
515
+ if (dropPosition) {
516
+ editor.setPosition(dropPosition);
517
+ editor.setSelection(new monaco.Selection(
518
+ dropPosition.lineNumber,
519
+ dropPosition.column,
520
+ dropPosition.lineNumber,
521
+ dropPosition.column,
522
+ ));
523
+ }
524
+
525
+ if (!props.meta?.uploadPluginInstanceId) {
526
+ const msg = 'uploadPluginInstanceId is missing; cannot upload dropped file.';
527
+ showAdminforthError(msg);
528
+ console.error('[adminforth-markdown]', msg);
529
+ return;
530
+ }
531
+
532
+ const markdownTags: string[] = [];
533
+ for (const file of Array.from(dt.files)) {
534
+ const tag = await uploadFileAndGetMarkdownTag(file);
535
+ if (tag) markdownTags.push(tag);
536
+ }
537
+
538
+ if (markdownTags.length) {
539
+ insertAtCursor(`${markdownTags.join('\n\n')}\n`);
540
+ }
541
+ };
542
+
543
+ domNode.addEventListener('dragover', onDragOver, true);
544
+ domNode.addEventListener('drop', onDrop, true);
545
+ removeDragOverListener = () => domNode.removeEventListener('dragover', onDragOver, true);
546
+ removeDropListener = () => domNode.removeEventListener('drop', onDrop, true);
436
547
  }
437
548
  if (editorContainer.value) {
438
549
  const noopPaste = () => {};
@@ -496,15 +607,9 @@ onMounted(async () => {
496
607
  const markdownTags: string[] = [];
497
608
  for (const blob of imageBlobs) {
498
609
  const file = blob instanceof File ? blob : fileFromClipboardImage(blob);
499
- try {
500
- const url = await uploadFileToS3(file);
501
- debug('upload result', { url });
502
- if (typeof url === 'string' && url.length) {
503
- markdownTags.push(`![](${url})`);
504
- }
505
- } catch (err) {
506
- console.error('[adminforth-markdown] upload failed', err);
507
- }
610
+ const tag = await uploadFileAndGetMarkdownTag(file);
611
+ if (!tag) continue;
612
+ markdownTags.push(tag);
508
613
  }
509
614
 
510
615
  if (markdownTags.length) {
@@ -570,7 +675,7 @@ async function uploadFileToS3(file: File): Promise<string | undefined> {
570
675
  const originalFilename = file.name.split('.').slice(0, -1).join('.');
571
676
  const originalExtension = file.name.split('.').pop();
572
677
 
573
- const { uploadUrl, tagline, previewUrl, s3Path, error } = await callAdminForthApi({
678
+ const { uploadUrl, tagline, previewUrl, error } = await callAdminForthApi({
574
679
  path: `/plugin/${props.meta.uploadPluginInstanceId}/get_file_upload_url`,
575
680
  method: 'POST',
576
681
  body: {
@@ -582,8 +687,13 @@ async function uploadFileToS3(file: File): Promise<string | undefined> {
582
687
  });
583
688
 
584
689
  if (error) {
585
- console.error('Upload failed:', error);
586
- return;
690
+ const message = extractErrorMessage(error);
691
+ if (/too\s*large|max\s*file\s*size|size\s*limit|limit\s*reached|exceed/i.test(message)) {
692
+ showAdminforthError(message);
693
+ } else {
694
+ showAdminforthError(message);
695
+ }
696
+ throw new Error(message);
587
697
  }
588
698
 
589
699
  const xhr = new XMLHttpRequest();
@@ -597,12 +707,16 @@ async function uploadFileToS3(file: File): Promise<string | undefined> {
597
707
  if (xhr.status === 200) {
598
708
  resolve(previewUrl as string);
599
709
  } else {
600
- reject('Error uploading to S3');
710
+ const message = `Error uploading to S3 (status ${xhr.status})`;
711
+ showAdminforthError(message);
712
+ reject(message);
601
713
  }
602
714
  };
603
715
 
604
716
  xhr.onerror = () => {
605
- reject('Error uploading to S3');
717
+ const message = 'Error uploading to S3';
718
+ showAdminforthError(message);
719
+ reject(message);
606
720
  };
607
721
  });
608
722
  }
@@ -627,6 +741,12 @@ onBeforeUnmount(() => {
627
741
  removeGlobalKeydownListener?.();
628
742
  removeGlobalKeydownListener = null;
629
743
 
744
+ removeDragOverListener?.();
745
+ removeDragOverListener = null;
746
+
747
+ removeDropListener?.();
748
+ removeDropListener = null;
749
+
630
750
  for (const d of disposables) d.dispose();
631
751
  disposables.length = 0;
632
752
 
@@ -240,6 +240,8 @@ let removePasteListener: (() => void) | null = null;
240
240
  let removePasteListenerSecondary: (() => void) | null = null;
241
241
  let removeGlobalPasteListener: (() => void) | null = null;
242
242
  let removeGlobalKeydownListener: (() => void) | null = null;
243
+ let removeDragOverListener: (() => void) | null = null;
244
+ let removeDropListener: (() => void) | null = null;
243
245
 
244
246
  type MarkdownImageRef = {
245
247
  lineNumber: number;
@@ -387,6 +389,62 @@ function fileFromClipboardImage(blob: Blob): File {
387
389
  return new File([blob], filename, { type });
388
390
  }
389
391
 
392
+ function escapeMarkdownLinkText(text: string): string {
393
+ return text.replace(/[\[\]\\]/g, '\\$&');
394
+ }
395
+
396
+ function showAdminforthError(message: string) {
397
+ const api = (window as any).adminforth;
398
+ if (api && typeof api.alert === 'function') {
399
+ api.alert({
400
+ message,
401
+ variant: 'danger',
402
+ timeout: 30,
403
+ });
404
+ return;
405
+ }
406
+ console.error('[adminforth-markdown]', message);
407
+ }
408
+
409
+ function extractErrorMessage(error: any): string {
410
+ if (!error) return 'Upload failed';
411
+ if (typeof error === 'string') return error;
412
+ if (typeof error?.error === 'string') return error.error;
413
+ if (typeof error?.message === 'string') return error.message;
414
+ try {
415
+ return JSON.stringify(error);
416
+ } catch {
417
+ return 'Upload failed';
418
+ }
419
+ }
420
+
421
+ function markdownForUploadedFile(file: File, url: string): string {
422
+ if (file.type?.startsWith('image/')) {
423
+ return `![](${url})`;
424
+ }
425
+
426
+ if (file.type?.startsWith('video/')) {
427
+ const mediaType = file.type || 'video/mp4';
428
+ return `<video width="400">\n<!-- For gif-like videos use: <video width="400" autoplay loop muted playsinline> -->\n <source src="${url}" type="${mediaType}">\n</video>`;
429
+ }
430
+
431
+ // alert that file cant be uploaded
432
+ showAdminforthError(`Uploaded file "${file.name}" is not an image or video and cannot be embedded. It has been uploaded and can be accessed at: ${url}`);
433
+ }
434
+
435
+ async function uploadFileAndGetMarkdownTag(file: File): Promise<string | undefined> {
436
+ try {
437
+ const url = await uploadFileToS3(file);
438
+ if (!url) return;
439
+ return markdownForUploadedFile(file, url);
440
+ } catch (error) {
441
+ const message = extractErrorMessage(error);
442
+ showAdminforthError(message);
443
+ console.error('[adminforth-markdown] upload failed', error);
444
+ return;
445
+ }
446
+ }
447
+
390
448
  onMounted(async () => {
391
449
  if (!editorContainer.value) return;
392
450
  try {
@@ -433,6 +491,59 @@ onMounted(async () => {
433
491
  const noopPaste = () => {};
434
492
  domNode.addEventListener('paste', noopPaste, true);
435
493
  removePasteListener = () => domNode.removeEventListener('paste', noopPaste, true);
494
+
495
+ const onDragOver = (e: DragEvent) => {
496
+ if (!e.dataTransfer) return;
497
+ if (!Array.from(e.dataTransfer.types).includes('Files')) return;
498
+ e.preventDefault();
499
+ e.dataTransfer.dropEffect = 'copy';
500
+ };
501
+
502
+ const onDrop = async (e: DragEvent) => {
503
+ const dt = e.dataTransfer;
504
+ if (!dt) return;
505
+ if (!dt.files || !dt.files.length) return;
506
+
507
+ e.preventDefault();
508
+ e.stopPropagation();
509
+
510
+ if (!editor) return;
511
+ editor.focus();
512
+
513
+ const target = editor.getTargetAtClientPoint(e.clientX, e.clientY);
514
+ const dropPosition = target?.position || target?.range?.getStartPosition?.();
515
+ if (dropPosition) {
516
+ editor.setPosition(dropPosition);
517
+ editor.setSelection(new monaco.Selection(
518
+ dropPosition.lineNumber,
519
+ dropPosition.column,
520
+ dropPosition.lineNumber,
521
+ dropPosition.column,
522
+ ));
523
+ }
524
+
525
+ if (!props.meta?.uploadPluginInstanceId) {
526
+ const msg = 'uploadPluginInstanceId is missing; cannot upload dropped file.';
527
+ showAdminforthError(msg);
528
+ console.error('[adminforth-markdown]', msg);
529
+ return;
530
+ }
531
+
532
+ const markdownTags: string[] = [];
533
+ for (const file of Array.from(dt.files)) {
534
+ const tag = await uploadFileAndGetMarkdownTag(file);
535
+ if (tag) markdownTags.push(tag);
536
+ }
537
+
538
+ if (markdownTags.length) {
539
+ insertAtCursor(`${markdownTags.join('\n\n')}\n`);
540
+ }
541
+ };
542
+
543
+ domNode.addEventListener('dragover', onDragOver, true);
544
+ domNode.addEventListener('drop', onDrop, true);
545
+ removeDragOverListener = () => domNode.removeEventListener('dragover', onDragOver, true);
546
+ removeDropListener = () => domNode.removeEventListener('drop', onDrop, true);
436
547
  }
437
548
  if (editorContainer.value) {
438
549
  const noopPaste = () => {};
@@ -496,15 +607,9 @@ onMounted(async () => {
496
607
  const markdownTags: string[] = [];
497
608
  for (const blob of imageBlobs) {
498
609
  const file = blob instanceof File ? blob : fileFromClipboardImage(blob);
499
- try {
500
- const url = await uploadFileToS3(file);
501
- debug('upload result', { url });
502
- if (typeof url === 'string' && url.length) {
503
- markdownTags.push(`![](${url})`);
504
- }
505
- } catch (err) {
506
- console.error('[adminforth-markdown] upload failed', err);
507
- }
610
+ const tag = await uploadFileAndGetMarkdownTag(file);
611
+ if (!tag) continue;
612
+ markdownTags.push(tag);
508
613
  }
509
614
 
510
615
  if (markdownTags.length) {
@@ -570,7 +675,7 @@ async function uploadFileToS3(file: File): Promise<string | undefined> {
570
675
  const originalFilename = file.name.split('.').slice(0, -1).join('.');
571
676
  const originalExtension = file.name.split('.').pop();
572
677
 
573
- const { uploadUrl, tagline, previewUrl, s3Path, error } = await callAdminForthApi({
678
+ const { uploadUrl, tagline, previewUrl, error } = await callAdminForthApi({
574
679
  path: `/plugin/${props.meta.uploadPluginInstanceId}/get_file_upload_url`,
575
680
  method: 'POST',
576
681
  body: {
@@ -582,8 +687,13 @@ async function uploadFileToS3(file: File): Promise<string | undefined> {
582
687
  });
583
688
 
584
689
  if (error) {
585
- console.error('Upload failed:', error);
586
- return;
690
+ const message = extractErrorMessage(error);
691
+ if (/too\s*large|max\s*file\s*size|size\s*limit|limit\s*reached|exceed/i.test(message)) {
692
+ showAdminforthError(message);
693
+ } else {
694
+ showAdminforthError(message);
695
+ }
696
+ throw new Error(message);
587
697
  }
588
698
 
589
699
  const xhr = new XMLHttpRequest();
@@ -597,12 +707,16 @@ async function uploadFileToS3(file: File): Promise<string | undefined> {
597
707
  if (xhr.status === 200) {
598
708
  resolve(previewUrl as string);
599
709
  } else {
600
- reject('Error uploading to S3');
710
+ const message = `Error uploading to S3 (status ${xhr.status})`;
711
+ showAdminforthError(message);
712
+ reject(message);
601
713
  }
602
714
  };
603
715
 
604
716
  xhr.onerror = () => {
605
- reject('Error uploading to S3');
717
+ const message = 'Error uploading to S3';
718
+ showAdminforthError(message);
719
+ reject(message);
606
720
  };
607
721
  });
608
722
  }
@@ -627,6 +741,12 @@ onBeforeUnmount(() => {
627
741
  removeGlobalKeydownListener?.();
628
742
  removeGlobalKeydownListener = null;
629
743
 
744
+ removeDragOverListener?.();
745
+ removeDragOverListener = null;
746
+
747
+ removeDropListener?.();
748
+ removeDropListener = null;
749
+
630
750
  for (const d of disposables) d.dispose();
631
751
  disposables.length = 0;
632
752
 
package/dist/index.js CHANGED
@@ -17,6 +17,14 @@ export default class MarkdownPlugin extends AdminForthPlugin {
17
17
  instanceUniqueRepresentation(pluginOptions) {
18
18
  return pluginOptions.fieldName;
19
19
  }
20
+ // Placeholder for future Upload Plugin API integration.
21
+ // For now, treat all extracted URLs as plugin-owned public URLs.
22
+ isPluginPublicUrl(_url) {
23
+ // todo: here we need to check that host name is same as upload plugin, probably create upload plugin endpoint
24
+ // should handle cases that user might define custom preview url
25
+ // and that local storage has no host name, here, the fact of luck of hostname might be used as
26
+ return true;
27
+ }
20
28
  validateConfigAfterDiscover(adminforth, resourceConfig) {
21
29
  this.adminforth = adminforth;
22
30
  const column = resourceConfig.columns.find(c => c.name === this.options.fieldName);
@@ -125,34 +133,80 @@ export default class MarkdownPlugin extends AdminForthPlugin {
125
133
  return stripQueryAndHash(url).replace(/^https?:\/\/[^\/]+\/+/, '').replace(/^\/+/, '');
126
134
  }
127
135
  };
136
+ const shouldTrackUrl = (url) => {
137
+ try {
138
+ return this.isPluginPublicUrl(url);
139
+ }
140
+ catch (err) {
141
+ console.error('Error checking URL ownership', url, err);
142
+ return false;
143
+ }
144
+ };
145
+ const getKeyFromTrackedUrl = (rawUrl) => {
146
+ const srcTrimmed = rawUrl.trim().replace(/^<|>$/g, '');
147
+ if (!srcTrimmed || srcTrimmed.startsWith('data:') || srcTrimmed.startsWith('javascript:')) {
148
+ return null;
149
+ }
150
+ if (!shouldTrackUrl(srcTrimmed)) {
151
+ return null;
152
+ }
153
+ const srcNoQuery = stripQueryAndHash(srcTrimmed);
154
+ const key = extractKeyFromUrl(srcNoQuery);
155
+ if (!key) {
156
+ return null;
157
+ }
158
+ return key;
159
+ };
160
+ const upsertMeta = (byKey, key, next) => {
161
+ var _a, _b;
162
+ const existing = byKey.get(key);
163
+ if (!existing) {
164
+ byKey.set(key, {
165
+ key,
166
+ alt: (_a = next.alt) !== null && _a !== void 0 ? _a : null,
167
+ title: (_b = next.title) !== null && _b !== void 0 ? _b : null,
168
+ });
169
+ return;
170
+ }
171
+ if ((existing.alt === null || existing.alt === '') && next.alt !== undefined && next.alt !== null) {
172
+ existing.alt = next.alt;
173
+ }
174
+ if ((existing.title === null || existing.title === '') && next.title !== undefined && next.title !== null) {
175
+ existing.title = next.title;
176
+ }
177
+ };
128
178
  function getAttachmentMetas(markdown) {
129
- var _a, _b, _c;
179
+ var _a, _b, _c, _d, _e, _f;
130
180
  if (!markdown) {
131
181
  return [];
132
182
  }
133
- // Minimal image syntax: ![alt](src) or ![alt](src "title") or ![alt](src 'title')
134
- // We track external (http/https) and relative sources, but skip data: URLs.
183
+ // Markdown image syntax: ![alt](src) or ![alt](src "title") or ![alt](src 'title')
135
184
  const imageRegex = /!\[([^\]]*)\]\(\s*([^\s)]+)\s*(?:\s+(?:\"([^\"]*)\"|'([^']*)'))?\s*\)/g;
185
+ // HTML embedded media links.
186
+ const htmlSrcRegex = /<(?:source|video)\b[^>]*\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s"'=<>`]+))[^>]*>/gi;
136
187
  const byKey = new Map();
137
188
  for (const match of markdown.matchAll(imageRegex)) {
138
189
  const altRaw = (_a = match[1]) !== null && _a !== void 0 ? _a : '';
139
190
  const srcRaw = match[2];
140
191
  const titleRaw = (_c = ((_b = match[3]) !== null && _b !== void 0 ? _b : match[4])) !== null && _c !== void 0 ? _c : null;
141
- const srcTrimmed = srcRaw.trim().replace(/^<|>$/g, '');
142
- if (!srcTrimmed || srcTrimmed.startsWith('data:')) {
143
- continue;
144
- }
145
- const srcNoQuery = stripQueryAndHash(srcTrimmed);
146
- const key = extractKeyFromUrl(srcNoQuery);
192
+ const key = getKeyFromTrackedUrl(srcRaw);
147
193
  if (!key) {
148
194
  continue;
149
195
  }
150
- byKey.set(key, {
151
- key,
196
+ upsertMeta(byKey, key, {
152
197
  alt: altRaw,
153
198
  title: titleRaw,
154
199
  });
155
200
  }
201
+ let srcMatch;
202
+ while ((srcMatch = htmlSrcRegex.exec(markdown)) !== null) {
203
+ const srcRaw = (_f = (_e = (_d = srcMatch[1]) !== null && _d !== void 0 ? _d : srcMatch[2]) !== null && _e !== void 0 ? _e : srcMatch[3]) !== null && _f !== void 0 ? _f : '';
204
+ const key = getKeyFromTrackedUrl(srcRaw);
205
+ if (!key) {
206
+ continue;
207
+ }
208
+ upsertMeta(byKey, key, {});
209
+ }
156
210
  return [...byKey.values()];
157
211
  }
158
212
  const createAttachmentRecords = (adminforth, options, recordId, metas, adminUser) => __awaiter(this, void 0, void 0, function* () {
package/index.ts CHANGED
@@ -17,6 +17,15 @@ export default class MarkdownPlugin extends AdminForthPlugin {
17
17
  return pluginOptions.fieldName;
18
18
  }
19
19
 
20
+ // Placeholder for future Upload Plugin API integration.
21
+ // For now, treat all extracted URLs as plugin-owned public URLs.
22
+ isPluginPublicUrl(_url: string): boolean {
23
+ // todo: here we need to check that host name is same as upload plugin, probably create upload plugin endpoint
24
+ // should handle cases that user might define custom preview url
25
+ // and that local storage has no host name, here, the fact of luck of hostname might be used as
26
+ return true;
27
+ }
28
+
20
29
  validateConfigAfterDiscover(adminforth: IAdminForth, resourceConfig: AdminForthResource) {
21
30
  this.adminforth = adminforth;
22
31
  const column = resourceConfig.columns.find(c => c.name === this.options.fieldName);
@@ -137,37 +146,91 @@ export default class MarkdownPlugin extends AdminForthPlugin {
137
146
  }
138
147
  };
139
148
 
149
+ const shouldTrackUrl = (url: string) => {
150
+ try {
151
+ return this.isPluginPublicUrl(url);
152
+ } catch (err) {
153
+ console.error('Error checking URL ownership', url, err);
154
+ return false;
155
+ }
156
+ };
157
+
158
+ const getKeyFromTrackedUrl = (rawUrl: string): string | null => {
159
+ const srcTrimmed = rawUrl.trim().replace(/^<|>$/g, '');
160
+ if (!srcTrimmed || srcTrimmed.startsWith('data:') || srcTrimmed.startsWith('javascript:')) {
161
+ return null;
162
+ }
163
+ if (!shouldTrackUrl(srcTrimmed)) {
164
+ return null;
165
+ }
166
+ const srcNoQuery = stripQueryAndHash(srcTrimmed);
167
+ const key = extractKeyFromUrl(srcNoQuery);
168
+ if (!key) {
169
+ return null;
170
+ }
171
+ return key;
172
+ };
173
+
174
+ const upsertMeta = (
175
+ byKey: Map<string, AttachmentMeta>,
176
+ key: string,
177
+ next: { alt?: string | null; title?: string | null }
178
+ ) => {
179
+ const existing = byKey.get(key);
180
+ if (!existing) {
181
+ byKey.set(key, {
182
+ key,
183
+ alt: next.alt ?? null,
184
+ title: next.title ?? null,
185
+ });
186
+ return;
187
+ }
188
+
189
+ if ((existing.alt === null || existing.alt === '') && next.alt !== undefined && next.alt !== null) {
190
+ existing.alt = next.alt;
191
+ }
192
+ if ((existing.title === null || existing.title === '') && next.title !== undefined && next.title !== null) {
193
+ existing.title = next.title;
194
+ }
195
+ };
196
+
140
197
  function getAttachmentMetas(markdown: string): AttachmentMeta[] {
141
198
  if (!markdown) {
142
199
  return [];
143
200
  }
144
201
 
145
- // Minimal image syntax: ![alt](src) or ![alt](src "title") or ![alt](src 'title')
146
- // We track external (http/https) and relative sources, but skip data: URLs.
202
+ // Markdown image syntax: ![alt](src) or ![alt](src "title") or ![alt](src 'title')
147
203
  const imageRegex = /!\[([^\]]*)\]\(\s*([^\s)]+)\s*(?:\s+(?:\"([^\"]*)\"|'([^']*)'))?\s*\)/g;
148
204
 
205
+ // HTML embedded media links.
206
+ const htmlSrcRegex = /<(?:source|video)\b[^>]*\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s"'=<>`]+))[^>]*>/gi;
207
+
149
208
  const byKey = new Map<string, AttachmentMeta>();
150
209
  for (const match of markdown.matchAll(imageRegex)) {
151
210
  const altRaw = match[1] ?? '';
152
211
  const srcRaw = match[2];
153
212
  const titleRaw = (match[3] ?? match[4]) ?? null;
154
213
 
155
- const srcTrimmed = srcRaw.trim().replace(/^<|>$/g, '');
156
- if (!srcTrimmed || srcTrimmed.startsWith('data:')) {
157
- continue;
158
- }
159
-
160
- const srcNoQuery = stripQueryAndHash(srcTrimmed);
161
- const key = extractKeyFromUrl(srcNoQuery);
214
+ const key = getKeyFromTrackedUrl(srcRaw);
162
215
  if (!key) {
163
216
  continue;
164
217
  }
165
- byKey.set(key, {
166
- key,
218
+ upsertMeta(byKey, key, {
167
219
  alt: altRaw,
168
220
  title: titleRaw,
169
221
  });
170
222
  }
223
+
224
+ let srcMatch: RegExpExecArray | null;
225
+ while ((srcMatch = htmlSrcRegex.exec(markdown)) !== null) {
226
+ const srcRaw = srcMatch[1] ?? srcMatch[2] ?? srcMatch[3] ?? '';
227
+ const key = getKeyFromTrackedUrl(srcRaw);
228
+ if (!key) {
229
+ continue;
230
+ }
231
+ upsertMeta(byKey, key, {});
232
+ }
233
+
171
234
  return [...byKey.values()];
172
235
  }
173
236
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/markdown",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Markdown plugin for adminforth",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",