Skip to content

Commit 5291829

Browse files
authored
Some bug fixes and review comments
1 parent 431f6e1 commit 5291829

9 files changed

Lines changed: 129 additions & 69 deletions

File tree

‎.github/workflows/single-file.yml‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ on:
55
branches: ["*"]
66
workflow_dispatch:
77

8+
permissions:
9+
contents: read
10+
811
concurrency:
912
group: "pages"
1013
cancel-in-progress: false
@@ -19,7 +22,7 @@ jobs:
1922
- name: Use Node.js
2023
uses: actions/setup-node@v4
2124
with:
22-
node-version: "22" # Using latest Node 22 for 2026 compatibility
25+
node-version: "20"
2326

2427
- name: Install dependencies
2528
working-directory: Build

‎Build/src/appState.ts‎

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,13 @@ const splitSizesInitial =
3535
: [50, 50];
3636
const STORAGE_KEY = "htmlRunnerState";
3737

38+
const ALLOWED_TABS = ["html", "css", "js"] as const;
39+
const ALLOWED_OUTPUTS = ["preview", "console"] as const;
40+
41+
let _lastPersistedSnapshot = "";
42+
let _idleHandle: number | null = null;
43+
let _timeoutHandle: number | null = null;
44+
3845
export const htmlState = signal(path("htmlrunner", "editor", "html"), htmlInitial);
3946
export const cssState = signal(path("htmlrunner", "editor", "css"), cssInitial);
4047
export const jsState = signal(path("htmlrunner", "editor", "js"), jsInitial);
@@ -46,11 +53,56 @@ export const autoRunState = signal(path("htmlrunner", "editor", "autoRun"), auto
4653
export const stateHydrated = signal(path("htmlrunner", "meta", "stateHydrated"), false);
4754

4855
effect(() => {
49-
if (!stateHydrated.get()) {
50-
return;
56+
if (!stateHydrated.get()) return;
57+
58+
const snapshot = createStateSnapshot();
59+
const snapshotStr = JSON.stringify(snapshot);
60+
61+
// Skip if nothing changed since last successful persist
62+
if (snapshotStr === _lastPersistedSnapshot) return;
63+
64+
// Cancel pending schedules
65+
if (_idleHandle != null && (window as any).cancelIdleCallback) {
66+
(window as any).cancelIdleCallback(_idleHandle);
67+
_idleHandle = null;
68+
}
69+
if (_timeoutHandle != null) {
70+
clearTimeout(_timeoutHandle);
71+
_timeoutHandle = null;
72+
}
73+
74+
const writeNow = () => {
75+
try {
76+
localStorage.setItem(STORAGE_KEY, snapshotStr);
77+
_lastPersistedSnapshot = snapshotStr;
78+
} catch (e) {
79+
console.warn("Failed to persist state:", e);
80+
}
81+
};
82+
83+
// Prefer idle callback when available, fallback to a short timeout (500ms)
84+
if (typeof (window as any).requestIdleCallback === "function") {
85+
_idleHandle = (window as any).requestIdleCallback(() => {
86+
writeNow();
87+
_idleHandle = null;
88+
}, { timeout: 1000 });
89+
} else {
90+
_timeoutHandle = window.setTimeout(() => {
91+
writeNow();
92+
_timeoutHandle = null;
93+
}, 500);
5194
}
5295

53-
localStorage.setItem("htmlRunnerState", JSON.stringify(createStateSnapshot()));
96+
return () => {
97+
if (_idleHandle != null && (window as any).cancelIdleCallback) {
98+
(window as any).cancelIdleCallback(_idleHandle);
99+
_idleHandle = null;
100+
}
101+
if (_timeoutHandle != null) {
102+
clearTimeout(_timeoutHandle);
103+
_timeoutHandle = null;
104+
}
105+
};
54106
});
55107

56108
export function createStateSnapshot(): State {
@@ -80,11 +132,19 @@ export function applyStateSnapshot(snapshot: Partial<State>): void {
80132
}
81133

82134
if (typeof snapshot.activeTab === "string") {
83-
activeTabState.set(snapshot.activeTab);
135+
if ((ALLOWED_TABS as readonly string[]).includes(snapshot.activeTab)) {
136+
activeTabState.set(snapshot.activeTab);
137+
} else {
138+
console.warn("Ignoring invalid persisted activeTab:", snapshot.activeTab);
139+
}
84140
}
85141

86142
if (typeof snapshot.activeOutput === "string") {
87-
activeOutputState.set(snapshot.activeOutput);
143+
if ((ALLOWED_OUTPUTS as readonly string[]).includes(snapshot.activeOutput)) {
144+
activeOutputState.set(snapshot.activeOutput);
145+
} else {
146+
console.warn("Ignoring invalid persisted activeOutput:", snapshot.activeOutput);
147+
}
88148
}
89149

90150
if (

‎Build/src/defaultContent.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export const defaultHtml = `<!DOCTYPE html>
55
<link rel="stylesheet" href="styles.css">
66
</head>
77
<body>
8-
<script src="main.js"><\/script>
8+
<script src="script.js"><\/script>
99
<h1>Hello, HTMLRunner!</h1>
1010
<p>This is a demo page.</p>
1111
<button onclick="testFunction()">Click me!</button>

‎Build/src/editor.ts‎

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ export function setAutoRun(value: boolean): void {
3636
autoRunState.set(value);
3737
}
3838

39+
// Shared debounced runner used by auto-run listeners so debounce state is preserved across keystrokes
40+
const debouncedRun = debounce(runCode, 1000);
41+
export const autoRunListener = EditorView.updateListener.of((update) => {
42+
if (update.docChanged) debouncedRun();
43+
});
44+
3945
function createEditorConfig(
4046
language: Extension,
4147
container: HTMLElement,
@@ -68,29 +74,11 @@ function createEditorConfig(
6874
),
6975
lintGutter(),
7076
keymap.of([
71-
...standardKeymap, // Add standard keymap
72-
...defaultKeymap, // Add default keymap
73-
{
74-
key: "Ctrl-/",
75-
run: (view: EditorView) => {
76-
view.dispatch({
77-
changes: { from: 0, to: view.state.doc.length, insert: "" },
78-
});
79-
return true;
80-
},
81-
preventDefault: true,
82-
},
77+
...defaultKeymap,
78+
...standardKeymap,
79+
{ key: "Mod-/", run: toggleComment },
8380
]),
84-
autoRunCompartment.of(
85-
autoRunState.get()
86-
? EditorView.updateListener.of((update) => {
87-
if (update.docChanged) {
88-
contentState.set(update.state.doc.toString());
89-
debounce(runCode, 1000)();
90-
}
91-
})
92-
: []
93-
),
81+
autoRunCompartment.of(autoRunState.get() ? autoRunListener : []),
9482
EditorView.updateListener.of((update) => {
9583
if (update.docChanged) {
9684
contentState.set(update.state.doc.toString());

‎Build/src/runner.ts‎

Lines changed: 21 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,11 @@ async function loadPrettierBundle(): Promise<PrettierBundle> {
2929
parserBabel,
3030
prettierPluginEstree,
3131
})
32-
);
32+
).catch((err) => {
33+
// Reset cached promise so future attempts can retry
34+
prettierBundlePromise = undefined;
35+
throw err;
36+
});
3337
}
3438

3539
return prettierBundlePromise;
@@ -120,6 +124,12 @@ export async function formatCode(): Promise<void> {
120124
prettierPluginEstree,
121125
} = await loadPrettierBundle();
122126

127+
// Some dynamic imports expose the plugin as default export or as module namespace.
128+
const pHtml = (parserHtml as any).default || parserHtml;
129+
const pCss = (parserCss as any).default || parserCss;
130+
const pBabel = (parserBabel as any).default || parserBabel;
131+
const pEstree = (prettierPluginEstree as any).default || prettierPluginEstree;
132+
123133
// Format each editor separately with error handling
124134
let formattedHtml = editors.html.view.state.doc.toString();
125135
let formattedCss = editors.css.view.state.doc.toString();
@@ -128,12 +138,12 @@ export async function formatCode(): Promise<void> {
128138
// Format HTML
129139
try {
130140
if (formattedHtml.trim()) {
131-
// First normalize the HTML by removing extra whitespace
132-
const normalizedHtml = formattedHtml.trim().replace(/^\s+/gm, '');
133-
141+
// Trim surrounding whitespace
142+
const normalizedHtml = formattedHtml.trim();
143+
134144
formattedHtml = await prettier.format(normalizedHtml, {
135145
parser: "html",
136-
plugins: [parserHtml],
146+
plugins: [pHtml],
137147
printWidth: 120,
138148
tabWidth: 4,
139149
htmlWhitespaceSensitivity: "ignore",
@@ -144,9 +154,6 @@ export async function formatCode(): Promise<void> {
144154
// Ensure the formatted HTML is well-formed
145155
formattedHtml = formattedHtml.replace(/>\n\s*\n/g, '>\n');
146156

147-
// Remove two spaces from the beginning of each line
148-
formattedHtml = formattedHtml.replace(/^ /gm, '');
149-
150157
}
151158
} catch (error) {
152159
console.warn("HTML formatting skipped:", error);
@@ -158,12 +165,11 @@ export async function formatCode(): Promise<void> {
158165
if (formattedCss.trim()) {
159166
formattedCss = await prettier.format(formattedCss, {
160167
parser: "css",
161-
plugins: [parserCss],
168+
plugins: [pCss],
162169
printWidth: 100,
163170
tabWidth: 2,
164171
});
165-
// Remove two spaces from the beginning of each line
166-
formattedCss = formattedCss.replace(/^ /gm, '');
172+
167173
}
168174
} catch (error) {
169175
console.warn("CSS formatting failed:", error);
@@ -174,37 +180,29 @@ export async function formatCode(): Promise<void> {
174180
if (formattedJs.trim()) {
175181
formattedJs = await prettier.format(formattedJs, {
176182
parser: "babel", // Use babel instead of flow
177-
plugins: [
178-
parserBabel,
179-
(prettierPluginEstree as any).default || prettierPluginEstree,
180-
],
183+
plugins: [pBabel, pEstree],
181184
printWidth: 100,
182185
tabWidth: 2,
183186
semi: true,
184187
singleQuote: true,
185188
trailingComma: "es5",
186189
bracketSpacing: true,
187190
});
188-
// Remove two spaces from the beginning of each line
189-
formattedJs = formattedJs.replace(/^ /gm, '');
191+
190192
}
191193
} catch (error) {
192194
console.warn("JavaScript formatting failed:", error);
193195
// Try with a simpler parser as fallback
194196
try {
195197
formattedJs = await prettier.format(formattedJs, {
196198
parser: "babel-ts", // Alternative parser
197-
plugins: [
198-
parserBabel,
199-
(prettierPluginEstree as any).default || prettierPluginEstree,
200-
],
199+
plugins: [pBabel, pEstree],
201200
printWidth: 100,
202201
tabWidth: 2,
203202
semi: true,
204203
singleQuote: true,
205204
});
206-
// Remove two spaces from the beginning of each line
207-
formattedJs = formattedJs.replace(/^ /gm, '');
205+
208206
} catch (fallbackError) {
209207
console.warn("Fallback JavaScript formatting also failed:", fallbackError);
210208
}

‎Build/src/ui.ts‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {
22
editors,
33
setDarkMode,
44
setAutoRun,
5+
autoRunListener,
56
} from "./editor";
67
import { runCode } from "./runner";
78
import { debounce } from "./utils";
@@ -92,13 +93,7 @@ export function toggleAutoRun(): void {
9293
setAutoRun(newAutoRun);
9394

9495
Object.values(editors).forEach((editor) => {
95-
const listener = newAutoRun
96-
? EditorView.updateListener.of((update) => {
97-
if (update.docChanged) {
98-
debounce(runCode, 1000)();
99-
}
100-
})
101-
: [];
96+
const listener = newAutoRun ? autoRunListener : [];
10297
editor.view.dispatch({
10398
effects: editor.autoRunCompartment.reconfigure(listener),
10499
});

‎Build/vite.config.mjs‎

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { defineConfig, loadEnv } from "vite";
22
import { VitePWA } from "vite-plugin-pwa";
33
import { viteSingleFile } from "vite-plugin-singlefile";
44
import fs from "fs";
5+
import path from "path";
56

67
// SVG inliner plugin
78
function inlineSvgFaviconPlugin(options) {
@@ -10,15 +11,29 @@ function inlineSvgFaviconPlugin(options) {
1011
enforce: "post",
1112
transformIndexHtml(html) {
1213
if (!fs.existsSync(options.svg)) return html;
13-
let svgContent = fs.readFileSync(options.svg, "utf8");
14-
// Remove XML header if present, minify spaces
15-
svgContent = svgContent
16-
.replace(/<\?xml[^>]*>\s*/g, "")
17-
.replace(/\s+/g, " ");
18-
// Base64 encode the SVG
19-
const base64 = Buffer.from(svgContent).toString("base64");
20-
const faviconTag = `<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,${base64}"/>\n`;
21-
// Insert favicon into <head>
14+
const ext = path.extname(options.svg).toLowerCase();
15+
let faviconTag = "";
16+
17+
try {
18+
if (ext === ".svg") {
19+
let svgContent = fs.readFileSync(options.svg, "utf8");
20+
svgContent = svgContent
21+
.replace(/<\?xml[^>]*>\s*/g, "")
22+
.replace(/\s+/g, " ");
23+
const base64 = Buffer.from(svgContent).toString("base64");
24+
faviconTag = `<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;base64,${base64}"/>\n`;
25+
} else {
26+
// Non-SVG (png, ico, etc.) — read binary and base64-encode
27+
const buf = fs.readFileSync(options.svg);
28+
const base64 = buf.toString("base64");
29+
const mime = ext === ".png" ? "image/png" : ext === ".ico" ? "image/x-icon" : "application/octet-stream";
30+
faviconTag = `<link rel="icon" type="${mime}" href="data:${mime};base64,${base64}"/>\n`;
31+
}
32+
} catch (e) {
33+
// If reading fails, don't modify the HTML
34+
return html;
35+
}
36+
2237
return html.replace(/<head>(.*?)/, `<head>$1\n ${faviconTag}`);
2338
},
2439
};

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ A powerful, browser-based HTML/CSS/JavaScript code editor and live preview tool.
3232
- **Export Functionality** - Download your project as a ZIP file
3333
- **Copy to Clipboard** for individual editors or console output
3434
- **Responsive Split-Panel** layout
35+
- **State Management** - Uses the Sairin path-based reactive store to manage editor content, UI state, and persistence.
3536

3637
### **User Experience**
3738

‎TODO.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99
- [ ] **Version Control** - Built-in Git integration using [isomorphic-git](https://github.com/isomorphic-git/isomorphic-git)
1010
- [ ] **Template Library** - Pre-built code snippets
1111
- [ ] **Performance Profiler** - Analyze code performance
12-
- [ ] **Auto Completion** (using the built in CodeMirror tools)
12+
- [ ] **Auto Completion** (using the built-in CodeMirror tools)
1313
- [ ] **Code Folding**
1414
- [ ] **Clickable Stack Traces**

0 commit comments

Comments
 (0)