@isrd-isi-edu/ermrestjs 2.3.0 → 2.4.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/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@isrd-isi-edu/ermrestjs",
3
3
  "description": "ERMrest client library in JavaScript",
4
- "version": "2.3.0",
4
+ "version": "2.4.0",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
7
7
  "node": ">= 20.0.0",
8
- "npm": ">=6.0.0"
8
+ "npm": ">= 7.0.0"
9
9
  },
10
10
  "main": "dist/ermrest.js",
11
11
  "module": "dist/ermrest.min.js",
@@ -42,11 +42,16 @@
42
42
  "postgresql",
43
43
  "library"
44
44
  ],
45
+ "overrides": {
46
+ "@microsoft/api-extractor": {
47
+ "typescript": "$typescript"
48
+ }
49
+ },
45
50
  "dependencies": {
46
51
  "@types/lodash-es": "^4.17.12",
47
52
  "@types/markdown-it": "^14.1.2",
48
53
  "@types/q": "^1.5.8",
49
- "axios": "1.13.2",
54
+ "axios": "1.13.5",
50
55
  "handlebars": "4.7.8",
51
56
  "lodash-es": "^4.17.23",
52
57
  "lz-string": "^1.5.0",
@@ -455,13 +455,18 @@ export class AssetPseudoColumn extends ReferenceColumn {
455
455
  */
456
456
  get filePreview(): FilePreviewConfig | null {
457
457
  if (this._filePreview === undefined) {
458
- const disp = this._annotation.display;
459
- const currDisplay = isObjectAndNotNull(disp) ? _getAnnotationValueByContext(this._context, disp) : null;
460
- const settings = isObjectAndKeyExists(currDisplay, 'file_preview') ? currDisplay.file_preview : {};
461
- if (settings === false) {
458
+ // if the colum has markdown-pattern, don't show the file preview
459
+ if (this.display.sourceMarkdownPattern || this._baseCol.getDisplay(this._context).isMarkdownPattern) {
462
460
  this._filePreview = null;
463
461
  } else {
464
- this._filePreview = new FilePreviewConfig(settings);
462
+ const disp = this._annotation.display;
463
+ const currDisplay = isObjectAndNotNull(disp) ? _getAnnotationValueByContext(this._context, disp) : null;
464
+ const settings = isObjectAndKeyExists(currDisplay, 'file_preview') ? currDisplay.file_preview : {};
465
+ if (settings === false) {
466
+ this._filePreview = null;
467
+ } else {
468
+ this._filePreview = new FilePreviewConfig(settings);
469
+ }
465
470
  }
466
471
  }
467
472
  return this._filePreview;
@@ -98,13 +98,18 @@ export default class FilePreviewService {
98
98
  storedFilename?: string,
99
99
  contentDisposition?: string,
100
100
  contentType?: string,
101
+ forcedPreviewType?: FilePreviewTypes,
102
+ forcedPrefetchBytes?: number,
103
+ forcedPrefetchMaxFileSize?: number,
101
104
  ): {
102
105
  previewType: FilePreviewTypes | null;
103
106
  prefetchBytes: number | null;
104
107
  prefetchMaxFileSize: number | null;
108
+ filename: string;
105
109
  } {
106
- const disabledValue = { previewType: null, prefetchBytes: null, prefetchMaxFileSize: null };
107
- const previewType = FilePreviewService.getFilePreviewType(url, column, storedFilename, contentDisposition, contentType);
110
+ const disabledValue = { previewType: null, prefetchBytes: null, prefetchMaxFileSize: null, filename: '' };
111
+ const filename = storedFilename || getFilename(url, contentDisposition);
112
+ const previewType = forcedPreviewType ? forcedPreviewType : FilePreviewService.getFilePreviewType(url, filename, column, contentType);
108
113
  let prefetchBytes: number | null = null;
109
114
  let prefetchMaxFileSize: number | null = null;
110
115
 
@@ -127,30 +132,31 @@ export default class FilePreviewService {
127
132
  prefetchMaxFileSize = FILE_PREVIEW.MAX_FILE_SIZE;
128
133
  }
129
134
 
135
+ // the forced (manual) setting cannot exceed the column/default settings
136
+ if (typeof forcedPrefetchBytes === 'number' && forcedPrefetchBytes >= 0 && forcedPrefetchBytes < prefetchBytes) {
137
+ prefetchBytes = forcedPrefetchBytes;
138
+ }
139
+ if (typeof forcedPrefetchMaxFileSize === 'number' && forcedPrefetchMaxFileSize >= 0 && forcedPrefetchMaxFileSize < prefetchMaxFileSize) {
140
+ prefetchMaxFileSize = forcedPrefetchMaxFileSize;
141
+ }
142
+
130
143
  // if prefetchMaxFileSize is 0, we should not show the preview
131
144
  if (prefetchMaxFileSize === 0) {
132
145
  return disabledValue;
133
146
  }
134
147
 
135
- return { previewType, prefetchBytes, prefetchMaxFileSize };
148
+ return { previewType, prefetchBytes, prefetchMaxFileSize, filename };
136
149
  }
137
150
 
138
151
  /**
139
152
  * Returns the preview type based on the given file properties and the column's file preview settings.
140
153
  * @param url the file url
141
154
  * @param column the asset column
142
- * @param storedFilename the stored filename
155
+ * @param filename the filename (either stored filename or from content-disposition or url)
143
156
  * @param contentDisposition content-disposition header value
144
157
  * @param contentType content-type header value
145
158
  */
146
- private static getFilePreviewType(
147
- url: string,
148
- column?: AssetPseudoColumn,
149
- storedFilename?: string,
150
- contentDisposition?: string,
151
- contentType?: string,
152
- ): FilePreviewTypes | null {
153
- const filename = storedFilename || getFilename(url, contentDisposition);
159
+ private static getFilePreviewType(url: string, filename: string, column?: AssetPseudoColumn, contentType?: string): FilePreviewTypes | null {
154
160
  const extension = getFilenameExtension(filename, column?.filenameExtFilter, column?.filenameExtRegexp);
155
161
  let mappedFilePreviewType: FilePreviewTypes | typeof USE_EXT_MAPPING | false = USE_EXT_MAPPING;
156
162
 
@@ -0,0 +1,291 @@
1
+ import type MarkdownIt from 'markdown-it';
2
+
3
+ /**
4
+ * Custom div block plugin for markdown-it
5
+ * Supports:
6
+ * - Multi-line syntax with attributes: :::div {.class #id attr="value"}\nContent\n:::
7
+ * - Nesting with colon counting: :::, ::::, :::::, etc.
8
+ * - Single-line syntax: :::div content {.class}\n:::
9
+ */
10
+
11
+ const MARKER_CHAR = 0x3a; // ':'
12
+ const MIN_MARKERS = 3;
13
+
14
+ // Store markdown-it instance for use in renderers
15
+ let mdInstance: any = null;
16
+
17
+ /**
18
+ * Unwrap paragraph tags that contain only a single element, but only within div tags
19
+ * This handles cases like <div><p><img></p></div> -> <div><img></div>
20
+ */
21
+ function unwrapSingleElementParagraphsInDivs(html: string): string {
22
+ // Pattern to match <div...>...</div> including nested divs
23
+ const divPattern = /<div([^>]*)>([\s\S]*?)<\/div>/g;
24
+
25
+ return html.replace(divPattern, (match, divAttrs, divContent) => {
26
+ // Apply paragraph unwrapping only to content inside this div
27
+ const unwrappedContent = unwrapParagraphsInContent(divContent);
28
+ return `<div${divAttrs}>${unwrappedContent}</div>`;
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Unwrap paragraph tags containing single elements from the given content
34
+ */
35
+ function unwrapParagraphsInContent(content: string): string {
36
+ const pTagPattern = /<p([^>]*)>(.*?)<\/p>/gs;
37
+
38
+ return content.replace(pTagPattern, (match, _pAttrs, pContent) => {
39
+ const trimmedContent = pContent.trim();
40
+
41
+ // Check if content contains only a single HTML element (no surrounding text)
42
+ const singleElementPattern = /^<([a-z][a-z0-9]*)\b[^>]*>.*?<\/\1>$|^<[a-z][a-z0-9]*\b[^>]*\/?>$/is;
43
+
44
+ if (singleElementPattern.test(trimmedContent)) {
45
+ const tagMatch = trimmedContent.match(/^<([a-z][a-z0-9]*)/i);
46
+ if (tagMatch) {
47
+ const tagName = tagMatch[1].toLowerCase();
48
+ // Block-level elements and elements that shouldn't be wrapped in <p>
49
+ const unwrapTags = ['img', 'div', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'table', 'figure', 'iframe', 'video'];
50
+
51
+ if (unwrapTags.includes(tagName)) {
52
+ return trimmedContent;
53
+ }
54
+
55
+ // For inline elements like <a>, only unwrap if it's the ONLY content
56
+ if (tagName === 'a' || tagName === 'span') {
57
+ return trimmedContent;
58
+ }
59
+ }
60
+ }
61
+
62
+ // Keep the <p> tag
63
+ return match;
64
+ });
65
+ }
66
+
67
+ /**
68
+ * Parse the div block opening line to extract attributes
69
+ */
70
+ function parseOpeningLine(line: string): { attrs: string; hasContent: boolean; content: string } {
71
+ // Match: :::div or :::div {attrs} or :::div content {attrs}
72
+ const match = line.trim().match(/^:+div\s*(.*)$/i);
73
+ if (!match) {
74
+ return { attrs: '', hasContent: false, content: '' };
75
+ }
76
+
77
+ const rest = match[1].trim();
78
+ if (!rest) {
79
+ return { attrs: '', hasContent: false, content: '' };
80
+ }
81
+
82
+ // Check if it's attributes-only pattern: {.class}, {#id}, {attr=value}, or combinations
83
+ const isAttributesOnly = /^(\{[^}]*\}\s*)+$/.test(rest);
84
+
85
+ if (isAttributesOnly) {
86
+ // Multi-line syntax with attributes
87
+ return { attrs: rest, hasContent: false, content: '' };
88
+ } else {
89
+ // Single-line syntax with content (and possibly attributes at the end)
90
+ return { attrs: '', hasContent: true, content: rest };
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Block rule for div containers
96
+ */
97
+ function divBlock(state: any, startLine: number, endLine: number, silent: boolean): boolean {
98
+ let pos = state.bMarks[startLine] + state.tShift[startLine];
99
+ let max = state.eMarks[startLine];
100
+
101
+ // Check for block quote or other indentation
102
+ if (state.sCount[startLine] - state.blkIndent >= 4) {
103
+ return false;
104
+ }
105
+
106
+ // Check if line starts with enough colons
107
+ let markerCount = 0;
108
+ while (pos < max && state.src.charCodeAt(pos) === MARKER_CHAR) {
109
+ markerCount++;
110
+ pos++;
111
+ }
112
+
113
+ if (markerCount < MIN_MARKERS) {
114
+ return false;
115
+ }
116
+
117
+ // Must be followed by 'div' (case insensitive)
118
+ const restOfLine = state.src.slice(pos, max);
119
+ if (!restOfLine.match(/^div(\s|$)/i)) {
120
+ return false;
121
+ }
122
+
123
+ // If in validation mode, we're done
124
+ if (silent) {
125
+ return true;
126
+ }
127
+
128
+ // Parse the opening line
129
+ const fullLine = state.src.slice(state.bMarks[startLine], max);
130
+ const { attrs, hasContent, content } = parseOpeningLine(fullLine);
131
+
132
+ // Find the closing marker
133
+ let nextLine = startLine;
134
+ let autoClose = false;
135
+
136
+ for (;;) {
137
+ nextLine++;
138
+ if (nextLine >= endLine) {
139
+ // Reached end without finding closing marker
140
+ autoClose = true;
141
+ break;
142
+ }
143
+
144
+ pos = state.bMarks[nextLine] + state.tShift[nextLine];
145
+ max = state.eMarks[nextLine];
146
+
147
+ if (pos < max && state.sCount[nextLine] < state.blkIndent) {
148
+ // Non-empty line with negative indent should stop the block
149
+ break;
150
+ }
151
+
152
+ // Check for closing marker with same number of colons
153
+ let closeMarkerCount = 0;
154
+ let checkPos = pos;
155
+ while (checkPos < max && state.src.charCodeAt(checkPos) === MARKER_CHAR) {
156
+ closeMarkerCount++;
157
+ checkPos++;
158
+ }
159
+
160
+ // Closing marker must have exact same number of colons and nothing else (or whitespace)
161
+ if (closeMarkerCount === markerCount) {
162
+ const afterMarker = state.src.slice(checkPos, max).trim();
163
+ if (afterMarker === '' || afterMarker === 'div') {
164
+ // Found closing marker
165
+ break;
166
+ }
167
+ }
168
+ }
169
+
170
+ const oldParent = state.parentType;
171
+ const oldLineMax = state.lineMax;
172
+ state.parentType = 'container';
173
+
174
+ // Create opening token
175
+ let token: any = state.push('div_open', 'div', 1);
176
+ token.markup = ':'.repeat(markerCount);
177
+ token.block = true;
178
+ token.meta = { attrs, hasContent, content }; // Store in meta instead of info
179
+ token.map = [startLine, nextLine];
180
+
181
+ if (!hasContent) {
182
+ // Multi-line syntax: parse content between markers
183
+ const contentStart = startLine + 1;
184
+ const contentEnd = nextLine;
185
+
186
+ if (contentStart < contentEnd) {
187
+ state.lineMax = contentEnd;
188
+ state.md.block.tokenize(state, contentStart, contentEnd);
189
+ state.lineMax = oldLineMax;
190
+ }
191
+ }
192
+ // For single-line syntax (hasContent === true), we don't create any tokens
193
+ // The content will be rendered directly in the div_open renderer
194
+
195
+ // Create closing token
196
+ token = state.push('div_close', 'div', -1);
197
+ token.markup = ':'.repeat(markerCount);
198
+ token.block = true;
199
+
200
+ state.parentType = oldParent;
201
+ state.line = nextLine + (autoClose ? 0 : 1);
202
+
203
+ return true;
204
+ }
205
+
206
+ /**
207
+ * Renderer for div_open token
208
+ */
209
+ function renderDivOpen(tokens: any[], idx: number): string {
210
+ const token = tokens[idx];
211
+
212
+ // Get metadata from token
213
+ const meta = token.meta || {};
214
+ const { attrs = '', hasContent = false, content = '' } = meta;
215
+
216
+ if (hasContent && content) {
217
+ // Single-line syntax: render the content and extract attributes from the paragraph tag
218
+ const html = mdInstance.render(content).trim();
219
+
220
+ // html should be like '<p class="class">content</p>' or just '<p>content</p>'
221
+ if (html.startsWith('<p') && html.endsWith('</p>')) {
222
+ // Extract attributes from <p> tag and content
223
+ const match = html.match(/^<p([^>]*)>(.*)<\/p>$/);
224
+ if (match) {
225
+ const extractedAttrs = match[1]; // attributes like ' class="class"'
226
+ const extractedContent = match[2]; // the actual content
227
+
228
+ // Return the div with attributes and content, and DON'T close it yet
229
+ // (div_close will handle closing)
230
+ return `<div${extractedAttrs}>${extractedContent}`;
231
+ }
232
+ }
233
+
234
+ // Fallback: just render the content normally
235
+ return '<div>' + html.replace(/^<p[^>]*>/, '').replace(/<\/p>$/, '');
236
+ }
237
+
238
+ // Multi-line syntax: check attrs for attributes
239
+ if (attrs) {
240
+ // Render a dummy content with the attributes to extract them
241
+ const tempContent = 'x ' + attrs;
242
+ const html = mdInstance.render(tempContent).trim();
243
+
244
+ // html should be something like '<p class="class">x</p>' or '<p class="class" id="id">x</p>'
245
+ if (html.startsWith('<p') && html.endsWith('</p>')) {
246
+ const match = html.match(/^<p([^>]*)>.*<\/p>$/);
247
+ if (match && match[1]) {
248
+ return `<div${match[1]}>`;
249
+ }
250
+ }
251
+ }
252
+
253
+ return '<div>';
254
+ }
255
+
256
+ /**
257
+ * Renderer for div_close token
258
+ */
259
+ function renderDivClose(): string {
260
+ return '</div>\n';
261
+ }
262
+
263
+ /**
264
+ * Plugin registration function
265
+ */
266
+ export function markdownDivBlock(md: MarkdownIt): void {
267
+ // Store the markdown-it instance for use in renderers
268
+ mdInstance = md;
269
+
270
+ // Register the block rule before fence
271
+ md.block.ruler.before('fence', 'div_block', divBlock, {
272
+ alt: ['paragraph', 'reference', 'blockquote', 'list'],
273
+ });
274
+
275
+ // Register renderers
276
+ md.renderer.rules.div_open = renderDivOpen;
277
+ md.renderer.rules.div_close = renderDivClose;
278
+
279
+ // Post-process rendered HTML to unwrap single-element paragraphs ONLY in divs
280
+ const originalRender = md.render.bind(md);
281
+ md.render = function (src: string, env?: any) {
282
+ const html = originalRender(src, env);
283
+ return unwrapSingleElementParagraphsInDivs(html);
284
+ };
285
+
286
+ const originalRenderInline = md.renderInline.bind(md);
287
+ md.renderInline = function (src: string, env?: any) {
288
+ const html = originalRenderInline(src, env);
289
+ return unwrapSingleElementParagraphsInDivs(html);
290
+ };
291
+ }
@@ -5,6 +5,7 @@ import $log from '@isrd-isi-edu/ermrestjs/src/services/logger';
5
5
 
6
6
  // utils
7
7
  import { _classNames } from '@isrd-isi-edu/ermrestjs/src/utils/constants';
8
+ import { markdownDivBlock } from '@isrd-isi-edu/ermrestjs/src/utils/markdown-div-block';
8
9
 
9
10
  // vendor
10
11
  import markdownItSub from '@isrd-isi-edu/ermrestjs/vendor/markdown-it-sub.min';
@@ -657,38 +658,8 @@ function _bindCustomMarkdownTags(md: typeof MarkdownIt) {
657
658
  },
658
659
  });
659
660
 
660
- md.use(MarkdownItContainer, 'div', {
661
- /*
662
- * Checks whether string matches format ":::div CONTENT \n:::"
663
- * string inside `{}` is optional, specifies attributes to be applied to element
664
- */
665
- validate: function (params: any) {
666
- return params.trim().match(/^div\s+(.*)$/i);
667
- },
668
-
669
- render: function (tokens: any, idx: any) {
670
- const m = tokens[idx].info.trim().match(/^div\s+(.*)$/i);
671
-
672
- // opening tag
673
- if (tokens[idx].nesting === 1) {
674
- // if the next tag is a paragraph, we can change the paragraph into a div
675
- const attrs = md.parse(m[1], {});
676
- if (attrs && attrs.length > 0 && attrs[0].type === 'paragraph_open') {
677
- const html = md.render(m[1]).trim();
678
-
679
- // this will remove the closing and opening p tag.
680
- return '<div' + html.slice(2, html.length - 4);
681
- }
682
-
683
- // otherwise just add the div tag
684
- return '<div>\n' + md.render(m[1]).trim();
685
- }
686
- // the closing tag
687
- else {
688
- return '</div>\n';
689
- }
690
- },
691
- });
661
+ // Use custom div block implementation
662
+ md.use(markdownDivBlock);
692
663
 
693
664
  md.use(MarkdownItContainer, 'geneSequence', {
694
665
  validate: function (params: any) {
@@ -746,6 +717,116 @@ function _bindCustomMarkdownTags(md: typeof MarkdownIt) {
746
717
  },
747
718
  });
748
719
 
720
+ // Dependent on 'markdown-it-container' and 'markdown-it-attrs' plugins
721
+ // Injects `filePreview` tag that will be rendered as a React component in chaise
722
+ md.use(MarkdownItContainer, 'filePreview', {
723
+ /*
724
+ * Checks whether string matches format "::: filePreview [](URL){ATTR=VALUE .CLASSNAME}"
725
+ * String inside '{}' is Optional, specifies attributes to be applied to the element
726
+ */
727
+ validate: function (params: any) {
728
+ return params.trim().match(/^filePreview\s+(.*)$/i);
729
+ },
730
+
731
+ render: function (tokens: any, idx: any) {
732
+ // Get token string after regexp matching
733
+ const m = tokens[idx].info.trim().match(/^filePreview\s+(.*)$/i);
734
+
735
+ // If this is the opening tag i.e. starts with "::: filePreview "
736
+ if (tokens[idx].nesting === 1 && m && m.length > 0) {
737
+ // Extract remaining string and get its parsed markdown attributes
738
+ const attrs = md.parseInline(m[1], {});
739
+ let html = '';
740
+
741
+ if (attrs && attrs.length == 1 && attrs[0].children) {
742
+ // Check if the markdown is a link
743
+ if (attrs[0].children[0].type == 'link_open') {
744
+ const openingLink = attrs[0].children[0];
745
+ let fileUrl = '';
746
+ let filename = '';
747
+ let classAttr = '';
748
+ let otherAttrs = '';
749
+ let hideDownloadBtn = false;
750
+ let downloadBtnClass = '';
751
+ let previewType = '';
752
+ let prefetchBytes = '';
753
+ let prefetchMaxFileSize = '';
754
+
755
+ // Extract attributes
756
+ openingLink!.attrs!.forEach(function (attr) {
757
+ switch (attr[0]) {
758
+ case 'href':
759
+ fileUrl = attr[1];
760
+ break;
761
+ case 'filename':
762
+ filename = attr[1];
763
+ break;
764
+ case 'class':
765
+ classAttr = attr[1];
766
+ break;
767
+ case 'preview-type':
768
+ previewType = attr[1];
769
+ break;
770
+ case 'prefetch-bytes':
771
+ prefetchBytes = attr[1];
772
+ break;
773
+ case 'prefetch-max-file-size':
774
+ prefetchMaxFileSize = attr[1];
775
+ break;
776
+ case 'hide-download-btn':
777
+ hideDownloadBtn = true;
778
+ break;
779
+ case 'download-btn-class':
780
+ downloadBtnClass = attr[1];
781
+ break;
782
+ default:
783
+ otherAttrs += ' ' + attr[0] + '="' + attr[1] + '"';
784
+ break;
785
+ }
786
+ });
787
+
788
+ let caption = '';
789
+
790
+ // Extract caption as plain text (no HTML parsing)
791
+ if (attrs[0].children[1].type != 'link_close') {
792
+ for (let i = 1; i < attrs[0].children.length; i++) {
793
+ if (attrs[0].children[i].type == 'text') {
794
+ caption += attrs[0].children[i].content;
795
+ } else if (attrs[0].children[i].type !== 'link_close') {
796
+ // Skip non-text tokens (caption must be plain text only)
797
+ continue;
798
+ } else {
799
+ break;
800
+ }
801
+ }
802
+ }
803
+
804
+ const props = [
805
+ `class="file-preview-placeholder${classAttr ? ' ' + classAttr : ''}"`,
806
+ 'data-chaise-file-preview="true"',
807
+ 'data-file-url="' + fileUrl + '"',
808
+ filename ? 'data-filename="' + filename + '"' : '',
809
+ otherAttrs,
810
+ previewType ? 'data-preview-type="' + previewType + '"' : '',
811
+ hideDownloadBtn ? 'data-hide-download-btn="true"' : '',
812
+ downloadBtnClass ? 'data-download-btn-class="' + downloadBtnClass + '"' : '',
813
+ caption ? 'data-download-btn-caption="' + caption + '"' : '',
814
+ prefetchBytes ? 'data-prefetch-bytes="' + prefetchBytes + '"' : '',
815
+ prefetchMaxFileSize ? 'data-prefetch-max-file-size="' + prefetchMaxFileSize + '"' : '',
816
+ ].filter(Boolean);
817
+
818
+ html = `<div ${props.join(' ')}></div>`;
819
+ }
820
+ }
821
+
822
+ return html;
823
+ } else {
824
+ // closing tag
825
+ return '';
826
+ }
827
+ },
828
+ });
829
+
749
830
  // Note: Following how this was done in markdown-it-sub and markdown-it-span
750
831
  md.use(function rid_plugin(md) {
751
832
  // same as UNESCAPE_MD_RE plus a space