@chrysb/alphaclaw 0.3.2 → 0.3.3
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/bin/alphaclaw.js +29 -2
- package/lib/cli/git-sync.js +25 -0
- package/lib/public/css/explorer.css +983 -0
- package/lib/public/css/shell.css +48 -4
- package/lib/public/css/theme.css +6 -1
- package/lib/public/icons/folder-line.svg +1 -0
- package/lib/public/icons/hashtag.svg +3 -0
- package/lib/public/icons/home-5-line.svg +1 -0
- package/lib/public/icons/save-fill.svg +3 -0
- package/lib/public/js/app.js +259 -158
- package/lib/public/js/components/action-button.js +12 -1
- package/lib/public/js/components/file-tree.js +322 -0
- package/lib/public/js/components/file-viewer.js +691 -0
- package/lib/public/js/components/icons.js +182 -0
- package/lib/public/js/components/sidebar-git-panel.js +149 -0
- package/lib/public/js/components/sidebar.js +272 -0
- package/lib/public/js/lib/api.js +26 -0
- package/lib/public/js/lib/browse-draft-state.js +109 -0
- package/lib/public/js/lib/file-highlighting.js +6 -0
- package/lib/public/js/lib/file-tree-utils.js +12 -0
- package/lib/public/js/lib/syntax-highlighters/css.js +124 -0
- package/lib/public/js/lib/syntax-highlighters/frontmatter.js +49 -0
- package/lib/public/js/lib/syntax-highlighters/html.js +209 -0
- package/lib/public/js/lib/syntax-highlighters/index.js +28 -0
- package/lib/public/js/lib/syntax-highlighters/javascript.js +134 -0
- package/lib/public/js/lib/syntax-highlighters/json.js +61 -0
- package/lib/public/js/lib/syntax-highlighters/markdown.js +37 -0
- package/lib/public/js/lib/syntax-highlighters/utils.js +13 -0
- package/lib/public/setup.html +1 -0
- package/lib/server/constants.js +1 -0
- package/lib/server/onboarding/workspace.js +3 -2
- package/lib/server/routes/browse.js +295 -0
- package/lib/server.js +24 -3
- package/lib/setup/core-prompts/TOOLS.md +3 -1
- package/lib/setup/skills/control-ui/SKILL.md +12 -20
- package/package.json +1 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export const kFileDraftStorageKeyPrefix = "alphaclaw.browse.draft.";
|
|
2
|
+
export const kLegacyFileDraftStorageKeyPrefix = "alphaclawBrowseDraft:";
|
|
3
|
+
export const kDraftIndexStorageKey = "alphaclaw.draftIndex";
|
|
4
|
+
export const kDraftIndexChangedEventName = "alphaclaw:browse-draft-index-changed";
|
|
5
|
+
|
|
6
|
+
const getStorage = (storage) => storage || window.localStorage;
|
|
7
|
+
|
|
8
|
+
export const getFileDraftStorageKey = (filePath, useLegacyPrefix = false) =>
|
|
9
|
+
`${useLegacyPrefix ? kLegacyFileDraftStorageKeyPrefix : kFileDraftStorageKeyPrefix}${String(filePath || "").trim()}`;
|
|
10
|
+
|
|
11
|
+
export const readStoredFileDraft = (filePath, storage) => {
|
|
12
|
+
try {
|
|
13
|
+
if (!filePath) return "";
|
|
14
|
+
const localStorage = getStorage(storage);
|
|
15
|
+
const draft =
|
|
16
|
+
localStorage.getItem(getFileDraftStorageKey(filePath)) ||
|
|
17
|
+
localStorage.getItem(getFileDraftStorageKey(filePath, true));
|
|
18
|
+
return typeof draft === "string" ? draft : "";
|
|
19
|
+
} catch {
|
|
20
|
+
return "";
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const writeStoredFileDraft = (filePath, content, storage) => {
|
|
25
|
+
try {
|
|
26
|
+
if (!filePath) return;
|
|
27
|
+
const localStorage = getStorage(storage);
|
|
28
|
+
localStorage.setItem(getFileDraftStorageKey(filePath), String(content || ""));
|
|
29
|
+
} catch {}
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const clearStoredFileDraft = (filePath, storage) => {
|
|
33
|
+
try {
|
|
34
|
+
if (!filePath) return;
|
|
35
|
+
const localStorage = getStorage(storage);
|
|
36
|
+
localStorage.removeItem(getFileDraftStorageKey(filePath));
|
|
37
|
+
localStorage.removeItem(getFileDraftStorageKey(filePath, true));
|
|
38
|
+
} catch {}
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const readDraftIndex = (storage) => {
|
|
42
|
+
try {
|
|
43
|
+
const localStorage = getStorage(storage);
|
|
44
|
+
const rawValue = localStorage.getItem(kDraftIndexStorageKey);
|
|
45
|
+
if (!rawValue) return new Set();
|
|
46
|
+
const parsedValue = JSON.parse(rawValue);
|
|
47
|
+
if (!Array.isArray(parsedValue)) return new Set();
|
|
48
|
+
return new Set(
|
|
49
|
+
parsedValue.map((entry) => String(entry || "").trim()).filter(Boolean),
|
|
50
|
+
);
|
|
51
|
+
} catch {
|
|
52
|
+
return new Set();
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export const writeDraftIndex = (draftPaths, options = {}) => {
|
|
57
|
+
const { storage, dispatchEvent } = options;
|
|
58
|
+
try {
|
|
59
|
+
const localStorage = getStorage(storage);
|
|
60
|
+
const normalizedPaths = Array.from(draftPaths).sort((left, right) =>
|
|
61
|
+
left.localeCompare(right),
|
|
62
|
+
);
|
|
63
|
+
localStorage.setItem(kDraftIndexStorageKey, JSON.stringify(normalizedPaths));
|
|
64
|
+
if (dispatchEvent) {
|
|
65
|
+
dispatchEvent(
|
|
66
|
+
new CustomEvent(kDraftIndexChangedEventName, {
|
|
67
|
+
detail: { paths: normalizedPaths },
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
} catch {}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const updateDraftIndex = (filePath, hasDraft, options = {}) => {
|
|
75
|
+
const { storage, dispatchEvent } = options;
|
|
76
|
+
if (!filePath) return;
|
|
77
|
+
const normalizedPath = String(filePath || "").trim();
|
|
78
|
+
if (!normalizedPath) return;
|
|
79
|
+
const nextDraftPaths = readDraftIndex(storage);
|
|
80
|
+
if (hasDraft) nextDraftPaths.add(normalizedPath);
|
|
81
|
+
else nextDraftPaths.delete(normalizedPath);
|
|
82
|
+
writeDraftIndex(nextDraftPaths, { storage, dispatchEvent });
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
export const readStoredDraftPaths = (storage) => {
|
|
86
|
+
try {
|
|
87
|
+
const localStorage = getStorage(storage);
|
|
88
|
+
const draftIndex = readDraftIndex(localStorage);
|
|
89
|
+
if (draftIndex.size > 0) return draftIndex;
|
|
90
|
+
const nextDraftPaths = new Set();
|
|
91
|
+
for (let index = 0; index < localStorage.length; index += 1) {
|
|
92
|
+
const key = localStorage.key(index) || "";
|
|
93
|
+
if (key.startsWith(kFileDraftStorageKeyPrefix)) {
|
|
94
|
+
const path = key.slice(kFileDraftStorageKeyPrefix.length).trim();
|
|
95
|
+
if (path) nextDraftPaths.add(path);
|
|
96
|
+
}
|
|
97
|
+
if (key.startsWith(kLegacyFileDraftStorageKeyPrefix)) {
|
|
98
|
+
const path = key.slice(kLegacyFileDraftStorageKeyPrefix.length).trim();
|
|
99
|
+
if (path) nextDraftPaths.add(path);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (nextDraftPaths.size > 0) {
|
|
103
|
+
writeDraftIndex(nextDraftPaths, { storage: localStorage });
|
|
104
|
+
}
|
|
105
|
+
return nextDraftPaths;
|
|
106
|
+
} catch {
|
|
107
|
+
return new Set();
|
|
108
|
+
}
|
|
109
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const collectAncestorFolderPaths = (targetPath) => {
|
|
2
|
+
const normalizedPath = String(targetPath || "")
|
|
3
|
+
.split("/")
|
|
4
|
+
.map((segment) => segment.trim())
|
|
5
|
+
.filter(Boolean);
|
|
6
|
+
if (normalizedPath.length <= 1) return [];
|
|
7
|
+
const ancestors = [];
|
|
8
|
+
for (let index = 0; index < normalizedPath.length - 1; index += 1) {
|
|
9
|
+
ancestors.push(normalizedPath.slice(0, index + 1).join("/"));
|
|
10
|
+
}
|
|
11
|
+
return ancestors;
|
|
12
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { escapeHtml } from "./utils.js";
|
|
2
|
+
|
|
3
|
+
const kCssNumberRegex =
|
|
4
|
+
/(^|[^\w.#-])(-?\d+(?:\.\d+)?(?:px|em|rem|vh|vw|%|deg|s|ms)?)(?=$|[^\w-])/g;
|
|
5
|
+
|
|
6
|
+
const highlightCssTextSegment = (text) => {
|
|
7
|
+
let content = escapeHtml(text);
|
|
8
|
+
content = content.replace(/@[a-zA-Z-]+/g, '<span class="hl-keyword">$&</span>');
|
|
9
|
+
content = content.replace(/#[0-9a-fA-F]{3,8}\b/g, '<span class="hl-number">$&</span>');
|
|
10
|
+
content = content.replace(kCssNumberRegex, '$1<span class="hl-number">$2</span>');
|
|
11
|
+
content = content.replace(
|
|
12
|
+
/(^|[;{\s])([a-zA-Z-]+)(\s*:)/g,
|
|
13
|
+
'$1<span class="hl-attr">$2</span>$3',
|
|
14
|
+
);
|
|
15
|
+
return content;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const findClosingQuote = (source, startIndex, quote) => {
|
|
19
|
+
let index = startIndex + 1;
|
|
20
|
+
while (index < source.length) {
|
|
21
|
+
if (source[index] === "\\" && index + 1 < source.length) {
|
|
22
|
+
index += 2;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (source[index] === quote) return index;
|
|
26
|
+
index += 1;
|
|
27
|
+
}
|
|
28
|
+
return -1;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const tokenizeCssLine = (line, inBlockComment) => {
|
|
32
|
+
const source = String(line || "");
|
|
33
|
+
const parts = [];
|
|
34
|
+
let cursor = 0;
|
|
35
|
+
let nextInBlockComment = inBlockComment;
|
|
36
|
+
|
|
37
|
+
while (cursor < source.length) {
|
|
38
|
+
if (nextInBlockComment) {
|
|
39
|
+
const blockEnd = source.indexOf("*/", cursor);
|
|
40
|
+
if (blockEnd === -1) {
|
|
41
|
+
parts.push({ kind: "comment", value: source.slice(cursor) });
|
|
42
|
+
return { parts, inBlockComment: true };
|
|
43
|
+
}
|
|
44
|
+
parts.push({ kind: "comment", value: source.slice(cursor, blockEnd + 2) });
|
|
45
|
+
cursor = blockEnd + 2;
|
|
46
|
+
nextInBlockComment = false;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const blockCommentIndex = source.indexOf("/*", cursor);
|
|
51
|
+
const singleQuoteIndex = source.indexOf("'", cursor);
|
|
52
|
+
const doubleQuoteIndex = source.indexOf('"', cursor);
|
|
53
|
+
const indexes = [blockCommentIndex, singleQuoteIndex, doubleQuoteIndex].filter(
|
|
54
|
+
(index) => index !== -1,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (indexes.length === 0) {
|
|
58
|
+
parts.push({ kind: "text", value: source.slice(cursor) });
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const nextIndex = Math.min(...indexes);
|
|
63
|
+
if (nextIndex > cursor) {
|
|
64
|
+
parts.push({ kind: "text", value: source.slice(cursor, nextIndex) });
|
|
65
|
+
cursor = nextIndex;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (blockCommentIndex === nextIndex) {
|
|
69
|
+
const blockEnd = source.indexOf("*/", nextIndex + 2);
|
|
70
|
+
if (blockEnd === -1) {
|
|
71
|
+
parts.push({ kind: "comment", value: source.slice(nextIndex) });
|
|
72
|
+
nextInBlockComment = true;
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
parts.push({ kind: "comment", value: source.slice(nextIndex, blockEnd + 2) });
|
|
76
|
+
cursor = blockEnd + 2;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const quote = source[nextIndex];
|
|
81
|
+
const quoteEnd = findClosingQuote(source, nextIndex, quote);
|
|
82
|
+
if (quoteEnd === -1) {
|
|
83
|
+
parts.push({ kind: "string", value: source.slice(nextIndex) });
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
parts.push({ kind: "string", value: source.slice(nextIndex, quoteEnd + 1) });
|
|
87
|
+
cursor = quoteEnd + 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { parts, inBlockComment: nextInBlockComment };
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const highlightCssLine = (line, state = { inBlockComment: false }) => {
|
|
94
|
+
const tokens = tokenizeCssLine(line, Boolean(state?.inBlockComment));
|
|
95
|
+
const html = tokens.parts
|
|
96
|
+
.map((part) => {
|
|
97
|
+
if (part.kind === "comment") {
|
|
98
|
+
return `<span class="hl-comment">${escapeHtml(part.value)}</span>`;
|
|
99
|
+
}
|
|
100
|
+
if (part.kind === "string") {
|
|
101
|
+
return `<span class="hl-string">${escapeHtml(part.value)}</span>`;
|
|
102
|
+
}
|
|
103
|
+
return highlightCssTextSegment(part.value);
|
|
104
|
+
})
|
|
105
|
+
.join("");
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
html,
|
|
109
|
+
state: { inBlockComment: tokens.inBlockComment },
|
|
110
|
+
};
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const highlightCssContent = (content) => {
|
|
114
|
+
const lines = String(content || "").split("\n");
|
|
115
|
+
let state = { inBlockComment: false };
|
|
116
|
+
return lines.map((line, index) => {
|
|
117
|
+
const renderedLine = highlightCssLine(line, state);
|
|
118
|
+
state = renderedLine.state;
|
|
119
|
+
return {
|
|
120
|
+
lineNumber: index + 1,
|
|
121
|
+
html: renderedLine.html,
|
|
122
|
+
};
|
|
123
|
+
});
|
|
124
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export const parseFrontmatter = (markdown) => {
|
|
2
|
+
const value = String(markdown || "");
|
|
3
|
+
if (!(value.startsWith("---\n") || value === "---")) {
|
|
4
|
+
return { entries: [], body: value };
|
|
5
|
+
}
|
|
6
|
+
const lines = value.split("\n");
|
|
7
|
+
if (lines[0] !== "---") {
|
|
8
|
+
return { entries: [], body: value };
|
|
9
|
+
}
|
|
10
|
+
const closingFenceIndex = lines.findIndex(
|
|
11
|
+
(line, index) => index > 0 && line === "---",
|
|
12
|
+
);
|
|
13
|
+
if (closingFenceIndex === -1) {
|
|
14
|
+
return { entries: [], body: value };
|
|
15
|
+
}
|
|
16
|
+
const frontmatterLines = lines.slice(1, closingFenceIndex);
|
|
17
|
+
const bodyLines = lines.slice(closingFenceIndex + 1);
|
|
18
|
+
const entries = frontmatterLines
|
|
19
|
+
.map((line) => {
|
|
20
|
+
const separatorIndex = line.indexOf(":");
|
|
21
|
+
if (separatorIndex <= 0) return null;
|
|
22
|
+
const key = line.slice(0, separatorIndex).trim();
|
|
23
|
+
const rawValue = line.slice(separatorIndex + 1).trim();
|
|
24
|
+
if (!key) return null;
|
|
25
|
+
return { key, rawValue };
|
|
26
|
+
})
|
|
27
|
+
.filter((entry) => entry !== null);
|
|
28
|
+
return {
|
|
29
|
+
entries,
|
|
30
|
+
body: bodyLines.join("\n").replace(/^\n+/, ""),
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export const formatFrontmatterValue = (rawValue) => {
|
|
35
|
+
const trimmedValue = String(rawValue || "").trim();
|
|
36
|
+
if (!trimmedValue) return trimmedValue;
|
|
37
|
+
if (
|
|
38
|
+
(trimmedValue.startsWith("{") && trimmedValue.endsWith("}")) ||
|
|
39
|
+
(trimmedValue.startsWith("[") && trimmedValue.endsWith("]"))
|
|
40
|
+
) {
|
|
41
|
+
try {
|
|
42
|
+
const parsedValue = JSON.parse(trimmedValue);
|
|
43
|
+
return JSON.stringify(parsedValue, null, 2);
|
|
44
|
+
} catch {
|
|
45
|
+
return trimmedValue;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return trimmedValue;
|
|
49
|
+
};
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { escapeHtml } from "./utils.js";
|
|
2
|
+
import { highlightCssLine } from "./css.js";
|
|
3
|
+
import { highlightJavaScriptLine } from "./javascript.js";
|
|
4
|
+
|
|
5
|
+
const highlightHtmlTextSegment = (text) =>
|
|
6
|
+
escapeHtml(text).replace(
|
|
7
|
+
/(&[a-zA-Z][a-zA-Z0-9]+;|&#\d+;|&#x[0-9a-fA-F]+;)/g,
|
|
8
|
+
'<span class="hl-entity">$1</span>',
|
|
9
|
+
);
|
|
10
|
+
|
|
11
|
+
const highlightHtmlAttributeValue = (valueWithSpace) => {
|
|
12
|
+
const leadingWhitespace = valueWithSpace.match(/^\s*/)?.[0] || "";
|
|
13
|
+
const rawValue = valueWithSpace.slice(leadingWhitespace.length);
|
|
14
|
+
return `${escapeHtml(leadingWhitespace)}<span class="hl-string">${escapeHtml(rawValue)}</span>`;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const highlightHtmlAttributes = (attributesText) => {
|
|
18
|
+
const attrRegex = /([:@A-Za-z_][\w:.-]*)(\s*=\s*(?:"[^"]*"|'[^']*'|[^\s"'=<>`]+))?/g;
|
|
19
|
+
let html = "";
|
|
20
|
+
let cursor = 0;
|
|
21
|
+
let match = attrRegex.exec(attributesText);
|
|
22
|
+
|
|
23
|
+
while (match) {
|
|
24
|
+
const fullMatch = match[0];
|
|
25
|
+
const attrName = match[1];
|
|
26
|
+
const attrAssignment = match[2] || "";
|
|
27
|
+
const start = match.index;
|
|
28
|
+
const end = start + fullMatch.length;
|
|
29
|
+
|
|
30
|
+
if (start > cursor) {
|
|
31
|
+
html += escapeHtml(attributesText.slice(cursor, start));
|
|
32
|
+
}
|
|
33
|
+
html += `<span class="hl-attr">${escapeHtml(attrName)}</span>`;
|
|
34
|
+
|
|
35
|
+
if (attrAssignment) {
|
|
36
|
+
const equalsIndex = attrAssignment.indexOf("=");
|
|
37
|
+
if (equalsIndex !== -1) {
|
|
38
|
+
const beforeEquals = attrAssignment.slice(0, equalsIndex);
|
|
39
|
+
const afterEquals = attrAssignment.slice(equalsIndex + 1);
|
|
40
|
+
html += `${escapeHtml(beforeEquals)}<span class="hl-punc">=</span>${highlightHtmlAttributeValue(afterEquals)}`;
|
|
41
|
+
} else {
|
|
42
|
+
html += escapeHtml(attrAssignment);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
cursor = end;
|
|
47
|
+
match = attrRegex.exec(attributesText);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (cursor < attributesText.length) {
|
|
51
|
+
html += escapeHtml(attributesText.slice(cursor));
|
|
52
|
+
}
|
|
53
|
+
return html;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const renderHighlightedHtmlTag = (tagText) => {
|
|
57
|
+
if (/^<!--[\s\S]*-->$/.test(tagText) || /^<!DOCTYPE/i.test(tagText)) {
|
|
58
|
+
return `<span class="hl-meta">${escapeHtml(tagText)}</span>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const tagMatch = tagText.match(/^<\s*(\/?)\s*([A-Za-z][\w:-]*)([\s\S]*?)(\/?)\s*>$/);
|
|
62
|
+
if (!tagMatch) {
|
|
63
|
+
return `<span class="hl-tag">${escapeHtml(tagText)}</span>`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const isClosing = tagMatch[1] === "/";
|
|
67
|
+
const tagName = tagMatch[2];
|
|
68
|
+
const attributesText = tagMatch[3] || "";
|
|
69
|
+
const isSelfClosing = tagMatch[4] === "/";
|
|
70
|
+
const open = isClosing ? "</" : "<";
|
|
71
|
+
const attrsHtml = isClosing ? "" : highlightHtmlAttributes(attributesText);
|
|
72
|
+
const close = isSelfClosing ? "/>" : ">";
|
|
73
|
+
|
|
74
|
+
return `<span class="hl-punc">${open}</span><span class="hl-tag">${escapeHtml(tagName)}</span>${attrsHtml}<span class="hl-punc">${close}</span>`;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const renderHighlightedHtmlLine = (line) => {
|
|
78
|
+
const tokenRegex = /<!--[\s\S]*?-->|<!DOCTYPE[^>]*>|<\/?[A-Za-z][^>]*>/gi;
|
|
79
|
+
const source = String(line || "");
|
|
80
|
+
let html = "";
|
|
81
|
+
let cursor = 0;
|
|
82
|
+
let match = tokenRegex.exec(source);
|
|
83
|
+
|
|
84
|
+
while (match) {
|
|
85
|
+
const token = match[0];
|
|
86
|
+
const start = match.index;
|
|
87
|
+
const end = start + token.length;
|
|
88
|
+
if (start > cursor) {
|
|
89
|
+
html += highlightHtmlTextSegment(source.slice(cursor, start));
|
|
90
|
+
}
|
|
91
|
+
html += renderHighlightedHtmlTag(token);
|
|
92
|
+
cursor = end;
|
|
93
|
+
match = tokenRegex.exec(source);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (cursor < source.length) {
|
|
97
|
+
html += highlightHtmlTextSegment(source.slice(cursor));
|
|
98
|
+
}
|
|
99
|
+
return html;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const findNextTag = (source, tagName) => {
|
|
103
|
+
const regex = new RegExp(`<\\/?\\s*${tagName}\\b[^>]*>`, "ig");
|
|
104
|
+
const match = regex.exec(source);
|
|
105
|
+
if (!match) return null;
|
|
106
|
+
return {
|
|
107
|
+
text: match[0],
|
|
108
|
+
start: match.index,
|
|
109
|
+
end: match.index + match[0].length,
|
|
110
|
+
isClosing: /^<\s*\//.test(match[0]),
|
|
111
|
+
};
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const highlightInlineSection = (line, state) => {
|
|
115
|
+
let html = "";
|
|
116
|
+
let cursor = 0;
|
|
117
|
+
let nextMode = state.mode;
|
|
118
|
+
let nextLanguageState = state.languageState;
|
|
119
|
+
|
|
120
|
+
while (cursor < line.length) {
|
|
121
|
+
if (nextMode === "script") {
|
|
122
|
+
const closeTag = findNextTag(line.slice(cursor), "script");
|
|
123
|
+
if (!closeTag || !closeTag.isClosing) {
|
|
124
|
+
const renderedJs = highlightJavaScriptLine(line.slice(cursor), nextLanguageState);
|
|
125
|
+
html += renderedJs.html;
|
|
126
|
+
nextLanguageState = renderedJs.state;
|
|
127
|
+
cursor = line.length;
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
const absoluteCloseStart = cursor + closeTag.start;
|
|
131
|
+
const absoluteCloseEnd = cursor + closeTag.end;
|
|
132
|
+
const jsPart = line.slice(cursor, absoluteCloseStart);
|
|
133
|
+
const renderedJs = highlightJavaScriptLine(jsPart, nextLanguageState);
|
|
134
|
+
html += renderedJs.html;
|
|
135
|
+
html += renderHighlightedHtmlLine(line.slice(absoluteCloseStart, absoluteCloseEnd));
|
|
136
|
+
nextMode = "html";
|
|
137
|
+
nextLanguageState = { inBlockComment: false };
|
|
138
|
+
cursor = absoluteCloseEnd;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (nextMode === "style") {
|
|
143
|
+
const closeTag = findNextTag(line.slice(cursor), "style");
|
|
144
|
+
if (!closeTag || !closeTag.isClosing) {
|
|
145
|
+
const renderedCss = highlightCssLine(line.slice(cursor), nextLanguageState);
|
|
146
|
+
html += renderedCss.html;
|
|
147
|
+
nextLanguageState = renderedCss.state;
|
|
148
|
+
cursor = line.length;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
const absoluteCloseStart = cursor + closeTag.start;
|
|
152
|
+
const absoluteCloseEnd = cursor + closeTag.end;
|
|
153
|
+
const cssPart = line.slice(cursor, absoluteCloseStart);
|
|
154
|
+
const renderedCss = highlightCssLine(cssPart, nextLanguageState);
|
|
155
|
+
html += renderedCss.html;
|
|
156
|
+
html += renderHighlightedHtmlLine(line.slice(absoluteCloseStart, absoluteCloseEnd));
|
|
157
|
+
nextMode = "html";
|
|
158
|
+
nextLanguageState = { inBlockComment: false };
|
|
159
|
+
cursor = absoluteCloseEnd;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const remaining = line.slice(cursor);
|
|
164
|
+
const nextScript = findNextTag(remaining, "script");
|
|
165
|
+
const nextStyle = findNextTag(remaining, "style");
|
|
166
|
+
const candidates = [nextScript, nextStyle]
|
|
167
|
+
.filter((candidate) => candidate && !candidate.isClosing)
|
|
168
|
+
.sort((left, right) => left.start - right.start);
|
|
169
|
+
|
|
170
|
+
if (candidates.length === 0) {
|
|
171
|
+
html += renderHighlightedHtmlLine(remaining);
|
|
172
|
+
cursor = line.length;
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const nextTag = candidates[0];
|
|
177
|
+
const absoluteTagStart = cursor + nextTag.start;
|
|
178
|
+
const absoluteTagEnd = cursor + nextTag.end;
|
|
179
|
+
html += renderHighlightedHtmlLine(line.slice(cursor, absoluteTagStart));
|
|
180
|
+
html += renderHighlightedHtmlLine(line.slice(absoluteTagStart, absoluteTagEnd));
|
|
181
|
+
nextMode = /<\s*script\b/i.test(nextTag.text) ? "script" : "style";
|
|
182
|
+
nextLanguageState = { inBlockComment: false };
|
|
183
|
+
cursor = absoluteTagEnd;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
html,
|
|
188
|
+
state: {
|
|
189
|
+
mode: nextMode,
|
|
190
|
+
languageState: nextLanguageState,
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
export const highlightHtmlContent = (content) => {
|
|
196
|
+
const lines = String(content || "").split("\n");
|
|
197
|
+
let state = {
|
|
198
|
+
mode: "html",
|
|
199
|
+
languageState: { inBlockComment: false },
|
|
200
|
+
};
|
|
201
|
+
return lines.map((line, index) => {
|
|
202
|
+
const renderedLine = highlightInlineSection(line, state);
|
|
203
|
+
state = renderedLine.state;
|
|
204
|
+
return {
|
|
205
|
+
lineNumber: index + 1,
|
|
206
|
+
html: renderedLine.html,
|
|
207
|
+
};
|
|
208
|
+
});
|
|
209
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { formatFrontmatterValue, parseFrontmatter } from "./frontmatter.js";
|
|
2
|
+
import { highlightCssContent } from "./css.js";
|
|
3
|
+
import { highlightHtmlContent } from "./html.js";
|
|
4
|
+
import { highlightJavaScriptContent } from "./javascript.js";
|
|
5
|
+
import { highlightJsonContent } from "./json.js";
|
|
6
|
+
import { highlightMarkdownContent } from "./markdown.js";
|
|
7
|
+
import { escapeHtml, toLineObjects } from "./utils.js";
|
|
8
|
+
|
|
9
|
+
export const getFileSyntaxKind = (filePath) => {
|
|
10
|
+
const normalizedPath = String(filePath || "").toLowerCase();
|
|
11
|
+
if (/\.(md|markdown|mdx)$/i.test(normalizedPath)) return "markdown";
|
|
12
|
+
if (/\.(json|jsonl)$/i.test(normalizedPath)) return "json";
|
|
13
|
+
if (/\.(html|htm)$/i.test(normalizedPath)) return "html";
|
|
14
|
+
if (/\.(js|mjs|cjs)$/i.test(normalizedPath)) return "javascript";
|
|
15
|
+
if (/\.(css|scss)$/i.test(normalizedPath)) return "css";
|
|
16
|
+
return "plain";
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const highlightEditorLines = (content, syntaxKind) => {
|
|
20
|
+
if (syntaxKind === "markdown") return highlightMarkdownContent(content);
|
|
21
|
+
if (syntaxKind === "json") return highlightJsonContent(content);
|
|
22
|
+
if (syntaxKind === "html") return highlightHtmlContent(content);
|
|
23
|
+
if (syntaxKind === "javascript") return highlightJavaScriptContent(content);
|
|
24
|
+
if (syntaxKind === "css") return highlightCssContent(content);
|
|
25
|
+
return toLineObjects(content, (line) => escapeHtml(line));
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export { formatFrontmatterValue, parseFrontmatter };
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { escapeHtml } from "./utils.js";
|
|
2
|
+
|
|
3
|
+
const kJavaScriptKeywordsRegex =
|
|
4
|
+
/\b(await|break|case|catch|class|const|continue|debugger|default|delete|do|else|export|extends|finally|for|from|function|if|import|in|instanceof|let|new|of|return|static|super|switch|this|throw|try|typeof|var|void|while|with|yield)\b/g;
|
|
5
|
+
|
|
6
|
+
const kNumberRegex =
|
|
7
|
+
/(^|[^\w.])(-?(?:0x[a-fA-F0-9]+|\d+(?:\.\d+)?(?:[eE][+-]?\d+)?))(?=$|[^\w.])/g;
|
|
8
|
+
|
|
9
|
+
const highlightJavaScriptTextSegment = (text) => {
|
|
10
|
+
let content = escapeHtml(text);
|
|
11
|
+
content = content.replace(kJavaScriptKeywordsRegex, '<span class="hl-keyword">$1</span>');
|
|
12
|
+
content = content.replace(/\b(true|false)\b/g, '<span class="hl-boolean">$1</span>');
|
|
13
|
+
content = content.replace(/\b(null|undefined)\b/g, '<span class="hl-null">$1</span>');
|
|
14
|
+
content = content.replace(kNumberRegex, '$1<span class="hl-number">$2</span>');
|
|
15
|
+
return content;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const findClosingQuote = (source, startIndex, quote) => {
|
|
19
|
+
let index = startIndex + 1;
|
|
20
|
+
while (index < source.length) {
|
|
21
|
+
if (source[index] === "\\" && index + 1 < source.length) {
|
|
22
|
+
index += 2;
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (source[index] === quote) return index;
|
|
26
|
+
index += 1;
|
|
27
|
+
}
|
|
28
|
+
return -1;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const tokenizeJavaScriptLine = (line, inBlockComment) => {
|
|
32
|
+
const source = String(line || "");
|
|
33
|
+
const parts = [];
|
|
34
|
+
let cursor = 0;
|
|
35
|
+
let nextInBlockComment = inBlockComment;
|
|
36
|
+
|
|
37
|
+
while (cursor < source.length) {
|
|
38
|
+
if (nextInBlockComment) {
|
|
39
|
+
const blockEnd = source.indexOf("*/", cursor);
|
|
40
|
+
if (blockEnd === -1) {
|
|
41
|
+
parts.push({ kind: "comment", value: source.slice(cursor) });
|
|
42
|
+
return { parts, inBlockComment: true };
|
|
43
|
+
}
|
|
44
|
+
parts.push({ kind: "comment", value: source.slice(cursor, blockEnd + 2) });
|
|
45
|
+
cursor = blockEnd + 2;
|
|
46
|
+
nextInBlockComment = false;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const lineCommentIndex = source.indexOf("//", cursor);
|
|
51
|
+
const blockCommentIndex = source.indexOf("/*", cursor);
|
|
52
|
+
const singleQuoteIndex = source.indexOf("'", cursor);
|
|
53
|
+
const doubleQuoteIndex = source.indexOf('"', cursor);
|
|
54
|
+
const templateQuoteIndex = source.indexOf("`", cursor);
|
|
55
|
+
const indexes = [
|
|
56
|
+
lineCommentIndex,
|
|
57
|
+
blockCommentIndex,
|
|
58
|
+
singleQuoteIndex,
|
|
59
|
+
doubleQuoteIndex,
|
|
60
|
+
templateQuoteIndex,
|
|
61
|
+
].filter((index) => index !== -1);
|
|
62
|
+
|
|
63
|
+
if (indexes.length === 0) {
|
|
64
|
+
parts.push({ kind: "text", value: source.slice(cursor) });
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const nextIndex = Math.min(...indexes);
|
|
69
|
+
if (nextIndex > cursor) {
|
|
70
|
+
parts.push({ kind: "text", value: source.slice(cursor, nextIndex) });
|
|
71
|
+
cursor = nextIndex;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (lineCommentIndex === nextIndex) {
|
|
75
|
+
parts.push({ kind: "comment", value: source.slice(nextIndex) });
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (blockCommentIndex === nextIndex) {
|
|
80
|
+
const blockEnd = source.indexOf("*/", nextIndex + 2);
|
|
81
|
+
if (blockEnd === -1) {
|
|
82
|
+
parts.push({ kind: "comment", value: source.slice(nextIndex) });
|
|
83
|
+
nextInBlockComment = true;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
parts.push({ kind: "comment", value: source.slice(nextIndex, blockEnd + 2) });
|
|
87
|
+
cursor = blockEnd + 2;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const quote = source[nextIndex];
|
|
92
|
+
const quoteEnd = findClosingQuote(source, nextIndex, quote);
|
|
93
|
+
if (quoteEnd === -1) {
|
|
94
|
+
parts.push({ kind: "string", value: source.slice(nextIndex) });
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
parts.push({ kind: "string", value: source.slice(nextIndex, quoteEnd + 1) });
|
|
98
|
+
cursor = quoteEnd + 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { parts, inBlockComment: nextInBlockComment };
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const highlightJavaScriptLine = (line, state = { inBlockComment: false }) => {
|
|
105
|
+
const tokens = tokenizeJavaScriptLine(line, Boolean(state?.inBlockComment));
|
|
106
|
+
const html = tokens.parts
|
|
107
|
+
.map((part) => {
|
|
108
|
+
if (part.kind === "comment") {
|
|
109
|
+
return `<span class="hl-comment">${escapeHtml(part.value)}</span>`;
|
|
110
|
+
}
|
|
111
|
+
if (part.kind === "string") {
|
|
112
|
+
return `<span class="hl-string">${escapeHtml(part.value)}</span>`;
|
|
113
|
+
}
|
|
114
|
+
return highlightJavaScriptTextSegment(part.value);
|
|
115
|
+
})
|
|
116
|
+
.join("");
|
|
117
|
+
return {
|
|
118
|
+
html,
|
|
119
|
+
state: { inBlockComment: tokens.inBlockComment },
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
export const highlightJavaScriptContent = (content) => {
|
|
124
|
+
const lines = String(content || "").split("\n");
|
|
125
|
+
let state = { inBlockComment: false };
|
|
126
|
+
return lines.map((line, index) => {
|
|
127
|
+
const renderedLine = highlightJavaScriptLine(line, state);
|
|
128
|
+
state = renderedLine.state;
|
|
129
|
+
return {
|
|
130
|
+
lineNumber: index + 1,
|
|
131
|
+
html: renderedLine.html,
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
};
|