@deckbuilderkit/editor 0.0.0-stage → 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 (56) hide show
  1. package/package.json +53 -4
  2. package/src/ArchiveView.tsx +111 -0
  3. package/src/CodeEditor.tsx +265 -0
  4. package/src/CodeView.tsx +67 -0
  5. package/src/DiffView.tsx +139 -0
  6. package/src/EnvView.tsx +42 -0
  7. package/src/FilePreview.tsx +108 -0
  8. package/src/FontView.tsx +48 -0
  9. package/src/HarView.tsx +70 -0
  10. package/src/HexView.tsx +28 -0
  11. package/src/HtmlPreview.tsx +23 -0
  12. package/src/MarkdownEditor.tsx +90 -0
  13. package/src/MarkdownToc.tsx +135 -0
  14. package/src/MarkdownView.tsx +84 -0
  15. package/src/MediaView.tsx +17 -0
  16. package/src/NotebookView.tsx +74 -0
  17. package/src/PdfView.tsx +289 -0
  18. package/src/RequestEditor.tsx +419 -0
  19. package/src/ResponseView.tsx +89 -0
  20. package/src/SqlGraph.tsx +322 -0
  21. package/src/SqlSteps.tsx +487 -0
  22. package/src/SqliteView.tsx +68 -0
  23. package/src/SvgView.tsx +231 -0
  24. package/src/TableView.tsx +85 -0
  25. package/src/VarSuggest.tsx +125 -0
  26. package/src/curl-file.ts +70 -0
  27. package/src/editorOptions.ts +83 -0
  28. package/src/editorOptionsContext.ts +4 -0
  29. package/src/fileKinds.ts +69 -0
  30. package/src/http-client.ts +9 -0
  31. package/src/http-import.ts +594 -0
  32. package/src/http.ts +251 -0
  33. package/src/import-warning.ts +8 -0
  34. package/src/index.ts +56 -0
  35. package/src/languages.ts +107 -0
  36. package/src/markdown.ts +123 -0
  37. package/src/markdownLive.ts +289 -0
  38. package/src/options.ts +3 -0
  39. package/src/pdf-url.d.ts +4 -0
  40. package/src/sql/format.ts +637 -0
  41. package/src/sql/formatOptions.ts +71 -0
  42. package/src/sql/lineage.ts +365 -0
  43. package/src/sql/lint.ts +951 -0
  44. package/src/sql/markerHighlight.ts +50 -0
  45. package/src/sql/markers.ts +138 -0
  46. package/src/sql/outline.ts +662 -0
  47. package/src/sql/statements.ts +83 -0
  48. package/src/sql/tokens.ts +156 -0
  49. package/src/sql/xmlQuery.ts +249 -0
  50. package/src/sqlProblems.ts +40 -0
  51. package/src/table.ts +89 -0
  52. package/src/text-save.ts +28 -0
  53. package/src/theme.ts +86 -0
  54. package/src/toc.ts +11 -0
  55. package/src/variables.ts +123 -0
  56. package/README.md +0 -3
package/src/http.ts ADDED
@@ -0,0 +1,251 @@
1
+ import {
2
+ HTTP_TIMEOUT_MAX_MS,
3
+ HTTP_TIMEOUT_MIN_MS,
4
+ type HttpAuthKind,
5
+ type HttpMethod,
6
+ type HttpRequestDraft,
7
+ type HttpRequestSpec,
8
+ type HttpResponseInfo,
9
+ } from '@deckbuilderkit/server-core/protocol';
10
+ import type { Lang } from './languages';
11
+
12
+ import type { HttpHeaderDraft } from '@deckbuilderkit/server-core/protocol';
13
+
14
+ export type { HttpHeaderDraft, HttpRequestDraft } from '@deckbuilderkit/server-core/protocol';
15
+
16
+ /** The timeout field's text (seconds) as whole-second milliseconds within the protocol bounds; undefined for empty or not a number. */
17
+ export function timeoutFromSeconds(text: string): number | undefined {
18
+ const n = Number(text);
19
+ if (text.trim() === '' || !Number.isFinite(n)) return undefined;
20
+ return Math.min(HTTP_TIMEOUT_MAX_MS, Math.max(HTTP_TIMEOUT_MIN_MS, Math.round(n) * 1000));
21
+ }
22
+
23
+ /** Headers as "Name: Value" lines; disabled ones are prefixed with "// ". */
24
+ export function headersToText(headers: HttpHeaderDraft[]): string {
25
+ return headers.map((h) => `${h.enabled ? '' : '// '}${h.name}: ${h.value}`).join('\n');
26
+ }
27
+
28
+ export function headersFromText(text: string): HttpHeaderDraft[] {
29
+ const headers: HttpHeaderDraft[] = [];
30
+ for (const raw of text.split(/\r?\n/)) {
31
+ let line = raw.trim();
32
+ const enabled = !line.startsWith('//');
33
+ if (!enabled) line = line.slice(2).trim();
34
+ if (line === '') continue;
35
+ const colon = line.indexOf(':');
36
+ headers.push(
37
+ colon < 0
38
+ ? { name: line, value: '', enabled }
39
+ : { name: line.slice(0, colon).trim(), value: line.slice(colon + 1).trim(), enabled },
40
+ );
41
+ }
42
+ return headers;
43
+ }
44
+
45
+ const XML_TOKEN = /<!--[\s\S]*?-->|<!\[CDATA\[[\s\S]*?\]\]>|<\?[\s\S]*?\?>|<![\s\S]*?>|<\/?[A-Za-z_][^>]*>|[^<]+|</g;
46
+
47
+ /**
48
+ * Re-indents XML with two spaces. Text-only elements stay on one line and whitespace-only text between
49
+ * tags is dropped; throws on unbalanced tags. Text nodes are trimmed, which is fine for data but not for mixed content.
50
+ */
51
+ export function formatXml(source: string): string {
52
+ const tokens = source.match(XML_TOKEN) ?? [];
53
+ const lines: string[] = [];
54
+ const open: string[] = [];
55
+ const nameOf = (tag: string) => /^<\/?([^\s/>]+)/.exec(tag)![1];
56
+ for (let i = 0; i < tokens.length; i++) {
57
+ const tok = tokens[i];
58
+ const pad = ' '.repeat(open.length);
59
+ if (tok === '<') throw new Error('Unexpected "<"');
60
+ if (!tok.startsWith('<')) {
61
+ const text = tok.trim();
62
+ if (text) lines.push(pad + text);
63
+ } else if (tok.startsWith('</')) {
64
+ const name = nameOf(tok);
65
+ if (open.pop() !== name) throw new Error(`Unexpected closing tag </${name}>`);
66
+ lines.push(' '.repeat(open.length) + tok);
67
+ } else if (/^<[A-Za-z_]/.test(tok) && !tok.endsWith('/>')) {
68
+ const next = tokens[i + 1];
69
+ const after = tokens[i + 2];
70
+ if (next !== undefined && !next.startsWith('<') && after?.startsWith('</') && nameOf(after) === nameOf(tok)) {
71
+ lines.push(pad + tok + next.trim() + after);
72
+ i += 2;
73
+ } else {
74
+ open.push(nameOf(tok));
75
+ lines.push(pad + tok);
76
+ }
77
+ } else {
78
+ lines.push(pad + tok);
79
+ }
80
+ }
81
+ if (open.length) throw new Error(`Unclosed tag <${open[open.length - 1]}>`);
82
+ return lines.join('\n');
83
+ }
84
+
85
+ export interface QueryParam {
86
+ name: string;
87
+ value: string;
88
+ }
89
+
90
+ /** The URL split at the query: text before `?`, the raw query (undefined without `?`) and the `#fragment`. */
91
+ function splitQuery(url: string): { base: string; query?: string; hash: string } {
92
+ const h = url.indexOf('#');
93
+ const hash = h < 0 ? '' : url.slice(h);
94
+ const rest = h < 0 ? url : url.slice(0, h);
95
+ const q = rest.indexOf('?');
96
+ return q < 0 ? { base: rest, hash } : { base: rest.slice(0, q), query: rest.slice(q + 1), hash };
97
+ }
98
+
99
+ /** The query of `url` as raw (not decoded) name/value pairs; empty segments are skipped. */
100
+ export function queryParams(url: string): QueryParam[] {
101
+ const { query } = splitQuery(url);
102
+ if (!query) return [];
103
+ return query
104
+ .split('&')
105
+ .filter((s) => s !== '')
106
+ .map((s) => {
107
+ const eq = s.indexOf('=');
108
+ return eq < 0 ? { name: s, value: '' } : { name: s.slice(0, eq), value: s.slice(eq + 1) };
109
+ });
110
+ }
111
+
112
+ const escapeQueryPart = (s: string, isName: boolean) =>
113
+ s.replace(isName ? /[&#=]/g : /[&#]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
114
+
115
+ /** `url` with its query replaced by `params`; everything else (scheme-less, `{{vars}}`, fragment) stays as typed. */
116
+ export function withQueryParams(url: string, params: QueryParam[]): string {
117
+ const { base, hash } = splitQuery(url);
118
+ const query = params
119
+ .filter((p) => p.name !== '' || p.value !== '')
120
+ .map((p) => escapeQueryPart(p.name, true) + (p.value === '' ? '' : `=${escapeQueryPart(p.value, false)}`))
121
+ .join('&');
122
+ return query === '' ? base + hash : `${base}?${query}${hash}`;
123
+ }
124
+
125
+ export interface AuthParts {
126
+ kind?: HttpAuthKind;
127
+ user: string;
128
+ password: string;
129
+ token: string;
130
+ }
131
+
132
+ const toBase64 = (s: string) => btoa(Array.from(new TextEncoder().encode(s), (b) => String.fromCharCode(b)).join(''));
133
+
134
+ function fromBase64(s: string): string | undefined {
135
+ try {
136
+ return new TextDecoder('utf-8', { fatal: true }).decode(Uint8Array.from(atob(s), (c) => c.charCodeAt(0)));
137
+ } catch {
138
+ return undefined;
139
+ }
140
+ }
141
+
142
+ /** The Authorization header value for `kind`: `Basic base64(user:password)` or `Bearer token`; '' when the fields are empty. */
143
+ export function authHeaderValue(kind: HttpAuthKind, parts: Pick<AuthParts, 'user' | 'password' | 'token'>): string {
144
+ if (kind === 'bearer') return parts.token.trim() === '' ? '' : `Bearer ${parts.token.trim()}`;
145
+ return parts.user === '' && parts.password === '' ? '' : `Basic ${toBase64(`${parts.user}:${parts.password}`)}`;
146
+ }
147
+
148
+ /** Splits an Authorization value back into the Auth tab's fields; `kind` is undefined when it is neither Basic nor Bearer. */
149
+ export function parseAuthValue(value: string): AuthParts {
150
+ const empty = { user: '', password: '', token: '' };
151
+ const m = /^(\w+)\s+(.*)$/s.exec(value.trim());
152
+ if (!m) return empty;
153
+ const scheme = m[1].toLowerCase();
154
+ if (scheme === 'bearer') return { ...empty, kind: 'bearer', token: m[2] };
155
+ if (scheme !== 'basic') return empty;
156
+ const decoded = fromBase64(m[2].trim());
157
+ if (decoded === undefined) return { ...empty, kind: 'basic' };
158
+ const colon = decoded.indexOf(':');
159
+ return colon < 0
160
+ ? { ...empty, kind: 'basic', user: decoded }
161
+ : { ...empty, kind: 'basic', user: decoded.slice(0, colon), password: decoded.slice(colon + 1) };
162
+ }
163
+
164
+ /** True when an enabled header named Authorization (any case) is set by hand; it wins over the Auth tab. */
165
+ export const hasAuthHeader = (headers: HttpHeaderDraft[]): boolean =>
166
+ headers.some((h) => h.enabled && h.name.trim().toLowerCase() === 'authorization');
167
+
168
+ export const emptyDraft =(): HttpRequestDraft => ({ method: 'GET', url: '', headers: [], body: '', bodyLang: 'json' });
169
+
170
+ /** GET and HEAD requests carry no body. */
171
+ export const sendsBody = (method: HttpMethod): boolean => method !== 'GET' && method !== 'HEAD';
172
+
173
+ export function draftToSpec(draft: HttpRequestDraft): HttpRequestSpec {
174
+ const headers: Array<[string, string]> = draft.headers
175
+ .filter((h) => h.enabled && h.name.trim() !== '')
176
+ .map((h) => [h.name.trim(), h.value]);
177
+ const spec: HttpRequestSpec = { method: draft.method, url: draft.url, headers };
178
+ if (draft.bodyLang === 'form' || draft.bodyLang === 'multipart') {
179
+ const form = (draft.form ?? []).filter((f) => f.name.trim() !== '').map((f): [string, string] => [f.name.trim(), f.value]);
180
+ if (sendsBody(draft.method) && form.length) {
181
+ spec.bodyKind = draft.bodyLang;
182
+ spec.form = form;
183
+ }
184
+ } else if (sendsBody(draft.method) && draft.body !== '') {
185
+ spec.body = draft.body;
186
+ if (draft.bodyLang !== 'text' && !headers.some(([name]) => name.toLowerCase() === 'content-type')) {
187
+ headers.push(['Content-Type', draft.bodyLang === 'xml' ? 'application/xml' : 'application/json']);
188
+ }
189
+ }
190
+ if (draft.timeoutMs !== undefined) spec.timeoutMs = draft.timeoutMs;
191
+ if (draft.insecure !== undefined) spec.insecure = draft.insecure;
192
+ if (draft.followRedirects !== undefined) spec.followRedirects = draft.followRedirects;
193
+ return spec;
194
+ }
195
+
196
+ const shq = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
197
+
198
+ /**
199
+ * The request as a multi-line cURL command for a POSIX shell. `auth` is the Authorization value
200
+ * from the Auth tab (Basic → `-u user:pass`, otherwise a header); an Authorization header in the
201
+ * spec wins over it. Form rows become `--data-urlencode 'n=v'`, multipart rows `-F 'n=v'`
202
+ * (`--form-string` when the value starts with `@`/`<` or holds `;`, which `-F` would read as a file or part option).
203
+ */
204
+ export function toCurl(spec: HttpRequestSpec, auth?: string): string {
205
+ const parts = [`curl ${shq(spec.url)}`];
206
+ const form = spec.bodyKind && spec.form?.length ? spec.form : undefined;
207
+ const bodyless = spec.method === 'GET' && spec.body === undefined && !form;
208
+ if (!bodyless) parts.push(`-X ${spec.method}`);
209
+ if (spec.followRedirects !== false) parts.push('-L');
210
+ if (spec.insecure) parts.push('--insecure');
211
+ if (spec.timeoutMs !== undefined) parts.push(`--max-time ${Math.round(spec.timeoutMs / 1000)}`);
212
+ if (auth && !spec.headers.some(([name]) => name.toLowerCase() === 'authorization')) {
213
+ const parsed = parseAuthValue(auth);
214
+ const basic = parsed.kind === 'basic' && authHeaderValue('basic', parsed) === auth.trim();
215
+ parts.push(basic ? `-u ${shq(`${parsed.user}:${parsed.password}`)}` : `-H ${shq(`Authorization: ${auth.trim()}`)}`);
216
+ }
217
+ for (const [name, value] of spec.headers) parts.push(`-H ${shq(`${name}: ${value}`)}`);
218
+ if (form && spec.bodyKind === 'form') for (const [name, value] of form) parts.push(`--data-urlencode ${shq(`${name}=${value}`)}`);
219
+ else if (form) for (const [name, value] of form) parts.push(`${/^[@<]|;/.test(value) ? '--form-string' : '-F'} ${shq(`${name}=${value}`)}`);
220
+ else if (spec.body !== undefined) parts.push(`--data-raw ${shq(spec.body)}`);
221
+ return parts.join(' \\\n ');
222
+ }
223
+
224
+ export function statusTone(status: number): 'ok' | 'info' | 'warn' | 'err' {
225
+ if (status >= 200 && status < 300) return 'ok';
226
+ if (status >= 300 && status < 400) return 'info';
227
+ if (status >= 400 && status < 500) return 'warn';
228
+ return 'err';
229
+ }
230
+
231
+ export function formatMs(n: number): string {
232
+ if (n < 1000) return `${Math.round(n)} ms`;
233
+ const s = n / 1000;
234
+ return `${s >= 10 ? Math.round(s) : Math.round(s * 10) / 10} s`;
235
+ }
236
+
237
+ export type BodyView =
238
+ | { kind: 'json'; value: unknown; pretty: string }
239
+ | { kind: 'text'; lang: Lang; text: string }
240
+ | { kind: 'binary' };
241
+
242
+ export function bodyView(info: Pick<HttpResponseInfo, 'binary' | 'text' | 'contentType'>): BodyView {
243
+ if (info.binary || info.text === undefined) return { kind: 'binary' };
244
+ try {
245
+ const value: unknown = JSON.parse(info.text);
246
+ return { kind: 'json', value, pretty: JSON.stringify(value, null, 2) };
247
+ } catch {
248
+ const markdown = info.contentType.split(';')[0].trim().toLowerCase() === 'text/markdown';
249
+ return { kind: 'text', lang: markdown ? 'markdown' : 'text', text: info.text };
250
+ }
251
+ }
@@ -0,0 +1,8 @@
1
+ import { tryT } from '@deckbuilderkit/ui';
2
+
3
+ /** `<code>:<argument>` from the parser → translated text; unknown codes show as they are. */
4
+ export const warningText = (warning: string) => {
5
+ const colon = warning.indexOf(':');
6
+ const code = colon < 0 ? warning : warning.slice(0, colon);
7
+ return tryT(`rest.import.warn.${code}`, { arg: colon < 0 ? '' : warning.slice(colon + 1) }) ?? warning;
8
+ };
package/src/index.ts ADDED
@@ -0,0 +1,56 @@
1
+ export * from './ArchiveView';
2
+ export * from './CodeEditor';
3
+ export * from './CodeView';
4
+ export * from './DiffView';
5
+ export * from './FilePreview';
6
+ export * from './http';
7
+ export { HEX_LIMIT } from './HexView';
8
+ export type { SqliteContent } from './SqliteView';
9
+ export * from './http-import';
10
+ export * from './curl-file';
11
+ export * from './import-warning';
12
+ export * from './RequestEditor';
13
+ export * from './http-client';
14
+ export * from './ResponseView';
15
+ export * from './languages';
16
+ export { DEFAULT_FORMAT_OPTIONS, FORMAT_OPTION_META, formatSql, type FormatOptionMeta, type SqlFormatOptions } from './sql/format';
17
+ export * from './markdown';
18
+ export * from './MarkdownEditor';
19
+ export * from './MarkdownView';
20
+ export * from './text-save';
21
+ export * from './SqlSteps';
22
+ export * from './SqlGraph';
23
+ export * from './sqlProblems';
24
+ export {
25
+ sqlOutline,
26
+ type SqlJoin,
27
+ type SqlJoinType,
28
+ type SqlOutline,
29
+ type SqlRead,
30
+ type SqlSetOp,
31
+ type SqlStatementKind,
32
+ type SqlStatementOutline,
33
+ type SqlStep,
34
+ } from './sql/outline';
35
+ export { SQL_LINT_SEVERITY, sqlLint, type SqlLintCode, type SqlLintSeverity, type SqlProblem } from './sql/lint';
36
+ export { splitStatements, statementAt, type Statement } from './sql/statements';
37
+ export { resolveMarkers, sqlMarkers, sqlParams, type SqlParam } from './sql/markers';
38
+ export {
39
+ layoutGraph,
40
+ lineageGraph,
41
+ lineageOf,
42
+ sqlNameKey,
43
+ type Layout,
44
+ type LayoutBox,
45
+ type LayoutOptions,
46
+ type LayoutPoint,
47
+ type LineageEdge,
48
+ type LineageGraph,
49
+ type LineageJoin,
50
+ type LineageNode,
51
+ type LineageNodeKind,
52
+ } from './sql/lineage';
53
+ export * from './theme';
54
+ export * from './editorOptions';
55
+ export * from './VarSuggest';
56
+ export * from './sql/xmlQuery';
@@ -0,0 +1,107 @@
1
+ import type { Completion, CompletionSource } from '@codemirror/autocomplete';
2
+ import { json, jsonLanguage } from '@codemirror/lang-json';
3
+ import { markdown } from '@codemirror/lang-markdown';
4
+ import { GFM } from '@lezer/markdown';
5
+ import { MSSQL, MySQL, PLSQL, PostgreSQL, sql, SQLDialect, SQLite, StandardSQL } from '@codemirror/lang-sql';
6
+ import { type Language, StreamLanguage } from '@codemirror/language';
7
+ import { c, cpp, csharp, java, kotlin } from '@codemirror/legacy-modes/mode/clike';
8
+ import { css } from '@codemirror/legacy-modes/mode/css';
9
+ import { diff } from '@codemirror/legacy-modes/mode/diff';
10
+ import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
11
+ import { go } from '@codemirror/legacy-modes/mode/go';
12
+ import { javascript, typescript } from '@codemirror/legacy-modes/mode/javascript';
13
+ import { properties } from '@codemirror/legacy-modes/mode/properties';
14
+ import { python } from '@codemirror/legacy-modes/mode/python';
15
+ import { rust } from '@codemirror/legacy-modes/mode/rust';
16
+ import { shell } from '@codemirror/legacy-modes/mode/shell';
17
+ import { toml } from '@codemirror/legacy-modes/mode/toml';
18
+ import { html, xml } from '@codemirror/legacy-modes/mode/xml';
19
+ import { yaml } from '@codemirror/legacy-modes/mode/yaml';
20
+ import type { Extension } from '@codemirror/state';
21
+
22
+ export type Lang =
23
+ | 'sql' | 'json' | 'shell' | 'java' | 'markdown' | 'xml' | 'text'
24
+ | 'javascript' | 'typescript' | 'css' | 'html' | 'python' | 'yaml' | 'toml' | 'properties'
25
+ | 'rust' | 'go' | 'c' | 'cpp' | 'csharp' | 'kotlin' | 'diff' | 'dockerfile';
26
+ /** Display names, as VS Code's language picker shows them. */
27
+ const LANG_LABELS: Record<Lang, string> = {
28
+ sql: 'SQL', json: 'JSON', shell: 'Shell Script', java: 'Java', markdown: 'Markdown', xml: 'XML', text: 'Plain Text',
29
+ javascript: 'JavaScript', typescript: 'TypeScript', css: 'CSS', html: 'HTML', python: 'Python', yaml: 'YAML', toml: 'TOML', properties: 'Properties',
30
+ rust: 'Rust', go: 'Go', c: 'C', cpp: 'C++', csharp: 'C#', kotlin: 'Kotlin', diff: 'Diff', dockerfile: 'Dockerfile',
31
+ };
32
+
33
+ export const langLabel = (lang: Lang) => LANG_LABELS[lang];
34
+
35
+ export type SqlDialect = 'postgres' | 'mysql' | 'sqlite' | 'mssql' | 'plsql' | 'standard';
36
+ /** Table name → column names, for SQL completion. */
37
+ export type SqlSchema = Record<string, string[]>;
38
+
39
+ // Oracle's unquoted names are case-insensitive: without this, completing an upper-case table inserts it in "quotes".
40
+ const OracleSQL = SQLDialect.define({ ...PLSQL.spec, caseInsensitiveIdentifiers: true });
41
+
42
+ const DIALECTS = { postgres: PostgreSQL, mysql: MySQL, sqlite: SQLite, mssql: MSSQL, plsql: OracleSQL, standard: StandardSQL };
43
+
44
+ const STREAM_MODES = { shell, java, xml, html, javascript, typescript, css, python, yaml, toml, properties, rust, go, c, cpp, csharp, kotlin, diff, dockerfile: dockerFile };
45
+ type StreamLang = keyof typeof STREAM_MODES;
46
+ const streamLanguages = new Map<string, Language>();
47
+ /** The language of a legacy stream mode, defined once per mode. */
48
+ const streamLanguage = (lang: StreamLang): Language => {
49
+ let language = streamLanguages.get(lang);
50
+ if (!language) {
51
+ language = StreamLanguage.define(STREAM_MODES[lang]);
52
+ streamLanguages.set(lang, language);
53
+ }
54
+ return language;
55
+ };
56
+ const isStreamLang = (lang: Lang): lang is StreamLang => lang in STREAM_MODES;
57
+
58
+ export function languageFor(lang: Lang, opts: { sqlDialect?: SqlDialect; sqlSchema?: SqlSchema } = {}): Extension {
59
+ switch (lang) {
60
+ case 'sql': {
61
+ const support = sql({ dialect: DIALECTS[opts.sqlDialect ?? 'standard'], schema: opts.sqlSchema });
62
+ return opts.sqlSchema ? [support, support.language.data.of({ autocomplete: columnCompletion(opts.sqlSchema) })] : support;
63
+ }
64
+ case 'json': return json();
65
+ case 'markdown': return markdown({ extensions: GFM });
66
+ default: return isStreamLang(lang) ? streamLanguage(lang) : [];
67
+ }
68
+ }
69
+
70
+ const IDENT = /[A-Za-z_][\w$#]*/g;
71
+ /** Most text scanned for table names; a longer document gets no column completions. */
72
+ const MAX_SCAN = 200_000;
73
+
74
+ /**
75
+ * Columns of the schema's tables named anywhere in the text, without a `table.` prefix (lang-sql
76
+ * only completes columns after one). Table names match case-insensitively, as Oracle's unquoted ones do.
77
+ */
78
+ export function columnCompletion(schema: SqlSchema): CompletionSource {
79
+ const tables = new Map(Object.keys(schema).map((name) => [name.toUpperCase(), name]));
80
+ return (ctx) => {
81
+ const word = ctx.matchBefore(/[\w$#]*/);
82
+ if (!word || (word.from === word.to && !ctx.explicit)) return null;
83
+ if (ctx.state.sliceDoc(word.from - 1, word.from) === '.' || ctx.state.doc.length > MAX_SCAN) return null;
84
+ const named = new Set<string>();
85
+ for (const [ident] of ctx.state.doc.toString().matchAll(IDENT)) {
86
+ const table = tables.get(ident.toUpperCase());
87
+ if (table) named.add(table);
88
+ }
89
+ const options: Completion[] = [];
90
+ for (const table of named) for (const label of schema[table]!) options.push({ label, type: 'property', detail: table, boost: -1 });
91
+ return options.length ? { from: word.from, options, validFor: /^[\w$#]*$/ } : null;
92
+ };
93
+ }
94
+
95
+ export type Parser = Language['parser'];
96
+
97
+ /** The parser behind a language, for highlighting text outside an editor; null when there is none. */
98
+ export function parserOf(lang: Lang): Parser | null {
99
+ switch (lang) {
100
+ case 'sql': return StandardSQL.language.parser;
101
+ case 'json': return jsonLanguage.parser;
102
+ default: return isStreamLang(lang) ? streamLanguage(lang).parser : null;
103
+ }
104
+ }
105
+
106
+
107
+ export { langOfFile, previewKind, type PreviewKind } from './fileKinds';
@@ -0,0 +1,123 @@
1
+ import DOMPurify from 'dompurify';
2
+ import { marked } from 'marked';
3
+
4
+ export interface MarkdownOptions {
5
+ /** Maps a relative image src to a URL the page may load (e.g. a blob URL); null blocks it. */
6
+ resolveImage?(src: string): string | null;
7
+ /** Text of the placeholder that replaces a blocked image. */
8
+ blockedLabel?: string;
9
+ }
10
+
11
+ /** Links that stay: http(s) open outside the app, `#anchors` jump within the page; anything else loses its href. */
12
+ export function linkPolicy(href: string): { href: string; external: boolean } | null {
13
+ const h = href.trim();
14
+ if (h.startsWith('#')) return { href: h, external: false };
15
+ if (/^https?:/i.test(h)) return { href: h, external: true };
16
+ return null;
17
+ }
18
+
19
+ const SAFE_DATA_IMAGE = /^data:image\/(png|jpeg|gif|webp);base64,/i;
20
+ // Any scheme (http:, file:, data:, javascript:, ...) or a protocol-relative //host.
21
+ const ABSOLUTE = /^([a-z][a-z0-9+.-]*:|\/\/)/i;
22
+
23
+ /** The src an image may use: relative ones go through `resolveImage`, remote and other schemes are blocked. */
24
+ export function imagePolicy(src: string, resolveImage?: (src: string) => string | null): string | 'blocked' {
25
+ const s = src.trim();
26
+ if (SAFE_DATA_IMAGE.test(s)) return s;
27
+ if (!s || ABSOLUTE.test(s)) return 'blocked';
28
+ return resolveImage?.(s) ?? 'blocked';
29
+ }
30
+
31
+ const FRONTMATTER = /^?---[ \t]*\r?\n(?:([\s\S]*?)\r?\n)?(?:---|\.\.\.)[ \t]*(?:\r?\n|$)/;
32
+
33
+ /** A leading YAML frontmatter block (`---` ... `---`): its full length and its YAML text; null if there is none. */
34
+ export function frontmatter(md: string): { length: number; yaml: string } | null {
35
+ const m = FRONTMATTER.exec(md);
36
+ return m ? { length: m[0].length, yaml: m[1] ?? '' } : null;
37
+ }
38
+
39
+ /** Top-level `key: value` pairs of frontmatter YAML; nested lines and list items fold into their key's value. Null if no key is found. */
40
+ export function frontmatterEntries(yaml: string): [string, string][] | null {
41
+ const entries: [string, string[]][] = [];
42
+ for (const line of yaml.split(/\r?\n/)) {
43
+ const m = /^([^\s#:-][^:]*?|-[^\s:][^:]*?):(?:\s+(.*))?$/.exec(line.trimEnd());
44
+ if (m) {
45
+ entries.push([m[1], m[2] && !/^[|>][+-]?$/.test(m[2]) ? [unquote(m[2].trim())] : []]);
46
+ } else if (entries.length && line.trim() && !line.trim().startsWith('#')) {
47
+ entries[entries.length - 1][1].push(unquote(line.trim().replace(/^-\s+/, '')));
48
+ }
49
+ }
50
+ if (!entries.length) return null;
51
+ return entries.map(([k, v]) => [k, v.join(', ')]);
52
+ }
53
+
54
+ function unquote(v: string): string {
55
+ return /^(".*"|'.*')$/.test(v) ? v.slice(1, -1) : v;
56
+ }
57
+
58
+ /** The frontmatter as a two-column table (built with textContent, so values are never parsed as HTML). */
59
+ export function frontmatterTable(entries: [string, string][]): HTMLTableElement {
60
+ const table = document.createElement('table');
61
+ table.className = 'md-frontmatter';
62
+ const body = table.createTBody();
63
+ for (const [key, value] of entries) {
64
+ const row = body.insertRow();
65
+ const th = document.createElement('th');
66
+ th.scope = 'row';
67
+ th.textContent = key;
68
+ row.append(th);
69
+ row.insertCell().textContent = value;
70
+ }
71
+ return table;
72
+ }
73
+
74
+ const BLOCKED_ATTR = 'data-md-blocked';
75
+
76
+ /** Markdown (GFM) to sanitized HTML. Needs a DOM (DOMPurify): browser only. */
77
+ export function renderMarkdown(md: string, opts: MarkdownOptions = {}): string {
78
+ const fm = frontmatter(md);
79
+ const entries = fm ? frontmatterEntries(fm.yaml) : null;
80
+ if (fm) md = (entries ? '' : '~~~~yaml\n' + fm.yaml + '\n~~~~\n') + md.slice(fm.length);
81
+ const raw = marked.parse(md, { gfm: true, async: false }) as string;
82
+ const hook = (node: Element) => {
83
+ if (node.tagName === 'A' && node.hasAttribute('href')) {
84
+ const link = linkPolicy(node.getAttribute('href')!);
85
+ if (!link) {
86
+ node.removeAttribute('href');
87
+ } else if (link.external) {
88
+ node.setAttribute('target', '_blank');
89
+ node.setAttribute('rel', 'noopener noreferrer');
90
+ }
91
+ } else if (node.tagName === 'IMG') {
92
+ node.removeAttribute('srcset');
93
+ const src = imagePolicy(node.getAttribute('src') ?? '', opts.resolveImage);
94
+ if (src === 'blocked') node.setAttribute(BLOCKED_ATTR, node.getAttribute('src') ?? '');
95
+ else node.setAttribute('src', src);
96
+ }
97
+ };
98
+ DOMPurify.addHook('afterSanitizeAttributes', hook);
99
+ let fragment: DocumentFragment;
100
+ try {
101
+ fragment = DOMPurify.sanitize(raw, {
102
+ // HTML only: SVG/MathML links and images escape the A/IMG policies above.
103
+ USE_PROFILES: { html: true },
104
+ // Media and <picture> sources would load remote URLs past imagePolicy.
105
+ FORBID_TAGS: ['style', 'iframe', 'form', 'object', 'embed', 'video', 'audio', 'source', 'track', 'picture', 'input', 'button'],
106
+ FORBID_ATTR: ['style', 'ping', 'download', 'formaction', 'background', 'id', 'name'],
107
+ RETURN_DOM_FRAGMENT: true,
108
+ });
109
+ } finally {
110
+ DOMPurify.removeHook('afterSanitizeAttributes', hook);
111
+ }
112
+ for (const img of fragment.querySelectorAll(`img[${BLOCKED_ATTR}]`)) {
113
+ const span = document.createElement('span');
114
+ span.className = 'md-img-blocked';
115
+ span.title = img.getAttribute(BLOCKED_ATTR) ?? '';
116
+ span.textContent = opts.blockedLabel ?? 'Remote image blocked';
117
+ img.replaceWith(span);
118
+ }
119
+ const box = document.createElement('div');
120
+ if (entries) box.append(frontmatterTable(entries));
121
+ box.append(fragment);
122
+ return box.innerHTML;
123
+ }