quasar-ui-danx 0.4.95 → 0.5.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/dist/danx.es.js +25284 -23176
- package/dist/danx.es.js.map +1 -1
- package/dist/danx.umd.js +133 -120
- package/dist/danx.umd.js.map +1 -1
- package/dist/style.css +1 -1
- package/package.json +4 -2
- package/scripts/publish.sh +76 -0
- package/src/components/Utility/Buttons/ActionButton.vue +11 -3
- package/src/components/Utility/Code/CodeViewer.vue +219 -0
- package/src/components/Utility/Code/CodeViewerCollapsed.vue +34 -0
- package/src/components/Utility/Code/CodeViewerFooter.vue +53 -0
- package/src/components/Utility/Code/LanguageBadge.vue +122 -0
- package/src/components/Utility/Code/MarkdownContent.vue +405 -0
- package/src/components/Utility/Code/index.ts +5 -0
- package/src/components/Utility/Dialogs/FullscreenCarouselDialog.vue +134 -38
- package/src/components/Utility/Files/CarouselHeader.vue +24 -0
- package/src/components/Utility/Files/FileMetadataDialog.vue +69 -0
- package/src/components/Utility/Files/FilePreview.vue +118 -166
- package/src/components/Utility/Files/index.ts +1 -0
- package/src/components/Utility/index.ts +1 -0
- package/src/composables/index.ts +5 -0
- package/src/composables/useCodeFormat.ts +199 -0
- package/src/composables/useCodeViewerCollapse.ts +125 -0
- package/src/composables/useCodeViewerEditor.ts +420 -0
- package/src/composables/useFilePreview.ts +119 -0
- package/src/composables/useTranscodeLoader.ts +68 -0
- package/src/helpers/formats/highlightSyntax.ts +327 -0
- package/src/helpers/formats/index.ts +3 -1
- package/src/helpers/formats/markdown/escapeHtml.ts +15 -0
- package/src/helpers/formats/markdown/escapeSequences.ts +60 -0
- package/src/helpers/formats/markdown/index.ts +85 -0
- package/src/helpers/formats/markdown/parseInline.ts +124 -0
- package/src/helpers/formats/markdown/render/index.ts +92 -0
- package/src/helpers/formats/markdown/render/renderFootnotes.ts +30 -0
- package/src/helpers/formats/markdown/render/renderList.ts +69 -0
- package/src/helpers/formats/markdown/render/renderTable.ts +38 -0
- package/src/helpers/formats/markdown/state.ts +58 -0
- package/src/helpers/formats/markdown/tokenize/extractDefinitions.ts +39 -0
- package/src/helpers/formats/markdown/tokenize/index.ts +139 -0
- package/src/helpers/formats/markdown/tokenize/parseBlockquote.ts +34 -0
- package/src/helpers/formats/markdown/tokenize/parseCodeBlock.ts +85 -0
- package/src/helpers/formats/markdown/tokenize/parseDefinitionList.ts +88 -0
- package/src/helpers/formats/markdown/tokenize/parseHeading.ts +65 -0
- package/src/helpers/formats/markdown/tokenize/parseHorizontalRule.ts +22 -0
- package/src/helpers/formats/markdown/tokenize/parseList.ts +119 -0
- package/src/helpers/formats/markdown/tokenize/parseParagraph.ts +59 -0
- package/src/helpers/formats/markdown/tokenize/parseTable.ts +70 -0
- package/src/helpers/formats/markdown/tokenize/parseTaskList.ts +47 -0
- package/src/helpers/formats/markdown/tokenize/utils.ts +25 -0
- package/src/helpers/formats/markdown/types.ts +63 -0
- package/src/styles/danx.scss +4 -0
- package/src/styles/themes/danx/code.scss +158 -0
- package/src/styles/themes/danx/index.scss +2 -0
- package/src/styles/themes/danx/markdown.scss +241 -0
- package/src/styles/themes/danx/scrollbar.scss +125 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { onMounted, Ref, ref, watch } from "vue";
|
|
2
|
+
import { danxOptions } from "../config";
|
|
3
|
+
import { UploadedFile } from "../types";
|
|
4
|
+
|
|
5
|
+
export interface UseTranscodeLoaderOptions {
|
|
6
|
+
file: Ref<UploadedFile | null | undefined>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface UseTranscodeLoaderReturn {
|
|
10
|
+
isLoading: Ref<boolean>;
|
|
11
|
+
loadTranscodes: () => Promise<void>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Composable for loading transcodes for a file
|
|
16
|
+
* Automatically loads transcodes on mount and when the file changes
|
|
17
|
+
*/
|
|
18
|
+
export function useTranscodeLoader(options: UseTranscodeLoaderOptions): UseTranscodeLoaderReturn {
|
|
19
|
+
const { file } = options;
|
|
20
|
+
const isLoading = ref(false);
|
|
21
|
+
|
|
22
|
+
function shouldLoadTranscodes(): boolean {
|
|
23
|
+
if (!file.value?.id) return false;
|
|
24
|
+
if (isLoading.value) return false;
|
|
25
|
+
if (!danxOptions.value.fileUpload?.refreshFile) return false;
|
|
26
|
+
|
|
27
|
+
// Only load if transcodes is explicitly null, undefined, or an empty array
|
|
28
|
+
const transcodes = file.value.transcodes;
|
|
29
|
+
return transcodes === null || transcodes === undefined || (Array.isArray(transcodes) && transcodes.length === 0);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function loadTranscodes() {
|
|
33
|
+
if (!shouldLoadTranscodes()) return;
|
|
34
|
+
|
|
35
|
+
isLoading.value = true;
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const refreshFile = danxOptions.value.fileUpload?.refreshFile;
|
|
39
|
+
if (refreshFile && file.value?.id) {
|
|
40
|
+
const refreshedFile = await refreshFile(file.value.id);
|
|
41
|
+
|
|
42
|
+
// Update the file object with the loaded transcodes
|
|
43
|
+
if (refreshedFile.transcodes && file.value) {
|
|
44
|
+
file.value.transcodes = refreshedFile.transcodes;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error("Failed to load transcodes:", error);
|
|
49
|
+
} finally {
|
|
50
|
+
isLoading.value = false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Load transcodes when component mounts
|
|
55
|
+
onMounted(() => {
|
|
56
|
+
loadTranscodes();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Watch for file changes and reload transcodes if needed
|
|
60
|
+
watch(() => file.value?.id, () => {
|
|
61
|
+
loadTranscodes();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
isLoading,
|
|
66
|
+
loadTranscodes
|
|
67
|
+
};
|
|
68
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight syntax highlighting for JSON and YAML
|
|
3
|
+
* Returns HTML string with syntax highlighting spans
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type HighlightFormat = "json" | "yaml" | "text" | "markdown";
|
|
7
|
+
|
|
8
|
+
export interface HighlightOptions {
|
|
9
|
+
format: HighlightFormat;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Escape HTML entities to prevent XSS
|
|
14
|
+
*/
|
|
15
|
+
function escapeHtml(text: string): string {
|
|
16
|
+
return text
|
|
17
|
+
.replace(/&/g, "&")
|
|
18
|
+
.replace(/</g, "<")
|
|
19
|
+
.replace(/>/g, ">")
|
|
20
|
+
.replace(/"/g, """)
|
|
21
|
+
.replace(/'/g, "'");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Highlight JSON syntax by tokenizing first, then applying highlights
|
|
26
|
+
* This prevents issues with regex replacing content inside already-matched strings
|
|
27
|
+
*/
|
|
28
|
+
export function highlightJSON(code: string): string {
|
|
29
|
+
if (!code) return "";
|
|
30
|
+
|
|
31
|
+
const result: string[] = [];
|
|
32
|
+
let i = 0;
|
|
33
|
+
|
|
34
|
+
while (i < code.length) {
|
|
35
|
+
const char = code[i];
|
|
36
|
+
|
|
37
|
+
// String (starts with ")
|
|
38
|
+
if (char === '"') {
|
|
39
|
+
const startIndex = i;
|
|
40
|
+
i++; // skip opening quote
|
|
41
|
+
|
|
42
|
+
// Find the closing quote, handling escape sequences
|
|
43
|
+
while (i < code.length) {
|
|
44
|
+
if (code[i] === '\\' && i + 1 < code.length) {
|
|
45
|
+
i += 2; // skip escaped character
|
|
46
|
+
} else if (code[i] === '"') {
|
|
47
|
+
i++; // include closing quote
|
|
48
|
+
break;
|
|
49
|
+
} else {
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const str = code.slice(startIndex, i);
|
|
55
|
+
const escapedStr = escapeHtml(str);
|
|
56
|
+
|
|
57
|
+
// Check if this is a key (followed by colon)
|
|
58
|
+
const afterString = code.slice(i).match(/^(\s*):/);
|
|
59
|
+
if (afterString) {
|
|
60
|
+
result.push(`<span class="syntax-key">${escapedStr}</span>`);
|
|
61
|
+
// Add the whitespace and colon
|
|
62
|
+
result.push(`<span class="syntax-punctuation">${escapeHtml(afterString[1])}:</span>`);
|
|
63
|
+
i += afterString[0].length;
|
|
64
|
+
} else {
|
|
65
|
+
// It's a string value
|
|
66
|
+
result.push(`<span class="syntax-string">${escapedStr}</span>`);
|
|
67
|
+
}
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Number (starts with digit or minus followed by digit)
|
|
72
|
+
if (/[-\d]/.test(char)) {
|
|
73
|
+
const numMatch = code.slice(i).match(/^-?\d+(\.\d+)?([eE][+-]?\d+)?/);
|
|
74
|
+
if (numMatch) {
|
|
75
|
+
result.push(`<span class="syntax-number">${escapeHtml(numMatch[0])}</span>`);
|
|
76
|
+
i += numMatch[0].length;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Boolean true
|
|
82
|
+
if (code.slice(i, i + 4) === 'true') {
|
|
83
|
+
result.push(`<span class="syntax-boolean">true</span>`);
|
|
84
|
+
i += 4;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Boolean false
|
|
89
|
+
if (code.slice(i, i + 5) === 'false') {
|
|
90
|
+
result.push(`<span class="syntax-boolean">false</span>`);
|
|
91
|
+
i += 5;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Null
|
|
96
|
+
if (code.slice(i, i + 4) === 'null') {
|
|
97
|
+
result.push(`<span class="syntax-null">null</span>`);
|
|
98
|
+
i += 4;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Punctuation
|
|
103
|
+
if (/[{}\[\],:]/.test(char)) {
|
|
104
|
+
result.push(`<span class="syntax-punctuation">${escapeHtml(char)}</span>`);
|
|
105
|
+
i++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Whitespace and other characters
|
|
110
|
+
result.push(escapeHtml(char));
|
|
111
|
+
i++;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return result.join('');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Highlight a YAML value based on its type
|
|
119
|
+
*/
|
|
120
|
+
function highlightYAMLValue(value: string): string {
|
|
121
|
+
if (!value) return value;
|
|
122
|
+
|
|
123
|
+
// Quoted string (complete)
|
|
124
|
+
if (/^(".*"|'.*')$/.test(value) || /^["'].*["']$/.test(value)) {
|
|
125
|
+
return `<span class="syntax-string">${value}</span>`;
|
|
126
|
+
}
|
|
127
|
+
// Number (strict format: integers, decimals, scientific notation)
|
|
128
|
+
if (/^-?\d+(\.\d+)?([eE][+-]?\d+)?$/.test(value)) {
|
|
129
|
+
return `<span class="syntax-number">${value}</span>`;
|
|
130
|
+
}
|
|
131
|
+
// Boolean
|
|
132
|
+
if (/^(true|false)$/i.test(value)) {
|
|
133
|
+
return `<span class="syntax-boolean">${value}</span>`;
|
|
134
|
+
}
|
|
135
|
+
// Null
|
|
136
|
+
if (/^(null|~)$/i.test(value)) {
|
|
137
|
+
return `<span class="syntax-null">${value}</span>`;
|
|
138
|
+
}
|
|
139
|
+
// Block scalar indicators - don't wrap, handle continuation separately
|
|
140
|
+
if (/^[|>][-+]?\d*$/.test(value)) {
|
|
141
|
+
return `<span class="syntax-punctuation">${value}</span>`;
|
|
142
|
+
}
|
|
143
|
+
// Unquoted string
|
|
144
|
+
return `<span class="syntax-string">${value}</span>`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check if a line is a continuation of a multi-line string
|
|
149
|
+
* (indented content following a block scalar or inside a quoted string)
|
|
150
|
+
*/
|
|
151
|
+
function getIndentLevel(line: string): number {
|
|
152
|
+
const match = line.match(/^(\s*)/);
|
|
153
|
+
return match ? match[1].length : 0;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Highlight YAML syntax with multi-line string support
|
|
158
|
+
*/
|
|
159
|
+
export function highlightYAML(code: string): string {
|
|
160
|
+
if (!code) return "";
|
|
161
|
+
|
|
162
|
+
const lines = code.split("\n");
|
|
163
|
+
const highlightedLines: string[] = [];
|
|
164
|
+
|
|
165
|
+
// State tracking for multi-line constructs
|
|
166
|
+
let inBlockScalar = false;
|
|
167
|
+
let blockScalarIndent = 0;
|
|
168
|
+
let inQuotedString = false;
|
|
169
|
+
let quoteChar = "";
|
|
170
|
+
let inUnquotedMultiline = false;
|
|
171
|
+
let unquotedMultilineKeyIndent = 0;
|
|
172
|
+
|
|
173
|
+
for (let i = 0; i < lines.length; i++) {
|
|
174
|
+
const line = lines[i];
|
|
175
|
+
const escaped = escapeHtml(line);
|
|
176
|
+
const currentIndent = getIndentLevel(line);
|
|
177
|
+
const trimmedLine = line.trim();
|
|
178
|
+
|
|
179
|
+
// Handle block scalar continuation (| or > style)
|
|
180
|
+
if (inBlockScalar) {
|
|
181
|
+
// Block scalar ends when we hit a line with less or equal indentation (and content)
|
|
182
|
+
if (trimmedLine && currentIndent <= blockScalarIndent) {
|
|
183
|
+
inBlockScalar = false;
|
|
184
|
+
// Fall through to normal processing
|
|
185
|
+
} else {
|
|
186
|
+
// This is a continuation line - highlight as string
|
|
187
|
+
highlightedLines.push(`<span class="syntax-string">${escaped}</span>`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Handle quoted string continuation
|
|
193
|
+
if (inQuotedString) {
|
|
194
|
+
// Check if this line closes the quote
|
|
195
|
+
const escapedQuote = quoteChar === '"' ? '"' : ''';
|
|
196
|
+
|
|
197
|
+
// Count unescaped quotes in this line
|
|
198
|
+
let closed = false;
|
|
199
|
+
let searchLine = escaped;
|
|
200
|
+
while (searchLine.includes(escapedQuote)) {
|
|
201
|
+
const idx = searchLine.indexOf(escapedQuote);
|
|
202
|
+
// Check if preceded by backslash (escaped)
|
|
203
|
+
if (idx > 0 && searchLine[idx - 1] === '\\') {
|
|
204
|
+
searchLine = searchLine.slice(idx + escapedQuote.length);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
closed = true;
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (closed) {
|
|
212
|
+
// Find position of closing quote
|
|
213
|
+
const closeIdx = escaped.indexOf(escapedQuote);
|
|
214
|
+
const beforeClose = escaped.slice(0, closeIdx + escapedQuote.length);
|
|
215
|
+
const afterClose = escaped.slice(closeIdx + escapedQuote.length);
|
|
216
|
+
|
|
217
|
+
highlightedLines.push(`<span class="syntax-string">${beforeClose}</span>${afterClose}`);
|
|
218
|
+
inQuotedString = false;
|
|
219
|
+
} else {
|
|
220
|
+
// Still in quoted string
|
|
221
|
+
highlightedLines.push(`<span class="syntax-string">${escaped}</span>`);
|
|
222
|
+
}
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Handle unquoted multi-line string continuation
|
|
227
|
+
if (inUnquotedMultiline) {
|
|
228
|
+
// Unquoted multiline ends when we hit a line with <= indentation to the key
|
|
229
|
+
// or when the line contains a colon (new key-value pair)
|
|
230
|
+
if (trimmedLine && currentIndent <= unquotedMultilineKeyIndent) {
|
|
231
|
+
inUnquotedMultiline = false;
|
|
232
|
+
// Fall through to normal processing
|
|
233
|
+
} else if (trimmedLine) {
|
|
234
|
+
// This is a continuation line - highlight as string
|
|
235
|
+
highlightedLines.push(`<span class="syntax-string">${escaped}</span>`);
|
|
236
|
+
continue;
|
|
237
|
+
} else {
|
|
238
|
+
// Empty line within multiline - keep it
|
|
239
|
+
highlightedLines.push(escaped);
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Comments
|
|
245
|
+
if (/^\s*#/.test(line)) {
|
|
246
|
+
highlightedLines.push(`<span class="syntax-punctuation">${escaped}</span>`);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Key-value pairs
|
|
251
|
+
const keyValueMatch = escaped.match(/^(\s*)([^:]+?)(:)(\s*)(.*)$/);
|
|
252
|
+
if (keyValueMatch) {
|
|
253
|
+
const [, indent, key, colon, space, value] = keyValueMatch;
|
|
254
|
+
|
|
255
|
+
// Check for block scalar start
|
|
256
|
+
if (/^[|>][-+]?\d*$/.test(value.trim())) {
|
|
257
|
+
inBlockScalar = true;
|
|
258
|
+
blockScalarIndent = currentIndent;
|
|
259
|
+
const highlightedValue = `<span class="syntax-punctuation">${value}</span>`;
|
|
260
|
+
highlightedLines.push(`${indent}<span class="syntax-key">${key}</span><span class="syntax-punctuation">${colon}</span>${space}${highlightedValue}`);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Check for start of multi-line quoted string
|
|
265
|
+
const startsWithQuote = /^("|'|"|')/.test(value);
|
|
266
|
+
const endsWithQuote = /("|'|"|')$/.test(value);
|
|
267
|
+
|
|
268
|
+
if (startsWithQuote && !endsWithQuote && value.length > 1) {
|
|
269
|
+
// Multi-line quoted string starts here
|
|
270
|
+
inQuotedString = true;
|
|
271
|
+
quoteChar = value.startsWith('"') || value.startsWith('"') ? '"' : "'";
|
|
272
|
+
highlightedLines.push(`${indent}<span class="syntax-key">${key}</span><span class="syntax-punctuation">${colon}</span>${space}<span class="syntax-string">${value}</span>`);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Check for start of unquoted multi-line string
|
|
277
|
+
// If value is an unquoted string and next line is more indented, it's a multiline
|
|
278
|
+
if (value && !startsWithQuote && i + 1 < lines.length) {
|
|
279
|
+
const nextLine = lines[i + 1];
|
|
280
|
+
const nextIndent = getIndentLevel(nextLine);
|
|
281
|
+
const nextTrimmed = nextLine.trim();
|
|
282
|
+
// Next line must be more indented than current key and not be a new key-value or array item
|
|
283
|
+
if (nextTrimmed && nextIndent > currentIndent && !nextTrimmed.includes(':') && !nextTrimmed.startsWith('-')) {
|
|
284
|
+
inUnquotedMultiline = true;
|
|
285
|
+
unquotedMultilineKeyIndent = currentIndent;
|
|
286
|
+
highlightedLines.push(`${indent}<span class="syntax-key">${key}</span><span class="syntax-punctuation">${colon}</span>${space}<span class="syntax-string">${value}</span>`);
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Normal single-line value
|
|
292
|
+
const highlightedValue = highlightYAMLValue(value);
|
|
293
|
+
highlightedLines.push(`${indent}<span class="syntax-key">${key}</span><span class="syntax-punctuation">${colon}</span>${space}${highlightedValue}`);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Array items (lines starting with -)
|
|
298
|
+
const arrayMatch = escaped.match(/^(\s*)(-)(\s*)(.*)$/);
|
|
299
|
+
if (arrayMatch) {
|
|
300
|
+
const [, indent, dash, space, value] = arrayMatch;
|
|
301
|
+
const highlightedValue = value ? highlightYAMLValue(value) : "";
|
|
302
|
+
highlightedLines.push(`${indent}<span class="syntax-punctuation">${dash}</span>${space}${highlightedValue}`);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
highlightedLines.push(escaped);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return highlightedLines.join("\n");
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Highlight code based on format
|
|
314
|
+
*/
|
|
315
|
+
export function highlightSyntax(code: string, options: HighlightOptions): string {
|
|
316
|
+
if (!code) return "";
|
|
317
|
+
|
|
318
|
+
switch (options.format) {
|
|
319
|
+
case "json":
|
|
320
|
+
return highlightJSON(code);
|
|
321
|
+
case "yaml":
|
|
322
|
+
return highlightYAML(code);
|
|
323
|
+
case "text":
|
|
324
|
+
default:
|
|
325
|
+
return escapeHtml(code);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTML entity escaping for XSS prevention
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Escape HTML entities to prevent XSS
|
|
7
|
+
*/
|
|
8
|
+
export function escapeHtml(text: string): string {
|
|
9
|
+
return text
|
|
10
|
+
.replace(/&/g, "&")
|
|
11
|
+
.replace(/</g, "<")
|
|
12
|
+
.replace(/>/g, ">")
|
|
13
|
+
.replace(/"/g, """)
|
|
14
|
+
.replace(/'/g, "'");
|
|
15
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markdown escape sequence handling
|
|
3
|
+
* Maps backslash-escaped characters to Unicode placeholders and back
|
|
4
|
+
* Using Private Use Area characters (U+E000-U+F8FF) to avoid conflicts
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Escape sequences mapping - character to Unicode placeholder
|
|
9
|
+
*/
|
|
10
|
+
export const ESCAPE_MAP: Record<string, string> = {
|
|
11
|
+
"\\*": "\uE000",
|
|
12
|
+
"\\_": "\uE001",
|
|
13
|
+
"\\~": "\uE002",
|
|
14
|
+
"\\`": "\uE003",
|
|
15
|
+
"\\[": "\uE004",
|
|
16
|
+
"\\]": "\uE005",
|
|
17
|
+
"\\#": "\uE006",
|
|
18
|
+
"\\>": "\uE007", // Escaped > becomes > after HTML escaping
|
|
19
|
+
"\\-": "\uE008",
|
|
20
|
+
"\\+": "\uE009",
|
|
21
|
+
"\\.": "\uE00A",
|
|
22
|
+
"\\!": "\uE00B",
|
|
23
|
+
"\\=": "\uE00C",
|
|
24
|
+
"\\^": "\uE00D"
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Reverse mapping - placeholder back to literal character
|
|
29
|
+
* Generated from ESCAPE_MAP for DRY compliance
|
|
30
|
+
*/
|
|
31
|
+
export const UNESCAPE_MAP: Record<string, string> = Object.fromEntries(
|
|
32
|
+
Object.entries(ESCAPE_MAP).map(([escaped, placeholder]) => {
|
|
33
|
+
// Extract the literal character from the escape sequence
|
|
34
|
+
// "\\*" -> "*", "\\>" -> ">" (special case for HTML-escaped >)
|
|
35
|
+
const literal = escaped.startsWith("\\&") ? escaped.slice(1) : escaped.slice(1);
|
|
36
|
+
return [placeholder, literal];
|
|
37
|
+
})
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Apply escape sequences - convert backslash-escaped characters to placeholders
|
|
42
|
+
*/
|
|
43
|
+
export function applyEscapes(text: string): string {
|
|
44
|
+
let result = text;
|
|
45
|
+
for (const [pattern, placeholder] of Object.entries(ESCAPE_MAP)) {
|
|
46
|
+
result = result.split(pattern).join(placeholder);
|
|
47
|
+
}
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Revert escape sequences - convert placeholders back to literal characters
|
|
53
|
+
*/
|
|
54
|
+
export function revertEscapes(text: string): string {
|
|
55
|
+
let result = text;
|
|
56
|
+
for (const [placeholder, literal] of Object.entries(UNESCAPE_MAP)) {
|
|
57
|
+
result = result.split(placeholder).join(literal);
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight markdown to HTML renderer
|
|
3
|
+
* Zero external dependencies, XSS-safe by default
|
|
4
|
+
*
|
|
5
|
+
* Supports:
|
|
6
|
+
* - Headings (# through ###### and setext-style with === or ---)
|
|
7
|
+
* - Paragraphs (double newlines)
|
|
8
|
+
* - Code blocks (```language ... ``` and indented with 4 spaces or tab)
|
|
9
|
+
* - Blockquotes (> text)
|
|
10
|
+
* - Unordered lists (-, *, +)
|
|
11
|
+
* - Ordered lists (1., 2., etc.)
|
|
12
|
+
* - Task lists (- [ ] unchecked, - [x] checked)
|
|
13
|
+
* - Definition lists (Term followed by : Definition)
|
|
14
|
+
* - Tables (| col | col | with alignment support)
|
|
15
|
+
* - Horizontal rules (---, ***, ___)
|
|
16
|
+
* - Bold (**text** or __text__)
|
|
17
|
+
* - Italic (*text* or _text_)
|
|
18
|
+
* - Bold+Italic (***text***)
|
|
19
|
+
* - Inline code (`code`)
|
|
20
|
+
* - Links [text](url)
|
|
21
|
+
* - Reference-style links ([text][ref], [text][], [ref] with [ref]: url definitions)
|
|
22
|
+
* - Images 
|
|
23
|
+
* - Escape sequences (\* \_ \~ etc.)
|
|
24
|
+
* - Hard line breaks (two trailing spaces)
|
|
25
|
+
* - Autolinks (<https://...> and <email@...>)
|
|
26
|
+
* - Strikethrough (~~text~~)
|
|
27
|
+
* - Highlight (==text==)
|
|
28
|
+
* - Superscript (X^2^)
|
|
29
|
+
* - Subscript (H~2~O)
|
|
30
|
+
* - Footnotes ([^id] with [^id]: content definitions)
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
// Re-export types
|
|
34
|
+
export type {
|
|
35
|
+
MarkdownRenderOptions,
|
|
36
|
+
LinkReference,
|
|
37
|
+
FootnoteDefinition,
|
|
38
|
+
ListItem,
|
|
39
|
+
BlockToken,
|
|
40
|
+
TableAlignment,
|
|
41
|
+
ParseResult
|
|
42
|
+
} from "./types";
|
|
43
|
+
|
|
44
|
+
// Re-export state management
|
|
45
|
+
export { getFootnotes, resetParserState } from "./state";
|
|
46
|
+
|
|
47
|
+
// Re-export parsers
|
|
48
|
+
export { parseInline } from "./parseInline";
|
|
49
|
+
export { tokenizeBlocks } from "./tokenize";
|
|
50
|
+
|
|
51
|
+
// Re-export renderers
|
|
52
|
+
export { renderTokens } from "./render";
|
|
53
|
+
|
|
54
|
+
// Import for main function
|
|
55
|
+
import type { MarkdownRenderOptions } from "./types";
|
|
56
|
+
import { getFootnotes, resetParserState } from "./state";
|
|
57
|
+
import { tokenizeBlocks } from "./tokenize";
|
|
58
|
+
import { renderTokens } from "./render";
|
|
59
|
+
import { renderFootnotesSection } from "./render/renderFootnotes";
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Convert markdown text to HTML
|
|
63
|
+
*/
|
|
64
|
+
export function renderMarkdown(markdown: string, options?: MarkdownRenderOptions): string {
|
|
65
|
+
if (!markdown) return "";
|
|
66
|
+
|
|
67
|
+
const sanitize = options?.sanitize ?? true;
|
|
68
|
+
const preserveState = options?.preserveState ?? false;
|
|
69
|
+
|
|
70
|
+
// Reset state for fresh document parse (unless preserving for nested rendering)
|
|
71
|
+
if (!preserveState) {
|
|
72
|
+
resetParserState();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const tokens = tokenizeBlocks(markdown);
|
|
76
|
+
let html = renderTokens(tokens, sanitize);
|
|
77
|
+
|
|
78
|
+
// Append footnotes section if any exist (only for top-level rendering)
|
|
79
|
+
const footnotes = getFootnotes();
|
|
80
|
+
if (!preserveState && Object.keys(footnotes).length > 0) {
|
|
81
|
+
html += renderFootnotesSection(footnotes, sanitize);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return html;
|
|
85
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inline markdown element parser
|
|
3
|
+
* Handles: bold, italic, strikethrough, highlight, superscript, subscript,
|
|
4
|
+
* links, images, reference-style links, footnotes, autolinks, code
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { escapeHtml } from "./escapeHtml";
|
|
8
|
+
import { applyEscapes, revertEscapes } from "./escapeSequences";
|
|
9
|
+
import { getLinkRefs, getFootnotes } from "./state";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Parse inline markdown elements within text
|
|
13
|
+
* Order matters: more specific patterns first
|
|
14
|
+
*/
|
|
15
|
+
export function parseInline(text: string, sanitize: boolean = true): string {
|
|
16
|
+
if (!text) return "";
|
|
17
|
+
|
|
18
|
+
const currentLinkRefs = getLinkRefs();
|
|
19
|
+
const currentFootnotes = getFootnotes();
|
|
20
|
+
|
|
21
|
+
// Escape HTML if sanitizing (before applying markdown)
|
|
22
|
+
let result = sanitize ? escapeHtml(text) : text;
|
|
23
|
+
|
|
24
|
+
// 1. ESCAPE SEQUENCES - Pre-process backslash escapes to placeholders
|
|
25
|
+
// Must be first so escaped characters aren't treated as markdown
|
|
26
|
+
result = applyEscapes(result);
|
|
27
|
+
|
|
28
|
+
// 2. HARD LINE BREAKS - Two trailing spaces + newline becomes <br />
|
|
29
|
+
result = result.replace(/ {2,}\n/g, "<br />\n");
|
|
30
|
+
|
|
31
|
+
// 3. AUTOLINKS - Must be before regular link parsing
|
|
32
|
+
// URL autolinks: <https://example.com>
|
|
33
|
+
result = result.replace(/<(https?:\/\/[^&]+)>/g, '<a href="$1">$1</a>');
|
|
34
|
+
// Email autolinks: <user@example.com>
|
|
35
|
+
result = result.replace(/<([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})>/g, '<a href="mailto:$1">$1</a>');
|
|
36
|
+
|
|
37
|
+
// 4. FOOTNOTE REFERENCES: [^id]
|
|
38
|
+
// Must be before regular link/image parsing to avoid conflicts
|
|
39
|
+
result = result.replace(/\[\^([^\]]+)\]/g, (match, fnId) => {
|
|
40
|
+
const fn = currentFootnotes[fnId];
|
|
41
|
+
if (fn) {
|
|
42
|
+
return `<sup class="footnote-ref"><a href="#fn-${fnId}" id="fnref-${fnId}">[${fn.index}]</a></sup>`;
|
|
43
|
+
}
|
|
44
|
+
return match; // Keep original if footnote not defined
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// 5. IMAGES:  - must be before links
|
|
48
|
+
result = result.replace(
|
|
49
|
+
/!\[([^\]]*)\]\(([^)]+)\)/g,
|
|
50
|
+
'<img src="$2" alt="$1" />'
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// 6. INLINE LINKS: [text](url)
|
|
54
|
+
result = result.replace(
|
|
55
|
+
/\[([^\]]+)\]\(([^)]+)\)/g,
|
|
56
|
+
'<a href="$2">$1</a>'
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
// 7. REFERENCE-STYLE LINKS - Process after regular links
|
|
60
|
+
// Full reference: [text][ref-id]
|
|
61
|
+
result = result.replace(/\[([^\]]+)\]\[([^\]]+)\]/g, (match, text, refId) => {
|
|
62
|
+
const ref = currentLinkRefs[refId.toLowerCase()];
|
|
63
|
+
if (ref) {
|
|
64
|
+
const title = ref.title ? ` title="${escapeHtml(ref.title)}"` : "";
|
|
65
|
+
return `<a href="${ref.url}"${title}>${text}</a>`;
|
|
66
|
+
}
|
|
67
|
+
return match; // Keep original if ref not found
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// Collapsed reference: [text][]
|
|
71
|
+
result = result.replace(/\[([^\]]+)\]\[\]/g, (match, text) => {
|
|
72
|
+
const ref = currentLinkRefs[text.toLowerCase()];
|
|
73
|
+
if (ref) {
|
|
74
|
+
const title = ref.title ? ` title="${escapeHtml(ref.title)}"` : "";
|
|
75
|
+
return `<a href="${ref.url}"${title}>${text}</a>`;
|
|
76
|
+
}
|
|
77
|
+
return match;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Shortcut reference: [ref] alone (must not match [text](url) or [text][ref])
|
|
81
|
+
// Only match [word] not followed by ( or [
|
|
82
|
+
result = result.replace(/\[([^\]]+)\](?!\(|\[)/g, (match, text) => {
|
|
83
|
+
const ref = currentLinkRefs[text.toLowerCase()];
|
|
84
|
+
if (ref) {
|
|
85
|
+
const title = ref.title ? ` title="${escapeHtml(ref.title)}"` : "";
|
|
86
|
+
return `<a href="${ref.url}"${title}>${text}</a>`;
|
|
87
|
+
}
|
|
88
|
+
return match;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// 8. INLINE CODE: `code`
|
|
92
|
+
result = result.replace(/`([^`]+)`/g, "<code>$1</code>");
|
|
93
|
+
|
|
94
|
+
// 9. STRIKETHROUGH: ~~text~~ - Must be before subscript (single tilde)
|
|
95
|
+
result = result.replace(/~~([^~]+)~~/g, "<del>$1</del>");
|
|
96
|
+
|
|
97
|
+
// 10. HIGHLIGHT: ==text==
|
|
98
|
+
result = result.replace(/==([^=]+)==/g, "<mark>$1</mark>");
|
|
99
|
+
|
|
100
|
+
// 11. SUPERSCRIPT: X^2^ - Must be before subscript
|
|
101
|
+
result = result.replace(/\^([^\^]+)\^/g, "<sup>$1</sup>");
|
|
102
|
+
|
|
103
|
+
// 12. SUBSCRIPT: H~2~O - Single tilde, use negative lookbehind/lookahead to avoid ~~
|
|
104
|
+
result = result.replace(/(?<!~)~([^~]+)~(?!~)/g, "<sub>$1</sub>");
|
|
105
|
+
|
|
106
|
+
// 13. BOLD + ITALIC: ***text*** or ___text___
|
|
107
|
+
result = result.replace(/\*\*\*([^*]+)\*\*\*/g, "<strong><em>$1</em></strong>");
|
|
108
|
+
result = result.replace(/___([^_]+)___/g, "<strong><em>$1</em></strong>");
|
|
109
|
+
|
|
110
|
+
// 14. BOLD: **text** or __text__
|
|
111
|
+
result = result.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
|
112
|
+
result = result.replace(/__([^_]+)__/g, "<strong>$1</strong>");
|
|
113
|
+
|
|
114
|
+
// 15. ITALIC: *text* or _text_ (but not inside words for underscores)
|
|
115
|
+
// For asterisks, match any single asterisk pairs
|
|
116
|
+
result = result.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
|
117
|
+
// For underscores, only match at word boundaries
|
|
118
|
+
result = result.replace(/(^|[^a-zA-Z0-9])_([^_]+)_([^a-zA-Z0-9]|$)/g, "$1<em>$2</em>$3");
|
|
119
|
+
|
|
120
|
+
// LAST: Restore escaped characters from placeholders to literals
|
|
121
|
+
result = revertEscapes(result);
|
|
122
|
+
|
|
123
|
+
return result;
|
|
124
|
+
}
|