feat(file-viewer): syntax highlighting for code files and chat code blocks
Nightly Build / build (push) Successful in 8s

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.
This commit is contained in:
Daniele
2026-08-11 10:41:50 +01:00
parent 4d1b1e63be
commit 402c9ffe50
16 changed files with 240 additions and 3 deletions
+15
View File
@@ -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-view>; 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 {
<pre class="fv-code"><code>${this._content}</code></pre>
`;
}
if (this._codeHtml != null) {
return html`<pre class="fv-code"><code class="hljs">${unsafeHTML(this._codeHtml)}</code></pre>`;
}
return html`<pre class="fv-code"><code>${this._content}</code></pre>`;
}
}
+73
View File
@@ -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 <pre>) 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; }
+11
View File
@@ -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);
+1
View File
@@ -70,6 +70,7 @@
<link rel="stylesheet" href="css/projects/base.css" />
<link rel="stylesheet" href="css/projects/board.css" />
<link rel="stylesheet" href="css/file-viewer.css" />
<link rel="stylesheet" href="css/hljs-theme.css" />
<!-- pdf.js TextLayer's DOM contract (selectable text over the rendered pages). -->
<link rel="stylesheet" href="/vendor/pdf-text-layer.css" />
+6 -2
View File
@@ -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. `<pre>` 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 = `<button type="button" class="md-code-copy" title="${t('chat.copy_code')}"><i class="bi bi-clipboard"></i></button>`;
return html.replaceAll('<pre>', `<div class="md-code-wrap">${btn}<pre>`)
.replaceAll('</pre>', '</pre></div>');
return highlighted.replaceAll('<pre>', `<div class="md-code-wrap">${btn}<pre>`)
.replaceAll('</pre>', '</pre></div>');
}
// One delegated listener serves every copy button renderMarkdown has ever
+72
View File
@@ -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 `<pre><code
* class="language-…">` 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;
}
+1
View File
@@ -46,6 +46,7 @@
<link rel="stylesheet" href="css/agent-tasks.css" />
<link rel="stylesheet" href="css/inbox-cards.css" />
<link rel="stylesheet" href="css/file-viewer.css" />
<link rel="stylesheet" href="css/hljs-theme.css" />
<!-- pdf.js TextLayer's DOM contract (selectable text over the rendered pages). -->
<link rel="stylesheet" href="/vendor/pdf-text-layer.css" />
<link rel="stylesheet" href="css/mobile.css" />
+10
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -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
File diff suppressed because one or more lines are too long
+8
View File
@@ -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
+8
View File
@@ -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
File diff suppressed because one or more lines are too long
+8
View File
@@ -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