From 402c9ffe50e387cb5ecde34c37d6787635b7ed08 Mon Sep 17 00:00:00 2001 From: Daniele Date: Tue, 11 Aug 2026 10:41:50 +0100 Subject: [PATCH] feat(file-viewer): syntax highlighting for code files and chat code blocks Vendor highlight.js (core + python, javascript, typescript, json, yaml, bash) and highlight the file viewer's text kind (computed once per load) plus fenced blocks in renderMarkdown. Colors come from new --syn-* CSS variables aliased to the existing palette, so dark mode follows. --- docs/projects.md | 2 + docs/shared-folders.md | 2 +- web/components/shared/file-viewer-base.js | 15 +++++ web/css/hljs-theme.css | 73 +++++++++++++++++++++ web/css/variables.css | 11 ++++ web/index.html | 1 + web/lib/base.js | 8 ++- web/lib/highlight.js | 72 ++++++++++++++++++++ web/mobile.html | 1 + web/vendor/hljs/core.min.js | 10 +++ web/vendor/hljs/languages/bash.min.js | 8 +++ web/vendor/hljs/languages/javascript.min.js | 8 +++ web/vendor/hljs/languages/json.min.js | 8 +++ web/vendor/hljs/languages/python.min.js | 8 +++ web/vendor/hljs/languages/typescript.min.js | 8 +++ web/vendor/hljs/languages/yaml.min.js | 8 +++ 16 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 web/css/hljs-theme.css create mode 100644 web/lib/highlight.js create mode 100644 web/vendor/hljs/core.min.js create mode 100644 web/vendor/hljs/languages/bash.min.js create mode 100644 web/vendor/hljs/languages/javascript.min.js create mode 100644 web/vendor/hljs/languages/json.min.js create mode 100644 web/vendor/hljs/languages/python.min.js create mode 100644 web/vendor/hljs/languages/typescript.min.js create mode 100644 web/vendor/hljs/languages/yaml.min.js diff --git a/docs/projects.md b/docs/projects.md index 9d9c693..ab93e34 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -40,6 +40,8 @@ A file explorer rooted at the project folder: - Click a **folder** to navigate into it. - The listing **updates by itself**: if another member or the assistant creates, renames or deletes a file while you're looking at a folder, the change appears within a second — no refresh needed. +For **code files** — Python, JavaScript, TypeScript, JSON, YAML, shell scripts — the viewer colors the syntax (keywords, strings, comments…), so the structure of a script is easy to follow when reviewing it. The same coloring applies to code blocks inside chat messages. + For **PDF** files, the viewer shows the whole document as one continuous scroll — every page, in order, on phone, tablet and computer alike. A small toolbar on top gives zoom out / zoom in and tells you which page you are on (`3 / 12`). The text stays selectable and copiable where the PDF itself has real text. Pages are drawn as you reach them, so a long document opens quickly instead of making you wait for the last page. If you'd rather open it in another app, the **download** button in the header saves the original file. For **Markdown** files (`.md`), if you have write access the viewer has two tabs: diff --git a/docs/shared-folders.md b/docs/shared-folders.md index d56f488..c247799 100644 --- a/docs/shared-folders.md +++ b/docs/shared-folders.md @@ -38,7 +38,7 @@ Two things worth knowing about membership: There is no file explorer for shared folders — no grid of files, no upload button. The files live on the server, and members reach them through the assistant: - **Ask the assistant** — "what's in the recipes folder?", "add this note to documents", "send me the manual for the boiler". The assistant knows which folders you belong to, can list their contents, open and search files, and — if you have read & write — create and edit them. -- **Open a file** — when the assistant shows you a file from a shared folder, it opens in the usual file viewer (Markdown rendered, images, PDFs, text), exactly like any other file. You can read it there; editing in the viewer is available if you have read & write access. +- **Open a file** — when the assistant shows you a file from a shared folder, it opens in the usual file viewer (Markdown rendered, images, PDFs, syntax-colored code, text), exactly like any other file. You can read it there; editing in the viewer is available if you have read & write access. A practical consequence: if a member wants a file *from* a shared folder, the assistant is the way to get it — there is no download button on the folder itself. (An admin can of course reach the folder directly on the server, but members should not need to.) diff --git a/web/components/shared/file-viewer-base.js b/web/components/shared/file-viewer-base.js index 28a0c80..68e9ee8 100644 --- a/web/components/shared/file-viewer-base.js +++ b/web/components/shared/file-viewer-base.js @@ -2,6 +2,7 @@ import { html, nothing } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { keyed } from 'lit/directives/keyed.js'; import { LightElement, renderMarkdown } from '../../lib/base.js'; +import { codeLangForExt, highlightCode } from '../../lib/highlight.js'; import { fileWatcher } from '../../lib/file-watcher.js'; import { t } from '../../lib/i18n.js'; import './pdf-view.js'; // registers ; pdf.js itself is imported lazily @@ -137,6 +138,7 @@ export class FileViewerBase extends LightElement { _path: { state: true }, _kind: { state: true }, _content: { state: true }, + _codeHtml: { state: true }, // highlighted HTML for code files (null = plain text) _blobUrl: { state: true }, _loading: { state: true }, _error: { state: true }, @@ -163,6 +165,7 @@ export class FileViewerBase extends LightElement { this._path = null; this._kind = null; this._content = ''; + this._codeHtml = null; this._blobUrl = null; this._loading = false; this._error = null; @@ -261,6 +264,7 @@ export class FileViewerBase extends LightElement { this._path = null; this._kind = null; this._content = ''; + this._codeHtml = null; this._error = null; this._compileError = null; this._htmlMode = 'preview'; @@ -283,6 +287,7 @@ export class FileViewerBase extends LightElement { this._path = path; this._kind = kindFor(path); this._content = ''; + this._codeHtml = null; this._error = null; this._compileError = null; // Fresh load: drop any editor state from the previous file. @@ -315,6 +320,13 @@ export class FileViewerBase extends LightElement { const res = await fetch(url); if (!res.ok) throw new Error(`HTTP ${res.status}`); this._content = await res.text(); + // Syntax highlighting is computed once per load (not per render) and is + // best-effort: a highlight failure must never lose the file's content. + this._codeHtml = null; + if (this._kind === 'text') { + try { this._codeHtml = highlightCode(this._content, codeLangForExt(extOf(path))); } + catch { this._codeHtml = null; } + } // Optimistic-locking version token + write flag (editable surface). this._etag = res.headers.get('ETag'); this._canWrite = res.headers.get('X-Writable') === '1'; @@ -788,6 +800,9 @@ export class FileViewerBase extends LightElement {
${this._content}
`; } + if (this._codeHtml != null) { + return html`
${unsafeHTML(this._codeHtml)}
`; + } return html`
${this._content}
`; } } diff --git a/web/css/hljs-theme.css b/web/css/hljs-theme.css new file mode 100644 index 0000000..c37ecc4 --- /dev/null +++ b/web/css/hljs-theme.css @@ -0,0 +1,73 @@ +/* ── Syntax highlighting (highlight.js) ───────────────────────────────────── + Token colors for code rendered through web/lib/highlight.js: the file + viewer's code kind and fenced blocks in chat markdown. All colors come from + the --syn-* variables (variables.css), which track the light/dark palette. + No background here: the container (.fv-code, chat
) already owns it. */
+
+.hljs {
+  color: inherit;
+  background: transparent;
+}
+
+.hljs-comment,
+.hljs-quote {
+  color: var(--syn-comment);
+  font-style: italic;
+}
+
+.hljs-keyword,
+.hljs-literal,
+.hljs-selector-tag {
+  color: var(--syn-keyword);
+}
+
+.hljs-string,
+.hljs-regexp,
+.hljs-template-string,
+.hljs-addition {
+  color: var(--syn-string);
+}
+
+.hljs-number,
+.hljs-symbol,
+.hljs-bullet {
+  color: var(--syn-number);
+}
+
+.hljs-title,
+.hljs-section {
+  color: var(--syn-title);
+}
+
+.hljs-built_in,
+.hljs-type,
+.hljs-title.class_ {
+  color: var(--syn-builtin);
+}
+
+.hljs-attr,
+.hljs-attribute,
+.hljs-variable,
+.hljs-template-variable,
+.hljs-name {
+  color: var(--syn-attr);
+}
+
+.hljs-meta,
+.hljs-meta .hljs-keyword,
+.hljs-selector-class,
+.hljs-selector-id {
+  color: var(--syn-meta);
+}
+
+/* Punctuation, operators and params stay on the base text color. */
+.hljs-subst,
+.hljs-params,
+.hljs-punctuation,
+.hljs-operator {
+  color: inherit;
+}
+
+.hljs-emphasis { font-style: italic; }
+.hljs-strong   { font-weight: bold; }
+.hljs-link     { text-decoration: underline; }
diff --git a/web/css/variables.css b/web/css/variables.css
index afbfdbf..747a5ae 100644
--- a/web/css/variables.css
+++ b/web/css/variables.css
@@ -32,6 +32,17 @@
   --radius-md:           12px;
   --radius-lg:           16px;
 
+  /* Syntax highlighting (consumed by hljs-theme.css). Aliases of the palette
+     above, so the dark block needs no copies — var() resolves at use time. */
+  --syn-comment:         var(--tool-shell);
+  --syn-keyword:         var(--accent-hover);
+  --syn-string:          var(--tool-outline);
+  --syn-number:          var(--tool-config);
+  --syn-title:           var(--tool-edit);
+  --syn-builtin:         var(--tool-list);
+  --syn-attr:            var(--tool-read);
+  --syn-meta:            var(--tool-search);
+
   /* Sidebar — warm cream */
   --sidebar-bg:          #f6f0e6;
   --sidebar-hover:       rgba(63, 52, 40, 0.06);
diff --git a/web/index.html b/web/index.html
index 03917ce..da075c0 100644
--- a/web/index.html
+++ b/web/index.html
@@ -70,6 +70,7 @@
   
   
   
+  
   
   
 
diff --git a/web/lib/base.js b/web/lib/base.js
index 919894c..703bf93 100644
--- a/web/lib/base.js
+++ b/web/lib/base.js
@@ -2,6 +2,7 @@ import { LitElement } from 'lit';
 import { marked }     from 'marked';
 import DOMPurify      from 'dompurify';
 import { t }          from './i18n.js';
+import { highlightMarkdownCodeBlocks } from './highlight.js';
 
 marked.use({ breaks: true, gfm: true });
 
@@ -36,13 +37,16 @@ export function renderMarkdown(text) {
   // `target` is not in DOMPurify's default attribute allow-list, so the
   // external-link hook above needs it whitelisted here to survive sanitization.
   const html = DOMPurify.sanitize(marked.parse(text ?? ''), { ADD_ATTR: ['target'] });
+  // Syntax-highlight fenced code blocks whose language we support (hljs escapes
+  // its own output; unknown fences stay plain).
+  const highlighted = highlightMarkdownCodeBlocks(html);
   // Wrap fenced code blocks in .md-code-wrap so a copy button can float over
   // them on hover. `
` reaches this point only from a marked code block —
   // a literal one in the source text is escaped by sanitize, so the string
   // replace cannot wrap anything else.
   const btn = ``;
-  return html.replaceAll('
', `
${btn}
`)
-             .replaceAll('
', '
'); + return highlighted.replaceAll('
', `
${btn}
`)
+                    .replaceAll('
', '
'); } // One delegated listener serves every copy button renderMarkdown has ever diff --git a/web/lib/highlight.js b/web/lib/highlight.js new file mode 100644 index 0000000..15f1734 --- /dev/null +++ b/web/lib/highlight.js @@ -0,0 +1,72 @@ +import hljs from '../vendor/hljs/core.min.js'; +import DOMPurify from 'dompurify'; +import python from '../vendor/hljs/languages/python.min.js'; +import javascript from '../vendor/hljs/languages/javascript.min.js'; +import typescript from '../vendor/hljs/languages/typescript.min.js'; +import json from '../vendor/hljs/languages/json.min.js'; +import yaml from '../vendor/hljs/languages/yaml.min.js'; +import bash from '../vendor/hljs/languages/bash.min.js'; + +hljs.registerLanguage('python', python); +hljs.registerLanguage('javascript', javascript); +hljs.registerLanguage('typescript', typescript); +hljs.registerLanguage('json', json); +hljs.registerLanguage('yaml', yaml); +hljs.registerLanguage('bash', bash); + +// File extension → hljs language. Extensions not listed here render as plain +// text (never auto-detected: guessing wrong is worse than no colors). +const LANG_FOR_EXT = { + py: 'python', + js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript', + ts: 'typescript', tsx: 'typescript', + json: 'json', + yml: 'yaml', yaml: 'yaml', + sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash', +}; + +// Markdown fence info string → hljs language (common aliases included). +const LANG_FOR_FENCE = { + py: 'python', python: 'python', + js: 'javascript', jsx: 'javascript', mjs: 'javascript', cjs: 'javascript', javascript: 'javascript', + ts: 'typescript', tsx: 'typescript', typescript: 'typescript', + json: 'json', + yml: 'yaml', yaml: 'yaml', + sh: 'bash', bash: 'bash', shell: 'bash', zsh: 'bash', +}; + +export function codeLangForExt(ext) { + return LANG_FOR_EXT[ext] ?? null; +} + +/** + * Highlight `code` as `lang`, returning sanitized HTML (hljs escapes the input + * itself; DOMPurify is belt-and-braces). Returns null when the language is not + * registered, so callers can fall back to plain text. + */ +export function highlightCode(code, lang) { + if (!lang || !hljs.getLanguage(lang)) return null; + return DOMPurify.sanitize(hljs.highlight(code, { language: lang }).value); +} + +/** + * Post-process sanitized marked HTML: highlight every `
` whose fence maps to a registered language, in place.
+ * Unknown fences are left as the plain (already escaped) text marked emitted.
+ */
+export function highlightMarkdownCodeBlocks(htmlStr) {
+  if (!htmlStr.includes('language-')) return htmlStr;
+  const tpl = document.createElement('template');
+  tpl.innerHTML = htmlStr;
+  let changed = false;
+  for (const code of tpl.content.querySelectorAll('pre > code[class*="language-"]')) {
+    const fence = [...code.classList].find(c => c.startsWith('language-'))?.slice(9);
+    const lang  = LANG_FOR_FENCE[fence];
+    const out   = lang && highlightCode(code.textContent ?? '', lang);
+    if (!out) continue;
+    code.innerHTML = out;
+    code.classList.add('hljs');
+    changed = true;
+  }
+  return changed ? tpl.innerHTML : htmlStr;
+}
diff --git a/web/mobile.html b/web/mobile.html
index 5e5ce3f..c339ff4 100644
--- a/web/mobile.html
+++ b/web/mobile.html
@@ -46,6 +46,7 @@
   
   
   
+  
   
   
   
diff --git a/web/vendor/hljs/core.min.js b/web/vendor/hljs/core.min.js
new file mode 100644
index 0000000..0454f85
--- /dev/null
+++ b/web/vendor/hljs/core.min.js
@@ -0,0 +1,10 @@
+/**
+ * Bundled by jsDelivr using Rollup v4.62.2 and esbuild v0.28.1.
+ * Original file: /npm/highlight.js@11.11.1/lib/core.js
+ *
+ * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
+ */
+var re,De;function Kt(){if(De)return re;De=1;function ce(e){return e instanceof Map?e.clear=e.delete=e.set=function(){throw new Error("map is read-only")}:e instanceof Set&&(e.add=e.clear=e.delete=function(){throw new Error("set is read-only")}),Object.freeze(e),Object.getOwnPropertyNames(e).forEach(t=>{const i=e[t],u=typeof i;(u==="object"||u==="function")&&!Object.isFrozen(i)&&ce(i)}),e}class oe{constructor(t){t.data===void 0&&(t.data={}),this.data=t.data,this.isMatchIgnored=!1}ignoreMatch(){this.isMatchIgnored=!0}}function ae(e){return e.replace(/&/g,"&").replace(//g,">").replace(/"/g,""").replace(/'/g,"'")}function T(e,...t){const i=Object.create(null);for(const u in e)i[u]=e[u];return t.forEach(function(u){for(const b in u)i[b]=u[b]}),i}const Ce="",le=e=>!!e.scope,Le=(e,{prefix:t})=>{if(e.startsWith("language:"))return e.replace("language:","language-");if(e.includes(".")){const i=e.split(".");return[`${t}${i.shift()}`,...i.map((u,b)=>`${u}${"_".repeat(b+1)}`)].join(" ")}return`${t}${e}`};class He{constructor(t,i){this.buffer="",this.classPrefix=i.classPrefix,t.walk(this)}addText(t){this.buffer+=ae(t)}openNode(t){if(!le(t))return;const i=Le(t.scope,{prefix:this.classPrefix});this.span(i)}closeNode(t){le(t)&&(this.buffer+=Ce)}value(){return this.buffer}span(t){this.buffer+=``}}const ue=(e={})=>{const t={children:[]};return Object.assign(t,e),t};class Z{constructor(){this.rootNode=ue(),this.stack=[this.rootNode]}get top(){return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(t){this.top.children.push(t)}openNode(t){const i=ue({scope:t});this.add(i),this.stack.push(i)}closeNode(){if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}walk(t){return this.constructor._walk(t,this.rootNode)}static _walk(t,i){return typeof i=="string"?t.addText(i):i.children&&(t.openNode(i),i.children.forEach(u=>this._walk(t,u)),t.closeNode(i)),t}static _collapse(t){typeof t!="string"&&t.children&&(t.children.every(i=>typeof i=="string")?t.children=[t.children.join("")]:t.children.forEach(i=>{Z._collapse(i)}))}}class Pe extends Z{constructor(t){super(),this.options=t}addText(t){t!==""&&this.add(t)}startScope(t){this.openNode(t)}endScope(){this.closeNode()}__addSublanguage(t,i){const u=t.root;i&&(u.scope=`language:${i}`),this.add(u)}toHTML(){return new He(this,this.options).value()}finalize(){return this.closeAllNodes(),!0}}function P(e){return e?typeof e=="string"?e:e.source:null}function fe(e){return v("(?=",e,")")}function je(e){return v("(?:",e,")*")}function Ue(e){return v("(?:",e,")?")}function v(...e){return e.map(i=>P(i)).join("")}function $e(e){const t=e[e.length-1];return typeof t=="object"&&t.constructor===Object?(e.splice(e.length-1,1),t):{}}function q(...e){return"("+($e(e).capture?"":"?:")+e.map(u=>P(u)).join("|")+")"}function ge(e){return new RegExp(e.toString()+"|").exec("").length-1}function Ge(e,t){const i=e&&e.exec(t);return i&&i.index===0}const We=/\[(?:[^\\\]]|\\.)*\]|\(\??|\\([1-9][0-9]*)|\\./;function J(e,{joinWith:t}){let i=0;return e.map(u=>{i+=1;const b=i;let _=P(u),c="";for(;_.length>0;){const r=We.exec(_);if(!r){c+=_;break}c+=_.substring(0,r.index),_=_.substring(r.index+r[0].length),r[0][0]==="\\"&&r[1]?c+="\\"+String(Number(r[1])+b):(c+=r[0],r[0]==="("&&i++)}return c}).map(u=>`(${u})`).join(t)}const Ke=/\b\B/,he="[a-zA-Z]\\w*",V="[a-zA-Z_]\\w*",pe="\\b\\d+(\\.\\d+)?",de="(-?)(\\b0[xX][a-fA-F0-9]+|(\\b\\d+(\\.\\d*)?|\\.\\d+)([eE][-+]?\\d+)?)",Ee="\\b(0b[01]+)",ze="!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~",Fe=(e={})=>{const t=/^#![ ]*\//;return e.binary&&(e.begin=v(t,/.*\b/,e.binary,/\b.*/)),T({scope:"meta",begin:t,end:/$/,relevance:0,"on:begin":(i,u)=>{i.index!==0&&u.ignoreMatch()}},e)},j={begin:"\\\\[\\s\\S]",relevance:0},Xe={scope:"string",begin:"'",end:"'",illegal:"\\n",contains:[j]},Ye={scope:"string",begin:'"',end:'"',illegal:"\\n",contains:[j]},Ze={begin:/\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\b/},$=function(e,t,i={}){const u=T({scope:"comment",begin:e,end:t,contains:[]},i);u.contains.push({scope:"doctag",begin:"[ ]*(?=(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):)",end:/(TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):/,excludeBegin:!0,relevance:0});const b=q("I","a","is","so","us","to","at","if","in","it","on",/[A-Za-z]+['](d|ve|re|ll|t|s|n)/,/[A-Za-z]+[-][a-z]+/,/[A-Za-z][a-z]{2,}/);return u.contains.push({begin:v(/[ ]+/,"(",b,/[.]?[:]?([.][ ]|[ ])/,"){3}")}),u},qe=$("//","$"),Je=$("/\\*","\\*/"),Ve=$("#","$"),Qe={scope:"number",begin:pe,relevance:0},me={scope:"number",begin:de,relevance:0},et={scope:"number",begin:Ee,relevance:0},tt={scope:"regexp",begin:/\/(?=[^/\n]*\/)/,end:/\/[gimuy]*/,contains:[j,{begin:/\[/,end:/\]/,relevance:0,contains:[j]}]},nt={scope:"title",begin:he,relevance:0},it={scope:"title",begin:V,relevance:0},st={begin:"\\.\\s*"+V,relevance:0};var G=Object.freeze({__proto__:null,APOS_STRING_MODE:Xe,BACKSLASH_ESCAPE:j,BINARY_NUMBER_MODE:et,BINARY_NUMBER_RE:Ee,COMMENT:$,C_BLOCK_COMMENT_MODE:Je,C_LINE_COMMENT_MODE:qe,C_NUMBER_MODE:me,C_NUMBER_RE:de,END_SAME_AS_BEGIN:function(e){return Object.assign(e,{"on:begin":(t,i)=>{i.data._beginMatch=t[1]},"on:end":(t,i)=>{i.data._beginMatch!==t[1]&&i.ignoreMatch()}})},HASH_COMMENT_MODE:Ve,IDENT_RE:he,MATCH_NOTHING_RE:Ke,METHOD_GUARD:st,NUMBER_MODE:Qe,NUMBER_RE:pe,PHRASAL_WORDS_MODE:Ze,QUOTE_STRING_MODE:Ye,REGEXP_MODE:tt,RE_STARTERS_RE:ze,SHEBANG:Fe,TITLE_MODE:nt,UNDERSCORE_IDENT_RE:V,UNDERSCORE_TITLE_MODE:it});function rt(e,t){e.input[e.index-1]==="."&&t.ignoreMatch()}function ct(e,t){e.className!==void 0&&(e.scope=e.className,delete e.className)}function ot(e,t){t&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)",e.__beforeBegin=rt,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords,e.relevance===void 0&&(e.relevance=0))}function at(e,t){Array.isArray(e.illegal)&&(e.illegal=q(...e.illegal))}function lt(e,t){if(e.match){if(e.begin||e.end)throw new Error("begin & end are not supported with match");e.begin=e.match,delete e.match}}function ut(e,t){e.relevance===void 0&&(e.relevance=1)}const ft=(e,t)=>{if(!e.beforeMatch)return;if(e.starts)throw new Error("beforeMatch cannot be used with starts");const i=Object.assign({},e);Object.keys(e).forEach(u=>{delete e[u]}),e.keywords=i.keywords,e.begin=v(i.beforeMatch,fe(i.begin)),e.starts={relevance:0,contains:[Object.assign(i,{endsParent:!0})]},e.relevance=0,delete i.beforeMatch},gt=["of","and","for","in","not","or","if","then","parent","list","value"],ht="keyword";function be(e,t,i=ht){const u=Object.create(null);return typeof e=="string"?b(i,e.split(" ")):Array.isArray(e)?b(i,e):Object.keys(e).forEach(function(_){Object.assign(u,be(e[_],t,_))}),u;function b(_,c){t&&(c=c.map(r=>r.toLowerCase())),c.forEach(function(r){const l=r.split("|");u[l[0]]=[_,pt(l[0],l[1])]})}}function pt(e,t){return t?Number(t):dt(e)?0:1}function dt(e){return gt.includes(e.toLowerCase())}const _e={},D=e=>{console.error(e)},Me=(e,...t)=>{console.log(`WARN: ${e}`,...t)},L=(e,t)=>{_e[`${e}/${t}`]||(console.log(`Deprecated as of ${e}. ${t}`),_e[`${e}/${t}`]=!0)},W=new Error;function xe(e,t,{key:i}){let u=0;const b=e[i],_={},c={};for(let r=1;r<=t.length;r++)c[r+u]=b[r],_[r+u]=!0,u+=ge(t[r-1]);e[i]=c,e[i]._emit=_,e[i]._multi=!0}function Et(e){if(Array.isArray(e.begin)){if(e.skip||e.excludeBegin||e.returnBegin)throw D("skip, excludeBegin, returnBegin not compatible with beginScope: {}"),W;if(typeof e.beginScope!="object"||e.beginScope===null)throw D("beginScope must be object"),W;xe(e,e.begin,{key:"beginScope"}),e.begin=J(e.begin,{joinWith:""})}}function bt(e){if(Array.isArray(e.end)){if(e.skip||e.excludeEnd||e.returnEnd)throw D("skip, excludeEnd, returnEnd not compatible with endScope: {}"),W;if(typeof e.endScope!="object"||e.endScope===null)throw D("endScope must be object"),W;xe(e,e.end,{key:"endScope"}),e.end=J(e.end,{joinWith:""})}}function _t(e){e.scope&&typeof e.scope=="object"&&e.scope!==null&&(e.beginScope=e.scope,delete e.scope)}function Mt(e){_t(e),typeof e.beginScope=="string"&&(e.beginScope={_wrap:e.beginScope}),typeof e.endScope=="string"&&(e.endScope={_wrap:e.endScope}),Et(e),bt(e)}function xt(e){function t(c,r){return new RegExp(P(c),"m"+(e.case_insensitive?"i":"")+(e.unicodeRegex?"u":"")+(r?"g":""))}class i{constructor(){this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}addRule(r,l){l.position=this.position++,this.matchIndexes[this.matchAt]=l,this.regexes.push([l,r]),this.matchAt+=ge(r)+1}compile(){this.regexes.length===0&&(this.exec=()=>null);const r=this.regexes.map(l=>l[1]);this.matcherRe=t(J(r,{joinWith:"|"}),!0),this.lastIndex=0}exec(r){this.matcherRe.lastIndex=this.lastIndex;const l=this.matcherRe.exec(r);if(!l)return null;const w=l.findIndex((U,m)=>m>0&&U!==void 0),M=this.matchIndexes[w];return l.splice(0,w),Object.assign(l,M)}}class u{constructor(){this.rules=[],this.multiRegexes=[],this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(r){if(this.multiRegexes[r])return this.multiRegexes[r];const l=new i;return this.rules.slice(r).forEach(([w,M])=>l.addRule(w,M)),l.compile(),this.multiRegexes[r]=l,l}resumingScanAtSamePosition(){return this.regexIndex!==0}considerAll(){this.regexIndex=0}addRule(r,l){this.rules.push([r,l]),l.type==="begin"&&this.count++}exec(r){const l=this.getMatcher(this.regexIndex);l.lastIndex=this.lastIndex;let w=l.exec(r);if(this.resumingScanAtSamePosition()&&!(w&&w.index===this.lastIndex)){const M=this.getMatcher(0);M.lastIndex=this.lastIndex+1,w=M.exec(r)}return w&&(this.regexIndex+=w.position+1,this.regexIndex===this.count&&this.considerAll()),w}}function b(c){const r=new u;return c.contains.forEach(l=>r.addRule(l.begin,{rule:l,type:"begin"})),c.terminatorEnd&&r.addRule(c.terminatorEnd,{type:"end"}),c.illegal&&r.addRule(c.illegal,{type:"illegal"}),r}function _(c,r){const l=c;if(c.isCompiled)return l;[ct,lt,Mt,ft].forEach(M=>M(c,r)),e.compilerExtensions.forEach(M=>M(c,r)),c.__beforeBegin=null,[ot,at,ut].forEach(M=>M(c,r)),c.isCompiled=!0;let w=null;return typeof c.keywords=="object"&&c.keywords.$pattern&&(c.keywords=Object.assign({},c.keywords),w=c.keywords.$pattern,delete c.keywords.$pattern),w=w||/\w+/,c.keywords&&(c.keywords=be(c.keywords,e.case_insensitive)),l.keywordPatternRe=t(w,!0),r&&(c.begin||(c.begin=/\B|\b/),l.beginRe=t(l.begin),!c.end&&!c.endsWithParent&&(c.end=/\B|\b/),c.end&&(l.endRe=t(l.end)),l.terminatorEnd=P(l.end)||"",c.endsWithParent&&r.terminatorEnd&&(l.terminatorEnd+=(c.end?"|":"")+r.terminatorEnd)),c.illegal&&(l.illegalRe=t(c.illegal)),c.contains||(c.contains=[]),c.contains=[].concat(...c.contains.map(function(M){return wt(M==="self"?c:M)})),c.contains.forEach(function(M){_(M,l)}),c.starts&&_(c.starts,r),l.matcher=b(l),l}if(e.compilerExtensions||(e.compilerExtensions=[]),e.contains&&e.contains.includes("self"))throw new Error("ERR: contains `self` is not supported at the top-level of a language.  See documentation.");return e.classNameAliases=T(e.classNameAliases||{}),_(e)}function we(e){return e?e.endsWithParent||we(e.starts):!1}function wt(e){return e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map(function(t){return T(e,{variants:null},t)})),e.cachedVariants?e.cachedVariants:we(e)?T(e,{starts:e.starts?T(e.starts):null}):Object.isFrozen(e)?T(e):e}var Ot="11.11.1";class Rt extends Error{constructor(t,i){super(t),this.name="HTMLInjectionError",this.html=i}}const Q=ae,Oe=T,Re=Symbol("nomatch"),yt=7,ye=function(e){const t=Object.create(null),i=Object.create(null),u=[];let b=!0;const _="Could not find the language '{}', did you forget to load/include a language module?",c={disableAutodetect:!0,name:"Plain text",contains:[]};let r={ignoreUnescapedHTML:!1,throwUnescapedHTML:!1,noHighlightRe:/^(no-?highlight)$/i,languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-",cssSelector:"pre code",languages:null,__emitter:Pe};function l(n){return r.noHighlightRe.test(n)}function w(n){let a=n.className+" ";a+=n.parentNode?n.parentNode.className:"";const h=r.languageDetectRe.exec(a);if(h){const d=I(h[1]);return d||(Me(_.replace("{}",h[1])),Me("Falling back to no-highlight mode for this block.",n)),d?h[1]:"no-highlight"}return a.split(/\s+/).find(d=>l(d)||I(d))}function M(n,a,h){let d="",x="";typeof a=="object"?(d=n,h=a.ignoreIllegals,x=a.language):(L("10.7.0","highlight(lang, code, ...args) has been deprecated."),L("10.7.0",`Please use highlight(code, options) instead.
+https://github.com/highlightjs/highlight.js/issues/2277`),x=n,d=a),h===void 0&&(h=!0);const S={code:d,language:x};z("before:highlight",S);const B=S.result?S.result:U(S.language,S.code,h);return B.code=S.code,z("after:highlight",B),B}function U(n,a,h,d){const x=Object.create(null);function S(s,o){return s.keywords[o]}function B(){if(!f.keywords){O.addText(E);return}let s=0;f.keywordPatternRe.lastIndex=0;let o=f.keywordPatternRe.exec(E),g="";for(;o;){g+=E.substring(s,o.index);const p=A.case_insensitive?o[0].toLowerCase():o[0],R=S(f,p);if(R){const[k,Gt]=R;if(O.addText(g),g="",x[p]=(x[p]||0)+1,x[p]<=yt&&(Y+=Gt),k.startsWith("_"))g+=o[0];else{const Wt=A.classNameAliases[k]||k;N(o[0],Wt)}}else g+=o[0];s=f.keywordPatternRe.lastIndex,o=f.keywordPatternRe.exec(E)}g+=E.substring(s),O.addText(g)}function F(){if(E==="")return;let s=null;if(typeof f.subLanguage=="string"){if(!t[f.subLanguage]){O.addText(E);return}s=U(f.subLanguage,E,!0,ve[f.subLanguage]),ve[f.subLanguage]=s._top}else s=ee(E,f.subLanguage.length?f.subLanguage:null);f.relevance>0&&(Y+=s.relevance),O.__addSublanguage(s._emitter,s.language)}function y(){f.subLanguage!=null?F():B(),E=""}function N(s,o){s!==""&&(O.startScope(o),O.addText(s),O.endScope())}function ke(s,o){let g=1;const p=o.length-1;for(;g<=p;){if(!s._emit[g]){g++;continue}const R=A.classNameAliases[s[g]]||s[g],k=o[g];R?N(k,R):(E=k,B(),E=""),g++}}function Te(s,o){return s.scope&&typeof s.scope=="string"&&O.openNode(A.classNameAliases[s.scope]||s.scope),s.beginScope&&(s.beginScope._wrap?(N(E,A.classNameAliases[s.beginScope._wrap]||s.beginScope._wrap),E=""):s.beginScope._multi&&(ke(s.beginScope,o),E="")),f=Object.create(s,{parent:{value:f}}),f}function Ie(s,o,g){let p=Ge(s.endRe,g);if(p){if(s["on:end"]){const R=new oe(s);s["on:end"](o,R),R.isMatchIgnored&&(p=!1)}if(p){for(;s.endsParent&&s.parent;)s=s.parent;return s}}if(s.endsWithParent)return Ie(s.parent,o,g)}function Ht(s){return f.matcher.regexIndex===0?(E+=s[0],1):(se=!0,0)}function Pt(s){const o=s[0],g=s.rule,p=new oe(g),R=[g.__beforeBegin,g["on:begin"]];for(const k of R)if(k&&(k(s,p),p.isMatchIgnored))return Ht(o);return g.skip?E+=o:(g.excludeBegin&&(E+=o),y(),!g.returnBegin&&!g.excludeBegin&&(E=o)),Te(g,s),g.returnBegin?0:o.length}function jt(s){const o=s[0],g=a.substring(s.index),p=Ie(f,s,g);if(!p)return Re;const R=f;f.endScope&&f.endScope._wrap?(y(),N(o,f.endScope._wrap)):f.endScope&&f.endScope._multi?(y(),ke(f.endScope,s)):R.skip?E+=o:(R.returnEnd||R.excludeEnd||(E+=o),y(),R.excludeEnd&&(E=o));do f.scope&&O.closeNode(),!f.skip&&!f.subLanguage&&(Y+=f.relevance),f=f.parent;while(f!==p.parent);return p.starts&&Te(p.starts,s),R.returnEnd?0:o.length}function Ut(){const s=[];for(let o=f;o!==A;o=o.parent)o.scope&&s.unshift(o.scope);s.forEach(o=>O.openNode(o))}let X={};function Be(s,o){const g=o&&o[0];if(E+=s,g==null)return y(),0;if(X.type==="begin"&&o.type==="end"&&X.index===o.index&&g===""){if(E+=a.slice(o.index,o.index+1),!b){const p=new Error(`0 width match regex (${n})`);throw p.languageName=n,p.badRule=X.rule,p}return 1}if(X=o,o.type==="begin")return Pt(o);if(o.type==="illegal"&&!h){const p=new Error('Illegal lexeme "'+g+'" for mode "'+(f.scope||"")+'"');throw p.mode=f,p}else if(o.type==="end"){const p=jt(o);if(p!==Re)return p}if(o.type==="illegal"&&g==="")return E+=`
+`,1;if(ie>1e5&&ie>o.index*3)throw new Error("potential infinite loop, way more iterations than matches");return E+=g,g.length}const A=I(n);if(!A)throw D(_.replace("{}",n)),new Error('Unknown language: "'+n+'"');const $t=xt(A);let ne="",f=d||$t;const ve={},O=new r.__emitter(r);Ut();let E="",Y=0,C=0,ie=0,se=!1;try{if(A.__emitTokens)A.__emitTokens(a,O);else{for(f.matcher.considerAll();;){ie++,se?se=!1:f.matcher.considerAll(),f.matcher.lastIndex=C;const s=f.matcher.exec(a);if(!s)break;const o=a.substring(C,s.index),g=Be(o,s);C=s.index+g}Be(a.substring(C))}return O.finalize(),ne=O.toHTML(),{language:n,value:ne,relevance:Y,illegal:!1,_emitter:O,_top:f}}catch(s){if(s.message&&s.message.includes("Illegal"))return{language:n,value:Q(a),illegal:!0,relevance:0,_illegalBy:{message:s.message,index:C,context:a.slice(C-100,C+100),mode:s.mode,resultSoFar:ne},_emitter:O};if(b)return{language:n,value:Q(a),illegal:!1,relevance:0,errorRaised:s,_emitter:O,_top:f};throw s}}function m(n){const a={value:Q(n),illegal:!1,relevance:0,_top:c,_emitter:new r.__emitter(r)};return a._emitter.addText(n),a}function ee(n,a){a=a||r.languages||Object.keys(t);const h=m(n),d=a.filter(I).filter(Ae).map(y=>U(y,n,!1));d.unshift(h);const x=d.sort((y,N)=>{if(y.relevance!==N.relevance)return N.relevance-y.relevance;if(y.language&&N.language){if(I(y.language).supersetOf===N.language)return 1;if(I(N.language).supersetOf===y.language)return-1}return 0}),[S,B]=x,F=S;return F.secondBest=B,F}function St(n,a,h){const d=a&&i[a]||h;n.classList.add("hljs"),n.classList.add(`language-${d}`)}function te(n){let a=null;const h=w(n);if(l(h))return;if(z("before:highlightElement",{el:n,language:h}),n.dataset.highlighted){console.log("Element previously highlighted. To highlight again, first unset `dataset.highlighted`.",n);return}if(n.children.length>0&&(r.ignoreUnescapedHTML||(console.warn("One of your code blocks includes unescaped HTML. This is a potentially serious security risk."),console.warn("https://github.com/highlightjs/highlight.js/wiki/security"),console.warn("The element with unescaped HTML:"),console.warn(n)),r.throwUnescapedHTML))throw new Rt("One of your code blocks includes unescaped HTML.",n.innerHTML);a=n;const d=a.textContent,x=h?M(d,{language:h,ignoreIllegals:!0}):ee(d);n.innerHTML=x.value,n.dataset.highlighted="yes",St(n,h,x.language),n.result={language:x.language,re:x.relevance,relevance:x.relevance},x.secondBest&&(n.secondBest={language:x.secondBest.language,relevance:x.secondBest.relevance}),z("after:highlightElement",{el:n,result:x,text:d})}function Nt(n){r=Oe(r,n)}const At=()=>{K(),L("10.6.0","initHighlighting() deprecated.  Use highlightAll() now.")};function kt(){K(),L("10.6.0","initHighlightingOnLoad() deprecated.  Use highlightAll() now.")}let Se=!1;function K(){function n(){K()}if(document.readyState==="loading"){Se||window.addEventListener("DOMContentLoaded",n,!1),Se=!0;return}document.querySelectorAll(r.cssSelector).forEach(te)}function Tt(n,a){let h=null;try{h=a(e)}catch(d){if(D("Language definition for '{}' could not be registered.".replace("{}",n)),b)D(d);else throw d;h=c}h.name||(h.name=n),t[n]=h,h.rawDefinition=a.bind(null,e),h.aliases&&Ne(h.aliases,{languageName:n})}function It(n){delete t[n];for(const a of Object.keys(i))i[a]===n&&delete i[a]}function Bt(){return Object.keys(t)}function I(n){return n=(n||"").toLowerCase(),t[n]||t[i[n]]}function Ne(n,{languageName:a}){typeof n=="string"&&(n=[n]),n.forEach(h=>{i[h.toLowerCase()]=a})}function Ae(n){const a=I(n);return a&&!a.disableAutodetect}function vt(n){n["before:highlightBlock"]&&!n["before:highlightElement"]&&(n["before:highlightElement"]=a=>{n["before:highlightBlock"](Object.assign({block:a.el},a))}),n["after:highlightBlock"]&&!n["after:highlightElement"]&&(n["after:highlightElement"]=a=>{n["after:highlightBlock"](Object.assign({block:a.el},a))})}function Dt(n){vt(n),u.push(n)}function Ct(n){const a=u.indexOf(n);a!==-1&&u.splice(a,1)}function z(n,a){const h=n;u.forEach(function(d){d[h]&&d[h](a)})}function Lt(n){return L("10.7.0","highlightBlock will be removed entirely in v12.0"),L("10.7.0","Please use highlightElement now."),te(n)}Object.assign(e,{highlight:M,highlightAuto:ee,highlightAll:K,highlightElement:te,highlightBlock:Lt,configure:Nt,initHighlighting:At,initHighlightingOnLoad:kt,registerLanguage:Tt,unregisterLanguage:It,listLanguages:Bt,getLanguage:I,registerAliases:Ne,autoDetection:Ae,inherit:Oe,addPlugin:Dt,removePlugin:Ct}),e.debugMode=function(){b=!1},e.safeMode=function(){b=!0},e.versionString=Ot,e.regex={concat:v,lookahead:fe,either:q,optional:Ue,anyNumberOfTimes:je};for(const n in G)typeof G[n]=="object"&&ce(G[n]);return Object.assign(e,G),e},H=ye({});return H.newInstance=()=>ye({}),re=H,H.HighlightJS=H,H.default=H,re}var zt=Kt();export{zt as default};
+//# sourceMappingURL=/sm/be627a3f6bc9ebd09cf026a613d7525e6be26e13047adc4c39591ae89fd6ae38.map
\ No newline at end of file
diff --git a/web/vendor/hljs/languages/bash.min.js b/web/vendor/hljs/languages/bash.min.js
new file mode 100644
index 0000000..a7169e3
--- /dev/null
+++ b/web/vendor/hljs/languages/bash.min.js
@@ -0,0 +1,8 @@
+/**
+ * Minified by jsDelivr using Terser v5.39.0.
+ * Original file: /npm/highlight.js@11.11.1/es/languages/bash.js
+ *
+ * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
+ */
+function bash(e){const s=e.regex,t={},n={begin:/\$\{/,end:/\}/,contains:["self",{begin:/:-/,contains:[t]}]};Object.assign(t,{className:"variable",variants:[{begin:s.concat(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},n]});const a={className:"subst",begin:/\$\(/,end:/\)/,contains:[e.BACKSLASH_ESCAPE]},i=e.inherit(e.COMMENT(),{match:[/(^|\s)/,/#.*$/],scope:{2:"comment"}}),c={begin:/<<-?\s*(?=\w+)/,starts:{contains:[e.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/,className:"string"})]}},o={className:"string",begin:/"/,end:/"/,contains:[e.BACKSLASH_ESCAPE,t,a]};a.contains.push(o);const r={begin:/\$?\(\(/,end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},e.NUMBER_MODE,t]},l=e.SHEBANG({binary:`(${["fish","bash","zsh","sh","csh","ksh","tcsh","dash","scsh"].join("|")})`,relevance:10}),m={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0,contains:[e.inherit(e.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z][a-z0-9._-]+\b/,keyword:["if","then","else","elif","fi","time","for","while","until","in","do","done","case","esac","coproc","function","select"],literal:["true","false"],built_in:["break","cd","continue","eval","exec","exit","export","getopts","hash","pwd","readonly","return","shift","test","times","trap","umask","unset","alias","bind","builtin","caller","command","declare","echo","enable","help","let","local","logout","mapfile","printf","read","readarray","source","sudo","type","typeset","ulimit","unalias","set","shopt","autoload","bg","bindkey","bye","cap","chdir","clone","comparguments","compcall","compctl","compdescribe","compfiles","compgroups","compquote","comptags","comptry","compvalues","dirs","disable","disown","echotc","echoti","emulate","fc","fg","float","functions","getcap","getln","history","integer","jobs","kill","limit","log","noglob","popd","print","pushd","pushln","rehash","sched","setcap","setopt","stat","suspend","ttyctl","unfunction","unhash","unlimit","unsetopt","vared","wait","whence","where","which","zcompile","zformat","zftp","zle","zmodload","zparseopts","zprof","zpty","zregexparse","zsocket","zstyle","ztcp","chcon","chgrp","chown","chmod","cp","dd","df","dir","dircolors","ln","ls","mkdir","mkfifo","mknod","mktemp","mv","realpath","rm","rmdir","shred","sync","touch","truncate","vdir","b2sum","base32","base64","cat","cksum","comm","csplit","cut","expand","fmt","fold","head","join","md5sum","nl","numfmt","od","paste","ptx","pr","sha1sum","sha224sum","sha256sum","sha384sum","sha512sum","shuf","sort","split","sum","tac","tail","tr","tsort","unexpand","uniq","wc","arch","basename","chroot","date","dirname","du","echo","env","expr","factor","groups","hostid","id","link","logname","nice","nohup","nproc","pathchk","pinky","printenv","printf","pwd","readlink","runcon","seq","sleep","stat","stdbuf","stty","tee","test","timeout","tty","uname","unlink","uptime","users","who","whoami","yes"]},contains:[l,e.SHEBANG(),m,r,i,c,{match:/(\/[a-z._-]+)+/},o,{match:/\\"/},{className:"string",begin:/'/,end:/'/},{match:/\\'/},t]}}export{bash as default};
+//# sourceMappingURL=/sm/de51d06e654380eeba27e164aa3184c082d7ebd41695db22b9cadc02d289a657.map
\ No newline at end of file
diff --git a/web/vendor/hljs/languages/javascript.min.js b/web/vendor/hljs/languages/javascript.min.js
new file mode 100644
index 0000000..621a22e
--- /dev/null
+++ b/web/vendor/hljs/languages/javascript.min.js
@@ -0,0 +1,8 @@
+/**
+ * Minified by jsDelivr using Terser v5.39.0.
+ * Original file: /npm/highlight.js@11.11.1/es/languages/javascript.js
+ *
+ * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
+ */
+const IDENT_RE="[A-Za-z$_][0-9A-Za-z$_]*",KEYWORDS=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends","using"],LITERALS=["true","false","null","undefined","NaN","Infinity"],TYPES=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],ERROR_TYPES=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],BUILT_IN_GLOBALS=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],BUILT_IN_VARIABLES=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],BUILT_INS=[].concat(BUILT_IN_GLOBALS,TYPES,ERROR_TYPES);function javascript(e){const n=e.regex,a=IDENT_RE,t="<>",s="",r={begin:/<[A-Za-z0-9\\._:-]+/,end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{const a=e[0].length+e.index,t=e.input[a];if("<"===t||","===t)return void n.ignoreMatch();let s;">"===t&&(((e,{after:n})=>{const a="`${e}\\s*\\(`)),n.concat("(?!",p.join("|"),")")),a,n.lookahead(/\s*\(/)),className:"title.function",relevance:0};var p;const T={begin:n.concat(/\./,n.lookahead(n.concat(a,/(?![0-9A-Za-z$_(])/))),end:a,excludeBegin:!0,keywords:"prototype",className:"property",relevance:0},v={match:[/get|set/,/\s+/,a,/(?=\()/],className:{1:"keyword",3:"title.function"},contains:[{begin:/\(\)/},y]},O="(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+e.UNDERSCORE_IDENT_RE+")\\s*=>",w={match:[/const|var|let/,/\s+/,a,/\s*/,/=\s*/,/(async\s*)?/,n.lookahead(O)],keywords:"async",className:{1:"keyword",3:"title.function"},contains:[y]};return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:c,exports:{PARAMS_CONTAINS:S,CLASS_REFERENCE:h},illegal:/#(?![$_A-z])/,contains:[e.SHEBANG({label:"shebang",binary:"node",relevance:5}),{label:"use_strict",className:"meta",relevance:10,begin:/^\s*['"]use (strict|asm)['"]/},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,g,E,u,m,A,{match:/\$\d+/},d,h,{scope:"attr",match:a+n.lookahead(":"),relevance:0},w,{begin:"("+e.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",relevance:0,contains:[A,e.REGEXP_MODE,{className:"function",begin:O,returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:e.UNDERSCORE_IDENT_RE,relevance:0},{className:null,begin:/\(\s*\)/,skip:!0},{begin:/(\s*)\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:c,contains:S}]}]},{begin:/,/,relevance:0},{match:/\s+/,relevance:0},{variants:[{begin:t,end:s},{match:/<[A-Za-z0-9\\._:-]+\s*\/>/},{begin:r.begin,"on:begin":r.isTrulyOpeningTag,end:r.end}],subLanguage:"xml",contains:[{begin:r.begin,end:r.end,skip:!0,contains:["self"]}]}]},I,{beginKeywords:"while if switch catch for"},{begin:"\\b(?!function)"+e.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{",returnBegin:!0,label:"func.def",contains:[y,e.inherit(e.TITLE_MODE,{begin:a,className:"title.function"})]},{match:/\.\.\./,relevance:0},T,{match:"\\$"+a,relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"},contains:[y]},f,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/,className:"variable.constant"},R,v,{match:/\$[(.]/}]}}export{javascript as default};
+//# sourceMappingURL=/sm/6626c239836aa62d600d09fa14e948e4a4d38fc4ce0874dba972944ba1e0630c.map
\ No newline at end of file
diff --git a/web/vendor/hljs/languages/json.min.js b/web/vendor/hljs/languages/json.min.js
new file mode 100644
index 0000000..36a8089
--- /dev/null
+++ b/web/vendor/hljs/languages/json.min.js
@@ -0,0 +1,8 @@
+/**
+ * Minified by jsDelivr using Terser v5.37.0.
+ * Original file: /npm/highlight.js@11.11.1/es/languages/json.js
+ *
+ * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
+ */
+function json(e){const a=["true","false","null"],n={scope:"literal",beginKeywords:a.join(" ")};return{name:"JSON",aliases:["jsonc"],keywords:{literal:a},contains:[{className:"attr",begin:/"(\\.|[^\\"\r\n])*"(?=\s*:)/,relevance:1.01},{match:/[{}[\],:]/,className:"punctuation",relevance:0},e.QUOTE_STRING_MODE,n,e.C_NUMBER_MODE,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE],illegal:"\\S"}}export{json as default};
+//# sourceMappingURL=/sm/cc8c22863ca08ad2642e2c51559f66774c360ac66c3780814484c6d4373e04c3.map
\ No newline at end of file
diff --git a/web/vendor/hljs/languages/python.min.js b/web/vendor/hljs/languages/python.min.js
new file mode 100644
index 0000000..edf2dba
--- /dev/null
+++ b/web/vendor/hljs/languages/python.min.js
@@ -0,0 +1,8 @@
+/**
+ * Minified by jsDelivr using Terser v5.39.0.
+ * Original file: /npm/highlight.js@11.11.1/es/languages/python.js
+ *
+ * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
+ */
+function python(e){const n=e.regex,a=/[\p{XID_Start}_]\p{XID_Continue}*/u,t=["and","as","assert","async","await","break","case","class","continue","def","del","elif","else","except","finally","for","from","global","if","import","in","is","lambda","match","nonlocal|10","not","or","pass","raise","return","try","while","with","yield"],i={$pattern:/[A-Za-z]\w+|__\w+__/,keyword:t,built_in:["__import__","abs","all","any","ascii","bin","bool","breakpoint","bytearray","bytes","callable","chr","classmethod","compile","complex","delattr","dict","dir","divmod","enumerate","eval","exec","filter","float","format","frozenset","getattr","globals","hasattr","hash","help","hex","id","input","int","isinstance","issubclass","iter","len","list","locals","map","max","memoryview","min","next","object","oct","open","ord","pow","print","property","range","repr","reversed","round","set","setattr","slice","sorted","staticmethod","str","sum","super","tuple","type","vars","zip"],literal:["__debug__","Ellipsis","False","None","NotImplemented","True"],type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"]},s={className:"meta",begin:/^(>>>|\.\.\.) /},r={className:"subst",begin:/\{/,end:/\}/,keywords:i,illegal:/#/},l={begin:/\{\{/,relevance:0},o={className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/,contains:[e.BACKSLASH_ESCAPE,s],relevance:10},{begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/,contains:[e.BACKSLASH_ESCAPE,s],relevance:10},{begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/,contains:[e.BACKSLASH_ESCAPE,s,l,r]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/,end:/"""/,contains:[e.BACKSLASH_ESCAPE,s,l,r]},{begin:/([uU]|[rR])'/,end:/'/,relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/,end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/,contains:[e.BACKSLASH_ESCAPE,l,r]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/,contains:[e.BACKSLASH_ESCAPE,l,r]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]},b="[0-9](_?[0-9])*",c=`(\\b(${b}))?\\.(${b})|\\b(${b})\\.`,d=`\\b|${t.join("|")}`,p={className:"number",relevance:0,variants:[{begin:`(\\b(${b})|(${c}))[eE][+-]?(${b})[jJ]?(?=${d})`},{begin:`(${c})[jJ]?`},{begin:`\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?(?=${d})`},{begin:`\\b0[bB](_?[01])+[lL]?(?=${d})`},{begin:`\\b0[oO](_?[0-7])+[lL]?(?=${d})`},{begin:`\\b0[xX](_?[0-9a-fA-F])+[lL]?(?=${d})`},{begin:`\\b(${b})[jJ](?=${d})`}]},g={className:"comment",begin:n.lookahead(/# type:/),end:/$/,keywords:i,contains:[{begin:/# type:/},{begin:/#/,end:/\b\B/,endsWithParent:!0}]},m={className:"params",variants:[{className:"",begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:i,contains:["self",s,p,o,e.HASH_COMMENT_MODE]}]};return r.contains=[o,p,s],{name:"Python",aliases:["py","gyp","ipython"],unicodeRegex:!0,keywords:i,illegal:/(<\/|\?)|=>/,contains:[s,p,{scope:"variable.language",match:/\bself\b/},{beginKeywords:"if",relevance:0},{match:/\bor\b/,scope:"keyword"},o,g,e.HASH_COMMENT_MODE,{match:[/\bdef/,/\s+/,a],scope:{1:"keyword",3:"title.function"},contains:[m]},{variants:[{match:[/\bclass/,/\s+/,a,/\s*/,/\(\s*/,a,/\s*\)/]},{match:[/\bclass/,/\s+/,a]}],scope:{1:"keyword",3:"title.class",6:"title.class.inherited"}},{className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[p,m,o]}]}}export{python as default};
+//# sourceMappingURL=/sm/5a8c1b91a61b978b4dd40a28193492db6e070459b84d0d6c39038e5addf91c28.map
\ No newline at end of file
diff --git a/web/vendor/hljs/languages/typescript.min.js b/web/vendor/hljs/languages/typescript.min.js
new file mode 100644
index 0000000..98f27fe
--- /dev/null
+++ b/web/vendor/hljs/languages/typescript.min.js
@@ -0,0 +1,8 @@
+/**
+ * Minified by jsDelivr using Terser v5.37.0.
+ * Original file: /npm/highlight.js@11.11.1/es/languages/typescript.js
+ *
+ * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
+ */
+const IDENT_RE="[A-Za-z$_][0-9A-Za-z$_]*",KEYWORDS=["as","in","of","if","for","while","finally","var","new","function","do","return","void","else","break","catch","instanceof","with","throw","case","default","try","switch","continue","typeof","delete","let","yield","const","class","debugger","async","await","static","import","from","export","extends","using"],LITERALS=["true","false","null","undefined","NaN","Infinity"],TYPES=["Object","Function","Boolean","Symbol","Math","Date","Number","BigInt","String","RegExp","Array","Float32Array","Float64Array","Int8Array","Uint8Array","Uint8ClampedArray","Int16Array","Int32Array","Uint16Array","Uint32Array","BigInt64Array","BigUint64Array","Set","Map","WeakSet","WeakMap","ArrayBuffer","SharedArrayBuffer","Atomics","DataView","JSON","Promise","Generator","GeneratorFunction","AsyncFunction","Reflect","Proxy","Intl","WebAssembly"],ERROR_TYPES=["Error","EvalError","InternalError","RangeError","ReferenceError","SyntaxError","TypeError","URIError"],BUILT_IN_GLOBALS=["setInterval","setTimeout","clearInterval","clearTimeout","require","exports","eval","isFinite","isNaN","parseFloat","parseInt","decodeURI","decodeURIComponent","encodeURI","encodeURIComponent","escape","unescape"],BUILT_IN_VARIABLES=["arguments","this","super","console","window","document","localStorage","sessionStorage","module","global"],BUILT_INS=[].concat(BUILT_IN_GLOBALS,TYPES,ERROR_TYPES);function javascript(e){const n=e.regex,a=IDENT_RE,t="<>",s="",c={begin:/<[A-Za-z0-9\\._:-]+/,end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{const a=e[0].length+e.index,t=e.input[a];if("<"===t||","===t)return void n.ignoreMatch();let s;">"===t&&(((e,{after:n})=>{const a="`${e}\\s*\\(`)),n.concat("(?!",h.join("|"),")")),a,n.lookahead(/\s*\(/)),className:"title.function",relevance:0};var h;const T={begin:n.concat(/\./,n.lookahead(n.concat(a,/(?![0-9A-Za-z$_(])/))),end:a,excludeBegin:!0,keywords:"prototype",className:"property",relevance:0},v={match:[/get|set/,/\s+/,a,/(?=\()/],className:{1:"keyword",3:"title.function"},contains:[{begin:/\(\)/},p]},O="(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+e.UNDERSCORE_IDENT_RE+")\\s*=>",w={match:[/const|var|let/,/\s+/,a,/\s*/,/=\s*/,/(async\s*)?/,n.lookahead(O)],keywords:"async",className:{1:"keyword",3:"title.function"},contains:[p]};return{name:"JavaScript",aliases:["js","jsx","mjs","cjs"],keywords:r,exports:{PARAMS_CONTAINS:N,CLASS_REFERENCE:R},illegal:/#(?![$_A-z])/,contains:[e.SHEBANG({label:"shebang",binary:"node",relevance:5}),{label:"use_strict",className:"meta",relevance:10,begin:/^\s*['"]use (strict|asm)['"]/},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,E,g,u,m,A,{match:/\$\d+/},d,R,{scope:"attr",match:a+n.lookahead(":"),relevance:0},w,{begin:"("+e.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*",keywords:"return throw case",relevance:0,contains:[A,e.REGEXP_MODE,{className:"function",begin:O,returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{begin:e.UNDERSCORE_IDENT_RE,relevance:0},{className:null,begin:/\(\s*\)/,skip:!0},{begin:/(\s*)\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:r,contains:N}]}]},{begin:/,/,relevance:0},{match:/\s+/,relevance:0},{variants:[{begin:t,end:s},{match:/<[A-Za-z0-9\\._:-]+\s*\/>/},{begin:c.begin,"on:begin":c.isTrulyOpeningTag,end:c.end}],subLanguage:"xml",contains:[{begin:c.begin,end:c.end,skip:!0,contains:["self"]}]}]},I,{beginKeywords:"while if switch catch for"},{begin:"\\b(?!function)"+e.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{",returnBegin:!0,label:"func.def",contains:[p,e.inherit(e.TITLE_MODE,{begin:a,className:"title.function"})]},{match:/\.\.\./,relevance:0},T,{match:"\\$"+a,relevance:0},{match:[/\bconstructor(?=\s*\()/],className:{1:"title.function"},contains:[p]},f,{relevance:0,match:/\b[A-Z][A-Z_0-9]+\b/,className:"variable.constant"},y,v,{match:/\$[(.]/}]}}function typescript(e){const n=e.regex,a=javascript(e),t=IDENT_RE,s=["any","void","number","boolean","string","object","never","symbol","bigint","unknown"],c={begin:[/namespace/,/\s+/,e.IDENT_RE],beginScope:{1:"keyword",3:"title.class"}},r={beginKeywords:"interface",end:/\{/,excludeEnd:!0,keywords:{keyword:"interface extends",built_in:s},contains:[a.exports.CLASS_REFERENCE]},i={$pattern:IDENT_RE,keyword:KEYWORDS.concat(["type","interface","public","private","protected","implements","declare","abstract","readonly","enum","override","satisfies"]),literal:LITERALS,built_in:BUILT_INS.concat(s),"variable.language":BUILT_IN_VARIABLES},o={className:"meta",begin:"@"+t},l=(e,n,a)=>{const t=e.contains.findIndex((e=>e.label===n));if(-1===t)throw new Error("can not find mode to replace");e.contains.splice(t,1,a)};Object.assign(a.keywords,i),a.exports.PARAMS_CONTAINS.push(o);const d=a.contains.find((e=>"attr"===e.scope)),b=Object.assign({},d,{match:n.concat(t,n.lookahead(/\s*\?:/))});a.exports.PARAMS_CONTAINS.push([a.exports.CLASS_REFERENCE,d,b]),a.contains=a.contains.concat([o,c,r,b]),l(a,"shebang",e.SHEBANG()),l(a,"use_strict",{className:"meta",relevance:10,begin:/^\s*['"]use strict['"]/});return a.contains.find((e=>"func.def"===e.label)).relevance=0,Object.assign(a,{name:"TypeScript",aliases:["ts","tsx","mts","cts"]}),a}export{typescript as default};
+//# sourceMappingURL=/sm/5488a7fbe43e41a7df89e5f514f5404caf288a5a7aace998653812ec1026138c.map
\ No newline at end of file
diff --git a/web/vendor/hljs/languages/yaml.min.js b/web/vendor/hljs/languages/yaml.min.js
new file mode 100644
index 0000000..034884f
--- /dev/null
+++ b/web/vendor/hljs/languages/yaml.min.js
@@ -0,0 +1,8 @@
+/**
+ * Minified by jsDelivr using Terser v5.39.0.
+ * Original file: /npm/highlight.js@11.11.1/es/languages/yaml.js
+ *
+ * Do NOT use SRI with dynamically generated files! More information: https://www.jsdelivr.com/using-sri-with-dynamic-files
+ */
+function yaml(e){const n="true false yes no null",a="[\\w#;/?:@&=+$,.~*'()[\\]]+",s={className:"string",relevance:0,variants:[{begin:/"/,end:/"/},{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable",variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(s,{variants:[{begin:/'/,end:/'/,contains:[{begin:/''/,relevance:0}]},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),l={className:"number",begin:"\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\.[0-9]*)?([ \\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\b"},t={end:",",endsWithParent:!0,excludeEnd:!0,keywords:n,relevance:0},c={begin:/\{/,end:/\}/,contains:[t],illegal:"\\n",relevance:0},b={begin:"\\[",end:"\\]",contains:[t],illegal:"\\n",relevance:0},g=[{className:"attr",variants:[{begin:/[\w*@][\w*@ :()\./-]*:(?=[ \t]|$)/},{begin:/"[\w*@][\w*@ :()\./-]*":(?=[ \t]|$)/},{begin:/'[\w*@][\w*@ :()\./-]*':(?=[ \t]|$)/}]},{className:"meta",begin:"^---\\s*$",relevance:10},{className:"string",begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0,relevance:0},{className:"type",begin:"!\\w+!"+a},{className:"type",begin:"!<"+a+">"},{className:"type",begin:"!"+a},{className:"type",begin:"!!"+a},{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta",begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)",relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},l,{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},c,b,{className:"string",relevance:0,begin:/'/,end:/'/,contains:[{match:/''/,scope:"char.escape",relevance:0}]},s],r=[...g];return r.pop(),r.push(i),t.contains=r,{name:"YAML",case_insensitive:!0,aliases:["yml"],contains:g}}export{yaml as default};
+//# sourceMappingURL=/sm/5b3961de0ff3696c1cfff7a4550ffc4f9694605df34cfd6da27a118f3dd5ea41.map
\ No newline at end of file