diff --git a/package-lock.json b/package-lock.json
index eed599f..fe2f813 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,20 +1,24 @@
{
"name": "browsercode",
- "version": "1.1.0",
+ "version": "1.2.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "browsercode",
- "version": "1.1.0",
+ "version": "1.2.0",
"dependencies": {
"@iconify-json/mingcute": "^1.2.7",
"@iconify/svelte": "^5.2.1",
"@leaningtech/browserpod": "3.0.1",
+ "@shikijs/langs": "^4.4.3",
+ "@shikijs/monaco": "^4.4.3",
+ "@shikijs/themes": "^4.4.3",
"@sveltejs/adapter-static": "^3.0.10",
"fflate": "^0.8.3",
"monaco-editor": "^0.55.1",
- "qrcode": "^1.5.4"
+ "qrcode": "^1.5.4",
+ "shiki": "^4.4.3"
},
"devDependencies": {
"@eslint/compat": "^2.0.2",
@@ -1148,6 +1152,120 @@
"win32"
]
},
+ "node_modules/@shikijs/core": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz",
+ "integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/primitive": "4.4.3",
+ "@shikijs/types": "4.4.3",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.5",
+ "hast-util-to-html": "^9.0.5"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/engine-javascript": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz",
+ "integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.4.3",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "oniguruma-to-es": "^4.3.6"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/engine-oniguruma": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.4.3.tgz",
+ "integrity": "sha512-EcOQkxdxGQrc1Row/cC2c96/v1dbZqGnEVu1qTuT/MJmp6+cXCvQussowVmCv5Tqr3KuY3c7IbM6HTW3LJ1k9w==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.4.3",
+ "@shikijs/vscode-textmate": "^10.0.2"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/langs": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz",
+ "integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.4.3"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/monaco": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@shikijs/monaco/-/monaco-4.4.3.tgz",
+ "integrity": "sha512-xyXoWFtqVsN33RhGbZReHFKOoxGbDEJ5mgGKdUbQLe6GF0/DDv7C4kgxkNwbWwBo7zhkICbqIqCfGjKKdF8RzQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/core": "4.4.3",
+ "@shikijs/types": "4.4.3",
+ "@shikijs/vscode-textmate": "^10.0.2"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/primitive": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz",
+ "integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.4.3",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.5"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/themes": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.4.3.tgz",
+ "integrity": "sha512-w8UHjeUnIR965KMWJHUPXOc2mNJUnK3vpVLYLvw5IYU2mnTTJ89E24OrJDBNiJDQ0qzb0tc4l7mrIXx5cFeIyw==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/types": "4.4.3"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/types": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz",
+ "integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.5"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@shikijs/vscode-textmate": {
+ "version": "10.0.2",
+ "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
+ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
+ "license": "MIT"
+ },
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -1534,6 +1652,15 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"license": "MIT"
},
+ "node_modules/@types/hast": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
+ "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1541,6 +1668,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/mdast": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "24.12.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz",
@@ -1567,6 +1703,12 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
+ "node_modules/@types/unist": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
+ "license": "MIT"
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz",
@@ -1849,6 +1991,12 @@
"url": "https://opencollective.com/eslint"
}
},
+ "node_modules/@ungap/structured-clone": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz",
+ "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==",
+ "license": "ISC"
+ },
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
@@ -1974,6 +2122,16 @@
"node": ">=6"
}
},
+ "node_modules/ccount": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@@ -1991,6 +2149,26 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
+ "node_modules/character-entities-html4": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
+ "node_modules/character-entities-legacy": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -2045,6 +2223,16 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
+ "node_modules/comma-separated-tokens": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -2132,6 +2320,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@@ -2148,6 +2345,19 @@
"integrity": "sha512-2zA9pFEsnp7vWBZbXF5JAgAq0fsUIt/1XPbRiAmRV3lp/2C3upzH+sADiyy66aFCihoLEsrQHxNM5w1gIDfsBg==",
"license": "MIT"
},
+ "node_modules/devlop": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
+ "license": "MIT",
+ "dependencies": {
+ "dequal": "^2.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
@@ -2651,6 +2861,52 @@
"node": ">=8"
}
},
+ "node_modules/hast-util-to-html": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
+ "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/unist": "^3.0.0",
+ "ccount": "^2.0.0",
+ "comma-separated-tokens": "^2.0.0",
+ "hast-util-whitespace": "^3.0.0",
+ "html-void-elements": "^3.0.0",
+ "mdast-util-to-hast": "^13.0.0",
+ "property-information": "^7.0.0",
+ "space-separated-tokens": "^2.0.0",
+ "stringify-entities": "^4.0.0",
+ "zwitch": "^2.0.4"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/hast-util-whitespace": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/html-void-elements": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
+ "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3130,6 +3386,116 @@
"node": ">= 18"
}
},
+ "node_modules/mdast-util-to-hast": {
+ "version": "13.2.1",
+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/hast": "^3.0.0",
+ "@types/mdast": "^4.0.0",
+ "@ungap/structured-clone": "^1.0.0",
+ "devlop": "^1.0.0",
+ "micromark-util-sanitize-uri": "^2.0.0",
+ "trim-lines": "^3.0.0",
+ "unist-util-position": "^5.0.0",
+ "unist-util-visit": "^5.0.0",
+ "vfile": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/micromark-util-character": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-symbol": "^2.0.0",
+ "micromark-util-types": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-encode": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-sanitize-uri": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "micromark-util-character": "^2.0.0",
+ "micromark-util-encode": "^2.0.0",
+ "micromark-util-symbol": "^2.0.0"
+ }
+ },
+ "node_modules/micromark-util-symbol": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/micromark-util-types": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
+ "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
+ "funding": [
+ {
+ "type": "GitHub Sponsors",
+ "url": "https://github.com/sponsors/unifiedjs"
+ },
+ {
+ "type": "OpenCollective",
+ "url": "https://opencollective.com/unified"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -3214,6 +3580,23 @@
],
"license": "MIT"
},
+ "node_modules/oniguruma-parser": {
+ "version": "0.12.2",
+ "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz",
+ "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==",
+ "license": "MIT"
+ },
+ "node_modules/oniguruma-to-es": {
+ "version": "4.3.6",
+ "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz",
+ "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==",
+ "license": "MIT",
+ "dependencies": {
+ "oniguruma-parser": "^0.12.2",
+ "regex": "^6.1.0",
+ "regex-recursion": "^6.0.2"
+ }
+ },
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -3584,6 +3967,16 @@
}
}
},
+ "node_modules/property-information": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
+ "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -3625,6 +4018,30 @@
"url": "https://paulmillr.com/funding/"
}
},
+ "node_modules/regex": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
+ "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==",
+ "license": "MIT",
+ "dependencies": {
+ "regex-utilities": "^2.3.0"
+ }
+ },
+ "node_modules/regex-recursion": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz",
+ "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==",
+ "license": "MIT",
+ "dependencies": {
+ "regex-utilities": "^2.3.0"
+ }
+ },
+ "node_modules/regex-utilities": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz",
+ "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
+ "license": "MIT"
+ },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -3755,6 +4172,25 @@
"node": ">=8"
}
},
+ "node_modules/shiki": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.4.3.tgz",
+ "integrity": "sha512-Mb/GvXPHBAXdgGIcnfU5L3ldpn1XcxrGkPHwqgRx17/I2XRfqlFKk2vGkHWINn1kdXvzJZeuO3is6I9KLPFm0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@shikijs/core": "4.4.3",
+ "@shikijs/engine-javascript": "4.4.3",
+ "@shikijs/engine-oniguruma": "4.4.3",
+ "@shikijs/langs": "4.4.3",
+ "@shikijs/themes": "4.4.3",
+ "@shikijs/types": "4.4.3",
+ "@shikijs/vscode-textmate": "^10.0.2",
+ "@types/hast": "^3.0.5"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@@ -3778,6 +4214,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/space-separated-tokens": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -3792,6 +4238,20 @@
"node": ">=8"
}
},
+ "node_modules/stringify-entities": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
+ "license": "MIT",
+ "dependencies": {
+ "character-entities-html4": "^2.0.0",
+ "character-entities-legacy": "^3.0.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
@@ -3958,6 +4418,16 @@
"node": ">=6"
}
},
+ "node_modules/trim-lines": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
+ },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -4037,6 +4507,74 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/unist-util-is": {
+ "version": "6.0.1",
+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
+ "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-position": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-stringify-position": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit": {
+ "version": "5.1.0",
+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
+ "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0",
+ "unist-util-visit-parents": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/unist-util-visit-parents": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
+ "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-is": "^6.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -4054,6 +4592,34 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/vfile": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "vfile-message": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
+ "node_modules/vfile-message": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
+ "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/unist": "^3.0.0",
+ "unist-util-stringify-position": "^4.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/unified"
+ }
+ },
"node_modules/vite": {
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
@@ -4304,6 +4870,16 @@
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
"license": "MIT"
+ },
+ "node_modules/zwitch": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/wooorm"
+ }
}
}
}
diff --git a/package.json b/package.json
index 3b7ab53..9505c3d 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "browsercode",
"private": true,
- "version": "1.1.0",
+ "version": "1.2.0",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -39,9 +39,13 @@
"@iconify-json/mingcute": "^1.2.7",
"@iconify/svelte": "^5.2.1",
"@leaningtech/browserpod": "3.0.1",
+ "@shikijs/langs": "^4.4.3",
+ "@shikijs/monaco": "^4.4.3",
+ "@shikijs/themes": "^4.4.3",
"@sveltejs/adapter-static": "^3.0.10",
"fflate": "^0.8.3",
"monaco-editor": "^0.55.1",
- "qrcode": "^1.5.4"
+ "qrcode": "^1.5.4",
+ "shiki": "^4.4.3"
}
}
diff --git a/src/lib/components/Portal.svelte b/src/lib/components/Portal.svelte
index b40a86a..32a1caf 100644
--- a/src/lib/components/Portal.svelte
+++ b/src/lib/components/Portal.svelte
@@ -1,4 +1,5 @@
{#if portals.length > 0}
-
+
-
-
- Preview
-
+ {#if onCollapse}
+
+
+
+
+ {/if}
{#if src}
-
- {#if portals.length > 1}
-
-
- {#each portals as item (item.port)}
- :{item.port}
- {/each}
-
-
+
+
+ port
+ {selectedPort}
-
-
+
+ {#if hasChoice}
+
+ {/if}
+
+
+ {#if showPorts}
+
{/if}
+
+
+
+
+
+
+
+
+
+
+
-
- {copied ? 'Copied!' : 'Portal'}
+
{#if showMenu}
-
-
-
- Copy link
-
-
-
- Open in new tab
+
{/if}
+ {#if showInfo}
+
+ {/if}
+ {/if}
+
+ {#if sweeping}
+ {#key sweepId}
+
+ {/key}
{/if}
@@ -153,42 +267,140 @@
{src}
id="portal"
title="Portal content"
+ bind:this={frameEl}
onload={onFrameLoad}
class="h-full min-h-0 w-full border-none {frameStatus === 'ready'
? 'bg-white'
: 'bg-bc-navy'}"
>
{/if}
-
- {#if showInfo}
-
-
-
- Dismiss
-
-
-
-
-
-
- {#if qrError}
-
{qrError}
- {:else}
-
-
- {src}
-
- {/if}
-
- {/if}
{/if}
{/if}
+
+
diff --git a/src/lib/components/ide/EditorPane.svelte b/src/lib/components/ide/EditorPane.svelte
index 1119ab1..730ed05 100644
--- a/src/lib/components/ide/EditorPane.svelte
+++ b/src/lib/components/ide/EditorPane.svelte
@@ -3,8 +3,10 @@
import { SvelteMap } from 'svelte/reactivity';
import Icon from '@iconify/svelte';
import { fileIcon } from '$lib/ide/file-icons';
+ import ImageViewer from '$lib/components/ide/ImageViewer.svelte';
import type * as Monaco from 'monaco-editor';
import type { IdeSession } from '$lib/ide/session.svelte';
+ import { activeEditorThemeId } from '$lib/stores/editor-settings.svelte';
let { session }: { session: IdeSession } = $props();
@@ -18,6 +20,8 @@
const viewStates = new SvelteMap
();
let renderedPath = '';
+ let activeFile = $derived(session.openFiles.find((file) => file.path === session.selectedFile));
+
// Responsive font
const FONT_QUERY = '(min-width: 640px)';
const fontSizeFor = (desktop: boolean) => (desktop ? 12.8 : 11.5);
@@ -27,13 +31,15 @@
const onMediaChange = () => editor?.updateOptions({ fontSize: fontSizeFor(media.matches) });
media.addEventListener('change', onMediaChange);
- // Load Monaco lazily
- void import('./monaco').then((mod) => {
+ // Load Monaco lazily. Highlighting must be installed first: it patches `editor.create`.
+ void (async () => {
+ const mod = await import('./monaco');
+ await mod.highlightingReady;
if (destroyed || !container) return;
monacoMod = mod;
editor = mod.monaco.editor.create(container, {
model: null,
- theme: 'browsercode-dark',
+ theme: activeEditorThemeId(),
automaticLayout: true,
fontSize: fontSizeFor(media.matches),
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace",
@@ -55,17 +61,19 @@
entry.content = value;
entry.preview = false;
});
- });
+ })();
return () => media.removeEventListener('change', onMediaChange);
});
- /** Returns (creating if needed) the Monaco model that backs `path`. */
+ /**
+ * Returns (creating if needed) the Monaco model that backs `path`. Passing no language lets
+ * Monaco resolve it from the URI.
+ */
function modelFor(mod: typeof import('./monaco'), path: string, content: string) {
const uri = mod.monaco.Uri.file(path);
return (
- mod.monaco.editor.getModel(uri) ??
- mod.monaco.editor.createModel(content, mod.languageFor(path), uri)
+ mod.monaco.editor.getModel(uri) ?? mod.monaco.editor.createModel(content, undefined, uri)
);
}
@@ -82,11 +90,12 @@
// Show the active tab: park the outgoing view state, attach the incoming
// model, restore its cursor/scroll.
$effect(() => {
- const entry = session.openFiles.find((file) => file.path === session.selectedFile);
+ const entry = activeFile;
if (!editor || !monacoMod) return;
// Track the reveal request so a jump to the already-open file still re-runs this effect.
void session.revealRequest;
- if (!entry) {
+ // Detaching on an image tab stops the previous file's text showing through under it.
+ if (!entry || entry.image) {
if (renderedPath) viewStates.set(renderedPath, editor.saveViewState());
editor.setModel(null);
renderedPath = '';
@@ -109,6 +118,12 @@
consumeReveal(entry.path);
});
+ // Theme switching repaints only; no model or view state is touched.
+ $effect(() => {
+ const themeId = activeEditorThemeId();
+ monacoMod?.applyTheme(themeId);
+ });
+
// Dispose models and view states whose tab has been closed (or renamed away).
$effect(() => {
const open = new Set(session.openFiles.map((file) => file.path));
@@ -198,6 +213,11 @@
+ {#if activeFile?.image}
+
+
+
+ {/if}
{#if session.openFiles.length === 0 && !session.loading && editor}
No file open
diff --git a/src/lib/components/ide/IdeShell.svelte b/src/lib/components/ide/IdeShell.svelte
index 60fb0cd..eb0ec58 100644
--- a/src/lib/components/ide/IdeShell.svelte
+++ b/src/lib/components/ide/IdeShell.svelte
@@ -7,6 +7,7 @@
import SearchPanel from '$lib/components/ide/SearchPanel.svelte';
import TerminalTabs from '$lib/components/ide/TerminalTabs.svelte';
import LoadingScene from '$lib/components/ide/LoadingScene.svelte';
+ import SettingsMenu from '$lib/components/ide/SettingsMenu.svelte';
import { fade } from 'svelte/transition';
import type { BootStage, IdeSession } from '$lib/ide/session.svelte';
import { downloadProject } from '$lib/ide/download';
@@ -76,10 +77,24 @@
let activePanel = $state<'files' | 'search' | null>('files');
let fileTree = $state<{ startCreate: (kind: 'file' | 'folder') => void } | null>(null);
+ // ── Mobile state ──────────────────────────────────────────────────────────
+ let isMobile = $state(false);
+ let activeMobileView = $state<'editor' | 'terminal' | 'preview'>('editor');
+
// Frameworks with a declared app port keep the preview pinned to it; other
- // ports stay reachable through the port selector.
+ // ports stay reachable through the toolbar's port menu.
const portal = new PortalState({ preferredPort: () => session.appPort });
+ let isPreviewVisible = $state(true);
+ let previewCollapsed = $derived(!isPreviewVisible && !isMobile);
+
+ /** Collapses to the stub without unmounting: a remount would lose the previewed app's route. */
+ function togglePreview(): void {
+ isPreviewVisible = !isPreviewVisible;
+ // xterm only refits on a resize event.
+ setTimeout(() => fitTerminals(), 0);
+ }
+
// Recomputed as the preview moves ports, so a report always carries the live portal URL.
let bugReportHref = $derived(bugReportUrl({ repo: session.repo, previewUrl: portal.url }));
@@ -93,10 +108,6 @@
let bootLines = $derived(BOOT_LOG[session.mode]);
let activeLine = $derived(STAGE_LINE[session.bootStage]);
- // ── Mobile state ──────────────────────────────────────────────────────────
- let isMobile = $state(false);
- let activeMobileView = $state<'editor' | 'terminal' | 'preview'>('editor');
-
// ── Resize state ──────────────────────────────────────────────────────────
let filePanelWidth = $state(208);
let leftColFraction = $state(0.6);
@@ -255,6 +266,11 @@
+
@@ -396,7 +416,7 @@
{/if}
- {#if !isMobile}
+ {#if !isMobile && isPreviewVisible}
- {#if !isCompatibleBrowser}
-
+
+ {#if portal.selectedPort !== null}
+
port {portal.selectedPort}
+ {/if}
+
+ {/if}
+
+
+ {#if !isCompatibleBrowser}
-
+
+
+
+
Incompatible Browser
+
+ Requires Atomics.waitAsync (Chrome, Edge, Safari
+ 16.4+).
+
-
Incompatible Browser
-
- Requires Atomics.waitAsync (Chrome, Edge, Safari
- 16.4+).
-
-
- {:else}
- {#if portal.portals.length > 0}
-
- {/if}
-
- {#if loaderVisible}
-
-
(loaderVisible = false)}
+ {:else}
+ {#if portal.portals.length > 0}
+ session.saveAll()}
+ onCollapse={isMobile ? undefined : togglePreview}
/>
-
+ {/if}
+
+ {#if loaderVisible}
+
+ (loaderVisible = false)}
+ />
+
+ {/if}
{/if}
- {/if}
+
@@ -570,8 +617,8 @@
/* ── Mobile ────────────────────────────────────────────────────────────── */
/* Keep hidden panes mounted (terminals/iframes need persistent DOM) but
- take them out of layout so the active pane fills the viewport. */
- .mobile-hidden {
+ take them out of layout so the visible panes fill the space. */
+ .pane-hidden {
display: none !important;
}
diff --git a/src/lib/components/ide/ImageViewer.svelte b/src/lib/components/ide/ImageViewer.svelte
new file mode 100644
index 0000000..cce0d9a
--- /dev/null
+++ b/src/lib/components/ide/ImageViewer.svelte
@@ -0,0 +1,169 @@
+
+
+
+
+ {#if failed}
+
+
+ This image could not be displayed
+
+ {:else}
+
+
+
(failed = true)}
+ class:pixelated={scale > 1}
+ class={natural ? 'm-auto' : 'm-auto max-h-full max-w-full object-contain'}
+ style={natural
+ ? `width: ${Math.round(natural.width * scale)}px; height: ${Math.round(natural.height * scale)}px;`
+ : ''}
+ />
+
+ {/if}
+
+
+ {#if natural}
+ {natural.width} × {natural.height}
+ {/if}
+ {formatBytes(image.bytes)}
+ {#if natural && !failed}
+
+ stepZoom(-1)}
+ disabled={!canZoomOut}
+ title="Zoom out"
+ aria-label="Zoom out"
+ class="zoom-btn"
+ >
+
+
+ (zoom = null)}
+ title="Fit to window"
+ class="zoom-btn w-11 justify-center tabular-nums"
+ >
+ {Math.round(scale * 100)}%
+
+ stepZoom(1)}
+ disabled={!canZoomIn}
+ title="Zoom in"
+ aria-label="Zoom in"
+ class="zoom-btn"
+ >
+
+
+
+ {/if}
+
+
+
+
diff --git a/src/lib/components/ide/SettingsMenu.svelte b/src/lib/components/ide/SettingsMenu.svelte
new file mode 100644
index 0000000..ea77c84
--- /dev/null
+++ b/src/lib/components/ide/SettingsMenu.svelte
@@ -0,0 +1,130 @@
+
+
+
+ (open = !open)}
+ aria-expanded={open}
+ class="{baseClass} {open ? activeClass : idleClass}"
+ title="Settings"
+ aria-label="Settings"
+ >
+
+
+
+ {#if open}
+
+ {/if}
+
+
+
diff --git a/src/lib/components/ide/monaco.ts b/src/lib/components/ide/monaco.ts
index 4f6dd08..4eb4726 100644
--- a/src/lib/components/ide/monaco.ts
+++ b/src/lib/components/ide/monaco.ts
@@ -5,6 +5,31 @@ import jsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
import cssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker';
import htmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker';
import tsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker';
+import { shikiToMonaco, textmateThemeToMonacoTheme } from '@shikijs/monaco';
+import { createHighlighterCore } from 'shiki/core';
+import { createJavaScriptRegexEngine } from 'shiki/engine/javascript';
+import type { ThemeRegistrationResolved } from 'shiki';
+import css from '@shikijs/langs/css';
+import docker from '@shikijs/langs/docker';
+import graphql from '@shikijs/langs/graphql';
+import html from '@shikijs/langs/html';
+import ini from '@shikijs/langs/ini';
+import javascript from '@shikijs/langs/javascript';
+import json from '@shikijs/langs/json';
+import less from '@shikijs/langs/less';
+import markdown from '@shikijs/langs/markdown';
+import python from '@shikijs/langs/python';
+import scss from '@shikijs/langs/scss';
+import shellscript from '@shikijs/langs/shellscript';
+import sql from '@shikijs/langs/sql';
+import svelte from '@shikijs/langs/svelte';
+import toml from '@shikijs/langs/toml';
+import typescript from '@shikijs/langs/typescript';
+import vue from '@shikijs/langs/vue';
+import xml from '@shikijs/langs/xml';
+import yaml from '@shikijs/langs/yaml';
+import type { EditorThemeId } from '$lib/config/editor-themes';
+import { THEME_DATA } from './themes/theme-data';
/**
* Monaco bootstrap for the playground editor. This module (and EditorPane, which
@@ -26,61 +51,68 @@ self.MonacoEnvironment = {
for (const defaults of [monacoTs.typescriptDefaults, monacoTs.javascriptDefaults])
defaults.setDiagnosticsOptions({ noSemanticValidation: true, noSyntaxValidation: false });
-monaco.editor.defineTheme('browsercode-dark', {
- base: 'vs-dark',
- inherit: true,
- rules: [],
- colors: {
- 'editor.background': '#09090b',
- 'editor.foreground': '#d9d9d9',
- 'editorCursor.foreground': '#10b981',
- 'editor.selectionBackground': '#10b9812e',
- 'editor.inactiveSelectionBackground': '#10b9811a',
- 'editor.lineHighlightBackground': '#ffffff05',
- 'editorLineNumber.foreground': '#ffffff40',
- 'editorLineNumber.activeForeground': '#ffffff8c',
- 'editorGutter.background': '#09090b',
- 'editorIndentGuide.background1': '#ffffff0a',
- 'editorIndentGuide.activeBackground1': '#ffffff1f',
- 'editorWidget.background': '#111111',
- 'editorWidget.border': '#ffffff14',
- 'scrollbarSlider.background': '#ffffff1f',
- 'scrollbarSlider.hoverBackground': '#ffffff38',
- 'scrollbarSlider.activeBackground': '#ffffff38',
- 'editorOverviewRuler.border': '#00000000'
- }
-});
+// Fallback for a failed install below: Monaco resolves an unknown theme name to vs-light, and
+// silently, so every theme has to exist even without TextMate.
+for (const [id, theme] of Object.entries(THEME_DATA))
+ monaco.editor.defineTheme(
+ id,
+ textmateThemeToMonacoTheme(
+ theme as ThemeRegistrationResolved
+ ) as monaco.editor.IStandaloneThemeData
+ );
-// Svelte and Vue have no Monaco grammar, so they fall back to HTML.
-const LANGUAGE_BY_EXT: Record
= {
- svelte: 'html',
- vue: 'html',
- html: 'html',
- htm: 'html',
- ts: 'typescript',
- tsx: 'typescript',
- mts: 'typescript',
- cts: 'typescript',
- js: 'javascript',
- jsx: 'javascript',
- mjs: 'javascript',
- cjs: 'javascript',
- css: 'css',
- scss: 'scss',
- less: 'less',
- json: 'json',
- md: 'markdown',
- markdown: 'markdown',
- yaml: 'yaml',
- yml: 'yaml',
- xml: 'xml',
- svg: 'xml'
-};
+/* Replaces Monarch with TextMate grammars */
+async function installHighlighting(): Promise {
+ // Not Monaco languages, so shikiToMonaco would skip them and they would fall back to
+ // HTML (.svelte/.vue) or plaintext (.toml). Registering the extensions also lets Monaco
+ // infer them in `modelFor`.
+ monaco.languages.register({ id: 'svelte', extensions: ['.svelte'] });
+ monaco.languages.register({ id: 'vue', extensions: ['.vue'] });
+ monaco.languages.register({ id: 'toml', extensions: ['.toml'] });
-// Maps a file path to a Monaco language id by its extension; unknown types are plaintext.
-export function languageFor(file: string): string {
- const ext = file.slice(file.lastIndexOf('.') + 1).toLowerCase();
- return LANGUAGE_BY_EXT[ext] ?? 'plaintext';
+ const highlighter = await createHighlighterCore({
+ themes: Object.values(THEME_DATA),
+ langs: [
+ css,
+ docker,
+ graphql,
+ html,
+ ini,
+ javascript,
+ json,
+ less,
+ markdown,
+ python,
+ scss,
+ shellscript,
+ sql,
+ svelte,
+ toml,
+ typescript,
+ vue,
+ xml,
+ yaml
+ ],
+ // Covers every grammar loaded here, and keeps the oniguruma wasm out of the bundle.
+ engine: createJavaScriptRegexEngine()
+ });
+
+ // Reads the theme list once, and re-running would wrap `setTheme` twice.
+ shikiToMonaco(highlighter, monaco);
+}
+
+/** Await before creating an editor: `shikiToMonaco` patches `create` and `setTheme`. */
+export const highlightingReady: Promise = installHighlighting().catch((error) => {
+ console.error('Failed to install TextMate highlighting:', error);
+});
+
+/** Global; no editor or model is rebuilt. */
+export function applyTheme(id: EditorThemeId): void {
+ try {
+ monaco.editor.setTheme(id);
+ } catch (error) {
+ console.error(`Failed to apply the ${id} editor theme:`, error);
+ }
}
export { monaco };
diff --git a/src/lib/components/ide/themes/browsercode-dark.ts b/src/lib/components/ide/themes/browsercode-dark.ts
new file mode 100644
index 0000000..8d5b317
--- /dev/null
+++ b/src/lib/components/ide/themes/browsercode-dark.ts
@@ -0,0 +1,93 @@
+import type { ThemeRegistration } from 'shiki';
+
+/**
+ * The house theme, in TextMate form so Shiki loads it alongside the bundled ones. Token colors come
+ * from the `bc-*` palette in `routes/layout.css`.
+ */
+export const browsercodeDark: ThemeRegistration = {
+ name: 'browsercode-dark',
+ type: 'dark',
+ colors: {
+ 'editor.background': '#09090b',
+ 'editor.foreground': '#d9d9d9',
+ 'editorCursor.foreground': '#10b981',
+ 'editor.selectionBackground': '#10b9812e',
+ 'editor.inactiveSelectionBackground': '#10b9811a',
+ 'editor.lineHighlightBackground': '#ffffff05',
+ 'editorLineNumber.foreground': '#ffffff40',
+ 'editorLineNumber.activeForeground': '#ffffff8c',
+ 'editorGutter.background': '#09090b',
+ 'editorIndentGuide.background1': '#ffffff0a',
+ 'editorIndentGuide.activeBackground1': '#ffffff1f',
+ 'editorWidget.background': '#111111',
+ 'editorWidget.border': '#ffffff14',
+ 'scrollbarSlider.background': '#ffffff1f',
+ 'scrollbarSlider.hoverBackground': '#ffffff38',
+ 'scrollbarSlider.activeBackground': '#ffffff38',
+ 'editorOverviewRuler.border': '#00000000'
+ },
+ tokenColors: [
+ {
+ scope: ['comment', 'punctuation.definition.comment'],
+ settings: { foreground: '#5c6473', fontStyle: 'italic' }
+ },
+ {
+ scope: ['string', 'string.quoted', 'string.template'],
+ settings: { foreground: '#b7cdff' }
+ },
+ {
+ scope: ['constant.character.escape', 'string.regexp'],
+ settings: { foreground: '#ffd633' }
+ },
+ {
+ scope: ['constant.numeric', 'constant.language', 'support.constant'],
+ settings: { foreground: '#c73da6' }
+ },
+ {
+ scope: ['keyword', 'keyword.control', 'storage', 'storage.type', 'storage.modifier'],
+ settings: { foreground: '#4a7dff' }
+ },
+ {
+ scope: ['keyword.operator', 'punctuation', 'meta.brace'],
+ settings: { foreground: '#7d8595' }
+ },
+ {
+ scope: ['entity.name.function', 'support.function', 'meta.function-call'],
+ settings: { foreground: '#ffd633' }
+ },
+ {
+ scope: [
+ 'entity.name.type',
+ 'entity.name.class',
+ 'entity.other.inherited-class',
+ 'support.type',
+ 'support.class'
+ ],
+ settings: { foreground: '#8fb0ff' }
+ },
+ {
+ scope: ['variable', 'variable.other', 'meta.definition.variable'],
+ settings: { foreground: '#d9d9d9' }
+ },
+ {
+ scope: ['variable.parameter', 'meta.object-literal.key', 'support.variable.property'],
+ settings: { foreground: '#a9b4c8' }
+ },
+ {
+ scope: ['entity.name.tag', 'punctuation.definition.tag'],
+ settings: { foreground: '#ff6161' }
+ },
+ {
+ scope: ['entity.other.attribute-name'],
+ settings: { foreground: '#ffd633' }
+ },
+ {
+ scope: ['markup.heading', 'markup.bold'],
+ settings: { foreground: '#b7cdff', fontStyle: 'bold' }
+ },
+ {
+ scope: ['invalid', 'invalid.illegal'],
+ settings: { foreground: '#ff6161' }
+ }
+ ]
+};
diff --git a/src/lib/components/ide/themes/colorblind-safe.ts b/src/lib/components/ide/themes/colorblind-safe.ts
new file mode 100644
index 0000000..086eefd
--- /dev/null
+++ b/src/lib/components/ide/themes/colorblind-safe.ts
@@ -0,0 +1,93 @@
+import type { ThemeRegistration } from 'shiki';
+
+/**
+ * Built on the Okabe–Ito palette, which stays separable under protanopia, deuteranopia and
+ * tritanopia. Every color clears WCAG AA (4.5:1) on this background.
+ */
+export const colorblindSafe: ThemeRegistration = {
+ name: 'colorblind-safe',
+ type: 'dark',
+ colors: {
+ 'editor.background': '#0f1319',
+ 'editor.foreground': '#e6edf3',
+ 'editorCursor.foreground': '#56b4e9',
+ 'editor.selectionBackground': '#56b4e93d',
+ 'editor.inactiveSelectionBackground': '#56b4e921',
+ 'editor.lineHighlightBackground': '#ffffff08',
+ 'editorLineNumber.foreground': '#ffffff4d',
+ 'editorLineNumber.activeForeground': '#ffffff99',
+ 'editorGutter.background': '#0f1319',
+ 'editorIndentGuide.background1': '#ffffff0f',
+ 'editorIndentGuide.activeBackground1': '#ffffff2b',
+ 'editorWidget.background': '#161b22',
+ 'editorWidget.border': '#ffffff1f',
+ 'scrollbarSlider.background': '#ffffff26',
+ 'scrollbarSlider.hoverBackground': '#ffffff40',
+ 'scrollbarSlider.activeBackground': '#ffffff40',
+ 'editorOverviewRuler.border': '#00000000'
+ },
+ tokenColors: [
+ {
+ scope: ['comment', 'punctuation.definition.comment'],
+ settings: { foreground: '#8d96a0', fontStyle: 'italic' }
+ },
+ {
+ scope: ['string', 'string.quoted', 'string.template'],
+ settings: { foreground: '#f0e442' }
+ },
+ {
+ scope: ['constant.character.escape', 'string.regexp'],
+ settings: { foreground: '#f5793a' }
+ },
+ {
+ scope: ['constant.numeric', 'constant.language', 'support.constant'],
+ settings: { foreground: '#e69f00' }
+ },
+ {
+ scope: ['keyword', 'keyword.control', 'storage', 'storage.type', 'storage.modifier'],
+ settings: { foreground: '#cc79a7' }
+ },
+ {
+ scope: ['keyword.operator', 'punctuation', 'meta.brace'],
+ settings: { foreground: '#7d8895' }
+ },
+ {
+ scope: ['entity.name.function', 'support.function', 'meta.function-call'],
+ settings: { foreground: '#56b4e9' }
+ },
+ {
+ scope: [
+ 'entity.name.type',
+ 'entity.name.class',
+ 'entity.other.inherited-class',
+ 'support.type',
+ 'support.class'
+ ],
+ settings: { foreground: '#9fe6ff' }
+ },
+ {
+ scope: ['variable', 'variable.other', 'meta.definition.variable'],
+ settings: { foreground: '#e6edf3' }
+ },
+ {
+ scope: ['variable.parameter', 'meta.object-literal.key', 'support.variable.property'],
+ settings: { foreground: '#b8c4d0' }
+ },
+ {
+ scope: ['entity.name.tag', 'punctuation.definition.tag'],
+ settings: { foreground: '#cc79a7' }
+ },
+ {
+ scope: ['entity.other.attribute-name'],
+ settings: { foreground: '#56b4e9' }
+ },
+ {
+ scope: ['markup.heading', 'markup.bold'],
+ settings: { foreground: '#e6edf3', fontStyle: 'bold' }
+ },
+ {
+ scope: ['invalid', 'invalid.illegal'],
+ settings: { foreground: '#f5793a', fontStyle: 'underline' }
+ }
+ ]
+};
diff --git a/src/lib/components/ide/themes/theme-data.ts b/src/lib/components/ide/themes/theme-data.ts
new file mode 100644
index 0000000..c47b274
--- /dev/null
+++ b/src/lib/components/ide/themes/theme-data.ts
@@ -0,0 +1,25 @@
+import type { ThemeRegistration } from 'shiki';
+import catppuccinMocha from '@shikijs/themes/catppuccin-mocha';
+import dracula from '@shikijs/themes/dracula';
+import githubDarkDefault from '@shikijs/themes/github-dark-default';
+import nightOwl from '@shikijs/themes/night-owl';
+import oneDarkPro from '@shikijs/themes/one-dark-pro';
+import tokyoNight from '@shikijs/themes/tokyo-night';
+import type { EditorThemeId } from '$lib/config/editor-themes';
+import { browsercodeDark } from './browsercode-dark';
+import { colorblindSafe } from './colorblind-safe';
+
+/**
+ * Colors for every id in `config/editor-themes.ts`, which holds the metadata the menu and store
+ * read. Kept apart from it so this data stays behind the lazy Monaco import.
+ */
+export const THEME_DATA: Record = {
+ 'browsercode-dark': browsercodeDark,
+ 'colorblind-safe': colorblindSafe,
+ dracula,
+ 'tokyo-night': tokyoNight,
+ 'night-owl': nightOwl,
+ 'one-dark-pro': oneDarkPro,
+ 'github-dark-default': githubDarkDefault,
+ 'catppuccin-mocha': catppuccinMocha
+};
diff --git a/src/lib/config/editor-themes.ts b/src/lib/config/editor-themes.ts
new file mode 100644
index 0000000..77f4607
--- /dev/null
+++ b/src/lib/config/editor-themes.ts
@@ -0,0 +1,44 @@
+/**
+ * Editor theme metadata.
+ */
+
+export type EditorThemeVariant = 'dark' | 'light';
+
+export type EditorThemeId =
+ | 'browsercode-dark'
+ | 'colorblind-safe'
+ | 'dracula'
+ | 'tokyo-night'
+ | 'night-owl'
+ | 'one-dark-pro'
+ | 'github-dark-default'
+ | 'catppuccin-mocha';
+
+export type EditorThemeConfig = {
+ id: EditorThemeId;
+ label: string;
+ variant: EditorThemeVariant;
+};
+
+/** Ids are Shiki theme names, which `shikiToMonaco` registers with Monaco verbatim. */
+export const editorThemes: EditorThemeConfig[] = [
+ { id: 'browsercode-dark', label: 'BrowserCode Dark', variant: 'dark' },
+ { id: 'colorblind-safe', label: 'Colorblind Safe', variant: 'dark' },
+ { id: 'dracula', label: 'Dracula', variant: 'dark' },
+ { id: 'tokyo-night', label: 'Tokyo Night', variant: 'dark' },
+ { id: 'night-owl', label: 'Night Owl', variant: 'dark' },
+ { id: 'one-dark-pro', label: 'One Dark Pro', variant: 'dark' },
+ { id: 'github-dark-default', label: 'GitHub Dark', variant: 'dark' },
+ { id: 'catppuccin-mocha', label: 'Catppuccin Mocha', variant: 'dark' }
+];
+
+/** Stands in for any variant with nothing stored, including light until one ships. */
+export const defaultEditorThemeId: EditorThemeId = 'browsercode-dark';
+
+export function editorThemesFor(variant: EditorThemeVariant): EditorThemeConfig[] {
+ return editorThemes.filter((theme) => theme.variant === variant);
+}
+
+export function isEditorThemeId(value: string | null): value is EditorThemeId {
+ return value !== null && editorThemes.some((theme) => theme.id === value);
+}
diff --git a/src/lib/ide/media.ts b/src/lib/ide/media.ts
new file mode 100644
index 0000000..d950591
--- /dev/null
+++ b/src/lib/ide/media.ts
@@ -0,0 +1,57 @@
+/**
+ * Image tabs: pod files have no URL an ` ` can reach, so the bytes are read once and
+ * republished as an object URL, owned by the tab that holds it.
+ */
+import type { BrowserPod } from '@leaningtech/browserpod';
+import { readPodBinaryFile } from '$lib/pod/fs';
+
+/** `url` stays valid until {@link releaseImage}. */
+export type ImagePayload = { url: string; bytes: number };
+
+/** Extensions a browser renders in an ` `. SVG is absent so it stays editable as text; */
+const IMAGE_MIME: Record = {
+ png: 'image/png',
+ apng: 'image/apng',
+ jpg: 'image/jpeg',
+ jpeg: 'image/jpeg',
+ jpe: 'image/jpeg',
+ jif: 'image/jpeg',
+ jfif: 'image/jpeg',
+ gif: 'image/gif',
+ webp: 'image/webp',
+ avif: 'image/avif',
+ bmp: 'image/bmp',
+ ico: 'image/x-icon'
+};
+
+const extensionOf = (path: string): string => path.slice(path.lastIndexOf('.') + 1).toLowerCase();
+
+/** True when `path` opens as an image tab rather than in the text editor. */
+export function isImagePath(path: string): boolean {
+ return extensionOf(path) in IMAGE_MIME;
+}
+
+/** Reads an image out of the pod and publishes it as an object URL. */
+export async function loadPodImage(pod: BrowserPod, absPath: string): Promise {
+ const bytes = await readPodBinaryFile(pod, absPath);
+ const blob = new Blob([bytes], { type: IMAGE_MIME[extensionOf(absPath)] });
+ return { url: URL.createObjectURL(blob), bytes: bytes.byteLength };
+}
+
+/** Releases the object URL; a leaked one pins the whole image in memory. */
+export function releaseImage(image: ImagePayload | undefined): void {
+ if (image) URL.revokeObjectURL(image.url);
+}
+
+const BYTE_UNITS = ['B', 'KB', 'MB'];
+
+/** Byte count for the viewer's status line, e.g. `24.1 KB`. */
+export function formatBytes(bytes: number): string {
+ let value = bytes;
+ let unit = 0;
+ while (value >= 1024 && unit < BYTE_UNITS.length - 1) {
+ value /= 1024;
+ unit++;
+ }
+ return `${unit === 0 ? value : value.toFixed(1)} ${BYTE_UNITS[unit]}`;
+}
diff --git a/src/lib/ide/native-deps.ts b/src/lib/ide/native-deps.ts
index db42cca..5b838f2 100644
--- a/src/lib/ide/native-deps.ts
+++ b/src/lib/ide/native-deps.ts
@@ -11,6 +11,9 @@ type Manifest = {
/** What the repo declares, package name to version spec, dependencies and devDependencies. */
type DeclaredDeps = Map;
+/** Reads the repo's own value for an entry, never an earlier patch's, so rule order cannot matter. */
+type CurrentValue = (section: string, name: string) => string | undefined;
+
type SectionPatch = {
/** Top-level manifest key holding a name to value map. */
section: string;
@@ -21,7 +24,7 @@ type SectionPatch = {
type FrameworkRule = {
applies: (deps: DeclaredDeps) => boolean;
- patches: SectionPatch[];
+ patches: (deps: DeclaredDeps, current: CurrentValue) => SectionPatch[];
};
/** Ours wins: for where the repo's own value is the thing that breaks the pod. */
@@ -42,24 +45,46 @@ const DEP_SECTIONS = new Set([
'peerDependencies'
]);
+/** Native binaries the pod cannot execute. */
const WASM_BUNDLERS = {
- esbuild: 'npm:esbuild-wasm@0.25.11',
- rollup: 'npm:@rollup/wasm-node@4.52.4'
+ esbuild: 'npm:esbuild-wasm@*',
+ rollup: 'npm:@rollup/wasm-node@*'
};
const FRAMEWORK_RULES: FrameworkRule[] = [
- // Vite 8+
+ // Vite 8.2+
{
- applies: (deps) => majorAtLeast(deps, 'vite', 8),
- patches: [fill('devDependencies', { '@rolldown/binding-wasm32-wasi': '1.2.2' })]
+ applies: (deps) => minorAtLeast(deps, 'vite', 8, 2),
+ patches: () => [fill('devDependencies', { '@rolldown/binding-wasm32-wasi': '1.2.5' })]
},
// Vite 7 and earlier
{
applies: (deps) => majorBelow(deps, 'vite', 8),
- patches: [force('overrides', WASM_BUNDLERS)]
+ patches: () => [force('overrides', WASM_BUNDLERS)]
+ },
+ // Next.js
+ {
+ applies: (deps) => deps.has('next'),
+ patches: (deps, current) => {
+ const dev = nextDevWithWebpack(current('scripts', 'dev'), deps);
+ return dev ? [force('scripts', { dev })] : [];
+ }
}
];
+/**
+ * Turbopack needs native bindings the pod cannot execute. Dropping its flags is enough through Next
+ * 15; 16 defaults to it and needs `--webpack` to opt out.
+ */
+function nextDevWithWebpack(script: string | undefined, deps: DeclaredDeps): string | undefined {
+ if (!script) return undefined;
+ const withoutTurbopack = script.replace(/ --turbo(?:pack)?\b/g, '');
+ if (!majorAtLeast(deps, 'next', 16) || withoutTurbopack.includes('--webpack')) {
+ return withoutTurbopack;
+ }
+ return withoutTurbopack.replace(/\bnext\s+dev\b/, '$& --webpack');
+}
+
/** Highest major the spec could install. Null means no ceiling at all. */
function highestMajor(spec: string): number | null {
if (spec.includes('>') && !spec.includes('<')) return null;
@@ -68,6 +93,27 @@ function highestMajor(spec: string): number | null {
return Math.max(...versions.map((version) => Number.parseInt(version, 10)));
}
+/** Highest minor the spec could install within `major`, Infinity when it leaves the minor free. */
+function highestMinor(spec: string, major: number): number {
+ // A caret or a bare major floats the minor: `^8.1.1` installs 8.2 today, so it reads as 8.x.
+ if (spec.includes('^')) return Infinity;
+ const minors = (spec.match(/\d+(?:\.\d+)*/g) ?? [])
+ .map((version) => version.split('.').map(Number))
+ .filter(([declared]) => declared === major)
+ .map(([, minor]) => minor ?? Infinity);
+ return minors.length > 0 ? Math.max(...minors) : Infinity;
+}
+
+/** True when `name` is declared and can install `major.minor` or newer. */
+function minorAtLeast(deps: DeclaredDeps, name: string, major: number, minor: number): boolean {
+ const spec = deps.get(name);
+ if (spec === undefined) return false;
+ const highest = highestMajor(spec);
+ if (highest === null) return true;
+ if (highest !== major) return highest > major;
+ return highestMinor(spec, major) >= minor;
+}
+
/** True when `name` is declared and can install `major` or newer, an unversioned spec included. */
function majorAtLeast(deps: DeclaredDeps, name: string, major: number): boolean {
const spec = deps.get(name);
@@ -116,6 +162,10 @@ export function patchClonedManifest(
const deps: DeclaredDeps = new Map(
Object.entries({ ...manifest.dependencies, ...manifest.devDependencies })
);
+ const currentValue: CurrentValue = (section, name) => {
+ const held = manifest[section];
+ return isRecord(held) ? held[name] : undefined;
+ };
// Working copies, seeded from the manifest the first time a rule touches the section. A section
// holding anything other than a map is treated as absent; npm would reject it anyway.
const sections = new Map>();
@@ -132,7 +182,7 @@ export function patchClonedManifest(
const notes: string[] = [];
for (const rule of FRAMEWORK_RULES) {
if (!rule.applies(deps)) continue;
- for (const { section: sectionName, mode, entries } of rule.patches) {
+ for (const { section: sectionName, mode, entries } of rule.patches(deps, currentValue)) {
const section = workingCopy(sectionName);
for (const [name, value] of Object.entries(entries)) {
if (mode === 'fill' && alreadyPresent(section, sectionName, name, deps)) continue;
diff --git a/src/lib/ide/session.svelte.ts b/src/lib/ide/session.svelte.ts
index b0d218f..6f82467 100644
--- a/src/lib/ide/session.svelte.ts
+++ b/src/lib/ide/session.svelte.ts
@@ -15,6 +15,7 @@ import {
} from '$lib/pod/fs';
import { ANSI, BP_RC, BP_RC_PATH } from './shell-rc';
import { patchClonedManifest } from './native-deps';
+import { isImagePath, loadPodImage, releaseImage, type ImagePayload } from './media';
import { fetchRepoTree } from '$lib/github/api';
import { trackEvent } from '$lib/utils/useLazyTracking';
import type { PortalUpdate } from '$lib/pod/portals';
@@ -29,7 +30,14 @@ const COLOR_ENV = ['FORCE_COLOR=3', 'COLORTERM=truecolor'];
* A file open as an editor tab. A `preview` tab (opened by single-click)
* is reused by the next preview open; double-clicking or editing pins it.
*/
-export type OpenFile = { path: string; content: string; savedContent: string; preview: boolean };
+export type OpenFile = {
+ path: string;
+ content: string;
+ savedContent: string;
+ preview: boolean;
+ /** Set on image tabs, which render as a picture and never save. */
+ image?: ImagePayload;
+};
/** Where the boot pipeline currently is; drives the loader's progress readout. `copying` is
* framework-only, `cloning` GitHub-only. */
@@ -38,8 +46,8 @@ export type BootStage = 'booting' | 'copying' | 'cloning' | 'installing' | 'star
/**
* Owns the BrowserPod lifecycle for the playground IDE: boots the pod, hydrates
* the selected framework template into its filesystem, starts the dev server and
- * exposes file/terminal/portal state. UI components render from this; the editor
- * implementation (Monaco today, VS Code Web later) stays swappable on top.
+ * exposes file/terminal/portal state. UI components render from this; it holds no
+ * editor state of its own, so Monaco lives entirely in the component layer.
*/
export class IdeSession {
framework = $state(defaultFrameworkId);
@@ -461,6 +469,7 @@ export class IdeSession {
const gone = (p: string) => p === path || p.startsWith(`${path}/`);
this.projectFiles = this.projectFiles.filter((p) => !gone(p));
this.projectDirs = this.projectDirs.filter((p) => !gone(p));
+ for (const file of this.openFiles) if (gone(file.path)) releaseImage(file.image);
this.openFiles = this.openFiles.filter((file) => !gone(file.path));
if (gone(this.selectedFile)) this.selectedFile = this.openFiles.at(-1)?.path ?? '';
return null;
@@ -486,20 +495,28 @@ export class IdeSession {
this.loading = true;
this.selectedFile = path;
try {
- const content = await readPodFile(this.pod, `${this.workdir}/${path}`);
- if (this.unmounted || this.openFiles.some((file) => file.path === path)) return;
+ const absPath = `${this.workdir}/${path}`;
+ const image = isImagePath(path) ? await loadPodImage(this.pod, absPath) : undefined;
+ const content = image ? '' : await readPodFile(this.pod, absPath);
+ if (this.unmounted || this.openFiles.some((file) => file.path === path)) {
+ releaseImage(image);
+ return;
+ }
// A pin that arrived while the read was in flight wins over the preview flag.
const entry: OpenFile = {
path,
content,
savedContent: content,
- preview: preview && !this.pendingPins.delete(path)
+ preview: preview && !this.pendingPins.delete(path),
+ image
};
const previewIndex = entry.preview ? this.openFiles.findIndex((file) => file.preview) : -1;
- this.openFiles =
- previewIndex >= 0
- ? this.openFiles.map((file, i) => (i === previewIndex ? entry : file))
- : [...this.openFiles, entry];
+ if (previewIndex >= 0) {
+ releaseImage(this.openFiles[previewIndex].image);
+ this.openFiles = this.openFiles.map((file, i) => (i === previewIndex ? entry : file));
+ } else {
+ this.openFiles = [...this.openFiles, entry];
+ }
} catch (error) {
console.error('Failed to load file:', error);
this.pendingPins.delete(path);
@@ -531,6 +548,7 @@ export class IdeSession {
if (index < 0) return;
const entry = this.openFiles[index];
if (entry.content !== entry.savedContent) void this.saveEntry(entry);
+ releaseImage(entry.image);
this.openFiles = this.openFiles.filter((file) => file.path !== path);
if (this.selectedFile === path)
this.selectedFile = (this.openFiles[index] ?? this.openFiles[index - 1])?.path ?? '';
@@ -554,6 +572,8 @@ export class IdeSession {
}
private async saveEntry(entry: OpenFile): Promise {
+ // An image tab carries no text, so writing its content back would truncate the file.
+ if (entry.image) return;
// Saving only makes sense once the dev server is reachable; earlier writes
// would race the template hydration.
if (!this.pod || !this.hasPortal || this.unmounted) return;
@@ -594,6 +614,7 @@ export class IdeSession {
/** Tears down the pod and cancels any in-flight boot. */
shutdown(): void {
this.unmounted = true;
+ for (const file of this.openFiles) releaseImage(file.image);
this.bootToken += 1;
if (this.pod) void shutdownPod(this.pod);
}
diff --git a/src/lib/stores/editor-settings.svelte.ts b/src/lib/stores/editor-settings.svelte.ts
new file mode 100644
index 0000000..dc21a48
--- /dev/null
+++ b/src/lib/stores/editor-settings.svelte.ts
@@ -0,0 +1,45 @@
+import {
+ defaultEditorThemeId,
+ isEditorThemeId,
+ type EditorThemeId,
+ type EditorThemeVariant
+} from '$lib/config/editor-themes';
+
+const STORAGE_KEY = 'browsercode:editor-theme';
+
+export const appearance: EditorThemeVariant = 'dark';
+
+type ThemeChoices = Record;
+
+/** Unknown ids are dropped, so a stale value can't reach Monaco. */
+function loadChoices(): ThemeChoices {
+ const choices: ThemeChoices = { dark: null, light: null };
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return choices;
+ const stored = JSON.parse(raw) as Partial>;
+ for (const variant of ['dark', 'light'] as const) {
+ const value = stored[variant];
+ if (typeof value === 'string' && isEditorThemeId(value)) choices[variant] = value;
+ }
+ } catch (error) {
+ console.warn('Could not read the stored editor theme:', error);
+ }
+ return choices;
+}
+
+const choices = $state(loadChoices());
+
+export function activeEditorThemeId(): EditorThemeId {
+ return choices[appearance] ?? defaultEditorThemeId;
+}
+
+/** Persists the pick for the current appearance. */
+export function setEditorTheme(id: EditorThemeId): void {
+ choices[appearance] = id;
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(choices));
+ } catch (error) {
+ console.warn('Could not persist the editor theme:', error);
+ }
+}
diff --git a/src/lib/stores/portals.svelte.ts b/src/lib/stores/portals.svelte.ts
index 98a1d33..96318c0 100644
--- a/src/lib/stores/portals.svelte.ts
+++ b/src/lib/stores/portals.svelte.ts
@@ -35,6 +35,7 @@ export class PortalState {
url = $state('');
frameStatus = $state('waiting');
showMenu = $state(false);
+ showPorts = $state(false);
showInfo = $state(false);
copied = $state(false);
qrError = $state('');
@@ -76,11 +77,8 @@ export class PortalState {
if (next.length === 0) this.options.onEmpty?.();
};
- /** Change handler for Portal.svelte's port . */
- onPortChange = (event: Event): void => {
- const value = Number((event.currentTarget as HTMLSelectElement).value);
- if (!Number.isInteger(value)) return;
- this.selectUrl(value, this.portals.find((item) => item.port === value)?.url ?? '');
+ selectPort = (port: number): void => {
+ this.selectUrl(port, this.portals.find((item) => item.port === port)?.url ?? '');
this.closeOverlays();
};
@@ -100,18 +98,39 @@ export class PortalState {
toggleMenu = (): void => {
this.showMenu = !this.showMenu;
- if (this.showMenu) this.showInfo = false;
+ this.clearCopied();
+ if (this.showMenu) {
+ this.showPorts = false;
+ this.showInfo = false;
+ }
+ };
+
+ togglePorts = (): void => {
+ this.showPorts = !this.showPorts;
+ if (this.showPorts) {
+ this.showMenu = false;
+ this.showInfo = false;
+ }
};
closeOverlays = (): void => {
this.showMenu = false;
+ this.showPorts = false;
this.showInfo = false;
+ this.clearCopied();
this.qrError = '';
};
+ /** The pending timer would otherwise close a later menu. */
+ private clearCopied(): void {
+ clearTimeout(this.copiedTimeout);
+ this.copied = false;
+ }
+
showQRCode = (): void => {
if (!this.url) return;
this.showMenu = false;
+ this.showPorts = false;
this.showInfo = true;
};
@@ -128,11 +147,13 @@ export class PortalState {
copyUrl = async (): Promise => {
if (!this.url) return;
- this.showMenu = false;
await navigator.clipboard.writeText(this.url);
this.copied = true;
clearTimeout(this.copiedTimeout);
- this.copiedTimeout = setTimeout(() => (this.copied = false), 1200);
+ this.copiedTimeout = setTimeout(() => {
+ this.copied = false;
+ this.showMenu = false;
+ }, 1200);
};
/** Reports that the framed document loaded. Ignored unless that frame is still the current one. */
diff --git a/src/routes/agents/[tool]/+page.svelte b/src/routes/agents/[tool]/+page.svelte
index 80da270..8d7be95 100644
--- a/src/routes/agents/[tool]/+page.svelte
+++ b/src/routes/agents/[tool]/+page.svelte
@@ -377,10 +377,12 @@
portals={portal.portals}
selectedPort={portal.selectedPort}
showMenu={portal.showMenu}
+ showPorts={portal.showPorts}
showInfo={portal.showInfo}
copied={portal.copied}
qrError={portal.qrError}
- onPortChange={portal.onPortChange}
+ onSelectPort={portal.selectPort}
+ onTogglePorts={portal.togglePorts}
onToggleMenu={portal.toggleMenu}
onCopyLink={portal.copyUrl}
onOpenNewTab={portal.openInNewTab}
@@ -399,10 +401,12 @@
portals={portal.portals}
selectedPort={portal.selectedPort}
showMenu={portal.showMenu}
+ showPorts={portal.showPorts}
showInfo={portal.showInfo}
copied={portal.copied}
qrError={portal.qrError}
- onPortChange={portal.onPortChange}
+ onSelectPort={portal.selectPort}
+ onTogglePorts={portal.togglePorts}
onToggleMenu={portal.toggleMenu}
onCopyLink={portal.copyUrl}
onOpenNewTab={portal.openInNewTab}