@adminforth/markdown 1.4.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);
@@ -111,31 +119,94 @@ export default class MarkdownPlugin extends AdminForthPlugin {
111
119
  };
112
120
  const editorRecordPkField = resourceConfig.columns.find(c => c.primaryKey);
113
121
  if (this.options.attachments) {
114
- const extractKeyFromUrl = (url) => url.replace(/^https:\/\/[^\/]+\/+/, '');
122
+ const stripQueryAndHash = (value) => value.split('#')[0].split('?')[0];
123
+ const extractKeyFromUrl = (url) => {
124
+ // Supports absolute https/http URLs and protocol-relative URLs.
125
+ // Returns the object key as a path without leading slashes.
126
+ try {
127
+ const normalized = url.startsWith('//') ? `https:${url}` : url;
128
+ const u = new URL(normalized);
129
+ return u.pathname.replace(/^\/+/, '');
130
+ }
131
+ catch (_a) {
132
+ // Fallback: strip scheme/host if it looks like a URL, otherwise treat as a path.
133
+ return stripQueryAndHash(url).replace(/^https?:\/\/[^\/]+\/+/, '').replace(/^\/+/, '');
134
+ }
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
+ };
115
178
  function getAttachmentMetas(markdown) {
116
- var _a, _b, _c;
179
+ var _a, _b, _c, _d, _e, _f;
117
180
  if (!markdown) {
118
181
  return [];
119
182
  }
120
- // Minimal image syntax: ![alt](src) or ![alt](src "title") or ![alt](src 'title')
121
- // We only track https URLs and only those that look like S3/AWS public URLs.
122
- const imageRegex = /!\[([^\]]*)\]\(\s*(https:\/\/[^\s)]+)\s*(?:\s+(?:"([^"]*)"|'([^']*)'))?\s*\)/g;
183
+ // Markdown image syntax: ![alt](src) or ![alt](src "title") or ![alt](src 'title')
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;
123
187
  const byKey = new Map();
124
188
  for (const match of markdown.matchAll(imageRegex)) {
125
189
  const altRaw = (_a = match[1]) !== null && _a !== void 0 ? _a : '';
126
190
  const srcRaw = match[2];
127
191
  const titleRaw = (_c = ((_b = match[3]) !== null && _b !== void 0 ? _b : match[4])) !== null && _c !== void 0 ? _c : null;
128
- const srcNoQuery = srcRaw.split('?')[0];
129
- if (!srcNoQuery.includes('s3') && !srcNoQuery.includes('amazonaws')) {
192
+ const key = getKeyFromTrackedUrl(srcRaw);
193
+ if (!key) {
130
194
  continue;
131
195
  }
132
- const key = extractKeyFromUrl(srcNoQuery);
133
- byKey.set(key, {
134
- key,
196
+ upsertMeta(byKey, key, {
135
197
  alt: altRaw,
136
198
  title: titleRaw,
137
199
  });
138
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
+ }
139
210
  return [...byKey.values()];
140
211
  }
141
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);
@@ -122,16 +131,79 @@ export default class MarkdownPlugin extends AdminForthPlugin {
122
131
 
123
132
  type AttachmentMeta = { key: string; alt: string | null; title: string | null };
124
133
 
125
- const extractKeyFromUrl = (url: string) => url.replace(/^https:\/\/[^\/]+\/+/, '');
134
+ const stripQueryAndHash = (value: string) => value.split('#')[0].split('?')[0];
135
+
136
+ const extractKeyFromUrl = (url: string) => {
137
+ // Supports absolute https/http URLs and protocol-relative URLs.
138
+ // Returns the object key as a path without leading slashes.
139
+ try {
140
+ const normalized = url.startsWith('//') ? `https:${url}` : url;
141
+ const u = new URL(normalized);
142
+ return u.pathname.replace(/^\/+/, '');
143
+ } catch {
144
+ // Fallback: strip scheme/host if it looks like a URL, otherwise treat as a path.
145
+ return stripQueryAndHash(url).replace(/^https?:\/\/[^\/]+\/+/, '').replace(/^\/+/, '');
146
+ }
147
+ };
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
+ };
126
196
 
127
197
  function getAttachmentMetas(markdown: string): AttachmentMeta[] {
128
198
  if (!markdown) {
129
199
  return [];
130
200
  }
131
201
 
132
- // Minimal image syntax: ![alt](src) or ![alt](src "title") or ![alt](src 'title')
133
- // We only track https URLs and only those that look like S3/AWS public URLs.
134
- const imageRegex = /!\[([^\]]*)\]\(\s*(https:\/\/[^\s)]+)\s*(?:\s+(?:"([^"]*)"|'([^']*)'))?\s*\)/g;
202
+ // Markdown image syntax: ![alt](src) or ![alt](src "title") or ![alt](src 'title')
203
+ const imageRegex = /!\[([^\]]*)\]\(\s*([^\s)]+)\s*(?:\s+(?:\"([^\"]*)\"|'([^']*)'))?\s*\)/g;
204
+
205
+ // HTML embedded media links.
206
+ const htmlSrcRegex = /<(?:source|video)\b[^>]*\bsrc\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s"'=<>`]+))[^>]*>/gi;
135
207
 
136
208
  const byKey = new Map<string, AttachmentMeta>();
137
209
  for (const match of markdown.matchAll(imageRegex)) {
@@ -139,18 +211,26 @@ export default class MarkdownPlugin extends AdminForthPlugin {
139
211
  const srcRaw = match[2];
140
212
  const titleRaw = (match[3] ?? match[4]) ?? null;
141
213
 
142
- const srcNoQuery = srcRaw.split('?')[0];
143
- if (!srcNoQuery.includes('s3') && !srcNoQuery.includes('amazonaws')) {
214
+ const key = getKeyFromTrackedUrl(srcRaw);
215
+ if (!key) {
144
216
  continue;
145
217
  }
146
-
147
- const key = extractKeyFromUrl(srcNoQuery);
148
- byKey.set(key, {
149
- key,
218
+ upsertMeta(byKey, key, {
150
219
  alt: altRaw,
151
220
  title: titleRaw,
152
221
  });
153
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
+
154
234
  return [...byKey.values()];
155
235
  }
156
236
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adminforth/markdown",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "Markdown plugin for adminforth",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",