开始对话
+选择一个模型,输入消息开始聊天
+From 98a20812b15f021a219e76f16cd6956155cde84b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=B4=AB=E5=BD=B1233?= <1440196015@qq.com> Date: Fri, 3 Apr 2026 10:36:29 +0800 Subject: [PATCH] =?UTF-8?q?=E5=88=9D=E5=A7=8B=E7=89=88=E6=9C=AC=20-=20Meto?= =?UTF-8?q?na=20Ollama=20Client=20v1.1.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 100 ++ chat-db.js | 193 ++++ index.html | 1608 ++++++++++++++++++++++++++++++ llama.ico | Bin 0 -> 370070 bytes llama.png | Bin 0 -> 8437 bytes manifest.json | 19 + marked.esm.js | 2580 +++++++++++++++++++++++++++++++++++++++++++++++++ ollama-api.js | 169 ++++ style.css | 1413 +++++++++++++++++++++++++++ sw.js | 63 ++ 10 files changed, 6145 insertions(+) create mode 100644 README.md create mode 100644 chat-db.js create mode 100644 index.html create mode 100644 llama.ico create mode 100644 llama.png create mode 100644 manifest.json create mode 100644 marked.esm.js create mode 100644 ollama-api.js create mode 100644 style.css create mode 100644 sw.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..04ee571 --- /dev/null +++ b/README.md @@ -0,0 +1,100 @@ +# 🦙 Metona Ollama Client + +基于原生 JavaScript 的 [Ollama](https://ollama.com) AI 聊天客户端,支持流式对话、多模态图片、Think 推理模式、历史管理,可安装为 PWA 离线使用。 + + + + + +## ✨ 功能特性 + +- **流式对话** — 基于 ReadableStream 的实时打字机效果 +- **多模型支持** — 自动加载 Ollama 已安装模型,一键切换 +- **Think 推理模式** — 可展开/收起的思考过程展示 +- **多模态输入** — 支持图片上传,兼容视觉模型 +- **历史管理** — IndexedDB 持久化,支持搜索、分页、导出(Markdown/HTML/TXT/JSON) +- **PWA 离线** — Service Worker 缓存,可安装到桌面 +- **连接检测** — 实时状态指示,CORS 问题自动提示 +- **显存管理** — 一键卸载模型释放显存 +- **XSS 防护** — 内置 HTML 净化器,安全渲染 Markdown + +## 🚀 快速开始 + +### 前置条件 + +1. 安装 [Ollama](https://ollama.com) 并启动服务 +2. 下载至少一个模型(例如 `ollama pull qwen2.5`) + +### 启动 + +```bash +# 克隆仓库 +git clone https://gitee.com/thzxx/metona-ollama.git +cd metona-ollama + +# 直接用浏览器打开 +open index.html + +# 或用任意 HTTP 服务器托管 +python3 -m http.server 8080 +# 然后访问 http://localhost:8080 +``` + +### 跨域配置 + +如果 Ollama 和页面不在同一域,需要设置环境变量: + +```bash +OLLAMA_ORIGINS="*" ollama serve +``` + +## 📁 项目结构 + +``` +metona-ollama/ +├── index.html # 主页面 + 应用逻辑 +├── style.css # 暗色主题样式 +├── ollama-api.js # Ollama REST API 封装 +├── chat-db.js # IndexedDB 持久化层 +├── marked.esm.js # Markdown 渲染器 +├── sw.js # Service Worker (PWA) +├── manifest.json # PWA 清单 +└── README.md +``` + +## ⚙️ 设置项 + +| 设置 | 说明 | 默认值 | +|------|------|--------| +| Ollama 服务地址 | API 端点 | `http://127.0.0.1:11434` | +| 系统提示词 | 全局 System Prompt | 关闭 | +| 上下文长度 | `num_ctx` 参数 | 24576 tokens | +| Think 模式 | 启用深度推理(需要模型支持) | 关闭 | + +## 📦 数据导出 + +支持以下导出格式: + +- **Markdown** — 单会话导出,便于阅读 +- **HTML** — 带样式的离线页面 +- **TXT** — 纯文本备份 +- **JSON** — 全量备份/迁移,可跨设备导入 + +## 🛠️ 技术栈 + +- **纯原生 JS** — 零依赖(除 marked.js),无构建步骤 +- **IndexedDB** — 异步持久化,支持大数据存储 +- **Fetch API + ReadableStream** — 流式 NDJSON 解析 +- **PWA** — Service Worker 离线缓存 +- **CSS Variables** — 暗色主题,响应式布局 + +## 🔒 安全 + +- 内置 HTML 净化器(白名单标签 + 属性过滤) +- Markdown 链接协议白名单(http/https/mailto/tel) +- 阻止 `javascript:` / `vbscript:` / `data:` 协议注入 +- 输入内容自动转义 + +## 📄 License + +MIT diff --git a/chat-db.js b/chat-db.js new file mode 100644 index 0000000..53ddb3b --- /dev/null +++ b/chat-db.js @@ -0,0 +1,193 @@ +/** + * ChatDB - IndexedDB 封装层 + * + * 用于持久化聊天历史记录和设置 + * 使用 IndexedDB 而非 localStorage,原因: + * 1. 支持存储更大的数据(如 base64 图片) + * 2. 异步操作不阻塞主线程 + * 3. 支持事务和索引查询 + */ + +export class ChatDB { + constructor(dbName = 'metona-ollama', version = 1) { + this.dbName = dbName; + this.version = version; + this.db = null; + } + + /** + * 初始化数据库 + * 创建 object stores 和索引 + */ + async init() { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.dbName, this.version); + + request.onerror = () => { + console.error('[ChatDB] 数据库打开失败:', request.error); + reject(request.error); + }; + + request.onsuccess = () => { + this.db = request.result; + console.log('[ChatDB] 数据库已连接'); + resolve(); + }; + + // 数据库升级/创建回调 + request.onupgradeneeded = (event) => { + const db = event.target.result; + + // 会话存储 + if (!db.objectStoreNames.contains('sessions')) { + const sessionStore = db.createObjectStore('sessions', { keyPath: 'id' }); + sessionStore.createIndex('updatedAt', 'updatedAt', { unique: false }); + sessionStore.createIndex('model', 'model', { unique: false }); + console.log('[ChatDB] 创建 sessions 存储'); + } + + // 设置存储 + if (!db.objectStoreNames.contains('settings')) { + db.createObjectStore('settings', { keyPath: 'key' }); + console.log('[ChatDB] 创建 settings 存储'); + } + }; + }); + } + + /** + * 确保数据库已连接 + */ + _ensureDB() { + if (!this.db) throw new Error('数据库未初始化,请先调用 init()'); + } + + /** + * 获取事务 + */ + _tx(storeName, mode = 'readonly') { + this._ensureDB(); + const tx = this.db.transaction(storeName, mode); + return tx.objectStore(storeName); + } + + // ═══════════════════════════════════════════════════════════════ + // 会话 CRUD + // ═══════════════════════════════════════════════════════════════ + + /** 保存/更新会话 */ + async saveSession(session) { + return new Promise((resolve, reject) => { + const store = this._tx('sessions', 'readwrite'); + const request = store.put(session); + request.onsuccess = () => resolve(session.id); + request.onerror = () => reject(request.error); + }); + } + + /** 获取单个会话 */ + async getSession(id) { + return new Promise((resolve, reject) => { + const store = this._tx('sessions'); + const request = store.get(id); + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => reject(request.error); + }); + } + + /** 获取所有会话 */ + async getAllSessions() { + return new Promise((resolve, reject) => { + const store = this._tx('sessions'); + const request = store.getAll(); + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(request.error); + }); + } + + /** 删除会话 */ + async deleteSession(id) { + return new Promise((resolve, reject) => { + const store = this._tx('sessions', 'readwrite'); + const request = store.delete(id); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + /** 清空所有会话 */ + async clearAll() { + return new Promise((resolve, reject) => { + const store = this._tx('sessions', 'readwrite'); + const request = store.clear(); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + /** 批量保存会话(单事务,原子性) */ + async importSessions(sessions) { + return new Promise((resolve, reject) => { + const tx = this.db.transaction('sessions', 'readwrite'); + const store = tx.objectStore('sessions'); + let imported = 0; + let skipped = 0; + + tx.oncomplete = () => resolve({ imported, skipped }); + tx.onerror = () => reject(tx.error); + + // 先检查每个 ID 是否已存在,再 put + for (const session of sessions) { + if (!session.id || !Array.isArray(session.messages)) { + skipped++; + continue; + } + const getRequest = store.get(session.id); + getRequest.onsuccess = () => { + if (getRequest.result) { + skipped++; + } else { + store.put(session); + imported++; + } + }; + } + }); + } + + /** 按时间范围查询会话 */ + async getSessionsByTimeRange(startTime, endTime) { + return new Promise((resolve, reject) => { + const store = this._tx('sessions'); + const index = store.index('updatedAt'); + const range = IDBKeyRange.bound(startTime, endTime); + const request = index.getAll(range); + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(request.error); + }); + } + + // ═══════════════════════════════════════════════════════════════ + // 设置 CRUD + // ═══════════════════════════════════════════════════════════════ + + /** 保存设置 */ + async saveSetting(key, value) { + return new Promise((resolve, reject) => { + const store = this._tx('settings', 'readwrite'); + const request = store.put({ key, value }); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }); + } + + /** 获取设置 */ + async getSetting(key, defaultValue = null) { + return new Promise((resolve, reject) => { + const store = this._tx('settings'); + const request = store.get(key); + request.onsuccess = () => resolve(request.result ? request.result.value : defaultValue); + request.onerror = () => reject(request.error); + }); + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..681b363 --- /dev/null +++ b/index.html @@ -0,0 +1,1608 @@ + + +
+ + + + + + + + +选择一个模型,输入消息开始聊天
+Zf<<`v&HJp%tLkdhkBXw zi={8;ox}N4$SbuW({7opIpbX%{OYs|8<*tsN%PvM=OtrZ4u5wb-*oDCvug=CYsn9G o51WVZ2$a zPLc24DVJtFe|D|SE4|S%*iY*XIt}IA$(O)HzIr{b-6eS^xz?2Zhqi5YCFrLy9T898 z7v;?LV146nm7t&2*n! ;|Q@4qln}-Z(Y+nlh_mSqGxb|8|Td1s8FveOS$tq2Q3i|^+Pn4i( z56yq*6-rBXe3{0o ;cl(N zq0T=QqaWWhYjoQEcn6M&V)VPUh9(LN$a-Nh`bif=)#XMx(9uQ3=#Rxy6Wpj*RGH9^ z-xs4F_+KNcF45&k_ohDs+Fvb7zsqxFc6sXj-b$SSnj`R2ewo(12Txn4-DFsIHu6Jr ze|GB-nZ8bHKV ;;wjswyLZ>(XKK}N6-;IonMv11!PI&_d zE V{BIsJ!8TWi^7+C4|CyOcf5}dLZloPLY0*W}-CG|6JzaCk6K`FvjgH-4 zlqd8HH@HEM(=xO3gBhC7I(L9}>`_N*(;K@_;qYd+qprJEh5PzU >)w8K zt+PhX+VsqnyE;}^nkK9D_p4q0bI`u8Z|#b4v6kn~zH0JXeE+L6tmdzDy8a_pT-kqQ z+nJ EiVMK!7GFPRAwTa??)>#rOD|H!uBh z`sT;H%>OksJ@=I!2?Ret`%iw~$w-TOrr@F-6=&T01iBt}REWh)*?o4qvVHeI344oK zT}wuD@mYFkoUuogf4dpMSVQ`Rp)L*Mfiuj~dHoDD(R^cOoJGEGZq-k7dW3OzUCKna z>atTV=y^q_iN ?FmirCEI4K zi`GP(&@3tst!o+Sq2Cw}T_vjg{Blr!*Ph)>U2#>)PvhnPwAPf*KlaAO+@b;VL|csY zVLsmTeIQ2OXuZuy5B;Y3AY&SlzfpcVx4z2Gxq_Bax%5ywptv^2fx2@24a mj+?^>OIrM<;@b!7P0ebb!s;WQuoI>;xh zhddlSBdo|rdq}Z41llKNR(84m>Q4Em{a$ADl=%$$lv<8!SpGf&YFEw5_Ih5B^cCxy z^UP=w{0zQB>R)O90PeGjls&|$44vdF DxXJ3kMu<&3WjAFz1hTooJ zjwkKN(n(tKv-B_j?!+B2;soBe`j>w-t >JV%|M7I60PvrYgB$+q_06eMgTa65 z4N); XX0ZV_Q02~uaRz1zXM?Z?^=!9=me9_J)f?(UryW6hYQ7*b;7Gz9*SG; zGrgxV&LidSMKt5p>{-_D2C>$x>Oks^Ok4`A%@U0mi(Y|w6U2H}=Aewl7^gj&UB@E7 zi4RMZOTHyrk>}5u{D_lT2|sVp@ShTNcIz+p` %* z_~O;VJMn?<>5UfoujqQLTbP6Qq(%O_=3?BU#p`mZF9PpY_mZ#mnm#w{qdlXDJ$%10 z&Tk1lc)(41yeOMuJX73CjM-%H`X>0-hRj&6T|9b~Hge`B^FB}S|FBN!1&yNWz&GQ6 zgU2DXZya~p+y4(-raV%t=>enKwCm?QWBQ)*p`W-?6m4#oGv?+E-yAe{)!yOJ4cd$* zr65m6Jch?MYD1;<1=C8+9Y1oLZc*i5Gxh0*C(Um!$cvG0{WtAh9yP0=4ZP%5+zC-U z8_T(N>bh5TzO3GF#93GRd0F{+Fh-nfEZ@(+`}7(3$ M&DKGoXAzaFohbN(gT v)?bdJ^$tvevrSdHD0uL zE8UoH_ln>AdT8A!>(f`rr&E{zCfavk)o(Mu9>4!Tq+#QaubsXw)p@>1``Sqh+UeWp zA9R+P({sZ7&a+Nm-w|ymVbA+3xU~MYZMRNe)G;O#>jd8ddYWpz53E~$2;vmHN_+nM zk7&Jt=3(m^Hoae4zY%*{{2SLulspit;XLsEdcV3AnRwx7-)^~l{Az7#V{$L`lcMVJ zYdMhpf1N+#iA+q5Yz+7hC{7Ofs{55@x9G~At9uX4 {F5
B`0DMzpId|x?d;J8h<*X0-S&HI tSiW6W#)bg z_$&o^eKJ4Rl~ETcCPt8DCX)8fcq!(7DPEjEEs_^wS?JeR7-gRK+toOeMmqfdyXcvE zh!^a4`U}xllrNlPS14cDo@2iE@jTn;>L>9$u>Q!P{ox{fX%v4fY_c}v7Z|k_#HqsZ z0nO;O`V9TrUkdU?;~iNcEmAoDLYRErJt#H!2e!uw2Z+Q_-{bFa{i6ZXMN-A2{X2 z#Ld?Qir*1LKEcNy@_EVo-?ek;gM=5_!RPXDG4}ryk9km#_|a*_`2W6Qe9=B~J#Cop zz^Lfnbn m%@nN zSNfcwVme&t9ev-G 2;c)-uE)q?zeaRy!`u+92@{ktSa z&;vTNp%OHfQz!V+W7_77`HlQyZfkuxbp$!Dy||pI ZPtbAf%ImhdWurNwt|Q`>|7XKqF(T=2ufyx#2=X`$FGRTpo%lx`6AR`Ky5-G0o& zZA05KJKCb0Bw{=Unk~+2P#cjNT?(&idk=ha&q24?R(hZd)CT>F<|WDZoW>b;vCp12 z@Q!s_-8*mFF6~~Vrx;|`v9Z>Nx>z2KB_7JePFvc3kQIFF_HAhY7g{N|8q##m`DM=0 z@T#vDS QeJMB^%BO8^C*6W zab6;lNd5vm7TWh~N)L@it4YpgywJBf#(ZmR*@eHx1g5gf^7B5 oios`+ZqdnwI8Uq_{0*vl_5(dtn#$WH=X0-)Xw~v5AFVVyBLHy zeZ8FisUPL&geF>pg)ILWW_jA>o#qXkVC}~|m>;XN%YW$8V;_VcW$A<_%x76WYgG6+ zI ;sInsAmGtbRMR@jT;L(U&^D^zvC_c zeQzj6yU*?)$lK?jtZ?FwYAn~S-`O}$yw>MjuU^t+Qy*7Ph8^XfYrfCV=d;rp+HCvI z+WstV?~wd6{xXSF$^^?tbp7z-o%S%9(9G?>_4Z&N`4q!v;#yN2<<#YVl)Fw7VqIQg zO1E|XL$52d A`$;1GoRe zbl^3LYn6|&pZ`buKb7S#ME>tk3>rVn+Ke{He>BU#Z27+#MA?%*MB}NeS^j0nALks# zno>uY*;WvqQ5-dvza#nY$9{=BDK<(F I= zb9@3SC*eA|74xNoyof{Ul@8JVWp4kA=?P-p(pe|*bRVx|;$=N{;$4@aCyj_DGnjni z;0rr8l2GdL|6RELh-a&SFP$Sw@%sGG&g`T3!?Z3!@c`#8ec_W{%ERlrvU|SW!yRYD zTWv3AE>3 (E;*-7fr8T9_baur_KR-@)*Q* z{~?{(Na?NWqh3$7-y!=~+>w9k4cZ4$?f3 pu4D{o>*OmR> zw6`n__ywIu>;a3qVgEFr6Lf6CKtI+1rnw;heIm|d3IqO?=FuJbr{1VEUtGU61YFt! zlD1Ymr>ZmpSnpaSzWrBLSDG)b-x>l~gMD1Q{8N9iRy(JvBm%S+2HC$UTK+LvZLTC= zT(>m@GV7x*to`C_#G9>Az^N*QK*sjzoNX86PjT-m#TVCG8i8qx(nn63zrAhJ{B~{p z?c21;ceZO%vm(p~Boa?uRGJD-;Uxmnk{w^BH96S(leqRxYfNgj4V$!qV^(SXYFo7n z>)Ny{C#}ioL)mQtd~v+wjqB=(0LAtrKXu4*I(+9=;7)#TJ!~A`|JtZoo3sI=R%Y~o zbYYmZUc=sy9%;?;n>^c3XGiv=h@ZHDKql5~OZvN=XV|7j)Au}Nx(0Hw }#IkY^fh#AieU;kii>_O#4MjUWqbcb~)`fJ2#)4EnUG%$9x}0vC^2_5Q?p+ic6kNgFYG_A^e# z{;vip_LVzQa;MY_hAsc%*_Yg=^}Bk$Htp_bT-O17TiUJza}G1z;hsx9=9E(oJEr#! zwPSi8q4nw0N4rk?gKhaZX$NfNd7J$`-P6;<4?q0lC!KWit0aHA&b#m`U&$Zq{WIL- zo=YYKBuV b1V&i$e-d48|2a@-<#$?t@SzK zgd83C#d!m}-kasI@(lAlceCvMA$y_%V}egWZf{wUU72Y S3n2;BUlgKMY%CL=dRlEs|Nn+6FdHkViQ9LZ25oX>CAq# zC+iWzVSG@|vZ)5yiR6LKo`ejqntFFf>$F9Rsqd%3=lgw%uSnxMI?J?vvHEq9v~XG8 zBXBxm@Aa!~i@;xKFZm2zJbI;e`S{h^kQwVAjmYT>rY%aotFQ5s+`XreYjg{N{?{)* z6f%F3 )N6`Osfn=@u VBl$_8VcU+TYj{ zIx=s5k8Hm~iYs5MTBez?E(9><|8uweaefNU{bir6E_kZ`ha@L^!B6NK% LKW;#ohVe%gW zQuB_(AupT_+#}Wnd{ iZwJ5XQQ91cP!RMGca0>zG(PE3bL}P&m+@gcawT1wV z{UHY%`y4qwsWoK7nF@1%x7x6Otv-=FF`P*GgKOH$WJg{|yN~y_`a^bpqyj$D RUcQ5wXILGdR#SO5F}n4$t$70#%qB=lCpQSN{#9l?b?4Vo|@ntg 0?c)BaG$nZ$^L+pnd`RVedRmS^X=cR~ZDHwAW<+6yvTk6mzY9M*#ZqD(Q&z zEA %3~^DCU~| zi~w}0&8|LC?0rA;RL!)JE!&kB((jj+?D%pu O4w zd#y7q_I96NZrz-}`VfdEl!SfzSD&mtB!a;aWc7ptdC~k%Bp4hrm0luXV-JvVj_@s> z1N2f#-F3l^A8=& Y%g&lFXVaEGa|X7z#xF{ z|JhFT3GK#Zfnl Lw$OO z&RDA$;UTRU@ffdnC@-9Ssr)UZurb>O5GY5VXg^AUV)?O42tZyV9NT|FeaR)VxY&Rq zfcU>RI@KqN9}rNs14}mg@WD>yMfs%W_Rj~Fc_v&q0vP{KEkmEC3nz#REQ&x>PF-Gx zyflg-RI~zq87c&zOOZ14=?|d-%LmLlh}o30K8ShSp*~=PN$?2*<*X;6-PrCEHbO&2 zq&an78SS-3{$8O0iy1A6fZZ6tdAhfUVp5l^gcAruAkw0qnJ0ss{@ud6Wx|Nzf&z~K z^ku#Uohhtie8z;QI^ZY }DUtq3new82K$4GHWYRoD0J@YeQ=i`FeE{AG zOoaVY3<2*b=9>JB0BrxwGVNcJ q3_75L`g`N=|@`^Ba?=3=R(U$a)A%`;b?8oHf=|XfV!STSo zQYNWM`LPI@#o|g|uW06a{D}Z!Nn9dAmr%w({K;YUQ6*&lkH6WfPca;DR8H)Vymn)s zB=7u$0}?okO$6!_$rJOi@8Oea vmlvmttMQU7yAgHFWDwYmv{}(KKzby z2%NKd%@F~dsVWel^ZQ`??~1a2lrz67fblsKw0B34%=Yt~jn9ScB6)NclpsCQ=bs*N zkz6i1ivZ4Rt`#LW_S+7xT8W!S%FYkORW-b5ddE3@cgKmce~tz0o!tCZ6Le^lFg@yw zFZJQlrP_gh #!kG|>Gg309K z@(_T}=6wR?c28dM{BH;lsBiB0mOvdcepB*!U@q$qo#87`c2BxYE*I}Z1Q7GPpFkZl z*ncYSLw2i*B&`2!6ev5Ts=#4pEFln+)fobHsN4SI>f@FO;6#oQ!1({~0%i9f$Aoak zz#;(s5$5-wr;F*pf)W72vp$=<@-Xjif3x$jcHZCI`0Qr`z&evC2!X5){mYJy*hssZ zV*v{ilu0oSP0xMBhAb?#3zDq KChDuTtnL&Q^U~Io61^ zh~Jn`_*umnV*)%wK;Zrn^Zq5C?&BFVA*%@UHvL7|d`=mN0TnVBL(Oiv=Y8hrj>-3C z{&}F}6Flp_t)HX)MzNOkcQfZ#(HV9Huk?{pBJ~|wqm&l39};R| 8CSpTQVH&WxXB z^_00wU-+a|%>0xNe7sf$4uvr0^PBx7WEW|{TyMe!{VAkZety0idlgn;E_5cwzvoK# zra$8%>_UomW9=`i3+w|dL_+>AM6mCepY7W&ZF{k=egp8CL+fFF{AO@v?jPeUnpg3^ z<}!ulB7pN={+s)LYq5-h(>u8C$M^P7sVV)9a @a;gbFY(VokpIhl#UgS7?srPf&k z;`AO0+3j{qeo}M$=e^>E>+v1|k}q^;k6Zc^;&PwITI0>+Q|+cy-S*jx$sONwlYTC{ zvIyWi_R0|3XgPhooxJT>Uz|uj7NVsM**)T>{OC+U(KK^;{y_k9yi48GA1*s*UXjLG zuvg^@5&p|kT=^QxBOeAkZIP|Cw6@?MZDmSCAwYX^$-fi2v(HWa;j# xkWVSH{0UTK-WhnnUOF zfG5}H{dBxy33aj+dN@rm5WxI&Z^-spUA}xT*gsdQefc-mW0%lbo(+v1hnZ;=J;%J? zdM{*$dghwZz|Vq*0L|q XZ0V)mc%`3QkL&Hg!}+?z{|5_!NOS5u*a_`{E!x)ed&Z}y4k)XGrR_|u5rJ%6 zIM2q5J@toeD&Uy38}*5Q2M2*zlk#oo-m3xEKOVDGcHH(-a5SDN)e*?9@hNQkJpL$C z2j16pitoWdpfirY@?e>A=YF@c{jP8DpEoc4@nC2^Q%MNG?nklhbH6W22PnP}TUIa- zpnd3&`}?9~&*gACLh)gOq5a{elKjZwud#(~mTgv~4%Ex(pN8AAg26p}wa1E-JD0`n z2 rQx-#U~o<`?76?@{@N8Ch$|z4VFlr& zQflt_5#-MM__^;F-}lq_pZ5U!x?#b-u;01T?z26)VoyPn6~!_1wE={fIH`HZ;m{|X zQNd$h#=spNptD6oY+}A<9b>Z=)_ZsKo^kEOm10Et+6Y2QoYbg(5jwhy^_1=3RUMF( zzlGGqe9bn_r5o?6-1(b=zyF~Fh%@@MuMHuj#KHDE*#5cicV7omsgRnOZ`rPIPMzw$ z+}XAm J%$ID_*&Xord)Xt|^EWWhgJ^S>Y&qDM z#t=fHqAkivZ2R2zd#M9bQ~DbrG&Ntcj58T-@ly6&7q>OBrqmI>G=>lo1$*4iHpw>W zsSe2MaUnD{-?5zC=eyri+4J{r=;uJ_{21RELkJ0?bK%(bx$pN{2e1dAUkJ_3cP!I6 zv0lrb>*MwYXO&;!J7WkTK^)(YZLuQlv8>jG(A<2*^4z5_d=j$XQxW;|96+e&01yLk zqElm_^C)KBxAk+9yz@|}WpF-<>z_J-ZJ+!9O6UN-|Lg78AlW;{sv|+`6S&?s(EJX* zCGKlz>^RI$d7QSU26M2r1{_1(A2AMIw}Z+ZR&0>Qs5mcR8st;-%vp+CVWm7y(~SVd zVPU=ZTJO0&(mC`!iEd3a|J8Z+Oe$qTN8h6TYv!f!(?SRU*C#Dt%i{u`t8hUFQvX{> zFX?$qR(}cI#Mr)Md%2U^iDG5(J97kp?TZ%hbwTzeWy*Dy*85;%6jvWL*UFk2Iv)sn zS#-Rg)!SW&RolBzIsCjZ0<`}M7_#4AwcD9r`HQQo%(Rov2E@Gd8y5Se>1Ji{v(gAq zyJ->QVD~eA)&G#wMSdswTDo+pb|Chmt+A+=Z{FPa>}T`K;Qy^5fEcXjTf~{|KjgOR zik`^obNQMYi>D@7tt%2&=jG?;|E(i{7>q-$V$JDzzQP3^$cX{gxerZw&vHF2$z!Z* zJEzP?0PUw_8EfwUcnl<72X+~ClFkIOp^rO_dHJ_}1b}s;4ZK ZD1byu_|NbLi;Bt|A*QD zU~AbAh2qp3^_YJ ko#uH0%Y8+CB6%YG0B*3Qhlo|}OyfD15dmOs#kbnB zEN#h @!4nz{kU%(ITQ5(9mllJ}_^_YJ RYhnL=hJ(qFQQJE!CR-$fm;{0$19pF!d*AlxNMa{E_ZlqvU>JYEv513M*Y`A5TT zwc@zXp7|qMMqD1-y<0jkM4a|>Nj(1NF@1o>e;wTSi*2){=JwAANd9j1DO_#<#w(HL z)OljHpG%`y>TLf!1_*!-Jiyi}9)awBOYZ*y(EkO%M%~h**BY9h`-*t&=Ms7SpZk8F zw=4S*pXNfgRCf@N7PKGYbNRc~r*OGJ81u;LEO#`YOCvT?Msd7 z7j^yQySoSg`+phn=ihAe71V)EdMrS9wVq4Y5kSnv=PD>~o_pXim97I%O7hM_b-m=f zHwfVTpKWaa+~-$V2O23Bg*O_{wHOhA-0j3%)!S;mbH6P@2X;l`%C$y) Z3x{p0tQMIaha zUCs8-_AfvO_S0Fibe0Fl>8h+v+o>Jn|9%2w@Av+Q>2q)1QHjNsX|X2d+jjJh)A|E} z`sUQBRbv0oVl86@cv(gl^+mLoB h8$;doRYF&fFY@F;HI` z6HzSKO5w=$Ru%z_?}8ZvL{|S=S>){3PU8!TyDrJeUQ(0t $Out(<7VA;R2I&3mx zei;F`5Wx8VoIu%sT>YI}Sa7+FK-dtdm(xEDlKo@8@|Lj4i1}p%+(Ll%aR Q)2 %r7J076KIG#jXujG(Ddi=awpPxgkV=Vp71K_gB=WZOlK^H+Ot1 zgkoZr83ET2fbH)Iiv9Dxf7jH23l1p)u>JP~V*ek~cqOD_VzwCpR}g^hzY`Gqf7KNL zxKKtQ#0bFlcL&7&J3=fbW}Oibj=<8T+JOPFeGPG|R|yAz3uFXBi~ybK5*YhWC=Z5M zRLnXfARGaT6CV`&hwp!jZ~(YKMj*rpMC0i`L9zd+oVqQ 8_K#SF zr-WBp%sC?<7J+Dsa#CPyAA5via>w_?BEY3F0^vmfw!b(?_D{YF;guG1&IpJ@Kx*D` zIBb7caO_`sPaOAL5+e{^1SDzsM MyZx?su@~%pPB F)7NV^+_u#>Z9puZn&2D>&dCUb0|ENBk0q3!MH9*$u=!Vt_2bmn50y4(14gYh zZ3n1r7&La3HV)&0V&!=8E)q{&6b=b8myCct1SlpZ&c(V4Hh*6Gxh0$ zZNL>1*Ho$v*vD}{t5cVmtHKCStjk!F^6iM6zJSiX!}VK{_FkxsxMA)V?IO$pn9U7b zebc%Mi~&-q!YJ_@Mj*Hd(D@q_gM-esfjthvTwO!wI9^?g|0@=k^QMLE+9hM3GHnCA zo)18~_xs>Vm?>ujYHH>z*>Z4wvwA%C+h2ok^x3fOhj2ZMYcH+}^r@xmoYvTZHegkc zHsJE{t39PRCMy$JhZupNA`p{P1Ew!ZzB~1<4sAwL(o@?hw{4lcpk3=fvgaBh@ltNt zR`Vl9!C66-J<}a-1cpi*FF}8~PhV@N`=vKLrHz8W&5R1pr(3l>N=Ivi`Evo+&)MRc z{Y#DN7sIVZ%zY3MkR n`Y_uLia)Ap31DV4~uNjuA017yKMYZ+7%Njwx(vwe(9Uv$P4)o8`}uC zen%3@-K m@cLDl z^RJxWtf;ZT6s!a2_J85XHgEg@`Mj^7sXfyR1cujdI<`M-pX|lUd2&& ;;`jPF^zWHkE z`)Tch_^S18-Ji$)0o4DnlMH{K6&X9hetP5BIq1J bNr}{V!mdlq zfAA2%_&DS5=hgbSSM3VL3i8lb@!{Y}o+%DH0{urU??rt-U9|2u^=|L>$hh4Oc)3?0 zmPnl+c3oorgNMKcBinv*X1{6L@h6<3op8db+8O5#)yC96SXJKvZnrZizk~Ko1W)=* zao7>~=_!MLa!l_dwEXM53$N0q-09hxdBEEn<%+QD5%V8R1di=} w%KiCMI zaBT0veNH%jcfRePeA3C tK5fml3!Hf)9B_;SBr1OvoGy}8On&+IS)T?j;F z^%TUhxEJlf2X3_mk$6%YJM$rJ*u+KJ$mze;rrqsXe6Diawhl?&`H3#D_y;3Ur3lPi zocuy8uHJw)V7th6;5z9K+Nr0Vk+bf2+%dhi%Z5zHcZaXz4Sx`AP9I&R;%2rOfr1D` zThd2je~^TBfX)DL+=q^wexG*iQ9sJDedF9?-%Ez04e(&Uo&z30*R=%!;unlS2oNA& zw@4!S3$y`$vfB>KShQ6;sn3Z8ZC`H(YHwQXxP7rpla2I^ArLRK$Ou?Q0Bc|~ZP<@S zcCFiZu{-%6+9}ulw913*=VMtdIAKT-h$hmf*|EJVMl@Kmf0`SxQ?9FN5z|j n2q$C&LWF?b{(suSjoMk~ z4k+3N9DmGF+K>qg{b2u(%w77zCqpDoW|a}Jg@Cl=*>9r%-*0COKxw5#+q9_*H<@0( z@H>>1yQHP<2iXFH6EgxKL?DXqW4re6 r|FI)6n z03(n=puSl>9`@f6fHq)jq&amSi;WRr1jHgRZ(jQ2(L`!It~Y$v4(y92ln3#x@^g;c zAy(#G8Y7U80Qt>i_72MGa}{m}HpOIRq9iAK< $DNk4ce$# zo3(Lsx26$mU=hXH!gwJ`V}_y925r!|r*iB+Cae9uqn>MG1Q-D)2=pDi>eKyd+m;yZ zzR2$fhYj2CF{(#Oq<;w8|KNtXTebdmt=bh6*W}ng&h`G96M8rgBftoFgTRHuSN4YO z? gAc(dbHm@mQXg%Xi920qcoFd=#2G7`+wdW4!9OZfDv$lK;POG z17X*h^Mx(ge;bVx^m;H?IIeTtzrL$|xPHzvM*F` G`P9xjL}@ebo0G%NQQpXdL_ho6p?NM~?qz1S*dJ*7zPa+HHZ~d-~V54$G&Oe1%~9 zne&IPnYu2={wL1gZnXdV^Yilmi~u80Sp@nHUw&)>dpFwhTm472ooPmCR8Cz^ xhA!Ku z^6F;hoDH_WezE#>voiP@Bftn$27!SCmmUaPeZ7eNZ|Xa0RY8B9SUfd>Y#(O|Xcv#} zT<@o{E*#mG? )Jh_D}2ldO5?fA4q?m zxg(YUultokl;1M~-XnnV|KP0M?(bXM+GzCUEk+IOy2)tc`n><8Xa}&rC&&J2pKr0U z_#Gp_2vi1vfy0)47UOlP-^i7g$LFyo<=e3RoS315#;(#XAHO=Y254*-@cS+D3$6@z zTq`5M2#7`i`}dpkY&@sGH@?GJd_zUo&*d=!j6h`&$etaR)At+g-SB%i;&~0O43%6f zBfto_fdJz3U2m}QLj8VQv^jmW8})OUi~u80IRr45H@1NNBTl#a7{~UioNTyeM!; 7y(9r5nu!u0Y-okU<4QeMt~7u1Q-EEfDvE>7y(AW4+#7}EN->J literal 0 HcmV?d00001 diff --git a/llama.png b/llama.png new file mode 100644 index 0000000000000000000000000000000000000000..5f53924175eded147ce1a6df0f1f77f8b7500d35 GIT binary patch literal 8437 zcmX9k1z1z>*Y8G+Zc!u#BJiU-1qKd-Mv!#0fFO;a iPv R6g&2dMirM1T@lg6=^ )5je{YsC08jYR?_GFDd3i{219uAWM zI;l3rWWR_^AHPMmw;Zc`Irv#5(s41Sh+`KWFs4bk&rSKb48@CdzNt?84C$tH*|$PP zWOOQ9qm>7f;iW>moL&+I=&YRu|AG_|PRX{(70+e0Fe58JKtA9>lHx{SEp7-{A6gX6 z*xw6MS~n0cTet=ee1o)oy^#~65g2Mttf_BMe)o410!G9fr$?y;fYcn`61<(~Sx!qX zIys(8-|zpl3E~vCD()u|1G~}DzQ4=a*Jzq0M=FVS-7d8hOb1+Ma#8&A=2z?Pspb&o zJW#nk@5iBlKYK{ ?ibmW6<%CFjIJ2O`qgL|SqU+@!$kYj}vUvI*PfAx$_ z2|2Cr{P?>W5ct&CE!}58xDL0h1MVd5g+_3)F`~!z+547n8dIh&b57)&;oKq343&>_ ziKc(qK9>0Iu~e*CTz48(G^usq6+~ktj9QX>+Sfc}>Ri4qCuH24+jOzdIG$^YEBR*; z>|lLKxb{oIh2$hm@DWRFfUV8vH0~BQu%!5u4o5?MwcJHf2q0n_TciD6AnH&g__-t9 zLNGJJAtPwp(-eF(+U3B0)IOXiO-uCjn+A`blsmq8NOFDsPF1DP g=+rDOT^N_Kvkd$vK2m#U{F^y>73U@9wes?G* zS W#IiY9+wlFf6(|lv5(a~Y--E$jk^?~& z?g#KI0JzUI==M5nH37$U58D Y0xNs7vZa_E#PK_HHA9rO(&iho7G6?| z7wwc0yPRD)Bb ^t+IGj#&jof_w=efzD;=-qmkOc8w%2Xy zcz)lwAes)RqFVAGyKp2;*Uzl$FzN}4$N9a@ I9L4{l@+nv1DQ i@UOcj|D)M&SkABmx6+;|eP*Q8reZlL5Q z940TGKsIzmnpHmF(0r6oJ-a;g)o-@fwd!uWQ1|f(VXJNX+BpALW8Aje*Gtl#-(J@h z$>sfRuS`FnYoYp>1Frm7H{R>o%V692Mhn=lkm)wO>FEhgud{P=-r2m$S+40nX^4=F zB1ILP+z#-~EBu%f4%;Ned-&atT}=_`V_zAG-dS(V)}+r8yhVfxZF=9e&&M$|I}3+_ zYozv7X`+Y>w}|{HkqVcOIp%8KZ3mnhl`a+b#DRB1>|uwuL{4;A<%x-oFIf*w+FH6^ zoRBZ{Ueu}KKYzQO>kkay6{+KT1Fnj=J$Rs~uNZJ0S48UW={9Ei)s-^n^!HWwT6zC! zt8wzfjpUzdG2bW~tro5@6WmspN66F;+X=egS5AVx-~W&^P<=pRWfQAtx4gbL=DykQ zTk`DS!}Uf6@fTgZ(alx8`B;kS@Zct&&)YR$B^L^$FgQBwZJ1tD?tTFLzT^%wh^Uu) zY2Qv}?qf?xH%jE0&AqHl*Y-+p{8ovA>akX;jL>uZfH=1O#g52eYlT%nlK+E26_p6Y zd u9p*?(ZVA3u z3|e_FtIfilCA_J-wCoV mv{POEaNg!k&t3bM zmB_^B?6ZUN#rH{B%h7Qvxb--`v3hqW$9jBgcKnMdg;8?xP|j Iu3+V9MfBqzc%%cQd$}cRyeo2dT zUD5Yhu#no(N?V1)vtT3_jmJavO!U;;V{DwO%iiL*KSYD9WJW^HQyyzi5EEHajSF2N zVZ%S)06DMyQ|9meLFN!Uf^M@8YH+SAl;Gj0zG>(csS+9z71^MIqYJOD c80kxEKy##Wqqoq9v*r) zxwB1*pOuEyCpyUopd<+!UB~Q3`T~+8u|(vs>dU~;beTu9D=27swbEs* zC>~5;_WJK(RK(=29YadiaGsG&I$&F9BR~;-L}Ibw>v5VxVFsPDuCo&-{Jq*v}Ei z)D?f!Gb)6nqp=q)jdc^s#B{^XVLA%Y7|sHc-I>tj?LRgqcQ0JMp|Cmj5u<;UUU idcxKsCO)?6Ql5hgu;`(OVS zdr)!7M(gh$mb(;Q@mN&S;OzO>ZBy~UFZNFaus=Zn2Ug#Lqbl%#L#ohxXr4Vfkdy{B z^56Mn?roIqQMvqlBZndSUM4(KF`s4RBNO2TvKA2)Ieibg S*OA zc4HybjhXqw?kC2$HMf1w>*5z)N4sO^Lyq)W(=U{P!t8a;f|rBGqBnO2kO4%f=M}d) zHk2I4D%sh4dN7k8Gyixn?TOy>L ^>e@s%rt90K;0i=^` zt%Wx=+B%v&EYhypI7{!fi ;!a zZUdQ7Pm^s5v)2mhFk;!vbN96Rk=I-!n8~x!{o&gjwqh>pGM*_)vMoWVn7iW#TD(X& z0T3IhluY_YfUzlc@nv4VY+p;Nf5=S_!R=q3!LYZrhT?_c$1>3H@V4kCQ)MSH03|Jf z+0LEn3NF;rm{9)5@f28(KtT&Wq*Q*AnN_fx8eKeN9?1Nm5gyV |=g=Zx|Kb*P&&LR 16poEr!;bb z+Cy=aZ5r_Zk<%HBt}&{3wo0&|p7MLbP~sHr2`Z)8^GQ}SshuQPBAWZOK#MO$M3t$i zlLWjF0x4qs-qcy{kQGd>8NyI_1VI|t0_lXM7`qH!HZ-;Bq==|7WkbkcY0N=xtA+HS zH+_~Egbyeta%%?zDwkQ&){srbxV&MY-85U1?!|weuuQoy7z$G0YCI*dr |yx_k9f%Nz{i!`6#vy{Pv@!zmEW$lutqN3`t0cmvGhTy`sHs*ntWWzyU+SzPg zjKq)3M8kqaTqKHrpzjh)z4iBP$=}UgW!=}2PRm8wZEL{MMvYOtLN4qQooolHuCUzc zkq)n!PKKv}qfwBIa8$+%i?NcqB4vMbv;F@KfpN-D9=vI2Z*5K<^?AHha%iO?7s|9v z*MAw_XGKTbAH~%w#nc)KScx;eFE|alQY*kPXun0LOft(zCnHYq!^*Tver-4S5?s=u z=Mut@$AO=$Y_MKC=d=nL(1kr%F$(S0%D?oJos}#m{3f4c%Qo5@e)*>AWl}3TZ1c$l z8<`v+vVwCy4% wvar}On!UYIm#|dkRr1lML(u2tbH@_W zG_(dS3E>r>C0Rm)oAl`<_s;ry@fsu+?VpnJs>=e_s@8c4>d*eW2kR@D-lsHKPSzfp zc}{y}7=>?~GHN9+5g_rnvX5C1k~X(!b_YSxs=9*V=^`1XjOl!u4iH&Mw(G`2V!sw| zcJA_()MeSerzJM_J-4$XJ#K!2XIfjY8}bTCV8Rhfq}zg2r#6I+(?h$l-V~ACDsE|7 z>|Ks3;RmW#wb+y-D$Wnext4O9DpSv RRn*O7n+wjPWD9m zPNhj<^hBV9ZtW+cOTU8d_FZ{Bu}A%+qHMVG=Jp7Mdc3PO#gG$ j@QRzg-%8%<4+kJz?{9v2it>$40#!Le69u;tQ!V0P&1`Fd$Dxt*jU0YZOs zhXnj9QaD0Ii?j2k-Za 38rLS} b-DGY z+Hb92uQ386ea@qG(dUsKUS};e7jp;J5Nw<3_QI5a+AEaz)W1ren?V-c9iCNzhNFll zGt1|@4|cHeM|vhM?4A_Jmg&-$BwkGmL(SZbfc#CIDvF+>U@1q_Wi0vn3B-#@^DZqa ztnF~-+HXS@8m3gR D ze~AZlExMoHv1ebv@FN>pQkL`g<$qCtKNzSS{c4YKdMjqT-vQ2F? }~6ECir3MFfR@w~^pPiZv1VN{3G`@a0+b6kT}* z5xGfVp5w}6UcK?506KZ+%^dKlcV|VD-4szqfIq;gmYDugPg;|ncw=N)la!Lv-k;I3 zaS{G|FjT=OGGzCMZ$wtm_8{M;v}^(+9}qou9SW A9y|QW3-&F`x -{oIGX3kd z W{Aahb2 z6N3Blu>-_N`K-J@I7v)=->c>2zGm=T!sv$ZR0PxnDNd6hr40Ajpe4$^`Dhnrg7d-? zo>{(-WkqmKoS8@9?V-9c_h~@*$Y`#s|C?%m&ZoJF24UeV=&dOcs%vlV12*^3`&Daj zcUr7}O6sKlQt$cMJ@y;5KYZ4U20H^0I$!F)76#4K-$yF Xnld16#0ai`zWMo$Eff6W2O8^^>GX}d`nOhiFTM&e^15v3q zZX_i?AOo7=K9SKOPyGqZIL8lEPZSyZ5<5wOpx-#e%?uH<3!dAIE1a<+N|mu&J(G4G z(}SO)9)ma+XPcmZwNRq}Wb+pFKN5g4Z<2<>C2EZY?CF0Z$_!{ dV7C%&UW;QhOUu0! z0aP)v+#fBaWjilL5Cg2Z F 9IX=c|LDTw4qpV5f4xp@qH&Y@NdlGsG+*d(6h|d1PFE6t8_2gUIRpU zS$;m@$Dd%5KKt5B`f_Z-w%~*KKgYuYrvBZOr~EYsDkaf)VEh}N@Q jLfgGuYH2!R&i!LaPr2L7Ui)Y CU{pNXn+%~!(5NsPB!-+`c#m}upBLJC zOdL9|k#5p_iv@G0Kx|v)AKodx*6I(H|Ki))Q58n_56fm0cvdkE!X=KYDo=Xf1+j^# zp4vtHM-od)G~xmGzoJJ9!`y3fpA3F}=#agN$a~YhTm5j`_Qb7i9}_q+J_HnoLbD~I zr$BOU8`{y{ce~(03w{$32Rd~mu`H<5_dmA0Y#l~3Qbcg7UF2KbpptCw8|@uXoo(Z% zeFh1!M;s-F(FF3u^@Smyfzo24To<3w=zj$DC%4eSq&o)>+*q0$-~ghCvLLSh;mr(- zgSrROg0x*w;CnE~iA&vgZ~YF2pSeT*Nhh@7HpI{lAr82+tg@&wRe(E0CL}Eyqz&5B zRmYc-3;wO+^a}Hy{@Zv${oa*KtaCp5I>>6#KedT^rJoX-zYdh)B}sT^=~<=9C6@`~ zCYJJzfMWV$IDw?7@_U>CHaErOp<7XLK8D09D?B`pY@0)jnpo^I$~1lb3AQ95#Yb#8 z?PT-8m}=|0%;lRU$8obJ=F0Pf(W)HXo GBGp5dso{i5kJM4TD5kK$}1F)ER6 zixunibJW4icX$YDAN#^`^uy|WEzhyB6Rwg=p;%9{cU|Re#m`2Av uv!(!9v?((-U>39=X@ArHm+8E?ZczcF&75;s}rsSZ3ZARrlPn1@^(-0R4`@f z7Jli9$~XmnVDH2>FAGIhNsMK|89nV6sSAz~pyA@S_sxL)^3>R)h1YS5Az{ kN0n)1L!g`?{FNU5pHXqi^&t4;T*Bck&>%ktIU*EnYx^@bPx1oQZ>jO_)Q0SA({P zoUg;iU0P(z rgF=A&t%$Waz`q%rM~}%vZJqQ zc!{aadis2ID6ee>H$91oikN3YenFJC50GBrT+i4SywMugP$Ye;p9m*J^2N78wZ+VM zj1gnQJLCtt&HRA=6ODnw4EzL{o(4F?W=^cY-07!BcPQ(*Vbk>7Ht@hXBY&;k5ObGT z8#Tj3!{X4c**8!4=N<4A*StFJ)b;$ludot*Ak^kcf|?eE!5P4$O|{QNx}=65P_{BE zA_mvk5-NGXjn<8xiJ|S3+0o^&rF0_Chg7JLo4;%mz}9Pj178*DZ%1}qxJT<6w5S9M zbt_GX{TpZ97jHJ2blC UOFg=NYf49E@u8 zBRL^c+{*M;K2bz-6bSRzehWieP}9k1zqj7>&SU!t#Z7el58F+xf|{l=XUYg$q`STa zwb-O@9+M?xb8TpOx@YTF-8|qh@XGch)n5t#*9177zpJpYmGC^Ii9qVl4Xv5ubcjzB zHlQ7-?R_)6{8tf5*gh%%IVYX46iAD;1AV@%M`>`mE9_tLWgvyRW|{E%F~ry@Zwq6X z%*$m807aaeDE-c&|`KA z;Y;^7P=a<#OcB~Hw7Pug F9hAd&| o7ok*mocb7y2SW1gDdww zoLCDW%=RkDN*;EXk9_d=D 7iM-y2pi+O)ezsOJCn7 zz9F;mb|k~k z2hz~L?50x@3>|1;A`UpAu49}uS1`kD$F|Ao?`vXgIIq#jyH_5 ;I(b@o$SMo`Yj!fR46gKxk1+J%gDx;nL5O~x5%CrWjXb7 zQqG6x(lrFfVi*KVi(rS8NE>+5o4e*ssd|B{yo8FH 🦙 ", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ], + "categories": ["productivity", "utilities"] +} diff --git a/marked.esm.js b/marked.esm.js new file mode 100644 index 0000000..d07059e --- /dev/null +++ b/marked.esm.js @@ -0,0 +1,2580 @@ +/** + * marked v15.0.7 - a markdown parser + * Copyright (c) 2011-2025, Christopher Jeffrey. (MIT Licensed) + * https://github.com/markedjs/marked + */ + +/** + * DO NOT EDIT THIS FILE + * The code in this file is generated from files in ./src/ + */ + +/** + * Gets the original marked default options. + */ +function _getDefaults() { + return { + async: false, + breaks: false, + extensions: null, + gfm: true, + hooks: null, + pedantic: false, + renderer: null, + silent: false, + tokenizer: null, + walkTokens: null, + }; +} +let _defaults = _getDefaults(); +function changeDefaults(newDefaults) { + _defaults = newDefaults; +} + +const noopTest = { exec: () => null }; +function edit(regex, opt = '') { + let source = typeof regex === 'string' ? regex : regex.source; + const obj = { + replace: (name, val) => { + let valSource = typeof val === 'string' ? val : val.source; + valSource = valSource.replace(other.caret, '$1'); + source = source.replace(name, valSource); + return obj; + }, + getRegex: () => { + return new RegExp(source, opt); + }, + }; + return obj; +} +const other = { + codeRemoveIndent: /^(?: {1,4}| {0,3}\t)/gm, + outputLinkReplace: /\\([\[\]])/g, + indentCodeCompensation: /^(\s+)(?:```)/, + beginningSpace: /^\s+/, + endingHash: /#$/, + startingSpaceChar: /^ /, + endingSpaceChar: / $/, + nonSpaceChar: /[^ ]/, + newLineCharGlobal: /\n/g, + tabCharGlobal: /\t/g, + multipleSpaceGlobal: /\s+/g, + blankLine: /^[ \t]*$/, + doubleBlankLine: /\n[ \t]*\n[ \t]*$/, + blockquoteStart: /^ {0,3}>/, + blockquoteSetextReplace: /\n {0,3}((?:=+|-+) *)(?=\n|$)/g, + blockquoteSetextReplace2: /^ {0,3}>[ \t]?/gm, + listReplaceTabs: /^\t+/, + listReplaceNesting: /^ {1,4}(?=( {4})*[^ ])/g, + listIsTask: /^\[[ xX]\] /, + listReplaceTask: /^\[[ xX]\] +/, + anyLine: /\n.*\n/, + hrefBrackets: /^<(.*)>$/, + tableDelimiter: /[:|]/, + tableAlignChars: /^\||\| *$/g, + tableRowBlankLine: /\n[ \t]*$/, + tableAlignRight: /^ *-+: *$/, + tableAlignCenter: /^ *:-+: *$/, + tableAlignLeft: /^ *:-+ *$/, + startATag: /^/i, + startPreScriptTag: /^<(pre|code|kbd|script)(\s|>)/i, + endPreScriptTag: /^<\/(pre|code|kbd|script)(\s|>)/i, + startAngleBracket: /^, + endAngleBracket: />$/, + pedanticHrefTitle: /^([^'"]*[^\s])\s+(['"])(.*)\2/, + unicodeAlphaNumeric: /[\p{L}\p{N}]/u, + escapeTest: /[&<>"']/, + escapeReplace: /[&<>"']/g, + escapeTestNoEncode: /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/, + escapeReplaceNoEncode: /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/g, + unescapeTest: /&(#(?:\d+)|(?:#x[0-9A-Fa-f]+)|(?:\w+));?/ig, + caret: /(^|[^\[])\^/g, + percentDecode: /%25/g, + findPipe: /\|/g, + splitPipe: / \|/, + slashPipe: /\\\|/g, + carriageReturn: /\r\n|\r/g, + spaceLine: /^ +$/gm, + notSpaceStart: /^\S*/, + endingNewline: /\n$/, + listItemRegex: (bull) => new RegExp(`^( {0,3}${bull})((?:[\t ][^\\n]*)?(?:\\n|$))`), + nextBulletRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(?:\\n|$))`), + hrRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}((?:- *){3,}|(?:_ *){3,}|(?:\\* *){3,})(?:\\n+|$)`), + fencesBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}(?:\`\`\`|~~~)`), + headingBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}#`), + htmlBeginRegex: (indent) => new RegExp(`^ {0,${Math.min(3, indent - 1)}}<(?:[a-z].*>|!--)`, 'i'), +}; +/** + * Block-Level Grammar + */ +const newline = /^(?:[ \t]*(?:\n|$))+/; +const blockCode = /^((?: {4}| {0,3}\t)[^\n]+(?:\n(?:[ \t]*(?:\n|$))*)?)+/; +const fences = /^ {0,3}(`{3,}(?=[^`\n]*(?:\n|$))|~{3,})([^\n]*)(?:\n|$)(?:|([\s\S]*?)(?:\n|$))(?: {0,3}\1[~`]* *(?=\n|$)|$)/; +const hr = /^ {0,3}((?:-[\t ]*){3,}|(?:_[ \t]*){3,}|(?:\*[ \t]*){3,})(?:\n+|$)/; +const heading = /^ {0,3}(#{1,6})(?=\s|$)(.*)(?:\n+|$)/; +const bullet = /(?:[*+-]|\d{1,9}[.)])/; +const lheadingCore = /^(?!bull |blockCode|fences|blockquote|heading|html|table)((?:.|\n(?!\s*?\n|bull |blockCode|fences|blockquote|heading|html|table))+?)\n {0,3}(=+|-+) *(?:\n+|$)/; +const lheading = edit(lheadingCore) + .replace(/bull/g, bullet) // lists can interrupt + .replace(/blockCode/g, /(?: {4}| {0,3}\t)/) // indented code blocks can interrupt + .replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/) // fenced code blocks can interrupt + .replace(/blockquote/g, / {0,3}>/) // blockquote can interrupt + .replace(/heading/g, / {0,3}#{1,6}/) // ATX heading can interrupt + .replace(/html/g, / {0,3}<[^\n>]+>\n/) // block html can interrupt + .replace(/\|table/g, '') // table not in commonmark + .getRegex(); +const lheadingGfm = edit(lheadingCore) + .replace(/bull/g, bullet) // lists can interrupt + .replace(/blockCode/g, /(?: {4}| {0,3}\t)/) // indented code blocks can interrupt + .replace(/fences/g, / {0,3}(?:`{3,}|~{3,})/) // fenced code blocks can interrupt + .replace(/blockquote/g, / {0,3}>/) // blockquote can interrupt + .replace(/heading/g, / {0,3}#{1,6}/) // ATX heading can interrupt + .replace(/html/g, / {0,3}<[^\n>]+>\n/) // block html can interrupt + .replace(/table/g, / {0,3}\|?(?:[:\- ]*\|)+[\:\- ]*\n/) // table can interrupt + .getRegex(); +const _paragraph = /^([^\n]+(?:\n(?!hr|heading|lheading|blockquote|fences|list|html|table| +\n)[^\n]+)*)/; +const blockText = /^[^\n]+/; +const _blockLabel = /(?!\s*\])(?:\\.|[^\[\]\\])+/; +const def = edit(/^ {0,3}\[(label)\]: *(?:\n[ \t]*)?([^<\s][^\s]*|<.*?>)(?:(?: +(?:\n[ \t]*)?| *\n[ \t]*)(title))? *(?:\n+|$)/) + .replace('label', _blockLabel) + .replace('title', /(?:"(?:\\"?|[^"\\])*"|'[^'\n]*(?:\n[^'\n]+)*\n?'|\([^()]*\))/) + .getRegex(); +const list = edit(/^( {0,3}bull)([ \t][^\n]+?)?(?:\n|$)/) + .replace(/bull/g, bullet) + .getRegex(); +const _tag = 'address|article|aside|base|basefont|blockquote|body|caption' + + '|center|col|colgroup|dd|details|dialog|dir|div|dl|dt|fieldset|figcaption' + + '|figure|footer|form|frame|frameset|h[1-6]|head|header|hr|html|iframe' + + '|legend|li|link|main|menu|menuitem|meta|nav|noframes|ol|optgroup|option' + + '|p|param|search|section|summary|table|tbody|td|tfoot|th|thead|title' + + '|tr|track|ul'; +const _comment = /|$))/; +const html = edit('^ {0,3}(?:' // optional indentation + + '<(script|pre|style|textarea)[\\s>][\\s\\S]*?(?:\\1>[^\\n]*\\n+|$)' // (1) + + '|comment[^\\n]*(\\n+|$)' // (2) + + '|<\\?[\\s\\S]*?(?:\\?>\\n*|$)' // (3) + + '|\\n*|$)' // (4) + + '|\\n*|$)' // (5) + + '|?(tag)(?: +|\\n|/?>)[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (6) + + '|<(?!script|pre|style|textarea)([a-z][\\w-]*)(?:attribute)*? */?>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (7) open tag + + '|(?!script|pre|style|textarea)[a-z][\\w-]*\\s*>(?=[ \\t]*(?:\\n|$))[\\s\\S]*?(?:(?:\\n[ \t]*)+\\n|$)' // (7) closing tag + + ')', 'i') + .replace('comment', _comment) + .replace('tag', _tag) + .replace('attribute', / +[a-zA-Z:_][\w.:-]*(?: *= *"[^"\n]*"| *= *'[^'\n]*'| *= *[^\s"'=<>`]+)?/) + .getRegex(); +const paragraph = edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs + .replace('|table', '') + .replace('blockquote', ' {0,3}>') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', '?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // pars can be interrupted by type (6) html blocks + .getRegex(); +const blockquote = edit(/^( {0,3}> ?(paragraph|[^\n]*)(?:\n|$))+/) + .replace('paragraph', paragraph) + .getRegex(); +/** + * Normal Block Grammar + */ +const blockNormal = { + blockquote, + code: blockCode, + def, + fences, + heading, + hr, + html, + lheading, + list, + newline, + paragraph, + table: noopTest, + text: blockText, +}; +/** + * GFM Block Grammar + */ +const gfmTable = edit('^ *([^\\n ].*)\\n' // Header + + ' {0,3}((?:\\| *)?:?-+:? *(?:\\| *:?-+:? *)*(?:\\| *)?)' // Align + + '(?:\\n((?:(?! *\\n|hr|heading|blockquote|code|fences|list|html).*(?:\\n|$))*)\\n*|$)') // Cells + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('blockquote', ' {0,3}>') + .replace('code', '(?: {4}| {0,3}\t)[^\\n]') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', '?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // tables can be interrupted by type (6) html blocks + .getRegex(); +const blockGfm = { + ...blockNormal, + lheading: lheadingGfm, + table: gfmTable, + paragraph: edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' {0,3}#{1,6}(?:\\s|$)') + .replace('|lheading', '') // setext headings don't interrupt commonmark paragraphs + .replace('table', gfmTable) // interrupt paragraphs with table + .replace('blockquote', ' {0,3}>') + .replace('fences', ' {0,3}(?:`{3,}(?=[^`\\n]*\\n)|~{3,})[^\\n]*\\n') + .replace('list', ' {0,3}(?:[*+-]|1[.)]) ') // only lists starting from 1 can interrupt + .replace('html', '?(?:tag)(?: +|\\n|/?>)|<(?:script|pre|style|textarea|!--)') + .replace('tag', _tag) // pars can be interrupted by type (6) html blocks + .getRegex(), +}; +/** + * Pedantic grammar (original John Gruber's loose markdown specification) + */ +const blockPedantic = { + ...blockNormal, + html: edit('^ *(?:comment *(?:\\n|\\s*$)' + + '|<(tag)[\\s\\S]+?\\1> *(?:\\n{2,}|\\s*$)' // closed tag + + '|\\s]*)*?/?> *(?:\\n{2,}|\\s*$))') + .replace('comment', _comment) + .replace(/tag/g, '(?!(?:' + + 'a|em|strong|small|s|cite|q|dfn|abbr|data|time|code|var|samp|kbd|sub' + + '|sup|i|b|u|mark|ruby|rt|rp|bdi|bdo|span|br|wbr|ins|del|img)' + + '\\b)\\w+(?!:|[^\\w\\s@]*@)\\b') + .getRegex(), + def: /^ *\[([^\]]+)\]: *([^\s>]+)>?(?: +(["(][^\n]+[")]))? *(?:\n+|$)/, + heading: /^(#{1,6})(.*)(?:\n+|$)/, + fences: noopTest, // fences not supported + lheading: /^(.+?)\n {0,3}(=+|-+) *(?:\n+|$)/, + paragraph: edit(_paragraph) + .replace('hr', hr) + .replace('heading', ' *#{1,6} *[^\n]') + .replace('lheading', lheading) + .replace('|table', '') + .replace('blockquote', ' {0,3}>') + .replace('|fences', '') + .replace('|list', '') + .replace('|html', '') + .replace('|tag', '') + .getRegex(), +}; +/** + * Inline-Level Grammar + */ +const escape$1 = /^\\([!"#$%&'()*+,\-./:;<=>?@\[\]\\^_`{|}~])/; +const inlineCode = /^(`+)([^`]|[^`][\s\S]*?[^`])\1(?!`)/; +const br = /^( {2,}|\\)\n(?!\s*$)/; +const inlineText = /^(`+|[^`])(?:(?= {2,}\n)|[\s\S]*?(?:(?=[\\ +const blockSkip = /\[[^[\]]*?\]\((?:\\.|[^\\\(\)]|\((?:\\.|[^\\\(\)])*\))*\)|`[^`]*?`|<[^<>]*?>/g; +const emStrongLDelimCore = /^(?:\*+(?:((?!\*)punct)|[^\s*]))|^_+(?:((?!_)punct)|([^\s_]))/; +const emStrongLDelim = edit(emStrongLDelimCore, 'u') + .replace(/punct/g, _punctuation) + .getRegex(); +const emStrongLDelimGfm = edit(emStrongLDelimCore, 'u') + .replace(/punct/g, _punctuationGfmStrongEm) + .getRegex(); +const emStrongRDelimAstCore = '^[^_*]*?__[^_*]*?\\*[^_*]*?(?=__)' // Skip orphan inside strong + + '|[^*]+(?=[^*])' // Consume to delim + + '|(?!\\*)punct(\\*+)(?=[\\s]|$)' // (1) #*** can only be a Right Delimiter + + '|notPunctSpace(\\*+)(?!\\*)(?=punctSpace|$)' // (2) a***#, a*** can only be a Right Delimiter + + '|(?!\\*)punctSpace(\\*+)(?=notPunctSpace)' // (3) #***a, ***a can only be Left Delimiter + + '|[\\s](\\*+)(?!\\*)(?=punct)' // (4) ***# can only be Left Delimiter + + '|(?!\\*)punct(\\*+)(?!\\*)(?=punct)' // (5) #***# can be either Left or Right Delimiter + + '|notPunctSpace(\\*+)(?=notPunctSpace)'; // (6) a***a can be either Left or Right Delimiter +const emStrongRDelimAst = edit(emStrongRDelimAstCore, 'gu') + .replace(/notPunctSpace/g, _notPunctuationOrSpace) + .replace(/punctSpace/g, _punctuationOrSpace) + .replace(/punct/g, _punctuation) + .getRegex(); +const emStrongRDelimAstGfm = edit(emStrongRDelimAstCore, 'gu') + .replace(/notPunctSpace/g, _notPunctuationOrSpaceGfmStrongEm) + .replace(/punctSpace/g, _punctuationOrSpaceGfmStrongEm) + .replace(/punct/g, _punctuationGfmStrongEm) + .getRegex(); +// (6) Not allowed for _ +const emStrongRDelimUnd = edit('^[^_*]*?\\*\\*[^_*]*?_[^_*]*?(?=\\*\\*)' // Skip orphan inside strong + + '|[^_]+(?=[^_])' // Consume to delim + + '|(?!_)punct(_+)(?=[\\s]|$)' // (1) #___ can only be a Right Delimiter + + '|notPunctSpace(_+)(?!_)(?=punctSpace|$)' // (2) a___#, a___ can only be a Right Delimiter + + '|(?!_)punctSpace(_+)(?=notPunctSpace)' // (3) #___a, ___a can only be Left Delimiter + + '|[\\s](_+)(?!_)(?=punct)' // (4) ___# can only be Left Delimiter + + '|(?!_)punct(_+)(?!_)(?=punct)', 'gu') // (5) #___# can be either Left or Right Delimiter + .replace(/notPunctSpace/g, _notPunctuationOrSpace) + .replace(/punctSpace/g, _punctuationOrSpace) + .replace(/punct/g, _punctuation) + .getRegex(); +const anyPunctuation = edit(/\\(punct)/, 'gu') + .replace(/punct/g, _punctuation) + .getRegex(); +const autolink = edit(/^<(scheme:[^\s\x00-\x1f<>]*|email)>/) + .replace('scheme', /[a-zA-Z][a-zA-Z0-9+.-]{1,31}/) + .replace('email', /[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+(@)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(?![-_])/) + .getRegex(); +const _inlineComment = edit(_comment).replace('(?:-->|$)', '-->').getRegex(); +const tag = edit('^comment' + + '|^[a-zA-Z][\\w:-]*\\s*>' // self-closing tag + + '|^<[a-zA-Z][\\w-]*(?:attribute)*?\\s*/?>' // open tag + + '|^<\\?[\\s\\S]*?\\?>' // processing instruction, e.g. + + '|^' // declaration, e.g. + + '|^') // CDATA section + .replace('comment', _inlineComment) + .replace('attribute', /\s+[a-zA-Z:_][\w.:-]*(?:\s*=\s*"[^"]*"|\s*=\s*'[^']*'|\s*=\s*[^\s"'=<>`]+)?/) + .getRegex(); +const _inlineLabel = /(?:\[(?:\\.|[^\[\]\\])*\]|\\.|`[^`]*`|[^\[\]\\`])*?/; +const link = edit(/^!?\[(label)\]\(\s*(href)(?:\s+(title))?\s*\)/) + .replace('label', _inlineLabel) + .replace('href', /<(?:\\.|[^\n<>\\])+>|[^\s\x00-\x1f]*/) + .replace('title', /"(?:\\"?|[^"\\])*"|'(?:\\'?|[^'\\])*'|\((?:\\\)?|[^)\\])*\)/) + .getRegex(); +const reflink = edit(/^!?\[(label)\]\[(ref)\]/) + .replace('label', _inlineLabel) + .replace('ref', _blockLabel) + .getRegex(); +const nolink = edit(/^!?\[(ref)\](?:\[\])?/) + .replace('ref', _blockLabel) + .getRegex(); +const reflinkSearch = edit('reflink|nolink(?!\\()', 'g') + .replace('reflink', reflink) + .replace('nolink', nolink) + .getRegex(); +/** + * Normal Inline Grammar + */ +const inlineNormal = { + _backpedal: noopTest, // only used for GFM url + anyPunctuation, + autolink, + blockSkip, + br, + code: inlineCode, + del: noopTest, + emStrongLDelim, + emStrongRDelimAst, + emStrongRDelimUnd, + escape: escape$1, + link, + nolink, + punctuation, + reflink, + reflinkSearch, + tag, + text: inlineText, + url: noopTest, +}; +/** + * Pedantic Inline Grammar + */ +const inlinePedantic = { + ...inlineNormal, + link: edit(/^!?\[(label)\]\((.*?)\)/) + .replace('label', _inlineLabel) + .getRegex(), + reflink: edit(/^!?\[(label)\]\s*\[([^\]]*)\]/) + .replace('label', _inlineLabel) + .getRegex(), +}; +/** + * GFM Inline Grammar + */ +const inlineGfm = { + ...inlineNormal, + emStrongRDelimAst: emStrongRDelimAstGfm, + emStrongLDelim: emStrongLDelimGfm, + url: edit(/^((?:ftp|https?):\/\/|www\.)(?:[a-zA-Z0-9\-]+\.?)+[^\s<]*|^email/, 'i') + .replace('email', /[A-Za-z0-9._+-]+(@)[a-zA-Z0-9-_]+(?:\.[a-zA-Z0-9-_]*[a-zA-Z0-9])+(?![-_])/) + .getRegex(), + _backpedal: /(?:[^?!.,:;*_'"~()&]+|\([^)]*\)|&(?![a-zA-Z0-9]+;$)|[?!.,:;*_'"~)]+(?!$))+/, + del: /^(~~?)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/, + text: /^([`~]+|[^`~])(?:(?= {2,}\n)|(?=[a-zA-Z0-9.!#$%&'*+\/=?_`{\|}~-]+@)|[\s\S]*?(?:(?=[\\': '>', + '"': '"', + "'": ''', +}; +const getEscapeReplacement = (ch) => escapeReplacements[ch]; +function escape(html, encode) { + if (encode) { + if (other.escapeTest.test(html)) { + return html.replace(other.escapeReplace, getEscapeReplacement); + } + } + else { + if (other.escapeTestNoEncode.test(html)) { + return html.replace(other.escapeReplaceNoEncode, getEscapeReplacement); + } + } + return html; +} +function cleanUrl(href) { + try { + href = encodeURI(href).replace(other.percentDecode, '%'); + } + catch { + return null; + } + return href; +} +function splitCells(tableRow, count) { + // ensure that every cell-delimiting pipe has a space + // before it to distinguish it from an escaped pipe + const row = tableRow.replace(other.findPipe, (match, offset, str) => { + let escaped = false; + let curr = offset; + while (--curr >= 0 && str[curr] === '\\') + escaped = !escaped; + if (escaped) { + // odd number of slashes means | is escaped + // so we leave it alone + return '|'; + } + else { + // add space before unescaped | + return ' |'; + } + }), cells = row.split(other.splitPipe); + let i = 0; + // First/last cell in a row cannot be empty if it has no leading/trailing pipe + if (!cells[0].trim()) { + cells.shift(); + } + if (cells.length > 0 && !cells.at(-1)?.trim()) { + cells.pop(); + } + if (count) { + if (cells.length > count) { + cells.splice(count); + } + else { + while (cells.length < count) + cells.push(''); + } + } + for (; i < cells.length; i++) { + // leading or trailing whitespace is ignored per the gfm spec + cells[i] = cells[i].trim().replace(other.slashPipe, '|'); + } + return cells; +} +/** + * Remove trailing 'c's. Equivalent to str.replace(/c*$/, ''). + * /c*$/ is vulnerable to REDOS. + * + * @param str + * @param c + * @param invert Remove suffix of non-c chars instead. Default falsey. + */ +function rtrim(str, c, invert) { + const l = str.length; + if (l === 0) { + return ''; + } + // Length of suffix matching the invert condition. + let suffLen = 0; + // Step left until we fail to match the invert condition. + while (suffLen < l) { + const currChar = str.charAt(l - suffLen - 1); + if (currChar === c && true) { + suffLen++; + } + else { + break; + } + } + return str.slice(0, l - suffLen); +} +function findClosingBracket(str, b) { + if (str.indexOf(b[1]) === -1) { + return -1; + } + let level = 0; + for (let i = 0; i < str.length; i++) { + if (str[i] === '\\') { + i++; + } + else if (str[i] === b[0]) { + level++; + } + else if (str[i] === b[1]) { + level--; + if (level < 0) { + return i; + } + } + } + return -1; +} + +function outputLink(cap, link, raw, lexer, rules) { + const href = link.href; + const title = link.title || null; + const text = cap[1].replace(rules.other.outputLinkReplace, '$1'); + if (cap[0].charAt(0) !== '!') { + lexer.state.inLink = true; + const token = { + type: 'link', + raw, + href, + title, + text, + tokens: lexer.inlineTokens(text), + }; + lexer.state.inLink = false; + return token; + } + return { + type: 'image', + raw, + href, + title, + text, + }; +} +function indentCodeCompensation(raw, text, rules) { + const matchIndentToCode = raw.match(rules.other.indentCodeCompensation); + if (matchIndentToCode === null) { + return text; + } + const indentToCode = matchIndentToCode[1]; + return text + .split('\n') + .map(node => { + const matchIndentInNode = node.match(rules.other.beginningSpace); + if (matchIndentInNode === null) { + return node; + } + const [indentInNode] = matchIndentInNode; + if (indentInNode.length >= indentToCode.length) { + return node.slice(indentToCode.length); + } + return node; + }) + .join('\n'); +} +/** + * Tokenizer + */ +class _Tokenizer { + options; + rules; // set by the lexer + lexer; // set by the lexer + constructor(options) { + this.options = options || _defaults; + } + space(src) { + const cap = this.rules.block.newline.exec(src); + if (cap && cap[0].length > 0) { + return { + type: 'space', + raw: cap[0], + }; + } + } + code(src) { + const cap = this.rules.block.code.exec(src); + if (cap) { + const text = cap[0].replace(this.rules.other.codeRemoveIndent, ''); + return { + type: 'code', + raw: cap[0], + codeBlockStyle: 'indented', + text: !this.options.pedantic + ? rtrim(text, '\n') + : text, + }; + } + } + fences(src) { + const cap = this.rules.block.fences.exec(src); + if (cap) { + const raw = cap[0]; + const text = indentCodeCompensation(raw, cap[3] || '', this.rules); + return { + type: 'code', + raw, + lang: cap[2] ? cap[2].trim().replace(this.rules.inline.anyPunctuation, '$1') : cap[2], + text, + }; + } + } + heading(src) { + const cap = this.rules.block.heading.exec(src); + if (cap) { + let text = cap[2].trim(); + // remove trailing #s + if (this.rules.other.endingHash.test(text)) { + const trimmed = rtrim(text, '#'); + if (this.options.pedantic) { + text = trimmed.trim(); + } + else if (!trimmed || this.rules.other.endingSpaceChar.test(trimmed)) { + // CommonMark requires space before trailing #s + text = trimmed.trim(); + } + } + return { + type: 'heading', + raw: cap[0], + depth: cap[1].length, + text, + tokens: this.lexer.inline(text), + }; + } + } + hr(src) { + const cap = this.rules.block.hr.exec(src); + if (cap) { + return { + type: 'hr', + raw: rtrim(cap[0], '\n'), + }; + } + } + blockquote(src) { + const cap = this.rules.block.blockquote.exec(src); + if (cap) { + let lines = rtrim(cap[0], '\n').split('\n'); + let raw = ''; + let text = ''; + const tokens = []; + while (lines.length > 0) { + let inBlockquote = false; + const currentLines = []; + let i; + for (i = 0; i < lines.length; i++) { + // get lines up to a continuation + if (this.rules.other.blockquoteStart.test(lines[i])) { + currentLines.push(lines[i]); + inBlockquote = true; + } + else if (!inBlockquote) { + currentLines.push(lines[i]); + } + else { + break; + } + } + lines = lines.slice(i); + const currentRaw = currentLines.join('\n'); + const currentText = currentRaw + // precede setext continuation with 4 spaces so it isn't a setext + .replace(this.rules.other.blockquoteSetextReplace, '\n $1') + .replace(this.rules.other.blockquoteSetextReplace2, ''); + raw = raw ? `${raw}\n${currentRaw}` : currentRaw; + text = text ? `${text}\n${currentText}` : currentText; + // parse blockquote lines as top level tokens + // merge paragraphs if this is a continuation + const top = this.lexer.state.top; + this.lexer.state.top = true; + this.lexer.blockTokens(currentText, tokens, true); + this.lexer.state.top = top; + // if there is no continuation then we are done + if (lines.length === 0) { + break; + } + const lastToken = tokens.at(-1); + if (lastToken?.type === 'code') { + // blockquote continuation cannot be preceded by a code block + break; + } + else if (lastToken?.type === 'blockquote') { + // include continuation in nested blockquote + const oldToken = lastToken; + const newText = oldToken.raw + '\n' + lines.join('\n'); + const newToken = this.blockquote(newText); + tokens[tokens.length - 1] = newToken; + raw = raw.substring(0, raw.length - oldToken.raw.length) + newToken.raw; + text = text.substring(0, text.length - oldToken.text.length) + newToken.text; + break; + } + else if (lastToken?.type === 'list') { + // include continuation in nested list + const oldToken = lastToken; + const newText = oldToken.raw + '\n' + lines.join('\n'); + const newToken = this.list(newText); + tokens[tokens.length - 1] = newToken; + raw = raw.substring(0, raw.length - lastToken.raw.length) + newToken.raw; + text = text.substring(0, text.length - oldToken.raw.length) + newToken.raw; + lines = newText.substring(tokens.at(-1).raw.length).split('\n'); + continue; + } + } + return { + type: 'blockquote', + raw, + tokens, + text, + }; + } + } + list(src) { + let cap = this.rules.block.list.exec(src); + if (cap) { + let bull = cap[1].trim(); + const isordered = bull.length > 1; + const list = { + type: 'list', + raw: '', + ordered: isordered, + start: isordered ? +bull.slice(0, -1) : '', + loose: false, + items: [], + }; + bull = isordered ? `\\d{1,9}\\${bull.slice(-1)}` : `\\${bull}`; + if (this.options.pedantic) { + bull = isordered ? bull : '[*+-]'; + } + // Get next list item + const itemRegex = this.rules.other.listItemRegex(bull); + let endsWithBlankLine = false; + // Check if current bullet point can start a new List Item + while (src) { + let endEarly = false; + let raw = ''; + let itemContents = ''; + if (!(cap = itemRegex.exec(src))) { + break; + } + if (this.rules.block.hr.test(src)) { // End list if bullet was actually HR (possibly move into itemRegex?) + break; + } + raw = cap[0]; + src = src.substring(raw.length); + let line = cap[2].split('\n', 1)[0].replace(this.rules.other.listReplaceTabs, (t) => ' '.repeat(3 * t.length)); + let nextLine = src.split('\n', 1)[0]; + let blankLine = !line.trim(); + let indent = 0; + if (this.options.pedantic) { + indent = 2; + itemContents = line.trimStart(); + } + else if (blankLine) { + indent = cap[1].length + 1; + } + else { + indent = cap[2].search(this.rules.other.nonSpaceChar); // Find first non-space char + indent = indent > 4 ? 1 : indent; // Treat indented code blocks (> 4 spaces) as having only 1 indent + itemContents = line.slice(indent); + indent += cap[1].length; + } + if (blankLine && this.rules.other.blankLine.test(nextLine)) { // Items begin with at most one blank line + raw += nextLine + '\n'; + src = src.substring(nextLine.length + 1); + endEarly = true; + } + if (!endEarly) { + const nextBulletRegex = this.rules.other.nextBulletRegex(indent); + const hrRegex = this.rules.other.hrRegex(indent); + const fencesBeginRegex = this.rules.other.fencesBeginRegex(indent); + const headingBeginRegex = this.rules.other.headingBeginRegex(indent); + const htmlBeginRegex = this.rules.other.htmlBeginRegex(indent); + // Check if following lines should be included in List Item + while (src) { + const rawLine = src.split('\n', 1)[0]; + let nextLineWithoutTabs; + nextLine = rawLine; + // Re-align to follow commonmark nesting rules + if (this.options.pedantic) { + nextLine = nextLine.replace(this.rules.other.listReplaceNesting, ' '); + nextLineWithoutTabs = nextLine; + } + else { + nextLineWithoutTabs = nextLine.replace(this.rules.other.tabCharGlobal, ' '); + } + // End list item if found code fences + if (fencesBeginRegex.test(nextLine)) { + break; + } + // End list item if found start of new heading + if (headingBeginRegex.test(nextLine)) { + break; + } + // End list item if found start of html block + if (htmlBeginRegex.test(nextLine)) { + break; + } + // End list item if found start of new bullet + if (nextBulletRegex.test(nextLine)) { + break; + } + // Horizontal rule found + if (hrRegex.test(nextLine)) { + break; + } + if (nextLineWithoutTabs.search(this.rules.other.nonSpaceChar) >= indent || !nextLine.trim()) { // Dedent if possible + itemContents += '\n' + nextLineWithoutTabs.slice(indent); + } + else { + // not enough indentation + if (blankLine) { + break; + } + // paragraph continuation unless last line was a different block level element + if (line.replace(this.rules.other.tabCharGlobal, ' ').search(this.rules.other.nonSpaceChar) >= 4) { // indented code block + break; + } + if (fencesBeginRegex.test(line)) { + break; + } + if (headingBeginRegex.test(line)) { + break; + } + if (hrRegex.test(line)) { + break; + } + itemContents += '\n' + nextLine; + } + if (!blankLine && !nextLine.trim()) { // Check if current line is blank + blankLine = true; + } + raw += rawLine + '\n'; + src = src.substring(rawLine.length + 1); + line = nextLineWithoutTabs.slice(indent); + } + } + if (!list.loose) { + // If the previous item ended with a blank line, the list is loose + if (endsWithBlankLine) { + list.loose = true; + } + else if (this.rules.other.doubleBlankLine.test(raw)) { + endsWithBlankLine = true; + } + } + let istask = null; + let ischecked; + // Check for task list items + if (this.options.gfm) { + istask = this.rules.other.listIsTask.exec(itemContents); + if (istask) { + ischecked = istask[0] !== '[ ] '; + itemContents = itemContents.replace(this.rules.other.listReplaceTask, ''); + } + } + list.items.push({ + type: 'list_item', + raw, + task: !!istask, + checked: ischecked, + loose: false, + text: itemContents, + tokens: [], + }); + list.raw += raw; + } + // Do not consume newlines at end of final item. Alternatively, make itemRegex *start* with any newlines to simplify/speed up endsWithBlankLine logic + const lastItem = list.items.at(-1); + if (lastItem) { + lastItem.raw = lastItem.raw.trimEnd(); + lastItem.text = lastItem.text.trimEnd(); + } + else { + // not a list since there were no items + return; + } + list.raw = list.raw.trimEnd(); + // Item child tokens handled here at end because we needed to have the final item to trim it first + for (let i = 0; i < list.items.length; i++) { + this.lexer.state.top = false; + list.items[i].tokens = this.lexer.blockTokens(list.items[i].text, []); + if (!list.loose) { + // Check if list should be loose + const spacers = list.items[i].tokens.filter(t => t.type === 'space'); + const hasMultipleLineBreaks = spacers.length > 0 && spacers.some(t => this.rules.other.anyLine.test(t.raw)); + list.loose = hasMultipleLineBreaks; + } + } + // Set all items to loose if list is loose + if (list.loose) { + for (let i = 0; i < list.items.length; i++) { + list.items[i].loose = true; + } + } + return list; + } + } + html(src) { + const cap = this.rules.block.html.exec(src); + if (cap) { + const token = { + type: 'html', + block: true, + raw: cap[0], + pre: cap[1] === 'pre' || cap[1] === 'script' || cap[1] === 'style', + text: cap[0], + }; + return token; + } + } + def(src) { + const cap = this.rules.block.def.exec(src); + if (cap) { + const tag = cap[1].toLowerCase().replace(this.rules.other.multipleSpaceGlobal, ' '); + const href = cap[2] ? cap[2].replace(this.rules.other.hrefBrackets, '$1').replace(this.rules.inline.anyPunctuation, '$1') : ''; + const title = cap[3] ? cap[3].substring(1, cap[3].length - 1).replace(this.rules.inline.anyPunctuation, '$1') : cap[3]; + return { + type: 'def', + tag, + raw: cap[0], + href, + title, + }; + } + } + table(src) { + const cap = this.rules.block.table.exec(src); + if (!cap) { + return; + } + if (!this.rules.other.tableDelimiter.test(cap[2])) { + // delimiter row must have a pipe (|) or colon (:) otherwise it is a setext heading + return; + } + const headers = splitCells(cap[1]); + const aligns = cap[2].replace(this.rules.other.tableAlignChars, '').split('|'); + const rows = cap[3]?.trim() ? cap[3].replace(this.rules.other.tableRowBlankLine, '').split('\n') : []; + const item = { + type: 'table', + raw: cap[0], + header: [], + align: [], + rows: [], + }; + if (headers.length !== aligns.length) { + // header and align columns must be equal, rows can be different. + return; + } + for (const align of aligns) { + if (this.rules.other.tableAlignRight.test(align)) { + item.align.push('right'); + } + else if (this.rules.other.tableAlignCenter.test(align)) { + item.align.push('center'); + } + else if (this.rules.other.tableAlignLeft.test(align)) { + item.align.push('left'); + } + else { + item.align.push(null); + } + } + for (let i = 0; i < headers.length; i++) { + item.header.push({ + text: headers[i], + tokens: this.lexer.inline(headers[i]), + header: true, + align: item.align[i], + }); + } + for (const row of rows) { + item.rows.push(splitCells(row, item.header.length).map((cell, i) => { + return { + text: cell, + tokens: this.lexer.inline(cell), + header: false, + align: item.align[i], + }; + })); + } + return item; + } + lheading(src) { + const cap = this.rules.block.lheading.exec(src); + if (cap) { + return { + type: 'heading', + raw: cap[0], + depth: cap[2].charAt(0) === '=' ? 1 : 2, + text: cap[1], + tokens: this.lexer.inline(cap[1]), + }; + } + } + paragraph(src) { + const cap = this.rules.block.paragraph.exec(src); + if (cap) { + const text = cap[1].charAt(cap[1].length - 1) === '\n' + ? cap[1].slice(0, -1) + : cap[1]; + return { + type: 'paragraph', + raw: cap[0], + text, + tokens: this.lexer.inline(text), + }; + } + } + text(src) { + const cap = this.rules.block.text.exec(src); + if (cap) { + return { + type: 'text', + raw: cap[0], + text: cap[0], + tokens: this.lexer.inline(cap[0]), + }; + } + } + escape(src) { + const cap = this.rules.inline.escape.exec(src); + if (cap) { + return { + type: 'escape', + raw: cap[0], + text: cap[1], + }; + } + } + tag(src) { + const cap = this.rules.inline.tag.exec(src); + if (cap) { + if (!this.lexer.state.inLink && this.rules.other.startATag.test(cap[0])) { + this.lexer.state.inLink = true; + } + else if (this.lexer.state.inLink && this.rules.other.endATag.test(cap[0])) { + this.lexer.state.inLink = false; + } + if (!this.lexer.state.inRawBlock && this.rules.other.startPreScriptTag.test(cap[0])) { + this.lexer.state.inRawBlock = true; + } + else if (this.lexer.state.inRawBlock && this.rules.other.endPreScriptTag.test(cap[0])) { + this.lexer.state.inRawBlock = false; + } + return { + type: 'html', + raw: cap[0], + inLink: this.lexer.state.inLink, + inRawBlock: this.lexer.state.inRawBlock, + block: false, + text: cap[0], + }; + } + } + link(src) { + const cap = this.rules.inline.link.exec(src); + if (cap) { + const trimmedUrl = cap[2].trim(); + if (!this.options.pedantic && this.rules.other.startAngleBracket.test(trimmedUrl)) { + // commonmark requires matching angle brackets + if (!(this.rules.other.endAngleBracket.test(trimmedUrl))) { + return; + } + // ending angle bracket cannot be escaped + const rtrimSlash = rtrim(trimmedUrl.slice(0, -1), '\\'); + if ((trimmedUrl.length - rtrimSlash.length) % 2 === 0) { + return; + } + } + else { + // find closing parenthesis + const lastParenIndex = findClosingBracket(cap[2], '()'); + if (lastParenIndex > -1) { + const start = cap[0].indexOf('!') === 0 ? 5 : 4; + const linkLen = start + cap[1].length + lastParenIndex; + cap[2] = cap[2].substring(0, lastParenIndex); + cap[0] = cap[0].substring(0, linkLen).trim(); + cap[3] = ''; + } + } + let href = cap[2]; + let title = ''; + if (this.options.pedantic) { + // split pedantic href and title + const link = this.rules.other.pedanticHrefTitle.exec(href); + if (link) { + href = link[1]; + title = link[3]; + } + } + else { + title = cap[3] ? cap[3].slice(1, -1) : ''; + } + href = href.trim(); + if (this.rules.other.startAngleBracket.test(href)) { + if (this.options.pedantic && !(this.rules.other.endAngleBracket.test(trimmedUrl))) { + // pedantic allows starting angle bracket without ending angle bracket + href = href.slice(1); + } + else { + href = href.slice(1, -1); + } + } + return outputLink(cap, { + href: href ? href.replace(this.rules.inline.anyPunctuation, '$1') : href, + title: title ? title.replace(this.rules.inline.anyPunctuation, '$1') : title, + }, cap[0], this.lexer, this.rules); + } + } + reflink(src, links) { + let cap; + if ((cap = this.rules.inline.reflink.exec(src)) + || (cap = this.rules.inline.nolink.exec(src))) { + const linkString = (cap[2] || cap[1]).replace(this.rules.other.multipleSpaceGlobal, ' '); + const link = links[linkString.toLowerCase()]; + if (!link) { + const text = cap[0].charAt(0); + return { + type: 'text', + raw: text, + text, + }; + } + return outputLink(cap, link, cap[0], this.lexer, this.rules); + } + } + emStrong(src, maskedSrc, prevChar = '') { + let match = this.rules.inline.emStrongLDelim.exec(src); + if (!match) + return; + // _ can't be between two alphanumerics. \p{L}\p{N} includes non-english alphabet/numbers as well + if (match[3] && prevChar.match(this.rules.other.unicodeAlphaNumeric)) + return; + const nextChar = match[1] || match[2] || ''; + if (!nextChar || !prevChar || this.rules.inline.punctuation.exec(prevChar)) { + // unicode Regex counts emoji as 1 char; spread into array for proper count (used multiple times below) + const lLength = [...match[0]].length - 1; + let rDelim, rLength, delimTotal = lLength, midDelimTotal = 0; + const endReg = match[0][0] === '*' ? this.rules.inline.emStrongRDelimAst : this.rules.inline.emStrongRDelimUnd; + endReg.lastIndex = 0; + // Clip maskedSrc to same section of string as src (move to lexer?) + maskedSrc = maskedSrc.slice(-1 * src.length + lLength); + while ((match = endReg.exec(maskedSrc)) != null) { + rDelim = match[1] || match[2] || match[3] || match[4] || match[5] || match[6]; + if (!rDelim) + continue; // skip single * in __abc*abc__ + rLength = [...rDelim].length; + if (match[3] || match[4]) { // found another Left Delim + delimTotal += rLength; + continue; + } + else if (match[5] || match[6]) { // either Left or Right Delim + if (lLength % 3 && !((lLength + rLength) % 3)) { + midDelimTotal += rLength; + continue; // CommonMark Emphasis Rules 9-10 + } + } + delimTotal -= rLength; + if (delimTotal > 0) + continue; // Haven't found enough closing delimiters + // Remove extra characters. *a*** -> *a* + rLength = Math.min(rLength, rLength + delimTotal + midDelimTotal); + // char length can be >1 for unicode characters; + const lastCharLength = [...match[0]][0].length; + const raw = src.slice(0, lLength + match.index + lastCharLength + rLength); + // Create `em` if smallest delimiter has odd char count. *a*** + if (Math.min(lLength, rLength) % 2) { + const text = raw.slice(1, -1); + return { + type: 'em', + raw, + text, + tokens: this.lexer.inlineTokens(text), + }; + } + // Create 'strong' if smallest delimiter has even char count. **a*** + const text = raw.slice(2, -2); + return { + type: 'strong', + raw, + text, + tokens: this.lexer.inlineTokens(text), + }; + } + } + } + codespan(src) { + const cap = this.rules.inline.code.exec(src); + if (cap) { + let text = cap[2].replace(this.rules.other.newLineCharGlobal, ' '); + const hasNonSpaceChars = this.rules.other.nonSpaceChar.test(text); + const hasSpaceCharsOnBothEnds = this.rules.other.startingSpaceChar.test(text) && this.rules.other.endingSpaceChar.test(text); + if (hasNonSpaceChars && hasSpaceCharsOnBothEnds) { + text = text.substring(1, text.length - 1); + } + return { + type: 'codespan', + raw: cap[0], + text, + }; + } + } + br(src) { + const cap = this.rules.inline.br.exec(src); + if (cap) { + return { + type: 'br', + raw: cap[0], + }; + } + } + del(src) { + const cap = this.rules.inline.del.exec(src); + if (cap) { + return { + type: 'del', + raw: cap[0], + text: cap[2], + tokens: this.lexer.inlineTokens(cap[2]), + }; + } + } + autolink(src) { + const cap = this.rules.inline.autolink.exec(src); + if (cap) { + let text, href; + if (cap[2] === '@') { + text = cap[1]; + href = 'mailto:' + text; + } + else { + text = cap[1]; + href = text; + } + return { + type: 'link', + raw: cap[0], + text, + href, + tokens: [ + { + type: 'text', + raw: text, + text, + }, + ], + }; + } + } + url(src) { + let cap; + if (cap = this.rules.inline.url.exec(src)) { + let text, href; + if (cap[2] === '@') { + text = cap[0]; + href = 'mailto:' + text; + } + else { + // do extended autolink path validation + let prevCapZero; + do { + prevCapZero = cap[0]; + cap[0] = this.rules.inline._backpedal.exec(cap[0])?.[0] ?? ''; + } while (prevCapZero !== cap[0]); + text = cap[0]; + if (cap[1] === 'www.') { + href = 'http://' + cap[0]; + } + else { + href = cap[0]; + } + } + return { + type: 'link', + raw: cap[0], + text, + href, + tokens: [ + { + type: 'text', + raw: text, + text, + }, + ], + }; + } + } + inlineText(src) { + const cap = this.rules.inline.text.exec(src); + if (cap) { + const escaped = this.lexer.state.inRawBlock; + return { + type: 'text', + raw: cap[0], + text: cap[0], + escaped, + }; + } + } +} + +/** + * Block Lexer + */ +class _Lexer { + tokens; + options; + state; + tokenizer; + inlineQueue; + constructor(options) { + // TokenList cannot be created in one go + this.tokens = []; + this.tokens.links = Object.create(null); + this.options = options || _defaults; + this.options.tokenizer = this.options.tokenizer || new _Tokenizer(); + this.tokenizer = this.options.tokenizer; + this.tokenizer.options = this.options; + this.tokenizer.lexer = this; + this.inlineQueue = []; + this.state = { + inLink: false, + inRawBlock: false, + top: true, + }; + const rules = { + other, + block: block.normal, + inline: inline.normal, + }; + if (this.options.pedantic) { + rules.block = block.pedantic; + rules.inline = inline.pedantic; + } + else if (this.options.gfm) { + rules.block = block.gfm; + if (this.options.breaks) { + rules.inline = inline.breaks; + } + else { + rules.inline = inline.gfm; + } + } + this.tokenizer.rules = rules; + } + /** + * Expose Rules + */ + static get rules() { + return { + block, + inline, + }; + } + /** + * Static Lex Method + */ + static lex(src, options) { + const lexer = new _Lexer(options); + return lexer.lex(src); + } + /** + * Static Lex Inline Method + */ + static lexInline(src, options) { + const lexer = new _Lexer(options); + return lexer.inlineTokens(src); + } + /** + * Preprocessing + */ + lex(src) { + src = src.replace(other.carriageReturn, '\n'); + this.blockTokens(src, this.tokens); + for (let i = 0; i < this.inlineQueue.length; i++) { + const next = this.inlineQueue[i]; + this.inlineTokens(next.src, next.tokens); + } + this.inlineQueue = []; + return this.tokens; + } + blockTokens(src, tokens = [], lastParagraphClipped = false) { + if (this.options.pedantic) { + src = src.replace(other.tabCharGlobal, ' ').replace(other.spaceLine, ''); + } + while (src) { + let token; + if (this.options.extensions?.block?.some((extTokenizer) => { + if (token = extTokenizer.call({ lexer: this }, src, tokens)) { + src = src.substring(token.raw.length); + tokens.push(token); + return true; + } + return false; + })) { + continue; + } + // newline + if (token = this.tokenizer.space(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (token.raw.length === 1 && lastToken !== undefined) { + // if there's a single \n as a spacer, it's terminating the last line, + // so move it there so that we don't get unnecessary paragraph tags + lastToken.raw += '\n'; + } + else { + tokens.push(token); + } + continue; + } + // code + if (token = this.tokenizer.code(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + // An indented code block cannot interrupt a paragraph. + if (lastToken?.type === 'paragraph' || lastToken?.type === 'text') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue.at(-1).src = lastToken.text; + } + else { + tokens.push(token); + } + continue; + } + // fences + if (token = this.tokenizer.fences(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // heading + if (token = this.tokenizer.heading(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // hr + if (token = this.tokenizer.hr(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // blockquote + if (token = this.tokenizer.blockquote(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // list + if (token = this.tokenizer.list(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // html + if (token = this.tokenizer.html(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // def + if (token = this.tokenizer.def(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (lastToken?.type === 'paragraph' || lastToken?.type === 'text') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.raw; + this.inlineQueue.at(-1).src = lastToken.text; + } + else if (!this.tokens.links[token.tag]) { + this.tokens.links[token.tag] = { + href: token.href, + title: token.title, + }; + } + continue; + } + // table (gfm) + if (token = this.tokenizer.table(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // lheading + if (token = this.tokenizer.lheading(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // top-level paragraph + // prevent paragraph consuming extensions by clipping 'src' to extension start + let cutSrc = src; + if (this.options.extensions?.startBlock) { + let startIndex = Infinity; + const tempSrc = src.slice(1); + let tempStart; + this.options.extensions.startBlock.forEach((getStartIndex) => { + tempStart = getStartIndex.call({ lexer: this }, tempSrc); + if (typeof tempStart === 'number' && tempStart >= 0) { + startIndex = Math.min(startIndex, tempStart); + } + }); + if (startIndex < Infinity && startIndex >= 0) { + cutSrc = src.substring(0, startIndex + 1); + } + } + if (this.state.top && (token = this.tokenizer.paragraph(cutSrc))) { + const lastToken = tokens.at(-1); + if (lastParagraphClipped && lastToken?.type === 'paragraph') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue.pop(); + this.inlineQueue.at(-1).src = lastToken.text; + } + else { + tokens.push(token); + } + lastParagraphClipped = cutSrc.length !== src.length; + src = src.substring(token.raw.length); + continue; + } + // text + if (token = this.tokenizer.text(src)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (lastToken?.type === 'text') { + lastToken.raw += '\n' + token.raw; + lastToken.text += '\n' + token.text; + this.inlineQueue.pop(); + this.inlineQueue.at(-1).src = lastToken.text; + } + else { + tokens.push(token); + } + continue; + } + if (src) { + const errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); + if (this.options.silent) { + console.error(errMsg); + break; + } + else { + throw new Error(errMsg); + } + } + } + this.state.top = true; + return tokens; + } + inline(src, tokens = []) { + this.inlineQueue.push({ src, tokens }); + return tokens; + } + /** + * Lexing/Compiling + */ + inlineTokens(src, tokens = []) { + // String with links masked to avoid interference with em and strong + let maskedSrc = src; + let match = null; + // Mask out reflinks + if (this.tokens.links) { + const links = Object.keys(this.tokens.links); + if (links.length > 0) { + while ((match = this.tokenizer.rules.inline.reflinkSearch.exec(maskedSrc)) != null) { + if (links.includes(match[0].slice(match[0].lastIndexOf('[') + 1, -1))) { + maskedSrc = maskedSrc.slice(0, match.index) + + '[' + 'a'.repeat(match[0].length - 2) + ']' + + maskedSrc.slice(this.tokenizer.rules.inline.reflinkSearch.lastIndex); + } + } + } + } + // Mask out other blocks + while ((match = this.tokenizer.rules.inline.blockSkip.exec(maskedSrc)) != null) { + maskedSrc = maskedSrc.slice(0, match.index) + '[' + 'a'.repeat(match[0].length - 2) + ']' + maskedSrc.slice(this.tokenizer.rules.inline.blockSkip.lastIndex); + } + // Mask out escaped characters + while ((match = this.tokenizer.rules.inline.anyPunctuation.exec(maskedSrc)) != null) { + maskedSrc = maskedSrc.slice(0, match.index) + '++' + maskedSrc.slice(this.tokenizer.rules.inline.anyPunctuation.lastIndex); + } + let keepPrevChar = false; + let prevChar = ''; + while (src) { + if (!keepPrevChar) { + prevChar = ''; + } + keepPrevChar = false; + let token; + // extensions + if (this.options.extensions?.inline?.some((extTokenizer) => { + if (token = extTokenizer.call({ lexer: this }, src, tokens)) { + src = src.substring(token.raw.length); + tokens.push(token); + return true; + } + return false; + })) { + continue; + } + // escape + if (token = this.tokenizer.escape(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // tag + if (token = this.tokenizer.tag(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // link + if (token = this.tokenizer.link(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // reflink, nolink + if (token = this.tokenizer.reflink(src, this.tokens.links)) { + src = src.substring(token.raw.length); + const lastToken = tokens.at(-1); + if (token.type === 'text' && lastToken?.type === 'text') { + lastToken.raw += token.raw; + lastToken.text += token.text; + } + else { + tokens.push(token); + } + continue; + } + // em & strong + if (token = this.tokenizer.emStrong(src, maskedSrc, prevChar)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // code + if (token = this.tokenizer.codespan(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // br + if (token = this.tokenizer.br(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // del (gfm) + if (token = this.tokenizer.del(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // autolink + if (token = this.tokenizer.autolink(src)) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // url (gfm) + if (!this.state.inLink && (token = this.tokenizer.url(src))) { + src = src.substring(token.raw.length); + tokens.push(token); + continue; + } + // text + // prevent inlineText consuming extensions by clipping 'src' to extension start + let cutSrc = src; + if (this.options.extensions?.startInline) { + let startIndex = Infinity; + const tempSrc = src.slice(1); + let tempStart; + this.options.extensions.startInline.forEach((getStartIndex) => { + tempStart = getStartIndex.call({ lexer: this }, tempSrc); + if (typeof tempStart === 'number' && tempStart >= 0) { + startIndex = Math.min(startIndex, tempStart); + } + }); + if (startIndex < Infinity && startIndex >= 0) { + cutSrc = src.substring(0, startIndex + 1); + } + } + if (token = this.tokenizer.inlineText(cutSrc)) { + src = src.substring(token.raw.length); + if (token.raw.slice(-1) !== '_') { // Track prevChar before string of ____ started + prevChar = token.raw.slice(-1); + } + keepPrevChar = true; + const lastToken = tokens.at(-1); + if (lastToken?.type === 'text') { + lastToken.raw += token.raw; + lastToken.text += token.text; + } + else { + tokens.push(token); + } + continue; + } + if (src) { + const errMsg = 'Infinite loop on byte: ' + src.charCodeAt(0); + if (this.options.silent) { + console.error(errMsg); + break; + } + else { + throw new Error(errMsg); + } + } + } + return tokens; + } +} + +/** + * Renderer + */ +class _Renderer { + options; + parser; // set by the parser + constructor(options) { + this.options = options || _defaults; + } + space(token) { + return ''; + } + code({ text, lang, escaped }) { + const langString = (lang || '').match(other.notSpaceStart)?.[0]; + const code = text.replace(other.endingNewline, '') + '\n'; + if (!langString) { + return ' \n'; + } + return '' + + (escaped ? code : escape(code, true)) + + '\n'; + } + blockquote({ tokens }) { + const body = this.parser.parse(tokens); + return `' + + (escaped ? code : escape(code, true)) + + '\n${body}\n`; + } + html({ text }) { + return text; + } + heading({ tokens, depth }) { + return `${this.parser.parseInline(tokens)} \n`; + } + hr(token) { + return '
\n'; + } + list(token) { + const ordered = token.ordered; + const start = token.start; + let body = ''; + for (let j = 0; j < token.items.length; j++) { + const item = token.items[j]; + body += this.listitem(item); + } + const type = ordered ? 'ol' : 'ul'; + const startAttr = (ordered && start !== 1) ? (' start="' + start + '"') : ''; + return '<' + type + startAttr + '>\n' + body + '' + type + '>\n'; + } + listitem(item) { + let itemBody = ''; + if (item.task) { + const checkbox = this.checkbox({ checked: !!item.checked }); + if (item.loose) { + if (item.tokens[0]?.type === 'paragraph') { + item.tokens[0].text = checkbox + ' ' + item.tokens[0].text; + if (item.tokens[0].tokens && item.tokens[0].tokens.length > 0 && item.tokens[0].tokens[0].type === 'text') { + item.tokens[0].tokens[0].text = checkbox + ' ' + escape(item.tokens[0].tokens[0].text); + item.tokens[0].tokens[0].escaped = true; + } + } + else { + item.tokens.unshift({ + type: 'text', + raw: checkbox + ' ', + text: checkbox + ' ', + escaped: true, + }); + } + } + else { + itemBody += checkbox + ' '; + } + } + itemBody += this.parser.parse(item.tokens, !!item.loose); + return `${itemBody} \n`; + } + checkbox({ checked }) { + return ''; + } + paragraph({ tokens }) { + return `${this.parser.parseInline(tokens)}
\n`; + } + table(token) { + let header = ''; + // header + let cell = ''; + for (let j = 0; j < token.header.length; j++) { + cell += this.tablecell(token.header[j]); + } + header += this.tablerow({ text: cell }); + let body = ''; + for (let j = 0; j < token.rows.length; j++) { + const row = token.rows[j]; + cell = ''; + for (let k = 0; k < row.length; k++) { + cell += this.tablecell(row[k]); + } + body += this.tablerow({ text: cell }); + } + if (body) + body = `${body}`; + return '\n' + + '\n' + + header + + '\n' + + body + + '
\n'; + } + tablerow({ text }) { + return `\n${text} \n`; + } + tablecell(token) { + const content = this.parser.parseInline(token.tokens); + const type = token.header ? 'th' : 'td'; + const tag = token.align + ? `<${type} align="${token.align}">` + : `<${type}>`; + return tag + content + `${type}>\n`; + } + /** + * span level renderer + */ + strong({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + em({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + codespan({ text }) { + return `${escape(text, true)}`; + } + br(token) { + return '
'; + } + del({ tokens }) { + return `${this.parser.parseInline(tokens)}`; + } + link({ href, title, tokens }) { + const text = this.parser.parseInline(tokens); + const cleanHref = cleanUrl(href); + if (cleanHref === null) { + return text; + } + href = cleanHref; + let out = '' + text + ''; + return out; + } + image({ href, title, text }) { + const cleanHref = cleanUrl(href); + if (cleanHref === null) { + return escape(text); + } + href = cleanHref; + let out = `'; + return out; + } + text(token) { + return 'tokens' in token && token.tokens + ? this.parser.parseInline(token.tokens) + : ('escaped' in token && token.escaped ? token.text : escape(token.text)); + } +} + +/** + * TextRenderer + * returns only the textual part of the token + */ +class _TextRenderer { + // no need for block level renderers + strong({ text }) { + return text; + } + em({ text }) { + return text; + } + codespan({ text }) { + return text; + } + del({ text }) { + return text; + } + html({ text }) { + return text; + } + text({ text }) { + return text; + } + link({ text }) { + return '' + text; + } + image({ text }) { + return '' + text; + } + br() { + return ''; + } +} + +/** + * Parsing & Compiling + */ +class _Parser { + options; + renderer; + textRenderer; + constructor(options) { + this.options = options || _defaults; + this.options.renderer = this.options.renderer || new _Renderer(); + this.renderer = this.options.renderer; + this.renderer.options = this.options; + this.renderer.parser = this; + this.textRenderer = new _TextRenderer(); + } + /** + * Static Parse Method + */ + static parse(tokens, options) { + const parser = new _Parser(options); + return parser.parse(tokens); + } + /** + * Static Parse Inline Method + */ + static parseInline(tokens, options) { + const parser = new _Parser(options); + return parser.parseInline(tokens); + } + /** + * Parse Loop + */ + parse(tokens, top = true) { + let out = ''; + for (let i = 0; i < tokens.length; i++) { + const anyToken = tokens[i]; + // Run any renderer extensions + if (this.options.extensions?.renderers?.[anyToken.type]) { + const genericToken = anyToken; + const ret = this.options.extensions.renderers[genericToken.type].call({ parser: this }, genericToken); + if (ret !== false || !['space', 'hr', 'heading', 'code', 'table', 'blockquote', 'list', 'html', 'paragraph', 'text'].includes(genericToken.type)) { + out += ret || ''; + continue; + } + } + const token = anyToken; + switch (token.type) { + case 'space': { + out += this.renderer.space(token); + continue; + } + case 'hr': { + out += this.renderer.hr(token); + continue; + } + case 'heading': { + out += this.renderer.heading(token); + continue; + } + case 'code': { + out += this.renderer.code(token); + continue; + } + case 'table': { + out += this.renderer.table(token); + continue; + } + case 'blockquote': { + out += this.renderer.blockquote(token); + continue; + } + case 'list': { + out += this.renderer.list(token); + continue; + } + case 'html': { + out += this.renderer.html(token); + continue; + } + case 'paragraph': { + out += this.renderer.paragraph(token); + continue; + } + case 'text': { + let textToken = token; + let body = this.renderer.text(textToken); + while (i + 1 < tokens.length && tokens[i + 1].type === 'text') { + textToken = tokens[++i]; + body += '\n' + this.renderer.text(textToken); + } + if (top) { + out += this.renderer.paragraph({ + type: 'paragraph', + raw: body, + text: body, + tokens: [{ type: 'text', raw: body, text: body, escaped: true }], + }); + } + else { + out += body; + } + continue; + } + default: { + const errMsg = 'Token with "' + token.type + '" type was not found.'; + if (this.options.silent) { + console.error(errMsg); + return ''; + } + else { + throw new Error(errMsg); + } + } + } + } + return out; + } + /** + * Parse Inline Tokens + */ + parseInline(tokens, renderer = this.renderer) { + let out = ''; + for (let i = 0; i < tokens.length; i++) { + const anyToken = tokens[i]; + // Run any renderer extensions + if (this.options.extensions?.renderers?.[anyToken.type]) { + const ret = this.options.extensions.renderers[anyToken.type].call({ parser: this }, anyToken); + if (ret !== false || !['escape', 'html', 'link', 'image', 'strong', 'em', 'codespan', 'br', 'del', 'text'].includes(anyToken.type)) { + out += ret || ''; + continue; + } + } + const token = anyToken; + switch (token.type) { + case 'escape': { + out += renderer.text(token); + break; + } + case 'html': { + out += renderer.html(token); + break; + } + case 'link': { + out += renderer.link(token); + break; + } + case 'image': { + out += renderer.image(token); + break; + } + case 'strong': { + out += renderer.strong(token); + break; + } + case 'em': { + out += renderer.em(token); + break; + } + case 'codespan': { + out += renderer.codespan(token); + break; + } + case 'br': { + out += renderer.br(token); + break; + } + case 'del': { + out += renderer.del(token); + break; + } + case 'text': { + out += renderer.text(token); + break; + } + default: { + const errMsg = 'Token with "' + token.type + '" type was not found.'; + if (this.options.silent) { + console.error(errMsg); + return ''; + } + else { + throw new Error(errMsg); + } + } + } + } + return out; + } +} + +class _Hooks { + options; + block; + constructor(options) { + this.options = options || _defaults; + } + static passThroughHooks = new Set([ + 'preprocess', + 'postprocess', + 'processAllTokens', + ]); + /** + * Process markdown before marked + */ + preprocess(markdown) { + return markdown; + } + /** + * Process HTML after marked is finished + */ + postprocess(html) { + return html; + } + /** + * Process all tokens before walk tokens + */ + processAllTokens(tokens) { + return tokens; + } + /** + * Provide function to tokenize markdown + */ + provideLexer() { + return this.block ? _Lexer.lex : _Lexer.lexInline; + } + /** + * Provide function to parse tokens + */ + provideParser() { + return this.block ? _Parser.parse : _Parser.parseInline; + } +} + +class Marked { + defaults = _getDefaults(); + options = this.setOptions; + parse = this.parseMarkdown(true); + parseInline = this.parseMarkdown(false); + Parser = _Parser; + Renderer = _Renderer; + TextRenderer = _TextRenderer; + Lexer = _Lexer; + Tokenizer = _Tokenizer; + Hooks = _Hooks; + constructor(...args) { + this.use(...args); + } + /** + * Run callback for every token + */ + walkTokens(tokens, callback) { + let values = []; + for (const token of tokens) { + values = values.concat(callback.call(this, token)); + switch (token.type) { + case 'table': { + const tableToken = token; + for (const cell of tableToken.header) { + values = values.concat(this.walkTokens(cell.tokens, callback)); + } + for (const row of tableToken.rows) { + for (const cell of row) { + values = values.concat(this.walkTokens(cell.tokens, callback)); + } + } + break; + } + case 'list': { + const listToken = token; + values = values.concat(this.walkTokens(listToken.items, callback)); + break; + } + default: { + const genericToken = token; + if (this.defaults.extensions?.childTokens?.[genericToken.type]) { + this.defaults.extensions.childTokens[genericToken.type].forEach((childTokens) => { + const tokens = genericToken[childTokens].flat(Infinity); + values = values.concat(this.walkTokens(tokens, callback)); + }); + } + else if (genericToken.tokens) { + values = values.concat(this.walkTokens(genericToken.tokens, callback)); + } + } + } + } + return values; + } + use(...args) { + const extensions = this.defaults.extensions || { renderers: {}, childTokens: {} }; + args.forEach((pack) => { + // copy options to new object + const opts = { ...pack }; + // set async to true if it was set to true before + opts.async = this.defaults.async || opts.async || false; + // ==-- Parse "addon" extensions --== // + if (pack.extensions) { + pack.extensions.forEach((ext) => { + if (!ext.name) { + throw new Error('extension name required'); + } + if ('renderer' in ext) { // Renderer extensions + const prevRenderer = extensions.renderers[ext.name]; + if (prevRenderer) { + // Replace extension with func to run new extension but fall back if false + extensions.renderers[ext.name] = function (...args) { + let ret = ext.renderer.apply(this, args); + if (ret === false) { + ret = prevRenderer.apply(this, args); + } + return ret; + }; + } + else { + extensions.renderers[ext.name] = ext.renderer; + } + } + if ('tokenizer' in ext) { // Tokenizer Extensions + if (!ext.level || (ext.level !== 'block' && ext.level !== 'inline')) { + throw new Error("extension level must be 'block' or 'inline'"); + } + const extLevel = extensions[ext.level]; + if (extLevel) { + extLevel.unshift(ext.tokenizer); + } + else { + extensions[ext.level] = [ext.tokenizer]; + } + if (ext.start) { // Function to check for start of token + if (ext.level === 'block') { + if (extensions.startBlock) { + extensions.startBlock.push(ext.start); + } + else { + extensions.startBlock = [ext.start]; + } + } + else if (ext.level === 'inline') { + if (extensions.startInline) { + extensions.startInline.push(ext.start); + } + else { + extensions.startInline = [ext.start]; + } + } + } + } + if ('childTokens' in ext && ext.childTokens) { // Child tokens to be visited by walkTokens + extensions.childTokens[ext.name] = ext.childTokens; + } + }); + opts.extensions = extensions; + } + // ==-- Parse "overwrite" extensions --== // + if (pack.renderer) { + const renderer = this.defaults.renderer || new _Renderer(this.defaults); + for (const prop in pack.renderer) { + if (!(prop in renderer)) { + throw new Error(`renderer '${prop}' does not exist`); + } + if (['options', 'parser'].includes(prop)) { + // ignore options property + continue; + } + const rendererProp = prop; + const rendererFunc = pack.renderer[rendererProp]; + const prevRenderer = renderer[rendererProp]; + // Replace renderer with func to run extension, but fall back if false + renderer[rendererProp] = (...args) => { + let ret = rendererFunc.apply(renderer, args); + if (ret === false) { + ret = prevRenderer.apply(renderer, args); + } + return ret || ''; + }; + } + opts.renderer = renderer; + } + if (pack.tokenizer) { + const tokenizer = this.defaults.tokenizer || new _Tokenizer(this.defaults); + for (const prop in pack.tokenizer) { + if (!(prop in tokenizer)) { + throw new Error(`tokenizer '${prop}' does not exist`); + } + if (['options', 'rules', 'lexer'].includes(prop)) { + // ignore options, rules, and lexer properties + continue; + } + const tokenizerProp = prop; + const tokenizerFunc = pack.tokenizer[tokenizerProp]; + const prevTokenizer = tokenizer[tokenizerProp]; + // Replace tokenizer with func to run extension, but fall back if false + // @ts-expect-error cannot type tokenizer function dynamically + tokenizer[tokenizerProp] = (...args) => { + let ret = tokenizerFunc.apply(tokenizer, args); + if (ret === false) { + ret = prevTokenizer.apply(tokenizer, args); + } + return ret; + }; + } + opts.tokenizer = tokenizer; + } + // ==-- Parse Hooks extensions --== // + if (pack.hooks) { + const hooks = this.defaults.hooks || new _Hooks(); + for (const prop in pack.hooks) { + if (!(prop in hooks)) { + throw new Error(`hook '${prop}' does not exist`); + } + if (['options', 'block'].includes(prop)) { + // ignore options and block properties + continue; + } + const hooksProp = prop; + const hooksFunc = pack.hooks[hooksProp]; + const prevHook = hooks[hooksProp]; + if (_Hooks.passThroughHooks.has(prop)) { + // @ts-expect-error cannot type hook function dynamically + hooks[hooksProp] = (arg) => { + if (this.defaults.async) { + return Promise.resolve(hooksFunc.call(hooks, arg)).then(ret => { + return prevHook.call(hooks, ret); + }); + } + const ret = hooksFunc.call(hooks, arg); + return prevHook.call(hooks, ret); + }; + } + else { + // @ts-expect-error cannot type hook function dynamically + hooks[hooksProp] = (...args) => { + let ret = hooksFunc.apply(hooks, args); + if (ret === false) { + ret = prevHook.apply(hooks, args); + } + return ret; + }; + } + } + opts.hooks = hooks; + } + // ==-- Parse WalkTokens extensions --== // + if (pack.walkTokens) { + const walkTokens = this.defaults.walkTokens; + const packWalktokens = pack.walkTokens; + opts.walkTokens = function (token) { + let values = []; + values.push(packWalktokens.call(this, token)); + if (walkTokens) { + values = values.concat(walkTokens.call(this, token)); + } + return values; + }; + } + this.defaults = { ...this.defaults, ...opts }; + }); + return this; + } + setOptions(opt) { + this.defaults = { ...this.defaults, ...opt }; + return this; + } + lexer(src, options) { + return _Lexer.lex(src, options ?? this.defaults); + } + parser(tokens, options) { + return _Parser.parse(tokens, options ?? this.defaults); + } + parseMarkdown(blockType) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const parse = (src, options) => { + const origOpt = { ...options }; + const opt = { ...this.defaults, ...origOpt }; + const throwError = this.onError(!!opt.silent, !!opt.async); + // throw error if an extension set async to true but parse was called with async: false + if (this.defaults.async === true && origOpt.async === false) { + return throwError(new Error('marked(): The async option was set to true by an extension. Remove async: false from the parse options object to return a Promise.')); + } + // throw error in case of non string input + if (typeof src === 'undefined' || src === null) { + return throwError(new Error('marked(): input parameter is undefined or null')); + } + if (typeof src !== 'string') { + return throwError(new Error('marked(): input parameter is of type ' + + Object.prototype.toString.call(src) + ', string expected')); + } + if (opt.hooks) { + opt.hooks.options = opt; + opt.hooks.block = blockType; + } + const lexer = opt.hooks ? opt.hooks.provideLexer() : (blockType ? _Lexer.lex : _Lexer.lexInline); + const parser = opt.hooks ? opt.hooks.provideParser() : (blockType ? _Parser.parse : _Parser.parseInline); + if (opt.async) { + return Promise.resolve(opt.hooks ? opt.hooks.preprocess(src) : src) + .then(src => lexer(src, opt)) + .then(tokens => opt.hooks ? opt.hooks.processAllTokens(tokens) : tokens) + .then(tokens => opt.walkTokens ? Promise.all(this.walkTokens(tokens, opt.walkTokens)).then(() => tokens) : tokens) + .then(tokens => parser(tokens, opt)) + .then(html => opt.hooks ? opt.hooks.postprocess(html) : html) + .catch(throwError); + } + try { + if (opt.hooks) { + src = opt.hooks.preprocess(src); + } + let tokens = lexer(src, opt); + if (opt.hooks) { + tokens = opt.hooks.processAllTokens(tokens); + } + if (opt.walkTokens) { + this.walkTokens(tokens, opt.walkTokens); + } + let html = parser(tokens, opt); + if (opt.hooks) { + html = opt.hooks.postprocess(html); + } + return html; + } + catch (e) { + return throwError(e); + } + }; + return parse; + } + onError(silent, async) { + return (e) => { + e.message += '\nPlease report this to https://github.com/markedjs/marked.'; + if (silent) { + const msg = '
An error occurred:
' + + escape(e.message + '', true) + + ''; + if (async) { + return Promise.resolve(msg); + } + return msg; + } + if (async) { + return Promise.reject(e); + } + throw e; + }; + } +} + +const markedInstance = new Marked(); +function marked(src, opt) { + return markedInstance.parse(src, opt); +} +/** + * Sets the default options. + * + * @param options Hash of options + */ +marked.options = + marked.setOptions = function (options) { + markedInstance.setOptions(options); + marked.defaults = markedInstance.defaults; + changeDefaults(marked.defaults); + return marked; + }; +/** + * Gets the original marked default options. + */ +marked.getDefaults = _getDefaults; +marked.defaults = _defaults; +/** + * Use Extension + */ +marked.use = function (...args) { + markedInstance.use(...args); + marked.defaults = markedInstance.defaults; + changeDefaults(marked.defaults); + return marked; +}; +/** + * Run callback for every token + */ +marked.walkTokens = function (tokens, callback) { + return markedInstance.walkTokens(tokens, callback); +}; +/** + * Compiles markdown to HTML without enclosing `p` tag. + * + * @param src String of markdown source to be compiled + * @param options Hash of options + * @return String of compiled HTML + */ +marked.parseInline = markedInstance.parseInline; +/** + * Expose + */ +marked.Parser = _Parser; +marked.parser = _Parser.parse; +marked.Renderer = _Renderer; +marked.TextRenderer = _TextRenderer; +marked.Lexer = _Lexer; +marked.lexer = _Lexer.lex; +marked.Tokenizer = _Tokenizer; +marked.Hooks = _Hooks; +marked.parse = marked; +const options = marked.options; +const setOptions = marked.setOptions; +const use = marked.use; +const walkTokens = marked.walkTokens; +const parseInline = marked.parseInline; +const parse = marked; +const parser = _Parser.parse; +const lexer = _Lexer.lex; + +export { _Hooks as Hooks, _Lexer as Lexer, Marked, _Parser as Parser, _Renderer as Renderer, _TextRenderer as TextRenderer, _Tokenizer as Tokenizer, _defaults as defaults, _getDefaults as getDefaults, lexer, marked, options, parse, parseInline, parser, setOptions, use, walkTokens }; +//# sourceMappingURL=marked.esm.js.map diff --git a/ollama-api.js b/ollama-api.js new file mode 100644 index 0000000..d2f2632 --- /dev/null +++ b/ollama-api.js @@ -0,0 +1,169 @@ +/** + * Ollama API 客户端封装 + * + * 基于 Fetch API 与 Ollama REST 接口交互 + * 支持:流式聊天、模型管理、连接检测 + * + * API 文档:https://docs.ollama.com/api + */ + +export class OllamaAPI { + constructor(baseUrl = 'http://127.0.0.1:11434') { + this.baseUrl = baseUrl.replace(/\/+$/, ''); + } + + /** 通用请求方法 */ + async _request(path, options = {}) { + const url = `${this.baseUrl}${path}`; + const config = { + ...options, + headers: { 'Content-Type': 'application/json', ...options.headers } + }; + const response = await fetch(url, config); + if (!response.ok) { + const errorBody = await response.text().catch(() => ''); + throw new Error(`Ollama API 错误 ${response.status}: ${errorBody || response.statusText}`); + } + return response; + } + + /** JSON 请求快捷方法 */ + async _json(path, body = null) { + const options = body ? { method: 'POST', body: JSON.stringify(body) } : {}; + const response = await this._request(path, options); + return response.json(); + } + + // ── 模型管理 ── + + /** 获取已安装模型列表 */ + async listModels() { return this._json('/api/tags'); } + + /** 获取运行中的模型列表 */ + async psModels() { return this._json('/api/ps'); } + + /** 获取 Ollama 版本 */ + async getVersion() { return this._json('/api/version'); } + + /** 查看模型详情 */ + async showModel(model) { return this._json('/api/show', { model }); } + + // ── 聊天 API ── + + /** 非流式聊天 */ + async chat(params) { + const body = { + model: params.model, + messages: params.messages, + stream: false, + ...(params.think !== undefined && { think: params.think }), + ...(params.system && { system: params.system }), + ...(params.keep_alive !== undefined && { keep_alive: params.keep_alive }), + ...(params.options && { options: params.options }) + }; + return this._json('/api/chat', body); + } + + /** + * 流式聊天 + * 使用 ReadableStream + TextDecoder 解析 NDJSON 流 + * Ollama 流式响应格式 (NDJSON): + * {"model":"xxx","message":{"role":"assistant","content":"Hello"},"done":false} + */ + async chatStream(params, onChunk) { + const body = { + model: params.model, + messages: params.messages, + stream: true, + ...(params.think !== undefined && { think: params.think }), + ...(params.system && { system: params.system }), + ...(params.keep_alive !== undefined && { keep_alive: params.keep_alive }), + ...(params.options && { options: params.options }) + }; + + const response = await this._request('/api/chat', { + method: 'POST', + body: JSON.stringify(body) + }); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const chunk = JSON.parse(line); + if (onChunk) onChunk(chunk); + if (chunk.done) { + reader.cancel(); + return; + } + } catch (err) { + console.warn('[OllamaAPI] 流式数据解析警告:', err.message); + } + } + } + + if (buffer.trim()) { + try { + const chunk = JSON.parse(buffer); + if (onChunk) onChunk(chunk); + } catch { /* 忽略 */ } + } + } + + /** 流式生成(单轮) */ + async generateStream(params, onChunk) { + const body = { + model: params.model, + prompt: params.prompt, + stream: true, + ...(params.images && { images: params.images }), + ...(params.options && { options: params.options }) + }; + + const response = await this._request('/api/generate', { + method: 'POST', + body: JSON.stringify(body) + }); + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; + + for (const line of lines) { + if (!line.trim()) continue; + try { + const chunk = JSON.parse(line); + if (onChunk) onChunk(chunk); + if (chunk.done) { + reader.cancel(); + return; + } + } catch { /* 忽略不完整数据 */ } + } + } + } + + /** 生成嵌入向量 */ + async embed(model, input) { + return this._json('/api/embed', { model, input }); + } +} diff --git a/style.css b/style.css new file mode 100644 index 0000000..d1b8404 --- /dev/null +++ b/style.css @@ -0,0 +1,1413 @@ +/* ═══════════════════════════════════════════════════════════════ + Metona Ollama Client - 未来感深色主题 + 玻璃拟态 + 霓虹色点缀 + Mobile First 响应式 + ═══════════════════════════════════════════════════════════════ */ + +/* ── CSS 变量 ── */ +:root { + --bg-primary: #0a0a1a; + --bg-secondary: #0f0f2a; + --bg-glass: rgba(15, 15, 42, 0.7); + --bg-glass-light: rgba(25, 25, 60, 0.5); + --bg-glass-hover: rgba(35, 35, 80, 0.5); + --border-glass: rgba(100, 100, 255, 0.15); + --border-glow: rgba(0, 245, 212, 0.3); + --text-primary: #e8e8f0; + --text-secondary: #8888aa; + --text-muted: #555570; + --accent-cyan: #00f5d4; + --accent-purple: #7b2ff7; + --accent-blue: #3b82f6; + --accent-pink: #f472b6; + --accent-red: #ef4444; + --accent-green: #22c55e; + --accent-yellow: #eab308; + --gradient-main: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple)); + --shadow-glow: 0 0 20px rgba(0, 245, 212, 0.15); + --shadow-glass: 0 8px 32px rgba(0, 0, 0, 0.4); + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-xl: 20px; + --font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', monospace; + --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; + --transition: all 0.2s ease; +} + +/* ── 全局重置 ── */ +*, *::before, *::after { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +html, body { + height: 100%; + overflow: hidden; + background: var(--bg-primary); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* 背景动态网格 */ +body::before { + content: ''; + position: fixed; + inset: 0; + background: + radial-gradient(circle at 20% 30%, rgba(0, 245, 212, 0.05) 0%, transparent 50%), + radial-gradient(circle at 80% 70%, rgba(123, 47, 247, 0.05) 0%, transparent 50%); + pointer-events: none; + z-index: 0; +} + +#app { + display: flex; + flex-direction: column; + height: 100vh; + position: relative; + z-index: 1; +} + +/* ═══ 顶部导航 ═══ */ +.app-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + background: var(--bg-glass); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-bottom: 1px solid var(--border-glass); + flex-shrink: 0; + z-index: 10; +} + +.header-left { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + flex: 1; +} + +.logo { + font-size: 24px; + filter: drop-shadow(0 0 8px rgba(0, 245, 212, 0.4)); + flex-shrink: 0; +} + +.app-title { + font-size: 16px; + font-weight: 700; + background: var(--gradient-main); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + letter-spacing: 0.5px; + white-space: nowrap; +} + +.app-version { + font-size: 10px; + font-weight: 600; + color: #fff; + background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple)); + border: none; + border-radius: 10px; + padding: 2px 8px; + margin-left: 4px; + letter-spacing: 0.8px; + user-select: none; + flex-shrink: 0; + box-shadow: 0 0 8px rgba(0, 245, 212, 0.3); + animation: version-pulse 3s ease-in-out infinite; +} + +@keyframes version-pulse { + 0%, 100% { box-shadow: 0 0 8px rgba(0, 245, 212, 0.25); } + 50% { box-shadow: 0 0 14px rgba(0, 245, 212, 0.45); } +} + +.header-right { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} + +/* ── 连接状态指示器 ── */ +.conn-status { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: 20px; + cursor: default; + transition: var(--transition); + font-size: 12px; + font-weight: 500; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + transition: var(--transition); +} + +.status-label { + white-space: nowrap; +} + +/* 已连接 - 绿色脉冲 */ +.conn-status.connected { + background: rgba(34, 197, 94, 0.12); + border: 1px solid rgba(34, 197, 94, 0.3); + color: var(--accent-green); +} +.conn-status.connected .status-dot { + background: var(--accent-green); + box-shadow: 0 0 6px var(--accent-green); + animation: pulse 2s ease-in-out infinite; +} + +/* 未连接 - 红色 */ +.conn-status.disconnected { + background: rgba(239, 68, 68, 0.12); + border: 1px solid rgba(239, 68, 68, 0.3); + color: var(--accent-red); +} +.conn-status.disconnected .status-dot { + background: var(--accent-red); + box-shadow: 0 0 6px var(--accent-red); +} + +/* 连接中 - 黄色闪烁 */ +.conn-status.pending { + background: rgba(234, 179, 8, 0.12); + border: 1px solid rgba(234, 179, 8, 0.3); + color: var(--accent-yellow); +} +.conn-status.pending .status-dot { + background: var(--accent-yellow); + box-shadow: 0 0 6px var(--accent-yellow); + animation: pulse 1s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; box-shadow: 0 0 6px currentColor; } + 50% { opacity: 0.5; box-shadow: 0 0 12px currentColor; } +} + +/* ── 图标按钮 ── */ +.icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + border: none; + background: transparent; + color: var(--text-secondary); + border-radius: var(--radius-sm); + cursor: pointer; + transition: var(--transition); +} + +.icon-btn:hover { + background: var(--bg-glass-hover); + color: var(--text-primary); +} + +.icon-btn svg { + width: 18px; + height: 18px; +} + +.icon-btn.sm { + width: 28px; + height: 28px; + font-size: 14px; + background: transparent; + border: none; + cursor: pointer; + opacity: 0.6; + transition: var(--transition); +} + +.icon-btn.sm:hover { opacity: 1; } +.icon-btn.danger:hover { color: var(--accent-red); } + +/* ═══ 模型选择栏 ═══ */ +.model-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 16px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-glass); + flex-shrink: 0; +} + +.model-select { + flex: 1; + padding: 6px 12px; + background: var(--bg-glass-light); + border: 1px solid var(--border-glass); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 13px; + outline: none; + cursor: pointer; + transition: var(--transition); +} + +.model-select:focus { + border-color: var(--accent-cyan); + box-shadow: 0 0 0 2px rgba(0, 245, 212, 0.1); +} + +.model-select option { + background: var(--bg-secondary); + color: var(--text-primary); +} + +/* ── 开关控件 ── */ +.toggle-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-secondary); + cursor: pointer; + user-select: none; + white-space: nowrap; +} + +.toggle-label input { display: none; } + +.toggle-slider { + position: relative; + width: 32px; + height: 18px; + background: var(--bg-glass-light); + border: 1px solid var(--border-glass); + border-radius: 9px; + transition: var(--transition); +} + +.toggle-slider::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 12px; + height: 12px; + background: var(--text-secondary); + border-radius: 50%; + transition: var(--transition); +} + +.toggle-label input:checked + .toggle-slider { + background: rgba(0, 245, 212, 0.2); + border-color: var(--accent-cyan); +} + +.toggle-label input:checked + .toggle-slider::after { + transform: translateX(14px); + background: var(--accent-cyan); + box-shadow: 0 0 6px var(--accent-cyan); +} + +/* ═══ 聊天区域 ═══ */ +.chat-area { + flex: 1; + overflow-y: auto; + overflow-x: hidden; + padding: 16px; + scroll-behavior: smooth; +} + +.chat-area::-webkit-scrollbar { + width: 6px; +} + +.chat-area::-webkit-scrollbar-track { + background: transparent; +} + +.chat-area::-webkit-scrollbar-thumb { + background: var(--border-glass); + border-radius: 3px; +} + +/* ── 空状态 ── */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + gap: 16px; + opacity: 0.6; +} + +.empty-icon svg { + width: 100px; + height: 100px; + opacity: 0.5; +} + +.empty-state h2 { + font-size: 20px; + font-weight: 600; + background: var(--gradient-main); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.empty-state p { + color: var(--text-muted); + font-size: 14px; +} + +/* ── 消息气泡 ── */ +.messages { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 800px; + margin: 0 auto; +} + +.message { + display: flex; + gap: 12px; + animation: fadeInUp 0.3s ease; +} + +@keyframes fadeInUp { + from { opacity: 0; transform: translateY(10px); } + to { opacity: 1; transform: translateY(0); } +} + +.msg-avatar { + flex-shrink: 0; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; + border-radius: var(--radius-sm); + background: var(--bg-glass-light); + border: 1px solid var(--border-glass); +} + +.msg-body { + flex: 1; + min-width: 0; +} + +.msg-role { + font-size: 12px; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 1px; + display: flex; + align-items: center; + gap: 8px; +} + +.model-tag { + display: inline-block; + font-size: 11px; + font-weight: 700; + text-transform: none; + letter-spacing: 0.3px; + color: #fff; + background: linear-gradient(135deg, var(--accent-cyan), var(--accent-purple)); + border-radius: 6px; + padding: 2px 10px; + line-height: 1.5; + white-space: nowrap; + box-shadow: 0 0 8px rgba(0, 245, 212, 0.2); +} + +.message.user .msg-body { + background: var(--bg-glass-light); + border: 1px solid var(--border-glass); + border-radius: var(--radius-md); + padding: 10px 14px; +} + +.message.assistant .msg-body { + background: rgba(0, 245, 212, 0.03); + border: 1px solid rgba(0, 245, 212, 0.08); + border-radius: var(--radius-md); + padding: 10px 14px; +} + +.msg-content { + font-size: 14px; + line-height: 1.7; + word-break: break-word; +} + +.msg-content pre { + background: rgba(0, 0, 0, 0.3); + border: 1px solid var(--border-glass); + border-radius: var(--radius-sm); + padding: 12px; + overflow-x: auto; + font-family: var(--font-mono); + font-size: 13px; + margin: 8px 0; +} + +.msg-content code { + background: rgba(0, 0, 0, 0.2); + padding: 2px 6px; + border-radius: 4px; + font-family: var(--font-mono); + font-size: 13px; +} + +.msg-content pre code { + background: none; + padding: 0; +} + +.msg-content p { margin-bottom: 8px; } +.msg-content p:last-child { margin-bottom: 0; } + +/* 思考块 */ +.think-block { + margin-bottom: 8px; + border: 1px solid rgba(234, 179, 8, 0.2); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.think-header { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: rgba(234, 179, 8, 0.05); + color: var(--accent-yellow); + font-size: 12px; + cursor: pointer; + user-select: none; +} + +.think-header .chevron { + margin-left: auto; + transition: transform 0.2s; +} + +.think-header .chevron.expanded { + transform: rotate(180deg); +} + +.think-content { + padding: 8px 12px; + background: rgba(234, 179, 8, 0.03); +} + +.think-content pre { + font-size: 12px; + color: var(--text-secondary); + white-space: pre-wrap; + word-break: break-word; + margin: 0; + background: none; + border: none; + padding: 0; +} + +/* 打字光标 */ +.typing-cursor { + display: inline-block; + animation: blink 0.8s infinite; + color: var(--accent-cyan); +} + +@keyframes blink { + 0%, 50% { opacity: 1; } + 51%, 100% { opacity: 0; } +} + +/* ═══ Loading 等待动画 ═══ */ +.loading-dots { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 0; +} + +.loading-dots span { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent-cyan); + animation: dotPulse 1.4s ease-in-out infinite; + box-shadow: 0 0 8px rgba(0, 245, 212, 0.5); +} + +.loading-dots span:nth-child(1) { animation-delay: 0s; } +.loading-dots span:nth-child(2) { animation-delay: 0.2s; } +.loading-dots span:nth-child(3) { animation-delay: 0.4s; } + +@keyframes dotPulse { + 0%, 80%, 100% { + transform: scale(0.4); + opacity: 0.3; + } + 40% { + transform: scale(1); + opacity: 1; + } +} + +.loading-text { + display: inline-block; + font-size: 13px; + color: var(--text-muted); + animation: loadingFade 1.5s ease-in-out infinite; +} + +@keyframes loadingFade { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; } +} + +/* 消息图片 */ +.msg-images { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 8px; +} + +.msg-img { + max-width: 200px; + max-height: 200px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-glass); + object-fit: cover; +} + +/* 统计信息 */ +.msg-stats { + display: flex; + gap: 12px; + margin-top: 6px; + font-size: 11px; + color: var(--text-muted); +} + +/* ═══ 输入区域 ═══ */ +.input-area { + flex-shrink: 0; + padding: 10px 16px 14px; + background: var(--bg-glass); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-top: 1px solid var(--border-glass); +} + +.image-preview { + display: flex; + gap: 8px; + padding: 8px 0; + flex-wrap: wrap; +} + +.preview-thumb { + position: relative; + width: 60px; + height: 60px; + border-radius: var(--radius-sm); + overflow: hidden; + border: 1px solid var(--border-glass); +} + +.preview-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.remove-img { + position: absolute; + top: 2px; + right: 2px; + width: 20px; + height: 20px; + border: none; + background: rgba(239, 68, 68, 0.9); + color: white; + border-radius: 50%; + font-size: 14px; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.input-row { + display: flex; + align-items: flex-end; + gap: 8px; +} + +.attach-btn { + flex-shrink: 0; +} + +.chat-input { + flex: 1; + padding: 10px 14px; + background: var(--bg-glass-light); + border: 1px solid var(--border-glass); + border-radius: var(--radius-md); + color: var(--text-primary); + font-family: var(--font-sans); + font-size: 14px; + resize: none; + outline: none; + min-height: 42px; + max-height: 120px; + line-height: 1.5; + transition: var(--transition); +} + +.chat-input:focus { + border-color: var(--accent-cyan); + box-shadow: 0 0 0 2px rgba(0, 245, 212, 0.1); +} + +.chat-input::placeholder { + color: var(--text-muted); +} + +.send-btn { + flex-shrink: 0; + width: 42px; + height: 42px; + border: none; + border-radius: var(--radius-md); + background: var(--gradient-main); + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition); + box-shadow: 0 2px 10px rgba(0, 245, 212, 0.3); +} + +.send-btn:hover { + transform: scale(1.05); + box-shadow: 0 4px 20px rgba(0, 245, 212, 0.4); +} + +.send-btn:active { + transform: scale(0.95); +} + +.send-btn.disabled { + opacity: 0.4; + cursor: not-allowed; + transform: none; + box-shadow: none; +} + +.send-btn svg { + width: 18px; + height: 18px; +} + +.spinner { + width: 18px; + height: 18px; + border: 2px solid rgba(255,255,255,0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ═══ 模态框 ═══ */ +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; + animation: fadeIn 0.2s ease; + padding: 16px; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.modal { + background: var(--bg-secondary); + border: 1px solid var(--border-glass); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-glass); + width: 100%; + max-width: 500px; + max-height: 80vh; + display: flex; + flex-direction: column; + animation: slideUp 0.3s ease; +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border-glass); +} + +.modal-header h3 { + font-size: 16px; + font-weight: 600; + background: var(--gradient-main); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.modal-body { + padding: 16px 20px; + overflow-y: auto; + flex: 1; +} + +/* ── 设置项 ── */ +.setting-group { + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--border-glass); +} + +.setting-group:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.setting-label { + display: flex; + align-items: center; + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); + margin-bottom: 8px; +} + +.setting-input { + width: 100%; + padding: 10px 12px; + background: var(--bg-glass-light); + border: 1px solid var(--border-glass); + border-radius: var(--radius-sm); + color: var(--text-primary); + font-size: 13px; + outline: none; + margin-bottom: 8px; + transition: var(--transition); +} + +.setting-input:focus { + border-color: var(--accent-cyan); +} + +/* ── 按钮 ── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 8px 16px; + border: none; + border-radius: var(--radius-sm); + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: var(--transition); + gap: 6px; +} + +.btn-primary { + background: var(--gradient-main); + color: white; +} + +.btn-primary:hover { + box-shadow: 0 4px 16px rgba(0, 245, 212, 0.3); +} + +.btn-outline { + background: transparent; + border: 1px solid var(--border-glass); + color: var(--text-secondary); +} + +.btn-outline:hover { + border-color: var(--accent-cyan); + color: var(--text-primary); +} + +.btn-danger { + background: rgba(239, 68, 68, 0.15); + border: 1px solid rgba(239, 68, 68, 0.3); + color: var(--accent-red); +} + +.btn-danger:hover { + background: rgba(239, 68, 68, 0.25); +} + +.btn-sm { + padding: 4px 10px; + font-size: 12px; +} + +/* ── 连接信息 ── */ +.connection-info { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 8px; +} + +.status-badge { + padding: 4px 10px; + border-radius: 20px; + font-size: 12px; + font-weight: 600; +} + +.status-badge.success { + background: rgba(34, 197, 94, 0.15); + color: var(--accent-green); + border: 1px solid rgba(34, 197, 94, 0.3); +} + +.status-badge.error { + background: rgba(239, 68, 68, 0.15); + color: var(--accent-red); + border: 1px solid rgba(239, 68, 68, 0.3); +} + +.status-badge.warning { + background: rgba(234, 179, 8, 0.15); + color: var(--accent-yellow); + border: 1px solid rgba(234, 179, 8, 0.3); +} + +.server-version { + font-size: 12px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.cors-hint { + padding: 10px; + background: rgba(234, 179, 8, 0.08); + border: 1px solid rgba(234, 179, 8, 0.2); + border-radius: var(--radius-sm); + margin-top: 8px; +} + +.cors-hint p { + font-size: 12px; + color: var(--accent-yellow); + margin-bottom: 6px; +} + +.cors-hint code { + display: block; + padding: 8px; + background: rgba(0, 0, 0, 0.3); + border-radius: 4px; + font-family: var(--font-mono); + font-size: 12px; + color: var(--accent-cyan); + user-select: all; +} + +/* ── 运行中的模型 ── */ +.running-models { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 10px; +} + +.running-model { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 12px; + background: var(--bg-glass-light); + border-radius: var(--radius-sm); + border: 1px solid var(--border-glass); +} + +.running-model .model-name { + flex: 1; + font-size: 13px; + font-weight: 500; +} + +.running-model .model-vram { + font-size: 12px; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.text-muted { + color: var(--text-muted); + font-size: 13px; +} + +/* ── 历史记录 ── */ +.empty-history { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 30px 0; + opacity: 0.5; +} + +.history-list { + display: flex; + flex-direction: column; + gap: 4px; +} + +/* 历史搜索栏 */ +.history-search-wrap { + position: relative; + margin-bottom: 10px; +} + +.history-search-input { + width: 100%; + padding: 10px 36px 10px 14px; + background: var(--bg-glass-light); + border: 1px solid var(--border-glass); + border-radius: var(--radius-md); + color: var(--text-primary); + font-size: 13px; + outline: none; + transition: var(--transition); +} + +.history-search-input::placeholder { + color: var(--text-muted); +} + +.history-search-input:focus { + border-color: var(--accent-cyan); + box-shadow: 0 0 0 2px rgba(0, 245, 212, 0.1); +} + +.history-search-clear { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: var(--text-muted); + cursor: pointer; + font-size: 14px; + padding: 4px; + line-height: 1; + border-radius: 4px; + transition: var(--transition); +} + +.history-search-clear:hover { + color: var(--text-primary); + background: rgba(255,255,255,0.06); +} + +/* 历史列表项 */ +.history-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid transparent; + transition: var(--transition); +} + +.history-item:hover { + background: var(--bg-glass-light); + border-color: var(--border-glass); +} + +.history-info { + flex: 1; + min-width: 0; + cursor: pointer; +} + +.history-title { + font-size: 13px; + font-weight: 500; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.history-meta { + display: flex; + gap: 10px; + font-size: 11px; + color: var(--text-muted); + margin-top: 2px; +} + +.history-actions { + display: flex; + gap: 2px; + flex-shrink: 0; +} + +/* ── 历史只读模式提示栏 ── */ +.history-view-bar { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 16px; + background: rgba(123, 47, 247, 0.15); + border-top: 1px solid rgba(123, 47, 247, 0.3); + font-size: 13px; + color: var(--accent-purple); + flex-shrink: 0; +} + +/* ── 历史记录分页 ── */ +.history-pagination { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 0 4px; + border-top: 1px solid var(--border-glass); + margin-top: 8px; +} + +.page-info { + font-size: 12px; + color: var(--text-muted); + white-space: nowrap; +} + +.page-buttons { + display: flex; + align-items: center; + gap: 4px; +} + +.page-btn { + min-width: 32px; + height: 32px; + border: 1px solid var(--border-glass); + background: var(--bg-glass-light); + color: var(--text-secondary); + border-radius: var(--radius-sm); + font-size: 13px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition); +} + +.page-btn:hover:not(.disabled):not(.active) { + border-color: var(--accent-cyan); + color: var(--text-primary); +} + +.page-btn.active { + background: rgba(0, 245, 212, 0.15); + border-color: var(--accent-cyan); + color: var(--accent-cyan); + font-weight: 600; +} + +.page-btn.disabled { + opacity: 0.3; + cursor: not-allowed; +} + +.page-ellipsis { + color: var(--text-muted); + font-size: 13px; + padding: 0 2px; +} + +/* ═══ 图片预览 Lightbox ═══ */ +.lightbox-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.85); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 300; + cursor: zoom-out; + animation: fadeIn 0.2s ease; +} + +.lightbox-img { + max-width: 90vw; + max-height: 90vh; + border-radius: var(--radius-md); + box-shadow: 0 0 40px rgba(0, 0, 0, 0.5); + object-fit: contain; + cursor: default; +} + +.lightbox-close { + position: fixed; + top: 16px; + right: 20px; + width: 40px; + height: 40px; + border: none; + background: rgba(255, 255, 255, 0.1); + color: white; + font-size: 20px; + border-radius: 50%; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: var(--transition); + z-index: 301; +} + +.lightbox-close:hover { + background: rgba(255, 255, 255, 0.2); +} + +/* 消息图片可点击提示 */ +.msg-img[data-lightbox] { + cursor: zoom-in; + transition: var(--transition); +} + +.msg-img[data-lightbox]:hover { + opacity: 0.85; + transform: scale(1.02); +} + +/* ═══ 响应式 - 平板及以上 ═══ */ +@media (min-width: 768px) { + .app-header { + padding: 12px 24px; + } + + .app-title { + font-size: 18px; + } + + .model-bar { + padding: 8px 24px; + } + + .chat-area { + padding: 24px; + } + + .messages { + max-width: 900px; + } + + .input-area { + padding: 12px 24px 16px; + } + + .modal { + max-width: 560px; + } +} + +/* ═══ 响应式 - 桌面 ═══ */ +@media (min-width: 1024px) { + .messages { + max-width: 960px; + } + + .msg-content { + font-size: 15px; + } + + /* 历史记录面板加宽 */ + #historyModal .modal { + max-width: 680px; + } +} + +@media (min-width: 1440px) { + #historyModal .modal { + max-width: 800px; + } +} + +/* ── 选中文本样式 ── */ +::selection { + background: rgba(0, 245, 212, 0.3); + color: white; +} + +/* ── 链接样式 ── */ +.msg-content a { + color: var(--accent-cyan); + text-decoration: none; +} + +.msg-content a:hover { + text-decoration: underline; +} + +/* 被 sanitizer 拦截的危险链接/图片 */ +.msg-content .blocked-link { + color: var(--accent-red); + text-decoration: line-through; + cursor: not-allowed; + font-size: 0.9em; + opacity: 0.7; +} + +/* ── 列表样式 ── */ +.msg-content ul, .msg-content ol { + padding-left: 20px; + margin: 8px 0; +} + +.msg-content li { + margin-bottom: 4px; +} + +/* ── 表格样式 ── */ +.msg-content table { + width: 100%; + border-collapse: collapse; + margin: 8px 0; + font-size: 13px; +} + +.msg-content th, .msg-content td { + padding: 6px 10px; + border: 1px solid var(--border-glass); + text-align: left; +} + +.msg-content th { + background: var(--bg-glass-light); + font-weight: 600; +} + +/* ── 引用块 ── */ +.msg-content blockquote { + border-left: 3px solid var(--accent-cyan); + padding-left: 12px; + margin: 8px 0; + color: var(--text-secondary); +} + +/* ═══ Toast 通知 ═══ */ +.toast-container { + position: fixed; + top: 60px; + right: 16px; + z-index: 200; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; +} + +.toast { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 16px; + border-radius: var(--radius-md); + background: var(--bg-secondary); + border: 1px solid var(--border-glass); + box-shadow: var(--shadow-glass); + font-size: 13px; + color: var(--text-primary); + pointer-events: auto; + animation: toastIn 0.3s ease; + max-width: 340px; + backdrop-filter: blur(12px); +} + +.toast.removing { + animation: toastOut 0.25s ease forwards; +} + +.toast-icon { + font-size: 16px; + flex-shrink: 0; +} + +.toast.success { border-color: rgba(34, 197, 94, 0.4); } +.toast.success .toast-icon { color: var(--accent-green); } + +.toast.error { border-color: rgba(239, 68, 68, 0.4); } +.toast.error .toast-icon { color: var(--accent-red); } + +.toast.warning { border-color: rgba(234, 179, 8, 0.4); } +.toast.warning .toast-icon { color: var(--accent-yellow); } + +.toast.info { border-color: rgba(0, 245, 212, 0.3); } +.toast.info .toast-icon { color: var(--accent-cyan); } + +@keyframes toastIn { + from { opacity: 0; transform: translateX(40px); } + to { opacity: 1; transform: translateX(0); } +} + +@keyframes toastOut { + from { opacity: 1; transform: translateX(0); } + to { opacity: 0; transform: translateX(40px); } +} diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..ae8682d --- /dev/null +++ b/sw.js @@ -0,0 +1,63 @@ +/** + * Service Worker - PWA 离线支持 + * 缓存应用资源以便离线使用 + */ + +const CACHE_NAME = 'metona-ollama-v3'; +const ASSETS = [ + './', + './index.html', + './style.css', + './chat-db.js', + './marked.esm.js', + './ollama-api.js', + './manifest.json' +]; + +// 安装阶段:预缓存核心资源 +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => cache.addAll(ASSETS)) + .then(() => self.skipWaiting()) + ); +}); + +// 激活阶段:清理旧缓存 +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then(keys => Promise.all( + keys.filter(key => key !== CACHE_NAME).map(key => caches.delete(key)) + )) + .then(() => self.clients.claim()) + ); +}); + +// 拦截请求:缓存优先,网络回退 +self.addEventListener('fetch', (event) => { + // 不拦截非 GET 请求 + if (event.request.method !== 'GET') return; + + event.respondWith( + caches.match(event.request) + .then(cached => { + if (cached) return cached; + return fetch(event.request).then(response => { + // 仅缓存成功的响应 + if (response && response.status === 200) { + const clone = response.clone(); + caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone)); + } + return response; + }).catch(() => { + // 离线且无缓存时返回简单提示 + if (event.request.destination === 'document') { + return new Response('离线模式:请检查网络连接', { + headers: { 'Content-Type': 'text/plain; charset=utf-8' } + }); + } + }); + }) + ); +});