diff --git a/Library/sstent/icalendar/PLUG.md b/Library/sstent/icalendar/PLUG.md deleted file mode 100644 index 3857e2c..0000000 --- a/Library/sstent/icalendar/PLUG.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: Library/sstent/icalendar -version: 0.3.1 -tags: meta/library -files: - - icalendar.plug.js ---- -iCalendar sync plug for SilverBullet. \ No newline at end of file diff --git a/Library/sstent/icalendar/icalendar.plug.js b/Library/sstent/icalendar/icalendar.plug.js deleted file mode 100644 index 30da660..0000000 --- a/Library/sstent/icalendar/icalendar.plug.js +++ /dev/null @@ -1,2 +0,0 @@ -function u(n){let e=atob(n),o=e.length,r=new Uint8Array(o);for(let t=0;t(...r)=>{let t=this.prefix?[this.prefix,...r]:r;this.originalConsole[o](...t),this.captureLog(o,r)};console.log=e("log"),console.info=e("info"),console.warn=e("warn"),console.error=e("error"),console.debug=e("debug")}captureLog(e,o){let r={level:e,timestamp:Date.now(),message:o.map(t=>{if(typeof t=="string")return t;try{return JSON.stringify(t)}catch{return String(t)}}).join(" ")};this.logBuffer.push(r),this.logBuffer.length>this.maxCaptureSize&&this.logBuffer.shift()}async postToServer(e,o){if(this.logBuffer.length>0){let t=[...this.logBuffer];this.logBuffer=[];try{if(!(await fetch(e,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(t.map(s=>({...s,source:o})))})).ok)throw new Error("Failed to post logs to server")}catch(a){console.warn("Could not post logs to server",a.message),this.logBuffer.unshift(...t)}}}},f;function h(n=""){return f=new l(n),f}var c=n=>{throw new Error("Not initialized yet")},p=typeof window>"u"&&typeof globalThis.WebSocketPair>"u";typeof Deno>"u"&&(self.Deno={args:[],build:{arch:"x86_64"},env:{get(){}}});var y=new Map,d=0;p&&(globalThis.syscall=async(n,...e)=>await new Promise((o,r)=>{d++,y.set(d,{resolve:o,reject:r}),c({type:"sys",id:d,name:n,args:e})}));function g(n,e,o){p&&(c=o,self.addEventListener("message",r=>{(async()=>{let t=r.data;switch(t.type){case"inv":{let a=n[t.name];if(!a)throw new Error(`Function not loaded: ${t.name}`);try{let s=await Promise.resolve(a(...t.args||[]));c({type:"invr",id:t.id,result:s})}catch(s){console.error("An exception was thrown as a result of invoking function",t.name,"error:",s.message),c({type:"invr",id:t.id,error:s.message})}}break;case"sysr":{let a=t.id,s=y.get(a);if(!s)throw Error("Invalid request id");y.delete(a),t.error?s.reject(new Error(t.error)):s.resolve(t.result)}break}})().catch(console.error)}),c({type:"manifest",manifest:e}),h(`[${e.name} plug]`))}async function b(n,e){if(typeof n!="string"){let o=new Uint8Array(await n.arrayBuffer()),r=o.length>0?i(o):void 0;e={method:n.method,headers:Object.fromEntries(n.headers.entries()),base64Body:r},n=n.url}return syscall("sandboxFetch.fetch",n,e)}globalThis.nativeFetch=globalThis.fetch;function x(){globalThis.fetch=async function(n,e){let o=e&&e.body?i(new Uint8Array(await new Response(e.body).arrayBuffer())):void 0,r=await b(n,e&&{method:e.method,headers:e.headers,base64Body:o});return new Response(r.base64Body?u(r.base64Body):null,{status:r.status,headers:r.headers})}}p&&x();var w={},m={name:"icalendar",version:"0.3.1",author:"sstent",functions:{syncCalendars:{command:"iCalendar: Sync"},forceSync:{command:"iCalendar: Force Sync"},clearCache:{command:"iCalendar: Clear Cache"},showVersion:{command:"iCalendar: Show Version"}},permissions:["http"],assets:{}},K={manifest:m,functionMapping:w};g(w,m,self.postMessage);export{K as plug}; -//# sourceMappingURL=icalendar.plug.js.map diff --git a/SilverBullet_digest.md b/SilverBullet_digest.md new file mode 100644 index 0000000..f4f62dd --- /dev/null +++ b/SilverBullet_digest.md @@ -0,0 +1,152393 @@ +Repository: https://github.com/silverbulletmd/silverbullet +Files analyzed: 606 + +Directory structure: +└── silverbulletmd-silverbullet/ + ├── .githooks + │ └── pre-commit + ├── .github + │ ├── workflows + │ │ ├── docker.yml + │ │ ├── edge.yml + │ │ ├── release.yml + │ │ ├── server.yml + │ │ └── test.yml + │ └── FUNDING.yml + ├── .helix + │ └── languages.toml + ├── .zed + ├── bench + │ └── lua.bench.ts + ├── bin + │ └── plug-compile.ts + ├── client + │ ├── asset_bundle + │ │ ├── builder.ts + │ │ ├── bundle.test.ts + │ │ └── bundle.ts + │ ├── codemirror + │ │ ├── admonition.ts + │ │ ├── attribute.ts + │ │ ├── block.ts + │ │ ├── block_quote.ts + │ │ ├── change.test.ts + │ │ ├── change.ts + │ │ ├── clean.ts + │ │ ├── cm_util.ts + │ │ ├── code_copy.ts + │ │ ├── code_widget.ts + │ │ ├── editor_paste.ts + │ │ ├── editor_state.ts + │ │ ├── escapes.ts + │ │ ├── fenced_code.ts + │ │ ├── frontmatter.ts + │ │ ├── hashtag.ts + │ │ ├── hide_mark.ts + │ │ ├── iframe_widget.ts + │ │ ├── inline_content.ts + │ │ ├── line_wrapper.ts + │ │ ├── link.ts + │ │ ├── lint.ts + │ │ ├── list.ts + │ │ ├── lua_directive.ts + │ │ ├── lua_widget.ts + │ │ ├── smart_quotes.ts + │ │ ├── spell_checking.ts + │ │ ├── table.ts + │ │ ├── task.ts + │ │ ├── top_bottom_panels.ts + │ │ ├── util.ts + │ │ ├── widget_util.ts + │ │ ├── wiki_link.ts + │ │ └── wiki_link_processor.ts + │ ├── components + │ │ ├── anything_picker.tsx + │ │ ├── basic_modals.tsx + │ │ ├── command_palette.tsx + │ │ ├── filter.tsx + │ │ ├── mini_editor.tsx + │ │ ├── panel.tsx + │ │ ├── panel_html.ts + │ │ ├── top_bar.tsx + │ │ └── widget_sandbox_iframe.ts + │ ├── data + │ │ ├── data_augmenter.test.ts + │ │ ├── data_augmenter.ts + │ │ ├── datastore.test.ts + │ │ ├── datastore.ts + │ │ ├── encrypted_kv_primitives.test.ts + │ │ ├── encrypted_kv_primitives.ts + │ │ ├── indexeddb_kv_primitives.test.ts + │ │ ├── indexeddb_kv_primitives.ts + │ │ ├── kv_primitives.test.ts + │ │ ├── kv_primitives.ts + │ │ ├── memory_kv_primitives.test.ts + │ │ ├── memory_kv_primitives.ts + │ │ ├── mq.datastore.test.ts + │ │ ├── mq.datastore.ts + │ │ └── object_index.ts + │ ├── fonts + │ │ ├── iAWriterMonoS-Bold.woff2 + │ │ ├── iAWriterMonoS-BoldItalic.woff2 + │ │ ├── iAWriterMonoS-Italic.woff2 + │ │ ├── iAWriterMonoS-Regular.woff2 + │ │ └── LICENSE.md + │ ├── html + │ │ ├── auth.html + │ │ └── index.html + │ ├── images + │ │ └── silver-bullet.sketch + │ ├── lib + │ │ ├── box_proxy.test.ts + │ │ ├── box_proxy.ts + │ │ ├── fuse_search.test.ts + │ │ ├── fuse_search.ts + │ │ ├── logger.test.ts + │ │ ├── logger.ts + │ │ ├── polyfills.ts + │ │ ├── url_prefix.test.ts + │ │ ├── url_prefix.ts + │ │ └── util.ts + │ ├── markdown_parser + │ │ ├── constants.ts + │ │ ├── customtags.ts + │ │ ├── extended_task.ts + │ │ ├── parse_tree.ts + │ │ ├── parser.test.ts + │ │ ├── parser.ts + │ │ └── table_parser.ts + │ ├── markdown_renderer + │ │ ├── html_render.test.ts + │ │ ├── html_render.ts + │ │ ├── inline.ts + │ │ ├── justified_tables.ts + │ │ ├── markdown_render.test.ts + │ │ ├── markdown_render.ts + │ │ └── result_render.ts + │ ├── plugos + │ │ ├── hooks + │ │ │ ├── code_widget.ts + │ │ │ ├── command.ts + │ │ │ ├── document_editor.ts + │ │ │ ├── event.ts + │ │ │ ├── mq.ts + │ │ │ ├── plug_namespace.ts + │ │ │ ├── slash_command.ts + │ │ │ └── syscall.ts + │ │ ├── sandboxes + │ │ │ ├── deno_worker_sandbox.ts + │ │ │ ├── sandbox.ts + │ │ │ ├── web_worker_sandbox.ts + │ │ │ └── worker_sandbox.ts + │ │ ├── syscalls + │ │ │ ├── asset.ts + │ │ │ ├── client_code_widget.ts + │ │ │ ├── clientStore.ts + │ │ │ ├── code_widget.ts + │ │ │ ├── config.ts + │ │ │ ├── datastore.ts + │ │ │ ├── editor.ts + │ │ │ ├── event.ts + │ │ │ ├── fetch.ts + │ │ │ ├── index.ts + │ │ │ ├── jsonschema.ts + │ │ │ ├── language.ts + │ │ │ ├── lua.ts + │ │ │ ├── markdown.ts + │ │ │ ├── mq.ts + │ │ │ ├── service_registry.ts + │ │ │ ├── shell.ts + │ │ │ ├── space.ts + │ │ │ ├── sync.ts + │ │ │ └── system.ts + │ │ ├── event.test.ts + │ │ ├── event.ts + │ │ ├── eventhook.ts + │ │ ├── manifest_cache.ts + │ │ ├── plug.ts + │ │ ├── plug_compile.ts + │ │ ├── protocol.ts + │ │ ├── proxy_fetch.ts + │ │ ├── system.ts + │ │ ├── types.ts + │ │ ├── util.ts + │ │ └── worker_runtime.ts + │ ├── service_worker + │ │ ├── proxy_router.ts + │ │ ├── sync_engine.ts + │ │ └── util.ts + │ ├── space_lua + │ │ ├── stdlib + │ │ │ ├── crypto.ts + │ │ │ ├── crypto_test.lua + │ │ │ ├── encoding.ts + │ │ │ ├── encoding_test.lua + │ │ │ ├── format.ts + │ │ │ ├── format_test.lua + │ │ │ ├── global_test.lua + │ │ │ ├── js.ts + │ │ │ ├── js_test.lua + │ │ │ ├── load.ts + │ │ │ ├── load_test.lua + │ │ │ ├── math.ts + │ │ │ ├── math_test.lua + │ │ │ ├── net.ts + │ │ │ ├── os.ts + │ │ │ ├── os_test.lua + │ │ │ ├── pattern.ts + │ │ │ ├── pattern_test.lua + │ │ │ ├── space_lua.ts + │ │ │ ├── space_lua_test.lua + │ │ │ ├── string.ts + │ │ │ ├── string_test.lua + │ │ │ ├── table.ts + │ │ │ └── table_test.lua + │ │ ├── arithmetic_test.lua + │ │ ├── ast.ts + │ │ ├── ast_narrow.ts + │ │ ├── break_async.test.ts + │ │ ├── close_attribute.test.ts + │ │ ├── const_attribute.test.ts + │ │ ├── context_errors.test.ts + │ │ ├── eval.test.ts + │ │ ├── eval.ts + │ │ ├── goto_test.lua + │ │ ├── labels.ts + │ │ ├── language_core_test.lua + │ │ ├── len_test.lua + │ │ ├── lua.grammar + │ │ ├── lua.test.ts + │ │ ├── lume_test.lua + │ │ ├── metamethods_test.lua + │ │ ├── numeric.ts + │ │ ├── parse-lua.js + │ │ ├── parse-lua.terms.js + │ │ ├── parse.test.ts + │ │ ├── parse.ts + │ │ ├── query_collection.test.ts + │ │ ├── query_collection.ts + │ │ ├── query_test.lua + │ │ ├── rp.bench.ts + │ │ ├── rp.ts + │ │ ├── runtime.test.ts + │ │ ├── runtime.ts + │ │ ├── stdlib.ts + │ │ ├── tonumber.ts + │ │ ├── tonumber_test.lua + │ │ ├── truthiness_test.lua + │ │ ├── util.test.ts + │ │ └── util.ts + │ ├── spaces + │ │ ├── checked_space_primitives.ts + │ │ ├── constants.ts + │ │ ├── datastore_space_primitives.test.ts + │ │ ├── datastore_space_primitives.ts + │ │ ├── evented_space_primitives.ts + │ │ ├── filtered_space_primitives.ts + │ │ ├── http_space_primitives.ts + │ │ ├── space_primitives.test.ts + │ │ ├── space_primitives.ts + │ │ ├── sync.test.ts + │ │ └── sync.ts + │ ├── styles + │ │ ├── colors.scss + │ │ ├── editor.scss + │ │ ├── main.scss + │ │ ├── modals.scss + │ │ ├── theme.scss + │ │ └── top.scss + │ ├── types + │ │ ├── command.ts + │ │ └── ui.ts + │ ├── boot.ts + │ ├── boot_config.test.ts + │ ├── boot_config.ts + │ ├── client.ts + │ ├── client_system.ts + │ ├── config.test.ts + │ ├── config.ts + │ ├── debug.ts + │ ├── document_editor.ts + │ ├── document_editor_js.ts + │ ├── editor_ui.tsx + │ ├── filtered_material_icons.ts + │ ├── languages.ts + │ ├── navigator.ts + │ ├── README.md + │ ├── reducer.ts + │ ├── service_registry.test.ts + │ ├── service_registry.ts + │ ├── service_worker.ts + │ ├── space.test.ts + │ ├── space.ts + │ ├── space_lua.ts + │ ├── space_lua_api.ts + │ └── style.ts + ├── client_bundle + │ └── bundle.go + ├── libraries + │ ├── Library + │ │ ├── Std + │ │ │ ├── APIs + │ │ │ │ ├── Action Button.md + │ │ │ │ ├── Command.md + │ │ │ │ ├── Date.md + │ │ │ │ ├── DOM.md + │ │ │ │ ├── Schema.md + │ │ │ │ ├── Tag.md + │ │ │ │ ├── Task State.md + │ │ │ │ ├── Template.md + │ │ │ │ ├── Virtual Page.md + │ │ │ │ └── Widget.md + │ │ │ ├── Editor + │ │ │ │ ├── Lua.md + │ │ │ │ └── Table.md + │ │ │ ├── Infrastructure + │ │ │ │ ├── Builtin Tags.md + │ │ │ │ ├── Export.md + │ │ │ │ ├── Github.md + │ │ │ │ ├── Library.md + │ │ │ │ ├── Page Templates.md + │ │ │ │ ├── Query Templates.md + │ │ │ │ ├── Share.md + │ │ │ │ ├── Slash Templates.md + │ │ │ │ ├── Tag Page.md + │ │ │ │ └── URI.md + │ │ │ ├── Page Templates + │ │ │ │ ├── Page Template.md + │ │ │ │ ├── Quick Note.md + │ │ │ │ └── Slash Template.md + │ │ │ ├── Pages + │ │ │ │ ├── Library Manager.md + │ │ │ │ ├── Maintenance.md + │ │ │ │ └── Space Overview.md + │ │ │ ├── Slash Commands + │ │ │ │ └── Slash.md + │ │ │ ├── Slash Templates + │ │ │ │ ├── code.md + │ │ │ │ ├── danger-admonition.md + │ │ │ │ ├── func.md + │ │ │ │ ├── hr.md + │ │ │ │ ├── lua-command.md + │ │ │ │ ├── lua-query.md + │ │ │ │ ├── lua-slash-command.md + │ │ │ │ ├── note-admonition.md + │ │ │ │ ├── query.md + │ │ │ │ ├── success-admonition.md + │ │ │ │ ├── table.md + │ │ │ │ ├── tpl.md + │ │ │ │ └── warning-admonition.md + │ │ │ ├── Widgets + │ │ │ │ ├── Embed.md + │ │ │ │ └── Widgets.md + │ │ │ └── Config.md + │ │ └── Std.md + │ └── Repositories + │ └── Std.md + ├── plug-api + │ ├── lib + │ │ ├── async.test.ts + │ │ ├── async.ts + │ │ ├── crypto.test.ts + │ │ ├── crypto.ts + │ │ ├── dates.test.ts + │ │ ├── dates.ts + │ │ ├── json.test.ts + │ │ ├── json.ts + │ │ ├── limited_map.test.ts + │ │ ├── limited_map.ts + │ │ ├── memory_cache.test.ts + │ │ ├── memory_cache.ts + │ │ ├── native_fetch.ts + │ │ ├── README.md + │ │ ├── ref.test.ts + │ │ ├── ref.ts + │ │ ├── resolve.test.ts + │ │ ├── resolve.ts + │ │ ├── tags.ts + │ │ ├── transclusion.ts + │ │ ├── tree.test.ts + │ │ ├── tree.ts + │ │ ├── yaml.test.ts + │ │ └── yaml.ts + │ ├── syscalls + │ │ ├── asset.ts + │ │ ├── client_store.ts + │ │ ├── code_widget.ts + │ │ ├── config.ts + │ │ ├── datastore.ts + │ │ ├── editor.ts + │ │ ├── event.ts + │ │ ├── index.ts + │ │ ├── jsonschema.ts + │ │ ├── language.ts + │ │ ├── lua.ts + │ │ ├── markdown.ts + │ │ ├── mq.ts + │ │ ├── shell.ts + │ │ ├── space.ts + │ │ ├── sync.ts + │ │ ├── system.ts + │ │ └── yaml.ts + │ ├── types + │ │ ├── client.ts + │ │ ├── config.ts + │ │ ├── datastore.ts + │ │ ├── event.ts + │ │ ├── index.ts + │ │ ├── manifest.ts + │ │ └── namespace.ts + │ ├── constants.ts + │ ├── README.md + │ ├── syscall.ts + │ ├── syscalls.ts + │ └── system_mock.ts + ├── plugs + │ ├── core + │ ├── editor + │ │ ├── complete.ts + │ │ ├── document.ts + │ │ ├── editor.ts + │ │ ├── help.ts + │ │ ├── navigate.ts + │ │ ├── outline.ts + │ │ ├── page.ts + │ │ ├── stats.ts + │ │ ├── system.ts + │ │ ├── text.ts + │ │ ├── upload.ts + │ │ └── vim.ts + │ ├── emoji + │ │ ├── .gitignore + │ │ ├── build.ts + │ │ ├── emoji.ts + │ │ └── Makefile + │ ├── image-viewer + │ │ └── viewer.ts + │ ├── index + │ │ ├── api.ts + │ │ ├── attribute.ts + │ │ ├── cheap_yaml.test.ts + │ │ ├── cheap_yaml.ts + │ │ ├── command.ts + │ │ ├── complete.ts + │ │ ├── constants.ts + │ │ ├── data.test.ts + │ │ ├── data.ts + │ │ ├── document.ts + │ │ ├── frontmatter.ts + │ │ ├── header.test.ts + │ │ ├── header.ts + │ │ ├── indexer.ts + │ │ ├── item.test.ts + │ │ ├── item.ts + │ │ ├── link.test.ts + │ │ ├── link.ts + │ │ ├── lint.ts + │ │ ├── page.test.ts + │ │ ├── page.ts + │ │ ├── paragraph.test.ts + │ │ ├── paragraph.ts + │ │ ├── plug_api.ts + │ │ ├── queue.ts + │ │ ├── refactor.ts + │ │ ├── snippet.test.ts + │ │ ├── snippet.ts + │ │ ├── space_lua.test.ts + │ │ ├── space_lua.ts + │ │ ├── space_style.test.ts + │ │ ├── space_style.ts + │ │ ├── table.test.ts + │ │ ├── table.ts + │ │ ├── tags.test.ts + │ │ ├── tags.ts + │ │ ├── task.ts + │ │ ├── widget.ts + │ │ └── yaml.ts + │ ├── plug-manager + │ │ └── plugmanager.ts + │ ├── sync + │ │ └── sync.ts + │ ├── builtin_plugs.ts + │ └── README.md + ├── scripts + │ ├── playground_space + │ │ └── index.md + │ ├── deploy_silverbullet_md.sh + │ ├── deploy_silverbullet_playground.sh + │ └── release.sh + ├── server + │ ├── cmd + │ │ ├── space_template + │ │ │ ├── CONFIG.md + │ │ │ └── index.md + │ │ ├── server.go + │ │ ├── upgrade.go + │ │ └── version.go + │ ├── auth.go + │ ├── auth_utils.go + │ ├── crypto.go + │ ├── crypto_test.go + │ ├── disk_space_primitives.go + │ ├── disk_space_primitives_test.go + │ ├── embed_space_primitives.go + │ ├── embed_space_primitives_test.go + │ ├── fs.go + │ ├── logs_endpoint.go + │ ├── manifest_endpoint.go + │ ├── prometheus.go + │ ├── proxy.go + │ ├── proxy_test.go + │ ├── read_only_space_primitives.go + │ ├── server.go + │ ├── shell_backend.go + │ ├── shell_backend_test.go + │ ├── shell_endpoint.go + │ ├── space_primitive_testing.go + │ ├── spaces.go + │ ├── ssr.go + │ ├── types.go + │ ├── util.go + │ ├── util_test.go + │ ├── version.go + │ └── version_test.go + ├── website + │ ├── API + │ │ ├── asset.md + │ │ ├── clientStore.md + │ │ ├── codeWidget.md + │ │ ├── command.md + │ │ ├── config.md + │ │ ├── datastore.md + │ │ ├── dom.md + │ │ ├── editor.md + │ │ ├── encoding.md + │ │ ├── event.md + │ │ ├── global.md + │ │ ├── http.md + │ │ ├── index.md + │ │ ├── js.md + │ │ ├── jsonschema.md + │ │ ├── language.md + │ │ ├── lua.md + │ │ ├── markdown.md + │ │ ├── math.md + │ │ ├── mq.md + │ │ ├── net.md + │ │ ├── os.md + │ │ ├── service.md + │ │ ├── shell.md + │ │ ├── slashCommand.md + │ │ ├── space.md + │ │ ├── spacelua.md + │ │ ├── string.md + │ │ ├── sync.md + │ │ ├── system.md + │ │ ├── table.md + │ │ ├── tag.md + │ │ ├── taskState.md + │ │ ├── template.md + │ │ ├── widget.md + │ │ └── yaml.md + │ ├── Architecture + │ │ └── Architecture.excalidraw + │ ├── Deployments + │ │ └── Caddy.md + │ ├── Install + │ │ ├── Binary.md + │ │ ├── Configuration.md + │ │ ├── Docker.md + │ │ └── Network and Internet.md + │ ├── Library + │ │ ├── Development.md + │ │ ├── Website Templates.md + │ │ └── Website.md + │ ├── Markdown + │ │ ├── Admonition.md + │ │ ├── Basics.md + │ │ ├── Extensions.md + │ │ ├── Fenced Code Block.md + │ │ ├── Hashtags.md + │ │ └── Syntax Highlighting.md + │ ├── Object + │ │ ├── aspiring-page.md + │ │ ├── data.md + │ │ ├── header.md + │ │ ├── item.md + │ │ ├── link.md + │ │ ├── page.md + │ │ ├── paragraph.md + │ │ ├── space-lua.md + │ │ ├── table.md + │ │ ├── tag.md + │ │ └── task.md + │ ├── Person + │ │ ├── John.md + │ │ └── Zef.md + │ ├── Plugs + │ │ └── Development.md + │ ├── Space Lua + │ │ ├── Conventions.md + │ │ ├── Lua Integrated Query.md + │ │ ├── Quirks.md + │ │ ├── Thread Locals.md + │ │ └── Widget.md + │ ├── Anything Picker.md + │ ├── API.md + │ ├── architecture.excalidraw + │ ├── Architecture.md + │ ├── Aspiring Pages.md + │ ├── Attribute.md + │ ├── Authelia.md + │ ├── Authentication Proxy.md + │ ├── Authentication.md + │ ├── Browser.md + │ ├── Client Encryption.md + │ ├── Command Palette.md + │ ├── Command.md + │ ├── CONFIG.md + │ ├── CONTAINER_BOOT.md + │ ├── Data Sovereignty.md + │ ├── Development.md + │ ├── Document Editor.md + │ ├── Document.md + │ ├── Editor.md + │ ├── End-User Programming.md + │ ├── Event.md + │ ├── Export.md + │ ├── Extensions.md + │ ├── Folder.md + │ ├── Frontmatter.md + │ ├── Full Text Search.md + │ ├── Funding.md + │ ├── HTTP API.md + │ ├── Index Page.md + │ ├── index.md + │ ├── Install.md + │ ├── Keyboard Shortcuts.md + │ ├── Knowledge Management.md + │ ├── Library Manager.md + │ ├── Library.md + │ ├── Link.md + │ ├── Linked Mention.md + │ ├── Linked Tasks.md + │ ├── Live Preview.md + │ ├── LLM Use.md + │ ├── Local First.md + │ ├── Log.md + │ ├── Lua.md + │ ├── Manual.md + │ ├── Markdown.md + │ ├── Meta Page.md + │ ├── Meta Picker.md + │ ├── Metadata.md + │ ├── Migrate from v1.md + │ ├── Names.md + │ ├── Object Index.md + │ ├── Object.md + │ ├── Open Source.md + │ ├── Outlines.md + │ ├── Page Decorations.md + │ ├── Page Namer.md + │ ├── Page Picker.md + │ ├── Page Template.md + │ ├── Page.md + │ ├── Paths.md + │ ├── Personal.md + │ ├── Platform.md + │ ├── Plugs.md + │ ├── Private.md + │ ├── Programmable.md + │ ├── PWA.md + │ ├── Repository.md + │ ├── Schema.md + │ ├── Self Hosted.md + │ ├── Service.md + │ ├── Share.md + │ ├── SilverBullet.md + │ ├── Slash Command.md + │ ├── Slash Templates.md + │ ├── Space Lua.md + │ ├── Space Style.md + │ ├── Space.md + │ ├── Special Pages.md + │ ├── Sync.md + │ ├── Tag Picker.md + │ ├── Tag.md + │ ├── Task.md + │ ├── Template.md + │ ├── TLS.md + │ ├── Top Bar.md + │ ├── Transclusions.md + │ ├── Troubleshooting.md + │ ├── URI.md + │ ├── Vim.md + │ ├── YAML.md + │ ├── Zef.md + │ └── Zero Tracking.md + ├── .air.toml + ├── .gitignore + ├── build_client.ts + ├── build_deps.ts + ├── build_plug_compile.ts + ├── build_plugs_libraries.ts + ├── CONTRIBUTING.md + ├── docker-entrypoint.sh + ├── Dockerfile + ├── Dockerfile.ci + ├── Dockerfile.website + ├── go.mod + ├── go.sum + ├── LICENSE.md + ├── Makefile + ├── README.md + ├── silverbullet.go + ├── STYLE.md + └── version.ts + + +================================================ +FILE: CONTRIBUTING.md +================================================ +So you're interested in helping out? That's great! + +First of all, please have a look at our [LLM use policy](https://silverbullet.md/LLM%20Use) (TL;DR: no). All good? Let's proceed. + +## Issuing PRs +Before issuing a PR, please run a few commands: + +```bash +# Run all tests +make test +# Reformat all code +make fmt +``` + +This ensures that the basics work. + + +================================================ +FILE: LICENSE.md +================================================ +Copyright 2022, Zef Hemel + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +================================================ +FILE: README.md +================================================ +![GitHub Repo stars](https://img.shields.io/github/stars/silverbulletmd/silverbullet) +![Docker Pulls](https://img.shields.io/docker/pulls/zefhemel/silverbullet) +![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/silverbulletmd/silverbullet/total) +![GitHub contributors](https://img.shields.io/github/contributors/silverbulletmd/silverbullet) + +# SilverBullet +SilverBullet is a Programmable, Private, Browser-based, Open Source, Self Hosted, Personal Knowledge Management Platform. + +_Yowza!_ That surely is a lot of adjectives to describe a browser-based Markdown editor programmable with Lua. + +Let’s get more specific. + +In SilverBullet you keep your content as a collection of Markdown Pages (called a Space). You navigate your space using the Page Picker like a traditional notes app, or through Links like a wiki (except they are bi-directional). + +If you are the **writer** type, you’ll appreciate SilverBullet as a clean Markdown editor with Live Preview. If you have more of an **outliner** personality, SilverBullet has Outlining tools for you. Productivity freak? Have a look at Tasks. More of a **database** person? You will appreciate Objects and Queries. + +And if you are comfortable **programming** a little bit — now we’re really talking. You will love _dynamically generating content_ with Space Lua (SilverBullet’s Lua dialect), or to use it to create custom Commands, Page Templates or Widgets. + +[Much more detail can be found on silverbullet.md](https://silverbullet.md) + +## Installing SilverBullet +Check out the [instructions](https://silverbullet.md/Install). + +## Developing SilverBullet +SilverBullet's frontend is written in [TypeScript](https://www.typescriptlang.org/) and built on top of the excellent [CodeMirror 6](https://codemirror.net/) editor component. Additional UI is built using [Preact](https://preactjs.com). [ESBuild](https://esbuild.github.io)) running through Deno is used to build both the front-end. + +The server backend is written in Go. + +If you're considering contributing changes, be aware of the [LLM use policy](https://silverbullet.md/LLM%20Use). + +## Code structure +* `client/`: The SilverBullet client, implemented with TypeScript +* `server/`: The SilverBullet server, written in Go +* `plugs`: Set of built-in plugs that are distributed with SilverBullet +* `libraries`: A set of libraries (space scripts, page templates, slash templates) distributed with SilverBullet +* `plug-api/`: Useful APIs for use in plugs + * `lib/`: Useful libraries to be used in plugs + * `syscalls/`: TypeScript wrappers around syscalls + * `types/`: Various (client) types that can be references from plugs +* `bin` + * `plug_compile.ts` the plug compiler +* `scripts/`: Useful scripts +* `website/`: silverbullet.md website content + +### Requirements +* [Deno](https://deno.com/): Used to build the frontend and plugs +* [Go](https://go.dev/): Used to build the backend + +It's convenient to also install [air](https://github.com/air-verse/air) for development, this will automatically rebuild both the frontend and backend when changes are made: + +```shell +go install github.com/air-verse/air@latest +``` +Make sure your `$GOPATH/bin` is in your $PATH. + +To build everything and run the server: + +```shell +air +``` + +Alternatively, to build: + +```shell +make +``` + +To run the resulting server: + +```shell +./silverbullet +``` + +### Useful development tasks + +```shell +# Clean all generated files +make clean +# Typecheck and lint all code +make check +# Format all code +make fmt +# Run all tests +make test +``` + +### Build a docker container +Note, you do not need Deno nor Go locally installed for this to work: + +```shell +docker build -t silverbullet . +``` + +To run: + +```shell +docker run -p 3000:3000 -v :/space silverbullet +``` + + +================================================ +FILE: STYLE.md +================================================ +A few notes on coding conventions used in this project. + +# Tooling + +Run these commands to perform type and style checks and automatically reformat code based on conventions: + +```bash +make check # Type check + lint frontend and backend +make fmt # Format all code +``` + +# Code Style Guidelines + +## TypeScript (client and plugs) + +### Import Organization + +```typescript +// Use relative imports by default (include .ts extension) +import { Space } from "./space.ts"; +// Use `type` for type-only imports (part of the lint rules) +import type { Command } from "./types/command.ts"; + +// Use package-prefixed absolute when available (defined in deno.json) +import { sleep } from "@silverbulletmd/silverbullet/lib/async"; +``` + +### TypeScript Conventions +* Use `type` over `interface` for object shapes +* Use `type` for unions and complex types +* Always type function parameters explicitly +* Type return values for public APIs +* Let TypeScript infer obvious variable types +* Use `any` for error objects in catch blocks + + +### Naming Conventions +- **Variables & functions:** `camelCase` +- **Classes:** `PascalCase` +- **Files:** `snake_case.ts` (e.g., `http_space_primitives.ts`) +- **Test files:** `*.test.ts` alongside source files +- **Types:** `PascalCase` (e.g., `PageMeta`, `SpacePrimitives`) +- **Constants:** `camelCase` + + +### Testing Patterns +TypeScript tests run with Deno. + +**Test structure:** +```typescript +Deno.test("Test description in plain English", async () => { + // Setup + const kv = new MemoryKvPrimitives(); + const space = new Space(new DataStoreSpacePrimitives(kv), eventHook); + + // Execute + await space.writePage("test", testPage); + + // Assert + assertEquals(await space.readPage("test"), testPage); +}); +``` + +**Assertions:** +```typescript +import { assertEquals, assertNotEquals } from "@std/assert"; + +assertEquals(actual, expected); +assertNotEquals(actual, notExpected); +``` + +**Test configuration:** +```typescript +Deno.test("Test with options", { + sanitizeResources: false, + sanitizeOps: false, +}, async () => { + // test code +}); +``` + +### Comments & Documentation + +Use JSDoc for public APIs: +```typescript +/** + * Lists all pages (files ending in .md) in the space. + * @param unfiltered - Whether to include filtered pages + * @returns A list of all pages represented as PageMeta objects + */ +export function listPages(unfiltered?: boolean): Promise { + return syscall("space.listPages", unfiltered); +} +``` + +Inline comments: +* In case of doubt: add comments around the _why_ of the code (not what) +* Add TODO comments for known issues + +```typescript +// Note: these events are dispatched asynchronously (not waiting for results) +this.eventHook.dispatchEvent(...); + +// TODO: Clean this up, this has become a god class +export class Client { +``` + +### Code Patterns + +**Definite assignment for late initialization:** +```typescript +class Client { + space!: Space; // Initialized in init() + editorView!: EditorView; +} +``` + +**Destructuring:** +```typescript +const { name, def } = command; +const { text, meta } = await this.space.readPage(name); +``` + +## Go (server) +Follow common Go conventions. `make fmt` also reformats Go code. + +## Lua (libraries) +See `website/Space Lua/Conventions` + +================================================ +FILE: build_client.ts +================================================ +import { copy } from "@std/fs"; +import { fileURLToPath } from "node:url"; + +import sass from "denosass"; + +import { patchDenoLibJS } from "./client/plugos/plug_compile.ts"; +import { denoPlugin, esbuild } from "./build_deps.ts"; + +// This builds the client and puts it into client_bundle/client + +export async function bundleAll(): Promise { + await buildCopyBundleAssets(); +} + +export async function copyAssets(dist: string) { + await Deno.mkdir(dist, { recursive: true }); + await copy("client/fonts", `${dist}`, { overwrite: true }); + await copy("client/html/index.html", `${dist}/index.html`, { + overwrite: true, + }); + await copy("client/html/auth.html", `${dist}/auth.html`, { + overwrite: true, + }); + await copy("client/images/favicon.png", `${dist}/favicon.png`, { + overwrite: true, + }); + await copy("client/images/logo.png", `${dist}/logo.png`, { + overwrite: true, + }); + await copy("client/images/logo-dock.png", `${dist}/logo-dock.png`, { + overwrite: true, + }); + + const compiler = sass( + Deno.readTextFileSync("client/styles/main.scss"), + { + load_paths: ["client/styles"], + }, + ); + await Deno.writeTextFile( + `${dist}/main.css`, + compiler.to_string("expanded") as string, + ); + + // HACK: Patch the JS by removing an invalid regex + let bundleJs = await Deno.readTextFile(`${dist}/client.js`); + bundleJs = patchDenoLibJS(bundleJs); + await Deno.writeTextFile(`${dist}/client.js`, bundleJs); +} + +async function buildCopyBundleAssets() { + await Deno.mkdir("client_bundle/client", { recursive: true }); + await Deno.mkdir("client_bundle/base_fs", { recursive: true }); + + console.log("Now ESBuilding the client and service workers..."); + + const result = await esbuild.build({ + entryPoints: [ + { + in: "client/boot.ts", + out: ".client/client", + }, + { + in: "client/service_worker.ts", + out: "service_worker", + }, + ], + outdir: "client_bundle/client", + absWorkingDir: Deno.cwd(), + bundle: true, + treeShaking: true, + sourcemap: "linked", + minify: true, + jsxFactory: "h", + // metafile: true, + jsx: "automatic", + jsxFragment: "Fragment", + jsxImportSource: "npm:preact@10.23.1", + plugins: [denoPlugin({ + configPath: fileURLToPath(new URL("./deno.json", import.meta.url)), + })], + }); + + if (result.metafile) { + const text = await esbuild.analyzeMetafile(result.metafile!); + console.log("Bundle info", text); + } + + // Patch the service_worker {{CACHE_NAME}} + let swCode = await Deno.readTextFile( + "client_bundle/client/service_worker.js", + ); + swCode = swCode.replaceAll("{{CACHE_NAME}}", `cache-${Date.now()}`); + await Deno.writeTextFile("client_bundle/client/service_worker.js", swCode); + + await copyAssets("client_bundle/client/.client"); + + console.log("Built!"); +} + +if (import.meta.main) { + await bundleAll(); + esbuild.stop(); +} + + +================================================ +FILE: build_deps.ts +================================================ +// deno-lint-ignore-file no-import-prefix + +// To avoid having to create import maps for plug-compile, we're centralizing the build dependencies here instead of putting them in deno.json + +import * as esbuild from "npm:esbuild@^0.27.3"; +import { denoPlugin } from "jsr:@deno/esbuild-plugin@^1.2.1"; + +export { esbuild }; +export { denoPlugin }; + + +================================================ +FILE: build_plug_compile.ts +================================================ +import { denoPlugin, esbuild } from "./build_deps.ts"; + +await Deno.mkdir("dist", { recursive: true }); +const result = await esbuild.build({ + entryPoints: { + "plug-compile": "./bin/plug-compile.ts", + }, + outdir: "dist", + format: "esm", + absWorkingDir: Deno.cwd(), + bundle: true, + metafile: false, + treeShaking: true, + logLevel: "error", + minify: true, + external: [ + // Exclude weird yarn detection modules + "pnpapi", + // Exclude some larger dependencies that can be downloaded on the fly + "npm:esbuild*", + "jsr:@deno/esbuild-plugin*", + ], + plugins: [denoPlugin({ + configPath: new URL("./deno.json", import.meta.url).pathname, + })], +}); +if (result.metafile) { + const text = await esbuild.analyzeMetafile(result.metafile!); + console.log("Bundle info", text); +} +// const plugBundleJS = await Deno.readTextFile("dist/plug-compile.js"); +// Patch output JS with import.meta.main override to avoid ESBuild CLI handling +// await Deno.writeTextFile( +// "dist/plug-compile.js", +// "import.meta.main = false;\n" + plugBundleJS, +// ); +console.log("Output in dist"); +esbuild.stop(); + + +================================================ +FILE: build_plugs_libraries.ts +================================================ +import * as path from "@std/path"; +import { esbuild } from "./build_deps.ts"; +import { compileManifests } from "./client/plugos/plug_compile.ts"; +import { builtinPlugNames } from "./plugs/builtin_plugs.ts"; +import { parseArgs } from "@std/cli/parse-args"; +import { fileURLToPath } from "node:url"; +import { copy } from "@std/fs"; +import { version } from "./version.ts"; + +// This builds all built-in plugs and libraries and puts them into client_bundle/base_fs + +if (import.meta.main) { + await updateVersionFile(); + const args = parseArgs(Deno.args, { + boolean: ["debug", "reload", "info"], + alias: { w: "watch" }, + }); + + const manifests = builtinPlugNames.map((name) => + `./plugs/${name}/${name}.plug.yaml` + ); + + const plugBundlePath = "client_bundle/base_fs"; + const targetDir = path.join(plugBundlePath, "Library", "Std", "Plugs"); + Deno.mkdirSync(targetDir, { recursive: true }); + Deno.mkdirSync("dist", { recursive: true }); + + // Copy Library files + await copy("libraries/Library", `${plugBundlePath}/Library`, { + overwrite: true, + }); + await copy("libraries/Repositories", `${plugBundlePath}/Repositories`, { + overwrite: true, + }); + + // Build the plugs + await compileManifests( + manifests, + targetDir, + { + debug: args.debug, + info: args.info, + configPath: fileURLToPath(new URL("deno.json", import.meta.url)), + }, + ); + esbuild.stop(); +} + +export async function updateVersionFile() { + const command = new Deno.Command("git", { + args: ["describe", "--tags", "--long"], + stdout: "piped", + stderr: "piped", + }); + + const { stdout } = await command.output(); + let commitVersion = new TextDecoder().decode(stdout).trim(); + + if (!commitVersion) { + // Probably no valid .git repo, fallback to GITHUB_SHA env var (used in CI) + commitVersion = `${version}-${Deno.env.get("GITHUB_SHA") || "unknown"}`; + } + + const versionFilePath = "./public_version.ts"; + // Write version to file with date in YYYY-MM-DDTHH-MM-SSZ format attached to the version + const versionContent = `export const publicVersion = "${commitVersion}-${ + new Date().toISOString().split(".")[0].replaceAll(":", "-").concat("Z") + }";`; + + await Deno.writeTextFile(versionFilePath, versionContent); +} + + +================================================ +FILE: docker-entrypoint.sh +================================================ +#!/bin/bash -e +# If a /space/CONTAINER_BOOT.md file exists, execute it as a bash script upon boot +if [ -f "/space/CONTAINER_BOOT.md" ]; then + echo "Executing CONTAINER_BOOT.md script" + bash /space/CONTAINER_BOOT.md & +fi + +# Check if UID and GID are passed as environment variables, if not, extract from the space folder owner +if [ -z "$PUID" ] && [ "$UID" == "0" ] ; then + # Get the UID of the folder owner + PUID=$(stat -c "%u" "$SB_FOLDER") + echo "Will run SilverBullet with UID $PUID, inferred from the owner of $SB_FOLDER (set PUID environment variable to override)" +fi +if [ -z "$PGID" ]; then + # Get the GID of the folder owner + PGID=$(stat -c "%g" "$SB_FOLDER") +fi + +if [ "$PUID" == "0" ] || [ "$UID" != "0" ]; then + # Will run SilverBullet as default user + /silverbullet $@ +else + echo "Creating 'silverbullet' group (with GID $PGID) and 'silverbullet' user (with UID $PUID) inside container" + # Create silverbullet user and group ad-hoc mapped to PUID and PGID if they don't already exist + getent group silverbullet > /dev/null || addgroup -g $PGID silverbullet + getent passwd silverbullet > /dev/null || adduser -D -H -G silverbullet -u $PUID silverbullet + args="$@" + # And run via su as requested PUID + echo "Running SilverBullet as user configured with PUID $PUID and PGID $PGID" + su silverbullet -s /bin/bash -c "/silverbullet $args" +fi + + +================================================ +FILE: silverbullet.go +================================================ +package main + +import ( + _ "embed" + + "github.com/silverbulletmd/silverbullet/client_bundle" + "github.com/silverbulletmd/silverbullet/server/cmd" +) + +//go:embed public_version.ts +var VersionFileText string + +func main() { + c := cmd.ServerCommand(client_bundle.BundledFiles) + c.AddCommand(cmd.VersionCommand(VersionFileText), cmd.UpgradeCommand(), cmd.UpgradeEdgeCommand()) + c.Execute() +} + + +================================================ +FILE: version.ts +================================================ +export const version = "2.4.1"; + + +================================================ +FILE: bench/lua.bench.ts +================================================ +import { parse } from "../client/space_lua/parse.ts"; +import { luaBuildStandardEnv } from "../client/space_lua/stdlib.ts"; +import { + LuaEnv, + LuaRuntimeError, + LuaStackFrame, +} from "../client/space_lua/runtime.ts"; +import { evalStatement } from "../client/space_lua/eval.ts"; +import { assert } from "@std/assert/assert"; +import { fileURLToPath } from "node:url"; + +Deno.bench("[Lua] Core language", async () => { + await runLuaTest("../client/space_lua/language_core_test.lua"); +}); + +Deno.bench("[Lua] Core language (length)", async () => { + await runLuaTest("../client/space_lua/len_test.lua"); +}); + +Deno.bench("[Lua] Core language (truthiness)", async () => { + await runLuaTest("../client/space_lua/truthiness_test.lua"); +}); + +Deno.bench("[Lua] Core language (arithmetic)", async () => { + await runLuaTest("../client/space_lua/arithmetic_test.lua"); +}); + +Deno.bench("[Lua] Load tests", async () => { + await runLuaTest("../client/space_lua/stdlib/load_test.lua"); +}); + +Deno.bench("[Lua] Table tests", async () => { + await runLuaTest("../client/space_lua/stdlib/table_test.lua"); +}); + +Deno.bench("[Lua] String to number tests", async () => { + await runLuaTest("../client/space_lua/tonumber_test.lua"); +}); + +Deno.bench("[Lua] String tests", async () => { + await runLuaTest("../client/space_lua/stdlib/string_test.lua"); + // await runLuaTest("../client/space_lua/stdlib/string_test2.lua"); +}); + +Deno.bench("[Lua] Space Lua tests", async () => { + await runLuaTest("../client/space_lua/stdlib/space_lua_test.lua"); +}); + +Deno.bench("[Lua] OS tests", async () => { + await runLuaTest("../client/space_lua/stdlib/os_test.lua"); +}); + +Deno.bench("[Lua] Math tests", async () => { + await runLuaTest("../client/space_lua/stdlib/math_test.lua"); +}); + +Deno.bench("[Lua] JS tests", async () => { + await runLuaTest("../client/space_lua/stdlib/js_test.lua"); +}); + +Deno.bench("[Lua] Global functions tests", async () => { + await runLuaTest("../client/space_lua/stdlib/global_test.lua"); +}); + +Deno.bench("[Lua] Encoding functions tests", async () => { + await runLuaTest("../client/space_lua/stdlib/encoding_test.lua"); +}); + +Deno.bench("[Lua] Lume functions tests", async () => { + await runLuaTest("../client/space_lua/lume_test.lua"); +}); + +async function runLuaTest(luaPath: string) { + const luaFile = await Deno.readTextFile( + fileURLToPath(new URL(luaPath, import.meta.url)), + ); + const chunk = parse(luaFile, {}); + const env = new LuaEnv(luaBuildStandardEnv()); + const sf = LuaStackFrame.createWithGlobalEnv(env, chunk.ctx); + + try { + await evalStatement(chunk, env, sf); + } catch (e: any) { + if (e instanceof LuaRuntimeError) { + console.error(`Error evaluating script:`, e.toPrettyString(luaFile)); + } else { + console.error(`Error evaluating script:`, e); + } + assert(false); + } +} + + +================================================ +FILE: bin/plug-compile.ts +================================================ +import.meta.main = false; +import { Command } from "@cliffy/command"; + +import { version } from "../version.ts"; + +import { plugCompileCommand } from "../client/plugos/plug_compile.ts"; + +await new Command() + .name("plug-compile") + .description("Bundle (compile) one or more plug manifests") + .version(version) + .helpOption(false) + .usage(" | (see below)") + .arguments("<...name.plug.yaml:string>") + .option("--debug", "Do not minifiy code", { default: false }) + .option("--info", "Print out size info per function", { + default: false, + }) + .option("--watch, -w [type:boolean]", "Watch for changes and rebuild", { + default: false, + }) + .option( + "--dist ", + "Folder to put the resulting .plug.json file into", + { default: "." }, + ) + .option("-c, --config ", "Path to deno.json file to use") + .option("--runtimeUrl ", "URL to worker_runtime.ts to use") + .action(plugCompileCommand) + .parse(Deno.args); + +Deno.exit(0); + + +================================================ +FILE: client/README.md +================================================ +SilverBullet-specific code that is shared between server and client + +================================================ +FILE: client/asset_bundle/builder.ts +================================================ +// import { globToRegExp, mime, path, walk } from "../deps_server.ts"; +import { globToRegExp } from "@std/path"; +import { AssetBundle } from "./bundle.ts"; +import { walk } from "@std/fs"; +import { mime } from "mimetypes"; + +export async function bundleAssets( + rootPath: string, + patterns: string[], +): Promise { + const bundle = new AssetBundle(); + if (patterns.length === 0) { + return bundle; + } + const matchRegexes = patterns.map((pat) => globToRegExp(pat)); + for await ( + const file of walk(rootPath) + ) { + const cleanPath = file.path.substring(rootPath.length + 1); + let match = false; + // console.log("Considering", rootPath, file.path, cleanPath); + for (const matchRegex of matchRegexes) { + if (matchRegex.test(cleanPath)) { + match = true; + break; + } + } + if (match) { + bundle.writeFileSync( + cleanPath, + mime.getType(cleanPath) || "application/octet-stream", + await Deno.readFile(file.path), + ); + } + } + return bundle; +} + + +================================================ +FILE: client/asset_bundle/bundle.test.ts +================================================ +import { AssetBundle } from "./bundle.ts"; +import { assertEquals } from "@std/assert"; + +Deno.test("Asset bundle", () => { + const assetBundle = new AssetBundle(); + assetBundle.writeTextFileSync("test.txt", "text/plain", "Sup yo"); + assertEquals("text/plain", assetBundle.getMimeType("test.txt")); + assertEquals("Sup yo", assetBundle.readTextFileSync("test.txt")); + const buf = new Uint8Array(3); + buf[0] = 1; + buf[1] = 2; + buf[2] = 3; + assetBundle.writeFileSync("test.bin", "application/octet-stream", buf); + assertEquals("application/octet-stream", assetBundle.getMimeType("test.bin")); + assertEquals(buf, assetBundle.readFileSync("test.bin")); +}); + + +================================================ +FILE: client/asset_bundle/bundle.ts +================================================ +import { + base64Decode, + base64EncodedDataUrl, +} from "../../plug-api/lib/crypto.ts"; + +type DataUrl = string; + +// Mapping from path -> `data:mimetype;base64,base64-encoded-data` strings +export type AssetJson = Record; + +export class AssetBundle { + readonly bundle: AssetJson; + + constructor(bundle: AssetJson = {}) { + this.bundle = bundle; + } + + has(path: string): boolean { + return path in this.bundle; + } + + listFiles(): string[] { + return Object.keys(this.bundle); + } + + readFileSync( + path: string, + ): Uint8Array { + const content = this.bundle[path]; + if (!content) { + throw new Error(`No such file ${path}`); + } + const data = content.data.split(",", 2)[1]; + return base64Decode(data); + } + + readFileAsDataUrl(path: string): string { + const content = this.bundle[path]; + if (!content) { + throw new Error(`No such file ${path}`); + } + return content.data; + } + + readTextFileSync( + path: string, + ): string { + return new TextDecoder().decode(this.readFileSync(path)); + } + + getMimeType( + path: string, + ): string { + const entry = this.bundle[path]; + if (!entry) { + throw new Error(`No such file ${path}`); + } + return entry.data.split(";")[0].split(":")[1]; + } + + getMtime(path: string): number { + const entry = this.bundle[path]; + if (!entry) { + throw new Error(`No such file ${path}`); + } + return entry.mtime; + } + + writeFileSync( + path: string, + mimeType: string, + data: Uint8Array, + mtime: number = Date.now(), + ) { + // Replace \ with / for windows + path = path.replaceAll("\\", "/"); + this.bundle[path] = { + data: base64EncodedDataUrl(mimeType, data), + mtime, + }; + } + + writeTextFileSync( + path: string, + mimeType: string, + s: string, + mtime: number = Date.now(), + ) { + this.writeFileSync(path, mimeType, new TextEncoder().encode(s), mtime); + } + + toJSON(): AssetJson { + return this.bundle; + } +} + + +================================================ +FILE: client/boot.ts +================================================ +import { race, safeRun, sleep } from "@silverbulletmd/silverbullet/lib/async"; +import { + notAuthenticatedError, + offlineError, + offlineStatusCodes, +} from "@silverbulletmd/silverbullet/constants"; +import { initLogger } from "./lib/logger.ts"; +import { extractSpaceLuaFromPageText, loadConfig } from "./boot_config.ts"; +import { Client } from "./client.ts"; +import type { Config } from "./config.ts"; +import { + flushCachesAndUnregisterServiceWorker, + unregisterServiceWorkers, +} from "./service_worker/util.ts"; +import "./lib/polyfills.ts"; +import type { BootConfig, ServiceWorkerTargetMessage } from "./types/ui.ts"; +import { BoxProxy } from "./lib/box_proxy.ts"; +import { importKey } from "@silverbulletmd/silverbullet/lib/crypto"; +import "./debug.ts"; +const logger = initLogger("[Client]"); + +if (!crypto.subtle) { + alert( + "You are likely accessing SilverBullet via HTTP (rather than HTTPS or localhost), this is not a supported configuration. See https://silverbullet.md/TLS", + ); +} + +safeRun(async () => { + // First we attempt to fetch the config from the server + let bootConfig: BootConfig | undefined; + let config: Config | undefined; + // Placeholder proxy for Client object to be swapped in later + const clientProxy = new BoxProxy({}); + let bootstrapLuaScriptPages: string[] = []; + // Try loading config and scripts + try { + let configJSONText: string; + [configJSONText, ...bootstrapLuaScriptPages] = await Promise + .all([ + cachedFetch(".config"), + // Some minimal bootstrap Lua: schema definition + cachedFetch(".fs/Library/Std/APIs/Schema.md"), + // Configuration option definitions and defaults + cachedFetch(".fs/Library/Std/Config.md"), + // Tag definition API + cachedFetch(".fs/Library/Std/APIs/Tag.md"), + // Custom configuration + cachedFetch(".fs/CONFIG.md"), + ]); + bootConfig = JSON.parse(configJSONText); + } catch (e: any) { + if (e.message === offlineError.message) { + alert( + "Could not process config and no cached copy, please connect to the Internet", + ); + // Not recoverable + return; + } + } + // Concatenate and evaluate + try { + config = await loadConfig( + bootstrapLuaScriptPages.map( + extractSpaceLuaFromPageText, + ).join("\n"), + clientProxy.buildProxy(), + ); + } catch (e: any) { + alert( + `Failed to run Space Lua code (likely CONFIG), will attempt to boot without. Error: ${e.message}`, + ); + console.error("Error evaluating Space Lua script on boot:", e); + try { + config = await loadConfig( + // Everything but CONFIG (at the end) + bootstrapLuaScriptPages.slice(0, bootstrapLuaScriptPages.length - 1) + .map( + extractSpaceLuaFromPageText, + ).join("\n"), + clientProxy.buildProxy(), + ); + } catch (e: any) { + console.error("Boot error", e); + alert( + "Could not load boot scripts, even without CONFIG, check your browser's logs", + ); + return; + } + } + + let encryptionKey: CryptoKey | undefined; + // If client encryption is enabled (from auth page) AND the server signals it + if ( + localStorage.getItem("enableEncryption") && + bootConfig?.enableClientEncryption + ) { + // Init encryption + console.log("Initializing encryption"); + const swController = navigator.serviceWorker.controller; + if (swController) { + // Service is already running, let's see if has an encryption key for me + console.log( + "Service worker already running, querying it for an encryption key", + ); + swController.postMessage( + { type: "get-encryption-key" } as ServiceWorkerTargetMessage, + ); + await race([ + new Promise((resolve) => { + function keyListener(e: any) { + if (e.data.type === "encryption-key") { + navigator.serviceWorker.removeEventListener( + "message", + keyListener, + ); + importKey(e.data.key).then((key) => { + encryptionKey = key; + resolve(); + }); + } + } + navigator.serviceWorker.addEventListener("message", keyListener); + }), + sleep(200), + ]); + if (!encryptionKey) { + // No encryption key, redirecting to the auth page + console.warn("Not authenticated, redirecting to auth page"); + location.href = ".auth"; + throw new Error("Not authenticated"); + } + } else { + // No service worker, no encryption key, redirecting to the auth page + console.warn("Not authenticated, redirecting to auth page"); + location.href = ".auth"; + throw new Error("Not authenticated"); + } + } else { + bootConfig!.enableClientEncryption = false; + } + + await augmentBootConfig(bootConfig!, config!); + + // Update the browser URL to no longer contain the query parameters using pushState + if (location.search) { + const newURL = new URL(location.href); + newURL.search = ""; + history.pushState({}, "", newURL.toString()); + } + console.log("Booting SilverBullet client"); + console.log("Boot config", bootConfig, config.values); + + if (localStorage.getItem("enableSW") !== "0" && navigator.serviceWorker) { + // Register service worker + const workerURL = new URL("service_worker.js", document.baseURI); + let startNotificationCount = 0; + let lastStartNotification = 0; + navigator.serviceWorker.addEventListener("message", (event) => { + if (event.data.type === "service-worker-started") { + // Service worker started, let's make sure it has the current config + console.log( + "Got notified that service worker has just started, sending config", + bootConfig, + ); + navigator.serviceWorker.ready.then((registration) => { + registration.active!.postMessage({ + type: "config", + config: bootConfig, + }); + }); + // Check for weird restart behavior + startNotificationCount++; + if (Date.now() - lastStartNotification > 5000) { + // Last restart was longer than 5s ago: this is fine + startNotificationCount = 0; + } + if (startNotificationCount > 2) { + // This is not normal. Safari sometimes gets stuck on a database connection if the service worker is updated which means it cannot boot properly + // the only know fix is to quit the browser and restart it + console.warn( + "Something is wrong with the sync engine, please quit your browser and restart it.", + ); + } + lastStartNotification = Date.now(); + } + }); + navigator.serviceWorker + .register(workerURL, { + type: "module", + //limit the scope of the service worker to any potential URL prefix + scope: workerURL.pathname.substring( + 0, + workerURL.pathname.lastIndexOf("/") + 1, + ), + }) + .then((registration) => { + console.log("Service worker registered..."); + + // Send the config + registration.active?.postMessage({ + type: "config", + config: bootConfig, + }); + + // Set up update detection + registration.addEventListener("updatefound", () => { + const newWorker = registration.installing; + console.log("New service worker installing..."); + + if (newWorker) { + newWorker.addEventListener("statechange", () => { + if ( + newWorker.state === "installed" && + navigator.serviceWorker.controller + ) { + console.log( + "New service worker installed and ready to take over.", + ); + // Force the new service worker to activate immediately + newWorker.postMessage({ type: "skip-waiting" }); + } + }); + } + }); + }); + } else { + console.info("Service worker disabled."); + } + const client = new Client( + document.getElementById("sb-root")!, + bootConfig!, + config!, + ); + if (bootConfig!.logPush) { + setInterval(() => { + logger.postToServer(".logs", "client"); + }, 1000); + } + // @ts-ignore: on purpose + globalThis.client = client; + clientProxy.setTarget(client); + await client.init(encryptionKey); + if (navigator.serviceWorker) { + navigator.serviceWorker.addEventListener("message", (event) => { + client.handleServiceWorkerMessage(event.data); + }); + } +}); + +/** + * Augments the boot config with values from the page's search params + * as well as well as Lua-based configuration from CONFIG + */ +async function augmentBootConfig(bootConfig: BootConfig, config: Config) { + // Pull out sync configuration + bootConfig.syncDocuments = config.get(["sync", "documents"], false); + let syncIgnore = config!.get(["sync", "ignore"], ""); + if (Array.isArray(syncIgnore)) { + syncIgnore = syncIgnore.join("\n"); + } + bootConfig.syncIgnore = syncIgnore; + + // Then we augment the config based on the URL arguments + const urlParams = new URLSearchParams(location.search); + if (urlParams.has("readOnly")) { + bootConfig.readOnly = true; + } + if (urlParams.has("disableSpaceLua")) { + bootConfig.disableSpaceLua = true; + } + if (urlParams.has("disablePlugs")) { + bootConfig.disablePlugs = true; + } + if (urlParams.has("disableSpaceStyle")) { + bootConfig.disableSpaceStyle = true; + } + if (urlParams.has("wipeClient")) { + bootConfig.performWipe = true; + } + if (urlParams.has("resetClient")) { + bootConfig.performReset = true; + } + if (urlParams.has("enableSW")) { + const val = urlParams.get("enableSW")!; + localStorage.setItem("enableSW", val); + if (val === "0") { + await flushCachesAndUnregisterServiceWorker(); + } + } +} + +if (!globalThis.indexedDB) { + alert( + "SilverBullet requires IndexedDB to operate and it is not available in your browser. Please use a recent version of Chrome, Firefox (not in private mode) or Safari.", + ); +} + +async function cachedFetch(path: string): Promise { + const cacheKey = `silverbullet.${document.baseURI}.${path}`; + try { + const response = await fetch(path, { + // We don't want to follow redirects, we want to get the redirect header in case of auth issues + redirect: "manual", + // Add short timeout in case of a bad internet connection, this would block loading of the UI + signal: AbortSignal.timeout(1000), + headers: { + "X-Sync-Mode": "1", + }, + }); + if (response.status in offlineStatusCodes) { + const text = localStorage.getItem(cacheKey); + if (text) { + console.info("Falling back to cache for", path); + return text; + } else { + throw offlineError; + } + } + if (response.type === "opaqueredirect") { + // We received an opaque redirect, there's little sensible we can do than unregister service workers and reload + console.log( + "Got opaque redirect, going to unregister service workers and reload", + ); + await unregisterServiceWorkers(); + console.log("Ok, now going to reload, let's hope for the best"); + location.reload(); + throw notAuthenticatedError; + } + const redirectHeader = response.headers.get("location"); + if (redirectHeader) { + console.info( + "Received an (authentication) redirect, redirecting to URL: " + + redirectHeader, + ); + location.href = redirectHeader; + throw notAuthenticatedError; + } + const text = await response.text(); + // Persist to localStorage + localStorage.setItem(cacheKey, text); + return text; + } catch (e: any) { + console.info("Falling back to cache for", path); + // We may be offline, let's see if we have a cached config + const text = localStorage.getItem(cacheKey); + if (text) { + // Yep! Let's use it + return text; + } else { + throw e; + } + } +} + + +================================================ +FILE: client/boot_config.test.ts +================================================ +import { assertEquals } from "@std/assert"; +import { extractSpaceLuaFromPageText, loadConfig } from "./boot_config.ts"; + +Deno.test("Test boot config", () => { + // One block present + assertEquals( + extractSpaceLuaFromPageText("Hello\n\n```space-lua\ntest()\n```\nMore"), + "test()", + ); + // Two blocks present + assertEquals( + extractSpaceLuaFromPageText( + "Hello\n\n```space-lua\ntest()\n```\nMore\n\n```space-lua\ntest2()\n```", + ), + "test()\ntest2()", + ); + // No lua present + assertEquals( + extractSpaceLuaFromPageText("Hello\n\n```lua\ntest()\n```\nMore"), + "", + ); +}); + +Deno.test("Test CONFIG lua eval", async () => { + // Test base case: no config code + let config = await loadConfig("", {}); + assertEquals(config.values, {}); + + // Check a few config sets + config = await loadConfig( + ` + config.set { + option1 = "pete" + } + config.set("optionObj.nested", 5) +`, + {}, + ); + assertEquals(config.values, { + option1: "pete", + optionObj: { + nested: 5, + }, + }); + + // Check random Lua code crap resilience + config = await loadConfig( + ` + config.set { + option1 = "pete" + } + slashCommand.define {} + local shouldSet = true + if shouldSet then + config.set("optionObj.nested", 5) + end +`, + {}, + ); + assertEquals(config.values, { + option1: "pete", + optionObj: { + nested: 5, + }, + }); +}); + + +================================================ +FILE: client/boot_config.ts +================================================ +import { + findNodeOfType, + traverseTree, +} from "@silverbulletmd/silverbullet/lib/tree"; +import { parseMarkdown } from "./markdown_parser/parser.ts"; +import { System } from "./plugos/system.ts"; +import { configSyscalls } from "./plugos/syscalls/config.ts"; +import { Config } from "./config.ts"; +import { luaBuildStandardEnv } from "./space_lua/stdlib.ts"; +import { exposeSyscalls } from "./space_lua_api.ts"; +import { parse } from "./space_lua/parse.ts"; +import { LuaEnv, LuaStackFrame } from "./space_lua/runtime.ts"; +import { evalStatement } from "./space_lua/eval.ts"; +import { editorSyscalls } from "./plugos/syscalls/editor.ts"; +import { markdownSyscalls } from "./plugos/syscalls/markdown.ts"; +import { languageSyscalls } from "./plugos/syscalls/language.ts"; +import { jsonschemaSyscalls } from "./plugos/syscalls/jsonschema.ts"; + +/** + * Parses a page (CONFIG in practice) and extracts all space-lua code + */ +export function extractSpaceLuaFromPageText(text: string): string { + const tree = parseMarkdown(text); + const codes: string[] = []; + traverseTree(tree, (node) => { + if (node.type === "FencedCode") { + const codeInfo = findNodeOfType(node, "CodeInfo"); + if (codeInfo?.children?.[0].text !== "space-lua") { + return false; + } + const codeText = findNodeOfType(node, "CodeText"); + codes.push(codeText!.children![0].text!); + return true; + } + return false; + }); + return codes.join("\n"); +} + +/** + * Runs the Lua code in a contained environment (only exposing config.* calls) to build up a config object + */ +export async function loadConfig( + luaCode: string, + lateBoundClient: any, +): Promise { + const config = new Config(); + + // We start with a standard env + const rootEnv = luaBuildStandardEnv(); + + // This is a system only used for the boot sequence, will be replaced later + const bootSystem = new System(); + // Only expose a limited set of syscalls that we can offer at this point + bootSystem.registerSyscalls( + [], + // Collecting the config.* calls is basically what we're here for + configSyscalls(config), + // This offers calls like isMobile() which will be useful, and late binding for e.g. to make actionButtons run() work immediately on boot + editorSyscalls(lateBoundClient), + // And these, because: why not + markdownSyscalls(lateBoundClient), + languageSyscalls(), + jsonschemaSyscalls(), + ); + + exposeSyscalls(rootEnv, bootSystem); + + // Parse the code + const chunk = parse(luaCode, {}); + const sf = LuaStackFrame.createWithGlobalEnv(rootEnv, chunk.ctx); + + // And eval + const localEnv = new LuaEnv(rootEnv); + for (const statement of chunk.statements) { + try { + await evalStatement(statement, localEnv, sf); + } catch (e: any) { + // Since people may do whatever in their Lua code, we're going to be extremely liberal in ignoring errors + // Primarily config.* calls are processed, the rest is implicitly ignored through failure + console.info( + "Statement errored out during boot, but ignoring:", + luaCode.slice(statement.ctx.from, statement.ctx.to), + "Error:", + e.message, + ); + } + } + return config; +} + + +================================================ +FILE: client/client.ts +================================================ +import type { + CompletionContext, + CompletionResult, +} from "@codemirror/autocomplete"; +import type { Compartment, EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import { syntaxTree } from "@codemirror/language"; +import { history, isolateHistory } from "@codemirror/commands"; +import type { SyntaxNode } from "@lezer/common"; +import { Space } from "./space.ts"; +import type { + AppEvent, + ClickEvent, + CompleteEvent, + EnrichedClickEvent, + FilterOption, + SlashCompletions, +} from "@silverbulletmd/silverbullet/type/client"; +import { EventHook } from "./plugos/hooks/event.ts"; +import type { Command } from "./types/command.ts"; +import { + type LocationState, + parseRefFromURI, + PathPageNavigator, +} from "./navigator.ts"; + +import type { + AppViewState, + BootConfig, + ServiceWorkerSourceMessage, + ServiceWorkerTargetMessage, +} from "./types/ui.ts"; + +import type { + PageCreatingContent, + PageCreatingEvent, +} from "@silverbulletmd/silverbullet/type/event"; +import type { StyleObject } from "../plugs/index/space_style.ts"; +import { jitter, throttle } from "@silverbulletmd/silverbullet/lib/async"; +import { EventedSpacePrimitives } from "./spaces/evented_space_primitives.ts"; +import { HttpSpacePrimitives } from "./spaces/http_space_primitives.ts"; +import { + encodePageURI, + encodeRef, + getNameFromPath, + getOffsetFromHeader, + getOffsetFromLineColumn, + isMarkdownPath, + parseToRef, + type Path, + type Ref, +} from "@silverbulletmd/silverbullet/lib/ref"; +import { ClientSystem } from "./client_system.ts"; +import { createEditorState, isValidEditor } from "./codemirror/editor_state.ts"; +import { MainUI } from "./editor_ui.tsx"; +import { DataStore } from "./data/datastore.ts"; +import { IndexedDBKvPrimitives } from "./data/indexeddb_kv_primitives.ts"; +import { DataStoreMQ } from "./data/mq.datastore.ts"; + +import { LimitedMap } from "@silverbulletmd/silverbullet/lib/limited_map"; +import { fsEndpoint } from "./spaces/constants.ts"; +import { diffAndPrepareChanges } from "./codemirror/cm_util.ts"; +import { DocumentEditor } from "./document_editor.ts"; +import { parseExpressionString } from "./space_lua/parse.ts"; +import type { Config } from "./config.ts"; +import type { + DocumentMeta, + FileMeta, + PageMeta, +} from "@silverbulletmd/silverbullet/type/index"; +import { parseMarkdown } from "./markdown_parser/parser.ts"; +import { CheckedSpacePrimitives } from "./spaces/checked_space_primitives.ts"; +import { + notFoundError, + offlineError, +} from "@silverbulletmd/silverbullet/constants"; +import { Augmenter } from "./data/data_augmenter.ts"; +import { EncryptedKvPrimitives } from "./data/encrypted_kv_primitives.ts"; +import type { KvPrimitives } from "./data/kv_primitives.ts"; +import { deriveDbName } from "@silverbulletmd/silverbullet/lib/crypto"; +import { LuaRuntimeError } from "./space_lua/runtime.ts"; +import { resolveASTReference } from "./space_lua.ts"; +import { ObjectIndex } from "./data/object_index.ts"; +import type { LuaCollectionQuery } from "./space_lua/query_collection.ts"; + +const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/; + +const autoSaveInterval = 1000; + +// Fetch the file list ever so often, this will implicitly kick off a snapshot comparison resulting in the indexing of changed pages +const fetchFileListInterval = 10000; + +declare global { + var client: Client; +} + +type WidgetCacheItem = { + html: string; + block?: boolean; + copyContent?: string; +}; + +// TODO: Clean this up, this has become a god class... +export class Client { + // Event bus used to communicate between components + eventHook: EventHook; + + space!: Space; + + clientSystem!: ClientSystem; + eventedSpacePrimitives!: EventedSpacePrimitives; + httpSpacePrimitives!: HttpSpacePrimitives; + + ui!: MainUI; + ds!: DataStore; + mq!: DataStoreMQ; + // Used to store additional pageMeta outside the page index itself persistent between client runs (specifically: lastOpened) + pageMetaAugmenter!: Augmenter; + // Used to store additional command data outside the objects themselves persistent between client rusn (specifically: lastRun) + commandAugmenter!: Augmenter; + + // CodeMirror editor + editorView!: EditorView; + commandKeyHandlerCompartment?: Compartment; + indentUnitCompartment?: Compartment; + undoHistoryCompartment?: Compartment; + + // Document editor + documentEditor: DocumentEditor | null = null; + saveTimeout?: number; + debouncedUpdateEvent = throttle(() => { + this.eventHook + .dispatchEvent("editor:updated") + .catch((e) => console.error("Error dispatching editor:updated event", e)); + }, 1000); + // Track if plugs have been updated since sync cycle + fullSyncCompleted = false; + + // Set to true once the system is ready (plugs loaded) + public systemReady: boolean = false; + private pageNavigator!: PathPageNavigator; + private onLoadRef: Ref; + // Progress circle handling + private progressTimeout?: number; + // Widget and image height caching + private widgetCache = new LimitedMap(100); // bodyText -> WidgetCacheItem + debouncedWidgetCacheFlush = throttle(() => { + this.ds.set(["cache", "widgets"], this.widgetCache.toJSON()) + .catch( + console.error, + ); + }, 2000); + private widgetHeightCache = new LimitedMap(1000); // bodytext -> height + debouncedWidgetHeightCacheFlush = throttle(() => { + this.ds.set( + ["cache", "widgetHeight"], + this.widgetHeightCache.toJSON(), + ) + .catch( + console.error, + ); + }, 2000); + objectIndex!: ObjectIndex; + + constructor( + private parent: Element, + public bootConfig: BootConfig, + readonly config: Config, + ) { + this.eventHook = new EventHook(this.config); + // The third case should only ever happen when the user provides an invalid index env variable + this.onLoadRef = parseRefFromURI() || this.getIndexRef(); + } + + /** + * Initialize the client + * This is a separated from the constructor to allow for async initialization + */ + async init(encryptionKey?: CryptoKey) { + const dbName = await deriveDbName( + "data", + this.bootConfig.spaceFolderPath, + document.baseURI.replace(/\/$/, ""), + encryptionKey, + ); + // Setup the KV (database) + let kvPrimitives: KvPrimitives = new IndexedDBKvPrimitives(dbName); + await (kvPrimitives as IndexedDBKvPrimitives).init(); + + console.log("Using IndexedDB database", dbName); + + // See if we need to encrypt this + if (encryptionKey) { + kvPrimitives = new EncryptedKvPrimitives( + kvPrimitives, + encryptionKey, + ); + await (kvPrimitives as EncryptedKvPrimitives).init(); + console.log("Enabled client-side encryption"); + } + // Wrap it in a datastore + this.ds = new DataStore(kvPrimitives); + + this.pageMetaAugmenter = new Augmenter(this.ds, ["aug", "pageMeta"]); + this.commandAugmenter = new Augmenter(this.ds, ["aug", "command"]); + + // Setup message queue on top of that + this.mq = new DataStoreMQ(this.ds, this.eventHook); + + this.objectIndex = new ObjectIndex( + this.ds, + this.config, + this.eventHook, + this.mq, + ); + + // Instantiate a PlugOS system + this.clientSystem = new ClientSystem( + this, + this.mq, + this.ds, + this.eventHook, + this.objectIndex, + this.bootConfig.readOnly, + ); + + this.initSpace(); + + this.ui = new MainUI(this); + this.ui.render(this.parent); + + this.editorView = new EditorView({ + state: createEditorState(this, "", "", true), + parent: document.getElementById("sb-editor")!, + }); + + this.focus(); + + this.clientSystem.init(); + + if (this.bootConfig.performWipe) { + if (confirm("Are you sure you want to wipe the client?")) { + await this.wipeClient(); + alert("Wipe done. Please reload the page or navigate away."); + return; + } + } + if (this.bootConfig.performReset) { + if ( + confirm( + "Are you sure you want to reset the client? This will wipe all local data and re-sync everything.", + ) + ) { + await this.wipeClient(); + location.reload(); + return; + } + } + + await this.loadCaches(); + + // Let's ping the remote space to ensure we're authenticated properly, if not will result in a redirect to auth page + try { + await this.httpSpacePrimitives.ping(); + } catch (e: any) { + if (e.message === "Not authenticated") { + console.warn("Not authenticated, redirecting to auth page"); + return; + } + console.warn( + "Could not reach remote server, we're offline or the server is down", + e, + ); + } + + // Load plugs + await this.loadPlugs(); + + await this.clientSystem.loadLuaScripts(); + await this.initNavigator(); + // await this.initSync(); + await this.eventHook.dispatchEvent("system:ready"); + this.systemReady = true; + + // Load space snapshot and enable events + await this.eventedSpacePrimitives.enable(); + + // Kick off a cron event interval + setInterval(() => { + this.dispatchAppEvent("cron:secondPassed"); + }, 1000); + + // We can load custom styles async + this.loadCustomStyles().catch(console.error); + + await this.dispatchAppEvent("editor:init"); + + // Reset Undo History after editor initialization. + client.editorView.dispatch({ + effects: client.undoHistoryCompartment?.reconfigure([]), + }); + client.editorView.dispatch({ + effects: client.undoHistoryCompartment?.reconfigure([history()]), + }); + + // Asynchronously update caches + this.updatePageListCache().catch(console.error); + this.updateDocumentListCache().catch(console.error); + } + + initSpace() { + this.httpSpacePrimitives = new HttpSpacePrimitives( + document.baseURI.replace(/\/*$/, "") + fsEndpoint, + this.bootConfig.spaceFolderPath, + (message, actionOrRedirectHeader) => { + alert(message); + if (actionOrRedirectHeader === "reload") { + location.reload(); + } else { + location.href = actionOrRedirectHeader; + } + }, + ); + + this.eventedSpacePrimitives = new EventedSpacePrimitives( + new CheckedSpacePrimitives( + this.httpSpacePrimitives, + this.bootConfig.readOnly, + ), + this.eventHook, + this.ds, + ); + + // Kick off a regular file listing request to trigger events + setInterval(() => { + this.eventedSpacePrimitives.fetchFileList(); + }, fetchFileListInterval + jitter()); + + this.eventHook.addLocalListener( + "file:changed", + async ( + name: string, + ) => { + console.log("Queueing index for", name); + await this.objectIndex.clearFileIndex(name); + await this.mq.send("indexQueue", name); + }, + ); + + const space = new Space( + this.eventedSpacePrimitives, + this.eventHook, + ); + + this.space = space; + + let lastSaveTimestamp: number | undefined; + + const updateLastSaveTimestamp = () => { + lastSaveTimestamp = Date.now(); + }; + + this.eventHook.addLocalListener( + "editor:pageSaving", + updateLastSaveTimestamp, + ); + + this.eventHook.addLocalListener( + "editor:documentSaving", + updateLastSaveTimestamp, + ); + + this.eventHook.addLocalListener( + "file:changed", + ( + path: string, + oldHash: number, + newHash: number, + ) => { + // Only reload when watching the current page or document (to avoid reloading when switching pages) + if ( + this.space.watchInterval && this.currentPath() === path && + // Avoid reloading if the page was just saved (5s window) + (!lastSaveTimestamp || (lastSaveTimestamp < Date.now() - 5000)) && + // Avoid reloading if the previous hash was undefined (first load) + oldHash !== undefined + ) { + console.log( + "Page changed elsewhere, reloading. Old hash", + oldHash, + "new hash", + newHash, + ); + this.flashNotification( + "Page or document changed elsewhere, reloading", + ); + this.reloadEditor(); + } + }, + ); + + // Caching a list of known files for the wiki_link highlighter (that checks if a file exists) + // And keeping it up to date as we go + this.eventHook.addLocalListener("file:changed", (fileName: string) => { + // Make sure this file is in the list of known pages + this.clientSystem.allKnownFiles.add(fileName); + }); + this.eventHook.addLocalListener("file:deleted", (fileName: string) => { + this.clientSystem.allKnownFiles.delete(fileName); + }); + this.eventHook.addLocalListener( + "file:listed", + (allFiles: FileMeta[]) => { + // Update list of known pages + this.clientSystem.allKnownFiles.clear(); + allFiles.forEach((f) => { + this.clientSystem.allKnownFiles.add(f.name); + }); + this.clientSystem.knownFilesLoaded = true; + }, + ); + + this.space.watch(); + } + + currentPath(): Path { + return this.ui.viewState.current?.path || this.onLoadRef.path; + } + + currentName(): string { + return getNameFromPath( + this.ui.viewState.current?.path || this.onLoadRef.path, + ); + } + + currentPageMeta(): PageMeta | undefined { + return this.ui.viewState.current?.meta; + } + + dispatchAppEvent(name: AppEvent, ...args: any[]): Promise { + return this.eventHook.dispatchEvent(name, ...args); + } + + dispatchClickEvent(clickEvent: ClickEvent) { + const editorState = this.editorView.state; + const sTree = syntaxTree(editorState); + const currentNode = sTree.resolveInner(clickEvent.pos); + + const parentNodes: string[] = this.extractParentNodes( + editorState, + currentNode, + ); + const enrichedEvent: EnrichedClickEvent = { + ...clickEvent, + parentNodes, + }; + return this.dispatchAppEvent("page:click", enrichedEvent); + } + + // Save the current page + save(immediate = false): Promise { + return new Promise((resolve, reject) => { + if (this.saveTimeout) { + clearTimeout(this.saveTimeout); + } + this.saveTimeout = setTimeout( + () => { + if ( + !this.ui.viewState.unsavedChanges || + this.isReadOnlyMode() + ) { + // No unsaved changes, or read-only mode, not gonna save + return resolve(); + } + + if (this.isDocumentEditor()) { + console.log("Requesting save for document", this.currentPath()); + this.dispatchAppEvent( + "editor:documentSaving", + this.currentPath(), + ); + + // Only thing we can really do is request a save + this.documentEditor.requestSave(); + + return resolve(); + } else { + console.log("Saving page", this.currentPath()); + this.dispatchAppEvent( + "editor:pageSaving", + this.currentName(), + ); + this.space + .writePage( + this.currentName(), + this.editorView.state.sliceDoc(0), + ) + .then(async (meta) => { + this.ui.viewDispatch({ type: "page-saved" }); + await this.dispatchAppEvent( + "editor:pageSaved", + this.currentName(), + meta, + ); + + // At this all the essential stuff is done, let's proceed + resolve(); + + // In the background we'll fetch any enriched meta data, if any + const enrichedMeta = await this.objectIndex.getObjectByRef< + PageMeta + >( + this.currentName(), + "page", + this.currentName(), + ); + if (enrichedMeta) { + this.ui.viewDispatch({ + type: "update-current-page-meta", + meta: enrichedMeta, + }); + + // Trigger editor re-render to update Lua widgets with the new metadata + this.editorView.dispatch({}); + } + }) + .catch((e) => { + this.flashNotification( + "Could not save page, retrying again in 10 seconds", + "error", + ); + this.saveTimeout = setTimeout(this.save.bind(this), 10000); + reject(e); + }); + } + }, + immediate ? 0 : autoSaveInterval, + ); + }); + } + + flashNotification(message: string, type: "info" | "error" = "info") { + const id = Math.floor(Math.random() * 1000000); + this.ui.viewDispatch({ + type: "show-notification", + notification: { + id, + type, + message, + date: new Date(), + }, + }); + setTimeout( + () => { + this.ui.viewDispatch({ + type: "dismiss-notification", + id: id, + }); + }, + type === "info" ? 4000 : 5000, + ); + } + + reportError(e: any, context: string = "") { + console.error(`Error during ${context}:`, e); + + if (e instanceof LuaRuntimeError) { + this.flashNotification(`Lua error: ${e.message}`, "error"); + const origin = resolveASTReference(e.sf.astCtx!); + if (origin) { + client.navigate(origin); + } + } else { + this.flashNotification(`Error: ${e.message}`, "error"); + } + } + + startPageNavigate(mode: "page" | "meta" | "document" | "all") { + // Then show the page navigator + this.ui.viewDispatch({ type: "start-navigate", mode }); + // And update the page list cache asynchronously + this.updatePageListCache().catch(console.error); + this.updateDocumentListCache().catch(console.error); + } + + queryLuaObjects( + tag: string, + query: LuaCollectionQuery, + scopedVariables?: Record, + ): Promise { + return this.objectIndex.queryLuaObjects( + this.clientSystem.spaceLuaEnv.env, + tag, + query, + scopedVariables, + ); + } + + async updatePageListCache() { + console.log("Updating page list cache"); + // Check if the initial sync has been completed + const initialIndexCompleted = await this.objectIndex + .hasFullIndexCompleted(); + + let allPages: PageMeta[] = []; + + if (initialIndexCompleted) { + console.log( + "Initial index complete, loading full page list via index.", + ); + // Fetch indexed pages + allPages = await this.queryLuaObjects("page", {}); + // Overlay augmented meta values + await this.pageMetaAugmenter.augmentObjectArray(allPages, "ref"); + // Fetch aspiring pages + const aspiringPageNames = await this.queryLuaObjects( + "aspiring-page", + { select: parseExpressionString("name"), distinct: true }, + ); + // Fetch any augmented page meta data (for now only lastOpened) + // this.clientSystem.ds.query({prefix: }) + // Map and push aspiring pages directly into allPages + allPages.push( + ...aspiringPageNames.map((name): PageMeta => ({ + ref: name, + tag: "page", + _isAspiring: true, + name: name, + created: "", // Aspiring pages don't have timestamps yet + lastModified: "", // Aspiring pages don't have timestamps yet + perm: "rw", + })), + ); + } else { + console.log( + "Initial sync not complete or index plug not loaded. Fetching page list directly using space.fetchPageList().", + ); + try { + // Call fetchPageList directly + allPages = await this.space.fetchPageList(); + + // Let's do some heuristic-based post processing + for (const page of allPages) { + // These are _mostly_ meta pages, let's add a tag for them + if (page.name.startsWith("Library/")) { + page.tags = ["meta"]; + } + } + } catch (e) { + console.error("Failed to list pages directly from space:", e); + // Handle error, maybe show notification or leave list empty + this.flashNotification( + "Could not fetch page list directly.", + "error", + ); + } + } + + this.ui.viewDispatch({ + type: "update-page-list", + allPages: allPages, + }); + + // Async kick-off file listing to bring listing up to date + this.space.spacePrimitives.fetchFileList(); + } + + async updateDocumentListCache() { + console.log("Updating document list cache"); + const allDocuments = await this.queryLuaObjects( + "document", + {}, + ); + + this.ui.viewDispatch({ + type: "update-document-list", + allDocuments: allDocuments, + }); + } + + async startCommandPalette() { + const commands = this.ui.viewState.commands; + await this.commandAugmenter.augmentObjectMap(commands); + this.ui.viewDispatch({ + type: "show-palette", + commands, + context: client.getContext(), + }); + } + + /** + * Saves when a command was last run to the datastore for command palette ordering + */ + async registerCommandRun(name: string) { + await this.commandAugmenter.setAugmentation(name, { + lastRun: Date.now(), + }); + } + + showProgress(progressPercentage?: number, progressType?: "sync" | "index") { + // console.log("Showing progress", progressPercentage, progressType); + this.ui.viewDispatch({ + type: "set-progress", + progressPercentage, + progressType, + }); + if (this.progressTimeout) { + clearTimeout(this.progressTimeout); + } + this.progressTimeout = setTimeout( + () => { + this.ui.viewDispatch({ + type: "set-progress", + }); + }, + 5000, + ); + } + + // Various UI elements + filterBox( + label: string, + options: FilterOption[], + helpText = "", + placeHolder = "", + ): Promise { + return new Promise((resolve) => { + this.ui.viewDispatch({ + type: "show-filterbox", + label, + options, + placeHolder, + helpText, + onSelect: (option: any) => { + this.ui.viewDispatch({ type: "hide-filterbox" }); + this.focus(); + resolve(option); + }, + }); + }); + } + + prompt( + message: string, + defaultValue = "", + ): Promise { + return new Promise((resolve) => { + this.ui.viewDispatch({ + type: "show-prompt", + message, + defaultValue, + callback: (value: string | undefined) => { + this.ui.viewDispatch({ type: "hide-prompt" }); + this.focus(); + resolve(value); + }, + }); + }); + } + + confirm( + message: string, + ): Promise { + return new Promise((resolve) => { + this.ui.viewDispatch({ + type: "show-confirm", + message, + callback: (value: boolean) => { + this.ui.viewDispatch({ type: "hide-confirm" }); + this.focus(); + resolve(value); + }, + }); + }); + } + + async loadPlugs() { + await this.clientSystem.reloadPlugsFromSpace(this.space); + await this.dispatchAppEvent("plugs:loaded"); + } + + rebuildEditorState() { + const editorView = this.editorView; + + editorView.setState( + createEditorState( + this, + this.currentName(), + editorView.state.sliceDoc(), + this.currentPageMeta()?.perm === "ro", + ), + ); + } + + // Code completion support + async completeWithEvent( + context: CompletionContext, + eventName: AppEvent, + ): Promise { + const editorState = context.state; + const selection = editorState.selection.main; + const line = editorState.doc.lineAt(selection.from); + const linePrefix = line.text.slice(0, selection.from - line.from); + + // Build up list of parent nodes, some completions need this + const sTree = syntaxTree(editorState); + const currentNode = sTree.resolveInner(editorState.selection.main.from); + + const parentNodes: string[] = this.extractParentNodes( + editorState, + currentNode, + ); + + // Dispatch the event + const results = await this.dispatchAppEvent(eventName, { + pageName: this.currentName(), + linePrefix, + pos: selection.from, + parentNodes, + } as CompleteEvent); + + // Merge results + let currentResult: CompletionResult | null = null; + for (const result of results) { + if (!result) { + continue; + } + if (currentResult) { + // Let's see if we can merge results + if (currentResult.from !== result.from) { + console.error( + "Got completion results from multiple sources with different `from` locators, cannot deal with that", + ); + console.error( + "Previously had", + currentResult, + "now also got", + result, + ); + return null; + } else { + // Merge + currentResult = { + from: result.from, + options: [...currentResult.options, ...result.options], + }; + } + } else { + currentResult = result; + } + } + return currentResult; + } + + isReadOnlyMode(): boolean { + return this.bootConfig.readOnly || + this.currentPageMeta()?.perm === "ro"; + } + + public extractParentNodes(editorState: EditorState, currentNode: SyntaxNode) { + const parentNodes: string[] = []; + if (currentNode) { + let node: SyntaxNode | null = currentNode; + do { + if (["FencedCode", "FrontMatter"].includes(node.name)) { + const body = editorState.sliceDoc(node.from + 3, node.to - 3); + parentNodes.push(`${node.name}:${body}`); + } else if (node.name === "LuaDirective") { + const body = editorState.sliceDoc(node.from + 2, node.to - 1); + parentNodes.push(`${node.name}:${body}`); + } else { + parentNodes.push(node.name); + } + node = node.parent; + } while (node); + } + return parentNodes; + } + + editorComplete( + context: CompletionContext, + ): Promise { + return this.completeWithEvent(context, "editor:complete") as Promise< + CompletionResult | null + >; + } + + async reloadEditor() { + if (!this.systemReady) return; + + console.log("Reloading editor"); + clearTimeout(this.saveTimeout); + + try { + if (isMarkdownPath(this.currentPath())) { + await this.loadPage({ path: this.currentPath() }, false); + } else { + await this.loadDocumentEditor({ path: this.currentPath() }); + } + } catch { + console.log(this.currentPath()); + console.error("There was an error during reload"); + } + } + + // Focus the editor + focus() { + const viewState = this.ui.viewState; + if ( + [ + viewState.showCommandPalette, + viewState.showPageNavigator, + viewState.showFilterBox, + viewState.showConfirm, + viewState.showPrompt, + ].some(Boolean) + ) { + // console.log("not focusing"); + // Some other modal UI element is visible, don't focus editor now + return; + } + + if (this.isDocumentEditor()) { + this.documentEditor.focus(); + } else { + this.editorView.focus(); + } + } + + getIndexRef(): Ref { + return parseToRef(this.bootConfig.indexPage) || { path: "index.md" }; + } + + async navigate( + ref: Ref | null, + replaceState = false, + newWindow = false, + ) { + ref ??= this.getIndexRef(); + + if (newWindow) { + console.log( + "Navigating to new page in new window", + `${document.baseURI}${encodePageURI(encodeRef(ref))}`, + ); + const win = globalThis.open( + `${document.baseURI}${encodePageURI(encodeRef(ref))}`, + "_blank", + ); + if (win) { + win.focus(); + } + return; + } + + await this.pageNavigator!.navigate( + ref, + replaceState, + ); + this.focus(); + } + + async loadDocumentEditor(locationState: LocationState) { + const path = locationState.path; + if (isMarkdownPath(path)) throw Error("This is a markdown path"); + + const previousPath = this.ui.viewState.current?.path; + const loadingDifferentPath = previousPath + ? (previousPath !== path) + // Always load as different editor if editor is loaded from scratch + : true; + + if (previousPath) { + this.space.unwatchFile(previousPath); + await this.save(true); + } + + // This can throw, but that will be catched and handled upstream. + const doc = await this.space.readDocument(path); + + // Create the document editor if it doesn't already exist + if ( + !this.isDocumentEditor() || + this.documentEditor.extension !== doc.meta.extension + ) { + try { + await this.switchToDocumentEditor(doc.meta.extension); + } catch (e: any) { + // If there is no document editor we will open the file raw + if (e.message.includes("Couldn't find")) { + this.openUrl(fsEndpoint + "/" + path, !previousPath); + } + + throw e; + } + + if (!this.isDocumentEditor()) { + throw new Error("Problem setting up document editor"); + } + } + + this.documentEditor!.openFile(doc.data, doc.meta, locationState.details); + + this.space.watchFile(path); + + this.ui.viewDispatch({ + type: "document-editor-loaded", + meta: doc.meta, + path: path, + }); + + this.eventHook.dispatchEvent( + loadingDifferentPath + ? "editor:documentLoaded" + : "editor:documentReloaded", + path, + previousPath, + ).catch(console.error); + } + + async loadPage( + locationState: LocationState, + navigateWithinPage: boolean = true, + ) { + const path = locationState.path; + if (!isMarkdownPath(path)) throw Error("This is not a markdown path"); + + const previousPath = this.ui.viewState.current?.path; + const loadingDifferentPath = previousPath + ? (previousPath !== path) + // Always load as different page if page is loaded from scratch + : true; + const pageName = getNameFromPath(path); + + if (previousPath) { + this.space.unwatchFile(previousPath); + await this.save(true); + } + + // Fetch next page to open + let doc; + let markerIndex = -1; + try { + doc = await this.space.readPage(pageName); + } catch (e: any) { + if ( + e.message !== notFoundError.message && + e.message !== offlineError.message + ) { + // If the error is not a "not found" or "offline" error, rethrow it + throw e; + } + + if (e.message === offlineError.message) { + console.info( + "Currently offline, will assume page doesn't exist:", + pageName, + ); + } + + // Scenarios: + // 1. We got a not found error -> Create an empty page + // 2. We got a offline error (which meant that the service worker didn't locally retrieve the page either so likely it doesn't exist) -> Create a new page + // Either way... we create an empty page! + + console.log(`Page doesn't exist, creating new page: ${pageName}`); + + // Mock up the page. We won't yet safe it, because the user may not even + // want to create that page + doc = { + text: "", + meta: { + ref: pageName, + tags: ["page"], + name: pageName, + lastModified: "", + created: "", + perm: "rw", + } as PageMeta, + }; + + // Let's dispatch a editor:pageCreating event to see if anybody wants to do something before the page is created + const results = await this.dispatchAppEvent( + "editor:pageCreating", + { name: pageName } as PageCreatingEvent, + ) as PageCreatingContent[]; + + if (results.length === 1) { + doc.text = results[0].text; + doc.meta.perm = results[0].perm; + // check for |^| and remove it; record position to place cursor later + const cursorMarker = "|^|"; + const idx = doc.text.indexOf(cursorMarker); + if (idx !== -1) { + markerIndex = idx; + doc.text = doc.text.slice(0, idx) + + doc.text.slice(idx + cursorMarker.length); + } + } else if (results.length > 1) { + console.error( + "Multiple responses for editor:pageCreating event, this is not supported", + ); + } + } + + // This could create an invalid editor state, but that doesn't matter, we'll update it later + this.switchToPageEditor(); + + await this.pageMetaAugmenter.setAugmentation(pageName, { + lastOpened: Date.now(), + }); + + this.ui.viewDispatch({ + type: "page-loaded", + meta: doc.meta, + path: path, + }); + + // Fetch the meta which includes the possibly indexed stuff, like page + // decorations + if (await this.objectIndex.hasFullIndexCompleted()) { + try { + const enrichedMeta = await this.objectIndex.getObjectByRef( + pageName, + "page", + pageName, + ) ?? doc.meta; + + const body = document.body; + body.removeAttribute("class"); + + if (enrichedMeta.pageDecoration?.cssClasses) { + body.className = enrichedMeta.pageDecoration.cssClasses + .join(" ") + .replaceAll(/[^a-zA-Z0-9-_ ]/g, ""); + } + + this.ui.viewDispatch({ + type: "update-current-page-meta", + meta: enrichedMeta, + }); + + // Trigger editor re-render to update Lua widgets with the new metadata + this.editorView.dispatch({}); + } catch (e: any) { + console.log( + `There was an error trying to fetch enriched metadata: ${e.message}`, + ); + } + } + + // When loading a different page OR if the page is read-only (in which case we don't want to apply local patches, because there's no point) + if (loadingDifferentPath || doc.meta.perm === "ro") { + const editorState = createEditorState( + this, + pageName, + doc.text, + doc.meta.perm === "ro", + ); + this.editorView.setState(editorState); + } else { + // Just apply minimal patches so that the cursor is preserved + this.setEditorText(doc.text, true); + } + + this.space.watchFile(path); + + if (navigateWithinPage) { + // Setup scroll position, cursor position, etc + try { + this.navigateWithinPage(locationState); + } catch { + // We don't really care if this fails. + } + } + // Note: these events are dispatched asynchronously deliberately (not waiting for results) + this.eventHook.dispatchEvent( + loadingDifferentPath ? "editor:pageLoaded" : "editor:pageReloaded", + pageName, + previousPath ? getNameFromPath(previousPath) : undefined, + ).catch(console.error); + + // If a cursor marker was found for a newly-created page, place the + // cursor there now (after navigateWithinPage so it doesn't get + // overwritten by default positioning). + if (markerIndex !== -1) { + try { + const pos = Math.max( + 0, + Math.min(markerIndex, this.editorView.state.doc.length), + ); + this.editorView.dispatch({ + selection: { anchor: pos }, + effects: [EditorView.scrollIntoView(pos, { y: "center" })], + }); + this.editorView.focus(); + } catch (e) { + console.error("Failed to set cursor at cursor marker:", e); + } + } + } + + isDocumentEditor(): this is { documentEditor: DocumentEditor } & this { + return this.documentEditor !== null; + } + + switchToPageEditor() { + if (!this.isDocumentEditor()) return; + + // Deliberately not awaiting this function as destroying & last-save can be handled in the background + this.documentEditor.destroy(); + // @ts-ignore: This is there the hacked type-guard from isDocumentEditor fails + this.documentEditor = null; + + this.rebuildEditorState(); + + document.getElementById("sb-editor")!.classList.remove("hide-cm"); + } + + async switchToDocumentEditor(extension: string) { + if (this.documentEditor) { + // Deliberately not awaiting this function as destroying & last-save can be handled in the background + this.documentEditor.destroy(); + } + + // This is probably not the best way to hide the codemirror editor, but it works + document.getElementById("sb-editor")!.classList.add("hide-cm"); + + this.documentEditor = new DocumentEditor( + document.getElementById("sb-editor")!, + this, + (path, content) => { + this.space + .writeDocument(path, content) + .then(async (meta) => { + this.ui.viewDispatch({ type: "document-editor-saved" }); + + await this.dispatchAppEvent( + "editor:documentSaved", + path, + meta, + ); + }) + .catch(() => { + this.flashNotification( + "Could not save document, retrying again in 10 seconds", + "error", + ); + this.saveTimeout = setTimeout(this.save.bind(this), 10000); + }); + }, + ); + + await this.documentEditor.init(extension); + + // We have to rebuild the editor state here to update the keymap correctly + // This is a little hacky but any other solution would pose a larger rewrite + this.rebuildEditorState(); + this.editorView.contentDOM.blur(); + } + + setEditorText(newText: string, shouldIsolateHistory = false) { + const currentText = this.editorView.state.sliceDoc(); + const allChanges = diffAndPrepareChanges(currentText, newText); + client.editorView.dispatch({ + changes: allChanges, + annotations: shouldIsolateHistory ? isolateHistory.of("full") : undefined, + }); + } + + openUrl(url: string, existingWindow = false) { + if (!existingWindow) { + const win = globalThis.open(url, "_blank"); + if (win) { + win.focus(); + } + } else { + location.href = url; + } + } + + async loadCustomStyles() { + if (this.bootConfig.disableSpaceStyle) { + console.warn("Not loading custom styles, since space style is disabled"); + return; + } + if (!await this.objectIndex.hasFullIndexCompleted()) { + console.warn( + "Not loading custom styles, since full indexing has not completed yet", + ); + return; + } + + const spaceStyles = await this.queryLuaObjects( + "space-style", + { + objectVariable: "_", + orderBy: [{ + expr: parseExpressionString("_.priority"), + desc: true, + }], + }, + ); + if (!spaceStyles) { + return; + } + + // Prepare separate " + ).join("\n\n"); + this.ui.viewDispatch({ + type: "set-ui-option", + key: "customStyles", + value: customStylesContent, + }); + document.getElementById("custom-styles")!.innerHTML = customStylesContent; + } + + async runCommandByName(name: string, args?: any[]) { + const cmd = this.ui.viewState.commands.get(name); + if (cmd) { + if (args) { + await cmd.run!(args); + } else { + await cmd.run!(); + } + } else { + throw new Error(`Command ${name} not found`); + } + } + + getCommandsByContext( + state: AppViewState, + ): Map { + const currentEditor = client.documentEditor?.name; + const commands = new Map(state.commands); + for (const [k, v] of state.commands.entries()) { + if ( + v.contexts && + (!state.showCommandPaletteContext || + !v.contexts.includes(state.showCommandPaletteContext)) + ) { + commands.delete(k); + } + + const requiredEditor = v.requireEditor; + if (!isValidEditor(currentEditor, requiredEditor)) { + commands.delete(k); + } + } + + return commands; + } + + getContext(): string | undefined { + const state = this.editorView.state; + const selection = state.selection.main; + if (selection.empty) { + return syntaxTree(state).resolveInner(selection.from).type.name; + } + return; + } + + async loadCaches() { + const [widgetHeightCache, widgetCache] = await this + .ds.batchGet([[ + "cache", + "widgetHeight", + ], ["cache", "widgets"]]); + this.widgetHeightCache = new LimitedMap(1000, widgetHeightCache || {}); + this.widgetCache = new LimitedMap(100, widgetCache || {}); + } + + setCachedWidgetHeight(bodyText: string, height: number) { + this.widgetHeightCache.set(bodyText, height); + this.debouncedWidgetHeightCacheFlush(); + } + + getCachedWidgetHeight(bodyText: string): number { + return this.widgetHeightCache.get(bodyText) ?? -1; + } + + setWidgetCache(key: string, cacheItem: WidgetCacheItem) { + this.widgetCache.set(key, cacheItem); + this.debouncedWidgetCacheFlush(); + } + + getWidgetCache(key: string): WidgetCacheItem | undefined { + return this.widgetCache.get(key); + } + + async handleServiceWorkerMessage(message: ServiceWorkerSourceMessage) { + switch (message.type) { + case "space-sync-complete": { + this.fullSyncCompleted = true; + break; + } + case "online-status": { + this.ui.viewDispatch({ + type: "online-status-change", + isOnline: message.isOnline, + }); + break; + } + case "auth-error": { + alert(message.message); + if ( + message.actionOrRedirectHeader && + message.actionOrRedirectHeader !== "reload" + ) { + location.href = message.actionOrRedirectHeader; + } else { + location.reload(); + } + break; + } + } + + // Also dispatch it on the event hook for any other listeners + await this.eventHook.dispatchEvent( + `service-worker:${message.type}`, + message, + ); + } + + private navigateWithinPage(pageState: LocationState) { + if (!isMarkdownPath(pageState.path)) return; + + // We can't use getOffsetFromRef here, because it is asyncronous. + let pos: number | undefined = undefined; + + // Don't use getOffsetFromRef, so we can show error messages + if (pageState.details?.type === "header") { + const pageText = this.editorView.state.sliceDoc(); + + pos = getOffsetFromHeader( + parseMarkdown(pageText), + pageState.details.header, + ); + + if (pos === -1) { + this.flashNotification( + `Could not find header "${pageState.details.header}"`, + "error", + ); + + pos = undefined; + } + } else if (pageState.details?.type === "position") { + pos = Math.max( + 0, + Math.min(pageState.details.pos, this.editorView.state.doc.length), + ); + } else if (pageState.details?.type === "linecolumn") { + const pageText = this.editorView.state.sliceDoc(); + + pos = getOffsetFromLineColumn( + pageText, + pageState.details.line, + pageState.details.column, + ); + } + + if (pos !== undefined) { + this.editorView.dispatch({ + selection: { anchor: pos }, + effects: EditorView.scrollIntoView(pos, { + y: "start", + yMargin: 5, + }), + }); + + // If a position was specified, we bail out and ignore any cached state + return; + } + + let adjustedPosition = false; + + // Was a particular scroll position persisted? + if (pageState.scrollTop && pageState.scrollTop > 0) { + setTimeout(() => { + this.editorView.scrollDOM.scrollTop = pageState.scrollTop!; + }); + adjustedPosition = true; + } + + // Was a particular cursor/selection set? + if (pageState.selection?.anchor) { + this.editorView.dispatch({ + selection: pageState.selection, + }); + adjustedPosition = true; + } + + // If not: just put the cursor at the top of the page, right after the frontmatter + if (!adjustedPosition) { + // Somewhat ad-hoc way to determine if the document contains frontmatter and if so, putting the cursor _after it_. + const pageText = this.editorView.state.sliceDoc(); + + // Default the cursor to be at position 0 + let initialCursorPos = 0; + const match = frontMatterRegex.exec(pageText); + if (match) { + // Frontmatter found, put cursor after it + initialCursorPos = match[0].length; + } + // By default scroll to the top + this.editorView.scrollDOM.scrollTop = 0; + this.editorView.dispatch({ + selection: { anchor: initialCursorPos }, + // And then scroll down if required + scrollIntoView: true, + }); + } + } + + private async initNavigator() { + this.pageNavigator = new PathPageNavigator(this); + + this.pageNavigator.subscribe(async (locationState) => { + console.log(`Now navigating to ${encodeRef(locationState)}`); + + if (isMarkdownPath(locationState.path)) { + await this.loadPage(locationState); + } else { + await this.loadDocumentEditor(locationState); + } + + // Persist this page as the last opened page, we'll use this for cold start PWA loads + await this.ds.set( + ["client", "lastOpenedPath"], + locationState.path, + ); + }); + + // Initial navigation + let ref = this.onLoadRef; + + if (ref.details?.type === "header" && ref.details.header === "boot") { + const path = await this.ds.get( + ["client", "lastOpenedPath"], + ) as Path; + + if (path) { + console.log("Navigating to last opened page", getNameFromPath(path)); + ref = { path }; + } + } + + await this.navigate(ref, true); + + console.log("Focusing editor"); + this.focus(); + } + + async wipeClient() { + // Clean out _other_ IndexedDB databases + console.log("Wiping IndexedDB databses not connected to this space..."); + const dbName = (this.ds.kv as any).dbName; + const suffix = dbName.replace("sb_data", ""); + if (indexedDB.databases) { + const allDbs = await indexedDB.databases(); + for (const db of allDbs) { + if (!db.name?.endsWith(suffix)) { + console.log("Deleting database", db.name); + indexedDB.deleteDatabase(db.name!); + } + } + } + // Instructe service worker to wipe + if (navigator.serviceWorker?.controller) { + // We will attempt to unregister the service worker, best effort + await new Promise((resolve) => { + navigator.serviceWorker.addEventListener("message", async (e: any) => { + const message: ServiceWorkerSourceMessage = e.data; + if (message.type == "dataWiped") { + console.log( + "Got data wipe confirm, uninstalling service worker now", + ); + const registrations = await navigator.serviceWorker + .getRegistrations(); + for (const registration of registrations) { + await registration.unregister(); + } + console.log("Unregistered all service workers"); + resolve(); + } + }); + // Send wipe request + navigator.serviceWorker.getRegistration().then((registration) => { + console.log( + "Sending data wipe request to service worker", + registration, + ); + registration?.active?.postMessage( + { type: "wipe-data" } as ServiceWorkerTargetMessage, + ); + }); + }); + } else { + console.info( + "Service workers not enabled (no HTTPS?), so not unregistering.", + ); + } + console.log("Stopping all systems"); + this.space.unwatch(); + + console.log("Clearing data store"); + await this.ds.kv.clear(); + console.log("Clearing complete."); + } + + public async postServiceWorkerMessage(message: ServiceWorkerTargetMessage) { + const registration = await navigator.serviceWorker.getRegistration(); + if (!registration?.active) { + throw new Error("No active service worker to post message to"); + } + registration?.active?.postMessage(message); + } +} + + +================================================ +FILE: client/client_system.ts +================================================ +import { PlugNamespaceHook } from "./plugos/hooks/plug_namespace.ts"; +import type { SilverBulletHooks } from "@silverbulletmd/silverbullet/type/manifest"; +import type { EventHook } from "./plugos/hooks/event.ts"; +import { createWorkerSandboxFromLocalPath } from "./plugos/sandboxes/web_worker_sandbox.ts"; + +import assetSyscalls from "./plugos/syscalls/asset.ts"; +import { System } from "./plugos/system.ts"; +import type { Client } from "./client.ts"; +import { CodeWidgetHook } from "./plugos/hooks/code_widget.ts"; +import { CommandHook } from "./plugos/hooks/command.ts"; +import { SlashCommandHook } from "./plugos/hooks/slash_command.ts"; +import { SyscallHook } from "./plugos/hooks/syscall.ts"; +import { clientStoreSyscalls } from "./plugos/syscalls/clientStore.ts"; +import { editorSyscalls } from "./plugos/syscalls/editor.ts"; +import { sandboxFetchSyscalls } from "./plugos/syscalls/fetch.ts"; +import { markdownSyscalls } from "./plugos/syscalls/markdown.ts"; +import { shellSyscalls } from "./plugos/syscalls/shell.ts"; +import { + spaceReadSyscalls, + spaceWriteSyscalls, +} from "./plugos/syscalls/space.ts"; +import { syncSyscalls } from "./plugos/syscalls/sync.ts"; +import { systemSyscalls } from "./plugos/syscalls/system.ts"; +import type { Space } from "./space.ts"; +import { MQHook } from "./plugos/hooks/mq.ts"; +import { mqSyscalls } from "./plugos/syscalls/mq.ts"; +import { + dataStoreReadSyscalls, + dataStoreWriteSyscalls, +} from "./plugos/syscalls/datastore.ts"; +import type { DataStore } from "./data/datastore.ts"; +import { languageSyscalls } from "./plugos/syscalls/language.ts"; +import { codeWidgetSyscalls } from "./plugos/syscalls/code_widget.ts"; +import { clientCodeWidgetSyscalls } from "./plugos/syscalls/client_code_widget.ts"; +import { KVPrimitivesManifestCache } from "./plugos/manifest_cache.ts"; +import { createCommandKeyBindings } from "./codemirror/editor_state.ts"; +import type { DataStoreMQ } from "./data/mq.datastore.ts"; +import { jsonschemaSyscalls } from "./plugos/syscalls/jsonschema.ts"; +import { luaSyscalls } from "./plugos/syscalls/lua.ts"; +import { indexSyscalls } from "./plugos/syscalls/index.ts"; +import { configSyscalls } from "./plugos/syscalls/config.ts"; +import { eventSyscalls } from "./plugos/syscalls/event.ts"; +import { DocumentEditorHook } from "./plugos/hooks/document_editor.ts"; +import type { Command } from "./types/command.ts"; +import { SpaceLuaEnvironment } from "./space_lua.ts"; +import { builtinPlugPaths } from "../plugs/builtin_plugs.ts"; +import { ServiceRegistry } from "./service_registry.ts"; +import { serviceRegistrySyscalls } from "./plugos/syscalls/service_registry.ts"; +import type { ObjectIndex } from "./data/object_index.ts"; + +const mqTimeout = 10000; // 10s +const mqTimeoutRetry = 3; + +/** + * Handles the extension-related mechanisms of the client by wrapping a PlugOS System object as well as Space Lua environments + */ +export class ClientSystem { + // PlugOS system + system!: System; + // ... and hooks + commandHook!: CommandHook; + slashCommandHook!: SlashCommandHook; + namespaceHook!: PlugNamespaceHook; + codeWidgetHook!: CodeWidgetHook; + documentEditorHook!: DocumentEditorHook; + mqHook!: MQHook; + + serviceRegistry!: ServiceRegistry; + + // Space Lua + spaceLuaEnv: SpaceLuaEnvironment; + readonly scriptCommands = new Map(); + scriptsLoaded: boolean = false; + + // Known files (for UI) + readonly allKnownFiles = new Set(); + public knownFilesLoaded: boolean = false; + + constructor( + private client: Client, + protected mq: DataStoreMQ, + public ds: DataStore, + public eventHook: EventHook, + private objectIndex: ObjectIndex, + public readOnlyMode: boolean, + ) { + this.system = new System(undefined, { + manifestCache: new KVPrimitivesManifestCache( + ds.kv, + "manifest", + ), + }); + + this.spaceLuaEnv = new SpaceLuaEnvironment(this.system, objectIndex); + this.serviceRegistry = new ServiceRegistry(this.eventHook, client.config); + + setInterval(() => { + mq.requeueTimeouts(mqTimeout, mqTimeoutRetry, true).catch(console.error); + }, 20000); // Look to requeue every 20s + + this.system.addHook(this.eventHook); + + // Plug page namespace hook + this.namespaceHook = new PlugNamespaceHook(); + this.system.addHook(this.namespaceHook); + + // Code widget hook + this.codeWidgetHook = new CodeWidgetHook(); + this.system.addHook(this.codeWidgetHook); + + // Document editor hook + this.documentEditorHook = new DocumentEditorHook(); + this.system.addHook(this.documentEditorHook); + + // Command hook + this.commandHook = new CommandHook( + this.readOnlyMode, + this.scriptCommands, + ); + this.commandHook.on({ + commandsUpdated: (commandMap) => { + this.client.ui?.viewDispatch({ + type: "update-commands", + commands: commandMap, + }); + // Replace the key mapping compartment (keybindings) + this.client.editorView.dispatch({ + effects: this.client.commandKeyHandlerCompartment?.reconfigure( + createCommandKeyBindings(this.client), + ), + }); + }, + }); + + this.slashCommandHook = new SlashCommandHook(this.client); + + // MQ hook + this.mqHook = new MQHook(this.system, this.mq, this.client.config); + this.system.addHook(this.mqHook); + + // Syscall hook + this.system.addHook(new SyscallHook()); + + this.eventHook.addLocalListener("editor:reloadState", async () => { + await this.reloadState(); + }); + } + + init() { + // Init is called after the editor is initialized, so we can safely add the command hook + this.system.addHook(this.commandHook); + this.system.addHook(this.slashCommandHook); + + // Syscalls available to all plugs + this.system.registerSyscalls( + [], + eventSyscalls(this.eventHook, this.client), + editorSyscalls(this.client), + spaceReadSyscalls(this.client), + systemSyscalls(client, this.readOnlyMode), + markdownSyscalls(client), + assetSyscalls(this.system), + codeWidgetSyscalls(this.codeWidgetHook), + clientCodeWidgetSyscalls(), + languageSyscalls(), + jsonschemaSyscalls(), + indexSyscalls(this.objectIndex, this.client), + //commandSyscalls(client), + luaSyscalls(this), + mqSyscalls(this.mq), + serviceRegistrySyscalls(this.serviceRegistry), + dataStoreReadSyscalls(this.ds, this), + dataStoreWriteSyscalls(this.ds), + syncSyscalls(this.client), + clientStoreSyscalls(this.ds), + configSyscalls(this.client.config), + ); + + if (!this.readOnlyMode) { + // Write syscalls + this.system.registerSyscalls( + [], + spaceWriteSyscalls(this.client), + ); + // Syscalls that require some additional permissions + this.system.registerSyscalls( + ["fetch"], + sandboxFetchSyscalls(this.client), + ); + + this.system.registerSyscalls( + ["shell"], + shellSyscalls(this.client), + ); + } + } + + async loadLuaScripts() { + if (this.client.bootConfig.disableSpaceLua) { + console.info("Space Lua scripts are disabled, skipping loading scripts"); + return; + } + if (!await this.objectIndex.hasFullIndexCompleted()) { + console.info( + "Not loading space scripts, since full indexing has not completed yet", + ); + return; + } + this.client.config.clear(); + try { + await this.spaceLuaEnv.reload(); + } catch (e: any) { + console.error("Error loading Lua script:", e.message); + } + + // Reset the space script commands + this.scriptCommands.clear(); + for ( + const [name, command] of Object.entries( + this.client.config.get>("commands", {}), + ) + ) { + this.scriptCommands.set(name, command); + } + + // Make scripted (slash) commands available + this.commandHook.throttledBuildAllCommandsAndEmit(); + this.slashCommandHook.throttledBuildAllCommands(); + this.mqHook.throttledReloadQueues(); + + this.scriptsLoaded = true; + } + + async reloadPlugsFromSpace(space: Space) { + console.log("(Re)loading plugs"); + await this.system.unloadAll(); + + let allPlugs = await space.listPlugs(); + // console.log("All plugs", allPlugs); + if (this.client.bootConfig.disablePlugs) { + // Only keep builtin plugs + allPlugs = allPlugs.filter(({ name }) => builtinPlugPaths.includes(name)); + + console.warn("Not loading custom plugs as `disablePlugs` has been set"); + } + + await Promise.all(allPlugs.map((fileMeta) => + this.system.loadPlug( + createWorkerSandboxFromLocalPath(fileMeta.name), + fileMeta.name, + fileMeta.lastModified, + ).catch((e) => + console.error( + `Could not load plug ${fileMeta.name} error: ${e.message}`, + ) + ) + )); + } + + localSyscall(name: string, args: any[]) { + return this.system.localSyscall(name, args); + } + + public async reloadState() { + console.log( + "Now loading space scripts, custom styles and rebuilding editor state", + ); + await this.loadLuaScripts(); + await this.client.loadCustomStyles(); + this.client.rebuildEditorState(); + } +} + + +================================================ +FILE: client/codemirror/admonition.ts +================================================ +import type { EditorState } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; +import { Decoration } from "@codemirror/view"; +import type { SyntaxNodeRef } from "@lezer/common"; +import { decoratorStateField, isCursorInRange } from "./util.ts"; + +const ADMONITION_REGEX = + /^>( *)(?:\*{2}|\[!)(.*?)(\*{2}|\])( *)(.*)(?:\n([\s\S]*))?/im; +const ADMONITION_LINE_SPLIT_REGEX = /\n>/gm; + +type AdmonitionFields = { + preSpaces: string; + admonitionType: string; + postSyntax: string; + postSpaces: string; + admonitionTitle: string; + admonitionContent: string; +}; + +// Given the raw text of an entire Blockquote, match an Admonition block. +// If matched, extract relevant fields using regex capture groups and return them +// as an object. +// +// If not matched return null. +// +// Example Admonition block (github formatted): +// +// > **note** I am an Admonition Title +// > admonition text +// +// or +// > [!note] I am an Admonition Title +// > admonition text + +function extractAdmonitionFields(rawText: string): AdmonitionFields | null { + const regexResults = rawText.match(ADMONITION_REGEX); + + if (regexResults) { + const preSpaces = regexResults[1] || ""; + const admonitionType = regexResults[2].toLowerCase(); + const postSyntax = regexResults[3]; + const postSpaces = regexResults[4] || ""; + const admonitionTitle: string = regexResults[5] || ""; + const admonitionContent: string = regexResults[6] || ""; + + return { + preSpaces, + admonitionType, + postSyntax, + postSpaces, + admonitionTitle, + admonitionContent, + }; + } + + return null; +} + +export function admonitionPlugin() { + return decoratorStateField((state: EditorState) => { + const widgets: any[] = []; + + syntaxTree(state).iterate({ + enter: (node: SyntaxNodeRef) => { + const { type, from, to } = node; + + if (type.name === "Blockquote") { + // Extract raw text from admonition block + const rawText = state.sliceDoc(from, to); + + // Split text into type, title and content using regex capture groups + const extractedFields = extractAdmonitionFields(rawText); + + // Bailout here if we don't have a proper Admonition formatted blockquote + if (!extractedFields) { + return; + } + + const { preSpaces, admonitionType, postSyntax } = extractedFields; + + // A blockquote is actually rendered as many divs, one per line. + // We need to keep track of the `from` offsets here, so we can attach css + // classes to them further down. + const fromOffsets: number[] = []; + const lines = rawText.slice(1).split(ADMONITION_LINE_SPLIT_REGEX); + let accum = from; + lines.forEach((line) => { + fromOffsets.push(accum); + accum += line.length + 2; + }); + + // `from` and `to` range info for switching out keyword text with correct + // icon further down. + const iconRange = { + from: from + 2, + to: from + preSpaces.length + 2 + admonitionType.length + + postSyntax.length + 1, + }; + + // The first div is the title, attach title css class + widgets.push( + Decoration.line({ + class: "sb-admonition-title", + }).range(fromOffsets[0]), + ); + + // If cursor is not within the first line, replace the keyword text + // with the icon + if ( + !isCursorInRange(state, [ + from, + fromOffsets.length > 1 ? fromOffsets[1] : to, + ]) + ) { + widgets.push( + Decoration.mark({ + tagName: "span", + class: "sb-admonition-type", + }).range(iconRange.from, iconRange.to), + ); + } + + // Each line of the blockquote is spread across separate divs, attach + // relevant css classes and attribute here. + fromOffsets.forEach((fromOffset) => { + widgets.push( + Decoration.line({ + attributes: { admonition: admonitionType }, + class: "sb-admonition", + }).range(fromOffset), + ); + }); + } + }, + }); + + return Decoration.set(widgets, true); + }); +} + + +================================================ +FILE: client/codemirror/attribute.ts +================================================ +import { syntaxTree } from "@codemirror/language"; +import { Decoration } from "@codemirror/view"; +import { decoratorStateField, isCursorInRange } from "./util.ts"; + +export function attributePlugin() { + return decoratorStateField((state) => { + const widgets: any[] = []; + + syntaxTree(state).iterate({ + enter: (node) => { + if (node.type.name !== "Attribute") { + return; + } + if (isCursorInRange(state, [node.from, node.to])) { + return; + } + + const attributeText = state.sliceDoc(node.from, node.to); + + // attribute text will have a format of [hell: bla bla bla] + const attributeName = attributeText.slice( + 1, + attributeText.indexOf(":"), + ); + const attributeValue = attributeText.slice( + attributeText.indexOf(":") + 1, + attributeText.length - 1, + ).trim(); + + // Wrap the tag in html anchor element + widgets.push( + Decoration.mark({ + tagName: "span", + class: "sb-attribute", + attributes: { + [`data-${attributeName}`]: attributeValue, + }, + }).range(node.from, node.to), + ); + }, + }); + + return Decoration.set(widgets, true); + }); +} + + +================================================ +FILE: client/codemirror/block.ts +================================================ +import type { EditorState } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; +import { Decoration } from "@codemirror/view"; +import { + decoratorStateField, + invisibleDecoration, + isCursorInRange, +} from "./util.ts"; + +export function cleanBlockPlugin() { + return decoratorStateField( + (state: EditorState) => { + const widgets: any[] = []; + + syntaxTree(state).iterate({ + enter(node) { + if ( + node.name === "HorizontalRule" && + !isCursorInRange(state, [node.from, node.to]) + ) { + widgets.push(invisibleDecoration.range(node.from, node.to)); + widgets.push( + Decoration.line({ + class: "sb-line-hr", + }).range(node.from), + ); + } + }, + }); + return Decoration.set(widgets, true); + }, + ); +} + + +================================================ +FILE: client/codemirror/block_quote.ts +================================================ +import type { EditorState } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; +import { Decoration } from "@codemirror/view"; +import { + decoratorStateField, + invisibleDecoration, + isCursorInRange, +} from "./util.ts"; + +function decorateBlockQuote(state: EditorState) { + const widgets: any[] = []; + syntaxTree(state).iterate({ + enter: ({ type, from, to }) => { + if (isCursorInRange(state, [from, to])) return; + if (type.name === "QuoteMark") { + widgets.push(invisibleDecoration.range(from, to)); + widgets.push( + Decoration.line({ class: "sb-blockquote-outside" }).range(from), + ); + } + }, + }); + return Decoration.set(widgets, true); +} + +export function blockquotePlugin() { + return decoratorStateField(decorateBlockQuote); +} + + +================================================ +FILE: client/codemirror/change.test.ts +================================================ +import { rangeLength, rangesOverlap } from "./change.ts"; +import { assertEquals } from "@std/assert"; + +Deno.test("rangeLength", () => { + assertEquals(rangeLength({ from: 4, to: 11 }), 7); +}); + +Deno.test("rangesOverlap", () => { + assertEquals( + rangesOverlap({ from: 0, to: 5 }, { from: 3, to: 8 }), + true, + ); + assertEquals( + rangesOverlap({ from: 0, to: 5 }, { from: 6, to: 8 }), + false, + ); + // `to` is exclusive + assertEquals( + rangesOverlap({ from: 0, to: 6 }, { from: 6, to: 8 }), + false, + ); + assertEquals( + rangesOverlap({ from: 3, to: 6 }, { from: 0, to: 4 }), + true, + ); +}); + + +================================================ +FILE: client/codemirror/change.ts +================================================ +// API for working with changes to document text + +/** Denotes a region in the document, based on character indicices + */ +export type Range = { + /** The starting index of the span, 0-based, inclusive + */ + from: number; + + /** The ending index of the span, 0-based, exclusive + */ + to: number; +}; + +/** A modification to the document */ +export type TextChange = { + /** The new text */ + inserted: string; + + /** The modified range **before** this change took effect. + * + * Example: "aaabbbccc" => "aaaccc", oldRange is [3, 6) + */ + oldRange: Range; + + /** The modified range **after** this change took effect. + * + * Example: "aaabbbccc" => "aaaccc", newRange is [3, 3) + */ + newRange: Range; +}; + +/** Get this distance between the start and end of a range */ +export function rangeLength(range: Range): number { + return range.to - range.from; +} + +export function rangesOverlap(a: Range, b: Range): boolean { + // `b.from >= a.to` => "b" starts after "a" + // `a.from >= b.to` => "b" ends before "a" + // if neither is true these two ranges must overlap + return !(b.from >= a.to || a.from >= b.to); +} + + +================================================ +FILE: client/codemirror/clean.ts +================================================ +import type { Extension } from "@codemirror/state"; +import type { Client } from "../client.ts"; +import { blockquotePlugin } from "./block_quote.ts"; +import { admonitionPlugin } from "./admonition.ts"; +import { hideHeaderMarkPlugin, hideMarksPlugin } from "./hide_mark.ts"; +import { cleanBlockPlugin } from "./block.ts"; +import { linkPlugin } from "./link.ts"; +import { listBulletPlugin } from "./list.ts"; +import { tablePlugin } from "./table.ts"; +import { taskListPlugin } from "./task.ts"; +import { cleanWikiLinkPlugin } from "./wiki_link.ts"; +import { fencedCodePlugin } from "./fenced_code.ts"; +import { frontmatterPlugin } from "./frontmatter.ts"; +import { cleanEscapePlugin } from "./escapes.ts"; +import { luaDirectivePlugin } from "./lua_directive.ts"; +import { hashtagPlugin } from "./hashtag.ts"; +import type { ClickEvent } from "@silverbulletmd/silverbullet/type/client"; +import { attributePlugin } from "./attribute.ts"; + +export function cleanModePlugins(client: Client) { + const pluginsNeededEvenWhenRenderingSyntax = [ + luaDirectivePlugin(client), + cleanWikiLinkPlugin(client), + hashtagPlugin(), + attributePlugin(), + frontmatterPlugin(client), + ]; + + if (client.ui.viewState.uiOptions.markdownSyntaxRendering) { + return pluginsNeededEvenWhenRenderingSyntax; + } + + return [ + ...pluginsNeededEvenWhenRenderingSyntax, + linkPlugin(client), + blockquotePlugin(), + admonitionPlugin(), + hideMarksPlugin(), + hideHeaderMarkPlugin(), + cleanBlockPlugin(), + fencedCodePlugin(client), + taskListPlugin({ + // TODO: Move this logic elsewhere? + onCheckboxClick: (pos) => { + const clickEvent: ClickEvent = { + page: client.currentName(), + altKey: false, + ctrlKey: false, + metaKey: false, + pos: pos, + }; + // Propagate click event from checkbox + client.dispatchClickEvent(clickEvent); + }, + }), + listBulletPlugin(), + tablePlugin(client), + cleanEscapePlugin(), + ] as Extension[]; +} + + +================================================ +FILE: client/codemirror/cm_util.ts +================================================ +import diff, { DELETE, EQUAL, INSERT } from "fast-diff"; +import type { ChangeSpec } from "@codemirror/state"; + +export function diffAndPrepareChanges( + oldString: string, + newString: string, +): ChangeSpec[] { + // Use the fast-diff library to compute the changes + const diffs = diff(oldString, newString); + + // Convert the diffs to CodeMirror transactions + let startIndex = 0; + const changes: ChangeSpec[] = []; + for (const part of diffs) { + if (part[0] === INSERT) { + changes.push({ from: startIndex, insert: part[1] }); + } else if (part[0] === EQUAL) { + startIndex += part[1].length; + } else if (part[0] === DELETE) { + changes.push({ from: startIndex, to: startIndex + part[1].length }); + startIndex += part[1].length; + } + } + return changes; +} + + +================================================ +FILE: client/codemirror/code_copy.ts +================================================ +import type { Client } from "../client.ts"; +import type { Range } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; +import { + Decoration, + type DecorationSet, + type EditorView, + ViewPlugin, + type ViewUpdate, + WidgetType, +} from "@codemirror/view"; + +const ICON_SVG = + ''; + +const EXCLUDE_LANGUAGES = ["template", "include", "query", "toc", "embed"]; + +class CodeCopyWidget extends WidgetType { + constructor(readonly value: string, readonly client: Client) { + super(); + } + + override eq(other: CodeCopyWidget) { + return other.value == this.value; + } + + toDOM() { + const wrap = document.createElement("span"); + // wrap.setAttribute("aria-hidden", "true"); + wrap.className = "sb-actions"; + + const button = wrap.appendChild(document.createElement("button")); + button.type = "button"; + button.title = "Copy to clipboard"; + button.className = "sb-code-copy-button"; + button.innerHTML = ICON_SVG; + button.title = "Copy"; + button.onclick = (e) => { + e.stopPropagation(); + e.preventDefault(); + navigator.clipboard.writeText(this.value) + .catch((err) => { + this.client.flashNotification( + `Error copying to clipboard: ${err}`, + "error", + ); + }) + .then(() => { + this.client.flashNotification("Copied to clipboard", "info"); + }); + }; + + return wrap; + } + + override ignoreEvent() { + return true; + } +} + +function codeCopyDecoration( + view: EditorView, + client: Client, +) { + const widgets: Range[] = []; + for (const { from, to } of view.visibleRanges) { + syntaxTree(view.state).iterate({ + from, + to, + enter: (node) => { + if (node.name == "FencedCode") { + const textNodes = node.node.getChildren("CodeText"); + const infoNode = node.node.getChild("CodeInfo"); + + if (textNodes.length === 0) { + return; + } + + const language = infoNode + ? view.state.doc.sliceString( + infoNode.from, + infoNode.to, + ) + : undefined; + + if (language && EXCLUDE_LANGUAGES.includes(language)) { + return; + } + + // Accumulate the text content of the code block + let text = ""; + for (const textNode of textNodes) { + text += view.state.doc.sliceString(textNode.from, textNode.to); + } + const deco = Decoration.widget({ + widget: new CodeCopyWidget(text, client), + side: 0, + }); + widgets.push(deco.range(node.from)); + } + }, + }); + } + return Decoration.set(widgets); +} + +export const codeCopyPlugin = (client: Client) => { + return ViewPlugin.fromClass( + class { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = codeCopyDecoration(view, client); + } + + update(update: ViewUpdate) { + if ( + update.docChanged || update.viewportChanged || + syntaxTree(update.startState) != syntaxTree(update.state) + ) { + this.decorations = codeCopyDecoration(update.view, client); + } + } + }, + { + decorations: (v) => v.decorations, + }, + ); +}; + + +================================================ +FILE: client/codemirror/code_widget.ts +================================================ +export const activeWidgets = new Set(); + +export interface DomWidget { + dom?: HTMLElement; + + renderContent( + div: HTMLElement, + cachedHtml: string | undefined, + ): Promise; +} + +export async function reloadAllWidgets() { + for (const widget of [...activeWidgets]) { + if (!widget.dom || !widget.dom.parentNode) { + activeWidgets.delete(widget); + continue; + } + // Create an empty widget DIV node + const newEl = document.createElement("div"); + await widget.renderContent(newEl, undefined); + // Replace the old widget with the new one + widget.dom.innerHTML = ""; + widget.dom.appendChild(newEl); + } +} + +function garbageCollectWidgets() { + for (const widget of activeWidgets) { + if (!widget.dom || !widget.dom.parentNode) { + // console.log("Garbage collecting widget", widget.bodyText); + activeWidgets.delete(widget); + } + } +} + +setInterval(garbageCollectWidgets, 5000); + + +================================================ +FILE: client/codemirror/editor_paste.ts +================================================ +import { syntaxTree } from "@codemirror/language"; +import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view"; +import type { Client } from "../client.ts"; + +// We use turndown to convert HTML to Markdown +import TurndownService from "turndown"; + +// With tables and task notation as well +import { tables, taskListItems } from "turndown-plugin-gfm"; +import { lezerToParseTree } from "../markdown_parser/parse_tree.ts"; +import { + addParentPointers, + findParentMatching, + nodeAtPos, +} from "@silverbulletmd/silverbullet/lib/tree"; +import { maximumDocumentSize } from "@silverbulletmd/silverbullet/constants"; +import { safeRun } from "@silverbulletmd/silverbullet/lib/async"; +import { resolveMarkdownLink } from "@silverbulletmd/silverbullet/lib/resolve"; +import { localDateString } from "@silverbulletmd/silverbullet/lib/dates"; +import type { UploadFile } from "@silverbulletmd/silverbullet/type/client"; +import { isValidName, isValidPath } from "@silverbulletmd/silverbullet/lib/ref"; + +const turndownService = new TurndownService({ + hr: "---", + codeBlockStyle: "fenced", + headingStyle: "atx", + emDelimiter: "*", + bulletListMarker: "*", // Duh! + strongDelimiter: "**", + linkStyle: "inlined", +}); +turndownService.use(taskListItems); +turndownService.use(tables); + +function striptHtmlComments(s: string): string { + return s.replace(//g, ""); +} + +const urlRegexp = + /^https?:\/\/[-a-zA-Z0-9@:%._\+~#=]{1,256}([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/; + +// Known iOS Safari paste issue (unrelated to this implementation): https://voxpelli.com/2015/03/ios-safari-url-copy-paste-bug/ +export const pasteLinkExtension = ViewPlugin.fromClass( + class { + update(update: ViewUpdate): void { + update.transactions.forEach((tr) => { + if (tr.isUserEvent("input.paste")) { + const pastedText: string[] = []; + let from = 0; + let to = 0; + tr.changes.iterChanges((fromA, _toA, _fromB, toB, inserted) => { + pastedText.push(inserted.sliceString(0)); + from = fromA; + to = toB; + }); + const pastedString = pastedText.join(""); + if (pastedString.match(urlRegexp)) { + const selection = update.startState.selection.main; + if (!selection.empty) { + setTimeout(() => { + update.view.dispatch({ + changes: [ + { + from: from, + to: to, + insert: `[${ + update.startState.sliceDoc( + selection.from, + selection.to, + ) + }](${pastedString})`, + }, + ], + }); + }); + } + } + } + }); + } + }, +); + +export function documentExtension(editor: Client) { + let shiftDown = false; + return EditorView.domEventHandlers({ + dragover: (event) => { + event.preventDefault(); + }, + keydown: (event) => { + if (event.key === "Shift") { + shiftDown = true; + } + return false; + }, + keyup: (event) => { + if (event.key === "Shift") { + shiftDown = false; + } + return false; + }, + drop: (event: DragEvent) => { + // TODO: This doesn't take into account the target cursor position, + // it just drops the document wherever the cursor was last. + if (event.dataTransfer) { + const payload = [...event.dataTransfer.files]; + if (!payload.length) { + return; + } + safeRun(async () => { + await processFileTransfer(payload); + }); + } + }, + paste: (event: ClipboardEvent) => { + const payload = [...event.clipboardData!.items]; + const richText = event.clipboardData?.getData("text/html"); + + // Only do rich text paste if shift is NOT down + if (richText && !shiftDown) { + // Are we in a fencede code block? + const editorText = editor.editorView.state.sliceDoc(); + const tree = lezerToParseTree( + editorText, + syntaxTree(editor.editorView.state).topNode, + ); + addParentPointers(tree); + const currentNode = nodeAtPos( + tree, + editor.editorView.state.selection.main.from, + ); + if (currentNode) { + const fencedParentNode = findParentMatching( + currentNode, + (t) => ["FrontMatter", "FencedCode"].includes(t.type!), + ); + if ( + fencedParentNode || + ["FrontMatter", "FencedCode"].includes(currentNode.type!) + ) { + console.log("Inside of fenced code block, not pasting rich text"); + return false; + } + } + + const markdown = striptHtmlComments(turndownService.turndown(richText)) + .trim(); + const view = editor.editorView; + const selection = view.state.selection.main; + view.dispatch({ + changes: [ + { + from: selection.from, + to: selection.to, + insert: markdown, + }, + ], + selection: { + anchor: selection.from + markdown.length, + }, + scrollIntoView: true, + }); + return true; + } + if (!payload.length || payload.length === 0) { + return false; + } + safeRun(async () => { + await processItemTransfer(payload); + }); + }, + }); + + async function processFileTransfer(payload: File[]) { + const data = await payload[0].arrayBuffer(); + // data.byteLength > maximumDocumentSize; + const fileData: UploadFile = { + name: payload[0].name, + contentType: payload[0].type, + content: new Uint8Array(data), + }; + await saveFile(fileData); + } + + async function processItemTransfer(payload: DataTransferItem[]) { + const file = payload.find((item) => item.kind === "file"); + if (!file) { + return false; + } + const fileType = file.type; + const ext = fileType.split("/")[1]; + const fileName = localDateString(new Date()) + .split(".")[0] + .replace("T", "_") + .replaceAll(":", "-"); + const data = await file!.getAsFile()?.arrayBuffer(); + if (!data) { + return false; + } + const fileData: UploadFile = { + name: `${fileName}.${ext}`, + contentType: fileType, + content: new Uint8Array(data), + }; + await saveFile(fileData); + } + + async function saveFile(file: UploadFile) { + const maxSize = maximumDocumentSize; + if (file.content.length > maxSize * 1024 * 1024) { + editor.flashNotification( + `Document is too large, maximum is ${maxSize}MiB`, + "error", + ); + return; + } + + const finalFilePath = await editor.prompt( + "File name for pasted document", + resolveMarkdownLink( + client.currentPath(), + isValidPath(file.name) + ? file.name + : `file.${ + file.name.indexOf(".") !== -1 ? file.name.split(".").pop() : "txt" + }`, + ), + ); + if (!finalFilePath || !isValidName(finalFilePath)) { + return; + } + + await editor.space.writeDocument(finalFilePath, file.content); + let documentMarkdown = `[[${finalFilePath}]]`; + if (file.contentType.startsWith("image/")) { + documentMarkdown = "!" + documentMarkdown; + } + editor.editorView.dispatch({ + changes: [ + { + insert: documentMarkdown, + from: editor.editorView.state.selection.main.from, + }, + ], + }); + } +} + + +================================================ +FILE: client/codemirror/editor_state.ts +================================================ +import customMarkdownStyle from "../style.ts"; +import { + history, + indentWithTab, + insertNewlineAndIndent, + isolateHistory, + standardKeymap, +} from "@codemirror/commands"; +import { + acceptCompletion, + autocompletion, + closeBrackets, + closeBracketsKeymap, + completionKeymap, +} from "@codemirror/autocomplete"; +import { + codeFolding, + foldEffect, + indentOnInput, + indentUnit, + LanguageDescription, + LanguageSupport, + syntaxHighlighting, + unfoldEffect, +} from "@codemirror/language"; +import { Compartment, EditorState, type Extension } from "@codemirror/state"; +import { + drawSelection, + dropCursor, + EditorView, + highlightSpecialChars, + type KeyBinding, + keymap, + ViewPlugin, + type ViewUpdate, +} from "@codemirror/view"; +import { vim } from "@replit/codemirror-vim"; +import { markdown } from "@codemirror/lang-markdown"; +import type { Client } from "../client.ts"; +import { inlineContentPlugin } from "./inline_content.ts"; +import { cleanModePlugins } from "./clean.ts"; +import { lineWrapper } from "./line_wrapper.ts"; +import { createSmartQuoteKeyBindings } from "./smart_quotes.ts"; +import { documentExtension, pasteLinkExtension } from "./editor_paste.ts"; +import type { TextChange } from "./change.ts"; +import { postScriptPrefacePlugin } from "./top_bottom_panels.ts"; +import { languageFor } from "../languages.ts"; +import { plugLinter } from "./lint.ts"; +import { extendedMarkdownLanguage } from "../markdown_parser/parser.ts"; +import { safeRun } from "@silverbulletmd/silverbullet/lib/async"; +import { codeCopyPlugin } from "../codemirror/code_copy.ts"; +import { disableSpellcheck } from "../codemirror/spell_checking.ts"; +import type { ClickEvent } from "@silverbulletmd/silverbullet/type/client"; + +export function createEditorState( + client: Client, + pageName: string, + text: string, + readOnly: boolean, +): EditorState { + let touchCount = 0; + + // Ugly: keep the commandKeyHandler compartment in the client, to be replaced + // later once more commands are loaded + client.commandKeyHandlerCompartment = new Compartment(); + const commandKeyBindings = client.commandKeyHandlerCompartment.of( + createCommandKeyBindings(client), + ); + // Regular key bindings are not dynamically updated and do not require a + // compartment. + const regularKeyBindings = createRegularKeyBindings(client); + + client.indentUnitCompartment = new Compartment(); + const indentUnits = client.indentUnitCompartment.of( + indentUnit.of(" "), + ); + + client.undoHistoryCompartment = new Compartment(); + const undoHistory = client.undoHistoryCompartment.of([history()]); + + return EditorState.create({ + doc: text, + extensions: [ + // Not using CM theming right now, but some extensions depend on the "dark" thing + EditorView.theme({}, { + dark: client.ui.viewState.uiOptions.darkMode, + }), + + // Insert our command key bindings *before* vim mode. Vim in normal-mode is + // greedy and captures all key events, preventing them from reaching our + // own handlers to trigger commands. This will mean some vim-mode + // bindings wont trigger if they have the same keys. + commandKeyBindings, + + // Enable vim mode, or not + [ + ...client.ui.viewState.uiOptions.vimMode + ? [ + vim({ status: true }), + EditorState.allowMultipleSelections.of(true), + ] + : [], + ], + [ + ...(readOnly || + client.ui.viewState.uiOptions.forcedROMode || + client.bootConfig.readOnly) + ? [EditorView.editable.of(false), EditorState.readOnly.of(true)] + : [], + ], + + // The uber markdown mode + markdown({ + base: extendedMarkdownLanguage, + codeLanguages: (info) => { + const lang = languageFor(info); + if (lang) { + return LanguageDescription.of({ + name: info, + support: new LanguageSupport(lang), + }); + } + + return null; + }, + addKeymap: true, + }), + extendedMarkdownLanguage.data.of({ + closeBrackets: { + brackets: client.config.get( + "autoCloseBrackets", + undefined, + )?.split( + "", + ), + }, + }), + syntaxHighlighting(customMarkdownStyle()), + autocompletion({ + override: [ + client.editorComplete.bind(client), + client.clientSystem.slashCommandHook!.slashCommandCompleter.bind( + client.clientSystem.slashCommandHook, + ), + ], + optionClass(completion: any) { + if (completion.cssClass) { + return "sb-decorated-object " + completion.cssClass; + } else { + return ""; + } + }, + }), + EditorView.contentAttributes.of({ + spellcheck: "true", + autocorrect: "on", + autocapitalize: "on", + }), + inlineContentPlugin(client), + codeCopyPlugin(client), + highlightSpecialChars(), + undoHistory, + dropCursor(), + codeFolding({ + placeholderText: "…", + }), + indentUnits, + indentOnInput(), + ...cleanModePlugins(client), + EditorView.lineWrapping, + plugLinter(client), + drawSelection(), + postScriptPrefacePlugin(client), + lineWrapper([ + { selector: "ATXHeading1", class: "sb-line-h1" }, + { selector: "ATXHeading2", class: "sb-line-h2" }, + { selector: "ATXHeading3", class: "sb-line-h3" }, + { selector: "ATXHeading4", class: "sb-line-h4" }, + { selector: "ATXHeading5", class: "sb-line-h5" }, + { selector: "ATXHeading6", class: "sb-line-h6" }, + { selector: "ListItem", class: "sb-line-li", nesting: true }, + { selector: "Blockquote", class: "sb-line-blockquote" }, + { selector: "Task", class: "sb-line-task" }, + { selector: "CodeBlock", class: "sb-line-code" }, + { selector: "FencedCode", class: "sb-line-fenced-code" }, + { selector: "Comment", class: "sb-line-comment" }, + { selector: "BulletList", class: "sb-line-ul" }, + { selector: "OrderedList", class: "sb-line-ol" }, + { selector: "TableHeader", class: "sb-line-tbl-header" }, + { + selector: "FrontMatter", + class: "sb-frontmatter", + }, + ]), + disableSpellcheck(["InlineCode", "CodeText", "CodeInfo", "FrontMatter"]), + regularKeyBindings, + EditorView.domEventHandlers({ + // This may result in duplicated touch events on mobile devices + touchmove: () => { + touchCount++; + }, + touchend: (event: TouchEvent, view: EditorView) => { + if (touchCount === 0) { + safeRun(async () => { + const touch = event.changedTouches.item(0)!; + if (!event.altKey && event.target instanceof Element) { + // prevent the browser from opening the link twice + const parentA = event.target.closest("a"); + if (parentA) { + event.preventDefault(); + } + } + + const pos = view.posAtCoords({ + x: touch.clientX, + y: touch.clientY, + })!; + + const potentialClickEvent: ClickEvent = { + page: pageName, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + altKey: event.altKey, + pos: pos, + }; + + const distanceX = touch.clientX - view.coordsAtPos(pos)!.left; + // What we're trying to determine here is if the tap occured anywhere near the looked up position + // this may not be the case with locations that expand signifcantly based on live preview (such as links), we don't want any accidental clicks + // Fixes #585 + // + if (distanceX <= view.defaultCharacterWidth) { + await client.dispatchAppEvent( + "page:click", + potentialClickEvent, + ); + } + }); + } + touchCount = 0; + }, + + click: (event: MouseEvent, view: EditorView) => { + const pos = view.posAtCoords(event); + if (event.button !== 0) { + return; + } + if (!pos) { + return; + } + safeRun(async () => { + const potentialClickEvent: ClickEvent = { + page: pageName, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + altKey: event.altKey, + pos: view.posAtCoords({ + x: event.x, + y: event.y, + })!, + }; + // Make sure tags are clicked without moving the cursor there + if (!event.altKey && event.target instanceof Element) { + const parentA = event.target.closest("a"); + if (parentA) { + event.stopPropagation(); + event.preventDefault(); + await client.dispatchAppEvent( + "page:click", + potentialClickEvent, + ); + return; + } + } + + const distanceX = event.x - view.coordsAtPos(pos)!.left; + // What we're trying to determine here is if the click occured anywhere near the looked up position + // this may not be the case with locations that expand signifcantly based on live preview (such as links), we don't want any accidental clicks + // Fixes #357 + if (distanceX <= view.defaultCharacterWidth) { + await client.dispatchClickEvent(potentialClickEvent); + } + }); + }, + }), + ViewPlugin.fromClass( + class { + update(update: ViewUpdate): void { + if (update.transactions.length > 0) { + for (const tr of update.transactions) { + for (const e of tr.effects) { + if (e.is(foldEffect)) { + client.dispatchAppEvent("editor:fold", e.value); + } + if (e.is(unfoldEffect)) { + client.dispatchAppEvent("editor:unfold", e.value); + } + } + } + } + if (update.docChanged) { + // Find if there's a history isolate in the transaction, if so it came from a local reload and we don't do anything + if ( + update.transactions.some((t) => t.annotation(isolateHistory)) + ) { + return; + } + const changes: TextChange[] = []; + update.changes.iterChanges((fromA, toA, fromB, toB, inserted) => + changes.push({ + inserted: inserted.toString(), + oldRange: { from: fromA, to: toA }, + newRange: { from: fromB, to: toB }, + }) + ); + client.dispatchAppEvent("editor:pageModified", { changes }); + client.ui.viewDispatch({ type: "page-changed" }); + client.debouncedUpdateEvent(); + client.save().catch((e) => console.error("Error saving", e)); + } + } + }, + ), + pasteLinkExtension, + documentExtension(client), + closeBrackets(), + ], + }); +} + +// TODO: Move this elsewhere +export function isValidEditor( + currentEditor: string | undefined, + requiredEditor: string | undefined, +): boolean { + return (requiredEditor === undefined) || + (currentEditor === undefined && + requiredEditor === "page") || + (requiredEditor === "any") || + (currentEditor === requiredEditor) || + (currentEditor !== undefined && requiredEditor === "notpage"); +} + +export function createCommandKeyBindings(client: Client): Extension { + const commandKeyBindings: KeyBinding[] = []; + + // Then add bindings for plug commands + for ( + const def of client.clientSystem.commandHook.buildAllCommands().values() + ) { + const currentEditor = client.documentEditor?.name; + const requiredEditor = def.requireEditor; + + if ((def.key || def.mac) && isValidEditor(currentEditor, requiredEditor)) { + const run = (): boolean => { + if (def.contexts) { + const context = client.getContext(); + if (!context || !def.contexts.includes(context)) { + return false; + } + } + Promise.resolve([]) + .then(def.run) + .catch((e: any) => { + client.reportError(e, "key"); + }).then((returnValue: any) => { + // Always be focusing the editor after running a command UNLESS it returns false + if (returnValue !== false) { + client.focus(); + } + }); + + return true; + }; + // Only create a generic key handler (non-mac specific) when + // EITHER we're not on a mac, or we're on a mac AND not specific mac key binding is set + if (def.key && (!isMacLike || (isMacLike && !def.mac))) { + if (Array.isArray(def.key)) { + for (const key of def.key) { + commandKeyBindings.push({ key, run }); + } + } else { + commandKeyBindings.push({ key: def.key, run }); + } + } + // Only set mac key handlers if we're on a mac, because... you know, logic + if (def.mac && isMacLike) { + if (Array.isArray(def.mac)) { + for (const key of def.mac) { + commandKeyBindings.push({ mac: key, run }); + } + } else { + commandKeyBindings.push({ mac: def.mac, run }); + } + } + } + } + + return keymap.of([ + ...commandKeyBindings, + ]); +} + +export function createRegularKeyBindings(client: Client): Extension { + if (client.isDocumentEditor()) { + return keymap.of([]); + } else { + return keymap.of([ + ...createSmartQuoteKeyBindings(client), + ...closeBracketsKeymap, + ...client.ui.viewState.uiOptions.vimMode + ? [ + // Workaround for https://github.com/replit/codemirror-vim/issues/182; + // without this, Enter does nothing for ordinary paragraphs in insert + // mode. + { + key: "Enter", + run: insertNewlineAndIndent, + shift: insertNewlineAndIndent, + }, + ] + : standardKeymap, + ...completionKeymap, + { key: "Tab", run: acceptCompletion }, + indentWithTab, + ]); + } +} + +/** + * Checks if the current platform is Mac-like (Mac, iPhone, iPod, iPad). + * @returns A boolean indicating if the platform is Mac-like. + */ +export const isMacLike = /(Mac|iPhone|iPod|iPad)/i.test(navigator.platform); + + +================================================ +FILE: client/codemirror/escapes.ts +================================================ +import type { EditorState } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; +import { Decoration } from "@codemirror/view"; +import { + decoratorStateField, + invisibleDecoration, + isCursorInRange, +} from "./util.ts"; + +export function cleanEscapePlugin() { + return decoratorStateField( + (state: EditorState) => { + const widgets: any[] = []; + + syntaxTree(state).iterate({ + enter({ type, from, to }) { + if ( + type.name === "Escape" && + !isCursorInRange(state, [from, to]) + ) { + widgets.push(invisibleDecoration.range(from, from + 1)); + } + }, + }); + return Decoration.set(widgets, true); + }, + ); +} + + +================================================ +FILE: client/codemirror/fenced_code.ts +================================================ +import type { EditorState } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; +import { Decoration } from "@codemirror/view"; +import type { Client } from "../client.ts"; +import { + decoratorStateField, + invisibleDecoration, + isCursorInRange, + shouldRenderWidgets, +} from "./util.ts"; +import { IFrameWidget } from "./iframe_widget.ts"; + +export function fencedCodePlugin(client: Client) { + return decoratorStateField((state: EditorState) => { + const widgets: any[] = []; + syntaxTree(state).iterate({ + enter({ from, to, name, node }) { + if (name === "FencedCode") { + if (isCursorInRange(state, [from, to])) { + // Don't render the widget if the cursor is inside the fenced code + return; + } + const text = state.sliceDoc(from, to); + const [_, lang] = text.match(/^(?:```+|~~~+)(\w+)?/)!; + const codeWidgetCallback = client.clientSystem.codeWidgetHook + .codeWidgetCallbacks + .get(lang); + // Only custom render when we have a custom renderer, and the current page is not a template + if (codeWidgetCallback && shouldRenderWidgets(client)) { + // We got a custom renderer! + const lineStrings = text.split("\n"); + + const lines: { from: number; to: number }[] = []; + let fromIt = from; + for (const line of lineStrings) { + lines.push({ + from: fromIt, + to: fromIt + line.length, + }); + fromIt += line.length + 1; + } + + const firstLine = lines[0], lastLine = lines[lines.length - 1]; + + // In case of doubt, back out + if (!firstLine || !lastLine) return; + + widgets.push( + invisibleDecoration.range(firstLine.from, firstLine.to), + ); + widgets.push( + invisibleDecoration.range(lastLine.from, lastLine.to), + ); + widgets.push( + Decoration.line({ + class: "sb-fenced-code-iframe", + }).range(firstLine.from), + ); + widgets.push( + Decoration.line({ + class: "sb-fenced-code-hide", + }).range(lastLine.from), + ); + + lines.slice(1, lines.length - 1).forEach((line) => { + widgets.push( + Decoration.line({ class: "sb-line-table-outside" }).range( + line.from, + ), + ); + }); + + const widget = new IFrameWidget( + from + lineStrings[0].length + 1, + to - lineStrings[lineStrings.length - 1].length - 1, + client, + lineStrings.slice(1, lineStrings.length - 1).join("\n"), + codeWidgetCallback, + ); + widgets.push( + Decoration.widget({ + widget: widget, + }).range(from), + ); + return false; + } + return true; + } + if ( + name === "CodeMark" + ) { + const parent = node.parent!; + // Hide ONLY if CodeMark is not insine backticks (InlineCode) and the cursor is placed outside + if ( + parent.node.name !== "InlineCode" && + !isCursorInRange(state, [parent.from, parent.to]) + ) { + widgets.push( + Decoration.line({ + class: "sb-line-code-outside", + }).range(state.doc.lineAt(from).from), + ); + } + } + }, + }); + return Decoration.set(widgets, true); + }); +} + + +================================================ +FILE: client/codemirror/frontmatter.ts +================================================ +import type { EditorState } from "@codemirror/state"; +import { foldedRanges, syntaxTree } from "@codemirror/language"; +import { Decoration } from "@codemirror/view"; +import { + decoratorStateField, + HtmlWidget, + isCursorInRange, + LinkWidget, +} from "./util.ts"; +import type { Client } from "../client.ts"; +import { + frontmatterQuotesRegex, + frontmatterUrlRegex, + frontmatterWikiLinkRegex, + frontmatterMailtoRegex, +} from "../markdown_parser/constants.ts"; +import { processWikiLink, type WikiLinkMatch } from "./wiki_link_processor.ts"; + +export function frontmatterPlugin(client: Client) { + return decoratorStateField( + (state: EditorState) => { + const widgets: any[] = []; + const foldRanges = foldedRanges(state); + const shortWikiLinks = client.config.get("shortWikiLinks", true); + + syntaxTree(state).iterate({ + enter(node) { + if ( + node.name === "FrontMatterMarker" + ) { + const parent = node.node.parent!; + + const folded = foldRanges.iter(); + let shouldShowFrontmatterBanner = false; + while (folded.value) { + // Check if cursor is in the folded range + if (isCursorInRange(state, [folded.from, folded.to])) { + // console.log("Cursor is in folded area, "); + shouldShowFrontmatterBanner = true; + break; + } + folded.next(); + } + if (!isCursorInRange(state, [parent.from, parent.to])) { + widgets.push( + Decoration.line({ + class: "sb-line-frontmatter-outside", + }).range(node.from), + ); + shouldShowFrontmatterBanner = true; + } + if (shouldShowFrontmatterBanner && parent.from === node.from) { + // Only put this on the first line of the frontmatter + widgets.push( + Decoration.widget({ + widget: new HtmlWidget( + `frontmatter`, + "sb-frontmatter-marker", + ), + }).range(node.from), + ); + } + } + + // Render links inside frontmatter code as clickable anchors (external and wiki links) + if (node.name === "FrontMatterCode") { + const oFrom = node.from; + const oTo = node.to; + + if (isCursorInRange(state, [oFrom, oTo])) { + return; + } + + const otext = state.sliceDoc(oFrom, oTo); + + let oMatch: RegExpExecArray | null; + while ((oMatch = frontmatterQuotesRegex.exec(otext)) !== null) { + const from = oFrom + (oMatch.index ?? 0); + const to = from + oMatch[0].length; + const text = state.sliceDoc(from, to); + + // 1) External links: http(s), :// URLs + frontmatterUrlRegex.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = frontmatterUrlRegex.exec(text)) !== null) { + const mFrom = from + (match.index ?? 0); + const mTo = mFrom + match[0].length; + const url = match[1]; + widgets.push( + Decoration.replace({ + widget: new LinkWidget({ + text: url, + title: `Open ${url}`, + href: url, + cssClass: "sb-external-link", + from: mFrom, + callback: (e) => { + if (e.altKey) { + // Move cursor into the link + client.editorView.dispatch({ + selection: { anchor: mFrom }, + }); + client.focus(); + return; + } + try { + // Open http(s) links in a new window/tab, open + // alternate schemes in the same page, as they'll + // bounce to another application. + if (/^https?:\/\//i.test(url)) { + globalThis.open(url, "_blank"); + } else { + globalThis.open(url, "_self"); + } + } catch (err) { + console.error("Failed to open external link", err); + } + }, + }), + }).range(mFrom, mTo), + ); + } + + // 2) Internal links: WikiLinks [[...]] (make navigable) + frontmatterWikiLinkRegex.lastIndex = 0; + let wMatch: RegExpExecArray | null; + while ((wMatch = frontmatterWikiLinkRegex.exec(text)) !== null) { + if (!wMatch || !wMatch.groups) { + return; + } + const mFrom = from + (wMatch.index ?? 0); + const mTo = mFrom + wMatch[0].length; + + const wikiLinkMatch: WikiLinkMatch = { + leadingTrivia: wMatch.groups.leadingTrivia, + stringRef: wMatch.groups.stringRef, + alias: wMatch.groups.alias, + trailingTrivia: wMatch.groups.trailingTrivia, + }; + + const decorations = processWikiLink({ + from, + to, + match: wikiLinkMatch, + matchFrom: mFrom, + matchTo: mTo, + client, + shortWikiLinks, + state, + callback: (e, ref) => { + if (e.altKey) { + // Move cursor into the link's content + client.editorView.dispatch({ + selection: { + anchor: mFrom + wikiLinkMatch.leadingTrivia.length, + }, + }); + client.focus(); + return; + } + client.navigate( + ref, + false, + e.ctrlKey || e.metaKey, + ); + }, + }); + + widgets.push(...decorations); + } + + // 3) mailto:... links + frontmatterMailtoRegex.lastIndex = 0; + let mMatch: RegExpExecArray | null; + while ((mMatch = frontmatterMailtoRegex.exec(text)) !== null) { + const mFrom = from + (mMatch.index ?? 0); + const mTo = mFrom + mMatch[0].length; + const url = mMatch[1]; + const address = url.slice(7); + widgets.push( + Decoration.replace({ + widget: new LinkWidget({ + text: url, + title: `Mail ${address}`, + href: url, + cssClass: "sb-external-link", + from: mFrom, + callback: (e) => { + if (e.altKey) { + // Move cursor into the link + client.editorView.dispatch({ + selection: { anchor: mFrom }, + }); + client.focus(); + return; + } + try { + globalThis.open(url, "_self"); + } catch (err) { + console.error("Failed to open external link", err); + } + }, + }), + }).range(mFrom, mTo), + ); + } + } + } + }, + }); + return Decoration.set(widgets, true); + }, + ); +} + + +================================================ +FILE: client/codemirror/hashtag.ts +================================================ +import { syntaxTree } from "@codemirror/language"; +import { Decoration } from "@codemirror/view"; +import { decoratorStateField } from "./util.ts"; +import * as Constants from "../../plugs/index/constants.ts"; +import { extractHashtag } from "../../plug-api/lib/tags.ts"; +import { encodePageURI } from "@silverbulletmd/silverbullet/lib/ref"; + +export function hashtagPlugin() { + return decoratorStateField((state) => { + const widgets: any[] = []; + + syntaxTree(state).iterate({ + enter: ({ type, from, to }) => { + if (type.name !== "Hashtag") { + return; + } + + const tag = state.sliceDoc(from, to); + + if (tag.length === 1) { + // Invalid Hashtag, a length of 1 means its just # + return; + } + + const tagName = extractHashtag(tag); + + // Wrap the tag in html anchor element + widgets.push( + Decoration.mark({ + tagName: "a", + class: "sb-hashtag", + attributes: { + href: `/${encodePageURI(Constants.tagPrefix + tagName)}`, + rel: "tag", + "data-tag-name": tagName, + }, + }).range(from, to), + ); + }, + }); + + return Decoration.set(widgets, true); + }); +} + + +================================================ +FILE: client/codemirror/hide_mark.ts +================================================ +// Forked from https://codeberg.org/retronav/ixora +// Original author: Pranav Karawale +// License: Apache License 2.0. + +import type { EditorState } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; +import { Decoration } from "@codemirror/view"; +import { + checkRangeOverlap, + decoratorStateField, + invisibleDecoration, + isCursorInRange, +} from "./util.ts"; + +/** + * These types contain markers as child elements that can be hidden. + */ +const typesWithMarks = [ + "Emphasis", + "StrongEmphasis", + "InlineCode", + "Highlight", + "Strikethrough", + "Superscript", + "Subscript", +]; +/** + * The elements which are used as marks. + */ +const markTypes = [ + "EmphasisMark", + "CodeMark", + "HighlightMark", + "StrikethroughMark", + "SuperscriptMark", + "SubscriptMark", +]; + +/** + * Ixora hide marks plugin. + * + * This plugin allows to: + * - Hide marks when they are not in the editor selection. + */ +export function hideMarksPlugin() { + return decoratorStateField((state: EditorState) => { + const widgets: any[] = []; + let parentRange: [number, number]; + syntaxTree(state).iterate({ + enter: ({ type, from, to, node }) => { + if (typesWithMarks.includes(type.name)) { + // There can be a possibility that the current node is a + // child eg. a bold node in a emphasis node, so check + // for that or else save the node range + if ( + parentRange && + checkRangeOverlap([from, to], parentRange) + ) { + return; + } else parentRange = [from, to]; + if (isCursorInRange(state, [from, to])) return; + const innerTree = node.toTree(); + innerTree.iterate({ + enter({ type, from: markFrom, to: markTo }) { + // Check for mark types and push the replace + // decoration + if (!markTypes.includes(type.name)) return; + widgets.push( + invisibleDecoration.range( + from + markFrom, + from + markTo, + ), + ); + }, + }); + } + }, + }); + return Decoration.set(widgets, true); + }); +} + +// HEADINGS + +export function hideHeaderMarkPlugin() { + return decoratorStateField((state) => { + const widgets: any[] = []; + syntaxTree(state).iterate({ + enter: ({ type, from, to }) => { + if (!type.name.startsWith("ATXHeading")) { + return; + } + // Get the active line + const line = state.sliceDoc(from, to); + if (line === "#") { + // Empty header, potentially a tag, style it as such + widgets.push( + Decoration.mark({ + tagName: "span", + class: "sb-hashtag-text", + }).range(from, from + 1), + ); + + return; + } + if (isCursorInRange(state, [from, to])) { + widgets.push( + Decoration.line({ class: "sb-header-inside" }).range(from), + ); + return; + } + + const spacePos = line.indexOf(" "); + if (spacePos === -1) { + // Not complete header + return; + } + widgets.push( + invisibleDecoration.range( + from, + from + spacePos + 1, + ), + ); + }, + }); + return Decoration.set(widgets, true); + }); +} + + +================================================ +FILE: client/codemirror/iframe_widget.ts +================================================ +import { WidgetType } from "@codemirror/view"; +import type { Client } from "../client.ts"; +import { createWidgetSandboxIFrame } from "../components/widget_sandbox_iframe.ts"; +import type { + CodeWidgetCallback, + CodeWidgetContent, +} from "@silverbulletmd/silverbullet/type/client"; + +export class IFrameWidget extends WidgetType { + iframe?: HTMLIFrameElement; + + constructor( + readonly from: number, + readonly to: number, + readonly client: Client, + readonly bodyText: string, + readonly codeWidgetCallback: CodeWidgetCallback, + ) { + super(); + } + + override get estimatedHeight(): number { + const cachedHeight = this.client.getCachedWidgetHeight(this.bodyText); + // console.log("Calling estimated height", this.bodyText, cachedHeight); + return cachedHeight > 0 ? cachedHeight : 150; + } + + toDOM(): HTMLElement { + const from = this.from; + const iframe = createWidgetSandboxIFrame( + this.client, + this.bodyText, + this.codeWidgetCallback( + this.bodyText, + this.client.currentName(), + ), + (message) => { + switch (message.type) { + case "blur": + this.client.editorView.dispatch({ + selection: { anchor: from }, + }); + this.client.focus(); + + break; + case "reload": + this.codeWidgetCallback( + this.bodyText, + this.client.currentName(), + ) + .then( + (widgetContent: CodeWidgetContent | null) => { + if (widgetContent === null) { + iframe.contentWindow!.postMessage({ + type: "html", + html: "", + theme: + document.getElementsByTagName("html")[0].dataset.theme, + }); + } else { + iframe.contentWindow!.postMessage({ + type: "html", + html: widgetContent.html, + script: widgetContent.script, + theme: + document.getElementsByTagName("html")[0].dataset.theme, + }); + } + }, + ); + break; + } + }, + ); + + const estimatedHeight = this.estimatedHeight; + iframe.style.height = `${estimatedHeight}px`; + + return iframe; + } + + override eq(other: WidgetType): boolean { + return ( + other instanceof IFrameWidget && + other.bodyText === this.bodyText + ); + } +} + + +================================================ +FILE: client/codemirror/inline_content.ts +================================================ +import type { EditorState, Range } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; +import { Decoration } from "@codemirror/view"; +import { + decoratorStateField, + invisibleDecoration, + isCursorInRange, + shouldRenderWidgets, +} from "./util.ts"; +import type { Client } from "../client.ts"; +import { LuaWidget } from "./lua_widget.ts"; +import { + expandMarkdown, + inlineContentFromURL, +} from "../markdown_renderer/inline.ts"; +import { parseMarkdown } from "../markdown_parser/parser.ts"; +import { renderToText } from "@silverbulletmd/silverbullet/lib/tree"; +import { + nameFromTransclusion, + parseTransclusion, +} from "@silverbulletmd/silverbullet/lib/transclusion"; +import { parseToRef } from "@silverbulletmd/silverbullet/lib/ref"; + +export function inlineContentPlugin(client: Client) { + return decoratorStateField((state: EditorState) => { + const widgets: Range[] = []; + if (!shouldRenderWidgets(client)) { + // console.info("Not rendering widgets"); + return Decoration.set([]); + } + + syntaxTree(state).iterate({ + enter: ({ type, from, to }) => { + if (type.name !== "Image") { + return; + } + + const text = state.sliceDoc(from, to); + + const transclusion = parseTransclusion(text); + if (!transclusion) { + return; + } + + const renderingSyntax = client.ui.viewState.uiOptions + .markdownSyntaxRendering; + const cursorIsInRange = isCursorInRange(state, [from, to]); + if (cursorIsInRange) { + return; + } + if (!renderingSyntax && !cursorIsInRange) { + widgets.push(invisibleDecoration.range(from, to)); + } + + widgets.push( + Decoration.widget({ + widget: new LuaWidget( + client, + `widget:${client.currentPath()}:${text}`, + text, + text, + async () => { + try { + const result: any = await inlineContentFromURL( + client.space, + transclusion, + ); + const content = result.text !== undefined + ? { + markdown: renderToText( + await expandMarkdown( + client.space, + nameFromTransclusion(transclusion), + parseMarkdown(result.text, result.offset), + client.clientSystem.spaceLuaEnv, + ), + ), + } + : { html: result }; + + return { + _isWidget: true, + display: "block", + cssClasses: ["sb-inline-content"], + ...content, + }; + } catch (e: any) { + return { + _isWidget: true, + display: "block", + cssClasses: ["sb-inline-content"], + markdown: `**Error:** ${e.message}`, + }; + } + }, + true, + true, + parseToRef(transclusion.url), + ), + block: true, + }).range(from), + ); + }, + }); + + return Decoration.set(widgets, true); + }); +} + + +================================================ +FILE: client/codemirror/line_wrapper.ts +================================================ +import type { EditorState, Range } from "@codemirror/state"; +import { Decoration } from "@codemirror/view"; +import { syntaxTree } from "@codemirror/language"; +import { decoratorStateField } from "./util.ts"; + +interface WrapElement { + selector: string; + class: string; + nesting?: boolean; +} + +export function lineWrapper(wrapElements: WrapElement[]) { + return decoratorStateField((state: EditorState) => { + const widgets: Range[] = []; + const elementStack: string[] = []; + const doc = state.doc; + syntaxTree(state).iterate({ + enter: ({ type, from, to }) => { + for (const wrapElement of wrapElements) { + if (type.name == wrapElement.selector) { + if (wrapElement.nesting) { + elementStack.push(type.name); + } + const bodyText = doc.sliceString(from, to); + let idx = from; + for (const line of bodyText.split("\n")) { + let cls = wrapElement.class; + if (wrapElement.nesting) { + cls = `${cls} ${cls}-${elementStack.length}`; + } + widgets.push( + Decoration.line({ + class: cls, + }).range(doc.lineAt(idx).from), + ); + idx += line.length + 1; + } + } + } + }, + leave({ type }) { + for (const wrapElement of wrapElements) { + if (type.name == wrapElement.selector && wrapElement.nesting) { + elementStack.pop(); + } + } + }, + }); + + return Decoration.set(widgets, true); + }); +} + + +================================================ +FILE: client/codemirror/link.ts +================================================ +import { + isLocalURL, + resolveMarkdownLink, +} from "@silverbulletmd/silverbullet/lib/resolve"; +import type { Client } from "../client.ts"; +import { syntaxTree } from "@codemirror/language"; +import { Decoration } from "@codemirror/view"; +import { + decoratorStateField, + invisibleDecoration, + isCursorInRange, +} from "./util.ts"; +import { mdLinkRegex } from "../markdown_parser/constants.ts"; + +export function linkPlugin(client: Client) { + return decoratorStateField((state) => { + const widgets: any[] = []; + + syntaxTree(state).iterate({ + enter: ({ type, from, to }) => { + if (type.name !== "Link") { + return; + } + if (isCursorInRange(state, [from, to])) { + return; + } + + const text = state.sliceDoc(from, to); + + mdLinkRegex.lastIndex = 0; + const match = mdLinkRegex.exec(text); + if (!match || !match.groups) { + return; + } + + const groups = match.groups; + + if (groups.title === "") { + // Empty link text, let's not do live preview (because it would make it disappear) + return; + } + + let url = groups.url; + + if (isLocalURL(url)) { + url = resolveMarkdownLink( + client.currentName(), + decodeURI(url), + ); + } + + // Hide the start [ + widgets.push( + invisibleDecoration.range( + from, + from + 1, + ), + ); + // Wrap the link in a href + widgets.push( + Decoration.mark({ + tagName: "a", + class: "sb-link", + attributes: { + href: url, + title: `Click to visit ${url}`, + }, + }).range(from + 1, from + 1 + groups.title.length), + ); + // Hide the tail end of the link + widgets.push( + invisibleDecoration.range( + from + 1 + groups.title.length, + to, + ), + ); + }, + }); + + return Decoration.set(widgets, true); + }); +} + + +================================================ +FILE: client/codemirror/lint.ts +================================================ +import { type Diagnostic, linter } from "@codemirror/lint"; +import type { Client } from "../client.ts"; +import { parse } from "../markdown_parser/parse_tree.ts"; +import { extendedMarkdownLanguage } from "../markdown_parser/parser.ts"; +import type { LintEvent } from "@silverbulletmd/silverbullet/type/client"; + +export function plugLinter(client: Client) { + return linter(async (view): Promise => { + const text = view.state.sliceDoc(); + const tree = parse( + extendedMarkdownLanguage, + text, + ); + const results = (await client.dispatchAppEvent("editor:lint", { + name: client.currentName(), + pageMeta: client.currentPageMeta(), + tree, + text, + } as LintEvent)).flat(); + return results; + }); +} + + +================================================ +FILE: client/codemirror/list.ts +================================================ +// Forked from https://codeberg.org/retronav/ixora +// Original author: Pranav Karawale +// License: Apache License 2.0. + +import { syntaxTree } from "@codemirror/language"; +import { Decoration, WidgetType } from "@codemirror/view"; +import { decoratorStateField, isCursorInRange } from "./util.ts"; + +const bulletListMarkerRE = /^[-+*]/; + +export function listBulletPlugin() { + return decoratorStateField((state) => { + const widgets: any[] = []; + syntaxTree(state).iterate({ + enter: ({ type, from, to }) => { + if (type.name === "ListMark") { + if (isCursorInRange(state, [from, to])) { + // Cursor is in the list mark + widgets.push( + Decoration.mark({ + class: "sb-li-cursor", + }).range(from, to), + ); + } else { + // Cursor is outside the list mark, render as a (silver) bullet + const listMark = state.sliceDoc(from, to); + if (bulletListMarkerRE.test(listMark)) { + const dec = Decoration.replace({ + widget: new ListBulletWidget(listMark), + }); + widgets.push(dec.range(from, to)); + } else { + // Ordered list, no special rendering + widgets.push( + Decoration.mark({ + class: "sb-li-cursor", + }).range(from, to), + ); + } + } + } + }, + }); + return Decoration.set(widgets, true); + }); +} + +/** + * Widget to render list bullet mark. + */ +class ListBulletWidget extends WidgetType { + constructor(readonly bullet: string) { + super(); + } + + toDOM(): HTMLElement { + const listBullet = document.createElement("span"); + listBullet.textContent = this.bullet; + listBullet.className = "cm-list-bullet"; + return listBullet; + } +} + + +================================================ +FILE: client/codemirror/lua_directive.ts +================================================ +import type { EditorState, Range } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; +import { Decoration } from "@codemirror/view"; +import { + decoratorStateField, + invisibleDecoration, + isCursorInRange, +} from "./util.ts"; +import type { Client } from "../client.ts"; +import { parse as parseLua } from "../space_lua/parse.ts"; +import type { LuaBlock, LuaFunctionCallStatement } from "../space_lua/ast.ts"; +import { evalExpression } from "../space_lua/eval.ts"; +import { + LuaEnv, + LuaRuntimeError, + LuaStackFrame, + luaValueToJS, + singleResult, +} from "../space_lua/runtime.ts"; +import { isTaggedFloat } from "../space_lua/numeric.ts"; +import { + encodeRef, + getNameFromPath, +} from "@silverbulletmd/silverbullet/lib/ref"; +import { resolveASTReference } from "../space_lua.ts"; +import { LuaWidget } from "./lua_widget.ts"; + +export function luaDirectivePlugin(client: Client) { + return decoratorStateField((state: EditorState) => { + const widgets: Range[] = []; + + let shouldRender = true; + + // Don't render Lua directives of federated pages (security) + if ( + !client.clientSystem.scriptsLoaded + ) { + return Decoration.none; + } + + syntaxTree(state).iterate({ + enter: (node) => { + // Disable rendering of Lua directives in #meta/template pages + // Either in frontmatter + if (node.name === "FrontMatterCode") { + const text = state.sliceDoc(node.from, node.to); + try { + // Very ad-hoc regex to detect if meta/template appears in the tag list + if (/tags:.*meta\/template/s.exec(text)) { + shouldRender = false; + return; + } + } catch { + // Ignore + } + } + // Or with a hash tag + if (node.name === "Hashtag") { + const text = state.sliceDoc(node.from, node.to); + if (text.startsWith("#meta/template")) { + shouldRender = false; + return; + } + } + + if (node.name !== "LuaDirective") { + return; + } + + if (isCursorInRange(state, [node.from, node.to])) { + return; + } + + const codeText = state.sliceDoc(node.from, node.to); + const expressionText = codeText.slice(2, -1); + const currentPageMeta = client.currentPageMeta(); + widgets.push( + Decoration.widget({ + widget: new LuaWidget( + client, + `lua:${expressionText}:${currentPageMeta?.name}`, + expressionText, + codeText, + async (bodyText) => { + if (bodyText.trim().length === 0) { + return "**Error:** Empty Lua expression"; + } + try { + const parsedLua = parseLua(`_(${bodyText})`) as LuaBlock; + const expr = + (parsedLua.statements[0] as LuaFunctionCallStatement).call + .args[0]; + + const tl = new LuaEnv(); + tl.setLocal( + "currentPage", + currentPageMeta || (client.ui.viewState.current + ? { + name: getNameFromPath( + client.ui.viewState.current.path, + ), + } + : undefined), + ); + const sf = LuaStackFrame.createWithGlobalEnv( + client.clientSystem.spaceLuaEnv.env, + expr.ctx, + ); + const threadLocalizedEnv = new LuaEnv( + client.clientSystem.spaceLuaEnv.env, + ); + threadLocalizedEnv.setLocal("_CTX", tl); + const rawResult = singleResult( + await evalExpression( + expr, + threadLocalizedEnv, + sf, + ), + ); + // keep tagged floats as-is for proper formatting + if ( + isTaggedFloat(rawResult) || typeof rawResult === "number" + ) { + return rawResult; + } + // everything else needs luaValueToJS for widget support + return luaValueToJS(rawResult, sf); + } catch (e: any) { + if (e instanceof LuaRuntimeError) { + if (e.sf?.astCtx) { + const source = resolveASTReference(e.sf.astCtx); + if (source) { + // We know the origin node of the error, let's reference it + return `**Lua error:** ${e.message} (Origin: [[${ + encodeRef(source) + }]])`; + } + } + } + return `**Lua error:** ${e.message}`; + } + }, + true, + true, + null, + ), + }).range(node.to), + ); + + if (!client.ui.viewState.uiOptions.markdownSyntaxRendering) { + widgets.push(invisibleDecoration.range(node.from, node.to)); + } + }, + }); + + if (!shouldRender) { + return Decoration.set([]); + } + + return Decoration.set(widgets, true); + }); +} + + +================================================ +FILE: client/codemirror/lua_widget.ts +================================================ +import { WidgetType } from "@codemirror/view"; +import type { Client } from "../client.ts"; +import { renderMarkdownToHtml } from "../markdown_renderer/markdown_render.ts"; +import { + isLocalURL, + resolveMarkdownLink, +} from "@silverbulletmd/silverbullet/lib/resolve"; +import { parse } from "../markdown_parser/parse_tree.ts"; +import { extendedMarkdownLanguage } from "../markdown_parser/parser.ts"; +import { renderToText } from "@silverbulletmd/silverbullet/lib/tree"; +import { + attachWidgetEventHandlers, + moveCursorIntoText, +} from "./widget_util.ts"; +import { expandMarkdown } from "../markdown_renderer/inline.ts"; +import { luaFormatNumber, LuaTable } from "../space_lua/runtime.ts"; +import { isTaggedFloat } from "../space_lua/numeric.ts"; +import { + isBlockMarkdown, + jsonToMDTable, + refCellTransformer, +} from "../markdown_renderer/result_render.ts"; +import { activeWidgets } from "./code_widget.ts"; +import type { Ref } from "@silverbulletmd/silverbullet/lib/ref"; + +export type LuaWidgetCallback = ( + bodyText: string, + pageName: string, +) => Promise; + +export type EventPayLoad = { + name: string; + data: any; +}; + +export type LuaWidgetContent = { + // Magic marker + _isWidget?: true; + // Render as HTML + html?: string | HTMLElement; + // Render as markdown + markdown?: string; + // CSS classes for wrapper + cssClasses?: string[]; + display?: "block" | "inline"; + // Event handlers + events?: Record void>; +} | string; + +export class LuaWidget extends WidgetType { + public dom?: HTMLElement; + + constructor( + readonly client: Client, + // key to use for caching + readonly cacheKey: string, + // body text to send to widget renderer + readonly expressionText: string, + // code as it appears in the page (used to find when hitting the "edit" button) + readonly codeText: string, + readonly callback: LuaWidgetCallback, + private renderEmpty: boolean, + readonly inPage: boolean, + // Add open ref option + private openRef: Ref | null, + ) { + super(); + } + + override get estimatedHeight(): number { + return this.client.getCachedWidgetHeight(this.cacheKey); + } + + toDOM(): HTMLElement { + const wrapperSpan = document.createElement("span"); + wrapperSpan.className = "sb-lua-wrapper"; + const innerDiv = document.createElement("div"); + wrapperSpan.appendChild(innerDiv); + const cacheItem = this.client.getWidgetCache(this.cacheKey); + if (cacheItem) { + if (cacheItem.block) { + innerDiv.className += " sb-lua-directive-block"; + } else { + innerDiv.className += " sb-lua-directive-inline"; + } + // This is to make the initial render faster, will later be replaced by the actual content + innerDiv.replaceChildren( + this.wrapHtml(!!cacheItem.block, cacheItem.html, cacheItem.copyContent), + ); + } + + // Async kick-off of content renderer + this.renderContent(innerDiv).catch(console.error); + this.dom = wrapperSpan; + return wrapperSpan; + } + + async renderContent( + div: HTMLElement, + ) { + const currentName = this.client.currentName(); + let widgetContent = await this.callback( + this.expressionText, + currentName, + ); + activeWidgets.add(this); + if (widgetContent === null || widgetContent === undefined) { + if (!this.renderEmpty) { + div.innerHTML = ""; + this.client.setWidgetCache( + this.cacheKey, + { html: "", block: false }, + ); + this.client.setCachedWidgetHeight(this.cacheKey, div.clientHeight); + return; + } + widgetContent = { markdown: "nil", _isWidget: true }; + } + + let html: HTMLElement | undefined; + let block = false; + let copyContent: string | undefined = undefined; + + // Normalization + if (typeof widgetContent === "string" || !widgetContent._isWidget) { + // Apply heuristic to render the object as a markdown table + widgetContent = { + _isWidget: true, + markdown: await renderExpressionResult(widgetContent), + }; + } + + if (widgetContent.cssClasses) { + div.className = widgetContent.cssClasses.join(" "); + } + if (widgetContent.html) { + if (typeof widgetContent.html === "string") { + html = parseHtmlString(widgetContent.html); + copyContent = widgetContent.html; + } else { + html = widgetContent.html; + copyContent = widgetContent.html.outerHTML; + } + + block = widgetContent.display === "block"; + if (block) { + div.className += " sb-lua-directive-block"; + } else { + div.className += " sb-lua-directive-inline"; + } + } + if (widgetContent.markdown) { + let mdTree = parse( + extendedMarkdownLanguage, + widgetContent.markdown || "", + ); + + mdTree = await expandMarkdown( + client.space, + currentName, + mdTree, + client.clientSystem.spaceLuaEnv, + ); + const trimmedMarkdown = renderToText(mdTree).trim(); + + copyContent = trimmedMarkdown; + + if (!trimmedMarkdown) { + // Net empty result after expansion + div.innerHTML = ""; + this.client.setWidgetCache( + this.cacheKey, + { html: "", block: false }, + ); + this.client.setCachedWidgetHeight(this.cacheKey, div.clientHeight); + return; + } + + block = widgetContent._isWidget && widgetContent.display === "block" || + isBlockMarkdown(trimmedMarkdown); + if (block) { + div.className += " sb-lua-directive-block"; + } else { + div.className += " sb-lua-directive-inline"; + } + + // Parse the markdown again after trimming + mdTree = parse( + extendedMarkdownLanguage, + trimmedMarkdown, + ); + + html = parseHtmlString(renderMarkdownToHtml(mdTree, { + shortWikiLinks: this.client.config.get("shortWikiLinks", false), + translateUrls: (url) => { + if (isLocalURL(url)) { + url = resolveMarkdownLink( + this.client.currentName(), + decodeURI(url), + ); + } + + return url; + }, + preserveAttributes: true, + }, this.client.ui.viewState.allPages)); + } + if (html) { + div.replaceChildren(this.wrapHtml(block, html, copyContent)); + attachWidgetEventHandlers( + div, + this.client, + this.inPage ? this.codeText : undefined, + widgetContent._isWidget && widgetContent.events, + ); + } + + // Let's give it a tick, then measure and cache + setTimeout(() => { + this.client.setWidgetCache( + this.cacheKey, + { + html: html?.outerHTML || "", + block, + copyContent: copyContent, + }, + ); + this.client.setCachedWidgetHeight(this.cacheKey, div.offsetHeight); + // Because of the rejiggering of the DOM, we need to do a no-op cursor move to make sure it's positioned correctly + this.client.editorView.dispatch({ + selection: this.client.editorView.state.selection, + }); + }); + } + + wrapHtml( + isBlock: boolean, + html: string | HTMLElement, + copyContent: string | undefined, + ): HTMLElement { + if (typeof html === "string") { + html = parseHtmlString(html); + } + if (!isBlock) { + return html; + } + const container = document.createElement("div"); + const buttonBar = document.createElement("div"); + buttonBar.className = "button-bar"; + + const createButton = ( + { title, icon, listener }: { + title: string; + icon: string; + listener: (event: MouseEvent) => void; + }, + ) => { + const button = document.createElement("button"); + button.setAttribute("data-button", title.toLowerCase()); + button.setAttribute("title", title); + button.innerHTML = icon; + button.addEventListener("click", listener); + + return button; + }; + + buttonBar.appendChild(createButton( + { + title: "Reload", + icon: + '', + listener: (e) => { + e.stopPropagation(); + this.client.clientSystem.localSyscall( + "system.invokeFunction", + ["index.refreshWidgets"], + ).catch(console.error); + }, + }, + )); + + if (copyContent) { + buttonBar.appendChild(createButton( + { + title: "Copy", + icon: + ``, + listener: (e) => { + e.stopPropagation(); + this.client.clientSystem.localSyscall( + "editor.copyToClipboard", + [copyContent], + ).catch(console.error); + }, + }, + )); + } + + if (this.inPage) { + buttonBar.appendChild(createButton( + { + title: "Edit", + icon: + '', + listener: (e) => { + e.stopPropagation(); + moveCursorIntoText(this.client, this.codeText); + }, + }, + )); + } + + if (this.openRef) { + buttonBar.appendChild(createButton( + { + title: "Open", + icon: + '', + listener: (e) => { + e.stopPropagation(); + this.client.navigate(this.openRef!); + }, + }, + )); + } + + const content = document.createElement("div"); + content.className = "content"; + content.appendChild(html); + + container.appendChild(buttonBar); + container.appendChild(content); + + return container; + } + + override eq(other: WidgetType): boolean { + return ( + other instanceof LuaWidget && + other.expressionText === this.expressionText && + other.cacheKey === this.cacheKey + ); + } +} + +export function renderExpressionResult(result: any): Promise { + if (result instanceof LuaTable) { + result = result.toJS(); + } + // Must check before object/array checks — tagged floats are plain objects + if (isTaggedFloat(result)) { + return Promise.resolve(luaFormatNumber(result.value, "float")); + } + if (typeof result === "number") { + return Promise.resolve(luaFormatNumber(result)); + } + if ( + Array.isArray(result) && result.length > 0 && typeof result[0] === "object" + ) { + // If result is an array of objects, render as a Markdown table + try { + return jsonToMDTable(result, refCellTransformer); + } catch (e: any) { + console.error( + `Error rendering expression directive: ${e.message} for value ${ + JSON.stringify(result) + }`, + ); + return Promise.resolve(JSON.stringify(result)); + } + } else if (typeof result === "object" && result.constructor === Object) { + // If result is a plain object, render as a Markdown table + return jsonToMDTable([result], refCellTransformer); + } else if (Array.isArray(result)) { + // Not-object array, let's render it as a Markdown list + return Promise.resolve(result.map((item) => `- ${item}`).join("\n")); + } else { + return Promise.resolve("" + result); + } +} + +export function parseHtmlString(html: string): HTMLElement { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, "text/html"); + // Create a wrapper div to hold all elements + const wrapper = document.createElement("span"); + wrapper.className = "wrapper"; + // Move all body children into the wrapper + while (doc.body.firstChild) { + wrapper.appendChild(doc.body.firstChild); + } + return wrapper; +} + + +================================================ +FILE: client/codemirror/smart_quotes.ts +================================================ +import type { KeyBinding } from "@codemirror/view"; +import { syntaxTree } from "@codemirror/language"; +import { EditorSelection } from "@codemirror/state"; +import type { Client } from "../client.ts"; + +import type { SmartQuotesConfig } from "../../plug-api/types/config.ts"; + +const straightQuoteContexts = [ + "CommentBlock", + "CodeBlock", + "CodeText", + "FencedCode", + "InlineCode", + "FrontMatterCode", + "Attribute", + "CommandLink", + "LuaDirective", +]; + +// TODO: Add support for selection (put quotes around or create blockquote block?) +function keyBindingForQuote( + originalQuote: string, + left: string, + right: string, +): KeyBinding { + return { + any: (target, event): boolean => { + // Moving this check here rather than using the regular "key" property because + // for some reason the "ä" key is not recognized as a quote key by CodeMirror. + if (event.key !== originalQuote) { + return false; + } + const cursorPos = target.state.selection.main.from; + const chBefore = target.state.sliceDoc(cursorPos - 1, cursorPos); + + // Figure out the context, if in some sort of code/comment fragment don't be smart + let node = syntaxTree(target.state).resolveInner(cursorPos); + while (node) { + if ( + straightQuoteContexts.find((sqc) => sqc.startsWith(node.type.name)) + ) { + return false; + } + if (node.parent) { + node = node.parent; + } else { + break; + } + } + + // Ok, still here, let's use a smart quote + const changes = target.state.changeByRange((range) => { + if (!range.empty) { + return { + changes: [ + { insert: left, from: range.from }, + { insert: right, from: range.to }, + ], + range: EditorSelection.range( + range.anchor + left.length, + range.head + left.length, + ), + }; + } else { + const quote = (/\W/.exec(chBefore) && !/[!\?,\.\-=“]/.exec(chBefore)) + ? left + : right; + + return { + changes: { + insert: quote, + from: cursorPos, + }, + range: EditorSelection.cursor( + range.anchor + quote.length, + ), + }; + } + }); + target.dispatch(changes); + + return true; + }, + }; +} + +export function createSmartQuoteKeyBindings(client: Client): KeyBinding[] { + const smartQuotes = client.config.get("smartQuotes", {}); + if ( + smartQuotes.enabled === false + ) { + return []; + } + + let doubleLeft = "“"; + let doubleRight = "”"; + let singleLeft = "‘"; + let singleRight = "’"; + if (typeof smartQuotes.double?.left === "string") { + doubleLeft = smartQuotes.double!.left; + } + if (typeof smartQuotes.double?.right === "string") { + doubleRight = smartQuotes.double!.right; + } + if (typeof smartQuotes.single?.left === "string") { + singleLeft = smartQuotes.single!.left; + } + if (typeof smartQuotes.single?.right === "string") { + singleRight = smartQuotes.single!.right; + } + + return [ + keyBindingForQuote('"', doubleLeft, doubleRight), + keyBindingForQuote("'", singleLeft, singleRight), + ]; +} + + +================================================ +FILE: client/codemirror/spell_checking.ts +================================================ +import type { EditorState, Range } from "@codemirror/state"; +import { Decoration } from "@codemirror/view"; +import { syntaxTree } from "@codemirror/language"; +import { decoratorStateField } from "./util.ts"; + +export function disableSpellcheck(selectors: string[]) { + return decoratorStateField((state: EditorState) => { + const widgets: Range[] = []; + syntaxTree(state).iterate({ + enter: ({ type, from, to }) => { + for (const selector of selectors) { + if (type.name === selector) { + widgets.push( + Decoration.mark({ + attributes: { spellcheck: "false" }, + }).range(from, to), + ); + } + } + }, + }); + + return Decoration.set(widgets, true); + }); +} + + +================================================ +FILE: client/codemirror/table.ts +================================================ +import type { EditorState } from "@codemirror/state"; +import { syntaxTree } from "@codemirror/language"; +import { Decoration, WidgetType } from "@codemirror/view"; +import { + decoratorStateField, + invisibleDecoration, + isCursorInRange, +} from "./util.ts"; + +import { renderMarkdownToHtml } from "../markdown_renderer/markdown_render.ts"; +import { + type ParseTree, + renderToText, +} from "@silverbulletmd/silverbullet/lib/tree"; +import { lezerToParseTree } from "../markdown_parser/parse_tree.ts"; +import type { Client } from "../client.ts"; +import { + isLocalURL, + resolveMarkdownLink, +} from "@silverbulletmd/silverbullet/lib/resolve"; +import { expandMarkdown } from "../markdown_renderer/inline.ts"; +import { attachWidgetEventHandlers } from "./widget_util.ts"; + +class TableViewWidget extends WidgetType { + tableBodyText: string; + + constructor( + readonly pos: number, + readonly client: Client, + readonly t: ParseTree, + ) { + super(); + this.tableBodyText = renderToText(t); + } + + override get estimatedHeight(): number { + return this.client.getCachedWidgetHeight( + `table:${this.tableBodyText}`, + ); + } + + toDOM(): HTMLElement { + const dom = document.createElement("span"); + dom.classList.add("sb-table-widget"); + dom.addEventListener("click", (e) => { + // Pulling data-pos to put the cursor in the right place, falling back + // to the start of the table. + const dataAttributes = (e.target as any).dataset; + this.client.editorView.dispatch({ + selection: { + anchor: dataAttributes.pos ? +dataAttributes.pos : this.pos, + }, + }); + }); + + expandMarkdown( + client.space, + client.currentName(), + this.t, + client.clientSystem.spaceLuaEnv, + ).then((t) => { + dom.innerHTML = renderMarkdownToHtml(t, { + // Annotate every element with its position so we can use it to put + // the cursor there when the user clicks on the table. + annotationPositions: true, + shortWikiLinks: this.client.config.get("shortWikiLinks", false), + translateUrls: (url) => { + if (isLocalURL(url)) { + url = resolveMarkdownLink( + this.client.currentName(), + decodeURI(url), + ); + } + + return url; + }, + preserveAttributes: true, + }); + setTimeout(() => { + // Give it a tick to render + attachWidgetEventHandlers(dom, this.client, this.tableBodyText); + + this.client.setCachedWidgetHeight( + `table:${this.tableBodyText}`, + dom.clientHeight, + ); + }); + }); + return dom; + } + + override eq(other: WidgetType): boolean { + return ( + other instanceof TableViewWidget && + other.tableBodyText === this.tableBodyText + ); + } +} + +export function tablePlugin(editor: Client) { + return decoratorStateField((state: EditorState) => { + const widgets: any[] = []; + syntaxTree(state).iterate({ + enter: (node) => { + const { from, to, name } = node; + if (name !== "Table") return; + if (isCursorInRange(state, [from, to])) return; + + const tableText = state.sliceDoc(from, to); + const lineStrings = tableText.split("\n"); + + const lines: { from: number; to: number }[] = []; + let fromIt = from; + for (const line of lineStrings) { + lines.push({ + from: fromIt, + to: fromIt + line.length, + }); + fromIt += line.length + 1; + } + + const firstLine = lines[0], lastLine = lines[lines.length - 1]; + + // In case of doubt, back out + if (!firstLine || !lastLine) return; + + widgets.push(invisibleDecoration.range(firstLine.from, firstLine.to)); + widgets.push(invisibleDecoration.range(lastLine.from, lastLine.to)); + + lines.slice(1, lines.length - 1).forEach((line) => { + widgets.push( + Decoration.line({ class: "sb-line-table-outside" }).range( + line.from, + ), + ); + }); + const text = state.sliceDoc(0, to); + widgets.push( + Decoration.widget({ + widget: new TableViewWidget( + from, + editor, + lezerToParseTree(text, node.node), + ), + }).range(from), + ); + }, + }); + return Decoration.set(widgets, true); + }); +} + + +================================================ +FILE: client/codemirror/task.ts +================================================ +import { syntaxTree } from "@codemirror/language"; +import { Decoration, WidgetType } from "@codemirror/view"; +import type { NodeType } from "@lezer/common"; +import { decoratorStateField, isCursorInRange } from "./util.ts"; + +/** + * Widget to render checkbox for a task list item. + */ +class CheckboxWidget extends WidgetType { + constructor( + public checked: boolean, + readonly pos: number, + readonly clickCallback: (pos: number) => void, + ) { + super(); + } + + toDOM(): HTMLElement { + const wrap = document.createElement("span"); + wrap.classList.add("sb-checkbox"); + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.checked = this.checked; + checkbox.addEventListener("click", (e) => { + e.stopPropagation(); + e.preventDefault(); + }); + checkbox.addEventListener("mouseup", (e) => { + e.stopPropagation(); + this.clickCallback(this.pos); + }); + wrap.appendChild(checkbox); + return wrap; + } +} + +export function taskListPlugin( + { onCheckboxClick }: { onCheckboxClick: (pos: number) => void }, +) { + return decoratorStateField((state) => { + const widgets: any[] = []; + syntaxTree(state).iterate({ + enter({ type, from, to, node }) { + if (type.name !== "Task") return; + // true/false if this is a checkbox, undefined when it's a custom-status task + let checkboxStatus: boolean | undefined; + // Iterate inside the task node to find the checkbox + node.toTree().iterate({ + enter: (ref) => iterateInner(ref.type, ref.from, ref.to), + }); + if (checkboxStatus === true) { + widgets.push( + Decoration.mark({ + tagName: "span", + class: "cm-task-checked", + }).range(from, to), + ); + } + + function iterateInner(type: NodeType, nfrom: number, nto: number) { + if (type.name !== "TaskState") return; + if (isCursorInRange(state, [from + nfrom, from + nto])) return; + const checkbox = state.sliceDoc(from + nfrom, from + nto); + // Checkbox is checked if it has a 'x' in between the [] + if (checkbox === "[x]" || checkbox === "[X]") { + checkboxStatus = true; + } else if (checkbox === "[ ]") { + checkboxStatus = false; + } + if (checkboxStatus === undefined) { + // Not replacing it with a widget + return; + } + const dec = Decoration.replace({ + widget: new CheckboxWidget( + checkboxStatus, + from + nfrom + 1, + onCheckboxClick, + ), + }); + widgets.push(dec.range(from + nfrom, from + nto)); + } + }, + }); + return Decoration.set(widgets, true); + }); +} + + +================================================ +FILE: client/codemirror/top_bottom_panels.ts +================================================ +import type { EditorState } from "@codemirror/state"; +import { Decoration, WidgetType } from "@codemirror/view"; +import type { Client } from "../client.ts"; +import { decoratorStateField } from "./util.ts"; +import { LuaWidget, type LuaWidgetContent } from "./lua_widget.ts"; +import { activeWidgets } from "./code_widget.ts"; + +class ArrayWidget extends WidgetType { + public dom?: HTMLElement; + + constructor( + readonly client: Client, + readonly cacheKey: string, + readonly callback: ( + pageName: string, + ) => Promise, + readonly childClass: string, + ) { + super(); + } + + override get estimatedHeight(): number { + return this.client.getCachedWidgetHeight(this.cacheKey); + } + + toDOM(): HTMLElement { + activeWidgets.add(this); + + const div = document.createElement("div"); + div.className = "sb-widget-array"; + + // This doesn't do that much, but it also doesn't really hurt + const cacheItem = this.client.getWidgetCache(this.cacheKey); + if (cacheItem) { + div.innerHTML = cacheItem.html; + } + + // Async kick-off of content renderer + this.renderContent(div).catch(console.error); + this.dom = div; + return div; + } + + async renderContent( + div: HTMLElement, + ) { + const content = await this.callback(this.client.currentName()); + if (!content) return; + + const renderedWidgets: HTMLElement[] = []; + + for (const [i, widgetContent] of content.entries()) { + // Filter out any "empty" widgets. Leaving the content empty, but + // returning a valid widgets, seems to be a common pattern + if ( + !widgetContent || + widgetContent === "" || + (widgetContent instanceof Object && + !widgetContent.markdown && + !widgetContent.html) + ) continue; + + const widget = new LuaWidget( + this.client, + `${this.cacheKey}:${i}`, + "", + "", + () => Promise.resolve(widgetContent), + false, + false, + null, + ); + + // Throw away the wrapper, as it only causes trouble and we are rewrapping + // anyways + const html = widget.toDOM().querySelector(":scope > div"); + if (!html) { + // This should never really happen, just in case + console.log("There was an error rendering one of the panel widgets"); + continue; + } + + html.classList.add(this.childClass); + + renderedWidgets.push(html); + } + + if (renderedWidgets.length === 0) { + div.style.display = "none"; + return; + } + + div.replaceChildren(...renderedWidgets); + + // Wait for the clientHeight to settle + setTimeout(() => { + this.client.setWidgetCache(this.cacheKey, { + block: true, + html: div.innerHTML, + }); + this.client.setCachedWidgetHeight(this.cacheKey, div.clientHeight); + }); + } + + override eq(other: WidgetType): boolean { + // This class isn't really used for stuff that's updated. If that's + // needed in the future, one could e.g. add a `bodyText` property again + return other instanceof ArrayWidget && other.cacheKey === this.cacheKey; + } +} + +export function postScriptPrefacePlugin( + editor: Client, +) { + return decoratorStateField((state: EditorState) => { + if (!editor.clientSystem.scriptsLoaded) { + // console.info("System not yet ready, not rendering panel widgets."); + return Decoration.none; + } + const widgets: any[] = []; + + widgets.push( + Decoration.widget({ + widget: new ArrayWidget( + editor, + `top:lua:${editor.currentPath()}`, + async () => + await client.dispatchAppEvent( + "hooks:renderTopWidgets", + ), + "sb-lua-top-widget", + ), + side: -1, + block: true, + }).range(0), + ); + + widgets.push( + Decoration.widget({ + widget: new ArrayWidget( + editor, + `bottom:lua:${editor.currentPath()}`, + async () => + await client.dispatchAppEvent( + "hooks:renderBottomWidgets", + ), + "sb-lua-bottom-widget", + ), + side: 1, + block: true, + }).range(state.doc.length), + ); + + return Decoration.set(widgets); + }); +} + + +================================================ +FILE: client/codemirror/util.ts +================================================ +// Forked from https://codeberg.org/retronav/ixora +// Original author: Pranav Karawale +// License: Apache License 2.0. +import { + type EditorState, + StateField, + type Transaction, +} from "@codemirror/state"; +import type { DecorationSet } from "@codemirror/view"; +import { Decoration, EditorView, WidgetType } from "@codemirror/view"; +import type { Client } from "../client.ts"; + +type LinkOptions = { + text: string; + href?: string; + title: string; + cssClass: string; + from: number; + callback: (e: MouseEvent) => void; +}; + +export class LinkWidget extends WidgetType { + constructor( + readonly options: LinkOptions, + ) { + super(); + } + + toDOM(): HTMLElement { + const anchor = document.createElement("a"); + anchor.className = this.options.cssClass; + anchor.textContent = this.options.text; + + // Mouse handling + anchor.addEventListener("click", (e) => { + if (e.button !== 0) { + return; + } + e.preventDefault(); + e.stopPropagation(); + try { + this.options.callback(e); + } catch (e) { + console.error("Error handling wiki link click", e); + } + }); + + // Touch handling + let touchCount = 0; + anchor.addEventListener("touchmove", () => { + touchCount++; + }); + anchor.addEventListener("touchend", (e) => { + if (touchCount === 0) { + e.preventDefault(); + e.stopPropagation(); + this.options.callback(new MouseEvent("click", e)); + } + touchCount = 0; + }); + anchor.setAttribute("title", this.options.title); + anchor.href = this.options.href || "#"; + return anchor; + } + + override eq(other: WidgetType): boolean { + return other instanceof LinkWidget && + this.options.from === other.options.from && + this.options.text === other.options.text && + this.options.href === other.options.href && + this.options.title === other.options.title; + } +} + +export class HtmlWidget extends WidgetType { + constructor( + readonly html: string, + readonly className?: string, + readonly onClick?: (e: MouseEvent) => void, + ) { + super(); + } + + toDOM(): HTMLElement { + const el = document.createElement("span"); + if (this.className) { + el.className = this.className; + } + if (this.onClick) { + el.addEventListener("click", this.onClick); + } + el.innerHTML = this.html; + return el; + } +} + +export function decoratorStateField( + stateToDecoratorMapper: (state: EditorState) => DecorationSet, +) { + return StateField.define({ + create(state: EditorState) { + return stateToDecoratorMapper(state); + }, + + update(value: DecorationSet, tr: Transaction) { + if (tr.isUserEvent("select.pointer")) return value; + return stateToDecoratorMapper(tr.state); + }, + + provide: (f) => EditorView.decorations.from(f), + }); +} + +export class ButtonWidget extends WidgetType { + constructor( + readonly text: string, + readonly title: string, + readonly cssClass: string, + readonly callback: (e: MouseEvent) => void, + ) { + super(); + } + + toDOM(): HTMLElement { + const anchor = document.createElement("button"); + anchor.className = this.cssClass; + anchor.textContent = this.text; + anchor.addEventListener("mouseup", (e) => { + e.preventDefault(); + e.stopPropagation(); + this.callback(e); + }); + anchor.setAttribute("title", this.title); + return anchor; + } +} + +/** + * Check if two ranges overlap + * Based on the visual diagram on https://stackoverflow.com/a/25369187 + * @param range1 - Range 1 + * @param range2 - Range 2 + * @returns True if the ranges overlap + */ +export function checkRangeOverlap( + range1: [number, number], + range2: [number, number], +) { + return range1[0] <= range2[1] && range2[0] <= range1[1]; +} + +/** + * Check if a range is inside another range + * @param parent - Parent (bigger) range + * @param child - Child (smaller) range + * @returns True if child is inside parent + */ +export function checkRangeSubset( + parent: [number, number], + child: [number, number], +) { + return child[0] >= parent[0] && child[1] <= parent[1]; +} + +/** + * Check if any of the editor cursors is in the given range + * @param state - Editor state + * @param range - Range to check + * @returns True if the cursor is in the range + */ +export function isCursorInRange(state: EditorState, range: [number, number]) { + return state.selection.ranges.some((selection) => + checkRangeOverlap(range, [selection.from, selection.to]) + ); +} + +/** + * Decoration to simply hide anything. + */ +export const invisibleDecoration = Decoration.replace({}); + +export function shouldRenderWidgets(client: Client) { + return client.systemReady && + client.currentPageMeta()?.pageDecoration?.renderWidgets !== false; +} + + +================================================ +FILE: client/codemirror/widget_util.ts +================================================ +import { parseToRef } from "@silverbulletmd/silverbullet/lib/ref"; +import type { Client } from "../client.ts"; +import type { EventPayLoad } from "./lua_widget.ts"; + +export function moveCursorIntoText(client: Client, textToFind: string) { + const allText = client.editorView.state.sliceDoc(); + const pos = allText.indexOf(textToFind); + if (pos === -1) { + console.error("Could not find position of widget in text", textToFind); + return; + } + client.editorView.dispatch({ + selection: { + anchor: pos, + }, + }); + client.focus(); +} + +export function attachWidgetEventHandlers( + div: HTMLElement, + client: Client, + widgetText?: string, + events?: Record void>, +) { + div.addEventListener("mousedown", (e) => { + if (e.altKey && widgetText) { + // Move cursor there + moveCursorIntoText(client, widgetText); + e.preventDefault(); + } + // CodeMirror overrides mousedown on parent elements to implement its own selection highlighting. + // That's nice, but not for markdown widgets, so let's not propagate the event to CodeMirror here. + e.stopPropagation(); + }); + + div.addEventListener("mouseup", (e) => { + // Same as above + e.stopPropagation(); + }); + + // Override wiki links with local navigate (faster) + div.querySelectorAll("a[data-ref]").forEach((el_) => { + const el = el_ as HTMLElement; + // Override default click behavior with a local navigate (faster) + el.addEventListener("click", (e) => { + if (e.ctrlKey || e.metaKey) { + // Don't do anything special for ctrl/meta clicks + return; + } + e.preventDefault(); + e.stopPropagation(); + client.navigate( + parseToRef(el.dataset.ref!), + false, + e.ctrlKey || e.metaKey, + ); + }); + }); + + div.querySelectorAll("button[data-onclick]").forEach((el_) => { + const el = el_ as HTMLElement; + const onclick = el.dataset.onclick!; + const parsedOnclick = JSON.parse(onclick); + if (parsedOnclick[0] === "command") { + const command = parsedOnclick[1]; + el.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + console.info( + "Command link clicked in widget, running", + parsedOnclick, + ); + client.runCommandByName(command, parsedOnclick[2]).catch( + console.error, + ); + }); + } + }); + + // Implement task toggling + div.querySelectorAll("span[data-external-task-ref]").forEach((el: any) => { + const taskRef = el.dataset.externalTaskRef; + const input = el.querySelector("input[type=checkbox]"); + if (input) { + input.addEventListener( + "click", + (e: any) => { + // Avoid triggering the click on the parent + e.stopPropagation(); + }, + ); + input.addEventListener( + "change", + (e: any) => { + e.stopPropagation(); + const oldState = e.target.dataset.state; + const newState = oldState === " " ? "x" : " "; + // Update state in DOM as well for future toggles + e.target.dataset.state = newState; + console.log("Toggling task", taskRef); + client.clientSystem.localSyscall( + "system.invokeFunction", + ["index.updateTaskState", taskRef, oldState, newState], + ).catch( + console.error, + ); + }, + ); + } + }); + + if (events) { + for (const [eventName, event] of Object.entries(events)) { + div.addEventListener(eventName, (e) => { + event({ name: eventName, data: e }); + }); + } + } +} + + +================================================ +FILE: client/codemirror/wiki_link.ts +================================================ +import { syntaxTree } from "@codemirror/language"; +import { Decoration } from "@codemirror/view"; +import type { Client } from "../client.ts"; +import { decoratorStateField } from "./util.ts"; +import type { ClickEvent } from "@silverbulletmd/silverbullet/type/client"; +import { wikiLinkRegex } from "../markdown_parser/constants.ts"; +import { processWikiLink, type WikiLinkMatch } from "./wiki_link_processor.ts"; + +/** + * Plugin to hide path prefix when the cursor is not inside. + */ +export function cleanWikiLinkPlugin(client: Client) { + return decoratorStateField((state) => { + const widgets: any[] = []; + const shortWikiLinks = client.config.get("shortWikiLinks", true); + + syntaxTree(state).iterate({ + enter: ({ type, from, to }) => { + if (type.name !== "WikiLink") { + return; + } + + const text = state.sliceDoc(from, to); + + wikiLinkRegex.lastIndex = 0; + const match = wikiLinkRegex.exec(text); + if (!match || !match.groups) { + return; + } + + const wikiLinkMatch: WikiLinkMatch = { + leadingTrivia: match.groups.leadingTrivia, + stringRef: match.groups.stringRef, + alias: match.groups.alias, + trailingTrivia: match.groups.trailingTrivia, + }; + + const decorations = processWikiLink({ + from, + to, + match: wikiLinkMatch, + matchFrom: from, + matchTo: to, + client, + shortWikiLinks, + state, + callback: (e) => { + if (e.altKey) { + // Move cursor into the link + client.editorView.dispatch({ + selection: { + anchor: from + wikiLinkMatch.leadingTrivia.length, + }, + }); + client.focus(); + return; + } + // Dispatch click event to navigate there without moving the cursor + const clickEvent: ClickEvent = { + page: client.currentName(), + ctrlKey: e.ctrlKey, + metaKey: e.metaKey, + altKey: e.altKey, + pos: from, + }; + client.dispatchClickEvent(clickEvent).catch( + console.error, + ); + }, + }); + + widgets.push(...decorations); + }, + }); + return Decoration.set(widgets, true); + }); +} + + +================================================ +FILE: client/codemirror/wiki_link_processor.ts +================================================ +import type { EditorState } from "@codemirror/state"; +import { Decoration } from "@codemirror/view"; +import type { Client } from "../client.ts"; +import { + fileName, + isBuiltinPath, +} from "@silverbulletmd/silverbullet/lib/resolve"; +import { + encodePageURI, + encodeRef, + getNameFromPath, + parseToRef, +} from "@silverbulletmd/silverbullet/lib/ref"; +import { isCursorInRange, LinkWidget } from "./util.ts"; + +export interface WikiLinkMatch { + leadingTrivia: string; + stringRef: string; + alias?: string; + trailingTrivia: string; +} + +export interface WikiLinkProcessorOptions { + from: number; + to: number; + match: WikiLinkMatch; + matchFrom: number; + matchTo: number; + client: Client; + state: EditorState; + shortWikiLinks: boolean; + callback: (e: MouseEvent, ref: any) => void; +} + +export function processWikiLink(options: WikiLinkProcessorOptions): any[] { + const { from, to, match, matchFrom, matchTo, client, state, callback } = + options; + const widgets: any[] = []; + + const { leadingTrivia, stringRef, alias, trailingTrivia } = match; + const ref = parseToRef(stringRef); + + let linkStatus: "file-missing" | "default" | "invalid" = "default"; + + if (!ref) { + linkStatus = "invalid"; + } else if (ref.path === "" || isBuiltinPath(ref.path)) { + linkStatus = "default"; + } else if ( + Array.from(client.clientSystem.allKnownFiles).some((file) => + file === ref.path + ) + ) { + linkStatus = "default"; + } else if (client.fullSyncCompleted || client.clientSystem.knownFilesLoaded) { + linkStatus = "file-missing"; + } + + let css = { + "file-missing": "sb-wiki-link-missing", + "invalid": "sb-wiki-link-invalid", + "default": "", + }[linkStatus]; + + const renderingSyntax = client.ui.viewState.uiOptions.markdownSyntaxRendering; + + if (isCursorInRange(state, [from, to]) || renderingSyntax) { + // Only attach a CSS class, then get out + if (linkStatus !== "default") { + widgets.push( + Decoration.mark({ + class: css, + }).range(from + leadingTrivia.length, to - trailingTrivia.length), + ); + } + return widgets; + } + + const cleanedPath = ref ? getNameFromPath(ref.path) : stringRef; + const helpText = { + "default": `Navigate to ${cleanedPath}`, + "file-missing": `Create ${cleanedPath}`, + "invalid": `Cannot create invalid file ${cleanedPath}`, + }[linkStatus]; + + let linkText = alias || stringRef; + + // The `&& ref` is only there to make typescript happy + if (linkStatus === "default" && ref) { + const meta = client.ui.viewState.allPages.find((p) => + parseToRef(p.ref)?.path === ref.path + ); + + const renderedRef = structuredClone(ref); + + // We don't want to render the meta + renderedRef.meta = false; + // We also don't want to rendered the prefix of the path + renderedRef.path = options.shortWikiLinks + ? fileName(renderedRef.path) + : renderedRef.path; + + const prefix = (ref.details?.type === "position" || + ref.details?.type === "linecolumn") + ? "" + : (meta?.pageDecoration?.prefix ?? ""); + + linkText = alias || (prefix + encodeRef(renderedRef)); + + if (meta?.pageDecoration?.cssClasses) { + css += " sb-decorated-object " + + meta.pageDecoration.cssClasses + .join(" ") + .replaceAll(/[^a-zA-Z0-9-_ ]/g, ""); + } + } + + widgets.push( + Decoration.replace({ + widget: new LinkWidget({ + text: linkText, + title: helpText, + href: ref ? encodePageURI(encodeRef(ref)) : undefined, + cssClass: "sb-wiki-link " + css, + from: matchFrom, + callback: (e) => callback(e, ref), + }), + }).range(matchFrom, matchTo), + ); + + return widgets; +} + + +================================================ +FILE: client/components/anything_picker.tsx +================================================ +import { FilterList } from "./filter.tsx"; +import type { FilterOption } from "@silverbulletmd/silverbullet/type/client"; +import { tagRegex as mdTagRegex } from "../markdown_parser/constants.ts"; +import { extractHashtag } from "@silverbulletmd/silverbullet/lib/tags"; +import type { + DocumentMeta, + PageMeta, +} from "@silverbulletmd/silverbullet/type/index"; +import { + getNameFromPath, + parseToRef, + type Path, +} from "@silverbulletmd/silverbullet/lib/ref"; +import { folderName } from "@silverbulletmd/silverbullet/lib/resolve"; + +const tagRegex = new RegExp(mdTagRegex.source, "g"); + +export function AnythingPicker({ + allPages, + allDocuments, + extensions, + onNavigate, + onModeSwitch, + vimMode, + mode, + darkMode, + currentPath, +}: { + allDocuments: DocumentMeta[]; + allPages: PageMeta[]; + extensions: Set; + vimMode: boolean; + darkMode?: boolean; + mode: "page" | "meta" | "document" | "all"; + onNavigate: (name: string | null) => void; + onModeSwitch: (mode: "page" | "meta" | "document" | "all") => void; + currentPath: Path; +}) { + const options: FilterOption[] = []; + + if (mode === "document" || mode === "all") { + for (const documentMeta of allDocuments) { + const isViewable = extensions.has(documentMeta.extension); + + let orderId = isViewable + ? -new Date(documentMeta.lastModified).getTime() + : (Number.MAX_VALUE - new Date(documentMeta.lastModified).getTime()); + + if (currentPath === documentMeta.name) { + orderId = Infinity; + } + + // Can't really add tags to document as of right now, but maybe in the future + let description: string | undefined; + if (documentMeta.tags) { + description = (description || "") + + documentMeta.tags.map((tag) => `#${tag}`).join(" "); + } + + if (!isViewable && client.clientSystem.readOnlyMode) { + continue; + } + + options.push({ + type: "document", + meta: documentMeta, + name: documentMeta.name, + description, + orderId: orderId, + hint: documentMeta.name.split(".").pop()?.toUpperCase(), + hintInactive: !isViewable, + }); + } + } + + if (mode !== "document") { + for (const pageMeta of allPages) { + // Sanitize the page name + if (!pageMeta.name) { + pageMeta.name = pageMeta.ref; + } + // Order by last modified date in descending order + let orderId = -new Date(pageMeta.lastModified).getTime(); + // Unless it was opened in this session + if (pageMeta.lastOpened) { + orderId = -pageMeta.lastOpened; + } + // Or it's the currently open page + if ( + currentPath === `${pageMeta.name}.md` || pageMeta._isAspiring + ) { + // ... then we put it all the way to the end + orderId = Infinity; + } + const cssClass = (pageMeta.pageDecoration?.cssClasses || []).join(" ") + .replaceAll(/[^a-zA-Z0-9-_ ]/g, ""); + + if (mode === "page") { + // Special behavior for regular pages + let description: string | undefined; + let aliases: string[] = []; + if (pageMeta.displayName) { + aliases.push(pageMeta.displayName); + } + if (Array.isArray(pageMeta.aliases)) { + aliases = aliases.concat(pageMeta.aliases); + } + if (aliases.length > 0) { + description = "(a.k.a. " + aliases.join(", ") + ") "; + } + if (pageMeta.tags) { + description = (description || "") + + pageMeta.tags.map((tag) => `#${tag}`).join(" "); + } + options.push({ + type: "page", + meta: pageMeta, + name: pageMeta.name, + prefix: pageMeta.pageDecoration?.prefix, + description, + orderId: orderId, + hint: pageMeta._isAspiring ? "Create page" : undefined, + cssClass, + }); + } else if (mode === "meta") { + // Special behavior for #meta pages + if (pageMeta._isAspiring) { + // Skip over broken links + continue; + } + options.push({ + type: "page", + meta: pageMeta, + name: pageMeta.name, + description: pageMeta.description + ? pageMeta.description.slice(0, 200) + : "", + hint: pageMeta.tags![0], + orderId: orderId, + cssClass, + }); + } else { // all + // In mode "all" just show the full path and all tags + let description: string | undefined; + if (pageMeta.tags) { + description = pageMeta.tags.map((tag) => `#${tag}`).join(" "); + } + options.push({ + type: "page", + meta: pageMeta, + name: pageMeta.name, + description, + orderId: orderId, + cssClass, + }); + } + } + } + + const completePrefix = + (folderName(currentPath) || getNameFromPath(currentPath)) + "/"; + + const allowNew = mode !== "document"; + const creatablePageNoun = mode !== "all" ? mode : "page"; + const openablePageNoun = mode !== "all" ? mode : "page or document"; + + return ( + { + phrase = phrase.replaceAll(tagRegex, "").trim(); + return phrase; + }} + onKeyPress={(view, event) => { + const text = view.state.sliceDoc(); + // Pages cannot start with ^, as documented in Page Name Rules + if (event.key === "^" && text === "^") { + switch (mode) { + case "page": + onModeSwitch("meta"); + break; + case "meta": + onModeSwitch("document"); + break; + case "document": + onModeSwitch("all"); + break; + case "all": + onModeSwitch("page"); + break; + } + return true; + } + return false; + }} + preFilter={(options, phrase) => { + if (mode === "page") { + const allTags = phrase.match(tagRegex); + if (allTags) { + // Search phrase contains hash tags, let's pre-filter the results based on this + const filterTags = allTags.map((t) => extractHashtag(t)); + options = options.filter((page) => { + if (!page.meta.tags) { + return false; + } + return filterTags.every((tag) => + page.meta.tags.find((itemTag: string) => + itemTag.startsWith(tag) + ) + ); + }); + } + // Remove pages that are tagged as templates or meta + options = options.filter((page) => !isMetaPageOption(page)); + } else if (mode === "meta") { + // Filter on pages tagged with "template" or "meta" prefix + options = options.filter(isMetaPageOption); + } + + if (mode !== "all") { + // Filter out hidden pages + options = options.filter((page) => + !(page.meta.pageDecoration?.hide === true) + ); + } + return options; + }} + allowNew={allowNew} + helpText={`Press Enter to open the selected ${openablePageNoun}` + + (allowNew + ? `, or Shift-Enter to create a new ${creatablePageNoun} with this exact name.` + : "")} + newHint={`Create ${creatablePageNoun}`} + completePrefix={completePrefix} + onSelect={(opt) => { + if (!opt) { + onNavigate(null); + return; + } + + const ref: string | undefined = opt.meta?.ref; + const path = ref ? parseToRef(ref)?.path : null; + const name = path ? getNameFromPath(path) : opt.name; + onNavigate(name); + }} + /> + ); +} + +function isMetaPageOption(page: FilterOption) { + return page.meta.tags?.includes("template") || + page.meta.tags?.find((tag: string) => tag.startsWith("meta")); +} + + +================================================ +FILE: client/components/basic_modals.tsx +================================================ +import { useEffect, useRef, useState } from "preact/hooks"; +import { MiniEditor } from "./mini_editor.tsx"; +import type { ComponentChildren, Ref } from "preact"; + +export function Prompt({ + message, + defaultValue, + vimMode, + darkMode, + callback, +}: { + message: string; + defaultValue?: string; + vimMode: boolean; + darkMode: boolean | undefined; + callback: (value?: string) => void; +}) { + const [text, setText] = useState(defaultValue || ""); + const returnEl = ( + { + callback(); + }} + > +
+ + { + callback(text); + return true; + }} + onEscape={() => { + callback(); + }} + onChange={(text) => { + setText(text); + }} + editable={true} + /> +
+ + +
+
+
+ ); + + return returnEl; +} + +export function Confirm({ + message, + callback, +}: { + message: string; + callback: (value: boolean) => void; +}) { + const okButtonRef = useRef(null); + setTimeout(() => { + okButtonRef.current?.focus(); + }); + const returnEl = ( + { + callback(false); + }} + > +
+ +
+ + +
+
+
+ ); + + return returnEl; +} + +export function Button({ + children, + primary, + onActivate, + buttonRef, +}: { + children: ComponentChildren; + primary?: boolean; + onActivate: () => void; + buttonRef?: Ref; +}) { + return ( + + ); +} + +export function AlwaysShownModal({ + children, + onCancel, +}: { + children: ComponentChildren; + onCancel?: () => void; +}) { + const dialogRef = useRef(null); + + useEffect(() => { + dialogRef.current?.showModal(); + }, []); + + return ( + { + e.preventDefault(); + onCancel?.(); + }} + onKeyDown={(e) => { + e.stopPropagation(); + }} + ref={dialogRef} + > + {children} + + ); +} + + +================================================ +FILE: client/components/command_palette.tsx +================================================ +import { FilterList } from "./filter.tsx"; +import { Terminal } from "preact-feather"; +import type { Command } from "../types/command.ts"; +import type { FilterOption } from "@silverbulletmd/silverbullet/type/client"; +import { isMacLike } from "../codemirror/editor_state.ts"; + +export function CommandPalette({ + commands, + onTrigger, + vimMode, + darkMode, +}: { + commands: Map; + vimMode: boolean; + darkMode?: boolean; + onTrigger: (command: Command | undefined) => void; +}) { + const options: FilterOption[] = []; + for (const [name, def] of commands.entries()) { + if (def.hide) { + continue; + } + + options.push({ + name: name, + hint: keyboardHint(def), + orderId: def.lastRun !== undefined + ? -def.lastRun + : def.priority || Infinity, + }); + // console.log("Options", options); + } + return ( + { + if (opt) { + onTrigger(commands.get(opt.name)); + } else { + onTrigger(undefined); + } + }} + /> + ); +} + +function keyboardHint(def: Command): string | undefined { + const shortcuts: string[] = []; + if (isMacLike && def.mac) { + if (Array.isArray(def.mac)) { + shortcuts.push(...def.mac); + } else { + shortcuts.push(def.mac); + } + } else if (def.key) { + if (Array.isArray(def.key)) { + shortcuts.push(...def.key); + } else { + shortcuts.push(def.key); + } + } + return shortcuts.length > 0 ? shortcuts.join(" | ") : undefined; +} + + +================================================ +FILE: client/components/filter.tsx +================================================ +import type { FeatherProps } from "preact-feather/types"; +import type { FunctionalComponent } from "preact"; +import { useEffect, useRef, useState } from "preact/hooks"; +import type { FilterOption } from "@silverbulletmd/silverbullet/type/client"; +import { MiniEditor } from "./mini_editor.tsx"; +import { fuzzySearchAndSort } from "../lib/fuse_search.ts"; +import { deepEqual } from "../../plug-api/lib/json.ts"; +import { AlwaysShownModal } from "./basic_modals.tsx"; +import type { EditorView } from "@codemirror/view"; + +export function FilterList({ + placeholder, + options, + label, + onSelect, + onKeyPress, + vimMode, + darkMode, + preFilter, + phrasePreprocessor, + allowNew = false, + helpText = "", + completePrefix, + icon: Icon, + newHint, +}: { + placeholder: string; + options: FilterOption[]; + label: string; + onKeyPress?: (view: EditorView, event: KeyboardEvent) => boolean; + onSelect: (option: FilterOption | undefined) => void; + preFilter?: (options: FilterOption[], phrase: string) => FilterOption[]; + phrasePreprocessor?: (phrase: string) => string; + vimMode: boolean; + darkMode?: boolean; + allowNew?: boolean; + completePrefix?: string; + helpText: string; + newHint?: string; + icon?: FunctionalComponent; +}) { + const [text, setText] = useState(""); + const [matchingOptions, setMatchingOptions] = useState( + fuzzySearchAndSort( + preFilter ? preFilter(options, "") : options, + "", + ), + ); + const [selectedOption, setSelectionOption] = useState(0); + + const selectedElementRef = useRef(null); + + function updateFilter(originalPhrase: string) { + const prefilteredOptions = preFilter + ? preFilter(options, originalPhrase) + : options; + if (phrasePreprocessor) { + originalPhrase = phrasePreprocessor(originalPhrase); + } + const results = fuzzySearchAndSort(prefilteredOptions, originalPhrase); + const foundExactMatch = !!results.find((result) => + result.name === originalPhrase + ); + if (allowNew && !foundExactMatch && originalPhrase) { + results.splice(1, 0, { + name: originalPhrase, + hint: newHint, + }); + } + + if (!deepEqual(matchingOptions, results)) { + // Only do this (=> rerender of UI) if the results have changed + setMatchingOptions(results); + setSelectionOption(0); + } + } + + useEffect(() => { + updateFilter(text); + }, [options, text]); + + useEffect(() => { + function closer() { + onSelect(undefined); + } + + document.addEventListener("click", closer); + + return () => { + document.removeEventListener("click", closer); + }; + }, []); + + const returnEl = ( + { + onSelect(undefined); + }} + > +
{ + // Allow tapping/clicking the header without closing it + e.stopPropagation(); + }} + > + + { + onSelect( + shiftDown + ? { name: text, type: "page" } + : matchingOptions[selectedOption], + ); + return true; + }} + onEscape={() => { + onSelect(undefined); + }} + onChange={(text) => { + setText(text); + }} + onKeyUp={(view, e) => { + if (e.code === "Space" && e.altKey) { + if (matchingOptions.length > 0) { + const text = view.state.sliceDoc().trimEnd(); // space already added, remove it + const option = matchingOptions[0]; + if (option.name.toLowerCase().startsWith(text.toLowerCase())) { + // If the prefixes are the same, add one more segment + let nextSlash = option.name.indexOf("/", text.length + 1); + if (nextSlash === -1) { + nextSlash = Infinity; + } + setText(option.name.slice(0, nextSlash)); + } else { + setText(`${option.name.split("/")[0]}/`); + } + } + return true; + } + if (onKeyPress) { + return onKeyPress(view, e); + } + return false; + }} + onKeyDown={(view, e) => { + if ( + e.key === "ArrowUp" || + e.ctrlKey && e.key === "p" + ) { + setSelectionOption(Math.max(0, selectedOption - 1)); + } else if ( + e.key === "ArrowDown" || + e.ctrlKey && e.key === "n" + ) { + setSelectionOption( + Math.min(matchingOptions.length - 1, selectedOption + 1), + ); + } else if (e.key === "PageUp") { + setSelectionOption(Math.max(0, selectedOption - 5)); + } else if (e.key === "PageDown") { + setSelectionOption( + Math.min(matchingOptions.length - 1, selectedOption + 5), + ); + } else if (e.key === "Home") { + setSelectionOption(0); + } else if (e.key === "End") { + setSelectionOption(matchingOptions.length - 1); + } else if ( + (e.key === " ") && completePrefix && + (view.state.sliceDoc() === "") + ) { + setText(completePrefix); + } else { + return false; + } + + setTimeout(() => { + selectedElementRef.current?.scrollIntoView({ + block: "nearest", + }); + }); + + return true; + }} + editable={true} + /> +
+
+
+
+ {matchingOptions && matchingOptions.length > 0 + ? (() => { + let optionIndex = 0; + + return matchingOptions.map((option) => { + const currentOptionIndex = optionIndex; + optionIndex++; + + return ( +
{ + if (selectedOption !== currentOptionIndex) { + setSelectionOption(currentOptionIndex); + } + }} + onClick={(e) => { + e.stopPropagation(); + onSelect(option); + }} + > + {Icon && ( + + + + )} + + {(option.prefix ?? "") + option.name} + + {option.hint && ( + + {option.hint} + + )} +
+ {option.description} +
+
+ ); + }); + })() + : null} +
+
+ ); + + return returnEl; +} + + +================================================ +FILE: client/components/mini_editor.tsx +================================================ +import { useEffect, useRef } from "preact/hooks"; +import { history, historyKeymap, standardKeymap } from "@codemirror/commands"; +import { EditorState } from "@codemirror/state"; +import { + EditorView, + keymap, + placeholder, + ViewPlugin, + type ViewUpdate, +} from "@codemirror/view"; +import { getCM as vimGetCm, Vim, vim } from "@replit/codemirror-vim"; +import { createCommandKeyBindings } from "../codemirror/editor_state.ts"; + +type MiniEditorEvents = { + onEnter: (newText: string, shiftDown?: boolean) => void; + onEscape?: (newText: string) => void; + onBlur?: (newText: string) => void | Promise; + onChange?: (newText: string) => void; + onKeyUp?: (view: EditorView, event: KeyboardEvent) => boolean; + onKeyDown?: (view: EditorView, event: KeyboardEvent) => boolean; +}; + +export function MiniEditor( + { + text, + placeholderText, + vimMode, + darkMode, + vimStartInInsertMode, + onBlur, + onEscape, + onKeyUp, + onKeyDown, + onEnter, + onChange, + focus, + editable, + }: { + text: string; + placeholderText?: string; + vimMode: boolean; + darkMode?: boolean; + vimStartInInsertMode?: boolean; + focus?: boolean; + editable: boolean; + } & MiniEditorEvents, +) { + const editorDiv = useRef(null); + const editorViewRef = useRef(); + const vimModeRef = useRef("normal"); + // TODO: This super duper ugly, but I don't know how to avoid it + // Due to how MiniCodeEditor is built, it captures the closures of all callback functions + // which results in them pointing to old state variables, to avoid this we do this... + const callbacksRef = useRef(); + + useEffect(() => { + const currentEditorDiv = editorDiv.current; + if (currentEditorDiv) { + // console.log("Creating editor view"); + const editorView = new EditorView({ + state: buildEditorState(), + parent: currentEditorDiv, + }); + editorViewRef.current = editorView; + + const focusEditorView = editorView.focus.bind(editorView); + currentEditorDiv.addEventListener("focusin", focusEditorView); + + if (focus) { + editorView.focus(); + // Put cursor at the very end + editorView.dispatch({ + selection: { anchor: text.length }, + }); + } + + return () => { + currentEditorDiv.removeEventListener("focusin", focusEditorView); + if (editorViewRef.current) { + editorViewRef.current.destroy(); + } + }; + } + }, [editorDiv, placeholderText]); + + useEffect(() => { + callbacksRef.current = { + onBlur, + onEnter, + onEscape, + onKeyUp, + onKeyDown, + onChange, + }; + }); + + useEffect(() => { + if (editorViewRef.current) { + const currentEditorText = editorViewRef.current.state.sliceDoc(); + if (currentEditorText !== text) { + editorViewRef.current.setState(buildEditorState()); + editorViewRef.current.dispatch({ + selection: { anchor: text.length }, + }); + } + } + }, [text, vimMode]); + + let onBlurred = false, onEntered = false; + + return ( +
{ + let stopPropagation = false; + if (callbacksRef.current!.onKeyDown) { + stopPropagation = callbacksRef.current!.onKeyDown( + editorViewRef.current!, + e, + ); + } + if (stopPropagation) { + e.preventDefault(); + e.stopPropagation(); + } + }} + ref={editorDiv} + /> + ); + + function buildEditorState() { + // When vim mode is active, we need for CM to have created the new state + // and the subscribe to the vim mode's events + // This needs to happen in the next tick, so we wait a tick with setTimeout + if (vimMode) { + // Only applies to vim mode + setTimeout(() => { + const cm = vimGetCm(editorViewRef.current!)!; + cm.on("vim-mode-change", ({ mode }: { mode: string }) => { + vimModeRef.current = mode; + }); + if (vimStartInInsertMode) { + Vim.handleKey(cm, "i", "+input"); + } + }); + } + return EditorState.create({ + doc: text, + extensions: [ + EditorView.theme({}, { dark: darkMode }), + // Insert command bindings before vim-mode to ensure they're available + // in normal mode. See editor_state.ts for more details. + createCommandKeyBindings(globalThis.client), + // Enable vim mode, or not + [...vimMode ? [vim()] : []], + [ + ...editable + ? [] + : [EditorView.editable.of(false), EditorState.readOnly.of(true)], + ], + history(), + [...placeholderText ? [placeholder(placeholderText)] : []], + keymap.of([ + { + key: "Enter", + run: (view) => { + onEnter(view, false); + return true; + }, + }, + { + key: "Shift-Enter", + run: (view) => { + onEnter(view, true); + return true; + }, + }, + { + key: "Escape", + run: (view) => { + callbacksRef.current!.onEscape && + callbacksRef.current!.onEscape(view.state.sliceDoc()); + return true; + }, + }, + ...standardKeymap, + ...historyKeymap, + ]), + EditorView.domEventHandlers({ + click: (e) => { + e.stopPropagation(); + }, + keyup: (event, view) => { + if (event.key === "Escape") { + // Esc should be handled by the keymap + return false; + } + if (event.key === "Enter") { + // Enter should be handled by the keymap, except when in Vim normal mode + // because then it's disabled + if (vimMode && vimModeRef.current === "normal") { + onEnter(view, event.shiftKey); + return true; + } + return false; + } + if (callbacksRef.current!.onKeyUp) { + return callbacksRef.current!.onKeyUp(view, event); + } + return false; + }, + blur: (_e, view) => { + onBlur(view); + }, + }), + + ViewPlugin.fromClass( + class { + update(update: ViewUpdate): void { + if (update.docChanged) { + callbacksRef.current!.onChange && + callbacksRef.current!.onChange(update.state.sliceDoc()); + } + } + }, + ), + ], + }); + + // Avoid double triggering these events (may happen due to onkeypress vs onkeyup delay) + function onEnter(view: EditorView, shiftDown: boolean) { + if (onEntered) { + return; + } + onEntered = true; + callbacksRef.current!.onEnter(view.state.sliceDoc(), shiftDown); + // Event may occur again in 500ms + setTimeout(() => { + onEntered = false; + }, 500); + } + + function onBlur(view: EditorView) { + if (onBlurred || onEntered) { + return; + } + onBlurred = true; + if (callbacksRef.current!.onBlur) { + Promise.resolve(callbacksRef.current!.onBlur(view.state.sliceDoc())) + .catch(() => { + // Reset the state + view.setState(buildEditorState()); + }); + } + // Event may occur again in 500ms + setTimeout(() => { + onBlurred = false; + }, 500); + } + } +} + + +================================================ +FILE: client/components/panel.tsx +================================================ +import { useEffect, useMemo, useRef } from "preact/hooks"; +import type { Client } from "../client.ts"; +import type { PanelConfig } from "../types/ui.ts"; +import { panelHtml } from "./panel_html.ts"; + +export function Panel({ + config, + editor, +}: { + config: PanelConfig; + editor: Client; +}) { + const iFrameRef = useRef(null); + + const html = useMemo(() => { + return panelHtml.replace("{{.HostPrefix}}", document.baseURI); + }, []); + + function updateContent() { + if (!iFrameRef.current?.contentWindow) { + return; + } + + iFrameRef.current.contentWindow.postMessage({ + type: "html", + html: config.html, + script: config.script, + theme: document.getElementsByTagName("html")[0].dataset.theme, + }); + } + + useEffect(() => { + const iframe = iFrameRef.current; + if (!iframe) { + return; + } + + iframe.addEventListener("load", updateContent); + updateContent(); + + return () => { + iframe.removeEventListener("load", updateContent); + }; + }, [config.html, config.script]); + + useEffect(() => { + const messageListener = (evt: any) => { + if (evt.source !== iFrameRef.current!.contentWindow) { + return; + } + const data = evt.data; + if (!data) { + return; + } + switch (data.type) { + case "syscall": { + const { id, name, args } = data; + editor.clientSystem.localSyscall(name, args).then( + (result) => { + if (!iFrameRef.current?.contentWindow) { + // iFrame already went away + return; + } + iFrameRef.current!.contentWindow!.postMessage({ + type: "syscall-response", + id, + result, + }); + }, + ).catch((e: any) => { + if (!iFrameRef.current?.contentWindow) { + // iFrame already went away + return; + } + iFrameRef.current!.contentWindow!.postMessage({ + type: "syscall-response", + id, + error: e.message, + }); + }); + break; + } + } + }; + globalThis.addEventListener("message", messageListener); + return () => { + globalThis.removeEventListener("message", messageListener); + }; + }, []); + + return ( +
+ + + + diff --git a/node_modules/.deno/playwright-core@1.58.2/node_modules/playwright-core/lib/vite/traceViewer/sw.bundle.js b/node_modules/.deno/playwright-core@1.58.2/node_modules/playwright-core/lib/vite/traceViewer/sw.bundle.js new file mode 100644 index 0000000..9621383 --- /dev/null +++ b/node_modules/.deno/playwright-core@1.58.2/node_modules/playwright-core/lib/vite/traceViewer/sw.bundle.js @@ -0,0 +1,5 @@ +var $s=Object.defineProperty;var Js=(s,t,e)=>t in s?$s(s,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):s[t]=e;var We=(s,t,e)=>Js(s,typeof t!="symbol"?t+"":t,e);class Qs{constructor(t,e){this._snapshotIds=new Map,this._snapshotStorage=t,this._resourceLoader=e}serveSnapshot(t,e,n){const i=this._snapshot(t,e);if(!i)return new Response(null,{status:404});const r=i.render();return this._snapshotIds.set(n,i),new Response(r.html,{status:200,headers:{"Content-Type":"text/html; charset=utf-8"}})}async serveClosestScreenshot(t,e){const n=this._snapshot(t,e),i=n==null?void 0:n.closestScreenshot();return i?new Response(await this._resourceLoader(i)):new Response(null,{status:404})}serveSnapshotInfo(t,e){const n=this._snapshot(t,e);return this._respondWithJson(n?{viewport:n.viewport(),url:n.snapshot().frameUrl,timestamp:n.snapshot().timestamp,wallTime:n.snapshot().wallTime}:{error:"No snapshot found"})}_snapshot(t,e){const n=e.get("name");return this._snapshotStorage.snapshotByName(t,n)}_respondWithJson(t){return new Response(JSON.stringify(t),{status:200,headers:{"Cache-Control":"public, max-age=31536000","Content-Type":"application/json"}})}async serveResource(t,e,n){let i;const r=this._snapshotIds.get(n);for(const R of t)if(i=r==null?void 0:r.resourceByUrl(zs(R),e),i)break;if(!i)return new Response(null,{status:404});const a=i.response.content._sha1,o=a?await this._resourceLoader(a)||new Blob([]):new Blob([]);let l=i.response.content.mimeType;/^text\/|^application\/(javascript|json)/.test(l)&&!l.includes("charset")&&(l=`${l}; charset=utf-8`);const d=new Headers;l!=="x-unknown"&&d.set("Content-Type",l);for(const{name:R,value:A}of i.response.headers)d.set(R,A);d.delete("Content-Encoding"),d.delete("Access-Control-Allow-Origin"),d.set("Access-Control-Allow-Origin","*"),d.delete("Content-Length"),d.set("Content-Length",String(o.size)),this._snapshotStorage.hasResourceOverride(i.request.url)?d.set("Cache-Control","no-store, no-cache, max-age=0"):d.set("Cache-Control","public, max-age=31536000");const{status:m}=i.response,y=m===101||m===204||m===205||m===304;return new Response(y?null:o,{headers:d,status:i.response.status,statusText:i.response.statusText})}}function zs(s){try{const t=new URL(s);return t.hash="",t.toString()}catch{return s}}function er(s){const t=new Map,{files:e,stacks:n}=s;for(const i of n){const[r,a]=i;t.set(`call@${r}`,a.map(o=>({file:e[o[0]],line:o[1],column:o[2],function:o[3]})))}return t}const Wn={"&":"&","<":"<",">":">",'"':""","'":"'"};function tr(s){return s.replace(/[&<>"']/ug,t=>Wn[t])}function nr(s){return s.replace(/[&<]/ug,t=>Wn[t])}function Ut(s,t,e){return s.find((n,i)=>{if(i===s.length-1)return!0;const r=s[i+1];return Math.abs(t(n)-e)r.frameSwapWallTime,t):Ut(this._screencastFrames,r=>r.timestamp,e);return n==null?void 0:n.sha1}render(){const t=[],e=(r,a,o,l)=>{if(typeof r=="string"){o==="STYLE"||o==="style"?t.push(dr(lr(r))):t.push(nr(r));return}if(sr(r)){const _=a-r[0][0];if(_>=0&&_<=a){const d=ar(this._snapshots[_]),m=r[0][1];if(m>=0&&mw[0]===A),g=y==="SOURCE"&&o==="PICTURE"&&(l==null?void 0:l.some(w=>w[0]===A));for(const[w,S]of R){let h=w;f&&w.toLowerCase()==="src"&&(h="__playwright_src__"),u&&w===A&&(h="src"),["src","srcset"].includes(w.toLowerCase())&&(b||g)&&(h="_"+h);let T=S;!c&&(w.toLowerCase()==="href"||w.toLowerCase()==="src"||w===A)&&(T=ft(S)),t.push(" ",h,'="',tr(T),'"')}t.push(">");for(const w of m)e(w,a,y,R);ir.has(y)||t.push("");return}else return},n=this._snapshot;return{html:this._htmlCache.getOrCompute(this,()=>{e(n.html,this._index,void 0,void 0);const a=(n.doctype?``:"")+["",` + + + + + +
+ + diff --git a/node_modules/.deno/playwright-core@1.58.2/node_modules/playwright-core/lib/vite/traceViewer/xtermModule.DYP7pi_n.css b/node_modules/.deno/playwright-core@1.58.2/node_modules/playwright-core/lib/vite/traceViewer/xtermModule.DYP7pi_n.css new file mode 100644 index 0000000..c27f4fb --- /dev/null +++ b/node_modules/.deno/playwright-core@1.58.2/node_modules/playwright-core/lib/vite/traceViewer/xtermModule.DYP7pi_n.css @@ -0,0 +1,32 @@ +/** + * Copyright (c) 2014 The xterm.js authors. All rights reserved. + * Copyright (c) 2012-2013, Christopher Jeffrey (MIT License) + * https://github.com/chjj/term.js + * @license MIT + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in + * all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + * THE SOFTWARE. + * + * Originally forked from (with the author's permission): + * Fabrice Bellard's javascript vt100 for jslinux: + * http://bellard.org/jslinux/ + * Copyright (c) 2011 Fabrice Bellard + * The original design remains. The terminal itself + * has been extended to include xterm CSI codes, among + * other features. + */.xterm{cursor:text;position:relative;user-select:none;-ms-user-select:none;-webkit-user-select:none}.xterm.focus,.xterm:focus{outline:none}.xterm .xterm-helpers{position:absolute;top:0;z-index:5}.xterm .xterm-helper-textarea{padding:0;border:0;margin:0;position:absolute;opacity:0;left:-9999em;top:0;width:0;height:0;z-index:-5;white-space:nowrap;overflow:hidden;resize:none}.xterm .composition-view{background:#000;color:#fff;display:none;position:absolute;white-space:nowrap;z-index:1}.xterm .composition-view.active{display:block}.xterm .xterm-viewport{background-color:#000;overflow-y:scroll;cursor:default;position:absolute;right:0;left:0;top:0;bottom:0}.xterm .xterm-screen{position:relative}.xterm .xterm-screen canvas{position:absolute;left:0;top:0}.xterm .xterm-scroll-area{visibility:hidden}.xterm-char-measure-element{display:inline-block;visibility:hidden;position:absolute;top:0;left:-9999em;line-height:normal}.xterm.enable-mouse-events{cursor:default}.xterm.xterm-cursor-pointer,.xterm .xterm-cursor-pointer{cursor:pointer}.xterm.column-select.focus{cursor:crosshair}.xterm .xterm-accessibility:not(.debug),.xterm .xterm-message{position:absolute;left:0;top:0;bottom:0;right:0;z-index:10;color:transparent;pointer-events:none}.xterm .xterm-accessibility-tree:not(.debug) *::selection{color:transparent}.xterm .xterm-accessibility-tree{-webkit-user-select:text;user-select:text;white-space:pre}.xterm .live-region{position:absolute;left:-9999px;width:1px;height:1px;overflow:hidden}.xterm-dim{opacity:1!important}.xterm-underline-1{text-decoration:underline}.xterm-underline-2{text-decoration:double underline}.xterm-underline-3{text-decoration:wavy underline}.xterm-underline-4{text-decoration:dotted underline}.xterm-underline-5{text-decoration:dashed underline}.xterm-overline{text-decoration:overline}.xterm-overline.xterm-underline-1{text-decoration:overline underline}.xterm-overline.xterm-underline-2{text-decoration:overline double underline}.xterm-overline.xterm-underline-3{text-decoration:overline wavy underline}.xterm-overline.xterm-underline-4{text-decoration:overline dotted underline}.xterm-overline.xterm-underline-5{text-decoration:overline dashed underline}.xterm-strikethrough{text-decoration:line-through}.xterm-screen .xterm-decoration-container .xterm-decoration{z-index:6;position:absolute}.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer{z-index:7}.xterm-decoration-overview-ruler{z-index:8;position:absolute;top:0;right:0;pointer-events:none}.xterm-decoration-top{z-index:2;position:relative} diff --git a/node_modules/.deno/playwright-core@1.58.2/node_modules/playwright-core/lib/zipBundle.js b/node_modules/.deno/playwright-core@1.58.2/node_modules/playwright-core/lib/zipBundle.js new file mode 100644 index 0000000..e9307a3 --- /dev/null +++ b/node_modules/.deno/playwright-core@1.58.2/node_modules/playwright-core/lib/zipBundle.js @@ -0,0 +1,34 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); +var zipBundle_exports = {}; +__export(zipBundle_exports, { + extract: () => extract, + yauzl: () => yauzl, + yazl: () => yazl +}); +module.exports = __toCommonJS(zipBundle_exports); +const yazl = require("./zipBundleImpl").yazl; +const yauzl = require("./zipBundleImpl").yauzl; +const extract = require("./zipBundleImpl").extract; +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + extract, + yauzl, + yazl +}); diff --git a/node_modules/.deno/playwright-core@1.58.2/node_modules/playwright-core/lib/zipBundleImpl.js b/node_modules/.deno/playwright-core@1.58.2/node_modules/playwright-core/lib/zipBundleImpl.js new file mode 100644 index 0000000..04dda73 --- /dev/null +++ b/node_modules/.deno/playwright-core@1.58.2/node_modules/playwright-core/lib/zipBundleImpl.js @@ -0,0 +1,5 @@ +"use strict";var Et=Object.create;var oe=Object.defineProperty;var vt=Object.getOwnPropertyDescriptor;var wt=Object.getOwnPropertyNames;var yt=Object.getPrototypeOf,gt=Object.prototype.hasOwnProperty;var v=(e,r)=>()=>(r||e((r={exports:{}}).exports,r),r.exports),Ct=(e,r)=>{for(var t in r)oe(e,t,{get:r[t],enumerable:!0})},or=(e,r,t,n)=>{if(r&&typeof r=="object"||typeof r=="function")for(let i of wt(r))!gt.call(e,i)&&i!==t&&oe(e,i,{get:()=>r[i],enumerable:!(n=vt(r,i))||n.enumerable});return e};var sr=(e,r,t)=>(t=e!=null?Et(yt(e)):{},or(r||!e||!e.__esModule?oe(t,"default",{value:e,enumerable:!0}):t,e)),bt=e=>or(oe({},"__esModule",{value:!0}),e);var Ne=v((Mn,ar)=>{var U=require("buffer").Buffer,Ae=[0,1996959894,3993919788,2567524794,124634137,1886057615,3915621685,2657392035,249268274,2044508324,3772115230,2547177864,162941995,2125561021,3887607047,2428444049,498536548,1789927666,4089016648,2227061214,450548861,1843258603,4107580753,2211677639,325883990,1684777152,4251122042,2321926636,335633487,1661365465,4195302755,2366115317,997073096,1281953886,3579855332,2724688242,1006888145,1258607687,3524101629,2768942443,901097722,1119000684,3686517206,2898065728,853044451,1172266101,3705015759,2882616665,651767980,1373503546,3369554304,3218104598,565507253,1454621731,3485111705,3099436303,671266974,1594198024,3322730930,2970347812,795835527,1483230225,3244367275,3060149565,1994146192,31158534,2563907772,4023717930,1907459465,112637215,2680153253,3904427059,2013776290,251722036,2517215374,3775830040,2137656763,141376813,2439277719,3865271297,1802195444,476864866,2238001368,4066508878,1812370925,453092731,2181625025,4111451223,1706088902,314042704,2344532202,4240017532,1658658271,366619977,2362670323,4224994405,1303535960,984961486,2747007092,3569037538,1256170817,1037604311,2765210733,3554079995,1131014506,879679996,2909243462,3663771856,1141124467,855842277,2852801631,3708648649,1342533948,654459306,3188396048,3373015174,1466479909,544179635,3110523913,3462522015,1591671054,702138776,2966460450,3352799412,1504918807,783551873,3082640443,3233442989,3988292384,2596254646,62317068,1957810842,3939845945,2647816111,81470997,1943803523,3814918930,2489596804,225274430,2053790376,3826175755,2466906013,167816743,2097651377,4027552580,2265490386,503444072,1762050814,4150417245,2154129355,426522225,1852507879,4275313526,2312317920,282753626,1742555852,4189708143,2394877945,397917763,1622183637,3604390888,2714866558,953729732,1340076626,3518719985,2797360999,1068828381,1219638859,3624741850,2936675148,906185462,1090812512,3747672003,2825379669,829329135,1181335161,3412177804,3160834842,628085408,1382605366,3423369109,3138078467,570562233,1426400815,3317316542,2998733608,733239954,1555261956,3268935591,3050360625,752459403,1541320221,2607071920,3965973030,1969922972,40735498,2617837225,3943577151,1913087877,83908371,2512341634,3803740692,2075208622,213261112,2463272603,3855990285,2094854071,198958881,2262029012,4057260610,1759359992,534414190,2176718541,4139329115,1873836001,414664567,2282248934,4279200368,1711684554,285281116,2405801727,4167216745,1634467795,376229701,2685067896,3608007406,1308918612,956543938,2808555105,3495958263,1231636301,1047427035,2932959818,3654703836,1088359270,936918e3,2847714899,3736837829,1202900863,817233897,3183342108,3401237130,1404277552,615818150,3134207493,3453421203,1423857449,601450431,3009837614,3294710456,1567103746,711928724,3020668471,3272380065,1510334235,755167117];typeof Int32Array!="undefined"&&(Ae=new Int32Array(Ae));function fr(e){if(U.isBuffer(e))return e;var r=typeof U.alloc=="function"&&typeof U.from=="function";if(typeof e=="number")return r?U.alloc(e):new U(e);if(typeof e=="string")return r?U.from(e):new U(e);throw new Error("input must be buffer, number, or string, received "+typeof e)}function Ft(e){var r=fr(4);return r.writeInt32BE(e,0),r}function Ue(e,r){e=fr(e),U.isBuffer(r)&&(r=r.readUInt32BE(0));for(var t=~~r^-1,n=0;n>>8;return t^-1}function Te(){return Ft(Ue.apply(null,arguments))}Te.signed=function(){return Ue.apply(null,arguments)};Te.unsigned=function(){return Ue.apply(null,arguments)>>>0};ar.exports=Te});var Fr=v(We=>{var ur=require("fs"),ce=require("stream").Transform,cr=require("stream").PassThrough,dr=require("zlib"),Pe=require("util"),It=require("events").EventEmitter,lr=Ne();We.ZipFile=P;We.dateToDosDateTime=br;Pe.inherits(P,It);function P(){this.outputStream=new cr,this.entries=[],this.outputStreamCursor=0,this.ended=!1,this.allDone=!1,this.forceZip64Eocd=!1}P.prototype.addFile=function(e,r,t){var n=this;r=de(r,!1),t==null&&(t={});var i=new m(r,!1,t);n.entries.push(i),ur.stat(e,function(s,o){if(s)return n.emit("error",s);if(!o.isFile())return n.emit("error",new Error("not a file: "+e));i.uncompressedSize=o.size,t.mtime==null&&i.setLastModDate(o.mtime),t.mode==null&&i.setFileAttributesMode(o.mode),i.setFileDataPumpFunction(function(){var f=ur.createReadStream(e);i.state=m.FILE_DATA_IN_PROGRESS,f.on("error",function(u){n.emit("error",u)}),hr(n,i,f)}),N(n)})};P.prototype.addReadStream=function(e,r,t){var n=this;r=de(r,!1),t==null&&(t={});var i=new m(r,!1,t);n.entries.push(i),i.setFileDataPumpFunction(function(){i.state=m.FILE_DATA_IN_PROGRESS,hr(n,i,e)}),N(n)};P.prototype.addBuffer=function(e,r,t){var n=this;if(r=de(r,!1),e.length>1073741823)throw new Error("buffer too large: "+e.length+" > 1073741823");if(t==null&&(t={}),t.size!=null)throw new Error("options.size not allowed");var i=new m(r,!1,t);i.uncompressedSize=e.length,i.crc32=lr.unsigned(e),i.crcAndFileSizeKnown=!0,n.entries.push(i),i.compress?dr.deflateRaw(e,function(o,f){s(f)}):s(e);function s(o){i.compressedSize=o.length,i.setFileDataPumpFunction(function(){q(n,o),q(n,i.getDataDescriptor()),i.state=m.FILE_DATA_DONE,setImmediate(function(){N(n)})}),N(n)}};P.prototype.addEmptyDirectory=function(e,r){var t=this;if(e=de(e,!0),r==null&&(r={}),r.size!=null)throw new Error("options.size not allowed");if(r.compress!=null)throw new Error("options.compress not allowed");var n=new m(e,!0,r);t.entries.push(n),n.setFileDataPumpFunction(function(){q(t,n.getDataDescriptor()),n.state=m.FILE_DATA_DONE,N(t)}),N(t)};var St=T([80,75,5,6]);P.prototype.end=function(e,r){if(typeof e=="function"&&(r=e,e=null),e==null&&(e={}),!this.ended){if(this.ended=!0,this.finalSizeCallback=r,this.forceZip64Eocd=!!e.forceZip64Format,e.comment){if(typeof e.comment=="string"?this.comment=zt(e.comment):this.comment=e.comment,this.comment.length>65535)throw new Error("comment is too large");if(J(this.comment,St))throw new Error("comment contains end of central directory record signature")}else this.comment=le;N(this)}};function q(e,r){e.outputStream.write(r),e.outputStreamCursor+=r.length}function hr(e,r,t){var n=new Ze,i=new ue,s=r.compress?new dr.DeflateRaw:new cr,o=new ue;t.pipe(n).pipe(i).pipe(s).pipe(o).pipe(e.outputStream,{end:!1}),o.on("end",function(){if(r.crc32=n.crc32,r.uncompressedSize==null)r.uncompressedSize=i.byteCount;else if(r.uncompressedSize!==i.byteCount)return e.emit("error",new Error("file data stream has unexpected number of bytes"));r.compressedSize=o.byteCount,e.outputStreamCursor+=r.compressedSize,q(e,r.getDataDescriptor()),r.state=m.FILE_DATA_DONE,N(e)})}function N(e){if(e.allDone)return;if(e.ended&&e.finalSizeCallback!=null){var r=Lt(e);r!=null&&(e.finalSizeCallback(r),e.finalSizeCallback=null)}var t=n();function n(){for(var s=0;s=m.READY_TO_PUMP_FILE_DATA){if(i.uncompressedSize==null)return-1}else if(i.uncompressedSize==null)return null;i.relativeOffsetOfLocalHeader=r;var s=i.useZip64Format();r+=mr+i.utf8FileName.length,r+=i.uncompressedSize,i.crcAndFileSizeKnown||(s?r+=gr:r+=yr),t+=Cr+i.utf8FileName.length+i.fileComment.length,s&&(t+=Be)}var o=0;return(e.forceZip64Eocd||e.entries.length>=65535||t>=65535||r>=4294967295)&&(o+=fe+Me),o+=ae+e.comment.length,r+t+o}var fe=56,Me=20,ae=22;function Ot(e,r){var t=!1,n=e.entries.length;(e.forceZip64Eocd||e.entries.length>=65535)&&(n=65535,t=!0);var i=e.outputStreamCursor-e.offsetOfStartOfCentralDirectory,s=i;(e.forceZip64Eocd||i>=4294967295)&&(s=4294967295,t=!0);var o=e.offsetOfStartOfCentralDirectory;if((e.forceZip64Eocd||e.offsetOfStartOfCentralDirectory>=4294967295)&&(o=4294967295,t=!0),r)return t?fe+Me+ae:ae;var f=g(ae+e.comment.length);if(f.writeUInt32LE(101010256,0),f.writeUInt16LE(0,4),f.writeUInt16LE(0,6),f.writeUInt16LE(n,8),f.writeUInt16LE(n,10),f.writeUInt32LE(s,12),f.writeUInt32LE(o,16),f.writeUInt16LE(e.comment.length,20),e.comment.copy(f,22),!t)return f;var u=g(fe);u.writeUInt32LE(101075792,0),I(u,fe-12,4),u.writeUInt16LE(Er,12),u.writeUInt16LE(xr,14),u.writeUInt32LE(0,16),u.writeUInt32LE(0,20),I(u,e.entries.length,24),I(u,e.entries.length,32),I(u,i,40),I(u,e.offsetOfStartOfCentralDirectory,48);var l=g(Me);return l.writeUInt32LE(117853008,0),l.writeUInt32LE(0,4),I(l,e.outputStreamCursor,8),l.writeUInt32LE(1,16),Buffer.concat([u,l,f])}function de(e,r){if(e==="")throw new Error("empty metadataPath");if(e=e.replace(/\\/g,"/"),/^[a-zA-Z]:/.test(e)||/^\//.test(e))throw new Error("absolute path: "+e);if(e.split("/").indexOf("..")!==-1)throw new Error("invalid relative path: "+e);var t=/\/$/.test(e);if(r)t||(e+="/");else if(t)throw new Error("file path cannot end with '/': "+e);return e}var le=g(0);function m(e,r,t){if(this.utf8FileName=T(e),this.utf8FileName.length>65535)throw new Error("utf8 file name too long. "+utf8FileName.length+" > 65535");if(this.isDirectory=r,this.state=m.WAITING_FOR_METADATA,this.setLastModDate(t.mtime!=null?t.mtime:new Date),t.mode!=null?this.setFileAttributesMode(t.mode):this.setFileAttributesMode(r?16893:33204),r?(this.crcAndFileSizeKnown=!0,this.crc32=0,this.uncompressedSize=0,this.compressedSize=0):(this.crcAndFileSizeKnown=!1,this.crc32=null,this.uncompressedSize=null,this.compressedSize=null,t.size!=null&&(this.uncompressedSize=t.size)),r?this.compress=!1:(this.compress=!0,t.compress!=null&&(this.compress=!!t.compress)),this.forceZip64Format=!!t.forceZip64Format,t.fileComment){if(typeof t.fileComment=="string"?this.fileComment=T(t.fileComment,"utf-8"):this.fileComment=t.fileComment,this.fileComment.length>65535)throw new Error("fileComment is too large")}else this.fileComment=le}m.WAITING_FOR_METADATA=0;m.READY_TO_PUMP_FILE_DATA=1;m.FILE_DATA_IN_PROGRESS=2;m.FILE_DATA_DONE=3;m.prototype.setLastModDate=function(e){var r=br(e);this.lastModFileTime=r.time,this.lastModFileDate=r.date};m.prototype.setFileAttributesMode=function(e){if((e&65535)!==e)throw new Error("invalid mode. expected: 0 <= "+e+" <= 65535");this.externalFileAttributes=e<<16>>>0};m.prototype.setFileDataPumpFunction=function(e){this.doFileDataPump=e,this.state=m.READY_TO_PUMP_FILE_DATA};m.prototype.useZip64Format=function(){return this.forceZip64Format||this.uncompressedSize!=null&&this.uncompressedSize>4294967294||this.compressedSize!=null&&this.compressedSize>4294967294||this.relativeOffsetOfLocalHeader!=null&&this.relativeOffsetOfLocalHeader>4294967294};var mr=30,pr=20,xr=45,Er=831,vr=2048,wr=8;m.prototype.getLocalFileHeader=function(){var e=0,r=0,t=0;this.crcAndFileSizeKnown&&(e=this.crc32,r=this.compressedSize,t=this.uncompressedSize);var n=g(mr),i=vr;return this.crcAndFileSizeKnown||(i|=wr),n.writeUInt32LE(67324752,0),n.writeUInt16LE(pr,4),n.writeUInt16LE(i,6),n.writeUInt16LE(this.getCompressionMethod(),8),n.writeUInt16LE(this.lastModFileTime,10),n.writeUInt16LE(this.lastModFileDate,12),n.writeUInt32LE(e,14),n.writeUInt32LE(r,18),n.writeUInt32LE(t,22),n.writeUInt16LE(this.utf8FileName.length,26),n.writeUInt16LE(0,28),Buffer.concat([n,this.utf8FileName])};var yr=16,gr=24;m.prototype.getDataDescriptor=function(){if(this.crcAndFileSizeKnown)return le;if(this.useZip64Format()){var e=g(gr);return e.writeUInt32LE(134695760,0),e.writeUInt32LE(this.crc32,4),I(e,this.compressedSize,8),I(e,this.uncompressedSize,16),e}else{var e=g(yr);return e.writeUInt32LE(134695760,0),e.writeUInt32LE(this.crc32,4),e.writeUInt32LE(this.compressedSize,8),e.writeUInt32LE(this.uncompressedSize,12),e}};var Cr=46,Be=28;m.prototype.getCentralDirectoryRecord=function(){var e=g(Cr),r=vr;this.crcAndFileSizeKnown||(r|=wr);var t=this.compressedSize,n=this.uncompressedSize,i=this.relativeOffsetOfLocalHeader,s,o;return this.useZip64Format()?(t=4294967295,n=4294967295,i=4294967295,s=xr,o=g(Be),o.writeUInt16LE(1,0),o.writeUInt16LE(Be-4,2),I(o,this.uncompressedSize,4),I(o,this.compressedSize,12),I(o,this.relativeOffsetOfLocalHeader,20)):(s=pr,o=le),e.writeUInt32LE(33639248,0),e.writeUInt16LE(Er,4),e.writeUInt16LE(s,6),e.writeUInt16LE(r,8),e.writeUInt16LE(this.getCompressionMethod(),10),e.writeUInt16LE(this.lastModFileTime,12),e.writeUInt16LE(this.lastModFileDate,14),e.writeUInt32LE(this.crc32,16),e.writeUInt32LE(t,20),e.writeUInt32LE(n,24),e.writeUInt16LE(this.utf8FileName.length,28),e.writeUInt16LE(o.length,30),e.writeUInt16LE(this.fileComment.length,32),e.writeUInt16LE(0,34),e.writeUInt16LE(0,36),e.writeUInt32LE(this.externalFileAttributes,38),e.writeUInt32LE(i,42),Buffer.concat([e,this.utf8FileName,o,this.fileComment])};m.prototype.getCompressionMethod=function(){var e=0,r=8;return this.compress?r:e};function br(e){var r=0;r|=e.getDate()&31,r|=(e.getMonth()+1&15)<<5,r|=(e.getFullYear()-1980&127)<<9;var t=0;return t|=Math.floor(e.getSeconds()/2),t|=(e.getMinutes()&63)<<5,t|=(e.getHours()&31)<<11,{date:r,time:t}}function I(e,r,t){var n=Math.floor(r/4294967296),i=r%4294967296;e.writeUInt32LE(i,t),e.writeUInt32LE(n,t+4)}Pe.inherits(ue,ce);function ue(e){ce.call(this,e),this.byteCount=0}ue.prototype._transform=function(e,r,t){this.byteCount+=e.length,t(null,e)};Pe.inherits(Ze,ce);function Ze(e){ce.call(this,e),this.crc32=0}Ze.prototype._transform=function(e,r,t){this.crc32=lr.unsigned(e,this.crc32),t(null,e)};var qe="\0\u263A\u263B\u2665\u2666\u2663\u2660\u2022\u25D8\u25CB\u25D9\u2642\u2640\u266A\u266B\u263C\u25BA\u25C4\u2195\u203C\xB6\xA7\u25AC\u21A8\u2191\u2193\u2192\u2190\u221F\u2194\u25B2\u25BC !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\u2302\xC7\xFC\xE9\xE2\xE4\xE0\xE5\xE7\xEA\xEB\xE8\xEF\xEE\xEC\xC4\xC5\xC9\xE6\xC6\xF4\xF6\xF2\xFB\xF9\xFF\xD6\xDC\xA2\xA3\xA5\u20A7\u0192\xE1\xED\xF3\xFA\xF1\xD1\xAA\xBA\xBF\u2310\xAC\xBD\xBC\xA1\xAB\xBB\u2591\u2592\u2593\u2502\u2524\u2561\u2562\u2556\u2555\u2563\u2551\u2557\u255D\u255C\u255B\u2510\u2514\u2534\u252C\u251C\u2500\u253C\u255E\u255F\u255A\u2554\u2569\u2566\u2560\u2550\u256C\u2567\u2568\u2564\u2565\u2559\u2558\u2552\u2553\u256B\u256A\u2518\u250C\u2588\u2584\u258C\u2590\u2580\u03B1\xDF\u0393\u03C0\u03A3\u03C3\xB5\u03C4\u03A6\u0398\u03A9\u03B4\u221E\u03C6\u03B5\u2229\u2261\xB1\u2265\u2264\u2320\u2321\xF7\u2248\xB0\u2219\xB7\u221A\u207F\xB2\u25A0\xA0";if(qe.length!==256)throw new Error("assertion failure");var se=null;function zt(e){if(/^[\x20-\x7e]*$/.test(e))return T(e,"utf-8");if(se==null){se={};for(var r=0;r{Lr.exports=he;function he(){this.pending=0,this.max=1/0,this.listeners=[],this.waiting=[],this.error=null}he.prototype.go=function(e){this.pending0&&e.pending{var Q=require("fs"),me=require("util"),Ge=require("stream"),zr=Ge.Readable,Ye=Ge.Writable,_t=Ge.PassThrough,Rt=Or(),pe=require("events").EventEmitter;k.createFromBuffer=Dt;k.createFromFd=At;k.BufferSlicer=D;k.FdSlicer=R;me.inherits(R,pe);function R(e,r){r=r||{},pe.call(this),this.fd=e,this.pend=new Rt,this.pend.max=1,this.refCount=0,this.autoClose=!!r.autoClose}R.prototype.read=function(e,r,t,n,i){var s=this;s.pend.go(function(o){Q.read(s.fd,e,r,t,n,function(f,u,l){o(),i(f,u,l)})})};R.prototype.write=function(e,r,t,n,i){var s=this;s.pend.go(function(o){Q.write(s.fd,e,r,t,n,function(f,u,l){o(),i(f,u,l)})})};R.prototype.createReadStream=function(e){return new xe(this,e)};R.prototype.createWriteStream=function(e){return new Ee(this,e)};R.prototype.ref=function(){this.refCount+=1};R.prototype.unref=function(){var e=this;if(e.refCount-=1,e.refCount>0)return;if(e.refCount<0)throw new Error("invalid unref");e.autoClose&&Q.close(e.fd,r);function r(t){t?e.emit("error",t):e.emit("close")}};me.inherits(xe,zr);function xe(e,r){r=r||{},zr.call(this,r),this.context=e,this.context.ref(),this.start=r.start||0,this.endOffset=r.end,this.pos=this.start,this.destroyed=!1}xe.prototype._read=function(e){var r=this;if(!r.destroyed){var t=Math.min(r._readableState.highWaterMark,e);if(r.endOffset!=null&&(t=Math.min(t,r.endOffset-r.pos)),t<=0){r.destroyed=!0,r.push(null),r.context.unref();return}r.context.pend.go(function(n){if(r.destroyed)return n();var i=Buffer.allocUnsafe(t);Q.read(r.context.fd,i,0,t,r.pos,function(s,o){s?r.destroy(s):o===0?(r.destroyed=!0,r.push(null),r.context.unref()):(r.pos+=o,r.push(i.slice(0,o))),n()})})}};xe.prototype.destroy=function(e){this.destroyed||(e=e||new Error("stream destroyed"),this.destroyed=!0,this.emit("error",e),this.context.unref())};me.inherits(Ee,Ye);function Ee(e,r){r=r||{},Ye.call(this,r),this.context=e,this.context.ref(),this.start=r.start||0,this.endOffset=r.end==null?1/0:+r.end,this.bytesWritten=0,this.pos=this.start,this.destroyed=!1,this.on("finish",this.destroy.bind(this))}Ee.prototype._write=function(e,r,t){var n=this;if(!n.destroyed){if(n.pos+e.length>n.endOffset){var i=new Error("maximum file length exceeded");i.code="ETOOBIG",n.destroy(),t(i);return}n.context.pend.go(function(s){if(n.destroyed)return s();Q.write(n.context.fd,e,0,e.length,n.pos,function(o,f){o?(n.destroy(),s(),t(o)):(n.bytesWritten+=f,n.pos+=f,n.emit("progress"),s(),t())})})}};Ee.prototype.destroy=function(){this.destroyed||(this.destroyed=!0,this.context.unref())};me.inherits(D,pe);function D(e,r){pe.call(this),r=r||{},this.refCount=0,this.buffer=e,this.maxChunkSize=r.maxChunkSize||Number.MAX_SAFE_INTEGER}D.prototype.read=function(e,r,t,n,i){if(!(0<=r&&r<=e.length))throw new RangeError("offset outside buffer: 0 <= "+r+" <= "+e.length);if(n<0)throw new RangeError("position is negative: "+n);if(r+t>e.length&&(t=e.length-r),n+t>this.buffer.length&&(t=this.buffer.length-n),t<=0){setImmediate(function(){i(null,0)});return}this.buffer.copy(e,r,n,n+t),setImmediate(function(){i(null,t)})};D.prototype.write=function(e,r,t,n,i){e.copy(this.buffer,n,r,r+t),setImmediate(function(){i(null,t,e)})};D.prototype.createReadStream=function(e){e=e||{};var r=new _t(e);r.destroyed=!1,r.start=e.start||0,r.endOffset=e.end,r.pos=r.endOffset||this.buffer.length;for(var t=this.buffer.slice(r.start,r.pos),n=0;;){var i=n+this.maxChunkSize;if(i>=t.length){nt.endOffset){var f=new Error("maximum file length exceeded");f.code="ETOOBIG",t.destroyed=!0,s(f);return}n.copy(r.buffer,t.pos,0,n.length),t.bytesWritten+=n.length,t.pos=o,t.emit("progress"),s()}},t.destroy=function(){t.destroyed=!0},t};D.prototype.ref=function(){this.refCount+=1};D.prototype.unref=function(){if(this.refCount-=1,this.refCount<0)throw new Error("invalid unref")};function Dt(e,r){return new D(e,r)}function At(e,r){return new R(e,r)}});var Xe=v(C=>{var je=require("fs"),Ut=require("zlib"),Rr=_r(),Tt=Ne(),ye=require("util"),ge=require("events").EventEmitter,Dr=require("stream").Transform,Ke=require("stream").PassThrough,Nt=require("stream").Writable;C.open=Mt;C.fromFd=Ar;C.fromBuffer=Bt;C.fromRandomAccessReader=Ve;C.dosDateTimeToDate=Nr;C.getFileNameLowLevel=Mr;C.validateFileName=Br;C.parseExtraFields=qr;C.ZipFile=_;C.Entry=ee;C.LocalFileHeader=Tr;C.RandomAccessReader=M;function Mt(e,r,t){typeof r=="function"&&(t=r,r=null),r==null&&(r={}),r.autoClose==null&&(r.autoClose=!0),r.lazyEntries==null&&(r.lazyEntries=!1),r.decodeStrings==null&&(r.decodeStrings=!0),r.validateEntrySizes==null&&(r.validateEntrySizes=!0),r.strictFileNames==null&&(r.strictFileNames=!1),t==null&&(t=we),je.open(e,"r",function(n,i){if(n)return t(n);Ar(i,r,function(s,o){s&&je.close(i,we),t(s,o)})})}function Ar(e,r,t){typeof r=="function"&&(t=r,r=null),r==null&&(r={}),r.autoClose==null&&(r.autoClose=!1),r.lazyEntries==null&&(r.lazyEntries=!1),r.decodeStrings==null&&(r.decodeStrings=!0),r.validateEntrySizes==null&&(r.validateEntrySizes=!0),r.strictFileNames==null&&(r.strictFileNames=!1),t==null&&(t=we),je.fstat(e,function(n,i){if(n)return t(n);var s=Rr.createFromFd(e,{autoClose:!0});Ve(s,i.size,r,t)})}function Bt(e,r,t){typeof r=="function"&&(t=r,r=null),r==null&&(r={}),r.autoClose=!1,r.lazyEntries==null&&(r.lazyEntries=!1),r.decodeStrings==null&&(r.decodeStrings=!0),r.validateEntrySizes==null&&(r.validateEntrySizes=!0),r.strictFileNames==null&&(r.strictFileNames=!1);var n=Rr.createFromBuffer(e,{maxChunkSize:65536});Ve(n,e.length,r,t)}function Ve(e,r,t,n){typeof t=="function"&&(n=t,t=null),t==null&&(t={}),t.autoClose==null&&(t.autoClose=!0),t.lazyEntries==null&&(t.lazyEntries=!1),t.decodeStrings==null&&(t.decodeStrings=!0);var i=!!t.decodeStrings;if(t.validateEntrySizes==null&&(t.validateEntrySizes=!0),t.strictFileNames==null&&(t.strictFileNames=!1),n==null&&(n=we),typeof r!="number")throw new Error("expected totalSize parameter to be a number");if(r>Number.MAX_SAFE_INTEGER)throw new Error("zip file too large. only file sizes up to 2^52 are supported due to JavaScript's Number type being an IEEE 754 double.");e.ref();var s=22,o=20,f=65535,u=Math.min(o+s+f,r),l=A(u),a=r-l.length;j(e,l,0,u,a,function(c){if(c)return n(c);for(var d=u-s;d>=0;d-=1)if(l.readUInt32LE(d)===101010256){var h=l.subarray(d),E=h.readUInt16LE(4),p=h.readUInt16LE(10),x=h.readUInt32LE(16),L=h.readUInt16LE(20),B=h.length-s;if(L!==B)return n(new Error("Invalid comment length. Expected: "+B+". Found: "+L+". Are there extra bytes at the end of the file? Or is the end of central dir signature `PK\u263A\u263B` in the comment?"));var ne=i?ve(h.subarray(22),!1):h.subarray(22);if(d-o>=0&&l.readUInt32LE(d-o)===117853008){var G=l.subarray(d-o,d-o+o),nr=Y(G,8),O=A(56);return j(e,O,0,O.length,nr,function(ie){return ie?n(ie):O.readUInt32LE(0)!==101075792?n(new Error("invalid zip64 end of central directory record signature")):(E=O.readUInt32LE(16),E!==0?n(new Error("multi-disk zip files are not supported: found disk number: "+E)):(p=Y(O,32),x=Y(O,48),n(null,new _(e,x,r,p,ne,t.autoClose,t.lazyEntries,i,t.validateEntrySizes,t.strictFileNames))))})}return E!==0?n(new Error("multi-disk zip files are not supported: found disk number: "+E)):n(null,new _(e,x,r,p,ne,t.autoClose,t.lazyEntries,i,t.validateEntrySizes,t.strictFileNames))}n(new Error("End of central directory record signature not found. Either not a zip file, or file is truncated."))})}ye.inherits(_,ge);function _(e,r,t,n,i,s,o,f,u,l){var a=this;ge.call(a),a.reader=e,a.reader.on("error",function(c){Ur(a,c)}),a.reader.once("close",function(){a.emit("close")}),a.readEntryCursor=r,a.fileSize=t,a.entryCount=n,a.comment=i,a.entriesRead=0,a.autoClose=!!s,a.lazyEntries=!!o,a.decodeStrings=!!f,a.validateEntrySizes=!!u,a.strictFileNames=!!l,a.isOpen=!0,a.emittedError=!1,a.lazyEntries||a._readEntry()}_.prototype.close=function(){this.isOpen&&(this.isOpen=!1,this.reader.unref())};function z(e,r){e.autoClose&&e.close(),Ur(e,r)}function Ur(e,r){e.emittedError||(e.emittedError=!0,e.emit("error",r))}_.prototype.readEntry=function(){if(!this.lazyEntries)throw new Error("readEntry() called without lazyEntries:true");this._readEntry()};_.prototype._readEntry=function(){var e=this;if(e.entryCount===e.entriesRead){setImmediate(function(){e.autoClose&&e.close(),!e.emittedError&&e.emit("end")});return}if(!e.emittedError){var r=A(46);j(e.reader,r,0,r.length,e.readEntryCursor,function(t){if(t)return z(e,t);if(!e.emittedError){var n=new ee,i=r.readUInt32LE(0);if(i!==33639248)return z(e,new Error("invalid central directory file header signature: 0x"+i.toString(16)));if(n.versionMadeBy=r.readUInt16LE(4),n.versionNeededToExtract=r.readUInt16LE(6),n.generalPurposeBitFlag=r.readUInt16LE(8),n.compressionMethod=r.readUInt16LE(10),n.lastModFileTime=r.readUInt16LE(12),n.lastModFileDate=r.readUInt16LE(14),n.crc32=r.readUInt32LE(16),n.compressedSize=r.readUInt32LE(20),n.uncompressedSize=r.readUInt32LE(24),n.fileNameLength=r.readUInt16LE(28),n.extraFieldLength=r.readUInt16LE(30),n.fileCommentLength=r.readUInt16LE(32),n.internalFileAttributes=r.readUInt16LE(36),n.externalFileAttributes=r.readUInt32LE(38),n.relativeOffsetOfLocalHeader=r.readUInt32LE(42),n.generalPurposeBitFlag&64)return z(e,new Error("strong encryption is not supported"));e.readEntryCursor+=46,r=A(n.fileNameLength+n.extraFieldLength+n.fileCommentLength),j(e.reader,r,0,r.length,e.readEntryCursor,function(s){if(s)return z(e,s);if(!e.emittedError){n.fileNameRaw=r.subarray(0,n.fileNameLength);var o=n.fileNameLength+n.extraFieldLength;n.extraFieldRaw=r.subarray(n.fileNameLength,o),n.fileCommentRaw=r.subarray(o,o+n.fileCommentLength);try{n.extraFields=qr(n.extraFieldRaw)}catch(p){return z(e,p)}if(e.decodeStrings){var f=(n.generalPurposeBitFlag&2048)!==0;n.fileComment=ve(n.fileCommentRaw,f),n.fileName=Mr(n.generalPurposeBitFlag,n.fileNameRaw,n.extraFields,e.strictFileNames);var u=Br(n.fileName);if(u!=null)return z(e,new Error(u))}else n.fileComment=n.fileCommentRaw,n.fileName=n.fileNameRaw;n.comment=n.fileComment,e.readEntryCursor+=r.length,e.entriesRead+=1;for(var l=0;lc.length)return z(e,new Error("zip64 extended information extra field does not include uncompressed size"));n.uncompressedSize=Y(c,d),d+=8}if(n.compressedSize===4294967295){if(d+8>c.length)return z(e,new Error("zip64 extended information extra field does not include compressed size"));n.compressedSize=Y(c,d),d+=8}if(n.relativeOffsetOfLocalHeader===4294967295){if(d+8>c.length)return z(e,new Error("zip64 extended information extra field does not include relative header offset"));n.relativeOffsetOfLocalHeader=Y(c,d),d+=8}break}}if(e.validateEntrySizes&&n.compressionMethod===0){var h=n.uncompressedSize;if(n.isEncrypted()&&(h+=12),n.compressedSize!==h){var E="compressed/uncompressed size mismatch for stored file: "+n.compressedSize+" != "+n.uncompressedSize;return z(e,new Error(E))}}e.emit("entry",n),e.lazyEntries||e._readEntry()}})}})}};_.prototype.openReadStream=function(e,r,t){var n=this,i=0,s=e.compressedSize;if(t==null&&(t=r,r=null),r==null)r={};else{if(r.decrypt!=null){if(!e.isEncrypted())throw new Error("options.decrypt can only be specified for encrypted entries");if(r.decrypt!==!1)throw new Error("invalid options.decrypt value: "+r.decrypt);if(e.isCompressed()&&r.decompress!==!1)throw new Error("entry is encrypted and compressed, and options.decompress !== false")}if(r.decompress!=null){if(!e.isCompressed())throw new Error("options.decompress can only be specified for compressed entries");if(!(r.decompress===!1||r.decompress===!0))throw new Error("invalid options.decompress value: "+r.decompress)}if(r.start!=null||r.end!=null){if(e.isCompressed()&&r.decompress!==!1)throw new Error("start/end range not allowed for compressed entry without options.decompress === false");if(e.isEncrypted()&&r.decrypt!==!1)throw new Error("start/end range not allowed for encrypted entry without options.decrypt === false")}if(r.start!=null){if(i=r.start,i<0)throw new Error("options.start < 0");if(i>e.compressedSize)throw new Error("options.start > entry.compressedSize")}if(r.end!=null){if(s=r.end,s<0)throw new Error("options.end < 0");if(s>e.compressedSize)throw new Error("options.end > entry.compressedSize");if(sn.fileSize)return t(new Error("file data overflows file bounds: "+l+" + "+e.compressedSize+" > "+n.fileSize));if(r.minimal)return t(null,{fileDataStart:l});var a=new Tr;a.fileDataStart=l,a.versionNeededToExtract=i.readUInt16LE(4),a.generalPurposeBitFlag=i.readUInt16LE(6),a.compressionMethod=i.readUInt16LE(8),a.lastModFileTime=i.readUInt16LE(10),a.lastModFileDate=i.readUInt16LE(12),a.crc32=i.readUInt32LE(14),a.compressedSize=i.readUInt32LE(18),a.uncompressedSize=i.readUInt32LE(22),a.fileNameLength=f,a.extraFieldLength=u,i=A(f+u),n.reader.ref(),j(n.reader,i,0,i.length,e.relativeOffsetOfLocalHeader+30,function(c){try{return c?t(c):(a.fileName=i.subarray(0,f),a.extraField=i.subarray(f),t(null,a))}finally{n.reader.unref()}})}finally{n.reader.unref()}})};function ee(){}ee.prototype.getLastModDate=function(e){if(e==null&&(e={}),!e.forceDosFormat)for(var r=0;rn.length)break;var a=4294967296*n.readInt32LE(f+4)+n.readUInt32LE(f),c=a/1e4-116444736e5;return new Date(c)}}return Nr(this.lastModFileDate,this.lastModFileTime,e.timezone)};ee.prototype.isEncrypted=function(){return(this.generalPurposeBitFlag&1)!==0};ee.prototype.isCompressed=function(){return this.compressionMethod===8};function Tr(){}function Nr(e,r,t){var n=e&31,i=(e>>5&15)-1,s=(e>>9&127)+1980,o=0,f=(r&31)*2,u=r>>5&63,l=r>>11&31;if(t==null||t==="local")return new Date(s,i,n,l,u,f,o);if(t==="UTC")return new Date(Date.UTC(s,i,n,l,u,f,o));throw new Error("unrecognized options.timezone: "+options.timezone)}function Mr(e,r,t,n){for(var i=null,s=0;se.length)throw new Error("extra field length exceeds extra field buffer size");var f=e.subarray(s,o);r.push({id:n,data:f}),t=o}return r}function j(e,r,t,n,i,s){if(n===0)return setImmediate(function(){s(null,A(0))});e.read(r,t,n,i,function(o,f){if(o)return s(o);if(fthis.expectedByteCount){var n="too many bytes in the stream. expected "+this.expectedByteCount+". got at least "+this.actualByteCount;return t(new Error(n))}t(null,e)};re.prototype._flush=function(e){if(this.actualByteCount0)return;if(e.refCount<0)throw new Error("invalid unref");e.close(r);function r(t){if(t)return e.emit("error",t);e.emit("close")}};M.prototype.createReadStream=function(e){e==null&&(e={});var r=e.start,t=e.end;if(r===t){var n=new Ke;return setImmediate(function(){n.end()}),n}var i=this._readStreamForRange(r,t),s=!1,o=new Ce(this);i.on("error",function(u){setImmediate(function(){s||o.emit("error",u)})}),He(o,function(){i.unpipe(o),o.unref(),i.destroy()});var f=new re(t-r);return o.on("error",function(u){setImmediate(function(){s||f.emit("error",u)})}),He(f,function(){s=!0,o.unpipe(f),o.destroy()}),i.pipe(o).pipe(f)};M.prototype._readStreamForRange=function(e,r){throw new Error("not implemented")};M.prototype.read=function(e,r,t,n,i){var s=this.createReadStream({start:n,end:n+t}),o=new Nt,f=0;o._write=function(u,l,a){u.copy(e,r+f,0,u.length),f+=u.length,a()},o.on("finish",i),s.on("error",function(u){i(u)}),s.pipe(o)};M.prototype.close=function(e){setImmediate(e)};ye.inherits(Ce,Ke);function Ce(e){Ke.call(this),this.context=e,this.context.ref(),this.unreffedYet=!1}Ce.prototype._flush=function(e){this.unref(),e()};Ce.prototype.unref=function(e){this.unreffedYet||(this.unreffedYet=!0,this.context.unref())};var qt="\0\u263A\u263B\u2665\u2666\u2663\u2660\u2022\u25D8\u25CB\u25D9\u2642\u2640\u266A\u266B\u263C\u25BA\u25C4\u2195\u203C\xB6\xA7\u25AC\u21A8\u2191\u2193\u2192\u2190\u221F\u2194\u25B2\u25BC !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~\u2302\xC7\xFC\xE9\xE2\xE4\xE0\xE5\xE7\xEA\xEB\xE8\xEF\xEE\xEC\xC4\xC5\xC9\xE6\xC6\xF4\xF6\xF2\xFB\xF9\xFF\xD6\xDC\xA2\xA3\xA5\u20A7\u0192\xE1\xED\xF3\xFA\xF1\xD1\xAA\xBA\xBF\u2310\xAC\xBD\xBC\xA1\xAB\xBB\u2591\u2592\u2593\u2502\u2524\u2561\u2562\u2556\u2555\u2563\u2551\u2557\u255D\u255C\u255B\u2510\u2514\u2534\u252C\u251C\u2500\u253C\u255E\u255F\u255A\u2554\u2569\u2566\u2560\u2550\u256C\u2567\u2568\u2564\u2565\u2559\u2558\u2552\u2553\u256B\u256A\u2518\u250C\u2588\u2584\u258C\u2590\u2580\u03B1\xDF\u0393\u03C0\u03A3\u03C3\xB5\u03C4\u03A6\u0398\u03A9\u03B4\u221E\u03C6\u03B5\u2229\u2261\xB1\u2265\u2264\u2320\u2321\xF7\u2248\xB0\u2219\xB7\u221A\u207F\xB2\u25A0\xA0";function ve(e,r){if(r)return e.toString("utf8");for(var t="",n=0;n{var H=1e3,K=H*60,V=K*60,Z=V*24,Pt=Z*7,Zt=Z*365.25;Pr.exports=function(e,r){r=r||{};var t=typeof e;if(t==="string"&&e.length>0)return Wt(e);if(t==="number"&&isFinite(e))return r.long?Yt(e):Gt(e);throw new Error("val is not a non-empty string or a valid number. val="+JSON.stringify(e))};function Wt(e){if(e=String(e),!(e.length>100)){var r=/^(-?(?:\d+)?\.?\d+) *(milliseconds?|msecs?|ms|seconds?|secs?|s|minutes?|mins?|m|hours?|hrs?|h|days?|d|weeks?|w|years?|yrs?|y)?$/i.exec(e);if(r){var t=parseFloat(r[1]),n=(r[2]||"ms").toLowerCase();switch(n){case"years":case"year":case"yrs":case"yr":case"y":return t*Zt;case"weeks":case"week":case"w":return t*Pt;case"days":case"day":case"d":return t*Z;case"hours":case"hour":case"hrs":case"hr":case"h":return t*V;case"minutes":case"minute":case"mins":case"min":case"m":return t*K;case"seconds":case"second":case"secs":case"sec":case"s":return t*H;case"milliseconds":case"millisecond":case"msecs":case"msec":case"ms":return t;default:return}}}}function Gt(e){var r=Math.abs(e);return r>=Z?Math.round(e/Z)+"d":r>=V?Math.round(e/V)+"h":r>=K?Math.round(e/K)+"m":r>=H?Math.round(e/H)+"s":e+"ms"}function Yt(e){var r=Math.abs(e);return r>=Z?be(e,r,Z,"day"):r>=V?be(e,r,V,"hour"):r>=K?be(e,r,K,"minute"):r>=H?be(e,r,H,"second"):e+" ms"}function be(e,r,t,n){var i=r>=t*1.5;return Math.round(e/t)+" "+n+(i?"s":"")}});var $e=v((Gn,Wr)=>{function jt(e){t.debug=t,t.default=t,t.coerce=u,t.disable=o,t.enable=i,t.enabled=f,t.humanize=Zr(),t.destroy=l,Object.keys(e).forEach(a=>{t[a]=e[a]}),t.names=[],t.skips=[],t.formatters={};function r(a){let c=0;for(let d=0;d{if(O==="%%")return"%";G++;let ir=t.formatters[ie];if(typeof ir=="function"){let xt=x[G];O=ir.call(L,xt),x.splice(G,1),G--}return O}),t.formatArgs.call(L,x),(L.log||t.log).apply(L,x)}return p.namespace=a,p.useColors=t.useColors(),p.color=t.selectColor(a),p.extend=n,p.destroy=t.destroy,Object.defineProperty(p,"enabled",{enumerable:!0,configurable:!1,get:()=>d!==null?d:(h!==t.namespaces&&(h=t.namespaces,E=t.enabled(a)),E),set:x=>{d=x}}),typeof t.init=="function"&&t.init(p),p}function n(a,c){let d=t(this.namespace+(typeof c=="undefined"?":":c)+a);return d.log=this.log,d}function i(a){t.save(a),t.namespaces=a,t.names=[],t.skips=[];let c=(typeof a=="string"?a:"").trim().replace(" ",",").split(",").filter(Boolean);for(let d of c)d[0]==="-"?t.skips.push(d.slice(1)):t.names.push(d)}function s(a,c){let d=0,h=0,E=-1,p=0;for(;d"-"+c)].join(",");return t.enable(""),a}function f(a){for(let c of t.skips)if(s(a,c))return!1;for(let c of t.names)if(s(a,c))return!0;return!1}function u(a){return a instanceof Error?a.stack||a.message:a}function l(){console.warn("Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`.")}return t.enable(t.load()),t}Wr.exports=jt});var Gr=v((b,Fe)=>{b.formatArgs=Kt;b.save=Vt;b.load=Xt;b.useColors=Ht;b.storage=$t();b.destroy=(()=>{let e=!1;return()=>{e||(e=!0,console.warn("Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`."))}})();b.colors=["#0000CC","#0000FF","#0033CC","#0033FF","#0066CC","#0066FF","#0099CC","#0099FF","#00CC00","#00CC33","#00CC66","#00CC99","#00CCCC","#00CCFF","#3300CC","#3300FF","#3333CC","#3333FF","#3366CC","#3366FF","#3399CC","#3399FF","#33CC00","#33CC33","#33CC66","#33CC99","#33CCCC","#33CCFF","#6600CC","#6600FF","#6633CC","#6633FF","#66CC00","#66CC33","#9900CC","#9900FF","#9933CC","#9933FF","#99CC00","#99CC33","#CC0000","#CC0033","#CC0066","#CC0099","#CC00CC","#CC00FF","#CC3300","#CC3333","#CC3366","#CC3399","#CC33CC","#CC33FF","#CC6600","#CC6633","#CC9900","#CC9933","#CCCC00","#CCCC33","#FF0000","#FF0033","#FF0066","#FF0099","#FF00CC","#FF00FF","#FF3300","#FF3333","#FF3366","#FF3399","#FF33CC","#FF33FF","#FF6600","#FF6633","#FF9900","#FF9933","#FFCC00","#FFCC33"];function Ht(){if(typeof window!="undefined"&&window.process&&(window.process.type==="renderer"||window.process.__nwjs))return!0;if(typeof navigator!="undefined"&&navigator.userAgent&&navigator.userAgent.toLowerCase().match(/(edge|trident)\/(\d+)/))return!1;let e;return typeof document!="undefined"&&document.documentElement&&document.documentElement.style&&document.documentElement.style.WebkitAppearance||typeof window!="undefined"&&window.console&&(window.console.firebug||window.console.exception&&window.console.table)||typeof navigator!="undefined"&&navigator.userAgent&&(e=navigator.userAgent.toLowerCase().match(/firefox\/(\d+)/))&&parseInt(e[1],10)>=31||typeof navigator!="undefined"&&navigator.userAgent&&navigator.userAgent.toLowerCase().match(/applewebkit\/(\d+)/)}function Kt(e){if(e[0]=(this.useColors?"%c":"")+this.namespace+(this.useColors?" %c":" ")+e[0]+(this.useColors?"%c ":" ")+"+"+Fe.exports.humanize(this.diff),!this.useColors)return;let r="color: "+this.color;e.splice(1,0,r,"color: inherit");let t=0,n=0;e[0].replace(/%[a-zA-Z%]/g,i=>{i!=="%%"&&(t++,i==="%c"&&(n=t))}),e.splice(n,0,r)}b.log=console.debug||console.log||(()=>{});function Vt(e){try{e?b.storage.setItem("debug",e):b.storage.removeItem("debug")}catch{}}function Xt(){let e;try{e=b.storage.getItem("debug")}catch{}return!e&&typeof process!="undefined"&&"env"in process&&(e=process.env.DEBUG),e}function $t(){try{return localStorage}catch{}}Fe.exports=$e()(b);var{formatters:Jt}=Fe.exports;Jt.j=function(e){try{return JSON.stringify(e)}catch(r){return"[UnexpectedJSONParseError]: "+r.message}}});var jr=v((Yn,Yr)=>{"use strict";Yr.exports=(e,r=process.argv)=>{let t=e.startsWith("-")?"":e.length===1?"-":"--",n=r.indexOf(t+e),i=r.indexOf("--");return n!==-1&&(i===-1||n{"use strict";var Qt=require("os"),Hr=require("tty"),F=jr(),{env:w}=process,Ie;F("no-color")||F("no-colors")||F("color=false")||F("color=never")?Ie=0:(F("color")||F("colors")||F("color=true")||F("color=always"))&&(Ie=1);function kt(){if("FORCE_COLOR"in w)return w.FORCE_COLOR==="true"?1:w.FORCE_COLOR==="false"?0:w.FORCE_COLOR.length===0?1:Math.min(Number.parseInt(w.FORCE_COLOR,10),3)}function en(e){return e===0?!1:{level:e,hasBasic:!0,has256:e>=2,has16m:e>=3}}function rn(e,{streamIsTTY:r,sniffFlags:t=!0}={}){let n=kt();n!==void 0&&(Ie=n);let i=t?Ie:n;if(i===0)return 0;if(t){if(F("color=16m")||F("color=full")||F("color=truecolor"))return 3;if(F("color=256"))return 2}if(e&&!r&&i===void 0)return 0;let s=i||0;if(w.TERM==="dumb")return s;if(process.platform==="win32"){let o=Qt.release().split(".");return Number(o[0])>=10&&Number(o[2])>=10586?Number(o[2])>=14931?3:2:1}if("CI"in w)return["TRAVIS","CIRCLECI","APPVEYOR","GITLAB_CI","GITHUB_ACTIONS","BUILDKITE","DRONE"].some(o=>o in w)||w.CI_NAME==="codeship"?1:s;if("TEAMCITY_VERSION"in w)return/^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/.test(w.TEAMCITY_VERSION)?1:0;if(w.COLORTERM==="truecolor")return 3;if("TERM_PROGRAM"in w){let o=Number.parseInt((w.TERM_PROGRAM_VERSION||"").split(".")[0],10);switch(w.TERM_PROGRAM){case"iTerm.app":return o>=3?3:2;case"Apple_Terminal":return 2}}return/-256(color)?$/i.test(w.TERM)?2:/^screen|^xterm|^vt100|^vt220|^rxvt|color|ansi|cygwin|linux/i.test(w.TERM)||"COLORTERM"in w?1:s}function Je(e,r={}){let t=rn(e,{streamIsTTY:e&&e.isTTY,...r});return en(t)}Kr.exports={supportsColor:Je,stdout:Je({isTTY:Hr.isatty(1)}),stderr:Je({isTTY:Hr.isatty(2)})}});var $r=v((y,Le)=>{var tn=require("tty"),Se=require("util");y.init=cn;y.log=fn;y.formatArgs=on;y.save=an;y.load=un;y.useColors=nn;y.destroy=Se.deprecate(()=>{},"Instance method `debug.destroy()` is deprecated and no longer does anything. It will be removed in the next major version of `debug`.");y.colors=[6,2,3,4,5,1];try{let e=Vr();e&&(e.stderr||e).level>=2&&(y.colors=[20,21,26,27,32,33,38,39,40,41,42,43,44,45,56,57,62,63,68,69,74,75,76,77,78,79,80,81,92,93,98,99,112,113,128,129,134,135,148,149,160,161,162,163,164,165,166,167,168,169,170,171,172,173,178,179,184,185,196,197,198,199,200,201,202,203,204,205,206,207,208,209,214,215,220,221])}catch{}y.inspectOpts=Object.keys(process.env).filter(e=>/^debug_/i.test(e)).reduce((e,r)=>{let t=r.substring(6).toLowerCase().replace(/_([a-z])/g,(i,s)=>s.toUpperCase()),n=process.env[r];return/^(yes|on|true|enabled)$/i.test(n)?n=!0:/^(no|off|false|disabled)$/i.test(n)?n=!1:n==="null"?n=null:n=Number(n),e[t]=n,e},{});function nn(){return"colors"in y.inspectOpts?!!y.inspectOpts.colors:tn.isatty(process.stderr.fd)}function on(e){let{namespace:r,useColors:t}=this;if(t){let n=this.color,i="\x1B[3"+(n<8?n:"8;5;"+n),s=` ${i};1m${r} \x1B[0m`;e[0]=s+e[0].split(` +`).join(` +`+s),e.push(i+"m+"+Le.exports.humanize(this.diff)+"\x1B[0m")}else e[0]=sn()+r+" "+e[0]}function sn(){return y.inspectOpts.hideDate?"":new Date().toISOString()+" "}function fn(...e){return process.stderr.write(Se.formatWithOptions(y.inspectOpts,...e)+` +`)}function an(e){e?process.env.DEBUG=e:delete process.env.DEBUG}function un(){return process.env.DEBUG}function cn(e){e.inspectOpts={};let r=Object.keys(y.inspectOpts);for(let t=0;tr.trim()).join(" ")};Xr.O=function(e){return this.inspectOpts.colors=this.useColors,Se.inspect(e,this.inspectOpts)}});var Jr=v((Hn,Qe)=>{typeof process=="undefined"||process.type==="renderer"||process.browser===!0||process.__nwjs?Qe.exports=Gr():Qe.exports=$r()});var et=v((Kn,kr)=>{kr.exports=Qr;function Qr(e,r){if(e&&r)return Qr(e)(r);if(typeof e!="function")throw new TypeError("need wrapper function");return Object.keys(e).forEach(function(n){t[n]=e[n]}),t;function t(){for(var n=new Array(arguments.length),i=0;i{var rt=et();ke.exports=rt(Oe);ke.exports.strict=rt(tt);Oe.proto=Oe(function(){Object.defineProperty(Function.prototype,"once",{value:function(){return Oe(this)},configurable:!0}),Object.defineProperty(Function.prototype,"onceStrict",{value:function(){return tt(this)},configurable:!0})});function Oe(e){var r=function(){return r.called?r.value:(r.called=!0,r.value=e.apply(this,arguments))};return r.called=!1,r}function tt(e){var r=function(){if(r.called)throw new Error(r.onceError);return r.called=!0,r.value=e.apply(this,arguments)},t=e.name||"Function wrapped with `once`";return r.onceError=t+" shouldn't be called more than once",r.called=!1,r}});var ot=v((Xn,it)=>{var dn=er(),ln=function(){},hn=function(e){return e.setHeader&&typeof e.abort=="function"},mn=function(e){return e.stdio&&Array.isArray(e.stdio)&&e.stdio.length===3},nt=function(e,r,t){if(typeof r=="function")return nt(e,null,r);r||(r={}),t=dn(t||ln);var n=e._writableState,i=e._readableState,s=r.readable||r.readable!==!1&&e.readable,o=r.writable||r.writable!==!1&&e.writable,f=!1,u=function(){e.writable||l()},l=function(){o=!1,s||t.call(e)},a=function(){s=!1,o||t.call(e)},c=function(x){t.call(e,x?new Error("exited with error code: "+x):null)},d=function(x){t.call(e,x)},h=function(){process.nextTick(E)},E=function(){if(!f){if(s&&!(i&&i.ended&&!i.destroyed))return t.call(e,new Error("premature close"));if(o&&!(n&&n.ended&&!n.destroyed))return t.call(e,new Error("premature close"))}},p=function(){e.req.on("finish",l)};return hn(e)?(e.on("complete",l),e.on("abort",h),e.req?p():e.on("request",p)):o&&!n&&(e.on("end",u),e.on("close",u)),mn(e)&&e.on("exit",c),e.on("end",a),e.on("finish",l),r.error!==!1&&e.on("error",d),e.on("close",h),function(){f=!0,e.removeListener("complete",l),e.removeListener("abort",h),e.removeListener("request",p),e.req&&e.req.removeListener("finish",l),e.removeListener("end",u),e.removeListener("close",u),e.removeListener("finish",l),e.removeListener("exit",c),e.removeListener("end",a),e.removeListener("error",d),e.removeListener("close",h)}};it.exports=nt});var at=v(($n,ft)=>{var pn=er(),xn=ot(),ze;try{ze=require("fs")}catch{}var te=function(){},En=/^v?\.0/.test(process.version),_e=function(e){return typeof e=="function"},vn=function(e){return!En||!ze?!1:(e instanceof(ze.ReadStream||te)||e instanceof(ze.WriteStream||te))&&_e(e.close)},wn=function(e){return e.setHeader&&_e(e.abort)},yn=function(e,r,t,n){n=pn(n);var i=!1;e.on("close",function(){i=!0}),xn(e,{readable:r,writable:t},function(o){if(o)return n(o);i=!0,n()});var s=!1;return function(o){if(!i&&!s){if(s=!0,vn(e))return e.close(te);if(wn(e))return e.abort();if(_e(e.destroy))return e.destroy();n(o||new Error("stream was destroyed"))}}},st=function(e){e()},gn=function(e,r){return e.pipe(r)},Cn=function(){var e=Array.prototype.slice.call(arguments),r=_e(e[e.length-1]||te)&&e.pop()||te;if(Array.isArray(e[0])&&(e=e[0]),e.length<2)throw new Error("pump requires two streams per minimum");var t,n=e.map(function(i,s){var o=s0;return yn(i,o,f,function(u){t||(t=u),u&&n.forEach(st),!o&&(n.forEach(st),r(t))})});return e.reduce(gn)};ft.exports=Cn});var ct=v((Jn,ut)=>{"use strict";var{PassThrough:bn}=require("stream");ut.exports=e=>{e={...e};let{array:r}=e,{encoding:t}=e,n=t==="buffer",i=!1;r?i=!(t||n):t=t||"utf8",n&&(t=null);let s=new bn({objectMode:i});t&&s.setEncoding(t);let o=0,f=[];return s.on("data",u=>{f.push(u),i?o=f.length:o+=u.length}),s.getBufferedValue=()=>r?f:n?Buffer.concat(f,o):f.join(""),s.getBufferedLength=()=>o,s}});var dt=v((Qn,X)=>{"use strict";var{constants:Fn}=require("buffer"),In=at(),Sn=ct(),Re=class extends Error{constructor(){super("maxBuffer exceeded"),this.name="MaxBufferError"}};async function De(e,r){if(!e)return Promise.reject(new Error("Expected a stream"));r={maxBuffer:1/0,...r};let{maxBuffer:t}=r,n;return await new Promise((i,s)=>{let o=f=>{f&&n.getBufferedLength()<=Fn.MAX_LENGTH&&(f.bufferedData=n.getBufferedValue()),s(f)};n=In(e,Sn(r),f=>{if(f){o(f);return}i()}),n.on("data",()=>{n.getBufferedLength()>t&&o(new Re)})}),n.getBufferedValue()}X.exports=De;X.exports.default=De;X.exports.buffer=(e,r)=>De(e,{...r,encoding:"buffer"});X.exports.array=(e,r)=>De(e,{...r,array:!0});X.exports.MaxBufferError=Re});var ht=v((kn,lt)=>{"use strict";var S=Jr()("extract-zip"),{createWriteStream:Ln,promises:$}=require("fs"),On=dt(),W=require("path"),{promisify:tr}=require("util"),zn=require("stream"),_n=Xe(),Rn=tr(_n.open),Dn=tr(zn.pipeline),rr=class{constructor(r,t){this.zipPath=r,this.opts=t}async extract(){return S("opening",this.zipPath,"with opts",this.opts),this.zipfile=await Rn(this.zipPath,{lazyEntries:!0}),this.canceled=!1,new Promise((r,t)=>{this.zipfile.on("error",n=>{this.canceled=!0,t(n)}),this.zipfile.readEntry(),this.zipfile.on("close",()=>{this.canceled||(S("zip extraction complete"),r())}),this.zipfile.on("entry",async n=>{if(this.canceled){S("skipping entry",n.fileName,{cancelled:this.canceled});return}if(S("zipfile entry",n.fileName),n.fileName.startsWith("__MACOSX/")){this.zipfile.readEntry();return}let i=W.dirname(W.join(this.opts.dir,n.fileName));try{await $.mkdir(i,{recursive:!0});let s=await $.realpath(i);if(W.relative(this.opts.dir,s).split(W.sep).includes(".."))throw new Error(`Out of bound path "${s}" found while processing file ${n.fileName}`);await this.extractEntry(n),S("finished processing",n.fileName),this.zipfile.readEntry()}catch(s){this.canceled=!0,this.zipfile.close(),t(s)}})})}async extractEntry(r){if(this.canceled){S("skipping entry extraction",r.fileName,{cancelled:this.canceled});return}this.opts.onEntry&&this.opts.onEntry(r,this.zipfile);let t=W.join(this.opts.dir,r.fileName),n=r.externalFileAttributes>>16&65535,i=61440,s=16384,f=(n&i)===40960,u=(n&i)===s;!u&&r.fileName.endsWith("/")&&(u=!0);let l=r.versionMadeBy>>8;u||(u=l===0&&r.externalFileAttributes===16),S("extracting entry",{filename:r.fileName,isDir:u,isSymlink:f});let a=this.getExtractedMode(n,u)&511,c=u?t:W.dirname(t),d={recursive:!0};if(u&&(d.mode=a),S("mkdir",{dir:c,...d}),await $.mkdir(c,d),u)return;S("opening read stream",t);let h=await tr(this.zipfile.openReadStream.bind(this.zipfile))(r);if(f){let E=await On(h);S("creating symlink",E,t),await $.symlink(E,t)}else await Dn(h,Ln(t,{mode:a}))}getExtractedMode(r,t){let n=r;return n===0&&(t?(this.opts.defaultDirMode&&(n=parseInt(this.opts.defaultDirMode,10)),n||(n=493)):(this.opts.defaultFileMode&&(n=parseInt(this.opts.defaultFileMode,10)),n||(n=420))),n}};lt.exports=async function(e,r){if(S("creating target directory",r.dir),!W.isAbsolute(r.dir))throw new Error("Target directory is expected to be absolute");return await $.mkdir(r.dir,{recursive:!0}),r.dir=await $.realpath(r.dir),new rr(e,r).extract()}});var Tn={};Ct(Tn,{extract:()=>Un,yauzl:()=>pt,yazl:()=>mt});module.exports=bt(Tn);var mt=sr(Fr()),pt=sr(Xe()),An=ht(),Un=An;0&&(module.exports={extract,yauzl,yazl}); diff --git a/node_modules/.deno/playwright-core@1.58.2/node_modules/playwright-core/package.json b/node_modules/.deno/playwright-core@1.58.2/node_modules/playwright-core/package.json new file mode 100644 index 0000000..1a9356d --- /dev/null +++ b/node_modules/.deno/playwright-core@1.58.2/node_modules/playwright-core/package.json @@ -0,0 +1,43 @@ +{ + "name": "playwright-core", + "version": "1.58.2", + "description": "A high-level API to automate web browsers", + "repository": { + "type": "git", + "url": "git+https://github.com/microsoft/playwright.git" + }, + "homepage": "https://playwright.dev", + "engines": { + "node": ">=18" + }, + "author": { + "name": "Microsoft Corporation" + }, + "license": "Apache-2.0", + "exports": { + ".": { + "types": "./index.d.ts", + "import": "./index.mjs", + "require": "./index.js", + "default": "./index.js" + }, + "./package.json": "./package.json", + "./lib/outofprocess": "./lib/outofprocess.js", + "./lib/cli/program": "./lib/cli/program.js", + "./lib/mcpBundle": "./lib/mcpBundle.js", + "./lib/remote/playwrightServer": "./lib/remote/playwrightServer.js", + "./lib/server": "./lib/server/index.js", + "./lib/server/utils/image_tools/stats": "./lib/server/utils/image_tools/stats.js", + "./lib/server/utils/image_tools/compare": "./lib/server/utils/image_tools/compare.js", + "./lib/server/utils/image_tools/imageChannel": "./lib/server/utils/image_tools/imageChannel.js", + "./lib/server/utils/image_tools/colorUtils": "./lib/server/utils/image_tools/colorUtils.js", + "./lib/server/registry/index": "./lib/server/registry/index.js", + "./lib/utils": "./lib/utils.js", + "./lib/utilsBundle": "./lib/utilsBundle.js", + "./lib/zipBundle": "./lib/zipBundle.js" + }, + "bin": { + "playwright-core": "cli.js" + }, + "types": "types/types.d.ts" +} diff --git a/node_modules/.deno/playwright-core@1.58.2/node_modules/playwright-core/types/protocol.d.ts b/node_modules/.deno/playwright-core@1.58.2/node_modules/playwright-core/types/protocol.d.ts new file mode 100644 index 0000000..265faf3 --- /dev/null +++ b/node_modules/.deno/playwright-core@1.58.2/node_modules/playwright-core/types/protocol.d.ts @@ -0,0 +1,23824 @@ +// This is generated from /utils/protocol-types-generator/index.js +type binary = string; +export namespace Protocol { + export namespace Accessibility { + /** + * Unique accessibility node identifier. + */ + export type AXNodeId = string; + /** + * Enum of possible property types. + */ + export type AXValueType = "boolean"|"tristate"|"booleanOrUndefined"|"idref"|"idrefList"|"integer"|"node"|"nodeList"|"number"|"string"|"computedString"|"token"|"tokenList"|"domRelation"|"role"|"internalRole"|"valueUndefined"; + /** + * Enum of possible property sources. + */ + export type AXValueSourceType = "attribute"|"implicit"|"style"|"contents"|"placeholder"|"relatedElement"; + /** + * Enum of possible native property sources (as a subtype of a particular AXValueSourceType). + */ + export type AXValueNativeSourceType = "description"|"figcaption"|"label"|"labelfor"|"labelwrapped"|"legend"|"rubyannotation"|"tablecaption"|"title"|"other"; + /** + * A single source for a computed AX property. + */ + export interface AXValueSource { + /** + * What type of source this is. + */ + type: AXValueSourceType; + /** + * The value of this property source. + */ + value?: AXValue; + /** + * The name of the relevant attribute, if any. + */ + attribute?: string; + /** + * The value of the relevant attribute, if any. + */ + attributeValue?: AXValue; + /** + * Whether this source is superseded by a higher priority source. + */ + superseded?: boolean; + /** + * The native markup source for this value, e.g. a `