@refrakt-md/editor 0.8.3 → 0.8.5
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/app/dist/assets/{index-CQDCT-XT.js → index-BV3goZlJ.js} +1 -1
- package/app/dist/assets/{index-DezxtfNV.js → index-BXrXHIy0.js} +1 -1
- package/app/dist/assets/{index-COFbngzR.js → index-BnN-_mNn.js} +1 -1
- package/app/dist/assets/{index-B7e694w6.js → index-C1WrA8T3.js} +1 -1
- package/app/dist/assets/{index-CPEo_rvd.js → index-C1gBeCLd.js} +1 -1
- package/app/dist/assets/{index-3MvwKRVQ.js → index-CAmKzaYH.js} +1 -1
- package/app/dist/assets/{index-BjlNcvOf.js → index-CTFb0PXm.js} +1 -1
- package/app/dist/assets/{index-BGy7ixjW.js → index-CcdBReM4.js} +1 -1
- package/app/dist/assets/{index-CKfKYVw7.js → index-Cmi9Zmvg.js} +1 -1
- package/app/dist/assets/{index-CUmEjEeR.js → index-CoDOdTlL.js} +1 -1
- package/app/dist/assets/{index-DwfxgjnU.js → index-D6Wd1wiF.js} +1 -1
- package/app/dist/assets/{index-BEGy_i8o.js → index-DBEBbKSr.js} +1 -1
- package/app/dist/assets/{index-ChbH55h5.js → index-D_JLbBJP.js} +1 -1
- package/app/dist/assets/{index-BaLgiiKk.js → index-DhbHcMvG.js} +1 -1
- package/app/dist/assets/{index-DrI4IfXE.js → index-Dl9k73LF.js} +1 -1
- package/app/dist/assets/{index-CeV-Af4N.js → index-HSMIxxw0.js} +1 -1
- package/app/dist/assets/{index-D9-aYc3I.js → index-Nji8szQy.js} +1 -1
- package/app/dist/assets/{index-ogrpJNou.js → index-Y1_ZkrfC.js} +175 -174
- package/app/dist/assets/{index-BBljOYQu.js → index-n8MtxI3L.js} +1 -1
- package/app/dist/index.html +1 -1
- package/app/src/lib/api/client.ts +1 -0
- package/app/src/lib/editor/attribute-completion.ts +159 -0
- package/app/src/lib/editor/codemirror-theme.ts +115 -0
- package/app/src/lib/editor/content-model-resolver.ts +196 -0
- package/app/src/lib/editor/inline-markdown.ts +237 -0
- package/app/src/lib/editor/markdoc-highlight.ts +74 -0
- package/app/src/lib/editor/rune-palette.ts +95 -0
- package/app/src/lib/editor/section-mapper.ts +476 -0
- package/app/src/lib/state/editor.svelte.ts +151 -0
- package/app/src/lib/utils/frontmatter.ts +43 -0
- package/app/src/lib/utils/layout-parser.ts +197 -0
- package/dist/server.js +15 -32
- package/dist/server.js.map +1 -1
- package/package.json +10 -8
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import Markdoc from '@markdoc/markdoc';
|
|
2
|
+
import type { Node } from '@markdoc/markdoc';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse inline markdown source into HTML suitable for contentEditable.
|
|
6
|
+
*
|
|
7
|
+
* Uses Markdoc.parse() on a synthetic paragraph, then walks the inline AST
|
|
8
|
+
* to produce clean HTML with only the inline elements we support:
|
|
9
|
+
* strong, em, code, and links.
|
|
10
|
+
*/
|
|
11
|
+
export function parseInlineMarkdown(source: string): string {
|
|
12
|
+
if (!source) return '';
|
|
13
|
+
|
|
14
|
+
const ast = Markdoc.parse(source);
|
|
15
|
+
// The AST is: document > paragraph > inline children
|
|
16
|
+
const doc = ast;
|
|
17
|
+
if (!doc.children?.length) return escapeHtml(source);
|
|
18
|
+
|
|
19
|
+
const paragraph = doc.children[0];
|
|
20
|
+
if (!paragraph?.children?.length) return escapeHtml(source);
|
|
21
|
+
|
|
22
|
+
return renderInlineNodes(paragraph.children);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function renderInlineNodes(nodes: Node[]): string {
|
|
26
|
+
return nodes.map(renderInlineNode).join('');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function renderInlineNode(node: Node): string {
|
|
30
|
+
switch (node.type) {
|
|
31
|
+
case 'text':
|
|
32
|
+
return escapeHtml(node.attributes.content ?? '');
|
|
33
|
+
|
|
34
|
+
case 'strong':
|
|
35
|
+
return `<strong>${renderInlineNodes(node.children)}</strong>`;
|
|
36
|
+
|
|
37
|
+
case 'em':
|
|
38
|
+
return `<em>${renderInlineNodes(node.children)}</em>`;
|
|
39
|
+
|
|
40
|
+
case 's':
|
|
41
|
+
return `<s>${renderInlineNodes(node.children)}</s>`;
|
|
42
|
+
|
|
43
|
+
case 'code':
|
|
44
|
+
return `<code>${escapeHtml(node.attributes.content ?? '')}</code>`;
|
|
45
|
+
|
|
46
|
+
case 'link': {
|
|
47
|
+
const href = node.attributes.href ?? '';
|
|
48
|
+
const title = node.attributes.title;
|
|
49
|
+
const titleAttr = title ? ` title="${escapeAttr(title)}"` : '';
|
|
50
|
+
return `<a href="${escapeAttr(href)}"${titleAttr}>${renderInlineNodes(node.children)}</a>`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
case 'hardbreak':
|
|
54
|
+
return ' ';
|
|
55
|
+
|
|
56
|
+
case 'softbreak':
|
|
57
|
+
return ' ';
|
|
58
|
+
|
|
59
|
+
default:
|
|
60
|
+
// Graceful fallback: render children if any
|
|
61
|
+
if (node.children?.length) {
|
|
62
|
+
return renderInlineNodes(node.children);
|
|
63
|
+
}
|
|
64
|
+
return '';
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Serialize the DOM contents of a contentEditable element back to inline markdown.
|
|
70
|
+
*/
|
|
71
|
+
export function serializeInlineHtml(el: HTMLElement): string {
|
|
72
|
+
return serializeNodes(el.childNodes);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function serializeNodes(nodes: NodeListOf<ChildNode> | ChildNode[]): string {
|
|
76
|
+
const parts: string[] = [];
|
|
77
|
+
for (const node of nodes) {
|
|
78
|
+
parts.push(serializeNode(node));
|
|
79
|
+
}
|
|
80
|
+
return parts.join('');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const TEXT_NODE = 3;
|
|
84
|
+
const ELEMENT_NODE = 1;
|
|
85
|
+
|
|
86
|
+
function serializeNode(node: ChildNode): string {
|
|
87
|
+
if (node.nodeType === TEXT_NODE) {
|
|
88
|
+
return escapeMarkdown(node.textContent ?? '');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (node.nodeType !== ELEMENT_NODE) return '';
|
|
92
|
+
|
|
93
|
+
const el = node as HTMLElement;
|
|
94
|
+
const tag = el.tagName;
|
|
95
|
+
|
|
96
|
+
switch (tag) {
|
|
97
|
+
case 'STRONG':
|
|
98
|
+
case 'B':
|
|
99
|
+
return `**${serializeNodes(el.childNodes)}**`;
|
|
100
|
+
|
|
101
|
+
case 'EM':
|
|
102
|
+
case 'I':
|
|
103
|
+
return `*${serializeNodes(el.childNodes)}*`;
|
|
104
|
+
|
|
105
|
+
case 'S':
|
|
106
|
+
case 'STRIKE':
|
|
107
|
+
case 'DEL':
|
|
108
|
+
return `~~${serializeNodes(el.childNodes)}~~`;
|
|
109
|
+
|
|
110
|
+
case 'CODE':
|
|
111
|
+
return `\`${el.textContent ?? ''}\``;
|
|
112
|
+
|
|
113
|
+
case 'A': {
|
|
114
|
+
const href = el.getAttribute('href') ?? '';
|
|
115
|
+
const text = serializeNodes(el.childNodes);
|
|
116
|
+
return `[${text}](${href})`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case 'BR':
|
|
120
|
+
return ' ';
|
|
121
|
+
|
|
122
|
+
// Browser quirks: contentEditable may wrap text in divs/spans
|
|
123
|
+
case 'DIV':
|
|
124
|
+
case 'P':
|
|
125
|
+
case 'SPAN':
|
|
126
|
+
return serializeNodes(el.childNodes);
|
|
127
|
+
|
|
128
|
+
default:
|
|
129
|
+
return serializeNodes(el.childNodes);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Strip inline markdown formatting to produce plain text (for matching).
|
|
135
|
+
*/
|
|
136
|
+
export function stripInlineMarkdown(source: string): string {
|
|
137
|
+
return source
|
|
138
|
+
// Links: [text](url) or [text](url "title") → text
|
|
139
|
+
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
140
|
+
// Images:  → alt
|
|
141
|
+
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
|
|
142
|
+
// Bold: **text** or __text__
|
|
143
|
+
.replace(/\*\*(.+?)\*\*/g, '$1')
|
|
144
|
+
.replace(/__(.+?)__/g, '$1')
|
|
145
|
+
// Italic: *text* or _text_
|
|
146
|
+
.replace(/\*(.+?)\*/g, '$1')
|
|
147
|
+
.replace(/_(.+?)_/g, '$1')
|
|
148
|
+
// Strikethrough: ~~text~~
|
|
149
|
+
.replace(/~~(.+?)~~/g, '$1')
|
|
150
|
+
// Inline code: `text`
|
|
151
|
+
.replace(/`([^`]+)`/g, '$1');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── Helpers ──────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
function escapeHtml(s: string): string {
|
|
157
|
+
return s
|
|
158
|
+
.replace(/&/g, '&')
|
|
159
|
+
.replace(/</g, '<')
|
|
160
|
+
.replace(/>/g, '>')
|
|
161
|
+
.replace(/"/g, '"');
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function escapeAttr(s: string): string {
|
|
165
|
+
return s
|
|
166
|
+
.replace(/&/g, '&')
|
|
167
|
+
.replace(/"/g, '"')
|
|
168
|
+
.replace(/</g, '<')
|
|
169
|
+
.replace(/>/g, '>');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Escape markdown-significant characters in plain text that will be
|
|
174
|
+
* inserted into a markdown context. Only escapes characters that would
|
|
175
|
+
* otherwise trigger formatting.
|
|
176
|
+
*/
|
|
177
|
+
function escapeMarkdown(s: string): string {
|
|
178
|
+
// Escape characters that start markdown formatting tokens
|
|
179
|
+
// when they appear in text nodes (i.e. user typed them literally)
|
|
180
|
+
return s.replace(/([\\*_`~\[\]])/g, '\\$1');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Normalize a contentEditable element's DOM to clean up browser quirks.
|
|
185
|
+
* Call this after paste or format operations.
|
|
186
|
+
*/
|
|
187
|
+
export function normalizeEditableDom(el: HTMLElement): void {
|
|
188
|
+
// Replace <b> with <strong>, <i> with <em>
|
|
189
|
+
for (const b of Array.from(el.querySelectorAll('b'))) {
|
|
190
|
+
const strong = document.createElement('strong');
|
|
191
|
+
strong.innerHTML = b.innerHTML;
|
|
192
|
+
b.replaceWith(strong);
|
|
193
|
+
}
|
|
194
|
+
for (const i of Array.from(el.querySelectorAll('i'))) {
|
|
195
|
+
const em = document.createElement('em');
|
|
196
|
+
em.innerHTML = i.innerHTML;
|
|
197
|
+
i.replaceWith(em);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Remove styled spans (browser formatting artifacts)
|
|
201
|
+
for (const span of Array.from(el.querySelectorAll('span[style]'))) {
|
|
202
|
+
// Check if the style indicates bold or italic and convert
|
|
203
|
+
const style = (span as HTMLElement).style;
|
|
204
|
+
const parent = span.parentNode;
|
|
205
|
+
if (!parent) continue;
|
|
206
|
+
|
|
207
|
+
if (style.fontWeight === 'bold' || parseInt(style.fontWeight) >= 700) {
|
|
208
|
+
const strong = document.createElement('strong');
|
|
209
|
+
strong.innerHTML = span.innerHTML;
|
|
210
|
+
span.replaceWith(strong);
|
|
211
|
+
} else if (style.fontStyle === 'italic') {
|
|
212
|
+
const em = document.createElement('em');
|
|
213
|
+
em.innerHTML = span.innerHTML;
|
|
214
|
+
span.replaceWith(em);
|
|
215
|
+
} else {
|
|
216
|
+
// Unwrap the span, keeping its children
|
|
217
|
+
while (span.firstChild) {
|
|
218
|
+
parent.insertBefore(span.firstChild, span);
|
|
219
|
+
}
|
|
220
|
+
span.remove();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Flatten block elements that contentEditable may create
|
|
225
|
+
for (const div of Array.from(el.querySelectorAll('div, p'))) {
|
|
226
|
+
const parent = div.parentNode;
|
|
227
|
+
if (!parent || div === el) continue;
|
|
228
|
+
// Replace with a space + children
|
|
229
|
+
if (div.previousSibling) {
|
|
230
|
+
parent.insertBefore(document.createTextNode(' '), div);
|
|
231
|
+
}
|
|
232
|
+
while (div.firstChild) {
|
|
233
|
+
parent.insertBefore(div.firstChild, div);
|
|
234
|
+
}
|
|
235
|
+
div.remove();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ViewPlugin,
|
|
3
|
+
Decoration,
|
|
4
|
+
type DecorationSet,
|
|
5
|
+
type EditorView,
|
|
6
|
+
type ViewUpdate,
|
|
7
|
+
} from '@codemirror/view';
|
|
8
|
+
import { RangeSetBuilder } from '@codemirror/state';
|
|
9
|
+
import type { Extension } from '@codemirror/state';
|
|
10
|
+
|
|
11
|
+
const tagDeco = Decoration.mark({ class: 'cm-markdoc-tag' });
|
|
12
|
+
const bracketDeco = Decoration.mark({ class: 'cm-markdoc-bracket' });
|
|
13
|
+
const nameDeco = Decoration.mark({ class: 'cm-markdoc-name' });
|
|
14
|
+
|
|
15
|
+
// Matches: {% tagname ... %}, {% /tagname %}, {% tagname ... /%}
|
|
16
|
+
const TAG_RE = /\{%\s*(\/?)(\w[\w-]*)((?:\s+[^%]*)?)(\/?)\s*%\}/g;
|
|
17
|
+
|
|
18
|
+
function buildDecorations(view: EditorView): DecorationSet {
|
|
19
|
+
const ranges: { from: number; to: number; deco: Decoration }[] = [];
|
|
20
|
+
|
|
21
|
+
for (const { from, to } of view.visibleRanges) {
|
|
22
|
+
const text = view.state.doc.sliceString(from, to);
|
|
23
|
+
TAG_RE.lastIndex = 0;
|
|
24
|
+
|
|
25
|
+
let match: RegExpExecArray | null;
|
|
26
|
+
while ((match = TAG_RE.exec(text)) !== null) {
|
|
27
|
+
const start = from + match.index;
|
|
28
|
+
const end = start + match[0].length;
|
|
29
|
+
|
|
30
|
+
ranges.push({ from: start, to: end, deco: tagDeco });
|
|
31
|
+
ranges.push({ from: start, to: start + 2, deco: bracketDeco });
|
|
32
|
+
ranges.push({ from: end - 2, to: end, deco: bracketDeco });
|
|
33
|
+
|
|
34
|
+
const slash = match[1];
|
|
35
|
+
const name = match[2];
|
|
36
|
+
const nameOffset = match[0].indexOf(slash + name, 2);
|
|
37
|
+
const nameStart = start + nameOffset + slash.length;
|
|
38
|
+
ranges.push({ from: nameStart, to: nameStart + name.length, deco: nameDeco });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
ranges.sort((a, b) => a.from - b.from || a.to - b.to);
|
|
43
|
+
|
|
44
|
+
const builder = new RangeSetBuilder<Decoration>();
|
|
45
|
+
for (const r of ranges) {
|
|
46
|
+
builder.add(r.from, r.to, r.deco);
|
|
47
|
+
}
|
|
48
|
+
return builder.finish();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const markdocPlugin = ViewPlugin.fromClass(
|
|
52
|
+
class {
|
|
53
|
+
decorations: DecorationSet;
|
|
54
|
+
|
|
55
|
+
constructor(view: EditorView) {
|
|
56
|
+
this.decorations = buildDecorations(view);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
update(update: ViewUpdate) {
|
|
60
|
+
if (update.docChanged || update.viewportChanged) {
|
|
61
|
+
this.decorations = buildDecorations(update.view);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
},
|
|
65
|
+
{ decorations: (v) => v.decorations },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Creates a CodeMirror extension that highlights Markdoc tag syntax.
|
|
70
|
+
* Adds visual decoration to `{% tagname %}` and `{% /tagname %}` blocks.
|
|
71
|
+
*/
|
|
72
|
+
export function markdocHighlight(): Extension {
|
|
73
|
+
return markdocPlugin;
|
|
74
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type CompletionContext,
|
|
3
|
+
type CompletionResult,
|
|
4
|
+
type Completion,
|
|
5
|
+
type CompletionSource,
|
|
6
|
+
} from '@codemirror/autocomplete';
|
|
7
|
+
import type { RuneInfo } from '../api/client.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Strip VSCode snippet placeholder syntax for plain-text insertion:
|
|
11
|
+
* ${N:text} → text
|
|
12
|
+
* ${N|a,b,c|} → a (first choice)
|
|
13
|
+
* $0 / $N → empty string
|
|
14
|
+
*/
|
|
15
|
+
function stripSnippetPlaceholders(line: string): string {
|
|
16
|
+
return line
|
|
17
|
+
.replace(/\$\{\d+\|([^,}]+)[^}]*\}/g, '$1') // ${N|first,...|} → first
|
|
18
|
+
.replace(/\$\{\d+:([^}]*)}/g, '$1') // ${N:text} → text
|
|
19
|
+
.replace(/\$\d+/g, ''); // $0, $1 → empty
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Build a snippet string for inserting a rune.
|
|
24
|
+
* Prefers the rune's hand-crafted snippet (with VSCode placeholders stripped).
|
|
25
|
+
* Falls back to a basic template from schema attributes.
|
|
26
|
+
*/
|
|
27
|
+
function buildSnippet(rune: RuneInfo): string {
|
|
28
|
+
// Use hand-crafted snippet if available
|
|
29
|
+
if (rune.snippet && rune.snippet.length > 0) {
|
|
30
|
+
return rune.snippet.map(stripSnippetPlaceholders).join('\n');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Build required attribute defaults
|
|
34
|
+
const attrParts: string[] = [];
|
|
35
|
+
for (const [name, attr] of Object.entries(rune.attributes)) {
|
|
36
|
+
if (attr.required) {
|
|
37
|
+
const defaultVal = attr.values?.[0] ?? '';
|
|
38
|
+
attrParts.push(`${name}="${defaultVal}"`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const attrStr = attrParts.length > 0 ? ' ' + attrParts.join(' ') : '';
|
|
42
|
+
|
|
43
|
+
if (rune.selfClosing) {
|
|
44
|
+
return `{% ${rune.name}${attrStr} /%}`;
|
|
45
|
+
}
|
|
46
|
+
return `{% ${rune.name}${attrStr} %}\n\n{% /${rune.name} %}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Creates a CompletionSource for the rune palette triggered by `/`.
|
|
51
|
+
* Accepts a getter function so runes are read at query time (not creation time).
|
|
52
|
+
*/
|
|
53
|
+
export function runeCompletionSource(getRunes: () => RuneInfo[]): CompletionSource {
|
|
54
|
+
return (context: CompletionContext): CompletionResult | null => {
|
|
55
|
+
const word = context.matchBefore(/\/\w*/);
|
|
56
|
+
if (!word) return null;
|
|
57
|
+
|
|
58
|
+
// Only trigger at start of line or after whitespace
|
|
59
|
+
if (word.from > 0) {
|
|
60
|
+
const charBefore = context.state.doc.sliceString(word.from - 1, word.from);
|
|
61
|
+
if (charBefore.trim() !== '') return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const runes = getRunes();
|
|
65
|
+
if (runes.length === 0) return null;
|
|
66
|
+
|
|
67
|
+
const slashFrom = word.from; // position of the `/` trigger character
|
|
68
|
+
|
|
69
|
+
const completions: Completion[] = runes.map((rune) => ({
|
|
70
|
+
label: rune.name,
|
|
71
|
+
detail: rune.category,
|
|
72
|
+
info: rune.description,
|
|
73
|
+
section: rune.category,
|
|
74
|
+
type: 'keyword',
|
|
75
|
+
apply: (view, _completion, from, to) => {
|
|
76
|
+
const snippet = buildSnippet(rune);
|
|
77
|
+
// Replace from the `/` to remove the trigger character
|
|
78
|
+
view.dispatch({
|
|
79
|
+
changes: { from: slashFrom, to, insert: snippet },
|
|
80
|
+
selection: {
|
|
81
|
+
anchor: rune.selfClosing
|
|
82
|
+
? slashFrom + snippet.indexOf('/%}')
|
|
83
|
+
: slashFrom + snippet.indexOf('%}') + 2 + 1,
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
},
|
|
87
|
+
}));
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
from: slashFrom + 1, // filter starts after `/` so "hint" matches typed "h"
|
|
91
|
+
options: completions,
|
|
92
|
+
filter: true,
|
|
93
|
+
};
|
|
94
|
+
};
|
|
95
|
+
}
|