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 <PATH-TO-YOUR-SPACE>
```

Alternatively, to build:

```shell
make
```

To run the resulting server:

```shell
./silverbullet <PATH-TO-YOUR-SPACE>
```

### 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 <PATH-TO-YOUR-SPACE>:/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<PageMeta[]> {
  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<void> {
  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("<options> <manifest paths> | <command> (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 <path:string>",
    "Folder to put the resulting .plug.json file into",
    { default: "." },
  )
  .option("-c, --config <path:string>", "Path to deno.json file to use")
  .option("--runtimeUrl <url:string>", "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<AssetBundle> {
  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<string, { data: DataUrl; mtime: number }>;

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<void>((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<boolean>(["sync", "documents"], false);
  let syncIgnore = config!.get<string | string[]>(["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<string> {
  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<Config> {
  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<any>();
  // 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<WidgetCacheItem>(100); // bodyText -> WidgetCacheItem
  debouncedWidgetCacheFlush = throttle(() => {
    this.ds.set(["cache", "widgets"], this.widgetCache.toJSON())
      .catch(
        console.error,
      );
  }, 2000);
  private widgetHeightCache = new LimitedMap<number>(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<any[]> {
    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<void> {
    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<T>(
    tag: string,
    query: LuaCollectionQuery,
    scopedVariables?: Record<string, any>,
  ): Promise<T[]> {
    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<PageMeta>("page", {});
      // Overlay augmented meta values
      await this.pageMetaAugmenter.augmentObjectArray(allPages, "ref");
      // Fetch aspiring pages
      const aspiringPageNames = await this.queryLuaObjects<string>(
        "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<DocumentMeta>(
      "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<FilterOption | undefined> {
    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<string | undefined> {
    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<boolean> {
    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<CompletionResult | SlashCompletions | null> {
    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<CompletionResult | null> {
    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<PageMeta>(
          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<StyleObject>(
      "space-style",
      {
        objectVariable: "_",
        orderBy: [{
          expr: parseExpressionString("_.priority"),
          desc: true,
        }],
      },
    );
    if (!spaceStyles) {
      return;
    }

    // Prepare separate <style> tag per custom style (for robustness)
    const customStylesContent = spaceStyles.map((s) =>
      "<style>" + s.style + "</style>"
    ).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<string, Command> {
    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<void>((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<SilverBulletHooks>;
  // ... and hooks
  commandHook!: CommandHook;
  slashCommandHook!: SlashCommandHook;
  namespaceHook!: PlugNamespaceHook;
  codeWidgetHook!: CodeWidgetHook;
  documentEditorHook!: DocumentEditorHook;
  mqHook!: MQHook;

  serviceRegistry!: ServiceRegistry;

  // Space Lua
  spaceLuaEnv: SpaceLuaEnvironment;
  readonly scriptCommands = new Map<string, Command>();
  scriptsLoaded: boolean = false;

  // Known files (for UI)
  readonly allKnownFiles = new Set<string>();
  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<SilverBulletHooks>(
        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<Record<string, Command>>("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 =
  '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-copy"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></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<Decoration>[] = [];
  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<DomWidget>();

export interface DomWidget {
  dom?: HTMLElement;

  renderContent(
    div: HTMLElement,
    cachedHtml: string | undefined,
  ): Promise<void>;
}

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(/<!--[\s\S]*?-->/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<string | undefined>(
            "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 <a> 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), <scheme>:// 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<Decoration>[] = [];
    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<Decoration>[] = [];
    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<Diagnostic[]> => {
    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<Decoration>[] = [];

    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<LuaWidgetContent | null>;

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<string, (event: EventPayLoad) => 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:
          '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-refresh-cw"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>',
        listener: (e) => {
          e.stopPropagation();
          this.client.clientSystem.localSyscall(
            "system.invokeFunction",
            ["index.refreshWidgets"],
          ).catch(console.error);
        },
      },
    ));

    if (copyContent) {
      buttonBar.appendChild(createButton(
        {
          title: "Copy",
          icon:
            `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-copy"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`,
          listener: (e) => {
            e.stopPropagation();
            this.client.clientSystem.localSyscall(
              "editor.copyToClipboard",
              [copyContent],
            ).catch(console.error);
          },
        },
      ));
    }

    if (this.inPage) {
      buttonBar.appendChild(createButton(
        {
          title: "Edit",
          icon:
            '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-edit"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>',
          listener: (e) => {
            e.stopPropagation();
            moveCursorIntoText(this.client, this.codeText);
          },
        },
      ));
    }

    if (this.openRef) {
      buttonBar.appendChild(createButton(
        {
          title: "Open",
          icon:
            '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" class="css-i6dzq1"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>',
          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<string> {
  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<SmartQuotesConfig>("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<Decoration>[] = [];
    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<LuaWidgetContent[] | null>,
    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<HTMLDivElement>(":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<DecorationSet>({
    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<string, (event: EventPayLoad) => 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<string>;
  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 (
    <FilterList
      placeholder={mode === "page"
        ? "Page"
        : (mode === "meta"
          ? "#meta page"
          : (mode === "document"
            ? "Document"
            : "Any page or Document, also hidden"))}
      label="Open"
      options={options}
      vimMode={vimMode}
      darkMode={darkMode}
      phrasePreprocessor={(phrase) => {
        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 <code>Enter</code> to open the selected ${openablePageNoun}` +
        (allowNew
          ? `, or <code>Shift-Enter</code> 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 = (
    <AlwaysShownModal
      onCancel={() => {
        callback();
      }}
    >
      <div className="sb-prompt">
        <label>{message}</label>
        <MiniEditor
          text={defaultValue || ""}
          vimMode={vimMode}
          vimStartInInsertMode={true}
          focus={true}
          darkMode={darkMode}
          onEnter={(text) => {
            callback(text);
            return true;
          }}
          onEscape={() => {
            callback();
          }}
          onChange={(text) => {
            setText(text);
          }}
          editable={true}
        />
        <div className="sb-prompt-buttons">
          <Button
            primary={true}
            onActivate={() => {
              callback(text);
            }}
          >
            Ok
          </Button>
          <Button
            onActivate={() => {
              callback();
            }}
          >
            Cancel
          </Button>
        </div>
      </div>
    </AlwaysShownModal>
  );

  return returnEl;
}

export function Confirm({
  message,
  callback,
}: {
  message: string;
  callback: (value: boolean) => void;
}) {
  const okButtonRef = useRef<HTMLButtonElement>(null);
  setTimeout(() => {
    okButtonRef.current?.focus();
  });
  const returnEl = (
    <AlwaysShownModal
      onCancel={() => {
        callback(false);
      }}
    >
      <div className="sb-prompt">
        <label>{message}</label>
        <div className="sb-prompt-buttons">
          <Button
            buttonRef={okButtonRef}
            primary={true}
            onActivate={() => {
              callback(true);
            }}
          >
            Ok
          </Button>
          <Button
            onActivate={() => {
              callback(false);
            }}
          >
            Cancel
          </Button>
        </div>
      </div>
    </AlwaysShownModal>
  );

  return returnEl;
}

export function Button({
  children,
  primary,
  onActivate,
  buttonRef,
}: {
  children: ComponentChildren;
  primary?: boolean;
  onActivate: () => void;
  buttonRef?: Ref<HTMLButtonElement>;
}) {
  return (
    <button
      ref={buttonRef}
      type="button"
      className={primary ? "sb-button-primary" : "sb-button"}
      onClick={(e) => {
        e.stopPropagation();
        e.preventDefault();
        onActivate();
      }}
      onKeyDown={(e) => {
        if (e.key === "Enter") {
          e.stopPropagation();
          e.preventDefault();
          onActivate();
        }
      }}
    >
      {children}
    </button>
  );
}

export function AlwaysShownModal({
  children,
  onCancel,
}: {
  children: ComponentChildren;
  onCancel?: () => void;
}) {
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    dialogRef.current?.showModal();
  }, []);

  return (
    <dialog
      className="sb-modal-box"
      onCancel={(e: Event) => {
        e.preventDefault();
        onCancel?.();
      }}
      onKeyDown={(e) => {
        e.stopPropagation();
      }}
      ref={dialogRef}
    >
      {children}
    </dialog>
  );
}


================================================
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<string, Command>;
  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 (
    <FilterList
      label="Run"
      placeholder="Command"
      options={options}
      allowNew={false}
      icon={Terminal}
      vimMode={vimMode}
      darkMode={darkMode}
      helpText="Start typing the command name to filter results, press <code>Enter</code> to run."
      onSelect={(opt) => {
        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<FeatherProps>;
}) {
  const [text, setText] = useState("");
  const [matchingOptions, setMatchingOptions] = useState(
    fuzzySearchAndSort(
      preFilter ? preFilter(options, "") : options,
      "",
    ),
  );
  const [selectedOption, setSelectionOption] = useState(0);

  const selectedElementRef = useRef<HTMLDivElement>(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 = (
    <AlwaysShownModal
      onCancel={() => {
        onSelect(undefined);
      }}
    >
      <div
        className="sb-header"
        onClick={(e) => {
          // Allow tapping/clicking the header without closing it
          e.stopPropagation();
        }}
      >
        <label>{label}</label>
        <MiniEditor
          text={text}
          vimMode={vimMode}
          vimStartInInsertMode={true}
          focus={true}
          darkMode={darkMode}
          placeholderText={placeholder}
          onEnter={(_newText, shiftDown) => {
            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}
        />
      </div>
      <div
        className="sb-help-text"
        dangerouslySetInnerHTML={{ __html: helpText }}
      >
      </div>
      <div className="sb-result-list" tabIndex={-1}>
        {matchingOptions && matchingOptions.length > 0
          ? (() => {
            let optionIndex = 0;

            return matchingOptions.map((option) => {
              const currentOptionIndex = optionIndex;
              optionIndex++;

              return (
                <div
                  key={`option-${currentOptionIndex}`}
                  ref={selectedOption === currentOptionIndex
                    ? selectedElementRef
                    : undefined}
                  className={(selectedOption === currentOptionIndex
                    ? "sb-option sb-selected-option"
                    : "sb-option") +
                    (option.cssClass
                      ? " sb-decorated-object " + option.cssClass
                      : "")}
                  onMouseMove={() => {
                    if (selectedOption !== currentOptionIndex) {
                      setSelectionOption(currentOptionIndex);
                    }
                  }}
                  onClick={(e) => {
                    e.stopPropagation();
                    onSelect(option);
                  }}
                >
                  {Icon && (
                    <span className="sb-icon">
                      <Icon width={16} height={16} />
                    </span>
                  )}
                  <span className="sb-name">
                    {(option.prefix ?? "") + option.name}
                  </span>
                  {option.hint && (
                    <span
                      className={"sb-hint" +
                        (option.hintInactive ? " sb-hint-inactive" : "")}
                    >
                      {option.hint}
                    </span>
                  )}
                  <div className="sb-description">
                    {option.description}
                  </div>
                </div>
              );
            });
          })()
          : null}
      </div>
    </AlwaysShownModal>
  );

  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<void>;
  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<HTMLDivElement>(null);
  const editorViewRef = useRef<EditorView>();
  const vimModeRef = useRef<string>("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<MiniEditorEvents>();

  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 (
    <div
      class="sb-mini-editor"
      onKeyDownCapture={(e) => {
        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<HTMLIFrameElement>(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 (
    <div className="sb-panel" style={{ flex: config.mode }}>
      <iframe
        srcDoc={html}
        ref={iFrameRef}
        style={{ visibility: "hidden" }}
        onLoad={() => iFrameRef.current!.style.visibility = "visible"}
      />
    </div>
  );
}


================================================
FILE: client/components/panel_html.ts
================================================
export const panelHtml = `<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <base target="_top" href="{{.HostPrefix}}">
    <meta name='color-scheme' content='dark light'>
<script>
const pendingRequests = new Map();
let syscallReqId = 0;

globalThis.syscall = async (name, ...args) => {
  return await new Promise((resolve, reject) => {
    syscallReqId++;
    pendingRequests.set(syscallReqId, { resolve, reject });
    globalThis.parent.postMessage({
      type: "syscall",
      id: syscallReqId,
      name,
      args,
    }, "*");
  });
};

let oldHeight = undefined;
let heightChecks = 0;

globalThis.addEventListener("message", (message) => {
  const data = message.data;
  switch (data.type) {
    case "html":
      document.body.innerHTML = data.html;
      if(data.theme) {
        document.getElementsByTagName("html")[0].setAttribute("data-theme", data.theme);
      }
      if (data.script) {
        try {
          eval(data.script);
        } catch (e) {
          console.error("Error evaling script", e);
        }
      }
      setTimeout(() => {
        oldHeight = undefined;
        heightChecks = 0;
        updateHeight();
      });
      break;
    case "syscall-response":
      {
        const syscallId = data.id;
        const lookup = pendingRequests.get(syscallId);
        if (!lookup) {
          console.log(
            "Current outstanding requests",
            pendingRequests,
            "looking up",
            syscallId,
          );
          throw Error("Invalid request id");
        }
        pendingRequests.delete(syscallId);
        if (data.error) {
          lookup.reject(new Error(data.error));
        } else {
          lookup.resolve(data.result);
        }
      }

      break;
  }
});

function updateHeight() {
  const body = document.body, html = document.documentElement;
  let height = Math.max(body.offsetHeight, html.offsetHeight);
  heightChecks++;
  if(height !== oldHeight) {
    oldHeight = height;
    globalThis.parent.postMessage({
      type: "setHeight",
      height: height,
    });
  }
  if(heightChecks < 25) {
    setTimeout(updateHeight, 100);
  }
}

function loadJsByUrl(url,integrity=null) {
  const script = document.createElement("script");
  script.src = url;
  if(integrity){
    script.integrity=integrity;
    script.crossOrigin="anonymous"; //for some weird reason this attribute is case sensitive when used in JS
  }

  return new Promise((resolve) => {
    script.onload = resolve;
    document.documentElement.firstChild.appendChild(script);
  });
}
</script>
</head>
<body>

</body>
</html>`;


================================================
FILE: client/components/top_bar.tsx
================================================
import type { ComponentChildren, FunctionalComponent } from "preact";
import type { Notification } from "@silverbulletmd/silverbullet/type/client";
import { MiniEditor } from "./mini_editor.tsx";

export type ActionButton = {
  icon: FunctionalComponent<any>;
  description: string;
  class?: string;
  callback: () => void;
  href?: string;
  mobile?: boolean;
};

export function TopBar({
  pageName,
  unsavedChanges,
  isOnline,
  isLoading,
  notifications,
  onRename,
  actionButtons,
  darkMode,
  vimMode,
  progressPercentage,
  progressType,
  lhs,
  onClick,
  rhs,
  pageNamePrefix,
  cssClass,
  mobileMenuStyle,
}: {
  pageName?: string;
  unsavedChanges: boolean;
  isOnline: boolean;
  isLoading: boolean;
  notifications: Notification[];
  darkMode?: boolean;
  vimMode: boolean;
  progressPercentage?: number;
  progressType?: string;
  onRename: (newName?: string) => Promise<void>;
  onClick: () => void;
  actionButtons: ActionButton[];
  lhs?: ComponentChildren;
  rhs?: ComponentChildren;
  pageNamePrefix?: string;
  cssClass?: string;
  mobileMenuStyle?: string;
}) {
  return (
    <div
      id="sb-top"
      className={isOnline ? undefined : "sb-sync-error"}
      onClick={onClick}
    >
      {lhs}
      <div className="main">
        <div className="inner">
          <div className="wrapper">
            <div className="sb-page-prefix">{pageNamePrefix}</div>
            <span
              id="sb-current-page"
              className={(isLoading
                ? "sb-loading"
                : unsavedChanges
                ? "sb-unsaved"
                : "sb-saved") +
                (cssClass ? " sb-decorated-object " + cssClass : "")}
            >
              <MiniEditor
                text={pageName ?? ""}
                vimMode={vimMode}
                darkMode={darkMode}
                onBlur={(newName) => {
                  if (newName !== pageName) {
                    return onRename(newName);
                  } else {
                    return onRename();
                  }
                }}
                onEnter={(newName) => {
                  onRename(newName);
                }}
                editable={!client.ui.viewState.uiOptions.forcedROMode &&
                  !client.bootConfig.readOnly}
              />
            </span>
            {notifications.length > 0 && (
              <div className="sb-notifications">
                {notifications.map((notification) => (
                  <div
                    key={notification.id}
                    className={`sb-notification-${notification.type}`}
                  >
                    {notification.message}
                  </div>
                ))}
              </div>
            )}
            <div className="sb-sync-progress">
              {progressPercentage !== undefined &&
                (
                  <div
                    className="progress-wrapper"
                    title={`${progressType} progress: ${progressPercentage}%`}
                  >
                    <div
                      className="progress-bar"
                      style={`background: radial-gradient(closest-side, var(--top-background-color) 79%, transparent 80% 100%), conic-gradient(var(--progress-${progressType}-color) ${progressPercentage}%, var(--progress-background-color) 0);`}
                    >
                      {progressPercentage}
                    </div>
                  </div>
                )}
            </div>
            <div
              className={"sb-actions " +
                (mobileMenuStyle ? mobileMenuStyle : "")}
            >
              {actionButtons.map((actionButton) => {
                const button = (
                  <button
                    type="button"
                    onClick={(e) => {
                      e.preventDefault();
                      e.stopPropagation();
                      actionButton.callback();
                    }}
                    onBlur={() => {
                      // Close the hamburger menu in mobile mode if the action button loses focus after callback
                      if (mobileMenuStyle === "hamburger") {
                        document.querySelector("#sb-top .sb-actions.hamburger")
                          ?.classList.remove("open");
                      }
                    }}
                    title={actionButton.description}
                    className={actionButton.class}
                  >
                    <actionButton.icon size={18} />
                  </button>
                );

                return actionButton.href
                  ? (
                    <a href={actionButton.href} key={actionButton.href}>
                      {button}
                    </a>
                  )
                  : button;
              })}
            </div>
          </div>
        </div>
      </div>
      {rhs}
    </div>
  );
}


================================================
FILE: client/components/widget_sandbox_iframe.ts
================================================
import type { Client } from "../client.ts";
import { panelHtml } from "./panel_html.ts";
import type { CodeWidgetContent } from "@silverbulletmd/silverbullet/type/client";

/**
 * Implements sandbox widgets using iframe with a pooling mechanism to speed up loading
 */

type PreloadedIFrame = {
  // The wrapped iframe element
  iframe: HTMLIFrameElement;
  // Has the iframe been used yet?
  used: boolean;
  // Is it ready (that is: has the initial load happened)
  ready: Promise<void>;
};

const iframePool = new Set<PreloadedIFrame>();
const desiredPoolSize = 3;

updatePool();

function updatePool(exclude?: PreloadedIFrame) {
  let availableFrames = 0;
  // Iterate over all iframes
  for (const preloadedIframe of iframePool) {
    if (preloadedIframe === exclude) {
      continue;
    }
    if (
      // Is this iframe in use, but has it since been removed from the DOM?
      preloadedIframe.used && !document.body.contains(preloadedIframe.iframe)
    ) {
      // Ditch it
      // console.log("Garbage collecting iframe", preloadedIframe);
      iframePool.delete(preloadedIframe);
    }
    if (!preloadedIframe.used) {
      availableFrames++;
    }
  }
  // And after, add more iframes if needed
  for (let i = 0; i < desiredPoolSize - availableFrames; i++) {
    iframePool.add(prepareSandboxIFrame());
  }
}

export function prepareSandboxIFrame(): PreloadedIFrame {
  // console.log("Preloading iframe");
  const iframe = document.createElement("iframe");

  // Empty page with current origin. Handled this differently before, but "dock apps" in Safari (PWA implementation) seem to have various restrictions
  // This one works in all browsers, although it's probably less secure
  iframe.src = "about:blank";
  iframe.style.visibility = "hidden";

  const ready = new Promise<void>((resolve) => {
    iframe.onload = () => {
      iframe.contentDocument!.write(panelHtml);
      iframe.style.visibility = "visible";
      // Now ready to use
      resolve();
    };
  });
  return {
    iframe,
    used: false,
    ready,
  };
}

function claimIFrame(): PreloadedIFrame {
  for (const preloadedIframe of iframePool) {
    if (!preloadedIframe.used) {
      // console.log("Took iframe from pool");
      preloadedIframe.used = true;
      updatePool(preloadedIframe);
      return preloadedIframe;
    }
  }
  // Nothing available in the pool, let's spin up a new one and add it to the pool
  console.warn("Had to create a new iframe on the fly, this shouldn't happen");
  const newPreloadedIFrame = prepareSandboxIFrame();
  newPreloadedIFrame.used = true;
  iframePool.add(newPreloadedIFrame);
  return newPreloadedIFrame;
}

export function broadcastReload() {
  for (const preloadedIframe of iframePool) {
    if (preloadedIframe.used && preloadedIframe.iframe?.contentWindow) {
      // Send a message to the global object, which the iframe is listening to
      globalThis.dispatchEvent(
        new MessageEvent("message", {
          source: preloadedIframe.iframe.contentWindow,
          data: {
            type: "reload",
          },
        }),
      );
    }
  }
}

export function mountIFrame(
  preloadedIFrame: PreloadedIFrame,
  client: Client,
  widgetHeightCacheKey: string | null,
  content: CodeWidgetContent | null | Promise<CodeWidgetContent | null>,
  onMessage?: (message: any) => void,
) {
  const iframe = preloadedIFrame.iframe;

  preloadedIFrame.ready.then(async () => {
    const messageListener = (evt: any) => {
      (async () => {
        if (evt.source !== iframe.contentWindow) {
          return;
        }
        const data = evt.data;
        if (!data) {
          return;
        }
        switch (data.type) {
          case "syscall": {
            const { id, name, args } = data;
            try {
              const result = await client.clientSystem.localSyscall(name, args);
              if (!iframe.contentWindow) {
                // iFrame already went away
                return;
              }
              iframe.contentWindow!.postMessage({
                type: "syscall-response",
                id,
                result,
              });
            } catch (e: any) {
              if (!iframe.contentWindow) {
                // iFrame already went away
                return;
              }
              iframe.contentWindow!.postMessage({
                type: "syscall-response",
                id,
                error: e.message,
              });
            }
            break;
          }
          case "setHeight":
            // iframe.height = data.height + "px";
            iframe.style.height = data.height + "px";
            if (widgetHeightCacheKey) {
              client.setCachedWidgetHeight(
                widgetHeightCacheKey,
                data.height,
              );
            }
            break;
          default:
            if (onMessage) {
              onMessage(data);
            }
        }
      })().catch((e) => {
        console.error("Message listener error", e);
      });
    };

    // Subscribe to message event on global object (to receive messages from iframe)
    globalThis.addEventListener("message", messageListener);
    // Only run this code once
    iframe.onload = null;
    const resolvedContent = await Promise.resolve(content);
    if (!iframe.contentWindow) {
      console.warn("Iframe went away or content was not loaded");
      return;
    }
    if (resolvedContent) {
      if (resolvedContent.html) {
        iframe.contentWindow!.postMessage({
          type: "html",
          html: resolvedContent.html,
          script: resolvedContent.script,
          theme: document.getElementsByTagName("html")[0].dataset.theme,
        });
      } else if (resolvedContent.url) {
        iframe.contentWindow!.location.href = resolvedContent.url;
        if (resolvedContent.height) {
          iframe.style.height = resolvedContent.height + "px";
          if (widgetHeightCacheKey) {
            client.setCachedWidgetHeight(
              widgetHeightCacheKey!,
              resolvedContent.height,
            );
          }
        }
        if (resolvedContent.width) {
          iframe.width = resolvedContent.width + "px";
        }
      }
    }
  }).catch(console.error);
}

export function createWidgetSandboxIFrame(
  client: Client,
  widgetHeightCacheKey: string | null,
  content: CodeWidgetContent | null | Promise<CodeWidgetContent | null>,
  onMessage?: (message: any) => void,
) {
  // console.log("Claiming iframe");
  const preloadedIFrame = claimIFrame();
  mountIFrame(
    preloadedIFrame,
    client,
    widgetHeightCacheKey,
    content,
    onMessage,
  );
  return preloadedIFrame.iframe;
}


================================================
FILE: client/config.test.ts
================================================
import { assert, assertEquals, assertThrows } from "@std/assert";
import { Config } from "./config.ts";

Deno.test("Config - basic functionality", () => {
  const config = new Config();

  // Test set and get
  config.set("testKey", "testValue");
  assertEquals(config.get(["testKey"], null), "testValue");

  // Test insert
  config.insert("plugs", "plug1");
  config.insert("plugs", "plug1");
  assertEquals(config.get("plugs", null), ["plug1", "plug1"]);

  // Test default value
  assertEquals(config.get("nonExistentKey", "default"), "default");

  // Test has
  assert(config.has("testKey"));
  assert(!config.has("nonExistentKey"));
});

Deno.test("Config - object-based setting", () => {
  const config = new Config();

  // Test setting multiple values at once
  config.set({
    key1: "value1",
    key2: "value2",
    key3: 123,
  });

  assertEquals(config.get("key1", null), "value1");
  assertEquals(config.get("key2", null), "value2");
  assertEquals(config.get("key3", 0), 123);
});

Deno.test("Config - paths", () => {
  const config = new Config();

  // Test setting with dot notation
  config.set("user.name", "John");
  config.set("user.profile.age", 30);

  // Test getting with dot notation
  assertEquals(config.get("user.name", null), "John");
  assertEquals(config.get("user.profile.age", 0), 30);

  config.set(["user", "name"], "Pete");
  config.set(["user", "profile", "age"], 20);
  assertEquals(config.get(["user", "name"], null), "Pete");
  assertEquals(config.get(["user", "profile", "age"], 0), 20);

  // Test has with dot notation
  assert(config.has("user.name"));
  assert(config.has("user.profile"));
  assert(config.has("user.profile.age"));
  assert(!config.has("user.profile.nonExistent"));

  // Test with path notation
  assert(config.has(["user", "name"]));
  assert(config.has(["user", "profile", "age"]));

  // Test getting the entire object
  assertEquals(config.get("user", {}), {
    name: "Pete",
    profile: {
      age: 20,
    },
  });
});

Deno.test("Config - edge cases", () => {
  const config = new Config();

  // Test setting a value on a non-existent path
  config.set("a.b.c", "value");
  assertEquals(config.get("a.b.c", null), "value");

  // Test overwriting a primitive with an object
  config.set("x", "primitive");
  assertEquals(config.get("x", null), "primitive");

  config.set("x.y", "nested");
  assertEquals(config.get("x.y", null), "nested");

  // Test setting a value on a path where part of the path is a primitive
  config.set("p", "primitive");
  config.set("p.q.r", "nested");
  assertEquals(config.get("p.q.r", null), "nested");

  // Test deep nesting
  config.set("deep.nesting.test.value", 42);
  assertEquals(config.get("deep.nesting.test.value", 0), 42);

  // Test with empty string key
  config.set("", "empty");
  assertEquals(config.get("", null), "empty");

  // Test with special characters in key
  config.set("special!@#", "chars");
  assertEquals(config.get("special!@#", null), "chars");
});

Deno.test("Config - object setting with dot notation in keys", () => {
  const config = new Config();

  // Test setting an object with dot notation in keys
  config.set({
    "user.name": "John",
    "user.profile.age": 30,
  });

  assertEquals(config.get("user.name", null), "John");
  assertEquals(config.get("user.profile.age", 0), 30);
});

Deno.test("Config - constructor with initial values", () => {
  const config = new Config({
    simple: "value",
    nested: {
      key: "nestedValue",
    },
  });

  assertEquals(config.get("simple", null), "value");
  assertEquals(config.get("nested.key", null), "nestedValue");
});

Deno.test("Config - schema validation", () => {
  const config = new Config();

  // Define a schema for a key
  const userSchema = {
    type: "object",
    properties: {
      name: { type: "string" },
      age: { type: "number", minimum: 0 },
      email: { type: "string", format: "email" },
    },
    required: ["name", "age"],
  };

  config.define("user", userSchema);

  // Valid data should work
  config.set("user", {
    name: "John",
    age: 30,
    email: "john@example.com",
  });

  config.set("user.name", "John");

  // Missing required field should throw
  assertThrows(
    () => {
      config.set("user", {
        name: "John",
      });
    },
    Error,
    "Validation error for user",
  );

  // Wrong type should throw
  assertThrows(
    () => {
      config.set("user", {
        name: "John",
        age: "thirty", // Should be a number
      });
    },
    Error,
    "Validation error for user",
  );

  // Wrong type should throw with path
  assertThrows(
    () => {
      config.set(["user", "name"], 22);
    },
    Error,
    "Validation error for user",
  );

  // Invalid format should throw
  assertThrows(
    () => {
      config.set("user", {
        name: "John",
        age: 30,
        email: "not-an-email",
      });
    },
    Error,
    "Validation error for user",
  );

  // Value below minimum should throw
  assertThrows(
    () => {
      config.set("user", {
        name: "John",
        age: -1, // Should be >= 0
      });
    },
    Error,
    "Validation error for user",
  );

  // Check nested keys
  config.define("a.b.user", userSchema);

  console.log(config.schemas);

  config.set("a.b.user.name", "Hank");
  assertThrows(
    () => {
      config.set("a.b.user.name", 22);
    },
    Error,
    "Validation error for a.b.user",
  );
});

Deno.test("Config - nested schema definitions with arrays", () => {
  const config = new Config();

  // Define schema for nested path using array syntax
  const settingsSchema = {
    type: "object",
    properties: {
      theme: { type: "string" },
      maxItems: { type: "number", minimum: 1 },
    },
    required: ["theme"],
  };

  config.define(["app", "ui", "settings"], settingsSchema);

  // Valid nested setting should work
  config.set("app.ui.settings", {
    theme: "dark",
    maxItems: 10,
  });

  assertEquals(config.get("app.ui.settings.theme", null), "dark");
  assertEquals(config.get("app.ui.settings.maxItems", 0), 10);

  // Setting individual nested properties should work
  config.set("app.ui.settings.theme", "light");
  assertEquals(config.get("app.ui.settings.theme", null), "light");

  // Invalid type should throw
  assertThrows(
    () => {
      config.set("app.ui.settings.theme", 123);
    },
    Error,
    "Validation error for app.ui.settings",
  );

  // Invalid value should throw
  assertThrows(
    () => {
      config.set("app.ui.settings.maxItems", -1);
    },
    Error,
    "Validation error for app.ui.settings",
  );
});

Deno.test("Config - multiple nested schemas", () => {
  const config = new Config();

  // Define multiple schemas at different nesting levels
  const userSchema = {
    type: "object",
    properties: {
      name: { type: "string" },
      age: { type: "number" },
    },
    required: ["name"],
  };

  const configSchema = {
    type: "object",
    properties: {
      version: { type: "string" },
      enabled: { type: "boolean" },
    },
    required: ["version"],
  };

  config.define("user", userSchema);
  config.define("system.config", configSchema);

  // Both schemas should work independently
  config.set("user.name", "Alice");
  config.set("system.config.version", "1.0.0");
  config.set("system.config.enabled", true);

  assertEquals(config.get("user.name", null), "Alice");
  assertEquals(config.get("system.config.version", null), "1.0.0");
  assertEquals(config.get("system.config.enabled", false), true);

  // Validation should work for both
  assertThrows(
    () => {
      config.set("user.name", 123);
    },
    Error,
    "Validation error for user",
  );

  assertThrows(
    () => {
      config.set("system.config.enabled", "not-boolean");
    },
    Error,
    "Validation error for system.config",
  );
});

Deno.test("Config - invalid schema definition", () => {
  const config = new Config();

  // Invalid schema should throw when defined
  assertThrows(
    () => {
      config.define("test", {
        type: "invalid-type", // Invalid type
      });
    },
    Error,
    "Invalid schema for key test",
  );
});


================================================
FILE: client/config.ts
================================================
import { Ajv } from "ajv";

/**
 * Configuration management (config.* APIs) for the client
 */
export class Config {
  public schemas: Record<string, any> = {
    type: "object",
    properties: {},
  };
  private ajv = new Ajv();

  constructor(public values: Record<string, any> = {}) {
    // Add the same formats as in jsonschema.ts
    this.ajv.addFormat("email", {
      validate: (data: string) => {
        return data.includes("@");
      },
      async: false,
    });

    this.ajv.addFormat("page-ref", {
      validate: (data: string) => {
        return data.startsWith("[[") && data.endsWith("]]");
      },
      async: false,
    });
  }

  public clear() {
    this.schemas = {
      type: "object",
      properties: {},
    };
    this.values = {};
  }

  /**
   * Defines a JSON schema for a configuration key
   * @param key The configuration key to define a schema for
   * @param schema The JSON schema to validate against
   */
  define(key: string | string[], schema: any): void {
    // Validate the schema itself first
    const valid = this.ajv.validateSchema(schema);
    if (!valid) {
      const errorText = this.ajv.errorsText(this.ajv.errors);
      throw new Error(`Invalid schema for key ${key}: ${errorText}`);
    }

    if (typeof key === "string") {
      key = key.split(".");
    }

    // Navigate/create the path in the schema structure
    let current = this.schemas;
    for (let i = 0; i < key.length - 1; i++) {
      const part = key[i];
      if (!current.properties[part]) {
        current.properties[part] = {
          type: "object",
          properties: {},
        };
      }
      current = current.properties[part];
    }

    // Store the schema at the final key
    const finalKey = key[key.length - 1];
    current.properties[finalKey] = schema;
  }

  /**
   * Gets a value from the config
   * @param path The path to get, supports dot notation (e.g. "foo.bar.baz")
   * @param defaultValue The default value to return if the path doesn't exist
   * @returns The value at the path, or the default value
   */
  get<T>(path: string | string[], defaultValue: T): T {
    if (typeof path === "string") {
      path = path.split(".");
    }
    const resolved = resolvePath(this.values, path);
    if (!resolved) {
      return defaultValue;
    }

    return (resolved.obj[resolved.key] ?? defaultValue) as T;
  }

  /**
   * Sets a value in the config
   * @param path The path to set, supports dot notation (e.g. "foo.bar.baz")
   * @param value The value to set
   */
  set<T>(path: string, value: T): void;
  /**
   * Sets a value in the config
   * @param path The path to set (one path element per array element)
   * @param value The value to set
   */
  set<T>(path: string[], value: T): void;

  /**
   * Sets multiple values in the config
   * @param values An object containing key-value pairs to set
   */
  set(values: Record<string, any>): void;

  set<T>(
    keyOrValues: string | string[] | Record<string, any>,
    value?: T,
  ): void {
    if (typeof keyOrValues === "string") {
      keyOrValues = keyOrValues.split(".");
    }
    if (Array.isArray(keyOrValues)) {
      const key = keyOrValues as string[];

      const resolved = resolvePath(this.values, key, true);
      if (resolved) {
        resolved.obj[resolved.key] = value;
      } else {
        throw new Error(`Invalid key ${key}`);
      }

      // Find and validate only the relevant schema
      this.validatePath(key);
    } else {
      // Handle object form
      for (const [key, val] of Object.entries(keyOrValues)) {
        this.set(key, val);
      }
    }
  }

  insert<T>(
    key: string | string[],
    value?: T,
  ): void {
    if (typeof key === "string") {
      key = key.split(".");
    }
    const resolved = resolvePath(this.values, key, true);
    if (resolved) {
      if (!Array.isArray(resolved.obj[resolved.key])) {
        resolved.obj[resolved.key] = [];
      }
      resolved.obj[resolved.key].push(value);
    } else {
      throw new Error(`Invalid key ${key}`);
    }

    // Find and validate only the relevant schema after the fact...
    this.validatePath(key);
  }

  /**
   * Checks if a path exists in the config
   * @param path The path to check, supports dot notation (e.g. "foo.bar.baz")
   * @returns True if the path exists, false otherwise
   */
  has(path: string | string[]): boolean {
    if (typeof path === "string") {
      path = path.split(".");
    }
    const resolved = resolvePath(this.values, path);
    if (!resolved) {
      return false;
    }

    return resolved.key in resolved.obj;
  }

  /**
   * Lists all configuration keys in the config
   * @returns An array of keys
   */
  keys(): string[] {
    return Object.keys(this.values);
  }

  /**
   * Validates a specific path against its schema
   */
  private validatePath(path: string[]): void {
    // Find the deepest schema that applies to this path
    for (let i = path.length; i > 0; i--) {
      const schemaPath = path.slice(0, i);
      const schema = this.getSchemaAtPath(schemaPath);
      if (schema) {
        const valueAtPath = this.get(schemaPath, undefined);
        if (valueAtPath !== undefined) {
          const validate = this.ajv.compile(schema);
          if (!validate(valueAtPath)) {
            let errorText = this.ajv.errorsText(validate.errors);
            errorText = errorText.replaceAll("/", ".");
            errorText = errorText.replace(/^data\.?/, "");
            throw new Error(
              `Validation error for ${schemaPath.join(".")}:> ${errorText}`,
            );
          }
        }
        break; // Only validate the most specific schema
      }
    }
  }

  /**
   * Gets the schema at a specific path
   */
  private getSchemaAtPath(path: string[]): any | null {
    let current = this.schemas;
    for (const part of path) {
      if (!current.properties || !current.properties[part]) {
        return null;
      }
      current = current.properties[part];
    }
    return current.type ? current : null;
  }
}

/**
 * Resolves a configuration path to the containing object and final key
 * @param path The path to resolve (e.g. ["foo", "bar", "baz"])
 * @param create Whether to create objects along the path if they don't exist
 * @returns The containing object and the final key, or null if the path cannot be resolved
 */
function resolvePath(
  inObject: Record<string, any>,
  path: string[],
  create = false,
): { obj: any; key: string } | null {
  // To avoid side effects, let's clone the path bits
  path = [...path];

  const lastKey = path.pop()!;
  let current = inObject;

  for (const part of path) {
    if (current[part] === undefined) {
      if (create) {
        current[part] = {};
      } else {
        return null;
      }
    } else if (typeof current[part] !== "object" || current[part] === null) {
      if (create) {
        // Convert primitive to object if we're creating the path
        current[part] = {};
      } else {
        return null;
      }
    }

    current = current[part];
  }

  return { obj: current, key: lastKey };
}


================================================
FILE: client/data/data_augmenter.test.ts
================================================
import { assertEquals } from "@std/assert";
import { Augmenter } from "./data_augmenter.ts";
import { DataStore } from "./datastore.ts";
import { MemoryKvPrimitives } from "./memory_kv_primitives.ts";

Deno.test("Test data augmentation", async () => {
  const ds = new DataStore(new MemoryKvPrimitives());
  const john: any = {
    ref: "john",
    name: "John",
    age: 1234,
  };
  const mary: any = {
    ref: "mary",
    name: "Mary",
    age: 5678,
  };
  ds.batchSet([{
    key: ["john"],
    value: john,
  }, {
    key: ["mary"],
    value: mary,
  }]);
  const augm = new Augmenter(ds, ["aug"]);
  // Augment only john
  await augm.setAugmentation("john", { augmented: true });
  // Fetch them back
  const objs = [john, mary];
  await augm.augmentObjectArray(objs, "ref");
  assertEquals(objs[0].augmented, true);
  assertEquals(objs[1].augmented, undefined);
});


================================================
FILE: client/data/data_augmenter.ts
================================================
/**
 * Implement out of band data augmentation operations
 */

import type { KvKey } from "@silverbulletmd/silverbullet/type/datastore";
import type { DataStore } from "./datastore.ts";

/**
 * Implements out of band augmentation of entries in a DataStore
 * Example use cases: store "lastOpened" for pages and "lastRun" for commands
 */
export class Augmenter {
  constructor(
    private ds: DataStore,
    private augmentationNamespace: KvKey,
  ) {
  }

  /**
   * Augments objects additional attributes pulled from a data source
   */
  async augmentObjectArray(
    objects: any[],
    keyField: string,
  ): Promise<void> {
    const objectMap = new Map<string, any>();
    // create a lookup map based on objects
    for (const obj of objects) {
      objectMap.set(obj[keyField], obj);
    }
    await this.augmentObjectMap(objectMap);
  }

  async augmentObjectMap(objectMap: Map<string, any>) {
    // Now augment
    for await (
      const augmentation of this.ds.query({
        prefix: this.augmentationNamespace,
      })
    ) {
      const obj = objectMap.get(
        augmentation.key[this.augmentationNamespace.length],
      );
      if (obj) {
        // Copy over properties from augmentation
        Object.assign(obj, augmentation.value);
      }
    }
  }

  setAugmentation(
    key: string,
    augmentation: Record<string, any>,
  ): Promise<void> {
    return this.ds.set([...this.augmentationNamespace, key], augmentation);
  }
}


================================================
FILE: client/data/datastore.test.ts
================================================
import "fake-indexeddb/auto";
import { IndexedDBKvPrimitives } from "./indexeddb_kv_primitives.ts";
import { MemoryKvPrimitives } from "./memory_kv_primitives.ts";
import type { KvPrimitives } from "./kv_primitives.ts";
import { assertEquals } from "@std/assert";
import { DataStore } from "./datastore.ts";
import { LuaEnv, LuaStackFrame } from "../space_lua/runtime.ts";
import { parseExpressionString } from "../space_lua/parse.ts";

async function test(db: KvPrimitives) {
  const datastore = new DataStore(db);
  await datastore.set(["user", "peter"], { name: "Peter" });
  await datastore.set(["user", "hank"], { name: "Hank" });
  const env = new LuaEnv();
  const sf = LuaStackFrame.lostFrame;

  // Basic test, fancier tests are done in common/space_lua/query_collection.test.ts
  const results = await datastore.luaQuery(
    ["user"],
    {
      objectVariable: "user",
      where: parseExpressionString("user.name == 'Peter'"),
    },
    env,
    sf,
  );
  assertEquals(results, [{ name: "Peter" }]);
}

Deno.test("Test Memory KV DataStore", async () => {
  const db = new MemoryKvPrimitives(); // In-memory only, no persistence
  await test(db);
  await db.close();
});

Deno.test("Test IndexDB DataStore", {
  sanitizeResources: false,
  sanitizeOps: false,
}, async () => {
  const db = new IndexedDBKvPrimitives("test");
  await db.init();
  await test(db);
  db.close();
});


================================================
FILE: client/data/datastore.ts
================================================
import {
  type LuaCollectionQuery,
  queryLua,
} from "../space_lua/query_collection.ts";
import { LuaEnv, LuaStackFrame } from "../space_lua/runtime.ts";
import type { KvPrimitives, KvQueryOptions } from "./kv_primitives.ts";

import type { KV, KvKey } from "../../plug-api/types/datastore.ts";

/**
 * This is the data store class you'll actually want to use, wrapping the primitives
 * in a more user-friendly way
 */
export class DataStore {
  constructor(
    readonly kv: KvPrimitives,
  ) {
  }

  async get<T = any>(key: KvKey): Promise<T | null> {
    return (await this.batchGet([key]))[0];
  }

  batchGet<T = any>(keys: KvKey[]): Promise<(T | null)[]> {
    if (keys.length === 0) {
      return Promise.resolve([]);
    }
    return this.kv.batchGet(keys);
  }

  set(key: KvKey, value: any): Promise<void> {
    return this.batchSet([{ key, value }]);
  }

  batchSet<T = any>(entries: KV<T>[]): Promise<void> {
    if (entries.length === 0) {
      return Promise.resolve();
    }
    const allKeyStrings = new Set<string>();
    const uniqueEntries: KV[] = [];
    for (const { key, value } of entries) {
      const keyString = JSON.stringify(key);
      if (allKeyStrings.has(keyString)) {
        console.warn(`Duplicate key ${keyString} in batchSet, skipping`);
      } else {
        allKeyStrings.add(keyString);
        uniqueEntries.push({ key, value });
      }
    }
    return this.kv.batchSet(uniqueEntries);
  }

  delete(key: KvKey): Promise<void> {
    return this.batchDelete([key]);
  }

  batchDelete(keys: KvKey[]): Promise<void> {
    if (keys.length === 0) {
      return Promise.resolve();
    }
    return this.kv.batchDelete(keys);
  }

  async batchDeletePrefix(prefix: KvKey): Promise<void> {
    const keys: KvKey[] = [];
    for await (const { key } of this.kv.query({ prefix })) {
      keys.push(key);
    }
    return this.batchDelete(keys);
  }

  query<T = any>(options: KvQueryOptions): AsyncIterableIterator<KV<T>> {
    return this.kv.query<T>(options);
  }

  luaQuery<T = any>(
    prefix: KvKey,
    query: LuaCollectionQuery,
    env: LuaEnv = new LuaEnv(),
    sf: LuaStackFrame = LuaStackFrame.lostFrame,
    enricher?: (key: KvKey, item: any) => any,
  ): Promise<T[]> {
    return queryLua(this.kv, prefix, query, env, sf, enricher);
  }
}


================================================
FILE: client/data/encrypted_kv_primitives.test.ts
================================================
import { assert, assertEquals } from "@std/assert";
import { EncryptedKvPrimitives } from "./encrypted_kv_primitives.ts";
import { MemoryKvPrimitives } from "./memory_kv_primitives.ts";
import { deriveCTRKeyFromPassword } from "@silverbulletmd/silverbullet/lib/crypto";

Deno.test("Test Encrypted KV Primitives", async () => {
  const memoryKv = new MemoryKvPrimitives();
  const salt = new Uint8Array(32);
  const key = await deriveCTRKeyFromPassword("test", salt);
  const kv = new EncryptedKvPrimitives(memoryKv, key);
  await kv.init();

  // Store a basic key
  await kv.batchSet([{ key: ["key"], value: 10 }]);
  const [value] = await kv.batchGet([["key"]]);
  assertEquals(value, 10);

  // Store a binary blob
  const blob = new Uint8Array([1, 2, 3, 4, 5]);
  await kv.batchSet([{ key: ["blob"], value: blob }]);
  const [blobValue] = await kv.batchGet([["blob"]]);
  assertEquals(blobValue, blob);

  // Store a nested JSON and blob structure
  const nested = {
    json: { a: 1, b: 2 },
    blob: new Uint8Array([6, 7, 8, 9, 10]),
  };
  await kv.batchSet([{ key: ["nested"], value: nested }]);
  const [nestedValue] = await kv.batchGet([["nested"]]);
  assertEquals(nestedValue, nested);

  // Put a few objects with a person prefix
  await kv.batchSet([
    { key: ["person", "alice"], value: { name: "Alice", age: 30 } },
    { key: ["person", "bob"], value: { name: "Bob", age: 25 } },
  ]);

  // Then query based on the prefix
  let counter = 0;
  for await (const { key, value } of kv.query({ prefix: ["person"] })) {
    assertEquals(key[0], "person");
    assert(key[1] === "alice" || key[1] === "bob");
    assert(value.age);
    counter++;
  }
  assertEquals(counter, 2);

  // Delete something
  await kv.batchDelete([["person", "alice"]]);
  // Check it's gone
  const [deletedValue] = await kv.batchGet([["person", "alice"]]);
  assertEquals(deletedValue, undefined);

  console.log(memoryKv);

  // Clear
  await kv.clear();
  counter = 0;
  for await (const _ of kv.query({ prefix: ["person"] })) {
    counter++;
  }
  assertEquals(counter, 0);
});


================================================
FILE: client/data/encrypted_kv_primitives.ts
================================================
import type { KvPrimitives, KvQueryOptions } from "./kv_primitives.ts";

import type { KV, KvKey } from "../../plug-api/types/datastore.ts";
import {
  decryptAesGcm,
  decryptStringDeterministic,
  deriveGCMKeyFromCTR,
  encryptAesGcm,
  encryptStringDeterministic,
} from "@silverbulletmd/silverbullet/lib/crypto";

import { decode, encode } from "@msgpack/msgpack";

export class EncryptedKvPrimitives implements KvPrimitives {
  private keyKey: CryptoKey;
  private dataKey!: CryptoKey;

  constructor(
    private wrapped: KvPrimitives,
    encryptionKey: CryptoKey,
  ) {
    this.keyKey = encryptionKey;
  }

  get dbName(): string {
    return (this.wrapped as any).dbName;
  }

  // MUST immediately be called after constructor
  async init() {
    this.dataKey = await deriveGCMKeyFromCTR(this.keyKey);
  }

  clear(): Promise<void> {
    return this.wrapped.clear();
  }

  private encryptKey(key: KvKey): Promise<KvKey> {
    return Promise.all(
      key.map((part) => encryptStringDeterministic(this.keyKey, part)),
    );
  }

  private decryptKey(key: KvKey): Promise<KvKey> {
    return Promise.all(
      key.map((part) => decryptStringDeterministic(this.keyKey, part)),
    );
  }

  private encryptValue(value: any): Promise<any> {
    if (value === undefined) {
      return Promise.resolve(undefined);
    }
    return encryptAesGcm(this.dataKey, encode(value));
  }

  private async decryptValue(value: any): Promise<any> {
    if (value === undefined) {
      return undefined;
    }
    return decode(await decryptAesGcm(this.dataKey, value));
  }

  async batchGet(keys: KvKey[]): Promise<any[]> {
    const encryptedKeys: KvKey[] = await Promise.all(keys.map((key) => {
      return this.encryptKey(key);
    }));
    const encryptedValues = await this.wrapped.batchGet(encryptedKeys);
    return Promise.all(
      encryptedValues.map((value) => this.decryptValue(value)),
    );
  }

  async batchSet(entries: KV[]): Promise<void> {
    const encryptedEntries: KV[] = await Promise.all(
      entries.map(async ({ key, value }) => {
        const encryptedKey = await this.encryptKey(key);
        const encryptedValue = await this.encryptValue(value);
        return { key: encryptedKey, value: encryptedValue };
      }),
    );
    await this.wrapped.batchSet(encryptedEntries);
  }

  async batchDelete(keys: KvKey[]): Promise<void> {
    const encryptedKeys = await Promise.all(
      keys.map((key) => this.encryptKey(key)),
    );
    await this.wrapped.batchDelete(encryptedKeys);
  }

  async *query({ prefix }: KvQueryOptions): AsyncIterableIterator<KV> {
    const encryptedResults: KV[] = [];
    // Collect all results first
    for await (
      const entry of this.wrapped.query({
        prefix: prefix ? await this.encryptKey(prefix) : undefined,
      })
    ) {
      encryptedResults.push(entry);
    }

    // Then decrypt them (to avoid transaction problems with wrapped indexed DBs)
    for (const entry of encryptedResults) {
      yield {
        key: await this.decryptKey(entry.key),
        value: await this.decryptValue(entry.value),
      };
    }
  }

  async countQuery({ prefix }: KvQueryOptions): Promise<number> {
    const encryptedPrefix = prefix ? await this.encryptKey(prefix) : undefined;
    return this.wrapped.countQuery({ prefix: encryptedPrefix });
  }

  close() {
    this.wrapped.close();
  }
}


================================================
FILE: client/data/indexeddb_kv_primitives.test.ts
================================================
import "fake-indexeddb/auto";
import { IndexedDBKvPrimitives } from "./indexeddb_kv_primitives.ts";
import { allTests } from "./kv_primitives.test.ts";

Deno.test("Test IDB key primitives", {
  sanitizeResources: false,
  sanitizeOps: false,
}, async () => {
  const db = new IndexedDBKvPrimitives("test");
  await db.init();
  await allTests(db);
  db.close();
});


================================================
FILE: client/data/indexeddb_kv_primitives.ts
================================================
import type { KvPrimitives, KvQueryOptions } from "./kv_primitives.ts";
import { type IDBPDatabase, openDB } from "idb";

import type { KV, KvKey } from "../../plug-api/types/datastore.ts";

// Separator character to use for key serialization
const sep = "\0";
const objectStoreName = "data";

export class IndexedDBKvPrimitives implements KvPrimitives {
  db!: IDBPDatabase<any>;

  constructor(
    private dbName: string,
  ) {
  }

  async init() {
    this.db = await openDB(this.dbName, 1, {
      upgrade: (db) => {
        db.createObjectStore(objectStoreName);
      },
    });
  }

  async clear(): Promise<void> {
    const objectStoreNames = this.db.objectStoreNames;

    // Create a transaction that includes all object stores
    const tx = this.db.transaction(objectStoreNames, "readwrite");

    // Clear each object store in parallel
    const clearPromises = Array.from(objectStoreNames).map((storeName) =>
      tx.objectStore(storeName).clear()
    );

    // Wait for all clears to complete
    await Promise.all(clearPromises);

    // Complete the transaction
    await tx.done;
  }

  batchGet(keys: KvKey[]): Promise<any[]> {
    const tx = this.db.transaction(objectStoreName, "readonly");
    return Promise.all(keys.map((key) => tx.store.get(this.buildKey(key))));
  }

  async batchSet(entries: KV[]): Promise<void> {
    const tx = this.db.transaction(objectStoreName, "readwrite");
    await Promise.all([
      ...entries.map(({ key, value }) =>
        tx.store.put(value, this.buildKey(key))
      ),
      tx.done,
    ]);
  }

  async batchDelete(keys: KvKey[]): Promise<void> {
    const tx = this.db.transaction(objectStoreName, "readwrite");
    await Promise.all([
      ...keys.map((key) => tx.store.delete(this.buildKey(key))),
      tx.done,
    ]);
  }

  // Important IndexedDB limitation: no asynchronous processing can happen
  // in the body of the for await: https://stackoverflow.com/a/51898463
  async *query({ prefix }: KvQueryOptions): AsyncIterableIterator<KV> {
    const tx = this.db.transaction(objectStoreName, "readonly");
    prefix = prefix || [];
    for await (
      const entry of tx.store.iterate(IDBKeyRange.bound(
        this.buildKey([...prefix, ""]),
        this.buildKey([...prefix, "\uffff"]),
      ))
    ) {
      yield { key: this.extractKey(entry.key), value: entry.value };
    }
  }

  countQuery({ prefix }: KvQueryOptions): Promise<number> {
    const tx = this.db.transaction(objectStoreName, "readonly");
    prefix = prefix || [];
    return tx.store.count(IDBKeyRange.bound(
      this.buildKey([...prefix, ""]),
      this.buildKey([...prefix, "\uffff"]),
    ));
  }

  close() {
    this.db.close();
  }

  private buildKey(key: KvKey): string {
    for (const k of key) {
      if (typeof k !== "string") {
        throw new Error(`Key needs to consists of strings: ${key}`);
      }
      if (k.includes(sep)) {
        throw new Error(`Key cannot contain ${sep}`);
      }
    }
    return key.join(sep);
  }

  private extractKey(key: string): KvKey {
    return key.split(sep);
  }
}


================================================
FILE: client/data/kv_primitives.test.ts
================================================
import type { KvPrimitives } from "./kv_primitives.ts";
import { assertEquals } from "@std/assert";

import type { KV } from "../../plug-api/types/datastore.ts";

export async function allTests(db: KvPrimitives) {
  await db.batchSet([
    { key: ["kv", "test2"], value: "Hello2" },
    { key: ["kv", "test1"], value: "Hello1" },
    { key: ["other", "random"], value: "Hello3" },
  ]);
  const result = await db.batchGet([["kv", "test1"], ["kv", "test2"], [
    "kv",
    "test3",
  ]]);
  assertEquals(result.length, 3);
  assertEquals(result[0], "Hello1");
  assertEquals(result[1], "Hello2");
  assertEquals(result[2], undefined);
  let counter = 0;
  // Query all
  for await (const _entry of db.query({})) {
    counter++;
  }
  assertEquals(counter, 3);

  counter = 0;
  // Query prefix
  for await (const _entry of db.query({ prefix: ["kv"] })) {
    counter++;
    console.log(_entry);
  }
  assertEquals(counter, 2);

  // Delete a few keys
  await db.batchDelete([["kv", "test1"], ["other", "random"]]);
  const result2 = await db.batchGet([["kv", "test1"], ["kv", "test2"], [
    "other",
    "random",
  ]]);
  assertEquals(result2.length, 3);
  assertEquals(result2[0], undefined);
  assertEquals(result2[1], "Hello2");
  assertEquals(result2[2], undefined);

  // Update a key
  await db.batchSet([{ key: ["kv", "test2"], value: "Hello2.1" }]);
  const [val] = await db.batchGet([["kv", "test2"]]);
  assertEquals(val, "Hello2.1");

  // Set a large batch
  const largeBatch: KV[] = [];
  for (let i = 0; i < 50; i++) {
    largeBatch.push({ key: ["test", "test" + i], value: "Hello" });
  }
  await db.batchSet(largeBatch);
  const largeBatchResult: KV[] = [];
  for await (const entry of db.query({ prefix: ["test"] })) {
    largeBatchResult.push(entry);
  }
  assertEquals(largeBatchResult.length, 50);

  // Delete the large batch
  await db.batchDelete(largeBatch.map((e) => e.key));

  // Make sure they're gone
  for await (const _entry of db.query({ prefix: ["test"] })) {
    throw new Error("This should not happen");
  }
}


================================================
FILE: client/data/kv_primitives.ts
================================================
import type { KV, KvKey } from "../../plug-api/types/datastore.ts";

export type KvQueryOptions = {
  prefix?: KvKey;
};

export interface KvPrimitives {
  batchGet(keys: KvKey[]): Promise<(any | undefined)[]>;

  batchSet(entries: KV[]): Promise<void>;

  batchDelete(keys: KvKey[]): Promise<void>;

  query<T = any>(options: KvQueryOptions): AsyncIterableIterator<KV<T>>;

  /**
   * A more efficient way to do counts
   */
  countQuery(options: KvQueryOptions): Promise<number>;

  // Completely clear all data from this datastore
  clear(): Promise<void>;

  close(): void;
}


================================================
FILE: client/data/memory_kv_primitives.test.ts
================================================
import { assertEquals } from "@std/assert";
import { MemoryKvPrimitives } from "./memory_kv_primitives.ts";
import { allTests } from "./kv_primitives.test.ts";

import type { KV } from "../../plug-api/types/datastore.ts";

Deno.test("MemoryKvPrimitives loads from non-existent file without error", async () => {
  const tempPath = await Deno.makeTempFile() + "_nonexistent";
  // Disable throttling for tests
  const store = new MemoryKvPrimitives(tempPath, { throttleMs: 0 });
  await store.init();

  // Should create an empty store
  const result = await store.batchGet([["test"]]);
  assertEquals(result, [undefined]);
});

Deno.test("MemoryKvPrimitives passes all KvPrimitives tests", async () => {
  const tempPath = await Deno.makeTempFile();

  try {
    const store = new MemoryKvPrimitives(tempPath, { throttleMs: 0 });
    await store.init();
    await allTests(store);
    await store.close();
  } finally {
    // Clean up
    try {
      await Deno.remove(tempPath);
    } catch (_) {
      // Ignore errors during cleanup
    }
  }
});

Deno.test("MemoryKvPrimitives persists and loads data", async () => {
  const tempPath = await Deno.makeTempFile();

  try {
    // Create and populate first instance
    const store1 = new MemoryKvPrimitives(tempPath, { throttleMs: 0 });
    await store1.init();
    await store1.batchSet([
      { key: ["test", "key1"], value: "value1" },
      { key: ["test", "key2"], value: "value2" },
    ]);

    // Force persistence
    await store1.close();

    // Create second instance that loads from the same file
    const store2 = new MemoryKvPrimitives(tempPath, { throttleMs: 0 });
    await store2.init();

    // Check if data was loaded correctly
    const results = await store2.batchGet([["test", "key1"], ["test", "key2"]]);
    assertEquals(results, ["value1", "value2"]);
  } finally {
    // Clean up
    try {
      await Deno.remove(tempPath);
    } catch (_) {
      // Ignore errors during cleanup
    }
  }
});

Deno.test("MemoryKvPrimitives mutations trigger persistence", async () => {
  const tempPath = await Deno.makeTempFile();

  try {
    // Create and populate first instance
    const store1 = new MemoryKvPrimitives(tempPath, { throttleMs: 0 });
    await store1.init();
    await store1.batchSet([{ key: ["test", "key"], value: "value" }]);

    // No need to wait for throttled persistence since we disabled it

    // Create second instance without closing the first one
    const store2 = new MemoryKvPrimitives(tempPath, { throttleMs: 0 });
    await store2.init();

    // Check if data was persisted
    const results = await store2.batchGet([["test", "key"]]);
    assertEquals(results, ["value"]);
  } finally {
    // Clean up
    try {
      await Deno.remove(tempPath);
    } catch (_) {
      // Ignore errors during cleanup
    }
  }
});

Deno.test("MemoryKvPrimitives persists delete operations", async () => {
  const tempPath = await Deno.makeTempFile();

  try {
    // Create and populate store
    const store1 = new MemoryKvPrimitives(tempPath, { throttleMs: 0 });
    await store1.init();
    await store1.batchSet([
      { key: ["test", "key1"], value: "value1" },
      { key: ["test", "key2"], value: "value2" },
    ]);

    // Delete one key
    await store1.batchDelete([["test", "key1"]]);

    // No need to wait for throttled persistence since we disabled it

    // Create second instance
    const store2 = new MemoryKvPrimitives(tempPath, { throttleMs: 0 });
    await store2.init();

    // Check if delete was persisted
    const results = await store2.batchGet([["test", "key1"], ["test", "key2"]]);
    assertEquals(results, [undefined, "value2"]);
  } finally {
    // Clean up
    try {
      await Deno.remove(tempPath);
    } catch (_) {
      // Ignore errors during cleanup
    }
  }
});

Deno.test("MemoryKvPrimitives.fromFile creates and initializes store", async () => {
  const tempPath = await Deno.makeTempFile();

  try {
    // Create JSON file with initial data
    const initialData = {
      "test\0key": "value",
    };
    await Deno.writeTextFile(tempPath, JSON.stringify(initialData));

    // Use factory method with throttling disabled
    const store = await MemoryKvPrimitives.fromFile(tempPath, {
      throttleMs: 0,
    });

    // Check if data was loaded
    const result = await store.batchGet([["test", "key"]]);
    assertEquals(result, ["value"]);
  } finally {
    // Clean up
    try {
      await Deno.remove(tempPath);
    } catch (_) {
      // Ignore errors during cleanup
    }
  }
});

Deno.test("MemoryKvPrimitives query works with persisted data", async () => {
  const tempPath = await Deno.makeTempFile();

  try {
    // Create and populate store
    const store1 = new MemoryKvPrimitives(tempPath, { throttleMs: 0 });
    await store1.init();
    await store1.batchSet([
      { key: ["test", "key1"], value: "value1" },
      { key: ["test", "key2"], value: "value2" },
      { key: ["other", "key"], value: "value3" },
    ]);

    // Force persistence
    await store1.close();

    // Create second instance
    const store2 = await MemoryKvPrimitives.fromFile(tempPath, {
      throttleMs: 0,
    });

    // Test query with prefix
    const results: KV[] = [];
    for await (const item of store2.query({ prefix: ["test"] })) {
      results.push(item);
    }

    assertEquals(results.length, 2);
    assertEquals(results[0].key, ["test", "key1"]);
    assertEquals(results[0].value, "value1");
    assertEquals(results[1].key, ["test", "key2"]);
    assertEquals(results[1].value, "value2");
  } finally {
    // Clean up
    try {
      await Deno.remove(tempPath);
    } catch (_) {
      // Ignore errors during cleanup
    }
  }
});


================================================
FILE: client/data/memory_kv_primitives.ts
================================================
import type { KvPrimitives, KvQueryOptions } from "./kv_primitives.ts";
import { throttle } from "@silverbulletmd/silverbullet/lib/async";

import type { KV, KvKey } from "@silverbulletmd/silverbullet/type/datastore";

const memoryKeySeparator = "\0";

export class MemoryKvPrimitives implements KvPrimitives {
  protected store = new Map<string, any>();
  private throttledPersist?: () => void;

  constructor(
    protected filePath?: string,
    options: { throttleMs?: number } = {},
  ) {
    // Set up throttled persistence if throttleMs is provided or default to 1000ms
    if (this.filePath) {
      const throttleMs = options.throttleMs !== undefined
        ? options.throttleMs
        : 1000;

      // If throttleMs is 0, persistence will happen immediately without throttling
      if (throttleMs > 0) {
        this.throttledPersist = throttle(() => {
          this.persistToDisk().catch((err) =>
            console.error(`Error persisting to disk: ${err}`)
          );
        }, throttleMs);
      }
    }
  }

  static fromJSON(json: Record<string, any>): MemoryKvPrimitives {
    const result = new MemoryKvPrimitives();
    for (const key of Object.keys(json)) {
      result.store.set(key, json[key]);
    }
    return result;
  }

  /**
   * Create a new MemoryKvPrimitives instance from a file and initialize it
   */
  static async fromFile(
    filePath: string,
    options: { throttleMs?: number } = {},
  ): Promise<MemoryKvPrimitives> {
    const instance = new MemoryKvPrimitives(filePath, options);
    await instance.init();
    return instance;
  }

  clear(): Promise<void> {
    this.store.clear();
    return Promise.resolve();
  }

  /**
   * Initialize the store by loading data from disk if a file path was provided
   */
  async init(): Promise<void> {
    if (!this.filePath) return;

    try {
      const text = await Deno.readTextFile(this.filePath);
      // Handle empty files gracefully to prevent "SyntaxError: Unexpected end of JSON input"
      if (text.trim() === "") {
        return;
      }

      const jsonData = JSON.parse(text);
      for (const key of Object.keys(jsonData)) {
        this.store.set(key, jsonData[key]);
      }
    } catch (error) {
      // Handle specific errors more gracefully
      if (error instanceof Deno.errors.NotFound) {
        // File doesn't exist yet, nothing to load
        return;
      }

      // Other errors (like invalid JSON) should be logged
      console.warn(`Failed to load KV store from ${this.filePath}:`, error);
    }
  }

  batchGet(keys: KvKey[]): Promise<any[]> {
    return Promise.resolve(
      keys.map((key) => this.store.get(key.join(memoryKeySeparator))),
    );
  }

  async batchSet(entries: KV[]): Promise<void> {
    for (const { key, value } of entries) {
      this.store.set(key.join(memoryKeySeparator), value);
    }

    // Trigger persistence
    if (this.throttledPersist) {
      this.throttledPersist();
    } else if (this.filePath) {
      // If no throttling is set up but we have a filePath, persist immediately
      await this.persistToDisk();
    }

    return Promise.resolve();
  }

  async batchDelete(keys: KvKey[]): Promise<void> {
    for (const key of keys) {
      this.store.delete(key.join(memoryKeySeparator));
    }

    // Trigger persistence
    if (this.throttledPersist) {
      this.throttledPersist();
    } else if (this.filePath) {
      // If no throttling is set up but we have a filePath, persist immediately
      await this.persistToDisk();
    }

    return Promise.resolve();
  }

  toJSON(): Record<string, any> {
    const result: Record<string, any> = {};
    for (const [key, value] of this.store) {
      result[key] = value;
    }
    return result;
  }

  async *query(options: KvQueryOptions): AsyncIterableIterator<KV> {
    const prefix = options.prefix?.join(memoryKeySeparator);
    const sortedKeys = [...this.store.keys()].sort();
    for (const key of sortedKeys) {
      if (prefix && !key.startsWith(prefix)) {
        continue;
      }
      yield {
        key: key.split(memoryKeySeparator),
        value: this.store.get(key),
      };
    }
  }

  countQuery({ prefix }: KvQueryOptions): Promise<number> {
    const prefixStr = prefix ? prefix.join(memoryKeySeparator) : undefined;
    const keys = [...this.store.keys()];
    return Promise.resolve(
      keys.filter((key) => !prefixStr || key.startsWith(prefixStr)).length,
    );
  }

  async close(): Promise<void> {
    // Force immediate persistence when closing
    if (this.filePath) {
      await this.persistToDisk();
    }
  }

  /**
   * Persist the current state to disk
   */
  private async persistToDisk(): Promise<void> {
    if (!this.filePath) return;

    try {
      const jsonData = this.toJSON();
      await Deno.writeTextFile(
        this.filePath,
        JSON.stringify(jsonData, null, 2),
      );
    } catch (error) {
      console.error(`Failed to persist KV store to ${this.filePath}:`, error);
    }
  }
}


================================================
FILE: client/data/mq.datastore.test.ts
================================================
import { DataStoreMQ } from "./mq.datastore.ts";
import { assertEquals } from "@std/assert";
import { MemoryKvPrimitives } from "./memory_kv_primitives.ts";
import { DataStore } from "./datastore.ts";
import { FakeTime } from "@std/testing/time";

import type { MQMessage } from "../../plug-api/types/datastore.ts";
import { EventHook } from "../plugos/hooks/event.ts";
import { System } from "../plugos/system.ts";
import type { EventHookT } from "@silverbulletmd/silverbullet/type/manifest";

Deno.test("DataStore MQ", async () => {
  const time = new FakeTime();
  const db = new MemoryKvPrimitives(); // In-memory only, no persistence
  const eventHook = new EventHook();
  const system = new System<EventHookT>();
  system.addHook(eventHook);

  try {
    const mq = new DataStoreMQ(
      new DataStore(db),
      eventHook,
    );

    let messages: MQMessage[];

    // Send and ack
    await mq.send("test", "Hello World");
    messages = await mq.poll("test", 10);
    assertEquals(messages.length, 1);
    await mq.ack("test", messages[0].id);
    assertEquals([], await mq.poll("test", 10));

    // Timeout
    await mq.send("test", "Hello World");
    messages = await mq.poll("test", 10);
    assertEquals(messages.length, 1);
    assertEquals([], await mq.poll("test", 10));
    await time.tickAsync(20);
    await mq.requeueTimeouts(10);
    messages = await mq.poll("test", 10);
    const stats = await mq.getQueueStats();
    assertEquals(stats.processing, 1);
    assertEquals(messages.length, 1);
    assertEquals(messages[0].retries, 1);

    // Max retries
    await time.tickAsync(20);
    await mq.requeueTimeouts(10, 1);
    assertEquals((await mq.fetchDLQMessages()).length, 1);

    // Batch send and ack
    await mq.batchSend("test", ["Hello", "World"]);
    const messageBatch1 = await mq.poll("test", 1);
    assertEquals(messageBatch1.length, 1);
    assertEquals(messageBatch1[0].body, "Hello");
    const messageBatch2 = await mq.poll("test", 1);
    assertEquals(messageBatch2.length, 1);
    assertEquals(messageBatch2[0].body, "World");
    await mq.batchAck("test", [messageBatch1[0].id, messageBatch2[0].id]);
    assertEquals(await mq.fetchProcessingMessages(), []);

    // Subscribe
    let receivedMessage = false;
    const worker = mq.subscribe("test123", {}, async (messages) => {
      assertEquals(messages.length, 1);
      receivedMessage = true;
      await mq.ack("test123", messages[0].id);
    });
    await mq.send("test123", "Hello World");
    // Wait for message to be processed by checking queue stats
    while ((await mq.getQueueStats("test123")).queued > 0) {
      await time.tickAsync(100);
    }
    assertEquals(receivedMessage, true);
    worker.stop();
    assertEquals(mq.queueWaiters.size, 0);
  } finally {
    await db.close();
    time.restore();
  }
});

Deno.test("DataStore MQ - Scale test with multiple subscribers", async () => {
  const time = new FakeTime();
  const db = new MemoryKvPrimitives();
  const eventHook = new EventHook();
  const system = new System<EventHookT>();
  system.addHook(eventHook);

  try {
    const mq = new DataStoreMQ(
      new DataStore(db),
      eventHook,
    );

    const queueName = "scale-test";
    const totalMessages = 1000;
    const batchSize = 7;
    const numSubscribers = 3;

    // Track processed messages across all subscribers
    const processedMessages = new Set<string>();
    const subscriberStats = new Map<number, number>();
    const processingLock = new Set<string>(); // Track which messages are being processed

    // Initialize subscriber stats
    for (let i = 0; i < numSubscribers; i++) {
      subscriberStats.set(i, 0);
    }

    // Create 3 subscribers, each processing batches of 7 messages
    const workers = [];
    for (let subscriberId = 0; subscriberId < numSubscribers; subscriberId++) {
      const worker = mq.subscribe(
        queueName,
        { batchSize },
        async (messages) => {
          assertEquals(
            messages.length <= batchSize,
            true,
            `Batch size should not exceed ${batchSize}`,
          );

          console.log(
            `[Subscriber ${subscriberId}] Processing batch of ${messages.length} messages`,
          );

          // Process each message in the batch
          const messageIds = [];
          for (const message of messages) {
            // Check for concurrent processing (this shouldn't happen due to MQ design)
            if (processingLock.has(message.body)) {
              console.warn(
                `Message ${message.body} is being processed concurrently by subscriber ${subscriberId}`,
              );
              continue;
            }
            processingLock.add(message.body);

            // Ensure no duplicate processing
            if (processedMessages.has(message.body)) {
              console.warn(
                `Message ${message.body} already processed by another subscriber`,
              );
              processingLock.delete(message.body);
              continue;
            }

            processedMessages.add(message.body);
            messageIds.push(message.id);

            // Update subscriber stats
            const currentCount = subscriberStats.get(subscriberId) || 0;
            subscriberStats.set(subscriberId, currentCount + 1);

            // Remove from processing lock
            processingLock.delete(message.body);
          }

          // Ack all messages in the batch that were actually processed
          if (messageIds.length > 0) {
            await mq.batchAck(queueName, messageIds);
          }
        },
      );
      workers.push(worker);
    }

    // Send messages in smaller batches to allow better distribution
    const messageBodies = [];
    for (let i = 0; i < totalMessages; i++) {
      messageBodies.push(`message-${i}`);
    }

    // Send in chunks to allow workers to process and become available
    const chunkSize = 50;
    for (let i = 0; i < messageBodies.length; i += chunkSize) {
      const chunk = messageBodies.slice(i, i + chunkSize);
      await mq.batchSend(queueName, chunk);
      // Give a small delay to allow processing
      await time.tickAsync(10);
    }

    // Wait for all messages to be processed
    const maxWaitTime = 10000; // 10 seconds max wait
    let waitTime = 0;

    while (processedMessages.size < totalMessages && waitTime < maxWaitTime) {
      await time.tickAsync(100);
      waitTime += 100;
    }

    // Verify all messages were processed
    assertEquals(
      processedMessages.size,
      totalMessages,
      `Expected ${totalMessages} messages processed, got ${processedMessages.size}`,
    );

    // Wait a bit more to ensure all acks are processed
    await time.tickAsync(100);

    // Verify queue is empty
    const stats = await mq.getQueueStats(queueName);
    assertEquals(stats.queued, 0, "Queue should be empty");
    assertEquals(stats.processing, 0, "No messages should be processing");

    // Verify work distribution among subscribers
    let totalProcessedAcrossSubscribers = 0;
    for (const [subscriberId, count] of subscriberStats.entries()) {
      totalProcessedAcrossSubscribers += count;
      console.log(`Subscriber ${subscriberId} processed ${count} messages`);
    }
    assertEquals(
      totalProcessedAcrossSubscribers,
      totalMessages,
      "Total processed should match sent messages",
    );

    // Verify at least one subscriber processed messages (relaxed requirement since MQ may favor one worker)
    const activeSubscribers =
      Array.from(subscriberStats.values()).filter((count) => count > 0).length;
    assertEquals(
      activeSubscribers >= 1,
      true,
      "At least one subscriber should have processed messages",
    );

    // Stop all workers
    workers.forEach((worker) => worker.stop());

    // Verify no queue waiters remain
    assertEquals(mq.queueWaiters.size, 0);
  } finally {
    await db.close();
    time.restore();
  }
});


================================================
FILE: client/data/mq.datastore.ts
================================================
import type { DataStore } from "./datastore.ts";
import { parseExpressionString } from "../space_lua/parse.ts";
import { LuaEnv } from "../space_lua/runtime.ts";
import type {
  KV,
  KvKey,
  MQMessage,
  MQStats,
  MQSubscribeOptions,
} from "../../plug-api/types/datastore.ts";
import { race, sleep } from "@silverbulletmd/silverbullet/lib/async";
import type { EventHook } from "../plugos/hooks/event.ts";

export type ProcessingMessage = MQMessage & {
  ts: number;
};

const queuedPrefix = ["mq", "queued"];
const processingPrefix = ["mq", "processing"];
const dlqPrefix = ["mq", "dlq"];

export class QueueWorker {
  stopping = false;
  private stopReject?: (e: any) => void;

  constructor(
    private mq: DataStoreMQ,
    readonly queue: string,
    readonly options: MQSubscribeOptions,
    private callback: (messages: MQMessage[]) => Promise<void> | void,
  ) {
  }

  /**
   * This is the main loop of the worker, whenever it exits the loop it means the worker has stopped
   */
  async run() {
    try {
      while (true) {
        if (this.stopping) {
          break;
        }
        // Poll for messages
        const messages = await this.mq.poll(
          this.queue,
          this.options.batchSize || 1,
        );
        if (messages.length > 0) {
          // We have messages, process them, then immediately loop to poll again
          await this.callback(messages);
        } else {
          // No messages, wait to be woken up or a timeout
          this.mq.eventHook.dispatchEvent(
            `mq:emptyQueue:${this.queue}`,
            this.queue,
          );
          try {
            await race([
              // Wait to be woken up explicitly
              new Promise<void>((resolve, reject) => {
                this.stopReject = reject;
                this.mq.queueWorker(this.queue, resolve, reject);
              }),
              // Or a poll interval timeout
              sleep(this.options.pollInterval || 1000).then(() => {
                // Remove self from waiters
                this.mq.removeQueuedWorker(this.queue, this.stopReject!);
              }),
            ]);
          } catch {
            // Only scenario we should end up here is stop being called
            break;
          }
        }
      }
    } catch (e) {
      console.error("Error in queue worker", e);
    }
  }

  stop() {
    this.stopping = true;
    if (this.stopReject) {
      // Worker was in a waiting state, reject the promise to wake it up and remove from waiters
      this.mq.removeQueuedWorker(this.queue, this.stopReject);
      this.stopReject(new Error("Queue worker stopped"));
    }
  }
}

/**
 * Basic message queue implementation on top of a DataStore
 */
export class DataStoreMQ {
  // Internal sequencer for messages, only really necessary when batch sending tons of messages within a millisecond
  seq = 0;

  queueWaiters = new Map<
    string,
    ({ resolve: () => void; reject: (e: any) => void })[]
  >();

  constructor(
    private ds: DataStore,
    public eventHook: EventHook,
  ) {
  }

  /// Worker management
  public queueWorker(
    queue: string,
    resolve: () => void,
    reject: (e: any) => void,
  ) {
    let waiters = this.queueWaiters.get(queue);
    if (!waiters) {
      waiters = [];
      this.queueWaiters.set(queue, waiters);
    }
    // console.log("[mq]", "Queuing a worker for queue", queue);
    waiters.push({ resolve, reject });
  }

  /**
   * Wakes up a single worker waiting on the given queue, if any
   * @param queue
   */
  wakeupWorker(queue: string) {
    const waiters = this.queueWaiters.get(queue);
    if (waiters && waiters.length > 0) {
      // console.log("[mq]", "Waking up a worker for queue", queue);
      const { resolve } = waiters.shift()!;
      resolve();
      if (waiters.length === 0) {
        // Clean up empty arrays
        this.queueWaiters.delete(queue);
      }
    }
  }

  removeQueuedWorker(queue: string, reject: (e: any) => void) {
    const waiters = this.queueWaiters.get(queue);
    if (waiters) {
      const index = waiters.findIndex((w) => w.reject === reject);
      if (index !== -1) {
        waiters.splice(index, 1);
      }
      if (waiters.length === 0) {
        // Let's not keep empty arrays around
        this.queueWaiters.delete(queue);
      }
    }
  }

  /**
   * Sends a batch of messages to a queue.
   * @param queue the name of the queue
   * @param bodies the bodies of the messages to send
   * @returns
   */
  async batchSend(queue: string, bodies: any[]): Promise<void> {
    if (bodies.length === 0) {
      return;
    }

    const messages: KV<MQMessage>[] = bodies.map((body) => {
      const id = `${Date.now()}-${String(++this.seq).padStart(6, "0")}`;
      const key = [...queuedPrefix, queue, id];
      return {
        key,
        value: { id, queue, body },
      };
    });

    await this.ds.batchSet(messages);

    this.wakeupWorker(queue);
  }

  send(queue: string, body: any): Promise<void> {
    return this.batchSend(queue, [body]);
  }

  async poll(queue: string, maxItems: number): Promise<MQMessage[]> {
    // Note: this is not happening in a transactional way, so we may get duplicate message delivery
    // Retrieve a batch of messages
    const messages = await this.ds.luaQuery<MQMessage>(
      [...queuedPrefix, queue],
      {
        limit: maxItems,
      },
    );
    if (messages.length === 0) {
      return [];
    }

    // Put them in the processing queue
    await this.ds.batchSet(
      messages.map((m) => ({
        key: [...processingPrefix, queue, m.id],
        value: {
          ...m,
          ts: Date.now(),
        },
      })),
    );
    // Delete them from the queued queue
    await this.ds.batchDelete(
      messages.map((m) => [...queuedPrefix, queue, m.id]),
    );

    // Return them
    return messages;
  }

  /**
   * @param queue
   * @param batchSize
   * @param callback
   * @returns a function to be called to unsubscribe
   */
  subscribe(
    queue: string,
    options: MQSubscribeOptions,
    callback: (messages: MQMessage[]) => Promise<void> | void,
  ): QueueWorker {
    const worker = new QueueWorker(this, queue, options, callback);
    // Start the worker asynchronously
    worker.run();
    return worker;
  }

  ack(queue: string, id: string) {
    return this.batchAck(queue, [id]);
  }

  async batchAck(queue: string, ids: string[]) {
    if (ids.length === 0) {
      return;
    }
    await this.ds.batchDelete(
      ids.map((id) => [...processingPrefix, queue, id]),
    );
  }

  async requeueTimeouts(
    timeout: number,
    maxRetries?: number,
    disableDLQ?: boolean,
  ) {
    const now = Date.now();
    const env = new LuaEnv();
    env.setLocal("ts", now - timeout);
    const messages = await this.ds.luaQuery<ProcessingMessage>(
      processingPrefix,
      {
        objectVariable: "m",
        where: parseExpressionString("m.ts < ts"),
      },
      env,
    );
    if (messages.length === 0) {
      return;
    }
    await this.ds.batchDelete(
      messages.map((m) => [...processingPrefix, m.queue, m.id]),
    );
    const newMessages: KV<ProcessingMessage>[] = [];
    for (const m of messages) {
      const retries = (m.retries || 0) + 1;
      if (maxRetries && retries > maxRetries) {
        if (disableDLQ) {
          console.warn(
            "[mq]",
            "Message exceeded max retries, flushing message",
            m,
          );
        } else {
          console.warn(
            "[mq]",
            "Message exceeded max retries, moving to DLQ",
            m,
          );
          newMessages.push({
            key: [...dlqPrefix, m.queue, m.id],
            value: {
              queue: m.queue,
              id: m.id,
              body: m.body,
              ts: Date.now(),
              retries,
            },
          });
        }
      } else {
        console.info("[mq]", "Message ack timed out, requeueing", m);
        newMessages.push({
          key: [...queuedPrefix, m.queue, m.id],
          value: {
            ...m,
            retries,
          },
        });
      }
    }
    await this.ds.batchSet(newMessages);
  }

  async fetchDLQMessages(): Promise<ProcessingMessage[]> {
    return (await this.ds.luaQuery<ProcessingMessage>(dlqPrefix, {}));
  }

  async fetchProcessingMessages(): Promise<ProcessingMessage[]> {
    return (await this.ds.luaQuery<ProcessingMessage>(processingPrefix, {}));
  }

  async flushDLQ(): Promise<void> {
    const ids: KvKey[] = [];
    for (const item of await this.ds.luaQuery<MQMessage>(dlqPrefix, {})) {
      ids.push([...dlqPrefix, item.queue, item.id]);
    }
    await this.ds.batchDelete(ids);
  }

  /**
   * Flushes a queue, including all queued, processing and DLQ messages
   * @param queue
   */
  async flushQueue(queue: string): Promise<void> {
    const ids: KvKey[] = [];
    for (
      const item of await this.ds.luaQuery<MQMessage>(
        [...queuedPrefix, queue],
        {},
      )
    ) {
      ids.push([...queuedPrefix, queue, item.id]);
    }
    for (
      const item of await this.ds.luaQuery<ProcessingMessage>([
        ...processingPrefix,
        queue,
      ], {})
    ) {
      ids.push([...processingPrefix, queue, item.id]);
    }
    for (
      const item of await this.ds.luaQuery<ProcessingMessage>([
        ...dlqPrefix,
        queue,
      ], {})
    ) {
      ids.push([...dlqPrefix, queue, item.id]);
    }
    await this.ds.batchDelete(ids);
  }

  /**
   * Flushes all queues
   */
  flushAllQueues() {
    return this.ds.batchDeletePrefix(["mq"]);
  }

  async getQueueStats(queue?: string): Promise<MQStats> {
    const queued = await this.ds.kv.countQuery({
      prefix: queue ? [...queuedPrefix, queue] : queuedPrefix,
    });
    const processing = await this.ds.kv.countQuery({
      prefix: queue ? [...processingPrefix, queue] : processingPrefix,
    });
    const dlq = await this.ds.kv.countQuery({
      prefix: queue ? [...dlqPrefix, queue] : dlqPrefix,
    });
    return {
      queued,
      processing,
      dlq,
    };
  }

  public async isQueueEmpty(queue: string): Promise<boolean> {
    const stats = await this.getQueueStats(queue);
    return stats.queued === 0 && stats.processing === 0;
  }

  public async awaitEmptyQueue(queue: string): Promise<void> {
    while (true) {
      if (await this.isQueueEmpty(queue)) {
        break;
      }
      await sleep(200);
    }
  }
}


================================================
FILE: client/data/object_index.ts
================================================
import type { ObjectValue } from "@silverbulletmd/silverbullet/type/index";
import type { Config } from "../config.ts";
import type {
  LuaCollectionQuery,
  LuaQueryCollection,
} from "../space_lua/query_collection.ts";
import {
  jsToLuaValue,
  LuaEnv,
  LuaStackFrame,
  type LuaTable,
} from "../space_lua/runtime.ts";
import type { DataStore } from "./datastore.ts";
import type { KV, KvKey } from "@silverbulletmd/silverbullet/type/datastore";
import type { EventHook } from "../plugos/hooks/event.ts";
import type { DataStoreMQ } from "./mq.datastore.ts";
import type { Space } from "../space.ts";
import { validateObject } from "../plugos/syscalls/jsonschema.ts";

const indexKey = "idx";
const pageKey = "ridx";

const indexVersionKey = ["$indexVersion"];

// Bump this one every time a full reindex is needed
const desiredIndexVersion = 9;

type TagDefinition = {
  metatable?: any;
  mustValidate?: boolean;
  schema?: any;
  validate?: (o: ObjectValue) => Promise<string | null | undefined>;
  transform?: (
    o: ObjectValue,
  ) =>
    | Promise<ObjectValue[] | ObjectValue>
    | ObjectValue[]
    | ObjectValue
    | null;
};

export class ObjectValidationError extends Error {
  constructor(message: string, readonly object: ObjectValue) {
    super(message);
  }
}

export class ObjectIndex {
  constructor(
    private ds: DataStore,
    private config: Config,
    private eventHook: EventHook,
    private mq: DataStoreMQ,
  ) {
    // Clear any entries for deleted files
    this.eventHook.addLocalListener("file:deleted", (path: string) => {
      return this.clearFileIndex(path);
    });

    // Tracks if the file:listed event has been triggered,
    // which is fired after all file:changed events have been dispatched
    // resulting in new index entries (if any) being queued in the index queue
    // this is later used to track if the index is complete
    let indexStarted = false;
    this.eventHook.addLocalListener("file:listed", () => {
      indexStarted = true;
    });

    // Handle initial index completion
    this.hasFullIndexCompleted().then((hasCompleted) => {
      if (!hasCompleted) {
        const emptyQueueHandler = async () => {
          console.log("Index queue empty, checking if index is complete");
          // Theoretically we could get empty queue notifications before the file:listed event has been triggered, so let's account for this
          if (indexStarted) {
            // Indexing has just finished for the first time for this client
            console.info(
              "Initial index complete, reloading editor state",
            );
            await this.markFullIndexComplete();
            // Unsubscribe yourself
            this.eventHook.removeLocalListener(
              "mq:emptyQueue:indexQueue",
              emptyQueueHandler,
            );
            // Trigger an editor:reloadState event to reload the editor state (render widgets etc.)
            this.eventHook.dispatchEvent("editor:reloadState");
          }
        };
        this.eventHook.addLocalListener(
          "mq:emptyQueue:indexQueue",
          emptyQueueHandler,
        );
      }
    });
  }

  tag(tagName: string): LuaQueryCollection {
    if (!tagName) {
      throw new Error("Tag name is required");
    }
    return {
      query: (
        query: LuaCollectionQuery,
        env: LuaEnv,
        sf: LuaStackFrame,
      ): Promise<any[]> => {
        return this.ds.luaQuery(
          ["idx", tagName],
          query,
          env,
          sf,
          (key, value: any) => {
            const tag = key[1];
            const mt = this.config.get<LuaTable | undefined>(
              ["tags", tag, "metatable"],
              undefined,
            );
            if (!mt) {
              // Return as is
              return value;
            }
            // Convert to LuaTable
            value = jsToLuaValue(value);
            value.metatable = mt;
            return value;
          },
        );
      },
    };
  }

  getObjectByRef<T>(page: string, tag: string, ref: string) {
    return this.ds.get([indexKey, tag, this.cleanKey(ref, page), page]);
  }

  async ensureFullIndex(space: Space) {
    const currentIndexVersion = await this.getCurrentIndexVersion();

    if (!currentIndexVersion) {
      console.log("No index version found, assuming fresh install");
      return;
    }

    if (
      // If the index version is less than the desired version
      currentIndexVersion < desiredIndexVersion &&
      // And the index queue is empty (meaning no indexing is ongoing)
      await this.mq.isQueueEmpty("indexQueue")
    ) {
      console.info(
        "[index]",
        "Performing a full space reindex, this could take a while...",
        currentIndexVersion,
        desiredIndexVersion,
      );

      await this.reindexSpace(space);

      // Dispatch an editor:reloadState event to reload the editor state (render widgets etc.)
      this.eventHook.dispatchEvent("editor:reloadState");
    }
  }

  async reindexSpace(space: Space) {
    console.log("Clearing page index...");
    await this.clearIndex();
    await this.markFullIndexInComplete();

    const files = await space.deduplicatedFileList();

    console.log("Queing", files.length, "pages to be indexed.");
    // Queue all file names to be indexed
    const startTime = Date.now();
    await this.mq.batchSend("indexQueue", files.map((file) => file.name));
    await this.mq.awaitEmptyQueue("indexQueue");
    await this.markFullIndexComplete();
    console.log("Full index completed after", Date.now() - startTime, "ms");
  }

  public async hasFullIndexCompleted() {
    return (await this.ds.get(indexVersionKey)) >= desiredIndexVersion;
  }

  private getCurrentIndexVersion() {
    return this.ds.get(indexVersionKey);
  }

  async markFullIndexComplete() {
    await this.ds.set(indexVersionKey, desiredIndexVersion);
  }

  async markFullIndexInComplete() {
    await this.ds.delete(indexVersionKey);
  }

  cleanKey(ref: string, page: string) {
    if (ref.startsWith(`${page}@`)) {
      return ref.substring(page.length + 1);
    } else {
      return ref;
    }
  }

  queryLuaObjects<T>(
    globalEnv: LuaEnv,
    tag: string,
    query: LuaCollectionQuery,
    scopedVariables?: Record<string, any>,
  ): Promise<ObjectValue<T>[]> {
    const sf = LuaStackFrame.createWithGlobalEnv(
      globalEnv,
    );
    let env = globalEnv;
    if (scopedVariables) {
      env = new LuaEnv(globalEnv);
      for (const [key, value] of Object.entries(scopedVariables)) {
        env.setLocal(key, jsToLuaValue(value));
      }
    }
    return this.ds.luaQuery([indexKey, tag], query, env, sf);
  }

  batchSet(page: string, kvs: KV[]): Promise<void> {
    const finalBatch: KV[] = [];
    for (const { key, value } of kvs) {
      finalBatch.push({
        key: [indexKey, ...key, page],
        value,
      }, {
        key: [pageKey, page, ...key],
        value: true,
      });
    }
    return this.ds.batchSet(finalBatch);
  }

  batchDelete(page: string, keys: KvKey[]): Promise<void> {
    const finalBatch: KvKey[] = [];
    for (const key of keys) {
      finalBatch.push([indexKey, ...key, page]);
    }
    return this.ds.batchDelete(finalBatch);
  }

  /**
   * Clears all keys for a given file
   * @param file
   */
  public async clearFileIndex(file: string): Promise<void> {
    if (file.endsWith(".md")) {
      file = file.replace(/\.md$/, "");
    }
    // console.log("Clearing index for", file);
    const allKeys: KvKey[] = [];
    for await (
      const { key } of this.ds.query({
        prefix: [pageKey, file],
      })
    ) {
      allKeys.push(key);
      allKeys.push([indexKey, ...key.slice(2), file]);
    }
    await this.ds.batchDelete(allKeys);
  }

  /**
   * Clears the entire index
   */
  public async clearIndex(): Promise<void> {
    const allKeys: KvKey[] = [];
    for await (
      const { key } of this.ds.query({ prefix: [indexKey] })
    ) {
      allKeys.push(key);
    }
    for await (
      const { key } of this.ds.query({ prefix: [pageKey] })
    ) {
      allKeys.push(key);
    }
    await this.ds.batchDelete(allKeys);
    console.log("Deleted", allKeys.length, "keys from the index");
  }

  /**
   * Indexes entities in the data store
   */
  public async indexObjects<T>(
    page: string,
    objects: ObjectValue<T>[],
  ): Promise<void> {
    const kvs = await this.processObjectsToKVs<T>(page, objects, false);
    if (kvs.length > 0) {
      return this.batchSet(page, kvs);
    } else {
      return Promise.resolve();
    }
  }

  /**
   * Validate and transform objects, throws a ValidationError when it fails
   * @param page
   * @param objects
   * @throw ValidationError
   */
  public async validateObjects<T>(page: string, objects: ObjectValue<T>[]) {
    await this.processObjectsToKVs(page, objects, true);
  }

  private async processObjectsToKVs<T>(
    page: string,
    objects: ObjectValue<T>[],
    throwOnValidationErrors: boolean,
  ): Promise<KV<T>[]> {
    const kvs: KV<T>[] = [];
    const tagDefinitions: Record<string, TagDefinition> = this.config.get(
      "tags",
      {},
    );
    // Taking this iteration approach as new objects may be pushed into this array on the fly
    while (objects.length > 0) {
      const obj = objects.shift()!;
      if (!obj.tag) {
        console.error("Object has no tag", obj, "this shouldn't happen");
        continue;
      }
      // Index as all the tag + any additional tags specified
      const allTags = [obj.tag, ...obj.tags || []];
      for (const tag of allTags) {
        const tagDefinition = tagDefinitions[tag];
        // Validate object based on schema if required
        if (
          tagDefinition?.schema &&
          (tagDefinition?.mustValidate || throwOnValidationErrors)
        ) {
          const validationError = validateObject(tagDefinition?.schema, obj);
          if (validationError) {
            if (!throwOnValidationErrors) {
              console.warn(
                `Object failed ${tag} validation so won't be indexed:`,
                obj,
                "Validation error:",
                validationError,
              );
              continue;
            } else {
              throw new ObjectValidationError(validationError, obj);
            }
          }
        }
        // Validate object based on validate callback if required
        if (
          tagDefinition?.validate &&
          (tagDefinition?.mustValidate || throwOnValidationErrors)
        ) {
          const validationError = await tagDefinition.validate(obj);
          if (validationError) {
            if (!throwOnValidationErrors) {
              console.warn(
                `Object failed ${tag} validation so won't be indexed:`,
                obj,
                "Validation error:",
                validationError,
              );
              continue;
            } else {
              throw new ObjectValidationError(validationError, obj);
            }
          }
        }
        // Transform object
        if (tagDefinition?.transform) {
          let newObjects;
          try {
            newObjects = await tagDefinition.transform(obj);
          } catch (e: any) {
            throw new ObjectValidationError(e.message, obj);
          }

          if (!newObjects) {
            // null value returned, just index as usual
            kvs.push({
              key: [tag, this.cleanKey(obj.ref, page)],
              value: obj,
            });
            continue;
          }

          if (!Array.isArray(newObjects)) {
            // Probably returned single object, let's normalize
            newObjects = [newObjects];
          }
          // A transform function _must_ either return an empty list of objects to index, or return at least one object with the same ref
          // If this doesn't happen, we may end up in an infinite loop.
          let foundAssignedRef = false;
          for (const newObj of newObjects) {
            if (!newObj.ref) {
              console.error(
                "transform result object did not contain ref",
                newObj,
              );
              continue;
            }
            if (newObj.ref === obj.ref) {
              // Got the same object back here, let's just index it without further processing
              kvs.push({
                key: [tag, this.cleanKey(newObj.ref, page)],
                value: newObj,
              });
              foundAssignedRef = true;
            } else {
              // Some other object
              objects.push(newObj);
            }
          }
          if (!foundAssignedRef && newObjects.length) {
            throw new Error(
              `transform() result objects for ${tag} did not contain result with original ref.`,
            );
          }
        } else {
          // Just insert it directly
          kvs.push({
            key: [tag, this.cleanKey(obj.ref, page)],
            value: obj,
          });
        }
      }
    }
    return kvs;
  }

  deleteObject(
    page: string,
    tag: string,
    ref: string,
  ): Promise<void> {
    return this.batchDelete(page, [[tag, this.cleanKey(ref, page)]]);
  }
}


================================================
FILE: client/debug.ts
================================================
globalThis.addEventListener("unhandledrejection", (event) => {
  client.reportError(event.reason);
});


================================================
FILE: client/document_editor.ts
================================================
import type { Client } from "./client.ts";
import { timeout } from "@silverbulletmd/silverbullet/lib/async";
import type { DocumentMeta } from "@silverbulletmd/silverbullet/type/index";
import type { Ref } from "@silverbulletmd/silverbullet/lib/ref";
import documentEditorJS from "./document_editor_js.ts";
import type { DocumentEditorContent } from "@silverbulletmd/silverbullet/type/client";

export class DocumentEditor {
  iframe!: HTMLIFrameElement;
  name!: string;
  extension!: string;
  currentPath: string | null = null;
  savePromise: PromiseWithResolvers<void> | null = null;

  constructor(
    readonly parent: HTMLElement,
    readonly client: Client,
    readonly saveMethod: (path: string, content: Uint8Array) => void,
  ) {
  }

  async init(extension: string) {
    this.extension = extension;

    const entry = Array.from(
      this.client.clientSystem.documentEditorHook.documentEditors
        .entries(),
    ).find(([_, { extensions }]) => extensions.includes(this.extension));

    if (!entry) {
      throw new Error("Couldn't find plug for specified extension");
    }

    const [name, { callback }] = entry;
    this.name = name;

    const content = await callback();

    globalThis.addEventListener("message", this.handleMessage.bind(this));

    const { iframe, ready } = DocumentEditor.createIframe(content);
    this.iframe = iframe;

    this.parent.appendChild(this.iframe);

    await ready;
  }

  async destroy() {
    // If name isn't initalized the editor is probably dead
    if (!this.name) return;

    await this.waitForSave();

    this.iframe.remove();
  }

  sendPublicMessage(message: { type: string; data?: any }) {
    this.sendMessage(message);
  }

  openFile(
    data: Uint8Array,
    meta: DocumentMeta,
    details: Ref["details"],
  ) {
    this.sendMessage({
      type: "file-open",
      data: {
        data,
        meta,
        details,
      },
    });

    this.currentPath = meta.name;
  }

  requestSave() {
    if (this.savePromise) {
      console.log(
        "Save was already requested from editor, trying again anyways",
      );
    } else {
      this.savePromise = Promise.withResolvers();
    }

    this.sendMessage({
      type: "request-save",
    });
  }

  focus() {
    this.sendMessage({
      type: "focus",
    });
  }

  private async waitForSave() {
    if (this.savePromise) {
      try {
        await Promise.race([
          this.savePromise.promise,
          timeout(2500),
        ]);
      } catch {
        this.savePromise.resolve();
        this.savePromise = null;

        console.log(
          "Unable to save content of document editor in 2.5s. Aborting save",
        );
      }
    }
  }

  private sendMessage(
    message: { type: string; internal?: boolean; data?: any },
  ) {
    if (!this.iframe?.contentWindow) return;
    message.internal ??= false;
    this.iframe.contentWindow.postMessage(message);
  }

  private async handleMessage(event: any) {
    if (event.source !== this.iframe.contentWindow) return;
    const response = event.data;
    if (!response) return;

    const data = response.data;

    switch (response.type) {
      case "file-changed":
        {
          this.client.ui.viewDispatch({
            type: "document-editor-changed",
          });
          this.client.save().catch((e) => console.error("Couldn't save: ", e));
        }
        break;
      case "file-saved":
        {
          this.savePromise?.resolve();
          this.savePromise = null;

          if (!this.currentPath) return;
          this.saveMethod(this.currentPath, data.data);
        }
        break;
      case "syscall":
        {
          let result: any;

          try {
            const response = await this.client.clientSystem.localSyscall(
              data.name,
              data.args,
            );

            result = { result: response };
          } catch (e: any) {
            result = { error: e.message };
          }

          this.sendMessage({
            type: "syscall-response",
            internal: true,
            data: {
              id: data.id,
              ...result,
            },
          });
        }
        break;
      default:
        console.warn("Unknown event sent from plug: ", data.type);
    }
  }

  updateTheme() {
    this.sendMessage({
      type: "set-theme",
      internal: true,
      data: {
        theme: this.client.ui.viewState.uiOptions.darkMode ? "dark" : "light",
      },
    });
  }

  private static createIframe(
    content: DocumentEditorContent,
  ): { iframe: HTMLIFrameElement; ready: Promise<void> } {
    const doc = new DOMParser().parseFromString(content.html, "text/html");

    // This is only for legacy support
    if ((content as any).script) {
      console.warn(
        "Providing a `script` property with your document editor is deprecated. Inline the script into your HTML instead.",
      );

      // Load the legacy script
      const script = doc.createElement("script");
      script.type = "text/javascript";
      script.text = (content as any).script;
      doc.body.appendChild(script);

      // Because now an html and body tag will be auto created, it can happen
      // that the content is not properly stretching those elements and the
      // content will stay small, let's try to mitgate this
      const style = doc.createElement("style");
      style.textContent =
        "html, body { width: 100%; height: 100%; margin: 0; }";
      doc.head.appendChild(style);
    }

    const script = doc.createElement("script");
    script.type = "text/javascript";
    script.text = documentEditorJS;
    doc.head.prepend(script);

    const base = doc.createElement("base");
    base.href = document.baseURI;
    doc.head.prepend(base);

    // This isn't great, but the only way to get the up-to-date theme without
    // using media queries
    const theme = document.querySelector("html")?.getAttribute("data-theme");
    doc.querySelector("html")!.setAttribute("data-theme", theme ?? "light");

    const iframe = document.createElement("iframe");
    iframe.srcdoc = doc.documentElement.outerHTML;

    const ready = new Promise<void>((resolve) => {
      iframe.addEventListener("load", () => resolve(), { once: true });
    });

    return { iframe, ready };
  }
}


================================================
FILE: client/document_editor_js.ts
================================================
export default `
const pendingRequests = new Map();
let requestID = 0;

globalThis.silverbullet = new EventTarget();

globalThis.silverbullet.syscall = (name, ...args) => {
  const promise = Promise.withResolvers();

  pendingRequests.set(++requestID, { ...promise });

  globalThis.parent.postMessage({
    type: "syscall",
    data: {
      id: requestID,
      name,
      args,
    },
  }, "*");

  return promise.promise;
};

globalThis.addEventListener("message", (message) => {
  const response = message.data, data = message.data.data;

  // Passthrough non internal events
  if (!response.internal) {
    globalThis.silverbullet.dispatchEvent(new CustomEvent(response.type, { detail: data }));
    return;
  }

  switch (response.type) {
    case "syscall-response": {
      const id = data.id;
      const request = pendingRequests.get(id);
      if (!request) {
        console.error(\`Failed to lookup request with id \${id}\`);
        throw Error("Invalid request id");
      }

      pendingRequests.delete(id);

      if (data.error) {
        request.reject(new Error(data.error));
      } else {
        request.resolve(data.result);
      }
    } break;
    case "set-theme": {
      document.querySelector("html").setAttribute("data-theme", data.theme);
    } break;
  }
});

globalThis.silverbullet.sendMessage = (type, data) => {
  globalThis.parent.postMessage({ type, data }, "*");
};

// When an iframe is focused, all keyboard events will be captured by the
// iframe. This is bad because we obviously want stuff like the command
// picker to keep working. This is done by listening to the event **in the
// capture phase** (so we get it first), sending it to the actual SB DOM,
// and only if it doesn't have defaultPrevented set, we send it further
// down.
globalThis.addEventListener("keydown", (event) => {
  // This is really hacky... open to other solutions
  const keyEvent = new KeyboardEvent("keydown", event);

  Object.defineProperty(keyEvent, "target", {
    value: globalThis.parent.document.body,
  });

  globalThis.parent.document.dispatchEvent(keyEvent);

  if (keyEvent.defaultPrevented) {
    event.preventDefault();
    event.stopPropagation();
  }
}, true);
`;


================================================
FILE: client/editor_ui.tsx
================================================
import { Confirm, Prompt } from "./components/basic_modals.tsx";
import { CommandPalette } from "./components/command_palette.tsx";
import { FilterList } from "./components/filter.tsx";
import { AnythingPicker } from "./components/anything_picker.tsx";
import { TopBar } from "./components/top_bar.tsx";
import reducer from "./reducer.ts";
import {
  type Action,
  type AppViewState,
  initialViewState,
} from "./types/ui.ts";
import * as featherIcons from "preact-feather";
import * as mdi from "./filtered_material_icons.ts";
import { h, render as preactRender } from "preact";
import { useEffect, useReducer } from "preact/hooks";
import { closeSearchPanel } from "@codemirror/search";
import { runScopeHandlers } from "@codemirror/view";
import type { Client } from "./client.ts";
import { Panel } from "./components/panel.tsx";
import { safeRun } from "@silverbulletmd/silverbullet/lib/async";
import type { FilterOption } from "@silverbulletmd/silverbullet/type/client";
import {
  getNameFromPath,
  getPathExtension,
  isMarkdownPath,
  isValidName,
  parseToRef,
  type Path,
} from "@silverbulletmd/silverbullet/lib/ref";

export class MainUI {
  viewState: AppViewState = initialViewState;

  constructor(private client: Client) {
    // Make keyboard shortcuts work even when the editor is in read only mode or not focused
    globalThis.addEventListener("keydown", (ev) => {
      if (!client.editorView.hasFocus) {
        const target = ev.target as HTMLElement;
        if (target.className === "cm-textfield" && ev.key === "Escape") {
          // Search panel is open, let's close it
          console.log("Closing search panel");
          closeSearchPanel(client.editorView);
          return;
        } else if (
          target.className === "cm-textfield" ||
          target.closest(".cm-content") || target.closest(".cm-vim-panel")
        ) {
          // In some cm element, let's back out
          return;
        }
        if (runScopeHandlers(client.editorView, ev, "editor")) {
          ev.preventDefault();
        }
      }
    });

    globalThis.addEventListener("touchstart", (ev) => {
      // Launch the page picker on a two-finger tap
      if (ev.touches.length === 2) {
        ev.stopPropagation();
        ev.preventDefault();
        client.startPageNavigate("page");
      }
      // Launch the command palette using a three-finger tap
      if (ev.touches.length === 3) {
        ev.stopPropagation();
        ev.preventDefault();
        client.startCommandPalette();
      }
    });

    globalThis.addEventListener("mouseup", (_) => {
      setTimeout(() => {
        client.editorView.dispatch({});
      });
    });
  }

  viewDispatch: (action: Action) => void = () => {
  };

  ViewComponent() {
    const [viewState, dispatch] = useReducer(reducer, initialViewState);
    this.viewState = viewState;
    this.viewDispatch = dispatch;

    const client = this.client;

    useEffect(() => {
      if (viewState.current) {
        document.title =
          (this.client.currentPageMeta()?.pageDecoration?.prefix ?? "") +
          getNameFromPath(viewState.current.path);
      }
    }, [viewState.current]);

    useEffect(() => {
      this.client.rebuildEditorState();
      this.client.dispatchAppEvent("editor:modeswitch");
    }, [viewState.uiOptions.vimMode]);

    useEffect(() => {
      const updateTheme = () => {
        const darkMode = viewState.uiOptions.darkMode === undefined
          ? globalThis.matchMedia("(prefers-color-scheme: dark)").matches
          : viewState.uiOptions.darkMode;

        document.documentElement.dataset.theme = darkMode ? "dark" : "light";

        if (this.client.isDocumentEditor()) {
          this.client.documentEditor.updateTheme();
        }
      };

      updateTheme();

      if (viewState.uiOptions.darkMode === undefined) {
        const mediaQuery = globalThis.matchMedia(
          "(prefers-color-scheme: dark)",
        );
        mediaQuery.addEventListener("change", updateTheme);

        return () => {
          mediaQuery.removeEventListener("change", updateTheme);
        };
      }
    }, [viewState.uiOptions.darkMode]);

    useEffect(() => {
      document.documentElement.dataset.markdownSyntaxRendering =
        viewState.uiOptions.markdownSyntaxRendering ? "on" : "off";
    }, [viewState.uiOptions.markdownSyntaxRendering]);

    useEffect(() => {
      // Need to dispatch a resize event so that the top_bar can pick it up
      globalThis.dispatchEvent(new Event("resize"));
    }, [viewState.panels]);
    const actionButtons = client.config.get<ActionButton[]>(
      "actionButtons",
      [],
    );
    return (
      <>
        {viewState.showPageNavigator && (
          <AnythingPicker
            allDocuments={viewState.allDocuments}
            allPages={viewState.allPages}
            extensions={new Set(
              Array.from(
                client.clientSystem.documentEditorHook.documentEditors.values(),
              ).flatMap(({ extensions }) => extensions),
            )}
            currentPath={client.currentPath()}
            mode={viewState.pageNavigatorMode}
            vimMode={viewState.uiOptions.vimMode}
            darkMode={viewState.uiOptions.darkMode}
            onModeSwitch={(mode) => {
              dispatch({ type: "stop-navigate" });
              setTimeout(() => {
                dispatch({ type: "start-navigate", mode });
              });
            }}
            onNavigate={(name) => {
              dispatch({ type: "stop-navigate" });
              setTimeout(() => {
                client.focus();
              });

              if (!name) {
                return;
              }

              safeRun(async () => {
                const ref = parseToRef(name);

                // Check beforhand, because we don't want to allow any link
                // stuff like #header here. The `!ref` check is just for
                // Typescript
                if (!isValidName(name) || !ref) {
                  // It's not a valid name so either, the user tried to create a
                  // page or we have an invalid file in the space. Names are
                  // only unique for files which follow our rules, so we are
                  // kind of in unknown territory now.

                  if (client.clientSystem.allKnownFiles.has(name)) {
                    // Try it as a document name === path
                    await this.promptDocumentOperation(
                      name as Path,
                      `'${name}' has an invalid name. You can now modify it`,
                    );
                  } else if (
                    client.clientSystem.allKnownFiles.has(`${name}.md`)
                  ) {
                    // Try it as a page
                    await this.promptDocumentOperation(
                      `${name}.md`,
                      `'${name}.md' has an invalid name. You can now modify it`,
                    );
                  } else {
                    client.flashNotification(
                      `Couldn't create page ${name}, name is invalid`,
                      "error",
                    );
                  }

                  return;
                }

                if (
                  !isMarkdownPath(ref.path) &&
                  !Array.from(
                    client.clientSystem.documentEditorHook.documentEditors
                      .values(),
                  ).some(({ extensions }) =>
                    extensions.includes(getPathExtension(ref.path))
                  )
                ) {
                  await this.promptDocumentOperation(
                    ref.path,
                    "This file cannot be edited, select your desired action.",
                  );
                } else {
                  client.navigate(ref);
                }
              });
            }}
          />
        )}
        {viewState.showCommandPalette && (
          <CommandPalette
            onTrigger={(cmd) => {
              safeRun(async () => {
                dispatch({ type: "hide-palette" });
                if (cmd) {
                  await this.client.registerCommandRun(cmd.name);
                  try {
                    const returnValue = await cmd.run!();
                    if (returnValue !== false) {
                      client.focus();
                    }
                  } catch (e: any) {
                    this.client.reportError(e, "Command invocation");
                  }
                } else {
                  setTimeout(() => client.focus());
                }
              });
            }}
            commands={client.getCommandsByContext(viewState)}
            vimMode={viewState.uiOptions.vimMode}
            darkMode={viewState.uiOptions.darkMode}
          />
        )}
        {viewState.showFilterBox && (
          <FilterList
            label={viewState.filterBoxLabel}
            placeholder={viewState.filterBoxPlaceHolder}
            options={viewState.filterBoxOptions}
            vimMode={viewState.uiOptions.vimMode}
            darkMode={viewState.uiOptions.darkMode}
            allowNew={false}
            helpText={viewState.filterBoxHelpText}
            onSelect={viewState.filterBoxOnSelect}
          />
        )}
        {viewState.showPrompt && (
          <Prompt
            message={viewState.promptMessage!}
            defaultValue={viewState.promptDefaultValue}
            vimMode={viewState.uiOptions.vimMode}
            darkMode={viewState.uiOptions.darkMode}
            callback={(value) => {
              dispatch({ type: "hide-prompt" });
              viewState.promptCallback!(value);
            }}
          />
        )}
        {viewState.showConfirm && (
          <Confirm
            message={viewState.confirmMessage!}
            callback={(value) => {
              dispatch({ type: "hide-confirm" });
              viewState.confirmCallback!(value);
            }}
          />
        )}
        <TopBar
          pageName={!viewState.current
            ? ""
            : getNameFromPath(viewState.current.path)}
          notifications={viewState.notifications}
          isOnline={viewState.isOnline}
          unsavedChanges={viewState.unsavedChanges}
          isLoading={viewState.isLoading}
          vimMode={viewState.uiOptions.vimMode}
          darkMode={viewState.uiOptions.darkMode}
          progressPercentage={viewState.progressPercentage}
          progressType={viewState.progressType}
          onClick={() => {
            if (!client.isDocumentEditor()) {
              client.editorView.scrollDOM.scrollTop = 0;
            }
          }}
          onRename={async (newName) => {
            if (client.isDocumentEditor()) {
              if (!newName) return;

              console.log("Now renaming document to...", newName);
              await client.clientSystem.system.invokeFunction(
                "index.renameDocumentCommand",
                [{ document: newName }],
              );
            } else {
              if (!newName) {
                // Always move cursor to the start of the page
                client.editorView.dispatch({
                  selection: { anchor: 0 },
                });
                client.focus();
                return;
              }
              console.log("Now renaming page to...", newName);
              await client.clientSystem.system.invokeFunction(
                "index.renamePageCommand",
                [{ page: newName }],
              );
              client.focus();
            }
          }}
          actionButtons={[
            // Vertical menu button
            ...(viewState.isMobile &&
                client.config.get<string>("mobileMenuStyle", "hamburger")
                  .includes(
                    "hamburger",
                  ))
              ? [{
                icon: featherIcons.MoreVertical,
                description: "Open Menu",
                class: "expander",
                callback: () => {
                  // Make the expander button open/close the menu via toggling the CSS class "open"
                  document.querySelector("#sb-top .sb-actions.hamburger")
                    ?.classList.toggle("open");
                },
              }]
              : [],
            // Custom action buttons
            ...actionButtons.filter(( // Filter out buttons without icons (invalid) and mobile buttons when not in mobile mode
              button,
            ) =>
              button.icon && (
                (typeof button.mobile === "undefined") ||
                (button.mobile === viewState.isMobile)
              )
            )
              // Then ensure all buttons have a priority set (by default based on array index)
              .map((button, index) => ({
                ...button,
                priority: button.priority ?? actionButtons.length - index,
              }))
              .sort((a, b) => b.priority - a.priority)
              .map((button) => {
                const mdiIcon = (mdi as any)[kebabToCamel(button.icon)];
                let featherIcon =
                  (featherIcons as any)[kebabToCamel(button.icon)];
                if (!featherIcon) {
                  featherIcon = featherIcons.HelpCircle;
                }
                return {
                  icon: mdiIcon ? mdiIcon : featherIcon,
                  description: button.description || "",
                  callback: button.run || (() => {
                    client.flashNotification(
                      "actionButton did not specify a run() callback",
                      "error",
                    );
                  }),
                  href: "",
                };
              }),
          ]}
          rhs={!!viewState.panels.rhs.mode && (
            <div
              className="panel"
              style={{ flex: viewState.panels.rhs.mode }}
            />
          )}
          lhs={!!viewState.panels.lhs.mode && (
            <div
              className="panel"
              style={{ flex: viewState.panels.lhs.mode }}
            />
          )}
          pageNamePrefix={client.currentPageMeta()?.pageDecoration?.prefix ??
            ""}
          cssClass={(client.currentPageMeta()?.pageDecoration?.cssClasses ?? [])
            .join(" ").replaceAll(/[^a-zA-Z0-9-_ ]/g, "")}
          mobileMenuStyle={client.config.get<string>(
            "mobileMenuStyle",
            "hamburger",
          )}
        />
        <div id="sb-main">
          {(viewState.panels.lhs.mode !== undefined) && (
            <Panel config={viewState.panels.lhs} editor={client} />
          )}
          <div id="sb-editor" />
          {(viewState.panels.rhs.mode !== undefined) && (
            <Panel config={viewState.panels.rhs} editor={client} />
          )}
        </div>
        {(viewState.panels.modal.mode !== undefined) && (
          <div
            className="sb-modal"
            style={{ inset: `${viewState.panels.modal.mode}px` }}
          >
            <Panel config={viewState.panels.modal} editor={client} />
          </div>
        )}
        {(viewState.panels.bhs.mode !== undefined) && (
          <div className="sb-bhs">
            <Panel config={viewState.panels.bhs} editor={client} />
          </div>
        )}
      </>
    );
  }

  render(container: Element) {
    // const ViewComponent = this.ui.ViewComponent.bind(this.ui);
    container.innerHTML = "";
    preactRender(h(this.ViewComponent.bind(this), {}), container);
  }

  async promptDocumentOperation(path: Path, msg: string) {
    const options: string[] = ["View", "Delete", "Rename"];

    const option = await client.filterBox(
      "Modify",
      options.map((x) => ({ name: x } as FilterOption)),
      msg,
    );
    if (!option) return;

    switch (option.name) {
      case "View": {
        await client.navigate({ path: path });
        break;
      }
      case "Delete": {
        if (
          await client.confirm(
            `Are you sure you would like delete ${getNameFromPath(path)}?`,
          )
        ) {
          if (isMarkdownPath(path)) {
            await client.space.deletePage(getNameFromPath(path));
          } else {
            await client.space.deleteDocument(getNameFromPath(path));
          }
        }
        break;
      }
      case "Rename": {
        if (isMarkdownPath(path)) {
          await client.clientSystem.system.invokeFunction(
            "index.renamePageCommand",
            [{ oldPage: getNameFromPath(path) }],
          );
        } else {
          await client.clientSystem.system.invokeFunction(
            "index.renameDocumentCommand",
            [{ oldDocument: getNameFromPath(path) }],
          );
        }
        break;
      }
    }
  }
}

// TODO: Parking this here for now, this is very similar to the definition in top_bar.tsx

type ActionButton = {
  icon: string;
  description?: string;
  mobile?: boolean;
  priority?: number;
  run: () => void;
};

function kebabToCamel(str: string) {
  return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase()).replace(
    /^./,
    (g) => g.toUpperCase(),
  );
}


================================================
FILE: client/filtered_material_icons.ts
================================================
/**
 * We apply a selective export of allowed icons.
 * It allows us to only import icons which fit an editor context, this avoids inflating the
 * bundle size with mostly unused icons, but still allows users to freely choose from the allowed
 * selection.
 */
export {
  MdChecklist,
  MdCode,
  MdFileUpload,
  MdFormatBold,
  MdFormatIndentDecrease,
  MdFormatIndentIncrease,
  MdFormatItalic,
  MdFormatListBulleted,
  MdFormatListNumbered,
  MdFormatQuote,
  MdFormatSize,
  MdFormatStrikethrough,
  MdHome,
  MdHorizontalRule,
  MdImage,
  MdLink,
  MdOutlineLibraryBooks,
  MdTerminal,
} from "react-icons/md";


================================================
FILE: client/fonts/LICENSE.md
================================================
# iA Writer Typeface

Copyright © 2018 Information Architects Inc. with Reserved Font Name "iA Writer"

# Based on IBM Plex Typeface

Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"

# License

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL

---

## SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007

PREAMBLE The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation efforts
of academic and linguistic communities, and to provide a free and open framework
in which fonts may be shared and improved in partnership with others.

The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The fonts,
including any derivative works, can be bundled, embedded, redistributed and/or
sold with any software provided that any reserved names are not used by
derivative works. The fonts and derivatives, however, cannot be released under
any other type of license. The requirement for fonts to remain under this
license does not apply to any document created using the fonts or their
derivatives.

DEFINITIONS "Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may include source
files, build scripts and documentation.

"Reserved Font Name" refers to any names specified as such after the copyright
statement(s).

"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).

"Modified Version" refers to any derivative made by adding to, deleting, or
substituting -- in part or in whole -- any of the components of the Original
Version, by changing formats or by porting the Font Software to a new
environment.

"Author" refers to any designer, engineer, programmer, technical writer or other
person who contributed to the Font Software.

PERMISSION & CONDITIONS Permission is hereby granted, free of charge, to any
person obtaining a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:

1. Neither the Font Software nor any of its individual components, in Original
   or Modified Versions, may be sold by itself.

2. Original or Modified Versions of the Font Software may be bundled,
   redistributed and/or sold with any software, provided that each copy contains
   the above copyright notice and this license. These can be included either as
   stand-alone text files, human-readable headers or in the appropriate
   machine-readable metadata fields within text or binary files as long as those
   fields can be easily viewed by the user.

3. No Modified Version of the Font Software may use the Reserved Font Name(s)
   unless explicit written permission is granted by the corresponding Copyright
   Holder. This restriction only applies to the primary font name as presented
   to the users.

4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software
   shall not be used to promote, endorse or advertise any Modified Version,
   except to acknowledge the contribution(s) of the Copyright Holder(s) and the
   Author(s) or with their explicit written permission.

5. The Font Software, modified or unmodified, in part or in whole, must be
   distributed entirely under this license, and must not be distributed under
   any other license. The requirement for fonts to remain under this license
   does not apply to any document created using the Font Software.

TERMINATION This license becomes null and void if any of the above conditions
are not met.

DISCLAIMER THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT
HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY
GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY
TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.


================================================
FILE: client/html/auth.html
================================================
<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <meta
            content="width=device-width, initial-scale=1, maximum-scale=1"
            name="viewport"
        />
        <base href="{{.HostPrefix}}/" />
        <link href="favicon.png" rel="icon" type="image/x-icon" />
        <title>Login to {{.SpaceName}}</title>
        <style>
            *,
            *::before,
            *::after {
                box-sizing: border-box;
            }

            * {
                padding: 0;
                margin: 0;
            }

            html {
                font-family:
                    -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
                    "Helvetica Neue", Arial, "Noto Sans", sans-serif,
                    "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
                    "Noto Color Emoji";
                --primary-button-color: #eee;
                --primary-button-background-color: #464cfc;
                --primary-button-hover-background-color: color-mix(
                    in srgb,
                    var(--primary-button-background-color),
                    black 35%
                );
                --primary-button-border-color: transparent;
                --modal-border-color: #6c6c6c;
                --top-color: #66a3bb;
                --bottom-color: #0b3a69;
                background: linear-gradient(
                    var(--top-color),
                    var(--bottom-color)
                );
            }

            header {
                position: absolute;
                padding: 1rem;
                font-size: 1.2rem;
            }

            footer {
                text-align: center;
            }

            h1 {
                font-size: 1.5rem;
                font-weight: normal;
            }

            input {
                font-family: inherit;
                font-size: inherit;
                padding: 0.5em;
            }

            form > div {
                display: flex;
                flex-direction: column;
                flex-grow: 1;
            }

            button {
                background: var(--primary-button-background-color);
                color: var(--primary-button-color);
                box-shadow: 0 0 0.2em rgba(0, 0, 0, 0.05);
                border: 1px solid var(--border-color);
                padding: 0.7em;
                border-radius: 0.5em;

                &:hover {
                    background-color: var(
                        --primary-button-hover-background-color
                    );
                }

                &:focus {
                    outline: 2px solid var(--primary-button-color);
                    outline-offset: -3px;
                }
            }

            .floating-island {
                padding: 3em;
                border-radius: 8px;
                box-shadow: rgba(0, 0, 0, 0.35) 0px 20px 20px;
                background-color: white;
                border: var(--modal-border-color) 1px solid;
            }

            .error-message {
                color: red;
            }

            .center {
                height: 100vh;
                display: grid;
                place-items: center;
            }

            .flow {
                display: flex;
                flex-direction: column;
                justify-content: flex-start;
            }

            .flow > * {
                margin-block: 0;
            }

            .flow > * + * {
                margin-block-start: var(--space, 1rem);
            }

            /* Add styles for checkbox alignment */
            .checkbox-wrapper {
                display: flex;
                align-items: center;
                gap: 0.5rem;
                flex-direction: row;
            }

            .checkbox-wrapper input[type="checkbox"] {
                margin: 0;
            }
        </style>
    </head>

    <body>
        <div class="center">
            <div class="flow floating-island">
                <h1>
                    Login to <img src=".client/logo.png" style="height: 1ch" />
                    {{.SpaceName}}
                </h1>
                <form class="flow" id="login" method="POST">
                    <div class="error-message"></div>
                    <div>
                        <label for="username">Username</label>
                        <input
                            autocapitalize="off"
                            autocomplete="off"
                            autocorrect="off"
                            autofocus
                            id="username"
                            name="username"
                            type="text"
                        />
                    </div>
                    <div>
                        <label for="password">Password</label>
                        <input id="password" name="password" type="password" />
                    </div>
                    <div class="checkbox-wrapper">
                        <input
                            id="rememberMe"
                            name="rememberMe"
                            type="checkbox"
                        />
                        <label for="rememberMe">Remember me ({{.RememberMeDays}} days)</label>
                    </div>
                    <div class="checkbox-wrapper">
                        <input
                            id="clientEncryption"
                            name="clientEncryption"
                            type="checkbox"
                        />
                        <label for="clientEncryption">Enable client encryption (e.g. when using a public computer)</label>
                    </div>
                    <div style="--space: 1.8rem">
                        <button>Login</button>
                    </div>
                </form>
                <footer>
                    <a href="https://silverbullet.md">What is SilverBullet?</a>
                </footer>
            </div>
        </div>

<script>
const workerURL = new URL("service_worker.js", document.baseURI);
console.log("Registering service worker");
navigator.serviceWorker.register(workerURL, {
  type: "module",
  scope: workerURL.pathname.substring(0, workerURL.pathname.lastIndexOf("/") + 1),
}).then(() => {
  console.log("Service worker registered");
});

document.getElementById("login").addEventListener("submit", (e) => {
  const enableEncryption = document.getElementById('clientEncryption').checked;
  const params = new URLSearchParams();
  const rememberMe = document.getElementById('rememberMe').checked;
  const username = document.getElementById('username').value;
  const password = document.getElementById('password').value;
  params.append('username', username);
  params.append('password', password);
  if (rememberMe) {
    params.append('rememberMe', 'true');
  }
  fetch('.auth', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    credentials:'include',
    body: params.toString(),
  })
  .then(async(response) => {
    const body = await response.json();
    if(body.status === "error") {
      document.querySelector(".error-message").innerText =
          body.error;
    } else {
      if(enableEncryption) {
        localStorage.setItem('enableEncryption', "true");
      } else {
        localStorage.removeItem('enableEncryption');
      }
      if(enableEncryption) {
        const salt = base64Decode({{.EncryptionSalt}});
        const key = await deriveKey(`${username}:${password}`, salt);
        // Encode password to ArrayBuffer
        if(navigator.serviceWorker?.controller) {
          navigator.serviceWorker.controller.postMessage({
            type: "set-encryption-key",
            key,
          });
        } else {
          alert("Something is not right, service worker not active");
          return;
        }
      }
      location.href = body.redirect;
    }
  })
  e.preventDefault();
});
const params = new URLSearchParams(window.location.search);
const from = params.get("from");
if(localStorage.getItem("enableEncryption")) {
  document.getElementById("clientEncryption").checked = true;
}
if (from) {
    var input = document.createElement("input");
    input.setAttribute("type", "hidden");
    input.setAttribute("name", "from");
    input.setAttribute("value", from);

    document.getElementById("login").appendChild(input);
}

async function deriveKey(phrase, salt) {
  // Encode password to ArrayBuffer
  const phraseBytes = new TextEncoder().encode(phrase);

  // Import password as a CryptoKey
  const baseKey = await crypto.subtle.importKey(
    "raw",
    phraseBytes,
    { name: "PBKDF2" },
    false,
    ["deriveBits", "deriveKey"],
  );

  // Derive CTR key
  const ctrKey = await crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt: salt,
      iterations: 100000,
      hash: "SHA-256",
    },
    baseKey,
    {
      name: "AES-CTR",
      length: 256,
    },
    true, // extractable
    ["encrypt", "decrypt"],
  );

  // Export
  const key = await crypto.subtle.exportKey("raw", ctrKey);
  // Base64 encode
  return base64Encode(new Uint8Array(key));
}

function base64Encode(buffer) {
  let binary = "";
  const len = buffer.byteLength;
  for (let i = 0; i < len; i++) {
    binary += String.fromCharCode(buffer[i]);
  }
  return btoa(binary);
}

function base64Decode(s) {
  const binString = atob(s);
  const len = binString.length;
  const bytes = new Uint8Array(len);
  for (let i = 0; i < len; i++) {
    bytes[i] = binString.charCodeAt(i);
  }
  return bytes;
}
</script>
    </body>
</html>


================================================
FILE: client/html/index.html
================================================
<!doctype html>
<html lang="en" prefix="og: https://ogp.me/ns#">
    <head>
        <meta charset="utf-8" />
        <meta
            content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=resizes-content"
            name="viewport"
        />
        <base href="{{.HostPrefix}}/" />
        <link href=".client/logo.png" rel="apple-touch-icon" />
        <meta
            content="#e1e1e1"
            media="(prefers-color-scheme: light)"
            name="theme-color"
        />
        <meta
            content="#262626"
            media="(prefers-color-scheme: dark)"
            name="theme-color"
        />
        <meta content="no-referrer" name="referrer" />

        <title>{{.Title}}</title>
        <meta content=".client/logo.png" property="og:image" />
        <meta content="{{.Description}}" property="og:description" />

        <script>
            // Some global variables we need to make this work
            Deno = {
                args: [],
                build: {
                    arch: "x86_64",
                    os: "browser",
                },
                core: {
                    runMicrotasks() {
                        // console.log("Not supported");
                    },
                    setHasTickScheduled() {
                        // console.log("Not supported");
                    },
                },
                env: {
                    get(key) {
                        // return undefined;
                    },
                },
                errors: {
                    AlreadyExists: class extends Error {},
                },
            };
        </script>
        <link href=".client/main.css" rel="stylesheet" />
        <script src=".client/client.js" type="module"></script>
        <link
            crossorigin="use-credentials"
            href=".client/manifest.json"
            rel="manifest"
        />
        <link href=".client/favicon.png" rel="icon" type="image/x-icon" />
        {{.AdditionalHeadHTML}}
    </head>

    <body>
        <div id="sb-root">
            <div id="sb-main">
                <div id="sb-editor">
                    <div class="cm-editor">
                        <div class="cm-content">{{.Content | markdown}}</div>
                    </div>
                </div>
            </div>
        </div>
        <div id="custom-styles"></div>
    </body>
</html>


================================================
FILE: client/languages.ts
================================================
import { yaml as yamlLanguage } from "@codemirror/legacy-modes/mode/yaml";
import {
  pgSQL as postgresqlLanguage,
  standardSQL as sqlLanguage,
} from "@codemirror/legacy-modes/mode/sql";
import { rust as rustLanguage } from "@codemirror/legacy-modes/mode/rust";
import { python as pythonLanguage } from "@codemirror/legacy-modes/mode/python";
import {
  protobuf as protobufLanguage,
} from "@codemirror/legacy-modes/mode/protobuf";
import { shell as shellLanguage } from "@codemirror/legacy-modes/mode/shell";
import { swift as swiftLanguage } from "@codemirror/legacy-modes/mode/swift";
import { toml as tomlLanguage } from "@codemirror/legacy-modes/mode/toml";
import { xml as xmlLanguage } from "@codemirror/legacy-modes/mode/xml";
import { json as jsonLanguage } from "@codemirror/legacy-modes/mode/javascript";
import { r as rLanguage } from "@codemirror/legacy-modes/mode/r";
import { htmlLanguage } from "@codemirror/lang-html";
import { go as goLanguage } from "@codemirror/legacy-modes/mode/go";
import { diff as diffLanguage } from "@codemirror/legacy-modes/mode/diff";
import {
  powerShell as powerShellLanguage,
} from "@codemirror/legacy-modes/mode/powershell";
import { perl as perlLanguage } from "@codemirror/legacy-modes/mode/perl";
import { ruby as rubyLanguage } from "@codemirror/legacy-modes/mode/ruby";
import { tcl as tclLanguage } from "@codemirror/legacy-modes/mode/tcl";
import {
  verilog as verilogLanguage,
} from "@codemirror/legacy-modes/mode/verilog";
import { vhdl as vhdlLanguage } from "@codemirror/legacy-modes/mode/vhdl";
import {
  dockerFile as dockerfileLanguage,
} from "@codemirror/legacy-modes/mode/dockerfile";
import { cmake as cmakeLanguage } from "@codemirror/legacy-modes/mode/cmake";
import { erlang as erlangLanguage } from "@codemirror/legacy-modes/mode/erlang";
import {
  c as cLanguage,
  cpp as cppLanguage,
  csharp as csharpLanguage,
  dart as dartLanguage,
  java as javaLanguage,
  kotlin as kotlinLanguage,
  objectiveC as objectiveCLanguage,
  objectiveCpp as objectiveCppLanguage,
  scala as scalaLanguage,
} from "@codemirror/legacy-modes/mode/clike";
import { type Language, StreamLanguage } from "@codemirror/language";
import {
  javascriptLanguage,
  typescriptLanguage,
} from "@codemirror/lang-javascript";
import { cssLanguage } from "@codemirror/lang-css";
import { nixLanguage } from "@replit/codemirror-lang-nix";
import { luaLanguage } from "./space_lua/parse.ts";

const yamlStreamLanguage = StreamLanguage.define(yamlLanguage);

export const builtinLanguages: Record<string, Language> = {
  "meta": yamlStreamLanguage,
  "yaml": yamlStreamLanguage,
  "include": yamlStreamLanguage,
  "space-config": yamlStreamLanguage,
  "data": yamlStreamLanguage,
  "toc": yamlStreamLanguage,
  "javascript": javascriptLanguage,
  "space-script": javascriptLanguage,
  "js": javascriptLanguage,
  "typescript": typescriptLanguage,
  "ts": typescriptLanguage,
  "sql": StreamLanguage.define(sqlLanguage),
  "postgresql": StreamLanguage.define(postgresqlLanguage),
  "pgsql": StreamLanguage.define(postgresqlLanguage),
  "postgres": StreamLanguage.define(postgresqlLanguage),
  "rust": StreamLanguage.define(rustLanguage),
  "rs": StreamLanguage.define(rustLanguage),
  "r": StreamLanguage.define(rLanguage),
  "css": cssLanguage,
  "space-style": cssLanguage,
  "html": htmlLanguage,
  "python": StreamLanguage.define(pythonLanguage),
  "py": StreamLanguage.define(pythonLanguage),
  "protobuf": StreamLanguage.define(protobufLanguage),
  "proto": StreamLanguage.define(protobufLanguage),
  "shell": StreamLanguage.define(shellLanguage),
  "sh": StreamLanguage.define(shellLanguage),
  "bash": StreamLanguage.define(shellLanguage),
  "zsh": StreamLanguage.define(shellLanguage),
  "fish": StreamLanguage.define(shellLanguage),
  "swift": StreamLanguage.define(swiftLanguage),
  "toml": StreamLanguage.define(tomlLanguage),
  "json": StreamLanguage.define(jsonLanguage),
  "xml": StreamLanguage.define(xmlLanguage),
  "c": StreamLanguage.define(cLanguage),
  "cpp": StreamLanguage.define(cppLanguage),
  "c++": StreamLanguage.define(cppLanguage),
  "cxx": StreamLanguage.define(cppLanguage),
  "java": StreamLanguage.define(javaLanguage),
  "csharp": StreamLanguage.define(csharpLanguage),
  "cs": StreamLanguage.define(csharpLanguage),
  "c#": StreamLanguage.define(csharpLanguage),
  "scala": StreamLanguage.define(scalaLanguage),
  "kotlin": StreamLanguage.define(kotlinLanguage),
  "objc": StreamLanguage.define(objectiveCLanguage),
  "objective-c": StreamLanguage.define(objectiveCLanguage),
  "objectivec": StreamLanguage.define(objectiveCLanguage),
  "objcpp": StreamLanguage.define(objectiveCppLanguage),
  "objective-cpp": StreamLanguage.define(objectiveCppLanguage),
  "objectivecpp": StreamLanguage.define(objectiveCppLanguage),
  "objective-c++": StreamLanguage.define(objectiveCppLanguage),
  "objectivec++": StreamLanguage.define(objectiveCppLanguage),
  "dart": StreamLanguage.define(dartLanguage),
  "go": StreamLanguage.define(goLanguage),
  "golang": StreamLanguage.define(goLanguage),
  "diff": StreamLanguage.define(diffLanguage),
  "powershell": StreamLanguage.define(powerShellLanguage),
  "perl": StreamLanguage.define(perlLanguage),
  "ruby": StreamLanguage.define(rubyLanguage),
  "tcl": StreamLanguage.define(tclLanguage),
  "verilog": StreamLanguage.define(verilogLanguage),
  "vhdl": StreamLanguage.define(vhdlLanguage),
  "dockerfile": StreamLanguage.define(dockerfileLanguage),
  "cmake": StreamLanguage.define(cmakeLanguage),
  "erlang": StreamLanguage.define(erlangLanguage),
  "nix": nixLanguage,
  "space-lua": luaLanguage,
  "lua": luaLanguage,
};

export function languageFor(name: string): Language | null {
  if (builtinLanguages[name]) {
    return builtinLanguages[name];
  }
  if (name.startsWith("#")) {
    return StreamLanguage.define(yamlLanguage);
  }
  return null;
}


================================================
FILE: client/lib/box_proxy.test.ts
================================================
import { assertEquals } from "@std/assert";
import { BoxProxy } from "./box_proxy.ts";

Deno.test("Test LateBinder - basic get functionality", () => {
  const binder = new BoxProxy({
    returnNumber: () => 1,
  });
  const p = binder.buildProxy();
  assertEquals(p.returnNumber(), 1);
  // Now swap out target
  binder.setTarget({
    returnNumber: () => 3,
  });
  assertEquals(p.returnNumber(), 3);
});

Deno.test("Test LateBinder - property setting", () => {
  const target: any = { count: 0 };
  const proxy = new BoxProxy(target).buildProxy();

  proxy.count = 5;
  assertEquals(target.count, 5);
  assertEquals(proxy.count, 5);

  proxy.newProp = "test";
  assertEquals(target.newProp, "test");
  assertEquals(proxy.newProp, "test");
});

Deno.test("Test LateBinder - has operator", () => {
  const proxy = new BoxProxy({
    existing: true,
    nested: { prop: "value" },
  }).buildProxy();

  assertEquals("existing" in proxy, true);
  assertEquals("nested" in proxy, true);
  assertEquals("nonexistent" in proxy, false);
});

Deno.test("Test LateBinder - delete property", () => {
  const target: any = {
    prop1: "value1",
    prop2: "value2",
  };
  const proxy = new BoxProxy(target).buildProxy();

  assertEquals("prop1" in proxy, true);
  delete proxy.prop1;
  assertEquals("prop1" in proxy, false);
  assertEquals("prop1" in target, false);

  assertEquals("prop2" in proxy, true);
});

Deno.test("Test LateBinder - ownKeys", () => {
  const target = {
    prop1: "value1",
    prop2: "value2",
    method: () => "result",
  };
  const proxy = new BoxProxy(target).buildProxy();

  const keys = Object.keys(proxy);
  const expectedKeys = Object.keys(target);
  assertEquals(keys.sort(), expectedKeys.sort());
});

Deno.test("Test LateBinder - with functions and methods", () => {
  const target: any = {
    multiplier: 2,
    multiply: function (x: number) {
      return x * this.multiplier;
    },
    arrow: (x: number) => x * 3,
  };

  const proxy = new BoxProxy(target).buildProxy();

  assertEquals(proxy.multiply(5), 10);
  assertEquals(proxy.arrow(4), 12);
  assertEquals(proxy.multiplier, 2);
});

Deno.test("Test LateBinder - complex object interactions", () => {
  const target: any = {
    data: { nested: { value: 42 } },
    getValue: function () {
      return this.data.nested.value;
    },
  };

  const proxy = new BoxProxy(target).buildProxy();

  assertEquals(proxy.getValue(), 42);

  proxy.data.nested.value = 100;
  assertEquals(proxy.getValue(), 100);
  assertEquals(target.data.nested.value, 100);
});

Deno.test("Test LateBinder - target switching with different interfaces", () => {
  interface Calculator {
    add(a: number, b: number): number;
  }

  const calc1: Calculator = {
    add: (a, b) => a + b,
  };

  const calc2: Calculator = {
    add: (a, b) => a + b + 1, // adds extra 1
  };

  const binder = new BoxProxy(calc1);
  const proxy = binder.buildProxy();

  assertEquals(proxy.add(2, 3), 5);

  binder.setTarget(calc2);
  assertEquals(proxy.add(2, 3), 6);
});

Deno.test("Test LateBinder - empty target", () => {
  const proxy = new BoxProxy({}).buildProxy();

  assertEquals(Object.keys(proxy).length, 0);
  assertEquals("anything" in proxy, false);

  (proxy as any).newProp = "added";
  assertEquals((proxy as any).newProp, "added");
  assertEquals("newProp" in proxy, true);
});


================================================
FILE: client/lib/box_proxy.ts
================================================
/**
 * A proxy class that enables swapping out the target object later on
 */
export class BoxProxy {
  /**
   * Initializes a new instance of the BoxProxy class.
   * @param target The initial target object
   */
  constructor(private target: any) {
  }

  /**
   * Swaps out the target object :magic:
   */
  setTarget(target: any) {
    this.target = target;
  }

  /**
   * Builds a proxy object that forwards everything we care about to the target object
   */
  buildProxy() {
    return new Proxy(this, {
      get: (_obj: any, prop) => {
        return this.target[prop];
      },
      set: (_obj: any, prop, value) => {
        this.target[prop] = value;
        return true;
      },
      has: (_obj: any, prop) => {
        return prop in this.target;
      },
      deleteProperty: (_obj: any, prop) => {
        delete this.target[prop];
        return true;
      },
      ownKeys: (_obj: any) => {
        return Object.keys(this.target);
      },
      getOwnPropertyDescriptor: (_obj: any, prop) => {
        return Object.getOwnPropertyDescriptor(this.target, prop);
      },
    });
  }
}


================================================
FILE: client/lib/fuse_search.test.ts
================================================
import type { FilterOption } from "@silverbulletmd/silverbullet/type/client";
import { assertEquals } from "@std/assert";
import { fuzzySearchAndSort } from "./fuse_search.ts";

Deno.test("testFuzzyFilter", () => {
  const array: FilterOption[] = [
    { name: "My Company/Hank", orderId: 2 },
    { name: "My Company/Hane", orderId: 1 },
    { name: "My Company/Steve Co" },
    { name: "Other/Steve" },
    { name: "Steve" },
  ];

  // Prioritize match in last path part
  let results = fuzzySearchAndSort(array, "");
  assertEquals(results.length, array.length);
  results = fuzzySearchAndSort(array, "Steve");
  assertEquals(results.length, 3);
  results = fuzzySearchAndSort(array, "Co");
  // Match in last path part
  assertEquals(results[0].name, "My Company/Steve Co");
  // Due to orderId
  assertEquals(results[1].name, "My Company/Hane");
  assertEquals(results[2].name, "My Company/Hank");
});

Deno.test("Fuzzy search edge case testing", () => {
  // Test edge case where aspiring pages (Infinity orderId) sort last without NaN
  const array: FilterOption[] = [
    { name: "Existing", orderId: 1 },
    { name: "Aspiring A", orderId: Infinity },
    { name: "Aspiring B", orderId: Infinity },
  ];
  const results = fuzzySearchAndSort(array, "");
  assertEquals(results.length, 3);
  assertEquals(results[0].name, "Existing");
  // Both aspiring pages last; relative order between them is stable (no NaN)
  assertEquals(results[1].name, "Aspiring A");
  assertEquals(results[2].name, "Aspiring B");
});


================================================
FILE: client/lib/fuse_search.ts
================================================
import Fuse from "fuse";
import type { FilterOption } from "@silverbulletmd/silverbullet/type/client";
import { fileName } from "@silverbulletmd/silverbullet/lib/resolve";

type FuseOption = FilterOption & {
  baseName: string;
};

/**
 * Compare orderIds; aspiring pages (Infinity) sort last. Avoids NaN when both are Infinity.
 * @returns -1 if a is less than b, 1 if a is greater than b, 0 if they are equal
 */
function compareOrderId(a: number | undefined, b: number | undefined): number {
  const aOrder = a ?? 0;
  const bOrder = b ?? 0;
  if (aOrder === Infinity && bOrder === Infinity) return 0;
  if (aOrder === Infinity) return 1;
  if (bOrder === Infinity) return -1;
  return aOrder - bOrder;
}

export const fuzzySearchAndSort = (
  arr: FilterOption[],
  searchPhrase: string,
): FilterOption[] => {
  if (!searchPhrase) {
    return arr.sort((a, b) => compareOrderId(a.orderId, b.orderId));
  }

  const enrichedArr: FuseOption[] = arr.map((item) => {
    return {
      ...item,
      // Only relevant for pages and or documents and not commands
      baseName: fileName(item.name),
      displayName: item?.meta?.displayName,
      aliases: item?.meta?.aliases?.join(" "),
    };
  });

  const fuse = new Fuse(enrichedArr, {
    keys: [{
      name: "name",
      weight: 2,
    }, {
      name: "baseName",
      weight: 3,
    }, {
      name: "displayName",
      weight: 2,
    }, {
      name: "aliases",
      weight: 2,
    }, {
      name: "description",
      weight: 1,
    }],
    isCaseSensitive: false,
    ignoreDiacritics: true,
    shouldSort: true,
    threshold: 0.3,
    includeScore: true,
    sortFn: (a, b): number => {
      const aItem = enrichedArr[a.idx];
      const bItem = enrichedArr[b.idx];

      // If either orderId is Infinity (aspiring page), put it last (compareOrderId avoids NaN)
      if (aItem.orderId === Infinity || bItem.orderId === Infinity) {
        return compareOrderId(aItem.orderId, bItem.orderId);
      }

      // If scores are the same, use orderId for sorting
      if (a.score === b.score) {
        const aOrder = aItem.orderId ?? 0;
        const bOrder = bItem.orderId ?? 0;
        if (aOrder !== bOrder) {
          return aOrder - bOrder;
        }
      }
      return a.score - b.score;
    },
  });

  const results = fuse.search(searchPhrase);
  const enhancedResults = results.map((r) => ({
    ...r.item,
    fuseScore: r.score,
  }));
  return enhancedResults;
};


================================================
FILE: client/lib/logger.test.ts
================================================
import { assert, assertEquals } from "@std/assert";
import { initLogger, Logger } from "./logger.ts";

Deno.test("Logger prefix functionality", () => {
  // Store original console methods
  const originalLog = console.log;
  const originalInfo = console.info;
  const originalWarn = console.warn;
  const originalError = console.error;

  const capturedOutput: string[] = [];

  // Mock console methods to capture output
  console.log = (...args: any[]) => {
    capturedOutput.push(args.join(" "));
  };
  console.info = (...args: any[]) => {
    capturedOutput.push(args.join(" "));
  };
  console.warn = (...args: any[]) => {
    capturedOutput.push(args.join(" "));
  };
  console.error = (...args: any[]) => {
    capturedOutput.push(args.join(" "));
  };

  try {
    new Logger("[TEST]");

    console.log("Hello", "world");
    console.info("Info message");
    console.warn("Warning message");
    console.error("Error message");

    assertEquals(capturedOutput[0], "[TEST] Hello world");
    assertEquals(capturedOutput[1], "[TEST] Info message");
    assertEquals(capturedOutput[2], "[TEST] Warning message");
    assertEquals(capturedOutput[3], "[TEST] Error message");
  } finally {
    // Restore original console methods
    console.log = originalLog;
    console.info = originalInfo;
    console.warn = originalWarn;
    console.error = originalError;
  }
});

Deno.test("Logger without prefix", () => {
  const originalLog = console.log;
  const capturedOutput: string[] = [];

  console.log = (...args: any[]) => {
    capturedOutput.push(args.join(" "));
  };

  try {
    new Logger("");

    console.log("Hello", "world");

    assertEquals(capturedOutput[0], "Hello world");
  } finally {
    console.log = originalLog;
  }
});

Deno.test("Logger log capture", () => {
  const logger = new Logger("[CAPTURE]");

  console.log("First message");
  console.info("Second message", { key: "value" });
  console.warn("Third message", 123);
  console.error("Fourth message");

  const capturedLogs = logger.logBuffer;

  assertEquals(capturedLogs.length, 4);

  assertEquals(capturedLogs[0].level, "log");
  assertEquals(capturedLogs[0].message, "First message");

  assertEquals(capturedLogs[1].level, "info");
  assertEquals(capturedLogs[1].message, 'Second message {"key":"value"}');

  assertEquals(capturedLogs[2].level, "warn");
  assertEquals(capturedLogs[2].message, "Third message 123");

  assertEquals(capturedLogs[3].level, "error");
  assertEquals(capturedLogs[3].message, "Fourth message");

  // Check that all entries have timestamps
  capturedLogs.forEach((entry) => {
    assert(typeof entry.timestamp === "number");
    assert(entry.timestamp > 0);
  });
});

Deno.test("Logger max capture size", () => {
  const logger = new Logger("[SIZE]", 3);

  // Add more logs than the max size
  console.log("Message 1");
  console.log("Message 2");
  console.log("Message 3");
  console.log("Message 4");
  console.log("Message 5");

  const capturedLogs = logger.logBuffer;

  // Should only keep the last 3 messages
  assertEquals(capturedLogs.length, 3);
  assertEquals(capturedLogs[0].message, "Message 3");
  assertEquals(capturedLogs[1].message, "Message 4");
  assertEquals(capturedLogs[2].message, "Message 5");
});

Deno.test("Global logger initialization", () => {
  const originalLog = console.log;
  const capturedOutput: string[] = [];

  console.log = (...args: any[]) => {
    capturedOutput.push(args.join(" "));
  };

  try {
    const logger = initLogger("[GLOBAL]");

    console.log("Global test");

    assertEquals(capturedOutput[0], "[GLOBAL] Global test");
    assert(logger instanceof Logger);
  } finally {
    console.log = originalLog;
  }
});

Deno.test("Logger handles complex objects", () => {
  const logger = new Logger("[COMPLEX]");

  const complexObject = {
    name: "test",
    nested: { value: 42 },
    array: [1, 2, 3],
  };

  const circularObject: any = { name: "circular" };
  circularObject.self = circularObject;

  console.log("Complex object:", complexObject);
  console.log("Circular object:", circularObject);

  const capturedLogs = logger.logBuffer;

  assertEquals(capturedLogs.length, 2);

  // First log should handle complex object properly
  assertEquals(
    capturedLogs[0].message,
    `Complex object: {"name":"test","nested":{"value":42},"array":[1,2,3]}`,
  );

  // Second log should handle circular reference gracefully
  assertEquals(capturedLogs[1].message, `Circular object: [object Object]`);
});


================================================
FILE: client/lib/logger.ts
================================================
// Logger that monkey patches console methods with prefixes and can capture logs for server transmission

export interface LogEntry {
  level: "log" | "info" | "warn" | "error" | "debug";
  timestamp: number;
  message: string;
}

export class Logger {
  private originalConsole: {
    log: typeof console.log;
    info: typeof console.info;
    warn: typeof console.warn;
    error: typeof console.error;
    debug: typeof console.debug;
  };
  public logBuffer: LogEntry[] = [];

  constructor(
    private prefix: string = "",
    private maxCaptureSize: number = 1000,
  ) {
    this.prefix = prefix;

    // Store original console methods
    this.originalConsole = {
      log: console.log.bind(console),
      info: console.info.bind(console),
      warn: console.warn.bind(console),
      error: console.error.bind(console),
      debug: console.debug.bind(console),
    };

    this.patchConsole();
  }

  private patchConsole(): void {
    const createPatchedMethod = (level: keyof typeof this.originalConsole) => {
      return (...args: any[]) => {
        const prefixedArgs = this.prefix ? [this.prefix, ...args] : args;

        // Call original console method
        this.originalConsole[level](...prefixedArgs);

        // Capture log if capturing is enabled
        this.captureLog(level, args);
      };
    };

    console.log = createPatchedMethod("log");
    console.info = createPatchedMethod("info");
    console.warn = createPatchedMethod("warn");
    console.error = createPatchedMethod("error");
    console.debug = createPatchedMethod("debug");
  }

  private captureLog(level: LogEntry["level"], args: any[]): void {
    const entry: LogEntry = {
      level,
      timestamp: Date.now(),
      message: args.map((arg) => {
        if (typeof arg === "string") {
          return arg;
        }
        try {
          return JSON.stringify(arg);
        } catch {
          // Handle circular references or other JSON.stringify failures
          return String(arg);
        }
      }).join(" "),
    };

    this.logBuffer.push(entry);

    // Maintain max capture size by removing oldest entries
    if (this.logBuffer.length > this.maxCaptureSize) {
      this.logBuffer.shift();
    }
  }

  /**
   * Posts all buffered logs to a server endpoint
   */
  async postToServer(logEndpoint: string, source: string) {
    const logs = this.logBuffer;
    if (logs.length > 0) {
      // Flush the buffer
      const logCopy = [...this.logBuffer];
      this.logBuffer = [];
      try {
        const resp = await fetch(logEndpoint, {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify(logCopy.map((entry) => ({ ...entry, source }))),
        });
        if (!resp.ok) {
          throw new Error("Failed to post logs to server");
        }
      } catch (e: any) {
        console.warn("Could not post logs to server", e.message);
        // Put back the logs into the buffer
        this.logBuffer.unshift(...logCopy);
      }
    }
  }
}

// Global logger instance
let globalLogger: Logger | undefined = undefined;

export function initLogger(prefix: string = ""): Logger {
  globalLogger = new Logger(prefix);
  return globalLogger;
}


================================================
FILE: client/lib/polyfills.ts
================================================
// Source: https://github.com/ungap/with-resolvers/blob/main/index.js
// @ts-ignore This is fine
Promise.withResolvers || (Promise.withResolvers = function withResolvers() {
  let a, b;
  const c = new this(function (resolve, reject) {
    a = resolve;
    b = reject;
  });
  return { resolve: a, reject: b, promise: c };
});


================================================
FILE: client/lib/url_prefix.test.ts
================================================
import { assertEquals } from "@std/assert";
import { applyUrlPrefix, removeUrlPrefix } from "./url_prefix.ts";

Deno.test("url_prefix - removeUrlPrefix - with value", async (t) => {
  await t.step("Absolute URL, present, should be removed", () => {
    assertEquals(
      removeUrlPrefix("http://myserver/sb/relevant", "/sb"),
      "http://myserver/relevant",
    );
    assertEquals(
      removeUrlPrefix("https://myserver/sb/relevant", "/sb"),
      "https://myserver/relevant",
    );
  });

  await t.step("Absolute URL, present, should only remove leading", () => {
    assertEquals(
      removeUrlPrefix("http://myserver/sb/sb/relevant/sb", "/sb"),
      "http://myserver/sb/relevant/sb",
    );
    assertEquals(
      removeUrlPrefix("http://myserver/relevant/sb", "/sb"),
      "http://myserver/relevant/sb",
    );
  });

  await t.step("Absolute URL, absent, should be untouched", () => {
    assertEquals(
      removeUrlPrefix("http://myserver/other/relevant", "/sb"),
      "http://myserver/other/relevant",
    );
    assertEquals(
      removeUrlPrefix("https://myserver/other/relevant", "/sb"),
      "https://myserver/other/relevant",
    );
  });

  await t.step("Absolute URL, queryString, should be preserved", () => {
    assertEquals(
      removeUrlPrefix("http://myserver/sb/sb/relevant/sb?param=arg", "/sb"),
      "http://myserver/sb/relevant/sb?param=arg",
    );
  });

  await t.step("Absolute URL, unsupported, should be untouched", () => {
    assertEquals(
      removeUrlPrefix("ftp://myserver/sb/relevant", "/sb"),
      "ftp://myserver/sb/relevant",
    );
  });

  await t.step("Host-Relative URL, present, should be removed", () => {
    assertEquals(removeUrlPrefix("/sb/relevant", "/sb"), "/relevant");
  });

  await t.step("Host-Relative URL, present, should only remove leading", () => {
    assertEquals(
      removeUrlPrefix("/sb/sb/relevant/sb", "/sb"),
      "/sb/relevant/sb",
    );
    assertEquals(removeUrlPrefix("/relevant/sb", "/sb"), "/relevant/sb");
  });

  await t.step("Host-Relative URL, queryString, should be preserved", () => {
    assertEquals(
      removeUrlPrefix("/sb/sb/relevant/sb?param=arg", "/sb"),
      "/sb/relevant/sb?param=arg",
    );
    assertEquals(
      removeUrlPrefix("/relevant/sb?param=arg", "/sb"),
      "/relevant/sb?param=arg",
    );
  });

  await t.step("Host-Relative URL, absent, should be untouched", () => {
    assertEquals(removeUrlPrefix("/other/relevant", "/sb"), "/other/relevant");
  });

  await t.step("Page-Relative URL, should be untouched", () => {
    assertEquals(removeUrlPrefix("sb/relevant", "/sb"), "sb/relevant");
  });
});

Deno.test("url_prefix - removeUrlPrefix - no value", async (t) => {
  await t.step("Absolute URL, should be untouched", () => {
    assertEquals(
      removeUrlPrefix("http://myserver/sb/relevant", ""),
      "http://myserver/sb/relevant",
    );
    assertEquals(
      removeUrlPrefix("https://myserver/sb/relevant"),
      "https://myserver/sb/relevant",
    );
  });

  await t.step("Host-Relative URL, should be untouched", () => {
    assertEquals(removeUrlPrefix("/sb/relevant", ""), "/sb/relevant");
    assertEquals(removeUrlPrefix("/sb/relevant"), "/sb/relevant");
  });

  await t.step("Page-Relative URL, should be untouched", () => {
    assertEquals(removeUrlPrefix("sb/relevant", ""), "sb/relevant");
    assertEquals(removeUrlPrefix("sb/relevant"), "sb/relevant");
  });
});

Deno.test("url_prefix - applyUrlPrefix - with value", async (t) => {
  await t.step("string, Absolute URL, should be prefixed", () => {
    assertEquals(
      applyUrlPrefix("http://myserver/relevant", "/sb"),
      "http://myserver/sb/relevant",
    );
    assertEquals(
      applyUrlPrefix("https://myserver/relevant", "/sb"),
      "https://myserver/sb/relevant",
    );
  });

  await t.step("string, Absolute URL, should not care about dups", () => {
    assertEquals(
      applyUrlPrefix("http://myserver/sb/relevant/sb", "/sb"),
      "http://myserver/sb/sb/relevant/sb",
    );
  });

  await t.step("string, Absolute URL, queryString should be preserved", () => {
    assertEquals(
      applyUrlPrefix("http://myserver/sb/relevant/sb?param=arg", "/sb"),
      "http://myserver/sb/sb/relevant/sb?param=arg",
    );
  });

  await t.step("string, Absolute URL, unsupported, should be untouched", () => {
    assertEquals(
      applyUrlPrefix("ftp://myserver/relevant", "/sb"),
      "ftp://myserver/relevant",
    );
  });

  await t.step("string, Host-Relative URL, should be prefixed", () => {
    assertEquals(applyUrlPrefix("/relevant", "/sb"), "/sb/relevant");
  });

  await t.step("string, Host-Relative URL, should not care about dups", () => {
    assertEquals(
      applyUrlPrefix("/sb/relevant/sb", "/sb"),
      "/sb/sb/relevant/sb",
    );
  });

  await t.step(
    "string, Host-Relative URL, queryString should be preserved",
    () => {
      assertEquals(
        applyUrlPrefix("/sb/relevant/sb?param=arg", "/sb"),
        "/sb/sb/relevant/sb?param=arg",
      );
    },
  );

  await t.step("string, Page-Relative URL, should be untouched", () => {
    assertEquals(applyUrlPrefix("relevant", "/sb"), "relevant");
  });

  await t.step("URL object, Absolute URL, should be prefixed", () => {
    assertEquals(
      applyUrlPrefix(new URL("http://myserver/relevant"), "/sb"),
      new URL("http://myserver/sb/relevant"),
    );
  });

  await t.step(
    "URL object, Absolute URL, queryString should be preserved",
    () => {
      assertEquals(
        applyUrlPrefix(new URL("http://myserver/relevant?param=arg"), "/sb"),
        new URL("http://myserver/sb/relevant?param=arg"),
      );
    },
  );
});

Deno.test("url_prefix - applyUrlPrefix - no value", async (t) => {
  await t.step("Absolute URL, should be untouched", () => {
    assertEquals(
      applyUrlPrefix("http://myserver/relevant", ""),
      "http://myserver/relevant",
    );
    assertEquals(
      applyUrlPrefix("https://myserver/relevant"),
      "https://myserver/relevant",
    );
  });

  await t.step("Host-Relative URL, should be untouched", () => {
    assertEquals(applyUrlPrefix("/relevant", ""), "/relevant");
    assertEquals(applyUrlPrefix("/relevant"), "/relevant");
  });

  await t.step("Page-Relative URL, should be untouched", () => {
    assertEquals(applyUrlPrefix("relevant", ""), "relevant");
    assertEquals(applyUrlPrefix("relevant"), "relevant");
  });
});


================================================
FILE: client/lib/url_prefix.ts
================================================
export function removeUrlPrefix(url: string, prefix?: string): string {
  if (!prefix || prefix === "") return url;

  if (url.startsWith("http://") || url.startsWith("https://")) {
    const parsedUrl = new URL(url);
    if (parsedUrl.pathname.startsWith(prefix)) {
      parsedUrl.pathname = parsedUrl.pathname.substring(
        prefix.length,
      );
      return parsedUrl.href;
    } else {
      return url;
    }
  } else if (url.startsWith(prefix)) {
    return url.substring(prefix.length);
  } else {
    return url;
  }
}

export function applyUrlPrefix<T extends (string | URL)>(
  url: T,
  prefix?: string,
): T {
  if (!prefix || prefix === "") return url;

  if (typeof url === "string") {
    const urlString = url as string;

    if (urlString.startsWith("http://") || urlString.startsWith("https://")) {
      return applyUrlPrefix(new URL(urlString), prefix).href as T;
    } else if (urlString.startsWith("/")) {
      return (prefix + urlString) as T;
    } else {
      return url; //return page-relative paths as-is
    }
  } else if (url.protocol === "http:" || url.protocol === "https:") {
    const urlObj = new URL(url);
    urlObj.pathname = prefix + urlObj.pathname;
    return urlObj as T;
  } else {
    return url;
  }
}


================================================
FILE: client/lib/util.ts
================================================
import type { FileMeta } from "@silverbulletmd/silverbullet/type/index";

export function utcDateString(mtime: number): string {
  return new Date(mtime).toUTCString();
}

export function authCookieName(host: string) {
  return `auth_${host.replaceAll(/\W/g, "_")}`;
}

export function fileMetaToHeaders(fileMeta: FileMeta) {
  return {
    "Content-Type": fileMeta.contentType,
    "X-Last-Modified": "" + fileMeta.lastModified,
    "X-Created": "" + fileMeta.created,
    "Cache-Control": "no-cache",
    "X-Permission": fileMeta.perm,
    "X-Content-Length": "" + fileMeta.size,
  };
}

export function headersToFileMeta(
  name: string,
  headers: Headers,
): FileMeta | undefined {
  if (headers.has("X-Last-Modified")) {
    // If this header is set, we need to pull out the rest also
    return {
      name,
      // The server may set a custom X-Content-Length header in case a GET request was sent with X-Get-Meta, in which case the body may be omitted
      size: headers.has("X-Content-Length")
        ? +headers.get("X-Content-Length")!
        : +headers.get("Content-Length")!,
      contentType: headers.get("Content-type")!,
      created: +(headers.get("X-Created") || "0"),
      lastModified: +(headers.get("X-Last-Modified") || "0"),
      perm: (headers.get("X-Permission") as "rw" | "ro") || "ro",
    };
  } else {
    // Otherwise: no file meta in headers
    return undefined;
  }
}


================================================
FILE: client/markdown_parser/constants.ts
================================================
export const wikiLinkRegex =
  /(?<leadingTrivia>!?\[\[)(?<stringRef>.*?)(?:\|(?<alias>.*?))?(?<trailingTrivia>\]\])/g;
export const mdLinkRegex = /!?\[(?<title>[^\]]*)\]\((?<url>.+)\)/g;
export const tagRegex =
  /#(?:(?:\d*[^\d\s!@#$%^&*(),.?":{}|<>\\][^\s!@#$%^&*(),.?":{}|<>\\]*)|(?:<[^>\n]+>))/;
export const nakedUrlRegex =
  /(^https?:\/\/([-a-zA-Z0-9@:%_\+~#=]|(?:[.](?!(\s|$)))){1,256})(([-a-zA-Z0-9(@:%_\+~#?&=\/]|(?:[.,:;)](?!(\s|$))))*)/;
export const frontmatterQuotesRegex = /["'].*["']/g;
export const frontmatterUrlRegex = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s"']+)/g;
export const frontmatterWikiLinkRegex =
  /(?<leadingTrivia>!?\[\[)(?<stringRef>.*?)(?:\|(?<alias>.*?))?(?<trailingTrivia>\]\])/g;
export const frontmatterMailtoRegex = /(mailto:[^@\s]+@[^@\s"']+)/ig;
export const pWikiLinkRegex = new RegExp("^" + wikiLinkRegex.source); // Modified regex used only in parser


================================================
FILE: client/markdown_parser/customtags.ts
================================================
import { Tag } from "@lezer/highlight";

export const WikiLinkTag = Tag.define();
export const WikiLinkPartTag = Tag.define();
export const CodeInfoTag = Tag.define();
export const CommentTag = Tag.define();
export const CommentMarkerTag = Tag.define();
export const BulletList = Tag.define();
export const OrderedList = Tag.define();
export const Highlight = Tag.define();
export const HorizontalRuleTag = Tag.define();

export const AttributeTag = Tag.define();
export const AttributeNameTag = Tag.define();
export const AttributeValueTag = Tag.define();

export const NamedAnchorTag = Tag.define();

export const TaskTag = Tag.define();
export const TaskMarkTag = Tag.define();
export const TaskStateTag = Tag.define();
export const TaskDeadlineTag = Tag.define();

export const HashtagTag = Tag.define();
export const NakedURLTag = Tag.define();

export const DirectiveMarkTag = Tag.define();
export const DirectiveTag = Tag.define();

export const SubscriptTag = Tag.define();
export const SuperscriptTag = Tag.define();


================================================
FILE: client/markdown_parser/extended_task.ts
================================================
import type {
  BlockContext,
  LeafBlock,
  LeafBlockParser,
  MarkdownConfig,
} from "@lezer/markdown";

import { tags as t } from "@lezer/highlight";
import { TaskStateTag } from "./customtags.ts";

// Taken from https://github.com/lezer-parser/markdown/blob/main/src/extension.ts and adapted

class MultiStatusTaskParser implements LeafBlockParser {
  constructor(private status: string) {
  }

  nextLine() {
    return false;
  }

  finish(cx: BlockContext, leaf: LeafBlock) {
    cx.addLeafElement(
      leaf,
      cx.elt("Task", leaf.start, leaf.start + leaf.content.length, [
        cx.elt("TaskState", leaf.start, leaf.start + 2 + this.status.length, [
          cx.elt("TaskMark", leaf.start, leaf.start + 1),
          cx.elt(
            "TaskMark",
            leaf.start + 1 + this.status.length,
            leaf.start + 2 + this.status.length,
          ),
        ]),
        ...cx.parser.parseInline(
          leaf.content.slice(this.status.length + 2),
          leaf.start + this.status.length + 2,
        ),
      ]),
    );
    return true;
  }
}

export const TaskList: MarkdownConfig = {
  defineNodes: [
    { name: "Task", block: true, style: t.list },
    { name: "TaskMark", style: t.atom },
    { name: "TaskState", style: TaskStateTag },
  ],
  parseBlock: [{
    name: "TaskList",
    leaf(cx, leaf) {
      // Note: task states cannot contain : to avoid ambiguity with attribute syntax
      const match = /^\[([^\]:]+)\][ \t]/.exec(leaf.content);
      return match &&
          cx.parentType().name == "ListItem"
        ? new MultiStatusTaskParser(match[1])
        : null;
    },
    after: "SetextHeading",
  }],
};


================================================
FILE: client/markdown_parser/parse_tree.ts
================================================
import type { ParseTree } from "../../plug-api/lib/tree.ts";
import type { SyntaxNode } from "@lezer/common";
import type { Language } from "@codemirror/language";

export function lezerToParseTree(
  text: string,
  n: SyntaxNode,
  offset = 0,
): ParseTree {
  let children: ParseTree[] = [];
  let nodeText: string | undefined;
  let child = n.firstChild;
  while (child) {
    children.push(lezerToParseTree(text, child, offset));
    child = child.nextSibling;
  }

  if (children.length === 0) {
    children = [
      {
        from: n.from + offset,
        to: n.to + offset,
        text: text.substring(n.from, n.to),
      },
    ];
  } else {
    const newChildren: ParseTree[] = [];
    let startIndex = n.from + offset;
    for (const child of children) {
      // indices are shifted with offset, so first need to shift back to get text
      const s = text.substring(startIndex - offset, child.from! - offset);
      if (s) {
        newChildren.push({
          from: startIndex,
          to: child.from!,
          text: s,
        });
      }
      newChildren.push(child);
      startIndex = child.to!;
    }
    const s = text.substring(startIndex - offset, n.to);
    if (s) {
      newChildren.push({
        from: startIndex,
        to: n.to + offset,
        text: s,
      });
    }
    children = newChildren;
  }

  const result: ParseTree = {
    type: n.name,
    from: n.from + offset,
    to: n.to + offset,
  };
  if (children.length > 0) {
    result.children = children;
  }
  if (nodeText) {
    result.text = nodeText;
  }
  return result;
}

export function parse(
  language: Language,
  text: string,
  offset?: number,
): ParseTree {
  // Remove \r for Windows before parsing
  text = text.replaceAll("\r", "");
  const tree = lezerToParseTree(
    text,
    language.parser.parse(text).topNode,
    offset,
  );
  return tree;
}


================================================
FILE: client/markdown_parser/parser.test.ts
================================================
import {
  collectNodesOfType,
  findNodeOfType,
  renderToText,
} from "@silverbulletmd/silverbullet/lib/tree";
import { assertEquals, assertNotEquals } from "@std/assert";
import { parseMarkdown } from "./parser.ts";
import { extractHashtag } from "../../plug-api/lib/tags.ts";
import { renderHashtag } from "../../plugs/index/tags.ts";

const sample1 = `---
type: page
tags:
- hello
- world
---
# This is a doc

Here is a [[wiki link]] and a [[wiki link|alias]].

Supper`;

const sampleInvalid1 = `---
name: Zef
# This is a doc

Supper`;

Deno.test("Test parser", () => {
  let tree = parseMarkdown(sample1);
  // console.log("tree", JSON.stringify(tree, null, 2));
  // Check if rendering back to text works
  assertEquals(renderToText(tree), sample1);

  const tree2 = parseMarkdown(sample1, 3);
  // console.log("tree", JSON.stringify(tree, null, 2));
  // Check if rendering back to text works
  assertEquals(renderToText(tree2), sample1);

  // Find wiki link and wiki link alias
  const links = collectNodesOfType(tree, "WikiLink");
  assertEquals(links.length, 2);
  const nameNode = findNodeOfType(links[0], "WikiLinkPage");
  assertEquals(nameNode!.children![0].text, "wiki link");

  // Check if alias is parsed properly
  const aliasNode = findNodeOfType(links[1], "WikiLinkAlias");
  assertEquals(aliasNode!.children![0].text, "alias");

  // Find frontmatter
  let node = findNodeOfType(tree, "FrontMatter");
  assertNotEquals(node, undefined);
  tree = parseMarkdown(sampleInvalid1);
  node = findNodeOfType(tree, "FrontMatter");
  // console.log("Invalid node", node);
  assertEquals(node, undefined);
});

const inlineAttributeSample = `
Hello there [a link](http://zef.plus)
[age: 100]
[age:: 200]

Here's a more [ambiguous: case](http://zef.plus)

And one with nested brackets: [array: [1, 2, 3]]
`;

Deno.test("Test inline attribute syntax", () => {
  const tree = parseMarkdown(inlineAttributeSample);
  // console.log("Attribute parsed", JSON.stringify(tree, null, 2));
  const attributes = collectNodesOfType(tree, "Attribute");
  let nameNode = findNodeOfType(attributes[0], "AttributeName");
  assertEquals(nameNode!.children![0].text, "age");
  let valueNode = findNodeOfType(attributes[0], "AttributeValue");
  assertEquals(valueNode!.children![0].text, "100");

  nameNode = findNodeOfType(attributes[1], "AttributeName");
  assertEquals(nameNode!.children![0].text, "age");
  valueNode = findNodeOfType(attributes[1], "AttributeValue");
  assertEquals(valueNode!.children![0].text, "200");

  nameNode = findNodeOfType(attributes[2], "AttributeName");
  assertEquals(nameNode!.children![0].text, "array");
  valueNode = findNodeOfType(attributes[2], "AttributeValue");
  assertEquals(valueNode!.children![0].text, "[1, 2, 3]");
});

const multiStatusTaskExample = `
* [ ] Task 1
- [x] Task 2
* [TODO] Task 3
`;

Deno.test("Test multi-status tasks", () => {
  const tree = parseMarkdown(multiStatusTaskExample);
  // console.log("Tasks parsed", JSON.stringify(tree, null, 2));
  const tasks = collectNodesOfType(tree, "Task");
  assertEquals(tasks.length, 3);
  // Check " " checkbox state parsing
  assertEquals(tasks[0].children![0].children![1].text, " ");
  assertEquals(tasks[1].children![0].children![1].text, "x");
  assertEquals(tasks[2].children![0].children![1].text, "TODO");
});

Deno.test("Test lua directive parser", () => {
  const simpleExample = `Simple \${query_coll("something")}`;
  console.log(JSON.stringify(parseMarkdown(simpleExample), null, 2));
});

const hashtagSample = `
Hashtags, e.g. #mytag but ignore in code \`#mytag\`.
They can contain slashes like #level/beginner, single quotes, and dashes: #Mike's-idea.
Can be just #a single letter.
But no other #interpunction: #exclamation! #question?
There is a way to write #<tag with spaces>
These cannot span #<multiple
lines>
#no#spacing also works.
Hashtags can start with number if there's something after it: #3dprint #15-52_Trip-to-NYC.
But magazine issue #1 or #123 are not hashtags.
Should support other languages, like #żółć or #井号
`;

Deno.test("Test hashtag parser", () => {
  const tree = parseMarkdown(hashtagSample);
  const hashtags = collectNodesOfType(tree, "Hashtag");
  assertEquals(hashtags.length, 14);

  assertEquals(hashtags[0].children![0].text, "#mytag");
  assertEquals(hashtags[1].children![0].text, "#level/beginner");
  assertEquals(hashtags[2].children![0].text, "#Mike's-idea");
  assertEquals(hashtags[3].children![0].text, "#a");
  assertEquals(hashtags[4].children![0].text, "#interpunction");
  assertEquals(hashtags[5].children![0].text, "#exclamation");
  assertEquals(hashtags[6].children![0].text, "#question");
  assertEquals(hashtags[7].children![0].text, "#<tag with spaces>");
  // multiple lines not allowed
  assertEquals(hashtags[8].children![0].text, "#no");
  assertEquals(hashtags[9].children![0].text, "#spacing");
  assertEquals(hashtags[10].children![0].text, "#3dprint");
  assertEquals(hashtags[11].children![0].text, "#15-52_Trip-to-NYC");
  assertEquals(hashtags[12].children![0].text, "#żółć");
  assertEquals(hashtags[13].children![0].text, "#井号");
});

Deno.test("Test hashtag helper functions", () => {
  assertEquals(extractHashtag("#name"), "name");
  assertEquals(extractHashtag("#123-content"), "123-content");
  assertEquals(extractHashtag("#<escaped tag>"), "escaped tag");
  assertEquals(
    extractHashtag("#<allow < and # inside>"),
    "allow < and # inside",
  );

  assertEquals(renderHashtag("simple"), "#simple");
  assertEquals(renderHashtag("123-content"), "#123-content");
  assertEquals(renderHashtag("with spaces"), "#<with spaces>");
  assertEquals(renderHashtag("single'quote"), "#single'quote");
  // should behave like this for all characters in tagRegex
  assertEquals(renderHashtag("exclamation!"), "#<exclamation!>");
});

const nakedURLSample = `
http://abc.com is a URL
Also http://no-trailing-period.com. That's a URL. It ends with m, not '.'.
http://no-trailing-comma.com, that same a URL, ends with m (and not ',').
http://trailing-slash.com/. That ends with '/' (still not '.').
http://abc.com?e=2.71,pi=3.14 is a URL too.
http://abc.com?e=2.71. That is a URL, which ends with 1 (and not '.').
`;

Deno.test("Test NakedURL parser", () => {
  const tree = parseMarkdown(nakedURLSample);
  const urls = collectNodesOfType(tree, "NakedURL");

  assertEquals(urls.map((x) => x.children![0].text), [
    "http://abc.com",
    "http://no-trailing-period.com",
    "http://no-trailing-comma.com",
    "http://trailing-slash.com/",
    "http://abc.com?e=2.71,pi=3.14",
    "http://abc.com?e=2.71",
  ]);
});

const tableSample = `
| Header A | Header B |
|----------|----------|
| [[Wiki|Alias]] | 1B |
| 2A             | 2B |
`;

Deno.test("Test table parser", () => {
  const tree = parseMarkdown(tableSample);
  const cells = collectNodesOfType(tree, "TableCell");

  assertEquals(cells.map((x) => x.children![0].text), [
    "Header A",
    "Header B",
    undefined,
    "1B",
    "2A",
    "2B",
  ]);

  // Check the Wiki Link - Make sure no backslash has been added (issue 943)
  assertEquals(cells[2].children![0].type, "WikiLink");
  const wikiName = findNodeOfType(cells[2], "WikiLinkPage");
  const wikiAlias = findNodeOfType(cells[2], "WikiLinkAlias");
  assertEquals(wikiName!.children![0].text, "Wiki");
  assertEquals(wikiAlias!.children![0].text, "Alias");
});


================================================
FILE: client/markdown_parser/parser.ts
================================================
import { yaml as yamlLanguage } from "@codemirror/legacy-modes/mode/yaml";
import { styleTags, tags as t } from "@lezer/highlight";
import {
  type Line,
  type MarkdownConfig,
  Strikethrough,
  Subscript,
  Superscript,
} from "@lezer/markdown";
import { markdown } from "@codemirror/lang-markdown";
import { foldNodeProp, StreamLanguage } from "@codemirror/language";
import * as ct from "./customtags.ts";
import { TaskList } from "./extended_task.ts";
import { Table } from "./table_parser.ts";
import { nakedUrlRegex, pWikiLinkRegex, tagRegex } from "./constants.ts";
import { parse } from "./parse_tree.ts";
import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree";
import { luaLanguage } from "../space_lua/parse.ts";

const WikiLink: MarkdownConfig = {
  defineNodes: [
    { name: "WikiLink" },
    { name: "WikiLinkPage", style: ct.WikiLinkPartTag },
    { name: "WikiLinkAlias", style: ct.WikiLinkPartTag },
    { name: "WikiLinkDimensions", style: ct.WikiLinkPartTag },
    { name: "WikiLinkMark", style: t.processingInstruction },
  ],
  parseInline: [
    {
      name: "WikiLink",
      parse(cx, next, pos) {
        // Do a preliminary check for performance
        if (next != 91 /* '[' */ && next != 33 /* '!' */) {
          return -1;
        }

        pWikiLinkRegex.lastIndex = 0;
        const match = pWikiLinkRegex.exec(cx.slice(pos, cx.end));
        if (!match || !match.groups) {
          return -1;
        }

        //const [fullMatch, firstMark, page, alias, _lastMark] = match;
        const { leadingTrivia, stringRef, alias } = match.groups;
        const endPos = pos + match[0].length;
        let aliasElts: any[] = [];
        if (alias) {
          const pipeStartPos = pos + leadingTrivia.length + stringRef.length;
          aliasElts = [
            cx.elt("WikiLinkMark", pipeStartPos, pipeStartPos + 1),
            cx.elt(
              "WikiLinkAlias",
              pipeStartPos + 1,
              pipeStartPos + 1 + alias.length,
            ),
          ];
        }

        let allElts = cx.elt("WikiLink", pos, endPos, [
          cx.elt("WikiLinkMark", pos, pos + leadingTrivia.length),
          cx.elt(
            "WikiLinkPage",
            pos + leadingTrivia.length,
            pos + leadingTrivia.length + stringRef.length,
          ),
          ...aliasElts,
          cx.elt("WikiLinkMark", endPos - 2, endPos),
        ]);

        // If inline image
        if (next == 33) {
          allElts = cx.elt("Image", pos, endPos, [allElts]);
        }

        return cx.addElement(allElts);
      },
      after: "Emphasis",
    },
  ],
};

const LuaDirectives: MarkdownConfig = {
  defineNodes: [
    { name: "LuaDirective" },
    { name: "LuaExpressionDirective" },
    { name: "LuaDirectiveMark", style: ct.DirectiveMarkTag },
  ],
  parseInline: [
    {
      name: "LuaDirective",
      parse(cx, next, pos) {
        const textFromPos = cx.slice(pos, cx.end);
        if (
          next !== 36 /* '$' */ ||
          cx.slice(pos, pos + 2) !== "${"
        ) {
          return -1;
        }

        let bracketNestingDepth = 0;
        let valueLength = 0;
        // We need to ensure balanced { and } pairs
        loopLabel:
        for (; valueLength < textFromPos.length; valueLength++) {
          switch (textFromPos[valueLength]) {
            case "{":
              bracketNestingDepth++;
              break;
            case "}":
              bracketNestingDepth--;
              if (bracketNestingDepth === 0) {
                // Done!
                break loopLabel;
              }
              break;
          }
        }
        if (bracketNestingDepth !== 0) {
          return -1;
        }

        const bodyText = textFromPos.slice(2, valueLength);
        const endPos = pos + valueLength + 1;

        // Let's parse as an expression
        const parsedExpression = luaLanguage.parser.parse(`_(${bodyText})`);

        // If bodyText starts with whitespace, we need to offset this later
        const whiteSpaceOffset = bodyText.match(/^\s*/)?.[0].length ?? 0;

        const node = parsedExpression.resolveInner(2, 0).firstChild?.nextSibling
          ?.nextSibling;

        if (!node) {
          return -1;
        }
        const bodyEl = cx.elt(
          "LuaExpressionDirective",
          pos + 2,
          endPos - 1,
          [cx.elt(node.toTree()!, pos + 2 + whiteSpaceOffset)],
        );

        return cx.addElement(
          cx.elt("LuaDirective", pos, endPos, [
            cx.elt("LuaDirectiveMark", pos, pos + 2),
            bodyEl,
            cx.elt("LuaDirectiveMark", endPos - 1, endPos),
          ]),
        );
      },
      after: "Emphasis",
    },
  ],
};

const HighlightDelim = { resolve: "Highlight", mark: "HighlightMark" };

export const Highlight: MarkdownConfig = {
  defineNodes: [
    {
      name: "Highlight",
      style: { "Highlight/...": ct.Highlight },
    },
    {
      name: "HighlightMark",
      style: t.processingInstruction,
    },
  ],
  parseInline: [
    {
      name: "Highlight",
      parse(cx, next, pos) {
        if (next != 61 /* '=' */ || cx.char(pos + 1) != 61) return -1;
        return cx.addDelimiter(HighlightDelim, pos, pos + 2, true, true);
      },
      after: "Emphasis",
    },
  ],
};

export const attributeStartRegex = /^\[([\w\$]+)(::?\s*)/;

export const Attribute: MarkdownConfig = {
  defineNodes: [
    { name: "Attribute", style: { "Attribute/...": ct.AttributeTag } },
    { name: "AttributeName", style: ct.AttributeNameTag },
    { name: "AttributeValue", style: ct.AttributeValueTag },
    { name: "AttributeMark", style: t.processingInstruction },
    { name: "AttributeColon", style: t.processingInstruction },
  ],
  parseInline: [
    {
      name: "Attribute",
      parse(cx, next, pos) {
        let match: RegExpMatchArray | null;
        const textFromPos = cx.slice(pos, cx.end);
        if (
          next != 91 /* '[' */ ||
          // and match the whole thing
          !(match = attributeStartRegex.exec(textFromPos))
        ) {
          return -1;
        }
        const [fullMatch, attributeName, attributeColon] = match;
        let bracketNestingDepth = 1;
        let valueLength = fullMatch.length;
        loopLabel:
        for (; valueLength < textFromPos.length; valueLength++) {
          switch (textFromPos[valueLength]) {
            case "[":
              bracketNestingDepth++;
              break;
            case "]":
              bracketNestingDepth--;
              if (bracketNestingDepth === 0) {
                // Done!
                break loopLabel;
              }
              break;
          }
        }
        if (bracketNestingDepth !== 0) {
          console.log("Failed to parse attribute", fullMatch, textFromPos);
          return -1;
        }

        if (textFromPos[valueLength + 1] === "(") {
          // This turns out to be a link, back out!
          return -1;
        }

        return cx.addElement(
          cx.elt("Attribute", pos, pos + valueLength + 1, [
            cx.elt("AttributeMark", pos, pos + 1), // [
            cx.elt("AttributeName", pos + 1, pos + 1 + attributeName.length),
            cx.elt(
              "AttributeColon",
              pos + 1 + attributeName.length,
              pos + 1 + attributeName.length + attributeColon.length,
            ),
            cx.elt(
              "AttributeValue",
              pos + 1 + attributeName.length + attributeColon.length,
              pos + valueLength,
            ),
            cx.elt("AttributeMark", pos + valueLength, pos + valueLength + 1), // [
          ]),
        );
      },
      after: "Emphasis",
    },
  ],
};

type RegexParserExtension = {
  // unicode char code for efficiency .charCodeAt(0)
  firstCharCode: number;
  regex: RegExp;
  nodeType: string;
};

function regexParser({
  regex,
  firstCharCode,
  nodeType,
}: RegexParserExtension): MarkdownConfig {
  return {
    defineNodes: [nodeType],
    parseInline: [
      {
        name: nodeType,
        parse(cx, next, pos) {
          if (firstCharCode !== next) {
            return -1;
          }
          const match = regex.exec(cx.slice(pos, cx.end));
          if (!match) {
            return -1;
          }
          return cx.addElement(cx.elt(nodeType, pos, pos + match[0].length));
        },
      },
    ],
  };
}

const NakedURL = regexParser(
  {
    firstCharCode: 104, // h
    regex: new RegExp(`^${nakedUrlRegex.source}`),
    nodeType: "NakedURL",
  },
);

const Hashtag = regexParser({
  firstCharCode: 35, // #
  regex: new RegExp(`^${tagRegex.source}`),
  nodeType: "Hashtag",
});

// FrontMatter parser

const yamlLang = StreamLanguage.define(yamlLanguage);

export const FrontMatter: MarkdownConfig = {
  defineNodes: [
    { name: "FrontMatter", block: true },
    { name: "FrontMatterMarker" },
    { name: "FrontMatterCode" },
  ],
  parseBlock: [{
    name: "FrontMatter",
    parse: (cx, line: Line) => {
      if (cx.parsedPos !== 0) {
        return false;
      }
      if (line.text !== "---") {
        return false;
      }
      const frontStart = cx.parsedPos;
      const elts = [
        cx.elt(
          "FrontMatterMarker",
          cx.parsedPos,
          cx.parsedPos + line.text.length + 1,
        ),
      ];
      cx.nextLine();
      const startPos = cx.parsedPos;
      let endPos = startPos;
      let text = "";
      let lastPos = cx.parsedPos;
      do {
        text += line.text + "\n";
        endPos += line.text.length + 1;
        cx.nextLine();
        if (cx.parsedPos === lastPos) {
          // End of file, no progress made, there may be a better way to do this but :shrug:
          return false;
        }
        lastPos = cx.parsedPos;
      } while (line.text !== "---");
      const yamlTree = yamlLang.parser.parse(text);

      elts.push(
        cx.elt("FrontMatterCode", startPos, endPos, [
          cx.elt(yamlTree, startPos),
        ]),
      );
      endPos = cx.parsedPos + line.text.length;
      elts.push(cx.elt(
        "FrontMatterMarker",
        cx.parsedPos,
        cx.parsedPos + line.text.length,
      ));
      cx.nextLine();
      cx.addElement(cx.elt("FrontMatter", frontStart, endPos, elts));
      return true;
    },
    before: "HorizontalRule",
  }],
};

export const extendedMarkdownLanguage = markdown({
  extensions: [
    WikiLink,
    Attribute,
    FrontMatter,
    TaskList,
    Highlight,
    LuaDirectives,
    Strikethrough,
    Table,
    NakedURL,
    Hashtag,
    Superscript,
    Subscript,
    {
      props: [
        foldNodeProp.add({
          // Don't fold at the list level
          BulletList: () => null,
          OrderedList: () => null,
          // Fold list items
          ListItem: (tree, state) => ({
            from: state.doc.lineAt(tree.from).to,
            to: tree.to,
          }),
          // Fold frontmatter
          FrontMatter: (tree) => ({
            from: tree.from,
            to: tree.to,
          }),
        }),

        styleTags({
          Task: ct.TaskTag,
          TaskMark: ct.TaskMarkTag,
          Comment: ct.CommentTag,
          Subscript: ct.SubscriptTag,
          Superscript: ct.SuperscriptTag,
          "TableDelimiter StrikethroughMark": t.processingInstruction,
          "TableHeader/...": t.heading,
          TableCell: t.content,
          CodeInfo: ct.CodeInfoTag,
          HorizontalRule: ct.HorizontalRuleTag,
          Hashtag: ct.HashtagTag,
          NakedURL: ct.NakedURLTag,
          NamedAnchor: ct.NamedAnchorTag,
        }),
      ],
    },
  ],
}).language;

export function parseMarkdown(text: string, offset?: number): ParseTree {
  return parse(extendedMarkdownLanguage, text, offset);
}


================================================
FILE: client/markdown_parser/table_parser.ts
================================================
import type {
  BlockContext,
  Element,
  LeafBlock,
  LeafBlockParser,
  Line,
  MarkdownConfig,
} from "@lezer/markdown";
import { tags as t } from "@lezer/highlight";

// Forked from https://github.com/lezer-parser/markdown/blob/main/src/extension.ts
// MIT License
// Author: Marijn Haverbeke
// Change made: Avoid wiki links and commands with aliases [[link|alias]], {[My: Command|alias]}
//  from being parsed as table row separators

function parseRow(
  cx: BlockContext,
  line: string,
  startI = 0,
  elts?: Element[],
  offset = 0,
) {
  let count = 0, first = true, cellStart = -1, cellEnd = -1, esc = false;
  const parseCell = () => {
    elts!.push(
      cx.elt(
        "TableCell",
        offset + cellStart,
        offset + cellEnd,
        cx.parser.parseInline(
          line.slice(cellStart, cellEnd),
          offset + cellStart,
        ),
      ),
    );
  };

  let inWikilink = false;
  let inCommandButton = false;
  for (let i = startI; i < line.length; i++) {
    const next = line.charCodeAt(i);

    if (line[i] === "[" && line.charAt(i + 1) === "[") {
      inWikilink = true;
    } else if (line[i] === "]" && line.charAt(i - 1) === "]" && inWikilink) {
      inWikilink = false;
    }

    if (line[i] === "{" && line.charAt(i + 1) === "[") {
      inCommandButton = true;
    } else if (inCommandButton) {
      if (line[i] === "}" && line.charAt(i - 1) === "]") {
        // Command button without arguments: {[My Command: BeepBoop|Alias]}
        inCommandButton = false;
      } else if (line[i] === "}" && line.charAt(i - 1) === ")") {
        // Command button with arguments: {[My Command: BeepBoop|Alias]("args")}
        inCommandButton = false;
      }
    }

    if (next == 124 /* '|' */ && !esc && !inWikilink && !inCommandButton) {
      if (!first || cellStart > -1) count++;
      first = false;
      if (elts) {
        if (cellStart > -1) parseCell();
        elts.push(cx.elt("TableDelimiter", i + offset, i + offset + 1));
      }
      cellStart = cellEnd = -1;
    } else if (esc || next != 32 && next != 9) {
      if (cellStart < 0) cellStart = i;
      cellEnd = i + 1;
    }
    esc = !esc && next == 92;
  }
  if (cellStart > -1) {
    count++;
    if (elts) parseCell();
  }
  return count;
}

function hasPipe(str: string, start: number) {
  for (let i = start; i < str.length; i++) {
    const next = str.charCodeAt(i);
    if (next == 124 /* '|' */) return true;
    if (next == 92 /* '\\' */) i++;
  }
  return false;
}

const delimiterLine = /^\|?(\s*:?-+:?\s*\|)+(\s*:?-+:?\s*)?$/;

class TableParser implements LeafBlockParser {
  // Null means we haven't seen the second line yet, false means this
  // isn't a table, and an array means this is a table and we've
  // parsed the given rows so far.
  rows: false | null | Element[] = null;

  nextLine(cx: BlockContext, line: Line, leaf: LeafBlock) {
    if (this.rows == null) { // Second line
      this.rows = false;
      let lineText;
      if (
        (line.next == 45 || line.next == 58 || line.next == 124 /* '-:|' */) &&
        delimiterLine.test(lineText = line.text.slice(line.pos))
      ) {
        const firstRow: Element[] = [],
          firstCount = parseRow(cx, leaf.content, 0, firstRow, leaf.start);
        if (firstCount == parseRow(cx, lineText, line.pos)) {
          this.rows = [
            cx.elt(
              "TableHeader",
              leaf.start,
              leaf.start + leaf.content.length,
              firstRow,
            ),
            cx.elt(
              "TableDelimiter",
              cx.lineStart + line.pos,
              cx.lineStart + line.text.length,
            ),
          ];
        }
      }
    } else if (this.rows) { // Line after the second
      const content: Element[] = [];
      parseRow(cx, line.text, line.pos, content, cx.lineStart);
      this.rows.push(
        cx.elt(
          "TableRow",
          cx.lineStart + line.pos,
          cx.lineStart + line.text.length,
          content,
        ),
      );
    }
    return false;
  }

  finish(cx: BlockContext, leaf: LeafBlock) {
    if (!this.rows) return false;
    cx.addLeafElement(
      leaf,
      cx.elt(
        "Table",
        leaf.start,
        leaf.start + leaf.content.length,
        this.rows as readonly Element[],
      ),
    );
    return true;
  }
}

/// This extension provides
/// [GFM-style](https://github.github.com/gfm/#tables-extension-)
/// tables, using syntax like this:
///
/// ```
/// | head 1 | head 2 |
/// | ---    | ---    |
/// | cell 1 | cell 2 |
/// ```
export const Table: MarkdownConfig = {
  defineNodes: [
    { name: "Table", block: true },
    { name: "TableHeader", style: { "TableHeader/...": t.heading } },
    "TableRow",
    { name: "TableCell", style: t.content },
    { name: "TableDelimiter", style: t.processingInstruction },
  ],
  parseBlock: [{
    name: "Table",
    leaf(_, leaf) {
      return hasPipe(leaf.content, 0) ? new TableParser() : null;
    },
    endLeaf(cx, line, leaf) {
      if (
        leaf.parsers.some((p) => p instanceof TableParser) ||
        !hasPipe(line.text, line.basePos)
      ) return false;
      // @ts-ignore: internal
      const next = cx.scanLine(cx.absoluteLineEnd + 1).text;
      return delimiterLine.test(next) &&
        parseRow(cx, line.text, line.basePos) ==
          parseRow(cx, next, line.basePos);
    },
    before: "SetextHeading",
  }],
};


================================================
FILE: client/markdown_renderer/html_render.test.ts
================================================
import { assertEquals } from "@std/assert";
import { renderHtml } from "./html_render.ts";

Deno.test("HTML Render", () => {
  assertEquals(
    renderHtml({
      name: "b",
      body: "hello",
    }),
    `<b>hello</b>`,
  );
  assertEquals(
    renderHtml({
      name: "a",
      attrs: {
        href: "https://example.com",
      },
      body: "hello",
    }),
    `<a href="https://example.com">hello</a>`,
  );
  assertEquals(
    renderHtml({
      name: "span",
      body: "<>",
    }),
    `<span>&lt;&gt;</span>`,
  );
});


================================================
FILE: client/markdown_renderer/html_render.ts
================================================
export const Fragment = "FRAGMENT";

export type Tag = {
  name: string;
  attrs?: Record<string, string | undefined>;
  body: Tag[] | string;
} | string;

export function htmlEscape(s: string): string {
  if (typeof s !== "string") {
    return s;
  }

  s = s.replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/\n/g, "<br>");

  let oldS = s;
  do {
    oldS = s;
    s = s.replace(/ {2}/g, "&nbsp; ");
  } while (s !== oldS);
  return s;
}

export function renderHtml(t: Tag | null): string {
  if (!t) {
    return "";
  }
  if (typeof t === "string") {
    return htmlEscape(t);
  }
  const attrs = t.attrs
    ? " " + Object.entries(t.attrs)
      .filter(([, value]) => value !== undefined)
      .map(([k, v]) => `${k}="${htmlEscape(v!)}"`).join(
        " ",
      )
    : "";
  const body = typeof t.body === "string"
    ? htmlEscape(t.body)
    : t.body.map(renderHtml).join("");
  if (t.name === Fragment) {
    return body;
  }
  return `<${t.name}${attrs}>${body}</${t.name}>`;
}


================================================
FILE: client/markdown_renderer/inline.ts
================================================
import {
  addParentPointers,
  findNodeOfType,
  type ParseTree,
  renderToText,
  replaceNodesMatchingAsync,
} from "@silverbulletmd/silverbullet/lib/tree";
import {
  getPathExtension,
  isMarkdownPath,
  parseToRef,
} from "@silverbulletmd/silverbullet/lib/ref";
import { isLocalURL } from "@silverbulletmd/silverbullet/lib/resolve";
import { mime } from "mimetypes";
import { LuaStackFrame, LuaTable } from "../space_lua/runtime.ts";
import { parseMarkdown } from "../markdown_parser/parser.ts";
import { renderExpressionResult } from "./result_render.ts";
import { parseExpressionString } from "../space_lua/parse.ts";
import { evalExpression } from "../space_lua/eval.ts";
import type { LuaExpression } from "../space_lua/ast.ts";

import { fsEndpoint } from "../spaces/constants.ts";
import {
  nameFromTransclusion,
  parseTransclusion,
  type Transclusion,
} from "@silverbulletmd/silverbullet/lib/transclusion";
import type { Space } from "../space.ts";
import type { SpaceLuaEnvironment } from "../space_lua.ts";

export type MarkdownExpandOptions = {
  // all options default to true, set to false to explicitly disable
  // Replace (markdown transclusions) with their content
  expandTransclusions?: boolean;
  // Replace Lua directives with their evaluated values
  expandLuaDirectives?: boolean;
  // Rewrite tasks to include references so that they can be updated
  rewriteTasks?: boolean;
};

/**
 * Expands custom markdown Lua directives and transclusions into plain markdown
 * @param mdTree parsed markdown tree
 * @returns modified mdTree
 */
export async function expandMarkdown(
  space: Space,
  pageName: string,
  mdTree: ParseTree,
  sle: SpaceLuaEnvironment,
  options: MarkdownExpandOptions = {},
  processedPages: Set<string> = new Set(),
): Promise<ParseTree> {
  addParentPointers(mdTree);
  await replaceNodesMatchingAsync(mdTree, async (n) => {
    if (n.type === "Image" && options.expandTransclusions !== false) {
      // Let's scan for ![[embeds]] that are codified as Images, confusingly
      const text = renderToText(n);

      const transclusion = parseTransclusion(text);
      if (!transclusion || processedPages.has(transclusion.url)) {
        return n;
      }

      try {
        const result = await inlineContentFromURL(space, transclusion);

        // We don't transclude anything that's not markdown
        if (typeof result !== "string") {
          return n;
        }

        // We know it's a markdown page and we know we are transcluding it. "Mark"
        // it so we won't touch it down the line and cause endless recursion
        processedPages.add(transclusion.url);

        const tree = parseMarkdown(result);

        // Recursively process
        return expandMarkdown(
          space,
          nameFromTransclusion(transclusion),
          tree,
          sle,
          options,
          processedPages,
        );
      } catch (e: any) {
        return parseMarkdown(`**Error:** ${e.message}`);
      }
    } else if (
      n.type === "LuaDirective" && options.expandLuaDirectives !== false
    ) {
      const expr = findNodeOfType(n, "LuaExpressionDirective") as
        | LuaExpression
        | null;
      if (!expr) {
        return;
      }
      const exprText = renderToText(expr);

      try {
        const sf = LuaStackFrame.createWithGlobalEnv(sle.env);

        let result = await evalExpression(
          parseExpressionString(exprText),
          sle.env,
          sf,
        );

        if (result?.markdown) {
          result = result.markdown;
        } else if (result instanceof LuaTable && result.has("markdown")) {
          result = result.get("markdown");
        }
        return parseMarkdown(await renderExpressionResult(result));
      } catch (e: any) {
        // Reduce blast radius and give useful error message
        console.error("Error evaluating Lua directive", exprText, e);
        return parseMarkdown(`**Error:** ${e.message}`);
      }
    } else if (n.type === "Task" && options.rewriteTasks !== false) {
      // Add a task reference to this based on the current page name if there's not one already
      const existingLink = findNodeOfType(n, "WikiLink");
      if (!existingLink) {
        n.children!.splice(1, 0, {
          "text": " ",
        }, {
          "type": "WikiLink",
          "children": [
            {
              "type": "WikiLinkMark",
              "children": [{ "text": "[[" }],
            },
            {
              "type": "WikiLinkPage",
              "children": [{ "text": `${pageName}@${n.parent!.from!}` }],
            },
            {
              "type": "WikiLinkMark",
              "children": [{ "text": "]]" }],
            },
          ],
        });
      }
    }
  });
  return mdTree;
}

type OffsetText = {
  text: string;
  offset: number;
};

/**
 * Function to generate HTML or markdown for a ![[<link>]] type transclusion.
 * @param space space object to use to retrieve content (readRef)
 * @param transclusion transclusion object to process
 * @returns a string for a markdown transclusion, or html for everything else
 */
export function inlineContentFromURL(
  space: Space,
  transclusion: Transclusion,
): HTMLElement | OffsetText | Promise<HTMLElement | OffsetText> {
  const allowExternal = transclusion.linktype !== "wikilink";
  if (!client) {
    return { text: "", offset: 0 };
  }
  let mimeType: string | null | undefined;
  if (!isLocalURL(transclusion.url) && allowExternal) {
    // Remote URL
    // Realistically we should dertermine the mine type by sending a HEAD
    // request, this poses multiple problems
    // 1. This makes `async` a hard requirement here
    // 2. We would need to proxy the request (because of CORS)
    // 3. It won't work "offline" (i.e. away from the SB instance, because it
    //    can't proxy the request anymore)
    // 4. It can be pretty heavy. If your internet connection is bad you will
    //    have to wait for all HEAD request, for your `markdownToHtml` to
    //    complete. This could take a noticeable amount of time.
    // For this reason we will stick to doing it the `dumb` way by just getting
    // it from the URL extension
    const extension = URL.parse(transclusion.url)?.pathname.split(".").pop();
    if (extension) {
      mimeType = mime.getType(extension);
    }
  } else {
    const ref = parseToRef(transclusion.url);
    if (!ref) {
      throw Error(`Failed to parse url: ${transclusion.url}`);
    }

    mimeType = mime.getType(getPathExtension(ref.path));
  }

  if (!mimeType) {
    throw Error(`Failed to determine mime type for ${transclusion.url}`);
  }

  const style = `max-width: 100%;` +
    (transclusion.dimension?.width
      ? `width: ${transclusion.dimension.width}px;`
      : "") +
    (transclusion.dimension?.height
      ? `height: ${transclusion.dimension.height}px;`
      : "");

  // If the URL is a local, prefix it with /.fs and encode the : so that it's not interpreted as a protocol
  const sanitizedFsUrl = isLocalURL(transclusion.url)
    ? fsEndpoint.slice(1) + "/" + transclusion.url.replace(":", "%3A")
    : transclusion.url;

  if (mimeType.startsWith("image/")) {
    const img = document.createElement("img");
    img.src = sanitizedFsUrl;
    img.alt = transclusion.alias;
    img.style = style;
    return img;
  } else if (mimeType.startsWith("video/")) {
    const video = document.createElement("video");
    video.src = sanitizedFsUrl;
    video.title = transclusion.alias;
    video.controls = true;
    video.autoplay = false;
    video.style = style;
    return video;
  } else if (mimeType.startsWith("audio/")) {
    const audio = document.createElement("audio");
    audio.src = sanitizedFsUrl;
    audio.title = transclusion.alias;
    audio.controls = true;
    audio.autoplay = false;
    audio.style = style;
    return audio;
  } else if (mimeType === "application/pdf") {
    const embed = document.createElement("object");
    embed.type = mimeType;
    embed.data = sanitizedFsUrl;
    embed.style.width = "100%";
    embed.style.height = "20em";
    embed.style = style;
    return embed;
  } else if (mimeType === "text/markdown") {
    if (!isLocalURL(transclusion.url) && allowExternal) {
      throw Error(`Transcluding markdown from external sources is not allowed`);
    }

    const ref = parseToRef(transclusion.url);
    if (!ref || !isMarkdownPath(ref.path)) {
      // We can be fairly sure this can't happen, but just be sure
      throw Error(
        `Couldn't transclude markdown, invalid path: ${transclusion.url}`,
      );
    }

    return space.readRef(ref);
  } else {
    return { text: `File has unsupported mimeType: ${mimeType}`, offset: 0 };
  }
}


================================================
FILE: client/markdown_renderer/justified_tables.ts
================================================
import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree";
import type { Tag } from "./html_render.ts";

/** How to justify a table's row's cells */
type Justification = "" | "left" | "right" | "center";

export function justifiedTableRender(
  cleaned_table: Tag[],
  parse_tree: ParseTree,
): Tag[] {
  const justify: Justification[] = getTableJustification(parse_tree);
  return justifiedTableTags(cleaned_table, justify);
}

/** Takes a markdown table's `ParseTree` and returns how the table delimiter requests the table be justified
 * @argument t ParseTree - A markdown table's `ParseTree`
 */
function getTableJustification(t: ParseTree): Justification[] {
  const delimiter = t.children?.find((child) =>
    child.type === "TableDelimiter"
  );
  const delimiterText = delimiter?.children?.at(0)?.text;

  if (!delimiterText) return [];

  // Split at "|" and filter out empty strings (from leading/trailing |)
  const columnDelimiters = delimiterText.split("|").filter((part) =>
    part.trim() !== ""
  );

  return columnDelimiters.map(parseColumnAlignment);
}

/** Parse a single column delimiter to determine its alignment */
function parseColumnAlignment(delimiter: string): Justification {
  const trimmed = delimiter.trim();

  if (trimmed === "") return "";

  const left = trimmed.startsWith(":");
  const right = trimmed.endsWith(":");

  if (left && right) return "center";
  if (left) return "left";
  if (right) return "right";
  return "";
}

type TagObject = {
  name: string;
  attrs?: Record<string, string | undefined>;
  body: Tag[] | string;
};

/** Type guard to check if a value is a non-string Tag */
function isTagObject(value: Tag): value is TagObject {
  return typeof value !== "string";
}

/** Type guard to check if a Tag has a body array */
function hasBodyArray(tag: TagObject): tag is TagObject & { body: Tag[] } {
  return Array.isArray(tag.body);
}

/** Apply justification to a single row */
function justifyRow(row: TagObject, justify: Justification[]): Tag[] {
  if (!hasBodyArray(row)) {
    return [];
  }

  return row.body.map((cell, i) => {
    const alignment = justify[i];

    // Skip if no alignment specified or if cell is a string
    if (!alignment || !isTagObject(cell)) {
      return cell;
    }

    // Ensure cell has attrs object
    if (!cell.attrs) {
      cell.attrs = {};
    }

    // Add alignment class
    const alignmentClass = `sb-table-align-${alignment}`;
    if (cell.attrs.class) {
      cell.attrs.class += ` ${alignmentClass}`;
    } else {
      cell.attrs.class = alignmentClass;
    }

    return cell;
  });
}

/** Takes a table body and justifications to apply and applies the justifications
 * @argument table_body Tag[] - The pre-rendered table body as seen in `render()`
 * @argument justify Justification[] - A justification per table column
 */
function justifiedTableTags(
  table_body: Tag[],
  justify: Justification[],
): Tag[] {
  if (justify.length === 0) return table_body;

  return table_body.map((row: Tag) => {
    if (!isTagObject(row)) return row;

    // Handle thead specially - it contains a tr row
    if (row.name === "thead" && hasBodyArray(row)) {
      const trRow = row.body[0];
      if (isTagObject(trRow)) {
        const newBody = justifyRow(trRow, justify);
        trRow.body = newBody;
      }
    } else {
      // Handle regular rows
      const newBody = justifyRow(row, justify);
      if (newBody.length > 0) {
        row.body = newBody;
      }
    }

    return row;
  });
}


================================================
FILE: client/markdown_renderer/markdown_render.test.ts
================================================
import { parse } from "../markdown_parser/parse_tree.ts";

import { renderMarkdownToHtml } from "./markdown_render.ts";
import { extendedMarkdownLanguage } from "../markdown_parser/parser.ts";
import { assertEquals } from "@std/assert";

const sampleMarkdown = `---
name: Sup
---

# Hello world

This is **bold** and _italic_, or _italic_. And a **_mix_**. And ==highlight==!

Lists:

- This
- Is a
- list
- And here we go nested
1. This is a numbered
2. Two
- And different
- Bla
- More bla

And:

1. Numbered
2. Two

## Second heading

And some

And like this:

  More code
  Bla

And a blockquote:

> Sup yo Empty line\
> Second part

<!-- this is a comment -->

And more custom stuff [[Page link]]

{[Command button]}

- [ ] #next Task
- [x] #next Task 2


## Tables

| type      | actor_login | created_at           | payload_ref            |
| --------- | ----------- | -------------------- | ---------------------- |
| PushEvent | avb         | 2022-10-27T08:27:48Z | refs/heads/master      |
| PushEvent | avb         | 2022-10-27T04:31:27Z | refs/heads/jitterSched |

Here is something

---

A new thing.
`;

Deno.test("Markdown render", () => {
  const tree = parse(extendedMarkdownLanguage, sampleMarkdown);
  renderMarkdownToHtml(tree, {
    failOnUnknown: true,
  });
});

Deno.test("Smart hard break test", () => {
  const example = `**Hello**
*world!*`;
  const tree = parse(extendedMarkdownLanguage, example);
  const html = renderMarkdownToHtml(tree, {
    failOnUnknown: true,
    smartHardBreak: true,
  });
  assertEquals(
    html,
    `<span class="p"><strong>Hello</strong><br><em>world!</em></span>`,
  );

  const example2 = `This is going to be a text. With a new line.

And another

* and a list
* with a second item

### [[Bla]]
  Url: something
  Server: something else
  📅 last_updated - [Release notes](release_notes_url)`;

  const tree2 = parse(extendedMarkdownLanguage, example2);
  const html2 = renderMarkdownToHtml(tree2, {
    failOnUnknown: true,
    smartHardBreak: true,
  });

  console.log(html2);
});


================================================
FILE: client/markdown_renderer/markdown_render.ts
================================================
import {
  addParentPointers,
  collectNodesOfType,
  findNodeOfType,
  type ParseTree,
  removeParentPointers,
  renderToText,
  traverseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import {
  encodePageURI,
  encodeRef,
  parseToRef,
} from "@silverbulletmd/silverbullet/lib/ref";
import { Fragment, renderHtml, type Tag } from "./html_render.ts";
import * as TagConstants from "../../plugs/index/constants.ts";
import { extractHashtag } from "@silverbulletmd/silverbullet/lib/tags";
import { justifiedTableRender } from "./justified_tables.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import { inlineContentFromURL } from "./inline.ts";
import { parseTransclusion } from "@silverbulletmd/silverbullet/lib/transclusion";

export type MarkdownRenderOptions = {
  failOnUnknown?: true;
  smartHardBreak?: true;
  annotationPositions?: true;
  preserveAttributes?: true;
  shortWikiLinks?: boolean;
  // When defined, use to inline images as data: urls
  translateUrls?: (url: string, type: "link" | "image") => string;
  expand?: true;
};

function cleanTags(values: (Tag | null)[], cleanWhitespace = false): Tag[] {
  const result: Tag[] = [];
  for (const value of values) {
    if (cleanWhitespace && typeof value === "string" && value.match(/^\s+$/)) {
      continue;
    }
    if (value) {
      result.push(value);
    }
  }
  return result;
}

/**
 * Cleans up a markdown tree. Side effect: adds parent pointers
 */
function preprocess(t: ParseTree) {
  addParentPointers(t);
  traverseTree(t, (node) => {
    if (!node.type) {
      // Remove redundant newlines in table
      if (node.text?.startsWith("\n")) {
        const prevNodeIdx = node.parent!.children!.indexOf(node) - 1;
        const prevNodeType = node.parent!.children![prevNodeIdx]?.type;
        if (
          prevNodeType?.includes("Heading") || prevNodeType?.includes("Table")
        ) {
          node.text = node.text.slice(1);
        }
      }
    }
    return false;
  });
}

function posPreservingRender(
  t: ParseTree,
  options: MarkdownRenderOptions = {},
): Tag | null {
  const tag = render(t, options);
  if (!options.annotationPositions) {
    return tag;
  }
  if (!tag) {
    return null;
  }
  if (typeof tag === "string") {
    return tag;
  }
  if (t.from) {
    if (!tag.attrs) {
      tag.attrs = {};
    }
    tag.attrs["data-pos"] = "" + t.from;
  }
  return tag;
}

function render(
  t: ParseTree,
  options: MarkdownRenderOptions = {},
): Tag | null {
  if (t.type?.endsWith("Mark") || t.type?.endsWith("Delimiter")) {
    return null;
  }
  switch (t.type) {
    case "Document":
      return {
        name: Fragment,
        body: cleanTags(mapRender(t.children!)),
      };
    case "FrontMatter":
      return null;
    case "CommentBlock":
      // Remove, for now
      return null;
    case "ATXHeading1":
      return {
        name: "h1",
        body: cleanTags(mapRender(t.children!)),
      };
    case "ATXHeading2":
      return {
        name: "h2",
        body: cleanTags(mapRender(t.children!)),
      };
    case "ATXHeading3":
      return {
        name: "h3",
        body: cleanTags(mapRender(t.children!)),
      };
    case "ATXHeading4":
      return {
        name: "h4",
        body: cleanTags(mapRender(t.children!)),
      };
    case "ATXHeading5":
      return {
        name: "h5",
        body: cleanTags(mapRender(t.children!)),
      };
    case "ATXHeading6":
      return {
        name: "h6",
        body: cleanTags(mapRender(t.children!)),
      };
    case "Paragraph":
      return {
        name: "span",
        attrs: {
          class: "p",
        },
        body: cleanTags(mapRender(t.children!)),
      };
    // Code blocks
    case "FencedCode":
    case "CodeBlock": {
      // Clear out top-level indent blocks
      const lang = findNodeOfType(t, "CodeInfo");
      t.children = t.children!.filter((c) => c.type);
      return {
        name: "pre",
        attrs: {
          "data-lang": lang ? lang.children![0].text : undefined,
        },
        body: cleanTags(mapRender(t.children!)),
      };
    }
    case "CodeInfo":
      return null;
    case "CodeText":
      return t.children![0].text!;
    case "Blockquote":
      return {
        name: "blockquote",
        body: cleanTags(mapRender(t.children!)),
      };
    case "HardBreak":
      return {
        name: "br",
        body: "",
      };
    // Basic styling
    case "Emphasis":
      return {
        name: "em",
        body: cleanTags(mapRender(t.children!)),
      };
    case "Highlight":
      return {
        name: "span",
        attrs: {
          class: "highlight",
        },
        body: cleanTags(mapRender(t.children!)),
      };
    case "Strikethrough":
      return {
        name: "del",
        body: cleanTags(mapRender(t.children!)),
      };
    case "InlineCode":
      return {
        name: "code",
        attrs: {
          class: "sb-code",
        },
        body: cleanTags(mapRender(t.children!)),
      };
    case "BulletList":
      return {
        name: "ul",
        body: cleanTags(mapRender(t.children!), true),
      };
    case "OrderedList":
      return {
        name: "ol",
        body: cleanTags(mapRender(t.children!), true),
      };
    case "ListItem":
      return {
        name: "li",
        body: cleanTags(mapRender(t.children!), true),
      };
    case "StrongEmphasis":
      return {
        name: "strong",
        body: cleanTags(mapRender(t.children!)),
      };
    case "HorizontalRule":
      return {
        name: "hr",
        body: "",
      };
    case "Link": {
      const linkTextChildren = t.children!.slice(1, -4);
      const urlNode = findNodeOfType(t, "URL");
      if (!urlNode) {
        return renderToText(t);
      }
      return {
        name: "a",
        attrs: {
          href: urlNode.children![0].text!,
          target: "_blank",
        },
        body: cleanTags(mapRender(linkTextChildren)),
      };
    }
    case "Autolink": {
      const urlNode = findNodeOfType(t, "URL");
      if (!urlNode) {
        return renderToText(t);
      }
      const url = urlNode.children![0].text!;
      return {
        name: "a",
        attrs: {
          href: url,
          target: "_blank",
        },
        body: url,
      };
    }
    case "Image": {
      const text = renderToText(t);

      const transclusion = parseTransclusion(text);
      if (!transclusion) {
        return text;
      }

      try {
        const result = inlineContentFromURL(client.space, transclusion);
        if (result instanceof Promise) {
          // console.warn(
          //   `Unsupported inline in markdown render context: ${transclusion.url}`,
          // );
          // Can't support promises in this context, returning original text
          return text;
        }
        // Running in non-browser context
        if (!globalThis.HTMLElement || !(result instanceof HTMLElement)) {
          return text;
        }

        return {
          name: result.tagName,
          attrs: Array.from(result.attributes).reduce(
            (obj, attr) => {
              obj[attr.name] = attr.value;
              return obj;
            },
            {} as Record<string, string>,
          ),
          body: "",
        };
      } catch (e: any) {
        console.error("Error handling image or transclusion", e.message);
        return {
          name: "span",
          body: "Error loading image",
        };
      }
    }

    // Custom stuff
    case "WikiLink": {
      const link = findNodeOfType(t, "WikiLinkPage")!.children![0].text!;
      let linkText = options.shortWikiLinks === true
        ? link.split("/").pop()!
        : link;
      const aliasNode = findNodeOfType(t, "WikiLinkAlias");
      if (aliasNode) {
        linkText = aliasNode.children![0].text!;
      }

      // For invalid refs the link just won't link
      let href: string = `#`;

      const ref = parseToRef(link);
      if (ref) {
        href = `/${encodePageURI(encodeRef(ref))}`;
      }

      return {
        name: "a",
        attrs: {
          href,
          class: "wiki-link",
          "data-ref": link,
        },
        body: linkText,
      };
    }
    case "NakedURL": {
      const url = t.children![0].text!;
      return {
        name: "a",
        attrs: {
          href: url,
          target: "_blank",
        },
        body: url,
      };
    }
    case "Hashtag": {
      const tagText: string = t.children![0].text!;
      const link = TagConstants.tagPrefix + extractHashtag(tagText);
      return {
        name: "a",
        attrs: {
          class: "hashtag sb-hashtag",
          "data-tag-name": extractHashtag(tagText),
          href: `/${encodePageURI(link)}`,
          "data-ref": link,
        },
        body: tagText,
      };
    }
    case "Task": {
      let externalTaskRef = "";
      collectNodesOfType(t, "WikiLinkPage").forEach((wikilink) => {
        const ref = parseToRef(wikilink.children![0].text!);

        if (
          !externalTaskRef && ref &&
          (ref.details?.type === "position" ||
            ref.details?.type === "linecolumn")
        ) {
          externalTaskRef = wikilink.children![0].text!;
        }
      });

      return {
        name: "span",
        attrs: externalTaskRef
          ? {
            "data-external-task-ref": externalTaskRef,
          }
          : {},
        body: cleanTags(mapRender(t.children!)),
      };
    }
    case "TaskState": {
      // child[0] = marker, child[1] = state, child[2] = marker
      const stateText = t.children![1].text!;
      if ([" ", "x", "X"].includes(stateText)) {
        return {
          name: "input",
          attrs: {
            type: "checkbox",
            checked: stateText !== " " ? "checked" : undefined,
            "data-state": stateText,
          },
          body: "",
        };
      } else {
        return {
          name: "span",
          attrs: {
            class: "task-state",
          },
          body: stateText,
        };
      }
    }

    // Tables
    case "Table":
      return {
        name: "table",
        body: justifiedTableRender(cleanTags(mapRender(t.children!)), t),
      };
    case "TableHeader":
      return {
        name: "thead",
        body: [
          {
            name: "tr",
            body: cleanTags(mapRender(t.children!), true),
          },
        ],
      };
    case "TableCell":
      return {
        name: "td",
        body: cleanTags(mapRender(t.children!)),
      };
    case "TableRow": {
      const children = t.children!;
      const newChildren: ParseTree[] = [];
      // Ensure there is TableCell in between every delimiter
      let lookingForCell = false;
      for (const child of children) {
        if (child.type === "TableDelimiter" && lookingForCell) {
          // We were looking for a cell, but didn't fine one: empty cell!
          // Let's inject an empty one
          newChildren.push({
            type: "TableCell",
            children: [],
          });
        }
        if (child.type === "TableDelimiter") {
          lookingForCell = true;
        }
        if (child.type === "TableCell") {
          lookingForCell = false;
        }
        newChildren.push(child);
      }
      return {
        name: "tr",
        body: cleanTags(mapRender(newChildren), true),
      };
    }
    case "Attribute":
      if (options.preserveAttributes) {
        return {
          name: "span",
          attrs: {
            class: "attribute",
          },
          body: renderToText(t),
        };
      }
      return null;
    case "Escape": {
      return {
        name: "span",
        attrs: {
          class: "escape",
        },
        body: t.children![0].text!.slice(1),
      };
    }
    case "Entity":
      return t.children![0].text!;

    case "Superscript":
      return {
        name: "sup",
        body: cleanTags(mapRender(t.children!)),
      };
    case "Subscript":
      return {
        name: "sub",
        body: cleanTags(mapRender(t.children!)),
      };
    case "LuaDirective":
      return {
        name: "span",
        attrs: {
          class: "sb-lua-directive",
        },
        body: renderToText(t),
      };

    // Text
    case undefined:
      return t.text!;
    default:
      if (options.failOnUnknown) {
        removeParentPointers(t);
        console.error("Not handling", JSON.stringify(t, null, 2));
        throw new Error(`Unknown markdown node type ${t.type}`);
      } else {
        // Falling back to rendering verbatim
        removeParentPointers(t);
        console.warn("Not handling", JSON.stringify(t, null, 2));
        return renderToText(t);
      }
  }

  function mapRender(children: ParseTree[]) {
    return children.map((t) => posPreservingRender(t, options));
  }
}

function traverseTag(
  t: Tag,
  fn: (t: Tag) => void,
) {
  fn(t);
  if (typeof t === "string") {
    return;
  }
  if (t.body) {
    for (const child of t.body) {
      traverseTag(child, fn);
    }
  }
}

export function renderMarkdownToHtml(
  t: ParseTree,
  options: MarkdownRenderOptions = {},
  allPages: PageMeta[] = [],
) {
  preprocess(t);
  const htmlTree = posPreservingRender(t, options);
  if (htmlTree) {
    traverseTag(htmlTree, (t) => {
      if (typeof t === "string") {
        return;
      }
      if (t.name === "img" && options.translateUrls) {
        t.attrs!.src = options.translateUrls!(t.attrs!.src!, "image");
      }

      if (t.name === "a" && t.attrs!.href) {
        if (options.translateUrls) {
          t.attrs!.href = options.translateUrls!(t.attrs!.href, "link");
        }
        if (t.attrs!["data-ref"]?.length) {
          const ref = parseToRef(t.attrs!["data-ref"]!);
          if (!ref) {
            return;
          }

          const pageMeta = allPages.find((p) =>
            ref.path === parseToRef(p.ref)?.path
          );
          if (
            pageMeta &&
            !(ref.details?.type === "position" ||
              ref.details?.type === "linecolumn")
          ) {
            t.body = [(pageMeta.pageDecoration?.prefix ?? "") + t.body];
            if (pageMeta.pageDecoration?.cssClasses) {
              t.attrs!.class += " sb-decorated-object " +
                pageMeta.pageDecoration.cssClasses.join(" ").replaceAll(
                  /[^a-zA-Z0-9-_ ]/g,
                  "",
                );
            }
          }
        }
        if (t.body.length === 0) {
          t.body = [t.attrs!.href];
        }
      }
    });
  }
  return renderHtml(htmlTree);
}


================================================
FILE: client/markdown_renderer/result_render.ts
================================================
import {
  luaFormatNumber,
  LuaTable,
  luaToString,
} from "../space_lua/runtime.ts";
import { isTaggedFloat } from "../space_lua/numeric.ts";

export function defaultTransformer(v: any, _k: string): Promise<string> {
  if (v === undefined) {
    return Promise.resolve("");
  }
  if (typeof v === "string") {
    return Promise.resolve(escapeRegularPipes(v.replaceAll("\n", " ")));
  }
  if (v && typeof v === "object") {
    return Promise.resolve(luaToString(v));
  }
  if (typeof v === "number") {
    return Promise.resolve(luaFormatNumber(v));
  }
  return Promise.resolve("" + v);
}

export function refCellTransformer(v: any, k: string) {
  if (k === "ref") {
    return Promise.resolve(`[[${v}]]`);
  } else {
    return defaultTransformer(v, k);
  }
}

/**
 * Escapes all pipes that would inadvertently delimit a markdown table column.
 * Does not escape columns that are used for aliasing in WikiLinks or Commands:
 * `[[WikiLink|Alias]]` and `{[Command: Name|Click Me!]("args")}`
 * @param s The text to replace
 * @returns The text where the pipes outside of silverbullet specific context is
 *  replaced with an escaped pipe.
 */
function escapeRegularPipes(s: string) {
  let result = "";
  let isInWikiLink = false;
  let isInCommandButton = false;

  for (let i = 0; i < s.length; i++) {
    if (s[i] == "[" && s[i + 1] == "[") {
      isInWikiLink = true;
    } else if (s[i] == "]" && s[i + 1] == "]" && isInWikiLink) {
      isInWikiLink = false;
    }
    if (s[i] == "{" && s[i + 1] == "[") {
      isInCommandButton = true;
    } else if (
      (s[i] == "]" || s[i] == ")") && s[i + 1] == "}" && isInCommandButton
    ) {
      isInCommandButton = false;
    } else if (s[i] == "|" && !isInWikiLink && !isInCommandButton) {
      result += "\\";
    }

    result += s[i];
  }

  return result;
}

// Nicely format an array of JSON objects as a Markdown table
export async function jsonToMDTable(
  jsonArray: any[],
  valueTransformer: (v: any, k: string) => Promise<string> = defaultTransformer,
): Promise<string> {
  const headers = new Set<string>();
  for (const entry of jsonArray) {
    for (const k of Object.keys(entry)) {
      headers.add(k);
    }
  }

  // Handle empty case manually, instead of three lines of ||
  if (headers.size == 0) {
    return "*(empty table)*";
  }

  const headerList = [...headers];
  const lines = [];
  lines.push(
    "|" +
      headerList
        .map(
          (headerName) => headerName,
        )
        .join("|") +
      "|",
  );
  lines.push(
    "|" +
      headerList
        .map(() => "--")
        .join("|") +
      "|",
  );
  for (const val of jsonArray) {
    const el = [];
    for (const prop of headerList) {
      const s = await valueTransformer(val[prop], prop);
      el.push(s);
    }
    lines.push("|" + el.join("|") + "|");
  }
  return lines.join("\n");
}

export function renderExpressionResult(result: any): Promise<string> {
  if (result === undefined || result === null) {
    return Promise.resolve("nil");
  }
  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);
    } 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]);
  } 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);
  }
}

/**
 * Applies some heuristics to figure out if a string should be rendered as a markdown block or inline markdown
 * @param s markdown string
 */
export function isBlockMarkdown(s: string) {
  if (s.includes("\n")) {
    return true;
  }
  // If it contains something resembling a list
  return !!s.match(/[\-\*]\s+/);
}


================================================
FILE: client/navigator.ts
================================================
import {
  encodePageURI,
  getNameFromPath,
  isMarkdownPath,
  parseToRef,
  type Path,
  type Ref,
} from "@silverbulletmd/silverbullet/lib/ref";
import type { Client } from "./client.ts";

// The path of a location state should not be empty, rather it should be
// normalized to the indexpage beforhand
export type LocationState = Ref & {
  scrollTop?: number;
  selection?: {
    anchor: number;
    head?: number;
  };
};

export class PathPageNavigator {
  navigationPromise: PromiseWithResolvers<string | null> | null = null;
  indexRef!: Ref;

  openLocations = new Map<string, LocationState>();

  constructor(
    private client: Client,
  ) {
    this.indexRef = this.client.getIndexRef();
  }

  /**
   * Navigates the client to the given page. An empty string path is navigated
   * to the index page. This function also updates the browser history
   * @param replaceState whether to update the state in place (rather than to
   * push a new state)
   */
  async navigate(
    ref: Ref,
    replaceState = false,
  ) {
    // We are already navigating, let's wait
    if (this.navigationPromise) {
      await this.navigationPromise.promise;
    }

    const currentState = this.buildCurrentLocationState();
    // Remove details as we prefer to actually keep the scrollTop
    currentState.details = undefined;

    if (ref.path === "") {
      ref.path = this.indexRef.path;
    }

    this.openLocations.set(currentState.path, currentState);

    if (!replaceState) {
      globalThis.history.replaceState(
        currentState,
        "",
        `${document.baseURI}${this.pathToURI(currentState.path)}`,
      );
      globalThis.history.pushState(
        ref,
        "",
        `${document.baseURI}${this.pathToURI(ref.path)}`,
      );
    } else {
      globalThis.history.replaceState(
        ref,
        "",
        `${document.baseURI}${this.pathToURI(ref.path)}`,
      );
    }

    this.navigationPromise = Promise.withResolvers();

    globalThis.dispatchEvent(
      new PopStateEvent("popstate", {
        state: ref,
      }),
    );

    const error = await this.navigationPromise.promise;

    if (error !== null) {
      // The navigation failed, let's revert everything we've done (This could
      // e.g. be a document editor which doesn't exist)
      this.client.flashNotification(`Failed to navigate: ${error}`, "error");

      if (!replaceState) {
        history.go(-1);
      } else {
        // This can e.g. happen on the first navigate. We obviously can't fall back to the same path, so fallback to the indexpage

        const newState: LocationState = currentState.path === ref.path
          ? this.indexRef
          : currentState;

        globalThis.history.replaceState(
          newState,
          "",
          `${document.baseURI}${this.pathToURI(newState.path)}`,
        );

        globalThis.dispatchEvent(
          new PopStateEvent("popstate", {
            state: newState,
          }),
        );

        // This is should never fail, because we already navigated here before.
        await this.navigationPromise.promise;
      }
    }

    this.navigationPromise = null;
  }

  private pathToURI(path: Path): string {
    return path !== this.indexRef.path
      ? encodePageURI(getNameFromPath(path))
      : "";
  }

  private buildCurrentLocationState(): LocationState {
    const locationState: LocationState = parseRefFromURI() || this.indexRef;
    if (locationState.path === "") {
      locationState.path = this.indexRef.path;
    }

    if (isMarkdownPath(locationState.path)) {
      const editorView = this.client.editorView;

      const mainSelection = editorView.state.selection.main;
      locationState.scrollTop = editorView.scrollDOM.scrollTop;
      locationState.selection = {
        head: mainSelection.head,
        anchor: mainSelection.anchor,
      };
    }

    return locationState;
  }

  subscribe(
    pageLoadCallback: (
      locationState: LocationState,
    ) => Promise<void>,
  ) {
    globalThis.addEventListener("popstate", async (event: PopStateEvent) => {
      const state: LocationState = event.state
        ? event.state
        // The popstate event can be fired in a whole list of weird situations
        // (see MDN). Try to do our best if it's fired without the state
        : parseRefFromURI() || this.indexRef;

      // Try filling in the ref using the openLocation cache
      if (
        !state.details && !state.selection && !state.scrollTop
      ) {
        const openLocation = this.openLocations.get(state.path);
        if (openLocation) {
          state.selection = openLocation.selection;
          state.scrollTop = openLocation.scrollTop;
        }
      }

      // For some (propably smart) reason the reject() function on a
      // Promise.withResolvers, also throws. This is hugely annoying here, so
      // let's resolve for both cases
      await pageLoadCallback(state)
        .then(
          () => this.navigationPromise?.resolve(null),
          (e) => this.navigationPromise?.resolve(e.message),
        );
    });
  }
}

export function parseRefFromURI(): Ref | null {
  const locationRef = parseToRef(decodeURIComponent(
    location.href.substring(document.baseURI.length), //this essentially returns location with prefix and leading slash removed (equivalent to location.pathname.substring(prefix.length).substring(1)),
  ));

  if (locationRef && location.hash) {
    locationRef.details = {
      type: "header",
      header: decodeURIComponent(location.hash.substring(1)),
    };
  }

  return locationRef;
}


================================================
FILE: client/plugos/event.test.ts
================================================
import { assert, assertEquals } from "@std/assert";
import { EventEmitter } from "./event.ts";

// Test event handler interfaces
interface TestEvents {
  message: (content: string) => void | Promise<void>;
  count: (num: number) => void | Promise<void>;
  multi: (a: string, b: number, c: boolean) => void | Promise<void>;
  async: () => void | Promise<void>;
  sync: () => void | Promise<void>;
  error: () => void | Promise<void>;
}

// Concrete test implementation
class TestEventEmitter extends EventEmitter<TestEvents> {
  // Just a concrete implementation for testing
}

Deno.test("EventEmitter - basic on/emit functionality", async () => {
  const emitter = new TestEventEmitter();
  let received = "";
  let count = 0;

  emitter.on({
    message: (content: string) => {
      received = content;
    },
    count: (num: number) => {
      count = num;
    },
  });

  await emitter.emit("message", "hello world");
  assertEquals(received, "hello world");

  await emitter.emit("count", 42);
  assertEquals(count, 42);
});

Deno.test("EventEmitter - multiple handlers for same event", async () => {
  const emitter = new TestEventEmitter();
  const results: string[] = [];

  emitter.on({
    message: (content: string) => {
      results.push(`handler1: ${content}`);
    },
  });

  emitter.on({
    message: (content: string) => {
      results.push(`handler2: ${content}`);
    },
  });

  await emitter.emit("message", "test");

  assertEquals(results.length, 2);
  assertEquals(results[0], "handler1: test");
  assertEquals(results[1], "handler2: test");
});

Deno.test("EventEmitter - multiple events in single handler", async () => {
  const emitter = new TestEventEmitter();
  let messageReceived = "";
  let countReceived = 0;

  emitter.on({
    message: (content: string) => {
      messageReceived = content;
    },
    count: (num: number) => {
      countReceived = num;
    },
  });

  await emitter.emit("message", "multi-handler");
  await emitter.emit("count", 123);

  assertEquals(messageReceived, "multi-handler");
  assertEquals(countReceived, 123);
});

Deno.test("EventEmitter - emit with multiple arguments", async () => {
  const emitter = new TestEventEmitter();
  let receivedA = "";
  let receivedB = 0;
  let receivedC = false;

  emitter.on({
    multi: (a: string, b: number, c: boolean) => {
      receivedA = a;
      receivedB = b;
      receivedC = c;
    },
  });

  await emitter.emit("multi", "test", 456, true);

  assertEquals(receivedA, "test");
  assertEquals(receivedB, 456);
  assertEquals(receivedC, true);
});

Deno.test("EventEmitter - off removes specific handler object with multiple events", async () => {
  const emitter = new TestEventEmitter();
  let messageCount = 0;
  let numberCount = 0;
  let remainingMessageCount = 0;

  // Handler object with multiple events that we'll remove
  const handlersToRemove = {
    message: () => {
      messageCount++;
    },
    count: () => {
      numberCount++;
    },
  };

  // Another handler for message events that should remain
  const remainingHandlers = {
    message: () => {
      remainingMessageCount++;
    },
  };

  emitter.on(handlersToRemove);
  emitter.on(remainingHandlers);

  // Test that both handlers work initially
  await emitter.emit("message", "test1");
  await emitter.emit("count", 42);
  assertEquals(messageCount, 1);
  assertEquals(numberCount, 1);
  assertEquals(remainingMessageCount, 1);

  // Remove the first handler object
  emitter.off(handlersToRemove);

  // Test that only the remaining message handler works
  await emitter.emit("message", "test2");
  await emitter.emit("count", 43);
  assertEquals(messageCount, 1); // Should not have incremented
  assertEquals(numberCount, 1); // Should not have incremented
  assertEquals(remainingMessageCount, 2); // Should have incremented
});

Deno.test("EventEmitter - off removes only the specified handler object", async () => {
  const emitter = new TestEventEmitter();
  let handler1MessageCount = 0;
  let handler1CountValue = 0;
  let handler2MessageCount = 0;
  let handler2CountValue = 0;

  const handlers1 = {
    message: () => {
      handler1MessageCount++;
    },
    count: (num: number) => {
      handler1CountValue = num;
    },
  };

  const handlers2 = {
    message: () => {
      handler2MessageCount++;
    },
    count: (num: number) => {
      handler2CountValue = num;
    },
  };

  emitter.on(handlers1);
  emitter.on(handlers2);

  // Test both handlers work initially
  await emitter.emit("message", "test1");
  await emitter.emit("count", 100);
  assertEquals(handler1MessageCount, 1);
  assertEquals(handler1CountValue, 100);
  assertEquals(handler2MessageCount, 1);
  assertEquals(handler2CountValue, 100);

  // Remove only handlers1
  emitter.off(handlers1);

  // Test that only handlers2 continues to work
  await emitter.emit("message", "test2");
  await emitter.emit("count", 200);
  assertEquals(handler1MessageCount, 1); // Should not have incremented
  assertEquals(handler1CountValue, 100); // Should not have changed
  assertEquals(handler2MessageCount, 2); // Should have incremented
  assertEquals(handler2CountValue, 200); // Should have changed
});

Deno.test("EventEmitter - no handlers for event", async () => {
  const emitter = new TestEventEmitter();

  // Should not throw when emitting to non-existent handlers
  await emitter.emit("message", "test");
  await emitter.emit("count", 42);

  // Test passes if no exception is thrown
  assert(true);
});


================================================
FILE: client/plugos/event.ts
================================================
/**
 * EventEmitter implementation, similar to the one used in CodeMirror
 */
export abstract class EventEmitter<HandlerT> {
  private handlers: Partial<HandlerT>[] = [];

  /**
   * Subscribe to events
   * @param handlers
   */
  on(handlers: Partial<HandlerT>) {
    this.handlers.push(handlers);
  }

  /**
   * Unsubscribe from events
   * @param handlers
   */
  off(handlers: Partial<HandlerT>) {
    this.handlers = this.handlers.filter((h) => h !== handlers);
  }

  /**
   * Broadcast an event to all subscribers
   * @param eventName
   * @param args
   */
  async emit(eventName: keyof HandlerT, ...args: any[]): Promise<void> {
    for (const handler of this.handlers) {
      const fn: any = handler[eventName];
      if (fn) {
        await Promise.resolve(fn(...args));
      }
    }
  }
}


================================================
FILE: client/plugos/eventhook.ts
================================================
import type { EventHookT } from "@silverbulletmd/silverbullet/type/manifest";
import type { Hook } from "./types.ts";

export interface EventHookI extends Hook<EventHookT> {
  dispatchEvent(eventName: string, ...args: unknown[]): Promise<unknown[]>;

  listEvents(): string[];
}


================================================
FILE: client/plugos/hooks/code_widget.ts
================================================
import type { Hook, Manifest } from "../types.ts";
import type { System } from "../system.ts";
import type { CodeWidgetT } from "@silverbulletmd/silverbullet/type/manifest";
import type { CodeWidgetCallback } from "@silverbulletmd/silverbullet/type/client";

export class CodeWidgetHook implements Hook<CodeWidgetT> {
  codeWidgetCallbacks = new Map<string, CodeWidgetCallback>();
  codeWidgetModes = new Map<string, "markdown" | "iframe">();

  constructor() {
  }

  collectAllCodeWidgets(system: System<CodeWidgetT>) {
    this.codeWidgetCallbacks.clear();
    for (const plug of system.loadedPlugs.values()) {
      for (
        const [name, functionDef] of Object.entries(
          plug.manifest!.functions,
        )
      ) {
        if (!functionDef.codeWidget) {
          continue;
        }
        this.codeWidgetModes.set(
          functionDef.codeWidget,
          functionDef.renderMode || "iframe",
        );
        this.codeWidgetCallbacks.set(
          functionDef.codeWidget,
          (bodyText, pageName) => {
            return plug.invoke(name, [bodyText, pageName]);
          },
        );
      }
    }
  }

  apply(system: System<CodeWidgetT>): void {
    this.collectAllCodeWidgets(system);
    system.on({
      plugLoaded: () => {
        this.collectAllCodeWidgets(system);
      },
    });
  }

  validateManifest(manifest: Manifest<CodeWidgetT>): string[] {
    const errors = [];
    for (const functionDef of Object.values(manifest.functions)) {
      if (!functionDef.codeWidget) {
        continue;
      }
      if (typeof functionDef.codeWidget !== "string") {
        errors.push(`Codewidgets require a string name.`);
      }
    }
    return errors;
  }
}


================================================
FILE: client/plugos/hooks/command.ts
================================================
import type { Hook, Manifest } from "../types.ts";
import type { System } from "../system.ts";
import { EventEmitter } from "../event.ts";
import { throttle } from "@silverbulletmd/silverbullet/lib/async";
import type { Command, CommandHookEvents } from "../../types/command.ts";
import type { CommandHookT } from "@silverbulletmd/silverbullet/type/manifest";

export class CommandHook extends EventEmitter<CommandHookEvents>
  implements Hook<CommandHookT> {
  system?: System<CommandHookT>;
  public throttledBuildAllCommandsAndEmit = throttle(() => {
    this.buildAllCommandsAndEmit();
  }, 200);

  constructor(
    private readOnly: boolean,
    private additionalCommands: Map<string, Command>,
  ) {
    super();
  }

  /**
   * Build the command map
   */
  buildAllCommands(): Map<string, Command> {
    const commands = new Map<string, Command>();
    // Add commands from plugs
    if (!this.system) {
      // Not initialized yet
      return commands;
    }
    for (const plug of this.system.loadedPlugs.values()) {
      for (
        const [name, functionDef] of Object.entries(
          plug.manifest!.functions,
        )
      ) {
        if (!functionDef.command) {
          continue;
        }
        const cmd = functionDef.command;
        if (cmd.requireMode === "rw" && this.readOnly) {
          // Bit hacky, but don't expose commands that require write mode in read-only mode
          continue;
        }
        commands.set(cmd.name, {
          ...cmd,
          run: (args?: string[]) => {
            return plug.invoke(name, [cmd, ...args ?? []]);
          },
        });
      }
    }
    for (const [name, cmd] of this.additionalCommands) {
      if (commands.has(name)) {
        // Existing command, let's do some inline patching
        const existingCommand = commands.get(name)!;
        const command: Command = {
          ...existingCommand,
          ...cmd,
        };
        if (cmd.run) {
          command.run = cmd.run;
        }
        commands.set(name, command);
      } else {
        // New command, let's just set
        commands.set(name, cmd);
      }
    }
    return commands;
  }

  public buildAllCommandsAndEmit() {
    this.emit("commandsUpdated", this.buildAllCommands());
  }

  apply(system: System<CommandHookT>): void {
    this.system = system;
    system.on({
      plugLoaded: () => {
        this.throttledBuildAllCommandsAndEmit();
      },
    });
    // On next tick
    setTimeout(() => {
      this.throttledBuildAllCommandsAndEmit();
    });
  }

  validateManifest(manifest: Manifest<CommandHookT>): string[] {
    const errors = [];
    for (const [name, functionDef] of Object.entries(manifest.functions)) {
      if (!functionDef.command) {
        continue;
      }
      const cmd = functionDef.command;
      if (!cmd.name) {
        errors.push(`Function ${name} has a command but no name`);
      }
    }
    return [];
  }
}


================================================
FILE: client/plugos/hooks/document_editor.ts
================================================
import type { Hook, Manifest } from "../types.ts";
import type { DocumentEditorT } from "@silverbulletmd/silverbullet/type/manifest";
import type { System } from "../system.ts";
import type { DocumentEditorCallback } from "@silverbulletmd/silverbullet/type/client";

export class DocumentEditorHook implements Hook<DocumentEditorT> {
  documentEditors = new Map<
    string,
    { extensions: string[]; callback: DocumentEditorCallback }
  >();

  constructor() {
  }

  collectAllDocumentEditors(system: System<DocumentEditorT>) {
    this.documentEditors.clear();
    for (const plug of system.loadedPlugs.values()) {
      for (
        const [name, functionDef] of Object.entries(
          plug.manifest!.functions,
        )
      ) {
        if (!functionDef.editor) {
          continue;
        }

        const keys = Array.isArray(functionDef.editor)
          ? functionDef.editor
          : [functionDef.editor];

        const conflict = Array.from(this.documentEditors.entries()).find((
          [_, { extensions }],
        ) => keys.some((key) => extensions.includes(key)));

        if (conflict) {
          console.log(
            `Extension definition of document editor ${name}: [${keys}] conflicts with the one from ${
              conflict[0]
            }: [${conflict[1].extensions}]! Using the latter.`,
          );
        }

        this.documentEditors.set(
          name,
          { extensions: keys, callback: () => plug.invoke(name, []) },
        );
      }
    }
  }

  apply(system: System<DocumentEditorT>): void {
    this.collectAllDocumentEditors(system);
    system.on({
      plugLoaded: () => {
        this.collectAllDocumentEditors(system);
      },
    });
  }

  validateManifest(manifest: Manifest<DocumentEditorT>): string[] {
    const errors = [];
    for (const functionDef of Object.values(manifest.functions)) {
      if (!functionDef.editor) {
        continue;
      }
      if (
        typeof functionDef.editor !== "string" &&
        !Array.isArray(functionDef.editor)
      ) {
        errors.push(
          `Document editors require a string name or an array of string names.`,
        );
      }
    }
    return errors;
  }
}


================================================
FILE: client/plugos/hooks/event.ts
================================================
// deno-lint-ignore-file ban-types
import type { Manifest } from "../types.ts";
import type { System } from "../system.ts";
import type { EventHookI } from "../eventhook.ts";
import type { EventHookT } from "@silverbulletmd/silverbullet/type/manifest";
import type { Config } from "../../config.ts";

// System events:
// - plug:load (plugName: string)

export class EventHook implements EventHookI {
  private system?: System<EventHookT>;
  private localListeners: Map<string, ((...args: any[]) => any)[]> = new Map();

  constructor(readonly config?: Config) {
  }

  addLocalListener(eventName: string, callback: (...args: any[]) => any) {
    if (!this.localListeners.has(eventName)) {
      this.localListeners.set(eventName, []);
    }
    this.localListeners.get(eventName)!.push(callback);
  }

  removeLocalListener(eventName: string, callback: (...args: any[]) => any) {
    if (!this.localListeners.has(eventName)) {
      return;
    }
    const listeners = this.localListeners.get(eventName)!;
    const index = listeners.indexOf(callback);
    if (index !== -1) {
      listeners.splice(index, 1);
    }
    if (listeners.length === 0) {
      this.localListeners.delete(eventName);
    }
  }

  // Pull all events listened to
  listEvents(): string[] {
    if (!this.system) {
      throw new Error("Event hook is not initialized");
    }
    const eventNames = new Set<string>();
    for (const plug of this.system.loadedPlugs.values()) {
      for (const functionDef of Object.values(plug.manifest!.functions)) {
        if (functionDef.events) {
          for (const eventName of functionDef.events) {
            eventNames.add(eventName);
          }
        }
      }
    }
    for (const eventName of this.localListeners.keys()) {
      eventNames.add(eventName);
    }
    if (this.config) {
      const configListeners: Record<string, Function[]> = this.config.get(
        "eventListeners",
        {},
      );
      for (const name of Object.keys(configListeners)) {
        eventNames.add(name);
      }
    }

    return [...eventNames];
  }

  async dispatchEvent(eventName: string, ...args: any[]): Promise<any[]> {
    if (!this.system) {
      throw new Error("Event hook is not initialized");
    }
    const promises: Promise<any>[] = [];
    for (const plug of this.system.loadedPlugs.values()) {
      const manifest = plug.manifest;
      for (
        const [name, functionDef] of Object.entries(
          manifest!.functions,
        )
      ) {
        if (functionDef.events) {
          for (const event of functionDef.events) {
            if (
              event === eventName || eventNameToRegex(event).test(eventName)
            ) {
              // Only dispatch functions that can run in this environment
              if (plug.canInvoke(name)) {
                // Queue the promise
                promises.push((async () => {
                  try {
                    return await plug.invoke(name, args);
                  } catch (e: any) {
                    console.error(
                      `Error dispatching event ${eventName} to ${plug.manifest.name}.${name}: ${e.message}`,
                    );
                    throw e;
                  }
                })());
              }
            }
          }
        }
      }
    }

    // Local listeners
    for (const [name, localListeners] of this.localListeners) {
      if (eventNameToRegex(name).test(eventName)) {
        for (const localListener of localListeners) {
          // Queue the promise
          promises.push((async () => {
            return await Promise.resolve(localListener(...args));
          })());
        }
      }
    }

    // Space Lua listeners
    if (this.config) {
      const configListeners: Record<string, Function[]> = this.config.get(
        "eventListeners",
        {},
      );
      for (const [name, listeners] of Object.entries(configListeners)) {
        if (eventNameToRegex(name).test(eventName)) {
          for (const listener of listeners) {
            promises.push((async () => {
              return await Promise.resolve(
                listener({
                  name: eventName,
                  // Most events have a single argument, so let's optimize for that, otherwise pass all arguments as an array
                  data: args.length === 1 ? args[0] : args,
                }),
              );
            })());
          }
        }
      }
    }

    // Wait for all promises to resolve
    return (await Promise.allSettled(promises))
      .filter((result) => {
        if (result.status === "rejected") {
          console.error(
            "Error while dispatching event",
            eventName,
            ":",
            result.reason,
          );
        }
        return result.status === "fulfilled";
      })
      .map((result) => result.value)
      .filter((result) => result != null); // This keeps non-null/undefined results
  }

  apply(system: System<EventHookT>): void {
    this.system = system;
    this.system.on({
      plugLoaded: async (plug) => {
        await this.dispatchEvent("plug:load", plug.manifest.name);
      },
    });
  }

  validateManifest(manifest: Manifest<EventHookT>): string[] {
    const errors = [];
    for (
      const [_, functionDef] of Object.entries(
        manifest.functions || {},
      )
    ) {
      if (functionDef.events && !Array.isArray(functionDef.events)) {
        errors.push("'events' key must be an array of strings");
      }
    }
    return errors;
  }
}

function eventNameToRegex(eventName: string): RegExp {
  return new RegExp(
    `^${eventName.replace(/\*/g, ".*").replace(/\//g, "\\/")}$`,
  );
}


================================================
FILE: client/plugos/hooks/mq.ts
================================================
// deno-lint-ignore-file ban-types
import type { Hook, Manifest } from "../types.ts";
import type { System } from "../system.ts";
import { throttle } from "@silverbulletmd/silverbullet/lib/async";
import type { MQHookT } from "@silverbulletmd/silverbullet/type/manifest";
import type { DataStoreMQ, QueueWorker } from "../../data/mq.datastore.ts";
import type {
  MQMessage,
  MQSubscribeOptions,
} from "@silverbulletmd/silverbullet/type/datastore";
import type { Config } from "../../config.ts";

export type MQListenerSpec =
  & MQSubscribeOptions
  & {
    queue: string;
    autoAck?: boolean;
    run: Function;
  };

export class MQHook implements Hook<MQHookT> {
  subscriptions: QueueWorker[] = [];
  throttledReloadQueues = throttle(() => {
    this.reloadQueues();
  }, 1000);

  constructor(
    private system: System<MQHookT>,
    readonly mq: DataStoreMQ,
    readonly config: Config,
  ) {
  }

  apply(system: System<MQHookT>): void {
    this.system = system;
    system.on({
      plugLoaded: () => {
        this.throttledReloadQueues();
      },
      plugUnloaded: () => {
        this.throttledReloadQueues();
      },
    });

    this.throttledReloadQueues();
  }

  stop() {
    this.subscriptions.forEach((worker) => worker.stop());
    this.subscriptions = [];
  }

  reloadQueues() {
    this.stop();
    // Plug based subscriptions
    for (const plug of this.system.loadedPlugs.values()) {
      if (!plug.manifest) {
        continue;
      }
      for (
        const [name, functionDef] of Object.entries(
          plug.manifest.functions,
        )
      ) {
        if (!functionDef.mqSubscriptions) {
          continue;
        }
        const subscriptions = functionDef.mqSubscriptions;
        for (const subscriptionDef of subscriptions) {
          const queue = subscriptionDef.queue;
          this.subscriptions.push(
            this.mq.subscribe(
              queue,
              {
                batchSize: subscriptionDef.batchSize,
                pollInterval: subscriptionDef.pollInterval,
              },
              async (messages: MQMessage[]) => {
                try {
                  await plug.invoke(name, [messages]);
                  if (subscriptionDef.autoAck) {
                    await this.mq.batchAck(queue, messages.map((m) => m.id));
                  }
                } catch (e: any) {
                  console.error(
                    "Execution of mqSubscription for queue",
                    queue,
                    "invoking",
                    name,
                    "with messages",
                    messages,
                    "failed:",
                    e,
                  );
                }
              },
            ),
          );
        }
      }
    }
    // Space Lua based subscriptions
    const configListeners: Record<string, MQListenerSpec[]> = this.config.get(
      "mqSubscriptions",
      {},
    );
    for (const [queue, listeners] of Object.entries(configListeners)) {
      for (const listener of listeners) {
        // console.log("Subscribing to", queue, listener);
        this.subscriptions.push(
          this.mq.subscribe(
            queue,
            {
              batchSize: listener.batchSize,
              pollInterval: listener.pollInterval,
            },
            async (messages: MQMessage[]) => {
              try {
                await listener.run(messages);
                if (listener.autoAck) {
                  await this.mq.batchAck(queue, messages.map((m) => m.id));
                }
              } catch (e: any) {
                console.error(
                  "Execution of mqSubscription for queue",
                  queue,
                  "with messages",
                  messages,
                  "failed:",
                  e,
                );
              }
            },
          ),
        );
      }
    }
  }

  validateManifest(manifest: Manifest<MQHookT>): string[] {
    const errors: string[] = [];
    for (const functionDef of Object.values(manifest.functions)) {
      if (!functionDef.mqSubscriptions) {
        continue;
      }
      for (const subscriptionDef of functionDef.mqSubscriptions) {
        if (!subscriptionDef.queue) {
          errors.push("Missing queue name for mqSubscription");
        }
      }
    }
    return errors;
  }
}


================================================
FILE: client/plugos/hooks/plug_namespace.ts
================================================
import type { NamespaceOperation } from "@silverbulletmd/silverbullet/type/namespace";
import type { PlugNamespaceHookT } from "@silverbulletmd/silverbullet/type/manifest";
import type { Plug } from "../plug.ts";
import type { System } from "../system.ts";
import type { Hook, Manifest } from "../types.ts";

type SpaceFunction = {
  operation: NamespaceOperation;
  pattern: RegExp;
  plug: Plug<PlugNamespaceHookT>;
  name: string;
  env?: string;
};

export class PlugNamespaceHook implements Hook<PlugNamespaceHookT> {
  spaceFunctions: SpaceFunction[] = [];

  constructor() {
  }

  apply(system: System<PlugNamespaceHookT>): void {
    system.on({
      plugLoaded: () => {
        this.updateCache(system);
      },
      plugUnloaded: () => {
        this.updateCache(system);
      },
    });
  }

  updateCache(system: System<PlugNamespaceHookT>) {
    this.spaceFunctions = [];
    for (const plug of system.loadedPlugs.values()) {
      if (plug.manifest?.functions) {
        for (
          const [funcName, funcDef] of Object.entries(
            plug.manifest.functions,
          )
        ) {
          if (funcDef.pageNamespace) {
            this.spaceFunctions.push({
              operation: funcDef.pageNamespace.operation,
              pattern: new RegExp(funcDef.pageNamespace.pattern),
              plug,
              name: funcName,
              env: funcDef.env,
            });
          }
        }
      }
    }
  }

  validateManifest(manifest: Manifest<PlugNamespaceHookT>): string[] {
    const errors: string[] = [];
    if (!manifest.functions) {
      return [];
    }
    for (const [funcName, funcDef] of Object.entries(manifest.functions)) {
      if (funcDef.pageNamespace) {
        if (!funcDef.pageNamespace.pattern) {
          errors.push(`Function ${funcName} has a namespace but no pattern`);
        }
        if (!funcDef.pageNamespace.operation) {
          errors.push(`Function ${funcName} has a namespace but no operation`);
        }
        if (
          ![
            "readFile",
            "writeFile",
            "getFileMeta",
            "listFiles",
            "deleteFile",
          ].includes(funcDef.pageNamespace.operation)
        ) {
          errors.push(
            `Function ${funcName} has an invalid operation ${funcDef.pageNamespace.operation}`,
          );
        }
      }
    }
    return errors;
  }
}


================================================
FILE: client/plugos/hooks/slash_command.ts
================================================
import type { Hook, Manifest } from "../types.ts";
import type { System } from "../system.ts";
import type {
  Completion,
  CompletionContext,
  CompletionResult,
} from "@codemirror/autocomplete";
import type { Client } from "../../client.ts";
import { syntaxTree } from "@codemirror/language";
import { safeRun, throttle } from "@silverbulletmd/silverbullet/lib/async";
import type { SlashCommandHookT } from "@silverbulletmd/silverbullet/type/manifest";
import type { SlashCommand } from "../../types/command.ts";
import type {
  SlashCompletionOption,
  SlashCompletions,
} from "@silverbulletmd/silverbullet/type/client";

const slashCommandRegexp = /([^\w:]|^)\/[\w#\-]*/;

export class SlashCommandHook implements Hook<SlashCommandHookT> {
  slashCommands: SlashCommand[] = [];
  throttledBuildAllCommands = throttle(() => {
    this.buildAllCommands();
  }, 200);

  constructor(private client: Client) {
  }

  buildAllCommands() {
    const clientSystem = this.client.clientSystem;
    const system = clientSystem.system;

    this.slashCommands = [];
    for (const plug of system.loadedPlugs.values()) {
      for (
        const [name, functionDef] of Object.entries(
          plug.manifest!.functions,
        )
      ) {
        if (!functionDef.slashCommand) {
          continue;
        }
        const cmd = functionDef.slashCommand;
        this.slashCommands.push({
          ...cmd,
          run: () => {
            return plug.invoke(name, [cmd]);
          },
        });
      }
    }
    // Iterate over script defined slash commands
    for (
      const command of Object.values(
        this.client.config.get<Record<string, SlashCommand>>(
          "slashCommands",
          {},
        ),
      )
    ) {
      this.slashCommands.push(command);
    }
  }

  // Completer for CodeMirror
  public async slashCommandCompleter(
    ctx: CompletionContext,
  ): Promise<CompletionResult | null> {
    const prefix = ctx.matchBefore(slashCommandRegexp);
    if (!prefix) {
      return null;
    }
    const prefixText = prefix.text;
    const options: Completion[] = [];

    // No slash commands in comment blocks (queries and such) or links
    const currentNode = syntaxTree(ctx.state).resolveInner(ctx.pos);
    if (
      currentNode.type.name === "CommentBlock" ||
      currentNode.type.name === "Link"
    ) {
      return null;
    }

    // Check if the slash command is available in the current context
    const parentNodes = this.client.extractParentNodes(ctx.state, currentNode);
    for (const def of this.slashCommands) {
      if (
        def.onlyContexts &&
        !def.onlyContexts.some((context) =>
          parentNodes.some((node) => node.startsWith(context))
        )
      ) {
        continue;
      }
      if (
        def.exceptContexts && def.exceptContexts.some(
          (context) => parentNodes.some((node) => node.startsWith(context)),
        )
      ) {
        continue;
      }
      options.push({
        label: def.name,
        detail: def.description,
        boost: def.priority,
        apply: () => {
          // Delete slash command part
          this.client.editorView.dispatch({
            changes: {
              from: prefix!.from + prefixText.indexOf("/"),
              to: ctx.pos,
              insert: "",
            },
          });
          // Replace with whatever the completion is
          safeRun(async () => {
            await def.run!();
            this.client.focus();
          });
        },
      });
    }

    const slashCompletions: CompletionResult | SlashCompletions | null =
      await this.client
        .completeWithEvent(
          ctx,
          "slash:complete",
        );

    if (slashCompletions) {
      for (
        const slashCompletion of slashCompletions
          .options as SlashCompletionOption[]
      ) {
        options.push({
          label: slashCompletion.label,
          detail: slashCompletion.detail,
          boost: slashCompletion.order && -slashCompletion.order,
          apply: () => {
            // Delete slash command part
            this.client.editorView.dispatch({
              changes: {
                from: prefix!.from + prefixText.indexOf("/"),
                to: ctx.pos,
                insert: "",
              },
            });
            // Replace with whatever the completion is
            safeRun(async () => {
              await this.client.clientSystem.system.invokeFunction(
                slashCompletion.invoke,
                [slashCompletion],
              );
              this.client.focus();
            });
          },
        });
      }
    }

    return {
      // + 1 because of the '/'
      from: prefix.from + prefixText.indexOf("/") + 1,
      options: options,
    };
  }

  apply(system: System<SlashCommandHookT>): void {
    this.buildAllCommands();
    system.on({
      plugLoaded: () => {
        this.buildAllCommands();
      },
    });
  }

  validateManifest(manifest: Manifest<SlashCommandHookT>): string[] {
    const errors = [];
    for (const [name, functionDef] of Object.entries(manifest.functions)) {
      if (!functionDef.slashCommand) {
        continue;
      }
      const cmd = functionDef.slashCommand;
      if (!cmd.name) {
        errors.push(`Function ${name} has a command but no name`);
      }
    }
    return [];
  }
}


================================================
FILE: client/plugos/hooks/syscall.ts
================================================
import type { Hook, Manifest } from "../types.ts";
import type { SysCallMapping, System } from "../system.ts";
import type { SyscallHookT } from "@silverbulletmd/silverbullet/type/manifest";

export class SyscallHook implements Hook<SyscallHookT> {
  apply(system: System<SyscallHookT>): void {
    this.registerSyscalls(system);
    system.on({
      plugLoaded: () => {
        this.registerSyscalls(system);
      },
    });
  }

  registerSyscalls(system: System<SyscallHookT>) {
    // Register syscalls from all loaded plugs
    for (const plug of system.loadedPlugs.values()) {
      const syscalls: SysCallMapping = {};

      for (
        const [name, functionDef] of Object.entries(plug.manifest!.functions)
      ) {
        if (!functionDef.syscall) {
          continue;
        }

        const syscallName = functionDef.syscall;

        // Add the syscall to our mapping
        syscalls[syscallName] = (ctx, ...args) => {
          // Delegate to the system to invoke the function
          return system.syscall(ctx, "system.invokeFunction", [
            `${plug.manifest!.name}.${name}`,
            ...args,
          ]);
        };

        // Register the syscalls with no required permissions
        system.registerSyscalls([], syscalls);
      }
    }
  }

  validateManifest(manifest: Manifest<SyscallHookT>): string[] {
    const errors: string[] = [];
    for (const [name, functionDef] of Object.entries(manifest.functions)) {
      if (!functionDef.syscall) {
        continue;
      }

      // Validate syscall name is provided
      if (!functionDef.syscall) {
        errors.push(`Function ${name} has a syscall but no name`);
        continue;
      }

      // Validate syscall name format (should be namespaced)
      if (!functionDef.syscall.includes(".")) {
        errors.push(
          `Function ${name} has invalid syscall name "${functionDef.syscall}" - must be in format "namespace.name"`,
        );
      }
    }
    return errors;
  }
}


================================================
FILE: client/plugos/manifest_cache.ts
================================================
import type { KvPrimitives } from "../data/kv_primitives.ts";
import type { Plug } from "./plug.ts";
import type { Manifest } from "./types.ts";

export interface ManifestCache<T> {
  getManifest(
    plug: Plug<T>,
    cacheKey: string,
    cacheHash: number,
  ): Promise<Manifest<T>>;
}

export class KVPrimitivesManifestCache<T> implements ManifestCache<T> {
  constructor(private kv: KvPrimitives, private manifestPrefix: string) {
  }

  async getManifest(
    plug: Plug<T>,
    cacheKey: string,
    cacheHash: number,
  ): Promise<Manifest<T>> {
    const [cached] = await this.kv.batchGet([[
      this.manifestPrefix,
      cacheKey,
    ]]);
    if (cached && cached.hash === cacheHash) {
      // console.log("Using KV cached manifest for", plug.name);
      return cached.manifest;
    }
    await plug.sandbox.init();
    const manifest = plug.sandbox.manifest!;
    await this.kv.batchSet([{
      key: [this.manifestPrefix, cacheKey],
      // Deliverately removing the assets from the manifest to preserve space, will be re-added upon load of actual worker
      value: { manifest: { ...manifest, assets: undefined }, hash: cacheHash },
    }]);
    return manifest;
  }
}

export class InMemoryManifestCache<T> implements ManifestCache<T> {
  private cache = new Map<string, {
    manifest: Manifest<T>;
    hash: number;
  }>();

  async getManifest(
    plug: Plug<T>,
    cacheKey: string,
    cacheHash: number,
  ): Promise<Manifest<T>> {
    const cached = this.cache.get(cacheKey);
    if (cached && cached.hash === cacheHash) {
      // console.log("Using memory cached manifest for", plug.name);
      return cached.manifest;
    }
    await plug.sandbox.init();
    const manifest = plug.sandbox.manifest!;

    // Deliverately removing the assets from the manifest to preserve space, will be re-added upon load of actual worker
    this.cache.set(cacheKey, {
      manifest: { ...manifest, assets: undefined },
      hash: cacheHash,
    });
    return manifest;
  }
}


================================================
FILE: client/plugos/plug.ts
================================================
import type { Manifest } from "./types.ts";
import type { System } from "./system.ts";
import type { AssetBundle } from "../asset_bundle/bundle.ts";
import type { Sandbox, SandboxFactory } from "./sandboxes/sandbox.ts";

export class Plug<HookT> {
  readonly runtimeEnv?: string;

  public grantedPermissions: string[] = [];
  public sandbox: Sandbox<HookT>;

  public manifest!: Manifest<HookT>;
  public assets?: AssetBundle;

  constructor(
    readonly system: System<HookT>,
    sandboxFactory: SandboxFactory<HookT>,
  ) {
    this.runtimeEnv = system.env;
    this.sandbox = sandboxFactory(this);
  }

  static async createLazily<HookT>(
    system: System<HookT>,
    cacheKey: string,
    cacheHash: number,
    sandboxFactory: SandboxFactory<HookT>,
  ): Promise<Plug<HookT>> {
    const plug = new Plug(
      system,
      sandboxFactory,
    );

    // Retrieve the manifest, which may either come from a cache or be loaded from the worker
    plug.manifest = await system.options.manifestCache!.getManifest(
      plug,
      cacheKey,
      cacheHash,
    );

    // TODO: These need to be explicitly granted, not just taken
    plug.grantedPermissions = plug.manifest.requiredPermissions || [];

    return plug;
  }

  // Invoke a syscall
  syscall(name: string, args: any[]): Promise<any> {
    return this.system.syscall({ plug: this.manifest.name }, name, args);
  }

  /**
   * Checks if a function can be invoked (it may be restricted on its execution environment)
   */
  canInvoke(name: string) {
    const funDef = this.manifest!.functions[name];
    if (!funDef) {
      throw new Error(`Function ${name} not found in manifest`);
    }
    return !funDef.env || !this.runtimeEnv || funDef.env === this.runtimeEnv;
  }

  // Invoke a function
  async invoke(name: string, args: any[]): Promise<any> {
    // Before we access the manifest
    const funDef = this.manifest!.functions[name];
    if (!funDef) {
      throw new Error(`Function ${name} not found in manifest`);
    }
    const sandbox = this.sandbox!;
    if (funDef.redirect) {
      // Function redirect, look up
      // deno-lint-ignore no-this-alias
      let plug: Plug<HookT> | undefined = this;
      if (funDef.redirect.indexOf(".") !== -1) {
        const [plugName, functionName] = funDef.redirect.split(".");
        plug = this.system.loadedPlugs.get(plugName);
        if (!plug) {
          throw Error(`Plug ${plugName} redirected to not found`);
        }
        name = functionName;
      } else {
        name = funDef.redirect;
      }
      return plug.invoke(name, args);
    }
    if (!await this.canInvoke(name)) {
      throw new Error(
        `Function ${name} is not available in ${this.runtimeEnv}`,
      );
    }
    return sandbox.invoke(name, args);
  }

  stop() {
    console.log("Stopping sandbox for", this.manifest.name);
    this.sandbox.stop();
  }
}


================================================
FILE: client/plugos/plug_compile.ts
================================================
import * as path from "@std/path";
import * as YAML from "@std/yaml";
import { denoPlugin, esbuild } from "../../build_deps.ts";
import { bundleAssets } from "../asset_bundle/builder.ts";
import type { Manifest } from "./types.ts";
import { version } from "../../version.ts";

// const workerRuntimeUrl = new URL(
//   "../lib/plugos/worker_runtime.ts",
//   import.meta.url,
// );
const workerRuntimeUrl =
  `https://deno.land/x/silverbullet@${version}/client/plugos/worker_runtime.ts`;

export type CompileOptions = {
  debug?: boolean;
  runtimeUrl?: string;
  // path to config file
  configPath?: string;
  // Print info on bundle size
  info?: boolean;
};

export async function compileManifest(
  manifestPath: string,
  destPath: string,
  options: CompileOptions = {},
): Promise<string> {
  const rootPath = path.dirname(manifestPath);
  const manifest = YAML.parse(
    await Deno.readTextFile(manifestPath),
  ) as Manifest<any>;

  if (!manifest.name) {
    throw new Error(`Missing 'name' in ${manifestPath}`);
  }

  // Assets
  const assetsBundle = await bundleAssets(
    path.resolve(rootPath),
    manifest.assets as string[] || [],
  );
  manifest.assets = assetsBundle.toJSON();

  // Normalize the edge case of a plug with no functions
  if (!manifest.functions) {
    manifest.functions = {};
  }

  const jsFile = `
import { setupMessageListener } from "${
    options.runtimeUrl || workerRuntimeUrl
  }";

// Imports
${
    Object.entries(manifest.functions).map(([funcName, def]) => {
      if (!def.path) {
        return "";
      }
      let [filePath, jsFunctionName] = def.path.split(":");
      // Resolve path
      filePath = path.join(rootPath, filePath);

      return `import {${jsFunctionName} as ${funcName}} from "file://${
        // Replacing \ with / for Windows
        path.resolve(filePath).replaceAll(
          "\\",
          "\\\\",
        )}";\n`;
    }).join("")
  }

// Function mapping
const functionMapping = {
${
    Object.entries(manifest.functions).map(([funcName, def]) => {
      if (!def.path) {
        return "";
      }
      return `  ${funcName}: ${funcName},\n`;
    }).join("")
  }
};

// Manifest
const manifest = ${JSON.stringify(manifest, null, 2)};

export const plug = {manifest, functionMapping};

setupMessageListener(functionMapping, manifest, self.postMessage);
`;

  // console.log("Code:", jsFile);

  const inFile = await Deno.makeTempFile({ suffix: ".js" });
  const outFile = `${destPath}/${manifest.name}.plug.js`;
  await Deno.writeTextFile(inFile, jsFile);

  const result = await esbuild.build({
    entryPoints: [inFile],
    bundle: true,
    format: "esm",
    globalName: "mod",
    platform: "browser",
    sourcemap: "linked",
    minify: !options.debug,
    outfile: outFile,
    metafile: options.info,
    treeShaking: true,
    plugins: [
      denoPlugin({
        configPath: options.configPath &&
          path.resolve(Deno.cwd(), options.configPath),
      }),
    ],
  });

  if (options.info) {
    const text = await esbuild.analyzeMetafile(result.metafile!);
    console.log("Bundle info for", manifestPath, text);
  }

  let jsCode = await Deno.readTextFile(outFile);
  jsCode = patchDenoLibJS(jsCode);
  await Deno.writeTextFile(outFile, jsCode);
  console.log(`Plug ${manifest.name} written to ${outFile}.`);
  return outFile;
}

export async function compileManifests(
  manifestFiles: string[],
  dist: string,
  options: CompileOptions = {},
) {
  let building = false;
  dist = path.resolve(dist);

  async function buildAll() {
    if (building) {
      return;
    }
    console.log("Building", manifestFiles);
    building = true;
    Deno.mkdirSync(dist, { recursive: true });
    const startTime = Date.now();
    // Build all plugs in parallel
    await Promise.all(manifestFiles.map(async (plugManifestPath) => {
      const manifestPath = plugManifestPath as string;
      try {
        await compileManifest(
          manifestPath,
          dist,
          options,
        );
      } catch (e: any) {
        console.error(`Error building ${manifestPath}:`, e.message);
        throw e;
      }
    }));
    console.log(`Done building plugs in ${Date.now() - startTime}ms`);
    building = false;
  }

  await buildAll();
}

export function patchDenoLibJS(code: string): string {
  // The Deno std lib has one occurence of a regex that Webkit JS doesn't (yet parse), we'll strip it because it's likely never invoked anyway, YOLO
  return code.replaceAll("/(?<=\\n)/", "/()/");
}

export async function plugCompileCommand(
  { dist, debug, info, config, runtimeUrl }: {
    dist: string;
    debug: boolean;
    info: boolean;
    config?: string;
    runtimeUrl?: string;
  },
  ...manifestPaths: string[]
) {
  await compileManifests(
    manifestPaths,
    dist,
    {
      debug: debug,
      info: info,
      runtimeUrl,
      configPath: config,
    },
  );
  esbuild.stop();
  Deno.exit(0);
}


================================================
FILE: client/plugos/protocol.ts
================================================
import type { Manifest } from "@silverbulletmd/silverbullet/type/manifest";

// Messages received from the worker
export type ControllerMessage =
  | {
    // Parsed manifest when worker is initialized
    type: "manifest";
    manifest: Manifest;
  }
  | {
    // Function invocation result
    type: "invr";
    id: number;
    error?: string;
    result?: any;
  }
  | {
    // Syscall
    type: "sys";
    id: number;
    name: string;
    args: any[];
  };

// Messages received inside the worker
export type WorkerMessage =
  | {
    // Function invocation
    type: "inv";
    id: number;
    name: string;
    args: any[];
  }
  | {
    // Syscall result
    type: "sysr";
    id: number;
    result?: any;
    error?: any;
  };


================================================
FILE: client/plugos/proxy_fetch.ts
================================================
import { base64Decode, base64Encode } from "../../plug-api/lib/crypto.ts";

export type ProxyFetchRequest64 = {
  method?: string;
  headers?: Record<string, string>;
  base64Body?: string;
};

export type ProxyFetchResponse64 = {
  ok: boolean;
  status: number;
  headers: Record<string, string>;
  // We base64 encode the body because the body can be binary data that we have to push through the worker boundary
  base64Body: string;
};

export type ProxyFetchRequest = {
  method?: string;
  headers?: Record<string, string>;
  responseEncoding?: string;
  body?: Uint8Array | string | any;
};

export type ProxyFetchResponse = {
  ok: boolean;
  status: number;
  headers: Record<string, string>;
  // We base64 encode the body because the body can be binary data that we have to push through the worker boundary
  body: Uint8Array | string | any;
};

export async function performLocalFetch(
  url: string,
  req: ProxyFetchRequest64,
): Promise<ProxyFetchResponse64> {
  const result = await fetch(
    url,
    req && {
      method: req.method,
      headers: req.headers,
      body: req.base64Body && base64Decode(req.base64Body),
      // Casting  to "any" for now, since of weird Deno typing
    } as any,
  );
  return {
    ok: result.ok,
    status: result.status,
    headers: Object.fromEntries(result.headers.entries()),
    base64Body: base64Encode(
      new Uint8Array(await (await result.blob()).arrayBuffer()),
    ),
  };
}


================================================
FILE: client/plugos/sandboxes/deno_worker_sandbox.ts
================================================
import { WorkerSandbox } from "./worker_sandbox.ts";
import type { SandboxFactory } from "./sandbox.ts";

export function createSandbox<HookT>(workerUrl: URL): SandboxFactory<HookT> {
  return (plug) => new WorkerSandbox(plug, workerUrl);
}


================================================
FILE: client/plugos/sandboxes/sandbox.ts
================================================
import type { Plug } from "../plug.ts";
import type { Manifest } from "../types.ts";

export type SandboxFactory<HookT> = (plug: Plug<HookT>) => Sandbox<HookT>;

export interface Sandbox<HookT> {
  manifest?: Manifest<HookT>;

  init(): Promise<void>;

  invoke(name: string, args: any[]): Promise<any>;

  stop(): void;
}


================================================
FILE: client/plugos/sandboxes/web_worker_sandbox.ts
================================================
import { WorkerSandbox } from "./worker_sandbox.ts";
import type { Plug } from "../plug.ts";
import type { SandboxFactory } from "./sandbox.ts";
import { fsEndpoint } from "../../spaces/constants.ts";

export function createWorkerSandboxFromLocalPath<HookT>(
  name: string,
): SandboxFactory<HookT> {
  return (plug: Plug<HookT>) =>
    new WorkerSandbox(
      plug,
      new URL(
        name,
        document.baseURI.slice(0, -1) + fsEndpoint + "/", // We're NOT striping trailing '/', this used to be `location.origin`
      ),
    );
}


================================================
FILE: client/plugos/sandboxes/worker_sandbox.ts
================================================
import type { Manifest } from "../types.ts";
import type { ControllerMessage, WorkerMessage } from "../protocol.ts";
import type { Plug } from "../plug.ts";
import { AssetBundle, type AssetJson } from "../../asset_bundle/bundle.ts";
import type { Sandbox } from "./sandbox.ts";
import { race, timeout } from "@silverbulletmd/silverbullet/lib/async";

/**
 * Represents a "safe" execution environment for plug code
 * Effectively this wraps a web worker, the reason to have this split from Plugs is to allow plugs to manage multiple sandboxes, e.g. for performance in the future
 */
export class WorkerSandbox<HookT> implements Sandbox<HookT> {
  public manifest?: Manifest<HookT>;
  private worker?: Worker;
  private reqId = 0;
  private outstandingInvocations = new Map<
    number,
    { resolve: (result: any) => void; reject: (e: any) => void }
  >();

  constructor(
    readonly plug: Plug<HookT>,
    public workerUrl: URL,
    private workerOptions = {},
  ) {
  }

  /**
   * Should only invoked lazily (either by invoke, or by a ManifestCache to load the manifest)
   */
  init(): Promise<void> {
    console.log("Booting up worker from", this.workerUrl.pathname);
    if (this.worker) {
      // Race condition
      console.warn("Double init of sandbox, ignoring");
      return Promise.resolve();
    }
    this.worker = new Worker(this.workerUrl, {
      ...this.workerOptions,
      type: "module",
    });

    return race([
      // We're adding a timeout of 5s here to handle the case where a plug blows up during initialization
      timeout(5000).catch((_) =>
        Promise.reject(new Error("Plug timed out during creation"))
      ),
      new Promise((resolve) => {
        this.worker!.onmessage = (ev) => {
          if (ev.data.type === "manifest") {
            this.manifest = ev.data.manifest;
            // Set manifest in the plug
            this.plug.manifest = this.manifest!;

            // Set assets in the plug
            this.plug.assets = new AssetBundle(
              this.manifest?.assets ? this.manifest.assets as AssetJson : {},
            );

            return resolve();
          }

          this.onMessage(ev.data);
        };
      }),
    ]);
  }

  async onMessage(data: ControllerMessage) {
    if (!this.worker) {
      console.warn("Received message for terminated worker, ignoring");
      return;
    }
    switch (data.type) {
      case "sys":
        try {
          const result = await this.plug.syscall(data.name!, data.args!);

          this.worker && this.worker!.postMessage({
            type: "sysr",
            id: data.id,
            result: result,
          } as WorkerMessage);
        } catch (e: any) {
          // console.error("Syscall fail", e);
          this.worker && this.worker!.postMessage({
            type: "sysr",
            id: data.id,
            error: e.message,
          } as WorkerMessage);
        }
        break;
      case "invr": {
        const resultCbs = this.outstandingInvocations.get(data.id!);
        this.outstandingInvocations.delete(data.id!);
        if (data.error) {
          resultCbs &&
            resultCbs.reject(new Error(data.error));
        } else {
          resultCbs && resultCbs.resolve(data.result);
        }
        break;
      }
      default:
        console.error("Unknown message type", data);
    }
  }

  async invoke(name: string, args: any[]): Promise<any> {
    if (!this.worker) {
      // Lazy initialization
      await this.init();
    }
    this.reqId++;
    this.worker!.postMessage({
      type: "inv",
      id: this.reqId,
      name,
      args,
    } as WorkerMessage);
    return new Promise((resolve, reject) => {
      this.outstandingInvocations.set(this.reqId, { resolve, reject });
    });
  }

  stop() {
    if (this.worker) {
      this.worker.terminate();
      this.worker = undefined;
    }
  }
}


================================================
FILE: client/plugos/syscalls/asset.ts
================================================
import type { SysCallMapping, System } from "../system.ts";
import type { FileMeta } from "../../../plug-api/types/index.ts";

export default function assetSyscalls(system: System<any>): SysCallMapping {
  return {
    "asset.readAsset": (_ctx, plugName: string, name: string): string => {
      return system.loadedPlugs.get(plugName)!.assets!.readFileAsDataUrl(
        name,
      );
    },
    "asset.listFiles": (_ctx, plugName: string): FileMeta[] => {
      const assets = system.loadedPlugs.get(plugName)!.assets!;
      const fileNames = assets.listFiles();
      return fileNames.map((name) => ({
        name,
        contentType: assets.getMimeType(name),
        created: assets.getMtime(name),
        lastModified: assets.getMtime(name),
        size: -1,
        perm: "ro",
      }));
    },
    "asset.getFileMeta": (_ctx, plugName: string, name: string): FileMeta => {
      const assets = system.loadedPlugs.get(plugName)!.assets!;
      return {
        name,
        contentType: assets.getMimeType(name),
        created: assets.getMtime(name),
        lastModified: assets.getMtime(name),
        size: -1,
        perm: "ro",
      };
    },
  };
}


================================================
FILE: client/plugos/syscalls/clientStore.ts
================================================
import type { SysCallMapping } from "../../plugos/system.ts";
import type { DataStore } from "../../data/datastore.ts";

import type { KvKey } from "@silverbulletmd/silverbullet/type/datastore";

export function clientStoreSyscalls(
  ds: DataStore,
  prefix: KvKey = ["client"],
): SysCallMapping {
  return {
    "clientStore.get": (_ctx, key: string): Promise<any> => {
      return ds.get([...prefix, key]);
    },
    "clientStore.set": (_ctx, key: string, val: any): Promise<void> => {
      return ds.set([...prefix, key], val);
    },
    "clientStore.delete": (_ctx, key: string): Promise<void> => {
      return ds.delete([...prefix, key]);
    },
  };
}


================================================
FILE: client/plugos/syscalls/client_code_widget.ts
================================================
import type { SysCallMapping } from "../../plugos/system.ts";
import { reloadAllWidgets } from "../../codemirror/code_widget.ts";
import { broadcastReload } from "../../components/widget_sandbox_iframe.ts";

export function clientCodeWidgetSyscalls(): SysCallMapping {
  return {
    "codeWidget.refreshAll": () => {
      broadcastReload();
      return reloadAllWidgets();
    },
  };
}


================================================
FILE: client/plugos/syscalls/code_widget.ts
================================================
import type { SysCallMapping } from "../system.ts";
import type { CodeWidgetHook } from "../hooks/code_widget.ts";
import type { CodeWidgetContent } from "@silverbulletmd/silverbullet/type/client";

export function codeWidgetSyscalls(
  codeWidgetHook: CodeWidgetHook,
): SysCallMapping {
  return {
    "codeWidget.render": (
      _ctx,
      lang: string,
      body: string,
      pageName: string,
    ): Promise<CodeWidgetContent | null> => {
      const langCallback = codeWidgetHook.codeWidgetCallbacks.get(
        lang,
      );
      if (!langCallback) {
        throw new Error(`Code widget ${lang} not found`);
      }
      return langCallback(body, pageName);
    },
  };
}


================================================
FILE: client/plugos/syscalls/config.ts
================================================
import type { SysCallMapping } from "../system.ts";
import type { Config } from "../../config.ts";
import { LuaStackFrame, luaValueToJS } from "../../space_lua/runtime.ts";

export function configSyscalls(config: Config): SysCallMapping {
  return {
    "config.get": (_ctx, path: string, defaultValue: any) => {
      return config.get(path, defaultValue);
    },
    "config.set": (
      _ctx,
      keyOrValues: string | string[] | Record<string, any>,
      value?: any,
    ) => {
      config.set(keyOrValues as any, value);
    },
    "lua:config.setLuaValue": async (
      _ctx,
      keyOrValues: string | string[] | Record<string, any>,
      value?: any,
    ) => {
      // This is for special cases where we explicitly want to NOT convert a value to a JS value, but maintain its Lua version (mostly for LuaTables) — main use case: metatable
      keyOrValues = await luaValueToJS(keyOrValues, LuaStackFrame.lostFrame);
      config.set(keyOrValues as any, value);
    },
    "config.insert": (
      _ctx,
      key: string | string[],
      value: any,
    ) => {
      config.insert(key, value);
    },
    "config.has": (_ctx, path: string) => {
      return config.has(path);
    },
    "config.define": (_ctx, key: string, schema: any) => {
      config.define(key, schema);
    },
    "config.getValues": () => {
      return config.values;
    },
    "config.getSchemas": () => {
      return config.schemas;
    },
  };
}


================================================
FILE: client/plugos/syscalls/datastore.ts
================================================
import {
  type LuaCollectionQuery,
  queryLua,
} from "../../space_lua/query_collection.ts";
import type { DataStore } from "../../data/datastore.ts";
import type { SysCallMapping } from "../system.ts";
import {
  jsToLuaValue,
  LuaEnv,
  LuaStackFrame,
  luaValueToJS,
} from "../../space_lua/runtime.ts";
import type { KvQueryOptions } from "../../data/kv_primitives.ts";
import type { ClientSystem } from "../../client_system.ts";

import type { KV, KvKey } from "@silverbulletmd/silverbullet/type/datastore";

/**
 * Exposes the datastore API to plugs, but scoping everything to a prefix based on the plug's name
 * @param ds the datastore to wrap
 * @param prefix prefix to scope all keys to to which the plug name will be appended
 */
export function dataStoreReadSyscalls(
  ds: DataStore,
  clientSystem: ClientSystem,
): SysCallMapping {
  return {
    "datastore.batchGet": (
      _ctx,
      keys: KvKey[],
    ): Promise<(any | undefined)[]> => {
      return ds.batchGet(keys);
    },

    "datastore.get": (_ctx, key: KvKey): Promise<any | null> => {
      return ds.get(key);
    },

    "datastore.query": async (_ctx, options: KvQueryOptions): Promise<KV[]> => {
      const results: KV[] = [];
      for await (const item of ds.query(options)) {
        results.push(item);
      }
      return results;
    },

    "datastore.queryLua": async (
      _ctx,
      prefix: string[],
      query: LuaCollectionQuery,
      scopeVariables?: Record<string, any>,
    ): Promise<any[]> => {
      const sf = LuaStackFrame.createWithGlobalEnv(
        clientSystem.spaceLuaEnv.env,
      );
      let env = clientSystem.spaceLuaEnv.env;
      if (scopeVariables) {
        env = new LuaEnv(clientSystem.spaceLuaEnv.env);
        for (const [key, value] of Object.entries(scopeVariables)) {
          env.setLocal(key, jsToLuaValue(value));
        }
      }
      return (await queryLua<any>(ds.kv, prefix, query, env, sf)).map((item) =>
        luaValueToJS(item, sf)
      );
    },
  };
}

export function dataStoreWriteSyscalls(ds: DataStore): SysCallMapping {
  return {
    "datastore.delete": (_ctx, key: KvKey) => {
      console.log("Deleting key", key);
      return ds.delete(key);
    },

    "datastore.set": (_ctx, key: KvKey, value: any) => {
      return ds.set(key, value);
    },

    "datastore.batchSet": (_ctx, kvs: KV[]) => {
      return ds.batchSet(kvs);
    },

    "datastore.batchDelete": (_ctx, keys: KvKey[]) => {
      return ds.batchDelete(keys);
    },
  };
}


================================================
FILE: client/plugos/syscalls/editor.ts
================================================
import type { Client } from "../../client.ts";
import {
  foldAll,
  foldCode,
  toggleFold,
  unfoldAll,
  unfoldCode,
} from "@codemirror/language";
import {
  deleteLine,
  insertNewline,
  insertNewlineAndIndent,
  moveLineDown,
  moveLineUp,
  redo,
  toggleComment,
  undo,
} from "@codemirror/commands";
import type { Transaction } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { getCM as vimGetCm, Vim } from "@replit/codemirror-vim";
import type { SysCallMapping } from "../system.ts";
import type {
  FilterOption,
  UploadFile,
} from "@silverbulletmd/silverbullet/type/client";
import { openSearchPanel } from "@codemirror/search";
import {
  isValidPath,
  parseToRef,
  type Path,
  type Ref,
} from "@silverbulletmd/silverbullet/lib/ref";
import { insertNewlineContinueMarkup } from "@codemirror/lang-markdown";
import type { VimConfig } from "@silverbulletmd/silverbullet/type/config";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";

export function editorSyscalls(client: Client): SysCallMapping {
  const syscalls: SysCallMapping = {
    "editor.getCurrentPage": (): string => {
      return client.currentName();
    },
    "editor.getCurrentPageMeta": (): Promise<PageMeta | undefined> => {
      const name = client.currentName();
      return client.objectIndex.getObjectByRef(name, "page", name);
    },
    "editor.getCurrentPath": (_ctx): string => {
      return client.currentPath();
    },
    "editor.getCurrentEditor": (): string => {
      return client.documentEditor?.name || "page";
    },
    "editor.getRecentlyOpenedPages": (): PageMeta[] => {
      return client.ui.viewState.allPages.sort((a, b) =>
        (b.lastOpened || 0) - (a.lastOpened || 0)
      );
    },
    "editor.getText": () => {
      return client.editorView.state.sliceDoc();
    },
    "editor.getCurrentLine": (): {
      from: number;
      to: number;
      text: string;
      textWithCursor: string;
    } => {
      const pos = client.editorView.state.selection.main.from;
      const line = client.editorView.state.doc.lineAt(pos);
      return {
        ...line,
        textWithCursor: line.text.slice(0, pos - line.from) + "|^|" +
          line.text.slice(pos - line.from),
      };
    },
    "editor.setText": (_ctx, newText: string, shouldIsolateHistory = false) => {
      client.setEditorText(newText, shouldIsolateHistory);
    },
    "editor.getCursor": (): number => {
      return client.editorView.state.selection.main.from;
    },
    "editor.getSelection": (): { from: number; to: number; text: string } => {
      const selection = client.editorView.state.selection.main;
      const text = client.editorView.state.sliceDoc(
        selection.from,
        selection.to,
      );
      return {
        from: selection.from,
        to: selection.to,
        text,
      };
    },
    "editor.save": () => {
      return client.save(true);
    },
    "editor.navigate": async (
      _ctx,
      ref: Ref | string,
      replaceState = false,
      newWindow = false,
    ) => {
      if (typeof ref === "string") {
        const parsedRef = parseToRef(ref);
        if (!parsedRef) {
          throw new Error(
            "Unable to parse string provided to `editor.navigate` as ref",
          );
        }
        ref = parsedRef;
      }

      if (
        // @ts-ignore: Legacy support
        ref.page !== undefined
      ) {
        console.warn(
          "You are using legacy navigation syntax with `editor.navigate()`, this will be phased out in the future",
        );

        const legacyRef = ref as unknown as {
          kind: "page" | "document";
          page: string;
          pos?: number | { line: number; column: number };
          header?: string;
          meta?: boolean;
        };

        legacyRef.kind ??= "page";

        let details: Ref["details"] = undefined;

        if (typeof legacyRef.pos === "number") {
          details = {
            type: "position",
            pos: legacyRef.pos,
          };
        } else if (legacyRef.pos) {
          details = {
            type: "linecolumn",
            line: legacyRef.pos.line,
            column: legacyRef.pos.column,
          };
        } else if (legacyRef.header) {
          details = {
            type: "header",
            header: legacyRef.header,
          };
        }

        ref = {
          path: (legacyRef.kind === "page"
            ? `${legacyRef.page}.md`
            : legacyRef.page) as Path,
          details,
          meta: legacyRef.meta,
        };
      }

      // This validation code should be connected to the Ref type. Ideally using
      // some validation library. Didn't want to use jsonschemas here tho.
      // Ideally this would be moved into a function too
      if (!isValidPath(ref.path) && ref.path !== "") {
        throw new Error(
          "Path passed in ref to `editor.navigate` is invalid",
        );
      } else if (typeof ref.meta !== "boolean" && ref.meta !== undefined) {
        throw new Error(
          "ref.meta has to be of type `boolean`",
        );
      } else if (ref.details !== undefined && typeof ref.details !== "object") {
        throw new Error(
          "ref.details has to be of type `object` or `undefined`",
        );
      } else if (
        ref.details &&
        !["position", "linecolumn", "header"].includes(ref.details.type)
      ) {
        throw new Error(
          "ref.details.type has to be 'position', 'linecolumn' or 'header'",
        );
      }

      if (
        ref.details?.type === "position" && typeof ref.details.pos !== "number"
      ) {
        throw new Error(
          "ref.details.pos has to be of type `number`",
        );
      } else if (
        ref.details?.type === "header" && typeof ref.details.header !== "string"
      ) {
        throw new Error(
          "ref.details.header has to be of type `string`",
        );
      } else if (
        ref.details?.type === "linecolumn" &&
        typeof ref.details.line !== "number" &&
        typeof ref.details.column !== "number"
      ) {
        throw new Error(
          "ref.details.line and ref.details.column has to be of type `number`",
        );
      }

      await client.navigate(ref, replaceState, newWindow);
    },
    "editor.reloadPage": async () => {
      await client.reloadEditor();
    },
    "editor.reloadUI": () => {
      location.reload();
    },
    "editor.rebuildEditorState": () => {
      client.rebuildEditorState();
    },
    "editor.reloadConfigAndCommands": async () => {
      await client.clientSystem.system.localSyscall(
        "system.loadScripts",
        [],
      );
      await client.clientSystem.system.localSyscall(
        "system.loadSpaceStyles",
        [],
      );
    },
    "editor.invokeCommand": (_ctx, name: string, args?: string[]) => {
      return client.runCommandByName(name, args);
    },
    "editor.openUrl": (_ctx, url: string, existingWindow = false) => {
      client.openUrl(url, existingWindow);
    },
    "editor.newWindow": () => {
      globalThis.open(
        location.href,
        "rnd" + Math.random(),
        `width=${globalThis.innerWidth},heigh=${globalThis.innerHeight}`,
      );
    },
    "editor.goHistory": (_ctx, delta: number) => {
      globalThis.history.go(delta);
    },
    "editor.downloadFile": (_ctx, filename: string, dataUrl: string) => {
      const link = document.createElement("a");
      link.href = dataUrl;
      link.download = filename;
      link.click();
    },
    "editor.uploadFile": (
      _ctx,
      accept?: string,
      capture?: string,
    ): Promise<UploadFile> => {
      return new Promise<UploadFile>((resolve, reject) => {
        const input = document.createElement("input");
        input.type = "file";
        if (accept) {
          input.accept = accept;
        }
        if (capture) {
          input.capture = capture;
        }

        input.onchange = () => {
          const file = input.files?.item(0);
          if (!file) {
            reject(new Error("No file found"));
          } else {
            const reader = new FileReader();
            reader.readAsArrayBuffer(file);
            reader.onloadend = async (evt) => {
              if (evt.target?.readyState == FileReader.DONE) {
                resolve({
                  name: file.name,
                  contentType: file.type,
                  content: new Uint8Array(await file.arrayBuffer()),
                });
              }
            };
            reader.onabort = (e) => {
              reject(e);
            };
            reader.onerror = (e) => {
              reject(e);
            };
          }
        };
        input.onabort = (e) => {
          reject(e);
        };

        input.click();
      });
    },
    "editor.flashNotification": (
      _ctx,
      message: string,
      type: "error" | "info" = "info",
    ) => {
      client.flashNotification(message, type);
    },
    "editor.filterBox": (
      _ctx,
      label: string,
      options: FilterOption[],
      helpText = "",
      placeHolder = "",
    ): Promise<FilterOption | undefined> => {
      return client.filterBox(label, options, helpText, placeHolder);
    },
    "editor.showPanel": (
      _ctx,
      id: string,
      mode: number,
      html: string,
      script: string,
    ) => {
      client.ui.viewDispatch({
        type: "show-panel",
        id: id as any,
        config: { html, script, mode },
      });
      setTimeout(() => {
        // Dummy dispatch to rerender the editor and toggle the panel
        client.editorView.dispatch({});
      });
    },
    "editor.hidePanel": (_ctx, id: string) => {
      client.ui.viewDispatch({
        type: "hide-panel",
        id: id as any,
      });
      setTimeout(() => {
        // Dummy dispatch to rerender the editor and toggle the panel
        client.editorView.dispatch({});
      });
    },
    "editor.showProgress": (
      _ctx,
      progressPercentage?: number,
      progressType?: "sync" | "index",
    ) => {
      client.showProgress(progressPercentage, progressType);
    },
    "editor.insertAtPos": (
      _ctx,
      text: string,
      pos: number,
      cursorPlaceHolder = false,
    ) => {
      let cursorPlaceholderPos = -1;
      if (cursorPlaceHolder) {
        cursorPlaceholderPos = text.indexOf("|^|");
        if (cursorPlaceholderPos !== -1) {
          text = text.slice(0, cursorPlaceholderPos) +
            text.slice(cursorPlaceholderPos + 3);
        } else {
          cursorPlaceHolder = false;
        }
      }
      client.editorView.dispatch({
        changes: {
          insert: text,
          from: pos,
        },
      });
      if (cursorPlaceHolder) {
        const cursorPos = pos + cursorPlaceholderPos;
        client.editorView.dispatch({
          selection: {
            anchor: cursorPos,
          },
          effects: [
            EditorView.scrollIntoView(cursorPos),
          ],
        });
      }
    },
    "editor.replaceRange": (
      _ctx,
      from: number,
      to: number,
      text: string,
      cursorPlaceHolder = false,
    ) => {
      let cursorPlaceholderPos = -1;
      if (cursorPlaceHolder) {
        cursorPlaceholderPos = text.indexOf("|^|");
        text = text.slice(0, cursorPlaceholderPos) +
          text.slice(cursorPlaceholderPos + 3);
      }
      client.editorView.dispatch({
        changes: {
          insert: text,
          from: from,
          to: to,
        },
      });
      if (cursorPlaceHolder) {
        const cursorPos = from + cursorPlaceholderPos;
        client.editorView.dispatch({
          selection: {
            anchor: cursorPos,
          },
          effects: [
            EditorView.scrollIntoView(cursorPos),
          ],
        });
      }
    },
    "editor.moveCursor": (_ctx, pos: number, center = false) => {
      client.editorView.dispatch({
        selection: {
          anchor: pos,
        },
      });
      if (center) {
        client.editorView.dispatch({
          effects: [
            EditorView.scrollIntoView(
              pos,
              {
                y: "center",
              },
            ),
          ],
        });
      }
      client.editorView.focus();
    },
    "editor.moveCursorToLine": (
      _ctx,
      line: number,
      column = 1,
      center = false,
    ) => {
      // CodeMirror already keeps information about lines
      const cmLine = client.editorView.state.doc.line(line);
      // How much to move inside the line, column number starts from 1
      const offset = Math.max(0, Math.min(cmLine.length, column - 1));
      // Just reuse the implementation above
      syscalls["editor.moveCursor"](_ctx, cmLine.from + offset, center);
    },
    "editor.setSelection": (_ctx, from: number, to: number) => {
      client.editorView.dispatch({
        selection: {
          anchor: from,
          head: to,
        },
      });
    },

    "editor.insertAtCursor": (
      _ctx,
      text: string,
      scrollIntoView = false,
      cursorPlaceHolder = false,
    ) => {
      const editorView = client.editorView;
      const from = editorView.state.selection.main.from;
      const cursorPlaceholderPos = text.indexOf("|^|");
      if (cursorPlaceHolder && cursorPlaceholderPos !== -1) {
        text = text.slice(0, cursorPlaceholderPos) +
          text.slice(cursorPlaceholderPos + 3);
      } else {
        cursorPlaceHolder = false;
      }
      editorView.dispatch({
        changes: {
          insert: text,
          from: from,
        },
        selection: {
          anchor: cursorPlaceHolder
            ? from + cursorPlaceholderPos
            : from + text.length,
        },
        scrollIntoView,
      });
    },
    "editor.dispatch": (_ctx, change: Transaction) => {
      client.editorView.dispatch(change);
    },
    "editor.prompt": (
      _ctx,
      message: string,
      defaultValue = "",
    ): Promise<string | undefined> => {
      return client.prompt(message, defaultValue);
    },
    "editor.confirm": (_ctx, message: string): Promise<boolean> => {
      return client.confirm(message);
    },
    "editor.alert": (_ctx, message: string) => {
      alert(message);
    },
    "editor.getUiOption": (_ctx, key: string): any => {
      return (client.ui.viewState.uiOptions as any)[key];
    },
    "editor.setUiOption": (_ctx, key: string, value: any) => {
      client.ui.viewDispatch({
        type: "set-ui-option",
        key,
        value,
      });
      client.reloadEditor();
    },
    "editor.vimEx": (_ctx, exCommand: string) => {
      const cm = vimGetCm(client.editorView);
      if (cm && cm.state.vim) {
        return Vim.handleEx(cm as any, exCommand);
      } else {
        throw new Error("Vim mode not active or not initialized.");
      }
    },
    "editor.configureVimMode": () => {
      // Override the default "o" binding to be more intelligent and follow the markdown editor's behavior
      Vim.mapCommand("o", "action", "newline-continue-markup", {}, {});
      Vim.mapCommand("O", "action", "back-newline-continue-markup", {}, {});
      Vim.unmap("<C-q>", undefined as any);
      Vim.defineAction("newline-continue-markup", (cm) => {
        // Append at end of line
        Vim.handleKey(cm, "A", "+input");
        // Insert newline continuing markup where appropriate
        insertNewlineContinueMarkup(client.editorView) ||
          insertNewlineAndIndent(client.editorView);
      });
      Vim.defineAction("back-newline-continue-markup", (cm) => {
        // Determine current line
        const pos = client.editorView.state.selection.main.from;
        const line = client.editorView.state.doc.lineAt(pos).number;
        if (line === 1) {
          // We're on the top line
          // Go to 0:0
          Vim.handleKey(cm, "0", "+input");
          // Insert a newline
          insertNewline(client.editorView);
          // Go up to the new line
          Vim.handleKey(cm, "k", "+input");
          // Into insert mode
          Vim.handleKey(cm, "i", "+input");
        } else {
          // We're elsewhere in the document
          // Go up
          Vim.handleKey(cm, "k", "+input");
          // Append mode at the end of the line
          Vim.handleKey(cm, "A", "+input");
          // Insert a newline using the continue markup thing
          insertNewlineContinueMarkup(client.editorView) ||
            insertNewlineAndIndent(client.editorView);
        }
      });

      // Load the config if any
      const config = client.config.get<VimConfig>("vim", {});
      if (config) {
        config.unmap?.forEach((binding) => {
          if (typeof binding === "string") {
            console.log("Unmapping " + binding);
            // @ts-ignore: unmap expects a string for the mode, this is problematic with Ex mappings which requires undefined or false
            Vim.unmap(binding, undefined);
          } else if (binding.key) {
            console.log(
              "Unmapping " + binding.key + " in " + (binding.mode ?? "normal"),
            );
            Vim.unmap(binding.key, binding.mode ?? "normal");
          }
        });
        config.map?.forEach(({ map, to, mode }) => {
          console.log(
            "Mapping " + map + " to " + to + " for " + (mode ?? "normal"),
          );
          Vim.map(map, to, mode ?? "normal");
        });
        config.noremap?.forEach(({ map, to, mode }) => {
          console.log(
            "Noremapping " + map + " to " + to + " for " + (mode ?? "normal"),
          );
          Vim.noremap(map, to, mode ?? "normal");
        });
        config.commands?.forEach(({ ex, command }) => {
          console.log("Mapping command '" + command + "' to Ex " + ex);
          Vim.defineEx(ex, "", () => client.runCommandByName(command));
        });
      } else {
        console.log("No vim config found");
      }
    },
    "editor.openPageNavigator": (
      _ctx,
      mode: "page" | "meta" | "document" | "all" = "page",
    ) => {
      client.startPageNavigate(mode);
    },
    "editor.openCommandPalette": () => {
      client.startCommandPalette();
    },
    "editor.deleteLine": () => {
      deleteLine(client.editorView);
    },
    "editor.toggleComment": () => {
      return toggleComment({
        state: client.editorView.state,
        dispatch: client.editorView.dispatch,
      });
    },
    "editor.moveLineUp": () => {
      return moveLineUp({
        state: client.editorView.state,
        dispatch: client.editorView.dispatch,
      });
    },
    "editor.moveLineDown": () => {
      return moveLineDown({
        state: client.editorView.state,
        dispatch: client.editorView.dispatch,
      });
    },
    // Folding
    "editor.fold": () => {
      foldCode(client.editorView);
    },
    "editor.unfold": () => {
      unfoldCode(client.editorView);
    },
    "editor.toggleFold": () => {
      toggleFold(client.editorView);
    },
    "editor.foldAll": () => {
      foldAll(client.editorView);
    },
    "editor.unfoldAll": () => {
      unfoldAll(client.editorView);
    },
    "editor.undo": () => {
      return undo(client.editorView);
    },
    "editor.redo": () => {
      return redo(client.editorView);
    },
    "editor.openSearchPanel": () => {
      openSearchPanel(client.editorView);
    },
    "editor.copyToClipboard": async (_ctx, data: string | Blob) => {
      try {
        if (typeof data === "string") {
          await navigator.clipboard.writeText(data);
        } else {
          await navigator.clipboard.write([
            new ClipboardItem({ [data.type]: data }),
          ]);
        }
      } catch (e) {
        console.error(e);
        client.flashNotification(`Could not copy to clipboard: ${e}`);
      }
    },
    "editor.sendMessage": (_ctx, type: string, data: any) => {
      if (!client.isDocumentEditor()) return;

      client.documentEditor.sendPublicMessage({
        type,
        data,
      });
    },

    "editor.isMobile": () => {
      const mouseDetected = globalThis.matchMedia("(pointer:fine)").matches;
      return !mouseDetected;
    },
  };

  return syscalls;
}


================================================
FILE: client/plugos/syscalls/event.ts
================================================
import type { SysCallMapping } from "../system.ts";
import type { EventHookI } from "../eventhook.ts";
import type { EventSubscription } from "@silverbulletmd/silverbullet/type/event";
import { LuaStackFrame, luaValueToJS } from "../../space_lua/runtime.ts";
import type { Client } from "../../client.ts";

export function eventSyscalls(
  eventHook: EventHookI,
  client: Client,
): SysCallMapping {
  return {
    "event.dispatch": (_ctx, eventName: string, data: any) => {
      return eventHook.dispatchEvent(eventName, data);
    },
    "event.listEvents": () => {
      return eventHook.listEvents();
    },
    /**
     * Define a Lua event listener
     */
    "event.listen": (
      _ctx,
      def: EventSubscription,
    ) => {
      // console.log("Registering Lua event listener: ", def.name);
      client.config.insert([
        "eventListeners",
        def.name,
      ], async (...args: any[]) => {
        // Convert return value to JS
        const val = await def.run(...args);
        return luaValueToJS(val, LuaStackFrame.lostFrame);
      });
    },
  };
}


================================================
FILE: client/plugos/syscalls/fetch.ts
================================================
import type { SysCallMapping } from "../system.ts";
import type {
  ProxyFetchRequest,
  ProxyFetchRequest64,
  ProxyFetchResponse,
  ProxyFetchResponse64,
} from "../proxy_fetch.ts";
import type { Client } from "../../client.ts";
import {
  base64Decode,
  base64Encode,
} from "@silverbulletmd/silverbullet/lib/crypto";
import { fsEndpoint } from "../../spaces/constants.ts";

export function sandboxFetchSyscalls(
  client: Client,
): SysCallMapping {
  return {
    // For use in Lua
    "http.request": async (
      _ctx,
      url: string,
      options: ProxyFetchRequest = {},
    ): Promise<ProxyFetchResponse> => {
      console.warn("Deprecated: use net.proxyFetch() instead");
      // JSONify any non-serializable body
      if (
        options?.body && typeof options.body !== "string" &&
        !(options.body instanceof Uint8Array)
      ) {
        options.body = JSON.stringify(options.body);
      }
      const fetchOptions = options
        ? {
          method: options.method,
          headers: {} as Record<string, string>,
          body: options.body,
        }
        : {};

      fetchOptions.headers = buildProxyHeaders(options?.headers);

      const resp = await client.httpSpacePrimitives.authenticatedFetch(
        buildProxyUrl(client, url),
        fetchOptions,
      );
      // Do sensible things with the body based on the content type
      let body: any;
      const contentTypeHeader = options.responseEncoding ||
        resp.headers.get("x-proxy-header-content-type");
      const statusCode = +(resp.headers.get("x-proxy-status-code") || "200");
      if (contentTypeHeader?.startsWith("application/json")) {
        body = await resp.json();
      } else if (
        contentTypeHeader?.startsWith("application/xml") ||
        contentTypeHeader?.startsWith("text/")
      ) {
        body = await resp.text();
      } else {
        body = new Uint8Array(await resp.arrayBuffer());
      }
      return {
        ok: resp.ok,
        status: statusCode,
        headers: extractProxyHeaders(resp.headers),
        body: body,
      };
    },
    "sandboxFetch.fetch": async (
      _ctx,
      url: string,
      options?: ProxyFetchRequest64,
    ): Promise<ProxyFetchResponse64> => {
      // console.log("Got sandbox fetch ", url, op);
      const fetchOptions = options
        ? {
          method: options.method,
          headers: options.headers,
          body: options.base64Body && base64Decode(options.base64Body),
        }
        : {};
      fetchOptions.headers = buildProxyHeaders(options?.headers);
      const resp = await client.httpSpacePrimitives.authenticatedFetch(
        buildProxyUrl(client, url),
        // Casting this to any because of weird Deno typing
        fetchOptions as any,
      );
      const statusCode = +(resp.headers.get("x-proxy-status-code") || "200");
      const body = await resp.arrayBuffer();
      return {
        ok: resp.ok,
        status: statusCode,
        headers: extractProxyHeaders(resp.headers),
        base64Body: base64Encode(new Uint8Array(body)),
      };
    },
  };
}

function buildProxyUrl(client: Client, url: string) {
  url = url.replace(/^https?:\/\//, "");
  // Strip off the /.fs and replace with /.proxy
  return client.httpSpacePrimitives.url.slice(0, -fsEndpoint.length) +
    "/.proxy/" + url;
}

function buildProxyHeaders(headers?: Record<string, any>): Record<string, any> {
  const newHeaders: Record<string, any> = { "X-Proxy-Request": "true" };
  if (!headers) {
    return newHeaders;
  }
  for (const [key, value] of Object.entries(headers)) {
    newHeaders[`X-Proxy-Header-${key}`] = value;
  }
  return newHeaders;
}

function extractProxyHeaders(
  headers: Headers,
): Record<string, any> {
  const newHeaders: Record<string, any> = {};
  for (const [key, value] of headers.entries()) {
    if (key.toLowerCase().startsWith("x-proxy-header-")) {
      newHeaders[key.slice("x-proxy-header-".length)] = value;
    }
  }
  return newHeaders;
}


================================================
FILE: client/plugos/syscalls/index.ts
================================================
import type { SysCallMapping } from "../system.ts";
import type {
  LuaCollectionQuery,
  LuaQueryCollection,
} from "../../space_lua/query_collection.ts";

import {
  type ObjectIndex,
  ObjectValidationError,
} from "../../data/object_index.ts";
import type { ObjectValue } from "@silverbulletmd/silverbullet/type/index";
import type { Client } from "../../client.ts";
import type { LuaTable } from "../../space_lua/runtime.ts";

export function indexSyscalls(
  objectIndex: ObjectIndex,
  client: Client,
): SysCallMapping {
  return {
    "index.tag": (_ctx, tagName: string): LuaQueryCollection => {
      return objectIndex.tag(tagName);
    },
    "index.ensureFullIndex": (_ctx) => {
      return objectIndex.ensureFullIndex(client.space);
    },
    "index.reindexSpace": () => {
      return objectIndex.reindexSpace(client.space);
    },
    "index.indexObjects": (
      _ctx,
      page: string,
      objects: ObjectValue[],
    ): Promise<void> => {
      return objectIndex.indexObjects(page, objects);
    },
    "index.validateObjects": async (
      _ctx,
      page: string,
      objects: ObjectValue[],
    ): Promise<{ error: string; object: ObjectValue } | null> => {
      try {
        await objectIndex.validateObjects(page, objects);
        return null;
      } catch (e: any) {
        if (e instanceof ObjectValidationError) {
          return {
            error: e.message,
            object: e.object,
          };
        } else {
          throw e;
        }
      }
    },
    "index.getObjectByRef": (
      _ctx,
      page: string,
      tag: string,
      ref: string,
    ): Promise<ObjectValue | undefined> => {
      return objectIndex.getObjectByRef(ref, page, tag);
    },
    "index.queryLuaObjects": (
      _ctx,
      tag: string,
      query: LuaCollectionQuery,
      scopedVariables?: Record<string, any>,
    ): Promise<ObjectValue[]> => {
      return objectIndex.queryLuaObjects(
        client.clientSystem.spaceLuaEnv.env,
        tag,
        query,
        scopedVariables,
      );
    },

    "index.deleteObject": (
      _ctx,
      page: string,
      tag: string,
      ref: string,
    ): Promise<void> => {
      return objectIndex.deleteObject(page, tag, ref);
    },
    "lua:index.defineTag": (_ctx, tagDef: LuaTable) => {
      // Using 'lua:' prefix to _not_ convert tagDef to a JS version (but keep original LuaTable)
      if (!tagDef.has("name")) {
        throw new Error("A tag name is required");
      }
      const currentTag = client.config.get(["tags", tagDef.get("name")], null);
      if (!currentTag) {
        client.config.set(["tags", tagDef.get("name")], {
          name: tagDef.get("name"),
        });
      }
      client.config.set(
        ["tags", tagDef.get("name"), "metatable"],
        tagDef.get("metatable"),
      );
    },
  };
}


================================================
FILE: client/plugos/syscalls/jsonschema.ts
================================================
import type { SysCallMapping } from "../system.ts";
import { Ajv, type ValidateFunction } from "ajv";

const ajv = new Ajv();

ajv.addFormat("email", {
  validate: (data: string) => {
    // TODO: Implement email validation
    return data.includes("@");
  },
  async: false,
});

ajv.addFormat("page-ref", {
  validate: (data: string) => {
    return data.startsWith("[[") && data.endsWith("]]");
  },
  async: false,
});

const schemaCache = new Map<string, ValidateFunction>();

export function validateObject(schema: any, object: any): undefined | string {
  try {
    const schemaKey = JSON.stringify(schema);
    if (!schemaCache.has(schemaKey)) {
      const validate = ajv.compile(schema);
      schemaCache.set(schemaKey, validate);
    }
    const validate = schemaCache.get(schemaKey)!;
    if (validate(object)) {
      return;
    } else {
      let text = ajv.errorsText(validate.errors);
      text = text.replaceAll("/", ".");
      text = text.replace(/^data[\.\s]/, "");
      return text;
    }
  } catch (e: any) {
    return e.message;
  }
}

export function validateSchema(schema: any): undefined | string {
  const valid = ajv.validateSchema(schema);
  if (valid) {
    return;
  } else {
    return ajv.errorsText(ajv.errors);
  }
}

export function jsonschemaSyscalls(): SysCallMapping {
  return {
    "jsonschema.validateObject": (
      _ctx,
      schema: any,
      object: any,
    ): undefined | string => {
      return validateObject(schema, object);
    },
    "jsonschema.validateSchema": (
      _ctx,
      schema: any,
    ): undefined | string => {
      return validateSchema(schema);
    },
  };
}


================================================
FILE: client/plugos/syscalls/language.ts
================================================
import type { SysCallMapping } from "../system.ts";
import { parse } from "../../markdown_parser/parse_tree.ts";
import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree";
import { builtinLanguages, languageFor } from "../../languages.ts";

export function languageSyscalls(): SysCallMapping {
  return {
    "language.parseLanguage": (
      _ctx,
      language: string,
      code: string,
    ): ParseTree => {
      const lang = languageFor(language);
      if (!lang) {
        throw new Error(`Unknown language ${language}`);
      }
      return parse(lang, code);
    },
    "language.listLanguages": (): string[] => {
      return Object.keys(builtinLanguages);
    },
  };
}


================================================
FILE: client/plugos/syscalls/lua.ts
================================================
import type { SysCallMapping } from "../system.ts";
import { evalExpression } from "../../space_lua/eval.ts";
import { parse, parseExpressionString } from "../../space_lua/parse.ts";
import {
  LuaStackFrame,
  luaToString,
  luaValueToJS,
} from "../../space_lua/runtime.ts";
import { buildThreadLocalEnv } from "../../space_lua_api.ts";
import { isSendable } from "../util.ts";
import type { LuaBlock, LuaExpression } from "../../space_lua/ast.ts";
import type { ClientSystem } from "../../client_system.ts";

export function luaSyscalls(clientSystem: ClientSystem): SysCallMapping {
  return {
    "lua.parse": (_ctx, code: string): LuaBlock => {
      return parse(code);
    },
    "lua.parseExpression": (_ctx, expression: string): LuaExpression => {
      return parseExpressionString(expression);
    },
    /**
     * Evaluates a Lua expression and returns the result as a JavaScript value
     * @param _ctx
     * @param expression
     * @returns
     */
    "lua.evalExpression": async (_ctx, expression: string) => {
      try {
        const ast = parseExpressionString(expression);
        const env = await buildThreadLocalEnv(
          clientSystem.system,
          clientSystem.spaceLuaEnv.env,
        );
        const sf = new LuaStackFrame(env, null);
        const luaResult = await evalExpression(
          ast,
          clientSystem.spaceLuaEnv.env,
          sf,
        );
        const jsResult = luaValueToJS(luaResult, sf);
        if (isSendable(jsResult)) {
          return jsResult;
        } else {
          // This may evaluate to e.g. a function, which is not sendable, in this case we'll console.warn and return a stringified version of the result
          console.warn(
            "Lua eval result is not sendable, returning stringified version",
            jsResult,
          );
          return luaToString(luaResult);
        }
      } catch (e: any) {
        console.error("Lua eval error: ", e.message, e.sf?.astCtx);
        throw e;
      }
    },
  };
}


================================================
FILE: client/plugos/syscalls/markdown.ts
================================================
import type { SysCallMapping } from "../system.ts";
import { parse } from "../../markdown_parser/parse_tree.ts";
import {
  type ParseTree,
  renderToText,
} from "@silverbulletmd/silverbullet/lib/tree";
import { extendedMarkdownLanguage } from "../../markdown_parser/parser.ts";
import {
  expandMarkdown,
  type MarkdownExpandOptions,
} from "../../markdown_renderer/inline.ts";
import type { Client } from "../../client.ts";
import {
  type MarkdownRenderOptions,
  renderMarkdownToHtml,
} from "../../markdown_renderer/markdown_render.ts";
import {
  jsonToMDTable,
  refCellTransformer,
} from "../../markdown_renderer/result_render.ts";

export function markdownSyscalls(client: Client): SysCallMapping {
  return {
    "markdown.parseMarkdown": (_ctx, text: string): ParseTree => {
      return parse(extendedMarkdownLanguage, text);
    },
    "markdown.renderParseTree": (_ctx, tree: ParseTree): string => {
      return renderToText(tree);
    },
    "markdown.expandMarkdown": async (
      _ctx,
      treeOrText: ParseTree | string,
      options?: MarkdownExpandOptions,
    ): Promise<ParseTree | string> => {
      const outputString = typeof treeOrText === "string";
      if (typeof treeOrText === "string") {
        treeOrText = parse(extendedMarkdownLanguage, treeOrText);
      }
      const result = await expandMarkdownWithClient(
        client,
        treeOrText,
        options,
      );
      if (outputString) {
        return renderToText(result);
      } else {
        return result;
      }
    },
    "markdown.markdownToHtml": async (
      _ctx,
      text: string,
      options: MarkdownRenderOptions = {},
    ) => {
      let mdTree = parse(extendedMarkdownLanguage, text);
      if (options.expand) {
        mdTree = await expandMarkdownWithClient(client, mdTree);
      }
      return renderMarkdownToHtml(mdTree, options);
    },
    "markdown.objectsToTable": (
      _ctx,
      data: any[],
      options: { renderCell?: (val: any, key: string) => Promise<any> | any } =
        {},
    ) => {
      return jsonToMDTable(data, options.renderCell || refCellTransformer);
    },
  };
}

function expandMarkdownWithClient(
  client: Client,
  tree: ParseTree,
  options?: MarkdownExpandOptions,
) {
  return expandMarkdown(
    client.space,
    client.currentName(),
    tree,
    client.clientSystem.spaceLuaEnv,
    options,
  );
}


================================================
FILE: client/plugos/syscalls/mq.ts
================================================
import type { DataStoreMQ } from "../../data/mq.datastore.ts";
import type { MQListenerSpec } from "../hooks/mq.ts";
import type { SysCallMapping } from "../system.ts";

export type EventSubscription = MQListenerSpec & {
  run: (...args: any[]) => Promise<any>;
};

export function mqSyscalls(
  mq: DataStoreMQ,
): SysCallMapping {
  return {
    /**
     * Define a Lua event listener
     */
    "mq.subscribe": (
      _ctx,
      def: MQListenerSpec,
    ) => {
      def.autoAck = def.autoAck != false;
      // console.log("Registering Lua event listener: ", def.name);
      client.config.insert([
        "mqSubscriptions",
        def.queue,
      ], def);
    },
    "mq.send": (_ctx, queue: string, body: any) => {
      return mq.send(queue, body);
    },
    "mq.batchSend": (_ctx, queue: string, bodies: any[]) => {
      return mq.batchSend(queue, bodies);
    },
    "mq.ack": (_ctx, queue: string, id: string) => {
      return mq.ack(queue, id);
    },
    "mq.batchAck": (_ctx, queue: string, ids: string[]) => {
      return mq.batchAck(queue, ids);
    },
    "mq.flushQueue": (_ctx, queue: string) => {
      return mq.flushQueue(queue);
    },
    "mq.flushAllQueues": () => {
      return mq.flushAllQueues();
    },
    "mq.getQueueStats": (_ctx, queue: string) => {
      return mq.getQueueStats(queue);
    },
    "mq.awaitEmptyQueue": (_ctx, queue: string) => {
      return mq.awaitEmptyQueue(queue);
    },
  };
}


================================================
FILE: client/plugos/syscalls/service_registry.ts
================================================
import type {
  ServiceMatch,
  ServiceRegistry,
  ServiceSpec,
} from "../../service_registry.ts";
import type { SysCallMapping } from "../system.ts";

export function serviceRegistrySyscalls(
  serviceRegistry: ServiceRegistry,
): SysCallMapping {
  return {
    /**
     * Define a Lua event listener
     */
    "service.define": (
      _ctx,
      def: ServiceSpec,
    ) => {
      return serviceRegistry.define(def);
    },
    "service.discover": (
      _ctx,
      selector: string,
      data: any,
    ): Promise<ServiceMatch[]> => {
      return serviceRegistry.discover(selector, data);
    },
    "service.invoke": (
      _ctx,
      service: ServiceMatch,
      data: any,
    ): Promise<any> => {
      return serviceRegistry.invoke(service, data);
    },
    "service.invokeBestMatch": (
      _ctx,
      selector: string,
      data: any,
    ): Promise<any> => {
      return serviceRegistry.invokeBestMatch(selector, data);
    },
  };
}


================================================
FILE: client/plugos/syscalls/shell.ts
================================================
import type { SysCallMapping } from "../system.ts";
import type { Client } from "../../client.ts";
import { fsEndpoint } from "../../spaces/constants.ts";

export function shellSyscalls(
  client: Client,
): SysCallMapping {
  return {
    "shell.run": async (
      _ctx,
      cmd: string,
      args: string[],
      stdin?: string,
    ): Promise<{ stdout: string; stderr: string; code: number }> => {
      if (!client.httpSpacePrimitives) {
        throw new Error("Not supported in fully local mode");
      }
      const resp = client.httpSpacePrimitives.authenticatedFetch(
        buildShellUrl(client),
        {
          method: "POST",
          body: JSON.stringify({
            cmd,
            args,
            stdin,
          }),
        },
      );
      const { code, stderr, stdout } = await (await resp).json();
      return { code, stderr, stdout };
    },
  };
}

function buildShellUrl(client: Client) {
  // Strip off the /.fs and replace with /.shell
  return client.httpSpacePrimitives.url.slice(0, -fsEndpoint.length) +
    "/.shell";
}


================================================
FILE: client/plugos/syscalls/space.ts
================================================
import { parseToRef, type Ref } from "@silverbulletmd/silverbullet/lib/ref";
import type { Client } from "../../client.ts";
import type { SysCallMapping } from "../system.ts";

import type {
  DocumentMeta,
  FileMeta,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";

export function spaceReadSyscalls(client: Client): SysCallMapping {
  return {
    "space.listPages": (): Promise<PageMeta[]> => {
      return client.space.fetchPageList();
    },
    "space.readPage": async (_ctx, name: string): Promise<string> => {
      return (await client.space.readPage(name)).text;
    },
    "space.readPageWithMeta": (
      _ctx,
      name: string,
    ): Promise<{ text: string; meta: PageMeta }> => {
      return client.space.readPage(name);
    },
    "space.readRef": async (_ctx, ref: string | Ref): Promise<string> => {
      if (typeof ref === "string") {
        ref = parseToRef(ref)!;
        if (!ref) {
          throw new Error(`Invalid ref: ${ref}`);
        }
      }
      return (await client.space.readRef(ref)).text;
    },
    "space.pageExists": (_ctx, name: string): boolean => {
      return client.clientSystem.allKnownFiles.has(name + ".md");
    },
    "space.getPageMeta": (_ctx, name: string): Promise<PageMeta> => {
      return client.space.getPageMeta(name);
    },
    "space.listPlugs": (): Promise<FileMeta[]> => {
      return client.space.listPlugs();
    },
    "space.listDocuments": async (): Promise<DocumentMeta[]> => {
      return await client.space.fetchDocumentList();
    },
    "space.readDocument": async (_ctx, name: string): Promise<Uint8Array> => {
      return (await client.space.readDocument(name)).data;
    },
    "space.getDocumentMeta": async (
      _ctx,
      name: string,
    ): Promise<DocumentMeta> => {
      return await client.space.getDocumentMeta(name);
    },
    // DEPRECATED, please use document versions instead, left here for backwards compatibility
    "space.listAttachments": async (): Promise<DocumentMeta[]> => {
      return await client.space.fetchDocumentList();
    },
    "space.readAttachment": async (_ctx, name: string): Promise<Uint8Array> => {
      return (await client.space.readDocument(name)).data;
    },
    "space.getAttachmentMeta": async (
      _ctx,
      name: string,
    ): Promise<DocumentMeta> => {
      return await client.space.getDocumentMeta(name);
    },
    // FS
    "space.listFiles": (): Promise<FileMeta[]> => {
      return client.space.spacePrimitives.fetchFileList();
    },
    "space.getFileMeta": (_ctx, name: string): Promise<FileMeta> => {
      return client.space.spacePrimitives.getFileMeta(name);
    },
    "space.readFile": async (_ctx, name: string): Promise<Uint8Array> => {
      return (await client.space.spacePrimitives.readFile(name)).data;
    },
    "space.readFileWithMeta": async (
      _ctx,
      name: string,
    ): Promise<{ data: Uint8Array; meta: FileMeta }> => {
      return await client.space.spacePrimitives.readFile(name);
    },
    "space.fileExists": async (_ctx, name: string): Promise<boolean> => {
      // If a full sync has successfully completed (so we know what files exist)
      // and we have a snapshot, let's use the snapshot
      if (
        client.fullSyncCompleted &&
        !client.eventedSpacePrimitives.isSnapshotEmpty()
      ) {
        return !!client.eventedSpacePrimitives.getSnapshot()[name];
      }
      try {
        await client.space.spacePrimitives.getFileMeta(name);
        // If this returned the file exists
        return true;
      } catch {
        // Assumption: any error means the file does not exist
        return false;
      }
    },
  };
}

export function spaceWriteSyscalls(editor: Client): SysCallMapping {
  return {
    "space.writePage": (
      _ctx,
      name: string,
      text: string,
    ): Promise<PageMeta> => {
      return editor.space.writePage(name, text);
    },
    "space.deletePage": async (_ctx, name: string) => {
      console.log("Deleting page");
      await editor.space.deletePage(name);
    },
    "space.writeDocument": (
      _ctx,
      name: string,
      data: Uint8Array,
    ): Promise<DocumentMeta> => {
      return editor.space.writeDocument(name, data);
    },
    "space.deleteDocument": async (_ctx, name: string) => {
      await editor.space.deleteDocument(name);
    },
    "space.writeFile": (
      _ctx,
      name: string,
      data: Uint8Array,
    ): Promise<FileMeta> => {
      return editor.space.spacePrimitives.writeFile(name, data);
    },
    "space.deleteFile": (_ctx, name: string) => {
      return editor.space.spacePrimitives.deleteFile(name);
    },
  };
}


================================================
FILE: client/plugos/syscalls/sync.ts
================================================
import type { SysCallMapping } from "../system.ts";
import type { Client } from "../../client.ts";

// TODO: Reimplement this
export function syncSyscalls(client: Client): SysCallMapping {
  return {
    "sync.hasInitialSyncCompleted": (): boolean => {
      return client.fullSyncCompleted;
    },
    "sync.performFileSync": (_ctx, path: string): Promise<void> => {
      client.postServiceWorkerMessage({ type: "perform-file-sync", path });
      return waitForServiceWorkerActivation(path);
    },
    "sync.performSpaceSync": (): Promise<number> => {
      client.postServiceWorkerMessage({ type: "perform-space-sync" });
      return waitForServiceWorkerActivation();
    },
  };
}

function waitForServiceWorkerActivation(path?: string): Promise<any> {
  return new Promise<any>((resolve, reject) => {
    client.eventHook.addLocalListener(
      "service-worker:file-sync-complete",
      eventHandler,
    );
    client.eventHook.addLocalListener(
      "service-worker:space-sync-complete",
      eventHandler,
    );
    client.eventHook.addLocalListener(
      "service-worker:sync-error",
      errorHandler,
    );
    function eventHandler(data: any) {
      // If data.path is set, we are notified about a specific file sync -> all good, even for an individual file sync
      // If data.path is not set, we are notified about a full space sync
      // If we were waiting for a specific path, ignore other paths
      if (data.path && path && data.path !== path) {
        // Event for other file sync
        return;
      }
      // If we were waiting for a specific path, ignore other paths
      resolve(data);

      // Unsubscribe from all these events
      client.eventHook.removeLocalListener(
        "service-worker:file-sync-complete",
        eventHandler,
      );
      client.eventHook.removeLocalListener(
        "service-worker:space-sync-complete",
        eventHandler,
      );
      client.eventHook.removeLocalListener(
        "service-worker:sync-error",
        errorHandler,
      );
    }
    function errorHandler(e: any) {
      reject(e);
      // Unsubscribe from all these events
      client.eventHook.removeLocalListener(
        "service-worker:file-sync-complete",
        eventHandler,
      );
      client.eventHook.removeLocalListener(
        "service-worker:space-sync-complete",
        eventHandler,
      );
      client.eventHook.removeLocalListener(
        "service-worker:sync-error",
        errorHandler,
      );
    }
  });
}


================================================
FILE: client/plugos/syscalls/system.ts
================================================
import type { SysCallMapping } from "../system.ts";
import type { Client } from "../../client.ts";
import { publicVersion } from "../../../public_version.ts";
import type { CommandDef } from "@silverbulletmd/silverbullet/type/manifest";
import type { SyscallMeta } from "@silverbulletmd/silverbullet/type/index";

export function systemSyscalls(
  client: Client,
  readOnlyMode: boolean,
): SysCallMapping {
  const api: SysCallMapping = {
    "system.invokeFunction": (
      _ctx,
      fullName: string, // plug.function
      ...args: any[]
    ) => {
      const [plugName, functionName] = fullName.split(".");
      if (!plugName || !functionName) {
        throw Error(`Invalid function name ${fullName}`);
      }
      const plug = client.clientSystem.system.loadedPlugs.get(plugName);
      if (!plug) {
        throw Error(`Plug ${plugName} not found`);
      }
      const functionDef = plug.manifest!.functions[functionName];
      if (!functionDef) {
        throw Error(`Function ${functionName} not found`);
      }
      return plug.invoke(functionName, args);
    },
    "system.invokeFunctionOnServer": (
      ctx,
      fullName: string, // plug.function
      ...args: any[]
    ) => {
      console.warn(
        "Calling deprecated system.invokeFunctionOnServer, use system.invokeFunction instead",
      );
      return api["system.invokeFunction"](ctx, fullName, ...args);
    },
    "system.serverSyscall": (_ctx, name: string, ...args: any[]) => {
      console.warn(
        "Calling deprecated system.serverSyscall, use syscall instead",
      );
      return client.clientSystem.localSyscall(name, args);
    },
    "system.invokeCommand": (_ctx, name: string, args?: string[]) => {
      console.warn("Deprecated, use editor.invokeCommand instead");
      return client.runCommandByName(name, args);
    },
    "system.listCommands": (): { [key: string]: CommandDef } => {
      const commandHook = client.clientSystem.commandHook;
      const allCommands: { [key: string]: CommandDef } = {};
      for (const [cmd, def] of commandHook.buildAllCommands()) {
        allCommands[cmd] = {
          name: def.name,
          contexts: def.contexts,
          priority: def.priority,
          key: def.key,
          mac: def.mac,
          hide: def.hide,
          requireMode: def.requireMode,
        };
      }
      return allCommands;
    },
    "system.listSyscalls": (): SyscallMeta[] => {
      const syscalls: SyscallMeta[] = [];
      for (
        const [name, info] of client.clientSystem.system.registeredSyscalls
      ) {
        syscalls.push({
          name,
          requiredPermissions: info.requiredPermissions,
          argCount: Math.max(0, info.callback.length - 1),
        });
      }
      return syscalls;
    },
    "system.reloadPlugs": () => {
      if (!client) {
        throw new Error("Not supported");
      }
      return client.loadPlugs();
    },
    "system.reloadConfig": (): Record<string, any> => {
      console.warn("system.reloadConfig is deprecated, it's now a no-op");
      return client.config.values;
    },
    "system.loadSpaceScripts": async () => {
      console.warn("DEPRECATED: used system.loadScripts instead");
      await client.clientSystem.loadLuaScripts();
    },
    "system.loadScripts": async () => {
      await client.clientSystem.loadLuaScripts();
    },
    "system.loadSpaceStyles": async () => {
      if (!client) {
        throw new Error("Not supported on server");
      }
      await client.loadCustomStyles();
    },
    "system.wipeClient": async (_ctx, logout = false) => {
      await client.wipeClient();
      if (logout) {
        location.href = ".logout";
      } else {
        alert("Client wiped, feel free to navigate elsewhere");
      }
    },
    // DEPRECATED
    "system.cleanDatabases": (): boolean => {
      console.warn(
        "system.cleanDatabses is deprecated, use Client: Wipe instead",
      );
      return false;
    },
    "system.getMode": () => {
      return readOnlyMode ? "ro" : "rw";
    },
    "system.getURLPrefix": () => {
      const url = new URL(document.baseURI);

      return url.pathname;
    },
    "system.getBaseURI": () => {
      return document.baseURI;
    },
    "system.getVersion": () => {
      return publicVersion;
    },
    "system.getConfig": (_ctx, key: string, defaultValue: any = undefined) => {
      return client.config.get(key, defaultValue);
    },
    // DEPRECATED
    "system.getEnv": () => {
      console.warn(
        "system.getEnv is deprecated, you can assume the env to always be the client",
      );
      return null;
    },
    "system.getSpaceConfig": (_ctx, key, defaultValue?) => {
      console.warn(
        "system.getSpaceConfig is deprecated, use system.getConfig instead",
      );
      return client.config.get(key, defaultValue);
    },
  };
  return api;
}


================================================
FILE: client/plugos/system.ts
================================================
import type { Hook } from "./types.ts";
import { EventEmitter } from "./event.ts";
import type { SandboxFactory } from "./sandboxes/sandbox.ts";
import { Plug } from "./plug.ts";
import { InMemoryManifestCache, type ManifestCache } from "./manifest_cache.ts";
import {
  builtinPlugNames,
  builtinPlugPaths,
} from "../../plugs/builtin_plugs.ts";

export interface SysCallMapping {
  [key: string]: (ctx: SyscallContext, ...args: any) => Promise<any> | any;
}

export type SystemEvents<HookT> = {
  plugLoaded: (plug: Plug<HookT>) => void | Promise<void>;
  plugUnloaded: (name: string) => void | Promise<void>;
};

// Passed to every syscall, allows to pass in additional context that the syscall may use
export type SyscallContext = {
  // This is the plug that is invoking the syscall,
  // which may be undefined where this cannot be determined (e.g. when running in a NoSandbox)
  plug?: string;
};

type SyscallSignature = (
  ...args: any[]
) => Promise<any> | any;

type Syscall = {
  requiredPermissions: string[];
  callback: SyscallSignature;
};

export type SystemOptions = {
  manifestCache?: ManifestCache<any>;
  plugFlushTimeout?: number;
};

export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
  registeredSyscalls = new Map<string, Syscall>();
  protected plugs = new Map<string, Plug<HookT>>();
  protected enabledHooks = new Set<Hook<HookT>>();

  /**
   * @param env either an environment or undefined for hybrid mode
   */
  constructor(
    readonly env: string | undefined = undefined,
    readonly options: SystemOptions = {},
  ) {
    super();
    if (!options.manifestCache) {
      options.manifestCache = new InMemoryManifestCache();
    }
  }

  get loadedPlugs(): Map<string, Plug<HookT>> {
    return this.plugs;
  }

  addHook(feature: Hook<HookT>) {
    this.enabledHooks.add(feature);
    feature.apply(this);
  }

  registerSyscalls(
    requiredCapabilities: string[],
    ...registrationObjects: SysCallMapping[]
  ) {
    for (const registrationObject of registrationObjects) {
      for (const [name, callback] of Object.entries(registrationObject)) {
        this.registeredSyscalls.set(name, {
          requiredPermissions: requiredCapabilities,
          callback,
        });
      }
    }
  }

  /**
   * Invokes a function named using the "plug.functionName" pattern, for convenience
   * @param name name of the function (e.g. plug.doSomething)
   * @param args an array of arguments to pass to the function
   */
  invokeFunction(name: string, args: any[]): Promise<any> {
    // Some sanity type checks
    if (typeof name !== "string") {
      throw new Error(
        `invokeFunction: function name should be a string, got ${typeof name}`,
      );
    }
    if (!Array.isArray(args)) {
      throw new Error(
        `invokeFunction: args should be an array, got ${typeof args}`,
      );
    }
    const [plugName, functionName] = name.split(".");
    if (!functionName) {
      // Sanity check
      throw new Error(`Missing function name: ${name}`);
    }
    const plug = this.loadedPlugs.get(plugName);
    if (!plug) {
      throw new Error(`Plug ${plugName} not found invoking ${name}`);
    }
    return plug.invoke(functionName, args);
  }

  localSyscall(name: string, args: any[]): Promise<any> {
    return this.syscall({}, name, args);
  }

  syscall(
    ctx: SyscallContext,
    name: string,
    args: any[],
  ): Promise<any> {
    const syscall = this.registeredSyscalls.get(name);
    if (!syscall) {
      throw Error(`Unregistered syscall ${name}`);
    }
    if (ctx.plug) {
      // Only when running in a plug context do we check permissions
      const plug = this.loadedPlugs.get(ctx.plug);
      if (!plug) {
        throw new Error(
          `Plug ${ctx.plug} not found while attempting to invoke ${name}}`,
        );
      }
      for (const permission of syscall.requiredPermissions) {
        if (!plug.grantedPermissions.includes(permission)) {
          throw Error(`Missing permission '${permission}' for syscall ${name}`);
        }
      }
    }
    return Promise.resolve(syscall.callback(ctx, ...args));
  }

  /**
   * @param cacheKey Used to cache the manifest of the worker. Should be equal to the filepath
   * @param cacheHash Used to determine if the manifest is up to date. Should be `lastModified`
   */
  async loadPlug(
    sandboxFactory: SandboxFactory<HookT>,
    cacheKey: string,
    cacheHash: number = -1,
  ): Promise<Plug<HookT>> {
    const plug = await Plug.createLazily(
      this,
      cacheKey,
      cacheHash,
      sandboxFactory,
    );

    const manifest = plug.manifest;

    // This depends on cacheKey being the file path
    if (
      !builtinPlugPaths.includes(cacheKey) &&
      builtinPlugNames.includes(manifest.name)
    ) {
      plug.stop();
      throw new Error("Plug tried to overwrite internal plug");
    }

    // Validate the manifest
    let errors: string[] = [];
    for (const feature of this.enabledHooks) {
      errors = [...errors, ...feature.validateManifest(manifest)];
    }
    if (errors.length > 0) {
      throw new Error(`Invalid manifest: ${errors.join(", ")}`);
    }
    if (this.plugs.has(manifest.name)) {
      this.unload(manifest.name);
    }
    console.log("Activated plug", manifest.name);
    this.plugs.set(manifest.name, plug);

    await this.emit("plugLoaded", plug);
    return plug;
  }

  unload(name: string) {
    const plug = this.plugs.get(name);
    if (!plug) {
      return;
    }
    plug.stop();
    this.emit("plugUnloaded", name);
    this.plugs.delete(name);
  }

  unloadAll(): Promise<void[]> {
    return Promise.all(
      Array.from(this.plugs.keys()).map(this.unload.bind(this)),
    );
  }
}


================================================
FILE: client/plugos/types.ts
================================================
import type { System } from "./system.ts";
import type { AssetJson } from "../asset_bundle/bundle.ts";

/** The generic top level of a plug manifest file.
 * Defines plug metadata and functions.
 */
export interface Manifest<HookT> {
  /** The plug's name. Typically this is the name of the manifest file, without the file extension. */
  name: string;

  /** A list of syscall permissions required for this plug to function.
   *
   * Possible values:
   * - `fetch`: enables `fetch` function. (see: plug-api/plugos-syscall/fetch.ts, and plug-api/lib/fetch.ts)
   * - `shell`: enables the `shell.run` syscall. (see: plug-api/plugos-syscall/shell.ts)
   */
  requiredPermissions?: string[];

  /** A list of files or glob patterns that should be bundled with the plug.
   *
   * These files will be accessible through the `asset.readAsset` function.
   *
   * see: plug-api/plugos-syscall/asset.ts#readAsset
   */
  assets?: string[] | AssetJson;

  /** A map of function names to definitions. Declared functions are public, and may be associated with various hooks
   *
   * see: common/manifest.ts#SilverBulletHooks
   */
  functions: Record<string, FunctionDef<HookT>>;

  /**
   * A map of configuration options for the plug (to be merged with the system configuration).
   */
  config?: any;
}

/** Associates hooks with a function. This is the generic base structure, that identifies the function. Hooks are defined by the type parameter. */
export type FunctionDef<HookT> = {
  /** A function path, in the form `${relativeFilename}:${functionName}`.
   *
   * During compilation (see `../build_plugs.ts`) the function is read from the file and inlined into the plug bundle.
   *
   * This field and `FunctionDef.redirect` are mutually exclusive.
   */
  path?: string;

  /** A function from another plug, in the form `${plugName}.${functionName}` that will be attached to the given hooks. */
  redirect?: string;

  /** Environments where this plug is allowed to run, current may be one of "cli", "server", or "client". */
  env?: string;
} & HookT;

export interface Hook<HookT> {
  validateManifest(manifest: Manifest<HookT>): string[];

  apply(system: System<HookT>): void;
}


================================================
FILE: client/plugos/util.ts
================================================
/**
 * Checks if an object is sendable across the plugos worker boundary.
 *
 * @param o - The object to check.
 * @returns `true` if the object is sendable, `false` otherwise.
 */
export function isSendable(o: any): boolean {
  try {
    structuredClone(o);
    return true;
  } catch {
    return false;
  }
}


================================================
FILE: client/plugos/worker_runtime.ts
================================================
// This is the runtime imported from the compiled plug worker code
import type { ControllerMessage, WorkerMessage } from "./protocol.ts";
import type {
  ProxyFetchRequest64,
  ProxyFetchResponse64,
} from "./proxy_fetch.ts";
import { base64Decode, base64Encode } from "../../plug-api/lib/crypto.ts";
import { initLogger } from "../lib/logger.ts";

declare global {
  function syscall(name: string, ...args: any[]): Promise<any>;
}

let workerPostMessage = (_msg: ControllerMessage): void => {
  throw new Error("Not initialized yet");
};

// Are we running in a (web) worker?

// Determines if we're running in a web worker environment (Deno or browser)
// - in a browser's main threads, typeof window is "object"
// - in a browser's worker threads, typeof window === "undefined"
// - in Deno's main thread typeof window === "object"
// - in Deno's workers typeof window === "undefined
// - in Cloudflare workers typeof window === "undefined", but typeof globalThis.WebSocketPair is defined
const runningAsWebWorker = typeof window === "undefined" &&
  // @ts-ignore: globalThis
  typeof globalThis.WebSocketPair === "undefined";

if (typeof Deno === "undefined") {
  // @ts-ignore: Deno hack
  self.Deno = {
    args: [],
    // @ts-ignore: Deno hack
    build: {
      arch: "x86_64",
    },
    env: {
      // @ts-ignore: Deno hack
      get() {
      },
    },
  };
}

const pendingRequests = new Map<
  number,
  {
    resolve: (result: unknown) => void;
    reject: (e: any) => void;
  }
>();

let syscallReqId = 0;

if (runningAsWebWorker) {
  globalThis.syscall = async (name: string, ...args: any[]) => {
    return await new Promise((resolve, reject) => {
      syscallReqId++;
      pendingRequests.set(syscallReqId, { resolve, reject });
      workerPostMessage({
        type: "sys",
        id: syscallReqId,
        name,
        args,
      });
    });
  };
}

export function setupMessageListener(
  // deno-lint-ignore ban-types
  functionMapping: Record<string, Function>,
  manifest: any,
  postMessageFn: (msg: ControllerMessage) => void,
) {
  if (!runningAsWebWorker) {
    // Don't do any of this stuff if this is not a web worker
    // This caters to the NoSandbox run mode
    return;
  }
  workerPostMessage = postMessageFn;
  self.addEventListener("message", (event: { data: WorkerMessage }) => {
    (async () => {
      const data = event.data;
      switch (data.type) {
        case "inv":
          {
            const fn = functionMapping[data.name!];
            if (!fn) {
              throw new Error(`Function not loaded: ${data.name}`);
            }
            try {
              const result = await Promise.resolve(fn(...(data.args || [])));
              workerPostMessage({
                type: "invr",
                id: data.id,
                result: result,
              } as ControllerMessage);
            } catch (e: any) {
              console.error(
                "An exception was thrown as a result of invoking function",
                data.name,
                "error:",
                e.message,
              );
              workerPostMessage({
                type: "invr",
                id: data.id!,
                error: e.message,
              });
            }
          }
          break;
        case "sysr":
          {
            const syscallId = data.id;
            const lookup = pendingRequests.get(syscallId);
            if (!lookup) {
              throw Error("Invalid request id");
            }
            pendingRequests.delete(syscallId);
            if (data.error) {
              lookup.reject(new Error(data.error));
            } else {
              lookup.resolve(data.result);
            }
          }

          break;
      }
    })().catch(console.error);
  });
  // Signal initialization with manifest
  workerPostMessage({
    type: "manifest",
    manifest,
  });
  initLogger(`[${manifest.name} plug]`);
}

export async function sandboxFetch(
  reqInfo: RequestInfo,
  options?: ProxyFetchRequest64,
): Promise<ProxyFetchResponse64> {
  if (typeof reqInfo !== "string") {
    const body = new Uint8Array(await reqInfo.arrayBuffer());
    const encodedBody = body.length > 0 ? base64Encode(body) : undefined;
    options = {
      method: reqInfo.method,
      headers: Object.fromEntries(reqInfo.headers.entries()),
      base64Body: encodedBody,
    };
    reqInfo = reqInfo.url;
  }
  return syscall("sandboxFetch.fetch", reqInfo, options);
}

// @ts-ignore: monkey patching fetch
globalThis.nativeFetch = globalThis.fetch;

// Monkey patch fetch()
export function monkeyPatchFetch() {
  // @ts-ignore: monkey patching fetch
  globalThis.fetch = async function (
    reqInfo: RequestInfo,
    init?: RequestInit,
  ): Promise<Response> {
    const encodedBody = init && init.body
      ? base64Encode(
        new Uint8Array(await (new Response(init.body)).arrayBuffer()),
      )
      : undefined;
    const r = await sandboxFetch(
      reqInfo,
      init && {
        method: init.method,
        headers: init.headers as Record<string, string>,
        base64Body: encodedBody,
      },
    );
    // Casting the response to "any" for now, since of weird Deno typing
    return new Response(
      (r.base64Body ? base64Decode(r.base64Body) : null) as any,
      {
        status: r.status,
        headers: r.headers,
      },
    );
  };
}

if (runningAsWebWorker) {
  monkeyPatchFetch();
}


================================================
FILE: client/reducer.ts
================================================
import type { Action, AppViewState } from "./types/ui.ts";
import type { PageMeta } from "../plug-api/types/index.ts";
import {
  isMarkdownPath,
  parseToRef,
} from "@silverbulletmd/silverbullet/lib/ref";

export default function reducer(
  state: AppViewState,
  action: Action,
): AppViewState {
  switch (action.type) {
    case "document-editor-loaded":
      return {
        ...state,
        isLoading: false,
        current: {
          path: action.path,
          meta: action.meta,
        },
      };
    case "page-loaded": {
      const mouseDetected = globalThis.matchMedia("(pointer:fine)").matches;
      return {
        ...state,
        isLoading: false,
        isMobile: !mouseDetected,
        current: {
          path: action.path,
          meta: action.meta as PageMeta,
        },
      };
    }
    case "document-editor-changed":
    case "page-changed":
      return {
        ...state,
        unsavedChanges: true,
      };
    case "document-editor-saved":
    case "page-saved": {
      return {
        ...state,
        unsavedChanges: false,
      };
    }
    case "update-current-page-meta": {
      // Update in the allPages list as well
      state.allPages = state.allPages.map((pageMeta) =>
        pageMeta.name === action.meta.name
          ? { ...action.meta, lastOpened: Date.now() }
          : pageMeta
      );
      // Can't update page meta if not on a page
      if (!state.current || !isMarkdownPath(state.current.path)) {
        return state;
      }
      return {
        ...state,
        current: {
          ...state.current,
          meta: action.meta,
        },
      };
    }
    case "online-status-change":
      return {
        ...state,
        isOnline: action.isOnline,
      };
    case "update-page-list": {
      // Let's move over any "lastOpened" times to the "allPages" list
      const oldPageMeta = new Map(
        [...state.allPages].map((pm) => [pm.name, pm]),
      );
      let currPageMeta: PageMeta | undefined;
      for (const pageMeta of action.allPages) {
        const oldPageMetaItem = oldPageMeta.get(pageMeta.name);
        if (oldPageMetaItem && oldPageMetaItem.lastOpened) {
          pageMeta.lastOpened = oldPageMetaItem.lastOpened;
        }
        if (parseToRef(pageMeta.name)?.path === state.current?.path) {
          currPageMeta = pageMeta;
        }
      }
      const newState = {
        ...state,
        allPages: action.allPages,
      };
      if (currPageMeta) {
        newState.current!.meta = currPageMeta;
      }
      return newState;
    }
    case "update-document-list": {
      return {
        ...state,
        allDocuments: action.allDocuments,
      };
    }
    case "start-navigate": {
      return {
        ...state,
        showPageNavigator: true,
        pageNavigatorMode: action.mode,
        showCommandPalette: false,
        showFilterBox: false,
      };
    }
    case "stop-navigate":
      return {
        ...state,
        showPageNavigator: false,
      };

    case "show-palette": {
      return {
        ...state,
        showCommandPalette: true,
        showPageNavigator: false,
        showFilterBox: false,
        showCommandPaletteContext: action.context,
        commands: action.commands,
      };
    }
    case "hide-palette":
      return {
        ...state,
        showCommandPalette: false,
        showCommandPaletteContext: undefined,
      };
    case "update-commands":
      return {
        ...state,
        commands: action.commands,
      };
    case "show-notification":
      return {
        ...state,
        notifications: [...state.notifications, action.notification],
      };
    case "dismiss-notification":
      return {
        ...state,
        notifications: state.notifications.filter((n) => n.id !== action.id),
      };
    case "show-panel":
      return {
        ...state,
        panels: {
          ...state.panels,
          [action.id]: action.config,
        },
      };
    case "hide-panel":
      return {
        ...state,
        panels: {
          ...state.panels,
          [action.id]: {},
        },
      };

    case "show-filterbox":
      return {
        ...state,
        showFilterBox: true,
        filterBoxOnSelect: action.onSelect,
        filterBoxPlaceHolder: action.placeHolder,
        filterBoxOptions: action.options,
        filterBoxLabel: action.label,
        filterBoxHelpText: action.helpText,
      };
    case "hide-filterbox":
      return {
        ...state,
        showCommandPalette: false,
        showPageNavigator: false,
        showFilterBox: false,
        filterBoxOnSelect: () => {
        },
        filterBoxPlaceHolder: "",
        filterBoxOptions: [],
        filterBoxHelpText: "",
      };
    case "show-prompt":
      return {
        ...state,
        showPrompt: true,
        promptDefaultValue: action.defaultValue,
        promptMessage: action.message,
        promptCallback: action.callback,
      };
    case "hide-prompt":
      return {
        ...state,
        showPrompt: false,
        promptDefaultValue: undefined,
        promptMessage: undefined,
        promptCallback: undefined,
      };
    case "show-confirm":
      return {
        ...state,
        showConfirm: true,
        confirmMessage: action.message,
        confirmCallback: action.callback,
      };
    case "hide-confirm":
      return {
        ...state,
        showConfirm: false,
        confirmMessage: undefined,
        confirmCallback: undefined,
      };
    case "set-ui-option":
      return {
        ...state,
        uiOptions: {
          ...state.uiOptions,
          [action.key]: action.value,
        },
      };
    case "set-progress":
      return {
        ...state,
        progressPercentage: action.progressPercentage,
        progressType: action.progressType,
      };
  }
}


================================================
FILE: client/service_registry.test.ts
================================================
import type { EventHookT } from "@silverbulletmd/silverbullet/type/manifest";
import { EventHook } from "./plugos/hooks/event.ts";
import { System } from "./plugos/system.ts";
import { ServiceRegistry } from "./service_registry.ts";
import { assertEquals } from "@std/assert";
import { Config } from "./config.ts";

Deno.test("Test services", async () => {
  const system = new System<EventHookT>();
  const config = new Config();
  const eventHook = new EventHook(config);
  system.addHook(eventHook);

  const registry = new ServiceRegistry(eventHook, config);

  registry.define({
    selector: "greeter",
    match: {
      priority: 1,
    },
    run: (name: string) => {
      return Promise.resolve(`Hello ${name}!`);
    },
  });

  registry.define({
    selector: "greeter-multi",
    match: () => {
      return Promise.resolve({
        priority: 1,
      });
    },
    run: (name: string) => {
      return Promise.resolve(`Hello 1 ${name}!`);
    },
  });

  registry.define({
    selector: "greeter-multi",
    match: {
      priority: 2,
    },
    run: (name: string) => {
      return Promise.resolve(`Hello 2 ${name}!`);
    },
  });

  registry.define({
    selector: "greeter-multi",
    match: () => {
      return Promise.resolve(null);
    },
    run: () => {
      throw new Error("I should never be run");
    },
  });

  const matches = await registry.discover("greeter", {});
  assertEquals(matches.length, 1);
  assertEquals(matches[0].priority, 1);

  const matchesMulti = await registry.discover("greeter-multi", {});
  assertEquals(matchesMulti.length, 2);
  // higher prio should come first
  assertEquals(matchesMulti[0].priority, 2);
  assertEquals(matchesMulti[1].priority, 1);

  assertEquals(await registry.invoke(matches[0], "Pete"), "Hello Pete!");
  assertEquals(
    await registry.invokeBestMatch("greeter", "Pete"),
    "Hello Pete!",
  );

  assertEquals(
    await registry.invokeBestMatch("greeter-multi", "Pete"),
    "Hello 2 Pete!",
  );
});


================================================
FILE: client/service_registry.ts
================================================
import type { Config } from "./config.ts";
import type { EventHook } from "./plugos/hooks/event.ts";

export type ServiceMatch = {
  id?: string; // uuid, set automatically
  priority?: number;
} & Record<string, any>;

export type ServiceSpec = {
  selector: string;
  match:
    | ServiceMatch
    | ((data: any) => Promise<ServiceMatch | null | undefined>);
  run: (data: any) => Promise<any>;
};

export class ServiceRegistry {
  constructor(private eventHook: EventHook, private config: Config) {
  }

  public define(spec: ServiceSpec): void {
    const id = crypto.randomUUID();
    // Register with discover:* event
    this.config.insert([
      "eventListeners",
      `discover:${spec.selector}`,
    ], async (e: any) => {
      const matchResult = typeof spec.match === "function"
        ? await spec.match(e.data)
        : spec.match;
      if (matchResult) {
        return {
          ...matchResult,
          id,
        };
      }
    });
    // Register callback when invoked
    this.config.insert(["eventListeners", `service:${id}`], (e: any) => {
      return spec.run(e.data);
    });
  }

  public async discover(selector: string, opts: any): Promise<ServiceMatch[]> {
    const discoveryResults: ServiceMatch[] = await this.eventHook.dispatchEvent(
      `discover:${selector}`,
      opts,
    );
    discoveryResults.sort((a, b) => (b.priority || 0) - (a.priority || 0));
    return discoveryResults;
  }

  public async invoke(match: ServiceMatch, data: any): Promise<any> {
    const results = await this.eventHook.dispatchEvent(
      `service:${match.id}`,
      data,
    );
    // Note: results may be an empty array in case no service actually returned a result (void) case, which is implicitly passed on here
    return results[0];
  }

  public async invokeBestMatch(selector: string, data: any): Promise<any> {
    const results = await this.discover(selector, data);
    if (results.length === 0) {
      throw new Error(`No services matching: ${selector}`);
    }
    return this.invoke(results[0], data);
  }
}


================================================
FILE: client/service_worker.ts
================================================
import { initLogger } from "./lib/logger.ts";
import { ProxyRouter } from "./service_worker/proxy_router.ts";

import { SyncEngine } from "./service_worker/sync_engine.ts";
import type {
  ServiceWorkerSourceMessage,
  ServiceWorkerTargetMessage,
} from "./types/ui.ts";
import {
  deriveDbName,
  exportKey,
  importKey,
} from "@silverbulletmd/silverbullet/lib/crypto";
import { IndexedDBKvPrimitives } from "./data/indexeddb_kv_primitives.ts";
import { fsEndpoint } from "./spaces/constants.ts";
import { DataStoreSpacePrimitives } from "./spaces/datastore_space_primitives.ts";
import { HttpSpacePrimitives } from "./spaces/http_space_primitives.ts";
import { throttleImmediately } from "@silverbulletmd/silverbullet/lib/async";
import { wrongSpacePathError } from "@silverbulletmd/silverbullet/constants";
import type { KvPrimitives } from "./data/kv_primitives.ts";
import { EncryptedKvPrimitives } from "./data/encrypted_kv_primitives.ts";

const logger = initLogger("[Service Worker]");

// Note: the only thing cached here is SilverBullet client assets, files are kept in IndexedDB
const CACHE_NAME = "{{CACHE_NAME}}";

//`location.href` minus this worker's filename will be our base URL, including any URL prefix
//(-1 is to remove the trailing '/')
const workerFilename = location.pathname.substring(
  location.pathname.lastIndexOf("/") + 1,
);
const baseURI = location.href.substring(
  0,
  location.href.length - workerFilename.length - 1,
);
const basePathName = location.pathname.substring(
  0,
  location.pathname.length - workerFilename.length - 1,
);

const precacheFiles = Object.fromEntries([
  "/",
  "/.client/client.js",
  "/.client/favicon.png",
  "/.client/iAWriterMonoS-Bold.woff2",
  "/.client/iAWriterMonoS-BoldItalic.woff2",
  "/.client/iAWriterMonoS-Italic.woff2",
  "/.client/iAWriterMonoS-Regular.woff2",
  "/.client/logo.png",
  "/.client/logo-dock.png",
  "/.client/main.css",
  "/.client/manifest.json",
].map((path) => [path, `${baseURI}${path}?v=${CACHE_NAME}`, path])); // Cache busting

// Initially set to undefined, resulting in all "fetch" being proxied.
// Once the service worker is configured, this will be set and the proxy will handle fetches.
const proxyRouter = new ProxyRouter(
  basePathName,
  baseURI,
  precacheFiles,
);

// Configuration mutex
let configuring = false;

// @ts-ignore: debugging
globalThis.proxyRouter = proxyRouter;

// This is the in-memory store of an encryption key that SB clients and the index engine can share without asking for it constantly
let encryptionKeyMemoryStore: CryptoKey | undefined;

// Let's clean this encryptionKey if there's no more clients left for a little while, asking to re-enter
setInterval(() => {
  // @ts-ignore: service worker API
  globalThis.clients.matchAll().then((clients) => {
    if (clients.length === 0 && encryptionKeyMemoryStore) {
      console.info("No more clients, flushing encryption key");
      encryptionKeyMemoryStore = undefined;
    }
  });
}, 5000); // little while is 5s

// Message received from client
self.addEventListener("message", async (event: any) => {
  const message: ServiceWorkerTargetMessage = event.data;
  switch (message.type) {
    case "skip-waiting": {
      // @ts-ignore: Skip waiting to activate this service worker immediately
      self.skipWaiting();
      break;
    }
    case "shutdown": {
      proxyRouter.reset();
      break;
    }
    case "flush-cache": {
      const cacheNames = await caches.keys();

      await Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheName !== CACHE_NAME) {
            console.log("Removing cache", cacheName);
            return caches.delete(cacheName);
          }
        }),
      );
      broadcastMessage({
        type: "cacheFlushed",
      });
      break;
    }
    case "wipe-data": {
      if (proxyRouter.syncEngine) {
        await proxyRouter.syncEngine.wipe();
        broadcastMessage({
          type: "dataWiped",
        });
      } else {
        console.warn("Not performing sync data wipe, sync engine not started");
      }
      break;
    }
    case "perform-file-sync": {
      if (proxyRouter.syncEngine) {
        await proxyRouter.syncEngine.syncSingleFile(
          message.path,
        );
      } else {
        console.warn(
          "Ignoring perform-file-sync request, proxy not configured yet",
        );
      }

      break;
    }
    case "perform-space-sync": {
      if (proxyRouter.syncEngine) {
        await proxyRouter.syncEngine.syncSpace();
      } else {
        console.warn(
          "Ignoring perform-space-sync request, proxy not configured yet",
        );
      }
      break;
    }
    case "get-encryption-key": {
      event.source.postMessage({
        type: "encryption-key",
        key: encryptionKeyMemoryStore &&
          await exportKey(encryptionKeyMemoryStore),
      } as ServiceWorkerSourceMessage);
      break;
    }
    case "set-encryption-key": {
      encryptionKeyMemoryStore = await importKey(message.key);
      console.info("Encryption phrase set");
      break;
    }
    case "config": {
      const config = message.config;
      // Configure the service worker if it hasn't been already
      if (isConfigured()) {
        console.info(
          "Service worker already configured, just updating configs",
        );
        proxyRouter.syncEngine!.setSyncConfig({
          syncDocuments: config.syncDocuments,
          syncIgnore: config.syncIgnore,
        });

        return;
      } else {
        console.info("Service being configured with", config);
      }
      if (configuring) {
        console.info("Configuration already in progress, skipping");
        return;
      }
      // Lock configuration mutex
      configuring = true;
      // Put a timeout on it, just in case
      setTimeout(() => {
        configuring = false;
      }, 5000);
      try {
        if (config.enableClientEncryption) {
          if (!encryptionKeyMemoryStore) {
            console.error(
              "Supposed to use encryption, but no phrase set yet, auth error",
            );
            broadcastMessage({
              type: "auth-error",
              message: "Re-authentication required, redirecting...",
              actionOrRedirectHeader: ".auth",
            });
            // ABORT
            return;
          }
        }

        const spaceFolderPath = config.spaceFolderPath;
        const dbName = await deriveDbName(
          "files",
          spaceFolderPath,
          baseURI,
          encryptionKeyMemoryStore,
        );

        if (config.logPush) {
          setInterval(() => {
            logger.postToServer(".logs", "service_worker");
          }, 1000);
        }

        // Setup KV (database) for store synced files
        let kv: KvPrimitives = new IndexedDBKvPrimitives(dbName);
        await (kv as IndexedDBKvPrimitives).init();
        console.log("Using IndexedDB database", dbName);

        if (encryptionKeyMemoryStore) {
          kv = new EncryptedKvPrimitives(kv, encryptionKeyMemoryStore);
          await (kv as EncryptedKvPrimitives).init();
          console.log("Enabled client-side encryption for synced files");
        }

        // And use that to power the IndexedDB backed local storage
        const local = new DataStoreSpacePrimitives(kv);

        // Which we'll sync with the remote server
        const remote = new HttpSpacePrimitives(
          basePathName + fsEndpoint,
          spaceFolderPath,
          (message, actionOrRedirectHeader) => {
            // And auth error occured
            console.error(
              "[service proxy error]",
              message,
              actionOrRedirectHeader,
            );
            if (message === wrongSpacePathError.message) {
              proxyRouter.reset();
            }
            broadcastMessage({
              type: "auth-error",
              message,
              actionOrRedirectHeader,
            });
          },
        );

        // Now let's setup sync
        const syncEngine = new SyncEngine(kv, local, remote);
        syncEngine.setSyncConfig({
          syncDocuments: config.syncDocuments,
          syncIgnore: config.syncIgnore,
        });
        await syncEngine.start();

        // Ok, we're ready to go, let's plug in the proxy router
        proxyRouter.configure(syncEngine);

        // And wire up some events
        proxyRouter.on({
          observedRequest: (path) => {
            // This is triggered for the currently open file, we want to proactively sync it to keep it up to date
            syncEngine.syncSingleFile(path);
          },
          onlineStatusUpdated: (isOnline) => {
            broadcastMessage({
              type: "online-status",
              isOnline,
            });
          },
        });
        syncEngine.on({
          syncProgress: (status) => {
            broadcastMessage({
              type: "sync-status",
              status,
            });
          },
          syncConflict: (path) => {
            console.warn("Sync conflict detected:", path);
            broadcastMessage({
              type: "sync-conflict",
              path,
            });
          },
          spaceSyncComplete: (operations) => {
            broadcastMessage({
              type: "space-sync-complete",
              operations,
            });
          },
          fileSyncComplete: (path, operations) => {
            broadcastMessage({
              type: "file-sync-complete",
              path,
              operations,
            });
          },
          syncError: (error) => {
            broadcastMessage({
              type: "sync-error",
              message: error.message,
            });
          },
        });
      } finally {
        // Unlock mutex
        configuring = false;
      }
      break;
    }
  }
});

function broadcastMessage(message: ServiceWorkerSourceMessage) {
  // @ts-ignore: service worker API
  const clients: any = self.clients;
  // Find all windows attached to this service worker
  clients.matchAll({
    type: "window",
  }).then((clients: any[]) => {
    clients.forEach((client) => {
      client.postMessage(message);
    });
    if (clients.length === 0) {
      console.info(
        "No clients are listening for messages, dropping message",
        message,
      );
    }
  });
}

const throttledServiceWorkerStarted = throttleImmediately(() => {
  broadcastMessage({
    type: "service-worker-started",
  });
}, 100);

self.addEventListener("fetch", (event: any) => {
  if (!isConfigured()) {
    throttledServiceWorkerStarted();
  }

  // Always delegate to the proxy router
  proxyRouter.onFetch(event);
});

// Service worker lifecycle management
self.addEventListener("install", (event: any) => {
  console.log("Installing service worker...");
  event.waitUntil(
    (async () => {
      const cache = await caches.open(CACHE_NAME);
      console.log(
        "Now pre-caching client files",
      );
      await cache.addAll(Object.values(precacheFiles));
      console.log(
        Object.keys(precacheFiles).length,
        "client files cached",
      );
      // @ts-ignore: Force the waiting service worker to become the active service worker
      await self.skipWaiting();
    })(),
  );
});

self.addEventListener("activate", (event: any) => {
  console.log("Activating new service worker!");

  if (!isConfigured()) {
    throttledServiceWorkerStarted();
  }

  event.waitUntil(
    (async () => {
      // Flush old caches
      const cacheNames = await caches.keys();
      await Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheName !== CACHE_NAME) {
            console.log("Removing old cache", cacheName);
            return caches.delete(cacheName);
          }
        }),
      );
      // @ts-ignore: Take control of all clients as soon as the service worker activates
      await clients.claim();
    })(),
  );
});

console.log("Service worker loaded");

function isConfigured() {
  return !!proxyRouter.syncEngine;
}


================================================
FILE: client/service_worker/proxy_router.ts
================================================
import { fsEndpoint } from "../spaces/constants.ts";
import { decodePageURI } from "@silverbulletmd/silverbullet/lib/ref";
import type { SpacePrimitives } from "../spaces/space_primitives.ts";
import { fileMetaToHeaders, headersToFileMeta } from "../lib/util.ts";
import {
  notFoundError,
  offlineError,
  pingInterval,
} from "@silverbulletmd/silverbullet/constants";
import type { SyncEngine } from "./sync_engine.ts";
import { EventEmitter } from "../plugos/event.ts";

const alwaysProxy = [
  "/.auth",
  "/.shell",
  "/.logout",
  "/.config",
  "/.logs",
  "/.proxy",
];

export type ProxyRouterEvents = {
  // Use case: the user likely has this file open in the editor, so it's good to prioritize syncing it
  observedRequest: (path: string) => void;
  // Use case: client showing the "yellow bar" indicating not being online
  onlineStatusUpdated: (isOnline: boolean) => void;
};

/**
 * Implements a service worker level HTTP proxy (fetch requests) that serves /.fs calls locally for synced spaces
 */
export class ProxyRouter extends EventEmitter<ProxyRouterEvents> {
  private fullSyncConfirmed = false;
  online = true;
  localSpacePrimitives?: SpacePrimitives;
  syncEngine?: SyncEngine;

  constructor(
    private basePathName: string,
    private baseURI: string,
    private precacheFiles: Record<string, string>,
  ) {
    super();
    // Actively check if we're online by pinging the server
    this.checkOnline();
    setInterval(() => {
      this.checkOnline();
    }, pingInterval);
  }

  /**
   * Called as soon the service worker is configured, and the service worker is ready to start serving requests.
   */
  configure(syncEngine: SyncEngine) {
    this.localSpacePrimitives = syncEngine.local;
    this.syncEngine = syncEngine;
    syncEngine.on({
      spaceSyncComplete: () => {
        if (!this.fullSyncConfirmed) {
          this.fullSyncConfirmed = true;
          console.log(
            "First full sync confirmed, will now start serving requests locally",
          );
        }
      },
    });
  }

  /**
   * Stops service worker operation only to be continued after reconfiguration
   */
  reset() {
    console.log("Shutting down proxy router and linked components");
    if (this.syncEngine) {
      this.syncEngine.stop();
      this.syncEngine = undefined;
    }
  }

  async checkOnline() {
    if (this.syncEngine) {
      try {
        await this.syncEngine.remote.ping();
        // If the ping is successful, we are online
        this.online = true;
      } catch {
        // Otherwise we're not
        this.online = false;
      } finally {
        this.emit("onlineStatusUpdated", this.online);
      }
    } else {
      console.info(
        "Sync engine not initialized yet, cannot check online status",
      );
    }
  }

  /**
   * Handles /.fs fetch events from the service worker.
   * @param event FetchEvent from the service worker
   */
  public onFetch(event: any) {
    const url = new URL(event.request.url);

    const pathname = url.pathname.substring(this.basePathName.length); //url.pathname with any URL prefix removed

    // Use the custom cache key if available, otherwise use the request URL
    const cacheKey = this.precacheFiles[pathname] || event.request.url;

    event.respondWith(
      (async () => {
        const request = event.request;
        const requestUrl = new URL(request.url);
        try {
          // Are we fetching a URL from the same origin as the app? If not, we don't handle it and pass it on
          if (!requestUrl.href.startsWith(this.baseURI)) {
            return fetch(request);
          }

          // Try the static (client) file cache first
          const cachedResponse = await caches.match(cacheKey);
          // Return the cached response if found
          if (cachedResponse) {
            return cachedResponse;
          }

          //requestUrl.pathname without with any URL prefix removed
          const pathname = requestUrl.pathname.substring(
            this.basePathName.length,
          );

          if (
            // Not yet configured -> Proxy
            !this.localSpacePrimitives || !this.syncEngine ||
            // Not fully synced but online -> Proxy
            (!this.fullSyncConfirmed && this.online) ||
            // A path we always need to proxy -> Proxy
            alwaysProxy.find((prefix) => pathname.startsWith(prefix))
          ) {
            try {
              return await fetch(request);
            } catch (e: any) {
              if (e.message === "Offline") {
                console.info(
                  "Detected offline, marking offline and falling through",
                );
                this.online = false;
              } else {
                throw e;
              }
            }
          }

          // We are now in a state we're configured and either a full sync cycle has complete (since boot) OR we're offline

          if (
            pathname.startsWith(fsEndpoint) &&
            pathname.endsWith(".md") &&
            !request.headers.has("X-Sync-Mode")
          ) {
            // This handles the case of ending up with a .md URL in the browser address bar (likely due to a auth proxy redirect)
            return Response.redirect(
              `${pathname.slice(fsEndpoint.length, -3)}`,
            );
          } else if (pathname.startsWith(fsEndpoint)) {
            // Handle /.fs file system APIs
            return this.handleRequest(pathname, request);
          } else {
            // Fallback to the app shell for all other requests (SPA)
            return (await caches.match(this.precacheFiles["/"])) ||
              fetch(request);
          }
        } catch (e: any) {
          console.warn("Fetch failed for", request.url, "error:", e.message);
          this.online = false;
          return new Response(offlineError.message, {
            status: 503, // Service Unavailable
          });
        }
      })(),
    );
  }

  handleRequest(
    pathname: string,
    request: Request,
  ): Promise<Response> {
    const path = decodePageURI(pathname.slice(fsEndpoint.length + 1));
    switch (request.method) {
      case "GET": {
        if (!path) { // .fs GET
          return this.handleFileListing();
        } else { // .fs/* GET
          return this.handleGet(path, request);
        }
      }
      case "PUT": {
        return this.handlePut(path, request);
      }
      case "DELETE": {
        return this.handleDelete(path, request);
      }
      default: {
        console.log("Unhandled method", request.method, "proxying to server");
        return fetch(request);
      }
    }
  }

  /**
   * Shortcut to nonSyncedFiles kept in snapshot
   */
  get nonSyncedFiles() {
    return this.syncEngine!.snapshot.nonSyncedFiles;
  }

  async handleFileListing(): Promise<Response> {
    if (!this.syncEngine || !this.localSpacePrimitives) {
      throw new Error("This should not happen");
    }

    const files = await this.localSpacePrimitives.fetchFileList();
    // Now augment this with non-synced file metadata
    for (
      const nonSyncedFile of this.nonSyncedFiles
        .values()
    ) {
      const existingFile = files.find((file) =>
        file.name === nonSyncedFile.name
      );
      if (!existingFile) {
        files.push(nonSyncedFile);
      }
    }
    return new Response(
      JSON.stringify(files),
      {
        headers: {
          "Content-Type": "application/json",
        },
      },
    );
  }

  async handleGet(path: string, request: Request): Promise<Response> {
    if (!this.syncEngine || !this.localSpacePrimitives) {
      throw new Error("This should not happen");
    }

    try {
      if (request.headers.has("x-get-meta")) {
        // Requesting only file meta
        const meta = await this.localSpacePrimitives.getFileMeta(path);
        if (request.headers.has("x-observing")) {
          setTimeout(() => {
            // Next tick
            this.emit("observedRequest", path);
          });
        }
        return new Response(null, {
          headers: fileMetaToHeaders(meta),
        });
      } else {
        const { meta, data } = await this.localSpacePrimitives.readFile(path);
        return new Response(data as any, {
          headers: fileMetaToHeaders(meta),
        });
      }
    } catch (err: any) {
      if (err.message === notFoundError.message && this.online) {
        console.info("No local copy of", path, "proxying to server");
        // Not found locally, but we're online, so let's try the server
        return fetch(request);
      } else if (err.message === notFoundError.message) {
        console.warn(
          "No local copy of",
          path,
          "and offline, so will 404 on this one",
        );
        // We're not online so let's assume the file indeed doesn't exist
        // TODO: What could be nice here is to check if this is a nonSyncedFile and if so serve some sort of offline placeholder
        return new Response(notFoundError.message, {
          status: 404,
        });
      }
      console.error("Error reading", path, err.message);
      return new Response(err.message, {
        status: 500,
      });
    }
  }

  async handlePut(path: string, request: Request): Promise<Response> {
    if (!this.syncEngine || !this.localSpacePrimitives) {
      throw new Error("This should not happen");
    }
    try {
      // console.log("Doing a write for", path);
      if (!this.syncEngine.isSyncCandidate(path) && this.online) {
        console.log("Handling file write for non-synced file", path);
        // Writing a non-synced file while being online
        // Proxy the request
        const resp = await fetch(request);
        // Update the nonSynced snapshot in place for later file listing consistency
        this.nonSyncedFiles.set(path, headersToFileMeta(path, resp.headers)!);
        return resp;
      } else {
        // Synced file
        const body = await request.arrayBuffer();
        // console.log("Handling file write", path, body.byteLength);
        const meta = await this.localSpacePrimitives.writeFile(
          path,
          new Uint8Array(body),
          // Note: there are going to be many cases where no meta is supplied in the request, this is ok, in that case this argument will be undefined
          headersToFileMeta(path, request.headers),
        );
        // Attempt immediate sync
        try {
          const operations = await this.syncEngine.syncSingleFile(path);
          if (operations === -1) {
            console.info("File sync delayed for", path);
            // Sync was in progress, will sync later
            return new Response("Delayed", {
              status: 202,
              headers: fileMetaToHeaders(meta),
            });
          }
        } catch (e: any) {
          console.error(
            "File sync delayed for",
            path,
            "due to error",
            e.message,
          );
          // Sync failed (could be offline or other reason)
          return new Response(e.message, {
            status: 202,
            headers: fileMetaToHeaders(meta),
          });
        }

        return new Response("OK", {
          status: 200,
          headers: fileMetaToHeaders(meta),
        });
      }
    } catch (e: any) {
      console.error("Error writing", path, e.message);
      return new Response(e.message, {
        status: 500,
      });
    }
  }

  async handleDelete(path: string, request: Request): Promise<Response> {
    if (!this.syncEngine || !this.localSpacePrimitives) {
      throw new Error("This should not happen");
    }

    try {
      if (!this.syncEngine.isSyncCandidate(path)) {
        console.log("Handling file delete for non-synced file", path);
        this.nonSyncedFiles.delete(path);
        // Proxy the request
        return fetch(request);
      }
      // console.log("Handling file delete", path);
      await this.localSpacePrimitives.deleteFile(path);
      return new Response("OK", {
        status: 200,
      });
    } catch (e: any) {
      console.error("Error deleting", path, e.message);
      if (e.message === notFoundError.message) {
        return new Response(notFoundError.message, {
          status: 404,
        });
      }
      return new Response(e.message, {
        status: 500,
      });
    }
  }
}


================================================
FILE: client/service_worker/sync_engine.ts
================================================
import { compile as gitIgnoreCompiler } from "gitignore-parser";
import { jitter, sleep } from "@silverbulletmd/silverbullet/lib/async";
import type { KvPrimitives } from "../data/kv_primitives.ts";
import { EventEmitter } from "../plugos/event.ts";
import { stdLibPrefix } from "../spaces/constants.ts";
import type { SpacePrimitives } from "../spaces/space_primitives.ts";
import { SpaceSync, SyncSnapshot, type SyncStatus } from "../spaces/sync.ts";
import type { HttpSpacePrimitives } from "../spaces/http_space_primitives.ts";

const syncSnapshotKey = ["$sync", "snapshot"];
const syncInterval = 20;

type SyncEngineEvents = {
  // Full sync cycle has completed
  spaceSyncComplete: (operations: number) => void | Promise<void>;

  // A single file syncle has completed
  fileSyncComplete: (path: string, operations: number) => void | Promise<void>;

  syncError: (error: Error) => void | Promise<void>;

  // Sync conflict occurred
  syncConflict: (path: string) => void | Promise<void>;

  // Sync progress updated
  syncProgress: (
    syncStatus: SyncStatus,
    snapshot: SyncSnapshot,
  ) => void | Promise<void>;
};

export type SyncConfig = {
  syncDocuments?: boolean;
  syncIgnore?: string;
};

/**
 * Thin wrapper around SpaceSync, adds snapshot persistence and a few other things
 */
export class SyncEngine extends EventEmitter<SyncEngineEvents> {
  spaceSync!: SpaceSync;

  private syncConfig: SyncConfig = {
    syncDocuments: true,
  };

  stopping = false;
  syncAccepts: (path: string) => boolean = () => true;
  snapshot!: SyncSnapshot;

  constructor(
    private kv: KvPrimitives,
    readonly local: SpacePrimitives,
    readonly remote: HttpSpacePrimitives,
  ) {
    super();
  }

  async start() {
    this.snapshot = await this.loadSnapshot();

    this.spaceSync = new SpaceSync(this.local, this.remote, {
      conflictResolver: this.stdLibAwareConflictResolver.bind(this),
      isSyncCandidate: this.isSyncCandidate.bind(this),
    });

    this.spaceSync.on({
      syncProgress: async (status, snapshot) => {
        this.emit("syncProgress", status, snapshot);
        await this.saveSnapshot(snapshot);
      },
      snapshotUpdated: this.saveSnapshot.bind(this),
    });

    // Start the sync loop
    this.run();
  }

  stop() {
    this.stopping = true;
  }

  async run() {
    while (true) {
      if (this.stopping) {
        return;
      }
      try {
        await this.syncSpace();
      } catch (e: any) {
        // User error communication is happening in syncSpace
        console.error("Sync space error", e.message);
      }
      await sleep(syncInterval * 1000 + jitter());
    }
  }

  public setSyncConfig(config: SyncConfig) {
    this.syncConfig = config;
    this.syncAccepts = config.syncIgnore
      ? gitIgnoreCompiler(config.syncIgnore).accepts
      : () => true;
    console.log(
      "[sync] Updated sync config:",
      this.syncConfig,
    );
  }

  isSyncCandidate(path: string): boolean {
    // ALWAYS sync plugs
    if (path.endsWith(".plug.js")) {
      return true;
    }
    // Follow SB_SYNC_IGNORE rules
    if (!this.syncAccepts(path)) {
      return false;
    }
    // Either sync all files, or only .md files if syncDocuments is false
    return this.syncConfig.syncDocuments || path.endsWith(".md");
  }

  async syncSpace(): Promise<number> {
    try {
      const operations = await this.spaceSync.syncFiles(this.snapshot);
      if (operations !== -1) {
        // emit successful sync event (not when operations === -1, because that means another sync was ongoing)
        this.emit("spaceSyncComplete", operations);
      }
      return operations;
    } catch (e) {
      this.emit("syncError", e);
      throw e;
    }
  }

  async syncSingleFile(path: string): Promise<number> {
    try {
      const operations = await this.spaceSync.syncSingleFile(
        path,
        this.snapshot,
      );
      this.emit("fileSyncComplete", path, operations);
      return operations;
    } catch (e) {
      this.emit("syncError", e);
      throw e;
    }
  }

  /**
   * Loads the sync snapshot from the data store.
   * @returns A map of sync status items.
   */
  async loadSnapshot(): Promise<SyncSnapshot> {
    const [snapshot] = await this.kv.batchGet([syncSnapshotKey]);
    return SyncSnapshot.fromJSON(snapshot);
  }

  /**
   * Saves the sync snapshot to the data store.
   * @param snapshot A map of sync status items.
   */
  saveSnapshot(snapshot: SyncSnapshot) {
    return this.kv.batchSet([{
      key: syncSnapshotKey,
      value: snapshot.toJSON(),
    }]);
  }

  async wipe() {
    this.stop();
    console.log("Wiping sync database");
    await this.kv.clear();
    console.log("Done wiping");
  }

  /**
   * Delegates to the standard primary conflict resolver, but in case of any conflicts in plugs, it will always take the version from the secondary.
   */
  async stdLibAwareConflictResolver(
    name: string,
    snapshot: SyncSnapshot,
    primary: SpacePrimitives,
    secondary: SpacePrimitives,
  ): Promise<number> {
    if (!name.startsWith(stdLibPrefix)) {
      const operations = await SpaceSync.primaryConflictResolver(
        name,
        snapshot,
        primary,
        secondary,
      );

      if (operations > 0) {
        // Something happened -> conflict copy generated, let's report it
        this.emit("syncConflict", name);
      }

      return operations;
    }
    console.log(
      "[sync]",
      "Conflict in plug",
      name,
      "will pick the version from secondary and be done with it.",
    );
    // Read file from secondary
    const { data, meta } = await secondary.readFile(
      name,
    );
    // Write file to primary
    const newMeta = await primary.writeFile(
      name,
      data,
      meta,
    );
    // Update snapshot
    snapshot.files.set(name, [
      newMeta.lastModified,
      meta.lastModified,
    ]);

    return 1;
  }
}


================================================
FILE: client/service_worker/util.ts
================================================
export async function unregisterServiceWorkers() {
  if (navigator.serviceWorker) {
    const registrations = await navigator.serviceWorker
      .getRegistrations();
    for (const registration of registrations) {
      await registration.unregister();
      console.log("Service worker unregistered");
    }
  } else {
    console.error("No service worker found to unregister");
  }
}

export function flushCachesAndUnregisterServiceWorker() {
  console.log("Going to flush the caches and unregister the service worker");
  return new Promise<void>((resolve) => {
    if (!navigator.serviceWorker?.controller) {
      console.log("No service worker active");
      return resolve();
    }

    navigator.serviceWorker.addEventListener("message", (event) => {
      if (event.data.type === "cacheFlushed") {
        console.log("Cache flushed");
        // Then unregister all service workers
        navigator.serviceWorker.getRegistrations().then((registrations) => {
          for (const registration of registrations) {
            registration.unregister();
            console.log("Service worker unregistered");
            resolve();
          }
        });
      }
    });

    // First flush active cache
    navigator.serviceWorker.ready.then((registration) => {
      console.log("Sending request to flush cache");
      registration.active!.postMessage({ type: "flush-cache" });
    });
  });
}


================================================
FILE: client/space.test.ts
================================================
import { sleep } from "@silverbulletmd/silverbullet/lib/async";
import { MemoryKvPrimitives } from "./data/memory_kv_primitives.ts";
import { EventHook } from "./plugos/hooks/event.ts";
import { Space } from "./space.ts";
import { DataStoreSpacePrimitives } from "./spaces/datastore_space_primitives.ts";
import { assertEquals } from "@std/assert";
import { parseToRef } from "@silverbulletmd/silverbullet/lib/ref";

const testPage = `
Some paragraph
# Header 1
Some text

# Header 2
* Item 1
  * Sub item
* [ ] Task 1
  * Sub item 2
    * Sub-sub item
`.trim();

Deno.test("readRef checks", async () => {
  const kv = new MemoryKvPrimitives();
  const eventHook = new EventHook();
  const space = new Space(new DataStoreSpacePrimitives(kv), eventHook);
  await sleep(1);
  await space.writePage("test", testPage);

  // Reference to page
  assertEquals(await space.readRef(parseToRef("test")!), {
    text: testPage,
    offset: 0,
  });

  // Pointer to a paragraph
  assertEquals(await space.readRef(parseToRef("test@0")!), {
    text: "Some paragraph",
    offset: 0,
  });

  // With a linecolumn ref
  assertEquals(await space.readRef(parseToRef("test@l1c1")!), {
    text: "Some paragraph",
    offset: 0,
  });

  // Reference to a header
  assertEquals(
    await space.readRef(parseToRef("test#Header 1")!),
    {
      text: "# Header 1\nSome text\n\n",
      offset: testPage.indexOf("# Header 1"),
    },
  );
  assertEquals(
    await space.readRef(parseToRef("test#Header 2")!),
    {
      text:
        "# Header 2\n* Item 1\n  * Sub item\n* [ ] Task 1\n  * Sub item 2\n    * Sub-sub item",
      offset: testPage.indexOf("# Header 2"),
    },
  );

  // Reference to an item should get item and children
  const itemPos = testPage.indexOf("* Item 1");
  assertEquals(
    await space.readRef(parseToRef(`test@${itemPos}`)!),
    { text: "* Item 1\n  * Sub item", offset: itemPos },
  );

  // Reference to a task should get item and children
  const taskPos = testPage.indexOf("* [ ] Task 1");
  assertEquals(
    await space.readRef(parseToRef(`test@${taskPos}`)!),
    {
      text: "* [ ] Task 1\n  * Sub item 2\n    * Sub-sub item",
      offset: taskPos,
    },
  );

  // Check left shift in case of jumping into nested item
  const subItemPos = testPage.indexOf("* Sub item 2");
  assertEquals(
    await space.readRef(parseToRef(`test@${subItemPos}`)!),
    { text: "* Sub item 2\n  * Sub-sub item", offset: subItemPos },
  );
});


================================================
FILE: client/space.ts
================================================
import type { EventHook } from "./plugos/hooks/event.ts";
import { jitter, safeRun } from "@silverbulletmd/silverbullet/lib/async";
import { localDateString } from "@silverbulletmd/silverbullet/lib/dates";
import type {
  DocumentMeta,
  FileMeta,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import type { SpacePrimitives } from "./spaces/space_primitives.ts";
import {
  getOffsetFromLineColumn,
  getPathExtension,
  type Path,
  type Ref,
} from "@silverbulletmd/silverbullet/lib/ref";
import { parseMarkdown } from "./markdown_parser/parser.ts";
import {
  addParentPointers,
  findNodeMatching,
  nodeAtPos,
  renderToText,
  traverseTree,
} from "@silverbulletmd/silverbullet/lib/tree";

const pageWatchInterval = 3000; // + jitter

/**
 * Wrapper around space primitives (see spaces/) for Client use
 * Adds:
 * - Concept of a page (on top of files)
 * - Page watchers
 */
export class Space {
  // We do watch files in the background to detect changes
  // This set of pages should only ever contain 1 page
  watchedFiles = new Set<string>();
  watchInterval?: number;

  // private initialPageListLoad = true;
  private saving = false;

  constructor(
    readonly spacePrimitives: SpacePrimitives,
    eventHook: EventHook,
  ) {
    eventHook.addLocalListener("file:deleted", (fileName: string) => {
      if (this.watchedFiles.has(fileName)) {
        this.watchedFiles.delete(fileName);
      }
    });
    setTimeout(async () => {
      // The only reason to do this is to trigger events
      await this.spacePrimitives.fetchFileList();
    });
  }

  async deletePage(name: string): Promise<void> {
    await this.getPageMeta(name); // Check if page exists, if not throws Error
    await this.spacePrimitives.deleteFile(`${name}.md`);
  }

  async getPageMeta(name: string): Promise<PageMeta> {
    return fileMetaToPageMeta(
      await this.spacePrimitives.getFileMeta(`${name}.md`),
    );
  }

  async listPlugs(): Promise<FileMeta[]> {
    const files = await this.deduplicatedFileList();
    return files
      .filter((fileMeta) => fileMeta.name.endsWith(".plug.js"));
  }

  async readPage(name: string): Promise<{ text: string; meta: PageMeta }> {
    const pageData = await this.spacePrimitives.readFile(`${name}.md`);
    return {
      text: new TextDecoder().decode(pageData.data),
      meta: fileMetaToPageMeta(pageData.meta),
    };
  }

  async readRef(ref: Ref): Promise<{ offset: number; text: string }> {
    if (!ref.path.endsWith(".md")) {
      throw new Error("Not supported");
    }
    const name = ref.path.slice(0, -3);
    const pageText = (await this.readPage(name)).text;
    if (!ref.details) {
      return { offset: 0, text: pageText };
    }
    const tree = parseMarkdown(pageText);
    addParentPointers(tree);
    if (ref.details.type === "linecolumn") {
      ref.details = {
        type: "position",
        pos: getOffsetFromLineColumn(
          pageText,
          ref.details.line,
          ref.details.column,
        ),
      };
    }
    switch (ref.details.type) {
      case "header": {
        const desiredHeaderText = ref.details.header;
        let text = `**Error:** Header not found: ${desiredHeaderText}`;
        let offset = 0;
        traverseTree(tree, (n) => {
          if (n.type && n.type.startsWith("ATXHeading")) {
            const level = +n.type!.substring("ATXHeading".length);
            const headerText = renderToText(n).slice(level + 1);
            let endPos = pageText.length;
            if (headerText === desiredHeaderText) {
              // Now we have to scan for the end point
              // Let's go up one level and find either another header at this same level
              const parent = n.parent!;
              const nextHeader = findNodeMatching(
                parent,
                (h) => h.type === `ATXHeading${level}` && h.from! > n.from!,
              );
              if (nextHeader) {
                endPos = nextHeader.from!;
              }
              text = pageText.slice(n.from!, endPos);
              offset = n.from!;
              return true;
            }
          }
          return false;
        });
        return { offset, text };
      }
      case "position": {
        const pos = ref.details.pos;
        let n = nodeAtPos(tree, pos);
        if (!n) {
          throw new Error("No node found at position");
        }
        if (["ListMark"].includes(n.type!)) {
          n = n.parent!;
        }
        const sliceText = pageText.slice(n.from!, n.to!);

        // Determine indent level
        const targetLineIndex =
          pageText.substring(0, n.from!).split("\n").length - 1;
        const lines = pageText.split("\n");
        const targetLine = lines[targetLineIndex];
        const indent = targetLine.match(/^\s*/)![0];
        return { offset: pos, text: sliceText.replaceAll(`\n${indent}`, "\n") };
      }
    }
    return { text: "", offset: 0 };
  }

  async writePage(
    name: string,
    text: string,
  ): Promise<PageMeta> {
    try {
      this.saving = true;
      const pageMeta = fileMetaToPageMeta(
        await this.spacePrimitives.writeFile(
          `${name}.md`,
          new TextEncoder().encode(text),
        ),
      );
      // Note: we don't do very elaborate cache invalidation work here, quite quickly the cache will be flushed anyway
      return pageMeta;
    } finally {
      this.saving = false;
    }
  }

  // We're listing all pages that don't start with a _
  isListedPage(fileMeta: FileMeta): boolean {
    return fileMeta.name.endsWith(".md") && !fileMeta.name.startsWith("_");
  }

  // Checks if this a document to be listed meaning:
  // - it's not a markdown file
  // - it's not a javascript of javascript source map (.map)
  isListedDocument(fileMeta: FileMeta): boolean {
    return !this.isListedPage(fileMeta) && !fileMeta.name.endsWith(".js") &&
      !fileMeta.name.endsWith(".map");
  }

  async fetchPageList(): Promise<PageMeta[]> {
    return (await this.deduplicatedFileList())
      .filter(this.isListedPage)
      .map(fileMetaToPageMeta);
  }

  async fetchDocumentList(): Promise<DocumentMeta[]> {
    return (await this.deduplicatedFileList()).flatMap((fileMeta) =>
      this.isListedDocument(fileMeta) ? [fileMetaToDocumentMeta(fileMeta)] : []
    );
  }

  async deduplicatedFileList(): Promise<FileMeta[]> {
    const files = await this.spacePrimitives.fetchFileList();
    const fileMap = new Map<string, FileMeta>();
    for (const file of files) {
      if (fileMap.has(file.name)) {
        const existing = fileMap.get(file.name)!;
        if (existing.lastModified < file.lastModified) {
          fileMap.set(file.name, file);
        }
      } else {
        fileMap.set(file.name, file);
      }
    }
    return [...fileMap.values()];
  }

  /**
   * Reads a document
   * @param name path of the document
   * @returns
   */
  async readDocument(
    name: string,
  ): Promise<{ data: Uint8Array; meta: DocumentMeta }> {
    const file = await this.spacePrimitives.readFile(name);
    return { data: file.data, meta: fileMetaToDocumentMeta(file.meta) };
  }

  async getDocumentMeta(name: string): Promise<DocumentMeta> {
    return fileMetaToDocumentMeta(
      await this.spacePrimitives.getFileMeta(name),
    );
  }

  async writeDocument(
    name: string,
    data: Uint8Array,
  ): Promise<DocumentMeta> {
    return fileMetaToDocumentMeta(
      await this.spacePrimitives.writeFile(name, data),
    );
  }

  deleteDocument(name: string): Promise<void> {
    return this.spacePrimitives.deleteFile(name);
  }

  /**
   * Polls for changes in the watched files.
   */
  watch() {
    if (this.watchInterval) {
      clearInterval(this.watchInterval);
    }
    this.watchInterval = setInterval(() => {
      safeRun(async () => {
        if (this.saving) {
          return;
        }
        for (const fileName of this.watchedFiles) {
          // Setting observing to true here to hint that we may be interested in more active syncing
          await this.spacePrimitives.getFileMeta(fileName, true);
        }
      });
    }, pageWatchInterval + jitter());
  }

  unwatch() {
    if (this.watchInterval) {
      clearInterval(this.watchInterval);
    }
  }

  watchFile(fileName: string) {
    this.watchedFiles.add(fileName);
  }

  unwatchFile(fileName: string) {
    this.watchedFiles.delete(fileName);
  }
}

export function fileMetaToPageMeta(fileMeta: FileMeta): PageMeta {
  const name = fileMeta.name.substring(0, fileMeta.name.length - 3);
  try {
    return {
      ...fileMeta,
      ref: name,
      tag: "page",
      tags: [],
      name,
      created: localDateString(new Date(fileMeta.created)),
      lastModified: localDateString(new Date(fileMeta.lastModified)),
    } as PageMeta;
  } catch (e) {
    console.error("Failed to convert fileMeta to pageMeta", fileMeta, e);
    throw e;
  }
}

export function fileMetaToDocumentMeta(
  fileMeta: FileMeta,
): DocumentMeta {
  try {
    return {
      ...fileMeta,
      ref: fileMeta.name,
      tag: "document",
      created: localDateString(new Date(fileMeta.created)),
      lastModified: localDateString(new Date(fileMeta.lastModified)),
      // Name is always equal to the path for documents
      extension: getPathExtension(fileMeta.name as Path),
    } as DocumentMeta;
  } catch (e) {
    console.error("Failed to convert fileMeta to documentMeta", fileMeta, e);
    throw e;
  }
}


================================================
FILE: client/space_lua.ts
================================================
import type { System } from "./plugos/system.ts";
import type { SpaceLuaObject } from "../plugs/index/space_lua.ts";
import { LuaEnv, LuaRuntimeError, LuaStackFrame } from "./space_lua/runtime.ts";
import { parse as parseLua, parseExpressionString } from "./space_lua/parse.ts";
import { evalStatement } from "./space_lua/eval.ts";
import {
  encodeRef,
  parseToRef,
  type Ref,
} from "@silverbulletmd/silverbullet/lib/ref";
import type { ASTCtx } from "./space_lua/ast.ts";
import { buildLuaEnv } from "./space_lua_api.ts";
import type { LuaCollectionQuery } from "./space_lua/query_collection.ts";
import type { ObjectIndex } from "./data/object_index.ts";

export class SpaceLuaEnvironment {
  env: LuaEnv;

  constructor(private system: System<any>, private objectIndex: ObjectIndex) {
    this.env = buildLuaEnv(system);
  }

  /**
   * Loads all Lua scripts from the database and evaluates them in a new environment
   * @param system
   */
  async reload() {
    const allScripts: SpaceLuaObject[] = await this.objectIndex.queryLuaObjects(
      this.env,
      "space-lua",
      {
        objectVariable: "script",
        orderBy: [{
          expr: parseExpressionString("script.priority"),
          desc: true,
        }],
      } as LuaCollectionQuery,
    );
    try {
      this.env = buildLuaEnv(this.system);
      const tl = new LuaEnv();
      tl.setLocal("_GLOBAL", this.env);
      for (const script of allScripts) {
        try {
          console.log("Now evaluating", script.ref);
          const ast = parseLua(script.script, { ref: script.ref });
          // We create a local scope for each script
          const scriptEnv = new LuaEnv(this.env);
          const sf = new LuaStackFrame(tl, ast.ctx);
          await evalStatement(ast, scriptEnv, sf);
        } catch (e: any) {
          if (e instanceof LuaRuntimeError) {
            const origin = resolveASTReference(e.sf.astCtx!);
            if (origin) {
              console.error(
                `Error evaluating script: ${e.message} at [[${
                  encodeRef(origin)
                }]]`,
              );
              continue;
            }
          }
          console.error(
            `Error evaluating script: ${e.message} for script: ${script.script}`,
            e,
          );
        }
      }

      console.log("[Lua] Loaded", allScripts.length, "scripts");
    } catch (e: any) {
      console.error("Error reloading Lua scripts:", e.message);
    }
  }
}

export function resolveASTReference(ctx?: ASTCtx): Ref | null {
  if (!ctx?.ref) {
    return null;
  }
  const ref = parseToRef(ctx.ref);
  if (!ref) {
    return null;
  }

  if (ref.details?.type === "position") {
    ref.details.pos = (ref.details.pos as number) +
      "```space-lua\n".length +
      ctx.from!;
  }

  return ref;
}


================================================
FILE: client/space_lua/arithmetic_test.lua
================================================
local function assertEquals(actual, expected, message)
  if actual ~= expected then
    error('Assertion failed: ' .. message)
  end
end

local function assertThrows(msg_substr, fn)
  local ok, err = pcall(fn)

  if ok then
    error('Assertion failed: expected error containing "'
      .. msg_substr .. '"')
  end

  if type(err) ~= 'string' then
    err = tostring(err)
  end

  if not string.find(err, msg_substr, 1, true) then
    error('Assertion failed: expected error message to contain "'
      .. msg_substr .. '", got: "' .. err .. '"')
  end
end

-- 1. Integer vs float zero divisors

-- 1.1. Integer zeros collapse (no -0 for integers)
assertEquals(1/0 == 1/-0, true, 'int: 1/0 == 1/-0 (+Inf)')
assertEquals(-1/0 == 1/-0, false, 'int: -1/0 != 1/-0')

-- 1.2. Float zeros sign preservation
assertEquals(1/0.0 == 1/-0.0, false, 'float: +Inf != -Inf')
assertEquals(1/0.0 == -1/-0.0, true, 'float: 1/0.0 == -1/-0.0')
assertEquals(-1/0.0 == 1/-0.0, true, 'float: -1/0.0 == 1/-0.0')

-- 1.3. Basic division
assertEquals(5/2, 2.5, 'div: 5/2 == 2.5')
assertEquals(-5/2, -2.5, 'div: -5/2 == -2.5')
assertEquals(5/-2, -2.5, 'div: 5/-2 == -2.5')
assertEquals(-5/-2, 2.5, 'div: -5/-2 == 2.5')

-- 2. Unary minus literals and simple expressions
assertEquals(1/-(0) == 1/0, true, 'unary minus: int literal (+Inf)')
assertEquals(1/-(0.0) == -1/0.0, true, 'unary minus: float literal (-Inf)')
assertEquals(1/-(1-1) == 1/0, true, 'unary minus: int expr (+Inf)')
assertEquals(1/-(1.0-1.0) == -1/0.0, true, 'unary minus: float expr (-Inf)')

-- 2.1. Unary minus coercion and precedence with power
assertEquals(-2^2, -4, 'precedence: -2^2 == -(2^2)')
assertEquals((-2)^2, 4, 'precedence: (-2)^2 == 4')

-- 2.1.1. Unary minus with float-typed exponentiation results
do
  local function mt(x)
    return math.type(x)
  end

  assertEquals(mt(-("1.0" ^ 1)), "float", "math.type(-('1.0'^1)) => float")
  assertEquals(mt(-"1.0" ^ 1), "float", "math.type(-'1.0'^1) => float")
  assertEquals(mt(-("2.0" ^ 1)), "float", "math.type(-('2.0'^1)) => float")
  assertEquals(mt(-("2.0" ^ 2)), "float", "math.type(-('2.0'^2)) => float")
end

-- 2.2. Unary minus uses table metadata for dynamic keys
local dyn_tbl = {}
local dyn_key = 'zf'
dyn_tbl[dyn_key] = 0.0
assertEquals(1/-(dyn_tbl[dyn_key]) == -1/0.0, true, 'unary minus: table dyn key float (-Inf)')

-- 2.3. Unary minus uses table metadata for property access
local prop_tbl = { zf = 0.0, zi = 0 }
assertEquals(1/-(prop_tbl.zf) == -1/0.0, true, 'unary minus: table prop float (-Inf)')
assertEquals(1/-(prop_tbl.zi) == 1/0, true, 'unary minus: table prop int (+Inf)')

-- 2.4. Unary minus uses env metadata for locals
local u_zi, u_zf, u_zfn = 0, 0.0, -0.0
assertEquals(1/-(u_zi) == 1/0, true, 'var: unary minus zi (+Inf)')
assertEquals(1/-(u_zfn) == 1/0.0, true, 'var: unary minus zfn (+Inf)')
assertEquals(1/u_zf == 1/0.0, true, 'var: zf (+Inf)')

-- 3. Integer operations (must not produce -0)
assertEquals(1/(1-1) == 1/0, true, 'int: sub (+0)')
assertEquals(1/(0*-1) == 1/0, true, 'int: mul (+0)')
assertEquals(1/(0%1) == 1/0, true, 'int: mod (+0)')
assertEquals(1/(0%-1) == 1/0, true, 'int: mod neg divisor (+0)')

-- 4. Float operations (must preserve -0.0)
assertEquals(1/(0.0*-1.0) == -1/ 0.0, true, 'float: mul (-0.0)')
assertEquals(1/((-0.0)%1.0) == -1/ 0.0, true, 'float: mod (-0.0)')
assertEquals(1/((-0.0)%-1.0) == -1/ 0.0, true, 'float: mod neg divisor (-0.0)')

-- 4.1. Zero result from float addition prefers +0.0
assertEquals(1/((-0.0)+0.0) == 1/0.0, true, 'float: (-0.0)+0.0 yields +0.0')

-- 4.2. Plain -0.0 handling in arithmetic
do
  local nz = -0.0
  -- Addition: -0.0 + 0 yields +0.0 (IEEE 754 rule)
  assertEquals(1/(nz + 0), 1/0.0, 'plain -0.0: nz + 0 yields +0.0')
  assertEquals(1/(0 + nz), 1/0.0, 'plain -0.0: 0 + nz yields +0.0')

  -- Multiplication preserves -0.0
  assertEquals(1/(nz * 1), -1/0.0, 'plain -0.0: nz * 1 yields -0.0')
  assertEquals(1/(1 * nz), -1/0.0, 'plain -0.0: 1 * nz yields -0.0')

  -- Subtraction: -0.0 - 0 = -0.0
  assertEquals(1/(nz - 0), 1/0.0, 'plain -0.0: nz - 0 yields +0.0')

  -- Subtraction: -0.0 - 0.0 = -0.0 (float)
  assertEquals(1/(nz - 0.0), -1/0.0, 'plain -0.0: nz - 0.0 yields -0.0')

  -- Subtraction: 0 - (-0.0) = +0.0
  assertEquals(1/(0 - nz), 1/0.0, 'plain -0.0: 0 - nz yields +0.0')
end

-- 4.3. Expression-generated -0.0
do
  local r = 0.0 * -1.0
  assertEquals(1/r, -1/0.0, 'expr: 0.0 * -1.0 yields -0.0')

  local s = -1.0 * 0.0
  assertEquals(1/s, -1/0.0, 'expr: -1.0 * 0.0 yields -0.0')

  -- Division producing -0.0
  local d = -0.0 / 1.0
  assertEquals(1/d, -1/0.0, 'expr: -0.0 / 1.0 yields -0.0')
end

-- 5. Mixed arithmetic producing zero
assertEquals(1/(0*-1.0) == -1/0.0, true, 'mixed: mul int*float (-0.0)')
assertEquals(1/(0.0*-1 ) == -1/0.0, true, 'mixed: mul float*int (-0.0)')
assertEquals(1/(1.0+(-1)) == 1/0.0, true, 'mixed: add (+0.0)')
assertEquals(1/(-(1-1.0)) == -1/0.0, true, 'mixed: sub then unary minus (-0.0)')

-- 5.1. Dynamic key table metadata affects binary ops
local dyn_t, dyn_k = {}, 'zf'
dyn_t[dyn_k] = 0.0
assertEquals(1/dyn_t[dyn_k] == 1/0.0, true, 'binary: table dyn key float (+Inf)')

-- 6. Variables
local zi, zf, zfn = 0, 0.0, -0.0

assertEquals(1/zi == 1/0, true, 'var: zi (+Inf)')
assertEquals(1/zf == 1/0.0, true, 'var: zf (+Inf)')
assertEquals(1/zfn == -1/0.0, true, 'var: zfn (-Inf)')
assertEquals(1/-(zi) == 1/0, true, 'var: unary minus zi (+Inf)')
assertEquals(1/-(zfn) == 1/0.0, true, 'var: unary minus zfn (+Inf)')

-- 6.1. Variables: metadata must flow through reassignment
local zswap = 0.0
assertEquals(1/zswap == 1/0.0, true, 'var: zswap float (+Inf)')
zswap = 0
assertEquals(1/zswap == 1/0, true, 'var: zswap reassigned int (+Inf)')
zswap = 0.0
assertEquals(1/zswap == 1/0.0, true, 'var: zswap reassigned float (+Inf)')

-- 7. Functions returning zeros and unary minus
local function ret_zi()
  return 0
end

local function ret_zf()
  return 0.0
end

local function ret_zfn()
  return -0.0
end

assertEquals(1/ret_zi() == 1/0, true, 'fn: ret_zi (+Inf)')
assertEquals(1/ret_zf() == 1/0.0, true, 'fn: ret_zf (+Inf)')
assertEquals(1/ret_zfn() == -1/0.0, true, 'fn: ret_zfn (-Inf)')

assertEquals(1/-(ret_zi()) == 1/0, true, 'fn unary minus: ret_zi (+Inf)')
assertEquals(1/-(ret_zf()) == -1/0.0, true, 'fn unary minus: ret_zf (-Inf)')
assertEquals(1/-(ret_zfn()) == 1/0.0, true, 'fn unary minus: ret_zfn (+Inf)')

-- 8. Tables and arrays
local t, arr = {zi=zi, zfn=zfn}, {zi, zfn}

-- 8.1. Tables
assertEquals(1/t.zi == 1/0, true, 'table: t.zi (+Inf)')
assertEquals(1/t.zfn == -1/0.0, true, 'table: t.zfn (-Inf)')

-- 8.2. Arrays
assertEquals(1/arr[1] == 1/0, true, 'array: arr[1]=zi (+Inf)')
assertEquals(1/arr[2] == -1/0.0, true, 'array: arr[2]=zfn (-Inf)')

-- 8.3. Arrays: dynamic numeric index key metadata
local arr2 = {}
local idx = 1
arr2[idx] = 0.0
assertEquals(1/arr2[idx] == 1/0.0, true, 'array: dyn index float (+Inf)')

-- 9. Deeply nested parentheses and expressions
local xi, xf = 1-1, 1.0-1.0
local deepi = -((((0+0)-(1-1))+(zi-xi))) -- int path (+0)
local deepf = -((((0.0+0.0)-(1.0-1.0))+(zf-xf))) -- float path (-0.0)

assertEquals(1/deepi == 1/0, true, 'nested: int (+Inf)')
assertEquals(1/deepf == -1/0.0, true, 'nested: float (-Inf)')

-- 10. Floor division near zero
assertEquals(1/(0//1) == 1/0, true, 'floor div: int (+0)')
assertEquals(1/(0.0//1.0) == 1/0.0, true, 'floor div: float (+Inf)')
assertEquals(1/((-0.0)//1.0) == -1/0.0, true, 'floor div: float (-Inf)')
assertEquals(1/(0//1.0) == 1/0.0, true, 'floor div: mixed (+0.0)')

-- 10.1. Modulo/division identity
local function id_ok(a, b)
  return a == b * (a // b) + a % b
end

assertEquals(id_ok(5, 2), true, 'identity: 5, 2')
assertEquals(id_ok(-5, 2), true, 'identity: -5, 2')
assertEquals(id_ok(5, -2), true, 'identity: 5, -2')
assertEquals(id_ok(-5, -2), true, 'identity: -5, -2')

-- 10.2. Floor division signs
assertEquals(5//-2, -3, 'idiv: 5//-2 == -3')
assertEquals(-5//2, -3, 'idiv: -5//2 == -3')
assertEquals(-5//-2, 2, 'idiv: -5//-2 == 2')

-- 11. Ordering and NaN
assertEquals((-0.0) < (0.0), false, 'ordering: -0.0 < 0.0 is false')
assertEquals((0.0) < (-0.0), false, 'ordering: 0.0 < -0.0 is false')
assertEquals((-0.0) <= (0.0), true, 'ordering: -0.0 <= 0.0')
assertEquals((0.0) <= (-0.0), true, 'ordering: 0.0 <= -0.0')
assertEquals((0/0) == (0/0), false, 'NaN: never equals itself')

-- 12. Bitwise operators
assertEquals((~0) == -1, true, 'bitwise not on int ok')

val = pcall(
  function()
    return ~0.0
  end
)
assertEquals(val, true, 'bitwise not on float ok')

val = pcall(
  function()
    return 0<<1
  end
)
assertEquals(val, true, 'shl int ok')

val = pcall(
  function()
    return 0.0<<1
  end
)
assertEquals(val, true, 'shl float ok')

-- 12.1 Bitwise ops results
assertEquals((5&3) == 1, true, 'bitwise and result')
assertEquals((5|2) == 7, true, 'bitwise or result')
assertEquals((5~1) == 4, true, 'bitwise xor result')
assertEquals((1<<5) == 32, true, 'bitwise shl result')
assertEquals((32>>5) == 1, true, 'bitwise shr result')

-- 12.2 Bitwise with float values
assertEquals((~(-0.0)) == -1, true, 'bitwise not on -0.0 == -1')

-- 13. Evaluation order (left-to-right) for binary ops
local log, val

-- arithmetic + - * /
local function lhs_num()
  log[#log + 1] = 'L'
  return 1
end

local function rhs_num()
  log[#log + 1] = 'R'
  return 2
end

log = {}
val = lhs_num()+rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: +')

log = {}
val = lhs_num()-rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: -')

log = {}
val = lhs_num()*rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: *')

log = {}
val = lhs_num()/rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: /')

-- floor div and mod
log = {}
val = lhs_num()//rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: //')

log = {}
val = lhs_num()%rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: %')

-- power and concatenation
log = {}
val = lhs_num()^rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: ^')

local function lhs_str()
  log[#log + 1] = 'L'
  return 'a'
end

local function rhs_str()
  log[#log + 1] = 'R'
  return 'b'
end

log = {}
val = lhs_str()..rhs_str()
assertEquals(table.concat(log, ''), 'LR', 'order: ..')

-- relational
log = {}
val = (lhs_num() < rhs_num())
assertEquals(table.concat(log, ''), 'LR', 'order: <')

log = {}
val = (lhs_num() <= rhs_num())
assertEquals(table.concat(log, ''), 'LR', 'order: <=')

log = {}
val = (lhs_num() > rhs_num())
assertEquals(table.concat(log, ''), 'LR', 'order: >')

log = {}
val = (lhs_num() >= rhs_num())
assertEquals(table.concat(log, ''), 'LR', 'order: >=')

log = {}
val = (lhs_num() == rhs_num())
assertEquals(table.concat(log, ''), 'LR', 'order: ==')

log = {}
val = (lhs_num() ~= rhs_num())
assertEquals(table.concat(log, ''), 'LR', 'order: ~=')

-- bitwise
log = {}
val = lhs_num()&rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: &')

log = {}
val = lhs_num()|rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: |')

log = {}
val = lhs_num()~rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: ~ (xor)')

log = {}
val = lhs_num()<<rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: <<')

log = {}
val = lhs_num()>>rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: >>')

-- 13.2. Nested expressions and associativity
local function exp_a()
  log[#log + 1] = 'A'
  return 2
end

local function exp_b()
  log[#log + 1] = 'B'
  return 3
end

local function exp_c()
  log[#log + 1] = 'C'
  return 2
end

log = {}
val = exp_a() ^ (exp_b() ^ exp_c())
assertEquals(val, 512, 'nested expression result')
assertEquals(table.concat(log, ''), 'ABC', 'nested expression associativity')

assertEquals(2^3^2, 512, 'power associativity')

-- 14. String-to-number coercion around zero
assertEquals(1/('0') == 1/0.0, true, 'str: ("0") (+Inf)')
assertEquals(1/('-0') == 1/0.0, true, 'str: ("-0") (+Inf)')
assertEquals(1/('0.0') == 1/0.0, true, 'str: ("0.0") (+Inf)')
assertEquals(1/('-0.0') == -1/0.0, true, 'str: ("-0.0") (-Inf)')
assertEquals(1/-('0') == 1/0.0, true, 'str: unary minus -("0") (+Inf)')
assertEquals(1/-('-0') == 1/0.0, true, 'str: unary minus -("-0") (+Inf)')

-- 14.1. General arithmetic with numeric strings
assertEquals('1'+2, 3, 'str-num: "1"+2 == 3')
assertEquals(' -2 ' * '3', -6, 'str-num: " -2 " * "3" == -6')
assertEquals('0x10' + 1, 17, 'str-num: hex int string + 1 == 17')
assertEquals('0x1p4' + 0, 16, 'str-num: hex float string + 0 == 16')
assertThrows("attempt to add a 'string' with a 'number'",
  function()
    return 'x1'+1
  end
)

-- 14.2 Numeric string coercion preserves int/float kind
do
  local function mt(x)
    return math.type(x)
  end

  assertEquals(mt("0" + 0), "integer", "string '0' + 0 => integer")
  assertEquals(mt("-0" + 0), "integer", "string '-0' + 0 => integer")

  assertEquals(mt("0.0" + 0), "float", "string '0.0' + 0 => float")
  assertEquals(mt("-0.0" + 0), "float", "string '-0.0' + 0 => float")

  assertEquals(mt("0" + 0.0), "float", "string '0' + 0.0 => float")
  assertEquals(mt("0.0" + 0.0), "float", "string '0.0' + 0.0 => float")

  assertEquals(mt("0" + -0), "integer", "string '0' + -0 => integer")
  assertEquals(mt("0" + -0.0), "float", "string '0' + -0.0 => float")

  -- Regression coverage: numeric strings for all arithmetic ops and operand order
  assertEquals(mt("0" - 0), "integer", "string '0' - 0 => integer")
  assertEquals(mt("0.0" - 0), "float", "string '0.0' - 0 => float")
  assertEquals(mt("0" * 1), "integer", "string '0' * 1 => integer")
  assertEquals(mt("0.0" * 1), "float", "string '0.0' * 1 => float")
  assertEquals(mt("0" / 1), "float", "string '0' / 1 => float")
  assertEquals(mt("0.0" / 1), "float", "string '0.0' / 1 => float")
  assertEquals(mt("0" // 1), "integer", "string '0' // 1 => integer")
  assertEquals(mt("0.0" // 1), "float", "string '0.0' // 1 => float")
  assertEquals(mt("0" % 1), "integer", "string '0' % 1 => integer")
  assertEquals(mt("0.0" % 1), "float", "string '0.0' % 1 => float")
  assertEquals(mt("2" ^ 1), "float", "string '2' ^ 1 => float")
  assertEquals(mt("2.0" ^ 1), "float", "string '2.0' ^ 1 => float")

  assertEquals(mt(0 + "0"), "integer", "0 + string '0' => integer")
  assertEquals(mt(0 + "0.0"), "float", "0 + string '0.0' => float")

  assertEquals(mt(0 - "0"), "integer", "0 - string '0' => integer")
  assertEquals(mt(0 - "0.0"), "float", "0 - string '0.0' => float")

  assertEquals(mt(1 * "0"), "integer", "1 * string '0' => integer")
  assertEquals(mt(1 * "0.0"), "float", "1 * string '0.0' => float")

  assertEquals(mt(0 / "1"), "float", "0 / string '1' => float")
  assertEquals(mt(0 / "1.0"), "float", "0 / string '1.0' => float")

  assertEquals(mt(0 // "1"), "integer", "0 // string '1' => integer")
  assertEquals(mt(0 // "1.0"), "float", "0 // string '1.0' => float")

  assertEquals(mt(0 % "1"), "integer", "0 % string '1' => integer")
  assertEquals(mt(0 % "1.0"), "float", "0 % string '1.0' => float")

  assertEquals(mt(2 ^ "1"), "float", "2 ^ string '1' => float")
  assertEquals(mt(2 ^ "1.0"), "float", "2 ^ string '1.0' => float")

  assertEquals((1 // "2"), 0, "1 // '2' == 0")
  assertEquals((1 // "2.0"), 0.0, "1 // '2.0' == 0.0")
end

-- 15. Recursive function producing int zero (and unary minus)
local function rec_zero(n)
  if n == 0 then
    return 0
  end
  return -rec_zero(n - 1)
end

assertEquals(1/rec_zero(5) == 1/0, true, 'recursive: rec_zero (+Inf)')
assertEquals(1/(rec_zero(5)) == 1/0, true, 'recursive: (rec_zero) (+Inf)')
assertEquals(1/-(rec_zero(5)) == 1/0, true, 'recursive: -(rec_zero) (+Inf)')
assertEquals(1/-rec_zero(5) == 1/0, true, 'recursive: -(rec_zero) (+Inf)')

-- 16. Modulo and integer division by zero

-- 16.1. Modulo by zero
assertThrows("attempt to perform 'n%0'",
  function()
    return 1%0
  end
)

val = pcall(
  function()
    return 1.0%0.0
  end
)
assertEquals(val, true, 'float mod by zero ok (NaN)')

val = pcall(
  function()
    return 1.0%0
  end
)
assertEquals(val, true, 'mixed (float,int) mod by zero ok (NaN)')

val = pcall(
  function()
    return 1%0.0
  end
)
assertEquals(val, true, 'mixed (int,float) mod by zero ok (NaN)')

-- 16.2. Integer division by zero
assertThrows('divide by zero',
  function()
    return 1//0
  end
)

val = pcall(
  function()
    return 1.0//0.0
  end
)
assertEquals(val, true, 'float idiv by zero ok (+Inf/-Inf)')

val = pcall(
  function()
    return 1.0//0
  end
)
assertEquals(val, true, 'mixed (float,int) idiv by zero ok (+Inf/-Inf)')

val = pcall(
  function()
    return 1//0.0
  end
)
assertEquals(val, true, 'mixed (int,float) idiv by zero ok (+Inf/-Inf)')

-- 16.3. Modulo sign semantics (explicit)
assertEquals(5 % -2, -1, 'mod: 5 % -2 == -1')
assertEquals(-5 % 2, 1, 'mod: -5 % 2 == 1')
assertEquals(-5 % -2, -1, 'mod: -5 % -2 == -1')

-- 17. Metamethod precedence: __add should dispatch
local mt = {
  __add = function(_, _)
    return 'added'
  end
}
local tbl = setmetatable({}, mt)

assertEquals(tbl + tbl, 'added', '__add dispatched')

-- 17.1 Unary metamethod: __unm (negation)
local mt_unm = {
  __unm = function(_)
    return 'negated'
  end
}
local u = setmetatable({}, mt_unm)

assertEquals(-u, 'negated', '__unm dispatched')

-- 17.2 Unary metamethod: __bnot (bitwise NOT)
local bnot_calls = 0
local mt_bnot = {
  __bnot = function(_)
    bnot_calls = bnot_calls + 1
    return 123
  end
}
local b = setmetatable({}, mt_bnot)

assertEquals(~b, 123, '__bnot dispatched')
assertEquals(bnot_calls, 1, '__bnot called exactly once')

-- 17.3. Unary metamethods multi-return (first return only)
local mt_unm_mr = {
  __unm = function(_)
    return 7, 8
  end
}
local um = setmetatable({}, mt_unm_mr)

assertEquals(-um, 7, '__unm uses first return value')

local mt_bnot_mr = {
  __bnot = function(_)
    return 9, 10
  end
}
local bm = setmetatable({}, mt_bnot_mr)

assertEquals(~bm, 9, '__bnot uses first return value')

-- 18. Multi-return in arithmetic (first return only)
local function multi_ret()
  return 0, 1
end

assertEquals(1/(multi_ret()) == 1/0, true, 'multi-ret: 1st used (+Inf)')
assertEquals(1/-(multi_ret()) == 1/0, true, 'multi-ret: unary minus 1st used (+Inf)')

-- 18.1 Exponentiation zero edge cases
assertEquals(0^0 == 1, true, 'pow: 0^0 == 1')
assertEquals((-0.0)^0 == 1, true, 'pow: (-0.0)^0 == 1')

-- 19. Error tests
assertThrows('has no integer representation',
  function()
    return ~0.5
  end
)

assertThrows("attempt to add a 'string' with a 'number'",
  function()
    return 'a'+1
  end
)

assertThrows('attempt to perform arithmetic on a table value',
  function()
    return -{}
  end
)

-- 19.1. Bitwise on non-integers should error
assertThrows('has no integer representation',
  function()
    return 1.5&1
  end
)

assertThrows(
  "attempt to perform bitwise operation on a string value (constant '3')",
  function()
    return '3'|1
  end
)

assertThrows('has no integer representation',
  function()
    return 1~1.2
  end
)

assertThrows('has no integer representation',
  function()
    return 1<<0.1
  end
)

-- 19.2. Relational type error
assertThrows('attempt to compare number with string',
  function()
    return 1<'1'
  end
)

-- 19.3. Additional negative tests
assertThrows('attempt to perform arithmetic on a table value',
  function()
    return 1+{}
  end
)

assertThrows("attempt to unm a 'string' with a 'string'",
  function()
    return -'x'
  end
)

assertThrows(
  "attempt to perform bitwise operation on a string value (constant '1')",
  function()
    return ~'1'
  end
)

assertThrows('attempt to compare string with number',
  function()
    return '1'<1
  end
)

assertThrows('attempt to compare number with table',
  function()
    return 1<{}
  end
)

-- 19.4. String arithmetic: exact verb mapping
assertThrows("attempt to sub a 'string' with a 'number'",
  function()
    return 'x' - 1
  end
)

assertThrows("attempt to mul a 'string' with a 'number'",
  function()
    return 'x' * 2
  end
)

assertThrows("attempt to div a 'string' with a 'number'",
  function()
    return 'x' / 2
  end
)

assertThrows("attempt to idiv a 'string' with a 'number'",
  function()
    return 'x' // 2
  end
)

assertThrows("attempt to mod a 'string' with a 'number'",
  function()
    return 'x' % 2
  end
)

assertThrows("attempt to pow a 'string' with a 'number'",
  function()
    return 'x' ^ 2
  end
)

-- 19.4.1. String arithmetic: type pairing and string-vs-string cases
assertThrows("attempt to add a 'string' with a 'string'",
  function()
    return 'x' + 'y'
  end
)

assertThrows("attempt to mul a 'number' with a 'string'",
  function()
    return 2 * 'x'
  end
)

-- 19.5. Bitwise: exact type errors
assertThrows("attempt to perform bitwise operation on a table value",
  function()
    return {} & 1
  end
)

assertThrows("attempt to perform bitwise operation on a nil value",
  function()
    return nil | 1
  end
)

assertThrows("attempt to perform bitwise operation on a boolean value",
  function()
    return true ~ 1
  end
)

-- 19.6. Bitwise shifts: non-integer RHS
assertThrows("number has no integer representation",
  function()
    return 1 << 0.5
  end
)

assertThrows("number has no integer representation",
  function()
    return 8 >> 0.25
  end
)

-- 19.7. Concatenation: exact messages
assertThrows("attempt to concatenate a nil value",
  function()
    return nil .. "x"
  end
)

assertThrows("attempt to concatenate a nil value",
  function()
    return "x" .. nil
  end
)

assertThrows("attempt to concatenate a table value",
  function()
    return "x" .. {}
  end
)

-- 19.8. Length operator: exact message
assertThrows("attempt to get length of a number value",
  function()
    return #1
  end
)

assertThrows("attempt to get length of a nil value",
  function()
    return #nil
  end
)

-- 19.9. Relational mismatches
assertThrows("attempt to compare number with string",
  function()
    return 1 <= '1'
  end
)

assertThrows("attempt to compare number with string",
  function()
    return '1' >= 1
  end
)

assertThrows("attempt to compare table with number",
  function()
    return 1 > {}
  end
)

assertThrows("attempt to compare table with number",
  function()
    return {} < 1
  end
)

-- 19.10. Explicit int-path division/modulo by zero through expr
assertThrows("attempt to perform 'n%0'",
  function()
    return 1 % (1-1)
  end
)

assertThrows("attempt to divide by zero",
  function()
    return 1 // (1-1)
  end
)

-- 20. `tostring()` numeric formatting

-- 20.1. Integers
assertEquals(tostring(0), '0', 'tostring: 0')
assertEquals(tostring(1), '1', 'tostring: 1')
assertEquals(tostring(-5), '-5', 'tostring: -5')
assertEquals(tostring(42), '42', 'tostring: 42')
assertEquals(tostring(-123), '-123', 'tostring: -123')

-- 20.2. Float zeros (positive and negative)
assertEquals(tostring(0.0), '0.0', 'tostring: 0.0')
assertEquals(tostring(-0.0), '-0.0', 'tostring: -0.0')

-- 20.3. Integer-valued floats
assertEquals(tostring(1.0), '1.0', 'tostring: 1.0')
assertEquals(tostring(2.0), '2.0', 'tostring: 2.0')
assertEquals(tostring(-3.0), '-3.0', 'tostring: -3.0')
assertEquals(tostring(100.0), '100.0', 'tostring: 100.0')

-- 20.4. Non-integer floats
assertEquals(tostring(0.5), '0.5', 'tostring: 0.5')
assertEquals(tostring(3.14), '3.14', 'tostring: 3.14')
assertEquals(tostring(-2.5), '-2.5', 'tostring: -2.5')

-- 20.5. Special float values
assertEquals(tostring(1/0.0), 'inf', 'tostring: +inf')
assertEquals(tostring(-1/0.0), '-inf', 'tostring: -inf')
assertEquals(tostring(0.0/0.0), '-nan', 'tostring: NaN')
assertEquals(tostring(1.0%0.0), '-nan', 'tostring: NaN from modulo')
assertEquals(tostring((-1.0)%0.0), '-nan', 'tostring: NaN from neg modulo')

-- 20.6. Results from tonumber() preserve type
assertEquals(tostring(tonumber('5')), '5', 'tonumber int: "5"')
assertEquals(tostring(tonumber('5.')), '5.0', 'tonumber float: "5."')
assertEquals(tostring(tonumber('.5')), '0.5', 'tonumber float: ".5"')
assertEquals(tostring(tonumber('5.0')), '5.0', 'tonumber float: "5.0"')
assertEquals(tostring(tonumber('0x10')), '16', 'tonumber hex int: "0x10"')
assertEquals(tostring(tonumber('0x10.0')), '16.0', 'tonumber hex float: "0x10.0"')
assertEquals(tostring(tonumber('0x1p4')), '16.0', 'tonumber hex float: "0x1p4"')

-- 20.7. Arithmetic results formatting
assertEquals(tostring(1+1), '2', 'int+int yields int')
assertEquals(tostring(1.0+1.0), '2.0', 'float+float yields float')
assertEquals(tostring(1+1.0), '2.0', 'int+float yields float')
assertEquals(tostring(5/2), '2.5', 'division yields float')
assertEquals(tostring(4/2), '2.0', 'division exact yields float')

-- 21. Numeric for loops: comprehensive type/mode coverage

-- 21.1. Integer mode (all params integer-valued and untagged)

do
  local types, vals = {}, {}
  for i = 1, 3 do
    table.insert(types, math.type(i))
    table.insert(vals, tostring(i))
  end
  assertEquals(table.concat(types, ','), 'integer,integer,integer', 'for 1,3: types')
  assertEquals(table.concat(vals, ','), '1,2,3', 'for 1,3: values')
end

do
  local types = {}
  for i = 1, 3, 1 do
    table.insert(types, math.type(i))
  end
  assertEquals(types[1], 'integer', 'for 1,3,1: type')
end

do
  local vals = {}
  for i = 3, 1, -1 do
    table.insert(vals, tostring(i))
  end
  assertEquals(table.concat(vals, ','), '3,2,1', 'for 3,1,-1: descending')
end

do
  local found_zero = false
  local zero_str
  for i = -1, 1 do
    if i == 0 then
      found_zero = true
      zero_str = tostring(i)
    end
  end
  assertEquals(found_zero, true, 'for -1,1: crosses zero')
  assertEquals(zero_str, '0', 'for -1,1: zero is int')
end

do
  for i = 1, 3 do
    local zero = i - i
    assertEquals(math.type(zero), 'integer', 'for 1,3: i-i is int')
    assertEquals(tostring(zero), '0', 'for 1,3: i-i formats as int')
  end
end

do
  for i = 2, 4 do
    local expr = i * 2 - i - i
    assertEquals(math.type(expr), 'integer', 'for 2,4: int expr is int')
    assertEquals(expr, 0, 'for 2,4: int expr value')
  end
end

-- 21.2. Float mode (all params float-typed)

do
  local types, vals = {}, {}
  for i = 1.0, 3.0, 1.0 do
    table.insert(types, math.type(i))
    table.insert(vals, tostring(i))
  end
  assertEquals(table.concat(types, ','), 'float,float,float', 'for 1.0,3.0,1.0: types')
  assertEquals(table.concat(vals, ','), '1.0,2.0,3.0', 'for 1.0,3.0,1.0: values')
end

do
  local vals = {}
  for i = 0.5, 2.5, 0.5 do
    table.insert(vals, tostring(i))
  end
  assertEquals(table.concat(vals, ','), '0.5,1.0,1.5,2.0,2.5', 'for 0.5,2.5,0.5: values')
end

do
  local vals = {}
  for i = 3.0, 1.0, -1.0 do
    table.insert(vals, tostring(i))
  end
  assertEquals(table.concat(vals, ','), '3.0,2.0,1.0', 'for 3.0,1.0,-1.0: descending')
end

do
  local vals = {}
  for i = 2.5, 0.5, -0.5 do
    table.insert(vals, tostring(i))
  end
  assertEquals(table.concat(vals, ','), '2.5,2.0,1.5,1.0,0.5', 'for 2.5,0.5,-0.5: values')
end

do
  local zero_str
  for i = -1.0, 1.0 do
    if i == 0 then
      zero_str = tostring(i)
    end
  end
  assertEquals(zero_str, '0.0', 'for -1.0,1.0: zero is float')
end

do
  for i = 1.0, 3.0 do
    local zero = i - i
    assertEquals(math.type(zero), 'float', 'for 1.0,3.0: i-i is float')
    assertEquals(tostring(zero), '0.0', 'for 1.0,3.0: i-i formats as float')
  end
end

do
  for i = 2.0, 4.0 do
    local expr = i * 2.0 - i - i
    assertEquals(math.type(expr), 'float', 'for 2.0,4.0: float expr is float')
    assertEquals(tostring(expr), '0.0', 'for 2.0,4.0: float expr formats')
  end
end

-- 21.3. Mixed mode (start type determines loop var type)

do
  local types, vals = {}, {}
  for i = 1, 3.5 do
    table.insert(types, math.type(i))
    table.insert(vals, tostring(i))
  end
  assertEquals(types[1], 'integer', 'for 1,3.5: start int yields var int')
  assertEquals(types[2], 'integer', 'for 1,3.5: var stays int')
  assertEquals(table.concat(vals, ','), '1,2,3', 'for 1,3.5: int formatting')
end

do
  local types, vals = {}, {}
  for i = 1.0, 3 do
    table.insert(types, math.type(i))
    table.insert(vals, tostring(i))
  end
  assertEquals(types[1], 'float', 'for 1.0,3: start float yields var float')
  assertEquals(types[2], 'float', 'for 1.0,3: var stays float')
  assertEquals(table.concat(vals, ','), '1.0,2.0,3.0', 'for 1.0,3: float formatting')
end

do
  local types = {}
  for i = 1, 3.0 do
    table.insert(types, math.type(i))
  end
  assertEquals(types[1], 'integer', 'for 1,3.0: end=3.0 but start int yields var int')
end

do
  local types, vals = {}, {}
  for i = 1, 3, 1.0 do
    table.insert(types, math.type(i))
    table.insert(vals, tostring(i))
  end
  assertEquals(types[1], 'float', 'for 1,3,1.0: step float yields var float')
  assertEquals(table.concat(vals, ','), '1.0,2.0,3.0', 'for 1,3,1.0: float formatting')
end

do
  local types, vals = {}, {}
  for i = 1.0, 3.0, 1 do
    table.insert(types, math.type(i))
    table.insert(vals, tostring(i))
  end
  assertEquals(types[1], 'float', 'for 1.0,3.0,1: start float yields var float')
  assertEquals(table.concat(vals, ','), '1.0,2.0,3.0', 'for 1.0,3.0,1: float formatting')
end

do
  local types = {}
  for i = 3.0, 1, -1 do
    table.insert(types, math.type(i))
  end
  assertEquals(types[1], 'float', 'for 3.0,1,-1: start float yields var float')
end

-- Mixed mode: float step, integer start
do
  local types, vals = {}, {}
  for i = 1, 3, 1.0 do
    table.insert(types, math.type(i))
    table.insert(vals, tostring(i))
  end
  assertEquals(types[1], 'float', 'for 1,3,1.0: step float yields var float')
  assertEquals(table.concat(vals, ','), '1.0,2.0,3.0', 'for 1,3,1.0: float formatting')
end

-- Mixed mode: integer start, negative float step
do
  local types, vals = {}, {}
  for i = 3, 1, -1.0 do
    table.insert(types, math.type(i))
    table.insert(vals, tostring(i))
  end
  assertEquals(types[1], 'float', 'for 3,1,-1.0: step float yields var float')
  assertEquals(table.concat(vals, ','), '3.0,2.0,1.0', 'for 3,1,-1.0: float formatting')
end

do
  for i = 1, 3.5 do
    local zero = i - i
    assertEquals(math.type(zero), 'integer', 'for 1,3.5: start int yields i-i is int')
  end
end

do
  for i = 1.0, 3 do
    local zero = i - i
    assertEquals(math.type(zero), 'float', 'for 1.0,3: start float yields i-i is float')
  end
end

-- 21.4. Boundary conditions

do
  local count = 0
  for i = 5, 5 do
    count = count + 1
    assertEquals(i, 5, 'for 5,5: i=5')
  end
  assertEquals(count, 1, 'for 5,5: one iteration')
end

do
  local count = 0
  for i = 5, 3 do
    count = count + 1
  end
  assertEquals(count, 0, 'for 5,3: no iterations')
end

do
  local count = 0
  for i = 3, 5, -1 do
    count = count + 1
  end
  assertEquals(count, 0, 'for 3,5,-1: no iterations')
end

do
  local last
  for i = 1.0, 3.0 do
    last = i
  end
  assertEquals(tostring(last), '3.0', 'for 1.0,3.0: exactly reaches end')
end

do
  local last
  for i = 1, 10, 3 do
    last = i
  end
  assertEquals(last, 10, 'for 1,10,3: reaches end exactly')
end

do
  local last
  for i = 1, 9, 3 do
    last = i
  end
  assertEquals(last, 7, 'for 1,9,3: stops before end')
end

do
  local vals = {}
  for i = 0.1, 0.3, 0.1 do
    table.insert(vals, i)
  end
  assertEquals(#vals, 2, 'for 0.1,0.3,0.1: small float step iterations')
end

do
  local vals = {}
  -- 0.5 has exact binary representation
  for i = 0.5, 1.5, 0.5 do
    table.insert(vals, i)
  end
  assertEquals(#vals, 3, 'for 0.5,1.5,0.5: small float step')
end

-- 21.5. Edge cases and errors

assertThrows('step is zero', function()
  for i = 1, 10, 0 do
  end
end)

assertThrows('step is zero', function()
  for i = 1.0, 10.0, 0.0 do
  end
end)

do
  local count = 0
  for i = 1, 1000000 do
    count = count + 1
    if count > 5 then break end
  end
  assertEquals(count, 6, 'for 1,1000000: large range with break')
end

do
  local sum = 0
  for i = 1, 5, 2 do
    sum = sum + i
  end
  assertEquals(sum, 9, 'for 1,5,2: step>1 sum (1+3+5)')
end

do
  local sum = 0
  for i = 10, 1, -3 do
    sum = sum + i
  end
  assertEquals(sum, 22, 'for 10,1,-3: negative step>1 sum (10+7+4+1)')
end

-- 22. Table numeric key equivalence

-- 22.1. Integer-valued floats normalize to integers

do
  local t = {}
  t[1] = 'one'
  assertEquals(t[1.0], 'one', 'table: t[1] accessed via t[1.0]')
  assertEquals(t[1], 'one', 'table: t[1] accessed via t[1]')
end

do
  local t = {}
  t[2.0] = 'two'
  assertEquals(t[2], 'two', 'table: t[2.0] accessed via t[2]')
  assertEquals(t[2.0], 'two', 'table: t[2.0] accessed via t[2.0]')
end

do
  local t = {}
  t[1] = 'a'
  t[2.0] = 'b'
  t[3] = 'c'
  assertEquals(t[1.0], 'a', 'table: multi-key t[1.0]')
  assertEquals(t[2], 'b', 'table: multi-key t[2]')
  assertEquals(t[3.0], 'c', 'table: multi-key t[3.0]')
end

do
  local t = {}
  t[5] = 'first'
  t[5.0] = 'second'
  assertEquals(t[5], 'second', 'table: overwrite int with float')
  assertEquals(t[5.0], 'second', 'table: overwrite int with float (access)')
end

do
  local t = {}
  t[100] = 'hundred'
  assertEquals(t[100.0], 'hundred', 'table: large int t[100.0]')
end

-- 22.2. Zero normalization (both -0 and +0 map to same key)

do
  local t = {}
  t[0] = 'zero'
  assertEquals(t[-0.0], 'zero', 'table: t[0] accessed via t[-0.0]')
  assertEquals(t[0.0], 'zero', 'table: t[0] accessed via t[0.0]')
  assertEquals(t[0], 'zero', 'table: t[0] accessed via t[0]')
end

do
  local t = {}
  t[-0.0] = 'negzero'
  assertEquals(t[0], 'negzero', 'table: t[-0.0] accessed via t[0]')
  assertEquals(t[0.0], 'negzero', 'table: t[-0.0] accessed via t[0.0]')
  assertEquals(t[-0.0], 'negzero', 'table: t[-0.0] accessed via t[-0.0]')
end

do
  local t = {}
  t[0.0] = 'float_zero'
  assertEquals(t[0], 'float_zero', 'table: t[0.0] accessed via t[0]')
  assertEquals(t[-0.0], 'float_zero', 'table: t[0.0] accessed via t[-0.0]')
end

do
  local t = {}
  t[0] = 'first'
  t[-0.0] = 'second'
  t[0.0] = 'third'
  assertEquals(t[0], 'third', 'table: zero overwrite final')
  assertEquals(t[-0.0], 'third', 'table: zero overwrite via -0.0')
end

-- 22.3. Non-integer floats are distinct keys

do
  local t = {}
  t[1] = 'int_one'
  t[1.5] = 'one_point_five'
  assertEquals(t[1], 'int_one', 'table: non-int float t[1]')
  assertEquals(t[1.5], 'one_point_five', 'table: non-int float t[1.5]')
  assertEquals(t[1.0], 'int_one', 'table: non-int float t[1.0] maps to int')
end

do
  local t = {}
  t[0.5] = 'half'
  t[1.5] = 'one_half'
  t[2.5] = 'two_half'
  assertEquals(t[0.5], 'half', 'table: multi non-int t[0.5]')
  assertEquals(t[1.5], 'one_half', 'table: multi non-int t[1.5]')
  assertEquals(t[2.5], 'two_half', 'table: multi non-int t[2.5]')
end

-- 22.4. Mixed integer and float keys

do
  local t = {}
  t[0] = 'zero'
  t[1] = 'one'
  t[1.5] = 'one_point_five'
  t[2.0] = 'two'
  t[-0.0] = 'neg_zero'

  assertEquals(t[0], 'neg_zero', 'table: mixed t[0] (last zero)')
  assertEquals(t[0.0], 'neg_zero', 'table: mixed t[0.0]')
  assertEquals(t[-0.0], 'neg_zero', 'table: mixed t[-0.0]')
  assertEquals(t[1], 'one', 'table: mixed t[1]')
  assertEquals(t[1.0], 'one', 'table: mixed t[1.0]')
  assertEquals(t[1.5], 'one_point_five', 'table: mixed t[1.5]')
  assertEquals(t[2], 'two', 'table: mixed t[2]')
  assertEquals(t[2.0], 'two', 'table: mixed t[2.0]')
end

-- 22.5. Key equivalence with expressions

do
  local t = {}
  t[1+1] = 'two'
  assertEquals(t[2.0], 'two', 'table: expr key t[1+1] via t[2.0]')
  assertEquals(t[4/2], 'two', 'table: expr key t[4/2]')
end

do
  local t = {}
  t[1-1] = 'int_zero'
  assertEquals(t[0], 'int_zero', 'table: expr t[1-1] via t[0]')
  assertEquals(t[0.0], 'int_zero', 'table: expr t[1-1] via t[0.0]')
  assertEquals(t[-0.0], 'int_zero', 'table: expr t[1-1] via t[-0.0]')
end

do
  local t = {}
  t[1.0-1.0] = 'float_zero'
  assertEquals(t[0], 'float_zero', 'table: expr t[1.0-1.0] via t[0]')
  assertEquals(t[-0.0], 'float_zero', 'table: expr t[1.0-1.0] via t[-0.0]')
end

-- 22.6. Key equivalence with variables

do
  local t = {}
  local i = 1
  local f = 1.0
  t[i] = 'from_int'
  assertEquals(t[f], 'from_int', 'table: var int key via float var')
end

do
  local t = {}
  local i = 1
  local f = 1.0
  t[f] = 'from_float'
  assertEquals(t[i], 'from_float', 'table: var float key via int var')
end

do
  local t = {}
  local zi = 0
  local zf = 0.0
  local zfn = -0.0
  t[zi] = 'int_zero'
  assertEquals(t[zf], 'int_zero', 'table: var zi via zf')
  assertEquals(t[zfn], 'int_zero', 'table: var zi via zfn')
end

-- 22.7. Key equivalence in array part

do
  local t = {10, 20, 30}
  assertEquals(t[1.0], 10, 'table: array t[1.0]')
  assertEquals(t[2.0], 20, 'table: array t[2.0]')
  assertEquals(t[3.0], 30, 'table: array t[3.0]')
end

do
  local t = {}
  t[1.0] = 'first'
  t[2.0] = 'second'
  assertEquals(t[1], 'first', 'table: array assign t[1.0] via t[1]')
  assertEquals(t[2], 'second', 'table: array assign t[2.0] via t[2]')
end

-- 22.8. Key counting and iteration

do
  local t = {}
  t[1] = 'a'
  t[1.0] = 'b'
  t[2] = 'c'
  t[2.0] = 'd'

  local count = 0
  for k, v in pairs(t) do
    count = count + 1
  end
  assertEquals(count, 2, 'table: normalized keys count as one')
end

do
  local t = {}
  t[0] = 'a'
  t[0.0] = 'b'
  t[-0.0] = 'c'

  local count = 0
  for k, v in pairs(t) do
    count = count + 1
  end
  assertEquals(count, 1, 'table: all zeros count as one key')
end

-- 23. Type introspection: type() and math.type()

-- 23.1. Basic type() function (returns general Lua types)

assertEquals(type(nil), 'nil', 'type(nil)')
assertEquals(type(true), 'boolean', 'type(true)')
assertEquals(type(false), 'boolean', 'type(false)')
assertEquals(type(0), 'number', 'type(0) is number')
assertEquals(type(1), 'number', 'type(1) is number')
assertEquals(type(0.0), 'number', 'type(0.0) is number')
assertEquals(type(1.0), 'number', 'type(1.0) is number')
assertEquals(type(-0.0), 'number', 'type(-0.0) is number')
assertEquals(type(1.5), 'number', 'type(1.5) is number')
assertEquals(type(1/0.0), 'number', 'type(inf) is number')
assertEquals(type(0.0/0.0), 'number', 'type(nan) is number')
assertEquals(type('hello'), 'string', 'type(string)')
assertEquals(type({}), 'table', 'type(table)')
assertEquals(type(function() end), 'function', 'type(function)')

-- 23.2. math.type() function (distinguishes integer vs float)

-- Integers
assertEquals(math.type(0), 'integer', 'math.type(0)')
assertEquals(math.type(1), 'integer', 'math.type(1)')
assertEquals(math.type(-1), 'integer', 'math.type(-1)')
assertEquals(math.type(42), 'integer', 'math.type(42)')
assertEquals(math.type(-123), 'integer', 'math.type(-123)')
assertEquals(math.type(1000000), 'integer', 'math.type(1000000)')

-- Floats (literals with decimal point)
assertEquals(math.type(0.0), 'float', 'math.type(0.0)')
assertEquals(math.type(-0.0), 'float', 'math.type(-0.0)')
assertEquals(math.type(1.0), 'float', 'math.type(1.0)')
assertEquals(math.type(2.0), 'float', 'math.type(2.0)')
assertEquals(math.type(-3.0), 'float', 'math.type(-3.0)')
assertEquals(math.type(100.0), 'float', 'math.type(100.0)')

-- Floats (non-integer values)
assertEquals(math.type(0.5), 'float', 'math.type(0.5)')
assertEquals(math.type(1.5), 'float', 'math.type(1.5)')
assertEquals(math.type(3.14), 'float', 'math.type(3.14)')
assertEquals(math.type(-2.5), 'float', 'math.type(-2.5)')

-- Special float values
assertEquals(math.type(1/0.0), 'float', 'math.type(inf)')
assertEquals(math.type(-1/0.0), 'float', 'math.type(-inf)')
assertEquals(math.type(0.0/0.0), 'float', 'math.type(nan)')

-- Non-numbers return nil
assertEquals(math.type(nil), nil, 'math.type(nil) yields nil')
assertEquals(math.type(true), nil, 'math.type(boolean) yields nil')
assertEquals(math.type('123'), nil, 'math.type(string) yields nil')
assertEquals(math.type({}), nil, 'math.type(table) yields nil')
assertEquals(math.type(function() end), nil, 'math.type(function) yields nil')

-- 23.3. Integer arithmetic preserves integer type

do
  local a = 1
  local b = 2
  assertEquals(math.type(a + b), 'integer', 'int + int yields integer')
  assertEquals(math.type(a - b), 'integer', 'int - int yields integer')
  assertEquals(math.type(a * b), 'integer', 'int * int yields integer')
  assertEquals(math.type(a // b), 'integer', 'int // int yields integer')
  assertEquals(math.type(a % b), 'integer', 'int % int yields integer')
end

-- Integer operations producing zero
do
  assertEquals(math.type(1 - 1), 'integer', '1 - 1 yields integer zero')
  assertEquals(math.type(0 * 5), 'integer', '0 * 5 yields integer zero')
  assertEquals(math.type(0 % 1), 'integer', '0 % 1 yields integer zero')
  assertEquals(math.type(0 // 1), 'integer', '0 // 1 yields integer zero')
end

-- 23.4. Float arithmetic preserves float type

do
  local a = 1.0
  local b = 2.0
  assertEquals(math.type(a + b), 'float', 'float + float yields float')
  assertEquals(math.type(a - b), 'float', 'float - float yields float')
  assertEquals(math.type(a * b), 'float', 'float * float yields float')
  assertEquals(math.type(a / b), 'float', 'float / float yields float')
  assertEquals(math.type(a // b), 'float', 'float // float yields float')
  assertEquals(math.type(a % b), 'float', 'float % float yields float')
end

-- Float operations producing zero
do
  assertEquals(math.type(1.0 - 1.0), 'float', '1.0 - 1.0 yields float zero')
  assertEquals(math.type(0.0 * 5.0), 'float', '0.0 * 5.0 yields float zero')
  assertEquals(math.type(0.0 % 1.0), 'float', '0.0 % 1.0 yields float zero')
  assertEquals(math.type(0.0 // 1.0), 'float', '0.0 // 1.0 yields float zero')
end

-- 23.5. Mixed arithmetic promotes to float

do
  assertEquals(math.type(1 + 1.0), 'float', 'int + float yields float')
  assertEquals(math.type(1.0 + 1), 'float', 'float + int yields float')
  assertEquals(math.type(2 * 1.5), 'float', 'int * float yields float')
  assertEquals(math.type(3.0 - 1), 'float', 'float - int yields float')
  assertEquals(math.type(5 // 2.0), 'float', 'int // float yields float')
  assertEquals(math.type(5.0 % 2), 'float', 'float % int yields float')
end

-- Mixed operations producing zero
do
  assertEquals(math.type(1 - 1.0), 'float', '1 - 1.0 yields float zero')
  assertEquals(math.type(0 * 1.0), 'float', '0 * 1.0 yields float zero')
  assertEquals(math.type(0.0 * 1), 'float', '0.0 * 1 yields float zero')
end

-- 23.6. Division and power always produce floats

do
  assertEquals(math.type(4 / 2), 'float', '4 / 2 yields float (2.0)')
  assertEquals(math.type(5 / 2), 'float', '5 / 2 yields float (2.5)')
  assertEquals(math.type(1 / 1), 'float', '1 / 1 yields float (1.0)')

  assertEquals(math.type(2 ^ 3), 'float', '2 ^ 3 yields float (8.0)')
  assertEquals(math.type(2 ^ 0), 'float', '2 ^ 0 yields float (1.0)')
  assertEquals(math.type(10 ^ 2), 'float', '10 ^ 2 yields float (100.0)')
end

-- Division producing zero
do
  assertEquals(math.type(0 / 1), 'float', '0 / 1 yields float zero')
  assertEquals(math.type(0.0 / 1.0), 'float', '0.0 / 1.0 yields float zero')
end

-- 23.7. Unary minus preserves type

do
  assertEquals(math.type(-5), 'integer', '-5 is integer')
  assertEquals(math.type(-0), 'integer', '-0 is integer')

  assertEquals(math.type(-5.0), 'float', '-5.0 is float')
  assertEquals(math.type(-0.0), 'float', '-0.0 is float')
end

-- Unary minus on variables
do
  local i = 5
  local f = 5.0
  assertEquals(math.type(-i), 'integer', 'unary minus int var')
  assertEquals(math.type(-f), 'float', 'unary minus float var')
end

-- Unary minus on expressions
do
  assertEquals(math.type(-(1 + 1)), 'integer', 'unary minus int expr')
  assertEquals(math.type(-(1.0 + 1.0)), 'float', 'unary minus float expr')
end

-- 23.8. String coercion preserves type (tonumber)

do
  assertEquals(math.type(tonumber('5')), 'integer', 'tonumber("5") yields integer')
  assertEquals(math.type(tonumber('5.')), 'float', 'tonumber("5.") yields float')
  assertEquals(math.type(tonumber('.5')), 'float', 'tonumber(".5") yields float')
  assertEquals(math.type(tonumber('5.0')), 'float', 'tonumber("5.0") yields float')
  assertEquals(math.type(tonumber('-0')), 'integer', 'tonumber("-0") yields integer')
  assertEquals(math.type(tonumber('-0.0')), 'float', 'tonumber("-0.0") yields float')
end

-- Hexadecimal literals
do
  assertEquals(math.type(tonumber('0x10')), 'integer', 'tonumber("0x10") yields integer')
  assertEquals(math.type(tonumber('0x10.0')), 'float', 'tonumber("0x10.0") yields float')
  assertEquals(math.type(tonumber('0x1p4')), 'float', 'tonumber("0x1p4") yields float')
end

-- 23.9. Variables preserve type through assignment

do
  local i = 5
  local f = 5.0

  assertEquals(math.type(i), 'integer', 'int variable')
  assertEquals(math.type(f), 'float', 'float variable')

  local i2 = i
  local f2 = f

  assertEquals(math.type(i2), 'integer', 'int variable copy')
  assertEquals(math.type(f2), 'float', 'float variable copy')
end

-- 23.10. Function returns preserve type

do
  local function ret_int()
    return 42
  end

  local function ret_float()
    return 42.0
  end

  assertEquals(math.type(ret_int()), 'integer', 'function returns integer')
  assertEquals(math.type(ret_float()), 'float', 'function returns float')
end

-- 23.11. Table values preserve type

do
  local t = {
    i = 10,
    f = 10.0,
    [1] = 20,
    [2] = 20.0
  }

  assertEquals(math.type(t.i), 'integer', 'table int value (string key)')
  assertEquals(math.type(t.f), 'float', 'table float value (string key)')
  assertEquals(math.type(t[1]), 'integer', 'table int value (int key)')
  assertEquals(math.type(t[2]), 'float', 'table float value (int key)')
end

-- 23.12. For-loop variable type tracking

do
  -- Integer loop
  for i = 1, 3 do
    assertEquals(math.type(i), 'integer', 'for 1,3: var is integer')
    break  -- Just test first iteration
  end

  -- Float loop (start is float)
  for i = 1.0, 3 do
    assertEquals(math.type(i), 'float', 'for 1.0,3: var is float')
    break
  end

  -- Float loop (step is float)
  for i = 1, 3, 1.0 do
    assertEquals(math.type(i), 'float', 'for 1,3,1.0: var is float')
    break
  end
end

-- 23.13. Bitwise operations require integers (input & output)

do
  assertEquals(math.type(5 & 3), 'integer', 'bitwise and yields integer')
  assertEquals(math.type(5 | 2), 'integer', 'bitwise or yields integer')
  assertEquals(math.type(5 ~ 1), 'integer', 'bitwise xor yields integer')
  assertEquals(math.type(1 << 5), 'integer', 'left shift yields integer')
  assertEquals(math.type(32 >> 2), 'integer', 'right shift yields integer')
  assertEquals(math.type(~0), 'integer', 'bitwise not yields integer')
end

-- Bitwise operations convert float operands to integer
do
  assertEquals(math.type(5.0 & 3), 'integer', 'float & int yields integer')
  assertEquals(math.type(5 | 2.0), 'integer', 'int | float yields integer')
  assertEquals(math.type(5.0 ~ 1.0), 'integer', 'float ~ float yields integer')
end

-- 23.14. Edge cases: zero types

do
  -- Positive integer zero
  assertEquals(math.type(0), 'integer', '0 is integer')
  assertEquals(math.type(1 - 1), 'integer', '1 - 1 is integer')
  assertEquals(math.type(0 * 1), 'integer', '0 * 1 is integer')

  -- Positive float zero
  assertEquals(math.type(0.0), 'float', '0.0 is float')
  assertEquals(math.type(1.0 - 1.0), 'float', '1.0 - 1.0 is float')

  -- Negative float zero
  assertEquals(math.type(-0.0), 'float', '-0.0 is float')
  assertEquals(math.type(0.0 * -1.0), 'float', '0.0 * -1.0 is float')

  -- Integer operations never produce -0
  assertEquals(math.type(0 * -1), 'integer', '0 * -1 is integer (not -0)')
end

-- 23.15. Special float values

do
  local inf = 1.0 / 0.0
  local neginf = -1.0 / 0.0
  local nan = 0.0 / 0.0

  assertEquals(type(inf), 'number', 'inf has type number')
  assertEquals(type(neginf), 'number', '-inf has type number')
  assertEquals(type(nan), 'number', 'nan has type number')

  assertEquals(math.type(inf), 'float', 'inf has math.type float')
  assertEquals(math.type(neginf), 'float', '-inf has math.type float')
  assertEquals(math.type(nan), 'float', 'nan has math.type float')
end

-- 23.16. Type consistency across operations

do
  local i = 10
  local sum_i = 0

  for n = 1, 5 do
    sum_i = sum_i + n
    assertEquals(math.type(sum_i), 'integer', 'integer sum stays integer')
  end

  local f = 10.0
  local sum_f = 0.0

  for n = 1.0, 5.0 do
    sum_f = sum_f + n
    assertEquals(math.type(sum_f), 'float', 'float sum stays float')
  end
end


================================================
FILE: client/space_lua/ast.ts
================================================
type ASTContext = {
  ctx: ASTCtx;
};

export type NumericType = "int" | "float";

export type ASTCtx = {
  from?: number;
  to?: number;
} & Record<string, any>;

export type LuaBlock = {
  type: "Block";
  statements: LuaStatement[];
  hasLabel?: boolean;
  hasGoto?: boolean;
  // the first duplicated label is recorded here for later eval
  dupLabelError?: { name: string; ctx: ASTCtx };
  // this block declares top-level locals and therefore needs a new env
  needsEnv?: boolean;
  // true if this block itself (not nested ones) defines at least one label
  hasLabelHere?: boolean;
  // true if this block itself (not nested ones) may create to-be-closed values
  hasCloseHere?: boolean;
  // true if this block's subtree contains any function definition
  hasFunctionDef?: boolean;
} & ASTContext;

// STATEMENTS
export type LuaReturnStatement = {
  type: "Return";
  expressions: LuaExpression[];
} & ASTContext;

export type LuaStatement =
  | LuaSemicolonStatement
  | LuaLabelStatement
  | LuaBreakStatement
  | LuaGotoStatement
  | LuaReturnStatement
  | LuaBlock
  | LuaWhileStatement
  | LuaRepeatStatement
  | LuaIfStatement
  | LuaForStatement
  | LuaForInStatement
  | LuaFunctionStatement
  | LuaLocalFunctionStatement
  | LuaAssignmentStatement
  | LuaLocalStatement
  | LuaFunctionCallStatement;

export type LuaSemicolonStatement = {
  type: "Semicolon";
} & ASTContext;

export type LuaLabelStatement = {
  type: "Label";
  name: string;
} & ASTContext;

export type LuaBreakStatement = {
  type: "Break";
} & ASTContext;

export type LuaGotoStatement = {
  type: "Goto";
  name: string;
} & ASTContext;

export type LuaWhileStatement = {
  type: "While";
  condition: LuaExpression;
  block: LuaBlock;
} & ASTContext;

export type LuaRepeatStatement = {
  type: "Repeat";
  block: LuaBlock;
  condition: LuaExpression;
} & ASTContext;

export type LuaIfStatement = {
  type: "If";
  conditions: { condition: LuaExpression; block: LuaBlock }[];
  elseBlock?: LuaBlock;
} & ASTContext;

export type LuaForStatement = {
  type: "For";
  name: string;
  start: LuaExpression;
  end: LuaExpression;
  step?: LuaExpression;
  block: LuaBlock;
  // function definition in the block captures the loop variable
  capturesLoopVar?: boolean;
} & ASTContext;

export type LuaForInStatement = {
  type: "ForIn";
  names: string[];
  expressions: LuaExpression[];
  block: LuaBlock;
  // function definition in the block captures any loop variable
  capturesLoopVar?: boolean;
} & ASTContext;

export type LuaFunctionStatement = {
  type: "Function";
  name: LuaFunctionName;
  body: LuaFunctionBody;
} & ASTContext;

export type LuaLocalFunctionStatement = {
  type: "LocalFunction";
  name: string;
  body: LuaFunctionBody;
} & ASTContext;

export type LuaFunctionName = {
  type: "FunctionName";
  propNames: string[];
  colonName?: string;
} & ASTContext;

export type LuaFunctionBody = {
  type: "FunctionBody";
  parameters: string[];
  block: LuaBlock;
} & ASTContext;

export type LuaAssignmentStatement = {
  type: "Assignment";
  variables: LuaLValue[];
  expressions: LuaExpression[];
} & ASTContext;

export type LuaLValue =
  | LuaVariable
  | LuaPropertyAccessExpression
  | LuaTableAccessExpression;

export type LuaLocalStatement = {
  type: "Local";
  names: LuaAttName[];
  expressions?: LuaExpression[];
} & ASTContext;

export enum LuaAttribute {
  Const = "const",
  Close = "close",
}

export type LuaAttName = {
  type: "AttName";
  name: string;
  attribute?: string;
  attributes?: LuaAttribute[];
} & ASTContext;

export type LuaFunctionCallStatement = {
  type: "FunctionCallStatement";
  call: LuaFunctionCallExpression;
} & ASTContext;

// EXPRESSIONS
export type LuaExpression =
  | LuaNilLiteral
  | LuaBooleanLiteral
  | LuaNumberLiteral
  | LuaStringLiteral
  | LuaPrefixExpression
  | LuaBinaryExpression
  | LuaUnaryExpression
  | LuaTableConstructor
  | LuaFunctionDefinition
  | LuaQueryExpression;

export type LuaNilLiteral = {
  type: "Nil";
} & ASTContext;

export type LuaBooleanLiteral = {
  type: "Boolean";
  value: boolean;
} & ASTContext;

export type LuaNumberLiteral = {
  type: "Number";
  value: number;
  numericType: NumericType;
} & ASTContext;

export type LuaStringLiteral = {
  type: "String";
  value: string;
} & ASTContext;

export type LuaPrefixExpression =
  | LuaVariableExpression
  | LuaParenthesizedExpression
  | LuaFunctionCallExpression;

export type LuaParenthesizedExpression = {
  type: "Parenthesized";
  expression: LuaExpression;
} & ASTContext;

export type LuaVariableExpression =
  | LuaVariable
  | LuaPropertyAccessExpression
  | LuaTableAccessExpression;

export type LuaVariable = {
  type: "Variable";
  name: string;
} & ASTContext;

export type LuaPropertyAccessExpression = {
  type: "PropertyAccess";
  object: LuaPrefixExpression;
  property: string;
} & ASTContext;

export type LuaTableAccessExpression = {
  type: "TableAccess";
  object: LuaPrefixExpression;
  key: LuaExpression;
} & ASTContext;

export type LuaFunctionCallExpression = {
  type: "FunctionCall";
  prefix: LuaPrefixExpression;
  name?: string;
  args: LuaExpression[];
} & ASTContext;

export type LuaBinaryExpression = {
  type: "Binary";
  operator: string;
  left: LuaExpression;
  right: LuaExpression;
} & ASTContext;

export type LuaUnaryExpression = {
  type: "Unary";
  operator: string;
  argument: LuaExpression;
} & ASTContext;

export type LuaTableConstructor = {
  type: "TableConstructor";
  fields: LuaTableField[];
} & ASTContext;

export type LuaTableField =
  | LuaDynamicField
  | LuaPropField
  | LuaExpressionField;

export type LuaDynamicField = {
  type: "DynamicField";
  key: LuaExpression;
  value: LuaExpression;
} & ASTContext;

export type LuaPropField = {
  type: "PropField";
  key: string;
  value: LuaExpression;
} & ASTContext;

export type LuaExpressionField = {
  type: "ExpressionField";
  value: LuaExpression;
} & ASTContext;

export type LuaFunctionDefinition = {
  type: "FunctionDefinition";
  body: LuaFunctionBody;
} & ASTContext;

// Query stuff
export type LuaQueryExpression = {
  type: "Query";
  clauses: LuaQueryClause[];
} & ASTContext;

export type LuaQueryClause =
  | LuaFromClause
  | LuaWhereClause
  | LuaLimitClause
  | LuaOrderByClause
  | LuaSelectClause;

export type LuaFromClause = {
  type: "From";
  name?: string;
  expression: LuaExpression;
} & ASTContext;

export type LuaWhereClause = {
  type: "Where";
  expression: LuaExpression;
} & ASTContext;

export type LuaLimitClause = {
  type: "Limit";
  limit: LuaExpression;
  offset?: LuaExpression;
} & ASTContext;

export type LuaOrderByClause = {
  type: "OrderBy";
  orderBy: LuaOrderBy[];
} & ASTContext;

export type LuaOrderBy = {
  type: "Order";
  expression: LuaExpression;
  direction: "asc" | "desc";
} & ASTContext;

export type LuaSelectClause = {
  type: "Select";
  expression: LuaExpression;
} & ASTContext;


================================================
FILE: client/space_lua/ast_narrow.ts
================================================
// Typed narrowers for AST nodes to avoid `(node as any)` in consumers.

import type { ASTCtx, LuaExpression, LuaLValue, LuaStatement } from "./ast.ts";

// Extract by `type` discriminant
type NarrowByType<U, K extends U extends { type: infer T } ? T : never> =
  Extract<U, { type: K }>;

// Expressions
export const asStringExpr = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "String">;
export const asNumberExpr = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "Number">;
export const asBooleanExpr = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "Boolean">;
export const asNilExpr = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "Nil">;
export const asUnary = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "Unary">;
export const asBinary = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "Binary">;
export const asVariable = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "Variable">;
export const asFunctionCall = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "FunctionCall">;
export const asTableAccess = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "TableAccess">;
export const asPropertyAccess = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "PropertyAccess">;
export const asParenthesized = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "Parenthesized">;
export const asTableConstructor = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "TableConstructor">;
export const asFunctionDef = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "FunctionDefinition">;
export const asQueryExpr = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "Query">;

// L-values
export const asLValueVariable = (l: LuaLValue) =>
  l as Extract<LuaLValue, { type: "Variable" }>;
export const asLValueTableAccess = (l: LuaLValue) =>
  l as Extract<LuaLValue, { type: "TableAccess" }>;
export const asLValuePropertyAccess = (l: LuaLValue) =>
  l as Extract<LuaLValue, { type: "PropertyAccess" }>;

// Statements
export const asAssignment = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Assignment">;
export const asLocal = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Local">;
export const asBlock = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Block">;
export const asIf = (s: LuaStatement) => s as NarrowByType<LuaStatement, "If">;
export const asWhile = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "While">;
export const asRepeat = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Repeat">;
export const asBreak = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Break">;
export const asFunctionStmt = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Function">;
export const asLocalFunction = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "LocalFunction">;
export const asFunctionCallStmt = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "FunctionCallStatement">;
export const asReturn = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Return">;
export const asFor = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "For">;
export const asForIn = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "ForIn">;
export const asLabel = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Label">;
export const asGoto = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Goto">;
export const asSemicolon = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Semicolon">;

// Pull ctx with a single shape
export const ctxOf = (node: { ctx: ASTCtx }): ASTCtx => node.ctx;


================================================
FILE: client/space_lua/break_async.test.ts
================================================
import { assertEquals } from "@std/assert/equals";
import { parse } from "./parse.ts";
import { evalStatement } from "./eval.ts";
import { LuaEnv, LuaNativeJSFunction, LuaStackFrame } from "./runtime.ts";
import { luaBuildStandardEnv } from "./stdlib.ts";

async function evalBlock(code: string, env?: LuaEnv) {
  const ast = parse(code);
  // Use the same global env for both the base env's parent and the StackFrame's _GLOBAL
  const G = luaBuildStandardEnv();
  const base = env ?? new LuaEnv(G);
  const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx);
  const r = evalStatement(ast, base, sf, false);
  if (r instanceof Promise) await r;
  return base;
}

Deno.test("For loops: inner break", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());
  await evalBlock(
    `
    local fruits = {"apple", "banana", "cherry"}
    local result = {}
    found = 0
    for index, fruit in ipairs(fruits) do
      table.insert(result, fruit)
      found = found + 1
      break
    end
    `,
    env,
  );
  assertEquals(env.get("found"), 1);
});

Deno.test("Nested for loops: inner break (sync body)", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());
  await evalBlock(
    `
      local t = {1,2,3}
      found = 0
      for i = 1, #t do
        for j = i + 1, #t do
          found = found + 1
          break
            error("BUG! Execution continued after break!")
        end
      end
    `,
    env,
  );
  assertEquals(env.get("found"), 2);
});

Deno.test("Nested for loops: inner break with async call in body (Promise path)", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());
  env.set(
    "asyncOne",
    new LuaNativeJSFunction(() => Promise.resolve(1)),
  );
  await evalBlock(
    `
      local t = {1,2,3}
      found = 0
      for i = 1, #t do
        for j = i + 1, #t do
          asyncOne()         -- forces Promise in body
          found = found + 1  -- still must execute before break
          break              -- breaks only inner loop
          error("BUG! Execution continued after break!")
        end
      end
    `,
    env,
  );
  assertEquals(env.get("found"), 2);
});

Deno.test("While: Promise condition, break inside body", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());
  // asyncTrue returns a truthy value via Promise to trigger Promise-based while path
  env.set(
    "asyncTrue",
    new LuaNativeJSFunction(() => Promise.resolve(1)),
  );
  await evalBlock(
    `
      cnt = 0
      local first = true
      local function cond()
        if first then
          first = false
          return asyncTrue()
        end
        return nil
      end
      while cond() do
        cnt = cnt + 1
        break
        error("BUG! Execution continued after break!")
      end
    `,
    env,
  );
  assertEquals(env.get("cnt"), 1);
});

Deno.test("Repeat-until: body goes Promise once, break handled locally", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());
  env.set(
    "asyncOne",
    new LuaNativeJSFunction(() => Promise.resolve(1)),
  );
  await evalBlock(
    `
      n = 0
      repeat
        asyncOne() -- body Promise
        n = n + 1
        break
        error("BUG! Execution continued after break!")
      until true
    `,
    env,
  );
  assertEquals(env.get("n"), 1);
});

Deno.test("For-in: custom iterator, Promise in body before break", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());
  env.set(
    "asyncOne",
    new LuaNativeJSFunction(() => Promise.resolve(1)),
  );
  await evalBlock(
    `
      function gen(n)
        local i = 0
        return function()
          i = i + 1
          if i <= n then return i end
        end
      end

      hits = 0
      for v in gen(3) do
        asyncOne() -- force Promise during body
        hits = hits + 1
        break
        error("BUG! Execution continued after break!")
      end
    `,
    env,
  );
  assertEquals(env.get("hits"), 1);
});

Deno.test("Jaro-like inner-window matching: inner break with occasional Promise", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());
  env.set(
    "tick",
    new LuaNativeJSFunction(() => Promise.resolve(true)),
  );
  await evalBlock(
    `
      local s1 = "#aa"
      local s2 = "#ab"
      local l1, l2 = #s1, #s2
      local md = (l1 > l2 and l1 or l2) // 2 - 1
      if md < 0 then md = 0 end

      s1m = {}
      s2m = {}
      mc  = 0

      for i = 1, l1 do
        local start  = (1 > i - md) and 1 or (i - md)
        local finish = ((i + md) < l2) and (i + md) or l2

        for j = start, finish do
          tick() -- Promise in inner loop body to exercise Promise path
          if not s2m[j] and s1:byte(i) == s2:byte(j) then
            s1m[i] = true
            s2m[j] = true
            mc = mc + 1
            break
            error("BUG! Execution continued after break!")
          end
        end
      end

      ok = (mc >= 1)
    `,
    env,
  );
  assertEquals(env.get("ok"), true);
});


================================================
FILE: client/space_lua/close_attribute.test.ts
================================================
import {
  assertEquals,
  assertInstanceOf,
  assertStringIncludes,
} from "@std/assert";
import { isPromise } from "./rp.ts";
import { parse } from "./parse.ts";
import { evalStatement } from "./eval.ts";
import { LuaEnv, LuaRuntimeError, LuaStackFrame } from "./runtime.ts";
import { luaBuildStandardEnv } from "./stdlib.ts";

async function evalBlock(code: string, env?: LuaEnv): Promise<LuaEnv> {
  const ast = parse(code);
  const G = luaBuildStandardEnv();
  const base = env ?? new LuaEnv(G);
  const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx);
  const r = evalStatement(ast, base, sf, false);

  if (isPromise(r)) {
    await r;
  }
  return base;
}

async function runAndCatch(code: string, ref = "close_attribute.lua") {
  const ast = parse(code, { ref });
  const G = luaBuildStandardEnv();
  const env = new LuaEnv(G);
  const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx);

  try {
    const r = evalStatement(ast, env, sf, false);
    if (isPromise(r)) {
      await r;
    }
    throw new Error("Expected error but evaluation succeeded");
  } catch (e: unknown) {
    return { e, code, ref };
  }
}

async function runAndCatchEnv(code: string, ref = "close_attribute.lua") {
  const ast = parse(code, { ref });
  const G = luaBuildStandardEnv();
  const env = new LuaEnv(G);
  const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx);
  let err: unknown = null;

  try {
    const r = evalStatement(ast, env, sf, false);
    if (isPromise(r)) {
      await r;
    }
  } catch (e: unknown) {
    err = e;
  }

  return { err, env };
}

// 1. parsing and static validation

Deno.test("close: parse ok", () => {
  parse(
    `
    local x<close> = {}
  `,
    { ref: "close_parse.lua" },
  );
});

Deno.test("close: parse ok without init", () => {
  parse(
    `
    do
      local x<close>
    end
  `,
    { ref: "close_no_init.lua" },
  );
});

Deno.test("close: local list only one", () => {
  try {
    parse(
      `
      do
        local a<close>, b<close> = {}, {}
      end
    `,
      { ref: "multi_close.lua" },
    );
    throw new Error("Expected parse error");
  } catch (e) {
    assertStringIncludes(String((e as any)?.message ?? e), "<close>");
    assertStringIncludes(String((e as any)?.message ?? e), "local list");
  }
});

Deno.test("close: <const> parse ok", () => {
  parse(
    `
    do
      local x<const> = 1
    end
  `,
    { ref: "const_parse.lua" },
  );
});

Deno.test("close: <const> and <close> parse ok", () => {
  parse(
    `
    do
      local x<const> = 1
      local y<close> = setmetatable({}, {
        __close = function()
        end
      })
    end
  `,
    { ref: "const_and_close_parse.lua" },
  );
});

Deno.test("close: invalid goto into scope", () => {
  try {
    parse(
      `
      do
        goto L
        local x<close> = setmetatable({}, {
          __close = function()
          end
        })
        ::L::
      end
    `,
      { ref: "goto_into_scope.lua" },
    );
    throw new Error("Expected parse error");
  } catch (e) {
    assertStringIncludes(String((e as any)?.message ?? e), "goto");
  }
});

// 2. basic scope exit

Deno.test("close: nil ignored; false is non-closable", async () => {
  const { e, ref, code } = await runAndCatch(
    `
    do
      local x<close> = nil
      local y<close> = false
    end
  `,
    "close_nil_false.lua",
  );

  assertStringIncludes(ref, "close_nil_false.lua");
  assertStringIncludes(code, "local y<close> = false");

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: normal close gets nil error", async () => {
  const env = await evalBlock(`
    t = {}

    do
      local x<close> = setmetatable({"X"}, {
        __close = function(self, err)
          if err == nil then
            table.insert(t, "nil")
          else
            table.insert(t, "not-nil")
          end
        end
      })
    end
  `);

  assertEquals((env.get("t") as any).get(1), "nil");
});

Deno.test("close: close order", async () => {
  const env = await evalBlock(`
    t = {}

    do
      local a<close> = setmetatable({1}, {
        __close = function(self)
          table.insert(t, self[1])
        end
      })

      local b<close> = setmetatable({2}, {
        __close = function(self)
          table.insert(t, self[1])
        end
      })
    end
  `);

  assertEquals((env.get("t") as any).get(1), 2);
  assertEquals((env.get("t") as any).get(2), 1);
});

Deno.test("close: shadowed variables close independently", async () => {
  const env = await evalBlock(`
    t = {}

    do
      local x<close> = setmetatable({"OUTER"}, {
        __close = function(self) table.insert(t, "close-" .. self[1]) end
      })

      do
        local x<close> = setmetatable({"INNER"}, {
          __close = function(self) table.insert(t, "close-" .. self[1]) end
        })
        table.insert(t, "inner-end")
      end

      table.insert(t, "middle")
    end

    table.insert(t, "outer-end")
  `);

  const t = env.get("t") as any;

  assertEquals(t.get(1), "inner-end");
  assertEquals(t.get(2), "close-INNER");
  assertEquals(t.get(3), "middle");
  assertEquals(t.get(4), "close-OUTER");
  assertEquals(t.get(5), "outer-end");
});

Deno.test("close: metatable swap after mark closes with new __close", async () => {
  const env = await evalBlock(`
    t = {}

    do
      local mt1 = {
        __close = function()
          table.insert(t, "close-1")
        end
      }
      local mt2 = {
        __close = function()
          table.insert(t, "close-2")
        end
      }

      local x<close> = setmetatable({}, mt1)
      setmetatable(x, mt2)
    end
  `);

  const t = env.get("t") as any;

  // Lua uses the metamethod at close time; this verifies close-time
  // lookup
  assertEquals(t.get(1), "close-2");
});

Deno.test("close: replacing __close function affects close-time behavior", async () => {
  const env = await evalBlock(`
    t = {}

    do
      local mt = {
        __close = function()
          table.insert(t, "close-1")
        end
      }

      local x<close> = setmetatable({}, mt)

      mt.__close = function()
        table.insert(t, "close-2")
      end
    end
  `);

  const t = env.get("t") as any;
  assertEquals(t.length, 1);
  assertEquals(t.get(1), "close-2");
});

// 3. errors and unwinding

Deno.test("close: non-closable init", async () => {
  const { e } = await runAndCatch(`
    do
      local x<close> = {}
    end
  `);

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: reassign after implicit nil errors", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close>
      a = 1
    end
  `,
    "close_reassign_after_implicit_nil.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'a'",
  );
});

Deno.test("close: reassign after explicit nil errors", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close> = nil
      a = 1
    end
  `,
    "close_reassign_after_explicit_nil.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'a'",
  );
});

Deno.test("close: reassign after false is non-closable", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close> = false
      a = 1
    end
  `,
    "close_reassign_after_false.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: reassign after closable value errors", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close> = setmetatable({"A"}, {
        __close = function(self)
        end
      })
      a = 1
    end
  `,
    "close_reassign_after_closable.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'a'",
  );
});

Deno.test("close: reassignment through closure is non-closable", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close> = false

      local function g()
        a = nil
      end

      g()
    end
  `,
    "close_reassign_through_closure.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: initializer true is non-closable", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close> = true
    end
  `,
    "close_init_true.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: initializer integer is non-closable", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close> = 1
    end
  `,
    "close_init_int.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: initializer float is non-closable", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close> = 3.14
    end
  `,
    "close_init_float.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: initializer string is non-closable", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close> = "string"
    end
  `,
    "close_init_string.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: initializer function is non-closable", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close> = function() end
    end
  `,
    "close_init_function.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: __close called exactly once on normal exit", async () => {
  const env = await evalBlock(`
    t = {}

    do
      local called = 0
      local x<close> = setmetatable({}, {
        __close = function()
          called = called + 1
          table.insert(t, called)
        end
      })
    end
  `);

  const t = env.get("t") as any;

  assertEquals(t.length, 1);
  assertEquals(t.get(1), 1);
});

Deno.test("close: async __close awaited on normal exit", async () => {
  const G = luaBuildStandardEnv();
  const env = new LuaEnv(G);

  // Promise-returning function: forces async close path deterministically.
  (env as any).setLocal(
    "delay",
    (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
  );

  await evalBlock(
    `
    t = {}

    do
      local x<close> = setmetatable({}, {
        __close = function()
          delay(10)
          table.insert(t, "closed")
        end
      })
    end
  `,
    env,
  );

  const t = env.get("t") as any;
  assertEquals(t.length, 1);
  assertEquals(t.get(1), "closed");
});

Deno.test("close: __close called exactly once on error unwind", async () => {
  const { err: e, env } = await runAndCatchEnv(
    `
    t = {}
    called = 0

    do
      local x<close> = setmetatable({}, {
        __close = function(self, err)
          called = called + 1
          table.insert(t, called)
        end
      })
      error("boom")
    end
  `,
    "close_once_on_error.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "boom");

  const t = env.get("t") as any;
  assertEquals(t.length, 1);
  assertEquals(t.get(1), 1);
});

Deno.test("close: async __close awaited on error unwind", async () => {
  const code = `
    t = {}

    do
      local x<close> = setmetatable({"X"}, {
        __close = function(self, err)
          delay(10)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
      error("boom")
    end
  `;

  const ast = parse(code, { ref: "close_async_unwind.lua" });
  const G = luaBuildStandardEnv();
  const env = new LuaEnv(G);
  const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx);

  env.setLocal(
    "delay",
    (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
  );

  let e: unknown = null;
  try {
    const r = evalStatement(ast, env, sf, false);
    if (isPromise(r)) {
      await r;
    }
  } catch (err: unknown) {
    e = err;
  }

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "boom");

  const t = env.get("t") as any;
  assertEquals(t.length, 1);
  assertEquals(t.get(1), "close-X-boom");
});

Deno.test("close: error in __close inside vararg function", async () => {
  const { e } = await runAndCatch(
    `
    local function mk()
      return setmetatable({}, {
        __close = function()
          error("closefail")
        end
      })
    end

    local function f(...)
      local x<close> = mk()
      return ...
    end

    f(1, 2, 3)
  `,
    "close_vararg_close_error.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "closefail");
});

Deno.test("close: __close not callable", async () => {
  const { e } = await runAndCatch(
    `
    do
      local x<close> = setmetatable({}, {
        __close = 1
      })
    end
  `,
    "close_not_callable.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: __close not found via __index", async () => {
  const { e } = await runAndCatch(
    `
    do
      local mt = {
        __index = {
          __close = function()
          end
        }
      }

      local x<close> = setmetatable({}, mt)
    end
  `,
    "close_index_metaclose.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: mutate __close", async () => {
  const { e } = await runAndCatch(
    `
    do
      local mt = {
        __close = function()
        end
      }
      local x<close> = setmetatable({}, mt)
      mt.__close = 1
    end
  `,
    "close_mutate.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "not callable");
});

Deno.test("close: remove __close after mark errors at close time", async () => {
  const { err: e } = await runAndCatchEnv(
    `
    do
      local mt = {
        __close = function()
        end
      }
      local x<close> = setmetatable({}, mt)

      mt.__close = nil
    end
  `,
    "close_remove_metaclose.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "not callable");
});

Deno.test("close: __close callable via __call", async () => {
  const { e } = await runAndCatch(
    `
    do
      local c = setmetatable({}, {
        __call = function(self, obj, err)
          table.insert(t, "called")
        end
      })

      local x<close> = setmetatable({}, {
        __close = c
      })
    end
  `,
    "close_callable_via_call.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: error after close var init still closes", async () => {
  const { e } = await runAndCatch(
    `
    t = {}

    local function mk()
      return setmetatable({"A"}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
        end
      })
    end

    do
      local a<close> = mk()
      error("boom")
    end
  `,
    "close_error_after_init_closes.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "boom");
});

Deno.test("close: close gets error", async () => {
  const { e } = await runAndCatch(`
    t = {}

    local function mk()
      return setmetatable({}, {
        __close = function(self, err)
          table.insert(t, tostring(err))
        end
      })
    end

    do
      local a<close> = mk()
      error("boom")
    end
  `);

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "boom");
});

Deno.test("close: close error", async () => {
  const { e } = await runAndCatch(
    `
    do
      local x<close> = setmetatable({}, {
        __close = function()
          error("closefail")
        end
      })
    end
  `,
    "close_error.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "closefail");
});

Deno.test("close: close error on error", async () => {
  const { e } = await runAndCatch(
    `
    do
      local x<close> = setmetatable({}, {
        __close = function()
          error("closefail")
        end
      })

      error("boom")
    end
  `,
    "close_error_on_error.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "closefail");
});

Deno.test("close: close errors stop", async () => {
  const { err: e, env } = await runAndCatchEnv(
    `
    t = {}

    do
      local a<close> = setmetatable({"A"}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
          error("c1")
        end
      })

      local b<close> = setmetatable({"B"}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
          error("c2")
        end
      })
    end
  `,
    "close_errors_stop.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "c2");

  // verify that both closes were attempted (Lua 5.4), but the first
  // close error (from B, closed first) is the reported error
  const t = env.get("t") as any;
  assertEquals(t.length, 2);
  assertEquals(t.get(1), "close-B");
  assertEquals(t.get(2), "close-A");
});

Deno.test("close: multiple closers unwind on error", async () => {
  const { err: e, env } = await runAndCatchEnv(
    `
    t = {}

    local function mk(x)
      return setmetatable({x}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
    end

    do
      local a<close> = mk("A")
      local b<close> = mk("B")
      error("boom")
    end
  `,
    "close_multi_unwind.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "boom");

  // verify both were closed in reverse order with the error object
  const t = env.get("t") as any;
  assertEquals(t.length, 2);
  assertEquals(t.get(1), "close-B-boom");
  assertEquals(t.get(2), "close-A-boom");
});

Deno.test("close: close errors stop during unwind", async () => {
  const { err: e, env } = await runAndCatchEnv(
    `
    t = {}

    local function mk(x, fail)
      return setmetatable({x}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
          if fail then
            error("closefail-" .. self[1])
          end
        end
      })
    end

    do
      local a<close> = mk("A", false)
      local b<close> = mk("B", true)
      error("boom")
    end
  `,
    "close_unwind_close_error.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "closefail-B");

  // verify both closes were attempted (Lua 5.4), but the first close
  // error (from B, closed first) is the reported error
  const t = env.get("t") as any;
  assertEquals(t.length, 2);
  assertEquals(t.get(1), "close-B-boom");
  assertEquals(t.get(2), "close-A-boom");
});

Deno.test("close: complex assignment error closes prior", async () => {
  const { err: e, env } = await runAndCatchEnv(
    `
    t = {}

    local function f1()
      return setmetatable({}, {
        __close = function()
          table.insert(t, "a_closed")
        end
      })
    end

    local function f2()
      error("assign_error")
    end

    do
      local a<close> = f1()
      local b = f2()
    end
  `,
    "complex_assign_error.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "assign_error");

  // verify 'a' was closed despite 'b' failing to assign
  const t = env.get("t") as any;
  assertEquals(t.get(1), "a_closed");
});

Deno.test("close: multi-init closes prior when later init errors", async () => {
  const { err: e, env } = await runAndCatchEnv(
    `
    t = {}

    local function mk()
      return setmetatable({}, {
        __close = function()
          table.insert(t, "a_closed")
        end
      })
    end

    local function fail()
      error("boom")
    end

    do
      local a<close>, b = mk(), fail()
    end
  `,
    "close_multi_init_later_error.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "boom");

  const t = env.get("t") as any;
  assertEquals(t.get(1), "a_closed");
});

Deno.test("close: async close error reported; later closers still run", async () => {
  const G = luaBuildStandardEnv();
  const env = new LuaEnv(G);

  env.setLocal(
    "delay",
    (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
  );

  const { err: e } = await (async () => {
    const code = `
      t = {}

      do
        local a<close> = setmetatable({"A"}, {
          __close = function(self, err)
            delay(5)
            table.insert(t, "close-" .. self[1])
          end
        })

        local b<close> = setmetatable({"B"}, {
          __close = function(self, err)
            delay(5)
            table.insert(t, "close-" .. self[1])
            error("b-closefail")
          end
        })
      end
    `;
    const ast = parse(code, { ref: "close_async_close_error.lua" });
    const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx);

    let err: unknown = null;
    try {
      const r = evalStatement(ast, env, sf, false);
      if (isPromise(r)) await r;
    } catch (x) {
      err = x;
    }
    return { err };
  })();

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "b-closefail");

  const t = env.get("t") as any;
  // B closes first and errors, but A must still be closed in Lua 5.4 intent
  assertEquals(t.length, 2);
  assertEquals(t.get(1), "close-B");
  assertEquals(t.get(2), "close-A");
});

// 4. control flow exits

Deno.test("close: return closes", async () => {
  const env = await evalBlock(`
    t = {}

    local function f()
      do
        local x<close> = setmetatable({"X"}, {
          __close = function(self)
            table.insert(t, "close-" .. self[1])
          end
        })
        return 1
      end
    end

    table.insert(t, f())
  `);

  assertEquals((env.get("t") as any).get(1), "close-X");
  assertEquals((env.get("t") as any).get(2), 1);
});

Deno.test("close: return in if closes", async () => {
  const env = await evalBlock(`
    t = {}

    local function f()
      do
        local x<close> = setmetatable({"X"}, {
          __close = function(self)
            table.insert(t, "close-" .. self[1])
          end
        })
        if true then
          return 2
        end
        return 3
      end
    end

    table.insert(t, f())
  `);

  assertEquals((env.get("t") as any).get(1), "close-X");
  assertEquals((env.get("t") as any).get(2), 2);
});

Deno.test("close: goto closes", async () => {
  const env = await evalBlock(`
    t = {}

    do
      local x<close> = setmetatable({"X"}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
        end
      })

      goto L1
      ::L2::
      error("bad")
      ::L1::
      goto L3
      ::L3::
    end
  `);

  assertEquals((env.get("t") as any).get(1), "close-X");
});

Deno.test("close: goto nested", async () => {
  const env = await evalBlock(`
    t = {}

    do
      do
        local a<close> = setmetatable({"A"}, {
          __close = function(self)
            table.insert(t, "close-" .. self[1])
          end
        })
        do
          local b<close> = setmetatable({"B"}, {
            __close = function(self)
              table.insert(t, "close-" .. self[1])
            end
          })
          goto L
        end
      end
      ::L::
    end
  `);

  assertEquals((env.get("t") as any).get(1), "close-B");
  assertEquals((env.get("t") as any).get(2), "close-A");
});

Deno.test("close: return to-be-closed variable", async () => {
  const env = await evalBlock(`
    t = {}

    local function f()
      local x<close> = setmetatable({"X"}, {
        __close = function(self)
          table.insert(t, "closed")
        end
      })
      return x
    end

    local r = f()
    table.insert(t, type(r))
  `);

  const t = env.get("t") as any;
  assertEquals(t.get(1), "closed");
  assertEquals(t.get(2), "table");
});

// 5. generic-for loop-scoped closing

Deno.test("close: for-in no close", async () => {
  const env = await evalBlock(`
    t = {}

    local function iter(state, ctrl)
      if ctrl == nil then
        return 1, "v"
      end
      return nil
    end

    local function gen()
      return iter, nil, nil
    end

    for k, v in gen() do
      table.insert(t, "body")
    end
  `);

  assertEquals((env.get("t") as any).get(1), "body");
  assertEquals((env.get("t") as any).length, 1);
});

Deno.test("close: for-in false close is non-closable", async () => {
  const { e } = await runAndCatch(
    `
    local function iter(state, ctrl)
      if ctrl == nil then
        return 1
      end
      return nil
    end

    local function gen()
      return iter, nil, nil, false
    end

    for k in gen() do
    end
  `,
    "for_in_false_close.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: for-in bad close", async () => {
  const { e } = await runAndCatch(
    `
    local function iter(state, ctrl)
      if ctrl == nil then
        return 1
      end
      return nil
    end

    local function gen()
      return iter, nil, nil, {}
    end

    for k in gen() do
    end
  `,
    "for_in_bad_close.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: for-in updates control", async () => {
  const env = await evalBlock(`
    t = {}
    local calls = 0

    local function iter(state, ctrl)
      calls = calls + 1
      if ctrl == nil then
        return 1
      end
      return nil
    end

    local function gen()
      local closing = setmetatable({"C"}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
        end
      })

      return iter, nil, nil, closing
    end

    for k in gen() do
      table.insert(t, "body")
    end

    table.insert(t, calls)
  `);

  assertEquals((env.get("t") as any).get(1), "body");
  assertEquals((env.get("t") as any).get(2), "close-C");
  assertEquals((env.get("t") as any).get(3), 2);
});

Deno.test("close: for-in closes", async () => {
  const env = await evalBlock(`
    t = {}

    local function iter(state, ctrl)
      if ctrl == nil then
        return 1, "v"
      end
      return nil
    end

    local function gen()
      local closing = setmetatable({"C"}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
        end
      })

      return iter, nil, nil, closing
    end

    for k, v in gen() do
      table.insert(t, "body")
    end
  `);

  assertEquals((env.get("t") as any).get(1), "body");
  assertEquals((env.get("t") as any).get(2), "close-C");
});

Deno.test("close: for-in async iterator and async closing are awaited", async () => {
  const G = luaBuildStandardEnv();
  const env = new LuaEnv(G);

  (env as any).setLocal(
    "delay",
    (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
  );

  await evalBlock(
    `
    t = {}
    local calls = 0

    local function iter(state, ctrl)
      calls = calls + 1
      delay(5)
      if ctrl == nil then
        return 1
      end
      return nil
    end

    local function gen()
      local closing = setmetatable({"C"}, {
        __close = function(self, err)
          delay(5)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
      return iter, nil, nil, closing
    end

    for k in gen() do
      table.insert(t, "body")
    end

    table.insert(t, calls)
  `,
    env,
  );

  const t = env.get("t") as any;
  assertEquals(t.get(1), "body");
  assertEquals(t.get(2), "close-C-nil");
  assertEquals(t.get(3), 2);
});

Deno.test("close: loop scope", async () => {
  const env = await evalBlock(`
    t = {}

    local function iter(state, ctrl)
      if ctrl == nil then
        return 1
      end
      return nil
    end

    local function gen()
      local closing = setmetatable({"C"}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
        end
      })
      return iter, nil, nil, closing
    end

    for k in gen() do
      table.insert(t, "body")
    end

    table.insert(t, "after")
  `);

  assertEquals((env.get("t") as any).get(1), "body");
  assertEquals((env.get("t") as any).get(2), "close-C");
  assertEquals((env.get("t") as any).get(3), "after");
});

Deno.test("close: for-in closing closes before outer block closers", async () => {
  const env = await evalBlock(`
    t = {}

    local function iter(state, ctrl)
      if ctrl == nil then
        return 1
      end
      return nil
    end

    do
      local outer<close> = setmetatable({"O"}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
        end
      })

      local function gen()
        local closing = setmetatable({"L"}, {
          __close = function(self)
            table.insert(t, "close-" .. self[1])
          end
        })
        return iter, nil, nil, closing
      end

      for k in gen() do
        table.insert(t, "body")
      end

      table.insert(t, "after-loop")
    end

    table.insert(t, "after-block")
  `);

  const t = env.get("t") as any;

  // Required ordering:
  // body runs
  // loop closing value closes at loop end
  // after-loop runs
  // outer closes when leaving do-block
  // after-block runs
  assertEquals(t.get(1), "body");
  assertEquals(t.get(2), "close-L");
  assertEquals(t.get(3), "after-loop");
  assertEquals(t.get(4), "close-O");
  assertEquals(t.get(5), "after-block");
});

Deno.test("close: for-in closes on break", async () => {
  const env = await evalBlock(`
    t = {}

    local function iter(state, ctrl)
      if ctrl == nil then
        return 1, "v"
      end
      return 2, "w"
    end

    local function gen()
      local closing = setmetatable({"C"}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
        end
      })

      return iter, nil, nil, closing
    end

    for k, v in gen() do
      table.insert(t, "body")
      break
    end
  `);

  assertEquals((env.get("t") as any).get(1), "body");
  assertEquals((env.get("t") as any).get(2), "close-C");
});

Deno.test("close: for-in closes on error", async () => {
  const { err: e, env } = await runAndCatchEnv(`
    t = {}

    local function iter(state, ctrl)
      if ctrl == nil then
        return 1, "v"
      end
      return nil
    end

    local function gen()
      local closing = setmetatable({"C"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })

      return iter, nil, nil, closing
    end

    for k, v in gen() do
      error("boom")
    end
  `);

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "boom");

  // verify closer received error
  const t = env.get("t") as any;
  assertEquals(t.get(1), "close-C-boom");
});

Deno.test("close: for-in closes if iterator errors", async () => {
  const { err: e, env } = await runAndCatchEnv(
    `
    t = {}

    local function iter(state, ctrl)
      error("iterboom")
    end

    local function gen()
      local closing = setmetatable({"C"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })

      return iter, nil, nil, closing
    end

    for k in gen() do
    end
  `,
    "for_in_iterator_error.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "iterboom");

  // verify closer received error
  const t = env.get("t") as any;
  assertEquals(t.get(1), "close-C-iterboom");
});

Deno.test("close: return inside for-in body closes loop closing value", async () => {
  const env = await evalBlock(`
    t = {}

    local function iter(state, ctrl)
      if ctrl == nil then
        return 1
      end
      return nil
    end

    local function gen()
      local closing = setmetatable({"C"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
      return iter, nil, nil, closing
    end

    local function f()
      for k in gen() do
        return 1
      end
      return 2
    end

    table.insert(t, f())
  `);

  const t = env.get("t") as any;
  assertEquals(t.get(1), "close-C-nil");
  assertEquals(t.get(2), 1);
});

Deno.test("close: error inside for-in body closes loop closing value with error", async () => {
  const { err: e, env } = await runAndCatchEnv(
    `
    t = {}

    local function iter(state, ctrl)
      if ctrl == nil then
        return 1
      end
      return nil
    end

    local function gen()
      local closing = setmetatable({"C"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
      return iter, nil, nil, closing
    end

    for k in gen() do
      error("boom")
    end
  `,
    "for_in_body_error_close_value.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "boom");

  const t = env.get("t") as any;
  assertEquals(t.get(1), "close-C-boom");
});

// 6. pairs integration

Deno.test("close: pairs closes", async () => {
  const env = await evalBlock(`
    t = {}
    local closed = false

    local a = {10, 20}

    local function iter(state, ctrl)
      if ctrl == nil then
        return 1, state[1]
      end
      return nil
    end

    setmetatable(a, {
      __pairs = function(self)
        local tbc = setmetatable({"P"}, {
          __close = function()
            table.insert(t, "close-P")
            closed = true
          end
        })
        return iter, self, nil, tbc
      end
    })

    for k, v in pairs(a) do
      table.insert(t, "body")
    end

    if closed then
      table.insert(t, "closed")
    end
  `);

  assertEquals((env.get("t") as any).get(1), "body");
  assertEquals((env.get("t") as any).get(2), "close-P");
  assertEquals((env.get("t") as any).get(3), "closed");
});

// 7. protected calls

Deno.test("close: pcall closes on success", async () => {
  const env = await evalBlock(`
    t = {}

    local function f()
      local x<close> = setmetatable({"X"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
      return 1
    end

    local ok, r = pcall(f)
    if ok then
      table.insert(t, r)
    end
  `);

  assertEquals((env.get("t") as any).get(1), "close-X-nil");
  assertEquals((env.get("t") as any).get(2), 1);
});

Deno.test("close: multi-return expansion binds and closes", async () => {
  const env = await evalBlock(`
    t = {}

    local function mk()
      local obj = setmetatable({"A"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
      return obj, 99
    end

    do
      local a<close>, b = mk()
      table.insert(t, b)
    end
  `);

  const t = env.get("t") as any;
  assertEquals(t.get(1), 99);
  assertEquals(t.get(2), "close-A-nil");
});

Deno.test("close: pcall return closes with nil error", async () => {
  const env = await evalBlock(`
    t = {}

    local function mk()
      return setmetatable({"R"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
    end

    local function f()
      do
        local x<close> = mk()
        return 7
      end
    end

    local ok, r = pcall(f)
    table.insert(t, ok)
    table.insert(t, r)
  `);

  const t = env.get("t") as any;
  assertEquals(t.get(1), "close-R-nil");
  assertEquals(t.get(2), true);
  assertEquals(t.get(3), 7);
});

Deno.test("close: pcall break closes with nil error", async () => {
  const env = await evalBlock(`
    t = {}

    local function mk()
      return setmetatable({"B"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
    end

    local function f()
      do
        local x<close> = mk()
        while true do
          break
        end
      end
      return 1
    end

    local ok, r = pcall(f)
    table.insert(t, ok)
    table.insert(t, r)
  `);

  const t = env.get("t") as any;
  assertEquals(t.get(1), "close-B-nil");
  assertEquals(t.get(2), true);
  assertEquals(t.get(3), 1);
});

Deno.test("close: pcall goto closes with nil error", async () => {
  const env = await evalBlock(`
    t = {}

    local function mk()
      return setmetatable({"G"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
    end

    local function f()
      do
        local x<close> = mk()
        goto L
        error("unreachable")
        ::L::
      end
      return 2
    end

    local ok, r = pcall(f)
    table.insert(t, ok)
    table.insert(t, r)
  `);

  const t = env.get("t") as any;
  assertEquals(t.get(1), "close-G-nil");
  assertEquals(t.get(2), true);
  assertEquals(t.get(3), 2);
});

Deno.test("close: pcall closes on error", async () => {
  const env = await evalBlock(`
    t = {}

    local function f()
      local x<close> = setmetatable({"X"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
      error("boom")
    end

    local ok, msg = pcall(f)
    if not ok then
      table.insert(t, tostring(msg))
    end
  `);

  assertEquals((env.get("t") as any).get(1), "close-X-boom");
  assertEquals((env.get("t") as any).get(2), "boom");
});

Deno.test("close: pcall close error overrides original and skips remaining closers", async () => {
  const env = await evalBlock(`
      t = {}

      local function mk(label, mode)
        return setmetatable({label}, {
          __close = function(self, err)
            table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
            if mode == "error" then
              error("closefail-" .. self[1])
            end
          end
        })
      end

      local function f()
        do
          local a<close> = mk("A", "ok")
          local b<close> = mk("B", "error")
          local c<close> = mk("C", "ok")
          error("boom")
        end
      end

      local ok, msg = pcall(f)
      table.insert(t, ok)
      table.insert(t, tostring(msg))
    `);

  const t = env.get("t") as any;

  // C closes first, then B closes and errors; A is still closed in Lua 5.4
  assertEquals(t.get(1), "close-C-boom");
  assertEquals(t.get(2), "close-B-boom");
  assertEquals(t.get(3), "close-A-boom");
  assertEquals(t.get(4), false);
  assertEquals(t.get(5), "closefail-B");
  assertEquals(t.length, 5);
});

Deno.test("close: xpcall closes on error", async () => {
  const env = await evalBlock(`
    t = {}

    local function f()
      local x<close> = setmetatable({"X"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
      error("boom")
    end

    local function h(err)
      return "handled-" .. tostring(err)
    end

    local ok, msg = xpcall(f, h)
    if not ok then
      table.insert(t, tostring(msg))
    end
  `);

  assertEquals((env.get("t") as any).get(1), "close-X-boom");
  assertEquals((env.get("t") as any).get(2), "handled-boom");
});

Deno.test("close: xpcall boundary contains __close errors", async () => {
  const env = await evalBlock(`
    t = {}

    local function f()
      do
        local x<close> = setmetatable({}, {
          __close = function(self, err)
            table.insert(t, "close-err-" .. tostring(err))
            error("closefail")
          end
        })
        error("boom")
      end
    end

    local function h(err)
      table.insert(t, "handler-" .. tostring(err))
      return "handled-" .. tostring(err)
    end

    local ok, msg = xpcall(f, h)
    table.insert(t, ok)
    table.insert(t, tostring(msg))
  `);

  const t = env.get("t") as any;

  // close runs during unwind and sees original error
  assertEquals(t.get(1), "close-err-boom");

  // the close error overrides the original for `xpcall`, so the
  // handler sees "closefail"
  assertEquals(t.get(2), "handler-closefail");

  assertEquals(t.get(3), false);
  assertEquals(t.get(4), "handled-closefail");
  assertEquals(t.length, 4);
});

Deno.test("close: nested to-be-closed created inside __close", async () => {
  const env = await evalBlock(`
    t = {}

    local function mk(label)
      return setmetatable({label}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
    end

    do
      local outer<close> = setmetatable({"OUTER"}, {
        __close = function(self, err)
          table.insert(t, "outer-close-start-" .. tostring(err))
          do
            local inner<close> = mk("INNER")
            table.insert(t, "inner-scope-end")
          end
          table.insert(t, "outer-close-end")
        end
      })
      table.insert(t, "body-end")
    end
  `);

  const t = env.get("t") as any;

  // Expected order:
  // - body finishes
  // - outer close begins
  // - inner scope ends, so inner closes (during exec of outer __close)
  // - outer close ends
  assertEquals(t.get(1), "body-end");
  assertEquals(t.get(2), "outer-close-start-nil");
  assertEquals(t.get(3), "inner-scope-end");
  assertEquals(t.get(4), "close-INNER-nil");
  assertEquals(t.get(5), "outer-close-end");
});

Deno.test("close: pcall boundary does not close closers created in pcall args", async () => {
  const env = await evalBlock(`
    t = {}

    local function mk(label)
      return setmetatable({label}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
        end
      })
    end

    local function f()
      table.insert(t, "in-f")
      return 1
    end

    local function arg()
      local x<close> = mk("ARG")
      table.insert(t, "arg")
      return 123
    end

    local ok, r = pcall(f, arg())
    table.insert(t, ok)
    table.insert(t, r)
  `);

  const t = env.get("t") as any;

  // if `pcall` boundary is correct, ARG is closed at end of its own
  // scope and `pcall` does not close it
  assertEquals(t.get(1), "arg");
  assertEquals(t.get(2), "close-ARG");
  assertEquals(t.get(3), "in-f");
  assertEquals(t.get(4), true);
  assertEquals(t.get(5), 1);
});

Deno.test("close: nested pcall boundaries", async () => {
  const env = await evalBlock(`
    t = {}

    local function mk(label)
      return setmetatable({label}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
    end

    local function inner()
      local b<close> = mk("B")
      error("inner")
    end

    local function outer()
      local a<close> = mk("A")
      local ok, msg = pcall(inner)
      table.insert(t, ok)
      table.insert(t, tostring(msg))
      return 1
    end

    local ok, r = pcall(outer)
    table.insert(t, ok)
    table.insert(t, r)
  `);

  const t = env.get("t") as any;

  // inner closes with "inner" outer continues, then outer closes with
  // nil on success of outer itself
  assertEquals(t.get(1), "close-B-inner");
  assertEquals(t.get(2), false);
  assertEquals(t.get(3), "inner");
  assertEquals(t.get(4), "close-A-nil");
  assertEquals(t.get(5), true);
  assertEquals(t.get(6), 1);
});


================================================
FILE: client/space_lua/const_attribute.test.ts
================================================
import {
  assertEquals,
  assertInstanceOf,
  assertStringIncludes,
} from "@std/assert";
import { parse } from "./parse.ts";
import { evalStatement } from "./eval.ts";
import { LuaEnv, LuaRuntimeError, LuaStackFrame } from "./runtime.ts";
import { luaBuildStandardEnv } from "./stdlib.ts";

async function evalBlock(code: string, env?: LuaEnv): Promise<LuaEnv> {
  const ast = parse(code);
  const G = luaBuildStandardEnv();
  const base = env ?? new LuaEnv(G);
  const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx);
  const r = evalStatement(ast, base, sf, false);

  if (r instanceof Promise) {
    await r;
  }
  return base;
}

async function runAndCatch(code: string, ref = "const_attribute.lua") {
  const ast = parse(code, { ref });
  const G = luaBuildStandardEnv();
  const env = new LuaEnv(G);
  const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx);

  try {
    const r = evalStatement(ast, env, sf, false);
    if (r instanceof Promise) {
      await r;
    }
    throw new Error("Expected LuaRuntimeError but evaluation succeeded");
  } catch (e: unknown) {
    return { e, code, ref };
  }
}

Deno.test("const: Unknown attribute (parse-time)", () => {
  let threw = false;
  try {
    parse(`local x<nope> = 1`, { ref: "unknown_attribute.lua" });
  } catch (e: any) {
    threw = true;
    assertStringIncludes(String(e?.message ?? e), "unknown attribute 'nope'");
  }
  if (!threw) {
    throw new Error("Expected parse error for unknown attribute");
  }
});

Deno.test("const: Case-sensitive attribute (parse-time)", () => {
  let threw = false;
  try {
    parse(`local x<Const> = 1`, { ref: "unknown_attribute.lua" });
  } catch (e: any) {
    threw = true;
    assertStringIncludes(String(e?.message ?? e), "unknown attribute 'Const'");
  }
  if (!threw) {
    throw new Error("Expected parse error for unknown attribute");
  }
});
Deno.test("const: Requires initializer", async () => {
  const { e } = await runAndCatch(`
    local x <const>
  `);
  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "must be initialized");
});

Deno.test("const: Initialization ok, reassignment error", async () => {
  const env = await evalBlock(`
    local x<const> = 1
    y = x
  `);
  assertEquals(env.get("y"), 1);

  const { e } = await runAndCatch(`
    local x<const> = 1
    x = 2
  `);
  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'x'",
  );
});

Deno.test("const: Initialization to nil ok, reassignment error", async () => {
  const { e } = await runAndCatch(`
    local x <const> = nil
    x = 2
  `);
  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'x'",
  );
});

Deno.test("const: Multi-declaration and missing RHS", async () => {
  {
    const env = await evalBlock(`
      local a <const>, b = 1
      y1 = a
      y2 = b
    `);
    assertEquals(env.get("y1"), 1);
    assertEquals(env.get("y2"), null);
  }

  {
    const { e } = await runAndCatch(`
      local a<const>, b <const>; -- missing assignment
    `);
    assertInstanceOf(e, LuaRuntimeError);
    assertStringIncludes((e as LuaRuntimeError).message, "must be initialized");
  }
});

Deno.test("const: Multi-assignment after declaration fails", async () => {
  const { e } = await runAndCatch(`
    local a<const>, b = 1, 2
    a, b = 3, 4
  `);
  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'a'",
  );
});

Deno.test("const: Second position", async () => {
  const { e } = await runAndCatch(`
    local a, b<const> = 1, 2
    b = 3
  `);
  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'b'",
  );
});

Deno.test("const: Initialization from function returns", async () => {
  const env = await evalBlock(`
    local function f()
      return 7
    end

    local a <const>, b <const> = f()

    y1, y2 = a, b
  `);
  assertEquals(env.get("y1"), 7);
  assertEquals(env.get("y2"), null);

  const { e } = await runAndCatch(`
    local function f()
      return 7
    end

    local a<const>, b<const> = f()

    a = 2
  `);
  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'a'",
  );
});

Deno.test("const: Shadowing", async () => {
  {
    const { e } = await runAndCatch(`
      local a<const> = 1

      do
        local a<const> = 2
        a = 3
      end
    `);
    assertInstanceOf(e, LuaRuntimeError);
    assertStringIncludes(
      (e as LuaRuntimeError).message,
      "attempt to assign to const variable 'a'",
    );
  }

  {
    const env = await evalBlock(`
      local a<const> = 1

      do
        local a = 2
        a = 3
      end

      y = a
    `);
    assertEquals(env.get("y"), 1);
  }
});

Deno.test("const: Globals and fields not affected", async () => {
  const env = await evalBlock(`
    local a<const> = 1
    G = a

    local T = {}
    T.x = a

    y1, y2 = G, T.x
  `);
  assertEquals(env.get("y1"), 1);
  assertEquals(env.get("y2"), 1);
});

Deno.test("const: Table binding vs table contents", async () => {
  const env = await evalBlock(`
    local t<const> = { a = 1, 2, 3 }

    t.a  = 10 -- mutate property
    t[2] = 20 -- mutate array part

    y1, y2, y3 = t.a, t[2], #t
  `);
  assertEquals(env.get("y1"), 10);
  assertEquals(env.get("y2"), 20);
  assertEquals(env.get("y3"), 2);
});

Deno.test("const: Rebinding table fails", async () => {
  const { e } = await runAndCatch(
    `
    local t<const> = {
      a = 1
    }

    t = {
      b = 2
    }
  `,
    "const_table_rebind.lua",
  );
  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 't'",
  );
});

Deno.test("const: Inner block reassignment", async () => {
  const { e } = await runAndCatch(`
    local a<const> = 1
    do
      a = 2 -- must fail
    end
  `);
  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'a'",
  );
});

Deno.test("const: Closure reassignment", async () => {
  const { e } = await runAndCatch(`
    local a<const> = 1

    local function f()
      a = 2 -- must error
    end

    f()
  `);
  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'a'",
  );
});

Deno.test("const: Closure mutates table", async () => {
  const env = await evalBlock(`
    local t<const> = { x = 1 }

    local function bump()
      t.x = t.x + 1
    end

    bump()

    y = t.x
  `);
  assertEquals(env.get("y"), 2);
});


================================================
FILE: client/space_lua/context_errors.test.ts
================================================
import { parse } from "./parse.ts";
import { evalStatement } from "./eval.ts";
import { LuaEnv, LuaRuntimeError, LuaStackFrame } from "./runtime.ts";
import { luaBuildStandardEnv } from "./stdlib.ts";
import { assertInstanceOf, assertStringIncludes } from "@std/assert";

async function runAndCatch(code: string, ref = "ctx_test.lua") {
  const ast = parse(code, { ref });
  const global = luaBuildStandardEnv();
  const env = new LuaEnv(global);
  const sf = LuaStackFrame.createWithGlobalEnv(global, ast.ctx);
  try {
    const r = evalStatement(ast, env, sf, false);
    if (r instanceof Promise) await r;
    throw new Error("Expected LuaRuntimeError but evaluation succeeded");
  } catch (e: any) {
    return { e, code, ref };
  }
}

function assertCtxErrorContains(
  e: unknown,
  code: string,
  ref: string,
  msgIncludes: string,
) {
  assertInstanceOf(e, LuaRuntimeError);
  const err = e as LuaRuntimeError;
  assertStringIncludes(err.message, msgIncludes);
  const pretty = err.toPrettyString(code);
  assertStringIncludes(pretty, msgIncludes);
  assertStringIncludes(pretty, ref);
  // caret presence sanity check
  assertStringIncludes(pretty, "^");
}

Deno.test("Context error: indexing nil value includes message and ref", async () => {
  const { e, code, ref } = await runAndCatch(
    `
    local t = nil
    local x = t[1]
  `,
    "index_nil.lua",
  );
  assertCtxErrorContains(e, code, ref, "attempt to index a nil value");
});

Deno.test("Context error: indexing with nil key includes message and ref", async () => {
  const { e, code, ref } = await runAndCatch(
    `
    local t = {}
    local k = nil
    local x = t[k]
  `,
    "nil_key.lua",
  );
  assertCtxErrorContains(e, code, ref, "attempt to index with a nil key");
});

Deno.test("Context error: calling nil includes message and ref", async () => {
  const { e, code, ref } = await runAndCatch(
    `
    local f = nil
    f()
  `,
    "call_nil.lua",
  );
  assertCtxErrorContains(e, code, ref, "attempt to call a nil value");
});

Deno.test("Context error: modulo by zero includes message and ref", async () => {
  const { e, code, ref } = await runAndCatch(
    `
    local a = 1
    local b = 0
    local c = a % b
  `,
    "mod_zero.lua",
  );
  assertCtxErrorContains(e, code, ref, "attempt to perform 'n%0'");
});

Deno.test("Context error: type mismatch in comparison includes message and ref", async () => {
  const { e, code, ref } = await runAndCatch(
    `
    local x = "a" < 1
  `,
    "compare_type.lua",
  );
  assertCtxErrorContains(e, code, ref, "attempt to compare string with number");
});

/**
 * Additional generic context-error coverage for lazy dispatch:
 * - __index metamethod wrong type (neither function nor table)
 * - __call metamethod wrong type (non-callable)
 * - length operator on unsupported type
 */
Deno.test("Context error: __index metamethod must be function or table", async () => {
  const { e, code, ref } = await runAndCatch(
    `
    local t = {}
    setmetatable(t, { __index = 5 })
    local x = t.foo
  `,
    "meta_index_wrong.lua",
  );
  assertCtxErrorContains(
    e,
    code,
    ref,
    "attempt to index a number value",
  );
});

Deno.test("Context error: __call metamethod must be a function", async () => {
  const { e, code, ref } = await runAndCatch(
    `
    local t = {}
    setmetatable(t, { __call = 5 })
    t()
  `,
    "meta_call_wrong.lua",
  );
  assertCtxErrorContains(
    e,
    code,
    ref,
    "attempt to call a number value",
  );
});

Deno.test("Context error: length operator wrong type (number)", async () => {
  const { e, code, ref } = await runAndCatch(
    `
    local x = #1
  `,
    "len_number.lua",
  );
  assertCtxErrorContains(
    e,
    code,
    ref,
    "attempt to get length of a number value",
  );
});


================================================
FILE: client/space_lua/eval.test.ts
================================================
import { assertEquals } from "@std/assert/equals";
import {
  LuaEnv,
  LuaNativeJSFunction,
  LuaStackFrame,
  LuaTable,
  luaValueToJS,
  singleResult,
} from "./runtime.ts";
import { parse } from "./parse.ts";
import type { LuaBlock, LuaFunctionCallStatement } from "./ast.ts";
import { evalExpression, evalStatement } from "./eval.ts";
import { luaBuildStandardEnv } from "./stdlib.ts";

const sf = LuaStackFrame.lostFrame;

function evalExpr(s: string, e = new LuaEnv(), sf?: LuaStackFrame): any {
  const node = parse(`e(${s})`).statements[0] as LuaFunctionCallStatement;
  sf = sf || new LuaStackFrame(e, node.ctx);
  return evalExpression(
    node.call.args[0],
    e,
    sf,
  );
}

async function evalBlock(s: string, e = new LuaEnv()): Promise<void> {
  const node = parse(s) as LuaBlock;
  const sf = new LuaStackFrame(e, node.ctx);
  await evalStatement(node, e, sf);
}

Deno.test("Evaluator test", async () => {
  const env = new LuaEnv();
  env.set("test", new LuaNativeJSFunction((n) => n));
  env.set("asyncTest", new LuaNativeJSFunction((n) => Promise.resolve(n)));

  // Basic arithmetic
  assertEquals(evalExpr(`1 + 2 + 3 - 3`), 3);
  assertEquals(evalExpr(`4 // 3`), 1);
  assertEquals(evalExpr(`4 % 3`), 1);

  // Bitwise arithmetic
  assertEquals(evalExpr(`~171`), -172); // signed two's complement
  assertEquals(evalExpr(`5 & 3`), 1); // 101 & 011 = 001
  assertEquals(evalExpr(`5 | 3`), 7); // 101 | 011 = 111
  assertEquals(evalExpr(`5 ~ 3`), 6); // 101 ^ 011 = 110
  assertEquals(evalExpr(`5 << 3`), 40); // 101 << 3 = 101000
  assertEquals(evalExpr(`5 >> 2`), 1); // 101 >> 2 = 1

  // Strings
  assertEquals(evalExpr(`"a" .. "b"`), "ab");

  // Logic
  assertEquals(evalExpr(`true and false`), false);
  assertEquals(evalExpr(`true or false`), true);
  assertEquals(evalExpr(`not true`), false);
  // Test eager evaluation of left operand
  assertEquals(
    evalExpr(
      `true or (function() error("this should not be evaluated") end)()`,
    ),
    true,
  );
  // Tables
  const tbl = await evalExpr(`{3, 1, 2}`);
  assertEquals(tbl.get(1), 3);
  assertEquals(tbl.get(2), 1);
  assertEquals(tbl.get(3), 2);
  assertEquals(luaValueToJS(tbl, sf), [3, 1, 2]);

  assertEquals(
    luaValueToJS(await evalExpr(`{name=test("Zef"), age=100}`, env), sf),
    {
      name: "Zef",
      age: 100,
    },
  );

  assertEquals(
    luaValueToJS(await evalExpr(`{name="Zef", age=asyncTest(100)}`, env), sf),
    {
      name: "Zef",
      age: 100,
    },
  );

  const result = await evalExpr(`{[3+2]=1, ["a".."b"]=2}`);
  assertEquals(result.get(5), 1);
  assertEquals(result.get("ab"), 2);

  assertEquals(await evalExpr(`#{}`), 0);
  assertEquals(await evalExpr(`#{1, 2, 3}`), 3);

  // Unary operators
  assertEquals(await evalExpr(`-asyncTest(3)`, env), -3);

  // Function calls
  assertEquals(singleResult(evalExpr(`test(3)`, env)), 3);
  assertEquals(singleResult(await evalExpr(`asyncTest(3) + 1`, env)), 4);

  // Function expressions and table access
  assertEquals(
    await evalExpr(`(function() return {name="John"} end)().name`),
    "John",
  );

  // Function definitions
  const fn = evalExpr(`function(a, b) return a + b end`);
  assertEquals(fn.body.parameters, ["a", "b"]);
});

Deno.test("Parser rejects unary plus - parenthesized", () => {
  for (
    const src of [
      "return +(1)",
      "return +((1))",
      "return +(1 + 2)",
      "return +({})",
    ]
  ) {
    let err: any = null;
    try {
      parse(src);
    } catch (e: any) {
      err = e;
    }
    assertEquals(err !== null, true, `expected parse error for: ${src}`);
    assertEquals(
      String(err?.message ?? err).includes("unexpected symbol near '+'"),
      true,
      `expected unary-plus message for: ${src}`,
    );
  }
});

Deno.test("Parser rejects unary plus - variables and calls", () => {
  for (
    const src of [
      "return +(a)",
      "return +(a.b)",
      "return +(a[1])",
      "return +f()",
      "return +(f())",
    ]
  ) {
    let err: any = null;
    try {
      parse(src);
    } catch (e: any) {
      err = e;
    }
    assertEquals(err !== null, true, `expected parse error for: ${src}`);
    assertEquals(
      String(err?.message ?? err).includes("unexpected symbol near '+'"),
      true,
      `expected unary-plus message for: ${src}`,
    );
  }
});

Deno.test("Parser rejects unary plus - whitespace/newlines", () => {
  for (
    const src of [
      "return + 1",
      "return +\n1",
      "return +\t(1)",
      "return \n+\n(1)",
    ]
  ) {
    let err: any = null;
    try {
      parse(src);
    } catch (e: any) {
      err = e;
    }
    assertEquals(err !== null, true, `expected parse error for: ${src}`);
    assertEquals(
      String(err?.message ?? err).includes("unexpected symbol near '+'"),
      true,
      `expected unary-plus message for: ${src}`,
    );
  }
});

Deno.test("Comparison metamethods: __lt", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());

  await evalBlock(
    `
      local mt = {
        __lt = function(a, b) return a.v < b.v end
      }
      local a = setmetatable({ v = 1 }, mt)
      local b = setmetatable({ v = 2 }, mt)
      res = (a < b)
    `,
    env,
  );

  assertEquals(env.get("res"), true);
});

Deno.test("Comparison metamethods: __le does not fallback to __lt", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());

  let threw = false;
  try {
    await evalBlock(
      `
      local mt = {
        __lt = function(a, b) return a.v < b.v end
      }
      local a = setmetatable({ v = 2 }, mt)
      local b = setmetatable({ v = 2 }, mt)
      res = (a <= b)
    `,
      env,
    );
  } catch (e: any) {
    threw = true;
    const msg = String(e?.message ?? e);
    if (!msg.includes("attempt to compare")) {
      throw e;
    }
  }

  assertEquals(threw, true);
});

Deno.test("Equality metamethod: __eq requires same function", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());

  await evalBlock(
    `
      local f1 = function(a, b) return true end
      local f2 = function(a, b) return true end
      local mt1 = { __eq = f1 }
      local mt2 = { __eq = f2 } -- different function object
      local a = setmetatable({}, mt1)
      local b = setmetatable({}, mt2)
      res = (a == b)
    `,
    env,
  );

  // Lua calls __eq even when the functions differ
  assertEquals(env.get("res"), true);
});

Deno.test("Statement evaluation", async () => {
  const env = new LuaEnv();
  env.set("test", new LuaNativeJSFunction((n) => n));
  env.set("asyncTest", new LuaNativeJSFunction((n) => Promise.resolve(n)));

  assertEquals(undefined, await evalBlock(`a = 3`, env));
  assertEquals(env.get("a"), 3);
  assertEquals(undefined, await evalBlock(`b = test(3)`, env));
  assertEquals(env.get("b"), 3);

  await evalBlock(`c = asyncTest(3)`, env);
  assertEquals(env.get("c"), 3);

  // Multiple assignments
  const env2 = new LuaEnv();
  assertEquals(undefined, await evalBlock(`a, b = 1, 2`, env2));
  assertEquals(env2.get("a"), 1);
  assertEquals(env2.get("b"), 2);

  // Other lvalues
  const env3 = new LuaEnv();
  await evalBlock(`tbl = {1, 2, 3}`, env3);
  await evalBlock(`tbl[1] = 3`, env3);
  assertEquals(luaValueToJS(env3.get("tbl"), sf), [3, 2, 3]);
  await evalBlock("tbl.name = 'Zef'", env3);
  assertEquals(env3.get("tbl").get("name"), "Zef");
  await evalBlock(`tbl[2] = {age=10}`, env3);
  await evalBlock(`tbl[2].age = 20`, env3);
  assertEquals(env3.get("tbl").get(2).get("age"), 20);

  // Blocks and scopes
  const env4 = new LuaEnv();
  env4.set("print", new LuaNativeJSFunction(console.log));
  await evalBlock(
    `
    a = 1
    do
        -- sets global a to 3
        a = 3
        print("The number is: " .. a)
    end`,
    env4,
  );
  assertEquals(env4.get("a"), 3);

  const env5 = new LuaEnv();
  env5.set("print", new LuaNativeJSFunction(console.log));

  await evalBlock(
    `
    a = 1
    if a > 0 then
        a = 3
    else
        a = 0
    end`,
    env5,
  );
  assertEquals(env5.get("a"), 3);

  await evalBlock(
    `
    if a < 0 then
        a = -1
    elseif a > 0 then
        a = 1
    else
        a = 0
    end`,
    env5,
  );
  assertEquals(env5.get("a"), 1);

  await evalBlock(
    `
        var = 1
        do
            local var
            var = 2
        end`,
    env5,
  );
  assertEquals(env5.get("var"), 1);

  // While loop
  const env6 = new LuaEnv();
  await evalBlock(
    `
        c = 0
        while true do
            c = c + 1
            if c == 3 then
                break
            end
        end
    `,
    env6,
  );
  assertEquals(env6.get("c"), 3);

  // Repeat loop
  const env7 = new LuaEnv();
  await evalBlock(
    `
        c = 0
        repeat
            c = c + 1
            if c == 3 then
                break
            end
        until false
    `,
    env7,
  );
  assertEquals(env7.get("c"), 3);

  // Function definition and calling
  const env8 = new LuaEnv();
  env8.set("print", new LuaNativeJSFunction(console.log));
  await evalBlock(
    `
        function test(a)
            return a + 1
        end
        print("3 + 1 = " .. test(3))
    `,
    env8,
  );

  // Local fucntion definition
  const env9 = new LuaEnv();
  env9.set("print", new LuaNativeJSFunction(console.log));
  await evalBlock(
    `
        local function test(a)
            return a + 1
        end
        print("3 + 1 = " .. test(3))
    `,
    env9,
  );

  // For loop over range
  const env10 = new LuaEnv();
  await evalBlock(
    `
        c = 0
        for i = 1, 3 do
            c = c + i
        end
    `,
    env10,
  );
  assertEquals(env10.get("c"), 6);

  // For loop over iterator
  const env11 = new LuaEnv(luaBuildStandardEnv());
  await evalBlock(
    `
      function fruits()
        local list = { "apple", "banana", "cherry" }
        -- Track index internally
        local index = 0

        return function()
            index = index + 1
            if list[index] then
                return list[index]
            end
        end
      end

      for fruit in fruits() do
        print("Fruit: " .. fruit)
      end
    `,
    env11,
  );

  await evalBlock(
    `
    for _, f in ipairs({ "apple", "banana", "cherry" }) do
      print("Fruit: " .. f)
    end`,
    luaBuildStandardEnv(),
  );

  // Passing a Lua function as callback to a JS function
  const env12 = new LuaEnv();
  env12.set(
    "runMe",
    new LuaNativeJSFunction((fn) => {
      return fn("Lua");
    }),
  );
  assertEquals(
    "Hello from Lua",
    await evalExpr(
      `runMe(function(name) return "Hello from " .. name end)`,
      env12,
    ),
  );
});

Deno.test("Thread local _CTX", async () => {
  const env = new LuaEnv();
  const threadLocal = new LuaEnv();
  threadLocal.setLocal("threadValue", "test123");

  const sf = new LuaStackFrame(threadLocal, null);

  await evalBlock(
    `
    function test()
      return _CTX.threadValue
    end
  `,
    env,
  );

  const result = await evalExpr("test()", env, sf);
  assertEquals(singleResult(result), "test123");
});

Deno.test("Thread local _CTX - advanced cases", async () => {
  // Create environment with standard library
  const env = new LuaEnv(luaBuildStandardEnv());
  const threadLocal = new LuaEnv();

  env.setLocal("globalEnv", "GLOBAL");

  // Set up some thread local values
  threadLocal.setLocal("user", "alice");
  threadLocal.setLocal("permissions", new LuaTable());
  threadLocal.get("permissions").set("admin", true);
  threadLocal.setLocal("data", {
    id: 123,
    settings: { theme: "dark" },
  });

  const sf = new LuaStackFrame(threadLocal, null);

  // Test 1: Nested function access
  await evalBlock(
    `
    function outer()
      local function inner()
        return _CTX.user
      end
      return inner()
    end
  `,
    env,
  );
  assertEquals(await evalExpr("outer()", env, sf), "alice");

  // Test 2: Table access and modification
  await evalBlock(
    `
    function checkAdmin()
      return _CTX.permissions.admin
    end

    function revokeAdmin()
      _CTX.permissions.admin = false
      return _CTX.permissions.admin
    end
  `,
    env,
  );
  assertEquals(await evalExpr("checkAdmin()", env, sf), true);
  assertEquals(await evalExpr("revokeAdmin()", env, sf), false);
  assertEquals(threadLocal.get("permissions").get("admin"), false);

  // Test 3: Complex data structures
  await evalBlock(
    `
    function getNestedData()
      return _CTX.data.settings.theme
    end
    
    function updateTheme(newTheme)
      _CTX.data.settings.theme = newTheme
      return _CTX.data.settings.theme
    end
    `,
    env,
  );
  assertEquals(await evalExpr("getNestedData()", env, sf), "dark");
  assertEquals(await evalExpr("updateTheme('light')", env, sf), "light");

  // Test 4: Multiple thread locals
  const threadLocal2 = new LuaEnv();
  threadLocal2.setLocal("user", "bob");
  const sf2 = new LuaStackFrame(threadLocal2, null);

  await evalBlock(
    `
    function getUser()
      return _CTX.user
    end
  `,
    env,
  );

  // Same function, different thread contexts
  assertEquals(await evalExpr("getUser()", env, sf), "alice");
  assertEquals(await evalExpr("getUser()", env, sf2), "bob");

  // Test 5: Async operations with _CTX
  env.set(
    "asyncOperation",
    new LuaNativeJSFunction(async () => {
      await new Promise((resolve) => setTimeout(resolve, 10));
      return "done";
    }),
  );

  await evalBlock(
    `
    function asyncTest()
      _CTX.status = "starting"
      local result = asyncOperation()
      _CTX.status = "completed"
      return _CTX.status
    end
  `,
    env,
  );

  assertEquals(await evalExpr("asyncTest()", env, sf), "completed");
  assertEquals(threadLocal.get("status"), "completed");

  // Test 6: Error handling with _CTX
  await evalBlock(
    `
    function errorTest()
      _CTX.error = nil
      local status, err = pcall(function()
        error("test error")
      end)
      _CTX.error = "caught"
      return _CTX.error
    end
  `,
    env,
  );

  assertEquals(await evalExpr("errorTest()", env, sf), "caught");
  assertEquals(threadLocal.get("error"), "caught");

  // Test string interpolation
  sf.threadLocal.setLocal("_GLOBAL", env);
  assertEquals(
    await evalExpr(
      "spacelua.interpolate('Hello, ${globalEnv} and ${loc}!', {loc='local'})",
      env,
      sf,
    ),
    "Hello, GLOBAL and local!",
  );

  // Some more complex string interpolation with more complex lua expressions, with nested {}
  assertEquals(
    await evalExpr(
      `spacelua.interpolate('Some JSON \${js.stringify(js.tojs({name="Pete"}))}!')`,
      env,
      sf,
    ),
    `Some JSON {"name":"Pete"}!`,
  );
});

Deno.test("Length: rawlen ignores __len", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());

  await evalBlock(
    `
      local t = {1,2,3}
      setmetatable(t, { __len = function() return 99 end })
      a = #t
      b = rawlen(t)
    `,
    env,
  );

  assertEquals(env.get("a"), 99);
  assertEquals(env.get("b"), 3);
});


================================================
FILE: client/space_lua/eval.ts
================================================
import type {
  ASTCtx,
  LuaBlock,
  LuaExpression,
  LuaLValue,
  LuaStatement,
  NumericType,
} from "./ast.ts";
import { LuaAttribute } from "./ast.ts";
import { evalPromiseValues } from "./util.ts";
import {
  getMetatable,
  type ILuaFunction,
  type ILuaGettable,
  isILuaFunction,
  jsToLuaValue,
  luaCall,
  luaCloseFromMark,
  luaEnsureCloseStack,
  LuaEnv,
  luaEquals,
  LuaFunction,
  luaGet,
  luaIndexValue,
  type LuaLValueContainer,
  luaMarkToBeClosed,
  LuaMultiRes,
  LuaRuntimeError,
  luaSet,
  type LuaStackFrame,
  LuaTable,
  luaTruthy,
  type LuaType,
  luaTypeName,
  luaTypeOf,
  type LuaValue,
  luaValueToJS,
  singleResult,
} from "./runtime.ts";
import {
  ArrayQueryCollection,
  type LuaCollectionQuery,
} from "./query_collection.ts";
import {
  coerceNumericPair,
  coerceToNumber,
  inferNumericType,
  isNegativeZero,
  isTaggedFloat,
  luaStringCoercionError,
  makeLuaFloat,
  makeLuaZero,
  normalizeArithmeticResult,
  toInteger,
  untagNumber,
} from "./numeric.ts";
import { isPromise, rpAll, rpThen } from "./rp.ts";
import {
  asAssignment,
  asBinary,
  asBlock,
  asFor,
  asForIn,
  asFunctionCall,
  asFunctionCallStmt,
  asFunctionDef,
  asFunctionStmt,
  asGoto,
  asIf,
  asLabel,
  asLocal,
  asLocalFunction,
  asLValuePropertyAccess,
  asLValueTableAccess,
  asLValueVariable,
  asParenthesized,
  asPropertyAccess,
  asQueryExpr,
  asRepeat,
  asReturn,
  asTableAccess,
  asTableConstructor,
  asUnary,
  asVariable,
  asWhile,
} from "./ast_narrow.ts";
import { getBlockGotoMeta } from "./labels.ts";

const astNumberKindCache = new WeakMap<LuaExpression, NumericType>();

function astNumberKind(e: LuaExpression | undefined): NumericType | undefined {
  if (!e) return undefined;

  const cached = astNumberKindCache.get(e);
  if (cached) return cached;

  let unwrapped = e;
  while (unwrapped.type === "Parenthesized") {
    unwrapped = unwrapped.expression;
  }

  let result: NumericType | undefined;

  if (unwrapped.type === "Unary" && unwrapped.operator === "-") {
    result = astNumberKind(unwrapped.argument);
  } else if (unwrapped.type === "Number") {
    result = unwrapped.numericType === "int" ? "int" : "float";
  } else if (unwrapped.type === "Binary") {
    const op = unwrapped.operator;
    const numericOp = op === "+" || op === "-" || op === "*" || op === "/" ||
      op === "//" || op === "%" || op === "^";

    if (numericOp) {
      const lk = astNumberKind(unwrapped.left);
      const rk = astNumberKind(unwrapped.right);

      if (lk === "float" || rk === "float") {
        result = "float";
      } else if (lk === "int" && rk === "int") {
        result = "int";
      } else {
        return undefined;
      }
    } else {
      return undefined;
    }
  } else {
    return undefined;
  }

  if (result !== undefined) {
    astNumberKindCache.set(e, result);
  }

  return result;
}

type GotoSignal = { ctrl: "goto"; target: string };
type ReturnSignal = { ctrl: "return"; values: LuaValue[] };
type BreakSignal = { ctrl: "break" };
type ControlSignal = GotoSignal | ReturnSignal | BreakSignal;

function isGotoSignal(v: any): v is GotoSignal {
  return !!v && typeof v === "object" && v.ctrl === "goto";
}

function isBreakSignal(v: any): v is BreakSignal {
  return !!v && typeof v === "object" && v.ctrl === "break";
}

function consumeGotoInBlock(
  res: any,
  labels: Map<string, number>,
): number | any | undefined {
  if (res === undefined) {
    return undefined;
  }
  if (isGotoSignal(res)) {
    const labelIdx = labels.get(res.target);
    if (labelIdx !== undefined) {
      return labelIdx + 1; // next statement
    }
  }
  return res;
}

function blockMetaOrThrow(
  block: LuaBlock,
  sf: LuaStackFrame,
): ReturnType<typeof getBlockGotoMeta> {
  try {
    return getBlockGotoMeta(block);
  } catch (e: any) {
    if (e && typeof e === "object" && "astCtx" in e) {
      throw new LuaRuntimeError(e.message, sf.withCtx((e as any).astCtx));
    }
    throw e;
  }
}

// Queryable guard to avoid `(collection as any).query` usage
type Queryable = {
  query: (
    q: LuaCollectionQuery,
    env: LuaEnv,
    sf: LuaStackFrame,
  ) => Promise<any>;
};
function isQueryable(x: unknown): x is Queryable {
  return !!x && typeof (x as any).query === "function";
}

function arithVerbFromOperator(op: string): string | null {
  switch (op) {
    case "+":
      return "add";
    case "-":
      return "sub";
    case "*":
      return "mul";
    case "/":
      return "div";
    case "//":
      return "idiv";
    case "%":
      return "mod";
    case "^":
      return "pow";
    default:
      return null;
  }
}

function isNumericBinaryOp(op: string): boolean {
  return (
    op === "+" ||
    op === "-" ||
    op === "*" ||
    op === "/" ||
    op === "//" ||
    op === "%" ||
    op === "^"
  );
}

function arithCoercionErrorOrThrow(
  op: string,
  left: any,
  right: any,
  ctx: ASTCtx,
  sf: LuaStackFrame,
  e: any,
): never {
  if (e === luaStringCoercionError) {
    const mapped = maybeLuaArithStringError(op, left, right, ctx, sf);
    if (mapped) {
      throw mapped;
    }
    throw new LuaRuntimeError(
      "attempt to perform arithmetic on a string value",
      sf.withCtx(ctx),
    );
  }

  const mapped = maybeLuaArithStringError(op, left, right, ctx, sf);
  if (mapped) {
    throw mapped;
  }

  throw e;
}

function luaOp(
  op: string,
  left: any,
  right: any,
  leftType: NumericType | undefined,
  rightType: NumericType | undefined,
  ctx: ASTCtx,
  sf: LuaStackFrame,
): any {
  switch (op) {
    case "+":
    case "-":
    case "*":
    case "/":
    case "^": {
      const ar = numericArith[op as NumericArithOp];
      try {
        const { left: l, right: r, resultType } = coerceNumericPair(
          left,
          right,
          leftType,
          rightType,
          op,
        );

        let result = ar.f(l, r);

        if (
          ar.special === "sub" &&
          result === 0 &&
          isNegativeZero(result) &&
          resultType === "float"
        ) {
          const rhsIsIntZero = r === 0 && rightType === "int";
          if (rhsIsIntZero) {
            result = 0;
          }
        }

        const normalized = normalizeArithmeticResult(result, resultType);

        // Operators `/` and `^` produce float, wrap only if needed.
        if (op === "/" || op === "^") {
          if (normalized === 0) {
            return makeLuaZero(normalized, "float");
          }
          if (!Number.isFinite(normalized)) {
            return normalized;
          }
          if (!Number.isInteger(normalized)) {
            return normalized;
          }
          return makeLuaFloat(normalized);
        }

        if (normalized === 0) {
          return makeLuaZero(normalized, resultType);
        }
        if (resultType === "float" && Number.isInteger(normalized)) {
          return makeLuaFloat(normalized);
        }
        return normalized;
      } catch (e: any) {
        const meta = evalMetamethod(left, right, ar.metaMethod, ctx, sf);
        if (meta !== undefined) {
          return meta;
        }
        return arithCoercionErrorOrThrow(op, left, right, ctx, sf, e);
      }
    }
    case "..": {
      try {
        const coerce = (v: any): string => {
          if (v === null || v === undefined) {
            throw new LuaRuntimeError(
              "attempt to concatenate a nil value",
              sf.withCtx(ctx),
            );
          }
          if (typeof v === "string") {
            return v as string;
          }
          if (typeof v === "number") {
            return String(v);
          }
          if (isTaggedFloat(v)) {
            return String(v.value);
          }
          const t = luaTypeName(v);
          throw new LuaRuntimeError(
            `attempt to concatenate a ${t} value`,
            sf.withCtx(ctx),
          );
        };
        return coerce(left) + coerce(right);
      } catch (e: any) {
        const meta = evalMetamethod(left, right, "__concat", ctx, sf);
        if (meta !== undefined) {
          return meta;
        }
        throw e;
      }
    }
    case "==": {
      if (luaEquals(left, right)) return true;
      return luaEqWithMetamethod(left, right, ctx, sf);
    }
    case "~=":
    case "!=": {
      if (luaEquals(left, right)) {
        return false;
      }
      return !luaEqWithMetamethod(left, right, ctx, sf);
    }
    case "<": {
      return luaRelWithMetamethod("<", left, right, ctx, sf);
    }
    case "<=": {
      return luaRelWithMetamethod("<=", left, right, ctx, sf);
    }
    // Lua: `a>b` is `b<a`, `a>=b` is `b<=a`
    case ">": {
      return luaRelWithMetamethod("<", right, left, ctx, sf);
    }
    case ">=": {
      return luaRelWithMetamethod("<=", right, left, ctx, sf);
    }
  }

  // Remaining operators: //, %, bitwise
  const handler = operatorsMetaMethods[op];
  if (!handler) {
    throw new LuaRuntimeError(`Unknown operator ${op}`, sf.withCtx(ctx));
  }

  try {
    return handler.nativeImplementation(
      left,
      right,
      leftType,
      rightType,
      ctx,
      sf,
    );
  } catch (e: any) {
    if (handler.metaMethod) {
      const meta = evalMetamethod(left, right, handler.metaMethod, ctx, sf);
      if (meta !== undefined) {
        return meta;
      }
    }
    return arithCoercionErrorOrThrow(op, left, right, ctx, sf, e);
  }
}

type NumericArithOp = "+" | "-" | "*" | "/" | "^";

const numericArith: Record<NumericArithOp, {
  metaMethod: "__add" | "__sub" | "__mul" | "__div" | "__pow";
  f: (l: number, r: number) => number;
  special?: "sub";
}> = {
  "+": { metaMethod: "__add", f: (l, r) => l + r },
  "-": { metaMethod: "__sub", f: (l, r) => l - r, special: "sub" },
  "*": { metaMethod: "__mul", f: (l, r) => l * r },
  "/": { metaMethod: "__div", f: (l, r) => l / r },
  "^": { metaMethod: "__pow", f: (l, r) => l ** r },
};

function maybeLuaArithStringError(
  op: string,
  a: any,
  b: any,
  ctx: ASTCtx,
  sf: LuaStackFrame,
): LuaRuntimeError | null {
  const verb = arithVerbFromOperator(op);
  if (!verb) {
    return null;
  }

  const ta = luaTypeName(a);
  const tb = luaTypeName(b);

  if (ta === "string" || tb === "string") {
    return new LuaRuntimeError(
      `attempt to ${verb} a '${ta}' with a '${tb}'`,
      sf.withCtx(ctx),
    );
  }

  return null;
}

function luaFloorDiv(
  a: unknown,
  b: unknown,
  leftType: NumericType | undefined,
  rightType: NumericType | undefined,
  ctx: ASTCtx,
  sf: LuaStackFrame,
): any {
  const { left, right, resultType } = coerceNumericPair(
    a,
    b,
    leftType,
    rightType,
    "//",
  );

  if (resultType === "int" && right === 0) {
    throw new LuaRuntimeError(
      `attempt to divide by zero`,
      sf.withCtx(ctx),
    );
  }

  const result = Math.floor(left / right);
  const normalized = normalizeArithmeticResult(result, resultType);
  if (normalized === 0) {
    return makeLuaZero(normalized, resultType);
  }
  if (resultType === "float" && Number.isInteger(normalized)) {
    return makeLuaFloat(normalized);
  }
  return normalized;
}

function luaMod(
  a: unknown,
  b: unknown,
  leftType: NumericType | undefined,
  rightType: NumericType | undefined,
  ctx: ASTCtx,
  sf: LuaStackFrame,
): any {
  const { left, right, resultType } = coerceNumericPair(
    a,
    b,
    leftType,
    rightType,
    "%",
  );

  if (resultType === "int" && right === 0) {
    throw new LuaRuntimeError(
      `attempt to perform 'n%0'`,
      sf.withCtx(ctx),
    );
  }

  const q = Math.floor(left / right);
  const result = left - q * right;

  // Preserve -0.0 from left operand in float mode
  if (result === 0 && resultType === "float" && isNegativeZero(left)) {
    return makeLuaZero(-0, "float");
  }

  const normalized = normalizeArithmeticResult(result, resultType);
  if (normalized === 0) {
    return makeLuaZero(normalized, resultType);
  }
  if (resultType === "float" && Number.isInteger(normalized)) {
    return makeLuaFloat(normalized);
  }
  return normalized;
}

function luaUnaryMinus(
  v: number,
  numType: NumericType | undefined,
): number {
  const vType = numType ?? inferNumericType(v);

  if (v === 0 && vType === "int") {
    return 0;
  }

  if (v === 0 && vType === "float") {
    return isNegativeZero(v) ? 0 : -0;
  }

  return -v;
}

const operatorsMetaMethods: Record<string, {
  metaMethod?: string;
  nativeImplementation: (
    a: LuaValue,
    b: LuaValue,
    leftType: NumericType | undefined,
    rightType: NumericType | undefined,
    ctx: ASTCtx,
    sf: LuaStackFrame,
  ) => LuaValue;
}> = {
  "//": {
    metaMethod: "__idiv",
    nativeImplementation: (a, b, lt, rt, ctx, sf) =>
      luaFloorDiv(a, b, lt, rt, ctx, sf),
  },
  "%": {
    metaMethod: "__mod",
    nativeImplementation: (a, b, lt, rt, ctx, sf) =>
      luaMod(a, b, lt, rt, ctx, sf),
  },
  "&": {
    metaMethod: "__band",
    nativeImplementation: (a, b, _lt, _rt, ctx, sf) => {
      const aInt = toInteger(a);
      const bInt = toInteger(b);
      if (aInt === null) throw createBitwiseError(a, ctx, sf);
      if (bInt === null) throw createBitwiseError(b, ctx, sf);
      return aInt & bInt;
    },
  },
  "|": {
    metaMethod: "__bor",
    nativeImplementation: (a, b, _lt, _rt, ctx, sf) => {
      const aInt = toInteger(a);
      const bInt = toInteger(b);
      if (aInt === null) throw createBitwiseError(a, ctx, sf);
      if (bInt === null) throw createBitwiseError(b, ctx, sf);
      return aInt | bInt;
    },
  },
  "~": {
    metaMethod: "__bxor",
    nativeImplementation: (a, b, _lt, _rt, ctx, sf) => {
      const aInt = toInteger(a);
      const bInt = toInteger(b);
      if (aInt === null) throw createBitwiseError(a, ctx, sf);
      if (bInt === null) throw createBitwiseError(b, ctx, sf);
      return aInt ^ bInt;
    },
  },
  "<<": {
    metaMethod: "__shl",
    nativeImplementation: (a, b, _lt, _rt, ctx, sf) => {
      const aInt = toInteger(a);
      const bInt = toInteger(b);
      if (aInt === null) throw createBitwiseError(a, ctx, sf);
      if (bInt === null) throw createBitwiseError(b, ctx, sf);
      return aInt << bInt;
    },
  },
  ">>": {
    metaMethod: "__shr",
    nativeImplementation: (a, b, _lt, _rt, ctx, sf) => {
      const aInt = toInteger(a);
      const bInt = toInteger(b);
      if (aInt === null) throw createBitwiseError(a, ctx, sf);
      if (bInt === null) throw createBitwiseError(b, ctx, sf);
      return aInt >> bInt;
    },
  },
};

export function evalExpression(
  e: LuaExpression,
  env: LuaEnv,
  sf: LuaStackFrame,
): Promise<LuaValue> | LuaValue {
  try {
    switch (e.type) {
      case "String": {
        return e.value;
      }
      case "Number": {
        if (e.value === 0) {
          return makeLuaZero(e.value, e.numericType);
        }
        if (e.numericType === "float" && Number.isInteger(e.value)) {
          return makeLuaFloat(e.value);
        }
        return e.value;
      }
      case "Boolean": {
        return e.value;
      }
      case "Nil": {
        return null;
      }
      case "Binary": {
        const b = asBinary(e);
        if (b.operator === "or") {
          return evalLogical("or", b.left, b.right, env, sf);
        }
        if (b.operator === "and") {
          return evalLogical("and", b.left, b.right, env, sf);
        }
        return evalBinaryWithLR(
          b.operator,
          b.left,
          b.right,
          b.ctx,
          env,
          sf,
        );
      }
      case "Unary": {
        const u = asUnary(e);

        // Fast path: negation of numeric literal
        if (u.operator === "-" && u.argument.type === "Number") {
          const num = u.argument;
          if (num.value === 0) {
            const z = num.numericType === "int" ? 0 : -0;
            return makeLuaZero(z, num.numericType);
          }
          if (num.numericType === "float" && Number.isInteger(num.value)) {
            return makeLuaFloat(-num.value);
          }
          return -num.value;
        }

        if (u.operator === "-") {
          const tv = evalExprWithNumericType(u.argument, env, sf, true);

          const applyTyped = (typed: TypedValue) => {
            const arg = singleResult(typed.value);

            return unaryWithMeta(
              arg,
              "__unm",
              u.ctx,
              sf,
              () => {
                // Numeric-string coercion for unary minus
                if (typeof arg === "string") {
                  const n = coerceToNumber(arg);
                  if (n === null) {
                    throw new LuaRuntimeError(
                      "attempt to unm a 'string' with a 'string'",
                      sf.withCtx(u.ctx),
                    );
                  }
                  if (n === 0) {
                    return 0;
                  }
                  return -n;
                }

                const plain = untagNumber(arg);
                if (typeof plain !== "number") {
                  throw new LuaRuntimeError(
                    "attempt to perform arithmetic on a table value",
                    sf.withCtx(u.ctx),
                  );
                }

                const argType = isTaggedFloat(arg)
                  ? "float"
                  : astNumberKind(u.argument);

                const out = luaUnaryMinus(plain, argType);

                // If the operand is a float-tagged boxed number, unary
                // minus must keep the result float-typed.
                if (isTaggedFloat(arg)) {
                  if (out === 0) {
                    return makeLuaZero(out, "float");
                  }
                  return makeLuaFloat(out);
                }

                // Preserve numeric kind for zero results
                if (out === 0) {
                  const outType = argType ?? inferNumericType(plain);
                  return makeLuaZero(out, outType);
                }

                return out;
              },
            );
          };

          return rpThen(tv as any, applyTyped);
        }

        const value = evalExpression(u.argument, env, sf);

        const applyUnary = (value: LuaValue) => {
          switch (u.operator) {
            case "not": {
              return !luaTruthy(value);
            }
            case "~": {
              const arg = singleResult(value);
              return unaryWithMeta(
                arg,
                "__bnot",
                u.ctx,
                sf,
                () => {
                  const intVal = toInteger(arg);
                  if (intVal === null) {
                    if (typeof arg === "string") {
                      throw new LuaRuntimeError(
                        `attempt to perform bitwise operation on a string value (constant '${arg}')`,
                        sf.withCtx(u.ctx),
                      );
                    }
                    const t = luaTypeName(arg);
                    if (t === "number") {
                      throw new LuaRuntimeError(
                        `number has no integer representation`,
                        sf.withCtx(u.ctx),
                      );
                    }
                    throw new LuaRuntimeError(
                      `attempt to perform bitwise operation on a ${t} value`,
                      sf.withCtx(u.ctx),
                    );
                  }
                  return ~intVal;
                },
              );
            }
            case "#": {
              return luaLengthOp(singleResult(value), u.ctx, sf);
            }
            default: {
              throw new LuaRuntimeError(
                `Unknown unary operator ${u.operator}`,
                sf.withCtx(u.ctx),
              );
            }
          }
        };

        return rpThen(value, applyUnary);
      }
      case "Variable":
      case "FunctionCall":
      case "TableAccess":
      case "PropertyAccess": {
        return evalPrefixExpression(e, env, sf);
      }
      case "TableConstructor": {
        const tc = asTableConstructor(e);
        return Promise.resolve().then(async () => {
          const table = new LuaTable();
          // Expression fields assign consecutive integer keys starting
          // at 1 and advance even when the value is `nil`.
          let nextArrayIndex = 1;
          for (const field of tc.fields) {
            switch (field.type) {
              case "PropField": {
                const value = await evalExpression(field.value, env, sf);
                table.set(field.key, singleResult(value), sf);
                break;
              }
              case "DynamicField": {
                const key = await evalExpression(field.key, env, sf);
                const value = await evalExpression(field.value, env, sf);
                table.set(singleResult(key), singleResult(value), sf);
                break;
              }
              case "ExpressionField": {
                const value = await evalExpression(field.value, env, sf);

                if (value instanceof LuaMultiRes) {
                  const flat = value.flatten();
                  for (let i = 0; i < flat.values.length; i++) {
                    table.rawSetArrayIndex(nextArrayIndex, flat.values[i]);
                    nextArrayIndex++;
                  }
                } else {
                  table.rawSetArrayIndex(nextArrayIndex, singleResult(value));
                  nextArrayIndex++;
                }
                break;
              }
            }
          }
          return table;
        });
      }
      case "FunctionDefinition": {
        const fd = asFunctionDef(e);
        return new LuaFunction(fd.body, env);
      }
      case "Query": {
        const q = asQueryExpr(e);
        const findFromClause = q.clauses.find((c) => c.type === "From");
        if (!findFromClause) {
          throw new LuaRuntimeError(
            "No from clause found",
            sf.withCtx(q.ctx),
          );
        }
        const objectVariable = findFromClause.name;
        const objectExpression = findFromClause.expression;
        return Promise.resolve(evalExpression(objectExpression, env, sf)).then(
          async (collection: LuaValue) => {
            if (!collection) {
              throw new LuaRuntimeError(
                "Collection is nil",
                sf.withCtx(q.ctx),
              );
            }
            if (collection instanceof LuaTable && collection.empty()) {
              // Make sure we're converting an empty result to an array to "query"
              collection = [];
            } else {
              collection = luaValueToJS(collection, sf);
            }
            // Check if collection is a queryable collection
            if (!isQueryable(collection)) {
              if (!Array.isArray(collection)) {
                throw new LuaRuntimeError(
                  "Collection does not support query",
                  sf.withCtx(q.ctx),
                );
              }
              collection = new ArrayQueryCollection(collection);
            }
            // Build up query object
            const query: LuaCollectionQuery = {
              objectVariable,
              distinct: true,
            };

            // Map clauses to query parameters
            for (const clause of q.clauses) {
              switch (clause.type) {
                case "Where": {
                  query.where = clause.expression;
                  break;
                }
                case "OrderBy": {
                  query.orderBy = clause.orderBy.map((o) => ({
                    expr: o.expression,
                    desc: o.direction === "desc",
                  }));
                  break;
                }
                case "Select": {
                  query.select = clause.expression;
                  break;
                }
                case "Limit": {
                  const limitVal = await evalExpression(clause.limit, env, sf);
                  query.limit = Number(limitVal);
                  if (clause.offset) {
                    const offsetVal = await evalExpression(
                      clause.offset,
                      env,
                      sf,
                    );
                    query.offset = Number(offsetVal);
                  }
                  break;
                }
              }
            }

            return (collection as Queryable).query(query, env, sf).then(
              jsToLuaValue,
            );
          },
        );
      }
      default:
        throw new LuaRuntimeError(
          `Unknown expression type ${e.type}`,
          sf.withCtx(e.ctx),
        );
    }
  } catch (err: any) {
    // Repackage any non Lua-specific exceptions with some position information
    if (!err.constructor.name.startsWith("Lua")) {
      throw new LuaRuntimeError(err.message, sf.withCtx(e.ctx), err);
    } else {
      throw err;
    }
  }
}

function evalPrefixExpression(
  e: LuaExpression,
  env: LuaEnv,
  sf: LuaStackFrame,
): Promise<LuaValue> | LuaValue {
  switch (e.type) {
    case "Variable": {
      const v = asVariable(e);
      const value = env.get(v.name);
      if (value === undefined) {
        return null;
      }
      return value;
    }

    case "Parenthesized": {
      const p = asParenthesized(e);
      return evalExpression(p.expression, env, sf);
    }

    // <<expr>>[<<expr>>]
    case "TableAccess": {
      const ta = asTableAccess(e);
      // Sync-first: evaluate object and key without allocating Promise when both are sync.
      const objV = evalPrefixExpression(ta.object, env, sf);
      const keyV = evalExpression(ta.key, env, sf);

      if (!isPromise(objV) && !isPromise(keyV)) {
        const table = singleResult(objV);
        const key = singleResult(keyV);
        return luaGet(table, key, ta.ctx, sf);
      }

      return rpThen(
        objV,
        (obj) =>
          rpThen(
            keyV,
            (key) => luaGet(singleResult(obj), singleResult(key), ta.ctx, sf),
          ),
      );
    }

    // <expr>.property
    case "PropertyAccess": {
      const pa = asPropertyAccess(e);
      // Sync-first: evaluate object; avoid Promise when object is sync.
      const objV = evalPrefixExpression(pa.object, env, sf);
      if (!isPromise(objV)) {
        return luaGet(objV, pa.property, pa.ctx, sf);
      }
      return rpThen(objV, (obj) => luaGet(obj, pa.property, pa.ctx, sf));
    }

    case "FunctionCall": {
      const fc = asFunctionCall(e);
      const prefixValue = evalPrefixExpression(fc.prefix, env, sf);
      if (prefixValue === null || prefixValue === undefined) {
        const nilMsg = fc.prefix.type === "Variable"
          ? `attempt to call a nil value (global '${
            asVariable(fc.prefix).name
          }')`
          : `attempt to call a nil value`;
        throw new LuaRuntimeError(
          nilMsg,
          sf.withCtx(fc.prefix.ctx),
        );
      }

      let selfArgs: LuaValue[] = [];

      const handleFunctionCall = (
        calleeVal: LuaValue,
      ): LuaValue | Promise<LuaValue> => {
        // Normal argument handling for hello:there(a, b, c) type calls
        if (fc.name) {
          selfArgs = [calleeVal];
          calleeVal = luaIndexValue(calleeVal, fc.name, sf);

          if (isPromise(calleeVal)) {
            return (calleeVal as Promise<any>).then(handleFunctionCall);
          }
        }

        const argsVal = evalExpressions(fc.args, env, sf);

        const thenCall = (args: LuaValue[]) =>
          luaCall(calleeVal, [...selfArgs, ...args], fc.ctx, sf);

        return rpThen(argsVal, thenCall);
      };

      return rpThen(prefixValue, handleFunctionCall);
    }

    default: {
      throw new LuaRuntimeError(
        `Unknown prefix expression type ${e.type}`,
        sf.withCtx(e.ctx),
      );
    }
  }
}

// Helper functions to reduce duplication
function evalMetamethod(
  left: any,
  right: any,
  metaMethod: string,
  ctx: ASTCtx,
  sf: LuaStackFrame,
): LuaValue | undefined {
  const leftMetatable = getMetatable(left, sf);
  if (leftMetatable) {
    const fn = leftMetatable.rawGet(metaMethod);
    if (!(fn === undefined || fn === null)) {
      return luaCall(fn, [left, right], ctx, sf);
    }
  }

  const rightMetatable = getMetatable(right, sf);
  if (rightMetatable) {
    const fn = rightMetatable.rawGet(metaMethod);
    if (!(fn === undefined || fn === null)) {
      return luaCall(fn, [left, right], ctx, sf);
    }
  }
}

// Unary metamethod lookup and call
function evalUnaryMetamethod(
  value: any,
  metaMethod: "__unm" | "__bnot",
  ctx: ASTCtx,
  sf: LuaStackFrame,
): LuaValue | Promise<LuaValue> | undefined {
  const mt = getMetatable(value, sf);
  if (!mt) {
    return undefined;
  }
  const fn = mt.rawGet(metaMethod);
  if (fn === undefined || fn === null) {
    return undefined;
  }
  return luaCall(fn, [value], ctx, sf);
}

// Unary metamethod handling (with fallback)
function unaryWithMeta(
  arg: any,
  meta: "__unm" | "__bnot",
  ctx: ASTCtx,
  sf: LuaStackFrame,
  fallback: () => any,
): any {
  const mm = evalUnaryMetamethod(arg, meta, ctx, sf);

  if (mm !== undefined) {
    return isPromise(mm)
      ? (mm as Promise<any>).then(singleResult)
      : singleResult(mm);
  }
  return fallback();
}

// Logical short-circuit evaluation
function evalLogical(
  op: "and" | "or",
  leftExpr: LuaExpression,
  rightExpr: LuaExpression,
  env: LuaEnv,
  sf: LuaStackFrame,
): any {
  const left = evalExpression(leftExpr, env, sf);

  const decide = (lv: any) => {
    if (op === "or") {
      if (luaTruthy(lv)) {
        return singleResult(lv);
      }
      const rv = evalExpression(rightExpr, env, sf);
      return isPromise(rv)
        ? (rv as Promise<any>).then(singleResult)
        : singleResult(rv);
    }
    if (!luaTruthy(lv)) {
      return singleResult(lv);
    }
    const rv = evalExpression(rightExpr, env, sf);
    return isPromise(rv)
      ? (rv as Promise<any>).then(singleResult)
      : singleResult(rv);
  };

  if (isPromise(left)) {
    return (left as Promise<any>).then(decide);
  }
  return decide(left);
}

type TypedValue = { value: LuaValue };

function evalExprWithNumericType(
  expr: LuaExpression,
  env: LuaEnv,
  sf: LuaStackFrame,
  _wantNumericType: boolean,
): TypedValue | Promise<TypedValue> {
  const v = evalExpression(expr, env, sf);
  const apply = (vv: any): TypedValue => ({ value: vv });
  return rpThen(v, apply) as any;
}

function getSimpleLiteralType(expr: LuaExpression): NumericType | undefined {
  if (expr.type === "Number") {
    return expr.numericType === "int" ? "int" : "float";
  }
  if (
    expr.type === "Unary" &&
    (expr.operator === "+" || expr.operator === "-") &&
    expr.argument.type === "Number"
  ) {
    return expr.argument.numericType === "int" ? "int" : "float";
  }
  return undefined;
}

function evalBinaryWithLR(
  op: string,
  leftExpr: LuaExpression,
  rightExpr: LuaExpression,
  ctx: ASTCtx,
  env: LuaEnv,
  sf: LuaStackFrame,
): any {
  const wantNumericType = isNumericBinaryOp(op);
  const leftType = wantNumericType ? getSimpleLiteralType(leftExpr) : undefined;
  const rightType = wantNumericType
    ? getSimpleLiteralType(rightExpr)
    : undefined;
  const leftVal = evalExpression(leftExpr, env, sf);

  const applyLeft = (lv: any) => {
    const rightVal = evalExpression(rightExpr, env, sf);
    const applyRight = (rv: any) => {
      return luaOp(
        op,
        singleResult(lv),
        singleResult(rv),
        leftType,
        rightType,
        ctx,
        sf,
      );
    };
    return rpThen(rightVal, applyRight);
  };
  return rpThen(leftVal, applyLeft);
}

function createBitwiseError(
  val: any,
  ctx: ASTCtx,
  sf: LuaStackFrame,
): LuaRuntimeError {
  if (typeof val === "string") {
    return new LuaRuntimeError(
      `attempt to perform bitwise operation on a string value (constant '${val}')`,
      sf.withCtx(ctx),
    );
  }
  const t = luaTypeName(val);
  if (t === "number") {
    return new LuaRuntimeError(
      `number has no integer representation`,
      sf.withCtx(ctx),
    );
  }
  return new LuaRuntimeError(
    `attempt to perform bitwise operation on a ${t} value`,
    sf.withCtx(ctx),
  );
}

function getBinaryMM(
  a: any,
  b: any,
  mmName: string,
  sf: LuaStackFrame,
): any | null {
  // Look in a's metatable first; if absent, look in b's.
  const ma = getMetatable(a, sf);
  if (ma) {
    const mmA = ma.rawGet(mmName);
    if (!(mmA === undefined || mmA === null)) {
      return mmA;
    }
  }
  const mb = getMetatable(b, sf);
  if (mb) {
    const mmB = mb.rawGet(mmName);
    if (!(mmB === undefined || mmB === null)) {
      return mmB;
    }
  }
  return null;
}

function luaEqWithMetamethod(
  a: any,
  b: any,
  ctx: ASTCtx,
  sf: LuaStackFrame,
): boolean | Promise<boolean> {
  if (luaEquals(a, b)) {
    return true;
  }

  const ta = luaTypeName(a);
  const tb = luaTypeName(b);

  // __eq only applies to tables/userdata
  const aOk = ta === "table" || ta === "userdata";
  const bOk = tb === "table" || tb === "userdata";
  if (!aOk || !bOk) {
    return false;
  }

  const getEqMM = (obj: any): any | null => {
    const mt = getMetatable(obj, sf);
    if (!mt) return null;

    const mm = mt.rawGet("__eq");
    if (mm === undefined || mm === null) return null;

    if (typeof mm === "function" || isILuaFunction(mm)) {
      return mm;
    }

    const ty = luaTypeName(mm);
    throw new LuaRuntimeError(
      `attempt to call a ${ty} value`,
      sf.withCtx(ctx),
    );
  };

  // Try left __eq first, then right.
  const mm = getEqMM(a) ?? getEqMM(b);
  if (!mm) {
    return false;
  }

  const r = luaCall(mm, [a, b], ctx, sf);
  return isPromise(r)
    ? (r as Promise<any>).then((v) => !!singleResult(v))
    : !!singleResult(r);
}

function luaRelWithMetamethod(
  op: "<" | "<=",
  a: any,
  b: any,
  ctx: ASTCtx,
  sf: LuaStackFrame,
): boolean | Promise<boolean> {
  const an = isTaggedFloat(a) ? a.value : a;
  const bn = isTaggedFloat(b) ? b.value : b;

  if (typeof an === "number" && typeof bn === "number") {
    return op === "<" ? an < bn : an <= bn;
  }
  if (typeof an === "string" && typeof bn === "string") {
    return op === "<" ? an < bn : an <= bn;
  }

  const mmName = op === "<" ? "__lt" : "__le";
  const mm = getBinaryMM(a, b, mmName, sf);
  if (mm) {
    const r = luaCall(mm, [a, b], ctx, sf);
    if (isPromise(r)) {
      return (r as Promise<any>).then((v) => !!singleResult(v));
    }
    return !!singleResult(r);
  }

  throw new LuaRuntimeError(
    `attempt to compare ${luaTypeName(a)} with ${luaTypeName(b)}`,
    sf.withCtx(ctx),
  );
}

/**
 * Length operator:
 * - for strings return byte length, ignore `__len`,
 * - for Lua tables if metatable has `__len` metamethod then call it;
 *   use table length otherwise,
 * - for other values (userdata): honor `__len` if present,
 * - for JavaScript arrays return length,
 * - throw error otherwise.
 */
function luaLengthOp(
  val: any,
  ctx: ASTCtx,
  sf: LuaStackFrame,
): LuaValue {
  // Strings: ignore `__len`
  if (typeof val === "string") {
    return val.length;
  }

  // Tables: prefer metatable `__len` to raw length
  if (val instanceof LuaTable) {
    const mt = getMetatable(val, sf);
    if (mt) {
      const fn = mt.rawGet("__len");
      if (!(fn === undefined || fn === null)) {
        return luaCall(fn, [val], ctx, sf);
      }
    }
    return val.length;
  }

  // Other values: allow metatable `__len` first
  {
    const mt = getMetatable(val, sf);
    if (mt) {
      const fn = mt.rawGet("__len");
      if (!(fn === undefined || fn === null)) {
        return luaCall(fn, [val], ctx, sf);
      }
    }
  }

  // JS arrays (interop): length if no `__len` override
  if (Array.isArray(val)) {
    return val.length;
  }

  // Otherwise error with type
  const t = luaTypeOf(val) as LuaType;
  throw new LuaRuntimeError(
    `attempt to get length of a ${t} value`,
    sf.withCtx(ctx),
  );
}

function evalExpressions(
  es: LuaExpression[],
  env: LuaEnv,
  sf: LuaStackFrame,
): Promise<LuaValue[]> | LuaValue[] {
  // Evaluate all arguments first (sync-first); do not allocate a Promise if all are sync.
  const parts = es.map((arg) => evalExpression(arg, env, sf));
  const argsVal = rpAll(parts);

  // In Lua multi-returns propagate only in tail position of an expression list.
  const finalize = (argsResolved: any[]) => {
    if (argsResolved.length === 0) {
      return [];
    }
    const out: LuaValue[] = [];
    // All but last expression produce a single value
    for (let i = 0; i < argsResolved.length - 1; i++) {
      out.push(singleResult(argsResolved[i]));
    }
    // Last expression preserves multiple results
    const last = argsResolved[argsResolved.length - 1];
    if (last instanceof LuaMultiRes) {
      out.push(...last.flatten().values);
    } else {
      out.push(singleResult(last));
    }
    return out;
  };

  return rpThen(argsVal, finalize);
}

type EvalBlockResult =
  | void
  | ControlSignal
  | Promise<void | ControlSignal>;

function runStatementsNoGoto(
  stmts: LuaStatement[],
  execEnv: LuaEnv,
  sf: LuaStackFrame,
  returnOnReturn: boolean,
  startIdx: number,
): void | ControlSignal | Promise<void | ControlSignal> {
  const processFrom = (
    idx: number,
  ): void | ControlSignal | Promise<void | ControlSignal> => {
    for (let i = idx; i < stmts.length; i++) {
      const result = evalStatement(
        stmts[i],
        execEnv,
        sf,
        returnOnReturn,
      );
      if (isPromise(result)) {
        return (result as Promise<any>).then((res) => {
          if (res !== undefined) {
            if (isGotoSignal(res)) {
              throw new LuaRuntimeError(
                "unexpected goto signal",
                sf.withCtx(stmts[i].ctx),
              );
            }
            return res;
          }
          return processFrom(i + 1);
        });
      }
      if (result !== undefined) {
        if (isGotoSignal(result)) {
          throw new LuaRuntimeError(
            "unexpected goto signal",
            sf.withCtx(stmts[i].ctx),
          );
        }
        return result;
      }
    }
    return;
  };

  return processFrom(startIdx);
}

function withCloseBoundary(
  sf: LuaStackFrame,
  mark: number,
  out: EvalBlockResult,
): EvalBlockResult {
  if (!isPromise(out)) {
    const r = luaCloseFromMark(sf, mark, null);
    if (isPromise(r)) {
      return (r as Promise<void>).then(() => out as any);
    }
    return out;
  }

  const p = out as Promise<any>;

  const onFulfilled = (res: any) => {
    const r = luaCloseFromMark(sf, mark, null);
    return isPromise(r) ? (r as Promise<void>).then(() => res) : res;
  };

  const onRejected = (e: any) => {
    const errObj: LuaValue = e instanceof LuaRuntimeError
      ? e.message
      : (e?.message ?? String(e));
    const r = luaCloseFromMark(sf, mark, errObj);
    if (isPromise(r)) {
      return (r as Promise<void>).then(() => {
        throw e;
      });
    }
    throw e;
  };

  return p.then(onFulfilled, onRejected);
}

function evalBlockNoClose(
  b: LuaBlock,
  env: LuaEnv,
  sf: LuaStackFrame,
  returnOnReturn: boolean,
): EvalBlockResult {
  const hasGotoFlag = b.hasGoto === true;
  const hasLabelFlag = b.hasLabel === true;
  const hasLabelHere = b.hasLabelHere === true;

  const curFn = sf.currentFunction;
  const fnHasGotos = curFn?.funcHasGotos;

  if (fnHasGotos === false || (!hasGotoFlag && !hasLabelFlag)) {
    const dup = b.dupLabelError;
    if (dup) {
      // Duplicated labels detected by parser.
      throw new LuaRuntimeError(
        `label '${dup.name}' already defined`,
        sf.withCtx(dup.ctx),
      );
    }

    const execEnv = b.needsEnv === true ? new LuaEnv(env) : env;
    return runStatementsNoGoto(b.statements, execEnv, sf, returnOnReturn, 0);
  }

  if (fnHasGotos === true && !hasLabelHere && !hasGotoFlag) {
    const execEnv = b.needsEnv === true ? new LuaEnv(env) : env;
    const stmts = b.statements;
    const runFrom = (
      i: number,
    ): EvalBlockResult => {
      for (; i < stmts.length; i++) {
        const r = evalStatement(stmts[i], execEnv, sf, returnOnReturn);
        if (isPromise(r)) {
          return (r as Promise<any>).then((res) => {
            if (isGotoSignal(res)) return res;
            if (res !== undefined) return res;
            return runFrom(i + 1);
          });
        }
        if (isGotoSignal(r)) return r;
        if (r !== undefined) return r;
      }
      return;
    };
    return runFrom(0);
  }

  let meta: ReturnType<typeof getBlockGotoMeta> | undefined;
  if (fnHasGotos === undefined && (hasGotoFlag || hasLabelFlag)) {
    meta = blockMetaOrThrow(b, sf);
    if (curFn) {
      curFn.funcHasGotos = !!meta?.funcHasGotos;
    }
  } else if (fnHasGotos === true) {
    meta = hasLabelFlag || hasGotoFlag ? blockMetaOrThrow(b, sf) : undefined;
  } else {
    meta = undefined;
  }

  if (!meta || !meta.funcHasGotos) {
    const dup = b.dupLabelError;
    if (dup) {
      throw new LuaRuntimeError(
        `label '${dup.name}' already defined`,
        sf.withCtx(dup.ctx),
      );
    }
    const execEnv = b.needsEnv === true ? new LuaEnv(env) : env;
    return runStatementsNoGoto(b.statements, execEnv, sf, returnOnReturn, 0);
  }

  const execEnv = b.needsEnv === true ? new LuaEnv(env) : env;
  const stmts = b.statements;

  const runFrom = (
    i: number,
  ): EvalBlockResult => {
    for (; i < stmts.length; i++) {
      const r = evalStatement(stmts[i], execEnv, sf, returnOnReturn);
      if (isPromise(r)) {
        return (r as Promise<any>).then((res) => {
          const consumed = consumeGotoInBlock(res, meta!.labels);
          if (typeof consumed === "number") {
            return runFrom(consumed);
          }
          if (consumed !== undefined) {
            return consumed;
          }
          return runFrom(i + 1);
        });
      }
      const consumed = consumeGotoInBlock(r, meta.labels);
      if (typeof consumed === "number") {
        i = consumed - 1;
        continue;
      }
      if (consumed !== undefined) {
        return consumed;
      }
    }
    return;
  };

  return runFrom(0);
}

/**
 * Evaluates a statement in two possible modes:
 *
 * 1. With `returnOnReturn` set to `true` will return the value of
 *    a return statement.
 * 2. With `returnOnReturn` set to `false` will throw a LuaReturn
 *    exception if a return statement is encountered.
 *
 * May also return `{ctrl:"goto", target}` for goto.
 */
export function evalStatement(
  s: LuaStatement,
  env: LuaEnv,
  sf: LuaStackFrame,
  returnOnReturn = false,
): void | ControlSignal | Promise<void | ControlSignal> {
  switch (s.type) {
    case "Assignment": {
      const a = asAssignment(s);
      const valuesRP = evalExpressions(a.expressions, env, sf);
      const lvaluesRP = evalPromiseValues(a.variables
        .map((lval) => evalLValue(lval, env, sf)));

      const apply = (values: LuaValue[], lvalues: { env: any; key: any }[]) => {
        const ps: Promise<any>[] = [];
        for (let i = 0; i < lvalues.length; i++) {
          const r = luaSet(
            lvalues[i].env,
            lvalues[i].key,
            values[i],
            sf.withCtx(a.ctx),
          );

          if (isPromise(r)) {
            ps.push(r);
          }
        }
        if (ps.length) {
          return Promise.all(ps).then(() => undefined);
        }
        return;
      };

      if (!isPromise(valuesRP) && !isPromise(lvaluesRP)) {
        return apply(
          valuesRP as LuaValue[],
          lvaluesRP as LuaLValueContainer[],
        );
      }
      if (
        isPromise(valuesRP) && !isPromise(lvaluesRP)
      ) {
        return (valuesRP as Promise<LuaValue[]>).then((values: LuaValue[]) =>
          apply(values, lvaluesRP as LuaLValueContainer[])
        );
      }
      if (
        !isPromise(valuesRP) && isPromise(lvaluesRP)
      ) {
        return (lvaluesRP as Promise<any[]>).then((lvalues: any[]) =>
          apply(valuesRP as LuaValue[], lvalues)
        );
      }
      return (valuesRP as Promise<LuaValue[]>).then((values: LuaValue[]) =>
        (lvaluesRP as Promise<any[]>).then((lvalues: any[]) =>
          apply(values, lvalues)
        )
      );
    }
    case "Local": {
      const l = asLocal(s);

      const hasInit = Array.isArray(l.expressions) && l.expressions.length > 0;

      for (const att of l.names) {
        const isConst = att.attributes?.includes(LuaAttribute.Const) === true;
        if (isConst && !hasInit) {
          throw new LuaRuntimeError(
            `const variable '${att.name}' must be initialized`,
            sf.withCtx(att.ctx),
          );
        }
      }

      const bindOne = (name: any, v: LuaValue) => {
        const isConst = name.attributes?.includes(LuaAttribute.Const) === true;
        const isClose = name.attributes?.includes(LuaAttribute.Close) === true;

        if (isConst || isClose) {
          env.setLocalConst(name.name, v);
        } else {
          env.setLocal(name.name, v);
        }

        if (isClose) {
          luaMarkToBeClosed(sf, v, name.ctx);
        }
      };

      if (!hasInit) {
        for (let i = 0; i < l.names.length; i++) {
          bindOne(l.names[i], null);
        }
        return;
      }

      // Evaluate initializers left-to-right and bind/mark `<close>`
      // locals as soon as they receive a value.  This ensures earlier
      // `<close>` locals are closed if a later initializer errors.
      const exprs = l.expressions!;
      const out: LuaValue[] = [];
      let boundCount = 0;

      const bindAvailable = () => {
        while (boundCount < l.names.length && boundCount < out.length) {
          bindOne(
            l.names[boundCount],
            out[boundCount] ?? null,
          );
          boundCount++;
        }
      };

      const finish = () => {
        while (out.length < l.names.length) {
          out.push(null);
        }
        bindAvailable();
      };

      const runFrom = (i: number): void | Promise<void> => {
        if (i >= exprs.length) {
          finish();
          return;
        }

        const isLastExpr = i === exprs.length - 1;
        const rp = evalExpression(exprs[i], env, sf);

        const onValue = (v: LuaValue) => {
          if (isLastExpr) {
            if (v instanceof LuaMultiRes) {
              const flat = v.flatten();
              for (let k = 0; k < flat.values.length; k++) {
                out.push(flat.values[k]);
              }
            } else {
              out.push(v);
            }
          } else {
            out.push(singleResult(v));
          }

          bindAvailable();

          // If we already have enough values for all locals, remaining
          // expressions will not affect the binding, so we can stop.
          if (out.length >= l.names.length && !isLastExpr) {
            return;
          }

          return runFrom(i + 1);
        };

        return rpThen(rp, onValue) as any;
      };

      return runFrom(0);
    }
    case "Semicolon": {
      return;
    }
    case "Label": {
      const _lab = asLabel(s); // No-op!
      return;
    }
    case "Goto": {
      const g = asGoto(s);
      return { ctrl: "goto", target: g.name };
    }
    case "Block": {
      const b = asBlock(s);

      if (!b.hasCloseHere) {
        return evalBlockNoClose(b, env, sf, returnOnReturn);
      }

      // Blocks establish a boundary (mark) and close all entries
      // created within the block on exit or error, shrinking the stack
      // back to mark.  This is _required_ for correct `pcall` and
      // `xpcall` boundary semantics.
      const closeStack = luaEnsureCloseStack(sf);
      const mark = closeStack.length;

      let out: EvalBlockResult;
      try {
        out = evalBlockNoClose(b, env, sf, returnOnReturn);
      } catch (e: any) {
        const errObj: LuaValue = e instanceof LuaRuntimeError
          ? e.message
          : (e?.message ?? String(e));
        const r = luaCloseFromMark(sf, mark, errObj);
        if (isPromise(r)) {
          return (r as Promise<void>).then(() => {
            throw e;
          });
        }
        throw e;
      }

      return withCloseBoundary(sf, mark, out);
    }
    case "If": {
      const iff = asIf(s);
      // Evaluate conditions in order; avoid awaiting when not necessary
      const conds = iff.conditions;

      const runFrom = (
        i: number,
      ):
        | void
        | ControlSignal
        | Promise<void | ControlSignal> => {
        if (i >= conds.length) {
          if (iff.elseBlock) {
            return evalStatement(iff.elseBlock, env, sf, returnOnReturn);
          }
          return;
        }
        const cv = evalExpression(conds[i].condition, env, sf);
        if (isPromise(cv)) {
          return (cv as Promise<any>).then((val) => {
            if (luaTruthy(val)) {
              return evalStatement(conds[i].block, env, sf, returnOnReturn);
            }
            return runFrom(i + 1);
          });
        }
        if (luaTruthy(cv)) {
          return evalStatement(conds[i].block, env, sf, returnOnReturn);
        }
        return runFrom(i + 1);
      };

      return runFrom(0);
    }
    case "While": {
      const w = asWhile(s);

      const runAsync = async (): Promise<void | ControlSignal> => {
        while (true) {
          const c = await evalExpression(w.condition, env, sf);
          if (!luaTruthy(c)) {
            break;
          }
          const r = evalStatement(w.block, env, sf, returnOnReturn);
          const res = isPromise(r) ? await r : r;
          if (res !== undefined) {
            if (isBreakSignal(res)) {
              break;
            }
            return res;
          }
        }
        return;
      };

      while (true) {
        const c = evalExpression(w.condition, env, sf);
        if (isPromise(c)) {
          return (c as Promise<any>).then((cv) => {
            if (!luaTruthy(cv)) {
              return;
            }
            try {
              const r = evalStatement(w.block, env, sf, returnOnReturn);
              if (isPromise(r)) {
                return (r as Promise<any>).then((res) => {
                  if (res !== undefined) {
                    if (isBreakSignal(res)) {
                      return;
                    }
                    return res;
                  }
                  return runAsync();
                });
              }
              if (r !== undefined) {
                if (isBreakSignal(r)) {
                  return;
                }
                return r;
              }
              return runAsync();
            } catch (e: any) {
              throw e;
            }
          });
        }
        if (!luaTruthy(c)) {
          break;
        }
        const r = evalStatement(w.block, env, sf, returnOnReturn);
        if (isPromise(r)) {
          return (r as Promise<any>).then((res) => {
            if (res !== undefined) {
              if (isBreakSignal(res)) {
                return;
              }
              return res;
            }
            return runAsync();
          });
        }
        if (r !== undefined) {
          if (isBreakSignal(r)) {
            break;
          }
          return r;
        }
      }
      return;
    }
    case "Repeat": {
      const r = asRepeat(s);

      const runAsync = async (): Promise<void | ControlSignal> => {
        while (true) {
          const rr = evalStatement(r.block, env, sf, returnOnReturn);
          const res = isPromise(rr) ? await rr : rr;
          if (res !== undefined) {
            if (isBreakSignal(res)) {
              break;
            }
            return res;
          }
          const c = await evalExpression(r.condition, env, sf);
          if (luaTruthy(c)) {
            break;
          }
        }
        return;
      };

      while (true) {
        const rr = evalStatement(r.block, env, sf, returnOnReturn);
        if (isPromise(rr)) {
          return (rr as Promise<any>).then((res) => {
            if (res !== undefined) {
              if (isBreakSignal(res)) {
                return;
              }
              return res;
            }
            return runAsync();
          });
        }
        if (rr !== undefined) {
          if (isBreakSignal(rr)) {
            return;
          }
          return rr;
        }

        const c = evalExpression(r.condition, env, sf);
        if (isPromise(c)) {
          return (c as Promise<any>).then((cv) =>
            luaTruthy(cv) ? undefined : runAsync()
          );
        }
        if (luaTruthy(c)) {
          break;
        }
      }
      return;
    }
    case "Break": {
      return { ctrl: "break" };
    }
    case "FunctionCallStatement": {
      const fcs = asFunctionCallStmt(s);
      const r = evalExpression(fcs.call, env, sf);
      if (isPromise(r)) {
        return (r as Promise<any>).then(() => undefined);
      }
      return;
    }
    case "Function": {
      const fn = asFunctionStmt(s);
      let body = fn.body;
      let propNames = fn.name.propNames;
      if (fn.name.colonName) {
        // function hello:there() -> function hello.there(self) transformation
        body = {
          ...(fn.body),
          parameters: ["self", ...fn.body.parameters],
        };
        propNames = [...fn.name.propNames, fn.name.colonName];
      }
      let settable: ILuaGettable = env;
      for (let i = 0; i < propNames.length - 1; i++) {
        settable = (settable as any).get(propNames[i]);
        if (!settable) {
          throw new LuaRuntimeError(
            `Cannot find property ${propNames[i]}`,
            sf.withCtx(fn.name.ctx),
          );
        }
      }
      (settable as any).set(
        propNames[propNames.length - 1],
        new LuaFunction(body, env),
      );
      return;
    }
    case "LocalFunction": {
      const lf = asLocalFunction(s);
      env.setLocal(
        lf.name,
        new LuaFunction(lf.body, env),
      );
      return;
    }
    case "Return": {
      const ret = asReturn(s);

      const parts = ret.expressions.map((value: LuaExpression) =>
        evalExpression(value, env, sf)
      );
      const valuesRP = rpAll(parts);

      const finalize = (vals: any[]): ReturnSignal => {
        const outVals: LuaValue[] = [];

        if (vals.length === 0) {
          return { ctrl: "return", values: outVals };
        }

        for (let i = 0; i < vals.length; i++) {
          const isLast = i === vals.length - 1;
          const v = vals[i];

          if (!isLast) {
            outVals.push(singleResult(v));
            continue;
          }

          if (v instanceof LuaMultiRes) {
            const flat = v.flatten();
            outVals.push(...flat.values);
          } else {
            outVals.push(v);
          }
        }

        return {
          ctrl: "return" as const,
          values: outVals,
        };
      };

      if (isPromise(valuesRP)) {
        return (valuesRP as Promise<any[]>).then((vals) => finalize(vals));
      }
      return finalize(valuesRP as any[]);
    }
    case "For": {
      const fr = asFor(s);
      const startV = evalExpression(fr.start, env, sf);
      const endV = evalExpression(fr.end, env, sf);
      const stepV = fr.step ? evalExpression(fr.step, env, sf) : 1;

      const determineLoopType = (): NumericType => {
        const startType = astNumberKind(fr.start);
        const stepType = fr.step ? astNumberKind(fr.step) : "int";
        return (startType === "float" || stepType === "float")
          ? "float"
          : "int";
      };

      const wrapLoopVar = (i: number, loopType: NumericType) => {
        if (loopType === "float") {
          return makeLuaFloat(i);
        }
        return i;
      };

      const canReuseEnv = !fr.block.hasFunctionDef ||
        fr.capturesLoopVar === false;

      const executeIteration = canReuseEnv
        ? (
          loopEnv: LuaEnv,
          i: number,
          loopType: NumericType,
        ): void | ControlSignal | Promise<void | ControlSignal> => {
          loopEnv.setLocal(fr.name, wrapLoopVar(i, loopType));
          return evalStatement(fr.block, loopEnv, sf, returnOnReturn);
        }
        : (
          _loopEnv: LuaEnv,
          i: number,
          loopType: NumericType,
        ): void | ControlSignal | Promise<void | ControlSignal> => {
          const localEnv = new LuaEnv(env);
          localEnv.setLocal(fr.name, wrapLoopVar(i, loopType));
          return evalStatement(fr.block, localEnv, sf, returnOnReturn);
        };

      const runAsync = async (
        loopEnv: LuaEnv,
        end: number,
        step: number,
        startIndex: number,
        loopType: NumericType,
      ) => {
        if (step === 0) {
          throw new LuaRuntimeError("'for' step is zero", sf.withCtx(fr.ctx));
        }

        const shouldContinue = step > 0
          ? (i: number) => i <= end
          : (i: number) => i >= end;

        for (let i = startIndex; shouldContinue(i); i += step) {
          const r = executeIteration(loopEnv, i, loopType);
          const res = isPromise(r) ? await r : r;
          if (res !== undefined) {
            if (isBreakSignal(res)) {
              return;
            }
            return res;
          }
        }
      };

      const runSyncFirst = (
        start: number,
        end: number,
        step: number,
        loopType: NumericType,
      ):
        | void
        | ControlSignal
        | Promise<void | ControlSignal> => {
        if (step === 0) {
          throw new LuaRuntimeError("'for' step is zero", sf.withCtx(fr.ctx));
        }

        const shouldContinue = step > 0
          ? (i: number) => i <= end
          : (i: number) => i >= end;

        const loopEnv = new LuaEnv(env);

        for (let i = start; shouldContinue(i); i += step) {
          const r = executeIteration(loopEnv, i, loopType);
          if (isPromise(r)) {
            return (r as Promise<any>).then((res) => {
              if (res !== undefined) {
                if (isBreakSignal(res)) {
                  return;
                }
                return res;
              }
              return runAsync(loopEnv, end, step, i + step, loopType);
            });
          }
          if (r !== undefined) {
            if (isBreakSignal(r)) {
              return;
            }
            return r;
          }
        }
        return;
      };

      const loopType = determineLoopType();

      if (
        !isPromise(startV) &&
        !isPromise(endV) &&
        !isPromise(stepV)
      ) {
        return runSyncFirst(
          untagNumber(startV) as number,
          untagNumber(endV) as number,
          untagNumber(stepV ?? 1) as number,
          loopType,
        );
      }
      return Promise.all([
        isPromise(startV) ? startV : Promise.resolve(startV),
        isPromise(endV) ? endV : Promise.resolve(endV),
        isPromise(stepV) ? stepV : Promise.resolve(stepV),
      ]).then(([start, end, step]) => {
        return runSyncFirst(
          untagNumber(start) as number,
          untagNumber(end) as number,
          untagNumber(step ?? 1) as number,
          loopType,
        );
      });
    }
    case "ForIn": {
      const fi = asForIn(s);
      const exprVals = rpAll(
        fi.expressions.map((e: LuaExpression) => evalExpression(e, env, sf)),
      );

      const canReuseEnv = !fi.block.hasFunctionDef ||
        fi.capturesLoopVar === false;
      const setIterVars = (
        localEnv: LuaEnv,
        names: string[],
        values: LuaValue[],
      ) => {
        for (let i = 0; i < names.length; i++) {
          localEnv.setLocal(names[i], values[i]);
        }
      };

      const afterExprs = (resolved: any[]) => {
        const iteratorMultiRes = new LuaMultiRes(resolved).flatten();
        let iteratorValue: ILuaFunction | any = iteratorMultiRes.values[0];
        // Handle the case where the iterator is a table and we need
        // to call the `each` function.
        if (Array.isArray(iteratorValue) || iteratorValue instanceof LuaTable) {
          iteratorValue = (env.get("each") as ILuaFunction).call(
            sf,
            iteratorValue,
          );
        }

        if (!iteratorValue?.call) {
          console.error("Cannot iterate over", iteratorMultiRes.values[0]);
          throw new LuaRuntimeError(
            `Cannot iterate over ${iteratorMultiRes.values[0]}`,
            sf.withCtx(fi.ctx),
          );
        }

        const state: LuaValue = iteratorMultiRes.values[1] ?? null;
        let control: LuaValue = iteratorMultiRes.values[2] ?? null;
        const closing: LuaValue = iteratorMultiRes.values[3] ?? null;

        const closeStack = luaEnsureCloseStack(sf);
        const mark = closeStack.length;

        if (closing !== null) {
          luaMarkToBeClosed(sf, closing, fi.ctx);
        }

        const errObjFrom = (e: any): LuaValue =>
          e instanceof LuaRuntimeError ? e.message : (e?.message ?? String(e));

        const finish = (res: any) => {
          const r = luaCloseFromMark(sf, mark, null);
          return isPromise(r) ? (r as Promise<void>).then(() => res) : res;
        };

        const finishErr = (e: any): Promise<never> | never => {
          const errObj = errObjFrom(e);
          const r = luaCloseFromMark(sf, mark, errObj);
          if (isPromise(r)) {
            return (r as Promise<void>).then(() => {
              throw e;
            });
          }
          throw e;
        };

        // Allocate the reusable env once before the loop
        const loopEnv = canReuseEnv ? new LuaEnv(env) : null;

        const makeIterEnv = (): LuaEnv => {
          if (loopEnv) {
            return loopEnv;
          }
          return new LuaEnv(env);
        };

        try {
          const runAsync = async () => {
            while (true) {
              const callRes = luaCall(
                iteratorValue,
                [state, control],
                fi.ctx,
                sf,
              );
              const iterResult = new LuaMultiRes(
                isPromise(callRes) ? await callRes : callRes,
              ).flatten();
              const nextControl = iterResult.values[0];
              if (nextControl === null || nextControl === undefined) {
                break;
              }
              control = nextControl;

              const localEnv = makeIterEnv();
              setIterVars(localEnv, fi.names, iterResult.values);

              const r = evalStatement(fi.block, localEnv, sf, returnOnReturn);
              const res = isPromise(r) ? await r : r;
              if (res !== undefined) {
                if (isBreakSignal(res)) {
                  break;
                }
                return await finish(res);
              }
            }
            return await finish(undefined);
          };

          while (true) {
            const iterCall = luaCall(
              iteratorValue,
              [state, control],
              fi.ctx,
              sf,
            );
            if (isPromise(iterCall)) {
              return (iterCall as Promise<any>).then((itv) => {
                const iterResult = new LuaMultiRes(itv).flatten();
                const nextControl = iterResult.values[0];
                if (nextControl === null || nextControl === undefined) {
                  const r = finish(undefined);
                  if (isPromise(r)) return (r as Promise<void>).then(() => {});
                  return;
                }
                control = nextControl;

                const localEnv = makeIterEnv();
                setIterVars(localEnv, fi.names, iterResult.values);

                const r = evalStatement(
                  fi.block,
                  localEnv,
                  sf,
                  returnOnReturn,
                );
                if (isPromise(r)) {
                  return (r as Promise<any>).then((res) => {
                    if (res !== undefined) {
                      if (isBreakSignal(res)) {
                        return finish(undefined);
                      }
                      return rpThen(finish(undefined), () => res);
                    }
                    return runAsync();
                  });
                }
                if (r !== undefined) {
                  if (isBreakSignal(r)) {
                    return finish(undefined);
                  }
                  return rpThen(finish(undefined), () => r);
                }
                return runAsync();
              }).catch((e: any) => finishErr(e));
            }

            const iterResult = new LuaMultiRes(iterCall).flatten();
            const nextControl = iterResult.values[0];
            if (nextControl === null || nextControl === undefined) {
              const r = finish(undefined);
              if (isPromise(r)) {
                return (r as Promise<void>);
              }
              return;
            }
            control = nextControl;

            const localEnv = makeIterEnv();
            setIterVars(localEnv, fi.names, iterResult.values);

            const r = evalStatement(fi.block, localEnv, sf, returnOnReturn);
            if (isPromise(r)) {
              return (r as Promise<any>).then((res) => {
                if (res !== undefined) {
                  if (isBreakSignal(res)) {
                    return finish(undefined);
                  }
                  return rpThen(finish(undefined), () => res);
                }
                return runAsync();
              }).catch((e: any) => finishErr(e));
            }
            if (r !== undefined) {
              if (isBreakSignal(r)) {
                return finish(undefined);
              }
              return rpThen(finish(undefined), () => r);
            }
          }
        } catch (e: any) {
          return finishErr(e);
        }
      };

      if (isPromise(exprVals)) {
        return (exprVals as Promise<any[]>).then(afterExprs);
      }
      return afterExprs(exprVals as any[]);
    }
  }
}

function evalLValue(
  lval: LuaLValue,
  env: LuaEnv,
  sf: LuaStackFrame,
): LuaLValueContainer | Promise<LuaLValueContainer> {
  switch (lval.type) {
    case "Variable": {
      const v = asLValueVariable(lval);
      return {
        env,
        key: v.name,
      };
    }
    case "TableAccess": {
      const ta = asLValueTableAccess(lval);
      const objValue = evalExpression(
        ta.object,
        env,
        sf,
      );
      const keyValue = evalExpression(ta.key, env, sf);
      if (
        isPromise(objValue) ||
        isPromise(keyValue)
      ) {
        return Promise.all([
          isPromise(objValue) ? objValue : Promise.resolve(objValue),
          isPromise(keyValue) ? keyValue : Promise.resolve(keyValue),
        ]).then(([objValue, keyValue]) => ({
          env: singleResult(objValue),
          key: singleResult(keyValue),
        }));
      }
      return {
        env: singleResult(objValue),
        key: singleResult(keyValue),
      };
    }
    case "PropertyAccess": {
      const pa = asLValuePropertyAccess(lval);
      const objValue = evalExpression(
        pa.object,
        env,
        sf,
      );
      if (isPromise(objValue)) {
        return (objValue as Promise<any>).then((ov) => {
          return {
            env: ov,
            key: pa.property,
          };
        });
      }
      return {
        env: objValue,
        key: pa.property,
      };
    }
  }
}


================================================
FILE: client/space_lua/goto_test.lua
================================================
local function assertEqual(a, b, message)
  if a ~= b then
    error("Assertion failed: " .. tostring(a)
      .. " != " .. tostring(b) .. " " .. (message or ""))
  end
end

local function contains(hay, needle)
  return string.find(tostring(hay), needle, 1, true) ~= nil
end

local function expectError(fn, needle, msg)
  local ok, err = pcall(fn)
  if ok then
    error("Expected error, but call succeeded: " .. (msg or ""))
  end
  local s = tostring(err)
  if not contains(s, needle) then
    error("Error missing expected text '" .. needle .. "': got " .. s)
  end
end

-- Basic forward jump
do
  local x = 0
  goto after_init
  x = -1 -- skipped
  ::after_init:: x = x + 2
  assertEqual(x, 2)
end

-- Backward jump (simulated loop)
do
  local n = 0
  ::loop_start::
  n = n + 1
  if n < 3 then goto loop_start end
  assertEqual(n, 3)
end

-- Consecutive labels (two labels before one statement)
do
  local v = 0
  ::first_label::::second_label:: v = v + 1
  assertEqual(v, 1)
end

-- No visible label (forward into inner block / outward from inner)
do
  local function f() goto missing; do ::missing:: end end
  expectError(f, "no visible label 'missing'")
  local function g() do ::inner:: end; goto inner end
  expectError(g, "no visible label 'inner'")
end

-- Duplicate label in same block
do
  local function f() ::dup:: ::dup:: end
  expectError(f, "label 'dup'")
end

-- Jump into local scope (illegal forward into variable region)
do
  local function f()
    goto after_local
    local a = 1
    ::after_local:: return a
  end
  expectError(f, "jumps into the scope")
end

-- Repeat loop: safe-end label NOT allowed to bypass scope
do
  local function f()
    local flag = false
    repeat
      if flag then goto after_repeat end
      local hidden = 10
      ::after_repeat:: -- repeat end label scope test
    until hidden and flag
  end
  expectError(f, "jumps into the scope")
end

-- Safe end-of-block backward jump (allowed)
do
  local function f()
    do local a = 1 end
    ::end_block:: return 42
  end
  assertEqual(f(), 42)
end

-- Multi-label end sentinel (extra labels + semicolons)
do
  local x
  do
    local y = 12
    goto set_x
    ::after_set:: x = x + 1; goto check_x
    ::set_x:: x = y; goto after_set
  end
  ::check_x:: ::check_x_extra:: assertEqual(x, 13)
end

-- Skip local declarations (labels before local)
do
  goto after_vars
  local a = 23
  a = a
  ::after_vars:: ;
end

-- While loop labels inside loop; external exit
do
  local global = 13
  while true do
    goto exit_loop
    goto loop_label
    goto loop_label
    local inner = 45
    ::loop_label:: ;;;
  end
  ::exit_loop:: assertEqual(global, 13)
end

-- Mixed returns via labels (branch selection)
local function branch_test(a)
  if a == 1 then
    goto l1
  elseif a == 2 then
    goto l2
  elseif a == 3 then
    goto l3
  elseif a == 4 then
    goto l1
    ::l1:: a = a + 1
  else
    goto l4
    ::l4_a:: a = a * 2; goto l4_b
    ::l4:: goto l4_a
    ::l4_b::
  end
  do return a end
  ::l2:: do return "2" end
  ::l3:: do return "3" end
  ::l1:: return "1"
end

assertEqual(branch_test(1), "1")
assertEqual(branch_test(2), "2")
assertEqual(branch_test(3), "3")
assertEqual(branch_test(4), 5)
assertEqual(branch_test(5), 10)

-- Same label name in different functions (allowed)
do
  local function f() ::same:: return 1 end
  local function g() ::same:: return 2 end
  assertEqual(f(), 1)
  assertEqual(g(), 2)
end

-- Upward jump into ancestor block
do
  local n = 0
  ::up_loop::
  n = n + 1
  if n < 2 then
    do
      goto up_loop
    end
  end
  assertEqual(n, 2)
end

-- Escape potential infinite loop via early goto
do
  goto escaped
  ::inf_a:: goto inf_a
  ::inf_b:: goto inf_c
  ::inf_c:: goto inf_b
  ::escaped:: ;
end

-- Sequential building of array with gotos
local function build_array()
  local a = {}
  goto start
  ::add_one:: a[#a + 1] = 1; goto add_two
  ::add_two:: a[#a + 1] = 2; goto add_five
  ::start:: ::start_extra:: a[#a + 1] = 3; goto add_one
  ::add_four:: a[#a + 1] = 4; goto finish
  ::add_five:: a[#a + 1] = 5; goto add_four
  ::finish:: return a
end

do
  local arr = build_array()
  assertEqual(arr[1], 3)
  assertEqual(arr[2], 1)
  assertEqual(arr[3], 2)
  assertEqual(arr[4], 5)
  assertEqual(arr[5], 4)
end

-- Long label name (basic acceptance)
do
  ::very_long_label_end_marker_example_for_stress_test_end::
  local ok = true
  assertEqual(ok, true)
end

-- Sibling block visibility (cannot jump to sibling label)
do
  local function f()
    do ::sib:: end
    do goto sib end
  end
  expectError(f, "no visible label 'sib'", "cannot jump to sibling label")
end

-- Forward to inside for: label not visible from outside
do
  local function f()
    goto in_for
    for i = 1, 2 do
      ::in_for:: return i
    end
  end
  expectError(f, "no visible label 'in_for'")
end

-- Forward to inside for-in: label not visible from outside
do
  local function f()
    goto in_forin
    for k, v in pairs({1,2}) do
      ::in_forin:: return k
    end
  end
  expectError(f, "no visible label 'in_forin'")
end

-- Repeat block end scope jump (illegal)
do
  local function f()
    repeat
      goto end_repeat
      local z = 10
      ::end_repeat::
    until z == 0
  end
  expectError(f, "jumps into the scope")
end

-- Nested upward multi-jump
do
  local count = 0
  ::multi_up::
  count = count + 1
  if count < 3 then
    do
      do
        goto multi_up
      end
    end
  end
  assertEqual(count, 3)
end

-- Late label after inner block; allowed upward visibility
do
  local function f()
    do
      goto late_label
    end
    ::late_label:: return 1
  end
  assertEqual(f(), 1)
end

-- Upward goto to ancestor label inside nested block
do
  local function f()
    local n = 0
    ::ancestor:: n = n + 1
    do
      if n < 2 then goto ancestor end
    end
    return n
  end
  assertEqual(f(), 2)
end

-- Same label name reused in separate blocks (legal)
do
  local function f()
    local t = {}
    do ::lbl:: table.insert(t, "first") end
    do ::lbl:: table.insert(t, "second") end
    return t
  end
  local r = f()
  assertEqual(r[1], "first")
  assertEqual(r[2], "second")
end

-- Label visibility restricted to branch (goto in else to label in then)
do
  local function f_bad(flag)
    if flag then
      ::branch_label:: return "yes"
    else
      goto branch_label
    end
  end
  expectError(function() f_bad(true) end, "no visible label 'branch_label'")
  expectError(function() f_bad(false) end, "no visible label 'branch_label'")
end

-- Label visible to both branches (label placed after the if)
do
  local function f_good(flag)
    if not flag then goto after end
    ::after:: return "yes"
  end
  assertEqual(f_good(true), "yes")
  assertEqual(f_good(false), "yes")
end

-- Jump over multiple locals but not into them (allowed backward)
do
  local function f()
    local a = 1
    ::compute::
    local b = a + 1
    local c = b + 1
    if c < 5 then
      a = c
      goto compute -- backward: allowed
    end
    return c
  end
  assertEqual(f(), 5)
end

-- Label at end of block with only semicolons after (safe end)
do
  local function f()
    local x = 10
    ::end_ok:: ; ; ;
    return x
  end
  assertEqual(f(), 10)
end

-- Goto into local scope through chained labels (error)
do
  local function f()
    goto after_chain
    local p = 99
    ::chain_a::::chain_b::::after_chain:: return p
  end
  expectError(f, "jumps into the scope")
end

-- Cross-function goto (outer to inner label, invalid)
do
  local function inner()
    ::in_label:: return 1
  end
  local function outer()
    goto in_label
    return 0
  end
  expectError(outer, "no visible label 'in_label'")
end

-- Cross-function goto (inner to outer label, invalid)
do
  local function outer()
    ::outer_label:: ;
    local function inner()
      goto outer_label
    end
    return inner()
  end
  expectError(outer, "no visible label 'outer_label'")
end

-- Cross-function goto (outer after defining inner label, invalid)
do
  local function outer()
    local function inner()
      ::nested_label:: return 2
    end
    goto nested_label
  end
  expectError(outer, "no visible label 'nested_label'")
end

-- Forward goto to label not safe end (fails)
do
  local function f()
    goto after_locals
    local a = 1
    ::after_locals:: local b = 2
    return b
  end
  expectError(f, "jumps into the scope")
end

-- Repeat safe-end style still illegal
do
  local function f()
    repeat
      goto done
      local x = 1
      ::done:: ;
    until x == 0
  end
  expectError(f, "jumps into the scope")
end

-- goto to correct label when nested
::l1::
do
  local v = 0
  goto l1
  v = -1
  ::l1:: -- inner label shadowing the outer label
  v = v + 1
  assertEqual(v, 1)
end

-- regression test for Lua 5.2 bug
do
  local x
  ::l1::
  local y
  assert(y == nil)
  y = true
  if x == nil then
    x = 1
    goto l1
  else
    x = x + 1
  end
  assert(x == 2 and y == true)
end


================================================
FILE: client/space_lua/labels.ts
================================================
// Goto/label resolution and validation for function bodies

import type {
  ASTCtx,
  LuaBlock,
  LuaForInStatement,
  LuaForStatement,
  LuaGotoStatement,
  LuaIfStatement,
  LuaLabelStatement,
  LuaLocalStatement,
  LuaRepeatStatement,
  LuaWhileStatement,
} from "./ast.ts";
import { LuaAttribute } from "./ast.ts";
import { asBlock } from "./ast_narrow.ts";

type BlockGotoMeta = {
  labels: Map<string, number>; // in this block
};

type FunctionMeta = {
  // Stores per-block data and drops entries automatically when a block
  // is no longer used.
  blockMeta: WeakMap<LuaBlock, BlockGotoMeta>;
  funcHasGotos: boolean;
};

class LabelResolveError extends Error {
  constructor(msg: string, public astCtx: ASTCtx) {
    super(msg);
  }
}

// Cache
const functionMetaByRoot = new WeakMap<LuaBlock, FunctionMeta>();
const functionMetaByAnyBlock = new WeakMap<LuaBlock, FunctionMeta>();

export function getBlockGotoMeta(
  block: LuaBlock,
): (BlockGotoMeta & { funcHasGotos: boolean }) | undefined {
  let fm = functionMetaByAnyBlock.get(block);
  if (!fm) {
    fm = resolveFunction(block);
  }
  const bm = fm.blockMeta.get(block);
  if (!bm) {
    return undefined;
  }
  return { ...bm, funcHasGotos: fm.funcHasGotos };
}

type LocalID = number;

type GotoInfo = {
  node: LuaGotoStatement;
  active: Set<LocalID>;
  block: LuaBlock;
};

type ValidationCtx = {
  labelActiveByBlock: WeakMap<LuaBlock, Map<string, Set<LocalID>>>;
  labelLocByBlock: WeakMap<
    LuaBlock,
    Map<string, { index: number; ctx: ASTCtx }>
  >;
  gotos: GotoInfo[];
  hasGoto: boolean;
  nextLocalId: number;
  closeLocals: Set<LocalID>;
};

type BlockRole =
  | "Root"
  | "Do"
  | "If"
  | "While"
  | "Repeat"
  | "For"
  | "ForIn";

function resolveFunction(root: LuaBlock): FunctionMeta {
  const existing = functionMetaByRoot.get(root);
  if (existing) {
    return existing;
  }

  const blockMeta = new WeakMap<LuaBlock, BlockGotoMeta>();
  const vctx: ValidationCtx = {
    labelActiveByBlock: new WeakMap(),
    labelLocByBlock: new WeakMap(),
    gotos: [],
    hasGoto: false,
    nextLocalId: 1,
    closeLocals: new Set<LocalID>(),
  };

  const seenBlocks = new Set<LuaBlock>();
  const parentByBlock = new WeakMap<LuaBlock, LuaBlock | undefined>();
  const roleByBlock = new WeakMap<LuaBlock, BlockRole>();

  processBlock(
    root,
    undefined,
    "Root",
    new Set<LocalID>(),
    blockMeta,
    vctx,
    seenBlocks,
    parentByBlock,
    roleByBlock,
  );

  // Validate gotos
  for (const g of vctx.gotos) {
    const target = g.node.name;

    // Search current block for the label, then ancestors
    let searchBlock: LuaBlock | undefined = g.block;
    let labelIndex: number | undefined;
    let labelDefBlock: LuaBlock | undefined;

    while (searchBlock) {
      const meta = blockMeta.get(searchBlock);
      if (meta && meta.labels.has(target)) {
        labelIndex = meta.labels.get(target);
        labelDefBlock = searchBlock;
        break;
      }
      searchBlock = parentByBlock.get(searchBlock);
    }

    if (labelIndex === undefined || !labelDefBlock) {
      throw new LabelResolveError(
        `no visible label '${target}' for goto`,
        g.node.ctx,
      );
    }

    const activeMap = vctx.labelActiveByBlock.get(labelDefBlock);
    const locMap = vctx.labelLocByBlock.get(labelDefBlock);
    if (!activeMap || !locMap) {
      throw new LabelResolveError(
        `no visible label '${target}' for goto`,
        g.node.ctx,
      );
    }

    const lset = activeMap.get(target);
    const lloc = locMap.get(target);
    if (!lset || !lloc) {
      throw new LabelResolveError(
        `no visible label '${target}' for goto`,
        g.node.ctx,
      );
    }

    // Local scope forward jump check
    let entersLocalScope = false;
    let entersCloseScope = false;

    for (const id of lset) {
      if (!g.active.has(id)) {
        entersLocalScope = true;
        if (vctx.closeLocals.has(id)) {
          entersCloseScope = true;
          break;
        }
      }
    }

    if (entersCloseScope) {
      throw new LabelResolveError(
        `goto '${target}' jumps into the scope of a local variable`,
        g.node.ctx,
      );
    }

    if (entersLocalScope) {
      const safeEnd = isSafeEndLabel(labelDefBlock, lloc.index, roleByBlock);
      if (!safeEnd) {
        throw new LabelResolveError(
          `goto '${target}' jumps into the scope of a local variable`,
          g.node.ctx,
        );
      }
    }
  }

  const fm: FunctionMeta = {
    blockMeta,
    funcHasGotos: vctx.hasGoto,
  };

  functionMetaByRoot.set(root, fm);
  for (const block of seenBlocks) {
    functionMetaByAnyBlock.set(block, fm);
  }

  return fm;
}

function isSafeEndLabel(
  block: LuaBlock,
  labelIndex: number,
  roleByBlock: WeakMap<LuaBlock, BlockRole>,
): boolean {
  const role = roleByBlock.get(block);
  if (role === "Repeat") {
    return false;
  }
  for (let i = labelIndex + 1; i < block.statements.length; i++) {
    const t = block.statements[i].type;
    if (t !== "Label" && t !== "Semicolon") {
      return false;
    }
  }
  return true;
}

function cloneSet<T>(s: Set<T>): Set<T> {
  const c = new Set<T>();
  for (const v of s) {
    c.add(v);
  }
  return c;
}

function processBlock(
  block: LuaBlock,
  parent: LuaBlock | undefined,
  role: BlockRole,
  active: Set<LocalID>,
  blockMeta: WeakMap<LuaBlock, BlockGotoMeta>,
  vctx: ValidationCtx,
  seen: Set<LuaBlock>,
  parentByBlock: WeakMap<LuaBlock, LuaBlock | undefined>,
  roleByBlock: WeakMap<LuaBlock, BlockRole>,
): void {
  const labels = new Map<string, number>();
  blockMeta.set(block, { labels });

  seen.add(block);
  parentByBlock.set(block, parent);
  roleByBlock.set(block, role);

  const labelActiveMap = new Map<string, Set<LocalID>>();
  const labelLocMap = new Map<string, { index: number; ctx: ASTCtx }>();
  vctx.labelActiveByBlock.set(block, labelActiveMap);
  vctx.labelLocByBlock.set(block, labelLocMap);

  const curActive = cloneSet(active);

  const stmts = block.statements;
  for (let i = 0; i < stmts.length; i++) {
    const s = stmts[i];
    switch (s.type) {
      case "Label": {
        const lab = s as LuaLabelStatement;
        if (labels.has(lab.name)) {
          throw new LabelResolveError(
            `label '${lab.name}' already defined`,
            lab.ctx,
          );
        }
        labels.set(lab.name, i);
        const actSet = cloneSet(curActive);
        labelActiveMap.set(lab.name, actSet);
        labelLocMap.set(lab.name, { index: i, ctx: lab.ctx });
        break;
      }
      case "Goto": {
        const g = s as LuaGotoStatement;
        vctx.hasGoto = true;
        vctx.gotos.push({ node: g, active: cloneSet(curActive), block });
        break;
      }
      case "Local": {
        const l = s as LuaLocalStatement;
        for (let j = 0; j < l.names.length; j++) {
          const id = vctx.nextLocalId++;
          curActive.add(id);

          const isClose =
            l.names[j].attributes?.includes(LuaAttribute.Close) ===
              true;
          if (isClose) {
            vctx.closeLocals.add(id);
          }
        }
        break;
      }
      case "LocalFunction": {
        curActive.add(vctx.nextLocalId++);
        break;
      }
      case "Function": {
        break;
      }
      case "For": {
        const fr = s as LuaForStatement;
        const childActive = cloneSet(curActive);
        childActive.add(vctx.nextLocalId++);
        processBlock(
          fr.block,
          block,
          "For",
          childActive,
          blockMeta,
          vctx,
          seen,
          parentByBlock,
          roleByBlock,
        );
        break;
      }
      case "ForIn": {
        const fi = s as LuaForInStatement;
        const childActive = cloneSet(curActive);
        for (let j = 0; j < fi.names.length; j++) {
          childActive.add(vctx.nextLocalId++);
        }
        processBlock(
          fi.block,
          block,
          "ForIn",
          childActive,
          blockMeta,
          vctx,
          seen,
          parentByBlock,
          roleByBlock,
        );
        break;
      }
      case "While": {
        const w = s as LuaWhileStatement;
        processBlock(
          w.block,
          block,
          "While",
          cloneSet(curActive),
          blockMeta,
          vctx,
          seen,
          parentByBlock,
          roleByBlock,
        );
        break;
      }
      case "Repeat": {
        const r = s as LuaRepeatStatement;
        processBlock(
          r.block,
          block,
          "Repeat",
          cloneSet(curActive),
          blockMeta,
          vctx,
          seen,
          parentByBlock,
          roleByBlock,
        );
        break;
      }
      case "If": {
        const iff = s as LuaIfStatement;
        for (let k = 0; k < iff.conditions.length; k++) {
          processBlock(
            iff.conditions[k].block,
            block,
            "If",
            cloneSet(curActive),
            blockMeta,
            vctx,
            seen,
            parentByBlock,
            roleByBlock,
          );
        }
        if (iff.elseBlock) {
          processBlock(
            iff.elseBlock,
            block,
            "If",
            cloneSet(curActive),
            blockMeta,
            vctx,
            seen,
            parentByBlock,
            roleByBlock,
          );
        }
        break;
      }
      case "Block": {
        const child = asBlock(s);
        processBlock(
          child,
          block,
          "Do",
          cloneSet(curActive),
          blockMeta,
          vctx,
          seen,
          parentByBlock,
          roleByBlock,
        );
        break;
      }
      default: {
        break;
      }
    }
  }
}


================================================
FILE: client/space_lua/language_core_test.lua
================================================
local function assertEqual(a, b, message)
    if a ~= b then
        error("Assertion failed: " .. a .. " is not equal to " .. b .. " " .. message)
    end
end

-- Basic checks
assert(true, "True is true")

-- Basic arithmetic
assert(1 + 2 == 3)
-- Slightly more complex arithmetic with presedence
assert(1 + 2 * 3 == 7)
-- Arithmetic with variables
local a = 1
local b = 2
assert(a + b == 3)

-- Basic string stuff
assert("Hello " .. "world" == "Hello world")
assertEqual([[Hello world]], "Hello world")
assertEqual([==[Hello [[world]]!]==], "Hello [[world]]!")
-- First newline should be eliminated if present
assertEqual([==[
Hello world]==], "Hello world")

-- Various forms of function definitions
function f1()
    return 1
end

assert(f1() == 1)

function sqr(a)
    return a * a
end

assert(sqr(2) == 4)

local f2 = function()
    return 2
end

assert(f2() == 2)

-- Using functions as arguments
function apply(f, a)
    return f(a)
end

assert(apply(sqr, 3) == 9)

-- Supporting multiple return values
function multi_return()
    return 1, 2
end

function addAll(...)
    local total = 0
    for i, v in ipairs({ ... }) do
        total = total + v
    end
    return total
end

local a, b = multi_return()
assert(a == 1 and b == 2)
assert(addAll(1, 2, 3) == 6)
-- Test multiple return values in expressions
assertEqual(addAll(multi_return()), 3)
assertEqual(#{ multi_return() }, 2)

local a, b, c = 0, multi_return()
assert(a == 0 and b == 1 and c == 2)

-- Some table lookups
local t = { a = 1, b = 2 }
assert(t.a == 1 and t.b == 2)
assert(t["a"] == 1 and t["b"] == 2)

-- Unpacking tables
local a, b = unpack({ 1, 2 })
assert(a == 1 and b == 2)

-- Scope tests
local a = 1
do
    local a = 2
    assert(a == 2)
end
assert(a == 1)


-- Comprehensive pairs and ipairs tests
-- Test empty table behavior
local empty = {}
local count = 0
for _ in pairs(empty) do
    count = count + 1
end
assertEqual(count, 0, "pairs should not iterate over empty table")

count = 0
for _ in ipairs(empty) do
    count = count + 1
end
assertEqual(count, 0, "ipairs should not iterate over empty table")

-- Test ipairs with js arrays
local emptyJS = js.window.JSON.parse("[]")
for i, v in ipairs(emptyJS) do
    assert(false, "ipairs should not iterate over empty js array")
end

for i, v in ipairs(js.window.JSON.parse("null") or {}) do
    assert(false, "ipairs should not iterate over empty js array")
end


-- Test mixed key types
local mixed = {
    [1] = "one",
    [2] = "two",
    ["a"] = "alpha",
    ["b"] = "beta",
    [3] = "three"
}

-- Test pairs iteration order (should include all keys)
local pairs_keys = {}
local pairs_values = {}
for k, v in pairs(mixed) do
    table.insert(pairs_keys, k)
    table.insert(pairs_values, v)
end
assertEqual(#pairs_keys, 5, "pairs should iterate over all keys")
assertEqual(#pairs_values, 5, "pairs should iterate over all values")

-- Test ipairs behavior (should only iterate over numeric indices)
local ipairs_keys = {}
local ipairs_values = {}
for k, v in ipairs(mixed) do
    table.insert(ipairs_keys, k)
    table.insert(ipairs_values, v)
end
assertEqual(#ipairs_keys, 3, "ipairs should only iterate over numeric indices")
assertEqual(ipairs_values[1], "one", "first ipairs value should be 'one'")
assertEqual(ipairs_values[2], "two", "second ipairs value should be 'two'")
assertEqual(ipairs_values[3], "three", "third ipairs value should be 'three'")

-- Async function calling
function multiplier(a)
    -- Anything will be async in practice
    return function(b)
        return a * b
    end
end

local multiplier = multiplier(2)
assert(multiplier(3) == 6)

-- Checking of pairs and ipairs


-- Function definitions in tables
ns = { name = "Pete" }
function ns.returnOne()
    return 1
end

function ns:getName()
    return self.name
end

assert(ns.returnOne() == 1)
assert(ns.getName(ns) == "Pete")
-- Support colon syntax
assert(ns:getName() == "Pete")
-- Update the table
ns.name = "John"
assert(ns:getName() == "John")

-- Basic OOP with metatables
Person = {}
Person.__index = Person

-- Constructor
function Person:new(name, age)
    local self = setmetatable({}, Person)
    self.name = name
    -- Initialize object properties
    self.age = age
    return self
end

-- Method for the Person class
function Person:greet()
    return "Hello, my name is " .. self.name .. " and I am " .. self.age .. " years old."
end

-- Create a new instance of the Person class
local p = Person:new("John", 30)
assert(p:greet() == "Hello, my name is John and I am 30 years old.")

-- Metatables test
mt = {
    __index = function(table, key)
        return "Key not found: " .. key
    end
}

t = setmetatable({}, mt)
t.bar = "bar"
assert(t.bar == "bar")
assertEqual(t.foo, "Key not found: foo")

-- Test the __newindex metamethod
t, c = setmetatable(
    {}, {
        __newindex = function(table, key, value)
	    c = c + 1
            rawset(table, key, "Value: " .. value)
	    c = c + 1
        end
    }
), 0

t.name = "John"
-- rawset ignores the metamethod
rawset(t, "age", 100)
assertEqual(t.name, "Value: John")
assertEqual(t.age, 100)
assertEqual(c, 2)

-- Test some of the operator metamethods
t = setmetatable(
    { 1, 2, 3 },
    {
        -- Assume b to be a same length table and add the two
        __add = function(a, b)
            local result = {}
            for i = 1, #a do
                result[i] = a[i] + b[i]
            end
            return result
        end,
        -- Assume b to be a scalar and multiply the table by it
        __mul = function(a, b)
            local result = {}
            for i = 1, #a do
                result[i] = a[i] * b
            end
            return result
        end
    }
)
local added = t + { 4, 5, 6 }
assert(added[1] == 5 and added[2] == 7 and added[3] == 9)
local muliplied = t * 2
assert(muliplied[1] == 2 and muliplied[2] == 4 and muliplied[3] == 6)

-- __call Metamethod

local ts = {
    foo = "ARG 2"
}
local mt = {}

function mt.__call(table, arg1)
    assert(arg1 == "ARG 1")
    assert(table.foo == "ARG 2")
    return "return from metatable"
end

setmetatable(ts, mt)

assert(ts("ARG 1") == "return from metatable")

-- Let's try somethings lightly more complicated, like a deep comparison function implemented in Lua
function deepCompare(t1, t2)
    if t1 == t2 then return true end
    -- If they are the same object, return true
    if type(t1) ~= "table" or type(t2) ~= "table" then return false end
    -- If not both tables, return false
    -- Check if both tables have the same number of keys
    local t1_keys = 0
    local t2_keys = 0
    for k in pairs(t1) do
        t1_keys = t1_keys + 1
    end
    for k in pairs(t2) do
        t2_keys = t2_keys + 1
    end
    if t1_keys ~= t2_keys then return false end

    -- Recursively compare each key-value pair
    for k, v in pairs(t1) do
        if not deepCompare(v, t2[k]) then
            return false
        end
    end

    return true
end

assert(deepCompare({ 1, 2, 3 }, { 1, 2, 3 }))
assert(not deepCompare({ 1, 2, 3 }, { 1, 2 }))
assert(deepCompare({ a = 1, b = 2 }, { a = 1, b = 2 }))
assert(deepCompare(
    { a = { 1, 2, 3 }, b = { 4, 5, 6 } },
    { a = { 1, 2, 3 }, b = { 4, 5, 6 } }
))
assert(not deepCompare(
    { a = { 1, 2, 3 }, b = { 4, 5, 6 } },
    { a = { 1, 2, 3 }, b = { 4, 5, 7 } }
))

-- Closure tests
local function make_counter()
    local count = 0
    return function()
        count = count + 1
        return count
    end
end

local counter1 = make_counter()
local counter2 = make_counter()
assert(counter1() == 1, "First counter first call")
assert(counter1() == 2, "First counter second call")
assert(counter2() == 1, "Second counter should be independent")
assert(counter1() == 3, "First counter maintains state")

-- Test nested closures
local function make_adder(x)
    return function(y)
        return function(z)
            return x + y + z
        end
    end
end

local add5 = make_adder(5)
local add5and2 = add5(2)
assert(add5and2(3) == 10, "Nested closure should maintain all scopes")

-- Test closure variable independence
local function make_value_keeper()
    local value = 0
    return {
        set = function(v) value = v end,
        get = function() return value end
    }
end

local keeper1 = make_value_keeper()
local keeper2 = make_value_keeper()
keeper1.set(5)
keeper2.set(10)
assert(keeper1.get() == 5, "First keeper maintains its own value")
assert(keeper2.get() == 10, "Second keeper maintains its own value")

-- Test closure over loop variables
local functions = {}
for i = 1, 3 do
    functions[i] = function() return i end
end
assert(functions[1]() == 1, "Closure should capture loop variable value at creation time")
assert(functions[2]() == 2, "Each closure should have its own value")
assert(functions[3]() == 3, "Each closure should have its own value")

-- Test closure over mutating variables
local function make_accumulator(initial)
    local sum = initial
    return {
        add = function(x) sum = sum + x end,
        get = function() return sum end
    }
end

local acc = make_accumulator(5)
acc.add(3)
acc.add(2)
assert(acc.get() == 10, "Accumulator should maintain state through multiple calls")

-- Test closures with upvalues modified in nested scopes
local function make_counter_with_reset()
    local count = 0
    return {
        increment = function()
            local old = count
            count = count + 1
            return old
        end,
        reset = function()
            local old = count
            count = 0
            return old
        end
    }
end

local counter = make_counter_with_reset()
assert(counter.increment() == 0)
assert(counter.increment() == 1)
local final = counter.reset()
assert(final == 2, "Reset should return last value")
assert(counter.increment() == 0, "Counter should start fresh after reset")

-- Test custom iterators
-- Basic iterator that counts down from n to 1
local function countdown(n)
    local count = n
    return function()
        if count > 0 then
            local current = count
            count = count - 1
            return current
        end
    end
end

-- Test basic iterator usage
local sum = 0
for num in countdown(3) do
    sum = sum + num
end
assert(sum == 6, "Countdown iterator should sum to 6 (3+2+1)")

-- Iterator that returns even numbers from an array
local function even_values(arr)
    local index = 0
    return function()
        repeat
            index = index + 1
            if index > #arr then return nil end
            if arr[index] % 2 == 0 then
                return index, arr[index]
            end
        until false
    end
end

-- Test array iterator
local arr = { 1, 2, 3, 4, 6, 7, 8 }
local count = 0
local sum = 0
for i, v in even_values(arr) do
    count = count + 1
    sum = sum + v
end
assert(count == 4, "Should find 4 even numbers")
assert(sum == 20, "Sum of even numbers should be 20 (2+4+6+8)")

-- Range iterator with step
local function range(from, to, step)
    step = step or 1
    local current = from
    return function()
        if current > to then
            return nil
        end
        local value = current
        current = current + step
        return value
    end
end

-- Test range iterator with different steps
local function collect_range(from, to, step)
    local values = {}
    for v in range(from, to, step) do
        table.insert(values, v)
    end
    return values
end

local tblNew = { 1, 2, 3 }
table.insert(tblNew, 4)
assertEqual(#tblNew, 4)
assertEqual(tblNew[4], 4)

local values1 = collect_range(1, 5, 2)
assert(#values1 == 3, "Range with step 2 should return 3 values")
assert(values1[1] == 1 and values1[2] == 3 and values1[3] == 5, "Range values with step 2 should be correct")

local values2 = collect_range(10, 15)
assert(#values2 == 6, "Range with default step should return 6 values")
assert(values2[1] == 10 and values2[6] == 15, "Range values with default step should be correct")

local values3 = collect_range(1, 10, 3)
assert(#values3 == 4, "Range with step 3 should return 4 values")
assert(values3[1] == 1 and values3[2] == 4 and values3[3] == 7 and values3[4] == 10,
    "Range values with step 3 should be correct")

-- Test nested iterators
local function grid(rows, cols)
    local row = 0
    return function()
        row = row + 1
        if row <= rows then
            local col = 0
            return function()
                col = col + 1
                if col <= cols then
                    return row, col
                end
            end
        end
    end
end

local points = {}
for row_iter in grid(2, 3) do
    for r, c in row_iter do
        table.insert(points, { r, c })
    end
end

assert(#points == 6, "Grid should generate 6 points")
assert(points[1][1] == 1 and points[1][2] == 1, "First point should be (1,1)")
assert(points[6][1] == 2 and points[6][2] == 3, "Last point should be (2,3)")

-- Test for functions with variable number of arguments
function sum(...)
    local total = 0
    for i, v in ipairs({ ... }) do
        total = total + v
    end
    return total
end

assertEqual(sum(1, 2, 3), 6)
assertEqual(sum(1, 2, 3, 4, 5), 15)

local data = { { name = "John", favorite = { color = "blue" } }, { name = "Jane" } }
assertEqual(type(data[1].favorite), "table")
assertEqual(data[1].favorite.color, "blue")

local r = query [[from p = data where type(p.favorite) == "table" and p.favorite.color == "blue"]]
assertEqual(#r, 1)
assertEqual(r[1].name, "John")

-- Test string to number coercion for arithmetical operations
assertEqual(-1*' 16 '    ==  -16,  true)
assertEqual(0-'0X10'     ==  -16,  true)
assertEqual('-0x10'/1    ==  -16,  true)
assertEqual('-0X10p0'//1 ==  -16,  true)
assertEqual('0x10P0'%15  ==   1,   true)
assertEqual('0X10P-1'^-1 == 0.125, true)
assertEqual(-'16'        ==  -16,  true)

-- select tests
-- Base case
local a, b, c = select(1, 1, 2, 3)
assertEqual(a, 1)
assertEqual(b, 2)
assertEqual(c, 3)
-- One index later
local b, c = select(2, 1, 2, 3)
assertEqual(b, 2)
assertEqual(c, 3)
-- Negative index
local b, c = select(-2, 1, 2, 3)
assertEqual(b, 2)
assertEqual(c, 3)
-- Special "#" case
assertEqual(select("#", 1, 2, 3), 3)

-- next tests
local tbl = {a = "a value", b = "b value"}
-- base cases
assertEqual(next(nil), nil, "nil table case")
assertEqual(next({}), nil, "nil table case")
-- find first key (although this is not specified, we know this will be "a" in space lua)
local k, v = next(tbl)
assertEqual(k, "a")
assertEqual(v, "a value")
-- find the second key
local k, v = next(tbl, k)
assertEqual(k, "b")
assertEqual(v, "b value")
-- and now we should be done
assertEqual(next(tbl, k), nil)

-- Some more vararg verification
function varArgTest(a0, ...)
    -- ... gets the multi arg treatment
    local a1, a2 = ...
    assertEqual(a0, 1)
    assertEqual(a1, 2)
    assertEqual(a2, 3)
end

varArgTest(1, 2, 3, 4)

-- Some `rawlen` tests

-- Strings
assertEqual(rawlen(""), 0, "rawlen on empty string")
assertEqual(rawlen("abc"), 3, "rawlen on non-empty string")

-- Tables
assertEqual(rawlen({}), 0, "rawlen on empty table")
assertEqual(rawlen({1, 2, 3}), 3, "rawlen on array table")

-- `rawlen` ignores `__len` metamethod
do
  local t = {}
  setmetatable(t, { __len = function(_) return 42 end })
  assertEqual(rawlen(t), 0, "rawlen ignores __len (no array part)")
  t[1] = "x"; t[2] = "y"
  assertEqual(rawlen(t), 2, "rawlen returns raw array-part length")
end

-- JS arrays also have raw length
do
  local a0 = js.window.JSON.parse("[]")
  assertEqual(rawlen(a0), 0, "rawlen on empty JS array")

  local a4 = js.window.JSON.parse("[1,2,3,4]")
  assertEqual(rawlen(a4), 4, "rawlen on JS array length 4")
end

-- Non-string/non-table must error with proper message
local function expect_rawlen_error(v, typeName)
  local ok, err = pcall(
    function()
      return rawlen(v)
    end
  )

  assertEqual(ok, false, "rawlen must error on "
    .. tostring(typeName))

  local s = tostring(err)

  assert(
    string.find(s, "bad argument #1 to 'rawlen'", 1, true) ~= nil,
    "error must mention rawlen"
  )

  assert(
    string.find(s, typeName, 1, true) ~= nil,
    "error must mention type name"
  )
end

expect_rawlen_error(nil, "nil")
expect_rawlen_error(1, "number")
expect_rawlen_error(false, "boolean")
expect_rawlen_error(function() end, "function")

-- Some `rawget` tests

-- Tables
do
  local t = { "a", "b" }

  assertEqual(rawget(t, 1), "a")
  assertEqual(rawget(t, 2), "b")
  assertEqual(rawget(t, 3), nil, "rawget missing key returns nil")
end

-- `rawget` ignores `__index` metamethod
do
  local t = {}

  setmetatable(
    t,
    {
      __index = function()
        return "X"
      end
    }
  )

  assertEqual(rawget(t, "k"), nil, "rawget must ignore __index")

  t.k = "v"
  assertEqual(rawget(t, "k"), "v")
end

-- JS arrays
do
  local a = js.window.JSON.parse("[1, 2, 3]")

  assertEqual(rawget(a, 1), 1)
  assertEqual(rawget(a, 2), 2)
  assertEqual(rawget(a, 4), nil)
end

-- Plain JS objects
do
  local o = js.window.JSON.parse('{ "a": 1, "b": null }')

  assertEqual(rawget(o, "a"), 1)
  assertEqual(rawget(o, "b"), nil)
  assertEqual(rawget(o, "c"), nil)
end

-- `rawget` argument errors
local function expect_rawget_error(v, typeName)
  local ok, err = pcall(
    function()
      return rawget(v, "k")
    end
  )

  assertEqual(ok, false, "rawget must error on " .. tostring(typeName))

  local s = tostring(err)

  assert(
    string.find(s, "bad argument #1 to 'rawget'", 1, true) ~= nil,
    "error must mention rawget"
  )

  assert(
    string.find(s, typeName, 1, true) ~= nil,
    "error must mention type name"
  )
end

expect_rawget_error(nil, "nil")
expect_rawget_error(1, "number")
expect_rawget_error(false, "boolean")
expect_rawget_error(function() end, "function")

-- Some `rawequal` tests
do
  -- Primitives
  assertEqual(rawequal(1, 1), true)
  assertEqual(rawequal(1, 2), false)
  assertEqual(rawequal("x", "x"), true)
  assertEqual(rawequal("x", "y"), false)

  -- Tables: identity vs structural equality
  local t1 = {}
  local t2 = {}

  assertEqual(rawequal(t1, t1), true)
  assertEqual(rawequal(t1, t2), false)

  -- NaN is not equal to NaN
  local nan = 0/0

  assertEqual(rawequal(nan, nan), false)
end

-- Some `..` (concatenation) tests

-- Strings and numbers
assertEqual("a" .. "b", "ab")
assertEqual(1 .. "b", "1b")
assertEqual("a" .. 2, "a2")
assertEqual(4 .. 2, "42")
assertEqual("123" .. 45, "12345")

-- Multi-return (only first value is used)
do
  local function f()
    return "X", "Y"
  end

  assertEqual("A" .. f(), "AX")

  local function g()
    return 1, 2
  end

  assertEqual(g() .. "X", "1X")
end

local function expect_concat_nil_error(lhs, rhs)
  local ok, err = pcall(
    function()
      return lhs .. rhs
    end
  )

  assertEqual(ok, false, "concat must error on nil")
end

local function expect_concat_type_error(lhs, rhs)
  local ok, err = pcall(
    function()
      return lhs .. rhs
    end
  )

  assertEqual(ok, false, "concat must error on non-string/non-number")
end

-- Nil on either side
expect_concat_nil_error(nil, "x")
expect_concat_nil_error("x", nil)
expect_concat_nil_error(nil, 1)
expect_concat_nil_error(1, nil)
expect_concat_nil_error(nil, nil)

-- Non-string and non-number must error
expect_concat_type_error(true, "x")
expect_concat_type_error("x", false)
expect_concat_type_error(true, true)

do
  local t = {}

  expect_concat_type_error(t, "x")
  expect_concat_type_error("x", t)
  expect_concat_type_error(t, t)
end

-- JS arrays and JS objects behave as tables (no concat)
do
  local arr = js.window.JSON.parse("[1, 2]")

  expect_concat_type_error(arr, "x")
  expect_concat_type_error("x", arr)
  expect_concat_type_error(arr, arr)

  local obj = js.window.JSON.parse('{ "a": 1 }')

  expect_concat_type_error(obj, "x")
  expect_concat_type_error("x", obj)
  expect_concat_type_error(obj, obj)
end

-- `__tostring` alone does not relax concat rules
do
  local t = {}

  setmetatable(
    t,
    {
      __tostring = function(_)
        return "T"
      end
    }
  )

  expect_concat_type_error(t, "x")
  expect_concat_type_error("x", t)
  expect_concat_type_error(t, t)
end

-- `__concat` metamethod cases
do
  local L = {}

  setmetatable(
    L,
    {
      __concat = function(a, b)
        return "LEFT"
      end
    }
  )

  assertEqual(L .. "x", "LEFT")

  local R = {}

  setmetatable(
    R,
    {
      __concat = function(a, b)
        return "RIGHT"
      end
    }
  )

  assertEqual("x" .. R, "RIGHT")

  assertEqual(L .. R, "LEFT",
    "left-side __concat must be preferred when both defined")
end

-- `__concat` result handling (only first value used)
do
  local M = {}

  setmetatable(
    M,
    {
      __concat = function(a, b)
        return "A", "B"
      end
    }
  )

  assertEqual(M .. "x", "A",
    "__concat must use only first return value")
end


================================================
FILE: client/space_lua/len_test.lua
================================================
local function assertEquals(actual, expected, msg)
  if actual ~= expected then
    error('assertEquals failed: ' .. msg)
  end
end

local function assertThrows(substr, fn)
  local ok, err = pcall(fn)
  if ok then
    error("expected error containing: " .. substr)
  end

  err = tostring(err)
  if not string.find(err, substr, 1, true) then
    error("error message mismatch: got: " .. err)
  end
end

-- 1. Strings: raw length, ignore metamethods
assertEquals(#"", 0, "empty string length")
assertEquals(#"abc", 3, "string length")

-- 2. Tables (raw length for sequence or `__len`)
assertEquals(#{}, 0, "empty table length")
assertEquals(#{1,2,3}, 3, "array length")

do
  local t = {}

  setmetatable(t,
  {
    __len = function(_)
      return 42
    end,
  })

  assertEquals(#t, 42, "__len should override raw table length")
end

-- 2.1. Metamethod result handling
do
  local t = {}

  setmetatable(t,
  {
    __len = function(_)
      return 42, 1, 2, 3
    end,
  })

  assertEquals(#t, 42, "__len must use only first return value")
end

-- 2.2. Raw length when no array part
do
  local t = {
    a = 1,
    b = 2,
  }

  assertEquals(#t, 0, "table with only non-integer keys has length 0")
end

-- 2.3. Length when both array and key-value parts
do
  local t = {
    a = 1,
    b = 2,
    1,
    2,
    c = 3,
    3,
  }

  assertEquals(#t, 3, "table with mixed non-integer and integer keys")
end

-- 3. Non-strings and non-tables
assertThrows(
  "attempt to get length of a nil value",
  function()
    return #nil
  end
)

assertThrows(
  "attempt to get length of a number value",
  function()
    return #1
  end
)

assertThrows(
  "attempt to get length of a boolean value",
  function()
    return #false
  end
)

assertThrows(
  "attempt to get length of a function value",
  function()
    local f = function() end
    return #f
  end
)

-- 4. rawlen() semantics
do
  local t = { 1, 2, 3 }

  setmetatable(t, {
    __len = function(_) return 99 end,
  })

  assertEquals(#t, 99, "#t uses __len")
  assertEquals(rawlen(t), 3, "rawlen(t) ignores __len")
end

-- 5. Trailing-nil shrinking behavior
do
  local t = { 1, 2, 3 }
  assertEquals(#t, 3, "initial sequence length")

  t[3] = nil
  assertEquals(#t, 2, "sequence length shrinks when last element is set to nil")
  assertEquals(rawlen(t), 2, "rawlen shrinks with trailing nil (array part shrinks)")

  t[2] = nil
  assertEquals(#t, 1, "sequence length shrinks again after removing new last element")
  assertEquals(rawlen(t), 1, "rawlen shrinks again after trailing nil")
end

-- 6. rawlen on strings
assertEquals(rawlen("abc"), 3, "rawlen on strings")

-- 7. rawlen(t) must ignore the __len metamethod
local t = {1,2,3}

setmetatable(t, {
  __len = function()
    return 99 end
})

assert(#t == 99)
assert(rawlen(t) == 3)


================================================
FILE: client/space_lua/lua.test.ts
================================================
import { parse } from "./parse.ts";
import { luaBuildStandardEnv } from "./stdlib.ts";
import { LuaEnv, LuaRuntimeError, LuaStackFrame } from "./runtime.ts";
import { evalStatement } from "./eval.ts";
import { assert } from "@std/assert/assert";
import { fileURLToPath } from "node:url";

Deno.test("[Lua] Core language", async () => {
  await runLuaTest("./language_core_test.lua");
});

Deno.test("[Lua] Core language (labels and goto)", async () => {
  await runLuaTest("./goto_test.lua");
});

Deno.test("[Lua] Core language (length)", async () => {
  await runLuaTest("./len_test.lua");
});

Deno.test("[Lua] Core language (truthiness)", async () => {
  await runLuaTest("./truthiness_test.lua");
});

Deno.test("[Lua] Core language (arithmetic)", async () => {
  await runLuaTest("./arithmetic_test.lua");
});

Deno.test("[Lua] Core language (metamethods)", async () => {
  await runLuaTest("./metamethods_test.lua");
});

Deno.test("[Lua] Load tests", async () => {
  await runLuaTest("./stdlib/load_test.lua");
});

Deno.test("[Lua] Core language (length)", async () => {
  await runLuaTest("./len_test.lua");
});

Deno.test("[Lua] Format tests", async () => {
  await runLuaTest("./stdlib/format_test.lua");
});

Deno.test("[Lua] String to number tests", async () => {
  await runLuaTest("./tonumber_test.lua");
});

Deno.test("[Lua] Pattern tests", async () => {
  await runLuaTest("./stdlib/pattern_test.lua");
});

Deno.test("[Lua] String tests", async () => {
  await runLuaTest("./stdlib/string_test.lua");
});

Deno.test("[Lua] Space Lua tests", async () => {
  await runLuaTest("./stdlib/space_lua_test.lua");
});

Deno.test("[Lua] OS tests", async () => {
  await runLuaTest("./stdlib/os_test.lua");
});

Deno.test("[Lua] Math tests", async () => {
  await runLuaTest("./stdlib/math_test.lua");
});

Deno.test("[Lua] JS tests", async () => {
  await runLuaTest("./stdlib/js_test.lua");
});

Deno.test("[Lua] Global functions tests", async () => {
  await runLuaTest("./stdlib/global_test.lua");
});

Deno.test("[Lua] Encoding functions tests", async () => {
  await runLuaTest("./stdlib/encoding_test.lua");
});

Deno.test("[Lua] Crypto functions tests", async () => {
  await runLuaTest("./stdlib/crypto_test.lua");
});

Deno.test("[Lua] Lume functions tests", async () => {
  await runLuaTest("./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: client/space_lua/lume_test.lua
================================================
local function assertEqual(a, b, message)
    if a ~= b then
        error("Assertion failed: " .. a .. " is not equal to " .. b .. " " .. message)
    end
end

lume = lume or {}

local getiter = function(x)
  if lume.isarray(x) then
    return ipairs
  elseif type(x) == "table" then
    return pairs
  end
  error("expected table", 3)
end

function lume.isarray(x)
  return type(x) == "table" and x[1] ~= nil
end

function lume.concat(...)
  local rtn = {}
  for i = 1, select("#", ...) do
    local t = select(i, ...)
    if t ~= nil then
      local iter = getiter(t)
      for _, v in iter(t) do
        rtn[#rtn + 1] = v
      end
    end
  end
  return rtn
end

local r = lume.concat({1, 2}, {3, 4})
assertEqual(#r, 4)
assertEqual(r[1], 1)
assertEqual(r[4], 4)

================================================
FILE: client/space_lua/metamethods_test.lua
================================================
local function assertEquals(actual, expected, message)
  if actual ~= expected then
    error('Assertion failed: ' .. message
      .. ' (expected ' .. tostring(expected)
      .. ', got ' .. tostring(actual) .. ')')
  end
end

local function assertTrue(v, message)
  if v ~= true then
    error('Assertion failed: ' .. message .. ' (expected true)')
  end
end

local function assertFalse(v, message)
  if v ~= false then
    error('Assertion failed: ' .. message .. ' (expected false)')
  end
end

local function assertThrows(msg_substr, fn)
  local ok, err = pcall(fn)

  if ok then
    error('Assertion failed: expected error containing "'
      .. msg_substr .. '"')
  end

  if type(err) ~= 'string' then
    err = tostring(err)
  end

  if not string.find(err, msg_substr, 1, true) then
    error('Assertion failed: expected error message to contain "'
      .. msg_substr .. '", got: "' .. err .. '"')
  end
end

-- 1. Arithmetic + bitwise metamethods: values + multi-return trimming
do
  local mt = {}

  mt.__add = function(a, b) return a.tag .. '+' .. b.tag end
  mt.__sub = function(a, b) return a.tag .. '-' .. b.tag end
  mt.__mul = function(a, b) return a.tag .. '*' .. b.tag end
  mt.__div = function(a, b) return a.tag .. '/' .. b.tag end
  mt.__pow = function(a, b) return a.tag .. '^' .. b.tag end
  mt.__idiv = function(a, b) return a.tag .. '//' .. b.tag end
  mt.__mod = function(a, b) return a.tag .. '%' .. b.tag end
  mt.__unm = function(a) return '(-' .. a.tag .. ')' end
  mt.__bnot = function(a) return '(~' .. a.tag .. ')' end
  mt.__band = function(a, b) return a.tag .. '&' .. b.tag end
  mt.__bor = function(a, b) return a.tag .. '|' .. b.tag end
  mt.__bxor = function(a, b) return a.tag .. '~' .. b.tag end
  mt.__shl = function(a, b) return a.tag .. '<<' .. b.tag end
  mt.__shr = function(a, b) return a.tag .. '>>' .. b.tag end

  local a = setmetatable({ tag = 'A' }, mt)
  local b = setmetatable({ tag = 'B' }, mt)

  assertEquals(a + b, 'A+B', '__add produced correct value')
  assertEquals(a - b, 'A-B', '__sub produced correct value')
  assertEquals(a * b, 'A*B', '__mul produced correct value')
  assertEquals(a / b, 'A/B', '__div produced correct value')
  assertEquals(a ^ b, 'A^B', '__pow produced correct value')
  assertEquals(a // b, 'A//B', '__idiv produced correct value')
  assertEquals(a % b, 'A%B', '__mod produced correct value')

  assertEquals(-a, '(-A)', '__unm produced correct value')
  assertEquals(~a, '(~A)', '__bnot produced correct value')

  assertEquals(a & b, 'A&B', '__band produced correct value')
  assertEquals(a | b, 'A|B', '__bor produced correct value')
  assertEquals(a ~ b, 'A~B', '__bxor produced correct value')
  assertEquals(a << b, 'A<<B', '__shl produced correct value')
  assertEquals(a >> b, 'A>>B', '__shr produced correct value')

  -- Multi-return from metamethod: must take only the first return
  do
    local mt_mr = {
      __add = function(_, _) return 'first', 'second' end,
      __unm = function(_) return 7, 8 end,
      __bnot = function(_) return 9, 10 end,
      __concat = function(_, _) return "c1", "c2" end,
      __len = function(_) return 123, 999 end,
    }

    local x = setmetatable({}, mt_mr)

    assertEquals(x + x, 'first', 'binary operator uses first return value')
    assertEquals(-x, 7, 'unary operator uses first return value')
    assertEquals(~x, 9, 'unary operator uses first return value')
    assertEquals(x .. x, "c1", 'concat uses first return value')
    assertEquals(#x, 123, 'len uses first return value')
  end
end

-- 2. Comparison operators
do
  -- 2.1. No ordering for plain tables
  assertThrows("attempt to compare", function() return ({} < {}) end)
  assertThrows("attempt to compare", function() return ({} <= {}) end)
  assertThrows("attempt to compare", function() return ({} > {}) end)
  assertThrows("attempt to compare", function() return ({} >= {}) end)

  -- 2.2. With metamethods: `__lt` / `__le`
  do
    local mt = {
      __lt = function(a, b) return a.v < b.v end,
      __le = function(a, b) return a.v <= b.v end,
    }
    local function O(v) return setmetatable({ v = v }, mt) end

    assertTrue(O(1) < O(2), "__lt true")
    assertFalse(O(2) < O(1), "__lt false")
    assertTrue(O(2) <= O(2), "__le true")
    assertFalse(O(3) <= O(2), "__le false")
  end

-- 2.3. `__eq` semantics
do
  do
    local a, b = {}, {}
    assertFalse(a == b, "raw equality for distinct tables without __eq is false")
    assertTrue(a == a, "raw equality for same table is true")
  end

  do
    local calls = 0
    local mt = {
      __eq = function(_, _)
        calls = calls + 1
        return true
      end
    }
    local x = setmetatable({}, mt)
    local y = {} -- no metatable

    assertTrue(x == y, "__eq may be used from left operand metatable")
    assertTrue(y == x, "__eq may be used from right operand metatable")
    assertTrue(calls >= 1, "__eq was called at least once")
  end

  do
    local a = setmetatable({ v = 1 }, { __eq = function() return true end })
    local b = setmetatable({ v = 2 }, { __eq = function() return true end })
    assertTrue(a == b, "__eq true/true => true")
    assertFalse(a ~= b, "~= is negation of ==")
  end

  do
    local a = setmetatable({ v = 1 }, { __eq = function() return false end })
    local b = setmetatable({ v = 2 }, { __eq = function() return false end })
    assertFalse(a == b, "__eq false/false => false")
    assertTrue(a ~= b, "~= is negation of ==")
  end
end

-- 2.4. Operand swap rules: `a > b` is `b < a` and `a >= b` is `b <= a`
do
  local mt2 = { __lt = function(a, b) return a.v < b.v end }
  local function P(v) return setmetatable({ v = v }, mt2) end

  local a = P(3)
  local b = P(2)

  assertTrue(b < a, "sanity: b < a uses __lt")
  assertTrue(a > b, '">" uses swapped __lt')
  assertFalse(b > a, '">" uses swapped __lt (false case)')
end

do
  local mt3 = { __le = function(a, b) return a.v <= b.v end }
  local function P(v) return setmetatable({ v = v }, mt3) end

  local a = P(3)
  local b = P(2)

  assertTrue(b <= a, "sanity: b <= a uses __le")
  assertTrue(a >= b, '">=" uses swapped __le')
  assertFalse(b >= a, '">=" uses swapped __le (false case)')
end

-- 2.5. No `__le` fallback via `__lt` in Lua for metamethod comparisons
do
  local mt = { __lt = function(a, b) return a.v < b.v end }
  local function P(v) return setmetatable({ v = v }, mt) end

  local a = P(2)
  local b = P(2)

  assertFalse(a < b, "sanity: 2 < 2 is false via __lt")
  assertThrows("attempt to compare", function() return a <= b end)
end

do
  local mt = {
    __lt = function(a, b) return a.v < b.v end,
    __le = function(a, b) return a.v <= b.v end,
  }
  local function P(v) return setmetatable({ v = v }, mt) end
  local a = P(2)
  local b = P(2)
  assertTrue(a <= b, "__le works when provided")
end
end

-- 3. `rawlen` vs `#` when `__len` exists
do
  local t = setmetatable({ 1, 2, 3 }, { __len = function() return 10 end })
  assertEquals(#t, 10, '# uses __len')
  assertEquals(rawlen(t), 3, 'rawlen ignores __len on tables')
  assertEquals(rawlen("abc"), 3, 'rawlen works on strings')
end

-- 4. `__len`: tables use metamethod; strings are raw length
do
  local t = setmetatable({ x = 20 }, { __len = function(tt) return tt.x end })
  assertEquals(#t, 20, '__len dispatched for tables')
  t.x = "234"
  assertEquals(#t, "234", '__len can return non-number')
end

do
  local s = "abc"
  assertEquals(#s, 3, 'strings ignore __len metamethod')
end

-- 5. `__concat`: produced value and number/table interop
do
  local a = setmetatable({ x = "u" }, {
    __concat = function(l, r) return l.x .. "." .. r.x end
  })
  assertEquals(a .. a, "u.u", '__concat dispatched')
end

do
  local c = {}
  setmetatable(c, {
    __concat = function(a, b)
      if type(a) == "number" then
        assertEquals(b, c, '__concat rhs is c when lhs number')
        return "n..c"
      else
        assertEquals(a, c, '__concat lhs is c when rhs number')
        return "c..n"
      end
    end
  })
  assertEquals(c .. 5, "c..n", "__concat handles table .. number")
  assertEquals(5 .. c, "n..c", "__concat handles number .. table")
end

-- 6. `__call`: produced values
do
  local t = setmetatable({}, {
    __call = function(self, a, b)
      assertEquals(type(self), 'table', '__call self is table')
      return a + b
    end
  })
  assertEquals(t(2, 3), 5, '__call dispatched')
end

do
  local t = setmetatable({}, {
    __call = function(_, ...)
      return "a", "b", select('#', ...)
    end
  })
  local a, b, n = t(10, 20)
  assertEquals(a, "a", "__call multi-return first")
  assertEquals(b, "b", "__call multi-return second")
  assertEquals(n, 2, "__call sees arguments")
end

do
  local n = 200
  local function leaf()
    if n == 0 then return 1023 end
    n = n - 1
    return leaf()
  end

  local f = leaf
  for _ = 1, 50 do
    f = setmetatable({}, { __call = f })
  end

  assertEquals(f(), 1023, 'chain of __call metamethods works')
end

do
  local i = 0
  local tt = {
    __call = function(t, ...)
      i = i + 1
      if t.f then return t.f(...) end
      return {...}
    end
  }

  local a = setmetatable({}, tt)
  local b = setmetatable({ f = a }, tt)
  local c = setmetatable({ f = b }, tt)

  local x = c(3, 4, 5)
  assertEquals(i, 3, "__call nested chain increments i")
  assertEquals(x[1], 3, "__call nested chain arg1")
  assertEquals(x[2], 4, "__call nested chain arg2")
  assertEquals(x[3], 5, "__call nested chain arg3")
end

do
  local t = setmetatable({}, { __call = 123 })
  assertThrows("attempt to call a number value", function()
    return t()
  end)
end

-- 7. `__index` / `__newindex`: function + table forms
do
  local backing = { x = 10 }
  local t = setmetatable({}, {
    __index = function(_, k) return backing[k] end,
    __newindex = function(_, k, v) backing[k] = v end,
  })

  assertEquals(t.x, 10, '__index function dispatched')
  t.y = 20
  assertEquals(backing.y, 20, '__newindex function dispatched')
end

do
  local backing = { a = 1, b = 2 }
  local t = setmetatable({}, { __index = backing })
  assertEquals(t.a, 1, '__index table dispatched (a)')
  assertEquals(t.b, 2, '__index table dispatched (b)')
  assertEquals(t.c, nil, '__index table missing key yields nil')
end

do
  -- __newindex as table target (Lua 5.4)
  local backing = {}
  local t = setmetatable({}, { __newindex = backing })
  t.k = 99
  assertEquals(backing.k, 99, '__newindex table dispatched')
end

do
  local calls = 0
  local t = { present = 1 }
  setmetatable(t, {
    __newindex = function(tt, k, v)
      calls = calls + 1
      rawset(tt, k, v)
    end
  })

  t.present = 2
  assertEquals(calls, 0, '__newindex not called for existing key')

  t.absent = 3
  assertEquals(calls, 1, '__newindex called for absent key')
  assertEquals(t.absent, 3, '__newindex stored value')
end

do
  local a = {}
  setmetatable(a, a)
  a.__index = a
  a.__newindex = a

  assertThrows("chain too long", function()
    local _ = a[10]
  end)

  assertThrows("chain too long", function()
    a[10] = true
  end)
end

-- 8. `__pairs` and `ipairs` via `__index`-backed table
do
  local a = {}
  local out = {}

  local function foo(e, i)
    assertEquals(e, a, "__pairs sees original table")
    if i < 10 then
      return i + 1, i + 2
    end
  end

  setmetatable(a, { __pairs = function(x) return foo, x, 0 end })

  local i = 0
  for k, v in pairs(a) do
    i = i + 1
    out[i] = { k, v }
  end

  assertEquals(i, 10, "__pairs loop count")
  assertEquals(out[1][1], 1, "__pairs k1")
  assertEquals(out[1][2], 2, "__pairs v1")
  assertEquals(out[10][1], 10, "__pairs k10")
  assertEquals(out[10][2], 11, "__pairs v10")
end

do
  local a = { n = 5 }
  setmetatable(a, {
    __index = function(t, k)
      if type(k) == "number" and k <= t.n then
        return k * 10
      end
    end
  })

  local out = {}
  for k, v in ipairs(a) do
    out[#out + 1] = k .. ":" .. v
  end

  assertEquals(#out, 5, "ipairs iterates n entries via __index")
  assertEquals(out[1], "1:10", "ipairs entry 1")
  assertEquals(out[5], "5:50", "ipairs entry 5")
end

-- 9. `table` library interaction with metamethods
do
  local function check_proxy(proxy, backing)
    for i = 1, 10 do
      table.insert(proxy, 1, i)
    end
    assertEquals(#proxy, 10, "proxy length after inserts")
    assertEquals(#backing, 10, "backing length after inserts")

    for i = 1, 10 do
      assertEquals(backing[i], 11 - i, "backing reversed insert " .. i)
    end

    table.sort(proxy)
    for i = 1, 10 do
      assertEquals(backing[i], i, "backing sorted " .. i)
      assertEquals(proxy[i], i, "proxy sorted " .. i)
    end

    assertEquals(table.concat(proxy, ","), "1,2,3,4,5,6,7,8,9,10", "table.concat(proxy)")

    for i = 1, 8 do
      assertEquals(table.remove(proxy, 1), i, "table.remove(proxy,1) yields i")
    end
    assertEquals(#proxy, 2, "proxy length after removes")
    assertEquals(#backing, 2, "backing length after removes")

    local a, b, c = table.unpack(proxy)
    assertEquals(a, 9, "table.unpack(proxy) first")
    assertEquals(b, 10, "table.unpack(proxy) second")
    assertEquals(c, nil, "table.unpack(proxy) third nil")
  end

  local backing = {}
  local proxy = setmetatable({}, {
    __len = function() return #backing end,
    __index = backing,
    __newindex = backing,
  })
  check_proxy(proxy, backing)
end

-- 10. Comparison compatibility corner: metamethod dispatch present
do
  local mt1 = {
    __eq = function() return true end,
    __lt = function() return true end,
    __le = function() return false end,
  }

  local c = setmetatable({}, mt1)
  local d = setmetatable({}, mt1)

  assertTrue(c == d, "comparison compat: __eq true (same mt)")
  assertTrue(c < d, "comparison compat: __lt true (same mt)")
  assertFalse(d <= c, "comparison compat: __le false (same mt)")

  local mt2 = {
    __eq = mt1.__eq,
    __lt = mt1.__lt,
    __le = mt1.__le,
  }
  local e = setmetatable({}, mt1)
  local f = setmetatable({}, mt2)

  assertTrue(e == f, "comparison compat across metatables: __eq")
  assertTrue(e < f, "comparison compat across metatables: __lt")
  assertFalse(f <= e, "comparison compat across metatables: __le")
end

-- 11. Concat chain associativity + returning table from `__concat`
do
  local t = {}
  t.__concat = function(a, b)
    local av = (type(a) == "table") and a.val or tostring(a)
    local bv = (type(b) == "table") and b.val or tostring(b)
    return setmetatable({ val = av .. bv }, t)
  end

  local c = setmetatable({ val = "c" }, t)
  local d = setmetatable({ val = "d" }, t)

  local x = c .. d .. c .. d
  assertEquals(type(x), "table", "__concat chain returns table")
  assertEquals(x.val, "cdcd", "__concat chain value")
end

-- 12. `__eq` is not used for table indexing / hashing
do
  local mt = {}
  mt.__eq = function(_, _) return true end

  local function Set(x)
    local t = {}
    for _, k in pairs(x) do t[k] = true end
    return setmetatable(t, mt)
  end

  local k1 = Set{1, 2, 3}
  local k2 = Set{1, 2, 3}

  assertTrue(k1 == k2, "__eq says equal")
  assertFalse(rawequal(k1, k2), "not the same object")

  local t = {}
  t[k1] = 123

  assertEquals(t[k2], nil, "table indexing ignores __eq")
  assertEquals(t[k1], 123, "table indexing by same object works")
end

-- 13. operator metamethod lookup must be raw (no `__index` involvement)
do
  local mt = {
    __index = function(_, _)
      return function() return "BAD" end
    end
  }

  local a = setmetatable({}, mt)

  assertThrows("attempt to perform arithmetic on a table value", function()
    local _ = a + a
  end)
end

-- 14. `__tostring`: must return a string
do
  local m = setmetatable({ name = "NAME" }, {
    __tostring = function(x) return x.name end
  })
  assertEquals(tostring(m), "NAME", "__tostring used by tostring()")
end

do
  local m = setmetatable({}, {
    __tostring = function() return {} end
  })
  assertThrows("'__tostring' must return a string", function()
    return tostring(m)
  end)
end


================================================
FILE: client/space_lua/numeric.ts
================================================
import type { NumericType } from "./ast.ts";
import { luaToNumberDetailed } from "./tonumber.ts";
import { luaTypeName } from "./runtime.ts";

export interface LuaTaggedFloat {
  readonly value: number;
  readonly isFloat: true;
}

// Pre-allocated singletons for float zeros
const FLOAT_POS_ZERO: LuaTaggedFloat = { value: 0, isFloat: true };
const FLOAT_NEG_ZERO: LuaTaggedFloat = { value: -0, isFloat: true };

export const luaStringCoercionError: Error = new Error(
  "LuaStringCoercionError",
);

export function isNegativeZero(n: number): boolean {
  return n === 0 && 1 / n === -Infinity;
}

export function isTaggedFloat(v: unknown): v is LuaTaggedFloat {
  return v !== null && typeof v === "object" && (v as any).isFloat === true;
}

function makeFloat(n: number): LuaTaggedFloat {
  if (n === 0) {
    return isNegativeZero(n) ? FLOAT_NEG_ZERO : FLOAT_POS_ZERO;
  }
  return { value: n, isFloat: true };
}

// Box a zero with a given kind tag.
export function makeLuaZero(
  n: number,
  numericType: NumericType,
): any {
  if (n !== 0) {
    return n;
  }
  if (numericType !== "float") {
    return 0;
  }
  return isNegativeZero(n) ? FLOAT_NEG_ZERO : FLOAT_POS_ZERO;
}

// Tag an integer-valued number as float.
// Only allocates for integer-valued results; non-integer floats
// (1.5, NaN, Inf) are already unambiguously float as plain `number`.
export function makeLuaFloat(n: number): any {
  if (!Number.isInteger(n)) {
    return n;
  }
  return makeFloat(n);
}

export function getZeroBoxKind(x: any): NumericType | undefined {
  if (isTaggedFloat(x)) {
    return "float";
  }
  return undefined;
}

// Unwrap a potentially tagged or boxed Number to a plain number.
export function untagNumber(n: any): number {
  if (typeof n === "number") return n;
  if (isTaggedFloat(n)) {
    return n.value;
  }
  return n;
}

export function coerceToNumber(v: unknown): number | null {
  if (typeof v === "number") {
    return v;
  }
  if (isTaggedFloat(v)) {
    return v.value;
  }
  if (typeof v === "string") {
    const det = luaToNumberDetailed(v);
    if (!det) {
      return null;
    }
    return det.value;
  }
  return null;
}

export function inferNumericType(n: number): NumericType {
  if (!Number.isFinite(n)) {
    return "float";
  }
  if (isNegativeZero(n)) {
    return "float";
  }
  return Number.isInteger(n) ? "int" : "float";
}

export function combineNumericTypes(
  a: NumericType | undefined,
  b: NumericType | undefined,
): NumericType {
  if (a === "float" || b === "float") {
    return "float";
  }
  return "int";
}

export function getNumericKind(
  n: unknown,
): NumericType | undefined {
  if (typeof n === "number") {
    return inferNumericType(n);
  }
  if (isTaggedFloat(n)) {
    return "float";
  }
  return undefined;
}

export type CoerceNumericResult = {
  n: number;
  type: NumericType;
};

export function coerceNumeric(
  val: unknown,
  hint?: NumericType,
): CoerceNumericResult {
  if (typeof val === "number") {
    return { n: val, type: hint ?? inferNumericType(val) };
  }

  if (isTaggedFloat(val)) {
    return { n: val.value, type: hint ?? "float" };
  }

  if (typeof val === "string") {
    const det = luaToNumberDetailed(val);
    if (!det) {
      throw luaStringCoercionError;
    }
    return { n: det.value, type: hint ?? det.numericType };
  }

  throw new Error(
    `attempt to perform arithmetic on a ${luaTypeName(val)} value`,
  );
}

export type CoerceNumericPairResult = {
  left: number;
  right: number;
  resultType: NumericType;
};

export function coerceNumericPair(
  a: unknown,
  b: unknown,
  leftType?: NumericType,
  rightType?: NumericType,
  op?: string,
): CoerceNumericPairResult {
  const forceFloat = op === "/" || op === "^";

  // Both plain numbers
  if (typeof a === "number" && typeof b === "number") {
    const lt = leftType ?? inferNumericType(a);
    const rt = rightType ?? inferNumericType(b);

    return {
      left: a,
      right: b,
      resultType: forceFloat
        ? "float"
        : ((lt === "float" || rt === "float") ? "float" : "int"),
    };
  }

  // One tagged float, one plain number
  if (typeof a === "number" && isTaggedFloat(b)) {
    return {
      left: a,
      right: b.value,
      resultType: "float",
    };
  }

  if (isTaggedFloat(a) && typeof b === "number") {
    return {
      left: a.value,
      right: b,
      resultType: "float",
    };
  }

  // Both tagged floats
  if (isTaggedFloat(a) && isTaggedFloat(b)) {
    return {
      left: a.value,
      right: b.value,
      resultType: "float",
    };
  }

  // General fallback
  const A = coerceNumeric(a, leftType);
  const B = coerceNumeric(b, rightType);

  return {
    left: A.n,
    right: B.n,
    resultType: forceFloat ? "float" : combineNumericTypes(A.type, B.type),
  };
}

export function normalizeArithmeticResult(
  n: number,
  resultType: NumericType,
): number {
  if (n === 0) {
    if (resultType === "int") {
      return 0;
    }
    return isNegativeZero(n) ? -0 : 0;
  }
  return n;
}

export function toInteger(v: unknown): number | null {
  if (typeof v === "number") {
    return Number.isInteger(v) ? v : null;
  }
  if (isTaggedFloat(v)) {
    const n = v.value;
    return Number.isInteger(n) ? n : null;
  }
  return null;
}


================================================
FILE: client/space_lua/parse-lua.js
================================================
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
const spec_identifier = {__proto__:null,break:16, goto:20, do:24, end:26, while:30, nil:32, true:34, false:36, or:80, and:82, not:104, function:114, query:122, from:129, where:133, order:137, by:139, desc:143, select:147, limit:151, repeat:154, until:156, if:160, then:162, elseif:164, else:166, for:170, in:178, local:188, return:204}
export const parser = LRParser.deserialize({
  version: 14,
  states: "EUO!ZQPOOOOQO'#Cc'#CcO!UQPO'#CaO!bQPOOOOQO'#E{'#E{O!vQQO'#CwO$]QPO'#EzOOQO'#Ez'#EzO$gQPO'#EzOOQO'#E`'#E`O%vQPO'#E_OOQO'#Ev'#EvOOQO'#Eg'#EgO%{QPO'#C_OOQO'#C_'#C_QOQPOOO!UQPO'#CeO&`QPO'#CgO!vQQO'#CjO&gQPO'#DzO!vQQO'#D}O!UQPO'#ESO!UQPO'#EZO&nQPO'#EaO&vQQO'#EeO'^QPO,58{OOQO'#Cq'#CqO!UQPO,59^O!vQQO,59`O(gQQO'#C|O(nQQO'#FQOOQO'#E|'#E|OOQO,59f,59fO!UQPO,59fO(uQPO'#ExO,eQPO,59cOOQO'#Dc'#DcOOQO'#Dd'#DdOOQO'#De'#DeO!vQQO'#DaOOQO'#Ex'#ExO,lQPO'#DfO,qQSO'#DjO,vQPO'#EpO-OQPO,5<QO!vQQO,5:yOOQO-E8e-E8eOOQO,58y,58yOOQO,59P,59PO-WQPO,59RO-]QPO,59UO-dQPO,5:fO-iQPO,5:iO-pQPO'#FeOOQO'#EW'#EWO-{QPO'#EVO.QQPO,5:nO.VQPO'#E[O,lQPO,5:uO.bQQO'#EcO/xQPO'#FgOOQO'#Eb'#EbO!UQPO,5:wO1]QPO,5:{O2mQPO'#FROOQO'#EY'#EYOOQO,5;P,5;PO4SQPO,5;POOQO1G.g1G.gOOQO1G.x1G.xO4hQPO1G.zO4oQWO'#ExO7uQWO'#E{O!vQQO'#DOO9WQSO'#DQOOQO'#E}'#E}OOQO,59h,59hO9bQSO,59hOOQO,5;l,5;lO9jQPO,5;lO9oQPO1G/QOOQO1G.}1G.}OOQO'#DX'#DXOOQO'#DY'#DYOOQO'#DZ'#DZOOQO'#D['#D[OOQO'#D^'#D^OOQO'#D_'#D_OOQO'#D`'#D`O!vQQO,59oO!vQQO,59oO!vQQO,59oO!vQQO,59oO!vQQO,59oO!vQQO,59oO!vQQO,59oO!vQQO,59oO!vQQO,59oO;RQPO,59{O<kQQO'#DhOOQO,5:Q,5:QO=[QQO,5:UO=cQPO,5;[OOQO-E8n-E8nOOQO1G0e1G0eOOQO1G.m1G.mO&`QPO1G.pO!vQQO1G0QO=mQPO1G0TO!vQQO,5:pO!UQPO'#EnO=zQPO,5<PO!vQQO,5:qO&`QPO1G0YO!UQPO'#EoO>SQPO,5:vO!UQPO,5:vOOQO1G0a1G0aO!UQPO'#EdOOQO,5:},5:}O!UQPO'#EqO>_QPO,5<RO,lQPO1G0cO!vQQO1G0gO!vQQO'#EiO?rQPO,5;mOOQO1G0k1G0kOOQO7+$f7+$fOAVQQO,59kOBWQPO,59jOB_QQO1G/SOBfQSO1G/SOOQO1G/S1G/SODUQSO,59{OOQO1G1W1G1WOOQO7+$l7+$lOFxQPO1G/ZOGPQPO1G/ZOIpQPO1G/ZOIwQPO1G/ZOLbQPO1G/ZOLoQPO1G/ZONpQPO1G/ZO!$VQPO1G/ZO!$^QPO1G/ZO!$eQPO'#EzO!$oQPO'#FaOOQO'#Di'#DiO!$wQPO,5:SOOQO'#Dl'#DlOOQO'#Ek'#EkO!$|QQO1G/pOAVQQO'#DmOAVQQO'#DoO!%TQPO'#DqOAVQQO'#DvO!%YQQO'#DxOOQO1G/p1G/pO!&ZQPO7+$[O!&`QPO7+%lO!'oQPO7+%oO!'zQPO1G0[OOQO,5;Y,5;YOOQO-E8l-E8lOOQO1G0]1G0]O!(RQPO7+%tOOQO,5;Z,5;ZOOQO-E8m-E8mO!UQPO1G0bOOQO1G0b1G0bO!(WQQO,5;OOOQO,5;],5;]OOQO-E8o-E8oOOQO7+%}7+%}OOQO7+&R7+&RO!(]QPO,5;TOOQO-E8g-E8gO!)rQSO1G/VO!)|QPO1G/UO!+lQSO1G/ZO!+sQSO1G/ZO!-eQSO1G/ZO!-lQSO1G/ZO!/WQSO1G/ZO!/eQSO1G/ZO!1YQSO1G/ZO!2}QSO1G/ZO!3UQSO1G/ZOOQO,5;S,5;SOOQO7+$n7+$nO!3]QQO7+$nOOQO-E8f-E8fO!3dQQO'#EjO!3oQPO,5;{O&`QPO1G/nOOQO-E8i-E8iOOQO7+%[7+%[O!3wQWO,5:XO!4bQSO,5:XO!4xQSO,5:ZO!%YQQO,5:]O!5`QSO,5:bO!5vQ`O'#ExO!8jQ`O,5:dOOQO<<Gv<<GvO!9TQPO<<IZO!vQQO'#EmOOQO<<IZ<<IZO&`QPO<<IZO!vQQO7+%vOOQO<<I`<<I`OOQO7+%|7+%|OOQO1G0j1G0jOAVQQO7+$pOOQO<<HY<<HYP'cQQO'#EhO!9`QPO,5;UOOQO,5;U,5;UOOQO-E8h-E8hO!9jQPO7+%YOAVQQO1G/sO!9oQ`O'#DtO!:]QQO'#FcOOQO1G/w1G/wOAVQQO1G0OO!:tQ`O,59{OOQO-E8k-E8kOOQOAN>uAN>uO&`QPOAN>uO!;OQPO,5;XO!;VQPOAN>uO!;[QPO<<IbO!;fQSO<<H[OOQO<<Ht<<HtO!;pQSO7+%_OOQO,5:`,5:`O!%YQQO'#ElO!<WQQO,5;}O!>YQ`O1G/ZO!>aQ`O1G/ZO!@RQ`O1G/ZO!@YQ`O1G/ZO!AtQ`O1G/ZO!BRQ`O1G/ZO!B`Q`O1G/ZO!DTQ`O1G/ZO!D[Q`O1G/ZO!DcQSO7+%jO!DyQPOG24aO=mQPO1G0sOOQOG24aG24aO!vQQOAN>|OOQO,5;W,5;WOOQO-E8j-E8jOOQOLD){LD){OOQO7+&_7+&_O!EOQPOG24hOAVQQO'#DaO!%YQQO'#DaOAVQQO,59oO!%YQQO,59oOAVQQO,59oO!%YQQO,59oOAVQQO,59oO!%YQQO,59oOAVQQO,59oO!%YQQO,59oOAVQQO,59oO!%YQQO,59oOAVQQO,59oO!%YQQO,59oOAVQQO,59oO!%YQQO,59oOAVQQO,59oO!%YQQO,59oOAVQQO,59oO!%YQQO,59o",
  stateData: "!EY~O#hOS#iOSPOS~OSZOUQOWZOY`O[aO_bOlTO!ZfO!ocO!rdO!weO#QgO#YhO#kPO~O#fRP~P]OgkOilOlnOoqOqmO#mjO~O`xOaxObxOcxOdxOlTOqmO!UwO!ZyO!_zO#kPO#mjO#xuO#|tO$SvO~Og#nXi#nXl#nXo#nXq#nX#m#nX~Ov{O#r$YX~P#wOS#jXU#jXW#jXY#jX[#jX_#jXl#jX!Z#jX!o#jX!r#jX!w#jX#Q#jX#Y#jX#f#jX#k#jX]#jX!p#jX!t#jX!u#jX~P#wO#r}O~O#fRX]RX!pRX!tRX!uRX~P]O]RP~P]O!pRP~P]O!Z!`O#kPO~OS!dO#f#XX]#XX!p#XX!t#XX!u#XX~P!vOU!fO~O`xOaxObxOcxOdxOi!kOlTOqmO!U&kO!ZyO!_zO#kPO#mjO#xuO#|tO$SvO~Ou!nO~P'cOm!pO~P!vOm#lXx#lXy#lXz#lX!P#lX#v#lX#w#lX#x#lX#y#lX#z#lX#{#lX#|#lX#}#lX$O#lX$P#lX$Q#lX$R#lX[#lX!s#lXS#lXv#lX#f#lXj#lXU#lXW#lXY#lX_#lX!Z#lX!o#lX!r#lX!w#lX#Q#lX#Y#lX#k#lX]#lX!p#lX!t#lX!u#lX~P!bOx#SOy#TOz!{O!P#PO#v!tO#w!uO#x!vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zO~Om!sO~P+aOl#VO~O$U#XO~OlTO#kPO~Ov{O#r$Ya~O]#]O~O[#^O~P+aO!p#_O~O!s#`O~P+aOv#bO#r#aO!{$XX~O!{#dO~O[#eO~Og#fOo#hOl#OX~O$[#jOS#WPU#WPW#WPY#WP[#WP_#WPl#WPv#WP!Z#WP!o#WP!r#WP!w#WP#Q#WP#Y#WP#f#WP#k#WP#r#WP]#WP!p#WP!t#WP!u#WP~Ov#lOS$ZXU$ZXW$ZXY$ZX[$ZX_$ZXl$ZX!Z$ZX!o$ZX!r$ZX!w$ZX#Q$ZX#Y$ZX#f$ZX#k$ZX#r$ZX]$ZX!p$ZX!t$ZX!u$ZX~O#r#oOS#TaU#TaW#TaY#Ta[#Ta_#Tal#Ta!Z#Ta!o#Ta!r#Ta!w#Ta#Q#Ta#Y#Ta#f#Ta#k#Ta]#Ta!p#Ta!t#Ta!u#Ta~Ov#pOS#uX#f#uXm#uXU#uXW#uXY#uX[#uX_#uXl#uX!Z#uX!o#uX!r#uX!w#uX#Q#uX#Y#uX#k#uX]#uX!p#uX!t#uX!u#uX~P+aOS#rO#f#Xa]#Xa!p#Xa!t#Xa!u#Xa~Oj#sO~P+aOu#lXx#lXy#lXz#lX!P#lX#s#lX#v#lX#w#lX#x#lX#y#lX#z#lX#{#lX#|#lX#}#lX$O#lX$P#lX$Q#lX$R#lX!b#lX!d#lX!f#lX!k#lX!m#lX$W#lX~P!bOg#oXi#oXl#oXo#oXq#oXx#oXy#oXz#oX!P#oX#m#oX#v#oX#w#oX#x#oX#y#oX#z#oX#{#oX#|#oX#}#oX$O#oX$P#oX$Q#oX$R#oX~O#r#tOu#oX#s#oX~P6_Ox&{Oy&}Oz&mO!P&uO#v!tO#w!uO#x!vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zO~OutX#stX~P8SOu#xO#s#vO~Om#zO~OlnOqmO#mjO~O$R!zOx!Tay!Taz!Ta!P!Ta#v!Ta#w!Ta#x!Ta#y!Ta#z!Ta#{!Ta#|!Ta#}!Ta$O!Ta$P!Ta$Q!Tav!Ta~Om!Ta[!Ta!s!TaS!Ta#f!Taj!TaU!TaW!TaY!Ta_!Tal!Ta!Z!Ta!o!Ta!r!Ta!w!Ta#Q!Ta#Y!Ta#k!Ta]!Ta!p!Ta!t!Ta!u!Ta~P9zOc$WOlTO#kPOm!]P~O!b$^O!d$_O!f$`O!k$aO!m$bO~O$W$cO~P<yOv#da#r#da~P#wO]RP!tRP!uRP~P]Ov#bO!{$Xa~Og#fOo$nOl#Oa~Ov#lOS$ZaU$ZaW$ZaY$Za[$Za_$Zal$Za!Z$Za!o$Za!r$Za!w$Za#Q$Za#Y$Za#f$Za#k$Za#r$Za]$Za!p$Za!t$Za!u$Za~Ov#pOS#ua#f#uam#uaU#uaW#uaY#ua[#ua_#ual#ua!Z#ua!o#ua!r#ua!w#ua#Q#ua#Y#ua#k#ua]#ua!p#ua!t#ua!u#ua~O`xOaxObxOcxOdxOlTOqmO!U&kO!ZyO!_zO#kPO#mjO#xuO#|tO$SvO~Oj$xO~P+aOu%TO~P'cOu%TO#s%UO~O$R!zOx!Tay!Taz!Ta!P!Ta#v!Ta#w!Ta#x!Ta#y!Ta#z!Ta#{!Ta#|!Ta#}!Ta$O!Ta$P!Ta$Q!Ta!b!Ta!d!Ta!f!Ta!k!Ta!m!Ta$W!Ta~Ou!Ta#s!Ta~PBnO!P#PO#w!uO#x!vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zOmwixwiywizwi[wi!swiSwivwi#fwijwiUwiWwiYwi_wilwi!Zwi!owi!rwi!wwi#Qwi#Ywi#kwi]wi!pwi!twi!uwi~O#v!tO~PD`O#vwi~PD`O!P#PO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zOmwixwiywizwi#vwi#xwi[wi!swiSwivwi#fwijwiUwiWwiYwi_wilwi!Zwi!owi!rwi!wwi#Qwi#Ywi#kwi]wi!pwi!twi!uwi~O#wwi~PGWO#w!uO~PGWO#}!yO$O!yO$P!yO$Q!yO$R!zOmwixwiywizwi#vwi#wwi#xwi#ywi#zwi[wi!swiSwivwi#fwijwiUwiWwiYwi_wilwi!Zwi!owi!rwi!wwi#Qwi#Ywi#kwi]wi!pwi!twi!uwi~O!P#PO#{!xO#|!xO~PJOO!Pwi#{wi#|wi~PJOO$R!zOmwixwiywi[wi!swiSwivwi#fwijwiUwiWwiYwi_wilwi!Zwi!owi!rwi!wwi#Qwi#Ywi#kwi]wi!pwi!twi!uwi~Ozwi!Pwi#vwi#wwi#xwi#ywi#zwi#{wi#|wi#}wi$Owi$Pwi$Qwi~PL|Oz!{O!P#PO#v!tO#w!uO#x!vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zOmwixwi[wi!swiSwivwi#fwijwiUwiWwiYwi_wilwi!Zwi!owi!rwi!wwi#Qwi#Ywi#kwi]wi!pwi!twi!uwi~Oy#TO~P! mOywi~P! mOv%WOm$TX~P#wOv%WOm$TX~Om%YO~O$W%[O~P<yO!g%`O~O`xOaxObxOcxOdxOlTOqmO!U&lO!ZyO!_zO#kPO#mjO#xuO#|tO$SvO~O]%dO~OS!nqU!nqW!nqY!nq[!nq_!nql!nq!Z!nq!o!nq!r!nq!w!nq#Q!nq#Y!nq#f!nq#k!nq]!nq!p!nq!t!nq!u!nq~P+aO]%gO!t%fO!u%hO~Ov%iO~P+aO]%jO~O$]%lO~OS#]av#]a#f#]am#]aU#]aW#]aY#]a[#]a_#]al#]a!Z#]a!o#]a!r#]a!w#]a#Q#]a#Y#]a#k#]a]#]a!p#]a!t#]a!u#]a~P+aOusi#ssi~P8SO#r%mO~O!P&uO#w!uO#x!vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zOuwixwiywizwi#swi!bwi!dwi!fwi!kwi!mwi$Wwi~O#v!tO~P!*RO#vwi~P!*RO!P&uO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zOuwixwiywizwi#swi#vwi#xwi!bwi!dwi!fwi!kwi!mwi$Wwi~O#wwi~P!+zO#w!uO~P!+zO#}!yO$O!yO$P!yO$Q!yO$R!zOuwixwiywizwi#swi#vwi#wwi#xwi#ywi#zwi!bwi!dwi!fwi!kwi!mwi$Wwi~O!P&uO#{!xO#|!xO~P!-sO!Pwi#{wi#|wi~P!-sO$R!zOxwiywizwi!Pwi#vwi#wwi#xwi#ywi#zwi#{wi#|wi#}wi$Owi$Pwi$Qwi!bwi!dwi!fwi!kwi!mwi$Wwi~Ouwi#swi~P!/rOz&mO!P&uO#v!tO#w!uO#x!vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zOuwixwi#swi!bwi!dwi!fwi!kwi!mwi$Wwi~Oy&}O~P!1dOywi~P!1dOu%nO~P'cOc%qOlTO#kPO~Ov%WOm$Ta~O#r%tO!b#oX!d#oX!f#oX!k#oX!m#oX$W#oX~P6_O!b!aa!d!aa!f!aa!k!aa!m!aa$W!aa~P8SO!b!ca!d!ca!f!ca!k!ca!m!ca$W!ca~P8SO!b!ja!d!ja!f!ja!k!ja!m!ja$W!ja~P8SOv#lXx#lXy#lXz#lX!P#lX!b#lX!d#lX!f#lX!k#lX!m#lX#v#lX#w#lX#x#lX#y#lX#z#lX#{#lX#|#lX#}#lX$O#lX$P#lX$Q#lX$R#lX$W#lX!i#lX~P!bOx&|Oy'OOz&nO!P&vO#v!tO#w!uO#x!vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zO~Ov%xO!b!la!d!la!f!la!k!la!m!la$W!la~P!7fO]%{O!t%fO!u%|O~Om#^av#^a~P#wO]&RO~O!i&TOv!hX!b!hX!d!hX!f!hX!k!hX!m!hX$W!hX~P!7fOv&UO!b$VX!d$VX!f$VX!k$VX!m$VX$W$VX~Ov!Ta!i!Ta~PBnO!s&cO~P+aO]&dO~Ov&eO[!xy~P+aOury#sry~P8SO!b!aq!d!aq!f!aq!k!aq!m!aq$W!aq~P8SOv&UO!b$Va!d$Va!f$Va!k$Va!m$Va$W$Va~O!P&vO#w!uO#x!vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zOvwixwiywizwi!bwi!dwi!fwi!kwi!mwi$Wwi!iwi~O#v!tO~P!<oO#vwi~P!<oO!P&vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zOvwixwiywizwi!bwi!dwi!fwi!kwi!mwi#vwi#xwi$Wwi!iwi~O#wwi~P!>hO#w!uO~P!>hO#}!yO$O!yO$P!yO$Q!yO$R!zOvwixwiywizwi!bwi!dwi!fwi!kwi!mwi#vwi#wwi#xwi#ywi#zwi$Wwi!iwi~O!P&vO#{!xO#|!xO~P!@aO!Pwi#{wi#|wi~P!@aOvwi!iwi~P!/rOz&nO!P&vO#v!tO#w!uO#x!vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zOvwixwi!bwi!dwi!fwi!kwi!mwi$Wwi!iwi~Oy'OO~P!BjOywi~P!BjO!b!lq!d!lq!f!lq!k!lq!m!lq$W!lq~P8SO]&hO~O[!x!Z~P+aOP#|~",
  goto: "CY$[PPP$]P${P%YP${P${PP${PPPPPP'v)_P)_PP*}PP,mP'vP._._._PP'}PPP.e/[0X1RP2R3U4['}P5h5h5h'}P6|7V'}P7Y7^P7^P7^PP7bP7^P7^P${PP${PPPP${P7h7h7kP7n${7z${P${7}${8[8_8e8hP8w9W9^9d9k9q9w9}:T:Z:aPPPP:gP:tP>`@UA|BVPPB_BfPPPPPPPPPPPPPBoPBrPBuBxCVQ_OQ!RaQ!TcQ$d#^Q$f#`Q$k#eQ%s%YQ&O%hQ&b%|R&i&cgZO]ac#^#`#e%Y%h%|&c$OSOT]abcdhlnw{}!k!{!|!}#O#P#Q#R#S#T#V#^#_#`#a#d#e#o#p#t$_$a$b%W%Y%`%f%h%i%m%t%x%|&U&c&e&k&l&m&n&o&p&q&r&s&t&u&v&w&x&y&z&{&|&}'OQiQQ!Q`Q!VeQ!ZfS!]g#lQ!gkW!jm#v%U%oQ!rqQ#n!`Q$h#bQ$l#fQ$o#hQ$p#jQ%]$^R%k$nYoRr!i!r%b#mxTbdhlmnw}!k!{!|!}#O#P#Q#R#S#T#_#a#d#o#p#t#v$^$_$a$b%U%`%f%i%m%o%t%x&U&e&k&l&m&n&o&p&q&r&s&t&u&v&w&x&y&z&{&|&}'O$ZSOT]abcdhlmnw{}!k!{!|!}#O#P#Q#R#S#T#V#^#_#`#a#d#e#o#p#t#v$^$_$a$b%U%W%Y%`%f%h%i%m%o%t%x%|&U&c&e&k&l&m&n&o&p&q&r&s&t&u&v&w&x&y&z&{&|&}'O$ZVOT]abcdhlmnw{}!k!{!|!}#O#P#Q#R#S#T#V#^#_#`#a#d#e#o#p#t#v$^$_$a$b%U%W%Y%`%f%h%i%m%o%t%x%|&U&c&e&k&l&m&n&o&p&q&r&s&t&u&v&w&x&y&z&{&|&}'O#rVTbdhlmnw{}!k!{!|!}#O#P#Q#R#S#T#V#_#a#d#o#p#t#v$^$_$a$b%U%W%`%f%i%m%o%t%x&U&e&k&l&m&n&o&p&q&r&s&t&u&v&w&x&y&z&{&|&}'OgWO]ac#^#`#e%Y%h%|&cX!mm#v%U%on!|s!S!U!b!h#u#|$T$U$e$g$u%}&P&jf&o!l$w$y%Q%R%^%_%a&Q&S&aZ&p%c%u&W&_&`r!}s!S!U!b!h#u#|#}$P$T$U$e$g$u%}&P&jj&q!l$w$y$z$|%Q%R%^%_%a&Q&S&a_&r%c%u&W&X&Z&_&`p#Os!S!U!b!h#u#|#}$T$U$e$g$u%}&P&jh&s!l$w$y$z%Q%R%^%_%a&Q&S&a]&t%c%u&W&X&_&`t#Ps!S!U!b!h#u#|#}$O$P$T$U$e$g$u%}&P&jl&u!l$w$y$z${$|%Q%R%^%_%a&Q&S&aa&v%c%u&W&X&Y&Z&_&`v#Qs!S!U!b!h#u#|#}$O$P$Q$T$U$e$g$u%}&P&jn&w!l$w$y$z${$|$}%Q%R%^%_%a&Q&S&ac&x%c%u&W&X&Y&Z&[&_&`x#Rs!S!U!b!h#u#|#}$O$P$Q$R$T$U$e$g$u%}&P&jp&y!l$w$y$z${$|$}%O%Q%R%^%_%a&Q&S&ae&z%c%u&W&X&Y&Z&[&]&_&`|#Rs!S!U!b!h#U#u#|#}$O$P$Q$R$S$T$U$e$g$u%}&P&jt&y!l#y$w$y$z${$|$}%O%P%Q%R%^%_%a&Q&S&ai&z%c%u%y&W&X&Y&Z&[&]&^&_&`!UwTbdhlnw}!k!{!|!}#O#P#Q#R#S#T#_#a#d#o#p%f%i&ez&km#t#v$^$_$a%U%m%o%t%x&k&m&o&q&s&u&w&y&{&}k&l$b%`&U&l&n&p&r&t&v&x&z&|'OQ#WyQ#i![R$s#nR$Y#VT$[#X$]T$Z#X$]Q%v%`R&f&UR!YeR!XeQ!ehQ#[}Q$j#dR$t#oR![fgYO]ac#^#`#e%Y%h%|&cR!agQ!^gR$q#lR#k!]d^Oac#^#`#e%Y%h%|&cR!P]d]Oac#^#`#e%Y%h%|&cR!O]Q#w!oR%V#wQ#q!bR$v#qS%X$V$WR%r%XQ$]#XR%Z$]Q&V%vR&g&VQ%e$fR%z%eQ#c!VR$i#cQ#g!ZR$m#gQ|UR#Z|Q#m!^R$r#mg[O]ac#^#`#e%Y%h%|&cQsTQ!SbQ!UdY!bhn}#d#oQ!hlW!lm#v%U%oQ#UwQ#u!kQ#y&kQ#|!{Q#}!|Q$O!}Q$P#OQ$Q#PQ$R#QQ$S#RQ$T#SQ$U#TQ$e#_Q$g#aQ$u#pQ$w#tQ$y&mQ$z&oQ${&qQ$|&sQ$}&uQ%O&wQ%P&yQ%Q&{Q%R&}Q%^$^Q%_$_Q%a$aQ%c$bS%u%`&UQ%y&lQ%}%fQ&P%iQ&Q%mQ&S%tQ&W&nQ&X&pQ&Y&rQ&Z&tQ&[&vQ&]&xQ&^&zQ&_&|Q&`'OQ&a%xR&j&elRO]ac{#V#^#`#e%W%Y%h%|&c!UrTbdhlnw}!k!{!|!}#O#P#Q#R#S#T#_#a#d#o#p%f%i&ez!im#t#v$^$_$a%U%m%o%t%x&k&m&o&q&s&u&w&y&{&}k%b$b%`&U&l&n&p&r&t&v&x&z&|'OfUO]ac#^#`#e%Y%h%|&c#lVTbdhlmnw}!k!{!|!}#O#P#Q#R#S#T#_#a#d#o#p#t#v$^$_$a$b%U%`%f%i%m%o%t%x&U&e&k&l&m&n&o&p&q&r&s&t&u&v&w&x&y&z&{&|&}'OQ#Y{Q$V#VR%p%WWpRr!i%bR#{!rQ!omV%S#v%U%oZoRr!i!r%bW!ch}#d#oR!qnR$X#VR%w%`R!WegXO]ac#^#`#e%Y%h%|&cR!_g",
  nodeNames: "⚠ Comment Chunk Block ; Label :: Name break Goto goto Scope do end WhileStatement while nil true false Ellipsis Number LiteralString Property . MemberExpression [ ] Parens ( ) FunctionCall : TableConstructor { FieldDynamic FieldProp FieldExp } , BinaryExpression or and CompareOp BitOp BitOp BitOp BitOp Concat ArithOp ArithOp ArithOp UnaryExpression not ArithOp BitOp LenOp FunctionDef function FuncBody ArgList Query query QueryClause FromClause from WhereClause where OrderByClause order by OrderBy desc SelectClause select LimitClause limit RepeatStatement repeat until IfStatement if then elseif else ForStatement for ForNumeric ForGeneric NameList in ExpList Function FuncName LocalFunction local Assign VarList Local AttNameList AttName Attrib ReturnStatement return",
  maxTerm: 151,
  nodeProps: [
    ["group", -14,4,5,8,9,11,14,30,76,79,84,91,93,95,97,"Statement",-3,34,35,36,"Field"]
  ],
  skippedNodes: [0,1],
  repeatNodeCount: 11,
  tokenData: "Dj~RuXY#fYZ$Q[]#f]^$_pq#fqr$grs$rst)`uv)evw)jwx)oxy.Wyz.]z{.b{|.g|}.l}!O.s!O!P/g!P!Q/|!Q!R0^!R![1u![!]3m!]!^3z!^!_4R!_!`4e!`!a4m!c!}5P!}#O5b#O#P#w#P#QCo#Q#RC|#R#S5P#T#o5P#o#pDR#p#qDW#q#rD]#r#sDb~#kS#i~XY#f[]#fpq#f#O#P#w~#zQYZ#f]^#f~$VP#h~]^$Y~$_O#h~~$dP#h~YZ$Ym$jP!_!`$mm$rOzmk$uXOY$rZ]$r^r$rrs%bs#O$r#O#P%g#P;'S$r;'S;=`'}<%lO$rk%gO#mkk%j]rs$rwx$r!Q![&c#O#P$r#T#U$r#U#V$r#Y#Z$r#b#c$r#f#g$r#h#i$r#i#j(T#l#m(v#n#o$rk&fZOY$rZ]$r^r$rrs%bs!Q$r!Q!['X![#O$r#O#P%g#P;'S$r;'S;=`'}<%lO$rk'[ZOY$rZ]$r^r$rrs%bs!Q$r!Q![$r![#O$r#O#P%g#P;'S$r;'S;=`'}<%lO$rk(QP;=`<%l$rk(WP#o#p(Zk(^R!Q![(g!c!i(g#T#Z(gk(jS!Q![(g!c!i(g#T#Z(g#q#r$rk(yR!Q![)S!c!i)S#T#Z)Sk)VR!Q![$r!c!i$r#T#Z$r~)eO$S~~)jO$P~~)oO#w~k)rXOY)oZ])o^w)owx%bx#O)o#O#P*_#P;'S)o;'S;=`,u<%lO)ok*b]rs)owx)o!Q![+Z#O#P)o#T#U)o#U#V)o#Y#Z)o#b#c)o#f#g)o#h#i)o#i#j,{#l#m-n#n#o)ok+^ZOY)oZ])o^w)owx%bx!Q)o!Q![,P![#O)o#O#P*_#P;'S)o;'S;=`,u<%lO)ok,SZOY)oZ])o^w)owx%bx!Q)o!Q![)o![#O)o#O#P*_#P;'S)o;'S;=`,u<%lO)ok,xP;=`<%l)ok-OP#o#p-Rk-UR!Q![-_!c!i-_#T#Z-_k-bS!Q![-_!c!i-_#T#Z-_#q#r)ok-qR!Q![-z!c!i-z#T#Z-zk-}R!Q![)o!c!i)o#T#Z)o~.]Ol~~.bOm~~.gO#}~~.lO#{~o.sOvc#s[~.xP#|~}!O.{~/QTP~OY.{Z].{^;'S.{;'S;=`/a<%lO.{~/dP;=`<%l.{o/lPgi!O!P/oo/tP!Pm!O!P/wQ/|OcQ~0RQ$O~!P!Q0X!_!`$m~0^O$Q~~0cUd~!O!P0u!Q![1u!g!h1Z!z!{2W#X#Y1Z#l#m2W~0xP!Q![0{~1QRd~!Q![0{!g!h1Z#X#Y1Z~1^R{|1g}!O1g!Q![1m~1jP!Q![1m~1rPd~!Q![1m~1zSd~!O!P0u!Q![1u!g!h1Z#X#Y1Z~2ZR!Q![2d!c!i2d#T#Z2d~2iUd~!O!P2{!Q![2d!c!i2d!r!s1Z#T#Z2d#d#e1Z~3OR!Q![3X!c!i3X#T#Z3X~3^Td~!Q![3X!c!i3X!r!s1Z#T#Z3X#d#e1Z~3rPo~![!]3u~3zOU~_4ROSR#s[o4YQ$[Qzm!^!_4`!_!`$mm4eO#ym~4jP#r~!_!`$mo4tQ$]Qzm!_!`$m!`!a4zm5PO#zm~5US#k~!Q![5P!c!}5P#R#S5P#T#o5Po5gQik!_!`5m!}#OB_k5pQ!_!`5v!}#O=ok5yP!}#O5|k6PTO#P5|#P#Q6`#Q;'S5|;'S;=`8W<%lO5|k6cVO!_6x!_!`:s!`#P6x#P#Q;w#Q;'S6x;'S;=`=i<%lO6xk6{VO!_7b!_!`8^!`#P7b#P#Q8v#Q;'S7b;'S;=`=c<%lO7bk7eTO#P5|#P#Q7t#Q;'S5|;'S;=`8W<%lO5|k7wP!_!`7zk7}P!_!`8Qk8TP#P#Q%bk8ZP;=`<%l5|k8aTO#P8^#P#Q7t#Q;'S8^;'S;=`8p<%lO8^k8sP;=`<%l8^k8yUO!_5|!_!`9]!`#P5|#Q;'S5|;'S;=`8W<%lO5|k9`VO!_5|!_!`9u!`#P5|#P#Q6`#Q;'S5|;'S;=`8W<%lO5|k9xTO#P5|#P#Q:X#Q;'S5|;'S;=`8W<%lO5|k:^V#mkO!_6x!_!`:s!`#P6x#P#Q;w#Q;'S6x;'S;=`=i<%lO6xk:vVO!_8^!_!`;]!`#P8^#P#Q7t#Q;'S8^;'S;=`8p<%lO8^k;`TO#P8^#P#Q;o#Q;'S8^;'S;=`8p<%lO8^k;tP#mk!_!`7zk;zVO!_7b!_!`:s!`#P7b#P#Q<a#Q;'S7b;'S;=`=c<%lO7bk<dVO!_5|!_!`9]!`#P5|#P#Q<y#Q;'S5|;'S;=`8W<%lO5|k<|VO!_8^!_!`:s!`#P8^#P#Q<y#Q;'S8^;'S;=`8p<%lO8^k=fP;=`<%l7bk=lP;=`<%l6xk=rTO#P=o#P#Q>R#Q;'S=o;'S;=`>}<%lO=ok>UVO!_>k!_!`?T!`#P>k#P#Q@X#Q;'S>k;'S;=`Ao<%lO>kk>nTO#P=o#P#Q7z#Q;'S=o;'S;=`>}<%lO=ok?QP;=`<%l=ok?WTO#P?g#P#Q@P#Q;'S?g;'S;=`?y<%lO?gk?jTO#P?g#P#Q7z#Q;'S?g;'S;=`?y<%lO?gk?|P;=`<%l?gk@UP#mk!_!`8Qk@[VO!_=o!_!`@q!`#P=o#P#QAu#Q;'S=o;'S;=`>}<%lO=ok@tTO#P=o#P#QAT#Q;'S=o;'S;=`>}<%lO=okAYV#mkO!_>k!_!`?T!`#P>k#P#Q@X#Q;'S>k;'S;=`Ao<%lO>kkArP;=`<%l>kkAxVO!_?g!_!`?T!`#P?g#P#QAu#Q;'S?g;'S;=`?y<%lO?goBdT$USO#PBs#P#QCV#Q;'SBs;'S;=`Ci<%lOBskBvTO#PBs#P#QCV#Q;'SBs;'S;=`Ci<%lOBskCYTO#PBs#P#Q%b#Q;'SBs;'S;=`Ci<%lOBskClP;=`<%lBsoCtPjP#P#QCwnC|O$Wn~DRO$R~~DWOq~~D]O#v~~DbOu~~DgP#x~!_!`$m",
  tokenizers: [0, 1, 2, 3, 4],
  topRules: {"Chunk":[0,2]},
  dynamicPrecedences: {"128":1},
  specialized: [{term: 119, get: (value) => spec_identifier[value] || -1}],
  tokenPrec: 3779
})


================================================
FILE: client/space_lua/parse-lua.terms.js
================================================
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
  Comment = 1,
  Chunk = 2,
  Block = 3,
  Label = 5,
  Name = 7,
  Number = 20,
  LiteralString = 21,
  FunctionCall = 30,
  TableConstructor = 32,
  BinaryExpression = 39,
  CompareOp = 42,
  UnaryExpression = 51,
  FuncBody = 58,
  ArgList = 59,
  Query = 60,
  QueryClause = 62,
  FromClause = 63,
  WhereClause = 65,
  OrderByClause = 67,
  OrderBy = 70,
  SelectClause = 72,
  LimitClause = 74,
  IfStatement = 79,
  ForStatement = 84,
  ForNumeric = 86,
  ForGeneric = 87,
  NameList = 88,
  ExpList = 90,
  FuncName = 92,
  VarList = 96,
  AttNameList = 98,
  AttName = 99,
  Attrib = 100,
  ReturnStatement = 101


================================================
FILE: client/space_lua/parse.test.ts
================================================
import { parse, parseExpressionString, stripLuaComments } from "./parse.ts";
import type { LuaNumberLiteral } from "./ast.ts";
import { assertEquals } from "@std/assert/equals";

Deno.test("Test Lua parser", () => {
  // Basic block test
  parse(`
        print("Hello, World!")
        print(10)
`);
  parse("");
  // Expression tests
  parse(
    `e(1, 1.2, -3.8, -4, #lst, true, false, nil, "string", "", "Hello there \x00", ...)`,
  );
  parse(`e([[hel]lo]], "Grinny face\\u{1F600}")`);
  parse(`e([=[Hello page [[index]] end scene]=], [[yo]])`);

  // console.log(
  //   JSON.stringify(
  parse(`e([==[Hello page [[index]] end scene]==], [==[yo]==])`),
    // null,
    // 2,
    //   ),
    // );
    console.log(
      JSON.stringify(
        parse(`e([==[Hello page [[bla]]
]==])`),
        null,
        2,
      ),
    );

  parse(`e(10 << 10, 10 >> 10, 10 & 10, 10 | 10, 10 ~ 10)`);

  parse(`e(true and false or true)`);
  parse(`e(a < 3 and b > 4 or b == 5 or c <= 6 and d >= 7 or a /= 8)`);
  parse(`e(a.b.c)`);
  parse(`e((1+2))`);

  // Use keywordy variables
  parse(`e(order, limit, where)`);

  // Table expressions
  parse(`e({})`);
  parse(`e({1, 2, 3, })`);
  parse(`e({1 ; 2 ; 3})`);
  parse(`e({a = 1, b = 2, c = 3})`);
  parse(`e({[3] = 1, [10 * 10] = "sup"})`);
  parse(`e(tbl.name)`);
  parse(`e(tbl["name" + 10])`);
  parse(`e(test().bla)`);

  // Function calls
  parse(`e(func(), func(1, 2, 3), a.b(), a.b.c:hello(), (a.b)(7))`);

  // Function expression
  parse(`e(function(a, b) test() end)`);
  parse(`e(function(a, b, ...) end)`);

  // Statements
  parse(`do end`);
  parse(`do print() end`);
  parse(`::hello::
        goto hello`);
  parse(`while true do print() end`);
  parse(`repeat print() until false`);
  parse(
    `if 1 == 2 then print() elseif 1 < 2 then print2() else print3() end`,
  );
  parse(`if true then print() end`);
  parse(`if true then print() else print2() end`);
  parse(`if true then print() elseif false then print2() end`);

  // For loops
  parse(`for i = 1, 10, 1 do print(i) end`);
  parse(`for i = 1, 10 do print(i) end`);
  parse(`for el in each({1, 2, 3}) do print(i) end`);
  parse(`for i, l in 1, pairs() do print(i) end`);

  // Function statements
  parse(`function a() end`);
  parse(`function a:b() end`);
  parse(`function a.b.c:d() end`);
  parse(`function a.b.c() end`);
  parse(`function hello(a, b) end`);
  parse(`function hello(a, b, ...) end`);
  parse(`local function hello() end`);

  // Assignments, local variables etc.
  parse(`a = 1`);
  parse(`a, b = 1, 2`);
  parse(`a.b.c = 1`);
  parse(`a["name"] = 1`);
  parse(`local a, b<const>`);
  parse(`local a = 1`);
  parse(`local a<const> = 4`);
  parse(`local a, b = 1, 2`);

  // Function calls
  parse(`a(1, 2, 3)`);
  parse(`print "Sup"`);
  parse(`e(1 + print "8")`);

  // Return statements
  parse(`return`);
  parse(`return 1`);
  parse(`return 1, 2, 3`);
});

Deno.test("Test comment handling", () => {
  const code = `
-- Single line comment
--[[ Multi
line
comment ]]
f([[
hello
-- yo
]])`;
  const code2 = stripLuaComments(code);
  assertEquals(code2.length, code.length);
  console.log(code2);
  console.log(stripLuaComments(`e([==[
    --- Hello
  ]==])`));
});

Deno.test("Test query parsing", () => {
  parse(
    `_(query[[from p = index.tag("page") where p.name == "John" limit 10, 3]])`,
  );
  parse(`_(query[[from index.tag("page") select {name="hello", age=10}]])`);
  parse(
    `_(query[[from p = index.tag("page") order by p.lastModified desc, p.name]])`,
  );
  parse(`_(query[[from p = index.tag("page") order by p.lastModified]])`);
});

Deno.test("Test numeric constant parsing", () => {
  // Examples from Lua 5.4 Reference Manual, except hexadecimal constants with fractional part
  assertEquals((parseExpressionString(`3`) as LuaNumberLiteral).value, 3);
  assertEquals((parseExpressionString(`345`) as LuaNumberLiteral).value, 345);
  assertEquals((parseExpressionString(`0xff`) as LuaNumberLiteral).value, 0xff);
  assertEquals(
    (parseExpressionString(`0xBEBADA`) as LuaNumberLiteral).value,
    0xBEBADA,
  );
  assertEquals((parseExpressionString(`3.0`) as LuaNumberLiteral).value, 3.0);
  assertEquals(
    (parseExpressionString(`3.1416`) as LuaNumberLiteral).value,
    3.1416,
  );
  assertEquals(
    (parseExpressionString(`314.16e-2`) as LuaNumberLiteral).value,
    314.16e-2,
  );
  assertEquals(
    (parseExpressionString(`0.31416E1`) as LuaNumberLiteral).value,
    0.31416E1,
  );
  assertEquals(
    (parseExpressionString(`34e1`) as LuaNumberLiteral).value,
    34e1,
  );
});


================================================
FILE: client/space_lua/parse.ts
================================================
import { lezerToParseTree } from "../../client/markdown_parser/parse_tree.ts";
import type { SyntaxNode } from "@lezer/common";
import {
  cleanTree,
  type ParseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import { parser } from "./parse-lua.js";
import { styleTags, tags as t } from "@lezer/highlight";
import { indentNodeProp, LRLanguage } from "@codemirror/language";
import type {
  ASTCtx,
  LuaAttName,
  LuaBlock,
  LuaExpression,
  LuaFunctionBody,
  LuaFunctionCallExpression,
  LuaFunctionCallStatement,
  LuaFunctionName,
  LuaIfStatement,
  LuaLValue,
  LuaOrderBy,
  LuaPrefixExpression,
  LuaQueryClause,
  LuaStatement,
  LuaTableField,
} from "./ast.ts";
import { LuaAttribute } from "./ast.ts";
import { getBlockGotoMeta } from "./labels.ts";
import { LuaRuntimeError, LuaStackFrame } from "./runtime.ts";

const luaStyleTags = styleTags({
  Name: t.variableName,
  LiteralString: t.string,
  Number: t.number,
  CompareOp: t.operator,
  "true false": t.bool,
  Comment: t.lineComment,
  "return break goto do end while repeat until function local if then else elseif in for nil or and not query from where limit select order by desc":
    t.keyword,
});

const customIndent = indentNodeProp.add({
  "IfStatement FuncBody WhileStatement ForStatement TableConstructor": (
    context,
  ) => {
    return context.lineIndent(context.node.from) + context.unit;
  },
});

// Use the customIndent in your language support
export const luaLanguage = LRLanguage.define({
  name: "space-lua",
  parser: parser.configure({
    props: [
      luaStyleTags,
      customIndent,
    ],
  }),
  languageData: {
    commentTokens: { line: "--", block: { open: "--[[", close: "--]]" } },
  },
});

function context(t: ParseTree, ctx: Record<string, any>): ASTCtx {
  return { ...ctx, from: t.from, to: t.to };
}

function parseChunk(t: ParseTree, ctx: ASTCtx): LuaBlock {
  if (t.type !== "Chunk") {
    throw new Error(`Expected Chunk, got ${t.type}`);
  }
  return parseBlock(t.children![0], ctx);
}

function hasCloseLocal(names: LuaAttName[] | undefined): boolean {
  if (!names) {
    return false;
  }
  for (const n of names) {
    if (n.attributes?.includes(LuaAttribute.Close) === true) {
      return true;
    }
  }
  return false;
}

function expressionHasFunctionDef(e: LuaExpression): boolean {
  if (!e) return false;
  switch (e.type) {
    case "FunctionDefinition":
      return true;
    case "FunctionCall":
      if (expressionHasFunctionDef(e.prefix)) return true;
      for (let i = 0; i < e.args.length; i++) {
        if (expressionHasFunctionDef(e.args[i])) return true;
      }
      return false;
    case "Binary":
      return expressionHasFunctionDef(e.left) ||
        expressionHasFunctionDef(e.right);
    case "Unary":
      return expressionHasFunctionDef(e.argument);
    case "Parenthesized":
      return expressionHasFunctionDef(e.expression);
    case "TableConstructor":
      for (let i = 0; i < e.fields.length; i++) {
        const f = e.fields[i];
        switch (f.type) {
          case "DynamicField":
            if (expressionHasFunctionDef(f.key)) return true;
            if (expressionHasFunctionDef(f.value)) return true;
            break;
          case "PropField":
          case "ExpressionField":
            if (expressionHasFunctionDef(f.value)) return true;
            break;
        }
      }
      return false;
    case "TableAccess":
      return expressionHasFunctionDef(e.object) ||
        expressionHasFunctionDef(e.key);
    case "PropertyAccess":
      return expressionHasFunctionDef(e.object);
    case "Query":
      for (let i = 0; i < e.clauses.length; i++) {
        const c = e.clauses[i];
        switch (c.type) {
          case "From":
            if (expressionHasFunctionDef(c.expression)) return true;
            break;
          case "Where":
          case "Select":
            if (expressionHasFunctionDef(c.expression)) return true;
            break;
          case "Limit":
            if (expressionHasFunctionDef(c.limit)) return true;
            if (c.offset && expressionHasFunctionDef(c.offset)) return true;
            break;
          case "OrderBy":
            for (let j = 0; j < c.orderBy.length; j++) {
              if (expressionHasFunctionDef(c.orderBy[j].expression)) {
                return true;
              }
            }
            break;
        }
      }
      return false;
    default:
      return false;
  }
}

function expressionsHaveFunctionDef(
  exprs: LuaExpression[] | undefined,
): boolean {
  if (!exprs) return false;
  for (let i = 0; i < exprs.length; i++) {
    if (expressionHasFunctionDef(exprs[i])) return true;
  }
  return false;
}

// Does the expression reference any of `names`?
// Note: It DOES NOT descend into `FunctionDefinition`.
function exprReferencesNames(e: LuaExpression, names: Set<string>): boolean {
  if (!e) return false;
  switch (e.type) {
    case "Variable":
      return names.has(e.name);
    case "Binary":
      return exprReferencesNames(e.left, names) ||
        exprReferencesNames(e.right, names);
    case "Unary":
      return exprReferencesNames(e.argument, names);
    case "Parenthesized":
      return exprReferencesNames(e.expression, names);
    case "FunctionCall":
      if (exprReferencesNames(e.prefix, names)) return true;
      for (let i = 0; i < e.args.length; i++) {
        if (exprReferencesNames(e.args[i], names)) return true;
      }
      return false;
    case "TableAccess":
      return exprReferencesNames(e.object, names) ||
        exprReferencesNames(e.key, names);
    case "PropertyAccess":
      return exprReferencesNames(e.object, names);
    case "TableConstructor":
      for (let i = 0; i < e.fields.length; i++) {
        const f = e.fields[i];
        switch (f.type) {
          case "DynamicField":
            if (exprReferencesNames(f.key, names)) return true;
            if (exprReferencesNames(f.value, names)) return true;
            break;
          case "PropField":
          case "ExpressionField":
            if (exprReferencesNames(f.value, names)) return true;
            break;
        }
      }
      return false;
    case "FunctionDefinition":
      return false;
    case "Query":
      for (let i = 0; i < e.clauses.length; i++) {
        const c = e.clauses[i];
        switch (c.type) {
          case "From":
            if (exprReferencesNames(c.expression, names)) return true;
            break;
          case "Where":
          case "Select":
            if (exprReferencesNames(c.expression, names)) return true;
            break;
          case "Limit":
            if (exprReferencesNames(c.limit, names)) return true;
            if (c.offset && exprReferencesNames(c.offset, names)) return true;
            break;
          case "OrderBy":
            for (let j = 0; j < c.orderBy.length; j++) {
              if (exprReferencesNames(c.orderBy[j].expression, names)) {
                return true;
              }
            }
            break;
        }
      }
      return false;
    default:
      return false;
  }
}

function lvalueReferencesNames(lv: LuaLValue, names: Set<string>): boolean {
  switch (lv.type) {
    case "Variable":
      return names.has(lv.name);
    case "PropertyAccess":
      return exprReferencesNames(lv.object as LuaExpression, names);
    case "TableAccess":
      return exprReferencesNames(lv.object as LuaExpression, names) ||
        exprReferencesNames(lv.key, names);
  }
}

// Does a function body reference any of `names` NOT shadowed by its
// parameters?
function functionBodyCapturesNames(
  body: LuaFunctionBody,
  names: Set<string>,
): boolean {
  let unshadowed: Set<string> | null = null;
  for (let i = 0; i < body.parameters.length; i++) {
    if (names.has(body.parameters[i])) {
      if (!unshadowed) unshadowed = new Set(names);
      unshadowed.delete(body.parameters[i]);
    }
  }
  const check = unshadowed ?? names;
  if (check.size === 0) return false;
  return blockReferencesNames(body.block, check);
}

// Walk block using `exprReferencesNames` (inside a function body).
function blockReferencesNames(block: LuaBlock, names: Set<string>): boolean {
  for (let i = 0; i < block.statements.length; i++) {
    if (statementReferencesNames(block.statements[i], names)) return true;
  }
  return false;
}

function statementReferencesNames(
  s: LuaStatement,
  names: Set<string>,
): boolean {
  switch (s.type) {
    case "Local": {
      const exprs = (s as any).expressions as LuaExpression[] | undefined;
      if (exprs) {
        for (let i = 0; i < exprs.length; i++) {
          if (exprReferencesNames(exprs[i], names)) return true;
        }
      }
      return false;
    }
    case "LocalFunction": {
      const lf = s as any;
      return functionBodyCapturesNames(lf.body as LuaFunctionBody, names);
    }
    case "Function": {
      const fn = s as any;
      return functionBodyCapturesNames(fn.body as LuaFunctionBody, names);
    }
    case "FunctionCallStatement": {
      const call = (s as any).call as LuaFunctionCallExpression;
      if (exprReferencesNames(call.prefix, names)) return true;
      for (let i = 0; i < call.args.length; i++) {
        if (exprReferencesNames(call.args[i], names)) return true;
      }
      return false;
    }
    case "Assignment": {
      const a = s as any;
      const vars = a.variables as LuaLValue[];
      if (vars) {
        for (let i = 0; i < vars.length; i++) {
          if (lvalueReferencesNames(vars[i], names)) return true;
        }
      }
      const exprs = a.expressions as LuaExpression[];
      for (let i = 0; i < exprs.length; i++) {
        if (exprReferencesNames(exprs[i], names)) return true;
      }
      return false;
    }
    case "Return": {
      const exprs = (s as any).expressions as LuaExpression[];
      for (let i = 0; i < exprs.length; i++) {
        if (exprReferencesNames(exprs[i], names)) return true;
      }
      return false;
    }
    case "Block":
      return blockReferencesNames(s as LuaBlock, names);
    case "If": {
      const iff = s as LuaIfStatement;
      for (const c of iff.conditions) {
        if (exprReferencesNames(c.condition, names)) return true;
        if (blockReferencesNames(c.block, names)) return true;
      }
      if (iff.elseBlock && blockReferencesNames(iff.elseBlock, names)) {
        return true;
      }
      return false;
    }
    case "While": {
      const w = s as any;
      if (exprReferencesNames(w.condition, names)) return true;
      return blockReferencesNames(w.block as LuaBlock, names);
    }
    case "Repeat": {
      const r = s as any;
      if (blockReferencesNames(r.block as LuaBlock, names)) return true;
      if (exprReferencesNames(r.condition, names)) return true;
      return false;
    }
    case "For": {
      const fr = s as any;
      if (exprReferencesNames(fr.start, names)) return true;
      if (exprReferencesNames(fr.end, names)) return true;
      if (fr.step && exprReferencesNames(fr.step, names)) return true;
      return blockReferencesNames(fr.block as LuaBlock, names);
    }
    case "ForIn": {
      const fi = s as any;
      const exprs = fi.expressions as LuaExpression[];
      for (let i = 0; i < exprs.length; i++) {
        if (exprReferencesNames(exprs[i], names)) return true;
      }
      return blockReferencesNames(fi.block as LuaBlock, names);
    }
    default:
      return false;
  }
}

// Walk block looking for `FunctionDefinition` nodes that capture `names`.
function blockCapturesNames(block: LuaBlock, names: Set<string>): boolean {
  for (let i = 0; i < block.statements.length; i++) {
    if (statementCapturesNames(block.statements[i], names)) return true;
  }
  return false;
}

function statementCapturesNames(
  s: LuaStatement,
  names: Set<string>,
): boolean {
  switch (s.type) {
    case "Local": {
      const exprs = (s as any).expressions as LuaExpression[] | undefined;
      if (exprs) {
        for (let i = 0; i < exprs.length; i++) {
          if (exprCapturesNames(exprs[i], names)) return true;
        }
      }
      return false;
    }
    case "LocalFunction": {
      const lf = s as any;
      return functionBodyCapturesNames(lf.body as LuaFunctionBody, names);
    }
    case "Function": {
      const fn = s as any;
      return functionBodyCapturesNames(fn.body as LuaFunctionBody, names);
    }
    case "FunctionCallStatement": {
      const call = (s as any).call as LuaFunctionCallExpression;
      if (exprCapturesNames(call.prefix, names)) return true;
      for (let i = 0; i < call.args.length; i++) {
        if (exprCapturesNames(call.args[i], names)) return true;
      }
      return false;
    }
    case "Assignment": {
      const exprs = (s as any).expressions as LuaExpression[];
      for (let i = 0; i < exprs.length; i++) {
        if (exprCapturesNames(exprs[i], names)) return true;
      }
      return false;
    }
    case "Return": {
      const exprs = (s as any).expressions as LuaExpression[];
      for (let i = 0; i < exprs.length; i++) {
        if (exprCapturesNames(exprs[i], names)) return true;
      }
      return false;
    }
    case "Block":
      return blockCapturesNames(s as LuaBlock, names);
    case "If": {
      const iff = s as LuaIfStatement;
      for (const c of iff.conditions) {
        if (exprCapturesNames(c.condition, names)) return true;
        if (blockCapturesNames(c.block, names)) return true;
      }
      if (iff.elseBlock && blockCapturesNames(iff.elseBlock, names)) {
        return true;
      }
      return false;
    }
    case "While": {
      const w = s as any;
      if (exprCapturesNames(w.condition, names)) return true;
      return blockCapturesNames(w.block as LuaBlock, names);
    }
    case "Repeat": {
      const r = s as any;
      if (blockCapturesNames(r.block as LuaBlock, names)) return true;
      if (exprCapturesNames(r.condition, names)) return true;
      return false;
    }
    case "For": {
      const fr = s as any;
      if (exprCapturesNames(fr.start, names)) return true;
      if (exprCapturesNames(fr.end, names)) return true;
      if (fr.step && exprCapturesNames(fr.step, names)) return true;
      return blockCapturesNames(fr.block as LuaBlock, names);
    }
    case "ForIn": {
      const fi = s as any;
      const exprs = fi.expressions as LuaExpression[];
      for (let i = 0; i < exprs.length; i++) {
        if (exprCapturesNames(exprs[i], names)) return true;
      }
      return blockCapturesNames(fi.block as LuaBlock, names);
    }
    default:
      return false;
  }
}

// At loop block level find `FunctionDefinition` and check if it
// captures `names`.
function exprCapturesNames(e: LuaExpression, names: Set<string>): boolean {
  if (!e) return false;
  switch (e.type) {
    case "FunctionDefinition":
      return functionBodyCapturesNames(e.body, names);
    case "Binary":
      return exprCapturesNames(e.left, names) ||
        exprCapturesNames(e.right, names);
    case "Unary":
      return exprCapturesNames(e.argument, names);
    case "Parenthesized":
      return exprCapturesNames(e.expression, names);
    case "FunctionCall":
      if (exprCapturesNames(e.prefix, names)) return true;
      for (let i = 0; i < e.args.length; i++) {
        if (exprCapturesNames(e.args[i], names)) return true;
      }
      return false;
    case "TableAccess":
      return exprCapturesNames(e.object, names) ||
        exprCapturesNames(e.key, names);
    case "PropertyAccess":
      return exprCapturesNames(e.object, names);
    case "TableConstructor":
      for (let i = 0; i < e.fields.length; i++) {
        const f = e.fields[i];
        switch (f.type) {
          case "DynamicField":
            if (exprCapturesNames(f.key, names)) return true;
            if (exprCapturesNames(f.value, names)) return true;
            break;
          case "PropField":
          case "ExpressionField":
            if (exprCapturesNames(f.value, names)) return true;
            break;
        }
      }
      return false;
    case "Query":
      for (let i = 0; i < e.clauses.length; i++) {
        const c = e.clauses[i];
        switch (c.type) {
          case "From":
            if (exprCapturesNames(c.expression, names)) return true;
            break;
          case "Where":
          case "Select":
            if (exprCapturesNames(c.expression, names)) return true;
            break;
          case "Limit":
            if (exprCapturesNames(c.limit, names)) return true;
            if (c.offset && exprCapturesNames(c.offset, names)) return true;
            break;
          case "OrderBy":
            for (let j = 0; j < c.orderBy.length; j++) {
              if (exprCapturesNames(c.orderBy[j].expression, names)) {
                return true;
              }
            }
            break;
        }
      }
      return false;
    default:
      return false;
  }
}

function parseBlock(t: ParseTree, ctx: ASTCtx): LuaBlock {
  if (t.type !== "Block") {
    throw new Error(`Expected Block, got ${t.type}`);
  }
  const stmtNodes = t.children!.filter((c) => c && c.type);
  const statements = stmtNodes.map((s) => parseStatement(s, ctx));
  const block: LuaBlock = { type: "Block", statements, ctx: context(t, ctx) };

  let hasLabel = false;
  let hasGoto = false;
  let hasLocalDecl = false;
  let dup: { name: string; ctx: ASTCtx } | undefined;
  let hasLabelHere = false;
  let hasCloseHere = false;
  let hasFunctionDef = false;

  const seen = new Set<string>();

  for (const s of statements) {
    switch (s.type) {
      case "Label": {
        hasLabel = true;
        hasLabelHere = true;
        // Duplicate labels in the same block are illegal
        const name = (s as any).name as string;
        if (!dup) {
          if (seen.has(name)) {
            dup = { name, ctx: (s as any).ctx as ASTCtx };
          } else {
            seen.add(name);
          }
        }
        break;
      }
      case "Goto": {
        hasGoto = true;
        break;
      }
      case "Local": {
        hasLocalDecl = true;
        if (!hasCloseHere) {
          hasCloseHere = hasCloseLocal((s as any).names as LuaAttName[]);
        }
        if (!hasFunctionDef) {
          hasFunctionDef = expressionsHaveFunctionDef(
            (s as any).expressions as LuaExpression[] | undefined,
          );
        }
        break;
      }
      case "LocalFunction": {
        hasLocalDecl = true;
        hasFunctionDef = true;
        break;
      }
      case "Function": {
        hasFunctionDef = true;
        break;
      }
      case "FunctionCallStatement": {
        if (!hasFunctionDef) {
          const call = (s as any).call as LuaFunctionCallExpression;
          hasFunctionDef = expressionHasFunctionDef(call.prefix) ||
            expressionsHaveFunctionDef(call.args);
        }
        break;
      }
      case "Assignment": {
        if (!hasFunctionDef) {
          hasFunctionDef = expressionsHaveFunctionDef(
            (s as any).expressions as LuaExpression[],
          );
        }
        break;
      }
      case "Return": {
        if (!hasFunctionDef) {
          hasFunctionDef = expressionsHaveFunctionDef(
            (s as any).expressions as LuaExpression[],
          );
        }
        break;
      }
      case "Block": {
        const child = s as LuaBlock;
        hasLabel = hasLabel || !!child.hasLabel;
        hasGoto = hasGoto || !!child.hasGoto;
        hasCloseHere = hasCloseHere || !!child.hasCloseHere;
        hasFunctionDef = hasFunctionDef || !!child.hasFunctionDef;
        break;
      }
      case "If": {
        const iff = s as LuaIfStatement;
        for (const c of iff.conditions) {
          hasLabel = hasLabel || !!c.block.hasLabel;
          hasGoto = hasGoto || !!c.block.hasGoto;
          hasCloseHere = hasCloseHere || !!c.block.hasCloseHere;
          hasFunctionDef = hasFunctionDef || !!c.block.hasFunctionDef;
          if (!hasFunctionDef) {
            hasFunctionDef = expressionHasFunctionDef(c.condition);
          }
        }
        if (iff.elseBlock) {
          hasLabel = hasLabel || !!iff.elseBlock.hasLabel;
          hasGoto = hasGoto || !!iff.elseBlock.hasGoto;
          hasCloseHere = hasCloseHere || !!iff.elseBlock.hasCloseHere;
          hasFunctionDef = hasFunctionDef || !!iff.elseBlock.hasFunctionDef;
        }
        break;
      }
      case "While":
      case "Repeat": {
        const child = (s as any).block as LuaBlock;
        hasLabel = hasLabel || !!child.hasLabel;
        hasGoto = hasGoto || !!child.hasGoto;
        hasCloseHere = hasCloseHere || !!child.hasCloseHere;
        hasFunctionDef = hasFunctionDef || !!child.hasFunctionDef;
        if (!hasFunctionDef) {
          hasFunctionDef = expressionHasFunctionDef((s as any).condition);
        }
        break;
      }
      case "For": {
        const child = (s as any).block as LuaBlock;
        hasLabel = hasLabel || !!child.hasLabel;
        hasGoto = hasGoto || !!child.hasGoto;
        hasCloseHere = hasCloseHere || !!child.hasCloseHere;
        hasFunctionDef = hasFunctionDef || !!child.hasFunctionDef;
        if (!hasFunctionDef) {
          hasFunctionDef = expressionHasFunctionDef((s as any).start) ||
            expressionHasFunctionDef((s as any).end) ||
            ((s as any).step
              ? expressionHasFunctionDef((s as any).step)
              : false);
        }
        break;
      }
      case "ForIn": {
        const child = (s as any).block as LuaBlock;
        hasLabel = hasLabel || !!child.hasLabel;
        hasGoto = hasGoto || !!child.hasGoto;
        hasCloseHere = true;
        hasFunctionDef = hasFunctionDef || !!child.hasFunctionDef;
        if (!hasFunctionDef) {
          hasFunctionDef = expressionsHaveFunctionDef(
            (s as any).expressions as LuaExpression[],
          );
        }
        break;
      }
      default: {
        break;
      }
    }
  }

  if (hasLabel) {
    block.hasLabel = true;
  }
  if (hasGoto) {
    block.hasGoto = true;
  }
  if (dup) {
    block.dupLabelError = dup;
  }
  if (hasLocalDecl) {
    block.needsEnv = true;
  }
  if (hasLabelHere) {
    block.hasLabelHere = true;
  }
  if (hasCloseHere) {
    block.hasCloseHere = true;
  }
  if (hasFunctionDef) {
    block.hasFunctionDef = true;
  }

  return block;
}

function parseStatement(t: ParseTree, ctx: ASTCtx): LuaStatement {
  if (!t || !t.type) {
    return {
      type: "Semicolon",
      ctx: context(t, ctx),
    };
  }
  switch (t.type) {
    case "Block":
      return parseChunk(t.children![0], ctx);
    case "Semicolon":
      return { type: "Semicolon", ctx: context(t, ctx) };
    case "Label":
      return {
        type: "Label",
        name: t.children![1].children![0].text!,
        ctx: context(t, ctx),
      };
    case "Break":
      return { type: "Break", ctx: context(t, ctx) };
    case "Goto":
      return {
        type: "Goto",
        name: t.children![1].children![0].text!,
        ctx: context(t, ctx),
      };
    case "Scope":
      return parseBlock(t.children![1], ctx);
    case ";":
      return { type: "Semicolon", ctx: context(t, ctx) };
    case "WhileStatement":
      return {
        type: "While",
        condition: parseExpression(t.children![1], ctx),
        block: parseBlock(t.children![3], ctx),
        ctx: context(t, ctx),
      };
    case "RepeatStatement":
      return {
        type: "Repeat",
        block: parseBlock(t.children![1], ctx),
        condition: parseExpression(t.children![3], ctx),
        ctx: context(t, ctx),
      };
    case "IfStatement": {
      const conditions: {
        condition: LuaExpression;
        block: LuaBlock;
        from?: number;
        to?: number;
      }[] = [];
      let elseBlock: LuaBlock | undefined = undefined;
      for (let i = 0; i < t.children!.length; i += 4) {
        const child = t.children![i];
        if (!child || !child.children || !child.children[0]) {
          continue;
        }
        const token = child.children![0].text;
        if (token === "if" || token === "elseif") {
          conditions.push({
            condition: parseExpression(t.children![i + 1], ctx),
            block: parseBlock(t.children![i + 3], ctx),
            from: child.from,
            to: child.to,
          });
        } else if (token === "else") {
          elseBlock = parseBlock(t.children![i + 1], ctx);
        } else if (token === "end") {
          break;
        } else {
          throw new Error(`Unknown if clause type: ${token}`);
        }
      }
      return {
        type: "If",
        conditions,
        elseBlock,
        ctx: context(t, ctx),
      };
    }
    case "ForStatement": {
      if (t.children![1].type === "ForNumeric") {
        const forNumeric = t.children![1];
        const name = forNumeric.children![0].children![0].text!;
        const block = parseBlock(t.children![3], ctx);
        const node: LuaStatement = {
          type: "For",
          name,
          start: parseExpression(forNumeric.children![2], ctx),
          end: parseExpression(forNumeric.children![4], ctx),
          step: forNumeric.children![5]
            ? parseExpression(forNumeric.children![6], ctx)
            : undefined,
          block,
          ctx: context(t, ctx),
        };
        if (block.hasFunctionDef) {
          const names = new Set([name]);
          (node as any).capturesLoopVar = blockCapturesNames(block, names);
        }
        return node;
      }
      const forGeneric = t.children![1];
      const names = parseNameList(forGeneric.children![0]);
      const block = parseBlock(t.children![3], ctx);
      const node: LuaStatement = {
        type: "ForIn",
        names,
        expressions: parseExpList(forGeneric.children![2], ctx),
        block,
        ctx: context(t, ctx),
      };
      if (block.hasFunctionDef) {
        const nameSet = new Set(names);
        (node as any).capturesLoopVar = blockCapturesNames(block, nameSet);
      }
      return node;
    }
    case "Function":
      return {
        type: "Function",
        name: parseFunctionName(t.children![1], ctx),
        body: parseFunctionBody(t.children![2], ctx),
        ctx: context(t, ctx),
      };
    case "LocalFunction":
      return {
        type: "LocalFunction",
        name: t.children![2].children![0].text!,
        body: parseFunctionBody(t.children![3], ctx),
        ctx: context(t, ctx),
      };
    case "FunctionCall":
      return {
        type: "FunctionCallStatement",
        call: parseFunctionCall(t, ctx),
        ctx: context(t, ctx),
      };
    case "Assign":
      return {
        type: "Assignment",
        variables: t.children![0].children!
          .filter((c) => c.type && c.type !== ",")
          .map((lvalue) => parseLValue(lvalue, ctx)),
        expressions: parseExpList(t.children![2], ctx),
        ctx: context(t, ctx),
      };
    case "Local": {
      const names = parseAttNames(t.children![1], ctx);

      let closeCount = 0;
      for (const n of names) {
        if (n.attributes?.includes(LuaAttribute.Close) === true) {
          closeCount++;
          if (closeCount > 1) {
            throw new Error("multiple <close> variables in local list");
          }
        }
      }

      return {
        type: "Local",
        names,
        expressions: t.children![3] ? parseExpList(t.children![3], ctx) : [],
        ctx: context(t, ctx),
      };
    }
    case "ReturnStatement": {
      const expressions = t.children![1]
        ? parseExpList(t.children![1], ctx)
        : [];
      return { type: "Return", expressions, ctx: context(t, ctx) };
    }
    case "break":
      return { type: "Break", ctx: context(t, ctx) };
    default:
      // Gracefully ignore unknown empty nodes
      if (!t.children || t.children.length === 0) {
        return {
          type: "Semicolon",
          ctx: context(t, ctx),
        };
      }
      console.error(t);
      throw new Error(
        `Unknown statement type: ${
          t.children![0] && t.children![0].text
            ? t.children![0].text
            : String(t.type)
        }`,
      );
  }
}

function parseFunctionCall(
  t: ParseTree,
  ctx: ASTCtx,
): LuaFunctionCallExpression {
  if (t.children![1] && t.children![1].type === ":") {
    return {
      type: "FunctionCall",
      prefix: parsePrefixExpression(t.children![0], ctx),
      name: t.children![2].children![0].text!,
      args: parseFunctionArgs(t.children!.slice(3), ctx),
      ctx: context(t, ctx),
    };
  }
  return {
    type: "FunctionCall",
    prefix: parsePrefixExpression(t.children![0], ctx),
    args: parseFunctionArgs(t.children!.slice(1), ctx),
    ctx: context(t, ctx),
  };
}

function parseAttNames(t: ParseTree, ctx: ASTCtx): LuaAttName[] {
  if (t.type !== "AttNameList") {
    throw new Error(`Expected AttNameList, got ${t.type}`);
  }
  return t.children!
    .filter((c) => c.type && c.type !== ",")
    .map((att) => parseAttName(att, ctx));
}

function parseAttName(t: ParseTree, ctx: ASTCtx): LuaAttName {
  if (t.type !== "AttName") {
    throw new Error(`Expected AttName, got ${t.type}`);
  }
  const attribute = t.children![1].children![1]
    ? t.children![1].children![1].children![0].text!
    : undefined;
  if (
    attribute &&
    attribute !== LuaAttribute.Const &&
    attribute !== LuaAttribute.Close
  ) {
    throw new Error(`unknown attribute '${attribute}'`);
  }
  const attributes = attribute ? [attribute as LuaAttribute] : undefined;
  return {
    type: "AttName",
    name: t.children![0].children![0].text!,
    attribute,
    attributes,
    ctx: context(t, ctx),
  };
}

function parseLValue(t: ParseTree, ctx: ASTCtx): LuaLValue {
  switch (t.type) {
    case "Name":
      return {
        type: "Variable",
        name: t.children![0].text!,
        ctx: context(t, ctx),
      };
    case "Property":
      return {
        type: "PropertyAccess",
        object: parsePrefixExpression(t.children![0], ctx),
        property: t.children![2].children![0].text!,
        ctx: context(t, ctx),
      };
    case "MemberExpression":
      return {
        type: "TableAccess",
        object: parsePrefixExpression(t.children![0], ctx),
        key: parseExpression(t.children![2], ctx),
        ctx: context(t, ctx),
      };
    default:
      console.error(t);
      throw new Error(`Unknown lvalue type: ${t.type}`);
  }
}

function parseFunctionName(t: ParseTree, ctx: ASTCtx): LuaFunctionName {
  if (t.type !== "FuncName") {
    throw new Error(`Expected FunctionName, got ${t.type}`);
  }
  const propNames: string[] = [];
  let colonName: string | undefined = undefined;
  for (let i = 0; i < t.children!.length; i += 2) {
    const prop = t.children![i];
    propNames.push(prop.children![0].text!);
    if (t.children![i + 1] && t.children![i + 1].type === ":") {
      colonName = t.children![i + 2].children![0].text!;
      break;
    }
  }
  return {
    type: "FunctionName",
    propNames,
    colonName,
    ctx: context(t, ctx),
  };
}

function parseNameList(t: ParseTree): string[] {
  if (t.type !== "NameList") {
    throw new Error(`Expected NameList, got ${t.type}`);
  }
  return t.children!
    .filter((c) => c.type === "Name")
    .map((c) => c.children![0].text!);
}

function parseExpList(t: ParseTree, ctx: ASTCtx): LuaExpression[] {
  if (t.type !== "ExpList") {
    throw new Error(`Expected ExpList, got ${t.type}`);
  }
  return t.children!
    .filter((c) => c.type && c.type !== ",")
    .map((e) => parseExpression(e, ctx));
}

const delimiterRegex = /^(\[=*\[)([\s\S]*)(\]=*\])$/;

// In case of quoted strings, remove the quotes and unescape the string
// In case of a [[ type ]] literal string, remove the brackets
function parseString(s: string): string {
  // Handle long strings with delimiters
  const delimiterMatch = s.match(delimiterRegex);
  if (delimiterMatch) {
    let text = delimiterMatch[2];
    // According to Lua semantics, whenever a [[ formatted string starts with a newline, that newline should be skipped
    if (text[0] === "\n") {
      text = text.slice(1);
    }
    return text;
  }
  return s.slice(1, -1).replace(
    /\\(x[0-9a-fA-F]{2}|u\{[0-9a-fA-F]+\}|[abfnrtv\\'"n])/g,
    (match, capture) => {
      switch (capture) {
        case "a":
          return "\x07"; // Bell
        case "b":
          return "\b"; // Backspace
        case "f":
          return "\f"; // Form feed
        case "n":
          return "\n"; // Newline
        case "r":
          return "\r"; // Carriage return
        case "t":
          return "\t"; // Horizontal tab
        case "v":
          return "\v"; // Vertical tab
        case "\\":
          return "\\"; // Backslash
        case '"':
          return '"'; // Double quote
        case "'":
          return "'"; // Single quote
        default:
          // Handle hexadecimal \x00
          if (capture.startsWith("x")) {
            return String.fromCharCode(parseInt(capture.slice(1), 16));
          }
          // Handle unicode \u{XXXX}
          if (capture.startsWith("u{")) {
            const codePoint = parseInt(capture.slice(2, -1), 16);
            return String.fromCodePoint(codePoint);
          }
          return match; // return the original match if nothing fits
      }
    },
  );
}

function parseExpression(t: ParseTree, ctx: ASTCtx): LuaExpression {
  if (!t || !t.type) {
    throw new Error("Undefined expression node");
  }
  switch (t.type) {
    case "LiteralString": {
      const cleanString = parseString(t.children![0].text!);
      return {
        type: "String",
        value: cleanString,
        ctx: context(t, ctx),
      };
    }
    case "Number": {
      const text = t.children![0].text!.toLowerCase();
      return {
        type: "Number",
        // Use the integer parser fox 0x literals
        value: text.includes("x") ? parseInt(text) : parseFloat(text),
        numericType: /[\.eEpP]/.test(text) ? "float" : "int",
        ctx: context(t, ctx),
      };
    }
    case "BinaryExpression":
      return {
        type: "Binary",
        operator: t.children![1].children![0].text!,
        left: parseExpression(t.children![0], ctx),
        right: parseExpression(t.children![2], ctx),
        ctx: context(t, ctx),
      };
    case "UnaryExpression": {
      const op = t.children![0].children![0].text!;
      if (op === "+") {
        const err = new Error("unexpected symbol near '+'");
        (err as any).astCtx = context(t.children![0], ctx);
        throw err;
      }
      return {
        type: "Unary",
        operator: op,
        argument: parseExpression(t.children![1], ctx),
        ctx: context(t, ctx),
      };
    }
    case "Property":
      return {
        type: "PropertyAccess",
        object: parsePrefixExpression(t.children![0], ctx),
        property: t.children![2].children![0].text!,
        ctx: context(t, ctx),
      };

    case "MemberExpression":
      return {
        type: "TableAccess",
        object: parsePrefixExpression(t.children![0], ctx),
        key: parseExpression(t.children![2], ctx),
        ctx: context(t, ctx),
      };

    case "Parens":
      return parseExpression(t.children![1], ctx);
    case "FunctionCall": {
      return parseFunctionCall(t, ctx);
    }
    case "FunctionDef": {
      const body = parseFunctionBody(t.children![1], ctx);
      return {
        type: "FunctionDefinition",
        body,
        ctx: context(t, ctx),
      };
    }
    case "Name":
      return {
        type: "Variable",
        name: t.children![0].text!,
        ctx: context(t, ctx),
      };
    case "Ellipsis":
      return { type: "Variable", name: "...", ctx: context(t, ctx) };
    case "true":
      return { type: "Boolean", value: true, ctx: context(t, ctx) };
    case "false":
      return { type: "Boolean", value: false, ctx: context(t, ctx) };
    case "TableConstructor":
      return {
        type: "TableConstructor",
        fields: t.children!
          .slice(1, -1)
          .filter((c) =>
            ["FieldExp", "FieldProp", "FieldDynamic"].includes(c.type!)
          )
          .map((tf) => parseTableField(tf, ctx)),
        ctx: context(t, ctx),
      };
    case "nil":
      return { type: "Nil", ctx: context(t, ctx) };
    case "Query":
      return {
        type: "Query",
        clauses: t.children!.slice(2, -1).map((c) => parseQueryClause(c, ctx)),
        ctx: context(t, ctx),
      };
    default:
      console.error(t);
      throw new Error(`Unknown expression type: ${t.type}`);
  }
}

function parseQueryClause(t: ParseTree, ctx: ASTCtx): LuaQueryClause {
  if (t.type !== "QueryClause") {
    throw new Error(`Expected QueryClause, got ${t.type}`);
  }
  t = t.children![0];
  switch (t.type) {
    case "FromClause": {
      if (t.children!.length === 4) {
        // From clause with a name
        return {
          type: "From",
          name: t.children![1].children![0].text!,
          expression: parseExpression(t.children![3], ctx),
          ctx: context(t, ctx),
        };
      }
      return {
        type: "From",
        expression: parseExpression(t.children![1], ctx),
        ctx: context(t, ctx),
      };
    }
    case "WhereClause":
      return {
        type: "Where",
        expression: parseExpression(t.children![1], ctx),
        ctx: context(t, ctx),
      };
    case "LimitClause": {
      const limit = parseExpression(t.children![1], ctx);
      const offset = t.children![2]
        ? parseExpression(t.children![3], ctx)
        : undefined;
      return {
        type: "Limit",
        limit,
        offset,
        ctx: context(t, ctx),
      };
    }
    case "OrderByClause": {
      const orderBy: LuaOrderBy[] = [];
      for (const child of t.children!) {
        if (child.type === "OrderBy") {
          orderBy.push({
            type: "Order",
            expression: parseExpression(child.children![0], ctx),
            direction: child.children![1]?.type === "desc" ? "desc" : "asc",
            ctx: context(child, ctx),
          });
        }
      }
      return {
        type: "OrderBy",
        orderBy,
        ctx: context(t, ctx),
      };
    }
    case "SelectClause": {
      return {
        type: "Select",
        expression: parseExpression(t.children![1], ctx),
        ctx: context(t, ctx),
      };
    }
    default:
      console.error(t);
      throw new Error(`Unknown query clause type: ${t.type}`);
  }
}

function parseFunctionArgs(ts: ParseTree[], ctx: ASTCtx): LuaExpression[] {
  return ts
    .filter((t) => t.type && ![",", "(", ")"].includes(t.type))
    .map((e) => parseExpression(e, ctx));
}

function parseFunctionBody(t: ParseTree, ctx: ASTCtx): LuaFunctionBody {
  if (t.type !== "FuncBody") {
    throw new Error(`Expected FunctionBody, got ${t.type}`);
  }
  return {
    type: "FunctionBody",
    parameters: t.children![1].children!
      .filter((c) => c.type && ["Name", "Ellipsis"].includes(c.type))
      .map((c) => c.children![0].text!),
    block: parseBlock(t.children![3], ctx),
    ctx: context(t, ctx),
  };
}

function parsePrefixExpression(t: ParseTree, ctx: ASTCtx): LuaPrefixExpression {
  if (!t || !t.type) {
    throw new Error("Undefined prefix expression node");
  }
  switch (t.type) {
    case "Name":
      return {
        type: "Variable",
        name: t.children![0].text!,
        ctx: context(t, ctx),
      };
    case "Property":
      return {
        type: "PropertyAccess",
        object: parsePrefixExpression(t.children![0], ctx),
        property: t.children![2].children![0].text!,
        ctx: context(t, ctx),
      };
    case "MemberExpression":
      return {
        type: "TableAccess",
        object: parsePrefixExpression(t.children![0], ctx),
        key: parseExpression(t.children![2], ctx),
        ctx: context(t, ctx),
      };
    case "Parens":
      return {
        type: "Parenthesized",
        expression: parseExpression(t.children![1], ctx),
        ctx: context(t, ctx),
      };
    case "FunctionCall": {
      return parseFunctionCall(t, ctx);
    }
    default:
      console.error(t);
      throw new Error(`Unknown prefix expression type: ${t.type}`);
  }
}

function parseTableField(t: ParseTree, ctx: ASTCtx): LuaTableField {
  switch (t.type) {
    case "FieldExp":
      return {
        type: "ExpressionField",
        value: parseExpression(t.children![0], ctx),
        ctx: context(t, ctx),
      };
    case "FieldProp":
      return {
        type: "PropField",
        key: t.children![0].children![0].text!,
        value: parseExpression(t.children![2], ctx),
        ctx: context(t, ctx),
      };
    case "FieldDynamic":
      return {
        type: "DynamicField",
        key: parseExpression(t.children![1], ctx),
        value: parseExpression(t.children![4], ctx),
        ctx: context(t, ctx),
      };
    default:
      console.error(t);
      throw new Error(`Unknown table field type: ${t.type}`);
  }
}

export function stripLuaComments(s: string): string {
  let result = "";
  let i = 0;

  while (i < s.length) {
    // Check for long string
    if (s[i] === "[") {
      let j = i + 1;
      let equalsCount = 0;
      while (s[j] === "=") {
        equalsCount++;
        j++;
      }
      if (s[j] === "[") {
        // Found long string start
        const openBracket = s.substring(i, j + 1);
        const closeBracket = "]" + "=".repeat(equalsCount) + "]";
        result += openBracket;
        i = j + 1;

        // Find matching closing bracket
        const content = s.substring(i);
        const closeIndex = content.indexOf(closeBracket);
        if (closeIndex !== -1) {
          // Copy string content verbatim, including any comment-like sequences
          result += content.substring(0, closeIndex) + closeBracket;
          i += closeIndex + closeBracket.length;
          continue;
        }
      }
    }

    // Check for single quoted string
    if (s[i] === '"' || s[i] === "'") {
      const quote = s[i];
      result += quote;
      i++;
      while (i < s.length && s[i] !== quote) {
        if (s[i] === "\\") {
          result += s[i] + s[i + 1];
          i += 2;
        } else {
          result += s[i];
          i++;
        }
      }
      if (i < s.length) {
        result += s[i]; // closing quote
        i++;
      }
      continue;
    }

    // Check for comments
    if (s[i] === "-" && s[i + 1] === "-") {
      // Replace the -- with spaces
      result += "  ";
      i += 2;

      // Check for long comment
      if (s[i] === "[") {
        let j = i + 1;
        let equalsCount = 0;
        while (s[j] === "=") {
          equalsCount++;
          j++;
        }
        if (s[j] === "[") {
          // Found long comment start
          const closeBracket = "]" + "=".repeat(equalsCount) + "]";
          // Replace opening bracket with spaces
          result += " ".repeat(j - i + 1);
          i = j + 1;

          // Find matching closing bracket
          const content = s.substring(i);
          const closeIndex = content.indexOf(closeBracket);
          if (closeIndex !== -1) {
            // Replace comment content and closing bracket with spaces
            result += " ".repeat(closeIndex) + " ".repeat(closeBracket.length);
            i += closeIndex + closeBracket.length;
            continue;
          }
        }
      }

      // Single line comment - replace rest of line with spaces
      while (i < s.length && s[i] !== "\n") {
        result += " ";
        i++;
      }
      continue;
    }

    result += s[i];
    i++;
  }

  return result;
}

export function parse(s: string, ctx: ASTCtx = {}): LuaBlock {
  try {
    const t = parseToAST(stripLuaComments(s));
    // console.log("Clean tree", JSON.stringify(t, null, 2));
    const result = parseChunk(t, ctx);
    // console.log("Parsed AST", JSON.stringify(result, null, 2));
    getBlockGotoMeta(result);
    return result;
  } catch (e: any) {
    if (e && typeof e === "object" && "astCtx" in e) {
      throw new LuaRuntimeError(
        e.message,
        LuaStackFrame.lostFrame.withCtx(
          (e as any).astCtx as ASTCtx,
        ),
      );
    }
    throw e;
  }
}

export function parseToAST(t: string): ParseTree {
  const tree = parser.parse(t);

  const errNode = findFirstParseError(tree.topNode);
  if (errNode) {
    const err = new Error(luaUnexpectedSymbolMessage(t, errNode.from));
    (err as any).astCtx = { from: errNode.from, to: errNode.to };
    throw err;
  }

  const n = lezerToParseTree(t, tree.topNode);
  return cleanTree(n, true);
}

function findFirstParseError(node: SyntaxNode): SyntaxNode | null {
  if (node.type.isError) {
    return node;
  }
  for (let ch = node.firstChild; ch; ch = ch.nextSibling) {
    const hit = findFirstParseError(ch);
    if (hit) {
      return hit;
    }
  }
  return null;
}

function luaUnexpectedSymbolMessage(src: string, from: number): string {
  let i = from;
  while (i < src.length && /\s/.test(src[i])) i++;
  const sym = i < src.length ? src[i] : "?";
  return `unexpected symbol near '${sym}'`;
}

/**
 * Helper function to parse a Lua expression string
 */
export function parseExpressionString(
  expr: string,
): LuaExpression {
  const parsedLua = parse(`_(${expr})`) as LuaBlock;
  return (parsedLua.statements[0] as LuaFunctionCallStatement).call.args[0];
}


================================================
FILE: client/space_lua/query_collection.test.ts
================================================
import { parseExpressionString } from "./parse.ts";
import { ArrayQueryCollection } from "./query_collection.ts";
import { LuaEnv, LuaNativeJSFunction, LuaStackFrame } from "./runtime.ts";
import { assert, assertEquals } from "@std/assert";

Deno.test("ArrayQueryCollection", async () => {
  const rootEnv = new LuaEnv();
  rootEnv.setLocal(
    "build_name",
    new LuaNativeJSFunction((a, b) => {
      return Promise.resolve(a + " " + b);
    }),
  );

  const collection = new ArrayQueryCollection([{ x: 1, y: 1 }, { x: 2, y: 2 }, {
    x: 3,
    y: 3,
  }]);
  const result = await collection.query(
    {
      objectVariable: "p",
      where: parseExpressionString("p.x >= 2"),
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    {}, // Default collation configuration, since the config API is unavailable
  );
  // console.log(result);
  assert(result.length === 2);

  // Test limit
  const result2 = await collection.query(
    {
      objectVariable: "p",
      limit: 1,
    },
    rootEnv,
    LuaStackFrame.lostFrame,
  );
  assert(result2.length === 1);
  assert(result2[0].x === 1);

  // Test offset
  const result3 = await collection.query(
    {
      objectVariable: "p",
      offset: 1,
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    {},
  );
  assert(result3.length === 2);
  assert(result3[0].x === 2);

  // Test order by
  const result4 = await collection.query(
    {
      objectVariable: "p",
      orderBy: [{ expr: parseExpressionString("p.x"), desc: false }],
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    {},
  );
  assert(result4.length === 3);
  assert(result4[0].x === 1);
  assert(result4[1].x === 2);
  assert(result4[2].x === 3);

  // Test order by desc
  const result5 = await collection.query(
    {
      objectVariable: "p",
      orderBy: [{ expr: parseExpressionString("p.x"), desc: true }],
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    {},
  );
  assert(result5.length === 3);
  assert(result5[0].x === 3);
  assert(result5[1].x === 2);
  assert(result5[2].x === 1);

  // Test order by multiple fields
  const collection2 = new ArrayQueryCollection([
    { firstName: "John", lastName: "Doe" },
    { firstName: "Alice", lastName: "Johnson" },
    { firstName: "Jane", lastName: "Doe" },
    { firstName: "Bob", lastName: "Johnson" },
  ]);
  const result6 = await collection2.query(
    {
      objectVariable: "p",
      orderBy: [
        { expr: parseExpressionString("p.lastName"), desc: false },
        { expr: parseExpressionString("p.firstName"), desc: true },
      ],
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    {},
  );
  assertEquals(result6[0].firstName, "John");
  assertEquals(result6[0].lastName, "Doe");
  assertEquals(result6[1].firstName, "Jane");
  assertEquals(result6[1].lastName, "Doe");
  assertEquals(result6[2].firstName, "Bob");
  assertEquals(result6[2].lastName, "Johnson");
  assertEquals(result6[3].firstName, "Alice");
  assertEquals(result6[3].lastName, "Johnson");

  // Test select with expression
  const result8 = await collection2.query(
    {
      objectVariable: "p",
      select: parseExpressionString("p.firstName .. ' ' .. p.lastName"),
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    {},
  );
  assertEquals(result8[0], "John Doe");
  assertEquals(result8[1], "Alice Johnson");
  assertEquals(result8[2], "Jane Doe");
  assertEquals(result8[3], "Bob Johnson");

  // Test select with native function and implicit object variable
  const result9 = await collection2.query(
    {
      select: parseExpressionString("build_name(firstName, lastName)"),
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    {},
  );
  assertEquals(result9[0], "John Doe");
  assertEquals(result9[1], "Alice Johnson");
  assertEquals(result9[2], "Jane Doe");
  assertEquals(result9[3], "Bob Johnson");

  // Test distinct
  const collectionWithDuplicates = new ArrayQueryCollection([
    { category: "fruit", name: "apple" },
    { category: "vegetable", name: "carrot" },
    { category: "fruit", name: "banana" },
    { category: "fruit", name: "apple" }, // Duplicate
    { category: "vegetable", name: "spinach" },
    { category: "fruit", name: "banana" }, // Duplicate
  ]);

  // Test distinct with select
  const distinctResult = await collectionWithDuplicates.query(
    {
      objectVariable: "item",
      select: parseExpressionString("item.category"),
      distinct: true,
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    {},
  );
  assertEquals(distinctResult.length, 2);
  assertEquals(distinctResult.includes("fruit"), true);
  assertEquals(distinctResult.includes("vegetable"), true);

  // Test distinct with objects
  const distinctObjectsResult = await collectionWithDuplicates.query(
    {
      objectVariable: "item",
      select: parseExpressionString(
        "{ category = item.category, name = item.name }",
      ),
      distinct: true,
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    {},
  );
  assertEquals(distinctObjectsResult.length, 4);

  // Test string sorting (collation) with example from MDN
  const letterCollection = new ArrayQueryCollection([
    { letter: "Z" },
    { letter: "z" },
    { letter: "ä" },
    { letter: "a" },
  ]);

  // Default ordering by codepoint
  const resultCodepoint = await letterCollection.query(
    {
      objectVariable: "item",
      orderBy: [{ expr: parseExpressionString("item.letter"), desc: false }],
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    { enabled: false },
  );
  assertEquals(resultCodepoint[0].letter, "Z");
  assertEquals(resultCodepoint[1].letter, "a");
  assertEquals(resultCodepoint[2].letter, "z");
  assertEquals(resultCodepoint[3].letter, "ä");

  // Defaults for German
  const resultGerman = await letterCollection.query(
    {
      objectVariable: "item",
      orderBy: [{ expr: parseExpressionString("item.letter"), desc: false }],
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    { enabled: true, locale: "de" },
  );
  assertEquals(resultGerman[0].letter, "a");
  assertEquals(resultGerman[1].letter, "ä");
  assertEquals(resultGerman[2].letter, "z");
  assertEquals(resultGerman[3].letter, "Z");

  // Defaults for Swedish
  const resultSwedish = await letterCollection.query(
    {
      objectVariable: "item",
      orderBy: [{ expr: parseExpressionString("item.letter"), desc: false }],
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    { enabled: true, locale: "sv" },
  );
  assertEquals(resultSwedish[0].letter, "a");
  assertEquals(resultSwedish[1].letter, "z");
  assertEquals(resultSwedish[2].letter, "Z");
  assertEquals(resultSwedish[3].letter, "ä");

  // Uppercase first
  const resultUpper = await letterCollection.query(
    {
      objectVariable: "item",
      orderBy: [{ expr: parseExpressionString("item.letter"), desc: false }],
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    { enabled: true, locale: "de", options: { caseFirst: "upper" } },
  );
  assertEquals(resultUpper[0].letter, "a");
  assertEquals(resultUpper[1].letter, "ä");
  assertEquals(resultUpper[2].letter, "Z");
  assertEquals(resultUpper[3].letter, "z");
});


================================================
FILE: client/space_lua/query_collection.ts
================================================
import type { LuaExpression } from "./ast.ts";
import { LuaEnv, luaGet, luaKeys, LuaStackFrame, LuaTable } from "./runtime.ts";
import { evalExpression } from "./eval.ts";
import { asyncQuickSort } from "./util.ts";
import type { DataStore } from "../data/datastore.ts";
import type { KvPrimitives } from "../data/kv_primitives.ts";

import type { QueryCollationConfig } from "../../plug-api/types/config.ts";

import type { KvKey } from "../../plug-api/types/datastore.ts";

export function buildItemEnv(
  objectVariable: string | undefined,
  item: any,
  env: LuaEnv,
  sf: LuaStackFrame,
): LuaEnv {
  const itemEnv = new LuaEnv(env);
  if (!objectVariable) {
    // Inject all item keys as variables
    for (const key of luaKeys(item)) {
      itemEnv.setLocal(key, luaGet(item, key, sf.astCtx ?? null, sf));
    }
    // As well as _
    itemEnv.setLocal("_", item);
  } else {
    itemEnv.setLocal(objectVariable, item);
  }
  return itemEnv;
}

export type LuaOrderBy = {
  expr: LuaExpression;
  desc: boolean;
};

/**
 * Represents a query for a collection
 */
export type LuaCollectionQuery = {
  objectVariable?: string;
  // The filter expression evaluated with Lua
  where?: LuaExpression;
  // The order by expression evaluated with Lua
  orderBy?: LuaOrderBy[];
  // The select expression evaluated with Lua
  select?: LuaExpression;
  // The limit of the query
  limit?: number;
  // The offset of the query
  offset?: number;
  // Whether to return only distinct values
  distinct?: boolean;
};

export interface LuaQueryCollection {
  query(
    query: LuaCollectionQuery,
    env: LuaEnv,
    sf: LuaStackFrame,
  ): Promise<any[]>;
}

/**
 * Implements a query collection for a regular JavaScript array
 */
export class ArrayQueryCollection<T> implements LuaQueryCollection {
  constructor(private readonly array: T[]) {
  }

  query(
    query: LuaCollectionQuery,
    env: LuaEnv,
    sf: LuaStackFrame,
    collation?: QueryCollationConfig,
  ): Promise<any[]> {
    return applyQuery(this.array, query, env, sf, collation);
  }
}

/**
 * Applies a given query (where, order by, limit etc.) to a set of results
 */
export async function applyQuery(
  results: any[],
  query: LuaCollectionQuery,
  env: LuaEnv,
  sf: LuaStackFrame,
  collation?: QueryCollationConfig,
): Promise<any[]> {
  // Shallow copy to avoid updating underlying data structures
  results = results.slice();

  // Filter results based on `where` clause first
  if (query.where) {
    const filteredResults = [];
    for (const value of results) {
      // Enrich value
      const itemEnv = buildItemEnv(query.objectVariable, value, env, sf);
      if (await evalExpression(query.where, itemEnv, sf)) {
        filteredResults.push(value);
      }
    }
    results = filteredResults;
  }

  // Apply `order by` next
  if (query.orderBy) {
    // Retrieve from config API if not passed
    if (collation === undefined) {
      // @ts-ignore: Hack to access client via the browser
      const config = globalThis.client.config; // HACK: Shouldn't be using client here directly

      collation = config.get("queryCollation", {});
    }
    // Both arguments are optional, so passing undefined is fine
    const collator = Intl.Collator(collation?.locale, collation?.options);

    results = await asyncQuickSort(results, async (a, b) => {
      // Compare each orderBy clause until we find a difference
      for (const { expr, desc } of query.orderBy!) {
        const aEnv = buildItemEnv(query.objectVariable, a, env, sf);
        const bEnv = buildItemEnv(query.objectVariable, b, env, sf);

        const aVal = await evalExpression(expr, aEnv, sf);
        const bVal = await evalExpression(expr, bEnv, sf);

        if (
          collation?.enabled &&
          typeof aVal === "string" &&
          typeof bVal === "string"
        ) {
          const order = collator.compare(aVal, bVal);
          if (order != 0) {
            return desc ? -order : order;
          }
        } else if (aVal < bVal) {
          return desc ? 1 : -1;
        } else if (aVal > bVal) {
          return desc ? -1 : 1;
        }
        // If equal, continue to next orderBy clause
      }
      return 0; // All orderBy clauses were equal
    });
  }

  // Apply the select
  if (query.select) {
    const newResult = [];
    for (const item of results) {
      const itemEnv = buildItemEnv(query.objectVariable, item, env, sf);
      newResult.push(await evalExpression(query.select, itemEnv, sf));
    }
    results = newResult;
  }

  // Apply distinct filter (after select to filter on selected values)
  if (query.distinct) {
    const seen = new Set();
    const distinctResult = [];

    for (const item of results) {
      // For non-primitive values, we use a JSON string as the key for comparison
      const key = generateKey(item);

      if (!seen.has(key)) {
        seen.add(key);
        distinctResult.push(item);
      }
    }

    results = distinctResult;
  }

  // Apply the limit and offset
  if (query.limit !== undefined && query.offset !== undefined) {
    results = results.slice(query.offset, query.offset + query.limit);
  } else if (query.limit !== undefined) {
    results = results.slice(0, query.limit);
  } else if (query.offset !== undefined) {
    results = results.slice(query.offset);
  }

  return results;
}

export async function queryLua<T = any>(
  kv: KvPrimitives,
  prefix: KvKey,
  query: LuaCollectionQuery,
  env: LuaEnv,
  sf: LuaStackFrame = LuaStackFrame.lostFrame,
  enricher?: (key: KvKey, item: any) => any,
): Promise<T[]> {
  const results: T[] = [];
  // Accumulate all results into an array
  for await (
    let { key, value } of kv.query({ prefix })
  ) {
    if (enricher) {
      value = enricher(key, value);
    }
    results.push(value);
  }

  return applyQuery(results, query, env, sf);
}

function generateKey(value: any) {
  if (value instanceof LuaTable) {
    return JSON.stringify(value.toJS());
  }
  return typeof value === "object" && value !== null
    ? JSON.stringify(value)
    : value;
}
export class DataStoreQueryCollection implements LuaQueryCollection {
  constructor(
    private readonly dataStore: DataStore,
    readonly prefix: string[],
  ) {
  }

  query(
    query: LuaCollectionQuery,
    env: LuaEnv,
    sf: LuaStackFrame,
  ): Promise<any[]> {
    return queryLua(this.dataStore.kv, this.prefix, query, env, sf);
  }
}


================================================
FILE: client/space_lua/query_test.lua
================================================
local function assertEquals(a, b)
    if a ~= b then
        error("Assertion failed: " .. a .. " is not equal to " .. b)
    end
end

-- Test query functionality
local data = { { name = "John", lastModified = 1, age = 20 }, { name = "Jane", lastModified = 2, age = 21 } }

-- Basic limit query
local r = query [[from p = data limit 1]]
assertEquals(#r, 1)
assertEquals(r[1].name, "John")
assertEquals(r[1].lastModified, 1)

-- Order by descending
local r = query [[from p = data order by p.lastModified desc]]
assertEquals(#r, 2)
assertEquals(r[1].name, "Jane")
assertEquals(r[1].lastModified, 2)
assertEquals(r[2].name, "John")
assertEquals(r[2].lastModified, 1)

-- Order by ascending
local r = query [[from p = data order by p.lastModified]]
assertEquals(#r, 2)
assertEquals(r[1].name, "John")
assertEquals(r[1].lastModified, 1)
assertEquals(r[2].name, "Jane")
assertEquals(r[2].lastModified, 2)

-- Select specific fields
local r = query [[from p = data order by p.age select {name=p.name, age=p.age}]]
assertEquals(#r, 2)
assertEquals(r[1].name, "John")
assertEquals(r[1].age, 20)
assertEquals(r[2].name, "Jane")
assertEquals(r[2].age, 21)
assertEquals(r[1].lastModified, nil)
assertEquals(r[2].lastModified, nil)

-- Array transformation
local r = query [[from {1, 2, 3} select _ + 1]]
assertEquals(#r, 3)
assertEquals(r[1], 2)
assertEquals(r[2], 3)
assertEquals(r[3], 4) 

================================================
FILE: client/space_lua/rp.bench.ts
================================================
// Benchmark suite for Space Lua RP (Result-or-Promise) optimizations
// that exercises hot synchronous paths (binary ops, loops, function
// calls, argument lists, table get/set, concatenation).
//
// # NOTES
//
// * Parsing cost is excluded from measured time by compiling each
//   snippet once.
//
// * Each bench creates a fresh Lua environment to isolate state.
//
// * Minimal global environment (`_GLOBAL`) is installed with included:
//
//   * `string.format` (simple `%s` formatter) and
//   * `type`.
//
// * To add benches that need more stdlib, extend makeEnv() accordingly.

import { evalStatement } from "./eval.ts";
import {
  LuaBuiltinFunction,
  LuaEnv,
  LuaRuntimeError,
  LuaStackFrame,
  LuaTable,
  luaTypeOf,
} from "./runtime.ts";
import { parse as parseLua } from "./parse.ts";

const LOOP = 100000;
const SMALL = 20000;

function makeEnv(): { global: LuaEnv; sf: LuaStackFrame } {
  const global = new LuaEnv();

  const stringLib = new LuaTable({
    format: (fmt: string, ...args: any[]) => {
      let i = 0;
      return String(fmt).replace(/%s/g, () => String(args[i++]));
    },
  });

  global.setLocal("string", stringLib);

  global.setLocal(
    "type",
    new LuaBuiltinFunction((_sf, v) => luaTypeOf(v)),
  );

  const sf = LuaStackFrame.createWithGlobalEnv(global);

  return { global, sf };
}

async function run(ast: any) {
  const { global, sf } = makeEnv();
  try {
    const r = evalStatement(ast, global, sf, false);
    if (r instanceof Promise) {
      await r;
    }
  } catch (e) {
    if (e instanceof LuaRuntimeError) {
      throw e;
    }
    throw new Error(`Lua execution error: ${e && (e as any).message || e}`);
  }
}

// Snippets

const luaWhileSync = `
  local i = 0
  local s = 0
  while i < ${LOOP} do
    i = i + 1
    s = s + i
  end
`;

const luaForNumeric = `
  local s = 0
  for i = 1, ${LOOP} do
    s = s + i
  end
`;

const luaWhileFuncCondTruthy = `
  local n = 0
  local function next_or_nil()
    n = n + 1
    if n <= 2 then
      return 0  -- 0 is truthy; should loop exactly twice
    end
    return nil
  end
  local count = 0
  while next_or_nil() do
    count = count + 1
  end
`;

const luaFuncCallArgs = `
  local s = 0
  local function f(a, b, c) return a + b + c end
  for i = 1, ${LOOP}, 3 do
    s = s + f(i, i + 1, i + 2)
  end
`;

const luaTableGetSet = `
  local t = { a = 1 }
  for i = 1, ${LOOP} do
    t.a = t.a + 1
  end
`;

const luaTableIndexNumeric = `
  local t = {}
  for i = 1, ${LOOP} do
    t[i] = i
  end
  local s = 0
  for i = 1, ${LOOP} do
    s = s + t[i]
  end
`;

const luaConcatStrings = `
  local s = ""
  for i = 1, ${SMALL} do
    s = s .. "x"
  end
`;

const luaArithmeticBinary = `
  local a, b, c = 1, 2, 3
  local s = 0
  for i = 1, ${LOOP} do
    s = s + (a * b) - (c / a) + (a ^ 2) % 5
  end
`;

const luaWhileTruthinessMix = `
  local items = { 0, "", {}, 1, -0.0, "x" }
  local idx = 0
  local function next_item()
    idx = idx + 1
    local v = items[idx]
    if v ~= nil then return v end
    return nil
  end
  local cnt = 0
  while next_item() do
    cnt = cnt + 1
  end
`;

const luaTableDeepDotGetSet = `
  local t = { a = { b = { c = 1 } } }
  for i = 1, ${LOOP} do
    t.a.b.c = t.a.b.c + 1
  end
`;

const luaTableDotMissRead = `
  local t = { a = 1 }
  local s = 0
  for i = 1, ${LOOP} do
    if t.m == nil then
      s = s + 1
    end
  end
`;

// The truthiness_test.lua uses the `string.format`.
const truthinessPath =
  new URL("./truthiness_test.lua", import.meta.url).pathname;
const truthinessCode = await Deno.readTextFile(truthinessPath);

const astWhileSync = parseLua(luaWhileSync);
const astForNumeric = parseLua(luaForNumeric);
const astWhileFuncCondTruthy = parseLua(luaWhileFuncCondTruthy);
const astFuncCallArgs = parseLua(luaFuncCallArgs);
const astTableGetSet = parseLua(luaTableGetSet);
const astTableIndexNumeric = parseLua(luaTableIndexNumeric);
const astConcatStrings = parseLua(luaConcatStrings);
const astArithmeticBinary = parseLua(luaArithmeticBinary);
const astWhileTruthinessMix = parseLua(luaWhileTruthinessMix);
const astTableDeepDotGetSet = parseLua(luaTableDeepDotGetSet);
const astTableDotMissRead = parseLua(luaTableDotMissRead);
const astTruthiness = parseLua(truthinessCode);

Deno.bench(
  { name: "RP: while (sync cond) numeric sum", group: "space-lua" },
  async () => {
    await run(astWhileSync);
  },
);

Deno.bench({ name: "RP: for (numeric) sum", group: "space-lua" }, async () => {
  await run(astForNumeric);
});

Deno.bench({
  name: "RP: while (function cond -> truthy then nil)",
  group: "space-lua",
}, async () => {
  await run(astWhileFuncCondTruthy);
});

Deno.bench(
  { name: "RP: function calls + arg eval", group: "space-lua" },
  async () => {
    await run(astFuncCallArgs);
  },
);

Deno.bench({ name: "RP: table dot get/set", group: "space-lua" }, async () => {
  await run(astTableGetSet);
});

Deno.bench(
  { name: "RP: table numeric index get/set", group: "space-lua" },
  async () => {
    await run(astTableIndexNumeric);
  },
);

Deno.bench(
  { name: "RP: table deep dot get/set (3 levels)", group: "space-lua" },
  async () => {
    await run(astTableDeepDotGetSet);
  },
);

Deno.bench(
  { name: "RP: table dot miss (nil reads, no metatable)", group: "space-lua" },
  async () => {
    await run(astTableDotMissRead);
  },
);

Deno.bench(
  { name: "RP: string concatenation (..)", group: "space-lua" },
  async () => {
    await run(astConcatStrings);
  },
);

Deno.bench(
  { name: "RP: arithmetic (binary ops)", group: "space-lua" },
  async () => {
    await run(astArithmeticBinary);
  },
);

Deno.bench({
  name: "RP: while truthiness mix (0,'' ,{},...)",
  group: "space-lua",
}, async () => {
  await run(astWhileTruthinessMix);
});

Deno.bench(
  { name: "RP: truthiness_test.lua (end-to-end)", group: "space-lua" },
  async () => {
    await run(astTruthiness);
  },
);


================================================
FILE: client/space_lua/rp.ts
================================================
// Result-or-Promise helpers

export type RP<T> = T | Promise<T>;

// Returns true when v is a Promise or a has a then function.
export function isPromise<T>(v: RP<T>): v is Promise<T> {
  return v !== null &&
    (typeof v === "object" || typeof v === "function") &&
    typeof (v as any).then === "function";
}

export function rpThen<A, B>(v: RP<A>, f: (a: A) => RP<B>): RP<B> {
  return isPromise(v) ? (v as Promise<A>).then(f) : f(v as A);
}

/**
 * Collect an array of Result-or-Promise values into a single array
 * avoiding Promise allocation when all inputs are synchronous.
 */
export function rpAll<T>(arr: RP<T>[]): RP<T[]> {
  for (let i = 0; i < arr.length; i++) {
    if (isPromise(arr[i])) {
      return Promise.all(arr as Promise<T>[]);
    }
  }
  return arr as T[];
}


================================================
FILE: client/space_lua/runtime.test.ts
================================================
import { assertEquals } from "@std/assert/equals";
import {
  jsToLuaValue,
  luaLen,
  LuaMultiRes,
  LuaStackFrame,
  luaToString,
} from "./runtime.ts";

Deno.test("Test Lua Rutime", async () => {
  // Test LuaMultires
  assertEquals(new LuaMultiRes([]).flatten().values, []);
  assertEquals(new LuaMultiRes([1, 2, 3]).flatten().values, [1, 2, 3]);
  assertEquals(
    new LuaMultiRes([1, new LuaMultiRes([2, 3])]).flatten().values,
    [
      1,
      2,
      3,
    ],
  );

  // Test JavaScript to Lua conversion
  assertEquals(jsToLuaValue(1), 1);
  assertEquals(jsToLuaValue("hello"), "hello");
  // Arrays
  let luaVal = jsToLuaValue([1, 2, 3]);
  assertEquals(luaLen(luaVal), 3);
  assertEquals(luaVal.get(1), 1);
  // Objects
  luaVal = jsToLuaValue({ name: "Pete", age: 10 });
  assertEquals(luaVal.get("name"), "Pete");
  assertEquals(luaVal.get("age"), 10);
  // Nested objects
  luaVal = jsToLuaValue({ name: "Pete", list: [1, 2, 3] });
  assertEquals(luaVal.get("name"), "Pete");
  assertEquals(luaLen(luaVal.get("list")), 3);
  assertEquals(luaVal.get("list").get(2), 2);
  luaVal = jsToLuaValue([{ name: "Pete" }, { name: "John" }]);
  assertEquals(luaLen(luaVal), 2);
  assertEquals(luaVal.get(1).get("name"), "Pete");
  assertEquals(luaVal.get(2).get("name"), "John");
  // Functions in objects
  luaVal = jsToLuaValue({ name: "Pete", first: (l: any[]) => l[0] });
  assertEquals(luaVal.get("first").call(LuaStackFrame.lostFrame, [1, 2, 3]), 1);

  // Test luaToString
  assertEquals(await luaToString(new Promise((resolve) => resolve(1))), "1");
  assertEquals(await luaToString({ a: 1 }), "{a = 1}");
  assertEquals(await luaToString([{ a: 1 }]), "{{a = 1}}");
  // Ensure simple cases are not returning promises
  assertEquals(luaToString(10), "10");
  // Test circular references
  const circular: any = {};
  circular.self = circular;
  assertEquals(await luaToString(circular), "{self = <circular reference>}");
});


================================================
FILE: client/space_lua/runtime.ts
================================================
import type { ASTCtx, LuaFunctionBody, NumericType } from "./ast.ts";
import { evalStatement } from "./eval.ts";
import { asyncQuickSort } from "./util.ts";
import { isPromise, rpAll } from "./rp.ts";
import { isNegativeZero, isTaggedFloat } from "./numeric.ts";
import { luaFormat } from "./stdlib/format.ts";

export type LuaType =
  | "nil"
  | "boolean"
  | "number"
  | "string"
  | "table"
  | "function"
  | "userdata"
  | "thread";

// These types are for documentation only
export type LuaValue = any;
export type JSValue = any;

export interface ILuaFunction {
  call(sf: LuaStackFrame, ...args: LuaValue[]): Promise<LuaValue> | LuaValue;

  asString(): string;
}

export interface ILuaSettable {
  set(
    key: LuaValue,
    value: LuaValue,
    sf?: LuaStackFrame,
    numType?: NumericType,
  ): void | Promise<void>;
}

export interface ILuaGettable {
  get(key: LuaValue, sf?: LuaStackFrame): LuaValue | Promise<LuaValue> | null;
  getNumericType?(key: LuaValue): NumericType | undefined;
}

// Small helpers for type safety/readability
export function isILuaFunction(v: unknown): v is ILuaFunction {
  return !!v && typeof (v as any).call === "function";
}

export function isLuaTable(v: unknown): v is LuaTable {
  return v instanceof LuaTable;
}

export function toNumKey(key: unknown): string | number {
  if (isTaggedFloat(key)) {
    return key.value;
  }
  if (typeof key === "number" || typeof key === "string") {
    return key;
  }
  return key as unknown as string | number;
}

export function ctxOrNull(sf?: LuaStackFrame): ASTCtx | null {
  return sf?.astCtx ?? null;
}

// Reuse a single empty context to avoid allocating `{}` in hot paths
const EMPTY_CTX = {} as ASTCtx;

const MAX_TAG_LOOP = 200;

// Close-stack support
export type LuaCloseEntry = { value: LuaValue; ctx: ASTCtx };

type LuaThreadState = {
  closeStack?: LuaCloseEntry[];
};

function isLuaNumber(v: any): boolean {
  return typeof v === "number" || isTaggedFloat(v);
}

export function luaTypeName(val: any): LuaType {
  if (val === null || val === undefined) {
    return "nil";
  }

  const t = luaTypeOf(val);

  if (typeof t === "string") {
    return t;
  }

  const ty = typeof val;
  if (ty === "number") {
    return "number";
  }
  if (ty === "string") {
    return "string";
  }
  if (ty === "boolean") {
    return "boolean";
  }
  if (ty === "function") {
    return "function";
  }
  if (Array.isArray(val)) {
    return "table";
  }
  if (ty === "object" && (val as any).constructor === Object) {
    return "table";
  }

  return "userdata";
}

// Check whether a value is callable without invoking it.
export function luaIsCallable(
  v: LuaValue,
  sf: LuaStackFrame,
): boolean {
  if (v === null || v === undefined) {
    return false;
  }
  if (typeof v === "function") {
    return true;
  }
  if (isILuaFunction(v)) {
    return true;
  }
  if (v instanceof LuaTable) {
    const mt = getMetatable(v, sf);
    if (mt && mt.has("__call")) {
      const mm = mt.get("__call", sf);
      return !!mm && (typeof mm === "function" || isILuaFunction(mm));
    }
  }
  return false;
}

// In Lua, `__close` must be a function (no `__call` fallback).
function luaIsCloseMethod(
  v: LuaValue,
): boolean {
  return typeof v === "function" || isILuaFunction(v);
}

export function luaEnsureCloseStack(sf: LuaStackFrame): LuaCloseEntry[] {
  if (!sf.threadState.closeStack) {
    sf.threadState.closeStack = [];
  }
  return sf.threadState.closeStack as LuaCloseEntry[];
}

export function luaMarkToBeClosed(
  sf: LuaStackFrame,
  value: LuaValue,
  ctx: ASTCtx,
): void {
  const closeStack = luaEnsureCloseStack(sf);

  // In Lua, `nil` is not closed.
  if (value === null) {
    return;
  }

  const mt = getMetatable(value, sf);
  if (!mt || !mt.has("__close")) {
    throw new LuaRuntimeError(
      "variable got a non-closable value",
      sf.withCtx(ctx),
    );
  }

  const mm = mt.get("__close");
  if (!luaIsCloseMethod(mm)) {
    throw new LuaRuntimeError(
      "variable got a non-closable value",
      sf.withCtx(ctx),
    );
  }

  closeStack.push({ value, ctx });
}

// Close entries from a mark (LIFO) and shrink stack back to mark.  This
// is the core semantic for block exits and protected call boundaries.
export function luaCloseFromMark(
  sf: LuaStackFrame,
  mark: number,
  errObj: LuaValue | null,
): Promise<void> | void {
  const closeStack = sf.threadState?.closeStack as LuaCloseEntry[] | undefined;
  if (!closeStack) {
    return;
  }
  if (closeStack.length <= mark) {
    return;
  }

  const callClose = (entry: LuaCloseEntry): LuaValue | Promise<LuaValue> => {
    const mt = getMetatable(entry.value, sf);
    const mm = mt ? mt.get("__close", sf) : null;
    if (!luaIsCloseMethod(mm)) {
      throw new LuaRuntimeError(
        "metamethod '__close' is not callable",
        sf.withCtx(entry.ctx),
      );
    }
    if (errObj === null) {
      return luaCall(mm, [entry.value], entry.ctx, sf);
    }
    return luaCall(mm, [entry.value, errObj], entry.ctx, sf);
  };

  // Close all to-be-closed variables (LIFO) even if one close errors.
  // The reported error should be the first close error encountered.
  const runFrom = (i: number): void | Promise<void> => {
    let firstErr: unknown | null = null;

    const recordErr = (e: unknown) => {
      if (firstErr === null) {
        firstErr = e;
      }
    };

    const next = (idx: number): void | Promise<void> => {
      for (let j = idx; j >= mark; j--) {
        let r: LuaValue | Promise<LuaValue>;
        try {
          r = callClose(closeStack[j]);
        } catch (e) {
          recordErr(e);
          continue;
        }

        if (isPromise(r)) {
          return (r as Promise<any>).then(
            () => next(j - 1),
            (e: any) => {
              recordErr(e);
              return next(j - 1);
            },
          );
        }
      }

      closeStack.length = mark;
      if (firstErr !== null) {
        throw firstErr;
      }
    };

    return next(i);
  };

  return runFrom(closeStack.length - 1);
}

export class LuaEnv implements ILuaSettable, ILuaGettable {
  variables = new Map<string, LuaValue>();

  private readonly consts = new Set<string>();
  private readonly numericTypes = new Map<string, NumericType>();

  constructor(readonly parent?: LuaEnv) {
  }

  setLocal(name: string, value: LuaValue, numType?: NumericType) {
    this.variables.set(name, value);
    if (isLuaNumber(value) && numType) {
      this.numericTypes.set(name, numType);
    } else {
      this.numericTypes.delete(name);
    }
  }

  setLocalConst(name: string, value: LuaValue, numType?: NumericType) {
    this.variables.set(name, value);
    this.consts.add(name);
    if (isLuaNumber(value) && numType) {
      this.numericTypes.set(name, numType);
    } else {
      this.numericTypes.delete(name);
    }
  }

  set(
    key: string,
    value: LuaValue,
    sf?: LuaStackFrame,
    numType?: NumericType,
  ): void {
    if (this.variables.has(key) || !this.parent) {
      if (this.consts.has(key)) {
        throw new LuaRuntimeError(
          `attempt to assign to const variable '${key}'`,
          sf || LuaStackFrame.lostFrame,
        );
      }
      this.variables.set(key, value);
      if (isLuaNumber(value) && numType) {
        this.numericTypes.set(key, numType);
      } else {
        this.numericTypes.delete(key);
      }
    } else {
      this.parent.set(key, value, sf, numType);
    }
  }

  getNumericType(name: string): NumericType | undefined {
    if (this.numericTypes.has(name)) {
      return this.numericTypes.get(name);
    }
    if (this.parent) {
      return this.parent.getNumericType(name);
    }
    return undefined;
  }

  has(key: string): boolean {
    if (this.variables.has(key)) {
      return true;
    }
    if (this.parent) {
      return this.parent.has(key);
    }
    return false;
  }

  get(
    name: string,
    _sf?: LuaStackFrame,
  ): Promise<LuaValue> | LuaValue | null {
    if (this.variables.has(name)) {
      return this.variables.get(name);
    }
    if (this.parent) {
      return this.parent.get(name, _sf);
    }
    return null;
  }

  /**
   * Lists all keys in the environment including its parents
   */
  keys(): string[] {
    const keys = Array.from(this.variables.keys());
    if (this.parent) {
      return keys.concat(this.parent.keys());
    }
    return keys;
  }

  toJSON(omitKeys: string[] = []): Record<string, any> {
    const result: Record<string, any> = {};
    for (const key of this.keys()) {
      if (omitKeys.includes(key)) {
        continue;
      }
      result[key] = luaValueToJS(this.get(key), LuaStackFrame.lostFrame);
    }
    return result;
  }
}

export class LuaStackFrame {
  // Must not share mutable per-thread state across calls/tests. This is
  // a getter that returns a fresh frame each time.
  static get lostFrame(): LuaStackFrame {
    return new LuaStackFrame(new LuaEnv(), null, undefined, undefined, {
      closeStack: undefined,
    });
  }

  constructor(
    readonly threadLocal: LuaEnv,
    readonly astCtx: ASTCtx | null,
    readonly parent?: LuaStackFrame,
    readonly currentFunction?: LuaFunction,
    readonly threadState: LuaThreadState = { closeStack: undefined },
  ) {
  }

  static createWithGlobalEnv(
    globalEnv: LuaEnv,
    ctx: ASTCtx | null = null,
  ): LuaStackFrame {
    const env = new LuaEnv();
    env.setLocal("_GLOBAL", globalEnv);
    return new LuaStackFrame(env, ctx, undefined, undefined, {
      closeStack: undefined,
    });
  }

  withCtx(ctx: ASTCtx): LuaStackFrame {
    return new LuaStackFrame(
      this.threadLocal,
      ctx,
      this,
      this.currentFunction,
      this.threadState,
    );
  }

  withFunction(fn: LuaFunction): LuaStackFrame {
    return new LuaStackFrame(
      this.threadLocal,
      this.astCtx,
      this.parent,
      fn,
      this.threadState,
    );
  }
}

export class LuaMultiRes {
  values: any[];

  constructor(values: LuaValue[] | LuaValue) {
    if (values instanceof LuaMultiRes) {
      this.values = values.values;
    } else {
      this.values = Array.isArray(values) ? values : [values];
    }
  }

  unwrap(): any {
    if (this.values.length === 0) {
      return null;
    }
    return this.values[0];
  }

  // Takes an array of either LuaMultiRes or LuaValue and flattens them into a single LuaMultiRes
  flatten(): LuaMultiRes {
    const result: any[] = [];

    for (const value of this.values) {
      if (value instanceof LuaMultiRes) {
        result.push(...value.values);
      } else {
        result.push(value);
      }
    }

    return new LuaMultiRes(result);
  }
}

export function singleResult(value: any): any {
  if (value instanceof LuaMultiRes) {
    return value.unwrap();
  }
  return value;
}

export class LuaFunction implements ILuaFunction {
  private capturedEnv: LuaEnv;
  funcHasGotos?: boolean;

  constructor(readonly body: LuaFunctionBody, closure: LuaEnv) {
    this.capturedEnv = closure;
  }

  call(sf: LuaStackFrame, ...args: LuaValue[]): Promise<LuaValue> | LuaValue {
    // Create a new environment that chains to the captured environment
    const env = new LuaEnv(this.capturedEnv);
    if (!sf) {
      console.trace(sf);
    }
    // Set _CTX to the thread local environment from the stack frame
    env.setLocal("_CTX", sf.threadLocal);

    // Eval using a stack frame that knows the current function
    const sfWithFn = sf.currentFunction === this ? sf : sf.withFunction(this);

    // Resolve args (sync-first)
    const argsRP = rpAll(args as any[]);
    const resolveArgs = (resolvedArgs: any[]) => {
      // Assign parameter values to variable names in env
      let varargs: LuaValue[] = [];
      for (let i = 0; i < this.body.parameters.length; i++) {
        const paramName = this.body.parameters[i];
        if (paramName === "...") {
          // Vararg parameter, let's collect the remainder of the resolved args into the varargs array
          varargs = resolvedArgs.slice(i);
          // Done, break out of this loop
          break;
        }
        env.setLocal(paramName, resolvedArgs[i] ?? null);
      }
      env.setLocal("...", new LuaMultiRes(varargs));

      // Evaluate the function body with returnOnReturn set to true
      const r = evalStatement(this.body.block, env, sfWithFn, true);

      const map = (val: any) => {
        if (val === undefined) {
          return;
        }
        if (val && typeof val === "object" && val.ctrl === "return") {
          return mapFunctionReturnValue(val.values);
        }
        if (val && typeof val === "object" && val.ctrl === "break") {
          throw new LuaRuntimeError(
            "break outside loop",
            sfWithFn.withCtx(this.body.block.ctx),
          );
        }
        if (val && typeof val === "object" && val.ctrl === "goto") {
          throw new LuaRuntimeError(
            "unexpected goto signal",
            sfWithFn.withCtx(this.body.block.ctx),
          );
        }
      };

      if (isPromise(r)) {
        return r.then(map);
      }
      return map(r);
    };

    if (isPromise(argsRP)) {
      return argsRP.then(resolveArgs);
    }
    return resolveArgs(argsRP);
  }

  asString(): string {
    return `<lua function(${this.body.parameters.join(", ")})>`;
  }

  toString(): string {
    return this.asString();
  }
}

function mapFunctionReturnValue(values: any[]): any {
  if (values.length === 0) {
    return;
  }

  if (values.length === 1) {
    return values[0];
  }

  return new LuaMultiRes(values);
}

export class LuaNativeJSFunction implements ILuaFunction {
  constructor(readonly fn: (...args: JSValue[]) => JSValue) {
  }

  // Performs automatic conversion between Lua and JS values for arguments, but not for return values
  call(sf: LuaStackFrame, ...args: LuaValue[]): Promise<LuaValue> | LuaValue {
    const jsArgsRP = args.map((v) => luaValueToJS(v, sf));
    const resolved = rpAll(jsArgsRP);
    if (isPromise(resolved)) {
      return resolved.then((jsArgs) => this.fn(...jsArgs));
    }
    return this.fn(...resolved);
  }

  asString(): string {
    return `<native js function: ${this.fn.name}>`;
  }

  toString(): string {
    return this.asString();
  }
}

export class LuaBuiltinFunction implements ILuaFunction {
  constructor(
    readonly fn: (sf: LuaStackFrame, ...args: LuaValue[]) => LuaValue,
  ) {
  }

  call(sf: LuaStackFrame, ...args: LuaValue[]): Promise<LuaValue> | LuaValue {
    // _CTX is already available via the stack frame
    return this.fn(sf, ...args);
  }

  asString(): string {
    return `<builtin lua function>`;
  }

  toString(): string {
    return this.asString();
  }
}

export class LuaTable implements ILuaSettable, ILuaGettable {
  // To optimize the table implementation we use a combination of different data structures
  public metatable: LuaTable | null;

  // When tables are used as maps, the common case is that they are string keys, so we use a simple object for that
  private stringKeys: Record<string, any>;
  // Other keys we can support using a Map as a fallback
  private otherKeys: Map<any, any> | null;
  // When tables are used as arrays, we use a native JavaScript array for that
  private arrayPart: any[];

  // Numeric type metadata at storage boundaries
  private readonly stringKeyTypes = new Map<string, NumericType>();
  private otherKeyTypes: Map<any, NumericType> | null = null;
  private readonly arrayTypes: (NumericType | undefined)[] = [];

  constructor(init?: any[] | Record<string, any>) {
    // For efficiency and performance reasons we pre-allocate these (modern JS engines are very good at optimizing this)
    this.arrayPart = Array.isArray(init) ? init : [];
    this.stringKeys = init && !Array.isArray(init) ? init : {};

    if (init && !Array.isArray(init)) {
      for (const k in init) {
        if (Object.prototype.hasOwnProperty.call(init, k)) {
          this.stringKeys[k] = (init as any)[k];
        }
      }
    }
    this.otherKeys = null; // Only create this when needed
    this.metatable = null;
  }

  private static numKeyValue(key: any): number | null {
    if (isTaggedFloat(key)) {
      return key.value;
    }
    if (typeof key === "number") {
      return key;
    }
    return null;
  }

  // Normalize numeric keys for table indexing:
  // * negative zero becomes positive zero,
  // * integer-valued floats become plain integers,
  // * non-integer floats stay as-is.
  static normalizeNumericKey(key: any): any {
    if (typeof key === "string") {
      return key;
    }

    const numVal = LuaTable.numKeyValue(key);
    if (numVal !== null) {
      // Normalize -0 to +0
      if (isNegativeZero(numVal)) {
        return 0;
      }
      // Integer-valued numbers normalize to integers
      if (Number.isInteger(numVal)) {
        return numVal;
      }
      // Non-integer floats
      return numVal;
    }
    return key;
  }

  private static isIntegerKey(key: any): boolean {
    const norm = LuaTable.normalizeNumericKey(key);
    return typeof norm === "number" && Number.isInteger(norm) && norm >= 1;
  }

  private static toIndex(key: any): number {
    const norm = LuaTable.normalizeNumericKey(key);
    const k = typeof norm === "number" ? norm : (norm as number);
    return k - 1;
  }

  get rawLength(): number {
    return this.arrayPart.length;
  }

  get length(): number {
    let n = this.arrayPart.length;
    while (n > 0) {
      const v = this.arrayPart[n - 1];
      if (v === null || v === undefined) {
        n--;
        continue;
      }
      break;
    }
    return n;
  }

  keys(): any[] {
    const keys: any[] = [];
    for (const k in this.stringKeys) {
      if (Object.prototype.hasOwnProperty.call(this.stringKeys, k)) {
        keys.push(k);
      }
    }
    for (let i = 0; i < this.arrayPart.length; i++) {
      keys.push(i + 1);
    }
    if (this.otherKeys) {
      for (const key of this.otherKeys.keys()) {
        keys.push(key);
      }
    }
    return keys;
  }

  empty(): boolean {
    for (const k in this.stringKeys) {
      if (Object.prototype.hasOwnProperty.call(this.stringKeys, k)) {
        return false;
      }
    }
    if (this.arrayPart.length !== 0) {
      return false;
    }
    if (this.otherKeys && this.otherKeys.size !== 0) {
      return false;
    }
    return true;
  }

  has(key: LuaValue) {
    if (typeof key === "string") {
      return this.stringKeys[key] !== undefined;
    }

    const normalizedKey = LuaTable.normalizeNumericKey(key);

    if (
      typeof normalizedKey === "number" && Number.isInteger(normalizedKey) &&
      normalizedKey >= 1
    ) {
      const idx = normalizedKey - 1;
      const v = this.arrayPart[idx];
      if (v !== undefined) {
        return true;
      }
      return this.otherKeys ? this.otherKeys.has(normalizedKey) : false;
    }
    if (typeof normalizedKey === "string") {
      return this.stringKeys[normalizedKey] !== undefined;
    }
    if (this.otherKeys) {
      return this.otherKeys.has(normalizedKey);
    }

    return false;
  }

  // Used by table constructors to preserve positional semantics
  // including nils and ensure the array part grows to the final
  // constructor size.
  rawSetArrayIndex(
    index1: number,
    value: LuaValue,
    numType?: NumericType,
  ): void {
    const idx = index1 - 1;

    this.arrayPart[idx] = value;
    if (isLuaNumber(value) && numType) {
      this.arrayTypes[idx] = numType;
    } else {
      this.arrayTypes[idx] = undefined;
    }
  }

  private promoteIntegerKeysFromHash(): void {
    if (!this.otherKeys) return;

    while (true) {
      const nextIndex1 = this.arrayPart.length + 1;
      if (!this.otherKeys.has(nextIndex1)) {
        break;
      }

      const v = this.otherKeys.get(nextIndex1);
      const nt = this.otherKeyTypes
        ? this.otherKeyTypes.get(nextIndex1)
        : undefined;

      this.otherKeys.delete(nextIndex1);
      if (this.otherKeyTypes) {
        this.otherKeyTypes.delete(nextIndex1);
      }

      this.arrayPart.push(v);
      this.arrayTypes.push(nt);
    }
  }

  rawSet(
    key: LuaValue,
    value: LuaValue,
    numType?: NumericType,
  ): void | Promise<void> {
    if (isPromise(key)) {
      return key.then((key) => this.rawSet(key, value, numType));
    }
    if (isPromise(value)) {
      return value.then((v) => this.rawSet(key, v, numType));
    }

    // Fast path: string keys (the dominant case)
    if (typeof key === "string") {
      if (value === null || value === undefined) {
        delete this.stringKeys[key];
        this.stringKeyTypes.delete(key);
      } else {
        this.stringKeys[key] = value;
        if (isLuaNumber(value) && numType) {
          this.stringKeyTypes.set(key, numType);
        } else {
          this.stringKeyTypes.delete(key);
        }
      }
      return;
    }

    const normalizedKey = LuaTable.normalizeNumericKey(key);

    if (typeof normalizedKey === "string") {
      if (value === null || value === undefined) {
        delete this.stringKeys[normalizedKey];
        this.stringKeyTypes.delete(normalizedKey);
      } else {
        this.stringKeys[normalizedKey] = value;
        if (isLuaNumber(value) && numType) {
          this.stringKeyTypes.set(normalizedKey, numType);
        } else {
          this.stringKeyTypes.delete(normalizedKey);
        }
      }
      return;
    }

    if (
      typeof normalizedKey === "number" && Number.isInteger(normalizedKey) &&
      normalizedKey >= 1
    ) {
      const idx = normalizedKey - 1;

      // Sparse writes (e.g. `a[7]=4` when length is 3) go to the hash
      // part so that `#a` does not jump across holes.
      if (idx <= this.arrayPart.length) {
        this.arrayPart[idx] = value;
        if (isLuaNumber(value) && numType) {
          this.arrayTypes[idx] = numType;
        } else {
          this.arrayTypes[idx] = undefined;
        }

        // If we extended the array by appending, we may now be able to
        // promote subsequent integer keys from the hash part.
        if (idx === this.arrayPart.length - 1) {
          this.promoteIntegerKeysFromHash();
        }

        // Trailing nil shrink
        if (value === null || value === undefined) {
          let n = this.arrayPart.length;
          while (n > 0) {
            const v = this.arrayPart[n - 1];
            if (v === null || v === undefined) {
              n--;
              continue;
            }
            break;
          }
          if (n !== this.arrayPart.length) {
            this.arrayPart.length = n;
            this.arrayTypes.length = n;
          }
        }

        return;
      }

      // Sparse numeric key
      if (!this.otherKeys) {
        this.otherKeys = new Map();
      }
      if (!this.otherKeyTypes) {
        this.otherKeyTypes = new Map();
      }

      if (value === null || value === undefined) {
        this.otherKeys.delete(normalizedKey);
        this.otherKeyTypes.delete(normalizedKey);
      } else {
        this.otherKeys.set(normalizedKey, value);
        if (isLuaNumber(value) && numType) {
          this.otherKeyTypes.set(normalizedKey, numType);
        } else {
          this.otherKeyTypes.delete(normalizedKey);
        }
      }
      return;
    }

    if (!this.otherKeys) {
      this.otherKeys = new Map();
    }
    if (!this.otherKeyTypes) {
      this.otherKeyTypes = new Map();
    }

    if (value === null || value === undefined) {
      this.otherKeys.delete(normalizedKey);
      this.otherKeyTypes.delete(normalizedKey);
    } else {
      this.otherKeys.set(normalizedKey, value);
      if (isLuaNumber(value) && numType) {
        this.otherKeyTypes.set(normalizedKey, numType);
      } else {
        this.otherKeyTypes.delete(normalizedKey);
      }
    }
  }

  set(
    key: LuaValue,
    value: LuaValue,
    sf?: LuaStackFrame,
    numType?: NumericType,
  ): Promise<void> | void {
    const errSf = sf || LuaStackFrame.lostFrame;
    const ctx = sf?.astCtx ?? EMPTY_CTX;

    if (this.has(key)) {
      return this.rawSet(key, value, numType);
    }

    if (this.metatable === null) {
      return this.rawSet(key, value, numType);
    }

    const newIndexMM = this.metatable.rawGet("__newindex");

    if (newIndexMM === undefined || newIndexMM === null) {
      return this.rawSet(key, value, numType);
    }

    const k: LuaValue = key;
    const v: LuaValue = value;
    const nt: NumericType | undefined = numType;

    let target: LuaValue | null = null;

    for (let loop = 0; loop < MAX_TAG_LOOP; loop++) {
      const t = target === null ? this : target;

      if (t instanceof LuaTable) {
        if (t.has(k)) {
          return t.rawSet(k, v, nt);
        }

        const mt = t.metatable;
        if (!mt) {
          return t.rawSet(k, v, nt);
        }

        const mm = mt.rawGet("__newindex");
        const hasMM = !(mm === undefined || mm === null);

        if (!hasMM) {
          return t.rawSet(k, v, nt);
        }

        // Function metamethod: call and stop
        if (typeof mm === "function" || isILuaFunction(mm)) {
          return luaCall(mm, [t, k, v], ctx, errSf);
        }

        // Table/env metamethod: forward assignment
        if (mm instanceof LuaTable || mm instanceof LuaEnv) {
          target = mm;
          continue;
        }

        const ty = luaTypeOf(mm) as any as string;
        throw new LuaRuntimeError(
          `attempt to index a ${ty} value`,
          errSf.withCtx(ctx),
        );
      }

      const ty = luaTypeOf(t) as any as string;
      throw new LuaRuntimeError(
        `attempt to index a ${ty} value`,
        errSf.withCtx(ctx),
      );
    }

    throw new LuaRuntimeError(
      "'__newindex' chain too long; possible loop",
      errSf.withCtx(ctx),
    );
  }

  getNumericType(key: LuaValue): NumericType | undefined {
    if (typeof key === "string") {
      return this.stringKeyTypes.get(key);
    }
    if (LuaTable.isIntegerKey(key)) {
      return this.arrayTypes[LuaTable.toIndex(key)];
    }
    if (this.otherKeyTypes) {
      return this.otherKeyTypes.get(key);
    }
    return undefined;
  }

  rawGet(key: LuaValue): LuaValue | null {
    if (typeof key === "string") {
      return this.stringKeys[key];
    }

    const normalizedKey = LuaTable.normalizeNumericKey(key);

    if (typeof normalizedKey === "string") {
      return this.stringKeys[normalizedKey];
    }

    if (
      typeof normalizedKey === "number" && Number.isInteger(normalizedKey) &&
      normalizedKey >= 1
    ) {
      const idx = normalizedKey - 1;
      const v = this.arrayPart[idx];
      if (v !== undefined) {
        return v;
      }
      // Sparse integer keys can live in the hash part.
      if (this.otherKeys) {
        return this.otherKeys.get(normalizedKey);
      }
      return undefined;
    }

    if (this.otherKeys) {
      return this.otherKeys.get(normalizedKey);
    }
  }

  get(key: LuaValue, sf?: LuaStackFrame): LuaValue | Promise<LuaValue> | null {
    return luaIndexValue(this, key, sf);
  }

  insert(value: LuaValue, pos: number) {
    this.arrayPart.splice(pos - 1, 0, value);
  }

  remove(pos: number) {
    this.arrayPart.splice(pos - 1, 1);
  }

  async sort(fn?: ILuaFunction, sf?: LuaStackFrame) {
    if (fn && sf) {
      this.arrayPart = await asyncQuickSort(this.arrayPart, async (a, b) => {
        return (await fn.call(sf, a, b)) ? -1 : 1;
      });
    } else {
      this.arrayPart.sort();
    }
  }

  toJSObject(sf = LuaStackFrame.lostFrame): Record<string, any> {
    const result: Record<string, any> = {};
    for (const key of this.keys()) {
      result[key] = luaValueToJS(this.get(key, sf), sf);
    }
    return result;
  }

  toJSArray(sf = LuaStackFrame.lostFrame): any[] {
    return this.arrayPart.map((v) => luaValueToJS(v, sf));
  }

  toJS(sf = LuaStackFrame.lostFrame): Record<string, any> | any[] {
    if (this.length > 0) {
      return this.toJSArray(sf);
    }
    return this.toJSObject(sf);
  }

  async toStringAsync(): Promise<string> {
    const metatable = getMetatable(this);
    if (metatable) {
      const mm = metatable.rawGet("__tostring");
      if (!(mm === undefined || mm === null)) {
        const ctx = EMPTY_CTX;
        const sf = LuaStackFrame.lostFrame.withCtx(ctx);

        const r = luaCall(mm, [this], ctx, sf);
        const v = isPromise(r) ? await r : r;

        const s = singleResult(v);
        if (typeof s !== "string") {
          throw new LuaRuntimeError(
            "'__tostring' must return a string",
            sf,
          );
        }
        return s;
      }
    }

    let result = "{";
    let first = true;
    for (const key of this.keys()) {
      if (first) {
        first = false;
      } else {
        result += ", ";
      }
      if (typeof key === "number") {
        result += await luaToString(this.get(key));
        continue;
      }
      if (typeof key === "string") {
        result += key;
      } else {
        result += "[" + key + "]";
      }
      result += " = " + await luaToString(this.get(key));
    }
    result += "}";
    return result;
  }
}

/**
 * Lookup a key in a table or a metatable
 */
export function luaIndexValue(
  value: LuaValue,
  key: LuaValue,
  sf?: LuaStackFrame,
): LuaValue | Promise<LuaValue> | null {
  // `nil` handling is done by luaGet() which has better context;
  // keep this defensive for direct callers.
  if (value === null || value === undefined) {
    return null;
  }

  const errSf = sf || LuaStackFrame.lostFrame;
  const ctx = sf?.astCtx ?? EMPTY_CTX;

  let t: LuaValue = value;

  for (let loop = 0; loop < MAX_TAG_LOOP; loop++) {
    // Primitive get when table
    if (t instanceof LuaTable) {
      const raw = t.rawGet(key);
      if (raw !== undefined) {
        return raw;
      }
      // If no metatable, raw miss => nil
      if (t.metatable === null) {
        return null;
      }
    }

    const mt = getMetatable(t, errSf);
    const mm = mt ? mt.rawGet("__index") : null;

    if (mm === undefined || mm === null) {
      // Strict Lua: only tables are indexable without a metamethod.
      // For a table, raw miss yields nil; for non-table, it's a type error.
      if (t instanceof LuaTable) {
        return null;
      }
      const ty = luaTypeOf(t) as any as string;
      throw new LuaRuntimeError(
        `attempt to index a ${ty} value`,
        errSf.withCtx(ctx),
      );
    }

    // Function metamethod
    if (typeof mm === "function" || isILuaFunction(mm)) {
      return luaCall(mm, [t, key], ctx, errSf);
    }

    // Table/metatable delegation: repeat with mm as new "t"
    if (mm instanceof LuaTable || mm instanceof LuaEnv) {
      t = mm;
      continue;
    }

    // Bad metamethod type: make it a Lua-like type error
    const ty = luaTypeOf(mm) as any as string;
    throw new LuaRuntimeError(
      `attempt to index a ${ty} value`,
      errSf.withCtx(ctx),
    );
  }

  throw new LuaRuntimeError(
    "'__index' chain too long; possible loop",
    errSf.withCtx(ctx),
  );
}

export type LuaLValueContainer = { env: ILuaSettable; key: LuaValue };

export async function luaSet(
  obj: any,
  key: any,
  value: any,
  sf: LuaStackFrame,
  numType?: NumericType,
): Promise<void> {
  if (!obj) {
    throw new LuaRuntimeError(
      `Not a settable object: nil`,
      sf,
    );
  }

  const normKey = isTaggedFloat(key) ? key.value : key;

  if (obj instanceof LuaTable || obj instanceof LuaEnv) {
    await obj.set(normKey, value, sf, numType);
  } else {
    const k = toNumKey(normKey);
    (obj as Record<string | number, any>)[k] = await luaValueToJS(value, sf);
  }
}

export function luaGet(
  obj: any,
  key: any,
  ctx: ASTCtx | null,
  sf: LuaStackFrame,
): Promise<any> | any {
  const errSf = ctx ? sf.withCtx(ctx) : sf;

  if (obj === null || obj === undefined) {
    throw new LuaRuntimeError(
      `attempt to index a nil value`,
      errSf,
    );
  }
  if (key === null || key === undefined) {
    throw new LuaRuntimeError(
      `attempt to index with a nil key`,
      errSf,
    );
  }

  if (obj instanceof LuaTable || obj instanceof LuaEnv) {
    return obj.get(key, sf);
  }
  if (typeof key === "number") {
    return (obj as any[])[key - 1];
  }
  if (isTaggedFloat(key)) {
    return (obj as any[])[key.value - 1];
  }
  // Native JS object
  const k = toNumKey(key);
  const val = (obj as Record<string | number, any>)[k];
  if (typeof val === "function") {
    // Automatically bind the function to the object
    return val.bind(obj);
  }
  if (val === undefined) {
    return null;
  }
  return val;
}

export function luaLen(
  obj: any,
  sf?: LuaStackFrame,
): number {
  if (typeof obj === "string") {
    return obj.length;
  }
  if (Array.isArray(obj)) {
    return obj.length;
  }
  if (obj instanceof LuaTable) {
    return obj.rawLength;
  }

  const t = luaTypeOf(obj) as LuaType;
  throw new LuaRuntimeError(
    `bad argument #1 to 'rawlen' (table or string expected, got ${t})`,
    sf || LuaStackFrame.lostFrame,
  );
}

export function luaCall(
  callee: any,
  args: any[],
  ctx: ASTCtx,
  sf?: LuaStackFrame,
): any {
  if (!callee) {
    throw new LuaRuntimeError(
      `attempt to call a nil value`,
      (sf || LuaStackFrame.lostFrame).withCtx(ctx),
    );
  }

  // Fast path: native JS function
  if (typeof callee === "function") {
    const jsArgs = rpAll(
      args.map((v) => luaValueToJS(v, sf || LuaStackFrame.lostFrame)),
    );

    if (isPromise(jsArgs)) {
      return jsArgs.then((resolved) =>
        (callee as (...a: any[]) => any)(...resolved)
      );
    }
    return (callee as (...a: any[]) => any)(...jsArgs);
  }

  // Lua table: may be callable via __call metamethod
  if (callee instanceof LuaTable) {
    const metatable = getMetatable(callee, sf);

    // Metamethod lookup must be raw (no __index involvement).
    const mm = metatable ? metatable.rawGet("__call") : null;

    if (!(mm === undefined || mm === null)) {
      const isCallable = (v: any): boolean => {
        if (v === null || v === undefined) return false;
        if (typeof v === "function") return true;
        if (isILuaFunction(v)) return true;
        if (v instanceof LuaTable) {
          const mt2 = getMetatable(v, sf);
          const mm2 = mt2 ? mt2.rawGet("__call") : null;
          return !(mm2 === undefined || mm2 === null);
        }
        return false;
      };

      if (!isCallable(mm)) {
        throw new LuaRuntimeError(
          `attempt to call a ${luaTypeOf(mm)} value`,
          (sf || LuaStackFrame.lostFrame).withCtx(ctx),
        );
      }

      return luaCall(mm, [callee, ...args], ctx, sf);
    }
  }

  // ILuaFunction (LuaFunction/LuaBuiltinFunction/LuaNativeJSFunction/etc.)
  if (isILuaFunction(callee)) {
    const base = (sf || LuaStackFrame.lostFrame).withCtx(ctx);
    const frameForCall = callee instanceof LuaFunction
      ? base.withFunction(callee)
      : base;
    return callee.call(
      frameForCall,
      ...args,
    );
  }

  throw new LuaRuntimeError(
    `attempt to call a non-callable value of type: ${luaTypeOf(callee)}`,
    (sf || LuaStackFrame.lostFrame).withCtx(ctx),
  );
}

export function luaEquals(a: any, b: any): boolean {
  const an = isTaggedFloat(a) ? a.value : a;
  const bn = isTaggedFloat(b) ? b.value : b;
  return an === bn;
}

export function luaKeys(val: any): any[] {
  if (val instanceof LuaTable) {
    return val.keys();
  }
  if (Array.isArray(val)) {
    return val.map((_, i) => i + 1);
  }
  return Object.keys(val);
}

export function luaTypeOf(val: any): LuaType | Promise<LuaType> {
  if (val === null || val === undefined) {
    return "nil";
  }
  if (isPromise(val)) {
    return (val as Promise<any>).then((v) => luaTypeOf(v));
  }
  if (typeof val === "boolean") {
    return "boolean";
  }
  if (typeof val === "number") {
    return "number";
  }
  if (isTaggedFloat(val)) {
    return "number";
  }
  if (typeof val === "string") {
    return "string";
  }
  if (val instanceof LuaTable) {
    return "table";
  }
  if (Array.isArray(val)) {
    return "table";
  }
  if (typeof val === "function" || isILuaFunction(val)) {
    return "function";
  }
  if (typeof val === "object" && (val as any).constructor === Object) {
    return "table";
  }
  return "userdata";
}

export class LuaRuntimeError extends Error {
  constructor(
    override readonly message: string,
    public sf: LuaStackFrame,
    cause?: Error,
  ) {
    super(message, cause);
  }

  toPrettyString(code: string): string {
    if (!this.sf || !this.sf.astCtx?.from || !this.sf.astCtx?.to) {
      return this.toString();
    }
    let traceStr = "";
    let current: LuaStackFrame | undefined = this.sf;
    while (current) {
      const ctx = current.astCtx;
      if (!ctx || !ctx.from || !ctx.to) {
        break;
      }
      // Find the line and column
      let line = 1;
      let column = 0;
      let lastNewline = -1;
      for (let i = 0; i < ctx.from; i++) {
        if (code[i] === "\n") {
          line++;
          lastNewline = i;
          column = 0;
        } else {
          column++;
        }
      }

      // Get the full line of code for context
      const lineStart = lastNewline + 1;
      const lineEnd = code.indexOf("\n", ctx.from);
      const codeLine = code.substring(
        lineStart,
        lineEnd === -1 ? undefined : lineEnd,
      );

      // Add position indicator
      const pointer = " ".repeat(column) + "^";

      traceStr += `* ${ctx.ref || "(unknown source)"} @ ${line}:${column}:\n` +
        `   ${codeLine}\n` +
        `   ${pointer}\n`;
      current = current.parent;
    }

    return `LuaRuntimeError: ${this.message}\nStack trace:\n${traceStr}`;
  }

  override toString() {
    return `LuaRuntimeError: ${this.message} at ${this.sf.astCtx?.from}, ${this.sf.astCtx?.to}`;
  }
}

export function luaTruthy(value: any): boolean {
  if (value === undefined || value === null || value === false) {
    return false;
  }

  if (typeof value === "object" && value instanceof LuaMultiRes) {
    // for multi-return values, only the first result determines truthiness
    const first = value.unwrap();
    return !(first === null || first === undefined || first === false);
  }

  // all non-`nil`/non-`false` values are truthy (including empty tables)
  return true;
}

export function luaToString(
  value: any,
  visited: Set<any> = new Set(),
): string | Promise<string> {
  if (value === null || value === undefined) {
    return "nil";
  }
  if (isPromise(value)) {
    return (value as Promise<any>).then((v) => luaToString(v, visited));
  }

  if (isTaggedFloat(value)) {
    return luaFormatNumber(value.value, "float");
  }

  if (typeof value === "number") {
    return luaFormatNumber(value);
  }

  // Check for circular references
  if (typeof value === "object" && visited.has(value)) {
    return "<circular reference>";
  }
  if ((value as any).toStringAsync) {
    // Add to visited before recursing
    visited.add(value);
    return (value as any).toStringAsync();
  }
  if ((value as any).asString) {
    visited.add(value);
    return (value as any).asString();
  }
  if (value instanceof LuaFunction) {
    // Don't recurse into the function body, just show the function signature
    return `<lua-function (${value.body.parameters.join(", ")})>`;
  }
  // Handle plain JavaScript objects in a Lua-like format
  if (typeof value === "object") {
    // Add to visited before recursing
    visited.add(value);
    return (async () => {
      let result = "{";
      let first = true;

      // Handle arrays
      if (Array.isArray(value)) {
        for (const val of value) {
          if (first) {
            first = false;
          } else {
            result += ", ";
          }
          // Recursively stringify the value, passing the visited set
          const strVal = await luaToString(val, visited);
          result += strVal;
        }
        return result + "}";
      }

      // Handle objects
      for (const [key, val] of Object.entries(value)) {
        if (first) {
          first = false;
        } else {
          result += ", ";
        }
        if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
          result += `${key} = `;
        } else {
          result += `["${key}"] = `;
        }
        // Recursively stringify the value, passing the visited set
        const strVal = await luaToString(val, visited);
        result += strVal;
      }
      result += "}";
      return result;
    })();
  }
  return String(value);
}

export function luaFormatNumber(n: number, kind?: "int" | "float"): string {
  if (kind !== "float" && Number.isInteger(n) && isFinite(n)) {
    return String(n);
  }
  if (n !== n) return "-nan";
  if (n === Infinity) return "inf";
  if (n === -Infinity) return "-inf";
  if (n === 0) {
    return (1 / n === -Infinity) ? "-0.0" : "0.0";
  }
  // Delegate to luaFormat for `%.14g`
  const s = luaFormat("%.14g", n);
  // Guarantee `.01 suffix for integer-valued floats
  if (s.indexOf(".") === -1 && s.indexOf("e") === -1) {
    return s + ".0";
  }
  return s;
}

export function getMetatable(
  value: LuaValue,
  sf?: LuaStackFrame,
): LuaTable | null {
  if (value === null || value === undefined) {
    return null;
  }
  if (typeof value === "string") {
    // Prefer per-thread cached string metatable if `_GLOBAL` available
    const thread = sf?.threadLocal;
    const globalEnv = thread?.get("_GLOBAL") as LuaEnv | null | undefined;

    if (thread && globalEnv instanceof LuaEnv) {
      const cached = thread.get("_STRING_MT") as LuaTable | undefined;
      if (cached instanceof LuaTable) {
        return cached;
      }

      const stringMetatable = new LuaTable();
      stringMetatable.set("__index", (globalEnv as any).get("string"));
      thread.setLocal("_STRING_MT", stringMetatable);

      return stringMetatable;
    }

    return null;
  }

  if ((value as any).metatable) {
    return (value as any).metatable as LuaTable;
  }
  return null;
}

export function jsToLuaValue(value: any): any {
  if (isPromise(value)) {
    return (value as Promise<any>).then(jsToLuaValue);
  }
  if (value instanceof LuaTable) {
    return value;
  }
  if (value instanceof Uint8Array || value instanceof ArrayBuffer) {
    return value;
  }
  if (Array.isArray(value) && "index" in value && "input" in value) {
    // This is a RegExpMatchArray
    const regexMatch = value as RegExpMatchArray;
    const regexMatchTable = new LuaTable();
    for (let i = 0; i < regexMatch.length; i++) {
      regexMatchTable.set(i + 1, regexMatch[i]);
    }
    regexMatchTable.set("index", regexMatch.index);
    regexMatchTable.set("input", regexMatch.input);
    regexMatchTable.set("groups", regexMatch.groups);
    return regexMatchTable;
  }
  if (Array.isArray(value)) {
    const table = new LuaTable();
    for (let i = 0; i < value.length; i++) {
      table.set(i + 1, jsToLuaValue(value[i]));
    }
    return table;
  }
  if (typeof value === "object") {
    const table = new LuaTable();
    for (const key in value) {
      table.set(key, jsToLuaValue((value as any)[key]));
    }
    return table;
  }
  if (typeof value === "function") {
    return new LuaNativeJSFunction(value);
  }
  return value;
}

// Inverse of jsToLuaValue
export function luaValueToJS(value: any, sf: LuaStackFrame): any {
  if (isPromise(value)) {
    return (value as Promise<any>).then((v) => luaValueToJS(v, sf));
  }
  if (value instanceof LuaTable) {
    return value.toJS(sf);
  }
  if (
    value instanceof LuaNativeJSFunction || value instanceof LuaFunction ||
    value instanceof LuaBuiltinFunction
  ) {
    return (...args: any[]) => {
      const jsArgs = rpAll(
        args.map((v) => luaValueToJS(v, sf)),
      );
      if (isPromise(jsArgs)) {
        return luaValueToJS(
          jsArgs.then((jsArgs) => (value as ILuaFunction).call(sf, ...jsArgs)),
          sf,
        );
      }
      return luaValueToJS((value as ILuaFunction).call(sf, ...jsArgs), sf);
    };
  }
  if (isTaggedFloat(value)) {
    return value.value;
  }
  return value;
}


================================================
FILE: client/space_lua/stdlib.ts
================================================
import {
  type ILuaFunction,
  isILuaFunction,
  isLuaTable,
  LuaBuiltinFunction,
  luaCall,
  luaCloseFromMark,
  luaEnsureCloseStack,
  LuaEnv,
  luaGet,
  luaKeys,
  luaLen,
  LuaMultiRes,
  LuaRuntimeError,
  type LuaStackFrame,
  type LuaTable,
  luaToString,
  luaTypeOf,
  type LuaValue,
} from "./runtime.ts";
import { stringApi } from "./stdlib/string.ts";
import { tableApi } from "./stdlib/table.ts";
import { osApi } from "./stdlib/os.ts";
import { jsApi } from "./stdlib/js.ts";
import { spaceluaApi } from "./stdlib/space_lua.ts";
import { mathApi } from "./stdlib/math.ts";
import { parse } from "./parse.ts";
import { evalStatement } from "./eval.ts";
import { encodingApi } from "./stdlib/encoding.ts";
import { luaToNumberDetailed } from "./tonumber.ts";
import { luaLoad } from "./stdlib/load.ts";
import { cryptoApi } from "./stdlib/crypto.ts";
import { netApi } from "./stdlib/net.ts";
import { isTaggedFloat, makeLuaFloat } from "./numeric.ts";

const printFunction = new LuaBuiltinFunction(async (_sf, ...args) => {
  console.log(
    "[Lua]",
    ...(await Promise.all(args.map((v) => luaToString(v)))),
  );
});

const assertFunction = new LuaBuiltinFunction(
  async (sf, value: any, message?: string) => {
    if (!await value) {
      throw new LuaRuntimeError(`Assertion failed: ${message}`, sf);
    }
  },
);

const ipairsFunction = new LuaBuiltinFunction((sf, t: LuaTable | any[]) => {
  let i = 0;

  return async () => {
    i = i + 1;

    const v = await luaGet(t, i, sf.astCtx ?? null, sf);
    if (v === null || v === undefined) {
      return;
    }

    return new LuaMultiRes([i, v]);
  };
});

const pairsFunction = new LuaBuiltinFunction(
  (sf, t: LuaTable | any[] | Record<string, any>) => {
    // Respect `__pairs` metamethod for Lua tables
    if (isLuaTable(t)) {
      const mt = (t as any).metatable as LuaTable | null | undefined;
      if (mt) {
        const mm = mt.get("__pairs", sf);
        if (mm && (typeof mm === "function" || isILuaFunction(mm))) {
          // __pairs must return (iter, state, control, closing)
          return luaCall(mm, [t], sf.astCtx ?? {}, sf);
        }
      }
    }

    let keys: (string | number)[];
    if (Array.isArray(t)) {
      keys = Array.from({ length: t.length }, (_, i) => i + 1); // For arrays, generate 1-based indices
    } else if (isLuaTable(t) || t instanceof LuaEnv) {
      keys = t.keys();
    } else {
      // For plain JavaScript objects case, note: this will also include keys from the prototype
      keys = [];
      for (const key in t) {
        keys.push(key);
      }
    }

    let i = 0;
    const iter = async () => {
      if (i >= keys.length) {
        return;
      }
      const key = keys[i];
      i++;
      const value = await luaGet(t, key, sf.astCtx ?? null, sf);
      return new LuaMultiRes([key, value]);
    };

    // Must return (iter, state, control) for generic for
    return new LuaMultiRes([iter, t, null]);
  },
);

export const eachFunction = new LuaBuiltinFunction(
  (sf, ar: LuaTable | any[]) => {
    let i = 1;
    const length = (ar as any).length;
    return async () => {
      if (i > length) {
        return;
      }
      const result = await luaGet(ar, i, sf.astCtx ?? null, sf);
      i++;
      return result;
    };
  },
);

const unpackFunction = new LuaBuiltinFunction(async (sf, t: LuaTable) => {
  const values: LuaValue[] = [];
  for (let i = 1; i <= (t as any).length; i++) {
    values.push(await luaGet(t, i, sf.astCtx ?? null, sf));
  }
  return new LuaMultiRes(values);
});

const typeFunction = new LuaBuiltinFunction(
  (_sf, value: LuaValue): string | Promise<string> => {
    return luaTypeOf(value);
  },
);

const tostringFunction = new LuaBuiltinFunction((_sf, value: any) => {
  return luaToString(value);
});

const tonumberFunction = new LuaBuiltinFunction(
  (sf, value: LuaValue, base?: number) => {
    if (base !== undefined) {
      if (!(typeof base === "number" && base >= 2 && base <= 36)) {
        throw new LuaRuntimeError(
          "bad argument #2 to 'tonumber' (base out of range)",
          sf,
        );
      }
    }

    if (typeof value === "number") {
      return value;
    }
    if (isTaggedFloat(value)) {
      return value;
    }

    if (typeof value !== "string") {
      return null;
    }

    const result = luaToNumberDetailed(value, base);
    if (result === null) {
      return null;
    }

    if (result.numericType === "float") {
      return makeLuaFloat(result.value);
    }

    return result.value;
  },
);

const errorFunction = new LuaBuiltinFunction((sf, message: string) => {
  throw new LuaRuntimeError(message, sf);
});

async function pcallBoundary(
  sf: LuaStackFrame,
  fn: ILuaFunction,
  args: LuaValue[],
): Promise<
  | { ok: true; values: LuaValue[] }
  | { ok: false; message: string }
> {
  const closeStack = luaEnsureCloseStack(sf);
  const mark = closeStack.length;

  const errMsgOf = (e: any): string =>
    e instanceof LuaRuntimeError ? e.message : (e?.message ?? String(e));

  try {
    const r = await luaCall(fn, args, sf.astCtx!, sf);
    await luaCloseFromMark(sf, mark, null);
    const values = r instanceof LuaMultiRes ? r.flatten().values : [r];
    return { ok: true, values };
  } catch (e: any) {
    const msg = errMsgOf(e);
    try {
      await luaCloseFromMark(sf, mark, msg);
      return { ok: false, message: msg };
    } catch (closeErr: any) {
      return { ok: false, message: errMsgOf(closeErr) };
    }
  }
}

const pcallFunction = new LuaBuiltinFunction(
  async (sf, fn: ILuaFunction, ...args) => {
    // To-be-closed variables must be closed when unwinding to the
    // protected call boundary. Space Lua uses a per-thread close
    // stack, so we snapshot its length and close anything pushed
    // after that.
    //
    // The protected call boundary must be established *before*
    // evaluating the function and its arguments.  Otherwise, any
    // `<close>` locals created while evaluating `pcall`'s arguments
    // will be wrongly treated as "inside" the protected call, and
    // `pcall` may end up closing them (or affecting close ordering).
    //
    // `threadState` is read-only on the stack frame; do not reassign!
    const res = await pcallBoundary(sf, fn, args);
    if (res.ok) {
      return new LuaMultiRes([true, ...res.values]);
    }
    return new LuaMultiRes([false, res.message]);
  },
);

const xpcallFunction = new LuaBuiltinFunction(
  async (sf, fn: ILuaFunction, errorHandler: ILuaFunction, ...args) => {
    // Same semantic as `pcall` (see comments there)
    const res = await pcallBoundary(sf, fn, args);
    if (res.ok) {
      return new LuaMultiRes([true, ...res.values]);
    }
    const hr = await luaCall(errorHandler, [res.message], sf.astCtx!, sf);
    const outVals = hr instanceof LuaMultiRes ? hr.flatten().values : [hr];
    return new LuaMultiRes([false, ...outVals]);
  },
);

const setmetatableFunction = new LuaBuiltinFunction(
  (sf, table: LuaTable, metatable: LuaTable) => {
    if (!metatable) {
      throw new LuaRuntimeError("metatable cannot be set to nil", sf);
    }
    table.metatable = metatable;
    return table;
  },
);

const rawlenFunction = new LuaBuiltinFunction(
  (_sf, value: LuaValue) => {
    return luaLen(value, _sf);
  },
);

const rawsetFunction = new LuaBuiltinFunction(
  (_sf, table: LuaTable, key: LuaValue, value: LuaValue) => {
    return (table as any).rawSet(key, value);
  },
);

const rawgetFunction = new LuaBuiltinFunction(
  (_sf, table: any, key: LuaValue) => {
    const isArray = Array.isArray(table);

    const isPlainObj = typeof table === "object" &&
      table !== null &&
      (table as any).constructor === Object;

    if (!isLuaTable(table) && !isArray && !isPlainObj) {
      let typeName = "userdata";
      if (table === null || table === undefined) {
        typeName = "nil";
      } else if (typeof table === "boolean") {
        typeName = "boolean";
      } else if (typeof table === "number" || isTaggedFloat(table)) {
        typeName = "number";
      } else if (typeof table === "string") {
        typeName = "string";
      } else if (
        typeof table === "function" ||
        (typeof table === "object" &&
          table !== null &&
          typeof (table as any).call === "function")
      ) {
        typeName = "function";
      }
      throw new LuaRuntimeError(
        `bad argument #1 to 'rawget' (table expected, got ${typeName})`,
        _sf,
      );
    }

    if (isLuaTable(table)) {
      const v = table.rawGet(key);
      return v === undefined ? null : v;
    }

    const k = isTaggedFloat(key) ? key.value : key;

    if (isArray) {
      if (typeof k === "number") {
        const v = (table as any[])[k - 1];
        return v === undefined ? null : v;
      }
      const v = (table as Record<string, any>)[k];
      return v === undefined ? null : v;
    }

    const v = (table as Record<string | number, any>)[k as any];
    return v === undefined ? null : v;
  },
);

const rawequalFunction = new LuaBuiltinFunction(
  (_sf, a: any, b: any) => {
    const av = isTaggedFloat(a) ? a.value : a;
    const bv = isTaggedFloat(b) ? b.value : b;
    return av === bv;
  },
);

const getmetatableFunction = new LuaBuiltinFunction((_sf, table: LuaTable) => {
  return (table as any).metatable;
});

const dofileFunction = new LuaBuiltinFunction(async (sf, filename: string) => {
  const global = sf.threadLocal.get("_GLOBAL") as LuaEnv;
  const file = await luaCall(
    (global.get("space") as any).get("readFile"),
    [filename],
    sf.astCtx!,
    sf,
  ) as Uint8Array;
  const code = new TextDecoder().decode(file);
  try {
    const parsedExpr = parse(code);
    const env = new LuaEnv(global);
    await evalStatement(parsedExpr, env, sf.withCtx(parsedExpr.ctx));
  } catch (e: any) {
    throw new LuaRuntimeError(
      `Error evaluating "${filename}": ${e.message}`,
      sf,
    );
  }
});

/**
 * From the Lua docs:
 *
 * If index is a number, returns all arguments after argument number
 * index; a negative number indexes from the end (-1 is the last
 * argument). Otherwise, index must be the string "#", and select
 * returns the total number of extra arguments it received.
 */
const selectFunction = new LuaBuiltinFunction(
  (_sf, index: number | "#", ...args: LuaValue[]) => {
    if (index === "#") {
      return args.length;
    }
    if (typeof index === "number") {
      if (index >= 0) {
        return new LuaMultiRes(args.slice(index - 1));
      }
      return new LuaMultiRes(args.slice(args.length + index));
    }
  },
);

/**
 * From the Lua docs:
 *
 * Allows a program to traverse all fields of a table. Its first
 * argument is a table and its second argument is an index in this
 * table. A call to next returns the next index of the table and its
 * associated value. When called with nil as its second argument, next
 * returns an initial index and its associated value. When called with
 * the last index, or with nil in an empty table, next returns nil. If
 * the second argument is absent, then it is interpreted as nil. In
 * particular, you can use next(t) to check whether a table is empty.
 *
 * The order in which the indices are enumerated is not specified, even
 * for numeric indices. (To traverse a table in numerical order, use
 * a numerical for.)
 *
 * You should not assign any value to a non-existent field in a table
 * during its traversal. You may however modify existing fields. In
 * particular, you may set existing fields to nil.
 */
const nextFunction = new LuaBuiltinFunction(
  (sf, table: LuaTable | Record<string, any>, index: number | null = null) => {
    if (!table) {
      // When nil value
      return null;
    }
    const keys = luaKeys(table);

    // Empty table -> null return value
    if (keys.length === 0) {
      return null;
    }

    if (index === null) {
      // Return the first key, value
      const key = keys[0];
      return new LuaMultiRes([key, luaGet(table, key, sf.astCtx ?? null, sf)]);
    }
    // Find index in the key list
    const idx = keys.indexOf(index);
    if (idx === -1) { // Not found
      throw new LuaRuntimeError("invalid key to 'next': key not found", sf);
    }
    const key = keys[idx + 1];
    if (key === undefined) {
      // When called with the last key, should return nil
      return null;
    }
    return new LuaMultiRes([key, luaGet(table, key, sf.astCtx ?? null, sf)]);
  },
);

// Non-standard, but useful
const someFunction = new LuaBuiltinFunction(async (_sf, value: any) => {
  switch (await luaTypeOf(value)) {
    case "number":
      if (!isFinite(value)) return null;
      break;
    case "string":
      if (value.trim() === "") return null;
      break;
    case "table":
      if (luaKeys(value).length === 0) return null;
  }
  return value;
});

const loadFunction = new LuaBuiltinFunction((sf, s) => luaLoad(s, sf));

export function luaBuildStandardEnv() {
  const env = new LuaEnv();
  // _G global
  env.set("_G", env);
  // Top-level builtins
  env.set("print", printFunction);
  env.set("assert", assertFunction);
  env.set("type", typeFunction);
  env.set("tostring", tostringFunction);
  env.set("tonumber", tonumberFunction);
  env.set("unpack", unpackFunction);
  env.set("select", selectFunction);
  env.set("next", nextFunction);
  // Iterators
  env.set("pairs", pairsFunction);
  env.set("ipairs", ipairsFunction);
  // meta table stuff
  env.set("setmetatable", setmetatableFunction);
  env.set("getmetatable", getmetatableFunction);
  env.set("rawlen", rawlenFunction);
  env.set("rawset", rawsetFunction);
  env.set("rawget", rawgetFunction);
  env.set("rawequal", rawequalFunction);
  env.set("dofile", dofileFunction);
  // Error handling
  env.set("error", errorFunction);
  env.set("pcall", pcallFunction);
  env.set("xpcall", xpcallFunction);
  // Evaluation
  env.set("load", loadFunction);
  // APIs
  env.set("string", stringApi);
  env.set("table", tableApi);
  env.set("os", osApi);
  env.set("js", jsApi);
  env.set("math", mathApi);
  // Non-standard
  env.set("each", eachFunction);
  env.set("spacelua", spaceluaApi);
  env.set("encoding", encodingApi);
  env.set("crypto", cryptoApi);
  env.set("net", netApi);
  env.set("some", someFunction);
  return env;
}


================================================
FILE: client/space_lua/stdlib/crypto.ts
================================================
import { LuaBuiltinFunction, LuaTable } from "../runtime.ts";
import { hashSHA256 } from "../../../plug-api/lib/crypto.ts";

export const cryptoApi = new LuaTable({
  sha256: new LuaBuiltinFunction(
    (_sf, s: string | Uint8Array): Promise<string> => {
      return hashSHA256(s);
    },
  ),
});


================================================
FILE: client/space_lua/stdlib/crypto_test.lua
================================================
local function assertEqual(a, b)
    if a ~= b then
        error("Assertion failed: " .. a .. " is not equal to " .. b)
    end
end


local s = crypto.sha256(encoding.utf8Encode("test"))
local s2 = crypto.sha256("test")
assertEqual(s, s2)


================================================
FILE: client/space_lua/stdlib/encoding.ts
================================================
import { LuaBuiltinFunction, LuaTable } from "../runtime.ts";
import { base64Decode, base64Encode } from "../../../plug-api/lib/crypto.ts";

export const encodingApi = new LuaTable({
  base64Encode: new LuaBuiltinFunction(
    (_sf, s: string | Uint8Array): string => {
      return base64Encode(s);
    },
  ),
  base64Decode: new LuaBuiltinFunction((_sf, s: string): Uint8Array => {
    return base64Decode(s);
  }),
  utf8Encode: new LuaBuiltinFunction((_sf, s: string): Uint8Array => {
    return new TextEncoder().encode(s);
  }),
  utf8Decode: new LuaBuiltinFunction((_sf, data: Uint8Array): string => {
    return new TextDecoder().decode(data);
  }),
});


================================================
FILE: client/space_lua/stdlib/encoding_test.lua
================================================
local function assertEqual(a, b)
    if a ~= b then
        error("Assertion failed: " .. a .. " is not equal to " .. b)
    end
end


assertEqual(encoding.utf8Decode(encoding.utf8Encode("test")), "test")
assertEqual(encoding.utf8Decode(encoding.base64Decode(encoding.base64Encode("test"))), "test")


================================================
FILE: client/space_lua/stdlib/format.ts
================================================
// Supported specifiers set: [diuoxXaAfeEgGcspq%]
// Supported flags set: [-+0# ]
// Width and precision via digits or `*`

type FormatSpec = {
  flags: number; // FLAG_*
  width: number;
  hasPrec: boolean;
  prec: number;
  spec: number;
};

const FLAG_MINUS = 1;
const FLAG_PLUS = 2;
const FLAG_ZERO = 4;
const FLAG_HASH = 8;
const FLAG_SPACE = 16;

function isDigit(c: number): boolean {
  return c >= 48 && c <= 57; // '0'..'9'
}

// Parse a format spec starting after '%' and return index of specifier.
function parseSpec(
  fmt: string,
  start: number,
): { spec: FormatSpec; end: number } {
  let i = start;
  const len = fmt.length;
  let flags = 0;

  outer: while (i < len) {
    switch (fmt.charCodeAt(i)) {
      case 45:
        flags |= FLAG_MINUS;
        i++;
        break; // '-'
      case 43:
        flags |= FLAG_PLUS;
        i++;
        break; // '+'
      case 48:
        flags |= FLAG_ZERO;
        i++;
        break; // '0'
      case 35:
        flags |= FLAG_HASH;
        i++;
        break; // '#'
      case 32:
        flags |= FLAG_SPACE;
        i++;
        break; // ' '
      default:
        break outer;
    }
  }

  // Parse width
  let width = 0;
  if (i < len && fmt.charCodeAt(i) === 42) { // '*'
    width = -1;
    i++;
  } else {
    while (i < len && isDigit(fmt.charCodeAt(i))) {
      width = width * 10 + (fmt.charCodeAt(i) - 48);
      i++;
    }
  }

  // Parse precision
  let hasPrec = false;
  let prec = 0;
  if (i < len && fmt.charCodeAt(i) === 46) { // '.'
    hasPrec = true;
    i++;
    if (i < len && fmt.charCodeAt(i) === 42) { // '*'
      prec = -1;
      i++;
    } else {
      while (i < len && isDigit(fmt.charCodeAt(i))) {
        prec = prec * 10 + (fmt.charCodeAt(i) - 48);
        i++;
      }
    }
  }

  // Skip length modifiers [hlL] ignored in Lua
  while (
    i < len &&
    (fmt.charCodeAt(i) === 104 || // 'h'
      fmt.charCodeAt(i) === 108 || // 'l'
      fmt.charCodeAt(i) === 76) // 'L'
  ) {
    i++;
  }

  if (i >= len) {
    throw new Error("invalid format (missing specifier)");
  }

  return {
    spec: { flags, width, hasPrec, prec, spec: fmt.charCodeAt(i) },
    end: i,
  };
}

// pad a string to `width` respecting `FLAG_MINUS` and `FLAG_ZERO`
function pad(s: string, width: number, flags: number, numPad: boolean): string {
  if (width <= 0 || s.length >= width) return s;
  const n = width - s.length;
  if (numPad && (flags & FLAG_ZERO) && !(flags & FLAG_MINUS)) {
    let signLen = 0;
    if (s.charCodeAt(0) === 45 || s.charCodeAt(0) === 43) { // '-' or '+'
      signLen = 1;
    } else if (
      s.charCodeAt(0) === 48 &&
      (s.charCodeAt(1) === 120 || s.charCodeAt(1) === 88)
    ) {
      signLen = 2; // '0x' or '0X'
    }
    return s.slice(0, signLen) + "0".repeat(n) + s.slice(signLen);
  }
  if (flags & FLAG_MINUS) {
    return s + " ".repeat(n);
  }
  return " ".repeat(n) + s;
}

function addSign(s: string, flags: number): string {
  if (flags & FLAG_PLUS) return "+" + s;
  if (flags & FLAG_SPACE) return " " + s;
  return s;
}

function formatInt(n: number, spec: FormatSpec): string {
  const code = spec.spec;
  const v = Math.trunc(n);

  let base = 10;
  let unsigned = false;
  let upper = false;

  switch (code) {
    case 100:
    case 105: // 'd', 'i'
      break;
    case 117: // 'u'
      unsigned = true;
      break;
    case 111: // 'o'
      base = 8;
      unsigned = true;
      break;
    case 120: // 'x'
      base = 16;
      unsigned = true;
      break;
    case 88: // 'X'
      base = 16;
      unsigned = true;
      upper = true;
      break;
  }

  let neg = false;
  let digits: string;

  if (unsigned && v < 0) {
    // Reinterpret as 64-bit unsigned
    const bv = BigInt(v) + (1n << 64n);
    digits = bv.toString(base);
  } else if (unsigned) {
    digits = v.toString(base);
  } else {
    neg = v < 0;
    digits = (neg ? -v : v).toString(base);
  }

  if (upper) digits = digits.toUpperCase();

  // Precision
  if (spec.hasPrec) {
    if (spec.prec === 0 && v === 0) {
      digits = "";
    } else if (digits.length < spec.prec) {
      digits = "0".repeat(spec.prec - digits.length) + digits;
    }
  }

  // Alt flag
  let prefix = "";
  if (spec.flags & FLAG_HASH) {
    if (base === 8 && (digits.length === 0 || digits.charCodeAt(0) !== 48)) {
      prefix = "0";
    } else if (base === 16 && v !== 0) {
      prefix = upper ? "0X" : "0x";
    }
  }

  let result: string;
  if (neg) {
    result = "-" + prefix + digits;
  } else {
    result = addSign(prefix + digits, spec.flags);
  }

  const numPad = !spec.hasPrec;
  return pad(result, spec.width, spec.flags, numPad);
}

function formatFloat(n: number, spec: FormatSpec): string {
  const code = spec.spec;
  const upper = code === 69 || code === 71 || code === 70;
  // 'E'=69 'G'=71 'F'=70 'e'=101 'g'=103 'f'=102
  const lower = code | 32; // to lowercase

  // Lua convention
  if (!isFinite(n)) {
    let s: string;
    if (n !== n) {
      s = upper ? "-NAN" : "-nan";
    } else if (n > 0) {
      s = upper ? "INF" : "inf";
      s = addSign(s, spec.flags);
    } else {
      s = upper ? "-INF" : "-inf";
    }
    return pad(s, spec.width, spec.flags, false);
  }

  const neg = n < 0 || (n === 0 && 1 / n === -Infinity);
  const abs = neg ? -n : n;
  const prec = spec.hasPrec ? spec.prec : 6;

  let body: string;

  if (lower === 102) { // 'f'
    body = abs.toFixed(prec);
  } else if (lower === 101) { // 'e'
    body = abs.toExponential(prec);
    // Ensure exponent has at least 2 digits
    body = ensureExpTwoDigits(body);
  } else { // 'g'
    const gPrec = (prec === 0) ? 1 : prec;
    if (abs === 0) {
      body = "0";
    } else {
      // C rule: use 'e' if exponent < -4 or exponent >= precision
      const exp = Math.floor(Math.log10(abs));
      if (exp < -4 || exp >= gPrec) {
        body = abs.toExponential(gPrec - 1);
        body = ensureExpTwoDigits(body);
      } else {
        // Number of decimals = precision - (exponent + 1)
        const decimals = gPrec - (exp + 1);
        body = abs.toFixed(decimals);
      }
    }
    // Strip trailing zeros unless '#' flag
    if (!(spec.flags & FLAG_HASH)) {
      body = stripTrailingZerosG(body);
    }
  }

  if (upper) {
    body = body.toUpperCase();
  }

  // Alt flag for 'f'/'e': ensure decimal point exists
  if ((spec.flags & FLAG_HASH) && lower !== 103) {
    if (body.indexOf(".") === -1) {
      // Insert dot before 'e' if present, else append
      const eIdx = body.indexOf("e");
      const EIdx = body.indexOf("E");
      const expIdx = eIdx !== -1 ? eIdx : EIdx;
      if (expIdx !== -1) {
        body = body.slice(0, expIdx) + "." + body.slice(expIdx);
      } else {
        body = body + ".";
      }
    }
  }

  // Alt flag for 'g': keep trailing zeros but ensure decimal point
  if ((spec.flags & FLAG_HASH) && lower === 103) {
    if (body.indexOf(".") === -1) {
      const expIdx = findExpIndex(body);
      if (expIdx !== -1) {
        body = body.slice(0, expIdx) + "." + body.slice(expIdx);
      } else {
        body = body + ".";
      }
    }
  }

  let result: string;
  if (neg) {
    result = "-" + body;
  } else {
    result = addSign(body, spec.flags);
  }

  return pad(result, spec.width, spec.flags, true);
}

function findExpIndex(s: string): number {
  for (let i = 0; i < s.length; i++) {
    const c = s.charCodeAt(i);
    if (c === 101 || c === 69) return i; // 'e' or 'E'
  }
  return -1;
}

// Ensure exponent part has at least 2 digits
function ensureExpTwoDigits(s: string): string {
  const idx = findExpIndex(s);
  if (idx === -1) return s;
  // idx+1 is sign, idx+2... are digits
  const signIdx = idx + 1;
  if (signIdx >= s.length) return s;
  const digitStart = signIdx + 1;
  const expLen = s.length - digitStart;
  if (expLen < 2) {
    return s.slice(0, digitStart) + "0" + s.slice(digitStart);
  }
  return s;
}

// Strip trailing zeros from '%g' output
function stripTrailingZerosG(s: string): string {
  const expIdx = findExpIndex(s);
  const mantissa = expIdx !== -1 ? s.slice(0, expIdx) : s;
  const exp = expIdx !== -1 ? s.slice(expIdx) : "";

  const dotIdx = mantissa.indexOf(".");
  if (dotIdx === -1) return s; // nothing to strip

  let end = mantissa.length;
  while (end > dotIdx + 1 && mantissa.charCodeAt(end - 1) === 48) { // '0'
    end--;
  }
  // Remove dot if nothing after it
  if (end === dotIdx + 1) {
    end = dotIdx;
  }

  return mantissa.slice(0, end) + exp;
}

// Format a number as hexadecimal floating-point (%a/%A)
function formatHexFloat(n: number, spec: FormatSpec): string {
  const code = spec.spec;
  const upper = code === 65; // 'A'

  if (!isFinite(n)) {
    let s: string;
    if (n !== n) {
      s = upper ? "-NAN" : "-nan";
    } else if (n > 0) {
      s = upper ? "INF" : "inf";
      s = addSign(s, spec.flags);
    } else {
      s = upper ? "-INF" : "-inf";
    }
    return pad(s, spec.width, spec.flags, false);
  }

  const neg = n < 0 || (n === 0 && 1 / n === -Infinity);
  const abs = neg ? -n : n;

  let body: string;
  if (abs === 0) {
    const prec = spec.hasPrec ? spec.prec : 0;
    if (prec > 0) {
      body = "0x0." + "0".repeat(prec) + "p+0";
    } else {
      body = "0x0p+0";
    }
  } else {
    body = hexFloatBody(abs, spec);
  }

  if (upper) body = body.toUpperCase();

  // Alt flag: ensure decimal point
  if (spec.flags & FLAG_HASH) {
    const pIdx = findPIndex(body);
    if (pIdx !== -1) {
      let hasDot = false;
      for (let k = 0; k < pIdx; k++) {
        if (body.charCodeAt(k) === 46) { // '.'
          hasDot = true;
          break;
        }
      }
      if (!hasDot) {
        body = body.slice(0, pIdx) + "." + body.slice(pIdx);
      }
    }
  }

  let result: string;
  if (neg) {
    result = "-" + body;
  } else {
    result = addSign(body, spec.flags);
  }

  return pad(result, spec.width, spec.flags, true);
}

// Find index of 'p' or 'P' in hex float string
function findPIndex(s: string): number {
  for (let i = 0; i < s.length; i++) {
    const c = s.charCodeAt(i);
    if (c === 112 || c === 80) return i; // 'p' or 'P'
  }
  return -1;
}

// Number of bits needed to represent a positive bigint
function bitLength(n: bigint): number {
  let bits = 0;
  let v = n;
  while (v > 0n) {
    bits++;
    v >>= 1n;
  }
  return bits;
}

// Decompose a positive non-zero finite float into `0xH.HHHpN` form
function hexFloatBody(abs: number, spec: FormatSpec): string {
  const buf = new Float64Array(1);
  const view = new DataView(buf.buffer);
  view.setFloat64(0, abs);
  const bits = view.getBigUint64(0);
  const biasedExp = Number((bits >> 52n) & 0x7FFn);
  const frac = bits & 0xFFFFFFFFFFFFFn;

  let exponent: number;
  let mantBits: bigint;

  if (biasedExp === 0) {
    // Subnormal
    if (frac === 0n) return "0x0p+0";
    const shift = 52 - bitLength(frac) + 1;
    mantBits = frac << BigInt(shift);
    exponent = -1022 - shift;
  } else {
    // Normal
    exponent = biasedExp - 1023;
    mantBits = frac | (1n << 52n);
  }

  let firstDigit = Number(mantBits >> 52n);
  const restBits = mantBits & ((1n << 52n) - 1n);

  // 13 hex digits from 52 bits
  let fracHex = hexDigits52(restBits);

  if (spec.hasPrec) {
    if (spec.prec < 13) {
      const carry = roundHexInPlace(fracHex, spec.prec);
      if (carry) firstDigit++;
      fracHex = truncHexDigits(fracHex, spec.prec);
    } else {
      fracHex = padHexRight(fracHex, spec.prec);
    }
  } else {
    fracHex = stripHexTrailingZeros(fracHex);
  }

  const expSign = exponent >= 0 ? "+" : "";
  if (fracHex.length > 0) {
    return "0x" + firstDigit + "." + fracHex + "p" + expSign + exponent;
  }
  return "0x" + firstDigit + "p" + expSign + exponent;
}

// Convert 52-bit value to 13 hex digits, zero-padded
function hexDigits52(bits: bigint): string {
  const s = bits.toString(16);
  if (s.length >= 13) return s;
  return "0".repeat(13 - s.length) + s;
}

// Parse one hex char to its numeric value
function hexVal(c: number): number {
  if (c >= 48 && c <= 57) return c - 48; // '0'..'9'
  if (c >= 97 && c <= 102) return c - 87; // 'a'..'f'
  if (c >= 65 && c <= 70) return c - 55; // 'A'..'F'
  return 0;
}

function roundHexInPlace(digits: string, prec: number): boolean {
  if (prec >= digits.length) return false;

  const nextVal = hexVal(digits.charCodeAt(prec));
  if (nextVal < 8) return false;

  if (prec === 0) return true;

  const arr = new Array<number>(prec);
  for (let i = 0; i < prec; i++) {
    arr[i] = hexVal(digits.charCodeAt(i));
  }

  let carry = 1;
  for (let i = prec - 1; i >= 0 && carry; i--) {
    arr[i] += carry;
    if (arr[i] >= 16) {
      arr[i] = 0;
      carry = 1;
    } else {
      carry = 0;
    }
  }

  return carry === 1;
}

function truncHexDigits(digits: string, prec: number): string {
  if (prec === 0) return "";

  const nextVal = hexVal(digits.charCodeAt(prec));
  if (nextVal < 8) return digits.slice(0, prec);

  const arr = new Array<number>(prec);
  for (let i = 0; i < prec; i++) {
    arr[i] = hexVal(digits.charCodeAt(i));
  }

  let carry = 1;
  for (let i = prec - 1; i >= 0 && carry; i--) {
    arr[i] += carry;
    if (arr[i] >= 16) {
      arr[i] = 0;
    } else {
      carry = 0;
    }
  }

  let out = "";
  for (let i = 0; i < prec; i++) {
    out += arr[i].toString(16);
  }

  return out;
}

function padHexRight(s: string, len: number): string {
  if (s.length >= len) return s;
  return s + "0".repeat(len - s.length);
}

function stripHexTrailingZeros(s: string): string {
  let end = s.length;
  while (end > 0 && s.charCodeAt(end - 1) === 48) { // '0'
    end--;
  }
  if (end === s.length) return s;
  return s.slice(0, end);
}

function quoteString(s: string): string {
  let out = '"';
  for (let i = 0; i < s.length; i++) {
    const c = s.charCodeAt(i);
    if (c === 34 || c === 92 || c === 10) {
      // '"', '\\', '\n': backslash + literal char
      out += "\\";
      out += String.fromCharCode(c);
    } else if (c < 32) {
      const next = i + 1 < s.length ? s.charCodeAt(i + 1) : -1;
      const isNextDigit = next >= 48 && next <= 57;
      if (isNextDigit) {
        const ds = c.toString();
        out += "\\";
        if (ds.length < 3) out += "0".repeat(3 - ds.length);
        out += ds;
      } else {
        out += "\\" + c.toString();
      }
    } else {
      out += String.fromCharCode(c);
    }
  }
  out += '"';
  return out;
}

// Format a float for %q: hex representation preserving full precision
function quoteFloat(n: number): string {
  if (n !== n) return "(0/0)";
  if (n === Infinity) return "1e9999";
  if (n === -Infinity) return "-1e9999";

  const spec: FormatSpec = {
    flags: 0,
    width: 0,
    hasPrec: false,
    prec: 0,
    spec: 97, // 'a'
  };
  return formatHexFloat(n, spec);
}

function formatQ(v: unknown): string {
  if (v === null || v === undefined) return "nil";
  if (v === true) return "true";
  if (v === false) return "false";

  if (typeof v === "number") {
    if (v === 0 && 1 / v === -Infinity) return quoteFloat(v);
    if (Number.isInteger(v) && Number.isFinite(v)) {
      return v.toString();
    }
    return quoteFloat(v);
  }

  return quoteString(String(v));
}

function formatChar(n: number): string {
  return String.fromCharCode(n & 0x7f);
}

const objectIds = new WeakMap<WeakKey, number>();
const stringIds = new Map<string, number>();
let nextId = 1;

function toPointer(v: unknown): string {
  if (v === null || v === undefined) return "(null)";
  if (typeof v === "boolean" || typeof v === "number") return "(null)";

  // Primitives (strings, symbols, etc.) cannot be `WeakMap` keys
  if (typeof v !== "object" && typeof v !== "function") {
    const key = String(v);
    let id = stringIds.get(key);
    if (id === undefined) {
      id = nextId++;
      stringIds.set(key, id);
    }
    return "0x" + id.toString(16).padStart(14, "0");
  }

  const obj = v as object;
  let id = objectIds.get(obj);
  if (id === undefined) {
    id = nextId++;
    objectIds.set(obj, id);
  }
  return "0x" + id.toString(16).padStart(14, "0");
}

function formatPointer(v: unknown, spec: FormatSpec): string {
  const s = toPointer(v);
  // `%p` only supports width and '-' flag, no precision
  return pad(s, spec.width, spec.flags, false);
}

export function luaFormat(fmt: string, ...args: any[]): string {
  let out = "";
  let ai = 0; // arg index
  const len = fmt.length;
  let i = 0;

  while (i < len) {
    const c = fmt.charCodeAt(i);
    if (c !== 37) { // not '%'
      // Fast path: scan for next '%' or end
      let j = i + 1;
      while (j < len && fmt.charCodeAt(j) !== 37) j++;
      out += fmt.slice(i, j);
      i = j;
      continue;
    }

    // '%' found
    i++;
    if (i >= len) {
      throw new Error("invalid format (ends with '%')");
    }

    // '%%' into literal '%'
    if (fmt.charCodeAt(i) === 37) {
      out += "%";
      i++;
      continue;
    }

    const { spec, end } = parseSpec(fmt, i);
    i = end + 1;

    // Resolve `*` width and precision from args
    let width = spec.width;
    if (width === -1) {
      width = Number(args[ai++]) || 0;
      if (width < 0) {
        spec.flags |= FLAG_MINUS;
        width = -width;
      }
      spec.width = width;
    }
    if (spec.prec === -1) {
      let p = Number(args[ai++]) || 0;
      if (p < 0) {
        spec.hasPrec = false;
        p = 0;
      }
      spec.prec = p;
    }

    const code = spec.spec;
    switch (code) {
      case 97:
      case 65: // 'a', 'A'
        out += formatHexFloat(Number(args[ai++]), spec);
        break;
      case 100:
      case 105:
      case 117: // 'd', 'i', 'u'
      case 111:
      case 120:
      case 88: // 'o', 'x', 'X'
        out += formatInt(Number(args[ai++]), spec);
        break;
      case 102:
      case 101:
      case 69: // 'f', 'e', 'E'
      case 103:
      case 71:
      case 70: // 'g', 'G', 'F'
        out += formatFloat(Number(args[ai++]), spec);
        break;
      case 99: // 'c'
        out += pad(
          formatChar(Number(args[ai++])),
          spec.width,
          spec.flags,
          false,
        );
        break;
      case 112: { // 'p'
        out += formatPointer(args[ai++], spec);
        break;
      }
      case 113: { // 'q'
        out += formatQ(args[ai++]);
        break;
      }
      case 115: { // 's'
        let s = String(args[ai++]);
        if (spec.hasPrec && s.length > spec.prec) {
          s = s.slice(0, spec.prec);
        }
        out += pad(s, spec.width, spec.flags, false);
        break;
      }
      default:
        throw new Error(
          `invalid format specifier '${String.fromCharCode(code)}'`,
        );
    }
  }

  return out;
}


================================================
FILE: client/space_lua/stdlib/format_test.lua
================================================
local function assertEquals(actual, expected, message)
  if actual ~= expected then
    error('Assertion failed: ' .. message
      .. '\n  actual:   ' .. tostring(actual)
      .. '\n  expected: ' .. tostring(expected), 2)
  end
end

local function assertThrows(msg_substr, fn)
  local ok, err = pcall(fn)
  if ok then
    error('Assertion failed: expected error containing "'
      .. msg_substr .. '"', 2)
  end
  if type(err) ~= 'string' then
    err = tostring(err)
  end
  if not string.find(err, msg_substr, 1, true) then
    error('Assertion failed: expected error message to contain "'
      .. msg_substr .. '", got: "' .. err .. '"', 2)
  end
end

-- %%: literal percent
assertEquals(string.format("%%"), "%", "%%: literal")
assertEquals(string.format("100%%"), "100%", "%%: 100%%")
assertEquals(string.format("%%%%"), "%%", "%%: %%%%")
assertEquals(string.format("a%%b%%c"), "a%b%c", "%%: a%%b%%c")

-- %d: basic integers
assertEquals(string.format("%d", 0), "0", "%d: 0")
assertEquals(string.format("%d", 1), "1", "%d: 1")
assertEquals(string.format("%d", -1), "-1", "%d: -1")
assertEquals(string.format("%d", 42), "42", "%d: 42")
assertEquals(string.format("%d", -42), "-42", "%d: -42")
assertEquals(string.format("%d", 2147483647), "2147483647", "%d: INT_MAX")
assertEquals(string.format("%d", -2147483648), "-2147483648", "%d: INT_MIN")

-- %i: alias for %d
assertEquals(string.format("%i", 42), "42", "%i: 42")
assertEquals(string.format("%i", -7), "-7", "%i: -7")

-- %d: width
assertEquals(string.format("%5d", 42), "   42", "%d: width 5")
assertEquals(string.format("%5d", -42), "  -42", "%d: width 5 neg")
assertEquals(string.format("%1d", 42), "42", "%d: width 1 no pad")

-- %d: left-justify
assertEquals(string.format("%-5d", 42), "42   ", "%d: left 42")
assertEquals(string.format("%-5d", -42), "-42  ", "%d: left -42")

-- %d: zero-pad
assertEquals(string.format("%05d", 42), "00042", "%d: zero-pad 42")
assertEquals(string.format("%05d", -42), "-0042", "%d: zero-pad -42")
assertEquals(string.format("%05d", 0), "00000", "%d: zero-pad 0")

-- %d: plus flag
assertEquals(string.format("%+d", 42), "+42", "%d: +42")
assertEquals(string.format("%+d", -42), "-42", "%d: + -42")
assertEquals(string.format("%+d", 0), "+0", "%d: +0")

-- %d: space flag
assertEquals(string.format("% d", 42), " 42", "%d: space 42")
assertEquals(string.format("% d", -42), "-42", "%d: space -42")
assertEquals(string.format("% d", 0), " 0", "%d: space 0")

-- %d: precision
assertEquals(string.format("%.5d", 42), "00042", "%d: prec5 42")
assertEquals(string.format("%.5d", -42), "-00042", "%d: prec5 -42")
assertEquals(string.format("%.0d", 0), "", "%d: prec0 0")
assertEquals(string.format("%.0d", 1), "1", "%d: prec0 1")
assertEquals(string.format("%.1d", 0), "0", "%d: prec1 0")

-- %d: width and precision
assertEquals(string.format("%8.5d", 42), "   00042", "%d: w8 p5 42")
assertEquals(string.format("%-8.5d", 42), "00042   ", "%d: left w8 p5 42")

-- %u: unsigned integers
assertEquals(string.format("%u", 0), "0", "%u: 0")
assertEquals(string.format("%u", 42), "42", "%u: 42")
assertEquals(string.format("%u", -1), "18446744073709551615", "%u: -1")
assertEquals(string.format("%u", -42), "18446744073709551574", "%u: -42")

-- %o: octal
assertEquals(string.format("%o", 0), "0", "%o: 0")
assertEquals(string.format("%o", 8), "10", "%o: 8")
assertEquals(string.format("%o", 255), "377", "%o: 255")
assertEquals(string.format("%o", -1), "1777777777777777777777", "%o: -1")

-- %o: alt flag
assertEquals(string.format("%#o", 0), "0", "%#o: 0")
assertEquals(string.format("%#o", 8), "010", "%#o: 8")
assertEquals(string.format("%#o", 255), "0377", "%#o: 255")

-- %x: hex lowercase
assertEquals(string.format("%x", 0), "0", "%x: 0")
assertEquals(string.format("%x", 255), "ff", "%x: 255")
assertEquals(string.format("%x", 4095), "fff", "%x: 4095")
assertEquals(string.format("%x", -1), "ffffffffffffffff", "%x: -1")

-- %X: hex uppercase
assertEquals(string.format("%X", 255), "FF", "%X: 255")
assertEquals(string.format("%X", -1), "FFFFFFFFFFFFFFFF", "%X: -1")

-- %x: alt flag
assertEquals(string.format("%#x", 0), "0", "%#x: 0")
assertEquals(string.format("%#x", 255), "0xff", "%#x: 255")
assertEquals(string.format("%#X", 255), "0XFF", "%#X: 255")

-- %x: width and zero-pad
assertEquals(string.format("%08x", 255), "000000ff", "%x: zero-pad 255")
assertEquals(string.format("%#08x", 255), "0x0000ff", "%#x: zero-pad 255")

-- %c: character
assertEquals(string.format("%c", 65), "A", "%c: 65")
assertEquals(string.format("%c", 97), "a", "%c: 97")
assertEquals(string.format("%c", 48), "0", "%c: 48")

-- %c: width
assertEquals(string.format("%3c", 65), "  A", "%c: width 3")
assertEquals(string.format("%-3c", 65), "A  ", "%c: left width 3")

-- %s: strings
assertEquals(string.format("%s", "hello"), "hello", "%s: hello")
assertEquals(string.format("%s", ""), "", "%s: empty")
assertEquals(string.format("%s %s", "hello", "world"), "hello world", "%s: two")

-- %s: width
assertEquals(string.format("%10s", "hello"), "     hello", "%s: width 10")
assertEquals(string.format("%-10s", "hello"), "hello     ", "%s: left width 10")

-- %s: precision truncates
assertEquals(string.format("%.3s", "hello"), "hel", "%s: prec3")
assertEquals(string.format("%.10s", "hello"), "hello", "%s: prec10")
assertEquals(string.format("%.0s", "hello"), "", "%s: prec0")

-- %s: width and precision
assertEquals(string.format("%10.3s", "hello"), "       hel", "%s: w10 p3")
assertEquals(string.format("%-10.3s", "hello"), "hel       ", "%s: left w10 p3")

-- %f: basic floats
assertEquals(string.format("%f", 0), "0.000000", "%f: 0")
assertEquals(string.format("%f", 1), "1.000000", "%f: 1")
assertEquals(string.format("%f", -1), "-1.000000", "%f: -1")
assertEquals(string.format("%f", 3.14), "3.140000", "%f: 3.14")
assertEquals(string.format("%f", 0.1), "0.100000", "%f: 0.1")

-- %f: precision
assertEquals(string.format("%.2f", 3.14159), "3.14", "%f: prec2")
assertEquals(string.format("%.0f", 3.14159), "3", "%f: prec0")
assertEquals(string.format("%.10f", 1.0), "1.0000000000", "%f: prec10")

-- %f: width and precision
assertEquals(string.format("%10.2f", 3.14), "      3.14", "%f: w10 p2")
assertEquals(string.format("%-10.2f", 3.14), "3.14      ", "%f: left w10 p2")
assertEquals(string.format("%010.2f", 3.14), "0000003.14", "%f: zero-pad w10 p2")

-- %f: sign flags
assertEquals(string.format("%+f", 3.14), "+3.140000", "%f: +3.14")
assertEquals(string.format("%+f", -3.14), "-3.140000", "%f: + -3.14")
assertEquals(string.format("% f", 3.14), " 3.140000", "%f: space 3.14")
assertEquals(string.format("% f", -3.14), "-3.140000", "%f: space -3.14")

-- %f: non-finite
assertEquals(string.format("%f", 1/0), "inf", "%f: inf")
assertEquals(string.format("%f", -1/0), "-inf", "%f: -inf")
assertEquals(string.format("%f", 0/0), "-nan", "%f: nan")
assertEquals(string.format("%+f", 1/0), "+inf", "%f: +inf")

-- %f: width with non-finite
assertEquals(string.format("%10f", 1/0), "       inf", "%f: w10 inf")
assertEquals(string.format("%-10f", 1/0), "inf       ", "%f: left w10 inf")

-- %e: basic
assertEquals(string.format("%e", 0), "0.000000e+00", "%e: 0")
assertEquals(string.format("%e", 1), "1.000000e+00", "%e: 1")
assertEquals(string.format("%e", 100), "1.000000e+02", "%e: 100")
assertEquals(string.format("%e", 0.001), "1.000000e-03", "%e: 0.001")
assertEquals(string.format("%e", -42), "-4.200000e+01", "%e: -42")

-- %E: uppercase
assertEquals(string.format("%E", 100), "1.000000E+02", "%E: 100")

-- %e: precision
assertEquals(string.format("%.2e", 100), "1.00e+02", "%e: prec2")
assertEquals(string.format("%.0e", 100), "1e+02", "%e: prec0")
assertEquals(string.format("%.14e", 1), "1.00000000000000e+00", "%e: prec14")

-- %e: non-finite
assertEquals(string.format("%e", 1/0), "inf", "%e: inf")
assertEquals(string.format("%e", -1/0), "-inf", "%e: -inf")
assertEquals(string.format("%e", 0/0), "-nan", "%e: nan")

-- %e: width and flags
assertEquals(string.format("%15e", 100), "   1.000000e+02", "%e: w15")
assertEquals(string.format("%-15e", 100), "1.000000e+02   ", "%e: left w15")
assertEquals(string.format("%+e", 100), "+1.000000e+02", "%e: +100")

-- %g: basic
assertEquals(string.format("%g", 0), "0", "%g: 0")
assertEquals(string.format("%g", 1), "1", "%g: 1")
assertEquals(string.format("%g", 100), "100", "%g: 100")
assertEquals(string.format("%g", 100000), "100000", "%g: 100000")
assertEquals(string.format("%g", 1000000), "1e+06", "%g: 1000000")
assertEquals(string.format("%g", 0.0001), "0.0001", "%g: 0.0001")
assertEquals(string.format("%g", 0.00001), "1e-05", "%g: 0.00001")
assertEquals(string.format("%g", -42), "-42", "%g: -42")

-- %G: uppercase
assertEquals(string.format("%G", 1e6), "1E+06", "%G: 1e6")
assertEquals(string.format("%G", 1e-5), "1E-05", "%G: 1e-5")

-- %g: precision
assertEquals(string.format("%.1g", 3.14), "3", "%g: prec1")
assertEquals(string.format("%.2g", 3.14), "3.1", "%g: prec2")
assertEquals(string.format("%.4g", 3.14159), "3.142", "%g: prec4")
assertEquals(string.format("%.0g", 3.14), "3", "%g: prec0")
assertEquals(string.format("%.10g", 1), "1", "%g: prec10 int")

-- %g: strips trailing zeros
assertEquals(string.format("%.6g", 1.0), "1", "%g: strip 1.0")
assertEquals(string.format("%.6g", 1.5), "1.5", "%g: strip 1.5")
assertEquals(string.format("%.6g", 1.50), "1.5", "%g: strip 1.50")
assertEquals(string.format("%.14g", 1.0), "1", "%g: strip prec14 1.0")
assertEquals(string.format("%.14g", 1.5), "1.5", "%g: strip prec14 1.5")

-- %g: alt flag keeps trailing zeros and dot
assertEquals(string.format("%#g", 1.0), "1.00000", "%#g: 1.0")
assertEquals(string.format("%#.2g", 1.0), "1.0", "%#g: prec2 1.0")
assertEquals(string.format("%#.4g", 100), "100.0", "%#g: prec4 100")

-- %g: non-finite
assertEquals(string.format("%g", 1/0), "inf", "%g: inf")
assertEquals(string.format("%g", -1/0), "-inf", "%g: -inf")
assertEquals(string.format("%g", 0/0), "-nan", "%g: nan")
assertEquals(string.format("%+g", 1/0), "+inf", "%g: +inf")

-- %g: width and flags
assertEquals(string.format("%10g", 42), "        42", "%g: w10 42")
assertEquals(string.format("%-10g", 42), "42        ", "%g: left w10 42")
assertEquals(string.format("%010g", 42), "0000000042", "%g: zero-pad w10 42")
assertEquals(string.format("%+g", 42), "+42", "%g: +42")
assertEquals(string.format("% g", 42), " 42", "%g: space 42")

-- %g: Lua 5.4 %.14g reference values
assertEquals(string.format("%.14g", 0.0), "0", "%.14g: 0.0")
assertEquals(string.format("%.14g", 1.0), "1", "%.14g: 1.0")
assertEquals(string.format("%.14g", 1/3), "0.33333333333333", "%.14g: 1/3")
assertEquals(string.format("%.14g", math.pi), "3.1415926535898", "%.14g: pi")
assertEquals(string.format("%.14g", 1e-10), "1e-10", "%.14g: 1e-10")
assertEquals(string.format("%.14g", 1e18), "1e+18", "%.14g: 1e18")
assertEquals(string.format("%.14g", 2^63), "9.2233720368548e+18", "%.14g: 2^63")
assertEquals(string.format("%.14g", 2^53), "9.007199254741e+15", "%.14g: 2^53")
assertEquals(string.format("%.14g", 1.7976931348623e+308), "1.7976931348623e+308", "%.14g: max")
assertEquals(string.format("%.14g", 5e-324), "4.9406564584125e-324", "%.14g: min subnormal")
assertEquals(string.format("%.14g", 0/0), "-nan", "%.14g: nan")
assertEquals(string.format("%.14g", 1/0), "inf", "%.14g: inf")
assertEquals(string.format("%.14g", -1/0), "-inf", "%.14g: -inf")

-- mixed: multiple specifiers
assertEquals(string.format("%d + %d = %d", 1, 2, 3), "1 + 2 = 3", "mixed: d+d=d")
assertEquals(string.format("%s is %d", "age", 42), "age is 42", "mixed: s is d")
assertEquals(string.format("[%05d] %s (%.2f)", 7, "test", 3.14), "[00007] test (3.14)", "mixed: complex")

-- mixed: no specifiers
assertEquals(string.format("hello world"), "hello world", "mixed: no spec")
assertEquals(string.format(""), "", "mixed: empty")

-- error: invalid specifier (Lua 5.4 says "invalid conversion", 5.5 may differ)
do
  local ok = pcall(string.format, "%z", 1)
  assertEquals(ok, false, "%z: should error")
end

-- error: trailing percent
do
  local ok = pcall(string.format, "abc%")
  assertEquals(ok, false, "trailing %%: should error")
end

-- %f: negative zero
assertEquals(string.format("%f", -0.0), "-0.000000", "%f: -0.0")
assertEquals(string.format("%e", -0.0), "-0.000000e+00", "%e: -0.0")
assertEquals(string.format("%g", -0.0), "-0", "%g: -0.0")

-- %f: large precision
do
  local s = string.format("%.20f", 1.0)
  assertEquals(string.find(s, "%.") ~= nil, true, "%f: large prec has dot")
  assertEquals(#s, 22, "%f: large prec length")
end

-- zero width is no-op
assertEquals(string.format("%0d", 42), "42", "%0d: no-op")

-- %a: basic hex floats
assertEquals(string.format("%a", 0), "0x0p+0", "%a: 0")
assertEquals(string.format("%a", 1), "0x1p+0", "%a: 1")
assertEquals(string.format("%a", -1), "-0x1p+0", "%a: -1")
assertEquals(string.format("%a", 2), "0x1p+1", "%a: 2")
assertEquals(string.format("%a", 0.5), "0x1p-1", "%a: 0.5")
assertEquals(string.format("%a", 1.5), "0x1.8p+0", "%a: 1.5")
assertEquals(string.format("%a", -0.0), "-0x0p+0", "%a: -0.0")

-- %A: uppercase
assertEquals(string.format("%A", 1.5), "0X1.8P+0", "%A: 1.5")
assertEquals(string.format("%A", 255), "0X1.FEP+7", "%A: 255")

-- %a: precision
assertEquals(string.format("%.0a", 1.5), "0x2p+0", "%a: prec0 1.5")
assertEquals(string.format("%.1a", 1.5), "0x1.8p+0", "%a: prec1 1.5")
assertEquals(string.format("%.4a", 1.5), "0x1.8000p+0", "%a: prec4 1.5")
assertEquals(string.format("%.4a", 1), "0x1.0000p+0", "%a: prec4 1")

-- %a: width and flags
assertEquals(string.format("%20a", 1.5), "            0x1.8p+0", "%a: w20 1.5")
assertEquals(string.format("%-20a", 1.5), "0x1.8p+0            ", "%a: left w20 1.5")
assertEquals(string.format("%+a", 1.5), "+0x1.8p+0", "%a: +1.5")
assertEquals(string.format("% a", 1.5), " 0x1.8p+0", "%a: space 1.5")

-- %a: non-finite
assertEquals(string.format("%a", 1/0), "inf", "%a: inf")
assertEquals(string.format("%a", -1/0), "-inf", "%a: -inf")
assertEquals(string.format("%a", 0/0), "-nan", "%a: nan")
assertEquals(string.format("%A", 1/0), "INF", "%A: inf")

-- %a: Math.PI
assertEquals(string.format("%a", math.pi), "0x1.921fb54442d18p+1", "%a: pi")

-- %a: 0.1
assertEquals(string.format("%a", 0.1), "0x1.999999999999ap-4", "%a: 0.1")

-- %q: nil and booleans
assertEquals(string.format("%q", nil), "nil", "%q: nil")
assertEquals(string.format("%q", true), "true", "%q: true")
assertEquals(string.format("%q", false), "false", "%q: false")

-- %q: integers
assertEquals(string.format("%q", 0), "0", "%q: 0")
assertEquals(string.format("%q", 1), "1", "%q: 1")
assertEquals(string.format("%q", -1), "-1", "%q: -1")
assertEquals(string.format("%q", 42), "42", "%q: 42")
assertEquals(string.format("%q", -42), "-42", "%q: -42")

-- %q: floats as hex
assertEquals(string.format("%q", 1.5), "0x1.8p+0", "%q: 1.5")
assertEquals(string.format("%q", 0.1), "0x1.999999999999ap-4", "%q: 0.1")
assertEquals(string.format("%q", math.pi), "0x1.921fb54442d18p+1", "%q: pi")
assertEquals(string.format("%q", -0.5), "-0x1p-1", "%q: -0.5")
assertEquals(string.format("%q", -0.0), "-0x0p+0", "%q: -0.0")

-- %q: non-finite numbers
assertEquals(string.format("%q", 0/0), "(0/0)", "%q: nan")
assertEquals(string.format("%q", 1/0), "1e9999", "%q: inf")
assertEquals(string.format("%q", -1/0), "-1e9999", "%q: -inf")

-- %q: simple strings
assertEquals(string.format("%q", "hello"), '"hello"', "%q: hello")
assertEquals(string.format("%q", ""), '""', "%q: empty")

-- %q: strings with special characters
assertEquals(string.format("%q", 'say "hi"'), '"say \\"hi\\""', '%q: quotes')
assertEquals(string.format("%q", "back\\slash"), '"back\\\\slash"', "%q: backslash")
assertEquals(string.format("%q", "line\nbreak"), '"line\\\nbreak"', "%q: newline")
assertEquals(string.format("%q", "cr\rreturn"), '"cr\\13return"', "%q: cr")
assertEquals(string.format("%q", "tab\there"), '"tab\\9here"', "%q: tab")

-- %q: Lua reference example
do
  local result = string.format("%q", 'a string with "quotes" and \n new line')
  assertEquals(result, '"a string with \\"quotes\\" and \\\n new line"', "%q: reference")
end

-- %q: mixed with other specifiers
assertEquals(
  string.format("name=%q age=%d", "Alice", 30),
  'name="Alice" age=30',
  "%q: mixed"
)

-- %p: pointer-like identity for tables
do
  local t1 = {}
  local t2 = {}
  local s1 = string.format("%p", t1)
  local s2 = string.format("%p", t2)
  -- starts with 0x
  assertEquals(s1:sub(1, 2), "0x", "%p: table prefix")
  -- same object gives same result
  assertEquals(string.format("%p", t1), s1, "%p: stable identity")
  -- different objects give different results
  assertEquals(s1 ~= s2, true, "%p: distinct tables differ")
end

-- %p: functions get an identity too
do
  local f1 = function() end
  local f2 = function() end
  local s1 = string.format("%p", f1)
  local s2 = string.format("%p", f2)
  assertEquals(s1:sub(1, 2), "0x", "%p: function prefix")
  assertEquals(s1 ~= s2, true, "%p: distinct functions differ")
end

-- %p: nil and primitives yield (null)
assertEquals(string.format("%p", nil), "(null)", "%p: nil")
assertEquals(string.format("%p", true), "(null)", "%p: boolean")
assertEquals(string.format("%p", 42), "(null)", "%p: number")

-- %p: strings get an identity (they are GC objects in Lua)
do
  local s = string.format("%p", "hello")
  assertEquals(s:sub(1, 2), "0x", "%p: string prefix")
end

-- %p: width
do
  local t = {}
  local s = string.format("%20p", t)
  assertEquals(#s >= 20, true, "%p: width pads")
end

-- %p: left-justify
do
  local t = {}
  local s = string.format("%-20p", t)
  assertEquals(#s >= 20, true, "%p: left width pads")
  -- trailing spaces
  assertEquals(s:sub(#s, #s), " ", "%p: left-justify trailing space")
end

-- %p: pointer-like identity for tables
do
  local t1 = {}
  local t2 = {}
  local s1 = string.format("%p", t1)
  local s2 = string.format("%p", t2)
  assertEquals(s1:sub(1, 2), "0x", "%p: table prefix")
  assertEquals(string.format("%p", t1), s1, "%p: table stable identity")
  assertEquals(s1 ~= s2, true, "%p: distinct tables differ")
end

-- %p: functions get an identity too
do
  local f1 = function() end
  local f2 = function() end
  local s1 = string.format("%p", f1)
  local s2 = string.format("%p", f2)
  assertEquals(s1:sub(1, 2), "0x", "%p: function prefix")
  assertEquals(string.format("%p", f1), s1, "%p: function stable identity")
  assertEquals(s1 ~= s2, true, "%p: distinct functions differ")
end

-- %p: strings get an identity (interned: same content = same id)
do
  local s1 = string.format("%p", "hello")
  local s2 = string.format("%p", "hello")
  local s3 = string.format("%p", "world")
  assertEquals(s1:sub(1, 2), "0x", "%p: string prefix")
  assertEquals(s1, s2, "%p: same string content = same id")
  assertEquals(s1 ~= s3, true, "%p: different string content differs")
end

-- %p: nil and primitives yield (null)
assertEquals(string.format("%p", nil), "(null)", "%p: nil")
assertEquals(string.format("%p", true), "(null)", "%p: boolean")
assertEquals(string.format("%p", 42), "(null)", "%p: number")

-- %p: repeated calls on same table are stable
do
  local t = {}
  local ids = {}
  for i = 1, 5 do
    ids[i] = string.format("%p", t)
  end
  for i = 2, 5 do
    assertEquals(ids[i], ids[1], "%p: repeated call #" .. i .. " stable")
  end
end

-- %p: table stored in another table keeps identity
do
  local inner = {}
  local outer = { ref = inner }
  local s1 = string.format("%p", inner)
  local s2 = string.format("%p", outer.ref)
  assertEquals(s1, s2, "%p: table via reference stable")
end

-- %p: width
do
  local t = {}
  local s = string.format("%20p", t)
  assertEquals(#s >= 20, true, "%p: width pads")
end

-- %p: left-justify
do
  local t = {}
  local s = string.format("%-20p", t)
  assertEquals(#s >= 20, true, "%p: left width pads")
  assertEquals(s:sub(#s, #s), " ", "%p: left-justify trailing space")
end


================================================
FILE: client/space_lua/stdlib/global_test.lua
================================================
local function assertEqual(a, b)
    if a ~= b then
        error("Assertion failed: " .. a .. " is not equal to " .. b)
    end
end

-- Logic values, shouldn't be affected
assertEqual(some(nil), nil)
assertEqual(some(true), true)
assertEqual(some(false), false)

-- Numbers
assertEqual(some(1), 1)
assertEqual(some(0), 0) -- 0 is not falsy unlike C
assertEqual(some(-1), -1)
assertEqual(some(1/0), nil) -- inf
assertEqual(some(0/0), nil) -- nan

-- Strings
assertEqual(some("foo bar"), "foo bar")
assertEqual(some(""), nil)
assertEqual(some(" \n"), nil)

-- Tables
assertEqual(some({}), nil)
assertEqual(some({"baz"})[1], "baz") -- compare an element to ensure passthrough
assertEqual(some({foo="bar"})["foo"], "bar")


================================================
FILE: client/space_lua/stdlib/js.ts
================================================
import {
  jsToLuaValue,
  LuaBuiltinFunction,
  LuaTable,
  luaValueToJS,
} from "../runtime.ts";

export const jsApi = new LuaTable({
  /**
   * Creates a new instance of a JavaScript class.
   * @param constructorFn - The constructor function.
   * @param args - The arguments to pass to the constructor.
   * @returns The new instance.
   */
  new: new LuaBuiltinFunction(
    (sf, constructorFn: any, ...args) => {
      return new constructorFn(
        ...args.map((v) => luaValueToJS(v, sf)),
      );
    },
  ),
  /**
   * Imports a JavaScript module.
   * @param url - The URL of the module to import.
   * @returns The imported module.
   */
  import: new LuaBuiltinFunction(async (_sf, url) => {
    let m = await import(url);
    // Unwrap default if it exists
    if (Object.keys(m).length === 1 && m.default) {
      m = m.default;
    }
    return m;
  }),
  eachIterable: new LuaBuiltinFunction((_sf, val) => {
    const iterator = val[Symbol.asyncIterator]();
    return async () => {
      const result = await iterator.next();
      if (result.done) {
        return;
      }
      return result.value;
    };
  }),
  /**
   * Converts a JavaScript value to a Lua value.
   * @param val - The JavaScript value to convert.
   * @returns The Lua value.
   */
  tolua: new LuaBuiltinFunction((_sf, val) => jsToLuaValue(val)),
  /**
   * Converts a Lua value to a JavaScript value.
   * @param val - The Lua value to convert.
   * @returns The JavaScript value.
   */
  tojs: new LuaBuiltinFunction((sf, val) => luaValueToJS(val, sf)),
  /**
   * Logs a message to the console.
   * @param args - The arguments to log.
   */
  log: new LuaBuiltinFunction((_sf, ...args) => {
    console.log(...args);
  }),
  /**
   * Converts a Lua value to a JSON string.
   * @param val - The Lua value to convert.
   * @returns The JSON string.
   */
  stringify: new LuaBuiltinFunction((_sf, val) => JSON.stringify(val)),

  // Expose the global window object
  window: globalThis,
});


================================================
FILE: client/space_lua/stdlib/js_test.lua
================================================
local function assertEquals(a, b)
    if a ~= b then
        error("Assertion failed: " .. a .. " is not equal to " .. b)
    end
end

-- Disabling these for now, too dependent on flaky esm.sh

-- local lodash = js.import("https://esm.sh/lodash@4.17.21")

-- assertEquals(js.stringify(lodash.chunk({1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 3)), '[[1,2,3],[4,5,6],[7,8,9],[10]]')

-- local moment = js.import("https://esm.sh/moment@2.30.1")

-- local day = moment("1995-12-25");
-- assertEquals(day.format("DD-MM-YYYY"), "25-12-1995")


================================================
FILE: client/space_lua/stdlib/load.ts
================================================
import {
  LuaBuiltinFunction,
  type LuaEnv,
  LuaMultiRes,
  type LuaStackFrame,
  type LuaValue,
} from "../runtime.ts";
import { parse } from "../parse.ts";
import { evalStatement } from "../eval.ts";

// Returns a function (callable chunk) or (nil, "error message") pair.
export function luaLoad(code: LuaValue, sf: LuaStackFrame): LuaValue {
  const s = typeof code === "string" ? code : String(code);

  try {
    const block = parse(s, sf.astCtx || {});
    const globalEnvMaybe = sf.threadLocal.get("_GLOBAL");

    // Be vocal when no _GLOBAL is set
    if (!globalEnvMaybe) {
      console.warn(
        "load() called without _GLOBAL in thread-local environment",
      );
      return new LuaMultiRes([null, "Global environment not set"]);
    }

    const globalEnv: LuaEnv = globalEnvMaybe as LuaEnv;

    const runner = new LuaBuiltinFunction(async (innerSf: LuaStackFrame) => {
      const res = await evalStatement(block, globalEnv, innerSf, true);
      if (res === undefined) {
        return null;
      } else {
        if (res && typeof res === "object" && (res as any).ctrl === "return") {
          return new LuaMultiRes((res as any).values);
        }
        if (res && typeof res === "object" && (res as any).ctrl === "break") {
          throw new Error("break outside loop");
        }
        if (res && typeof res === "object" && (res as any).ctrl === "goto") {
          throw new Error("unexpected goto signal");
        }
        return null;
      }
    });

    return runner;
  } catch (e: any) {
    const msg = e && typeof e.message === "string" ? e.message : String(e);
    return new LuaMultiRes([null, msg]);
  }
}


================================================
FILE: client/space_lua/stdlib/load_test.lua
================================================
local function assertEquals(actual, expected, msg)
  if actual ~= expected then
    error('assertEquals failed: ' .. msg)
  end
end

local function assertTrue(cond, msg)
  if not cond then
    error('assertTrue failed: ' .. msg)
  end
end

local function assertFalse(cond, msg)
  if cond then
    error('assertFalse failed: ' .. msg)
  end
end

-- 1. Successful compile -> returns function; call yields value

do
  local f, err = load("return 40 + 2")

  assertEquals(type(f), "function", "load must return function on success")
  assertEquals(err, nil, "load must not return error on success")
  assertEquals(f(), 42, "loaded chunk executes and returns")
end

-- 2. No explicit return -> nil on call

do
  local f, err = load("local x = 1; x = x + 1")

  assertEquals(type(f), "function", "load returns function even without returns")
  assertEquals(err, nil, "no error expected")
  assertEquals(f(), nil, "chunk without return yields nil")
end

-- 3. Syntax error -> (nil, "error message"), no throw on load

do
  local f, err = load("return 1 +")

  assertEquals(f, nil, "syntax error: first result is nil")
  assertEquals(type(err), "string", "syntax error: second result is error string")
end

-- 4. Runtime error when calling the returned function

do
  local f = load("return 1 + {}") -- arithmetic on non-number at runtime
  local ok, res = pcall(f)

  assertFalse(ok, "pcall must be false on runtime error")
  assertEquals(type(res), "string", "pcall error message is string")
end

-- 5. Global reads are visible in loaded chunk

do
  g = 41
  local f = load("return g + 1")

  assertEquals(f(), 42, "loaded chunk sees global env (read)")
end

-- 6. Global writes from loaded chunk update globals
do
  h = 0
  local f = load("h = 9")

  assertEquals(f(), nil, "no explicit return")
  assertEquals(h, 9, "global updated by loaded chunk (write)")
end


================================================
FILE: client/space_lua/stdlib/math.ts
================================================
import {
  LuaBuiltinFunction,
  LuaMultiRes,
  LuaRuntimeError,
  LuaTable,
} from "../runtime.ts";
import { isNegativeZero, isTaggedFloat } from "../numeric.ts";

// Fast unwrap: avoids function call overhead for the common plain-number case
function untagNumber(x: any): number {
  if (typeof x === "number") return x;
  if (isTaggedFloat(x)) return x.value;
  return Number(x);
}

export const mathApi = new LuaTable({
  // math constants
  huge: 1 / 0,
  pi: Math.PI,

  // math.type(x) => "integer" | "float" | nil
  type: new LuaBuiltinFunction((_sf, x?: any) => {
    if (x === undefined) {
      throw new LuaRuntimeError(
        "bad argument #1 to 'math.type' (value expected)",
        _sf,
      );
    }
    if (isTaggedFloat(x)) {
      return "float";
    }
    if (typeof x === "number") {
      if (!Number.isFinite(x) || isNegativeZero(x)) {
        return "float";
      }
      return Number.isInteger(x) ? "integer" : "float";
    }
    if (typeof x === "bigint") {
      return "integer";
    }
    return null;
  }),
  /**
   * When called without arguments, returns a pseudo-random float with
   * uniform distribution in the range [0,1). When called with two
   * integers m and n, math.random returns a pseudo-random integer
   * with uniform distribution in the range [m, n]. The call
   * math.random(n), for a positive n, is equivalent to
   * math.random(1,n). The call math.random(0) produces an integer
   * with all bits (pseudo)random.
   */
  random: new LuaBuiltinFunction((_sf, m?: number, n?: number) => {
    if (m !== undefined) m = untagNumber(m);
    if (n !== undefined) n = untagNumber(n);

    if (m === undefined && n === undefined) {
      return Math.random();
    }

    if (!Number.isInteger(m)) {
      throw new LuaRuntimeError(
        "bad argument #1 to 'math.random' (integer expected)",
        _sf,
      );
    }

    if (n === undefined) {
      if (m! == 0) {
        const high = Math.floor(Math.random() * 0x100000000);
        const low = Math.floor(Math.random() * 0x100000000);
        let result = (BigInt(high) << 32n) | BigInt(low);
        if (result & (1n << 63n)) {
          result -= 1n << 64n;
        }
        return result;
      }
      if (m! < 1) {
        throw new LuaRuntimeError(
          "bad argument #1 to 'math.random' (interval is empty)",
          _sf,
        );
      }
      return Math.floor(Math.random() * m!) + 1;
    }

    if (!Number.isInteger(n!)) {
      throw new LuaRuntimeError(
        "bad argument #2 to 'math.random' (integer expected)",
        _sf,
      );
    }

    if (n! < m!) {
      throw new LuaRuntimeError(
        "bad argument #1 to 'math.random' (interval is empty)",
        _sf,
      );
    }
    return Math.floor(Math.random() * (n! - m! + 1)) + m!;
  }),

  // Basic functions
  abs: new LuaBuiltinFunction((_sf, x: number) => Math.abs(untagNumber(x))),
  ceil: new LuaBuiltinFunction((_sf, x: number) => Math.ceil(untagNumber(x))),
  floor: new LuaBuiltinFunction((_sf, x: number) => Math.floor(untagNumber(x))),
  max: new LuaBuiltinFunction((_sf, ...args: number[]) =>
    Math.max(...args.map(untagNumber))
  ),
  min: new LuaBuiltinFunction((_sf, ...args: number[]) =>
    Math.min(...args.map(untagNumber))
  ),

  // Rounding and remainder
  fmod: new LuaBuiltinFunction((_sf, x: number, y: number) =>
    untagNumber(x) % untagNumber(y)
  ),
  modf: new LuaBuiltinFunction((_sf, x: number) => {
    const xn = untagNumber(x);
    const int = Math.trunc(xn);
    const frac = xn - int;
    return new LuaMultiRes([int, frac]);
  }),

  // Power and logarithms
  exp: new LuaBuiltinFunction((_sf, x: number) => Math.exp(untagNumber(x))),
  log: new LuaBuiltinFunction((_sf, x: number, base?: number) => {
    if (base === undefined) {
      return Math.log(untagNumber(x));
    }
    return Math.log(untagNumber(x)) / Math.log(untagNumber(base));
  }),
  pow: new LuaBuiltinFunction((_sf, x: number, y: number) =>
    Math.pow(untagNumber(x), untagNumber(y))
  ),
  sqrt: new LuaBuiltinFunction((_sf, x: number) => Math.sqrt(untagNumber(x))),

  // Trigonometric functions
  cos: new LuaBuiltinFunction((_sf, x: number) => Math.cos(untagNumber(x))),
  sin: new LuaBuiltinFunction((_sf, x: number) => Math.sin(untagNumber(x))),
  tan: new LuaBuiltinFunction((_sf, x: number) => Math.tan(untagNumber(x))),
  acos: new LuaBuiltinFunction((_sf, x: number) => Math.acos(untagNumber(x))),
  asin: new LuaBuiltinFunction((_sf, x: number) => Math.asin(untagNumber(x))),
  atan: new LuaBuiltinFunction((_sf, y: number, x?: number) => {
    if (x === undefined) {
      return Math.atan(untagNumber(y));
    }
    return Math.atan2(untagNumber(y), untagNumber(x));
  }),

  // Hyperbolic functions
  cosh: new LuaBuiltinFunction((_sf, x: number) => Math.cosh(untagNumber(x))),
  sinh: new LuaBuiltinFunction((_sf, x: number) => Math.sinh(untagNumber(x))),
  tanh: new LuaBuiltinFunction((_sf, x: number) => Math.tanh(untagNumber(x))),

  // Additional utility
  deg: new LuaBuiltinFunction((_sf, x: number) =>
    untagNumber(x) * 180 / Math.PI
  ),
  rad: new LuaBuiltinFunction((_sf, x: number) =>
    untagNumber(x) * Math.PI / 180
  ),
  ult: new LuaBuiltinFunction((_sf, m: number, n: number) => {
    return (untagNumber(m) >>> 0) < (untagNumber(n) >>> 0);
  }),

  // Keep the cosineSimilarity utility function
  cosineSimilarity: new LuaBuiltinFunction(
    (sf, vecA: LuaTable | number[], vecB: LuaTable | number[]) => {
      if (vecA instanceof LuaTable) {
        vecA = vecA.toJSArray();
      }
      if (vecB instanceof LuaTable) {
        vecB = vecB.toJSArray();
      }

      if (vecA.length !== vecB.length) {
        throw new LuaRuntimeError("Vectors must be of the same length", sf);
      }

      let dotProduct = 0;
      let normA = 0;
      let normB = 0;

      for (let i = 0; i < vecA.length; i++) {
        dotProduct += vecA[i] * vecB[i];
        normA += vecA[i] ** 2;
        normB += vecB[i] ** 2;
      }

      return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
    },
  ),
});


================================================
FILE: client/space_lua/stdlib/math_test.lua
================================================
local function assertEquals(a, b, msg)
  if a ~= b then
    error((msg or "assertEquals failed") .. ": expected " .. tostring(b) .. " got " .. tostring(a))
  end
end

local function assertTrue(v, msg)
  if not v then
    error(msg or "assertTrue failed")
  end
end

local function assertClose(a, b, eps, msg)
  eps = eps or 1e-12
  if a ~= a and b ~= b then
    return
  end
  if a == b then
    return
  end
  local d = a - b
  if d < 0 then d = -d end
  if d > eps then
    error((msg or "assertClose failed") .. ": expected " .. tostring(b) .. " got " .. tostring(a))
  end
end

-- math.type (basic)
do
  assertEquals(math.type(0), "integer", "math.type(0)")
  assertEquals(math.type(1), "integer", "math.type(1)")
  assertEquals(math.type(0.0), "float", "math.type(0.0)")
  assertEquals(math.type(-0.0), "float", "math.type(-0.0)")
  assertEquals(math.type(1.5), "float", "math.type(1.5)")
end

-- constants
do
  assertTrue(type(math.pi) == "number", "math.pi exists")
  assertTrue(type(math.huge) == "number", "math.huge exists")
end

-- abs
do
  assertEquals(math.abs(-5), 5, "abs(-5)")
  assertEquals(math.abs(-3), 3, "abs(-3)")
  assertEquals(math.abs(3), 3, "abs(3)")
  assertClose(math.abs(-3.25), 3.25, 1e-12, "abs(-3.25)")
end

-- floor/ceil (rounding correctness)
do
  assertEquals(math.floor(3.7), 3, "floor(3.7)")
  assertEquals(math.floor(3), 3, "floor(3)")
  assertEquals(math.floor(3.0), 3, "floor(3.0)")
  assertEquals(math.floor(3.1), 3, "floor(3.1)")
  assertEquals(math.floor(3.9), 3, "floor(3.9)")
  assertEquals(math.floor(-3.1), -4, "floor(-3.1)")
  assertEquals(math.floor(-3.9), -4, "floor(-3.9)")

  assertEquals(math.ceil(3.3), 4, "ceil(3.3)")
  assertEquals(math.ceil(3), 3, "ceil(3)")
  assertEquals(math.ceil(3.0), 3, "ceil(3.0)")
  assertEquals(math.ceil(3.1), 4, "ceil(3.1)")
  assertEquals(math.ceil(3.9), 4, "ceil(3.9)")
  assertEquals(math.ceil(-3.1), -3, "ceil(-3.1)")
  assertEquals(math.ceil(-3.9), -3, "ceil(-3.9)")
end

-- min/max
do
  assertEquals(math.max(1, 2, 3, 4), 4, "max(1,2,3,4)")
  assertEquals(math.min(1, 2, 3, 4), 1, "min(1,2,3,4)")

  assertEquals(math.max(1, 2, 3), 3, "max")
  assertEquals(math.min(1, 2, 3), 1, "min")
  assertEquals(math.max(-1, -2), -1, "max negative")
  assertEquals(math.min(-1, -2), -2, "min negative")
end

-- modf
do
  local i, f = math.modf(3.5)
  assertEquals(i, 3, "modf(3.5) int")
  assertClose(f, 0.5, 1e-12, "modf(3.5) frac")

  local i2, f2 = math.modf(-3.5)
  assertEquals(i2, -3, "modf(-3.5) int")
  assertClose(f2, -0.5, 1e-12, "modf(-3.5) frac")
end

-- sqrt/exp/log/pow
do
  assertEquals(math.exp(0), 1, "exp(0)")
  assertClose(math.log(math.exp(1)), 1, 1e-12, "log(exp(1))")
  assertClose(math.log(8, 2), 3, 1e-12, "log base 2 of 8")
  assertEquals(math.pow(2, 3), 8, "pow(2,3)")
  assertEquals(math.sqrt(9), 3, "sqrt(9)")
end

-- trigonomic
do
  assertEquals(math.cos(0), 1, "cos(0)")
  assertEquals(math.sin(0), 0, "sin(0)")
  assertEquals(math.tan(0), 0, "tan(0)")
  assertEquals(math.acos(1), 0, "acos(1)")
  assertEquals(math.asin(0), 0, "asin(0)")
  assertEquals(math.atan(0), 0, "atan(0)")
  assertEquals(math.atan(0, 1), 0, "atan(0,1)")
end

-- hyperbolic
do
  assertEquals(math.cosh(0), 1, "cosh(0)")
  assertEquals(math.sinh(0), 0, "sinh(0)")
  assertEquals(math.tanh(0), 0, "tanh(0)")
end

-- remainder
do
  assertEquals(math.fmod(7, 3), 1, "fmod(7,3)")
end

-- random
do
  local a1 = math.random()
  assertTrue(a1 >= 0 and a1 < 1, "random() range")

  local aN = math.random(10)
  assertTrue(aN >= 1 and aN <= 10, "random(10) range")

  local aR = math.random(5, 10)
  assertTrue(aR >= 5 and aR <= 10, "random(5,10) range")
end

-- unsigned less-than
do
  assertEquals(math.ult(1, 2), true, "ult(1,2)")
  assertEquals(math.ult(2, 1), false, "ult(2,1)")
end

-- reported regression
do
  local x1 = 8 * 0.5
  local x2 = 8 * 0.49
  local x3 = 9 * 0.5

  assertClose(x1, 4.0, 0, "8*0.5 == 4.0")
  assertClose(x2, 3.92, 1e-12, "8*0.49 == 3.92")
  assertClose(x3, 4.5, 1e-12, "9*0.5 == 4.5")

  local f1 = math.floor(x1)
  local f2 = math.floor(x2)
  local f3 = math.floor(x3)

  assertTrue(f1 == f1, "floor(8*0.5) not NaN")
  assertTrue(f2 == f2, "floor(8*0.49) not NaN")
  assertTrue(f3 == f3, "floor(9*0.5) not NaN")

  assertEquals(f1, 4, "floor(8*0.5)")
  assertEquals(f2, 3, "floor(8*0.49)")
  assertEquals(f3, 4, "floor(9*0.5)")
end


================================================
FILE: client/space_lua/stdlib/net.ts
================================================
import type { Client } from "../../client.ts";
import type {
  ProxyFetchRequest,
  ProxyFetchResponse,
} from "../../plugos/proxy_fetch.ts";
import { fsEndpoint } from "../../spaces/constants.ts";
import { LuaNativeJSFunction, LuaTable } from "../runtime.ts";

export const netApi = new LuaTable({
  proxyFetch: new LuaNativeJSFunction(
    async (
      url: string,
      options: ProxyFetchRequest = {},
    ): Promise<ProxyFetchResponse> => {
      // JSONify any non-serializable body
      if (
        options?.body && typeof options.body !== "string" &&
        !(options.body instanceof Uint8Array)
      ) {
        options.body = JSON.stringify(options.body);
      }
      const fetchOptions = options
        ? {
          method: options.method,
          headers: {} as Record<string, string>,
          body: options.body,
        }
        : {};
      fetchOptions.headers = buildProxyHeaders(options.headers);
      const resp = await client.httpSpacePrimitives.authenticatedFetch(
        buildProxyUrl(client, url),
        fetchOptions,
      );
      if (resp.status !== 200) {
        return {
          ok: false,
          status: resp.status,
          headers: extractProxyHeaders(resp.headers),
          body: await resp.text(),
        };
      }
      // Do sensible things with the body based on the content type
      let body: any;
      const contentTypeHeader = options.responseEncoding ||
        resp.headers.get("x-proxy-header-content-type");
      const statusCode = +(resp.headers.get("x-proxy-status-code") || "200");
      if (contentTypeHeader?.startsWith("application/json")) {
        body = await resp.json();
      } else if (
        contentTypeHeader?.startsWith("application/xml") ||
        contentTypeHeader?.startsWith("text/")
      ) {
        body = await resp.text();
      } else {
        body = new Uint8Array(await resp.arrayBuffer());
      }
      return {
        ok: resp.ok,
        status: statusCode,
        headers: extractProxyHeaders(resp.headers),
        body: body,
      };
    },
  ),
  readURI: new LuaNativeJSFunction(
    (uri: string, options: { uri?: string; encoding?: string } = {}) => {
      options.uri = uri;
      return client.clientSystem.serviceRegistry.invokeBestMatch(
        "net.readURI:" + uri,
        options,
      );
    },
  ),
  writeURI: new LuaNativeJSFunction(
    (uri: string, content: string | Uint8Array) => {
      return client.clientSystem.serviceRegistry.invokeBestMatch(
        "net.writeURI:" + uri,
        { uri, content },
      );
    },
  ),
});

// Utility functions
function buildProxyUrl(client: Client, url: string) {
  url = url.replace(/^https?:\/\//, "");
  // Strip off the /.fs and replace with /.proxy
  return client.httpSpacePrimitives.url.slice(0, -fsEndpoint.length) +
    "/.proxy/" + url;
}

function buildProxyHeaders(headers?: Record<string, any>): Record<string, any> {
  const newHeaders: Record<string, any> = { "X-Proxy-Request": "true" };
  if (!headers) {
    return newHeaders;
  }
  for (const [key, value] of Object.entries(headers)) {
    newHeaders[`X-Proxy-Header-${key}`] = value;
  }
  return newHeaders;
}

function extractProxyHeaders(
  headers: Headers,
): Record<string, any> {
  const newHeaders: Record<string, any> = {};
  for (const [key, value] of headers.entries()) {
    if (key.toLowerCase().startsWith("x-proxy-header-")) {
      newHeaders[key.slice("x-proxy-header-".length)] = value;
    }
  }
  return newHeaders;
}


================================================
FILE: client/space_lua/stdlib/os.ts
================================================
import { LuaBuiltinFunction, LuaTable } from "../runtime.ts";

const ONE_DAY = 1000 * 60 * 60 * 24;
const ONE_WEEK = ONE_DAY * 7;

function weekNumber(
  d: Date,
  utc: boolean,
  weekStartDay: number,
  iso: boolean,
): number {
  const year = utc ? d.getUTCFullYear() : d.getFullYear();
  const month = utc ? d.getUTCMonth() : d.getMonth();
  const day = utc ? d.getUTCDate() : d.getDate();
  const date = new Date(Date.UTC(year, month, day));

  if (iso) {
    const target = new Date(date);
    target.setUTCDate(
      target.getUTCDate() + 3 - ((target.getUTCDay() + 6) % 7),
    );

    const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
    const weekStart = new Date(yearStart);
    weekStart.setUTCDate(
      yearStart.getUTCDate() - ((yearStart.getUTCDay() + 6) % 7),
    );

    return 1 + Math.floor((target.getTime() - weekStart.getTime()) / ONE_WEEK);
  }

  const yearStart = new Date(Date.UTC(year, 0, 1));
  const startDay = yearStart.getUTCDay();
  const offset = (7 + (startDay - weekStartDay)) % 7;
  const firstWeekStart = new Date(yearStart);

  firstWeekStart.setUTCDate(yearStart.getUTCDate() + (7 - offset) % 7);

  if (date < firstWeekStart) return 0;
  return 1 +
    Math.floor((date.getTime() - firstWeekStart.getTime()) / ONE_WEEK);
}

function dayOfYear(d: Date, utc: boolean): number {
  const year = utc ? d.getUTCFullYear() : d.getFullYear();
  const start = new Date(Date.UTC(year, 0, 0));

  const current = utc
    ? new Date(
      Date.UTC(
        year,
        d.getUTCMonth(),
        d.getUTCDate(),
        d.getUTCHours(),
        d.getUTCMinutes(),
        d.getUTCSeconds(),
      ),
    )
    : new Date(
      Date.UTC(
        year,
        d.getMonth(),
        d.getDate(),
        d.getHours(),
        d.getMinutes(),
        d.getSeconds(),
      ),
    );

  return Math.floor((current.getTime() - start.getTime()) / ONE_DAY);
}

function isoWeekYear(d: Date, utc: boolean): number {
  const year = utc ? d.getUTCFullYear() : d.getFullYear();
  const month = utc ? d.getUTCMonth() : d.getMonth();
  const day = utc ? d.getUTCDate() : d.getDate();
  const target = new Date(Date.UTC(year, month, day));

  target.setUTCDate(
    target.getUTCDate() + 3 - ((target.getUTCDay() + 6) % 7),
  );

  return target.getUTCFullYear();
}

function isDST(d: Date): boolean {
  const jan = new Date(d.getFullYear(), 0, 1);

  return d.getTimezoneOffset() < jan.getTimezoneOffset();
}

function yr(d: Date, utc: boolean): number {
  return utc ? d.getUTCFullYear() : d.getFullYear();
}

function mo(d: Date, utc: boolean): number {
  return utc ? d.getUTCMonth() : d.getMonth();
}

function da(d: Date, utc: boolean): number {
  return utc ? d.getUTCDate() : d.getDate();
}

function hr(d: Date, utc: boolean): number {
  return utc ? d.getUTCHours() : d.getHours();
}

function mi(d: Date, utc: boolean): number {
  return utc ? d.getUTCMinutes() : d.getMinutes();
}

function sc(d: Date, utc: boolean): number {
  return utc ? d.getUTCSeconds() : d.getSeconds();
}

function wd(d: Date, utc: boolean): number {
  return utc ? d.getUTCDay() : d.getDay();
}

function pad2(n: number): string {
  return n.toString().padStart(2, "0");
}

function pad3(n: number): string {
  return n.toString().padStart(3, "0");
}

function dateTable(d: Date, utc: boolean): LuaTable {
  const tbl = new LuaTable({
    year: yr(d, utc),
    month: mo(d, utc) + 1,
    day: da(d, utc),
    hour: hr(d, utc),
    min: mi(d, utc),
    sec: sc(d, utc),
    wday: wd(d, utc) + 1,
    yday: dayOfYear(d, utc),
  });

  if (!utc) {
    tbl.rawSet("isdst", isDST(d));
  }

  return tbl;
}

// Build the specifier map for a given `Date` and `utc` flag.
// Returns a record mapping single-char specifier to its output string.
function buildSpecMap(
  d: Date,
  utc: boolean,
): Record<string, () => string> {
  const h = () => hr(d, utc);
  const h12 = () => h() % 12 || 12;
  const dow = () => wd(d, utc);

  return {
    // Date
    "Y": () => yr(d, utc).toString(),
    "y": () => pad2(yr(d, utc) % 100),
    "C": () => pad2(Math.floor(yr(d, utc) / 100)),
    "m": () => pad2(mo(d, utc) + 1),
    "d": () => pad2(da(d, utc)),
    "e": () => da(d, utc).toString().padStart(2, " "),
    "j": () => pad3(dayOfYear(d, utc)),

    // Time
    "H": () => pad2(h()),
    "I": () => pad2(h12()),
    "M": () => pad2(mi(d, utc)),
    "S": () => pad2(sc(d, utc)),
    "p": () => h() >= 12 ? "PM" : "AM",

    // Weekday
    "A": () =>
      d.toLocaleString("en-US", {
        weekday: "long",
        ...(utc ? { timeZone: "UTC" } : {}),
      }),
    "a": () =>
      d.toLocaleString("en-US", {
        weekday: "short",
        ...(utc ? { timeZone: "UTC" } : {}),
      }),
    "w": () => dow().toString(),
    "u": () => (dow() === 0 ? 7 : dow()).toString(),

    // Month name
    "b": () =>
      d.toLocaleString("en-US", {
        month: "short",
        ...(utc ? { timeZone: "UTC" } : {}),
      }),
    "h": () =>
      d.toLocaleString("en-US", {
        month: "short",
        ...(utc ? { timeZone: "UTC" } : {}),
      }),
    "B": () =>
      d.toLocaleString("en-US", {
        month: "long",
        ...(utc ? { timeZone: "UTC" } : {}),
      }),

    // Week number
    "U": () => pad2(weekNumber(d, utc, 0, false)),
    "W": () => pad2(weekNumber(d, utc, 1, false)),
    "V": () => pad2(weekNumber(d, utc, 1, true)),
    "G": () => isoWeekYear(d, utc).toString(),
    "g": () => pad2(isoWeekYear(d, utc) % 100),

    // Composite specifiers
    "c": () =>
      d.toLocaleString("en-US", {
        ...(utc ? { timeZone: "UTC" } : {}),
      }),
    "x": () =>
      d.toLocaleDateString("en-US", {
        ...(utc ? { timeZone: "UTC" } : {}),
      }),
    "X": () =>
      d.toLocaleTimeString("en-US", {
        ...(utc ? { timeZone: "UTC" } : {}),
      }),
    "D": () =>
      `${pad2(mo(d, utc) + 1)}/${pad2(da(d, utc))}/${pad2(yr(d, utc) % 100)}`,
    "F": () =>
      `${yr(d, utc).toString()}-${pad2(mo(d, utc) + 1)}-${pad2(da(d, utc))}`,
    "R": () => `${pad2(h())}:${pad2(mi(d, utc))}`,
    "T": () => `${pad2(h())}:${pad2(mi(d, utc))}:${pad2(sc(d, utc))}`,
    "r": () =>
      `${pad2(h12())}:${pad2(mi(d, utc))}:${pad2(sc(d, utc))} ${
        h() >= 12 ? "PM" : "AM"
      }`,

    // Epoch
    "s": () => Math.floor(d.getTime() / 1000).toString(),

    // Whitespace
    "n": () => "\n",
    "t": () => "\t",

    // Timezone
    "Z": () => {
      if (utc) return "UTC";
      const match = d.toTimeString().match(/\((.*)\)/);
      return match ? match[1] : "";
    },
    "z": () => {
      if (utc) return "+0000";
      const offset = -d.getTimezoneOffset();
      const sign = offset >= 0 ? "+" : "-";
      const abs = Math.abs(offset);
      return `${sign}${pad2(Math.floor(abs / 60))}${pad2(abs % 60)}`;
    },

    // Literal
    "%": () => "%",
  };
}

function luaFormatTime(fmt: string, d: Date, utc: boolean): string {
  const specs = buildSpecMap(d, utc);

  let out = "";
  let i = 0;

  while (i < fmt.length) {
    if (fmt[i] !== "%") {
      out += fmt[i];
      i++;
      continue;
    }
    i++; // skip '%'
    if (i >= fmt.length) {
      throw new Error("invalid conversion specifier '%'");
    }

    const ch = fmt[i];
    const fn = specs[ch];
    if (!fn) {
      throw new Error(`invalid conversion specifier '%${ch}'`);
    }

    out += fn();
    i++;
  }

  return out;
}

export const osApi = new LuaTable({
  time: new LuaBuiltinFunction((_sf, tbl?: LuaTable) => {
    if (tbl) {
      if (!tbl.has("year")) {
        throw new Error("time(): year is required");
      }

      if (!tbl.has("month")) {
        throw new Error("time(): month is required");
      }

      if (!tbl.has("day")) {
        throw new Error("time(): day is required");
      }

      const year = tbl.get("year");
      const month = tbl.get("month");
      const day = tbl.get("day");
      const hour = tbl.get("hour") ?? 12;
      const min = tbl.get("min") ?? 0;
      const sec = tbl.get("sec") ?? 0;
      const date = new Date(year, month - 1, day, hour, min, sec);

      return Math.floor(date.getTime() / 1000);
    }

    return Math.floor(Date.now() / 1000);
  }),

  // Returns the difference, from time `t1` to time `t2` in seconds
  // In POSIX and some other systems, this value is exactly $t2-t1$.
  difftime: new LuaBuiltinFunction((_sf, t2: number, t1: number): number => {
    return t2 - t1;
  }),

  // Returns a string or a table containing date and time, formatted
  // according to the given string format.
  //
  // If format starts with '!', the date is formatted in UTC.
  //
  // If format is "*t" (or "!*t"), returns a table with fields:
  //
  // - `year`,
  // - `month` (1-12),
  // - `day` (1-31),
  // - `hour` (0-23),
  // - `min` (0-59),
  // - `sec` (0-61),
  // - `wday` (1-7, Sunday is 1),
  // - `yday` (1-366), and
  // - `isdst` (boolean).
  //
  // Otherwise, format specifiers follow ISO C `strftime`.
  //
  // If format is absent, it defaults to `%c`.
  date: new LuaBuiltinFunction(
    (_sf, format?: string, timestamp?: number) => {
      let fmt = format ?? "%c";
      let utc = false;

      if (fmt.startsWith("!")) {
        utc = true;
        fmt = fmt.slice(1);
      }

      const d = timestamp !== undefined && timestamp !== null
        ? new Date(timestamp * 1000)
        : new Date();

      if (fmt === "*t") {
        return dateTable(d, utc);
      }

      return luaFormatTime(fmt, d, utc);
    },
  ),

  // Returns an approximation of CPU time used by the program in seconds.
  clock: new LuaBuiltinFunction((_sf): number => {
    return performance.now() / 1000.0;
  }),
});


================================================
FILE: client/space_lua/stdlib/os_test.lua
================================================
local function assertEquals(a, b)
    if a ~= b then
        error("Assertion failed: " .. tostring(a) .. " ~= " .. tostring(b))
    end
end

-- os.time basics
assert(os.time() > 0)

-- os.difftime
local t = os.time()
assert(os.difftime(t + 10, t) == 10)
assert(os.difftime(t, t + 10) == -10)
assert(os.difftime(t, t) == 0)

-- os.date default format (no args) returns a string
assert(type(os.date()) == "string")

-- Empty format returns empty string
assertEquals(os.date(""), "")

-- "!" alone returns empty string
assertEquals(os.date("!"), "")

-- Literal %%
assertEquals(os.date("%%"), "%")
assertEquals(os.date("%%%%"), "%%")

-- Reference time: 2006-01-02 15:04:05 UTC (Go reference time)
-- All fields are distinct: Y=2006 m=01 d=02 H=15 M=04 S=05 wday=Mon
local ts = 1136214245

-- Basic date specifiers (UTC)
assertEquals(os.date("!%Y", ts), "2006")
assertEquals(os.date("!%m", ts), "01")
assertEquals(os.date("!%d", ts), "02")
assertEquals(os.date("!%H", ts), "15")
assertEquals(os.date("!%M", ts), "04")
assertEquals(os.date("!%S", ts), "05")

-- %y (2-digit year)
assertEquals(os.date("!%y", ts), "06")

-- %C (century)
assertEquals(os.date("!%C", ts), "20")

-- %I (12-hour clock): 15 -> 03
assertEquals(os.date("!%I", ts), "03")

-- %p (AM/PM): hour 15 -> PM
assertEquals(os.date("!%p", ts), "PM")

-- AM case: 2006-01-02 03:04:05 UTC = ts - 12*3600
local ts_am = ts - 12 * 3600
assertEquals(os.date("!%p", ts_am), "AM")
assertEquals(os.date("!%I", ts_am), "03")
assertEquals(os.date("!%H", ts_am), "03")

-- %w (weekday, 0=Sunday..6=Saturday); 2006-01-02 is Monday=1
assertEquals(os.date("!%w", ts), "1")

-- %u (weekday, 1=Monday..7=Sunday); Monday=1
assertEquals(os.date("!%u", ts), "1")

-- Sunday: 2006-01-01 is Sunday
local ts_sun = ts - 86400
assertEquals(os.date("!%w", ts_sun), "0")
assertEquals(os.date("!%u", ts_sun), "7")

-- %j (day of year, 001-366); Jan 2 = 002
assertEquals(os.date("!%j", ts), "002")

-- %e (day of month, space-padded); 2 -> " 2"
assertEquals(os.date("!%e", ts), " 2")

-- Composite: %F (ISO date)
assertEquals(os.date("!%F", ts), "2006-01-02")

-- Composite: %D (MM/DD/YY)
assertEquals(os.date("!%D", ts), "01/02/06")

-- Composite: %T (HH:MM:SS)
assertEquals(os.date("!%T", ts), "15:04:05")

-- Composite: %R (HH:MM)
assertEquals(os.date("!%R", ts), "15:04")

-- Composite: %r (12-hour with AM/PM)
assertEquals(os.date("!%r", ts), "03:04:05 PM")

-- %n and %t
assertEquals(os.date("!%n"), "\n")
assertEquals(os.date("!%t"), "\t")

-- %s (epoch seconds)
--
-- Note: POSIX extension; comment out to run with standard Lua
-- interpreters.
assertEquals(os.date("!%s", ts), "1136214245")

-- %z and %Z in UTC/GMT mode
--
-- Note: Uncomment the "GMT" variant to run with standard Lua interpreter
-- and comment out the "UTC" variant.
assertEquals(os.date("!%z", ts), "+0000")
--assertEquals(os.date("!%Z", ts), "GMT") -- on most platforms
assertEquals(os.date("!%Z", ts), "UTC") -- in Space Lua

-- %h is alias for %b
assertEquals(os.date("!%h", ts), os.date("!%b", ts))

-- Multiple specifiers combined
assertEquals(os.date("!%Y-%m-%d %H:%M:%S", ts), "2006-01-02 15:04:05")

-- Plain text mixed with specifiers
assertEquals(os.date("!year=%Y", ts), "year=2006")

-- *t table (UTC)
local dt = os.date("!*t", ts)
assertEquals(dt.year, 2006)
assertEquals(dt.month, 1)
assertEquals(dt.day, 2)
assertEquals(dt.hour, 15)
assertEquals(dt.min, 4)
assertEquals(dt.sec, 5)
assertEquals(dt.wday, 2) -- Monday; Lua: 1=Sunday, so Monday=2
assertEquals(dt.yday, 2)

-- *t table (local time) has isdst field
local dt_local = os.date("*t", ts)
assert(type(dt_local.isdst) == "boolean")

-- UTC *t should not have isdst
assert(not dt.isdst)

-- %G/%g (ISO week-based year)
-- 2006-01-02 is in ISO week 1 of 2006
assertEquals(os.date("!%G", ts), "2006")
assertEquals(os.date("!%g", ts), "06")

-- ISO week-year boundary: 2005-01-01 (Saturday) is in ISO week 53 of 2004
local ts_2005 = 1104537600  -- 2005-01-01 00:00:00 UTC
assertEquals(os.date("!%G", ts_2005), "2004")
assertEquals(os.date("!%V", ts_2005), "53")

-- Week number specifiers at reference time (2006-01-02, Monday)
-- %U (weeks starting Sunday): Jan 1 is Sunday -> week 01, Jan 2 still week 01
assertEquals(os.date("!%U", ts), "01")
-- %W (weeks starting Monday): Jan 2 is first Monday -> week 01
assertEquals(os.date("!%W", ts), "01")
-- %V (ISO week): week 01
assertEquals(os.date("!%V", ts), "01")

-- Preserved original week tests
assertEquals(
    os.date("%Y-%m-%d", os.time({ year = 2020, month = 1, day = 1 })),
    "2020-01-01"
)
assertEquals(
    os.date("%Y-%m-%d", os.time({ year = 2025, month = 2, day = 3 })),
    "2025-02-03"
)

-- Invalid specifier throws error
local ok, err = pcall(os.date, "%Q")
assert(not ok)
assert(type(err) == "string")

-- Repeated specifiers
assertEquals(os.date("!%Y%Y", ts), "20062006")

-- os.clock
-- Returns a number (seconds)
assert(type(os.clock()) == "number")

-- Must be non-negative
assert(os.clock() >= 0)

-- Monotonically non-decreasing
local c1 = os.clock()
local c2 = os.clock()
assert(c2 >= c1)


================================================
FILE: client/space_lua/stdlib/pattern.ts
================================================
// Lua Optimized Pattern Matching Engine Implementation

const CH_ESC = 37; // '%'
const CH_OPEN = 40; // '('
const CH_CLOSE = 41; // ')'
const CH_DOT = 46; // '.'
const CH_DOLLAR = 36; // '$'
const CH_LBRACKET = 91; // '['
const CH_RBRACKET = 93; // ']'
const CH_CARET = 94; // '^'
const CH_DASH = 45; // '-'
const CH_STAR = 42; // '*'
const CH_PLUS = 43; // '+'
const CH_QUESTION = 63; // '?'
const CH_0 = 48; // '0'
const CH_9 = 57; // '9'

const SPECIALS_SET = new Set<number>([
  CH_CARET,
  CH_DOLLAR,
  CH_STAR,
  CH_PLUS,
  CH_QUESTION,
  CH_DOT,
  CH_OPEN,
  CH_LBRACKET,
  CH_ESC,
  CH_DASH,
]);

const MAX_CAPTURES = 32;
const MAX_MATCH_DEPTH = 200;

const CAP_UNFINISHED = -1;
const CAP_POSITION = -2;

interface Capture {
  init: number;
  len: number;
}

export interface MatchState {
  src: string; // original source string (for substring extraction)
  s: Uint8Array; // source bytes
  slen: number;
  p: Uint8Array; // pattern bytes
  plen: number;
  level: number;
  capture: Capture[]; // pre-allocated, length `MAX_CAPTURES`
  matchdepth: number;
}

function toBytes(s: string): Uint8Array {
  const len = s.length;
  const arr = new Uint8Array(len);
  for (let i = 0; i < len; i++) {
    arr[i] = s.charCodeAt(i) & 0xFF;
  }
  return arr;
}

function matchClass(c: number, cl: number): boolean {
  const lcl = cl | 32;
  let res: boolean;
  switch (lcl) {
    case 97: // 'a'
      res = (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
      break;
    case 99: // 'c'
      res = c < 32 || c === 127;
      break;
    case 100: // 'd'
      res = c >= 48 && c <= 57;
      break;
    case 103: // 'g'
      res = c > 32 && c < 127;
      break;
    case 108: // 'l'
      res = c >= 97 && c <= 122;
      break;
    case 112: // 'p'
      res = (c >= 33 && c <= 47) || (c >= 58 && c <= 64) ||
        (c >= 91 && c <= 96) || (c >= 123 && c <= 126);
      break;
    case 115: // 's'
      res = c === 32 || (c >= 9 && c <= 13);
      break;
    case 117: // 'u'
      res = c >= 65 && c <= 90;
      break;
    case 119: // 'w'
      res = (c >= 65 && c <= 90) || (c >= 97 && c <= 122) ||
        (c >= 48 && c <= 57);
      break;
    case 120: // 'x'
      res = (c >= 48 && c <= 57) || (c >= 65 && c <= 70) ||
        (c >= 97 && c <= 102);
      break;
    case 122: // 'z'
      res = c === 0;
      break;
    default:
      return cl === c;
  }
  return (cl >= 97 && cl <= 122) ? res : !res;
}

function classEnd(p: Uint8Array, plen: number, pi: number): number {
  const ch = p[pi];
  pi++;
  if (ch === CH_ESC) {
    if (pi >= plen) {
      throw new Error("malformed pattern (ends with '%')");
    }
    return pi + 1;
  }
  if (ch === CH_LBRACKET) {
    if (pi < plen && p[pi] === CH_CARET) pi++;
    do {
      if (pi >= plen) {
        throw new Error("malformed pattern (missing ']')");
      }
      if (p[pi] === CH_ESC && pi + 1 < plen) {
        pi++;
      }
      pi++;
    } while (pi < plen && p[pi] !== CH_RBRACKET);
    if (pi >= plen) {
      throw new Error("malformed pattern (missing ']')");
    }
    return pi + 1;
  }
  return pi;
}

function matchBracketClass(
  c: number,
  p: Uint8Array,
  pi: number,
  ec: number,
): boolean {
  let sig = true;
  if (p[pi + 1] === CH_CARET) {
    sig = false;
    pi++;
  }
  pi++;
  while (pi < ec) {
    const pch = p[pi];
    if (pch === CH_ESC) {
      pi++;
      if (matchClass(c, p[pi])) return sig;
    } else if (
      pi + 2 < ec && p[pi + 1] === CH_DASH
    ) {
      pi += 2;
      if (pch <= c && c <= p[pi]) return sig;
    } else if (pch === c) {
      return sig;
    }
    pi++;
  }
  return !sig;
}

function singleMatch(
  ms: MatchState,
  si: number,
  pi: number,
  ep: number,
): boolean {
  if (si >= ms.slen) return false;
  const c = ms.s[si];
  const pch = ms.p[pi];
  if (pch === CH_DOT) return true;
  if (pch === CH_ESC) {
    return matchClass(c, ms.p[pi + 1]);
  }
  if (pch === CH_LBRACKET) {
    return matchBracketClass(c, ms.p, pi, ep - 1);
  }
  return pch === c;
}

function matchBalance(
  ms: MatchState,
  si: number,
  pi: number,
): number {
  if (pi >= ms.plen - 1) {
    throw new Error("malformed pattern (missing arguments to '%b')");
  }
  if (si >= ms.slen || ms.s[si] !== ms.p[pi]) {
    return -1;
  }
  const b = ms.p[pi];
  const e = ms.p[pi + 1];
  let cont = 1;
  si++;
  while (si < ms.slen) {
    const sc = ms.s[si];
    if (sc === e) {
      if (--cont === 0) return si + 1;
    } else if (sc === b) {
      cont++;
    }
    si++;
  }
  return -1;
}

function maxExpand(
  ms: MatchState,
  si: number,
  pi: number,
  ep: number,
): number {
  let i = 0;
  while (singleMatch(ms, si + i, pi, ep)) i++;
  while (i >= 0) {
    const res = match(ms, si + i, ep + 1);
    if (res >= 0) return res;
    i--;
  }
  return -1;
}

function minExpand(
  ms: MatchState,
  si: number,
  pi: number,
  ep: number,
): number {
  for (;;) {
    const res = match(ms, si, ep + 1);
    if (res >= 0) return res;
    if (singleMatch(ms, si, pi, ep)) {
      si++;
    } else {
      return -1;
    }
  }
}

function checkCapture(ms: MatchState, l: number): number {
  l -= CH_0 + 1;
  if (l < 0 || l >= ms.level || ms.capture[l].len === CAP_UNFINISHED) {
    throw new Error(`invalid capture index %${l + 1}`);
  }
  return l;
}

function captureToClose(ms: MatchState): number {
  for (let level = ms.level - 1; level >= 0; level--) {
    if (ms.capture[level].len === CAP_UNFINISHED) return level;
  }
  throw new Error("invalid pattern capture");
}

function startCapture(
  ms: MatchState,
  si: number,
  pi: number,
  what: number,
): number {
  const level = ms.level;
  if (level >= MAX_CAPTURES) throw new Error("too many captures");
  ms.capture[level].init = si;
  ms.capture[level].len = what;
  ms.level = level + 1;
  const res = match(ms, si, pi);
  if (res < 0) ms.level--;
  return res;
}

function endCapture(ms: MatchState, si: number, pi: number): number {
  const l = captureToClose(ms);
  const savedLen = ms.capture[l].len;
  ms.capture[l].len = si - ms.capture[l].init;
  const res = match(ms, si, pi);
  if (res < 0) ms.capture[l].len = savedLen;
  return res;
}

function matchCapture(ms: MatchState, si: number, l: number): number {
  const idx = checkCapture(ms, l);
  const cap = ms.capture[idx];
  if (cap.len === CAP_POSITION) {
    throw new Error(`invalid capture index %${idx + 1}`);
  }
  const len = cap.len;
  if (ms.slen - si < len) return -1;
  for (let k = 0; k < len; k++) {
    if (ms.s[cap.init + k] !== ms.s[si + k]) return -1;
  }
  return si + len;
}

function match(ms: MatchState, si: number, pi: number): number {
  if (ms.matchdepth-- <= 0) {
    throw new Error("pattern too complex");
  }
  while (true) {
    if (pi >= ms.plen) {
      ms.matchdepth++;
      return si;
    }
    const pch = ms.p[pi];
    if (pch === CH_OPEN) {
      if (pi + 1 < ms.plen && ms.p[pi + 1] === CH_CLOSE) {
        si = startCapture(ms, si, pi + 2, CAP_POSITION);
      } else {
        si = startCapture(ms, si, pi + 1, CAP_UNFINISHED);
      }
      ms.matchdepth++;
      return si;
    }
    if (pch === CH_CLOSE) {
      si = endCapture(ms, si, pi + 1);
      ms.matchdepth++;
      return si;
    }
    if (pch === CH_DOLLAR && pi + 1 === ms.plen) {
      ms.matchdepth++;
      return si === ms.slen ? si : -1;
    }
    if (pch === CH_ESC && pi + 1 < ms.plen) {
      const next = ms.p[pi + 1];
      if (next === 98) { // 'b'
        si = matchBalance(ms, si, pi + 2);
        if (si >= 0) {
          pi += 4;
          continue;
        }
        ms.matchdepth++;
        return -1;
      }
      if (next === 102) { // 'f'
        pi += 2;
        if (pi >= ms.plen || ms.p[pi] !== CH_LBRACKET) {
          throw new Error("missing '[' after '%f' in pattern");
        }
        const ep = classEnd(ms.p, ms.plen, pi);
        const previous = si === 0 ? 0 : ms.s[si - 1];
        const current = si < ms.slen ? ms.s[si] : 0;
        if (
          !matchBracketClass(previous, ms.p, pi, ep - 1) &&
          matchBracketClass(current, ms.p, pi, ep - 1)
        ) {
          pi = ep;
          continue;
        }
        ms.matchdepth++;
        return -1;
      }
      if (next >= CH_0 && next <= CH_9) {
        si = matchCapture(ms, si, next);
        if (si >= 0) {
          pi += 2;
          continue;
        }
        ms.matchdepth++;
        return -1;
      }
    }
    // default: class[*+?-]?
    const ep = classEnd(ms.p, ms.plen, pi);
    const matched = singleMatch(ms, si, pi, ep);
    if (!matched) {
      if (ep < ms.plen) {
        const suffix = ms.p[ep];
        if (
          suffix === CH_STAR || suffix === CH_QUESTION || suffix === CH_DASH
        ) {
          pi = ep + 1;
          continue;
        }
      }
      ms.matchdepth++;
      return -1;
    }
    if (ep < ms.plen) {
      const suffix = ms.p[ep];
      if (suffix === CH_QUESTION) {
        const res = match(ms, si + 1, ep + 1);
        if (res >= 0) {
          ms.matchdepth++;
          return res;
        }
        pi = ep + 1;
        continue;
      }
      if (suffix === CH_PLUS) {
        si++;
        const res = maxExpand(ms, si, pi, ep);
        ms.matchdepth++;
        return res;
      }
      if (suffix === CH_STAR) {
        const res = maxExpand(ms, si, pi, ep);
        ms.matchdepth++;
        return res;
      }
      if (suffix === CH_DASH) {
        const res = minExpand(ms, si, pi, ep);
        ms.matchdepth++;
        return res;
      }
    }
    si++;
    pi = ep;
    continue;
  }
}

function createMatchState(s: string, p: string): MatchState {
  const sb = toBytes(s);
  const pb = toBytes(p);
  const capture: Capture[] = new Array(MAX_CAPTURES);
  for (let i = 0; i < MAX_CAPTURES; i++) {
    capture[i] = { init: 0, len: 0 };
  }
  return {
    src: s,
    s: sb,
    slen: sb.length,
    p: pb,
    plen: pb.length,
    level: 0,
    capture,
    matchdepth: MAX_MATCH_DEPTH,
  };
}

function resetMatchState(ms: MatchState): void {
  ms.level = 0;
  ms.matchdepth = MAX_MATCH_DEPTH;
}

function noSpecials(p: string): boolean {
  for (let i = 0; i < p.length; i++) {
    if (SPECIALS_SET.has(p.charCodeAt(i))) return false;
  }
  return true;
}

// Public API

export type CaptureResult = { s: string } | { position: number };

interface RawCapture {
  kind: 0 | 1; // 0 = string slice, 1 = position
  start: number;
  len: number; // kind = 0: substring length; kind = 1: 1-based position
}

function getOneRawCapture(
  ms: MatchState,
  i: number,
  matchStart: number,
  matchEnd: number,
): RawCapture {
  if (i >= ms.level) {
    if (i !== 0) {
      throw new Error(`invalid capture index %${i + 1}`);
    }
    return { kind: 0, start: matchStart, len: matchEnd - matchStart };
  }
  const cap = ms.capture[i];
  if (cap.len === CAP_UNFINISHED) {
    throw new Error("unfinished capture");
  }
  if (cap.len === CAP_POSITION) {
    return { kind: 1, start: cap.init + 1, len: 0 };
  }
  return { kind: 0, start: cap.init, len: cap.len };
}

function rawToResult(ms: MatchState, raw: RawCapture): CaptureResult {
  if (raw.kind === 1) {
    return { position: raw.start };
  }
  return { s: ms.src.substring(raw.start, raw.start + raw.len) };
}

function getCaptures(
  ms: MatchState,
  matchStart: number,
  matchEnd: number,
): CaptureResult[] {
  const nlevels = ms.level === 0 ? 1 : ms.level;
  const result: CaptureResult[] = [];
  for (let i = 0; i < nlevels; i++) {
    result.push(rawToResult(ms, getOneRawCapture(ms, i, matchStart, matchEnd)));
  }
  return result;
}

function getRawCaptureString(ms: MatchState, raw: RawCapture): string {
  if (raw.kind === 1) {
    return raw.start.toString();
  }
  return ms.src.substring(raw.start, raw.start + raw.len);
}

export function patternFind(
  s: string,
  pattern: string,
  init: number = 1,
  plain: boolean = false,
): { start: number; end: number; captures: CaptureResult[] } | null {
  if (init < 1) init = 1;
  if (init > s.length + 1) return null;
  const si0 = init - 1;
  if (plain || noSpecials(pattern)) {
    const idx = s.indexOf(pattern, si0);
    if (idx < 0) return null;
    return { start: idx + 1, end: idx + pattern.length, captures: [] };
  }
  let p = pattern;
  let anchor = false;
  if (p.length > 0 && p.charCodeAt(0) === CH_CARET) {
    anchor = true;
    p = p.substring(1);
  }
  const ms = createMatchState(s, p);
  for (let si = si0; si <= ms.slen; si++) {
    resetMatchState(ms);
    const res = match(ms, si, 0);
    if (res >= 0) {
      const caps = ms.level === 0 ? [] : getCaptures(ms, si, res);
      return { start: si + 1, end: res, captures: caps };
    }
    if (anchor) break;
  }
  return null;
}

export function patternMatch(
  s: string,
  pattern: string,
  init: number = 1,
): CaptureResult[] | null {
  if (init < 1) init = 1;
  if (init > s.length + 1) return null;
  const si0 = init - 1;
  let p = pattern;
  let anchor = false;
  if (p.length > 0 && p.charCodeAt(0) === CH_CARET) {
    anchor = true;
    p = p.substring(1);
  }
  const ms = createMatchState(s, p);
  for (let si = si0; si <= ms.slen; si++) {
    resetMatchState(ms);
    const res = match(ms, si, 0);
    if (res >= 0) {
      return getCaptures(ms, si, res);
    }
    if (anchor) break;
  }
  return null;
}

export function patternGmatch(
  s: string,
  pattern: string,
  init: number = 1,
): () => CaptureResult[] | null {
  if (init < 1) init = 1;
  let p = pattern;
  let anchor = false;
  if (p.length > 0 && p.charCodeAt(0) === CH_CARET) {
    anchor = true;
    p = p.substring(1);
  }
  const ms = createMatchState(s, p);
  let src = init - 1;
  let lastMatch: number | null = null;
  return () => {
    while (src <= ms.slen) {
      resetMatchState(ms);
      const e = match(ms, src, 0);
      if (e >= 0 && e !== lastMatch) {
        const captures = getCaptures(ms, src, e);
        src = e;
        lastMatch = e;
        return captures;
      }
      src++;
      if (anchor) break;
    }
    return null;
  };
}

function expandReplacementString(
  repl: string,
  ms: MatchState,
  matchStart: number,
  matchEnd: number,
): string {
  const parts: string[] = [];
  let i = 0;
  while (i < repl.length) {
    const ch = repl.charCodeAt(i);
    if (ch === CH_ESC) {
      i++;
      if (i >= repl.length) {
        throw new Error("invalid use of '%' in replacement string");
      }
      const rc = repl.charCodeAt(i);
      if (rc === CH_ESC) {
        parts.push("%");
      } else if (rc === CH_0) {
        parts.push(ms.src.substring(matchStart, matchEnd));
      } else if (rc >= 49 && rc <= CH_9) {
        parts.push(getRawCaptureString(
          ms,
          getOneRawCapture(ms, rc - 49, matchStart, matchEnd),
        ));
      } else {
        throw new Error("invalid use of '%' in replacement string");
      }
    } else {
      // Collect consecutive literal characters
      let j = i + 1;
      while (j < repl.length && repl.charCodeAt(j) !== CH_ESC) j++;
      parts.push(repl.substring(i, j));
      i = j;
      continue;
    }
    i++;
  }
  return parts.join("");
}

export interface GsubCallbacks {
  replString?: string;
  replFunction?: (
    ...captures: CaptureResult[]
  ) => Promise<string | null | undefined> | string | null | undefined;
  replTable?: (key: string) => string | null | undefined;
}

export async function patternGsub(
  s: string,
  pattern: string,
  callbacks: GsubCallbacks,
  maxN?: number,
): Promise<[string, number]> {
  const max_s = maxN !== undefined ? maxN : s.length + 1;
  let p = pattern;
  let anchor = false;
  if (p.length > 0 && p.charCodeAt(0) === CH_CARET) {
    anchor = true;
    p = p.substring(1);
  }
  const ms = createMatchState(s, p);
  let src = 0;
  let lastMatch: number | null = null;
  let n = 0;
  const resultParts: string[] = [];

  while (n < max_s) {
    resetMatchState(ms);
    const e = match(ms, src, 0);
    if (e >= 0 && e !== lastMatch) {
      n++;
      let replStr: string | null | undefined;
      if (callbacks.replString !== undefined) {
        replStr = expandReplacementString(callbacks.replString, ms, src, e);
      } else if (callbacks.replFunction) {
        const caps = getCaptures(ms, src, e);
        replStr = await callbacks.replFunction(...caps);
        if (replStr === null || replStr === undefined) {
          replStr = ms.src.substring(src, e);
        }
      } else if (callbacks.replTable) {
        const raw = getOneRawCapture(ms, 0, src, e);
        const key = getRawCaptureString(ms, raw);
        replStr = callbacks.replTable(key);
        if (replStr === null || replStr === undefined) {
          replStr = ms.src.substring(src, e);
        }
      }
      resultParts.push(replStr!);
      src = e;
      lastMatch = e;
    } else if (src < ms.slen) {
      resultParts.push(s[src]);
      src++;
      lastMatch = null;
    } else {
      break;
    }
    if (anchor) break;
  }
  if (src < s.length) {
    resultParts.push(s.substring(src));
  }
  return [resultParts.join(""), n];
}


================================================
FILE: client/space_lua/stdlib/pattern_test.lua
================================================
local function assertEqual(a, b)
    if a ~= b then
        error("Assertion failed: " .. tostring(a) .. " ~= " .. tostring(b))
    end
end

local function assertError(f, expectedMsg)
    local ok, err = pcall(f)
    assert(not ok, "Expected error but call succeeded")
    local msg = tostring(err)
    -- Strip stack trace prefix if present (e.g. "LuaRuntimeError: ...")
    local clean = msg:match(":%s*(.+)$") or msg
    assert(clean:find(expectedMsg, 1, true),
        "Expected error containing '" .. expectedMsg .. "' but got: " .. msg)
end

-- 1. Character classes

-- %a / %A
assertEqual(string.match("hello123", "%a+"), "hello")
assertEqual(string.match("123hello", "%A+"), "123")

-- %d / %D
assertEqual(string.match("abc42xyz", "%d+"), "42")
assertEqual(string.match("42abc", "%D+"), "abc")

-- %l / %L (lowercase)
assertEqual(string.match("Hello", "%l+"), "ello")
assertEqual(string.match("hello", "%L+"), nil)

-- %u / %U (uppercase)
assertEqual(string.match("helloWORLD", "%u+"), "WORLD")
assertEqual(string.match("HELLO", "%U+"), nil)

-- %w / %W (alphanumeric)
assertEqual(string.match("hello world", "%w+"), "hello")
assertEqual(string.match("hello world", "%W+"), " ")

-- %s / %S (whitespace)
assertEqual(string.match("hello world", "%s+"), " ")
assertEqual(string.match("  hello", "%S+"), "hello")

-- %p (punctuation)
assertEqual(string.match("hello, world!", "%p+"), ",")

-- %g (printable, non-space)
assertEqual(string.match("  abc  ", "%g+"), "abc")

-- %x (hex digits)
assertEqual(string.match("ghABCDij", "%x+"), "ABCD")

-- %. (dot matches any)
assertEqual(string.match("abc", "."), "a")
assertEqual(string.match("abc", ".."), "ab")

-- 2. Anchors

assertEqual(string.match("hello", "^hello$"), "hello")
assertEqual(string.match("hello world", "^hello$"), nil)
assertEqual(string.match("hello", "^h"), "h")
assertEqual(string.match("hello", "o$"), "o")
assertEqual(string.match("hello", "x$"), nil)

-- 3. Repetition: *, +, -, ?

-- * (greedy, 0 or more)
assertEqual(string.match("aaa", "a*"), "aaa")
assertEqual(string.match("bbb", "a*"), "")

-- + (greedy, 1 or more)
assertEqual(string.match("aaa", "a+"), "aaa")
assertEqual(string.match("bbb", "a+"), nil)

-- - (lazy, 0 or more)
assertEqual(string.match("aaa", "a-b"), nil)
assertEqual(string.match("aab", "a-b"), "aab")
-- lazy match takes shortest prefix
local cap = string.match("<tag>content</tag>", "<(.-)>")
assertEqual(cap, "tag")

-- ? (optional)
assertEqual(string.match("colour", "colou?r"), "colour")
assertEqual(string.match("color", "colou?r"), "color")

-- 4. Escaping magic characters with %

assertEqual(string.match("100%", "(%d+)%%"), "100")
assertEqual(string.match("hello.world", "(%a+)%.(%a+)"), "hello")
assertEqual(string.match("a+b", "a%+b"), "a+b")
assertEqual(string.match("a*b", "a%*b"), "a*b")
assertEqual(string.match("(x)", "%(x%)"), "(x)")

-- Non-magic chars after % are literal
assertEqual(string.match("*", "%*"), "*")
assertEqual(string.match("?", "%?"), "?")

-- 5. Character sets [...]

assertEqual(string.match("cat", "[abc]"), "c")
assertEqual(string.match("dog", "[abc]"), nil)

-- Ranges
assertEqual(string.match("m", "[a-z]"), "m")
assertEqual(string.match("M", "[a-z]"), nil)
assertEqual(string.match("5", "[0-9]"), "5")

-- Negated set
assertEqual(string.match("x", "[^abc]"), "x")
assertEqual(string.match("a", "[^abc]"), nil)

-- Classes inside sets
assertEqual(string.match("3", "[%d]"), "3")
assertEqual(string.match("a", "[%d]"), nil)

-- 6. Captures

-- Single capture
local y, m, d = string.match("2024-03-14", "(%d+)-(%d+)-(%d+)")
assertEqual(y, "2024")
assertEqual(m, "03")
assertEqual(d, "14")

-- No captures returns whole match
assertEqual(string.match("hello", "%a+"), "hello")

-- Nested captures
local outer, inner = string.match("hello world", "(h(ello))")
assertEqual(outer, "hello")
assertEqual(inner, "ello")

-- Captures with literal parens
local content = string.match("((test))", "%((%(%a+%))%)")
assertEqual(content, "(test)")

-- Multiple captures
local a, b = string.match("hello world", "(%a+) (%a+)")
assertEqual(a, "hello")
assertEqual(b, "world")

-- 7. Position captures ()

local p1, p2 = string.match("hello", "()()")
assertEqual(p1, 1)
assertEqual(p2, 1)

local pos = string.match("hello world", "()world")
assertEqual(pos, 7)

-- 8. Back-references %1-%9

-- Match repeated word
local word = string.match("hello hello", "(%a+) %1")
assertEqual(word, "hello")

-- No match if different
assertEqual(string.match("hello world", "(%a+) %1"), nil)

-- Match repeated char
local ch = string.match("aabcc", "(.)%1")
assertEqual(ch, "a")

-- 9. Balanced match %bxy

assertEqual(string.match("(hello (world))", "%b()"), "(hello (world))")
assertEqual(string.match("{a{b}c}", "%b{}"), "{a{b}c}")
assertEqual(string.match("(unbalanced", "%b()"), nil)

-- 10. Frontier pattern %f[set]

-- Transition from non-alpha to alpha
local fw = string.match("hello world", "%f[%a]%a+", 2)
assertEqual(fw, "world")

-- Transition at string start
local fs = string.match("hello", "%f[%a]%a+")
assertEqual(fs, "hello")

-- Word boundaries
local words = {}
for w in string.gmatch("one two three", "%f[%a]%a+") do
    table.insert(words, w)
end
assertEqual(#words, 3)
assertEqual(words[1], "one")
assertEqual(words[2], "two")
assertEqual(words[3], "three")

-- 11. string.find

-- Basic find
local s, e = string.find("hello world", "world")
assertEqual(s, 7)
assertEqual(e, 11)

-- Plain find
s, e = string.find("hello.world", ".", 1, true)
assertEqual(s, 6)
assertEqual(e, 6)

-- Find with captures
local s2, e2, c1 = string.find("hello world", "(%a+)")
assertEqual(s2, 1)
assertEqual(e2, 5)
assertEqual(c1, "hello")

-- Not found
assertEqual(string.find("hello", "xyz"), nil)

-- Find with init
s, e = string.find("abcabc", "abc", 2)
assertEqual(s, 4)
assertEqual(e, 6)

-- Find returns nil (not false)
assert(string.find("a", "b") == nil)
assert(not (string.find("a", "b") ~= nil))

-- Find with special chars
local bf = string.find("[", "[_%w]")
assert(bf == nil)

-- 12. string.match

-- Match from init position
assertEqual(string.match("abcdef", "%a+", 4), "def")

-- Match empty pattern
assertEqual(string.match("abc", ""), "")

-- Match returns nil (not false)
assert(string.match("a", "b") == nil)
assert(not (string.match("a", "b") ~= nil))

-- Match with init
local initMatch = string.match("hello world", "(world)", 7)
assertEqual(initMatch, "world")

-- 13. string.gmatch

-- Basic iteration
local t = {}
for w in string.gmatch("hello world lua", "%a+") do
    table.insert(t, w)
end
assertEqual(#t, 3)
assertEqual(t[1], "hello")
assertEqual(t[2], "world")
assertEqual(t[3], "lua")

-- With captures
local kv = {}
for k, v in string.gmatch("from=world, to=Lua", "(%w+)=(%w+)") do
    kv[k] = v
end
assertEqual(kv.from, "world")
assertEqual(kv.to, "Lua")

-- gmatch with empty matches (like Lua reference: ";a;" with "a*")
local r = {}
for mm in string.gmatch(";a;", "a*") do
    table.insert(r, mm)
end
assertEqual(r[1], "")
assertEqual(r[2], "a")
assertEqual(r[3], "")

-- gmatch without captures returns whole match
local t2 = {}
for mm in string.gmatch("from=world, to=Lua", "%w+=%w+") do
    table.insert(t2, mm)
end
assertEqual(t2[1], "from=world")
assertEqual(t2[2], "to=Lua")

-- 14. string.gsub

-- Simple string replacement
local res, count = string.gsub("hello world", "(%w+)", "%1-%1")
assertEqual(res, "hello-hello world-world")
assertEqual(count, 2)

-- Limited replacements
res, count = string.gsub("aaa", "a", "b", 2)
assertEqual(res, "bba")
assertEqual(count, 2)

-- Replacement with %0
res = string.gsub("hello", "%w+", "[%0]")
assertEqual(res, "[hello]")

-- Function replacement
res, count = string.gsub("hello world", "%w+", function(w)
    return w:upper()
end)
assertEqual(res, "HELLO WORLD")
assertEqual(count, 2)

-- Function returning nil keeps original
res = string.gsub("hello world", "%w+", function(w)
    if w == "hello" then return "HI" end
end)
assertEqual(res, "HI world")

-- Table replacement
local tbl = {hello = "HI", world = "EARTH"}
res = string.gsub("hello world", "(%w+)", tbl)
assertEqual(res, "HI EARTH")

-- Table with missing key keeps original
tbl = {hello = "HI"}
res = string.gsub("hello world", "(%w+)", tbl)
assertEqual(res, "HI world")

-- gsub with magic chars in pattern
res = string.gsub("hello.world", "%.", "-")
assertEqual(res, "hello-world")

-- gsub %% in replacement
res = string.gsub("hello", "hello", "100%%")
assertEqual(res, "100%")

-- Empty pattern match (inserts between every char)
res = string.gsub("abc", "", "-")
assertEqual(res, "-a-b-c-")

-- Anchored gsub
res, count = string.gsub("abc", "^a", "x")
assertEqual(res, "xbc")
assertEqual(count, 1)

-- gsub with XML pattern
local xmlpat = '<%?xml version="1.0" encoding="UTF%-8"%?>'
local xmlstr = '<?xml version="1.0" encoding="UTF-8"?><my-xml></my-xml>'
res = string.gsub(xmlstr, xmlpat, "moo")
assertEqual(res, "moo<my-xml></my-xml>")

-- gsub with %%1
res = string.gsub("Hello %1", "%%1", "world")
assertEqual(res, "Hello world")

-- gsub counting digits
res, count = string.gsub("ab5kfd8scf4lll", "%d", "")
assertEqual(res, "abkfdscflll")
assertEqual(count, 3)

-- 15. The dash (lazy) vs literal dash

assertEqual(string.match("2024-03-14", "%d+-(%d+)-%d+"), "03")
assertEqual(string.match("2024-03-14", "(%d+)-(%d+)-(%d+)"), "2024")

local y2, m2, d2 = string.match("2024-03-14", "(%d+)-(%d+)-(%d+)")
assertEqual(y2, "2024")
assertEqual(m2, "03")
assertEqual(d2, "14")

-- 16. Edge cases

-- Empty string
assertEqual(string.match("", ".*"), "")
local fs2, fe2 = string.find("", "")
assertEqual(fs2, 1)
assertEqual(fe2, 0)

-- Pattern matching entire string
assertEqual(string.match("abc", "^(.-)$"), "abc")

-- Pattern at end of string
assertEqual(string.match("test!", "!$"), "!")

-- Multiple position captures
local p3, p4 = string.match("abcd", "()ab()cd")
assertEqual(p3, 1)
assertEqual(p4, 3)

-- string.rep with separator
assertEqual(string.rep("ab", 3, ","), "ab,ab,ab")
assertEqual(string.rep("x", 1, ","), "x")
assertEqual(string.rep("x", 0), "")

-- 17. More complex patterns

-- CSV-like parsing
local fields = {}
for f in string.gmatch("one,two,,four", "([^,]*)") do
    table.insert(fields, f)
end
assertEqual(fields[1], "one")
assertEqual(fields[2], "two")
assertEqual(fields[3], "")
assertEqual(fields[4], "four")

-- Trim whitespace
local function trim(s2)
    return string.match(s2, "^%s*(.-)%s*$")
end
assertEqual(trim("  hello  "), "hello")
assertEqual(trim("hello"), "hello")
assertEqual(trim("  "), "")

-- Match identifier
assertEqual(string.match("my_var123", "^[%a_][%w_]*$"), "my_var123")
assertEqual(string.match("123bad", "^[%a_][%w_]*$"), nil)

-- Hex color
local hex = string.match("#FF00AA", "^#(%x%x)(%x%x)(%x%x)$")
assertEqual(hex, "FF")

local r2, g, b2 = string.match("#FF00AA", "^#(%x%x)(%x%x)(%x%x)$")
assertEqual(r2, "FF")
assertEqual(g, "00")
assertEqual(b2, "AA")

-- Email-like pattern
local user, domain = string.match("user@example.com", "([%w_.]+)@([%w_.]+)")
assertEqual(user, "user")
assertEqual(domain, "example.com")

-- 18. Error cases — malformed patterns

-- Pattern ending with lone %
assertError(function()
    string.find("abc", "abc%")
end, "malformed pattern (ends with '%')")

assertError(function()
    string.match("abc", "%")
end, "malformed pattern (ends with '%')")

assertError(function()
    local iter = string.gmatch("abc", "a%")
    iter()
end, "malformed pattern (ends with '%')")

-- %b with missing arguments
assertError(function()
    string.match("abc", "%b")
end, "malformed pattern (missing arguments to '%b')")

assertError(function()
    string.match("abc", "%b(")
end, "malformed pattern (missing arguments to '%b')")

-- %f not followed by [
assertError(function()
    string.match("abc", "%f")
end, "missing '[' after '%f' in pattern")

assertError(function()
    string.match("abc", "%fa")
end, "missing '[' after '%f' in pattern")

-- Lone % at end of replacement
assertError(function()
    string.gsub("abc", "a", "x%")
end, "invalid use of '%' in replacement string")

-- Invalid escape in replacement
assertError(function()
    string.gsub("abc", "a", "%z")
end, "invalid use of '%' in replacement string")

-- 19. Error cases — invalid captures

-- Unmatched close paren
assertError(function()
    string.match("abc", ")")
end, "invalid pattern capture")

-- Back-reference to non-existent capture
assertError(function()
    string.match("abc", "%1")
end, "invalid capture index")

-- Back-reference to capture not yet closed
assertError(function()
    string.match("abab", "(%a+%1)")
end, "invalid capture index")

-- Too high capture index in replacement
assertError(function()
    string.gsub("abc", "(%a+)", "%2")
end, "invalid capture index")

-- Unfinished capture used in match (tries to return it)
assertError(function()
    string.match("abc", "(abc")
end, "unfinished capture")

-- Unfinished capture used in find (tries to return it)
assertError(function()
    string.find("abc", "(abc")
end, "unfinished capture")

-- Nested unfinished capture
assertError(function()
    string.match("abc", "(ab(c)")
end, "unfinished capture")

-- 20. Error cases — %b and %f

-- %b with missing arguments
assertError(function()
    string.match("abc", "%b")
end, "malformed pattern (missing arguments to '%b')")

assertError(function()
    string.match("abc", "%b(")
end, "malformed pattern (missing arguments to '%b')")

-- %f not followed by [
assertError(function()
    string.match("abc", "%f")
end, "missing '[' after '%f' in pattern")

assertError(function()
    string.match("abc", "%fa")
end, "missing '[' after '%f' in pattern")

-- 21. Error cases — gsub replacement string

-- Lone % at end of replacement
assertError(function()
    string.gsub("abc", "a", "x%")
end, "invalid use of '%' in replacement string")

-- Invalid escape in replacement (not a digit or %)
assertError(function()
    string.gsub("abc", "a", "%z")
end, "invalid use of '%' in replacement string")

-- %2 when only one capture exists
assertError(function()
    string.gsub("abc", "(a)", "%2")
end, "invalid capture index")

-- %9 with no captures at all
assertError(function()
    string.gsub("abc", "a", "%9")
end, "invalid capture index")

-- 22. Error cases — too many captures

assertError(function()
    local pat = string.rep("(", 33) .. "a" .. string.rep(")", 33)
    string.match("a", pat)
end, "too many captures")

-- 23. Error cases — unfinished capture

-- Open paren never closed
assertError(function()
    string.find("abc", "(abc")
end, "unfinished capture")

assertError(function()
    string.match("abc", "(ab(c)")
end, "unfinished capture")


================================================
FILE: client/space_lua/stdlib/space_lua.ts
================================================
import { parseExpressionString } from "../parse.ts";
import type { LuaExpression } from "../ast.ts";
import { evalExpression } from "../eval.ts";
import {
  LuaBuiltinFunction,
  LuaEnv,
  LuaRuntimeError,
  type LuaStackFrame,
  LuaTable,
  luaToString,
  luaValueToJS,
  singleResult,
} from "../runtime.ts";

/**
 * These are Space Lua specific functions that are available to all scripts, but are not part of the standard Lua language.
 */

/**
 * Helper function to create an augmented environment
 */
function createAugmentedEnv(
  sf: LuaStackFrame,
  envAugmentation?: LuaTable,
): LuaEnv {
  const globalEnv = sf.threadLocal.get("_GLOBAL");
  if (!globalEnv) {
    throw new Error("_GLOBAL not defined");
  }
  const env = new LuaEnv(globalEnv);
  if (envAugmentation) {
    env.setLocal("_", envAugmentation);
    for (const key of envAugmentation.keys()) {
      env.setLocal(key, envAugmentation.rawGet(key));
    }
  }
  return env;
}

/**
 * Interpolates a string with lua expressions and returns the result.
 *
 * @param sf - The current space_lua state.
 * @param template - The template string to interpolate.
 * @param envAugmentation - An optional environment to augment the global environment with.
 * @returns The interpolated string.
 */
export async function interpolateLuaString(
  sf: LuaStackFrame,
  template: string,
  envAugmentation?: LuaTable,
): Promise<string> {
  let result = "";
  let currentIndex = 0;

  while (true) {
    const startIndex = template.indexOf("${", currentIndex);
    if (startIndex === -1) {
      result += template.slice(currentIndex);
      break;
    }

    result += template.slice(currentIndex, startIndex);

    // Find matching closing brace by counting nesting
    let nestLevel = 1;
    let endIndex = startIndex + 2;
    while (nestLevel > 0 && endIndex < template.length) {
      if (template[endIndex] === "{") {
        nestLevel++;
      } else if (template[endIndex] === "}") {
        nestLevel--;
      }
      if (nestLevel > 0) {
        endIndex++;
      }
    }

    if (nestLevel > 0) {
      throw new LuaRuntimeError("Unclosed interpolation expression", sf);
    }

    const expr = template.slice(startIndex + 2, endIndex);
    try {
      const parsedExpr = parseExpressionString(expr);
      const env = createAugmentedEnv(sf, envAugmentation);
      // Do `luaToString` before `luaValueToJS` to preserve tagged float
      // formatting.
      const luaResult = singleResult(await evalExpression(parsedExpr, env, sf));
      result += await luaToString(luaResult);
    } catch (e: any) {
      throw new LuaRuntimeError(
        `Error evaluating "${expr}": ${e.message}`,
        sf,
      );
    }

    currentIndex = endIndex + 1;
  }

  return result;
}

export const spaceluaApi = new LuaTable({
  /**
   * Parses a lua expression and returns the parsed expression.
   *
   * @param sf - The current space_lua state.
   * @param luaExpression - The lua expression to parse.
   * @returns The parsed expression.
   */
  parseExpression: new LuaBuiltinFunction(
    (_sf, luaExpression: string) => {
      return parseExpressionString(luaExpression);
    },
  ),
  /**
   * Evaluates a parsed lua expression and returns the result.
   *
   * @param sf - The current space_lua state.
   * @param parsedExpr - The parsed lua expression to evaluate.
   * @param envAugmentation - An optional environment to augment the global environment with.
   * @returns The result of the evaluated expression.
   */
  evalExpression: new LuaBuiltinFunction(
    async (sf, parsedExpr: LuaExpression, envAugmentation?: LuaTable) => {
      const env = createAugmentedEnv(sf, envAugmentation);
      return luaValueToJS(await evalExpression(parsedExpr, env, sf), sf);
    },
  ),
  /**
   * Interpolates a string with lua expressions and returns the result.
   */
  interpolate: new LuaBuiltinFunction(
    (sf, template: string, envAugmentation?: LuaTable) => {
      return interpolateLuaString(sf, template, envAugmentation);
    },
  ),
  /**
   * Returns your SilverBullet instance's base URL, or `undefined` when run on the server
   */
  baseUrl: new LuaBuiltinFunction(
    () => {
      // Deal with Deno
      if (typeof location === "undefined") {
        return null;
      } else {
        //NOTE: Removing trailing slash to stay compatible with original code: `location.protocol + "//" + location.host;`
        return document.baseURI.replace(/\/*$/, "");
      }
    },
  ),
});


================================================
FILE: client/space_lua/stdlib/space_lua_test.lua
================================================
local parsedExpr = spacelua.parseExpression("1 + 1")
local evalResult = spacelua.evalExpression(parsedExpr)
assert(evalResult == 2, "Eval should return 2")

-- Slightly more advanced example with augmented environment
local parsedExpr = spacelua.parseExpression("tostring(a + 1)")
local evalResult = spacelua.evalExpression(parsedExpr, { a = 1 })
assert(evalResult == "2", "Eval should return 2 as a string")


================================================
FILE: client/space_lua/stdlib/string.ts
================================================
import {
  jsToLuaValue,
  LuaBuiltinFunction,
  LuaMultiRes,
  LuaRuntimeError,
  LuaTable,
  luaToString,
} from "../runtime.ts";
import { untagNumber } from "../numeric.ts";
import { luaFormat } from "./format.ts";
import {
  type CaptureResult,
  type GsubCallbacks,
  patternFind,
  patternGmatch,
  patternGsub,
  patternMatch,
} from "./pattern.ts";

function capturesToLua(caps: CaptureResult[]): any {
  if (caps.length === 0) {return null;}
  if (caps.length === 1) {
    const c = caps[0];
    return "s" in c ? c.s : c.position;
  }
  return new LuaMultiRes(
    caps.map((c) => ("s" in c ? c.s : c.position)),
  );
}

export const stringApi = new LuaTable({
  byte: new LuaBuiltinFunction((_sf, s: string, i?: number, j?: number) => {
    i = i ?? 1;
    j = j ?? i;
    if (j > s.length) {j = s.length;}
    if (i < 1) {i = 1;}
    const result = [];
    for (let k = i; k <= j; k++) {
      result.push(s.charCodeAt(k - 1));
    }
    return new LuaMultiRes(result);
  }),
  char: new LuaBuiltinFunction((_sf, ...args: number[]) => {
    return String.fromCharCode(...args);
  }),
  find: new LuaBuiltinFunction(
    (_sf, s: string, pattern: string, init = 1, plain = false) => {
      const r = patternFind(s, pattern, init, plain);
      if (!r) {return null;}
      const result: any[] = [r.start, r.end];
      for (const c of r.captures) {
        result.push("s" in c ? c.s : c.position);
      }
      return new LuaMultiRes(result);
    },
  ),
  format: new LuaBuiltinFunction((_sf, format: string, ...args: any[]) => {
    for (let i = 0; i < args.length; i++) {
      args[i] = untagNumber(args[i]);
    }
    return luaFormat(format, ...args);
  }),
  gmatch: new LuaBuiltinFunction(
    (_sf, s: string, pattern: string, init = 1) => {
      const iter = patternGmatch(s, pattern, init);
      return () => {
        const caps = iter();
        if (!caps) {return;}
        return capturesToLua(caps);
      };
    },
  ),
  gsub: new LuaBuiltinFunction(
    async (
      sf,
      s: string,
      pattern: string,
      repl: any,
      n?: number,
    ) => {
      const callbacks: GsubCallbacks = {};
      if (typeof repl === "string") {
        callbacks.replString = repl;
      } else if (repl instanceof LuaTable) {
        callbacks.replTable = (key: string) => {
          const v = repl.get(key);
          if (v === null || v === undefined || v === false) {return null;}
          return String(v);
        };
      } else if (repl.call) {
        callbacks.replFunction = async (...caps: CaptureResult[]) => {
          const args = caps.map((c) => ("s" in c ? c.s : c.position));
          let result = await repl.call(sf, ...args);
          if (result instanceof LuaMultiRes) {
            result = result.values[0];
          }
          if (result === null || result === undefined || result === false) {
            return null;
          }
          return luaToString(result);
        };
      } else {
        throw new LuaRuntimeError(
          "string.gsub replacement argument should be a function, table or string",
          sf,
        );
      }
      const [result, count] = await patternGsub(s, pattern, callbacks, n);
      return new LuaMultiRes([result, count]);
    },
  ),
  len: new LuaBuiltinFunction((_sf, s: string) => {
    return s.length;
  }),
  lower: new LuaBuiltinFunction((_sf, s: string) => {
    return luaToString(s.toLowerCase());
  }),
  upper: new LuaBuiltinFunction((_sf, s: string) => {
    return luaToString(s.toUpperCase());
  }),
  match: new LuaBuiltinFunction(
    (_sf, s: string, pattern: string, init = 1) => {
      const caps = patternMatch(s, pattern, init);
      if (!caps) {return null;}
      return capturesToLua(caps);
    },
  ),
  rep: new LuaBuiltinFunction((_sf, s: string, n: number, sep?: string) => {
    if (n <= 0) {return "";}
    sep = sep ?? "";
    const parts: string[] = [];
    for (let i = 0; i < n; i++) {
      parts.push(s);
    }
    return parts.join(sep);
  }),
  reverse: new LuaBuiltinFunction((_sf, s: string) => {
    return s.split("").reverse().join("");
  }),
  sub: new LuaBuiltinFunction((_sf, s: string, i: number, j?: number) => {
    const len = s.length;
    let start: number;
    if (i > 0) {
      start = i;
    } else if (i < -len) {
      start = 1;
    } else {
      start = i === 0 ? 1 : len + i + 1;
    }
    let end: number;
    if (j === undefined || j === null || j > len) {
      end = len;
    } else if (j >= 0) {
      end = j;
    } else if (j < -len) {
      end = 0;
    } else {
      end = len + j + 1;
    }
    if (start <= end) {
      return s.substring(start - 1, end);
    }
    return "";
  }),
  split: new LuaBuiltinFunction((_sf, s: string, sep: string) => {
    return s.split(sep);
  }),

  // Non-standard extensions
  startsWith: new LuaBuiltinFunction((_sf, s: string, prefix: string) => {
    return s.startsWith(prefix);
  }),
  endsWith: new LuaBuiltinFunction((_sf, s: string, suffix: string) => {
    return s.endsWith(suffix);
  }),
  trim: new LuaBuiltinFunction((_sf, s: string) => {
    return s.trim();
  }),
  trimStart: new LuaBuiltinFunction((_sf, s: string) => {
    return s.trimStart();
  }),
  trimEnd: new LuaBuiltinFunction((_sf, s: string) => {
    return s.trimEnd();
  }),
  matchRegex: new LuaBuiltinFunction((_sf, s: string, pattern: string) => {
    const regex = new RegExp(pattern);
    const result = s.match(regex);
    return jsToLuaValue(result);
  }),
  matchRegexAll: new LuaBuiltinFunction((_sf, s: string, pattern: string) => {
    const regex = new RegExp(pattern, "g");
    return () => {
      const match = regex.exec(s);
      if (!match) {
        return;
      }
      return jsToLuaValue(match);
    };
  }),
});


================================================
FILE: client/space_lua/stdlib/string_test.lua
================================================
local function assertEqual(a, b)
    if a ~= b then
        error("Assertion failed: " .. tostring(a) .. " is not equal to " .. tostring(b))
    end
end

-- Basic string functions
assert(string.len("Hello") == 5)
assert(string.byte("Hello", 1) == 72)
assert(string.char(72) == "H")
assert(string.rep("Hello", 3) == "HelloHelloHello")
assert(string.sub("Hello", 2, 4) == "ell")
assert(string.upper("Hello") == "HELLO")
assert(string.lower("Hello") == "hello")

-- string.byte tests
local a, b = string.byte('Mo0')
assert(a == 77, 'string.byte() should return the code for the first char')
assert(b == nil, 'string.byte() should return only one item when no length is given')

a, b = string.byte('Mo0', 2)
assert(a == 111, 'string.byte() should return the code for the nth character')
assert(b == nil, 'string.byte() should return only one item when no length is given')

local a2, b2, c2 = string.byte('Mo0', 2, 3)
assert(a2 == 111, 'string.byte() multi-return [1]')
assert(b2 == 48, 'string.byte() multi-return [2]')
assert(c2 == nil, 'string.byte() should stop at end of string')

a2, b2 = string.byte('Mo0', 3, 20)
assert(a2 == 48, 'string.byte() should clamp to string length')
assert(b2 == nil, 'string.byte() should not return past end of string')

-- string.char tests
assertEqual(string.char(), '')
assertEqual(string.char(116, 101, 115, 116), 'test')

-- string.len tests
assertEqual(string.len(''), 0)
assertEqual(string.len('McLaren Mercedes'), 16)

-- string.lower tests
assertEqual(string.lower(''), '')
assertEqual(string.lower('McLaren Mercedes'), 'mclaren mercedes')

-- string.upper tests
assertEqual(string.upper(''), '')
assertEqual(string.upper('JavaScript'), 'JAVASCRIPT')

-- string.rep tests
assertEqual(string.rep('Ho', 0), '')
assertEqual(string.rep('Ho', 1), 'Ho')
assertEqual(string.rep('Ho', 3), 'HoHoHo')
assertEqual(string.rep("ab", 3, ","), "ab,ab,ab")
assertEqual(string.rep("x", 1, ","), "x")

-- string.reverse tests
assertEqual(string.reverse(''), '')
assertEqual(string.reverse('x'), 'x')
assertEqual(string.reverse('tpircSavaJ'), 'JavaScript')

-- string.sub tests
assertEqual(string.sub("hello", 1), "hello")
assertEqual(string.sub("hello", 1, 1), "h")
-- with negative indices
assertEqual(string.sub("hello", -1), "o")
assertEqual(string.sub("hello", -2), "lo")
assertEqual(string.sub("hello", -2, -1), "lo")

local s = 'Pub Standards'
assertEqual(string.sub(s, 1), 'Pub Standards')
assertEqual(string.sub(s, 5), 'Standards')
assertEqual(string.sub(s, -4), 'ards')
assertEqual(string.sub(s, 1, 3), 'Pub')
assertEqual(string.sub(s, 7, 9), 'and')
assertEqual(string.sub(s, 5, -2), 'Standard')
assertEqual(string.sub(s, 0), 'Pub Standards')

-- Invoke string metatable methods
assertEqual(("hello"):len(), 5)
assertEqual(("hello"):upper(), "HELLO")
assertEqual(('Hey'):lower(), 'hey')

-- Test string.split (non-standard)
local parts = string.split("a,b,c", ",")
assertEqual(parts[1], "a")
assertEqual(parts[2], "b")
assertEqual(parts[3], "c")

-- Test non-standard string extensions
assertEqual(string.startsWith("hello world", "hello"), true)
assertEqual(string.startsWith("hello world", "world"), false)
assertEqual(string.endsWith("hello world", "world"), true)
assertEqual(string.endsWith("hello world", "hello"), false)

-- Test matchRegexAll (non-standard, regex-based)
local matches = {}
for match in string.matchRegexAll("hellolllbl", "(l+)") do
    table.insert(matches, match)
end
assertEqual(#matches, 3)
assertEqual(matches[1][1], "ll")
assertEqual(matches[2][1], "lll")
assertEqual(matches[3][1], "l")


================================================
FILE: client/space_lua/stdlib/table.ts
================================================
import {
  getMetatable,
  type ILuaFunction,
  LuaBuiltinFunction,
  luaCall,
  type LuaEnv,
  luaEquals,
  luaGet,
  LuaMultiRes,
  LuaRuntimeError,
  luaSet,
  LuaTable,
  type LuaValue,
  luaValueToJS,
  singleResult,
} from "../runtime.ts";
import { asyncQuickSort, evalPromiseValues } from "../util.ts";
import { isTaggedFloat } from "../numeric.ts";

// For `LuaTable` honor `__len` when present; otherwise use raw array
// length.  For JS arrays use `.length`.
function luaLenForTableLib(
  sf: any,
  tbl: LuaTable | any[],
): number | Promise<number> {
  if (Array.isArray(tbl)) {
    return tbl.length;
  }
  if (!(tbl instanceof LuaTable)) {
    return 0;
  }

  const mt = getMetatable(tbl, sf);
  const mm = mt ? mt.rawGet("__len") : null;
  if (!(mm === undefined || mm === null)) {
    const r = luaCall(mm, [tbl], sf.astCtx ?? {}, sf);
    if (r instanceof Promise) {
      return r.then((v: any) => Number(singleResult(v)));
    }
    return Number(singleResult(r));
  }

  return tbl.length;
}

async function luaLenForTableLibAsync(sf: any, tbl: LuaTable | any[]) {
  const r = luaLenForTableLib(sf, tbl);
  return r instanceof Promise ? await r : r;
}

export const tableApi = new LuaTable({
  /**
   * Concatenates the elements of a table into a string, using a separator.
   * @param tbl - The table to concatenate.
   * @param sep - The separator to use between elements.
   * @param i - The start index.
   * @param j - The end index.
   * @returns The concatenated string.
   */
  concat: new LuaBuiltinFunction(
    async (sf, tbl: LuaTable | any[], sep?: string, i?: number, j?: number) => {
      sep = sep ?? "";
      i = i ?? 1;
      if (j === undefined || j === null) {
        j = await luaLenForTableLibAsync(sf, tbl);
      }

      const luaConcatElemToString = (v: any, idx: number): string => {
        // Concat errors on nil and non-string or non-number values.
        if (v === null || v === undefined) {
          throw new LuaRuntimeError(
            `invalid value (nil) at index ${idx} in table for 'concat'`,
            sf,
          );
        }
        if (typeof v === "string") {
          return v;
        }
        if (typeof v === "number") {
          return String(v);
        }
        if (isTaggedFloat(v)) {
          return String(v.value);
        }

        const ty = typeof v === "object" && v instanceof LuaTable
          ? "table"
          : typeof v;
        throw new LuaRuntimeError(
          `invalid value (${ty}) at index ${idx} in table for 'concat'`,
          sf,
        );
      };

      if (Array.isArray(tbl)) {
        const out: string[] = [];
        for (let k = i; k <= j; k++) {
          const v = tbl[k - 1];
          out.push(luaConcatElemToString(v, k));
        }
        return out.join(sep);
      }

      const out: string[] = [];
      for (let k = i; k <= j; k++) {
        const v = await luaGet(tbl, k, sf.astCtx ?? null, sf);
        out.push(luaConcatElemToString(v, k));
      }
      return out.join(sep);
    },
  ),

  /**
   * Inserts an element into a table at a specified position.
   * @param tbl - The table to insert the element into.
   * @param posOrValue - The position or value to insert.
   * @param value - The value to insert.
   */
  insert: new LuaBuiltinFunction(
    async (
      sf,
      tbl: LuaTable | any[],
      posOrValue: number | any,
      value?: any,
    ) => {
      if (Array.isArray(tbl)) {
        // Since we're inserting/appending to a native JS array, we'll also convert the value to a JS value on the fly
        // this seems like a reasonable heuristic
        if (value === undefined) {
          tbl.push(luaValueToJS(posOrValue, sf));
        } else {
          tbl.splice(posOrValue - 1, 0, luaValueToJS(value, sf));
        }
        return;
      }

      if (!(tbl instanceof LuaTable)) {
        return;
      }

      let pos: number;
      let v: any;

      if (value === undefined) {
        v = posOrValue;
        pos = (await luaLenForTableLibAsync(sf, tbl)) + 1;
      } else {
        pos = posOrValue;
        v = value;
      }

      const n = await luaLenForTableLibAsync(sf, tbl);

      // Shift up: for k = n, pos, -1 do t[k+1] = t[k] end
      for (let k = n; k >= pos; k--) {
        const cur = await luaGet(tbl, k, sf.astCtx ?? null, sf);
        await luaSet(tbl, k + 1, cur, sf);
      }

      await luaSet(tbl, pos, v, sf);
    },
  ),

  /**
   * Removes an element from a table at a specified position.
   * @param tbl - The table to remove the element from.
   * @param pos - The position of the element to remove.
   */
  remove: new LuaBuiltinFunction(
    async (sf, tbl: LuaTable | any[], pos?: number) => {
      if (Array.isArray(tbl)) {
        const n = tbl.length;
        const p = pos ?? n;
        if (p < 1 || p > n) {
          throw new LuaRuntimeError("position out of bounds", sf);
        }
        const idx = p - 1;
        const v = tbl[idx];
        tbl.splice(idx, 1);
        return v;
      }

      if (!(tbl instanceof LuaTable)) {
        return null;
      }

      const n = await luaLenForTableLibAsync(sf, tbl);
      const p = pos ?? n;

      if (p < 1 || p > n) {
        throw new LuaRuntimeError("position out of bounds", sf);
      }

      const v = await luaGet(tbl, p, sf.astCtx ?? null, sf);

      // Shift down: for k = p, n-1 do t[k] = t[k+1] end; t[n] = nil
      for (let k = p; k < n; k++) {
        const next = await luaGet(tbl, k + 1, sf.astCtx ?? null, sf);
        await luaSet(tbl, k, next, sf);
      }
      await luaSet(tbl, n, null, sf);

      return v;
    },
  ),

  /**
   * Sorts a table.
   * @param tbl - The table to sort.
   * @param comp - The comparison function.
   * @returns The sorted table.
   */
  sort: new LuaBuiltinFunction(
    async (sf, tbl: LuaTable | any[], comp?: ILuaFunction) => {
      if (Array.isArray(tbl)) {
        return await asyncQuickSort(tbl, async (a, b) => {
          if (comp) {
            return (await comp.call(sf, a, b)) ? -1 : 1;
          }
          return (a as any) < (b as any) ? -1 : 1;
        });
      }

      if (!(tbl instanceof LuaTable)) {
        return tbl;
      }

      const n = await luaLenForTableLibAsync(sf, tbl);

      const values: any[] = [];
      for (let i = 1; i <= n; i++) {
        values.push(await luaGet(tbl, i, sf.astCtx ?? null, sf));
      }

      const cmp = async (a: any, b: any): Promise<number> => {
        if (comp) {
          const r = await luaCall(comp, [a, b], sf.astCtx ?? {}, sf);
          return r ? -1 : 1;
        }

        const av = isTaggedFloat(a) ? a.value : a;
        const bv = isTaggedFloat(b) ? b.value : b;

        if (typeof av === "number" && typeof bv === "number") {
          return av < bv ? -1 : 1;
        }
        if (typeof av === "string" && typeof bv === "string") {
          return av < bv ? -1 : 1;
        }

        const ta = typeof av;
        const tb = typeof bv;
        throw new LuaRuntimeError(
          `attempt to compare ${ta} with ${tb}`,
          sf,
        );
      };

      const sorted = await asyncQuickSort(values, cmp);

      for (let i = 1; i <= n; i++) {
        await luaSet(tbl, i, sorted[i - 1], sf);
      }

      return tbl;
    },
  ),

  /**
   * Returns the keys of a table.
   * Note: Space Lua specific
   * @param tbl - The table to get the keys from.
   * @returns The keys of the table.
   */
  keys: new LuaBuiltinFunction((_sf, tbl: LuaTable | LuaEnv | any) => {
    if (tbl.keys) {
      return tbl.keys();
    }
    return Object.keys(tbl);
  }),

  /**
   * Checks if a table (used as an array) contains a value.
   * Note: Space Lua specific
   * @param tbl - The table to check.
   * @param value - The value to check for.
   * @returns True if the value is in the table, false otherwise.
   */
  includes: new LuaBuiltinFunction(
    (sf, tbl: LuaTable | any[], value: LuaValue) => {
      if (!tbl) {
        return false;
      }
      if (tbl instanceof LuaTable) {
        // Iterate over the table
        for (const key of tbl.keys()) {
          if (luaEquals(tbl.get(key), value)) {
            return true;
          }
        }
        return false;
      }
      if (Array.isArray(tbl)) {
        return !!tbl.find((item) => luaEquals(item, value));
      }
      throw new LuaRuntimeError(
        `Cannot use includes on a non-table or non-array value`,
        sf,
      );
    },
  ),

  /**
   * Returns a new table from an old one, only with selected keys
   * @param tbl a Lua table or JS object
   * @param keys a list of keys to select from the table, if keys[0] is a table or array, assumed to contain the keys to select
   * @returns a new table with only the selected keys
   */
  select: new LuaBuiltinFunction(
    (sf, tbl: LuaTable | Record<string, any>, ...keys: LuaValue[]) => {
      // Normalize arguments
      if (Array.isArray(keys[0])) {
        // First argument is key array, let's unpack
        keys = keys[0];
      } else if (keys[0] instanceof LuaTable) {
        keys = keys[0].toJSArray();
      }
      const resultTable = new LuaTable();
      const setPromises: (void | Promise<void>)[] = [];
      for (const key of keys) {
        setPromises.push(resultTable.set(key, luaGet(tbl, key, null, sf)));
      }
      const promised = evalPromiseValues(setPromises);
      if (promised instanceof Promise) {
        return promised.then(() => resultTable);
      }
      return resultTable;
    },
  ),

  pack: new LuaBuiltinFunction((_sf, ...args: any[]) => {
    const tbl = new LuaTable();
    for (let i = 0; i < args.length; i++) {
      tbl.set(i + 1, args[i]);
    }
    tbl.set("n", args.length);
    return tbl;
  }),

  unpack: new LuaBuiltinFunction(
    async (sf, tbl: LuaTable | any[], i?: number, j?: number) => {
      i = i ?? 1;
      if (j === undefined || j === null) {
        j = Array.isArray(tbl)
          ? tbl.length
          : await luaLenForTableLibAsync(sf, tbl);
      }

      const result: LuaValue[] = [];
      for (let k = i; k <= j; k++) {
        const v = Array.isArray(tbl)
          ? tbl[k - 1]
          : await luaGet(tbl, k, sf.astCtx ?? null, sf);
        result.push(v);
      }
      return new LuaMultiRes(result);
    },
  ),

  // Non-standard Lua functions
  /**
   * Finds an element in a table that matches a criteria function. Returns the first matching element.
   * @param tbl - The table to search.
   * @param criteriaFn - The criteria function.
   * @param fromIndex - The index to start searching from.
   * @returns Lua multi value of index, value, or nil if no element is found.
   */
  find: new LuaBuiltinFunction(
    async (
      sf,
      tbl: LuaTable | any[],
      criteriaFn: ILuaFunction,
      fromIndex = 1,
    ) => {
      if (!tbl) {
        return null;
      }
      const startIndex = fromIndex < 1 ? 1 : fromIndex;
      const n = Array.isArray(tbl)
        ? tbl.length
        : await luaLenForTableLibAsync(sf, tbl);
      for (let i = startIndex; i <= n; i++) {
        const val = await luaGet(tbl, i, sf.astCtx ?? null, sf);
        if (await luaCall(criteriaFn, [val], sf.astCtx!, sf)) {
          return new LuaMultiRes([i, val]);
        }
      }
      return null;
    },
  ),
});


================================================
FILE: client/space_lua/stdlib/table_test.lua
================================================
local function deepCompare(t1, t2, seen)
    if t1 == t2 then return true end
    if type(t1) ~= "table" or type(t2) ~= "table" then return false end

    seen = seen or {}
    if seen[t1] and seen[t1] == t2 then return true end
    seen[t1] = t2

    local n1, n2 = 0, 0
    for _ in pairs(t1) do n1 = n1 + 1 end
    for _ in pairs(t2) do n2 = n2 + 1 end
    if n1 ~= n2 then return false end

    for k, v in pairs(t1) do
        if not deepCompare(v, t2[k], seen) then
            return false
        end
    end
    return true
end

local function assertEqual(a, b)
    if not deepCompare(a, b) then
        error("Assertion failed: " .. tostring(a) .. " is not equal to " .. tostring(b))
    end
end

local function assertTrue(v, msg)
    if not v then error(msg or "Assertion failed") end
end

local function assertFalse(v, msg)
    if v then error(msg or "Assertion failed (expected false)") end
end

local function assertError(fn, msg)
    local ok, err = pcall(fn)
    if ok then
        error(msg or "Assertion failed (expected error)")
    end
    return err
end

-- Basic table operations (insert/remove)
do
    local t = { 1, 2, 3 }
    table.insert(t, 4)
    assertEqual(t[4], 4)

    table.insert(t, 1, 99)
    assertEqual(t[1], 99)
    assertEqual(#t, 5)

    local x = table.remove(t, 1)
    assertEqual(x, 99)

    table.remove(t) -- remove last
    assertEqual(#t, 3)
    assertEqual(t, { 1, 2, 3 })

    -- remove out of range should error
    assertError(function() table.remove(t, 0) end)
    assertError(function() table.remove(t, #t + 2) end)
end

-- Reference semantics / aliasing (tables are mutable references)
do
    local a = { x = 1 }
    local b = a
    b.x = 2
    assertEqual(a.x, 2)

    local nested = { inner = { 1, 2 } }
    local alias = nested.inner
    alias[1] = 10
    assertEqual(nested.inner[1], 10)
end

-- rawget/rawset/rawequal ignore metamethods
do
    local backing = {}
    local t = setmetatable({}, {
        __index = function(_, k)
            return backing[k]
        end,
        __newindex = function(_, k, v)
            backing[k] = v
        end,
    })

    t.a = 1
    assertEqual(backing.a, 1)
    assertEqual(t.a, 1)

    rawset(t, "a", 2)
    assertEqual(rawget(t, "a"), 2)
    assertEqual(backing.a, 1)

    assertEqual(rawget(t, "missing"), nil)

    local t2 = t
    local t3 = {}
    assertTrue(rawequal(t2, t))
    assertFalse(rawequal(t3, t))
end

-- Length operator semantics
do
    local seq = { 1, 2, 3 }
    assertEqual(#seq, 3)

    local holes = { 1, 2, 3, nil, nil, nil, 4 }
    assertEqual(rawlen(holes), 7)

    local t = { 1, 2, 3 }
    t[5] = 5
    local collected = {}
    for i, v in ipairs(t) do
        collected[i] = v
    end
    assertEqual(collected, { 1, 2, 3 })
end

-- __len metamethod can override #t
do
    local t = setmetatable({ 1, 2, 3 }, {
        __len = function(_) return 123 end
    })
    assertEqual(#t, 123)
end

-- pairs / __pairs
do
    local t = setmetatable({ a = 1, b = 2 }, {
        __pairs = function(_)
            local yielded = false
            return function()
                if yielded then return nil end
                yielded = true
                return "only", 42
            end
        end
    })

    local out = {}
    for k, v in pairs(t) do
        out[k] = v
    end
    assertEqual(out, { only = 42 })
end

-- next
do
    local t = { a = 1, b = 2, c = 3 }
    local k, v = next(t, nil)
    assertTrue(k ~= nil)
    assertTrue(v ~= nil)

    local seen = {}
    while k do
        seen[k] = v
        k, v = next(t, k)
    end
    assertEqual(seen.a, 1)
    assertEqual(seen.b, 2)
    assertEqual(seen.c, 3)
end

-- table.concat
do
    assertEqual(table.concat({ "Hello", "world" }, " "), "Hello world")
    assertEqual(table.concat({ "Hello", "world", "three" }, " ", 2, 3), "world three")
    assertEqual(table.concat({ "a", "b", "c" }), "abc")
    assertEqual(table.concat({ "a", "b", "c" }, "", 2, 1), "")

    assertError(function() table.concat({ "a", {} }, "") end)
end

-- table.sort
do
    local t = { 3, 1, 2 }
    table.sort(t)
    assertEqual(t, { 1, 2, 3 })

    table.sort(t, function(a, b) return a > b end)
    assertEqual(t, { 3, 2, 1 })

    local data = { { name = "John", age = 30 }, { name = "Jane", age = 25 } }
    table.sort(data, function(a, b) return a.age < b.age end)
    assertEqual(data[1].name, "Jane")
    assertEqual(data[2].name, "John")

    assertError(function() table.sort({ 1, "x" }) end)

    -- Portable error test: comparator itself throws -> sort must raise
    assertError(function()
        table.sort({ 2, 1 }, function()
            error("boom")
        end)
    end)

    -- NOTE: Do NOT require an error for comparator returning nil; Lua may treat it as false.
end

-- table.pack / table.unpack
do
    local t = { 1, 2, 3 }
    local packed = table.pack(table.unpack(t))
    assertEqual(packed[1], 1)
    assertEqual(packed[2], 2)
    assertEqual(packed[3], 3)
    assertEqual(packed.n, 3)

    local p = table.pack(1, nil, 3, nil)
    assertEqual(p.n, 4)
    assertEqual(p[1], 1)
    assertEqual(p[2], nil)
    assertEqual(p[3], 3)
    assertEqual(p[4], nil)

    local a, b, c, d = table.unpack(p, 1, p.n)
    assertEqual(a, 1)
    assertEqual(b, nil)
    assertEqual(c, 3)
    assertEqual(d, nil)

    local seq = { "x", "y" }
    local x, y = table.unpack(seq)
    assertEqual(x, "x")
    assertEqual(y, "y")
end

-- __index / __newindex semantics
do
    local backing = { a = 10 }
    local t = setmetatable({}, {
        __index = backing,
        __newindex = function(_, k, v)
            backing[k] = v * 2
        end,
    })

    assertEqual(t.a, 10)

    t.b = 5
    assertEqual(backing.b, 10)
    assertEqual(rawget(t, "b"), nil)
end

-- Equality semantics: tables compare by reference
do
    local a = { 1 }
    local b = { 1 }
    assertFalse(a == b)
    local c = a
    assertTrue(a == c)
end


================================================
FILE: client/space_lua/tonumber.ts
================================================
/**
 * Space Lua `tonumber` implementation
 *
 * Matches Lua 5.4 semantics:
 * - parses decimal or hex numerals
 * - if base is given parses signed integers in the given base (2..36)
 * - leading and trailing Lua spaces are trimmed (`\t\r\n\h\v`)
 * - returns null on failure
 *
 * Notes:
 * - hex without exponent is an integer
 * - hex float requires `p` or `P` exponent
 * - decimal float allows '.' and `e` or `E` exponent
 */
import type { NumericType } from "./ast.ts";

function skipSpace(s: string, i: number): number {
  const n = s.length;
  while (i < n) {
    const c = s.charCodeAt(i);
    if (c === 32 || (c >= 9 && c <= 13)) { // SP, HT, LF, VT, FF, CR
      i++;
    } else {
      break;
    }
  }
  return i;
}

function charToDigitBase(c: number, base: number): number {
  if (c >= 48 && c <= 57) { // '0'..'9'
    const v = c - 48;
    if (v < base) {
      return v;
    }
    return -1;
  }
  if (c >= 65 && c <= 90) { // 'A'..'Z'
    const v = 10 + (c - 65);
    if (v < base) {
      return v;
    }
    return -1;
  }
  if (c >= 97 && c <= 122) { // 'a'..'z'
    const v = 10 + (c - 97);
    if (v < base) {
      return v;
    }
    return -1;
  }
  return -1;
}

function parseIntWithBase(
  s: string,
  base: number,
): { ok: boolean; end: number; value: number } {
  const n = s.length;
  let i = 0;
  i = skipSpace(s, i);
  if (i >= n) {
    return { ok: false, end: i, value: 0 };
  }

  let sign = 1;
  if (s.charCodeAt(i) === 45) { // '-'
    sign = -1;
    i++;
  } else if (s.charCodeAt(i) === 43) { // '+'
    i++;
  }

  if (i >= n) {
    return { ok: false, end: i, value: 0 };
  }

  let acc = 0;
  let any = false;

  while (i < n) {
    const c = s.charCodeAt(i);
    const isAlnum = (c >= 48 && c <= 57) || // '0'..'9'
      (c >= 65 && c <= 90) || // 'A'..'Z'
      (c >= 97 && c <= 122); // 'a'..'z'

    if (!isAlnum) {
      break;
    }

    const d = charToDigitBase(c, base);
    if (d < 0) {
      return { ok: false, end: i, value: 0 };
    }

    acc = acc * base + d;
    any = true;
    i++;
  }

  if (!any) {
    return { ok: false, end: i, value: 0 };
  }

  i = skipSpace(s, i);
  if (i !== n) {
    return { ok: false, end: i, value: 0 };
  }

  return { ok: true, end: i, value: sign * acc };
}

function parseInt(s: string): { ok: boolean; value: number } {
  const n = s.length;
  let i = 0;
  i = skipSpace(s, i);
  if (i >= n) {
    return { ok: false, value: 0 };
  }

  let neg = false;
  if (s.charCodeAt(i) === 45) { // '-'
    neg = true;
    i++;
  } else if (s.charCodeAt(i) === 43) { // '+'
    i++;
  }

  if (i >= n) {
    return { ok: false, value: 0 };
  }

  let acc = 0;
  let any = false;

  // hex?
  if (s.charCodeAt(i) === 48 && i + 1 < n) { // '0'
    const x = s.charCodeAt(i + 1);
    if (x === 120 || x === 88) { // 'x' or 'X'
      i += 2;
      while (i < n) {
        const c = s.charCodeAt(i);
        let d = -1;
        if (c >= 48 && c <= 57) { // '0'..'9'
          d = c - 48; // '0'
        } else if (c >= 65 && c <= 70) { // 'A'..'F'
          d = 10 + (c - 65);
        } else if (c >= 97 && c <= 102) { // 'a'..'f'
          d = 10 + (c - 97);
        } else {
          d = -1;
        }
        if (d < 0) {
          break;
        }
        acc = acc * 16 + d;
        any = true;
        i++;
      }
      i = skipSpace(s, i);
      if (!any || i !== n) {
        return { ok: false, value: 0 };
      }
      return { ok: true, value: neg ? -acc : acc };
    }
  }

  // decimal integer
  while (i < n) {
    const c = s.charCodeAt(i);
    if (c < 48 || c > 57) { // not '0'..'9'
      break;
    }
    acc = acc * 10 + (c - 48);
    any = true;
    i++;
  }

  i = skipSpace(s, i);
  if (!any || i !== n) {
    return { ok: false, value: 0 };
  }
  return { ok: true, value: neg ? -acc : acc };
}

function parseDecFloat(s: string): { ok: boolean; value: number } {
  const n = s.length;
  let i = 0;
  i = skipSpace(s, i);
  if (i >= n) {
    return { ok: false, value: 0 };
  }

  let sign = 1;
  const c0 = s.charCodeAt(i);
  if (c0 === 45) { // '-'
    sign = -1;
    i++;
  } else if (c0 === 43) { // '+'
    i++;
  }

  let intAny = false;
  let fracAny = false;
  let val = 0;

  // integer part
  while (i < n) {
    const c = s.charCodeAt(i);
    if (c < 48 || c > 57) { // not '0'..'9'
      break;
    }
    val = val * 10 + (c - 48);
    intAny = true;
    i++;
  }

  // fractional part
  if (i < n) {
    if (s.charCodeAt(i) === 46) { // '.'
      i++;
      let scale = 1;
      while (i < n) {
        const c = s.charCodeAt(i);
        if (c < 48 || c > 57) { // not '0'..'9'
          break;
        }
        scale *= 0.1;
        val += (c - 48) * scale;
        fracAny = true;
        i++;
      }
    }
  }

  // exponent
  let exp = 0;
  let hasExp = false;
  if (i < n) {
    const ec = s.charCodeAt(i);
    if (ec === 101 || ec === 69) { // 'e' or 'E'
      hasExp = true;
      i++;
      if (i >= n) {
        return { ok: false, value: 0 };
      }
      let expSign = 1;
      if (i < n) {
        const es = s.charCodeAt(i);
        if (es === 45) { // '-'
          expSign = -1;
          i++;
        } else if (es === 43) { // '+'
          i++;
        }
      }
      if (i >= n) {
        return { ok: false, value: 0 };
      }
      let anyExp = false;
      while (i < n) {
        const c = s.charCodeAt(i);
        if (c < 48 || c > 57) { // not '0'..'9'
          break;
        }
        exp = exp * 10 + (c - 48);
        anyExp = true;
        i++;
      }
      if (!anyExp) {
        return { ok: false, value: 0 };
      }
      exp *= expSign;
    }
  }

  if (!intAny && !fracAny) {
    return { ok: false, value: 0 };
  }

  i = skipSpace(s, i);
  if (i !== n) {
    return { ok: false, value: 0 };
  }

  const result = sign * (hasExp ? val * Math.pow(10, exp) : val);
  return { ok: true, value: result };
}

function parseHexFloat(s: string): { ok: boolean; value: number } {
  const n = s.length;
  let i = 0;
  i = skipSpace(s, i);
  if (i >= n) {
    return { ok: false, value: 0 };
  }

  let sign = 1;
  const c0 = s.charCodeAt(i);
  if (c0 === 45) { // '-'
    sign = -1;
    i++;
  } else if (c0 === 43) { // '+'
    i++;
  }

  if (!(i + 1 < n && s.charCodeAt(i) === 48)) { // '0'
    return { ok: false, value: 0 };
  }
  const x = s.charCodeAt(i + 1);
  if (!(x === 120 || x === 88)) { // 'x' or 'X'
    return { ok: false, value: 0 };
  }
  i += 2;

  let intVal = 0;
  let fracVal = 0;
  let fracScale = 1;
  let anyHex = false;
  let sawDot = false;

  // integer hex digits
  while (i < n) {
    const c = s.charCodeAt(i);
    let d = -1;
    if (c >= 48 && c <= 57) { // '0'..'9'
      d = c - 48;
    } else if (c >= 65 && c <= 70) { // 'A'..'F'
      d = 10 + (c - 65);
    } else if (c >= 97 && c <= 102) { // 'a'..'f'
      d = 10 + (c - 97);
    } else {
      d = -1;
    }
    if (d < 0) {
      break;
    }
    intVal = intVal * 16 + d;
    anyHex = true;
    i++;
  }

  // optional fractional part
  if (i < n) {
    if (s.charCodeAt(i) === 46) { // '.'
      sawDot = true;
      i++;
      while (i < n) {
        const c = s.charCodeAt(i);
        let d = -1;
        if (c >= 48 && c <= 57) { // '0'..'9'
          d = c - 48;
        } else if (c >= 65 && c <= 70) { // 'A'..'F'
          d = 10 + (c - 65);
        } else if (c >= 97 && c <= 102) { // 'a'..'f'
          d = 10 + (c - 97);
        } else {
          d = -1;
        }
        if (d < 0) {
          break;
        }
        fracScale *= 16;
        fracVal = fracVal * 16 + d;
        anyHex = true;
        i++;
      }
    }
  }

  // exponent:
  // - required if there is no dot (so `0x10` stays an integer),
  // - optional if there was a dot (`0x1.2` means `0x1.2p0`).
  let expSign = 1;
  let exp = 0;

  if (i < n) {
    const ec = s.charCodeAt(i);
    if (ec === 112 || ec === 80) { // 'p' or 'P'
      i++;
      if (i < n) {
        const sc = s.charCodeAt(i);
        if (sc === 45) { // '-'
          expSign = -1;
          i++;
        } else if (sc === 43) { // '+'
          i++;
        }
      }
      if (i >= n) {
        return { ok: false, value: 0 };
      }

      let anyExp = false;
      while (i < n) {
        const c = s.charCodeAt(i);
        if (c < 48 || c > 57) { // not '0'..'9'
          break;
        }
        exp = exp * 10 + (c - 48);
        anyExp = true;
        i++;
      }
      if (!anyHex || !anyExp) {
        return { ok: false, value: 0 };
      }
    } else {
      // no exponent marker
      if (!sawDot) {
        // without dot we must reject so parseInt can claim it as integer
        return { ok: false, value: 0 };
      }
      if (!anyHex) {
        return { ok: false, value: 0 };
      }
      expSign = 1;
      exp = 0; // implicit p0
    }
  } else {
    // end of string
    if (!sawDot) {
      return { ok: false, value: 0 };
    }
    if (!anyHex) {
      return { ok: false, value: 0 };
    }
    expSign = 1;
    exp = 0;
  }

  i = skipSpace(s, i);
  if (i !== n) {
    return { ok: false, value: 0 };
  }

  const frac = fracVal === 0 ? 0 : (fracVal / fracScale);
  const result = sign * (intVal + frac) * Math.pow(2, expSign * exp);
  return { ok: true, value: result };
}

// Detailed string conversion that returns numeric type, returns null
// on failure, integer -0 is canonicalized to +0
export function luaToNumberDetailed(
  s: string,
  base?: number,
): { value: number; numericType: NumericType } | null {
  if (base !== undefined) {
    // Base range errors are handled by the stdlib `tonumber` builtin,
    // to match Lua's "bad argument #2 ..." error message.
    if (!(typeof base === "number" && base >= 2 && base <= 36)) {
      return null;
    }

    const parsed = parseIntWithBase(s, base);
    if (parsed.ok) {
      const v = parsed.value;
      return { value: v === 0 ? 0 : v, numericType: "int" };
    }
    return null;
  }

  {
    const parsed = parseInt(s);
    if (parsed.ok) {
      const v = parsed.value;
      return { value: v === 0 ? 0 : v, numericType: "int" };
    }
  }

  {
    const parsed = parseHexFloat(s);
    if (parsed.ok) {
      return { value: parsed.value, numericType: "float" };
    }
  }

  {
    const parsed = parseDecFloat(s);
    if (parsed.ok) {
      return { value: parsed.value, numericType: "float" };
    }
  }

  return null;
}

// Space Lua `tonumber` returning only the numeric value or null
export function luaToNumber(s: unknown, base?: number): number | null {
  if (typeof s === "number") {
    return s;
  }
  // Tagged float: { value: number, isFloat: true }
  if (s !== null && typeof s === "object" && (s as any).isFloat === true) {
    return (s as any).value;
  }
  if (typeof s !== "string") {
    return null;
  }
  const detailed = luaToNumberDetailed(s, base);
  return detailed ? detailed.value : null;
}


================================================
FILE: client/space_lua/tonumber_test.lua
================================================
local function assertEquals(a, b, message)
    if a ~= b then
        error('Assertion failed: ' ..
        tostring(a) .. ' is not equal to ' .. tostring(b) .. (message and (' | ' .. message) or ''))
    end
end

local function assertThrows(msg_substr, fn)
    local ok, err = pcall(fn)

    if ok then
        error('Assertion failed: expected error containing "'
        .. msg_substr .. '"')
    end

    if type(err) ~= 'string' then
        err = tostring(err)
    end

    if not string.find(err, msg_substr, 1, true) then
        error('Assertion failed: expected error message to contain "'
        .. msg_substr .. '", got: "' .. err .. '"')
    end
end

-- helper for whitespace handling tests
local chr = string.char
-- no base: whitespace handling
assertEquals(tonumber(chr(32, 9, 10, 11, 12, 13) .. '42'), 42)
assertEquals(tonumber('42' .. chr(32, 9, 10, 11, 12, 13)), 42)

-- converting numbers that are already numbers
assertEquals(tonumber(0), 0)
assertEquals(tonumber(10), 10)

-- no base: zeroes
assertEquals(tonumber('-0'), 0)
assertEquals(tonumber('0x0p0'), 0.0)
assertEquals(tonumber('0x.0p10'), 0.0)

-- no base: valid decimal integers and floats
assertEquals(tonumber('123'), 123)
assertEquals(tonumber('+123'), 123)
assertEquals(tonumber('-123'), -123)
assertEquals(tonumber('0'), 0)
assertEquals(tonumber('000123'), 123)
assertEquals(tonumber('123.45'), 123.45)
assertEquals(tonumber('5.'), 5.0)
assertEquals(tonumber('.5'), 0.5)
assertEquals(tonumber('5.e1'), 50.0)
assertEquals(tonumber('.5e2'), 50.0)
assertEquals(tonumber('-.5e2'), -50.0)
assertEquals(tonumber('1e-2'), 0.01)
assertEquals(tonumber('1E+2'), 100.0)

-- no base: valid hexadecimal integers (no exponent)
assertEquals(tonumber('0x10'), 16)
assertEquals(tonumber('0X10'), 16)
assertEquals(tonumber('+0x10'), 16)
assertEquals(tonumber('-0x10'), -16)
assertEquals(tonumber(' 0x0 '), 0)
assertEquals(tonumber('0x000F'), 15)
assertEquals(tonumber('0XdeadBEEF'), 3735928559)

-- no base: valid hexadecimal floats (with exponent)
assertEquals(tonumber('0x1p0'), 1.0)
assertEquals(tonumber('0x1p+0'), 1.0)
assertEquals(tonumber('0x1p-2'), 0.25)
assertEquals(tonumber('0x1.0p-2'), 0.25)
assertEquals(tonumber('0x1.8p1'), 3.0)
assertEquals(tonumber('0x.8p0'), 0.5)
assertEquals(tonumber('0x.Fp4'), 15.0)
assertEquals(tonumber('0XA.P-1'), 5.0)
assertEquals(tonumber('  0x10P0  '), 16.0)
assertEquals(tonumber('-0x10p0'), -16.0)
assertEquals(tonumber('0x10.2p0'), 16.125)
assertEquals(tonumber('0x10.3P-1'), 8.09375)
assertEquals(tonumber('-0X10.3P-1'), -8.09375)
assertEquals(tonumber('0x1.2'), 1.125)

-- no base: invalid
assertEquals(tonumber(''), nil)
assertEquals(tonumber(' '), nil)
assertEquals(tonumber('abc'), nil)
assertEquals(tonumber('12.34.56'), nil)
assertEquals(tonumber('e10'), nil)
assertEquals(tonumber('.e1'), nil)
assertEquals(tonumber('1e'), nil)
assertEquals(tonumber('1e+'), nil)
assertEquals(tonumber('0x'), nil)
assertEquals(tonumber('0xG'), nil)
assertEquals(tonumber('+ 10'), nil)
assertEquals(tonumber('- 10'), nil)
assertEquals(tonumber('123x'), nil)

-- with base: whitespace handling
assertEquals(tonumber(chr(32, 9, 10, 11, 12, 13) .. 'FF', 16), 255)
assertEquals(tonumber('FF' .. chr(32, 9, 10, 11, 12, 13), 16), 255)

-- with base: valid
assertEquals(tonumber('1010', 2), 10)
assertEquals(tonumber('+1011', 2), 11)
assertEquals(tonumber('ff', 16), 255)
assertEquals(tonumber('FF', 16), 255)
assertEquals(tonumber('fF', 16), 255)
assertEquals(tonumber('377', 8), 255)
assertEquals(tonumber('z', 36), 35)
assertEquals(tonumber('Aa', 11), 120)
assertEquals(tonumber('  ff  ', 16), 255)
assertEquals(tonumber('1010', 10), 1010)

-- with base: invalid
assertThrows("bad argument #2 to 'tonumber' (base out of range)",
    function()
        return tonumber('1010', 1)
    end
)

assertThrows("bad argument #2 to 'tonumber' (base out of range)",
    function()
        return tonumber('1010', 37)
    end
)

assertEquals(tonumber('FF', 10), nil)
assertEquals(tonumber('8', 8), nil)
assertEquals(tonumber('2', 2), nil)
assertEquals(tonumber('', 16), nil)
assertEquals(tonumber(' ', 16), nil)
assertEquals(tonumber('+', 16), nil)
assertEquals(tonumber('-', 16), nil)
assertEquals(tonumber('+ FF', 16), nil)
assertEquals(tonumber('1_0', 10), nil)
assertEquals(tonumber('123x', 10), nil)


================================================
FILE: client/space_lua/truthiness_test.lua
================================================
local function assertEquals(actual, expected, msg)
  if actual ~= expected then
    error('assertEquals failed: ' .. msg)
  end
end

local function assertTrue(cond, msg)
  if not cond then
    error('assertTrue failed: ' .. msg)
  end
end

local function assertFalse(cond, msg)
  if cond then
    error('assertFalse failed: ' .. msg)
  end
end

-- Helpers for multi-return tests

local function ret_nil_one()
  return nil, 1
end

local function ret_false_one()
  return false, 1
end

local function ret_zero_one()
  return 0, 1
end

local function ret_emptystr_one()
  return "", 1
end

local function ret_tbl_one()
  return {}, 1
end

local function ret_one_two()
  return 1, 2
end

-- 1. Basic boolean/nil

assertFalse(false, "false must be falsey")
assertTrue(true, "true must be truthy")
assertFalse(nil, "nil must be falsey")

-- 2. Numbers: all numbers are truthy

assertTrue(0, "0 must be truthy")
assertTrue(1, "1 must be truthy")
assertTrue(-1, "-1 must be truthy")
assertTrue(0.0, "0.0 must be truthy")
assertTrue(-0.0, "-0.0 must be truthy")

-- 3. Strings: empty and non-empty strings are truthy

assertTrue("", "empty string must be truthy")
assertTrue("0", '"0" must be truthy')
assertTrue("a", '"a" must be truthy')

-- 4. Tables: empty and non-empty tables are truthy

assertTrue({}, "empty table must be truthy")
assertTrue({1}, "non-empty table must be truthy")

-- 5. Functions are truthy

local fn = function() end

assertTrue(fn, "function must be truthy")
assertTrue(function() end, "function must be truthy")

-- 6. Multi-return: only the first value determines truthiness

if ret_nil_one() then
  error("ret_nil_one() must be falsey (first result nil)")
end

if ret_false_one() then
  error("ret_false_one() must be falsey (first result false)")
end

if not ret_zero_one() then
  error("ret_zero_one() must be truthy (first result 0)")
end

if not ret_emptystr_one() then
  error("ret_emptystr_one() must be truthy (first result empty string)")
end

if not ret_tbl_one() then
  error("ret_tbl_one() must be truthy (first result table)")
end

-- 7. `and`/`or` short-circuiting and value returns

-- 7.1. `and` returns first falsey value, otherwise last value

assertEquals((true and 2), 2, "and: true and 2 -> 2")
assertEquals((false and 2), false, "and: false and 2 -> false")
assertEquals((nil and 2), nil, "and: nil and 2 -> nil")
assertEquals((0 and "x"), "x", "and: 0 and 'x' -> 'x' (0 is truthy)")

-- 7.2. `or` returns first truthy value, otherwise last value

assertEquals((false or 3), 3, "or: false or 3 -> 3")
assertEquals((nil or 3), 3, "or: nil or 3 -> 3")
assertEquals((0 or 3), 0, "or: 0 or 3 -> 0 (0 is truthy)")
assertEquals(("" or "x"), "", "or: '' or 'x' -> '' ('' is truthy)")

-- 7.3. Multi-return: operand evaluation yields first value

assertEquals((true and ret_one_two()), 1,
  "and: true and (1,2) yields first result 1")

assertEquals((false or ret_one_two()), 1,
  "or: false or (1,2) yields first result 1")

-- 8. Litmus tests involving tables and `and`/`or`

-- {} and 2 or 3 -> 2
-- {} or 2 and 3 -> {} (because {} is truthy, or returns left operand)

local t = {}

assertEquals((t and 2 or 3), 2, "{} and 2 or 3 must be 2")

-- `v` should be the same table reference `t`

local v = (t or 2 and 3)

assertTrue(type(v) == "table",
  "{} or 2 and 3 must return the table (truthy left operand)")

-- changing `t` reflects in `v` (same reference)

t.key = "ok"

assertEquals(v.key, "ok",
  "{} or 2 and 3 returns original table reference")

-- 9. `if`/`while` with truthiness

local ran_if_truthy = false

if {} then
  ran_if_truthy = true
end

assertTrue(ran_if_truthy, "if {} must run branch")

local ran_if_falsey = false

if nil then
  ran_if_falsey = true
end

assertFalse(ran_if_falsey, "if nil must not run branch")

-- `while` must stop only on `false`/`nil`

local n = 0

local function next_or_nil()
  n = n + 1

  if n <= 2 then
    return 0
    -- 0 truthy -> loop two times
  end

  return nil -- stop
end

local count = 0

while next_or_nil() do
  count = count + 1
end

assertEquals(count, 2,
  "while should loop while condition is truthy")

-- 10. Short-circuit evaluation (no RHS evaluation when not needed)

local side = 0

local function bump()
  side = side + 1
  return true
end

-- For `true or bump()` RHS must not be evaluated

local s0 = side
local _ = (true or bump())

assertEquals(side, s0, "or short-circuit must avoid RHS when LHS truthy")

-- For `false and bump()` RHS must not be evaluated

local _ = (false and bump())

assertEquals(side, s0, "and short-circuit must avoid RHS when LHS falsey")

-- 11. Multi-return short-circuit

local function falsy_pair()
  return nil, "x"
end

local function truthy_pair()
  return 1, "x"
end

-- `or`: pick RHS only when LHS falsey (first result)

local s1 = (falsy_pair() or 5)

assertEquals(s1, 5, "or should see falsy first result and evaluate RHS")

local s2 = (truthy_pair() or 5)

assertEquals(s2, 1, "or should return first result of LHS when truthy")

-- 12. Only the last expression expands

local function ret() return "A", "B" end
local function a() return "A1", "A2" end
local function b() return "B1", "B2" end
local function c() return "C1", "C2" end

assertEquals(string.format("%s-%s", ret()), "A-B",
  "last-only: single arg expands")

assertEquals(string.format("%s-%s", ret(), "Z"), "A-Z",
  "last-only: earlier arg is single")

assertEquals(string.format("%s-%s-%s-%s", a(), b(), c()), "A1-B1-C1-C2",
  "last-only: only last expands, earlier args single")

-- 13. Only false and `nil` are falsey

assertFalse(not 0, "not 0 must be false")
assertFalse(not -0.0, "not -0.0 must be false")
assertFalse(not "", "not '' must be false")
assertFalse(not {}, "not {} must be false")

assertTrue(not nil, "not nil must be true")
assertTrue(not false, "not false must be true")


================================================
FILE: client/space_lua/util.test.ts
================================================
import { evalPromiseValues } from "./util.ts";
import { assertEquals } from "@std/assert/equals";
import { assert } from "@std/assert";

Deno.test("Test promise helpers", async () => {
  const r = evalPromiseValues([1, 2, 3]);
  // should return the same array not as a promise
  assertEquals(r, [1, 2, 3]);
  const asyncR = evalPromiseValues([
    new Promise((resolve) => {
      setTimeout(() => {
        resolve(1);
      }, 5);
    }),
    Promise.resolve(2),
    3,
  ]);
  // should return a promise
  assert(asyncR instanceof Promise);
  assertEquals(await asyncR, [1, 2, 3]);
});


================================================
FILE: client/space_lua/util.ts
================================================
import { isPromise } from "./rp.ts";

export function evalPromiseValues(vals: any[]): Promise<any[]> | any[] {
  const promises = [];
  const promiseResults = new Array(vals.length);
  for (let i = 0; i < vals.length; i++) {
    if (isPromise(vals[i])) {
      promises.push(
        (vals[i] as Promise<any>).then((v: any) => promiseResults[i] = v),
      );
    } else {
      promiseResults[i] = vals[i];
    }
  }
  if (promises.length === 0) {
    return promiseResults;
  } else {
    return Promise.all(promises).then(() => promiseResults);
  }
}

/**
 * return the mid value among x, y, and z
 * @param x
 * @param y
 * @param z
 * @param compare
 * @returns {Promise.<*>}
 */
async function getPivot(
  x: any,
  y: any,
  z: any,
  compare: (a: any, b: any) => Promise<number>,
) {
  if (await compare(x, y) < 0) {
    if (await compare(y, z) < 0) {
      return y;
    } else if (await compare(z, x) < 0) {
      return x;
    } else {
      return z;
    }
  } else if (await compare(y, z) > 0) {
    return y;
  } else if (await compare(z, x) > 0) {
    return x;
  } else {
    return z;
  }
}

/**
 * asynchronous quick sort
 * @param arr array to sort
 * @param compare asynchronous comparing function
 * @param left index where the range of elements to be sorted starts
 * @param right index where the range of elements to be sorted ends
 * @returns {Promise.<*>}
 */
export async function asyncQuickSort(
  arr: any[],
  compare: (a: any, b: any) => Promise<number>,
  left = 0,
  right = arr.length - 1,
) {
  if (left < right) {
    let i = left, j = right, tmp;
    const pivot = await getPivot(
      arr[i],
      arr[i + Math.floor((j - i) / 2)],
      arr[j],
      compare,
    );
    while (true) {
      while (await compare(arr[i], pivot) < 0) {
        i++;
      }
      while (await compare(pivot, arr[j]) < 0) {
        j--;
      }
      if (i >= j) {
        break;
      }
      tmp = arr[i];
      arr[i] = arr[j];
      arr[j] = tmp;

      i++;
      j--;
    }
    await asyncQuickSort(arr, compare, left, i - 1);
    await asyncQuickSort(arr, compare, j + 1, right);
  }
  return arr;
}


================================================
FILE: client/space_lua_api.ts
================================================
import { luaBuildStandardEnv } from "./space_lua/stdlib.ts";
import {
  LuaBuiltinFunction,
  LuaEnv,
  LuaNativeJSFunction,
  type LuaRuntimeError,
  LuaStackFrame,
  LuaTable,
} from "./space_lua/runtime.ts";
import type { System } from "./plugos/system.ts";
import { resolveASTReference } from "./space_lua.ts";

export function buildLuaEnv(system: System<any>) {
  const env = new LuaEnv(luaBuildStandardEnv());

  // Expose all syscalls to Lua
  exposeSyscalls(env, system);

  return env;
}

/**
 * Exposes all registered syscalls to Lua, automatically converting Lua arguments to JS values
 * If a syscall is prefixed with `lua:` it exposes the syscall as a native Lua function, skipping the argument conversion0
 */
export function exposeSyscalls(env: LuaEnv, system: System<any>) {
  // Expose all syscalls to Lua
  const nativeFs = new LuaStackFrame(env, null);
  for (const syscallName of system.registeredSyscalls.keys()) {
    const isLuaNativeSyscall = syscallName.startsWith("lua:");
    let cleanSyscallName = syscallName;
    if (isLuaNativeSyscall) {
      cleanSyscallName = syscallName.slice("lua:".length);
    }
    const [ns, fn] = cleanSyscallName.split(".");
    if (!env.has(ns)) {
      env.set(ns, new LuaTable(), nativeFs);
    }
    const luaFn = isLuaNativeSyscall
      ? new LuaBuiltinFunction((_sf, ...args) => {
        return system.localSyscall(syscallName, args);
      })
      : new LuaNativeJSFunction((...args) => {
        return system.localSyscall(syscallName, args);
      });
    env.get(ns, nativeFs).set(fn, luaFn, nativeFs);
  }
}

export async function buildThreadLocalEnv(
  system: System<any>,
  globalEnv: LuaEnv,
) {
  const tl = new LuaEnv();
  if (system.registeredSyscalls.has("editor.getCurrentPageMeta")) {
    const currentPageMeta = await system.localSyscall(
      "editor.getCurrentPageMeta",
      [],
    );
    if (currentPageMeta) {
      tl.setLocal("currentPage", currentPageMeta);
    } else {
      tl.setLocal("currentPage", {
        name: await system.localSyscall("editor.getCurrentPage", []),
      });
    }
  }
  tl.setLocal("_GLOBAL", globalEnv);
  return tl;
}

export async function handleLuaError(e: LuaRuntimeError, system: System<any>) {
  console.error(
    "Lua eval exception",
    e.message,
    e.sf?.astCtx,
  );
  if (e.sf?.astCtx && e.sf.astCtx.ref) {
    // We got an error and actually know where it came from, let's navigate there to help debugging
    await system.localSyscall(
      "editor.flashNotification",
      [
        `Lua error: ${e.message}`,
        "error",
      ],
    );

    const ref = resolveASTReference(e.sf.astCtx);
    if (!ref) return;

    await system.localSyscall(
      "editor.flashNotification",
      [
        `Navigating to the place in the code where this error occurred in ${ref.path}`,
        "info",
      ],
    );
    await system.localSyscall("editor.navigate", [ref]);
  }
}


================================================
FILE: client/spaces/checked_space_primitives.ts
================================================
import type { FileMeta } from "@silverbulletmd/silverbullet/type/index";
import type { SpacePrimitives } from "./space_primitives.ts";
import { isValidPath } from "@silverbulletmd/silverbullet/lib/ref";

/**
 * Adds checks for two things:
 * 1. Allowed path names
 * 2. Permissions
 */
export class CheckedSpacePrimitives implements SpacePrimitives {
  constructor(
    private wrapped: SpacePrimitives,
    private readOnly: boolean,
  ) {
  }

  async fetchFileList(): Promise<FileMeta[]> {
    return (await this.wrapped.fetchFileList()).filter(({ name }) =>
      this.isReadable(name)
    );
  }

  readFile(path: string): Promise<{ data: Uint8Array; meta: FileMeta }> {
    if (!this.isReadable(path)) {
      throw new Error("Couldn't write file, path isn't writable");
    }
    return this.wrapped.readFile(path);
  }

  getFileMeta(path: string, observing?: boolean): Promise<FileMeta> {
    if (!this.isReadable(path)) {
      throw new Error("Couldn't get file meta, path isn't writable");
    }
    return this.wrapped.getFileMeta(path, observing);
  }

  async writeFile(
    path: string,
    data: Uint8Array,
    meta?: FileMeta,
  ): Promise<FileMeta> {
    if (!await this.isWritable(path)) {
      throw new Error("Couldn't write file, path is not writable");
    }
    return this.wrapped.writeFile(path, data, meta);
  }

  async deleteFile(path: string): Promise<void> {
    // We allow deletion of paths we can't write to. This is for the case when
    // the user has an invalidly named file in their space and they need to
    // remove it/rename it
    if (!await this.isWritable(path)) {
      throw new Error("Couldn't delete file, path is not writable");
    }
    return this.wrapped.deleteFile(path);
  }

  private isReadable(path: string): boolean {
    return !path.startsWith(".");
  }

  private async isWritable(path: string): Promise<boolean> {
    if (this.readOnly) {
      return false;
    }
    try {
      const fileMeta = await this.getFileMeta(path);
      if (fileMeta.perm === "ro") {
        return false;
      }
    } catch {
      // Assumption, not found, that's ok
    }
    return this.isReadable(path) && isValidPath(path);
  }
}


================================================
FILE: client/spaces/constants.ts
================================================
export const stdLibPrefix = "Library/Std/";
export const fsEndpoint = "/.fs";


================================================
FILE: client/spaces/datastore_space_primitives.test.ts
================================================
import "fake-indexeddb/auto";
import { IndexedDBKvPrimitives } from "../data/indexeddb_kv_primitives.ts";
import { DataStoreSpacePrimitives } from "./datastore_space_primitives.ts";
import { testSpacePrimitives } from "./space_primitives.test.ts";

Deno.test("DataStoreSpacePrimitives", {
  sanitizeResources: false,
  sanitizeOps: false,
}, async () => {
  const db = new IndexedDBKvPrimitives("test");
  await db.init();

  const space = new DataStoreSpacePrimitives(db);
  await testSpacePrimitives(space);
  db.close();
});


================================================
FILE: client/spaces/datastore_space_primitives.ts
================================================
import type { SpacePrimitives } from "./space_primitives.ts";
import { mime } from "mimetypes";
import type { FileMeta } from "@silverbulletmd/silverbullet/type/index";
import { notFoundError } from "@silverbulletmd/silverbullet/constants";
import type { KvPrimitives } from "../data/kv_primitives.ts";

const filesMetaPrefix = ["meta"];
const filesContentPrefix = ["content"];

export class DataStoreSpacePrimitives implements SpacePrimitives {
  constructor(
    private kv: KvPrimitives,
  ) {
  }

  async fetchFileList(): Promise<FileMeta[]> {
    const fileMetas: FileMeta[] = [];
    // Iterate over all keys with the fileMetaPrefix
    for await (const meta of this.kv.query({ prefix: filesMetaPrefix })) {
      fileMetas.push(this.cleanFileMeta(meta.value as FileMeta));
    }
    return fileMetas;
  }

  async readFile(
    path: string,
  ): Promise<{ data: Uint8Array; meta: FileMeta }> {
    // Fetch content and metadata in parallel
    const [fileMeta, fileContent] = await this.kv.batchGet([
      [
        ...filesMetaPrefix,
        path,
      ],
      [
        ...filesContentPrefix,
        path,
      ],
    ]);
    if (!fileMeta) {
      throw notFoundError;
    }

    return {
      meta: this.cleanFileMeta(fileMeta),
      data: fileContent,
    };
  }

  async writeFile(
    path: string,
    data: Uint8Array,
    suggestedMeta?: FileMeta,
  ): Promise<FileMeta> {
    let meta: FileMeta | undefined;
    try {
      // Build off of the existing file meta, if file exists
      meta = await this.getFileMeta(path);
    } catch {
      // Not found, that's fine
    }
    if (!meta) {
      // No existing meta data, let's set some defaults
      meta = {
        name: path,
        created: suggestedMeta?.created || Date.now(),
        perm: "rw",
        contentType: mime.getType(path) || "application/octet-stream",
        // Overwritten in a sec
        lastModified: 0,
        size: 0,
      };
    }
    meta.lastModified = suggestedMeta?.lastModified || Date.now();
    meta.size = data.byteLength;
    if (suggestedMeta?.perm) {
      meta.perm = suggestedMeta.perm;
    }

    // Write metadata and content in same transaction
    await this.kv.batchSet([
      {
        key: [...filesMetaPrefix, path],
        value: meta,
      },
      {
        key: [...filesContentPrefix, path],
        value: data,
      },
    ]);
    return meta;
  }

  async deleteFile(path: string): Promise<void> {
    const [fileMeta] = await this.kv.batchGet([
      [...filesMetaPrefix, path],
    ]);
    if (!fileMeta) {
      throw notFoundError;
    }
    return this.kv.batchDelete([
      [...filesMetaPrefix, path],
      [...filesContentPrefix, path],
    ]);
  }

  async getFileMeta(path: string, _observing?: boolean): Promise<FileMeta> {
    const [fileMeta] = await this.kv.batchGet([[...filesMetaPrefix, path]]);
    if (!fileMeta) {
      throw notFoundError;
    }
    return this.cleanFileMeta(fileMeta);
  }

  cleanFileMeta(fileMeta: FileMeta): FileMeta {
    if (!fileMeta.created) {
      fileMeta.created = fileMeta.lastModified;
    }
    return fileMeta;
  }
}


================================================
FILE: client/spaces/evented_space_primitives.ts
================================================
import type { EventHook } from "../plugos/hooks/event.ts";

import type { SpacePrimitives } from "./space_primitives.ts";
import type { FileMeta } from "@silverbulletmd/silverbullet/type/index";
import type { DataStore } from "../data/datastore.ts";

/**
 * Events exposed:
 * - file:changed (string, oldHash, newHash)
 * - file:deleted (string)
 * - file:listed (FileMeta[])
 * - file:initial: triggered in case of an initially empty snapshot, after the first set of events has gone out
 * - page:saved (string, FileMeta)
 * - page:deleted (string)
 */
export class EventedSpacePrimitives implements SpacePrimitives {
  // Various operations may be going on at the same time, and we don't want to trigger events unnessarily.
  // Therefore, we use this variable to track if any operation is in flight, and if so, we skip event triggering.
  // This is ok, because any event will be picked up in a following iteration.
  operationInProgress = false;

  private enabled = false;

  // Snapshot state management
  private spaceSnapshot: Record<string, number> = {};
  private snapshotChanged = false;

  constructor(
    private wrapped: SpacePrimitives,
    private eventHook: EventHook,
    private ds: DataStore,
    private snapshotKey = ["$spaceSnapshot"],
  ) {
  }

  async enable() {
    console.log("Loading snapshot and enabling events");
    this.spaceSnapshot = (await this.ds.get(this.snapshotKey)) || {};
    this.snapshotChanged = false;
    this.enabled = true;
  }

  public isSnapshotEmpty() {
    return Object.keys(this.spaceSnapshot).length === 0;
  }

  public getSnapshot() {
    return this.spaceSnapshot;
  }

  private updateInSnapshot(key: string, value: number) {
    const oldValue = this.spaceSnapshot[key];
    this.spaceSnapshot[key] = value;
    this.snapshotChanged = this.snapshotChanged || oldValue !== value;
  }

  private deleteFromSnapshot(key: string) {
    delete this.spaceSnapshot[key];
    this.snapshotChanged = true;
  }

  private async saveSnapshot() {
    if (this.enabled && this.snapshotChanged) {
      await this.ds.set(this.snapshotKey, this.spaceSnapshot);
      this.snapshotChanged = false;
    }
  }

  dispatchEvent(name: string, ...args: any[]): Promise<any[]> {
    if (!this.enabled) {
      return Promise.resolve([]);
    }
    // console.log("Evented space, dispatching", name, args);
    return this.eventHook.dispatchEvent(name, ...args);
  }

  async fetchFileList(): Promise<FileMeta[]> {
    if (this.operationInProgress) {
      // Some other operation (read, write, list, meta) is already going on
      // this will likely trigger events, so let's not worry about any of that and avoid race condition and inconsistent data.
      console.info(
        "alreadyFetching is on, skipping even triggering for fetchFileList.",
      );
      return this.wrapped.fetchFileList();
    }
    if (!this.enabled) {
      return this.wrapped.fetchFileList();
    }
    // console.log("Fetching file list");
    // Fetching mutex
    this.operationInProgress = true;
    try {
      // Fetch the list
      const newFileList = await this.wrapped.fetchFileList();

      // Now we have the list, let's compare it to the snapshot and trigger events appropriately
      const deletedFiles = new Set<string>(Object.keys(this.spaceSnapshot));
      for (const meta of newFileList) {
        const oldHash = this.spaceSnapshot[meta.name];
        const newHash = meta.lastModified;
        // Update in snapshot
        this.updateInSnapshot(meta.name, newHash);

        // Check what happened to the file
        if (
          (
            // New file scenario
            !oldHash
          ) || (
            // Changed file scenario
            oldHash &&
            oldHash !== newHash
          )
        ) {
          console.log(
            "Detected file change during listing",
            meta.name,
            oldHash,
            newHash,
          );
          await this.dispatchEvent(
            "file:changed",
            meta.name,
            oldHash,
            newHash,
          );
        }
        // Page found, not deleted
        deletedFiles.delete(meta.name);
      }

      for (const deletedFile of deletedFiles) {
        this.deleteFromSnapshot(deletedFile);
        await this.dispatchEvent("file:deleted", deletedFile);

        if (deletedFile.endsWith(".md")) {
          const pageName = deletedFile.substring(0, deletedFile.length - 3);
          await this.dispatchEvent("page:deleted", pageName);
        }
      }

      await this.dispatchEvent("file:listed", newFileList);
      return newFileList;
    } finally {
      await this.saveSnapshot();
      this.operationInProgress = false;
    }
  }

  async readFile(
    path: string,
  ): Promise<{ data: Uint8Array; meta: FileMeta }> {
    if (!this.enabled) {
      return this.wrapped.readFile(path);
    }
    try {
      // Fetching mutex
      const wasFetching = this.operationInProgress;
      this.operationInProgress = true;

      // Fetch file
      const data = await this.wrapped.readFile(path);
      if (!wasFetching) {
        await this.triggerEventsAndCache(path, data.meta.lastModified);
      }
      return data;
    } finally {
      this.operationInProgress = false;
    }
  }

  async writeFile(
    path: string,
    data: Uint8Array,
    meta?: FileMeta,
  ): Promise<FileMeta> {
    if (!this.enabled) {
      return this.wrapped.writeFile(path, data, meta);
    }

    const wasFetching = this.operationInProgress;
    this.operationInProgress = true;
    try {
      const newMeta = await this.wrapped.writeFile(
        path,
        data,
        meta,
      );
      if (!wasFetching) {
        await this.triggerEventsAndCache(path, newMeta.lastModified);
      }
      if (path.endsWith(".md")) {
        const pageName = path.substring(0, path.length - 3);
        await this.dispatchEvent("page:saved", pageName, newMeta);
      }

      return newMeta;
    } finally {
      this.operationInProgress = false;
    }
  }

  /**
   * @param name
   * @param newHash
   * @return whether something changed in the snapshot
   */
  async triggerEventsAndCache(name: string, newHash: number) {
    const oldHash = this.spaceSnapshot[name];
    // if (oldHash && newHash && oldHash !== newHash) {
    if (oldHash !== newHash) {
      // Page changed since last cached metadata, trigger event
      await this.dispatchEvent("file:changed", name, oldHash, newHash);
    }
    this.updateInSnapshot(name, newHash);
    await this.saveSnapshot();
  }

  async getFileMeta(path: string, observing?: boolean): Promise<FileMeta> {
    if (!this.enabled) {
      return this.wrapped.getFileMeta(path, observing);
    }

    try {
      const wasFetching = this.operationInProgress;
      this.operationInProgress = true;
      const newMeta = await this.wrapped.getFileMeta(path, observing);
      if (!wasFetching) {
        await this.triggerEventsAndCache(path, newMeta.lastModified);
      }
      return newMeta;
    } finally {
      this.operationInProgress = false;
    }
  }

  async deleteFile(path: string): Promise<void> {
    if (!this.enabled) {
      return this.wrapped.deleteFile(path);
    }

    try {
      this.operationInProgress = true;
      if (path.endsWith(".md")) {
        const pageName = path.substring(0, path.length - 3);
        await this.dispatchEvent("page:deleted", pageName);
      }
      // await this.getPageMeta(path); // Check if page exists, if not throws Error
      await this.wrapped.deleteFile(path);
      this.deleteFromSnapshot(path);
      await this.dispatchEvent("file:deleted", path);
    } finally {
      await this.saveSnapshot();
      this.operationInProgress = false;
    }
  }
}


================================================
FILE: client/spaces/filtered_space_primitives.ts
================================================
import type { SpacePrimitives } from "./space_primitives.ts";
import type { FileMeta } from "../../plug-api/types/index.ts";

export class FilteredSpacePrimitives implements SpacePrimitives {
  constructor(
    private wrapped: SpacePrimitives,
    private filterFn: (name: FileMeta) => boolean,
    private onFetchList?: () => Promise<void>,
  ) {
  }

  async fetchFileList(): Promise<FileMeta[]> {
    if (this.onFetchList) {
      await this.onFetchList();
    }
    return (await this.wrapped.fetchFileList()).filter(this.filterFn);
  }

  readFile(path: string): Promise<{ data: Uint8Array; meta: FileMeta }> {
    return this.wrapped.readFile(path);
  }

  getFileMeta(path: string, observing?: boolean): Promise<FileMeta> {
    return this.wrapped.getFileMeta(path, observing);
  }

  writeFile(
    path: string,
    data: Uint8Array,
    meta?: FileMeta,
  ): Promise<FileMeta> {
    return this.wrapped.writeFile(path, data, meta);
  }

  deleteFile(path: string): Promise<void> {
    return this.wrapped.deleteFile(path);
  }
}


================================================
FILE: client/spaces/http_space_primitives.ts
================================================
import type { SpacePrimitives } from "./space_primitives.ts";
import { encodePageURI } from "@silverbulletmd/silverbullet/lib/ref";
import {
  flushCachesAndUnregisterServiceWorker,
} from "../../client/service_worker/util.ts";
import type { FileMeta } from "@silverbulletmd/silverbullet/type/index";
import {
  notFoundError,
  offlineError,
  offlineStatusCodes,
  pingTimeout,
  wrongSpacePathError,
} from "@silverbulletmd/silverbullet/constants";
import { headersToFileMeta } from "../lib/util.ts";

const defaultFetchTimeout = 30000; // 30 seconds

export class HttpSpacePrimitives implements SpacePrimitives {
  constructor(
    readonly url: string,
    readonly expectedSpacePath: string,
    private authErrorCallback: (message: string, ...args: any[]) => void,
    private bearerToken?: string,
  ) {
  }

  public async authenticatedFetch(
    url: string,
    options: RequestInit,
    fetchTimeout: number = defaultFetchTimeout,
  ): Promise<Response> {
    if (!options.headers) {
      options.headers = {};
    }
    options.headers = {
      ...options.headers,
      "X-Sync-Mode": "true",
    };
    if (this.bearerToken) {
      options.headers = {
        ...options.headers,
        "Authorization": `Bearer ${this.bearerToken}`,
      };
    }

    try {
      options.signal = AbortSignal.timeout(fetchTimeout);
      options.redirect = "manual";
      const result = await fetch(url, options);
      if (result.status in offlineStatusCodes) {
        throw offlineError;
      }
      const redirectHeader = result.headers.get("location");

      if (result.type === "opaqueredirect" && !redirectHeader) {
        console.log("Result", result, "for", url, JSON.stringify(options));
        // This is a scenario where the server sent a redirect, but this redirect is not visible to the client, likely due to CORS
        // The best we can do is to reload the page and hope that the server will redirect us to the correct location
        this.authErrorCallback(
          "You are not authenticated, reloading to reauthenticate",
          "reload",
        );
        // console.log("Unregistering service workers", redirectHeader);
        // await unregisterServiceWorkers();
        // location.reload();
        // Let's throw to avoid any further processing
        throw Error("Not authenticated");
      }

      // console.log("Got response", result.status, result.statusText, result.url);

      // Attempting to handle various authentication proxies
      if (result.status >= 300 && result.status < 400) {
        if (redirectHeader) {
          // Got a redirect
          this.authErrorCallback(
            "Received an authentication redirect",
            redirectHeader,
          );
          // location.href = redirectHeader;
          throw new Error("Redirected");
        } else {
          console.error("Got a redirect status but no location header", result);
        }
      }
      // Check for unauthorized status
      if (result.status === 401 || result.status === 403) {
        // If it came with a redirect header, we'll redirect to that URL
        if (redirectHeader) {
          console.log(
            "Received unauthorized status and got a redirect via the API so will redirect to URL",
            result.url,
          );
          this.authErrorCallback("You are not authenticated ", redirectHeader);
          // location.href = redirectHeader;
          throw new Error("Not authenticated");
        } else {
          // If not, let's reload
          this.authErrorCallback(
            "You are not authenticated, going to reload and hope that that kicks off authentication",
          );
          // location.reload();
          throw new Error("Not authenticated");
        }
      }
      return result;
    } catch (e: any) {
      // Errors when there is no internet connection:
      //
      // * Firefox: NetworkError when attempting to fetch resource (with SW and without)
      // * Safari (service worker enabled): FetchEvent.respondWith received an error: TypeError: Load failed
      // * Safari (no service worker): Load failed
      // * Chrome: Failed to fetch
      //
      // Common substrings: "fetch" "load failed"
      const errorMessage = e.message.toLowerCase();
      if (
        errorMessage.includes("fetch") ||
        errorMessage.includes("load failed") ||
        errorMessage.includes("unavailable")
      ) {
        console.error(
          "Got error fetching, throwing offline",
          url,
          e.message,
        );
        throw offlineError;
      }
      throw e;
    }
  }

  async fetchFileList(): Promise<FileMeta[]> {
    const resp = await this.authenticatedFetch(this.url, {
      method: "GET",
    });

    await this.validateSpacePathFromHeaders(resp);
    return resp.json();
  }

  /**
   * The /.fs file listing and /.ping endpoints both expose the currently exposed space path, if this doesn't match what the client expects, the client has to restart
   */
  async validateSpacePathFromHeaders(resp: Response) {
    if (
      resp.status === 200 &&
      this.expectedSpacePath &&
      resp.headers.get("X-Space-Path") &&
      resp.headers.get("X-Space-Path") !== this.expectedSpacePath
    ) {
      console.log("Expected space path", this.expectedSpacePath);
      console.log("Got space path", resp.headers.get("X-Space-Path"));
      await flushCachesAndUnregisterServiceWorker();
      this.authErrorCallback(
        wrongSpacePathError.message,
        "reload",
      );
    }
  }

  async readFile(
    path: string,
  ): Promise<{ data: Uint8Array; meta: FileMeta }> {
    const res = await this.authenticatedFetch(
      `${this.url}/${encodePageURI(path)}`,
      {
        method: "GET",
        headers: {
          // This header won't trigger CORS preflight requests but can be interpreted on the server
          Accept: "application/octet-stream",
        },
      },
    );
    if (res.status === 404) {
      throw notFoundError;
    }
    return {
      data: new Uint8Array(await res.arrayBuffer()),
      meta: headersToFileMeta(path, res.headers)!,
    };
  }

  async writeFile(
    path: string,
    data: Uint8Array,
    meta?: FileMeta,
  ): Promise<FileMeta> {
    const headers: Record<string, string> = {
      "Content-Type": "application/octet-stream",
    };
    if (meta) {
      headers["X-Created"] = "" + meta.created;
      headers["X-Last-Modified"] = "" + meta.lastModified;
      headers["X-Perm"] = "" + meta.perm;
    }

    const res = await this.authenticatedFetch(
      `${this.url}/${encodePageURI(path)}`,
      {
        method: "PUT",
        headers,
        // Casting to any because of weird Deno typing
        body: data as any,
      },
    );
    return headersToFileMeta(path, res.headers)!;
  }

  async deleteFile(path: string): Promise<void> {
    const req = await this.authenticatedFetch(
      `${this.url}/${encodePageURI(path)}`,
      {
        method: "DELETE",
      },
    );
    if (req.status !== 200) {
      throw Error(`Failed to delete file: ${req.statusText}`);
    }
  }

  async getFileMeta(path: string, observing?: boolean): Promise<FileMeta> {
    const res = await this.authenticatedFetch(
      `${this.url}/${encodePageURI(path)}`,
      // This used to use HEAD, but it seems that Safari on iOS is blocking cookies/credentials to be sent along with HEAD requests
      // so we'll use GET instead with a magic header which the server may or may not use to omit the body.
      {
        method: "GET",
        headers: {
          "X-Get-Meta": "true",
          ...(observing ? { "X-Observing": "true" } : {}),
        },
      },
    );
    if (res.status === 404) {
      throw notFoundError;
    }
    if (!res.ok) {
      throw new Error(`Failed to get file meta: ${res.statusText}`);
    }
    return headersToFileMeta(path, res.headers)!;
  }

  // If not: throws an error or invokes a redirect
  async ping() {
    const parentEndpoint = this.url.split("/").slice(0, -1).join("/") +
      "/.ping";
    const resp = await this.authenticatedFetch(parentEndpoint, {
      method: "GET",
      headers: {
        Accept: "application/json",
      },
    }, pingTimeout);

    if (!resp.ok) {
      throw new Error(`Ping failed: ${resp.status} ${resp.statusText}`);
    }

    await this.validateSpacePathFromHeaders(resp);

    // Consume the response body to avoid leaks
    await resp.text();
  }
}


================================================
FILE: client/spaces/space_primitives.test.ts
================================================
import { assert, assertEquals } from "@std/assert";
import type { SpacePrimitives } from "./space_primitives.ts";
import type { FileMeta } from "@silverbulletmd/silverbullet/type/index";
import { notFoundError } from "@silverbulletmd/silverbullet/constants";

export async function testSpacePrimitives(spacePrimitives: SpacePrimitives) {
  await testBasicOperations(spacePrimitives);
  await testFileOverwriting(spacePrimitives);
  await testEmptyFiles(spacePrimitives);
  await testUnicodeContent(spacePrimitives);
  await testSpecialFileNames(spacePrimitives);
  await testErrorHandling(spacePrimitives);
  await testLargeFiles(spacePrimitives);
  await testMetadataPreservation(spacePrimitives);

  // Ensure clean state at the end
  const finalFiles = await spacePrimitives.fetchFileList();
  assertEquals(finalFiles, []);
}

async function testBasicOperations(spacePrimitives: SpacePrimitives) {
  const files = await spacePrimitives.fetchFileList();
  assertEquals(files, []);

  // Write text file
  const fileMeta = await spacePrimitives.writeFile(
    "test.txt",
    stringToBytes("Hello World"),
    {
      name: "test.txt",
      perm: "rw",
      created: 10,
      contentType: "text/plain",
      lastModified: 20,
      size: 11,
    },
  );

  const { data: retrievedData, meta: retrievedMeta } = await spacePrimitives
    .readFile("test.txt");

  assertEquals(retrievedData, stringToBytes("Hello World"));
  // Check that the meta data is persisted
  assertEquals(retrievedMeta.lastModified, 20);

  const fbContent = (await spacePrimitives.readFile("test.txt"))
    .data;
  assertEquals(new TextDecoder().decode(fbContent), "Hello World");

  assertEquals(await spacePrimitives.fetchFileList(), [fileMeta]);

  // Write binary file
  const buf = new Uint8Array(1024 * 1024);
  buf.set([1, 2, 3, 4, 5]);
  await spacePrimitives.writeFile("test.bin", buf);
  const fileData = await spacePrimitives.readFile("test.bin");
  assertEquals(fileData.data.length, 1024 * 1024);
  assertEquals((await spacePrimitives.fetchFileList()).length, 2);

  await spacePrimitives.deleteFile("test.bin");
  assertEquals(await spacePrimitives.fetchFileList(), [fileMeta]);

  // Clean up
  await spacePrimitives.deleteFile("test.txt");
  assertEquals(await spacePrimitives.fetchFileList(), []);

  // Test weird file names
  await spacePrimitives.writeFile("test+'s.txt", stringToBytes("Hello world!"));
  assertEquals(
    stringToBytes("Hello world!"),
    (await spacePrimitives.readFile("test+'s.txt")).data,
  );
  await spacePrimitives.deleteFile("test+'s.txt");

  // Check deletion of weird file file name
  try {
    await spacePrimitives.getFileMeta("test+'s.txt");
    assert(false);
  } catch (e: any) {
    assertEquals(e, notFoundError);
  }
}

async function testFileOverwriting(spacePrimitives: SpacePrimitives) {
  // Test overwriting existing files
  await spacePrimitives.writeFile("overwrite.txt", stringToBytes("Original"));
  const _originalMeta = await spacePrimitives.getFileMeta("overwrite.txt");

  await spacePrimitives.writeFile("overwrite.txt", stringToBytes("Updated"));
  const updatedData = await spacePrimitives.readFile("overwrite.txt");
  assertEquals(new TextDecoder().decode(updatedData.data), "Updated");

  // File list should still have only one entry for this file
  const filesAfterOverwrite = await spacePrimitives.fetchFileList();
  const overwriteFiles = filesAfterOverwrite.filter((f) =>
    f.name === "overwrite.txt"
  );
  assertEquals(overwriteFiles.length, 1);

  await spacePrimitives.deleteFile("overwrite.txt");
}

async function testEmptyFiles(spacePrimitives: SpacePrimitives) {
  // Test empty file
  await spacePrimitives.writeFile("empty.txt", new Uint8Array(0));
  const emptyFile = await spacePrimitives.readFile("empty.txt");
  assertEquals(emptyFile.data.length, 0);
  assertEquals(emptyFile.meta.size, 0);
  await spacePrimitives.deleteFile("empty.txt");
}

async function testUnicodeContent(spacePrimitives: SpacePrimitives) {
  // Test files with Unicode characters
  const unicodeContent = "Hello 世界! 🌍 Здравствуй мир!";
  await spacePrimitives.writeFile("unicode.txt", stringToBytes(unicodeContent));
  const unicodeFile = await spacePrimitives.readFile("unicode.txt");
  assertEquals(new TextDecoder().decode(unicodeFile.data), unicodeContent);
  await spacePrimitives.deleteFile("unicode.txt");
}

async function testSpecialFileNames(spacePrimitives: SpacePrimitives) {
  // Test file names with various special characters
  const specialNames = [
    "file with spaces.txt",
    "file-with-hyphens.txt",
    "file_with_underscores.txt",
    "file.with.dots.txt",
    "UPPERCASE.TXT",
    "123numeric.txt",
    "émojis🚀file.txt",
  ];

  for (const fileName of specialNames) {
    await spacePrimitives.writeFile(
      fileName,
      stringToBytes(`Content of ${fileName}`),
    );
    const fileData = await spacePrimitives.readFile(fileName);
    assertEquals(
      new TextDecoder().decode(fileData.data),
      `Content of ${fileName}`,
    );
  }

  // Verify all special files are in the list
  const allFiles = await spacePrimitives.fetchFileList();
  for (const fileName of specialNames) {
    const found = allFiles.find((f) => f.name === fileName);
    assert(found, `File ${fileName} should be in the file list`);
  }

  // Clean up special files
  for (const fileName of specialNames) {
    await spacePrimitives.deleteFile(fileName);
  }
}

async function testErrorHandling(spacePrimitives: SpacePrimitives) {
  // Test error cases
  try {
    await spacePrimitives.readFile("nonexistent.txt");
    assert(false, "Should throw error for non-existent file");
  } catch (e: any) {
    assertEquals(e, notFoundError);
  }

  try {
    await spacePrimitives.deleteFile("nonexistent.txt");
    assert(false, "Should throw error when deleting non-existent file");
  } catch (e: any) {
    assertEquals(e, notFoundError);
  }
}

async function testLargeFiles(spacePrimitives: SpacePrimitives) {
  // Test large file content
  const largeContent = new Uint8Array(5 * 1024 * 1024); // 5MB
  for (let i = 0; i < largeContent.length; i++) {
    largeContent[i] = i % 256;
  }

  await spacePrimitives.writeFile("large.bin", largeContent);
  const largeFile = await spacePrimitives.readFile("large.bin");
  assertEquals(largeFile.data.length, largeContent.length);
  assertEquals(largeFile.meta.size, largeContent.length);

  // Verify content integrity
  for (let i = 0; i < Math.min(1000, largeContent.length); i++) {
    assertEquals(largeFile.data[i], largeContent[i]);
  }

  await spacePrimitives.deleteFile("large.bin");
}

async function testMetadataPreservation(spacePrimitives: SpacePrimitives) {
  // Test metadata preservation
  const testContent = stringToBytes("Hello meta!");
  const customMeta: FileMeta = {
    name: "meta-test.txt",
    perm: "rw",
    created: 1000000,
    contentType: "text/plain", // Use a content type that matches the file extension
    lastModified: 2000000,
    size: testContent.length, // Use actual content length
  };

  await spacePrimitives.writeFile(
    "meta-test.txt",
    testContent,
    customMeta,
  );
  const metaFile = await spacePrimitives.readFile("meta-test.txt");

  // Check that some metadata is preserved (implementations may handle timestamps differently)
  assert(
    metaFile.meta.lastModified > 0,
    "LastModified timestamp should be set",
  );
  assertEquals(metaFile.meta.name, "meta-test.txt");
  assertEquals(metaFile.meta.size, testContent.length);

  await spacePrimitives.deleteFile("meta-test.txt");
}

function stringToBytes(str: string): Uint8Array {
  return new TextEncoder().encode(str);
}


================================================
FILE: client/spaces/space_primitives.ts
================================================
import type { FileMeta } from "../../plug-api/types/index.ts";

/**
 * A generic interface used by `Space` to interact with the underlying storage, designed to be easy to implement for different storage backends
 */
export interface SpacePrimitives {
  fetchFileList(): Promise<FileMeta[]>;

  /**
   * Retrieves metadata for a specific file.
   * @param path The path of the file to retrieve metadata for.
   * @param observing used to hint at the sync engine this file is under regular observation (and may sync more aggressively)
   */
  getFileMeta(path: string, observing?: boolean): Promise<FileMeta>;

  readFile(
    path: string,
  ): Promise<{ data: Uint8Array; meta: FileMeta }>;

  writeFile(
    path: string,
    data: Uint8Array,
    // May be ignored, but ideally should be used to set the lastModified time
    meta?: FileMeta,
  ): Promise<FileMeta>;

  deleteFile(path: string): Promise<void>;
}


================================================
FILE: client/spaces/sync.test.ts
================================================
import { SpaceSync, SyncSnapshot } from "./sync.ts";
import { assertEquals } from "@std/assert";
import { sleep } from "@silverbulletmd/silverbullet/lib/async";
import { assert } from "node:console";
import { MemoryKvPrimitives } from "../data/memory_kv_primitives.ts";
import { DataStoreSpacePrimitives } from "./datastore_space_primitives.ts";

Deno.test("Test sync with no filtering", async () => {
  const primary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
  const secondary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
  const snapshot = new SyncSnapshot();
  const sync = new SpaceSync(primary, secondary, {
    conflictResolver: SpaceSync.primaryConflictResolver,
    isSyncCandidate: () => true, // Sync everything always
  });

  console.log("Write one page to primary");
  await primary.writeFile("index.md", stringToBytes("Hello"));
  assertEquals((await secondary.fetchFileList()).length, 0);
  console.log("Initial sync ops", await doSync());

  assertEquals((await secondary.fetchFileList()).length, 1);
  assertEquals(
    (await secondary.readFile("index.md")).data,
    stringToBytes("Hello"),
  );

  // Should be a no-op
  let ops = await doSync();
  assertEquals(ops, 0);
  assertEquals(snapshot.nonSyncedFiles.size, 0);

  // Now let's make a change on the secondary
  await secondary.writeFile("index.md", stringToBytes("Hello!!"));
  await secondary.writeFile("test.md", stringToBytes("Test page"));

  // And sync it
  ops = await doSync();
  assertEquals(ops, 2);
  assertEquals(snapshot.nonSyncedFiles.size, 0);

  assertEquals((await primary.fetchFileList()).length, 2);
  assertEquals((await secondary.fetchFileList()).length, 2);

  assertEquals(
    (await primary.readFile("index.md")).data,
    stringToBytes("Hello!!"),
  );

  // Let's make some random edits on both ends
  await primary.writeFile("index.md", stringToBytes("1"));
  await primary.writeFile("index2.md", stringToBytes("2"));
  await secondary.writeFile("index3.md", stringToBytes("3"));
  await secondary.writeFile("index4.md", stringToBytes("4"));
  await doSync();

  assertEquals((await primary.fetchFileList()).length, 5);
  assertEquals((await secondary.fetchFileList()).length, 5);

  ops = await doSync();
  assertEquals(ops, 0);

  console.log("Deleting pages");
  // Delete some pages
  await primary.deleteFile("index.md");
  await primary.deleteFile("index3.md");

  await doSync();

  assertEquals((await primary.fetchFileList()).length, 3);
  assertEquals((await secondary.fetchFileList()).length, 3);

  // No-op
  ops = await doSync();
  assertEquals(ops, 0);

  await secondary.deleteFile("index4.md");
  await primary.deleteFile("index2.md");

  await doSync();

  // Just "test" left
  assertEquals((await primary.fetchFileList()).length, 1);
  assertEquals((await secondary.fetchFileList()).length, 1);

  // No-op
  ops = await doSync();
  assertEquals(ops, 0);

  await secondary.writeFile("index.md", stringToBytes("I'm back"));

  await doSync();

  assertEquals(
    (await primary.readFile("index.md")).data,
    stringToBytes("I'm back"),
  );

  // Cause a conflict
  console.log("Introducing a conflict now");
  await primary.writeFile("index.md", stringToBytes("Hello 1"));
  await secondary.writeFile("index.md", stringToBytes("Hello 2"));

  await doSync();

  // Sync conflicting copy back
  await doSync();

  // Verify that primary won
  assertEquals(
    (await primary.readFile("index.md")).data,
    stringToBytes("Hello 1"),
  );
  assertEquals(
    (await secondary.readFile("index.md")).data,
    stringToBytes("Hello 1"),
  );

  // test + index + index.conflicting copy
  assertEquals((await primary.fetchFileList()).length, 3);
  assertEquals((await secondary.fetchFileList()).length, 3);

  // Introducing a fake conflict (same content, so not really conflicting)
  await primary.writeFile("index.md", stringToBytes("Hello 1"));
  await secondary.writeFile("index.md", stringToBytes("Hello 1"));

  await doSync();
  await doSync();

  // test + index + index.md + previous index.conflicting copy but nothing more
  assertEquals((await primary.fetchFileList()).length, 3);

  async function doSync() {
    await sleep(10);
    const r = await sync.syncFiles(snapshot);
    await sleep(10);
    return r;
  }
});

Deno.test("Test sync with filtering", async () => {
  const primary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
  const secondary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());

  const snapshot = new SyncSnapshot();
  let sync = new SpaceSync(
    primary,
    secondary,
    {
      conflictResolver: SpaceSync.primaryConflictResolver,
      isSyncCandidate: (path) => path.endsWith(".md"), // Only sync .md files
    },
  );

  console.log(
    "Write one non-sync file on the primary, which SHOULD sync to the secondary",
  );
  await primary.writeFile("index.txt", stringToBytes("Hello"));
  assertEquals((await secondary.fetchFileList()).length, 0);
  let ops = await doSync();
  assertEquals(ops, 1);
  // Note: this number should be 0 because the file DOES have a local representation so it's not technically non-synced
  assertEquals(snapshot.nonSyncedFiles.size, 0);

  assertEquals((await secondary.fetchFileList()).length, 1);
  assertEquals(
    (await secondary.readFile("index.txt")).data,
    stringToBytes("Hello"),
  );

  console.log("Updating on secondary");
  await secondary.writeFile("index.txt", stringToBytes("Hello Updated"));
  ops = await doSync();
  assertEquals(ops, 1);
  assertEquals(snapshot.nonSyncedFiles.size, 1);
  try {
    await primary.getFileMeta("index.md");
    assert(
      false,
      "Local file should have been deleted locally since it's out of date",
    );
  } catch {
    // Expected
  }

  console.log("Deleting remote files");
  await secondary.deleteFile("index.txt");
  ops = await doSync();
  assertEquals(ops, 1);
  assertEquals(snapshot.nonSyncedFiles.size, 0);

  console.log("Creating a remote non-synced file");
  await secondary.writeFile("index2.txt", stringToBytes("Hello 2"));
  ops = await doSync();
  assertEquals(ops, 0); // No-op, metadata only
  assertEquals(snapshot.nonSyncedFiles.size, 1);
  ops = await doSync();
  assertEquals(ops, 0); // No-op, metadata only
  assertEquals(snapshot.nonSyncedFiles.size, 1);

  await primary.writeFile("index2.txt", stringToBytes("Hello local"));
  ops = await doSync();
  assertEquals(ops, 1);
  assertEquals(snapshot.nonSyncedFiles.size, 0);

  console.log("Getting into a state with some synced and non-synced files");
  await secondary.writeFile("index.md", stringToBytes("This will sync"));
  await secondary.writeFile("index.txt", stringToBytes("This will not sync"));
  await secondary.writeFile("index2.txt", stringToBytes("This will not sync"));
  ops = await doSync();
  assertEquals(ops, 2);
  assertEquals(snapshot.nonSyncedFiles.size, 2);

  // Check file listings on both ends
  assertEquals((await secondary.fetchFileList()).length, 3);
  assertEquals((await primary.fetchFileList()).length, 1);

  ////////////
  // Now let's start another sync session, but now wanting to sync everything
  console.log("Going to switch to syncing everything now");
  sync = new SpaceSync(
    primary,
    secondary,
    {
      conflictResolver: SpaceSync.primaryConflictResolver,
      isSyncCandidate: () => true,
    },
  );

  ops = await doSync();
  // This should pull 2 files from remote to local
  assertEquals(ops, 2);
  assertEquals((await primary.fetchFileList()).length, 3);
  assertEquals((await secondary.fetchFileList()).length, 3);

  console.log("And now to syncing nothing");
  sync = new SpaceSync(
    primary,
    secondary,
    {
      conflictResolver: SpaceSync.primaryConflictResolver,
      isSyncCandidate: () => false,
    },
  );

  ops = await doSync();
  // This should delete 3 files from the primary
  assertEquals(ops, 3);
  // Leaving nothing on primary
  assertEquals((await primary.fetchFileList()).length, 0);
  // And everything unchanged on secondary
  assertEquals((await secondary.fetchFileList()).length, 3);

  // Ok, now we're going to sync everything again
  sync = new SpaceSync(
    primary,
    secondary,
    {
      conflictResolver: SpaceSync.primaryConflictResolver,
      isSyncCandidate: () => true,
    },
  );
  // await secondary.
  ops = await doSync();
  assertEquals(ops, 3);
  assertEquals((await primary.fetchFileList()).length, 3);
  assertEquals((await secondary.fetchFileList()).length, 3);

  async function doSync() {
    await sleep(10);
    const r = await sync.syncFiles(snapshot);
    return r;
  }
});

Deno.test("Local push sync", async () => {
  const primary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
  const secondary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
  const snapshot = new SyncSnapshot();
  const sync = new SpaceSync(primary, secondary, {
    conflictResolver: SpaceSync.primaryConflictResolver,
    isSyncCandidate: (path) => path.endsWith(".md"), // Only sync .md files
  });

  console.log(
    "Write one non-sync file on the primary, which SHOULD sync to the secondary",
  );

  const operations = await sync.syncFiles(snapshot);
  assertEquals(operations, 0);

  await primary.writeFile("index.md", stringToBytes("Hello"));
  assertEquals(1, await sync.syncSingleFile("index.md", snapshot));

  assertEquals(
    (await secondary.readFile("index.md")).data,
    stringToBytes("Hello"),
  );

  console.log("Let's write a new file on primary that is not a sync candidate");
  await primary.writeFile("test.txt", stringToBytes("Hello"));
  assertEquals(1, await sync.syncSingleFile("test.txt", snapshot));
  assertEquals(snapshot.nonSyncedFiles.size, 0);
});

function stringToBytes(s: string): Uint8Array {
  return new TextEncoder().encode(s);
}


================================================
FILE: client/spaces/sync.ts
================================================
/**
 * This is where the sync magic happens
 */
import type { SpacePrimitives } from "./space_primitives.ts";
import { EventEmitter } from "../plugos/event.ts";
import type { FileMeta } from "@silverbulletmd/silverbullet/type/index";
import { notFoundError } from "@silverbulletmd/silverbullet/constants";
import { processWithConcurrency } from "@silverbulletmd/silverbullet/lib/async";

const syncConcurrency = 3;

// In practice this is the lastModified timestamp
type SyncHash = number;

// Tuple where the first value represents a lastModified timestamp for the primary space
// and the second item the lastModified value of the secondary space
export type SyncStatusItem = [SyncHash, SyncHash];

export class SyncSnapshot {
  constructor(
    public files: Map<string, SyncStatusItem> = new Map(),
    public nonSyncedFiles: Map<string, FileMeta> = new Map(),
  ) {
    this.files = files;
    this.nonSyncedFiles = nonSyncedFiles;
  }

  toJSON(): any {
    return {
      files: Object.fromEntries(this.files),
      nonSyncedFiles: Object.fromEntries(this.nonSyncedFiles),
    };
  }

  static fromJSON(json: any | undefined): SyncSnapshot {
    return new SyncSnapshot(
      new Map(Object.entries(json?.files || {})),
      new Map(Object.entries(json?.nonSyncedFiles || {})),
    );
  }
}

export type SyncStatus = {
  filesProcessed: number;
  totalFiles: number;
};

export type SyncOptions = {
  conflictResolver: (
    path: string,
    snapshot: SyncSnapshot,
    primarySpace: SpacePrimitives,
    secondarySpace: SpacePrimitives,
  ) => Promise<number>;
  isSyncCandidate: (path: string) => boolean;
};

type SyncDirection = "primary->secondary" | "secondary->primary";

export type SyncEvents = {
  syncProgress: (
    syncStatus: SyncStatus,
    snapshot: SyncSnapshot,
  ) => void | Promise<void>;
  snapshotUpdated: (snapshot: SyncSnapshot) => void | Promise<void>;
};

// Implementation of this algorithm: https://unterwaditzer.net/2016/sync-algorithm.html
export class SpaceSync extends EventEmitter<SyncEvents> {
  // Sync mutex (only one sync operation at a time)
  private isSyncing = false;

  constructor(
    private primary: SpacePrimitives,
    private secondary: SpacePrimitives,
    readonly options: SyncOptions,
  ) {
    super();
  }

  /**
   * Syncs all files in space.
   * If this completes successfully (with operations >= 0), a full space sync completed successfully.
   * @param snapshot - The current snapshot of the space (will be updated in place)
   * @returns number of operations performed, or -1 when sync was already ongoing and nonSynced files
   */
  public async syncFiles(snapshot: SyncSnapshot): Promise<number> {
    let operations = 0;

    // Mutex behavior, only sync can happen at a time
    if (this.isSyncing) {
      console.warn("Sync already in progress...");
      return -1;
    }
    this.isSyncing = true;
    console.log("[sync]", "Performing a full sync cycle...");
    const startTime = Date.now();

    try {
      const primaryAllPages = await this.primary.fetchFileList();
      const secondaryAllPages = await this.secondary.fetchFileList();

      // Assumption: the primary is local, and _all_ files should be synced to the secondary
      // However, the secondary is remote, and only a subset of files should be synced from it
      const nonSyncCandidates = this.getNonSyncCandidates(secondaryAllPages);

      const primaryFileMap = new Map<string, FileMeta>(
        primaryAllPages.map((m) => [m.name, m]),
      );
      const secondaryFileMap = new Map<string, FileMeta>(
        secondaryAllPages.map((m) => [m.name, m]),
      );

      const allFilesToProcess = new Set([
        ...snapshot.files.keys(),
        ...primaryFileMap.keys(),
        ...secondaryFileMap.keys(),
      ]);

      const sortedPaths = [...allFilesToProcess];
      sortedPaths.sort((a) => {
        // Just make sure that plug files appear first
        // This is important for the initial sync: plugs are loaded the moment they are pulled into the space,
        // which would activate e.g. any indexing logic for the remaining space content
        // TODO: To verify if this is still true today
        return a.endsWith(".plug.js") ? -1 : 1;
      });
      // console.log("[sync]", "Iterating over all files");
      let filesProcessed = 0;
      await processWithConcurrency(sortedPaths, async (path) => {
        const fileOperations = await this.syncFile(
          path,
          primaryFileMap.get(path),
          secondaryFileMap.get(path),
          !nonSyncCandidates.has(path),
          snapshot,
        );
        operations = operations + fileOperations;
        filesProcessed++;
        if (fileOperations > 0) {
          // Only report something significant
          await this.emit("syncProgress", {
            filesProcessed,
            totalFiles: sortedPaths.length,
          }, snapshot);
        }
      }, syncConcurrency);
      console.log(
        "[Sync]",
        "Completed:",
        operations,
        "operations in",
        ((Date.now() - startTime) / 1000) + "s",
      );
    } finally {
      this.isSyncing = false;
      if (operations > 0) {
        this.emit("snapshotUpdated", snapshot);
      }
    }

    return operations;
  }

  /**
   * Syncs a single file from primary to secondary.
   * Used to more actively sync currently open files.
   * @returns number of operations performed, or -1 when sync was already ongoing
   */
  public async syncSingleFile(
    path: string,
    snapshot: SyncSnapshot,
  ): Promise<number> {
    // Mutex behavior, only sync cycle can happen at a time
    if (this.isSyncing) {
      console.warn("[sync]", "Sync already in progress...");
      return -1;
    }
    if (snapshot.nonSyncedFiles.has(path)) {
      console.info(
        "[sync]",
        "Was asked to sync marked as non-synced, skipping",
        path,
      );
      return 0;
    }

    this.isSyncing = true;
    console.log("[sync]", "Performing a single file sync", path);

    let operations = 0;

    try {
      const primaryMeta = await this.primary.getFileMeta(path);
      let secondaryMeta: FileMeta | undefined;
      try {
        secondaryMeta = await this.secondary.getFileMeta(path);
      } catch (e: any) {
        if (e.message === notFoundError.message) {
          // File doesn't exist remotely, that's ok
        } else {
          throw e;
        }
      }
      operations = await this.syncFile(
        path,
        primaryMeta,
        secondaryMeta,
        true,
        snapshot,
      );
    } finally {
      this.isSyncing = false;
      if (operations > 0) {
        this.emit("snapshotUpdated", snapshot);
      }
    }

    return operations;
  }

  /**
   * Syncs a single file
   * @param path The path of the file to sync
   * @param primaryMeta The metadata of the file on the primary space
   * @param secondaryMeta The metadata of the file on the secondary space
   * @param syncBack Whether this a file that is marked to be synced
   * @param snapshot The snapshot of the file status (updated in place!)
   * @returns The number of operations performed
   */
  private async syncFile(
    path: string,
    primaryMeta: FileMeta | undefined,
    secondaryMeta: FileMeta | undefined,
    syncBack: boolean,
    snapshot: SyncSnapshot,
  ): Promise<number> {
    let operations = 0;

    if (
      primaryMeta !== undefined && secondaryMeta === undefined &&
      !snapshot.files.has(path)
    ) {
      // New file, created on primary, copy from primary to secondary
      console.log(
        "[sync]",
        "New file created on primary, copying to secondary",
        path,
      );
      const { data, meta } = await this.primary.readFile(path);
      const writtenMeta = await this.secondary.writeFile(
        path,
        data,
        meta,
      );
      // Update file snapshot
      snapshot.files.set(path, [
        meta.lastModified,
        writtenMeta.lastModified,
      ]);
      // Let's make sure this file is not marked as nonSynced, because we have a local copy
      snapshot.nonSyncedFiles.delete(path);
      operations++;
    } else if (
      secondaryMeta !== undefined && ((
        // Scenario: a new file was created on the secondary
        primaryMeta === undefined &&
        !snapshot.files.has(path)
      ) || (
        // Scenario: a file that was previously unsynced, should now be synced
        snapshot.nonSyncedFiles.has(path) && syncBack &&
        // because we don't have it yet
        primaryMeta === undefined
      ))
    ) {
      // New file to sync, present on secondary
      if (syncBack) {
        // Copy from secondary to primary
        console.log(
          "[sync]",
          "New file to sync on secondary, copying from secondary to primary",
          path,
        );
        const { data, meta } = await this.secondary.readFile(path);
        const writtenMeta = await this.primary.writeFile(
          path,
          data,
          meta,
        );
        // Update file snapshot
        snapshot.files.set(path, [
          writtenMeta.lastModified,
          meta.lastModified,
        ]);
        // Make sure the file is not marked as nonSynced anymore
        snapshot.nonSyncedFiles.delete(path);
        operations++;
      } else { // !syncBack
        // This has syncBack set to false, which means we'll just update the nonSyncedFiles and snapshot
        console.log(
          "[sync]",
          "New file created on secondary, but only updating metadata",
          path,
        );
        snapshot.files.set(path, [
          secondaryMeta.lastModified,
          secondaryMeta.lastModified,
        ]);
        snapshot.nonSyncedFiles.set(path, secondaryMeta);
      }
    } else if (
      primaryMeta !== undefined && snapshot.files.has(path) &&
      secondaryMeta === undefined
    ) {
      // File deleted on secondary
      if (syncBack) {
        snapshot.files.delete(path);
        if (snapshot.nonSyncedFiles.has(path)) {
          // This is the scenario where in the previous sync this file was not synced while in this new one it is
          console.log(
            "[sync]",
            "File deleted on secondary, but wasn't synced on primary, so skipping",
            path,
          );
          // Keeping non-synced files up-to-date
          snapshot.nonSyncedFiles.delete(path);
        } else {
          console.log(
            "[sync]",
            "File deleted on secondary, deleting from primary",
            path,
          );
          await this.primary.deleteFile(path);
        }
        operations++;
      } else { // !syncBack
        console.log(
          "[sync]",
          "File deleted on secondary, only updating snapshot",
          path,
        );
        try {
          // It's possible that there is a local copy anyway (if it started out there, so let's attempt to delete it just in case, but fail silently)
          await this.primary.deleteFile(path);
          console.info("[sync]", "Deleted local copy on primary", path);
          operations++;
        } catch {
          // Fail silently, likely the file doesn't exist
        }
        snapshot.files.delete(path);
        snapshot.nonSyncedFiles.delete(path);
      }
    } else if (
      // The file is on the secondary, and not on the primary
      secondaryMeta !== undefined && snapshot.files.has(path) &&
      primaryMeta === undefined &&
      // And we're talking about a synced file
      syncBack
    ) {
      // File deleted on primary
      console.log(
        "[sync]",
        "File deleted on primary, deleting from secondary",
        path,
      );
      await this.secondary.deleteFile(path);
      snapshot.files.delete(path);
      snapshot.nonSyncedFiles.delete(path);
      operations++;
    } else if (
      snapshot.files.has(path) && primaryMeta === undefined &&
      secondaryMeta === undefined
    ) {
      // File deleted on both sides, :shrug:
      console.log(
        "[sync]",
        "File deleted on both ends, deleting from snapshot",
        path,
      );
      snapshot.files.delete(path);
      snapshot.nonSyncedFiles.delete(path);
      operations++;
    } else if (
      primaryMeta !== undefined && secondaryMeta !== undefined &&
      snapshot.files.get(path) &&
      primaryMeta.lastModified !== snapshot.files.get(path)![0] &&
      secondaryMeta.lastModified === snapshot.files.get(path)![1]
    ) {
      // File has changed on primary, but not secondary: copy from primary to secondary
      console.log(
        "[sync]",
        "File changed on primary, copying to secondary",
        path,
      );
      const { data, meta } = await this.primary.readFile(path);
      const writtenMeta = await this.secondary.writeFile(
        path,
        data,
        meta,
      );
      snapshot.files.set(path, [
        meta.lastModified,
        writtenMeta.lastModified,
      ]);
      // Delete from non-synced files just in case, because we clearly have a local copy
      snapshot.nonSyncedFiles.delete(path);
      operations++;
    } else if (
      primaryMeta !== undefined && secondaryMeta !== undefined &&
      snapshot.files.get(path) &&
      primaryMeta.lastModified === snapshot.files.get(path)![0] &&
      secondaryMeta.lastModified !== snapshot.files.get(path)![1]
    ) {
      // File has changed on secondary, but not primary
      if (syncBack) {
        // Copy from secondary to primary
        console.log(
          "[sync]",
          "File has changed on secondary, but not primary: copy from secondary to primary",
          path,
        );
        const { data, meta } = await this.secondary.readFile(path);
        const writtenMeta = await this.primary.writeFile(
          path,
          data,
          meta,
        );
        snapshot.files.set(path, [
          writtenMeta.lastModified,
          meta.lastModified,
        ]);
        // Make sure it's not in nonSyncedFiles
        snapshot.nonSyncedFiles.delete(path);
        operations++;
      } else { // !syncBack
        console.log(
          "[sync]",
          "File has changed on secondary, but not primary: shouldn't be synced",
          path,
        );
        snapshot.files.set(path, [
          secondaryMeta.lastModified,
          secondaryMeta.lastModified,
        ]);
        snapshot.nonSyncedFiles.set(path, secondaryMeta);
        try {
          // It may be that the file does exist locally, but it will now be out of date, just delete it
          await this.primary.deleteFile(path);
          console.info(
            "[sync]",
            "Deleted local (out of date) copy on primary",
            path,
          );
          operations++;
        } catch {
          // Fail silently, likely the file doesn't exist
        }
      }
    } else if (
      ( // File changed on both ends, but we don't have any info in the snapshot (resync scenario?): have to run through conflict handling
        primaryMeta !== undefined && secondaryMeta !== undefined &&
        !snapshot.files.has(path)
      ) ||
      ( // File changed on both ends, CONFLICT!
        primaryMeta !== undefined && secondaryMeta !== undefined &&
        snapshot.files.get(path) &&
        primaryMeta.lastModified !== snapshot.files.get(path)![0] &&
        secondaryMeta.lastModified !== snapshot.files.get(path)![1]
      )
    ) {
      console.log(
        "[sync]",
        "File changed on both ends, potential conflict",
        path,
      );
      operations += await this.options.conflictResolver(
        path,
        snapshot,
        this.primary,
        this.secondary,
      );
    } else if (primaryMeta && secondaryMeta && !syncBack) {
      console.log(
        "[sync]",
        "File present locally, but no longer synced, deleting local copy of",
        path,
      );
      await this.primary.deleteFile(path);
      snapshot.nonSyncedFiles.set(path, secondaryMeta);
      operations += 1;
    } else {
      // Nothing needs to happen
      if (!syncBack && secondaryMeta) {
        snapshot.nonSyncedFiles.set(path, secondaryMeta);
      }
    }
    // End scene
    return operations;
  }

  getNonSyncCandidates(files: FileMeta[]): Map<string, FileMeta> {
    const nonSyncCandidates: Map<string, FileMeta> = new Map();
    files.forEach((meta) => {
      if (!this.options.isSyncCandidate(meta.name)) {
        nonSyncCandidates.set(meta.name, meta);
      }
    });
    return nonSyncCandidates;
  }

  // Strategy: Primary wins
  public static async primaryConflictResolver(
    path: string,
    snapshot: SyncSnapshot,
    primary: SpacePrimitives,
    secondary: SpacePrimitives,
  ): Promise<number> {
    console.log("[sync]", "Starting conflict resolution for", path);
    const filePieces = path.split(".");
    const fileNameBase = filePieces.slice(0, -1).join(".");
    const fileNameExt = filePieces[filePieces.length - 1];
    const pageData1 = await primary.readFile(path);
    const pageData2 = await secondary.readFile(path);

    let byteWiseMatch = true;
    const arrayBuffer1 = pageData1.data;
    const arrayBuffer2 = pageData2.data;
    if (arrayBuffer1.byteLength !== arrayBuffer2.byteLength) {
      byteWiseMatch = false;
    }
    if (byteWiseMatch) {
      // Byte-wise comparison
      for (let i = 0; i < arrayBuffer1.byteLength; i++) {
        if (arrayBuffer1[i] !== arrayBuffer2[i]) {
          byteWiseMatch = false;
          break;
        }
      }
      // Byte wise they're still the same, so no confict
      if (byteWiseMatch) {
        console.log("[sync]", "Files are the same, no conflict");

        snapshot.files.set(path, [
          pageData1.meta.lastModified,
          pageData2.meta.lastModified,
        ]);
        return 0;
      }
    }

    let operations = 0;
    const revisionFileName = filePieces.length === 1
      ? `${path}.conflicted:${pageData2.meta.lastModified}`
      : `${fileNameBase}.conflicted:${pageData2.meta.lastModified}.${fileNameExt}`;
    console.log(
      "[sync]",
      "Going to create conflicting copy",
      revisionFileName,
    );

    // Copy secondary to conflict copy
    const localConflictMeta = await primary.writeFile(
      revisionFileName,
      pageData2.data,
    );
    operations++;
    const remoteConflictMeta = await secondary.writeFile(
      revisionFileName,
      pageData2.data,
    );
    operations++;

    // Write replacement on top
    const writeMeta = await secondary.writeFile(
      path,
      pageData1.data,
    );
    operations++;

    // Updating snapshot
    snapshot.files.set(revisionFileName, [
      localConflictMeta.lastModified,
      remoteConflictMeta.lastModified,
    ]);

    snapshot.files.set(path, [
      pageData1.meta.lastModified,
      writeMeta.lastModified,
    ]);
    return operations;
  }
}


================================================
FILE: client/style.ts
================================================
import { HighlightStyle } from "@codemirror/language";
import { tagHighlighter, tags as t } from "@lezer/highlight";
import * as ct from "./markdown_parser/customtags.ts";

export default function highlightStyles() {
  tagHighlighter;
  return HighlightStyle.define([
    { tag: t.heading1, class: "sb-h1" },
    { tag: t.heading2, class: "sb-h2" },
    { tag: t.heading3, class: "sb-h3" },
    { tag: t.link, class: "sb-link" },
    { tag: t.meta, class: "sb-meta" },
    { tag: t.quote, class: "sb-quote" },
    { tag: t.monospace, class: "sb-code" },
    { tag: ct.WikiLinkPartTag, class: "sb-wiki-link" },
    { tag: ct.AttributeTag, class: "sb-frontmatter" },
    { tag: ct.AttributeNameTag, class: "sb-attribute-name" },
    { tag: ct.AttributeValueTag, class: "sb-attribute-value" },
    { tag: ct.TaskTag, class: "sb-task" },
    { tag: ct.TaskMarkTag, class: "sb-task-mark" },
    { tag: ct.TaskStateTag, class: "sb-task-state" },
    { tag: ct.CodeInfoTag, class: "sb-code-info" },
    { tag: ct.CommentTag, class: "sb-comment" },
    { tag: ct.CommentMarkerTag, class: "sb-comment-marker" },
    { tag: ct.Highlight, class: "sb-highlight" },
    { tag: t.emphasis, class: "sb-emphasis" },
    { tag: t.strong, class: "sb-strong" },
    { tag: t.atom, class: "sb-atom" },
    { tag: t.bool, class: "sb-bool" },
    { tag: t.url, class: "sb-url" },
    { tag: t.inserted, class: "sb-inserted" },
    { tag: t.deleted, class: "sb-deleted" },
    { tag: t.literal, class: "sb-literal" },
    { tag: t.keyword, class: "sb-keyword" },
    { tag: t.list, class: "sb-list" },
    { tag: t.operator, class: "sb-operator" },
    { tag: t.regexp, class: "sb-string" },
    { tag: t.string, class: "sb-string" },
    { tag: t.number, class: "sb-number" },
    { tag: [t.regexp, t.escape, t.special(t.string)], class: "sb-string2" },
    { tag: t.variableName, class: "sb-variableName" },
    { tag: t.typeName, class: "sb-typeName" },
    { tag: t.strikethrough, class: "sb-strikethrough" },
    { tag: t.comment, class: "sb-comment" },
    { tag: t.invalid, class: "sb-invalid" },
    { tag: t.processingInstruction, class: "sb-meta" },
    { tag: t.punctuation, class: "sb-punctuation" },
    { tag: ct.HorizontalRuleTag, class: "sb-hr" },
    { tag: ct.HashtagTag, class: "sb-hashtag-text" },
    { tag: ct.NakedURLTag, class: "sb-naked-url" },
    { tag: ct.TaskDeadlineTag, class: "sb-task-deadline" },
    { tag: ct.SubscriptTag, class: "sb-sub" },
    { tag: ct.SuperscriptTag, class: "sb-sup" },

    { tag: ct.DirectiveMarkTag, class: "sb-directive-mark" },
    { tag: ct.DirectiveTag, class: "sb-directive" },
  ]);
}


================================================
FILE: client/styles/colors.scss
================================================
#sb-root {
  font-family: var(--ui-font);
  background-color: var(--root-background-color);
  color: var(--root-color);
}

.cm-cursor,
.cm-dropCursor {
  border-left: 1.2px solid var(--editor-caret-color) !important;
}

#sb-top {
  color: var(--top-color);
  background-color: var(--top-background-color);
  border-bottom: var(--top-border-color) 1px solid;
}

#sb-top.sb-sync-error {
  color: var(--top-sync-error-color);
  background-color: var(--top-sync-error-background-color);
}

.sb-saved {
  color: var(--top-saved-color);
}

.sb-unsaved {
  color: var(--top-unsaved-color);
}

.sb-loading {
  color: var(--top-loading-color);
}

#sb-main {
  .sb-panel:last-child {
    border-left: 1px solid var(--panel-border-color);
  }

  .sb-panel:first-child {
    border-right: 1px solid var(--panel-border-color);
  }

  .sb-panel {
    background-color: var(--panel-background-color);
  }
}

.sb-bhs {
  border-top: 1px solid var(--bhs-border-color);
  background-color: var(--bhs-background-color);
}

.sb-notifications {
  font-family: var(--editor-font);
}

.sb-notifications > div {
  border: var(--notifications-border-color) 1px solid;
}

.sb-notification-info {
  background-color: var(--notification-info-background-color);
}

.sb-notification-error {
  background-color: var(--notification-error-background-color);
}

.sb-actions button {
  width: 28px;
  height: 28px;
  border: 0;
  margin: 2px;
  margin-top: 6px;
  padding: 5px;
  background-color: var(--action-button-background-color);
  color: var(--action-button-color);
  cursor: pointer;
}

.sb-actions button.sb-enabled {
  background-color: var(--top-border-color);
  border-radius: 5px;
}

.sb-actions button:hover {
  color: var(--action-button-hover-color);
}

// Only color the expander button, when the hamburger menu is open
.sb-actions:not(.open) button.expander:hover {
  color: var(--action-button-color);
}

.sb-button,
.sb-button-primary {
  --color: var(--button-color);
  --background-color: var(--button-background-color);
  --hover-background-color: var(--button-hover-background-color);
  --border-color: var(--button-border-color);

  &.sb-button-primary {
    --color: var(--primary-button-color);
    --background-color: var(--primary-button-background-color);
    --hover-background-color: var(--primary-button-hover-background-color);
    --border-color: var(--primary-button-border-color);
  }

  background-color: var(--background-color);
  color: var(--color);

  box-shadow: 0 0 0.2em rgba(0, 0, 0, 0.05);

  border: 1px solid var(--border-color);
  border-radius: 0.5em;
  padding: 0.2em 0.5em;
  font-size: 0.9em;

  &:hover {
    background-color: var(--hover-background-color);
  }

  &:focus {
    outline: 2px solid var(--color);
    outline-offset: -3px;
  }
}

/* Modal boxes */
.sb-modal-box {
  color: var(--modal-color);
  background-color: var(--modal-background-color);
  border: var(--modal-border-color) 1px solid;
  box-shadow: rgba(0, 0, 0, 0.35) 0px 20px 20px;

  .cm-scroller {
    font-family: var(--ui-font);
  }

  .sb-header {
    border-bottom: 1px var(--modal-border-color) solid;

    label {
      color: var(--modal-header-label-color);
    }

    .sb-mini-editor {
      font-family: var(--ui-font);
    }
  }

  .sb-help-text {
    background-color: var(--modal-help-background-color);
    border-bottom: 1px var(--modal-border-color) solid;
    color: var(--modal-help-color);
  }

  .sb-result-list {
    .sb-hint:not(.sb-hint-inactive) {
      color: var(--modal-hint-color);
      background-color: var(--modal-hint-background-color);
    }

    .sb-hint.sb-hint-inactive {
      color: var(--modal-hint-inactive-color);
      background-color: var(--modal-hint-inactive-background-color);
    }

    .sb-description {
      color: var(--modal-description-color);
    }

    .sb-selected-option {
      background-color: var(--modal-selected-option-background-color);
      color: var(--modal-selected-option-color);

      .sb-description {
        color: var(--modal-selected-option-description-color);
      }
    }
  }
}

/* Editor */

#sb-editor {
  .cm-content {
    font-family: var(--editor-font);
  }

  .cm-selectionBackground {
    background-color: var(--editor-selection-background-color);
  }

  .cm-panels-bottom {
    color: var(--editor-panels-bottom-color);
    background-color: var(--editor-panels-bottom-background-color);
    border-top: var(--editor-panels-bottom-border-color) 1px solid;

    .cm-search {
      .cm-textfield {
        background-color: var(--editor-panels-bottom-input-background-color);
      }
      .cm-button {
        background-image: var(--editor-panels-bottom-button-background-image);
        &:active {
          background-image: var(
            --editor-panels-bottom-button-active-background-image
          );
        }
      }
    }
  }

  .cm-editor .cm-tooltip-autocomplete {
    background-color: var(--root-background-color);
    color: var(--modal-color);

    li .cm-completionDetail {
      color: var(--editor-completion-detail-color);
    }

    li[aria-selected] .cm-completionDetail {
      color: var(--editor-completion-detail-selected-color);
    }
  }

  .cm-list-bullet::after {
    color: var(--editor-list-bullet-color);
  }

  .sb-line-h1,
  .sb-line-h2,
  .sb-line-h3,
  .sb-line-h4,
  .sb-line-h5,
  .sb-line-h6 {
    // background-color: rgba(0, 30, 77, 0.5);
    color: var(--editor-heading-color);
  }

  .sb-line-h1 .sb-meta,
  .sb-line-h2 .sb-meta,
  .sb-line-h3 .sb-meta,
  .sb-line-h4 .sb-meta,
  .sb-line-h5 .sb-meta,
  .sb-line-h6 .sb-meta {
    color: var(--editor-heading-meta-color);
  }
}

.sb-hashtag,
.hashtag {
  color: var(--editor-hashtag-color);
  background-color: var(--editor-hashtag-background-color);
  border: 1px solid var(--editor-hashtag-border-color);
  cursor: pointer;
}

.sb-line-hr {
  border-top: var(--editor-ruler-color) solid 1px;
}

.sb-naked-url {
  color: var(--editor-naked-url-color);
}

/* Color list items when the cursor is on it */
.sb-li-cursor .sb-meta {
  color: var(--editor-line-meta-color);
}

thead tr {
  background-color: var(--editor-table-head-background-color);
  color: var(--editor-table-head-color);
}

tbody tr:nth-of-type(even) {
  background-color: var(--editor-table-even-background-color);
}

.sb-line-blockquote {
  background-color: var(--editor-blockquote-background-color);
  color: var(--editor-blockquote-color);
  border-left: 1px solid var(--editor-blockquote-border-color);
}

.sb-line-code {
  background-color: var(--editor-code-background-color);
}

.sb-struct {
  color: var(--editor-struct-color);
}

.sb-code {
  background-color: var(--editor-code-background-color);
}

.sb-line-fenced-code .sb-code {
  color: var(--editor-code-color);
}

.sb-directive-mark {
  color: var(--editor-directive-mark-color);
  font-weight: bold;
}

.sb-directive {
  color: var(--editor-directive-color);
  font-weight: bold;
}

.sb-highlight {
  background-color: var(--editor-highlight-background-color);
}

.sb-line-fenced-code {
  background-color: var(--editor-code-background-color);
}

.sb-line-fenced-code .sb-comment {
  color: var(--editor-code-comment-color);
}

.sb-variableName {
  color: var(--editor-code-variable-color);
}

.sb-typeName {
  color: var(--editor-code-typename-color);
}

.sb-string,
.sb-string2 {
  color: var(--editor-code-string-color);
}

.sb-number {
  color: var(--editor-code-number-color);
}

.sb-operator {
  color: var(--editor-code-operator-color);
}

.sb-meta {
  color: var(--editor-meta-color);
}

.sb-deleted {
  color: var(--editor-code-deleted-color, #dc2626);
  background-color: var(--editor-code-deleted-bg, rgba(254, 202, 202, 0.25));
}

.sb-inserted {
  color: var(--editor-code-inserted-color, #16a34a);
  background-color: var(--editor-code-inserted-bg, rgba(187, 247, 208, 0.25));
}

// Admonitions

.sb-admonition {
  border-left-color: var(--admonition-color);
}

.sb-admonition-title {
  background-color: color-mix(
    in srgb,
    var(--admonition-color),
    transparent 90%
  );
}

.sb-admonition-type::before {
  background-color: var(--admonition-color);
}

// Frontmatter

.sb-frontmatter {
  background-color: var(--editor-frontmatter-background-color);
  color: var(--editor-frontmatter-color);
}

.sb-frontmatter-marker {
  color: var(--editor-frontmatter-marker-color);
}

.sb-emphasis {
  font-style: italic;
}

.sb-strong {
  font-weight: 900;
}

.sb-sub {
  vertical-align: sub;
  font-size: smaller;
}

.sb-sup {
  vertical-align: super;
  font-size: smaller;
}

.sb-line-code-outside .sb-code-info {
  color: var(--editor-code-info-color);
}

a,
.sb-link:not(.sb-meta, .sb-url) {
  color: var(--editor-link-color);
}

.sb-link.sb-url {
  color: var(--editor-link-meta-color);
}

.sb-url:not(.sb-link) {
  color: var(--editor-link-url-color);
}

.sb-atom, .sb-attribute-name {
  color: var(--editor-code-atom-color);
}

.sb-wiki-link {
  color: var(--editor-wiki-link-page-color);
  background-color: var(--editor-wiki-link-page-background-color);
}

a.sb-wiki-link-missing,
.sb-wiki-link-missing > .sb-wiki-link {
  color: var(--editor-wiki-link-page-missing-color);
}

a.sb-wiki-link-invalid,
.sb-wiki-link-invalid > .sb-wiki-link {
  color: var(--editor-wiki-link-page-invalid-color);
}

.sb-task-mark {
  color: var(--editor-task-marker-color);
}

.sb-task-state {
  color: var(--editor-task-state-color);
}

.sb-line-comment {
  background-color: var(--editor-code-comment-color); // rgba(255, 255, 0, 0.5);
}


================================================
FILE: client/styles/editor.scss
================================================
.cm-focused {
  outline: none !important;
}

#sb-main .cm-editor {
  font-size: 18px;
  height: 100%;

  .cm-content {
    width: 100%;
    margin-left: auto;
    margin-right: auto;
    max-width: var(--#{"editor-width"});
    padding: 5px 20px;
  }

  .cm-line {
    padding: 0;

    &.sb-line-h1,
    &.sb-line-h2,
    &.sb-line-h3,
    &.sb-line-h4,
    &.sb-line-h5,
    &.sb-line-h6 {
      font-weight: bold;
      padding: 2px;
    }
  }

  .cm-line:has(.sb-directive) {
    background-color: var(--editor-directive-background-color);
  }

  .sb-line-h1,
  h1 {
    font-size: 1.5em;
  }

  .sb-line-h2,
  h2 {
    font-size: 1.2em;
  }

  .sb-line-h3,
  h3 {
    font-size: 1.1em;
  }

  .sb-line-h4,
  h4,
  .sb-line-h5,
  h5,
  .sb-line-h6,
  h6 {
    font-size: 1em;
  }

  // If a header only contains a tag, it's likely a line containging "#" which may turn into a hashtag, so style it as such instead of a header
  .sb-line-h1:has(> a.sb-hashtag:only-child) {
    font-size: 1em;
    padding: 0;
    font-weight: normal;

    .sb-meta {
      color: var(--editor-hashtag-color);
    }
  }

  .cm-panels-bottom .cm-vim-panel {
    padding: 0 20px;
    max-width: var(--editor-width);
    margin: auto;
    font-family: var(--editor-font);
    height: 1.5em;
    line-height: 1.5em;

    input {
      font-family: var(--editor-font);
      font-size: 1em;
    }
  }

  // Gutter styling
  .cm-gutters {
    background-color: transparent;
    border-right: none;
  }

  .cm-foldPlaceholder {
    background-color: transparent;
    border: 0;
  }

  // Indentation of follow-up lines
  @mixin lineOverflow($baseIndent, $bulletIndent: 0) {
    text-indent: calc(-1 * (#{$baseIndent}ch + 2ch));
    padding-left: calc(#{$baseIndent}ch + 2ch);

    &.sb-line-task {
      text-indent: calc(-1 * (#{$baseIndent}ch + 5ch));
      padding-left: calc(#{$baseIndent}ch + 5ch);

      .cm-list-bullet::after {
        left: calc(#{$baseIndent}ch + 5ch);
      }
    }

    &.sb-line-blockquote {
      text-indent: calc(-1 * (#{$baseIndent}ch + 4ch));
      padding-left: calc(#{$baseIndent}ch + 4ch);
    }

    .cm-list-bullet::after {
      left: calc(#{$baseIndent}ch + #{$bulletIndent}ch + 2ch);
    }
  }

  .sb-line-ul {
    &.sb-line-li-1 {
      @include lineOverflow(0);
    }

    &.sb-line-li-1.sb-line-li-2 {
      @include lineOverflow(2);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3 {
      @include lineOverflow(4);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4 {
      @include lineOverflow(6);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4.sb-line-li-5 {
      @include lineOverflow(8);
    }
  }

  .sb-line-ol {
    &.sb-line-li-1 {
      @include lineOverflow(1);
    }

    &.sb-line-li-1.sb-line-li-2 {
      @include lineOverflow(4);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3 {
      @include lineOverflow(7);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4 {
      @include lineOverflow(10);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4.sb-line-li-5 {
      @include lineOverflow(13);
    }
  }

  .sb-line-ol.sb-line-ul {
    // &.sb-line-li-1 {
    //   @include lineOverflow(1);
    // }

    &.sb-line-li-1.sb-line-li-2 {
      @include lineOverflow(3);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3 {
      @include lineOverflow(6);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4 {
      @include lineOverflow(9);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4.sb-line-li-5 {
      @include lineOverflow(12);
    }
  }

  .sb-line-blockquote {
    &.sb-line-li-1 {
      @include lineOverflow(-1, 2);
    }

    &.sb-line-li-1.sb-line-li-2 {
      @include lineOverflow(1, 2);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3 {
      @include lineOverflow(4);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4 {
      @include lineOverflow(6);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4.sb-line-li-5 {
      @include lineOverflow(8);
    }
  }

  .sb-line-comment {
    text-indent: -1 * 3ch;
    padding-left: 3ch;
  }

  .cm-list-bullet {
    position: relative;
    visibility: hidden;
  }

  .cm-list-bullet::after {
    visibility: visible;
    position: absolute;
    content: "\2022";
    /* U+2022 BULLET */
  }

  .cm-task-checked,
  .sb-line-task:has(.cm-task-checked) .sb-wiki-link {
    text-decoration: line-through !important;
  }

  .cm-tooltip-autocomplete {
    .cm-completionDetail {
      font-style: normal;
      display: block;
      font-size: 80%;
      margin-left: 5px;
    }

    .cm-completionLabel {
      display: block;
      margin-left: 5px;
    }

    .cm-completionIcon {
      display: none;
    }
  }

  .sb-header-inside.sb-line-h1 {
    text-indent: -2ch;
  }

  .sb-header-inside.sb-line-h2 {
    text-indent: -3ch;
  }

  .sb-header-inside.sb-line-h3 {
    text-indent: -4ch;
  }

  .sb-header-inside.sb-line-h4 {
    text-indent: -5ch;
  }

  .sb-header-inside.sb-line-h5 {
    text-indent: -6ch;
  }

  .sb-header-inside.sb-line-h6 {
    text-indent: -7ch;
  }

  .sb-checkbox > input[type="checkbox"] {
    width: 3ch;
  }

  .sb-hashtag,
  .hashtag {
    border-radius: 6px;
    padding: 0 3px;
    margin: 0 1px 0 0;
    font-size: 0.9em;
  }

  a.sb-hashtag {
    text-decoration: none;
  }

  .sb-strikethrough {
    text-decoration: line-through;

    &.sb-meta {
      text-decoration: none;
    }
  }

  .sb-line-hr {
    margin-top: 1em;
    margin-bottom: -1em;
  }

  .sb-hr {
    font-weight: bold;
  }

  .sb-naked-url {
    cursor: pointer;
  }

  .sb-link:not(.sb-url) {
    cursor: pointer;
  }

  .sb-link:not(.sb-meta, .sb-url) {
    text-decoration: underline;
  }

  .sb-url:not(.sb-link) {
    text-decoration: underline;
    cursor: pointer;
  }

  .sb-wiki-link {
    border-radius: 5px;
    padding: 0 5px;
    // white-space: nowrap;
    text-decoration: none;
    cursor: pointer;
  }

  .sb-lua-directive-inline {
    display: inline;
    padding: 2px;
  }

  .sb-lua-directive-block {
    display: block;
    margin: -1em 0;
  }

  .sb-widget-array {
    display: flex;
    flex-direction: column;
  }

  .sb-task-mark {
    font-size: 91%;
  }

  .sb-task-state {
    font-size: 91%;
  }

  .sb-task-deadline {
    background-color: rgba(22, 22, 22, 0.07);
  }

  .sb-line-frontmatter-outside,
  .sb-line-code-outside {
    .sb-meta {
      color: transparent;
    }

    color: transparent;
  }

  .sb-line-blockquote {
    text-indent: -2ch;
  }

  .sb-blockquote-outside {
    text-indent: -1ch;
    min-height: 1em;
  }

  .sb-line-table-outside {
    display: none;
  }

  .sb-line-tbl-header {
    font-weight: bold;
  }

  .sb-line-tbl-header .meta {
    font-weight: normal;
  }

  .sb-table-widget {
    display: block;
    font-weight: normal;
    margin-bottom: -3rem;
    overflow: auto;
  }

  table {
    width: 100%;
    border-spacing: 0;
  }

  thead tr {
    font-weight: bold;
  }

  thead tr td {
    // Primarily to make the column headers look reasonable with the button bar that may be put on top
    padding: 11px 8px;
  }

  th,
  td {
    padding: 8px;
    white-space: nowrap;
  }

  // Table alignment classes for justified tables
  .sb-table-align-left {
    text-align: left;
  }

  .sb-table-align-right {
    text-align: right;
  }

  .sb-table-align-center {
    text-align: center;
  }

  // dont apply background color twice for (fenced) code blocks
  .sb-line-code .sb-code {
    background-color: transparent;
  }

  .sb-line-code-outside .sb-code-info {
    display: block;
    float: right;
    font-size: 90%;
    padding-right: 7px;
  }

  .sb-code-copy-button {
    float: right;
    cursor: pointer;
    margin: 0 3px;
  }

  .sb-code-copy-button > svg {
    height: 1rem;
    width: 1rem;
  }

  .sb-line-fenced-code .sb-code {
    background-color: transparent;
  }

  .sb-line-fenced-code .sb-comment {
    border-radius: 0;
    font-style: inherit;
    font-size: inherit;
    line-height: inherit;
  }

  .sb-keyword {
    font-weight: bold;
  }

  // Allow for cursor movement into a code widget fenced code block from bottom-up
  .sb-fenced-code-hide {
    background-color: transparent;
    line-height: 0;
  }

  .sb-lua-wrapper {
    ::selection {
      background-color: var(--editor-selection-background-color) !important;
    }
  }

  .sb-lua-top-widget,
  .sb-lua-bottom-widget {
    border: 1px solid var(--editor-widget-background-color);
    border-radius: 5px;
    white-space: normal;
    position: relative;
    min-height: 48px;
  }

  .sb-lua-top-widget {
    margin: 0 0 10px 0;
  }

  .sb-lua-bottom-widget {
    margin: 10px 0 0 0;
  }

  .sb-lua-top-widget h1,
  .sb-lua-bottom-widget h1 {
    margin: -10px -10px 10px -10px !important;
    padding: 15px 10px !important;
    background-color: var(--editor-widget-background-color);
    font-size: 1.2em;
  }

  .sb-markdown-top-widget h1,
  .sb-markdown-bottom-widget h1 {
    margin: 0 0 5px 0;
    padding: 10px !important;
    background-color: var(--editor-widget-background-color);
    font-size: 1.2em;
  }

  .sb-markdown-top-widget:has(*) {
    margin-bottom: 10px;
  }

  .sb-markdown-bottom-widget {
    margin-top: 10px;
  }

  .sb-lua-top-widget .content,
  .sb-lua-bottom-widget .content {
    max-height: 500px;
    padding: 10px;
  }

  .sb-lua-bottom-widget p strong {
    display: block;
    padding: 10px 12px;
    background-color: var(--editor-widget-background-color);
    border: 1px solid var(--editor-widget-background-color);
    border-radius: 8px 8px 0 0;
    font-weight: 600;
  }

  .sb-lua-bottom-widget blockquote {
    margin: 0 5px;
    padding: 12px;
    border: 1px solid var(--editor-widget-background-color);
    border-radius: 8px;
    background-color: var(--editor-widget-background-color);
    color: var(--editor-text-color);
    border-top: 1px solid var(--editor-widget-background-color);
  }

  .sb-markdown-top-widget:has(*) .content {
    max-height: 500px;
  }

  @media screen and (max-height: 1000px) {
    .sb-markdown-top-widget:has(*) .content {
      max-height: 300px;
    }
  }

  .sb-markdown-widget,
  .sb-lua-directive-block,
  .sb-lua-directive-inline,
  .sb-markdown-top-widget:has(*),
  .sb-markdown-bottom-widget:has(*) {
    border: 1px solid var(--editor-widget-background-color);
    border-radius: 5px;
    white-space: normal;
    position: relative;
    min-height: 48px;

    .content {
      overflow-y: auto;
    }

    ul,
    ol {
      margin-top: 0;
      margin-bottom: 0;
    }

    ul {
      list-style: none;
      // padding-left: 1ch;
    }

    ul li::before {
      content: "\2022";
      /* Add content: \2022 is the CSS Code/unicode for a bullet */
      color: var(--editor-list-bullet-color);
      display: inline-block;
      /* Needed to add space between the bullet and the text */
      width: 1em;
      /* Also needed for space (tweak if needed) */
      margin-left: -1em;
    }

    h1,
    h2,
    h3,
    h4,
    h5 {
      margin: 0;
    }

    a.wiki-link {
      border-radius: 5px;
      padding: 0 5px;
      color: var(--editor-wiki-link-page-color);
      background-color: var(--editor-wiki-link-page-background-color);
      text-decoration: none;
    }

    span.task-deadline {
      background-color: rgba(22, 22, 22, 0.07);
    }

    tt {
      background-color: var(--editor-code-background-color);
    }

    // Only show the button bar on hover on non-touch devices
    &:hover .button-bar {
      display: block;
    }

    // Always show button bar on touch devices
    @media (hover: none) and (pointer: coarse) {
      .button-bar {
        display: block !important;
      }
    }

    .button-bar {
      position: absolute;
      right: 0;
      top: 0;
      display: none;
      background: var(--editor-widget-background-color);
      padding-inline: 3px;
      padding: 4px 0;
      border-radius: 0 0 0 5px;

      button {
        border: none;
        background: none;
        cursor: pointer;
        color: var(--root-color);
        margin-right: -8px;
        padding: 11px 8px;
      }

      button:last-of-type {
        margin-right: 2px;
      }
    }
  }

  .sb-lua-directive-inline:has(> button) {
    border: none;
    border-radius: 0;
  }

  .sb-inline-content > div > .content > * {
    max-width: 100%;
    display: block;
  }

  .sb-inline-content {
    margin: 0;
    overflow: hidden;
  }

  .sb-fenced-code-iframe {
    background-color: transparent;

    iframe {
      border: 0;
      width: 100%;
      max-width: 100%;
      padding: 0;
      margin: 0 0 -2ch 0;
      border: 1px solid var(--editor-widget-background-color);
      border-radius: 5px;
    }
  }

  .sb-line-blockquote.sb-line-ul.sb-line-li > .sb-quote.sb-meta:first-child {
    margin-left: -1ch;
  }

  .sb-admonition {
    border-left-width: 4px !important;
    border-left-style: solid;
    padding-left: 5px;
  }

  .sb-admonition-type::before {
    content: "";
    mask: var(--admonition-icon) no-repeat;
    -webkit-mask: var(--admonition-icon) no-repeat;
    mask-size: cover;
    -webkit-mask-size: cover;

    /* used in (custom) admonition definitions */
    --admonition-width: 1.1em;
    width: 0em;
    height: 1.1em;
    display: inline-flex;
    vertical-align: middle;
  }

  .sb-frontmatter-marker {
    float: right;
    font-size: 80%;
    padding-right: 7px;
  }

  .cm-scroller {
    // Give some breathing space at the bottom of the screen
    padding-bottom: 20em;
  }
}

div:not(.cm-focused).cm-fat-cursor {
  outline: none !important;
}


================================================
FILE: client/styles/main.scss
================================================
@use "editor";
@use "modals";
@use "theme";
@use "colors";
@use "top";

@font-face {
  font-family: "iA-Mono";
  src: url("iAWriterMonoS-Regular.woff2");
  font-weight: normal;
  font-style: normal;
}

@font-face {
  font-family: "iA-Mono";
  src: url("iAWriterMonoS-Bold.woff2");
  font-weight: bold;
  font-style: normal;
}

@font-face {
  font-family: "iA-Mono";
  src: url("iAWriterMonoS-Italic.woff2");
  font-weight: normal;
  font-style: italic;
}

@font-face {
  font-family: "iA-Mono";
  src: url("iAWriterMonoS-BoldItalic.woff2");
  font-weight: bold;
  font-style: italic;
}

html,
body {
  margin: 0;
  height: 100%;
  padding: 0;
  width: 100%;
  overflow: hidden;
  background-color: var(--top-background-color);
}

#sb-root {
  display: flex;
  flex-direction: column;
  width: 100%;
  height: 100%;
  background-color: var(--root-background-color);
}

.sb-panel {
  flex: 1;
}

.sb-bottom-iframe {
  width: 100%;
  margin-top: 10px;
  border: 1px solid var(--editor-widget-background-color);
  border-radius: 5px;
}

.sb-top-iframe {
  width: 100%;
  margin-top: 10px;
  border: 1px solid var(--editor-widget-background-color);
  border-radius: 5px;
}

#sb-main {
  display: flex;
  flex-direction: row;
  flex-grow: 1;
  height: 0;

  .sb-panel {
    flex: 1;
  }
}

#sb-editor {
  flex: 2;
  height: 100%;

  width: 100%;

  iframe {
    width: 100%;
    height: 100%;
    border: none;
  }
}

#sb-editor.hide-cm {
  .cm-editor {
    display: none !important;
  }
}

.sb-bhs {
  height: 300px;
  width: 100%;
  z-index: 10;

  .sb-panel {
    height: 100%;
  }
}

.sb-modal {
  position: absolute;
  z-index: 100;

  .sb-panel {
    height: 100%;

    iframe {
      background-color: initial;
    }
  }
}

.sb-page-prefix {
  display: flex;
  align-items: baseline;
  flex: 0 0 auto;
  text-align: left;
  padding-top: 3px;
  font-family: var(--ui-font);
  white-space: pre-wrap;
}

.sb-panel {
  iframe {
    background-color: var(--root-background-color);
    border: 0;
    width: 100%;
    height: 100%;
    padding: 0;
    margin: 0;
  }
}

.sb-preview {
  position: relative;
}

.sb-markdown-toolbar {
  position: absolute;
  opacity: 0;
  transition: opacity 0.2s;
  width: 100%;
  display: flex;
  justify-content: flex-end;
  box-sizing: border-box;
}

.sb-markdown-toolbar:hover {
  opacity: 1;
}


================================================
FILE: client/styles/modals.scss
================================================
.sb-modal-box {
  margin-top: 60px;
  padding: 0;
  width: 700px;
  max-width: 90%;

  border-radius: 8px;
  overflow: hidden;

  .cm-content {
    padding: 0;

    .cm-line {
      padding: 2px 0 0 3px;
    }
  }

  .sb-header {
    padding: 13px 10px 10px 10px;
    display: flex;

    label {
      margin: 3px;
    }

    .sb-mini-editor {
      flex-grow: 1;
      min-width: 0;
    }
  }

  .sb-prompt {
    padding: 13px 10px 10px 10px;

    label {
      font-weight: bold;
    }

    .sb-mini-editor {
      background-color: var(--text-field-background-color);
      margin: 10px 0;
      padding: 0.2em 0.5em;
      border: 0;
      border-radius: 0.5em;
      outline: none;
    }

    .sb-prompt-buttons {
      display: flex;
      justify-content: flex-end;
      margin-top: 10px;
      gap: 10px;
    }
  }

  .sb-help-text {
    padding: 5px;
  }

  .sb-result-list {
    max-height: 250px;
    overflow-y: scroll;

    .sb-icon {
      padding: 0 8px 0 5px;
      position: relative;
      top: 3px;
    }

    .sb-description {
      font-size: 75%;
    }
  }

  .sb-option,
  .sb-selected-option {
    padding: 8px;
    cursor: pointer;
    line-height: 20px;
  }

  .sb-option .sb-hint,
  .sb-selected-option .sb-hint {
    float: right;
    margin-right: 0;
    margin-top: -4px;
    padding-left: 5px;
    padding-right: 5px;
    padding-top: 3px;
    padding-bottom: 3px;
    border-radius: 5px;
  }
}


================================================
FILE: client/styles/theme.scss
================================================
html {
  --ui-accent-color: #464cfc;
  --ui-accent-text-color: var(--ui-accent-color);
  --ui-accent-contrast-color: #eee;
  --highlight-color: rgba(255, 255, 0, 0.5);
  --link-color: #0330cb;
  --link-missing-color: #9e4705;
  --link-invalid-color: #bb08e7;
  --meta-color: #650007;
  --meta-subtle-color: #959595;
  --subtle-color: #676767;
  --subtle-background-color: rgba(72, 72, 72, 0.1);

  --root-background-color: #fff;
  --root-color: inherit;

  --top-color: inherit;
  --top-background-color: #e1e1e1;
  --top-border-color: #cacaca;
  --top-sync-error-color: var(--top-color);
  --top-sync-error-background-color: #fdf8cb;
  --top-saved-color: #111;
  --top-unsaved-color: #5e5e5e;
  --top-loading-color: #7a7a7a;

  --panel-background-color: #fff;
  --panel-border-color: #fff;

  --bhs-background-color: #fff;
  --bhs-border-color: rgb(193, 193, 193);

  --modal-color: inherit;
  --modal-background-color: #fff;
  --modal-border-color: rgb(108, 108, 108);
  --modal-header-label-color: var(--ui-accent-text-color);
  --modal-help-background-color: #eee;
  --modal-help-color: #555;
  --modal-selected-option-background-color: var(--ui-accent-color);
  --modal-selected-option-color: var(--ui-accent-contrast-color);
  --modal-hint-background-color: #212476;
  --modal-hint-color: #eee;
  --modal-hint-inactive-background-color: #e1e1e1;
  --modal-hint-inactive-color: #111;
  --modal-description-color: #6b6b6b;
  --modal-selected-option-description-color: #e6e6e6;

  --notifications-background-color: inherit;
  --notifications-border-color: rgb(41, 41, 41);
  --notification-info-background-color: rgb(187, 221, 247);
  --notification-error-background-color: rgb(255, 84, 84);

  --button-background-color: #eee;
  --button-hover-background-color: inherit;
  --button-color: black;
  --button-border-color: #6c6c6c;
  --primary-button-background-color: var(--ui-accent-color);
  --primary-button-hover-background-color: color-mix(
    in srgb,
    var(--ui-accent-color),
    black 35%
  );
  --primary-button-color: var(--ui-accent-contrast-color);
  --primary-button-border-color: transparent;

  --text-field-background-color: var(--button-background-color);

  --progress-background-color: #eee;
  --progress-sync-color: black;
  --progress-index-color: #0772be;

  --action-button-background-color: transparent;
  --action-button-color: #292929;
  --action-button-hover-color: #0772be;
  --action-button-active-color: #0772be;

  --editor-caret-color: black;
  --editor-selection-background-color: #d7e1f6;
  --editor-panels-bottom-color: inherit;
  --editor-panels-bottom-background-color: #e1e1e1;
  --editor-panels-bottom-border-color: #cacaca;
  --editor-completion-detail-color: #555;
  --editor-completion-detail-selected-color: #d2d2d2;
  --editor-list-bullet-color: rgb(150, 150, 150);
  --editor-heading-color: #333;
  --editor-heading-meta-color: var(--meta-subtle-color);
  --editor-hashtag-background-color: #002b6aad;
  --editor-hashtag-color: #e2e9ff;
  --editor-hashtag-border-color: #0120416b;
  --editor-ruler-color: rgb(76, 75, 75);
  --editor-naked-url-color: var(--link-color);
  --editor-code-color: #7c828e;
  --editor-link-color: var(--link-color);
  --editor-link-url-color: var(--link-color);
  --editor-link-meta-color: var(--meta-subtle-color);
  --editor-wiki-link-page-background-color: rgba(77, 141, 255, 0.07);
  --editor-wiki-link-page-color: var(--link-color);
  --editor-wiki-link-page-missing-color: var(--link-missing-color);
  --editor-wiki-link-page-invalid-color: var(--link-invalid-color);
  --editor-wiki-link-color: #8f96c2;
  --editor-command-button-color: inherit;
  --editor-command-button-background-color: #e3dfdf;
  --editor-command-button-hover-background-color: inherit;
  --editor-command-button-meta-color: var(--meta-subtle-color);
  --editor-command-button-border-color: gray;
  --editor-line-meta-color: var(--meta-subtle-color);
  --editor-meta-color: var(--meta-color);
  --editor-table-head-background-color: #333;
  --editor-table-head-color: #eee;
  --editor-table-even-background-color: #f3f3f3;
  --editor-blockquote-background-color: var(--subtle-background-color);
  --editor-blockquote-color: var(--subtle-color);
  --editor-blockquote-border-color: rgb(74, 74, 74);
  --editor-code-background-color: var(--subtle-background-color);
  --editor-struct-color: darkred;
  --editor-highlight-background-color: var(--highlight-color);
  --editor-code-comment-color: var(--meta-subtle-color);
  --editor-code-variable-color: #024866;
  --editor-code-typename-color: #038138;
  --editor-code-string-color: #540293;
  --editor-code-number-color: #01833c;
  --editor-code-operator-color: #808080;
  --editor-code-info-color: var(--subtle-color);
  --editor-code-atom-color: #5a0000;
  --editor-frontmatter-background-color: rgba(255, 246, 189, 0.3);
  --editor-frontmatter-color: var(--subtle-color);
  --editor-frontmatter-marker-color: #89000080;
  --editor-widget-background-color: rgb(238, 238, 238);
  --editor-task-marker-color: var(--subtle-color);
  --editor-task-state-color: var(--subtle-color);

  --editor-directive-mark-color: #6c0101;
  --editor-directive-color: #696969;
  --editor-directive-background-color: #ebebeb7d;

  --editor-panels-bottom-input-background-color: #fff;
  --editor-panels-bottom-button-background-image: linear-gradient(
    #eff1f5,
    #d9d9df
  );
  --editor-panels-bottom-button-active-background-image: linear-gradient(
    #b4b4b4,
    #d0d3d6
  );

  --ui-font:
    -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
    Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
    "Segoe UI Symbol", "Noto Color Emoji";
  --editor-font: "iA-Mono", "Menlo";
  --editor-width: 800px;
}

html[data-theme="light"] {
  color-scheme: light;
}

html[data-theme="dark"] {
  color-scheme: dark;

  --ui-accent-color: #464cfc;
  --ui-accent-text-color: var(--ui-accent-color);
  --ui-accent-text-color: color-mix(
    in srgb,
    var(--ui-accent-color),
    white 50%
  );
  --highlight-color: rgba(255, 255, 0, 0.5);
  --link-color: #7e99fc;
  --link-missing-color: #9e4705;
  --link-invalid-color: #d444f8;
  --meta-color: #d6222e;
  --meta-subtle-color: #959595;
  --subtle-color: #959595;
  --subtle-background-color: rgba(105, 105, 105, 0.1);

  --root-background-color: #111;
  --root-color: #fff;

  --top-color: #fff;
  --top-background-color: #262626;
  --top-border-color: rgb(62, 62, 62);
  --top-sync-error-color: var(--top-color);
  --top-sync-error-background-color: #622626;
  --top-saved-color: #fff;
  --top-unsaved-color: #c7c7c7;
  --top-loading-color: #c7c7c7;

  --panel-background-color: #111;
  --panel-border-color: rgb(62, 62, 62);

  --bhs-background-color: #111;
  --bhs-border-color: rgb(62, 62, 62);

  --modal-color: #ccc;
  --modal-background-color: #262626;
  --modal-border-color: #6c6c6c;
  --modal-header-label-color: var(--ui-accent-text-color);
  --modal-help-background-color: #333;
  --modal-help-color: #ccc;
  --modal-selected-option-background-color: var(--ui-accent-color);
  --modal-selected-option-color: #eee;
  --modal-hint-background-color: #212476;
  --modal-hint-color: #eee;
  --modal-hint-inactive-background-color: #353535;
  --modal-hint-inactive-color: #ccc;
  --modal-description-color: #969696;
  --modal-selected-option-description-color: #e6e6e6;

  --notifications-background-color: #333;
  --notifications-border-color: rgb(197, 197, 197);
  --notification-info-background-color: #1b76bb;
  --notification-error-background-color: #a32121;

  --button-background-color: #555;
  --button-hover-background-color: #777;
  --button-color: white;
  --button-border-color: #666;
  --primary-button-background-color: var(--ui-accent-color);
  --primary-button-hover-background-color: color-mix(
    in srgb,
    var(--ui-accent-color),
    black 35%
  );
  --primary-button-color: var(--ui-accent-contrast-color);
  --primary-button-border-color: transparent;

  --progress-background-color: #555;
  --progress-sync-color: white;
  --progress-index-color: yellow;

  --text-field-background-color: var(--button-background-color);

  --action-button-background-color: transparent;
  --action-button-color: #adadad;
  --action-button-hover-color: #37a1ed;
  --action-button-active-color: #37a1ed;

  --editor-caret-color: #fff;
  --editor-selection-background-color: #d7e1f630;
  --editor-panels-bottom-color: #fff;
  --editor-panels-bottom-background-color: #262626;
  --editor-panels-bottom-border-color: rgb(62, 62, 62);
  --editor-completion-detail-color: #aaa;
  --editor-completion-detail-selected-color: #d2d2d2;
  --editor-list-bullet-color: rgb(150, 150, 150);
  --editor-heading-color: #d1d1d1;
  --editor-heading-meta-color: var(--meta-subtle-color);
  --editor-hashtag-background-color: #004bbb;
  --editor-hashtag-color: #e2e9ff;
  --editor-hashtag-border-color: #007bff6b;
  --editor-ruler-color: rgb(76, 75, 75);
  --editor-naked-url-color: var(--link-color);
  --editor-link-color: var(--link-color);
  --editor-link-url-color: var(--link-color);
  --editor-link-meta-color: var(--meta-subtle-color);
  --editor-wiki-link-page-background-color: #a3bce712;
  --editor-wiki-link-page-color: var(--link-color);
  --editor-wiki-link-page-missing-color: var(--link-missing-color);
  --editor-wiki-link-page-invalid-color: var(--link-invalid-color);
  --editor-wiki-link-color: #8f96c2;
  --editor-command-button-color: #fff;
  --editor-command-button-background-color: #555;
  --editor-command-button-hover-background-color: #777;
  --editor-command-button-meta-color: var(--meta-subtle-color);
  --editor-command-button-border-color: #666;
  --editor-line-meta-color: var(--meta-subtle-color);
  --editor-meta-color: var(--meta-color);
  --editor-table-head-background-color: rgba(72, 72, 72, 0.4);
  --editor-table-head-color: #eee;
  --editor-table-even-background-color: rgba(72, 72, 72, 0.3);
  --editor-blockquote-background-color: var(--subtle-background-color);
  --editor-blockquote-color: var(--subtle-color);
  --editor-blockquote-border-color: rgb(74, 74, 74);
  --editor-code-background-color: var(--subtle-background-color);
  --editor-struct-color: #d6222e;
  --editor-highlight-background-color: var(--highlight-color);
  --editor-code-comment-color: var(--meta-subtle-color);
  --editor-code-variable-color: #41a3ce;
  --editor-code-typename-color: #038138;
  --editor-code-string-color: #986db9;
  --editor-code-number-color: #986db9;
  --editor-code-info-color: var(--subtle-color);
  --editor-code-atom-color: #d6222e;
  --editor-frontmatter-background-color: rgb(41, 40, 35, 0.5);
  --editor-frontmatter-color: var(--subtle-color);
  --editor-frontmatter-marker-color: #fff;
  --editor-widget-background-color: rgba(72, 72, 72, 0.5);
  --editor-task-marker-color: var(--subtle-color);
  --editor-task-state-color: var(--subtle-color);

  --editor-directive-mark-color: #ba0303;
  --editor-directive-color: #898989;
  --editor-directive-background-color: #4c4c4c7d;

  --editor-panels-bottom-input-background-color: inherit;
  --editor-panels-bottom-button-background-image: linear-gradient(
    #393939,
    #111
  );
  --editor-panels-bottom-button-active-background-image: linear-gradient(
    #111,
    #333
  );
}

html[data-markdown-syntax-rendering="on"] {
  --editor-meta-color: unset;
  --editor-directive-mark-color: unset;

  #sb-main
    .cm-editor
    .sb-lua-directive-block:not(
      .sb-lua-top-widget
    ):not(
      .sb-lua-bottom-widget
    ):not(
      .sb-inline-content
    ) {
    margin-top: 0.5em;
  }
}

.sb-admonition[admonition="note" i] {
  .sb-admonition-type {
    &::before {
      width: var(--admonition-width) !important;
    }

    * {
      display: none;
    }
  }

  --admonition-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>');
  --admonition-color: #00b8d4;
}

.sb-admonition[admonition="warning" i] {
  .sb-admonition-type {
    &::before {
      width: var(--admonition-width) !important;
    }

    * {
      display: none;
    }
  }

  --admonition-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>');
  --admonition-color: #ff9100;
}

.sb-admonition[admonition="danger" i] {
  .sb-admonition-type {
    &::before {
      width: var(--admonition-width) !important;
    }

    * {
      display: none;
    }
  }

  --admonition-icon: url('data:image/svg+xml,<svg  width="16" height="16" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-x-octagon"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>');
  --admonition-color: #d50000;
}

.sb-admonition[admonition="success" i] {
  .sb-admonition-type {
    &::before {
      width: var(--admonition-width) !important;
    }

    * {
      display: none;
    }
  }

  --admonition-icon: url('data:image/svg+xml,<svg  width="16" height="16" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-check-circle"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>');
  --admonition-color: #00c853;
}


================================================
FILE: client/styles/top.scss
================================================
#sb-top {
  display: flex;
  flex-direction: row;
  z-index: 20;

  height: 55px;

  .main {
    flex: 2;
    max-width: 100%;

    .inner {
      // Hack to not have SCSS precompile this value but use proper CSS variables
      max-width: var(--#{"editor-width"});
      margin: auto;
      font-size: 28px;
      padding: 8px 0;
      display: flex;
      flex-direction: row;

      .wrapper {
        width: env(titlebar-area-width, 100%);
        padding: 0 20px;
        display: flex;
        box-sizing: border-box;
        position: relative;

        .sb-notifications {
          position: absolute;
          right: 20px;
          top: 50px;

          font-size: 15px;
          z-index: 100;

          > div {
            padding: 3px;
            margin-bottom: 3px;
            border-radius: 5px;
          }
        }
      }
    }

    #sb-current-page {
      flex: 1;

      overflow: hidden;
      white-space: nowrap;
      text-align: left;
      display: block;

      -webkit-app-region: drag;

      .cm-scroller {
        font-family: var(--ui-font);
      }

      .cm-content {
        padding: 0;

        .cm-line {
          padding: 0;
          caret-color: var(--editor-caret-color);
        }
      }
    }
  }

  .sb-actions {
    display: flex;
    flex: 0 0 auto;
    text-align: right;

    &.hamburger button.expander {
      display: none;
    }
  }

  .progress-wrapper {
    position: relative;
    margin-top: 4px;
    margin-right: 6px;
    padding: 4px;
    background-color: var(--top-background-color);
  }

  .progress-bar {
    display: flex;
    justify-content: center;
    align-items: center;

    width: 24px;
    height: 24px;
    border-radius: 50%;
    font-size: 8px;
  }
}

@media only screen and (max-width: 600px) {
  /* Style the menu as a vertical fly-out */
  #sb-top .cm-scroller:has(.hamburger) {
    position: relative;
  }

  #sb-top .sb-sync-progress:has(~ .hamburger) {
    margin-right: 1.8em;
  }

  #sb-top .sb-actions.hamburger {
    position: absolute;

    top: 3px;
    right: 20px;
    border-radius: 5px;
    border: 1px solid #444;
    width: 2.8rem;
    background: color-mix(
      in srgb,
      var(--top-background-color) 90%,
      transparent
    );
    backdrop-filter: blur(20px);
    overflow: hidden;
    flex-direction: column;
    align-items: center;
    z-index: 10;
    transition: max-height 0.3s ease-in-out, box-shadow 0.3s ease-in-out;

    /* Initial collapsed height */
    max-height: 2.2rem;

    button {
      &.expander {
        display: initial;
      }

      &:not(.sb-code-copy-button) {
        display: block;
        height: 1.8rem;
        text-align: center;
        margin: 4px 0;
        padding: 4px 0;
      }

      &:not(:first-of-type) {
        margin: 8px 0;
        opacity: 0;
        transform: translateY(-10px);
        transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
      }
    }

    // CSS class for toggling the hamburger menu, so it can be toggled via JS
    &.open:hover {
      /* Expanded height limited to the viewport height minus some padding */
      max-height: calc(100vh - 24px);
      box-shadow: 0 2px 15px rgba(0, 0, 0, 0.5);

      button {
        opacity: 1;
        transform: translateY(0) translateX(0);
      }
    }
  }

  /* Style the menu as a bottom bar */
  #sb-top .sb-actions.bottom-bar {
    position: fixed;
    bottom: 0;
    left: 0;
    padding: 10px 0;
    background: var(--top-background-color);
    width: 100vw;
    box-shadow: 0px 4px 8px black;
    justify-content: space-around;

    button {
      padding: 1em;
      margin: 0;
      height: unset;
      width: unset;

      svg {
        margin-bottom: -5px;
      }
    }
  }
}


================================================
FILE: client/types/command.ts
================================================
import type {
  CommandDef,
  SlashCommandDef,
} from "@silverbulletmd/silverbullet/type/manifest";

import type { SlashCompletions } from "@silverbulletmd/silverbullet/type/client";

export type Command = CommandDef & {
  run?: (args?: any[]) => Promise<any>;
  lastRun?: number;
};

export type SlashCommand = SlashCommandDef & {
  run: (...args: any[]) => Promise<SlashCompletions>;
};

export type CommandHookEvents = {
  commandsUpdated(commands: Map<string, Command>): void;
};


================================================
FILE: client/types/ui.ts
================================================
import type { Command } from "./command.ts";
import type {
  FilterOption,
  Notification,
  PanelMode,
} from "@silverbulletmd/silverbullet/type/client";

import type {
  DocumentMeta,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import type { Path } from "@silverbulletmd/silverbullet/lib/ref";
import type { SyncStatus } from "../spaces/sync.ts";

export type PanelConfig = {
  mode?: PanelMode;
  html?: string;
  script?: string;
};

export type AppViewState = {
  current?: {
    path: Path;
    meta: PageMeta | DocumentMeta;
  };

  allPages: PageMeta[];
  allDocuments: DocumentMeta[];

  isLoading: boolean;
  isMobile: boolean;
  showPageNavigator: boolean;
  showCommandPalette: boolean;
  showCommandPaletteContext?: string;
  unsavedChanges: boolean;
  isOnline: boolean;

  // Progress tracker
  progressPercentage?: number; // Used to show progress circle
  progressType?: string; // Used for styling

  panels: { [key: string]: PanelConfig };
  commands: Map<string, Command>;
  notifications: Notification[];

  uiOptions: {
    vimMode: boolean;
    darkMode?: boolean;
    markdownSyntaxRendering: boolean;
    forcedROMode: boolean;
    customStyles?: string;
  };

  // Page navigator mode
  pageNavigatorMode: "page" | "meta" | "document" | "all";

  // Filter box
  showFilterBox: boolean;
  filterBoxLabel: string;
  filterBoxPlaceHolder: string;
  filterBoxOptions: FilterOption[];
  filterBoxHelpText: string;
  filterBoxOnSelect: (option: FilterOption | undefined) => void;

  // Prompt
  showPrompt: boolean;
  promptMessage?: string;
  promptDefaultValue?: string;
  promptCallback?: (value: string | undefined) => void;

  // Confirm
  showConfirm: boolean;
  confirmMessage?: string;
  confirmCallback?: (value: boolean) => void;
};

export const initialViewState: AppViewState = {
  isLoading: false,
  showPageNavigator: false,
  showCommandPalette: false,
  pageNavigatorMode: "page",
  unsavedChanges: false,
  isOnline: true,
  uiOptions: {
    vimMode: false,
    darkMode: undefined,
    markdownSyntaxRendering: false,
    forcedROMode: false,
  },
  isMobile: false,
  panels: {
    lhs: {},
    rhs: {},
    bhs: {},
    modal: {},
  },
  allPages: [],
  allDocuments: [],
  commands: new Map(),

  notifications: [],
  showFilterBox: false,
  filterBoxHelpText: "",
  filterBoxLabel: "",
  filterBoxOnSelect: () => {
  },
  filterBoxOptions: [],
  filterBoxPlaceHolder: "",

  showPrompt: false,
  showConfirm: false,
};

export type Action =
  | { type: "page-loaded"; path: Path; meta: PageMeta }
  | { type: "page-changed" }
  | { type: "page-saved" }
  | { type: "document-editor-loaded"; path: Path; meta: DocumentMeta }
  | { type: "document-editor-changed" }
  | { type: "document-editor-saved" }
  | { type: "online-status-change"; isOnline: boolean }
  | { type: "update-current-page-meta"; meta: PageMeta }
  | { type: "update-page-list"; allPages: PageMeta[] }
  | { type: "update-document-list"; allDocuments: DocumentMeta[] }
  | { type: "start-navigate"; mode: "page" | "meta" | "document" | "all" }
  | { type: "stop-navigate" }
  | {
    type: "update-commands";
    commands: Map<string, Command>;
  }
  | { type: "show-palette"; context?: string; commands: Map<string, Command> }
  | { type: "hide-palette" }
  | { type: "show-notification"; notification: Notification }
  | { type: "dismiss-notification"; id: number }
  | {
    type: "show-panel";
    id: "rhs" | "lhs" | "bhs" | "modal";
    config: PanelConfig;
  }
  | { type: "hide-panel"; id: string }
  | {
    type: "show-filterbox";
    options: FilterOption[];
    placeHolder: string;
    helpText: string;
    label: string;
    onSelect: (option: FilterOption | undefined) => void;
  }
  | { type: "hide-filterbox" }
  | {
    type: "show-prompt";
    message: string;
    defaultValue: string;
    callback: (value: string | undefined) => void;
  }
  | { type: "hide-prompt" }
  | {
    type: "show-confirm";
    message: string;
    callback: (value: boolean) => void;
  }
  | { type: "hide-confirm" }
  | { type: "set-ui-option"; key: string; value: any }
  | {
    type: "set-progress";
    progressPercentage?: number;
    progressType?: string;
  };

/**
 * Client configuration that is set at boot time, doesn't change at runtime
 */
export type BootConfig = {
  spaceFolderPath: string;
  indexPage: string;
  readOnly: boolean;
  logPush?: boolean;
  // Sync configuration
  syncDocuments?: boolean;
  syncIgnore?: string;
  // These are all configured via ?query parameters, e.g. ?disableSpaceLua=1
  disableSpaceLua?: boolean;
  disableSpaceStyle?: boolean;
  disablePlugs?: boolean;
  performWipe?: boolean;
  performReset?: boolean;

  enableClientEncryption: boolean;
};

/**
 * Messages sent client -> service worker
 */
export type ServiceWorkerTargetMessage =
  | {
    type: "skip-waiting";
  }
  | { type: "config"; config: BootConfig }
  | { type: "flush-cache" }
  | { type: "shutdown" }
  | { type: "wipe-data" }
  | { type: "perform-file-sync"; path: string }
  | { type: "perform-space-sync" }
  | { type: "force-connection-status"; enabled: boolean }
  | { type: "get-encryption-key" }
  | { type: "set-encryption-key"; key: string };
/**
 * Events received from the service worker -> client
 */
export type ServiceWorkerSourceMessage = {
  type: "sync-status";
  status: Omit<SyncStatus, "snapshot">;
} | {
  type: "sync-conflict";
  path: string;
} | {
  type: "space-sync-complete";
  operations: number;
} | {
  type: "file-sync-complete";
  path: string;
  operations: number;
} | {
  type: "sync-error";
  message: string;
} | {
  type: "online-status";
  isOnline: boolean;
} | {
  type: "auth-error";
  message: string;
  actionOrRedirectHeader: string;
} | {
  type: "cacheFlushed";
} | {
  type: "dataWiped";
} | {
  type: "service-worker-started";
} | {
  type: "encryption-key";
  key: string;
};


================================================
FILE: client_bundle/bundle.go
================================================
package client_bundle

// Files in this folder are generated as part of the build process (except this one)
// The sole purpose of this file is to embed these generated client files into the server binary

import "embed"

//go:embed base_fs/*
//go:embed client/*
var BundledFiles embed.FS


================================================
FILE: libraries/Library/Std.md
================================================
---
tags: meta/library
---
Welcome to SilverBullet’s standard library. This library (all pages under `Library/Std`) ship with SilverBullet itself. Therefore, you will not find these pages in your space folder, even though they _appear_ to be located there. These pages are all read-only, so you cannot (directly) modify them.

The goal of the standard library is provide a base-level of useful commands, slash commands, page templates and scripts.

The remainder of this page documents what's included.

# Config
SilverBullet is configured via the [config APIs](https://silverbullet.md/API/config). Built-in configuration options and their default values are defined in [[^Library/Std/Config]].

# Page templates
Page templates can be triggered via the ${widgets.commandButton "Page: From Template"} command (some of them have command or keyboard shortcuts) and provide a convenient way to create pages of a certain type. You can create your own page templates using the [[^Library/Std/Page Templates/Page Template]] template.

The following page templates are part of the standard library:
${template.each(query[[
  from index.tag "page"
  where name:startsWith("Library/Std/Page Templates/")
]], template.new[==[
    * [[${_.name}]]: ${_.description}
]==])}

# Slash templates
Slash templates provide a quick way to insert frequently used snippets of code. You can create your own using the [[^Library/Std/Page Templates/Slash Template]] page template.

The following slash templates are part of the standard library:
${template.each(query[[
  from index.tag "page"
  where name:startsWith("Library/Std/Slash Templates/")
]], template.new[==[
    * [[${_.name}]]: ${_.description}
]==])}

# Meta pages 
The SilverBullet ships with a few generally useful meta pages (pages tagged with `#meta`) you can use:

${template.each(query[[
  from index.tag "page"
  where name:startsWith("Library/Std/Pages/")
]], templates.pageItem)}

# Infrastructure
Core functionality that is implemented using Space Lua:
${template.each(query[[
  from index.tag "page"
  where name:startsWith("Library/Std/Infrastructure/")
]], template.new[==[
    * [[${_.name}]]: ${_.description}
]==])}

# APIs
Whereas a lot of APIs in SilverBullet are built in, some of them have been implemented Space Lua:
${template.each(query[[
  from index.tag "page"
  where name:startsWith("Library/Std/APIs/")
]], templates.pageItem)}

# Editor support
Some editor features have been implemented in Space Lua:
${template.each(query[[
  from index.tag "page"
  where name:startsWith("Library/Std/Editor/")
]], templates.pageItem)}


================================================
FILE: libraries/Library/Std/APIs/Action Button.md
================================================
---
description: APIs to define custom action buttons
tags: meta/api
---

APIs to more conveniently define action buttons (alternative to setting them via regular [[^Library/Std/Config]]).

# API
## actionButton.define(spec)
Keys to define:

* `icon`: [feather icon](https://feathericons.com) to use for your button
* `run`: function to invoke once the button is pushed
* `description` (optional): description of button (appears on hover)
* `priority` (optional): determines priority of button (the higher, the earlier in the list)
* `mobile`: when set to `true` this button will only appear on mobile devices

# Example
```lua
-- Defines a new button that forces your UI into read-only mode
actionButton.define {
  icon = "eye",
  -- Uncomment the following line to only make this button appear 
  -- ONLY on mobile devices:
  --   mobile = true,
  run = function()
    editor.setUiOption("forcedROMode", true)
    editor.rebuildEditorState()
  end
}
```
# Implementation
```space-lua
-- priority: 100

actionButton = actionButton or {}

function actionButton.define(spec)
  local actionButtonConfig = config.get("actionButtons", {})
  table.insert(actionButtonConfig, spec)
end
```


================================================
FILE: libraries/Library/Std/APIs/Command.md
================================================
---
description: APIs to define custom commands and slash commands
tags: meta/api
---

APIs to define and patch commands and slash commands.

# API

## command.define(commandDef)
Registers a command.

Available keys:

* `name`: Name of the command
* `run`: Callback function
* `contexts`: AST node context in which this command should be available
* `priority`: Command priority (how high it appears in the list)
* `key`: Windows/Linux key binding (and mac, if not separately defined)
* `mac`: Mac-specific key binding
* `hide`: Hide this command from the Command Palette
* `requireMode`: `rw` or `ro` — only enable this command in a particular mode (read-write, or read-only)

Example:

```lua
command.define {
  name = "My custom command",
  run = function()
    editor.flashNotification "Triggered my custom command"
  end
}
```

## command.update(commandDef)
Equivalent to `command.define`, but can be used to update the definition of previously defined commands (including built-in ones).

Example:

```lua
-- To assign a new key binding and command priority to a built-in command
command.update {
  name = "Stats: Show",
  key = "Ctrl-Shift-t",
  priority = 100
}

-- To disable key bindings of an existing command
command.update {
  name = "Navigate: Document Picker",
  key = "",
  mac = "",
}
```

## slashCommand.define(spec)
Defines a custom slash command.

Supported keys in the spec:

* `name`: name of the command
* `description`: Description of the command
* `run`: The callback function that will be invoked once the command is run.

Example:

```lua
slashCommand.define {
  name = "hello-world",
  run = function()
    editor.insertAtCursor("Hello |^| world!", false, true)
  end
}
```

# Implementation
Most of the heavy lifting happens in SB itself. These are just wrappers around the config object API.

```space-lua
-- priority: 99
command = command or {}
slashCommand = slashCommand or {}

-- DEPRECATED: old name of API
slashcommand = slashCommand

function command.define(def)
  config.set({"commands", def.name}, def)
end

function command.update(newDef)
  local def = config.get({"commands", newDef.name}, {})
  for k, v in pairs(newDef) do
    def[k] = v
  end
  config.set({"commands", newDef.name}, def)
end

function slashCommand.define(def)
  config.set({"slashCommands", def.name}, def)
end
```


================================================
FILE: libraries/Library/Std/APIs/DOM.md
================================================
#meta/api

A library to easily build DOM objects through the magic of Lua meta tables.

# Usage

```lua
-- any HTML tag can be used here
dom.span {
  -- tag attributes can be set like this:
  class = "my-class",
  id = "my-id",
  -- Plain text body elements can be added like this
  "Span content",
  -- And elements can be nested
  dom.strong { "I am strong" },
  -- Widgets can also be embedded
  widget.html "<b>Bold</b>",
  widget.html(dom.marquee { "nested widget" })
}
```

# Examples
${widget.html(dom.marquee{
  "I'm in a ",
  dom.span {
    style="color:red;",
    "marquee"
  }
})}

# Implementation
```space-lua
-- priority: 50

local function appendHtmlNode(parent, html)
  local htmlNode = js.window.document.createElement("dummy")
  parent.appendChild(htmlNode)
  htmlNode.outerHTML = html
end

dom =  setmetatable({}, {
  __index = function(self, tag)
    return function(spec)
      local node = js.window.document.createElement(tag)
      for key, val in pairs(spec) do
        if type(key) == "string" then
          -- This is an attribute
          if key:startsWith("on") then
            node.addEventListener(key:sub(3), val)
          else
            node.setAttribute(key, val)
          end
        -- Handling body values
        elseif type(val) == "string" then
          -- Text (markdown) body, process through markdown renderer before injecting
          appendHtmlNode(node, markdown.markdownToHtml(val, {expand=true}))
        else
          if val._isWidget then
            -- It's a widget
            if type(val.html) == "string" then
              -- HTML string widget
              appendHtmlNode(node, val.html)
            else
              -- HTML DOM node, attach directly
              node.appendChild(val.html)
            end
          else
            -- It's likely another dom.* returned node, just add directly
            node.appendChild(val)
          end
        end
      end
      return node
    end
  end
})
```


================================================
FILE: libraries/Library/Std/APIs/Date.md
================================================
#meta/api

Useful date and time APIs as well as slash commands by default in `YYYY-MM-dd` format with times in `HH:mm:ss` (24h) format. 

Included slash commands:

* `/today` for today's date.
* `/tomorrow` for tomorrow's date.
* `/yesterday` for yesterday's date.

# Examples
Today: ${date.today()} and ${date.time()}

# Implementation
```space-lua
-- priority: 10

local DAY_SECONDS = 60 * 60 * 24

date = {
  date_format = "%Y-%m-%d",
  time_format = "%H:%M:%S",
}

function date.today()
  return os.date(date.date_format)
end

function date.tomorrow()
  return os.date(date.date_format, os.time() + DAY_SECONDS)
end

function date.yesterday()
  return os.date(date.date_format, os.time() - DAY_SECONDS)
end

function date.time()
  return os.date(date.time_format)
end

slashCommand.define {
  name = "today",
  run = function()
    editor.insertAtCursor(date.today())
  end
}

slashCommand.define {
  name = "yesterday",
  run = function()
    editor.insertAtCursor(date.yesterday())
  end
}

slashCommand.define {
  name = "tomorrow",
  run = function()
    editor.insertAtCursor(date.tomorrow())
  end
}
```


================================================
FILE: libraries/Library/Std/APIs/Schema.md
================================================
#meta/api

Utilities to succinctly define JSON schema types in Lua.

```space-lua
-- priority: 101
schema = schema or {}

function schema.string()
  return { type = "string" }
end

function schema.number()
  return { type = "number" }
end

function schema.boolean()
  return { type = "boolean" }
end

function schema.array(typ)
  if type(typ) == "string" then
    return {
      type = "array",
      items = { type = typ }
    }
  else
    return {
      type = "array",
      items = typ,
    }
  end
end

function schema.nullable(typ)
  if type(typ) == "string" then
    return {
      anyOf = {
        { type = typ },
        { type = "null" },
      }
    }
  else
    return {
      anyOf = {
        typ,
        { type = "null" },
      }
    }
  end
end

function schema.nullableArray(typ)
  if type(typ) == "string" then
    return {
      anyOf = {
        { type = "array",
          items = { type = typ } },
        { type =  "null" },
      }
    }
  else
    return {
      anyOf = {
        { type = "array",
          items = typ, },
        { type = "null" },
      }
    }
  end
end

-- Used to specify we're expecting a function, but doesn't deeply validate
function schema.func()
  return {}
end

-- Used to specify we're expecting a schema, but doesn't deeply validate
function schema.schema()
  return { type = "object" }
end

function schema.null()
  return { type = "null" }
end
```


================================================
FILE: libraries/Library/Std/APIs/Tag.md
================================================
#meta/api

Implements convenience functions for accessing tag objects.

Currently:

* `tags.someTag` is an alias for `index.tag "someTag`

Example:

${#query[[from tags.page]]}

# Implementation

```space-lua
-- priority: 50
tag = tag or {}

function tag.define(spec)
  local finalSpec = config.get({"tags", spec.name}, {})
  local metatable = nil
  for k, v in pairs(spec) do
    if k == "metatable" then
      metatable = v
    else
      finalSpec[k] = v
    end
  end
  config.set({"tags", spec.name}, finalSpec)
  if metatable then
    config.setLuaValue({"tags", spec.name, "metatable"}, metatable)
  end
end

-- Set up tags.* short cut via meta tables
tags = setmetatable({}, {
  __index = function(self, tag)
    return index.tag(tag)
  end
})
```


================================================
FILE: libraries/Library/Std/APIs/Task State.md
================================================
#meta/api #maturity/beta

Implements APIs for defining custom task states. Currently extremely basic.

# API

## taskState.define(def)
Defines a custom task state. Options:
* `name`: name of the state
* `done`: whether or not the state should be considered "done" or not, used by the `Task: Remove Completed` command

# Example
```lua
taskState.define {
  name = "TO DO"
}
taskState.define {
  name = "IN PROGRESS"
}
taskState.define {
  name = "DONE",
  done = true
}
```

# Implementation

```space-lua
-- priority: 50
taskState = taskState or {}

function taskState.define(spec)
  config.set({"taskStates", spec.name}, spec)
end
```


================================================
FILE: libraries/Library/Std/APIs/Template.md
================================================
#meta/api

Implements useful template functions

```space-lua
-- priority: 50
-- Template API root table
template = template or {}
-- Template storage table
templates = {}

-- Iterates over a table/array and applies a function to each element,
-- concatenating the results
function template.each(tbl, fn)
  local result = {}
  for _, item in ipairs(tbl) do
      table.insert(result, fn(item))
  end
  return table.concat(result)
end

-- Creates a new template function from a string template
function template.new(templateStr, stripIndent)
  -- Preprocess: strip indentation
  if stripIndent == nil or strpIndent == true then
    local lines = {}
    local splitLines = string.split(templateStr, "\n")
    for _, line in ipairs(splitLines) do
      line = string.gsub(line, "^    ", "")
      table.insert(lines, line)
    end
    templateStr = table.concat(lines, "\n")
  end
  return function(obj)
    return spacelua.interpolate(templateStr, obj)
  end
end

-- Reads a template from a page
-- Return value:
-- * The template itself
-- * The extracted frontmatter
function template.fromPage(name, raw)
  local fm = index.extractFrontmatter(space.readPage(name),  {
    removeFrontMatterSection = true,
    removeTags = true
  })

  local templateText = string.trimStart(fm.text)
  if raw then
    -- Don't actually parse this as a template, just pass it along
    return function()
      return templateText
    end, fm.frontmatter
  end
  return template.new(templateText, false), fm.frontmatter
end
```


================================================
FILE: libraries/Library/Std/APIs/Virtual Page.md
================================================
---
description: APIs to define virtual pages
tags: meta/api
---
Virtual pages are (read-only) pages that don't actually exist in your space, but are dynamically generated on the fly. Used to implement [[^Library/Std/Infrastructure/Tag Page]]

# API

## virtualPage.define(def)
Defines a virtual page for specified pattern. Options:
* `pattern`: a Lua regular expression, e.g. `"myprefix:(.+)"` the captured groups will be passed as arguments to the `run` function
* `run`: a callback function where each captured group in the regex will be passed as an argument

# Example
```lua
virtualPage.define {
  -- match any page name starting with "newtag:"
  pattern = "newtag:(.+)",
  run = function(name)
    return "Page:" .. name
  end
}
```

# Implementation
```space-lua
-- priority: 99
virtualPage = virtualPage or {}

-- options:
--   pattern
--   run()
function virtualPage.define(def)
  config.set({"virtualPages", def.pattern}, def)
end
```

```space-lua
-- priority: -1
event.listen {
  name = "editor:pageCreating",
  run = function(e)
    local pageName = e.data.name
    for _, def in pairs(config.get("virtualPages")) do
      local match = table.pack(pageName:match(def.pattern))
      if not(#match == 1 and match[1] == nil) then
        -- we got an actual match
        return {
          text = def.run(table.unpack(match)),
          perm = "ro"
        }
      end
    end
  end
}
```


================================================
FILE: libraries/Library/Std/APIs/Widget.md
================================================
#meta/api

This implements the widget API. For a Lua Directive to be rendered as a widget, you need to return it via `widget.new`. Consider using [[^Library/Std/APIs/DOM]] to construct these.

```space-lua
-- priority: 50

-- Widget APIs
widget = {}

-- Container for widgets
widgets = {}

-- Widget schema for validation
local widgetSchema = {
  type = "object",
  properties = {
    markdown = { type = "string"},
    html = {
      anyOf = {
        -- HTMLElement
        { type = "object"},
        -- Plain HTML code
        { type = "string" }
      }
    },
    cssClasses = {
      type = "array",
      items = { type = "string" },
    },
    display = {
      type = "string",
      enum = {"block", "inline"}
    },
    events = {
      type = "object",
      additionalProperties = true
    }
  }
}

-- Creates a widget
function widget.new(spec)
  -- Validate spec
  local validationResult = jsonschema.validateObject(widgetSchema, spec)
  if validationResult then
    error(validationResult)
  end
  -- Mark as a widget
  spec._isWidget = true
  return spec
end

-- Convenience function for HTML widgets
function widget.html(html)
  return widget.new {
    html = html
  }
end

function widget.htmlBlock(html)
  return widget.new {
    html = html,
    display = "block"
  }
end

-- Convenience function for markdown widgets
function widget.markdown(markdown)
  return widget.new {
    markdown = markdown
  }
end

function widget.markdownBlock(markdown)
  return widget.new {
    markdown = markdown,
    display = "block"
  }
end
```


================================================
FILE: libraries/Library/Std/Config.md
================================================
---
description: Defines the schemas and built-in values of built-in configuration options
tags: meta
---

SilverBullet uses the [config APIs](https://silverbullet.md/API/config) to manage its runtime configuration. You can set most of these anywhere in your space, but the convention is to use your [[CONFIG]] page for this purpose.

This meta page defines the JSON Schema of all built-in configuration settings, as well as their default values. You can override them by e.g. putting the following in your CONFIG page (use `space-lua` instead of `lua` in your actual page):

```lua
config.set("sync.documents", true)
```

# Built-in options (schema)

This defines the [JSON schema](https://json-schema.org/) for built-in configuration.

```space-lua
-- priority: 100

config.define("sync", {
  description = "Configure sync",
  type = "object",
  properties = {
    -- Defaults to false
    documents = schema.boolean(),
    -- In .gitignore format, either in a single string, or as a list of strings
    ignore = {
      oneOf = {
        schema.array "string",
        schema.string()
      }
    }
  },
  additionalProperties = false
})

config.define("index", {
  description = "Configure individual indexers",
  type = "object",
  properties = {
    paragraph = {
      type = "object",
      properties = {
        -- Index paragraphs without a hashtag
        all = schema.boolean(),
      },
    },
    item = {
      type = "object",
      properties = {
        -- Index items without a hashtag
        all = schema.boolean(),
      },
    },
    task = {
      type = "object",
      properties = {
        -- Index tasks without a hashtag
        all = schema.boolean(),
      },
    },
  },
  additionalProperties = true
})

config.define("plugs", {
  description = "List of plugs to enable",
  oneOf = {
    schema.array "string",
    { -- only as a fallback for an empty table in Lua
      type = "object",
    }
  },
})


-- Editor configuration options
config.define("autoCloseBrackets", {
  description = "List of brackets to auto close",
  type = "string",
})

config.define("shortWikiLinks", {
  description = "Render wiki links to just the last segment, e.g. Person/John becomes John",
  type = "boolean"
})

config.define("emoji", {
  description = "Additional emoji aliases",
  type = "object",
  properties = {
    aliases = {
      type = "object",
      additionalProperties = schema.string(),
    }
  }
})

config.define("smartQuotes", {
  description = "Configure smart quotes",
  type = "object",
  properties = {
    enabled = {
      type = "boolean",
      description = "Indicates whether smart quotes are enabled"
    },
    double = {
      type = "object",
      properties = {
        left = {
          type = "string",
          description = "Character for the left double quote"
        },
        right = {
          type = "string",
          description = "Character for the right double quote"
        }
      },
      additionalProperties = false
    },
    single = {
      type = "object",
      properties = {
        left = {
          type = "string",
          description = "Character for the left single quote"
        },
        right = {
          type = "string",
          description = "Character for the right single quote"
        }
      },
      additionalProperties = false
    },
  },
  additionalProperties = false
})

config.define("mobileMenuStyle", {
  type = "string",
})

config.define("vim", {
  description = "Vim mode configuration",
  type = "object",
  properties = {
    unmap = {
      description = "Keys to unmap",
      type = "array",
      items = {
        oneOf = {
          { type = "string" },
          {
            type = "object",
            properties = {
              key = { type = "string" },
              mode = {
                type = "string",
                enum = {"normal", "insert", "visual"}
              }
            },
            required = { "key" },
            additionalProperties = false
          }
        }
      }
    },
    map = {
      description = "Custom mappings",
      type = "array",
      items = {
        type = "object",
        properties = {
          map = { type = "string" },
          to = { type = "string" },
          mode = {
            type = "string",
            enum = {"normal", "insert", "visual"}
          }
        },
        required = { "map", "to" },
        additionalProperties = false
      }
    },
    noremap = {
      description = "Non-recursive custom mappings",
      type = "array",
      items = {
        type = "object",
        properties = {
          map = { type = "string" },
          to = { type = "string" },
          mode = {
            type = "string",
            enum = {"normal", "insert", "visual"}
          }
        },
        required = { "map", "to" },
        additionalProperties = false
      }
    },
    commands = {
      description = "Custom Ex commands",
      type = "array",
      items = {
        type = "object",
        properties = {
          command = { type = "string" },
          ex = { type = "string" }
        },
        required = { "command", "ex" },
        additionalProperties = false
      }
    },
  },
  additionalProperties = false
})

-- Query specific configuration options
config.define("queryCollation", {
  description = "Configure string ordering in queries",
  type = "object",
  properties = {
    enabled = {
      type = "boolean",
      description = "Indicates whether string collation should be used instead of simple codepoint ordering"
    },
    locale = {
      type = "string",
      description = "Language tag to specify sorting rules (from BCP 47)"
    },
    options = {
      type = "object",
      description = "Additional options passed to Intl.Collator constructor"
      -- See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator/Collator#options
    },
  },
  additionalProperties = false
})

-- Configuration for internal use mostly

-- Don't use directly, use command.define instead
config.define("commands", {
  type = "object",
  additionalProperties = {
    type = "object",
    properties = {
      name = schema.string(),
      contexts = schema.nullableArray "string",
      priority = schema.nullable "number",
      key = {
        anyOf = {
          schema.string(),
          schema.array("string"),
          schema.null()
        }
      },
      mac = {
        anyOf = {
          schema.string(),
          schema.array("string"),
          schema.null()
        }
      },
      hide = schema.nullable "boolean",
      requireMode = schema.nullable {
        type = "string", 
        enum = {"rw", "ro"},
      },
      requireEditor = schema.nullable("string"),
      run = schema.func(),
    },
  },
})

-- Don't use directly, use slashCommands.define instead
config.define("slashCommands", {
  type = "object",
  additionalProperties = {
    type = "object",
    properties = {
      name = schema.string(),
      description = schema.nullable "string",
      priority = schema.nullable "number",
      onlyContexts = schema.nullableArray "string",
      exceptContexts = schema.nullableArray "string",
      run = schema.func(),
    },
  },
})

-- Don't use directly, use event.listen instead
config.define("eventHandlers", {
  type = "object",
  additionalProperties = schema.array(schema.func()),
})

-- Don't use directly, use mq.listen instead
config.define("mqSubscriptions", {
  type = "object",
  additionalProperties = {
    type = "array",
    items = {
      type = "object",
      properties = {
        batchSize = schema.number(),
        run = schema.func(),
      }
    }
  },
})

-- Task states
config.define("taskStates", {
  type = "object",
  additionalProperties = {
    type = "object",
    properties = {
      -- for future use
    },
  },
})

-- Don't use directly, WIP
config.define("tags", {
  type = "object",
  additionalProperties = {
    type = "object",
    properties = {
      name = schema.string(),
      schema = schema.schema(),
      -- Whether or not an object HAS to validate to be indexed (defaults to false), has a performance penalty
      mustValidate = schema.boolean(),
      -- Additional custom validation logic
      validate = schema.func(),
      -- Invoked by the object indexer, takes a proposed object as input, returns an array of objects (can be empty table to skip indexing altogether)
      transform = schema.func(),
    },
  },
})

-- Don't use directly, use actionButton.define instead
config.define("actionButtons", {
  type = "array",
  items = {
    type = "object",
    properties = {
      icon = {
        type = "string",
        description = "Icon for the action button, from https://feathericons.com"
      },
      description = {
        type = "string",
        description = "Optional description of the action button"
      },
      priority = {
        type = "number",
        description = "Optional priority: the higher the earlier the button will appear in the list"
      },
      mobile = {
        type = "boolean",
        description = "Optional boolean indicating if the action button is applicable for mobile"
      },
      run = schema.func(),
    },
    required = {"icon", "run"},
    additionalProperties = false
  }
})
```

# Default values
Default values for built-in configuration options.

```space-lua
-- priority: 99
config.set {
  index = {
    paragraph = { all = false },
    item = { all = true },
    task = { all = true },
  },
  taskStates = {},
  shortWikiLinks = true,
  actionButtons = {
    {
      icon = "home",
      description = "Go to the index page",
      priority = 3,
      run = function()
        editor.invokeCommand "Navigate: Home"
      end
    },
    {
      icon = "book",
      description = "Open page",
      priority = 2,
      run = function()
        editor.invokeCommand "Navigate: Page Picker"
      end
    },
    {
      icon = "terminal",
      description = "Run command",
      priority = 1,
      run = function()
        editor.invokeCommand "Open Command Palette"
      end,
    }
  },
}
```


================================================
FILE: libraries/Library/Std/Editor/Lua.md
================================================
#meta

Basic editor support for Space Lua, implemented in Space Lua.

Features:

* Basic code completion for globals, e.g. `string.gm` will be completed
* Navigation to global symbols implemented in Lua by Cmd-clicking (Mac) or Ctrl-clicking (other OSes)

# Code complete support
```space-lua
-- priority: 10
local LUA_KEYWORDS = {"do", "if", "then", "for", "else", "end", "function", "local", "return", "in"}

-- Are we in a comment?
local function inComment(line)
  return string.find(line, "%-%-")
end

-- Are we in a string?
local function inString(line)
  local singleQuotes = 0
  local doubleQuotes = 0
  local brackets = 0
  for i = 1, string.len(line) do
    local c = line[i]
    if c == "'" then
      singleQuotes = singleQuotes + 1
    elseif c == '"' then
      doubleQuotes = doubleQuotes + 1
    elseif c == "[" and line[i+1] == "[" and line:sub(i-5, i) != "query[" then
      brackets = brackets + 1
    elseif c == "]" and line[i-1] == "]" then
      brackets = brackets - 1
    end
  end
  return singleQuotes % 2 == 1 or doubleQuotes % 2 == 1 or brackets > 0
end

-- Converts (some) JSON schema to dummy Lua values for code complete to use
local function schemaToDummyValue(schema)
  if schema.type == "string" then
    return ""
  elseif schema.type == "number" then
    return 0
  elseif schema.type == "boolean" then
    return true
  elseif schema.type == "array" then
    return {}
  elseif schema.type == "object" then
    local t = {}
    for k, v in pairs(schema.properties) do
      t[k] = schemaToDummyValue(v)
    end
    return t
  else
    return nil
  end
end

-- Implements extremely simplistic global variable augmentation by scanning for local variables (declared via `local` and `from`)
local function augmentGlobal(localCode)
  local vars = {}
  -- Copy over all globals
  for k, v in pairs(_G) do
    vars[k] = v
  end
  -- Then approximate specific local tag-based object declarations in queries and add them
  -- matches `tags.something`, `index.tag "something"` and `index.tag("something")` patterns only
  for m in string.matchRegexAll(localCode, "(?:from|local)\\s+(\\w+)\\s+=\\s*(?:index\\.tag\\s*\"(\\w+)\"|index\\.tag\\s*\\(\"(\\w+)\"\\)|tags\\.(\\w+))") do
    local tag
    for i, t in ipairs(m) do
      if i > 2 and t then
        tag = t
      end
    end
    -- Do we have a schema for this tag?
    local schema = config.get({"tags", tag, "schema"})
    if schema then
      -- Schema exists, add to vars
      vars[m[2]] = schemaToDummyValue(schema)
    end
  end
  return vars
end

-- API code completion for Lua
-- Completes a.b-style property accesses 
event.listen {
  name = "editor:complete",
  run = function(e)
    local parents = e.data.parentNodes
    local luaCode = nil
    for _, parent in ipairs(parents) do
      if string.startsWith(parent, "FencedCode:space-lua") then
        luaCode = string.sub(parent, #"FencedCode:space-lua"+1)
      elseif string.startsWith(parent, "LuaDirective:") then
        luaCode = string.sub(parent, #"LuaDirective:"+1)
      end
    end
    -- Not in a Lua context
    if not luaCode then
      return
    end
    local linePrefix = e.data.linePrefix
    if inComment(linePrefix) or inString(linePrefix) then
      return
    end
    local pos = e.data.pos
    local propaccessPrefix = string.matchRegex(linePrefix, "([a-zA-Z_0-9]+\\.)*([a-zA-Z_0-9]*)$")
    if not propaccessPrefix or propaccessPrefix[1] == "" then
      -- No propaccess prefix, so we can't complete
      return
    end
    -- Split propaccess and traverse
    local propParts = string.split(propaccessPrefix[1], ".")
    local currentValue = augmentGlobal(luaCode)
    local failed = false
    for i = 1, #propParts-1 do
      local prop = propParts[i]
      if currentValue then
        currentValue = currentValue[prop]
      else
        failed = true
      end
    end
    if failed then
      return
    end
    local lastProp = propParts[#propParts]
    if table.includes(LUA_KEYWORDS, lastProp) then
      return
    end
    local options = {}
    if not currentValue then
      return
    end
    for key, val in pairs(currentValue) do
      if string.startsWith(key, lastProp) and val then
        if type(val) == "function" then
          -- We got a function
          if val.body then
            -- Function defined in Lua
            table.insert(options, {
              label = key .. "(" .. table.concat(val.body.parameters, ", ") ..")",
              apply = key,
              detail = "function"
            })
          else
            -- Builtin
            table.insert(options, {
              label = key .. "()",
              apply = key,
              detail = "built-in"
            })
          end
        else
          table.insert(options, {
            label = key,
            detail = type(val),
          })
        end
      end
    end
    if #options > 0 then
      return {
        from = pos - string.len(lastProp),
        options = options
      }
    end
  end
}
```

# Navigation
Ctrl/Cmd-click navigation to Lua function definition.

```space-lua
-- priority: 10
local function inLuaContext(parentNodes)
  for _, node in ipairs(parentNodes) do
    if node == "LuaDirective"
      or node:startsWith("FencedCode:space-lua") then
      return true
    end
  end
  return false
end

local function notDefinedinLua()
  return editor.flashNotification(
    "Cannot navigate to definition; not defined in Lua."
  )
end

event.listen {
  name = "page:click",
  run = function(e)
    -- Accept either Cmd-click (Mac) or Ctrl-click (Linux)
    if not (e.data.metaKey or e.data.ctrlKey) then
      return
    end
    if not inLuaContext(e.data.parentNodes) then
      return notDefinedinLua()
    end

    local pos = e.data.pos
    local text = editor.getText()
    -- Find start pos
    local startPos = pos
    while string.match(text[startPos], "[a-zA-Z0-9._]") do
      startPos = startPos - 1
      if startPos <= 0 then
        return
      end
    end
    -- Find end pos
    local endPos = pos
    while string.match(text[endPos], "[a-zA-Z0-9_]") do
      endPos = endPos + 1
      if startPos >= #text then
        return
      end
    end
    local callText = text:sub(startPos+1, endPos-1)
    local propParts = callText:split(".")
    local currentValue = _G
    for i = 1, #propParts do
      local prop = propParts[i]
      if currentValue then
        currentValue = currentValue[prop]
      else
        return
      end
    end
    -- Check if this is a Lua-defined API
    if currentValue and currentValue.body and currentValue.body.ctx then
      local ctx = currentValue.body.ctx
      -- Parse out the position in the doc
      local refBits = ctx.ref:split("@")
      -- Navigate there
      editor.navigate(refBits[1] .. "@" ..
        (tonumber(refBits[2]) + ctx.from + #"```space-lua\n")
      )
    else
      return notDefinedinLua()
    end
  end
}
```


================================================
FILE: libraries/Library/Std/Editor/Table.md
================================================
#meta

Implements utilities for working with Markdown tables

# Table
## Commands

```space-lua
command.define {
  name = "Table: Format",
  run = function()
    position = editor.getCursor()
    tree = markdown.parseMarkdown(editor.getText())
    tableNode = nodeParentOfType(tree, position, "Table")
    if tableNode then
      formatMarkdownTable(tableNode)
    else
      editor.flashNotification("This command must be run with cursor in a table", "error")
    end
  end
}
```

## Implementation

```space-lua
-- Find outermost node of given type that contains the position
-- similar to plug-api/lib/tree.ts nodeAtPos
function nodeParentOfType(tree, position, nodeType)
  if position < tree.from or position > tree.to then
    return nil
  end
  if tree.type == nodeType then
    return tree
  end
  if tree.children then
    for _, child in ipairs(tree.children) do
      if position >= child.from and position <= child.to then
        return nodeParentOfType(child, position, nodeType)
      end
    end
  end
  return nil
end
```

```space-lua
function formatMarkdownTable(tree)

  -- First find the desired length for each column
  local columnLengths = {}
  local columnAlignments = {}
  local tableHeader = nil
  local tableDelimiter = nil
  local tableRows = {}

  -- Separate parsing from output generation and identify table parts
  for childIdx, child in ipairs(tree.children) do
    if child.type == "TableDelimiter" then
      tableDelimiter = child
      local column = 1

      -- Extract delimiter text from children
      local delimiterText = ""
      for _, delimChild in ipairs(child.children or {}) do
        if delimChild.text and string.find(delimChild.text, "-") then
          delimiterText = delimChild.text
          break
        end
      end
      -- Split by | and process only non-empty parts, but skip first/last if empty
      local parts = string.split(delimiterText, "|")
      local startIdx = (parts[1] == "") and 2 or 1
      local endIdx = (parts[#parts] == "") and (#parts - 1) or #parts

      for i = startIdx, endIdx do
        local spec = string.trim(parts[i])
        local minLength = 1
        if #spec >= 2 and string.endsWith(spec, ":") then
          if string.startsWith(spec, ":") then
            columnAlignments[column] = "center"
            minLength = 3
          else
            columnAlignments[column] = "right"
            minLength = 2
          end
        elseif string.startsWith(spec, ":") then
          columnAlignments[column] = "left_explicit"
          minLength = 2
        else
          columnAlignments[column] = "left"
        end

        if minLength > (columnLengths[column] or 0) then
          columnLengths[column] = minLength
        end
        column = column + 1
      end
    elseif child.type == "TableHeader" then
      tableHeader = child
      local column = 1
      local isFirstCell = true

      for i, cell in ipairs(child.children) do
        local cellType = cell and cell.type or "nil"
        if cell and cell.type == "TableCell" then
          -- Found a TableCell directly - get its content
          local cellContent = ""
          for _, next_child in ipairs(cell.children) do
            cellContent = cellContent .. next_child.text
          end
          local trimmedContent = string.trim(cellContent)

          -- Skip empty first cell only
          if trimmedContent ~= "" or not isFirstCell then
            isFirstCell = false
            local len = #trimmedContent

            if len > (columnLengths[column] or 0) then
              columnLengths[column] = len
            end
            column = column + 1
          end
        end
      end
      -- columnLengths now contains width of each header column
    elseif child.type == "TableRow" then
      table.insert(tableRows, child)
      local column = 1
      local isFirstCell = true
      for i, cell in ipairs(child.children) do
        if cell and cell.type == "TableCell" then
          -- Get actual content length after trimming
          local cellContent = ""
          for _, next_child in ipairs(cell.children) do
            cellContent = cellContent .. next_child.text
          end
          local trimmedContent = string.trim(cellContent)

          -- Skip empty first cell only
          if trimmedContent ~= "" or not isFirstCell then
            isFirstCell = false
            local len = #trimmedContent

            if len > (columnLengths[column] or 0) then
              columnLengths[column] = len
            end
            column = column + 1
          end
        end
      end
    end
  end

  -- Helper function to format table row (one)
  local function formatTableRow(child)
    local rowOutput = "|"
    local column = 1
    local isFirstCell = true
    for i, cell in ipairs(child.children) do
      if cell and cell.type == "TableCell" then
        -- Get cell content
        local cellContent = ""
        for _, next_child in ipairs(cell.children) do
          cellContent = cellContent .. next_child.text
        end

        cellContent = string.trim(cellContent)

        -- Skip empty first cell only
        if cellContent ~= "" or not isFirstCell then
          isFirstCell = false
          local len = #cellContent

          -- Add exactly 1 space on each side (ignore alignment for content rows)
          rowOutput = rowOutput .. " " .. cellContent .. " |"

          column = column + 1
        end
      end
    end
    return rowOutput
  end


  -- Output in order: Header -> Delimiter -> Rows
  local output = ""

  if tableHeader then
    output = formatTableRow(tableHeader)
  end

  if tableDelimiter then
    if #output > 0 then
      output = output .. "\n"
    end
    output = output .. "|"
    for column, len in ipairs(columnLengths) do
      local align = columnAlignments[column]
      if align == "left" then
        output = output .. " " .. string.rep("-", len) .. " |"
      elseif align == "left_explicit" then
        output = output .. " :" .. string.rep("-", len - 1) .. " |"
      else
        output = output .. " " .. (align == "center" and ":" or "-") .. string.rep("-", len - 2) .. ": |"
      end
    end
  end

  for _, row in ipairs(tableRows) do
    if #output > 0 then
      output = output .. "\n"
    end
    output = output .. formatTableRow(row)
  end


  -- Replace the table node with the formatted content
  editor.replaceRange(tree.from, tree.to, output)
end
```


================================================
FILE: libraries/Library/Std/Infrastructure/Builtin Tags.md
================================================
---
description: Defines built-in tags.
tags: meta
---
Defined via [[^Library/Std/APIs/Tag]].

```space-lua
local function readOnlyType(type)
  return {
    type = type,
    readOnly = true
  }
end

local rangeType = {
  type = "array",
  items = { schema.number(), schema.number() },
  minItems = 2,
  maxItems = 2,
}

-- page
tag.define {
  name = "page",
  schema = {
    type = "object",
    additionalProperties = true,
    properties = {
      ref = readOnlyType("string"),
      tag = readOnlyType("string"),
      tags = {
        anyOf = {
          { type = "array", items = schema.string() },
          schema.string(),
        },
      },
      -- range of where fronmatter appears in the page (if present)
      range = rangeType,
      itags = {
        type = "array",
        readOnly = true,
        items = schema.string(),
        nullable = true,
      },
      name = schema.string(),
      pageDecoration = {
        type = "object",
        properties = {
          prefix = schema.nullable("string"),
          cssClasses = {
            type = "array",
            items = schema.string(),
            nullable = true,
          },
          hide = schema.nullable("boolean"),
          renderWidgets = schema.nullable("boolean"),
        },
        nullable = true,
      },
      displayName = schema.nullable("string"),
      aliases = {
        type = "array",
        items = schema.string(),
        nullable = true,
      },
      created = readOnlyType("string"),
      contentType = readOnlyType("string"),
      size = readOnlyType("number"),
      lastModified = readOnlyType("string"),
      perm = {
        type = "string",
        readOnly = true,
        enum = { "ro", "rw" },
      },
      lastOpened = readOnlyType("number"),
    },
  },
}

-- aspiring-page
tag.define {
  name = "aspiring-page",
  schema = {
    type = "object",
    additionalProperties = true,
    properties = {
      ref = readOnlyType("string"),
      tag = readOnlyType("string"),
      name = readOnlyType("string"),
      page = readOnlyType("string"),
      pos = readOnlyType("number"),
    },
  },
}

-- document
tag.define {
  name = "document",
  schema = {
    type = "object",
    additionalProperties = true,
    properties = {
      ref = readOnlyType("string"),
      tag = readOnlyType("string"),
      tags = {
        type = "array",
        readOnly = true,
        items = schema.string(),
        nullable = true,
      },
      itags = {
        type = "array",
        readOnly = true,
        items = schema.string(),
        nullable = true,
      },
      name = readOnlyType("string"),
      created = readOnlyType("string"),
      contentType = readOnlyType("string"),
      size = readOnlyType("number"),
      lastModified = readOnlyType("string"),
      perm = readOnlyType("string"),
      extension = readOnlyType("string"),
    },
  },
}

-- item
tag.define {
  name = "item",
  schema = {
    type = "object",
    additionalProperties = true,
    properties = {
      ref = readOnlyType("string"),
      tag = readOnlyType("string"),
      tags = schema.array("string"),
      itags = {
        type = "array",
        readOnly = true,
        items = schema.string(),
        nullable = true,
      },
      page = readOnlyType("string"),
      name = readOnlyType("string"),
      text = readOnlyType("string"),
      parent = readOnlyType("string"),
      range = rangeType,
      -- deprecated in favor of range
      pos = readOnlyType("number"),
    },
  },
}

-- tag
tag.define {
  name = "tag",
  schema = {
    type = "object",
    properties = {
      ref = readOnlyType("string"),
      tag = readOnlyType("string"),
      tags = schema.array("string"),
      itags = {
        type = "array",
        readOnly = true,
        items = schema.string(),
        nullable = true,
      },
      name = readOnlyType("string"),
      page = readOnlyType("string"),
      parent = readOnlyType("string"),
      context = readOnlyType("string"),
    },
  },
}

-- link
tag.define {
  name = "link",
  schema = {
    type = "object",
    properties = {
      ref = readOnlyType("string"),
      tag = schema.string(),
      -- type of link: page, file, url
      type = schema.string(),
      tags = schema.array("string"),
      itags = {
        type = "array",
        items = schema.string(),
        nullable = true,
        readOnly = true,
      },
      name = schema.string(),
      page = schema.string(),
      alias = schema.string(),
      pageLastModified = schema.string(),
      toFile = schema.nullable("string"),
      toPage = schema.nullable("string"),
      toURL = schema.nullable("string"),
      snippet = schema.string(),
      range = rangeType,
      -- deprecated in favor of range
      pos = schema.number(),
    },
  },
}

-- header
tag.define {
  name = "header",
  schema = {
    type = "object",
    properties = {
      ref = readOnlyType("string"),
      tag = readOnlyType("string"),
      tags = schema.array("string"),
      itags = {
        type = "array",
        items = schema.string(),
        nullable = true,
      },
      name = readOnlyType("string"),
      page = readOnlyType("string"),
      range = rangeType,
      level = readOnlyType("number"),
      -- deprecated in favor of range
      pos = readOnlyType("number"),
    },
  },
}

-- paragraph
tag.define {
  name = "paragraph",
  schema = {
    type = "object",
    properties = {
      ref = readOnlyType("string"),
      tag = readOnlyType("string"),
      tags = schema.array("string"),
      itags = {
        type = "array",
        items = schema.string(),
        readOnly = true,
        nullable = true,
      },
      text = readOnlyType("string"),
      page = readOnlyType("string"),
      range = rangeType,
      -- deprecated in favor of range
      pos = readOnlyType("number"),
    },
  },
}

-- table
tag.define {
  name = "table",
  schema = {
    type = "object",
    properties = {
      ref = readOnlyType("string"),
      tag = readOnlyType("string"),
      page = schema.string(),
      range = rangeType,
      -- deprecated in favor of range
      pos = schema.number(),
    },
  },
}

-- task
tag.define {
  name = "task",
  schema = {
    type = "object",
    additionalProperties = true,
    properties = {
      ref = readOnlyType("string"),
      tag = readOnlyType("string"),
      tags = schema.array("string"),
      itags = {
        type = "array",
        items = schema.string(),
        readOnly = true,
        nullable = true,
      },
      name = readOnlyType("string"),
      page = readOnlyType("string"),
      parent = readOnlyType("string"),
      range = rangeType,
      -- deprecated in favor of range
      pos = readOnlyType("number"),
      text = readOnlyType("string"),
      state = readOnlyType("string"),
      done = readOnlyType("boolean"),
    },
  },
}

-- page templates
tag.define {
  name = "meta/template/page",
  schema = {
    type = "object",
    properties = {
      tags = {
        anyOf = {
          { type = "array", items = schema.string() },
          schema.string(),
        },
      },
      frontmatter = schema.string(),
      suggestedName = schema.string(),
      confirmName = schema.boolean(),
      openIfExists = schema.boolean(),
      command = schema.string(),
      key = {
        anyOf = {
          { type = "array", items = schema.string() },
          schema.string(),
        },
      },
      mac = {
        anyOf = {
          { type = "array", items = schema.string() },
          schema.string(),
        },
      },
      priority = schema.number(),
      description = schema.string(),
    },
  }
}

-- slash templates
tag.define {
  name = "meta/template/slash",
  schema = {
    type = "object",
    properties = {
      tags = {
        anyOf = {
          { type = "array", items = schema.string() },
          schema.string(),
        },
      },
      description = schema.string(),
      priority = schema.number(),
      onlyContexts = schema.array("string"),
      exceptContexts = schema.array("string"),
    },
  }
}
```

================================================
FILE: libraries/Library/Std/Infrastructure/Export.md
================================================
---
description: Implements the infrastructure of the Export commands.
tags: meta
---
Provides infrastructure for exporting pages outside of your space. It standardizes the ${widgets.commandButton("Export: Page Or Selection")} to export the current page or selection in various ways.

# Architecture
Flow:

1. Service discovery with `export` selector with data:
  * `pageMeta`
  * `text` (either the selection text or the entire page text)
2. Respond with:
  * `name`
  * `description`
  * `priority`
3. Ask user to select option, then invokes the selected service.

# Implementation
```space-lua
command.define {
  name = "Export: Page Or Selection",
  key = "Ctrl-e",
  mac = "Cmd-e",
  run = function()
    editor.save()
    local text = editor.getText()
    local meta = editor.getCurrentPageMeta()
    local selection = editor.getSelection()
    local exportObj = {
      pageMeta = meta,
      text = selection.text != "" and selection.text or text
    }
    local services = service.discover("export", exportObj)
    if #services == 0 then
      editor.flashNotification("No exporters available", "error")
      return
    end
    local selectedOption = editor.filterBox("Export to",
      services, "Select your export mechanism")
    if not selectedOption then
      return
    end
    service.invoke(selectedOption, exportObj)
  end
}
```

# Clipboard exporters
Implements two exporters:
* Copy rich text (e.g. for pasting into a Google Docs)
* Copy clean markdown

```space-lua
service.define {
  selector = "export",
  match = {
    name = "Clipboard: Export Rich Text",
    description = "To paste into Google Docs or other WYSIWYG environment"
  },
  run = function(data)
    local mdTree = markdown.parseMarkdown(data.text)
    mdTree = markdown.expandMarkdown(mdTree)
    local html = markdown.markdownToHtml(markdown.renderParseTree(mdTree))
    editor.copyToClipboard(js.new(js.window.Blob, {html}, {type="text/html"}))
  end
}

service.define {
  selector = "export",
  match = {
    name = "Clipboard: Export Clean Markdown",
    description = "To paste into another markdown supporting tool"
  },
  run = function(data)
    local mdTree = markdown.parseMarkdown(data.text)
    mdTree = markdown.expandMarkdown(mdTree)
    local renderedMd = markdown.renderParseTree(mdTree)
    editor.copyToClipboard(renderedMd)
  end
}
```


================================================
FILE: libraries/Library/Std/Infrastructure/Github.md
================================================
---
description: Integration with Github repositories and gists.
tags: meta
---
Support for [SilverBullet Share](https://silverbullet.md/Share) for:

* Github repo files
* Github gists

As well as [URI](https://silverbullet.md/URIs) support (both read and write) for the following schemes:
* `https://github.com/username/repo/blob/branch/path`
* `https://gist.github.com/username/gist-id`
* `github:username/repo@branch/path`
* `github:username/repo/path` (defaults to `main` branch)
* `ghr:username/repo@version/path` (github release support)

# Configuration
If you only want to read from Github URLs, no configuration is required.

To _write_ to Github repos and gists, you need to get a [personal Github token](https://github.com/settings/personal-access-tokens) (with repo and gists permissions). Configure your token somewhere in Space Lua (use a `space-lua` block), ideally a `SECRETS` page.

```lua
config.set("github.token", "your token")
```

In addition, to push to Github repos you need to configure a name and email that will be part of the commit:

```lua
config.set("github.name", "John Doe")
config.set("github.email", "john@doe.com")
```

# Implementation

## Constants
```space-lua
-- priority: 50
github = {}
```

## Github Repo Files
```space-lua
-- returns (something/bla, branch, path)
function github.extractData(url)
  if url == nil then
    return nil
  end
  return url:match("github%.com/([^/]+/[^/]+)/[^/]+/([^/]+)/(.+)")
end

function github.buildAPIURL(repo, path)
  return "https://api.github.com/repos/" .. repo .. "/contents/" .. path
end

function github.buildAPIURLWithBranch(repo, branch, path)
  return github.buildAPIURL(repo, path) .. "?ref=" .. branch
end

function github.buildURI(repo, branch, path)
  return "https://github.com/" .. repo .. "/blob/" .. branch .. "/" .. path
end

function github.request(url, method, body)
  local token = config.get("github.token")
  if not token then
    error("github.token config not set")
  end
  return net.proxyFetch(url, {
    method = method,
    headers = {
      Authorization = "token " .. token,
      Accept = "application/vnd.github+json"
    },
    body = body
  })
end

function github.checkConfig()
  if not config.get("github.token") then
    error("github.token needs to be set")
  end
  if not config.get("github.name") then
    error("github.name needs to be set")
    return
  end
  if not config.get("github.email") then
    error("github.email needs to be set")
    return
  end
end

service.define {
  selector = "share:onboard",
  match = {
    name = "Github file",
    description = "Share this page as a file on a github repo",
  },
  run = function(data)
    local name = data.name
    local content = data.text
    -- Check configuration
    local checkOk, err = pcall(github.checkConfig)
    if not checkOk then
      editor.flashNotification(err, "error")
      return
    end
    repo = editor.prompt "Github repo (user/repo):"
    if not repo then
      return
    end
    branch = editor.prompt("Branch:", "main")
    if not branch then
      return
    end
    path = editor.prompt("File path:", name .. ".md")
    if not path then
      return
    end
    -- Ask for a commit message
    local message = editor.prompt("Commit message:", "Commit")
    -- Push the change
    local resp = github.request(github.buildAPIURL(repo, path), "PUT", {
      message = message,
      committer = {
        name = config.get("github.name"),
        email = config.get("github.email"),
      },
      branch = branch,
      content = encoding.base64Encode(content)
    })
    if resp.ok then
      local uri = github.buildURI(repo, branch, path)
      return {
        uri = uri,
        hash = share.contentHash(content),
        mode = "push"
      }
    else
      js.log("Error", resp)
      error("Error, check console")
    end
  end
}

-- writeURI support
service.define {
  selector = "net.writeURI:https://github.com/*",
  match = {priority=10},
  run = function(data)
    local uri = data.uri
    local content = data.content
    -- Check configuration
    local checkOk, err = pcall(github.checkConfig)
    if not checkOk then
      editor.flashNotification(err, "error")
      return
    end
    local repo, branch, path = github.extractData(uri)
    -- We did find an existing file, let's fetch it to get the SHA
    local oldContent = github.request(github.buildAPIURLWithBranch(repo, branch, path), "GET")
    if not oldContent.ok then
      error("Could not fetch existing file")
    end
    local sha = oldContent.body.sha
    -- Ask for a commit message
    local message = editor.prompt("Commit message:", "Commit")
    -- Push the change
    local resp = github.request(github.buildAPIURL(repo, path), "PUT", {
      message = message,
      committer = {
        name = config.get("github.name"),
        email = config.get("github.email"),
      },
      branch = branch,
      sha = sha,
      content = encoding.base64Encode(content)
    })
    if not resp.ok then
      js.log("Error", resp)
      error("Error, check console")
    end
  end
}

service.define {
  selector = "net.writeURI:github:*",
  match = {priority=10},
  run = function(data)
    local uri = data.uri:sub(#"github:"+1)
    local owner, repo, path = table.unpack(uri:split("/"))
    local repo, branch = table.unpack(repo:split("@"))
    if not branch then
      branch = "main"
    end
    local fullUrl = "https://github.com/" .. owner .. "/" .. repo .. "/blob/" .. branch .. "/" .. path
    -- Redirect to full URI implementation
    net.writeURI(fullUrl, data.content)
  end
}

-- readURI
--   ghr:owner/repo/path (latest)
--   ghr:owner/repo@version/path
service.define {
  selector = "net.readURI:ghr:*",
  match = { priority=10 },
  run = function(data)
    local uri = data.uri:sub(#"ghr:"+1)
    local owner, repo, path = table.unpack(uri:split("/"))
    local repoClean, version = table.unpack(repo:split("@"))
    local url
    if not version or version == "latest" then
      url = "https://api.github.com/repos/"
            .. owner .. "/" .. repoClean .. "/releases/latest"
    else
      url = "https://api.github.com/repos/" .. owner .. "/" .. repoClean .. "/releases/tags/" .. version
    end
    local res = net.proxyFetch(url)
    if res.status != 200 then
      print("Could not fetch", url, res)
      return
    end
    local releaseInfo = res.body
    version = releaseInfo.tag_name
    local url = "https://github.com/" .. owner .. "/" .. repoClean .. "/releases/download/" .. version .. "/" .. path
    local res = net.proxyFetch(url, {responseEncoding=data.encoding})
    if res.status != 200 then
      print("Failed to fetch", ur, res)
      return nil
    end
    return res.body
  end
}

-- readURI
--   github:owner/repo/path (defaults to "main" branch)
--   github:owner/repo@branch/path
service.define {
  selector = "net.readURI:github:*",
  match = { priority=10 },
  run = function(data)
    local uri = data.uri:sub(#"github:"+1)
    local owner, repo, path, branch
    owner, repo, path = table.unpack(uri:split("/"))
    repo, branch = table.unpack(repo:split("@"))
    if not branch then
      branch = "main"
    end
    local url = "https://raw.githubusercontent.com/" .. owner .. "/" .. repo .. "/" .. branch .. "/" .. path
    local res = net.proxyFetch(url)
    if res.status != 200 then
      return nil
    end
    return res.body
  end
}

-- readURI
--   https://github.com/owner/repo/blob/branch/path
service.define {
  selector = "net.readURI:https://github.com/*",
  match = {priority=10},
  run = function(data)
    local owner, repo, branch, path = data.uri:match("github%.com/([^/]+)/([^/]+)/[^/]+/([^/]+)/(.+)")
    local url = "https://raw.githubusercontent.com/" .. owner .. "/" .. repo .. "/" .. branch .. "/" .. path
    local res = net.proxyFetch(url)
    if res.status != 200 then
      return nil
    end
    return res.body
  end
}
```

## Gists
```space-lua
local function extractGistId(url)
  if not url:startsWith("https://gist.github.com/") then
    return nil
  end
  return url:match("([^/]+)$")
end

-- Share onboarding
service.define {
  selector = "share:onboard",
  match = {
    name = "Github Gist",
    description = "Share this page as a gist"
  },
  run = function(data)
    local filename = "content.md"
    local text = share.cleanFrontmatter(editor.getText())
    filename = editor.prompt("File name", filename)
    if not filename then
      return
    end
    local resp = github.request("https://api.github.com/gists", "POST", {
      public = true,
      files = {
        [filename] = {
          content = text
        }
      }
    })
    if resp.ok then
      return {
        uri = resp.body.html_url,
        hash = share.contentHash(text),
        mode = "push"
      }
    else
      editor.flashNotification("Error, check console")
      js.log("Error", resp)
    end
  end
}

-- readURI supports
--   https://gist.github.com/user/gistid
service.define {
  selector = "net.readURI:https://gist.github.com/*",
  match = {priority=10},
  run = function(data)
    local gistId = extractGistId(data.uri)
    local resp = net.proxyFetch("https://api.github.com/gists/" .. gistId)
    if resp.status != 200 then
      print("Failed to fetch gist", resp)
      return nil
    end
    local files = resp.body.files
    for filename, data in pairs(files) do
      if data.content then
        return data.content
      end
    end
    return nil
  end
}

-- writeURI
service.define {
  selector = "net.writeURI:https://gist.github.com/*",
  match = {priority=10},
  run = function(data)
    local gistId = extractGistId(data.uri)
    
    -- First fetch the gist to find the file name
    local resp = github.request("https://api.github.com/gists/" .. gistId, "GET")
    if not resp.ok then
      js.log("Error with gist writeURI", resp)
      error("Gist writeURI failed")
    end
    local files = resp.body.files
    local selectedFilename
    -- Find a .md file in the gist to overwrite
    for filename, meta in pairs(files) do
      if filename:endsWith(".md") then
        selectedFilename = filename
        break
      end
    end
    if not selectedFilename then
      error("Could not find markdown file in gist")
    end
    local resp = github.request("https://api.github.com/gists/" .. gistId, "PATCH", {
      public = true,
      files = {
        [selectedFilename] = {
          content = data.content
        }
      }
    })
    if not resp.ok then
      js.log("Error", resp)
      error("Error publishing gist, check console")
    end
  end
}
```


================================================
FILE: libraries/Library/Std/Infrastructure/Library.md
================================================
#meta

This implements library management

# Implementation
```space-lua
-- namespace
library = {
  libraryTag = "meta/library",
  remoteLibraryTag = "meta/library/remote",
  repositoryTag = "meta/repository"
}

local libraryPrefix = "Library/"
local repositoryPrefix = "Repositories/"

-- Schema of frontmatter of the library page
-- Schema of #meta/library objects
local librarySchema = {
  type = "object",
  properties = {
    name = schema.string(),
    files = schema.array(schema.string())
  },
  required = {"name"}
}

local remoteLibrarySchema = {
  type = "object",
  properties = {
    name = schema.string(),
    description = schema.string(),
    website = schema.string(),
    uri = schema.string(),
  },
  required = {"name", "uri"}
}

-- Repo management commands
command.define {
  name = "Library: Add Repository",
  run = function()
    local repoUri = editor.prompt("URI:")
    if not repoUri then
      return
    end
    local text = net.readURI(repoUri, {encoding="text/markdown"})
    local suggestedName = library.suggestRepoNameFromUri(repoUri)
    local repoPage = editor.prompt("Install into:", repositoryPrefix .. suggestedName)
    if not repoPage then
      return
    end
    if space.pageExists(repoPage) then
      editor.flashNotification(repoPage .. " already exists", "error")
      return
    end
    space.writePage(repoPage, share.setFrontmatter({
      uri = repoUri,
      hash = share.contentHash(text),
      mode = "pull"
    }, text))
    editor.flashNotification "Library installed"
    editor.navigate(repoPage)
    reloadEverything()
  end
}

command.define {
  name = "Library: Update All Repositories",
  run = function()
    for repo in query[[from index.tag(library.repositoryTag)]] do
      if repo.share then
        share.sharePage(repo.name)
      end
    end
    editor.flashNotification "All repositories updated"
  end
}

-- Library management commands
command.define {
  name = "Library: Install",
  run = function()
    local uri = editor.prompt("URI:")
    if not uri then
      return
    end
    local libraryPage = library.install(uri)
    if libraryPage then
      editor.flashNotification "Library installed"
      reloadEverything()
      editor.navigate(libraryPage)
    end
  end
}

command.define {
  name = "Library: Update All",
  run = function()
    for lib in query[[
      from index.tag(library.libraryTag)
      where _.share and _.share.mode == "pull"
    ]] do
      if library.update(lib.name, false) then
        editor.flashNotification("Updated " .. lib.name)
      end
    end
    editor.flashNotification "Update complete!"
    reloadEverything()
  end
}

command.define {
  name = "Library: Manager",
  run = function()
    editor.navigate("Library/Std/Pages/Library Manager")
  end
}

-- Look up an installed library by name
function library.getInstalled(uri)
  local installedFMs = query[[
    from index.tag(library.libraryTag)
    where _.share and _.share.uri == uri
  ]]
  if #installedFMs == 0 then
    return nil
  else
    return installedFMs[1]
  end
end

-- Install a library
function library.install(uri, currentHash)
  -- Fetch from remote URL
  local text = net.readURI(uri, {
    encoding = "text/markdown"
  })

  if not text then
    error("Could not fetch: " .. uri)
  end

  local remoteLibFM = index.extractFrontmatter(text).frontmatter
  local remoteHash = share.contentHash(text)
  
  local err = jsonschema.validateObject(librarySchema, remoteLibFM)
  if err then
    editor.flashNotification("Library frontmatter validation error: " .. err, "error")
    return
  end
  
  local pageName = remoteLibFM.name

  -- If this is a fresh install, don't override existing pages
  if not currentHash and space.pageExists(pageName) then
    error("Page already exists: " .. pageName)
  end

  -- Check if update is required based on hash
  if currentHash and currentHash == remoteHash then
    return false
  end

  -- Ok, let's do this
  local sourceBase = urlDir(uri)
  local targetBase = urlDir(pageName)
  text = share.setFrontmatter({
    uri = uri,
    hash = remoteHash,
    mode = "pull"
  }, text)
  
  -- First write the main file
  space.writePage(pageName, text)
  
  -- And download and write assets, if any
  if remoteLibFM.files then
    for file in remoteLibFM.files do
      print("Downloading file", file)
      local targetPath = targetBase .. file
      local data = net.readURI(sourceBase .. file, {encoding="application/octet-stream"})
      if not data then
        error("Could not fetch file: " .. sourceBase .. file)
      end
      space.writeFile(targetPath, data)
    end
  end
  return pageName
end

-- Update a library
function library.update(pageName, force)
  local text = space.readPage(pageName)
  local fm = index.extractFrontmatter(text).frontmatter
  if fm.share then
     return library.install(fm.share.uri, fm.share.hash)
  else
    print("No 'share' key found in frontmatter for " .. pageName)
  end
end

function library.remove(pageName)
  local text = space.readPage(pageName)

  if not text then
    error("Could not read: " .. pageName)
  end

  local fm = index.extractFrontmatter(text).frontmatter

  local targetBase = urlDir(pageName)

  -- Remove associated files first
  if fm.files then
    for file in fm.files do
      local p = targetBase .. file
      space.deleteFile(p)
    end
  end
  
  -- Then page itself
  space.deletePage(pageName)
end

function library.installedLibrariesWidget()
  local rows = {}
  for lib in query[[
    from index.tag "meta/library"
  ]] do
    table.insert(rows, dom.tr {
      dom.td { "[[" .. lib.name .. "|" .. lib.name .. "]]" },
      dom.td {
        (
          not lib.share and ""
        ) or (
          lib.share.mode == "pull" and
          dom.span {
            widgets.button("Update", function()
              local updated = library.update(lib.name, true)
              if updated then
                editor.flashNotification "Updated."
                reloadEverything()
              else
                editor.flashNotification "No update available."
              end
            end, { class = "update" }),
            widgets.button("Remove", function()
              if editor.confirm("Are you sure?") then
                library.remove(lib.name)
                editor.flashNotification "Done!"
                reloadEverything()
              end
            end, { class = "remove"})  
          }
        ) or (
          dom.span {
            widgets.button("Publish", function()
              local updated = library.update(lib.name, true)
              if updated then
                editor.flashNotification "Published."
                reloadEverything()
              else
                editor.flashNotification "No changes to publish."
              end
            end, { class = "update" })
          }
        )
      }
    })
  end
  if #rows == 0 then
    rows = {
      dom.tr {
        dom.td { colspan="2", "_Nothing yet_" }
      }
    }
  end
  return widget.htmlBlock(dom.table {
    class = "manage-library",
    dom.thead {
      dom.tr {
        dom.td {"Library"},
        dom.td {"Action"}
      }
    },
    dom.tbody(rows)
  })
end

function library.installableLibrariesWidget()
  local installableLibraries = query[[from index.tag "meta/library/remote"]]
  local rows = {}
  for lib in query[[
    from installableLibraries where not library.getInstalled(_.uri)
  ]] do
    table.insert(rows, dom.tr {
      dom.td {
        dom.a {
          href = lib.website or lib.uri:startsWith("https://") and lib.uri,
          "**" .. lib.name .. "**",
        },
        " _([[" .. lib.page .. "]])_",
        lib.description and dom.p { lib.description } or ""
      },
      dom.td {
        widgets.button("Install", function()
          editor.flashNotification "Installing..."
          local libraryPage = library.install(lib.uri)
          if libraryPage then
            editor.flashNotification "Done!"
            reloadEverything()
            editor.navigate(libraryPage)
          end
        end, { class = "install" })
      }
    })
  end
  
  return widget.htmlBlock(dom.table {
    class = "manage-library",
    dom.thead {
      dom.tr {
        dom.td {"Library"},
        dom.td {"Action"}
      }
    },
    dom.tbody(rows)
  })
end

function library.installedRepositoriesWidget()
  local rows = {}
  for repo in query[[from index.tag(library.repositoryTag)]] do
    table.insert(rows, dom.tr {
      dom.td { "[[" .. repo.name .. "|" .. repo.name .. "]]" },
      dom.td {
        repo.share and dom.span {
          widgets.button("Update", function()
            if share.sharePage(repo.name) then
              editor.flashNotification "Repository updated"
              reloadEverything()
            else
              editor.flashNotification "No changes"
            end
          end),
          widgets.button("Remove", function()
            if editor.confirm("Are you sure?") then
              space.deletePage(repo.name)
              editor.flashNotification "Done!"
              reloadEverything()
            end
          end, { class = "remove" })
        } or ""
      }
    })
  end
  return widget.htmlBlock(dom.table {
    class = "manage-library",
    dom.thead {
      dom.tr {
        dom.td {"Repository"},
        dom.td {"Action"}
      }
    },
    dom.tbody(rows)
  })
end

-- Utility functions
local function urlDir(url)
  return string.match(url, "^(.*/)[^/]+$")
end

-- Construct full library path from name
function library.libPath(name)
  return libraryPrefix .. name
end

-- Extract name from full path
function library.libName(path)
  return path:sub(#libraryPrefix + 1)
end

function library.repoName(path)
  return path:sub(#repositoryPrefix + 1)
end

function library.suggestRepoNameFromUri(uri)
  -- Take the bit after the last :
  local uriParts = uri:split(":")
  local suggestedName = uriParts[#uriParts]
  -- Strip some generic suffixes
  for strippableSuffix in {".md", "/REPO", "/main", "/blob", "/silverbullet-libraries"} do
    suggestedName = suggestedName:gsub(strippableSuffix .. "$", "")
  end
  -- Strip branch names (@something)
  suggestedName = suggestedName:gsub("@.+$", "")
  -- Take the the last bit
  local parts = suggestedName:split("/")
  return parts[#parts]
end

function reloadEverything()
  mq.awaitEmptyQueue("indexQueue")
  editor.reloadConfigAndCommands()
  codeWidget.refreshAll()
end
```

# Styles
```space-style
table.manage-library td {
  vertical-align: top;
  white-space: normal !important;
}

table.manage-library thead td {
  white-space: nowrap !important;
}

table.manage-library thead td:last-child {
  text-align: right;
}

table.manage-library tbody td:last-child {
  text-align: right;
}
```


================================================
FILE: libraries/Library/Std/Infrastructure/Page Templates.md
================================================
---
description: Implements the infrastructure of Page Templates.
tags: meta
---
Page templates can be used to automate or simplify the creation of pages based on a template.

# Creating a page template
To create a page template, create a page and tag it with `#meta/template/page`.

The last component of the page name will be used as the template name.

## Cursor placement
In the template’s body text, you can optionally use `|^|` as a placeholder for where the cursor should be after creating
the page.

## Configuration
Optional keys you can set in the page’s frontmatter:

* `suggestedName`: the proposed name for the new page, can use embedded Lua expressions, like `Daily/${date.today()}`.
* `confirmName`: Confirm the suggested page name before creating it (defaults to `true`). Only effects pages created by a key binding, otherwise you will always be prompted to confirm the new page name.
* `openIfExists`: If a page with the `suggestedName` already exists, open it rather than showing an error.
* `command`: expose the page template as a command with this name.
* `key`: Bind the snippet to a keyboard shortcut (note: this requires to _also_ specify the `command` configuration).
* `mac`: Bind the snippet to a Mac-specific keyboard shortcut.
* `frontmatter`: Frontmatter (encoded as a string) to set in the resulting page.
* `priority`: Similar to how space lua scripts are loaded, this controls the order in which page template _commands_ are
  created (see "overriding page templates" below)

## Example: Daily note
The following creates a page template that can be run using the `Journal: Daily Note` command, it automatically creates
`Daily/today’s date` if it does not already exist with a bulleted list, putting the cursor at the first bullet. If the
page already exists, it navigates there.

~~~
---
command: "Journal: Daily Note"
suggestedName: "Daily/${date.today()}"
confirmName: false
openIfExists: true
tags: meta/template/page
---
* |^|
~~~

# Currently active page templates

${template.each(query[[
  from index.tag "meta/template/page"
  where _.tag == "page"
]], templates.fullPageItem)}

# Instantiating page templates

You can create a page based on a page template via the ${widgets.commandButton("Page: From Template")} command, or via
the command name that you defined in your template’s frontmatter.

# Overriding page templates

If you would like to override an existing page template (for instance the Quick Note) template with your own, you can
take advantage of the load order determined by the `priority` frontmatter. Built in page templates will have a priority
set that is higher than the default. Therefore, their commands and keybindings will be set early. Therefore, by simply
defining your own version of the page template _with the same command name_ will let that version override the versions
that are built in.

## Example: overriding the Quick Note template

~~~
---
command: Quick Note
key: "Ctrl-q q"
suggestedName: "Quick notes/${os.date('%Y-%m-%d/%H-%M-%S')}"
confirmName: false
tags: meta/template/page
---
This is my quick note version
~~~

# Implementation
```space-lua
-- priority: 10

local function createPageFromTemplate(templatePage, pageName, openIfExists)
  -- Won't override an existing page
  if space.pageExists(pageName) then
    if openIfExists then
      editor.navigate(pageName)
      return
    else
      editor.flashNotification("Page " .. pageName .. " already exists", "error")
      return
    end
  end
  local tpl, fm = template.fromPage(templatePage)
  local initialText = ""
  if fm.frontmatter then
    initialText = "---\n"
      .. string.trim(template.new(fm.frontmatter)())
      .. "\n---\n"
  end
  -- Write an empty page to start
  space.writePage(pageName, initialText)
  editor.navigate(pageName)
  -- Insert there, supporting |^| cursor placeholder
  editor.insertAtPos(tpl(), #initialText, true)
end

-- Create commands for all page templates with a command key in frontmatter
for pt in query[[
    from index.tag "meta/template/page"
    where _.tag == "page" and _.command
    order by _.priority desc
  ]] do
  command.define {
    name = pt.command,
    key = pt.key,
    mac = pt.mac,
    run = function()
      local pageName
      if pt.suggestedName then
        pageName = (template.new(pt.suggestedName))()
      end
      if pt.confirmName != false then
        pageName = string.trim(some(editor.prompt("Page name", pageName)) or "")
      end
      if pageName == "" then
        editor.flashNotification("No page name given for '" .. pt.command .. "', unable to continue.", "error")
        return
      end
      createPageFromTemplate(pt.name, pageName, pt.openIfExists)
    end
  }
end

local function cleanName(path)
  -- Just pull the last part
  local parts = path:split("/")
  return parts[#parts]
end

command.define {
  name = "Page: From Template",
  key = "Ctrl-q t",
  run = function()
    local pageTemplates = query[[
      from index.tag "meta/template/page"
      where _.tag == "page"
      select {
        name = cleanName(_.name),
        fullName = _.name,
        suggestedName = _.suggestedName,
        openIfExists = _.openIfExists
      }
    ]]
    local selected = editor.filterBox("Page template", pageTemplates, "Pick the template you would like to instantiate")
    if not selected then
      return
    end
    local pageName
    if selected.suggestedName then
      pageName = (template.new(selected.suggestedName))()
    end
    pageName = string.trim(some(editor.prompt("Page name", pageName)) or "")
    if pageName == "" then
      editor.flashNotification("No page name given for template '" .. cleanName(selected.fullName) .. "', unable to continue.", "error")
      return
    end
    createPageFromTemplate(selected.fullName, pageName, selected.openIfExists)
  end
}
```


================================================
FILE: libraries/Library/Std/Infrastructure/Query Templates.md
================================================
---
description: Implements generally useful query templates.
tags: meta
---
A work-in-progress library of generally useful templates for rendering queries.

```space-lua
-- Renders a page object as a linked list item
templates.pageItem = template.new([==[
* [[${name}]]
]==])

-- Renders a page object as a linked list item with full path
templates.fullPageItem = template.new([==[
* [[${name}|${name}]]
]==])

-- Renders a task object as a togglable task
templates.taskItem = template.new([==[
* [${state}] [[${ref}]] ${name}
]==])

-- Renders an item object
templates.itemItem = template.new([==[
* [[${ref}]] ${name}
]==])

-- Renders a paragraph object
templates.paragraphItem = template.new([==[
* [[${ref}]] ${text}
]==])

-- Renders a tag object
templates.tagItem = template.new([==[
* [[tag:${name}|#${name}]]
]==])
```

# Examples
`template.pageItem`:
${template.each(query[[from index.tag "page" limit 3]], templates.pageItem)}

`template.taskItem`:

    * [ ] Task 1
    * [ ] Task 2

${template.each(query[[from index.tag "task" where page == _CTX.currentPage.name]], templates.taskItem)}

`template.itemItem`:

    * Item 1
    * Item 2

${template.each(query[[from index.tag "item" where page == _CTX.currentPage.name]], templates.itemItem)}

`template.tagItem`:

    * #tag1
    * #tag2
    * #tag3

${template.each(query[[from index.tag "tag" where page == _CTX.currentPage.name]], templates.tagItem)}


================================================
FILE: libraries/Library/Std/Infrastructure/Share.md
================================================
#meta

# Implementation
```space-lua
share = {}

local shareSchema = {
  type = "object",
  properties = {
    uri = schema.string(),
    hash = schema.string(),
    mode = schema.string(), -- push | pull | sync
  },
}

-- Content change detection hash of 8 characters
-- First strips out share frontmatter-related keys to avoid hash-induced change detections
function share.contentHash(content)
  content = share.cleanFrontmatter(content)
  return crypto.sha256(content):sub(1, 8)
end

function share.cleanFrontmatter(text)
  return index.patchFrontmatter(text, {
    {op="delete-key", path="share.uri"},
    {op="delete-key", path="share.hash"},
    {op="delete-key", path="share.mode"},
  })
end

function share.setFrontmatter(m, text)
  return index.patchFrontmatter(text, {
    {op="set-key", path="share.uri", value=m.uri},
    {op="set-key", path="share.hash", value=m.hash},
    {op="set-key", path="share.mode", value=m.mode}
  })
end


-- Returns true if actually performed share, false if it was a no-op
function share.sharePage(name)
  local updateCurrent = not name
  local text
  if updateCurrent then
    text = editor.getText()
    name = editor.getCurrentPage()
  else
    text = space.readPage(name)
  end
  
  local m = index.extractFrontmatter(text).frontmatter.share
  if not m or type(m) != "table" then
    error("Not configured")
  end

  print("Current share frontmatter", m)
  
  local mode = m.mode or "push"
  local oldHash = m.hash
  local newLocalHash = share.contentHash(text)
  local newHash = newLocalHash

  if mode == "sync" then
    local remoteText = net.readURI(m.uri, {encoding="text/markdown"})
    if not remoteText then
      error("Could not read " .. m.uri)
    end
    -- Two-way sync mode
    local newRemoteHash = share.contentHash(remoteText)
    -- Sync cases
    if oldHash == newLocalHash and oldHash == newRemoteHash then
      print("Both sides up to date: nothing to do")
      return false
    elseif oldHash != newLocalHash and oldHash == newRemoteHash then
      print("Local changes, not remote one: local -> remote")
      net.writeURI(m.uri, share.cleanFrontmatter(text))
    elseif oldHash == newLocalHash and oldHash != newRemoteHash then
      print("Remote changes, not local ones: remote -> local")
      text = remoteText
      newHash = newRemoteHash
    else
      print("Changes on both ends: conflict")
      error("Conflict: not implemented yet")
    end
  elseif mode == "push" then
    if oldHash == newLocalHash then
      print("No local changes: nothing to do")
      return false
    else
      print("Local changes in push mode: local -> remote")
      net.writeURI(m.uri, share.cleanFrontmatter(text))
    end 
  elseif mode == "pull" then
    local remoteText = net.readURI(m.uri, {encoding="text/markdown"})
    if not remoteText then
      error("Could not read " .. m.uri)
    end
    local newRemoteHash = share.contentHash(remoteText)
    if oldHash == newRemoteHash then
      print("No remote changes: nothing to do")
      return false
    else
      if oldHash != newLocalHash then
        -- Local changes made since last pull, warn user
        if not editor.confirm("Local changes made to " .. name .. " are you ok to overwrite them with updated remote content from " .. m.uri .. "?") then
          return false
        end
      end  
      print("Remote changes in pull mode: remote -> local")
      text = remoteText
      newHash = newRemoteHash
    end
  end

  -- Update frontmatter
  m.hash = newHash
  m.mode = mode
  local updatedText = share.setFrontmatter(m, text)
  if updateCurrent then
    editor.setText(updatedText)
  else
    space.writePage(name, updatedText)
  end
  return true
end

command.define {
  name = "Share: Page",
  key = "Ctrl-p",
  mac = "Cmd-p",
  run = function()
    local ok, ret = pcall(share.sharePage)
    if ok then
      if ret then
        editor.flashNotification "Share successful"
      else
        editor.flashNotification "No changes"
      end
    else -- error
      if ret == "Not configured" then
        local data = {
          name = editor.getCurrentPage(),
          text = editor.getText()
        }
        local shareProviders = service.discover("share:onboard", data)
        local provider = editor.filterBox("Share provider", shareProviders, "Select how you would like to share this page")
        if not provider then
          return
        end
  
        print("Selected", provider)
        local m = service.invoke(provider, data)
        if m then
          editor.setText(share.setFrontmatter(m, data.text))
          editor.flashNotification "Share complete!"
        end
      else
        editor.flashNotification("Error: ".. ret)
      end
    end
  end
}
```


================================================
FILE: libraries/Library/Std/Infrastructure/Slash Templates.md
================================================
---
description: Implements the infrastructure of Slash Templates.
tags: meta
---
Allows for quick definition of slash commands based on templates.

# Creating slash templates

Create a page with the `#meta/template/slash` tag. The last component of the page name (after the `/` if any) will be
used as the slash command’s name. The body of the page will be used as the text to be inserted, using Lua directives is
supported.

## Configuration

You can configure some specifics about your slash template in its template page’s frontmatter.

Optional keys:

* `description`: The description of the slash command
* `priority`: To prioritize the slash command higher in the list
* `onlyContexts` (advanced): To only make the slash command appear in certain (AST node based) contexts
* `exceptContexts` (advanced): To make the slash command appear everywhere _except_ in these (AST node) contexts

# Currently active slash templates

${template.each(query[[
  from index.tag "meta/template/slash"
  where _.tag == "page"
]], templates.fullPageItem)}

# Implementation

```space-lua
for st in query[[
    from index.tag "meta/template/slash"
    where _.tag == "page"
  ]] do
  local components = st.name:split("/")
  local name = components[#components]
  slashCommand.define {
    name = name,
    description = st.description,
    priority = st.priority,
    onlyContexts = st.onlyContexts,
    exceptContexts = st.exceptContexts,
    run = function()
      local tpl = template.fromPage(st.name, st.raw)
      editor.insertAtCursor(tpl(), false, true)
    end
  }
end
```


================================================
FILE: libraries/Library/Std/Infrastructure/Tag Page.md
================================================
---
description: Implements the tag page
tags: meta
---

Implements a virtual tag page, that renders when navigating to a hashtag: #meta based on the `tag:` prefix.

# Overriding
You can override the tag page with a custom implementation as follows:

```lua
virtualPage.define {
  -- Using the same pattern as the built-in definition
  pattern = "tag:(.+)",
  run = function(tagName)
    return "# Custom tag page for " .. tagName
  end
}
```

# Implementation
```space-lua
-- priority: 10
virtualPage.define {
  pattern = "tag:(.+)",
  run = function(tagName)
    local text = "# Objects tagged with " .. tagName .. "\n"
    local allObjects = query[[
      from index.tag(tagName)
      order by ref
    ]]
    local tagParts = tagName:split("/")
    local parentTags = {}
    for i in ipairs(tagParts) do
      local slice = table.pack(table.unpack(tagParts, 1, i))
      if i != #tagParts then
        table.insert(parentTags, {name=table.concat(slice, "/")})
      end
    end
    if #parentTags > 0 then
      text = text .. "## Parent tags\n"
        .. template.each(parentTags, templates.tagItem)
    end
    local subTags = query[[
      from index.tag "tag"
      where string.startsWith(_.name, tagName .. "/")
      select {name=_.name}
    ]]
    if #subTags > 0 then
      text = text .. "## Child tags\n"
        .. template.each(subTags, templates.tagItem)
    end
    local taggedPages = query[[
      from allObjects where table.includes(_.itags, "page")
    ]]
    if #taggedPages > 0 then
      text = text .. "## Pages\n"
        .. template.each(taggedPages, templates.pageItem)
    end
    local taggedTasks = query[[
      from allObjects where table.includes(_.itags, "task")
    ]]
    if #taggedTasks > 0 then
      text = text .. "## Tasks\n"
        .. template.each(taggedTasks, templates.taskItem)
    end
    local taggedItems = query[[
      from allObjects where table.includes(_.itags, "item")
    ]]
    if #taggedItems > 0 then
      text = text .. "## Items\n"
        .. template.each(taggedItems, templates.itemItem)
    end
    local taggedData = query[[
      from allObjects where table.includes(_.itags, "data")
    ]]
    if #taggedData > 0 then
      text = text .. "## Data\n"
        .. markdown.objectsToTable(taggedData) .. "\n"
    end
    local taggedParagraphs = query[[
      from allObjects where table.includes(_.itags, "paragraph")
    ]]
    if #taggedParagraphs > 0 then
      text = text .. "## Paragraphs\n"
        .. template.each(taggedParagraphs, templates.paragraphItem)
    end
    return text
  end
}
```


================================================
FILE: libraries/Library/Std/Infrastructure/URI.md
================================================
---
description: Implements the infrastructure for reading and writing to URIs
tags: meta
---

# Implementation
```space-lua
-- Virtual page (uri:URI)
virtualPage.define {
  pattern = "uri:.+",
  run = function(path)
    local uri = path:sub(#"uri:"+1)
    return net.readURI(uri .. ".md", {encoding="text/markdown"})
  end
}

service.define {
  selector = "net.readURI:https:*",
  match = {},
  run = function(data)
    return net.proxyFetch(data.uri).body
  end
}
```


================================================
FILE: libraries/Library/Std/Page Templates/Page Template.md
================================================
---
suggestedName: "Library/Page Templates/"
description: A page template to create your own page templates
confirmName: true
tags: meta/template/page
frontmatter: |
  tags: "meta/template/page"
  ## Uncomment any of the single-commented lines to set options
  #############################################################
  ## Offer a suggested name when triggered
  #suggestedName: "Meeting Notes/"
  ## Whether or not to confirm the suggestedName or go there directly
  #confirmName: false
  ## To open a page if it already exists rather than create it fresh
  #openIfExists: true
  ## To trigger the page template with a custom command
  #command: Create My Custom Page
  ## To trigger the page template with a custom key
  #key: "Ctrl-q c"
  #mac: "Cmd-Alt-q"
  ## To prefill frontmatter
  #frontmatter: |
  #  date: "${"$"}{date.today()}"
  ## To manage the order in which the page templates appear
  #priority: 10
---
# Your page template title
Page template body


================================================
FILE: libraries/Library/Std/Page Templates/Quick Note.md
================================================
---
command: Quick Note
description: A page template to create a quick note
key:
- "Ctrl-q q"
- "Ctrl-q Ctrl-q"
suggestedName: "Inbox/${os.date('%Y-%m-%d/%H-%M-%S')}"
confirmName: false
tags: meta/template/page
priority: 10
---


================================================
FILE: libraries/Library/Std/Page Templates/Slash Template.md
================================================
---
suggestedName: "Library/Slash Templates/"
description: A page template to create a custom slash command
confirmName: true
tags: meta/template/page
frontmatter: |
  tags: "meta/template/slash"
  ## Uncomment any of the single-commented lines to set options
  #############################################################
  ## Offer a description in the drop down
  #description: My awesome slash template
  ## Set the priority of the slash template in the completion list
  #priority: 10
  ## (Advanced) Only offer this slash template in certain (AST) contexts
  #onlyContexts:
  #- FencedCode:space-lua
  ## (Advanced) Exclude offering this slash template in certain (AST) contexts
  #exceptContexts:
  #- FencedCode:space-lua
---
My slash template body


================================================
FILE: libraries/Library/Std/Pages/Library Manager.md
================================================
#meta

From here you can extend SilverBullet with additional functionality throught he use of [libraries](https://silverbullet.md/Libraries).

# Installed Libraries
${widgets.commandButton("Update All", "Library: Update All")}
${library.installedLibrariesWidget()}
# Available Libraries
${widgets.commandButton("Install from URI", "Library: Install")}
${library.installableLibrariesWidget()}
To discover more libraries, [have a look at the community forums](https://community.silverbullet.md/c/plugs-libraries/14).
# Repositories
${widgets.commandButton("Add from URI", "Library: Add Repository")} ${widgets.commandButton("Update All", "Library: Update All Repositories")}
${library.installedRepositoriesWidget()}
To discover more repositories, [have a look at the community forums](https://community.silverbullet.md/c/plugs-libraries/14).


================================================
FILE: libraries/Library/Std/Pages/Maintenance.md
================================================
#meta

We would like to keep our space clean. These are some tools that help you do that.

# Aspiring pages
This shows page links (max 20 to keep things sensible) that link to a page that does not (yet) exist. These could be broken links or just pages _aspiring_ to be created.

${some(template.each(query[[
  from index.tag "aspiring-page"
  limit 20
]], template.new[==[
    * [[${ref}]]: broken link to [[${name}]]
]==])) or "No aspiring pages, all good!"}

# Conflicting copies
These are pages that have conflicting copies (as a result of sync). Have a look at them as well as their original (non-conflicting) versions and decide which one to keep.

${some(template.each(query[[
  from index.tag "page" where name:find("%.conflicted:")
]], template.new[==[
    * [[${name:gsub("%.conflicted:.+$", "")}]]: conflict copy [[${name}]]
]==])) or "No conflicting pages!"}


================================================
FILE: libraries/Library/Std/Pages/Space Overview.md
================================================
#meta

This page compiles some useful stats about your space. You may also like the [[^Library/Std/Pages/Maintenance]] page.

# Stats
**Total pages:** ${#query[[from index.tag "page" select name]]}
**Total documents:** ${#query[[from index.tag "document" select name]]}

# Active Plugs
These are all the plugs currently active in your space:
${query[[
  from space.listPlugs()
  select {name=string.gsub(_.name, "_plug/([^.]+).plug.js", "%1")}
]]}

# Active Space Lua
These are all Space Lua scripts and the order in which they are loaded:
${template.each(query[[
  from index.tag "space-lua"
  order by _.priority desc
]], template.new[==[
    * [[${ref}]] (priority: ${priority or "default"})
]==])}

# Active Space Style
${template.each(query[[
  from index.tag "space-style"
  order by _.priority desc
  select {name=ref}
]], templates.pageItem)}


================================================
FILE: libraries/Library/Std/Slash Commands/Slash.md
================================================
#meta

Some convenient slash commands for editing Markdown files.

```space-lua
-- priority: 10

local function headerSlashCommand(level)
  local line = editor.getCurrentLine()
  local cleanText = string.gsub(line.textWithCursor, "^#+%s*", "")
  editor.replaceRange(line.from, line.to,
    string.rep("#", level) .. " " .. cleanText, true)
end

slashCommand.define {
  name = "h1",
  run = function()
    headerSlashCommand(1)
  end
}

slashCommand.define {
  name = "h2",
  run = function()
    headerSlashCommand(2)
  end
}

slashCommand.define {
  name = "h3",
  run = function()
    headerSlashCommand(3)
  end
}

slashCommand.define {
  name = "h4",
  run = function()
    headerSlashCommand(4)
  end
}

slashCommand.define {
  name = "frontmatter",
  run = function()
    editor.insertAtPos([==[---
|^|
---
]==], 0, true)
  end
}

slashCommand.define {
  name = "task",
  run = function()
    local line = editor.getCurrentLine()
    local ws, prefix, rest = string.match(line.textWithCursor, "^(%s*)([%-%*]?)%s*(.+)$")
    editor.replaceRange(line.from, line.to, ws .. "* [ ] " .. rest, true)
  end
}

slashCommand.define {
  name = "space-lua",
  description = "Insert Space Lua script",
  run = function()
    editor.insertAtCursor([==[```space-lua
|^|
```]==], false, true)
  end
}

```


================================================
FILE: libraries/Library/Std/Slash Templates/code.md
================================================
---
description: Insert a fenced code block
tags: meta/template/slash
exceptContexts:
- "FencedCode"
- "LuaDirective"
---
```|^|

```

================================================
FILE: libraries/Library/Std/Slash Templates/danger-admonition.md
================================================
---
description: Insert a danger admonition
tags: meta/template/slash
---
> **danger** Danger
> |^|

================================================
FILE: libraries/Library/Std/Slash Templates/func.md
================================================
---
description: Create a new Lua function
tags: meta/template/slash
onlyContexts:
- FencedCode:space-lua
- LuaDirective
---
function |^|()
end

================================================
FILE: libraries/Library/Std/Slash Templates/hr.md
================================================
---
description: Insert a horizontal rule
tags: meta/template/slash
---
---


================================================
FILE: libraries/Library/Std/Slash Templates/lua-command.md
================================================
---
description: Create a Lua command
tags: meta/template/slash
onlyContexts:
- "FencedCode:space-lua"
---
command.define {
  name = "|^|",
  run = function()
    editor.flashNotification "Hello world!"
  end
}

================================================
FILE: libraries/Library/Std/Slash Templates/lua-query.md
================================================
---
description: Insert a Lua query
tags: meta/template/slash
onlyContexts:
- "FencedCode:space-lua"
- "LuaDirective"
---
query[[from o = index.tag "|^|"]]


================================================
FILE: libraries/Library/Std/Slash Templates/lua-slash-command.md
================================================
---
description: Create a Lua slash command
tags: meta/template/slash
onlyContexts:
- "FencedCode:space-lua"
---

slashCommand.define {
  name = "|^|",
  run = function()
    editor.insertAtCursor("Hello |^| world!", false, true)
  end
}

================================================
FILE: libraries/Library/Std/Slash Templates/note-admonition.md
================================================
---
description: Insert a note admonition
tags: meta/template/slash
---
> **note** Note
> |^|

================================================
FILE: libraries/Library/Std/Slash Templates/query.md
================================================
---
description: Insert a query
tags: meta/template/slash
exceptContexts:
- "FencedCode:space-lua"
- "LuaDirective"
raw: true
---
${query[[from o = index.tag "|^|"]]}


================================================
FILE: libraries/Library/Std/Slash Templates/success-admonition.md
================================================
---
description: Insert a success admonition
tags: meta/template/slash
---
> **success** Success
> |^|

================================================
FILE: libraries/Library/Std/Slash Templates/table.md
================================================
---
description: Insert a markdown table
tags: meta/template/slash
exceptContexts:
- FencedCode
---
| Header A | Header B |
|----------|----------|
| Cell A|^| | Cell B |


================================================
FILE: libraries/Library/Std/Slash Templates/tpl.md
================================================
---
description: Create a new template
tags: meta/template/slash
onlyContexts:
- FencedCode:space-lua
- LuaDirective
---
template.new[==[|^|]==]

================================================
FILE: libraries/Library/Std/Slash Templates/warning-admonition.md
================================================
---
description: Insert a warning admonition
tags: meta/template/slash
---
> **warning** Warning
> |^|

================================================
FILE: libraries/Library/Std/Widgets/Embed.md
================================================
#meta

Implements useful embed widgets.

# Youtube
## Example
${embed.youtube "https://youtu.be/t1oy_41bDAY?si=X76FvJEUlJnApwEg"}

# Peertube
## Example 
${embed.peertube "https://peertube.fr/w/kkGMgK9ZtnKfYAgnEtQxbv"}

# Vimeo
## Example
${embed.vimeo "https://vimeo.com/1084537"}

# Implementation
```space-lua
-- Schema
local embedVideoSpecSchema = {
  type = "object",
  properties = {
    url = { type = "string"},
    width = { type = "number"},
    height = { type = "number"},
  },
  required = {"url"}
}

-- Youtube widget
function embed.youtube(specOrUrl)
  if type(specOrUrl) == "string" then
    specOrUrl = { url = specOrUrl }
  end
  -- Validate spec
  local validationResult = jsonschema.validateObject(embedVideoSpecSchema, specOrUrl)
  if validationResult then
    error(validationResult)
  end
  local videoId = string.match(specOrUrl.url, "youtube%.com/watch%?v=(.+)")
  if not videoId then
    videoId = string.match(specOrUrl.url, "youtu%.be/(.+)")
  end
  if not videoId then
    videoId = string.match(specOrUrl.url, "youtube%.com/shorts/(.+)")
  end

  if not videoId then
    error("No video id found")
  end
  
  local width = specOrUrl.width or "100%"
  local height = specOrUrl.height or "400px"
  return widget.html(dom.iframe {
    src="https://www.youtube.com/embed/" .. videoId,
    style="width: " .. width .. "; height: " .. height,
    class = "sb-video-embed",
    referrerpolicy = "strict-origin-when-cross-origin"
  })
end

-- Peertube widget
function embed.peertube(specOrUrl)
  if type(specOrUrl) == "string" then
    specOrUrl = { url = specOrUrl }
  end
 -- Validate spec
  local validationResult = jsonschema.validateObject(embedVideoSpecSchema, specOrUrl)
  if validationResult then
    error(validationResult)
  end
  
  local tempHost = string.match(specOrUrl.url, "https://(.+)")
  local lastIndex = string.find(tempHost, "/w/")
  local peertubeInstance = string.sub(tempHost, 1, (lastIndex -1) )
  local videoId = string.match(specOrUrl.url, "/w/(.+)")

  if not videoId then
    error("No video id found")
  end
  
  local width = specOrUrl.width or "100%"
  local height = specOrUrl.height or "400px"
  return widget.html(dom.iframe {
    src = "https://" .. peertubeInstance .. "/videos/embed/" .. videoId,
    style = "width: " .. width .. "; height: " .. height,
    frameborder = "0",
    allowfullscreen = "",
    sandbox = "allow-same-origin allow-scripts allow-popups allow-forms",
    class = "sb-video-embed"
  })
end

-- Vimeo widget
function embed.vimeo(specOrUrl)
  if type(specOrUrl) == "string" then
    specOrUrl = { url = specOrUrl }
  end
  -- Validate spec
  local validationResult = jsonschema.validateObject(embedVideoSpecSchema, specOrUrl)
  if validationResult then
    error(validationResult)
  end
  local videoId = string.match(specOrUrl.url, "vimeo%.com/(.+)")

  if not videoId then
    error("No video id found")
  end
  
  local width = specOrUrl.width or "100%"
  local height = specOrUrl.height or "400px"
  return widget.html(dom.iframe {
    src = "https://player.vimeo.com/video/" .. videoId,
    style = "width: " .. width .. "; height: " .. height,
    frameborder = "0", 
    referrerpolicy = "strict-origin-when-cross-origin", 
    allow = "fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share",   
    allowfullscreen = "",
    class = "sb-video-embed"
  })
end
```

## Styling

```space-style
.sb-video-embed {
  border: 0 !important;
}
```

# General implementation
```space-lua
-- priority: 50
embed = {}
```


================================================
FILE: libraries/Library/Std/Widgets/Widgets.md
================================================
#meta

Implements some useful general purpose widgets. Specifically:

## Buttons
Types of button widgets:

* `widgets.button(text, callback)` renders a simple button running the callback when clicked
* `widgets.commandButton(commandName)` renders a button for a particular command (where the button text is the command name itself)
* `widgets.commandButton(text, commandName)` renders a button for a particular command with a custom button text
* `widgets.commandButton(text, commandName, args)` renders a button for a particular command and arguments (specified as a table list) with a custom button text

Examples:

${widgets.button("Hello", function()
  editor.flashNotification "Hi there!"
end)}

${widgets.commandButton("System: Reload")}

## Top and bottom widgets
* Table of contents: shows a table of contents for your page
* Linked mentions: show a list of links that link to the current page, at the bottom of your page
* Linked tasks: shows a list of tasks that link to the current page, at the top of the page

These can each be individually enabled/disabled and configured in your [[CONFIG]] page (use `space-lua` instead of `lua`):

```lua
-- Disable TOC altogether
config.set("std.widgets.toc.enabled", false)
-- Only render a TOC when there's >= 5 headers
config.set("std.widgets.toc.minHeaders", 5)
-- Disable linked mentions altogether
config.set("std.widgets.linkedMentions.enabled", false)
-- Disable linked tasks altogether
config.set("std.widgets.linkedTasks.enabled", false)
```

# Implementation

## Buttons
```space-lua
-- priority: 10
function widgets.button(text, callback, attrs)
  local buttonEl = {
    onclick = callback,
    text
  }

  -- attrs can be used for additional customization
  if attrs then
    for k, v in pairs(attrs) do
      buttonEl[k] = v
    end
  end

  return widget.html(dom.button(buttonEl))
end

function widgets.commandButton(text, commandName, args)
  if not commandName then
    -- When only passed one argument, then let's assume it's a command name
    commandName = text
  end
  return widget.html(dom.button {
    onclick = function()
      editor.invokeCommand(commandName, args)
    end,
    text
  })
end

function widgets.subPages(pageName)
  local prefix = (pageName or editor.getCurrentPage()) .. "/"
  return widget.markdown(template.each(query[[
    from index.tag "page"
    where string.startsWith(_.name, prefix)
  ]], templates.pageItem))
end
```

## Table of contents
```space-lua
-- priority: 10
widgets = widgets or {}

-- configuration schema
config.define("std.widgets.toc", {
  type = "object",
  properties = {
    enabled = schema.boolean(),
    minHeaders = schema.number(),
  }
})

-- configuration default values
config.set("std.widgets.toc", {
  enabled = true,
  minHeaders = 3
})

function widgets.toc(options)
  options = options or config.get("std.widgets.toc")
  options.minHeaders = options.minHeaders or 3
  local text = editor.getText()
  local pageName = editor.getCurrentPage()
  local parsedMarkdown = markdown.parseMarkdown(text)
  -- Collect all headers
  local headers = {}
  for topLevelChild in parsedMarkdown.children do
    if topLevelChild.type then
      local headerLevel = string.match(topLevelChild.type, "^ATXHeading(%d+)")
      if headerLevel then
        local text = ""
        table.remove(topLevelChild.children, 1)
        for child in topLevelChild.children do
          text = text .. string.trim(markdown.renderParseTree(child))
        end
        -- Strip link syntax to avoid nested brackets in TOC
        text = string.gsub(text, "%[%[(.-)%]%]", "%1")

        if text != "" then
          table.insert(headers, {
            name = text,
            pos = topLevelChild.from,
            level = tonumber(headerLevel)
          })
        end
      end
    end
  end
  if options.minHeaders and options.minHeaders > #headers then
    return widget.new{}
  end
  -- Find min level
  local minLevel = 6
  for _, header in ipairs(headers) do
    if header.level < minLevel then
      minLevel = header.level
    end
  end
  -- Build up markdown
  local md = (options.header or "# Table of Contents") .. "\n"
  for _, header in ipairs(headers) do
    if not(options.maxHeader and header.level > options.maxHeader or
           options.minLevel and header.level < options.minLevel) then
      md = md .. string.rep(" ", (header.level - minLevel) * 2) ..
         "* [[" .. pageName .. "@" .. header.pos .. "|" .. header.name .. "]]\n"
    end
  end
  return widget.new {
    markdown = md
  }
end
```

### Top widget
```space-lua
-- priority: -1
if config.get("std.widgets.toc.enabled") then
  event.listen {
    name = "hooks:renderTopWidgets",
    run = function(e)
      local pageText = editor.getText()
      local fm = index.extractFrontmatter(pageText)
      if fm.frontmatter.pageDecoration and fm.frontmatter.pageDecoration.disableTOC then
        return
      end
      return widgets.toc()
    end
  }
end
```

## Linked mentions
```space-lua
-- priority: 10
widgets = widgets or {}

local mentionTemplate = template.new [==[
**[[${_.ref}|${_.ref}]]**:
${_.snippet}

]==]

-- configuration schema
config.define("std.widgets.linkedMentions", {
  type = "object",
  properties = {
    enabled = schema.boolean(),
  }
})

-- configuration default values
config.set("std.widgets.linkedMentions", {
  enabled = true,
})

function widgets.linkedMentions(pageName)
  pageName = pageName or editor.getCurrentPage()
  local linkedMentions = query[[
    from l = index.tag "link"
    where l.page != pageName and l.toPage == pageName
    order by l.pageLastModified desc, l.pos
  ]]
  if #linkedMentions > 0 then
    return widget.new {
      markdown = "# Linked Mentions\n"
        .. template.each(linkedMentions, mentionTemplate)
    }
  end
end
```

### Bottom widget
```space-lua
-- priority: -1
if config.get("std.widgets.linkedMentions.enabled") then
  event.listen {
    name = "hooks:renderBottomWidgets",
    run = function(e)
      return widgets.linkedMentions()
    end
  }
end
```

## Linked tasks
```space-lua
-- priority: 10

-- configuration schema
config.define("std.widgets.linkedTasks", {
  type = "object",
  properties = {
    enabled = schema.boolean(),
  }
})

-- configuration default values
config.set("std.widgets.linkedTasks", {
  enabled = true,
})

function widgets.linkedTasks(pageName)
  pageName = pageName or editor.getCurrentPage()
  local tasks = query[[
    from t = index.tag "task"
    where not t.done and table.includes(t.ilinks, pageName)
    order by t.page
  ]]
  local md = ""
  if #tasks > 0 then
    md = "# Linked Tasks\n"
       .. template.each(tasks, templates.taskItem)
  else
    md = ""
  end
  return widget.new {
    markdown = md
  }
end
```

### Top widget
```space-lua
-- priority: -1
if config.get("std.widgets.linkedTasks.enabled") then
  event.listen {
    name = "hooks:renderTopWidgets",
    run = function(e)
      return widgets.linkedTasks()
    end
  }
end
```


================================================
FILE: libraries/Repositories/Std.md
================================================
#meta/repository

Here are a few vetted libraries to get you started.

```#meta/library/remote
name: Git
description: Rudamentary Git integration into SilverBullet
author: Zef Hemel
uri: https://github.com/zefhemel/silverbullet-libraries/blob/main/Git.md
---
name: Mermaid diagrams
author: Zef Hemel
description: Mermaid diagram support for SilverBullet
uri: https://github.com/silverbulletmd/silverbullet-mermaid/blob/main/PLUG.md
---
name: Excalidraw diagrams
author: Logesh
website: https://github.com/LogeshG5/silverbullet-excalidraw
description: Support for [Excalidraw](https://excalidraw.com/) in SilverBullet.
uri: https://github.com/LogeshG5/silverbullet-excalidraw/blob/main/PLUG.md
---
name: Silversearch
author: MrMugame
description: Powerful full-text search engine for SilverBullet
website: https://github.com/MrMugame/silversearch
uri: ghr:MrMugame/silversearch/PLUG.md
---
name: TreeView
author: Joe Krill
description: Adds a tree view that allows you to navigate your SilverBullet pages hierarchically
website: https://github.com/joekrill/silverbullet-treeview
uri: https://github.com/joekrill/silverbullet-treeview/blob/main/PLUG.md
---
name: PDF Viewer
author: MrMugame
website: https://github.com/MrMugame/silverbullet-pdf
description: PDF Viewer for Silverbullet
uri: ghr:MrMugame/silverbullet-pdf/PLUG.md
---
name: Math
author: MrMugame
description: LaTeX-style math widgets for SilverBullet
uri: https://github.com/MrMugame/silverbullet-math/blob/main/Math.md
---
name: Basic Full-Text Search
author: Zef Hemel
description: Basic full-text search engine for SilverBullet (previously a built-in plug)
website: https://github.com/silverbulletmd/basic-search
uri: https://github.com/silverbulletmd/basic-search/blob/main/PLUG.md
```


================================================
FILE: plug-api/README.md
================================================
This folder contains two things:

* Wrappers for `syscall`s to be used by Plugs
* Reusable library code that plugs may find useful

================================================
FILE: plug-api/constants.ts
================================================
export const maximumDocumentSize: number = 10; // MiB
export const defaultLinkStyle: string = "wikilink";
export const offlineError: Error = new Error("Offline");
export const notFoundError: Error = new Error("Not found");
export const notAuthenticatedError: Error = new Error("Unauthenticated");
export const wrongSpacePathError: Error = new Error(
  "Space folder path different on server, reloading the page",
);
export const pingTimeout: number = 2000;
export const pingInterval: number = 5000;

/**
 * HTTP status codes that should be treated as "offline" conditions.
 *
 * This is particularly useful for cases where a proxy (such as Cloudflare or other reverse proxies)
 * indicates that the backend server is down, but there is still network connectivity between
 * the user and the proxy. In these scenarios, we want to allow the user to continue working
 * with their cached data rather than showing an error, even though technically there is network
 * connectivity to the proxy.
 *
 * This enables SilverBullet to work in a true "offline-first" manner, falling back to cached
 * content when the backend is unavailable through no fault of the user's network connection.
 *
 * All 5xx server errors are included to prevent the client from caching error HTML pages
 * (e.g., Nginx 500 error pages) which would prevent the client from booting in offline mode.
 */
export const offlineStatusCodes = {
  500: "Internal Server Error", // Server encountered an unexpected condition
  501: "Not Implemented", // Server does not support the functionality required
  502: "Bad Gateway", // Proxy server received invalid response from upstream server
  503: "Service Unavailable", // Server is temporarily unable to handle the request
  504: "Gateway Timeout", // Proxy server did not receive a timely response from upstream server
  505: "HTTP Version Not Supported", // Server does not support the HTTP version
  506: "Variant Also Negotiates", // Server has an internal configuration error
  507: "Insufficient Storage", // Server is unable to store the representation
  508: "Loop Detected", // Server detected an infinite loop while processing
  509: "Bandwidth Limit Exceeded", // Server bandwidth limit has been exceeded
  510: "Not Extended", // Further extensions to the request are required
  511: "Network Authentication Required", // Client needs to authenticate to gain network access

  530: "Unable to resolve origin hostname", // Served when cloudflared is down on the host
} as const;


================================================
FILE: plug-api/lib/README.md
================================================
This folder contains useful library functions to be used by plugs, built-in or external.

================================================
FILE: plug-api/lib/async.test.ts
================================================
import { assert, assertEquals } from "@std/assert";
import {
  batchRequests,
  processWithConcurrency,
  PromiseQueue,
  sleep,
} from "./async.ts";

Deno.test("PromiseQueue test", async () => {
  const q = new PromiseQueue();
  let r1RanFirst = false;
  const r1 = q.runInQueue(async () => {
    await sleep(10);
    r1RanFirst = true;
    // console.log("1");
    return 1;
  });
  const r2 = q.runInQueue(async () => {
    // console.log("2");
    await sleep(4);
    return 2;
  });
  assertEquals(await Promise.all([r1, r2]), [1, 2]);
  assertEquals(r1RanFirst, true);
  let wasRun = false;
  await q.runInQueue(async () => {
    await sleep(4);
    wasRun = true;
  });
  assertEquals(wasRun, true);
});

Deno.test("processWithConcurrency test - basic functionality", async () => {
  const items = [1, 2, 3, 4, 5];
  const results = await processWithConcurrency(
    items,
    async (item) => {
      await sleep(10);
      return item * 2;
    },
    2,
  );
  assertEquals(results, [2, 4, 6, 8, 10]);
});

Deno.test("processWithConcurrency test - maintains order", async () => {
  const items = [1, 2, 3, 4, 5];
  const startTimes: number[] = [];
  const endTimes: number[] = [];

  const results = await processWithConcurrency(
    items,
    async (item) => {
      const start = Date.now();
      startTimes[item - 1] = start;
      // Longer sleep for higher numbers to test order preservation
      await sleep(item * 5);
      endTimes[item - 1] = Date.now();
      return `item-${item}`;
    },
    2,
  );

  // Results should be in original order despite different processing times
  assertEquals(results, ["item-1", "item-2", "item-3", "item-4", "item-5"]);
});

Deno.test("processWithConcurrency test - concurrency limit", async () => {
  const items = [1, 2, 3, 4, 5, 6];
  const activeCount = { value: 0 };
  const maxActive = { value: 0 };

  const results = await processWithConcurrency(
    items,
    async (item) => {
      activeCount.value++;
      maxActive.value = Math.max(maxActive.value, activeCount.value);
      await sleep(20);
      activeCount.value--;
      return item;
    },
    3,
  );

  assertEquals(results, [1, 2, 3, 4, 5, 6]);
  // Should never exceed concurrency limit of 3
  assertEquals(maxActive.value <= 3, true);
});

Deno.test("processWithConcurrency test - empty array", async () => {
  const results = await processWithConcurrency(
    [],
    (item) => Promise.resolve(item),
    2,
  );
  assertEquals(results, []);
});

Deno.test("processWithConcurrency test - concurrency higher than item count", async () => {
  const items = [1, 2, 3];
  const results = await processWithConcurrency(
    items,
    async (item) => {
      await sleep(10);
      return item + 10;
    },
    10, // Higher than items.length
  );
  assertEquals(results, [11, 12, 13]);
});

Deno.test("processWithConcurrency test - error handling", async () => {
  const items = [1, 2, 3, 4, 5];

  try {
    await processWithConcurrency(
      items,
      (item) => {
        if (item === 3) {
          throw new Error(`Error processing item ${item}`);
        }
        return Promise.resolve(item * 2);
      },
      2,
    );
    assert(false, "Should have thrown an error");
  } catch (error) {
    assert(
      error instanceof Error &&
        error.message.includes("Error processing item 3"),
    );
  }
});

Deno.test("processWithConcurrency test - performance with concurrency", async () => {
  const items = Array.from({ length: 6 }, (_, i) => i + 1);
  const delay = 30;

  // Test with concurrency of 1 (sequential)
  const start1 = Date.now();
  await processWithConcurrency(
    items,
    async (item) => {
      await sleep(delay);
      return item;
    },
    1,
  );
  const sequential = Date.now() - start1;

  // Test with concurrency of 3 (parallel)
  const start2 = Date.now();
  await processWithConcurrency(
    items,
    async (item) => {
      await sleep(delay);
      return item;
    },
    3,
  );
  const parallel = Date.now() - start2;

  // Parallel should be significantly faster than sequential
  // Allow some margin for timing variations
  assert(
    parallel < sequential * 0.8,
    `Parallel (${parallel}ms) should be faster than sequential (${sequential}ms)`,
  );
});

Deno.test("Batch test", async () => {
  // Generate an array with numbers up to 100
  const elements = Array.from(Array(100).keys());
  const multiplied = await batchRequests(elements, async (batch) => {
    await sleep(2);
    // Batches should be 9 or smaller (last batch will be smaller)
    assert(batch.length <= 9);
    return batch.map((e) => e * 2);
  }, 9);
  assertEquals(multiplied, elements.map((e) => e * 2));
  const multiplied2 = await batchRequests(elements, (batch) => {
    return Promise.resolve(batch.map((e) => e * 2));
  }, 10000);
  assertEquals(multiplied2, elements.map((e) => e * 2));
});


================================================
FILE: plug-api/lib/async.ts
================================================
export function throttle(func: () => void, limit: number): () => void {
  let timer: any = null;
  return function () {
    if (!timer) {
      timer = setTimeout(() => {
        func();
        timer = null;
      }, limit);
    }
  };
}

export function throttleImmediately(
  func: () => void,
  limit: number,
): () => void {
  let timer: any = null;
  return function () {
    if (!timer) {
      func();
      timer = setTimeout(() => {
        timer = null;
      }, limit);
    }
  };
}

// race for promises returns first promise that resolves
export function race<T>(promises: Promise<T>[]): Promise<T> {
  return new Promise((resolve, reject) => {
    for (const p of promises) {
      p.then(resolve, reject);
    }
  });
}

export function timeout(ms: number): Promise<never> {
  return new Promise((_resolve, reject) =>
    setTimeout(() => {
      reject(new Error("timeout"));
    }, ms)
  );
}

export function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

export class PromiseQueue {
  private queue: {
    fn: () => Promise<any>;
    resolve: (value: any) => void;
    reject: (error: any) => void;
  }[] = [];
  private processing = false;

  runInQueue(fn: () => Promise<any>): Promise<any> {
    return new Promise((resolve, reject) => {
      this.queue.push({ fn, resolve, reject });
      if (!this.processing) {
        this.process();
      }
    });
  }

  private async process(): Promise<void> {
    if (this.queue.length === 0) {
      this.processing = false;
      return;
    }

    this.processing = true;
    const { fn, resolve, reject } = this.queue.shift()!;

    try {
      const result = await fn();
      resolve(result);
    } catch (error) {
      reject(error);
    }

    this.process(); // Continue processing the next promise in the queue
  }
}

/**
 * Batches up values, and processes in batches of batchSize in parallel
 * then merges the results in the appropriate order.
 * @param values - The values to batch.
 * @param fn - The function to run on each batch.
 * @param batchSize - The size of each batch.
 */
export async function batchRequests<I, O>(
  values: I[],
  fn: (batch: I[]) => Promise<O[]>,
  batchSize: number,
): Promise<O[]> {
  const results: O[] = [];
  // Split values into batches of batchSize
  const batches: I[][] = [];
  for (let i = 0; i < values.length; i += batchSize) {
    batches.push(values.slice(i, i + batchSize));
  }
  // Run fn on them in parallel
  const batchResults = await Promise.all(batches.map(fn));
  // Flatten the results
  for (const batchResult of batchResults) {
    if (Array.isArray(batchResult)) { // If fn returns an array, collect them
      results.push(...batchResult);
    }
  }
  return results;
}

/**
 * Processes items in parallel with a specified concurrency limit.
 * @param items - The items to process.
 * @param handler - The function to run on each item.
 * @param concurrency - The maximum number of concurrent operations.
 */
export async function processWithConcurrency<I, O>(
  items: I[],
  handler: (item: I) => Promise<O>,
  concurrency: number,
): Promise<O[]> {
  const results: O[] = [];
  let idx = 0;

  async function worker() {
    while (idx < items.length) {
      const currentIdx = idx++;
      const item = items[currentIdx];
      const result = await handler(item);
      results[currentIdx] = result;
    }
  }

  const workers = [];
  for (let i = 0; i < Math.min(concurrency, items.length); i++) {
    workers.push(worker());
  }

  await Promise.all(workers);
  return results;
}

/**
 * Runs a function safely by catching any errors and logging them to the console.
 * @param fn - The function to run.
 */
export function safeRun(fn: () => Promise<void>): void {
  fn().catch((e) => {
    console.error(e);
  });
}

/**
 * Generates a random delay between 0 and 1000 milliseconds.
 */
export function jitter(maxLength = 1000): number {
  return Math.floor(Math.random() * maxLength);
}


================================================
FILE: plug-api/lib/crypto.test.ts
================================================
import {
  decryptAesGcm,
  decryptStringDeterministic,
  deriveCTRKeyFromPassword,
  deriveGCMKeyFromCTR,
  encryptAesGcm,
  encryptStringDeterministic,
} from "@silverbulletmd/silverbullet/lib/crypto";
import { assertEquals } from "@std/assert";

Deno.test("Crypto test", async () => {
  const salt = new Uint8Array(16); // zeroes for testing
  const ctr = await deriveCTRKeyFromPassword("12345", salt);
  const gcm = await deriveGCMKeyFromCTR(ctr);
  const text = "123";
  const encrypted = await encryptStringDeterministic(ctr, text);
  const encrypted2 = await encryptStringDeterministic(ctr, text);
  // Ensure determinism
  assertEquals(encrypted, encrypted2);
  const decrypted = await decryptStringDeterministic(ctr, encrypted);
  assertEquals(decrypted, text);

  // Now gcm
  const buffer = new Uint8Array(100).fill(32);
  const encryptedBuf = await encryptAesGcm(gcm, buffer);
  const decryptedBuf = await decryptAesGcm(gcm, encryptedBuf);
  assertEquals(decryptedBuf, buffer);
});


================================================
FILE: plug-api/lib/crypto.ts
================================================
export function base64Decode(s: string): Uint8Array {
  const binString = atob(s);
  const len = binString.length;
  const bytes = new Uint8Array(len);
  for (let i = 0; i < len; i++) {
    bytes[i] = binString.charCodeAt(i);
  }
  return bytes;
}

export function base64Encode(buffer: Uint8Array | string): string {
  if (typeof buffer === "string") {
    buffer = new TextEncoder().encode(buffer);
  }
  let binary = "";
  const len = buffer.byteLength;
  for (let i = 0; i < len; i++) {
    binary += String.fromCharCode(buffer[i]);
  }
  return btoa(binary);
}

export function base64EncodedDataUrl(
  mimeType: string,
  buffer: Uint8Array,
): string {
  return `data:${mimeType};base64,${base64Encode(buffer)}`;
}

export function base64DecodeDataUrl(dataUrl: string): Uint8Array {
  const b64Encoded = dataUrl.split(",", 2)[1];
  return base64Decode(b64Encoded);
}

/**
 * Perform sha256 hash using the browser's crypto APIs
 * Note: this will only work over HTTPS
 * @param message
 */
export async function hashSHA256(
  message: string | Uint8Array,
): Promise<string> {
  // Transform the string into an ArrayBuffer
  const encoder = new TextEncoder();
  const data: Uint8Array = typeof message === "string"
    ? encoder.encode(message)
    : message;

  // Generate the hash
  const hashBuffer = await globalThis.crypto.subtle.digest(
    "SHA-256",
    data as BufferSource,
  );

  // Transform the hash into a hex string
  return Array.from(new Uint8Array(hashBuffer)).map((b) =>
    b.toString(16).padStart(2, "0")
  ).join("");
}

/**
 * To avoid database clashes based on space folder path name, base URLs and encryption keys we derive
 * a database name from a hash of all these combined together
 */
export async function deriveDbName(
  type: "data" | "files",
  spaceFolderPath: string,
  baseURI: string,
  encryptionKey?: CryptoKey,
): Promise<string> {
  let keyPart = "";
  if (encryptionKey) {
    keyPart = await exportKey(encryptionKey);
  }
  const spaceHash = await hashSHA256(
    `${spaceFolderPath}:${baseURI}:${keyPart}`,
  );
  return `sb_${type}_${spaceHash}`;
}

// Fixed counter for AES-CTR all zeroes, for determinism
const fixedCounter = new Uint8Array(16);

export async function encryptStringDeterministic(
  key: CryptoKey,
  clearText: string,
): Promise<string> {
  const encrypted = await crypto.subtle.encrypt(
    { name: "AES-CTR", counter: fixedCounter, length: fixedCounter.length * 8 },
    key,
    new TextEncoder().encode(clearText),
  );
  return base64Encode(new Uint8Array(encrypted));
}

export async function decryptStringDeterministic(
  key: CryptoKey,
  cipherText: string,
): Promise<string> {
  const decrypted = await crypto.subtle.decrypt(
    { name: "AES-CTR", counter: fixedCounter, length: fixedCounter.length * 8 },
    key,
    base64Decode(cipherText) as BufferSource,
  );
  return new TextDecoder().decode(decrypted);
}

// Encrypt using AES-GCM with random IV; output = IV + ciphertext
export async function encryptAesGcm(
  key: CryptoKey,
  data: Uint8Array,
): Promise<Uint8Array> {
  const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV recommended for GCM
  const encryptedBuffer = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    key,
    data as BufferSource,
  );
  const encrypted = new Uint8Array(encryptedBuffer);

  // Prepend IV to ciphertext
  const result = new Uint8Array(iv.length + encrypted.length);
  result.set(iv, 0);
  result.set(encrypted, iv.length);
  return result;
}

// Decrypt using AES-GCM assuming input format IV + ciphertext
export async function decryptAesGcm(
  key: CryptoKey,
  encryptedData: Uint8Array,
): Promise<Uint8Array> {
  const iv = encryptedData.slice(0, 12); // extract IV (first 12 bytes)
  const ciphertext = encryptedData.slice(12);
  const decryptedBuffer = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv },
    key,
    ciphertext,
  );
  return new Uint8Array(decryptedBuffer);
}

export async function deriveCTRKeyFromPassword(
  password: string,
  salt: Uint8Array,
): Promise<CryptoKey> {
  // Encode password to ArrayBuffer
  const passwordBytes = new TextEncoder().encode(password);

  // Import password as a CryptoKey
  const baseKey = await crypto.subtle.importKey(
    "raw",
    passwordBytes,
    { name: "PBKDF2" },
    false,
    ["deriveBits", "deriveKey"],
  );

  return crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt: salt as BufferSource,
      iterations: 100000,
      hash: "SHA-256",
    },
    baseKey,
    {
      name: "AES-CTR",
      length: 256,
    },
    true, // extractable
    ["encrypt", "decrypt"],
  );
}

export function importKey(b64EncodedKey: string): Promise<CryptoKey> {
  return crypto.subtle.importKey(
    "raw",
    base64Decode(b64EncodedKey) as BufferSource,
    { name: "AES-CTR" },
    true,
    ["encrypt", "decrypt"],
  );
}

export async function exportKey(ctrKey: CryptoKey): Promise<string> {
  const key = await crypto.subtle.exportKey("raw", ctrKey);
  return base64Encode(new Uint8Array(key));
}

export async function deriveGCMKeyFromCTR(
  ctrKey: CryptoKey,
): Promise<CryptoKey> {
  const rawKey = await crypto.subtle.exportKey("raw", ctrKey);
  return crypto.subtle.importKey(
    "raw",
    rawKey,
    { name: "AES-GCM" },
    true,
    ["encrypt", "decrypt"],
  );
}


================================================
FILE: plug-api/lib/dates.test.ts
================================================
import { localDateString } from "./dates.ts";

Deno.test("Dates", () => {
  console.log("Local date string", localDateString(new Date()));
});


================================================
FILE: plug-api/lib/dates.ts
================================================
export function niceDate(d: Date): string {
  return localDateString(d).split("T")[0];
}

export function localDateString(d: Date): string {
  return d.getFullYear() +
    "-" + String(d.getMonth() + 1).padStart(2, "0") +
    "-" + String(d.getDate()).padStart(2, "0") +
    "T" + String(d.getHours()).padStart(2, "0") +
    ":" + String(d.getMinutes()).padStart(2, "0") +
    ":" + String(d.getSeconds()).padStart(2, "0") +
    "." + String(d.getMilliseconds()).padStart(3, "0");
}


================================================
FILE: plug-api/lib/json.test.ts
================================================
import { assertEquals } from "@std/assert";
import { cleanupJSON, deepClone, deepEqual } from "./json.ts";

Deno.test("JSON utils", () => {
  assertEquals(deepEqual({ a: 1 }, { a: 1 }), true);
  assertEquals(deepEqual({ a: null }, { a: null }), true);
  assertEquals(deepEqual({ a: {} }, { a: null }), false);
  assertEquals(deepEqual({ a: {} }, { a: undefined }), false);
  assertEquals(deepEqual({ a: null }, { a: {} }), false);
  assertEquals(cleanupJSON({ "a.b": 1 }), { a: { b: 1 } });
  assertEquals(cleanupJSON({ a: { "a.b": 1 } }), {
    a: { a: { b: 1 } },
  });
  assertEquals(cleanupJSON({ a: [{ "a.b": 1 }] }), {
    a: [{ a: { b: 1 } }],
  });

  assertEquals(cleanupJSON(new Date("2023-05-03T00:00:00Z")), "2023-05-03");
});

Deno.test("JSON utils - deepObjectMerge", () => {
  // Tests for deepClone
  const obj1 = { a: 1, b: { c: 2, d: [3, 4] }, e: new Date("2023-08-21") };
  const clone1 = deepClone(obj1);
  assertEquals(clone1, obj1);
  assertEquals(clone1 === obj1, false); // Ensuring deep clone, not shallow
  assertEquals(clone1.b === obj1.b, false); // Nested object should be different reference
  assertEquals(clone1.e === obj1.e, false); // Date object should be different reference

  const arrayTest = [1, 2, { a: 3, b: [4, 5] }];
  const cloneArray = deepClone(arrayTest);
  assertEquals(cloneArray, arrayTest);
  assertEquals(cloneArray === arrayTest, false); // Array itself should be different reference
  assertEquals(cloneArray[2] === arrayTest[2], false); // Nested object in array should be different reference

  const nullTest = { a: null, b: undefined, c: { d: null } };
  const cloneNullTest = deepClone(nullTest);
  assertEquals(cloneNullTest, nullTest);
  assertEquals(cloneNullTest === nullTest, false); // Ensure it's a deep clone
  assertEquals(cloneNullTest.c === nullTest.c, false); // Nested object should be different reference

  const dateTest = new Date();
  const cloneDateTest = deepClone(dateTest);
  assertEquals(cloneDateTest.getTime(), dateTest.getTime());
  assertEquals(cloneDateTest === dateTest, false); // Date instance should be different reference
});


================================================
FILE: plug-api/lib/json.ts
================================================
/**
 * Performs a deep comparison of two objects, returning true if they are equal
 * @param a first object
 * @param b second object
 * @returns
 */
export function deepEqual(a: any, b: any): boolean {
  if (a === b) {
    return true;
  }
  if (typeof a !== typeof b) {
    return false;
  }
  if (a === null || b === null) {
    return false;
  }
  if (a === undefined || b === undefined) {
    return false;
  }
  if (typeof a === "object") {
    if (Array.isArray(a) && Array.isArray(b)) {
      if (a.length !== b.length) {
        return false;
      }
      for (let i = 0; i < a.length; i++) {
        if (!deepEqual(a[i], b[i])) {
          return false;
        }
      }
      return true;
    } else {
      const aKeys = Object.keys(a);
      const bKeys = Object.keys(b);
      if (aKeys.length !== bKeys.length) {
        return false;
      }
      for (const key of aKeys) {
        if (!deepEqual(a[key], b[key])) {
          return false;
        }
      }
      return true;
    }
  }
  return false;
}

/**
 * Converts a Date object to a date string in the format YYYY-MM-DD if it just contains a date (and no significant time), or a full ISO string otherwise
 * @param d the date to convert
 */
export function cleanStringDate(d: Date): string {
  // If no significant time, return a date string only
  if (
    d.getUTCHours() === 0 && d.getUTCMinutes() === 0 && d.getUTCSeconds() === 0
  ) {
    return d.getUTCFullYear() + "-" +
      String(d.getUTCMonth() + 1).padStart(2, "0") + "-" +
      String(d.getUTCDate()).padStart(2, "0");
  } else {
    return d.toISOString();
  }
}

/**
 * Processes a JSON (typically coming from parse YAML frontmatter) in two ways:
 * 1. Expands property names in an object containing a .-separated path
 * 2. Converts dates to strings in sensible ways
 * @param a
 * @returns
 */
export function cleanupJSON(a: any): any {
  if (!a) {
    return a;
  }
  if (typeof a !== "object") {
    return a;
  }
  if (Array.isArray(a)) {
    return a.map(cleanupJSON);
  }
  // If a is a date, convert to a string
  if (a instanceof Date) {
    return cleanStringDate(a);
  }
  const expanded: any = {};
  for (const key of Object.keys(a)) {
    const parts = key.split(".");
    let target = expanded;
    for (let i = 0; i < parts.length - 1; i++) {
      const part = parts[i];
      if (!target[part]) {
        target[part] = {};
      }
      target = target[part];
    }
    target[parts[parts.length - 1]] = cleanupJSON(a[key]);
  }
  return expanded;
}

export function deepClone<T>(obj: T, ignoreKeys: string[] = []): T {
  // Handle null, undefined, or primitive types (string, number, boolean, symbol, bigint)
  if (obj === null || typeof obj !== "object") {
    return obj;
  }

  // Handle Date
  if (obj instanceof Date) {
    return new Date(obj.getTime()) as any;
  }

  // Handle Array
  if (Array.isArray(obj)) {
    const arrClone: any[] = [];
    for (let i = 0; i < obj.length; i++) {
      arrClone[i] = deepClone(obj[i], ignoreKeys);
    }
    return arrClone as any;
  }

  // Handle Object
  if (obj instanceof Object) {
    const objClone: { [key: string]: any } = {};
    for (const key in obj) {
      if (ignoreKeys.includes(key)) {
        objClone[key] = obj[key];
      } else if (Object.prototype.hasOwnProperty.call(obj, key)) {
        objClone[key] = deepClone(obj[key], ignoreKeys);
      }
    }
    return objClone as T;
  }

  throw new Error("Unsupported data type.");
}


================================================
FILE: plug-api/lib/limited_map.test.ts
================================================
import { assertEquals } from "@std/assert";
import { LimitedMap } from "./limited_map.ts";
import { sleep } from "./async.ts";

Deno.test("limited map", async () => {
  const mp = new LimitedMap<string>(3);
  mp.set("a", "a");
  mp.set("b", "b", 5);
  mp.set("c", "c");
  assertEquals(mp.get("a"), "a");
  assertEquals(mp.get("b"), "b");
  assertEquals(mp.get("c"), "c");
  // Drops the first key
  mp.set("d", "d");
  // console.log(mp.toJSON());
  assertEquals(mp.get("a"), undefined);
  await sleep(10);
  // "b" should have been dropped
  assertEquals(mp.get("b"), undefined);
  assertEquals(mp.get("c"), "c");

  console.log(mp.toJSON());
});


================================================
FILE: plug-api/lib/limited_map.ts
================================================
type LimitedMapRecord<V> = {
  value: V;
  la: number;
  expTimer?: number;
};

export class LimitedMap<V> {
  private map: Map<string, LimitedMapRecord<V>>;

  constructor(
    private maxSize: number,
    initialJson: Record<string, LimitedMapRecord<V>> = {},
  ) {
    this.map = new Map(Object.entries(initialJson));
  }

  /**
   * @param key
   * @param value
   * @param ttl time to live (in ms)
   */
  set(key: string, value: V, ttl?: number) {
    const entry: LimitedMapRecord<V> = { value, la: Date.now() };
    if (ttl) {
      const existingEntry = this.map.get(key);
      if (existingEntry?.expTimer) {
        clearTimeout(existingEntry.expTimer);
      }
      entry.expTimer = setTimeout(() => {
        this.map.delete(key);
      }, ttl);
    }
    if (this.map.size >= this.maxSize) {
      // Remove the oldest key before adding a new one
      const oldestKey = this.getOldestKey();
      this.map.delete(oldestKey!);
    }
    this.map.set(key, entry);
  }

  get(key: string): V | undefined {
    const entry = this.map.get(key);
    if (entry) {
      // Update the last accessed timestamp
      entry.la = Date.now();
      return entry.value;
    }
    return undefined;
  }

  remove(key: string) {
    this.map.delete(key);
  }

  toJSON(): Record<string, any> {
    return Object.fromEntries(this.map.entries());
  }

  private getOldestKey(): string | undefined {
    let oldestKey: string | undefined;
    let oldestTimestamp: number | undefined;

    for (const [key, entry] of this.map.entries()) {
      if (!oldestTimestamp || entry.la < oldestTimestamp) {
        oldestKey = key;
        oldestTimestamp = entry.la;
      }
    }

    return oldestKey;
  }
}


================================================
FILE: plug-api/lib/memory_cache.test.ts
================================================
import { ttlCache } from "./memory_cache.ts";
import { sleep } from "./async.ts";
import { assertEquals } from "@std/assert";

Deno.test("Memory cache", async () => {
  let calls = 0;

  async function expensiveFunction(key: string) {
    calls++;
    await sleep(1);
    return key;
  }

  assertEquals("key", await ttlCache("key", expensiveFunction, 0.01));
  assertEquals(1, calls);
  assertEquals("key", await ttlCache("key", expensiveFunction, 0.01));
  assertEquals(1, calls);
  await sleep(10);
});


================================================
FILE: plug-api/lib/memory_cache.ts
================================================
import { LimitedMap } from "./limited_map.ts";

const cache = new LimitedMap<any>(50);

export async function ttlCache<K, V>(
  key: K,
  fn: (key: K) => Promise<V>,
  ttlSecs?: number,
): Promise<V> {
  if (!ttlSecs) {
    return fn(key);
  }
  const serializedKey = JSON.stringify(key);
  const cached = cache.get(serializedKey);
  if (cached) {
    return cached;
  }
  const result = await fn(key);
  cache.set(serializedKey, result, ttlSecs * 1000);
  return result;
}


================================================
FILE: plug-api/lib/native_fetch.ts
================================================
declare global {
  function nativeFetch(
    input: RequestInfo | URL,
    init?: RequestInit,
  ): Promise<Response>;
}


================================================
FILE: plug-api/lib/ref.test.ts
================================================
import {
  decodePageURI,
  encodePageURI,
  encodeRef,
  isValidName,
  isValidPath,
  parseToRef,
} from "./ref.ts";
import { assert, assertEquals } from "@std/assert";

Deno.test("parseToRef() default cases", () => {
  assertEquals(parseToRef("foo"), { path: "foo.md" });
  assertEquals(parseToRef("/foo"), { path: "foo.md" });
  assertEquals(parseToRef("foo/bar"), { path: "foo/bar.md" });
  assertEquals(parseToRef("foo.png"), { path: "foo.png" });
  assertEquals(parseToRef("foo.md"), { path: "foo.md" });
  assertEquals(parseToRef("foo."), { path: "foo..md" });
  assertEquals(parseToRef("foo.."), { path: "foo...md" });
  assertEquals(parseToRef(" .foo"), { path: " .foo" });
  assertEquals(parseToRef("foo[bar"), { path: "foo[bar.md" });
  assertEquals(parseToRef("foo]bar"), { path: "foo]bar.md" });
  assertEquals(parseToRef("foo(bar"), { path: "foo(bar.md" });
  assertEquals(parseToRef("foo)bar"), { path: "foo)bar.md" });
  assertEquals(parseToRef("/bar/.../foo"), { path: "bar/.../foo.md" });

  assertEquals(parseToRef("/foo/.bar.md"), null);
  assertEquals(parseToRef("foo.md.md"), null);
  assertEquals(parseToRef("/.../foo"), null);
  assertEquals(parseToRef("^.foo"), null);
  assertEquals(parseToRef(".foobar"), null);
  assertEquals(parseToRef("foo[[bar"), null);
  assertEquals(parseToRef("foo]]bar"), null);
  assertEquals(parseToRef("foo|bar"), null);
  assertEquals(parseToRef("foo@bar"), null);
  assertEquals(parseToRef("/../foo"), null);
  assertEquals(parseToRef("/./foo"), null);
  assertEquals(parseToRef("/bar/../foo"), null);
  assertEquals(parseToRef("/bar/./foo"), null);

  assertEquals(parseToRef(""), { path: "" });
  assertEquals(parseToRef("/"), { path: "" });
  assertEquals(parseToRef("/.md"), null);
  assertEquals(parseToRef("/.foo"), null);
  assertEquals(parseToRef("/@132"), {
    path: "",
    details: { type: "position", pos: 132 },
  });
});

Deno.test("parseToRef() link cases", () => {
  assertEquals(parseToRef("^foo"), { path: "foo.md", meta: true });

  assertEquals(parseToRef("foo# header"), {
    path: "foo.md",
    details: { type: "header", header: "header" },
  });
  assertEquals(parseToRef("foo# header@123"), {
    path: "foo.md",
    details: { type: "header", header: "header@123" },
  });
  assertEquals(parseToRef("foo@1231"), {
    path: "foo.md",
    details: { type: "position", pos: 1231 },
  });
  assertEquals(parseToRef("foo@l42c69"), {
    path: "foo.md",
    details: { type: "linecolumn", line: 42, column: 69 },
  });
  assertEquals(parseToRef("foo@L42C69"), {
    path: "foo.md",
    details: { type: "linecolumn", line: 42, column: 69 },
  });
  assertEquals(parseToRef("foo@L42"), {
    path: "foo.md",
    details: { type: "linecolumn", line: 42, column: 1 },
  });

  assertEquals(parseToRef("foo@ 123"), null);
  assertEquals(parseToRef("foo@c69"), null);
  assertEquals(parseToRef("foo@123#header"), null);
  assertEquals(parseToRef("foo@123@l29"), null);
});

Deno.test("encodeRef() cases", () => {
  // Encoding
  assertEquals(encodeRef({ path: "foo.md" }), "foo");
  assertEquals(
    encodeRef({ path: "foo.md", details: { type: "position", pos: 10 } }),
    "foo@10",
  );
  assertEquals(
    encodeRef({
      path: "foo.md",
      details: { type: "linecolumn", line: 10, column: 69 },
    }),
    "foo@L10C69",
  );
  assertEquals(
    encodeRef({
      path: "foo.md",
      details: { type: "header", header: "bar" },
    }),
    "foo#bar",
  );
});

Deno.test("isValidPath() and isValidName()", () => {
  assert(isValidPath("foo.md"));
  assert(!isValidPath("foo"));
  assert(!isValidPath("foo.md@123"));

  assert(isValidName("foo"));
  assert(!isValidName("foo.md"));
  assert(!isValidName("foo@123"));
  assert(!isValidName("^foo@123"));
  assert(!isValidName("foo[[bar"));

  // Disallow < and > in ref names
  assert(!isValidName("hello<there"));
  assert(!isValidName("hello>there"));
});

Deno.test("Page URI encoding", () => {
  assertEquals(encodePageURI("foo"), "foo");
  assertEquals(encodePageURI("folder/foo"), "folder/foo");
  assertEquals(encodePageURI("hello there"), "hello%20there");
  assertEquals(encodePageURI("hello?there"), "hello%3Fthere");
  // Now ensure all these cases are reversible
  assertEquals(decodePageURI("foo"), "foo");
  assertEquals(decodePageURI("folder/foo"), "folder/foo");
  assertEquals(decodePageURI("hello%20there"), "hello there");
  assertEquals(decodePageURI("hello%3Fthere"), "hello?there");
});


================================================
FILE: plug-api/lib/ref.ts
================================================
import {
  findNodeMatching,
  findNodeOfType,
  type ParseTree,
  renderToText,
} from "@silverbulletmd/silverbullet/lib/tree";

/**
 * Represents a path with an extension. This is a little cursed, but enforces
 * that people check the path before setting it. For navigation logic the empty
 * path will point to the index page. This could differ for e.g. for the
 * wikilink logic where it points to the currentPage
 */
export type Path = `${string}.${string}` | "";

/**
 * Represents a reference to a page or document, with optional position, anchor and header
 */
export type Ref = {
  path: Path;
  meta?: boolean;

  details?:
    | { type: "position"; pos: number }
    | { type: "linecolumn"; line: number; column: number }
    | { type: "header"; header: string };
};

/**
 * Determines the file extension of a ref. It will only return the last
 * extension, so `foo.tar.gz` resolves to `gz`
 * @returns The file extension WITHOUT the dot
 */
export function getPathExtension(path: Path): string {
  // If the ref links to the the file it's on (i.e. path === ""), it's safe to assume it's a link to a "md" page
  return path !== "" ? path.split(".").pop()!.toLowerCase() : "md";
}

/**
 * Renders a path into a "name". This means it removes the extension for `.md` path
 */
export function getNameFromPath(path: Path): string {
  return encodeRef({ path });
}

/**
 * Determines there a ref points to a markdown file
 */
export function isMarkdownPath(path: Path): boolean {
  return getPathExtension(path) === "md";
}

/**
 * Adds an `md` extension to any path without an extension or a path ending in
 * `.conflicted`, except to the empty path
 * @param path The path to normalize. Cannot contain any position or header
 * addons
 */
function normalizePath(path: string): Path {
  if (path.startsWith("/")) {
    path = path.slice(1);
  }

  if (/.+\.[a-zA-Z0-9]+$/.test(path) || path === "") {
    return path as Path;
  }

  return `${path}.md`;
}

/**
 * Determines wether a name conforms to all the requirments.
 */
export function isValidName(name: string): boolean {
  const ref = parseToRef(name);

  // If the name, parses as a link and doesn't provide any other info we can be
  // sure it was only parsed as a path and that the path then conforms to all
  // the requirements
  return !!ref && !ref.details && !ref.meta && name !== "" &&
    encodeRef(ref) === name;
}

/**
 * Determines wether a path conforms to all the requirments.
 */
export function isValidPath(path: string): path is Path {
  const ref = parseToRef(path);

  return !!ref && ref.path === path;
}

/**
 * ONLY TOUCH THIS IF YOU REALLY KNOW WHAT YOU ARE DOING. THIS REGEX IS INTEGRAL
 * TO THE INNER WORKINGS OF SILVERBULLET AND CHANGES COULD INTRODUCE MAJOR BUGS
 */
const refRegex =
  /^(?<meta>\^)?(?<path>(?!.*\.[a-zA-Z0-9]+\.md$)(?!\/?(\.|\^))(?!.*(?:\/|^)\.{1,2}(?:\/|$)|.*\/{2})(?!.*(?:\]\]|\[\[))[^@#\|<>]*)(@(?<pos>\d+)|@[Ll](?<line>\d+)(?:[Cc](?<col>\d+))?|#\s*(?<header>.*))?$/;

/**
 * Parses a reference string into a ref object.
 * @returns A ref or if the parsing fails null
 */
export function parseToRef(stringRef: string): Ref | null {
  const match = stringRef.match(refRegex);
  if (!match || !match.groups) {
    return null;
  }

  const groups = match.groups;

  const ref: Ref = { path: normalizePath(groups.path) };

  if (groups.meta) {
    ref.meta = true;
  }

  if (groups.pos !== undefined) {
    ref.details = {
      type: "position",
      pos: parseInt(groups.pos),
    };
  } else if (groups.line !== undefined) {
    ref.details = {
      type: "linecolumn",
      line: parseInt(groups.line),
      column: groups.col !== undefined ? parseInt(groups.col) : 1,
    };
  } else if (groups.header !== undefined) {
    ref.details = {
      type: "header",
      header: groups.header,
    };
  }

  return ref;
}

/**
 * The inverse of {@link parseToRef}, encodes a ref object into a reference string.
 * It tries to produce the shortest valid representation
 */
export function encodeRef(ref: Ref): string {
  let stringRef: string = ref.path;

  if (isMarkdownPath(ref.path)) {
    stringRef = stringRef.slice(0, -3);
  }

  if (ref.details?.type === "linecolumn") {
    stringRef += `@L${ref.details.line}`;

    if (ref.details.column !== 1) {
      stringRef += `C${ref.details.column}`;
    }
  } else if (ref.details?.type === "position") {
    stringRef += `@${ref.details.pos}`;
  } else if (ref.details?.type === "header") {
    stringRef += `#${ref.details.header}`;
  }

  return stringRef;
}

/**
 * Uses a parseTree and a ref pointing to a position inside it to determine the
 * offset from the start inside it, using {@link getOffsetFromHeader} and
 * {@link getOffsetFromLineColumn}
 * @param text If provided the parseTree won't be rendered back to text
 * @returns The offset in the file if it's able to determine it, otherwise -1
 */
export function getOffsetFromRef(
  parseTree: ParseTree,
  ref: Ref,
  text?: string,
): number {
  if (!ref.details) {
    return -1;
  }

  switch (ref.details.type) {
    case "position":
      return ref.details.pos;
    case "linecolumn":
      return getOffsetFromLineColumn(
        text ?? renderToText(parseTree),
        ref.details.line,
        ref.details.column,
      );
    case "header": {
      return getOffsetFromHeader(parseTree, ref.details.header);
    }
  }
}

/**
 * Find the header inside a {@link ParseTree} and returns the position the end
 * of the header
 * @param parseTree The parse tree. Can e.g. be generate using
 * `markdown.parseMarkdown`
 * @param header The header, spaces at the start or end are ignored
 * @returns The position of the header inside the document, if it can't be found
 * -1
 */
export function getOffsetFromHeader(
  parseTree: ParseTree,
  header: string,
): number {
  const node = findNodeMatching(
    parseTree,
    (subTree) => {
      if (!subTree.type || !subTree.type.startsWith("ATXHeading")) {
        return false;
      }

      const mark = findNodeOfType(subTree, "HeaderMark");
      if (!mark || mark.from === undefined || mark.to === undefined) {
        return false;
      }

      return renderToText(subTree)
        .slice(mark.to - mark.from)
        .trimStart() === header.trim();
    },
  );

  if (!node) {
    return -1;
  }

  return node.to ?? -1;
}

/**
 * Calculates the character offset from a line and column position. If the
 * position is out of bounds, it does a best-effort job returning a position.
 * @param text The text which is used to determine the offset. Only `\n` are
 * considered line breaks.
 * @param line The line number of the described position. Starts at 1
 * @param column The column number of the described position. Starts at 0
 */
export function getOffsetFromLineColumn(
  text: string,
  line: number,
  column: number,
): number {
  const lines = text.split("\n");

  const linePos = lines
    .slice(0, Math.max(line - 1, 0))
    .map((l) => l.length)
    .reduce((totalLen, len) => totalLen + len, 0);

  const columnPos = Math.max(
    0,
    Math.min(lines[line - 1]?.length ?? 0, column - 1),
  );

  return linePos + columnPos;
}

/**
 * Encodes a page name for use in a URI. Basically does
 * {@link encodeURIComponent}, but puts slashes back in place.
 */
export function encodePageURI(page: string): string {
  return encodeURIComponent(page).replace(/%2F/g, "/");
}

/**
 * Decodes a page name from a URI.
 */
export function decodePageURI(page: string): string {
  return decodeURIComponent(page);
}


================================================
FILE: plug-api/lib/resolve.test.ts
================================================
import { resolveMarkdownLink } from "@silverbulletmd/silverbullet/lib/resolve";
import { assertEquals } from "@std/assert";

Deno.test("Test URL resolver", () => {
  // Absolute paths
  assertEquals(
    resolveMarkdownLink("foo", "/bar"),
    "bar",
  );
  assertEquals(
    resolveMarkdownLink("/foo/bar/baz", "/qux"),
    "qux",
  );
  assertEquals(
    resolveMarkdownLink("foo", "/bar@123#456"),
    "bar@123#456",
  );
  assertEquals(
    resolveMarkdownLink("foo/bar", "/baz.jpg"),
    "baz.jpg",
  );

  // Relative paths
  assertEquals(
    resolveMarkdownLink("bar", "foo"),
    "foo",
  );
  assertEquals(
    resolveMarkdownLink("foo/bar.jpg", "baz"),
    "foo/baz",
  );
  assertEquals(
    resolveMarkdownLink("/foo/bar", "baz"),
    "/foo/baz",
  );
  assertEquals(
    resolveMarkdownLink("foo///bar", "baz"),
    "foo///baz",
  );
  assertEquals(
    resolveMarkdownLink("bar", "../foo/baz"),
    "foo/baz",
  );
  assertEquals(
    resolveMarkdownLink("bar", "../../foo/baz"),
    "foo/baz",
  );
  assertEquals(
    resolveMarkdownLink("bar/qux", "foo/../baz"),
    "bar/foo/../baz",
  );
});


================================================
FILE: plug-api/lib/resolve.ts
================================================
import type { Path } from "@silverbulletmd/silverbullet/lib/ref";

/**
 * Determines wether a url points into the world wide web or to the local SB instance
 */
export function isLocalURL(url: string): boolean {
  return !url.includes("://") &&
    !url.startsWith("mailto:") &&
    !url.startsWith("tel:");
}

/**
 * Extracts the folder name from a page or document name or a path
 */
export function folderName(name: string | Path): string {
  return name.split("/").slice(0, -1).join("/");
}

export function fileName(path: Path): Path;
export function fileName(name: string): string;
export function fileName(name: string | Path): string | Path {
  return name.split("/").pop()!;
}

const builtinPrefixes = [
  "tag:",
  "search:",
];

/**
 * Builtin pages are pages which SB should automatically consider as existing
 */
export function isBuiltinPath(path: Path): boolean {
  return builtinPrefixes.some((prefix) => path.startsWith(prefix));
}

/**
 * Resolves a markdown link url relative to an absolute url. It won't resolve
 * above the base of the absolute path in the file tree. This means excess `..`
 * will be dropped. It will also only resolve leading `..`
 */
export function resolveMarkdownLink(
  absolute: string,
  relative: string,
): string {
  // These are part of the commonmark spec for urls with spaces inbetween.
  if (relative.startsWith("<") && relative.endsWith(">")) {
    relative = relative.slice(1, -1);
  }

  if (relative.startsWith("/")) {
    return relative.slice(1);
  } else {
    const splitAbsolute = absolute
      .split("/")
      .slice(0, -1);
    const splitRelative = relative
      .split("/");

    while (splitRelative && splitRelative[0] === "..") {
      splitAbsolute.pop();
      splitRelative.shift();
    }

    return [...splitAbsolute, ...splitRelative].join("/") as Path;
  }
}

/**
 * Turns an absolute path into a relative path, relative to some base directory. USE WITH CAUTION, definitely buggy
 */
export function absoluteToRelativePath(base: string, absolute: string): string {
  // Remove leading /
  base = base.startsWith("/") ? base.slice(1) : base;
  absolute = absolute.startsWith("/") ? absolute.slice(1) : absolute;

  const splitAbsolute = absolute.split("/");
  const splitBase = base.split("/");
  splitBase.pop();

  // TODO: This is definitely not robust
  while (splitBase && splitBase[0] === splitAbsolute[0]) {
    splitBase.shift();
    splitAbsolute.shift();
  }

  splitBase.fill("..");

  return [...splitBase, ...splitAbsolute].join("/");
}


================================================
FILE: plug-api/lib/tags.ts
================================================
/** Extract the name from hashtag text, removing # prefix and <angle brackets> if necessary */
export function extractHashtag(text: string): string {
  if (text[0] !== "#") { // you shouldn't call this function at all
    console.error("extractHashtag called on already clean string", text);
    return text;
  } else if (text[1] === "<") {
    if (text.slice(-1) !== ">") { // this is malformed: #<name but maybe we're trying to autocomplete
      return text.slice(2);
    } else { // this is correct #<name>
      return text.slice(2, -1);
    }
  } else { // this is just #name
    return text.slice(1);
  }
}


================================================
FILE: plug-api/lib/transclusion.ts
================================================
import {
  isLocalURL,
  resolveMarkdownLink,
} from "@silverbulletmd/silverbullet/lib/resolve";
import {
  mdLinkRegex,
  wikiLinkRegex,
} from "../../client/markdown_parser/constants.ts";
import {
  getNameFromPath,
  parseToRef,
} from "@silverbulletmd/silverbullet/lib/ref";

export type LinkType = "wikilink" | "markdownlink";
/**
 * Represents a transclusion
 */
export type Transclusion = {
  url: string;
  alias: string;
  dimension?: ContentDimensions;
  linktype: LinkType;
};
/**
 * Describes the dimensions of a transclusion, if provided through the alias.
 * Can be parsed from the alias using {@link parseDimensionFromAlias}
 */
export type ContentDimensions = {
  width?: number;
  height?: number;
};

/**
 * Parse an alias, possibly containing dimensions into an object
 * @example "alias", "alias|100", "alias|100x200", "100", "100x200"
 */
export function parseDimensionFromAlias(
  text: string,
): { alias: string; dimension?: ContentDimensions } {
  let alias: string;
  let dim: ContentDimensions | undefined;
  if (text.includes("|")) {
    const [aliasPart, dimPart] = text.split("|");
    alias = aliasPart;
    const [width, height] = dimPart.split("x");
    dim = {};
    if (width) {
      dim.width = parseInt(width);
    }
    if (height) {
      dim.height = parseInt(height);
    }
  } else if (/^[x\d]/.test(text)) {
    const [width, height] = text.split("x");
    dim = {};
    if (width) {
      dim.width = parseInt(width);
    }
    if (height) {
      dim.height = parseInt(height);
    }
    alias = "";
  } else {
    alias = text;
  }

  return { alias, dimension: dim };
}

/**
 * Parses a transclusion of the type `![[]]` or `![]()`
 * @param text
 */
export function parseTransclusion(
  text: string,
): Transclusion | null {
  let url, alias = undefined;
  let linktype: LinkType = "markdownlink";
  // TODO: Take in the tree and use tree nodes to get url and alias (Applies to all regex uses)
  mdLinkRegex.lastIndex = 0;
  wikiLinkRegex.lastIndex = 0;
  let match: RegExpMatchArray | null = null;
  if ((match = mdLinkRegex.exec(text)) && match.groups) {
    ({ url, title: alias } = match.groups);

    if (isLocalURL(url)) {
      url = resolveMarkdownLink(
        client.currentName(),
        decodeURI(url),
      );
    }
    linktype = "markdownlink";
  } else if ((match = wikiLinkRegex.exec(text)) && match.groups) {
    ({ stringRef: url, alias } = match.groups);
    linktype = "wikilink";
  } else {
    // We found no match
    return null;
  }

  let dimension: ContentDimensions | undefined;
  if (alias) {
    ({ alias, dimension: dimension } = parseDimensionFromAlias(alias));
  } else {
    alias = "";
  }

  return {
    url,
    alias,
    dimension,
    linktype,
  };
}

export function nameFromTransclusion(t: Transclusion): string {
  const ref = parseToRef(t.url);
  if (!ref) {
    throw new Error(`Cannot extract name from transclusion: ${t.url}`);
  }
  return getNameFromPath(ref.path);
}


================================================
FILE: plug-api/lib/tree.test.ts
================================================
// import { parse } from "./parse_tree.ts";
import {
  addParentPointers,
  collectNodesMatching,
  findParentMatching,
  nodeAtPos,
  removeParentPointers,
  renderToText,
  replaceNodesMatching,
} from "./tree.ts";
import { assertEquals, assertNotEquals } from "@std/assert";
import { parse } from "../../client/markdown_parser/parse_tree.ts";
import { extendedMarkdownLanguage } from "../../client/markdown_parser/parser.ts";

const mdTest1 = `
# Heading
## Sub _heading_ cool

Hello, this is some **bold** text and *italic*. And [a link](http://zef.me).

%% My comment here
%% And second line

And an @mention

http://zef.plus

- This is a list [[PageLink]]
- With another item
- TODOs:
  - [ ] A task that's not yet done
  - [x] Hello
- And a _third_ one [[Wiki Page]] yo
`;

const mdTest3 = `
\`\`\`yaml
name: something
\`\`\`
`;

Deno.test("Test parsing", () => {
  const mdTree = parse(extendedMarkdownLanguage, mdTest1);
  addParentPointers(mdTree);
  // console.log(JSON.stringify(mdTree, null, 2));
  const wikiLink = nodeAtPos(mdTree, mdTest1.indexOf("Wiki Page"))!;
  assertEquals(wikiLink.type, "WikiLinkPage");
  assertNotEquals(
    findParentMatching(wikiLink, (n) => n.type === "BulletList"),
    null,
  );

  const allTodos = collectNodesMatching(mdTree, (n) => n.type === "Task");
  assertEquals(allTodos.length, 2);

  // Render back into markdown should be equivalent
  assertEquals(renderToText(mdTree), mdTest1);

  removeParentPointers(mdTree);
  replaceNodesMatching(mdTree, (n) => {
    if (n.type === "Task") {
      return {
        type: "Tosk",
      };
    }
  });
  // console.log(JSON.stringify(mdTree, null, 2));
  parse(extendedMarkdownLanguage, mdTest3);
  // console.log(JSON.stringify(mdTree3, null, 2));
});


================================================
FILE: plug-api/lib/tree.ts
================================================
import { deepClone } from "@silverbulletmd/silverbullet/lib/json";

export type ParseTree = {
  type?: string; // undefined === text node
  from?: number;
  to?: number;
  text?: string;
  children?: ParseTree[];
  // Only present after running addParentPointers
  parent?: ParseTree;
};

export function addParentPointers(tree: ParseTree) {
  if (!tree.children) {
    return;
  }
  for (const child of tree.children) {
    child.parent = tree;
    addParentPointers(child);
  }
}

export function removeParentPointers(tree: ParseTree) {
  delete tree.parent;
  if (!tree.children) {
    return;
  }
  for (const child of tree.children) {
    removeParentPointers(child);
  }
}

export function findParentMatching(
  tree: ParseTree,
  matchFn: (tree: ParseTree) => boolean,
): ParseTree | null {
  let node = tree.parent;
  while (node) {
    if (matchFn(node)) {
      return node;
    }
    node = node.parent;
  }
  return null;
}

export function collectNodesOfType(
  tree: ParseTree,
  nodeType: string,
): ParseTree[] {
  return collectNodesMatching(tree, (n) => n.type === nodeType);
}

export function collectNodesMatching(
  tree: ParseTree,
  matchFn: (tree: ParseTree) => boolean,
): ParseTree[] {
  if (matchFn(tree)) {
    return [tree];
  }
  let results: ParseTree[] = [];
  if (tree.children) {
    for (const child of tree.children) {
      results = [...results, ...collectNodesMatching(child, matchFn)];
    }
  }
  return results;
}

export async function collectNodesMatchingAsync(
  tree: ParseTree,
  matchFn: (tree: ParseTree) => Promise<boolean>,
): Promise<ParseTree[]> {
  if (await matchFn(tree)) {
    return [tree];
  }
  let results: ParseTree[] = [];
  if (tree.children) {
    for (const child of tree.children) {
      results = [
        ...results,
        ...await collectNodesMatchingAsync(child, matchFn),
      ];
    }
  }
  return results;
}

// return value: returning undefined = not matched, continue, null = delete, new node = replace
export function replaceNodesMatching(
  tree: ParseTree,
  substituteFn: (tree: ParseTree) => ParseTree | null | undefined,
) {
  if (tree && tree.children) {
    const children = tree.children.slice();
    for (const child of children) {
      const subst = substituteFn(child);
      if (subst !== undefined) {
        const pos = tree.children.indexOf(child);
        if (subst) {
          tree.children.splice(pos, 1, subst);
        } else {
          // null = delete
          tree.children.splice(pos, 1);
        }
      } else {
        replaceNodesMatching(child, substituteFn);
      }
    }
  }
}

export async function replaceNodesMatchingAsync(
  tree: ParseTree,
  substituteFn: (tree: ParseTree) => Promise<ParseTree | null | undefined>,
) {
  if (tree.children) {
    const children = tree.children.slice();
    for (const child of children) {
      const subst = await substituteFn(child);
      if (subst !== undefined) {
        const pos = tree.children.indexOf(child);
        if (subst) {
          tree.children.splice(pos, 1, subst);
        } else {
          // null = delete
          tree.children.splice(pos, 1);
        }
      } else {
        await replaceNodesMatchingAsync(child, substituteFn);
      }
    }
  }
}

export function findNodeMatching(
  tree: ParseTree,
  matchFn: (tree: ParseTree) => boolean,
): ParseTree | null {
  return collectNodesMatching(tree, matchFn)[0];
}

export function findNodeOfType(
  tree: ParseTree,
  nodeType: string,
): ParseTree | null {
  return collectNodesMatching(tree, (n) => n.type === nodeType)[0];
}

export function traverseTree(
  tree: ParseTree,
  // Return value = should stop traversal?
  matchFn: (tree: ParseTree) => boolean,
): void {
  // Do a collect, but ignore the result
  collectNodesMatching(tree, matchFn);
}

export async function traverseTreeAsync(
  tree: ParseTree,
  // Return value = should stop traversal?
  matchFn: (tree: ParseTree) => Promise<boolean>,
): Promise<void> {
  // Do a collect, but ignore the result
  await collectNodesMatchingAsync(tree, matchFn);
}

export function cloneTree(tree: ParseTree): ParseTree {
  return deepClone(tree, ["parent"]);
}

// Finds non-text node at position
export function nodeAtPos(tree: ParseTree, pos: number): ParseTree | null {
  if (pos < tree.from! || pos >= tree.to!) {
    return null;
  }
  if (!tree.children) {
    return tree;
  }
  for (const child of tree.children) {
    const n = nodeAtPos(child, pos);
    if (n && n.text !== undefined) {
      // Got a text node, let's return its parent
      return tree;
    } else if (n) {
      // Got it
      return n;
    }
  }
  return null;
}

// Turn ParseTree back into text
export function renderToText(tree?: ParseTree): string {
  if (!tree) {
    return "";
  }
  const pieces: string[] = [];
  if (tree.text !== undefined) {
    return tree.text;
  }
  for (const child of tree.children!) {
    pieces.push(renderToText(child));
  }
  return pieces.join("");
}

export function cleanTree(tree: ParseTree, omitTrimmable = true): ParseTree {
  if (tree.type === "⚠") {
    throw new Error(
      `Parse error at pos ${tree.from}`,
    );
  }
  if (tree.text !== undefined) {
    return tree;
  }
  const ast: ParseTree = {
    type: tree.type,
    children: [],
    from: tree.from,
    to: tree.to,
  };
  for (const node of tree.children!) {
    if (node.type && node.type !== "Comment") {
      ast.children!.push(cleanTree(node, omitTrimmable));
    }
    if (node.text && (omitTrimmable && node.text.trim() || !omitTrimmable)) {
      ast.children!.push(node);
    }
  }
  return ast;
}


================================================
FILE: plug-api/lib/yaml.test.ts
================================================
import { assertEquals, assertExists } from "@std/assert";
import { applyPatches, type YamlPatch } from "./yaml.ts";

Deno.test("YAML patching - basic operations and value types", () => {
  const initialYaml = `
name: my-package
version: 1.0.0
description: A sample package
`;

  const patches: YamlPatch[] = [
    { op: "set-key", path: "version", value: "1.1.0" },
    { op: "set-key", path: "description", value: "Updated description" },
    { op: "set-key", path: "empty", value: "" },
    { op: "set-key", path: "special", value: "value:with:colons" },
    { op: "set-key", path: "null", value: null },
    { op: "set-key", path: "emptyList", value: [] },
    { op: "set-key", path: "emptyObject", value: {} },
  ];

  const result = applyPatches(initialYaml, patches);

  assertExists(result);
  // Basic scalar updates
  assertEquals(result.includes("version: 1.1.0"), true);
  assertEquals(result.includes("description: Updated description"), true);
  assertEquals(result.includes("name: my-package"), true);

  // Special values and characters
  assertEquals(result.includes('empty: ""'), true);
  assertEquals(result.includes('special: "value:with:colons"'), true);
  assertEquals(result.includes("null: null"), true);

  // Empty collections
  assertEquals(result.includes("emptyList: []"), true);
  assertEquals(result.includes("emptyObject: {}"), true);
});

Deno.test("YAML patching - collections and nested structures", () => {
  const initialYaml = `
name: my-package
tags:
  - node
  - typescript
`;

  const patches: YamlPatch[] = [
    { op: "set-key", path: "tags", value: ["deno", "typescript", "yaml"] },
    { op: "set-key", path: "dependencies", value: ["std", "testing"] },
    {
      op: "set-key",
      path: "config",
      value: {
        port: 3000,
        host: "localhost",
        settings: {
          debug: true,
          timeout: 5000,
        },
      },
    },
  ];

  const result = applyPatches(initialYaml, patches);

  assertExists(result);
  // Array values
  assertEquals(result.includes("tags:"), true);
  assertEquals(result.includes("- deno"), true);
  assertEquals(result.includes("- typescript"), true);
  assertEquals(result.includes("- yaml"), true);
  assertEquals(result.includes("dependencies:"), true);
  assertEquals(result.includes("- std"), true);
  assertEquals(result.includes("- testing"), true);

  // Nested object values
  assertEquals(result.includes("config:"), true);
  assertEquals(result.includes("  port: 3000"), true);
  assertEquals(result.includes("  host: localhost"), true);
  assertEquals(result.includes("    debug: true"), true);
  assertEquals(result.includes("    timeout: 5000"), true);
});

Deno.test("YAML patching - comments and formatting preservation", () => {
  const initialYaml = `
# Main package configuration
name: my-package  # The package name
version: 1.0.0    # Current version
description: A sample package

# Additional metadata
author: John Doe  # Package maintainer
license: MIT      # License type

`;

  const patches: YamlPatch[] = [
    { op: "set-key", path: "version", value: "2.0.0" },
    { op: "set-key", path: "description", value: "An updated package" },
    { op: "set-key", path: "newProp", value: "new value" },
  ];

  const result = applyPatches(initialYaml, patches);

  // Verify all comments are preserved
  assertEquals(result.includes("# Main package configuration"), true);
  assertEquals(result.includes("# The package name"), true);
  assertEquals(result.includes("# Current version"), true);
  assertEquals(result.includes("# Additional metadata"), true);
  assertEquals(result.includes("# Package maintainer"), true);
  assertEquals(result.includes("# License type"), true);

  // Verify content is correct and in order
  assertEquals(result.includes("version: 2.0.0"), true);
  assertEquals(result.includes("description: An updated package"), true);
  assertEquals(result.includes("newProp: new value"), true);

  // Verify trailing newlines are preserved
  assertEquals(result.endsWith("\n"), true);
  const lines = result.split("\n");
  assertEquals(lines[lines.length - 1], "");
});

Deno.test("YAML patching - edge cases", () => {
  // Test empty input
  const emptyResult = applyPatches("", [
    { op: "set-key", path: "name", value: "new-package" },
  ]);
  assertEquals(emptyResult.trim(), "name: new-package");

  // Test single line with trailing newline
  const singleLineResult = applyPatches("key: value\n", [
    { op: "set-key", path: "key", value: "new value" },
  ]);
  assertEquals(singleLineResult, "key: new value\n");

  // Test multiple trailing newlines
  const multiNewlineResult = applyPatches("key: value\n\n\n", [
    { op: "set-key", path: "key", value: "new value" },
  ]);
  assertEquals(multiNewlineResult, "key: new value\n");
});

Deno.test("YAML patching - delete key basic operations", () => {
  const initialYaml = `
name: my-package
version: 1.0.0
description: A sample package
author: John Doe
license: MIT
`;

  const patches: YamlPatch[] = [
    { op: "delete-key", path: "version" },
    { op: "delete-key", path: "author" },
  ];

  const result = applyPatches(initialYaml, patches);

  assertExists(result);
  // Verify deleted keys are gone
  assertEquals(result.includes("version:"), false);
  assertEquals(result.includes("author:"), false);

  // Verify remaining keys are still present
  assertEquals(result.includes("name: my-package"), true);
  assertEquals(result.includes("description: A sample package"), true);
  assertEquals(result.includes("license: MIT"), true);
});

Deno.test("YAML patching - delete key with comments", () => {
  const initialYaml = `
# Package configuration
name: my-package

# Version number
version: 1.0.0  # Current version

# Description
description: A sample package
author: John Doe  # Main author
`;

  const patches: YamlPatch[] = [
    { op: "delete-key", path: "version" },
  ];

  const result = applyPatches(initialYaml, patches);

  // Verify the key and its comments are removed
  assertEquals(result.includes("version:"), false);
  assertEquals(result.includes("# Version number"), false);
  assertEquals(result.includes("# Current version"), false);

  // Verify other content remains
  assertEquals(result.includes("# Package configuration"), true);
  assertEquals(result.includes("name: my-package"), true);
  assertEquals(result.includes("# Description"), true);
  assertEquals(result.includes("description: A sample package"), true);
  assertEquals(result.includes("author: John Doe"), true);
  assertEquals(result.includes("# Main author"), true);
});

Deno.test("YAML patching - delete key with list values", () => {
  const initialYaml = `
name: my-package
tags:
  - node
  - typescript
  - yaml
dependencies:
  - std
  - testing
author: John Doe
`;

  const patches: YamlPatch[] = [
    { op: "delete-key", path: "tags" },
  ];

  const result = applyPatches(initialYaml, patches);

  // Verify the key and its list values are removed
  assertEquals(result.includes("tags:"), false);
  assertEquals(result.includes("- node"), false);
  assertEquals(result.includes("- typescript"), false);
  assertEquals(result.includes("- yaml"), false);

  // Verify other content remains
  assertEquals(result.includes("name: my-package"), true);
  assertEquals(result.includes("dependencies:"), true);
  assertEquals(result.includes("- std"), true);
  assertEquals(result.includes("- testing"), true);
  assertEquals(result.includes("author: John Doe"), true);
});

Deno.test("YAML patching - delete key with nested object values", () => {
  const initialYaml = `
name: my-package
config:
  port: 3000
  host: localhost
  settings:
    debug: true
    timeout: 5000
author: John Doe
`;

  const patches: YamlPatch[] = [
    { op: "delete-key", path: "config" },
  ];

  const result = applyPatches(initialYaml, patches);

  // Verify the key and its nested content are removed
  assertEquals(result.includes("config:"), false);
  assertEquals(result.includes("port: 3000"), false);
  assertEquals(result.includes("host: localhost"), false);
  assertEquals(result.includes("debug: true"), false);
  assertEquals(result.includes("timeout: 5000"), false);

  // Verify other content remains
  assertEquals(result.includes("name: my-package"), true);
  assertEquals(result.includes("author: John Doe"), true);
});

Deno.test("YAML patching - delete non-existent key", () => {
  const initialYaml = `
name: my-package
version: 1.0.0
`;

  const patches: YamlPatch[] = [
    { op: "delete-key", path: "nonexistent" },
  ];

  const result = applyPatches(initialYaml, patches);

  // Verify nothing changes when deleting a non-existent key
  assertEquals(result.includes("name: my-package"), true);
  assertEquals(result.includes("version: 1.0.0"), true);
  assertEquals(result, initialYaml);
});

Deno.test("YAML patching - mixed set and delete operations", () => {
  const initialYaml = `
name: my-package
version: 1.0.0
description: A sample package
author: John Doe
license: MIT
`;

  const patches: YamlPatch[] = [
    { op: "set-key", path: "version", value: "2.0.0" },
    { op: "delete-key", path: "author" },
    { op: "set-key", path: "maintainer", value: "Jane Smith" },
    { op: "delete-key", path: "description" },
  ];

  const result = applyPatches(initialYaml, patches);

  // Verify deletions
  assertEquals(result.includes("author:"), false);
  assertEquals(result.includes("description:"), false);

  // Verify updates and additions
  assertEquals(result.includes("version: 2.0.0"), true);
  assertEquals(result.includes("maintainer: Jane Smith"), true);

  // Verify unchanged content
  assertEquals(result.includes("name: my-package"), true);
  assertEquals(result.includes("license: MIT"), true);
});

Deno.test("YAML patching - delete all keys", () => {
  const initialYaml = `
name: my-package
version: 1.0.0
`;

  const patches: YamlPatch[] = [
    { op: "delete-key", path: "name" },
    { op: "delete-key", path: "version" },
  ];

  const result = applyPatches(initialYaml, patches);

  // Result should be effectively empty (just newlines)
  assertEquals(result.trim(), "");
});

Deno.test("YAML patching - delete key preserves surrounding structure", () => {
  const initialYaml = `
# Header comment
name: my-package
version: 1.0.0
description: A sample package

# Footer comment
license: MIT
`;

  const patches: YamlPatch[] = [
    { op: "delete-key", path: "version" },
  ];

  const result = applyPatches(initialYaml, patches);

  // Verify structure is maintained
  assertEquals(result.includes("# Header comment"), true);
  assertEquals(result.includes("name: my-package"), true);
  assertEquals(result.includes("description: A sample package"), true);
  assertEquals(result.includes("# Footer comment"), true);
  assertEquals(result.includes("license: MIT"), true);

  // Verify deleted key is gone
  assertEquals(result.includes("version:"), false);
});


================================================
FILE: plug-api/lib/yaml.ts
================================================
// Define the patch structure
export interface YamlPatch {
  op: "set-key" | "delete-key";
  path: string; // Still assuming simple, top-level key names
  value?: any; // Required for set-key, not used for delete-key
}

// Helper function specifically for serializing scalar types
function serializeToYamlScalar(
  value: string | number | boolean | null,
): string {
  if (typeof value === "string") {
    // Always quote empty strings and strings with special characters
    if (
      value === "" || // Empty string
      value.match(/[:{#}[],&*!|>'"%@`]/) || // Special YAML characters (added % and ")
      /^\d+(\.\d+)?([eE][+-]?\d+)?$/.test(value) || // Looks like a number
      value.includes(":") || // Contains colons
      ["true", "false", "null", "yes", "no", "on", "off"].includes(
        value.toLowerCase(),
      )
    ) {
      return JSON.stringify(value);
    }
    // Simple strings without special chars/meaning don't need quotes
    return value;
  } else if (
    typeof value === "number" || typeof value === "boolean" || value === null
  ) {
    return String(value);
  }
  // Default for unsupported scalar types (e.g., undefined)
  return "null";
}

// Updated helper function to serialize various JS types to YAML string representations
// Added baseIndentation parameter for handling nested lists correctly (though we only use it for top-level lists here)
function serializeToYamlValue(
  value: any,
  baseIndentation: string = "",
): string {
  if (Array.isArray(value)) {
    if (value.length === 0) {
      return "[]"; // Use flow style for empty arrays for simplicity
    }
    // Determine indentation for list items (base + 2 spaces)
    const itemIndentation = baseIndentation + "  ";
    // Format each item recursively, preceded by '- ' marker
    return "\n" +
      value.map((item) =>
        `${itemIndentation}- ${serializeToYamlValue(item, itemIndentation)}`
      ).join("\n");
    // Note: serializeToYamlValue is used recursively here to handle nested arrays/objects if needed in future
    // However, the current `applyMinimalSetKeyPatches` only handles top-level keys.
  } else if (typeof value === "object" && value !== null) {
    // Basic object serialization (not requested, but good to consider)
    // This is highly simplified and doesn't handle nesting well without more context
    const itemIndentation = baseIndentation + "  ";
    const entries = Object.entries(value);
    if (entries.length === 0) return "{}"; // Flow style empty objects
    return "\n" +
      entries.map(([key, val]) =>
        `${itemIndentation}${key}: ${
          serializeToYamlValue(val, itemIndentation)
        }`
      ).join("\n");
  } else {
    // Handle scalars using the dedicated function
    return serializeToYamlScalar(value);
  }
}

export function applyPatches(
  yamlString: string,
  patches: YamlPatch[],
): string {
  let currentYaml = yamlString;

  for (const patch of patches) {
    if (patch.op === "delete-key") {
      // Handle delete operation
      const key = patch.path;
      const lines = currentYaml.split("\n");
      let keyLineIndex = -1;
      let startDeleteIndex = -1;
      let endDeleteIndex = -1;

      // Find the key line
      for (let i = 0; i < lines.length; i++) {
        const line = lines[i];
        const trimmedLine = line.trim();
        if (trimmedLine.startsWith(key + ":")) {
          keyLineIndex = i;
          startDeleteIndex = i;
          endDeleteIndex = i;

          // Look backwards for preceding comments (delete them too)
          for (let j = i - 1; j >= 0; j--) {
            const prevLine = lines[j].trim();
            if (prevLine.startsWith("#")) {
              startDeleteIndex = j;
            } else if (prevLine !== "") {
              break;
            } else {
              // Empty line - include it in deletion if followed by comments
              if (startDeleteIndex < i) {
                startDeleteIndex = j;
              } else {
                break;
              }
            }
          }

          // Determine the indentation of the key
          const keyIndent = lines[i].match(/^(\s*)/)?.[1] || "";

          // Look forwards to find all content belonging to this key
          // This includes list items and nested objects
          for (let j = i + 1; j < lines.length; j++) {
            const nextLine = lines[j];
            const trimmedNextLine = nextLine.trim();

            // Empty line - continue looking
            if (trimmedNextLine === "") {
              continue;
            }

            // Comment at same or greater indentation - might be trailing comment
            if (trimmedNextLine.startsWith("#")) {
              const commentIndent = nextLine.match(/^(\s*)/)?.[1] || "";
              if (commentIndent.length > keyIndent.length) {
                endDeleteIndex = j;
                continue;
              } else {
                // Comment at same or less indentation - not part of this key
                break;
              }
            }

            // Check indentation of next non-empty, non-comment line
            const nextIndent = nextLine.match(/^(\s*)/)?.[1] || "";
            if (nextIndent.length > keyIndent.length) {
              // This line is indented more than the key, so it belongs to the key
              endDeleteIndex = j;
            } else {
              // This line is at same or less indentation, so it's a new key
              break;
            }
          }

          break;
        }
      }

      if (keyLineIndex !== -1) {
        // Delete the lines from startDeleteIndex to endDeleteIndex (inclusive)
        const beforeDelete = lines.slice(0, startDeleteIndex);
        const afterDelete = lines.slice(endDeleteIndex + 1);

        // Clean up excessive empty lines at the boundary
        // Remove trailing empty lines from beforeDelete
        while (
          beforeDelete.length > 0 &&
          beforeDelete[beforeDelete.length - 1].trim() === ""
        ) {
          beforeDelete.pop();
        }

        // Remove leading empty lines from afterDelete (but keep one if there's content after)
        let leadingEmptyCount = 0;
        for (const line of afterDelete) {
          if (line.trim() === "") {
            leadingEmptyCount++;
          } else {
            break;
          }
        }
        const cleanedAfterDelete = afterDelete.slice(
          Math.min(leadingEmptyCount, 1),
        );

        currentYaml = [...beforeDelete, ...cleanedAfterDelete].join("\n");
        if (currentYaml && !currentYaml.endsWith("\n")) {
          currentYaml += "\n";
        }
      }
      // If key not found, do nothing
      continue;
    }

    if (patch.op !== "set-key") continue;

    const key = patch.path;

    // Split the YAML into lines for easier processing
    const lines = currentYaml.split("\n");
    let keyLineIndex = -1;
    let commentBlock = "";
    let trailingComments = "";
    let inlineComment = "";

    // Find the key line and collect comments
    for (let i = 0; i < lines.length; i++) {
      const line = lines[i];
      const trimmedLine = line.trim();
      if (trimmedLine.startsWith(key + ":")) {
        keyLineIndex = i;
        // Extract inline comment if present
        const commentMatch = line.match(/#.*$/);
        if (commentMatch) {
          inlineComment = commentMatch[0];
        }
        // Look backwards for comments
        for (let j = i - 1; j >= 0; j--) {
          const prevLine = lines[j].trim();
          if (prevLine.startsWith("#")) {
            commentBlock = lines[j] + "\n" + commentBlock;
          } else if (prevLine !== "") {
            break;
          }
        }
        // Look forwards for comments
        for (let j = i + 1; j < lines.length; j++) {
          const nextLine = lines[j].trim();
          if (nextLine.startsWith("#")) {
            trailingComments += lines[j] + "\n";
          } else if (nextLine !== "") {
            break;
          }
        }
        break;
      }
    }

    // Serialize the new value
    const serializedNewValue = serializeToYamlValue(patch.value);

    // Create the replacement line
    let replacementLine: string;
    if (serializedNewValue.startsWith("\n")) {
      // For lists/objects, the key line ends with just ':'
      replacementLine = `${key}:${inlineComment}${serializedNewValue}`;
    } else {
      // For scalars, format as key: value
      replacementLine = `${key}: ${serializedNewValue}${
        inlineComment ? " " + inlineComment : ""
      }`;
    }

    if (keyLineIndex !== -1) {
      // Replace the existing line while preserving comments
      const beforeKey = lines.slice(
        0,
        keyLineIndex - commentBlock.split("\n").filter(Boolean).length,
      );
      const afterKey = lines.slice(
        keyLineIndex + 1 + trailingComments.split("\n").filter(Boolean).length,
      );

      // Build the new content
      const newContent = [
        ...beforeKey,
        ...commentBlock.split("\n").filter(Boolean),
        replacementLine,
        ...trailingComments.split("\n").filter(Boolean),
        ...afterKey,
      ];

      // Join lines and ensure proper newlines
      currentYaml = newContent.join("\n").replace(/\n*$/, "\n") + "\n";
    } else {
      // Key not found: Add the new key-value pair to the end
      const newLineBlock = replacementLine;
      if (currentYaml.trim() === "") {
        currentYaml = newLineBlock + "\n";
      } else {
        currentYaml = currentYaml.replace(/\n*$/, "\n") + newLineBlock + "\n";
      }
    }
  }

  // Ensure the result ends with a newline
  return currentYaml.replace(/\n*$/, "\n");
}


================================================
FILE: plug-api/syscall.ts
================================================
// declare global {
//   function syscall(name: string, ...args: any[]): Promise<any>;
// }

// This is the case when running tests only, so giving it a dummy syscall function
if (typeof self === "undefined") {
  (self as any) = {
    syscall: () => {
      throw new Error("Not implemented here");
    },
  };
}

// Late binding syscall
export function syscall(name: string, ...args: any[]): Promise<any> {
  return (globalThis as any).syscall(name, ...args);
}


================================================
FILE: plug-api/syscalls.ts
================================================
export * as editor from "./syscalls/editor.ts";
export * as markdown from "./syscalls/markdown.ts";
export * as space from "./syscalls/space.ts";
export * as system from "./syscalls/system.ts";
export * as clientStore from "./syscalls/client_store.ts";
export * as sync from "./syscalls/sync.ts";
export * as language from "./syscalls/language.ts";
export * as codeWidget from "./syscalls/code_widget.ts";
export * as asset from "./syscalls/asset.ts";
export * as events from "./syscalls/event.ts";
export * as shell from "./syscalls/shell.ts";
export * as mq from "./syscalls/mq.ts";
export * as datastore from "./syscalls/datastore.ts";
export * as jsonschema from "./syscalls/jsonschema.ts";
export * as lua from "./syscalls/lua.ts";
export * as config from "./syscalls/config.ts";

// Not technically syscalls, but we want to export them for convenience
export * as index from "./syscalls/index.ts";

export * from "./syscall.ts";


================================================
FILE: plug-api/syscalls/asset.ts
================================================
import { base64DecodeDataUrl } from "../lib/crypto.ts";
import { syscall } from "../syscall.ts";

import type { FileMeta } from "../../plug-api/types/index.ts";

/**
 * Reads an asset embedded in a plug (via the `assets` field in the plug manifest).
 * @param plugName name of the plug to read asset from
 * @param name name of the asset to read
 * @param encoding either "utf8" or "dataurl"
 * @returns the content of the asset in the requested encoding
 */
export async function readAsset(
  plugName: string,
  name: string,
  encoding: "utf8" | "dataurl" = "utf8",
): Promise<string> {
  const dataUrl = await syscall("asset.readAsset", plugName, name) as string;
  switch (encoding) {
    case "utf8":
      return new TextDecoder().decode(base64DecodeDataUrl(dataUrl));
    case "dataurl":
      return dataUrl;
  }
}

export async function listFiles(plugName: string): Promise<FileMeta[]> {
  return await syscall("asset.listFiles", plugName);
}

export async function getFileMeta(
  plugName: string,
  name: string,
): Promise<FileMeta> {
  return await syscall("asset.getFileMeta", plugName, name);
}


================================================
FILE: plug-api/syscalls/client_store.ts
================================================
import { syscall } from "../syscall.ts";

/**
 * Implements a very simple (string) key value store for the client.
 * Generally should only be used to set some client-specific states, such as preferences.
 * @module
 */

/**
 * Sets a value in the client store.
 * @param key the key to set
 * @param value the value to set
 */
export function set(key: string, value: any): Promise<void> {
  return syscall("clientStore.set", key, value);
}

/**
 * Gets a value from the client store.
 * @param key the key to get
 * @returns the value associated with the key
 */
export function get(key: string): Promise<any> {
  return syscall("clientStore.get", key);
}

/**
 * Deletes a value from the client store.
 * @param key the key to delete
 */
export function del(key: string): Promise<void> {
  return syscall("clientStore.delete", key);
}


================================================
FILE: plug-api/syscalls/code_widget.ts
================================================
import { syscall } from "../syscall.ts";

/**
 * Refreshes all code widgets on the page that support it.
 */
export function refreshAll(): Promise<void> {
  return syscall("codeWidget.refreshAll");
}


================================================
FILE: plug-api/syscalls/config.ts
================================================
import { syscall } from "../syscall.ts";

/**
 * Gets a config value by path, with support for dot notation.
 * @param path The path to get the value from
 * @param defaultValue The default value to return if the path doesn't exist
 * @returns The value at the path, or the default value
 */
export function get<T>(path: string | string[], defaultValue: T): Promise<T> {
  return syscall("config.get", path, defaultValue);
}

/**
 * Sets a config value by path, with support for dot notation.
 * @param path The path to set the value at
 * @param value The value to set
 */
export function set<T>(path: string, value: T): Promise<void>;
/**
 * Sets multiple config values at once.
 * @param values An object containing key-value pairs to set
 */
export function set(values: Record<string, any>): Promise<void>;
export function set<T>(
  pathOrValues: string | Record<string, any>,
  value?: T,
): Promise<void> {
  return syscall("config.set", pathOrValues, value);
}

/**
 * Inserts a config value into an array
 */
export function insert<T>(
  path: string | string[],
  value: T,
): Promise<void> {
  return syscall("config.insert", path, value);
}

/**
 * Checks if a config path exists.
 * @param path The path to check
 * @returns True if the path exists, false otherwise
 */
export function has(path: string): Promise<boolean> {
  return syscall("config.has", path);
}

/**
 * Defines a JSON schema for a configuration key.
 * The schema will be used to validate values when setting this key.
 * @param key The configuration key to define a schema for
 * @param schema The JSON schema to validate against
 */
export function define(key: string, schema: any): Promise<void> {
  return syscall("config.define", key, schema);
}


================================================
FILE: plug-api/syscalls/datastore.ts
================================================
import type { LuaCollectionQuery } from "../../client/space_lua/query_collection.ts";
import { syscall } from "../syscall.ts";

import type { KV, KvKey, KvQuery } from "../../plug-api/types/datastore.ts";

/**
 * Exposes a key value story with query capabilities.
 * @module
 */

/**
 * Sets a value in the key value store.
 * @param key the key to set
 * @param value the value to set
 */
export function set(key: KvKey, value: any): Promise<void> {
  return syscall("datastore.set", key, value);
}

/**
 * Sets multiple values in the key value store.
 * @param kvs the key value pairs to set
 */
export function batchSet(kvs: KV[]): Promise<void> {
  return syscall("datastore.batchSet", kvs);
}

/**
 * Gets a value from the key value store.
 * @param key the key to get
 * @returns the value associated with the key (or undefined if not found)
 */
export function get(key: KvKey): Promise<any | undefined> {
  return syscall("datastore.get", key);
}

/**
 * Gets multiple values from the key value store.
 * @param keys the keys to get
 * @returns the values associated with the keys (or undefined if not found)
 */
export function batchGet(keys: KvKey[]): Promise<(any | undefined)[]> {
  return syscall("datastore.batchGet", keys);
}

/**
 * Deletes a value from the key value store.
 * @param key the key to delete
 */
export function del(key: KvKey): Promise<void> {
  return syscall("datastore.delete", key);
}

/**
 * Deletes multiple values from the key value store.
 * @param keys the keys to delete
 */
export function batchDel(keys: KvKey[]): Promise<void> {
  return syscall("datastore.batchDelete", keys);
}

export function query(options: KvQuery): Promise<KV[]> {
  return syscall("datastore.query", options);
}

export function queryLua(
  prefix: string[],
  query: LuaCollectionQuery,
  scopeVariables?: Record<string, any>,
): Promise<any[]> {
  return syscall("datastore.queryLua", prefix, query, scopeVariables);
}

export function batchDeletePrefix(prefix: KvKey): Promise<void> {
  return syscall("datastore.batchDeletePrefix", prefix);
}

/**
 * Queries the key value store and deletes all matching items
 * @param query the query to run
 * @param variables the variables that can be referenced inside the query
 */
export function queryDelete(
  query: KvQuery,
  variables?: Record<string, any>,
): Promise<void> {
  return syscall("datastore.queryDelete", query, variables);
}

/**
 * Lists all functions currently defined and available for use in queries
 * @returns the names of all functions in the key value store
 */
export function listFunctions(): Promise<string[]> {
  return syscall("datastore.listFunctions");
}


================================================
FILE: plug-api/syscalls/editor.ts
================================================
import { syscall } from "../syscall.ts";
import type { FilterOption, UploadFile } from "../../plug-api/types/client.ts";
import type { Path, Ref } from "../lib/ref.ts";
import type { PageMeta } from "../../plug-api/types/index.ts";

/**
 * Exposes various editor utilities.
 * Important: These syscalls are only available in the client.
 * @module
 */

/**
 * Returns the name of the page or document currently open in the editor.
 * @returns the current page name
 */
export function getCurrentPage(): Promise<string> {
  return syscall("editor.getCurrentPage");
}

/**
 * Returns the meta data of the page or document currently open in the editor.
 * @returns the current page meta data
 */
export function getCurrentPageMeta(): Promise<PageMeta | undefined> {
  return syscall("editor.getCurrentPageMeta");
}

/**
 * Returns the path of the page or document currently open in the editor.
 * @returns the current page path
 */
export function getCurrentPath(): Promise<Path> {
  return syscall("editor.getCurrentPath");
}

/**
 * Returns the names of the pages that have been recently opened
 * @returns the names of the recently opened pages
 */
export function getRecentlyOpenedPages(): Promise<PageMeta[]> {
  return syscall("editor.getRecentlyOpenedPages");
}

/**
 * Returns the name of the currently open editor.
 * @returns the name of the current editor
 */
export function getCurrentEditor(): Promise<string> {
  return syscall("editor.getCurrentEditor");
}

/**
 * Returns the full text of the currently open page
 */
export function getText(): Promise<string> {
  return syscall("editor.getText");
}

/**
 * This updates the editor text, but in a minimal-diff way:
 * it compares the current editor text with the new text, and only sends the changes to the editor, thereby preserving cursor location
 */
export function setText(
  newText: string,
  isolateHistory = false,
): Promise<void> {
  return syscall("editor.setText", newText, isolateHistory);
}

/**
 * Returns the position (in # of characters from the beginning of the file) of the cursor in the editor
 */
export function getCursor(): Promise<number> {
  return syscall("editor.getCursor");
}

/**
 * Returns the line number and column number of the cursor in the editor
 */
export function getSelection(): Promise<
  { from: number; to: number; text: string }
> {
  return syscall("editor.getSelection");
}

/**
 * Sets the position of the cursor in the editor
 * @param from the start position of the selection
 * @param to the end position of the selection
 */
export function setSelection(from: number, to: number): Promise<void> {
  return syscall("editor.setSelection", from, to);
}

/**
 * Invoke a client command by name
 * Note: only available on the client
 * @param name name of the command
 * @param args arguments to pass to the command
 */
export function invokeCommand(name: string, args?: string[]): Promise<any> {
  return syscall("editor.invokeCommand", name, args);
}

/**
 * Forces a save of the current page
 */
export function save(): Promise<void> {
  return syscall("editor.save");
}

/**
 * Navigates to the specified page reference
 * @param pageRef the page reference to navigate to
 * @param replaceState whether to replace the current history state in the browser history
 * @param newWindow whether to open the page in a new window
 */
export function navigate(
  ref: Ref | string,
  replaceState = false,
  newWindow = false,
): Promise<void> {
  return syscall("editor.navigate", ref, replaceState, newWindow);
}

/**
 * Opens the page navigator
 * @param mode the mode to open the navigator in
 */
export function openPageNavigator(
  mode: "page" | "meta" | "document" | "all" = "page",
): Promise<void> {
  return syscall("editor.openPageNavigator", mode);
}

/**
 * Opens the command palette
 */
export function openCommandPalette(): Promise<void> {
  return syscall("editor.openCommandPalette");
}

/**
 * Force reloads the current page
 */
export function reloadPage(): Promise<void> {
  return syscall("editor.reloadPage");
}

/**
 * Force reloads the browser UI
 */
export function reloadUI(): Promise<void> {
  return syscall("editor.reloadUI");
}

/**
 * Rebuilds the editor state to ensure the dispatch updates the state.
 */
export function rebuildEditorState(): Promise<void> {
  return syscall("editor.rebuildEditorState");
}

/**
 * Reloads the config and commands, also in the server
 */
export function reloadConfigAndCommands(): Promise<void> {
  return syscall("editor.reloadConfigAndCommands");
}

/**
 * Opens the specified URL in the browser
 * @param url the URL to open
 * @param existingWindow whether to open the URL in an existing window
 */
export function openUrl(url: string, existingWindow = false): Promise<void> {
  return syscall("editor.openUrl", url, existingWindow);
}

export function newWindow(): Promise<void> {
  return syscall("editor.newWindow");
}

/**
 * This is calling the `go()` method from the History Web API.
 * @param delta Position in history to move to relative to the current page,
 * where a negative value moves backwards, and positive forwards
 */
export function goHistory(delta: number): Promise<void> {
  return syscall("editor.goHistory", delta);
}

/**
 * Force the client to download the file in dataUrl with filename as file name
 * @param filename the name of the file to download
 * @param dataUrl the dataUrl of the file to download
 */
export function downloadFile(filename: string, dataUrl: string): Promise<void> {
  return syscall("editor.downloadFile", filename, dataUrl);
}

/**
 * Triggers the browser's native file upload dialog/popup
 * @param accept the file types to accept
 * @param capture the capture mode for the file input
 */
export function uploadFile(
  accept?: string,
  capture?: string,
): Promise<UploadFile> {
  return syscall("editor.uploadFile", accept, capture);
}

/**
 * Shows a flash notification to the user (top right corner)
 * @param message the message to show
 * @param type the type of notification to show
 */
export function flashNotification(
  message: string,
  type: "info" | "error" = "info",
): Promise<void> {
  return syscall("editor.flashNotification", message, type);
}

/**
 * Exposes a filter box UI (similar to the page navigator and command palette)
 * @param label the label to show left of the input box
 * @param options the options to show and to filter on
 * @param helpText the help text to show below the input box
 * @param placeHolder the placeholder text to show in the input box
 */
export function filterBox(
  label: string,
  options: FilterOption[],
  helpText = "",
  placeHolder = "",
): Promise<FilterOption | undefined> {
  return syscall("editor.filterBox", label, options, helpText, placeHolder);
}

/**
 * Shows a panel in the editor
 * @param id the location of the panel to show
 * @param mode the mode or "size" of the panel
 * @param html the html content of the panel
 * @param script the script content of the panel
 */
export function showPanel(
  id: "lhs" | "rhs" | "bhs" | "modal",
  mode: number,
  html: string,
  script = "",
): Promise<void> {
  return syscall("editor.showPanel", id, mode, html, script);
}

/**
 * Hides a panel in the editor
 * @param id the location of the panel to hide
 */
export function hidePanel(
  id: "lhs" | "rhs" | "bhs" | "modal",
): Promise<void> {
  return syscall("editor.hidePanel", id);
}

export function showProgress(
  progressPercentage?: number,
  progressType?: string,
): Promise<void> {
  return syscall("editor.showProgress", progressPercentage, progressType);
}

/**
 * Insert text at the specified position into the editor
 * @param text the text to insert
 * @param pos
 */
export function insertAtPos(text: string, pos: number): Promise<void> {
  return syscall("editor.insertAtPos", text, pos);
}

/**
 * Replace the text in the specified range in the editor
 * @param from the start position of the range
 * @param to the end position of the range
 * @param text the text to replace with
 */
export function replaceRange(
  from: number,
  to: number,
  text: string,
): Promise<void> {
  return syscall("editor.replaceRange", from, to, text);
}

/**
 * Move the cursor to the specified position in the editor
 * @param pos the position to move the cursor to
 * @param center whether to center the cursor in the editor after moving
 */
export function moveCursor(pos: number, center = false): Promise<void> {
  return syscall("editor.moveCursor", pos, center);
}

/**
 * Move the cursor to the specified line and column in the editor
 * @param line the line number to move the cursor to
 * @param column the column number to move the cursor to
 * @param center whether to center the cursor in the editor after moving
 */
export function moveCursorToLine(
  line: number,
  column = 1,
  center = false,
): Promise<void> {
  return syscall("editor.moveCursorToLine", line, column, center);
}

/**
 * Insert text at the cursor position in the editor
 * @param text the text to insert
 */
export function insertAtCursor(
  text: string,
  scrollIntoView = false,
  cursorPlaceHolder = false,
): Promise<void> {
  return syscall(
    "editor.insertAtCursor",
    text,
    scrollIntoView,
    cursorPlaceHolder,
  );
}

/**
 * Dispatch a CodeMirror transaction: https://codemirror.net/docs/ref/#state.Transaction
 */
export function dispatch(change: any): Promise<void> {
  return syscall("editor.dispatch", change);
}

/**
 * Prompt the user for text input
 * @param message the message to show in the prompt
 * @param defaultValue a default value pre-filled in the prompt
 * @returns
 */
export function prompt(
  message: string,
  defaultValue = "",
): Promise<string | undefined> {
  return syscall("editor.prompt", message, defaultValue);
}

/**
 * Prompt the user for confirmation
 * @param message the message to show in the confirmation dialog
 * @returns
 */
export function confirm(
  message: string,
): Promise<boolean> {
  return syscall("editor.confirm", message);
}

/**
 * Prompt the user for confirmation
 * @param message the message to show in the confirmation dialog
 * @returns
 */
export function alert(
  message: string,
): Promise<boolean> {
  return syscall("editor.alert", message);
}

/**
 * Get the value of a UI option
 * @param key the key of the UI option to get
 * @returns
 */
export function getUiOption(key: string): Promise<any> {
  return syscall("editor.getUiOption", key);
}

/**
 * Set the value of a UI option
 * @param key the key of the UI option to set
 * @param value the value to set the UI option to
 */
export function setUiOption(key: string, value: any): Promise<void> {
  return syscall("editor.setUiOption", key, value);
}

/**
 * Perform a fold at the current cursor position
 */
export function fold(): Promise<void> {
  return syscall("editor.fold");
}

/**
 * Perform an unfold at the current cursor position
 */
export function unfold(): Promise<void> {
  return syscall("editor.unfold");
}

/**
 * Toggle the fold at the current cursor position
 */
export function toggleFold(): Promise<void> {
  return syscall("editor.toggleFold");
}

/**
 * Fold all code blocks in the editor
 */
export function foldAll(): Promise<void> {
  return syscall("editor.foldAll");
}

/**
 * Unfold all code blocks in the editor
 */
export function unfoldAll(): Promise<void> {
  return syscall("editor.unfoldAll");
}

/**
 * Perform an undo operation of the last edit in the editor
 */
export function undo(): Promise<void> {
  return syscall("editor.undo");
}

/**
 * Perform a redo operation of the last undo in the editor
 */
export function redo(): Promise<void> {
  return syscall("editor.redo");
}

/**
 * Open the editor's native search panel
 */
export function openSearchPanel(): Promise<void> {
  return syscall("editor.openSearchPanel");
}

/**
 * Copy the specified data to the clipboard
 * @param data the data to copy
 */
export function copyToClipboard(data: string | Blob): Promise<void> {
  return syscall("editor.copyToClipboard", data);
}

/**
 * Delete the current line in the editor
 */
export function deleteLine(): Promise<void> {
  return syscall("editor.deleteLine");
}

/**
 * Comment or uncomment the current line in the editor
 */
export function toggleComment(): Promise<void> {
  return syscall("editor.toggleComment");
}

export function moveLineUp(): Promise<void> {
  return syscall("editor.moveLineUp");
}

export function moveLineDown(): Promise<void> {
  return syscall("editor.moveLineDown");
}

// Vim-mode specific syscalls

/**
 * Execute a Vim ex command
 * @param exCommand the ex command to execute
 */
export function vimEx(exCommand: string): Promise<any> {
  return syscall("editor.vimEx", exCommand);
}

/**
 * Execute a vim config using the CodeMirror Vim Mode API
 */
export function configureVimMode(): Promise<any> {
  return syscall("editor.configureVimMode");
}

// Document editor specific syscalls

/**
 * Send a message - or event if you will - to the currently used document editor
 * @param type the message type, which can be listend to
 * @param data data attached to the message
 */
export function sendMessage(type: string, data?: any): Promise<void> {
  return syscall("editor.sendMessage", type, data);
}

/**
 * Check if the editor is running on a mobile device
 */
export function isMobile(): Promise<boolean> {
  return syscall("editor.isMobile");
}


================================================
FILE: plug-api/syscalls/event.ts
================================================
import { syscall } from "../syscall.ts";

/**
 * Exposes the SilverBullet event bus.
 * @module
 */

/**
 * Triggers an event on the SilverBullet event bus.
 * This can be used to implement an RPC-style system too, because event handlers can return values,
 * which are then accumulated in an array and returned to the caller.
 * @param eventName the name of the event to trigger
 * @param data payload to send with the event
 * @param timeout optional timeout in milliseconds to wait for a response
 * @returns an array of responses from the event handlers (if any)
 */
export function dispatchEvent(
  eventName: string,
  data: any,
  timeout?: number,
): Promise<any[]> {
  return new Promise((resolve, reject) => {
    let timeouter: any = -1;
    if (timeout) {
      timeouter = setTimeout(() => {
        console.log("Timeout!");
        reject("timeout");
      }, timeout);
    }
    syscall("event.dispatch", eventName, data)
      .then((r: any) => {
        if (timeouter !== -1) {
          clearTimeout(timeouter);
        }
        resolve(r);
      })
      .catch(reject);
  });
}

/**
 * List all events currently registered (listened to) on the SilverBullet event bus.
 * @returns an array of event names
 */
export function listEvents(): Promise<string[]> {
  return syscall("event.listEvents");
}


================================================
FILE: plug-api/syscalls/index.ts
================================================
import type { LuaCollectionQuery } from "../../client/space_lua/query_collection.ts";
import { syscall } from "@silverbulletmd/silverbullet/syscall";
import type { ObjectValue } from "../../plug-api/types/index.ts";

/**
 * Exposes the SilverBullet object indexing system
 * @module
 */

/**
 * Indexes objects for a specific page
 * @param page - The page identifier where objects will be indexed
 * @param objects - Array of objects to be indexed
 * @returns Promise that resolves when indexing is complete
 */
export function indexObjects<T>(
  page: string,
  objects: ObjectValue<T>[],
): Promise<void> {
  return syscall("index.indexObjects", page, objects);
}

export function validateObjects<T>(
  page: string,
  objects: ObjectValue<T>[],
): Promise<{ error: string; object: ObjectValue } | null> {
  return syscall("index.validateObjects", page, objects);
}

/**
 * Queries objects using a Lua-based collection query
 * @param tag - The tag to filter objects by
 * @param query - Lua query parameters to filter objects
 * @param scopedVariables - Optional variables to be used in the Lua query
 * @returns Promise that resolves with an array of matching objects
 */
export function queryLuaObjects<T>(
  tag: string,
  query: LuaCollectionQuery,
  scopedVariables?: Record<string, any>,
): Promise<ObjectValue<T>[]> {
  return syscall("index.queryLuaObjects", tag, query, scopedVariables);
}

/**
 * Retrieves a specific object by its reference
 * @param page - The page identifier where the object is located
 * @param tag - The tag of the object
 * @param ref - The reference identifier of the object
 * @returns Promise that resolves with the matching object or undefined if not found
 */
export function getObjectByRef<T>(
  page: string,
  tag: string,
  ref: string,
): Promise<ObjectValue<T> | undefined> {
  return syscall("index.getObjectByRef", page, tag, ref);
}

/**
 * Ensures that the full index is built and up-to-date
 */
export function ensureFullIndex(): Promise<void> {
  return syscall("index.ensureFullIndex");
}

export function reindexSpace(): Promise<void> {
  return syscall("index.reindexSpace");
}

export function deleteObject(
  page: string,
  tag: string,
  ref: string,
): Promise<void> {
  return syscall("index.deleteObject", page, tag, ref);
}


================================================
FILE: plug-api/syscalls/jsonschema.ts
================================================
import { syscall } from "../syscall.ts";

/**
 * Validates a JSON object against a JSON schema.
 * @param schema the JSON schema to validate against
 * @param object the JSON object to validate
 * @returns an error message if the object is invalid, or undefined if it is valid
 */
export function validateObject(
  schema: any,
  object: any,
): Promise<string | undefined> {
  return syscall("jsonschema.validateObject", schema, object);
}

/**
 * Validates a JSON schema.
 * @param schema the JSON schema to validate
 * @returns an error message if the schema is invalid, or undefined if it is valid
 */
export function validateSchema(
  schema: any,
): Promise<string | undefined> {
  return syscall("jsonschema.validateSchema", schema);
}


================================================
FILE: plug-api/syscalls/language.ts
================================================
import { syscall } from "../syscall.ts";
import type { ParseTree } from "../lib/tree.ts";

/**
 * Parses a piece of code using any of the supported SB languages, see `common/languages.ts` for a list
 * @param language the language to parse
 * @param code the code to parse
 * @returns a ParseTree representation of the code
 */
export function parseLanguage(
  language: string,
  code: string,
): Promise<ParseTree> {
  return syscall("language.parseLanguage", language, code);
}

/**
 * Lists all supported languages in fenced code blocks
 * @returns a list of all supported languages
 */
export function listLanguages(): Promise<string[]> {
  return syscall("language.listLanguages");
}


================================================
FILE: plug-api/syscalls/lua.ts
================================================
import { syscall } from "../syscall.ts";
import type { LuaBlock, LuaExpression } from "../../client/space_lua/ast.ts";

export function parse(
  code: string,
): Promise<LuaBlock> {
  return syscall("lua.parse", code);
}

export function parseExpression(
  expression: string,
): Promise<LuaExpression> {
  return syscall("lua.parseExpression", expression);
}

export function evalExpression(
  expression: string,
): Promise<any> {
  return syscall("lua.evalExpression", expression);
}


================================================
FILE: plug-api/syscalls/markdown.ts
================================================
import { syscall } from "../syscall.ts";
import type { ParseTree } from "../lib/tree.ts";

/**
 * Parses a piece of markdown text into a ParseTree.
 * @param text the markdown text to parse
 * @returns a ParseTree representation of the markdown text
 */
export function parseMarkdown(text: string): Promise<ParseTree> {
  return syscall("markdown.parseMarkdown", text);
}

/**
 * Renders a ParseTree to markdown.
 * @param tree the parse tree
 * @returns the rendered markdown of a passed parse tree
 */
export function renderParseTree(tree: ParseTree): Promise<string> {
  return syscall("markdown.renderParseTree", tree);
}

/**
 * Expands custom markdown Lua directives and transclusions into plain markdown
 * @param tree the parse tree
 * @returns the expaneded markdown
 */
export function expandMarkdown(tree: ParseTree): Promise<ParseTree> {
  return syscall("markdown.expandMarkdown", tree);
}

/**
 * Renders markdown text to HTML.
 * @param markdownText the markdown text to render
 * @returns HTML representation of the markdown
 */
export function markdownToHtml(markdownText: string): Promise<string> {
  return syscall("markdown.markdownToHtml", markdownText);
}


================================================
FILE: plug-api/syscalls/mq.ts
================================================
import { syscall } from "../syscall.ts";

import type { MQStats } from "../../plug-api/types/datastore.ts";

/**
 * Implements a simple Message Queue system.
 * @module
 */

/**
 * Sends a message to a queue.
 * @param queue the name of the queue to send the message to
 * @param body the body of the message to send
 */
export function send(queue: string, body: any): Promise<void> {
  return syscall("mq.send", queue, body);
}

/**
 * Sends a batch of messages to a queue.
 * @param queue the name of the queue
 * @param bodies the bodies of the messages to send
 */
export function batchSend(
  queue: string,
  bodies: any[],
): Promise<void> {
  return syscall("mq.batchSend", queue, bodies);
}

/**
 * Flushes all messages from a queue.
 * @param queue the name of the queue to subscribe to
 */
export function flushQueue(queue: string): Promise<void> {
  return syscall("mq.flushQueue", queue);
}

/**
 * Flushes all messages from all queues.
 */
export function flushAllQueues(): Promise<void> {
  return syscall("mq.flushAllQueues");
}

/**
 * Acknowledges a message from a queue, in case it needs to be explicitly acknowledged.
 * @param queue the name of the queue the message came from
 * @param id the id of the message to acknowledge
 */
export function ack(queue: string, id: string): Promise<void> {
  return syscall("mq.ack", queue, id);
}

/**
 * Acknowledges a batch of messages from a queue, in case they need to be explicitly acknowledged.
 * @param queue the name of the queue the messages came from
 * @param ids the ids of the messages to acknowledge
 */
export function batchAck(queue: string, ids: string[]): Promise<void> {
  return syscall("mq.batchAck", queue, ids);
}

/**
 * Retrieves stats on a particular queue.
 * @param queue the name of the queue
 */
export function getQueueStats(queue?: string): Promise<MQStats> {
  return syscall("mq.getQueueStats", queue);
}

/**
 * Waits for a queue to become empty.
 * @param queue the name of the queue
 * @returns a promise that resolves when the queue is empty
 */
export function awaitEmptyQueue(queue: string): Promise<void> {
  return syscall("mq.awaitEmptyQueue", queue);
}


================================================
FILE: plug-api/syscalls/shell.ts
================================================
import { syscall } from "../syscall.ts";

/**
 * Runs a shell command.
 * @param cmd the command to run
 * @param args the arguments to pass to the command
 * @returns the stdout, stderr, and exit code of the command
 */
export function run(
  cmd: string,
  args: string[],
): Promise<{ stdout: string; stderr: string; code: number }> {
  return syscall("shell.run", cmd, args);
}


================================================
FILE: plug-api/syscalls/space.ts
================================================
import { syscall } from "../syscall.ts";

import type {
  DocumentMeta,
  FileMeta,
  PageMeta,
} from "../../plug-api/types/index.ts";
import type { Ref } from "@silverbulletmd/silverbullet/lib/ref";

/**
 * Exposes the space with its pages, documents and plugs.
 * @module
 */

/**
 * Lists all pages (files ending in .md) in the space.
 * @param unfiltered
 * @returns a list of all pages in the space represented as PageMeta objects
 */
export function listPages(): Promise<PageMeta[]> {
  return syscall("space.listPages");
}

/**
 * Get metadata for a page in the space.
 * @param name the name of the page to get metadata for
 * @returns the metadata for the page
 */
export function getPageMeta(name: string): Promise<PageMeta> {
  return syscall("space.getPageMeta", name);
}

/**
 * Check if a page exists in the space.
 * @param name the name of the page to check
 * @returns true if the page exists, false otherwise
 */
export function pageExists(name: string): Promise<boolean> {
  return syscall("space.pageExists", name);
}

/**
 * Read a page from the space as text.
 * @param name the name of the page to read
 * @returns the text of the page
 */
export function readPage(
  name: string,
): Promise<string> {
  return syscall("space.readPage", name);
}

/**
 * Read a page from the space returning both its text and meta data
 * @param name the name of the page to read
 * @returns
 *  - text: page text as a string
 *  - meta: pageMeta
 */
export function readPageWithMeta(
  name: string,
): Promise<{ text: string; meta: PageMeta }> {
  return syscall("space.readPageWithMeta", name);
}

/**
 * Write a page to the space.
 * @param name the name of the page to write
 * @param text the text of the page to write
 * @returns the metadata for the written page
 */
export function writePage(name: string, text: string): Promise<PageMeta> {
  return syscall("space.writePage", name, text);
}

/**
 * Delete a page from the space.
 * @param name the name of the page to delete
 */
export function deletePage(name: string): Promise<void> {
  return syscall("space.deletePage", name);
}

/**
 * List all plugs in the space.
 * @returns a list of all plugs in the space represented as FileMeta objects
 */
export function listPlugs(): Promise<FileMeta[]> {
  return syscall("space.listPlugs");
}

/**
 * Lists all documents in the space (all files not ending in .md).
 * @returns a list of all documents in the space represented as DocumentMeta objects
 */
export function listDocuments(): Promise<DocumentMeta[]> {
  return syscall("space.listDocuments");
}

/**
 * Get metadata for an document in the space.
 * @param name the path of the document to get metadata for
 * @returns the metadata for the document
 */
export function getDocumentMeta(name: string): Promise<DocumentMeta> {
  return syscall("space.getDocumentMeta", name);
}

/**
 * Read an document from the space
 * @param name path of the document to read
 * @returns the document data as a UInt8Array
 */
export function readDocument(
  name: string,
): Promise<Uint8Array> {
  return syscall("space.readDocument", name);
}

/**
 * Writes a document to the space
 * @param name path of the document to write
 * @param data data itself
 * @returns
 */
export function writeDocument(
  name: string,
  data: Uint8Array,
): Promise<DocumentMeta> {
  return syscall("space.writeDocument", name, data);
}

/**
 * Deletes a document from the space
 * @param name path of the document to delete
 */
export function deleteDocument(name: string): Promise<void> {
  return syscall("space.deleteDocument", name);
}

// Lower level-file operations

/**
 * List all files in the space (pages, documents and plugs).
 * @returns a list of all files in the space represented as FileMeta objects
 */
export function listFiles(): Promise<FileMeta[]> {
  return syscall("space.listFiles");
}

/**
 * Read a file from the space as a Uint8Array.
 * @param name the name of the file to read
 * @returns the data of the file
 */
export function readFile(name: string): Promise<Uint8Array> {
  return syscall("space.readFile", name);
}

/**
 * Reads a reference (e.g. page#header or page@20) and returns it as a string
 */
export function readRef(ref: string | Ref): Promise<string> {
  return syscall("space.readRef", ref);
}

/**
 * Read a file from the space returning both its data and meta data
 * @param name the name of the page to read
 * @returns
 *  - data: file content
 *  - meta: pageMeta
 */
export function readFileWithMeta(
  name: string,
): Promise<{ data: Uint8Array; meta: FileMeta }> {
  return syscall("space.readFileWithMeta", name);
}

/**
 * Get metadata for a file in the space.
 * @param name the name of the file to get metadata for
 * @returns the metadata for the file
 */
export function getFileMeta(name: string): Promise<FileMeta> {
  return syscall("space.getFileMeta", name);
}

/**
 * Write a file to the space.
 * @param name the name of the file to write
 * @param data the data of the file to write
 * @returns the metadata for the written file
 */
export function writeFile(
  name: string,
  data: Uint8Array,
): Promise<FileMeta> {
  return syscall("space.writeFile", name, data);
}

/**
 * Delete a file from the space.
 * @param name the name of the file to delete
 */
export function deleteFile(name: string): Promise<void> {
  return syscall("space.deleteFile", name);
}

export function fileExists(name: string): Promise<boolean> {
  return syscall("space.fileExists", name);
}


================================================
FILE: plug-api/syscalls/sync.ts
================================================
import { syscall } from "../syscall.ts";

/**
 * Syscalls that interact with the sync engine (when the client runs in Sync mode)
 * @module
 */

/**
 * Checks if an initial sync has completed
 */
export function hasInitialSyncCompleted(): Promise<boolean> {
  return syscall("sync.hasInitialSyncCompleted");
}

/**
 * Syncs a file immediately. Sync would happen automatically, but this prioritizes the file.
 * @param path the path to the file to sync
 */
export function performFileSync(path: string): Promise<void> {
  return syscall("sync.performFileSync", path);
}

/**
 * Performs an immediate full sync.
 */
export function performSpaceSync(): Promise<number> {
  return syscall("sync.performSpaceSync");
}


================================================
FILE: plug-api/syscalls/system.ts
================================================
import { syscall } from "../syscall.ts";
import type { CommandDef } from "../types/manifest.ts";
import type { SyscallMeta } from "@silverbulletmd/silverbullet/type/index";

/**
 * System level syscalls
 * @module
 */

/**
 * Invoke a plug function
 * @param name a string representing the name of the function to invoke ("plug.functionName")
 * @param args arguments to pass to the function
 * @returns
 */
export function invokeFunction(
  name: string,
  ...args: any[]
): Promise<any> {
  return syscall("system.invokeFunction", name, ...args);
}

/**
 * Invoke a client command by name
 * Note: only available on the client
 * @param name name of the command
 * @param args arguments to pass to the command
 */
export function invokeCommand(name: string, args?: string[]): Promise<any> {
  return syscall("system.invokeCommand", name, args);
}

/**
 * Lists all commands available
 * @returns a map of all available commands
 */
export function listCommands(): Promise<Record<string, CommandDef>> {
  return syscall("system.listCommands");
}

/**
 * Lists all syscalls available
 * @returns a list of all available syscalls
 */
export function listSyscalls(): Promise<SyscallMeta[]> {
  return syscall("system.listSyscalls");
}

/**
 * Trigger a reload of all plugs
 * @returns
 */
export function reloadPlugs(): Promise<void> {
  return syscall("system.reloadPlugs");
}

/**
 * Returns the current mode of the system, either "ro" (read-only) or "rw" (read-write)
 */
export function getMode(): Promise<"ro" | "rw"> {
  return syscall("system.getMode");
}

/**
 * Returns the prefix set by SB_URL_PREFIX or "/" if the variable isn't set
 */
export function getURLPrefix(): Promise<string> {
  return syscall("system.getURLPrefix");
}

/**
 * Returns the base URI for this SilverBullet isntance
 */
export function getBaseURI(): Promise<string> {
  return syscall("system.getBaseURI");
}

/**
 * Returns the SilverBullet version
 */
export function getVersion(): Promise<string> {
  return syscall("system.getVersion");
}

export function getConfig<T = any>(
  key: string,
  defaultValue: any = undefined,
): Promise<T> {
  return syscall("system.getConfig", key, defaultValue);
}

export function wipeClient(logout = false): Promise<void> {
  return syscall("system.wipeClient", logout);
}

/**
 * DEPRECATED
 * Deletes all IndexedDB databases that are not connected to client (e.g. legacy databases)
 */
export function cleanDatabases(): Promise<boolean> {
  return syscall("system.cleanDatabases");
}


================================================
FILE: plug-api/syscalls/yaml.ts
================================================
import { syscall } from "../syscall.ts";

/**
 * YAML operations, exposed via `index` plug
 * @module
 */

/**
 * Parses a YAML string into a JavaScript object.
 * @param text the YAML text to parse
 * @returns a JavaScript object representation of the YAML text
 */
export function parse(
  text: string,
): Promise<any> {
  return syscall("yaml.parse", text);
}

/**
 * Converts a JavaScript object into a YAML string.
 * @param obj the object to stringify
 * @returns a YAML string representation of the object
 */
export function stringify(
  obj: any,
): Promise<string> {
  return syscall("yaml.stringify", obj);
}


================================================
FILE: plug-api/system_mock.ts
================================================
import { Config } from "../client/config.ts";
import { DataStore } from "../client/data/datastore.ts";
import { MemoryKvPrimitives } from "../client/data/memory_kv_primitives.ts";
import { DataStoreMQ } from "../client/data/mq.datastore.ts";
import { ObjectIndex } from "../client/data/object_index.ts";
import { EventHook } from "../client/plugos/hooks/event.ts";
import {
  dataStoreReadSyscalls,
  dataStoreWriteSyscalls,
} from "../client/plugos/syscalls/datastore.ts";
import { eventSyscalls } from "../client/plugos/syscalls/event.ts";
import { indexSyscalls } from "../client/plugos/syscalls/index.ts";
import { jsonschemaSyscalls } from "../client/plugos/syscalls/jsonschema.ts";
import { languageSyscalls } from "../client/plugos/syscalls/language.ts";
import { luaSyscalls } from "../client/plugos/syscalls/lua.ts";
import { markdownSyscalls } from "../client/plugos/syscalls/markdown.ts";
import { mqSyscalls } from "../client/plugos/syscalls/mq.ts";
import {
  spaceReadSyscalls,
  spaceWriteSyscalls,
} from "../client/plugos/syscalls/space.ts";
import { systemSyscalls } from "../client/plugos/syscalls/system.ts";
import { System } from "../client/plugos/system.ts";
import { Space } from "../client/space.ts";
import { SpaceLuaEnvironment } from "../client/space_lua.ts";
import { DataStoreSpacePrimitives } from "../client/spaces/datastore_space_primitives.ts";
import { EventedSpacePrimitives } from "../client/spaces/evented_space_primitives.ts";

export function createMockSystem() {
  const system = new System<any>();
  const eventHook = new EventHook();
  system.addHook(eventHook);
  const kv = new MemoryKvPrimitives();
  const ds = new DataStore(kv);
  const spacePrimitives = new EventedSpacePrimitives(
    new DataStoreSpacePrimitives(kv),
    eventHook,
    ds,
  );

  const space = new Space(spacePrimitives, eventHook);
  const mq = new DataStoreMQ(ds, eventHook);
  const config = new Config();
  const clientMock: any = {
    space,
    config,
    eventedSpacePrimitives: spacePrimitives,
  };

  const objectIndex = new ObjectIndex(ds, config, eventHook, mq);

  const clientSystemMock: any = {
    system: system,
    spaceLuaEnv: new SpaceLuaEnvironment(system, objectIndex),
  };
  clientMock.clientSystem = clientSystemMock;

  system.registerSyscalls(
    [],
    eventSyscalls(eventHook, clientMock),
    spaceReadSyscalls(clientMock),
    spaceWriteSyscalls(clientMock),
    markdownSyscalls(clientMock),
    languageSyscalls(),
    jsonschemaSyscalls(),
    indexSyscalls(objectIndex, clientMock),
    luaSyscalls(clientSystemMock),
    mqSyscalls(mq),
    dataStoreReadSyscalls(ds, clientSystemMock),
    dataStoreWriteSyscalls(ds),
    systemSyscalls(clientMock, false),
  );

  // @ts-ignore: global
  globalThis.syscall = (name: string, ...args: any): Promise<any> => {
    return system.localSyscall(name, args);
  };

  return {
    system,
    eventHook,
    config,
    kv,
    spacePrimitives,
    mq,
    ds,
    space,
  };
}


================================================
FILE: plug-api/types/client.ts
================================================
import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";

export type FilterOption = {
  name: string;
  description?: string;
  orderId?: number;
  hint?: string;
  hintInactive?: boolean;
  classes?: string;
  prefix?: string;
} & Record<string, any>;

export type Notification = {
  id: number;
  message: string;
  type: "info" | "error";
  date: Date;
};

export type PanelMode = number;

export type CodeWidgetContent = {
  html?: string;
  script?: string;
  width?: number;
  height?: number;
  url?: string;
};

// Document editors stuff
export type DocumentEditorCallback = () => Promise<DocumentEditorContent>;
export type DocumentEditorContent = {
  html: string;
};

export type LintDiagnostic = {
  from: number;
  to: number;
  severity: "error" | "warning" | "info" | "hint";
  message: string;
};

export type UploadFile = {
  name: string;
  contentType: string;
  content: Uint8Array;
};

export type AppEvent =
  | "page:click"
  | "slash:complete"
  | "editor:complete"
  | "editor:lint"
  | "editor:init"
  | "editor:pageLoaded" // args: pageName, previousPage, isSynced
  | "editor:pageReloaded"
  | "editor:pageSaving"
  | "editor:pageSaved"
  | "editor:pageCreating"
  | "editor:pageModified"
  | "editor:documentSaving"
  | "editor:documentSaved"
  | "editor:modeswitch"
  | "editor:fold"
  | "editor:unfold"
  | "plugs:loaded"
  | "cron:secondPassed"
  | "hooks:renderTopWidgets"
  | "hooks:renderBottomWidgets";

export type ClickEvent = {
  page: string;
  pos: number;
  metaKey: boolean;
  ctrlKey: boolean;
  altKey: boolean;
};

export type EnrichedClickEvent = ClickEvent & {
  parentNodes: string[];
};

export type LintEvent = {
  name: string;
  pageMeta: PageMeta;
  tree: ParseTree;
  text: string;
};

export type CompleteEvent = {
  pageName: string;
  linePrefix: string;
  pos: number;
  parentNodes: string[];
};

export type SlashCompletionOption = {
  label: string;
  detail?: string;
  invoke: string;
  order?: number;
} & Record<string, any>;

export type SlashCompletions = {
  // Ignore this one, only for compatibility with regular completions
  from?: number;
  // The actual completions
  options: SlashCompletionOption[];
};

// Code widget stuff
export type CodeWidgetCallback = (
  bodyText: string,
  pageName: string,
) => Promise<CodeWidgetContent | null>;


================================================
FILE: plug-api/types/config.ts
================================================
export type EmojiConfig = {
  aliases: string[][];
};
export type QueryCollationConfig = {
  enabled?: boolean;
  locale?: string;
  options?: object;
};

type vimMode = "normal" | "insert" | "visual";

export type VimConfig = {
  unmap?: (string | { key: string; mode?: vimMode })[];
  map?: { map: string; to: string; mode?: vimMode }[];
  noremap?: { map: string; to: string; mode?: vimMode }[];
  commands?: { ex: string; command: string }[];
};
export type SmartQuotesConfig = {
  enabled?: boolean;
  double?: { left?: string; right?: string };
  single?: { left?: string; right?: string };
};


================================================
FILE: plug-api/types/datastore.ts
================================================
// Message Queue related types
export type MQMessage = {
  id: string;
  queue: string;
  body: any;
  retries?: number;
};
export type MQStats = {
  queued: number;
  processing: number;
  dlq: number;
};
export type MQSubscribeOptions = {
  batchSize?: number;
  pollInterval?: number;
};

// KV types
export type KvKey = string[];

export type KV<T = any> = {
  key: KvKey;
  value: T;
};

export type KvQuery = {
  prefix?: KvKey;
};


================================================
FILE: plug-api/types/event.ts
================================================
import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree";
import type { EventSubscriptionDef } from "./manifest.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";

export type IndexTreeEvent = {
  name: string;
  tree: ParseTree;
  meta: PageMeta;
  text: string;
};

export type PageCreatingEvent = {
  name: string;
};

export type PageCreatingContent = {
  text: string;
  perm: "ro" | "rw";
};
export type EventSubscription = EventSubscriptionDef & {
  run: (...args: any[]) => Promise<any>;
};

export type ResolvedPlug = {
  code: string;
  name?: string; // This will only dictate the filename
} | string;


================================================
FILE: plug-api/types/index.ts
================================================
export type FileMeta = {
  name: string;
  created: number;
  lastModified: number;
  contentType: string;
  size: number;
  perm: "ro" | "rw";
};
/**
 * Decorates a page when it matches certain criteria
 */
export type PageDecoration = {
  prefix?: string;
  cssClasses?: string[];
  hide?: boolean;
  renderWidgets?: boolean; // Defaults to true
};
export type PageMeta = ObjectValue<
  {
    name: string;
    created: string; // indexing it as a string
    lastModified: string; // indexing it as a string
    perm: "ro" | "rw";
    lastOpened?: number;
    pageDecoration?: PageDecoration;
  } & Record<string, any>
>;
export type DocumentMeta = ObjectValue<
  {
    name: string;
    contentType: string;
    created: string;
    lastModified: string;
    size: number;
    perm: "ro" | "rw";
    extension: string;
  } & Record<string, any>
>;
export type SyscallMeta = {
  name: string;
  requiredPermissions: string[];
  argCount: number;
};
/**
 * An ObjectValue that can be indexed by the `index` plug, needs to have a minimum of
 * of two fields:
 * - ref: a unique reference (id) for the object, ideally a page reference
 * - tags: a list of tags that the object belongs to
 */
export type ObjectValue<T = any> = {
  ref: string;
  tag: string; // main tag
  range?: [number, number];
  tags?: string[];
  itags?: string[]; // implicit or inherited tags (inherited from the page for instance)
} & T;


================================================
FILE: plug-api/types/manifest.ts
================================================
import type { Manifest as PlugosManifest } from "../../client/plugos/types.ts";
import type { NamespaceOperation } from "./namespace.ts";

export type CodeWidgetT = {
  codeWidget?: string;
  renderMode?: "iframe";
};

export type CommandDef = {
  name: string;

  contexts?: string[];

  // Default 0, higher is higher priority = higher in the list
  priority?: number;

  // Bind to keyboard shortcut
  key?: string | string[];
  mac?: string | string[];

  hide?: boolean;
  requireMode?: "rw" | "ro";
  requireEditor?: "any" | "page" | "notpage" | string;
};
export type CommandHookT = {
  command?: CommandDef;
};

export type EventHookT = {
  events?: string[];
};

export type EventSubscriptionDef = {
  name: string;
};

type MQSubscription = {
  queue: string;
  batchSize?: number;
  pollInterval?: number;
  autoAck?: boolean;
};
export type MQHookT = {
  mqSubscriptions?: MQSubscription[];
};

export type PlugNamespaceDef = {
  pattern: string;
  operation: NamespaceOperation;
};

export type PlugNamespaceHookT = {
  pageNamespace?: PlugNamespaceDef;
};

export type SlashCommandDef = {
  name: string;
  description?: string;
  priority?: number;
  // Parent AST nodes in which this slash command is available, defaults to everywhere
  onlyContexts?: string[];
  // Parent AST nodes in which this slash command is not available
  exceptContexts?: string[];
};
export type SlashCommandHookT = {
  slashCommand?: SlashCommandDef;
};

export type SyscallHookT = {
  syscall?: string;
};

export type DocumentEditorT = {
  editor?: string | string[];
};

/** Silverbullet hooks give plugs access to silverbullet core systems.
 *
 * Hooks are associated with typescript functions through a manifest file.
 * On various triggers (user enters a slash command, an HTTP endpoint is hit, user clicks, etc) the typescript function is called.
 *
 * related: plugos/ui_types.ts#FunctionDef
 */
export type SilverBulletHooks =
  & CommandHookT
  & SlashCommandHookT
  & MQHookT
  & EventHookT
  & CodeWidgetT
  & PlugNamespaceHookT
  & DocumentEditorT
  & SyscallHookT;

/** A plug manifest configures hooks, declares syntax extensions, and describes plug metadata.
 *
 * Typically the manifest file is in a plug's root directory, named `${plugName}.plug.yaml`.
 */
export type Manifest = PlugosManifest<SilverBulletHooks>;


================================================
FILE: plug-api/types/namespace.ts
================================================
export type NamespaceOperation =
  | "readFile"
  | "writeFile"
  | "listFiles"
  | "getFileMeta"
  | "deleteFile";


================================================
FILE: plugs/README.md
================================================
This folder contains the implementation of SilverBullet's built-in plugs — the plugs that ship with SB itself.

================================================
FILE: plugs/builtin_plugs.ts
================================================
// TODO: Figure out how to keep this up-to-date automatically
export const builtinPlugNames = [
  "core",
  "editor",
  "index",
  "sync",
  "plug-manager",
  "emoji",
  "image-viewer",
];

export const builtinPlugPaths = builtinPlugNames.map((name) =>
  `Library/Std/Plugs/${name}.plug.js`
);


================================================
FILE: plugs/editor/complete.ts
================================================
import { folderName } from "@silverbulletmd/silverbullet/lib/resolve";
import { index, language, lua } from "@silverbulletmd/silverbullet/syscalls";
import type {
  DocumentMeta,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import type { CompleteEvent } from "@silverbulletmd/silverbullet/type/client";

// Page completion
export async function pageComplete(completeEvent: CompleteEvent) {
  const isDocumentQuery = {
    objectVariable: "_",
    where: await lua.parseExpression(
      `not string.startsWith(_.name, "_") and not string.endsWith(_.name, ".plug.js")`,
    ),
  };
  const isMetaPageQuery = {
    objectVariable: "_",
    where: await lua.parseExpression(`table.find(_.tags, function(tag)
           return string.startsWith(tag, "meta")
          end)`),
  };
  const isntMetaPageQuery = {
    objectVariable: "_",
    where: await lua.parseExpression(`not table.find(_.tags, function(tag)
           return string.startsWith(tag, "meta")
          end)`),
  };
  // Try to match [[wikilink]]
  let isWikilink = true;
  // This negative lookbehind is to prevent matching query[[. This requires negative lookbehind, which generally supported now (it seems), in versions of iOS Safari 13.1 and later
  // https://caniuse.com/js-regexp-lookbehind
  let match = /(?<!query)\[\[([^\]@$#:\{}]*)$/.exec(completeEvent.linePrefix);
  if (!match) {
    // Try to match [markdown link]()
    match = /\[.*\]\(([^\]\)@$#:\{}]*)$/.exec(completeEvent.linePrefix);
    isWikilink = false;
  }
  if (!match) {
    return null;
  }

  const prefix = match[1];

  let allPages: (PageMeta | DocumentMeta)[] = [];

  if (prefix.startsWith("^")) {
    // A carrot prefix means we're looking for a meta page
    allPages = await index.queryLuaObjects<PageMeta>("page", isMetaPageQuery);
    // Let's prefix the names with a caret to make them match
    allPages = allPages.map((page) => ({
      ...page,
      name: "^" + page.name,
    }));
  } else {
    // This is the most common case, we're combining three types of completions here:
    allPages = (await Promise.all([
      // All non-meta pages
      index.queryLuaObjects<PageMeta>("page", isntMetaPageQuery),
      // All documents
      index.queryLuaObjects<DocumentMeta>("document", isDocumentQuery),
      // And all links to non-existing pages (to augment the existing ones)
      index.queryLuaObjects<string>(
        "aspiring-page",
        {
          select: { type: "Variable", name: "name", ctx: {} as any },
          distinct: true,
        },
      ).then((aspiringPages) =>
        // Rewrite them to PageMeta shaped objects
        aspiringPages.map((aspiringPage: string): PageMeta => ({
          ref: aspiringPage,
          tag: "page",
          tags: ["non-existing"], // Picked up later in completion
          name: aspiringPage,
          created: "",
          lastModified: "",
          perm: "rw",
        }))
      ),
    ])).flat();
  }

  // Don't complete hidden pages
  allPages = allPages.filter((page) => !(page.pageDecoration?.hide === true));

  const folder = folderName(completeEvent.pageName);

  return {
    from: completeEvent.pos - prefix.length,
    options: allPages.map((pageMeta) => {
      const completions: any[] = [];
      const namePrefix = (pageMeta as PageMeta).pageDecoration?.prefix || "";
      const cssClass = ((pageMeta as PageMeta).pageDecoration?.cssClasses || [])
        .join(" ").replaceAll(/[^a-zA-Z0-9-_ ]/g, "");

      if (isWikilink) {
        // A [[wikilink]]
        const linkAlias = pageMeta.linkName || pageMeta.displayName;
        if (linkAlias) {
          const decoratedName = namePrefix + linkAlias;
          let boost = new Date(pageMeta.lastModified).getTime();
          if (pageMeta._isAspiring) {
            boost = -Infinity;
          }
          completions.push({
            label: linkAlias,
            displayLabel: decoratedName,
            boost,
            apply: pageMeta.tag === "template"
              ? pageMeta.name
              : `${pageMeta.name}|${linkAlias}`,
            detail: pageMeta.linkName
              ? `linkName for: ${pageMeta.name}`
              : `displayName for: ${pageMeta.name}`,
            type: "page",
            cssClass,
          });
        }
        if (Array.isArray(pageMeta.aliases)) {
          for (const alias of pageMeta.aliases) {
            const decoratedName = namePrefix + alias;
            completions.push({
              label: "" + alias,
              displayLabel: decoratedName,
              boost: new Date(pageMeta.lastModified).getTime(),
              apply: pageMeta.tag === "template"
                ? pageMeta.name
                : `${pageMeta.name}|${alias}`,
              detail: `alias to: ${pageMeta.name}`,
              type: "page",
              cssClass,
            });
          }
        }
        const decoratedName = namePrefix + pageMeta.name;
        completions.push({
          label: pageMeta.name,
          displayLabel: decoratedName,
          boost: new Date(pageMeta.lastModified).getTime(),
          detail: pageMeta.tags?.includes("non-existing")
            ? "Linked but not created"
            : undefined,
          type: "page",
          cssClass,
        });
      } else {
        // A markdown link []()
        let labelText = pageMeta.name;
        let boost = new Date(pageMeta.lastModified).getTime();
        // Relative path if in the same folder or a subfolder
        if (folder.length > 0 && labelText.startsWith(folder)) {
          labelText = labelText.slice(folder.length + 1);
          boost = boost * 1.1;
        } else {
          // Absolute path otherwise
          labelText = "/" + labelText;
        }
        completions.push({
          label: labelText,
          displayLabel: namePrefix + labelText,
          boost: boost,
          apply: labelText.includes(" ") ? "<" + labelText + ">" : labelText,
          type: "page",
          cssClass,
        });
      }
      return completions;
    }).flat(),
  };
}

export async function languageComplete(completeEvent: CompleteEvent) {
  const languagePrefix = /^(?:```+|~~~+)(\w*)$/.exec(
    completeEvent.linePrefix,
  );
  if (!languagePrefix) {
    return null;
  }

  const allLanguages = await language.listLanguages();
  return {
    from: completeEvent.pos - languagePrefix[1].length,
    options: allLanguages.map(
      (lang) => ({
        label: lang,
        type: "language",
      }),
    ),
  };
}


================================================
FILE: plugs/editor/document.ts
================================================
import { editor, space } from "@silverbulletmd/silverbullet/syscalls";

export async function deleteDocument() {
  const documentName = await editor.getCurrentPath();
  if (
    !await editor.confirm(
      `Are you sure you would like to delete ${documentName}?`,
    )
  ) {
    return;
  }
  console.log("Navigating to index page");
  await editor.navigate("");
  console.log("Deleting document from space");
  await space.deleteDocument(documentName);
}


================================================
FILE: plugs/editor/editor.ts
================================================
import {
  clientStore,
  codeWidget,
  editor,
  index,
  system,
} from "@silverbulletmd/silverbullet/syscalls";
import type { FilterOption } from "@silverbulletmd/silverbullet/type/client";

// Run on "editor:init"
export async function setEditorMode() {
  // TODO: Remove at some point: temporary upgrade code
  const allSyscalls = await system.listSyscalls();
  // console.log("All syscalls", allSyscalls);
  const queryLuaObjects = allSyscalls.find((sc) =>
    sc.name === "index.queryLuaObjects"
  );

  // console.log(readPageWithMetaCall);

  if (!queryLuaObjects) {
    await editor.alert(
      "Client needs reloading to update the cache, required syscalls are not available in this version. This message may appear a few times. Reloading now.",
    );
    editor.reloadUI();
  }

  if (await clientStore.get("vimMode")) {
    await editor.setUiOption("vimMode", true);
  }
  // Only set the darkmode value if it was deliberatly set in the clientstore,
  // otherwise leave it so the client can choose depending on the system
  // settings
  const darkMode = await clientStore.get("darkMode");
  if (darkMode != null) {
    await editor.setUiOption("darkMode", darkMode);
  }
  const markdownSyntaxRendering = await clientStore.get(
    "markdownSyntaxRendering",
  );
  if (markdownSyntaxRendering != null) {
    await editor.setUiOption(
      "markdownSyntaxRendering",
      markdownSyntaxRendering,
    );
    await editor.rebuildEditorState();
  }
}

export function openCommandPalette() {
  return editor.openCommandPalette();
}

export async function openPageNavigator() {
  await editor.openPageNavigator("page");
}

export async function openMetaNavigator() {
  await editor.openPageNavigator("meta");
}

export async function openTagNavigator() {
  // Query all tags with a matching parent
  const allTags: FilterOption[] = (await index.queryLuaObjects<string>("tag", {
    select: { type: "Variable", name: "name", ctx: {} as any },
    distinct: true,
  })).map((name) => ({ name }));

  const selectedTag = await editor.filterBox(
    "Open",
    allTags,
    "Press <tt>enter</tt> to go to the tag page of the selected tag.",
    "Tag",
  );
  if (!selectedTag) {
    return;
  }
  await editor.navigate(`tag:${selectedTag.name}`);
}

export async function openDocumentNavigator() {
  await editor.openPageNavigator("document");
}

export async function openAllNavigator() {
  await editor.openPageNavigator("all");
}

export async function toggleDarkMode() {
  let darkMode = await editor.getUiOption("darkMode");
  darkMode = !darkMode;
  await clientStore.set("darkMode", darkMode);
  await editor.reloadUI();
}

export async function toggleMarkdownSyntaxRendering() {
  let renderingSyntax = await editor.getUiOption(
    "markdownSyntaxRendering",
  );
  renderingSyntax = !renderingSyntax;
  await clientStore.set("markdownSyntaxRendering", renderingSyntax);
  await editor.setUiOption("markdownSyntaxRendering", renderingSyntax);
  await editor.rebuildEditorState();
}

export async function centerCursorCommand() {
  const pos = await editor.getCursor();
  await editor.moveCursor(pos, true);
}

export async function moveToPosCommand() {
  const posString = await editor.prompt("Move to position:");
  if (!posString) {
    return;
  }
  const pos = +posString;
  await editor.moveCursor(pos, true); // showing the movement for better UX
}

export async function copyRefCommand() {
  const page = await editor.getCurrentPage();
  const pos = await editor.getCursor();
  await editor.copyToClipboard(`[[${page}@${pos}]]`);
  await editor.flashNotification("Ref copied to clipboard");
}

export async function copyLinkCommand() {
  const page = await editor.getCurrentPage();
  const pos = await editor.getCursor();
  await editor.copyToClipboard(`${await system.getBaseURI()}${page}@${pos}`);
  await editor.flashNotification("Link copied to clipboard");
}

export async function moveToLineCommand() {
  const lineString = await editor.prompt(
    "Move to line (and optionally column):",
  );
  if (!lineString) {
    return;
  }
  // Match sequence of digits at the start, optionally another sequence
  const numberRegex = /^(\d+)(?:[^\d]+(\d+))?/;
  const match = lineString.match(numberRegex);
  if (!match) {
    await editor.flashNotification(
      "Could not parse line number in prompt",
      "error",
    );
    return;
  }
  let column = 1;
  const line = parseInt(match[1]);
  if (match[2]) {
    column = parseInt(match[2]);
  }
  await editor.moveCursorToLine(line, column, true); // showing the movement for better UX
}

export async function customFlashMessage(_def: any, message: string) {
  await editor.flashNotification(message);
}

export async function reloadSystem() {
  await editor.save();
  await editor.reloadConfigAndCommands();
  await codeWidget.refreshAll();
  await editor.flashNotification("System and widgets reloaded!");
}

export function refreshAllWidgets() {
  codeWidget.refreshAll();
}

export async function findInPageCommand() {
  await editor.openSearchPanel();
  return false;
}

export function undoCommand() {
  return editor.undo();
}

export function redoCommand() {
  return editor.redo();
}

export function deleteLineCommand() {
  return editor.deleteLine();
}

export function newWindowCommand() {
  return editor.newWindow();
}


================================================
FILE: plugs/editor/help.ts
================================================
import { editor } from "@silverbulletmd/silverbullet/syscalls";
import { publicVersion } from "../../public_version.ts";

export async function versionCommand() {
  await editor.flashNotification(
    `You are currently running SilverBullet ${publicVersion}`,
  );
}


================================================
FILE: plugs/editor/navigate.ts
================================================
import { editor, markdown, space } from "@silverbulletmd/silverbullet/syscalls";
import {
  addParentPointers,
  findNodeOfType,
  findParentMatching,
  nodeAtPos,
  type ParseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import {
  isLocalURL,
  resolveMarkdownLink,
} from "@silverbulletmd/silverbullet/lib/resolve";
import { parseToRef } from "@silverbulletmd/silverbullet/lib/ref";
import { tagPrefix } from "../index/constants.ts";
import type { ClickEvent } from "@silverbulletmd/silverbullet/type/client";
import { extractHashtag } from "@silverbulletmd/silverbullet/lib/tags";

async function actionClickOrActionEnter(
  mdTree: ParseTree | null,
  inNewWindow = false,
) {
  if (!mdTree) {
    return;
  }
  const navigationNodeFinder = (t: ParseTree) =>
    [
      "WikiLink",
      "Link",
      "Image",
      "Autolink",
      "NakedURL",
      "Hashtag",
    ]
      .includes(
        t.type!,
      );
  if (!navigationNodeFinder(mdTree)) {
    mdTree = findParentMatching(mdTree, navigationNodeFinder);
    if (!mdTree) {
      return;
    }
  }
  const currentPage = await editor.getCurrentPage();
  switch (mdTree.type) {
    case "WikiLink": {
      const link = mdTree.children![1]!.children![0].text!;
      const currentPath = await editor.getCurrentPath();
      const ref = parseToRef(link);

      if (!ref) {
        return editor.flashNotification(
          `Couldn't navigate to ${link}, WikiLink is invalid`,
          "error",
        );
      }

      if (ref.path === "") {
        ref.path = currentPath;
      }

      // TODO: Navigate behind frontmatter?
      // This is an explicit navigate, move to the top
      if (!ref.details) {
        ref.details = {
          type: "position",
          pos: 0,
        };
      }
      return editor.navigate(ref, false, inNewWindow);
    }
    // https://example.org
    case "NakedURL":
      return editor.openUrl(mdTree.children![0].text!);
    // <https://example.org>
    case "Autolink": {
      const urlNode = findNodeOfType(mdTree, "URL");
      if (!urlNode) {
        return;
      }

      return editor.openUrl(urlNode.children![0].text!);
    }
    case "Image":
    case "Link": {
      const urlNode = findNodeOfType(mdTree, "URL");
      if (!urlNode) {
        return;
      }
      const url = urlNode.children![0].text!;
      if (url.length <= 1) {
        return editor.flashNotification("Empty link, ignoring", "error");
      }
      if (isLocalURL(url)) {
        const link = resolveMarkdownLink(currentPage, decodeURI(url));
        // Parse the ref explicitly to throw a nice error message
        const ref = parseToRef(link);

        if (!ref) {
          return editor.flashNotification(
            `Couldn't navigate to ${link}, Link is invalid`,
            "error",
          );
        }

        return editor.navigate(ref);
      } else {
        return editor.openUrl(url);
      }
    }
    case "Hashtag": {
      const hashtag = extractHashtag(mdTree.children![0].text!);
      await editor.navigate(
        `${tagPrefix}${hashtag}`,
        false,
        inNewWindow,
      );
      break;
    }
  }
}

export async function linkNavigate() {
  const mdTree = await markdown.parseMarkdown(await editor.getText());
  const newNode = nodeAtPos(mdTree, await editor.getCursor());
  addParentPointers(mdTree);
  await actionClickOrActionEnter(newNode);
}

export async function clickNavigate(event: ClickEvent) {
  // Navigate by default, don't navigate when Alt is held
  if (event.altKey) {
    return;
  }
  const mdTree = await markdown.parseMarkdown(await editor.getText());
  addParentPointers(mdTree);
  const newNode = nodeAtPos(mdTree, event.pos);
  await actionClickOrActionEnter(newNode, event.ctrlKey || event.metaKey);
}

export async function navigateCommand(cmdDef: any) {
  await navigateToPage(cmdDef, cmdDef.page);
}

export async function navigateToPage(_cmdDef: any, pageName: string) {
  const ref = parseToRef(pageName);
  if (!ref) {
    await editor.flashNotification(
      `Couldn't navigate to ${pageName}, page name is invalid`,
      "error",
    );
    return;
  }

  if (!ref?.details) {
    ref.details = {
      type: "position",
      pos: 0,
    };
  }

  await editor.navigate(ref);
}

export async function createPageUnderCursorCommand() {
  const mdTree = await markdown.parseMarkdown(await editor.getText());
  addParentPointers(mdTree);
  let newNode = nodeAtPos(mdTree, await editor.getCursor());
  if (!newNode) {
    await editor.flashNotification("No page link under cursor", "error");
    return;
  }
  newNode = findParentMatching(newNode, (n) => n.type === "WikiLink");
  if (!newNode) {
    await editor.flashNotification("No page link under cursor", "error");
    return;
  }
  const wikiLinkPage = findNodeOfType(newNode, "WikiLinkPage")!;
  const pageName = wikiLinkPage.children![0].text!;
  if (pageName) {
    if (await space.pageExists(pageName)) {
      await editor.flashNotification(
        "Page under cursor already exists",
        "error",
      );
    } else {
      await space.writePage(pageName, "");
      await editor.dispatch({});
      await editor.flashNotification(`Empty page ${pageName} created.`);
    }
  }
}

export async function navigateToURL(_cmdDef: any, url: string) {
  await editor.openUrl(url, false);
}

export async function navigateBack() {
  await editor.goHistory(-1);
}

export async function navigateForward() {
  await editor.goHistory(1);
}


================================================
FILE: plugs/editor/outline.ts
================================================
import { editor } from "@silverbulletmd/silverbullet/syscalls";

export async function moveItemUp() {
  const cursorPos = await editor.getCursor();
  const text = await editor.getText();

  try {
    let currentItemBounds: ReturnType<typeof determineItemBounds> | undefined;
    try {
      currentItemBounds = determineItemBounds(text, cursorPos);
    } catch {
      // If `determineItemBounds()` throws, that likely means the cursor is NOT in a bullet list,
      // so we fall back to `moveLineUp()`
      editor.moveLineUp();
      return;
    }
    let previousItemBounds: ReturnType<typeof determineItemBounds> | undefined;

    try {
      previousItemBounds = determineItemBounds(
        text,
        currentItemBounds.from - 1,
        currentItemBounds.indentLevel,
      );
      if (currentItemBounds.from === previousItemBounds.from) {
        throw new Error("Already at the top");
      }
    } catch {
      // Ok, top of the list, let's find the previous item at any other indent level and adapt
      previousItemBounds = determineItemBounds(
        text,
        currentItemBounds.from - 1,
      );
    }

    let newPreviousText = text.slice(
      previousItemBounds.from,
      previousItemBounds.to,
    );
    // If the current item is embedded inside the previous item, we need to strip it out
    if (
      currentItemBounds.from >= previousItemBounds.from &&
      currentItemBounds.to <= previousItemBounds.to
    ) {
      newPreviousText =
        text.slice(previousItemBounds.from, currentItemBounds.from) +
        text.slice(currentItemBounds.to, previousItemBounds.to);
    }

    const newText =
      ensureNewLine(text.slice(currentItemBounds.from, currentItemBounds.to)) +
      newPreviousText;
    const newCursorPos = (cursorPos - currentItemBounds.from) +
      previousItemBounds.from;

    // console.log("New replacement text", newText);

    await editor.dispatch({
      changes: [
        {
          from: Math.min(previousItemBounds.from, currentItemBounds.from),
          to: Math.max(currentItemBounds.to, previousItemBounds.to),
          insert: newText,
        },
      ],
      selection: {
        anchor: newCursorPos,
      },
    });
  } catch (e: any) {
    await editor.flashNotification(e.message, "error");
  }
}

export async function moveItemDown() {
  const cursorPos = await editor.getCursor();
  const text = await editor.getText();

  try {
    let currentItemBounds: ReturnType<typeof determineItemBounds> | undefined;
    try {
      currentItemBounds = determineItemBounds(text, cursorPos);
    } catch {
      // If `determineItemBounds()` throws, that likely means the cursor is NOT in a bullet list,
      // so we fall back to `moveLineDown()`
      editor.moveLineDown();
      return;
    }
    let nextItemBounds: ReturnType<typeof determineItemBounds> | undefined;
    try {
      nextItemBounds = determineItemBounds(
        text,
        currentItemBounds.to + 1,
        currentItemBounds.indentLevel,
      );

      if (currentItemBounds.from === nextItemBounds.from) {
        throw new Error("Already at the bottom");
      }
    } catch {
      nextItemBounds = determineItemBounds(
        text,
        currentItemBounds.to + 1,
        undefined,
        false,
      );
    }

    if (currentItemBounds.to === nextItemBounds.to) {
      throw new Error("Already at the bottom");
    }

    const nextItemText = ensureNewLine(
      text.slice(nextItemBounds.from, nextItemBounds.to),
    );
    // console.log("Next item text", nextItemText);
    const newText = nextItemText +
      text.slice(currentItemBounds.from, currentItemBounds.to);
    const newCursorPos = (cursorPos - currentItemBounds.from) +
      currentItemBounds.from + nextItemText.length;
    await editor.dispatch({
      changes: [
        {
          from: Math.min(nextItemBounds.from, currentItemBounds.from),
          to: Math.max(nextItemBounds.to, currentItemBounds.to),
          insert: newText,
        },
      ],
      selection: {
        anchor: newCursorPos,
      },
    });
  } catch (e: any) {
    await editor.flashNotification(e.message, "error");
  }
}

export async function indentItem() {
  const cursorPos = await editor.getCursor();
  const text = await editor.getText();

  try {
    const currentItemBounds = determineItemBounds(text, cursorPos);
    const itemText = text.slice(currentItemBounds.from, currentItemBounds.to);
    const newText = itemText.split("\n").map((line) =>
      line ? "  " + line : line
    ).join("\n");
    const preText = text.slice(currentItemBounds.from, cursorPos);
    const newCursorPos = cursorPos + preText.split("\n").length * 2;
    await editor.dispatch({
      changes: [
        {
          from: currentItemBounds.from,
          to: currentItemBounds.to,
          insert: newText,
        },
      ],
      selection: {
        anchor: newCursorPos,
      },
    });
  } catch (e: any) {
    await editor.flashNotification(e.message, "error");
  }
}

export async function outdentItem() {
  const cursorPos = await editor.getCursor();
  const text = await editor.getText();

  try {
    const currentItemBounds = determineItemBounds(text, cursorPos);
    const itemText = text.slice(currentItemBounds.from, currentItemBounds.to);
    if (!itemText.startsWith("  ")) {
      throw new Error("Cannot outdent further");
    }
    const newText = itemText.split("\n").map((line) =>
      line.startsWith("  ") ? line.substring(2) : line
    ).join("\n");
    const preText = text.slice(currentItemBounds.from, cursorPos);
    const newCursorPos = cursorPos - preText.split("\n").length * 2;
    await editor.dispatch({
      changes: [
        {
          from: currentItemBounds.from,
          to: currentItemBounds.to,
          insert: newText,
        },
      ],
      selection: {
        anchor: newCursorPos,
      },
    });
  } catch (e: any) {
    await editor.flashNotification(e.message, "error");
  }
}

export function ensureNewLine(s: string) {
  if (!s.endsWith("\n")) {
    return s + "\n";
  } else {
    return s;
  }
}

export function determineItemBounds(
  text: string,
  pos: number,
  minIndentLevel?: number,
  withChildren = true,
): { from: number; to: number; indentLevel: number } {
  // Find the start of the item marked with a bullet
  let currentItemStart = pos;
  let indentLevel = 0;
  while (true) {
    while (currentItemStart > 0 && text[currentItemStart - 1] !== "\n") {
      currentItemStart--;
    }
    // Check if the line is a bullet and determine the indent level
    indentLevel = 0;
    while (text[currentItemStart + indentLevel] === " ") {
      indentLevel++;
    }
    if (minIndentLevel !== undefined && indentLevel < minIndentLevel) {
      throw new Error("No item found at minimum indent level");
    }
    if (minIndentLevel !== undefined && indentLevel > minIndentLevel) {
      // Not at the desired indent level yet, let's go up another line
      currentItemStart--;
      if (currentItemStart <= 0) {
        // We've reached the top of the document, no bullet found
        throw new Error("No item found");
      }
      continue;
    }
    if (["-", "*"].includes(text[currentItemStart + indentLevel])) {
      // This is a bullet line, found it, let's break out of this loop
      break;
    } else {
      // Not a bullet line, let's go up another line
      currentItemStart--;
      if (currentItemStart <= 0) {
        // We've reached the top of the document, no bullet found
        throw new Error("No item found");
      }
    }
  }

  // Ok, so at this point we have determine the starting point of our item
  // Relevant variables are currentItemStart and indentLevel
  // Now let's find the end point
  let currentItemEnd = currentItemStart + 1;
  while (true) {
    // Let's traverse to the end of the line
    while (currentItemEnd < text.length && text[currentItemEnd - 1] !== "\n") {
      currentItemEnd++;
    }
    if (!withChildren) {
      // We're not interested in the children, so let's stop here
      break;
    }
    // Check the indent level of the next line
    let nextIndentLevel = 0;
    while (text[currentItemEnd + nextIndentLevel] === " ") {
      nextIndentLevel++;
    }
    if (nextIndentLevel <= indentLevel) {
      // This is a line indentend less than the current item, found it, let's break out of this loop
      break;
    } else {
      // Not a bullet line, let's go up another line
      currentItemEnd++;
      if (currentItemEnd >= text.length) {
        // End of the document, mark this as the end of the item
        currentItemEnd = text.length - 1;
        break;
      }
    }
  }
  return {
    from: currentItemStart,
    to: currentItemEnd,
    indentLevel,
  };
}

export async function foldCommand() {
  await editor.fold();
}

export async function unfoldCommand() {
  await editor.unfold();
}

export async function toggleFoldCommand() {
  await editor.toggleFold();
}

export async function foldAllCommand() {
  await editor.foldAll();
}

export async function unfoldAllCommand() {
  await editor.unfoldAll();
}


================================================
FILE: plugs/editor/page.ts
================================================
import { editor, space } from "@silverbulletmd/silverbullet/syscalls";
import { notFoundError } from "@silverbulletmd/silverbullet/constants";

export async function deletePage() {
  const pageName = await editor.getCurrentPage();
  if (
    !await editor.confirm(`Are you sure you would like to delete ${pageName}?`)
  ) {
    return;
  }
  // Query for last
  const recentlyOpenedPages = await editor.getRecentlyOpenedPages();
  const allPages = await space.listPages();
  const existingPageNames = new Set(allPages.map((p) => p.name));

  // Find the first recently opened page that still exists and is not the current page
  const firstRecentlyOpenedPage = recentlyOpenedPages.find(
    (page) => page.name !== pageName && existingPageNames.has(page.name),
  );
  await space.deletePage(pageName);
  console.log("Navigating to previous page");
  await editor.navigate(firstRecentlyOpenedPage?.name || "");
}

export async function copyPage(
  _def: any,
  sourcePage?: string,
  toName?: string,
) {
  const currentPage = await editor.getCurrentPage();
  const fromName = sourcePage || currentPage;
  const suggestedName = toName || fromName;

  const newName = await editor.prompt(`Copy to page:`, suggestedName);

  if (!newName) {
    return;
  }

  try {
    // This throws an error if the page does not exist, which we expect to be the case
    await space.getPageMeta(newName);
    // So when we get to this point, we error out
    throw new Error(
      `"${newName}" already exists, cannot copy to existing page.`,
    );
  } catch (e: any) {
    if (e.message === notFoundError.message) {
      // Expected not found error, so we can continue
    } else {
      await editor.flashNotification(e.message, "error");
      throw e;
    }
  }

  const text = await space.readPage(fromName);

  console.log("Writing new page to space");
  await space.writePage(newName, text);

  if (currentPage === fromName) {
    // If we're copying the current page, navigate there
    console.log("Navigating to new page");
    await editor.navigate(newName);
  } else {
    // Otherwise just notify of success
    await editor.flashNotification("Page copied successfully");
  }
}


================================================
FILE: plugs/editor/stats.ts
================================================
import { editor, space } from "@silverbulletmd/silverbullet/syscalls";

function countWords(str: string): number {
  const matches = str.match(/[\w\d\'-]+/gi);
  return matches ? matches.length : 0;
}

function readingTime(wordCount: number): number {
  // 225 is average word reading speed for adults
  return Math.ceil(wordCount / 225);
}

export async function statsCommand() {
  const text = await editor.getText();
  const allPages = await space.listPages();
  const wordCount = countWords(text);
  const time = readingTime(wordCount);
  await editor.flashNotification(
    `${text.length} characters; ${wordCount} words; ${time} minutes read; ${allPages.length} total pages in space.`,
  );
}


================================================
FILE: plugs/editor/system.ts
================================================
import {
  editor,
  markdown,
  system,
} from "@silverbulletmd/silverbullet/syscalls";

export async function parsePageCommand() {
  console.log(
    "AST",
    JSON.stringify(
      await markdown.parseMarkdown(await editor.getText()),
      null,
      2,
    ),
  );
}

export async function wipeClientCommand() {
  if (
    !await editor.confirm(
      "Are you sure you want to wipe the client? This will clean the local cache.",
    )
  ) {
    return;
  }
  await system.wipeClient();
}

export async function wipeAndLogoutCommand() {
  await system.wipeClient(true);
}

export async function reloadUICommand() {
  await editor.reloadUI();
}


================================================
FILE: plugs/editor/text.ts
================================================
import { editor } from "@silverbulletmd/silverbullet/syscalls";

export async function quoteSelection() {
  let text = await editor.getText();
  const selection = await editor.getSelection();
  let from = selection.from;
  while (from >= 0 && text[from] !== "\n") {
    from--;
  }
  from++;
  if (text[from] === ">" && text[from + 1] === " ") {
    // Already quoted, we have to unquote
    text = text.slice(from + 2, selection.to);
    text = text.replaceAll("\n> ", "\n");
  } else {
    text = text.slice(from, selection.to);
    text = `> ${text.replaceAll("\n", "\n> ")}`;
  }
  await editor.replaceRange(from, selection.to, text);
}

export async function listifySelection() {
  let text = await editor.getText();
  const selection = await editor.getSelection();

  //if very first of doc, just add a bullet and end
  if (selection.to == 0 && selection.from == 0) {
    await editor.insertAtCursor("* ");
    return;
  }

  let from = selection.from;
  if (text[from] == "\n") {
    //end of line, need to find previous line break
    from--;
  }
  while (from >= 0 && text[from] !== "\n") {
    from--;
  }
  from++;
  text = text.slice(from, selection.to);
  text = `* ${text.replaceAll(/\n(?!\n)/g, "\n* ")}`;
  await editor.replaceRange(from, selection.to, text);
}

export async function numberListifySelection() {
  let text = await editor.getText();
  const selection = await editor.getSelection();
  let from = selection.from;
  while (from >= 0 && text[from] !== "\n") {
    from--;
  }
  from++;
  text = text.slice(from, selection.to);
  let counter = 1;
  text = `1. ${
    text.replaceAll(/\n(?!\n)/g, () => {
      counter++;
      return `\n${counter}. `;
    })
  }`;
  await editor.replaceRange(from, selection.to, text);
}

export async function linkSelection() {
  const text = await editor.getText();
  const selection = await editor.getSelection();
  const textSelection = text.slice(selection.from, selection.to);
  let linkedText = `[]()`;
  let pos = 1;
  if (textSelection.length > 0) {
    try {
      new URL(textSelection);
      linkedText = `[](${textSelection})`;
    } catch {
      linkedText = `[${textSelection}]()`;
      pos = linkedText.length - 1;
    }
  }
  await editor.replaceRange(selection.from, selection.to, linkedText);
  await editor.moveCursor(selection.from + pos);
}

export function wrapSelection(cmdDef: any) {
  return insertMarker(cmdDef.wrapper);
}

async function insertMarker(marker: string) {
  const text = await editor.getText();
  const selection = await editor.getSelection();
  if (selection.from === selection.to) {
    // empty selection
    if (markerAt(selection.from)) {
      // Already there, skipping ahead
      await editor.moveCursor(selection.from + marker.length);
    } else {
      // Not there, inserting
      await editor.insertAtCursor(marker + marker);
      await editor.moveCursor(selection.from + marker.length);
    }
  } else {
    let from = selection.from;
    let to = selection.to;
    let hasMarker = markerAt(from);
    if (!markerAt(from)) {
      // Maybe just before the cursor? We'll accept that
      from = selection.from - marker.length;
      to = selection.to + marker.length;
      hasMarker = markerAt(from);
    }

    if (!hasMarker) {
      // Adding
      await editor.replaceRange(
        selection.from,
        selection.to,
        marker + text.slice(selection.from, selection.to) + marker,
      );
      await editor.setSelection(
        selection.from + marker.length,
        selection.to + marker.length,
      );
    } else {
      // Removing
      await editor.replaceRange(
        from,
        to,
        text.substring(from + marker.length, to - marker.length),
      );
      await editor.setSelection(from, to - marker.length * 2);
    }
  }

  function markerAt(pos: number) {
    for (let i = 0; i < marker.length; i++) {
      if (text[pos + i] !== marker[i]) {
        return false;
      }
    }
    return true;
  }
}


================================================
FILE: plugs/editor/upload.ts
================================================
import { editor, space, system } from "@silverbulletmd/silverbullet/syscalls";
import {
  defaultLinkStyle,
  maximumDocumentSize,
} from "@silverbulletmd/silverbullet/constants";
import { resolveMarkdownLink } from "@silverbulletmd/silverbullet/lib/resolve";
import {
  encodePageURI,
  isValidPath,
} from "@silverbulletmd/silverbullet/lib/ref";
import type { UploadFile } from "@silverbulletmd/silverbullet/type/client";

export async function saveFile(file: UploadFile) {
  const maxSize = await system.getConfig<number>(
    "maximumDocumentSize",
    maximumDocumentSize,
  );
  if (typeof maxSize !== "number") {
    await editor.flashNotification(
      "The setting 'maximumDocumentSize' must be a number",
      "error",
    );
  }
  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(
      await editor.getCurrentPath(),
      isValidPath(file.name)
        ? file.name
        : `file.${
          file.name.indexOf(".") !== -1 ? file.name.split(".").pop() : "txt"
        }`,
    ),
  );
  if (!finalFilePath || !isValidPath(finalFilePath)) {
    return;
  }

  await space.writeDocument(finalFilePath, file.content);

  if (await editor.getCurrentEditor() === "page") {
    const linkStyle = await system.getConfig(
      "defaultLinkStyle",
      defaultLinkStyle,
    );
    let documentMarkdown = "";
    if (linkStyle === "wikilink") {
      documentMarkdown = `[[${finalFilePath}]]`;
    } else {
      documentMarkdown = `[${finalFilePath}](${encodePageURI(finalFilePath)})`;
    }
    if (file.contentType.startsWith("image/")) {
      documentMarkdown = "!" + documentMarkdown;
    }
    editor.insertAtCursor(documentMarkdown);
  }
}

export async function uploadFile(_ctx: any, accept?: string, capture?: string) {
  const uploadFile = await editor.uploadFile(accept, capture);
  await saveFile(uploadFile);
}


================================================
FILE: plugs/editor/vim.ts
================================================
import { clientStore, editor } from "@silverbulletmd/silverbullet/syscalls";

export async function toggleVimMode() {
  let vimMode = await clientStore.get("vimMode");
  vimMode = !vimMode;
  await editor.setUiOption("vimMode", vimMode);
  await clientStore.set("vimMode", vimMode);
}

export async function loadVimConfig() {
  const vimMode = await editor.getUiOption("vimMode");
  if (!vimMode) {
    console.log("Not in vim mode");
    return;
  }
  try {
    await editor.save();
    await editor.reloadConfigAndCommands();
    await editor.configureVimMode();
  } catch (e: any) {
    await editor.flashNotification(e.message, "error");
  }
}


================================================
FILE: plugs/emoji/build.ts
================================================
// Generates emoji.json from emoji-data.txt
const emojiRe = /#\s([^\s]+)\s+E[^\s]+\s+(.+)$/;

const text = Deno.readTextFileSync("emoji-data.txt");
const lines = text.split("\n").filter((line) => !line.startsWith("#"));

const emojis: string[] = [];
for (const line of lines) {
  const match = emojiRe.exec(line);
  if (match) {
    const emoji = match[1];
    const name = match[2].toLowerCase().replaceAll(/\W+/g, "_");
    emojis.push(`${name} ${emoji}`);
  }
}

Deno.writeFileSync(
  "emoji.json",
  new TextEncoder().encode(JSON.stringify(emojis.join("|"))),
);


================================================
FILE: plugs/emoji/emoji.ts
================================================
import emojiBlob from "./emoji.json" with { type: "json" };
import { editor, system } from "@silverbulletmd/silverbullet/syscalls";

import type { EmojiConfig } from "../../plug-api/types/config.ts";
import type { CompleteEvent } from "@silverbulletmd/silverbullet/type/client";

let emojiConfig: EmojiConfig = { aliases: [] };

const emojis = emojiBlob.split("|").map((line) => line.split(" "));

export function emojiCompleter(
  { linePrefix, pos, parentNodes }: CompleteEvent,
) {
  updateConfig(); // no need to await, will be ready for completion by next keystrokes

  const match = /:([\w]+)$/.exec(linePrefix);
  if (!match) {
    return null;
  }

  // Check if we're not in a Lua directive or space-lua block
  if (
    parentNodes.find((node) =>
      node === "LuaDirective" || node.startsWith("FencedCode")
    )
  ) {
    return;
  }

  const [fullMatch, emojiName] = match;

  const filteredEmoji = [...emojiConfig.aliases, ...emojis].filter(
    ([shortcode]) => shortcode.includes(emojiName),
  );

  return {
    from: pos - fullMatch.length,
    filter: false,
    options: filteredEmoji.map(([shortcode, emoji]) => ({
      detail: shortcode,
      label: emoji,
      type: "emoji",
    })),
  };
}

let lastConfigUpdate = 0;

async function updateConfig() {
  // Update at most every 5 seconds
  if (Date.now() < lastConfigUpdate + 5000) return;
  lastConfigUpdate = Date.now();
  const config = await system.getConfig("emoji");
  if (!config) {
    return;
  }

  const errorMsg =
    "Emoji aliases in space config should be a map with entries 'name: 😀'";

  let aliasMap: Record<string, any> = {};
  if (config.aliases && typeof config.aliases !== "object") {
    await editor.flashNotification(errorMsg, "error");
  } else {
    aliasMap = config.aliases;
  }

  const aliases = [];
  const badAliases = [];
  for (const alias in aliasMap) {
    if (typeof aliasMap[alias] !== "string") {
      badAliases.push(alias);
      continue;
    }
    const emoji: string = aliasMap[alias].trim();
    // For detecting misconfiguration like 'smile: grinning_face' which wouldn't work.
    // Side effect: can't use for phrases like 'br: Best regards', but Slash Commands are meant for that
    if ([...emoji].find((c) => c.charCodeAt(0) <= 127)) {
      // ASCII characters somewhere in text
      badAliases.push(alias);
      continue;
    }

    aliases.push([alias, emoji]);
  }
  if (badAliases.length > 0) {
    await editor.flashNotification(
      errorMsg + `, need to fix: ${badAliases.join(",")}`,
      "error",
    );
  }

  emojiConfig = {
    aliases: aliases,
  };
}


================================================
FILE: plugs/image-viewer/viewer.ts
================================================
import { editor } from "@silverbulletmd/silverbullet/syscalls";

export function viewer(): { html: string } {
  return {
    html: `
<html>
<head>
<style>
html {
  --ui-button-background-color: #e1e1e1;
  --ui-button-text-color: black;
  --background-color: #fff;
}
html[data-theme="dark"] {
  --ui-button-background-color: #262626;
  --ui-button-text-color: white;
  --background-color: #111;
}
html, body {
  margin: 0;
  height: 100%;
}
#container {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: var(--background-color);
}
#image {
  width: 100vw;
}
#toolbar {
  position: absolute;
  bottom: 1rem;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  flex-direction: row;
  gap: 0.5rem;
}
.ui-button {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 2.5rem;
  height: 2.5rem;
  cursor: pointer;
  outline: none;
  padding: 5px;
  border: 1px solid rgb(108, 108, 108);
  background-color: var(--ui-button-background-color);
  border-radius: 5px;
  color: var(--ui-button-text-color);
}
</style>
<script type="module">
/**
* Panzoom 4.6.0 for panning and zooming elements using CSS transforms
* Copyright Timmy Willison and other contributors
* https://github.com/timmywil/panzoom/blob/main/MIT-License.txt
*/
((t,e)=>{"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Panzoom=e()})(this,function(){var a,X=function(){return(X=Object.assign||function(t){for(var e,n=1,o=arguments.length;n<o;n++)for(var r in e=arguments[n])Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r]);return t}).apply(this,arguments)},i=("undefined"!=typeof window&&(window.NodeList&&!NodeList.prototype.forEach&&(NodeList.prototype.forEach=Array.prototype.forEach),"function"!=typeof window.CustomEvent)&&(window.CustomEvent=function(t,e){e=e||{bubbles:!1,cancelable:!1,detail:null};var n=document.createEvent("CustomEvent");return n.initCustomEvent(t,e.bubbles,e.cancelable,e.detail),n}),"undefined"!=typeof document&&!!document.documentMode);var c=["webkit","moz","ms"],l={};function Y(t){if(l[t])return l[t];var e=a=a||document.createElement("div").style;if(t in e)return l[t]=t;for(var n=t[0].toUpperCase()+t.slice(1),o=c.length;o--;){var r="".concat(c[o]).concat(n);if(r in e)return l[t]=r}}function o(t,e){return parseFloat(e[Y(t)])||0}function s(t,e,n){void 0===n&&(n=window.getComputedStyle(t));t="border"===e?"Width":"";return{left:o("".concat(e,"Left").concat(t),n),right:o("".concat(e,"Right").concat(t),n),top:o("".concat(e,"Top").concat(t),n),bottom:o("".concat(e,"Bottom").concat(t),n)}}function C(t,e,n){t.style[Y(e)]=n}function N(t){var e=t.parentNode,n=window.getComputedStyle(t),o=window.getComputedStyle(e),r=t.getBoundingClientRect(),a=e.getBoundingClientRect();return{elem:{style:n,width:r.width,height:r.height,top:r.top,bottom:r.bottom,left:r.left,right:r.right,margin:s(t,"margin",n),border:s(t,"border",n)},parent:{style:o,width:a.width,height:a.height,top:a.top,bottom:a.bottom,left:a.left,right:a.right,padding:s(e,"padding",o),border:s(e,"border",o)}}}var T={down:"mousedown",move:"mousemove",up:"mouseup mouseleave"};function L(t,e,n,o){T[t].split(" ").forEach(function(t){e.addEventListener(t,n,o)})}function V(t,e,n){T[t].split(" ").forEach(function(t){e.removeEventListener(t,n)})}function G(t,e){for(var n=t.length;n--;)if(t[n].pointerId===e.pointerId)return n;return-1}function I(t,e){if(e.touches)for(var n=0,o=0,r=e.touches;o<r.length;o++){var a=r[o];a.pointerId=n++,I(t,a)}else-1<(n=G(t,e))&&t.splice(n,1),t.push(e)}function R(t){for(var e,n=(t=t.slice(0)).pop();e=t.pop();)n={clientX:(e.clientX-n.clientX)/2+n.clientX,clientY:(e.clientY-n.clientY)/2+n.clientY};return n}function W(t){var e;return t.length<2?0:(e=t[0],t=t[1],Math.sqrt(Math.pow(Math.abs(t.clientX-e.clientX),2)+Math.pow(Math.abs(t.clientY-e.clientY),2)))}"undefined"!=typeof window&&("function"==typeof window.PointerEvent?T={down:"pointerdown",move:"pointermove",up:"pointerup pointerleave pointercancel"}:"function"==typeof window.TouchEvent&&(T={down:"touchstart",move:"touchmove",up:"touchend touchcancel"}));var Z=/^http:[\w\.\/]+svg$/;var q={animate:!1,canvas:!1,cursor:"move",disablePan:!1,disableZoom:!1,disableXAxis:!1,disableYAxis:!1,duration:200,easing:"ease-in-out",exclude:[],excludeClass:"panzoom-exclude",handleStartEvent:function(t){t.preventDefault(),t.stopPropagation()},maxScale:4,minScale:.125,overflow:"hidden",panOnlyWhenZoomed:!1,pinchAndPan:!1,relative:!1,setTransform:function(t,e,n){var o=e.x,r=e.y,a=e.isSVG;C(t,"transform","scale(".concat(e.scale,") translate(").concat(o,"px, ").concat(r,"px)")),a&&i&&(e=window.getComputedStyle(t).getPropertyValue("transform"),t.setAttribute("transform",e))},startX:0,startY:0,startScale:1,step:.3,touchAction:"none"};function t(u,f){if(!u)throw new Error("Panzoom requires an element as an argument");if(1!==u.nodeType)throw new Error("Panzoom requires an element with a nodeType of 1");if(!(t=>{for(var e=t;e&&e.parentNode;){if(e.parentNode===document)return 1;e=e.parentNode instanceof ShadowRoot?e.parentNode.host:e.parentNode}})(u))throw new Error("Panzoom should be called on elements that have been attached to the DOM");f=X(X({},q),f);t=u;var t,l=Z.test(t.namespaceURI)&&"svg"!==t.nodeName.toLowerCase(),n=u.parentNode;n.style.overflow=f.overflow,n.style.userSelect="none",n.style.touchAction=f.touchAction,(f.canvas?n:u).style.cursor=f.cursor,u.style.userSelect="none",u.style.touchAction=f.touchAction,C(u,"transformOrigin","string"==typeof f.origin?f.origin:l?"0 0":"50% 50%");var r,a,i,c,s,d,m=0,h=0,v=1,p=!1;function g(t,e,n){n.silent||(n=new CustomEvent(t,{detail:e}),u.dispatchEvent(n))}function y(o,r,t){var a={x:m,y:h,scale:v,isSVG:l,originalEvent:t};return requestAnimationFrame(function(){var t,e,n;"boolean"==typeof r.animate&&(r.animate?(t=u,e=r,n=Y("transform"),C(t,"transition","".concat(n," ").concat(e.duration,"ms ").concat(e.easing))):C(u,"transition","none")),r.setTransform(u,a,r),g(o,a,r),g("panzoomchange",a,r)}),a}function w(t,e,n,o){var r,a,i,c,l,s,d,o=X(X({},f),o),p={x:m,y:h,opts:o};return!o.force&&(o.disablePan||o.panOnlyWhenZoomed&&v===o.startScale)||(t=parseFloat(t),e=parseFloat(e),o.disableXAxis||(p.x=(o.relative?m:0)+t),o.disableYAxis||(p.y=(o.relative?h:0)+e),o.contain&&(e=((r=(e=(t=N(u)).elem.width/v)*n)-e)/2,i=((a=(i=t.elem.height/v)*n)-i)/2,"inside"===o.contain?(c=(-t.elem.margin.left-t.parent.padding.left+e)/n,l=(t.parent.width-r-t.parent.padding.left-t.elem.margin.left-t.parent.border.left-t.parent.border.right+e)/n,p.x=Math.max(Math.min(p.x,l),c),s=(-t.elem.margin.top-t.parent.padding.top+i)/n,d=(t.parent.height-a-t.parent.padding.top-t.elem.margin.top-t.parent.border.top-t.parent.border.bottom+i)/n,p.y=Math.max(Math.min(p.y,d),s)):"outside"===o.contain&&(c=(-(r-t.parent.width)-t.parent.padding.left-t.parent.border.left-t.parent.border.right+e)/n,l=(e-t.parent.padding.left)/n,p.x=Math.max(Math.min(p.x,l),c),s=(-(a-t.parent.height)-t.parent.padding.top-t.parent.border.top-t.parent.border.bottom+i)/n,d=(i-t.parent.padding.top)/n,p.y=Math.max(Math.min(p.y,d),s))),o.roundPixels&&(p.x=Math.round(p.x),p.y=Math.round(p.y))),p}function b(t,e){var n,o,r,a,e=X(X({},f),e),i={scale:v,opts:e};return!e.force&&e.disableZoom||(n=f.minScale,o=f.maxScale,e.contain&&(a=(e=N(u)).elem.width/v,r=e.elem.height/v,1<a)&&1<r&&(a=(e.parent.width-e.parent.border.left-e.parent.border.right)/a,e=(e.parent.height-e.parent.border.top-e.parent.border.bottom)/r,"inside"===f.contain?o=Math.min(o,a,e):"outside"===f.contain&&(n=Math.max(n,a,e))),i.scale=Math.min(Math.max(t,n),o)),i}function x(t,e,n,o){t=w(t,e,v,n);return m!==t.x||h!==t.y?(m=t.x,h=t.y,y("panzoompan",t.opts,o)):{x:m,y:h,scale:v,isSVG:l,originalEvent:o}}function S(t,e,n){var o,r,e=b(t,e),a=e.opts;if(a.force||!a.disableZoom)return t=e.scale,e=m,o=h,a.focal&&(e=((r=a.focal).x/t-r.x/v+m*t)/t,o=(r.y/t-r.y/v+h*t)/t),r=w(e,o,t,{relative:!1,force:!0}),m=r.x,h=r.y,v=t,y("panzoomzoom",a,n)}function e(t,e){e=X(X(X({},f),{animate:!0}),e);return S(v*Math.exp((t?1:-1)*e.step),e)}function E(t,e,n,o){var r=N(u),a=r.parent.width-r.parent.padding.left-r.parent.padding.right-r.parent.border.left-r.parent.border.right,i=r.parent.height-r.parent.padding.top-r.parent.padding.bottom-r.parent.border.top-r.parent.border.bottom,c=e.clientX-r.parent.left-r.parent.padding.left-r.parent.border.left-r.elem.margin.left,e=e.clientY-r.parent.top-r.parent.padding.top-r.parent.border.top-r.elem.margin.top,r=(l||(c-=r.elem.width/v/2,e-=r.elem.height/v/2),{x:c/a*(a*t),y:e/i*(i*t)});return S(t,X(X({},n),{animate:!1,focal:r}),o)}S(f.startScale,{animate:!1,force:!0}),setTimeout(function(){x(f.startX,f.startY,{animate:!1,force:!0})});var M=[];function o(t){((t,e)=>{for(var n,o,r=t;null!=r;r=r.parentNode)if(n=r,o=e.excludeClass,1===n.nodeType&&-1<" ".concat((n.getAttribute("class")||"").trim()," ").indexOf(" ".concat(o," "))||-1<e.exclude.indexOf(r))return 1})(t.target,f)||(I(M,t),p=!0,f.handleStartEvent(t),g("panzoomstart",{x:r=m,y:a=h,scale:v,isSVG:l,originalEvent:t},f),t=R(M),i=t.clientX,c=t.clientY,s=v,d=W(M))}function A(t){var e,n,o;p&&void 0!==r&&void 0!==a&&void 0!==i&&void 0!==c&&(I(M,t),e=R(M),n=1<M.length,o=v,n&&(0===d&&(d=W(M)),E(o=b((W(M)-d)*f.step/80+s).scale,e,{animate:!1},t)),n&&!f.pinchAndPan||x(r+(e.clientX-i)/o,a+(e.clientY-c)/o,{animate:!1},t))}function P(t){1===M.length&&g("panzoomend",{x:m,y:h,scale:v,isSVG:l,originalEvent:t},f);var e=M;if(t.touches)for(;e.length;)e.pop();else{t=G(e,t);-1<t&&e.splice(t,1)}p&&(p=!1,r=a=i=c=void 0)}var O=!1;function z(){O||(O=!0,L("down",f.canvas?n:u,o),L("move",document,A,{passive:!0}),L("up",document,P,{passive:!0}))}return f.noBind||z(),{bind:z,destroy:function(){O=!1,V("down",f.canvas?n:u,o),V("move",document,A),V("up",document,P)},eventNames:T,getPan:function(){return{x:m,y:h}},getScale:function(){return v},getOptions:function(){var t,e=f,n={};for(t in e)e.hasOwnProperty(t)&&(n[t]=e[t]);return n},handleDown:o,handleMove:A,handleUp:P,pan:x,reset:function(t){var t=X(X(X({},f),{animate:!0,force:!0}),t),e=(v=b(t.startScale,t).scale,w(t.startX,t.startY,v,t));return m=e.x,h=e.y,y("panzoomreset",t)},resetStyle:function(){n.style.overflow="",n.style.userSelect="",n.style.touchAction="",n.style.cursor="",u.style.cursor="",u.style.userSelect="",u.style.touchAction="",C(u,"transformOrigin","")},setOptions:function(t){for(var e in t=void 0===t?{}:t)t.hasOwnProperty(e)&&(f[e]=t[e]);(t.hasOwnProperty("cursor")||t.hasOwnProperty("canvas"))&&(n.style.cursor=u.style.cursor="",(f.canvas?n:u).style.cursor=f.cursor),t.hasOwnProperty("overflow")&&(n.style.overflow=t.overflow),t.hasOwnProperty("touchAction")&&(n.style.touchAction=t.touchAction,u.style.touchAction=t.touchAction)},setStyle:function(t,e){return C(u,t,e)},zoom:S,zoomIn:function(t){return e(!0,t)},zoomOut:function(t){return e(!1,t)},zoomToPoint:E,zoomWithWheel:function(t,e){t.preventDefault();var e=X(X(X({},f),e),{animate:!1}),n=0===t.deltaY&&t.deltaX?t.deltaX:t.deltaY;return E(b(v*Math.exp((n<0?1:-1)*e.step/3),e).scale,t,e,t)}}}return t.defaultOptions=q,t});

let imageRotation = 0;
let info = undefined;

const image = document.getElementById("image");
const element = document.getElementById("panzoom");

const panzoom = Panzoom(element, {
  maxScale: 5,
  startScale: 0.5,
});

image.parentElement.addEventListener("wheel", panzoom.zoomWithWheel);

function openFile(data, extension) {
  imageRotation = 0;

  const FileToMime = {
    "jpg": "image/jpg",
    "jpeg": "image/jpg",
    "png": "image/png",
    "gif": "image/gif",
    "svg": "image/svg+xml",
    "webp": "image/webp",
    "avif": "image/avif",
    "apng": "image/apng"
  }

  const img = document.getElementById("image");

  img.src = URL.createObjectURL(
    new Blob([data], { type: FileToMime[extension] })
  );

  img.addEventListener("load", () => {
    info = {
      type: FileToMime[extension],
      size: data.length,
      width: img.naturalWidth,
      height: img.naturalHeight
    };
  });
}

globalThis.silverbullet.addEventListener("file-open", (event) => openFile(event.detail.data, event.detail.meta.extension));
globalThis.silverbullet.addEventListener("file-update", (event) => openFile(event.detail.data, event.detail.meta.extension));

function updateRotation() {
  image.style.transform = "rotate(" + imageRotation + "deg)"
}

document.getElementById("rotateLeft").addEventListener("click", () => {
  imageRotation -= 90;
  updateRotation();
});

document.getElementById("rotateRight").addEventListener("click", () => {
  imageRotation += 90;
  updateRotation();
});

document.getElementById("zoomIn").addEventListener("click", () => {
  panzoom.zoomIn();
});

document.getElementById("zoomOut").addEventListener("click", () => {
  panzoom.zoomOut();
});

globalThis.silverbullet.addEventListener("flash-info", () => {
  if (!info) return;

  globalThis.silverbullet.syscall("editor.flashNotification", \`Type: \${info.type}; Size: \${Math.round(info.size / 1000)}kB; Dimensions: \${info.width}x\${info.height}\`);
});
</script>
</head>
<body>
<div id="container">
  <div id="panzoom">
    <img id="image"></img>
  </div>

  <div id="toolbar">
    <button class="ui-button" id="rotateLeft">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg>
    </button>
    <button class="ui-button" id="rotateRight">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
    </button>
    <button class="ui-button" id="zoomOut">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line><line x1="8" y1="11" x2="14" y2="11"></line></svg>
    </button>
    <button class="ui-button" id="zoomIn">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line><line x1="11" y1="8" x2="11" y2="14"></line><line x1="8" y1="11" x2="14" y2="11"></line></svg>
    </button>
  <div/>
</div>
</body>
</html>
`,
  };
}

export function showInfoMessage() {
  editor.sendMessage("flash-info");
}


================================================
FILE: plugs/index/api.ts
================================================
import { index, markdown } from "@silverbulletmd/silverbullet/syscalls";
import {
  extractFrontMatter as extractFrontmatterFromTree,
  type FrontMatter,
  type FrontMatterExtractOptions,
} from "./frontmatter.ts";
import {
  collectNodesOfType,
  renderToText,
} from "@silverbulletmd/silverbullet/lib/tree";
import { applyPatches, type YamlPatch } from "../../plug-api/lib/yaml.ts";
import type { LuaCollectionQuery } from "../../client/space_lua/query_collection.ts";
import type { ObjectValue } from "@silverbulletmd/silverbullet/type/index";

/*
 * Key namespace:
 * [indexKey, type, ...key, page] -> value
 * [pageKey, page, ...key] -> true // for fast page clearing
 * ["type", type] -> true // for fast type listing
 */

export async function extractFrontmatter(
  text: string,
  extractOptions: FrontMatterExtractOptions = {},
): Promise<{ frontmatter: FrontMatter; text: string }> {
  const tree = await markdown.parseMarkdown(text);
  const frontmatter = extractFrontmatterFromTree(tree, extractOptions);
  return { frontmatter, text: renderToText(tree) };
}

export async function patchFrontmatter(
  text: string,
  patches: YamlPatch[],
): Promise<string> {
  const tree = await markdown.parseMarkdown(text);
  const frontmatter = collectNodesOfType(tree, "FrontMatter");
  if (frontmatter.length === 0) {
    // No frontmatter found, create from patches
    const patchedFrontmatter = applyPatches("", patches).trim();
    if (patchedFrontmatter) {
      return "---\n" + patchedFrontmatter + "\n---\n" + text;
    } else {
      return text;
    }
  } else {
    // Existing frontmatter found, patch it
    const frontmatterText = renderToText(frontmatter[0].children![1]);
    const patchedFrontmatter = applyPatches(frontmatterText, patches).trim();

    if (patchedFrontmatter) {
      // Replace the frontmatter with the patched frontmatter in the original string
      return "---\n" + patchedFrontmatter + "\n---" +
        text.slice(frontmatter[0].to);
    } else {
      // Nothing left, let's just return the text content
      return text.slice(frontmatter[0].to! + 1); // +1 to skip the initial \n
    }
  }
}

// DEPRECATED: use index.queryLuaObjects directly
export function queryLuaObjects<T>(
  tag: string,
  query: LuaCollectionQuery,
  scopedVariables?: Record<string, any>,
): Promise<ObjectValue<T>[]> {
  return index.queryLuaObjects(tag, query, scopedVariables);
}

// DEPRECATED: use index.getObjectByRef directly
export function getObjectByRef<T>(
  page: string,
  tag: string,
  ref: string,
): Promise<ObjectValue<T> | undefined> {
  return index.getObjectByRef(page, tag, ref);
}


================================================
FILE: plugs/index/attribute.ts
================================================
import {
  findNodeOfType,
  type ParseTree,
  replaceNodesMatching,
  traverseTree,
} from "@silverbulletmd/silverbullet/lib/tree";

import { cleanupJSON } from "@silverbulletmd/silverbullet/lib/json";

import YAML from "js-yaml";

/**
 * Collects all attributes from a parse tree
 * @param tree to clean attributes from
 * @return
 */
export function collectAttributes(
  tree: ParseTree,
): Record<string, any> {
  const attributes: Record<string, any> = {};

  traverseTree(tree, (n) => {
    if (tree !== n && n.type === "ListItem") {
      // Find top-level only, no nested lists
      return true;
    }
    if (n.type === "Attribute") {
      const nameNode = findNodeOfType(n, "AttributeName");
      const valueNode = findNodeOfType(n, "AttributeValue");
      if (nameNode && valueNode) {
        const name = nameNode.children![0].text!;
        const val = valueNode.children![0].text!;
        try {
          attributes[name] = cleanupJSON(YAML.load(val));
        } catch (e: any) {
          console.error("Error parsing attribute value as YAML", val, e);
        }
      }
      return true;
    }
    return false;
  });

  return attributes;
}

/**
 * Cleans attributes from a tree (as a side effect)
 * @param tree to clean attributes from
 */
export function cleanAttributes(tree: ParseTree) {
  replaceNodesMatching(tree, (n) => {
    if (n.type === "Attribute") {
      return null;
    }
    return;
  });
}


================================================
FILE: plugs/index/cheap_yaml.test.ts
================================================
import { assertEquals } from "@std/assert";
import { determineTags } from "./cheap_yaml.ts";

Deno.test("cheap yaml", () => {
  assertEquals([], determineTags(""));
  assertEquals([], determineTags("hank: bla"));
  assertEquals(["template"], determineTags("tags: template"));
  assertEquals(["bla", "template"], determineTags("tags: bla,template"));
  assertEquals(["bla", "template"], determineTags("tags:\n- bla\n- template"));
  assertEquals(["bla", "template"], determineTags(`tags: "#bla,#template"`));
  assertEquals(["bla", "template"], determineTags(`tags: '#bla, #template'`));
  assertEquals(
    ["bla", "template"],
    determineTags(`tags:\n- "#bla"\n- template`),
  );
});


================================================
FILE: plugs/index/cheap_yaml.ts
================================================
const yamlKvRegex = /^\s*(\w+):\s*["']?([^'"]*)["']?$/;
const yamlListItemRegex = /^\s*-\s+["']?([^'"]+)["']?$/;

/**
 * Cheap YAML parser to determine tags (ugly, regex based but fast) and fault tolerant
 * @param yamlText
 * @returns
 */
export function determineTags(yamlText: string): string[] {
  const lines = yamlText.split("\n");
  let inTagsSection = false;
  const tags: string[] = [];
  for (const line of lines) {
    const yamlKv = yamlKvRegex.exec(line);
    if (yamlKv) {
      const [key, value] = yamlKv.slice(1);
      // Looking for a 'tags' key
      if (key === "tags") {
        inTagsSection = true;
        // 'template' there? Yay!
        if (value) {
          tags.push(
            ...value.split(/,\s*|\s+/).map((t) => t.replace(/^#/, "")),
          );
        }
      } else {
        inTagsSection = false;
      }
    }
    const yamlListem = yamlListItemRegex.exec(line);
    if (yamlListem && inTagsSection) {
      tags.push(yamlListem[1].replace(/^#/, ""));
    }
  }
  return tags;
}


================================================
FILE: plugs/index/command.ts
================================================
import { editor, index } from "@silverbulletmd/silverbullet/syscalls";

export async function reindexCommand() {
  await editor.flashNotification("Performing full page reindex...");
  await index.reindexSpace();
  await editor.flashNotification("Done with page index!");
}


================================================
FILE: plugs/index/complete.ts
================================================
import { config } from "@silverbulletmd/silverbullet/syscalls";
import type { CompleteEvent } from "@silverbulletmd/silverbullet/type/client";
import type { Completion } from "@codemirror/autocomplete";

export async function attributeCompletion(
  tags: string[],
): Promise<Completion[]> {
  const completions: Completion[] = [];

  for (const tag of tags) {
    const schema = await config.get<any>(["tags", tag, "schema"], null);
    if (!schema) {
      continue;
    }
    for (const [attr, val] of Object.entries(schema.properties)) {
      const def = val as any;

      if (def.readOnly) {
        continue;
      }

      completions.push({
        label: attr,
        type: `attribute`,
        detail: `for #${tag} (type: ${humanReadableSchemaType(def)})${
          def.description ? ": " + def.description : ""
        }`,
        apply: `${attr}: `,
      });
    }
  }

  return completions;
}

function humanReadableSchemaType(type: any): string {
  if (!type) {
    return "any";
  }
  if (type.type) {
    return type.type;
  }
  if (type.anyOf) {
    return type.anyOf.map(humanReadableSchemaType).join(" | ");
  }
  return "any";
}

/**
 * Task state completion
 */
export async function completeTaskState(completeEvent: CompleteEvent) {
  const taskMatch = /([\-\*]\s+\[)([^\[\]]+)$/.exec(
    completeEvent.linePrefix,
  );
  if (!taskMatch) {
    return null;
  }
  const allStates = Object.keys(await config.get("taskStates", {}));

  return {
    from: completeEvent.pos - taskMatch[2].length,
    options: allStates.map((state) => ({
      label: state,
    })),
  };
}


================================================
FILE: plugs/index/constants.ts
================================================
// This page is actually implemented in Library/Std/Tag.md
export const tagPrefix = "tag:";


================================================
FILE: plugs/index/data.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import { extractFrontMatter } from "./frontmatter.ts";
import { assertEquals } from "@std/assert";
import { indexData } from "./data.ts";

const testPage = `
Some test

\`\`\`#superduper
name: Pete
age: 100
\`\`\`

\`\`\`#superduper
name: Hank
age: 101
\`\`\`
`.trim();

Deno.test("Test indexers", async () => {
  createMockSystem();
  const tree = parseMarkdown(testPage);
  const frontmatter = extractFrontMatter(tree);

  const pageMeta: PageMeta = {
    ref: "test",
    name: "test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };

  const datas: ObjectValue<any>[] = await indexData(
    pageMeta,
    frontmatter,
    tree,
  );
  // 1 data 1 tag
  assertEquals(datas.length, 3);

  // data object first
  assertEquals(datas[0].tag, "superduper");
  assertEquals(datas[0].itags, ["superduper", "data"]);
  assertEquals(datas[0].name, "Pete");
  assertEquals(datas[0].age, 100);

  // data object first
  assertEquals(datas[1].tag, "superduper");
  assertEquals(datas[1].itags, ["superduper", "data"]);
  assertEquals(datas[1].name, "Hank");
  assertEquals(datas[1].age, 101);
});


================================================
FILE: plugs/index/data.ts
================================================
import YAML from "js-yaml";
import {
  collectNodesOfType,
  findNodeOfType,
  type ParseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import type { TagObject } from "./tags.ts";
import type { FrontMatter } from "./frontmatter.ts";
import { updateITags } from "./tags.ts";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";

type DataObject = ObjectValue<
  {
    pos: number;
    page: string;
  } & Record<string, any>
>;

export function indexData(
  pageMeta: PageMeta,
  frontmatter: FrontMatter,
  tree: ParseTree,
) {
  const separator = "---";
  const dataObjects: ObjectValue<DataObject>[] = [];
  const tagObjects: Map<string, ObjectValue<TagObject>> = new Map();

  collectNodesOfType(tree, "FencedCode").map((t) => {
    const codeInfoNode = findNodeOfType(t, "CodeInfo");
    if (!codeInfoNode) {
      return;
    }
    const fenceType = codeInfoNode.children![0].text!;
    if (fenceType !== "data" && !fenceType.startsWith("#")) {
      return;
    }
    const codeTextNode = findNodeOfType(t, "CodeText");
    if (!codeTextNode) {
      // Honestly, this shouldn't happen
      return;
    }
    const codeText = codeTextNode.children![0].text!;
    const dataType = fenceType === "data" ? "data" : fenceType.substring(1);
    try {
      const docs = codeText.split(separator);
      let cursor = 0;
      // We support multiple YAML documents in one block
      for (let i = 0; i < docs.length; i++) {
        const docStart = t.from! + cursor;
        const docEnd = docStart + docs[i].length;
        const doc = YAML.load(docs[i]);
        if (!doc) {
          cursor += docs[i].length;
          continue;
        }
        const dataObj = {
          ref: `${pageMeta.name}@${docStart}`,
          tag: dataType,
          itags: ["data"],
          pos: docStart,
          range: [docStart, docEnd],
          ...doc,
          page: pageMeta.name,
        };
        updateITags(dataObj, frontmatter);
        dataObjects.push(dataObj);
        cursor += docs[i].length;
      }
      tagObjects.set(dataType, {
        ref: dataType,
        tag: "tag",
        name: dataType,
        page: pageMeta.name,
        parent: "data",
      });
    } catch (e) {
      console.error("Could not parse data", codeText, "error:", e);
      return;
    }
  });

  return Promise.resolve([...dataObjects, ...tagObjects.values()]);
}


================================================
FILE: plugs/index/document.ts
================================================
import { index, space } from "@silverbulletmd/silverbullet/syscalls";
import type { DocumentMeta } from "@silverbulletmd/silverbullet/type/index";

const nonDocumentFileExtensions = [".md", ".plug.js", ".js.map"];

// Note: clearFileIndex is not called but since this is the only document:index listener, this should be fine (famous last words)
export async function indexDocument(name: string) {
  // Check if path doesn't end with a non-document file extension
  if (nonDocumentFileExtensions.find((ext) => name.endsWith(ext))) {
    return;
  }

  const fileMeta = await space.getDocumentMeta(name);
  await index.indexObjects<DocumentMeta>(fileMeta.name, [fileMeta]);
}


================================================
FILE: plugs/index/frontmatter.ts
================================================
import {
  addParentPointers,
  findNodeOfType,
  findParentMatching,
  type ParseTree,
  renderToText,
  replaceNodesMatching,
} from "@silverbulletmd/silverbullet/lib/tree";
import { cleanupJSON } from "@silverbulletmd/silverbullet/lib/json";
import YAML from "js-yaml";
import { extractHashtag } from "@silverbulletmd/silverbullet/lib/tags";
import type { CompleteEvent } from "@silverbulletmd/silverbullet/type/client";
import { determineTags } from "./cheap_yaml.ts";
import { attributeCompletion } from "./complete.ts";

export type FrontMatter = {
  tags?: string[];
  // Location in the document where the frontmatter appears
  range?: [number, number];
} & Record<string, any>;

export type FrontMatterExtractOptions = {
  removeKeys?: string[];
  removeTags?: string[] | true;
  removeFrontMatterSection?: boolean;
};

/**
 * Extracts frontmatter from a markdown tree, as well as extracting tags and attributes that are to apply to the page
 * optionally removes certain keys from the front matter
 * Side effect: adds parent pointers to tree
 */
export function extractFrontMatter(
  tree: ParseTree,
  options: FrontMatterExtractOptions = {},
): FrontMatter {
  addParentPointers(tree);
  let frontmatter: FrontMatter = {
    tags: [],
  };
  const tags: string[] = [];

  replaceNodesMatching(tree, (t) => {
    // Find tags in paragraphs directly nested under the document where the only content is tags
    if (t.type === "Paragraph" && t.parent?.type === "Document") {
      let onlyTags = true;
      const collectedTags = new Set<string>();
      for (const child of t.children!) {
        if (child.text) {
          if (child.text.startsWith("\n") && child.text !== "\n") {
            // Multi line paragraph, cut it off here
            break;
          }
          if (child.text.trim()) {
            // Text node with actual text (not just whitespace): not a page tag line!
            onlyTags = false;
            break;
          }
        } else if (child.type === "Hashtag") {
          const tagname = extractHashtag(child.children![0].text!);
          collectedTags.add(tagname);

          if (
            options.removeTags === true || options.removeTags?.includes(tagname)
          ) {
            // Ugly hack to remove the hashtag
            child.children![0].text = "";
          }
        } else if (child.type) {
          // Found something else than tags, so... nope!
          onlyTags = false;
          break;
        }
      }
      if (onlyTags) {
        tags.push(...collectedTags);
      }
    }
    // Find FrontMatter and parse it
    if (t.type === "FrontMatter") {
      const yamlNode = t.children![1].children![0];
      const yamlText = renderToText(yamlNode);
      frontmatter.range = [t.from!, t.to!];
      try {
        const parsedData: any = cleanupJSON(YAML.load(yamlText));
        // console.log("Parsed front matter", parsedData);
        const newData = { ...parsedData };
        frontmatter = { ...frontmatter, ...parsedData };
        // Make sure we have a tags array
        if (!frontmatter.tags) {
          frontmatter.tags = [];
        }
        // Normalize tags to an array
        // support "tag1, tag2" as well as "tag1 tag2" as well as "#tag1 #tag2" notations
        if (typeof frontmatter.tags === "string") {
          tags.push(...(frontmatter.tags as string).split(/,\s*|\s+/));
        }
        if (Array.isArray(frontmatter.tags)) {
          tags.push(...frontmatter.tags);
        }

        if (options.removeKeys && options.removeKeys.length > 0) {
          let removedOne = false;

          for (const key of options.removeKeys) {
            if (key in newData) {
              delete newData[key];
              removedOne = true;
            }
          }
          if (removedOne) {
            yamlNode.text = YAML.stringify(newData);
          }
        }
        // If nothing is left, let's just delete this whole block
        if (
          Object.keys(newData).length === 0 || options.removeFrontMatterSection
        ) {
          return null;
        }
      } catch {
        // console.warn("Could not parse frontmatter", e.message);
      }
    }
    if (t.type === "Attribute") {
      if (findParentMatching(t, (n) => n.type === "ListItem")) {
        return;
      }
      const nameNode = findNodeOfType(t, "AttributeName");
      const valueNode = findNodeOfType(t, "AttributeValue");
      if (nameNode && valueNode) {
        const name = nameNode.children![0].text!;
        const val = valueNode.children![0].text!;
        try {
          frontmatter[name] = cleanupJSON(YAML.load(val));
        } catch (e: any) {
          console.error("Error parsing attribute value as YAML", val, e);
        }
      }
      return;
    }

    return;
  });

  try {
    frontmatter.tags = [
      ...new Set([...tags.map((t) => {
        // Always treat tags as strings
        const tagAsString = String(t);
        // Strip # from tags
        return tagAsString.replace(/^#/, "");
      })]),
    ];
  } catch (e) {
    console.error("Error while processing tags", e);
  }

  // Expand property names (e.g. "foo.bar" => { foo: { bar: true } })
  frontmatter = cleanupJSON(frontmatter);

  return frontmatter;
}

const attributeRegex = /^[\w\-_]+$/;

export async function frontmatterComplete(completeEvent: CompleteEvent) {
  const frontmatterCode = completeEvent.parentNodes.find((nt) =>
    nt.startsWith("FrontMatter:")
  );

  if (!frontmatterCode) {
    return;
  }

  const attributeName = completeEvent.linePrefix;

  if (!attributeRegex.exec(attributeName)) {
    return;
  }

  const tags = determineTags(frontmatterCode);
  tags.push("page");

  return {
    from: completeEvent.pos - attributeName.length,
    options: await attributeCompletion(tags),
  };
}


================================================
FILE: plugs/index/header.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import { extractFrontMatter } from "./frontmatter.ts";
import { assertEquals } from "@std/assert";
import { indexHeaders } from "./header.ts";

const testPage = `
# Header 1
## Header 1.1
# Header 2 [testAttr: 10]
`.trim();

Deno.test("Test header indexing", async () => {
  createMockSystem();
  const tree = parseMarkdown(testPage);
  const frontmatter = await extractFrontMatter(tree);

  const pageMeta: PageMeta = {
    ref: "test",
    name: "test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };

  const headers = await indexHeaders(
    pageMeta,
    frontmatter,
    tree,
  );
  // 1 data 1 tag
  assertEquals(headers.length, 3);

  assertEquals(headers[0].name, "Header 1");
  assertEquals(headers[0].level, 1);
  assertEquals(headers[1].name, "Header 1.1");
  assertEquals(headers[1].level, 2);
});


================================================
FILE: plugs/index/header.ts
================================================
import {
  cloneTree,
  collectNodesMatching,
  type ParseTree,
  renderToText,
} from "@silverbulletmd/silverbullet/lib/tree";
import {
  getNameFromPath,
  parseToRef,
} from "@silverbulletmd/silverbullet/lib/ref";
import { index, lua } from "@silverbulletmd/silverbullet/syscalls";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import type { CompleteEvent } from "@silverbulletmd/silverbullet/type/client";
import type { FrontMatter } from "./frontmatter.ts";
import { cleanAttributes, collectAttributes } from "./attribute.ts";
import { cleanTags, collectTags } from "./tags.ts";

type HeaderObject = ObjectValue<
  {
    name: string;
    text: string;
    page: string;
    level: number;
    pos: number;
  } & Record<string, any>
>;

export function indexHeaders(
  pageMeta: PageMeta,
  _frontmatter: FrontMatter,
  tree: ParseTree,
): Promise<HeaderObject[]> {
  const headers: ObjectValue<HeaderObject>[] = [];

  for (
    const n of collectNodesMatching(
      tree,
      (t) => !!t.type?.startsWith("ATXHeading"),
    )
  ) {
    const level = +n.type!.substring("ATXHeading".length);
    const name = renderToText(n).slice(level + 1);
    const tags = collectTags(n);
    const attributes = collectAttributes(n);
    const nClone = cloneTree(n);
    cleanTags(nClone);
    cleanAttributes(nClone);
    const text = renderToText(nClone).slice(level + 1);

    headers.push({
      ref: `${pageMeta.name}@${n.from}`,
      tag: "header",
      tags: [...tags],
      level,
      name,
      text,
      page: pageMeta.name,
      pos: n.from!,
      range: [n.from!, n.to!],
      ...attributes,
    });
  }

  return Promise.resolve(headers);
}

export async function headerComplete(completeEvent: CompleteEvent) {
  const match = /(?:\[\[|\[.*?\]\()(?<path>[^\[]*)$/.exec(
    completeEvent.linePrefix,
  );
  if (!match || !match.groups?.path) {
    return;
  }

  const ref = parseToRef(match.groups.path);
  if (!ref || ref.details?.type !== "header") {
    return;
  }

  const headers = await index.queryLuaObjects<HeaderObject>(
    "header",
    {
      objectVariable: "_",
      where: await lua.parseExpression(`_.page == name`),
    },
    { name: getNameFromPath(ref.path) || completeEvent.pageName },
  );

  return {
    from: completeEvent.pos - match.groups.path.length,
    options: headers.map((header) => ({
      label: `${
        (ref.meta ? "^" : "") + getNameFromPath(ref.path)
      }#${header.name}`,
      type: "header",
    })),
  };
}


================================================
FILE: plugs/index/indexer.ts
================================================
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import { extractFrontMatter, type FrontMatter } from "./frontmatter.ts";
import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree";
import type { IndexTreeEvent } from "@silverbulletmd/silverbullet/type/event";
import { indexPage as pageIndexPage } from "./page.ts";
import { indexData } from "./data.ts";
import { indexItems } from "./item.ts";
import { indexHeaders } from "./header.ts";
import { indexParagraphs } from "./paragraph.ts";
import { indexLinks } from "./link.ts";
import { indexTables } from "./table.ts";
import { indexSpaceLua } from "./space_lua.ts";
import { indexSpaceStyle } from "./space_style.ts";
import { indexTags } from "./tags.ts";
import { index, markdown } from "@silverbulletmd/silverbullet/syscalls";

export type IndexerFunction = (
  pageMeta: PageMeta,
  frontmatter: FrontMatter,
  tree: ParseTree,
  text: string,
) => Promise<ObjectValue<any>[]>;

export const allIndexers: IndexerFunction[] = [
  pageIndexPage,
  indexData,
  indexItems,
  indexHeaders,
  indexParagraphs,
  indexLinks,
  indexTables,
  indexSpaceLua,
  indexSpaceStyle,
  indexTags,
];

/**
 * Ad-hoc index a piece of markdown text
 * @return a list of indexed objects
 */
export async function indexMarkdown(text: string, pageMeta: PageMeta = {
  ref: "",
  tag: "",
  name: "",
  perm: "ro",
  lastModified: "",
  created: "",
}): Promise<ObjectValue<any>> {
  const tree = await markdown.parseMarkdown(text);
  const frontmatter = extractFrontMatter(tree);
  const index = await Promise.all(
    allIndexers.filter((indexer) => indexer !== pageIndexPage).map(
      (indexer) => {
        return indexer(pageMeta, frontmatter, tree, text);
      },
    ),
  );
  return index.flat();
}

export async function indexPage({ name, tree, meta, text }: IndexTreeEvent) {
  const frontmatter = extractFrontMatter(tree);

  // console.log("Now going to index page", name);

  // Index the page
  const indexResults = await Promise.all(allIndexers.map((indexer) => {
    return indexer(meta, frontmatter, tree, text);
  }));

  // console.log("Found these objects", index.flat());

  await index.indexObjects<any>(name, indexResults.flat());
}


================================================
FILE: plugs/index/item.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { indexItems } from "./item.ts";
import { assertEquals } from "@std/assert";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import { extractFrontMatter } from "./frontmatter.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";

const itemsMd = `
* Item 1 #tag1 #tag2 [age: 100]
  * Item 1.1 #tag3 #tag1
    * Item 1.1.1
* [ ] Task 1
* [x] Task 2
* Item 2 #tag4
  * [ ] Sub task
* [[link]]
  * Child second [[link 2|alias]]
`;

Deno.test("Test item indexing", async () => {
  createMockSystem();
  const tree = parseMarkdown(itemsMd);
  const frontmatter = extractFrontMatter(tree);
  const pageMeta: PageMeta = {
    ref: "test",
    name: "test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };
  const items = await indexItems(pageMeta, frontmatter, tree);
  assertEquals(items.length, 9);
  // Test regular items
  assertEquals(items[0].name, "Item 1");
  assertEquals(items[0].age, 100);
  assertEquals(items[0].page, "test");
  assertEquals(items[0].parent, undefined);
  assertEquals(items[0].text, "Item 1 #tag1 #tag2 [age: 100]");
  assertEquals(new Set(items[0].tags), new Set(["tag1", "tag2"]));
  assertEquals(new Set(items[0].itags), new Set(["item", "tag1", "tag2"]));

  assertEquals(items[1].name, "Item 1.1");
  assertEquals(new Set(items[1].tags), new Set(["tag3", "tag1"]));
  assertEquals(
    new Set(items[1].itags),
    new Set(["tag3", "tag2", "tag1", "item"]),
  );
  assertEquals(items[1].parent, items[0].ref);

  assertEquals(items[2].name, "Item 1.1.1");
  assertEquals(items[2].parent, items[1].ref);

  // Test tasks
  assertEquals(items[3].tag, "task");
  assertEquals(items[3].name, "Task 1");
  assertEquals(items[3].done, false);
  assertEquals(items[3].state, " ");

  assertEquals(items[4].tag, "task");
  assertEquals(items[4].name, "Task 2");
  assertEquals(items[4].done, true);
  assertEquals(items[4].state, "x");

  assertEquals(items[6].tag, "task");
  assertEquals(items[6].name, "Sub task");
  assertEquals(items[6].done, false);
  assertEquals(items[6].parent, items[5].ref);
  assertEquals(new Set(items[6].itags), new Set(["task", "tag4"]));

  assertEquals(items[7].links, ["link"]);
  assertEquals(items[7].ilinks, ["link"]);
  assertEquals(items[8].links, ["link 2"]);
  assertEquals(new Set(items[8].ilinks), new Set(["link", "link 2"]));
});


================================================
FILE: plugs/index/item.ts
================================================
import {
  cloneTree,
  findParentMatching,
  type ParseTree,
  renderToText,
  traverseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import { cleanTags, collectTags, updateITags } from "./tags.ts";
import type { FrontMatter } from "./frontmatter.ts";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import { system } from "@silverbulletmd/silverbullet/syscalls";
import { cleanAttributes, collectAttributes } from "./attribute.ts";
import { collectPageLinks } from "./link.ts";

export type ItemObject = ObjectValue<
  {
    page: string;
    name: string;
    text: string;
    // Deprecated, use range instead
    pos: number;
    // Deprecated, use range instead
    toPos: number;
    parent?: string;
    links?: string[];
    ilinks?: string[];
  } & Record<string, any>
>;

export type TaskObject = ObjectValue<
  // "Inherit" everyting from item
  & ItemObject
  // And add a few more attributes
  & {
    done: boolean;
    state: string;
  }
  & Record<string, any>
>;

const completeStates = ["x", "X"];

export async function indexItems(
  pageMeta: PageMeta,
  frontmatter: FrontMatter,
  tree: ParseTree,
) {
  const shouldIndexAllItems = await system.getConfig(
    "index.item.all",
    true,
  );
  const shouldIndexAllTasks = await system.getConfig(
    "index.task.all",
    true,
  );

  let items: ObjectValue<ItemObject | TaskObject>[] = [];

  traverseTree(tree, (n) => {
    if (n.type !== "ListItem") {
      return false;
    }

    if (!n.children) {
      // Weird, let's jump out
      return true;
    }

    items.push(extractItemFromNode(
      pageMeta.name,
      n,
      frontmatter,
    ));

    // Traversal continue into child items (potentially)
    return false;
  });

  if (!shouldIndexAllItems) {
    items = items.filter((item) => item.tag !== "item" || item.tags?.length);
  }
  if (!shouldIndexAllTasks) {
    items = items.filter((item) => item.tag !== "task" || item.tags?.length);
  }

  return items;
}

export function extractItemFromNode(
  name: string,
  itemNode: ParseTree,
  frontmatter: FrontMatter,
  withParents = true,
): ItemObject | TaskObject {
  const item: ItemObject | TaskObject = {
    ref: `${name}@${itemNode.from}`,
    tag: "item",
    pos: itemNode.from!,
    toPos: itemNode.to!,
    range: [itemNode.from!, itemNode.to!],
    name: "", // to be replaced
    text: "", // to be replaced
    page: name,
  };

  // This will only be valid for items, not task
  let nameNode = itemNode.children!.find((n) => n.type === "Paragraph");

  // Is this a task?
  const taskNode = itemNode.children!.find((n) => n.type === "Task");
  if (taskNode) {
    item.tag = "task";
    item.state = taskNode.children![0].children![1].text!;
    item.done = completeStates.includes(item.state);
    // Fake a paragraph node for text rendering later
    nameNode = { type: "Paragraph", children: taskNode.children!.slice(1) };
  }

  // Now let's extract tags and attributes
  const tags = collectTags(itemNode);
  const attributes = collectAttributes(itemNode);
  const links = collectPageLinks(itemNode);

  item.text = renderToText(nameNode).trim();

  const nameNodeClone = cloneTree(nameNode!);
  cleanTags(nameNodeClone);
  cleanAttributes(nameNodeClone);
  item.name = renderToText(nameNodeClone).trim();

  if (tags.length > 0) {
    item.tags = tags;
  }

  if (links.length > 0) {
    item.links = links;
    item.ilinks = links;
  }

  for (const [key, value] of Object.entries(attributes)) {
    item[key] = value;
  }

  updateITags(item, frontmatter);

  if (withParents) {
    enrichItemFromParents(itemNode, item, name, frontmatter);
  }

  return item;
}

export function enrichItemFromParents(
  n: ParseTree,
  item: ItemObject,
  pageName: string,
  frontmatter: FrontMatter,
) {
  let directParent = true;
  let parentItemNode = findParentMatching(n, (n) => n.type === "ListItem");
  while (parentItemNode) {
    const parentItem = extractItemFromNode(
      pageName,
      parentItemNode,
      frontmatter,
      false,
    );
    if (directParent) {
      item.parent = parentItem.ref;
      directParent = false;
    }
    // Merge tags
    item.itags = [
      ...new Set([
        ...item.itags || [],
        ...(parentItem.itags!.filter((t) => !["item", "task"].includes(t))),
      ]),
    ];

    // And links
    const ilinks = [
      ...new Set([
        ...item.ilinks || [],
        ...parentItem.ilinks || [],
      ]),
    ];
    if (ilinks.length > 0) {
      item.ilinks = ilinks;
    }

    parentItemNode = findParentMatching(
      parentItemNode,
      (n) => n.type === "ListItem",
    );
  }
}


================================================
FILE: plugs/index/link.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import { extractFrontMatter } from "./frontmatter.ts";
import { assertEquals } from "@std/assert";
import { indexLinks } from "./link.ts";

const testPage = `
---
attribute: "[[fm-link]]"
---
This is a [[page-link]] to [[aliased-link|aliased]], or [this](md-link), and [[broken]], or [external](https://example.com), or [document](test.jpg), or [[test2.jpg]]
`.trim();

Deno.test("Test page link indexing", async () => {
  const { space } = createMockSystem();

  // Create dummy targets to avoid a lot of aspiring pages
  await space.writePage("page-link", "");
  await space.writePage("fm-link", "");
  await space.writePage("aliased-link", "");
  await space.writePage("folder/md-link", "");

  const tree = parseMarkdown(testPage);
  const frontmatter = extractFrontMatter(tree);

  const pageMeta: PageMeta = {
    ref: "folder/test",
    name: "folder/test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };

  const objects = await indexLinks(pageMeta, frontmatter, tree, testPage);
  assertEquals(objects.length, 9); // 7 links + 1 aspiring page
  assertEquals(objects[0].toPage, "fm-link");
  assertEquals(objects[0].type, "page");
  assertEquals(objects[1].toPage, "page-link");
  assertEquals(objects[1].type, "page");
  assertEquals(objects[2].toPage, "aliased-link");
  assertEquals(objects[2].type, "page");
  assertEquals(objects[2].alias, "aliased");
  assertEquals(objects[3].toPage, "folder/md-link"); // relative
  assertEquals(objects[3].type, "page");
  assertEquals(objects[4].toPage, "broken");
  assertEquals(objects[4].type, "page");
  assertEquals(objects[5].toURL, "https://example.com");
  assertEquals(objects[5].type, "url");
  assertEquals(objects[6].toFile, "folder/test.jpg");
  assertEquals(objects[6].type, "file");
  assertEquals(objects[7].toFile, "test2.jpg");
  assertEquals(objects[7].type, "file");
});


================================================
FILE: plugs/index/link.ts
================================================
import {
  collectNodesOfType,
  findNodeOfType,
  type ParseTree,
  renderToText,
  traverseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import {
  isLocalURL,
  resolveMarkdownLink,
} from "@silverbulletmd/silverbullet/lib/resolve";
import type { FrontMatter } from "./frontmatter.ts";
import { updateITags } from "./tags.ts";
import {
  getNameFromPath,
  isMarkdownPath,
  parseToRef,
} from "@silverbulletmd/silverbullet/lib/ref";
import {
  mdLinkRegex,
  wikiLinkRegex,
} from "../../client/markdown_parser/constants.ts";
import { index, lua, space } from "@silverbulletmd/silverbullet/syscalls";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import { extractSnippet } from "./snippet.ts";

export type LinkObject = ObjectValue<{
  // Common to all links
  page: string;
  pos: number;
  type: "page" | "file" | "url";
  snippet: string;
  alias?: string;
  pageLastModified: string;
  // Page Link
  toPage?: string;
  // File Link
  toFile?: string;
  // External URL
  toURL?: string;
}>;

/**
 * Represents a page that does not yet exist, but is being linked to. A page "aspiring" to be created.
 */
export type AspiringPageObject = ObjectValue<{
  // ref: page@pos
  // The page the link appears on
  page: string;
  // And the position
  pos: number;
  // The page the link points to
  name: string;
}>;

export async function indexLinks(
  pageMeta: PageMeta,
  frontmatter: FrontMatter,
  tree: ParseTree,
  pageText: string,
) {
  const objects: ObjectValue<any>[] = [];

  // If this is a meta template page, we don't want to index links
  if (frontmatter.tags?.find((t) => t.startsWith("meta/template"))) {
    return [];
  }

  const name = pageMeta.name;

  traverseTree(tree, (n): boolean => {
    // Index [[WikiLinks]]
    if (n.type === "WikiLink") {
      const wikiLinkPage = findNodeOfType(n, "WikiLinkPage")!;
      const wikiLinkAlias = findNodeOfType(n, "WikiLinkAlias");
      const url = wikiLinkPage.children![0].text!;
      const pos = wikiLinkPage.from!;

      const link: LinkObject = {
        ref: `${name}@${pos}`,
        type: "page", // can be swapped later
        tag: "link",
        snippet: extractSnippet(name, pageText, pos),
        pos,
        range: [n.from!, n.to!],
        page: name,
        pageLastModified: pageMeta.lastModified,
      };

      const ref = parseToRef(url);
      if (!ref) {
        // Invalid links aren't indexed
        return true;
      } else if (isMarkdownPath(ref.path)) {
        link.toPage = getNameFromPath(ref.path);
        link.type = "page";
      } else {
        link.toFile = ref.path;
        link.type = "file";
      }

      if (wikiLinkAlias) {
        link.alias = wikiLinkAlias.children![0].text!;
      }
      updateITags(link as LinkObject, frontmatter);
      objects.push(link);
      return true;
    }

    // Index [markdown style]() links
    if (n.type === "Link" || n.type === "Image") {
      // The [[Wiki links]] also have a wrapping Image node, but this just fails at the regex
      mdLinkRegex.lastIndex = 0;
      const match = mdLinkRegex.exec(renderToText(n));
      if (!match) {
        return false;
      }
      const { title: alias, url } = match.groups as {
        url: string;
        title: string;
      };

      // Check if local link
      const pos = n.from!;
      const link: LinkObject = {
        ref: `${name}@${pos}`,
        tag: "link",
        type: "page", // swapped out later if needed
        snippet: extractSnippet(name, pageText, pos),
        pos,
        range: [n.from!, n.to!],
        page: name,
        pageLastModified: pageMeta.lastModified,
      };

      if (isLocalURL(url)) {
        const ref = parseToRef(resolveMarkdownLink(name, decodeURI(url)));
        if (!ref) {
          // Invalid links aren't indexed
          return true;
        } else if (isMarkdownPath(ref.path)) {
          link.toPage = getNameFromPath(ref.path);
          link.type = "page";
        } else {
          link.toFile = ref.path;
          link.type = "file";
        }
      } else {
        // External URL
        link.type = "url";
        link.toURL = url;
      }

      if (alias) {
        link.alias = alias;
      }
      updateITags(link as LinkObject, frontmatter);
      objects.push(link);
      return true;
    }

    // Also index links used inside quoted frontmatter strings like "[[Page]]"
    // must match the full string node, only allowing for quotes and whitespace around it
    if (n.type === "FrontMatter") {
      // The YAML in frontmatter is parsed by CodeMirror itself
      for (const textNode of collectNodesOfType(n, "string")) {
        const text = textNode.children![0].text!;
        const trimmed = text.replace(/^["'\s]*/, "").replace(/["'\s]*$/, "");
        // Make sure we search from the beginning, when reusing a Regex object with global flag
        wikiLinkRegex.lastIndex = 0;
        const match = wikiLinkRegex.exec(text);
        // Search in entire node text to get correct position, but check for full match against trimmed
        if (match && match.groups && match[0] === trimmed) {
          const { leadingTrivia, stringRef, alias } = match.groups;
          const pos = textNode.from! + match.index! + leadingTrivia.length;
          const link: LinkObject = {
            ref: `${name}@${pos}`,
            tag: "link",
            type: "page", // final value set later
            page: name,
            snippet: extractSnippet(name, pageText, pos),
            pos: pos,
            range: [
              textNode.from! + match.index!,
              textNode.from! + match.index! + match[0].length,
            ],
            pageLastModified: pageMeta.lastModified,
          };

          const ref = parseToRef(stringRef);
          if (!ref) {
            // Invalid links aren't indexed
            return true;
          } else if (isMarkdownPath(ref.path)) {
            link.toPage = getNameFromPath(ref.path);
            link.type = "page";
          } else {
            link.toFile = ref.path;
            link.type = "file";
          }

          if (alias) {
            link.alias = alias;
          }
          updateITags(link, frontmatter);
          objects.push(link);
        }
      }
    }
    return false;
  });

  // Now let's check which are aspiring pages
  for (const link of objects.slice()) {
    if (link.toPage) {
      if (!await space.fileExists(`${link.toPage}.md`)) {
        objects.push({
          ref: `${name}@${link.pos}`,
          tag: "aspiring-page",
          page: name,
          pos: link.pos,
          range: link.range,
          name: link.toPage,
        } as AspiringPageObject);
        console.info(
          "Link from",
          name,
          "to",
          link.toPage,
          "is broken, indexing as aspiring page",
        );
      }
    }
  }

  return objects;
}

/**
 * Collects wiki links from a tree
 * @param n
 * @return found links
 */
export function collectPageLinks(n: ParseTree): string[] {
  const links = new Set<string>();
  traverseTree(n, (n) => {
    if (n.type === "WikiLink") {
      links.add(findNodeOfType(n, "WikiLinkPage")!.children![0].text!);
      return true;
    } else if (n.type === "OrderedList" || n.type === "BulletList") {
      // Don't traverse into sub-lists
      return true;
    }
    return false;
  });
  return [...links];
}

export async function getBackLinks(
  name: string,
): Promise<LinkObject[]> {
  return (await index.queryLuaObjects<LinkObject>("link", {
    objectVariable: "_",
    where: await lua.parseExpression(`_.toPage == name or _.toFile == name`),
  }, {
    name,
  }));
}


================================================
FILE: plugs/index/lint.ts
================================================
import { index, lua } from "@silverbulletmd/silverbullet/syscalls";
import {
  findNodeOfType,
  renderToText,
  traverseTree,
  traverseTreeAsync,
} from "@silverbulletmd/silverbullet/lib/tree";
import type {
  LintDiagnostic,
  LintEvent,
} from "@silverbulletmd/silverbullet/type/client";

import YAML from "js-yaml";
import { extractFrontMatter } from "./frontmatter.ts";
import { allIndexers } from "./indexer.ts";

/**
 * Lint YAML syntax in frontmatter and fenced code blocks
 */
export function lintYAML(
  { tree, name }: LintEvent,
): LintDiagnostic[] {
  const diagnostics: LintDiagnostic[] = [];

  traverseTree(tree, (node) => {
    if (node.type === "FrontMatterCode") {
      const yamlText = renderToText(node);
      const lintResult = lintYamlBlock(
        yamlText,
        node.from!,
        name,
      );
      if (lintResult) {
        diagnostics.push(lintResult);
      }
      return true;
    }
    if (node.type === "FencedCode") {
      const codeInfo = findNodeOfType(node, "CodeInfo")!;
      if (!codeInfo) {
        return true;
      }
      const codeLang = codeInfo.children![0].text!;
      // All known YAML formats
      if (
        ["yaml"].includes(codeLang) || codeLang.startsWith("#")
      ) {
        const codeText = findNodeOfType(node, "CodeText");
        if (!codeText) {
          return true;
        }
        const yamlCode = renderToText(codeText);
        const lintResult = lintYamlBlock(
          yamlCode,
          codeText.from!,
        );
        if (lintResult) {
          diagnostics.push(lintResult);
        }
        return true;
      }
    }
    return false;
  });
  return diagnostics;
}

const errorRegex = /\((\d+):(\d+)\)/;

/**
 * Lint a YAML block
 * @param yamlText - The YAML text to lint
 * @param startPos - The start position of the YAML block
 * @param pageName - The page name to check against
 * @returns A LintDiagnostic if there is an error, undefined otherwise
 */
function lintYamlBlock(
  yamlText: string,
  startPos: number,
  pageName?: string,
): LintDiagnostic | undefined {
  try {
    const parsed = YAML.load(yamlText);
    if (pageName && parsed.name && parsed.name != pageName) {
      return {
        from: startPos,
        to: startPos + yamlText.length,
        severity: "error",
        message: "'name' attribute has to match page name",
      };
    }
  } catch (e: any) {
    const errorMatch = errorRegex.exec(e.message);
    if (errorMatch) {
      console.log("YAML error", e.message);
      const line = parseInt(errorMatch[1], 10) - 1;
      const yamlLines = yamlText.split("\n");
      let pos = startPos;
      for (let i = 0; i < line; i++) {
        pos += yamlLines[i].length + 1;
      }
      const endPos = pos + yamlLines[line]?.length || pos;

      return {
        from: pos,
        to: endPos,
        severity: "error",
        message: e.message,
      };
    }
  }
}

/**
 * Lint Lua code in fenced code blocks
 * @returns A list of LintDiagnostics for any errors found
 */
export async function lintLua({ tree }: LintEvent): Promise<LintDiagnostic[]> {
  const diagnostics: LintDiagnostic[] = [];
  await traverseTreeAsync(tree, async (node) => {
    if (node.type === "FencedCode") {
      const codeInfo = findNodeOfType(node, "CodeInfo")!;
      if (!codeInfo) {
        return true;
      }
      const codeLang = codeInfo.children![0].text!;
      if (codeLang !== "space-lua") {
        return true;
      }
      const codeText = findNodeOfType(node, "CodeText");
      if (!codeText) {
        return true;
      }
      const luaCode = renderToText(codeText);
      try {
        await lua.parse(luaCode);
      } catch (e: any) {
        const offset = codeText.from!;
        let from = codeText.from!;
        let to = codeText.to!;
        let message = e.message;
        if (message.includes("Parse error")) {
          const pos = +message.slice("Parse error at pos ".length);
          from = offset + pos;
          to = offset + pos;
          message = "Parse error";
        }
        diagnostics.push({
          from,
          to,
          severity: "error",
          message,
        });
        console.log("Lua error", e);
      }
      return true;
    }

    return false;
  });
  return diagnostics;
}

/**
 * Lint objects in the page
 */
export async function lintObjects(
  { tree, pageMeta: meta, text, name }: LintEvent,
): Promise<LintDiagnostic[]> {
  const frontmatter = extractFrontMatter(tree);

  // Index the page
  const allObjects = (await Promise.all(allIndexers.map((indexer) => {
    return indexer(meta, frontmatter, tree, text);
  }))).flat();
  const result = await index.validateObjects(name, allObjects);
  // If validation failed, return the error
  if (result?.object?.range) {
    return [{
      from: result.object.range[0],
      to: result.object.range[1],
      severity: "error",
      message: result.error,
    }];
  }
  return [];
}


================================================
FILE: plugs/index/page.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import { indexPage } from "./page.ts";
import { extractFrontMatter } from "./frontmatter.ts";
import { assertEquals } from "@std/assert";

const testPage = `
---
hello: attribute
---
[hello2: 12]

# Hello world!

`.trim();

Deno.test("Test page indexing", async () => {
  createMockSystem();
  const tree = parseMarkdown(testPage);
  const frontmatter = extractFrontMatter(tree);

  const pageMeta: PageMeta = {
    ref: "test",
    name: "test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };

  const [pm] = await indexPage(pageMeta, frontmatter, tree);
  assertEquals(pm.hello, "attribute");
  assertEquals(pm.hello2, 12);
  assertEquals(pm.itags, ["page"]);
});


================================================
FILE: plugs/index/page.ts
================================================
import {
  editor,
  index,
  lua,
  markdown,
} from "@silverbulletmd/silverbullet/syscalls";

import type { FrontMatter } from "./frontmatter.ts";
import {
  findNodeOfType,
  type ParseTree,
  renderToText,
  traverseTreeAsync,
} from "@silverbulletmd/silverbullet/lib/tree";
import { updateITags } from "./tags.ts";
import type { AspiringPageObject } from "./link.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import type { LintDiagnostic } from "@silverbulletmd/silverbullet/type/client";

import YAML from "js-yaml";

export async function indexPage(
  pageMeta: PageMeta,
  frontmatter: FrontMatter,
  _tree: ParseTree,
) {
  // Push them all into the page object
  // Note the order here, making sure that the actual page meta data overrules
  // any attempt to manually set built-in attributes like 'name' or 'lastModified'
  // pageMeta appears at the beginning and the end due to the ordering behavior of ojects in JS (making builtin attributes appear first)
  const combinedPageMeta: PageMeta = {
    ...pageMeta,
    ...frontmatter,
    ...pageMeta,
  };

  combinedPageMeta.tags = [...new Set([...frontmatter.tags || []])];

  combinedPageMeta.tag = "page";

  if (combinedPageMeta.aliases && !Array.isArray(combinedPageMeta.aliases)) {
    console.warn(
      "Aliases must be an array",
      combinedPageMeta.aliases,
      "falling back to empty array",
    );
    combinedPageMeta.aliases = [];
  }

  updateITags(combinedPageMeta, frontmatter);

  // Make sure this page is no (longer) in the aspiring pages list
  // TODO: This can possibly done more optimally
  const aspiringPages = await index.queryLuaObjects<AspiringPageObject>(
    "aspiring-page",
    {
      objectVariable: "_",
      where: await lua.parseExpression(`_.name == pageRef`),
    },
    { pageRef: pageMeta.name },
  );
  for (const aspiringPage of aspiringPages) {
    console.log("Deleting aspiring page", aspiringPage);
    await index.deleteObject(
      "aspiring-page",
      aspiringPage.page,
      aspiringPage.ref,
    );
  }

  return [combinedPageMeta];
}

export async function lintFrontmatter(): Promise<LintDiagnostic[]> {
  const text = await editor.getText();
  const tree = await markdown.parseMarkdown(text);
  const diagnostics: LintDiagnostic[] = [];
  await traverseTreeAsync(tree, async (node) => {
    if (node.type === "FrontMatterCode") {
      const lintResult = await lintYaml(
        renderToText(node),
        node.from!,
        node.to!,
      );
      if (lintResult) {
        diagnostics.push(lintResult);
      }
      return true;
    }
    if (node.type === "FencedCode") {
      const codeInfo = findNodeOfType(node, "CodeInfo")!;
      if (!codeInfo) {
        return true;
      }
      const codeLang = codeInfo.children![0].text!;
      // All known YAML formats
      if (
        codeLang === "template" || codeLang === "yaml" ||
        codeLang.startsWith("#")
      ) {
        const codeText = findNodeOfType(node, "CodeText");
        if (!codeText) {
          return true;
        }
        const yamlCode = renderToText(codeText);
        const lintResult = await lintYaml(
          yamlCode,
          codeText.from!,
          codeText.to!,
        );
        if (lintResult) {
          diagnostics.push(lintResult);
        }
        return true;
      }
    }
    return false;
  });
  return diagnostics;
}

const errorRegex = /at line (\d+),? column (\d+)/;

async function lintYaml(
  yamlText: string,
  from: number,
  to: number,
): Promise<LintDiagnostic | undefined> {
  try {
    await YAML.load(yamlText);
  } catch (e: any) {
    const errorMatch = errorRegex.exec(e.message);
    if (errorMatch) {
      console.log("YAML error", e.message);

      return {
        from,
        to,
        severity: "error",
        message: e.message,
      };
    }
  }
}

export async function loadPageObject(pageName?: string): Promise<PageMeta> {
  if (!pageName) {
    return {
      ref: "",
      name: "",
      tags: ["page"],
      lastModified: "",
      created: "",
    } as PageMeta;
  }
  return (await index.getObjectByRef<PageMeta>(
    pageName,
    "page",
    pageName,
  )) || {
    ref: pageName,
    name: pageName,
    tags: ["page"],
    lastModified: "",
    created: "",
  } as PageMeta;
}


================================================
FILE: plugs/index/paragraph.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import { extractFrontMatter } from "./frontmatter.ts";
import { assertEquals } from "@std/assert";
import { indexParagraphs } from "./paragraph.ts";

const testPage = `
#tag-only

Some paragraph

Another paragraph that is #tagged

* Item paragraph (don't index)
`.trim();

Deno.test("Test paragraph indexing", async () => {
  const { config } = createMockSystem();

  config.set("index.paragraph.all", true);
  let tree = parseMarkdown(testPage);
  let frontmatter = extractFrontMatter(tree);

  const pageMeta: PageMeta = {
    ref: "test",
    name: "test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };

  let paragraphs = await indexParagraphs(pageMeta, frontmatter, tree);
  console.log(paragraphs);

  assertEquals(paragraphs.length, 2);
  assertEquals([...new Set(paragraphs[0].itags)], ["paragraph", "tag-only"]);
  assertEquals(paragraphs[1].tags, ["tagged"]);

  config.set("index.paragraph.all", false);
  tree = parseMarkdown(testPage);
  frontmatter = extractFrontMatter(tree);

  paragraphs = await indexParagraphs(pageMeta, frontmatter, tree);
  assertEquals(paragraphs.length, 1);
});


================================================
FILE: plugs/index/paragraph.ts
================================================
import {
  cloneTree,
  findParentMatching,
  type ParseTree,
  renderToText,
  traverseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import { cleanTags, collectTags, updateITags } from "./tags.ts";
import type { FrontMatter } from "./frontmatter.ts";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import { system } from "@silverbulletmd/silverbullet/syscalls";
import { cleanAttributes, collectAttributes } from "./attribute.ts";

/** ParagraphObject  An index object for the top level text nodes */
export type ParagraphObject = ObjectValue<
  {
    page: string;
    pos: number;
    text: string;
  } & Record<string, any>
>;

export async function indexParagraphs(
  pageMeta: PageMeta,
  frontmatter: FrontMatter,
  tree: ParseTree,
) {
  const shouldIndexAll = await system.getConfig(
    "index.paragraph.all",
    false,
  );

  const objects: ParagraphObject[] = [];

  traverseTree(tree, (p) => {
    if (p.type !== "Paragraph") {
      return false;
    }

    if (findParentMatching(p, (n) => n.type === "ListItem")) {
      // Not looking at paragraphs nested in a list
      return true;
    }

    const fullText = renderToText(p);

    // Collect tags
    const tags = collectTags(p);

    if (tags.length === 0 && !shouldIndexAll) {
      // Don't index paragraphs without a hashtag
      return true;
    }

    // Extract attributes
    const attrs = collectAttributes(p);

    // Clean tree, just to check if it's effectively empty or not
    const pClone = cloneTree(p);
    cleanTags(pClone);
    cleanAttributes(pClone);
    const text = renderToText(pClone);

    if (!text.trim()) {
      // Empty paragraph, just tags and attributes maybe
      return true;
    }

    const pos = p.from!;
    const paragraph: ParagraphObject = {
      tag: "paragraph",
      ref: `${pageMeta.name}@${pos}`,
      text: fullText,
      page: pageMeta.name,
      pos,
      range: [p.from!, p.to!],
      ...attrs,
    };
    if (tags.length > 0) {
      paragraph.tags = [...tags];
      paragraph.itags = [...tags];
    }

    updateITags(paragraph, frontmatter);
    objects.push(paragraph);

    // stop on every element except document, including paragraphs
    return true;
  });

  return objects;
}


================================================
FILE: plugs/index/plug_api.ts
================================================
import { system } from "@silverbulletmd/silverbullet/syscalls";
import type { ObjectValue } from "../../plug-api/types/index.ts";

import type { KV } from "../../plug-api/types/datastore.ts";

export function indexObjects<T>(
  page: string,
  objects: ObjectValue<T>[],
): Promise<void> {
  return system.invokeFunction("index.indexObjects", page, objects);
}

export function batchSet(page: string, kvs: KV[]): Promise<void> {
  return system.invokeFunction("index.batchSet", page, kvs);
}

export function getObjectByRef<T>(
  page: string,
  tag: string,
  ref: string,
): Promise<T | undefined> {
  return system.invokeFunction("index.getObjectByRef", page, tag, ref);
}


================================================
FILE: plugs/index/queue.ts
================================================
import {
  editor,
  events,
  markdown,
  mq,
  space,
} from "@silverbulletmd/silverbullet/syscalls";
import { sleep } from "@silverbulletmd/silverbullet/lib/async";
import type { MQMessage } from "@silverbulletmd/silverbullet/type/datastore";
import type { IndexTreeEvent } from "@silverbulletmd/silverbullet/type/event";

/// QUEUE PROCESSING

export async function processIndexQueue(messages: MQMessage[]) {
  for (const message of messages) {
    const path: string = message.body;
    console.log("[index]", `Indexing file ${path}`);
    await indexFile(path);
  }
}

async function indexFile(path: string) {
  if (path.endsWith(".md")) {
    // Page
    const name = path.slice(0, -3);
    // Read and parse the file
    const { text, meta } = await space.readPageWithMeta(name);
    const tree = await markdown.parseMarkdown(text);

    // Emit the event which will be picked up by indexers
    await events.dispatchEvent("page:index", {
      name,
      meta,
      tree,
      text,
    } as IndexTreeEvent);
  } else {
    await events.dispatchEvent("document:index", path);
  }
}

/// UI PROGRESS UPDATE LOGIC

const uiUpdateInterval = 5000;

// There is no reliable way to know the total number of queue items, so we'll keep track of the maximum observed queue size
// and use that to calculate the progress percentage.
let maximumObservedQueueSize = 0;

setTimeout(updateIndexProgressInUI, uiUpdateInterval);

// Returns the total number of items queued, updating the maximum observed queue size if necessary
async function totalItemsQueued() {
  const queueStats = await mq.getQueueStats();
  const total = queueStats.queued + queueStats.processing;
  if (total > maximumObservedQueueSize) {
    maximumObservedQueueSize = total;
  } else if (total === 0) {
    // Empty queue, let's reset the maximum observed queue size
    maximumObservedQueueSize = 0;
  }
  return total;
}

async function updateIndexProgressInUI() {
  // Let's see if there's anything in the index queue
  let totalQueued = await totalItemsQueued();
  while (totalQueued > 0) {
    const percentage = Math.round(
      (maximumObservedQueueSize - totalQueued) / maximumObservedQueueSize * 100,
    );
    if (percentage > 0 && percentage <= 99) {
      await editor.showProgress(percentage, "index");
    } else {
      // Hide progress circle
      await editor.showProgress();
    }
    await sleep(1000);
    totalQueued = await totalItemsQueued();
  }
  // Schedule again
  setTimeout(updateIndexProgressInUI, uiUpdateInterval);
}


================================================
FILE: plugs/index/refactor.ts
================================================
import {
  editor,
  index,
  lua,
  markdown,
  mq,
  space,
} from "@silverbulletmd/silverbullet/syscalls";
import { getBackLinks, type LinkObject } from "./link.ts";
import {
  absoluteToRelativePath,
  folderName,
} from "@silverbulletmd/silverbullet/lib/resolve";
import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree";
import {
  addParentPointers,
  findNodeOfType,
  findParentMatching,
  nodeAtPos,
} from "@silverbulletmd/silverbullet/lib/tree";
import type { ObjectValue } from "@silverbulletmd/silverbullet/type/index";
import { isValidPath } from "@silverbulletmd/silverbullet/lib/ref";
import { notFoundError } from "@silverbulletmd/silverbullet/constants";

/**
 * Renames a single page.
 * @param cmdDef Optional command arguments
 * @param cmdDef.oldPage The current name of the page to rename. Defaults to
 *   the current page selected in the editor.
 * @param cmdDef.page The name to rename the page to. If not provided the
 *   user will be prompted to enter a new name.
 * @returns True if the rename succeeded; otherwise, false.
 */
export async function renamePageCommand(cmdDef: any) {
  const oldName: string = cmdDef.oldPage || await editor.getCurrentPage();
  const newName: string = cmdDef.page ||
    await editor.prompt(`Rename ${oldName} to:`, oldName);
  if (!newName) {
    return false;
  }
  const pageList: [string, string][] = [[oldName + ".md", newName + ".md"]];
  await batchRenameFiles(pageList);
  return true;
}

export async function renamePageLinkCommand() {
  const mdTree = await markdown.parseMarkdown(await editor.getText());
  const link = nodeAtPos(mdTree, await editor.getCursor());
  if (!link) {
    console.error("No link found at cursor position...");
    return;
  }
  addParentPointers(mdTree);
  let node: ParseTree | null = link;
  if (node.type !== "WikiLink") {
    node = findParentMatching(node, (t) => t.type === "WikiLink");
    if (!node) {
      console.error("No link found at cursor position");
      return;
    }
  }
  const wikiLinkPage = findNodeOfType(node, "WikiLinkPage");
  if (!wikiLinkPage) {
    console.error("No link found at cursor position");
    return;
  }
  const oldName = wikiLinkPage.children![0].text!;

  const newName = await editor.prompt(`Rename ${oldName} to:`, oldName);
  if (!newName) {
    return false;
  }
  const pageList: [string, string][] = [[oldName + ".md", newName + ".md"]];
  await batchRenameFiles(pageList);
}

/**
 * Renames a single document.
 * @param cmdDef Optional command arguments
 * @param cmdDef.oldDocument The current name of the document to rename.
 * @param cmdDef.document The name to rename the document to. If not provided the
 *   user will be prompted to enter a new name.
 * @returns True if the rename succeeded; otherwise, false.
 */
export async function renameDocumentCommand(cmdDef: any) {
  const oldName: string = cmdDef.oldDocument || await editor.getCurrentPath();
  const newName: string = cmdDef.document ||
    await editor.prompt(`Rename ${oldName} to:`, oldName);
  if (!newName) {
    return false;
  }
  const pageList: [string, string][] = [[oldName, newName]];
  await batchRenameFiles(pageList);
  return true;
}

/**
 * Renames any amount of files.
 * If renaming pages, names should be passed with a .md extension
 * @param fileList An array of tuples containing [FileToBeRenamed, NewFileName]
 * @returns True if the rename succeeded; otherwise, false.
 */
export async function batchRenameFiles(fileList: [string, string][]) {
  await editor.save();

  // Skip unchanged names
  fileList = fileList.filter(([oldName, newName]) => {
    if (oldName.trim() === newName.trim()) {
      console.log(`${oldName}'s name unchanged, skipping`);
    } else {
      return [oldName, newName];
    }
  });

  try {
    // Pre-flight checks
    await Promise.all(fileList.map(async ([_oldName, newName]) => {
      try {
        // It's a FILEname not a PAGEname.
        if (!isValidPath(newName)) {
          throw new Error(`Name invalid: ${newName}`);
        }
        // Check if target file already exists
        await space.getFileMeta(newName);
        // If we got here, the file exists, so we error out
        throw new Error(
          `${newName} already exists, cannot rename to existing file.`,
        );
      } catch (e: any) {
        if (e.message === notFoundError.message) {
          // Expected not found error, so we can continue
        } else {
          throw e;
        }
      }
    }));

    // All new names are available, proceeding with rename
    for (const [oldName, newName] of fileList) {
      console.log("Renaming", oldName, "to", newName);
      try {
        if (newName.endsWith(".md") && oldName.endsWith(".md")) {
          await renamePage(oldName.slice(0, -3), newName.slice(0, -3));
        } else {
          await renameDocument(oldName, newName);
        }
      } catch (e: any) {
        if (e.message === notFoundError.message) {
          console.log(`${oldName} does not exist, skipping`);
        } else {
          throw e;
        }
      }
    }

    return true;
  } catch (e: any) {
    await editor.flashNotification(e.message, "error");
    return false;
  }
}

// Rename a page, update any backlinks and linked documents
async function renamePage(oldName: string, newName: string) {
  let text = await space.readPage(oldName);

  // Update relative links and documents on this page
  const oldFolder = folderName(oldName);
  const newFolder = folderName(newName);
  const documentsToMove = new Set<string>();
  // Links only need to be updated if the folder changes
  if (oldFolder !== newFolder) {
    const linksInPage = await index.queryLuaObjects<LinkObject>("link", {
      objectVariable: "_",
      where: await lua.parseExpression(`_.page == oldName`),
    }, {
      oldName,
    });

    const linksToUpdate: ObjectValue<LinkObject>[] = [];
    for (const link of linksInPage) {
      if (link.toFile && folderName(link.toFile) === oldFolder) {
        const documentBackLinks = await getBackLinks(link.toFile);
        if (documentBackLinks.filter((a) => a.page !== oldName).length === 0) {
          // Documents is in the same folder as the page
          // and is only linked to on this page, move it along with the page
          documentsToMove.add(link.toFile);
          continue;
        }
      }
      linksToUpdate.push(link);
    }

    // Sort links by position
    linksToUpdate.sort((a, b) => {
      // Backwards to prevent errors from position changes
      return b.pos - a.pos;
    });

    for (const link of linksToUpdate) {
      let newLink = link.toPage || link.toFile!;
      let newTail = text.substring(link.pos);

      // Only relative links need to be updated
      if (/^[^/][^\]]+?(?<!]])\)/.test(newTail)) {
        newLink = absoluteToRelativePath(newName, newLink);
        newTail = newTail.replace(/^.*?(?=@\d*|#|\$|\))/, newLink);
        // Wrap in <> if link has spaces
        if (newLink.includes(" ")) {
          newTail = "<" + newTail.replace(")", ">)");
        }
        text = text.substring(0, link.pos) + newTail;
      }
    }
  }

  // Write the new page
  const newPageMeta = await space.writePage(newName, text);

  // Move documents along with page
  const batchRenameDocuments: [string, string][] = [];
  for (const document of documentsToMove) {
    const newAttName = oldFolder.length === 0
      ? newFolder + "/" + document
      : document.replace(oldFolder, newFolder).replace(/^\//, "");
    batchRenameDocuments.push([document, newAttName]);
  }
  if (batchRenameDocuments.length > 0) {
    await batchRenameFiles(batchRenameDocuments);
  }

  // Handling the edge case of a changing page name just in casing on a case insensitive FS
  const oldPageMeta = await space.getPageMeta(oldName);
  if (oldPageMeta.lastModified !== newPageMeta.lastModified) {
    // If they're the same, let's assume it's the same file (case insensitive FS) and not delete, otherwise...
    await space.deletePage(oldName);
  }

  // Update backlinks to this page
  const updatedRefences = await updateBacklinks(oldName, newName);

  // Navigate to new page if currently viewing old page
  if (await editor.getCurrentPage() === oldName) {
    // Wait for index queue to be processed so that widgets are updated with up-to-date information
    await mq.awaitEmptyQueue("indexQueue");
    await editor.navigate(newName, true);
  }

  let message = `Renamed ${oldName} to ${newName}`;
  if (updatedRefences > 0) {
    message = `${message}, updated ${updatedRefences} backlinks`;
  }
  if (documentsToMove.size > 0) {
    message = `${message}, moved ${documentsToMove.size} documents`;
  }
  await editor.flashNotification(message, "info");
}

// Rename a document and update any backlinks
async function renameDocument(
  oldPath: string,
  newPath: string,
) {
  // Move the file
  const oldFile = await space.readDocument(oldPath);
  const newFileMeta = await space.writeDocument(newPath, oldFile);

  if (await editor.getCurrentPath() === oldPath) {
    await editor.navigate(newPath, true);
  }

  // Handling the edge case of a changing file name just in casing on a case insensitive FS
  const oldFileMeta = await space.getDocumentMeta(oldPath);
  if (oldFileMeta.lastModified !== newFileMeta.lastModified) {
    // If they're the same, let's assume it's the same file (case insensitive FS) and not delete, otherwise...
    await space.deleteDocument(oldPath);
  }

  // Update any backlinks
  const updatedRefences = await updateBacklinks(oldPath, newPath);
  let message = `Renamed ${oldPath} to ${newPath}`;
  if (updatedRefences > 0) {
    message = `${message}, updated ${updatedRefences} backlinks`;
  }
  await editor.flashNotification(message, "info");
}

/**
 * Renames pages based on a prefix string.
 * @param cmdDef Optional command arguments
 * @param cmdDef.oldPrefix The prefix to rename from. If not provided the
 *   user will be prompted to enter a prefix.
 * @param cmdDef.newPrefix The prefix with which to replace the `oldPrefix`
 *   value. If not provided the user will be prompted to enter a new prefix.
 * @param cmdDef.disableConfirmation If false, the user will be prompted
 *   to confirm the rename action; Otherwise no confirmation dialog will
 *   be shown before renaming. Defaults to false.
 * @returns True if the rename succeeded; otherwise, false.
 */
export async function renamePrefixCommand(cmdDef: any) {
  const oldPrefix = cmdDef.oldPrefix ??
    await editor.prompt("Prefix to rename:", "");
  if (!oldPrefix) {
    return false;
  }

  const newPrefix = cmdDef.newPrefix ??
    await editor.prompt("New prefix:", oldPrefix);
  if (!newPrefix) {
    return false;
  }

  const allDocuments = await space.listDocuments();
  const allPages = await space.listPages();
  let allAffectedFiles = allDocuments.map((file) => file.name).filter((
    file,
  ) => file.startsWith(oldPrefix));
  allAffectedFiles = allAffectedFiles.concat(
    allPages.map((page) => page.name + ".md").filter((page) =>
      page.startsWith(oldPrefix)
    ),
  );

  if (
    cmdDef.disableConfirmation !== true && !(await editor.confirm(
      `This will affect ${allAffectedFiles.length} files. Are you sure?`,
    ))
  ) {
    return false;
  }

  const allNewNames: [string, string][] = allAffectedFiles.map((name) => // This may seem naive, but it's actually fine, because we're only renaming the first occurrence (which will be the prefix)
  [name, name.replace(oldPrefix, newPrefix)]);
  await batchRenameFiles(allNewNames);
}

export async function extractToPageCommand() {
  const selection = await editor.getSelection();
  let text = await editor.getText();
  text = text.slice(selection.from, selection.to);

  const match = text.match("#{1,6}\\s+([^\n]*)");

  let newName;
  if (match) {
    newName = match[1];
  } else {
    newName = "new page";
  }
  newName = await editor.prompt(`New page title:`, newName);
  if (!newName) {
    return;
  }

  try {
    // This throws an error if the page does not exist, which we expect to be the case
    await space.getPageMeta(newName);
    // So when we get to this point, we error out
    throw new Error(
      `Page ${newName} already exists, cannot rename to existing page.`,
    );
  } catch (e: any) {
    if (e.message === notFoundError.message) {
      // Expected not found error, so we can continue
    } else {
      await editor.flashNotification(e.message, "error");
      throw e;
    }
  }
  await editor.replaceRange(selection.from, selection.to, `[[${newName}]]`);
  console.log("Writing new page to space");
  await space.writePage(newName, text);
  console.log("Navigating to new page");
  await editor.navigate(newName);
}

/**
 * Updates backlinks across all pages
 * @param oldName Full path to old page/file
 * @param newName Full path to new page/file
 * @returns The number of references updated
 */
async function updateBacklinks(
  oldName: string,
  newName: string,
): Promise<number> {
  // This is the bit where we update all the links
  const backLinks = await getBackLinks(oldName);
  let updatedReferences = 0;

  // Group by page to edit entire page at once
  const backLinksByPage = backLinks.reduce(
    (group: Record<string, LinkObject[]>, link) => {
      const { page } = link;
      group[page] = group[page] ?? [];
      group[page].push(link);
      return group;
    },
    {},
  );

  console.log("All pages containing backlinks", backLinks);
  for (const [pageToEdit, linksInPage] of Object.entries(backLinksByPage)) {
    if (pageToEdit === oldName) {
      continue;
    }

    let text = await space.readPage(pageToEdit);
    if (!text) {
      // Page likely does not exist, but at least we can skip it
      continue;
    }

    // Use indexed positions to replace links
    linksInPage.sort((a, b) => {
      // Backwards to prevent errors from position changes
      return b.pos - a.pos;
    });

    for (const link of linksInPage) {
      let newTail = text.substring(link.pos);
      let newLink = newName;
      if (/^[^\]]+?(?<!]])\)/.test(newTail)) {
        // Is [Markdown link]()
        if (newTail.startsWith("/") || newTail.startsWith("</")) {
          // Is absolute mdlink, update with full path with leading /
          newLink = "/" + newLink;
        } else {
          // Is relative mdlink
          newLink = absoluteToRelativePath(pageToEdit, newLink);
        }
        newTail = newTail.replace(/^.*?(?=@\d*|#|\$|\))/, newLink);

        // Wrap in <> if link has spaces
        if (newLink.includes(" ")) {
          newTail = "<" + newTail.replace(")", ">)");
        }
      } else {
        // Is wikilink, replace with full path
        newTail = newLink + newTail.slice(oldName.length);
      }

      text = text.substring(0, link.pos) + newTail;
      updatedReferences++;
    }
    await space.writePage(pageToEdit, text);
  }
  return updatedReferences;
}


================================================
FILE: plugs/index/snippet.test.ts
================================================
import { assertEquals } from "@std/assert";
import { extractSnippet } from "./snippet.ts";

Deno.test("extractSnippetByIndentation", () => {
  const text = `* Item 1
  * Item 1.1
    * Item 1.1.1
      * Item 1.1.1.1
    * Item 1.1.2
  * Item 1.2
* Item 2
  * Item 2.1`;

  // Unindented line with children
  assertEquals(
    extractSnippet("test", text, text.indexOf("* Item 1")),
    "* Item 1\n  * Item 1.1\n    * Item 1.1.1\n      * Item 1.1.1.1\n    * Item 1.1.2\n  * Item 1.2",
  );

  // Indented line with children (left-aligned)
  assertEquals(
    extractSnippet("test", text, text.indexOf("Item 1.1")),
    "* Item 1.1\n  * Item 1.1.1\n    * Item 1.1.1.1\n  * Item 1.1.2",
  );

  // Deeply nested line (left-aligned)
  assertEquals(
    extractSnippet("test", text, text.indexOf("Item 1.1.1.1")),
    "* Item 1.1.1.1",
  );

  // Stops at sibling
  assertEquals(
    extractSnippet("test", text, text.indexOf("Item 1.2")),
    "* Item 1.2",
  );

  // Stops at empty line
  const text2 = `* Item A
  * Item A.1
    Content

  * Item A.2`;
  assertEquals(
    extractSnippet("test", text2, text2.indexOf("Item A.1")),
    "* Item A.1\n  Content",
  );

  // Header extracts only itself and strips # marker
  const text3 = `## Sub Header
More text`;
  assertEquals(
    extractSnippet("test", text3, text3.indexOf("## Sub Header")),
    "Sub Header",
  );

  // maxLines limits the number of lines
  const text4 = `* Item X
  * Item X.1
  * Item X.2
  * Item X.3
  * Item X.4`;
  assertEquals(
    extractSnippet("test", text4, text4.indexOf("* Item X"), 3),
    "* Item X\n  * Item X.1\n  * Item X.2\n...",
  );

  // maxLines limits the number of lines
  const text5 = `  * Item X
    * [ ] Hello`;
  const taskPos = text5.indexOf("* [ ]");
  assertEquals(
    extractSnippet("test", text5, text4.indexOf("* Item X")),
    `* Item X\n  * [ ] [[test@${taskPos}]] Hello`,
  );
});


================================================
FILE: plugs/index/snippet.ts
================================================
/**
 * Extracts a snippet around a given index in markdown text based on indentation rules.
 *
 * If the line at the given index is indented, the snippet will include:
 * - The entire line containing the index
 * - All subsequent lines with indentation level greater than the current line
 *
 * @param text - The full markdown text
 * @param index - The position within the text where extraction should be centered
 * @param maxLines - Maximum number of lines to include in the snippet (default: Infinity)
 * @returns The extracted snippet
 */
export function extractSnippet(
  pageName: string,
  text: string,
  index: number,
  maxLines: number = 10,
): string {
  const lines = text.split("\n");
  const lineLengths = lines.map((line) => line.length);
  const targetLineIndex = text.substring(0, index).split("\n").length - 1;
  const targetLine = lines[targetLineIndex];
  const targetIndent = getIndentationLevel(targetLine);

  // Start with the target line
  const snippetLines = [targetLine.substring(targetIndent)];

  // Add all subsequent lines that have greater indentation than the target line
  for (
    let i = targetLineIndex + 1;
    i < lines.length;
    i++
  ) {
    let line = lines[i];
    const lineIndent = getIndentationLevel(line);

    if (snippetLines.length >= maxLines) {
      snippetLines.push("...");
      break;
    }

    // Stop if we hit an empty line
    if (line.trim() === "") {
      break;
    }

    // Stop if we hit a line with indentation equal to or less than the target line
    if (lineIndent <= targetIndent) {
      break;
    }

    // Find tasks that don't have a page reference, and add one
    const taskMatch = line.match(
      /^(\s*)([\*\-]\s+\[[^\]]+\]\s+)([^\[][^\[].+)$/,
    );
    if (taskMatch) {
      const pos = lineLengths.slice(0, i).reduce((acc, len) =>
        acc + len + 1, 0) +
        taskMatch[1].length;
      line = taskMatch[1] + taskMatch[2] + `[[${pageName}@${pos}]] ` +
        taskMatch[3];
    }
    snippetLines.push(line.substring(targetIndent));
  }

  let result = snippetLines.join("\n");

  // Specific cases: because headers look bad in snippets, let's strip those leading `#`
  result = result.replace(/^(#+)\s+/, "");

  return result;
}

/**
 * Gets the indentation level of a line (number of leading spaces).
 *
 * @param line - The line to measure
 * @returns The number of leading spaces
 */
function getIndentationLevel(line: string): number {
  const match = line.match(/^( *)/);
  return match ? match[1].length : 0;
}


================================================
FILE: plugs/index/space_lua.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import { extractFrontMatter } from "./frontmatter.ts";
import { assertEquals } from "@std/assert";
import { indexSpaceLua } from "./space_lua.ts";

const testPage = `
Hello
\`\`\`space-lua
function sup()
end
\`\`\`

\`\`\`space-lua
-- priority: 10
function sup()
end
\`\`\`
`.trim();

Deno.test("Test space lua indexing", async () => {
  createMockSystem();

  const tree = parseMarkdown(testPage);
  const frontmatter = extractFrontMatter(tree);

  const pageMeta: PageMeta = {
    ref: "folder/test",
    name: "folder/test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };

  const objects = await indexSpaceLua(pageMeta, frontmatter, tree);
  assertEquals(objects.length, 2);
  assertEquals(objects[0].priority, undefined);
  assertEquals(objects[1].priority, 10);
});


================================================
FILE: plugs/index/space_lua.ts
================================================
import {
  collectNodesOfType,
  findNodeOfType,
  type ParseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import type { FrontMatter } from "./frontmatter.ts";

export type SpaceLuaObject = ObjectValue<{
  script: string;
  priority?: number;
}>;

export function indexSpaceLua(
  pageMeta: PageMeta,
  _frontmatter: FrontMatter,
  tree: ParseTree,
) {
  const allSpaceLuas: SpaceLuaObject[] = [];
  collectNodesOfType(tree, "FencedCode").map((t) => {
    const codeInfoNode = findNodeOfType(t, "CodeInfo");
    if (!codeInfoNode) {
      return;
    }
    const fenceType = codeInfoNode.children![0].text!;
    if (fenceType !== "space-lua") {
      return;
    }
    const codeTextNode = findNodeOfType(t, "CodeText");
    if (!codeTextNode) {
      // Honestly, this shouldn't happen
      return;
    }
    const codeText = codeTextNode.children![0].text!;
    // Parse out "-- priority: <number>"
    const priority = codeText.match(/--\s*priority:\s*(-?\d+)/)?.[1];

    allSpaceLuas.push({
      ref: `${pageMeta.name}@${t.from!}`,
      tag: "space-lua",
      range: [t.from!, t.to!],
      script: codeText,
      priority: priority !== undefined ? +priority : undefined,
    });
  });
  return Promise.resolve(allSpaceLuas);
}


================================================
FILE: plugs/index/space_style.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import { extractFrontMatter } from "./frontmatter.ts";
import { assertEquals } from "@std/assert";
import { indexSpaceStyle } from "./space_style.ts";

const testPage = `
Hello
\`\`\`space-style
.hello {}
\`\`\`

\`\`\`space-style
/* priority: 10 */
.hello2 {}
\`\`\`
`.trim();

Deno.test("Test space style indexing", async () => {
  createMockSystem();

  const tree = parseMarkdown(testPage);
  const frontmatter = extractFrontMatter(tree);

  const pageMeta: PageMeta = {
    ref: "folder/test",
    name: "folder/test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };

  const objects = await indexSpaceStyle(pageMeta, frontmatter, tree);
  assertEquals(objects.length, 2);
  assertEquals(objects[0].priority, undefined);
  assertEquals(objects[1].priority, 10);
});


================================================
FILE: plugs/index/space_style.ts
================================================
import {
  collectNodesOfType,
  findNodeOfType,
  type ParseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import type { FrontMatter } from "./frontmatter.ts";

export type StyleObject = ObjectValue<{
  style: string;
  priority?: number;
}>;

export function indexSpaceStyle(
  pageMeta: PageMeta,
  _frontmatter: FrontMatter,
  tree: ParseTree,
) {
  const allStyles: StyleObject[] = [];

  // Also collect CSS from custom styles in config
  collectNodesOfType(tree, "FencedCode").map((t) => {
    const codeInfoNode = findNodeOfType(t, "CodeInfo");
    if (!codeInfoNode) {
      return;
    }

    const fenceType = codeInfoNode.children![0].text!;
    if (fenceType !== "space-style") {
      return;
    }

    const codeTextNode = findNodeOfType(t, "CodeText");
    if (!codeTextNode) {
      // Honestly, this shouldn't happen
      return;
    }
    const codeText = codeTextNode.children![0].text!;

    // Parse out /* priority: */
    const priority = codeText.match(/\/\*+\s*priority:\s*(-?\d+)/)?.[1];

    allStyles.push({
      ref: `${pageMeta.name}@${t.from!}`,
      tag: "space-style",
      range: [t.from!, t.to!],
      style: codeText,
      priority: priority !== undefined ? +priority : undefined,
    });
  });

  return Promise.resolve(allStyles);
}


================================================
FILE: plugs/index/table.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import { extractFrontMatter } from "./frontmatter.ts";
import { assertEquals } from "@std/assert";
import { indexTables } from "./table.ts";

const testPage = `
| name | age |
|----------|----------|
| Frank | 20 |
| Fred | 21 |
`.trim();

Deno.test("Test table indexing", async () => {
  createMockSystem();

  const tree = parseMarkdown(testPage);
  const frontmatter = extractFrontMatter(tree);

  const pageMeta: PageMeta = {
    ref: "test",
    name: "test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };

  const datas = await indexTables(pageMeta, frontmatter, tree);
  assertEquals(datas.length, 2);
  assertEquals(datas[0].name, "Frank");
  assertEquals(datas[0].age, "20");
  assertEquals(datas[1].name, "Fred");
  assertEquals(datas[1].age, "21");
});


================================================
FILE: plugs/index/table.ts
================================================
import {
  renderToText,
  replaceNodesMatching,
} from "@silverbulletmd/silverbullet/lib/tree";
import {
  collectNodesMatching,
  collectNodesOfType,
  type ParseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import { extractHashtag } from "@silverbulletmd/silverbullet/lib/tags";
import type { FrontMatter } from "./frontmatter.ts";

type TableRowObject =
  & ObjectValue<{
    tableref: string;
    page: string;
    pos: number;
  }>
  & Record<string, any>;

/**
 * Replace any invalid characters in a string so it can serve as indexed field name in a query
 * @param str the input string
 * @returns All lowercase string with special chars replaced with underscore
 */
function cleanHeaderFieldName(str: string): string {
  return str.replace(/[\W_]+/g, "_").toLowerCase();
}

/**
 * Concat text properties of all child nodes
 * @param nodes
 * @returns text of all child nodes
 */
function concatChildrenTexts(nodes: ParseTree[]): string {
  return nodes.map((c) => c.text).join("").trim();
}

/**
 * Concat text properties of all child nodes, preserving links
 * @param nodes
 * @returns text, preserving links, of all child nodes
 */
function concatChildrenTextsPreserveLinks(nodes: ParseTree[]): string {
  return nodes.map((c) => renderToText(c)).join("").trim();
}

export function indexTables(
  pageMeta: PageMeta,
  _frontmatter: FrontMatter,
  tree: ParseTree,
) {
  const result: ObjectValue<TableRowObject>[] = [];

  collectNodesMatching(
    tree,
    (t) => !!t.type?.startsWith("Table") && t.type !== "TableConstructor",
  ).forEach(
    (table) => {
      const rows = collectNodesOfType(table, "TableRow");
      const header = collectNodesOfType(table, "TableHeader")[0]; //Use first header. As per markdown spec there can only be exactly one
      const headerLabels = collectNodesOfType(header, "TableCell").map((cell) =>
        concatChildrenTexts(cell.children!)
      ).map(cleanHeaderFieldName);
      //console.log("Header labels", headerLabels);

      for (const row of rows) {
        const tags = new Set<string>();
        collectNodesOfType(row, "Hashtag").forEach((h) => {
          // Push tag to the list, removing the initial #
          tags.add(extractHashtag(h.children![0].text!));
        });

        const cells = collectNodesOfType(row, "TableCell");

        const tableRow: TableRowObject = {
          tableref: `${pageMeta.name}@${table.from}`,
          ref: `${pageMeta.name}@${row.from}`,
          tag: "table",
          tags: [...tags],
          page: pageMeta.name,
          pos: row.from!,
          range: [row.from!, row.to!],
        };
        cells.forEach((c, i) => {
          replaceNodesMatching(c, (tree) => {
            if (tree.type === "Hashtag") {
              return null;
            }
          });
          const content = concatChildrenTextsPreserveLinks(c.children!);
          const label = headerLabels[i];
          tableRow[label!] = content;
        });
        result.push(tableRow);
      }
    },
  );

  return Promise.resolve(result);
}


================================================
FILE: plugs/index/tags.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import { extractFrontMatter } from "./frontmatter.ts";
import { assertEquals } from "@std/assert";
import { indexTags } from "./tags.ts";

const testPage = `
#page-tag

* Item #item-tag
* [ ] Task #task-tag
`.trim();

Deno.test("Test tag indexing", async () => {
  createMockSystem();

  const tree = parseMarkdown(testPage);
  const frontmatter = extractFrontMatter(tree);

  const pageMeta: PageMeta = {
    ref: "folder/test",
    name: "folder/test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };

  const objects = await indexTags(pageMeta, frontmatter, tree);
  assertEquals(objects.length, 3);
  assertEquals(objects[0].name, "page-tag");
  assertEquals(objects[0].parent, "page");
  assertEquals(objects[1].name, "item-tag");
  assertEquals(objects[1].parent, "item");
  assertEquals(objects[2].name, "task-tag");
  assertEquals(objects[2].parent, "task");
});


================================================
FILE: plugs/index/tags.ts
================================================
import type { FrontMatter } from "./frontmatter.ts";
import {
  collectNodesOfType,
  findParentMatching,
  type ParseTree,
  replaceNodesMatching,
  traverseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import type { CompleteEvent } from "@silverbulletmd/silverbullet/type/client";
import { tagRegex } from "../../client/markdown_parser/constants.ts";
import { extractHashtag } from "@silverbulletmd/silverbullet/lib/tags";
import { index } from "@silverbulletmd/silverbullet/syscalls";

export type TagObject = ObjectValue<{
  name: string;
  page: string;
  parent: string;
}>;

/**
 * Handles indexing of page, item and task level tags, data tags are handled in data.ts
 */
export function indexTags(
  pageMeta: PageMeta,
  frontmatter: FrontMatter,
  tree: ParseTree,
) {
  const tags = new Set<string>(); // name:parent
  const pageTags: string[] = frontmatter.tags || [];
  for (const pageTag of pageTags) {
    tags.add(`${pageTag}:page`);
  }
  collectNodesOfType(tree, "Hashtag").forEach((h) => {
    const tagName = extractHashtag(h.children![0].text!);
    // Check if this occurs in the context of a task
    if (findParentMatching(h, (n) => n.type === "Task")) {
      tags.add(`${tagName}:task`);
    } else if (findParentMatching(h, (n) => n.type === "ListItem")) {
      // Or an item
      tags.add(`${tagName}:item`);
    } else if (findParentMatching(h, (n) => n.type === "Paragraph")) {
      // Still indexing this as a page tag
      tags.add(`${tagName}:page`);
    }
  });
  return Promise.resolve([...tags].map((tag) => {
    const [tagName, parent] = tag.split(":");
    return {
      ref: tag,
      tag: "tag",
      name: tagName,
      page: pageMeta.name,
      parent,
    };
  }));
}

export async function tagComplete(completeEvent: CompleteEvent) {
  const inLinkMatch = /(?:\[\[|\[.*\]\()([^\]]*)$/.exec(
    completeEvent.linePrefix,
  );
  if (inLinkMatch) {
    return null;
  }

  const match = /#[^#\s\[\]]+\w*$/.exec(completeEvent.linePrefix);
  if (!match) {
    return null;
  }

  // Query all tags with a matching parent
  const allTags: string[] = await index.queryLuaObjects<string>(
    "tag",
    {
      distinct: true,
      select: { type: "Variable", name: "name", ctx: {} as any },
    },
  );

  return {
    from: completeEvent.pos - match[0].length,
    options: allTags.map((tag) => ({
      label: renderHashtag(tag),
      type: "tag",
    })),
  };
}

export function updateITags<T>(obj: ObjectValue<T>, frontmatter: FrontMatter) {
  const itags = new Set<string>([obj.tag, ...frontmatter.tags || []]);
  for (const tag of obj.tags || []) {
    itags.add(tag);
  }
  for (const tag of obj.itags || []) {
    itags.add(tag);
  }
  obj.itags = [...itags];
}

/** Get markup for a hashtag name with # prefix and angle brackets if necessary */
export function renderHashtag(name: string): string {
  // detect with the same regex as the parser
  const simple: string = "#" + name;
  const match = simple.match(tagRegex);
  if (!match || match[0].length !== simple.length) {
    return `#<${name}>`;
  } else return simple;
}

/**
 * Cleans hashtags from a tree as a side effect
 * @param n
 * @return found hashtags
 */
export function collectTags(n: ParseTree): string[] {
  const tags = new Set<string>();
  traverseTree(n, (n) => {
    if (n.type === "Hashtag") {
      tags.add(extractHashtag(n.children![0].text!));
      return true;
    } else if (n.type === "OrderedList" || n.type === "BulletList") {
      // Don't traverse into sub-lists
      return true;
    }
    return false;
  });
  return [...tags];
}

/**
 * Cleans hashtags from a tree as a side effect
 * @param n
 */
export function cleanTags(n: ParseTree) {
  return replaceNodesMatching(n, (n) => {
    if (n.type === "Hashtag") {
      return null;
    }
    return;
  });
}


================================================
FILE: plugs/index/task.ts
================================================
import {
  config,
  editor,
  events,
  markdown,
  space,
  sync,
} from "@silverbulletmd/silverbullet/syscalls";

import {
  addParentPointers,
  collectNodesMatching,
  findNodeMatching,
  findNodeOfType,
  findParentMatching,
  nodeAtPos,
  type ParseTree,
  renderToText,
} from "@silverbulletmd/silverbullet/lib/tree";
import {
  getNameFromPath,
  getOffsetFromLineColumn,
  isMarkdownPath,
  parseToRef,
} from "@silverbulletmd/silverbullet/lib/ref";
import type { ClickEvent } from "@silverbulletmd/silverbullet/type/client";

export const completeStates = ["x", "X"];

export const incompleteStates = [" "];

export function taskToggle(event: ClickEvent) {
  if (event.altKey) {
    return;
  }
  return taskCycleAtPos(event.pos);
}

async function convertListItemToTask(node: ParseTree) {
  const listMark = node.children![0];
  const originalMark = renderToText(listMark);

  // Determine the task marker based on the original list type
  let taskMarker: string;
  if (originalMark.match(/^\d+\./)) {
    // Numbered list: preserve the number
    taskMarker = originalMark + " [ ]";
  } else {
    // Bullet list: use standard bullet
    taskMarker = "* [ ]";
  }

  await editor.dispatch({
    changes: {
      from: listMark.from,
      to: listMark.to,
      insert: taskMarker,
    },
  });
}

async function removeTaskCheckbox(listItemNode: ParseTree) {
  const taskNode = findNodeOfType(listItemNode, "Task");
  if (!taskNode) {
    console.error("No task node found in list item");
    return;
  }

  //  Task node contains: TaskMark, TaskState, and text content. Keep just list marker and content after the checkbox
  const listMark = listItemNode.children![0];
  const contentAfterCheckbox = taskNode.children!.slice(1); // Skip TaskMark which contains [ ]

  const textContent = contentAfterCheckbox.map(renderToText).join("");

  // Replace entire list item content
  await editor.dispatch({
    changes: {
      from: listItemNode.from!,
      to: listItemNode.to!,
      insert: renderToText(listMark) + textContent,
    },
  });
}

async function cycleTaskState(
  node: ParseTree,
  removeCheckbox: boolean = false,
) {
  const stateText = node.children![1].text!;

  // If removeCheckbox is true and task is complete, remove checkbox entirely
  if (removeCheckbox && completeStates.includes(stateText)) {
    // Convert back to regular list item
    const taskNode = node.parent!;
    const listItemNode = taskNode.parent!;
    await removeTaskCheckbox(listItemNode);
    return;
  }

  let changeTo: string | undefined;
  if (completeStates.includes(stateText)) {
    changeTo = " ";
  } else if (incompleteStates.includes(stateText)) {
    changeTo = "x";
  } else {
    // Not a checkbox, but a custom state
    const allStates = await config.get("taskStates", {});
    const states = Object.keys(allStates);
    console.log("All states", states);
    states.sort();
    // Select a next state
    let currentStateIndex = states.indexOf(stateText);
    if (currentStateIndex === -1) {
      console.error("Unknown state", stateText);
      currentStateIndex = 0;
    }
    const nextStateIndex = (currentStateIndex + 1) % states.length;
    changeTo = states[nextStateIndex];
  }
  await editor.dispatch({
    changes: {
      from: node.children![1].from,
      to: node.children![1].to,
      insert: changeTo,
    },
  });

  const parentWikiLinks = collectNodesMatching(
    node.parent!,
    (n) => n.type === "WikiLinkPage",
  );
  for (const wikiLink of parentWikiLinks) {
    const ref = wikiLink.children![0].text!;
    if (ref.includes("@")) {
      await updateTaskState(ref, stateText, changeTo);
    }
  }

  await events.dispatchEvent("task:stateChange", {
    from: node.parent!.from,
    to: node.parent!.to,
    newState: changeTo,
    text: renderToText(node.parent),
  });
}

export async function updateTaskState(
  path: string,
  oldState: string,
  newState: string,
) {
  const currentPath = await editor.getCurrentPath();
  const ref = parseToRef(path);

  if (
    !ref || !ref.details || !isMarkdownPath(ref.path) ||
    (ref.details.type !== "linecolumn" && ref.details.type !== "position")
  ) {
    console.log("No position found in page ref, skipping", ref);
    return;
  }

  if (ref.path === currentPath) {
    // In current page, just update the task marker with dispatch
    const editorText = await editor.getText();

    const targetPos = ref.details.type === "position"
      ? ref.details.pos
      : getOffsetFromLineColumn(
        editorText,
        ref.details.line,
        ref.details.column,
      );

    // Check if the task state marker is still there
    const targetText = editorText.substring(
      targetPos + 3, // 3 because: "* ["
      targetPos + 3 + oldState.length,
    );
    if (targetText !== oldState) {
      console.error(
        "Reference not a task marker, out of date?",
        targetText,
      );
      return;
    }
    await editor.dispatch({
      changes: {
        from: targetPos + 3,
        to: targetPos + 3 + oldState.length,
        insert: newState,
      },
    });
  } else {
    const pageName = getNameFromPath(ref.path);
    let text = await space.readPage(pageName);

    const referenceMdTree = await markdown.parseMarkdown(text);
    const targetPos = ref.details.type === "position"
      ? ref.details.pos
      : getOffsetFromLineColumn(
        text,
        ref.details.line,
        ref.details.column,
      );

    const itemNode = nodeAtPos(referenceMdTree, targetPos + 1);
    if (!itemNode) {
      console.error(
        "Reference not a valid item, out of date?",
        itemNode,
      );
      return;
    }
    const taskStateNode = findNodeOfType(itemNode, "TaskState");
    if (!taskStateNode) {
      console.error(
        "Cannot find a task state",
        taskStateNode,
      );
      return;
    }
    taskStateNode.children![1].text = newState;
    text = renderToText(referenceMdTree);
    await space.writePage(pageName, text);
    sync.performFileSync(`${pageName}.md`);
  }
}

export async function taskCycleAtPos(pos: number) {
  const text = await editor.getText();
  const mdTree = await markdown.parseMarkdown(text);
  addParentPointers(mdTree);

  let node = nodeAtPos(mdTree, pos);
  if (node) {
    if (node.type === "TaskMark") {
      node = node.parent!;
    }
    if (node.type === "TaskState") {
      await cycleTaskState(node, false);
    }
  }
}

export async function taskCycleCommand() {
  const text = await editor.getText();
  const pos = await editor.getCursor();
  const tree = await markdown.parseMarkdown(text);
  addParentPointers(tree);

  let node = nodeAtPos(tree, pos);
  if (!node) {
    await editor.flashNotification("No task at cursor");
    return;
  }
  if (["BulletList", "Document"].includes(node.type!)) {
    // Likely at the end of the line, let's back up a position
    node = nodeAtPos(tree, pos - 1);
  }
  if (!node) {
    await editor.flashNotification("No task at cursor");
    return;
  }
  const taskNode = node.type === "Task"
    ? node
    : findParentMatching(node!, (n) => n.type === "Task");

  if (taskNode) {
    const taskState = findNodeOfType(taskNode!, "TaskState");
    if (taskState) {
      // Cycle states: [ ] -> [x] -> (remove checkbox) -> [ ]
      await cycleTaskState(taskState, true);
    }
    return;
  }

  // Convert a bullet point to a task
  const listItem = findParentMatching(node!, (n) => n.type === "ListItem");
  if (!listItem) {
    await editor.flashNotification("No task at cursor");
    return;
  }

  // Check if this ListItem already contains a Task (cursor might be at beginning of line)
  const existingTask = findNodeOfType(listItem, "Task");
  if (existingTask) {
    const taskState = findNodeOfType(existingTask, "TaskState");
    if (taskState) {
      await cycleTaskState(taskState, true);
    }
    return;
  }

  convertListItemToTask(listItem);
}

export async function removeCompletedTasksCommand() {
  const tree = await markdown.parseMarkdown(await editor.getText());
  addParentPointers(tree);

  // Taking this ugly approach because the tree is modified in place
  // Just finding and removing one task at a time and then repeating until nothing changes
  const allCompletedStates = completeStates.concat(
    Object.values(await config.get("taskStates", {})).filter((ts: any) =>
      ts.done
    ).map((ts: any) => ts.name),
  );
  while (true) {
    const completedTaskNode = findNodeMatching(tree, (node) => {
      return node.type === "Task" &&
        allCompletedStates.includes(node.children![0].children![1].text!);
    });
    if (completedTaskNode) {
      // Ok got one, let's remove it
      const listItemNode = completedTaskNode.parent!;
      const bulletListNode = listItemNode.parent!;
      // Remove the list item
      const listItemIdx = bulletListNode.children!.indexOf(listItemNode);
      let removeItems = 1;
      if (bulletListNode.children![listItemIdx + 1]?.text === "\n") {
        removeItems++;
      }
      bulletListNode.children!.splice(listItemIdx, removeItems);
    } else {
      // No completed tasks left, we're done
      break;
    }
  }

  await editor.setText(renderToText(tree));
}


================================================
FILE: plugs/index/widget.ts
================================================
import { codeWidget } from "@silverbulletmd/silverbullet/syscalls";

export async function refreshWidgets() {
  await codeWidget.refreshAll();
}


================================================
FILE: plugs/index/yaml.ts
================================================
import YAML from "js-yaml";

export type YamlStringifyOptions = {
  /** indentation width to use (in spaces). */
  indent?: number;
  /** when true, will not add an indentation level to array elements */
  noArrayIndent?: boolean;
  /** do not throw on invalid types (like function in the safe schema) and skip pairs and single values with such types. */
  skipInvalid?: boolean;
  /** specifies level of nesting, when to switch from block to flow style for collections. -1 means block style everwhere */
  flowLevel?: number;
  /** if true, sort keys when dumping YAML. If a function, use the function to sort the keys. (default: false) */
  sortKeys?: boolean;
  /** set max line width. (default: 80) */
  lineWidth?: number;
  /** if true, don't convert duplicate objects into references (default: false) */
  noRefs?: boolean;
  /** if true don't try to be compatible with older yaml versions. Currently: don't quote "yes", "no" and so on, as required for YAML 1.1 (default: false) */
  noCompatMode?: boolean;
  /**
   * if true flow sequences will be condensed, omitting the space between `key: value` or `a, b`. Eg. `'[a,b]'` or `{a:{b:c}}`.
   * Can be useful when using yaml for pretty URL query params as spaces are %-encoded. (default: false).
   */
  condenseFlow?: boolean;
  /** strings will be quoted using this quoting style. If you specify single quotes, double quotes will still be used for non-printable characters. (default: `'`) */
  quotingType?: "'" | '"';
  /** if true, all non-key strings will be quoted even if they normally don't need to. (default: false) */
  forceQuotes?: boolean;
};

export function parse(text: string): any {
  return YAML.load(text);
}

export function stringify(
  text: string,
  options: YamlStringifyOptions = {},
): any {
  return YAML.dump(text, {
    quotingType: '"',
    noCompatMode: true,
    ...options,
  });
}


================================================
FILE: plugs/plug-manager/plugmanager.ts
================================================
import {
  editor,
  events,
  space,
  system,
} from "@silverbulletmd/silverbullet/syscalls";
import { builtinPlugPaths } from "../builtin_plugs.ts";
import type { ResolvedPlug } from "@silverbulletmd/silverbullet/type/event";

export async function reloadPlugsCommand() {
  console.log("Reloading plug...");
  await system.reloadPlugs();
}

export async function updatePlugsCommand() {
  // Save the current file (could be a config page)
  await editor.save();
  // Reload the config and commands
  await editor.reloadConfigAndCommands();
  await editor.flashNotification("Updating plugs...");
  try {
    const plugList: string[] = [];
    let configPlugs: any[] = await system.getConfig("plugs", []);
    if (Object.keys(configPlugs).length === 0) {
      // Handle case of empty Lua table
      configPlugs = [];
    }
    if (!Array.isArray(configPlugs)) {
      throw new Error("Expected 'plugs' in Space Config to be an array");
    }
    const stringPlugs = configPlugs.filter((plug) => typeof plug === "string");
    if (stringPlugs.length !== configPlugs.length) {
      throw new Error(
        `${
          configPlugs.length - stringPlugs.length
        } plugs in Space Config aren't set as strings`,
      );
    }
    plugList.push(...stringPlugs);

    // De-duplicate URIs, this is safe because by definition they point to the same plug
    plugList.forEach((uri, index) => {
      if (plugList.indexOf(uri) !== index) {
        plugList.splice(index, 1);
      }
    });

    console.log("Found Plug URIs:", plugList);
    const allCustomPlugPaths: string[] = [];
    for (const plugUri of plugList) {
      const [protocol, ...rest] = plugUri.split(":");

      const manifests = await events.dispatchEvent(
        `get-plug:${protocol}`,
        rest.join(":"),
      );

      if (manifests.length === 0) {
        console.error("Could not resolve plug uri", plugUri);
      } else if (manifests.length > 1) {
        console.error(
          `Got multiple results for plug uri ${plugUri}. Proceeding with the first result`,
        );
      }

      const manifest = manifests[0] as unknown;

      let code: string;
      if (typeof manifest === "string") {
        // "Legacy" syntax
        code = manifest;
      } else if (
        manifest &&
        typeof manifest === "object" &&
        "code" in manifest &&
        typeof manifest.code === "string"
      ) {
        code = manifest.code;
      } else {
        console.error(
          "Invalid return from `get-plug` event. Please return value of type `{ code: string, name?: string } | string`",
        );

        continue;
      }

      let name: string;
      if (
        typeof manifest !== "object" ||
        !("name" in manifest) ||
        typeof manifest.name !== "string"
      ) {
        // Try taking a good guess at a name if it isn't provided
        const match = /\/([^\/]+)\.plug\.js$/.exec(plugUri);
        if (!match) {
          console.error(
            `No plug name provided and could not extract name from ${plugUri} ignoring...`,
          );

          continue;
        }

        name = match[1];
      } else {
        name = manifest.name;
      }

      const path = `_plug/${name}.plug.js`;

      // Validate the extracted path
      if (builtinPlugPaths.includes(path)) {
        throw new Error(
          `Plug '${path}' is conflicting with a built-in plug`,
        );
      }
      if (allCustomPlugPaths.includes(path)) {
        throw new Error(
          `Plug '${path}' defined by more than one URI`,
        );
      }

      allCustomPlugPaths.push(path);

      console.log("Writing", path);
      await space.writeDocument(path, new TextEncoder().encode(code));
    }

    const allPlugPaths = [...builtinPlugPaths, ...allCustomPlugPaths];
    // And delete extra ones
    for (const { name: existingPlug } of await space.listPlugs()) {
      // Only delete plugs installed (for legacy reason) installed under _plug
      if (
        existingPlug.startsWith("_plug/") &&
        !allPlugPaths.includes(existingPlug)
      ) {
        console.log("Deleting", existingPlug);
        await space.deleteDocument(existingPlug);
      }
    }
    await editor.flashNotification("All done!");
    system.reloadPlugs();
  } catch (e: any) {
    editor.flashNotification("Error updating plugs: " + e.message, "error");
  }
}

export async function getPlugHTTPS(url: string): Promise<ResolvedPlug> {
  const fullUrl = `https:${url}`;
  console.log("Now fetching plug code from", fullUrl);
  const req = await fetch(fullUrl);
  if (req.status !== 200) {
    throw new Error(`Could not fetch plug code from ${fullUrl}`);
  }
  return req.text();
}

export function getPlugGithub(identifier: string): Promise<ResolvedPlug> {
  const [owner, repo, path] = identifier.split("/");
  let [repoClean, branch] = repo.split("@");
  if (!branch) {
    branch = "main"; // or "master"?
  }
  return getPlugHTTPS(
    `//raw.githubusercontent.com/${owner}/${repoClean}/${branch}/${path}`,
  );
}

export async function getPlugGithubRelease(
  identifier: string,
): Promise<ResolvedPlug> {
  let [owner, repo, version] = identifier.split("/");
  let releaseInfo: any = {};
  let req: Response;
  if (!version || version === "latest") {
    console.log(`Fetching release manifest of latest version for ${repo}`);
    req = await fetch(
      `https://api.github.com/repos/${owner}/${repo}/releases/latest`,
    );
  } else {
    console.log(`Fetching release manifest of version ${version} for ${repo}`);
    req = await fetch(
      `https://api.github.com/repos/${owner}/${repo}/releases/tags/${version}`,
    );
  }
  if (req.status !== 200) {
    throw new Error(
      `Could not fetch release manifest from ${identifier}`,
    );
  }
  releaseInfo = await req.json();
  version = releaseInfo.tag_name;

  let assetName: string | undefined;
  // Support plug like foo.plug.js are in repo silverbullet-foo
  const shortName = repo.startsWith("silverbullet-")
    ? repo.slice("silverbullet-".length)
    : undefined;
  for (const asset of releaseInfo.assets ?? []) {
    if (
      asset.name === `${repo}.plug.js` ||
      shortName && asset.name === `${shortName}.plug.js`
    ) {
      assetName = asset.name;
      break;
    }
  }
  if (!assetName) {
    throw new Error(
      `Could not find "${repo}.plug.js"` +
        (shortName ? ` or "${shortName}.plug.js"` : "") +
        ` in release ${version}`,
    );
  }

  const finalUrl =
    `//github.com/${owner}/${repo}/releases/download/${version}/${assetName}`;
  const code = await getPlugHTTPS(finalUrl);
  return {
    code: typeof code === "string" ? code : code.code,
    name: shortName ?? repo,
  };
}


================================================
FILE: plugs/sync/sync.ts
================================================
import {
  editor,
  index,
  space,
  sync,
} from "@silverbulletmd/silverbullet/syscalls";

export async function syncSpaceCommand() {
  await editor.flashNotification("Syncing space...");
  await sync.performSpaceSync();
  await editor.flashNotification("Done.");
}

export async function syncFileCommand() {
  await editor.flashNotification("Syncing file...");
  await sync.performFileSync(await editor.getCurrentPath());
  await editor.flashNotification("Done.");
}

export async function spaceSyncComplete(message: { operations: number }) {
  if (message.operations > 0) {
    // Update the page list
    await space.listFiles();
  }

  index.ensureFullIndex();
}

export async function updateSyncStatus(
  event: {
    status: {
      filesProcessed: number;
      totalFiles: number;
    };
  },
) {
  // Update the status in the UI
  const percentage = Math.round(
    (event.status.filesProcessed / event.status.totalFiles) * 100,
  );
  if (percentage >= 99) {
    // Just hide it
    await editor.showProgress();
  } else {
    await editor.showProgress(
      percentage,
      "sync",
    );
  }
}

export async function reportSyncConflict({ path }: { path: string }) {
  await editor.flashNotification(
    `Sync: conflict detected for ${path} - conflict copy created`,
    "error",
  );
}


================================================
FILE: scripts/deploy_silverbullet_md.sh
================================================
#!/bin/bash -e

cd $(realpath $(dirname $(dirname $0)))

IMAGE_NAME="ghcr.io/silverbulletmd/silverbullet:v2"
WEBSITE_SPACE="./website"
WEBSITE_PORT=3002

# Get the current image ID
BEFORE_PULL=$(docker images -q $IMAGE_NAME)

# Pull the Docker image
docker pull $IMAGE_NAME

# Get the new image ID
AFTER_PULL=$(docker images -q $IMAGE_NAME)

# Compare the image IDs
if [ "$BEFORE_PULL" == "$AFTER_PULL" ]; then
    echo "The Docker image has not been updated. Done!"
    exit 0
fi

echo "The Docker image has been updated, let's update the website content."
git pull

# Check if a silverbullet container is running already
if [ "$(docker ps -q -f name=silverbullet-v2)" ]; then
    echo "A silverbullet container is running, let's stop it."
    docker kill silverbullet-v2
    docker rm silverbullet-v2
fi

# Remove the old database
echo "Removing old databases"
rm -f $WEBSITE_SPACE/.silverbullet.db*

echo "Starting new silverbullet container"
docker run -d --name silverbullet-v2 --restart unless-stopped -v $WEBSITE_SPACE:/space -e SB_INDEX_PAGE=SilverBullet -e SB_READ_ONLY=1 -p $WEBSITE_PORT:3000 $IMAGE_NAME


echo "Waiting for the servers to start"

# Repeatedly try to connect to the server until it responds
until $(curl --output /dev/null --silent --head --fail http://localhost:$WEBSITE_PORT/); do
    printf '.'
    sleep 1
done
echo
echo "We're back up!"

================================================
FILE: scripts/deploy_silverbullet_playground.sh
================================================
#!/bin/bash -e

cd $(realpath $(dirname $(dirname $0)))

IMAGE_NAME="ghcr.io/silverbulletmd/silverbullet:v2"
PLAYGROUND_SPACE="/tmp/silverbullet-playground"
PLAYGROUND_PORT=3001
SB_USER=silverbullet:silverbullet

# Now do the same for the playground
echo "Now resetting the playground"

# Check if a silverbullet container is running already
if [ "$(docker ps -q -f name=silverbullet-playground)" ]; then
    echo "A playground container is running, let's stop it."
    docker kill silverbullet-playground
    docker rm silverbullet-playground
fi

# Remove the old space
echo "Removing old playground space"
rm -rf $PLAYGROUND_SPACE

echo "Creating fresh playground space"
mkdir -p $PLAYGROUND_SPACE
cp -r scripts/playground_space/* $PLAYGROUND_SPACE/

echo "Starting new playground container"
docker run -d --name silverbullet-playground --restart unless-stopped -v $PLAYGROUND_SPACE:/space -e SB_SHELL_BACKEND=off -e SB_USER=$SB_USER -p $PLAYGROUND_PORT:3000 $IMAGE_NAME

echo "Waiting for the server to start"

until $(curl --output /dev/null --silent --head --fail http://localhost:$PLAYGROUND_PORT/); do
    printf '.'
    sleep 1
done
echo
echo "We're back up!"

================================================
FILE: scripts/playground_space/index.md
================================================
# Welcome to the SilverBullet Playground
This is a fully functional SilverBullet instance that **resets every 15 minutes.**

So go ahead, play around, but don't keep anything valuable here. It will be gone in a snap.

## Don’t just sit there, try it!
* Click on the page picker (book icon) icon at the top right, or hit `Cmd-k` (Mac) or `Ctrl-k` (Linux and Windows) to open the **page switcher**. Type the name of a non-existent page to create it (although it won’t save in this environment).
* Click on the terminal button (top right) or hit `Cmd-/` (Mac) or `Ctrl-/` (Linux and Windows) to open the **command palette** (note that not all commands will work in this mode).
* Select some text and hit `Cmd-b` (Mac) or `Ctrl-b` (Windows/Linux) to make it **bold**, or `Cmd-i` (Mac) or `Ctrl-i` (Windows/Linux) to make it _italic_.
* Click a link somewhere on this page to navigate there.
* Start typing `[[` somewhere to insert a page link (with completion).
* [ ] Tap this box 👈 to mark this task as done.
* Start typing `:party` to trigger the emoji picker 🎉
* Type `/` somewhere in the text to invoke a **slash command**.
* Click this button {[Editor: Toggle Vim Mode]} to toggle Vim mode
* Open this site on your phone or tablet and... it just works!
* Are you using a browser with **PWA support** (e.g., any Chromium-based
  browser or recent version of Safari)? Click on that little icon to the right of your location bar that says “Install SilverBullet” to give SB its own window frame and desktop icon, like it is a stand-alone app. Now, unplug your network cable and reload the page. It still works!


================================================
FILE: scripts/release.sh
================================================
#!/bin/bash -e

VERSION=$1

# Patch version is version.ts and deno.json
echo "export const version = \"$VERSION\";" > version.ts
sed -i '' "s/\(\"version\": \"\)[^\"]*\(\"\)/\1$VERSION\2/" deno.json
git commit -am $VERSION
git tag $VERSION
git push && git push --tags


================================================
FILE: server/auth.go
================================================
package server

import (
	"fmt"
	"html/template"
	"log"
	"math"
	"net/http"
	"path"
	"strings"
	"sync"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/render"
)

// path to auth page in the client bundle
const authPagePath = ".client/auth.html"

// These endpoints are not protected by auth
var excludedPaths = []string{
	"/service_worker.js", // because browser fetch this without sending any cookie (auth) headers
	"/.client/",          // to allow a basic UI to load, not 100% sure this is necessary to exclude
	"/.auth",             // otherwise the authentication UI would be behind authentication
	"/.ping",             // because docker needs to be able to access this for its health check
}

func addAuthEndpoints(r chi.Router, config *ServerConfig) {
	// Logout
	r.Get("/.logout", func(w http.ResponseWriter, r *http.Request) {
		host := extractHost(r)
		cookieOptions := CookieOptions{
			Path: fmt.Sprintf("%s/", config.HostURLPrefix),
		}

		deleteCookie(w, authCookieName(host), cookieOptions)
		deleteCookie(w, "refreshLogin", cookieOptions)

		http.Redirect(w, r, applyURLPrefix("/.auth", config.HostURLPrefix), http.StatusFound)
	})

	// Auth page
	r.Get("/.auth", func(w http.ResponseWriter, r *http.Request) {
		spaceConfig := spaceConfigFromContext(r.Context())
		if spaceConfig.Auth == nil {
			http.Error(w, "Authentication not enabled", http.StatusForbidden)
			return
		}
		if err := spaceConfig.InitAuth(); err != nil {
			http.Error(w, "Failed to initialize authentication", http.StatusInternalServerError)
			return
		}

		data, _, err := config.ClientBundle.ReadFile(authPagePath)
		if err != nil {
			http.Error(w, "Auth page not found", http.StatusNotFound)
			return
		}

		tpl := template.Must(template.New("auth").Parse(string(data)))

		templateData := map[string]any{
			"HostPrefix":      config.HostURLPrefix,
			"SpaceName":       spaceConfig.SpaceName,
			"EncryptionSalt":  spaceConfig.JwtIssuer.Salt,
			"RememberMeDays":  spaceConfig.Auth.RememberMeHours / 24,
		}

		w.Header().Set("Content-type", "text/html")
		w.WriteHeader(http.StatusOK)
		if err := tpl.Execute(w, templateData); err != nil {
			log.Printf("Could not render auth page: %v", err)
			w.Write([]byte("Server error"))
		}
	})

	// Auth POST endpoint
	r.Post("/.auth", func(w http.ResponseWriter, r *http.Request) {
		spaceConfig := spaceConfigFromContext(r.Context())
		if err := spaceConfig.InitAuth(); err != nil {
			http.Error(w, "Failed to initialize authentication", http.StatusInternalServerError)
			return
		}
		if err := r.ParseForm(); err != nil {
			http.Error(w, "Failed to parse form", http.StatusBadRequest)
			return
		}

		username := r.FormValue("username")
		password := r.FormValue("password")
		rememberMe := r.FormValue("rememberMe")
		from := r.FormValue("from")

		if username == "" || password == "" {
			http.Redirect(w, r, applyURLPrefix("/.auth?error=0", config.HostURLPrefix), http.StatusFound)
			return
		}

		if spaceConfig.LockoutTimer.IsLocked() {
			log.Println("Authentication locked out, redirecting to auth page.")
			http.Redirect(w, r, applyURLPrefix("/.auth?error=2", config.HostURLPrefix), http.StatusFound)
			return
		}

		if spaceConfig.Authorize(username, password) {
			// Generate JWT with username (not currently used)
			payload := map[string]any{
				"username": username,
			}

			var jwt string
			var err error
			if rememberMe != "" {
				rememberMeExpirySeconds := spaceConfig.Auth.RememberMeHours * 60 * 60
				jwt, err = spaceConfig.JwtIssuer.CreateJWT(payload, rememberMeExpirySeconds)
			} else {
				jwt, err = spaceConfig.JwtIssuer.CreateJWT(payload, authenticationExpirySeconds)
			}

			if err != nil {
				log.Printf("Failed to create JWT: %v\n", err)
				http.Error(w, "Internal server error", http.StatusInternalServerError)
				return
			}

			host := extractHost(r)
			expirySeconds := authenticationExpirySeconds
			if rememberMe != "" {
				expirySeconds = spaceConfig.Auth.RememberMeHours * 60 * 60
			}
			expires := time.Now().Add(time.Duration(expirySeconds) * time.Second)			

			cookieOptions := CookieOptions{
				Path:    fmt.Sprintf("%s/", config.HostURLPrefix),
				Expires: expires,
			}

			setCookie(w, authCookieName(host), jwt, cookieOptions)

			if rememberMe != "" {
				setCookie(w, "refreshLogin", "true", cookieOptions)
			}

			redirectPath := applyURLPrefix("/", config.HostURLPrefix)
			if from != "" {
				redirectPath = from
			}

			render.JSON(w, r, map[string]any{
				"status":   "ok",
				"redirect": redirectPath,
			})
		} else {
			log.Println("Authentication failed, redirecting to auth page.")
			spaceConfig.LockoutTimer.AddCount()

			render.JSON(w, r, map[string]any{
				"status": "error",
				"error":  "Invalid username and/or password",
			})
		}
	})
}

func (spaceConfig *SpaceConfig) InitAuth() error {
	if spaceConfig.JwtIssuer == nil {
		spaceConfig.authMutex.Lock()
		defer spaceConfig.authMutex.Unlock()

		var err error
		// Need to do some initialization
		spaceConfig.JwtIssuer, err = CreateAuthenticator(path.Join(spaceConfig.SpaceFolderPath, ".silverbullet.auth.json"), spaceConfig.Auth)
		if err != nil {
			return err
		}

		// Initialize lockout timer
		if spaceConfig.Auth.LockoutLimit > 0 {
			spaceConfig.LockoutTimer = NewLockoutTimer(spaceConfig.Auth.LockoutTime*1000, spaceConfig.Auth.LockoutLimit)
		} else {
			spaceConfig.LockoutTimer = NewLockoutTimer(0, 0) // disabled
		}
	}
	return nil
}

// authMiddleware provides authentication middleware
func authMiddleware(config *ServerConfig) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			var err error
			spaceConfig := spaceConfigFromContext(r.Context())
			if spaceConfig.Auth == nil {
				// No authentication to do, moving on
				next.ServeHTTP(w, r)
				return
			}

			path := removeURLPrefix(r.URL.Path, config.HostURLPrefix)
			host := extractHost(r)

			if isExcludedPath(path) {
				next.ServeHTTP(w, r)
				return
			}

			if err := spaceConfig.InitAuth(); err != nil {
				http.Error(w, "Failed to initialize authentication", http.StatusInternalServerError)
				return
			}

			authCookie := getCookie(r, authCookieName(host))

			// Try Bearer token authentication if no cookie
			if authCookie == "" && spaceConfig.Auth.AuthToken != "" {
				authHeader := r.Header.Get("Authorization")
				if after, ok := strings.CutPrefix(authHeader, "Bearer "); ok {
					authToken := after
					if authToken == spaceConfig.Auth.AuthToken {
						next.ServeHTTP(w, r)
						return
					} else {
						log.Println("Unauthorized token access")
						http.Error(w, "Unauthorized", http.StatusUnauthorized)
						return
					}
				}
			}

			if authCookie == "" {
				log.Printf("Unauthorized access to %s, redirecting to auth page", path)
				redirectToAuth(w, "/.auth", path, config.HostURLPrefix)
				return
			}

			// Verify JWT
			claims, err := spaceConfig.JwtIssuer.VerifyAndDecodeJWT(authCookie)
			if err != nil {
				log.Printf("Error verifying JWT on %s, redirecting to auth page: %v\n", path, err)
				redirectToAuth(w, "/.auth", path, config.HostURLPrefix)
				return
			}

			_, ok := claims["username"].(string)
			if !ok {
				log.Printf("Username mismatch in JWT on %s", path)
				redirectToAuth(w, "/.auth", path, config.HostURLPrefix)
				return
			}

			refreshLogin(w, r, config, host, spaceConfig)
			next.ServeHTTP(w, r)
		})
	}
}

// refreshLogin refreshes the login cookie if needed
func refreshLogin(w http.ResponseWriter, r *http.Request, config *ServerConfig, host string, spaceConfig *SpaceConfig) {
	// if cookie doesn't exist, return
	if getCookie(r, "refreshLogin") != "" {
		return
	}
	oldJwt := getCookie(r, authCookieName(host))
	if oldJwt == "" {
		return
	}
	
	claims, err := spaceConfig.JwtIssuer.VerifyAndDecodeJWT(oldJwt)
	if err != nil {
		return
	}

	// Create new JWT with fresh expiry
	expirySeconds := spaceConfig.Auth.RememberMeHours * 60 * 60
	payload := map[string]any{"username": claims["username"]}
	newJwt, err := spaceConfig.JwtIssuer.CreateJWT(payload, expirySeconds)
	if err != nil {
		return
	}

	expires := time.Now().Add(time.Duration(expirySeconds) * time.Second)
	cookieOptions := CookieOptions{
		Path:    fmt.Sprintf("%s/", config.HostURLPrefix),
		Expires: expires,
	}

	setCookie(w, authCookieName(host), newJwt, cookieOptions)
	setCookie(w, "refreshLogin", "true", cookieOptions)
}

// LockoutTimer implements a simple rate limiter to prevent brute force attacks
type LockoutTimer struct {
	mutex       sync.Mutex
	bucketTime  int64
	bucketCount int
	bucketSize  int64 // duration in milliseconds
	limit       int
	disabled    bool
}

// NewLockoutTimer creates a new lockout timer
// countPeriodMs: time window in milliseconds
// limit: maximum attempts allowed in the time window
func NewLockoutTimer(countPeriodMs int, limit int) *LockoutTimer {
	disabled := math.IsNaN(float64(countPeriodMs)) || math.IsNaN(float64(limit)) ||
		countPeriodMs < 1 || limit < 1

	return &LockoutTimer{
		bucketSize: int64(countPeriodMs),
		limit:      limit,
		disabled:   disabled,
	}
}

// updateBucketTime updates the current bucket time and resets count if needed
func (lt *LockoutTimer) updateBucketTime() {
	currentBucketTime := time.Now().UnixMilli() / lt.bucketSize
	if lt.bucketTime == currentBucketTime {
		return
	}
	// the bucket is too old - empty it
	lt.bucketTime = currentBucketTime
	lt.bucketCount = 0
}

// IsLocked checks if the timer is currently locked due to too many attempts
func (lt *LockoutTimer) IsLocked() bool {
	if lt.disabled {
		return false
	}

	lt.mutex.Lock()
	defer lt.mutex.Unlock()

	lt.updateBucketTime()
	return lt.bucketCount >= lt.limit
}

// AddCount increments the attempt counter
// IsLocked() should be called first to keep bucketTime current
func (lt *LockoutTimer) AddCount() {
	if lt.disabled {
		return
	}

	lt.mutex.Lock()
	defer lt.mutex.Unlock()

	// updateBucketTime should have been called by IsLocked first
	lt.bucketCount++
}


================================================
FILE: server/auth_utils.go
================================================
package server

import (
	"fmt"
	"net/http"
	"regexp"
	"strings"
	"time"
)

const authenticationExpirySeconds = 60 * 60 * 24 * 7 // 1 week

// authCookieName generates a cookie name based on the host
func authCookieName(host string) string {
	// Replace non-word characters with underscores
	re := regexp.MustCompile(`\W`)
	cleanHost := re.ReplaceAllString(host, "_")
	return fmt.Sprintf("auth_%s", cleanHost)
}

// utcDateString converts a timestamp to UTC date string
func utcDateString(mtime int64) string {
	return time.Unix(mtime/1000, 0).UTC().Format(time.RFC1123)
}

// setCookie sets an HTTP cookie
func setCookie(w http.ResponseWriter, name, value string, options CookieOptions) {
	cookie := &http.Cookie{
		Name:  name,
		Value: value,
		Path:  options.Path,
	}

	if !options.Expires.IsZero() {
		cookie.Expires = options.Expires
	}

	http.SetCookie(w, cookie)
}

// getCookie gets an HTTP cookie value
func getCookie(r *http.Request, name string) string {
	cookie, err := r.Cookie(name)
	if err != nil {
		return ""
	}
	return cookie.Value
}

// deleteCookie deletes an HTTP cookie
func deleteCookie(w http.ResponseWriter, name string, options CookieOptions) {
	cookie := &http.Cookie{
		Name:    name,
		Value:   "",
		Path:    options.Path,
		Expires: time.Unix(0, 0),
		MaxAge:  -1,
	}
	http.SetCookie(w, cookie)
}

// CookieOptions represents cookie configuration options
type CookieOptions struct {
	Path     string
	Expires  time.Time
	HttpOnly bool
	Secure   bool
	SameSite string
}

func extractHost(r *http.Request) string {
	host := r.Host
	if host == "" {
		host = r.Header.Get("Host")
	}
	return host
}

// redirectToAuth creates a redirect response to the auth page
func redirectToAuth(w http.ResponseWriter, authPath, fromPath, hostURLPrefix string) {
	var redirectURL string

	// Try filtering api paths
	if strings.HasPrefix(fromPath, "/.") || strings.HasSuffix(fromPath, ".md") {
		w.Header().Set("Location", applyURLPrefix(authPath, hostURLPrefix))
		w.WriteHeader(http.StatusUnauthorized)
		return
	} else {
		redirectURL = fmt.Sprintf("%s?from=%s", authPath, fromPath)
	}

	w.Header().Set("Location", applyURLPrefix(redirectURL, hostURLPrefix))
	w.WriteHeader(http.StatusFound)
}

// applyURLPrefix applies a URL prefix to a path
func applyURLPrefix(path, prefix string) string {
	if prefix == "" {
		return path
	}
	return prefix + path
}

// removeURLPrefix removes a URL prefix from a path
func removeURLPrefix(path, prefix string) string {
	if prefix == "" {
		return path
	}
	if strings.HasPrefix(path, prefix) {
		return path[len(prefix):]
	}
	return path
}

// isExcludedPath checks if a path should be excluded from authentication
func isExcludedPath(path string) bool {
	if path == "/" {
		return true
	}
	// Check if path starts with any item in excludedPaths
	for _, excludedPath := range excludedPaths {
		if strings.HasPrefix(path, excludedPath) {
			return true
		}
	}
	return false
}

// parseFormValue safely extracts a form value
func parseFormValue(r *http.Request, key string) string {
	return r.FormValue(key)
}


================================================
FILE: server/cmd/server.go
================================================
package cmd

import (
	_ "embed"
	"errors"
	"fmt"
	"html/template"
	"io/fs"
	"log"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/silverbulletmd/silverbullet/server"
	"github.com/spf13/cobra"
)

func buildConfig(bundledFiles fs.FS, args []string) *server.ServerConfig {
	serverConfig := &server.ServerConfig{
		BindHost: "127.0.0.1",
		Port:     3000,
	}

	rootSpaceConfig := &server.SpaceConfig{
		IndexPage:        "index",
		SpaceName:        "SilverBullet",
		SpaceDescription: "Powerful and programmable note taking app",
	}

	// For now just point every request to the rootSpaceConfig
	serverConfig.SpaceConfigResolver = func(r *http.Request) (*server.SpaceConfig, error) {
		return rootSpaceConfig, nil
	}

	if os.Getenv("SB_HOSTNAME") != "" {
		serverConfig.BindHost = os.Getenv("SB_HOSTNAME")
	}

	if os.Getenv("SB_PORT") != "" {
		// Parse integer from string
		port, err := strconv.ParseInt(os.Getenv("SB_PORT"), 10, 32)
		if err != nil {
			log.Fatalf("Could not parse numeric port from SB_PORT: %v", err)
		}
		serverConfig.Port = int(port)
	}

	if os.Getenv("SB_INDEX_PAGE") != "" {
		rootSpaceConfig.IndexPage = os.Getenv("SB_INDEX_PAGE")
	}
	if len(args) > 0 {
		rootSpaceConfig.SpaceFolderPath = args[0]
	}
	if os.Getenv("SB_FOLDER") != "" {
		rootSpaceConfig.SpaceFolderPath = os.Getenv("SB_FOLDER")
	}

	if os.Getenv("SB_LOG_PUSH") != "" {
		rootSpaceConfig.LogPush = true
		log.Println("Client log push enabled")
	}

	rootSpaceConfig.GitIgnore = os.Getenv("SB_SPACE_IGNORE")

	if os.Getenv("SB_SPACE_IGNORE") != "" {
		log.Printf("Ignoring files matching: %s", os.Getenv("SB_SPACE_IGNORE"))
	}

	if rootSpaceConfig.SpaceFolderPath == "" {
		log.Fatal("No folder specified. Please pass a folder as an argument or set SB_FOLDER environment variable.")
	}

	var spacePrimitives server.SpacePrimitives
	spacePrimitives, err := server.NewDiskSpacePrimitives(rootSpaceConfig.SpaceFolderPath, rootSpaceConfig.GitIgnore)

	rootSpaceConfig.ReadOnlyMode = os.Getenv("SB_READ_ONLY") != ""

	if rootSpaceConfig.ReadOnlyMode {
		log.Println("Starting in read-only mode.")
	}

	if rootSpaceConfig.ReadOnlyMode {
		// Wrap the space primitives in read only mode
		spacePrimitives = server.NewReadOnlySpacePrimitives(spacePrimitives)
	}

	if err != nil {
		log.Fatal(err)
	}

	serverConfig.EnableHTTPLogging = os.Getenv("SB_HTTP_LOGGING") != ""

	if os.Getenv("SB_METRICS_PORT") != "" {
		serverConfig.MetricsPort, err = strconv.Atoi(os.Getenv("SB_METRICS_PORT"))
		if err != nil {
			log.Fatalf("Could not parse SB_METRICS_PORT as number: %v", err)
		}
	}

	if os.Getenv("SB_USER") != "" {
		pieces := strings.Split(os.Getenv("SB_USER"), ":")
		if len(pieces) != 2 {
			log.Fatal("SB_USER must be in the format user:pass")
		}

		rootSpaceConfig.Auth = &server.AuthOptions{
			User:         pieces[0],
			Pass:         pieces[1],
			AuthToken:    os.Getenv("SB_AUTH_TOKEN"),
			LockoutLimit: 10,
			LockoutTime:  60,
		}

		rootSpaceConfig.Authorize = func(username, password string) bool {
			return username == rootSpaceConfig.Auth.User && password == rootSpaceConfig.Auth.Pass
		}

		if os.Getenv("SB_LOCKOUT_LIMIT") != "" {
			rootSpaceConfig.Auth.LockoutLimit, err = strconv.Atoi(os.Getenv("SB_LOCKOUT_LIMIT"))
			if err != nil {
				log.Fatalf("Could not parse SB_LOCKOUT_LIMIT as number: %v", err)
			}

		}

		if os.Getenv("SB_LOCKOUT_TIME") != "" {
			rootSpaceConfig.Auth.LockoutTime, err = strconv.Atoi(os.Getenv("SB_LOCKOUT_TIME"))
			if err != nil {
				log.Fatalf("Could not parse SB_LOCKOUT_TIME as number: %v", err)
			}
		}

		rootSpaceConfig.Auth.RememberMeHours = 168 // default 7 days
		if os.Getenv("SB_REMEMBER_ME_HOURS") != "" {
			rootSpaceConfig.Auth.RememberMeHours, err = strconv.Atoi(os.Getenv("SB_REMEMBER_ME_HOURS"))
			if err != nil {
				log.Fatalf("Could not parse SB_REMEMBER_ME_HOURS as number: %v", err)
			}
		}

		log.Printf("User authentication enabled for user \"%s\" with lockout limit %d and lockout time %ds",
			pieces[0], rootSpaceConfig.Auth.LockoutLimit, rootSpaceConfig.Auth.LockoutTime)
	}

	if os.Getenv("SB_NAME") != "" {
		rootSpaceConfig.SpaceName = os.Getenv("SB_NAME")
	}

	if os.Getenv("SB_DESCRIPTION") != "" {
		rootSpaceConfig.SpaceDescription = os.Getenv("SB_DESCRIPTION")
	}

	rootSpaceConfig.AdditionalHeadHTML = template.HTML(os.Getenv("SB_HEAD_HTML"))

	if os.Getenv("SB_URL_PREFIX") != "" {
		hostUrlPrefix := os.Getenv("SB_URL_PREFIX")
		// Normalize the host URL prefix (if set)
		if !strings.HasPrefix(hostUrlPrefix, "/") {
			hostUrlPrefix = "/" + hostUrlPrefix
		}
		hostUrlPrefix = strings.TrimRight(hostUrlPrefix, "/")

		if hostUrlPrefix != "" {
			log.Printf("Host URL Prefix: %s", hostUrlPrefix)
			serverConfig.HostURLPrefix = hostUrlPrefix
		}
	}

	// Extract the last modified time from the main binary, best effort
	bundlePathDate := time.Now()
	if executablePath, err := os.Executable(); err == nil {
		if stat, err := os.Stat(executablePath); err == nil {
			bundlePathDate = stat.ModTime()
		}
	}

	serverConfig.ClientBundle = server.NewReadOnlyFallthroughSpacePrimitives(bundledFiles, "client", bundlePathDate, nil)
	rootSpaceConfig.SpacePrimitives = server.NewReadOnlyFallthroughSpacePrimitives(bundledFiles, "base_fs", bundlePathDate, spacePrimitives)

	if serverConfig.BindHost == "127.0.0.1" {
		log.Println("SilverBullet will only be available locally, to allow outside connections, pass -L0.0.0.0 as a flag, and put a TLS terminator on top.")
	}

	// Initialize shell backend
	backendConfig := os.Getenv("SB_SHELL_BACKEND")
	if backendConfig == "" && !rootSpaceConfig.ReadOnlyMode {
		localShell := server.NewLocalShell(rootSpaceConfig.SpaceFolderPath, os.Getenv("SB_SHELL_WHITELIST"))
		rootSpaceConfig.ShellBackend = localShell
		if localShell.AllowAllCmds {
			log.Println("Local shell command execution enabled for ALL commands.")
		} else {
			log.Printf("Local shell command execution enabled for: %v", localShell.CmdWhiteList)
		}
	} else {
		log.Println("Shell running disabled.")
		rootSpaceConfig.ShellBackend = server.NewNotSupportedShell()
	}

	// Ensure at least the index page and config page exist
	ensureIndexAndConfig(rootSpaceConfig)

	return serverConfig
}

//go:embed space_template/index.md
var indexPageContent []byte

//go:embed space_template/CONFIG.md
var configPageContent []byte

func ensureIndexAndConfig(rootSpaceConfig *server.SpaceConfig) {
	// Index page first
	indexPagePath := fmt.Sprintf("%s.md", rootSpaceConfig.IndexPage)
	_, err := rootSpaceConfig.SpacePrimitives.GetFileMeta(indexPagePath)
	if err == server.ErrNotFound {
		log.Printf("Index page %s does not yet exist, creating...", indexPagePath)
		if _, err := rootSpaceConfig.SpacePrimitives.WriteFile(indexPagePath, indexPageContent, nil); err != nil {
			log.Fatalf("Could not write index page %s: %v", indexPagePath, err)
		}
	}
	// Now let's check for a CONFIG.md
	configPagePath := "CONFIG.md"
	_, err = rootSpaceConfig.SpacePrimitives.GetFileMeta(configPagePath)
	if errors.Is(err, server.ErrNotFound) {
		log.Printf("Config page %s does not yet exist, creating...", configPagePath)
		if _, err := rootSpaceConfig.SpacePrimitives.WriteFile(configPagePath, configPageContent, nil); err != nil {
			log.Fatalf("Could not write config page %s: %v", configPagePath, err)
		}
	}
}

func ServerCommand(bundledFiles fs.FS) *cobra.Command {
	var hostname string
	var port int
	var c = &cobra.Command{
		Use:   "silverbullet",
		Short: "Run the Silverbullet server",
		Args:  cobra.MinimumNArgs(0),
		Run: func(cmd *cobra.Command, args []string) {
			serverConfig := buildConfig(bundledFiles, args)
			if port != 3000 {
				serverConfig.Port = port
			}
			if hostname != "" {
				serverConfig.BindHost = hostname
			}
			if err := server.RunServer(serverConfig); err != nil {
				log.Fatal(err)
			}
		},
	}
	c.Flags().StringVarP(&hostname, "hostname", "L", "", "Host or address to listen to")
	c.Flags().IntVarP(&port, "port", "p", 3000, "Port to listen to")

	return c
}


================================================
FILE: server/cmd/space_template/CONFIG.md
================================================
This is where you configure SilverBullet to your liking. See [[^Library/Std/Config]] for a full list of configuration options.

```space-lua
config.set("plugs", {
  -- Add your plugs here (https://silverbullet.md/Plugs)
  -- Then run the `Plugs: Update` command to update them
})
```


================================================
FILE: server/cmd/space_template/index.md
================================================
# Hello 👋
Welcome to the wondrous world of SilverBullet. A world that once you discover and appreciate, you’ll never want to leave.

_One of us!_

You can go ahead and delete this placeholder content in a second, but before you do, let me quickly show you around.

We’ll do this through a list of **silver bullets**.

_Ah, so that explains the name!_

* Click on the page picker (book icon) icon at the top right, or hit `Cmd-k` (Mac) or `Ctrl-k` (Linux and Windows) to open the **page picker**.
  * Type the name of a non-existent page to create it.
  * Folders are implicitly created by putting slashes (`/`) in the name (even on Windows), e.g. `My Folder/My Page`. Don’t worry about folders existing, SilverBullet will automatically create them if they don’t.
* Click on the terminal icon (top right), or hit `Cmd-/` (Mac) or `Ctrl-/` (Linux and Windows) to open the **command palette**. From here you can run various useful and perhaps less useful commands.
* Select some text and hit `Cmd-b` (Mac) or `Ctrl-b` (Windows/Linux) to make it **bold**, or `Cmd-i` (Mac) or `Ctrl-i` (Windows/Linux) to make it _italic_.
* You can create links to pages using the `[[other page]]` syntax, for instance: `[[aspiring page]]`. When you link to a non-existent page it will initially show up in orange (to indicate it does not yet exist), but once you click it — you will create the page automatically (only for real when you actually enter some text).
* Start typing `:party` to trigger the emoji picker 🎉
* Type `/` somewhere in the text to invoke a **slash command**.
* If this matches your personality type, there is also an `Editor: Toggle Vim Mode` command to switch to Vim mode. If you cannot figure out how to exit it (classic vim issue), just run that same command again. _Phew!_
* As you may have noticed, while SilverBullet uses [markdown](https://www.markdownguide.org/) underneath, it also “live previews” it to give a bit more of a WYSIWYG feel. You can always move your cursor “into” the underlying code by `Alt-clicking` it (even links and widgets, which we’ll get to later).

While SilverBullet implements (most) of [CommonMark](https://commonmark.org/), it also adds a few extensions that are SilverBullet-specific. Most notably, it adds the `${Lua expression}` syntax to render Lua expressions inline.

Using SilverBullet’s Lua APIs, you can do all kinds of cool stuff. For instance, query your space for the last 3 modified pages. _Exciting!_

${query[[from index.tag "page" order by lastModified desc limit 3]]}

There’s a whole world out there to explore, but let’s not get ahead of ourselves. First, have some fun and add some content to your fresh space.

Then we’ll talk.

# What next?
You can find more information on SilverBullet’s feature set on its [official website](https://silverbullet.md/). Also, be sure to join the [SilverBullet community](https://community.silverbullet.md/) to interact with fellow SilverBullet explorers.


================================================
FILE: server/cmd/upgrade.go
================================================
package cmd

import (
	"archive/zip"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"runtime"

	"github.com/spf13/cobra"
)

// We can add more now that this will be Go compiled (we can do ARM32 too, for instance)
var archMappings map[string]string = map[string]string{
	"amd64": "x86_64",
	"arm64": "aarch64",
}

func UpgradeCommand() *cobra.Command {
	return &cobra.Command{
		Use: "upgrade",
		Run: func(cmd *cobra.Command, args []string) {
			fmt.Println("Now going to attempt an upgrade...")
			if err := upgrade("https://github.com/silverbulletmd/silverbullet/releases/latest/download"); err != nil {
				log.Fatal(err)
			}
		},
	}
}

func UpgradeEdgeCommand() *cobra.Command {
	return &cobra.Command{
		Use: "upgrade-edge",
		Run: func(cmd *cobra.Command, args []string) {
			fmt.Println("Now going to attempt an upgrade...")
			if err := upgrade("https://github.com/silverbulletmd/silverbullet/releases/download/edge"); err != nil {
				log.Fatal(err)
			}
		},
	}
}

func upgrade(urlPrefix string) error {
	// Get the directory of the current executable
	execPath, err := os.Executable()
	if err != nil {
		return fmt.Errorf("failed to get executable path: %w", err)
	}
	installDir := filepath.Dir(execPath)

	fmt.Println("Install dir:", installDir)

	// Create temporary directory
	tmpDir, err := os.MkdirTemp("", "silverbullet-upgrade")
	if err != nil {
		return fmt.Errorf("failed to create temp dir: %w", err)
	}
	defer os.RemoveAll(tmpDir)

	// Construct download URL
	zipURL := fmt.Sprintf("%s/silverbullet-server-%s-%s.zip", urlPrefix, runtime.GOOS, archMappings[runtime.GOARCH])
	zipPath := filepath.Join(tmpDir, "silverbullet.zip")

	fmt.Println("Downloading from", zipURL)

	// Download the zip file
	resp, err := http.Get(zipURL)
	if err != nil {
		return fmt.Errorf("failed to download: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("download failed with status: %d", resp.StatusCode)
	}

	// Create the zip file
	zipFile, err := os.Create(zipPath)
	if err != nil {
		return fmt.Errorf("failed to create zip file: %w", err)
	}
	defer zipFile.Close()

	// Copy response body to file
	_, err = io.Copy(zipFile, resp.Body)
	if err != nil {
		return fmt.Errorf("failed to save zip file: %w", err)
	}
	zipFile.Close()

	fmt.Printf("Now going to replace the existing silverbullet binary in %s\n", installDir)

	// Extract the zip file
	err = extractZip(zipPath, installDir)
	if err != nil {
		return fmt.Errorf("failed to extract zip: %w", err)
	}

	// Set executable permissions on the binary
	binaryPath := filepath.Join(installDir, "silverbullet")
	if runtime.GOOS == "windows" {
		binaryPath += ".exe"
	}
	err = os.Chmod(binaryPath, 0755)
	if err != nil {
		return fmt.Errorf("failed to set executable permissions: %w", err)
	}

	fmt.Println("And done! Restart your server to get the latest and greatest!")
	return nil
}

func extractZip(src, dest string) error {
	reader, err := zip.OpenReader(src)
	if err != nil {
		return err
	}
	defer reader.Close()

	for _, file := range reader.File {
		path := filepath.Join(dest, file.Name)

		// Handle directories first, report errors here too
		if file.FileInfo().IsDir() {
			if err := os.MkdirAll(path, file.FileInfo().Mode()); err != nil {
				return err
			}
			continue
		}

		rc, err := file.Open()
		if err != nil {
			return err
		}
		defer rc.Close()

		// Create parent directories
		if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
			return err
		}

		// First, attempt to remove the file to prevent "text file busy" error
		err = os.Remove(path)
		if err != nil && !errors.Is(err, fs.ErrNotExist) {
			return err
		}

		// Create the file. It will create a new inode, so we can write to it while the executable still runs
		outFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.FileInfo().Mode())
		if err != nil {
			return err
		}
		defer outFile.Close()

		// Extract to the file
		_, err = io.Copy(outFile, rc)
		if err != nil {
			return err
		}
	}

	return nil
}


================================================
FILE: server/cmd/version.go
================================================
package cmd

import (
	"fmt"
	"log"

	"github.com/silverbulletmd/silverbullet/server"
	"github.com/spf13/cobra"
)

func VersionCommand(versionFileText string) *cobra.Command {
	return &cobra.Command{
		Use: "version",
		Run: func(cmd *cobra.Command, args []string) {
			version, err := server.ParseVersionFromTypeScript(versionFileText)
			if err != nil {
				log.Fatal(err)
			}
			fmt.Println(version)
		},
	}
}


================================================
FILE: server/crypto.go
================================================
package server

import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"log"
	"maps"
	"os"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

// Authenticator handles JWT creation and verification
type Authenticator struct {
	path      string
	SecretKey string `json:"secret_key"`
	AuthHash  string `json:"auth_hash"`
	Salt      string `json:"salt"` // base64 encoded 16 byte randomized salt used for encryption
}

func CreateAuthenticator(path string, authOptions *AuthOptions) (*Authenticator, error) {
	// Load from JSON
	jsonBytes, err := os.ReadFile(path)
	// If the file is not found, just start fresh
	if os.IsNotExist(err) {
		auth := &Authenticator{path: path}

		if err := auth.init(authOptions); err != nil {
			return nil, err
		}
		return auth, nil
	}
	if err != nil {
		return nil, err
	}

	var auth Authenticator
	auth.path = path
	if err := json.Unmarshal(jsonBytes, &auth); err != nil {
		log.Printf("Could not parse .silverbullet.auth.json, resetting")
	}
	if err := auth.init(authOptions); err != nil {
		return nil, err
	}

	return &auth, nil
}

func (j *Authenticator) save() error {
	jsonBytes, err := json.Marshal(j)
	if err != nil {
		return err
	}

	return os.WriteFile(j.path, jsonBytes, 0600)
}

// Init initializes the JWT issuer with an auth string for validation
func (j *Authenticator) init(authConfig *AuthOptions) error {
	if j.SecretKey == "" {
		log.Println("Generating new JWT secret key")
		if err := j.generateNewKey(); err != nil {
			return err
		}
	}

	newAuthHash := j.hashOptions(authConfig)
	if j.AuthHash != newAuthHash {
		log.Println("Authentication has changed since last run, so invalidating all existing tokens")
		// Generate new key to invalidate all existing tokens
		if err := j.generateNewKey(); err != nil {
			return err
		}
	}

	j.AuthHash = newAuthHash

	if j.Salt == "" {
		b := make([]byte, 16)
		rand.Read(b)
		j.Salt = base64.StdEncoding.EncodeToString(b)
	}

	return j.save()

}

// generateNewKey creates and stores a new secret key
func (j *Authenticator) generateNewKey() error {
	// Generate 32 bytes (256 bits) of random data for the secret
	secretKey := make([]byte, 32)
	if _, err := rand.Read(secretKey); err != nil {
		return fmt.Errorf("failed to generate secret key: %v", err)
	}

	j.SecretKey = base64.StdEncoding.EncodeToString(secretKey)

	return nil
}

func (j *Authenticator) hashOptions(authOptions *AuthOptions) string {
	data, err := json.Marshal(authOptions)
	if err != nil {
		panic(err)
	}
	hash := sha256.Sum256(data)
	return hex.EncodeToString(hash[:])
}

// CreateJWT creates a new JWT token with the given payload
func (j *Authenticator) CreateJWT(payload map[string]any, expirySeconds ...int) (string, error) {
	claims := jwt.MapClaims{}

	// Copy payload to claims
	maps.Copy(claims, payload)

	// Add expiry if specified
	if len(expirySeconds) > 0 && expirySeconds[0] > 0 {
		claims["exp"] = time.Now().Add(time.Duration(expirySeconds[0]) * time.Second).Unix()
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	key, err := base64.StdEncoding.DecodeString(j.SecretKey)
	if err != nil {
		return "", err
	}
	return token.SignedString(key)
}

// VerifyAndDecodeJWT verifies and decodes a JWT token
func (j *Authenticator) VerifyAndDecodeJWT(tokenString string) (map[string]any, error) {
	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
		// Validate the signing method
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
		}
		return base64.StdEncoding.DecodeString(j.SecretKey)
	})

	if err != nil {
		return nil, err
	}

	if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
		return claims, nil
	}

	return nil, fmt.Errorf("invalid token")
}


================================================
FILE: server/crypto_test.go
================================================
package server

import (
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestCreateAuthenticator_NewFile(t *testing.T) {
	tmpDir := t.TempDir()
	authFile := filepath.Join(tmpDir, "auth.json")

	authOptions := &AuthOptions{
		User:         "testuser",
		Pass:         "testpass",
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	auth, err := CreateAuthenticator(authFile, authOptions)
	assert.NoError(t, err, "Should create new authenticator without error")
	assert.NotNil(t, auth, "Authenticator should not be nil")
	assert.NotEmpty(t, auth.SecretKey, "Secret key should be generated")
	assert.NotEmpty(t, auth.AuthHash, "Auth hash should be generated")

	// Verify file was created
	_, err = os.Stat(authFile)
	assert.False(t, os.IsNotExist(err), "Auth file should be created")
}

func TestCreateAuthenticator_ExistingFile(t *testing.T) {
	tmpDir := t.TempDir()
	authFile := filepath.Join(tmpDir, "auth.json")

	// Create initial authenticator
	authOptions := &AuthOptions{
		User:         "testuser",
		Pass:         "testpass",
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	auth1, err := CreateAuthenticator(authFile, authOptions)
	require.NoError(t, err)
	originalKey := auth1.SecretKey

	// Load from existing file with same options
	auth2, err := CreateAuthenticator(authFile, authOptions)
	assert.NoError(t, err, "Should load existing authenticator without error")
	assert.Equal(t, originalKey, auth2.SecretKey, "Secret key should remain the same")
}

func TestCreateAuthenticator_AuthOptionsChanged(t *testing.T) {
	tmpDir := t.TempDir()
	authFile := filepath.Join(tmpDir, "auth.json")

	// Create initial authenticator
	authOptions1 := &AuthOptions{
		User:         "testuser",
		Pass:         "testpass",
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	auth1, err := CreateAuthenticator(authFile, authOptions1)
	require.NoError(t, err)
	originalKey := auth1.SecretKey

	// Load with different auth options
	authOptions2 := &AuthOptions{
		User:         "testuser",
		Pass:         "newpass", // Changed password
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	auth2, err := CreateAuthenticator(authFile, authOptions2)
	assert.NoError(t, err, "Should load authenticator with changed options")
	assert.NotEqual(t, originalKey, auth2.SecretKey, "Secret key should change when auth options change")
}

func TestAuthenticator_CreateJWT(t *testing.T) {
	tmpDir := t.TempDir()
	authFile := filepath.Join(tmpDir, "auth.json")

	authOptions := &AuthOptions{
		User:         "testuser",
		Pass:         "testpass",
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	auth, err := CreateAuthenticator(authFile, authOptions)
	require.NoError(t, err)

	payload := map[string]any{
		"user": "testuser",
		"role": "admin",
	}

	token, err := auth.CreateJWT(payload)
	assert.NoError(t, err, "Should create JWT without error")
	assert.NotEmpty(t, token, "Token should not be empty")

	// Verify token structure (should have 3 parts separated by dots)
	parts := len([]rune(token))
	assert.Greater(t, parts, 0, "Token should not be empty")
}

func TestAuthenticator_CreateJWT_WithExpiry(t *testing.T) {
	tmpDir := t.TempDir()
	authFile := filepath.Join(tmpDir, "auth.json")

	authOptions := &AuthOptions{
		User:         "testuser",
		Pass:         "testpass",
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	auth, err := CreateAuthenticator(authFile, authOptions)
	require.NoError(t, err)

	payload := map[string]any{
		"user": "testuser",
	}

	token, err := auth.CreateJWT(payload, 3600) // 1 hour expiry
	assert.NoError(t, err, "Should create JWT with expiry without error")
	assert.NotEmpty(t, token, "Token should not be empty")

	// Verify the token and check expiry
	claims, err := auth.VerifyAndDecodeJWT(token)
	assert.NoError(t, err, "Should verify token")

	exp, ok := claims["exp"]
	assert.True(t, ok, "Token should have expiry claim")
	assert.NotNil(t, exp, "Expiry should not be nil")
}

func TestAuthenticator_VerifyAndDecodeJWT(t *testing.T) {
	tmpDir := t.TempDir()
	authFile := filepath.Join(tmpDir, "auth.json")

	authOptions := &AuthOptions{
		User:         "testuser",
		Pass:         "testpass",
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	auth, err := CreateAuthenticator(authFile, authOptions)
	require.NoError(t, err)

	payload := map[string]any{
		"user": "testuser",
		"role": "admin",
		"id":   123,
	}

	token, err := auth.CreateJWT(payload)
	require.NoError(t, err)

	// Verify and decode the token
	claims, err := auth.VerifyAndDecodeJWT(token)
	assert.NoError(t, err, "Should verify and decode JWT without error")
	assert.Equal(t, "testuser", claims["user"], "Should preserve user claim")
	assert.Equal(t, "admin", claims["role"], "Should preserve role claim")
	assert.Equal(t, float64(123), claims["id"], "Should preserve id claim")
}

func TestAuthenticator_VerifyAndDecodeJWT_InvalidToken(t *testing.T) {
	tmpDir := t.TempDir()
	authFile := filepath.Join(tmpDir, "auth.json")

	authOptions := &AuthOptions{
		User:         "testuser",
		Pass:         "testpass",
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	auth, err := CreateAuthenticator(authFile, authOptions)
	require.NoError(t, err)

	// Test invalid token
	_, err = auth.VerifyAndDecodeJWT("invalid.token.here")
	assert.Error(t, err, "Should return error for invalid token")

	// Test empty token
	_, err = auth.VerifyAndDecodeJWT("")
	assert.Error(t, err, "Should return error for empty token")
}

func TestAuthenticator_VerifyAndDecodeJWT_ExpiredToken(t *testing.T) {
	tmpDir := t.TempDir()
	authFile := filepath.Join(tmpDir, "auth.json")

	authOptions := &AuthOptions{
		User:         "testuser",
		Pass:         "testpass",
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	auth, err := CreateAuthenticator(authFile, authOptions)
	require.NoError(t, err)

	payload := map[string]any{
		"user": "testuser",
	}

	// Create token with 1 second expiry
	token, err := auth.CreateJWT(payload, 1)
	require.NoError(t, err)

	// Wait for token to expire
	time.Sleep(2 * time.Second)

	// Try to verify expired token
	_, err = auth.VerifyAndDecodeJWT(token)
	assert.Error(t, err, "Should return error for expired token")
}

func TestAuthenticator_VerifyAndDecodeJWT_WrongKey(t *testing.T) {
	tmpDir := t.TempDir()
	authFile1 := filepath.Join(tmpDir, "auth1.json")
	authFile2 := filepath.Join(tmpDir, "auth2.json")

	authOptions := &AuthOptions{
		User:         "testuser",
		Pass:         "testpass",
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	// Create two different authenticators (different keys)
	auth1, err := CreateAuthenticator(authFile1, authOptions)
	require.NoError(t, err)

	auth2, err := CreateAuthenticator(authFile2, authOptions)
	require.NoError(t, err)

	payload := map[string]any{
		"user": "testuser",
	}

	// Create token with first authenticator
	token, err := auth1.CreateJWT(payload)
	require.NoError(t, err)

	// Try to verify with second authenticator (different key)
	_, err = auth2.VerifyAndDecodeJWT(token)
	assert.Error(t, err, "Should return error when verifying token with wrong key")
}


================================================
FILE: server/disk_space_primitives.go
================================================
package server

import (
	"fmt"
	"io/fs"
	"log"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"time"

	ignore "github.com/Diogenesoftoronto/go-gitignore"
	"github.com/charlievieth/fastwalk"
	"github.com/djherbis/times"
	"github.com/prometheus/client_golang/prometheus"
)

// DiskSpacePrimitives implements SpacePrimitives for local disk storage
type DiskSpacePrimitives struct {
	rootPath  string
	gitIgnore *ignore.GitIgnore
}

var _ SpacePrimitives = &DiskSpacePrimitives{}

var spaceFilesTotal = prometheus.NewGauge(prometheus.GaugeOpts{
	Name: "silverbullet_space_files_count",
	Help: "Total number of files in the SilverBullet space",
})

func init() {
	prometheus.MustRegister(spaceFilesTotal)
}

// NewDiskSpacePrimitives creates a new DiskSpacePrimitives instance
func NewDiskSpacePrimitives(rootPath string, gitIgnore string) (*DiskSpacePrimitives, error) {
	// Resolve to absolute path
	absPath, err := filepath.Abs(rootPath)
	if err != nil {
		return nil, fmt.Errorf("failed to resolve root path: %w", err)
	}

	// Ensure the directory exists
	if stat, err := os.Stat(absPath); err != nil || !stat.IsDir() {
		return nil, fmt.Errorf("no such directory: %w", err)
	}
	ignore := ignore.CompileIgnoreLines(strings.Split(gitIgnore, "\n")...)

	return &DiskSpacePrimitives{
		rootPath:  absPath,
		gitIgnore: ignore,
	}, nil
}

// safePath ensures the path is within the root directory
func (d *DiskSpacePrimitives) safePath(p string) (string, error) {
	// Clean the path to resolve any .. or . components
	cleanPath := filepath.Clean(p)

	// If the path is absolute and tries to escape, reject it
	if filepath.IsAbs(cleanPath) {
		return "", ErrPathOutsideRoot
	}

	// Make it absolute relative to root
	fullPath := filepath.Join(d.rootPath, cleanPath)

	// Clean the full path again to resolve any remaining .. components
	fullPath = filepath.Clean(fullPath)

	// Ensure the resolved path is still within root
	if !strings.HasPrefix(fullPath, d.rootPath) {
		return "", ErrPathOutsideRoot
	}

	// Additional check: ensure we don't have .. components that could escape
	relPath, err := filepath.Rel(d.rootPath, fullPath)
	if err != nil {
		return "", ErrPathOutsideRoot
	}
	if strings.HasPrefix(relPath, "..") {
		return "", ErrPathOutsideRoot
	}

	return fullPath, nil
}

// filenameToPath converts a relative filename to an absolute path
func (d *DiskSpacePrimitives) filenameToPath(filename string) (string, error) {
	return d.safePath(filename)
}

// pathToFilename converts an absolute path back to a relative filename
func (d *DiskSpacePrimitives) pathToFilename(fullPath string) string {
	if !strings.HasPrefix(fullPath, d.rootPath) {
		return fullPath
	}

	relativePath := strings.TrimPrefix(fullPath, d.rootPath)
	relativePath = strings.TrimPrefix(relativePath, string(filepath.Separator))

	// Normalize path separators to forward slashes
	return strings.ReplaceAll(relativePath, string(filepath.Separator), "/")
}

// fileInfoToFileMeta converts os.FileInfo to FileMeta
func (d *DiskSpacePrimitives) fileInfoToFileMeta(path string, info os.FileInfo) FileMeta {
	return FileMeta{
		Name:         path,
		Size:         info.Size(),
		ContentType:  LookupContentTypeFromPath(path),
		Created:      getCreationTime(info).UnixMilli(),
		LastModified: info.ModTime().UnixMilli(),
		Perm:         "rw",
	}
}

// FetchFileList implements SpacePrimitives.FetchFileList
// Returns a list of files in the space, with the following filtering rules:
// - Hidden files and directories (starting with '.') are excluded at any depth
// - Files without extensions are excluded
// - Files matching gitignore patterns are excluded
func (d *DiskSpacePrimitives) FetchFileList() ([]FileMeta, error) {
	var l sync.Mutex
	var allFiles []FileMeta

	cnf := fastwalk.Config{
		// Follow symlinks
		Follow: true,
	}

	err := fastwalk.Walk(&cnf, d.rootPath, func(path string, entry fs.DirEntry, err error) error {
		if err != nil {
			// Skip files that can't be accessed
			return nil
		}

		// Skip hidden directories and stop traversal into them
		if entry.IsDir() {
			if strings.HasPrefix(entry.Name(), ".") {
				return filepath.SkipDir
			}
			return nil
		}

		// Skip hidden files
		if strings.HasPrefix(entry.Name(), ".") {
			return nil
		}

		relativePath := d.pathToFilename(path)

		// Exclude ignored files
		if d.gitIgnore.MatchesPath(relativePath) {
			return nil
		}

		// Skip files without extensions
		if filepath.Ext(relativePath) == "" {
			return nil
		}

		// Get file info
		info, err := entry.Info()
		if err != nil {
			// Skip files we can't stat
			return nil
		}

		fileMeta := d.fileInfoToFileMeta(relativePath, info)
		// Mutex to handle concurrent directory walking
		l.Lock()
		allFiles = append(allFiles, fileMeta)
		l.Unlock()

		return nil
	})

	if err != nil {
		return nil, fmt.Errorf("failed to walk directory: %w", err)
	}

	spaceFilesTotal.Set(float64(len(allFiles)))

	return allFiles, nil
}

// GetFileMeta implements SpacePrimitives.GetFileMeta
func (d *DiskSpacePrimitives) GetFileMeta(path string) (FileMeta, error) {
	localPath, err := d.filenameToPath(path)
	if err != nil {
		return FileMeta{}, err
	}

	info, err := os.Stat(localPath)

	if err != nil {
		if os.IsNotExist(err) {
			return FileMeta{}, ErrNotFound
		}
		// Check if this is a filesystem syntax error (e.g., invalid characters)
		// This typically happens with virtual pages like "tag:Something" which contain
		// characters that are invalid in filesystem paths
		if isSyntaxError(err) {
			return FileMeta{}, ErrNotFound
		}
		return FileMeta{}, fmt.Errorf("%w: %s", ErrCouldNotGetMeta, path)
	}

	return d.fileInfoToFileMeta(path, info), nil
}

// ReadFile implements SpacePrimitives.ReadFile
func (d *DiskSpacePrimitives) ReadFile(path string) ([]byte, FileMeta, error) {
	localPath, err := d.filenameToPath(path)
	if err != nil {
		return nil, FileMeta{}, err
	}

	// Get file info first
	info, err := os.Stat(localPath)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, FileMeta{}, ErrNotFound
		}
		// Check if this is a filesystem syntax error (e.g., invalid characters)
		if isSyntaxError(err) {
			return nil, FileMeta{}, ErrNotFound
		}
		return nil, FileMeta{}, fmt.Errorf("failed to stat file %s: %w", path, err)
	}

	// Read file content
	data, err := os.ReadFile(localPath)
	if err != nil {
		// Check if this is a filesystem syntax error
		if isSyntaxError(err) {
			return nil, FileMeta{}, ErrNotFound
		}
		return nil, FileMeta{}, fmt.Errorf("failed to read file %s: %w", path, err)
	}

	meta := d.fileInfoToFileMeta(path, info)
	return data, meta, nil
}

// WriteFile implements SpacePrimitives.WriteFile
func (d *DiskSpacePrimitives) WriteFile(path string, data []byte, meta *FileMeta) (FileMeta, error) {
	localPath, err := d.filenameToPath(path)
	if err != nil {
		return FileMeta{}, err
	}

	// Ensure parent directory exists
	parentDir := filepath.Dir(localPath)
	if err := os.MkdirAll(parentDir, 0755); err != nil {
		return FileMeta{}, fmt.Errorf("%w: %s", ErrCouldNotWrite, path)
	}

	// Write file
	if err := os.WriteFile(localPath, data, 0644); err != nil {
		return FileMeta{}, fmt.Errorf("%w: %s", ErrCouldNotWrite, path)
	}

	// Set modification time if provided
	if meta != nil && meta.LastModified > 0 {
		modTime := time.UnixMilli(meta.LastModified)
		if err := os.Chtimes(localPath, modTime, modTime); err != nil {
			log.Printf("Failed to set the mtime for %s: %v", localPath, err)
		}
	}

	// Return actual metadata
	return d.GetFileMeta(path)
}

// DeleteFile implements SpacePrimitives.DeleteFile
func (d *DiskSpacePrimitives) DeleteFile(path string) error {
	localPath, err := d.filenameToPath(path)
	if err != nil {
		return err
	}

	if err := os.Remove(localPath); err != nil {
		if os.IsNotExist(err) {
			return ErrNotFound
		}
		return fmt.Errorf("failed to delete file %s: %w", path, err)
	}

	// Clean up empty parent directories
	d.cleanOrphaned(localPath)

	return nil
}

// cleanOrphaned removes empty parent directories up to rootPath
func (d *DiskSpacePrimitives) cleanOrphaned(deletedFilePath string) {
	current := filepath.Dir(deletedFilePath)

	for strings.HasPrefix(current, d.rootPath) && current != d.rootPath {
		err := os.Remove(current)
		if err != nil {
			// Directory not empty or other error, stop cleaning
			break
		}
		current = filepath.Dir(current)
	}
}

// This is tricky and OS specific, luckily we found the "times" package that abstracts from the OS-specific details
func getCreationTime(info os.FileInfo) time.Time {
	t := times.Get(info)
	if t.HasBirthTime() {
		return t.BirthTime()
	} else {
		return t.ChangeTime()
	}
}

// isSyntaxError checks if the error is due to invalid filename syntax
// This typically happens on virtual filesystems when trying to access files with characters
// like colons (e.g., "tag:Something") which are valid in SilverBullet virtual pages
// but not valid in some virtual filesystem paths
func isSyntaxError(err error) bool {
	if err == nil {
		return false
	}
	// Check for specific syntax errors in the error message
	// We need to check the entire error chain, not just the top-level error
	errMsg := err.Error()
	return strings.Contains(errMsg, "syntax is incorrect") ||
		strings.Contains(errMsg, "syntax incorrect") ||
		(strings.Contains(errMsg, "syntax") && strings.Contains(errMsg, "incorrect")) ||
		strings.Contains(errMsg, "invalid argument") ||
		strings.Contains(errMsg, "bad file descriptor")
}


================================================
FILE: server/disk_space_primitives_test.go
================================================
package server

import (
	"os"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestDiskSpacePrimitives(t *testing.T) {
	// Create DiskSpacePrimitives instance
	space, err := NewDiskSpacePrimitives(t.TempDir(), "")
	assert.NoError(t, err, "Failed to create DiskSpacePrimitives")

	// Run all test suites using local testing utilities
	TestSpacePrimitives(t, space)
}

// Test path safety - specific to disk implementation
func TestSafePath(t *testing.T) {
	space, err := NewDiskSpacePrimitives(t.TempDir(), "")
	assert.NoError(t, err, "Failed to create DiskSpacePrimitives")

	// Test that paths outside root are rejected
	_, err = space.safePath("../outside")
	assert.Equal(t, ErrPathOutsideRoot, err, "Expected ErrPathOutsideRoot for '../outside'")

	_, err = space.safePath("/etc/passwd")
	assert.Equal(t, ErrPathOutsideRoot, err, "Expected ErrPathOutsideRoot for '/etc/passwd'")

	// Test that valid paths work
	_, err = space.safePath("valid/path.txt")
	assert.NoError(t, err, "Valid path should not error")

	_, err = space.safePath("./valid.txt")
	assert.NoError(t, err, "Valid relative path should not error")
}

func TestGitIgnore(t *testing.T) {
	space, err := NewDiskSpacePrimitives(t.TempDir(), "*.txt\n*.jpg")
	assert.NoError(t, err, "Failed to create DiskSpacePrimitives")

	// Initially no files
	fileList, err := space.FetchFileList()
	assert.NoError(t, err)
	assert.Len(t, fileList, 0)

	// Write a file matching the gitignore pattern
	_, err = space.WriteFile("test.txt", []byte("Test"), nil)
	assert.NoError(t, err)
	// And one that doesn't
	_, err = space.WriteFile("test.md", []byte("Test"), nil)
	assert.NoError(t, err)

	fileList, err = space.FetchFileList()
	assert.NoError(t, err)
	// Only 1 file
	assert.Len(t, fileList, 1)
	assert.Equal(t, fileList[0].Name, "test.md")

}

func TestHiddenFilesAndDirectories(t *testing.T) {
	space, err := NewDiskSpacePrimitives(t.TempDir(), "")
	assert.NoError(t, err, "Failed to create DiskSpacePrimitives")

	// Create various files and directories
	_, err = space.WriteFile("visible.txt", []byte("visible"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile(".hidden.txt", []byte("hidden"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile("dir/.hidden.txt", []byte("hidden in dir"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile("dir/visible.txt", []byte("visible in dir"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile(".hidden/visible.txt", []byte("visible in hidden dir"), nil)
	assert.NoError(t, err)

	// Test deeply nested hidden directory
	_, err = space.WriteFile(".hidden/.nested/deep.txt", []byte("deep nested hidden"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile("visible/.hidden/nested.txt", []byte("hidden in visible dir"), nil)
	assert.NoError(t, err)

	fileList, err := space.FetchFileList()
	assert.NoError(t, err)

	// Should only contain visible files (not hidden files or files in hidden directories)
	assert.Len(t, fileList, 2)

	fileNames := make([]string, len(fileList))
	for i, file := range fileList {
		fileNames[i] = file.Name
	}

	assert.Contains(t, fileNames, "visible.txt")
	assert.Contains(t, fileNames, "dir/visible.txt")
	assert.NotContains(t, fileNames, ".hidden.txt")
	assert.NotContains(t, fileNames, "dir/.hidden.txt")
	assert.NotContains(t, fileNames, ".hidden/visible.txt")
	assert.NotContains(t, fileNames, ".hidden/.nested/deep.txt")
	assert.NotContains(t, fileNames, "visible/.hidden/nested.txt")
}

func TestFileExtensionRequirement(t *testing.T) {
	space, err := NewDiskSpacePrimitives(t.TempDir(), "")
	assert.NoError(t, err, "Failed to create DiskSpacePrimitives")

	// Create files with and without extensions
	_, err = space.WriteFile("file.txt", []byte("with extension"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile("file.md", []byte("with extension"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile("noextension", []byte("without extension"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile("README", []byte("without extension"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile("dir/file.json", []byte("with extension in dir"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile("dir/noext", []byte("without extension in dir"), nil)
	assert.NoError(t, err)

	fileList, err := space.FetchFileList()
	assert.NoError(t, err)

	// Should only contain files with extensions
	assert.Len(t, fileList, 3)

	fileNames := make([]string, len(fileList))
	for i, file := range fileList {
		fileNames[i] = file.Name
	}

	assert.Contains(t, fileNames, "file.txt")
	assert.Contains(t, fileNames, "file.md")
	assert.Contains(t, fileNames, "dir/file.json")
	assert.NotContains(t, fileNames, "noextension")
	assert.NotContains(t, fileNames, "README")
	assert.NotContains(t, fileNames, "dir/noext")
}

// Test directory cleanup - specific to disk implementation
func TestCleanOrphaned(t *testing.T) {
	tmpDir := t.TempDir()
	space, err := NewDiskSpacePrimitives(tmpDir, "")
	assert.NoError(t, err, "Failed to create DiskSpacePrimitives")

	// Create nested directory structure with a file
	_, err = space.WriteFile("deep/nested/dir/file.txt", []byte("test"), nil)
	assert.NoError(t, err, "Failed to write nested file")

	// Verify the directories were created
	nestedPath := filepath.Join(tmpDir, "deep", "nested", "dir")
	_, err = os.Stat(nestedPath)
	assert.False(t, os.IsNotExist(err), "Nested directory should exist")

	// Delete the file
	err = space.DeleteFile("deep/nested/dir/file.txt")
	assert.NoError(t, err, "Failed to delete nested file")

	_, err = os.Stat(nestedPath)
	assert.True(t, os.IsNotExist(err), "Empty nested directory should be cleaned up")

	deepPath := filepath.Join(tmpDir, "deep")
	_, err = os.Stat(deepPath)
	assert.True(t, os.IsNotExist(err), "Empty parent directory should be cleaned up")
}

func TestSymlinksAndInfiniteLoops(t *testing.T) {
	tmpDir := t.TempDir()
	space, err := NewDiskSpacePrimitives(tmpDir, "")
	assert.NoError(t, err, "Failed to create DiskSpacePrimitives")

	// Create a regular file to symlink to
	_, err = space.WriteFile("target.txt", []byte("target content"), nil)
	assert.NoError(t, err, "Failed to create target file")

	// Create a directory with files to symlink to
	_, err = space.WriteFile("targetdir/file.md", []byte("dir content"), nil)
	assert.NoError(t, err, "Failed to create target directory file")

	targetPath := filepath.Join(tmpDir, "target.txt")
	targetDirPath := filepath.Join(tmpDir, "targetdir")

	// Test 1: Create symlink to file
	symlinkPath := filepath.Join(tmpDir, "link.txt")
	err = os.Symlink(targetPath, symlinkPath)
	assert.NoError(t, err, "Failed to create file symlink")

	// Test 2: Create symlink to directory
	symlinkDirPath := filepath.Join(tmpDir, "linkdir")
	err = os.Symlink(targetDirPath, symlinkDirPath)
	assert.NoError(t, err, "Failed to create directory symlink")

	// Test 3: Create infinite loop - symlink pointing to itself
	loopPath := filepath.Join(tmpDir, "loop.txt")
	err = os.Symlink(loopPath, loopPath)
	assert.NoError(t, err, "Failed to create self-referencing symlink")

	// Test 4: Create circular symlinks
	circleAPath := filepath.Join(tmpDir, "circleA.txt")
	circleBPath := filepath.Join(tmpDir, "circleB.txt")
	err = os.Symlink(circleBPath, circleAPath)
	assert.NoError(t, err, "Failed to create first circular symlink")
	err = os.Symlink(circleAPath, circleBPath)
	assert.NoError(t, err, "Failed to create second circular symlink")

	// Test that FetchFileList doesn't hang and returns expected files
	fileList, err := space.FetchFileList()
	assert.NoError(t, err, "FetchFileList should handle symlinks without error")

	// Collect file names for verification
	fileNames := make([]string, len(fileList))
	for i, file := range fileList {
		fileNames[i] = file.Name
	}

	// Should contain the original files and symlinked files (if they have extensions)
	assert.Contains(t, fileNames, "target.txt", "Original file should be present")
	assert.Contains(t, fileNames, "targetdir/file.md", "File in target directory should be present")
	assert.Contains(t, fileNames, "link.txt", "Symlinked file should be present")
	assert.Contains(t, fileNames, "linkdir/file.md", "File in symlinked directory should be present")

	// Infinite loop symlinks should not cause issues (they may or may not appear in the list)
	// The important thing is that FetchFileList completes without hanging

	// Test that we can read through symlinks
	content, meta, err := space.ReadFile("link.txt")
	assert.NoError(t, err, "Should be able to read through file symlink")
	assert.Equal(t, []byte("target content"), content, "Symlinked file should have correct content")
	assert.Equal(t, "link.txt", meta.Name, "Meta should use the symlink name")

	content, meta, err = space.ReadFile("linkdir/file.md")
	assert.NoError(t, err, "Should be able to read through directory symlink")
	assert.Equal(t, []byte("dir content"), content, "File in symlinked directory should have correct content")
	assert.Equal(t, "linkdir/file.md", meta.Name, "Meta should use the symlinked path")

	// Test that we can write through symlinks
	_, err = space.WriteFile("link.txt", []byte("new content"), nil)
	assert.NoError(t, err, "Should be able to write through symlink")

	// Verify the original file was updated
	content, _, err = space.ReadFile("target.txt")
	assert.NoError(t, err, "Should be able to read original file")
	assert.Equal(t, []byte("new content"), content, "Original file should be updated through symlink")
}

// Test that virtual pages (like tag:Something) return ErrNotFound instead of possible filesystem errors
func TestVirtualPages(t *testing.T) {
	space, err := NewDiskSpacePrimitives(t.TempDir(), "")
	assert.NoError(t, err, "Failed to create DiskSpacePrimitives")

	// Virtual pages with colons should return ErrNotFound, not a filesystem error
	_, err = space.GetFileMeta("tag:Todo.md")
	assert.Equal(t, ErrNotFound, err, "Virtual page should return ErrNotFound")

	_, err = space.GetFileMeta("search:this")
	assert.Equal(t, ErrNotFound, err, "Virtual page should return ErrNotFound")

	_, _, err = space.ReadFile("tag:Todo.md")
	assert.Equal(t, ErrNotFound, err, "Reading virtual page should return ErrNotFound")

}


================================================
FILE: server/embed_space_primitives.go
================================================
package server

import (
	"errors"
	"fmt"
	"io/fs"
	"log"
	pathLib "path"
	"strings"
	"time"
)

// Implements a simple read-only fallthrough implementation of SpacePrimitives, used to serve static files embedde in the Go binary
type ReadOnlyFallthroughSpacePrimitives struct {
	fsys                       fs.FS
	fallthroughSpacePrimitives SpacePrimitives
	rootPath                   string    // Root path within the fs.FS
	timeStamp                  time.Time // Fake timestamp to use for all files
}

var _ SpacePrimitives = &ReadOnlyFallthroughSpacePrimitives{}

func NewReadOnlyFallthroughSpacePrimitives(fsys fs.FS, rootPath string, timeStamp time.Time, wrapped SpacePrimitives) *ReadOnlyFallthroughSpacePrimitives {
	return &ReadOnlyFallthroughSpacePrimitives{
		fsys:                       fsys,
		fallthroughSpacePrimitives: wrapped,
		rootPath:                   rootPath,
		timeStamp:                  timeStamp,
	}
}

// pathToEmbedPath converts a relative path to an fs.FS path
func (e *ReadOnlyFallthroughSpacePrimitives) pathToEmbedPath(path string) string {
	return pathLib.Join(e.rootPath, path)
}

// Inverse of pathToEmbedPath
func (e *ReadOnlyFallthroughSpacePrimitives) embedPathToPath(path string) string {
	return strings.TrimPrefix(path, e.rootPath+"/")
}

// fileInfoToFileMeta converts fs.FileInfo to FileMeta for fs.FS files
func (e *ReadOnlyFallthroughSpacePrimitives) fileInfoToFileMeta(path string, info fs.FileInfo) FileMeta {
	return FileMeta{
		Name:         path,
		Size:         info.Size(),
		ContentType:  LookupContentTypeFromPath(path),
		Created:      e.timeStamp.UnixMilli(),
		LastModified: e.timeStamp.UnixMilli(),
		Perm:         "ro",
	}
}

// FetchFileList implements SpacePrimitives.FetchFileList
// Lists all files from the filesystem first, then combines files from the fallback.
func (e *ReadOnlyFallthroughSpacePrimitives) FetchFileList() ([]FileMeta, error) {
	allFiles := make([]FileMeta, 0, 1000)

	// First, collect files from the filesystem
	err := fs.WalkDir(e.fsys, e.rootPath, func(path string, entry fs.DirEntry, err error) error {
		if err != nil {
			// Skip files that can't be accessed
			return nil
		}

		// Skip directories
		if entry.IsDir() {
			return nil
		}

		// Get file info
		info, err := entry.Info()
		if err != nil {
			// Skip files we can't stat
			return nil
		}

		// Convert to our path format (forward slashes)
		relativePath := e.embedPathToPath(path)
		fileMeta := e.fileInfoToFileMeta(relativePath, info)
		allFiles = append(allFiles, fileMeta)

		return nil
	})

	if err != nil {
		log.Printf("Something went wrong listing files in the FS: %v", err)
	}

	wrappedFiles, err := e.fallthroughSpacePrimitives.FetchFileList()
	if err != nil {
		return allFiles, err
	}

	allFiles = append(allFiles, wrappedFiles...)

	return allFiles, nil
}

// GetFileMeta implements SpacePrimitives.GetFileMeta
func (e *ReadOnlyFallthroughSpacePrimitives) GetFileMeta(path string) (FileMeta, error) {
	embedPath := e.pathToEmbedPath(path)

	// Try to get file info from filesystem
	info, err := fs.Stat(e.fsys, embedPath)
	if err == nil && !info.IsDir() {
		return e.fileInfoToFileMeta(path, info), nil
	}

	if e.fallthroughSpacePrimitives == nil {
		return FileMeta{}, errors.New("Not found")
	}

	// If not found in fs.FS, fall back
	return e.fallthroughSpacePrimitives.GetFileMeta(path)
}

// ReadFile implements SpacePrimitives.ReadFile
func (e *ReadOnlyFallthroughSpacePrimitives) ReadFile(path string) ([]byte, FileMeta, error) {
	embedPath := e.pathToEmbedPath(path)

	// Try to read from filesystem
	data, err := fs.ReadFile(e.fsys, embedPath)
	if err == nil {
		// Get file info for metadata
		info, statErr := fs.Stat(e.fsys, embedPath)
		if statErr != nil {
			return nil, FileMeta{}, statErr
		}

		return data, e.fileInfoToFileMeta(path, info), nil
	}

	if e.fallthroughSpacePrimitives == nil {
		return nil, FileMeta{}, errors.New("Not found")
	}

	// If not found in fs.FS, fall back
	return e.fallthroughSpacePrimitives.ReadFile(path)
}

// WriteFile implements SpacePrimitives.WriteFile
// Fails if file exists in filesystem, otherwise delegates to fallback
func (e *ReadOnlyFallthroughSpacePrimitives) WriteFile(path string, data []byte, meta *FileMeta) (FileMeta, error) {
	embedPath := e.pathToEmbedPath(path)

	// Check if file exists in filesystem
	_, err := fs.Stat(e.fsys, embedPath)
	if err == nil {
		// File exists in filesystem, cannot write
		return FileMeta{}, fmt.Errorf("Cannot write file %s: read-only", path)
	}

	if e.fallthroughSpacePrimitives == nil {
		return FileMeta{}, errors.New("Not found")
	}

	return e.fallthroughSpacePrimitives.WriteFile(path, data, meta)
}

// DeleteFile implements SpacePrimitives.DeleteFile
// Fails if file exists in filesystem, otherwise delegates to fallback
func (e *ReadOnlyFallthroughSpacePrimitives) DeleteFile(path string) error {
	embedPath := e.pathToEmbedPath(path)

	// Check if file exists in filesystem
	_, err := fs.Stat(e.fsys, embedPath)
	if err == nil {
		// File exists in filesystem, cannot delete
		return fmt.Errorf("cannot delete file %s: read-only file", path)
	}

	if e.fallthroughSpacePrimitives == nil {
		return errors.New("Not found")
	}

	return e.fallthroughSpacePrimitives.DeleteFile(path)
}


================================================
FILE: server/embed_space_primitives_test.go
================================================
package server

import (
	"testing"
	"testing/fstest"
	"time"

	"github.com/stretchr/testify/assert"
)

func TestFSSpacePrimitivesWithMapFS(t *testing.T) {
	// Test with a MapFS that has embedded files
	mapFS := fstest.MapFS{
		"readme.txt":  &fstest.MapFile{Data: []byte("Hello World")},
		"config.json": &fstest.MapFile{Data: []byte(`{"test": true}`)},
	}

	// Note: t.TempDir() automatically deletes the folder after the tests runs
	fallback, err := NewDiskSpacePrimitives(t.TempDir(), "")
	assert.NoError(t, err, "Failed to create fallback")

	primitives := NewReadOnlyFallthroughSpacePrimitives(mapFS, "", time.Now(), fallback)

	// Test reading embedded files
	data, meta, err := primitives.ReadFile("readme.txt")
	assert.NoError(t, err, "Should read embedded file")
	assert.Equal(t, []byte("Hello World"), data, "Content should match")
	assert.Equal(t, "readme.txt", meta.Name, "Name should match")
	assert.Equal(t, "ro", meta.Perm, "Embedded files should be read-only")

	// Test that writing to embedded file names fails
	_, err = primitives.WriteFile("readme.txt", []byte("new content"), nil)
	assert.Error(t, err, "Should not overwrite embedded files")
	assert.Contains(t, err.Error(), "read-only")

	// Test that deleting embedded files fails
	err = primitives.DeleteFile("config.json")
	assert.Error(t, err, "Should not delete embedded files")
	assert.Contains(t, err.Error(), "read-only")

	// Test writing to non-embedded file names (should work via fallback)
	_, err = primitives.WriteFile("new_file.txt", []byte("fallback content"), nil)
	assert.NoError(t, err, "Should write to fallback for non-embedded files")

	// Test that fallback file can be read back
	data, meta, err = primitives.ReadFile("new_file.txt")
	assert.NoError(t, err, "Should read fallback file")
	assert.Equal(t, []byte("fallback content"), data, "Fallback content should match")
	assert.Equal(t, "new_file.txt", meta.Name, "Fallback file name should match")
}


================================================
FILE: server/fs.go
================================================
package server

import (
	"io"
	"log"
	"net/http"
	"net/url"
	"strconv"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/render"
)

// buildFsRoutes creates the filesystem API routes
func buildFsRoutes() http.Handler {
	fsRouter := chi.NewRouter()

	// File list endpoint
	fsRouter.Get("/", handleFsList)

	// File operations
	fsRouter.Get("/*", handleFsGet)
	fsRouter.Put("/*", handleFsPut)
	fsRouter.Delete("/*", handleFsDelete)
	fsRouter.Options("/*", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Allow", "GET, PUT, DELETE, OPTIONS")
		w.WriteHeader(http.StatusOK)
	})

	return fsRouter
}

func handleFsList(w http.ResponseWriter, r *http.Request) {
	spaceConfig := spaceConfigFromContext(r.Context())
	if r.Header.Get("X-Sync-Mode") != "" {
		// Handle direct requests for JSON representation of file list
		files, err := spaceConfig.SpacePrimitives.FetchFileList()
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		w.Header().Set("X-Space-Path", spaceConfig.SpaceFolderPath)
		w.Header().Set("Cache-Control", "no-cache")
		render.JSON(w, r, files)
	} else {
		// Otherwise, redirect to the UI
		http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
	}
}

// handleFsGet handles GET requests for individual files
func handleFsGet(w http.ResponseWriter, r *http.Request) {
	path := DecodeURLParam(r, "*")
	spaceConfig := spaceConfigFromContext(r.Context())

	// log.Printf("Got this path: %s", path)

	if r.Header.Get("X-Get-Meta") != "" {
		// Getting meta via GET request
		meta, err := spaceConfig.SpacePrimitives.GetFileMeta(path)
		if err != nil {
			if err == ErrNotFound {
				http.NotFound(w, r)
			} else {
				http.Error(w, err.Error(), http.StatusInternalServerError)
			}
			return
		}

		setFileMetaHeaders(w, meta)
		w.WriteHeader(http.StatusOK)
		return
	}

	// Read file content
	data, meta, err := spaceConfig.SpacePrimitives.ReadFile(path)
	if err != nil {
		if err == ErrNotFound {
			http.NotFound(w, r)
		} else {
			http.Error(w, err.Error(), http.StatusInternalServerError)
		}
		return
	}

	setFileMetaHeaders(w, meta)
	w.WriteHeader(http.StatusOK)
	w.Write(data)
}

// handleFsPut handles PUT requests for writing files
func handleFsPut(w http.ResponseWriter, r *http.Request) {
	path := DecodeURLParam(r, "*")
	spaceConfig := spaceConfigFromContext(r.Context())

	// Read request body
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Failed to read request body", http.StatusBadRequest)
		return
	}

	// Write file
	meta, err := spaceConfig.SpacePrimitives.WriteFile(path, body, getFileMetaFromHeaders(r.Header, path))
	if err != nil {
		log.Printf("Write failed: %v\n", err)
		http.Error(w, "Write failed", http.StatusInternalServerError)
		return
	}

	setFileMetaHeaders(w, meta)
	w.WriteHeader(http.StatusOK)
	w.Write([]byte("OK"))
}

// handleFsDelete handles DELETE requests for removing files
func handleFsDelete(w http.ResponseWriter, r *http.Request) {
	path := DecodeURLParam(r, "*")
	spaceConfig := spaceConfigFromContext(r.Context())

	if err := spaceConfig.SpacePrimitives.DeleteFile(path); err != nil {
		if err == ErrNotFound {
			http.NotFound(w, r)
		} else {
			log.Printf("Error deleting file: %v\n", err)
			http.Error(w, err.Error(), http.StatusInternalServerError)
		}
		return
	}

	w.WriteHeader(http.StatusOK)
	w.Write([]byte("OK"))
}

// setFileMetaHeaders sets HTTP headers based on FileMeta
func setFileMetaHeaders(w http.ResponseWriter, meta FileMeta) {
	w.Header().Set("Content-Type", meta.ContentType)
	w.Header().Set("X-Created", strconv.FormatInt(meta.Created, 10))
	w.Header().Set("X-Last-Modified", strconv.FormatInt(meta.LastModified, 10))
	w.Header().Set("X-Content-Length", strconv.FormatInt(meta.Size, 10))
	w.Header().Set("X-Permission", meta.Perm)
	w.Header().Set("Cache-Control", "no-cache")
}

// Build FileMeta from HTTP headers (reverse of setFileMetaHeaders)
func getFileMetaFromHeaders(h http.Header, path string) *FileMeta {
	var err error

	fm := &FileMeta{
		Name:        path,
		ContentType: h.Get("Content-Type"),
		Perm:        h.Get("X-Permission"),
	}
	if fm.Perm == "" {
		fm.Perm = "ro"
	}
	if h.Get("X-Content-Length") != "" {
		fm.Size, err = strconv.ParseInt(h.Get("X-Content-Length"), 10, 64)
		if err != nil {
			log.Printf("Could not parse content length: %v", err)
		}
	} else if h.Get("Content-Length") != "" {
		fm.Size, err = strconv.ParseInt(h.Get("Content-Length"), 10, 64)
		if err != nil {
			log.Printf("Could not parse content length: %v", err)
		}
	}
	if h.Get("X-Created") != "" {
		fm.Created, err = strconv.ParseInt(h.Get("X-Created"), 10, 64)
		if err != nil {
			log.Printf("Could not parse created time: %v", err)
		}
	}
	if h.Get("X-Last-Modified") != "" {
		fm.LastModified, err = strconv.ParseInt(h.Get("X-Last-Modified"), 10, 64)
		if err != nil {
			log.Printf("Could not parse modified time: %v", err)
		}
	}

	return fm
}

func DecodeURLParam(r *http.Request, name string) string {
	// Source: https://github.com/go-chi/chi/issues/642
	value := chi.URLParam(r, name)
	if r.URL.RawPath != "" {
		value, _ = url.PathUnescape(value)
	}
	return value
}


================================================
FILE: server/logs_endpoint.go
================================================
package server

import (
	"log"
	"net/http"
	"strings"

	"github.com/go-chi/render"
)

type LogEntry struct {
	Source    string `json:"source"`
	Level     string `json:"level"`
	Message   string `json:"message"`
	Timestamp int64  `json:"timestamp"`
}

func handleLogsEndpoint(w http.ResponseWriter, r *http.Request) {
	var messages []LogEntry

	if err := render.DecodeJSON(r.Body, &messages); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	for _, message := range messages {
		remoteAddrPieces := strings.Split(r.RemoteAddr, ":")
		log.Printf("[Client %s] [%s %s] %s (%s)", remoteAddrPieces[0], message.Source, message.Level, message.Message, utcDateString(message.Timestamp))
	}
	render.PlainText(w, r, "OK")
}


================================================
FILE: server/manifest_endpoint.go
================================================
package server

import (
	"net/http"

	"github.com/go-chi/render"
)

// ManifestIcon represents an icon in the PWA manifest
type ManifestIcon struct {
	Src   string `json:"src"`
	Type  string `json:"type"`
	Sizes string `json:"sizes"`
}

// Manifest represents the structure of a PWA manifest.json file
type Manifest struct {
	ShortName       string         `json:"short_name"`
	Name            string         `json:"name"`
	Icons           []ManifestIcon `json:"icons"`
	CaptureLinks    string         `json:"capture_links"`
	StartURL        string         `json:"start_url"`
	Display         string         `json:"display"`
	DisplayOverride []string       `json:"display_override"`
	Scope           string         `json:"scope"`
	ThemeColor      string         `json:"theme_color"`
	Description     string         `json:"description"`
}

// Generate PWA manifest.json with dynamic values
func manifestHandler(w http.ResponseWriter, r *http.Request) {
	spaceConfig := spaceConfigFromContext(r.Context())
	config := serverConfigFromContext(r.Context())
	manifest := Manifest{
		ShortName: spaceConfig.SpaceName,
		Name:      spaceConfig.SpaceName,
		Icons: []ManifestIcon{
			{
				Src:   "/.client/logo-dock.png",
				Type:  "image/png",
				Sizes: "512x512",
			},
		},
		CaptureLinks:    "new-client",
		StartURL:        "/#boot",
		Display:         "standalone",
		DisplayOverride: []string{"window-controls-overlay"},
		Scope:           "/",
		ThemeColor:      "#e1e1e1",
		Description:     spaceConfig.SpaceDescription,
	}

	if config.HostURLPrefix != "" {
		manifest.Icons[0].Src = config.HostURLPrefix + manifest.Icons[0].Src
		manifest.StartURL = config.HostURLPrefix + manifest.StartURL
		manifest.Scope = config.HostURLPrefix + manifest.Scope
	}

	render.JSON(w, r, manifest)
}


================================================
FILE: server/prometheus.go
================================================
package server

import (
	"fmt"
	"log"
	"net/http"

	"github.com/go-chi/chi/v5"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

var httpRequestsTotal = prometheus.NewCounter(
	prometheus.CounterOpts{
		Name: "silverbullet_http_requests",
		Help: "Total number of HTTP requests to SilverBullet",
	},
)

func init() {
	prometheus.MustRegister(httpRequestsTotal)
}

func httpStatsMiddleware(config *ServerConfig) func(http.Handler) http.Handler {
	if config.MetricsPort == 0 {
		// NOOP
		return func(next http.Handler) http.Handler {
			return next
		}
	} else {
		return func(next http.Handler) http.Handler {
			return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				httpRequestsTotal.Inc()
				next.ServeHTTP(w, r)
			})
		}
	}
}

func runMetricsServer(config *ServerConfig) {
	if config.MetricsPort != 0 {
		metricRouter := chi.NewRouter()
		metricsServer := &http.Server{
			Addr:    fmt.Sprintf("%s:%d", config.BindHost, config.MetricsPort),
			Handler: metricRouter,
		}
		metricRouter.Handle("/metrics", promhttp.Handler())
		log.Printf("Metrics server started on %s:%d", config.BindHost, config.MetricsPort)
		metricsServer.ListenAndServe()
	}
}


================================================
FILE: server/proxy.go
================================================
package server

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"regexp"
	"strconv"
	"strings"

	"github.com/go-chi/chi/v5"
	"github.com/prometheus/client_golang/prometheus"
)

var localhostRegex *regexp.Regexp = regexp.MustCompile(`^(localhost|127\.0\.0\.1|\d+\.\d+\.\d+\.\d+|host\.docker\.internal)`)

var proxyRequestsTotal = prometheus.NewCounter(prometheus.CounterOpts{
	Name: "silverbullet_proxy_requests",
	Help: "Total number of proxy requests in the SilverBullet space",
})

func init() {
	prometheus.MustRegister(proxyRequestsTotal)
}

func proxyHandler(w http.ResponseWriter, r *http.Request) {
	spaceConfig := spaceConfigFromContext(r.Context())
	if spaceConfig.ReadOnlyMode {
		http.Error(w, "Read only mode, no proxy allowed", http.StatusMethodNotAllowed)
		return
	}

	// Extract the URI from the path
	path := chi.URLParam(r, "*")
	if path == "" {
		http.Error(w, "No URI provided", http.StatusBadRequest)
		return
	}

	// Add query parameters if they exist
	if r.URL.RawQuery != "" {
		path += "?" + r.URL.RawQuery
	}

	// Determine protocol based on hostname
	var targetURL string
	if localhostRegex.MatchString(path) {
		targetURL = "http://" + path
	} else {
		targetURL = "https://" + path
	}

	log.Printf("Proxying to %s\n", targetURL)

	// Create the proxy request
	req, err := http.NewRequest(r.Method, targetURL, r.Body)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	// Copy headers starting with X-Proxy-Header-, removing the prefix
	for key, values := range r.Header {
		if strings.HasPrefix(strings.ToLower(key), "x-proxy-header-") {
			headerName := key[len("x-proxy-header-"):]
			for _, value := range values {
				req.Header.Set(headerName, value)
			}
		}
	}

	// TODO: Replaced with specifically configured client
	client := http.DefaultClient

	resp, err := client.Do(req)
	if err != nil {
		log.Printf("Error fetching proxied URL: %v\n", err)
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	defer resp.Body.Close()

	// Copy response response header with x-proxy-header prefix to keep things clean
	for key, values := range resp.Header {
		for _, value := range values {
			w.Header().Set(fmt.Sprintf("x-proxy-header-%s", key), value)
		}
	}

	// Put status code in header
	w.Header().Set("x-proxy-status-code", strconv.Itoa(resp.StatusCode))
	w.Header().Set("content-type", resp.Header.Get("content-type"))
	// Always return 200, to avoid interference, actual status code is in x-proxy-status-code
	w.WriteHeader(http.StatusOK)
	// Copy body
	if _, err := io.Copy(w, resp.Body); err != nil {
		log.Printf("Proxy: failed to copy response body: %v", err)
	}
	proxyRequestsTotal.Inc()
}


================================================
FILE: server/proxy_test.go
================================================
package server

import (
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestLocalhostRegex(t *testing.T) {
	// Should match (use HTTP)
	shouldMatch := []string{
		"localhost:8080/api",
		"127.0.0.1:8080/api",
		"192.168.1.1:8080/api",
		"host.docker.internal:11434/api",
	}

	// Should NOT match (use HTTPS)
	shouldNotMatch := []string{
		"api.openai.com/v1",
		"example.com:8443/api",
		"",
	}

	for _, input := range shouldMatch {
		assert.True(t, localhostRegex.MatchString(input), "should match: %s", input)
	}

	for _, input := range shouldNotMatch {
		assert.False(t, localhostRegex.MatchString(input), "should not match: %s", input)
	}
}


================================================
FILE: server/read_only_space_primitives.go
================================================
package server

import (
	"errors"
)

type ReadOnlySpacePrimitives struct {
	wrapped SpacePrimitives
}

var _ SpacePrimitives = &ReadOnlySpacePrimitives{}
var NotAllowedError = errors.New("Not allowed")

func NewReadOnlySpacePrimitives(wrapped SpacePrimitives) *ReadOnlySpacePrimitives {
	return &ReadOnlySpacePrimitives{wrapped: wrapped}
}

// FetchFileList retrieves a list of all files in the space
func (ro *ReadOnlySpacePrimitives) FetchFileList() ([]FileMeta, error) {
	return ro.wrapped.FetchFileList()
}

// GetFileMeta retrieves metadata for a specific file
func (ro *ReadOnlySpacePrimitives) GetFileMeta(path string) (FileMeta, error) {
	return ro.wrapped.GetFileMeta(path)
}

// ReadFile reads a file and returns its data and metadata
func (ro *ReadOnlySpacePrimitives) ReadFile(path string) ([]byte, FileMeta, error) {
	return ro.wrapped.ReadFile(path)
}

// WriteFile returns an error since this is a read-only implementation
func (ro *ReadOnlySpacePrimitives) WriteFile(path string, data []byte, meta *FileMeta) (FileMeta, error) {
	return FileMeta{}, NotAllowedError
}

// DeleteFile returns an error since this is a read-only implementation
func (ro *ReadOnlySpacePrimitives) DeleteFile(path string) error {
	return NotAllowedError
}


================================================
FILE: server/server.go
================================================
package server

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/go-chi/render"
)

const indexHtmlPath = ".client/index.html"

// BootConfig represents the client configuration
type BootConfig struct {
	SpaceFolderPath string `json:"spaceFolderPath"`
	IndexPage       string `json:"indexPage"`
	ReadOnly        bool   `json:"readOnly"`

	// Whether or not the client should push logs to the server
	LogPush bool `json:"logPush"`

	// Encryption
	EnableClientEncryption bool `json:"enableClientEncryption"`
}

func Router(config *ServerConfig) chi.Router {
	r := chi.NewRouter()

	if config.EnableHTTPLogging {
		r.Use(middleware.Logger)
	}

	r.Use(middleware.RealIP)
	r.Use(middleware.Compress(5, "application/json", "text/markdown", "text/javascript", "text/html", "text/css", "text/plain"))

	// Expose space primitives and path to the request
	r.Use(spaceMiddleware(config))

	r.Use(httpStatsMiddleware(config))

	// Authentication middleware (applies to all routes after this point)
	r.Use(authMiddleware(config))

	routes := chi.NewRouter()

	// Authentication endpoints (must come before auth middleware)
	addAuthEndpoints(routes, config)

	routes.Get("/.ping", func(w http.ResponseWriter, r *http.Request) {
		spaceConfig := spaceConfigFromContext(r.Context())
		w.Header().Set("Cache-Control", "no-cache")
		w.Header().Set("X-Space-Path", spaceConfig.SpaceFolderPath)
		w.WriteHeader(http.StatusOK)
		w.Write([]byte("OK"))
	})

	// Mount filesystem routes under /.fs
	routes.Mount("/.fs", buildFsRoutes())

	// Config endpoint
	routes.Get("/.config", func(w http.ResponseWriter, r *http.Request) {
		spaceConfig := spaceConfigFromContext(r.Context())
		clientConfig := &BootConfig{
			SpaceFolderPath: spaceConfig.SpaceFolderPath,
			IndexPage:       spaceConfig.IndexPage,
			ReadOnly:        spaceConfig.ReadOnlyMode,
			LogPush:         spaceConfig.LogPush,
			// Client encryption is offered as an option when auth is enabled only
			EnableClientEncryption: spaceConfig.Auth != nil,
		}

		w.Header().Set("Cache-Control", "no-cache")
		render.JSON(w, r, clientConfig)
	})

	// Shell endpoint
	routes.Post("/.shell", handleShellEndpoint)

	// Log collection endpoint
	routes.Post("/.logs", handleLogsEndpoint)

	// Proxy endpoint
	routes.HandleFunc("/.proxy/*", proxyHandler)

	// Manifest endpoint
	routes.HandleFunc("/.client/manifest.json", manifestHandler)

	routes.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
		path := DecodeURLParam(r, "*")

		spaceConfig := spaceConfigFromContext(r.Context())

		// See if it's in the client bundle
		data, meta, err := config.ClientBundle.ReadFile(path)
		if err == nil {
			// File is in the bundle, let's serve it
			if r.Header.Get("If-Modified-Since") == utcDateString(meta.LastModified) {
				w.WriteHeader(304)
				return
			}
			w.Header().Set("Content-Type", meta.ContentType)
			w.Header().Set("Last-Modified", utcDateString(meta.LastModified))
			w.WriteHeader(200)
			w.Write(data)
			return
		}

		// TODO: handle request types
		ServerSideRender(config, spaceConfig, path, w, r)
	})

	if config.HostURLPrefix == "" {
		r.Mount("/", routes)
	} else {
		r.Mount(config.HostURLPrefix, routes)
	}
	return r
}

func RunServer(config *ServerConfig) error {
	r := Router(config)
	// Display the final server running message
	visibleHostname := config.BindHost
	if config.BindHost == "127.0.0.1" {
		visibleHostname = "localhost"
	}
	log.Printf("SilverBullet is now running: http://%s:%d", visibleHostname, config.Port)

	server := &http.Server{
		Addr:    fmt.Sprintf("%s:%d", config.BindHost, config.Port),
		Handler: r,
	}

	shutdownChannel := make(chan bool, 1)

	go func() {
		if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("HTTP server error: %v", err)
		}
		log.Println("Stopped serving new connections.")
		shutdownChannel <- true
	}()

	go runMetricsServer(config)

	signalChannel := make(chan os.Signal, 1)
	signal.Notify(signalChannel, syscall.SIGINT, syscall.SIGTERM)
	// Block on incoming signals.
	s := <-signalChannel
	log.Println("Received signal:", s)

	shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second)
	defer shutdownRelease()

	if err := server.Shutdown(shutdownCtx); err != nil {
		log.Fatalf("HTTP shutdown error: %v", err)
	}
	<-shutdownChannel
	log.Println("Graceful shutdown complete.")
	return nil
}


================================================
FILE: server/shell_backend.go
================================================
package server

import (
	"bytes"
	"log"
	"os/exec"
	"slices"
	"strings"
)

// LocalShell implements shell execution on the local system
type LocalShell struct {
	Cwd          string
	CmdWhiteList []string
	AllowAllCmds bool
}

// NewLocalShell creates a new LocalShell instance
func NewLocalShell(cwd string, cmdWhiteList string) *LocalShell {
	whiteListedCommands := strings.Split(cmdWhiteList, " ")
	if cmdWhiteList == "" {
		whiteListedCommands = []string{}
	}
	return &LocalShell{
		Cwd:          cwd,
		CmdWhiteList: whiteListedCommands,
		AllowAllCmds: len(whiteListedCommands) == 0,
	}
}

// Handle executes a shell command and returns the result
func (ls *LocalShell) Handle(request ShellRequest) (ShellResponse, error) {
	// Check if command is whitelisted
	if !ls.AllowAllCmds && !slices.Contains(ls.CmdWhiteList, request.Cmd) {
		log.Printf("Not running shell command because not in whitelist: %s\n", request.Cmd)
		return ShellResponse{
			Code:   -1,
			Stdout: "",
			Stderr: "Not allowed, command not in whitelist",
		}, nil
	}

	log.Printf("Running shell command: %s %s\n", request.Cmd, strings.Join(request.Args, " "))

	// Create the command
	cmd := exec.Command(request.Cmd, request.Args...)
	cmd.Dir = ls.Cwd

	// Set up stdin if provided
	if request.Stdin != nil {
		cmd.Stdin = strings.NewReader(*request.Stdin)
	}

	// Set up separate buffers for stdout and stderr
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr

	// Execute the command
	err := cmd.Run()

	var exitCode int
	if err != nil {
		// Try to get the exit code
		if exitError, ok := err.(*exec.ExitError); ok {
			exitCode = exitError.ExitCode()
		} else {
			// Command failed to start
			return ShellResponse{
				Code:   -1,
				Stdout: "",
				Stderr: err.Error(),
			}, nil
		}
	}

	return ShellResponse{
		Code:   exitCode,
		Stdout: stdout.String(),
		Stderr: stderr.String(),
	}, nil
}

// NotSupportedShell implements a shell backend that doesn't support execution
type NotSupportedShell struct{}

// NewNotSupportedShell creates a new NotSupportedShell instance
func NewNotSupportedShell() *NotSupportedShell {
	return &NotSupportedShell{}
}

// Handle always returns a "not supported" error
func (nss *NotSupportedShell) Handle(request ShellRequest) (ShellResponse, error) {
	return ShellResponse{
		Code:   1,
		Stdout: "",
		Stderr: "Not supported",
	}, nil
}


================================================
FILE: server/shell_backend_test.go
================================================
package server

import (
	"os"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestLocalShell_HandleValidCommand(t *testing.T) {
	tmpDir := t.TempDir()
	shell := NewLocalShell(tmpDir, "") // No whitelist = allow all commands

	request := ShellRequest{
		Cmd:  "echo",
		Args: []string{"Hello World"},
	}

	response, err := shell.Handle(request)
	assert.NoError(t, err, "Should handle valid command without error")
	assert.Equal(t, 0, response.Code, "Echo command should exit with code 0")
	assert.Equal(t, "Hello World\n", response.Stdout, "Should return stdout")
	assert.Empty(t, response.Stderr, "Should have empty stderr")
}

func TestLocalShell_HandleCommandWithStdin(t *testing.T) {
	tmpDir := t.TempDir()
	shell := NewLocalShell(tmpDir, "")

	input := "test input"
	request := ShellRequest{
		Cmd:   "cat",
		Args:  []string{},
		Stdin: &input,
	}

	response, err := shell.Handle(request)
	assert.NoError(t, err, "Should handle command with stdin without error")
	assert.Equal(t, 0, response.Code, "cat command should exit with code 0")
	assert.Equal(t, "test input", response.Stdout, "Should echo stdin to stdout")
	assert.Empty(t, response.Stderr, "Should have empty stderr")
}

func TestLocalShell_HandleWorkingDirectory(t *testing.T) {
	tmpDir := t.TempDir()
	shell := NewLocalShell(tmpDir, "")

	// Create a test file in the temp directory
	testFile := filepath.Join(tmpDir, "test.txt")
	err := os.WriteFile(testFile, []byte("test content"), 0644)
	assert.NoError(t, err, "Should create test file")

	request := ShellRequest{
		Cmd:  "ls",
		Args: []string{},
	}

	response, err := shell.Handle(request)
	assert.NoError(t, err, "Should handle ls command without error")
	assert.Equal(t, 0, response.Code, "ls command should exit with code 0")
	assert.Contains(t, response.Stdout, "test.txt", "Should list the test file")
	assert.Empty(t, response.Stderr, "Should have empty stderr")
}

func TestLocalShell_HandleWithWhitelist(t *testing.T) {
	tmpDir := t.TempDir()
	shell := NewLocalShell(tmpDir, "echo pwd")

	// Test allowed command
	request := ShellRequest{
		Cmd:  "echo",
		Args: []string{"allowed"},
	}

	response, err := shell.Handle(request)
	assert.NoError(t, err, "Should handle whitelisted command without error")
	assert.Equal(t, 0, response.Code, "Whitelisted command should succeed")
	assert.Equal(t, "allowed\n", response.Stdout, "Should return expected output")

	// Test disallowed command
	request = ShellRequest{
		Cmd:  "ls",
		Args: []string{},
	}

	response, err = shell.Handle(request)
	assert.NoError(t, err, "Should handle request without error")
	assert.Equal(t, -1, response.Code, "Non-whitelisted command should return -1")
	assert.Empty(t, response.Stdout, "Should have empty stdout")
	assert.Equal(t, "Not allowed, command not in whitelist", response.Stderr, "Should return whitelist error")
}

func TestLocalShell_HandleCommandWithBothStreams(t *testing.T) {
	tmpDir := t.TempDir()
	shell := NewLocalShell(tmpDir, "")

	// Create a shell script that outputs to both stdout and stderr
	script := `#!/bin/sh
echo "stdout message"
echo "stderr message" >&2
exit 0`

	scriptPath := filepath.Join(tmpDir, "test_script.sh")
	err := os.WriteFile(scriptPath, []byte(script), 0755)
	assert.NoError(t, err, "Should create test script")

	request := ShellRequest{
		Cmd:  "sh",
		Args: []string{scriptPath},
	}

	response, err := shell.Handle(request)
	assert.NoError(t, err, "Should handle script without error")
	assert.Equal(t, 0, response.Code, "Script should exit with code 0")
	assert.Equal(t, "stdout message\n", response.Stdout, "Should capture stdout")
	assert.Equal(t, "stderr message\n", response.Stderr, "Should capture stderr")
}


================================================
FILE: server/shell_endpoint.go
================================================
package server

import (
	"encoding/json"
	"log"
	"net/http"

	"github.com/prometheus/client_golang/prometheus"
)

var shellCommandsTotal = prometheus.NewCounter(prometheus.CounterOpts{
	Name: "silverbullet_shell_executions",
	Help: "Total number of shell commands executions",
})

func init() {
	prometheus.MustRegister(shellCommandsTotal)
}

// handleShellEndpoint handles POST requests to /.shell for executing shell commands
func handleShellEndpoint(w http.ResponseWriter, r *http.Request) {
	spaceConfig := spaceConfigFromContext(r.Context())
	// Parse the request body
	var shellRequest ShellRequest
	if err := json.NewDecoder(r.Body).Decode(&shellRequest); err != nil {
		http.Error(w, "Invalid JSON request", http.StatusBadRequest)
		return
	}

	// Execute the shell command
	shellResponse, err := spaceConfig.ShellBackend.Handle(shellRequest)
	if err != nil {
		log.Printf("Shell error: %v\n", err)
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	shellCommandsTotal.Inc()

	// Return the response as JSON
	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(shellResponse); err != nil {
		log.Printf("Error encoding shell response: %v\n", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
}


================================================
FILE: server/space_primitive_testing.go
================================================
package server

import (
	"bytes"
	"testing"

	"github.com/stretchr/testify/assert"
)

// TestSpacePrimitives runs a comprehensive test suite against any SpacePrimitives implementation
func TestSpacePrimitives(t *testing.T, spacePrimitives SpacePrimitives) {
	t.Run("BasicOperations", func(t *testing.T) {
		testBasicOperations(t, spacePrimitives)
	})
	t.Run("FileOverwriting", func(t *testing.T) {
		testFileOverwriting(t, spacePrimitives)
	})
	t.Run("EmptyFiles", func(t *testing.T) {
		testEmptyFiles(t, spacePrimitives)
	})
	t.Run("UnicodeContent", func(t *testing.T) {
		testUnicodeContent(t, spacePrimitives)
	})
	t.Run("SpecialFileNames", func(t *testing.T) {
		testSpecialFileNames(t, spacePrimitives)
	})
	t.Run("ErrorHandling", func(t *testing.T) {
		testErrorHandling(t, spacePrimitives)
	})
	t.Run("LargeFiles", func(t *testing.T) {
		testLargeFiles(t, spacePrimitives)
	})
	t.Run("MetadataPreservation", func(t *testing.T) {
		testMetadataPreservation(t, spacePrimitives)
	})

	// Ensure clean state at the end
	finalFiles, err := spacePrimitives.FetchFileList()
	assert.NoError(t, err, "Failed to fetch final file list")
	assert.Empty(t, finalFiles, "Expected empty file list at end")
}

func testBasicOperations(t *testing.T, spacePrimitives SpacePrimitives) {
	// Test initial empty state
	files, err := spacePrimitives.FetchFileList()
	assert.NoError(t, err, "Failed to fetch file list")
	assert.Empty(t, files, "Expected empty file list")

	// Write text file
	testContent := []byte("Hello World")
	fileMeta := &FileMeta{
		Name:         "test.txt",
		Perm:         "rw",
		Created:      10,
		ContentType:  "text/plain",
		LastModified: 20,
		Size:         11,
	}

	_, err = spacePrimitives.WriteFile("test.txt", testContent, fileMeta)
	assert.NoError(t, err, "Failed to write file")

	// Read the file back
	retrievedData, retrievedMeta, err := spacePrimitives.ReadFile("test.txt")
	assert.NoError(t, err, "Failed to read file")

	assert.Equal(t, testContent, retrievedData, "Retrieved data doesn't match")

	// Check that the meta data is persisted (lastModified should be preserved)
	assert.Equal(t, int64(20), retrievedMeta.LastModified, "Expected lastModified to be 20")

	// Check file list
	fileList, err := spacePrimitives.FetchFileList()
	assert.NoError(t, err, "Failed to fetch file list")
	assert.Len(t, fileList, 1, "Expected 1 file in list")
	assert.Equal(t, "test.txt", fileList[0].Name, "Expected file name 'test.txt'")

	// Write binary file
	buf := make([]byte, 1024*1024)
	buf[0] = 1
	buf[1] = 2
	buf[2] = 3
	buf[3] = 4
	buf[4] = 5

	_, err = spacePrimitives.WriteFile("test.bin", buf, nil)
	assert.NoError(t, err, "Failed to write binary file")

	fileData, _, err := spacePrimitives.ReadFile("test.bin")
	assert.NoError(t, err, "Failed to read binary file")

	assert.Len(t, fileData, 1024*1024, "Expected binary file size to match")

	fileList, err = spacePrimitives.FetchFileList()
	assert.NoError(t, err, "Failed to fetch file list")
	assert.Len(t, fileList, 2, "Expected 2 files in list")

	// Delete binary file
	err = spacePrimitives.DeleteFile("test.bin")
	assert.NoError(t, err, "Failed to delete binary file")

	fileList, err = spacePrimitives.FetchFileList()
	assert.NoError(t, err, "Failed to fetch file list")
	assert.Len(t, fileList, 1, "Expected 1 file in list after deletion")

	// Clean up
	err = spacePrimitives.DeleteFile("test.txt")
	assert.NoError(t, err, "Failed to delete test file")

	fileList, err = spacePrimitives.FetchFileList()
	assert.NoError(t, err, "Failed to fetch file list")
	assert.Empty(t, fileList, "Expected empty file list after cleanup")

	// Test weird file names
	weirdContent := []byte("Hello world!")
	_, err = spacePrimitives.WriteFile("test+'s.txt", weirdContent, nil)
	assert.NoError(t, err, "Failed to write file with weird name")

	readWeirdData, _, err := spacePrimitives.ReadFile("test+'s.txt")
	assert.NoError(t, err, "Failed to read file with weird name")

	assert.Equal(t, weirdContent, readWeirdData, "Weird file content doesn't match")

	err = spacePrimitives.DeleteFile("test+'s.txt")
	assert.NoError(t, err, "Failed to delete weird file")

	// Check deletion of weird file name
	_, _, err = spacePrimitives.ReadFile("test+'s.txt")
	assert.Equal(t, ErrNotFound, err, "Expected ErrNotFound when reading deleted file")
}

func testFileOverwriting(t *testing.T, spacePrimitives SpacePrimitives) {
	// Test overwriting existing files
	originalContent := []byte("Original")
	_, err := spacePrimitives.WriteFile("overwrite.txt", originalContent, nil)
	assert.NoError(t, err, "Failed to write original file")

	_, err = spacePrimitives.GetFileMeta("overwrite.txt")
	assert.NoError(t, err, "Failed to get original meta")

	updatedContent := []byte("Updated")
	_, err = spacePrimitives.WriteFile("overwrite.txt", updatedContent, nil)
	assert.NoError(t, err, "Failed to overwrite file")

	updatedData, _, err := spacePrimitives.ReadFile("overwrite.txt")
	assert.NoError(t, err, "Failed to read overwritten file")

	assert.Equal(t, updatedContent, updatedData, "Overwritten content doesn't match")

	// File list should still have only one entry for this file
	filesAfterOverwrite, err := spacePrimitives.FetchFileList()
	assert.NoError(t, err, "Failed to fetch files after overwrite")

	overwriteFiles := 0
	for _, f := range filesAfterOverwrite {
		if f.Name == "overwrite.txt" {
			overwriteFiles++
		}
	}

	assert.Equal(t, 1, overwriteFiles, "Expected 1 instance of overwrite.txt")

	err = spacePrimitives.DeleteFile("overwrite.txt")
	assert.NoError(t, err, "Failed to delete overwritten file")

	// Test writing files to new folders
	writeMeta, err := spacePrimitives.WriteFile("some/folder/test.dat", []byte("Testing"), nil)
	assert.NoError(t, err, "Writing to a nested folder should work")

	// Read it back
	data, meta, err := spacePrimitives.ReadFile("some/folder/test.dat")
	assert.NoError(t, err, "Reading should work")
	assert.Equal(t, writeMeta.LastModified, meta.LastModified, "Meta should be the same")
	assert.Equal(t, []byte("Testing"), data, "Did not get back what I put in")

	err = spacePrimitives.DeleteFile("some/folder/test.dat")
	assert.NoError(t, err, "Deletion should work")
}

func testEmptyFiles(t *testing.T, spacePrimitives SpacePrimitives) {
	// Test empty file
	emptyData := []byte{}
	_, err := spacePrimitives.WriteFile("empty.txt", emptyData, nil)
	assert.NoError(t, err, "Failed to write empty file")

	emptyFileData, emptyFileMeta, err := spacePrimitives.ReadFile("empty.txt")
	assert.NoError(t, err, "Failed to read empty file")

	assert.Empty(t, emptyFileData, "Expected empty file data")
	assert.Equal(t, int64(0), emptyFileMeta.Size, "Expected empty file meta size 0")

	err = spacePrimitives.DeleteFile("empty.txt")
	assert.NoError(t, err, "Failed to delete empty file")
}

func testUnicodeContent(t *testing.T, spacePrimitives SpacePrimitives) {
	// Test files with Unicode characters
	unicodeContent := []byte("Hello 世界! 🌍 Здравствуй мир!")
	_, err := spacePrimitives.WriteFile("unicode.txt", unicodeContent, nil)
	assert.NoError(t, err, "Failed to write unicode file")

	unicodeData, _, err := spacePrimitives.ReadFile("unicode.txt")
	assert.NoError(t, err, "Failed to read unicode file")

	assert.Equal(t, unicodeContent, unicodeData, "Unicode content doesn't match")

	err = spacePrimitives.DeleteFile("unicode.txt")
	assert.NoError(t, err, "Failed to delete unicode file")
}

func testSpecialFileNames(t *testing.T, spacePrimitives SpacePrimitives) {
	// Test file names with various special characters
	specialNames := []string{
		"file with spaces.txt",
		"file-with-hyphens.txt",
		"file_with_underscores.txt",
		"file.with.dots.txt",
		"UPPERCASE.TXT",
		"123numeric.txt",
		"émojis🚀file.txt",
	}

	for _, fileName := range specialNames {
		content := []byte("Content of " + fileName)
		_, err := spacePrimitives.WriteFile(fileName, content, nil)
		assert.NoError(t, err, "Failed to write file %s", fileName)

		fileData, _, err := spacePrimitives.ReadFile(fileName)
		assert.NoError(t, err, "Failed to read file %s", fileName)

		expectedContent := "Content of " + fileName
		assert.Equal(t, expectedContent, string(fileData), "Content mismatch for %s", fileName)
	}

	// Verify all special files are in the list
	allFiles, err := spacePrimitives.FetchFileList()
	assert.NoError(t, err, "Failed to fetch file list")

	for _, fileName := range specialNames {
		found := false
		for _, f := range allFiles {
			if f.Name == fileName {
				found = true
				break
			}
		}
		assert.True(t, found, "File %s should be in the file list", fileName)
	}

	// Clean up special files
	for _, fileName := range specialNames {
		err := spacePrimitives.DeleteFile(fileName)
		assert.NoError(t, err, "Failed to delete file %s", fileName)
	}
}

func testErrorHandling(t *testing.T, spacePrimitives SpacePrimitives) {
	// Test error cases
	_, _, err := spacePrimitives.ReadFile("nonexistent.txt")
	assert.Equal(t, ErrNotFound, err, "Expected ErrNotFound for non-existent file")

	err = spacePrimitives.DeleteFile("nonexistent.txt")
	assert.Equal(t, ErrNotFound, err, "Expected ErrNotFound when deleting non-existent file")

	_, err = spacePrimitives.GetFileMeta("nonexistent.txt")
	assert.Equal(t, ErrNotFound, err, "Expected ErrNotFound for non-existent file meta")
}

func testLargeFiles(t *testing.T, spacePrimitives SpacePrimitives) {
	// Test large file content
	largeContent := make([]byte, 5*1024*1024) // 5MB
	for i := 0; i < len(largeContent); i++ {
		largeContent[i] = byte(i % 256)
	}

	_, err := spacePrimitives.WriteFile("large.bin", largeContent, nil)
	assert.NoError(t, err, "Failed to write large file")

	largeData, largeMeta, err := spacePrimitives.ReadFile("large.bin")
	assert.NoError(t, err, "Failed to read large file")

	assert.Len(t, largeData, len(largeContent), "Large file size mismatch")
	assert.Equal(t, int64(len(largeContent)), largeMeta.Size, "Large file meta size mismatch")

	// Verify content integrity (check first 1000 bytes)
	checkSize := 1000
	if len(largeContent) < checkSize {
		checkSize = len(largeContent)
	}

	assert.True(t, bytes.Equal(largeData[:checkSize], largeContent[:checkSize]), "Large file content mismatch in first %d bytes", checkSize)

	err = spacePrimitives.DeleteFile("large.bin")
	assert.NoError(t, err, "Failed to delete large file")
}

func testMetadataPreservation(t *testing.T, spacePrimitives SpacePrimitives) {
	// Test metadata preservation
	testContent := []byte("Hello meta!")
	customMeta := &FileMeta{
		Name:         "meta-test.txt",
		Perm:         "rw",
		Created:      1000000,
		ContentType:  "text/plain",
		LastModified: 2000000,
		Size:         int64(len(testContent)),
	}

	_, err := spacePrimitives.WriteFile("meta-test.txt", testContent, customMeta)
	assert.NoError(t, err, "Failed to write file with custom meta")

	_, metaFile, err := spacePrimitives.ReadFile("meta-test.txt")
	assert.NoError(t, err, "Failed to read file with meta")

	// Check that some metadata is preserved (implementations may handle timestamps differently)
	assert.Greater(t, metaFile.LastModified, int64(0), "LastModified timestamp should be set")
	assert.Equal(t, "meta-test.txt", metaFile.Name, "Expected name 'meta-test.txt'")
	assert.Equal(t, int64(len(testContent)), metaFile.Size, "Expected size to match content length")

	err = spacePrimitives.DeleteFile("meta-test.txt")
	assert.NoError(t, err, "Failed to delete meta test file")
}


================================================
FILE: server/spaces.go
================================================
package server

import (
	"context"
	"net/http"
)

const spaceConfigKey = "spaceConfig"
const serverConfigKey = "serverConfig"

func spaceMiddleware(config *ServerConfig) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			spaceConfig, err := config.SpaceConfigResolver(r)
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			ctx := context.WithValue(r.Context(), spaceConfigKey, spaceConfig)
			ctx = context.WithValue(ctx, serverConfigKey, config)
			augmentedRequest := r.WithContext(ctx)
			next.ServeHTTP(w, augmentedRequest)
		})
	}
}

func spaceConfigFromContext(ctx context.Context) *SpaceConfig {
	return ctx.Value(spaceConfigKey).(*SpaceConfig)
}

func serverConfigFromContext(ctx context.Context) *ServerConfig {
	return ctx.Value(serverConfigKey).(*ServerConfig)
}


================================================
FILE: server/ssr.go
================================================
package server

import (
	"fmt"
	"html/template"
	"log"
	"net/http"
	pathLib "path"
	"regexp"
	"strings"

	"github.com/gomarkdown/markdown"
	"github.com/gomarkdown/markdown/html"
	"github.com/gomarkdown/markdown/parser"
)

// This implements very crude server-side rendered markdown using a Go markdown library
// Note: this not even nearly supports all SilverBullet markdown features. It's best effort.
// The ONLY use case of this is search engine indexing
// It is only activated when the space is both read-only and has no authentication, so basically the silverbullet.md scenario.

// Very crude markdown link replacement
func convertWikiLinksToMarkdown(input string) string {
	// Regular expression to match [[text]] patterns
	re := regexp.MustCompile(`\[\[([^\]]+)\]\]`)

	// Replace all matches with [text](text) format
	result := re.ReplaceAllStringFunc(input, func(match string) string {
		// Extract the text between [[ and ]]
		text := strings.TrimPrefix(match, "[[")
		text = strings.TrimSuffix(text, "]]")

		return fmt.Sprintf("[%s](%s)", text, text)
	})

	return result
}

func ServerSideRender(config *ServerConfig, spaceConfig *SpaceConfig, pageName string, w http.ResponseWriter, r *http.Request) {
	// Let's fetch the index.html text
	indexHtmlBuf, _, _ := config.ClientBundle.ReadFile(indexHtmlPath)

	if pageName == "" {
		pageName = spaceConfig.IndexPage
	}

	tpl := template.New("index_html")
	tpl.Funcs(template.FuncMap{
		"markdown": func(text string) template.HTML {
			if text == "" {
				// Let's not even bother doing anything
				return ""
			}

			// Setup markdown renderer
			var markdownParser = parser.NewWithExtensions(parser.CommonExtensions)
			var markdownRenderer = html.NewRenderer(html.RendererOptions{})
			text = convertWikiLinksToMarkdown(text)
			doc := markdownParser.Parse([]byte(text))
			return template.HTML(markdown.Render(doc, markdownRenderer))
		},
	})
	tpl = template.Must(tpl.Parse(string(indexHtmlBuf)))

	templateData := struct {
		HostPrefix         string
		Title              string
		Description        string
		Content            string
		AdditionalHeadHTML template.HTML
	}{
		Title:              "SilverBullet",
		HostPrefix:         config.HostURLPrefix,
		AdditionalHeadHTML: spaceConfig.AdditionalHeadHTML,
	}

	if spaceConfig.ReadOnlyMode && spaceConfig.Auth == nil {
		// Read only and without out, let's do some server side rendering
		templateData.Title = pathLib.Base(pageName)

		// Read page content
		pageData, _, err := spaceConfig.SpacePrimitives.ReadFile(fmt.Sprintf("%s.md", pageName))
		if err != nil {
			log.Printf("Could not read content for page %s", pageName)
		} else {
			templateData.Content = string(pageData)
		}
	}

	w.Header().Set("Content-type", "text/html")
	w.WriteHeader(http.StatusOK)
	if err := tpl.Execute(w, templateData); err != nil {
		log.Printf("SSR error for %s: %v", pageName, err)
		w.Write([]byte("Server error"))
	}
}


================================================
FILE: server/types.go
================================================
package server

import (
	"errors"
	"html/template"
	"net/http"
	"sync"
)

type ServerConfig struct {
	SpaceConfigResolver ConfigResolver

	BindHost          string
	Port              int
	MetricsPort       int
	EnableHTTPLogging bool
	// TODO: Ideally this is configurable per space, but kinda hard
	HostURLPrefix string

	ClientBundle SpacePrimitives
}

type UserPasswordAuthorizer func(username, password string) bool

type SpaceConfig struct {
	Hostname string
	Auth     *AuthOptions

	Authorize UserPasswordAuthorizer

	SpacePrimitives SpacePrimitives

	SpaceFolderPath string
	IndexPage       string
	GitIgnore       string
	ReadOnlyMode    bool
	LogPush         bool

	// Used for the PWA manifest and login page
	SpaceName        string
	SpaceDescription string

	// Used to inject additional HTML into the <head> of index.html
	AdditionalHeadHTML template.HTML

	// Shell configuration
	ShellBackend ShellBackend

	// Auth temporary objects
	JwtIssuer    *Authenticator
	LockoutTimer *LockoutTimer
	authMutex    sync.Mutex
}

type ConfigResolver func(r *http.Request) (*SpaceConfig, error)

// FileMeta represents metadata for a file in the space
type FileMeta struct {
	Name         string `json:"name"`
	Created      int64  `json:"created"`
	LastModified int64  `json:"lastModified"`
	ContentType  string `json:"contentType"`
	Size         int64  `json:"size"`
	Perm         string `json:"perm"` // "ro" or "rw"
}

// SpacePrimitives defines the interface for storage backends
type SpacePrimitives interface {
	// FetchFileList retrieves a list of all files in the space
	FetchFileList() ([]FileMeta, error)

	// GetFileMeta retrieves metadata for a specific file
	GetFileMeta(path string) (FileMeta, error)

	// ReadFile reads a file and returns its data and metadata
	ReadFile(path string) ([]byte, FileMeta, error)

	// WriteFile writes data to a file with optional metadata
	// Returns the actual metadata of the written file
	WriteFile(path string, data []byte, meta *FileMeta) (FileMeta, error)

	// DeleteFile removes a file from the space
	DeleteFile(path string) error
}

// Shell request/response types
type ShellRequest struct {
	Cmd   string   `json:"cmd"`
	Args  []string `json:"args"`
	Stdin *string  `json:"stdin,omitempty"`
}

type ShellResponse struct {
	Stdout string `json:"stdout"`
	Stderr string `json:"stderr"`
	Code   int    `json:"code"`
}

// Shell backend interface
type ShellBackend interface {
	Handle(request ShellRequest) (ShellResponse, error)
}

// Auth options for user authentication
type AuthOptions struct {
	AuthToken       string `json:"authToken,omitempty"`
	User            string `json:"user"`
	Pass            string `json:"pass"`
	LockoutTime     int    `json:"lockoutTime"`  // in seconds
	LockoutLimit    int    `json:"lockoutLimit"`
	RememberMeHours int    `json:"rememberMeHours"` // duration for "remember me" sessions
}

// Common errors
var (
	ErrNotFound        = errors.New("Not found")
	ErrPathOutsideRoot = errors.New("Path not in space")
	ErrCouldNotWrite   = errors.New("Could not write file")
	ErrCouldNotGetMeta = errors.New("Could not get file metadata")
)


================================================
FILE: server/util.go
================================================
package server

import (
	"mime"
	"path/filepath"
	"strings"
)

// Custom MIME type mappings for file extensions not in the system database
var mimeTypeExtMapping = map[string]string{
	".md":   "text/markdown",
	".heic": "image/heic",
	".heif": "image/heic",
}

// lookupContentTypeFromPath determines MIME type based on file extension
func LookupContentTypeFromPath(path string) string {
	ext := strings.ToLower(filepath.Ext(path))

	// Check our custom MIME type mappings first
	if mimeType, exists := mimeTypeExtMapping[ext]; exists {
		return mimeType
	}

	// Fall back to system MIME type detection
	contentType := mime.TypeByExtension(ext)
	if contentType == "" {
		return "application/octet-stream"
	}
	return contentType
}


================================================
FILE: server/util_test.go
================================================
package server

import (
	"testing"
)

func TestLookupContentTypeFromPath(t *testing.T) {
	tests := []struct {
		name     string
		path     string
		expected string
	}{
		// Specific file types we handle
		{
			name:     "markdown file",
			path:     "document.md",
			expected: "text/markdown",
		},
		{
			name:     "HEIF file",
			path:     "photo.heif",
			expected: "image/heic",
		},
		// Mixed case and path and specific file type
		{
			name:     "mixed case HEIC",
			path:     "/path/to/image.HeIc",
			expected: "image/heic",
		},

		// Special characters
		{
			name:     "unicode filename",
			path:     "фото.heic",
			expected: "image/heic",
		},
		{
			name:     "filename with hyphen underscore",
			path:     "my-file_v2.md",
			expected: "text/markdown",
		},

		// Well-known extensions (system handled)
		{
			name:     "JPEG file",
			path:     "/path/to/image.jpg",
			expected: "image/jpeg",
		},
		{
			name:     "PNG file",
			path:     "image.png",
			expected: "image/png",
		},
		{
			name:     "PDF file",
			path:     "document.pdf",
			expected: "application/pdf",
		},

		// Unknown extension
		{
			name:     "unknown extension",
			path:     "file.unknownext",
			expected: "application/octet-stream",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := LookupContentTypeFromPath(tt.path)
			if result != tt.expected {
				t.Errorf("LookupContentTypeFromPath(%q) = %q, expected %q", tt.path, result, tt.expected)
			}
		})
	}
}


================================================
FILE: server/version.go
================================================
package server

import (
	"fmt"
	"regexp"
	"strings"
)

func ParseVersionFromTypeScript(code string) (string, error) {
	// Parse the version using regex
	re := regexp.MustCompile(`"([^"]+)"`)
	matches := re.FindStringSubmatch(code)

	if len(matches) < 2 {
		return "", fmt.Errorf("version not found in file")
	}

	version := strings.TrimSpace(matches[1])
	if version == "" {
		return "", fmt.Errorf("empty version found")
	}

	return version, nil
}


================================================
FILE: server/version_test.go
================================================
package server

import (
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestVersionParsing(t *testing.T) {
	version, err := ParseVersionFromTypeScript(`export const publicVersion = "2.0.0"`)
	assert.NoError(t, err)
	assert.Equal(t, "2.0.0", version)
}


================================================
FILE: website/API.md
================================================
This describes the APIs available in [[Space Lua]]:
${widgets.subPages("API")}


================================================
FILE: website/API/asset.md
================================================
#api/syscall

The Asset API provides functions for reading and managing assets embedded in plugs.

### asset.readAsset(plugName, name, encoding?)
Reads an asset embedded in a plug.

Parameters:
- `plugName`: Name of the plug to read asset from
- `name`: Name of the asset to read
- `encoding`: Optional encoding type, either "utf8" (default) or "dataurl"

Returns the content of the asset in the requested encoding.

Example:
```lua
-- Read a text file as UTF-8
local text = asset.readAsset("myplug", "data.txt")
print(text)

-- Read an image as data URL
local imageData = asset.readAsset("myplug", "image.png", "dataurl")
print(imageData)
```

### asset.listFiles(plugName)
Lists all files in a plug.

Parameters:
- `plugName`: Name of the plug to list files from

Returns an array of FileMeta objects containing information about each file.

Example:
```lua
local files = asset.listFiles("myplug")
for _, file in ipairs(files) do
    print(file.name, file.size)
end
```

### asset.getFileMeta(plugName, name)
Gets metadata about a specific file in a plug.

Parameters:
- `plugName`: Name of the plug
- `name`: Name of the file to get metadata for

Returns a FileMeta object containing information about the file.

Example:
```lua
local meta = asset.getFileMeta("myplug", "data.txt")
print("File size:", meta.size)
print("Last modified:", meta.lastModified)
``` 

================================================
FILE: website/API/clientStore.md
================================================
#api/syscall

The Client Store API provides a simple key-value store for client-specific states and preferences.

## clientStore.set(key, value)
Sets a value in the client store.

Example:
```lua
clientStore.set("theme", "dark")
```

## clientStore.get(key)
Gets a value from the client store.

Example:
```lua
local theme = clientStore.get("theme")
print("Current theme: " .. theme)
```

## clientStore.del(key)
Deletes a value from the client store.

Example:
```lua
clientStore.del("theme")
``` 

================================================
FILE: website/API/codeWidget.md
================================================
The Code Widget API provides functions for managing code widgets in the editor.

### codeWidget.refreshAll()
Refreshes all code widgets on the current page that support refreshing.

Example:
```lua
codeWidget.refreshAll()  -- Refresh all code widgets on the page
``` 

================================================
FILE: website/API/command.md
================================================
#api/space-lua

See [[^Library/Std/APIs/Command]]

================================================
FILE: website/API/config.md
================================================
#api/syscall

The Config API provides functions for managing configuration values.

### config.get(path, defaultValue)
Gets a config value by path, with support for dot notation.

Parameters:
- `path`: The path to get the value from
- `defaultValue`: The default value to return if the path doesn't exist

Example:
```lua
local theme = config.get("theme", "light")
print("Current theme: " .. theme)
```

### config.set(path, value)
Sets a config value by path, with support for dot notation.

Parameters:
- `path`: The path to set the value at
- `value`: The value to set

Example:
```lua
config.set("theme", "dark")
```

### config.set(values)
Sets multiple config values at once.

Parameters:
- `values`: An object containing key-value pairs to set

Example:
```lua
config.set({
    theme = "dark",
    fontSize = 14
})
```

### config.has(path)
Checks if a config path exists.

Parameters:
- `path`: The path to check

Example:
```lua
if config.has("theme") then
    print("Theme is configured")
end
```

### config.define(key, schema)
Defines a JSON schema for a configuration key. The schema will be used to validate values when setting this key.

Parameters:
- `key`: The configuration key to define a schema for
- `schema`: The JSON schema to validate against

Example:
```lua
config.define("theme", {
    type = "string",
    enum = {"light", "dark"}
})
```

================================================
FILE: website/API/datastore.md
================================================
#api/syscall

The Datastore API provides functions for interacting with a key-value store that has query capabilities.

* **Keys** are represented as a list (Lua table) of strings.
* **Values** can be any persistable value.

# Key-Value Operations

## datastore.set(key, value)
Sets a value in the key-value store.

Example:
```lua
datastore.set({"user","123"}, {name = "John", age = 30})
```

## datastore.get(key)
Gets a value from the key-value store.

Example:
```lua
local user = datastore.get({"user","123"})
print(user.name)  -- prints "John"
```

## datastore.del(key)
Deletes a value from the key-value store.

Example:
```lua
datastore.del({"user", "123"})
```

# Batch Operations

## datastore.batchSet(kvs)
Sets multiple key-value pairs in a single operation.

Example:
```lua
local kvs = {
    {key = {"user", "1"}, value = {name = "Alice"}},
    {key = {"user", "2"}, value = {name = "Bob"}}
}
datastore.batchSet(kvs)
```

## datastore.batchGet(keys)
Gets multiple values in a single operation.

Example:
```lua
local keys = {{"user", "1"}, {"user", "2"}}
local values = datastore.batchGet(keys)
for _, value in ipairs(values) do
    print(value.name)
end
```

## datastore.batchDel(keys)
Deletes multiple values in a single operation.

Example:
```lua
local keys = {{"user", "1"}, {"user", "2"}}
datastore.batchDel(keys)


================================================
FILE: website/API/dom.md
================================================
An API to easily build DOM objects through the magic of Lua meta tables.

# Usage

```lua
-- any HTML tag can be used here
dom.span {
  -- tag attributes can be set like this:
  class = "my-class",
  id = "my-id",
  -- Plain text body elements can be added like this
  "Span content",
  -- And elements can be nested
  dom.strong { "I am strong" },
  -- Widgets can also be embedded
  widget.html "<b>Bold</b>",
  widget.html(dom.marquee { "nested widget" })
}
```

Example:
${widget.html(dom.marquee {
  "I'm in a ",
  dom.span {
    style="color:red;",
    "marquee"
  }
})}

# API
## dom.* {attribute1=value, attribute2=value, childElement1, childElement2}
Renders a HTML DOM.

* `attribute=value` key/value mappings are translated to HTML DOM attribtutes.
* Plain text elements such as `"Hello **world**"` are parsed and rendered as markdown translated to HTML.
* DOM elements (for instance those resulting from additional `dom.*` calls) are injected in place.
* [[API/widget|widgets]] are rendered in place.

For instance:

```lua
dom.span {
  class = "class-attribute",
  dom.span {
    "A first nested span"
  },
  dom.span {
    "A second nested span"
  },
}
```

Would be roughly equivalent to the following HTML:

```html
<span class="class-attribute">
  <span>A first nested span</span>
  <span>A second nested span</span>
</span>
```

This API is implemented using Lua metatables, its implementation lives here: [[^Library/Std/APIs/DOM]]

================================================
FILE: website/API/editor.md
================================================
#api/syscall

The Editor API provides functions for interacting with the editor interface.

### editor.getCurrentPage()
Returns the [[Names|name]] of the page (or document) currently open in the editor.

Example: ${editor.getCurrentPage()}

### editor.getCurrentPageMeta()
Returns the meta data of the page (or document) currently open in the editor.

Example:
${editor.getCurrentPageMeta()}

### editor.getCurrentPath()
Returns the [[Paths|path]] of the page or document currently open in the editor.

Example:
${editor.getCurrentPath()}

### editor.getCurrentEditor()
Returns the name of the currently open editor.

Example:
```lua
local editorName = editor.getCurrentEditor()
print(editorName)
```

### editor.getText()
Returns the full text of the currently open page.

Example:
```lua
local text = editor.getText()
print("Document length: " .. #text)
```

### editor.getCurrentLine()
Returns the current line range and text.

Example:
```lua
local line = editor.getCurrentLine()
print("from " .. line.from .. " to " .. line.to .. " text " .. line.text .. " text with cursor " .. line.textWithCursor)
```

### editor.setText(text, isolateHistory)
Updates the editor text while preserving cursor location.

Example:
```lua
local text = editor.getText()
editor.setText(text:upper(), false)  -- Convert to uppercase
```

### editor.insertAtPos(text, pos)
Insert text at the specified position.

Example:
```lua
editor.insertAtPos("Hello!", 0)  -- Insert at beginning
```

### editor.replaceRange(from, to, text)
Replace text in the specified range.

Example:
```lua
editor.replaceRange(0, 5, "New text")
```

### editor.insertAtCursor(text, scrollIntoView?)
Insert text at the current cursor position.

Example:
```lua
editor.insertAtCursor("Inserted at cursor")
```

### editor.getCursor()
Returns the cursor position as character offset.

Example:
```lua
local pos = editor.getCursor()
print("Cursor at position: " .. pos)
```

### editor.getSelection()
Returns the current selection range.

Example:
```lua
local sel = editor.getSelection()
print("Selection from " .. sel.from .. " to " .. sel.to)
```

### editor.setSelection(from, to)
Sets the current selection range.

Example:
```lua
editor.setSelection(0, 10)  -- Select first 10 characters
```

### editor.moveCursor(pos, center)
Move the cursor to a specific position.

Example:
```lua
editor.moveCursor(0, true)  -- Move to start and center
```

### editor.moveCursorToLine(line, column, center)
Move the cursor to a specific line and column.

Example:
```lua
editor.moveCursorToLine(1, 1, true)  -- Move to start of first line
```

### editor.invokeCommand(name, args?)
Invokes a client command by name.

Example:
```lua
editor.invokeCommand("Stats: Show")
```

### editor.save()
Force saves the current page.

Example:
```lua
editor.save()
```

### editor.navigate(ref, replaceState?, newWindow?)
Navigates to the specified page reference.

Parameters:
- `ref`: The (string) reference to navigate to, see [[../Link#Link syntax (String refs)|string refs]]
- `replaceState`: Whether to replace the current history state
- `newWindow`: Whether to open in a new window

Example:
```lua
editor.navigate("CHANGELOG@123")
```

### editor.openPageNavigator(mode)
Opens the page navigator.

Example:
```lua
editor.openPageNavigator("page")
```

### editor.openCommandPalette()
Opens the command palette.

Example:
```lua
editor.openCommandPalette()
```

### editor.reloadPage()
Force reloads the current page.

Example:
```lua
editor.reloadPage()
```

### editor.reloadUI()
Force reloads the browser UI.

Example:
```lua
editor.reloadUI()
```

### editor.rebuildEditorState()
Rebuilds the editor state to ensure the dispatch updates the state.

Example:
```lua
editor.rebuildEditorState()
```

### editor.reloadConfigAndCommands()
Reloads the config and commands, also in the server.

Example:
```lua
editor.reloadConfigAndCommands()
```

### editor.openUrl(url, existingWindow?)
Opens the specified URL in the browser.

Example:
```lua
editor.openUrl("https://example.com")
```

### editor.newWindow()
Opens a new window.

Example:
```lua
editor.newWindow()
```

### editor.goHistory(delta)
Moves in the browser history.

Example:
```lua
editor.goHistory(-1)  -- Go back
```

### editor.showPanel(id, mode, html, script)
Shows a panel in the editor.

Example:
```lua
editor.showPanel("rhs", 1, "<h1>Hello</h1>")
```

### editor.hidePanel(id)
Hides a panel in the editor.

Example:
```lua
editor.hidePanel("rhs")
```

### editor.flashNotification(message, type)
Shows a flash notification.

Example:
```lua
editor.flashNotification("Operation completed", "info")
```

### editor.downloadFile(filename, dataUrl)
Triggers a file download in the browser.

Example:
```lua
editor.downloadFile("test.txt", "data:text/plain;base64,SGVsbG8=")
```

### editor.uploadFile(accept, capture)
Opens a file upload dialog.

Example:
```lua
local file = editor.uploadFile(".txt", nil)
print("Uploaded: " .. file.name)
```

### editor.copyToClipboard(data)
Copies data to the clipboard.

> **note** Note
> Requires HTTPS, see [[Install/Network and Internet]]

Example:
```lua
editor.copyToClipboard("Copied text")
```

### editor.filterBox(label, options, helpText?, placeHolder?)
Shows a filter box UI.

Example:
```lua
local result = editor.filterBox("Select:", {
    { name="Option 1", value="1" },
    { name="Option 2", value="2", description="More details about 2" }
})
```

### editor.toggleFold()
Toggles code folding at the current position.

Example:
```lua
editor.toggleFold()
```

### editor.foldAll()
Folds all foldable regions.

Example:
```lua
editor.foldAll()
```

### editor.unfoldAll()
Unfolds all folded regions.

Example:
```lua
editor.unfoldAll()
```

### editor.undo()
Undoes the last change.

Example:
```lua
editor.undo()
```

### editor.redo()
Redoes the last undone change.

Example:
```lua
editor.redo()
```

### editor.openSearchPanel()
Opens the editor's search panel.

Example:
```lua
editor.openSearchPanel()
```

### editor.deleteLine()
Deletes the current line.

Example:
```lua
editor.deleteLine()
```

### editor.toggleComment()
Comments or uncomments the current line.

Example:
```lua
editor.toggleComment()
```

### editor.moveLineUp()
Moves the current line up.

Example:
```lua
editor.moveLineUp()
```

### editor.moveLineDown()
Moves the current line down.

Example:
```lua
editor.moveLineDown()
```

### editor.vimEx(exCommand)
Executes a Vim ex command.

Example:
```lua
editor.vimEx(":w")
```

### editor.sendMessage(type, data?)
Sends a message to the editor.

Example:
```lua
editor.sendMessage("custom-event", { data: "value" })
```

### editor.prompt(message, defaultValue?)
Shows a prompt dialog.

Example:
```lua
local result = editor.prompt("Enter your name:", "John")
```

### editor.confirm(message)
Shows a confirmation dialog.

Example:
```lua
local confirmed = editor.confirm("Are you sure?")
```

### editor.alert(message)
Shows an alert dialog.

Example:
```lua
editor.alert("Operation completed")
```

### editor.getUiOption(key)
Gets a UI option value.

Example:
```lua
local theme = editor.getUiOption("theme")
```

### editor.setUiOption(key, value)
Sets a UI option value.

Example:
```lua
editor.setUiOption("theme", "dark")
```


================================================
FILE: website/API/encoding.md
================================================
#api/space-lua

The `encoding` API implements a few useful functions to handle various types of encoding.

### encoding.base64Encode(data)
Encodes data (either a string or byte buffer) as a base64 encoded string.

### encoding.base64Decode(s)
Decodes a base64 encoded string into a byte buffer.

### encoding.utf8Encode(s)
Encodes a (UTF-8) string into a byte buffer.

### encoding.utf8Decode(data)
Decodes a byte buffer into a (UTF-8) string.

================================================
FILE: website/API/event.md
================================================
#api/syscall

The Event API provides functions for working with SilverBullet's event bus system, allowing communication between different parts of the application.

# API

### event.listen(listenerDef)
Register an event listener.

```lua
event.listen {
  name = "my-event",
  run = function(e)
    print("Data", e.data)
  end
}
```

### event.dispatch(eventName, data, timeout)
Triggers an event on the SilverBullet event bus. Event handlers can return values, which are accumulated and returned to the caller.

Example:
```lua
-- Simple event dispatch
event.dispatch("custom.event", {message = "Hello"})

-- Event dispatch with timeout and response handling
local responses = event.dispatchEvent("data.request", {id = 123}, 5000)
for _, response in ipairs(responses) do
    print(response)
end
```

### event.listEvents()
Lists all events currently registered (listened to) on the SilverBullet event bus.

Example:
```lua
local events = event.listEvents()
for _, eventName in ipairs(events) do
    print("Registered event: " .. eventName)
end
```

================================================
FILE: website/API/global.md
================================================
#api/lua

These are Lua functions defined in the global namespace:

# Standard Lua
## print(...)
Prints to your log (browser or server log).

Example:

```lua
print("Hello, world!")
```

## assert(expr, message?)
Asserts `expr` to be true otherwise raises an [[#error(message)]]

Example:

```lua
assert(1 == 2, "1 is not equal to 2")
```

## ipairs
Returns an iterator for array-like tables that iterates over numeric indices in order.

Example:
```lua
local fruits = {"apple", "banana", "orange"}
for i, fruit in ipairs(fruits) do
    print(i, fruit)
end
-- Output:
-- 1 apple
-- 2 banana
-- 3 orange
```

## pairs
Returns an iterator for tables that traverses all keys and values.

Example:
```lua
local person = {name = "John", age = 30, city = "New York"}
for key, value in pairs(person) do
    print(key, value)
end
-- Output (order not guaranteed):
-- name John
-- age 30
-- city New York
```

## unpack
Unpacks a table into individual values.

Example:
```lua
local numbers = {10, 20, 30}
print(unpack(numbers))  -- prints: 10 20 30

local function sum(a, b, c)
    return a + b + c
end
print(sum(unpack(numbers)))  -- prints: 60
```

## type
Returns the type of a value as a string.

Example:
```lua
print(type("hello"))    -- string
print(type(42))         -- number
print(type({}))         -- table
print(type(print))      -- function
print(type(nil))        -- nil
print(type(true))       -- boolean
```

## tostring
Converts a value to a string representation.

Example:
```lua
print(tostring(42))           -- "42"
print(tostring(true))         -- "true"
print(tostring({1, 2, 3}))    -- "{1, 2, 3}"
```

## tonumber
Converts a string to a number, returns nil if conversion fails.

Example:
```lua
print(tonumber("42"))      -- 42
print(tonumber("3.14"))    -- 3.14
print(tonumber("abc"))     -- nil
```

## error(message)
Throw an error.

Example: 
```lua
error("FAIL")
```

## pcall
Protected call - executes a function in protected mode, catching errors.

Example:
```lua
local status, result = pcall(function()
    return 10/0  -- will cause an error
end)
print(status)  -- false
print(result)  -- "attempt to divide by zero"

status, result = pcall(function()
    return 10/2  -- will succeed
end)
print(status)  -- true
print(result)  -- 5
```

## xpcall
Like pcall, but allows you to specify an error handler function.

Example:
```lua
local function errorHandler(err)
    return "Error occurred: " .. tostring(err)
end

local status, result = xpcall(function()
    error("something went wrong")
end, errorHandler)
print(status)  -- false
print(result)  -- "Error occurred: something went wrong"
```

## setmetatable
Sets the metatable for a table.

Example:
```lua
local t1 = {value = 10}
local t2 = {value = 20}
local mt = {
    __add = function(a, b)
        return a.value + b.value
    end
}
setmetatable(t1, mt)
setmetatable(t2, mt)

-- Now we can add the tables together using the + operator
print(t1 + t2)  -- prints: 30
```

## getmetatable
Gets the metatable of a table.

Example:
```lua
local t = {}
local mt = {}
setmetatable(t, mt)
print(getmetatable(t) == mt)  -- true
```

## rawset
Sets a table index without invoking metamethods.

Example:
```lua
local t = {}
local mt = {
    __newindex = function(t, k, v)
        print("Blocked setting:", k, v)
    end
}
setmetatable(t, mt)

t.foo = "bar"  -- prints: "Blocked setting: foo bar"
rawset(t, "foo", "bar")  -- bypasses the metamethod
print(t.foo)  -- prints: "bar"
```

## dofile(path)
Loads a Lua file from a path in your space, e.g. if you uploaded a `test.lua` file, you can load it with `dofile("test.lua")`.

# Non-standard Extensions
## each
Returns an iterator for array-like tables that iterates over values only (without indices).

Example:
```lua
local fruits = {"apple", "banana", "orange"}
for fruit in each(fruits) do
    print(fruit)
end
-- Output:
-- apple
-- banana
-- orange
```

## some
Returns nil if the value is empty, otherwise returns the value unchanged. Empty tables, strings containing only whitespace, `inf` and `nan` numeric value are considered empty.

Example:
```lua
print(some("hello"))    -- hello
print(some(""))         -- nil
print(some("  "))       -- nil
print(some({}))         -- nil
print(some(0))          -- 0
print(some(1/0))        -- nil

print(some({}) or "empty") -- empty
```


================================================
FILE: website/API/http.md
================================================
#api/space-lua

HTTP APIs

> **warning** Warning
> Deprecated: use [[API/net]] instead.

### http.request(url, options?)
Deprecated, use [[API/net#net.proxyFetch(url, options?)]] instead.


================================================
FILE: website/API/index.md
================================================
#api/syscall

The `index` API provides functions for interacting with SilverBullet's [[Object|Objects]], allowing you to store and query data.

## Object Operations
## index.tag(name)
Returns a given [[Object#Tags]] as a query collection, to be queried using [[Space Lua/Lua Integrated Query]].

Example:
${query[[from index.tag("page") limit 1]]}

## index.markdown(text, pageMeta?)
Ad-hoc indexes `text` (represented as a markdown string) in memory, and returns all objects found there for further query. When no `pageMeta` is supplied dummy (empty) values will be used.

Example:
${query[[
  from index.markdown("* Item 1\n* [ ] Task 1")
  where _.tag == "item"
]]}

## index.indexObjects(page, objects)
Indexes an array of objects for a specific page and stores it in the data store.

Example:
```lua
local objects = {
    {tag = "mytask", ref="task1", content = "Buy groceries"},
    {tag = "mytask", ref="task2", content = "Write docs"}
}
index.indexObjects("my page", objects)
```

## index.queryLuaObjects(tag, query, scopedVariables?)
Queries objects using a Lua-based collection query.

Example:
```lua
local tasks = index.queryLuaObjects("mytask", {limit=3})
```

## index.getObjectByRef(page, tag, ref)
Retrieves a specific object by its reference.

Example:
```lua
local task = index.getObjectByRef("my page", "mytask", "task1")
if task then
    print("Found task: " .. task.content)
end
```

## index.extractFrontmatter(text, extractOptions)
Extracts frontmatter from a markdown document (whose text is provided as argument), possibly cleaning it up. It also parses top-level tags consistent with SilverBullet's tag indexing system.

It returns a table with two keys:
- `frontmatter`: A table containing the parsed frontmatter.
- `text`: The text of the document, with any changes applied requested with the `extractOptions`.

The `extractOptions` is an optional table that can contain the following keys (which will affect the returned `text`):
- `removeKeys`: An array of keys to remove from the frontmatter.
- `removeTags`: A boolean or array of tags to remove from the frontmatter.
- `removeFrontMatterSection`: A boolean to remove the frontmatter section from the document.

Example applied to this page:
${(index.extractFrontmatter(editor.getText())).frontmatter}


================================================
FILE: website/API/js.md
================================================
#api/space-lua

API docs for Space Lua's `js` module, which provides JavaScript interoperability.

## js.import(url)
Imports a JavaScript module from a URL. Returns the imported module.

Example:
```lua
-- Import lodash library
local lodashLib = js.import("https://esm.sh/lodash@4.17.21")
local result = lodashLib.chunk({1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 3)

-- Import moment.js for date handling
local momentLib = js.import("https://esm.sh/moment@2.30.1")
local dateObj = momentLib("1995-12-25")
print(dateObj.format("DD-MM-YYYY"))  -- prints: 25-12-1995
```

## js.new(constructor, ...)
Creates a new instance of a JavaScript class. Takes a constructor function and its arguments.

Example:
```lua
local DateClass = js.import("https://esm.sh/date-fns")
local dateObj = js.new(DateClass, "2024-03-14")
```

## js.stringify(value)
Converts a Lua value to a JSON string representation.

Example:
```lua
local dataArray = {1, 2, 3}
print(js.stringify(dataArray))  -- prints: [1,2,3]

local nestedArray = lodashLib.chunk({1, 2, 3, 4, 5, 6}, 2)
print(js.stringify(nestedArray))  -- prints: [[1,2],[3,4],[5,6]]
```

## js.tolua(value)
Converts a JavaScript value to its Lua equivalent.

Example:
```lua
local jsArray = someJsFunction()
local luaTable = js.tolua(jsArray)
```

## js.tojs(value)
Converts a Lua value to its JavaScript equivalent.

Example:
```lua
local luaTable = {1, 2, 3}
local jsArray = js.tojs(luaTable)
```

## js.log(...)
Logs messages to the JavaScript console.

Example:
```lua
js.log("Debug message")
js.log("User data:", {name = "John", age = 30})
```

## js.eachIterable(iterable)
Creates an iterator for JavaScript async iterables.

Example:
```lua
local asyncIterator = js.eachIterable(someJsAsyncIterable)
for value in asyncIterator do
    print(value)
end


================================================
FILE: website/API/jsonschema.md
================================================
#api/space-lua

The JSON Schema API provides functions for validating JSON objects against JSON schemas.

## Validation Operations

### jsonschema.validateObject(schema, object)
Validates a JSON object against a JSON schema.

Example:
```lua
local schema = {
    type = "object",
    properties = {
        name = {type = "string"},
        age = {type = "number", minimum = 0}
    },
    required = {"name"}
}

local object = {name = "John", age = 30}
local error = jsonschema.validateObject(schema, object)
if error then
    print("Validation error: " .. error)
else
    print("Object is valid")
end
```

### jsonschema.validateSchema(schema)
Validates a JSON schema itself to ensure it's well-formed.

Example:
```lua
local schema = {
    type = "object",
    properties = {
        name = {type = "string"}
    }
}

local error = jsonschema.validateSchema(schema)
if error then
    print("Schema error: " .. error)
else
    print("Schema is valid")
end


================================================
FILE: website/API/language.md
================================================
#api/syscall

The Language API provides functions for parsing code in various programming languages and listing supported languages.

## Language Operations

### language.parseLanguage(language, code)
Parses a piece of code using any of the supported SilverBullet languages.

Example:
```lua
local code = [[
function hello() {
    console.log("Hello, world!");
}
]]

local tree = language.parseLanguage("javascript", [[
function hello() {
    console.log("Hello, world!");
}
]])
print("Parsed syntax tree:", tree)
```

### language.listLanguages()
Lists all supported languages in fenced code blocks.

Example:
${language.listLanguages()}


================================================
FILE: website/API/lua.md
================================================
#api/space-lua

The Lua API provides functions for parsing and evaluating Lua code.

### lua.parse(code)
Parses a string of Lua code into an abstract syntax tree (AST).

Parameters:
- `code`: The Lua code to parse

Returns a LuaBlock object representing the parsed code.

Example:
```lua
local ast = lua.parse("print('Hello')")
-- ast contains the parsed syntax tree
```

### lua.parseExpression(expression)
Parses a Lua expression into an abstract syntax tree (AST).

Parameters:
- `expression`: The Lua expression to parse

Returns a LuaExpression object representing the parsed expression.

Example:
```lua
local expr = lua.parseExpression("1 + 2 * 3")
-- expr contains the parsed expression tree
```

### lua.evalExpression(expression)
Evaluates a Lua expression and returns its result.

Parameters:
- `expression`: The Lua expression to evaluate

Returns the result of evaluating the expression.

Example:
```lua
local result = lua.evalExpression("1 + 2 * 3")
print(result)  -- prints: 7
``` 

================================================
FILE: website/API/markdown.md
================================================
#api/syscall

The Markdown API provides functions for parsing and rendering Markdown content.

## Markdown Operations
### markdown.parseMarkdown(text)
Parses a piece of markdown text into a ParseTree.

Example:
```lua
local text = [[
# Hello World

This is a **bold** statement.
]]

local tree = markdown.parseMarkdown(text)
print("Parsed markdown tree:", tree)
```

### markdown.renderParseTree(tree)
Renders a ParseTree back to markdown text.

Example:
```lua
local text = "# Title\n\nSome text"
local tree = markdown.parseMarkdown(text)
-- Modify tree if needed
local rendered = markdown.renderParseTree(tree)
print("Rendered markdown:", rendered)
```

### markdown.markdownToHtml(text)
Renders a piece of markdown text into HTML

Example:
```lua
local text = "# Title\n\nSome text"
local html = markdown.markdownToHtml(text)
print("Rendered html:", html)
```

### markdown.expandMarkdown(textOrTree, options?)
Expands custom markdown Lua directives and transclusions into plain markdown. Accepts either a markdown ParseTree or string.

Options (all default to `true`):
* `expandTransclusions`: Replace (markdown transclusions) with their content
* `expandLuaDirectives`: Replace Lua directives with their evaluated values
* `rewriteTasks`: Rewrite tasks to include references so that they can be updated

Example:
```lua
local text = "This is a some lua ${os.time()}"
print("Expanded markdown:", markdown.expandMarkdown(text))
```

### markdown.objectsToTable(data, options?)
Transforms a list of tables into a markdown table.

Supported options:
* `renderCell(val, key)` custom cell renderer

Example:
${markdown.objectsToTable({{name="Pete", age=20}, {name="Jane", age=32}}, {
  renderCell=function(v, k)
  if k == "age" and v > 20 then
    return "*" .. v .. "*"
  else
    return v
  end
end})}



================================================
FILE: website/API/math.md
================================================
#api/lua

API docs for Lua's `math` module.

## math.random(m?, n?)
Returns a random number.
- `random()` returns a random float in range [0,1)
- `random(m)` returns a random integer in range [1,m]
- `random(m,n)` returns a random integer in range [m,n]

Example:
```lua
print(math.random())      -- prints: 0.123456789 (random float between 0 and 1)
print(math.random(10))    -- prints: 5 (random integer between 1 and 10)
print(math.random(5, 10)) -- prints: 7 (random integer between 5 and 10)
```

## math.abs(x)
Returns the absolute value of `x`.

Example:
```lua
print(math.abs(-5))  -- prints: 5
print(math.abs(5))   -- prints: 5
```

## math.ceil(x)
Returns the smallest integer greater than or equal to `x`.

Example:
```lua
print(math.ceil(4.2))   -- prints: 5
print(math.ceil(-4.2))  -- prints: -4
```

## math.floor(x)
Returns the largest integer less than or equal to `x`.

Example:
```lua
print(math.floor(4.8))   -- prints: 4
print(math.floor(-4.8))  -- prints: -5
```

Note: `math.floor(x + 0.5)` rounds to closest integer, similar to `round(x)` function from other languages

## math.max(...)
Returns the maximum value among its arguments.

Example:
```lua
print(math.max(1, 2, 3, 4, 5))  -- prints: 5
print(math.max(-10, -5, -1))    -- prints: -1
```

## math.min(...)
Returns the minimum value among its arguments.

Example:
```lua
print(math.min(1, 2, 3, 4, 5))  -- prints: 1
print(math.min(-10, -5, -1))    -- prints: -10
```

## math.fmod(x, y)
Returns the remainder of the division of `x` by `y` that rounds the quotient towards zero.

Example:
```lua
print(math.fmod(10, 3))    -- prints: 1
print(math.fmod(-10, 3))   -- prints: -1
```

## math.modf(x)
Returns two numbers, the integral part of `x` and the fractional part of `x`.

Example:
```lua
local intPart, fracPart = math.modf(3.14)
print(intPart, fracPart)   -- prints: 3    0.14
```

## math.exp(x)
Returns the value e^x (where e is the base of natural logarithms).

Example:
```lua
print(math.exp(0))    -- prints: 1
print(math.exp(1))    -- prints: 2.7182818284590455
```

## math.log(x, base?)
Returns the logarithm of `x` in the given base. If base is not specified, returns the natural logarithm of `x`.

Example:
```lua
print(math.log(10))       -- prints: 2.302585092994046 (natural log)
print(math.log(100, 10))  -- prints: 2 (log base 10)
```

## math.pow(x, y)
Returns x^y.

Example:
```lua
print(math.pow(2, 3))   -- prints: 8
print(math.pow(10, 2))  -- prints: 100
```

## math.sqrt(x)
Returns the square root of `x`.

Example:
```lua
print(math.sqrt(16))   -- prints: 4
print(math.sqrt(2))    -- prints: 1.4142135623730951
```

## math.cos(x)
Returns the cosine of `x` (in radians).

Example:
```lua
print(math.cos(0))          -- prints: 1
print(math.cos(math.pi))    -- prints: -1
```

## math.sin(x)
Returns the sine of `x` (in radians).

Example:
```lua
print(math.sin(0))                  -- prints: 0
print(math.sin(math.pi / 2))        -- prints: 1
```

## math.tan(x)
Returns the tangent of `x` (in radians).

Example:
```lua
print(math.tan(0))             -- prints: 0
print(math.tan(math.pi / 4))   -- prints: 1
```

## math.acos(x)
Returns the arc cosine of `x` (in radians).

Example:
```lua
print(math.acos(1))    -- prints: 0
print(math.acos(0))    -- prints: 1.5707963267948966 (pi/2)
```

## math.asin(x)
Returns the arc sine of `x` (in radians).

Example:
```lua
print(math.asin(0))    -- prints: 0
print(math.asin(1))    -- prints: 1.5707963267948966 (pi/2)
```

## math.atan(y, x?)
Returns the arc tangent of `y/x` (in radians). If `x` is not provided, returns the arc tangent of `y` (in radians).

Example:
```lua
print(math.atan(1))        -- prints: 0.7853981633974483 (pi/4)
print(math.atan(1, 1))     -- prints: 0.7853981633974483 (pi/4)
```

## math.cosh(x)
Returns the hyperbolic cosine of `x`.

Example:
```lua
print(math.cosh(0))   -- prints: 1
```

## math.sinh(x)
Returns the hyperbolic sine of `x`.

Example:
```lua
print(math.sinh(0))   -- prints: 0
```

## math.tanh(x)
Returns the hyperbolic tangent of `x`.

Example:
```lua
print(math.tanh(0))   -- prints: 0
```

## math.deg(x)
Converts angle `x` from radians to degrees.

Example:
```lua
print(math.deg(math.pi))      -- prints: 180
print(math.deg(math.pi/2))    -- prints: 90
```

## math.rad(x)
Converts angle `x` from degrees to radians.

Example:
```lua
print(math.rad(180))    -- prints: 3.141592653589793
print(math.rad(90))     -- prints: 1.5707963267948966
```

## math.ult(m, n)
Returns a boolean, true if integer `m` is below integer `n` when they are compared as unsigned integers.

Example:
```lua
print(math.ult(2, 3))     -- prints: true
print(math.ult(-1, 1))    -- prints: false (as unsigned integers)
```

# Non-standard Extensions
## math.cosineSimilarity(vecA, vecB)
Returns the cosine similarity between two vectors.

Example:
```lua
local vec1 = {1, 2, 3}
local vec2 = {4, 5, 6}
print(math.cosineSimilarity(vec1, vec2))  -- prints: 0.9746318461970762
```


================================================
FILE: website/API/mq.md
================================================
#api/space-lua

The Message Queue API provides functions for implementing a simple message queue system.

## Example
```space-lua
mq.subscribe {
  queue = "testqueue",
  batchSize = 1,
  run = function(messages)
    for _, msg in ipairs(messages) do
      editor.flashNotification("Received message: " .. msg.body)
    end
  end
}
```

${widgets.button("Send message on queue", function()
  mq.send("testqueue", "Hello world")
end)}

## API
### mq.subscribe(spec)
Subscribe to a queue. Spec keys:

* `queue`: name of the queue to subscribe to
* `batchSize`: maximum number of messages to ingest per run
* `autoAck`: (defaults to `true`), whether to automatically acknowledge a message if no error occurs during processing
* `run`: callback with the function to run, will receive a `messages` argument where each message is a table containing:
  * `queue`: name of the queue the message was sent to
  * `id`: message id
  * `body`: message body

### mq.send(queue, body)
Sends a message to a queue.

Example:
```lua
mq.send("tasks", "my task")
```

### mq.batchSend(queue, bodies)
Sends multiple messages to a queue in a single operation.

Example:
```lua
mq.batchSend("tasks", {"task 1", "task 2" })
```

### mq.ack(queue, id)
Acknowledges a message from a queue, marking it as processed.

Example:
```lua
mq.ack("tasks", "message-123")
```

### mq.batchAck(queue, ids)
Acknowledges multiple messages from a queue in a single operation.

Example:
```lua
mq.batchAck("tasks", {"msg1", "msg2", "msg3"})
```

## Queue Management

### mq.getQueueStats(queue)
Retrieves statistics about a particular queue.

Example:
```lua
local stats = mq.getQueueStats("tasks")
print("Queue size: " .. stats.size)
print("Processing: " .. stats.processing)
```

### mq.awaitEmptyQueue(queue)
Waits for a queue to become empty.

Example:
```lua
mq.awaitEmptyQueue("tasks")
```

================================================
FILE: website/API/net.md
================================================
#api/space-lua

Network-related APIs.

### net.proxyFetch(url, options?)
Performs a HTTP call, proxied via the server (to avoid CORS issues, see [[HTTP API]]).

Options:
* `method`: GET, POST, PUT, DELETE (GET is default)
* `headers`: table with header -> value mappings
* `body`: either a string or table (which will be JSON stringified)

Returns:
* `ok`: boolean if the request went ok
* `status`: HTTP status code
* `headers`: HTTP headers
* `body`: for content types:
  * `text/*`: string
  * `application/json`: parsed JSON object
  * anything else: UInt8Array

## net.readURI(uri, options?)
Fetches the content of a [[URI]].

Options:
* `encoding` force an encoding for the result, e.g. `{encoding = "text/markdown"}`

## net.writeURI(uri, content)
Writes content to a specific [[URI|URI]].

================================================
FILE: website/API/os.md
================================================
#api/lua

API docs for Lua's `os` module.

## os.time(table?)
Returns the current time when called without arguments, or a timestamp for a specific date when given a table. The table can contain the following fields: year (required), month (required), day (required), hour (defaults to 12), min (defaults to 0), and sec (defaults to 0).

Example:
```lua
-- Get current timestamp
print(os.time())  -- prints: current Unix timestamp

-- Get timestamp for specific date
local timestamp = os.time({
    year = 2020,
    month = 1,
    day = 1
})
```

## os.date(format?, timestamp?)
Returns a string or table containing date and time, formatted according to the given format string. If timestamp is not provided, formats the current time.

Format specifiers:
- `%Y`: Full year (e.g., "2024")
- `%y`: Year without century (e.g., "24")
- `%m`: Month (01-12)
- `%b`: Abbreviated month name (e.g., "Jan")
- `%B`: Full month name (e.g., "January")
- `%d`: Day of month (01-31)
- `%e`: Day of month (1-31)
- `%H`: Hour (00-23)
- `%I`: Hour (01-12)
- `%M`: Minute (00-59)
- `%S`: Second (00-59)
- `%p`: AM/PM
- `%A`: Full weekday name (e.g., "Sunday")
- `%a`: Abbreviated weekday name (e.g., "Sun")
- `%w`: Weekday (0-6, Sunday is 0)
- `%U`: Week of the year, starting with the first Sunday as the first day of week 01 (00-53)
- `%W`: Week of the year, starting with the first Monday as the first day of week 01 (00-53)
- `%V`: ISO 8601 week of the year (01-53) (see [Wikipedia](https://en.wikipedia.org/wiki/ISO_week_date))
- `%j`: Day of year (001-366)
- `%Z`: Time zone name
- `%z`: Time zone offset from UTC
- `%%`: Literal "%"

Example:
```lua
-- Format specific date
local date = os.date("%Y-%m-%d", os.time({
    year = 2020,
    month = 1,
    day = 1
}))
print(date)  -- prints: 2020-01-01

-- Current date in different formats
print(os.date("%Y-%m-%d"))         -- prints: current date (e.g., "2024-03-14")
print(os.date("%B %d, %Y"))        -- prints: month day, year (e.g., "March 14, 2024")
print(os.date("%I:%M %p"))         -- prints: time in 12-hour format (e.g., "02:30 PM")
print(os.date("%A, %B %d, %Y"))    -- prints: full date (e.g., "Thursday, March 14, 2024")
``` 

================================================
FILE: website/API/service.md
================================================
#api/syscall

Exposes a simple service registry API leveraged by various parts of SilverBullet, see [[Service]].

## Example
```space-lua
service.define {
  selector = "greeter-service",
  -- no priority set
  match = {},
  run = function(name)
    return "Hello " .. name
  end
}

service.define {
  selector = "greeter-service",
  match = function(name)
    if name == "Pete" then
      -- takes precendence with an exact match
      return {priority=10}
    else
      return nil
    end
  end,
  run = function(name)
    return "Hello Pete, so happy to see you!"
  end
}
```

To invoke: ${service.invokeBestMatch("greeter-service", "Pete")} and ${service.invokeBestMatch("greeter-service", "Hank")}

## API
### service.define(spec)
Defines a service matching a selector.

Spec arguments:
* `selector`: service selector, can contain wildcards (e.g. `hello:*`)
* `match(data)`: callback (`data` argument), returns a match table if this service is a match or `nil` otherwise. A match table is service specific, but at least contains a `priority` key to set the service priority (used for sorting by `service.discover` and `service.invokeBestMatch`). Instead of a function can also simply be a match object directly.
* `run(data)`: callback to be invoked when the service is invoked

### service.discover(selector, data)
Discovers previously defined service matching a given selector. 

Return value is a list of tables (can be empty) with keys:
* `id`: guid of the service (automatically generated)
* `priority`: priority of service match (list will already be pre-sorted based on this key if present)

### service.invoke(match, data)
Invokes a service match (as returned by [[#service.discover(selector, options)]]).

### service.invokeBest(selector, options)
Performs a `discover` based on the selector, then immediately performs and `invoke` on the best match (based on priority).

================================================
FILE: website/API/shell.md
================================================
#api/syscall

The Shell API provides functions for running shell commands and interacting with processes.

### shell.run(cmd, args, stdin?)
Runs a shell command and returns its output.

Parameters:
- `cmd`: The command to run
- `args`: Array of arguments to pass to the command
- `stdin`: stdin string (optional)

Returns an object with:
- `stdout`: The standard output of the command
- `stderr`: The standard error of the command
- `code`: The exit code of the command

Example:
```lua
local result = shell.run("ls", {"-l"})
print("Output:", result.stdout)
print("Error:", result.stderr)
print("Exit code:", result.code)

local result = shell.run("cat", {}, "hello")
print("Output:", result.stdout) -- "hello"
```

================================================
FILE: website/API/slashCommand.md
================================================
#api/space-lua

APIs to create [[Slash Command]]. For simple cases it is recommended to use [[Library/Std/Infrastructure/Slash Templates]] instead.

## slashCommand.define(spec)

Define a custom slash command.

Supported keys in the spec:

* `name`: name of the command
* `description`: Description of the command
* `run`: The callback function that will be invoked once the command is run.

Example:

```lua
slashCommand.define {
  name = "hello-world",
  run = function()
    editor.insertAtCursor("Hello |^| world!", false, true)
  end
}
```



================================================
FILE: website/API/space.md
================================================
#api/syscall

The Space API provides functions for interacting with pages, documents, and files in the space.

# Page Operations

## space.listPages()
Returns a list of all pages in the space.

Example:
```lua
local pages = space.listPages()
for page in each(pages) do
    print(page.name)
end
```

## space.readPage(name)
Reads the content of a page.

Example:
```lua
local content = space.readPage("welcome")
print(content)  -- prints the content of the "welcome" page
```

## space.readRef(ref)
Reads a reference and returns it as a string, works for:
* `page`: reads the entire page
* `page#header` reads the referenced header
* `page@pos` where `pos` points to an item or task: reads the item/task and its children

## space.getPageMeta(name)
Gets metadata for a specific page.

Example:
```lua
local meta = space.getPageMeta("welcome")
print(meta.name, meta.lastModified)  -- prints page name and last modified date
```

## space.readPageWithMeta(name)
Combines readPage and getPageMeta in a single call, returning both in a table:
* `text`: the binary content
* `meta`: the meta data

## space.writePage(name, text)
Writes content to a page.

Example:
```lua
local meta = space.writePage("notes", "My new note content")
print("Page updated at: " .. meta.lastModified)
```

## space.deletePage(name)
Deletes a page from the space.

Example:
```lua
space.deletePage("old-notes")
```

## space.pageExists(name)
Checks if a page exists in the space.

Example:
```lua
if space.pageExists("Hello") then
    print("Page exists!")
else
    print("Page not found")
end
```

# Document Operations

## space.listDocuments()
Returns a list of all documents in the space.

Example:
```lua
local documents = space.listDocuments()
for doc in each(documents) do
    print(doc.name, doc.size)
end
```

## space.readDocument(name)
Reads the content of a document.

Example:
```lua
local data = space.readDocument("image.png")
print("Document size: " .. #data .. " bytes")
```

## space.writeDocument(name, data)
Writes binary data to a document.

Example:
```lua
local binaryData = string.char(72, 69, 76, 76, 79)  -- "HELLO" in binary
local meta = space.writeDocument("test.bin", binaryData)
print("Document saved with size: " .. meta.size)
```

## space.deleteDocument(name)
Deletes a document from the space.

Example:
```lua
space.deleteDocument("old-image.png")
```

# File Operations

## space.listFiles()
Returns a list of all files in the space.

Example:
```lua
local files = space.listFiles()
for _, file in ipairs(files) do
    print(file.name, file.size)
end
```

## space.getFileMeta(name)
Gets metadata for a specific file.

Example:
```lua
local meta = space.getFileMeta("document.txt")
print(meta.name, meta.modified, meta.size)
```

## space.readFile(name)
Reads the content of a file.

Example:
```lua
local content = space.readFile("document.txt")
print("File size: " .. #content .. " bytes")
```

## space.readFileWithMeta(name)
Combines readFile and getFileMeta in a single call, returning both in a table:
* `data`: the binary content
* `meta`: the meta data

## space.writeFile(name, data)
Writes binary data to a file.

Example:
```lua
local text = "Hello, World!"
local meta = space.writeFile("greeting.txt", text)
print("File written with size: " .. meta.size)
```

## space.deleteFile(name)
Deletes a file from the space.

Example:
```lua
space.deleteFile("old-document.txt")
```

## space.fileExists(name)
Checks if a file exists in the space.

Example:
```lua
if space.fileExists("config.json") then
    print("Config file exists!")
else
    print("Config file not found")
end


================================================
FILE: website/API/spacelua.md
================================================
#api/space-lua

The Space Lua API provides functions for working with Lua expressions and templates.

## spacelua.parseExpression(luaExpression)
Parses a lua expression and returns the parsed expression as an AST.

Example:
```lua
local parsedExpression = spacelua.parseExpression("1 + 1")
```

## spacelua.evalExpression(parsedExpr, envAugmentation?)
Evaluates a parsed Lua expression and returns the result. Optionally accepts an environment table to augment the global environment.

Example:
```lua
local parsedExpr = spacelua.parseExpression("x + y")
local result = spacelua.evalExpression(parsedExpr, {x = 1, y = 2})
print(result)  -- prints: 3
```

## spacelua.interpolate(template, envAugmentation?)
Interpolates a string with lua expressions and returns the result. Expressions are wrapped in ${...} syntax. Optionally accepts an environment table to augment the global environment.

Example:
```lua
local greeting = spacelua.interpolate("Hello ${name}!", {name="Pete"})
print(greeting)  -- prints: Hello Pete!
```

## spacelua.baseUrl()
Returns your SilverBullet instance's base URL, or `nil` when run on the server.

Example:
```lua
local url = spacelua.baseUrl()
print(url)  -- prints something like: https://example.com
```


================================================
FILE: website/API/string.md
================================================
#api/lua

API docs for Space Lua's `string` module.

**Note:** since string values set `string` as their meta table, these APIs can also be called as method calls on strings directly. For instance: `someString:startsWith("h")` is equivalent to `string.startsWith(someString, "h")`.

## Lua Pattern Matching

Lua patterns are not regular expressions. Space Lua makes a good effort at translating Lua patterns to regex to run in a Javascript environment, but there are two main differences:

1. Magic characters `^$()%.[]*+-?` must be escaped to represent their character. Standard Lua patterns do not require escaping magic characters when they are not contextually magic (e.g. `%d--` is a valid Lua pattern where the second hyphen is not magic). Space Lua may have unexpected results when expecting an un-escaped magic character to behave like a character.
2. In Space Lua, the repetition magic characters (`?`, `*`, `+`, and `-`) will apply to captures in patterns. They do not in standard Lua.

Additionally, the patterns for the *n*th captured string (`%*n*`), balanced match (`%b*xy*`), and frontier pattern (`%f[set]`) in [Lua](https://www.lua.org/manual/5.4/manual.html#6.4.1) will likely not be supported.

As noted below, the operations `string.matchRegex` and `string.matchRegexAll`
leverage regex in Javascript--not Space Lua patterns.

Here are some valid Lua patterns with different matches (or outright errors) in Space Lua:

```lua
print(string.match("1234", "(%d)+"))
-- prints "4" in Space Lua (last match of the captures)
-- prints "nil" in Lua (repetition magic does not work on captures)

print(string.match("*", "*"))
-- invalid regex in Space Lua ("*" is not escaped)
-- prints "*" in Lua

print(string.match("2024-03-14", "%d+-(%d+)-%d+"))
-- invalid regex in Space Lua (the "-"s are not escaped")
-- prints "03" in Lua
```

## String Operations

### string.byte(s, i?, j?)
Returns the numeric codes of characters in string `s` from position `i` to `j`. If `j` is not provided, defaults to `i`.

Example:
```lua
print(string.byte("Hello", 1))  -- prints: 72 (ASCII code for 'H')
```

### string.char(...)
Returns a string from given ASCII codes.

Example:
```lua
print(string.char(72))  -- prints: H
```

### string.find(s, pattern, init?, plain?)
Looks for the first match of `pattern` in string `s`. Returns start and end indices of match.

Example:
```lua
local start, end_ = string.find("Hello", "l")
print(start)  -- prints: 3 (first 'l' position)
```

### string.format(format, ...)
Returns a formatted string using C-style format specifiers.

Example:
```lua
print(string.format("Name: %s, Age: %d", "John", 30))  -- prints: Name: John, Age: 30
print(string.format("Pi: %.2f", 3.14159))             -- prints: Pi: 3.14
```

### string.gsub(s, pattern, repl, n?)
Returns a copy of `s` in which all (or the first `n`) occurrences of `pattern` have been replaced by `repl`.

Example:
```lua
-- Simple string replacement
local result, count = string.gsub("hello world", "hello", "hi")
print(result, count)  -- prints: hi world 1

-- Multiple replacements with limit
result = string.gsub("hello hello hello", "hello", "hi", 2)
print(result)  -- prints: hi hi hello

-- Function replacement
result = string.gsub("hello world", "(h)ello", function(h)
    return string.upper(h) .. "i"
end)
print(result)  -- prints: Hi world

-- Pattern with magic characters
result = string.gsub("hello.world", "%.", "-")
print(result)  -- prints: hello-world
```

### string.match(s, pattern, init?)
Returns the captures from the first match of `pattern` in string `s`.

Example:
```lua
-- Basic pattern matching
print(string.match("hello", "h"))  -- prints: h

-- Multiple captures
local year, month, day = string.match("2024-03-14", "(%d+)%-(%d+)%-(%d+)")
print(year, month, day)  -- prints: 2024 03 14

-- With init position
print(string.match("hello world", "(world)", 7))  -- prints: world

-- Pattern characters
print(string.match("123", "%d+"))      -- prints: 123
print(string.match("abc123", "%a+"))   -- prints: abc
print(string.match("   abc", "%s+"))   -- prints: "   "
```

### string.gmatch(s, pattern)
Returns an iterator function that returns successive captures from pattern matches in string `s`.

Example:
```lua
local words = {}
for word in string.gmatch("hello world lua", "%w+") do
    table.insert(words, word)
end
print(words[1], words[2], words[3])  -- prints: hello world lua
```

### string.len(s)
Returns the length of string `s`.

Example:
```lua
print(string.len("Hello"))  -- prints: 5
```

### string.lower(s)
Returns a copy of `s` with all characters converted to lowercase.

Example:
```lua
print(string.lower("Hello"))  -- prints: hello
```

### string.upper(s)
Returns a copy of `s` with all characters converted to uppercase.

Example:
```lua
print(string.upper("Hello"))  -- prints: HELLO
```

### string.rep(s, n, sep?)
Returns a string that is the concatenation of `n` copies of string `s`.

Example:
```lua
print(string.rep("Hello", 3))  -- prints: HelloHelloHello
```

### string.reverse(s)
Returns a string with the characters of `s` in reverse order.

Example:
```lua
print(string.reverse("hello"))  -- prints: olleh
print(string.reverse(""))       -- prints: "" (empty string)
```

### string.sub(s, i, j?)
Returns the substring of `s` from position `i` to `j`.

Example:
```lua
print(string.sub("Hello", 2, 4))  -- prints: ell
```

### string.split(s, sep)
Splits string `s` using separator `sep` and returns a table of substrings.

Example:
```lua
local parts = string.split("a,b,c", ",")
for i, part in ipairs(parts) do
    print(part)
end
-- Output:
-- a
-- b
-- c
```

# Non-standard Extensions
### string.startsWith(s, prefix)
Returns true if string `s` starts with `prefix`.

Example:
```lua
print(string.startsWith("hello world", "hello"))  -- prints: true
print(string.startsWith("hello world", "world"))  -- prints: false
```

### string.endsWith(s, suffix)
Returns true if string `s` ends with `suffix`.

Example:
```lua
print(string.endsWith("hello world", "world"))  -- prints: true
print(string.endsWith("hello world", "hello"))  -- prints: false
```

### string.trim(s)
Returns a copy of string `s` with whitespace removed from both ends.

Example:
```lua
print(string.trim("  hello  "))  -- prints: hello
```

### string.trimStart(s)
Returns a copy of string `s` with whitespace removed from the beginning.

Example:
```lua
print(string.trimStart("  hello  "))  -- prints: hello  
```

### string.trimEnd(s)
Returns a copy of string `s` with whitespace removed from the end.

Example:
```lua
print(string.trimEnd("  hello  "))  -- prints:   hello
```

### string.matchRegex(s, pattern)
Matches string `s` against a JavaScript regular expression pattern and returns the result. This uses JavaScript's native regex capabilities rather than Lua patterns.

Example:
```lua
local match = string.matchRegex("hello123", "([a-z]+)([0-9]+)")
print(match[1], match[2])  -- prints: hello 123
```

### string.matchRegexAll(s, pattern)
Returns an iterator that finds all matches of a JavaScript regular expression pattern in string `s`.

Example:
```lua
for match in string.matchRegexAll("a1b2c3", "([a-z])([0-9])") do
    print(match[0], match[1], match[2])  -- prints each full match and its capture groups
end
-- Output:
-- a1 a 1
-- b2 b 2
-- c3 c 3
```

================================================
FILE: website/API/sync.md
================================================
#api/syscall

The Sync API provides functions for interacting with the sync engine when the client runs in Sync mode.

## Sync Operations

### sync.isSyncing()
Checks if a sync is currently in progress.

Example:
```lua
if sync.isSyncing() then
    print("Sync in progress...")
end
```

### sync.hasInitialSyncCompleted()
Checks if an initial sync has completed.

Example:
```lua
if sync.hasInitialSyncCompleted() then
    print("Initial sync completed")
else
    print("Waiting for initial sync...")
end
```

### sync.performFileSync(path)
Immediately synchronizes a file with the server. Returns once the synchronization has completed.

Example:
```lua
sync.performFileSync("notes/important.md")
```

### sync.performSpaceSync()
Immediately triggers a full space sync. Returns `-1` if a sync was already ongoing, or the number of sync operations performed.

Example:
```lua
local changes = sync.scheduleSpaceSync()
print("Number of changes synced: " .. changes)
```

================================================
FILE: website/API/system.md
================================================
#api/syscall

The System API provides system-level functions for interacting with the SilverBullet environment.

## Function Operations

### system.invokeFunction(name, ...)
Invokes a plug function by name.

Example:
```lua
-- Invoke a function from a plug
system.invokeFunction("myplug.processData", "input", 123)
```

## System Information

### system.listCommands()
Lists all available commands.

Example:
```lua
local commands = system.listCommands()
for name, def in pairs(commands) do
    print(name .. ": " .. def.description)
end
```

### system.listSyscalls()
Lists all available syscalls.

Example:
```lua
local syscalls = system.listSyscalls()
for _, syscall in ipairs(syscalls) do
    print(syscall.name)
end
```

### system.getMode()
Returns the current mode of the system ("ro" or "rw").

Example:
```lua
local mode = system.getMode()
print("System mode: " .. mode)
```

### system.getURLPrefix()
Returns the prefix set by [[Install/Configuration|SB_URL_PREFIX]] or "/" if the variable isn't set

Example:
```lua
local prefix = system.getURLPrefix()
print("Prefix: " .. prefix)
```

### system.getVersion()
Returns the SilverBullet version.

Example:
```lua
local version = system.getVersion()
print("SilverBullet version: " .. version)
```

## Configuration

### system.reloadConfig()
Triggers an explicit reload of the configuration.

Example:
```lua
system.reloadConfig()
print("Configuration reloaded")
```

### system.reloadPlugs()
Triggers a reload of all plugs.

Example:
```lua
system.reloadPlugs()
print("All plugs reloaded")
```

### system.wipeClient(logout?)
Completely wipes the client state, including cached files, service worker and databases.

Parameters:
- `logout`: Optional boolean to also log out the user

Example:
```lua
system.wipeClient(true)  -- Wipe client and log out
print("Client state has been reset")
```


================================================
FILE: website/API/table.md
================================================
#api/lua

These are Lua functions defined in the `table` namespace:

## table.concat(table, sep?, i?, j?)
Concatenates the elements of a table into a string using a separator.

Example:
```lua
local fruits = {"apple", "banana", "orange"}
print(table.concat(fruits, ", "))  -- prints: apple, banana, orange
print(table.concat(fruits, "", 1, 2))  -- prints: applebanana
```

## table.insert(table, pos, value)
## table.insert(table, value)
Inserts a value into a table at the specified position, shifting elements up. If position is not provided, appends the value at the end of the table.

Example:
```lua
local fruits = {"apple", "orange"}
table.insert(fruits, "banana")  -- appends at end
print(table.concat(fruits, ", "))  -- prints: apple, orange, banana

table.insert(fruits, 2, "grape")  -- inserts at position 2
print(table.concat(fruits, ", "))  -- prints: apple, grape, orange, banana
```

## table.remove(table, pos?)
Removes an element from a table at the specified position, shifting elements down. If position is not provided, removes the last element.

Example:
```lua
local fruits = {"apple", "grape", "orange", "banana"}
table.remove(fruits, 2)  -- removes "grape"
print(table.concat(fruits, ", "))  -- prints: apple, orange, banana

table.remove(fruits)  -- removes last element
print(table.concat(fruits, ", "))  -- prints: apple, orange
```

## table.sort(table, comp?)
Sorts a table in-place using the optional comparison function. Without a comparison function, sorts in ascending order.

Example:
```lua
local numbers = {3, 1, 4, 1, 5, 9}
table.sort(numbers)  -- ascending order
print(table.concat(numbers, ", "))  -- prints: 1, 1, 3, 4, 5, 9

-- Custom comparison (descending order)
table.sort(numbers, function(a, b) return a > b end)
print(table.concat(numbers, ", "))  -- prints: 9, 5, 4, 3, 1, 1
```

## table.pack(...)
Creates a new table with the given arguments. The resulting table has all arguments stored at integer keys starting with 1, and a field "n" with the total number of arguments.

Example:
```lua
local args = table.pack("apple", "banana", "orange")
print(args[1], args[2], args[3])  -- prints: apple banana orange
print(args.n)  -- prints: 3
```

## table.unpack(table, i?, j?)
Returns all elements from the table as separate values. The optional `i` and `j` parameters specify the range of elements to return (defaults to 1 and the table length).

Example:
```lua
local fruits = {"apple", "banana", "orange"}
local a, b, c = table.unpack(fruits)
print(a, b, c)  -- prints: apple banana orange

-- With range specification
local x, y = table.unpack(fruits, 2, 3)
print(x, y)  -- prints: banana orange
```

# Non-standard APIs
## table.keys(table)
Returns an array containing all the keys in the table.

Example:
```lua
local person = {name = "John", age = 30, city = "New York"}
local keys = table.keys(person)
print(table.concat(keys, ", "))  -- prints: name, age, city
```

## table.includes(table, value)
Checks if a list-table contains a specific value.

Example:
```lua
local fruits = {"apple", "banana", "orange"}
print(table.includes(fruits, "banana"))  -- prints: true
print(table.includes(fruits, "grape"))   -- prints: false
```

## table.find(table, criteriaFn, fromIndex?)
Finds an element in a table that matches a criteria function. Returns a Lua multi value of index and first element or nil if no element is found.

Example:
```lua
local numbers = {1, 2, 3, 4, 5}
local _, firstEven = table.find(numbers, function(n) return n % 2 == 0 end)
print(firstEven)  -- prints: 2

-- With fromIndex parameter
local _, firstEvenAfter3 = table.find(numbers, function(n) return n % 2 == 0 end, 3)
print(firstEvenAfter3)  -- prints: 4

-- No match case
local result = table.find(numbers, function(n) return n > 10 end)
print(result)  -- prints: nil
```

## table.select(table, keys...)
Returns a new table from an old one, only with selected keys.

Example:
${query[[
  from p = index.tag "page"
  limit 3
  select table.select(p, "name", "lastModified")
]]}


================================================
FILE: website/API/tag.md
================================================
#api/space-lua #maturity/experimental

Enables customization of [[Tag|tags]] and its [[Object|objects]] in a few ways:

* Restrict and validates [[Metadata]] against a [[Schema]]
* Change how objects are indexed in the [[Object Index]]
* Tweak styling of tags in the editor using [[Space Style]]

> **success** Success
> Put all your `tag.define` calls in your [[CONFIG]] page so that they will be picked up during the index process.

# API
## tag.define(spec)
Defines a tag explicitly.

`spec` is a table that can contain:
* `name` (required) the name of the tag
* `metatable` to set a custom Lua metatable for objects with this tag.
* `mustValidate` a boolean defining whether or not schema validation must pass for the object to be indexed
* `schema` [[Schema]] to validate against
* `validate` callback function invoked when an objects needs to be validated, returns `nil` or an error message.
* `transform` callback function invoked when an object with tag `name` has been indexed. Allows you to make changes to it, skip indexing altogether or generate additional objects.

When `tag.define` is called multiple times with the same `name`, the specs will be merged. This e.g. enables overriding the schema of a built-in tag, or augment it with a custom `transform` callback.

This is a very powerful API, with a wide range of potential use cases.

# Use cases
## Schema validation
To define a [[Schema]] for validating an object (e.g. a page) tagged with `#person` ensuring that the `age` attribute is always a number, you can do the following:
```lua
tag.define {
  name = "person",
  -- mustValidate = true,
  schema = {
    type = "object",
    properties = {
      age = schema.number()
    }
  }
}
```
**Effect:** When editing a page tagged with `#person` where this schema does not validate (e.g. you set the `age` to be a string), you will see this error being highlighted in the editor.

If you set `mustValidate` to `true`, schema validation will happen during the index phase (in addition to linting in the editor) and non-validating objects will not be indexed (with errors being reported in the JavaScript console). Use this to ensure all your objects conform to your schema (it does come at a slight performance penalty at the indexing phase). Errors are reported to the [[Log]].

## Index augmentation
Based on your page’s markdown, an indexer produces a list of objects to be indexed. If you define a `transform` callback function for a tag, this function will be invoked for each object with that tag, when the indexer encounters one. `transform` can inspect the object and do a few things:

* Make changes to the object in place and return it: this is the most common scenario. It allows you to attach additional attributes to the object before it's persisted to the database.
* Return an empty table (`{}`): in this case the object will not be indexed at all.
* Return `nil` in which case indexing proceeds as usual.
* Return a list of objects that should be indexed _instead_: this may include the original object or a modification of it. This allows you to generate a set of custom objects, e.g. based on further parsing of the data in the object. A use case here could be to extract additional attributes from an existing attribute.
  **Note:** one of the returned objects _must_ have the same `ref` attribute as the object passed in.

> **warning** Warning
> For custom `transform` functions to be picked up during the initial client indexing phase they **have to be defined** in your [[CONFIG]] page.

> **note** Note
> `transform` will only be invoked when a page is indexed. This generally happens after making a change. To apply newly defined `transform` functionality to all pages in your space, you have to reindex the entire space using `Space: Reindex`.

### Example: adding [[Page Decorations]] dynamically
The following dynamically adds a 🧑 prefix [[Page Decorations|page decoration]] to all pages tagged with `#person`, such as [[Person/John]] and [[Person/Zef]].

```lua
tag.define {
  name = "person",
  transform = function(o)
    o.pageDecoration = { prefix = "🧑 " }
    return o
  end
}
```

### Example: a ‘deadline’ attribute for tasks
Let’s say that you’d like the encode a deadline attribute for tasks without using the `[deadline: "2026-12-31"]` syntax, instead you’d like to use an emoji as follows:

* [ ] Hello 📅 2026-12-31

In addition:

We’d like to highlight tasks that use a 📅 but then don’t follow the correct date format, like here:

* [ ] Hello task 📅 31-12-2026

And we’d like the `name` attribute to be cleaned from the deadline syntax.

This can be implemented by defining a custom `transform` for tasks:
 
```lua
local deadlinePattern = "📅%s*(%d%d%d%d%-%d%d%-%d%d)"

tag.define {
  name = "task",
  validate = function(o)
    if o.name:find("📅") then
      if not o.name:match(deadlinePattern) then
        return "Found 📅, but did not match YYYY-mm-dd format"
      end
    end
  end,
  transform = function(o)
    -- Use a regular expression to find a deadline
    local date = o.name:match(deadlinePattern)
    if date then
      -- Remove the deadline from the name
      o.name = o.name:gsub(deadlinePattern, "")
      -- And put it in as attribute
      o.deadline = date
    end
    return o
  end
}
```

The result is the following:
${query[[
  from t = tags.task
  where t.deadline
  select table.select(t, "name", "done", "deadline")
]]}

## Styling
Tags get assigned a `data-tag-name` attribute in the DOM, which you can use to do custom styling with [[Space Style]].

### Example: colorful tags
Example: #my-red-tag

```space-style
a[data-tag-name="my-red-tag"] {
  background-color: red;
}
```


================================================
FILE: website/API/taskState.md
================================================
#maturity/beta

APIs to define [[Task#Custom states]].

# API
## taskState.define(def)
Defines a custom task state. Options:
* `name`: name of the state
* `done`: whether or not the state should be considered "done" or not, used by the `Task: Remove Completed` command

Primarily used to offer code completion for custom task states for now.

## Example
```space-lua
taskState.define {
  name = "TO DO"
}

taskState.define {
  name = "IN PROGRESS"
}

taskState.define {
  name = "DONE",
  done = true
}
```
Use:
* [TO DO] Still to do
* [DONE] Task already completed


================================================
FILE: website/API/template.md
================================================
#api/space-lua

Template functions that use the [[API/template#template.new(template)]] function.

## template.new(template, stripIndent)
Returns a template function that can be used to render a template. Conventionally, a template string is put between `[==[` and `]==]` as string delimiters.
If `stripIndent` is set to true or omitted, the function will try to remove indentation at the start of the line. This can be useful for multiline strings.

Example:

```space-lua
examples = examples or {}

examples.sayHello = template.new[==[Hello ${name}!]==]
```

And its use: ${examples.sayHello {name="Pete"}}

## template.each(collection, template)
Iterates over a collection and renders a template for each item.

Example:

${template.each(query[[from index.tag "page" limit 3]], template.new[==[
    * ${name}
]==])}


================================================
FILE: website/API/widget.md
================================================
APIs to define widgets in SilverBullet, often used through [[Space Lua#Expressions]].

# Widget types
## Markdown widgets
When setting a `markdown` key, or using the `widget.markdown` API, a markdown-based widget can be created.

Example:

```space-lua
function helloWorld(name)
  return widget.markdown("Hello world, *" .. name .. "*!")
end
```

Can be used as follows:

${helloWorld("Pete")}

## DOM widgets
To render a custom HTML-based widget, use the [[API/dom]] elements passed as an argument to `widget.html`:

```space-lua
function marquee(text)
  return widget.html(dom.marquee {
    class = "my-marquee",
    onclick = function()
      editor.flashNotification "You clicked me"
    end,
    text
  })
end
```

We can combine this with some [[Space Style]] to style it:

```space-style
.my-marquee {
  color: purple;
}
```

This can be used as follows:
${marquee "Finally, marqeeeeeee!"}
# API
## widget.new(spec)
To render a widget, call `widget.new` with a `spec` table setting any of the following keys:

* `markdown`: Renders the value as markdown
* `html`: Renders a HTML DOM as a widget. It is usually used in conjunction with the [[API/dom]] API.
* `display`: Render the value either `inline` or as a `block` (defaults to `inline`).

## widget.markdown(text)
Shortcut for `widget.new { markdown = text }`

## widget.html(htmlOrDOM)
Shortcut for `widget.new { html = htmlOrDOM }`

Usually used in conjunction with [[API/dom]].


================================================
FILE: website/API/yaml.md
================================================
#api/syscall

The YAML API provides functions for parsing and stringifying YAML content.

### yaml.parse(text)
Parses a YAML string into a Lua table.

Example:
```lua
local text = [[
name: John
age: 30
hobbies:
  - reading
  - hiking
]]

local data = yaml.parse(text)
print(data.name)  -- prints: John
print(data.hobbies[1])  -- prints: reading
```

### yaml.stringify(obj)
Converts a Lua table into a YAML string.

Example:
```lua
local data = {
    name = "John",
    age = 30,
    hobbies = {"reading", "hiking"}
}

local yamlText = yaml.stringify(data)
print(yamlText)
```


================================================
FILE: website/Anything Picker.md
================================================
The anything picker can be used to open any page, including [[Meta Page]] and pages hidden with [[Page Decorations]]. Otherwise it’s equivalent to the [[Page Picker]].

To open, run the command ${widgets.commandButton("Navigate: Anything Picker")} using this button or [[Command Palette]]. Alternatively cycle from any other Picker by typing `^`.


================================================
FILE: website/Architecture.md
================================================
Here is a big-picture (conceptual) architecture of SilverBullet:
```excalidraw
url:architecture.excalidraw
height:800px
```

# Client
The client is what you see when you open SilverBullet in a browser tab or window. It renders the UI, interacts with the user and runs most of the logic. 90%+ of logic in SilverBullet lives here.

## Editor
The editor is built on [CodeMirror](https://codemirror.net/) with a lot of [[Markdown/Extensions]] to its default Markdown mode.

## Space Lua
SilverBullet contains a custom [[Lua]] runtime called [[Space Lua]]. It should be your first go-to when you are interested in extending SilverBullet to your like. Space Lua implements the majority of the Lua 5.4 standard library (where it makes sense), but also includes some additional Space Lua-specific [[API]]s. In addition all [[#Syscalls]] are accessible from Space Lua.

When Lua code gets too complicated or performance is more of a concern, the next level is to build your extension in TypeScript as a plug.

## Plugs
[[Plugs]] and the PlugOS library that implements are a generic means to extend SilverBullet. Code is written in TypeScript and compiled into a single JavaScript `.plug.js` bundle that can be distributed as part of a [[Library|Library]]. 

A lot of [“built in” functionality](https://github.com/silverbulletmd/silverbullet/tree/main/plugs) in SilverBullet in in fact implemented as plugs, partially to “stress test” this infrastructure. 

## Syscalls
Syscalls (system calls) are the abstraction used in SilverBullet to cleanly create an API boundary between SilverBullet and extension code (Plugs or Space Lua). A lot of functionality core to SilverBullet (in the editor, the event system, datastore) are exposed through syscalls.

## Events and services
[[Event]]

## Datastore


The client interacts with the service worker and server primarily via the [[HTTP API]], which exposes CRUD-style operations on files.

# Service Worker
The [service worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)’s role is to make SilverBullet offline capable. When enabled (it is enabled by default) it:
* Caches and serves the SilverBullet client code
* [[Sync|Syncs]] files with a local database
* Implements the [[HTTP API]], but handles it locally (where appropriate)

It does this by intercepting HTTP calls coming from the client aimed at the server.

The service worker embeds a [[Sync]] engine, that based on configuration constantly keeps a local copy of your files in sync with the server. To make the sync status visible, it emits events to the Client.

For debugging purposes you can disable the service worker by adding `?enableSW=0` to your URL. This disabling is persistent, to re-enable it use `?enableSW=1`.

# Server
The server has only three jobs:

* Handle authentication
* Serve the (static) client code
* Implement the [[HTTP API]], which mainly means listing, reading, writing and deleting files, and executing shell commands.

Note that all indexing, querying etc. happens in the client. The server effectively acts as a file store.


================================================
FILE: website/Aspiring Pages.md
================================================
An aspiring page is a [[Page|page]] that does not yet exist, but is already linked to.

Aspiring pages appear in the [[Page Picker]] (with a `Create page` hint) as well as in auto complete when creating [[Link]].

================================================
FILE: website/Attribute.md
================================================
Attribute syntax can contribute additional [[Metadata]] to various [[Object|Objects]], including:

* Pages (although the preferred way is [[Frontmatter]])
* Items (bulleted and numbered list items)
* [[Task|Tasks]]
* Paragraphs

## Syntax
The syntax is as follows:

```
[attributeName: value]
```

For Obsidian/LogSeq compatibility, you can also double the colon like this: `[attributeName:: value]`
 
Attribute names need to be alpha-numeric. Values are interpreted as [[YAML]] values. So here are some examples of valid attribute definitions:

* string: [attribute1: sup]
* number: [attribute2: 10]
* array: [attribute3: [sup, yo]]

Multiple attributes can be attached to a single entity, e.g. like so:

* Some item [attribute1: sup][attribute2: 22]

## Scope
Depending on where these attributes appear, they attach to different things. For instance, this attaches an attribute to a page:

[pageAttribute: hello]

However, usually, [[Frontmatter]] is used for this purpose instead.

Example query:

${query[[
  from index.tag "page"
  where _.name == editor.getCurrentPage()
  select {name=_.name, pageAttribute=_.pageAttribute}
]]}

This attaches an attribute to an item:

* Item [itemAttribute: hello] #specialitem

Example query:

${query[[
  from index.tag "specialitem"
  where itemAttribute == "hello"
  select {
    name = _.name,
    itemAttribute = _.itemAttribute
  }
]]}

This attaches an attribute to a task:

* [ ] Task with an attribute, I’m so cool [taskAttribute: hello]

Example query:

${query[[
  from index.tag "task"
  where taskAttribute == "hello"
  select {
    name = _.name,
    taskAttribute = _.taskAttribute
  }
]]}


================================================
FILE: website/Authelia.md
================================================
How to set up SilverBullet with Authelia

For SilverBullet to work as intended, some files will need to be excluded from your authentication method of choice. These files always need to be accessible, for example for offline or [[PWA]] support.

The files are the following:
- The web manifest
- The app icon
- The service worker

These files can be whitelisted by adjusting your Authelia configuration to something like this:

```yaml
access_control:
  default_policy: deny
  
  rules:
    - domain: silverbullet.yourdomain.com
      resources:
        - '/\.client/manifest\.json$'
        - '/\.client/[a-zA-Z0-9_-]+\.png$'
        - '/service_worker\.js$'
      policy: bypass
    - domain: yourdomain.com
      policy: two_factor
```

Please adjust this to fit your specific needs. The important part is that the files are associated with `policy: bypass`.


================================================
FILE: website/Authentication Proxy.md
================================================
In case you would like to run SilverBullet behind an authentication proxy (such as Authelia, Authentik or the ones integrated with Cloudflare Zero Trust or Pangolin) there is one key configuration tweak you need to make:

**You must exclude a few paths from authentication**. If you don’t do this a lot of PWA functionality may not work and SilverBullet may break in unexpected ways. Doing this is perfectly safe, it just gives browsers unauthorized access to some client code, not any of your content.

In your configuration add **rules to allow unauthenticated access** for the following paths:

* `/service_worker.js`
* `/.client/*`


================================================
FILE: website/Authentication.md
================================================
SilverBullet supports simple authentication for a single user.

By setting the `SB_USER` environment variable with a username:password combination, you enable authentication for a single user. For instance:

```shell
docker run -e SB_USER=pete:1234 ...
```

Will let `pete` authenticate with password `1234`. 

For more options see [[Install/Configuration]].

Alternatively, or in addition, you can also use an [[Authentication Proxy]].

================================================
FILE: website/Browser.md
================================================
SilverBullet is a web application. You access it via any modern browser, and it leverages a lot of recent technological advances built into modern browsers, such as service workers (for offline capabilities), IndexedDB (for storing and querying data) and cryptography APIs.

_Because_ it is a browser-based web application, you can access it from any device with a modern web browser, this includes desktops, laptops, phones and tablets. 

_While_ it’s a browser-based web application, many browsers (such as Chromium-based and Safari-based ones) allow you to install it as a [[PWA]], giving it an icon on your home screen, or icon in your dock, and removing any unnecessary browser Chrome, making it visually indistinguishable from a “native” desktop app:

![[pwa-screenshot.png]]


================================================
FILE: website/CONFIG.md
================================================
This is where you configure SilverBullet to your liking. See [[^Library/Std/Config]] for a full list of configuration options. 

# Main configuration
```space-lua
config.set {
  actionButtons = {
    {
      icon = "home",
      description = "Go to the index page",
      run = function()
        editor.invokeCommand("Navigate: Home")
      end
    },
    {
      icon = "activity",
      description = "What's new",
      run = function()
        editor.navigate "CHANGELOG"
      end
    },
    {
      icon = "message-circle",
      description = "Community",
      run = function()
        editor.openUrl "https://community.silverbullet.md"
      end
    },
    {
      icon = "github",
      description = "Github",
      run = function()
        editor.openUrl "https://github.com/silverbulletmd/silverbullet"
      end
    },
    {
      icon = "heart",
      description = "Funding",
      run = function()
        editor.navigate "Funding"
      end
    },
    {
      icon = "book",
      description = "Open page",
      run = function()
        editor.invokeCommand("Navigate: Page Picker")
      end
    },
    {
      icon = "search",
      description = "Search",
      run = function()
        editor.invokeCommand("Silversearch: Search")
      end
    },
    {
      icon = "terminal",
      description = "Run command",
      run = function()
        editor.invokeCommand "Open Command Palette"
      end,
    }
  }
}
```

# Custom tag definitions
(further detailed in [[API/tag#Use cases]])
```space-lua
tag.define {
  name = "person",
  transform = function(o)
    o.pageDecoration = { prefix = "🧑 " }
    return o
  end
}

local deadlinePattern = "📅%s*(%d%d%d%d%-%d%d%-%d%d)"

tag.define {
  name = "task",
  validate = function(o)
    if o.name:find("📅") then
      if not o.name:match(deadlinePattern) then
        return "Found 📅, but did not match YYYY-mm-dd format"
      end
    end
  end,
  transform = function(o)
    -- Use a regular expression to find a deadline
    local date = o.name:match(deadlinePattern)
    if date then
      -- Remove the deadline from the name
      o.name = o.name:gsub(deadlinePattern, "")
      -- And put it in as attribute
      o.deadline = date
    end
    return o
  end
}
```

================================================
FILE: website/CONTAINER_BOOT.md
================================================
## Uncomment the following line to install pandoc:
#apk add pandoc


================================================
FILE: website/Client Encryption.md
================================================
#maturity/beta

By default SilverBullet keeps a copy of all your files as well as the index, unencrypted in your browser’s IndexedDB. As long as you control your device and other people do not have access to it, this should be perfectly safe. Browsers do not allow access to these databases from other websites and domains.

However, if you are accessing SilverBullet from an “untrusted” device, such as a public computer, this is not a great option, because even after closing your browser all content remains stored on the device unencrypted. You can use commands like `Client: Wipe` and `Client: Logout` to remove data locally, but there’s a chance you will forget at some point.

> **note** Note
> Client encryption right now is only available for users of SilverBullet’s native [[Authentication]] system.

This is what SilverBullet’s **client encryption** is for. When you authenticate using SilverBullet’s [[Authentication]] system, you will have a checkbox “Enable client encryption”. When checked, _all_ your content kept on the client will be encrypted (both your synced files and index). Since no encryption keys are written to disk at any stage, closing your browser or even closing all SilverBullet tabs will flush the key. While content remains on the device, it cannot be viewed without the encryption key derived from your username and password, leaving it useless to malicious actors.

This safety does come at a cost:

1. **Performance:** due to constant encryption and decryption SilverBullet will be slower.
2. **Convenience:** the client-side encryption key (derived from your username and password) will only ever be kept in memory, which means that if you close all your SilverBullet browser tabs and windows, the key will be flushed and you will have to login again.

# Details
Some technical details on how client encryption works.

Note that this is _client_ encryption only, not end-to-end encryption. Data on the server is only encrypted if your space folder is kept on some encrypted volume on the server side. _Transfer_ of data from the client to server is always encrypted via TLS, whether you use client encryption or not.

On the client, all of SilverBullet’s local data storage is built on a small key-value based [abstract interface](https://github.com/silverbulletmd/silverbullet/blob/main/client/data/kv_primitives.ts). By default this interface is implemented to [directly](https://github.com/silverbulletmd/silverbullet/blob/main/client/data/indexeddb_kv_primitives.ts) communicate with your browser’s [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API). On top of this we build the sync engine and all our database indexing features. When you enable client encryption, we put a layer in-between: the encryption layer, which based on a cryptographic key will encrypt and decrypt both keys and values on the fly.

A strong 256-bit cryptographic key is derived (using _PBKDF2_) on the client from your username/password combo entered upon login. This key is kept in the service worker for SilverBullet clients to obtain so that the user is not required to constant log in when refreshing a tab, or opening new SilverBullet tabs and windows.

Since we need deterministic and stable encryption for data store keys, we use _AES-CTR_ with a fixed counter. For values we use _AES-GCM_ with randomized ivs.


================================================
FILE: website/Command Palette.md
================================================
The Command Palette is used to explore SilverBullet’s numerous [[Command]] as well as execute them.

The UI and its operation is largely the same as the [[Page Picker]]‘s, with a few differences:

* If a keyboard shortcut is configured for the given command, it is listed along the command name to the right.
* The ordering is decided based on two factors:
  * The last time the command was invoked via the [[Command Palette]] in this client session.
  * The `priority` configured for the command (in the plug, or via [[^Library/Std/Config]] under `shortcuts`).


================================================
FILE: website/Command.md
================================================
Commands define actions that SilverBullet can perform. They range from simple edit commands, such as `Text: Bold`, but may be more elaborate such as `Page: Rename`. 

SilverBullet ships with a lot of commands built in, but custom ones can also be defined using [[Space Lua]] and [[Plugs]].

Beside the list below, all commands available commands appear in the [[Command Palette]] but may have key bindings as well (these key bindings appear in the [[Command Palette]] and are configurable in [[^Library/Std/Config]]).

You can create custom command using the [[^Library/Std/APIs/Command]] API.


================================================
FILE: website/Data Sovereignty.md
================================================
#value

Data Sovereignty (in our definition) is the principle that your data ought to be controlled by _you_. _You_ ought to have control over your own data, how it is accessed, processed and used.

SilverBullet supports data sovereignty by taking the approach of [[Self Hosted]], combined with [[Open Source]], [[Zero Tracking]], and leveraging the [[Browser]] (rather than apps) for universal access.


================================================
FILE: website/Deployments/Caddy.md
================================================
The most straightforward way to add TLS on top of SilverBullet is to use [Caddy](https://caddyserver.com/). Caddy can automatically provision an SSL certificate for you.

When you’re deploying on a public server accessible to the Internet, you can do this as follows:

```shell
sudo caddy reverse-proxy --to :3000 --from yourdomain.com:443
```

If you’re deploying on a local network and accessing your server via a VPN, this is a bit more tricky. The recommended setup here is to use [Tailscale](https://tailscale.com/), which now [supports TLS certificates for your VPN servers](https://tailscale.com/kb/1153/enabling-https/). Once you have this enabled, get a certificate via:

```shell
tailscale cert yourserver.yourtsdomain.ts.net
```

Caddy can automatically find these certificates once provisioned, so you can just run:

```shell
sudo caddy reverse-proxy --to :3000 --from yourserver.yourtsdomain.ts.net:443
```


================================================
FILE: website/Development.md
================================================
SilverBullet development can happen at various level.

At its most basic level, you can do a lot using [[Space Lua]]. If this does not give enough power, you can upgrade to [[Plugs]]. If _that_ doesn’t suffice, you can contribute to SilverBullet’s core (see below).

# Stack
SilverBullet’s client 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/). [ES Build](https://esbuild.github.io) is used to build the frontend.

[[Plugs]] are also written in TypeScript.

The SilverBullet server is written in [Go](https://go.dev/).

# 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

# Development
Requirements: 
* [Deno](https://deno.land/) 2.4 or newer.
* [Go](https://go.dev/) 1.25 or newer
* Make

It's convenient to also install [air](https://github.com/air-verse/air) for development, this tool will watch your code base for changes and automatically rebuild:

```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 using air:

```shell
air <PATH-TO-YOUR-SPACE>
```

Note, that if you want to pass arguments to your SilverBullet binary like `-p` or `-L` you need to this as follows:

```shell
air -- -L 0.0.0.0 <PATH-TO-YOUR-SPACE>
```


Alternatively, to build the project without air:

```shell
make build
```

To run the resulting server:

```shell
./silverbullet <PATH-TO-YOUR-SPACE>
```

### 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 <PATH-TO-YOUR-SPACE>:/space silverbullet
```



================================================
FILE: website/Document Editor.md
================================================
Document editors allow users to not only view but also edit documents. This means that Silverbullet can (theoretically) be used for much more than just markdown, e.g. PDF, asciidoc, org and a lot more. Currently the feature is still very young and the only editors available are proof of concepts, but this should hopefully change soon. If you want to contribute and make your own editor look at [[#Internals]]

# Usage
Document editors are provided by [[Plugs]]. To install a document editor just install the plug. The editor will then be available for documents with certain file extensions which are specified by the plug, e.g. an image viewer would be available for `jpg`, `jpeg` and `png`.
To edit or view a document, use `Cmd-o` (Mac) or `Ctrl-o` (Windows, Linux) to open the document navigator. Files for which a document editor is available will have their extension highlighted in blue.

# Available editors
Currently only small demos are available
- [TXT editor](https://github.com/MrMugame/silverbullet-txt/)
- [PDF viewer](https://github.com/MrMugame/silverbullet-pdf/)

# Development
First the editor is defined inside the plug manifest. The `editor` field is used to specify the file extensions this editor can handle.

```yaml
name: txteditor
functions:
  TXTEditor:
    path: ./editor.ts:editor
    editor: ["txt"]
```

The function provided should look like this. The html tag will be directly inserted into an iframe as the `srcdoc`. This means you can also use the `<head>` or `<body>` tags.

```typescript
export async function editor(): Promise<{ html: string }> {
  return {
    html: "<h1>Document Editor example</h1>"
  }
}
```

To communicate with silverbullet events/messages are used. You can subscribe to an event inside a script tag as follows

```javascript
window.silverbullet.addEventLister("file-open", (event) => {
  console.log("Got an event:", event);
});
```

SB will dispatch the following events:
- `file-open`: A file was just navigated to, the document editor needs to open it. The details contain the meta and data: `{ data: Uint8Array, meta: DocumentMeta }`
- `request-save`: Silverbullet is requesting a save, you should send a `file-saved` event as soon as possible
- `focus`: You should focus the editor if possible (i.e. highlight cursor)

To send events/messages like the `file-saved` message, you can use the `sendMessage` function.

```javascript
window.silverbullet.sendMessage("file-saved", { data: new TextEncoder().encode("We saved a text file") })
```

If a the document changed and a save is necessary you can send the `file-changed` message. Silverbullet will request a save automatically.
You can also send events from the outside using the `editor.sendMessage` syscall. If you want to communicate the other way around you can call syscalls using `window.silverbullet.syscall(name: string, ...args: any[])`.


================================================
FILE: website/Document.md
================================================
While SilverBullet is aimed at primarily text-based content, life can not fully be represented in text always. Therefore, SilverBullet supports documents. Documents, like [[Page]] ultimately are — once again — just files on disk. Using plugs, documents of specific file types can also be viewed and edited.

# Uploading
To create a document, you have a few options:

* Use the ${widgets.commandButton("Upload: File")} command (especially useful on mobile devices)
* Drag & drop files or images onto a page
* Copy & paste files or images onto a page

All options will prompt you for a file name to use to store the document, and then include the document as an embedded image (if it was an image) or link to the file.

# Managment
Using `Cmd-o`/`Ctrl-o` you can open the document navigator, which will display all documents in your Silverbullet space. You can either:
  - Delete or rename documents for which no [[Document Editor]] is available (indicated by the grey hint)
  - Open and view documents for which a [[Document Editor]] is available (indicated by the blue hint)

# Linking
Documents can be linked to in two ways:
* Via the regular link syntax: `[link text](document.pdf)`. URLs here are relative to the page, so on a page named `MyFolder/Hello`, `[link text](document.pdf)` would refer to a document stored in `MyFolder/document.pdf`.
* Via the wiki link syntax: `[[document.pdf]]`. These paths are absolute and relative to your space’s root, just like regular page links. That is: on a page `MyFolder/Hello` a document link `[[document.pdf]]` would link to the file `document.pdf` in the space’s root folder.

# Embedding
Media can also be embedded using the [[#Linking]] syntax, but prefixed with an `!`:
Images, videos, audio and PDFs are currently supported.

* `![alternate text](image.png)`
* `![[image.png]]`

These follow the same relative/absolute path rules as links described before.

## Media resizing
In addition, media can be _sized_ using the following syntax:
* Specifying only a width: `![Alt text|300](image.png)` or `![[image.png|300]]`
* Specifying only a height: `![Alt text|x300](image.png)` or `![[image.png|x300]]`
* Specifying both width and height: `![Hello|300x300](image.png)` or `![[image.png|300x300]]`


================================================
FILE: website/Editor.md
================================================
By design, the vast majority of the SilverBullet UI is taken up by the editor. This is where the magic happens.

This is where you write in [[Markdown]].

================================================
FILE: website/End-User Programming.md
================================================
An excellent [essay from Ink & Switch](https://www.inkandswitch.com/end-user-programming/) introduces **end-user programming** as follows:

> Today’s software apps are like appliances: we can only use the capabilities exactly as programmed by the developer. What if we, and all computer users, could reach in and modify our favorite apps? Or even create new apps on the fly according to our needs in the moment?
> 
> This is end-user programming, a vision for empowered computing pursued by bright-eyed computer science visionaries. Its rich history reaches back to the 1960s with programming environments like Smalltalk and Logo. Notable successes since then include Unix, the spreadsheet, Hypercard, and HTML. And today, newcomers like Zapier, Coda, and Siri Shortcuts are trying their own approaches to automation and dynamic modeling.

SilverBullet is working towards supporting more end-user programming features. Some of them currently available include:

* [[Space Lua]]
* [[Object]]
* [[Metadata]]

================================================
FILE: website/Event.md
================================================
SilverBullet has its own event bus.

Events can be subscribed to and dispatched via the [[API/event]] API. Other features, such as [[Service]] are built on top of Events.

# Built-in events
Here is a list of built-in events (triggered by SilverBullet’s core).
To use them, simply subscribe to them via [[API/event#event.listen(listenerDef)]], and do a `print` to see what data you receive. Alternatively, grep the code base to see exactly where they are triggered.

* `page:click`: user clicks a location on the page
* `page:index`: page has changed and requested to be indexed (used by [[Object]] indexing)
* `editor:complete`: editor completion triggered, returns completion results
* `slash:complete`: slash completion triggered, returns completion results
* `editor:lint`: request to lint, returns errors
* `editor:init`: editor initialized
* `editor:pageLoaded`: page has loaded in the editor
* `editor:pageReloaded`: page reloaded in the editor (e.g. when it was changed on disk)
* `editor:pageSaving`: page is about to save
* `editor:pageSaved`: page has saved
* `editor:pageCreating`: page is creating (can return a page content object)
* `editor:pageModified`: a change was made to the document (real-time)
* `editor:documentSaving`: a document is about to be saved
* `editor:documentSaved`: a document was saved
* `editor:modeswitch`: triggered between vim mode and non-vim mode
* `editor:fold`: code was folded in the editor
* `editor:unfold`: code was unfolded in the editor
* `plugs:loaded`: plugs were loaded
* `cron:secondPassed`: a second has passed (useful to implement cron-like features)
* `hooks:renderTopWidgets`: top widgets have requested to render (return widgets)
* `hooks:renderBottomWidgets`: bottom widgets have requested to render (return widgets)

# All subscribed events
Here’s a dynamically generated list of events that this SilverBullet instance has subscribed to, to give a sense of what’s there:
${query[[
  from event.listEvents()
  where not _:startsWith("service:")
  order by _
]]}


================================================
FILE: website/Export.md
================================================
If you would like to export content (a full page or a selection), you can use SilverBullet’s _Export_ functionality triggered with ${widgets.commandButton "Export: Page Or Selection"}.

**Note:** Exports are one-offs. If you are interested in sharing content onto other platforms and keeping things in sync, have a look at [[Share]].

# Support
Out of the box SilverBullet ships with the following exporters:

* Copy rich text: e.g. for pasting into a Google Docs or some other WYSIWYG environment.
* Copy clean markdown: stripping SilverBullet-specific markdown and rendering e.g. [[Space Lua#Expressions]]: e.g. to paste into a Github issue or other markdown friendly platform.

The export infrastructure as well as the above exporters are implemented in Space Lua here: [[^Library/Std/Infrastructure/Export]]. If you would like to implement your own exporters, have a look at the built-in ones to understand how they work.

================================================
FILE: website/Extensions.md
================================================
There are various ways the functionality of SilverBullet can be extended:

* [[Library]]
* [[Plugs]]


================================================
FILE: website/Folder.md
================================================
While folders technically exist in SilverBullet, they’re not necessarily the key organizational tool that they are in other tools. SilverBullet doesn’t have a file and folder tree as part of its UI, for instance.

There’s no explicit way to create a folder, but you can do so by simply putting slashes `/` in your page name (also on Windows). SilverBullet will automatically create the folder hierarchy on disk, if necessary.

What are some alternatives to using folders for organization? [[Tag]], primarily.

================================================
FILE: website/Frontmatter.md
================================================
---
status: Complete
---
Frontmatter is a common format to attach additional metadata (data about data) to markdown documents. Many tools support it as a markdown [[Markdown/Extensions|extension]].

In SilverBullet, there are multiple ways to attach [[Metadata]] to a page; frontmatter is the most popular one.

You create frontmatter by starting your markdown document with `---` followed by [[YAML]] encoded attributes and then ending with `---` again. Followed by the regular body of your document. This very page contains some frontmatter, click on it to see the underlying code.

Here is another example:

    ---
    status: Draft
    tags:
    - tag1
    - tag2
    seeAlso: "[[YAML]]"
    ---
    ## This is a section
    This is content

For convenience, you may use the `attribute.subAttribute` notation, which internally will expand:

```yaml
attribute.subAttribute: 10
```

to

```yaml
attribute:
   subAttribute: 10
```

# Special attributes
While SilverBullet allows arbitrary metadata to be added to pages, there are a few attributes with special meaning:

* `name` (==DISALLOWED==): is an attribute used for page names, _you should not set it_.
* `displayName` (`string`): very similar in effect as `aliases` but will use this name for the page in certain contexts.
* `aliases` (`array of strings`): allow you to specify a list of alternative names for this page, which can be used to navigate or link to this page
* `tags` (`array of strings` or `string`): an alternative (and perhaps preferred) way to assign [[Tag]] to a page. There are various ways to define these:
  ```yaml
  tags: tag1, tag2 # with commas
  tags: tag1 tag2 # with spaces
  tags: "#tag1 #tag2" # with pound signs and quotes (with auto completion)
  tags: # as a list
  - tag1
  - tag2
  tags: # as a list with pound signs and quotes
  - "#tag1"
  - "#tag2"
  ```

For specific use cases, like [[^Library/Std/Infrastructure/Page Templates]] or [[^Library/Std/Infrastructure/Slash Templates]], frontmatter may have specific meaning.


================================================
FILE: website/Full Text Search.md
================================================
While, honestly not amazing, SilverBullet does have full-text search support.

To use it, run the ${widgets.commandButton("Search Space")} command.

================================================
FILE: website/Funding.md
================================================
While SilverBullet is free to use, it is not free to develop. A lot of time is poured into evolving and maintaining the product: building new features, reviewing contributions, and fixing bugs.

If you are a SilverBullet user, consider sponsoring the project financially. You likely already pay for other (non-open source) software, why not SilverBullet as well?

# Why?
While the project is largely a labor of love, and a lot of volunteers contribute significantly, it does benefit from funding. The more funding, the more time can be spent to make this product as good as it can be and to recoup some of its costs.

# How?
There are a few ways to financially support the project at the moment, ordered by preference:

1. [LiberaPay](https://liberapay.com/zef) (preferred)
2. [Github sponsors](https://github.com/sponsors/silverbulletmd)
3. [Patreon](https://www.patreon.com/cw/silverbulletmd)
4. [PayPal](https://paypal.me/zefhemel)

If you are interested in becoming a corporate sponsor, reach out to [Zef Hemel directly](mailto:zef@zef.me) to discuss options.

# What?
There are two costs of the project right now:
1. Infrastructure cost to run SilverBullet websites ([silverbullet.md](https://silverbullet.md) and [community.silverbullet.md](https://community.silverbullet.md), primarily)
2. [[Zef]]’s time for development and maintenance of the product

Infrastructure costs are relatively low, but [[Zef]]’s time is not. He has a family to support, and needs to reasonably share his time between SilverBullet and other activities that function as a source of income. The more funding coming in for SilverBullet, the more of his time can be dedicated to SilverBullet, the better the product becomes. It’s as simple as that.


================================================
FILE: website/HTTP API.md
================================================
The server API is relatively small. The client primarily communicates with the server for file “CRUD” (Create, Read, Update, Delete) style operations.

All API requests from the client will always set the `X-Sync-Mode` request header set to `true`. The server _will_ use this fact to distinguish between requests coming from the client and regular e.g. `GET` requests from the browser (through navigation) and redirect appropriately (for instance to the UI URL associated with a specific `.md` file).

# File system
The space file system is exposed under the `/.fs` prefix:

* `GET /.fs` will return a full listing of all files in your space including metadata like when the file was last modified, as well as permissions in JSON format. This is primarily used for sync purposes with the client.
* `GET /.fs/*`: _Reads_ and returns the content of the file at the given path. This means that if you `GET /.fs/index.md` you will receive the content of your `index` page. If the optional `X-Get-Meta` _request header_ is set, the server does not _need to_ return the body of the file (but it can). The `GET` _response_ will have a few additional SB-specific headers:
  * (optional) `X-Last-Modified` the last modified time of the file as a UNIX timestamp in ms since the epoch (as coming from `Data.now()`). This timestamp _has_ to match the `lastModified` listed for this file in `/.fs` otherwise syncing issues may occur. When this header is missing, frequent polling-based sync will be disabled for this file.
  * (optional) `X-Created` the created time of the file as a UNIX timestamp in ms since the epoch (as coming from `Data.now()`).
  * (optional) `X-Permission`: either `rw` or `ro` which will change whether the editor opens in read-only or edit mode. When missing, `ro` is assumed.
  * (optional) `X-Content-Length`: which will be the same as `Content-Length` except if the request was sent with a `X-Get-Meta` header and the body is not returned (then `Content-Length` will be `0` and `X-Content-Length` will be the size of the file)
* `PUT /.fs/*`: The same as `GET` except that it takes the body of the request and _writes_ it to a file.
* `DELETE /.fs/*`: Again the same, except this will _delete_ the given file.

# RPC
Some functionality is exposed as RPC calls

* `GET /.ping`: Returns 200 if the server is available
* `POST /.shell`: Run a shell command on the server side and return the result
* `* /.proxy/<uri>`: Proxy a HTTP request (to avoid CORS issues) 

# Client
* `GET /.config`: Retrieve client configuration, JSON with the following keys:
  * `readOnly`: Run the client in read-only mode
  * `spaceFolderPath`: Path of the space (used to prefix client database names to support switching space folders)
  * `indexPage`: name of the index page
* `GET /.client/*`: Retrieve files implementing the client
* `GET /*` (every other path) will serve the HTML of the SilverBullet UI


================================================
FILE: website/Index Page.md
================================================
The index page is the [[Page|page]] that opens by default when either clicking the “home” button in the [[Top Bar]], or when you visit your SilverBullet’s URL without a page name in the URL.

The default index page’s name is `index`, but this is configurable via [[Install/Configuration]]’s `SB_INDEX_PAGE` variable.

# How to set a dynamic index page
Some people want to set their index page to be e.g. their daily note, and the daily note changes every... day. 

Doing this can be achieved with some [[Space Lua]] magic (in the code below we’ve set this to the `lua` language in order not to actually activate this functionality, in your own copy be sure to use `space-lua` instead):

```lua
local function redirectToCustomIndex()
  if editor.getCurrentPage() == "index" then
    -- Change this to whatever page you want your index page to be
    editor.navigate("Daily/" .. date.today())
  end
end

-- Trigger the above function when the editor is first loaded
event.listen {
  name = "editor:init",
  run = redirectToCustomIndex
}

-- And also when any page is loaded (because it may be the index page)
event.listen {
  name = "editor:pageLoaded",
  run = redirectToCustomIndex
}
```

================================================
FILE: website/Install.md
================================================
Excited to use SilverBullet? Here are three ways for you to deploy it. Also have a look at the note on [[#File systems]] at the end.

# localhost (desktop, laptop)
While this is not an ideal deployment (it limits accessing your space to _just your own machine_), it is an easy way to get started: simply run the SilverBullet server on your own laptop or desktop.

Steps:
1. Install SilverBullet either as a single [[Install/Binary]] or run it as a [[Install/Docker]] container
2. Access it via `http://localhost:3000`

Is that working out for you? Great, then proceed to deploy SilverBullet _properly_ on a server so you can also access it from other devices (like your phone).

# Self hosted (server)
You can self host SilverBullet on essentially any Intel-compatible 64-bit or ARM 64-bit machine you have terminal access to. The server needs very limited resources (a few hundred megabytes of RAM is sufficient), so even a Raspberry Pi (with a 64-bit OS) is sufficient.

The main hurdle to overcome in deploying SilverBullet on a server is that _it requires [[TLS]]_.

Steps:
1. Login to your server of choice and install SilverBullet there as a single [[Install/Binary]] or as a [[Install/Docker]] container.
2. Decide on your [[TLS]] approach to access it.

Here are a few [community guides](https://community.silverbullet.md/c/guides/6) on how to setup SilverBullet in various (TLS-enabled) setups:

* [Cloudflare Zero Trust](https://community.silverbullet.md/t/use-silverbullet-with-cloudflare-zero-trust/3618): requires a (free) Cloudflare account and domain name, covers TLS and tunneling.
* [Tailscale](https://community.silverbullet.md/t/install-silverbullet-on-a-64-bit-debian-ubuntu-raspianos-internet-accessible-via-tailscale/48): requires a (free) Tailscale account, covers TLS and tunneling and VPN access.
* [Caddy and self-signed certificates](https://community.silverbullet.md/t/level-1-local-https-with-caddy-and-self-signed-certificates/3531): no accounts required, but requires manually accepting certificates.
* [Twingate](https://community.silverbullet.md/t/level-1-access-your-silverbullet-from-outside-your-home-network-using-twingate/3541): requires a Twingate account, covers TLS and tunneling.

# Cloud
While [[Self Hosted]] is the intended path, if this is too much hassle for you. There is a simpler option by using [PikaPods](https://www.pikapods.com/pods?run=silverbullet). For a small fee (about $1.50 per month and you get $5 in credit signing up, so the first months are _free_), you can run your instance there. PikaPods handles deployment, upgrades and backups and exposes SilverBullet securely via TLS.

PikaPods contribute a part of their revenue back to the projects they host, so it’s a source of [[Funding]] for SilverBullet itself.

# File systems
## Case insensitive file systems (Mac and Windows)
It is _highly discouraged_ to run SilverBullet (in real use) on a _case insensitive_ file system. SilverBullet assumes your file system is _case sensitive_ and acts accordingly.

> **note** Note
> This only applies to where the SilverBullet _server_ runs, **not** the clients you connect to it (via your browser).

While SilverBullet will run on a case insensitive file system, and everything may _generally_ seem fine, issues start to arise when:
* You edit pages via page names that do not match (case-wise) with their file names on disk.
* When you try to create or edit pages that already exist on disk with different casing.

If you’re deploying **Linux**, you’re likely using a file system that is case sensitive. This is the recommended deployment operating system.

**macOS**’s default file system (APFS) is _case insensitive_ by default as well. However, if you insist on running SilverBullet on macOS, you can create a new APFS Volume for which you enable case sensitivity. You can then use this new volume with SilverBullet.

**Windows**’ native NTFS file system is _case insensitive_ by default, [however there are apparently ways to mark specific folders as case sensitive](https://www.howtogeek.com/354220/how-to-enable-case-sensitive-folders-on-windows-10/). Still, this makes deploying SilverBullet on Windows _not recommended_.

## NAS, sync engines, experimental file systems
While using a NAS to store your space files should be fine, if you encounter issues (unreliable saving, sync issues) it is worth switching to a local file system to see if this resolves those issues. The same goes for synchronizing your files from your server elsewhere, e.g. using tools like SyncThing. They _should_ work fine, but if you run into [[Sync]] issues, disable everything, switch to a local file system and see if the issues persist to [[Troubleshooting]].

SilverBullet’s sync engine relies on reliably persisting **last modified timestamps** for your files, and those timestamps only being touched when actual changes to those file occur. If your file system (or NAS) does not persist these consistently, or you have some external sync process that updates them — this may lead to problems. 


================================================
FILE: website/Install/Binary.md
================================================
SilverBullet is distributed as a single server binary available for the following platforms:

* macOS (Intel and Apple Silicon) (platform name “darwin”)
* Linux (x86_64 and arm64) (note that this will run on Raspberry Pi as well, but requires a 64-bit Linux distribution)
* Windows (64-bit)

Steps:
1. [Download the binary for your platform from Github](https://github.com/silverbulletmd/silverbullet/releases).
2. Unzip the file in a convenient place
3. Run it from the command line (see below)

You need to create a folder to hold your [[Space|space]] files. This folder can be kept anywhere. You can then run `silverbullet` taking the folder path as an argument:

```bash
mkdir space
# Run SilverBullet on port 3000 (default)
./silverbullet -p3000 space
```

There are a bunch of [[Install/Configuration]] options you can pass as environment variables.

# MacOS security
SilverBullet binaries are not signed, therefore when you run `silverbullet` on a modern macOS, it will likely refuse to run it.

To work around this, go into your Mac’s System Settings. In the Privacy & Security section, make sure you allow applications from App Store & Known Developers. Once you’ve run `silverbullet` from the CLI already and failed, you’ll see it listed here with an option to override. Select that option and run it again from the CLI, you should now have an addition “Run anyway” button. Click it, authenticate and from this point all should be good.

# Upgrading
You can upgrade your SilverBullet install based on the version you’d like to run.

To upgrade to the latest **stable** release:

```bash
./silverbullet upgrade
```

To upgrade to the latest **edge** release (which keeps in sync with the `main` development branch):

```bash
./silverbullet upgrade-edge
```

To upgrade your client, be sure to refresh your page _twice_ somewhat slowly.

# Version
To check which version of SilverBullet you’re running, run the ${widgets.commandButton("Client: Version")} command.


================================================
FILE: website/Install/Configuration.md
================================================
SilverBullet is primarily configured via environment variables. This page gives a comprehensive overview of all configuration options. You can set these ad-hoc when running the SilverBullet server, or e.g. in your [[Install/Docker|docker-compose file]].

# General configuration

* `SB_INDEX_PAGE`: Sets the default page to load, defaults to `index`.
* `SB_SPACE_IGNORE`: Ignore certain path patterns based on a .gitignore style format, e.g. `SB_SPACE_IGNORE="IgnoreMe/*"`.
* `SB_HTTP_LOGGING`: Set to any value to enable HTTP logging
* `SB_LOG_PUSH`: Set to any value to ask clients to push their logs to the server (for debugging purposes)

# Network
* `SB_HOSTNAME`: Set to the hostname to bind to (defaults to `127.0.0.0`, set to `0.0.0.0` to accept outside connections for the local deno setup, defaults to `0.0.0.0` for docker)
* `SB_PORT`: Sets the port to listen to, e.g. `SB_PORT=1234`, default is `3000`
* `SB_URL_PREFIX`: Host SilverBullet on a particular URL prefix, e.g. `SB_URL_PREFIX=/notes`

# Authentication
SilverBullet supports basic authentication for a single user.

* `SB_USER`: Sets single-user credentials, e.g. `SB_USER=pete:1234` allows you to login with username “pete” and password “1234”.
* `SB_AUTH_TOKEN`: Enables `Authorization: Bearer <token>` style authentication on the [[HTTP API]].
* `SB_LOCKOUT_LIMIT`: Specifies the number of failed login attempt before locking the user out (for a `SB_LOCKOUT_TIME` specified amount of seconds), defaults to `10`
* `SB_LOCKOUT_TIME`: Specifies the amount of time (in seconds) a client will be blocked until attempting to log back in.
* `SB_REMEMBER_ME_HOURS`: Sets the session duration in hours when "Remember me" is checked during login, defaults to 7 days.

# Storage
SilverBullet supports storage backends for keeping your [[Space]] content. Right now the only supported backend is to use your local disk.

## Disk storage
This is the default and simplest backend to use: a folder on disk. It is configured as follows:

* `SB_FOLDER`: Sets the folder to expose. In the docker container, this defaults to `/space`.


# Run mode
* `SB_READ_ONLY` (==Experimental==): If you want to run the SilverBullet client and server in read-only mode (you get the full SilverBullet client, but all edit functionality and commands are disabled), you can do this by setting this environment variable to `true`. Upon the server start a full space index will happen, after which all write operations will be disabled.

# Security
SilverBullet enables plugs to run shell commands. This is potentially unsafe. If you don’t need this, you can disable this functionality:

* `SB_SHELL_BACKEND`: Enable/disable running of shell commands from plugs, defaults to `local` (enabled), set to `off` to disable. It is only enabled when using a local folder for [[#Storage]].
* `SB_SHELL_WHITELIST`: Allow only a specific list of shell commands (just the first command name, not arguments). When not set, allows all shell commands. Example: `SB_SHELL_WHITELIST="git pandoc"`

# Docker
Configuration only relevant to docker deployments:

* `PUID`: Runs the server process with the specified UID (default: whatever user owns the `/space` mapped folder)
* `PGID`: Runs the server process with the specified GID (default: whatever group owns the `/space` mapped folder)\

# Metrics
SilverBullet offers a few basic Prometheus metrics, these can be configured with the following environment variables:

* `SB_METRICS_PORT`: HTTP port to expose metrics (under the default `/metrics` endpoint) on

# Web app manifest
Configure aspects of web app appearance as well as the authentication page:

* `SB_NAME`: Sets `name` and `short_name` members of web app manifest to whatever specified in `SB_NAME`
* `SB_DESCRIPTION`: Sets `description` member of web app manifest to whatever specified in `SB_DESCRIPTION`


================================================
FILE: website/Install/Docker.md
================================================
[Docker](https://www.docker.com/) is a convenient and secure way to install server applications either locally or on a server you control. If you don’t have docker already running on your machine and are macOS user, consider giving [OrbStack](https://orbstack.dev/) a try — it’s a super nice docker experience.

Conveniently, SilverBullet is published as a [docker image on GHCR](https://github.com/silverbulletmd/silverbullet/pkgs/container/silverbullet). The image comes in two flavors:

* 64-bit Intel
* 64-bit ARM (e.g. for Raspberry Pis and Apple Silicon macs)

There is no 32-bit version of SilverBullet. Most people run 64-bit OSes these days, an exception may be Raspberry Pis. Recent (RPI 3 and later) can run 64-bit Linux as well.

> **warning** Warning
> To access SilverBullet outside of `localhost` you will need to set up [[TLS]].

# Release channels
Every release version of SilverBullet is tagged with its version number, but there are two release channels you can use (they automatically update):

* `:latest` always points to the latest _release_
* `:v2` always points to the latest _edge build_ (the last commit to `main`), use this if you want to live on the bleeding edge.

# Container
* The container binds to port `3000`, so be sure to port-map that, e.g. via `-p 3000:3000` (note: the first `3000` is the external port)
* By default SilverBullet runs _unauthenticated_, this is not safe at it allows anybody on your network to access your instance freely. Therefore, in a docker setup **always** set the `SB_USER=username:password` environment variable (see below).
* The container uses whatever is volume-mapped to `/space` as the space root folder. You can connect a docker volume, or a host folder to this, e.g. `-v /home/myuser/space:/space`
* SilverBullet will detect the UNIX owner (UID and GID) of the folder mapped into `/space` and run the server process with the same UID and GID so that permissions will just magically work. If you’d like to override this UID, set the `PUID` and `PGID` environment variables (see [[Install/Configuration]] for details).
* The Docker image is based on [Alpine](https://alpinelinux.org/). If you'd like to install additional packages into it, see [[#Installing additional packages]] below.

> **note** Note
> The same docker images are both available from [GHCR](https://github.com/silverbulletmd/silverbullet/pkgs/container/silverbullet) and [Docker Hub](https://hub.docker.com/r/zefhemel/silverbullet), use whichever you prefer.

## Versions
To check the version you’re running, use the ${widgets.commandButton("Client: Version")} command. Note that after an upgrade you may have to reload your (browser) client **twice**, to fully activate the new version.

# Docker Compose
[Docker compose](https://docs.docker.com/compose/) is a simple tool to manage running of multiple containers on a server you control, it is distributed with (modern) versions of docker. It is the recommended way to manage SilverBullet.

Below is a basic `compose.yml` that runs SilverBullet, check [[Install/Configuration]] for additional configuration options.

Instructions:
* Replace the password defined in `SB_USER` with something sensible such as `admin:b3stp4ssword3vah`
* This volume uses the `./space` directory (will be auto-created if it doesn’t already exist) in the same directory as the `compose.yml` file as the place where SB will keep its space. You may replace this with whatever location you keep your notes.

```yaml
services:
  silverbullet:
    image: ghcr.io/silverbulletmd/silverbullet:latest
    restart: unless-stopped
    environment:
    - SB_USER=admin:password
    volumes:
      - ./space:/space
    ports:
      - 3000:3000
```

Boot this up via:

```shell
docker compose up -d
```

And watch for logs with:

```shell
docker compose logs -f
```

## Upgrading
To upgrade, change the specific version you point to in your `compose.yml` file (not necessary when using `:latest` or `:v2`) and then:

```shell
docker compose pull
docker compose stop
docker compose up -d
```

# Plain Docker
If you don’t want to use docker compose, you can run SilverBullet “raw” as follows:

```shell
# Create a local folder "space" to keep files in
mkdir -p space
# Run the docker container in the background
docker run -d --restart unless-stopped \
  --name silverbullet \
  -p 3000:3000 \
  -v ./space:/space \
  -e SB_USER=user:password \
  ghcr.io/silverbulletmd/silverbullet:latest
```

There you go!

## Upgrades
To upgrade your version of SilverBullet, first pull the new image:

```shell
docker pull ghcr.io/silverbulletmd/silverbullet:latest
# Then stop the current container
docker stop silverbullet
# Remove the existing container
docker rm silverbullet
```

Then start the container again as before.

# Installing additional packages
If you would like to install additional packages into your docker container (e.g. to call via [[API/shell]]), you can do so by creating a [[CONTAINER_BOOT]] page in your space. Whatever you put in this page, will be run as a bash script upon container boot (hence its name).

In practice, you’ll likely want to put `apk add` commands install the (Alpine) packages you would like to install.


================================================
FILE: website/Install/Network and Internet.md
================================================
Running SilverBullet locally on your machine is nice, but you likely want to access it from elsewhere as well (other machines on your network, your mobile device), perhaps even from outside your home. 

> **note** IMPORTANT
> To use SilverBullet from anywhere other than localhost, you have to set up [[TLS]]. SilverBullet does not work (other than via localhost) on URLs other than `https://` ones.

For this, be sure to enable [[Authentication]].

There’s two parts to this process:
1. Run the SilverBullet server itself somewhere, following the [[Install]] instructions
2. Exposing this server to the network/Internet via [[TLS]]


================================================
FILE: website/Keyboard Shortcuts.md
================================================
SilverBullet uses [CodeMirror](https://codemirror.net) under the hood, which does the majority of the keyboard handling. In addition, it allows to for customization of (most) keys on a per-command basis. When you open the [[Command Palette]], you will see the currently assigned keyboard shortcut(s).

# Modifier keys
Your keyboard shortcut will typically consist of a combination of modifier keys as well as a letter or (special) character or number. The following modifier keys are supported:

* `Alt`
* `Ctrl`
* `Shift`
* `Cmd`: Command key (Mac only)
* `Meta`: usually the “Windows” key
* `Mod`: An alias for `Ctrl` on Linux and Windows, to `Cmd` on Mac

The keyboard shortcut is then combined to e.g. `Mod-Alt-t`.

# Constraints
SilverBullet is constrained in what keyboard can be used in a few ways:
* By the browser: you cannot (usually) override browser keyboard shortcuts, or at least shouldn’t (e.g. `Cmd-t` or `Ctrl-t` for opening a new tab)
* By already used keyboard shortcuts, see [[#Disabling keyboard shortcuts]] below to disable keyboard shortcuts for existing commands.
* By CodeMirror’s restrictions. There a few, here are some we know about:
  * `Alt-letter` is not allowed (e.g. `Alt-a`), because it clashes with various (international) keyboards

# Combos
Generally keyboard shortcuts are single stroke, however it possible to configure them as “combos” (multiple shortcuts entered in sequence). Out of the box, SilverBullet reserves two prefix keys for this “combo” use:

* `Mod-.` (so “command dot” or Mac and “Ctrl dot” on Linux/Windows), used for various [[Outlines]] related commands
* `Ctrl-q` used and reserved for [[Page Template]] use cases
  * `Ctrl-q q` creates a [[^Library/Std/Page Templates/Quick Note]]
  * `Ctrl-q t` runs the `Page: From Template` command
* `Ctrl-g` reserved for navigational commands:
  * `Ctrl-g h` navigates to the index (home) page

# Assigning keyboard shortcuts
Keyboard shortcuts can be assigned to [[Command|Commands]] with `key` or `mac`. If only `key` is defined, this keyboard shortcut will be used for all operating systems, if `mac` is set, this keyboard will be used for macOS.

To (re)assign a keyboard shortcut for an _existing_ command, you can use the [[^Library/Std/APIs/Command#command.update(commandDef)]] API as follows:
```lua
command.update {
  name = "Task: Cycle State",
  key = "Ctrl-Shift-t",
}
```

When you define your own command, you can simply make them part of your command definition:

```lua
command.define {
  name = "My command",
  key = "Ctrl-Shift-t",
  run = function()
    editor.flashNotification "Hello world "
  end
}
```

You can also specify multiple keyboard shortcuts by passing in a table to either `key` or `mac`:

```lua
command.define {
  name = "My command",
  key = {"Ctrl-Shift-t", "Ctrl-Shift-o"},
  run = function()
    editor.flashNotification "Hello world "
  end
}
```

# Disabling keyboard shortcuts
You can use the [[^Library/Std/APIs/Command#command.update(commandDef)]] API to disable keyboard shortcuts of existing commands by setting them to an empty string, for example:

```lua
command.update {
  name = "Task: Cycle State",
  key = "",
  mac = ""
}
```


================================================
FILE: website/Knowledge Management.md
================================================
[Wikipedia](https://en.wikipedia.org/wiki/Personal_knowledge_management):

> Personal knowledge management (PKM) is a process of collecting information that a person uses to gather, classify, store, search, retrieve and share knowledge in their daily activities and the way in which these processes support work activities. It is a response to the idea that knowledge workers need to be responsible for their own growth and learning. It is a bottom-up approach to knowledge management.

There you go.

================================================
FILE: website/LLM Use.md
================================================
**TL;DR:** Please do not (heavily) use LLM-based coding tools to contribute code to the [main SilverBullet code base](https://github.com/silverbulletmd/silverbullet). Also don’t heavily rely on LLMs to contribute to issues or community discussions (except for language support, if you need it).

The topic of how to deal with (largely) LLM generated contributions comes up from time to time in SilverBullet. Different open source project take different approaches: some allow them because “it’s the future,” some [decidedly do not](https://ziglang.org/code-of-conduct/). After much deliberation, I decided that for now I’ll put SilverBullet in the latter camp, as in: no thank you. 

There are two main reasons for this, the first is _moral_, a second more _practical_.

# The moral reason
I have been open sourcing code for over 25 years. My goals for doing so include helping people solve similar problems that I have, enjoy and benefit from the software I build, to allow them to adapt, improve, change and learn from it, and to build community. I have always distributed code under the most liberal of licenses (usually MIT), and have been lucky enough that those licenses were not exploited. Until recently, when LLMs came around.

Specifically _not_ on my list of goals was allowing companies to take my code (or content in general) as input to a _ridiculously_ resource-intensive (money, energy, cheap labor) algorithm, and have the result be sold back to me in the form of tokens. Yet, here we are. And while some may have the sentiment that this is fine, and perhaps even legal, I’m not too happy about it.

There are various areas in my life where I have to make more pragmatic choices, but in my open source work I feel I can afford to take a stand.

Therefore, the first reason to ask you not to contribute to SilverBullet using AI tools is a **moral** one: these tools are built by scraping all text (natural and code) LLM companies can get their hands on (often in sketchy ways), use scarce resources to train and operate them, and disproportionally benefit companies with highly questionable morals. This is not a cause I am interested in associating or supporting in my leisure time.

# The practical reason
The second reason is more practical. While there is a bubble of people that claim that the quality of the work produced by LLMs is amazing, this has not been my experience based on using these tools myself and reviewing the work of these tools done by others. Too often, it is _sloppy_.

From time to time people “contribute” to issues or the community forums with largely LLM generated text. At best it’s just a lot of text easily detected as LLM slop and ignored, at worst it contains inaccurate information that simply detracts the conversation or results in investing time in things that are simply hallucinated. This is not helpful.

In the context of code contributions, while sloppy work is not unique to LLMs, the pace and ease in which it produces it definitely is. If I would have enough faith in LLMs to either feel I do not have to do code reviews, or I can delegate all reviews to LLMs — this could _hypothetically_ be ok. However, again, this is not my experience. The work produced by LLMs if often bad, and I have little reason to believe that an LLM code review would detect it.

This means that _I still have to review everything myself._

There is an important difference in reviewing code produced by an LLM and a human.

With humans, even in an open source setting, you build some level of relationship over time. You build trust. In the SilverBullet project I have a few people where I’ve seen their work, I know they can be trusted to do great, and I don’t have to zoom in on every single change. This is great, and is one of the reasons why a project with the scope size of SilverBullet can still be run by a single person. When I invest time in giving a detailed code review, I think _learning_ can happen. The person on the other end, or so I would like to believe, considers things they hadn’t considered before, learns more about the internal workings of SilverBullet, or potentially about how to write code in general.

This is not the case with an LLM. Sure, in “best practice” scenarios, the LLM output is first reviewed by a skilled human before being submitted for a code review. My lived experience (also observing my own behavior) is that people get lazy quickly. I have had a period in which I was more trusting of AI coding agents and let them go rampant on the SilverBullet code base. I’m still cleaning up the technical debt and mess that it produced in just a few months, _and I completely missed it at the time_. If I cannot trust _myself_ to do this — and I definitely have an incentive — how can I trust random _others_ to do this?

This means that I have to review every single line of LLM produced code and never get lazy. I have seen cases where a _comment_ produced by the LLM “documented” a certain behavior, and implemented the opposite. I’ve seen LLMs make mistakes a human could not conceivably make. I’ve seen LLMs making code more “robust” by simply littering it with null checks rather than reasoning about why those may actually happen. The level of attention I have to pay in an LLM generated pull request is _higher_ than when it is produced by a human. There is often a disorienting big discrepancy between how it _looks_ and what it actually _is_.

The ease and pace at which an AI agent produces a full pull request with meticulous sounding description and thousands of lines of code added (many of which are “tests”) is impressive. However, because of reasons listed before, now _my part starts_. I now have to go through this code for _hours or even days_ that only took _minutes_ to produce. 

This is not a fair game, and I’m not here for it. I’m sure your intention is good, but it’s often simply counter productive.

# Enforcement
I’m not the type of person that is interested in strict rules and policing. My approach is always to focus on explaining the _why_ and giving context so that people can draw their own conclusions.

If I suspect that your contribution is AI generated, I will likely close it pointing to this policy. I may get it wrong sometimes, I’m sorry about that. I’m only human, consider being the same.

# What this means for you
So, is the target audience of SilverBullet just #NoAI people, then? No. _Especially_ a tool like SilverBullet is all about self-expression: your notes, your thoughts, your system, your crazy experiments. I know that people use LLMs to write [[Space Lua]] scripts, and I’m happy for them. I know that LLMs help people do things that they could not do before, and even learn things in the process. That’s great. Open source is all about freedom. Do with it what you like, use it as you like. There is a solid [SilverBullet AI library](https://github.com/justyns/silverbullet-ai) if you’re interested.

This policy only applies to the SilverBullet code base itself and its community. It is the code that I feel I should be able to understand and own, and the conversations that I decide to spend my time on contributing to. I have to make decisions that keep the project going and sustainable long term, and excessive use of AI tools in contributions puts that at risk.

I hope that makes sense.

— [[Zef]]


================================================
FILE: website/Library Manager.md
================================================
#maturity/beta

The library manager makes it easy to manage (install, update, remove) [[Library]].

The library is accessed via the ${widgets.commandButton "Library: Manager"} command.

================================================
FILE: website/Library.md
================================================
#maturity/beta

SilverBullet is designed to be [[Extensions|extensible]]. In fact, a lot of functionality in SilverBullet is implemented through [[Space Lua]] embedded in [[Meta Page]], other functionality is implemented using [[Plugs]]. 

A big part of the fun of SilverBullet is building your own extensions, big or small. Libraries are the way to [[Share]] those extensions. Both for you to [[Library/Development|publish your own]] libraries, and to pull in other people’s creations.

# Terms
Let’s define terms:

The [[Library]] is the **unit of distribution**, typically implemented as [[Meta Page]]. Most libraries will be implemented in a single page (e.g. with a big [[Space Lua]] block), but more elaborate libraries may span multiple pages, some may wrap [[Plugs]].

A [[Repository]] is a **discovery and curation mechanism** for libraries. They are collections of _pointers_ of _where_ to find libraries to install. SilverBullet ships with the [[^Repositories/Std]] repository, but you can install additional ones.

[[Share]] is the general-purpose mechanism built into SilverBullet to both _push_, _pull_ and _sync_ content in your space with the outside world. In the context of Libraries this mechanism is used (partially under the hood, but you’ll recognize traces of it) both for you to _publish_ your libraries, as well as to _install_ and _update_ other people’s creations.

To make discovery and installation of libraries easier, SilverBullet includes a basic [[Library Manager]].

By convention, libraries are kept under the `Library/` prefix (folder) in your space.

# What’s in a library
Here are some things that a library may provide:
* Various [[Slash Command|Slash Commands]], such as `/today`, `/task`, `/table`.
* Useful [[Page Template|Page Templates]]
* Useful [[^Library/Std/Widgets/Widgets|Widgets]]
* Useful pages that help you perform maintenance on your space, like detecting broken links, such as [[^Library/Std/Pages/Maintenance]].
* Additional functionality with compiled [[Plugs]]

# Can I change libraries locally?
Libraries you manually installed can be freely changed. Do note that when you attempt to update them, your local changes may be overwritten.

The [[^Library/Std]] library comes “baked in” with SilverBullet and is read only. You cannot change it. However, for most things there are ways to override or disable standard behavior, check [[Space Lua#Load order]] for some hints.

# How do I develop my own library?
See [[Library/Development]]


================================================
FILE: website/Library/Development.md
================================================
To develop your own SilverBullet library follow these steps:

# Decorate your library with frontmatter
Put all functionality you like in a [[Meta Page|Meta Page]] somewhere under `Library/`. For name spacing purposes it’s good form to put your (Github) username in the path as well, so create, for instance `Library/myuser/My Library`.

Then, decorate your library page with some [[Frontmatter]]. Here is an minimal example library that defines a new “Hello world” command:

    ---
    name: Library/myuser/My Library
    tags: meta/library
    ---
    This implements my super awesome hello world library!
    
    ```space-lua
    command.define {
      name = "Hello world",
      run = function()
        editor.flashNotification "Hello world!"
      end
    }
    ```

> **note** Important
> The `name` key _has_ to match your page’s full (path) name. Otherwise a validation error will appear on the frontmatter. When another user installs your library, they will install it to the location specified here.

If you want to distribute additional pages or files with the library (such as `.plug.js` files) make sure that they are kept at the same folder level as your library page. For instance if your library page is `Library/myuser/My Library` you can have a `Library/myuser/myplug.plug.js` and then mark it for distribution by adding it to the `files` key in your frontmatter, e.g.:

```
---
name: Library/myuser/My Library
tags: meta/library
files:
- myplug.plug.js
---
```

# Share the library
At this stage you can put your library file anywhere you like with a URL. However, for convenience, it’s good to use [[Share]] support for publishing.

For the purposes of this example, let’s use Github. Create a [[^Library/Std/Infrastructure/Github]] token and configure it as specified in the [[^Library/Std/Infrastructure/Github#Configuration|instructions]].

[[^Library/Std/Infrastructure/Github]] share support offers two options: Gists and Github repo files. Let’s use a Github repo.

[Create a new github repo](https://github.com/new), you can name it something like `silverbullet-libraries`.

In your library to be shared, run the ${widgets.commandButton "Share: Page"} command, then select “Github file”. This will ask for a few things:
1. The repo name: enter your `user/repo` here
2. The branch name: you can keep this as `main`
3. The file name: while this defaults to using your full page, remove that and just name it e.g. `My Library.md`
4. A commit message

If all went well, an initial version of your page should now be uploaded to github, check your repo page.

You’ll notice that a few [[Share]] related frontmatter keys were set in your local copy. You can now make further changes to your library and run the ${widgets.commandButton "Share: Page"} command again. After entering another commit message your page will be pushed again. This is how you publish new versions of your library for other people to use.

If you used `files` to include additional assets, you have to commit those to the repository through other means.

Your library is now ready to install, you can test this by running ${widgets.commandButton "Library: Install"} in a fresh space. As URI use the value of what is generated under `share.uri` in your library’s frontmatter. 

You can now [broadcast that your library is ready to install](https://community.silverbullet.md/c/plugs-libraries/14)!

# Create a repository
If you develop a few libraries, it may be good to make them more discoverable. To do so, you can group them in a [[Repository|Repository]].

For this, create another page in your space, this time under `Repository/`, e.g. `Repository/myuser` and tag it with `#meta/repository`. 

In the page body, put a list of all your libraries, encoded as [[Object#data|data objects]] as follows:

    ---
    tags: meta/repository
    ---
    This is my curated list of awesome libraries!
    
    ```#meta/library/remote
    name: Library1
    description: This is my first awesome library
    website: http://url.com/to/docs
    uri: https://github.com/myuser/silverbullet-libraries/blob/main/Library1.md
    ---
    name: Library2
    description: This is my second awesome library
    uri: https://github.com/myuser/silverbullet-libraries/blob/main/Library2.md
    ```

Required keys are:
* `name`: a descriptive name of your library.
  **Note**: this name is _not_ used as an installation location, the installation location is determined by the `name` key in the library itself.
* `uri`: The URI to install the library from.

Additional recommended keys are:
* `description`: a more elaborate description of the library and its functionality
* `author`: who developed this library
* `website`: a website URL for more information

Now share this repo page using [[Share]] to the same github repo (or any other, it doesn’t really matter), and call it `REPO.md` (by convention).

Then announce to the [community](https://community.silverbullet.md/c/plugs-libraries/14) that your repo is open for business and they can add it via the ${widgets.commandButton "Library: Add Repository"} command (using the same `share.uri` set in your frontmatter).


================================================
FILE: website/Library/Website Templates.md
================================================
#meta

A few templates used on the https://silverbullet.md website.

```space-lua
templates.featureItem = template.new[==[
    * [[${name}]]: ${description}
]==]
```

================================================
FILE: website/Library/Website.md
================================================
#meta

Some silverbullet.md specific widgets etc.

```space-lua
event.listen {
  name = "hooks:renderTopWidgets",
  run = function(e)
    local meta = editor.getCurrentPageMeta()
    if not meta then
      return
    end
    local maturityTag = nil
    for _, tagName in ipairs(meta.tags) do
      if tagName:startsWith("maturity/") then
        maturityTag = tagName
      end
    end
    if maturityTag then
      return widget.new {
        markdown = spacelua.interpolate([==[
**Note:** This is a #${maturityTag} feature. Feel free to use it, but it may change (significantly) in the future or potentially be replaced.
]==], {maturityTag=maturityTag}),
        cssClasses = {"website-warning"},
        display = "block"
      }
    end
  end
}
```

```space-style
.website-warning {
  background-color: #fff1d8;
  padding: 10px;
  margin: 0px !important;
}
```


================================================
FILE: website/Link.md
================================================
In SilverBullet, you can create links to pages or documents inside your space as well as to external resources, using different link formats.

# External links
- _Markdown links_, using the `[title](URL)` syntax, for instance: [SilverBullet community](https://community.silverbullet.md). If the URL contains a space or closing parenthesis, you can enclose the URL in angled brackets.
* _"[AutoLinks](https://spec.commonmark.org/current/#autolinks)"_, like <https://community.silverbullet.md>
* _“Naked” URL links_ (AKA _"Bare URLs"_), like https://community.silverbullet.md

# Internal links
- _Relative internal links_, using the `[title](<relative specifier><link>)` format. The _relative specifier_ can be used to move up the file tree (or down, if you picture a file tree like this 🌳) relative to the page the link is on, similar to POSIX file systems `/../`. The _link_ has to conform to the [[#Link syntax (String refs)|link syntax]]
* _Absolute internal links_ (also called wikilinks) using the `[[<link>]]` syntax. Again the _link_ has to conform to the link syntax. A alias can be added like this `[[<link>|This link points to <link>]]`

# Link syntax (String refs)
The link or “string refs” has to follow some specific formatting:
* A string ref starting with `^` links to a meta page, see [[#Caret page links|caret page links]]
* The "core" (After a possible `^` and before a `@` or `#`) of a string ref is a [[Names|name]] or [[Paths|path]].
  * The core of a  string ref can also be empty, an empty path points to the current page for links, and to the index page for `editor.navigate`
* A string ref can end three ways:
  * `#` followed by a string (which can also contain `#`). This will point to the header equal to that string. (Notably you can't link to every possible header, because e.g. `]]` will restrict you inside a wikilink for example.), e.g. [[SilverBullet#Introduction]]
  - `@` followed by an upper or lowercase `L` followed by a number, optionally followed by an upper or lowercase `C` followed by a number (e.g. `@l12c13`). This will point to the corresponding line and column inside a page (both 1-based), e.g. [[CHANGELOG@L12c42]]
  - `@` followed by a number _n_. This will point to the _nth_ character in the page (0-based), e.g. [[CHANGELOG@123]]

# Caret page links
[[Meta Page]] are excluded from link auto complete in many contexts. However, you may still want to reference a meta page outside of a “meta context.” To make it easier to reference, you can use the caret syntax: `[[^Library/Std]]`. Semantically this has the same meaning as `[[LIbrary/Std]]`. The only difference is that auto complete will _only_ complete meta pages.


================================================
FILE: website/Linked Mention.md
================================================
On this site you see a section “Linked Mentions” appear along the bottom of every page that has incoming links. 👇🏻 This section lists all pages that contain a _link to_ the current page. This allows for easy navigation, and may help you discover linked concepts through these links.

More details: [[^Library/Std/Widgets/Widgets]]

================================================
FILE: website/Linked Tasks.md
================================================
It is a common SilverBullet pattern to give people, groups of people and places dedicated pages so you can easily reference them. It then becomes very convenient to mention these pages elsewhere in your space, and using [[Linked Mention]] you can easily find what _pages_ link back to that page.

The equivalent of this idea the context of [[Task]] is Linked Tasks. These are tasks that _mention_ a particular page.

# Example workflow
Let’s say you keep notes of 1-on-1 meetings with people. You have a page for each person where you keep notes for each of your sessions. As you talk to person _A_, something comes up that you need to discuss with person _B_. B _also_ has their own page. What you can do is simply create a task on your note page with _A_ (so you don’t have to context switch) that includes a [[Link|link]] to _B_.

# Example
Below are two tasks that mention this very page (this is not the general pattern, just for demonstration purposes). Note that the not completed task is listed at the top of this page as well in the “Linked Tasks” widget.

* [ ] [[Linked Tasks]] This is a task
* [x] [[Linked Tasks]] This is a completed task

**Implementation:** [[^Library/Std/Widgets/Widgets]]

================================================
FILE: website/Live Preview.md
================================================
SilverBullet uses a “live preview” markdown editor. This mechanism is heavily inspired by [Obsidian’s live preview mode](https://help.obsidian.md/Live+preview+update).

It reduces visual noise by not constantly showing [[Markdown]] codes such as `[SilverBullet website](https://silverbullet.md)`, only showing the underlying Markdown formatting code when the cursor is placed inside.

In SilverBullet, you can always see the underlying format by moving your cursor “inside.” **Alt-clicking** on any piece of formatted text will move your cursor there and reveal the underlying (markdown) code, or you can reveal it permanently with the `Toggle Markdown Syntax Rendering` command.


================================================
FILE: website/Local First.md
================================================
---
description: SilverBullet synchronizes all your content locally, allowing full access even when not connected to (reliable) Internet.
awesomeness: 7
tags: feature
---

From the original [Local First](https://www.inkandswitch.com/essay/local-first/) article:

> Local first is a set of principles for software that enables both collaboration *and* ownership for users. Local-first ideals include the ability to work offline and collaborate across multiple devices, while also improving the security, privacy, long-term preservation, and user control of data.



================================================
FILE: website/Log.md
================================================
To better understand what SilverBullet is doing, especially when [[Troubleshooting]], it is good to know where and how to look at SilverBullet logs.

Most valuable logs are likely going to be in your browser’s logs, so especially if you’re a power user, get comfortable opening your JavaScript console.

* Client logs: Check your browser’s JavaScript console. Usually these are part of “(Web) Developer Tools” buried somewhere in a Tools or Developer menu.
* Service worker logs (especially useful when debugging [[Sync]] issues):
  * In Chromium-based browsers these logs are mixed with the regular JavaScript console.
  * In Firefox start at `about:debugging#/runtime/this-firefox` then find it under the Service Worker section and click “Inspect”.
  * In Safari they can be accessed via `Developer > Service workers`.
* Server logs: Server logs are written to the standard output of the server process.
* Reverse proxy logs: if you use some sort of reverse proxy, check those as well. Reverse proxies may do some surprising stuff, like block certain requests.


================================================
FILE: website/Lua.md
================================================
[Lua](https://www.lua.org/) is the programming language that SilverBullet leverages to make it extensible. It has a custom implementation of a Lua dialect called [[Space Lua]].

================================================
FILE: website/Manual.md
================================================
Welcome to the wonderful world of SilverBullet. The goal of this manual is to give you a broad sense of how to use this tool and what it’s capable of. However, its full capabilities are yet to be discovered. You too may find new and creative ways to use the various SilverBullet features in ways nobody previously thought of.

However, that is all unlikely to happen unless you understand what SilverBullet can actually do. So let’s give you a bit of a sense.

# Videos
${embed.youtube "https://youtu.be/bb1USz_cEBY"}
${embed.youtube "https://youtu.be/7hyLvEfw34w"}
${embed.youtube "https://youtu.be/bZ79-RbyNoU"}
${embed.youtube "https://www.youtube.com/watch?v=Of7zE0AVApc"}
${embed.youtube "https://www.youtube.com/watch?v=cH9cs8fowhY"}
${embed.youtube "https://youtu.be/qkLJ3viVq8I"}

# Keeping up-to-date
* [[CHANGELOG]]: what’s new in SilverBullet? This page will give you the latest details. It’s worth monitoring this page.

# Installation and deployment
The biggest hurdle to get over with SilverBullet is that you need to get this thing running. And as of yet, the only way to do that is to install and deploy it yourself. 🤷

* [[Install]]: Installation instructions for various setups
* [[TLS]]: the part where we walk through options to expose SilverBullet via HTTPS.

For additional guides, check out [our community guides](https://community.silverbullet.md/c/guides/6).

# Core Concepts
These are the core concepts used in SilverBullet:

* [[Space]]
* [[Page]] (and [[Meta Page]])
  * [[Frontmatter]]
  * [[Names]]
* [[Folder]]
* [[Document]]

# User interface
SilverBullet’s UI is minimalist by design. Let’s look at the few UI elements and how to use them.

* The [[Top Bar]] contains:
  * [[Page Namer]]
  * [[Index Page]] (the 🏠 button)
  * [[Page Picker]]
  * [[Command Palette]]
* The main [[Editor]] component contains your currently selected page’s text

# Editing and Formatting
* Content is written using [[Markdown]] and rendered using [[Live Preview]]
  * Markdown [[Markdown/Basics]]
  * Markdown [[Markdown/Extensions]]
    * [[Link]]
    * [[Markdown/Admonition]]
    * [[Task]]
    * [[Markdown/Syntax Highlighting]]
    * [[Markdown/Fenced Code Block]]
* [[Slash Command]]
* [[Outlines]]

# Navigation
The main ways to roam your space, beside following page links, are:

* [[Page Picker]]
* [[Meta Picker]]
* [[Tag Picker]]
* [[Full Text Search]]

# Advanced topics
* [[Object]]
  * [[Frontmatter]]
  * [[Attribute]]
* [[Space Lua]]
* [[Library]]
* [[Space Style]]
* [[Troubleshooting]]

# Customization
* [[^Library/Std/Config]]
* [[Page Decorations]]
* [[Space Style]]

# Extending SilverBullet
SilverBullet’s implementation is built on three things:

* The core is implemented as a TypeScript-based web app
* A lot of core functionality is implemented in [[Plugs]].
* An increasing amount of additional functionality is now being implemented in “native” [[Space Lua]].

# Contributing

SilverBullet is free and open-source software. You can contribute to
it [via Github](https://github.com/silverbulletmd/silverbullet). For some details on how to do this, have a look
at [[Development]].


================================================
FILE: website/Markdown.md
================================================
Markdown is a plain text formatting system [originally developed by John Gruber](https://daringfireball.net/projects/markdown/). It has since been standardized into [CommonMark](https://commonmark.org/), which is what SilverBullet uses (with [[Markdown/Extensions]]). While a bit more technical than [WYSIWYG](https://pl.wikipedia.org/wiki/WYSIWYG)-style editing (like MS Word), the nice thing about markdown is that it is a relatively easy-to-learn format, and you can read files even without special tools (like SilverBullet). 

This means that _you will always have access to your content_ even if you switch tools. It also means that you can use multiple tools at the same time to edit these files. You don’t have to use SilverBullet exclusively.

There is a bit of a caveat here: Markdown is limited in certain ways, and various tools using markdown as the underlying file format (like SilverBullet) need features that are not directly supported by markdown. As a result, different tools introduce extensions to markdown that are not standard nor interoperable. While in some cases, these tools converge on particular pieces of syntax (such as the non-standard `[[page link]]` syntax), some level of divergence in the markdown that each tool supports is unavoidable. SilverBullet is complicit in this as well. It adds a few extensions that are not widely supported and assigns new meaning to certain markdown features to implement novel features. In its defense, all these features are optional. If you want to just write plain markdown, you can.

See [[Markdown/Extensions]] for more details on these SilverBullet-specific extensions.

More about markdown:

* [[Markdown/Basics]]: learn some the markdown basics
* [[Markdown/Extensions]]: learn about SilverBullet’s set of markdown extensions
* [[Markdown/Syntax Highlighting]]: languages that SilverBullet supports syntax highlighting for


================================================
FILE: website/Markdown/Admonition.md
================================================
#level/intermediate

Silverbullet supports [admonitions](https://github.com/community/community/discussions/16925) using GitHub syntax (`note` and `warning`).

> **note** This is a
> note admonition

> **warning** This is a
> warning admonition

> **danger** This is a
> danger admonition

> **success** This is a
> success admonition

Custom admonitions can be added in a [[Space Style]] using the following format:

```space-style
/* Replace the keyword with a word or phrase of your choice */
.sb-admonition[admonition="keyword" i] {
  .sb-admonition-type * { display: none; }
  .sb-admonition-type::before { 
    width: var(--admonition-width) !important;
  }

    /* The icon can be a link or an embedded image like shown here */
  --admonition-icon: url('data:image/svg+xml,<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path d="M19.5 12L14.5 17M19.5 12L14.5 7M19.5 12L9.5 12C7.83333 12 4.5 11 4.5 7" stroke="%231C274C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></g></svg>'); 
  /* The accent color */
  --admonition-color: green;
}
```


================================================
FILE: website/Markdown/Basics.md
================================================
The idea of markdown is that you write plain text with some additional markup that even without further processing (like rendering it to HTML, or [[Live Preview]]) you could just read and understand. It was inspired by conventions used in plain-text e-mails, before e-mail supported rich formatting.

# Basic markup
To write markdown, you just write text. But then to emphasize something you can add `_underscores_` around a phrase to make look _italic_, or `**asterisks**` to make it **bold**. You can also use `~~tildes~~` for ~~strikethrough~~ and `==double equals==` for ==highlighting==.

# Links
To add external links you use the `[site link](https://silverbullet.md)` syntax, which will appear as [site link](https://silverbullet.md). If you want to link to other pages in your space you use the `[[wiki link syntax]]`, e.g. [[SilverBullet]]. To change the link text you can use the `[[SilverBullet|best PKM evah]]` syntax: [[SilverBullet|best PKM evah]].

# Lists and tasks
You can create three types of lists:

Unordered lists are created by prefixing a line with `*` or `-`. For instance:

* This is an unordered list
* And this is a second item

Since this tool is called SilverBullet, we prefer you to use the `*` bullet (which will even appear in _silver_ — clever huh?).

Ordered lists are created by simply putting a number followed by a period at the beginning of a line:

1. This is the first item
2. This is the second item

SilverBullet also supports a variant of the unordered list item to define task. Tasks are defined using the `* [ ] Task name` syntax:

* [ ] This is a task
* [ ] And this is another

When you click the checkbox, it will toggle its state and replace the ` ` inside the box with `x`. SilverBullet also supports custom task statuses by putting text in between `[` and `]`. When you click on such custom task states, it will cycle through all the task states it’s seen in your space:

* [IN PROGRESS] This task is in progress
* [DONE] This task is done
* [TO DO] This task is still to be done

# Headers
Markdown supports various levels of headings, which generally are created by prefixing a line with one or more `#`. The more `#`‘s the deeper the header nesting. 

# Quotes
You can use block quotes by prefixing lines with `>`:

> “If you don’t know where you’re going, you may not get there.”
> — Yogi Berra

# Code
For the programmers among us, there are three ways to mark up code. If you want to write some code inline, you can use backticks: `this is code`. For long code snippets, you can either use a four-space indent:

    This is code
    And another line

or (preferably) the triple-back tick notation, which also allows you to (optionally) specify a coding language:

```javascript
function hello() {
   return "sup";
}
```

SilverBullet supports [[Markdown/Syntax Highlighting]] for many languages out of the box.



================================================
FILE: website/Markdown/Extensions.md
================================================
In addition to supporting [[Markdown/Basics|markdown basics]] as standardized by [CommonMark](https://commonmark.org/), SilverBullet relies on the following extensions:

* [[Link]]
* [[Frontmatter]] and [[Attribute]]
* [[Markdown/Fenced Code Block]]
* [[Transclusions]] syntax
* [[Markdown/Admonition]]
* [[Markdown/Hashtags]]
* [[Space Lua]] expressions
* [Tables](https://www.markdownguide.org/extended-syntax/#tables)
* [Task lists](https://www.markdownguide.org/extended-syntax/#task-lists)
* [Highlight](https://www.markdownguide.org/extended-syntax/#highlight)
* [Automatic URL linking](https://www.markdownguide.org/extended-syntax/#automatic-url-linking)
* [Subscript](https://www.markdownguide.org/extended-syntax/#subscript) and [superscript](https://www.markdownguide.org/extended-syntax/#superscript)


================================================
FILE: website/Markdown/Fenced Code Block.md
================================================
Blocks use the fenced code block notation of [[Markdown]], and assign special behavior to it.

The general syntax is:

    ```language
    block configuration
    ```

These are the block types that ship with SilverBullet, but [[Plugs]] can define their own:

* `space-lua`: [[Space Lua]]
* `space-style`: [[Space Style]]

The fenced code block syntax is also used to get [[Markdown/Syntax Highlighting]] for numerous programming languages.

================================================
FILE: website/Markdown/Hashtags.md
================================================
Hashtag syntax (`#my-tag`) can be used to explicitly assign a [[Tag]] to an [[Object]].

# Scope rules
* If a hashtag is the only content in a paragraph (at most with additional hashtags), it applies to the **page**.
* If a hashtag is used inside of an item (bullet point, or ordered list), it applies to that **item**.
* If a hashtag is used inside of a [[Task]], it applies to that **task**.
* If a hashtag is used inside of a paragraph, they apply to the **paragraph**.
* If you create a [[Markdown/Fenced Code Block]] with a hashtag as language, it applies the tag to that [[Object/data]].

## Examples
#page-tag-example

Paragraph #paragraph-tag-example

* Item #item-tag-example
* [ ] Task #task-tag-example

```#data-example
name: Hank
```

# Naming rules
Hashtags can contain letters, dashes, underscores and other characters, but not:
- Whitespace (space, newline, etc.)
- Characters from this list: `!@#$%^&*(),.?":{}|<>\`
- Digits only (e.g. #123)

If you need your tags to contain these characters, you have to surround the tag content with angle brackets like this: #<my tag>


================================================
FILE: website/Markdown/Syntax Highlighting.md
================================================
SilverBullet comes with syntax highlighters for various programming languages. This page demonstrates some:

```yaml
name: Pete
```

```javascript
function myFunc() {
   console.log("Hello World!");
}
```

```typescript
function myFunc(name: string) {
   console.log("Hello", name);
}
```

```sql
select * from my_table;
```

```c++
class MyClass {

}
```

```rust
fn main() {
    // Print text to the console
    println!("Hello World!");
}
```

```swift
func myFunc() {
   print("Hello World!")
}
```

```xml
<MyTag attribute="cool">

</MyTag>
```

```html
<b>Regular HTML</b>
```

```kotlin
class MyClass {


}
```

```dart
void main() {
  print('Hello, World!');
}
```

```c
void main() {

}
```

```ruby
class BankAccount
  attr_accessor :balance, :account_holder
end
```


================================================
FILE: website/Meta Page.md
================================================
Meta pages are pages not core to your content, but function as a way to configure, or extend your [[Space]]. You can think of them as “tooling” pages. 

The most obvious examples are part of SilverBullet’s [[^Library/Std]] library and include generally useful [[Library/Std/Infrastructure/Slash Templates]]. 

If you use [[Space Lua]] or [[Space Style]], you may want to put those on separate pages and tag them as `#meta` as well, so they don’t mingle with your main content.

# How are meta pages identified?
Meta pages at a technical level are [[Page]] like any other, the only technical difference is that they are tagged with any tag _starting_ with `#meta`. This is picked up by the [[Page Picker]] and [[Meta Picker]].

# How do you link to meta pages?
You can link to a meta page like any other page, that is using the `[[page name]]` syntax. However, in the context of regular content, meta pages will not appear in auto complete. To get auto completion for meta pages, you can use the `[[^page name]]` caret syntax. More information: [[Link#Caret page links]].


================================================
FILE: website/Meta Picker.md
================================================
The meta picker is used to navigate between [[Meta Page]]. Functionally it’s equivalent to the [[Page Picker]] except that it only shows pages tagged with a `meta` tag prefix.


================================================
FILE: website/Metadata.md
================================================
Metadata is data about data. Most [[Object]] have a set of default attributes that can be augmented in a few additional ways:

* [[Tag]]: to tag the object (and add to the `tags` attribute directly)
* [[Frontmatter]]: at the top of [[Page]], a [[YAML]] encoded block can be used to define additional attributes to a page
* [[Attribute]] syntax


================================================
FILE: website/Migrate from v1.md
================================================
SilverBullet v2 removed a slew of features that were still present in the 0.x series (also known as “v1”). Sadly, we just had to rip the bandaid on this one.

Here are some pointers on what was removed and how to adapt.

# Queries
v2 does not have support for old-style [queries](https://v1.silverbullet.md/Query%20Language) (live queries) anymore. They have been replaced with [[Space Lua/Lua Integrated Query]]. Give the linked page a read, but generally there’s a few differences:

1. Lua Integrated Queries tend to start with `from index.tag "tag-name"` instead of plain `tag-name`. This is a bit longer, but since whatever comes after `from` is a Lua expression, you can not just query [[Object]], you can query any Lua table as well in the same way.
2. In the old query language, you access attribute simply by their name, this works in LIQ too, but stylistically it’s nicer to use either `_.attribute`, or to give the object you’re iterating over a name, using `from page = index.tag "page"`, for instance.
3. The `=` equals operator is `==` in Lua 😄

# Templates
In v1 there were various types of templates:

* [Live Templates](https://v1.silverbullet.md/Live%20Templates) (in `template` blocks)
* [Live Template Widgets](https://v1.silverbullet.md/Live%20Template%20Widgets) (using special [[Frontmatter]]-based hooks)
* [Snippets](https://v1.silverbullet.md/Snippets) (using special [[Frontmatter]]-based hooks)
* [Page Templates](https://v1.silverbullet.md/Page%20Templates) (using special [[Frontmatter]]-based hooks)

_Live templates_ are now better expressed by simply putting `${lua expressions}` in text. Iterating and rendering queries using templates is generally done using the `${template.each(query[[...]], someTemplateExpression)}` pattern.

_Live Template Widgets_ can now be implemented using an event listener:
```lua
event.listen {
  name = "hooks:renderTopWidgets", -- or hooks:renderBottomWidgets
  run = function(e)
    return widget.new {
      markdown = "Showing up at the top"
    }
  end
}
```

See [[^Library/Std/Widgets/Widgets]] for some examples.

_Snippets_ can be implemented as follows: [[Library/Std/Infrastructure/Slash Templates]].

_Page templates_ now work slightly differently: [[^Library/Std/Infrastructure/Page Templates]].

# Plugs
Many existing plugs _should_ keep working, but some may need updating.
However, since Space Config is removed. You now specify the plugs you like to use with Space Lua + the [[API/config]] API:

```lua
config.set("plugs", {
  "github:joekrill/silverbullet-treeview/treeview.plug.js"
})
```

After which you run the `Plugs: Update` to download and/or update them. `Plugs: Add` has not been (re)implemented, so you have to do this in code.

# Other changes
* Since no database is kept on the server, you can safely delete any `.silverbullet.db` file from your space — this isn’t used anymore.
* Since space config is no longer a thing, `SETTINGS`  is now gone. Instead the [[CONFIG]] page is now the preferred place to configure things. However, this is just a convention. You can use the `config.set` [[Space Lua]] API in any [[Space Lua]] block.

# Removed features
Certain features have been removed:

* Online mode: v2 is sync mode only, meaning all your content will be synced into your browser. A simpler Online mode may be added back later. If you like to wipe your local content at any time, use the `System: Wipe Client` command.
* [Federation](https://v1.silverbullet.md/Federation)
* [Share](https://v1.silverbullet.md/Plugs/Share) replaced by [[^Library/Std/Infrastructure/Export]]
* Space Script: replaced by [[Space Lua]]
* Space Config: replaced by the [[API/config]] APIs.
* Command link syntax (`{[...]})`) replaced by the `${widgets.commandButton(...)}` API.


================================================
FILE: website/Names.md
================================================
In [[SilverBullet]] every [[Page|page]] or [[Document|document]] has a name. Names are (currently) unique, meaning no two pages or documents can share the same name.

# Rules
Names _must_ also follow certain rules:
- Names cannot be empty
- Names cannot start with a `.`, `^` nor `/`
* Names cannot contain the characters `|`, `@` or `#`
* Names cannot contain the sequences `[[` or `]]`
* Names cannot contain one or two `.` enclosed by a combination of the start/end of the name or `/`
* Names cannot contain `//`
* Names cannot end in `.md` (See [[Paths#Relation to names]] section)
- (Names are case-sensitive and contrary to most filesystem, `/` is allowed)

## Valid Examples
- “foo”
- “this/is/a/page/name”
- “this/.../is/also/a/page”
- “this/is/a/document.png”

## Invalid Examples
- “foo@bar”
- “.foo”
- “foo//bar”
- “foo/../bar”

# Special characters
Certain HTTP reverse proxies may block “suspicious” characters (such as `?`, `#` and `;`) by default, including Traefik, [see this thread](https://community.silverbullet.md/t/traefik-proxied-setups-block-page-names-with-fix/3724/2) on how to work around this.


================================================
FILE: website/Object Index.md
================================================
The Object Index stores [[Object|Objects]] found in your [[Space]].

You interact with it in a few ways:

* Indirectly via various other SilverBullet features such as [[Page Picker]], [[Meta Picker]], [[Linked Mention]] etc.
* Via the [[API/index]] API directly

# Indexing
## Initial indexing process
When you launch a fresh client for the first time, the object index will be built from scratch. Depending on the size of your space this can take anything between a few seconds and minutes. If the process takes longer than a few seconds, you will see progress with a blue status circle. Until this initial indexing process finishes, you will notice that things like [[API/widget|Widgets]] and [[Space Lua/Lua Integrated Query]] are not yet rendered, this is to avoid errors and invalid data.

After the initial index process, the index will be kept up-to-date incrementally.

To forcefully reindex your space, run the `Space: Reindex` command.

## Indexing process
Objects are stored in your browser’s IndexedDB, implemented as a thin abstraction layer on top of [[API/datastore]]. Objects are always attached to a particular page.

When SilverBullet detects a page change (either via the editor or based on watching the file system for changes), it will queue reindexing the page.

This process follows the following steps:
1. Remove all existing page-connected objects from the database.
2. Parse the markdown, and broadcast it via the `page:index` event.
3. Various indexers do their magic, resulting in a list of found objects.
4. These objects are stored via the [[API/index#index.indexObjects(page, objects)]] API.

The indexObject API looks at the `tags` of the found objects, and for each tag:

* Looks up the tag spec (see [[API/tag#tag.define(spec)]])
* If the tag spec defines a `schema` and `mustValidate` is set to true, it will validate the object against the schema and only index the object if it passes. If `mustValidate` is not set (the default for performance reasons), no validation will happen at the indexing stage.
* If the tag spec defines a `transform` callback, it is invoked, see [[API/tag#Index augmentation]] for details.
* Stores the resulting objects with for the given tag

# Query
The Object Index is generally queried using [[Space Lua/Lua Integrated Query]]. 

Entry points are:

* [[API/index#index.tag(name)]], e.g.: `index.tag "page"`
* `tags.*`: as a convenience — `tags.page` is equivalent to `index.tag "page"`

If a `metatable` is defined for a particular tag with [[API/tag#tag.define(spec)]], the metatable is set for each object for the tag.

================================================
FILE: website/Object.md
================================================
SilverBullet automatically maintains an [[Object Index]] extracted from all [[Markdown]] [[Page|pages]] in your [[Space|Space]].

Objects are a feature that powers a lot of SilverBullet functionality, including the [[Page Picker]], [[Linked Mention|Linked Mentions]] and many others. They can also be queried by the user directly, typically via [[Space Lua/Lua Integrated Query]].

# Terminology
* [[Object]]: represent _things_ in your space at various level of granularity. Examples include [[Object/page]] at the highest level, but also more granular things like [[Object/task]] and [[Object/link]]. In relational database parlance, you can think of Objects as **database rows**.
* [[Tag]]: represent Object **types** or **tables** (in relational database parlance). Every Object has at least one tag, but can have additional tags attached explicitly, usually through the [[Markdown/Hashtags]] syntax. 

# Principles
**Markdown always is the source of truth**: all data indexed as objects will have some representation in markdown text as well. 

As a result, the [[Object Index]] can be flushed at any time and be rebuilt from its source markdown files kept in your space (and you can do so on demand using the `Space: Reindex` command).

# Object representation
Every object has a set of [[Attribute|Attributes]], some predefined, but you can add additional custom attributes.

The following attributes are predefined, you can expect all objects to have them:
* `ref`: a globally unique _identifier_, often represented as a pointer to the place (page, position) in your space where the object is defined. For instance, a _page_ object will use the page name as its `ref` attribute, and a `task` will use `page@pos` (where `pos` is the location the task appears in `page`).
* `tag`: the main type, or “tag” of the object.

In addition, most objects will also contain:
* `tags`: an optional set of additional, explicitly assigned tags.
* `itags`: a set of _implicit_ or _inherited_ tags: including the object’s `tag`, `tags` as well as any tags _assigned to its containing page_.

Beside these, any number of additional tag-specific and custom [[Attribute|Attributes]] can be defined. It is also possible to restrict this set of attributes via [[Schema]].


================================================
FILE: website/Object/aspiring-page.md
================================================
[[Aspiring Pages]] (codified using the `aspiring-page` tag) are pages that are linked to, but not yet created. [[Like this one]].

Internally SilverBullet uses these in [[^Library/Std/Pages/Maintenance]], page complete and the page picker (to hint at pages to still be created).

Example query (lists all aspiring pages in this space, which should just be the one created at the top of this page):

${query[[from index.tag "aspiring-page"]]}

================================================
FILE: website/Object/data.md
================================================
You can also embed arbitrary YAML data blocks in pages via fenced code blocks and use a tag as a coding language, e.g.

```#contact
name: Pete
age: 55
```

Which then becomes queryable via the `contact` tag:
${query[[from index.tag "contact"]]}


================================================
FILE: website/Object/header.md
================================================
Headers (lines starting with `#`, `##` etc.) are indexed as well and queryable via the `header` tag.

${query[[from index.tag "header" where page == _CTX.currentPage.name limit 3]]}


================================================
FILE: website/Object/item.md
================================================
List items (both bullet point and numbered items) are indexed with the `item` tag, additional tags can be added using [[Tag]].

Here is an example of a #quote item using a custom [[Attribute|attribute]]:

* “If you don’t know where you’re going you may not get there.” [by: Yogi Berra] #quote

And then queried via the #quote tag:
${query[[
  from index.tag "quote"
  where table.includes(_.itags, "item")
]]}

Additional attributes:

* `parent` will contain a ref to the item’s direct parent `item` if any
* `links` will contain a list of all the wiki-style [[Link|links]] in the item
* `ilinks` (inherited links) will contain a list of all the wiki-style links in the item _and its parents_.
* `itags` will also inherit their ancestors’ tags

Example query showing all attributes of items on this page:
${query[[from index.tag "item" where _.page == editor.getCurrentPage()]]}


================================================
FILE: website/Object/link.md
================================================
All page _links_ are tagged with `link`. You cannot attach additional tags to links. The main two attributes of a link are:

* `toPage` the page the link is linking _to_
* `page` the page the link appears on

In addition, the `snippet` attribute attempts to capture a little bit of context on where the link appears.

Here is a query that shows some links that appear in the [[Object]] page:

${query[[from index.tag "link" where page == "Object"]]}


================================================
FILE: website/Object/page.md
================================================
Every page in your space is available via the `page` tag. You can attach _additional_ tags to a page, by either specifying them in the `tags` attribute [[Frontmatter]], or by putting additional [[Tag|tags]] in a standalone paragraph with no other (textual) content in them.

In addition to `ref` and `tags`, the `page` tag defines a bunch of additional attributes as can be seen in this example query:

${query[[from index.tag "page" where name == _CTX.currentPage.name]]}


================================================
FILE: website/Object/paragraph.md
================================================
Top-level paragraphs (that is: paragraphs not embedded in a list) are indexed using the `paragraph` tag, any additional tags can be added using [[Tag]].

By default, paragraphs are only indexed when they contain a tag. However, you can enable indexing _all_ paragraphs by adding the following to your [[CONFIG]]:

```lua
config.set("index.paragraph.all", true)
```

Example query, querying a paragraph with a #paragraph-tag:

${query[[from index.tag "paragraph-tag"]]}


================================================
FILE: website/Object/space-lua.md
================================================
Every `space-lua` block across your space is indexed with the `space-lua` tag. Upon client boot (or after using `System: Reload`), all space lua scripts are executed in sequence.

================================================
FILE: website/Object/table.md
================================================
Markdown table rows are indexed using the `table` tag, any additional tags can be added using [[Tag]] in any of its cells.

| Title | Description Text |
| --- | ----- |
| This is some key | The value contains a #table-tag |
| Some Row | This is an example row in between two others |
| Another key | This time without a tag |

${query[[from index.tag "table" where page == _CTX.currentPage.name]]}

Table headers will be normalized by converting them to lowercase and replacing all non alphanumeric characters with `_`.

================================================
FILE: website/Object/tag.md
================================================
The ultimate meta tag is `tag` itself, which indexes for all tags used, in which page they appear and what their “parent tag” is (the context of the tag: either `page`, `item` or `task`).

Here are the tags used/defined in this space:
${query[[from index.tag "tag" select {name=name, parent=parent}]]}


================================================
FILE: website/Object/task.md
================================================
Every task in your space is tagged with the `task` tag by default. You tag it with additional tags by using [[Tag]] in the task name, e.g.

* [ ] My task #upnext 

And can then be queried via either `task` or `upnext`. 

The following query shows all attributes available for tasks:
${query[[from index.tag "upnext"]]}

Although you may want to render it using a template instead:
${template.each(query[[from index.tag "upnext"]], templates.taskItem)}

Specific attributes:
* `done`: set to true of the state of the task is “checked”
* `state`: the text of the state (useful for custom states), see [[^Library/Std/APIs/Task State]]

Additionally, tasks inherit all attributes of [[Object/item]].

================================================
FILE: website/Open Source.md
================================================
---
description: SilverBullet is MIT licensed, open source software.
awesomeness: 10
tags: feature
---

SilverBullet is [MIT licensed](https://github.com/silverbulletmd/silverbullet/blob/main/LICENSE.md), open source software. This license allows you to basically use it however you like, for whatever purpose. No restrictions. Enjoy.

However [[Funding]]

================================================
FILE: website/Outlines.md
================================================
While SilverBullet is not a “proper” outliner like e.g. [LogSeq](https://logseq.com), it does offer a useful commands to manage outlines.

An outline is simply a (nested) bulleted list, for instance:

* Introduction
  * Context
  * Problem space
  * Solution space
* Problem statement
  * Problem A
    * Sub problem 1
    * Sub problem 2
  * Problem B
  * Problem C
* Related work
  * Area A
  * Area B
  * Area C
* Solution
* Conclusion

# Commands
SilverBullet offers a number of `Outline` commands to make manipulating such outlines easier, they are (note that on Mac `Mod` binds to `Cmd`, on Linux and Windows it’s `Ctrl`):

* `Outline: Move Up` (`Alt-ArrowUp`): moves the current item and its children up
* `Outline: Move Down` (`Alt-ArrowDown`): moves the current item and its children down
* `Outline: Move Right` (`Mod-. l`): indents the current item and its children one level deeper
* `Outline: Move Left` (`Mod-. h`): outdents the current item and its children one level higher


And for folding outlines:

* `Outline: Fold`: folds the current item’s children
* `Outline: Unfold`: unfolds the current item’s children
* `Outline: Toggle Fold` (`Mod-. Mod-.`): toggles the current item’s fold state
* `Outline: Fold All`: folds all sections in the entire page
* `Outline: Unfold All`: unfolds all sections in the entire page


================================================
FILE: website/PWA.md
================================================
SilverBullet is developed as a [Progressive Web App](https://web.dev/progressive-web-apps/), a web application that is fully offline capable.

After your first load and sync, (practically) all functionality is available even without a network connection.

================================================
FILE: website/Page Decorations.md
================================================
---
tags: maturity/beta
pageDecoration:
  prefix: "🎄 "
  disableTOC: true
  cssClasses:
  - christmas-decoration
---
Page decorations allow you to “decorate” pages in various fun ways.
 
# Supported decorations
* `prefix`: A (visual) string prefix (often an emoji) to add to all page names. This prefix will appear in the top bar as well as in (live preview) links to this page. For example, the name of this page is actually “Page Decorations”, but when you link to it, you’ll see it’s prefixed with a 🎄: [[Page Decorations]]
* `cssClasses`: (list of strings) Attaches one or more CSS classes the page's `<body>` tag, wiki links, auto complete items and [[Page Picker]] entries for more advanced styling through a [[Space Style]] (see [[#Use case: pimp my page]] for an example).
* `disableTOC` (not technically built-in, but a feature of the [[^Library/Std/Widgets/Widgets|Table of Content]] widget): disable the TOC for this particular page.

# Apply with [[Frontmatter]]
This is demonstrated in the [[Frontmatter]] at the top of this page, by using the special `pageDecoration` attribute. This is how we get the fancy tree (🎄) in front of the page name. Sweet.

## Use case: pimp my page
Let’s say you feel you want to pimp up a specific page with some Christmas-level decoration, and prefixing it with a tree just doesn’t do it for you. The `cssClasses` decoration is saying: hold my beer. 

Note that in this page’s [[Frontmatter]] the `christmas-decoration` class is attached via `pageDecoration.cssClasses`. Now let’s hook into that with some [[Space Style]]:

```space-style

/* Style page links */
a.christmas-decoration {
  background-color: #b4e46e;
}

/* Style main editor components */
body.christmas-decoration #sb-top {
  background-color: #b4e46e;
}

/* Style auto complete items */
.cm-tooltip-autocomplete li.christmas-decoration {
  background-color: #b4e46e;
}

/* Style page picker item */
.sb-result-list .sb-option.christmas-decoration {
  background-color: #b4e46e;  
}
```

And _boom_! Ain’t that pretty?


================================================
FILE: website/Page Namer.md
================================================
At the header of each page, you see the currently open page name. Let’s call this user interface element the “Page Namer.” This element of the user interface serves a few roles:

* It indicates the **currently open name** (duh)
* Its font color indicates the current **page status**:
  * Black (in light mode) indicates the page is currently saved
  * Gray (in light mode) indicates the page is currently not saved
* By changing the page name and hitting `Enter` (or clicking/tapping outside of it) you **rename** the [[Page|page]], automatically updating all references to it as well.

================================================
FILE: website/Page Picker.md
================================================
The page picker has two main functions:

1. Enables quick navigation between [[Page]]
2. Enables creation of new pages
   * Based on the entered name
   * Based on [[Link|linked]] to, but not yet created page names

The page picker can be invoked by clicking the 📔 icon in the top bar, or by pressing `Cmd-k` on Mac, or `Ctrl-k` on Windows/Linux.

The main input is the **filter phrase** and can be used to narrow down the list of page results.

If the filter phrase contains `#tags` the results will be filtered based on matching those tags.

> **note** Note
> Any page tagged with a tag starting with `#meta`, even though technically regular pages, will **not** appear in the page picker. To navigate to them, use the [[Meta Picker]] instead.

> **note** Pro-tip
> To cycle between the three pickers [[Page Picker]], [[Meta Picker]] and [[Anything Picker]], type `^` in the filter phrase box.

Pressing the `Enter` key will open/create the selected page.
Pressing `Shift-Enter` will always open or create (if it doesn't already exist) the page _exactly matching_ the filter phrase.

Therefore, if you _intend to create a new page_, simply type the name of the new page and hit `Shift-Enter`.

# Result ordering
When no filter phrase is entered, pages are ordered by either _last opened_, or _last modified_ date in descending order. This makes it convenient to switch between recently edited pages.

When entering a filter phrase, the best matches should appear closer to the top, however the second option will always be an option to create a new page with _exactly_ the page name entered as the filter phrase.

# Keyboard shortcuts
* `Enter`: selects the highlighted page from the list and navigate there. If that page is marked with “Create page” it will create that page.
* `Shift-Enter`: navigate to the page entered in as the filter phrase.
* `Space`: with an empty filter phrase will attempt to do something intelligent:
  * If the currently opened page is nested in a [[Folder|folder]], it will auto complete the current folder name in its place.
  * If the currently opened page name starts with an emoji, it will complete that emoji in its place.
  * Otherwise, it will complete the full page name of the currently open page.
* `ArrowUp`/`ArrowDown`: move up and down the highlighted page list
* `PageUp`/`PageDown`: move up and down 5 entries in the page list in one go
* `Home`: moves to the start of the list
* `End`: moves to the end of the list
* `Escape`: closes the page picker UI
* Typing `^` when filter phrase is empty will cycle to the next picker, first [[Meta Picker]], then [[Anything Picker]]

# Mouse/touch operation
You can scroll and select an item from the list by clicking with the mouse, as well as close the page picker by clicking outside of it.



================================================
FILE: website/Page Template.md
================================================
Page templates can be used to automate or simplify the creation of pages based on a template.

# Creating a page template
To create a page template, create a page and tag it with `#meta/template/page`.

The last component of the page name will be used as the template name.

## Cursor placement
In the template’s body text, you can optionally use `|^|` as a placeholder for where the cursor should be after creating the page.

## Configuration
Optional keys you can set in the page’s frontmatter:

* `suggestedName`: the proposed name for the new page, can use embedded Lua expressions, like `Daily/${date.today()}`.
* `confirmName`: Confirm the suggested page name before creating it (defaults to `true`).
* `openIfExists`: If a page with the `suggestedName` already exists, open it rather than attempting to create it anew.
* `command`: expose the page template as a command with this name.
* `key`: Bind the snippet to a keyboard shortcut (note: this requires to _also_ specify the `command` configuration).
* `mac`: Bind the snippet to a Mac-specific keyboard shortcut.
* `frontmatter`: Frontmatter (encoded as a string) to set in the resulting page.
* `priority`: Similar to how space lua scripts are loaded, this controls the order in which page template _commands_ are
  created (see "overriding page templates" below)

## Example: Daily note
The following creates a page template that can be run using the `Journal: Daily Note` command, it automatically creates `Daily/today’s date` if it does not already exist with a bulleted list, putting the cursor at the first bullet. If the page already exists, it navigates there.

~~~
---
command: "Journal: Daily Note"
suggestedName: "Daily/${date.today()}"
confirmName: false
openIfExists: true
tags: meta/template/page
---
* |^|
~~~

# Currently active page templates

${template.each(query[[
  from index.tag "meta/template/page"
  where _.tag == "page"
]], templates.fullPageItem)}

# Instantiating page templates

You can create a page based on a page template via the ${widgets.commandButton("Page: From Template")} command, or via the command name that you defined in your template’s frontmatter.

# Overriding page templates

If you would like to override an existing page template (for instance the Quick Note) template with your own, you can take advantage of the load order determined by the `priority` frontmatter. Built in page templates will have a priority set that is higher than the default. Therefore, their commands and keybindings will be set early. Therefore, by simply defining your own version of the page template _with the same command name_ will let that version override the versions that are built in.

## Example: overriding the Quick Note template

~~~
---
command: Quick Note
key: "Ctrl-q q"
suggestedName: "Quick notes/${os.date('%Y-%m-%d/%H-%M-%S')}"
confirmName: false
tags: meta/template/page
---
This is my quick note version
~~~


================================================
FILE: website/Page.md
================================================
The primary thing you’ll be doing in SilverBullet (hopefully) is creating and editing pages. Pages are written in [[Markdown]], can contain [[Metadata]] and are kept on the server as text files.

Page _names_ are relatively free-form, but have to adhere to a few [[Names#Rules]].

# Types of pages
Your [[Space]] will contain two types of pages:

* **Content pages:** this is the core of your space — your notes. You navigate between these using the [[Page Picker]].
* [[Meta Page]] these are pages that contain implementation details _about_ your space (they are “meta”). A lot of these will be part of [[Library]]. You navigate between these using the [[Meta Picker]].


================================================
FILE: website/Paths.md
================================================
Every document or page in SilverBullet is identified by a path. The path represents the location where the corresponding page or document is stored inside your space folder. Paths are always absolute and always have to end in an [[#Extensions|extension]].

# Extensions
A valid extension is a `.` followed by one or more digits or letters, followed by the end of the string.

# Relation to names
The name of a page or document is very closely related to it’s path, specifically you get from the name to the path by adding `.md` to names not ending in a valid extension.
Inversely you can get the name from a path by removing any `.md` extension, which may exist (This is also the reason, why names cannot end in `.md`).
This entails that paths have to conform to the same [[Names#Rules|rules]] names have to.

# Valid Examples
- “foo.md”
- “foo.png”
- “bar.tar.gz”
- “bar.foo bar.baz”
- “this/is/a/path.md”

# Invalid Examples
- “foo”
- “/foo”
- “.foo”
- “foo@bar”
- “foo.md.md”

================================================
FILE: website/Person/John.md
================================================
---
firstName: John
lastName: Doe
location: America
age: 23
tags: person
---
John Doe is a engineer living in the US.

# Notable achievements
* None

================================================
FILE: website/Person/Zef.md
================================================
---
firstName: Zef
lastName: Hemel
location: Poland
tags: person
---

Zef Hemel is a Dutch engineer living in Poland.

# Notable achievements
* Created [[SilverBullet]]
* That’s about it


================================================
FILE: website/Personal.md
================================================
SilverBullet is designed to be a single-user application. That user is _you_. 

It’s a place for you to express yourself. To keep notes, structure ideas, customize, extend and do whatever you like in the [[Private]] of your own [[Space]].


================================================
FILE: website/Platform.md
================================================
While SilverBullet at its core is a notes application, it is [[Architecture|architected]] to be capable of doing much more than that. 

Many people use it effectively as a personal application platform and build crazy stuff on it. Have a look at the [community forums](https://community.silverbullet.md) to get a feel.

More information on SilverBullet’s technical [[Architecture]].

================================================
FILE: website/Plugs.md
================================================
SilverBullet at its core is bare bones in terms of functionality, most of its power it gains from **plugs** and [[Space Lua]].

Plugs are an extension mechanism that runs “plug” code in the browser using [web workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers).

Plugs can hook into SB in various ways:
* Define new commands and keybindings
* Respond to various events triggered either on the server or client-side
* Run recurring and background tasks.
* Define their own extension mechanisms through custom events

Each plug runs in its own _sandboxed environment_ and communicates with SB via _syscalls_ that expose a vast range of functionality. Plugs can be loaded, unloaded, and updated without having to restart SilverBullet itself.

Plugs are distributed as self-contained JavaScript bundles (ending with `.plug.js`). SilverBullet will load all core plugs bundled with SB itself (listed below), as well as any additional plugs stored in the `_plug` folder in your [[Space|space]].

# Available plugs
The [SilverBullet ‘Plugs’ category has a list of third-party plugs you can try](https://community.silverbullet.md/c/plugs/14).

* [SilverSearch](https://github.com/MrMugame/silversearch): implements a better full text search
* [TreeView](https://github.com/joekrill/silverbullet-treeview): adds a tree-view to your UI
* [GraphView](https://github.com/deepkn/silverbullet-graphview): adds a graph view to your UI, similar to the one in Obsidian
* [PDF](https://github.com/MrMugame/silverbullet-pdf): enables viewing and annotation of PDF documents with PDF.js
* [Mermaid](https://github.com/silverbulletmd/silverbullet-mermaid): adds mermaid diagram rendering
* [PlantUML](https://github.com/LogeshG5/silverbullet-plantuml): adds plantuml diagram rendering
* [Draw.io](https://github.com/LogeshG5/silverbullet-drawio): adds drawio diagram rendering and editing
* [Excalidraw](https://github.com/LogeshG5/silverbullet-excalidraw): adds excalidraw diagram rendering and editing

# Development
Want to develop your own plugs? Have a look at [[Plugs/Development]].


================================================
FILE: website/Plugs/Development.md
================================================
The easiest way to get started is to click the “Use this template” on the [silverbullet-plug-template](https://github.com/silverbulletmd/silverbullet-plug-template) repo.

Generally, every plug consists of a YAML manifest file named `yourplugname.plug.yaml`. This file defines all functions that form your plug. To be loadable by SilverBullet, it needs to be compiled into a bundle (ending with `.plug.js`).

For this you need to have [Deno](https://deno.com) installed, after which you can run the `deno task build` command specified in the plug template repo:

```shell
deno task build
```

For development it’s easiest to simply copy the `.plug.js` file into your space’s `_plug/` folder after it’s built:

```shell
cp myplug.plug.js ~/myspace/_plug/
```

Within seconds (watch your browser’s JavaScript console), your plug should be picked up both on the server and, synced to your browser and loaded. No need to even reload the page.

## Debugging
Since plugs run in your browser, you can use the usual browser debugging tools. When you `console.log` things, these logs will appear in your browser’s JavaScript console.

## Distribution
Once you’re happy with your plug, you can distribute it in various ways:

- You can put it on github by simply committing the resulting `.plug.js` file there and instructing users to point to by adding
  `- github:yourgithubuser/yourrepo/yourplugname.plug.js` to their `PLUGS` file
- Add a release in your github repo and instruct users to add the release as `- ghr:yourgithubuser/yourrepo` or if they need a specific release `- ghr:yourgithubuser/yourrepo/release-name`
  - You need to upload the plug file as asset in the release, with a name matching your repository, e.g, `yourrepo.plug.js`
  - If your repository name starts with "silverbullet-", like `silverbullet-foo`, both `silverbullet-foo.plug.js` and `foo.plug.js` asset names will be tried
- You can put it on any other web server, and tell people to load it via https, e.g., `- https://mydomain.com/mypugname.plug.js`.


================================================
FILE: website/Private.md
================================================
---
description: Your content is yours. You store it, nobody else gets access.
awesomeness: 7
tags: feature
---
SilverBullet is private because you [[Self Hosted|self host]] it, enabled [[Authentication]] and are not giving anybody else the username and password (_right!?_).

_You_ are in control of the server it runs on, and communication between your browser and your server is secured via [[TLS]]. It is a way to be [[Data Sovereignty|data sovereign]].

SilverBullet does not support end-to-end encryption yet (although you can enable [[Client Encryption]]). Therefore, you are in charge of securing your server using standard security practices (e.g. by enabling disk encryption to encrypt your disk).

================================================
FILE: website/Programmable.md
================================================
---
description: SilverBullet has been designed to be programmable from the ground up.
awesomeness: 10
tags: feature
---
SilverBullet has been designed to be programmable from the ground up. The core of SilverBullet is relatively small, see [[Architecture]].

On top of this core, a lot of functionality is built as [[Plugs]] and [[Space Lua]] scripts.

While not as fundamental in this approach as editors like [Emacs](https://www.gnu.org/software/emacs/), it draws a lot of inspiration from it.

================================================
FILE: website/Repository.md
================================================
#maturity/beta

Repositories are a collection of _pointers_ to [[Library|Libraries]] to install. Their purpose is to offer a curated list of libraries to install.

By default, SilverBullet ships with one repository ([[^Repositories/Std]]), more can be installed via the [[Library Manager]].

Repositories are implemented on top of a few SilverBullet mechanisms, specifically they are simply [[Meta Page|Meta Pages]] tagged with `#meta/repository` that contain [[Object#data]] objects tagged with `#meta/library/remote` with a `name` and `uri` attribute. Libraries are kept up to date using the [[Share]] mechanism.



================================================
FILE: website/Schema.md
================================================
SilverBullet relies on [JSON Schema](https://json-schema.org) for various types of validation, specifically:

* [[Tag#Custom tags]]
* [[^Library/Std/Config]] options

Often these schemas are encoded using [[Space Lua]], so take the shape of:

```lua
local schema = {
  type = "object",
  properties = {
    -- ...
  }
}
```

There are is the [[^Library/Std/APIs/Schema]] API for convenience.

================================================
FILE: website/Self Hosted.md
================================================
---
description: Rather than relying on the servers of a third-party provider, you run SilverBullet on your own server.
awesomeness: 10
tags: feature
---
According to [Wikipedia](https://en.wikipedia.org/wiki/Self-hosting_(web_services)):

> Self-hosting is the practice of running and maintaining a [website](https://en.wikipedia.org/wiki/Website) or [service](https://en.wikipedia.org/wiki/Service_\(systems_architecture\) "Service (systems architecture)") using a private [web server](https://en.wikipedia.org/wiki/Web_server "Web server"), instead of using a service outside of the administrator's own control. Self-hosting allows users to have [[Data Sovereignty|more control over their data, privacy, and computing infrastructure]], as well as potentially saving costs and improving skills.

SilverBullet is the ultimate gateway to practice hosting your own stuff. 

Had a taste and want more? Here are some excellent resources:

* [Self hosting Reddit](https://www.reddit.com/r/selfhosted/)
* [Awesome Self Hosted](https://github.com/awesome-selfhosted/awesome-selfhosted)



================================================
FILE: website/Service.md
================================================
The SilverBullet service bus is an extension mechanism built into SilverBullet.

# Concepts
* Discovery: the process of discovering available services based on a _selector_.
* Selector: a string (possibly with a wildcard `*`) used to advertise as well as discover services.
* Service: defined via [[API/service#service.define(spec)]]), implements a service advertised via a selector.
* Invocation: running a discovered service.

# Architecture
Services are an abstraction built on top of [[Event]]. When a new service is defined, it registers itself listening to the `discover:<<selector>>` event (to be discovered), and under `service:<<guid>>` to be invoked.

Service discovery happens by broadcasting an event on the event bus with the given selector, returning a list of services that are a match, ordered by (self assigned) priority. One (or all) of these services can then be invoked based on their returned ID.

See [[API/service]] for API details.

================================================
FILE: website/Share.md
================================================
#maturity/beta

SilverBullet is aimed for single-user, private use. Nevertheless, many have the need to _share_ some content kept in SilverBullet with the outside world: to push content out, pull it in, or even sync between different locations. This is where SilverBullet _share_ functionality comes in. 

**Note:** If you are interested in one-off ways to get content out, have a look at [[Export]].

# Modes
Sharing may be desirable in different directions:
* _Push_: produce content in SilverBullet, then push it to some external location, while being able to keep the external place up to date with changes, keeping SilverBullet as the source of truth. Example use cases:
  * Blog posts
  * Social network posts
  * [[Library]] you want to distribute to others
* _Pull_: pull in content from an external location and import it into your space and be able to keep pulling in new versions on demand. Use cases:
  * [[Library]] that you install, e.g. via the [[Library Manager]]
* _Sync_: For bi-directional push and pull. Example use cases:
  * Collaborate on a page with other SilverBullet users (not implemented yet)

# Operation
SilverBullet has [[^Library/Std/Infrastructure/Share|infrastructural support]] to solve this problem in a general way. It leverages [[Frontmatter]], specifically the following three keys:

* `share.uri`: specifies the external location to push, pull or sync your content with, represented as a [[URI|URI]].
* `share.mode`: specifies the _mode_ this sharing should happen, options are: `push`, `pull` or `sync`
* `share.hash`: automatically calculated and updated content hash of your local version to detect whether local changes were made since the last share operation.

# Workflow
On the page you’re interested to share, run the ${widgets.commandButton "Share: Page"} (bound to `Cmd-p`/`Ctrl-p` by default) command. This will pop up a list of enabled “Share providers”. Pick one and follow the provider-specific setup steps. At the end, your page will be shared and the `share.*` frontmatter keys automatically set.

Subsequent shares can be done simply by running the `Share: Page` command again.

# Support
Out of the box, sharing is supported for:

* Plain `https://` URIs (_Pull_ mode only) (implemented in [[^Library/Std/Infrastructure/URI]]).
* [[^Library/Std/Infrastructure/Github]] (`https://github.com/*` for repository files and `https://gist.github.com/*` URLs): both in _Pull_ mode, and _Push_ mode when a token is configured. Technically _Sync_ should work except that Github aggressively caches files, making this impractical.

To implement your own share provider, have a look at the implementations linked to understand how they work.


================================================
FILE: website/SilverBullet.md
================================================
# Introduction
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]] [[Page|Pages]] (called a [[Space]]). You navigate your space using the [[Page Picker]] like a traditional notes app, or through [[Link|Links]] like a wiki (except they are [[Linked Mention|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 [[Outlines|Outlining]] tools for you. Productivity freak? Have a look at [[Task|Tasks]]. More of a **database** person? You will appreciate [[Object|Objects]] and [[Space Lua/Lua Integrated Query|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 [[Command|Commands]], [[Page Template|Page Templates]] or [[API/widget|Widgets]].

# Programmable notes
Dynamically generating content, _programmable notes_... why would you want that, and how does it work?

Let’s say you have documented a set of product features in individual pages that you’ve [[Tag|tagged]] with a #feature tag, and annotated with a few custom [[Frontmatter]] [[Attribute|Attributes]].

With a simple [[Space Lua/Lua Integrated Query|Query]] and [[Template]], you can now dynamically build a product feature list, ordered by _awesomeness_ (`Alt-click` or hover and click the edit button to see the underlying code):

${template.each(query[[
  from tags.feature
  where tag == "page"
  order by awesomeness desc
]], templates.featureItem)}

_(The template generating the feature bullet items can be found in [[^Library/Website Templates]])_

Neat huh? A few more use cases.

## Active pages
Let’s say you want to have a list of your 5 modified pages. We can do that!
${template.each(query[[
  from tags.page
  order by lastModified desc
  limit 5
]], templates.pageItem)}

## To do items 
Maybe you want to collect all [[Task|Tasks]] that you have not yet completed from across your space? No problem:
${template.each(query[[
  from index.tag "task"
  where not _.done
  limit 3
]], templates.taskItem)}

# Tour
That all sounds nice, but what does that look like in practice? Well, if you’re wondering purely about _looks_: have a look around — this very website is hosted as a _read-only_ SilverBullet instance. You probably already figured this out.

If you’d like a bit of a tour and demo, give this a watch:
${embed.youtube "https://www.youtube.com/watch?v=mik1EbTshX4"}

Want to see even more? Here is a whole [playlist with instruction videos](https://www.youtube.com/watch?v=bb1USz_cEBY&list=PLxFAb_vXRcEp4465MVI6Ha9wzNiX5VevQ) that go more in depth.

# [[Install]]
As mentioned, SilverBullet is a [[Self Hosted]] web application. This is great if you care about [[Data Sovereignty]], but it does mean you need to [[Install]] it on a server yourself. Perhaps you do this on a Raspberry Pi you didn’t have a use for, or a VPS somewhere in the cloud. SilverBullet is distributed as a single self-contained server [[Install/Binary]] or [[Install/Docker]] container.

While this is a bit more complicated to set up than simply downloading desktop app or signing up for an account with some online service, self hosting is a path to both [[Data Sovereignty]] and to access your content from any device with a modern [[Browser]].

Ready? Proceed to [[Install]].

# [[Architecture]]
The SilverBullet client is built as a [[Local First]], [[PWA|Progressive Web Application]], syncing all your content into your browser’s local storage, enabling **instant access** to your entire space whether you are **online** or **offline**. Simply opt to “Install SilverBullet” from your browser (in any Chrome-based browser), add it to your Dock or home screen (Safari and Android), and voila: SilverBullet becomes indistinguishable from a regular desktop or mobile app. You can try it right here on silverbullet.md.
![[pwa-screenshot.png]]

Unplug your (hypothetical) network cable, and everything still works!

Learn more about SilverBullet’s technical [[Architecture]].

# Project status
SilverBullet has been in development since early 2022, but is ever evolving. The current iteration is **SilverBullet v2**. We had a little bit of a reboot, rebuilding some of the foundations, and replacing some of the previous ([v1](https://v1.silverbullet.md)) features. 

# What’s next?
* [[Manual]]: SilverBullet’s official manual.
* [[CHANGELOG]]: we’re in active development, so things change rapidly. Watch this page to keep up.
* [Community](https://community.silverbullet.md): join our community: ask questions, share your experiences.
* [Issues](https://github.com/silverbulletmd/silverbullet/issues): if you have ideas or find bugs, please report them.
* [Mastodon](https://fosstodon.org/@silverbulletmd): follow SilverBullet development on [Mastodon](https://joinmastodon.org/).


================================================
FILE: website/Slash Command.md
================================================
Slash commands are quick ways to perform repetitive tasks. 99% of the time this will mean using a [[^Library/Std/Infrastructure/Slash Templates]], but you can also create them in a more low-level way using [[Space Lua#Slash commands]].

# Invoking a slash command
You can trigger a slash command by typing a `/` in your text (after some white space) followed by its name (this will auto complete).

Technically, no slash commands ship with SilverBullet directly. However, you can easily add useful ones via [[Library]].


================================================
FILE: website/Slash Templates.md
================================================
See [[^Library/Std/Infrastructure/Slash Templates]]

================================================
FILE: website/Space Lua.md
================================================
Space Lua is a custom dialect and implementation of the [Lua programming language](https://lua.org/), embedded in SilverBullet. It aims to be a largely complete Lua implementation, but adds a few non-standard features while remaining syntactically compatible with “real” Lua.

In its essence, Space Lua adds two features to SilverBullet’s [[Markdown]] language:

* [[#Definitions]]: Code written in `space-lua` code blocks are enabled across your entire space.
* [[#Expressions]]: The `${expression}` syntax will [[Live Preview]] to its evaluated value. It is common to use this mechanism to render custom [[API/widget|Widgets]].

Have a look at [[Space Lua/Conventions]] for best practices around code style.

> **note** Note
> Many examples in the documentation use `lua` as a [[Markdown/Fenced Code Block]] language rather than `space-lua`, this is to be able to give example of Space Lua code without actually “activating” it as such on the website. When you use these snippets yourself, replace `lua` with `space-lua`.

# Definitions
Space Lua definitions are defined in fenced code blocks with the `space-lua` language. These blocks are active **across your entire space** (hence _Space_ Lua), not just on the page they appear on.

A simple example:

```space-lua
-- adds two numbers
function adder(a, b)
  return a + b
end
```

Each `space-lua` block has its own local scope. However, following Lua semantics, when functions and variables are not explicitly defined as `local` they will be available globally across your [[Space|space]]. This means that the `adder` function defined, can be called from anywhere in your space.

## Definition loading
Your `space-lua` definitions are constantly being indexed as part of the [[Object Index]] with the [[Object/space-lua]] tag. There is nothing you have to do for this, other than be a bit patient for things to start working when you initialize a fresh client.

When your client boots, or if you explicitly run the `System: Reload` command, all these scripts are executed in sequence. 

It is possible to **control load order** of space lua scripts using the special `-- priority: <number>` comment in Space Lua code. For instance:

```space-lua
-- priority: 10
local myCodeHere
```

Scripts are loaded in _reverse priority_ order. When you set no priority (the default) your scripts will be run last.

The order used is determined by this [[Space Lua/Lua Integrated Query|query]] (also part of your [[^Library/Std/Pages/Space Overview]]) page:

    query[[
      from t = index.tag "space-lua"
      order by t.priority desc
    ]]

This means that the higher the priority, the earlier the script is loaded. That also means that if you want to override previously defined definitions you need to a set a _lower_ priority (or in most cases: simply omit the priority comment).

Here are the conventions used by the [[Library/Std]] library:

* `priority: 100` for config definitions (schemas)
* `priority: 50` for setting really core and root variables (like `template.*` APIs) that will be used by other scripts
* `priority: 10`: for standard library definitions that may be overriden (by scripts with lower priority)

> **note** Tip
> All your space-lua scripts are loaded on boot, to reload them without reloading the page, simply run the ${widgets.commandButton("System: Reload")} (Ctrl-Alt-r) command.

# Expressions
One SilverBullet specific [[Markdown]] [[Markdown/Extensions]] is the `${lua expression}` syntax that you can use in your pages. This syntax will [[Live Preview]] to the evaluation of that Lua expression.

For example: 10 + 2 = ${adder(10, 2)} (Alt-click, or select to see the expression) is using the just defined `adder` function.

This mechanism is often used in conjunction with [[Space Lua/Lua Integrated Query]] and [[API/widget|Widgets]].

# API
![[API]]
# Space Lua vs “OG” Lua
Space Lua is a _custom Lua implementation_. It does not use the official [Lua](https://www.lua.org) nor [LuaJIT](https://luajit.org) implementations. Rather than using a WebAssembly or other implementation of Lua that could run in the browser, we have opted for a custom implementation. There are various (partially historical) reasons for this decision, but taking this path has given us a very smooth interoperability story allowing for very friction-free access to a lot of SilverBullet and browser functionality. It also enables relatively deep integration into the Lua runtime, enabling features like (some) code complete support, (some) jump to definition and various other features.

While the aim is to be 95% (let’s say) compatible with regular Lua, there are a few [[Space Lua/Quirks]] to be aware of.

In addition to quirks, Space introduces a (minimal) set of new features on top core Lua:

1. [[Space Lua/Lua Integrated Query]], embedding a query language into Lua itself
2. [[Space Lua/Thread Locals]]

================================================
FILE: website/Space Lua/Conventions.md
================================================
#maturity/beta

Some coding conventions for [[Space Lua]]. Work in progress.

# Indentation
Use 2 spaces for indentation.

# Variable naming
For variables and methods alike use camelCasing as opposed to snake_case. That is: `myVariable` and `myFunction` instead of `my_variable` and `my_function`.

# Name spacing
Since Space Lua has a single global namespace across your entire space, it is good practice to manually namespace functions using the following pattern:

```lua
-- This initializes the stuff variable with an empty table if it's not already defined
stuff = stuff or {}

function stuff.adder(a, b)
  return a + b
end
```

And to use `local` if you don’t need to access outside your code block:

```lua
local myVariable

local function myFunction()
  
end
```

## Use singular top-level namespaces for APIs
Such as [[API/widget]], [[API/tag]].

## Use plural namespaces for collections
Such as [[^Library/Std/Widgets/Widgets|widgets]].

# API naming conventions
## *.define
This is a common Space Lua API naming convention used to define new entities of some type globally. Such APIs typically take a single table with arguments as an argument.

Internally, these APIs typically update the [[API/config]] as a side effect, which is then read elsewhere.

Examples:
* [[API/tag#tag.define(spec)]]
* [[API/taskState#taskState.define(def)]]
* [[API/config#config.define(key, schema)]]
* [[API/slashCommand#slashCommand.define(spec)]]
* [[^Library/Std/APIs/Command#command.define(commandDef)]]

## *.new
These like constructors for objects and do not have side effects.

Examples:

[[API/widget#widget.new(spec)]]



================================================
FILE: website/Space Lua/Lua Integrated Query.md
================================================
Lua Integrated Query (LIQ) is a SilverBullet specific Lua extension. It adds a convenient query syntax to the language in a backwards compatible way. It does so by overloading Lua’s default function call + single argument syntax when using `query` as the function call. As a result, Lua programs using LIQ are still syntactically valid Lua.

The syntax for LIQ is `query[[my query]]`. In regular Lua `[[my query]]` is just another way of writing `"my query"` (it is an alternative string syntax). Function calls that only take a string argument can omit parentheses, therefore `query[[my query]]` is equivalent to `query("my query")`.

However, in [[Space Lua]] it interpreted as an SQL (and [LINQ](https://learn.microsoft.com/en-us/dotnet/csharp/linq/))-inspired integrated query language. 

General syntax:

    query[[
      from <var> in <expression>
      where <expression>
      order by <expression>
      limit <expression>, <expression>
      select <expression>
    ]]

LIQ operates on any Lua collection.

For instance, to sort a list of numbers in descending order:
${query[[from n = {1, 2, 3} order by n desc]]}

However, in most cases you’ll use it in conjunction with [[API/index#index.tag(name)]]. Here’s an example querying the 3 pages that were last modified:

${query[[
  from p = index.tag "page"
  order by p.lastModified desc
  select p.name
  limit 3
]]}

Note that the query returns a regular Lua table, so it can be part of a bigger expression:

${some(query[[
  from p = index.tag "page"
  limit 0
]]) or "Matched no pages"}

# Clauses
Here are the clauses that are currently supported:

## from <expression>
The `from` clause specifies the source of your data. There are two syntactic variants:

**Recommended:** With explicit variable binding:

    from v = <<expression>>

binding each item to the variable `v`.

However, there is also the more concise:

    from <<expression>>

implicitly binding each item to the variable `_` as well as making all attributes directly available as variables. The latter, while shorter, is less performant and will block future optimizations, so the variable-binding variant is preferred.

> **warning** Warning
> When you use a `from` clause without explicit variable binding (so without thee `v in` syntax), note that any attribute of the object you’re iterating over will shadow global variables. For instance, if you have an object with a `table` attribute, regular `table` APIs will become inaccessible within the query.
> 
> **Recommendation:** Use the explicit variable binding syntax

Example without variable binding:
${query[[from {1, 2, 3} select _]]}

With variable binding:
${query[[from n = {1, 2, 3} select n]]}

A more realistic example using `index.tag`:
${query[[from p = index.tag "page" order by p.lastModified select p.name limit 3]]}

## where <expression>
The `where` clause allows you to filter data. When the expression evaluated to a truthy value, the item is included in the result.

Example:

${query[[from n = {1, 2, 3, 4, 5} where n > 2]]}

Or to select 5 pages tagged with `#meta`:

${query[[from p = index.tag "page" where table.includes(p.tags, "meta") limit 5]]}

Or select based on name (including folder) and a [[API/string|string function]]

${query[[from p = index.tag "page" where p.name:startsWith("Person")]]}

## order by <expression> [desc]
The `order by` clause allows you to sort data, when `desc` is specified it reverts the sort order.

As an example, the last 3 modified pages:
${query[[
  from p = index.tag "page"
  order by p.lastModified desc
  select p.name
  limit 3
]]}

You can order based on multiple expressions by specifying multiple expressions separated by commas:

${query[[
  from p = index.tag "page"
  order by p.lastModified desc, p.name
  select p.name
  limit 3
]]}

Sorting of strings can be adjusted with `queryCollation` in [[^Library/Std/Config]]

## limit <expression>[, <expression>]
The `limit` clause allows you to limit the number of results, optionally with an offset.

Example:

${query[[from {1, 2, 3, 4, 5} limit 3]]}

You can also specify an offset to skip some results:

${query[[from {1, 2, 3, 4, 5} limit 3, 2]]}

## select <expression>
The `select` clause allows you to transform each item in the result set. If omitted, it defaults to returning the item itself.

Some examples:

Double each number:
${query[[from n = {1, 2, 3} select n * 2]]}

It is convenient to combine it with the [[API/table#table.select(table, keys...)]] API:
${query[[
  from p = index.tag "page" 
  select table.select(p, "name", "lastModified")
  limit 3
]]}

## Rendering the output
To render the output as a template, you can rely on the fact that queries return Lua tables. For example, to apply a template to render every page as a link:

${template.each(query[[
  from p = index.tag "page"
  order by p.lastModified desc
  limit 3
]], templates.pageItem)}

To render pages as links with their full local URL, use `templates.fullPageItem`. For more information on available templates, see [[^Library/Std/Infrastructure/Query Templates]].


================================================
FILE: website/Space Lua/Quirks.md
================================================
A few notable [[Space Lua]] quirks.

# Lua implementation notes
Space Lua is intended to be a more or less complete implementation of [Lua 5.4](https://www.lua.org/manual/5.4/). However, a few features are (still) missing:

* coroutines (not planned, not useful in the SilverBullet context)
* _ENV (planned)
* Hexadecimal numeric constants with a fractional part, or binary exponents (not supported by JavaScript number parser either)


# Conversion of Strings to Numbers

Space Lua converts strings to numbers like standard Lua.

Syntax:
* Leading and trailing whitespace (space, tab, newline, carriage return, form feed and vertical tab) is trimmed.
* The entire string after trimming trailing whitespace must form a number.
* Optional `+` or `-` signs are accepted before the number.
* _Decimal integers_ and _decimal floats_ are supported with optional exponent (e.g., `42`, `-3.5`, `.5`, `5.`, `1e3`, `-2.5E-2`).
* _Hexadecimal integers_ and _hexadecimal floats_ are also supported:
  * integers (e.g., `0x10`, `-0XFF`),
  * floats require `p` or `P` exponent (e.g., `0x1.8p1`, `-0X10.3P-1`).

Failure handling:
* In _arithmetic_ and _unary minus_ expressions invalid strings cause an Lua exception:
  `attempt to perform arithmetic on a non-number`.
* `tonumber(s)` returns `nil` on failure.
* `tonumber(s, base)` parses _signed integers_ in bases 2..36 (without decimal points or exponents) and returns `nil` on invalid input string or base.

Examples (with `tonumber` function):

Code                   | Result                  | Expected
-----------------------|-------------------------|---------
`tonumber(' 42 ')`     | ${tonumber(' 42 ')}     | `42`
`tonumber('-0xFf ')`   | ${tonumber('-0xFf ')}   | `-255`
`tonumber('0x1.8p1')`  | ${tonumber('0x1.8p1')}  | `3.0`
`tonumber('1e-2')`     | ${tonumber('1e-2')}     | `0.01`
`tonumber('abc')`      | ${tonumber('abc')}      | `nil`
`tonumber('1010', 2)`  | ${tonumber('1010', 2)}  | `10`
`tonumber(' +fF', 16)` | ${tonumber(' +fF', 16)} | `255`
`tonumber('8', 8)`     | ${tonumber('8', 8)}     | `nil`

Examples (with arithmetic operations):

Code              | Result             | Expected
------------------|--------------------|--------------------
`'0xfFfFp1'`      | ${'0xfFfFp1'}      | string: `0xfFfFp1`
`'0xffffP-3' + 0` | ${'0xffffP-3' + 0} | number: `8191.875`
`-'123E-12'`      | ${-'123E-12'}      | number: `-1.23e-10`


================================================
FILE: website/Space Lua/Thread Locals.md
================================================
#maturity/experimental

There is “magic” `_CTX` global variable available from which you can access some context-specific values. Currently the following keys are available:

* `_CTX.currentPage` providing access to the currently open page (PageMeta object)


================================================
FILE: website/Space Lua/Widget.md
================================================
The `${lua expression}` syntax can be used to implement custom widgets. If the Lua expression evaluates to a simple string, it will live preview as that string rendered as markdown. However, if the expression returns a `widget.new`-generated result value, you can do some fancier stuff.

To render a widget, call `widget.new` with a[text](https://meet.google.com/dtz-nbtq-wwf)ny of the following keys:

* `markdown`: Renders the value as markdown
* `html`: Renders a HTML string as a widget. This is somewhat brittle. Therefore, it’s preferred to use the [[^Library/Std/APIs/DOM]] API.
* `display`: Render the value either `inline` or as a `block` (defaults to `inline`)

For convenience there are `widget.markdown` and `widget.html` wrappers available, see below for examples.

## Markdown widgets

## DOM widgets
To render a custom HTML-based widget, use the [[^Library/Std/APIs/DOM]] API:

```space-lua
function marquee(text)
  return widget.html(dom.marquee {
    class = "my-marquee",
    onclick = function()
      editor.flashNotification "You clicked me"
    end,
    text
  })
end
```

We can combine this with some [[Space Style]] to style it:

```space-style
.my-marquee {
  color: purple;
}
```

Now, let’s use it (try clicking):
${marquee "Finally, marqeeeeeee!"}


================================================
FILE: website/Space Style.md
================================================
Space Style is [[Space Lua]]’s stylish sibling. It enables you to add your own styling to SilverBullet with `space-style` [[Markdown/Fenced Code Block]].

This can be used to achieve various things, such as overriding the default editor font or setting wider page widths. It is also possible to develop custom themes this way.

To apply the updated styles, either reload the client or run the ${widgets.commandButton("System: Reload")} command.

Many styles can be set with [variables](https://github.com/silverbulletmd/silverbullet/blob/main/client/styles/theme.scss) but not everything is covered. You’ll have to reverse-engineer those parts, unfortunately.

# Load order
You can tweak the CSS load order style by including a `/* priority: number */` comment:

```space-style
/* priority: 10 */
somestyle {

}
```

The following [[Space Lua/Lua Integrated Query]] is used to determine the order in which Space Style is loaded:

```lua
query[[from index.tag "space-style" order by _.priority desc]]
```

# Tag Styling
You can add custom styles to a tag by leveraging the `data-tag-name` attribute, [CSS Attribute Selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors) and custom [[Space Style]]'s. Every tag gets an attribute added to it called `data-tag-name` that is set to the tag name with the `#` symbol stripped out. So given the tag #my-cool-tag the `data-tag-name` attribute would look like:

    data-tag-name="my-cool-tag"

This allows us to do things like change the color of the #my-cool-tag
to have a purple background, limegreen text and bold font by adding the following [[Space Style]]:

```css
.sb-hashtag[data-tag-name="my-cool-tag"] {
  background: purple;
  color: limegreen;
  font-weight: bolder;
}
```

Additionally tags written using angle brackets, such as...

    #<my cool tag>

...can be styled via [[Space Style]] like this:
```css
.sb-hashtag[data-tag-name="my cool tag"] {
  background: purple;
  color: limegreen;
  font-weight: bolder;
}
```
# More examples
All the actual CSS in these examples is commented out as to not affect this very website.
```space-style
html {
  /* Changes to the default theme */
  /* Such as the accent color */
  /*--ui-accent-color: #464cfc;*/
}

html[data-theme="dark"] {
  /* Changes to the dark theme */
  /*--ui-accent-color: #464cfc;*/
}

html {
  /* Uncomment the next line to set the editor font to Courier */
  /* --editor-font: "Courier" !important; */
  /* Uncomment the next line to set the editor width to 1400px */
  /* --editor-width: 1400px !important; */
}

/* Choose another header color */
html {
  /*--top-background-color: #eee;*/
}
/* Or modify the element directly */
#sb-top {
  /*background-color: #eee !important;*/
}

/* You can even change the appearance of buttons */
button {
  /* align-items: center;
  background-color: #fff;
  border-radius: 6px;
  box-shadow: transparent 0 0 0 3px,rgba(18, 18, 18, .1) 0 6px 20px;
  box-sizing: border-box;
  color: #121212;
  cursor: pointer;
  display: inline-flex;
  flex: 1 1 auto;
  font-family: Inter,sans-serif;
  font-size: 0.6rem;
  justify-content: center;
  line-height: 1;
  margin: 0.2rem;
  outline: none;
  padding: 0.3rem 0.4rem;
  text-align: center;
  text-decoration: none;
  transition: box-shadow .2s,-webkit-box-shadow .2s;
  white-space: nowrap;
  border: 0;
  user-select: none;
  -webkit-user-select: none;
  touch-action: manipulation; */
}

button:hover {
  /* box-shadow: #121212 0 0 0 3px, transparent 0 0 0 0; */
}

/* Add custom styling to a specific tag
--> Example Tag: #my-cool-tag */
.sb-hashtag[data-tag-name="my-cool-tag"] {
  /* background: purple;
  color: limegreen;
  font-weight: bolder; */
}
/* --> Example Tag with angle brackets: #<my cool tag> */
.sb-hashtag[data-tag-name="my cool tag"] {
  /* background: purple;
  color: limegreen;
  font-weight: bolder; */
}
/* Custom Styling to all tags */
.sb-hashtag[data-tag-name] {
  /* background: yellow;
  color: goldenrod;
  border: 2px dashed goldenrod; */
}
```

Another example can be found in [[Page Decorations#Use case: pimp my page]]


================================================
FILE: website/Space.md
================================================
A _space_ is SilverBullet terminology for a workspace, or project, or instance. [Obsidian](https://obsidian.md/) calls this a vault, [LogSeq](https://logseq.com/) calls it a graph. You may think of it as a [[Folder]] or a directory — because in practical terms, that’s all it is.

Feel free to back up or manipulate your space’s folder and its files with whatever tool you like — you don’t have to use SilverBullet exclusively. You may want to turn your space’s folder into a git repository, for instance, and do version control and back-ups that way.

A space consists of
* [[Folder]]
* [[Page]] and [[Meta Page]]
* [[Document]]

# Folder layout
Every space in SilverBullet at the very least has an index page (by default named `index.md`) and a [[CONFIG]] page (named `CONFIG.md`). If you use [[Library]], you will likely have a `Library/` folder as well.


================================================
FILE: website/Special Pages.md
================================================
SilverBullet has a few “special pages.” The convention is to always use ALL CAPS for these page names. Generally the format of these pages is that they can contain arbitrary text, but embedded in them is some sort of fenced code block (triple backtick blocks) that encode something special. What _exactly_ depends on the page.

All special pages except [[CONFIG]] are optional: if they don’t exist, well, they don’t do anything.

Here are the current list of “special pages” known to humankind:

* [[CONFIG]] for setting various settings


================================================
FILE: website/Sync.md
================================================
SilverBullet is an offline-first web application. Therefore it keeps a copy of your space locally in your browser. It implements a sync engine to continuously keep this local copy in sync with the server (for technical details, see [[Architecture]]).

Sync happens:
* For the entire space: roughly every 20 seconds
* For the currently open file: every 4-5 seconds

In addition, you can use the ${widgets.commandButton "Sync: Space"} and ${widgets.commandButton "Sync: File"} commands to trigger these manually.

Sync runs in a service worker and therefore only runs when the service worker is active. The service worker is automatically kept active for as long as you have a SilverBullet tab or window open. Some browsers slow web apps down when they are in the background or not in an active tab. This may also affect whether sync happens in the background. Most browsers (including mobile ones), keep service workers running for a little while (perhaps up to a minute), even when e.g. the screen is locked, or another app is activated.

## Status
If sync takes longer than a second, a black circle progress indicator will appear in the [[Top Bar]].

## Conflicts
When you use multiple clients and make changes to the same files simultaneously conflicts may happen. SilverBullet will not attempt to try to merge these conflicts, but rather create a conflicting copy when this happens. You will be notified in the UI when this occurs.

## Configuration
You can tweak what files are synced locally via a few `config.*` configuration options.

> **note** Note
> Sync related configuration **need** to be done in the [[CONFIG]] page specifically. If they are configured elsewhere they will not be picked up.

By default all your pages are synced, but [[Document]] are not. They are fetched on demand. If you would also like to sync documents (so you can access them when offline), you can do so by setting the following in a Space Lua block in [[CONFIG]]:

    config.set("sync.documents", true)

In addition, you can fine-tune what files you do _not_ want to sync using [gitignore](https://git-scm.com/docs/gitignore) syntax assigned to the `sync.ignore` option, which accepts either a single string, or a list of strings:

    config.set("sync.ignore", {
      -- Don't sync PDFs and MP4 files
      "*.pdf",
      "*.mp4"
    })

After making changes to these options, you need to reload your client and wait for a sync cycle to kick in until they are applied.


================================================
FILE: website/TLS.md
================================================
SilverBullet relies on a few advanced browser features to operate (specifically service workers, crypto APIs and clipboard APIs). These features are _only_ enabled by browsers when websites are accessed via `http://localhost` or via [TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security), that is — a `https://` URL.

This is not a limitation imposed by SilverBullet, it is a restriction encoded in web standards. Therefore, to use SilverBullet you need to either access it via `localhost` or put a TLS certificate on it.

# localhost (http)
You can do this in a few ways:

## Run SilverBullet locally
If you run SilverBullet _locally_ on your machine, this is the easiest option. Everything runs fine as long as the browser sees `localhost` or `127.0.0.1` appear in the URL, even with `http://`.

The obvious drawback of this approach is that your SilverBullet instance is only accessible from the machine you run it on.

## SSH tunnel
If you run SilverBullet on a remote server in your network that you have SSH access to, you can “fake” a localhost URL by creating an SSH tunnel.

You can do this as follows:
```bash
ssh -N -L 3002:localhost:3000 user@someip
```

This will tunnel your localhost port 3002 (accessible via http://localhost:3002) to port 3000 on the `someip` server. Stop the tunnel with `Ctrl-c`. To keep the tunnel running in the background, add the `-f` option.

Compared to running it locally this is _slightly_ better, because you can now access SilverBullet from any machine you can create an SSH tunnel from.

## Local (Caddy) reverse proxy
If you don’t (or can’t) use SSH, you can create a [Caddy](https://caddyserver.com/) reverse proxy. [Install Caddy](https://caddyserver.com/docs/install) first, then create a `Caddyfile`:

```
localhost:3002 {
    reverse_proxy someip:3000
}
```
And run Caddy with:
```bash
caddy run --config /path/to/Caddyfile
```
Now you can access your remote server via `http://localhost:3002`

# TLS (https)
For this, you need to get your hands on a TLS certificate.

A few options:

## Tailscale SSL certificate
If you’re a [Tailscale](https://tailscale.com/) user, this a simple solution. If not, you may consider becoming one — it’s a solid service, very friendly to [[Self Hosted|self hosters]], and _free_ for this use case.

Part of the [guide to setup SilverBullet on Linux](https://community.silverbullet.md/t/install-silverbullet-on-a-64-bit-debian-ubuntu-raspianos-internet-accessible-via-tailscale/48) are instructions on how to install  (a free service) and use it to expose a local server (like SilverBullet) locally on your VPN, or the Internet — a setup that gives you a `.ts.net` subdomain with TLS certificate. 

The advantage of this approach is that you have the choice to expose your SilverBullet to the wide Internet, or limit it to just your Tailscale VPN. The disadvantage is that you now rely on a third party (Tailscale).

## Cloud VM with Caddy
There a various affordable providers of cloud servers that can be used to self-host SilverBullet in the cloud. It relatively easy to get a TLS certificate issued on a publicly exposed server.

The recommended approach for this requires two things:

1. An Internet exposed cloud server. Affordable options include:
   * [Hetzner Cloud](https://hetzner.cloud/?ref=6jW03LSGlJKf) (referral link, EU based) starting from about 3.5 euro/month.
   * [Vultr](https://www.vultr.com/products/cloud-compute/) starting from about $2.5/month.
2. A domain name of your own that you can configure DNS records on to point to your server, or using a service like [DuckDNS](https://www.duckdns.org/) which gives you a `*.duckdns.org` sub-domain for free.

After deploying SilverBullet on the VM (with [[Authentication]] enabled, obviously), you can deploy [Caddy](https://caddyserver.com/) next to it as a reverse proxy. Caddy can automatically request TLS certificates using [Let’s Encrypt](https://letsencrypt.org/). 

For this, [install Caddy](https://caddyserver.com/docs/install) into your VM. Then, in your Caddyfile (usually located `/etc/caddy/Caddyfile`) put:

```
silverbullet.mydomain.com {
    reverse_proxy localhost:3000
}
```

Replace `silverbullet.mydomain.com` with any domain that you have configured to resolve to the IP of your server, and the `:3000` port with whatever local port you run SilverBullet on.

Restart Caddy and access SilverBullet via `https://silverbullet.mydomain.com`. On first load, Caddy will work with Let’s Encrypt to issue a TLS certificate and install (and update) it automatically, this may take a minute, so be patient.

## Self-signed certificate
It is possible to self sign certificates and use those. Search the Internet for instructions on how to do this, [here is website that describes the way it works and how to do it](https://www.ssldragon.com/blog/what-is-self-signed-certificate/).


================================================
FILE: website/Tag Picker.md
================================================
Triggered via ${widgets.commandButton("Navigate: Tag Picker")}, allows you to quickly navigate to [[Tag|tag pages]].

================================================
FILE: website/Tag.md
================================================
Tags in SilverBullet are used to encode types of [[Object|Objects]], they’re similar to tables in relational databases, or classes in object-oriented programming.

Every [[Object]] has a main `tag`, which signifies the type of object being described. In addition, any number of additional tags can be assigned as well via the `tags` attribute. You can use either the main `tag` or any of the `tags` as query sources in [[Space Lua/Lua Integrated Query]].

# Built-in tags
${widgets.subPages("Object")}

# Custom tags
Tags are impliclty created when you use the [[Markdown/Hashtags]] syntax. Their behavior and functionality can be further expanded via the [[API/tag]] API.



================================================
FILE: website/Task.md
================================================
The task notation syntax is a [[Markdown]] extension to write down tasks.

Its most basic form is:

    * [ ] This is my first task

which renders as follows:

* [ ] This is my first task

SilverBullet allows you to simply toggle the complete state of a task by clicking the checkbox.

All tasks across your space are automatically [[Object/task|indexed]] and can therefore be [[Space Lua/Lua Integrated Query|queried]].

# Custom states
Tasks support the default `x` and ` ` states (done and not done), but custom states as well. Support for this is still basic, however.

Example:

* [NOT STARTED] Task 1
  Fix having global variables work in markdown.expandMarkdown
* [IN PROGRESS] Task 2

Restrictions:

* Task states cannot contain `:` to avoid ambiguity with [[Attribute]] syntax.

To define custom states explicitly (and get code completion for them), use the [[API/taskState]] API.


================================================
FILE: website/Template.md
================================================
A template in SilverBullet is simply a page or string using [[Space Lua#Expressions]].

They’re typically used to dynamically render parts of pages using [[Space Lua]] and in [[Page Template]] and [[Slash Templates]].

================================================
FILE: website/Top Bar.md
================================================
The top bar contains:

* [[Page Namer]]
* The home button, navigating to your [[Index Page]]
* [[Page Picker]]
* [[Command Palette]]

In addition the top bar color indicates the current online status:

* **Gray** indicates SilverBullet is connected to the server
* **Yellow** indicates not having a server connection

================================================
FILE: website/Transclusions.md
================================================
Transclusions are an extension of the [[Markdown]] syntax enabling inline embedding of content.

The general syntax is `![[path]]`. Two types of transclusions are currently supported:

# Media
Syntax: `![[path/to/image.jpg]]` see [[Document#Embedding]] for more details.

Media resizing is also supported:
![[Document#Media resizing]]
# Pages
Syntax:
* `![[page name]]` embed an entire page
* `![[page name#header]]` embed only a section (guarded by the given header)


================================================
FILE: website/Troubleshooting.md
================================================
Running into trouble? Sorry to hear it. Let’s figure this out.

Here are some things you can try when things don’t work, if you’re still stuck — ask the [community for help](https://community.silverbullet.md/).

The starting point is always [[Log|Logs]] to see if you see anything suspicious.

# Disable features
Depending on the situation you may (temporarily) disable various client features by appending any of the following to any SilverBullet URL (e.g. `http://localhost:3000/?disableSpaceLua=1`):
* `?disableSync=1` disables synchronization with the server
* `?disableSpaceLua=1` disables loading of all [[Space Lua]] scripts
* `?disableSpaceStyle=1` disables loading of all [[Space Style]]
* `?disablePlugs=1` disables loading of all non built-in [[Plugs]]

# Client reset
If all else fails, you may try to perform a full client reset, simply add `?resetClient=1` to any SilverBullet URL.

This will do the following:
1. Confirm you want to perform a full client reset
2. Wipe all data stored locally in your browser (IndexedDB, unregister the service worker)
3. Reload the client initiating a full resync


================================================
FILE: website/URI.md
================================================
URIs in SilverBullet are a means to point to locations outside of your [[Space|Space]], APIs to read and write to URIs are available via [[API/net]].

Use cases:
* [[Repository]]
* [[Share]]

================================================
FILE: website/Vim.md
================================================
SilverBullet has a basic Vim mode. You can toggle it using the ${widgets.commandButton("Editor: Toggle Vim Mode")} command.

In addition, it supports various ex commands that you can run as you would expect, for instance: `:imap jj <Esc>`.

The [[CONFIG]] file can also be used to define and extend these commands. It supports the following:

- **Key unmapping** - disable existing keybindings
- **Custom mappings** - using `map` and `noremap`
- **Ex command definitions** - map custom ex commands to built-in or custom SilverBullet commands

SilverBullet [[Command]] bindings take precedence over Vim bindings, you may wish to update or disable some [[Keyboard Shortcuts]] or remap the desired functionality in Vim as shown below.

To manually reload the `vim` section of your [[CONFIG]], use the ${widgets.commandButton("Editor: Vim: Load Vim Config")} command.

# Configuration
Using Space Lua:

```lua
config.set {
  vim = {
    unmap = {
      "<Space>",
      {
        key = "<C-c>",
        mode = "insert",
      },
    },
    map = {
      {
        map = "jj",
        to = "<Esc>",
        mode = "insert",
      },
    },
    noremap = {
      {
        map = "<",
        to = "<gv",
        mode = "visual",
      },
      {
        map = "<Space>ff",
        to = ":findfile<CR>",
        mode = "normal",
      },
      {
        map = "<Space>rc",
        to = ":runcommand<CR>",
        mode = "normal",
      },
    },
    commands = {
      {
        command = "Navigate: Page Picker",
        ex = "findfile",
      },
      {
        command = "Open Command Palette",
        ex = "runcommand",
      },
    }
  }
}
```


================================================
FILE: website/YAML.md
================================================
YAML stands for “YAML Ain’t Markup Language.” More information can be found at [the YAML website](https://yaml.org/).

SilverBullet uses YAML in various contexts, specifically [[Frontmatter]].

# Internal links
Many string values can be written directly in YAML without any quoting, like:
```yaml
property: value
```

However when you want to reference [[Link|a page]] you will need to quote the full link:
```yaml
some page: "[[Pages]]"
list of pages:
  - "[[Pages]]"
  - "[[Links]]"
```

This is because the square brackets used in the internal link format have a meaning in YAML as well. So an unquoted link is parsed as list inside a list:
```yaml
some page: [[Pages]]
equivalent yaml: [
    [ "Pages" ]
]
```


================================================
FILE: website/Zef.md
================================================
Hi! My name is [Zef Hemel](https://github.com/zefhemel/), and I have been working on SilverBullet since February 2022. For most of this time it’s been purely a labor of love and was (implicitly) funded by the companies I worked for. 

Today, I try more deliberately to spend time on making SilverBullet everything it can be, and for this I rely on [[Funding]].


================================================
FILE: website/Zero Tracking.md
================================================
---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 <PATH-TO-YOUR-SPACE>
```

Alternatively, to build:

```shell
make
```

To run the resulting server:

```shell
./silverbullet <PATH-TO-YOUR-SPACE>
```

### 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 <PATH-TO-YOUR-SPACE>:/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<PageMeta[]> {
  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<void> {
  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("<options> <manifest paths> | <command> (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 <path:string>",
    "Folder to put the resulting .plug.json file into",
    { default: "." },
  )
  .option("-c, --config <path:string>", "Path to deno.json file to use")
  .option("--runtimeUrl <url:string>", "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<AssetBundle> {
  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<string, { data: DataUrl; mtime: number }>;

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<void>((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<boolean>(["sync", "documents"], false);
  let syncIgnore = config!.get<string | string[]>(["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<string> {
  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<Config> {
  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<any>();
  // 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<WidgetCacheItem>(100); // bodyText -> WidgetCacheItem
  debouncedWidgetCacheFlush = throttle(() => {
    this.ds.set(["cache", "widgets"], this.widgetCache.toJSON())
      .catch(
        console.error,
      );
  }, 2000);
  private widgetHeightCache = new LimitedMap<number>(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<any[]> {
    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<void> {
    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<T>(
    tag: string,
    query: LuaCollectionQuery,
    scopedVariables?: Record<string, any>,
  ): Promise<T[]> {
    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<PageMeta>("page", {});
      // Overlay augmented meta values
      await this.pageMetaAugmenter.augmentObjectArray(allPages, "ref");
      // Fetch aspiring pages
      const aspiringPageNames = await this.queryLuaObjects<string>(
        "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<DocumentMeta>(
      "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<FilterOption | undefined> {
    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<string | undefined> {
    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<boolean> {
    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<CompletionResult | SlashCompletions | null> {
    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<CompletionResult | null> {
    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<PageMeta>(
          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<StyleObject>(
      "space-style",
      {
        objectVariable: "_",
        orderBy: [{
          expr: parseExpressionString("_.priority"),
          desc: true,
        }],
      },
    );
    if (!spaceStyles) {
      return;
    }

    // Prepare separate <style> tag per custom style (for robustness)
    const customStylesContent = spaceStyles.map((s) =>
      "<style>" + s.style + "</style>"
    ).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<string, Command> {
    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<void>((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<SilverBulletHooks>;
  // ... and hooks
  commandHook!: CommandHook;
  slashCommandHook!: SlashCommandHook;
  namespaceHook!: PlugNamespaceHook;
  codeWidgetHook!: CodeWidgetHook;
  documentEditorHook!: DocumentEditorHook;
  mqHook!: MQHook;

  serviceRegistry!: ServiceRegistry;

  // Space Lua
  spaceLuaEnv: SpaceLuaEnvironment;
  readonly scriptCommands = new Map<string, Command>();
  scriptsLoaded: boolean = false;

  // Known files (for UI)
  readonly allKnownFiles = new Set<string>();
  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<SilverBulletHooks>(
        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<Record<string, Command>>("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 =
  '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-copy"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></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<Decoration>[] = [];
  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<DomWidget>();

export interface DomWidget {
  dom?: HTMLElement;

  renderContent(
    div: HTMLElement,
    cachedHtml: string | undefined,
  ): Promise<void>;
}

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(/<!--[\s\S]*?-->/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<string | undefined>(
            "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 <a> 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), <scheme>:// 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<Decoration>[] = [];
    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<Decoration>[] = [];
    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<Diagnostic[]> => {
    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<Decoration>[] = [];

    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<LuaWidgetContent | null>;

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<string, (event: EventPayLoad) => 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:
          '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-refresh-cw"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>',
        listener: (e) => {
          e.stopPropagation();
          this.client.clientSystem.localSyscall(
            "system.invokeFunction",
            ["index.refreshWidgets"],
          ).catch(console.error);
        },
      },
    ));

    if (copyContent) {
      buttonBar.appendChild(createButton(
        {
          title: "Copy",
          icon:
            `<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-copy"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>`,
          listener: (e) => {
            e.stopPropagation();
            this.client.clientSystem.localSyscall(
              "editor.copyToClipboard",
              [copyContent],
            ).catch(console.error);
          },
        },
      ));
    }

    if (this.inPage) {
      buttonBar.appendChild(createButton(
        {
          title: "Edit",
          icon:
            '<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-edit"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>',
          listener: (e) => {
            e.stopPropagation();
            moveCursorIntoText(this.client, this.codeText);
          },
        },
      ));
    }

    if (this.openRef) {
      buttonBar.appendChild(createButton(
        {
          title: "Open",
          icon:
            '<svg viewBox="0 0 24 24" width="15" height="15" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round" class="css-i6dzq1"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path><circle cx="12" cy="12" r="3"></circle></svg>',
          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<string> {
  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<SmartQuotesConfig>("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<Decoration>[] = [];
    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<LuaWidgetContent[] | null>,
    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<HTMLDivElement>(":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<DecorationSet>({
    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<string, (event: EventPayLoad) => 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<string>;
  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 (
    <FilterList
      placeholder={mode === "page"
        ? "Page"
        : (mode === "meta"
          ? "#meta page"
          : (mode === "document"
            ? "Document"
            : "Any page or Document, also hidden"))}
      label="Open"
      options={options}
      vimMode={vimMode}
      darkMode={darkMode}
      phrasePreprocessor={(phrase) => {
        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 <code>Enter</code> to open the selected ${openablePageNoun}` +
        (allowNew
          ? `, or <code>Shift-Enter</code> 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 = (
    <AlwaysShownModal
      onCancel={() => {
        callback();
      }}
    >
      <div className="sb-prompt">
        <label>{message}</label>
        <MiniEditor
          text={defaultValue || ""}
          vimMode={vimMode}
          vimStartInInsertMode={true}
          focus={true}
          darkMode={darkMode}
          onEnter={(text) => {
            callback(text);
            return true;
          }}
          onEscape={() => {
            callback();
          }}
          onChange={(text) => {
            setText(text);
          }}
          editable={true}
        />
        <div className="sb-prompt-buttons">
          <Button
            primary={true}
            onActivate={() => {
              callback(text);
            }}
          >
            Ok
          </Button>
          <Button
            onActivate={() => {
              callback();
            }}
          >
            Cancel
          </Button>
        </div>
      </div>
    </AlwaysShownModal>
  );

  return returnEl;
}

export function Confirm({
  message,
  callback,
}: {
  message: string;
  callback: (value: boolean) => void;
}) {
  const okButtonRef = useRef<HTMLButtonElement>(null);
  setTimeout(() => {
    okButtonRef.current?.focus();
  });
  const returnEl = (
    <AlwaysShownModal
      onCancel={() => {
        callback(false);
      }}
    >
      <div className="sb-prompt">
        <label>{message}</label>
        <div className="sb-prompt-buttons">
          <Button
            buttonRef={okButtonRef}
            primary={true}
            onActivate={() => {
              callback(true);
            }}
          >
            Ok
          </Button>
          <Button
            onActivate={() => {
              callback(false);
            }}
          >
            Cancel
          </Button>
        </div>
      </div>
    </AlwaysShownModal>
  );

  return returnEl;
}

export function Button({
  children,
  primary,
  onActivate,
  buttonRef,
}: {
  children: ComponentChildren;
  primary?: boolean;
  onActivate: () => void;
  buttonRef?: Ref<HTMLButtonElement>;
}) {
  return (
    <button
      ref={buttonRef}
      type="button"
      className={primary ? "sb-button-primary" : "sb-button"}
      onClick={(e) => {
        e.stopPropagation();
        e.preventDefault();
        onActivate();
      }}
      onKeyDown={(e) => {
        if (e.key === "Enter") {
          e.stopPropagation();
          e.preventDefault();
          onActivate();
        }
      }}
    >
      {children}
    </button>
  );
}

export function AlwaysShownModal({
  children,
  onCancel,
}: {
  children: ComponentChildren;
  onCancel?: () => void;
}) {
  const dialogRef = useRef<HTMLDialogElement>(null);

  useEffect(() => {
    dialogRef.current?.showModal();
  }, []);

  return (
    <dialog
      className="sb-modal-box"
      onCancel={(e: Event) => {
        e.preventDefault();
        onCancel?.();
      }}
      onKeyDown={(e) => {
        e.stopPropagation();
      }}
      ref={dialogRef}
    >
      {children}
    </dialog>
  );
}


================================================
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<string, Command>;
  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 (
    <FilterList
      label="Run"
      placeholder="Command"
      options={options}
      allowNew={false}
      icon={Terminal}
      vimMode={vimMode}
      darkMode={darkMode}
      helpText="Start typing the command name to filter results, press <code>Enter</code> to run."
      onSelect={(opt) => {
        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<FeatherProps>;
}) {
  const [text, setText] = useState("");
  const [matchingOptions, setMatchingOptions] = useState(
    fuzzySearchAndSort(
      preFilter ? preFilter(options, "") : options,
      "",
    ),
  );
  const [selectedOption, setSelectionOption] = useState(0);

  const selectedElementRef = useRef<HTMLDivElement>(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 = (
    <AlwaysShownModal
      onCancel={() => {
        onSelect(undefined);
      }}
    >
      <div
        className="sb-header"
        onClick={(e) => {
          // Allow tapping/clicking the header without closing it
          e.stopPropagation();
        }}
      >
        <label>{label}</label>
        <MiniEditor
          text={text}
          vimMode={vimMode}
          vimStartInInsertMode={true}
          focus={true}
          darkMode={darkMode}
          placeholderText={placeholder}
          onEnter={(_newText, shiftDown) => {
            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}
        />
      </div>
      <div
        className="sb-help-text"
        dangerouslySetInnerHTML={{ __html: helpText }}
      >
      </div>
      <div className="sb-result-list" tabIndex={-1}>
        {matchingOptions && matchingOptions.length > 0
          ? (() => {
            let optionIndex = 0;

            return matchingOptions.map((option) => {
              const currentOptionIndex = optionIndex;
              optionIndex++;

              return (
                <div
                  key={`option-${currentOptionIndex}`}
                  ref={selectedOption === currentOptionIndex
                    ? selectedElementRef
                    : undefined}
                  className={(selectedOption === currentOptionIndex
                    ? "sb-option sb-selected-option"
                    : "sb-option") +
                    (option.cssClass
                      ? " sb-decorated-object " + option.cssClass
                      : "")}
                  onMouseMove={() => {
                    if (selectedOption !== currentOptionIndex) {
                      setSelectionOption(currentOptionIndex);
                    }
                  }}
                  onClick={(e) => {
                    e.stopPropagation();
                    onSelect(option);
                  }}
                >
                  {Icon && (
                    <span className="sb-icon">
                      <Icon width={16} height={16} />
                    </span>
                  )}
                  <span className="sb-name">
                    {(option.prefix ?? "") + option.name}
                  </span>
                  {option.hint && (
                    <span
                      className={"sb-hint" +
                        (option.hintInactive ? " sb-hint-inactive" : "")}
                    >
                      {option.hint}
                    </span>
                  )}
                  <div className="sb-description">
                    {option.description}
                  </div>
                </div>
              );
            });
          })()
          : null}
      </div>
    </AlwaysShownModal>
  );

  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<void>;
  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<HTMLDivElement>(null);
  const editorViewRef = useRef<EditorView>();
  const vimModeRef = useRef<string>("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<MiniEditorEvents>();

  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 (
    <div
      class="sb-mini-editor"
      onKeyDownCapture={(e) => {
        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<HTMLIFrameElement>(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 (
    <div className="sb-panel" style={{ flex: config.mode }}>
      <iframe
        srcDoc={html}
        ref={iFrameRef}
        style={{ visibility: "hidden" }}
        onLoad={() => iFrameRef.current!.style.visibility = "visible"}
      />
    </div>
  );
}


================================================
FILE: client/components/panel_html.ts
================================================
export const panelHtml = `<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <base target="_top" href="{{.HostPrefix}}">
    <meta name='color-scheme' content='dark light'>
<script>
const pendingRequests = new Map();
let syscallReqId = 0;

globalThis.syscall = async (name, ...args) => {
  return await new Promise((resolve, reject) => {
    syscallReqId++;
    pendingRequests.set(syscallReqId, { resolve, reject });
    globalThis.parent.postMessage({
      type: "syscall",
      id: syscallReqId,
      name,
      args,
    }, "*");
  });
};

let oldHeight = undefined;
let heightChecks = 0;

globalThis.addEventListener("message", (message) => {
  const data = message.data;
  switch (data.type) {
    case "html":
      document.body.innerHTML = data.html;
      if(data.theme) {
        document.getElementsByTagName("html")[0].setAttribute("data-theme", data.theme);
      }
      if (data.script) {
        try {
          eval(data.script);
        } catch (e) {
          console.error("Error evaling script", e);
        }
      }
      setTimeout(() => {
        oldHeight = undefined;
        heightChecks = 0;
        updateHeight();
      });
      break;
    case "syscall-response":
      {
        const syscallId = data.id;
        const lookup = pendingRequests.get(syscallId);
        if (!lookup) {
          console.log(
            "Current outstanding requests",
            pendingRequests,
            "looking up",
            syscallId,
          );
          throw Error("Invalid request id");
        }
        pendingRequests.delete(syscallId);
        if (data.error) {
          lookup.reject(new Error(data.error));
        } else {
          lookup.resolve(data.result);
        }
      }

      break;
  }
});

function updateHeight() {
  const body = document.body, html = document.documentElement;
  let height = Math.max(body.offsetHeight, html.offsetHeight);
  heightChecks++;
  if(height !== oldHeight) {
    oldHeight = height;
    globalThis.parent.postMessage({
      type: "setHeight",
      height: height,
    });
  }
  if(heightChecks < 25) {
    setTimeout(updateHeight, 100);
  }
}

function loadJsByUrl(url,integrity=null) {
  const script = document.createElement("script");
  script.src = url;
  if(integrity){
    script.integrity=integrity;
    script.crossOrigin="anonymous"; //for some weird reason this attribute is case sensitive when used in JS
  }

  return new Promise((resolve) => {
    script.onload = resolve;
    document.documentElement.firstChild.appendChild(script);
  });
}
</script>
</head>
<body>

</body>
</html>`;


================================================
FILE: client/components/top_bar.tsx
================================================
import type { ComponentChildren, FunctionalComponent } from "preact";
import type { Notification } from "@silverbulletmd/silverbullet/type/client";
import { MiniEditor } from "./mini_editor.tsx";

export type ActionButton = {
  icon: FunctionalComponent<any>;
  description: string;
  class?: string;
  callback: () => void;
  href?: string;
  mobile?: boolean;
};

export function TopBar({
  pageName,
  unsavedChanges,
  isOnline,
  isLoading,
  notifications,
  onRename,
  actionButtons,
  darkMode,
  vimMode,
  progressPercentage,
  progressType,
  lhs,
  onClick,
  rhs,
  pageNamePrefix,
  cssClass,
  mobileMenuStyle,
}: {
  pageName?: string;
  unsavedChanges: boolean;
  isOnline: boolean;
  isLoading: boolean;
  notifications: Notification[];
  darkMode?: boolean;
  vimMode: boolean;
  progressPercentage?: number;
  progressType?: string;
  onRename: (newName?: string) => Promise<void>;
  onClick: () => void;
  actionButtons: ActionButton[];
  lhs?: ComponentChildren;
  rhs?: ComponentChildren;
  pageNamePrefix?: string;
  cssClass?: string;
  mobileMenuStyle?: string;
}) {
  return (
    <div
      id="sb-top"
      className={isOnline ? undefined : "sb-sync-error"}
      onClick={onClick}
    >
      {lhs}
      <div className="main">
        <div className="inner">
          <div className="wrapper">
            <div className="sb-page-prefix">{pageNamePrefix}</div>
            <span
              id="sb-current-page"
              className={(isLoading
                ? "sb-loading"
                : unsavedChanges
                ? "sb-unsaved"
                : "sb-saved") +
                (cssClass ? " sb-decorated-object " + cssClass : "")}
            >
              <MiniEditor
                text={pageName ?? ""}
                vimMode={vimMode}
                darkMode={darkMode}
                onBlur={(newName) => {
                  if (newName !== pageName) {
                    return onRename(newName);
                  } else {
                    return onRename();
                  }
                }}
                onEnter={(newName) => {
                  onRename(newName);
                }}
                editable={!client.ui.viewState.uiOptions.forcedROMode &&
                  !client.bootConfig.readOnly}
              />
            </span>
            {notifications.length > 0 && (
              <div className="sb-notifications">
                {notifications.map((notification) => (
                  <div
                    key={notification.id}
                    className={`sb-notification-${notification.type}`}
                  >
                    {notification.message}
                  </div>
                ))}
              </div>
            )}
            <div className="sb-sync-progress">
              {progressPercentage !== undefined &&
                (
                  <div
                    className="progress-wrapper"
                    title={`${progressType} progress: ${progressPercentage}%`}
                  >
                    <div
                      className="progress-bar"
                      style={`background: radial-gradient(closest-side, var(--top-background-color) 79%, transparent 80% 100%), conic-gradient(var(--progress-${progressType}-color) ${progressPercentage}%, var(--progress-background-color) 0);`}
                    >
                      {progressPercentage}
                    </div>
                  </div>
                )}
            </div>
            <div
              className={"sb-actions " +
                (mobileMenuStyle ? mobileMenuStyle : "")}
            >
              {actionButtons.map((actionButton) => {
                const button = (
                  <button
                    type="button"
                    onClick={(e) => {
                      e.preventDefault();
                      e.stopPropagation();
                      actionButton.callback();
                    }}
                    onBlur={() => {
                      // Close the hamburger menu in mobile mode if the action button loses focus after callback
                      if (mobileMenuStyle === "hamburger") {
                        document.querySelector("#sb-top .sb-actions.hamburger")
                          ?.classList.remove("open");
                      }
                    }}
                    title={actionButton.description}
                    className={actionButton.class}
                  >
                    <actionButton.icon size={18} />
                  </button>
                );

                return actionButton.href
                  ? (
                    <a href={actionButton.href} key={actionButton.href}>
                      {button}
                    </a>
                  )
                  : button;
              })}
            </div>
          </div>
        </div>
      </div>
      {rhs}
    </div>
  );
}


================================================
FILE: client/components/widget_sandbox_iframe.ts
================================================
import type { Client } from "../client.ts";
import { panelHtml } from "./panel_html.ts";
import type { CodeWidgetContent } from "@silverbulletmd/silverbullet/type/client";

/**
 * Implements sandbox widgets using iframe with a pooling mechanism to speed up loading
 */

type PreloadedIFrame = {
  // The wrapped iframe element
  iframe: HTMLIFrameElement;
  // Has the iframe been used yet?
  used: boolean;
  // Is it ready (that is: has the initial load happened)
  ready: Promise<void>;
};

const iframePool = new Set<PreloadedIFrame>();
const desiredPoolSize = 3;

updatePool();

function updatePool(exclude?: PreloadedIFrame) {
  let availableFrames = 0;
  // Iterate over all iframes
  for (const preloadedIframe of iframePool) {
    if (preloadedIframe === exclude) {
      continue;
    }
    if (
      // Is this iframe in use, but has it since been removed from the DOM?
      preloadedIframe.used && !document.body.contains(preloadedIframe.iframe)
    ) {
      // Ditch it
      // console.log("Garbage collecting iframe", preloadedIframe);
      iframePool.delete(preloadedIframe);
    }
    if (!preloadedIframe.used) {
      availableFrames++;
    }
  }
  // And after, add more iframes if needed
  for (let i = 0; i < desiredPoolSize - availableFrames; i++) {
    iframePool.add(prepareSandboxIFrame());
  }
}

export function prepareSandboxIFrame(): PreloadedIFrame {
  // console.log("Preloading iframe");
  const iframe = document.createElement("iframe");

  // Empty page with current origin. Handled this differently before, but "dock apps" in Safari (PWA implementation) seem to have various restrictions
  // This one works in all browsers, although it's probably less secure
  iframe.src = "about:blank";
  iframe.style.visibility = "hidden";

  const ready = new Promise<void>((resolve) => {
    iframe.onload = () => {
      iframe.contentDocument!.write(panelHtml);
      iframe.style.visibility = "visible";
      // Now ready to use
      resolve();
    };
  });
  return {
    iframe,
    used: false,
    ready,
  };
}

function claimIFrame(): PreloadedIFrame {
  for (const preloadedIframe of iframePool) {
    if (!preloadedIframe.used) {
      // console.log("Took iframe from pool");
      preloadedIframe.used = true;
      updatePool(preloadedIframe);
      return preloadedIframe;
    }
  }
  // Nothing available in the pool, let's spin up a new one and add it to the pool
  console.warn("Had to create a new iframe on the fly, this shouldn't happen");
  const newPreloadedIFrame = prepareSandboxIFrame();
  newPreloadedIFrame.used = true;
  iframePool.add(newPreloadedIFrame);
  return newPreloadedIFrame;
}

export function broadcastReload() {
  for (const preloadedIframe of iframePool) {
    if (preloadedIframe.used && preloadedIframe.iframe?.contentWindow) {
      // Send a message to the global object, which the iframe is listening to
      globalThis.dispatchEvent(
        new MessageEvent("message", {
          source: preloadedIframe.iframe.contentWindow,
          data: {
            type: "reload",
          },
        }),
      );
    }
  }
}

export function mountIFrame(
  preloadedIFrame: PreloadedIFrame,
  client: Client,
  widgetHeightCacheKey: string | null,
  content: CodeWidgetContent | null | Promise<CodeWidgetContent | null>,
  onMessage?: (message: any) => void,
) {
  const iframe = preloadedIFrame.iframe;

  preloadedIFrame.ready.then(async () => {
    const messageListener = (evt: any) => {
      (async () => {
        if (evt.source !== iframe.contentWindow) {
          return;
        }
        const data = evt.data;
        if (!data) {
          return;
        }
        switch (data.type) {
          case "syscall": {
            const { id, name, args } = data;
            try {
              const result = await client.clientSystem.localSyscall(name, args);
              if (!iframe.contentWindow) {
                // iFrame already went away
                return;
              }
              iframe.contentWindow!.postMessage({
                type: "syscall-response",
                id,
                result,
              });
            } catch (e: any) {
              if (!iframe.contentWindow) {
                // iFrame already went away
                return;
              }
              iframe.contentWindow!.postMessage({
                type: "syscall-response",
                id,
                error: e.message,
              });
            }
            break;
          }
          case "setHeight":
            // iframe.height = data.height + "px";
            iframe.style.height = data.height + "px";
            if (widgetHeightCacheKey) {
              client.setCachedWidgetHeight(
                widgetHeightCacheKey,
                data.height,
              );
            }
            break;
          default:
            if (onMessage) {
              onMessage(data);
            }
        }
      })().catch((e) => {
        console.error("Message listener error", e);
      });
    };

    // Subscribe to message event on global object (to receive messages from iframe)
    globalThis.addEventListener("message", messageListener);
    // Only run this code once
    iframe.onload = null;
    const resolvedContent = await Promise.resolve(content);
    if (!iframe.contentWindow) {
      console.warn("Iframe went away or content was not loaded");
      return;
    }
    if (resolvedContent) {
      if (resolvedContent.html) {
        iframe.contentWindow!.postMessage({
          type: "html",
          html: resolvedContent.html,
          script: resolvedContent.script,
          theme: document.getElementsByTagName("html")[0].dataset.theme,
        });
      } else if (resolvedContent.url) {
        iframe.contentWindow!.location.href = resolvedContent.url;
        if (resolvedContent.height) {
          iframe.style.height = resolvedContent.height + "px";
          if (widgetHeightCacheKey) {
            client.setCachedWidgetHeight(
              widgetHeightCacheKey!,
              resolvedContent.height,
            );
          }
        }
        if (resolvedContent.width) {
          iframe.width = resolvedContent.width + "px";
        }
      }
    }
  }).catch(console.error);
}

export function createWidgetSandboxIFrame(
  client: Client,
  widgetHeightCacheKey: string | null,
  content: CodeWidgetContent | null | Promise<CodeWidgetContent | null>,
  onMessage?: (message: any) => void,
) {
  // console.log("Claiming iframe");
  const preloadedIFrame = claimIFrame();
  mountIFrame(
    preloadedIFrame,
    client,
    widgetHeightCacheKey,
    content,
    onMessage,
  );
  return preloadedIFrame.iframe;
}


================================================
FILE: client/config.test.ts
================================================
import { assert, assertEquals, assertThrows } from "@std/assert";
import { Config } from "./config.ts";

Deno.test("Config - basic functionality", () => {
  const config = new Config();

  // Test set and get
  config.set("testKey", "testValue");
  assertEquals(config.get(["testKey"], null), "testValue");

  // Test insert
  config.insert("plugs", "plug1");
  config.insert("plugs", "plug1");
  assertEquals(config.get("plugs", null), ["plug1", "plug1"]);

  // Test default value
  assertEquals(config.get("nonExistentKey", "default"), "default");

  // Test has
  assert(config.has("testKey"));
  assert(!config.has("nonExistentKey"));
});

Deno.test("Config - object-based setting", () => {
  const config = new Config();

  // Test setting multiple values at once
  config.set({
    key1: "value1",
    key2: "value2",
    key3: 123,
  });

  assertEquals(config.get("key1", null), "value1");
  assertEquals(config.get("key2", null), "value2");
  assertEquals(config.get("key3", 0), 123);
});

Deno.test("Config - paths", () => {
  const config = new Config();

  // Test setting with dot notation
  config.set("user.name", "John");
  config.set("user.profile.age", 30);

  // Test getting with dot notation
  assertEquals(config.get("user.name", null), "John");
  assertEquals(config.get("user.profile.age", 0), 30);

  config.set(["user", "name"], "Pete");
  config.set(["user", "profile", "age"], 20);
  assertEquals(config.get(["user", "name"], null), "Pete");
  assertEquals(config.get(["user", "profile", "age"], 0), 20);

  // Test has with dot notation
  assert(config.has("user.name"));
  assert(config.has("user.profile"));
  assert(config.has("user.profile.age"));
  assert(!config.has("user.profile.nonExistent"));

  // Test with path notation
  assert(config.has(["user", "name"]));
  assert(config.has(["user", "profile", "age"]));

  // Test getting the entire object
  assertEquals(config.get("user", {}), {
    name: "Pete",
    profile: {
      age: 20,
    },
  });
});

Deno.test("Config - edge cases", () => {
  const config = new Config();

  // Test setting a value on a non-existent path
  config.set("a.b.c", "value");
  assertEquals(config.get("a.b.c", null), "value");

  // Test overwriting a primitive with an object
  config.set("x", "primitive");
  assertEquals(config.get("x", null), "primitive");

  config.set("x.y", "nested");
  assertEquals(config.get("x.y", null), "nested");

  // Test setting a value on a path where part of the path is a primitive
  config.set("p", "primitive");
  config.set("p.q.r", "nested");
  assertEquals(config.get("p.q.r", null), "nested");

  // Test deep nesting
  config.set("deep.nesting.test.value", 42);
  assertEquals(config.get("deep.nesting.test.value", 0), 42);

  // Test with empty string key
  config.set("", "empty");
  assertEquals(config.get("", null), "empty");

  // Test with special characters in key
  config.set("special!@#", "chars");
  assertEquals(config.get("special!@#", null), "chars");
});

Deno.test("Config - object setting with dot notation in keys", () => {
  const config = new Config();

  // Test setting an object with dot notation in keys
  config.set({
    "user.name": "John",
    "user.profile.age": 30,
  });

  assertEquals(config.get("user.name", null), "John");
  assertEquals(config.get("user.profile.age", 0), 30);
});

Deno.test("Config - constructor with initial values", () => {
  const config = new Config({
    simple: "value",
    nested: {
      key: "nestedValue",
    },
  });

  assertEquals(config.get("simple", null), "value");
  assertEquals(config.get("nested.key", null), "nestedValue");
});

Deno.test("Config - schema validation", () => {
  const config = new Config();

  // Define a schema for a key
  const userSchema = {
    type: "object",
    properties: {
      name: { type: "string" },
      age: { type: "number", minimum: 0 },
      email: { type: "string", format: "email" },
    },
    required: ["name", "age"],
  };

  config.define("user", userSchema);

  // Valid data should work
  config.set("user", {
    name: "John",
    age: 30,
    email: "john@example.com",
  });

  config.set("user.name", "John");

  // Missing required field should throw
  assertThrows(
    () => {
      config.set("user", {
        name: "John",
      });
    },
    Error,
    "Validation error for user",
  );

  // Wrong type should throw
  assertThrows(
    () => {
      config.set("user", {
        name: "John",
        age: "thirty", // Should be a number
      });
    },
    Error,
    "Validation error for user",
  );

  // Wrong type should throw with path
  assertThrows(
    () => {
      config.set(["user", "name"], 22);
    },
    Error,
    "Validation error for user",
  );

  // Invalid format should throw
  assertThrows(
    () => {
      config.set("user", {
        name: "John",
        age: 30,
        email: "not-an-email",
      });
    },
    Error,
    "Validation error for user",
  );

  // Value below minimum should throw
  assertThrows(
    () => {
      config.set("user", {
        name: "John",
        age: -1, // Should be >= 0
      });
    },
    Error,
    "Validation error for user",
  );

  // Check nested keys
  config.define("a.b.user", userSchema);

  console.log(config.schemas);

  config.set("a.b.user.name", "Hank");
  assertThrows(
    () => {
      config.set("a.b.user.name", 22);
    },
    Error,
    "Validation error for a.b.user",
  );
});

Deno.test("Config - nested schema definitions with arrays", () => {
  const config = new Config();

  // Define schema for nested path using array syntax
  const settingsSchema = {
    type: "object",
    properties: {
      theme: { type: "string" },
      maxItems: { type: "number", minimum: 1 },
    },
    required: ["theme"],
  };

  config.define(["app", "ui", "settings"], settingsSchema);

  // Valid nested setting should work
  config.set("app.ui.settings", {
    theme: "dark",
    maxItems: 10,
  });

  assertEquals(config.get("app.ui.settings.theme", null), "dark");
  assertEquals(config.get("app.ui.settings.maxItems", 0), 10);

  // Setting individual nested properties should work
  config.set("app.ui.settings.theme", "light");
  assertEquals(config.get("app.ui.settings.theme", null), "light");

  // Invalid type should throw
  assertThrows(
    () => {
      config.set("app.ui.settings.theme", 123);
    },
    Error,
    "Validation error for app.ui.settings",
  );

  // Invalid value should throw
  assertThrows(
    () => {
      config.set("app.ui.settings.maxItems", -1);
    },
    Error,
    "Validation error for app.ui.settings",
  );
});

Deno.test("Config - multiple nested schemas", () => {
  const config = new Config();

  // Define multiple schemas at different nesting levels
  const userSchema = {
    type: "object",
    properties: {
      name: { type: "string" },
      age: { type: "number" },
    },
    required: ["name"],
  };

  const configSchema = {
    type: "object",
    properties: {
      version: { type: "string" },
      enabled: { type: "boolean" },
    },
    required: ["version"],
  };

  config.define("user", userSchema);
  config.define("system.config", configSchema);

  // Both schemas should work independently
  config.set("user.name", "Alice");
  config.set("system.config.version", "1.0.0");
  config.set("system.config.enabled", true);

  assertEquals(config.get("user.name", null), "Alice");
  assertEquals(config.get("system.config.version", null), "1.0.0");
  assertEquals(config.get("system.config.enabled", false), true);

  // Validation should work for both
  assertThrows(
    () => {
      config.set("user.name", 123);
    },
    Error,
    "Validation error for user",
  );

  assertThrows(
    () => {
      config.set("system.config.enabled", "not-boolean");
    },
    Error,
    "Validation error for system.config",
  );
});

Deno.test("Config - invalid schema definition", () => {
  const config = new Config();

  // Invalid schema should throw when defined
  assertThrows(
    () => {
      config.define("test", {
        type: "invalid-type", // Invalid type
      });
    },
    Error,
    "Invalid schema for key test",
  );
});


================================================
FILE: client/config.ts
================================================
import { Ajv } from "ajv";

/**
 * Configuration management (config.* APIs) for the client
 */
export class Config {
  public schemas: Record<string, any> = {
    type: "object",
    properties: {},
  };
  private ajv = new Ajv();

  constructor(public values: Record<string, any> = {}) {
    // Add the same formats as in jsonschema.ts
    this.ajv.addFormat("email", {
      validate: (data: string) => {
        return data.includes("@");
      },
      async: false,
    });

    this.ajv.addFormat("page-ref", {
      validate: (data: string) => {
        return data.startsWith("[[") && data.endsWith("]]");
      },
      async: false,
    });
  }

  public clear() {
    this.schemas = {
      type: "object",
      properties: {},
    };
    this.values = {};
  }

  /**
   * Defines a JSON schema for a configuration key
   * @param key The configuration key to define a schema for
   * @param schema The JSON schema to validate against
   */
  define(key: string | string[], schema: any): void {
    // Validate the schema itself first
    const valid = this.ajv.validateSchema(schema);
    if (!valid) {
      const errorText = this.ajv.errorsText(this.ajv.errors);
      throw new Error(`Invalid schema for key ${key}: ${errorText}`);
    }

    if (typeof key === "string") {
      key = key.split(".");
    }

    // Navigate/create the path in the schema structure
    let current = this.schemas;
    for (let i = 0; i < key.length - 1; i++) {
      const part = key[i];
      if (!current.properties[part]) {
        current.properties[part] = {
          type: "object",
          properties: {},
        };
      }
      current = current.properties[part];
    }

    // Store the schema at the final key
    const finalKey = key[key.length - 1];
    current.properties[finalKey] = schema;
  }

  /**
   * Gets a value from the config
   * @param path The path to get, supports dot notation (e.g. "foo.bar.baz")
   * @param defaultValue The default value to return if the path doesn't exist
   * @returns The value at the path, or the default value
   */
  get<T>(path: string | string[], defaultValue: T): T {
    if (typeof path === "string") {
      path = path.split(".");
    }
    const resolved = resolvePath(this.values, path);
    if (!resolved) {
      return defaultValue;
    }

    return (resolved.obj[resolved.key] ?? defaultValue) as T;
  }

  /**
   * Sets a value in the config
   * @param path The path to set, supports dot notation (e.g. "foo.bar.baz")
   * @param value The value to set
   */
  set<T>(path: string, value: T): void;
  /**
   * Sets a value in the config
   * @param path The path to set (one path element per array element)
   * @param value The value to set
   */
  set<T>(path: string[], value: T): void;

  /**
   * Sets multiple values in the config
   * @param values An object containing key-value pairs to set
   */
  set(values: Record<string, any>): void;

  set<T>(
    keyOrValues: string | string[] | Record<string, any>,
    value?: T,
  ): void {
    if (typeof keyOrValues === "string") {
      keyOrValues = keyOrValues.split(".");
    }
    if (Array.isArray(keyOrValues)) {
      const key = keyOrValues as string[];

      const resolved = resolvePath(this.values, key, true);
      if (resolved) {
        resolved.obj[resolved.key] = value;
      } else {
        throw new Error(`Invalid key ${key}`);
      }

      // Find and validate only the relevant schema
      this.validatePath(key);
    } else {
      // Handle object form
      for (const [key, val] of Object.entries(keyOrValues)) {
        this.set(key, val);
      }
    }
  }

  insert<T>(
    key: string | string[],
    value?: T,
  ): void {
    if (typeof key === "string") {
      key = key.split(".");
    }
    const resolved = resolvePath(this.values, key, true);
    if (resolved) {
      if (!Array.isArray(resolved.obj[resolved.key])) {
        resolved.obj[resolved.key] = [];
      }
      resolved.obj[resolved.key].push(value);
    } else {
      throw new Error(`Invalid key ${key}`);
    }

    // Find and validate only the relevant schema after the fact...
    this.validatePath(key);
  }

  /**
   * Checks if a path exists in the config
   * @param path The path to check, supports dot notation (e.g. "foo.bar.baz")
   * @returns True if the path exists, false otherwise
   */
  has(path: string | string[]): boolean {
    if (typeof path === "string") {
      path = path.split(".");
    }
    const resolved = resolvePath(this.values, path);
    if (!resolved) {
      return false;
    }

    return resolved.key in resolved.obj;
  }

  /**
   * Lists all configuration keys in the config
   * @returns An array of keys
   */
  keys(): string[] {
    return Object.keys(this.values);
  }

  /**
   * Validates a specific path against its schema
   */
  private validatePath(path: string[]): void {
    // Find the deepest schema that applies to this path
    for (let i = path.length; i > 0; i--) {
      const schemaPath = path.slice(0, i);
      const schema = this.getSchemaAtPath(schemaPath);
      if (schema) {
        const valueAtPath = this.get(schemaPath, undefined);
        if (valueAtPath !== undefined) {
          const validate = this.ajv.compile(schema);
          if (!validate(valueAtPath)) {
            let errorText = this.ajv.errorsText(validate.errors);
            errorText = errorText.replaceAll("/", ".");
            errorText = errorText.replace(/^data\.?/, "");
            throw new Error(
              `Validation error for ${schemaPath.join(".")}:> ${errorText}`,
            );
          }
        }
        break; // Only validate the most specific schema
      }
    }
  }

  /**
   * Gets the schema at a specific path
   */
  private getSchemaAtPath(path: string[]): any | null {
    let current = this.schemas;
    for (const part of path) {
      if (!current.properties || !current.properties[part]) {
        return null;
      }
      current = current.properties[part];
    }
    return current.type ? current : null;
  }
}

/**
 * Resolves a configuration path to the containing object and final key
 * @param path The path to resolve (e.g. ["foo", "bar", "baz"])
 * @param create Whether to create objects along the path if they don't exist
 * @returns The containing object and the final key, or null if the path cannot be resolved
 */
function resolvePath(
  inObject: Record<string, any>,
  path: string[],
  create = false,
): { obj: any; key: string } | null {
  // To avoid side effects, let's clone the path bits
  path = [...path];

  const lastKey = path.pop()!;
  let current = inObject;

  for (const part of path) {
    if (current[part] === undefined) {
      if (create) {
        current[part] = {};
      } else {
        return null;
      }
    } else if (typeof current[part] !== "object" || current[part] === null) {
      if (create) {
        // Convert primitive to object if we're creating the path
        current[part] = {};
      } else {
        return null;
      }
    }

    current = current[part];
  }

  return { obj: current, key: lastKey };
}


================================================
FILE: client/data/data_augmenter.test.ts
================================================
import { assertEquals } from "@std/assert";
import { Augmenter } from "./data_augmenter.ts";
import { DataStore } from "./datastore.ts";
import { MemoryKvPrimitives } from "./memory_kv_primitives.ts";

Deno.test("Test data augmentation", async () => {
  const ds = new DataStore(new MemoryKvPrimitives());
  const john: any = {
    ref: "john",
    name: "John",
    age: 1234,
  };
  const mary: any = {
    ref: "mary",
    name: "Mary",
    age: 5678,
  };
  ds.batchSet([{
    key: ["john"],
    value: john,
  }, {
    key: ["mary"],
    value: mary,
  }]);
  const augm = new Augmenter(ds, ["aug"]);
  // Augment only john
  await augm.setAugmentation("john", { augmented: true });
  // Fetch them back
  const objs = [john, mary];
  await augm.augmentObjectArray(objs, "ref");
  assertEquals(objs[0].augmented, true);
  assertEquals(objs[1].augmented, undefined);
});


================================================
FILE: client/data/data_augmenter.ts
================================================
/**
 * Implement out of band data augmentation operations
 */

import type { KvKey } from "@silverbulletmd/silverbullet/type/datastore";
import type { DataStore } from "./datastore.ts";

/**
 * Implements out of band augmentation of entries in a DataStore
 * Example use cases: store "lastOpened" for pages and "lastRun" for commands
 */
export class Augmenter {
  constructor(
    private ds: DataStore,
    private augmentationNamespace: KvKey,
  ) {
  }

  /**
   * Augments objects additional attributes pulled from a data source
   */
  async augmentObjectArray(
    objects: any[],
    keyField: string,
  ): Promise<void> {
    const objectMap = new Map<string, any>();
    // create a lookup map based on objects
    for (const obj of objects) {
      objectMap.set(obj[keyField], obj);
    }
    await this.augmentObjectMap(objectMap);
  }

  async augmentObjectMap(objectMap: Map<string, any>) {
    // Now augment
    for await (
      const augmentation of this.ds.query({
        prefix: this.augmentationNamespace,
      })
    ) {
      const obj = objectMap.get(
        augmentation.key[this.augmentationNamespace.length],
      );
      if (obj) {
        // Copy over properties from augmentation
        Object.assign(obj, augmentation.value);
      }
    }
  }

  setAugmentation(
    key: string,
    augmentation: Record<string, any>,
  ): Promise<void> {
    return this.ds.set([...this.augmentationNamespace, key], augmentation);
  }
}


================================================
FILE: client/data/datastore.test.ts
================================================
import "fake-indexeddb/auto";
import { IndexedDBKvPrimitives } from "./indexeddb_kv_primitives.ts";
import { MemoryKvPrimitives } from "./memory_kv_primitives.ts";
import type { KvPrimitives } from "./kv_primitives.ts";
import { assertEquals } from "@std/assert";
import { DataStore } from "./datastore.ts";
import { LuaEnv, LuaStackFrame } from "../space_lua/runtime.ts";
import { parseExpressionString } from "../space_lua/parse.ts";

async function test(db: KvPrimitives) {
  const datastore = new DataStore(db);
  await datastore.set(["user", "peter"], { name: "Peter" });
  await datastore.set(["user", "hank"], { name: "Hank" });
  const env = new LuaEnv();
  const sf = LuaStackFrame.lostFrame;

  // Basic test, fancier tests are done in common/space_lua/query_collection.test.ts
  const results = await datastore.luaQuery(
    ["user"],
    {
      objectVariable: "user",
      where: parseExpressionString("user.name == 'Peter'"),
    },
    env,
    sf,
  );
  assertEquals(results, [{ name: "Peter" }]);
}

Deno.test("Test Memory KV DataStore", async () => {
  const db = new MemoryKvPrimitives(); // In-memory only, no persistence
  await test(db);
  await db.close();
});

Deno.test("Test IndexDB DataStore", {
  sanitizeResources: false,
  sanitizeOps: false,
}, async () => {
  const db = new IndexedDBKvPrimitives("test");
  await db.init();
  await test(db);
  db.close();
});


================================================
FILE: client/data/datastore.ts
================================================
import {
  type LuaCollectionQuery,
  queryLua,
} from "../space_lua/query_collection.ts";
import { LuaEnv, LuaStackFrame } from "../space_lua/runtime.ts";
import type { KvPrimitives, KvQueryOptions } from "./kv_primitives.ts";

import type { KV, KvKey } from "../../plug-api/types/datastore.ts";

/**
 * This is the data store class you'll actually want to use, wrapping the primitives
 * in a more user-friendly way
 */
export class DataStore {
  constructor(
    readonly kv: KvPrimitives,
  ) {
  }

  async get<T = any>(key: KvKey): Promise<T | null> {
    return (await this.batchGet([key]))[0];
  }

  batchGet<T = any>(keys: KvKey[]): Promise<(T | null)[]> {
    if (keys.length === 0) {
      return Promise.resolve([]);
    }
    return this.kv.batchGet(keys);
  }

  set(key: KvKey, value: any): Promise<void> {
    return this.batchSet([{ key, value }]);
  }

  batchSet<T = any>(entries: KV<T>[]): Promise<void> {
    if (entries.length === 0) {
      return Promise.resolve();
    }
    const allKeyStrings = new Set<string>();
    const uniqueEntries: KV[] = [];
    for (const { key, value } of entries) {
      const keyString = JSON.stringify(key);
      if (allKeyStrings.has(keyString)) {
        console.warn(`Duplicate key ${keyString} in batchSet, skipping`);
      } else {
        allKeyStrings.add(keyString);
        uniqueEntries.push({ key, value });
      }
    }
    return this.kv.batchSet(uniqueEntries);
  }

  delete(key: KvKey): Promise<void> {
    return this.batchDelete([key]);
  }

  batchDelete(keys: KvKey[]): Promise<void> {
    if (keys.length === 0) {
      return Promise.resolve();
    }
    return this.kv.batchDelete(keys);
  }

  async batchDeletePrefix(prefix: KvKey): Promise<void> {
    const keys: KvKey[] = [];
    for await (const { key } of this.kv.query({ prefix })) {
      keys.push(key);
    }
    return this.batchDelete(keys);
  }

  query<T = any>(options: KvQueryOptions): AsyncIterableIterator<KV<T>> {
    return this.kv.query<T>(options);
  }

  luaQuery<T = any>(
    prefix: KvKey,
    query: LuaCollectionQuery,
    env: LuaEnv = new LuaEnv(),
    sf: LuaStackFrame = LuaStackFrame.lostFrame,
    enricher?: (key: KvKey, item: any) => any,
  ): Promise<T[]> {
    return queryLua(this.kv, prefix, query, env, sf, enricher);
  }
}


================================================
FILE: client/data/encrypted_kv_primitives.test.ts
================================================
import { assert, assertEquals } from "@std/assert";
import { EncryptedKvPrimitives } from "./encrypted_kv_primitives.ts";
import { MemoryKvPrimitives } from "./memory_kv_primitives.ts";
import { deriveCTRKeyFromPassword } from "@silverbulletmd/silverbullet/lib/crypto";

Deno.test("Test Encrypted KV Primitives", async () => {
  const memoryKv = new MemoryKvPrimitives();
  const salt = new Uint8Array(32);
  const key = await deriveCTRKeyFromPassword("test", salt);
  const kv = new EncryptedKvPrimitives(memoryKv, key);
  await kv.init();

  // Store a basic key
  await kv.batchSet([{ key: ["key"], value: 10 }]);
  const [value] = await kv.batchGet([["key"]]);
  assertEquals(value, 10);

  // Store a binary blob
  const blob = new Uint8Array([1, 2, 3, 4, 5]);
  await kv.batchSet([{ key: ["blob"], value: blob }]);
  const [blobValue] = await kv.batchGet([["blob"]]);
  assertEquals(blobValue, blob);

  // Store a nested JSON and blob structure
  const nested = {
    json: { a: 1, b: 2 },
    blob: new Uint8Array([6, 7, 8, 9, 10]),
  };
  await kv.batchSet([{ key: ["nested"], value: nested }]);
  const [nestedValue] = await kv.batchGet([["nested"]]);
  assertEquals(nestedValue, nested);

  // Put a few objects with a person prefix
  await kv.batchSet([
    { key: ["person", "alice"], value: { name: "Alice", age: 30 } },
    { key: ["person", "bob"], value: { name: "Bob", age: 25 } },
  ]);

  // Then query based on the prefix
  let counter = 0;
  for await (const { key, value } of kv.query({ prefix: ["person"] })) {
    assertEquals(key[0], "person");
    assert(key[1] === "alice" || key[1] === "bob");
    assert(value.age);
    counter++;
  }
  assertEquals(counter, 2);

  // Delete something
  await kv.batchDelete([["person", "alice"]]);
  // Check it's gone
  const [deletedValue] = await kv.batchGet([["person", "alice"]]);
  assertEquals(deletedValue, undefined);

  console.log(memoryKv);

  // Clear
  await kv.clear();
  counter = 0;
  for await (const _ of kv.query({ prefix: ["person"] })) {
    counter++;
  }
  assertEquals(counter, 0);
});


================================================
FILE: client/data/encrypted_kv_primitives.ts
================================================
import type { KvPrimitives, KvQueryOptions } from "./kv_primitives.ts";

import type { KV, KvKey } from "../../plug-api/types/datastore.ts";
import {
  decryptAesGcm,
  decryptStringDeterministic,
  deriveGCMKeyFromCTR,
  encryptAesGcm,
  encryptStringDeterministic,
} from "@silverbulletmd/silverbullet/lib/crypto";

import { decode, encode } from "@msgpack/msgpack";

export class EncryptedKvPrimitives implements KvPrimitives {
  private keyKey: CryptoKey;
  private dataKey!: CryptoKey;

  constructor(
    private wrapped: KvPrimitives,
    encryptionKey: CryptoKey,
  ) {
    this.keyKey = encryptionKey;
  }

  get dbName(): string {
    return (this.wrapped as any).dbName;
  }

  // MUST immediately be called after constructor
  async init() {
    this.dataKey = await deriveGCMKeyFromCTR(this.keyKey);
  }

  clear(): Promise<void> {
    return this.wrapped.clear();
  }

  private encryptKey(key: KvKey): Promise<KvKey> {
    return Promise.all(
      key.map((part) => encryptStringDeterministic(this.keyKey, part)),
    );
  }

  private decryptKey(key: KvKey): Promise<KvKey> {
    return Promise.all(
      key.map((part) => decryptStringDeterministic(this.keyKey, part)),
    );
  }

  private encryptValue(value: any): Promise<any> {
    if (value === undefined) {
      return Promise.resolve(undefined);
    }
    return encryptAesGcm(this.dataKey, encode(value));
  }

  private async decryptValue(value: any): Promise<any> {
    if (value === undefined) {
      return undefined;
    }
    return decode(await decryptAesGcm(this.dataKey, value));
  }

  async batchGet(keys: KvKey[]): Promise<any[]> {
    const encryptedKeys: KvKey[] = await Promise.all(keys.map((key) => {
      return this.encryptKey(key);
    }));
    const encryptedValues = await this.wrapped.batchGet(encryptedKeys);
    return Promise.all(
      encryptedValues.map((value) => this.decryptValue(value)),
    );
  }

  async batchSet(entries: KV[]): Promise<void> {
    const encryptedEntries: KV[] = await Promise.all(
      entries.map(async ({ key, value }) => {
        const encryptedKey = await this.encryptKey(key);
        const encryptedValue = await this.encryptValue(value);
        return { key: encryptedKey, value: encryptedValue };
      }),
    );
    await this.wrapped.batchSet(encryptedEntries);
  }

  async batchDelete(keys: KvKey[]): Promise<void> {
    const encryptedKeys = await Promise.all(
      keys.map((key) => this.encryptKey(key)),
    );
    await this.wrapped.batchDelete(encryptedKeys);
  }

  async *query({ prefix }: KvQueryOptions): AsyncIterableIterator<KV> {
    const encryptedResults: KV[] = [];
    // Collect all results first
    for await (
      const entry of this.wrapped.query({
        prefix: prefix ? await this.encryptKey(prefix) : undefined,
      })
    ) {
      encryptedResults.push(entry);
    }

    // Then decrypt them (to avoid transaction problems with wrapped indexed DBs)
    for (const entry of encryptedResults) {
      yield {
        key: await this.decryptKey(entry.key),
        value: await this.decryptValue(entry.value),
      };
    }
  }

  async countQuery({ prefix }: KvQueryOptions): Promise<number> {
    const encryptedPrefix = prefix ? await this.encryptKey(prefix) : undefined;
    return this.wrapped.countQuery({ prefix: encryptedPrefix });
  }

  close() {
    this.wrapped.close();
  }
}


================================================
FILE: client/data/indexeddb_kv_primitives.test.ts
================================================
import "fake-indexeddb/auto";
import { IndexedDBKvPrimitives } from "./indexeddb_kv_primitives.ts";
import { allTests } from "./kv_primitives.test.ts";

Deno.test("Test IDB key primitives", {
  sanitizeResources: false,
  sanitizeOps: false,
}, async () => {
  const db = new IndexedDBKvPrimitives("test");
  await db.init();
  await allTests(db);
  db.close();
});


================================================
FILE: client/data/indexeddb_kv_primitives.ts
================================================
import type { KvPrimitives, KvQueryOptions } from "./kv_primitives.ts";
import { type IDBPDatabase, openDB } from "idb";

import type { KV, KvKey } from "../../plug-api/types/datastore.ts";

// Separator character to use for key serialization
const sep = "\0";
const objectStoreName = "data";

export class IndexedDBKvPrimitives implements KvPrimitives {
  db!: IDBPDatabase<any>;

  constructor(
    private dbName: string,
  ) {
  }

  async init() {
    this.db = await openDB(this.dbName, 1, {
      upgrade: (db) => {
        db.createObjectStore(objectStoreName);
      },
    });
  }

  async clear(): Promise<void> {
    const objectStoreNames = this.db.objectStoreNames;

    // Create a transaction that includes all object stores
    const tx = this.db.transaction(objectStoreNames, "readwrite");

    // Clear each object store in parallel
    const clearPromises = Array.from(objectStoreNames).map((storeName) =>
      tx.objectStore(storeName).clear()
    );

    // Wait for all clears to complete
    await Promise.all(clearPromises);

    // Complete the transaction
    await tx.done;
  }

  batchGet(keys: KvKey[]): Promise<any[]> {
    const tx = this.db.transaction(objectStoreName, "readonly");
    return Promise.all(keys.map((key) => tx.store.get(this.buildKey(key))));
  }

  async batchSet(entries: KV[]): Promise<void> {
    const tx = this.db.transaction(objectStoreName, "readwrite");
    await Promise.all([
      ...entries.map(({ key, value }) =>
        tx.store.put(value, this.buildKey(key))
      ),
      tx.done,
    ]);
  }

  async batchDelete(keys: KvKey[]): Promise<void> {
    const tx = this.db.transaction(objectStoreName, "readwrite");
    await Promise.all([
      ...keys.map((key) => tx.store.delete(this.buildKey(key))),
      tx.done,
    ]);
  }

  // Important IndexedDB limitation: no asynchronous processing can happen
  // in the body of the for await: https://stackoverflow.com/a/51898463
  async *query({ prefix }: KvQueryOptions): AsyncIterableIterator<KV> {
    const tx = this.db.transaction(objectStoreName, "readonly");
    prefix = prefix || [];
    for await (
      const entry of tx.store.iterate(IDBKeyRange.bound(
        this.buildKey([...prefix, ""]),
        this.buildKey([...prefix, "\uffff"]),
      ))
    ) {
      yield { key: this.extractKey(entry.key), value: entry.value };
    }
  }

  countQuery({ prefix }: KvQueryOptions): Promise<number> {
    const tx = this.db.transaction(objectStoreName, "readonly");
    prefix = prefix || [];
    return tx.store.count(IDBKeyRange.bound(
      this.buildKey([...prefix, ""]),
      this.buildKey([...prefix, "\uffff"]),
    ));
  }

  close() {
    this.db.close();
  }

  private buildKey(key: KvKey): string {
    for (const k of key) {
      if (typeof k !== "string") {
        throw new Error(`Key needs to consists of strings: ${key}`);
      }
      if (k.includes(sep)) {
        throw new Error(`Key cannot contain ${sep}`);
      }
    }
    return key.join(sep);
  }

  private extractKey(key: string): KvKey {
    return key.split(sep);
  }
}


================================================
FILE: client/data/kv_primitives.test.ts
================================================
import type { KvPrimitives } from "./kv_primitives.ts";
import { assertEquals } from "@std/assert";

import type { KV } from "../../plug-api/types/datastore.ts";

export async function allTests(db: KvPrimitives) {
  await db.batchSet([
    { key: ["kv", "test2"], value: "Hello2" },
    { key: ["kv", "test1"], value: "Hello1" },
    { key: ["other", "random"], value: "Hello3" },
  ]);
  const result = await db.batchGet([["kv", "test1"], ["kv", "test2"], [
    "kv",
    "test3",
  ]]);
  assertEquals(result.length, 3);
  assertEquals(result[0], "Hello1");
  assertEquals(result[1], "Hello2");
  assertEquals(result[2], undefined);
  let counter = 0;
  // Query all
  for await (const _entry of db.query({})) {
    counter++;
  }
  assertEquals(counter, 3);

  counter = 0;
  // Query prefix
  for await (const _entry of db.query({ prefix: ["kv"] })) {
    counter++;
    console.log(_entry);
  }
  assertEquals(counter, 2);

  // Delete a few keys
  await db.batchDelete([["kv", "test1"], ["other", "random"]]);
  const result2 = await db.batchGet([["kv", "test1"], ["kv", "test2"], [
    "other",
    "random",
  ]]);
  assertEquals(result2.length, 3);
  assertEquals(result2[0], undefined);
  assertEquals(result2[1], "Hello2");
  assertEquals(result2[2], undefined);

  // Update a key
  await db.batchSet([{ key: ["kv", "test2"], value: "Hello2.1" }]);
  const [val] = await db.batchGet([["kv", "test2"]]);
  assertEquals(val, "Hello2.1");

  // Set a large batch
  const largeBatch: KV[] = [];
  for (let i = 0; i < 50; i++) {
    largeBatch.push({ key: ["test", "test" + i], value: "Hello" });
  }
  await db.batchSet(largeBatch);
  const largeBatchResult: KV[] = [];
  for await (const entry of db.query({ prefix: ["test"] })) {
    largeBatchResult.push(entry);
  }
  assertEquals(largeBatchResult.length, 50);

  // Delete the large batch
  await db.batchDelete(largeBatch.map((e) => e.key));

  // Make sure they're gone
  for await (const _entry of db.query({ prefix: ["test"] })) {
    throw new Error("This should not happen");
  }
}


================================================
FILE: client/data/kv_primitives.ts
================================================
import type { KV, KvKey } from "../../plug-api/types/datastore.ts";

export type KvQueryOptions = {
  prefix?: KvKey;
};

export interface KvPrimitives {
  batchGet(keys: KvKey[]): Promise<(any | undefined)[]>;

  batchSet(entries: KV[]): Promise<void>;

  batchDelete(keys: KvKey[]): Promise<void>;

  query<T = any>(options: KvQueryOptions): AsyncIterableIterator<KV<T>>;

  /**
   * A more efficient way to do counts
   */
  countQuery(options: KvQueryOptions): Promise<number>;

  // Completely clear all data from this datastore
  clear(): Promise<void>;

  close(): void;
}


================================================
FILE: client/data/memory_kv_primitives.test.ts
================================================
import { assertEquals } from "@std/assert";
import { MemoryKvPrimitives } from "./memory_kv_primitives.ts";
import { allTests } from "./kv_primitives.test.ts";

import type { KV } from "../../plug-api/types/datastore.ts";

Deno.test("MemoryKvPrimitives loads from non-existent file without error", async () => {
  const tempPath = await Deno.makeTempFile() + "_nonexistent";
  // Disable throttling for tests
  const store = new MemoryKvPrimitives(tempPath, { throttleMs: 0 });
  await store.init();

  // Should create an empty store
  const result = await store.batchGet([["test"]]);
  assertEquals(result, [undefined]);
});

Deno.test("MemoryKvPrimitives passes all KvPrimitives tests", async () => {
  const tempPath = await Deno.makeTempFile();

  try {
    const store = new MemoryKvPrimitives(tempPath, { throttleMs: 0 });
    await store.init();
    await allTests(store);
    await store.close();
  } finally {
    // Clean up
    try {
      await Deno.remove(tempPath);
    } catch (_) {
      // Ignore errors during cleanup
    }
  }
});

Deno.test("MemoryKvPrimitives persists and loads data", async () => {
  const tempPath = await Deno.makeTempFile();

  try {
    // Create and populate first instance
    const store1 = new MemoryKvPrimitives(tempPath, { throttleMs: 0 });
    await store1.init();
    await store1.batchSet([
      { key: ["test", "key1"], value: "value1" },
      { key: ["test", "key2"], value: "value2" },
    ]);

    // Force persistence
    await store1.close();

    // Create second instance that loads from the same file
    const store2 = new MemoryKvPrimitives(tempPath, { throttleMs: 0 });
    await store2.init();

    // Check if data was loaded correctly
    const results = await store2.batchGet([["test", "key1"], ["test", "key2"]]);
    assertEquals(results, ["value1", "value2"]);
  } finally {
    // Clean up
    try {
      await Deno.remove(tempPath);
    } catch (_) {
      // Ignore errors during cleanup
    }
  }
});

Deno.test("MemoryKvPrimitives mutations trigger persistence", async () => {
  const tempPath = await Deno.makeTempFile();

  try {
    // Create and populate first instance
    const store1 = new MemoryKvPrimitives(tempPath, { throttleMs: 0 });
    await store1.init();
    await store1.batchSet([{ key: ["test", "key"], value: "value" }]);

    // No need to wait for throttled persistence since we disabled it

    // Create second instance without closing the first one
    const store2 = new MemoryKvPrimitives(tempPath, { throttleMs: 0 });
    await store2.init();

    // Check if data was persisted
    const results = await store2.batchGet([["test", "key"]]);
    assertEquals(results, ["value"]);
  } finally {
    // Clean up
    try {
      await Deno.remove(tempPath);
    } catch (_) {
      // Ignore errors during cleanup
    }
  }
});

Deno.test("MemoryKvPrimitives persists delete operations", async () => {
  const tempPath = await Deno.makeTempFile();

  try {
    // Create and populate store
    const store1 = new MemoryKvPrimitives(tempPath, { throttleMs: 0 });
    await store1.init();
    await store1.batchSet([
      { key: ["test", "key1"], value: "value1" },
      { key: ["test", "key2"], value: "value2" },
    ]);

    // Delete one key
    await store1.batchDelete([["test", "key1"]]);

    // No need to wait for throttled persistence since we disabled it

    // Create second instance
    const store2 = new MemoryKvPrimitives(tempPath, { throttleMs: 0 });
    await store2.init();

    // Check if delete was persisted
    const results = await store2.batchGet([["test", "key1"], ["test", "key2"]]);
    assertEquals(results, [undefined, "value2"]);
  } finally {
    // Clean up
    try {
      await Deno.remove(tempPath);
    } catch (_) {
      // Ignore errors during cleanup
    }
  }
});

Deno.test("MemoryKvPrimitives.fromFile creates and initializes store", async () => {
  const tempPath = await Deno.makeTempFile();

  try {
    // Create JSON file with initial data
    const initialData = {
      "test\0key": "value",
    };
    await Deno.writeTextFile(tempPath, JSON.stringify(initialData));

    // Use factory method with throttling disabled
    const store = await MemoryKvPrimitives.fromFile(tempPath, {
      throttleMs: 0,
    });

    // Check if data was loaded
    const result = await store.batchGet([["test", "key"]]);
    assertEquals(result, ["value"]);
  } finally {
    // Clean up
    try {
      await Deno.remove(tempPath);
    } catch (_) {
      // Ignore errors during cleanup
    }
  }
});

Deno.test("MemoryKvPrimitives query works with persisted data", async () => {
  const tempPath = await Deno.makeTempFile();

  try {
    // Create and populate store
    const store1 = new MemoryKvPrimitives(tempPath, { throttleMs: 0 });
    await store1.init();
    await store1.batchSet([
      { key: ["test", "key1"], value: "value1" },
      { key: ["test", "key2"], value: "value2" },
      { key: ["other", "key"], value: "value3" },
    ]);

    // Force persistence
    await store1.close();

    // Create second instance
    const store2 = await MemoryKvPrimitives.fromFile(tempPath, {
      throttleMs: 0,
    });

    // Test query with prefix
    const results: KV[] = [];
    for await (const item of store2.query({ prefix: ["test"] })) {
      results.push(item);
    }

    assertEquals(results.length, 2);
    assertEquals(results[0].key, ["test", "key1"]);
    assertEquals(results[0].value, "value1");
    assertEquals(results[1].key, ["test", "key2"]);
    assertEquals(results[1].value, "value2");
  } finally {
    // Clean up
    try {
      await Deno.remove(tempPath);
    } catch (_) {
      // Ignore errors during cleanup
    }
  }
});


================================================
FILE: client/data/memory_kv_primitives.ts
================================================
import type { KvPrimitives, KvQueryOptions } from "./kv_primitives.ts";
import { throttle } from "@silverbulletmd/silverbullet/lib/async";

import type { KV, KvKey } from "@silverbulletmd/silverbullet/type/datastore";

const memoryKeySeparator = "\0";

export class MemoryKvPrimitives implements KvPrimitives {
  protected store = new Map<string, any>();
  private throttledPersist?: () => void;

  constructor(
    protected filePath?: string,
    options: { throttleMs?: number } = {},
  ) {
    // Set up throttled persistence if throttleMs is provided or default to 1000ms
    if (this.filePath) {
      const throttleMs = options.throttleMs !== undefined
        ? options.throttleMs
        : 1000;

      // If throttleMs is 0, persistence will happen immediately without throttling
      if (throttleMs > 0) {
        this.throttledPersist = throttle(() => {
          this.persistToDisk().catch((err) =>
            console.error(`Error persisting to disk: ${err}`)
          );
        }, throttleMs);
      }
    }
  }

  static fromJSON(json: Record<string, any>): MemoryKvPrimitives {
    const result = new MemoryKvPrimitives();
    for (const key of Object.keys(json)) {
      result.store.set(key, json[key]);
    }
    return result;
  }

  /**
   * Create a new MemoryKvPrimitives instance from a file and initialize it
   */
  static async fromFile(
    filePath: string,
    options: { throttleMs?: number } = {},
  ): Promise<MemoryKvPrimitives> {
    const instance = new MemoryKvPrimitives(filePath, options);
    await instance.init();
    return instance;
  }

  clear(): Promise<void> {
    this.store.clear();
    return Promise.resolve();
  }

  /**
   * Initialize the store by loading data from disk if a file path was provided
   */
  async init(): Promise<void> {
    if (!this.filePath) return;

    try {
      const text = await Deno.readTextFile(this.filePath);
      // Handle empty files gracefully to prevent "SyntaxError: Unexpected end of JSON input"
      if (text.trim() === "") {
        return;
      }

      const jsonData = JSON.parse(text);
      for (const key of Object.keys(jsonData)) {
        this.store.set(key, jsonData[key]);
      }
    } catch (error) {
      // Handle specific errors more gracefully
      if (error instanceof Deno.errors.NotFound) {
        // File doesn't exist yet, nothing to load
        return;
      }

      // Other errors (like invalid JSON) should be logged
      console.warn(`Failed to load KV store from ${this.filePath}:`, error);
    }
  }

  batchGet(keys: KvKey[]): Promise<any[]> {
    return Promise.resolve(
      keys.map((key) => this.store.get(key.join(memoryKeySeparator))),
    );
  }

  async batchSet(entries: KV[]): Promise<void> {
    for (const { key, value } of entries) {
      this.store.set(key.join(memoryKeySeparator), value);
    }

    // Trigger persistence
    if (this.throttledPersist) {
      this.throttledPersist();
    } else if (this.filePath) {
      // If no throttling is set up but we have a filePath, persist immediately
      await this.persistToDisk();
    }

    return Promise.resolve();
  }

  async batchDelete(keys: KvKey[]): Promise<void> {
    for (const key of keys) {
      this.store.delete(key.join(memoryKeySeparator));
    }

    // Trigger persistence
    if (this.throttledPersist) {
      this.throttledPersist();
    } else if (this.filePath) {
      // If no throttling is set up but we have a filePath, persist immediately
      await this.persistToDisk();
    }

    return Promise.resolve();
  }

  toJSON(): Record<string, any> {
    const result: Record<string, any> = {};
    for (const [key, value] of this.store) {
      result[key] = value;
    }
    return result;
  }

  async *query(options: KvQueryOptions): AsyncIterableIterator<KV> {
    const prefix = options.prefix?.join(memoryKeySeparator);
    const sortedKeys = [...this.store.keys()].sort();
    for (const key of sortedKeys) {
      if (prefix && !key.startsWith(prefix)) {
        continue;
      }
      yield {
        key: key.split(memoryKeySeparator),
        value: this.store.get(key),
      };
    }
  }

  countQuery({ prefix }: KvQueryOptions): Promise<number> {
    const prefixStr = prefix ? prefix.join(memoryKeySeparator) : undefined;
    const keys = [...this.store.keys()];
    return Promise.resolve(
      keys.filter((key) => !prefixStr || key.startsWith(prefixStr)).length,
    );
  }

  async close(): Promise<void> {
    // Force immediate persistence when closing
    if (this.filePath) {
      await this.persistToDisk();
    }
  }

  /**
   * Persist the current state to disk
   */
  private async persistToDisk(): Promise<void> {
    if (!this.filePath) return;

    try {
      const jsonData = this.toJSON();
      await Deno.writeTextFile(
        this.filePath,
        JSON.stringify(jsonData, null, 2),
      );
    } catch (error) {
      console.error(`Failed to persist KV store to ${this.filePath}:`, error);
    }
  }
}


================================================
FILE: client/data/mq.datastore.test.ts
================================================
import { DataStoreMQ } from "./mq.datastore.ts";
import { assertEquals } from "@std/assert";
import { MemoryKvPrimitives } from "./memory_kv_primitives.ts";
import { DataStore } from "./datastore.ts";
import { FakeTime } from "@std/testing/time";

import type { MQMessage } from "../../plug-api/types/datastore.ts";
import { EventHook } from "../plugos/hooks/event.ts";
import { System } from "../plugos/system.ts";
import type { EventHookT } from "@silverbulletmd/silverbullet/type/manifest";

Deno.test("DataStore MQ", async () => {
  const time = new FakeTime();
  const db = new MemoryKvPrimitives(); // In-memory only, no persistence
  const eventHook = new EventHook();
  const system = new System<EventHookT>();
  system.addHook(eventHook);

  try {
    const mq = new DataStoreMQ(
      new DataStore(db),
      eventHook,
    );

    let messages: MQMessage[];

    // Send and ack
    await mq.send("test", "Hello World");
    messages = await mq.poll("test", 10);
    assertEquals(messages.length, 1);
    await mq.ack("test", messages[0].id);
    assertEquals([], await mq.poll("test", 10));

    // Timeout
    await mq.send("test", "Hello World");
    messages = await mq.poll("test", 10);
    assertEquals(messages.length, 1);
    assertEquals([], await mq.poll("test", 10));
    await time.tickAsync(20);
    await mq.requeueTimeouts(10);
    messages = await mq.poll("test", 10);
    const stats = await mq.getQueueStats();
    assertEquals(stats.processing, 1);
    assertEquals(messages.length, 1);
    assertEquals(messages[0].retries, 1);

    // Max retries
    await time.tickAsync(20);
    await mq.requeueTimeouts(10, 1);
    assertEquals((await mq.fetchDLQMessages()).length, 1);

    // Batch send and ack
    await mq.batchSend("test", ["Hello", "World"]);
    const messageBatch1 = await mq.poll("test", 1);
    assertEquals(messageBatch1.length, 1);
    assertEquals(messageBatch1[0].body, "Hello");
    const messageBatch2 = await mq.poll("test", 1);
    assertEquals(messageBatch2.length, 1);
    assertEquals(messageBatch2[0].body, "World");
    await mq.batchAck("test", [messageBatch1[0].id, messageBatch2[0].id]);
    assertEquals(await mq.fetchProcessingMessages(), []);

    // Subscribe
    let receivedMessage = false;
    const worker = mq.subscribe("test123", {}, async (messages) => {
      assertEquals(messages.length, 1);
      receivedMessage = true;
      await mq.ack("test123", messages[0].id);
    });
    await mq.send("test123", "Hello World");
    // Wait for message to be processed by checking queue stats
    while ((await mq.getQueueStats("test123")).queued > 0) {
      await time.tickAsync(100);
    }
    assertEquals(receivedMessage, true);
    worker.stop();
    assertEquals(mq.queueWaiters.size, 0);
  } finally {
    await db.close();
    time.restore();
  }
});

Deno.test("DataStore MQ - Scale test with multiple subscribers", async () => {
  const time = new FakeTime();
  const db = new MemoryKvPrimitives();
  const eventHook = new EventHook();
  const system = new System<EventHookT>();
  system.addHook(eventHook);

  try {
    const mq = new DataStoreMQ(
      new DataStore(db),
      eventHook,
    );

    const queueName = "scale-test";
    const totalMessages = 1000;
    const batchSize = 7;
    const numSubscribers = 3;

    // Track processed messages across all subscribers
    const processedMessages = new Set<string>();
    const subscriberStats = new Map<number, number>();
    const processingLock = new Set<string>(); // Track which messages are being processed

    // Initialize subscriber stats
    for (let i = 0; i < numSubscribers; i++) {
      subscriberStats.set(i, 0);
    }

    // Create 3 subscribers, each processing batches of 7 messages
    const workers = [];
    for (let subscriberId = 0; subscriberId < numSubscribers; subscriberId++) {
      const worker = mq.subscribe(
        queueName,
        { batchSize },
        async (messages) => {
          assertEquals(
            messages.length <= batchSize,
            true,
            `Batch size should not exceed ${batchSize}`,
          );

          console.log(
            `[Subscriber ${subscriberId}] Processing batch of ${messages.length} messages`,
          );

          // Process each message in the batch
          const messageIds = [];
          for (const message of messages) {
            // Check for concurrent processing (this shouldn't happen due to MQ design)
            if (processingLock.has(message.body)) {
              console.warn(
                `Message ${message.body} is being processed concurrently by subscriber ${subscriberId}`,
              );
              continue;
            }
            processingLock.add(message.body);

            // Ensure no duplicate processing
            if (processedMessages.has(message.body)) {
              console.warn(
                `Message ${message.body} already processed by another subscriber`,
              );
              processingLock.delete(message.body);
              continue;
            }

            processedMessages.add(message.body);
            messageIds.push(message.id);

            // Update subscriber stats
            const currentCount = subscriberStats.get(subscriberId) || 0;
            subscriberStats.set(subscriberId, currentCount + 1);

            // Remove from processing lock
            processingLock.delete(message.body);
          }

          // Ack all messages in the batch that were actually processed
          if (messageIds.length > 0) {
            await mq.batchAck(queueName, messageIds);
          }
        },
      );
      workers.push(worker);
    }

    // Send messages in smaller batches to allow better distribution
    const messageBodies = [];
    for (let i = 0; i < totalMessages; i++) {
      messageBodies.push(`message-${i}`);
    }

    // Send in chunks to allow workers to process and become available
    const chunkSize = 50;
    for (let i = 0; i < messageBodies.length; i += chunkSize) {
      const chunk = messageBodies.slice(i, i + chunkSize);
      await mq.batchSend(queueName, chunk);
      // Give a small delay to allow processing
      await time.tickAsync(10);
    }

    // Wait for all messages to be processed
    const maxWaitTime = 10000; // 10 seconds max wait
    let waitTime = 0;

    while (processedMessages.size < totalMessages && waitTime < maxWaitTime) {
      await time.tickAsync(100);
      waitTime += 100;
    }

    // Verify all messages were processed
    assertEquals(
      processedMessages.size,
      totalMessages,
      `Expected ${totalMessages} messages processed, got ${processedMessages.size}`,
    );

    // Wait a bit more to ensure all acks are processed
    await time.tickAsync(100);

    // Verify queue is empty
    const stats = await mq.getQueueStats(queueName);
    assertEquals(stats.queued, 0, "Queue should be empty");
    assertEquals(stats.processing, 0, "No messages should be processing");

    // Verify work distribution among subscribers
    let totalProcessedAcrossSubscribers = 0;
    for (const [subscriberId, count] of subscriberStats.entries()) {
      totalProcessedAcrossSubscribers += count;
      console.log(`Subscriber ${subscriberId} processed ${count} messages`);
    }
    assertEquals(
      totalProcessedAcrossSubscribers,
      totalMessages,
      "Total processed should match sent messages",
    );

    // Verify at least one subscriber processed messages (relaxed requirement since MQ may favor one worker)
    const activeSubscribers =
      Array.from(subscriberStats.values()).filter((count) => count > 0).length;
    assertEquals(
      activeSubscribers >= 1,
      true,
      "At least one subscriber should have processed messages",
    );

    // Stop all workers
    workers.forEach((worker) => worker.stop());

    // Verify no queue waiters remain
    assertEquals(mq.queueWaiters.size, 0);
  } finally {
    await db.close();
    time.restore();
  }
});


================================================
FILE: client/data/mq.datastore.ts
================================================
import type { DataStore } from "./datastore.ts";
import { parseExpressionString } from "../space_lua/parse.ts";
import { LuaEnv } from "../space_lua/runtime.ts";
import type {
  KV,
  KvKey,
  MQMessage,
  MQStats,
  MQSubscribeOptions,
} from "../../plug-api/types/datastore.ts";
import { race, sleep } from "@silverbulletmd/silverbullet/lib/async";
import type { EventHook } from "../plugos/hooks/event.ts";

export type ProcessingMessage = MQMessage & {
  ts: number;
};

const queuedPrefix = ["mq", "queued"];
const processingPrefix = ["mq", "processing"];
const dlqPrefix = ["mq", "dlq"];

export class QueueWorker {
  stopping = false;
  private stopReject?: (e: any) => void;

  constructor(
    private mq: DataStoreMQ,
    readonly queue: string,
    readonly options: MQSubscribeOptions,
    private callback: (messages: MQMessage[]) => Promise<void> | void,
  ) {
  }

  /**
   * This is the main loop of the worker, whenever it exits the loop it means the worker has stopped
   */
  async run() {
    try {
      while (true) {
        if (this.stopping) {
          break;
        }
        // Poll for messages
        const messages = await this.mq.poll(
          this.queue,
          this.options.batchSize || 1,
        );
        if (messages.length > 0) {
          // We have messages, process them, then immediately loop to poll again
          await this.callback(messages);
        } else {
          // No messages, wait to be woken up or a timeout
          this.mq.eventHook.dispatchEvent(
            `mq:emptyQueue:${this.queue}`,
            this.queue,
          );
          try {
            await race([
              // Wait to be woken up explicitly
              new Promise<void>((resolve, reject) => {
                this.stopReject = reject;
                this.mq.queueWorker(this.queue, resolve, reject);
              }),
              // Or a poll interval timeout
              sleep(this.options.pollInterval || 1000).then(() => {
                // Remove self from waiters
                this.mq.removeQueuedWorker(this.queue, this.stopReject!);
              }),
            ]);
          } catch {
            // Only scenario we should end up here is stop being called
            break;
          }
        }
      }
    } catch (e) {
      console.error("Error in queue worker", e);
    }
  }

  stop() {
    this.stopping = true;
    if (this.stopReject) {
      // Worker was in a waiting state, reject the promise to wake it up and remove from waiters
      this.mq.removeQueuedWorker(this.queue, this.stopReject);
      this.stopReject(new Error("Queue worker stopped"));
    }
  }
}

/**
 * Basic message queue implementation on top of a DataStore
 */
export class DataStoreMQ {
  // Internal sequencer for messages, only really necessary when batch sending tons of messages within a millisecond
  seq = 0;

  queueWaiters = new Map<
    string,
    ({ resolve: () => void; reject: (e: any) => void })[]
  >();

  constructor(
    private ds: DataStore,
    public eventHook: EventHook,
  ) {
  }

  /// Worker management
  public queueWorker(
    queue: string,
    resolve: () => void,
    reject: (e: any) => void,
  ) {
    let waiters = this.queueWaiters.get(queue);
    if (!waiters) {
      waiters = [];
      this.queueWaiters.set(queue, waiters);
    }
    // console.log("[mq]", "Queuing a worker for queue", queue);
    waiters.push({ resolve, reject });
  }

  /**
   * Wakes up a single worker waiting on the given queue, if any
   * @param queue
   */
  wakeupWorker(queue: string) {
    const waiters = this.queueWaiters.get(queue);
    if (waiters && waiters.length > 0) {
      // console.log("[mq]", "Waking up a worker for queue", queue);
      const { resolve } = waiters.shift()!;
      resolve();
      if (waiters.length === 0) {
        // Clean up empty arrays
        this.queueWaiters.delete(queue);
      }
    }
  }

  removeQueuedWorker(queue: string, reject: (e: any) => void) {
    const waiters = this.queueWaiters.get(queue);
    if (waiters) {
      const index = waiters.findIndex((w) => w.reject === reject);
      if (index !== -1) {
        waiters.splice(index, 1);
      }
      if (waiters.length === 0) {
        // Let's not keep empty arrays around
        this.queueWaiters.delete(queue);
      }
    }
  }

  /**
   * Sends a batch of messages to a queue.
   * @param queue the name of the queue
   * @param bodies the bodies of the messages to send
   * @returns
   */
  async batchSend(queue: string, bodies: any[]): Promise<void> {
    if (bodies.length === 0) {
      return;
    }

    const messages: KV<MQMessage>[] = bodies.map((body) => {
      const id = `${Date.now()}-${String(++this.seq).padStart(6, "0")}`;
      const key = [...queuedPrefix, queue, id];
      return {
        key,
        value: { id, queue, body },
      };
    });

    await this.ds.batchSet(messages);

    this.wakeupWorker(queue);
  }

  send(queue: string, body: any): Promise<void> {
    return this.batchSend(queue, [body]);
  }

  async poll(queue: string, maxItems: number): Promise<MQMessage[]> {
    // Note: this is not happening in a transactional way, so we may get duplicate message delivery
    // Retrieve a batch of messages
    const messages = await this.ds.luaQuery<MQMessage>(
      [...queuedPrefix, queue],
      {
        limit: maxItems,
      },
    );
    if (messages.length === 0) {
      return [];
    }

    // Put them in the processing queue
    await this.ds.batchSet(
      messages.map((m) => ({
        key: [...processingPrefix, queue, m.id],
        value: {
          ...m,
          ts: Date.now(),
        },
      })),
    );
    // Delete them from the queued queue
    await this.ds.batchDelete(
      messages.map((m) => [...queuedPrefix, queue, m.id]),
    );

    // Return them
    return messages;
  }

  /**
   * @param queue
   * @param batchSize
   * @param callback
   * @returns a function to be called to unsubscribe
   */
  subscribe(
    queue: string,
    options: MQSubscribeOptions,
    callback: (messages: MQMessage[]) => Promise<void> | void,
  ): QueueWorker {
    const worker = new QueueWorker(this, queue, options, callback);
    // Start the worker asynchronously
    worker.run();
    return worker;
  }

  ack(queue: string, id: string) {
    return this.batchAck(queue, [id]);
  }

  async batchAck(queue: string, ids: string[]) {
    if (ids.length === 0) {
      return;
    }
    await this.ds.batchDelete(
      ids.map((id) => [...processingPrefix, queue, id]),
    );
  }

  async requeueTimeouts(
    timeout: number,
    maxRetries?: number,
    disableDLQ?: boolean,
  ) {
    const now = Date.now();
    const env = new LuaEnv();
    env.setLocal("ts", now - timeout);
    const messages = await this.ds.luaQuery<ProcessingMessage>(
      processingPrefix,
      {
        objectVariable: "m",
        where: parseExpressionString("m.ts < ts"),
      },
      env,
    );
    if (messages.length === 0) {
      return;
    }
    await this.ds.batchDelete(
      messages.map((m) => [...processingPrefix, m.queue, m.id]),
    );
    const newMessages: KV<ProcessingMessage>[] = [];
    for (const m of messages) {
      const retries = (m.retries || 0) + 1;
      if (maxRetries && retries > maxRetries) {
        if (disableDLQ) {
          console.warn(
            "[mq]",
            "Message exceeded max retries, flushing message",
            m,
          );
        } else {
          console.warn(
            "[mq]",
            "Message exceeded max retries, moving to DLQ",
            m,
          );
          newMessages.push({
            key: [...dlqPrefix, m.queue, m.id],
            value: {
              queue: m.queue,
              id: m.id,
              body: m.body,
              ts: Date.now(),
              retries,
            },
          });
        }
      } else {
        console.info("[mq]", "Message ack timed out, requeueing", m);
        newMessages.push({
          key: [...queuedPrefix, m.queue, m.id],
          value: {
            ...m,
            retries,
          },
        });
      }
    }
    await this.ds.batchSet(newMessages);
  }

  async fetchDLQMessages(): Promise<ProcessingMessage[]> {
    return (await this.ds.luaQuery<ProcessingMessage>(dlqPrefix, {}));
  }

  async fetchProcessingMessages(): Promise<ProcessingMessage[]> {
    return (await this.ds.luaQuery<ProcessingMessage>(processingPrefix, {}));
  }

  async flushDLQ(): Promise<void> {
    const ids: KvKey[] = [];
    for (const item of await this.ds.luaQuery<MQMessage>(dlqPrefix, {})) {
      ids.push([...dlqPrefix, item.queue, item.id]);
    }
    await this.ds.batchDelete(ids);
  }

  /**
   * Flushes a queue, including all queued, processing and DLQ messages
   * @param queue
   */
  async flushQueue(queue: string): Promise<void> {
    const ids: KvKey[] = [];
    for (
      const item of await this.ds.luaQuery<MQMessage>(
        [...queuedPrefix, queue],
        {},
      )
    ) {
      ids.push([...queuedPrefix, queue, item.id]);
    }
    for (
      const item of await this.ds.luaQuery<ProcessingMessage>([
        ...processingPrefix,
        queue,
      ], {})
    ) {
      ids.push([...processingPrefix, queue, item.id]);
    }
    for (
      const item of await this.ds.luaQuery<ProcessingMessage>([
        ...dlqPrefix,
        queue,
      ], {})
    ) {
      ids.push([...dlqPrefix, queue, item.id]);
    }
    await this.ds.batchDelete(ids);
  }

  /**
   * Flushes all queues
   */
  flushAllQueues() {
    return this.ds.batchDeletePrefix(["mq"]);
  }

  async getQueueStats(queue?: string): Promise<MQStats> {
    const queued = await this.ds.kv.countQuery({
      prefix: queue ? [...queuedPrefix, queue] : queuedPrefix,
    });
    const processing = await this.ds.kv.countQuery({
      prefix: queue ? [...processingPrefix, queue] : processingPrefix,
    });
    const dlq = await this.ds.kv.countQuery({
      prefix: queue ? [...dlqPrefix, queue] : dlqPrefix,
    });
    return {
      queued,
      processing,
      dlq,
    };
  }

  public async isQueueEmpty(queue: string): Promise<boolean> {
    const stats = await this.getQueueStats(queue);
    return stats.queued === 0 && stats.processing === 0;
  }

  public async awaitEmptyQueue(queue: string): Promise<void> {
    while (true) {
      if (await this.isQueueEmpty(queue)) {
        break;
      }
      await sleep(200);
    }
  }
}


================================================
FILE: client/data/object_index.ts
================================================
import type { ObjectValue } from "@silverbulletmd/silverbullet/type/index";
import type { Config } from "../config.ts";
import type {
  LuaCollectionQuery,
  LuaQueryCollection,
} from "../space_lua/query_collection.ts";
import {
  jsToLuaValue,
  LuaEnv,
  LuaStackFrame,
  type LuaTable,
} from "../space_lua/runtime.ts";
import type { DataStore } from "./datastore.ts";
import type { KV, KvKey } from "@silverbulletmd/silverbullet/type/datastore";
import type { EventHook } from "../plugos/hooks/event.ts";
import type { DataStoreMQ } from "./mq.datastore.ts";
import type { Space } from "../space.ts";
import { validateObject } from "../plugos/syscalls/jsonschema.ts";

const indexKey = "idx";
const pageKey = "ridx";

const indexVersionKey = ["$indexVersion"];

// Bump this one every time a full reindex is needed
const desiredIndexVersion = 9;

type TagDefinition = {
  metatable?: any;
  mustValidate?: boolean;
  schema?: any;
  validate?: (o: ObjectValue) => Promise<string | null | undefined>;
  transform?: (
    o: ObjectValue,
  ) =>
    | Promise<ObjectValue[] | ObjectValue>
    | ObjectValue[]
    | ObjectValue
    | null;
};

export class ObjectValidationError extends Error {
  constructor(message: string, readonly object: ObjectValue) {
    super(message);
  }
}

export class ObjectIndex {
  constructor(
    private ds: DataStore,
    private config: Config,
    private eventHook: EventHook,
    private mq: DataStoreMQ,
  ) {
    // Clear any entries for deleted files
    this.eventHook.addLocalListener("file:deleted", (path: string) => {
      return this.clearFileIndex(path);
    });

    // Tracks if the file:listed event has been triggered,
    // which is fired after all file:changed events have been dispatched
    // resulting in new index entries (if any) being queued in the index queue
    // this is later used to track if the index is complete
    let indexStarted = false;
    this.eventHook.addLocalListener("file:listed", () => {
      indexStarted = true;
    });

    // Handle initial index completion
    this.hasFullIndexCompleted().then((hasCompleted) => {
      if (!hasCompleted) {
        const emptyQueueHandler = async () => {
          console.log("Index queue empty, checking if index is complete");
          // Theoretically we could get empty queue notifications before the file:listed event has been triggered, so let's account for this
          if (indexStarted) {
            // Indexing has just finished for the first time for this client
            console.info(
              "Initial index complete, reloading editor state",
            );
            await this.markFullIndexComplete();
            // Unsubscribe yourself
            this.eventHook.removeLocalListener(
              "mq:emptyQueue:indexQueue",
              emptyQueueHandler,
            );
            // Trigger an editor:reloadState event to reload the editor state (render widgets etc.)
            this.eventHook.dispatchEvent("editor:reloadState");
          }
        };
        this.eventHook.addLocalListener(
          "mq:emptyQueue:indexQueue",
          emptyQueueHandler,
        );
      }
    });
  }

  tag(tagName: string): LuaQueryCollection {
    if (!tagName) {
      throw new Error("Tag name is required");
    }
    return {
      query: (
        query: LuaCollectionQuery,
        env: LuaEnv,
        sf: LuaStackFrame,
      ): Promise<any[]> => {
        return this.ds.luaQuery(
          ["idx", tagName],
          query,
          env,
          sf,
          (key, value: any) => {
            const tag = key[1];
            const mt = this.config.get<LuaTable | undefined>(
              ["tags", tag, "metatable"],
              undefined,
            );
            if (!mt) {
              // Return as is
              return value;
            }
            // Convert to LuaTable
            value = jsToLuaValue(value);
            value.metatable = mt;
            return value;
          },
        );
      },
    };
  }

  getObjectByRef<T>(page: string, tag: string, ref: string) {
    return this.ds.get([indexKey, tag, this.cleanKey(ref, page), page]);
  }

  async ensureFullIndex(space: Space) {
    const currentIndexVersion = await this.getCurrentIndexVersion();

    if (!currentIndexVersion) {
      console.log("No index version found, assuming fresh install");
      return;
    }

    if (
      // If the index version is less than the desired version
      currentIndexVersion < desiredIndexVersion &&
      // And the index queue is empty (meaning no indexing is ongoing)
      await this.mq.isQueueEmpty("indexQueue")
    ) {
      console.info(
        "[index]",
        "Performing a full space reindex, this could take a while...",
        currentIndexVersion,
        desiredIndexVersion,
      );

      await this.reindexSpace(space);

      // Dispatch an editor:reloadState event to reload the editor state (render widgets etc.)
      this.eventHook.dispatchEvent("editor:reloadState");
    }
  }

  async reindexSpace(space: Space) {
    console.log("Clearing page index...");
    await this.clearIndex();
    await this.markFullIndexInComplete();

    const files = await space.deduplicatedFileList();

    console.log("Queing", files.length, "pages to be indexed.");
    // Queue all file names to be indexed
    const startTime = Date.now();
    await this.mq.batchSend("indexQueue", files.map((file) => file.name));
    await this.mq.awaitEmptyQueue("indexQueue");
    await this.markFullIndexComplete();
    console.log("Full index completed after", Date.now() - startTime, "ms");
  }

  public async hasFullIndexCompleted() {
    return (await this.ds.get(indexVersionKey)) >= desiredIndexVersion;
  }

  private getCurrentIndexVersion() {
    return this.ds.get(indexVersionKey);
  }

  async markFullIndexComplete() {
    await this.ds.set(indexVersionKey, desiredIndexVersion);
  }

  async markFullIndexInComplete() {
    await this.ds.delete(indexVersionKey);
  }

  cleanKey(ref: string, page: string) {
    if (ref.startsWith(`${page}@`)) {
      return ref.substring(page.length + 1);
    } else {
      return ref;
    }
  }

  queryLuaObjects<T>(
    globalEnv: LuaEnv,
    tag: string,
    query: LuaCollectionQuery,
    scopedVariables?: Record<string, any>,
  ): Promise<ObjectValue<T>[]> {
    const sf = LuaStackFrame.createWithGlobalEnv(
      globalEnv,
    );
    let env = globalEnv;
    if (scopedVariables) {
      env = new LuaEnv(globalEnv);
      for (const [key, value] of Object.entries(scopedVariables)) {
        env.setLocal(key, jsToLuaValue(value));
      }
    }
    return this.ds.luaQuery([indexKey, tag], query, env, sf);
  }

  batchSet(page: string, kvs: KV[]): Promise<void> {
    const finalBatch: KV[] = [];
    for (const { key, value } of kvs) {
      finalBatch.push({
        key: [indexKey, ...key, page],
        value,
      }, {
        key: [pageKey, page, ...key],
        value: true,
      });
    }
    return this.ds.batchSet(finalBatch);
  }

  batchDelete(page: string, keys: KvKey[]): Promise<void> {
    const finalBatch: KvKey[] = [];
    for (const key of keys) {
      finalBatch.push([indexKey, ...key, page]);
    }
    return this.ds.batchDelete(finalBatch);
  }

  /**
   * Clears all keys for a given file
   * @param file
   */
  public async clearFileIndex(file: string): Promise<void> {
    if (file.endsWith(".md")) {
      file = file.replace(/\.md$/, "");
    }
    // console.log("Clearing index for", file);
    const allKeys: KvKey[] = [];
    for await (
      const { key } of this.ds.query({
        prefix: [pageKey, file],
      })
    ) {
      allKeys.push(key);
      allKeys.push([indexKey, ...key.slice(2), file]);
    }
    await this.ds.batchDelete(allKeys);
  }

  /**
   * Clears the entire index
   */
  public async clearIndex(): Promise<void> {
    const allKeys: KvKey[] = [];
    for await (
      const { key } of this.ds.query({ prefix: [indexKey] })
    ) {
      allKeys.push(key);
    }
    for await (
      const { key } of this.ds.query({ prefix: [pageKey] })
    ) {
      allKeys.push(key);
    }
    await this.ds.batchDelete(allKeys);
    console.log("Deleted", allKeys.length, "keys from the index");
  }

  /**
   * Indexes entities in the data store
   */
  public async indexObjects<T>(
    page: string,
    objects: ObjectValue<T>[],
  ): Promise<void> {
    const kvs = await this.processObjectsToKVs<T>(page, objects, false);
    if (kvs.length > 0) {
      return this.batchSet(page, kvs);
    } else {
      return Promise.resolve();
    }
  }

  /**
   * Validate and transform objects, throws a ValidationError when it fails
   * @param page
   * @param objects
   * @throw ValidationError
   */
  public async validateObjects<T>(page: string, objects: ObjectValue<T>[]) {
    await this.processObjectsToKVs(page, objects, true);
  }

  private async processObjectsToKVs<T>(
    page: string,
    objects: ObjectValue<T>[],
    throwOnValidationErrors: boolean,
  ): Promise<KV<T>[]> {
    const kvs: KV<T>[] = [];
    const tagDefinitions: Record<string, TagDefinition> = this.config.get(
      "tags",
      {},
    );
    // Taking this iteration approach as new objects may be pushed into this array on the fly
    while (objects.length > 0) {
      const obj = objects.shift()!;
      if (!obj.tag) {
        console.error("Object has no tag", obj, "this shouldn't happen");
        continue;
      }
      // Index as all the tag + any additional tags specified
      const allTags = [obj.tag, ...obj.tags || []];
      for (const tag of allTags) {
        const tagDefinition = tagDefinitions[tag];
        // Validate object based on schema if required
        if (
          tagDefinition?.schema &&
          (tagDefinition?.mustValidate || throwOnValidationErrors)
        ) {
          const validationError = validateObject(tagDefinition?.schema, obj);
          if (validationError) {
            if (!throwOnValidationErrors) {
              console.warn(
                `Object failed ${tag} validation so won't be indexed:`,
                obj,
                "Validation error:",
                validationError,
              );
              continue;
            } else {
              throw new ObjectValidationError(validationError, obj);
            }
          }
        }
        // Validate object based on validate callback if required
        if (
          tagDefinition?.validate &&
          (tagDefinition?.mustValidate || throwOnValidationErrors)
        ) {
          const validationError = await tagDefinition.validate(obj);
          if (validationError) {
            if (!throwOnValidationErrors) {
              console.warn(
                `Object failed ${tag} validation so won't be indexed:`,
                obj,
                "Validation error:",
                validationError,
              );
              continue;
            } else {
              throw new ObjectValidationError(validationError, obj);
            }
          }
        }
        // Transform object
        if (tagDefinition?.transform) {
          let newObjects;
          try {
            newObjects = await tagDefinition.transform(obj);
          } catch (e: any) {
            throw new ObjectValidationError(e.message, obj);
          }

          if (!newObjects) {
            // null value returned, just index as usual
            kvs.push({
              key: [tag, this.cleanKey(obj.ref, page)],
              value: obj,
            });
            continue;
          }

          if (!Array.isArray(newObjects)) {
            // Probably returned single object, let's normalize
            newObjects = [newObjects];
          }
          // A transform function _must_ either return an empty list of objects to index, or return at least one object with the same ref
          // If this doesn't happen, we may end up in an infinite loop.
          let foundAssignedRef = false;
          for (const newObj of newObjects) {
            if (!newObj.ref) {
              console.error(
                "transform result object did not contain ref",
                newObj,
              );
              continue;
            }
            if (newObj.ref === obj.ref) {
              // Got the same object back here, let's just index it without further processing
              kvs.push({
                key: [tag, this.cleanKey(newObj.ref, page)],
                value: newObj,
              });
              foundAssignedRef = true;
            } else {
              // Some other object
              objects.push(newObj);
            }
          }
          if (!foundAssignedRef && newObjects.length) {
            throw new Error(
              `transform() result objects for ${tag} did not contain result with original ref.`,
            );
          }
        } else {
          // Just insert it directly
          kvs.push({
            key: [tag, this.cleanKey(obj.ref, page)],
            value: obj,
          });
        }
      }
    }
    return kvs;
  }

  deleteObject(
    page: string,
    tag: string,
    ref: string,
  ): Promise<void> {
    return this.batchDelete(page, [[tag, this.cleanKey(ref, page)]]);
  }
}


================================================
FILE: client/debug.ts
================================================
globalThis.addEventListener("unhandledrejection", (event) => {
  client.reportError(event.reason);
});


================================================
FILE: client/document_editor.ts
================================================
import type { Client } from "./client.ts";
import { timeout } from "@silverbulletmd/silverbullet/lib/async";
import type { DocumentMeta } from "@silverbulletmd/silverbullet/type/index";
import type { Ref } from "@silverbulletmd/silverbullet/lib/ref";
import documentEditorJS from "./document_editor_js.ts";
import type { DocumentEditorContent } from "@silverbulletmd/silverbullet/type/client";

export class DocumentEditor {
  iframe!: HTMLIFrameElement;
  name!: string;
  extension!: string;
  currentPath: string | null = null;
  savePromise: PromiseWithResolvers<void> | null = null;

  constructor(
    readonly parent: HTMLElement,
    readonly client: Client,
    readonly saveMethod: (path: string, content: Uint8Array) => void,
  ) {
  }

  async init(extension: string) {
    this.extension = extension;

    const entry = Array.from(
      this.client.clientSystem.documentEditorHook.documentEditors
        .entries(),
    ).find(([_, { extensions }]) => extensions.includes(this.extension));

    if (!entry) {
      throw new Error("Couldn't find plug for specified extension");
    }

    const [name, { callback }] = entry;
    this.name = name;

    const content = await callback();

    globalThis.addEventListener("message", this.handleMessage.bind(this));

    const { iframe, ready } = DocumentEditor.createIframe(content);
    this.iframe = iframe;

    this.parent.appendChild(this.iframe);

    await ready;
  }

  async destroy() {
    // If name isn't initalized the editor is probably dead
    if (!this.name) return;

    await this.waitForSave();

    this.iframe.remove();
  }

  sendPublicMessage(message: { type: string; data?: any }) {
    this.sendMessage(message);
  }

  openFile(
    data: Uint8Array,
    meta: DocumentMeta,
    details: Ref["details"],
  ) {
    this.sendMessage({
      type: "file-open",
      data: {
        data,
        meta,
        details,
      },
    });

    this.currentPath = meta.name;
  }

  requestSave() {
    if (this.savePromise) {
      console.log(
        "Save was already requested from editor, trying again anyways",
      );
    } else {
      this.savePromise = Promise.withResolvers();
    }

    this.sendMessage({
      type: "request-save",
    });
  }

  focus() {
    this.sendMessage({
      type: "focus",
    });
  }

  private async waitForSave() {
    if (this.savePromise) {
      try {
        await Promise.race([
          this.savePromise.promise,
          timeout(2500),
        ]);
      } catch {
        this.savePromise.resolve();
        this.savePromise = null;

        console.log(
          "Unable to save content of document editor in 2.5s. Aborting save",
        );
      }
    }
  }

  private sendMessage(
    message: { type: string; internal?: boolean; data?: any },
  ) {
    if (!this.iframe?.contentWindow) return;
    message.internal ??= false;
    this.iframe.contentWindow.postMessage(message);
  }

  private async handleMessage(event: any) {
    if (event.source !== this.iframe.contentWindow) return;
    const response = event.data;
    if (!response) return;

    const data = response.data;

    switch (response.type) {
      case "file-changed":
        {
          this.client.ui.viewDispatch({
            type: "document-editor-changed",
          });
          this.client.save().catch((e) => console.error("Couldn't save: ", e));
        }
        break;
      case "file-saved":
        {
          this.savePromise?.resolve();
          this.savePromise = null;

          if (!this.currentPath) return;
          this.saveMethod(this.currentPath, data.data);
        }
        break;
      case "syscall":
        {
          let result: any;

          try {
            const response = await this.client.clientSystem.localSyscall(
              data.name,
              data.args,
            );

            result = { result: response };
          } catch (e: any) {
            result = { error: e.message };
          }

          this.sendMessage({
            type: "syscall-response",
            internal: true,
            data: {
              id: data.id,
              ...result,
            },
          });
        }
        break;
      default:
        console.warn("Unknown event sent from plug: ", data.type);
    }
  }

  updateTheme() {
    this.sendMessage({
      type: "set-theme",
      internal: true,
      data: {
        theme: this.client.ui.viewState.uiOptions.darkMode ? "dark" : "light",
      },
    });
  }

  private static createIframe(
    content: DocumentEditorContent,
  ): { iframe: HTMLIFrameElement; ready: Promise<void> } {
    const doc = new DOMParser().parseFromString(content.html, "text/html");

    // This is only for legacy support
    if ((content as any).script) {
      console.warn(
        "Providing a `script` property with your document editor is deprecated. Inline the script into your HTML instead.",
      );

      // Load the legacy script
      const script = doc.createElement("script");
      script.type = "text/javascript";
      script.text = (content as any).script;
      doc.body.appendChild(script);

      // Because now an html and body tag will be auto created, it can happen
      // that the content is not properly stretching those elements and the
      // content will stay small, let's try to mitgate this
      const style = doc.createElement("style");
      style.textContent =
        "html, body { width: 100%; height: 100%; margin: 0; }";
      doc.head.appendChild(style);
    }

    const script = doc.createElement("script");
    script.type = "text/javascript";
    script.text = documentEditorJS;
    doc.head.prepend(script);

    const base = doc.createElement("base");
    base.href = document.baseURI;
    doc.head.prepend(base);

    // This isn't great, but the only way to get the up-to-date theme without
    // using media queries
    const theme = document.querySelector("html")?.getAttribute("data-theme");
    doc.querySelector("html")!.setAttribute("data-theme", theme ?? "light");

    const iframe = document.createElement("iframe");
    iframe.srcdoc = doc.documentElement.outerHTML;

    const ready = new Promise<void>((resolve) => {
      iframe.addEventListener("load", () => resolve(), { once: true });
    });

    return { iframe, ready };
  }
}


================================================
FILE: client/document_editor_js.ts
================================================
export default `
const pendingRequests = new Map();
let requestID = 0;

globalThis.silverbullet = new EventTarget();

globalThis.silverbullet.syscall = (name, ...args) => {
  const promise = Promise.withResolvers();

  pendingRequests.set(++requestID, { ...promise });

  globalThis.parent.postMessage({
    type: "syscall",
    data: {
      id: requestID,
      name,
      args,
    },
  }, "*");

  return promise.promise;
};

globalThis.addEventListener("message", (message) => {
  const response = message.data, data = message.data.data;

  // Passthrough non internal events
  if (!response.internal) {
    globalThis.silverbullet.dispatchEvent(new CustomEvent(response.type, { detail: data }));
    return;
  }

  switch (response.type) {
    case "syscall-response": {
      const id = data.id;
      const request = pendingRequests.get(id);
      if (!request) {
        console.error(\`Failed to lookup request with id \${id}\`);
        throw Error("Invalid request id");
      }

      pendingRequests.delete(id);

      if (data.error) {
        request.reject(new Error(data.error));
      } else {
        request.resolve(data.result);
      }
    } break;
    case "set-theme": {
      document.querySelector("html").setAttribute("data-theme", data.theme);
    } break;
  }
});

globalThis.silverbullet.sendMessage = (type, data) => {
  globalThis.parent.postMessage({ type, data }, "*");
};

// When an iframe is focused, all keyboard events will be captured by the
// iframe. This is bad because we obviously want stuff like the command
// picker to keep working. This is done by listening to the event **in the
// capture phase** (so we get it first), sending it to the actual SB DOM,
// and only if it doesn't have defaultPrevented set, we send it further
// down.
globalThis.addEventListener("keydown", (event) => {
  // This is really hacky... open to other solutions
  const keyEvent = new KeyboardEvent("keydown", event);

  Object.defineProperty(keyEvent, "target", {
    value: globalThis.parent.document.body,
  });

  globalThis.parent.document.dispatchEvent(keyEvent);

  if (keyEvent.defaultPrevented) {
    event.preventDefault();
    event.stopPropagation();
  }
}, true);
`;


================================================
FILE: client/editor_ui.tsx
================================================
import { Confirm, Prompt } from "./components/basic_modals.tsx";
import { CommandPalette } from "./components/command_palette.tsx";
import { FilterList } from "./components/filter.tsx";
import { AnythingPicker } from "./components/anything_picker.tsx";
import { TopBar } from "./components/top_bar.tsx";
import reducer from "./reducer.ts";
import {
  type Action,
  type AppViewState,
  initialViewState,
} from "./types/ui.ts";
import * as featherIcons from "preact-feather";
import * as mdi from "./filtered_material_icons.ts";
import { h, render as preactRender } from "preact";
import { useEffect, useReducer } from "preact/hooks";
import { closeSearchPanel } from "@codemirror/search";
import { runScopeHandlers } from "@codemirror/view";
import type { Client } from "./client.ts";
import { Panel } from "./components/panel.tsx";
import { safeRun } from "@silverbulletmd/silverbullet/lib/async";
import type { FilterOption } from "@silverbulletmd/silverbullet/type/client";
import {
  getNameFromPath,
  getPathExtension,
  isMarkdownPath,
  isValidName,
  parseToRef,
  type Path,
} from "@silverbulletmd/silverbullet/lib/ref";

export class MainUI {
  viewState: AppViewState = initialViewState;

  constructor(private client: Client) {
    // Make keyboard shortcuts work even when the editor is in read only mode or not focused
    globalThis.addEventListener("keydown", (ev) => {
      if (!client.editorView.hasFocus) {
        const target = ev.target as HTMLElement;
        if (target.className === "cm-textfield" && ev.key === "Escape") {
          // Search panel is open, let's close it
          console.log("Closing search panel");
          closeSearchPanel(client.editorView);
          return;
        } else if (
          target.className === "cm-textfield" ||
          target.closest(".cm-content") || target.closest(".cm-vim-panel")
        ) {
          // In some cm element, let's back out
          return;
        }
        if (runScopeHandlers(client.editorView, ev, "editor")) {
          ev.preventDefault();
        }
      }
    });

    globalThis.addEventListener("touchstart", (ev) => {
      // Launch the page picker on a two-finger tap
      if (ev.touches.length === 2) {
        ev.stopPropagation();
        ev.preventDefault();
        client.startPageNavigate("page");
      }
      // Launch the command palette using a three-finger tap
      if (ev.touches.length === 3) {
        ev.stopPropagation();
        ev.preventDefault();
        client.startCommandPalette();
      }
    });

    globalThis.addEventListener("mouseup", (_) => {
      setTimeout(() => {
        client.editorView.dispatch({});
      });
    });
  }

  viewDispatch: (action: Action) => void = () => {
  };

  ViewComponent() {
    const [viewState, dispatch] = useReducer(reducer, initialViewState);
    this.viewState = viewState;
    this.viewDispatch = dispatch;

    const client = this.client;

    useEffect(() => {
      if (viewState.current) {
        document.title =
          (this.client.currentPageMeta()?.pageDecoration?.prefix ?? "") +
          getNameFromPath(viewState.current.path);
      }
    }, [viewState.current]);

    useEffect(() => {
      this.client.rebuildEditorState();
      this.client.dispatchAppEvent("editor:modeswitch");
    }, [viewState.uiOptions.vimMode]);

    useEffect(() => {
      const updateTheme = () => {
        const darkMode = viewState.uiOptions.darkMode === undefined
          ? globalThis.matchMedia("(prefers-color-scheme: dark)").matches
          : viewState.uiOptions.darkMode;

        document.documentElement.dataset.theme = darkMode ? "dark" : "light";

        if (this.client.isDocumentEditor()) {
          this.client.documentEditor.updateTheme();
        }
      };

      updateTheme();

      if (viewState.uiOptions.darkMode === undefined) {
        const mediaQuery = globalThis.matchMedia(
          "(prefers-color-scheme: dark)",
        );
        mediaQuery.addEventListener("change", updateTheme);

        return () => {
          mediaQuery.removeEventListener("change", updateTheme);
        };
      }
    }, [viewState.uiOptions.darkMode]);

    useEffect(() => {
      document.documentElement.dataset.markdownSyntaxRendering =
        viewState.uiOptions.markdownSyntaxRendering ? "on" : "off";
    }, [viewState.uiOptions.markdownSyntaxRendering]);

    useEffect(() => {
      // Need to dispatch a resize event so that the top_bar can pick it up
      globalThis.dispatchEvent(new Event("resize"));
    }, [viewState.panels]);
    const actionButtons = client.config.get<ActionButton[]>(
      "actionButtons",
      [],
    );
    return (
      <>
        {viewState.showPageNavigator && (
          <AnythingPicker
            allDocuments={viewState.allDocuments}
            allPages={viewState.allPages}
            extensions={new Set(
              Array.from(
                client.clientSystem.documentEditorHook.documentEditors.values(),
              ).flatMap(({ extensions }) => extensions),
            )}
            currentPath={client.currentPath()}
            mode={viewState.pageNavigatorMode}
            vimMode={viewState.uiOptions.vimMode}
            darkMode={viewState.uiOptions.darkMode}
            onModeSwitch={(mode) => {
              dispatch({ type: "stop-navigate" });
              setTimeout(() => {
                dispatch({ type: "start-navigate", mode });
              });
            }}
            onNavigate={(name) => {
              dispatch({ type: "stop-navigate" });
              setTimeout(() => {
                client.focus();
              });

              if (!name) {
                return;
              }

              safeRun(async () => {
                const ref = parseToRef(name);

                // Check beforhand, because we don't want to allow any link
                // stuff like #header here. The `!ref` check is just for
                // Typescript
                if (!isValidName(name) || !ref) {
                  // It's not a valid name so either, the user tried to create a
                  // page or we have an invalid file in the space. Names are
                  // only unique for files which follow our rules, so we are
                  // kind of in unknown territory now.

                  if (client.clientSystem.allKnownFiles.has(name)) {
                    // Try it as a document name === path
                    await this.promptDocumentOperation(
                      name as Path,
                      `'${name}' has an invalid name. You can now modify it`,
                    );
                  } else if (
                    client.clientSystem.allKnownFiles.has(`${name}.md`)
                  ) {
                    // Try it as a page
                    await this.promptDocumentOperation(
                      `${name}.md`,
                      `'${name}.md' has an invalid name. You can now modify it`,
                    );
                  } else {
                    client.flashNotification(
                      `Couldn't create page ${name}, name is invalid`,
                      "error",
                    );
                  }

                  return;
                }

                if (
                  !isMarkdownPath(ref.path) &&
                  !Array.from(
                    client.clientSystem.documentEditorHook.documentEditors
                      .values(),
                  ).some(({ extensions }) =>
                    extensions.includes(getPathExtension(ref.path))
                  )
                ) {
                  await this.promptDocumentOperation(
                    ref.path,
                    "This file cannot be edited, select your desired action.",
                  );
                } else {
                  client.navigate(ref);
                }
              });
            }}
          />
        )}
        {viewState.showCommandPalette && (
          <CommandPalette
            onTrigger={(cmd) => {
              safeRun(async () => {
                dispatch({ type: "hide-palette" });
                if (cmd) {
                  await this.client.registerCommandRun(cmd.name);
                  try {
                    const returnValue = await cmd.run!();
                    if (returnValue !== false) {
                      client.focus();
                    }
                  } catch (e: any) {
                    this.client.reportError(e, "Command invocation");
                  }
                } else {
                  setTimeout(() => client.focus());
                }
              });
            }}
            commands={client.getCommandsByContext(viewState)}
            vimMode={viewState.uiOptions.vimMode}
            darkMode={viewState.uiOptions.darkMode}
          />
        )}
        {viewState.showFilterBox && (
          <FilterList
            label={viewState.filterBoxLabel}
            placeholder={viewState.filterBoxPlaceHolder}
            options={viewState.filterBoxOptions}
            vimMode={viewState.uiOptions.vimMode}
            darkMode={viewState.uiOptions.darkMode}
            allowNew={false}
            helpText={viewState.filterBoxHelpText}
            onSelect={viewState.filterBoxOnSelect}
          />
        )}
        {viewState.showPrompt && (
          <Prompt
            message={viewState.promptMessage!}
            defaultValue={viewState.promptDefaultValue}
            vimMode={viewState.uiOptions.vimMode}
            darkMode={viewState.uiOptions.darkMode}
            callback={(value) => {
              dispatch({ type: "hide-prompt" });
              viewState.promptCallback!(value);
            }}
          />
        )}
        {viewState.showConfirm && (
          <Confirm
            message={viewState.confirmMessage!}
            callback={(value) => {
              dispatch({ type: "hide-confirm" });
              viewState.confirmCallback!(value);
            }}
          />
        )}
        <TopBar
          pageName={!viewState.current
            ? ""
            : getNameFromPath(viewState.current.path)}
          notifications={viewState.notifications}
          isOnline={viewState.isOnline}
          unsavedChanges={viewState.unsavedChanges}
          isLoading={viewState.isLoading}
          vimMode={viewState.uiOptions.vimMode}
          darkMode={viewState.uiOptions.darkMode}
          progressPercentage={viewState.progressPercentage}
          progressType={viewState.progressType}
          onClick={() => {
            if (!client.isDocumentEditor()) {
              client.editorView.scrollDOM.scrollTop = 0;
            }
          }}
          onRename={async (newName) => {
            if (client.isDocumentEditor()) {
              if (!newName) return;

              console.log("Now renaming document to...", newName);
              await client.clientSystem.system.invokeFunction(
                "index.renameDocumentCommand",
                [{ document: newName }],
              );
            } else {
              if (!newName) {
                // Always move cursor to the start of the page
                client.editorView.dispatch({
                  selection: { anchor: 0 },
                });
                client.focus();
                return;
              }
              console.log("Now renaming page to...", newName);
              await client.clientSystem.system.invokeFunction(
                "index.renamePageCommand",
                [{ page: newName }],
              );
              client.focus();
            }
          }}
          actionButtons={[
            // Vertical menu button
            ...(viewState.isMobile &&
                client.config.get<string>("mobileMenuStyle", "hamburger")
                  .includes(
                    "hamburger",
                  ))
              ? [{
                icon: featherIcons.MoreVertical,
                description: "Open Menu",
                class: "expander",
                callback: () => {
                  // Make the expander button open/close the menu via toggling the CSS class "open"
                  document.querySelector("#sb-top .sb-actions.hamburger")
                    ?.classList.toggle("open");
                },
              }]
              : [],
            // Custom action buttons
            ...actionButtons.filter(( // Filter out buttons without icons (invalid) and mobile buttons when not in mobile mode
              button,
            ) =>
              button.icon && (
                (typeof button.mobile === "undefined") ||
                (button.mobile === viewState.isMobile)
              )
            )
              // Then ensure all buttons have a priority set (by default based on array index)
              .map((button, index) => ({
                ...button,
                priority: button.priority ?? actionButtons.length - index,
              }))
              .sort((a, b) => b.priority - a.priority)
              .map((button) => {
                const mdiIcon = (mdi as any)[kebabToCamel(button.icon)];
                let featherIcon =
                  (featherIcons as any)[kebabToCamel(button.icon)];
                if (!featherIcon) {
                  featherIcon = featherIcons.HelpCircle;
                }
                return {
                  icon: mdiIcon ? mdiIcon : featherIcon,
                  description: button.description || "",
                  callback: button.run || (() => {
                    client.flashNotification(
                      "actionButton did not specify a run() callback",
                      "error",
                    );
                  }),
                  href: "",
                };
              }),
          ]}
          rhs={!!viewState.panels.rhs.mode && (
            <div
              className="panel"
              style={{ flex: viewState.panels.rhs.mode }}
            />
          )}
          lhs={!!viewState.panels.lhs.mode && (
            <div
              className="panel"
              style={{ flex: viewState.panels.lhs.mode }}
            />
          )}
          pageNamePrefix={client.currentPageMeta()?.pageDecoration?.prefix ??
            ""}
          cssClass={(client.currentPageMeta()?.pageDecoration?.cssClasses ?? [])
            .join(" ").replaceAll(/[^a-zA-Z0-9-_ ]/g, "")}
          mobileMenuStyle={client.config.get<string>(
            "mobileMenuStyle",
            "hamburger",
          )}
        />
        <div id="sb-main">
          {(viewState.panels.lhs.mode !== undefined) && (
            <Panel config={viewState.panels.lhs} editor={client} />
          )}
          <div id="sb-editor" />
          {(viewState.panels.rhs.mode !== undefined) && (
            <Panel config={viewState.panels.rhs} editor={client} />
          )}
        </div>
        {(viewState.panels.modal.mode !== undefined) && (
          <div
            className="sb-modal"
            style={{ inset: `${viewState.panels.modal.mode}px` }}
          >
            <Panel config={viewState.panels.modal} editor={client} />
          </div>
        )}
        {(viewState.panels.bhs.mode !== undefined) && (
          <div className="sb-bhs">
            <Panel config={viewState.panels.bhs} editor={client} />
          </div>
        )}
      </>
    );
  }

  render(container: Element) {
    // const ViewComponent = this.ui.ViewComponent.bind(this.ui);
    container.innerHTML = "";
    preactRender(h(this.ViewComponent.bind(this), {}), container);
  }

  async promptDocumentOperation(path: Path, msg: string) {
    const options: string[] = ["View", "Delete", "Rename"];

    const option = await client.filterBox(
      "Modify",
      options.map((x) => ({ name: x } as FilterOption)),
      msg,
    );
    if (!option) return;

    switch (option.name) {
      case "View": {
        await client.navigate({ path: path });
        break;
      }
      case "Delete": {
        if (
          await client.confirm(
            `Are you sure you would like delete ${getNameFromPath(path)}?`,
          )
        ) {
          if (isMarkdownPath(path)) {
            await client.space.deletePage(getNameFromPath(path));
          } else {
            await client.space.deleteDocument(getNameFromPath(path));
          }
        }
        break;
      }
      case "Rename": {
        if (isMarkdownPath(path)) {
          await client.clientSystem.system.invokeFunction(
            "index.renamePageCommand",
            [{ oldPage: getNameFromPath(path) }],
          );
        } else {
          await client.clientSystem.system.invokeFunction(
            "index.renameDocumentCommand",
            [{ oldDocument: getNameFromPath(path) }],
          );
        }
        break;
      }
    }
  }
}

// TODO: Parking this here for now, this is very similar to the definition in top_bar.tsx

type ActionButton = {
  icon: string;
  description?: string;
  mobile?: boolean;
  priority?: number;
  run: () => void;
};

function kebabToCamel(str: string) {
  return str.replace(/-([a-z])/g, (g) => g[1].toUpperCase()).replace(
    /^./,
    (g) => g.toUpperCase(),
  );
}


================================================
FILE: client/filtered_material_icons.ts
================================================
/**
 * We apply a selective export of allowed icons.
 * It allows us to only import icons which fit an editor context, this avoids inflating the
 * bundle size with mostly unused icons, but still allows users to freely choose from the allowed
 * selection.
 */
export {
  MdChecklist,
  MdCode,
  MdFileUpload,
  MdFormatBold,
  MdFormatIndentDecrease,
  MdFormatIndentIncrease,
  MdFormatItalic,
  MdFormatListBulleted,
  MdFormatListNumbered,
  MdFormatQuote,
  MdFormatSize,
  MdFormatStrikethrough,
  MdHome,
  MdHorizontalRule,
  MdImage,
  MdLink,
  MdOutlineLibraryBooks,
  MdTerminal,
} from "react-icons/md";


================================================
FILE: client/fonts/LICENSE.md
================================================
# iA Writer Typeface

Copyright © 2018 Information Architects Inc. with Reserved Font Name "iA Writer"

# Based on IBM Plex Typeface

Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"

# License

This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL

---

## SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007

PREAMBLE The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation efforts
of academic and linguistic communities, and to provide a free and open framework
in which fonts may be shared and improved in partnership with others.

The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The fonts,
including any derivative works, can be bundled, embedded, redistributed and/or
sold with any software provided that any reserved names are not used by
derivative works. The fonts and derivatives, however, cannot be released under
any other type of license. The requirement for fonts to remain under this
license does not apply to any document created using the fonts or their
derivatives.

DEFINITIONS "Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may include source
files, build scripts and documentation.

"Reserved Font Name" refers to any names specified as such after the copyright
statement(s).

"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).

"Modified Version" refers to any derivative made by adding to, deleting, or
substituting -- in part or in whole -- any of the components of the Original
Version, by changing formats or by porting the Font Software to a new
environment.

"Author" refers to any designer, engineer, programmer, technical writer or other
person who contributed to the Font Software.

PERMISSION & CONDITIONS Permission is hereby granted, free of charge, to any
person obtaining a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:

1. Neither the Font Software nor any of its individual components, in Original
   or Modified Versions, may be sold by itself.

2. Original or Modified Versions of the Font Software may be bundled,
   redistributed and/or sold with any software, provided that each copy contains
   the above copyright notice and this license. These can be included either as
   stand-alone text files, human-readable headers or in the appropriate
   machine-readable metadata fields within text or binary files as long as those
   fields can be easily viewed by the user.

3. No Modified Version of the Font Software may use the Reserved Font Name(s)
   unless explicit written permission is granted by the corresponding Copyright
   Holder. This restriction only applies to the primary font name as presented
   to the users.

4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font Software
   shall not be used to promote, endorse or advertise any Modified Version,
   except to acknowledge the contribution(s) of the Copyright Holder(s) and the
   Author(s) or with their explicit written permission.

5. The Font Software, modified or unmodified, in part or in whole, must be
   distributed entirely under this license, and must not be distributed under
   any other license. The requirement for fonts to remain under this license
   does not apply to any document created using the Font Software.

TERMINATION This license becomes null and void if any of the above conditions
are not met.

DISCLAIMER THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT
HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY
GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN
ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY
TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.


================================================
FILE: client/html/auth.html
================================================
<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <meta
            content="width=device-width, initial-scale=1, maximum-scale=1"
            name="viewport"
        />
        <base href="{{.HostPrefix}}/" />
        <link href="favicon.png" rel="icon" type="image/x-icon" />
        <title>Login to {{.SpaceName}}</title>
        <style>
            *,
            *::before,
            *::after {
                box-sizing: border-box;
            }

            * {
                padding: 0;
                margin: 0;
            }

            html {
                font-family:
                    -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
                    "Helvetica Neue", Arial, "Noto Sans", sans-serif,
                    "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
                    "Noto Color Emoji";
                --primary-button-color: #eee;
                --primary-button-background-color: #464cfc;
                --primary-button-hover-background-color: color-mix(
                    in srgb,
                    var(--primary-button-background-color),
                    black 35%
                );
                --primary-button-border-color: transparent;
                --modal-border-color: #6c6c6c;
                --top-color: #66a3bb;
                --bottom-color: #0b3a69;
                background: linear-gradient(
                    var(--top-color),
                    var(--bottom-color)
                );
            }

            header {
                position: absolute;
                padding: 1rem;
                font-size: 1.2rem;
            }

            footer {
                text-align: center;
            }

            h1 {
                font-size: 1.5rem;
                font-weight: normal;
            }

            input {
                font-family: inherit;
                font-size: inherit;
                padding: 0.5em;
            }

            form > div {
                display: flex;
                flex-direction: column;
                flex-grow: 1;
            }

            button {
                background: var(--primary-button-background-color);
                color: var(--primary-button-color);
                box-shadow: 0 0 0.2em rgba(0, 0, 0, 0.05);
                border: 1px solid var(--border-color);
                padding: 0.7em;
                border-radius: 0.5em;

                &:hover {
                    background-color: var(
                        --primary-button-hover-background-color
                    );
                }

                &:focus {
                    outline: 2px solid var(--primary-button-color);
                    outline-offset: -3px;
                }
            }

            .floating-island {
                padding: 3em;
                border-radius: 8px;
                box-shadow: rgba(0, 0, 0, 0.35) 0px 20px 20px;
                background-color: white;
                border: var(--modal-border-color) 1px solid;
            }

            .error-message {
                color: red;
            }

            .center {
                height: 100vh;
                display: grid;
                place-items: center;
            }

            .flow {
                display: flex;
                flex-direction: column;
                justify-content: flex-start;
            }

            .flow > * {
                margin-block: 0;
            }

            .flow > * + * {
                margin-block-start: var(--space, 1rem);
            }

            /* Add styles for checkbox alignment */
            .checkbox-wrapper {
                display: flex;
                align-items: center;
                gap: 0.5rem;
                flex-direction: row;
            }

            .checkbox-wrapper input[type="checkbox"] {
                margin: 0;
            }
        </style>
    </head>

    <body>
        <div class="center">
            <div class="flow floating-island">
                <h1>
                    Login to <img src=".client/logo.png" style="height: 1ch" />
                    {{.SpaceName}}
                </h1>
                <form class="flow" id="login" method="POST">
                    <div class="error-message"></div>
                    <div>
                        <label for="username">Username</label>
                        <input
                            autocapitalize="off"
                            autocomplete="off"
                            autocorrect="off"
                            autofocus
                            id="username"
                            name="username"
                            type="text"
                        />
                    </div>
                    <div>
                        <label for="password">Password</label>
                        <input id="password" name="password" type="password" />
                    </div>
                    <div class="checkbox-wrapper">
                        <input
                            id="rememberMe"
                            name="rememberMe"
                            type="checkbox"
                        />
                        <label for="rememberMe">Remember me ({{.RememberMeDays}} days)</label>
                    </div>
                    <div class="checkbox-wrapper">
                        <input
                            id="clientEncryption"
                            name="clientEncryption"
                            type="checkbox"
                        />
                        <label for="clientEncryption">Enable client encryption (e.g. when using a public computer)</label>
                    </div>
                    <div style="--space: 1.8rem">
                        <button>Login</button>
                    </div>
                </form>
                <footer>
                    <a href="https://silverbullet.md">What is SilverBullet?</a>
                </footer>
            </div>
        </div>

<script>
const workerURL = new URL("service_worker.js", document.baseURI);
console.log("Registering service worker");
navigator.serviceWorker.register(workerURL, {
  type: "module",
  scope: workerURL.pathname.substring(0, workerURL.pathname.lastIndexOf("/") + 1),
}).then(() => {
  console.log("Service worker registered");
});

document.getElementById("login").addEventListener("submit", (e) => {
  const enableEncryption = document.getElementById('clientEncryption').checked;
  const params = new URLSearchParams();
  const rememberMe = document.getElementById('rememberMe').checked;
  const username = document.getElementById('username').value;
  const password = document.getElementById('password').value;
  params.append('username', username);
  params.append('password', password);
  if (rememberMe) {
    params.append('rememberMe', 'true');
  }
  fetch('.auth', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    credentials:'include',
    body: params.toString(),
  })
  .then(async(response) => {
    const body = await response.json();
    if(body.status === "error") {
      document.querySelector(".error-message").innerText =
          body.error;
    } else {
      if(enableEncryption) {
        localStorage.setItem('enableEncryption', "true");
      } else {
        localStorage.removeItem('enableEncryption');
      }
      if(enableEncryption) {
        const salt = base64Decode({{.EncryptionSalt}});
        const key = await deriveKey(`${username}:${password}`, salt);
        // Encode password to ArrayBuffer
        if(navigator.serviceWorker?.controller) {
          navigator.serviceWorker.controller.postMessage({
            type: "set-encryption-key",
            key,
          });
        } else {
          alert("Something is not right, service worker not active");
          return;
        }
      }
      location.href = body.redirect;
    }
  })
  e.preventDefault();
});
const params = new URLSearchParams(window.location.search);
const from = params.get("from");
if(localStorage.getItem("enableEncryption")) {
  document.getElementById("clientEncryption").checked = true;
}
if (from) {
    var input = document.createElement("input");
    input.setAttribute("type", "hidden");
    input.setAttribute("name", "from");
    input.setAttribute("value", from);

    document.getElementById("login").appendChild(input);
}

async function deriveKey(phrase, salt) {
  // Encode password to ArrayBuffer
  const phraseBytes = new TextEncoder().encode(phrase);

  // Import password as a CryptoKey
  const baseKey = await crypto.subtle.importKey(
    "raw",
    phraseBytes,
    { name: "PBKDF2" },
    false,
    ["deriveBits", "deriveKey"],
  );

  // Derive CTR key
  const ctrKey = await crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt: salt,
      iterations: 100000,
      hash: "SHA-256",
    },
    baseKey,
    {
      name: "AES-CTR",
      length: 256,
    },
    true, // extractable
    ["encrypt", "decrypt"],
  );

  // Export
  const key = await crypto.subtle.exportKey("raw", ctrKey);
  // Base64 encode
  return base64Encode(new Uint8Array(key));
}

function base64Encode(buffer) {
  let binary = "";
  const len = buffer.byteLength;
  for (let i = 0; i < len; i++) {
    binary += String.fromCharCode(buffer[i]);
  }
  return btoa(binary);
}

function base64Decode(s) {
  const binString = atob(s);
  const len = binString.length;
  const bytes = new Uint8Array(len);
  for (let i = 0; i < len; i++) {
    bytes[i] = binString.charCodeAt(i);
  }
  return bytes;
}
</script>
    </body>
</html>


================================================
FILE: client/html/index.html
================================================
<!doctype html>
<html lang="en" prefix="og: https://ogp.me/ns#">
    <head>
        <meta charset="utf-8" />
        <meta
            content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=resizes-content"
            name="viewport"
        />
        <base href="{{.HostPrefix}}/" />
        <link href=".client/logo.png" rel="apple-touch-icon" />
        <meta
            content="#e1e1e1"
            media="(prefers-color-scheme: light)"
            name="theme-color"
        />
        <meta
            content="#262626"
            media="(prefers-color-scheme: dark)"
            name="theme-color"
        />
        <meta content="no-referrer" name="referrer" />

        <title>{{.Title}}</title>
        <meta content=".client/logo.png" property="og:image" />
        <meta content="{{.Description}}" property="og:description" />

        <script>
            // Some global variables we need to make this work
            Deno = {
                args: [],
                build: {
                    arch: "x86_64",
                    os: "browser",
                },
                core: {
                    runMicrotasks() {
                        // console.log("Not supported");
                    },
                    setHasTickScheduled() {
                        // console.log("Not supported");
                    },
                },
                env: {
                    get(key) {
                        // return undefined;
                    },
                },
                errors: {
                    AlreadyExists: class extends Error {},
                },
            };
        </script>
        <link href=".client/main.css" rel="stylesheet" />
        <script src=".client/client.js" type="module"></script>
        <link
            crossorigin="use-credentials"
            href=".client/manifest.json"
            rel="manifest"
        />
        <link href=".client/favicon.png" rel="icon" type="image/x-icon" />
        {{.AdditionalHeadHTML}}
    </head>

    <body>
        <div id="sb-root">
            <div id="sb-main">
                <div id="sb-editor">
                    <div class="cm-editor">
                        <div class="cm-content">{{.Content | markdown}}</div>
                    </div>
                </div>
            </div>
        </div>
        <div id="custom-styles"></div>
    </body>
</html>


================================================
FILE: client/languages.ts
================================================
import { yaml as yamlLanguage } from "@codemirror/legacy-modes/mode/yaml";
import {
  pgSQL as postgresqlLanguage,
  standardSQL as sqlLanguage,
} from "@codemirror/legacy-modes/mode/sql";
import { rust as rustLanguage } from "@codemirror/legacy-modes/mode/rust";
import { python as pythonLanguage } from "@codemirror/legacy-modes/mode/python";
import {
  protobuf as protobufLanguage,
} from "@codemirror/legacy-modes/mode/protobuf";
import { shell as shellLanguage } from "@codemirror/legacy-modes/mode/shell";
import { swift as swiftLanguage } from "@codemirror/legacy-modes/mode/swift";
import { toml as tomlLanguage } from "@codemirror/legacy-modes/mode/toml";
import { xml as xmlLanguage } from "@codemirror/legacy-modes/mode/xml";
import { json as jsonLanguage } from "@codemirror/legacy-modes/mode/javascript";
import { r as rLanguage } from "@codemirror/legacy-modes/mode/r";
import { htmlLanguage } from "@codemirror/lang-html";
import { go as goLanguage } from "@codemirror/legacy-modes/mode/go";
import { diff as diffLanguage } from "@codemirror/legacy-modes/mode/diff";
import {
  powerShell as powerShellLanguage,
} from "@codemirror/legacy-modes/mode/powershell";
import { perl as perlLanguage } from "@codemirror/legacy-modes/mode/perl";
import { ruby as rubyLanguage } from "@codemirror/legacy-modes/mode/ruby";
import { tcl as tclLanguage } from "@codemirror/legacy-modes/mode/tcl";
import {
  verilog as verilogLanguage,
} from "@codemirror/legacy-modes/mode/verilog";
import { vhdl as vhdlLanguage } from "@codemirror/legacy-modes/mode/vhdl";
import {
  dockerFile as dockerfileLanguage,
} from "@codemirror/legacy-modes/mode/dockerfile";
import { cmake as cmakeLanguage } from "@codemirror/legacy-modes/mode/cmake";
import { erlang as erlangLanguage } from "@codemirror/legacy-modes/mode/erlang";
import {
  c as cLanguage,
  cpp as cppLanguage,
  csharp as csharpLanguage,
  dart as dartLanguage,
  java as javaLanguage,
  kotlin as kotlinLanguage,
  objectiveC as objectiveCLanguage,
  objectiveCpp as objectiveCppLanguage,
  scala as scalaLanguage,
} from "@codemirror/legacy-modes/mode/clike";
import { type Language, StreamLanguage } from "@codemirror/language";
import {
  javascriptLanguage,
  typescriptLanguage,
} from "@codemirror/lang-javascript";
import { cssLanguage } from "@codemirror/lang-css";
import { nixLanguage } from "@replit/codemirror-lang-nix";
import { luaLanguage } from "./space_lua/parse.ts";

const yamlStreamLanguage = StreamLanguage.define(yamlLanguage);

export const builtinLanguages: Record<string, Language> = {
  "meta": yamlStreamLanguage,
  "yaml": yamlStreamLanguage,
  "include": yamlStreamLanguage,
  "space-config": yamlStreamLanguage,
  "data": yamlStreamLanguage,
  "toc": yamlStreamLanguage,
  "javascript": javascriptLanguage,
  "space-script": javascriptLanguage,
  "js": javascriptLanguage,
  "typescript": typescriptLanguage,
  "ts": typescriptLanguage,
  "sql": StreamLanguage.define(sqlLanguage),
  "postgresql": StreamLanguage.define(postgresqlLanguage),
  "pgsql": StreamLanguage.define(postgresqlLanguage),
  "postgres": StreamLanguage.define(postgresqlLanguage),
  "rust": StreamLanguage.define(rustLanguage),
  "rs": StreamLanguage.define(rustLanguage),
  "r": StreamLanguage.define(rLanguage),
  "css": cssLanguage,
  "space-style": cssLanguage,
  "html": htmlLanguage,
  "python": StreamLanguage.define(pythonLanguage),
  "py": StreamLanguage.define(pythonLanguage),
  "protobuf": StreamLanguage.define(protobufLanguage),
  "proto": StreamLanguage.define(protobufLanguage),
  "shell": StreamLanguage.define(shellLanguage),
  "sh": StreamLanguage.define(shellLanguage),
  "bash": StreamLanguage.define(shellLanguage),
  "zsh": StreamLanguage.define(shellLanguage),
  "fish": StreamLanguage.define(shellLanguage),
  "swift": StreamLanguage.define(swiftLanguage),
  "toml": StreamLanguage.define(tomlLanguage),
  "json": StreamLanguage.define(jsonLanguage),
  "xml": StreamLanguage.define(xmlLanguage),
  "c": StreamLanguage.define(cLanguage),
  "cpp": StreamLanguage.define(cppLanguage),
  "c++": StreamLanguage.define(cppLanguage),
  "cxx": StreamLanguage.define(cppLanguage),
  "java": StreamLanguage.define(javaLanguage),
  "csharp": StreamLanguage.define(csharpLanguage),
  "cs": StreamLanguage.define(csharpLanguage),
  "c#": StreamLanguage.define(csharpLanguage),
  "scala": StreamLanguage.define(scalaLanguage),
  "kotlin": StreamLanguage.define(kotlinLanguage),
  "objc": StreamLanguage.define(objectiveCLanguage),
  "objective-c": StreamLanguage.define(objectiveCLanguage),
  "objectivec": StreamLanguage.define(objectiveCLanguage),
  "objcpp": StreamLanguage.define(objectiveCppLanguage),
  "objective-cpp": StreamLanguage.define(objectiveCppLanguage),
  "objectivecpp": StreamLanguage.define(objectiveCppLanguage),
  "objective-c++": StreamLanguage.define(objectiveCppLanguage),
  "objectivec++": StreamLanguage.define(objectiveCppLanguage),
  "dart": StreamLanguage.define(dartLanguage),
  "go": StreamLanguage.define(goLanguage),
  "golang": StreamLanguage.define(goLanguage),
  "diff": StreamLanguage.define(diffLanguage),
  "powershell": StreamLanguage.define(powerShellLanguage),
  "perl": StreamLanguage.define(perlLanguage),
  "ruby": StreamLanguage.define(rubyLanguage),
  "tcl": StreamLanguage.define(tclLanguage),
  "verilog": StreamLanguage.define(verilogLanguage),
  "vhdl": StreamLanguage.define(vhdlLanguage),
  "dockerfile": StreamLanguage.define(dockerfileLanguage),
  "cmake": StreamLanguage.define(cmakeLanguage),
  "erlang": StreamLanguage.define(erlangLanguage),
  "nix": nixLanguage,
  "space-lua": luaLanguage,
  "lua": luaLanguage,
};

export function languageFor(name: string): Language | null {
  if (builtinLanguages[name]) {
    return builtinLanguages[name];
  }
  if (name.startsWith("#")) {
    return StreamLanguage.define(yamlLanguage);
  }
  return null;
}


================================================
FILE: client/lib/box_proxy.test.ts
================================================
import { assertEquals } from "@std/assert";
import { BoxProxy } from "./box_proxy.ts";

Deno.test("Test LateBinder - basic get functionality", () => {
  const binder = new BoxProxy({
    returnNumber: () => 1,
  });
  const p = binder.buildProxy();
  assertEquals(p.returnNumber(), 1);
  // Now swap out target
  binder.setTarget({
    returnNumber: () => 3,
  });
  assertEquals(p.returnNumber(), 3);
});

Deno.test("Test LateBinder - property setting", () => {
  const target: any = { count: 0 };
  const proxy = new BoxProxy(target).buildProxy();

  proxy.count = 5;
  assertEquals(target.count, 5);
  assertEquals(proxy.count, 5);

  proxy.newProp = "test";
  assertEquals(target.newProp, "test");
  assertEquals(proxy.newProp, "test");
});

Deno.test("Test LateBinder - has operator", () => {
  const proxy = new BoxProxy({
    existing: true,
    nested: { prop: "value" },
  }).buildProxy();

  assertEquals("existing" in proxy, true);
  assertEquals("nested" in proxy, true);
  assertEquals("nonexistent" in proxy, false);
});

Deno.test("Test LateBinder - delete property", () => {
  const target: any = {
    prop1: "value1",
    prop2: "value2",
  };
  const proxy = new BoxProxy(target).buildProxy();

  assertEquals("prop1" in proxy, true);
  delete proxy.prop1;
  assertEquals("prop1" in proxy, false);
  assertEquals("prop1" in target, false);

  assertEquals("prop2" in proxy, true);
});

Deno.test("Test LateBinder - ownKeys", () => {
  const target = {
    prop1: "value1",
    prop2: "value2",
    method: () => "result",
  };
  const proxy = new BoxProxy(target).buildProxy();

  const keys = Object.keys(proxy);
  const expectedKeys = Object.keys(target);
  assertEquals(keys.sort(), expectedKeys.sort());
});

Deno.test("Test LateBinder - with functions and methods", () => {
  const target: any = {
    multiplier: 2,
    multiply: function (x: number) {
      return x * this.multiplier;
    },
    arrow: (x: number) => x * 3,
  };

  const proxy = new BoxProxy(target).buildProxy();

  assertEquals(proxy.multiply(5), 10);
  assertEquals(proxy.arrow(4), 12);
  assertEquals(proxy.multiplier, 2);
});

Deno.test("Test LateBinder - complex object interactions", () => {
  const target: any = {
    data: { nested: { value: 42 } },
    getValue: function () {
      return this.data.nested.value;
    },
  };

  const proxy = new BoxProxy(target).buildProxy();

  assertEquals(proxy.getValue(), 42);

  proxy.data.nested.value = 100;
  assertEquals(proxy.getValue(), 100);
  assertEquals(target.data.nested.value, 100);
});

Deno.test("Test LateBinder - target switching with different interfaces", () => {
  interface Calculator {
    add(a: number, b: number): number;
  }

  const calc1: Calculator = {
    add: (a, b) => a + b,
  };

  const calc2: Calculator = {
    add: (a, b) => a + b + 1, // adds extra 1
  };

  const binder = new BoxProxy(calc1);
  const proxy = binder.buildProxy();

  assertEquals(proxy.add(2, 3), 5);

  binder.setTarget(calc2);
  assertEquals(proxy.add(2, 3), 6);
});

Deno.test("Test LateBinder - empty target", () => {
  const proxy = new BoxProxy({}).buildProxy();

  assertEquals(Object.keys(proxy).length, 0);
  assertEquals("anything" in proxy, false);

  (proxy as any).newProp = "added";
  assertEquals((proxy as any).newProp, "added");
  assertEquals("newProp" in proxy, true);
});


================================================
FILE: client/lib/box_proxy.ts
================================================
/**
 * A proxy class that enables swapping out the target object later on
 */
export class BoxProxy {
  /**
   * Initializes a new instance of the BoxProxy class.
   * @param target The initial target object
   */
  constructor(private target: any) {
  }

  /**
   * Swaps out the target object :magic:
   */
  setTarget(target: any) {
    this.target = target;
  }

  /**
   * Builds a proxy object that forwards everything we care about to the target object
   */
  buildProxy() {
    return new Proxy(this, {
      get: (_obj: any, prop) => {
        return this.target[prop];
      },
      set: (_obj: any, prop, value) => {
        this.target[prop] = value;
        return true;
      },
      has: (_obj: any, prop) => {
        return prop in this.target;
      },
      deleteProperty: (_obj: any, prop) => {
        delete this.target[prop];
        return true;
      },
      ownKeys: (_obj: any) => {
        return Object.keys(this.target);
      },
      getOwnPropertyDescriptor: (_obj: any, prop) => {
        return Object.getOwnPropertyDescriptor(this.target, prop);
      },
    });
  }
}


================================================
FILE: client/lib/fuse_search.test.ts
================================================
import type { FilterOption } from "@silverbulletmd/silverbullet/type/client";
import { assertEquals } from "@std/assert";
import { fuzzySearchAndSort } from "./fuse_search.ts";

Deno.test("testFuzzyFilter", () => {
  const array: FilterOption[] = [
    { name: "My Company/Hank", orderId: 2 },
    { name: "My Company/Hane", orderId: 1 },
    { name: "My Company/Steve Co" },
    { name: "Other/Steve" },
    { name: "Steve" },
  ];

  // Prioritize match in last path part
  let results = fuzzySearchAndSort(array, "");
  assertEquals(results.length, array.length);
  results = fuzzySearchAndSort(array, "Steve");
  assertEquals(results.length, 3);
  results = fuzzySearchAndSort(array, "Co");
  // Match in last path part
  assertEquals(results[0].name, "My Company/Steve Co");
  // Due to orderId
  assertEquals(results[1].name, "My Company/Hane");
  assertEquals(results[2].name, "My Company/Hank");
});

Deno.test("Fuzzy search edge case testing", () => {
  // Test edge case where aspiring pages (Infinity orderId) sort last without NaN
  const array: FilterOption[] = [
    { name: "Existing", orderId: 1 },
    { name: "Aspiring A", orderId: Infinity },
    { name: "Aspiring B", orderId: Infinity },
  ];
  const results = fuzzySearchAndSort(array, "");
  assertEquals(results.length, 3);
  assertEquals(results[0].name, "Existing");
  // Both aspiring pages last; relative order between them is stable (no NaN)
  assertEquals(results[1].name, "Aspiring A");
  assertEquals(results[2].name, "Aspiring B");
});


================================================
FILE: client/lib/fuse_search.ts
================================================
import Fuse from "fuse";
import type { FilterOption } from "@silverbulletmd/silverbullet/type/client";
import { fileName } from "@silverbulletmd/silverbullet/lib/resolve";

type FuseOption = FilterOption & {
  baseName: string;
};

/**
 * Compare orderIds; aspiring pages (Infinity) sort last. Avoids NaN when both are Infinity.
 * @returns -1 if a is less than b, 1 if a is greater than b, 0 if they are equal
 */
function compareOrderId(a: number | undefined, b: number | undefined): number {
  const aOrder = a ?? 0;
  const bOrder = b ?? 0;
  if (aOrder === Infinity && bOrder === Infinity) return 0;
  if (aOrder === Infinity) return 1;
  if (bOrder === Infinity) return -1;
  return aOrder - bOrder;
}

export const fuzzySearchAndSort = (
  arr: FilterOption[],
  searchPhrase: string,
): FilterOption[] => {
  if (!searchPhrase) {
    return arr.sort((a, b) => compareOrderId(a.orderId, b.orderId));
  }

  const enrichedArr: FuseOption[] = arr.map((item) => {
    return {
      ...item,
      // Only relevant for pages and or documents and not commands
      baseName: fileName(item.name),
      displayName: item?.meta?.displayName,
      aliases: item?.meta?.aliases?.join(" "),
    };
  });

  const fuse = new Fuse(enrichedArr, {
    keys: [{
      name: "name",
      weight: 2,
    }, {
      name: "baseName",
      weight: 3,
    }, {
      name: "displayName",
      weight: 2,
    }, {
      name: "aliases",
      weight: 2,
    }, {
      name: "description",
      weight: 1,
    }],
    isCaseSensitive: false,
    ignoreDiacritics: true,
    shouldSort: true,
    threshold: 0.3,
    includeScore: true,
    sortFn: (a, b): number => {
      const aItem = enrichedArr[a.idx];
      const bItem = enrichedArr[b.idx];

      // If either orderId is Infinity (aspiring page), put it last (compareOrderId avoids NaN)
      if (aItem.orderId === Infinity || bItem.orderId === Infinity) {
        return compareOrderId(aItem.orderId, bItem.orderId);
      }

      // If scores are the same, use orderId for sorting
      if (a.score === b.score) {
        const aOrder = aItem.orderId ?? 0;
        const bOrder = bItem.orderId ?? 0;
        if (aOrder !== bOrder) {
          return aOrder - bOrder;
        }
      }
      return a.score - b.score;
    },
  });

  const results = fuse.search(searchPhrase);
  const enhancedResults = results.map((r) => ({
    ...r.item,
    fuseScore: r.score,
  }));
  return enhancedResults;
};


================================================
FILE: client/lib/logger.test.ts
================================================
import { assert, assertEquals } from "@std/assert";
import { initLogger, Logger } from "./logger.ts";

Deno.test("Logger prefix functionality", () => {
  // Store original console methods
  const originalLog = console.log;
  const originalInfo = console.info;
  const originalWarn = console.warn;
  const originalError = console.error;

  const capturedOutput: string[] = [];

  // Mock console methods to capture output
  console.log = (...args: any[]) => {
    capturedOutput.push(args.join(" "));
  };
  console.info = (...args: any[]) => {
    capturedOutput.push(args.join(" "));
  };
  console.warn = (...args: any[]) => {
    capturedOutput.push(args.join(" "));
  };
  console.error = (...args: any[]) => {
    capturedOutput.push(args.join(" "));
  };

  try {
    new Logger("[TEST]");

    console.log("Hello", "world");
    console.info("Info message");
    console.warn("Warning message");
    console.error("Error message");

    assertEquals(capturedOutput[0], "[TEST] Hello world");
    assertEquals(capturedOutput[1], "[TEST] Info message");
    assertEquals(capturedOutput[2], "[TEST] Warning message");
    assertEquals(capturedOutput[3], "[TEST] Error message");
  } finally {
    // Restore original console methods
    console.log = originalLog;
    console.info = originalInfo;
    console.warn = originalWarn;
    console.error = originalError;
  }
});

Deno.test("Logger without prefix", () => {
  const originalLog = console.log;
  const capturedOutput: string[] = [];

  console.log = (...args: any[]) => {
    capturedOutput.push(args.join(" "));
  };

  try {
    new Logger("");

    console.log("Hello", "world");

    assertEquals(capturedOutput[0], "Hello world");
  } finally {
    console.log = originalLog;
  }
});

Deno.test("Logger log capture", () => {
  const logger = new Logger("[CAPTURE]");

  console.log("First message");
  console.info("Second message", { key: "value" });
  console.warn("Third message", 123);
  console.error("Fourth message");

  const capturedLogs = logger.logBuffer;

  assertEquals(capturedLogs.length, 4);

  assertEquals(capturedLogs[0].level, "log");
  assertEquals(capturedLogs[0].message, "First message");

  assertEquals(capturedLogs[1].level, "info");
  assertEquals(capturedLogs[1].message, 'Second message {"key":"value"}');

  assertEquals(capturedLogs[2].level, "warn");
  assertEquals(capturedLogs[2].message, "Third message 123");

  assertEquals(capturedLogs[3].level, "error");
  assertEquals(capturedLogs[3].message, "Fourth message");

  // Check that all entries have timestamps
  capturedLogs.forEach((entry) => {
    assert(typeof entry.timestamp === "number");
    assert(entry.timestamp > 0);
  });
});

Deno.test("Logger max capture size", () => {
  const logger = new Logger("[SIZE]", 3);

  // Add more logs than the max size
  console.log("Message 1");
  console.log("Message 2");
  console.log("Message 3");
  console.log("Message 4");
  console.log("Message 5");

  const capturedLogs = logger.logBuffer;

  // Should only keep the last 3 messages
  assertEquals(capturedLogs.length, 3);
  assertEquals(capturedLogs[0].message, "Message 3");
  assertEquals(capturedLogs[1].message, "Message 4");
  assertEquals(capturedLogs[2].message, "Message 5");
});

Deno.test("Global logger initialization", () => {
  const originalLog = console.log;
  const capturedOutput: string[] = [];

  console.log = (...args: any[]) => {
    capturedOutput.push(args.join(" "));
  };

  try {
    const logger = initLogger("[GLOBAL]");

    console.log("Global test");

    assertEquals(capturedOutput[0], "[GLOBAL] Global test");
    assert(logger instanceof Logger);
  } finally {
    console.log = originalLog;
  }
});

Deno.test("Logger handles complex objects", () => {
  const logger = new Logger("[COMPLEX]");

  const complexObject = {
    name: "test",
    nested: { value: 42 },
    array: [1, 2, 3],
  };

  const circularObject: any = { name: "circular" };
  circularObject.self = circularObject;

  console.log("Complex object:", complexObject);
  console.log("Circular object:", circularObject);

  const capturedLogs = logger.logBuffer;

  assertEquals(capturedLogs.length, 2);

  // First log should handle complex object properly
  assertEquals(
    capturedLogs[0].message,
    `Complex object: {"name":"test","nested":{"value":42},"array":[1,2,3]}`,
  );

  // Second log should handle circular reference gracefully
  assertEquals(capturedLogs[1].message, `Circular object: [object Object]`);
});


================================================
FILE: client/lib/logger.ts
================================================
// Logger that monkey patches console methods with prefixes and can capture logs for server transmission

export interface LogEntry {
  level: "log" | "info" | "warn" | "error" | "debug";
  timestamp: number;
  message: string;
}

export class Logger {
  private originalConsole: {
    log: typeof console.log;
    info: typeof console.info;
    warn: typeof console.warn;
    error: typeof console.error;
    debug: typeof console.debug;
  };
  public logBuffer: LogEntry[] = [];

  constructor(
    private prefix: string = "",
    private maxCaptureSize: number = 1000,
  ) {
    this.prefix = prefix;

    // Store original console methods
    this.originalConsole = {
      log: console.log.bind(console),
      info: console.info.bind(console),
      warn: console.warn.bind(console),
      error: console.error.bind(console),
      debug: console.debug.bind(console),
    };

    this.patchConsole();
  }

  private patchConsole(): void {
    const createPatchedMethod = (level: keyof typeof this.originalConsole) => {
      return (...args: any[]) => {
        const prefixedArgs = this.prefix ? [this.prefix, ...args] : args;

        // Call original console method
        this.originalConsole[level](...prefixedArgs);

        // Capture log if capturing is enabled
        this.captureLog(level, args);
      };
    };

    console.log = createPatchedMethod("log");
    console.info = createPatchedMethod("info");
    console.warn = createPatchedMethod("warn");
    console.error = createPatchedMethod("error");
    console.debug = createPatchedMethod("debug");
  }

  private captureLog(level: LogEntry["level"], args: any[]): void {
    const entry: LogEntry = {
      level,
      timestamp: Date.now(),
      message: args.map((arg) => {
        if (typeof arg === "string") {
          return arg;
        }
        try {
          return JSON.stringify(arg);
        } catch {
          // Handle circular references or other JSON.stringify failures
          return String(arg);
        }
      }).join(" "),
    };

    this.logBuffer.push(entry);

    // Maintain max capture size by removing oldest entries
    if (this.logBuffer.length > this.maxCaptureSize) {
      this.logBuffer.shift();
    }
  }

  /**
   * Posts all buffered logs to a server endpoint
   */
  async postToServer(logEndpoint: string, source: string) {
    const logs = this.logBuffer;
    if (logs.length > 0) {
      // Flush the buffer
      const logCopy = [...this.logBuffer];
      this.logBuffer = [];
      try {
        const resp = await fetch(logEndpoint, {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
          },
          body: JSON.stringify(logCopy.map((entry) => ({ ...entry, source }))),
        });
        if (!resp.ok) {
          throw new Error("Failed to post logs to server");
        }
      } catch (e: any) {
        console.warn("Could not post logs to server", e.message);
        // Put back the logs into the buffer
        this.logBuffer.unshift(...logCopy);
      }
    }
  }
}

// Global logger instance
let globalLogger: Logger | undefined = undefined;

export function initLogger(prefix: string = ""): Logger {
  globalLogger = new Logger(prefix);
  return globalLogger;
}


================================================
FILE: client/lib/polyfills.ts
================================================
// Source: https://github.com/ungap/with-resolvers/blob/main/index.js
// @ts-ignore This is fine
Promise.withResolvers || (Promise.withResolvers = function withResolvers() {
  let a, b;
  const c = new this(function (resolve, reject) {
    a = resolve;
    b = reject;
  });
  return { resolve: a, reject: b, promise: c };
});


================================================
FILE: client/lib/url_prefix.test.ts
================================================
import { assertEquals } from "@std/assert";
import { applyUrlPrefix, removeUrlPrefix } from "./url_prefix.ts";

Deno.test("url_prefix - removeUrlPrefix - with value", async (t) => {
  await t.step("Absolute URL, present, should be removed", () => {
    assertEquals(
      removeUrlPrefix("http://myserver/sb/relevant", "/sb"),
      "http://myserver/relevant",
    );
    assertEquals(
      removeUrlPrefix("https://myserver/sb/relevant", "/sb"),
      "https://myserver/relevant",
    );
  });

  await t.step("Absolute URL, present, should only remove leading", () => {
    assertEquals(
      removeUrlPrefix("http://myserver/sb/sb/relevant/sb", "/sb"),
      "http://myserver/sb/relevant/sb",
    );
    assertEquals(
      removeUrlPrefix("http://myserver/relevant/sb", "/sb"),
      "http://myserver/relevant/sb",
    );
  });

  await t.step("Absolute URL, absent, should be untouched", () => {
    assertEquals(
      removeUrlPrefix("http://myserver/other/relevant", "/sb"),
      "http://myserver/other/relevant",
    );
    assertEquals(
      removeUrlPrefix("https://myserver/other/relevant", "/sb"),
      "https://myserver/other/relevant",
    );
  });

  await t.step("Absolute URL, queryString, should be preserved", () => {
    assertEquals(
      removeUrlPrefix("http://myserver/sb/sb/relevant/sb?param=arg", "/sb"),
      "http://myserver/sb/relevant/sb?param=arg",
    );
  });

  await t.step("Absolute URL, unsupported, should be untouched", () => {
    assertEquals(
      removeUrlPrefix("ftp://myserver/sb/relevant", "/sb"),
      "ftp://myserver/sb/relevant",
    );
  });

  await t.step("Host-Relative URL, present, should be removed", () => {
    assertEquals(removeUrlPrefix("/sb/relevant", "/sb"), "/relevant");
  });

  await t.step("Host-Relative URL, present, should only remove leading", () => {
    assertEquals(
      removeUrlPrefix("/sb/sb/relevant/sb", "/sb"),
      "/sb/relevant/sb",
    );
    assertEquals(removeUrlPrefix("/relevant/sb", "/sb"), "/relevant/sb");
  });

  await t.step("Host-Relative URL, queryString, should be preserved", () => {
    assertEquals(
      removeUrlPrefix("/sb/sb/relevant/sb?param=arg", "/sb"),
      "/sb/relevant/sb?param=arg",
    );
    assertEquals(
      removeUrlPrefix("/relevant/sb?param=arg", "/sb"),
      "/relevant/sb?param=arg",
    );
  });

  await t.step("Host-Relative URL, absent, should be untouched", () => {
    assertEquals(removeUrlPrefix("/other/relevant", "/sb"), "/other/relevant");
  });

  await t.step("Page-Relative URL, should be untouched", () => {
    assertEquals(removeUrlPrefix("sb/relevant", "/sb"), "sb/relevant");
  });
});

Deno.test("url_prefix - removeUrlPrefix - no value", async (t) => {
  await t.step("Absolute URL, should be untouched", () => {
    assertEquals(
      removeUrlPrefix("http://myserver/sb/relevant", ""),
      "http://myserver/sb/relevant",
    );
    assertEquals(
      removeUrlPrefix("https://myserver/sb/relevant"),
      "https://myserver/sb/relevant",
    );
  });

  await t.step("Host-Relative URL, should be untouched", () => {
    assertEquals(removeUrlPrefix("/sb/relevant", ""), "/sb/relevant");
    assertEquals(removeUrlPrefix("/sb/relevant"), "/sb/relevant");
  });

  await t.step("Page-Relative URL, should be untouched", () => {
    assertEquals(removeUrlPrefix("sb/relevant", ""), "sb/relevant");
    assertEquals(removeUrlPrefix("sb/relevant"), "sb/relevant");
  });
});

Deno.test("url_prefix - applyUrlPrefix - with value", async (t) => {
  await t.step("string, Absolute URL, should be prefixed", () => {
    assertEquals(
      applyUrlPrefix("http://myserver/relevant", "/sb"),
      "http://myserver/sb/relevant",
    );
    assertEquals(
      applyUrlPrefix("https://myserver/relevant", "/sb"),
      "https://myserver/sb/relevant",
    );
  });

  await t.step("string, Absolute URL, should not care about dups", () => {
    assertEquals(
      applyUrlPrefix("http://myserver/sb/relevant/sb", "/sb"),
      "http://myserver/sb/sb/relevant/sb",
    );
  });

  await t.step("string, Absolute URL, queryString should be preserved", () => {
    assertEquals(
      applyUrlPrefix("http://myserver/sb/relevant/sb?param=arg", "/sb"),
      "http://myserver/sb/sb/relevant/sb?param=arg",
    );
  });

  await t.step("string, Absolute URL, unsupported, should be untouched", () => {
    assertEquals(
      applyUrlPrefix("ftp://myserver/relevant", "/sb"),
      "ftp://myserver/relevant",
    );
  });

  await t.step("string, Host-Relative URL, should be prefixed", () => {
    assertEquals(applyUrlPrefix("/relevant", "/sb"), "/sb/relevant");
  });

  await t.step("string, Host-Relative URL, should not care about dups", () => {
    assertEquals(
      applyUrlPrefix("/sb/relevant/sb", "/sb"),
      "/sb/sb/relevant/sb",
    );
  });

  await t.step(
    "string, Host-Relative URL, queryString should be preserved",
    () => {
      assertEquals(
        applyUrlPrefix("/sb/relevant/sb?param=arg", "/sb"),
        "/sb/sb/relevant/sb?param=arg",
      );
    },
  );

  await t.step("string, Page-Relative URL, should be untouched", () => {
    assertEquals(applyUrlPrefix("relevant", "/sb"), "relevant");
  });

  await t.step("URL object, Absolute URL, should be prefixed", () => {
    assertEquals(
      applyUrlPrefix(new URL("http://myserver/relevant"), "/sb"),
      new URL("http://myserver/sb/relevant"),
    );
  });

  await t.step(
    "URL object, Absolute URL, queryString should be preserved",
    () => {
      assertEquals(
        applyUrlPrefix(new URL("http://myserver/relevant?param=arg"), "/sb"),
        new URL("http://myserver/sb/relevant?param=arg"),
      );
    },
  );
});

Deno.test("url_prefix - applyUrlPrefix - no value", async (t) => {
  await t.step("Absolute URL, should be untouched", () => {
    assertEquals(
      applyUrlPrefix("http://myserver/relevant", ""),
      "http://myserver/relevant",
    );
    assertEquals(
      applyUrlPrefix("https://myserver/relevant"),
      "https://myserver/relevant",
    );
  });

  await t.step("Host-Relative URL, should be untouched", () => {
    assertEquals(applyUrlPrefix("/relevant", ""), "/relevant");
    assertEquals(applyUrlPrefix("/relevant"), "/relevant");
  });

  await t.step("Page-Relative URL, should be untouched", () => {
    assertEquals(applyUrlPrefix("relevant", ""), "relevant");
    assertEquals(applyUrlPrefix("relevant"), "relevant");
  });
});


================================================
FILE: client/lib/url_prefix.ts
================================================
export function removeUrlPrefix(url: string, prefix?: string): string {
  if (!prefix || prefix === "") return url;

  if (url.startsWith("http://") || url.startsWith("https://")) {
    const parsedUrl = new URL(url);
    if (parsedUrl.pathname.startsWith(prefix)) {
      parsedUrl.pathname = parsedUrl.pathname.substring(
        prefix.length,
      );
      return parsedUrl.href;
    } else {
      return url;
    }
  } else if (url.startsWith(prefix)) {
    return url.substring(prefix.length);
  } else {
    return url;
  }
}

export function applyUrlPrefix<T extends (string | URL)>(
  url: T,
  prefix?: string,
): T {
  if (!prefix || prefix === "") return url;

  if (typeof url === "string") {
    const urlString = url as string;

    if (urlString.startsWith("http://") || urlString.startsWith("https://")) {
      return applyUrlPrefix(new URL(urlString), prefix).href as T;
    } else if (urlString.startsWith("/")) {
      return (prefix + urlString) as T;
    } else {
      return url; //return page-relative paths as-is
    }
  } else if (url.protocol === "http:" || url.protocol === "https:") {
    const urlObj = new URL(url);
    urlObj.pathname = prefix + urlObj.pathname;
    return urlObj as T;
  } else {
    return url;
  }
}


================================================
FILE: client/lib/util.ts
================================================
import type { FileMeta } from "@silverbulletmd/silverbullet/type/index";

export function utcDateString(mtime: number): string {
  return new Date(mtime).toUTCString();
}

export function authCookieName(host: string) {
  return `auth_${host.replaceAll(/\W/g, "_")}`;
}

export function fileMetaToHeaders(fileMeta: FileMeta) {
  return {
    "Content-Type": fileMeta.contentType,
    "X-Last-Modified": "" + fileMeta.lastModified,
    "X-Created": "" + fileMeta.created,
    "Cache-Control": "no-cache",
    "X-Permission": fileMeta.perm,
    "X-Content-Length": "" + fileMeta.size,
  };
}

export function headersToFileMeta(
  name: string,
  headers: Headers,
): FileMeta | undefined {
  if (headers.has("X-Last-Modified")) {
    // If this header is set, we need to pull out the rest also
    return {
      name,
      // The server may set a custom X-Content-Length header in case a GET request was sent with X-Get-Meta, in which case the body may be omitted
      size: headers.has("X-Content-Length")
        ? +headers.get("X-Content-Length")!
        : +headers.get("Content-Length")!,
      contentType: headers.get("Content-type")!,
      created: +(headers.get("X-Created") || "0"),
      lastModified: +(headers.get("X-Last-Modified") || "0"),
      perm: (headers.get("X-Permission") as "rw" | "ro") || "ro",
    };
  } else {
    // Otherwise: no file meta in headers
    return undefined;
  }
}


================================================
FILE: client/markdown_parser/constants.ts
================================================
export const wikiLinkRegex =
  /(?<leadingTrivia>!?\[\[)(?<stringRef>.*?)(?:\|(?<alias>.*?))?(?<trailingTrivia>\]\])/g;
export const mdLinkRegex = /!?\[(?<title>[^\]]*)\]\((?<url>.+)\)/g;
export const tagRegex =
  /#(?:(?:\d*[^\d\s!@#$%^&*(),.?":{}|<>\\][^\s!@#$%^&*(),.?":{}|<>\\]*)|(?:<[^>\n]+>))/;
export const nakedUrlRegex =
  /(^https?:\/\/([-a-zA-Z0-9@:%_\+~#=]|(?:[.](?!(\s|$)))){1,256})(([-a-zA-Z0-9(@:%_\+~#?&=\/]|(?:[.,:;)](?!(\s|$))))*)/;
export const frontmatterQuotesRegex = /["'].*["']/g;
export const frontmatterUrlRegex = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/[^\s"']+)/g;
export const frontmatterWikiLinkRegex =
  /(?<leadingTrivia>!?\[\[)(?<stringRef>.*?)(?:\|(?<alias>.*?))?(?<trailingTrivia>\]\])/g;
export const frontmatterMailtoRegex = /(mailto:[^@\s]+@[^@\s"']+)/ig;
export const pWikiLinkRegex = new RegExp("^" + wikiLinkRegex.source); // Modified regex used only in parser


================================================
FILE: client/markdown_parser/customtags.ts
================================================
import { Tag } from "@lezer/highlight";

export const WikiLinkTag = Tag.define();
export const WikiLinkPartTag = Tag.define();
export const CodeInfoTag = Tag.define();
export const CommentTag = Tag.define();
export const CommentMarkerTag = Tag.define();
export const BulletList = Tag.define();
export const OrderedList = Tag.define();
export const Highlight = Tag.define();
export const HorizontalRuleTag = Tag.define();

export const AttributeTag = Tag.define();
export const AttributeNameTag = Tag.define();
export const AttributeValueTag = Tag.define();

export const NamedAnchorTag = Tag.define();

export const TaskTag = Tag.define();
export const TaskMarkTag = Tag.define();
export const TaskStateTag = Tag.define();
export const TaskDeadlineTag = Tag.define();

export const HashtagTag = Tag.define();
export const NakedURLTag = Tag.define();

export const DirectiveMarkTag = Tag.define();
export const DirectiveTag = Tag.define();

export const SubscriptTag = Tag.define();
export const SuperscriptTag = Tag.define();


================================================
FILE: client/markdown_parser/extended_task.ts
================================================
import type {
  BlockContext,
  LeafBlock,
  LeafBlockParser,
  MarkdownConfig,
} from "@lezer/markdown";

import { tags as t } from "@lezer/highlight";
import { TaskStateTag } from "./customtags.ts";

// Taken from https://github.com/lezer-parser/markdown/blob/main/src/extension.ts and adapted

class MultiStatusTaskParser implements LeafBlockParser {
  constructor(private status: string) {
  }

  nextLine() {
    return false;
  }

  finish(cx: BlockContext, leaf: LeafBlock) {
    cx.addLeafElement(
      leaf,
      cx.elt("Task", leaf.start, leaf.start + leaf.content.length, [
        cx.elt("TaskState", leaf.start, leaf.start + 2 + this.status.length, [
          cx.elt("TaskMark", leaf.start, leaf.start + 1),
          cx.elt(
            "TaskMark",
            leaf.start + 1 + this.status.length,
            leaf.start + 2 + this.status.length,
          ),
        ]),
        ...cx.parser.parseInline(
          leaf.content.slice(this.status.length + 2),
          leaf.start + this.status.length + 2,
        ),
      ]),
    );
    return true;
  }
}

export const TaskList: MarkdownConfig = {
  defineNodes: [
    { name: "Task", block: true, style: t.list },
    { name: "TaskMark", style: t.atom },
    { name: "TaskState", style: TaskStateTag },
  ],
  parseBlock: [{
    name: "TaskList",
    leaf(cx, leaf) {
      // Note: task states cannot contain : to avoid ambiguity with attribute syntax
      const match = /^\[([^\]:]+)\][ \t]/.exec(leaf.content);
      return match &&
          cx.parentType().name == "ListItem"
        ? new MultiStatusTaskParser(match[1])
        : null;
    },
    after: "SetextHeading",
  }],
};


================================================
FILE: client/markdown_parser/parse_tree.ts
================================================
import type { ParseTree } from "../../plug-api/lib/tree.ts";
import type { SyntaxNode } from "@lezer/common";
import type { Language } from "@codemirror/language";

export function lezerToParseTree(
  text: string,
  n: SyntaxNode,
  offset = 0,
): ParseTree {
  let children: ParseTree[] = [];
  let nodeText: string | undefined;
  let child = n.firstChild;
  while (child) {
    children.push(lezerToParseTree(text, child, offset));
    child = child.nextSibling;
  }

  if (children.length === 0) {
    children = [
      {
        from: n.from + offset,
        to: n.to + offset,
        text: text.substring(n.from, n.to),
      },
    ];
  } else {
    const newChildren: ParseTree[] = [];
    let startIndex = n.from + offset;
    for (const child of children) {
      // indices are shifted with offset, so first need to shift back to get text
      const s = text.substring(startIndex - offset, child.from! - offset);
      if (s) {
        newChildren.push({
          from: startIndex,
          to: child.from!,
          text: s,
        });
      }
      newChildren.push(child);
      startIndex = child.to!;
    }
    const s = text.substring(startIndex - offset, n.to);
    if (s) {
      newChildren.push({
        from: startIndex,
        to: n.to + offset,
        text: s,
      });
    }
    children = newChildren;
  }

  const result: ParseTree = {
    type: n.name,
    from: n.from + offset,
    to: n.to + offset,
  };
  if (children.length > 0) {
    result.children = children;
  }
  if (nodeText) {
    result.text = nodeText;
  }
  return result;
}

export function parse(
  language: Language,
  text: string,
  offset?: number,
): ParseTree {
  // Remove \r for Windows before parsing
  text = text.replaceAll("\r", "");
  const tree = lezerToParseTree(
    text,
    language.parser.parse(text).topNode,
    offset,
  );
  return tree;
}


================================================
FILE: client/markdown_parser/parser.test.ts
================================================
import {
  collectNodesOfType,
  findNodeOfType,
  renderToText,
} from "@silverbulletmd/silverbullet/lib/tree";
import { assertEquals, assertNotEquals } from "@std/assert";
import { parseMarkdown } from "./parser.ts";
import { extractHashtag } from "../../plug-api/lib/tags.ts";
import { renderHashtag } from "../../plugs/index/tags.ts";

const sample1 = `---
type: page
tags:
- hello
- world
---
# This is a doc

Here is a [[wiki link]] and a [[wiki link|alias]].

Supper`;

const sampleInvalid1 = `---
name: Zef
# This is a doc

Supper`;

Deno.test("Test parser", () => {
  let tree = parseMarkdown(sample1);
  // console.log("tree", JSON.stringify(tree, null, 2));
  // Check if rendering back to text works
  assertEquals(renderToText(tree), sample1);

  const tree2 = parseMarkdown(sample1, 3);
  // console.log("tree", JSON.stringify(tree, null, 2));
  // Check if rendering back to text works
  assertEquals(renderToText(tree2), sample1);

  // Find wiki link and wiki link alias
  const links = collectNodesOfType(tree, "WikiLink");
  assertEquals(links.length, 2);
  const nameNode = findNodeOfType(links[0], "WikiLinkPage");
  assertEquals(nameNode!.children![0].text, "wiki link");

  // Check if alias is parsed properly
  const aliasNode = findNodeOfType(links[1], "WikiLinkAlias");
  assertEquals(aliasNode!.children![0].text, "alias");

  // Find frontmatter
  let node = findNodeOfType(tree, "FrontMatter");
  assertNotEquals(node, undefined);
  tree = parseMarkdown(sampleInvalid1);
  node = findNodeOfType(tree, "FrontMatter");
  // console.log("Invalid node", node);
  assertEquals(node, undefined);
});

const inlineAttributeSample = `
Hello there [a link](http://zef.plus)
[age: 100]
[age:: 200]

Here's a more [ambiguous: case](http://zef.plus)

And one with nested brackets: [array: [1, 2, 3]]
`;

Deno.test("Test inline attribute syntax", () => {
  const tree = parseMarkdown(inlineAttributeSample);
  // console.log("Attribute parsed", JSON.stringify(tree, null, 2));
  const attributes = collectNodesOfType(tree, "Attribute");
  let nameNode = findNodeOfType(attributes[0], "AttributeName");
  assertEquals(nameNode!.children![0].text, "age");
  let valueNode = findNodeOfType(attributes[0], "AttributeValue");
  assertEquals(valueNode!.children![0].text, "100");

  nameNode = findNodeOfType(attributes[1], "AttributeName");
  assertEquals(nameNode!.children![0].text, "age");
  valueNode = findNodeOfType(attributes[1], "AttributeValue");
  assertEquals(valueNode!.children![0].text, "200");

  nameNode = findNodeOfType(attributes[2], "AttributeName");
  assertEquals(nameNode!.children![0].text, "array");
  valueNode = findNodeOfType(attributes[2], "AttributeValue");
  assertEquals(valueNode!.children![0].text, "[1, 2, 3]");
});

const multiStatusTaskExample = `
* [ ] Task 1
- [x] Task 2
* [TODO] Task 3
`;

Deno.test("Test multi-status tasks", () => {
  const tree = parseMarkdown(multiStatusTaskExample);
  // console.log("Tasks parsed", JSON.stringify(tree, null, 2));
  const tasks = collectNodesOfType(tree, "Task");
  assertEquals(tasks.length, 3);
  // Check " " checkbox state parsing
  assertEquals(tasks[0].children![0].children![1].text, " ");
  assertEquals(tasks[1].children![0].children![1].text, "x");
  assertEquals(tasks[2].children![0].children![1].text, "TODO");
});

Deno.test("Test lua directive parser", () => {
  const simpleExample = `Simple \${query_coll("something")}`;
  console.log(JSON.stringify(parseMarkdown(simpleExample), null, 2));
});

const hashtagSample = `
Hashtags, e.g. #mytag but ignore in code \`#mytag\`.
They can contain slashes like #level/beginner, single quotes, and dashes: #Mike's-idea.
Can be just #a single letter.
But no other #interpunction: #exclamation! #question?
There is a way to write #<tag with spaces>
These cannot span #<multiple
lines>
#no#spacing also works.
Hashtags can start with number if there's something after it: #3dprint #15-52_Trip-to-NYC.
But magazine issue #1 or #123 are not hashtags.
Should support other languages, like #żółć or #井号
`;

Deno.test("Test hashtag parser", () => {
  const tree = parseMarkdown(hashtagSample);
  const hashtags = collectNodesOfType(tree, "Hashtag");
  assertEquals(hashtags.length, 14);

  assertEquals(hashtags[0].children![0].text, "#mytag");
  assertEquals(hashtags[1].children![0].text, "#level/beginner");
  assertEquals(hashtags[2].children![0].text, "#Mike's-idea");
  assertEquals(hashtags[3].children![0].text, "#a");
  assertEquals(hashtags[4].children![0].text, "#interpunction");
  assertEquals(hashtags[5].children![0].text, "#exclamation");
  assertEquals(hashtags[6].children![0].text, "#question");
  assertEquals(hashtags[7].children![0].text, "#<tag with spaces>");
  // multiple lines not allowed
  assertEquals(hashtags[8].children![0].text, "#no");
  assertEquals(hashtags[9].children![0].text, "#spacing");
  assertEquals(hashtags[10].children![0].text, "#3dprint");
  assertEquals(hashtags[11].children![0].text, "#15-52_Trip-to-NYC");
  assertEquals(hashtags[12].children![0].text, "#żółć");
  assertEquals(hashtags[13].children![0].text, "#井号");
});

Deno.test("Test hashtag helper functions", () => {
  assertEquals(extractHashtag("#name"), "name");
  assertEquals(extractHashtag("#123-content"), "123-content");
  assertEquals(extractHashtag("#<escaped tag>"), "escaped tag");
  assertEquals(
    extractHashtag("#<allow < and # inside>"),
    "allow < and # inside",
  );

  assertEquals(renderHashtag("simple"), "#simple");
  assertEquals(renderHashtag("123-content"), "#123-content");
  assertEquals(renderHashtag("with spaces"), "#<with spaces>");
  assertEquals(renderHashtag("single'quote"), "#single'quote");
  // should behave like this for all characters in tagRegex
  assertEquals(renderHashtag("exclamation!"), "#<exclamation!>");
});

const nakedURLSample = `
http://abc.com is a URL
Also http://no-trailing-period.com. That's a URL. It ends with m, not '.'.
http://no-trailing-comma.com, that same a URL, ends with m (and not ',').
http://trailing-slash.com/. That ends with '/' (still not '.').
http://abc.com?e=2.71,pi=3.14 is a URL too.
http://abc.com?e=2.71. That is a URL, which ends with 1 (and not '.').
`;

Deno.test("Test NakedURL parser", () => {
  const tree = parseMarkdown(nakedURLSample);
  const urls = collectNodesOfType(tree, "NakedURL");

  assertEquals(urls.map((x) => x.children![0].text), [
    "http://abc.com",
    "http://no-trailing-period.com",
    "http://no-trailing-comma.com",
    "http://trailing-slash.com/",
    "http://abc.com?e=2.71,pi=3.14",
    "http://abc.com?e=2.71",
  ]);
});

const tableSample = `
| Header A | Header B |
|----------|----------|
| [[Wiki|Alias]] | 1B |
| 2A             | 2B |
`;

Deno.test("Test table parser", () => {
  const tree = parseMarkdown(tableSample);
  const cells = collectNodesOfType(tree, "TableCell");

  assertEquals(cells.map((x) => x.children![0].text), [
    "Header A",
    "Header B",
    undefined,
    "1B",
    "2A",
    "2B",
  ]);

  // Check the Wiki Link - Make sure no backslash has been added (issue 943)
  assertEquals(cells[2].children![0].type, "WikiLink");
  const wikiName = findNodeOfType(cells[2], "WikiLinkPage");
  const wikiAlias = findNodeOfType(cells[2], "WikiLinkAlias");
  assertEquals(wikiName!.children![0].text, "Wiki");
  assertEquals(wikiAlias!.children![0].text, "Alias");
});


================================================
FILE: client/markdown_parser/parser.ts
================================================
import { yaml as yamlLanguage } from "@codemirror/legacy-modes/mode/yaml";
import { styleTags, tags as t } from "@lezer/highlight";
import {
  type Line,
  type MarkdownConfig,
  Strikethrough,
  Subscript,
  Superscript,
} from "@lezer/markdown";
import { markdown } from "@codemirror/lang-markdown";
import { foldNodeProp, StreamLanguage } from "@codemirror/language";
import * as ct from "./customtags.ts";
import { TaskList } from "./extended_task.ts";
import { Table } from "./table_parser.ts";
import { nakedUrlRegex, pWikiLinkRegex, tagRegex } from "./constants.ts";
import { parse } from "./parse_tree.ts";
import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree";
import { luaLanguage } from "../space_lua/parse.ts";

const WikiLink: MarkdownConfig = {
  defineNodes: [
    { name: "WikiLink" },
    { name: "WikiLinkPage", style: ct.WikiLinkPartTag },
    { name: "WikiLinkAlias", style: ct.WikiLinkPartTag },
    { name: "WikiLinkDimensions", style: ct.WikiLinkPartTag },
    { name: "WikiLinkMark", style: t.processingInstruction },
  ],
  parseInline: [
    {
      name: "WikiLink",
      parse(cx, next, pos) {
        // Do a preliminary check for performance
        if (next != 91 /* '[' */ && next != 33 /* '!' */) {
          return -1;
        }

        pWikiLinkRegex.lastIndex = 0;
        const match = pWikiLinkRegex.exec(cx.slice(pos, cx.end));
        if (!match || !match.groups) {
          return -1;
        }

        //const [fullMatch, firstMark, page, alias, _lastMark] = match;
        const { leadingTrivia, stringRef, alias } = match.groups;
        const endPos = pos + match[0].length;
        let aliasElts: any[] = [];
        if (alias) {
          const pipeStartPos = pos + leadingTrivia.length + stringRef.length;
          aliasElts = [
            cx.elt("WikiLinkMark", pipeStartPos, pipeStartPos + 1),
            cx.elt(
              "WikiLinkAlias",
              pipeStartPos + 1,
              pipeStartPos + 1 + alias.length,
            ),
          ];
        }

        let allElts = cx.elt("WikiLink", pos, endPos, [
          cx.elt("WikiLinkMark", pos, pos + leadingTrivia.length),
          cx.elt(
            "WikiLinkPage",
            pos + leadingTrivia.length,
            pos + leadingTrivia.length + stringRef.length,
          ),
          ...aliasElts,
          cx.elt("WikiLinkMark", endPos - 2, endPos),
        ]);

        // If inline image
        if (next == 33) {
          allElts = cx.elt("Image", pos, endPos, [allElts]);
        }

        return cx.addElement(allElts);
      },
      after: "Emphasis",
    },
  ],
};

const LuaDirectives: MarkdownConfig = {
  defineNodes: [
    { name: "LuaDirective" },
    { name: "LuaExpressionDirective" },
    { name: "LuaDirectiveMark", style: ct.DirectiveMarkTag },
  ],
  parseInline: [
    {
      name: "LuaDirective",
      parse(cx, next, pos) {
        const textFromPos = cx.slice(pos, cx.end);
        if (
          next !== 36 /* '$' */ ||
          cx.slice(pos, pos + 2) !== "${"
        ) {
          return -1;
        }

        let bracketNestingDepth = 0;
        let valueLength = 0;
        // We need to ensure balanced { and } pairs
        loopLabel:
        for (; valueLength < textFromPos.length; valueLength++) {
          switch (textFromPos[valueLength]) {
            case "{":
              bracketNestingDepth++;
              break;
            case "}":
              bracketNestingDepth--;
              if (bracketNestingDepth === 0) {
                // Done!
                break loopLabel;
              }
              break;
          }
        }
        if (bracketNestingDepth !== 0) {
          return -1;
        }

        const bodyText = textFromPos.slice(2, valueLength);
        const endPos = pos + valueLength + 1;

        // Let's parse as an expression
        const parsedExpression = luaLanguage.parser.parse(`_(${bodyText})`);

        // If bodyText starts with whitespace, we need to offset this later
        const whiteSpaceOffset = bodyText.match(/^\s*/)?.[0].length ?? 0;

        const node = parsedExpression.resolveInner(2, 0).firstChild?.nextSibling
          ?.nextSibling;

        if (!node) {
          return -1;
        }
        const bodyEl = cx.elt(
          "LuaExpressionDirective",
          pos + 2,
          endPos - 1,
          [cx.elt(node.toTree()!, pos + 2 + whiteSpaceOffset)],
        );

        return cx.addElement(
          cx.elt("LuaDirective", pos, endPos, [
            cx.elt("LuaDirectiveMark", pos, pos + 2),
            bodyEl,
            cx.elt("LuaDirectiveMark", endPos - 1, endPos),
          ]),
        );
      },
      after: "Emphasis",
    },
  ],
};

const HighlightDelim = { resolve: "Highlight", mark: "HighlightMark" };

export const Highlight: MarkdownConfig = {
  defineNodes: [
    {
      name: "Highlight",
      style: { "Highlight/...": ct.Highlight },
    },
    {
      name: "HighlightMark",
      style: t.processingInstruction,
    },
  ],
  parseInline: [
    {
      name: "Highlight",
      parse(cx, next, pos) {
        if (next != 61 /* '=' */ || cx.char(pos + 1) != 61) return -1;
        return cx.addDelimiter(HighlightDelim, pos, pos + 2, true, true);
      },
      after: "Emphasis",
    },
  ],
};

export const attributeStartRegex = /^\[([\w\$]+)(::?\s*)/;

export const Attribute: MarkdownConfig = {
  defineNodes: [
    { name: "Attribute", style: { "Attribute/...": ct.AttributeTag } },
    { name: "AttributeName", style: ct.AttributeNameTag },
    { name: "AttributeValue", style: ct.AttributeValueTag },
    { name: "AttributeMark", style: t.processingInstruction },
    { name: "AttributeColon", style: t.processingInstruction },
  ],
  parseInline: [
    {
      name: "Attribute",
      parse(cx, next, pos) {
        let match: RegExpMatchArray | null;
        const textFromPos = cx.slice(pos, cx.end);
        if (
          next != 91 /* '[' */ ||
          // and match the whole thing
          !(match = attributeStartRegex.exec(textFromPos))
        ) {
          return -1;
        }
        const [fullMatch, attributeName, attributeColon] = match;
        let bracketNestingDepth = 1;
        let valueLength = fullMatch.length;
        loopLabel:
        for (; valueLength < textFromPos.length; valueLength++) {
          switch (textFromPos[valueLength]) {
            case "[":
              bracketNestingDepth++;
              break;
            case "]":
              bracketNestingDepth--;
              if (bracketNestingDepth === 0) {
                // Done!
                break loopLabel;
              }
              break;
          }
        }
        if (bracketNestingDepth !== 0) {
          console.log("Failed to parse attribute", fullMatch, textFromPos);
          return -1;
        }

        if (textFromPos[valueLength + 1] === "(") {
          // This turns out to be a link, back out!
          return -1;
        }

        return cx.addElement(
          cx.elt("Attribute", pos, pos + valueLength + 1, [
            cx.elt("AttributeMark", pos, pos + 1), // [
            cx.elt("AttributeName", pos + 1, pos + 1 + attributeName.length),
            cx.elt(
              "AttributeColon",
              pos + 1 + attributeName.length,
              pos + 1 + attributeName.length + attributeColon.length,
            ),
            cx.elt(
              "AttributeValue",
              pos + 1 + attributeName.length + attributeColon.length,
              pos + valueLength,
            ),
            cx.elt("AttributeMark", pos + valueLength, pos + valueLength + 1), // [
          ]),
        );
      },
      after: "Emphasis",
    },
  ],
};

type RegexParserExtension = {
  // unicode char code for efficiency .charCodeAt(0)
  firstCharCode: number;
  regex: RegExp;
  nodeType: string;
};

function regexParser({
  regex,
  firstCharCode,
  nodeType,
}: RegexParserExtension): MarkdownConfig {
  return {
    defineNodes: [nodeType],
    parseInline: [
      {
        name: nodeType,
        parse(cx, next, pos) {
          if (firstCharCode !== next) {
            return -1;
          }
          const match = regex.exec(cx.slice(pos, cx.end));
          if (!match) {
            return -1;
          }
          return cx.addElement(cx.elt(nodeType, pos, pos + match[0].length));
        },
      },
    ],
  };
}

const NakedURL = regexParser(
  {
    firstCharCode: 104, // h
    regex: new RegExp(`^${nakedUrlRegex.source}`),
    nodeType: "NakedURL",
  },
);

const Hashtag = regexParser({
  firstCharCode: 35, // #
  regex: new RegExp(`^${tagRegex.source}`),
  nodeType: "Hashtag",
});

// FrontMatter parser

const yamlLang = StreamLanguage.define(yamlLanguage);

export const FrontMatter: MarkdownConfig = {
  defineNodes: [
    { name: "FrontMatter", block: true },
    { name: "FrontMatterMarker" },
    { name: "FrontMatterCode" },
  ],
  parseBlock: [{
    name: "FrontMatter",
    parse: (cx, line: Line) => {
      if (cx.parsedPos !== 0) {
        return false;
      }
      if (line.text !== "---") {
        return false;
      }
      const frontStart = cx.parsedPos;
      const elts = [
        cx.elt(
          "FrontMatterMarker",
          cx.parsedPos,
          cx.parsedPos + line.text.length + 1,
        ),
      ];
      cx.nextLine();
      const startPos = cx.parsedPos;
      let endPos = startPos;
      let text = "";
      let lastPos = cx.parsedPos;
      do {
        text += line.text + "\n";
        endPos += line.text.length + 1;
        cx.nextLine();
        if (cx.parsedPos === lastPos) {
          // End of file, no progress made, there may be a better way to do this but :shrug:
          return false;
        }
        lastPos = cx.parsedPos;
      } while (line.text !== "---");
      const yamlTree = yamlLang.parser.parse(text);

      elts.push(
        cx.elt("FrontMatterCode", startPos, endPos, [
          cx.elt(yamlTree, startPos),
        ]),
      );
      endPos = cx.parsedPos + line.text.length;
      elts.push(cx.elt(
        "FrontMatterMarker",
        cx.parsedPos,
        cx.parsedPos + line.text.length,
      ));
      cx.nextLine();
      cx.addElement(cx.elt("FrontMatter", frontStart, endPos, elts));
      return true;
    },
    before: "HorizontalRule",
  }],
};

export const extendedMarkdownLanguage = markdown({
  extensions: [
    WikiLink,
    Attribute,
    FrontMatter,
    TaskList,
    Highlight,
    LuaDirectives,
    Strikethrough,
    Table,
    NakedURL,
    Hashtag,
    Superscript,
    Subscript,
    {
      props: [
        foldNodeProp.add({
          // Don't fold at the list level
          BulletList: () => null,
          OrderedList: () => null,
          // Fold list items
          ListItem: (tree, state) => ({
            from: state.doc.lineAt(tree.from).to,
            to: tree.to,
          }),
          // Fold frontmatter
          FrontMatter: (tree) => ({
            from: tree.from,
            to: tree.to,
          }),
        }),

        styleTags({
          Task: ct.TaskTag,
          TaskMark: ct.TaskMarkTag,
          Comment: ct.CommentTag,
          Subscript: ct.SubscriptTag,
          Superscript: ct.SuperscriptTag,
          "TableDelimiter StrikethroughMark": t.processingInstruction,
          "TableHeader/...": t.heading,
          TableCell: t.content,
          CodeInfo: ct.CodeInfoTag,
          HorizontalRule: ct.HorizontalRuleTag,
          Hashtag: ct.HashtagTag,
          NakedURL: ct.NakedURLTag,
          NamedAnchor: ct.NamedAnchorTag,
        }),
      ],
    },
  ],
}).language;

export function parseMarkdown(text: string, offset?: number): ParseTree {
  return parse(extendedMarkdownLanguage, text, offset);
}


================================================
FILE: client/markdown_parser/table_parser.ts
================================================
import type {
  BlockContext,
  Element,
  LeafBlock,
  LeafBlockParser,
  Line,
  MarkdownConfig,
} from "@lezer/markdown";
import { tags as t } from "@lezer/highlight";

// Forked from https://github.com/lezer-parser/markdown/blob/main/src/extension.ts
// MIT License
// Author: Marijn Haverbeke
// Change made: Avoid wiki links and commands with aliases [[link|alias]], {[My: Command|alias]}
//  from being parsed as table row separators

function parseRow(
  cx: BlockContext,
  line: string,
  startI = 0,
  elts?: Element[],
  offset = 0,
) {
  let count = 0, first = true, cellStart = -1, cellEnd = -1, esc = false;
  const parseCell = () => {
    elts!.push(
      cx.elt(
        "TableCell",
        offset + cellStart,
        offset + cellEnd,
        cx.parser.parseInline(
          line.slice(cellStart, cellEnd),
          offset + cellStart,
        ),
      ),
    );
  };

  let inWikilink = false;
  let inCommandButton = false;
  for (let i = startI; i < line.length; i++) {
    const next = line.charCodeAt(i);

    if (line[i] === "[" && line.charAt(i + 1) === "[") {
      inWikilink = true;
    } else if (line[i] === "]" && line.charAt(i - 1) === "]" && inWikilink) {
      inWikilink = false;
    }

    if (line[i] === "{" && line.charAt(i + 1) === "[") {
      inCommandButton = true;
    } else if (inCommandButton) {
      if (line[i] === "}" && line.charAt(i - 1) === "]") {
        // Command button without arguments: {[My Command: BeepBoop|Alias]}
        inCommandButton = false;
      } else if (line[i] === "}" && line.charAt(i - 1) === ")") {
        // Command button with arguments: {[My Command: BeepBoop|Alias]("args")}
        inCommandButton = false;
      }
    }

    if (next == 124 /* '|' */ && !esc && !inWikilink && !inCommandButton) {
      if (!first || cellStart > -1) count++;
      first = false;
      if (elts) {
        if (cellStart > -1) parseCell();
        elts.push(cx.elt("TableDelimiter", i + offset, i + offset + 1));
      }
      cellStart = cellEnd = -1;
    } else if (esc || next != 32 && next != 9) {
      if (cellStart < 0) cellStart = i;
      cellEnd = i + 1;
    }
    esc = !esc && next == 92;
  }
  if (cellStart > -1) {
    count++;
    if (elts) parseCell();
  }
  return count;
}

function hasPipe(str: string, start: number) {
  for (let i = start; i < str.length; i++) {
    const next = str.charCodeAt(i);
    if (next == 124 /* '|' */) return true;
    if (next == 92 /* '\\' */) i++;
  }
  return false;
}

const delimiterLine = /^\|?(\s*:?-+:?\s*\|)+(\s*:?-+:?\s*)?$/;

class TableParser implements LeafBlockParser {
  // Null means we haven't seen the second line yet, false means this
  // isn't a table, and an array means this is a table and we've
  // parsed the given rows so far.
  rows: false | null | Element[] = null;

  nextLine(cx: BlockContext, line: Line, leaf: LeafBlock) {
    if (this.rows == null) { // Second line
      this.rows = false;
      let lineText;
      if (
        (line.next == 45 || line.next == 58 || line.next == 124 /* '-:|' */) &&
        delimiterLine.test(lineText = line.text.slice(line.pos))
      ) {
        const firstRow: Element[] = [],
          firstCount = parseRow(cx, leaf.content, 0, firstRow, leaf.start);
        if (firstCount == parseRow(cx, lineText, line.pos)) {
          this.rows = [
            cx.elt(
              "TableHeader",
              leaf.start,
              leaf.start + leaf.content.length,
              firstRow,
            ),
            cx.elt(
              "TableDelimiter",
              cx.lineStart + line.pos,
              cx.lineStart + line.text.length,
            ),
          ];
        }
      }
    } else if (this.rows) { // Line after the second
      const content: Element[] = [];
      parseRow(cx, line.text, line.pos, content, cx.lineStart);
      this.rows.push(
        cx.elt(
          "TableRow",
          cx.lineStart + line.pos,
          cx.lineStart + line.text.length,
          content,
        ),
      );
    }
    return false;
  }

  finish(cx: BlockContext, leaf: LeafBlock) {
    if (!this.rows) return false;
    cx.addLeafElement(
      leaf,
      cx.elt(
        "Table",
        leaf.start,
        leaf.start + leaf.content.length,
        this.rows as readonly Element[],
      ),
    );
    return true;
  }
}

/// This extension provides
/// [GFM-style](https://github.github.com/gfm/#tables-extension-)
/// tables, using syntax like this:
///
/// ```
/// | head 1 | head 2 |
/// | ---    | ---    |
/// | cell 1 | cell 2 |
/// ```
export const Table: MarkdownConfig = {
  defineNodes: [
    { name: "Table", block: true },
    { name: "TableHeader", style: { "TableHeader/...": t.heading } },
    "TableRow",
    { name: "TableCell", style: t.content },
    { name: "TableDelimiter", style: t.processingInstruction },
  ],
  parseBlock: [{
    name: "Table",
    leaf(_, leaf) {
      return hasPipe(leaf.content, 0) ? new TableParser() : null;
    },
    endLeaf(cx, line, leaf) {
      if (
        leaf.parsers.some((p) => p instanceof TableParser) ||
        !hasPipe(line.text, line.basePos)
      ) return false;
      // @ts-ignore: internal
      const next = cx.scanLine(cx.absoluteLineEnd + 1).text;
      return delimiterLine.test(next) &&
        parseRow(cx, line.text, line.basePos) ==
          parseRow(cx, next, line.basePos);
    },
    before: "SetextHeading",
  }],
};


================================================
FILE: client/markdown_renderer/html_render.test.ts
================================================
import { assertEquals } from "@std/assert";
import { renderHtml } from "./html_render.ts";

Deno.test("HTML Render", () => {
  assertEquals(
    renderHtml({
      name: "b",
      body: "hello",
    }),
    `<b>hello</b>`,
  );
  assertEquals(
    renderHtml({
      name: "a",
      attrs: {
        href: "https://example.com",
      },
      body: "hello",
    }),
    `<a href="https://example.com">hello</a>`,
  );
  assertEquals(
    renderHtml({
      name: "span",
      body: "<>",
    }),
    `<span>&lt;&gt;</span>`,
  );
});


================================================
FILE: client/markdown_renderer/html_render.ts
================================================
export const Fragment = "FRAGMENT";

export type Tag = {
  name: string;
  attrs?: Record<string, string | undefined>;
  body: Tag[] | string;
} | string;

export function htmlEscape(s: string): string {
  if (typeof s !== "string") {
    return s;
  }

  s = s.replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/\n/g, "<br>");

  let oldS = s;
  do {
    oldS = s;
    s = s.replace(/ {2}/g, "&nbsp; ");
  } while (s !== oldS);
  return s;
}

export function renderHtml(t: Tag | null): string {
  if (!t) {
    return "";
  }
  if (typeof t === "string") {
    return htmlEscape(t);
  }
  const attrs = t.attrs
    ? " " + Object.entries(t.attrs)
      .filter(([, value]) => value !== undefined)
      .map(([k, v]) => `${k}="${htmlEscape(v!)}"`).join(
        " ",
      )
    : "";
  const body = typeof t.body === "string"
    ? htmlEscape(t.body)
    : t.body.map(renderHtml).join("");
  if (t.name === Fragment) {
    return body;
  }
  return `<${t.name}${attrs}>${body}</${t.name}>`;
}


================================================
FILE: client/markdown_renderer/inline.ts
================================================
import {
  addParentPointers,
  findNodeOfType,
  type ParseTree,
  renderToText,
  replaceNodesMatchingAsync,
} from "@silverbulletmd/silverbullet/lib/tree";
import {
  getPathExtension,
  isMarkdownPath,
  parseToRef,
} from "@silverbulletmd/silverbullet/lib/ref";
import { isLocalURL } from "@silverbulletmd/silverbullet/lib/resolve";
import { mime } from "mimetypes";
import { LuaStackFrame, LuaTable } from "../space_lua/runtime.ts";
import { parseMarkdown } from "../markdown_parser/parser.ts";
import { renderExpressionResult } from "./result_render.ts";
import { parseExpressionString } from "../space_lua/parse.ts";
import { evalExpression } from "../space_lua/eval.ts";
import type { LuaExpression } from "../space_lua/ast.ts";

import { fsEndpoint } from "../spaces/constants.ts";
import {
  nameFromTransclusion,
  parseTransclusion,
  type Transclusion,
} from "@silverbulletmd/silverbullet/lib/transclusion";
import type { Space } from "../space.ts";
import type { SpaceLuaEnvironment } from "../space_lua.ts";

export type MarkdownExpandOptions = {
  // all options default to true, set to false to explicitly disable
  // Replace (markdown transclusions) with their content
  expandTransclusions?: boolean;
  // Replace Lua directives with their evaluated values
  expandLuaDirectives?: boolean;
  // Rewrite tasks to include references so that they can be updated
  rewriteTasks?: boolean;
};

/**
 * Expands custom markdown Lua directives and transclusions into plain markdown
 * @param mdTree parsed markdown tree
 * @returns modified mdTree
 */
export async function expandMarkdown(
  space: Space,
  pageName: string,
  mdTree: ParseTree,
  sle: SpaceLuaEnvironment,
  options: MarkdownExpandOptions = {},
  processedPages: Set<string> = new Set(),
): Promise<ParseTree> {
  addParentPointers(mdTree);
  await replaceNodesMatchingAsync(mdTree, async (n) => {
    if (n.type === "Image" && options.expandTransclusions !== false) {
      // Let's scan for ![[embeds]] that are codified as Images, confusingly
      const text = renderToText(n);

      const transclusion = parseTransclusion(text);
      if (!transclusion || processedPages.has(transclusion.url)) {
        return n;
      }

      try {
        const result = await inlineContentFromURL(space, transclusion);

        // We don't transclude anything that's not markdown
        if (typeof result !== "string") {
          return n;
        }

        // We know it's a markdown page and we know we are transcluding it. "Mark"
        // it so we won't touch it down the line and cause endless recursion
        processedPages.add(transclusion.url);

        const tree = parseMarkdown(result);

        // Recursively process
        return expandMarkdown(
          space,
          nameFromTransclusion(transclusion),
          tree,
          sle,
          options,
          processedPages,
        );
      } catch (e: any) {
        return parseMarkdown(`**Error:** ${e.message}`);
      }
    } else if (
      n.type === "LuaDirective" && options.expandLuaDirectives !== false
    ) {
      const expr = findNodeOfType(n, "LuaExpressionDirective") as
        | LuaExpression
        | null;
      if (!expr) {
        return;
      }
      const exprText = renderToText(expr);

      try {
        const sf = LuaStackFrame.createWithGlobalEnv(sle.env);

        let result = await evalExpression(
          parseExpressionString(exprText),
          sle.env,
          sf,
        );

        if (result?.markdown) {
          result = result.markdown;
        } else if (result instanceof LuaTable && result.has("markdown")) {
          result = result.get("markdown");
        }
        return parseMarkdown(await renderExpressionResult(result));
      } catch (e: any) {
        // Reduce blast radius and give useful error message
        console.error("Error evaluating Lua directive", exprText, e);
        return parseMarkdown(`**Error:** ${e.message}`);
      }
    } else if (n.type === "Task" && options.rewriteTasks !== false) {
      // Add a task reference to this based on the current page name if there's not one already
      const existingLink = findNodeOfType(n, "WikiLink");
      if (!existingLink) {
        n.children!.splice(1, 0, {
          "text": " ",
        }, {
          "type": "WikiLink",
          "children": [
            {
              "type": "WikiLinkMark",
              "children": [{ "text": "[[" }],
            },
            {
              "type": "WikiLinkPage",
              "children": [{ "text": `${pageName}@${n.parent!.from!}` }],
            },
            {
              "type": "WikiLinkMark",
              "children": [{ "text": "]]" }],
            },
          ],
        });
      }
    }
  });
  return mdTree;
}

type OffsetText = {
  text: string;
  offset: number;
};

/**
 * Function to generate HTML or markdown for a ![[<link>]] type transclusion.
 * @param space space object to use to retrieve content (readRef)
 * @param transclusion transclusion object to process
 * @returns a string for a markdown transclusion, or html for everything else
 */
export function inlineContentFromURL(
  space: Space,
  transclusion: Transclusion,
): HTMLElement | OffsetText | Promise<HTMLElement | OffsetText> {
  const allowExternal = transclusion.linktype !== "wikilink";
  if (!client) {
    return { text: "", offset: 0 };
  }
  let mimeType: string | null | undefined;
  if (!isLocalURL(transclusion.url) && allowExternal) {
    // Remote URL
    // Realistically we should dertermine the mine type by sending a HEAD
    // request, this poses multiple problems
    // 1. This makes `async` a hard requirement here
    // 2. We would need to proxy the request (because of CORS)
    // 3. It won't work "offline" (i.e. away from the SB instance, because it
    //    can't proxy the request anymore)
    // 4. It can be pretty heavy. If your internet connection is bad you will
    //    have to wait for all HEAD request, for your `markdownToHtml` to
    //    complete. This could take a noticeable amount of time.
    // For this reason we will stick to doing it the `dumb` way by just getting
    // it from the URL extension
    const extension = URL.parse(transclusion.url)?.pathname.split(".").pop();
    if (extension) {
      mimeType = mime.getType(extension);
    }
  } else {
    const ref = parseToRef(transclusion.url);
    if (!ref) {
      throw Error(`Failed to parse url: ${transclusion.url}`);
    }

    mimeType = mime.getType(getPathExtension(ref.path));
  }

  if (!mimeType) {
    throw Error(`Failed to determine mime type for ${transclusion.url}`);
  }

  const style = `max-width: 100%;` +
    (transclusion.dimension?.width
      ? `width: ${transclusion.dimension.width}px;`
      : "") +
    (transclusion.dimension?.height
      ? `height: ${transclusion.dimension.height}px;`
      : "");

  // If the URL is a local, prefix it with /.fs and encode the : so that it's not interpreted as a protocol
  const sanitizedFsUrl = isLocalURL(transclusion.url)
    ? fsEndpoint.slice(1) + "/" + transclusion.url.replace(":", "%3A")
    : transclusion.url;

  if (mimeType.startsWith("image/")) {
    const img = document.createElement("img");
    img.src = sanitizedFsUrl;
    img.alt = transclusion.alias;
    img.style = style;
    return img;
  } else if (mimeType.startsWith("video/")) {
    const video = document.createElement("video");
    video.src = sanitizedFsUrl;
    video.title = transclusion.alias;
    video.controls = true;
    video.autoplay = false;
    video.style = style;
    return video;
  } else if (mimeType.startsWith("audio/")) {
    const audio = document.createElement("audio");
    audio.src = sanitizedFsUrl;
    audio.title = transclusion.alias;
    audio.controls = true;
    audio.autoplay = false;
    audio.style = style;
    return audio;
  } else if (mimeType === "application/pdf") {
    const embed = document.createElement("object");
    embed.type = mimeType;
    embed.data = sanitizedFsUrl;
    embed.style.width = "100%";
    embed.style.height = "20em";
    embed.style = style;
    return embed;
  } else if (mimeType === "text/markdown") {
    if (!isLocalURL(transclusion.url) && allowExternal) {
      throw Error(`Transcluding markdown from external sources is not allowed`);
    }

    const ref = parseToRef(transclusion.url);
    if (!ref || !isMarkdownPath(ref.path)) {
      // We can be fairly sure this can't happen, but just be sure
      throw Error(
        `Couldn't transclude markdown, invalid path: ${transclusion.url}`,
      );
    }

    return space.readRef(ref);
  } else {
    return { text: `File has unsupported mimeType: ${mimeType}`, offset: 0 };
  }
}


================================================
FILE: client/markdown_renderer/justified_tables.ts
================================================
import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree";
import type { Tag } from "./html_render.ts";

/** How to justify a table's row's cells */
type Justification = "" | "left" | "right" | "center";

export function justifiedTableRender(
  cleaned_table: Tag[],
  parse_tree: ParseTree,
): Tag[] {
  const justify: Justification[] = getTableJustification(parse_tree);
  return justifiedTableTags(cleaned_table, justify);
}

/** Takes a markdown table's `ParseTree` and returns how the table delimiter requests the table be justified
 * @argument t ParseTree - A markdown table's `ParseTree`
 */
function getTableJustification(t: ParseTree): Justification[] {
  const delimiter = t.children?.find((child) =>
    child.type === "TableDelimiter"
  );
  const delimiterText = delimiter?.children?.at(0)?.text;

  if (!delimiterText) return [];

  // Split at "|" and filter out empty strings (from leading/trailing |)
  const columnDelimiters = delimiterText.split("|").filter((part) =>
    part.trim() !== ""
  );

  return columnDelimiters.map(parseColumnAlignment);
}

/** Parse a single column delimiter to determine its alignment */
function parseColumnAlignment(delimiter: string): Justification {
  const trimmed = delimiter.trim();

  if (trimmed === "") return "";

  const left = trimmed.startsWith(":");
  const right = trimmed.endsWith(":");

  if (left && right) return "center";
  if (left) return "left";
  if (right) return "right";
  return "";
}

type TagObject = {
  name: string;
  attrs?: Record<string, string | undefined>;
  body: Tag[] | string;
};

/** Type guard to check if a value is a non-string Tag */
function isTagObject(value: Tag): value is TagObject {
  return typeof value !== "string";
}

/** Type guard to check if a Tag has a body array */
function hasBodyArray(tag: TagObject): tag is TagObject & { body: Tag[] } {
  return Array.isArray(tag.body);
}

/** Apply justification to a single row */
function justifyRow(row: TagObject, justify: Justification[]): Tag[] {
  if (!hasBodyArray(row)) {
    return [];
  }

  return row.body.map((cell, i) => {
    const alignment = justify[i];

    // Skip if no alignment specified or if cell is a string
    if (!alignment || !isTagObject(cell)) {
      return cell;
    }

    // Ensure cell has attrs object
    if (!cell.attrs) {
      cell.attrs = {};
    }

    // Add alignment class
    const alignmentClass = `sb-table-align-${alignment}`;
    if (cell.attrs.class) {
      cell.attrs.class += ` ${alignmentClass}`;
    } else {
      cell.attrs.class = alignmentClass;
    }

    return cell;
  });
}

/** Takes a table body and justifications to apply and applies the justifications
 * @argument table_body Tag[] - The pre-rendered table body as seen in `render()`
 * @argument justify Justification[] - A justification per table column
 */
function justifiedTableTags(
  table_body: Tag[],
  justify: Justification[],
): Tag[] {
  if (justify.length === 0) return table_body;

  return table_body.map((row: Tag) => {
    if (!isTagObject(row)) return row;

    // Handle thead specially - it contains a tr row
    if (row.name === "thead" && hasBodyArray(row)) {
      const trRow = row.body[0];
      if (isTagObject(trRow)) {
        const newBody = justifyRow(trRow, justify);
        trRow.body = newBody;
      }
    } else {
      // Handle regular rows
      const newBody = justifyRow(row, justify);
      if (newBody.length > 0) {
        row.body = newBody;
      }
    }

    return row;
  });
}


================================================
FILE: client/markdown_renderer/markdown_render.test.ts
================================================
import { parse } from "../markdown_parser/parse_tree.ts";

import { renderMarkdownToHtml } from "./markdown_render.ts";
import { extendedMarkdownLanguage } from "../markdown_parser/parser.ts";
import { assertEquals } from "@std/assert";

const sampleMarkdown = `---
name: Sup
---

# Hello world

This is **bold** and _italic_, or _italic_. And a **_mix_**. And ==highlight==!

Lists:

- This
- Is a
- list
- And here we go nested
1. This is a numbered
2. Two
- And different
- Bla
- More bla

And:

1. Numbered
2. Two

## Second heading

And some

And like this:

  More code
  Bla

And a blockquote:

> Sup yo Empty line\
> Second part

<!-- this is a comment -->

And more custom stuff [[Page link]]

{[Command button]}

- [ ] #next Task
- [x] #next Task 2


## Tables

| type      | actor_login | created_at           | payload_ref            |
| --------- | ----------- | -------------------- | ---------------------- |
| PushEvent | avb         | 2022-10-27T08:27:48Z | refs/heads/master      |
| PushEvent | avb         | 2022-10-27T04:31:27Z | refs/heads/jitterSched |

Here is something

---

A new thing.
`;

Deno.test("Markdown render", () => {
  const tree = parse(extendedMarkdownLanguage, sampleMarkdown);
  renderMarkdownToHtml(tree, {
    failOnUnknown: true,
  });
});

Deno.test("Smart hard break test", () => {
  const example = `**Hello**
*world!*`;
  const tree = parse(extendedMarkdownLanguage, example);
  const html = renderMarkdownToHtml(tree, {
    failOnUnknown: true,
    smartHardBreak: true,
  });
  assertEquals(
    html,
    `<span class="p"><strong>Hello</strong><br><em>world!</em></span>`,
  );

  const example2 = `This is going to be a text. With a new line.

And another

* and a list
* with a second item

### [[Bla]]
  Url: something
  Server: something else
  📅 last_updated - [Release notes](release_notes_url)`;

  const tree2 = parse(extendedMarkdownLanguage, example2);
  const html2 = renderMarkdownToHtml(tree2, {
    failOnUnknown: true,
    smartHardBreak: true,
  });

  console.log(html2);
});


================================================
FILE: client/markdown_renderer/markdown_render.ts
================================================
import {
  addParentPointers,
  collectNodesOfType,
  findNodeOfType,
  type ParseTree,
  removeParentPointers,
  renderToText,
  traverseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import {
  encodePageURI,
  encodeRef,
  parseToRef,
} from "@silverbulletmd/silverbullet/lib/ref";
import { Fragment, renderHtml, type Tag } from "./html_render.ts";
import * as TagConstants from "../../plugs/index/constants.ts";
import { extractHashtag } from "@silverbulletmd/silverbullet/lib/tags";
import { justifiedTableRender } from "./justified_tables.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import { inlineContentFromURL } from "./inline.ts";
import { parseTransclusion } from "@silverbulletmd/silverbullet/lib/transclusion";

export type MarkdownRenderOptions = {
  failOnUnknown?: true;
  smartHardBreak?: true;
  annotationPositions?: true;
  preserveAttributes?: true;
  shortWikiLinks?: boolean;
  // When defined, use to inline images as data: urls
  translateUrls?: (url: string, type: "link" | "image") => string;
  expand?: true;
};

function cleanTags(values: (Tag | null)[], cleanWhitespace = false): Tag[] {
  const result: Tag[] = [];
  for (const value of values) {
    if (cleanWhitespace && typeof value === "string" && value.match(/^\s+$/)) {
      continue;
    }
    if (value) {
      result.push(value);
    }
  }
  return result;
}

/**
 * Cleans up a markdown tree. Side effect: adds parent pointers
 */
function preprocess(t: ParseTree) {
  addParentPointers(t);
  traverseTree(t, (node) => {
    if (!node.type) {
      // Remove redundant newlines in table
      if (node.text?.startsWith("\n")) {
        const prevNodeIdx = node.parent!.children!.indexOf(node) - 1;
        const prevNodeType = node.parent!.children![prevNodeIdx]?.type;
        if (
          prevNodeType?.includes("Heading") || prevNodeType?.includes("Table")
        ) {
          node.text = node.text.slice(1);
        }
      }
    }
    return false;
  });
}

function posPreservingRender(
  t: ParseTree,
  options: MarkdownRenderOptions = {},
): Tag | null {
  const tag = render(t, options);
  if (!options.annotationPositions) {
    return tag;
  }
  if (!tag) {
    return null;
  }
  if (typeof tag === "string") {
    return tag;
  }
  if (t.from) {
    if (!tag.attrs) {
      tag.attrs = {};
    }
    tag.attrs["data-pos"] = "" + t.from;
  }
  return tag;
}

function render(
  t: ParseTree,
  options: MarkdownRenderOptions = {},
): Tag | null {
  if (t.type?.endsWith("Mark") || t.type?.endsWith("Delimiter")) {
    return null;
  }
  switch (t.type) {
    case "Document":
      return {
        name: Fragment,
        body: cleanTags(mapRender(t.children!)),
      };
    case "FrontMatter":
      return null;
    case "CommentBlock":
      // Remove, for now
      return null;
    case "ATXHeading1":
      return {
        name: "h1",
        body: cleanTags(mapRender(t.children!)),
      };
    case "ATXHeading2":
      return {
        name: "h2",
        body: cleanTags(mapRender(t.children!)),
      };
    case "ATXHeading3":
      return {
        name: "h3",
        body: cleanTags(mapRender(t.children!)),
      };
    case "ATXHeading4":
      return {
        name: "h4",
        body: cleanTags(mapRender(t.children!)),
      };
    case "ATXHeading5":
      return {
        name: "h5",
        body: cleanTags(mapRender(t.children!)),
      };
    case "ATXHeading6":
      return {
        name: "h6",
        body: cleanTags(mapRender(t.children!)),
      };
    case "Paragraph":
      return {
        name: "span",
        attrs: {
          class: "p",
        },
        body: cleanTags(mapRender(t.children!)),
      };
    // Code blocks
    case "FencedCode":
    case "CodeBlock": {
      // Clear out top-level indent blocks
      const lang = findNodeOfType(t, "CodeInfo");
      t.children = t.children!.filter((c) => c.type);
      return {
        name: "pre",
        attrs: {
          "data-lang": lang ? lang.children![0].text : undefined,
        },
        body: cleanTags(mapRender(t.children!)),
      };
    }
    case "CodeInfo":
      return null;
    case "CodeText":
      return t.children![0].text!;
    case "Blockquote":
      return {
        name: "blockquote",
        body: cleanTags(mapRender(t.children!)),
      };
    case "HardBreak":
      return {
        name: "br",
        body: "",
      };
    // Basic styling
    case "Emphasis":
      return {
        name: "em",
        body: cleanTags(mapRender(t.children!)),
      };
    case "Highlight":
      return {
        name: "span",
        attrs: {
          class: "highlight",
        },
        body: cleanTags(mapRender(t.children!)),
      };
    case "Strikethrough":
      return {
        name: "del",
        body: cleanTags(mapRender(t.children!)),
      };
    case "InlineCode":
      return {
        name: "code",
        attrs: {
          class: "sb-code",
        },
        body: cleanTags(mapRender(t.children!)),
      };
    case "BulletList":
      return {
        name: "ul",
        body: cleanTags(mapRender(t.children!), true),
      };
    case "OrderedList":
      return {
        name: "ol",
        body: cleanTags(mapRender(t.children!), true),
      };
    case "ListItem":
      return {
        name: "li",
        body: cleanTags(mapRender(t.children!), true),
      };
    case "StrongEmphasis":
      return {
        name: "strong",
        body: cleanTags(mapRender(t.children!)),
      };
    case "HorizontalRule":
      return {
        name: "hr",
        body: "",
      };
    case "Link": {
      const linkTextChildren = t.children!.slice(1, -4);
      const urlNode = findNodeOfType(t, "URL");
      if (!urlNode) {
        return renderToText(t);
      }
      return {
        name: "a",
        attrs: {
          href: urlNode.children![0].text!,
          target: "_blank",
        },
        body: cleanTags(mapRender(linkTextChildren)),
      };
    }
    case "Autolink": {
      const urlNode = findNodeOfType(t, "URL");
      if (!urlNode) {
        return renderToText(t);
      }
      const url = urlNode.children![0].text!;
      return {
        name: "a",
        attrs: {
          href: url,
          target: "_blank",
        },
        body: url,
      };
    }
    case "Image": {
      const text = renderToText(t);

      const transclusion = parseTransclusion(text);
      if (!transclusion) {
        return text;
      }

      try {
        const result = inlineContentFromURL(client.space, transclusion);
        if (result instanceof Promise) {
          // console.warn(
          //   `Unsupported inline in markdown render context: ${transclusion.url}`,
          // );
          // Can't support promises in this context, returning original text
          return text;
        }
        // Running in non-browser context
        if (!globalThis.HTMLElement || !(result instanceof HTMLElement)) {
          return text;
        }

        return {
          name: result.tagName,
          attrs: Array.from(result.attributes).reduce(
            (obj, attr) => {
              obj[attr.name] = attr.value;
              return obj;
            },
            {} as Record<string, string>,
          ),
          body: "",
        };
      } catch (e: any) {
        console.error("Error handling image or transclusion", e.message);
        return {
          name: "span",
          body: "Error loading image",
        };
      }
    }

    // Custom stuff
    case "WikiLink": {
      const link = findNodeOfType(t, "WikiLinkPage")!.children![0].text!;
      let linkText = options.shortWikiLinks === true
        ? link.split("/").pop()!
        : link;
      const aliasNode = findNodeOfType(t, "WikiLinkAlias");
      if (aliasNode) {
        linkText = aliasNode.children![0].text!;
      }

      // For invalid refs the link just won't link
      let href: string = `#`;

      const ref = parseToRef(link);
      if (ref) {
        href = `/${encodePageURI(encodeRef(ref))}`;
      }

      return {
        name: "a",
        attrs: {
          href,
          class: "wiki-link",
          "data-ref": link,
        },
        body: linkText,
      };
    }
    case "NakedURL": {
      const url = t.children![0].text!;
      return {
        name: "a",
        attrs: {
          href: url,
          target: "_blank",
        },
        body: url,
      };
    }
    case "Hashtag": {
      const tagText: string = t.children![0].text!;
      const link = TagConstants.tagPrefix + extractHashtag(tagText);
      return {
        name: "a",
        attrs: {
          class: "hashtag sb-hashtag",
          "data-tag-name": extractHashtag(tagText),
          href: `/${encodePageURI(link)}`,
          "data-ref": link,
        },
        body: tagText,
      };
    }
    case "Task": {
      let externalTaskRef = "";
      collectNodesOfType(t, "WikiLinkPage").forEach((wikilink) => {
        const ref = parseToRef(wikilink.children![0].text!);

        if (
          !externalTaskRef && ref &&
          (ref.details?.type === "position" ||
            ref.details?.type === "linecolumn")
        ) {
          externalTaskRef = wikilink.children![0].text!;
        }
      });

      return {
        name: "span",
        attrs: externalTaskRef
          ? {
            "data-external-task-ref": externalTaskRef,
          }
          : {},
        body: cleanTags(mapRender(t.children!)),
      };
    }
    case "TaskState": {
      // child[0] = marker, child[1] = state, child[2] = marker
      const stateText = t.children![1].text!;
      if ([" ", "x", "X"].includes(stateText)) {
        return {
          name: "input",
          attrs: {
            type: "checkbox",
            checked: stateText !== " " ? "checked" : undefined,
            "data-state": stateText,
          },
          body: "",
        };
      } else {
        return {
          name: "span",
          attrs: {
            class: "task-state",
          },
          body: stateText,
        };
      }
    }

    // Tables
    case "Table":
      return {
        name: "table",
        body: justifiedTableRender(cleanTags(mapRender(t.children!)), t),
      };
    case "TableHeader":
      return {
        name: "thead",
        body: [
          {
            name: "tr",
            body: cleanTags(mapRender(t.children!), true),
          },
        ],
      };
    case "TableCell":
      return {
        name: "td",
        body: cleanTags(mapRender(t.children!)),
      };
    case "TableRow": {
      const children = t.children!;
      const newChildren: ParseTree[] = [];
      // Ensure there is TableCell in between every delimiter
      let lookingForCell = false;
      for (const child of children) {
        if (child.type === "TableDelimiter" && lookingForCell) {
          // We were looking for a cell, but didn't fine one: empty cell!
          // Let's inject an empty one
          newChildren.push({
            type: "TableCell",
            children: [],
          });
        }
        if (child.type === "TableDelimiter") {
          lookingForCell = true;
        }
        if (child.type === "TableCell") {
          lookingForCell = false;
        }
        newChildren.push(child);
      }
      return {
        name: "tr",
        body: cleanTags(mapRender(newChildren), true),
      };
    }
    case "Attribute":
      if (options.preserveAttributes) {
        return {
          name: "span",
          attrs: {
            class: "attribute",
          },
          body: renderToText(t),
        };
      }
      return null;
    case "Escape": {
      return {
        name: "span",
        attrs: {
          class: "escape",
        },
        body: t.children![0].text!.slice(1),
      };
    }
    case "Entity":
      return t.children![0].text!;

    case "Superscript":
      return {
        name: "sup",
        body: cleanTags(mapRender(t.children!)),
      };
    case "Subscript":
      return {
        name: "sub",
        body: cleanTags(mapRender(t.children!)),
      };
    case "LuaDirective":
      return {
        name: "span",
        attrs: {
          class: "sb-lua-directive",
        },
        body: renderToText(t),
      };

    // Text
    case undefined:
      return t.text!;
    default:
      if (options.failOnUnknown) {
        removeParentPointers(t);
        console.error("Not handling", JSON.stringify(t, null, 2));
        throw new Error(`Unknown markdown node type ${t.type}`);
      } else {
        // Falling back to rendering verbatim
        removeParentPointers(t);
        console.warn("Not handling", JSON.stringify(t, null, 2));
        return renderToText(t);
      }
  }

  function mapRender(children: ParseTree[]) {
    return children.map((t) => posPreservingRender(t, options));
  }
}

function traverseTag(
  t: Tag,
  fn: (t: Tag) => void,
) {
  fn(t);
  if (typeof t === "string") {
    return;
  }
  if (t.body) {
    for (const child of t.body) {
      traverseTag(child, fn);
    }
  }
}

export function renderMarkdownToHtml(
  t: ParseTree,
  options: MarkdownRenderOptions = {},
  allPages: PageMeta[] = [],
) {
  preprocess(t);
  const htmlTree = posPreservingRender(t, options);
  if (htmlTree) {
    traverseTag(htmlTree, (t) => {
      if (typeof t === "string") {
        return;
      }
      if (t.name === "img" && options.translateUrls) {
        t.attrs!.src = options.translateUrls!(t.attrs!.src!, "image");
      }

      if (t.name === "a" && t.attrs!.href) {
        if (options.translateUrls) {
          t.attrs!.href = options.translateUrls!(t.attrs!.href, "link");
        }
        if (t.attrs!["data-ref"]?.length) {
          const ref = parseToRef(t.attrs!["data-ref"]!);
          if (!ref) {
            return;
          }

          const pageMeta = allPages.find((p) =>
            ref.path === parseToRef(p.ref)?.path
          );
          if (
            pageMeta &&
            !(ref.details?.type === "position" ||
              ref.details?.type === "linecolumn")
          ) {
            t.body = [(pageMeta.pageDecoration?.prefix ?? "") + t.body];
            if (pageMeta.pageDecoration?.cssClasses) {
              t.attrs!.class += " sb-decorated-object " +
                pageMeta.pageDecoration.cssClasses.join(" ").replaceAll(
                  /[^a-zA-Z0-9-_ ]/g,
                  "",
                );
            }
          }
        }
        if (t.body.length === 0) {
          t.body = [t.attrs!.href];
        }
      }
    });
  }
  return renderHtml(htmlTree);
}


================================================
FILE: client/markdown_renderer/result_render.ts
================================================
import {
  luaFormatNumber,
  LuaTable,
  luaToString,
} from "../space_lua/runtime.ts";
import { isTaggedFloat } from "../space_lua/numeric.ts";

export function defaultTransformer(v: any, _k: string): Promise<string> {
  if (v === undefined) {
    return Promise.resolve("");
  }
  if (typeof v === "string") {
    return Promise.resolve(escapeRegularPipes(v.replaceAll("\n", " ")));
  }
  if (v && typeof v === "object") {
    return Promise.resolve(luaToString(v));
  }
  if (typeof v === "number") {
    return Promise.resolve(luaFormatNumber(v));
  }
  return Promise.resolve("" + v);
}

export function refCellTransformer(v: any, k: string) {
  if (k === "ref") {
    return Promise.resolve(`[[${v}]]`);
  } else {
    return defaultTransformer(v, k);
  }
}

/**
 * Escapes all pipes that would inadvertently delimit a markdown table column.
 * Does not escape columns that are used for aliasing in WikiLinks or Commands:
 * `[[WikiLink|Alias]]` and `{[Command: Name|Click Me!]("args")}`
 * @param s The text to replace
 * @returns The text where the pipes outside of silverbullet specific context is
 *  replaced with an escaped pipe.
 */
function escapeRegularPipes(s: string) {
  let result = "";
  let isInWikiLink = false;
  let isInCommandButton = false;

  for (let i = 0; i < s.length; i++) {
    if (s[i] == "[" && s[i + 1] == "[") {
      isInWikiLink = true;
    } else if (s[i] == "]" && s[i + 1] == "]" && isInWikiLink) {
      isInWikiLink = false;
    }
    if (s[i] == "{" && s[i + 1] == "[") {
      isInCommandButton = true;
    } else if (
      (s[i] == "]" || s[i] == ")") && s[i + 1] == "}" && isInCommandButton
    ) {
      isInCommandButton = false;
    } else if (s[i] == "|" && !isInWikiLink && !isInCommandButton) {
      result += "\\";
    }

    result += s[i];
  }

  return result;
}

// Nicely format an array of JSON objects as a Markdown table
export async function jsonToMDTable(
  jsonArray: any[],
  valueTransformer: (v: any, k: string) => Promise<string> = defaultTransformer,
): Promise<string> {
  const headers = new Set<string>();
  for (const entry of jsonArray) {
    for (const k of Object.keys(entry)) {
      headers.add(k);
    }
  }

  // Handle empty case manually, instead of three lines of ||
  if (headers.size == 0) {
    return "*(empty table)*";
  }

  const headerList = [...headers];
  const lines = [];
  lines.push(
    "|" +
      headerList
        .map(
          (headerName) => headerName,
        )
        .join("|") +
      "|",
  );
  lines.push(
    "|" +
      headerList
        .map(() => "--")
        .join("|") +
      "|",
  );
  for (const val of jsonArray) {
    const el = [];
    for (const prop of headerList) {
      const s = await valueTransformer(val[prop], prop);
      el.push(s);
    }
    lines.push("|" + el.join("|") + "|");
  }
  return lines.join("\n");
}

export function renderExpressionResult(result: any): Promise<string> {
  if (result === undefined || result === null) {
    return Promise.resolve("nil");
  }
  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);
    } 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]);
  } 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);
  }
}

/**
 * Applies some heuristics to figure out if a string should be rendered as a markdown block or inline markdown
 * @param s markdown string
 */
export function isBlockMarkdown(s: string) {
  if (s.includes("\n")) {
    return true;
  }
  // If it contains something resembling a list
  return !!s.match(/[\-\*]\s+/);
}


================================================
FILE: client/navigator.ts
================================================
import {
  encodePageURI,
  getNameFromPath,
  isMarkdownPath,
  parseToRef,
  type Path,
  type Ref,
} from "@silverbulletmd/silverbullet/lib/ref";
import type { Client } from "./client.ts";

// The path of a location state should not be empty, rather it should be
// normalized to the indexpage beforhand
export type LocationState = Ref & {
  scrollTop?: number;
  selection?: {
    anchor: number;
    head?: number;
  };
};

export class PathPageNavigator {
  navigationPromise: PromiseWithResolvers<string | null> | null = null;
  indexRef!: Ref;

  openLocations = new Map<string, LocationState>();

  constructor(
    private client: Client,
  ) {
    this.indexRef = this.client.getIndexRef();
  }

  /**
   * Navigates the client to the given page. An empty string path is navigated
   * to the index page. This function also updates the browser history
   * @param replaceState whether to update the state in place (rather than to
   * push a new state)
   */
  async navigate(
    ref: Ref,
    replaceState = false,
  ) {
    // We are already navigating, let's wait
    if (this.navigationPromise) {
      await this.navigationPromise.promise;
    }

    const currentState = this.buildCurrentLocationState();
    // Remove details as we prefer to actually keep the scrollTop
    currentState.details = undefined;

    if (ref.path === "") {
      ref.path = this.indexRef.path;
    }

    this.openLocations.set(currentState.path, currentState);

    if (!replaceState) {
      globalThis.history.replaceState(
        currentState,
        "",
        `${document.baseURI}${this.pathToURI(currentState.path)}`,
      );
      globalThis.history.pushState(
        ref,
        "",
        `${document.baseURI}${this.pathToURI(ref.path)}`,
      );
    } else {
      globalThis.history.replaceState(
        ref,
        "",
        `${document.baseURI}${this.pathToURI(ref.path)}`,
      );
    }

    this.navigationPromise = Promise.withResolvers();

    globalThis.dispatchEvent(
      new PopStateEvent("popstate", {
        state: ref,
      }),
    );

    const error = await this.navigationPromise.promise;

    if (error !== null) {
      // The navigation failed, let's revert everything we've done (This could
      // e.g. be a document editor which doesn't exist)
      this.client.flashNotification(`Failed to navigate: ${error}`, "error");

      if (!replaceState) {
        history.go(-1);
      } else {
        // This can e.g. happen on the first navigate. We obviously can't fall back to the same path, so fallback to the indexpage

        const newState: LocationState = currentState.path === ref.path
          ? this.indexRef
          : currentState;

        globalThis.history.replaceState(
          newState,
          "",
          `${document.baseURI}${this.pathToURI(newState.path)}`,
        );

        globalThis.dispatchEvent(
          new PopStateEvent("popstate", {
            state: newState,
          }),
        );

        // This is should never fail, because we already navigated here before.
        await this.navigationPromise.promise;
      }
    }

    this.navigationPromise = null;
  }

  private pathToURI(path: Path): string {
    return path !== this.indexRef.path
      ? encodePageURI(getNameFromPath(path))
      : "";
  }

  private buildCurrentLocationState(): LocationState {
    const locationState: LocationState = parseRefFromURI() || this.indexRef;
    if (locationState.path === "") {
      locationState.path = this.indexRef.path;
    }

    if (isMarkdownPath(locationState.path)) {
      const editorView = this.client.editorView;

      const mainSelection = editorView.state.selection.main;
      locationState.scrollTop = editorView.scrollDOM.scrollTop;
      locationState.selection = {
        head: mainSelection.head,
        anchor: mainSelection.anchor,
      };
    }

    return locationState;
  }

  subscribe(
    pageLoadCallback: (
      locationState: LocationState,
    ) => Promise<void>,
  ) {
    globalThis.addEventListener("popstate", async (event: PopStateEvent) => {
      const state: LocationState = event.state
        ? event.state
        // The popstate event can be fired in a whole list of weird situations
        // (see MDN). Try to do our best if it's fired without the state
        : parseRefFromURI() || this.indexRef;

      // Try filling in the ref using the openLocation cache
      if (
        !state.details && !state.selection && !state.scrollTop
      ) {
        const openLocation = this.openLocations.get(state.path);
        if (openLocation) {
          state.selection = openLocation.selection;
          state.scrollTop = openLocation.scrollTop;
        }
      }

      // For some (propably smart) reason the reject() function on a
      // Promise.withResolvers, also throws. This is hugely annoying here, so
      // let's resolve for both cases
      await pageLoadCallback(state)
        .then(
          () => this.navigationPromise?.resolve(null),
          (e) => this.navigationPromise?.resolve(e.message),
        );
    });
  }
}

export function parseRefFromURI(): Ref | null {
  const locationRef = parseToRef(decodeURIComponent(
    location.href.substring(document.baseURI.length), //this essentially returns location with prefix and leading slash removed (equivalent to location.pathname.substring(prefix.length).substring(1)),
  ));

  if (locationRef && location.hash) {
    locationRef.details = {
      type: "header",
      header: decodeURIComponent(location.hash.substring(1)),
    };
  }

  return locationRef;
}


================================================
FILE: client/plugos/event.test.ts
================================================
import { assert, assertEquals } from "@std/assert";
import { EventEmitter } from "./event.ts";

// Test event handler interfaces
interface TestEvents {
  message: (content: string) => void | Promise<void>;
  count: (num: number) => void | Promise<void>;
  multi: (a: string, b: number, c: boolean) => void | Promise<void>;
  async: () => void | Promise<void>;
  sync: () => void | Promise<void>;
  error: () => void | Promise<void>;
}

// Concrete test implementation
class TestEventEmitter extends EventEmitter<TestEvents> {
  // Just a concrete implementation for testing
}

Deno.test("EventEmitter - basic on/emit functionality", async () => {
  const emitter = new TestEventEmitter();
  let received = "";
  let count = 0;

  emitter.on({
    message: (content: string) => {
      received = content;
    },
    count: (num: number) => {
      count = num;
    },
  });

  await emitter.emit("message", "hello world");
  assertEquals(received, "hello world");

  await emitter.emit("count", 42);
  assertEquals(count, 42);
});

Deno.test("EventEmitter - multiple handlers for same event", async () => {
  const emitter = new TestEventEmitter();
  const results: string[] = [];

  emitter.on({
    message: (content: string) => {
      results.push(`handler1: ${content}`);
    },
  });

  emitter.on({
    message: (content: string) => {
      results.push(`handler2: ${content}`);
    },
  });

  await emitter.emit("message", "test");

  assertEquals(results.length, 2);
  assertEquals(results[0], "handler1: test");
  assertEquals(results[1], "handler2: test");
});

Deno.test("EventEmitter - multiple events in single handler", async () => {
  const emitter = new TestEventEmitter();
  let messageReceived = "";
  let countReceived = 0;

  emitter.on({
    message: (content: string) => {
      messageReceived = content;
    },
    count: (num: number) => {
      countReceived = num;
    },
  });

  await emitter.emit("message", "multi-handler");
  await emitter.emit("count", 123);

  assertEquals(messageReceived, "multi-handler");
  assertEquals(countReceived, 123);
});

Deno.test("EventEmitter - emit with multiple arguments", async () => {
  const emitter = new TestEventEmitter();
  let receivedA = "";
  let receivedB = 0;
  let receivedC = false;

  emitter.on({
    multi: (a: string, b: number, c: boolean) => {
      receivedA = a;
      receivedB = b;
      receivedC = c;
    },
  });

  await emitter.emit("multi", "test", 456, true);

  assertEquals(receivedA, "test");
  assertEquals(receivedB, 456);
  assertEquals(receivedC, true);
});

Deno.test("EventEmitter - off removes specific handler object with multiple events", async () => {
  const emitter = new TestEventEmitter();
  let messageCount = 0;
  let numberCount = 0;
  let remainingMessageCount = 0;

  // Handler object with multiple events that we'll remove
  const handlersToRemove = {
    message: () => {
      messageCount++;
    },
    count: () => {
      numberCount++;
    },
  };

  // Another handler for message events that should remain
  const remainingHandlers = {
    message: () => {
      remainingMessageCount++;
    },
  };

  emitter.on(handlersToRemove);
  emitter.on(remainingHandlers);

  // Test that both handlers work initially
  await emitter.emit("message", "test1");
  await emitter.emit("count", 42);
  assertEquals(messageCount, 1);
  assertEquals(numberCount, 1);
  assertEquals(remainingMessageCount, 1);

  // Remove the first handler object
  emitter.off(handlersToRemove);

  // Test that only the remaining message handler works
  await emitter.emit("message", "test2");
  await emitter.emit("count", 43);
  assertEquals(messageCount, 1); // Should not have incremented
  assertEquals(numberCount, 1); // Should not have incremented
  assertEquals(remainingMessageCount, 2); // Should have incremented
});

Deno.test("EventEmitter - off removes only the specified handler object", async () => {
  const emitter = new TestEventEmitter();
  let handler1MessageCount = 0;
  let handler1CountValue = 0;
  let handler2MessageCount = 0;
  let handler2CountValue = 0;

  const handlers1 = {
    message: () => {
      handler1MessageCount++;
    },
    count: (num: number) => {
      handler1CountValue = num;
    },
  };

  const handlers2 = {
    message: () => {
      handler2MessageCount++;
    },
    count: (num: number) => {
      handler2CountValue = num;
    },
  };

  emitter.on(handlers1);
  emitter.on(handlers2);

  // Test both handlers work initially
  await emitter.emit("message", "test1");
  await emitter.emit("count", 100);
  assertEquals(handler1MessageCount, 1);
  assertEquals(handler1CountValue, 100);
  assertEquals(handler2MessageCount, 1);
  assertEquals(handler2CountValue, 100);

  // Remove only handlers1
  emitter.off(handlers1);

  // Test that only handlers2 continues to work
  await emitter.emit("message", "test2");
  await emitter.emit("count", 200);
  assertEquals(handler1MessageCount, 1); // Should not have incremented
  assertEquals(handler1CountValue, 100); // Should not have changed
  assertEquals(handler2MessageCount, 2); // Should have incremented
  assertEquals(handler2CountValue, 200); // Should have changed
});

Deno.test("EventEmitter - no handlers for event", async () => {
  const emitter = new TestEventEmitter();

  // Should not throw when emitting to non-existent handlers
  await emitter.emit("message", "test");
  await emitter.emit("count", 42);

  // Test passes if no exception is thrown
  assert(true);
});


================================================
FILE: client/plugos/event.ts
================================================
/**
 * EventEmitter implementation, similar to the one used in CodeMirror
 */
export abstract class EventEmitter<HandlerT> {
  private handlers: Partial<HandlerT>[] = [];

  /**
   * Subscribe to events
   * @param handlers
   */
  on(handlers: Partial<HandlerT>) {
    this.handlers.push(handlers);
  }

  /**
   * Unsubscribe from events
   * @param handlers
   */
  off(handlers: Partial<HandlerT>) {
    this.handlers = this.handlers.filter((h) => h !== handlers);
  }

  /**
   * Broadcast an event to all subscribers
   * @param eventName
   * @param args
   */
  async emit(eventName: keyof HandlerT, ...args: any[]): Promise<void> {
    for (const handler of this.handlers) {
      const fn: any = handler[eventName];
      if (fn) {
        await Promise.resolve(fn(...args));
      }
    }
  }
}


================================================
FILE: client/plugos/eventhook.ts
================================================
import type { EventHookT } from "@silverbulletmd/silverbullet/type/manifest";
import type { Hook } from "./types.ts";

export interface EventHookI extends Hook<EventHookT> {
  dispatchEvent(eventName: string, ...args: unknown[]): Promise<unknown[]>;

  listEvents(): string[];
}


================================================
FILE: client/plugos/hooks/code_widget.ts
================================================
import type { Hook, Manifest } from "../types.ts";
import type { System } from "../system.ts";
import type { CodeWidgetT } from "@silverbulletmd/silverbullet/type/manifest";
import type { CodeWidgetCallback } from "@silverbulletmd/silverbullet/type/client";

export class CodeWidgetHook implements Hook<CodeWidgetT> {
  codeWidgetCallbacks = new Map<string, CodeWidgetCallback>();
  codeWidgetModes = new Map<string, "markdown" | "iframe">();

  constructor() {
  }

  collectAllCodeWidgets(system: System<CodeWidgetT>) {
    this.codeWidgetCallbacks.clear();
    for (const plug of system.loadedPlugs.values()) {
      for (
        const [name, functionDef] of Object.entries(
          plug.manifest!.functions,
        )
      ) {
        if (!functionDef.codeWidget) {
          continue;
        }
        this.codeWidgetModes.set(
          functionDef.codeWidget,
          functionDef.renderMode || "iframe",
        );
        this.codeWidgetCallbacks.set(
          functionDef.codeWidget,
          (bodyText, pageName) => {
            return plug.invoke(name, [bodyText, pageName]);
          },
        );
      }
    }
  }

  apply(system: System<CodeWidgetT>): void {
    this.collectAllCodeWidgets(system);
    system.on({
      plugLoaded: () => {
        this.collectAllCodeWidgets(system);
      },
    });
  }

  validateManifest(manifest: Manifest<CodeWidgetT>): string[] {
    const errors = [];
    for (const functionDef of Object.values(manifest.functions)) {
      if (!functionDef.codeWidget) {
        continue;
      }
      if (typeof functionDef.codeWidget !== "string") {
        errors.push(`Codewidgets require a string name.`);
      }
    }
    return errors;
  }
}


================================================
FILE: client/plugos/hooks/command.ts
================================================
import type { Hook, Manifest } from "../types.ts";
import type { System } from "../system.ts";
import { EventEmitter } from "../event.ts";
import { throttle } from "@silverbulletmd/silverbullet/lib/async";
import type { Command, CommandHookEvents } from "../../types/command.ts";
import type { CommandHookT } from "@silverbulletmd/silverbullet/type/manifest";

export class CommandHook extends EventEmitter<CommandHookEvents>
  implements Hook<CommandHookT> {
  system?: System<CommandHookT>;
  public throttledBuildAllCommandsAndEmit = throttle(() => {
    this.buildAllCommandsAndEmit();
  }, 200);

  constructor(
    private readOnly: boolean,
    private additionalCommands: Map<string, Command>,
  ) {
    super();
  }

  /**
   * Build the command map
   */
  buildAllCommands(): Map<string, Command> {
    const commands = new Map<string, Command>();
    // Add commands from plugs
    if (!this.system) {
      // Not initialized yet
      return commands;
    }
    for (const plug of this.system.loadedPlugs.values()) {
      for (
        const [name, functionDef] of Object.entries(
          plug.manifest!.functions,
        )
      ) {
        if (!functionDef.command) {
          continue;
        }
        const cmd = functionDef.command;
        if (cmd.requireMode === "rw" && this.readOnly) {
          // Bit hacky, but don't expose commands that require write mode in read-only mode
          continue;
        }
        commands.set(cmd.name, {
          ...cmd,
          run: (args?: string[]) => {
            return plug.invoke(name, [cmd, ...args ?? []]);
          },
        });
      }
    }
    for (const [name, cmd] of this.additionalCommands) {
      if (commands.has(name)) {
        // Existing command, let's do some inline patching
        const existingCommand = commands.get(name)!;
        const command: Command = {
          ...existingCommand,
          ...cmd,
        };
        if (cmd.run) {
          command.run = cmd.run;
        }
        commands.set(name, command);
      } else {
        // New command, let's just set
        commands.set(name, cmd);
      }
    }
    return commands;
  }

  public buildAllCommandsAndEmit() {
    this.emit("commandsUpdated", this.buildAllCommands());
  }

  apply(system: System<CommandHookT>): void {
    this.system = system;
    system.on({
      plugLoaded: () => {
        this.throttledBuildAllCommandsAndEmit();
      },
    });
    // On next tick
    setTimeout(() => {
      this.throttledBuildAllCommandsAndEmit();
    });
  }

  validateManifest(manifest: Manifest<CommandHookT>): string[] {
    const errors = [];
    for (const [name, functionDef] of Object.entries(manifest.functions)) {
      if (!functionDef.command) {
        continue;
      }
      const cmd = functionDef.command;
      if (!cmd.name) {
        errors.push(`Function ${name} has a command but no name`);
      }
    }
    return [];
  }
}


================================================
FILE: client/plugos/hooks/document_editor.ts
================================================
import type { Hook, Manifest } from "../types.ts";
import type { DocumentEditorT } from "@silverbulletmd/silverbullet/type/manifest";
import type { System } from "../system.ts";
import type { DocumentEditorCallback } from "@silverbulletmd/silverbullet/type/client";

export class DocumentEditorHook implements Hook<DocumentEditorT> {
  documentEditors = new Map<
    string,
    { extensions: string[]; callback: DocumentEditorCallback }
  >();

  constructor() {
  }

  collectAllDocumentEditors(system: System<DocumentEditorT>) {
    this.documentEditors.clear();
    for (const plug of system.loadedPlugs.values()) {
      for (
        const [name, functionDef] of Object.entries(
          plug.manifest!.functions,
        )
      ) {
        if (!functionDef.editor) {
          continue;
        }

        const keys = Array.isArray(functionDef.editor)
          ? functionDef.editor
          : [functionDef.editor];

        const conflict = Array.from(this.documentEditors.entries()).find((
          [_, { extensions }],
        ) => keys.some((key) => extensions.includes(key)));

        if (conflict) {
          console.log(
            `Extension definition of document editor ${name}: [${keys}] conflicts with the one from ${
              conflict[0]
            }: [${conflict[1].extensions}]! Using the latter.`,
          );
        }

        this.documentEditors.set(
          name,
          { extensions: keys, callback: () => plug.invoke(name, []) },
        );
      }
    }
  }

  apply(system: System<DocumentEditorT>): void {
    this.collectAllDocumentEditors(system);
    system.on({
      plugLoaded: () => {
        this.collectAllDocumentEditors(system);
      },
    });
  }

  validateManifest(manifest: Manifest<DocumentEditorT>): string[] {
    const errors = [];
    for (const functionDef of Object.values(manifest.functions)) {
      if (!functionDef.editor) {
        continue;
      }
      if (
        typeof functionDef.editor !== "string" &&
        !Array.isArray(functionDef.editor)
      ) {
        errors.push(
          `Document editors require a string name or an array of string names.`,
        );
      }
    }
    return errors;
  }
}


================================================
FILE: client/plugos/hooks/event.ts
================================================
// deno-lint-ignore-file ban-types
import type { Manifest } from "../types.ts";
import type { System } from "../system.ts";
import type { EventHookI } from "../eventhook.ts";
import type { EventHookT } from "@silverbulletmd/silverbullet/type/manifest";
import type { Config } from "../../config.ts";

// System events:
// - plug:load (plugName: string)

export class EventHook implements EventHookI {
  private system?: System<EventHookT>;
  private localListeners: Map<string, ((...args: any[]) => any)[]> = new Map();

  constructor(readonly config?: Config) {
  }

  addLocalListener(eventName: string, callback: (...args: any[]) => any) {
    if (!this.localListeners.has(eventName)) {
      this.localListeners.set(eventName, []);
    }
    this.localListeners.get(eventName)!.push(callback);
  }

  removeLocalListener(eventName: string, callback: (...args: any[]) => any) {
    if (!this.localListeners.has(eventName)) {
      return;
    }
    const listeners = this.localListeners.get(eventName)!;
    const index = listeners.indexOf(callback);
    if (index !== -1) {
      listeners.splice(index, 1);
    }
    if (listeners.length === 0) {
      this.localListeners.delete(eventName);
    }
  }

  // Pull all events listened to
  listEvents(): string[] {
    if (!this.system) {
      throw new Error("Event hook is not initialized");
    }
    const eventNames = new Set<string>();
    for (const plug of this.system.loadedPlugs.values()) {
      for (const functionDef of Object.values(plug.manifest!.functions)) {
        if (functionDef.events) {
          for (const eventName of functionDef.events) {
            eventNames.add(eventName);
          }
        }
      }
    }
    for (const eventName of this.localListeners.keys()) {
      eventNames.add(eventName);
    }
    if (this.config) {
      const configListeners: Record<string, Function[]> = this.config.get(
        "eventListeners",
        {},
      );
      for (const name of Object.keys(configListeners)) {
        eventNames.add(name);
      }
    }

    return [...eventNames];
  }

  async dispatchEvent(eventName: string, ...args: any[]): Promise<any[]> {
    if (!this.system) {
      throw new Error("Event hook is not initialized");
    }
    const promises: Promise<any>[] = [];
    for (const plug of this.system.loadedPlugs.values()) {
      const manifest = plug.manifest;
      for (
        const [name, functionDef] of Object.entries(
          manifest!.functions,
        )
      ) {
        if (functionDef.events) {
          for (const event of functionDef.events) {
            if (
              event === eventName || eventNameToRegex(event).test(eventName)
            ) {
              // Only dispatch functions that can run in this environment
              if (plug.canInvoke(name)) {
                // Queue the promise
                promises.push((async () => {
                  try {
                    return await plug.invoke(name, args);
                  } catch (e: any) {
                    console.error(
                      `Error dispatching event ${eventName} to ${plug.manifest.name}.${name}: ${e.message}`,
                    );
                    throw e;
                  }
                })());
              }
            }
          }
        }
      }
    }

    // Local listeners
    for (const [name, localListeners] of this.localListeners) {
      if (eventNameToRegex(name).test(eventName)) {
        for (const localListener of localListeners) {
          // Queue the promise
          promises.push((async () => {
            return await Promise.resolve(localListener(...args));
          })());
        }
      }
    }

    // Space Lua listeners
    if (this.config) {
      const configListeners: Record<string, Function[]> = this.config.get(
        "eventListeners",
        {},
      );
      for (const [name, listeners] of Object.entries(configListeners)) {
        if (eventNameToRegex(name).test(eventName)) {
          for (const listener of listeners) {
            promises.push((async () => {
              return await Promise.resolve(
                listener({
                  name: eventName,
                  // Most events have a single argument, so let's optimize for that, otherwise pass all arguments as an array
                  data: args.length === 1 ? args[0] : args,
                }),
              );
            })());
          }
        }
      }
    }

    // Wait for all promises to resolve
    return (await Promise.allSettled(promises))
      .filter((result) => {
        if (result.status === "rejected") {
          console.error(
            "Error while dispatching event",
            eventName,
            ":",
            result.reason,
          );
        }
        return result.status === "fulfilled";
      })
      .map((result) => result.value)
      .filter((result) => result != null); // This keeps non-null/undefined results
  }

  apply(system: System<EventHookT>): void {
    this.system = system;
    this.system.on({
      plugLoaded: async (plug) => {
        await this.dispatchEvent("plug:load", plug.manifest.name);
      },
    });
  }

  validateManifest(manifest: Manifest<EventHookT>): string[] {
    const errors = [];
    for (
      const [_, functionDef] of Object.entries(
        manifest.functions || {},
      )
    ) {
      if (functionDef.events && !Array.isArray(functionDef.events)) {
        errors.push("'events' key must be an array of strings");
      }
    }
    return errors;
  }
}

function eventNameToRegex(eventName: string): RegExp {
  return new RegExp(
    `^${eventName.replace(/\*/g, ".*").replace(/\//g, "\\/")}$`,
  );
}


================================================
FILE: client/plugos/hooks/mq.ts
================================================
// deno-lint-ignore-file ban-types
import type { Hook, Manifest } from "../types.ts";
import type { System } from "../system.ts";
import { throttle } from "@silverbulletmd/silverbullet/lib/async";
import type { MQHookT } from "@silverbulletmd/silverbullet/type/manifest";
import type { DataStoreMQ, QueueWorker } from "../../data/mq.datastore.ts";
import type {
  MQMessage,
  MQSubscribeOptions,
} from "@silverbulletmd/silverbullet/type/datastore";
import type { Config } from "../../config.ts";

export type MQListenerSpec =
  & MQSubscribeOptions
  & {
    queue: string;
    autoAck?: boolean;
    run: Function;
  };

export class MQHook implements Hook<MQHookT> {
  subscriptions: QueueWorker[] = [];
  throttledReloadQueues = throttle(() => {
    this.reloadQueues();
  }, 1000);

  constructor(
    private system: System<MQHookT>,
    readonly mq: DataStoreMQ,
    readonly config: Config,
  ) {
  }

  apply(system: System<MQHookT>): void {
    this.system = system;
    system.on({
      plugLoaded: () => {
        this.throttledReloadQueues();
      },
      plugUnloaded: () => {
        this.throttledReloadQueues();
      },
    });

    this.throttledReloadQueues();
  }

  stop() {
    this.subscriptions.forEach((worker) => worker.stop());
    this.subscriptions = [];
  }

  reloadQueues() {
    this.stop();
    // Plug based subscriptions
    for (const plug of this.system.loadedPlugs.values()) {
      if (!plug.manifest) {
        continue;
      }
      for (
        const [name, functionDef] of Object.entries(
          plug.manifest.functions,
        )
      ) {
        if (!functionDef.mqSubscriptions) {
          continue;
        }
        const subscriptions = functionDef.mqSubscriptions;
        for (const subscriptionDef of subscriptions) {
          const queue = subscriptionDef.queue;
          this.subscriptions.push(
            this.mq.subscribe(
              queue,
              {
                batchSize: subscriptionDef.batchSize,
                pollInterval: subscriptionDef.pollInterval,
              },
              async (messages: MQMessage[]) => {
                try {
                  await plug.invoke(name, [messages]);
                  if (subscriptionDef.autoAck) {
                    await this.mq.batchAck(queue, messages.map((m) => m.id));
                  }
                } catch (e: any) {
                  console.error(
                    "Execution of mqSubscription for queue",
                    queue,
                    "invoking",
                    name,
                    "with messages",
                    messages,
                    "failed:",
                    e,
                  );
                }
              },
            ),
          );
        }
      }
    }
    // Space Lua based subscriptions
    const configListeners: Record<string, MQListenerSpec[]> = this.config.get(
      "mqSubscriptions",
      {},
    );
    for (const [queue, listeners] of Object.entries(configListeners)) {
      for (const listener of listeners) {
        // console.log("Subscribing to", queue, listener);
        this.subscriptions.push(
          this.mq.subscribe(
            queue,
            {
              batchSize: listener.batchSize,
              pollInterval: listener.pollInterval,
            },
            async (messages: MQMessage[]) => {
              try {
                await listener.run(messages);
                if (listener.autoAck) {
                  await this.mq.batchAck(queue, messages.map((m) => m.id));
                }
              } catch (e: any) {
                console.error(
                  "Execution of mqSubscription for queue",
                  queue,
                  "with messages",
                  messages,
                  "failed:",
                  e,
                );
              }
            },
          ),
        );
      }
    }
  }

  validateManifest(manifest: Manifest<MQHookT>): string[] {
    const errors: string[] = [];
    for (const functionDef of Object.values(manifest.functions)) {
      if (!functionDef.mqSubscriptions) {
        continue;
      }
      for (const subscriptionDef of functionDef.mqSubscriptions) {
        if (!subscriptionDef.queue) {
          errors.push("Missing queue name for mqSubscription");
        }
      }
    }
    return errors;
  }
}


================================================
FILE: client/plugos/hooks/plug_namespace.ts
================================================
import type { NamespaceOperation } from "@silverbulletmd/silverbullet/type/namespace";
import type { PlugNamespaceHookT } from "@silverbulletmd/silverbullet/type/manifest";
import type { Plug } from "../plug.ts";
import type { System } from "../system.ts";
import type { Hook, Manifest } from "../types.ts";

type SpaceFunction = {
  operation: NamespaceOperation;
  pattern: RegExp;
  plug: Plug<PlugNamespaceHookT>;
  name: string;
  env?: string;
};

export class PlugNamespaceHook implements Hook<PlugNamespaceHookT> {
  spaceFunctions: SpaceFunction[] = [];

  constructor() {
  }

  apply(system: System<PlugNamespaceHookT>): void {
    system.on({
      plugLoaded: () => {
        this.updateCache(system);
      },
      plugUnloaded: () => {
        this.updateCache(system);
      },
    });
  }

  updateCache(system: System<PlugNamespaceHookT>) {
    this.spaceFunctions = [];
    for (const plug of system.loadedPlugs.values()) {
      if (plug.manifest?.functions) {
        for (
          const [funcName, funcDef] of Object.entries(
            plug.manifest.functions,
          )
        ) {
          if (funcDef.pageNamespace) {
            this.spaceFunctions.push({
              operation: funcDef.pageNamespace.operation,
              pattern: new RegExp(funcDef.pageNamespace.pattern),
              plug,
              name: funcName,
              env: funcDef.env,
            });
          }
        }
      }
    }
  }

  validateManifest(manifest: Manifest<PlugNamespaceHookT>): string[] {
    const errors: string[] = [];
    if (!manifest.functions) {
      return [];
    }
    for (const [funcName, funcDef] of Object.entries(manifest.functions)) {
      if (funcDef.pageNamespace) {
        if (!funcDef.pageNamespace.pattern) {
          errors.push(`Function ${funcName} has a namespace but no pattern`);
        }
        if (!funcDef.pageNamespace.operation) {
          errors.push(`Function ${funcName} has a namespace but no operation`);
        }
        if (
          ![
            "readFile",
            "writeFile",
            "getFileMeta",
            "listFiles",
            "deleteFile",
          ].includes(funcDef.pageNamespace.operation)
        ) {
          errors.push(
            `Function ${funcName} has an invalid operation ${funcDef.pageNamespace.operation}`,
          );
        }
      }
    }
    return errors;
  }
}


================================================
FILE: client/plugos/hooks/slash_command.ts
================================================
import type { Hook, Manifest } from "../types.ts";
import type { System } from "../system.ts";
import type {
  Completion,
  CompletionContext,
  CompletionResult,
} from "@codemirror/autocomplete";
import type { Client } from "../../client.ts";
import { syntaxTree } from "@codemirror/language";
import { safeRun, throttle } from "@silverbulletmd/silverbullet/lib/async";
import type { SlashCommandHookT } from "@silverbulletmd/silverbullet/type/manifest";
import type { SlashCommand } from "../../types/command.ts";
import type {
  SlashCompletionOption,
  SlashCompletions,
} from "@silverbulletmd/silverbullet/type/client";

const slashCommandRegexp = /([^\w:]|^)\/[\w#\-]*/;

export class SlashCommandHook implements Hook<SlashCommandHookT> {
  slashCommands: SlashCommand[] = [];
  throttledBuildAllCommands = throttle(() => {
    this.buildAllCommands();
  }, 200);

  constructor(private client: Client) {
  }

  buildAllCommands() {
    const clientSystem = this.client.clientSystem;
    const system = clientSystem.system;

    this.slashCommands = [];
    for (const plug of system.loadedPlugs.values()) {
      for (
        const [name, functionDef] of Object.entries(
          plug.manifest!.functions,
        )
      ) {
        if (!functionDef.slashCommand) {
          continue;
        }
        const cmd = functionDef.slashCommand;
        this.slashCommands.push({
          ...cmd,
          run: () => {
            return plug.invoke(name, [cmd]);
          },
        });
      }
    }
    // Iterate over script defined slash commands
    for (
      const command of Object.values(
        this.client.config.get<Record<string, SlashCommand>>(
          "slashCommands",
          {},
        ),
      )
    ) {
      this.slashCommands.push(command);
    }
  }

  // Completer for CodeMirror
  public async slashCommandCompleter(
    ctx: CompletionContext,
  ): Promise<CompletionResult | null> {
    const prefix = ctx.matchBefore(slashCommandRegexp);
    if (!prefix) {
      return null;
    }
    const prefixText = prefix.text;
    const options: Completion[] = [];

    // No slash commands in comment blocks (queries and such) or links
    const currentNode = syntaxTree(ctx.state).resolveInner(ctx.pos);
    if (
      currentNode.type.name === "CommentBlock" ||
      currentNode.type.name === "Link"
    ) {
      return null;
    }

    // Check if the slash command is available in the current context
    const parentNodes = this.client.extractParentNodes(ctx.state, currentNode);
    for (const def of this.slashCommands) {
      if (
        def.onlyContexts &&
        !def.onlyContexts.some((context) =>
          parentNodes.some((node) => node.startsWith(context))
        )
      ) {
        continue;
      }
      if (
        def.exceptContexts && def.exceptContexts.some(
          (context) => parentNodes.some((node) => node.startsWith(context)),
        )
      ) {
        continue;
      }
      options.push({
        label: def.name,
        detail: def.description,
        boost: def.priority,
        apply: () => {
          // Delete slash command part
          this.client.editorView.dispatch({
            changes: {
              from: prefix!.from + prefixText.indexOf("/"),
              to: ctx.pos,
              insert: "",
            },
          });
          // Replace with whatever the completion is
          safeRun(async () => {
            await def.run!();
            this.client.focus();
          });
        },
      });
    }

    const slashCompletions: CompletionResult | SlashCompletions | null =
      await this.client
        .completeWithEvent(
          ctx,
          "slash:complete",
        );

    if (slashCompletions) {
      for (
        const slashCompletion of slashCompletions
          .options as SlashCompletionOption[]
      ) {
        options.push({
          label: slashCompletion.label,
          detail: slashCompletion.detail,
          boost: slashCompletion.order && -slashCompletion.order,
          apply: () => {
            // Delete slash command part
            this.client.editorView.dispatch({
              changes: {
                from: prefix!.from + prefixText.indexOf("/"),
                to: ctx.pos,
                insert: "",
              },
            });
            // Replace with whatever the completion is
            safeRun(async () => {
              await this.client.clientSystem.system.invokeFunction(
                slashCompletion.invoke,
                [slashCompletion],
              );
              this.client.focus();
            });
          },
        });
      }
    }

    return {
      // + 1 because of the '/'
      from: prefix.from + prefixText.indexOf("/") + 1,
      options: options,
    };
  }

  apply(system: System<SlashCommandHookT>): void {
    this.buildAllCommands();
    system.on({
      plugLoaded: () => {
        this.buildAllCommands();
      },
    });
  }

  validateManifest(manifest: Manifest<SlashCommandHookT>): string[] {
    const errors = [];
    for (const [name, functionDef] of Object.entries(manifest.functions)) {
      if (!functionDef.slashCommand) {
        continue;
      }
      const cmd = functionDef.slashCommand;
      if (!cmd.name) {
        errors.push(`Function ${name} has a command but no name`);
      }
    }
    return [];
  }
}


================================================
FILE: client/plugos/hooks/syscall.ts
================================================
import type { Hook, Manifest } from "../types.ts";
import type { SysCallMapping, System } from "../system.ts";
import type { SyscallHookT } from "@silverbulletmd/silverbullet/type/manifest";

export class SyscallHook implements Hook<SyscallHookT> {
  apply(system: System<SyscallHookT>): void {
    this.registerSyscalls(system);
    system.on({
      plugLoaded: () => {
        this.registerSyscalls(system);
      },
    });
  }

  registerSyscalls(system: System<SyscallHookT>) {
    // Register syscalls from all loaded plugs
    for (const plug of system.loadedPlugs.values()) {
      const syscalls: SysCallMapping = {};

      for (
        const [name, functionDef] of Object.entries(plug.manifest!.functions)
      ) {
        if (!functionDef.syscall) {
          continue;
        }

        const syscallName = functionDef.syscall;

        // Add the syscall to our mapping
        syscalls[syscallName] = (ctx, ...args) => {
          // Delegate to the system to invoke the function
          return system.syscall(ctx, "system.invokeFunction", [
            `${plug.manifest!.name}.${name}`,
            ...args,
          ]);
        };

        // Register the syscalls with no required permissions
        system.registerSyscalls([], syscalls);
      }
    }
  }

  validateManifest(manifest: Manifest<SyscallHookT>): string[] {
    const errors: string[] = [];
    for (const [name, functionDef] of Object.entries(manifest.functions)) {
      if (!functionDef.syscall) {
        continue;
      }

      // Validate syscall name is provided
      if (!functionDef.syscall) {
        errors.push(`Function ${name} has a syscall but no name`);
        continue;
      }

      // Validate syscall name format (should be namespaced)
      if (!functionDef.syscall.includes(".")) {
        errors.push(
          `Function ${name} has invalid syscall name "${functionDef.syscall}" - must be in format "namespace.name"`,
        );
      }
    }
    return errors;
  }
}


================================================
FILE: client/plugos/manifest_cache.ts
================================================
import type { KvPrimitives } from "../data/kv_primitives.ts";
import type { Plug } from "./plug.ts";
import type { Manifest } from "./types.ts";

export interface ManifestCache<T> {
  getManifest(
    plug: Plug<T>,
    cacheKey: string,
    cacheHash: number,
  ): Promise<Manifest<T>>;
}

export class KVPrimitivesManifestCache<T> implements ManifestCache<T> {
  constructor(private kv: KvPrimitives, private manifestPrefix: string) {
  }

  async getManifest(
    plug: Plug<T>,
    cacheKey: string,
    cacheHash: number,
  ): Promise<Manifest<T>> {
    const [cached] = await this.kv.batchGet([[
      this.manifestPrefix,
      cacheKey,
    ]]);
    if (cached && cached.hash === cacheHash) {
      // console.log("Using KV cached manifest for", plug.name);
      return cached.manifest;
    }
    await plug.sandbox.init();
    const manifest = plug.sandbox.manifest!;
    await this.kv.batchSet([{
      key: [this.manifestPrefix, cacheKey],
      // Deliverately removing the assets from the manifest to preserve space, will be re-added upon load of actual worker
      value: { manifest: { ...manifest, assets: undefined }, hash: cacheHash },
    }]);
    return manifest;
  }
}

export class InMemoryManifestCache<T> implements ManifestCache<T> {
  private cache = new Map<string, {
    manifest: Manifest<T>;
    hash: number;
  }>();

  async getManifest(
    plug: Plug<T>,
    cacheKey: string,
    cacheHash: number,
  ): Promise<Manifest<T>> {
    const cached = this.cache.get(cacheKey);
    if (cached && cached.hash === cacheHash) {
      // console.log("Using memory cached manifest for", plug.name);
      return cached.manifest;
    }
    await plug.sandbox.init();
    const manifest = plug.sandbox.manifest!;

    // Deliverately removing the assets from the manifest to preserve space, will be re-added upon load of actual worker
    this.cache.set(cacheKey, {
      manifest: { ...manifest, assets: undefined },
      hash: cacheHash,
    });
    return manifest;
  }
}


================================================
FILE: client/plugos/plug.ts
================================================
import type { Manifest } from "./types.ts";
import type { System } from "./system.ts";
import type { AssetBundle } from "../asset_bundle/bundle.ts";
import type { Sandbox, SandboxFactory } from "./sandboxes/sandbox.ts";

export class Plug<HookT> {
  readonly runtimeEnv?: string;

  public grantedPermissions: string[] = [];
  public sandbox: Sandbox<HookT>;

  public manifest!: Manifest<HookT>;
  public assets?: AssetBundle;

  constructor(
    readonly system: System<HookT>,
    sandboxFactory: SandboxFactory<HookT>,
  ) {
    this.runtimeEnv = system.env;
    this.sandbox = sandboxFactory(this);
  }

  static async createLazily<HookT>(
    system: System<HookT>,
    cacheKey: string,
    cacheHash: number,
    sandboxFactory: SandboxFactory<HookT>,
  ): Promise<Plug<HookT>> {
    const plug = new Plug(
      system,
      sandboxFactory,
    );

    // Retrieve the manifest, which may either come from a cache or be loaded from the worker
    plug.manifest = await system.options.manifestCache!.getManifest(
      plug,
      cacheKey,
      cacheHash,
    );

    // TODO: These need to be explicitly granted, not just taken
    plug.grantedPermissions = plug.manifest.requiredPermissions || [];

    return plug;
  }

  // Invoke a syscall
  syscall(name: string, args: any[]): Promise<any> {
    return this.system.syscall({ plug: this.manifest.name }, name, args);
  }

  /**
   * Checks if a function can be invoked (it may be restricted on its execution environment)
   */
  canInvoke(name: string) {
    const funDef = this.manifest!.functions[name];
    if (!funDef) {
      throw new Error(`Function ${name} not found in manifest`);
    }
    return !funDef.env || !this.runtimeEnv || funDef.env === this.runtimeEnv;
  }

  // Invoke a function
  async invoke(name: string, args: any[]): Promise<any> {
    // Before we access the manifest
    const funDef = this.manifest!.functions[name];
    if (!funDef) {
      throw new Error(`Function ${name} not found in manifest`);
    }
    const sandbox = this.sandbox!;
    if (funDef.redirect) {
      // Function redirect, look up
      // deno-lint-ignore no-this-alias
      let plug: Plug<HookT> | undefined = this;
      if (funDef.redirect.indexOf(".") !== -1) {
        const [plugName, functionName] = funDef.redirect.split(".");
        plug = this.system.loadedPlugs.get(plugName);
        if (!plug) {
          throw Error(`Plug ${plugName} redirected to not found`);
        }
        name = functionName;
      } else {
        name = funDef.redirect;
      }
      return plug.invoke(name, args);
    }
    if (!await this.canInvoke(name)) {
      throw new Error(
        `Function ${name} is not available in ${this.runtimeEnv}`,
      );
    }
    return sandbox.invoke(name, args);
  }

  stop() {
    console.log("Stopping sandbox for", this.manifest.name);
    this.sandbox.stop();
  }
}


================================================
FILE: client/plugos/plug_compile.ts
================================================
import * as path from "@std/path";
import * as YAML from "@std/yaml";
import { denoPlugin, esbuild } from "../../build_deps.ts";
import { bundleAssets } from "../asset_bundle/builder.ts";
import type { Manifest } from "./types.ts";
import { version } from "../../version.ts";

// const workerRuntimeUrl = new URL(
//   "../lib/plugos/worker_runtime.ts",
//   import.meta.url,
// );
const workerRuntimeUrl =
  `https://deno.land/x/silverbullet@${version}/client/plugos/worker_runtime.ts`;

export type CompileOptions = {
  debug?: boolean;
  runtimeUrl?: string;
  // path to config file
  configPath?: string;
  // Print info on bundle size
  info?: boolean;
};

export async function compileManifest(
  manifestPath: string,
  destPath: string,
  options: CompileOptions = {},
): Promise<string> {
  const rootPath = path.dirname(manifestPath);
  const manifest = YAML.parse(
    await Deno.readTextFile(manifestPath),
  ) as Manifest<any>;

  if (!manifest.name) {
    throw new Error(`Missing 'name' in ${manifestPath}`);
  }

  // Assets
  const assetsBundle = await bundleAssets(
    path.resolve(rootPath),
    manifest.assets as string[] || [],
  );
  manifest.assets = assetsBundle.toJSON();

  // Normalize the edge case of a plug with no functions
  if (!manifest.functions) {
    manifest.functions = {};
  }

  const jsFile = `
import { setupMessageListener } from "${
    options.runtimeUrl || workerRuntimeUrl
  }";

// Imports
${
    Object.entries(manifest.functions).map(([funcName, def]) => {
      if (!def.path) {
        return "";
      }
      let [filePath, jsFunctionName] = def.path.split(":");
      // Resolve path
      filePath = path.join(rootPath, filePath);

      return `import {${jsFunctionName} as ${funcName}} from "file://${
        // Replacing \ with / for Windows
        path.resolve(filePath).replaceAll(
          "\\",
          "\\\\",
        )}";\n`;
    }).join("")
  }

// Function mapping
const functionMapping = {
${
    Object.entries(manifest.functions).map(([funcName, def]) => {
      if (!def.path) {
        return "";
      }
      return `  ${funcName}: ${funcName},\n`;
    }).join("")
  }
};

// Manifest
const manifest = ${JSON.stringify(manifest, null, 2)};

export const plug = {manifest, functionMapping};

setupMessageListener(functionMapping, manifest, self.postMessage);
`;

  // console.log("Code:", jsFile);

  const inFile = await Deno.makeTempFile({ suffix: ".js" });
  const outFile = `${destPath}/${manifest.name}.plug.js`;
  await Deno.writeTextFile(inFile, jsFile);

  const result = await esbuild.build({
    entryPoints: [inFile],
    bundle: true,
    format: "esm",
    globalName: "mod",
    platform: "browser",
    sourcemap: "linked",
    minify: !options.debug,
    outfile: outFile,
    metafile: options.info,
    treeShaking: true,
    plugins: [
      denoPlugin({
        configPath: options.configPath &&
          path.resolve(Deno.cwd(), options.configPath),
      }),
    ],
  });

  if (options.info) {
    const text = await esbuild.analyzeMetafile(result.metafile!);
    console.log("Bundle info for", manifestPath, text);
  }

  let jsCode = await Deno.readTextFile(outFile);
  jsCode = patchDenoLibJS(jsCode);
  await Deno.writeTextFile(outFile, jsCode);
  console.log(`Plug ${manifest.name} written to ${outFile}.`);
  return outFile;
}

export async function compileManifests(
  manifestFiles: string[],
  dist: string,
  options: CompileOptions = {},
) {
  let building = false;
  dist = path.resolve(dist);

  async function buildAll() {
    if (building) {
      return;
    }
    console.log("Building", manifestFiles);
    building = true;
    Deno.mkdirSync(dist, { recursive: true });
    const startTime = Date.now();
    // Build all plugs in parallel
    await Promise.all(manifestFiles.map(async (plugManifestPath) => {
      const manifestPath = plugManifestPath as string;
      try {
        await compileManifest(
          manifestPath,
          dist,
          options,
        );
      } catch (e: any) {
        console.error(`Error building ${manifestPath}:`, e.message);
        throw e;
      }
    }));
    console.log(`Done building plugs in ${Date.now() - startTime}ms`);
    building = false;
  }

  await buildAll();
}

export function patchDenoLibJS(code: string): string {
  // The Deno std lib has one occurence of a regex that Webkit JS doesn't (yet parse), we'll strip it because it's likely never invoked anyway, YOLO
  return code.replaceAll("/(?<=\\n)/", "/()/");
}

export async function plugCompileCommand(
  { dist, debug, info, config, runtimeUrl }: {
    dist: string;
    debug: boolean;
    info: boolean;
    config?: string;
    runtimeUrl?: string;
  },
  ...manifestPaths: string[]
) {
  await compileManifests(
    manifestPaths,
    dist,
    {
      debug: debug,
      info: info,
      runtimeUrl,
      configPath: config,
    },
  );
  esbuild.stop();
  Deno.exit(0);
}


================================================
FILE: client/plugos/protocol.ts
================================================
import type { Manifest } from "@silverbulletmd/silverbullet/type/manifest";

// Messages received from the worker
export type ControllerMessage =
  | {
    // Parsed manifest when worker is initialized
    type: "manifest";
    manifest: Manifest;
  }
  | {
    // Function invocation result
    type: "invr";
    id: number;
    error?: string;
    result?: any;
  }
  | {
    // Syscall
    type: "sys";
    id: number;
    name: string;
    args: any[];
  };

// Messages received inside the worker
export type WorkerMessage =
  | {
    // Function invocation
    type: "inv";
    id: number;
    name: string;
    args: any[];
  }
  | {
    // Syscall result
    type: "sysr";
    id: number;
    result?: any;
    error?: any;
  };


================================================
FILE: client/plugos/proxy_fetch.ts
================================================
import { base64Decode, base64Encode } from "../../plug-api/lib/crypto.ts";

export type ProxyFetchRequest64 = {
  method?: string;
  headers?: Record<string, string>;
  base64Body?: string;
};

export type ProxyFetchResponse64 = {
  ok: boolean;
  status: number;
  headers: Record<string, string>;
  // We base64 encode the body because the body can be binary data that we have to push through the worker boundary
  base64Body: string;
};

export type ProxyFetchRequest = {
  method?: string;
  headers?: Record<string, string>;
  responseEncoding?: string;
  body?: Uint8Array | string | any;
};

export type ProxyFetchResponse = {
  ok: boolean;
  status: number;
  headers: Record<string, string>;
  // We base64 encode the body because the body can be binary data that we have to push through the worker boundary
  body: Uint8Array | string | any;
};

export async function performLocalFetch(
  url: string,
  req: ProxyFetchRequest64,
): Promise<ProxyFetchResponse64> {
  const result = await fetch(
    url,
    req && {
      method: req.method,
      headers: req.headers,
      body: req.base64Body && base64Decode(req.base64Body),
      // Casting  to "any" for now, since of weird Deno typing
    } as any,
  );
  return {
    ok: result.ok,
    status: result.status,
    headers: Object.fromEntries(result.headers.entries()),
    base64Body: base64Encode(
      new Uint8Array(await (await result.blob()).arrayBuffer()),
    ),
  };
}


================================================
FILE: client/plugos/sandboxes/deno_worker_sandbox.ts
================================================
import { WorkerSandbox } from "./worker_sandbox.ts";
import type { SandboxFactory } from "./sandbox.ts";

export function createSandbox<HookT>(workerUrl: URL): SandboxFactory<HookT> {
  return (plug) => new WorkerSandbox(plug, workerUrl);
}


================================================
FILE: client/plugos/sandboxes/sandbox.ts
================================================
import type { Plug } from "../plug.ts";
import type { Manifest } from "../types.ts";

export type SandboxFactory<HookT> = (plug: Plug<HookT>) => Sandbox<HookT>;

export interface Sandbox<HookT> {
  manifest?: Manifest<HookT>;

  init(): Promise<void>;

  invoke(name: string, args: any[]): Promise<any>;

  stop(): void;
}


================================================
FILE: client/plugos/sandboxes/web_worker_sandbox.ts
================================================
import { WorkerSandbox } from "./worker_sandbox.ts";
import type { Plug } from "../plug.ts";
import type { SandboxFactory } from "./sandbox.ts";
import { fsEndpoint } from "../../spaces/constants.ts";

export function createWorkerSandboxFromLocalPath<HookT>(
  name: string,
): SandboxFactory<HookT> {
  return (plug: Plug<HookT>) =>
    new WorkerSandbox(
      plug,
      new URL(
        name,
        document.baseURI.slice(0, -1) + fsEndpoint + "/", // We're NOT striping trailing '/', this used to be `location.origin`
      ),
    );
}


================================================
FILE: client/plugos/sandboxes/worker_sandbox.ts
================================================
import type { Manifest } from "../types.ts";
import type { ControllerMessage, WorkerMessage } from "../protocol.ts";
import type { Plug } from "../plug.ts";
import { AssetBundle, type AssetJson } from "../../asset_bundle/bundle.ts";
import type { Sandbox } from "./sandbox.ts";
import { race, timeout } from "@silverbulletmd/silverbullet/lib/async";

/**
 * Represents a "safe" execution environment for plug code
 * Effectively this wraps a web worker, the reason to have this split from Plugs is to allow plugs to manage multiple sandboxes, e.g. for performance in the future
 */
export class WorkerSandbox<HookT> implements Sandbox<HookT> {
  public manifest?: Manifest<HookT>;
  private worker?: Worker;
  private reqId = 0;
  private outstandingInvocations = new Map<
    number,
    { resolve: (result: any) => void; reject: (e: any) => void }
  >();

  constructor(
    readonly plug: Plug<HookT>,
    public workerUrl: URL,
    private workerOptions = {},
  ) {
  }

  /**
   * Should only invoked lazily (either by invoke, or by a ManifestCache to load the manifest)
   */
  init(): Promise<void> {
    console.log("Booting up worker from", this.workerUrl.pathname);
    if (this.worker) {
      // Race condition
      console.warn("Double init of sandbox, ignoring");
      return Promise.resolve();
    }
    this.worker = new Worker(this.workerUrl, {
      ...this.workerOptions,
      type: "module",
    });

    return race([
      // We're adding a timeout of 5s here to handle the case where a plug blows up during initialization
      timeout(5000).catch((_) =>
        Promise.reject(new Error("Plug timed out during creation"))
      ),
      new Promise((resolve) => {
        this.worker!.onmessage = (ev) => {
          if (ev.data.type === "manifest") {
            this.manifest = ev.data.manifest;
            // Set manifest in the plug
            this.plug.manifest = this.manifest!;

            // Set assets in the plug
            this.plug.assets = new AssetBundle(
              this.manifest?.assets ? this.manifest.assets as AssetJson : {},
            );

            return resolve();
          }

          this.onMessage(ev.data);
        };
      }),
    ]);
  }

  async onMessage(data: ControllerMessage) {
    if (!this.worker) {
      console.warn("Received message for terminated worker, ignoring");
      return;
    }
    switch (data.type) {
      case "sys":
        try {
          const result = await this.plug.syscall(data.name!, data.args!);

          this.worker && this.worker!.postMessage({
            type: "sysr",
            id: data.id,
            result: result,
          } as WorkerMessage);
        } catch (e: any) {
          // console.error("Syscall fail", e);
          this.worker && this.worker!.postMessage({
            type: "sysr",
            id: data.id,
            error: e.message,
          } as WorkerMessage);
        }
        break;
      case "invr": {
        const resultCbs = this.outstandingInvocations.get(data.id!);
        this.outstandingInvocations.delete(data.id!);
        if (data.error) {
          resultCbs &&
            resultCbs.reject(new Error(data.error));
        } else {
          resultCbs && resultCbs.resolve(data.result);
        }
        break;
      }
      default:
        console.error("Unknown message type", data);
    }
  }

  async invoke(name: string, args: any[]): Promise<any> {
    if (!this.worker) {
      // Lazy initialization
      await this.init();
    }
    this.reqId++;
    this.worker!.postMessage({
      type: "inv",
      id: this.reqId,
      name,
      args,
    } as WorkerMessage);
    return new Promise((resolve, reject) => {
      this.outstandingInvocations.set(this.reqId, { resolve, reject });
    });
  }

  stop() {
    if (this.worker) {
      this.worker.terminate();
      this.worker = undefined;
    }
  }
}


================================================
FILE: client/plugos/syscalls/asset.ts
================================================
import type { SysCallMapping, System } from "../system.ts";
import type { FileMeta } from "../../../plug-api/types/index.ts";

export default function assetSyscalls(system: System<any>): SysCallMapping {
  return {
    "asset.readAsset": (_ctx, plugName: string, name: string): string => {
      return system.loadedPlugs.get(plugName)!.assets!.readFileAsDataUrl(
        name,
      );
    },
    "asset.listFiles": (_ctx, plugName: string): FileMeta[] => {
      const assets = system.loadedPlugs.get(plugName)!.assets!;
      const fileNames = assets.listFiles();
      return fileNames.map((name) => ({
        name,
        contentType: assets.getMimeType(name),
        created: assets.getMtime(name),
        lastModified: assets.getMtime(name),
        size: -1,
        perm: "ro",
      }));
    },
    "asset.getFileMeta": (_ctx, plugName: string, name: string): FileMeta => {
      const assets = system.loadedPlugs.get(plugName)!.assets!;
      return {
        name,
        contentType: assets.getMimeType(name),
        created: assets.getMtime(name),
        lastModified: assets.getMtime(name),
        size: -1,
        perm: "ro",
      };
    },
  };
}


================================================
FILE: client/plugos/syscalls/clientStore.ts
================================================
import type { SysCallMapping } from "../../plugos/system.ts";
import type { DataStore } from "../../data/datastore.ts";

import type { KvKey } from "@silverbulletmd/silverbullet/type/datastore";

export function clientStoreSyscalls(
  ds: DataStore,
  prefix: KvKey = ["client"],
): SysCallMapping {
  return {
    "clientStore.get": (_ctx, key: string): Promise<any> => {
      return ds.get([...prefix, key]);
    },
    "clientStore.set": (_ctx, key: string, val: any): Promise<void> => {
      return ds.set([...prefix, key], val);
    },
    "clientStore.delete": (_ctx, key: string): Promise<void> => {
      return ds.delete([...prefix, key]);
    },
  };
}


================================================
FILE: client/plugos/syscalls/client_code_widget.ts
================================================
import type { SysCallMapping } from "../../plugos/system.ts";
import { reloadAllWidgets } from "../../codemirror/code_widget.ts";
import { broadcastReload } from "../../components/widget_sandbox_iframe.ts";

export function clientCodeWidgetSyscalls(): SysCallMapping {
  return {
    "codeWidget.refreshAll": () => {
      broadcastReload();
      return reloadAllWidgets();
    },
  };
}


================================================
FILE: client/plugos/syscalls/code_widget.ts
================================================
import type { SysCallMapping } from "../system.ts";
import type { CodeWidgetHook } from "../hooks/code_widget.ts";
import type { CodeWidgetContent } from "@silverbulletmd/silverbullet/type/client";

export function codeWidgetSyscalls(
  codeWidgetHook: CodeWidgetHook,
): SysCallMapping {
  return {
    "codeWidget.render": (
      _ctx,
      lang: string,
      body: string,
      pageName: string,
    ): Promise<CodeWidgetContent | null> => {
      const langCallback = codeWidgetHook.codeWidgetCallbacks.get(
        lang,
      );
      if (!langCallback) {
        throw new Error(`Code widget ${lang} not found`);
      }
      return langCallback(body, pageName);
    },
  };
}


================================================
FILE: client/plugos/syscalls/config.ts
================================================
import type { SysCallMapping } from "../system.ts";
import type { Config } from "../../config.ts";
import { LuaStackFrame, luaValueToJS } from "../../space_lua/runtime.ts";

export function configSyscalls(config: Config): SysCallMapping {
  return {
    "config.get": (_ctx, path: string, defaultValue: any) => {
      return config.get(path, defaultValue);
    },
    "config.set": (
      _ctx,
      keyOrValues: string | string[] | Record<string, any>,
      value?: any,
    ) => {
      config.set(keyOrValues as any, value);
    },
    "lua:config.setLuaValue": async (
      _ctx,
      keyOrValues: string | string[] | Record<string, any>,
      value?: any,
    ) => {
      // This is for special cases where we explicitly want to NOT convert a value to a JS value, but maintain its Lua version (mostly for LuaTables) — main use case: metatable
      keyOrValues = await luaValueToJS(keyOrValues, LuaStackFrame.lostFrame);
      config.set(keyOrValues as any, value);
    },
    "config.insert": (
      _ctx,
      key: string | string[],
      value: any,
    ) => {
      config.insert(key, value);
    },
    "config.has": (_ctx, path: string) => {
      return config.has(path);
    },
    "config.define": (_ctx, key: string, schema: any) => {
      config.define(key, schema);
    },
    "config.getValues": () => {
      return config.values;
    },
    "config.getSchemas": () => {
      return config.schemas;
    },
  };
}


================================================
FILE: client/plugos/syscalls/datastore.ts
================================================
import {
  type LuaCollectionQuery,
  queryLua,
} from "../../space_lua/query_collection.ts";
import type { DataStore } from "../../data/datastore.ts";
import type { SysCallMapping } from "../system.ts";
import {
  jsToLuaValue,
  LuaEnv,
  LuaStackFrame,
  luaValueToJS,
} from "../../space_lua/runtime.ts";
import type { KvQueryOptions } from "../../data/kv_primitives.ts";
import type { ClientSystem } from "../../client_system.ts";

import type { KV, KvKey } from "@silverbulletmd/silverbullet/type/datastore";

/**
 * Exposes the datastore API to plugs, but scoping everything to a prefix based on the plug's name
 * @param ds the datastore to wrap
 * @param prefix prefix to scope all keys to to which the plug name will be appended
 */
export function dataStoreReadSyscalls(
  ds: DataStore,
  clientSystem: ClientSystem,
): SysCallMapping {
  return {
    "datastore.batchGet": (
      _ctx,
      keys: KvKey[],
    ): Promise<(any | undefined)[]> => {
      return ds.batchGet(keys);
    },

    "datastore.get": (_ctx, key: KvKey): Promise<any | null> => {
      return ds.get(key);
    },

    "datastore.query": async (_ctx, options: KvQueryOptions): Promise<KV[]> => {
      const results: KV[] = [];
      for await (const item of ds.query(options)) {
        results.push(item);
      }
      return results;
    },

    "datastore.queryLua": async (
      _ctx,
      prefix: string[],
      query: LuaCollectionQuery,
      scopeVariables?: Record<string, any>,
    ): Promise<any[]> => {
      const sf = LuaStackFrame.createWithGlobalEnv(
        clientSystem.spaceLuaEnv.env,
      );
      let env = clientSystem.spaceLuaEnv.env;
      if (scopeVariables) {
        env = new LuaEnv(clientSystem.spaceLuaEnv.env);
        for (const [key, value] of Object.entries(scopeVariables)) {
          env.setLocal(key, jsToLuaValue(value));
        }
      }
      return (await queryLua<any>(ds.kv, prefix, query, env, sf)).map((item) =>
        luaValueToJS(item, sf)
      );
    },
  };
}

export function dataStoreWriteSyscalls(ds: DataStore): SysCallMapping {
  return {
    "datastore.delete": (_ctx, key: KvKey) => {
      console.log("Deleting key", key);
      return ds.delete(key);
    },

    "datastore.set": (_ctx, key: KvKey, value: any) => {
      return ds.set(key, value);
    },

    "datastore.batchSet": (_ctx, kvs: KV[]) => {
      return ds.batchSet(kvs);
    },

    "datastore.batchDelete": (_ctx, keys: KvKey[]) => {
      return ds.batchDelete(keys);
    },
  };
}


================================================
FILE: client/plugos/syscalls/editor.ts
================================================
import type { Client } from "../../client.ts";
import {
  foldAll,
  foldCode,
  toggleFold,
  unfoldAll,
  unfoldCode,
} from "@codemirror/language";
import {
  deleteLine,
  insertNewline,
  insertNewlineAndIndent,
  moveLineDown,
  moveLineUp,
  redo,
  toggleComment,
  undo,
} from "@codemirror/commands";
import type { Transaction } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { getCM as vimGetCm, Vim } from "@replit/codemirror-vim";
import type { SysCallMapping } from "../system.ts";
import type {
  FilterOption,
  UploadFile,
} from "@silverbulletmd/silverbullet/type/client";
import { openSearchPanel } from "@codemirror/search";
import {
  isValidPath,
  parseToRef,
  type Path,
  type Ref,
} from "@silverbulletmd/silverbullet/lib/ref";
import { insertNewlineContinueMarkup } from "@codemirror/lang-markdown";
import type { VimConfig } from "@silverbulletmd/silverbullet/type/config";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";

export function editorSyscalls(client: Client): SysCallMapping {
  const syscalls: SysCallMapping = {
    "editor.getCurrentPage": (): string => {
      return client.currentName();
    },
    "editor.getCurrentPageMeta": (): Promise<PageMeta | undefined> => {
      const name = client.currentName();
      return client.objectIndex.getObjectByRef(name, "page", name);
    },
    "editor.getCurrentPath": (_ctx): string => {
      return client.currentPath();
    },
    "editor.getCurrentEditor": (): string => {
      return client.documentEditor?.name || "page";
    },
    "editor.getRecentlyOpenedPages": (): PageMeta[] => {
      return client.ui.viewState.allPages.sort((a, b) =>
        (b.lastOpened || 0) - (a.lastOpened || 0)
      );
    },
    "editor.getText": () => {
      return client.editorView.state.sliceDoc();
    },
    "editor.getCurrentLine": (): {
      from: number;
      to: number;
      text: string;
      textWithCursor: string;
    } => {
      const pos = client.editorView.state.selection.main.from;
      const line = client.editorView.state.doc.lineAt(pos);
      return {
        ...line,
        textWithCursor: line.text.slice(0, pos - line.from) + "|^|" +
          line.text.slice(pos - line.from),
      };
    },
    "editor.setText": (_ctx, newText: string, shouldIsolateHistory = false) => {
      client.setEditorText(newText, shouldIsolateHistory);
    },
    "editor.getCursor": (): number => {
      return client.editorView.state.selection.main.from;
    },
    "editor.getSelection": (): { from: number; to: number; text: string } => {
      const selection = client.editorView.state.selection.main;
      const text = client.editorView.state.sliceDoc(
        selection.from,
        selection.to,
      );
      return {
        from: selection.from,
        to: selection.to,
        text,
      };
    },
    "editor.save": () => {
      return client.save(true);
    },
    "editor.navigate": async (
      _ctx,
      ref: Ref | string,
      replaceState = false,
      newWindow = false,
    ) => {
      if (typeof ref === "string") {
        const parsedRef = parseToRef(ref);
        if (!parsedRef) {
          throw new Error(
            "Unable to parse string provided to `editor.navigate` as ref",
          );
        }
        ref = parsedRef;
      }

      if (
        // @ts-ignore: Legacy support
        ref.page !== undefined
      ) {
        console.warn(
          "You are using legacy navigation syntax with `editor.navigate()`, this will be phased out in the future",
        );

        const legacyRef = ref as unknown as {
          kind: "page" | "document";
          page: string;
          pos?: number | { line: number; column: number };
          header?: string;
          meta?: boolean;
        };

        legacyRef.kind ??= "page";

        let details: Ref["details"] = undefined;

        if (typeof legacyRef.pos === "number") {
          details = {
            type: "position",
            pos: legacyRef.pos,
          };
        } else if (legacyRef.pos) {
          details = {
            type: "linecolumn",
            line: legacyRef.pos.line,
            column: legacyRef.pos.column,
          };
        } else if (legacyRef.header) {
          details = {
            type: "header",
            header: legacyRef.header,
          };
        }

        ref = {
          path: (legacyRef.kind === "page"
            ? `${legacyRef.page}.md`
            : legacyRef.page) as Path,
          details,
          meta: legacyRef.meta,
        };
      }

      // This validation code should be connected to the Ref type. Ideally using
      // some validation library. Didn't want to use jsonschemas here tho.
      // Ideally this would be moved into a function too
      if (!isValidPath(ref.path) && ref.path !== "") {
        throw new Error(
          "Path passed in ref to `editor.navigate` is invalid",
        );
      } else if (typeof ref.meta !== "boolean" && ref.meta !== undefined) {
        throw new Error(
          "ref.meta has to be of type `boolean`",
        );
      } else if (ref.details !== undefined && typeof ref.details !== "object") {
        throw new Error(
          "ref.details has to be of type `object` or `undefined`",
        );
      } else if (
        ref.details &&
        !["position", "linecolumn", "header"].includes(ref.details.type)
      ) {
        throw new Error(
          "ref.details.type has to be 'position', 'linecolumn' or 'header'",
        );
      }

      if (
        ref.details?.type === "position" && typeof ref.details.pos !== "number"
      ) {
        throw new Error(
          "ref.details.pos has to be of type `number`",
        );
      } else if (
        ref.details?.type === "header" && typeof ref.details.header !== "string"
      ) {
        throw new Error(
          "ref.details.header has to be of type `string`",
        );
      } else if (
        ref.details?.type === "linecolumn" &&
        typeof ref.details.line !== "number" &&
        typeof ref.details.column !== "number"
      ) {
        throw new Error(
          "ref.details.line and ref.details.column has to be of type `number`",
        );
      }

      await client.navigate(ref, replaceState, newWindow);
    },
    "editor.reloadPage": async () => {
      await client.reloadEditor();
    },
    "editor.reloadUI": () => {
      location.reload();
    },
    "editor.rebuildEditorState": () => {
      client.rebuildEditorState();
    },
    "editor.reloadConfigAndCommands": async () => {
      await client.clientSystem.system.localSyscall(
        "system.loadScripts",
        [],
      );
      await client.clientSystem.system.localSyscall(
        "system.loadSpaceStyles",
        [],
      );
    },
    "editor.invokeCommand": (_ctx, name: string, args?: string[]) => {
      return client.runCommandByName(name, args);
    },
    "editor.openUrl": (_ctx, url: string, existingWindow = false) => {
      client.openUrl(url, existingWindow);
    },
    "editor.newWindow": () => {
      globalThis.open(
        location.href,
        "rnd" + Math.random(),
        `width=${globalThis.innerWidth},heigh=${globalThis.innerHeight}`,
      );
    },
    "editor.goHistory": (_ctx, delta: number) => {
      globalThis.history.go(delta);
    },
    "editor.downloadFile": (_ctx, filename: string, dataUrl: string) => {
      const link = document.createElement("a");
      link.href = dataUrl;
      link.download = filename;
      link.click();
    },
    "editor.uploadFile": (
      _ctx,
      accept?: string,
      capture?: string,
    ): Promise<UploadFile> => {
      return new Promise<UploadFile>((resolve, reject) => {
        const input = document.createElement("input");
        input.type = "file";
        if (accept) {
          input.accept = accept;
        }
        if (capture) {
          input.capture = capture;
        }

        input.onchange = () => {
          const file = input.files?.item(0);
          if (!file) {
            reject(new Error("No file found"));
          } else {
            const reader = new FileReader();
            reader.readAsArrayBuffer(file);
            reader.onloadend = async (evt) => {
              if (evt.target?.readyState == FileReader.DONE) {
                resolve({
                  name: file.name,
                  contentType: file.type,
                  content: new Uint8Array(await file.arrayBuffer()),
                });
              }
            };
            reader.onabort = (e) => {
              reject(e);
            };
            reader.onerror = (e) => {
              reject(e);
            };
          }
        };
        input.onabort = (e) => {
          reject(e);
        };

        input.click();
      });
    },
    "editor.flashNotification": (
      _ctx,
      message: string,
      type: "error" | "info" = "info",
    ) => {
      client.flashNotification(message, type);
    },
    "editor.filterBox": (
      _ctx,
      label: string,
      options: FilterOption[],
      helpText = "",
      placeHolder = "",
    ): Promise<FilterOption | undefined> => {
      return client.filterBox(label, options, helpText, placeHolder);
    },
    "editor.showPanel": (
      _ctx,
      id: string,
      mode: number,
      html: string,
      script: string,
    ) => {
      client.ui.viewDispatch({
        type: "show-panel",
        id: id as any,
        config: { html, script, mode },
      });
      setTimeout(() => {
        // Dummy dispatch to rerender the editor and toggle the panel
        client.editorView.dispatch({});
      });
    },
    "editor.hidePanel": (_ctx, id: string) => {
      client.ui.viewDispatch({
        type: "hide-panel",
        id: id as any,
      });
      setTimeout(() => {
        // Dummy dispatch to rerender the editor and toggle the panel
        client.editorView.dispatch({});
      });
    },
    "editor.showProgress": (
      _ctx,
      progressPercentage?: number,
      progressType?: "sync" | "index",
    ) => {
      client.showProgress(progressPercentage, progressType);
    },
    "editor.insertAtPos": (
      _ctx,
      text: string,
      pos: number,
      cursorPlaceHolder = false,
    ) => {
      let cursorPlaceholderPos = -1;
      if (cursorPlaceHolder) {
        cursorPlaceholderPos = text.indexOf("|^|");
        if (cursorPlaceholderPos !== -1) {
          text = text.slice(0, cursorPlaceholderPos) +
            text.slice(cursorPlaceholderPos + 3);
        } else {
          cursorPlaceHolder = false;
        }
      }
      client.editorView.dispatch({
        changes: {
          insert: text,
          from: pos,
        },
      });
      if (cursorPlaceHolder) {
        const cursorPos = pos + cursorPlaceholderPos;
        client.editorView.dispatch({
          selection: {
            anchor: cursorPos,
          },
          effects: [
            EditorView.scrollIntoView(cursorPos),
          ],
        });
      }
    },
    "editor.replaceRange": (
      _ctx,
      from: number,
      to: number,
      text: string,
      cursorPlaceHolder = false,
    ) => {
      let cursorPlaceholderPos = -1;
      if (cursorPlaceHolder) {
        cursorPlaceholderPos = text.indexOf("|^|");
        text = text.slice(0, cursorPlaceholderPos) +
          text.slice(cursorPlaceholderPos + 3);
      }
      client.editorView.dispatch({
        changes: {
          insert: text,
          from: from,
          to: to,
        },
      });
      if (cursorPlaceHolder) {
        const cursorPos = from + cursorPlaceholderPos;
        client.editorView.dispatch({
          selection: {
            anchor: cursorPos,
          },
          effects: [
            EditorView.scrollIntoView(cursorPos),
          ],
        });
      }
    },
    "editor.moveCursor": (_ctx, pos: number, center = false) => {
      client.editorView.dispatch({
        selection: {
          anchor: pos,
        },
      });
      if (center) {
        client.editorView.dispatch({
          effects: [
            EditorView.scrollIntoView(
              pos,
              {
                y: "center",
              },
            ),
          ],
        });
      }
      client.editorView.focus();
    },
    "editor.moveCursorToLine": (
      _ctx,
      line: number,
      column = 1,
      center = false,
    ) => {
      // CodeMirror already keeps information about lines
      const cmLine = client.editorView.state.doc.line(line);
      // How much to move inside the line, column number starts from 1
      const offset = Math.max(0, Math.min(cmLine.length, column - 1));
      // Just reuse the implementation above
      syscalls["editor.moveCursor"](_ctx, cmLine.from + offset, center);
    },
    "editor.setSelection": (_ctx, from: number, to: number) => {
      client.editorView.dispatch({
        selection: {
          anchor: from,
          head: to,
        },
      });
    },

    "editor.insertAtCursor": (
      _ctx,
      text: string,
      scrollIntoView = false,
      cursorPlaceHolder = false,
    ) => {
      const editorView = client.editorView;
      const from = editorView.state.selection.main.from;
      const cursorPlaceholderPos = text.indexOf("|^|");
      if (cursorPlaceHolder && cursorPlaceholderPos !== -1) {
        text = text.slice(0, cursorPlaceholderPos) +
          text.slice(cursorPlaceholderPos + 3);
      } else {
        cursorPlaceHolder = false;
      }
      editorView.dispatch({
        changes: {
          insert: text,
          from: from,
        },
        selection: {
          anchor: cursorPlaceHolder
            ? from + cursorPlaceholderPos
            : from + text.length,
        },
        scrollIntoView,
      });
    },
    "editor.dispatch": (_ctx, change: Transaction) => {
      client.editorView.dispatch(change);
    },
    "editor.prompt": (
      _ctx,
      message: string,
      defaultValue = "",
    ): Promise<string | undefined> => {
      return client.prompt(message, defaultValue);
    },
    "editor.confirm": (_ctx, message: string): Promise<boolean> => {
      return client.confirm(message);
    },
    "editor.alert": (_ctx, message: string) => {
      alert(message);
    },
    "editor.getUiOption": (_ctx, key: string): any => {
      return (client.ui.viewState.uiOptions as any)[key];
    },
    "editor.setUiOption": (_ctx, key: string, value: any) => {
      client.ui.viewDispatch({
        type: "set-ui-option",
        key,
        value,
      });
      client.reloadEditor();
    },
    "editor.vimEx": (_ctx, exCommand: string) => {
      const cm = vimGetCm(client.editorView);
      if (cm && cm.state.vim) {
        return Vim.handleEx(cm as any, exCommand);
      } else {
        throw new Error("Vim mode not active or not initialized.");
      }
    },
    "editor.configureVimMode": () => {
      // Override the default "o" binding to be more intelligent and follow the markdown editor's behavior
      Vim.mapCommand("o", "action", "newline-continue-markup", {}, {});
      Vim.mapCommand("O", "action", "back-newline-continue-markup", {}, {});
      Vim.unmap("<C-q>", undefined as any);
      Vim.defineAction("newline-continue-markup", (cm) => {
        // Append at end of line
        Vim.handleKey(cm, "A", "+input");
        // Insert newline continuing markup where appropriate
        insertNewlineContinueMarkup(client.editorView) ||
          insertNewlineAndIndent(client.editorView);
      });
      Vim.defineAction("back-newline-continue-markup", (cm) => {
        // Determine current line
        const pos = client.editorView.state.selection.main.from;
        const line = client.editorView.state.doc.lineAt(pos).number;
        if (line === 1) {
          // We're on the top line
          // Go to 0:0
          Vim.handleKey(cm, "0", "+input");
          // Insert a newline
          insertNewline(client.editorView);
          // Go up to the new line
          Vim.handleKey(cm, "k", "+input");
          // Into insert mode
          Vim.handleKey(cm, "i", "+input");
        } else {
          // We're elsewhere in the document
          // Go up
          Vim.handleKey(cm, "k", "+input");
          // Append mode at the end of the line
          Vim.handleKey(cm, "A", "+input");
          // Insert a newline using the continue markup thing
          insertNewlineContinueMarkup(client.editorView) ||
            insertNewlineAndIndent(client.editorView);
        }
      });

      // Load the config if any
      const config = client.config.get<VimConfig>("vim", {});
      if (config) {
        config.unmap?.forEach((binding) => {
          if (typeof binding === "string") {
            console.log("Unmapping " + binding);
            // @ts-ignore: unmap expects a string for the mode, this is problematic with Ex mappings which requires undefined or false
            Vim.unmap(binding, undefined);
          } else if (binding.key) {
            console.log(
              "Unmapping " + binding.key + " in " + (binding.mode ?? "normal"),
            );
            Vim.unmap(binding.key, binding.mode ?? "normal");
          }
        });
        config.map?.forEach(({ map, to, mode }) => {
          console.log(
            "Mapping " + map + " to " + to + " for " + (mode ?? "normal"),
          );
          Vim.map(map, to, mode ?? "normal");
        });
        config.noremap?.forEach(({ map, to, mode }) => {
          console.log(
            "Noremapping " + map + " to " + to + " for " + (mode ?? "normal"),
          );
          Vim.noremap(map, to, mode ?? "normal");
        });
        config.commands?.forEach(({ ex, command }) => {
          console.log("Mapping command '" + command + "' to Ex " + ex);
          Vim.defineEx(ex, "", () => client.runCommandByName(command));
        });
      } else {
        console.log("No vim config found");
      }
    },
    "editor.openPageNavigator": (
      _ctx,
      mode: "page" | "meta" | "document" | "all" = "page",
    ) => {
      client.startPageNavigate(mode);
    },
    "editor.openCommandPalette": () => {
      client.startCommandPalette();
    },
    "editor.deleteLine": () => {
      deleteLine(client.editorView);
    },
    "editor.toggleComment": () => {
      return toggleComment({
        state: client.editorView.state,
        dispatch: client.editorView.dispatch,
      });
    },
    "editor.moveLineUp": () => {
      return moveLineUp({
        state: client.editorView.state,
        dispatch: client.editorView.dispatch,
      });
    },
    "editor.moveLineDown": () => {
      return moveLineDown({
        state: client.editorView.state,
        dispatch: client.editorView.dispatch,
      });
    },
    // Folding
    "editor.fold": () => {
      foldCode(client.editorView);
    },
    "editor.unfold": () => {
      unfoldCode(client.editorView);
    },
    "editor.toggleFold": () => {
      toggleFold(client.editorView);
    },
    "editor.foldAll": () => {
      foldAll(client.editorView);
    },
    "editor.unfoldAll": () => {
      unfoldAll(client.editorView);
    },
    "editor.undo": () => {
      return undo(client.editorView);
    },
    "editor.redo": () => {
      return redo(client.editorView);
    },
    "editor.openSearchPanel": () => {
      openSearchPanel(client.editorView);
    },
    "editor.copyToClipboard": async (_ctx, data: string | Blob) => {
      try {
        if (typeof data === "string") {
          await navigator.clipboard.writeText(data);
        } else {
          await navigator.clipboard.write([
            new ClipboardItem({ [data.type]: data }),
          ]);
        }
      } catch (e) {
        console.error(e);
        client.flashNotification(`Could not copy to clipboard: ${e}`);
      }
    },
    "editor.sendMessage": (_ctx, type: string, data: any) => {
      if (!client.isDocumentEditor()) return;

      client.documentEditor.sendPublicMessage({
        type,
        data,
      });
    },

    "editor.isMobile": () => {
      const mouseDetected = globalThis.matchMedia("(pointer:fine)").matches;
      return !mouseDetected;
    },
  };

  return syscalls;
}


================================================
FILE: client/plugos/syscalls/event.ts
================================================
import type { SysCallMapping } from "../system.ts";
import type { EventHookI } from "../eventhook.ts";
import type { EventSubscription } from "@silverbulletmd/silverbullet/type/event";
import { LuaStackFrame, luaValueToJS } from "../../space_lua/runtime.ts";
import type { Client } from "../../client.ts";

export function eventSyscalls(
  eventHook: EventHookI,
  client: Client,
): SysCallMapping {
  return {
    "event.dispatch": (_ctx, eventName: string, data: any) => {
      return eventHook.dispatchEvent(eventName, data);
    },
    "event.listEvents": () => {
      return eventHook.listEvents();
    },
    /**
     * Define a Lua event listener
     */
    "event.listen": (
      _ctx,
      def: EventSubscription,
    ) => {
      // console.log("Registering Lua event listener: ", def.name);
      client.config.insert([
        "eventListeners",
        def.name,
      ], async (...args: any[]) => {
        // Convert return value to JS
        const val = await def.run(...args);
        return luaValueToJS(val, LuaStackFrame.lostFrame);
      });
    },
  };
}


================================================
FILE: client/plugos/syscalls/fetch.ts
================================================
import type { SysCallMapping } from "../system.ts";
import type {
  ProxyFetchRequest,
  ProxyFetchRequest64,
  ProxyFetchResponse,
  ProxyFetchResponse64,
} from "../proxy_fetch.ts";
import type { Client } from "../../client.ts";
import {
  base64Decode,
  base64Encode,
} from "@silverbulletmd/silverbullet/lib/crypto";
import { fsEndpoint } from "../../spaces/constants.ts";

export function sandboxFetchSyscalls(
  client: Client,
): SysCallMapping {
  return {
    // For use in Lua
    "http.request": async (
      _ctx,
      url: string,
      options: ProxyFetchRequest = {},
    ): Promise<ProxyFetchResponse> => {
      console.warn("Deprecated: use net.proxyFetch() instead");
      // JSONify any non-serializable body
      if (
        options?.body && typeof options.body !== "string" &&
        !(options.body instanceof Uint8Array)
      ) {
        options.body = JSON.stringify(options.body);
      }
      const fetchOptions = options
        ? {
          method: options.method,
          headers: {} as Record<string, string>,
          body: options.body,
        }
        : {};

      fetchOptions.headers = buildProxyHeaders(options?.headers);

      const resp = await client.httpSpacePrimitives.authenticatedFetch(
        buildProxyUrl(client, url),
        fetchOptions,
      );
      // Do sensible things with the body based on the content type
      let body: any;
      const contentTypeHeader = options.responseEncoding ||
        resp.headers.get("x-proxy-header-content-type");
      const statusCode = +(resp.headers.get("x-proxy-status-code") || "200");
      if (contentTypeHeader?.startsWith("application/json")) {
        body = await resp.json();
      } else if (
        contentTypeHeader?.startsWith("application/xml") ||
        contentTypeHeader?.startsWith("text/")
      ) {
        body = await resp.text();
      } else {
        body = new Uint8Array(await resp.arrayBuffer());
      }
      return {
        ok: resp.ok,
        status: statusCode,
        headers: extractProxyHeaders(resp.headers),
        body: body,
      };
    },
    "sandboxFetch.fetch": async (
      _ctx,
      url: string,
      options?: ProxyFetchRequest64,
    ): Promise<ProxyFetchResponse64> => {
      // console.log("Got sandbox fetch ", url, op);
      const fetchOptions = options
        ? {
          method: options.method,
          headers: options.headers,
          body: options.base64Body && base64Decode(options.base64Body),
        }
        : {};
      fetchOptions.headers = buildProxyHeaders(options?.headers);
      const resp = await client.httpSpacePrimitives.authenticatedFetch(
        buildProxyUrl(client, url),
        // Casting this to any because of weird Deno typing
        fetchOptions as any,
      );
      const statusCode = +(resp.headers.get("x-proxy-status-code") || "200");
      const body = await resp.arrayBuffer();
      return {
        ok: resp.ok,
        status: statusCode,
        headers: extractProxyHeaders(resp.headers),
        base64Body: base64Encode(new Uint8Array(body)),
      };
    },
  };
}

function buildProxyUrl(client: Client, url: string) {
  url = url.replace(/^https?:\/\//, "");
  // Strip off the /.fs and replace with /.proxy
  return client.httpSpacePrimitives.url.slice(0, -fsEndpoint.length) +
    "/.proxy/" + url;
}

function buildProxyHeaders(headers?: Record<string, any>): Record<string, any> {
  const newHeaders: Record<string, any> = { "X-Proxy-Request": "true" };
  if (!headers) {
    return newHeaders;
  }
  for (const [key, value] of Object.entries(headers)) {
    newHeaders[`X-Proxy-Header-${key}`] = value;
  }
  return newHeaders;
}

function extractProxyHeaders(
  headers: Headers,
): Record<string, any> {
  const newHeaders: Record<string, any> = {};
  for (const [key, value] of headers.entries()) {
    if (key.toLowerCase().startsWith("x-proxy-header-")) {
      newHeaders[key.slice("x-proxy-header-".length)] = value;
    }
  }
  return newHeaders;
}


================================================
FILE: client/plugos/syscalls/index.ts
================================================
import type { SysCallMapping } from "../system.ts";
import type {
  LuaCollectionQuery,
  LuaQueryCollection,
} from "../../space_lua/query_collection.ts";

import {
  type ObjectIndex,
  ObjectValidationError,
} from "../../data/object_index.ts";
import type { ObjectValue } from "@silverbulletmd/silverbullet/type/index";
import type { Client } from "../../client.ts";
import type { LuaTable } from "../../space_lua/runtime.ts";

export function indexSyscalls(
  objectIndex: ObjectIndex,
  client: Client,
): SysCallMapping {
  return {
    "index.tag": (_ctx, tagName: string): LuaQueryCollection => {
      return objectIndex.tag(tagName);
    },
    "index.ensureFullIndex": (_ctx) => {
      return objectIndex.ensureFullIndex(client.space);
    },
    "index.reindexSpace": () => {
      return objectIndex.reindexSpace(client.space);
    },
    "index.indexObjects": (
      _ctx,
      page: string,
      objects: ObjectValue[],
    ): Promise<void> => {
      return objectIndex.indexObjects(page, objects);
    },
    "index.validateObjects": async (
      _ctx,
      page: string,
      objects: ObjectValue[],
    ): Promise<{ error: string; object: ObjectValue } | null> => {
      try {
        await objectIndex.validateObjects(page, objects);
        return null;
      } catch (e: any) {
        if (e instanceof ObjectValidationError) {
          return {
            error: e.message,
            object: e.object,
          };
        } else {
          throw e;
        }
      }
    },
    "index.getObjectByRef": (
      _ctx,
      page: string,
      tag: string,
      ref: string,
    ): Promise<ObjectValue | undefined> => {
      return objectIndex.getObjectByRef(ref, page, tag);
    },
    "index.queryLuaObjects": (
      _ctx,
      tag: string,
      query: LuaCollectionQuery,
      scopedVariables?: Record<string, any>,
    ): Promise<ObjectValue[]> => {
      return objectIndex.queryLuaObjects(
        client.clientSystem.spaceLuaEnv.env,
        tag,
        query,
        scopedVariables,
      );
    },

    "index.deleteObject": (
      _ctx,
      page: string,
      tag: string,
      ref: string,
    ): Promise<void> => {
      return objectIndex.deleteObject(page, tag, ref);
    },
    "lua:index.defineTag": (_ctx, tagDef: LuaTable) => {
      // Using 'lua:' prefix to _not_ convert tagDef to a JS version (but keep original LuaTable)
      if (!tagDef.has("name")) {
        throw new Error("A tag name is required");
      }
      const currentTag = client.config.get(["tags", tagDef.get("name")], null);
      if (!currentTag) {
        client.config.set(["tags", tagDef.get("name")], {
          name: tagDef.get("name"),
        });
      }
      client.config.set(
        ["tags", tagDef.get("name"), "metatable"],
        tagDef.get("metatable"),
      );
    },
  };
}


================================================
FILE: client/plugos/syscalls/jsonschema.ts
================================================
import type { SysCallMapping } from "../system.ts";
import { Ajv, type ValidateFunction } from "ajv";

const ajv = new Ajv();

ajv.addFormat("email", {
  validate: (data: string) => {
    // TODO: Implement email validation
    return data.includes("@");
  },
  async: false,
});

ajv.addFormat("page-ref", {
  validate: (data: string) => {
    return data.startsWith("[[") && data.endsWith("]]");
  },
  async: false,
});

const schemaCache = new Map<string, ValidateFunction>();

export function validateObject(schema: any, object: any): undefined | string {
  try {
    const schemaKey = JSON.stringify(schema);
    if (!schemaCache.has(schemaKey)) {
      const validate = ajv.compile(schema);
      schemaCache.set(schemaKey, validate);
    }
    const validate = schemaCache.get(schemaKey)!;
    if (validate(object)) {
      return;
    } else {
      let text = ajv.errorsText(validate.errors);
      text = text.replaceAll("/", ".");
      text = text.replace(/^data[\.\s]/, "");
      return text;
    }
  } catch (e: any) {
    return e.message;
  }
}

export function validateSchema(schema: any): undefined | string {
  const valid = ajv.validateSchema(schema);
  if (valid) {
    return;
  } else {
    return ajv.errorsText(ajv.errors);
  }
}

export function jsonschemaSyscalls(): SysCallMapping {
  return {
    "jsonschema.validateObject": (
      _ctx,
      schema: any,
      object: any,
    ): undefined | string => {
      return validateObject(schema, object);
    },
    "jsonschema.validateSchema": (
      _ctx,
      schema: any,
    ): undefined | string => {
      return validateSchema(schema);
    },
  };
}


================================================
FILE: client/plugos/syscalls/language.ts
================================================
import type { SysCallMapping } from "../system.ts";
import { parse } from "../../markdown_parser/parse_tree.ts";
import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree";
import { builtinLanguages, languageFor } from "../../languages.ts";

export function languageSyscalls(): SysCallMapping {
  return {
    "language.parseLanguage": (
      _ctx,
      language: string,
      code: string,
    ): ParseTree => {
      const lang = languageFor(language);
      if (!lang) {
        throw new Error(`Unknown language ${language}`);
      }
      return parse(lang, code);
    },
    "language.listLanguages": (): string[] => {
      return Object.keys(builtinLanguages);
    },
  };
}


================================================
FILE: client/plugos/syscalls/lua.ts
================================================
import type { SysCallMapping } from "../system.ts";
import { evalExpression } from "../../space_lua/eval.ts";
import { parse, parseExpressionString } from "../../space_lua/parse.ts";
import {
  LuaStackFrame,
  luaToString,
  luaValueToJS,
} from "../../space_lua/runtime.ts";
import { buildThreadLocalEnv } from "../../space_lua_api.ts";
import { isSendable } from "../util.ts";
import type { LuaBlock, LuaExpression } from "../../space_lua/ast.ts";
import type { ClientSystem } from "../../client_system.ts";

export function luaSyscalls(clientSystem: ClientSystem): SysCallMapping {
  return {
    "lua.parse": (_ctx, code: string): LuaBlock => {
      return parse(code);
    },
    "lua.parseExpression": (_ctx, expression: string): LuaExpression => {
      return parseExpressionString(expression);
    },
    /**
     * Evaluates a Lua expression and returns the result as a JavaScript value
     * @param _ctx
     * @param expression
     * @returns
     */
    "lua.evalExpression": async (_ctx, expression: string) => {
      try {
        const ast = parseExpressionString(expression);
        const env = await buildThreadLocalEnv(
          clientSystem.system,
          clientSystem.spaceLuaEnv.env,
        );
        const sf = new LuaStackFrame(env, null);
        const luaResult = await evalExpression(
          ast,
          clientSystem.spaceLuaEnv.env,
          sf,
        );
        const jsResult = luaValueToJS(luaResult, sf);
        if (isSendable(jsResult)) {
          return jsResult;
        } else {
          // This may evaluate to e.g. a function, which is not sendable, in this case we'll console.warn and return a stringified version of the result
          console.warn(
            "Lua eval result is not sendable, returning stringified version",
            jsResult,
          );
          return luaToString(luaResult);
        }
      } catch (e: any) {
        console.error("Lua eval error: ", e.message, e.sf?.astCtx);
        throw e;
      }
    },
  };
}


================================================
FILE: client/plugos/syscalls/markdown.ts
================================================
import type { SysCallMapping } from "../system.ts";
import { parse } from "../../markdown_parser/parse_tree.ts";
import {
  type ParseTree,
  renderToText,
} from "@silverbulletmd/silverbullet/lib/tree";
import { extendedMarkdownLanguage } from "../../markdown_parser/parser.ts";
import {
  expandMarkdown,
  type MarkdownExpandOptions,
} from "../../markdown_renderer/inline.ts";
import type { Client } from "../../client.ts";
import {
  type MarkdownRenderOptions,
  renderMarkdownToHtml,
} from "../../markdown_renderer/markdown_render.ts";
import {
  jsonToMDTable,
  refCellTransformer,
} from "../../markdown_renderer/result_render.ts";

export function markdownSyscalls(client: Client): SysCallMapping {
  return {
    "markdown.parseMarkdown": (_ctx, text: string): ParseTree => {
      return parse(extendedMarkdownLanguage, text);
    },
    "markdown.renderParseTree": (_ctx, tree: ParseTree): string => {
      return renderToText(tree);
    },
    "markdown.expandMarkdown": async (
      _ctx,
      treeOrText: ParseTree | string,
      options?: MarkdownExpandOptions,
    ): Promise<ParseTree | string> => {
      const outputString = typeof treeOrText === "string";
      if (typeof treeOrText === "string") {
        treeOrText = parse(extendedMarkdownLanguage, treeOrText);
      }
      const result = await expandMarkdownWithClient(
        client,
        treeOrText,
        options,
      );
      if (outputString) {
        return renderToText(result);
      } else {
        return result;
      }
    },
    "markdown.markdownToHtml": async (
      _ctx,
      text: string,
      options: MarkdownRenderOptions = {},
    ) => {
      let mdTree = parse(extendedMarkdownLanguage, text);
      if (options.expand) {
        mdTree = await expandMarkdownWithClient(client, mdTree);
      }
      return renderMarkdownToHtml(mdTree, options);
    },
    "markdown.objectsToTable": (
      _ctx,
      data: any[],
      options: { renderCell?: (val: any, key: string) => Promise<any> | any } =
        {},
    ) => {
      return jsonToMDTable(data, options.renderCell || refCellTransformer);
    },
  };
}

function expandMarkdownWithClient(
  client: Client,
  tree: ParseTree,
  options?: MarkdownExpandOptions,
) {
  return expandMarkdown(
    client.space,
    client.currentName(),
    tree,
    client.clientSystem.spaceLuaEnv,
    options,
  );
}


================================================
FILE: client/plugos/syscalls/mq.ts
================================================
import type { DataStoreMQ } from "../../data/mq.datastore.ts";
import type { MQListenerSpec } from "../hooks/mq.ts";
import type { SysCallMapping } from "../system.ts";

export type EventSubscription = MQListenerSpec & {
  run: (...args: any[]) => Promise<any>;
};

export function mqSyscalls(
  mq: DataStoreMQ,
): SysCallMapping {
  return {
    /**
     * Define a Lua event listener
     */
    "mq.subscribe": (
      _ctx,
      def: MQListenerSpec,
    ) => {
      def.autoAck = def.autoAck != false;
      // console.log("Registering Lua event listener: ", def.name);
      client.config.insert([
        "mqSubscriptions",
        def.queue,
      ], def);
    },
    "mq.send": (_ctx, queue: string, body: any) => {
      return mq.send(queue, body);
    },
    "mq.batchSend": (_ctx, queue: string, bodies: any[]) => {
      return mq.batchSend(queue, bodies);
    },
    "mq.ack": (_ctx, queue: string, id: string) => {
      return mq.ack(queue, id);
    },
    "mq.batchAck": (_ctx, queue: string, ids: string[]) => {
      return mq.batchAck(queue, ids);
    },
    "mq.flushQueue": (_ctx, queue: string) => {
      return mq.flushQueue(queue);
    },
    "mq.flushAllQueues": () => {
      return mq.flushAllQueues();
    },
    "mq.getQueueStats": (_ctx, queue: string) => {
      return mq.getQueueStats(queue);
    },
    "mq.awaitEmptyQueue": (_ctx, queue: string) => {
      return mq.awaitEmptyQueue(queue);
    },
  };
}


================================================
FILE: client/plugos/syscalls/service_registry.ts
================================================
import type {
  ServiceMatch,
  ServiceRegistry,
  ServiceSpec,
} from "../../service_registry.ts";
import type { SysCallMapping } from "../system.ts";

export function serviceRegistrySyscalls(
  serviceRegistry: ServiceRegistry,
): SysCallMapping {
  return {
    /**
     * Define a Lua event listener
     */
    "service.define": (
      _ctx,
      def: ServiceSpec,
    ) => {
      return serviceRegistry.define(def);
    },
    "service.discover": (
      _ctx,
      selector: string,
      data: any,
    ): Promise<ServiceMatch[]> => {
      return serviceRegistry.discover(selector, data);
    },
    "service.invoke": (
      _ctx,
      service: ServiceMatch,
      data: any,
    ): Promise<any> => {
      return serviceRegistry.invoke(service, data);
    },
    "service.invokeBestMatch": (
      _ctx,
      selector: string,
      data: any,
    ): Promise<any> => {
      return serviceRegistry.invokeBestMatch(selector, data);
    },
  };
}


================================================
FILE: client/plugos/syscalls/shell.ts
================================================
import type { SysCallMapping } from "../system.ts";
import type { Client } from "../../client.ts";
import { fsEndpoint } from "../../spaces/constants.ts";

export function shellSyscalls(
  client: Client,
): SysCallMapping {
  return {
    "shell.run": async (
      _ctx,
      cmd: string,
      args: string[],
      stdin?: string,
    ): Promise<{ stdout: string; stderr: string; code: number }> => {
      if (!client.httpSpacePrimitives) {
        throw new Error("Not supported in fully local mode");
      }
      const resp = client.httpSpacePrimitives.authenticatedFetch(
        buildShellUrl(client),
        {
          method: "POST",
          body: JSON.stringify({
            cmd,
            args,
            stdin,
          }),
        },
      );
      const { code, stderr, stdout } = await (await resp).json();
      return { code, stderr, stdout };
    },
  };
}

function buildShellUrl(client: Client) {
  // Strip off the /.fs and replace with /.shell
  return client.httpSpacePrimitives.url.slice(0, -fsEndpoint.length) +
    "/.shell";
}


================================================
FILE: client/plugos/syscalls/space.ts
================================================
import { parseToRef, type Ref } from "@silverbulletmd/silverbullet/lib/ref";
import type { Client } from "../../client.ts";
import type { SysCallMapping } from "../system.ts";

import type {
  DocumentMeta,
  FileMeta,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";

export function spaceReadSyscalls(client: Client): SysCallMapping {
  return {
    "space.listPages": (): Promise<PageMeta[]> => {
      return client.space.fetchPageList();
    },
    "space.readPage": async (_ctx, name: string): Promise<string> => {
      return (await client.space.readPage(name)).text;
    },
    "space.readPageWithMeta": (
      _ctx,
      name: string,
    ): Promise<{ text: string; meta: PageMeta }> => {
      return client.space.readPage(name);
    },
    "space.readRef": async (_ctx, ref: string | Ref): Promise<string> => {
      if (typeof ref === "string") {
        ref = parseToRef(ref)!;
        if (!ref) {
          throw new Error(`Invalid ref: ${ref}`);
        }
      }
      return (await client.space.readRef(ref)).text;
    },
    "space.pageExists": (_ctx, name: string): boolean => {
      return client.clientSystem.allKnownFiles.has(name + ".md");
    },
    "space.getPageMeta": (_ctx, name: string): Promise<PageMeta> => {
      return client.space.getPageMeta(name);
    },
    "space.listPlugs": (): Promise<FileMeta[]> => {
      return client.space.listPlugs();
    },
    "space.listDocuments": async (): Promise<DocumentMeta[]> => {
      return await client.space.fetchDocumentList();
    },
    "space.readDocument": async (_ctx, name: string): Promise<Uint8Array> => {
      return (await client.space.readDocument(name)).data;
    },
    "space.getDocumentMeta": async (
      _ctx,
      name: string,
    ): Promise<DocumentMeta> => {
      return await client.space.getDocumentMeta(name);
    },
    // DEPRECATED, please use document versions instead, left here for backwards compatibility
    "space.listAttachments": async (): Promise<DocumentMeta[]> => {
      return await client.space.fetchDocumentList();
    },
    "space.readAttachment": async (_ctx, name: string): Promise<Uint8Array> => {
      return (await client.space.readDocument(name)).data;
    },
    "space.getAttachmentMeta": async (
      _ctx,
      name: string,
    ): Promise<DocumentMeta> => {
      return await client.space.getDocumentMeta(name);
    },
    // FS
    "space.listFiles": (): Promise<FileMeta[]> => {
      return client.space.spacePrimitives.fetchFileList();
    },
    "space.getFileMeta": (_ctx, name: string): Promise<FileMeta> => {
      return client.space.spacePrimitives.getFileMeta(name);
    },
    "space.readFile": async (_ctx, name: string): Promise<Uint8Array> => {
      return (await client.space.spacePrimitives.readFile(name)).data;
    },
    "space.readFileWithMeta": async (
      _ctx,
      name: string,
    ): Promise<{ data: Uint8Array; meta: FileMeta }> => {
      return await client.space.spacePrimitives.readFile(name);
    },
    "space.fileExists": async (_ctx, name: string): Promise<boolean> => {
      // If a full sync has successfully completed (so we know what files exist)
      // and we have a snapshot, let's use the snapshot
      if (
        client.fullSyncCompleted &&
        !client.eventedSpacePrimitives.isSnapshotEmpty()
      ) {
        return !!client.eventedSpacePrimitives.getSnapshot()[name];
      }
      try {
        await client.space.spacePrimitives.getFileMeta(name);
        // If this returned the file exists
        return true;
      } catch {
        // Assumption: any error means the file does not exist
        return false;
      }
    },
  };
}

export function spaceWriteSyscalls(editor: Client): SysCallMapping {
  return {
    "space.writePage": (
      _ctx,
      name: string,
      text: string,
    ): Promise<PageMeta> => {
      return editor.space.writePage(name, text);
    },
    "space.deletePage": async (_ctx, name: string) => {
      console.log("Deleting page");
      await editor.space.deletePage(name);
    },
    "space.writeDocument": (
      _ctx,
      name: string,
      data: Uint8Array,
    ): Promise<DocumentMeta> => {
      return editor.space.writeDocument(name, data);
    },
    "space.deleteDocument": async (_ctx, name: string) => {
      await editor.space.deleteDocument(name);
    },
    "space.writeFile": (
      _ctx,
      name: string,
      data: Uint8Array,
    ): Promise<FileMeta> => {
      return editor.space.spacePrimitives.writeFile(name, data);
    },
    "space.deleteFile": (_ctx, name: string) => {
      return editor.space.spacePrimitives.deleteFile(name);
    },
  };
}


================================================
FILE: client/plugos/syscalls/sync.ts
================================================
import type { SysCallMapping } from "../system.ts";
import type { Client } from "../../client.ts";

// TODO: Reimplement this
export function syncSyscalls(client: Client): SysCallMapping {
  return {
    "sync.hasInitialSyncCompleted": (): boolean => {
      return client.fullSyncCompleted;
    },
    "sync.performFileSync": (_ctx, path: string): Promise<void> => {
      client.postServiceWorkerMessage({ type: "perform-file-sync", path });
      return waitForServiceWorkerActivation(path);
    },
    "sync.performSpaceSync": (): Promise<number> => {
      client.postServiceWorkerMessage({ type: "perform-space-sync" });
      return waitForServiceWorkerActivation();
    },
  };
}

function waitForServiceWorkerActivation(path?: string): Promise<any> {
  return new Promise<any>((resolve, reject) => {
    client.eventHook.addLocalListener(
      "service-worker:file-sync-complete",
      eventHandler,
    );
    client.eventHook.addLocalListener(
      "service-worker:space-sync-complete",
      eventHandler,
    );
    client.eventHook.addLocalListener(
      "service-worker:sync-error",
      errorHandler,
    );
    function eventHandler(data: any) {
      // If data.path is set, we are notified about a specific file sync -> all good, even for an individual file sync
      // If data.path is not set, we are notified about a full space sync
      // If we were waiting for a specific path, ignore other paths
      if (data.path && path && data.path !== path) {
        // Event for other file sync
        return;
      }
      // If we were waiting for a specific path, ignore other paths
      resolve(data);

      // Unsubscribe from all these events
      client.eventHook.removeLocalListener(
        "service-worker:file-sync-complete",
        eventHandler,
      );
      client.eventHook.removeLocalListener(
        "service-worker:space-sync-complete",
        eventHandler,
      );
      client.eventHook.removeLocalListener(
        "service-worker:sync-error",
        errorHandler,
      );
    }
    function errorHandler(e: any) {
      reject(e);
      // Unsubscribe from all these events
      client.eventHook.removeLocalListener(
        "service-worker:file-sync-complete",
        eventHandler,
      );
      client.eventHook.removeLocalListener(
        "service-worker:space-sync-complete",
        eventHandler,
      );
      client.eventHook.removeLocalListener(
        "service-worker:sync-error",
        errorHandler,
      );
    }
  });
}


================================================
FILE: client/plugos/syscalls/system.ts
================================================
import type { SysCallMapping } from "../system.ts";
import type { Client } from "../../client.ts";
import { publicVersion } from "../../../public_version.ts";
import type { CommandDef } from "@silverbulletmd/silverbullet/type/manifest";
import type { SyscallMeta } from "@silverbulletmd/silverbullet/type/index";

export function systemSyscalls(
  client: Client,
  readOnlyMode: boolean,
): SysCallMapping {
  const api: SysCallMapping = {
    "system.invokeFunction": (
      _ctx,
      fullName: string, // plug.function
      ...args: any[]
    ) => {
      const [plugName, functionName] = fullName.split(".");
      if (!plugName || !functionName) {
        throw Error(`Invalid function name ${fullName}`);
      }
      const plug = client.clientSystem.system.loadedPlugs.get(plugName);
      if (!plug) {
        throw Error(`Plug ${plugName} not found`);
      }
      const functionDef = plug.manifest!.functions[functionName];
      if (!functionDef) {
        throw Error(`Function ${functionName} not found`);
      }
      return plug.invoke(functionName, args);
    },
    "system.invokeFunctionOnServer": (
      ctx,
      fullName: string, // plug.function
      ...args: any[]
    ) => {
      console.warn(
        "Calling deprecated system.invokeFunctionOnServer, use system.invokeFunction instead",
      );
      return api["system.invokeFunction"](ctx, fullName, ...args);
    },
    "system.serverSyscall": (_ctx, name: string, ...args: any[]) => {
      console.warn(
        "Calling deprecated system.serverSyscall, use syscall instead",
      );
      return client.clientSystem.localSyscall(name, args);
    },
    "system.invokeCommand": (_ctx, name: string, args?: string[]) => {
      console.warn("Deprecated, use editor.invokeCommand instead");
      return client.runCommandByName(name, args);
    },
    "system.listCommands": (): { [key: string]: CommandDef } => {
      const commandHook = client.clientSystem.commandHook;
      const allCommands: { [key: string]: CommandDef } = {};
      for (const [cmd, def] of commandHook.buildAllCommands()) {
        allCommands[cmd] = {
          name: def.name,
          contexts: def.contexts,
          priority: def.priority,
          key: def.key,
          mac: def.mac,
          hide: def.hide,
          requireMode: def.requireMode,
        };
      }
      return allCommands;
    },
    "system.listSyscalls": (): SyscallMeta[] => {
      const syscalls: SyscallMeta[] = [];
      for (
        const [name, info] of client.clientSystem.system.registeredSyscalls
      ) {
        syscalls.push({
          name,
          requiredPermissions: info.requiredPermissions,
          argCount: Math.max(0, info.callback.length - 1),
        });
      }
      return syscalls;
    },
    "system.reloadPlugs": () => {
      if (!client) {
        throw new Error("Not supported");
      }
      return client.loadPlugs();
    },
    "system.reloadConfig": (): Record<string, any> => {
      console.warn("system.reloadConfig is deprecated, it's now a no-op");
      return client.config.values;
    },
    "system.loadSpaceScripts": async () => {
      console.warn("DEPRECATED: used system.loadScripts instead");
      await client.clientSystem.loadLuaScripts();
    },
    "system.loadScripts": async () => {
      await client.clientSystem.loadLuaScripts();
    },
    "system.loadSpaceStyles": async () => {
      if (!client) {
        throw new Error("Not supported on server");
      }
      await client.loadCustomStyles();
    },
    "system.wipeClient": async (_ctx, logout = false) => {
      await client.wipeClient();
      if (logout) {
        location.href = ".logout";
      } else {
        alert("Client wiped, feel free to navigate elsewhere");
      }
    },
    // DEPRECATED
    "system.cleanDatabases": (): boolean => {
      console.warn(
        "system.cleanDatabses is deprecated, use Client: Wipe instead",
      );
      return false;
    },
    "system.getMode": () => {
      return readOnlyMode ? "ro" : "rw";
    },
    "system.getURLPrefix": () => {
      const url = new URL(document.baseURI);

      return url.pathname;
    },
    "system.getBaseURI": () => {
      return document.baseURI;
    },
    "system.getVersion": () => {
      return publicVersion;
    },
    "system.getConfig": (_ctx, key: string, defaultValue: any = undefined) => {
      return client.config.get(key, defaultValue);
    },
    // DEPRECATED
    "system.getEnv": () => {
      console.warn(
        "system.getEnv is deprecated, you can assume the env to always be the client",
      );
      return null;
    },
    "system.getSpaceConfig": (_ctx, key, defaultValue?) => {
      console.warn(
        "system.getSpaceConfig is deprecated, use system.getConfig instead",
      );
      return client.config.get(key, defaultValue);
    },
  };
  return api;
}


================================================
FILE: client/plugos/system.ts
================================================
import type { Hook } from "./types.ts";
import { EventEmitter } from "./event.ts";
import type { SandboxFactory } from "./sandboxes/sandbox.ts";
import { Plug } from "./plug.ts";
import { InMemoryManifestCache, type ManifestCache } from "./manifest_cache.ts";
import {
  builtinPlugNames,
  builtinPlugPaths,
} from "../../plugs/builtin_plugs.ts";

export interface SysCallMapping {
  [key: string]: (ctx: SyscallContext, ...args: any) => Promise<any> | any;
}

export type SystemEvents<HookT> = {
  plugLoaded: (plug: Plug<HookT>) => void | Promise<void>;
  plugUnloaded: (name: string) => void | Promise<void>;
};

// Passed to every syscall, allows to pass in additional context that the syscall may use
export type SyscallContext = {
  // This is the plug that is invoking the syscall,
  // which may be undefined where this cannot be determined (e.g. when running in a NoSandbox)
  plug?: string;
};

type SyscallSignature = (
  ...args: any[]
) => Promise<any> | any;

type Syscall = {
  requiredPermissions: string[];
  callback: SyscallSignature;
};

export type SystemOptions = {
  manifestCache?: ManifestCache<any>;
  plugFlushTimeout?: number;
};

export class System<HookT> extends EventEmitter<SystemEvents<HookT>> {
  registeredSyscalls = new Map<string, Syscall>();
  protected plugs = new Map<string, Plug<HookT>>();
  protected enabledHooks = new Set<Hook<HookT>>();

  /**
   * @param env either an environment or undefined for hybrid mode
   */
  constructor(
    readonly env: string | undefined = undefined,
    readonly options: SystemOptions = {},
  ) {
    super();
    if (!options.manifestCache) {
      options.manifestCache = new InMemoryManifestCache();
    }
  }

  get loadedPlugs(): Map<string, Plug<HookT>> {
    return this.plugs;
  }

  addHook(feature: Hook<HookT>) {
    this.enabledHooks.add(feature);
    feature.apply(this);
  }

  registerSyscalls(
    requiredCapabilities: string[],
    ...registrationObjects: SysCallMapping[]
  ) {
    for (const registrationObject of registrationObjects) {
      for (const [name, callback] of Object.entries(registrationObject)) {
        this.registeredSyscalls.set(name, {
          requiredPermissions: requiredCapabilities,
          callback,
        });
      }
    }
  }

  /**
   * Invokes a function named using the "plug.functionName" pattern, for convenience
   * @param name name of the function (e.g. plug.doSomething)
   * @param args an array of arguments to pass to the function
   */
  invokeFunction(name: string, args: any[]): Promise<any> {
    // Some sanity type checks
    if (typeof name !== "string") {
      throw new Error(
        `invokeFunction: function name should be a string, got ${typeof name}`,
      );
    }
    if (!Array.isArray(args)) {
      throw new Error(
        `invokeFunction: args should be an array, got ${typeof args}`,
      );
    }
    const [plugName, functionName] = name.split(".");
    if (!functionName) {
      // Sanity check
      throw new Error(`Missing function name: ${name}`);
    }
    const plug = this.loadedPlugs.get(plugName);
    if (!plug) {
      throw new Error(`Plug ${plugName} not found invoking ${name}`);
    }
    return plug.invoke(functionName, args);
  }

  localSyscall(name: string, args: any[]): Promise<any> {
    return this.syscall({}, name, args);
  }

  syscall(
    ctx: SyscallContext,
    name: string,
    args: any[],
  ): Promise<any> {
    const syscall = this.registeredSyscalls.get(name);
    if (!syscall) {
      throw Error(`Unregistered syscall ${name}`);
    }
    if (ctx.plug) {
      // Only when running in a plug context do we check permissions
      const plug = this.loadedPlugs.get(ctx.plug);
      if (!plug) {
        throw new Error(
          `Plug ${ctx.plug} not found while attempting to invoke ${name}}`,
        );
      }
      for (const permission of syscall.requiredPermissions) {
        if (!plug.grantedPermissions.includes(permission)) {
          throw Error(`Missing permission '${permission}' for syscall ${name}`);
        }
      }
    }
    return Promise.resolve(syscall.callback(ctx, ...args));
  }

  /**
   * @param cacheKey Used to cache the manifest of the worker. Should be equal to the filepath
   * @param cacheHash Used to determine if the manifest is up to date. Should be `lastModified`
   */
  async loadPlug(
    sandboxFactory: SandboxFactory<HookT>,
    cacheKey: string,
    cacheHash: number = -1,
  ): Promise<Plug<HookT>> {
    const plug = await Plug.createLazily(
      this,
      cacheKey,
      cacheHash,
      sandboxFactory,
    );

    const manifest = plug.manifest;

    // This depends on cacheKey being the file path
    if (
      !builtinPlugPaths.includes(cacheKey) &&
      builtinPlugNames.includes(manifest.name)
    ) {
      plug.stop();
      throw new Error("Plug tried to overwrite internal plug");
    }

    // Validate the manifest
    let errors: string[] = [];
    for (const feature of this.enabledHooks) {
      errors = [...errors, ...feature.validateManifest(manifest)];
    }
    if (errors.length > 0) {
      throw new Error(`Invalid manifest: ${errors.join(", ")}`);
    }
    if (this.plugs.has(manifest.name)) {
      this.unload(manifest.name);
    }
    console.log("Activated plug", manifest.name);
    this.plugs.set(manifest.name, plug);

    await this.emit("plugLoaded", plug);
    return plug;
  }

  unload(name: string) {
    const plug = this.plugs.get(name);
    if (!plug) {
      return;
    }
    plug.stop();
    this.emit("plugUnloaded", name);
    this.plugs.delete(name);
  }

  unloadAll(): Promise<void[]> {
    return Promise.all(
      Array.from(this.plugs.keys()).map(this.unload.bind(this)),
    );
  }
}


================================================
FILE: client/plugos/types.ts
================================================
import type { System } from "./system.ts";
import type { AssetJson } from "../asset_bundle/bundle.ts";

/** The generic top level of a plug manifest file.
 * Defines plug metadata and functions.
 */
export interface Manifest<HookT> {
  /** The plug's name. Typically this is the name of the manifest file, without the file extension. */
  name: string;

  /** A list of syscall permissions required for this plug to function.
   *
   * Possible values:
   * - `fetch`: enables `fetch` function. (see: plug-api/plugos-syscall/fetch.ts, and plug-api/lib/fetch.ts)
   * - `shell`: enables the `shell.run` syscall. (see: plug-api/plugos-syscall/shell.ts)
   */
  requiredPermissions?: string[];

  /** A list of files or glob patterns that should be bundled with the plug.
   *
   * These files will be accessible through the `asset.readAsset` function.
   *
   * see: plug-api/plugos-syscall/asset.ts#readAsset
   */
  assets?: string[] | AssetJson;

  /** A map of function names to definitions. Declared functions are public, and may be associated with various hooks
   *
   * see: common/manifest.ts#SilverBulletHooks
   */
  functions: Record<string, FunctionDef<HookT>>;

  /**
   * A map of configuration options for the plug (to be merged with the system configuration).
   */
  config?: any;
}

/** Associates hooks with a function. This is the generic base structure, that identifies the function. Hooks are defined by the type parameter. */
export type FunctionDef<HookT> = {
  /** A function path, in the form `${relativeFilename}:${functionName}`.
   *
   * During compilation (see `../build_plugs.ts`) the function is read from the file and inlined into the plug bundle.
   *
   * This field and `FunctionDef.redirect` are mutually exclusive.
   */
  path?: string;

  /** A function from another plug, in the form `${plugName}.${functionName}` that will be attached to the given hooks. */
  redirect?: string;

  /** Environments where this plug is allowed to run, current may be one of "cli", "server", or "client". */
  env?: string;
} & HookT;

export interface Hook<HookT> {
  validateManifest(manifest: Manifest<HookT>): string[];

  apply(system: System<HookT>): void;
}


================================================
FILE: client/plugos/util.ts
================================================
/**
 * Checks if an object is sendable across the plugos worker boundary.
 *
 * @param o - The object to check.
 * @returns `true` if the object is sendable, `false` otherwise.
 */
export function isSendable(o: any): boolean {
  try {
    structuredClone(o);
    return true;
  } catch {
    return false;
  }
}


================================================
FILE: client/plugos/worker_runtime.ts
================================================
// This is the runtime imported from the compiled plug worker code
import type { ControllerMessage, WorkerMessage } from "./protocol.ts";
import type {
  ProxyFetchRequest64,
  ProxyFetchResponse64,
} from "./proxy_fetch.ts";
import { base64Decode, base64Encode } from "../../plug-api/lib/crypto.ts";
import { initLogger } from "../lib/logger.ts";

declare global {
  function syscall(name: string, ...args: any[]): Promise<any>;
}

let workerPostMessage = (_msg: ControllerMessage): void => {
  throw new Error("Not initialized yet");
};

// Are we running in a (web) worker?

// Determines if we're running in a web worker environment (Deno or browser)
// - in a browser's main threads, typeof window is "object"
// - in a browser's worker threads, typeof window === "undefined"
// - in Deno's main thread typeof window === "object"
// - in Deno's workers typeof window === "undefined
// - in Cloudflare workers typeof window === "undefined", but typeof globalThis.WebSocketPair is defined
const runningAsWebWorker = typeof window === "undefined" &&
  // @ts-ignore: globalThis
  typeof globalThis.WebSocketPair === "undefined";

if (typeof Deno === "undefined") {
  // @ts-ignore: Deno hack
  self.Deno = {
    args: [],
    // @ts-ignore: Deno hack
    build: {
      arch: "x86_64",
    },
    env: {
      // @ts-ignore: Deno hack
      get() {
      },
    },
  };
}

const pendingRequests = new Map<
  number,
  {
    resolve: (result: unknown) => void;
    reject: (e: any) => void;
  }
>();

let syscallReqId = 0;

if (runningAsWebWorker) {
  globalThis.syscall = async (name: string, ...args: any[]) => {
    return await new Promise((resolve, reject) => {
      syscallReqId++;
      pendingRequests.set(syscallReqId, { resolve, reject });
      workerPostMessage({
        type: "sys",
        id: syscallReqId,
        name,
        args,
      });
    });
  };
}

export function setupMessageListener(
  // deno-lint-ignore ban-types
  functionMapping: Record<string, Function>,
  manifest: any,
  postMessageFn: (msg: ControllerMessage) => void,
) {
  if (!runningAsWebWorker) {
    // Don't do any of this stuff if this is not a web worker
    // This caters to the NoSandbox run mode
    return;
  }
  workerPostMessage = postMessageFn;
  self.addEventListener("message", (event: { data: WorkerMessage }) => {
    (async () => {
      const data = event.data;
      switch (data.type) {
        case "inv":
          {
            const fn = functionMapping[data.name!];
            if (!fn) {
              throw new Error(`Function not loaded: ${data.name}`);
            }
            try {
              const result = await Promise.resolve(fn(...(data.args || [])));
              workerPostMessage({
                type: "invr",
                id: data.id,
                result: result,
              } as ControllerMessage);
            } catch (e: any) {
              console.error(
                "An exception was thrown as a result of invoking function",
                data.name,
                "error:",
                e.message,
              );
              workerPostMessage({
                type: "invr",
                id: data.id!,
                error: e.message,
              });
            }
          }
          break;
        case "sysr":
          {
            const syscallId = data.id;
            const lookup = pendingRequests.get(syscallId);
            if (!lookup) {
              throw Error("Invalid request id");
            }
            pendingRequests.delete(syscallId);
            if (data.error) {
              lookup.reject(new Error(data.error));
            } else {
              lookup.resolve(data.result);
            }
          }

          break;
      }
    })().catch(console.error);
  });
  // Signal initialization with manifest
  workerPostMessage({
    type: "manifest",
    manifest,
  });
  initLogger(`[${manifest.name} plug]`);
}

export async function sandboxFetch(
  reqInfo: RequestInfo,
  options?: ProxyFetchRequest64,
): Promise<ProxyFetchResponse64> {
  if (typeof reqInfo !== "string") {
    const body = new Uint8Array(await reqInfo.arrayBuffer());
    const encodedBody = body.length > 0 ? base64Encode(body) : undefined;
    options = {
      method: reqInfo.method,
      headers: Object.fromEntries(reqInfo.headers.entries()),
      base64Body: encodedBody,
    };
    reqInfo = reqInfo.url;
  }
  return syscall("sandboxFetch.fetch", reqInfo, options);
}

// @ts-ignore: monkey patching fetch
globalThis.nativeFetch = globalThis.fetch;

// Monkey patch fetch()
export function monkeyPatchFetch() {
  // @ts-ignore: monkey patching fetch
  globalThis.fetch = async function (
    reqInfo: RequestInfo,
    init?: RequestInit,
  ): Promise<Response> {
    const encodedBody = init && init.body
      ? base64Encode(
        new Uint8Array(await (new Response(init.body)).arrayBuffer()),
      )
      : undefined;
    const r = await sandboxFetch(
      reqInfo,
      init && {
        method: init.method,
        headers: init.headers as Record<string, string>,
        base64Body: encodedBody,
      },
    );
    // Casting the response to "any" for now, since of weird Deno typing
    return new Response(
      (r.base64Body ? base64Decode(r.base64Body) : null) as any,
      {
        status: r.status,
        headers: r.headers,
      },
    );
  };
}

if (runningAsWebWorker) {
  monkeyPatchFetch();
}


================================================
FILE: client/reducer.ts
================================================
import type { Action, AppViewState } from "./types/ui.ts";
import type { PageMeta } from "../plug-api/types/index.ts";
import {
  isMarkdownPath,
  parseToRef,
} from "@silverbulletmd/silverbullet/lib/ref";

export default function reducer(
  state: AppViewState,
  action: Action,
): AppViewState {
  switch (action.type) {
    case "document-editor-loaded":
      return {
        ...state,
        isLoading: false,
        current: {
          path: action.path,
          meta: action.meta,
        },
      };
    case "page-loaded": {
      const mouseDetected = globalThis.matchMedia("(pointer:fine)").matches;
      return {
        ...state,
        isLoading: false,
        isMobile: !mouseDetected,
        current: {
          path: action.path,
          meta: action.meta as PageMeta,
        },
      };
    }
    case "document-editor-changed":
    case "page-changed":
      return {
        ...state,
        unsavedChanges: true,
      };
    case "document-editor-saved":
    case "page-saved": {
      return {
        ...state,
        unsavedChanges: false,
      };
    }
    case "update-current-page-meta": {
      // Update in the allPages list as well
      state.allPages = state.allPages.map((pageMeta) =>
        pageMeta.name === action.meta.name
          ? { ...action.meta, lastOpened: Date.now() }
          : pageMeta
      );
      // Can't update page meta if not on a page
      if (!state.current || !isMarkdownPath(state.current.path)) {
        return state;
      }
      return {
        ...state,
        current: {
          ...state.current,
          meta: action.meta,
        },
      };
    }
    case "online-status-change":
      return {
        ...state,
        isOnline: action.isOnline,
      };
    case "update-page-list": {
      // Let's move over any "lastOpened" times to the "allPages" list
      const oldPageMeta = new Map(
        [...state.allPages].map((pm) => [pm.name, pm]),
      );
      let currPageMeta: PageMeta | undefined;
      for (const pageMeta of action.allPages) {
        const oldPageMetaItem = oldPageMeta.get(pageMeta.name);
        if (oldPageMetaItem && oldPageMetaItem.lastOpened) {
          pageMeta.lastOpened = oldPageMetaItem.lastOpened;
        }
        if (parseToRef(pageMeta.name)?.path === state.current?.path) {
          currPageMeta = pageMeta;
        }
      }
      const newState = {
        ...state,
        allPages: action.allPages,
      };
      if (currPageMeta) {
        newState.current!.meta = currPageMeta;
      }
      return newState;
    }
    case "update-document-list": {
      return {
        ...state,
        allDocuments: action.allDocuments,
      };
    }
    case "start-navigate": {
      return {
        ...state,
        showPageNavigator: true,
        pageNavigatorMode: action.mode,
        showCommandPalette: false,
        showFilterBox: false,
      };
    }
    case "stop-navigate":
      return {
        ...state,
        showPageNavigator: false,
      };

    case "show-palette": {
      return {
        ...state,
        showCommandPalette: true,
        showPageNavigator: false,
        showFilterBox: false,
        showCommandPaletteContext: action.context,
        commands: action.commands,
      };
    }
    case "hide-palette":
      return {
        ...state,
        showCommandPalette: false,
        showCommandPaletteContext: undefined,
      };
    case "update-commands":
      return {
        ...state,
        commands: action.commands,
      };
    case "show-notification":
      return {
        ...state,
        notifications: [...state.notifications, action.notification],
      };
    case "dismiss-notification":
      return {
        ...state,
        notifications: state.notifications.filter((n) => n.id !== action.id),
      };
    case "show-panel":
      return {
        ...state,
        panels: {
          ...state.panels,
          [action.id]: action.config,
        },
      };
    case "hide-panel":
      return {
        ...state,
        panels: {
          ...state.panels,
          [action.id]: {},
        },
      };

    case "show-filterbox":
      return {
        ...state,
        showFilterBox: true,
        filterBoxOnSelect: action.onSelect,
        filterBoxPlaceHolder: action.placeHolder,
        filterBoxOptions: action.options,
        filterBoxLabel: action.label,
        filterBoxHelpText: action.helpText,
      };
    case "hide-filterbox":
      return {
        ...state,
        showCommandPalette: false,
        showPageNavigator: false,
        showFilterBox: false,
        filterBoxOnSelect: () => {
        },
        filterBoxPlaceHolder: "",
        filterBoxOptions: [],
        filterBoxHelpText: "",
      };
    case "show-prompt":
      return {
        ...state,
        showPrompt: true,
        promptDefaultValue: action.defaultValue,
        promptMessage: action.message,
        promptCallback: action.callback,
      };
    case "hide-prompt":
      return {
        ...state,
        showPrompt: false,
        promptDefaultValue: undefined,
        promptMessage: undefined,
        promptCallback: undefined,
      };
    case "show-confirm":
      return {
        ...state,
        showConfirm: true,
        confirmMessage: action.message,
        confirmCallback: action.callback,
      };
    case "hide-confirm":
      return {
        ...state,
        showConfirm: false,
        confirmMessage: undefined,
        confirmCallback: undefined,
      };
    case "set-ui-option":
      return {
        ...state,
        uiOptions: {
          ...state.uiOptions,
          [action.key]: action.value,
        },
      };
    case "set-progress":
      return {
        ...state,
        progressPercentage: action.progressPercentage,
        progressType: action.progressType,
      };
  }
}


================================================
FILE: client/service_registry.test.ts
================================================
import type { EventHookT } from "@silverbulletmd/silverbullet/type/manifest";
import { EventHook } from "./plugos/hooks/event.ts";
import { System } from "./plugos/system.ts";
import { ServiceRegistry } from "./service_registry.ts";
import { assertEquals } from "@std/assert";
import { Config } from "./config.ts";

Deno.test("Test services", async () => {
  const system = new System<EventHookT>();
  const config = new Config();
  const eventHook = new EventHook(config);
  system.addHook(eventHook);

  const registry = new ServiceRegistry(eventHook, config);

  registry.define({
    selector: "greeter",
    match: {
      priority: 1,
    },
    run: (name: string) => {
      return Promise.resolve(`Hello ${name}!`);
    },
  });

  registry.define({
    selector: "greeter-multi",
    match: () => {
      return Promise.resolve({
        priority: 1,
      });
    },
    run: (name: string) => {
      return Promise.resolve(`Hello 1 ${name}!`);
    },
  });

  registry.define({
    selector: "greeter-multi",
    match: {
      priority: 2,
    },
    run: (name: string) => {
      return Promise.resolve(`Hello 2 ${name}!`);
    },
  });

  registry.define({
    selector: "greeter-multi",
    match: () => {
      return Promise.resolve(null);
    },
    run: () => {
      throw new Error("I should never be run");
    },
  });

  const matches = await registry.discover("greeter", {});
  assertEquals(matches.length, 1);
  assertEquals(matches[0].priority, 1);

  const matchesMulti = await registry.discover("greeter-multi", {});
  assertEquals(matchesMulti.length, 2);
  // higher prio should come first
  assertEquals(matchesMulti[0].priority, 2);
  assertEquals(matchesMulti[1].priority, 1);

  assertEquals(await registry.invoke(matches[0], "Pete"), "Hello Pete!");
  assertEquals(
    await registry.invokeBestMatch("greeter", "Pete"),
    "Hello Pete!",
  );

  assertEquals(
    await registry.invokeBestMatch("greeter-multi", "Pete"),
    "Hello 2 Pete!",
  );
});


================================================
FILE: client/service_registry.ts
================================================
import type { Config } from "./config.ts";
import type { EventHook } from "./plugos/hooks/event.ts";

export type ServiceMatch = {
  id?: string; // uuid, set automatically
  priority?: number;
} & Record<string, any>;

export type ServiceSpec = {
  selector: string;
  match:
    | ServiceMatch
    | ((data: any) => Promise<ServiceMatch | null | undefined>);
  run: (data: any) => Promise<any>;
};

export class ServiceRegistry {
  constructor(private eventHook: EventHook, private config: Config) {
  }

  public define(spec: ServiceSpec): void {
    const id = crypto.randomUUID();
    // Register with discover:* event
    this.config.insert([
      "eventListeners",
      `discover:${spec.selector}`,
    ], async (e: any) => {
      const matchResult = typeof spec.match === "function"
        ? await spec.match(e.data)
        : spec.match;
      if (matchResult) {
        return {
          ...matchResult,
          id,
        };
      }
    });
    // Register callback when invoked
    this.config.insert(["eventListeners", `service:${id}`], (e: any) => {
      return spec.run(e.data);
    });
  }

  public async discover(selector: string, opts: any): Promise<ServiceMatch[]> {
    const discoveryResults: ServiceMatch[] = await this.eventHook.dispatchEvent(
      `discover:${selector}`,
      opts,
    );
    discoveryResults.sort((a, b) => (b.priority || 0) - (a.priority || 0));
    return discoveryResults;
  }

  public async invoke(match: ServiceMatch, data: any): Promise<any> {
    const results = await this.eventHook.dispatchEvent(
      `service:${match.id}`,
      data,
    );
    // Note: results may be an empty array in case no service actually returned a result (void) case, which is implicitly passed on here
    return results[0];
  }

  public async invokeBestMatch(selector: string, data: any): Promise<any> {
    const results = await this.discover(selector, data);
    if (results.length === 0) {
      throw new Error(`No services matching: ${selector}`);
    }
    return this.invoke(results[0], data);
  }
}


================================================
FILE: client/service_worker.ts
================================================
import { initLogger } from "./lib/logger.ts";
import { ProxyRouter } from "./service_worker/proxy_router.ts";

import { SyncEngine } from "./service_worker/sync_engine.ts";
import type {
  ServiceWorkerSourceMessage,
  ServiceWorkerTargetMessage,
} from "./types/ui.ts";
import {
  deriveDbName,
  exportKey,
  importKey,
} from "@silverbulletmd/silverbullet/lib/crypto";
import { IndexedDBKvPrimitives } from "./data/indexeddb_kv_primitives.ts";
import { fsEndpoint } from "./spaces/constants.ts";
import { DataStoreSpacePrimitives } from "./spaces/datastore_space_primitives.ts";
import { HttpSpacePrimitives } from "./spaces/http_space_primitives.ts";
import { throttleImmediately } from "@silverbulletmd/silverbullet/lib/async";
import { wrongSpacePathError } from "@silverbulletmd/silverbullet/constants";
import type { KvPrimitives } from "./data/kv_primitives.ts";
import { EncryptedKvPrimitives } from "./data/encrypted_kv_primitives.ts";

const logger = initLogger("[Service Worker]");

// Note: the only thing cached here is SilverBullet client assets, files are kept in IndexedDB
const CACHE_NAME = "{{CACHE_NAME}}";

//`location.href` minus this worker's filename will be our base URL, including any URL prefix
//(-1 is to remove the trailing '/')
const workerFilename = location.pathname.substring(
  location.pathname.lastIndexOf("/") + 1,
);
const baseURI = location.href.substring(
  0,
  location.href.length - workerFilename.length - 1,
);
const basePathName = location.pathname.substring(
  0,
  location.pathname.length - workerFilename.length - 1,
);

const precacheFiles = Object.fromEntries([
  "/",
  "/.client/client.js",
  "/.client/favicon.png",
  "/.client/iAWriterMonoS-Bold.woff2",
  "/.client/iAWriterMonoS-BoldItalic.woff2",
  "/.client/iAWriterMonoS-Italic.woff2",
  "/.client/iAWriterMonoS-Regular.woff2",
  "/.client/logo.png",
  "/.client/logo-dock.png",
  "/.client/main.css",
  "/.client/manifest.json",
].map((path) => [path, `${baseURI}${path}?v=${CACHE_NAME}`, path])); // Cache busting

// Initially set to undefined, resulting in all "fetch" being proxied.
// Once the service worker is configured, this will be set and the proxy will handle fetches.
const proxyRouter = new ProxyRouter(
  basePathName,
  baseURI,
  precacheFiles,
);

// Configuration mutex
let configuring = false;

// @ts-ignore: debugging
globalThis.proxyRouter = proxyRouter;

// This is the in-memory store of an encryption key that SB clients and the index engine can share without asking for it constantly
let encryptionKeyMemoryStore: CryptoKey | undefined;

// Let's clean this encryptionKey if there's no more clients left for a little while, asking to re-enter
setInterval(() => {
  // @ts-ignore: service worker API
  globalThis.clients.matchAll().then((clients) => {
    if (clients.length === 0 && encryptionKeyMemoryStore) {
      console.info("No more clients, flushing encryption key");
      encryptionKeyMemoryStore = undefined;
    }
  });
}, 5000); // little while is 5s

// Message received from client
self.addEventListener("message", async (event: any) => {
  const message: ServiceWorkerTargetMessage = event.data;
  switch (message.type) {
    case "skip-waiting": {
      // @ts-ignore: Skip waiting to activate this service worker immediately
      self.skipWaiting();
      break;
    }
    case "shutdown": {
      proxyRouter.reset();
      break;
    }
    case "flush-cache": {
      const cacheNames = await caches.keys();

      await Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheName !== CACHE_NAME) {
            console.log("Removing cache", cacheName);
            return caches.delete(cacheName);
          }
        }),
      );
      broadcastMessage({
        type: "cacheFlushed",
      });
      break;
    }
    case "wipe-data": {
      if (proxyRouter.syncEngine) {
        await proxyRouter.syncEngine.wipe();
        broadcastMessage({
          type: "dataWiped",
        });
      } else {
        console.warn("Not performing sync data wipe, sync engine not started");
      }
      break;
    }
    case "perform-file-sync": {
      if (proxyRouter.syncEngine) {
        await proxyRouter.syncEngine.syncSingleFile(
          message.path,
        );
      } else {
        console.warn(
          "Ignoring perform-file-sync request, proxy not configured yet",
        );
      }

      break;
    }
    case "perform-space-sync": {
      if (proxyRouter.syncEngine) {
        await proxyRouter.syncEngine.syncSpace();
      } else {
        console.warn(
          "Ignoring perform-space-sync request, proxy not configured yet",
        );
      }
      break;
    }
    case "get-encryption-key": {
      event.source.postMessage({
        type: "encryption-key",
        key: encryptionKeyMemoryStore &&
          await exportKey(encryptionKeyMemoryStore),
      } as ServiceWorkerSourceMessage);
      break;
    }
    case "set-encryption-key": {
      encryptionKeyMemoryStore = await importKey(message.key);
      console.info("Encryption phrase set");
      break;
    }
    case "config": {
      const config = message.config;
      // Configure the service worker if it hasn't been already
      if (isConfigured()) {
        console.info(
          "Service worker already configured, just updating configs",
        );
        proxyRouter.syncEngine!.setSyncConfig({
          syncDocuments: config.syncDocuments,
          syncIgnore: config.syncIgnore,
        });

        return;
      } else {
        console.info("Service being configured with", config);
      }
      if (configuring) {
        console.info("Configuration already in progress, skipping");
        return;
      }
      // Lock configuration mutex
      configuring = true;
      // Put a timeout on it, just in case
      setTimeout(() => {
        configuring = false;
      }, 5000);
      try {
        if (config.enableClientEncryption) {
          if (!encryptionKeyMemoryStore) {
            console.error(
              "Supposed to use encryption, but no phrase set yet, auth error",
            );
            broadcastMessage({
              type: "auth-error",
              message: "Re-authentication required, redirecting...",
              actionOrRedirectHeader: ".auth",
            });
            // ABORT
            return;
          }
        }

        const spaceFolderPath = config.spaceFolderPath;
        const dbName = await deriveDbName(
          "files",
          spaceFolderPath,
          baseURI,
          encryptionKeyMemoryStore,
        );

        if (config.logPush) {
          setInterval(() => {
            logger.postToServer(".logs", "service_worker");
          }, 1000);
        }

        // Setup KV (database) for store synced files
        let kv: KvPrimitives = new IndexedDBKvPrimitives(dbName);
        await (kv as IndexedDBKvPrimitives).init();
        console.log("Using IndexedDB database", dbName);

        if (encryptionKeyMemoryStore) {
          kv = new EncryptedKvPrimitives(kv, encryptionKeyMemoryStore);
          await (kv as EncryptedKvPrimitives).init();
          console.log("Enabled client-side encryption for synced files");
        }

        // And use that to power the IndexedDB backed local storage
        const local = new DataStoreSpacePrimitives(kv);

        // Which we'll sync with the remote server
        const remote = new HttpSpacePrimitives(
          basePathName + fsEndpoint,
          spaceFolderPath,
          (message, actionOrRedirectHeader) => {
            // And auth error occured
            console.error(
              "[service proxy error]",
              message,
              actionOrRedirectHeader,
            );
            if (message === wrongSpacePathError.message) {
              proxyRouter.reset();
            }
            broadcastMessage({
              type: "auth-error",
              message,
              actionOrRedirectHeader,
            });
          },
        );

        // Now let's setup sync
        const syncEngine = new SyncEngine(kv, local, remote);
        syncEngine.setSyncConfig({
          syncDocuments: config.syncDocuments,
          syncIgnore: config.syncIgnore,
        });
        await syncEngine.start();

        // Ok, we're ready to go, let's plug in the proxy router
        proxyRouter.configure(syncEngine);

        // And wire up some events
        proxyRouter.on({
          observedRequest: (path) => {
            // This is triggered for the currently open file, we want to proactively sync it to keep it up to date
            syncEngine.syncSingleFile(path);
          },
          onlineStatusUpdated: (isOnline) => {
            broadcastMessage({
              type: "online-status",
              isOnline,
            });
          },
        });
        syncEngine.on({
          syncProgress: (status) => {
            broadcastMessage({
              type: "sync-status",
              status,
            });
          },
          syncConflict: (path) => {
            console.warn("Sync conflict detected:", path);
            broadcastMessage({
              type: "sync-conflict",
              path,
            });
          },
          spaceSyncComplete: (operations) => {
            broadcastMessage({
              type: "space-sync-complete",
              operations,
            });
          },
          fileSyncComplete: (path, operations) => {
            broadcastMessage({
              type: "file-sync-complete",
              path,
              operations,
            });
          },
          syncError: (error) => {
            broadcastMessage({
              type: "sync-error",
              message: error.message,
            });
          },
        });
      } finally {
        // Unlock mutex
        configuring = false;
      }
      break;
    }
  }
});

function broadcastMessage(message: ServiceWorkerSourceMessage) {
  // @ts-ignore: service worker API
  const clients: any = self.clients;
  // Find all windows attached to this service worker
  clients.matchAll({
    type: "window",
  }).then((clients: any[]) => {
    clients.forEach((client) => {
      client.postMessage(message);
    });
    if (clients.length === 0) {
      console.info(
        "No clients are listening for messages, dropping message",
        message,
      );
    }
  });
}

const throttledServiceWorkerStarted = throttleImmediately(() => {
  broadcastMessage({
    type: "service-worker-started",
  });
}, 100);

self.addEventListener("fetch", (event: any) => {
  if (!isConfigured()) {
    throttledServiceWorkerStarted();
  }

  // Always delegate to the proxy router
  proxyRouter.onFetch(event);
});

// Service worker lifecycle management
self.addEventListener("install", (event: any) => {
  console.log("Installing service worker...");
  event.waitUntil(
    (async () => {
      const cache = await caches.open(CACHE_NAME);
      console.log(
        "Now pre-caching client files",
      );
      await cache.addAll(Object.values(precacheFiles));
      console.log(
        Object.keys(precacheFiles).length,
        "client files cached",
      );
      // @ts-ignore: Force the waiting service worker to become the active service worker
      await self.skipWaiting();
    })(),
  );
});

self.addEventListener("activate", (event: any) => {
  console.log("Activating new service worker!");

  if (!isConfigured()) {
    throttledServiceWorkerStarted();
  }

  event.waitUntil(
    (async () => {
      // Flush old caches
      const cacheNames = await caches.keys();
      await Promise.all(
        cacheNames.map((cacheName) => {
          if (cacheName !== CACHE_NAME) {
            console.log("Removing old cache", cacheName);
            return caches.delete(cacheName);
          }
        }),
      );
      // @ts-ignore: Take control of all clients as soon as the service worker activates
      await clients.claim();
    })(),
  );
});

console.log("Service worker loaded");

function isConfigured() {
  return !!proxyRouter.syncEngine;
}


================================================
FILE: client/service_worker/proxy_router.ts
================================================
import { fsEndpoint } from "../spaces/constants.ts";
import { decodePageURI } from "@silverbulletmd/silverbullet/lib/ref";
import type { SpacePrimitives } from "../spaces/space_primitives.ts";
import { fileMetaToHeaders, headersToFileMeta } from "../lib/util.ts";
import {
  notFoundError,
  offlineError,
  pingInterval,
} from "@silverbulletmd/silverbullet/constants";
import type { SyncEngine } from "./sync_engine.ts";
import { EventEmitter } from "../plugos/event.ts";

const alwaysProxy = [
  "/.auth",
  "/.shell",
  "/.logout",
  "/.config",
  "/.logs",
  "/.proxy",
];

export type ProxyRouterEvents = {
  // Use case: the user likely has this file open in the editor, so it's good to prioritize syncing it
  observedRequest: (path: string) => void;
  // Use case: client showing the "yellow bar" indicating not being online
  onlineStatusUpdated: (isOnline: boolean) => void;
};

/**
 * Implements a service worker level HTTP proxy (fetch requests) that serves /.fs calls locally for synced spaces
 */
export class ProxyRouter extends EventEmitter<ProxyRouterEvents> {
  private fullSyncConfirmed = false;
  online = true;
  localSpacePrimitives?: SpacePrimitives;
  syncEngine?: SyncEngine;

  constructor(
    private basePathName: string,
    private baseURI: string,
    private precacheFiles: Record<string, string>,
  ) {
    super();
    // Actively check if we're online by pinging the server
    this.checkOnline();
    setInterval(() => {
      this.checkOnline();
    }, pingInterval);
  }

  /**
   * Called as soon the service worker is configured, and the service worker is ready to start serving requests.
   */
  configure(syncEngine: SyncEngine) {
    this.localSpacePrimitives = syncEngine.local;
    this.syncEngine = syncEngine;
    syncEngine.on({
      spaceSyncComplete: () => {
        if (!this.fullSyncConfirmed) {
          this.fullSyncConfirmed = true;
          console.log(
            "First full sync confirmed, will now start serving requests locally",
          );
        }
      },
    });
  }

  /**
   * Stops service worker operation only to be continued after reconfiguration
   */
  reset() {
    console.log("Shutting down proxy router and linked components");
    if (this.syncEngine) {
      this.syncEngine.stop();
      this.syncEngine = undefined;
    }
  }

  async checkOnline() {
    if (this.syncEngine) {
      try {
        await this.syncEngine.remote.ping();
        // If the ping is successful, we are online
        this.online = true;
      } catch {
        // Otherwise we're not
        this.online = false;
      } finally {
        this.emit("onlineStatusUpdated", this.online);
      }
    } else {
      console.info(
        "Sync engine not initialized yet, cannot check online status",
      );
    }
  }

  /**
   * Handles /.fs fetch events from the service worker.
   * @param event FetchEvent from the service worker
   */
  public onFetch(event: any) {
    const url = new URL(event.request.url);

    const pathname = url.pathname.substring(this.basePathName.length); //url.pathname with any URL prefix removed

    // Use the custom cache key if available, otherwise use the request URL
    const cacheKey = this.precacheFiles[pathname] || event.request.url;

    event.respondWith(
      (async () => {
        const request = event.request;
        const requestUrl = new URL(request.url);
        try {
          // Are we fetching a URL from the same origin as the app? If not, we don't handle it and pass it on
          if (!requestUrl.href.startsWith(this.baseURI)) {
            return fetch(request);
          }

          // Try the static (client) file cache first
          const cachedResponse = await caches.match(cacheKey);
          // Return the cached response if found
          if (cachedResponse) {
            return cachedResponse;
          }

          //requestUrl.pathname without with any URL prefix removed
          const pathname = requestUrl.pathname.substring(
            this.basePathName.length,
          );

          if (
            // Not yet configured -> Proxy
            !this.localSpacePrimitives || !this.syncEngine ||
            // Not fully synced but online -> Proxy
            (!this.fullSyncConfirmed && this.online) ||
            // A path we always need to proxy -> Proxy
            alwaysProxy.find((prefix) => pathname.startsWith(prefix))
          ) {
            try {
              return await fetch(request);
            } catch (e: any) {
              if (e.message === "Offline") {
                console.info(
                  "Detected offline, marking offline and falling through",
                );
                this.online = false;
              } else {
                throw e;
              }
            }
          }

          // We are now in a state we're configured and either a full sync cycle has complete (since boot) OR we're offline

          if (
            pathname.startsWith(fsEndpoint) &&
            pathname.endsWith(".md") &&
            !request.headers.has("X-Sync-Mode")
          ) {
            // This handles the case of ending up with a .md URL in the browser address bar (likely due to a auth proxy redirect)
            return Response.redirect(
              `${pathname.slice(fsEndpoint.length, -3)}`,
            );
          } else if (pathname.startsWith(fsEndpoint)) {
            // Handle /.fs file system APIs
            return this.handleRequest(pathname, request);
          } else {
            // Fallback to the app shell for all other requests (SPA)
            return (await caches.match(this.precacheFiles["/"])) ||
              fetch(request);
          }
        } catch (e: any) {
          console.warn("Fetch failed for", request.url, "error:", e.message);
          this.online = false;
          return new Response(offlineError.message, {
            status: 503, // Service Unavailable
          });
        }
      })(),
    );
  }

  handleRequest(
    pathname: string,
    request: Request,
  ): Promise<Response> {
    const path = decodePageURI(pathname.slice(fsEndpoint.length + 1));
    switch (request.method) {
      case "GET": {
        if (!path) { // .fs GET
          return this.handleFileListing();
        } else { // .fs/* GET
          return this.handleGet(path, request);
        }
      }
      case "PUT": {
        return this.handlePut(path, request);
      }
      case "DELETE": {
        return this.handleDelete(path, request);
      }
      default: {
        console.log("Unhandled method", request.method, "proxying to server");
        return fetch(request);
      }
    }
  }

  /**
   * Shortcut to nonSyncedFiles kept in snapshot
   */
  get nonSyncedFiles() {
    return this.syncEngine!.snapshot.nonSyncedFiles;
  }

  async handleFileListing(): Promise<Response> {
    if (!this.syncEngine || !this.localSpacePrimitives) {
      throw new Error("This should not happen");
    }

    const files = await this.localSpacePrimitives.fetchFileList();
    // Now augment this with non-synced file metadata
    for (
      const nonSyncedFile of this.nonSyncedFiles
        .values()
    ) {
      const existingFile = files.find((file) =>
        file.name === nonSyncedFile.name
      );
      if (!existingFile) {
        files.push(nonSyncedFile);
      }
    }
    return new Response(
      JSON.stringify(files),
      {
        headers: {
          "Content-Type": "application/json",
        },
      },
    );
  }

  async handleGet(path: string, request: Request): Promise<Response> {
    if (!this.syncEngine || !this.localSpacePrimitives) {
      throw new Error("This should not happen");
    }

    try {
      if (request.headers.has("x-get-meta")) {
        // Requesting only file meta
        const meta = await this.localSpacePrimitives.getFileMeta(path);
        if (request.headers.has("x-observing")) {
          setTimeout(() => {
            // Next tick
            this.emit("observedRequest", path);
          });
        }
        return new Response(null, {
          headers: fileMetaToHeaders(meta),
        });
      } else {
        const { meta, data } = await this.localSpacePrimitives.readFile(path);
        return new Response(data as any, {
          headers: fileMetaToHeaders(meta),
        });
      }
    } catch (err: any) {
      if (err.message === notFoundError.message && this.online) {
        console.info("No local copy of", path, "proxying to server");
        // Not found locally, but we're online, so let's try the server
        return fetch(request);
      } else if (err.message === notFoundError.message) {
        console.warn(
          "No local copy of",
          path,
          "and offline, so will 404 on this one",
        );
        // We're not online so let's assume the file indeed doesn't exist
        // TODO: What could be nice here is to check if this is a nonSyncedFile and if so serve some sort of offline placeholder
        return new Response(notFoundError.message, {
          status: 404,
        });
      }
      console.error("Error reading", path, err.message);
      return new Response(err.message, {
        status: 500,
      });
    }
  }

  async handlePut(path: string, request: Request): Promise<Response> {
    if (!this.syncEngine || !this.localSpacePrimitives) {
      throw new Error("This should not happen");
    }
    try {
      // console.log("Doing a write for", path);
      if (!this.syncEngine.isSyncCandidate(path) && this.online) {
        console.log("Handling file write for non-synced file", path);
        // Writing a non-synced file while being online
        // Proxy the request
        const resp = await fetch(request);
        // Update the nonSynced snapshot in place for later file listing consistency
        this.nonSyncedFiles.set(path, headersToFileMeta(path, resp.headers)!);
        return resp;
      } else {
        // Synced file
        const body = await request.arrayBuffer();
        // console.log("Handling file write", path, body.byteLength);
        const meta = await this.localSpacePrimitives.writeFile(
          path,
          new Uint8Array(body),
          // Note: there are going to be many cases where no meta is supplied in the request, this is ok, in that case this argument will be undefined
          headersToFileMeta(path, request.headers),
        );
        // Attempt immediate sync
        try {
          const operations = await this.syncEngine.syncSingleFile(path);
          if (operations === -1) {
            console.info("File sync delayed for", path);
            // Sync was in progress, will sync later
            return new Response("Delayed", {
              status: 202,
              headers: fileMetaToHeaders(meta),
            });
          }
        } catch (e: any) {
          console.error(
            "File sync delayed for",
            path,
            "due to error",
            e.message,
          );
          // Sync failed (could be offline or other reason)
          return new Response(e.message, {
            status: 202,
            headers: fileMetaToHeaders(meta),
          });
        }

        return new Response("OK", {
          status: 200,
          headers: fileMetaToHeaders(meta),
        });
      }
    } catch (e: any) {
      console.error("Error writing", path, e.message);
      return new Response(e.message, {
        status: 500,
      });
    }
  }

  async handleDelete(path: string, request: Request): Promise<Response> {
    if (!this.syncEngine || !this.localSpacePrimitives) {
      throw new Error("This should not happen");
    }

    try {
      if (!this.syncEngine.isSyncCandidate(path)) {
        console.log("Handling file delete for non-synced file", path);
        this.nonSyncedFiles.delete(path);
        // Proxy the request
        return fetch(request);
      }
      // console.log("Handling file delete", path);
      await this.localSpacePrimitives.deleteFile(path);
      return new Response("OK", {
        status: 200,
      });
    } catch (e: any) {
      console.error("Error deleting", path, e.message);
      if (e.message === notFoundError.message) {
        return new Response(notFoundError.message, {
          status: 404,
        });
      }
      return new Response(e.message, {
        status: 500,
      });
    }
  }
}


================================================
FILE: client/service_worker/sync_engine.ts
================================================
import { compile as gitIgnoreCompiler } from "gitignore-parser";
import { jitter, sleep } from "@silverbulletmd/silverbullet/lib/async";
import type { KvPrimitives } from "../data/kv_primitives.ts";
import { EventEmitter } from "../plugos/event.ts";
import { stdLibPrefix } from "../spaces/constants.ts";
import type { SpacePrimitives } from "../spaces/space_primitives.ts";
import { SpaceSync, SyncSnapshot, type SyncStatus } from "../spaces/sync.ts";
import type { HttpSpacePrimitives } from "../spaces/http_space_primitives.ts";

const syncSnapshotKey = ["$sync", "snapshot"];
const syncInterval = 20;

type SyncEngineEvents = {
  // Full sync cycle has completed
  spaceSyncComplete: (operations: number) => void | Promise<void>;

  // A single file syncle has completed
  fileSyncComplete: (path: string, operations: number) => void | Promise<void>;

  syncError: (error: Error) => void | Promise<void>;

  // Sync conflict occurred
  syncConflict: (path: string) => void | Promise<void>;

  // Sync progress updated
  syncProgress: (
    syncStatus: SyncStatus,
    snapshot: SyncSnapshot,
  ) => void | Promise<void>;
};

export type SyncConfig = {
  syncDocuments?: boolean;
  syncIgnore?: string;
};

/**
 * Thin wrapper around SpaceSync, adds snapshot persistence and a few other things
 */
export class SyncEngine extends EventEmitter<SyncEngineEvents> {
  spaceSync!: SpaceSync;

  private syncConfig: SyncConfig = {
    syncDocuments: true,
  };

  stopping = false;
  syncAccepts: (path: string) => boolean = () => true;
  snapshot!: SyncSnapshot;

  constructor(
    private kv: KvPrimitives,
    readonly local: SpacePrimitives,
    readonly remote: HttpSpacePrimitives,
  ) {
    super();
  }

  async start() {
    this.snapshot = await this.loadSnapshot();

    this.spaceSync = new SpaceSync(this.local, this.remote, {
      conflictResolver: this.stdLibAwareConflictResolver.bind(this),
      isSyncCandidate: this.isSyncCandidate.bind(this),
    });

    this.spaceSync.on({
      syncProgress: async (status, snapshot) => {
        this.emit("syncProgress", status, snapshot);
        await this.saveSnapshot(snapshot);
      },
      snapshotUpdated: this.saveSnapshot.bind(this),
    });

    // Start the sync loop
    this.run();
  }

  stop() {
    this.stopping = true;
  }

  async run() {
    while (true) {
      if (this.stopping) {
        return;
      }
      try {
        await this.syncSpace();
      } catch (e: any) {
        // User error communication is happening in syncSpace
        console.error("Sync space error", e.message);
      }
      await sleep(syncInterval * 1000 + jitter());
    }
  }

  public setSyncConfig(config: SyncConfig) {
    this.syncConfig = config;
    this.syncAccepts = config.syncIgnore
      ? gitIgnoreCompiler(config.syncIgnore).accepts
      : () => true;
    console.log(
      "[sync] Updated sync config:",
      this.syncConfig,
    );
  }

  isSyncCandidate(path: string): boolean {
    // ALWAYS sync plugs
    if (path.endsWith(".plug.js")) {
      return true;
    }
    // Follow SB_SYNC_IGNORE rules
    if (!this.syncAccepts(path)) {
      return false;
    }
    // Either sync all files, or only .md files if syncDocuments is false
    return this.syncConfig.syncDocuments || path.endsWith(".md");
  }

  async syncSpace(): Promise<number> {
    try {
      const operations = await this.spaceSync.syncFiles(this.snapshot);
      if (operations !== -1) {
        // emit successful sync event (not when operations === -1, because that means another sync was ongoing)
        this.emit("spaceSyncComplete", operations);
      }
      return operations;
    } catch (e) {
      this.emit("syncError", e);
      throw e;
    }
  }

  async syncSingleFile(path: string): Promise<number> {
    try {
      const operations = await this.spaceSync.syncSingleFile(
        path,
        this.snapshot,
      );
      this.emit("fileSyncComplete", path, operations);
      return operations;
    } catch (e) {
      this.emit("syncError", e);
      throw e;
    }
  }

  /**
   * Loads the sync snapshot from the data store.
   * @returns A map of sync status items.
   */
  async loadSnapshot(): Promise<SyncSnapshot> {
    const [snapshot] = await this.kv.batchGet([syncSnapshotKey]);
    return SyncSnapshot.fromJSON(snapshot);
  }

  /**
   * Saves the sync snapshot to the data store.
   * @param snapshot A map of sync status items.
   */
  saveSnapshot(snapshot: SyncSnapshot) {
    return this.kv.batchSet([{
      key: syncSnapshotKey,
      value: snapshot.toJSON(),
    }]);
  }

  async wipe() {
    this.stop();
    console.log("Wiping sync database");
    await this.kv.clear();
    console.log("Done wiping");
  }

  /**
   * Delegates to the standard primary conflict resolver, but in case of any conflicts in plugs, it will always take the version from the secondary.
   */
  async stdLibAwareConflictResolver(
    name: string,
    snapshot: SyncSnapshot,
    primary: SpacePrimitives,
    secondary: SpacePrimitives,
  ): Promise<number> {
    if (!name.startsWith(stdLibPrefix)) {
      const operations = await SpaceSync.primaryConflictResolver(
        name,
        snapshot,
        primary,
        secondary,
      );

      if (operations > 0) {
        // Something happened -> conflict copy generated, let's report it
        this.emit("syncConflict", name);
      }

      return operations;
    }
    console.log(
      "[sync]",
      "Conflict in plug",
      name,
      "will pick the version from secondary and be done with it.",
    );
    // Read file from secondary
    const { data, meta } = await secondary.readFile(
      name,
    );
    // Write file to primary
    const newMeta = await primary.writeFile(
      name,
      data,
      meta,
    );
    // Update snapshot
    snapshot.files.set(name, [
      newMeta.lastModified,
      meta.lastModified,
    ]);

    return 1;
  }
}


================================================
FILE: client/service_worker/util.ts
================================================
export async function unregisterServiceWorkers() {
  if (navigator.serviceWorker) {
    const registrations = await navigator.serviceWorker
      .getRegistrations();
    for (const registration of registrations) {
      await registration.unregister();
      console.log("Service worker unregistered");
    }
  } else {
    console.error("No service worker found to unregister");
  }
}

export function flushCachesAndUnregisterServiceWorker() {
  console.log("Going to flush the caches and unregister the service worker");
  return new Promise<void>((resolve) => {
    if (!navigator.serviceWorker?.controller) {
      console.log("No service worker active");
      return resolve();
    }

    navigator.serviceWorker.addEventListener("message", (event) => {
      if (event.data.type === "cacheFlushed") {
        console.log("Cache flushed");
        // Then unregister all service workers
        navigator.serviceWorker.getRegistrations().then((registrations) => {
          for (const registration of registrations) {
            registration.unregister();
            console.log("Service worker unregistered");
            resolve();
          }
        });
      }
    });

    // First flush active cache
    navigator.serviceWorker.ready.then((registration) => {
      console.log("Sending request to flush cache");
      registration.active!.postMessage({ type: "flush-cache" });
    });
  });
}


================================================
FILE: client/space.test.ts
================================================
import { sleep } from "@silverbulletmd/silverbullet/lib/async";
import { MemoryKvPrimitives } from "./data/memory_kv_primitives.ts";
import { EventHook } from "./plugos/hooks/event.ts";
import { Space } from "./space.ts";
import { DataStoreSpacePrimitives } from "./spaces/datastore_space_primitives.ts";
import { assertEquals } from "@std/assert";
import { parseToRef } from "@silverbulletmd/silverbullet/lib/ref";

const testPage = `
Some paragraph
# Header 1
Some text

# Header 2
* Item 1
  * Sub item
* [ ] Task 1
  * Sub item 2
    * Sub-sub item
`.trim();

Deno.test("readRef checks", async () => {
  const kv = new MemoryKvPrimitives();
  const eventHook = new EventHook();
  const space = new Space(new DataStoreSpacePrimitives(kv), eventHook);
  await sleep(1);
  await space.writePage("test", testPage);

  // Reference to page
  assertEquals(await space.readRef(parseToRef("test")!), {
    text: testPage,
    offset: 0,
  });

  // Pointer to a paragraph
  assertEquals(await space.readRef(parseToRef("test@0")!), {
    text: "Some paragraph",
    offset: 0,
  });

  // With a linecolumn ref
  assertEquals(await space.readRef(parseToRef("test@l1c1")!), {
    text: "Some paragraph",
    offset: 0,
  });

  // Reference to a header
  assertEquals(
    await space.readRef(parseToRef("test#Header 1")!),
    {
      text: "# Header 1\nSome text\n\n",
      offset: testPage.indexOf("# Header 1"),
    },
  );
  assertEquals(
    await space.readRef(parseToRef("test#Header 2")!),
    {
      text:
        "# Header 2\n* Item 1\n  * Sub item\n* [ ] Task 1\n  * Sub item 2\n    * Sub-sub item",
      offset: testPage.indexOf("# Header 2"),
    },
  );

  // Reference to an item should get item and children
  const itemPos = testPage.indexOf("* Item 1");
  assertEquals(
    await space.readRef(parseToRef(`test@${itemPos}`)!),
    { text: "* Item 1\n  * Sub item", offset: itemPos },
  );

  // Reference to a task should get item and children
  const taskPos = testPage.indexOf("* [ ] Task 1");
  assertEquals(
    await space.readRef(parseToRef(`test@${taskPos}`)!),
    {
      text: "* [ ] Task 1\n  * Sub item 2\n    * Sub-sub item",
      offset: taskPos,
    },
  );

  // Check left shift in case of jumping into nested item
  const subItemPos = testPage.indexOf("* Sub item 2");
  assertEquals(
    await space.readRef(parseToRef(`test@${subItemPos}`)!),
    { text: "* Sub item 2\n  * Sub-sub item", offset: subItemPos },
  );
});


================================================
FILE: client/space.ts
================================================
import type { EventHook } from "./plugos/hooks/event.ts";
import { jitter, safeRun } from "@silverbulletmd/silverbullet/lib/async";
import { localDateString } from "@silverbulletmd/silverbullet/lib/dates";
import type {
  DocumentMeta,
  FileMeta,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import type { SpacePrimitives } from "./spaces/space_primitives.ts";
import {
  getOffsetFromLineColumn,
  getPathExtension,
  type Path,
  type Ref,
} from "@silverbulletmd/silverbullet/lib/ref";
import { parseMarkdown } from "./markdown_parser/parser.ts";
import {
  addParentPointers,
  findNodeMatching,
  nodeAtPos,
  renderToText,
  traverseTree,
} from "@silverbulletmd/silverbullet/lib/tree";

const pageWatchInterval = 3000; // + jitter

/**
 * Wrapper around space primitives (see spaces/) for Client use
 * Adds:
 * - Concept of a page (on top of files)
 * - Page watchers
 */
export class Space {
  // We do watch files in the background to detect changes
  // This set of pages should only ever contain 1 page
  watchedFiles = new Set<string>();
  watchInterval?: number;

  // private initialPageListLoad = true;
  private saving = false;

  constructor(
    readonly spacePrimitives: SpacePrimitives,
    eventHook: EventHook,
  ) {
    eventHook.addLocalListener("file:deleted", (fileName: string) => {
      if (this.watchedFiles.has(fileName)) {
        this.watchedFiles.delete(fileName);
      }
    });
    setTimeout(async () => {
      // The only reason to do this is to trigger events
      await this.spacePrimitives.fetchFileList();
    });
  }

  async deletePage(name: string): Promise<void> {
    await this.getPageMeta(name); // Check if page exists, if not throws Error
    await this.spacePrimitives.deleteFile(`${name}.md`);
  }

  async getPageMeta(name: string): Promise<PageMeta> {
    return fileMetaToPageMeta(
      await this.spacePrimitives.getFileMeta(`${name}.md`),
    );
  }

  async listPlugs(): Promise<FileMeta[]> {
    const files = await this.deduplicatedFileList();
    return files
      .filter((fileMeta) => fileMeta.name.endsWith(".plug.js"));
  }

  async readPage(name: string): Promise<{ text: string; meta: PageMeta }> {
    const pageData = await this.spacePrimitives.readFile(`${name}.md`);
    return {
      text: new TextDecoder().decode(pageData.data),
      meta: fileMetaToPageMeta(pageData.meta),
    };
  }

  async readRef(ref: Ref): Promise<{ offset: number; text: string }> {
    if (!ref.path.endsWith(".md")) {
      throw new Error("Not supported");
    }
    const name = ref.path.slice(0, -3);
    const pageText = (await this.readPage(name)).text;
    if (!ref.details) {
      return { offset: 0, text: pageText };
    }
    const tree = parseMarkdown(pageText);
    addParentPointers(tree);
    if (ref.details.type === "linecolumn") {
      ref.details = {
        type: "position",
        pos: getOffsetFromLineColumn(
          pageText,
          ref.details.line,
          ref.details.column,
        ),
      };
    }
    switch (ref.details.type) {
      case "header": {
        const desiredHeaderText = ref.details.header;
        let text = `**Error:** Header not found: ${desiredHeaderText}`;
        let offset = 0;
        traverseTree(tree, (n) => {
          if (n.type && n.type.startsWith("ATXHeading")) {
            const level = +n.type!.substring("ATXHeading".length);
            const headerText = renderToText(n).slice(level + 1);
            let endPos = pageText.length;
            if (headerText === desiredHeaderText) {
              // Now we have to scan for the end point
              // Let's go up one level and find either another header at this same level
              const parent = n.parent!;
              const nextHeader = findNodeMatching(
                parent,
                (h) => h.type === `ATXHeading${level}` && h.from! > n.from!,
              );
              if (nextHeader) {
                endPos = nextHeader.from!;
              }
              text = pageText.slice(n.from!, endPos);
              offset = n.from!;
              return true;
            }
          }
          return false;
        });
        return { offset, text };
      }
      case "position": {
        const pos = ref.details.pos;
        let n = nodeAtPos(tree, pos);
        if (!n) {
          throw new Error("No node found at position");
        }
        if (["ListMark"].includes(n.type!)) {
          n = n.parent!;
        }
        const sliceText = pageText.slice(n.from!, n.to!);

        // Determine indent level
        const targetLineIndex =
          pageText.substring(0, n.from!).split("\n").length - 1;
        const lines = pageText.split("\n");
        const targetLine = lines[targetLineIndex];
        const indent = targetLine.match(/^\s*/)![0];
        return { offset: pos, text: sliceText.replaceAll(`\n${indent}`, "\n") };
      }
    }
    return { text: "", offset: 0 };
  }

  async writePage(
    name: string,
    text: string,
  ): Promise<PageMeta> {
    try {
      this.saving = true;
      const pageMeta = fileMetaToPageMeta(
        await this.spacePrimitives.writeFile(
          `${name}.md`,
          new TextEncoder().encode(text),
        ),
      );
      // Note: we don't do very elaborate cache invalidation work here, quite quickly the cache will be flushed anyway
      return pageMeta;
    } finally {
      this.saving = false;
    }
  }

  // We're listing all pages that don't start with a _
  isListedPage(fileMeta: FileMeta): boolean {
    return fileMeta.name.endsWith(".md") && !fileMeta.name.startsWith("_");
  }

  // Checks if this a document to be listed meaning:
  // - it's not a markdown file
  // - it's not a javascript of javascript source map (.map)
  isListedDocument(fileMeta: FileMeta): boolean {
    return !this.isListedPage(fileMeta) && !fileMeta.name.endsWith(".js") &&
      !fileMeta.name.endsWith(".map");
  }

  async fetchPageList(): Promise<PageMeta[]> {
    return (await this.deduplicatedFileList())
      .filter(this.isListedPage)
      .map(fileMetaToPageMeta);
  }

  async fetchDocumentList(): Promise<DocumentMeta[]> {
    return (await this.deduplicatedFileList()).flatMap((fileMeta) =>
      this.isListedDocument(fileMeta) ? [fileMetaToDocumentMeta(fileMeta)] : []
    );
  }

  async deduplicatedFileList(): Promise<FileMeta[]> {
    const files = await this.spacePrimitives.fetchFileList();
    const fileMap = new Map<string, FileMeta>();
    for (const file of files) {
      if (fileMap.has(file.name)) {
        const existing = fileMap.get(file.name)!;
        if (existing.lastModified < file.lastModified) {
          fileMap.set(file.name, file);
        }
      } else {
        fileMap.set(file.name, file);
      }
    }
    return [...fileMap.values()];
  }

  /**
   * Reads a document
   * @param name path of the document
   * @returns
   */
  async readDocument(
    name: string,
  ): Promise<{ data: Uint8Array; meta: DocumentMeta }> {
    const file = await this.spacePrimitives.readFile(name);
    return { data: file.data, meta: fileMetaToDocumentMeta(file.meta) };
  }

  async getDocumentMeta(name: string): Promise<DocumentMeta> {
    return fileMetaToDocumentMeta(
      await this.spacePrimitives.getFileMeta(name),
    );
  }

  async writeDocument(
    name: string,
    data: Uint8Array,
  ): Promise<DocumentMeta> {
    return fileMetaToDocumentMeta(
      await this.spacePrimitives.writeFile(name, data),
    );
  }

  deleteDocument(name: string): Promise<void> {
    return this.spacePrimitives.deleteFile(name);
  }

  /**
   * Polls for changes in the watched files.
   */
  watch() {
    if (this.watchInterval) {
      clearInterval(this.watchInterval);
    }
    this.watchInterval = setInterval(() => {
      safeRun(async () => {
        if (this.saving) {
          return;
        }
        for (const fileName of this.watchedFiles) {
          // Setting observing to true here to hint that we may be interested in more active syncing
          await this.spacePrimitives.getFileMeta(fileName, true);
        }
      });
    }, pageWatchInterval + jitter());
  }

  unwatch() {
    if (this.watchInterval) {
      clearInterval(this.watchInterval);
    }
  }

  watchFile(fileName: string) {
    this.watchedFiles.add(fileName);
  }

  unwatchFile(fileName: string) {
    this.watchedFiles.delete(fileName);
  }
}

export function fileMetaToPageMeta(fileMeta: FileMeta): PageMeta {
  const name = fileMeta.name.substring(0, fileMeta.name.length - 3);
  try {
    return {
      ...fileMeta,
      ref: name,
      tag: "page",
      tags: [],
      name,
      created: localDateString(new Date(fileMeta.created)),
      lastModified: localDateString(new Date(fileMeta.lastModified)),
    } as PageMeta;
  } catch (e) {
    console.error("Failed to convert fileMeta to pageMeta", fileMeta, e);
    throw e;
  }
}

export function fileMetaToDocumentMeta(
  fileMeta: FileMeta,
): DocumentMeta {
  try {
    return {
      ...fileMeta,
      ref: fileMeta.name,
      tag: "document",
      created: localDateString(new Date(fileMeta.created)),
      lastModified: localDateString(new Date(fileMeta.lastModified)),
      // Name is always equal to the path for documents
      extension: getPathExtension(fileMeta.name as Path),
    } as DocumentMeta;
  } catch (e) {
    console.error("Failed to convert fileMeta to documentMeta", fileMeta, e);
    throw e;
  }
}


================================================
FILE: client/space_lua.ts
================================================
import type { System } from "./plugos/system.ts";
import type { SpaceLuaObject } from "../plugs/index/space_lua.ts";
import { LuaEnv, LuaRuntimeError, LuaStackFrame } from "./space_lua/runtime.ts";
import { parse as parseLua, parseExpressionString } from "./space_lua/parse.ts";
import { evalStatement } from "./space_lua/eval.ts";
import {
  encodeRef,
  parseToRef,
  type Ref,
} from "@silverbulletmd/silverbullet/lib/ref";
import type { ASTCtx } from "./space_lua/ast.ts";
import { buildLuaEnv } from "./space_lua_api.ts";
import type { LuaCollectionQuery } from "./space_lua/query_collection.ts";
import type { ObjectIndex } from "./data/object_index.ts";

export class SpaceLuaEnvironment {
  env: LuaEnv;

  constructor(private system: System<any>, private objectIndex: ObjectIndex) {
    this.env = buildLuaEnv(system);
  }

  /**
   * Loads all Lua scripts from the database and evaluates them in a new environment
   * @param system
   */
  async reload() {
    const allScripts: SpaceLuaObject[] = await this.objectIndex.queryLuaObjects(
      this.env,
      "space-lua",
      {
        objectVariable: "script",
        orderBy: [{
          expr: parseExpressionString("script.priority"),
          desc: true,
        }],
      } as LuaCollectionQuery,
    );
    try {
      this.env = buildLuaEnv(this.system);
      const tl = new LuaEnv();
      tl.setLocal("_GLOBAL", this.env);
      for (const script of allScripts) {
        try {
          console.log("Now evaluating", script.ref);
          const ast = parseLua(script.script, { ref: script.ref });
          // We create a local scope for each script
          const scriptEnv = new LuaEnv(this.env);
          const sf = new LuaStackFrame(tl, ast.ctx);
          await evalStatement(ast, scriptEnv, sf);
        } catch (e: any) {
          if (e instanceof LuaRuntimeError) {
            const origin = resolveASTReference(e.sf.astCtx!);
            if (origin) {
              console.error(
                `Error evaluating script: ${e.message} at [[${
                  encodeRef(origin)
                }]]`,
              );
              continue;
            }
          }
          console.error(
            `Error evaluating script: ${e.message} for script: ${script.script}`,
            e,
          );
        }
      }

      console.log("[Lua] Loaded", allScripts.length, "scripts");
    } catch (e: any) {
      console.error("Error reloading Lua scripts:", e.message);
    }
  }
}

export function resolveASTReference(ctx?: ASTCtx): Ref | null {
  if (!ctx?.ref) {
    return null;
  }
  const ref = parseToRef(ctx.ref);
  if (!ref) {
    return null;
  }

  if (ref.details?.type === "position") {
    ref.details.pos = (ref.details.pos as number) +
      "```space-lua\n".length +
      ctx.from!;
  }

  return ref;
}


================================================
FILE: client/space_lua/arithmetic_test.lua
================================================
local function assertEquals(actual, expected, message)
  if actual ~= expected then
    error('Assertion failed: ' .. message)
  end
end

local function assertThrows(msg_substr, fn)
  local ok, err = pcall(fn)

  if ok then
    error('Assertion failed: expected error containing "'
      .. msg_substr .. '"')
  end

  if type(err) ~= 'string' then
    err = tostring(err)
  end

  if not string.find(err, msg_substr, 1, true) then
    error('Assertion failed: expected error message to contain "'
      .. msg_substr .. '", got: "' .. err .. '"')
  end
end

-- 1. Integer vs float zero divisors

-- 1.1. Integer zeros collapse (no -0 for integers)
assertEquals(1/0 == 1/-0, true, 'int: 1/0 == 1/-0 (+Inf)')
assertEquals(-1/0 == 1/-0, false, 'int: -1/0 != 1/-0')

-- 1.2. Float zeros sign preservation
assertEquals(1/0.0 == 1/-0.0, false, 'float: +Inf != -Inf')
assertEquals(1/0.0 == -1/-0.0, true, 'float: 1/0.0 == -1/-0.0')
assertEquals(-1/0.0 == 1/-0.0, true, 'float: -1/0.0 == 1/-0.0')

-- 1.3. Basic division
assertEquals(5/2, 2.5, 'div: 5/2 == 2.5')
assertEquals(-5/2, -2.5, 'div: -5/2 == -2.5')
assertEquals(5/-2, -2.5, 'div: 5/-2 == -2.5')
assertEquals(-5/-2, 2.5, 'div: -5/-2 == 2.5')

-- 2. Unary minus literals and simple expressions
assertEquals(1/-(0) == 1/0, true, 'unary minus: int literal (+Inf)')
assertEquals(1/-(0.0) == -1/0.0, true, 'unary minus: float literal (-Inf)')
assertEquals(1/-(1-1) == 1/0, true, 'unary minus: int expr (+Inf)')
assertEquals(1/-(1.0-1.0) == -1/0.0, true, 'unary minus: float expr (-Inf)')

-- 2.1. Unary minus coercion and precedence with power
assertEquals(-2^2, -4, 'precedence: -2^2 == -(2^2)')
assertEquals((-2)^2, 4, 'precedence: (-2)^2 == 4')

-- 2.1.1. Unary minus with float-typed exponentiation results
do
  local function mt(x)
    return math.type(x)
  end

  assertEquals(mt(-("1.0" ^ 1)), "float", "math.type(-('1.0'^1)) => float")
  assertEquals(mt(-"1.0" ^ 1), "float", "math.type(-'1.0'^1) => float")
  assertEquals(mt(-("2.0" ^ 1)), "float", "math.type(-('2.0'^1)) => float")
  assertEquals(mt(-("2.0" ^ 2)), "float", "math.type(-('2.0'^2)) => float")
end

-- 2.2. Unary minus uses table metadata for dynamic keys
local dyn_tbl = {}
local dyn_key = 'zf'
dyn_tbl[dyn_key] = 0.0
assertEquals(1/-(dyn_tbl[dyn_key]) == -1/0.0, true, 'unary minus: table dyn key float (-Inf)')

-- 2.3. Unary minus uses table metadata for property access
local prop_tbl = { zf = 0.0, zi = 0 }
assertEquals(1/-(prop_tbl.zf) == -1/0.0, true, 'unary minus: table prop float (-Inf)')
assertEquals(1/-(prop_tbl.zi) == 1/0, true, 'unary minus: table prop int (+Inf)')

-- 2.4. Unary minus uses env metadata for locals
local u_zi, u_zf, u_zfn = 0, 0.0, -0.0
assertEquals(1/-(u_zi) == 1/0, true, 'var: unary minus zi (+Inf)')
assertEquals(1/-(u_zfn) == 1/0.0, true, 'var: unary minus zfn (+Inf)')
assertEquals(1/u_zf == 1/0.0, true, 'var: zf (+Inf)')

-- 3. Integer operations (must not produce -0)
assertEquals(1/(1-1) == 1/0, true, 'int: sub (+0)')
assertEquals(1/(0*-1) == 1/0, true, 'int: mul (+0)')
assertEquals(1/(0%1) == 1/0, true, 'int: mod (+0)')
assertEquals(1/(0%-1) == 1/0, true, 'int: mod neg divisor (+0)')

-- 4. Float operations (must preserve -0.0)
assertEquals(1/(0.0*-1.0) == -1/ 0.0, true, 'float: mul (-0.0)')
assertEquals(1/((-0.0)%1.0) == -1/ 0.0, true, 'float: mod (-0.0)')
assertEquals(1/((-0.0)%-1.0) == -1/ 0.0, true, 'float: mod neg divisor (-0.0)')

-- 4.1. Zero result from float addition prefers +0.0
assertEquals(1/((-0.0)+0.0) == 1/0.0, true, 'float: (-0.0)+0.0 yields +0.0')

-- 4.2. Plain -0.0 handling in arithmetic
do
  local nz = -0.0
  -- Addition: -0.0 + 0 yields +0.0 (IEEE 754 rule)
  assertEquals(1/(nz + 0), 1/0.0, 'plain -0.0: nz + 0 yields +0.0')
  assertEquals(1/(0 + nz), 1/0.0, 'plain -0.0: 0 + nz yields +0.0')

  -- Multiplication preserves -0.0
  assertEquals(1/(nz * 1), -1/0.0, 'plain -0.0: nz * 1 yields -0.0')
  assertEquals(1/(1 * nz), -1/0.0, 'plain -0.0: 1 * nz yields -0.0')

  -- Subtraction: -0.0 - 0 = -0.0
  assertEquals(1/(nz - 0), 1/0.0, 'plain -0.0: nz - 0 yields +0.0')

  -- Subtraction: -0.0 - 0.0 = -0.0 (float)
  assertEquals(1/(nz - 0.0), -1/0.0, 'plain -0.0: nz - 0.0 yields -0.0')

  -- Subtraction: 0 - (-0.0) = +0.0
  assertEquals(1/(0 - nz), 1/0.0, 'plain -0.0: 0 - nz yields +0.0')
end

-- 4.3. Expression-generated -0.0
do
  local r = 0.0 * -1.0
  assertEquals(1/r, -1/0.0, 'expr: 0.0 * -1.0 yields -0.0')

  local s = -1.0 * 0.0
  assertEquals(1/s, -1/0.0, 'expr: -1.0 * 0.0 yields -0.0')

  -- Division producing -0.0
  local d = -0.0 / 1.0
  assertEquals(1/d, -1/0.0, 'expr: -0.0 / 1.0 yields -0.0')
end

-- 5. Mixed arithmetic producing zero
assertEquals(1/(0*-1.0) == -1/0.0, true, 'mixed: mul int*float (-0.0)')
assertEquals(1/(0.0*-1 ) == -1/0.0, true, 'mixed: mul float*int (-0.0)')
assertEquals(1/(1.0+(-1)) == 1/0.0, true, 'mixed: add (+0.0)')
assertEquals(1/(-(1-1.0)) == -1/0.0, true, 'mixed: sub then unary minus (-0.0)')

-- 5.1. Dynamic key table metadata affects binary ops
local dyn_t, dyn_k = {}, 'zf'
dyn_t[dyn_k] = 0.0
assertEquals(1/dyn_t[dyn_k] == 1/0.0, true, 'binary: table dyn key float (+Inf)')

-- 6. Variables
local zi, zf, zfn = 0, 0.0, -0.0

assertEquals(1/zi == 1/0, true, 'var: zi (+Inf)')
assertEquals(1/zf == 1/0.0, true, 'var: zf (+Inf)')
assertEquals(1/zfn == -1/0.0, true, 'var: zfn (-Inf)')
assertEquals(1/-(zi) == 1/0, true, 'var: unary minus zi (+Inf)')
assertEquals(1/-(zfn) == 1/0.0, true, 'var: unary minus zfn (+Inf)')

-- 6.1. Variables: metadata must flow through reassignment
local zswap = 0.0
assertEquals(1/zswap == 1/0.0, true, 'var: zswap float (+Inf)')
zswap = 0
assertEquals(1/zswap == 1/0, true, 'var: zswap reassigned int (+Inf)')
zswap = 0.0
assertEquals(1/zswap == 1/0.0, true, 'var: zswap reassigned float (+Inf)')

-- 7. Functions returning zeros and unary minus
local function ret_zi()
  return 0
end

local function ret_zf()
  return 0.0
end

local function ret_zfn()
  return -0.0
end

assertEquals(1/ret_zi() == 1/0, true, 'fn: ret_zi (+Inf)')
assertEquals(1/ret_zf() == 1/0.0, true, 'fn: ret_zf (+Inf)')
assertEquals(1/ret_zfn() == -1/0.0, true, 'fn: ret_zfn (-Inf)')

assertEquals(1/-(ret_zi()) == 1/0, true, 'fn unary minus: ret_zi (+Inf)')
assertEquals(1/-(ret_zf()) == -1/0.0, true, 'fn unary minus: ret_zf (-Inf)')
assertEquals(1/-(ret_zfn()) == 1/0.0, true, 'fn unary minus: ret_zfn (+Inf)')

-- 8. Tables and arrays
local t, arr = {zi=zi, zfn=zfn}, {zi, zfn}

-- 8.1. Tables
assertEquals(1/t.zi == 1/0, true, 'table: t.zi (+Inf)')
assertEquals(1/t.zfn == -1/0.0, true, 'table: t.zfn (-Inf)')

-- 8.2. Arrays
assertEquals(1/arr[1] == 1/0, true, 'array: arr[1]=zi (+Inf)')
assertEquals(1/arr[2] == -1/0.0, true, 'array: arr[2]=zfn (-Inf)')

-- 8.3. Arrays: dynamic numeric index key metadata
local arr2 = {}
local idx = 1
arr2[idx] = 0.0
assertEquals(1/arr2[idx] == 1/0.0, true, 'array: dyn index float (+Inf)')

-- 9. Deeply nested parentheses and expressions
local xi, xf = 1-1, 1.0-1.0
local deepi = -((((0+0)-(1-1))+(zi-xi))) -- int path (+0)
local deepf = -((((0.0+0.0)-(1.0-1.0))+(zf-xf))) -- float path (-0.0)

assertEquals(1/deepi == 1/0, true, 'nested: int (+Inf)')
assertEquals(1/deepf == -1/0.0, true, 'nested: float (-Inf)')

-- 10. Floor division near zero
assertEquals(1/(0//1) == 1/0, true, 'floor div: int (+0)')
assertEquals(1/(0.0//1.0) == 1/0.0, true, 'floor div: float (+Inf)')
assertEquals(1/((-0.0)//1.0) == -1/0.0, true, 'floor div: float (-Inf)')
assertEquals(1/(0//1.0) == 1/0.0, true, 'floor div: mixed (+0.0)')

-- 10.1. Modulo/division identity
local function id_ok(a, b)
  return a == b * (a // b) + a % b
end

assertEquals(id_ok(5, 2), true, 'identity: 5, 2')
assertEquals(id_ok(-5, 2), true, 'identity: -5, 2')
assertEquals(id_ok(5, -2), true, 'identity: 5, -2')
assertEquals(id_ok(-5, -2), true, 'identity: -5, -2')

-- 10.2. Floor division signs
assertEquals(5//-2, -3, 'idiv: 5//-2 == -3')
assertEquals(-5//2, -3, 'idiv: -5//2 == -3')
assertEquals(-5//-2, 2, 'idiv: -5//-2 == 2')

-- 11. Ordering and NaN
assertEquals((-0.0) < (0.0), false, 'ordering: -0.0 < 0.0 is false')
assertEquals((0.0) < (-0.0), false, 'ordering: 0.0 < -0.0 is false')
assertEquals((-0.0) <= (0.0), true, 'ordering: -0.0 <= 0.0')
assertEquals((0.0) <= (-0.0), true, 'ordering: 0.0 <= -0.0')
assertEquals((0/0) == (0/0), false, 'NaN: never equals itself')

-- 12. Bitwise operators
assertEquals((~0) == -1, true, 'bitwise not on int ok')

val = pcall(
  function()
    return ~0.0
  end
)
assertEquals(val, true, 'bitwise not on float ok')

val = pcall(
  function()
    return 0<<1
  end
)
assertEquals(val, true, 'shl int ok')

val = pcall(
  function()
    return 0.0<<1
  end
)
assertEquals(val, true, 'shl float ok')

-- 12.1 Bitwise ops results
assertEquals((5&3) == 1, true, 'bitwise and result')
assertEquals((5|2) == 7, true, 'bitwise or result')
assertEquals((5~1) == 4, true, 'bitwise xor result')
assertEquals((1<<5) == 32, true, 'bitwise shl result')
assertEquals((32>>5) == 1, true, 'bitwise shr result')

-- 12.2 Bitwise with float values
assertEquals((~(-0.0)) == -1, true, 'bitwise not on -0.0 == -1')

-- 13. Evaluation order (left-to-right) for binary ops
local log, val

-- arithmetic + - * /
local function lhs_num()
  log[#log + 1] = 'L'
  return 1
end

local function rhs_num()
  log[#log + 1] = 'R'
  return 2
end

log = {}
val = lhs_num()+rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: +')

log = {}
val = lhs_num()-rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: -')

log = {}
val = lhs_num()*rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: *')

log = {}
val = lhs_num()/rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: /')

-- floor div and mod
log = {}
val = lhs_num()//rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: //')

log = {}
val = lhs_num()%rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: %')

-- power and concatenation
log = {}
val = lhs_num()^rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: ^')

local function lhs_str()
  log[#log + 1] = 'L'
  return 'a'
end

local function rhs_str()
  log[#log + 1] = 'R'
  return 'b'
end

log = {}
val = lhs_str()..rhs_str()
assertEquals(table.concat(log, ''), 'LR', 'order: ..')

-- relational
log = {}
val = (lhs_num() < rhs_num())
assertEquals(table.concat(log, ''), 'LR', 'order: <')

log = {}
val = (lhs_num() <= rhs_num())
assertEquals(table.concat(log, ''), 'LR', 'order: <=')

log = {}
val = (lhs_num() > rhs_num())
assertEquals(table.concat(log, ''), 'LR', 'order: >')

log = {}
val = (lhs_num() >= rhs_num())
assertEquals(table.concat(log, ''), 'LR', 'order: >=')

log = {}
val = (lhs_num() == rhs_num())
assertEquals(table.concat(log, ''), 'LR', 'order: ==')

log = {}
val = (lhs_num() ~= rhs_num())
assertEquals(table.concat(log, ''), 'LR', 'order: ~=')

-- bitwise
log = {}
val = lhs_num()&rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: &')

log = {}
val = lhs_num()|rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: |')

log = {}
val = lhs_num()~rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: ~ (xor)')

log = {}
val = lhs_num()<<rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: <<')

log = {}
val = lhs_num()>>rhs_num()
assertEquals(table.concat(log, ''), 'LR', 'order: >>')

-- 13.2. Nested expressions and associativity
local function exp_a()
  log[#log + 1] = 'A'
  return 2
end

local function exp_b()
  log[#log + 1] = 'B'
  return 3
end

local function exp_c()
  log[#log + 1] = 'C'
  return 2
end

log = {}
val = exp_a() ^ (exp_b() ^ exp_c())
assertEquals(val, 512, 'nested expression result')
assertEquals(table.concat(log, ''), 'ABC', 'nested expression associativity')

assertEquals(2^3^2, 512, 'power associativity')

-- 14. String-to-number coercion around zero
assertEquals(1/('0') == 1/0.0, true, 'str: ("0") (+Inf)')
assertEquals(1/('-0') == 1/0.0, true, 'str: ("-0") (+Inf)')
assertEquals(1/('0.0') == 1/0.0, true, 'str: ("0.0") (+Inf)')
assertEquals(1/('-0.0') == -1/0.0, true, 'str: ("-0.0") (-Inf)')
assertEquals(1/-('0') == 1/0.0, true, 'str: unary minus -("0") (+Inf)')
assertEquals(1/-('-0') == 1/0.0, true, 'str: unary minus -("-0") (+Inf)')

-- 14.1. General arithmetic with numeric strings
assertEquals('1'+2, 3, 'str-num: "1"+2 == 3')
assertEquals(' -2 ' * '3', -6, 'str-num: " -2 " * "3" == -6')
assertEquals('0x10' + 1, 17, 'str-num: hex int string + 1 == 17')
assertEquals('0x1p4' + 0, 16, 'str-num: hex float string + 0 == 16')
assertThrows("attempt to add a 'string' with a 'number'",
  function()
    return 'x1'+1
  end
)

-- 14.2 Numeric string coercion preserves int/float kind
do
  local function mt(x)
    return math.type(x)
  end

  assertEquals(mt("0" + 0), "integer", "string '0' + 0 => integer")
  assertEquals(mt("-0" + 0), "integer", "string '-0' + 0 => integer")

  assertEquals(mt("0.0" + 0), "float", "string '0.0' + 0 => float")
  assertEquals(mt("-0.0" + 0), "float", "string '-0.0' + 0 => float")

  assertEquals(mt("0" + 0.0), "float", "string '0' + 0.0 => float")
  assertEquals(mt("0.0" + 0.0), "float", "string '0.0' + 0.0 => float")

  assertEquals(mt("0" + -0), "integer", "string '0' + -0 => integer")
  assertEquals(mt("0" + -0.0), "float", "string '0' + -0.0 => float")

  -- Regression coverage: numeric strings for all arithmetic ops and operand order
  assertEquals(mt("0" - 0), "integer", "string '0' - 0 => integer")
  assertEquals(mt("0.0" - 0), "float", "string '0.0' - 0 => float")
  assertEquals(mt("0" * 1), "integer", "string '0' * 1 => integer")
  assertEquals(mt("0.0" * 1), "float", "string '0.0' * 1 => float")
  assertEquals(mt("0" / 1), "float", "string '0' / 1 => float")
  assertEquals(mt("0.0" / 1), "float", "string '0.0' / 1 => float")
  assertEquals(mt("0" // 1), "integer", "string '0' // 1 => integer")
  assertEquals(mt("0.0" // 1), "float", "string '0.0' // 1 => float")
  assertEquals(mt("0" % 1), "integer", "string '0' % 1 => integer")
  assertEquals(mt("0.0" % 1), "float", "string '0.0' % 1 => float")
  assertEquals(mt("2" ^ 1), "float", "string '2' ^ 1 => float")
  assertEquals(mt("2.0" ^ 1), "float", "string '2.0' ^ 1 => float")

  assertEquals(mt(0 + "0"), "integer", "0 + string '0' => integer")
  assertEquals(mt(0 + "0.0"), "float", "0 + string '0.0' => float")

  assertEquals(mt(0 - "0"), "integer", "0 - string '0' => integer")
  assertEquals(mt(0 - "0.0"), "float", "0 - string '0.0' => float")

  assertEquals(mt(1 * "0"), "integer", "1 * string '0' => integer")
  assertEquals(mt(1 * "0.0"), "float", "1 * string '0.0' => float")

  assertEquals(mt(0 / "1"), "float", "0 / string '1' => float")
  assertEquals(mt(0 / "1.0"), "float", "0 / string '1.0' => float")

  assertEquals(mt(0 // "1"), "integer", "0 // string '1' => integer")
  assertEquals(mt(0 // "1.0"), "float", "0 // string '1.0' => float")

  assertEquals(mt(0 % "1"), "integer", "0 % string '1' => integer")
  assertEquals(mt(0 % "1.0"), "float", "0 % string '1.0' => float")

  assertEquals(mt(2 ^ "1"), "float", "2 ^ string '1' => float")
  assertEquals(mt(2 ^ "1.0"), "float", "2 ^ string '1.0' => float")

  assertEquals((1 // "2"), 0, "1 // '2' == 0")
  assertEquals((1 // "2.0"), 0.0, "1 // '2.0' == 0.0")
end

-- 15. Recursive function producing int zero (and unary minus)
local function rec_zero(n)
  if n == 0 then
    return 0
  end
  return -rec_zero(n - 1)
end

assertEquals(1/rec_zero(5) == 1/0, true, 'recursive: rec_zero (+Inf)')
assertEquals(1/(rec_zero(5)) == 1/0, true, 'recursive: (rec_zero) (+Inf)')
assertEquals(1/-(rec_zero(5)) == 1/0, true, 'recursive: -(rec_zero) (+Inf)')
assertEquals(1/-rec_zero(5) == 1/0, true, 'recursive: -(rec_zero) (+Inf)')

-- 16. Modulo and integer division by zero

-- 16.1. Modulo by zero
assertThrows("attempt to perform 'n%0'",
  function()
    return 1%0
  end
)

val = pcall(
  function()
    return 1.0%0.0
  end
)
assertEquals(val, true, 'float mod by zero ok (NaN)')

val = pcall(
  function()
    return 1.0%0
  end
)
assertEquals(val, true, 'mixed (float,int) mod by zero ok (NaN)')

val = pcall(
  function()
    return 1%0.0
  end
)
assertEquals(val, true, 'mixed (int,float) mod by zero ok (NaN)')

-- 16.2. Integer division by zero
assertThrows('divide by zero',
  function()
    return 1//0
  end
)

val = pcall(
  function()
    return 1.0//0.0
  end
)
assertEquals(val, true, 'float idiv by zero ok (+Inf/-Inf)')

val = pcall(
  function()
    return 1.0//0
  end
)
assertEquals(val, true, 'mixed (float,int) idiv by zero ok (+Inf/-Inf)')

val = pcall(
  function()
    return 1//0.0
  end
)
assertEquals(val, true, 'mixed (int,float) idiv by zero ok (+Inf/-Inf)')

-- 16.3. Modulo sign semantics (explicit)
assertEquals(5 % -2, -1, 'mod: 5 % -2 == -1')
assertEquals(-5 % 2, 1, 'mod: -5 % 2 == 1')
assertEquals(-5 % -2, -1, 'mod: -5 % -2 == -1')

-- 17. Metamethod precedence: __add should dispatch
local mt = {
  __add = function(_, _)
    return 'added'
  end
}
local tbl = setmetatable({}, mt)

assertEquals(tbl + tbl, 'added', '__add dispatched')

-- 17.1 Unary metamethod: __unm (negation)
local mt_unm = {
  __unm = function(_)
    return 'negated'
  end
}
local u = setmetatable({}, mt_unm)

assertEquals(-u, 'negated', '__unm dispatched')

-- 17.2 Unary metamethod: __bnot (bitwise NOT)
local bnot_calls = 0
local mt_bnot = {
  __bnot = function(_)
    bnot_calls = bnot_calls + 1
    return 123
  end
}
local b = setmetatable({}, mt_bnot)

assertEquals(~b, 123, '__bnot dispatched')
assertEquals(bnot_calls, 1, '__bnot called exactly once')

-- 17.3. Unary metamethods multi-return (first return only)
local mt_unm_mr = {
  __unm = function(_)
    return 7, 8
  end
}
local um = setmetatable({}, mt_unm_mr)

assertEquals(-um, 7, '__unm uses first return value')

local mt_bnot_mr = {
  __bnot = function(_)
    return 9, 10
  end
}
local bm = setmetatable({}, mt_bnot_mr)

assertEquals(~bm, 9, '__bnot uses first return value')

-- 18. Multi-return in arithmetic (first return only)
local function multi_ret()
  return 0, 1
end

assertEquals(1/(multi_ret()) == 1/0, true, 'multi-ret: 1st used (+Inf)')
assertEquals(1/-(multi_ret()) == 1/0, true, 'multi-ret: unary minus 1st used (+Inf)')

-- 18.1 Exponentiation zero edge cases
assertEquals(0^0 == 1, true, 'pow: 0^0 == 1')
assertEquals((-0.0)^0 == 1, true, 'pow: (-0.0)^0 == 1')

-- 19. Error tests
assertThrows('has no integer representation',
  function()
    return ~0.5
  end
)

assertThrows("attempt to add a 'string' with a 'number'",
  function()
    return 'a'+1
  end
)

assertThrows('attempt to perform arithmetic on a table value',
  function()
    return -{}
  end
)

-- 19.1. Bitwise on non-integers should error
assertThrows('has no integer representation',
  function()
    return 1.5&1
  end
)

assertThrows(
  "attempt to perform bitwise operation on a string value (constant '3')",
  function()
    return '3'|1
  end
)

assertThrows('has no integer representation',
  function()
    return 1~1.2
  end
)

assertThrows('has no integer representation',
  function()
    return 1<<0.1
  end
)

-- 19.2. Relational type error
assertThrows('attempt to compare number with string',
  function()
    return 1<'1'
  end
)

-- 19.3. Additional negative tests
assertThrows('attempt to perform arithmetic on a table value',
  function()
    return 1+{}
  end
)

assertThrows("attempt to unm a 'string' with a 'string'",
  function()
    return -'x'
  end
)

assertThrows(
  "attempt to perform bitwise operation on a string value (constant '1')",
  function()
    return ~'1'
  end
)

assertThrows('attempt to compare string with number',
  function()
    return '1'<1
  end
)

assertThrows('attempt to compare number with table',
  function()
    return 1<{}
  end
)

-- 19.4. String arithmetic: exact verb mapping
assertThrows("attempt to sub a 'string' with a 'number'",
  function()
    return 'x' - 1
  end
)

assertThrows("attempt to mul a 'string' with a 'number'",
  function()
    return 'x' * 2
  end
)

assertThrows("attempt to div a 'string' with a 'number'",
  function()
    return 'x' / 2
  end
)

assertThrows("attempt to idiv a 'string' with a 'number'",
  function()
    return 'x' // 2
  end
)

assertThrows("attempt to mod a 'string' with a 'number'",
  function()
    return 'x' % 2
  end
)

assertThrows("attempt to pow a 'string' with a 'number'",
  function()
    return 'x' ^ 2
  end
)

-- 19.4.1. String arithmetic: type pairing and string-vs-string cases
assertThrows("attempt to add a 'string' with a 'string'",
  function()
    return 'x' + 'y'
  end
)

assertThrows("attempt to mul a 'number' with a 'string'",
  function()
    return 2 * 'x'
  end
)

-- 19.5. Bitwise: exact type errors
assertThrows("attempt to perform bitwise operation on a table value",
  function()
    return {} & 1
  end
)

assertThrows("attempt to perform bitwise operation on a nil value",
  function()
    return nil | 1
  end
)

assertThrows("attempt to perform bitwise operation on a boolean value",
  function()
    return true ~ 1
  end
)

-- 19.6. Bitwise shifts: non-integer RHS
assertThrows("number has no integer representation",
  function()
    return 1 << 0.5
  end
)

assertThrows("number has no integer representation",
  function()
    return 8 >> 0.25
  end
)

-- 19.7. Concatenation: exact messages
assertThrows("attempt to concatenate a nil value",
  function()
    return nil .. "x"
  end
)

assertThrows("attempt to concatenate a nil value",
  function()
    return "x" .. nil
  end
)

assertThrows("attempt to concatenate a table value",
  function()
    return "x" .. {}
  end
)

-- 19.8. Length operator: exact message
assertThrows("attempt to get length of a number value",
  function()
    return #1
  end
)

assertThrows("attempt to get length of a nil value",
  function()
    return #nil
  end
)

-- 19.9. Relational mismatches
assertThrows("attempt to compare number with string",
  function()
    return 1 <= '1'
  end
)

assertThrows("attempt to compare number with string",
  function()
    return '1' >= 1
  end
)

assertThrows("attempt to compare table with number",
  function()
    return 1 > {}
  end
)

assertThrows("attempt to compare table with number",
  function()
    return {} < 1
  end
)

-- 19.10. Explicit int-path division/modulo by zero through expr
assertThrows("attempt to perform 'n%0'",
  function()
    return 1 % (1-1)
  end
)

assertThrows("attempt to divide by zero",
  function()
    return 1 // (1-1)
  end
)

-- 20. `tostring()` numeric formatting

-- 20.1. Integers
assertEquals(tostring(0), '0', 'tostring: 0')
assertEquals(tostring(1), '1', 'tostring: 1')
assertEquals(tostring(-5), '-5', 'tostring: -5')
assertEquals(tostring(42), '42', 'tostring: 42')
assertEquals(tostring(-123), '-123', 'tostring: -123')

-- 20.2. Float zeros (positive and negative)
assertEquals(tostring(0.0), '0.0', 'tostring: 0.0')
assertEquals(tostring(-0.0), '-0.0', 'tostring: -0.0')

-- 20.3. Integer-valued floats
assertEquals(tostring(1.0), '1.0', 'tostring: 1.0')
assertEquals(tostring(2.0), '2.0', 'tostring: 2.0')
assertEquals(tostring(-3.0), '-3.0', 'tostring: -3.0')
assertEquals(tostring(100.0), '100.0', 'tostring: 100.0')

-- 20.4. Non-integer floats
assertEquals(tostring(0.5), '0.5', 'tostring: 0.5')
assertEquals(tostring(3.14), '3.14', 'tostring: 3.14')
assertEquals(tostring(-2.5), '-2.5', 'tostring: -2.5')

-- 20.5. Special float values
assertEquals(tostring(1/0.0), 'inf', 'tostring: +inf')
assertEquals(tostring(-1/0.0), '-inf', 'tostring: -inf')
assertEquals(tostring(0.0/0.0), '-nan', 'tostring: NaN')
assertEquals(tostring(1.0%0.0), '-nan', 'tostring: NaN from modulo')
assertEquals(tostring((-1.0)%0.0), '-nan', 'tostring: NaN from neg modulo')

-- 20.6. Results from tonumber() preserve type
assertEquals(tostring(tonumber('5')), '5', 'tonumber int: "5"')
assertEquals(tostring(tonumber('5.')), '5.0', 'tonumber float: "5."')
assertEquals(tostring(tonumber('.5')), '0.5', 'tonumber float: ".5"')
assertEquals(tostring(tonumber('5.0')), '5.0', 'tonumber float: "5.0"')
assertEquals(tostring(tonumber('0x10')), '16', 'tonumber hex int: "0x10"')
assertEquals(tostring(tonumber('0x10.0')), '16.0', 'tonumber hex float: "0x10.0"')
assertEquals(tostring(tonumber('0x1p4')), '16.0', 'tonumber hex float: "0x1p4"')

-- 20.7. Arithmetic results formatting
assertEquals(tostring(1+1), '2', 'int+int yields int')
assertEquals(tostring(1.0+1.0), '2.0', 'float+float yields float')
assertEquals(tostring(1+1.0), '2.0', 'int+float yields float')
assertEquals(tostring(5/2), '2.5', 'division yields float')
assertEquals(tostring(4/2), '2.0', 'division exact yields float')

-- 21. Numeric for loops: comprehensive type/mode coverage

-- 21.1. Integer mode (all params integer-valued and untagged)

do
  local types, vals = {}, {}
  for i = 1, 3 do
    table.insert(types, math.type(i))
    table.insert(vals, tostring(i))
  end
  assertEquals(table.concat(types, ','), 'integer,integer,integer', 'for 1,3: types')
  assertEquals(table.concat(vals, ','), '1,2,3', 'for 1,3: values')
end

do
  local types = {}
  for i = 1, 3, 1 do
    table.insert(types, math.type(i))
  end
  assertEquals(types[1], 'integer', 'for 1,3,1: type')
end

do
  local vals = {}
  for i = 3, 1, -1 do
    table.insert(vals, tostring(i))
  end
  assertEquals(table.concat(vals, ','), '3,2,1', 'for 3,1,-1: descending')
end

do
  local found_zero = false
  local zero_str
  for i = -1, 1 do
    if i == 0 then
      found_zero = true
      zero_str = tostring(i)
    end
  end
  assertEquals(found_zero, true, 'for -1,1: crosses zero')
  assertEquals(zero_str, '0', 'for -1,1: zero is int')
end

do
  for i = 1, 3 do
    local zero = i - i
    assertEquals(math.type(zero), 'integer', 'for 1,3: i-i is int')
    assertEquals(tostring(zero), '0', 'for 1,3: i-i formats as int')
  end
end

do
  for i = 2, 4 do
    local expr = i * 2 - i - i
    assertEquals(math.type(expr), 'integer', 'for 2,4: int expr is int')
    assertEquals(expr, 0, 'for 2,4: int expr value')
  end
end

-- 21.2. Float mode (all params float-typed)

do
  local types, vals = {}, {}
  for i = 1.0, 3.0, 1.0 do
    table.insert(types, math.type(i))
    table.insert(vals, tostring(i))
  end
  assertEquals(table.concat(types, ','), 'float,float,float', 'for 1.0,3.0,1.0: types')
  assertEquals(table.concat(vals, ','), '1.0,2.0,3.0', 'for 1.0,3.0,1.0: values')
end

do
  local vals = {}
  for i = 0.5, 2.5, 0.5 do
    table.insert(vals, tostring(i))
  end
  assertEquals(table.concat(vals, ','), '0.5,1.0,1.5,2.0,2.5', 'for 0.5,2.5,0.5: values')
end

do
  local vals = {}
  for i = 3.0, 1.0, -1.0 do
    table.insert(vals, tostring(i))
  end
  assertEquals(table.concat(vals, ','), '3.0,2.0,1.0', 'for 3.0,1.0,-1.0: descending')
end

do
  local vals = {}
  for i = 2.5, 0.5, -0.5 do
    table.insert(vals, tostring(i))
  end
  assertEquals(table.concat(vals, ','), '2.5,2.0,1.5,1.0,0.5', 'for 2.5,0.5,-0.5: values')
end

do
  local zero_str
  for i = -1.0, 1.0 do
    if i == 0 then
      zero_str = tostring(i)
    end
  end
  assertEquals(zero_str, '0.0', 'for -1.0,1.0: zero is float')
end

do
  for i = 1.0, 3.0 do
    local zero = i - i
    assertEquals(math.type(zero), 'float', 'for 1.0,3.0: i-i is float')
    assertEquals(tostring(zero), '0.0', 'for 1.0,3.0: i-i formats as float')
  end
end

do
  for i = 2.0, 4.0 do
    local expr = i * 2.0 - i - i
    assertEquals(math.type(expr), 'float', 'for 2.0,4.0: float expr is float')
    assertEquals(tostring(expr), '0.0', 'for 2.0,4.0: float expr formats')
  end
end

-- 21.3. Mixed mode (start type determines loop var type)

do
  local types, vals = {}, {}
  for i = 1, 3.5 do
    table.insert(types, math.type(i))
    table.insert(vals, tostring(i))
  end
  assertEquals(types[1], 'integer', 'for 1,3.5: start int yields var int')
  assertEquals(types[2], 'integer', 'for 1,3.5: var stays int')
  assertEquals(table.concat(vals, ','), '1,2,3', 'for 1,3.5: int formatting')
end

do
  local types, vals = {}, {}
  for i = 1.0, 3 do
    table.insert(types, math.type(i))
    table.insert(vals, tostring(i))
  end
  assertEquals(types[1], 'float', 'for 1.0,3: start float yields var float')
  assertEquals(types[2], 'float', 'for 1.0,3: var stays float')
  assertEquals(table.concat(vals, ','), '1.0,2.0,3.0', 'for 1.0,3: float formatting')
end

do
  local types = {}
  for i = 1, 3.0 do
    table.insert(types, math.type(i))
  end
  assertEquals(types[1], 'integer', 'for 1,3.0: end=3.0 but start int yields var int')
end

do
  local types, vals = {}, {}
  for i = 1, 3, 1.0 do
    table.insert(types, math.type(i))
    table.insert(vals, tostring(i))
  end
  assertEquals(types[1], 'float', 'for 1,3,1.0: step float yields var float')
  assertEquals(table.concat(vals, ','), '1.0,2.0,3.0', 'for 1,3,1.0: float formatting')
end

do
  local types, vals = {}, {}
  for i = 1.0, 3.0, 1 do
    table.insert(types, math.type(i))
    table.insert(vals, tostring(i))
  end
  assertEquals(types[1], 'float', 'for 1.0,3.0,1: start float yields var float')
  assertEquals(table.concat(vals, ','), '1.0,2.0,3.0', 'for 1.0,3.0,1: float formatting')
end

do
  local types = {}
  for i = 3.0, 1, -1 do
    table.insert(types, math.type(i))
  end
  assertEquals(types[1], 'float', 'for 3.0,1,-1: start float yields var float')
end

-- Mixed mode: float step, integer start
do
  local types, vals = {}, {}
  for i = 1, 3, 1.0 do
    table.insert(types, math.type(i))
    table.insert(vals, tostring(i))
  end
  assertEquals(types[1], 'float', 'for 1,3,1.0: step float yields var float')
  assertEquals(table.concat(vals, ','), '1.0,2.0,3.0', 'for 1,3,1.0: float formatting')
end

-- Mixed mode: integer start, negative float step
do
  local types, vals = {}, {}
  for i = 3, 1, -1.0 do
    table.insert(types, math.type(i))
    table.insert(vals, tostring(i))
  end
  assertEquals(types[1], 'float', 'for 3,1,-1.0: step float yields var float')
  assertEquals(table.concat(vals, ','), '3.0,2.0,1.0', 'for 3,1,-1.0: float formatting')
end

do
  for i = 1, 3.5 do
    local zero = i - i
    assertEquals(math.type(zero), 'integer', 'for 1,3.5: start int yields i-i is int')
  end
end

do
  for i = 1.0, 3 do
    local zero = i - i
    assertEquals(math.type(zero), 'float', 'for 1.0,3: start float yields i-i is float')
  end
end

-- 21.4. Boundary conditions

do
  local count = 0
  for i = 5, 5 do
    count = count + 1
    assertEquals(i, 5, 'for 5,5: i=5')
  end
  assertEquals(count, 1, 'for 5,5: one iteration')
end

do
  local count = 0
  for i = 5, 3 do
    count = count + 1
  end
  assertEquals(count, 0, 'for 5,3: no iterations')
end

do
  local count = 0
  for i = 3, 5, -1 do
    count = count + 1
  end
  assertEquals(count, 0, 'for 3,5,-1: no iterations')
end

do
  local last
  for i = 1.0, 3.0 do
    last = i
  end
  assertEquals(tostring(last), '3.0', 'for 1.0,3.0: exactly reaches end')
end

do
  local last
  for i = 1, 10, 3 do
    last = i
  end
  assertEquals(last, 10, 'for 1,10,3: reaches end exactly')
end

do
  local last
  for i = 1, 9, 3 do
    last = i
  end
  assertEquals(last, 7, 'for 1,9,3: stops before end')
end

do
  local vals = {}
  for i = 0.1, 0.3, 0.1 do
    table.insert(vals, i)
  end
  assertEquals(#vals, 2, 'for 0.1,0.3,0.1: small float step iterations')
end

do
  local vals = {}
  -- 0.5 has exact binary representation
  for i = 0.5, 1.5, 0.5 do
    table.insert(vals, i)
  end
  assertEquals(#vals, 3, 'for 0.5,1.5,0.5: small float step')
end

-- 21.5. Edge cases and errors

assertThrows('step is zero', function()
  for i = 1, 10, 0 do
  end
end)

assertThrows('step is zero', function()
  for i = 1.0, 10.0, 0.0 do
  end
end)

do
  local count = 0
  for i = 1, 1000000 do
    count = count + 1
    if count > 5 then break end
  end
  assertEquals(count, 6, 'for 1,1000000: large range with break')
end

do
  local sum = 0
  for i = 1, 5, 2 do
    sum = sum + i
  end
  assertEquals(sum, 9, 'for 1,5,2: step>1 sum (1+3+5)')
end

do
  local sum = 0
  for i = 10, 1, -3 do
    sum = sum + i
  end
  assertEquals(sum, 22, 'for 10,1,-3: negative step>1 sum (10+7+4+1)')
end

-- 22. Table numeric key equivalence

-- 22.1. Integer-valued floats normalize to integers

do
  local t = {}
  t[1] = 'one'
  assertEquals(t[1.0], 'one', 'table: t[1] accessed via t[1.0]')
  assertEquals(t[1], 'one', 'table: t[1] accessed via t[1]')
end

do
  local t = {}
  t[2.0] = 'two'
  assertEquals(t[2], 'two', 'table: t[2.0] accessed via t[2]')
  assertEquals(t[2.0], 'two', 'table: t[2.0] accessed via t[2.0]')
end

do
  local t = {}
  t[1] = 'a'
  t[2.0] = 'b'
  t[3] = 'c'
  assertEquals(t[1.0], 'a', 'table: multi-key t[1.0]')
  assertEquals(t[2], 'b', 'table: multi-key t[2]')
  assertEquals(t[3.0], 'c', 'table: multi-key t[3.0]')
end

do
  local t = {}
  t[5] = 'first'
  t[5.0] = 'second'
  assertEquals(t[5], 'second', 'table: overwrite int with float')
  assertEquals(t[5.0], 'second', 'table: overwrite int with float (access)')
end

do
  local t = {}
  t[100] = 'hundred'
  assertEquals(t[100.0], 'hundred', 'table: large int t[100.0]')
end

-- 22.2. Zero normalization (both -0 and +0 map to same key)

do
  local t = {}
  t[0] = 'zero'
  assertEquals(t[-0.0], 'zero', 'table: t[0] accessed via t[-0.0]')
  assertEquals(t[0.0], 'zero', 'table: t[0] accessed via t[0.0]')
  assertEquals(t[0], 'zero', 'table: t[0] accessed via t[0]')
end

do
  local t = {}
  t[-0.0] = 'negzero'
  assertEquals(t[0], 'negzero', 'table: t[-0.0] accessed via t[0]')
  assertEquals(t[0.0], 'negzero', 'table: t[-0.0] accessed via t[0.0]')
  assertEquals(t[-0.0], 'negzero', 'table: t[-0.0] accessed via t[-0.0]')
end

do
  local t = {}
  t[0.0] = 'float_zero'
  assertEquals(t[0], 'float_zero', 'table: t[0.0] accessed via t[0]')
  assertEquals(t[-0.0], 'float_zero', 'table: t[0.0] accessed via t[-0.0]')
end

do
  local t = {}
  t[0] = 'first'
  t[-0.0] = 'second'
  t[0.0] = 'third'
  assertEquals(t[0], 'third', 'table: zero overwrite final')
  assertEquals(t[-0.0], 'third', 'table: zero overwrite via -0.0')
end

-- 22.3. Non-integer floats are distinct keys

do
  local t = {}
  t[1] = 'int_one'
  t[1.5] = 'one_point_five'
  assertEquals(t[1], 'int_one', 'table: non-int float t[1]')
  assertEquals(t[1.5], 'one_point_five', 'table: non-int float t[1.5]')
  assertEquals(t[1.0], 'int_one', 'table: non-int float t[1.0] maps to int')
end

do
  local t = {}
  t[0.5] = 'half'
  t[1.5] = 'one_half'
  t[2.5] = 'two_half'
  assertEquals(t[0.5], 'half', 'table: multi non-int t[0.5]')
  assertEquals(t[1.5], 'one_half', 'table: multi non-int t[1.5]')
  assertEquals(t[2.5], 'two_half', 'table: multi non-int t[2.5]')
end

-- 22.4. Mixed integer and float keys

do
  local t = {}
  t[0] = 'zero'
  t[1] = 'one'
  t[1.5] = 'one_point_five'
  t[2.0] = 'two'
  t[-0.0] = 'neg_zero'

  assertEquals(t[0], 'neg_zero', 'table: mixed t[0] (last zero)')
  assertEquals(t[0.0], 'neg_zero', 'table: mixed t[0.0]')
  assertEquals(t[-0.0], 'neg_zero', 'table: mixed t[-0.0]')
  assertEquals(t[1], 'one', 'table: mixed t[1]')
  assertEquals(t[1.0], 'one', 'table: mixed t[1.0]')
  assertEquals(t[1.5], 'one_point_five', 'table: mixed t[1.5]')
  assertEquals(t[2], 'two', 'table: mixed t[2]')
  assertEquals(t[2.0], 'two', 'table: mixed t[2.0]')
end

-- 22.5. Key equivalence with expressions

do
  local t = {}
  t[1+1] = 'two'
  assertEquals(t[2.0], 'two', 'table: expr key t[1+1] via t[2.0]')
  assertEquals(t[4/2], 'two', 'table: expr key t[4/2]')
end

do
  local t = {}
  t[1-1] = 'int_zero'
  assertEquals(t[0], 'int_zero', 'table: expr t[1-1] via t[0]')
  assertEquals(t[0.0], 'int_zero', 'table: expr t[1-1] via t[0.0]')
  assertEquals(t[-0.0], 'int_zero', 'table: expr t[1-1] via t[-0.0]')
end

do
  local t = {}
  t[1.0-1.0] = 'float_zero'
  assertEquals(t[0], 'float_zero', 'table: expr t[1.0-1.0] via t[0]')
  assertEquals(t[-0.0], 'float_zero', 'table: expr t[1.0-1.0] via t[-0.0]')
end

-- 22.6. Key equivalence with variables

do
  local t = {}
  local i = 1
  local f = 1.0
  t[i] = 'from_int'
  assertEquals(t[f], 'from_int', 'table: var int key via float var')
end

do
  local t = {}
  local i = 1
  local f = 1.0
  t[f] = 'from_float'
  assertEquals(t[i], 'from_float', 'table: var float key via int var')
end

do
  local t = {}
  local zi = 0
  local zf = 0.0
  local zfn = -0.0
  t[zi] = 'int_zero'
  assertEquals(t[zf], 'int_zero', 'table: var zi via zf')
  assertEquals(t[zfn], 'int_zero', 'table: var zi via zfn')
end

-- 22.7. Key equivalence in array part

do
  local t = {10, 20, 30}
  assertEquals(t[1.0], 10, 'table: array t[1.0]')
  assertEquals(t[2.0], 20, 'table: array t[2.0]')
  assertEquals(t[3.0], 30, 'table: array t[3.0]')
end

do
  local t = {}
  t[1.0] = 'first'
  t[2.0] = 'second'
  assertEquals(t[1], 'first', 'table: array assign t[1.0] via t[1]')
  assertEquals(t[2], 'second', 'table: array assign t[2.0] via t[2]')
end

-- 22.8. Key counting and iteration

do
  local t = {}
  t[1] = 'a'
  t[1.0] = 'b'
  t[2] = 'c'
  t[2.0] = 'd'

  local count = 0
  for k, v in pairs(t) do
    count = count + 1
  end
  assertEquals(count, 2, 'table: normalized keys count as one')
end

do
  local t = {}
  t[0] = 'a'
  t[0.0] = 'b'
  t[-0.0] = 'c'

  local count = 0
  for k, v in pairs(t) do
    count = count + 1
  end
  assertEquals(count, 1, 'table: all zeros count as one key')
end

-- 23. Type introspection: type() and math.type()

-- 23.1. Basic type() function (returns general Lua types)

assertEquals(type(nil), 'nil', 'type(nil)')
assertEquals(type(true), 'boolean', 'type(true)')
assertEquals(type(false), 'boolean', 'type(false)')
assertEquals(type(0), 'number', 'type(0) is number')
assertEquals(type(1), 'number', 'type(1) is number')
assertEquals(type(0.0), 'number', 'type(0.0) is number')
assertEquals(type(1.0), 'number', 'type(1.0) is number')
assertEquals(type(-0.0), 'number', 'type(-0.0) is number')
assertEquals(type(1.5), 'number', 'type(1.5) is number')
assertEquals(type(1/0.0), 'number', 'type(inf) is number')
assertEquals(type(0.0/0.0), 'number', 'type(nan) is number')
assertEquals(type('hello'), 'string', 'type(string)')
assertEquals(type({}), 'table', 'type(table)')
assertEquals(type(function() end), 'function', 'type(function)')

-- 23.2. math.type() function (distinguishes integer vs float)

-- Integers
assertEquals(math.type(0), 'integer', 'math.type(0)')
assertEquals(math.type(1), 'integer', 'math.type(1)')
assertEquals(math.type(-1), 'integer', 'math.type(-1)')
assertEquals(math.type(42), 'integer', 'math.type(42)')
assertEquals(math.type(-123), 'integer', 'math.type(-123)')
assertEquals(math.type(1000000), 'integer', 'math.type(1000000)')

-- Floats (literals with decimal point)
assertEquals(math.type(0.0), 'float', 'math.type(0.0)')
assertEquals(math.type(-0.0), 'float', 'math.type(-0.0)')
assertEquals(math.type(1.0), 'float', 'math.type(1.0)')
assertEquals(math.type(2.0), 'float', 'math.type(2.0)')
assertEquals(math.type(-3.0), 'float', 'math.type(-3.0)')
assertEquals(math.type(100.0), 'float', 'math.type(100.0)')

-- Floats (non-integer values)
assertEquals(math.type(0.5), 'float', 'math.type(0.5)')
assertEquals(math.type(1.5), 'float', 'math.type(1.5)')
assertEquals(math.type(3.14), 'float', 'math.type(3.14)')
assertEquals(math.type(-2.5), 'float', 'math.type(-2.5)')

-- Special float values
assertEquals(math.type(1/0.0), 'float', 'math.type(inf)')
assertEquals(math.type(-1/0.0), 'float', 'math.type(-inf)')
assertEquals(math.type(0.0/0.0), 'float', 'math.type(nan)')

-- Non-numbers return nil
assertEquals(math.type(nil), nil, 'math.type(nil) yields nil')
assertEquals(math.type(true), nil, 'math.type(boolean) yields nil')
assertEquals(math.type('123'), nil, 'math.type(string) yields nil')
assertEquals(math.type({}), nil, 'math.type(table) yields nil')
assertEquals(math.type(function() end), nil, 'math.type(function) yields nil')

-- 23.3. Integer arithmetic preserves integer type

do
  local a = 1
  local b = 2
  assertEquals(math.type(a + b), 'integer', 'int + int yields integer')
  assertEquals(math.type(a - b), 'integer', 'int - int yields integer')
  assertEquals(math.type(a * b), 'integer', 'int * int yields integer')
  assertEquals(math.type(a // b), 'integer', 'int // int yields integer')
  assertEquals(math.type(a % b), 'integer', 'int % int yields integer')
end

-- Integer operations producing zero
do
  assertEquals(math.type(1 - 1), 'integer', '1 - 1 yields integer zero')
  assertEquals(math.type(0 * 5), 'integer', '0 * 5 yields integer zero')
  assertEquals(math.type(0 % 1), 'integer', '0 % 1 yields integer zero')
  assertEquals(math.type(0 // 1), 'integer', '0 // 1 yields integer zero')
end

-- 23.4. Float arithmetic preserves float type

do
  local a = 1.0
  local b = 2.0
  assertEquals(math.type(a + b), 'float', 'float + float yields float')
  assertEquals(math.type(a - b), 'float', 'float - float yields float')
  assertEquals(math.type(a * b), 'float', 'float * float yields float')
  assertEquals(math.type(a / b), 'float', 'float / float yields float')
  assertEquals(math.type(a // b), 'float', 'float // float yields float')
  assertEquals(math.type(a % b), 'float', 'float % float yields float')
end

-- Float operations producing zero
do
  assertEquals(math.type(1.0 - 1.0), 'float', '1.0 - 1.0 yields float zero')
  assertEquals(math.type(0.0 * 5.0), 'float', '0.0 * 5.0 yields float zero')
  assertEquals(math.type(0.0 % 1.0), 'float', '0.0 % 1.0 yields float zero')
  assertEquals(math.type(0.0 // 1.0), 'float', '0.0 // 1.0 yields float zero')
end

-- 23.5. Mixed arithmetic promotes to float

do
  assertEquals(math.type(1 + 1.0), 'float', 'int + float yields float')
  assertEquals(math.type(1.0 + 1), 'float', 'float + int yields float')
  assertEquals(math.type(2 * 1.5), 'float', 'int * float yields float')
  assertEquals(math.type(3.0 - 1), 'float', 'float - int yields float')
  assertEquals(math.type(5 // 2.0), 'float', 'int // float yields float')
  assertEquals(math.type(5.0 % 2), 'float', 'float % int yields float')
end

-- Mixed operations producing zero
do
  assertEquals(math.type(1 - 1.0), 'float', '1 - 1.0 yields float zero')
  assertEquals(math.type(0 * 1.0), 'float', '0 * 1.0 yields float zero')
  assertEquals(math.type(0.0 * 1), 'float', '0.0 * 1 yields float zero')
end

-- 23.6. Division and power always produce floats

do
  assertEquals(math.type(4 / 2), 'float', '4 / 2 yields float (2.0)')
  assertEquals(math.type(5 / 2), 'float', '5 / 2 yields float (2.5)')
  assertEquals(math.type(1 / 1), 'float', '1 / 1 yields float (1.0)')

  assertEquals(math.type(2 ^ 3), 'float', '2 ^ 3 yields float (8.0)')
  assertEquals(math.type(2 ^ 0), 'float', '2 ^ 0 yields float (1.0)')
  assertEquals(math.type(10 ^ 2), 'float', '10 ^ 2 yields float (100.0)')
end

-- Division producing zero
do
  assertEquals(math.type(0 / 1), 'float', '0 / 1 yields float zero')
  assertEquals(math.type(0.0 / 1.0), 'float', '0.0 / 1.0 yields float zero')
end

-- 23.7. Unary minus preserves type

do
  assertEquals(math.type(-5), 'integer', '-5 is integer')
  assertEquals(math.type(-0), 'integer', '-0 is integer')

  assertEquals(math.type(-5.0), 'float', '-5.0 is float')
  assertEquals(math.type(-0.0), 'float', '-0.0 is float')
end

-- Unary minus on variables
do
  local i = 5
  local f = 5.0
  assertEquals(math.type(-i), 'integer', 'unary minus int var')
  assertEquals(math.type(-f), 'float', 'unary minus float var')
end

-- Unary minus on expressions
do
  assertEquals(math.type(-(1 + 1)), 'integer', 'unary minus int expr')
  assertEquals(math.type(-(1.0 + 1.0)), 'float', 'unary minus float expr')
end

-- 23.8. String coercion preserves type (tonumber)

do
  assertEquals(math.type(tonumber('5')), 'integer', 'tonumber("5") yields integer')
  assertEquals(math.type(tonumber('5.')), 'float', 'tonumber("5.") yields float')
  assertEquals(math.type(tonumber('.5')), 'float', 'tonumber(".5") yields float')
  assertEquals(math.type(tonumber('5.0')), 'float', 'tonumber("5.0") yields float')
  assertEquals(math.type(tonumber('-0')), 'integer', 'tonumber("-0") yields integer')
  assertEquals(math.type(tonumber('-0.0')), 'float', 'tonumber("-0.0") yields float')
end

-- Hexadecimal literals
do
  assertEquals(math.type(tonumber('0x10')), 'integer', 'tonumber("0x10") yields integer')
  assertEquals(math.type(tonumber('0x10.0')), 'float', 'tonumber("0x10.0") yields float')
  assertEquals(math.type(tonumber('0x1p4')), 'float', 'tonumber("0x1p4") yields float')
end

-- 23.9. Variables preserve type through assignment

do
  local i = 5
  local f = 5.0

  assertEquals(math.type(i), 'integer', 'int variable')
  assertEquals(math.type(f), 'float', 'float variable')

  local i2 = i
  local f2 = f

  assertEquals(math.type(i2), 'integer', 'int variable copy')
  assertEquals(math.type(f2), 'float', 'float variable copy')
end

-- 23.10. Function returns preserve type

do
  local function ret_int()
    return 42
  end

  local function ret_float()
    return 42.0
  end

  assertEquals(math.type(ret_int()), 'integer', 'function returns integer')
  assertEquals(math.type(ret_float()), 'float', 'function returns float')
end

-- 23.11. Table values preserve type

do
  local t = {
    i = 10,
    f = 10.0,
    [1] = 20,
    [2] = 20.0
  }

  assertEquals(math.type(t.i), 'integer', 'table int value (string key)')
  assertEquals(math.type(t.f), 'float', 'table float value (string key)')
  assertEquals(math.type(t[1]), 'integer', 'table int value (int key)')
  assertEquals(math.type(t[2]), 'float', 'table float value (int key)')
end

-- 23.12. For-loop variable type tracking

do
  -- Integer loop
  for i = 1, 3 do
    assertEquals(math.type(i), 'integer', 'for 1,3: var is integer')
    break  -- Just test first iteration
  end

  -- Float loop (start is float)
  for i = 1.0, 3 do
    assertEquals(math.type(i), 'float', 'for 1.0,3: var is float')
    break
  end

  -- Float loop (step is float)
  for i = 1, 3, 1.0 do
    assertEquals(math.type(i), 'float', 'for 1,3,1.0: var is float')
    break
  end
end

-- 23.13. Bitwise operations require integers (input & output)

do
  assertEquals(math.type(5 & 3), 'integer', 'bitwise and yields integer')
  assertEquals(math.type(5 | 2), 'integer', 'bitwise or yields integer')
  assertEquals(math.type(5 ~ 1), 'integer', 'bitwise xor yields integer')
  assertEquals(math.type(1 << 5), 'integer', 'left shift yields integer')
  assertEquals(math.type(32 >> 2), 'integer', 'right shift yields integer')
  assertEquals(math.type(~0), 'integer', 'bitwise not yields integer')
end

-- Bitwise operations convert float operands to integer
do
  assertEquals(math.type(5.0 & 3), 'integer', 'float & int yields integer')
  assertEquals(math.type(5 | 2.0), 'integer', 'int | float yields integer')
  assertEquals(math.type(5.0 ~ 1.0), 'integer', 'float ~ float yields integer')
end

-- 23.14. Edge cases: zero types

do
  -- Positive integer zero
  assertEquals(math.type(0), 'integer', '0 is integer')
  assertEquals(math.type(1 - 1), 'integer', '1 - 1 is integer')
  assertEquals(math.type(0 * 1), 'integer', '0 * 1 is integer')

  -- Positive float zero
  assertEquals(math.type(0.0), 'float', '0.0 is float')
  assertEquals(math.type(1.0 - 1.0), 'float', '1.0 - 1.0 is float')

  -- Negative float zero
  assertEquals(math.type(-0.0), 'float', '-0.0 is float')
  assertEquals(math.type(0.0 * -1.0), 'float', '0.0 * -1.0 is float')

  -- Integer operations never produce -0
  assertEquals(math.type(0 * -1), 'integer', '0 * -1 is integer (not -0)')
end

-- 23.15. Special float values

do
  local inf = 1.0 / 0.0
  local neginf = -1.0 / 0.0
  local nan = 0.0 / 0.0

  assertEquals(type(inf), 'number', 'inf has type number')
  assertEquals(type(neginf), 'number', '-inf has type number')
  assertEquals(type(nan), 'number', 'nan has type number')

  assertEquals(math.type(inf), 'float', 'inf has math.type float')
  assertEquals(math.type(neginf), 'float', '-inf has math.type float')
  assertEquals(math.type(nan), 'float', 'nan has math.type float')
end

-- 23.16. Type consistency across operations

do
  local i = 10
  local sum_i = 0

  for n = 1, 5 do
    sum_i = sum_i + n
    assertEquals(math.type(sum_i), 'integer', 'integer sum stays integer')
  end

  local f = 10.0
  local sum_f = 0.0

  for n = 1.0, 5.0 do
    sum_f = sum_f + n
    assertEquals(math.type(sum_f), 'float', 'float sum stays float')
  end
end


================================================
FILE: client/space_lua/ast.ts
================================================
type ASTContext = {
  ctx: ASTCtx;
};

export type NumericType = "int" | "float";

export type ASTCtx = {
  from?: number;
  to?: number;
} & Record<string, any>;

export type LuaBlock = {
  type: "Block";
  statements: LuaStatement[];
  hasLabel?: boolean;
  hasGoto?: boolean;
  // the first duplicated label is recorded here for later eval
  dupLabelError?: { name: string; ctx: ASTCtx };
  // this block declares top-level locals and therefore needs a new env
  needsEnv?: boolean;
  // true if this block itself (not nested ones) defines at least one label
  hasLabelHere?: boolean;
  // true if this block itself (not nested ones) may create to-be-closed values
  hasCloseHere?: boolean;
  // true if this block's subtree contains any function definition
  hasFunctionDef?: boolean;
} & ASTContext;

// STATEMENTS
export type LuaReturnStatement = {
  type: "Return";
  expressions: LuaExpression[];
} & ASTContext;

export type LuaStatement =
  | LuaSemicolonStatement
  | LuaLabelStatement
  | LuaBreakStatement
  | LuaGotoStatement
  | LuaReturnStatement
  | LuaBlock
  | LuaWhileStatement
  | LuaRepeatStatement
  | LuaIfStatement
  | LuaForStatement
  | LuaForInStatement
  | LuaFunctionStatement
  | LuaLocalFunctionStatement
  | LuaAssignmentStatement
  | LuaLocalStatement
  | LuaFunctionCallStatement;

export type LuaSemicolonStatement = {
  type: "Semicolon";
} & ASTContext;

export type LuaLabelStatement = {
  type: "Label";
  name: string;
} & ASTContext;

export type LuaBreakStatement = {
  type: "Break";
} & ASTContext;

export type LuaGotoStatement = {
  type: "Goto";
  name: string;
} & ASTContext;

export type LuaWhileStatement = {
  type: "While";
  condition: LuaExpression;
  block: LuaBlock;
} & ASTContext;

export type LuaRepeatStatement = {
  type: "Repeat";
  block: LuaBlock;
  condition: LuaExpression;
} & ASTContext;

export type LuaIfStatement = {
  type: "If";
  conditions: { condition: LuaExpression; block: LuaBlock }[];
  elseBlock?: LuaBlock;
} & ASTContext;

export type LuaForStatement = {
  type: "For";
  name: string;
  start: LuaExpression;
  end: LuaExpression;
  step?: LuaExpression;
  block: LuaBlock;
  // function definition in the block captures the loop variable
  capturesLoopVar?: boolean;
} & ASTContext;

export type LuaForInStatement = {
  type: "ForIn";
  names: string[];
  expressions: LuaExpression[];
  block: LuaBlock;
  // function definition in the block captures any loop variable
  capturesLoopVar?: boolean;
} & ASTContext;

export type LuaFunctionStatement = {
  type: "Function";
  name: LuaFunctionName;
  body: LuaFunctionBody;
} & ASTContext;

export type LuaLocalFunctionStatement = {
  type: "LocalFunction";
  name: string;
  body: LuaFunctionBody;
} & ASTContext;

export type LuaFunctionName = {
  type: "FunctionName";
  propNames: string[];
  colonName?: string;
} & ASTContext;

export type LuaFunctionBody = {
  type: "FunctionBody";
  parameters: string[];
  block: LuaBlock;
} & ASTContext;

export type LuaAssignmentStatement = {
  type: "Assignment";
  variables: LuaLValue[];
  expressions: LuaExpression[];
} & ASTContext;

export type LuaLValue =
  | LuaVariable
  | LuaPropertyAccessExpression
  | LuaTableAccessExpression;

export type LuaLocalStatement = {
  type: "Local";
  names: LuaAttName[];
  expressions?: LuaExpression[];
} & ASTContext;

export enum LuaAttribute {
  Const = "const",
  Close = "close",
}

export type LuaAttName = {
  type: "AttName";
  name: string;
  attribute?: string;
  attributes?: LuaAttribute[];
} & ASTContext;

export type LuaFunctionCallStatement = {
  type: "FunctionCallStatement";
  call: LuaFunctionCallExpression;
} & ASTContext;

// EXPRESSIONS
export type LuaExpression =
  | LuaNilLiteral
  | LuaBooleanLiteral
  | LuaNumberLiteral
  | LuaStringLiteral
  | LuaPrefixExpression
  | LuaBinaryExpression
  | LuaUnaryExpression
  | LuaTableConstructor
  | LuaFunctionDefinition
  | LuaQueryExpression;

export type LuaNilLiteral = {
  type: "Nil";
} & ASTContext;

export type LuaBooleanLiteral = {
  type: "Boolean";
  value: boolean;
} & ASTContext;

export type LuaNumberLiteral = {
  type: "Number";
  value: number;
  numericType: NumericType;
} & ASTContext;

export type LuaStringLiteral = {
  type: "String";
  value: string;
} & ASTContext;

export type LuaPrefixExpression =
  | LuaVariableExpression
  | LuaParenthesizedExpression
  | LuaFunctionCallExpression;

export type LuaParenthesizedExpression = {
  type: "Parenthesized";
  expression: LuaExpression;
} & ASTContext;

export type LuaVariableExpression =
  | LuaVariable
  | LuaPropertyAccessExpression
  | LuaTableAccessExpression;

export type LuaVariable = {
  type: "Variable";
  name: string;
} & ASTContext;

export type LuaPropertyAccessExpression = {
  type: "PropertyAccess";
  object: LuaPrefixExpression;
  property: string;
} & ASTContext;

export type LuaTableAccessExpression = {
  type: "TableAccess";
  object: LuaPrefixExpression;
  key: LuaExpression;
} & ASTContext;

export type LuaFunctionCallExpression = {
  type: "FunctionCall";
  prefix: LuaPrefixExpression;
  name?: string;
  args: LuaExpression[];
} & ASTContext;

export type LuaBinaryExpression = {
  type: "Binary";
  operator: string;
  left: LuaExpression;
  right: LuaExpression;
} & ASTContext;

export type LuaUnaryExpression = {
  type: "Unary";
  operator: string;
  argument: LuaExpression;
} & ASTContext;

export type LuaTableConstructor = {
  type: "TableConstructor";
  fields: LuaTableField[];
} & ASTContext;

export type LuaTableField =
  | LuaDynamicField
  | LuaPropField
  | LuaExpressionField;

export type LuaDynamicField = {
  type: "DynamicField";
  key: LuaExpression;
  value: LuaExpression;
} & ASTContext;

export type LuaPropField = {
  type: "PropField";
  key: string;
  value: LuaExpression;
} & ASTContext;

export type LuaExpressionField = {
  type: "ExpressionField";
  value: LuaExpression;
} & ASTContext;

export type LuaFunctionDefinition = {
  type: "FunctionDefinition";
  body: LuaFunctionBody;
} & ASTContext;

// Query stuff
export type LuaQueryExpression = {
  type: "Query";
  clauses: LuaQueryClause[];
} & ASTContext;

export type LuaQueryClause =
  | LuaFromClause
  | LuaWhereClause
  | LuaLimitClause
  | LuaOrderByClause
  | LuaSelectClause;

export type LuaFromClause = {
  type: "From";
  name?: string;
  expression: LuaExpression;
} & ASTContext;

export type LuaWhereClause = {
  type: "Where";
  expression: LuaExpression;
} & ASTContext;

export type LuaLimitClause = {
  type: "Limit";
  limit: LuaExpression;
  offset?: LuaExpression;
} & ASTContext;

export type LuaOrderByClause = {
  type: "OrderBy";
  orderBy: LuaOrderBy[];
} & ASTContext;

export type LuaOrderBy = {
  type: "Order";
  expression: LuaExpression;
  direction: "asc" | "desc";
} & ASTContext;

export type LuaSelectClause = {
  type: "Select";
  expression: LuaExpression;
} & ASTContext;


================================================
FILE: client/space_lua/ast_narrow.ts
================================================
// Typed narrowers for AST nodes to avoid `(node as any)` in consumers.

import type { ASTCtx, LuaExpression, LuaLValue, LuaStatement } from "./ast.ts";

// Extract by `type` discriminant
type NarrowByType<U, K extends U extends { type: infer T } ? T : never> =
  Extract<U, { type: K }>;

// Expressions
export const asStringExpr = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "String">;
export const asNumberExpr = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "Number">;
export const asBooleanExpr = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "Boolean">;
export const asNilExpr = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "Nil">;
export const asUnary = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "Unary">;
export const asBinary = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "Binary">;
export const asVariable = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "Variable">;
export const asFunctionCall = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "FunctionCall">;
export const asTableAccess = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "TableAccess">;
export const asPropertyAccess = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "PropertyAccess">;
export const asParenthesized = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "Parenthesized">;
export const asTableConstructor = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "TableConstructor">;
export const asFunctionDef = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "FunctionDefinition">;
export const asQueryExpr = (e: LuaExpression) =>
  e as NarrowByType<LuaExpression, "Query">;

// L-values
export const asLValueVariable = (l: LuaLValue) =>
  l as Extract<LuaLValue, { type: "Variable" }>;
export const asLValueTableAccess = (l: LuaLValue) =>
  l as Extract<LuaLValue, { type: "TableAccess" }>;
export const asLValuePropertyAccess = (l: LuaLValue) =>
  l as Extract<LuaLValue, { type: "PropertyAccess" }>;

// Statements
export const asAssignment = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Assignment">;
export const asLocal = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Local">;
export const asBlock = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Block">;
export const asIf = (s: LuaStatement) => s as NarrowByType<LuaStatement, "If">;
export const asWhile = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "While">;
export const asRepeat = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Repeat">;
export const asBreak = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Break">;
export const asFunctionStmt = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Function">;
export const asLocalFunction = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "LocalFunction">;
export const asFunctionCallStmt = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "FunctionCallStatement">;
export const asReturn = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Return">;
export const asFor = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "For">;
export const asForIn = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "ForIn">;
export const asLabel = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Label">;
export const asGoto = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Goto">;
export const asSemicolon = (s: LuaStatement) =>
  s as NarrowByType<LuaStatement, "Semicolon">;

// Pull ctx with a single shape
export const ctxOf = (node: { ctx: ASTCtx }): ASTCtx => node.ctx;


================================================
FILE: client/space_lua/break_async.test.ts
================================================
import { assertEquals } from "@std/assert/equals";
import { parse } from "./parse.ts";
import { evalStatement } from "./eval.ts";
import { LuaEnv, LuaNativeJSFunction, LuaStackFrame } from "./runtime.ts";
import { luaBuildStandardEnv } from "./stdlib.ts";

async function evalBlock(code: string, env?: LuaEnv) {
  const ast = parse(code);
  // Use the same global env for both the base env's parent and the StackFrame's _GLOBAL
  const G = luaBuildStandardEnv();
  const base = env ?? new LuaEnv(G);
  const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx);
  const r = evalStatement(ast, base, sf, false);
  if (r instanceof Promise) await r;
  return base;
}

Deno.test("For loops: inner break", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());
  await evalBlock(
    `
    local fruits = {"apple", "banana", "cherry"}
    local result = {}
    found = 0
    for index, fruit in ipairs(fruits) do
      table.insert(result, fruit)
      found = found + 1
      break
    end
    `,
    env,
  );
  assertEquals(env.get("found"), 1);
});

Deno.test("Nested for loops: inner break (sync body)", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());
  await evalBlock(
    `
      local t = {1,2,3}
      found = 0
      for i = 1, #t do
        for j = i + 1, #t do
          found = found + 1
          break
            error("BUG! Execution continued after break!")
        end
      end
    `,
    env,
  );
  assertEquals(env.get("found"), 2);
});

Deno.test("Nested for loops: inner break with async call in body (Promise path)", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());
  env.set(
    "asyncOne",
    new LuaNativeJSFunction(() => Promise.resolve(1)),
  );
  await evalBlock(
    `
      local t = {1,2,3}
      found = 0
      for i = 1, #t do
        for j = i + 1, #t do
          asyncOne()         -- forces Promise in body
          found = found + 1  -- still must execute before break
          break              -- breaks only inner loop
          error("BUG! Execution continued after break!")
        end
      end
    `,
    env,
  );
  assertEquals(env.get("found"), 2);
});

Deno.test("While: Promise condition, break inside body", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());
  // asyncTrue returns a truthy value via Promise to trigger Promise-based while path
  env.set(
    "asyncTrue",
    new LuaNativeJSFunction(() => Promise.resolve(1)),
  );
  await evalBlock(
    `
      cnt = 0
      local first = true
      local function cond()
        if first then
          first = false
          return asyncTrue()
        end
        return nil
      end
      while cond() do
        cnt = cnt + 1
        break
        error("BUG! Execution continued after break!")
      end
    `,
    env,
  );
  assertEquals(env.get("cnt"), 1);
});

Deno.test("Repeat-until: body goes Promise once, break handled locally", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());
  env.set(
    "asyncOne",
    new LuaNativeJSFunction(() => Promise.resolve(1)),
  );
  await evalBlock(
    `
      n = 0
      repeat
        asyncOne() -- body Promise
        n = n + 1
        break
        error("BUG! Execution continued after break!")
      until true
    `,
    env,
  );
  assertEquals(env.get("n"), 1);
});

Deno.test("For-in: custom iterator, Promise in body before break", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());
  env.set(
    "asyncOne",
    new LuaNativeJSFunction(() => Promise.resolve(1)),
  );
  await evalBlock(
    `
      function gen(n)
        local i = 0
        return function()
          i = i + 1
          if i <= n then return i end
        end
      end

      hits = 0
      for v in gen(3) do
        asyncOne() -- force Promise during body
        hits = hits + 1
        break
        error("BUG! Execution continued after break!")
      end
    `,
    env,
  );
  assertEquals(env.get("hits"), 1);
});

Deno.test("Jaro-like inner-window matching: inner break with occasional Promise", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());
  env.set(
    "tick",
    new LuaNativeJSFunction(() => Promise.resolve(true)),
  );
  await evalBlock(
    `
      local s1 = "#aa"
      local s2 = "#ab"
      local l1, l2 = #s1, #s2
      local md = (l1 > l2 and l1 or l2) // 2 - 1
      if md < 0 then md = 0 end

      s1m = {}
      s2m = {}
      mc  = 0

      for i = 1, l1 do
        local start  = (1 > i - md) and 1 or (i - md)
        local finish = ((i + md) < l2) and (i + md) or l2

        for j = start, finish do
          tick() -- Promise in inner loop body to exercise Promise path
          if not s2m[j] and s1:byte(i) == s2:byte(j) then
            s1m[i] = true
            s2m[j] = true
            mc = mc + 1
            break
            error("BUG! Execution continued after break!")
          end
        end
      end

      ok = (mc >= 1)
    `,
    env,
  );
  assertEquals(env.get("ok"), true);
});


================================================
FILE: client/space_lua/close_attribute.test.ts
================================================
import {
  assertEquals,
  assertInstanceOf,
  assertStringIncludes,
} from "@std/assert";
import { isPromise } from "./rp.ts";
import { parse } from "./parse.ts";
import { evalStatement } from "./eval.ts";
import { LuaEnv, LuaRuntimeError, LuaStackFrame } from "./runtime.ts";
import { luaBuildStandardEnv } from "./stdlib.ts";

async function evalBlock(code: string, env?: LuaEnv): Promise<LuaEnv> {
  const ast = parse(code);
  const G = luaBuildStandardEnv();
  const base = env ?? new LuaEnv(G);
  const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx);
  const r = evalStatement(ast, base, sf, false);

  if (isPromise(r)) {
    await r;
  }
  return base;
}

async function runAndCatch(code: string, ref = "close_attribute.lua") {
  const ast = parse(code, { ref });
  const G = luaBuildStandardEnv();
  const env = new LuaEnv(G);
  const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx);

  try {
    const r = evalStatement(ast, env, sf, false);
    if (isPromise(r)) {
      await r;
    }
    throw new Error("Expected error but evaluation succeeded");
  } catch (e: unknown) {
    return { e, code, ref };
  }
}

async function runAndCatchEnv(code: string, ref = "close_attribute.lua") {
  const ast = parse(code, { ref });
  const G = luaBuildStandardEnv();
  const env = new LuaEnv(G);
  const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx);
  let err: unknown = null;

  try {
    const r = evalStatement(ast, env, sf, false);
    if (isPromise(r)) {
      await r;
    }
  } catch (e: unknown) {
    err = e;
  }

  return { err, env };
}

// 1. parsing and static validation

Deno.test("close: parse ok", () => {
  parse(
    `
    local x<close> = {}
  `,
    { ref: "close_parse.lua" },
  );
});

Deno.test("close: parse ok without init", () => {
  parse(
    `
    do
      local x<close>
    end
  `,
    { ref: "close_no_init.lua" },
  );
});

Deno.test("close: local list only one", () => {
  try {
    parse(
      `
      do
        local a<close>, b<close> = {}, {}
      end
    `,
      { ref: "multi_close.lua" },
    );
    throw new Error("Expected parse error");
  } catch (e) {
    assertStringIncludes(String((e as any)?.message ?? e), "<close>");
    assertStringIncludes(String((e as any)?.message ?? e), "local list");
  }
});

Deno.test("close: <const> parse ok", () => {
  parse(
    `
    do
      local x<const> = 1
    end
  `,
    { ref: "const_parse.lua" },
  );
});

Deno.test("close: <const> and <close> parse ok", () => {
  parse(
    `
    do
      local x<const> = 1
      local y<close> = setmetatable({}, {
        __close = function()
        end
      })
    end
  `,
    { ref: "const_and_close_parse.lua" },
  );
});

Deno.test("close: invalid goto into scope", () => {
  try {
    parse(
      `
      do
        goto L
        local x<close> = setmetatable({}, {
          __close = function()
          end
        })
        ::L::
      end
    `,
      { ref: "goto_into_scope.lua" },
    );
    throw new Error("Expected parse error");
  } catch (e) {
    assertStringIncludes(String((e as any)?.message ?? e), "goto");
  }
});

// 2. basic scope exit

Deno.test("close: nil ignored; false is non-closable", async () => {
  const { e, ref, code } = await runAndCatch(
    `
    do
      local x<close> = nil
      local y<close> = false
    end
  `,
    "close_nil_false.lua",
  );

  assertStringIncludes(ref, "close_nil_false.lua");
  assertStringIncludes(code, "local y<close> = false");

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: normal close gets nil error", async () => {
  const env = await evalBlock(`
    t = {}

    do
      local x<close> = setmetatable({"X"}, {
        __close = function(self, err)
          if err == nil then
            table.insert(t, "nil")
          else
            table.insert(t, "not-nil")
          end
        end
      })
    end
  `);

  assertEquals((env.get("t") as any).get(1), "nil");
});

Deno.test("close: close order", async () => {
  const env = await evalBlock(`
    t = {}

    do
      local a<close> = setmetatable({1}, {
        __close = function(self)
          table.insert(t, self[1])
        end
      })

      local b<close> = setmetatable({2}, {
        __close = function(self)
          table.insert(t, self[1])
        end
      })
    end
  `);

  assertEquals((env.get("t") as any).get(1), 2);
  assertEquals((env.get("t") as any).get(2), 1);
});

Deno.test("close: shadowed variables close independently", async () => {
  const env = await evalBlock(`
    t = {}

    do
      local x<close> = setmetatable({"OUTER"}, {
        __close = function(self) table.insert(t, "close-" .. self[1]) end
      })

      do
        local x<close> = setmetatable({"INNER"}, {
          __close = function(self) table.insert(t, "close-" .. self[1]) end
        })
        table.insert(t, "inner-end")
      end

      table.insert(t, "middle")
    end

    table.insert(t, "outer-end")
  `);

  const t = env.get("t") as any;

  assertEquals(t.get(1), "inner-end");
  assertEquals(t.get(2), "close-INNER");
  assertEquals(t.get(3), "middle");
  assertEquals(t.get(4), "close-OUTER");
  assertEquals(t.get(5), "outer-end");
});

Deno.test("close: metatable swap after mark closes with new __close", async () => {
  const env = await evalBlock(`
    t = {}

    do
      local mt1 = {
        __close = function()
          table.insert(t, "close-1")
        end
      }
      local mt2 = {
        __close = function()
          table.insert(t, "close-2")
        end
      }

      local x<close> = setmetatable({}, mt1)
      setmetatable(x, mt2)
    end
  `);

  const t = env.get("t") as any;

  // Lua uses the metamethod at close time; this verifies close-time
  // lookup
  assertEquals(t.get(1), "close-2");
});

Deno.test("close: replacing __close function affects close-time behavior", async () => {
  const env = await evalBlock(`
    t = {}

    do
      local mt = {
        __close = function()
          table.insert(t, "close-1")
        end
      }

      local x<close> = setmetatable({}, mt)

      mt.__close = function()
        table.insert(t, "close-2")
      end
    end
  `);

  const t = env.get("t") as any;
  assertEquals(t.length, 1);
  assertEquals(t.get(1), "close-2");
});

// 3. errors and unwinding

Deno.test("close: non-closable init", async () => {
  const { e } = await runAndCatch(`
    do
      local x<close> = {}
    end
  `);

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: reassign after implicit nil errors", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close>
      a = 1
    end
  `,
    "close_reassign_after_implicit_nil.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'a'",
  );
});

Deno.test("close: reassign after explicit nil errors", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close> = nil
      a = 1
    end
  `,
    "close_reassign_after_explicit_nil.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'a'",
  );
});

Deno.test("close: reassign after false is non-closable", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close> = false
      a = 1
    end
  `,
    "close_reassign_after_false.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: reassign after closable value errors", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close> = setmetatable({"A"}, {
        __close = function(self)
        end
      })
      a = 1
    end
  `,
    "close_reassign_after_closable.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'a'",
  );
});

Deno.test("close: reassignment through closure is non-closable", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close> = false

      local function g()
        a = nil
      end

      g()
    end
  `,
    "close_reassign_through_closure.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: initializer true is non-closable", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close> = true
    end
  `,
    "close_init_true.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: initializer integer is non-closable", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close> = 1
    end
  `,
    "close_init_int.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: initializer float is non-closable", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close> = 3.14
    end
  `,
    "close_init_float.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: initializer string is non-closable", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close> = "string"
    end
  `,
    "close_init_string.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: initializer function is non-closable", async () => {
  const { e } = await runAndCatch(
    `
    do
      local a<close> = function() end
    end
  `,
    "close_init_function.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: __close called exactly once on normal exit", async () => {
  const env = await evalBlock(`
    t = {}

    do
      local called = 0
      local x<close> = setmetatable({}, {
        __close = function()
          called = called + 1
          table.insert(t, called)
        end
      })
    end
  `);

  const t = env.get("t") as any;

  assertEquals(t.length, 1);
  assertEquals(t.get(1), 1);
});

Deno.test("close: async __close awaited on normal exit", async () => {
  const G = luaBuildStandardEnv();
  const env = new LuaEnv(G);

  // Promise-returning function: forces async close path deterministically.
  (env as any).setLocal(
    "delay",
    (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
  );

  await evalBlock(
    `
    t = {}

    do
      local x<close> = setmetatable({}, {
        __close = function()
          delay(10)
          table.insert(t, "closed")
        end
      })
    end
  `,
    env,
  );

  const t = env.get("t") as any;
  assertEquals(t.length, 1);
  assertEquals(t.get(1), "closed");
});

Deno.test("close: __close called exactly once on error unwind", async () => {
  const { err: e, env } = await runAndCatchEnv(
    `
    t = {}
    called = 0

    do
      local x<close> = setmetatable({}, {
        __close = function(self, err)
          called = called + 1
          table.insert(t, called)
        end
      })
      error("boom")
    end
  `,
    "close_once_on_error.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "boom");

  const t = env.get("t") as any;
  assertEquals(t.length, 1);
  assertEquals(t.get(1), 1);
});

Deno.test("close: async __close awaited on error unwind", async () => {
  const code = `
    t = {}

    do
      local x<close> = setmetatable({"X"}, {
        __close = function(self, err)
          delay(10)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
      error("boom")
    end
  `;

  const ast = parse(code, { ref: "close_async_unwind.lua" });
  const G = luaBuildStandardEnv();
  const env = new LuaEnv(G);
  const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx);

  env.setLocal(
    "delay",
    (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
  );

  let e: unknown = null;
  try {
    const r = evalStatement(ast, env, sf, false);
    if (isPromise(r)) {
      await r;
    }
  } catch (err: unknown) {
    e = err;
  }

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "boom");

  const t = env.get("t") as any;
  assertEquals(t.length, 1);
  assertEquals(t.get(1), "close-X-boom");
});

Deno.test("close: error in __close inside vararg function", async () => {
  const { e } = await runAndCatch(
    `
    local function mk()
      return setmetatable({}, {
        __close = function()
          error("closefail")
        end
      })
    end

    local function f(...)
      local x<close> = mk()
      return ...
    end

    f(1, 2, 3)
  `,
    "close_vararg_close_error.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "closefail");
});

Deno.test("close: __close not callable", async () => {
  const { e } = await runAndCatch(
    `
    do
      local x<close> = setmetatable({}, {
        __close = 1
      })
    end
  `,
    "close_not_callable.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: __close not found via __index", async () => {
  const { e } = await runAndCatch(
    `
    do
      local mt = {
        __index = {
          __close = function()
          end
        }
      }

      local x<close> = setmetatable({}, mt)
    end
  `,
    "close_index_metaclose.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: mutate __close", async () => {
  const { e } = await runAndCatch(
    `
    do
      local mt = {
        __close = function()
        end
      }
      local x<close> = setmetatable({}, mt)
      mt.__close = 1
    end
  `,
    "close_mutate.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "not callable");
});

Deno.test("close: remove __close after mark errors at close time", async () => {
  const { err: e } = await runAndCatchEnv(
    `
    do
      local mt = {
        __close = function()
        end
      }
      local x<close> = setmetatable({}, mt)

      mt.__close = nil
    end
  `,
    "close_remove_metaclose.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "not callable");
});

Deno.test("close: __close callable via __call", async () => {
  const { e } = await runAndCatch(
    `
    do
      local c = setmetatable({}, {
        __call = function(self, obj, err)
          table.insert(t, "called")
        end
      })

      local x<close> = setmetatable({}, {
        __close = c
      })
    end
  `,
    "close_callable_via_call.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: error after close var init still closes", async () => {
  const { e } = await runAndCatch(
    `
    t = {}

    local function mk()
      return setmetatable({"A"}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
        end
      })
    end

    do
      local a<close> = mk()
      error("boom")
    end
  `,
    "close_error_after_init_closes.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "boom");
});

Deno.test("close: close gets error", async () => {
  const { e } = await runAndCatch(`
    t = {}

    local function mk()
      return setmetatable({}, {
        __close = function(self, err)
          table.insert(t, tostring(err))
        end
      })
    end

    do
      local a<close> = mk()
      error("boom")
    end
  `);

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "boom");
});

Deno.test("close: close error", async () => {
  const { e } = await runAndCatch(
    `
    do
      local x<close> = setmetatable({}, {
        __close = function()
          error("closefail")
        end
      })
    end
  `,
    "close_error.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "closefail");
});

Deno.test("close: close error on error", async () => {
  const { e } = await runAndCatch(
    `
    do
      local x<close> = setmetatable({}, {
        __close = function()
          error("closefail")
        end
      })

      error("boom")
    end
  `,
    "close_error_on_error.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "closefail");
});

Deno.test("close: close errors stop", async () => {
  const { err: e, env } = await runAndCatchEnv(
    `
    t = {}

    do
      local a<close> = setmetatable({"A"}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
          error("c1")
        end
      })

      local b<close> = setmetatable({"B"}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
          error("c2")
        end
      })
    end
  `,
    "close_errors_stop.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "c2");

  // verify that both closes were attempted (Lua 5.4), but the first
  // close error (from B, closed first) is the reported error
  const t = env.get("t") as any;
  assertEquals(t.length, 2);
  assertEquals(t.get(1), "close-B");
  assertEquals(t.get(2), "close-A");
});

Deno.test("close: multiple closers unwind on error", async () => {
  const { err: e, env } = await runAndCatchEnv(
    `
    t = {}

    local function mk(x)
      return setmetatable({x}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
    end

    do
      local a<close> = mk("A")
      local b<close> = mk("B")
      error("boom")
    end
  `,
    "close_multi_unwind.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "boom");

  // verify both were closed in reverse order with the error object
  const t = env.get("t") as any;
  assertEquals(t.length, 2);
  assertEquals(t.get(1), "close-B-boom");
  assertEquals(t.get(2), "close-A-boom");
});

Deno.test("close: close errors stop during unwind", async () => {
  const { err: e, env } = await runAndCatchEnv(
    `
    t = {}

    local function mk(x, fail)
      return setmetatable({x}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
          if fail then
            error("closefail-" .. self[1])
          end
        end
      })
    end

    do
      local a<close> = mk("A", false)
      local b<close> = mk("B", true)
      error("boom")
    end
  `,
    "close_unwind_close_error.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "closefail-B");

  // verify both closes were attempted (Lua 5.4), but the first close
  // error (from B, closed first) is the reported error
  const t = env.get("t") as any;
  assertEquals(t.length, 2);
  assertEquals(t.get(1), "close-B-boom");
  assertEquals(t.get(2), "close-A-boom");
});

Deno.test("close: complex assignment error closes prior", async () => {
  const { err: e, env } = await runAndCatchEnv(
    `
    t = {}

    local function f1()
      return setmetatable({}, {
        __close = function()
          table.insert(t, "a_closed")
        end
      })
    end

    local function f2()
      error("assign_error")
    end

    do
      local a<close> = f1()
      local b = f2()
    end
  `,
    "complex_assign_error.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "assign_error");

  // verify 'a' was closed despite 'b' failing to assign
  const t = env.get("t") as any;
  assertEquals(t.get(1), "a_closed");
});

Deno.test("close: multi-init closes prior when later init errors", async () => {
  const { err: e, env } = await runAndCatchEnv(
    `
    t = {}

    local function mk()
      return setmetatable({}, {
        __close = function()
          table.insert(t, "a_closed")
        end
      })
    end

    local function fail()
      error("boom")
    end

    do
      local a<close>, b = mk(), fail()
    end
  `,
    "close_multi_init_later_error.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "boom");

  const t = env.get("t") as any;
  assertEquals(t.get(1), "a_closed");
});

Deno.test("close: async close error reported; later closers still run", async () => {
  const G = luaBuildStandardEnv();
  const env = new LuaEnv(G);

  env.setLocal(
    "delay",
    (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
  );

  const { err: e } = await (async () => {
    const code = `
      t = {}

      do
        local a<close> = setmetatable({"A"}, {
          __close = function(self, err)
            delay(5)
            table.insert(t, "close-" .. self[1])
          end
        })

        local b<close> = setmetatable({"B"}, {
          __close = function(self, err)
            delay(5)
            table.insert(t, "close-" .. self[1])
            error("b-closefail")
          end
        })
      end
    `;
    const ast = parse(code, { ref: "close_async_close_error.lua" });
    const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx);

    let err: unknown = null;
    try {
      const r = evalStatement(ast, env, sf, false);
      if (isPromise(r)) await r;
    } catch (x) {
      err = x;
    }
    return { err };
  })();

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "b-closefail");

  const t = env.get("t") as any;
  // B closes first and errors, but A must still be closed in Lua 5.4 intent
  assertEquals(t.length, 2);
  assertEquals(t.get(1), "close-B");
  assertEquals(t.get(2), "close-A");
});

// 4. control flow exits

Deno.test("close: return closes", async () => {
  const env = await evalBlock(`
    t = {}

    local function f()
      do
        local x<close> = setmetatable({"X"}, {
          __close = function(self)
            table.insert(t, "close-" .. self[1])
          end
        })
        return 1
      end
    end

    table.insert(t, f())
  `);

  assertEquals((env.get("t") as any).get(1), "close-X");
  assertEquals((env.get("t") as any).get(2), 1);
});

Deno.test("close: return in if closes", async () => {
  const env = await evalBlock(`
    t = {}

    local function f()
      do
        local x<close> = setmetatable({"X"}, {
          __close = function(self)
            table.insert(t, "close-" .. self[1])
          end
        })
        if true then
          return 2
        end
        return 3
      end
    end

    table.insert(t, f())
  `);

  assertEquals((env.get("t") as any).get(1), "close-X");
  assertEquals((env.get("t") as any).get(2), 2);
});

Deno.test("close: goto closes", async () => {
  const env = await evalBlock(`
    t = {}

    do
      local x<close> = setmetatable({"X"}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
        end
      })

      goto L1
      ::L2::
      error("bad")
      ::L1::
      goto L3
      ::L3::
    end
  `);

  assertEquals((env.get("t") as any).get(1), "close-X");
});

Deno.test("close: goto nested", async () => {
  const env = await evalBlock(`
    t = {}

    do
      do
        local a<close> = setmetatable({"A"}, {
          __close = function(self)
            table.insert(t, "close-" .. self[1])
          end
        })
        do
          local b<close> = setmetatable({"B"}, {
            __close = function(self)
              table.insert(t, "close-" .. self[1])
            end
          })
          goto L
        end
      end
      ::L::
    end
  `);

  assertEquals((env.get("t") as any).get(1), "close-B");
  assertEquals((env.get("t") as any).get(2), "close-A");
});

Deno.test("close: return to-be-closed variable", async () => {
  const env = await evalBlock(`
    t = {}

    local function f()
      local x<close> = setmetatable({"X"}, {
        __close = function(self)
          table.insert(t, "closed")
        end
      })
      return x
    end

    local r = f()
    table.insert(t, type(r))
  `);

  const t = env.get("t") as any;
  assertEquals(t.get(1), "closed");
  assertEquals(t.get(2), "table");
});

// 5. generic-for loop-scoped closing

Deno.test("close: for-in no close", async () => {
  const env = await evalBlock(`
    t = {}

    local function iter(state, ctrl)
      if ctrl == nil then
        return 1, "v"
      end
      return nil
    end

    local function gen()
      return iter, nil, nil
    end

    for k, v in gen() do
      table.insert(t, "body")
    end
  `);

  assertEquals((env.get("t") as any).get(1), "body");
  assertEquals((env.get("t") as any).length, 1);
});

Deno.test("close: for-in false close is non-closable", async () => {
  const { e } = await runAndCatch(
    `
    local function iter(state, ctrl)
      if ctrl == nil then
        return 1
      end
      return nil
    end

    local function gen()
      return iter, nil, nil, false
    end

    for k in gen() do
    end
  `,
    "for_in_false_close.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: for-in bad close", async () => {
  const { e } = await runAndCatch(
    `
    local function iter(state, ctrl)
      if ctrl == nil then
        return 1
      end
      return nil
    end

    local function gen()
      return iter, nil, nil, {}
    end

    for k in gen() do
    end
  `,
    "for_in_bad_close.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "non-closable");
});

Deno.test("close: for-in updates control", async () => {
  const env = await evalBlock(`
    t = {}
    local calls = 0

    local function iter(state, ctrl)
      calls = calls + 1
      if ctrl == nil then
        return 1
      end
      return nil
    end

    local function gen()
      local closing = setmetatable({"C"}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
        end
      })

      return iter, nil, nil, closing
    end

    for k in gen() do
      table.insert(t, "body")
    end

    table.insert(t, calls)
  `);

  assertEquals((env.get("t") as any).get(1), "body");
  assertEquals((env.get("t") as any).get(2), "close-C");
  assertEquals((env.get("t") as any).get(3), 2);
});

Deno.test("close: for-in closes", async () => {
  const env = await evalBlock(`
    t = {}

    local function iter(state, ctrl)
      if ctrl == nil then
        return 1, "v"
      end
      return nil
    end

    local function gen()
      local closing = setmetatable({"C"}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
        end
      })

      return iter, nil, nil, closing
    end

    for k, v in gen() do
      table.insert(t, "body")
    end
  `);

  assertEquals((env.get("t") as any).get(1), "body");
  assertEquals((env.get("t") as any).get(2), "close-C");
});

Deno.test("close: for-in async iterator and async closing are awaited", async () => {
  const G = luaBuildStandardEnv();
  const env = new LuaEnv(G);

  (env as any).setLocal(
    "delay",
    (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)),
  );

  await evalBlock(
    `
    t = {}
    local calls = 0

    local function iter(state, ctrl)
      calls = calls + 1
      delay(5)
      if ctrl == nil then
        return 1
      end
      return nil
    end

    local function gen()
      local closing = setmetatable({"C"}, {
        __close = function(self, err)
          delay(5)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
      return iter, nil, nil, closing
    end

    for k in gen() do
      table.insert(t, "body")
    end

    table.insert(t, calls)
  `,
    env,
  );

  const t = env.get("t") as any;
  assertEquals(t.get(1), "body");
  assertEquals(t.get(2), "close-C-nil");
  assertEquals(t.get(3), 2);
});

Deno.test("close: loop scope", async () => {
  const env = await evalBlock(`
    t = {}

    local function iter(state, ctrl)
      if ctrl == nil then
        return 1
      end
      return nil
    end

    local function gen()
      local closing = setmetatable({"C"}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
        end
      })
      return iter, nil, nil, closing
    end

    for k in gen() do
      table.insert(t, "body")
    end

    table.insert(t, "after")
  `);

  assertEquals((env.get("t") as any).get(1), "body");
  assertEquals((env.get("t") as any).get(2), "close-C");
  assertEquals((env.get("t") as any).get(3), "after");
});

Deno.test("close: for-in closing closes before outer block closers", async () => {
  const env = await evalBlock(`
    t = {}

    local function iter(state, ctrl)
      if ctrl == nil then
        return 1
      end
      return nil
    end

    do
      local outer<close> = setmetatable({"O"}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
        end
      })

      local function gen()
        local closing = setmetatable({"L"}, {
          __close = function(self)
            table.insert(t, "close-" .. self[1])
          end
        })
        return iter, nil, nil, closing
      end

      for k in gen() do
        table.insert(t, "body")
      end

      table.insert(t, "after-loop")
    end

    table.insert(t, "after-block")
  `);

  const t = env.get("t") as any;

  // Required ordering:
  // body runs
  // loop closing value closes at loop end
  // after-loop runs
  // outer closes when leaving do-block
  // after-block runs
  assertEquals(t.get(1), "body");
  assertEquals(t.get(2), "close-L");
  assertEquals(t.get(3), "after-loop");
  assertEquals(t.get(4), "close-O");
  assertEquals(t.get(5), "after-block");
});

Deno.test("close: for-in closes on break", async () => {
  const env = await evalBlock(`
    t = {}

    local function iter(state, ctrl)
      if ctrl == nil then
        return 1, "v"
      end
      return 2, "w"
    end

    local function gen()
      local closing = setmetatable({"C"}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
        end
      })

      return iter, nil, nil, closing
    end

    for k, v in gen() do
      table.insert(t, "body")
      break
    end
  `);

  assertEquals((env.get("t") as any).get(1), "body");
  assertEquals((env.get("t") as any).get(2), "close-C");
});

Deno.test("close: for-in closes on error", async () => {
  const { err: e, env } = await runAndCatchEnv(`
    t = {}

    local function iter(state, ctrl)
      if ctrl == nil then
        return 1, "v"
      end
      return nil
    end

    local function gen()
      local closing = setmetatable({"C"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })

      return iter, nil, nil, closing
    end

    for k, v in gen() do
      error("boom")
    end
  `);

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "boom");

  // verify closer received error
  const t = env.get("t") as any;
  assertEquals(t.get(1), "close-C-boom");
});

Deno.test("close: for-in closes if iterator errors", async () => {
  const { err: e, env } = await runAndCatchEnv(
    `
    t = {}

    local function iter(state, ctrl)
      error("iterboom")
    end

    local function gen()
      local closing = setmetatable({"C"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })

      return iter, nil, nil, closing
    end

    for k in gen() do
    end
  `,
    "for_in_iterator_error.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "iterboom");

  // verify closer received error
  const t = env.get("t") as any;
  assertEquals(t.get(1), "close-C-iterboom");
});

Deno.test("close: return inside for-in body closes loop closing value", async () => {
  const env = await evalBlock(`
    t = {}

    local function iter(state, ctrl)
      if ctrl == nil then
        return 1
      end
      return nil
    end

    local function gen()
      local closing = setmetatable({"C"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
      return iter, nil, nil, closing
    end

    local function f()
      for k in gen() do
        return 1
      end
      return 2
    end

    table.insert(t, f())
  `);

  const t = env.get("t") as any;
  assertEquals(t.get(1), "close-C-nil");
  assertEquals(t.get(2), 1);
});

Deno.test("close: error inside for-in body closes loop closing value with error", async () => {
  const { err: e, env } = await runAndCatchEnv(
    `
    t = {}

    local function iter(state, ctrl)
      if ctrl == nil then
        return 1
      end
      return nil
    end

    local function gen()
      local closing = setmetatable({"C"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
      return iter, nil, nil, closing
    end

    for k in gen() do
      error("boom")
    end
  `,
    "for_in_body_error_close_value.lua",
  );

  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "boom");

  const t = env.get("t") as any;
  assertEquals(t.get(1), "close-C-boom");
});

// 6. pairs integration

Deno.test("close: pairs closes", async () => {
  const env = await evalBlock(`
    t = {}
    local closed = false

    local a = {10, 20}

    local function iter(state, ctrl)
      if ctrl == nil then
        return 1, state[1]
      end
      return nil
    end

    setmetatable(a, {
      __pairs = function(self)
        local tbc = setmetatable({"P"}, {
          __close = function()
            table.insert(t, "close-P")
            closed = true
          end
        })
        return iter, self, nil, tbc
      end
    })

    for k, v in pairs(a) do
      table.insert(t, "body")
    end

    if closed then
      table.insert(t, "closed")
    end
  `);

  assertEquals((env.get("t") as any).get(1), "body");
  assertEquals((env.get("t") as any).get(2), "close-P");
  assertEquals((env.get("t") as any).get(3), "closed");
});

// 7. protected calls

Deno.test("close: pcall closes on success", async () => {
  const env = await evalBlock(`
    t = {}

    local function f()
      local x<close> = setmetatable({"X"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
      return 1
    end

    local ok, r = pcall(f)
    if ok then
      table.insert(t, r)
    end
  `);

  assertEquals((env.get("t") as any).get(1), "close-X-nil");
  assertEquals((env.get("t") as any).get(2), 1);
});

Deno.test("close: multi-return expansion binds and closes", async () => {
  const env = await evalBlock(`
    t = {}

    local function mk()
      local obj = setmetatable({"A"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
      return obj, 99
    end

    do
      local a<close>, b = mk()
      table.insert(t, b)
    end
  `);

  const t = env.get("t") as any;
  assertEquals(t.get(1), 99);
  assertEquals(t.get(2), "close-A-nil");
});

Deno.test("close: pcall return closes with nil error", async () => {
  const env = await evalBlock(`
    t = {}

    local function mk()
      return setmetatable({"R"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
    end

    local function f()
      do
        local x<close> = mk()
        return 7
      end
    end

    local ok, r = pcall(f)
    table.insert(t, ok)
    table.insert(t, r)
  `);

  const t = env.get("t") as any;
  assertEquals(t.get(1), "close-R-nil");
  assertEquals(t.get(2), true);
  assertEquals(t.get(3), 7);
});

Deno.test("close: pcall break closes with nil error", async () => {
  const env = await evalBlock(`
    t = {}

    local function mk()
      return setmetatable({"B"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
    end

    local function f()
      do
        local x<close> = mk()
        while true do
          break
        end
      end
      return 1
    end

    local ok, r = pcall(f)
    table.insert(t, ok)
    table.insert(t, r)
  `);

  const t = env.get("t") as any;
  assertEquals(t.get(1), "close-B-nil");
  assertEquals(t.get(2), true);
  assertEquals(t.get(3), 1);
});

Deno.test("close: pcall goto closes with nil error", async () => {
  const env = await evalBlock(`
    t = {}

    local function mk()
      return setmetatable({"G"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
    end

    local function f()
      do
        local x<close> = mk()
        goto L
        error("unreachable")
        ::L::
      end
      return 2
    end

    local ok, r = pcall(f)
    table.insert(t, ok)
    table.insert(t, r)
  `);

  const t = env.get("t") as any;
  assertEquals(t.get(1), "close-G-nil");
  assertEquals(t.get(2), true);
  assertEquals(t.get(3), 2);
});

Deno.test("close: pcall closes on error", async () => {
  const env = await evalBlock(`
    t = {}

    local function f()
      local x<close> = setmetatable({"X"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
      error("boom")
    end

    local ok, msg = pcall(f)
    if not ok then
      table.insert(t, tostring(msg))
    end
  `);

  assertEquals((env.get("t") as any).get(1), "close-X-boom");
  assertEquals((env.get("t") as any).get(2), "boom");
});

Deno.test("close: pcall close error overrides original and skips remaining closers", async () => {
  const env = await evalBlock(`
      t = {}

      local function mk(label, mode)
        return setmetatable({label}, {
          __close = function(self, err)
            table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
            if mode == "error" then
              error("closefail-" .. self[1])
            end
          end
        })
      end

      local function f()
        do
          local a<close> = mk("A", "ok")
          local b<close> = mk("B", "error")
          local c<close> = mk("C", "ok")
          error("boom")
        end
      end

      local ok, msg = pcall(f)
      table.insert(t, ok)
      table.insert(t, tostring(msg))
    `);

  const t = env.get("t") as any;

  // C closes first, then B closes and errors; A is still closed in Lua 5.4
  assertEquals(t.get(1), "close-C-boom");
  assertEquals(t.get(2), "close-B-boom");
  assertEquals(t.get(3), "close-A-boom");
  assertEquals(t.get(4), false);
  assertEquals(t.get(5), "closefail-B");
  assertEquals(t.length, 5);
});

Deno.test("close: xpcall closes on error", async () => {
  const env = await evalBlock(`
    t = {}

    local function f()
      local x<close> = setmetatable({"X"}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
      error("boom")
    end

    local function h(err)
      return "handled-" .. tostring(err)
    end

    local ok, msg = xpcall(f, h)
    if not ok then
      table.insert(t, tostring(msg))
    end
  `);

  assertEquals((env.get("t") as any).get(1), "close-X-boom");
  assertEquals((env.get("t") as any).get(2), "handled-boom");
});

Deno.test("close: xpcall boundary contains __close errors", async () => {
  const env = await evalBlock(`
    t = {}

    local function f()
      do
        local x<close> = setmetatable({}, {
          __close = function(self, err)
            table.insert(t, "close-err-" .. tostring(err))
            error("closefail")
          end
        })
        error("boom")
      end
    end

    local function h(err)
      table.insert(t, "handler-" .. tostring(err))
      return "handled-" .. tostring(err)
    end

    local ok, msg = xpcall(f, h)
    table.insert(t, ok)
    table.insert(t, tostring(msg))
  `);

  const t = env.get("t") as any;

  // close runs during unwind and sees original error
  assertEquals(t.get(1), "close-err-boom");

  // the close error overrides the original for `xpcall`, so the
  // handler sees "closefail"
  assertEquals(t.get(2), "handler-closefail");

  assertEquals(t.get(3), false);
  assertEquals(t.get(4), "handled-closefail");
  assertEquals(t.length, 4);
});

Deno.test("close: nested to-be-closed created inside __close", async () => {
  const env = await evalBlock(`
    t = {}

    local function mk(label)
      return setmetatable({label}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
    end

    do
      local outer<close> = setmetatable({"OUTER"}, {
        __close = function(self, err)
          table.insert(t, "outer-close-start-" .. tostring(err))
          do
            local inner<close> = mk("INNER")
            table.insert(t, "inner-scope-end")
          end
          table.insert(t, "outer-close-end")
        end
      })
      table.insert(t, "body-end")
    end
  `);

  const t = env.get("t") as any;

  // Expected order:
  // - body finishes
  // - outer close begins
  // - inner scope ends, so inner closes (during exec of outer __close)
  // - outer close ends
  assertEquals(t.get(1), "body-end");
  assertEquals(t.get(2), "outer-close-start-nil");
  assertEquals(t.get(3), "inner-scope-end");
  assertEquals(t.get(4), "close-INNER-nil");
  assertEquals(t.get(5), "outer-close-end");
});

Deno.test("close: pcall boundary does not close closers created in pcall args", async () => {
  const env = await evalBlock(`
    t = {}

    local function mk(label)
      return setmetatable({label}, {
        __close = function(self)
          table.insert(t, "close-" .. self[1])
        end
      })
    end

    local function f()
      table.insert(t, "in-f")
      return 1
    end

    local function arg()
      local x<close> = mk("ARG")
      table.insert(t, "arg")
      return 123
    end

    local ok, r = pcall(f, arg())
    table.insert(t, ok)
    table.insert(t, r)
  `);

  const t = env.get("t") as any;

  // if `pcall` boundary is correct, ARG is closed at end of its own
  // scope and `pcall` does not close it
  assertEquals(t.get(1), "arg");
  assertEquals(t.get(2), "close-ARG");
  assertEquals(t.get(3), "in-f");
  assertEquals(t.get(4), true);
  assertEquals(t.get(5), 1);
});

Deno.test("close: nested pcall boundaries", async () => {
  const env = await evalBlock(`
    t = {}

    local function mk(label)
      return setmetatable({label}, {
        __close = function(self, err)
          table.insert(t, "close-" .. self[1] .. "-" .. tostring(err))
        end
      })
    end

    local function inner()
      local b<close> = mk("B")
      error("inner")
    end

    local function outer()
      local a<close> = mk("A")
      local ok, msg = pcall(inner)
      table.insert(t, ok)
      table.insert(t, tostring(msg))
      return 1
    end

    local ok, r = pcall(outer)
    table.insert(t, ok)
    table.insert(t, r)
  `);

  const t = env.get("t") as any;

  // inner closes with "inner" outer continues, then outer closes with
  // nil on success of outer itself
  assertEquals(t.get(1), "close-B-inner");
  assertEquals(t.get(2), false);
  assertEquals(t.get(3), "inner");
  assertEquals(t.get(4), "close-A-nil");
  assertEquals(t.get(5), true);
  assertEquals(t.get(6), 1);
});


================================================
FILE: client/space_lua/const_attribute.test.ts
================================================
import {
  assertEquals,
  assertInstanceOf,
  assertStringIncludes,
} from "@std/assert";
import { parse } from "./parse.ts";
import { evalStatement } from "./eval.ts";
import { LuaEnv, LuaRuntimeError, LuaStackFrame } from "./runtime.ts";
import { luaBuildStandardEnv } from "./stdlib.ts";

async function evalBlock(code: string, env?: LuaEnv): Promise<LuaEnv> {
  const ast = parse(code);
  const G = luaBuildStandardEnv();
  const base = env ?? new LuaEnv(G);
  const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx);
  const r = evalStatement(ast, base, sf, false);

  if (r instanceof Promise) {
    await r;
  }
  return base;
}

async function runAndCatch(code: string, ref = "const_attribute.lua") {
  const ast = parse(code, { ref });
  const G = luaBuildStandardEnv();
  const env = new LuaEnv(G);
  const sf = LuaStackFrame.createWithGlobalEnv(G, ast.ctx);

  try {
    const r = evalStatement(ast, env, sf, false);
    if (r instanceof Promise) {
      await r;
    }
    throw new Error("Expected LuaRuntimeError but evaluation succeeded");
  } catch (e: unknown) {
    return { e, code, ref };
  }
}

Deno.test("const: Unknown attribute (parse-time)", () => {
  let threw = false;
  try {
    parse(`local x<nope> = 1`, { ref: "unknown_attribute.lua" });
  } catch (e: any) {
    threw = true;
    assertStringIncludes(String(e?.message ?? e), "unknown attribute 'nope'");
  }
  if (!threw) {
    throw new Error("Expected parse error for unknown attribute");
  }
});

Deno.test("const: Case-sensitive attribute (parse-time)", () => {
  let threw = false;
  try {
    parse(`local x<Const> = 1`, { ref: "unknown_attribute.lua" });
  } catch (e: any) {
    threw = true;
    assertStringIncludes(String(e?.message ?? e), "unknown attribute 'Const'");
  }
  if (!threw) {
    throw new Error("Expected parse error for unknown attribute");
  }
});
Deno.test("const: Requires initializer", async () => {
  const { e } = await runAndCatch(`
    local x <const>
  `);
  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes((e as LuaRuntimeError).message, "must be initialized");
});

Deno.test("const: Initialization ok, reassignment error", async () => {
  const env = await evalBlock(`
    local x<const> = 1
    y = x
  `);
  assertEquals(env.get("y"), 1);

  const { e } = await runAndCatch(`
    local x<const> = 1
    x = 2
  `);
  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'x'",
  );
});

Deno.test("const: Initialization to nil ok, reassignment error", async () => {
  const { e } = await runAndCatch(`
    local x <const> = nil
    x = 2
  `);
  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'x'",
  );
});

Deno.test("const: Multi-declaration and missing RHS", async () => {
  {
    const env = await evalBlock(`
      local a <const>, b = 1
      y1 = a
      y2 = b
    `);
    assertEquals(env.get("y1"), 1);
    assertEquals(env.get("y2"), null);
  }

  {
    const { e } = await runAndCatch(`
      local a<const>, b <const>; -- missing assignment
    `);
    assertInstanceOf(e, LuaRuntimeError);
    assertStringIncludes((e as LuaRuntimeError).message, "must be initialized");
  }
});

Deno.test("const: Multi-assignment after declaration fails", async () => {
  const { e } = await runAndCatch(`
    local a<const>, b = 1, 2
    a, b = 3, 4
  `);
  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'a'",
  );
});

Deno.test("const: Second position", async () => {
  const { e } = await runAndCatch(`
    local a, b<const> = 1, 2
    b = 3
  `);
  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'b'",
  );
});

Deno.test("const: Initialization from function returns", async () => {
  const env = await evalBlock(`
    local function f()
      return 7
    end

    local a <const>, b <const> = f()

    y1, y2 = a, b
  `);
  assertEquals(env.get("y1"), 7);
  assertEquals(env.get("y2"), null);

  const { e } = await runAndCatch(`
    local function f()
      return 7
    end

    local a<const>, b<const> = f()

    a = 2
  `);
  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'a'",
  );
});

Deno.test("const: Shadowing", async () => {
  {
    const { e } = await runAndCatch(`
      local a<const> = 1

      do
        local a<const> = 2
        a = 3
      end
    `);
    assertInstanceOf(e, LuaRuntimeError);
    assertStringIncludes(
      (e as LuaRuntimeError).message,
      "attempt to assign to const variable 'a'",
    );
  }

  {
    const env = await evalBlock(`
      local a<const> = 1

      do
        local a = 2
        a = 3
      end

      y = a
    `);
    assertEquals(env.get("y"), 1);
  }
});

Deno.test("const: Globals and fields not affected", async () => {
  const env = await evalBlock(`
    local a<const> = 1
    G = a

    local T = {}
    T.x = a

    y1, y2 = G, T.x
  `);
  assertEquals(env.get("y1"), 1);
  assertEquals(env.get("y2"), 1);
});

Deno.test("const: Table binding vs table contents", async () => {
  const env = await evalBlock(`
    local t<const> = { a = 1, 2, 3 }

    t.a  = 10 -- mutate property
    t[2] = 20 -- mutate array part

    y1, y2, y3 = t.a, t[2], #t
  `);
  assertEquals(env.get("y1"), 10);
  assertEquals(env.get("y2"), 20);
  assertEquals(env.get("y3"), 2);
});

Deno.test("const: Rebinding table fails", async () => {
  const { e } = await runAndCatch(
    `
    local t<const> = {
      a = 1
    }

    t = {
      b = 2
    }
  `,
    "const_table_rebind.lua",
  );
  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 't'",
  );
});

Deno.test("const: Inner block reassignment", async () => {
  const { e } = await runAndCatch(`
    local a<const> = 1
    do
      a = 2 -- must fail
    end
  `);
  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'a'",
  );
});

Deno.test("const: Closure reassignment", async () => {
  const { e } = await runAndCatch(`
    local a<const> = 1

    local function f()
      a = 2 -- must error
    end

    f()
  `);
  assertInstanceOf(e, LuaRuntimeError);
  assertStringIncludes(
    (e as LuaRuntimeError).message,
    "attempt to assign to const variable 'a'",
  );
});

Deno.test("const: Closure mutates table", async () => {
  const env = await evalBlock(`
    local t<const> = { x = 1 }

    local function bump()
      t.x = t.x + 1
    end

    bump()

    y = t.x
  `);
  assertEquals(env.get("y"), 2);
});


================================================
FILE: client/space_lua/context_errors.test.ts
================================================
import { parse } from "./parse.ts";
import { evalStatement } from "./eval.ts";
import { LuaEnv, LuaRuntimeError, LuaStackFrame } from "./runtime.ts";
import { luaBuildStandardEnv } from "./stdlib.ts";
import { assertInstanceOf, assertStringIncludes } from "@std/assert";

async function runAndCatch(code: string, ref = "ctx_test.lua") {
  const ast = parse(code, { ref });
  const global = luaBuildStandardEnv();
  const env = new LuaEnv(global);
  const sf = LuaStackFrame.createWithGlobalEnv(global, ast.ctx);
  try {
    const r = evalStatement(ast, env, sf, false);
    if (r instanceof Promise) await r;
    throw new Error("Expected LuaRuntimeError but evaluation succeeded");
  } catch (e: any) {
    return { e, code, ref };
  }
}

function assertCtxErrorContains(
  e: unknown,
  code: string,
  ref: string,
  msgIncludes: string,
) {
  assertInstanceOf(e, LuaRuntimeError);
  const err = e as LuaRuntimeError;
  assertStringIncludes(err.message, msgIncludes);
  const pretty = err.toPrettyString(code);
  assertStringIncludes(pretty, msgIncludes);
  assertStringIncludes(pretty, ref);
  // caret presence sanity check
  assertStringIncludes(pretty, "^");
}

Deno.test("Context error: indexing nil value includes message and ref", async () => {
  const { e, code, ref } = await runAndCatch(
    `
    local t = nil
    local x = t[1]
  `,
    "index_nil.lua",
  );
  assertCtxErrorContains(e, code, ref, "attempt to index a nil value");
});

Deno.test("Context error: indexing with nil key includes message and ref", async () => {
  const { e, code, ref } = await runAndCatch(
    `
    local t = {}
    local k = nil
    local x = t[k]
  `,
    "nil_key.lua",
  );
  assertCtxErrorContains(e, code, ref, "attempt to index with a nil key");
});

Deno.test("Context error: calling nil includes message and ref", async () => {
  const { e, code, ref } = await runAndCatch(
    `
    local f = nil
    f()
  `,
    "call_nil.lua",
  );
  assertCtxErrorContains(e, code, ref, "attempt to call a nil value");
});

Deno.test("Context error: modulo by zero includes message and ref", async () => {
  const { e, code, ref } = await runAndCatch(
    `
    local a = 1
    local b = 0
    local c = a % b
  `,
    "mod_zero.lua",
  );
  assertCtxErrorContains(e, code, ref, "attempt to perform 'n%0'");
});

Deno.test("Context error: type mismatch in comparison includes message and ref", async () => {
  const { e, code, ref } = await runAndCatch(
    `
    local x = "a" < 1
  `,
    "compare_type.lua",
  );
  assertCtxErrorContains(e, code, ref, "attempt to compare string with number");
});

/**
 * Additional generic context-error coverage for lazy dispatch:
 * - __index metamethod wrong type (neither function nor table)
 * - __call metamethod wrong type (non-callable)
 * - length operator on unsupported type
 */
Deno.test("Context error: __index metamethod must be function or table", async () => {
  const { e, code, ref } = await runAndCatch(
    `
    local t = {}
    setmetatable(t, { __index = 5 })
    local x = t.foo
  `,
    "meta_index_wrong.lua",
  );
  assertCtxErrorContains(
    e,
    code,
    ref,
    "attempt to index a number value",
  );
});

Deno.test("Context error: __call metamethod must be a function", async () => {
  const { e, code, ref } = await runAndCatch(
    `
    local t = {}
    setmetatable(t, { __call = 5 })
    t()
  `,
    "meta_call_wrong.lua",
  );
  assertCtxErrorContains(
    e,
    code,
    ref,
    "attempt to call a number value",
  );
});

Deno.test("Context error: length operator wrong type (number)", async () => {
  const { e, code, ref } = await runAndCatch(
    `
    local x = #1
  `,
    "len_number.lua",
  );
  assertCtxErrorContains(
    e,
    code,
    ref,
    "attempt to get length of a number value",
  );
});


================================================
FILE: client/space_lua/eval.test.ts
================================================
import { assertEquals } from "@std/assert/equals";
import {
  LuaEnv,
  LuaNativeJSFunction,
  LuaStackFrame,
  LuaTable,
  luaValueToJS,
  singleResult,
} from "./runtime.ts";
import { parse } from "./parse.ts";
import type { LuaBlock, LuaFunctionCallStatement } from "./ast.ts";
import { evalExpression, evalStatement } from "./eval.ts";
import { luaBuildStandardEnv } from "./stdlib.ts";

const sf = LuaStackFrame.lostFrame;

function evalExpr(s: string, e = new LuaEnv(), sf?: LuaStackFrame): any {
  const node = parse(`e(${s})`).statements[0] as LuaFunctionCallStatement;
  sf = sf || new LuaStackFrame(e, node.ctx);
  return evalExpression(
    node.call.args[0],
    e,
    sf,
  );
}

async function evalBlock(s: string, e = new LuaEnv()): Promise<void> {
  const node = parse(s) as LuaBlock;
  const sf = new LuaStackFrame(e, node.ctx);
  await evalStatement(node, e, sf);
}

Deno.test("Evaluator test", async () => {
  const env = new LuaEnv();
  env.set("test", new LuaNativeJSFunction((n) => n));
  env.set("asyncTest", new LuaNativeJSFunction((n) => Promise.resolve(n)));

  // Basic arithmetic
  assertEquals(evalExpr(`1 + 2 + 3 - 3`), 3);
  assertEquals(evalExpr(`4 // 3`), 1);
  assertEquals(evalExpr(`4 % 3`), 1);

  // Bitwise arithmetic
  assertEquals(evalExpr(`~171`), -172); // signed two's complement
  assertEquals(evalExpr(`5 & 3`), 1); // 101 & 011 = 001
  assertEquals(evalExpr(`5 | 3`), 7); // 101 | 011 = 111
  assertEquals(evalExpr(`5 ~ 3`), 6); // 101 ^ 011 = 110
  assertEquals(evalExpr(`5 << 3`), 40); // 101 << 3 = 101000
  assertEquals(evalExpr(`5 >> 2`), 1); // 101 >> 2 = 1

  // Strings
  assertEquals(evalExpr(`"a" .. "b"`), "ab");

  // Logic
  assertEquals(evalExpr(`true and false`), false);
  assertEquals(evalExpr(`true or false`), true);
  assertEquals(evalExpr(`not true`), false);
  // Test eager evaluation of left operand
  assertEquals(
    evalExpr(
      `true or (function() error("this should not be evaluated") end)()`,
    ),
    true,
  );
  // Tables
  const tbl = await evalExpr(`{3, 1, 2}`);
  assertEquals(tbl.get(1), 3);
  assertEquals(tbl.get(2), 1);
  assertEquals(tbl.get(3), 2);
  assertEquals(luaValueToJS(tbl, sf), [3, 1, 2]);

  assertEquals(
    luaValueToJS(await evalExpr(`{name=test("Zef"), age=100}`, env), sf),
    {
      name: "Zef",
      age: 100,
    },
  );

  assertEquals(
    luaValueToJS(await evalExpr(`{name="Zef", age=asyncTest(100)}`, env), sf),
    {
      name: "Zef",
      age: 100,
    },
  );

  const result = await evalExpr(`{[3+2]=1, ["a".."b"]=2}`);
  assertEquals(result.get(5), 1);
  assertEquals(result.get("ab"), 2);

  assertEquals(await evalExpr(`#{}`), 0);
  assertEquals(await evalExpr(`#{1, 2, 3}`), 3);

  // Unary operators
  assertEquals(await evalExpr(`-asyncTest(3)`, env), -3);

  // Function calls
  assertEquals(singleResult(evalExpr(`test(3)`, env)), 3);
  assertEquals(singleResult(await evalExpr(`asyncTest(3) + 1`, env)), 4);

  // Function expressions and table access
  assertEquals(
    await evalExpr(`(function() return {name="John"} end)().name`),
    "John",
  );

  // Function definitions
  const fn = evalExpr(`function(a, b) return a + b end`);
  assertEquals(fn.body.parameters, ["a", "b"]);
});

Deno.test("Parser rejects unary plus - parenthesized", () => {
  for (
    const src of [
      "return +(1)",
      "return +((1))",
      "return +(1 + 2)",
      "return +({})",
    ]
  ) {
    let err: any = null;
    try {
      parse(src);
    } catch (e: any) {
      err = e;
    }
    assertEquals(err !== null, true, `expected parse error for: ${src}`);
    assertEquals(
      String(err?.message ?? err).includes("unexpected symbol near '+'"),
      true,
      `expected unary-plus message for: ${src}`,
    );
  }
});

Deno.test("Parser rejects unary plus - variables and calls", () => {
  for (
    const src of [
      "return +(a)",
      "return +(a.b)",
      "return +(a[1])",
      "return +f()",
      "return +(f())",
    ]
  ) {
    let err: any = null;
    try {
      parse(src);
    } catch (e: any) {
      err = e;
    }
    assertEquals(err !== null, true, `expected parse error for: ${src}`);
    assertEquals(
      String(err?.message ?? err).includes("unexpected symbol near '+'"),
      true,
      `expected unary-plus message for: ${src}`,
    );
  }
});

Deno.test("Parser rejects unary plus - whitespace/newlines", () => {
  for (
    const src of [
      "return + 1",
      "return +\n1",
      "return +\t(1)",
      "return \n+\n(1)",
    ]
  ) {
    let err: any = null;
    try {
      parse(src);
    } catch (e: any) {
      err = e;
    }
    assertEquals(err !== null, true, `expected parse error for: ${src}`);
    assertEquals(
      String(err?.message ?? err).includes("unexpected symbol near '+'"),
      true,
      `expected unary-plus message for: ${src}`,
    );
  }
});

Deno.test("Comparison metamethods: __lt", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());

  await evalBlock(
    `
      local mt = {
        __lt = function(a, b) return a.v < b.v end
      }
      local a = setmetatable({ v = 1 }, mt)
      local b = setmetatable({ v = 2 }, mt)
      res = (a < b)
    `,
    env,
  );

  assertEquals(env.get("res"), true);
});

Deno.test("Comparison metamethods: __le does not fallback to __lt", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());

  let threw = false;
  try {
    await evalBlock(
      `
      local mt = {
        __lt = function(a, b) return a.v < b.v end
      }
      local a = setmetatable({ v = 2 }, mt)
      local b = setmetatable({ v = 2 }, mt)
      res = (a <= b)
    `,
      env,
    );
  } catch (e: any) {
    threw = true;
    const msg = String(e?.message ?? e);
    if (!msg.includes("attempt to compare")) {
      throw e;
    }
  }

  assertEquals(threw, true);
});

Deno.test("Equality metamethod: __eq requires same function", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());

  await evalBlock(
    `
      local f1 = function(a, b) return true end
      local f2 = function(a, b) return true end
      local mt1 = { __eq = f1 }
      local mt2 = { __eq = f2 } -- different function object
      local a = setmetatable({}, mt1)
      local b = setmetatable({}, mt2)
      res = (a == b)
    `,
    env,
  );

  // Lua calls __eq even when the functions differ
  assertEquals(env.get("res"), true);
});

Deno.test("Statement evaluation", async () => {
  const env = new LuaEnv();
  env.set("test", new LuaNativeJSFunction((n) => n));
  env.set("asyncTest", new LuaNativeJSFunction((n) => Promise.resolve(n)));

  assertEquals(undefined, await evalBlock(`a = 3`, env));
  assertEquals(env.get("a"), 3);
  assertEquals(undefined, await evalBlock(`b = test(3)`, env));
  assertEquals(env.get("b"), 3);

  await evalBlock(`c = asyncTest(3)`, env);
  assertEquals(env.get("c"), 3);

  // Multiple assignments
  const env2 = new LuaEnv();
  assertEquals(undefined, await evalBlock(`a, b = 1, 2`, env2));
  assertEquals(env2.get("a"), 1);
  assertEquals(env2.get("b"), 2);

  // Other lvalues
  const env3 = new LuaEnv();
  await evalBlock(`tbl = {1, 2, 3}`, env3);
  await evalBlock(`tbl[1] = 3`, env3);
  assertEquals(luaValueToJS(env3.get("tbl"), sf), [3, 2, 3]);
  await evalBlock("tbl.name = 'Zef'", env3);
  assertEquals(env3.get("tbl").get("name"), "Zef");
  await evalBlock(`tbl[2] = {age=10}`, env3);
  await evalBlock(`tbl[2].age = 20`, env3);
  assertEquals(env3.get("tbl").get(2).get("age"), 20);

  // Blocks and scopes
  const env4 = new LuaEnv();
  env4.set("print", new LuaNativeJSFunction(console.log));
  await evalBlock(
    `
    a = 1
    do
        -- sets global a to 3
        a = 3
        print("The number is: " .. a)
    end`,
    env4,
  );
  assertEquals(env4.get("a"), 3);

  const env5 = new LuaEnv();
  env5.set("print", new LuaNativeJSFunction(console.log));

  await evalBlock(
    `
    a = 1
    if a > 0 then
        a = 3
    else
        a = 0
    end`,
    env5,
  );
  assertEquals(env5.get("a"), 3);

  await evalBlock(
    `
    if a < 0 then
        a = -1
    elseif a > 0 then
        a = 1
    else
        a = 0
    end`,
    env5,
  );
  assertEquals(env5.get("a"), 1);

  await evalBlock(
    `
        var = 1
        do
            local var
            var = 2
        end`,
    env5,
  );
  assertEquals(env5.get("var"), 1);

  // While loop
  const env6 = new LuaEnv();
  await evalBlock(
    `
        c = 0
        while true do
            c = c + 1
            if c == 3 then
                break
            end
        end
    `,
    env6,
  );
  assertEquals(env6.get("c"), 3);

  // Repeat loop
  const env7 = new LuaEnv();
  await evalBlock(
    `
        c = 0
        repeat
            c = c + 1
            if c == 3 then
                break
            end
        until false
    `,
    env7,
  );
  assertEquals(env7.get("c"), 3);

  // Function definition and calling
  const env8 = new LuaEnv();
  env8.set("print", new LuaNativeJSFunction(console.log));
  await evalBlock(
    `
        function test(a)
            return a + 1
        end
        print("3 + 1 = " .. test(3))
    `,
    env8,
  );

  // Local fucntion definition
  const env9 = new LuaEnv();
  env9.set("print", new LuaNativeJSFunction(console.log));
  await evalBlock(
    `
        local function test(a)
            return a + 1
        end
        print("3 + 1 = " .. test(3))
    `,
    env9,
  );

  // For loop over range
  const env10 = new LuaEnv();
  await evalBlock(
    `
        c = 0
        for i = 1, 3 do
            c = c + i
        end
    `,
    env10,
  );
  assertEquals(env10.get("c"), 6);

  // For loop over iterator
  const env11 = new LuaEnv(luaBuildStandardEnv());
  await evalBlock(
    `
      function fruits()
        local list = { "apple", "banana", "cherry" }
        -- Track index internally
        local index = 0

        return function()
            index = index + 1
            if list[index] then
                return list[index]
            end
        end
      end

      for fruit in fruits() do
        print("Fruit: " .. fruit)
      end
    `,
    env11,
  );

  await evalBlock(
    `
    for _, f in ipairs({ "apple", "banana", "cherry" }) do
      print("Fruit: " .. f)
    end`,
    luaBuildStandardEnv(),
  );

  // Passing a Lua function as callback to a JS function
  const env12 = new LuaEnv();
  env12.set(
    "runMe",
    new LuaNativeJSFunction((fn) => {
      return fn("Lua");
    }),
  );
  assertEquals(
    "Hello from Lua",
    await evalExpr(
      `runMe(function(name) return "Hello from " .. name end)`,
      env12,
    ),
  );
});

Deno.test("Thread local _CTX", async () => {
  const env = new LuaEnv();
  const threadLocal = new LuaEnv();
  threadLocal.setLocal("threadValue", "test123");

  const sf = new LuaStackFrame(threadLocal, null);

  await evalBlock(
    `
    function test()
      return _CTX.threadValue
    end
  `,
    env,
  );

  const result = await evalExpr("test()", env, sf);
  assertEquals(singleResult(result), "test123");
});

Deno.test("Thread local _CTX - advanced cases", async () => {
  // Create environment with standard library
  const env = new LuaEnv(luaBuildStandardEnv());
  const threadLocal = new LuaEnv();

  env.setLocal("globalEnv", "GLOBAL");

  // Set up some thread local values
  threadLocal.setLocal("user", "alice");
  threadLocal.setLocal("permissions", new LuaTable());
  threadLocal.get("permissions").set("admin", true);
  threadLocal.setLocal("data", {
    id: 123,
    settings: { theme: "dark" },
  });

  const sf = new LuaStackFrame(threadLocal, null);

  // Test 1: Nested function access
  await evalBlock(
    `
    function outer()
      local function inner()
        return _CTX.user
      end
      return inner()
    end
  `,
    env,
  );
  assertEquals(await evalExpr("outer()", env, sf), "alice");

  // Test 2: Table access and modification
  await evalBlock(
    `
    function checkAdmin()
      return _CTX.permissions.admin
    end

    function revokeAdmin()
      _CTX.permissions.admin = false
      return _CTX.permissions.admin
    end
  `,
    env,
  );
  assertEquals(await evalExpr("checkAdmin()", env, sf), true);
  assertEquals(await evalExpr("revokeAdmin()", env, sf), false);
  assertEquals(threadLocal.get("permissions").get("admin"), false);

  // Test 3: Complex data structures
  await evalBlock(
    `
    function getNestedData()
      return _CTX.data.settings.theme
    end
    
    function updateTheme(newTheme)
      _CTX.data.settings.theme = newTheme
      return _CTX.data.settings.theme
    end
    `,
    env,
  );
  assertEquals(await evalExpr("getNestedData()", env, sf), "dark");
  assertEquals(await evalExpr("updateTheme('light')", env, sf), "light");

  // Test 4: Multiple thread locals
  const threadLocal2 = new LuaEnv();
  threadLocal2.setLocal("user", "bob");
  const sf2 = new LuaStackFrame(threadLocal2, null);

  await evalBlock(
    `
    function getUser()
      return _CTX.user
    end
  `,
    env,
  );

  // Same function, different thread contexts
  assertEquals(await evalExpr("getUser()", env, sf), "alice");
  assertEquals(await evalExpr("getUser()", env, sf2), "bob");

  // Test 5: Async operations with _CTX
  env.set(
    "asyncOperation",
    new LuaNativeJSFunction(async () => {
      await new Promise((resolve) => setTimeout(resolve, 10));
      return "done";
    }),
  );

  await evalBlock(
    `
    function asyncTest()
      _CTX.status = "starting"
      local result = asyncOperation()
      _CTX.status = "completed"
      return _CTX.status
    end
  `,
    env,
  );

  assertEquals(await evalExpr("asyncTest()", env, sf), "completed");
  assertEquals(threadLocal.get("status"), "completed");

  // Test 6: Error handling with _CTX
  await evalBlock(
    `
    function errorTest()
      _CTX.error = nil
      local status, err = pcall(function()
        error("test error")
      end)
      _CTX.error = "caught"
      return _CTX.error
    end
  `,
    env,
  );

  assertEquals(await evalExpr("errorTest()", env, sf), "caught");
  assertEquals(threadLocal.get("error"), "caught");

  // Test string interpolation
  sf.threadLocal.setLocal("_GLOBAL", env);
  assertEquals(
    await evalExpr(
      "spacelua.interpolate('Hello, ${globalEnv} and ${loc}!', {loc='local'})",
      env,
      sf,
    ),
    "Hello, GLOBAL and local!",
  );

  // Some more complex string interpolation with more complex lua expressions, with nested {}
  assertEquals(
    await evalExpr(
      `spacelua.interpolate('Some JSON \${js.stringify(js.tojs({name="Pete"}))}!')`,
      env,
      sf,
    ),
    `Some JSON {"name":"Pete"}!`,
  );
});

Deno.test("Length: rawlen ignores __len", async () => {
  const env = new LuaEnv(luaBuildStandardEnv());

  await evalBlock(
    `
      local t = {1,2,3}
      setmetatable(t, { __len = function() return 99 end })
      a = #t
      b = rawlen(t)
    `,
    env,
  );

  assertEquals(env.get("a"), 99);
  assertEquals(env.get("b"), 3);
});


================================================
FILE: client/space_lua/eval.ts
================================================
import type {
  ASTCtx,
  LuaBlock,
  LuaExpression,
  LuaLValue,
  LuaStatement,
  NumericType,
} from "./ast.ts";
import { LuaAttribute } from "./ast.ts";
import { evalPromiseValues } from "./util.ts";
import {
  getMetatable,
  type ILuaFunction,
  type ILuaGettable,
  isILuaFunction,
  jsToLuaValue,
  luaCall,
  luaCloseFromMark,
  luaEnsureCloseStack,
  LuaEnv,
  luaEquals,
  LuaFunction,
  luaGet,
  luaIndexValue,
  type LuaLValueContainer,
  luaMarkToBeClosed,
  LuaMultiRes,
  LuaRuntimeError,
  luaSet,
  type LuaStackFrame,
  LuaTable,
  luaTruthy,
  type LuaType,
  luaTypeName,
  luaTypeOf,
  type LuaValue,
  luaValueToJS,
  singleResult,
} from "./runtime.ts";
import {
  ArrayQueryCollection,
  type LuaCollectionQuery,
} from "./query_collection.ts";
import {
  coerceNumericPair,
  coerceToNumber,
  inferNumericType,
  isNegativeZero,
  isTaggedFloat,
  luaStringCoercionError,
  makeLuaFloat,
  makeLuaZero,
  normalizeArithmeticResult,
  toInteger,
  untagNumber,
} from "./numeric.ts";
import { isPromise, rpAll, rpThen } from "./rp.ts";
import {
  asAssignment,
  asBinary,
  asBlock,
  asFor,
  asForIn,
  asFunctionCall,
  asFunctionCallStmt,
  asFunctionDef,
  asFunctionStmt,
  asGoto,
  asIf,
  asLabel,
  asLocal,
  asLocalFunction,
  asLValuePropertyAccess,
  asLValueTableAccess,
  asLValueVariable,
  asParenthesized,
  asPropertyAccess,
  asQueryExpr,
  asRepeat,
  asReturn,
  asTableAccess,
  asTableConstructor,
  asUnary,
  asVariable,
  asWhile,
} from "./ast_narrow.ts";
import { getBlockGotoMeta } from "./labels.ts";

const astNumberKindCache = new WeakMap<LuaExpression, NumericType>();

function astNumberKind(e: LuaExpression | undefined): NumericType | undefined {
  if (!e) return undefined;

  const cached = astNumberKindCache.get(e);
  if (cached) return cached;

  let unwrapped = e;
  while (unwrapped.type === "Parenthesized") {
    unwrapped = unwrapped.expression;
  }

  let result: NumericType | undefined;

  if (unwrapped.type === "Unary" && unwrapped.operator === "-") {
    result = astNumberKind(unwrapped.argument);
  } else if (unwrapped.type === "Number") {
    result = unwrapped.numericType === "int" ? "int" : "float";
  } else if (unwrapped.type === "Binary") {
    const op = unwrapped.operator;
    const numericOp = op === "+" || op === "-" || op === "*" || op === "/" ||
      op === "//" || op === "%" || op === "^";

    if (numericOp) {
      const lk = astNumberKind(unwrapped.left);
      const rk = astNumberKind(unwrapped.right);

      if (lk === "float" || rk === "float") {
        result = "float";
      } else if (lk === "int" && rk === "int") {
        result = "int";
      } else {
        return undefined;
      }
    } else {
      return undefined;
    }
  } else {
    return undefined;
  }

  if (result !== undefined) {
    astNumberKindCache.set(e, result);
  }

  return result;
}

type GotoSignal = { ctrl: "goto"; target: string };
type ReturnSignal = { ctrl: "return"; values: LuaValue[] };
type BreakSignal = { ctrl: "break" };
type ControlSignal = GotoSignal | ReturnSignal | BreakSignal;

function isGotoSignal(v: any): v is GotoSignal {
  return !!v && typeof v === "object" && v.ctrl === "goto";
}

function isBreakSignal(v: any): v is BreakSignal {
  return !!v && typeof v === "object" && v.ctrl === "break";
}

function consumeGotoInBlock(
  res: any,
  labels: Map<string, number>,
): number | any | undefined {
  if (res === undefined) {
    return undefined;
  }
  if (isGotoSignal(res)) {
    const labelIdx = labels.get(res.target);
    if (labelIdx !== undefined) {
      return labelIdx + 1; // next statement
    }
  }
  return res;
}

function blockMetaOrThrow(
  block: LuaBlock,
  sf: LuaStackFrame,
): ReturnType<typeof getBlockGotoMeta> {
  try {
    return getBlockGotoMeta(block);
  } catch (e: any) {
    if (e && typeof e === "object" && "astCtx" in e) {
      throw new LuaRuntimeError(e.message, sf.withCtx((e as any).astCtx));
    }
    throw e;
  }
}

// Queryable guard to avoid `(collection as any).query` usage
type Queryable = {
  query: (
    q: LuaCollectionQuery,
    env: LuaEnv,
    sf: LuaStackFrame,
  ) => Promise<any>;
};
function isQueryable(x: unknown): x is Queryable {
  return !!x && typeof (x as any).query === "function";
}

function arithVerbFromOperator(op: string): string | null {
  switch (op) {
    case "+":
      return "add";
    case "-":
      return "sub";
    case "*":
      return "mul";
    case "/":
      return "div";
    case "//":
      return "idiv";
    case "%":
      return "mod";
    case "^":
      return "pow";
    default:
      return null;
  }
}

function isNumericBinaryOp(op: string): boolean {
  return (
    op === "+" ||
    op === "-" ||
    op === "*" ||
    op === "/" ||
    op === "//" ||
    op === "%" ||
    op === "^"
  );
}

function arithCoercionErrorOrThrow(
  op: string,
  left: any,
  right: any,
  ctx: ASTCtx,
  sf: LuaStackFrame,
  e: any,
): never {
  if (e === luaStringCoercionError) {
    const mapped = maybeLuaArithStringError(op, left, right, ctx, sf);
    if (mapped) {
      throw mapped;
    }
    throw new LuaRuntimeError(
      "attempt to perform arithmetic on a string value",
      sf.withCtx(ctx),
    );
  }

  const mapped = maybeLuaArithStringError(op, left, right, ctx, sf);
  if (mapped) {
    throw mapped;
  }

  throw e;
}

function luaOp(
  op: string,
  left: any,
  right: any,
  leftType: NumericType | undefined,
  rightType: NumericType | undefined,
  ctx: ASTCtx,
  sf: LuaStackFrame,
): any {
  switch (op) {
    case "+":
    case "-":
    case "*":
    case "/":
    case "^": {
      const ar = numericArith[op as NumericArithOp];
      try {
        const { left: l, right: r, resultType } = coerceNumericPair(
          left,
          right,
          leftType,
          rightType,
          op,
        );

        let result = ar.f(l, r);

        if (
          ar.special === "sub" &&
          result === 0 &&
          isNegativeZero(result) &&
          resultType === "float"
        ) {
          const rhsIsIntZero = r === 0 && rightType === "int";
          if (rhsIsIntZero) {
            result = 0;
          }
        }

        const normalized = normalizeArithmeticResult(result, resultType);

        // Operators `/` and `^` produce float, wrap only if needed.
        if (op === "/" || op === "^") {
          if (normalized === 0) {
            return makeLuaZero(normalized, "float");
          }
          if (!Number.isFinite(normalized)) {
            return normalized;
          }
          if (!Number.isInteger(normalized)) {
            return normalized;
          }
          return makeLuaFloat(normalized);
        }

        if (normalized === 0) {
          return makeLuaZero(normalized, resultType);
        }
        if (resultType === "float" && Number.isInteger(normalized)) {
          return makeLuaFloat(normalized);
        }
        return normalized;
      } catch (e: any) {
        const meta = evalMetamethod(left, right, ar.metaMethod, ctx, sf);
        if (meta !== undefined) {
          return meta;
        }
        return arithCoercionErrorOrThrow(op, left, right, ctx, sf, e);
      }
    }
    case "..": {
      try {
        const coerce = (v: any): string => {
          if (v === null || v === undefined) {
            throw new LuaRuntimeError(
              "attempt to concatenate a nil value",
              sf.withCtx(ctx),
            );
          }
          if (typeof v === "string") {
            return v as string;
          }
          if (typeof v === "number") {
            return String(v);
          }
          if (isTaggedFloat(v)) {
            return String(v.value);
          }
          const t = luaTypeName(v);
          throw new LuaRuntimeError(
            `attempt to concatenate a ${t} value`,
            sf.withCtx(ctx),
          );
        };
        return coerce(left) + coerce(right);
      } catch (e: any) {
        const meta = evalMetamethod(left, right, "__concat", ctx, sf);
        if (meta !== undefined) {
          return meta;
        }
        throw e;
      }
    }
    case "==": {
      if (luaEquals(left, right)) return true;
      return luaEqWithMetamethod(left, right, ctx, sf);
    }
    case "~=":
    case "!=": {
      if (luaEquals(left, right)) {
        return false;
      }
      return !luaEqWithMetamethod(left, right, ctx, sf);
    }
    case "<": {
      return luaRelWithMetamethod("<", left, right, ctx, sf);
    }
    case "<=": {
      return luaRelWithMetamethod("<=", left, right, ctx, sf);
    }
    // Lua: `a>b` is `b<a`, `a>=b` is `b<=a`
    case ">": {
      return luaRelWithMetamethod("<", right, left, ctx, sf);
    }
    case ">=": {
      return luaRelWithMetamethod("<=", right, left, ctx, sf);
    }
  }

  // Remaining operators: //, %, bitwise
  const handler = operatorsMetaMethods[op];
  if (!handler) {
    throw new LuaRuntimeError(`Unknown operator ${op}`, sf.withCtx(ctx));
  }

  try {
    return handler.nativeImplementation(
      left,
      right,
      leftType,
      rightType,
      ctx,
      sf,
    );
  } catch (e: any) {
    if (handler.metaMethod) {
      const meta = evalMetamethod(left, right, handler.metaMethod, ctx, sf);
      if (meta !== undefined) {
        return meta;
      }
    }
    return arithCoercionErrorOrThrow(op, left, right, ctx, sf, e);
  }
}

type NumericArithOp = "+" | "-" | "*" | "/" | "^";

const numericArith: Record<NumericArithOp, {
  metaMethod: "__add" | "__sub" | "__mul" | "__div" | "__pow";
  f: (l: number, r: number) => number;
  special?: "sub";
}> = {
  "+": { metaMethod: "__add", f: (l, r) => l + r },
  "-": { metaMethod: "__sub", f: (l, r) => l - r, special: "sub" },
  "*": { metaMethod: "__mul", f: (l, r) => l * r },
  "/": { metaMethod: "__div", f: (l, r) => l / r },
  "^": { metaMethod: "__pow", f: (l, r) => l ** r },
};

function maybeLuaArithStringError(
  op: string,
  a: any,
  b: any,
  ctx: ASTCtx,
  sf: LuaStackFrame,
): LuaRuntimeError | null {
  const verb = arithVerbFromOperator(op);
  if (!verb) {
    return null;
  }

  const ta = luaTypeName(a);
  const tb = luaTypeName(b);

  if (ta === "string" || tb === "string") {
    return new LuaRuntimeError(
      `attempt to ${verb} a '${ta}' with a '${tb}'`,
      sf.withCtx(ctx),
    );
  }

  return null;
}

function luaFloorDiv(
  a: unknown,
  b: unknown,
  leftType: NumericType | undefined,
  rightType: NumericType | undefined,
  ctx: ASTCtx,
  sf: LuaStackFrame,
): any {
  const { left, right, resultType } = coerceNumericPair(
    a,
    b,
    leftType,
    rightType,
    "//",
  );

  if (resultType === "int" && right === 0) {
    throw new LuaRuntimeError(
      `attempt to divide by zero`,
      sf.withCtx(ctx),
    );
  }

  const result = Math.floor(left / right);
  const normalized = normalizeArithmeticResult(result, resultType);
  if (normalized === 0) {
    return makeLuaZero(normalized, resultType);
  }
  if (resultType === "float" && Number.isInteger(normalized)) {
    return makeLuaFloat(normalized);
  }
  return normalized;
}

function luaMod(
  a: unknown,
  b: unknown,
  leftType: NumericType | undefined,
  rightType: NumericType | undefined,
  ctx: ASTCtx,
  sf: LuaStackFrame,
): any {
  const { left, right, resultType } = coerceNumericPair(
    a,
    b,
    leftType,
    rightType,
    "%",
  );

  if (resultType === "int" && right === 0) {
    throw new LuaRuntimeError(
      `attempt to perform 'n%0'`,
      sf.withCtx(ctx),
    );
  }

  const q = Math.floor(left / right);
  const result = left - q * right;

  // Preserve -0.0 from left operand in float mode
  if (result === 0 && resultType === "float" && isNegativeZero(left)) {
    return makeLuaZero(-0, "float");
  }

  const normalized = normalizeArithmeticResult(result, resultType);
  if (normalized === 0) {
    return makeLuaZero(normalized, resultType);
  }
  if (resultType === "float" && Number.isInteger(normalized)) {
    return makeLuaFloat(normalized);
  }
  return normalized;
}

function luaUnaryMinus(
  v: number,
  numType: NumericType | undefined,
): number {
  const vType = numType ?? inferNumericType(v);

  if (v === 0 && vType === "int") {
    return 0;
  }

  if (v === 0 && vType === "float") {
    return isNegativeZero(v) ? 0 : -0;
  }

  return -v;
}

const operatorsMetaMethods: Record<string, {
  metaMethod?: string;
  nativeImplementation: (
    a: LuaValue,
    b: LuaValue,
    leftType: NumericType | undefined,
    rightType: NumericType | undefined,
    ctx: ASTCtx,
    sf: LuaStackFrame,
  ) => LuaValue;
}> = {
  "//": {
    metaMethod: "__idiv",
    nativeImplementation: (a, b, lt, rt, ctx, sf) =>
      luaFloorDiv(a, b, lt, rt, ctx, sf),
  },
  "%": {
    metaMethod: "__mod",
    nativeImplementation: (a, b, lt, rt, ctx, sf) =>
      luaMod(a, b, lt, rt, ctx, sf),
  },
  "&": {
    metaMethod: "__band",
    nativeImplementation: (a, b, _lt, _rt, ctx, sf) => {
      const aInt = toInteger(a);
      const bInt = toInteger(b);
      if (aInt === null) throw createBitwiseError(a, ctx, sf);
      if (bInt === null) throw createBitwiseError(b, ctx, sf);
      return aInt & bInt;
    },
  },
  "|": {
    metaMethod: "__bor",
    nativeImplementation: (a, b, _lt, _rt, ctx, sf) => {
      const aInt = toInteger(a);
      const bInt = toInteger(b);
      if (aInt === null) throw createBitwiseError(a, ctx, sf);
      if (bInt === null) throw createBitwiseError(b, ctx, sf);
      return aInt | bInt;
    },
  },
  "~": {
    metaMethod: "__bxor",
    nativeImplementation: (a, b, _lt, _rt, ctx, sf) => {
      const aInt = toInteger(a);
      const bInt = toInteger(b);
      if (aInt === null) throw createBitwiseError(a, ctx, sf);
      if (bInt === null) throw createBitwiseError(b, ctx, sf);
      return aInt ^ bInt;
    },
  },
  "<<": {
    metaMethod: "__shl",
    nativeImplementation: (a, b, _lt, _rt, ctx, sf) => {
      const aInt = toInteger(a);
      const bInt = toInteger(b);
      if (aInt === null) throw createBitwiseError(a, ctx, sf);
      if (bInt === null) throw createBitwiseError(b, ctx, sf);
      return aInt << bInt;
    },
  },
  ">>": {
    metaMethod: "__shr",
    nativeImplementation: (a, b, _lt, _rt, ctx, sf) => {
      const aInt = toInteger(a);
      const bInt = toInteger(b);
      if (aInt === null) throw createBitwiseError(a, ctx, sf);
      if (bInt === null) throw createBitwiseError(b, ctx, sf);
      return aInt >> bInt;
    },
  },
};

export function evalExpression(
  e: LuaExpression,
  env: LuaEnv,
  sf: LuaStackFrame,
): Promise<LuaValue> | LuaValue {
  try {
    switch (e.type) {
      case "String": {
        return e.value;
      }
      case "Number": {
        if (e.value === 0) {
          return makeLuaZero(e.value, e.numericType);
        }
        if (e.numericType === "float" && Number.isInteger(e.value)) {
          return makeLuaFloat(e.value);
        }
        return e.value;
      }
      case "Boolean": {
        return e.value;
      }
      case "Nil": {
        return null;
      }
      case "Binary": {
        const b = asBinary(e);
        if (b.operator === "or") {
          return evalLogical("or", b.left, b.right, env, sf);
        }
        if (b.operator === "and") {
          return evalLogical("and", b.left, b.right, env, sf);
        }
        return evalBinaryWithLR(
          b.operator,
          b.left,
          b.right,
          b.ctx,
          env,
          sf,
        );
      }
      case "Unary": {
        const u = asUnary(e);

        // Fast path: negation of numeric literal
        if (u.operator === "-" && u.argument.type === "Number") {
          const num = u.argument;
          if (num.value === 0) {
            const z = num.numericType === "int" ? 0 : -0;
            return makeLuaZero(z, num.numericType);
          }
          if (num.numericType === "float" && Number.isInteger(num.value)) {
            return makeLuaFloat(-num.value);
          }
          return -num.value;
        }

        if (u.operator === "-") {
          const tv = evalExprWithNumericType(u.argument, env, sf, true);

          const applyTyped = (typed: TypedValue) => {
            const arg = singleResult(typed.value);

            return unaryWithMeta(
              arg,
              "__unm",
              u.ctx,
              sf,
              () => {
                // Numeric-string coercion for unary minus
                if (typeof arg === "string") {
                  const n = coerceToNumber(arg);
                  if (n === null) {
                    throw new LuaRuntimeError(
                      "attempt to unm a 'string' with a 'string'",
                      sf.withCtx(u.ctx),
                    );
                  }
                  if (n === 0) {
                    return 0;
                  }
                  return -n;
                }

                const plain = untagNumber(arg);
                if (typeof plain !== "number") {
                  throw new LuaRuntimeError(
                    "attempt to perform arithmetic on a table value",
                    sf.withCtx(u.ctx),
                  );
                }

                const argType = isTaggedFloat(arg)
                  ? "float"
                  : astNumberKind(u.argument);

                const out = luaUnaryMinus(plain, argType);

                // If the operand is a float-tagged boxed number, unary
                // minus must keep the result float-typed.
                if (isTaggedFloat(arg)) {
                  if (out === 0) {
                    return makeLuaZero(out, "float");
                  }
                  return makeLuaFloat(out);
                }

                // Preserve numeric kind for zero results
                if (out === 0) {
                  const outType = argType ?? inferNumericType(plain);
                  return makeLuaZero(out, outType);
                }

                return out;
              },
            );
          };

          return rpThen(tv as any, applyTyped);
        }

        const value = evalExpression(u.argument, env, sf);

        const applyUnary = (value: LuaValue) => {
          switch (u.operator) {
            case "not": {
              return !luaTruthy(value);
            }
            case "~": {
              const arg = singleResult(value);
              return unaryWithMeta(
                arg,
                "__bnot",
                u.ctx,
                sf,
                () => {
                  const intVal = toInteger(arg);
                  if (intVal === null) {
                    if (typeof arg === "string") {
                      throw new LuaRuntimeError(
                        `attempt to perform bitwise operation on a string value (constant '${arg}')`,
                        sf.withCtx(u.ctx),
                      );
                    }
                    const t = luaTypeName(arg);
                    if (t === "number") {
                      throw new LuaRuntimeError(
                        `number has no integer representation`,
                        sf.withCtx(u.ctx),
                      );
                    }
                    throw new LuaRuntimeError(
                      `attempt to perform bitwise operation on a ${t} value`,
                      sf.withCtx(u.ctx),
                    );
                  }
                  return ~intVal;
                },
              );
            }
            case "#": {
              return luaLengthOp(singleResult(value), u.ctx, sf);
            }
            default: {
              throw new LuaRuntimeError(
                `Unknown unary operator ${u.operator}`,
                sf.withCtx(u.ctx),
              );
            }
          }
        };

        return rpThen(value, applyUnary);
      }
      case "Variable":
      case "FunctionCall":
      case "TableAccess":
      case "PropertyAccess": {
        return evalPrefixExpression(e, env, sf);
      }
      case "TableConstructor": {
        const tc = asTableConstructor(e);
        return Promise.resolve().then(async () => {
          const table = new LuaTable();
          // Expression fields assign consecutive integer keys starting
          // at 1 and advance even when the value is `nil`.
          let nextArrayIndex = 1;
          for (const field of tc.fields) {
            switch (field.type) {
              case "PropField": {
                const value = await evalExpression(field.value, env, sf);
                table.set(field.key, singleResult(value), sf);
                break;
              }
              case "DynamicField": {
                const key = await evalExpression(field.key, env, sf);
                const value = await evalExpression(field.value, env, sf);
                table.set(singleResult(key), singleResult(value), sf);
                break;
              }
              case "ExpressionField": {
                const value = await evalExpression(field.value, env, sf);

                if (value instanceof LuaMultiRes) {
                  const flat = value.flatten();
                  for (let i = 0; i < flat.values.length; i++) {
                    table.rawSetArrayIndex(nextArrayIndex, flat.values[i]);
                    nextArrayIndex++;
                  }
                } else {
                  table.rawSetArrayIndex(nextArrayIndex, singleResult(value));
                  nextArrayIndex++;
                }
                break;
              }
            }
          }
          return table;
        });
      }
      case "FunctionDefinition": {
        const fd = asFunctionDef(e);
        return new LuaFunction(fd.body, env);
      }
      case "Query": {
        const q = asQueryExpr(e);
        const findFromClause = q.clauses.find((c) => c.type === "From");
        if (!findFromClause) {
          throw new LuaRuntimeError(
            "No from clause found",
            sf.withCtx(q.ctx),
          );
        }
        const objectVariable = findFromClause.name;
        const objectExpression = findFromClause.expression;
        return Promise.resolve(evalExpression(objectExpression, env, sf)).then(
          async (collection: LuaValue) => {
            if (!collection) {
              throw new LuaRuntimeError(
                "Collection is nil",
                sf.withCtx(q.ctx),
              );
            }
            if (collection instanceof LuaTable && collection.empty()) {
              // Make sure we're converting an empty result to an array to "query"
              collection = [];
            } else {
              collection = luaValueToJS(collection, sf);
            }
            // Check if collection is a queryable collection
            if (!isQueryable(collection)) {
              if (!Array.isArray(collection)) {
                throw new LuaRuntimeError(
                  "Collection does not support query",
                  sf.withCtx(q.ctx),
                );
              }
              collection = new ArrayQueryCollection(collection);
            }
            // Build up query object
            const query: LuaCollectionQuery = {
              objectVariable,
              distinct: true,
            };

            // Map clauses to query parameters
            for (const clause of q.clauses) {
              switch (clause.type) {
                case "Where": {
                  query.where = clause.expression;
                  break;
                }
                case "OrderBy": {
                  query.orderBy = clause.orderBy.map((o) => ({
                    expr: o.expression,
                    desc: o.direction === "desc",
                  }));
                  break;
                }
                case "Select": {
                  query.select = clause.expression;
                  break;
                }
                case "Limit": {
                  const limitVal = await evalExpression(clause.limit, env, sf);
                  query.limit = Number(limitVal);
                  if (clause.offset) {
                    const offsetVal = await evalExpression(
                      clause.offset,
                      env,
                      sf,
                    );
                    query.offset = Number(offsetVal);
                  }
                  break;
                }
              }
            }

            return (collection as Queryable).query(query, env, sf).then(
              jsToLuaValue,
            );
          },
        );
      }
      default:
        throw new LuaRuntimeError(
          `Unknown expression type ${e.type}`,
          sf.withCtx(e.ctx),
        );
    }
  } catch (err: any) {
    // Repackage any non Lua-specific exceptions with some position information
    if (!err.constructor.name.startsWith("Lua")) {
      throw new LuaRuntimeError(err.message, sf.withCtx(e.ctx), err);
    } else {
      throw err;
    }
  }
}

function evalPrefixExpression(
  e: LuaExpression,
  env: LuaEnv,
  sf: LuaStackFrame,
): Promise<LuaValue> | LuaValue {
  switch (e.type) {
    case "Variable": {
      const v = asVariable(e);
      const value = env.get(v.name);
      if (value === undefined) {
        return null;
      }
      return value;
    }

    case "Parenthesized": {
      const p = asParenthesized(e);
      return evalExpression(p.expression, env, sf);
    }

    // <<expr>>[<<expr>>]
    case "TableAccess": {
      const ta = asTableAccess(e);
      // Sync-first: evaluate object and key without allocating Promise when both are sync.
      const objV = evalPrefixExpression(ta.object, env, sf);
      const keyV = evalExpression(ta.key, env, sf);

      if (!isPromise(objV) && !isPromise(keyV)) {
        const table = singleResult(objV);
        const key = singleResult(keyV);
        return luaGet(table, key, ta.ctx, sf);
      }

      return rpThen(
        objV,
        (obj) =>
          rpThen(
            keyV,
            (key) => luaGet(singleResult(obj), singleResult(key), ta.ctx, sf),
          ),
      );
    }

    // <expr>.property
    case "PropertyAccess": {
      const pa = asPropertyAccess(e);
      // Sync-first: evaluate object; avoid Promise when object is sync.
      const objV = evalPrefixExpression(pa.object, env, sf);
      if (!isPromise(objV)) {
        return luaGet(objV, pa.property, pa.ctx, sf);
      }
      return rpThen(objV, (obj) => luaGet(obj, pa.property, pa.ctx, sf));
    }

    case "FunctionCall": {
      const fc = asFunctionCall(e);
      const prefixValue = evalPrefixExpression(fc.prefix, env, sf);
      if (prefixValue === null || prefixValue === undefined) {
        const nilMsg = fc.prefix.type === "Variable"
          ? `attempt to call a nil value (global '${
            asVariable(fc.prefix).name
          }')`
          : `attempt to call a nil value`;
        throw new LuaRuntimeError(
          nilMsg,
          sf.withCtx(fc.prefix.ctx),
        );
      }

      let selfArgs: LuaValue[] = [];

      const handleFunctionCall = (
        calleeVal: LuaValue,
      ): LuaValue | Promise<LuaValue> => {
        // Normal argument handling for hello:there(a, b, c) type calls
        if (fc.name) {
          selfArgs = [calleeVal];
          calleeVal = luaIndexValue(calleeVal, fc.name, sf);

          if (isPromise(calleeVal)) {
            return (calleeVal as Promise<any>).then(handleFunctionCall);
          }
        }

        const argsVal = evalExpressions(fc.args, env, sf);

        const thenCall = (args: LuaValue[]) =>
          luaCall(calleeVal, [...selfArgs, ...args], fc.ctx, sf);

        return rpThen(argsVal, thenCall);
      };

      return rpThen(prefixValue, handleFunctionCall);
    }

    default: {
      throw new LuaRuntimeError(
        `Unknown prefix expression type ${e.type}`,
        sf.withCtx(e.ctx),
      );
    }
  }
}

// Helper functions to reduce duplication
function evalMetamethod(
  left: any,
  right: any,
  metaMethod: string,
  ctx: ASTCtx,
  sf: LuaStackFrame,
): LuaValue | undefined {
  const leftMetatable = getMetatable(left, sf);
  if (leftMetatable) {
    const fn = leftMetatable.rawGet(metaMethod);
    if (!(fn === undefined || fn === null)) {
      return luaCall(fn, [left, right], ctx, sf);
    }
  }

  const rightMetatable = getMetatable(right, sf);
  if (rightMetatable) {
    const fn = rightMetatable.rawGet(metaMethod);
    if (!(fn === undefined || fn === null)) {
      return luaCall(fn, [left, right], ctx, sf);
    }
  }
}

// Unary metamethod lookup and call
function evalUnaryMetamethod(
  value: any,
  metaMethod: "__unm" | "__bnot",
  ctx: ASTCtx,
  sf: LuaStackFrame,
): LuaValue | Promise<LuaValue> | undefined {
  const mt = getMetatable(value, sf);
  if (!mt) {
    return undefined;
  }
  const fn = mt.rawGet(metaMethod);
  if (fn === undefined || fn === null) {
    return undefined;
  }
  return luaCall(fn, [value], ctx, sf);
}

// Unary metamethod handling (with fallback)
function unaryWithMeta(
  arg: any,
  meta: "__unm" | "__bnot",
  ctx: ASTCtx,
  sf: LuaStackFrame,
  fallback: () => any,
): any {
  const mm = evalUnaryMetamethod(arg, meta, ctx, sf);

  if (mm !== undefined) {
    return isPromise(mm)
      ? (mm as Promise<any>).then(singleResult)
      : singleResult(mm);
  }
  return fallback();
}

// Logical short-circuit evaluation
function evalLogical(
  op: "and" | "or",
  leftExpr: LuaExpression,
  rightExpr: LuaExpression,
  env: LuaEnv,
  sf: LuaStackFrame,
): any {
  const left = evalExpression(leftExpr, env, sf);

  const decide = (lv: any) => {
    if (op === "or") {
      if (luaTruthy(lv)) {
        return singleResult(lv);
      }
      const rv = evalExpression(rightExpr, env, sf);
      return isPromise(rv)
        ? (rv as Promise<any>).then(singleResult)
        : singleResult(rv);
    }
    if (!luaTruthy(lv)) {
      return singleResult(lv);
    }
    const rv = evalExpression(rightExpr, env, sf);
    return isPromise(rv)
      ? (rv as Promise<any>).then(singleResult)
      : singleResult(rv);
  };

  if (isPromise(left)) {
    return (left as Promise<any>).then(decide);
  }
  return decide(left);
}

type TypedValue = { value: LuaValue };

function evalExprWithNumericType(
  expr: LuaExpression,
  env: LuaEnv,
  sf: LuaStackFrame,
  _wantNumericType: boolean,
): TypedValue | Promise<TypedValue> {
  const v = evalExpression(expr, env, sf);
  const apply = (vv: any): TypedValue => ({ value: vv });
  return rpThen(v, apply) as any;
}

function getSimpleLiteralType(expr: LuaExpression): NumericType | undefined {
  if (expr.type === "Number") {
    return expr.numericType === "int" ? "int" : "float";
  }
  if (
    expr.type === "Unary" &&
    (expr.operator === "+" || expr.operator === "-") &&
    expr.argument.type === "Number"
  ) {
    return expr.argument.numericType === "int" ? "int" : "float";
  }
  return undefined;
}

function evalBinaryWithLR(
  op: string,
  leftExpr: LuaExpression,
  rightExpr: LuaExpression,
  ctx: ASTCtx,
  env: LuaEnv,
  sf: LuaStackFrame,
): any {
  const wantNumericType = isNumericBinaryOp(op);
  const leftType = wantNumericType ? getSimpleLiteralType(leftExpr) : undefined;
  const rightType = wantNumericType
    ? getSimpleLiteralType(rightExpr)
    : undefined;
  const leftVal = evalExpression(leftExpr, env, sf);

  const applyLeft = (lv: any) => {
    const rightVal = evalExpression(rightExpr, env, sf);
    const applyRight = (rv: any) => {
      return luaOp(
        op,
        singleResult(lv),
        singleResult(rv),
        leftType,
        rightType,
        ctx,
        sf,
      );
    };
    return rpThen(rightVal, applyRight);
  };
  return rpThen(leftVal, applyLeft);
}

function createBitwiseError(
  val: any,
  ctx: ASTCtx,
  sf: LuaStackFrame,
): LuaRuntimeError {
  if (typeof val === "string") {
    return new LuaRuntimeError(
      `attempt to perform bitwise operation on a string value (constant '${val}')`,
      sf.withCtx(ctx),
    );
  }
  const t = luaTypeName(val);
  if (t === "number") {
    return new LuaRuntimeError(
      `number has no integer representation`,
      sf.withCtx(ctx),
    );
  }
  return new LuaRuntimeError(
    `attempt to perform bitwise operation on a ${t} value`,
    sf.withCtx(ctx),
  );
}

function getBinaryMM(
  a: any,
  b: any,
  mmName: string,
  sf: LuaStackFrame,
): any | null {
  // Look in a's metatable first; if absent, look in b's.
  const ma = getMetatable(a, sf);
  if (ma) {
    const mmA = ma.rawGet(mmName);
    if (!(mmA === undefined || mmA === null)) {
      return mmA;
    }
  }
  const mb = getMetatable(b, sf);
  if (mb) {
    const mmB = mb.rawGet(mmName);
    if (!(mmB === undefined || mmB === null)) {
      return mmB;
    }
  }
  return null;
}

function luaEqWithMetamethod(
  a: any,
  b: any,
  ctx: ASTCtx,
  sf: LuaStackFrame,
): boolean | Promise<boolean> {
  if (luaEquals(a, b)) {
    return true;
  }

  const ta = luaTypeName(a);
  const tb = luaTypeName(b);

  // __eq only applies to tables/userdata
  const aOk = ta === "table" || ta === "userdata";
  const bOk = tb === "table" || tb === "userdata";
  if (!aOk || !bOk) {
    return false;
  }

  const getEqMM = (obj: any): any | null => {
    const mt = getMetatable(obj, sf);
    if (!mt) return null;

    const mm = mt.rawGet("__eq");
    if (mm === undefined || mm === null) return null;

    if (typeof mm === "function" || isILuaFunction(mm)) {
      return mm;
    }

    const ty = luaTypeName(mm);
    throw new LuaRuntimeError(
      `attempt to call a ${ty} value`,
      sf.withCtx(ctx),
    );
  };

  // Try left __eq first, then right.
  const mm = getEqMM(a) ?? getEqMM(b);
  if (!mm) {
    return false;
  }

  const r = luaCall(mm, [a, b], ctx, sf);
  return isPromise(r)
    ? (r as Promise<any>).then((v) => !!singleResult(v))
    : !!singleResult(r);
}

function luaRelWithMetamethod(
  op: "<" | "<=",
  a: any,
  b: any,
  ctx: ASTCtx,
  sf: LuaStackFrame,
): boolean | Promise<boolean> {
  const an = isTaggedFloat(a) ? a.value : a;
  const bn = isTaggedFloat(b) ? b.value : b;

  if (typeof an === "number" && typeof bn === "number") {
    return op === "<" ? an < bn : an <= bn;
  }
  if (typeof an === "string" && typeof bn === "string") {
    return op === "<" ? an < bn : an <= bn;
  }

  const mmName = op === "<" ? "__lt" : "__le";
  const mm = getBinaryMM(a, b, mmName, sf);
  if (mm) {
    const r = luaCall(mm, [a, b], ctx, sf);
    if (isPromise(r)) {
      return (r as Promise<any>).then((v) => !!singleResult(v));
    }
    return !!singleResult(r);
  }

  throw new LuaRuntimeError(
    `attempt to compare ${luaTypeName(a)} with ${luaTypeName(b)}`,
    sf.withCtx(ctx),
  );
}

/**
 * Length operator:
 * - for strings return byte length, ignore `__len`,
 * - for Lua tables if metatable has `__len` metamethod then call it;
 *   use table length otherwise,
 * - for other values (userdata): honor `__len` if present,
 * - for JavaScript arrays return length,
 * - throw error otherwise.
 */
function luaLengthOp(
  val: any,
  ctx: ASTCtx,
  sf: LuaStackFrame,
): LuaValue {
  // Strings: ignore `__len`
  if (typeof val === "string") {
    return val.length;
  }

  // Tables: prefer metatable `__len` to raw length
  if (val instanceof LuaTable) {
    const mt = getMetatable(val, sf);
    if (mt) {
      const fn = mt.rawGet("__len");
      if (!(fn === undefined || fn === null)) {
        return luaCall(fn, [val], ctx, sf);
      }
    }
    return val.length;
  }

  // Other values: allow metatable `__len` first
  {
    const mt = getMetatable(val, sf);
    if (mt) {
      const fn = mt.rawGet("__len");
      if (!(fn === undefined || fn === null)) {
        return luaCall(fn, [val], ctx, sf);
      }
    }
  }

  // JS arrays (interop): length if no `__len` override
  if (Array.isArray(val)) {
    return val.length;
  }

  // Otherwise error with type
  const t = luaTypeOf(val) as LuaType;
  throw new LuaRuntimeError(
    `attempt to get length of a ${t} value`,
    sf.withCtx(ctx),
  );
}

function evalExpressions(
  es: LuaExpression[],
  env: LuaEnv,
  sf: LuaStackFrame,
): Promise<LuaValue[]> | LuaValue[] {
  // Evaluate all arguments first (sync-first); do not allocate a Promise if all are sync.
  const parts = es.map((arg) => evalExpression(arg, env, sf));
  const argsVal = rpAll(parts);

  // In Lua multi-returns propagate only in tail position of an expression list.
  const finalize = (argsResolved: any[]) => {
    if (argsResolved.length === 0) {
      return [];
    }
    const out: LuaValue[] = [];
    // All but last expression produce a single value
    for (let i = 0; i < argsResolved.length - 1; i++) {
      out.push(singleResult(argsResolved[i]));
    }
    // Last expression preserves multiple results
    const last = argsResolved[argsResolved.length - 1];
    if (last instanceof LuaMultiRes) {
      out.push(...last.flatten().values);
    } else {
      out.push(singleResult(last));
    }
    return out;
  };

  return rpThen(argsVal, finalize);
}

type EvalBlockResult =
  | void
  | ControlSignal
  | Promise<void | ControlSignal>;

function runStatementsNoGoto(
  stmts: LuaStatement[],
  execEnv: LuaEnv,
  sf: LuaStackFrame,
  returnOnReturn: boolean,
  startIdx: number,
): void | ControlSignal | Promise<void | ControlSignal> {
  const processFrom = (
    idx: number,
  ): void | ControlSignal | Promise<void | ControlSignal> => {
    for (let i = idx; i < stmts.length; i++) {
      const result = evalStatement(
        stmts[i],
        execEnv,
        sf,
        returnOnReturn,
      );
      if (isPromise(result)) {
        return (result as Promise<any>).then((res) => {
          if (res !== undefined) {
            if (isGotoSignal(res)) {
              throw new LuaRuntimeError(
                "unexpected goto signal",
                sf.withCtx(stmts[i].ctx),
              );
            }
            return res;
          }
          return processFrom(i + 1);
        });
      }
      if (result !== undefined) {
        if (isGotoSignal(result)) {
          throw new LuaRuntimeError(
            "unexpected goto signal",
            sf.withCtx(stmts[i].ctx),
          );
        }
        return result;
      }
    }
    return;
  };

  return processFrom(startIdx);
}

function withCloseBoundary(
  sf: LuaStackFrame,
  mark: number,
  out: EvalBlockResult,
): EvalBlockResult {
  if (!isPromise(out)) {
    const r = luaCloseFromMark(sf, mark, null);
    if (isPromise(r)) {
      return (r as Promise<void>).then(() => out as any);
    }
    return out;
  }

  const p = out as Promise<any>;

  const onFulfilled = (res: any) => {
    const r = luaCloseFromMark(sf, mark, null);
    return isPromise(r) ? (r as Promise<void>).then(() => res) : res;
  };

  const onRejected = (e: any) => {
    const errObj: LuaValue = e instanceof LuaRuntimeError
      ? e.message
      : (e?.message ?? String(e));
    const r = luaCloseFromMark(sf, mark, errObj);
    if (isPromise(r)) {
      return (r as Promise<void>).then(() => {
        throw e;
      });
    }
    throw e;
  };

  return p.then(onFulfilled, onRejected);
}

function evalBlockNoClose(
  b: LuaBlock,
  env: LuaEnv,
  sf: LuaStackFrame,
  returnOnReturn: boolean,
): EvalBlockResult {
  const hasGotoFlag = b.hasGoto === true;
  const hasLabelFlag = b.hasLabel === true;
  const hasLabelHere = b.hasLabelHere === true;

  const curFn = sf.currentFunction;
  const fnHasGotos = curFn?.funcHasGotos;

  if (fnHasGotos === false || (!hasGotoFlag && !hasLabelFlag)) {
    const dup = b.dupLabelError;
    if (dup) {
      // Duplicated labels detected by parser.
      throw new LuaRuntimeError(
        `label '${dup.name}' already defined`,
        sf.withCtx(dup.ctx),
      );
    }

    const execEnv = b.needsEnv === true ? new LuaEnv(env) : env;
    return runStatementsNoGoto(b.statements, execEnv, sf, returnOnReturn, 0);
  }

  if (fnHasGotos === true && !hasLabelHere && !hasGotoFlag) {
    const execEnv = b.needsEnv === true ? new LuaEnv(env) : env;
    const stmts = b.statements;
    const runFrom = (
      i: number,
    ): EvalBlockResult => {
      for (; i < stmts.length; i++) {
        const r = evalStatement(stmts[i], execEnv, sf, returnOnReturn);
        if (isPromise(r)) {
          return (r as Promise<any>).then((res) => {
            if (isGotoSignal(res)) return res;
            if (res !== undefined) return res;
            return runFrom(i + 1);
          });
        }
        if (isGotoSignal(r)) return r;
        if (r !== undefined) return r;
      }
      return;
    };
    return runFrom(0);
  }

  let meta: ReturnType<typeof getBlockGotoMeta> | undefined;
  if (fnHasGotos === undefined && (hasGotoFlag || hasLabelFlag)) {
    meta = blockMetaOrThrow(b, sf);
    if (curFn) {
      curFn.funcHasGotos = !!meta?.funcHasGotos;
    }
  } else if (fnHasGotos === true) {
    meta = hasLabelFlag || hasGotoFlag ? blockMetaOrThrow(b, sf) : undefined;
  } else {
    meta = undefined;
  }

  if (!meta || !meta.funcHasGotos) {
    const dup = b.dupLabelError;
    if (dup) {
      throw new LuaRuntimeError(
        `label '${dup.name}' already defined`,
        sf.withCtx(dup.ctx),
      );
    }
    const execEnv = b.needsEnv === true ? new LuaEnv(env) : env;
    return runStatementsNoGoto(b.statements, execEnv, sf, returnOnReturn, 0);
  }

  const execEnv = b.needsEnv === true ? new LuaEnv(env) : env;
  const stmts = b.statements;

  const runFrom = (
    i: number,
  ): EvalBlockResult => {
    for (; i < stmts.length; i++) {
      const r = evalStatement(stmts[i], execEnv, sf, returnOnReturn);
      if (isPromise(r)) {
        return (r as Promise<any>).then((res) => {
          const consumed = consumeGotoInBlock(res, meta!.labels);
          if (typeof consumed === "number") {
            return runFrom(consumed);
          }
          if (consumed !== undefined) {
            return consumed;
          }
          return runFrom(i + 1);
        });
      }
      const consumed = consumeGotoInBlock(r, meta.labels);
      if (typeof consumed === "number") {
        i = consumed - 1;
        continue;
      }
      if (consumed !== undefined) {
        return consumed;
      }
    }
    return;
  };

  return runFrom(0);
}

/**
 * Evaluates a statement in two possible modes:
 *
 * 1. With `returnOnReturn` set to `true` will return the value of
 *    a return statement.
 * 2. With `returnOnReturn` set to `false` will throw a LuaReturn
 *    exception if a return statement is encountered.
 *
 * May also return `{ctrl:"goto", target}` for goto.
 */
export function evalStatement(
  s: LuaStatement,
  env: LuaEnv,
  sf: LuaStackFrame,
  returnOnReturn = false,
): void | ControlSignal | Promise<void | ControlSignal> {
  switch (s.type) {
    case "Assignment": {
      const a = asAssignment(s);
      const valuesRP = evalExpressions(a.expressions, env, sf);
      const lvaluesRP = evalPromiseValues(a.variables
        .map((lval) => evalLValue(lval, env, sf)));

      const apply = (values: LuaValue[], lvalues: { env: any; key: any }[]) => {
        const ps: Promise<any>[] = [];
        for (let i = 0; i < lvalues.length; i++) {
          const r = luaSet(
            lvalues[i].env,
            lvalues[i].key,
            values[i],
            sf.withCtx(a.ctx),
          );

          if (isPromise(r)) {
            ps.push(r);
          }
        }
        if (ps.length) {
          return Promise.all(ps).then(() => undefined);
        }
        return;
      };

      if (!isPromise(valuesRP) && !isPromise(lvaluesRP)) {
        return apply(
          valuesRP as LuaValue[],
          lvaluesRP as LuaLValueContainer[],
        );
      }
      if (
        isPromise(valuesRP) && !isPromise(lvaluesRP)
      ) {
        return (valuesRP as Promise<LuaValue[]>).then((values: LuaValue[]) =>
          apply(values, lvaluesRP as LuaLValueContainer[])
        );
      }
      if (
        !isPromise(valuesRP) && isPromise(lvaluesRP)
      ) {
        return (lvaluesRP as Promise<any[]>).then((lvalues: any[]) =>
          apply(valuesRP as LuaValue[], lvalues)
        );
      }
      return (valuesRP as Promise<LuaValue[]>).then((values: LuaValue[]) =>
        (lvaluesRP as Promise<any[]>).then((lvalues: any[]) =>
          apply(values, lvalues)
        )
      );
    }
    case "Local": {
      const l = asLocal(s);

      const hasInit = Array.isArray(l.expressions) && l.expressions.length > 0;

      for (const att of l.names) {
        const isConst = att.attributes?.includes(LuaAttribute.Const) === true;
        if (isConst && !hasInit) {
          throw new LuaRuntimeError(
            `const variable '${att.name}' must be initialized`,
            sf.withCtx(att.ctx),
          );
        }
      }

      const bindOne = (name: any, v: LuaValue) => {
        const isConst = name.attributes?.includes(LuaAttribute.Const) === true;
        const isClose = name.attributes?.includes(LuaAttribute.Close) === true;

        if (isConst || isClose) {
          env.setLocalConst(name.name, v);
        } else {
          env.setLocal(name.name, v);
        }

        if (isClose) {
          luaMarkToBeClosed(sf, v, name.ctx);
        }
      };

      if (!hasInit) {
        for (let i = 0; i < l.names.length; i++) {
          bindOne(l.names[i], null);
        }
        return;
      }

      // Evaluate initializers left-to-right and bind/mark `<close>`
      // locals as soon as they receive a value.  This ensures earlier
      // `<close>` locals are closed if a later initializer errors.
      const exprs = l.expressions!;
      const out: LuaValue[] = [];
      let boundCount = 0;

      const bindAvailable = () => {
        while (boundCount < l.names.length && boundCount < out.length) {
          bindOne(
            l.names[boundCount],
            out[boundCount] ?? null,
          );
          boundCount++;
        }
      };

      const finish = () => {
        while (out.length < l.names.length) {
          out.push(null);
        }
        bindAvailable();
      };

      const runFrom = (i: number): void | Promise<void> => {
        if (i >= exprs.length) {
          finish();
          return;
        }

        const isLastExpr = i === exprs.length - 1;
        const rp = evalExpression(exprs[i], env, sf);

        const onValue = (v: LuaValue) => {
          if (isLastExpr) {
            if (v instanceof LuaMultiRes) {
              const flat = v.flatten();
              for (let k = 0; k < flat.values.length; k++) {
                out.push(flat.values[k]);
              }
            } else {
              out.push(v);
            }
          } else {
            out.push(singleResult(v));
          }

          bindAvailable();

          // If we already have enough values for all locals, remaining
          // expressions will not affect the binding, so we can stop.
          if (out.length >= l.names.length && !isLastExpr) {
            return;
          }

          return runFrom(i + 1);
        };

        return rpThen(rp, onValue) as any;
      };

      return runFrom(0);
    }
    case "Semicolon": {
      return;
    }
    case "Label": {
      const _lab = asLabel(s); // No-op!
      return;
    }
    case "Goto": {
      const g = asGoto(s);
      return { ctrl: "goto", target: g.name };
    }
    case "Block": {
      const b = asBlock(s);

      if (!b.hasCloseHere) {
        return evalBlockNoClose(b, env, sf, returnOnReturn);
      }

      // Blocks establish a boundary (mark) and close all entries
      // created within the block on exit or error, shrinking the stack
      // back to mark.  This is _required_ for correct `pcall` and
      // `xpcall` boundary semantics.
      const closeStack = luaEnsureCloseStack(sf);
      const mark = closeStack.length;

      let out: EvalBlockResult;
      try {
        out = evalBlockNoClose(b, env, sf, returnOnReturn);
      } catch (e: any) {
        const errObj: LuaValue = e instanceof LuaRuntimeError
          ? e.message
          : (e?.message ?? String(e));
        const r = luaCloseFromMark(sf, mark, errObj);
        if (isPromise(r)) {
          return (r as Promise<void>).then(() => {
            throw e;
          });
        }
        throw e;
      }

      return withCloseBoundary(sf, mark, out);
    }
    case "If": {
      const iff = asIf(s);
      // Evaluate conditions in order; avoid awaiting when not necessary
      const conds = iff.conditions;

      const runFrom = (
        i: number,
      ):
        | void
        | ControlSignal
        | Promise<void | ControlSignal> => {
        if (i >= conds.length) {
          if (iff.elseBlock) {
            return evalStatement(iff.elseBlock, env, sf, returnOnReturn);
          }
          return;
        }
        const cv = evalExpression(conds[i].condition, env, sf);
        if (isPromise(cv)) {
          return (cv as Promise<any>).then((val) => {
            if (luaTruthy(val)) {
              return evalStatement(conds[i].block, env, sf, returnOnReturn);
            }
            return runFrom(i + 1);
          });
        }
        if (luaTruthy(cv)) {
          return evalStatement(conds[i].block, env, sf, returnOnReturn);
        }
        return runFrom(i + 1);
      };

      return runFrom(0);
    }
    case "While": {
      const w = asWhile(s);

      const runAsync = async (): Promise<void | ControlSignal> => {
        while (true) {
          const c = await evalExpression(w.condition, env, sf);
          if (!luaTruthy(c)) {
            break;
          }
          const r = evalStatement(w.block, env, sf, returnOnReturn);
          const res = isPromise(r) ? await r : r;
          if (res !== undefined) {
            if (isBreakSignal(res)) {
              break;
            }
            return res;
          }
        }
        return;
      };

      while (true) {
        const c = evalExpression(w.condition, env, sf);
        if (isPromise(c)) {
          return (c as Promise<any>).then((cv) => {
            if (!luaTruthy(cv)) {
              return;
            }
            try {
              const r = evalStatement(w.block, env, sf, returnOnReturn);
              if (isPromise(r)) {
                return (r as Promise<any>).then((res) => {
                  if (res !== undefined) {
                    if (isBreakSignal(res)) {
                      return;
                    }
                    return res;
                  }
                  return runAsync();
                });
              }
              if (r !== undefined) {
                if (isBreakSignal(r)) {
                  return;
                }
                return r;
              }
              return runAsync();
            } catch (e: any) {
              throw e;
            }
          });
        }
        if (!luaTruthy(c)) {
          break;
        }
        const r = evalStatement(w.block, env, sf, returnOnReturn);
        if (isPromise(r)) {
          return (r as Promise<any>).then((res) => {
            if (res !== undefined) {
              if (isBreakSignal(res)) {
                return;
              }
              return res;
            }
            return runAsync();
          });
        }
        if (r !== undefined) {
          if (isBreakSignal(r)) {
            break;
          }
          return r;
        }
      }
      return;
    }
    case "Repeat": {
      const r = asRepeat(s);

      const runAsync = async (): Promise<void | ControlSignal> => {
        while (true) {
          const rr = evalStatement(r.block, env, sf, returnOnReturn);
          const res = isPromise(rr) ? await rr : rr;
          if (res !== undefined) {
            if (isBreakSignal(res)) {
              break;
            }
            return res;
          }
          const c = await evalExpression(r.condition, env, sf);
          if (luaTruthy(c)) {
            break;
          }
        }
        return;
      };

      while (true) {
        const rr = evalStatement(r.block, env, sf, returnOnReturn);
        if (isPromise(rr)) {
          return (rr as Promise<any>).then((res) => {
            if (res !== undefined) {
              if (isBreakSignal(res)) {
                return;
              }
              return res;
            }
            return runAsync();
          });
        }
        if (rr !== undefined) {
          if (isBreakSignal(rr)) {
            return;
          }
          return rr;
        }

        const c = evalExpression(r.condition, env, sf);
        if (isPromise(c)) {
          return (c as Promise<any>).then((cv) =>
            luaTruthy(cv) ? undefined : runAsync()
          );
        }
        if (luaTruthy(c)) {
          break;
        }
      }
      return;
    }
    case "Break": {
      return { ctrl: "break" };
    }
    case "FunctionCallStatement": {
      const fcs = asFunctionCallStmt(s);
      const r = evalExpression(fcs.call, env, sf);
      if (isPromise(r)) {
        return (r as Promise<any>).then(() => undefined);
      }
      return;
    }
    case "Function": {
      const fn = asFunctionStmt(s);
      let body = fn.body;
      let propNames = fn.name.propNames;
      if (fn.name.colonName) {
        // function hello:there() -> function hello.there(self) transformation
        body = {
          ...(fn.body),
          parameters: ["self", ...fn.body.parameters],
        };
        propNames = [...fn.name.propNames, fn.name.colonName];
      }
      let settable: ILuaGettable = env;
      for (let i = 0; i < propNames.length - 1; i++) {
        settable = (settable as any).get(propNames[i]);
        if (!settable) {
          throw new LuaRuntimeError(
            `Cannot find property ${propNames[i]}`,
            sf.withCtx(fn.name.ctx),
          );
        }
      }
      (settable as any).set(
        propNames[propNames.length - 1],
        new LuaFunction(body, env),
      );
      return;
    }
    case "LocalFunction": {
      const lf = asLocalFunction(s);
      env.setLocal(
        lf.name,
        new LuaFunction(lf.body, env),
      );
      return;
    }
    case "Return": {
      const ret = asReturn(s);

      const parts = ret.expressions.map((value: LuaExpression) =>
        evalExpression(value, env, sf)
      );
      const valuesRP = rpAll(parts);

      const finalize = (vals: any[]): ReturnSignal => {
        const outVals: LuaValue[] = [];

        if (vals.length === 0) {
          return { ctrl: "return", values: outVals };
        }

        for (let i = 0; i < vals.length; i++) {
          const isLast = i === vals.length - 1;
          const v = vals[i];

          if (!isLast) {
            outVals.push(singleResult(v));
            continue;
          }

          if (v instanceof LuaMultiRes) {
            const flat = v.flatten();
            outVals.push(...flat.values);
          } else {
            outVals.push(v);
          }
        }

        return {
          ctrl: "return" as const,
          values: outVals,
        };
      };

      if (isPromise(valuesRP)) {
        return (valuesRP as Promise<any[]>).then((vals) => finalize(vals));
      }
      return finalize(valuesRP as any[]);
    }
    case "For": {
      const fr = asFor(s);
      const startV = evalExpression(fr.start, env, sf);
      const endV = evalExpression(fr.end, env, sf);
      const stepV = fr.step ? evalExpression(fr.step, env, sf) : 1;

      const determineLoopType = (): NumericType => {
        const startType = astNumberKind(fr.start);
        const stepType = fr.step ? astNumberKind(fr.step) : "int";
        return (startType === "float" || stepType === "float")
          ? "float"
          : "int";
      };

      const wrapLoopVar = (i: number, loopType: NumericType) => {
        if (loopType === "float") {
          return makeLuaFloat(i);
        }
        return i;
      };

      const canReuseEnv = !fr.block.hasFunctionDef ||
        fr.capturesLoopVar === false;

      const executeIteration = canReuseEnv
        ? (
          loopEnv: LuaEnv,
          i: number,
          loopType: NumericType,
        ): void | ControlSignal | Promise<void | ControlSignal> => {
          loopEnv.setLocal(fr.name, wrapLoopVar(i, loopType));
          return evalStatement(fr.block, loopEnv, sf, returnOnReturn);
        }
        : (
          _loopEnv: LuaEnv,
          i: number,
          loopType: NumericType,
        ): void | ControlSignal | Promise<void | ControlSignal> => {
          const localEnv = new LuaEnv(env);
          localEnv.setLocal(fr.name, wrapLoopVar(i, loopType));
          return evalStatement(fr.block, localEnv, sf, returnOnReturn);
        };

      const runAsync = async (
        loopEnv: LuaEnv,
        end: number,
        step: number,
        startIndex: number,
        loopType: NumericType,
      ) => {
        if (step === 0) {
          throw new LuaRuntimeError("'for' step is zero", sf.withCtx(fr.ctx));
        }

        const shouldContinue = step > 0
          ? (i: number) => i <= end
          : (i: number) => i >= end;

        for (let i = startIndex; shouldContinue(i); i += step) {
          const r = executeIteration(loopEnv, i, loopType);
          const res = isPromise(r) ? await r : r;
          if (res !== undefined) {
            if (isBreakSignal(res)) {
              return;
            }
            return res;
          }
        }
      };

      const runSyncFirst = (
        start: number,
        end: number,
        step: number,
        loopType: NumericType,
      ):
        | void
        | ControlSignal
        | Promise<void | ControlSignal> => {
        if (step === 0) {
          throw new LuaRuntimeError("'for' step is zero", sf.withCtx(fr.ctx));
        }

        const shouldContinue = step > 0
          ? (i: number) => i <= end
          : (i: number) => i >= end;

        const loopEnv = new LuaEnv(env);

        for (let i = start; shouldContinue(i); i += step) {
          const r = executeIteration(loopEnv, i, loopType);
          if (isPromise(r)) {
            return (r as Promise<any>).then((res) => {
              if (res !== undefined) {
                if (isBreakSignal(res)) {
                  return;
                }
                return res;
              }
              return runAsync(loopEnv, end, step, i + step, loopType);
            });
          }
          if (r !== undefined) {
            if (isBreakSignal(r)) {
              return;
            }
            return r;
          }
        }
        return;
      };

      const loopType = determineLoopType();

      if (
        !isPromise(startV) &&
        !isPromise(endV) &&
        !isPromise(stepV)
      ) {
        return runSyncFirst(
          untagNumber(startV) as number,
          untagNumber(endV) as number,
          untagNumber(stepV ?? 1) as number,
          loopType,
        );
      }
      return Promise.all([
        isPromise(startV) ? startV : Promise.resolve(startV),
        isPromise(endV) ? endV : Promise.resolve(endV),
        isPromise(stepV) ? stepV : Promise.resolve(stepV),
      ]).then(([start, end, step]) => {
        return runSyncFirst(
          untagNumber(start) as number,
          untagNumber(end) as number,
          untagNumber(step ?? 1) as number,
          loopType,
        );
      });
    }
    case "ForIn": {
      const fi = asForIn(s);
      const exprVals = rpAll(
        fi.expressions.map((e: LuaExpression) => evalExpression(e, env, sf)),
      );

      const canReuseEnv = !fi.block.hasFunctionDef ||
        fi.capturesLoopVar === false;
      const setIterVars = (
        localEnv: LuaEnv,
        names: string[],
        values: LuaValue[],
      ) => {
        for (let i = 0; i < names.length; i++) {
          localEnv.setLocal(names[i], values[i]);
        }
      };

      const afterExprs = (resolved: any[]) => {
        const iteratorMultiRes = new LuaMultiRes(resolved).flatten();
        let iteratorValue: ILuaFunction | any = iteratorMultiRes.values[0];
        // Handle the case where the iterator is a table and we need
        // to call the `each` function.
        if (Array.isArray(iteratorValue) || iteratorValue instanceof LuaTable) {
          iteratorValue = (env.get("each") as ILuaFunction).call(
            sf,
            iteratorValue,
          );
        }

        if (!iteratorValue?.call) {
          console.error("Cannot iterate over", iteratorMultiRes.values[0]);
          throw new LuaRuntimeError(
            `Cannot iterate over ${iteratorMultiRes.values[0]}`,
            sf.withCtx(fi.ctx),
          );
        }

        const state: LuaValue = iteratorMultiRes.values[1] ?? null;
        let control: LuaValue = iteratorMultiRes.values[2] ?? null;
        const closing: LuaValue = iteratorMultiRes.values[3] ?? null;

        const closeStack = luaEnsureCloseStack(sf);
        const mark = closeStack.length;

        if (closing !== null) {
          luaMarkToBeClosed(sf, closing, fi.ctx);
        }

        const errObjFrom = (e: any): LuaValue =>
          e instanceof LuaRuntimeError ? e.message : (e?.message ?? String(e));

        const finish = (res: any) => {
          const r = luaCloseFromMark(sf, mark, null);
          return isPromise(r) ? (r as Promise<void>).then(() => res) : res;
        };

        const finishErr = (e: any): Promise<never> | never => {
          const errObj = errObjFrom(e);
          const r = luaCloseFromMark(sf, mark, errObj);
          if (isPromise(r)) {
            return (r as Promise<void>).then(() => {
              throw e;
            });
          }
          throw e;
        };

        // Allocate the reusable env once before the loop
        const loopEnv = canReuseEnv ? new LuaEnv(env) : null;

        const makeIterEnv = (): LuaEnv => {
          if (loopEnv) {
            return loopEnv;
          }
          return new LuaEnv(env);
        };

        try {
          const runAsync = async () => {
            while (true) {
              const callRes = luaCall(
                iteratorValue,
                [state, control],
                fi.ctx,
                sf,
              );
              const iterResult = new LuaMultiRes(
                isPromise(callRes) ? await callRes : callRes,
              ).flatten();
              const nextControl = iterResult.values[0];
              if (nextControl === null || nextControl === undefined) {
                break;
              }
              control = nextControl;

              const localEnv = makeIterEnv();
              setIterVars(localEnv, fi.names, iterResult.values);

              const r = evalStatement(fi.block, localEnv, sf, returnOnReturn);
              const res = isPromise(r) ? await r : r;
              if (res !== undefined) {
                if (isBreakSignal(res)) {
                  break;
                }
                return await finish(res);
              }
            }
            return await finish(undefined);
          };

          while (true) {
            const iterCall = luaCall(
              iteratorValue,
              [state, control],
              fi.ctx,
              sf,
            );
            if (isPromise(iterCall)) {
              return (iterCall as Promise<any>).then((itv) => {
                const iterResult = new LuaMultiRes(itv).flatten();
                const nextControl = iterResult.values[0];
                if (nextControl === null || nextControl === undefined) {
                  const r = finish(undefined);
                  if (isPromise(r)) return (r as Promise<void>).then(() => {});
                  return;
                }
                control = nextControl;

                const localEnv = makeIterEnv();
                setIterVars(localEnv, fi.names, iterResult.values);

                const r = evalStatement(
                  fi.block,
                  localEnv,
                  sf,
                  returnOnReturn,
                );
                if (isPromise(r)) {
                  return (r as Promise<any>).then((res) => {
                    if (res !== undefined) {
                      if (isBreakSignal(res)) {
                        return finish(undefined);
                      }
                      return rpThen(finish(undefined), () => res);
                    }
                    return runAsync();
                  });
                }
                if (r !== undefined) {
                  if (isBreakSignal(r)) {
                    return finish(undefined);
                  }
                  return rpThen(finish(undefined), () => r);
                }
                return runAsync();
              }).catch((e: any) => finishErr(e));
            }

            const iterResult = new LuaMultiRes(iterCall).flatten();
            const nextControl = iterResult.values[0];
            if (nextControl === null || nextControl === undefined) {
              const r = finish(undefined);
              if (isPromise(r)) {
                return (r as Promise<void>);
              }
              return;
            }
            control = nextControl;

            const localEnv = makeIterEnv();
            setIterVars(localEnv, fi.names, iterResult.values);

            const r = evalStatement(fi.block, localEnv, sf, returnOnReturn);
            if (isPromise(r)) {
              return (r as Promise<any>).then((res) => {
                if (res !== undefined) {
                  if (isBreakSignal(res)) {
                    return finish(undefined);
                  }
                  return rpThen(finish(undefined), () => res);
                }
                return runAsync();
              }).catch((e: any) => finishErr(e));
            }
            if (r !== undefined) {
              if (isBreakSignal(r)) {
                return finish(undefined);
              }
              return rpThen(finish(undefined), () => r);
            }
          }
        } catch (e: any) {
          return finishErr(e);
        }
      };

      if (isPromise(exprVals)) {
        return (exprVals as Promise<any[]>).then(afterExprs);
      }
      return afterExprs(exprVals as any[]);
    }
  }
}

function evalLValue(
  lval: LuaLValue,
  env: LuaEnv,
  sf: LuaStackFrame,
): LuaLValueContainer | Promise<LuaLValueContainer> {
  switch (lval.type) {
    case "Variable": {
      const v = asLValueVariable(lval);
      return {
        env,
        key: v.name,
      };
    }
    case "TableAccess": {
      const ta = asLValueTableAccess(lval);
      const objValue = evalExpression(
        ta.object,
        env,
        sf,
      );
      const keyValue = evalExpression(ta.key, env, sf);
      if (
        isPromise(objValue) ||
        isPromise(keyValue)
      ) {
        return Promise.all([
          isPromise(objValue) ? objValue : Promise.resolve(objValue),
          isPromise(keyValue) ? keyValue : Promise.resolve(keyValue),
        ]).then(([objValue, keyValue]) => ({
          env: singleResult(objValue),
          key: singleResult(keyValue),
        }));
      }
      return {
        env: singleResult(objValue),
        key: singleResult(keyValue),
      };
    }
    case "PropertyAccess": {
      const pa = asLValuePropertyAccess(lval);
      const objValue = evalExpression(
        pa.object,
        env,
        sf,
      );
      if (isPromise(objValue)) {
        return (objValue as Promise<any>).then((ov) => {
          return {
            env: ov,
            key: pa.property,
          };
        });
      }
      return {
        env: objValue,
        key: pa.property,
      };
    }
  }
}


================================================
FILE: client/space_lua/goto_test.lua
================================================
local function assertEqual(a, b, message)
  if a ~= b then
    error("Assertion failed: " .. tostring(a)
      .. " != " .. tostring(b) .. " " .. (message or ""))
  end
end

local function contains(hay, needle)
  return string.find(tostring(hay), needle, 1, true) ~= nil
end

local function expectError(fn, needle, msg)
  local ok, err = pcall(fn)
  if ok then
    error("Expected error, but call succeeded: " .. (msg or ""))
  end
  local s = tostring(err)
  if not contains(s, needle) then
    error("Error missing expected text '" .. needle .. "': got " .. s)
  end
end

-- Basic forward jump
do
  local x = 0
  goto after_init
  x = -1 -- skipped
  ::after_init:: x = x + 2
  assertEqual(x, 2)
end

-- Backward jump (simulated loop)
do
  local n = 0
  ::loop_start::
  n = n + 1
  if n < 3 then goto loop_start end
  assertEqual(n, 3)
end

-- Consecutive labels (two labels before one statement)
do
  local v = 0
  ::first_label::::second_label:: v = v + 1
  assertEqual(v, 1)
end

-- No visible label (forward into inner block / outward from inner)
do
  local function f() goto missing; do ::missing:: end end
  expectError(f, "no visible label 'missing'")
  local function g() do ::inner:: end; goto inner end
  expectError(g, "no visible label 'inner'")
end

-- Duplicate label in same block
do
  local function f() ::dup:: ::dup:: end
  expectError(f, "label 'dup'")
end

-- Jump into local scope (illegal forward into variable region)
do
  local function f()
    goto after_local
    local a = 1
    ::after_local:: return a
  end
  expectError(f, "jumps into the scope")
end

-- Repeat loop: safe-end label NOT allowed to bypass scope
do
  local function f()
    local flag = false
    repeat
      if flag then goto after_repeat end
      local hidden = 10
      ::after_repeat:: -- repeat end label scope test
    until hidden and flag
  end
  expectError(f, "jumps into the scope")
end

-- Safe end-of-block backward jump (allowed)
do
  local function f()
    do local a = 1 end
    ::end_block:: return 42
  end
  assertEqual(f(), 42)
end

-- Multi-label end sentinel (extra labels + semicolons)
do
  local x
  do
    local y = 12
    goto set_x
    ::after_set:: x = x + 1; goto check_x
    ::set_x:: x = y; goto after_set
  end
  ::check_x:: ::check_x_extra:: assertEqual(x, 13)
end

-- Skip local declarations (labels before local)
do
  goto after_vars
  local a = 23
  a = a
  ::after_vars:: ;
end

-- While loop labels inside loop; external exit
do
  local global = 13
  while true do
    goto exit_loop
    goto loop_label
    goto loop_label
    local inner = 45
    ::loop_label:: ;;;
  end
  ::exit_loop:: assertEqual(global, 13)
end

-- Mixed returns via labels (branch selection)
local function branch_test(a)
  if a == 1 then
    goto l1
  elseif a == 2 then
    goto l2
  elseif a == 3 then
    goto l3
  elseif a == 4 then
    goto l1
    ::l1:: a = a + 1
  else
    goto l4
    ::l4_a:: a = a * 2; goto l4_b
    ::l4:: goto l4_a
    ::l4_b::
  end
  do return a end
  ::l2:: do return "2" end
  ::l3:: do return "3" end
  ::l1:: return "1"
end

assertEqual(branch_test(1), "1")
assertEqual(branch_test(2), "2")
assertEqual(branch_test(3), "3")
assertEqual(branch_test(4), 5)
assertEqual(branch_test(5), 10)

-- Same label name in different functions (allowed)
do
  local function f() ::same:: return 1 end
  local function g() ::same:: return 2 end
  assertEqual(f(), 1)
  assertEqual(g(), 2)
end

-- Upward jump into ancestor block
do
  local n = 0
  ::up_loop::
  n = n + 1
  if n < 2 then
    do
      goto up_loop
    end
  end
  assertEqual(n, 2)
end

-- Escape potential infinite loop via early goto
do
  goto escaped
  ::inf_a:: goto inf_a
  ::inf_b:: goto inf_c
  ::inf_c:: goto inf_b
  ::escaped:: ;
end

-- Sequential building of array with gotos
local function build_array()
  local a = {}
  goto start
  ::add_one:: a[#a + 1] = 1; goto add_two
  ::add_two:: a[#a + 1] = 2; goto add_five
  ::start:: ::start_extra:: a[#a + 1] = 3; goto add_one
  ::add_four:: a[#a + 1] = 4; goto finish
  ::add_five:: a[#a + 1] = 5; goto add_four
  ::finish:: return a
end

do
  local arr = build_array()
  assertEqual(arr[1], 3)
  assertEqual(arr[2], 1)
  assertEqual(arr[3], 2)
  assertEqual(arr[4], 5)
  assertEqual(arr[5], 4)
end

-- Long label name (basic acceptance)
do
  ::very_long_label_end_marker_example_for_stress_test_end::
  local ok = true
  assertEqual(ok, true)
end

-- Sibling block visibility (cannot jump to sibling label)
do
  local function f()
    do ::sib:: end
    do goto sib end
  end
  expectError(f, "no visible label 'sib'", "cannot jump to sibling label")
end

-- Forward to inside for: label not visible from outside
do
  local function f()
    goto in_for
    for i = 1, 2 do
      ::in_for:: return i
    end
  end
  expectError(f, "no visible label 'in_for'")
end

-- Forward to inside for-in: label not visible from outside
do
  local function f()
    goto in_forin
    for k, v in pairs({1,2}) do
      ::in_forin:: return k
    end
  end
  expectError(f, "no visible label 'in_forin'")
end

-- Repeat block end scope jump (illegal)
do
  local function f()
    repeat
      goto end_repeat
      local z = 10
      ::end_repeat::
    until z == 0
  end
  expectError(f, "jumps into the scope")
end

-- Nested upward multi-jump
do
  local count = 0
  ::multi_up::
  count = count + 1
  if count < 3 then
    do
      do
        goto multi_up
      end
    end
  end
  assertEqual(count, 3)
end

-- Late label after inner block; allowed upward visibility
do
  local function f()
    do
      goto late_label
    end
    ::late_label:: return 1
  end
  assertEqual(f(), 1)
end

-- Upward goto to ancestor label inside nested block
do
  local function f()
    local n = 0
    ::ancestor:: n = n + 1
    do
      if n < 2 then goto ancestor end
    end
    return n
  end
  assertEqual(f(), 2)
end

-- Same label name reused in separate blocks (legal)
do
  local function f()
    local t = {}
    do ::lbl:: table.insert(t, "first") end
    do ::lbl:: table.insert(t, "second") end
    return t
  end
  local r = f()
  assertEqual(r[1], "first")
  assertEqual(r[2], "second")
end

-- Label visibility restricted to branch (goto in else to label in then)
do
  local function f_bad(flag)
    if flag then
      ::branch_label:: return "yes"
    else
      goto branch_label
    end
  end
  expectError(function() f_bad(true) end, "no visible label 'branch_label'")
  expectError(function() f_bad(false) end, "no visible label 'branch_label'")
end

-- Label visible to both branches (label placed after the if)
do
  local function f_good(flag)
    if not flag then goto after end
    ::after:: return "yes"
  end
  assertEqual(f_good(true), "yes")
  assertEqual(f_good(false), "yes")
end

-- Jump over multiple locals but not into them (allowed backward)
do
  local function f()
    local a = 1
    ::compute::
    local b = a + 1
    local c = b + 1
    if c < 5 then
      a = c
      goto compute -- backward: allowed
    end
    return c
  end
  assertEqual(f(), 5)
end

-- Label at end of block with only semicolons after (safe end)
do
  local function f()
    local x = 10
    ::end_ok:: ; ; ;
    return x
  end
  assertEqual(f(), 10)
end

-- Goto into local scope through chained labels (error)
do
  local function f()
    goto after_chain
    local p = 99
    ::chain_a::::chain_b::::after_chain:: return p
  end
  expectError(f, "jumps into the scope")
end

-- Cross-function goto (outer to inner label, invalid)
do
  local function inner()
    ::in_label:: return 1
  end
  local function outer()
    goto in_label
    return 0
  end
  expectError(outer, "no visible label 'in_label'")
end

-- Cross-function goto (inner to outer label, invalid)
do
  local function outer()
    ::outer_label:: ;
    local function inner()
      goto outer_label
    end
    return inner()
  end
  expectError(outer, "no visible label 'outer_label'")
end

-- Cross-function goto (outer after defining inner label, invalid)
do
  local function outer()
    local function inner()
      ::nested_label:: return 2
    end
    goto nested_label
  end
  expectError(outer, "no visible label 'nested_label'")
end

-- Forward goto to label not safe end (fails)
do
  local function f()
    goto after_locals
    local a = 1
    ::after_locals:: local b = 2
    return b
  end
  expectError(f, "jumps into the scope")
end

-- Repeat safe-end style still illegal
do
  local function f()
    repeat
      goto done
      local x = 1
      ::done:: ;
    until x == 0
  end
  expectError(f, "jumps into the scope")
end

-- goto to correct label when nested
::l1::
do
  local v = 0
  goto l1
  v = -1
  ::l1:: -- inner label shadowing the outer label
  v = v + 1
  assertEqual(v, 1)
end

-- regression test for Lua 5.2 bug
do
  local x
  ::l1::
  local y
  assert(y == nil)
  y = true
  if x == nil then
    x = 1
    goto l1
  else
    x = x + 1
  end
  assert(x == 2 and y == true)
end


================================================
FILE: client/space_lua/labels.ts
================================================
// Goto/label resolution and validation for function bodies

import type {
  ASTCtx,
  LuaBlock,
  LuaForInStatement,
  LuaForStatement,
  LuaGotoStatement,
  LuaIfStatement,
  LuaLabelStatement,
  LuaLocalStatement,
  LuaRepeatStatement,
  LuaWhileStatement,
} from "./ast.ts";
import { LuaAttribute } from "./ast.ts";
import { asBlock } from "./ast_narrow.ts";

type BlockGotoMeta = {
  labels: Map<string, number>; // in this block
};

type FunctionMeta = {
  // Stores per-block data and drops entries automatically when a block
  // is no longer used.
  blockMeta: WeakMap<LuaBlock, BlockGotoMeta>;
  funcHasGotos: boolean;
};

class LabelResolveError extends Error {
  constructor(msg: string, public astCtx: ASTCtx) {
    super(msg);
  }
}

// Cache
const functionMetaByRoot = new WeakMap<LuaBlock, FunctionMeta>();
const functionMetaByAnyBlock = new WeakMap<LuaBlock, FunctionMeta>();

export function getBlockGotoMeta(
  block: LuaBlock,
): (BlockGotoMeta & { funcHasGotos: boolean }) | undefined {
  let fm = functionMetaByAnyBlock.get(block);
  if (!fm) {
    fm = resolveFunction(block);
  }
  const bm = fm.blockMeta.get(block);
  if (!bm) {
    return undefined;
  }
  return { ...bm, funcHasGotos: fm.funcHasGotos };
}

type LocalID = number;

type GotoInfo = {
  node: LuaGotoStatement;
  active: Set<LocalID>;
  block: LuaBlock;
};

type ValidationCtx = {
  labelActiveByBlock: WeakMap<LuaBlock, Map<string, Set<LocalID>>>;
  labelLocByBlock: WeakMap<
    LuaBlock,
    Map<string, { index: number; ctx: ASTCtx }>
  >;
  gotos: GotoInfo[];
  hasGoto: boolean;
  nextLocalId: number;
  closeLocals: Set<LocalID>;
};

type BlockRole =
  | "Root"
  | "Do"
  | "If"
  | "While"
  | "Repeat"
  | "For"
  | "ForIn";

function resolveFunction(root: LuaBlock): FunctionMeta {
  const existing = functionMetaByRoot.get(root);
  if (existing) {
    return existing;
  }

  const blockMeta = new WeakMap<LuaBlock, BlockGotoMeta>();
  const vctx: ValidationCtx = {
    labelActiveByBlock: new WeakMap(),
    labelLocByBlock: new WeakMap(),
    gotos: [],
    hasGoto: false,
    nextLocalId: 1,
    closeLocals: new Set<LocalID>(),
  };

  const seenBlocks = new Set<LuaBlock>();
  const parentByBlock = new WeakMap<LuaBlock, LuaBlock | undefined>();
  const roleByBlock = new WeakMap<LuaBlock, BlockRole>();

  processBlock(
    root,
    undefined,
    "Root",
    new Set<LocalID>(),
    blockMeta,
    vctx,
    seenBlocks,
    parentByBlock,
    roleByBlock,
  );

  // Validate gotos
  for (const g of vctx.gotos) {
    const target = g.node.name;

    // Search current block for the label, then ancestors
    let searchBlock: LuaBlock | undefined = g.block;
    let labelIndex: number | undefined;
    let labelDefBlock: LuaBlock | undefined;

    while (searchBlock) {
      const meta = blockMeta.get(searchBlock);
      if (meta && meta.labels.has(target)) {
        labelIndex = meta.labels.get(target);
        labelDefBlock = searchBlock;
        break;
      }
      searchBlock = parentByBlock.get(searchBlock);
    }

    if (labelIndex === undefined || !labelDefBlock) {
      throw new LabelResolveError(
        `no visible label '${target}' for goto`,
        g.node.ctx,
      );
    }

    const activeMap = vctx.labelActiveByBlock.get(labelDefBlock);
    const locMap = vctx.labelLocByBlock.get(labelDefBlock);
    if (!activeMap || !locMap) {
      throw new LabelResolveError(
        `no visible label '${target}' for goto`,
        g.node.ctx,
      );
    }

    const lset = activeMap.get(target);
    const lloc = locMap.get(target);
    if (!lset || !lloc) {
      throw new LabelResolveError(
        `no visible label '${target}' for goto`,
        g.node.ctx,
      );
    }

    // Local scope forward jump check
    let entersLocalScope = false;
    let entersCloseScope = false;

    for (const id of lset) {
      if (!g.active.has(id)) {
        entersLocalScope = true;
        if (vctx.closeLocals.has(id)) {
          entersCloseScope = true;
          break;
        }
      }
    }

    if (entersCloseScope) {
      throw new LabelResolveError(
        `goto '${target}' jumps into the scope of a local variable`,
        g.node.ctx,
      );
    }

    if (entersLocalScope) {
      const safeEnd = isSafeEndLabel(labelDefBlock, lloc.index, roleByBlock);
      if (!safeEnd) {
        throw new LabelResolveError(
          `goto '${target}' jumps into the scope of a local variable`,
          g.node.ctx,
        );
      }
    }
  }

  const fm: FunctionMeta = {
    blockMeta,
    funcHasGotos: vctx.hasGoto,
  };

  functionMetaByRoot.set(root, fm);
  for (const block of seenBlocks) {
    functionMetaByAnyBlock.set(block, fm);
  }

  return fm;
}

function isSafeEndLabel(
  block: LuaBlock,
  labelIndex: number,
  roleByBlock: WeakMap<LuaBlock, BlockRole>,
): boolean {
  const role = roleByBlock.get(block);
  if (role === "Repeat") {
    return false;
  }
  for (let i = labelIndex + 1; i < block.statements.length; i++) {
    const t = block.statements[i].type;
    if (t !== "Label" && t !== "Semicolon") {
      return false;
    }
  }
  return true;
}

function cloneSet<T>(s: Set<T>): Set<T> {
  const c = new Set<T>();
  for (const v of s) {
    c.add(v);
  }
  return c;
}

function processBlock(
  block: LuaBlock,
  parent: LuaBlock | undefined,
  role: BlockRole,
  active: Set<LocalID>,
  blockMeta: WeakMap<LuaBlock, BlockGotoMeta>,
  vctx: ValidationCtx,
  seen: Set<LuaBlock>,
  parentByBlock: WeakMap<LuaBlock, LuaBlock | undefined>,
  roleByBlock: WeakMap<LuaBlock, BlockRole>,
): void {
  const labels = new Map<string, number>();
  blockMeta.set(block, { labels });

  seen.add(block);
  parentByBlock.set(block, parent);
  roleByBlock.set(block, role);

  const labelActiveMap = new Map<string, Set<LocalID>>();
  const labelLocMap = new Map<string, { index: number; ctx: ASTCtx }>();
  vctx.labelActiveByBlock.set(block, labelActiveMap);
  vctx.labelLocByBlock.set(block, labelLocMap);

  const curActive = cloneSet(active);

  const stmts = block.statements;
  for (let i = 0; i < stmts.length; i++) {
    const s = stmts[i];
    switch (s.type) {
      case "Label": {
        const lab = s as LuaLabelStatement;
        if (labels.has(lab.name)) {
          throw new LabelResolveError(
            `label '${lab.name}' already defined`,
            lab.ctx,
          );
        }
        labels.set(lab.name, i);
        const actSet = cloneSet(curActive);
        labelActiveMap.set(lab.name, actSet);
        labelLocMap.set(lab.name, { index: i, ctx: lab.ctx });
        break;
      }
      case "Goto": {
        const g = s as LuaGotoStatement;
        vctx.hasGoto = true;
        vctx.gotos.push({ node: g, active: cloneSet(curActive), block });
        break;
      }
      case "Local": {
        const l = s as LuaLocalStatement;
        for (let j = 0; j < l.names.length; j++) {
          const id = vctx.nextLocalId++;
          curActive.add(id);

          const isClose =
            l.names[j].attributes?.includes(LuaAttribute.Close) ===
              true;
          if (isClose) {
            vctx.closeLocals.add(id);
          }
        }
        break;
      }
      case "LocalFunction": {
        curActive.add(vctx.nextLocalId++);
        break;
      }
      case "Function": {
        break;
      }
      case "For": {
        const fr = s as LuaForStatement;
        const childActive = cloneSet(curActive);
        childActive.add(vctx.nextLocalId++);
        processBlock(
          fr.block,
          block,
          "For",
          childActive,
          blockMeta,
          vctx,
          seen,
          parentByBlock,
          roleByBlock,
        );
        break;
      }
      case "ForIn": {
        const fi = s as LuaForInStatement;
        const childActive = cloneSet(curActive);
        for (let j = 0; j < fi.names.length; j++) {
          childActive.add(vctx.nextLocalId++);
        }
        processBlock(
          fi.block,
          block,
          "ForIn",
          childActive,
          blockMeta,
          vctx,
          seen,
          parentByBlock,
          roleByBlock,
        );
        break;
      }
      case "While": {
        const w = s as LuaWhileStatement;
        processBlock(
          w.block,
          block,
          "While",
          cloneSet(curActive),
          blockMeta,
          vctx,
          seen,
          parentByBlock,
          roleByBlock,
        );
        break;
      }
      case "Repeat": {
        const r = s as LuaRepeatStatement;
        processBlock(
          r.block,
          block,
          "Repeat",
          cloneSet(curActive),
          blockMeta,
          vctx,
          seen,
          parentByBlock,
          roleByBlock,
        );
        break;
      }
      case "If": {
        const iff = s as LuaIfStatement;
        for (let k = 0; k < iff.conditions.length; k++) {
          processBlock(
            iff.conditions[k].block,
            block,
            "If",
            cloneSet(curActive),
            blockMeta,
            vctx,
            seen,
            parentByBlock,
            roleByBlock,
          );
        }
        if (iff.elseBlock) {
          processBlock(
            iff.elseBlock,
            block,
            "If",
            cloneSet(curActive),
            blockMeta,
            vctx,
            seen,
            parentByBlock,
            roleByBlock,
          );
        }
        break;
      }
      case "Block": {
        const child = asBlock(s);
        processBlock(
          child,
          block,
          "Do",
          cloneSet(curActive),
          blockMeta,
          vctx,
          seen,
          parentByBlock,
          roleByBlock,
        );
        break;
      }
      default: {
        break;
      }
    }
  }
}


================================================
FILE: client/space_lua/language_core_test.lua
================================================
local function assertEqual(a, b, message)
    if a ~= b then
        error("Assertion failed: " .. a .. " is not equal to " .. b .. " " .. message)
    end
end

-- Basic checks
assert(true, "True is true")

-- Basic arithmetic
assert(1 + 2 == 3)
-- Slightly more complex arithmetic with presedence
assert(1 + 2 * 3 == 7)
-- Arithmetic with variables
local a = 1
local b = 2
assert(a + b == 3)

-- Basic string stuff
assert("Hello " .. "world" == "Hello world")
assertEqual([[Hello world]], "Hello world")
assertEqual([==[Hello [[world]]!]==], "Hello [[world]]!")
-- First newline should be eliminated if present
assertEqual([==[
Hello world]==], "Hello world")

-- Various forms of function definitions
function f1()
    return 1
end

assert(f1() == 1)

function sqr(a)
    return a * a
end

assert(sqr(2) == 4)

local f2 = function()
    return 2
end

assert(f2() == 2)

-- Using functions as arguments
function apply(f, a)
    return f(a)
end

assert(apply(sqr, 3) == 9)

-- Supporting multiple return values
function multi_return()
    return 1, 2
end

function addAll(...)
    local total = 0
    for i, v in ipairs({ ... }) do
        total = total + v
    end
    return total
end

local a, b = multi_return()
assert(a == 1 and b == 2)
assert(addAll(1, 2, 3) == 6)
-- Test multiple return values in expressions
assertEqual(addAll(multi_return()), 3)
assertEqual(#{ multi_return() }, 2)

local a, b, c = 0, multi_return()
assert(a == 0 and b == 1 and c == 2)

-- Some table lookups
local t = { a = 1, b = 2 }
assert(t.a == 1 and t.b == 2)
assert(t["a"] == 1 and t["b"] == 2)

-- Unpacking tables
local a, b = unpack({ 1, 2 })
assert(a == 1 and b == 2)

-- Scope tests
local a = 1
do
    local a = 2
    assert(a == 2)
end
assert(a == 1)


-- Comprehensive pairs and ipairs tests
-- Test empty table behavior
local empty = {}
local count = 0
for _ in pairs(empty) do
    count = count + 1
end
assertEqual(count, 0, "pairs should not iterate over empty table")

count = 0
for _ in ipairs(empty) do
    count = count + 1
end
assertEqual(count, 0, "ipairs should not iterate over empty table")

-- Test ipairs with js arrays
local emptyJS = js.window.JSON.parse("[]")
for i, v in ipairs(emptyJS) do
    assert(false, "ipairs should not iterate over empty js array")
end

for i, v in ipairs(js.window.JSON.parse("null") or {}) do
    assert(false, "ipairs should not iterate over empty js array")
end


-- Test mixed key types
local mixed = {
    [1] = "one",
    [2] = "two",
    ["a"] = "alpha",
    ["b"] = "beta",
    [3] = "three"
}

-- Test pairs iteration order (should include all keys)
local pairs_keys = {}
local pairs_values = {}
for k, v in pairs(mixed) do
    table.insert(pairs_keys, k)
    table.insert(pairs_values, v)
end
assertEqual(#pairs_keys, 5, "pairs should iterate over all keys")
assertEqual(#pairs_values, 5, "pairs should iterate over all values")

-- Test ipairs behavior (should only iterate over numeric indices)
local ipairs_keys = {}
local ipairs_values = {}
for k, v in ipairs(mixed) do
    table.insert(ipairs_keys, k)
    table.insert(ipairs_values, v)
end
assertEqual(#ipairs_keys, 3, "ipairs should only iterate over numeric indices")
assertEqual(ipairs_values[1], "one", "first ipairs value should be 'one'")
assertEqual(ipairs_values[2], "two", "second ipairs value should be 'two'")
assertEqual(ipairs_values[3], "three", "third ipairs value should be 'three'")

-- Async function calling
function multiplier(a)
    -- Anything will be async in practice
    return function(b)
        return a * b
    end
end

local multiplier = multiplier(2)
assert(multiplier(3) == 6)

-- Checking of pairs and ipairs


-- Function definitions in tables
ns = { name = "Pete" }
function ns.returnOne()
    return 1
end

function ns:getName()
    return self.name
end

assert(ns.returnOne() == 1)
assert(ns.getName(ns) == "Pete")
-- Support colon syntax
assert(ns:getName() == "Pete")
-- Update the table
ns.name = "John"
assert(ns:getName() == "John")

-- Basic OOP with metatables
Person = {}
Person.__index = Person

-- Constructor
function Person:new(name, age)
    local self = setmetatable({}, Person)
    self.name = name
    -- Initialize object properties
    self.age = age
    return self
end

-- Method for the Person class
function Person:greet()
    return "Hello, my name is " .. self.name .. " and I am " .. self.age .. " years old."
end

-- Create a new instance of the Person class
local p = Person:new("John", 30)
assert(p:greet() == "Hello, my name is John and I am 30 years old.")

-- Metatables test
mt = {
    __index = function(table, key)
        return "Key not found: " .. key
    end
}

t = setmetatable({}, mt)
t.bar = "bar"
assert(t.bar == "bar")
assertEqual(t.foo, "Key not found: foo")

-- Test the __newindex metamethod
t, c = setmetatable(
    {}, {
        __newindex = function(table, key, value)
	    c = c + 1
            rawset(table, key, "Value: " .. value)
	    c = c + 1
        end
    }
), 0

t.name = "John"
-- rawset ignores the metamethod
rawset(t, "age", 100)
assertEqual(t.name, "Value: John")
assertEqual(t.age, 100)
assertEqual(c, 2)

-- Test some of the operator metamethods
t = setmetatable(
    { 1, 2, 3 },
    {
        -- Assume b to be a same length table and add the two
        __add = function(a, b)
            local result = {}
            for i = 1, #a do
                result[i] = a[i] + b[i]
            end
            return result
        end,
        -- Assume b to be a scalar and multiply the table by it
        __mul = function(a, b)
            local result = {}
            for i = 1, #a do
                result[i] = a[i] * b
            end
            return result
        end
    }
)
local added = t + { 4, 5, 6 }
assert(added[1] == 5 and added[2] == 7 and added[3] == 9)
local muliplied = t * 2
assert(muliplied[1] == 2 and muliplied[2] == 4 and muliplied[3] == 6)

-- __call Metamethod

local ts = {
    foo = "ARG 2"
}
local mt = {}

function mt.__call(table, arg1)
    assert(arg1 == "ARG 1")
    assert(table.foo == "ARG 2")
    return "return from metatable"
end

setmetatable(ts, mt)

assert(ts("ARG 1") == "return from metatable")

-- Let's try somethings lightly more complicated, like a deep comparison function implemented in Lua
function deepCompare(t1, t2)
    if t1 == t2 then return true end
    -- If they are the same object, return true
    if type(t1) ~= "table" or type(t2) ~= "table" then return false end
    -- If not both tables, return false
    -- Check if both tables have the same number of keys
    local t1_keys = 0
    local t2_keys = 0
    for k in pairs(t1) do
        t1_keys = t1_keys + 1
    end
    for k in pairs(t2) do
        t2_keys = t2_keys + 1
    end
    if t1_keys ~= t2_keys then return false end

    -- Recursively compare each key-value pair
    for k, v in pairs(t1) do
        if not deepCompare(v, t2[k]) then
            return false
        end
    end

    return true
end

assert(deepCompare({ 1, 2, 3 }, { 1, 2, 3 }))
assert(not deepCompare({ 1, 2, 3 }, { 1, 2 }))
assert(deepCompare({ a = 1, b = 2 }, { a = 1, b = 2 }))
assert(deepCompare(
    { a = { 1, 2, 3 }, b = { 4, 5, 6 } },
    { a = { 1, 2, 3 }, b = { 4, 5, 6 } }
))
assert(not deepCompare(
    { a = { 1, 2, 3 }, b = { 4, 5, 6 } },
    { a = { 1, 2, 3 }, b = { 4, 5, 7 } }
))

-- Closure tests
local function make_counter()
    local count = 0
    return function()
        count = count + 1
        return count
    end
end

local counter1 = make_counter()
local counter2 = make_counter()
assert(counter1() == 1, "First counter first call")
assert(counter1() == 2, "First counter second call")
assert(counter2() == 1, "Second counter should be independent")
assert(counter1() == 3, "First counter maintains state")

-- Test nested closures
local function make_adder(x)
    return function(y)
        return function(z)
            return x + y + z
        end
    end
end

local add5 = make_adder(5)
local add5and2 = add5(2)
assert(add5and2(3) == 10, "Nested closure should maintain all scopes")

-- Test closure variable independence
local function make_value_keeper()
    local value = 0
    return {
        set = function(v) value = v end,
        get = function() return value end
    }
end

local keeper1 = make_value_keeper()
local keeper2 = make_value_keeper()
keeper1.set(5)
keeper2.set(10)
assert(keeper1.get() == 5, "First keeper maintains its own value")
assert(keeper2.get() == 10, "Second keeper maintains its own value")

-- Test closure over loop variables
local functions = {}
for i = 1, 3 do
    functions[i] = function() return i end
end
assert(functions[1]() == 1, "Closure should capture loop variable value at creation time")
assert(functions[2]() == 2, "Each closure should have its own value")
assert(functions[3]() == 3, "Each closure should have its own value")

-- Test closure over mutating variables
local function make_accumulator(initial)
    local sum = initial
    return {
        add = function(x) sum = sum + x end,
        get = function() return sum end
    }
end

local acc = make_accumulator(5)
acc.add(3)
acc.add(2)
assert(acc.get() == 10, "Accumulator should maintain state through multiple calls")

-- Test closures with upvalues modified in nested scopes
local function make_counter_with_reset()
    local count = 0
    return {
        increment = function()
            local old = count
            count = count + 1
            return old
        end,
        reset = function()
            local old = count
            count = 0
            return old
        end
    }
end

local counter = make_counter_with_reset()
assert(counter.increment() == 0)
assert(counter.increment() == 1)
local final = counter.reset()
assert(final == 2, "Reset should return last value")
assert(counter.increment() == 0, "Counter should start fresh after reset")

-- Test custom iterators
-- Basic iterator that counts down from n to 1
local function countdown(n)
    local count = n
    return function()
        if count > 0 then
            local current = count
            count = count - 1
            return current
        end
    end
end

-- Test basic iterator usage
local sum = 0
for num in countdown(3) do
    sum = sum + num
end
assert(sum == 6, "Countdown iterator should sum to 6 (3+2+1)")

-- Iterator that returns even numbers from an array
local function even_values(arr)
    local index = 0
    return function()
        repeat
            index = index + 1
            if index > #arr then return nil end
            if arr[index] % 2 == 0 then
                return index, arr[index]
            end
        until false
    end
end

-- Test array iterator
local arr = { 1, 2, 3, 4, 6, 7, 8 }
local count = 0
local sum = 0
for i, v in even_values(arr) do
    count = count + 1
    sum = sum + v
end
assert(count == 4, "Should find 4 even numbers")
assert(sum == 20, "Sum of even numbers should be 20 (2+4+6+8)")

-- Range iterator with step
local function range(from, to, step)
    step = step or 1
    local current = from
    return function()
        if current > to then
            return nil
        end
        local value = current
        current = current + step
        return value
    end
end

-- Test range iterator with different steps
local function collect_range(from, to, step)
    local values = {}
    for v in range(from, to, step) do
        table.insert(values, v)
    end
    return values
end

local tblNew = { 1, 2, 3 }
table.insert(tblNew, 4)
assertEqual(#tblNew, 4)
assertEqual(tblNew[4], 4)

local values1 = collect_range(1, 5, 2)
assert(#values1 == 3, "Range with step 2 should return 3 values")
assert(values1[1] == 1 and values1[2] == 3 and values1[3] == 5, "Range values with step 2 should be correct")

local values2 = collect_range(10, 15)
assert(#values2 == 6, "Range with default step should return 6 values")
assert(values2[1] == 10 and values2[6] == 15, "Range values with default step should be correct")

local values3 = collect_range(1, 10, 3)
assert(#values3 == 4, "Range with step 3 should return 4 values")
assert(values3[1] == 1 and values3[2] == 4 and values3[3] == 7 and values3[4] == 10,
    "Range values with step 3 should be correct")

-- Test nested iterators
local function grid(rows, cols)
    local row = 0
    return function()
        row = row + 1
        if row <= rows then
            local col = 0
            return function()
                col = col + 1
                if col <= cols then
                    return row, col
                end
            end
        end
    end
end

local points = {}
for row_iter in grid(2, 3) do
    for r, c in row_iter do
        table.insert(points, { r, c })
    end
end

assert(#points == 6, "Grid should generate 6 points")
assert(points[1][1] == 1 and points[1][2] == 1, "First point should be (1,1)")
assert(points[6][1] == 2 and points[6][2] == 3, "Last point should be (2,3)")

-- Test for functions with variable number of arguments
function sum(...)
    local total = 0
    for i, v in ipairs({ ... }) do
        total = total + v
    end
    return total
end

assertEqual(sum(1, 2, 3), 6)
assertEqual(sum(1, 2, 3, 4, 5), 15)

local data = { { name = "John", favorite = { color = "blue" } }, { name = "Jane" } }
assertEqual(type(data[1].favorite), "table")
assertEqual(data[1].favorite.color, "blue")

local r = query [[from p = data where type(p.favorite) == "table" and p.favorite.color == "blue"]]
assertEqual(#r, 1)
assertEqual(r[1].name, "John")

-- Test string to number coercion for arithmetical operations
assertEqual(-1*' 16 '    ==  -16,  true)
assertEqual(0-'0X10'     ==  -16,  true)
assertEqual('-0x10'/1    ==  -16,  true)
assertEqual('-0X10p0'//1 ==  -16,  true)
assertEqual('0x10P0'%15  ==   1,   true)
assertEqual('0X10P-1'^-1 == 0.125, true)
assertEqual(-'16'        ==  -16,  true)

-- select tests
-- Base case
local a, b, c = select(1, 1, 2, 3)
assertEqual(a, 1)
assertEqual(b, 2)
assertEqual(c, 3)
-- One index later
local b, c = select(2, 1, 2, 3)
assertEqual(b, 2)
assertEqual(c, 3)
-- Negative index
local b, c = select(-2, 1, 2, 3)
assertEqual(b, 2)
assertEqual(c, 3)
-- Special "#" case
assertEqual(select("#", 1, 2, 3), 3)

-- next tests
local tbl = {a = "a value", b = "b value"}
-- base cases
assertEqual(next(nil), nil, "nil table case")
assertEqual(next({}), nil, "nil table case")
-- find first key (although this is not specified, we know this will be "a" in space lua)
local k, v = next(tbl)
assertEqual(k, "a")
assertEqual(v, "a value")
-- find the second key
local k, v = next(tbl, k)
assertEqual(k, "b")
assertEqual(v, "b value")
-- and now we should be done
assertEqual(next(tbl, k), nil)

-- Some more vararg verification
function varArgTest(a0, ...)
    -- ... gets the multi arg treatment
    local a1, a2 = ...
    assertEqual(a0, 1)
    assertEqual(a1, 2)
    assertEqual(a2, 3)
end

varArgTest(1, 2, 3, 4)

-- Some `rawlen` tests

-- Strings
assertEqual(rawlen(""), 0, "rawlen on empty string")
assertEqual(rawlen("abc"), 3, "rawlen on non-empty string")

-- Tables
assertEqual(rawlen({}), 0, "rawlen on empty table")
assertEqual(rawlen({1, 2, 3}), 3, "rawlen on array table")

-- `rawlen` ignores `__len` metamethod
do
  local t = {}
  setmetatable(t, { __len = function(_) return 42 end })
  assertEqual(rawlen(t), 0, "rawlen ignores __len (no array part)")
  t[1] = "x"; t[2] = "y"
  assertEqual(rawlen(t), 2, "rawlen returns raw array-part length")
end

-- JS arrays also have raw length
do
  local a0 = js.window.JSON.parse("[]")
  assertEqual(rawlen(a0), 0, "rawlen on empty JS array")

  local a4 = js.window.JSON.parse("[1,2,3,4]")
  assertEqual(rawlen(a4), 4, "rawlen on JS array length 4")
end

-- Non-string/non-table must error with proper message
local function expect_rawlen_error(v, typeName)
  local ok, err = pcall(
    function()
      return rawlen(v)
    end
  )

  assertEqual(ok, false, "rawlen must error on "
    .. tostring(typeName))

  local s = tostring(err)

  assert(
    string.find(s, "bad argument #1 to 'rawlen'", 1, true) ~= nil,
    "error must mention rawlen"
  )

  assert(
    string.find(s, typeName, 1, true) ~= nil,
    "error must mention type name"
  )
end

expect_rawlen_error(nil, "nil")
expect_rawlen_error(1, "number")
expect_rawlen_error(false, "boolean")
expect_rawlen_error(function() end, "function")

-- Some `rawget` tests

-- Tables
do
  local t = { "a", "b" }

  assertEqual(rawget(t, 1), "a")
  assertEqual(rawget(t, 2), "b")
  assertEqual(rawget(t, 3), nil, "rawget missing key returns nil")
end

-- `rawget` ignores `__index` metamethod
do
  local t = {}

  setmetatable(
    t,
    {
      __index = function()
        return "X"
      end
    }
  )

  assertEqual(rawget(t, "k"), nil, "rawget must ignore __index")

  t.k = "v"
  assertEqual(rawget(t, "k"), "v")
end

-- JS arrays
do
  local a = js.window.JSON.parse("[1, 2, 3]")

  assertEqual(rawget(a, 1), 1)
  assertEqual(rawget(a, 2), 2)
  assertEqual(rawget(a, 4), nil)
end

-- Plain JS objects
do
  local o = js.window.JSON.parse('{ "a": 1, "b": null }')

  assertEqual(rawget(o, "a"), 1)
  assertEqual(rawget(o, "b"), nil)
  assertEqual(rawget(o, "c"), nil)
end

-- `rawget` argument errors
local function expect_rawget_error(v, typeName)
  local ok, err = pcall(
    function()
      return rawget(v, "k")
    end
  )

  assertEqual(ok, false, "rawget must error on " .. tostring(typeName))

  local s = tostring(err)

  assert(
    string.find(s, "bad argument #1 to 'rawget'", 1, true) ~= nil,
    "error must mention rawget"
  )

  assert(
    string.find(s, typeName, 1, true) ~= nil,
    "error must mention type name"
  )
end

expect_rawget_error(nil, "nil")
expect_rawget_error(1, "number")
expect_rawget_error(false, "boolean")
expect_rawget_error(function() end, "function")

-- Some `rawequal` tests
do
  -- Primitives
  assertEqual(rawequal(1, 1), true)
  assertEqual(rawequal(1, 2), false)
  assertEqual(rawequal("x", "x"), true)
  assertEqual(rawequal("x", "y"), false)

  -- Tables: identity vs structural equality
  local t1 = {}
  local t2 = {}

  assertEqual(rawequal(t1, t1), true)
  assertEqual(rawequal(t1, t2), false)

  -- NaN is not equal to NaN
  local nan = 0/0

  assertEqual(rawequal(nan, nan), false)
end

-- Some `..` (concatenation) tests

-- Strings and numbers
assertEqual("a" .. "b", "ab")
assertEqual(1 .. "b", "1b")
assertEqual("a" .. 2, "a2")
assertEqual(4 .. 2, "42")
assertEqual("123" .. 45, "12345")

-- Multi-return (only first value is used)
do
  local function f()
    return "X", "Y"
  end

  assertEqual("A" .. f(), "AX")

  local function g()
    return 1, 2
  end

  assertEqual(g() .. "X", "1X")
end

local function expect_concat_nil_error(lhs, rhs)
  local ok, err = pcall(
    function()
      return lhs .. rhs
    end
  )

  assertEqual(ok, false, "concat must error on nil")
end

local function expect_concat_type_error(lhs, rhs)
  local ok, err = pcall(
    function()
      return lhs .. rhs
    end
  )

  assertEqual(ok, false, "concat must error on non-string/non-number")
end

-- Nil on either side
expect_concat_nil_error(nil, "x")
expect_concat_nil_error("x", nil)
expect_concat_nil_error(nil, 1)
expect_concat_nil_error(1, nil)
expect_concat_nil_error(nil, nil)

-- Non-string and non-number must error
expect_concat_type_error(true, "x")
expect_concat_type_error("x", false)
expect_concat_type_error(true, true)

do
  local t = {}

  expect_concat_type_error(t, "x")
  expect_concat_type_error("x", t)
  expect_concat_type_error(t, t)
end

-- JS arrays and JS objects behave as tables (no concat)
do
  local arr = js.window.JSON.parse("[1, 2]")

  expect_concat_type_error(arr, "x")
  expect_concat_type_error("x", arr)
  expect_concat_type_error(arr, arr)

  local obj = js.window.JSON.parse('{ "a": 1 }')

  expect_concat_type_error(obj, "x")
  expect_concat_type_error("x", obj)
  expect_concat_type_error(obj, obj)
end

-- `__tostring` alone does not relax concat rules
do
  local t = {}

  setmetatable(
    t,
    {
      __tostring = function(_)
        return "T"
      end
    }
  )

  expect_concat_type_error(t, "x")
  expect_concat_type_error("x", t)
  expect_concat_type_error(t, t)
end

-- `__concat` metamethod cases
do
  local L = {}

  setmetatable(
    L,
    {
      __concat = function(a, b)
        return "LEFT"
      end
    }
  )

  assertEqual(L .. "x", "LEFT")

  local R = {}

  setmetatable(
    R,
    {
      __concat = function(a, b)
        return "RIGHT"
      end
    }
  )

  assertEqual("x" .. R, "RIGHT")

  assertEqual(L .. R, "LEFT",
    "left-side __concat must be preferred when both defined")
end

-- `__concat` result handling (only first value used)
do
  local M = {}

  setmetatable(
    M,
    {
      __concat = function(a, b)
        return "A", "B"
      end
    }
  )

  assertEqual(M .. "x", "A",
    "__concat must use only first return value")
end


================================================
FILE: client/space_lua/len_test.lua
================================================
local function assertEquals(actual, expected, msg)
  if actual ~= expected then
    error('assertEquals failed: ' .. msg)
  end
end

local function assertThrows(substr, fn)
  local ok, err = pcall(fn)
  if ok then
    error("expected error containing: " .. substr)
  end

  err = tostring(err)
  if not string.find(err, substr, 1, true) then
    error("error message mismatch: got: " .. err)
  end
end

-- 1. Strings: raw length, ignore metamethods
assertEquals(#"", 0, "empty string length")
assertEquals(#"abc", 3, "string length")

-- 2. Tables (raw length for sequence or `__len`)
assertEquals(#{}, 0, "empty table length")
assertEquals(#{1,2,3}, 3, "array length")

do
  local t = {}

  setmetatable(t,
  {
    __len = function(_)
      return 42
    end,
  })

  assertEquals(#t, 42, "__len should override raw table length")
end

-- 2.1. Metamethod result handling
do
  local t = {}

  setmetatable(t,
  {
    __len = function(_)
      return 42, 1, 2, 3
    end,
  })

  assertEquals(#t, 42, "__len must use only first return value")
end

-- 2.2. Raw length when no array part
do
  local t = {
    a = 1,
    b = 2,
  }

  assertEquals(#t, 0, "table with only non-integer keys has length 0")
end

-- 2.3. Length when both array and key-value parts
do
  local t = {
    a = 1,
    b = 2,
    1,
    2,
    c = 3,
    3,
  }

  assertEquals(#t, 3, "table with mixed non-integer and integer keys")
end

-- 3. Non-strings and non-tables
assertThrows(
  "attempt to get length of a nil value",
  function()
    return #nil
  end
)

assertThrows(
  "attempt to get length of a number value",
  function()
    return #1
  end
)

assertThrows(
  "attempt to get length of a boolean value",
  function()
    return #false
  end
)

assertThrows(
  "attempt to get length of a function value",
  function()
    local f = function() end
    return #f
  end
)

-- 4. rawlen() semantics
do
  local t = { 1, 2, 3 }

  setmetatable(t, {
    __len = function(_) return 99 end,
  })

  assertEquals(#t, 99, "#t uses __len")
  assertEquals(rawlen(t), 3, "rawlen(t) ignores __len")
end

-- 5. Trailing-nil shrinking behavior
do
  local t = { 1, 2, 3 }
  assertEquals(#t, 3, "initial sequence length")

  t[3] = nil
  assertEquals(#t, 2, "sequence length shrinks when last element is set to nil")
  assertEquals(rawlen(t), 2, "rawlen shrinks with trailing nil (array part shrinks)")

  t[2] = nil
  assertEquals(#t, 1, "sequence length shrinks again after removing new last element")
  assertEquals(rawlen(t), 1, "rawlen shrinks again after trailing nil")
end

-- 6. rawlen on strings
assertEquals(rawlen("abc"), 3, "rawlen on strings")

-- 7. rawlen(t) must ignore the __len metamethod
local t = {1,2,3}

setmetatable(t, {
  __len = function()
    return 99 end
})

assert(#t == 99)
assert(rawlen(t) == 3)


================================================
FILE: client/space_lua/lua.test.ts
================================================
import { parse } from "./parse.ts";
import { luaBuildStandardEnv } from "./stdlib.ts";
import { LuaEnv, LuaRuntimeError, LuaStackFrame } from "./runtime.ts";
import { evalStatement } from "./eval.ts";
import { assert } from "@std/assert/assert";
import { fileURLToPath } from "node:url";

Deno.test("[Lua] Core language", async () => {
  await runLuaTest("./language_core_test.lua");
});

Deno.test("[Lua] Core language (labels and goto)", async () => {
  await runLuaTest("./goto_test.lua");
});

Deno.test("[Lua] Core language (length)", async () => {
  await runLuaTest("./len_test.lua");
});

Deno.test("[Lua] Core language (truthiness)", async () => {
  await runLuaTest("./truthiness_test.lua");
});

Deno.test("[Lua] Core language (arithmetic)", async () => {
  await runLuaTest("./arithmetic_test.lua");
});

Deno.test("[Lua] Core language (metamethods)", async () => {
  await runLuaTest("./metamethods_test.lua");
});

Deno.test("[Lua] Load tests", async () => {
  await runLuaTest("./stdlib/load_test.lua");
});

Deno.test("[Lua] Core language (length)", async () => {
  await runLuaTest("./len_test.lua");
});

Deno.test("[Lua] Format tests", async () => {
  await runLuaTest("./stdlib/format_test.lua");
});

Deno.test("[Lua] String to number tests", async () => {
  await runLuaTest("./tonumber_test.lua");
});

Deno.test("[Lua] Pattern tests", async () => {
  await runLuaTest("./stdlib/pattern_test.lua");
});

Deno.test("[Lua] String tests", async () => {
  await runLuaTest("./stdlib/string_test.lua");
});

Deno.test("[Lua] Space Lua tests", async () => {
  await runLuaTest("./stdlib/space_lua_test.lua");
});

Deno.test("[Lua] OS tests", async () => {
  await runLuaTest("./stdlib/os_test.lua");
});

Deno.test("[Lua] Math tests", async () => {
  await runLuaTest("./stdlib/math_test.lua");
});

Deno.test("[Lua] JS tests", async () => {
  await runLuaTest("./stdlib/js_test.lua");
});

Deno.test("[Lua] Global functions tests", async () => {
  await runLuaTest("./stdlib/global_test.lua");
});

Deno.test("[Lua] Encoding functions tests", async () => {
  await runLuaTest("./stdlib/encoding_test.lua");
});

Deno.test("[Lua] Crypto functions tests", async () => {
  await runLuaTest("./stdlib/crypto_test.lua");
});

Deno.test("[Lua] Lume functions tests", async () => {
  await runLuaTest("./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: client/space_lua/lume_test.lua
================================================
local function assertEqual(a, b, message)
    if a ~= b then
        error("Assertion failed: " .. a .. " is not equal to " .. b .. " " .. message)
    end
end

lume = lume or {}

local getiter = function(x)
  if lume.isarray(x) then
    return ipairs
  elseif type(x) == "table" then
    return pairs
  end
  error("expected table", 3)
end

function lume.isarray(x)
  return type(x) == "table" and x[1] ~= nil
end

function lume.concat(...)
  local rtn = {}
  for i = 1, select("#", ...) do
    local t = select(i, ...)
    if t ~= nil then
      local iter = getiter(t)
      for _, v in iter(t) do
        rtn[#rtn + 1] = v
      end
    end
  end
  return rtn
end

local r = lume.concat({1, 2}, {3, 4})
assertEqual(#r, 4)
assertEqual(r[1], 1)
assertEqual(r[4], 4)

================================================
FILE: client/space_lua/metamethods_test.lua
================================================
local function assertEquals(actual, expected, message)
  if actual ~= expected then
    error('Assertion failed: ' .. message
      .. ' (expected ' .. tostring(expected)
      .. ', got ' .. tostring(actual) .. ')')
  end
end

local function assertTrue(v, message)
  if v ~= true then
    error('Assertion failed: ' .. message .. ' (expected true)')
  end
end

local function assertFalse(v, message)
  if v ~= false then
    error('Assertion failed: ' .. message .. ' (expected false)')
  end
end

local function assertThrows(msg_substr, fn)
  local ok, err = pcall(fn)

  if ok then
    error('Assertion failed: expected error containing "'
      .. msg_substr .. '"')
  end

  if type(err) ~= 'string' then
    err = tostring(err)
  end

  if not string.find(err, msg_substr, 1, true) then
    error('Assertion failed: expected error message to contain "'
      .. msg_substr .. '", got: "' .. err .. '"')
  end
end

-- 1. Arithmetic + bitwise metamethods: values + multi-return trimming
do
  local mt = {}

  mt.__add = function(a, b) return a.tag .. '+' .. b.tag end
  mt.__sub = function(a, b) return a.tag .. '-' .. b.tag end
  mt.__mul = function(a, b) return a.tag .. '*' .. b.tag end
  mt.__div = function(a, b) return a.tag .. '/' .. b.tag end
  mt.__pow = function(a, b) return a.tag .. '^' .. b.tag end
  mt.__idiv = function(a, b) return a.tag .. '//' .. b.tag end
  mt.__mod = function(a, b) return a.tag .. '%' .. b.tag end
  mt.__unm = function(a) return '(-' .. a.tag .. ')' end
  mt.__bnot = function(a) return '(~' .. a.tag .. ')' end
  mt.__band = function(a, b) return a.tag .. '&' .. b.tag end
  mt.__bor = function(a, b) return a.tag .. '|' .. b.tag end
  mt.__bxor = function(a, b) return a.tag .. '~' .. b.tag end
  mt.__shl = function(a, b) return a.tag .. '<<' .. b.tag end
  mt.__shr = function(a, b) return a.tag .. '>>' .. b.tag end

  local a = setmetatable({ tag = 'A' }, mt)
  local b = setmetatable({ tag = 'B' }, mt)

  assertEquals(a + b, 'A+B', '__add produced correct value')
  assertEquals(a - b, 'A-B', '__sub produced correct value')
  assertEquals(a * b, 'A*B', '__mul produced correct value')
  assertEquals(a / b, 'A/B', '__div produced correct value')
  assertEquals(a ^ b, 'A^B', '__pow produced correct value')
  assertEquals(a // b, 'A//B', '__idiv produced correct value')
  assertEquals(a % b, 'A%B', '__mod produced correct value')

  assertEquals(-a, '(-A)', '__unm produced correct value')
  assertEquals(~a, '(~A)', '__bnot produced correct value')

  assertEquals(a & b, 'A&B', '__band produced correct value')
  assertEquals(a | b, 'A|B', '__bor produced correct value')
  assertEquals(a ~ b, 'A~B', '__bxor produced correct value')
  assertEquals(a << b, 'A<<B', '__shl produced correct value')
  assertEquals(a >> b, 'A>>B', '__shr produced correct value')

  -- Multi-return from metamethod: must take only the first return
  do
    local mt_mr = {
      __add = function(_, _) return 'first', 'second' end,
      __unm = function(_) return 7, 8 end,
      __bnot = function(_) return 9, 10 end,
      __concat = function(_, _) return "c1", "c2" end,
      __len = function(_) return 123, 999 end,
    }

    local x = setmetatable({}, mt_mr)

    assertEquals(x + x, 'first', 'binary operator uses first return value')
    assertEquals(-x, 7, 'unary operator uses first return value')
    assertEquals(~x, 9, 'unary operator uses first return value')
    assertEquals(x .. x, "c1", 'concat uses first return value')
    assertEquals(#x, 123, 'len uses first return value')
  end
end

-- 2. Comparison operators
do
  -- 2.1. No ordering for plain tables
  assertThrows("attempt to compare", function() return ({} < {}) end)
  assertThrows("attempt to compare", function() return ({} <= {}) end)
  assertThrows("attempt to compare", function() return ({} > {}) end)
  assertThrows("attempt to compare", function() return ({} >= {}) end)

  -- 2.2. With metamethods: `__lt` / `__le`
  do
    local mt = {
      __lt = function(a, b) return a.v < b.v end,
      __le = function(a, b) return a.v <= b.v end,
    }
    local function O(v) return setmetatable({ v = v }, mt) end

    assertTrue(O(1) < O(2), "__lt true")
    assertFalse(O(2) < O(1), "__lt false")
    assertTrue(O(2) <= O(2), "__le true")
    assertFalse(O(3) <= O(2), "__le false")
  end

-- 2.3. `__eq` semantics
do
  do
    local a, b = {}, {}
    assertFalse(a == b, "raw equality for distinct tables without __eq is false")
    assertTrue(a == a, "raw equality for same table is true")
  end

  do
    local calls = 0
    local mt = {
      __eq = function(_, _)
        calls = calls + 1
        return true
      end
    }
    local x = setmetatable({}, mt)
    local y = {} -- no metatable

    assertTrue(x == y, "__eq may be used from left operand metatable")
    assertTrue(y == x, "__eq may be used from right operand metatable")
    assertTrue(calls >= 1, "__eq was called at least once")
  end

  do
    local a = setmetatable({ v = 1 }, { __eq = function() return true end })
    local b = setmetatable({ v = 2 }, { __eq = function() return true end })
    assertTrue(a == b, "__eq true/true => true")
    assertFalse(a ~= b, "~= is negation of ==")
  end

  do
    local a = setmetatable({ v = 1 }, { __eq = function() return false end })
    local b = setmetatable({ v = 2 }, { __eq = function() return false end })
    assertFalse(a == b, "__eq false/false => false")
    assertTrue(a ~= b, "~= is negation of ==")
  end
end

-- 2.4. Operand swap rules: `a > b` is `b < a` and `a >= b` is `b <= a`
do
  local mt2 = { __lt = function(a, b) return a.v < b.v end }
  local function P(v) return setmetatable({ v = v }, mt2) end

  local a = P(3)
  local b = P(2)

  assertTrue(b < a, "sanity: b < a uses __lt")
  assertTrue(a > b, '">" uses swapped __lt')
  assertFalse(b > a, '">" uses swapped __lt (false case)')
end

do
  local mt3 = { __le = function(a, b) return a.v <= b.v end }
  local function P(v) return setmetatable({ v = v }, mt3) end

  local a = P(3)
  local b = P(2)

  assertTrue(b <= a, "sanity: b <= a uses __le")
  assertTrue(a >= b, '">=" uses swapped __le')
  assertFalse(b >= a, '">=" uses swapped __le (false case)')
end

-- 2.5. No `__le` fallback via `__lt` in Lua for metamethod comparisons
do
  local mt = { __lt = function(a, b) return a.v < b.v end }
  local function P(v) return setmetatable({ v = v }, mt) end

  local a = P(2)
  local b = P(2)

  assertFalse(a < b, "sanity: 2 < 2 is false via __lt")
  assertThrows("attempt to compare", function() return a <= b end)
end

do
  local mt = {
    __lt = function(a, b) return a.v < b.v end,
    __le = function(a, b) return a.v <= b.v end,
  }
  local function P(v) return setmetatable({ v = v }, mt) end
  local a = P(2)
  local b = P(2)
  assertTrue(a <= b, "__le works when provided")
end
end

-- 3. `rawlen` vs `#` when `__len` exists
do
  local t = setmetatable({ 1, 2, 3 }, { __len = function() return 10 end })
  assertEquals(#t, 10, '# uses __len')
  assertEquals(rawlen(t), 3, 'rawlen ignores __len on tables')
  assertEquals(rawlen("abc"), 3, 'rawlen works on strings')
end

-- 4. `__len`: tables use metamethod; strings are raw length
do
  local t = setmetatable({ x = 20 }, { __len = function(tt) return tt.x end })
  assertEquals(#t, 20, '__len dispatched for tables')
  t.x = "234"
  assertEquals(#t, "234", '__len can return non-number')
end

do
  local s = "abc"
  assertEquals(#s, 3, 'strings ignore __len metamethod')
end

-- 5. `__concat`: produced value and number/table interop
do
  local a = setmetatable({ x = "u" }, {
    __concat = function(l, r) return l.x .. "." .. r.x end
  })
  assertEquals(a .. a, "u.u", '__concat dispatched')
end

do
  local c = {}
  setmetatable(c, {
    __concat = function(a, b)
      if type(a) == "number" then
        assertEquals(b, c, '__concat rhs is c when lhs number')
        return "n..c"
      else
        assertEquals(a, c, '__concat lhs is c when rhs number')
        return "c..n"
      end
    end
  })
  assertEquals(c .. 5, "c..n", "__concat handles table .. number")
  assertEquals(5 .. c, "n..c", "__concat handles number .. table")
end

-- 6. `__call`: produced values
do
  local t = setmetatable({}, {
    __call = function(self, a, b)
      assertEquals(type(self), 'table', '__call self is table')
      return a + b
    end
  })
  assertEquals(t(2, 3), 5, '__call dispatched')
end

do
  local t = setmetatable({}, {
    __call = function(_, ...)
      return "a", "b", select('#', ...)
    end
  })
  local a, b, n = t(10, 20)
  assertEquals(a, "a", "__call multi-return first")
  assertEquals(b, "b", "__call multi-return second")
  assertEquals(n, 2, "__call sees arguments")
end

do
  local n = 200
  local function leaf()
    if n == 0 then return 1023 end
    n = n - 1
    return leaf()
  end

  local f = leaf
  for _ = 1, 50 do
    f = setmetatable({}, { __call = f })
  end

  assertEquals(f(), 1023, 'chain of __call metamethods works')
end

do
  local i = 0
  local tt = {
    __call = function(t, ...)
      i = i + 1
      if t.f then return t.f(...) end
      return {...}
    end
  }

  local a = setmetatable({}, tt)
  local b = setmetatable({ f = a }, tt)
  local c = setmetatable({ f = b }, tt)

  local x = c(3, 4, 5)
  assertEquals(i, 3, "__call nested chain increments i")
  assertEquals(x[1], 3, "__call nested chain arg1")
  assertEquals(x[2], 4, "__call nested chain arg2")
  assertEquals(x[3], 5, "__call nested chain arg3")
end

do
  local t = setmetatable({}, { __call = 123 })
  assertThrows("attempt to call a number value", function()
    return t()
  end)
end

-- 7. `__index` / `__newindex`: function + table forms
do
  local backing = { x = 10 }
  local t = setmetatable({}, {
    __index = function(_, k) return backing[k] end,
    __newindex = function(_, k, v) backing[k] = v end,
  })

  assertEquals(t.x, 10, '__index function dispatched')
  t.y = 20
  assertEquals(backing.y, 20, '__newindex function dispatched')
end

do
  local backing = { a = 1, b = 2 }
  local t = setmetatable({}, { __index = backing })
  assertEquals(t.a, 1, '__index table dispatched (a)')
  assertEquals(t.b, 2, '__index table dispatched (b)')
  assertEquals(t.c, nil, '__index table missing key yields nil')
end

do
  -- __newindex as table target (Lua 5.4)
  local backing = {}
  local t = setmetatable({}, { __newindex = backing })
  t.k = 99
  assertEquals(backing.k, 99, '__newindex table dispatched')
end

do
  local calls = 0
  local t = { present = 1 }
  setmetatable(t, {
    __newindex = function(tt, k, v)
      calls = calls + 1
      rawset(tt, k, v)
    end
  })

  t.present = 2
  assertEquals(calls, 0, '__newindex not called for existing key')

  t.absent = 3
  assertEquals(calls, 1, '__newindex called for absent key')
  assertEquals(t.absent, 3, '__newindex stored value')
end

do
  local a = {}
  setmetatable(a, a)
  a.__index = a
  a.__newindex = a

  assertThrows("chain too long", function()
    local _ = a[10]
  end)

  assertThrows("chain too long", function()
    a[10] = true
  end)
end

-- 8. `__pairs` and `ipairs` via `__index`-backed table
do
  local a = {}
  local out = {}

  local function foo(e, i)
    assertEquals(e, a, "__pairs sees original table")
    if i < 10 then
      return i + 1, i + 2
    end
  end

  setmetatable(a, { __pairs = function(x) return foo, x, 0 end })

  local i = 0
  for k, v in pairs(a) do
    i = i + 1
    out[i] = { k, v }
  end

  assertEquals(i, 10, "__pairs loop count")
  assertEquals(out[1][1], 1, "__pairs k1")
  assertEquals(out[1][2], 2, "__pairs v1")
  assertEquals(out[10][1], 10, "__pairs k10")
  assertEquals(out[10][2], 11, "__pairs v10")
end

do
  local a = { n = 5 }
  setmetatable(a, {
    __index = function(t, k)
      if type(k) == "number" and k <= t.n then
        return k * 10
      end
    end
  })

  local out = {}
  for k, v in ipairs(a) do
    out[#out + 1] = k .. ":" .. v
  end

  assertEquals(#out, 5, "ipairs iterates n entries via __index")
  assertEquals(out[1], "1:10", "ipairs entry 1")
  assertEquals(out[5], "5:50", "ipairs entry 5")
end

-- 9. `table` library interaction with metamethods
do
  local function check_proxy(proxy, backing)
    for i = 1, 10 do
      table.insert(proxy, 1, i)
    end
    assertEquals(#proxy, 10, "proxy length after inserts")
    assertEquals(#backing, 10, "backing length after inserts")

    for i = 1, 10 do
      assertEquals(backing[i], 11 - i, "backing reversed insert " .. i)
    end

    table.sort(proxy)
    for i = 1, 10 do
      assertEquals(backing[i], i, "backing sorted " .. i)
      assertEquals(proxy[i], i, "proxy sorted " .. i)
    end

    assertEquals(table.concat(proxy, ","), "1,2,3,4,5,6,7,8,9,10", "table.concat(proxy)")

    for i = 1, 8 do
      assertEquals(table.remove(proxy, 1), i, "table.remove(proxy,1) yields i")
    end
    assertEquals(#proxy, 2, "proxy length after removes")
    assertEquals(#backing, 2, "backing length after removes")

    local a, b, c = table.unpack(proxy)
    assertEquals(a, 9, "table.unpack(proxy) first")
    assertEquals(b, 10, "table.unpack(proxy) second")
    assertEquals(c, nil, "table.unpack(proxy) third nil")
  end

  local backing = {}
  local proxy = setmetatable({}, {
    __len = function() return #backing end,
    __index = backing,
    __newindex = backing,
  })
  check_proxy(proxy, backing)
end

-- 10. Comparison compatibility corner: metamethod dispatch present
do
  local mt1 = {
    __eq = function() return true end,
    __lt = function() return true end,
    __le = function() return false end,
  }

  local c = setmetatable({}, mt1)
  local d = setmetatable({}, mt1)

  assertTrue(c == d, "comparison compat: __eq true (same mt)")
  assertTrue(c < d, "comparison compat: __lt true (same mt)")
  assertFalse(d <= c, "comparison compat: __le false (same mt)")

  local mt2 = {
    __eq = mt1.__eq,
    __lt = mt1.__lt,
    __le = mt1.__le,
  }
  local e = setmetatable({}, mt1)
  local f = setmetatable({}, mt2)

  assertTrue(e == f, "comparison compat across metatables: __eq")
  assertTrue(e < f, "comparison compat across metatables: __lt")
  assertFalse(f <= e, "comparison compat across metatables: __le")
end

-- 11. Concat chain associativity + returning table from `__concat`
do
  local t = {}
  t.__concat = function(a, b)
    local av = (type(a) == "table") and a.val or tostring(a)
    local bv = (type(b) == "table") and b.val or tostring(b)
    return setmetatable({ val = av .. bv }, t)
  end

  local c = setmetatable({ val = "c" }, t)
  local d = setmetatable({ val = "d" }, t)

  local x = c .. d .. c .. d
  assertEquals(type(x), "table", "__concat chain returns table")
  assertEquals(x.val, "cdcd", "__concat chain value")
end

-- 12. `__eq` is not used for table indexing / hashing
do
  local mt = {}
  mt.__eq = function(_, _) return true end

  local function Set(x)
    local t = {}
    for _, k in pairs(x) do t[k] = true end
    return setmetatable(t, mt)
  end

  local k1 = Set{1, 2, 3}
  local k2 = Set{1, 2, 3}

  assertTrue(k1 == k2, "__eq says equal")
  assertFalse(rawequal(k1, k2), "not the same object")

  local t = {}
  t[k1] = 123

  assertEquals(t[k2], nil, "table indexing ignores __eq")
  assertEquals(t[k1], 123, "table indexing by same object works")
end

-- 13. operator metamethod lookup must be raw (no `__index` involvement)
do
  local mt = {
    __index = function(_, _)
      return function() return "BAD" end
    end
  }

  local a = setmetatable({}, mt)

  assertThrows("attempt to perform arithmetic on a table value", function()
    local _ = a + a
  end)
end

-- 14. `__tostring`: must return a string
do
  local m = setmetatable({ name = "NAME" }, {
    __tostring = function(x) return x.name end
  })
  assertEquals(tostring(m), "NAME", "__tostring used by tostring()")
end

do
  local m = setmetatable({}, {
    __tostring = function() return {} end
  })
  assertThrows("'__tostring' must return a string", function()
    return tostring(m)
  end)
end


================================================
FILE: client/space_lua/numeric.ts
================================================
import type { NumericType } from "./ast.ts";
import { luaToNumberDetailed } from "./tonumber.ts";
import { luaTypeName } from "./runtime.ts";

export interface LuaTaggedFloat {
  readonly value: number;
  readonly isFloat: true;
}

// Pre-allocated singletons for float zeros
const FLOAT_POS_ZERO: LuaTaggedFloat = { value: 0, isFloat: true };
const FLOAT_NEG_ZERO: LuaTaggedFloat = { value: -0, isFloat: true };

export const luaStringCoercionError: Error = new Error(
  "LuaStringCoercionError",
);

export function isNegativeZero(n: number): boolean {
  return n === 0 && 1 / n === -Infinity;
}

export function isTaggedFloat(v: unknown): v is LuaTaggedFloat {
  return v !== null && typeof v === "object" && (v as any).isFloat === true;
}

function makeFloat(n: number): LuaTaggedFloat {
  if (n === 0) {
    return isNegativeZero(n) ? FLOAT_NEG_ZERO : FLOAT_POS_ZERO;
  }
  return { value: n, isFloat: true };
}

// Box a zero with a given kind tag.
export function makeLuaZero(
  n: number,
  numericType: NumericType,
): any {
  if (n !== 0) {
    return n;
  }
  if (numericType !== "float") {
    return 0;
  }
  return isNegativeZero(n) ? FLOAT_NEG_ZERO : FLOAT_POS_ZERO;
}

// Tag an integer-valued number as float.
// Only allocates for integer-valued results; non-integer floats
// (1.5, NaN, Inf) are already unambiguously float as plain `number`.
export function makeLuaFloat(n: number): any {
  if (!Number.isInteger(n)) {
    return n;
  }
  return makeFloat(n);
}

export function getZeroBoxKind(x: any): NumericType | undefined {
  if (isTaggedFloat(x)) {
    return "float";
  }
  return undefined;
}

// Unwrap a potentially tagged or boxed Number to a plain number.
export function untagNumber(n: any): number {
  if (typeof n === "number") return n;
  if (isTaggedFloat(n)) {
    return n.value;
  }
  return n;
}

export function coerceToNumber(v: unknown): number | null {
  if (typeof v === "number") {
    return v;
  }
  if (isTaggedFloat(v)) {
    return v.value;
  }
  if (typeof v === "string") {
    const det = luaToNumberDetailed(v);
    if (!det) {
      return null;
    }
    return det.value;
  }
  return null;
}

export function inferNumericType(n: number): NumericType {
  if (!Number.isFinite(n)) {
    return "float";
  }
  if (isNegativeZero(n)) {
    return "float";
  }
  return Number.isInteger(n) ? "int" : "float";
}

export function combineNumericTypes(
  a: NumericType | undefined,
  b: NumericType | undefined,
): NumericType {
  if (a === "float" || b === "float") {
    return "float";
  }
  return "int";
}

export function getNumericKind(
  n: unknown,
): NumericType | undefined {
  if (typeof n === "number") {
    return inferNumericType(n);
  }
  if (isTaggedFloat(n)) {
    return "float";
  }
  return undefined;
}

export type CoerceNumericResult = {
  n: number;
  type: NumericType;
};

export function coerceNumeric(
  val: unknown,
  hint?: NumericType,
): CoerceNumericResult {
  if (typeof val === "number") {
    return { n: val, type: hint ?? inferNumericType(val) };
  }

  if (isTaggedFloat(val)) {
    return { n: val.value, type: hint ?? "float" };
  }

  if (typeof val === "string") {
    const det = luaToNumberDetailed(val);
    if (!det) {
      throw luaStringCoercionError;
    }
    return { n: det.value, type: hint ?? det.numericType };
  }

  throw new Error(
    `attempt to perform arithmetic on a ${luaTypeName(val)} value`,
  );
}

export type CoerceNumericPairResult = {
  left: number;
  right: number;
  resultType: NumericType;
};

export function coerceNumericPair(
  a: unknown,
  b: unknown,
  leftType?: NumericType,
  rightType?: NumericType,
  op?: string,
): CoerceNumericPairResult {
  const forceFloat = op === "/" || op === "^";

  // Both plain numbers
  if (typeof a === "number" && typeof b === "number") {
    const lt = leftType ?? inferNumericType(a);
    const rt = rightType ?? inferNumericType(b);

    return {
      left: a,
      right: b,
      resultType: forceFloat
        ? "float"
        : ((lt === "float" || rt === "float") ? "float" : "int"),
    };
  }

  // One tagged float, one plain number
  if (typeof a === "number" && isTaggedFloat(b)) {
    return {
      left: a,
      right: b.value,
      resultType: "float",
    };
  }

  if (isTaggedFloat(a) && typeof b === "number") {
    return {
      left: a.value,
      right: b,
      resultType: "float",
    };
  }

  // Both tagged floats
  if (isTaggedFloat(a) && isTaggedFloat(b)) {
    return {
      left: a.value,
      right: b.value,
      resultType: "float",
    };
  }

  // General fallback
  const A = coerceNumeric(a, leftType);
  const B = coerceNumeric(b, rightType);

  return {
    left: A.n,
    right: B.n,
    resultType: forceFloat ? "float" : combineNumericTypes(A.type, B.type),
  };
}

export function normalizeArithmeticResult(
  n: number,
  resultType: NumericType,
): number {
  if (n === 0) {
    if (resultType === "int") {
      return 0;
    }
    return isNegativeZero(n) ? -0 : 0;
  }
  return n;
}

export function toInteger(v: unknown): number | null {
  if (typeof v === "number") {
    return Number.isInteger(v) ? v : null;
  }
  if (isTaggedFloat(v)) {
    const n = v.value;
    return Number.isInteger(n) ? n : null;
  }
  return null;
}


================================================
FILE: client/space_lua/parse-lua.js
================================================
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
const spec_identifier = {__proto__:null,break:16, goto:20, do:24, end:26, while:30, nil:32, true:34, false:36, or:80, and:82, not:104, function:114, query:122, from:129, where:133, order:137, by:139, desc:143, select:147, limit:151, repeat:154, until:156, if:160, then:162, elseif:164, else:166, for:170, in:178, local:188, return:204}
export const parser = LRParser.deserialize({
  version: 14,
  states: "EUO!ZQPOOOOQO'#Cc'#CcO!UQPO'#CaO!bQPOOOOQO'#E{'#E{O!vQQO'#CwO$]QPO'#EzOOQO'#Ez'#EzO$gQPO'#EzOOQO'#E`'#E`O%vQPO'#E_OOQO'#Ev'#EvOOQO'#Eg'#EgO%{QPO'#C_OOQO'#C_'#C_QOQPOOO!UQPO'#CeO&`QPO'#CgO!vQQO'#CjO&gQPO'#DzO!vQQO'#D}O!UQPO'#ESO!UQPO'#EZO&nQPO'#EaO&vQQO'#EeO'^QPO,58{OOQO'#Cq'#CqO!UQPO,59^O!vQQO,59`O(gQQO'#C|O(nQQO'#FQOOQO'#E|'#E|OOQO,59f,59fO!UQPO,59fO(uQPO'#ExO,eQPO,59cOOQO'#Dc'#DcOOQO'#Dd'#DdOOQO'#De'#DeO!vQQO'#DaOOQO'#Ex'#ExO,lQPO'#DfO,qQSO'#DjO,vQPO'#EpO-OQPO,5<QO!vQQO,5:yOOQO-E8e-E8eOOQO,58y,58yOOQO,59P,59PO-WQPO,59RO-]QPO,59UO-dQPO,5:fO-iQPO,5:iO-pQPO'#FeOOQO'#EW'#EWO-{QPO'#EVO.QQPO,5:nO.VQPO'#E[O,lQPO,5:uO.bQQO'#EcO/xQPO'#FgOOQO'#Eb'#EbO!UQPO,5:wO1]QPO,5:{O2mQPO'#FROOQO'#EY'#EYOOQO,5;P,5;PO4SQPO,5;POOQO1G.g1G.gOOQO1G.x1G.xO4hQPO1G.zO4oQWO'#ExO7uQWO'#E{O!vQQO'#DOO9WQSO'#DQOOQO'#E}'#E}OOQO,59h,59hO9bQSO,59hOOQO,5;l,5;lO9jQPO,5;lO9oQPO1G/QOOQO1G.}1G.}OOQO'#DX'#DXOOQO'#DY'#DYOOQO'#DZ'#DZOOQO'#D['#D[OOQO'#D^'#D^OOQO'#D_'#D_OOQO'#D`'#D`O!vQQO,59oO!vQQO,59oO!vQQO,59oO!vQQO,59oO!vQQO,59oO!vQQO,59oO!vQQO,59oO!vQQO,59oO!vQQO,59oO;RQPO,59{O<kQQO'#DhOOQO,5:Q,5:QO=[QQO,5:UO=cQPO,5;[OOQO-E8n-E8nOOQO1G0e1G0eOOQO1G.m1G.mO&`QPO1G.pO!vQQO1G0QO=mQPO1G0TO!vQQO,5:pO!UQPO'#EnO=zQPO,5<PO!vQQO,5:qO&`QPO1G0YO!UQPO'#EoO>SQPO,5:vO!UQPO,5:vOOQO1G0a1G0aO!UQPO'#EdOOQO,5:},5:}O!UQPO'#EqO>_QPO,5<RO,lQPO1G0cO!vQQO1G0gO!vQQO'#EiO?rQPO,5;mOOQO1G0k1G0kOOQO7+$f7+$fOAVQQO,59kOBWQPO,59jOB_QQO1G/SOBfQSO1G/SOOQO1G/S1G/SODUQSO,59{OOQO1G1W1G1WOOQO7+$l7+$lOFxQPO1G/ZOGPQPO1G/ZOIpQPO1G/ZOIwQPO1G/ZOLbQPO1G/ZOLoQPO1G/ZONpQPO1G/ZO!$VQPO1G/ZO!$^QPO1G/ZO!$eQPO'#EzO!$oQPO'#FaOOQO'#Di'#DiO!$wQPO,5:SOOQO'#Dl'#DlOOQO'#Ek'#EkO!$|QQO1G/pOAVQQO'#DmOAVQQO'#DoO!%TQPO'#DqOAVQQO'#DvO!%YQQO'#DxOOQO1G/p1G/pO!&ZQPO7+$[O!&`QPO7+%lO!'oQPO7+%oO!'zQPO1G0[OOQO,5;Y,5;YOOQO-E8l-E8lOOQO1G0]1G0]O!(RQPO7+%tOOQO,5;Z,5;ZOOQO-E8m-E8mO!UQPO1G0bOOQO1G0b1G0bO!(WQQO,5;OOOQO,5;],5;]OOQO-E8o-E8oOOQO7+%}7+%}OOQO7+&R7+&RO!(]QPO,5;TOOQO-E8g-E8gO!)rQSO1G/VO!)|QPO1G/UO!+lQSO1G/ZO!+sQSO1G/ZO!-eQSO1G/ZO!-lQSO1G/ZO!/WQSO1G/ZO!/eQSO1G/ZO!1YQSO1G/ZO!2}QSO1G/ZO!3UQSO1G/ZOOQO,5;S,5;SOOQO7+$n7+$nO!3]QQO7+$nOOQO-E8f-E8fO!3dQQO'#EjO!3oQPO,5;{O&`QPO1G/nOOQO-E8i-E8iOOQO7+%[7+%[O!3wQWO,5:XO!4bQSO,5:XO!4xQSO,5:ZO!%YQQO,5:]O!5`QSO,5:bO!5vQ`O'#ExO!8jQ`O,5:dOOQO<<Gv<<GvO!9TQPO<<IZO!vQQO'#EmOOQO<<IZ<<IZO&`QPO<<IZO!vQQO7+%vOOQO<<I`<<I`OOQO7+%|7+%|OOQO1G0j1G0jOAVQQO7+$pOOQO<<HY<<HYP'cQQO'#EhO!9`QPO,5;UOOQO,5;U,5;UOOQO-E8h-E8hO!9jQPO7+%YOAVQQO1G/sO!9oQ`O'#DtO!:]QQO'#FcOOQO1G/w1G/wOAVQQO1G0OO!:tQ`O,59{OOQO-E8k-E8kOOQOAN>uAN>uO&`QPOAN>uO!;OQPO,5;XO!;VQPOAN>uO!;[QPO<<IbO!;fQSO<<H[OOQO<<Ht<<HtO!;pQSO7+%_OOQO,5:`,5:`O!%YQQO'#ElO!<WQQO,5;}O!>YQ`O1G/ZO!>aQ`O1G/ZO!@RQ`O1G/ZO!@YQ`O1G/ZO!AtQ`O1G/ZO!BRQ`O1G/ZO!B`Q`O1G/ZO!DTQ`O1G/ZO!D[Q`O1G/ZO!DcQSO7+%jO!DyQPOG24aO=mQPO1G0sOOQOG24aG24aO!vQQOAN>|OOQO,5;W,5;WOOQO-E8j-E8jOOQOLD){LD){OOQO7+&_7+&_O!EOQPOG24hOAVQQO'#DaO!%YQQO'#DaOAVQQO,59oO!%YQQO,59oOAVQQO,59oO!%YQQO,59oOAVQQO,59oO!%YQQO,59oOAVQQO,59oO!%YQQO,59oOAVQQO,59oO!%YQQO,59oOAVQQO,59oO!%YQQO,59oOAVQQO,59oO!%YQQO,59oOAVQQO,59oO!%YQQO,59oOAVQQO,59oO!%YQQO,59o",
  stateData: "!EY~O#hOS#iOSPOS~OSZOUQOWZOY`O[aO_bOlTO!ZfO!ocO!rdO!weO#QgO#YhO#kPO~O#fRP~P]OgkOilOlnOoqOqmO#mjO~O`xOaxObxOcxOdxOlTOqmO!UwO!ZyO!_zO#kPO#mjO#xuO#|tO$SvO~Og#nXi#nXl#nXo#nXq#nX#m#nX~Ov{O#r$YX~P#wOS#jXU#jXW#jXY#jX[#jX_#jXl#jX!Z#jX!o#jX!r#jX!w#jX#Q#jX#Y#jX#f#jX#k#jX]#jX!p#jX!t#jX!u#jX~P#wO#r}O~O#fRX]RX!pRX!tRX!uRX~P]O]RP~P]O!pRP~P]O!Z!`O#kPO~OS!dO#f#XX]#XX!p#XX!t#XX!u#XX~P!vOU!fO~O`xOaxObxOcxOdxOi!kOlTOqmO!U&kO!ZyO!_zO#kPO#mjO#xuO#|tO$SvO~Ou!nO~P'cOm!pO~P!vOm#lXx#lXy#lXz#lX!P#lX#v#lX#w#lX#x#lX#y#lX#z#lX#{#lX#|#lX#}#lX$O#lX$P#lX$Q#lX$R#lX[#lX!s#lXS#lXv#lX#f#lXj#lXU#lXW#lXY#lX_#lX!Z#lX!o#lX!r#lX!w#lX#Q#lX#Y#lX#k#lX]#lX!p#lX!t#lX!u#lX~P!bOx#SOy#TOz!{O!P#PO#v!tO#w!uO#x!vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zO~Om!sO~P+aOl#VO~O$U#XO~OlTO#kPO~Ov{O#r$Ya~O]#]O~O[#^O~P+aO!p#_O~O!s#`O~P+aOv#bO#r#aO!{$XX~O!{#dO~O[#eO~Og#fOo#hOl#OX~O$[#jOS#WPU#WPW#WPY#WP[#WP_#WPl#WPv#WP!Z#WP!o#WP!r#WP!w#WP#Q#WP#Y#WP#f#WP#k#WP#r#WP]#WP!p#WP!t#WP!u#WP~Ov#lOS$ZXU$ZXW$ZXY$ZX[$ZX_$ZXl$ZX!Z$ZX!o$ZX!r$ZX!w$ZX#Q$ZX#Y$ZX#f$ZX#k$ZX#r$ZX]$ZX!p$ZX!t$ZX!u$ZX~O#r#oOS#TaU#TaW#TaY#Ta[#Ta_#Tal#Ta!Z#Ta!o#Ta!r#Ta!w#Ta#Q#Ta#Y#Ta#f#Ta#k#Ta]#Ta!p#Ta!t#Ta!u#Ta~Ov#pOS#uX#f#uXm#uXU#uXW#uXY#uX[#uX_#uXl#uX!Z#uX!o#uX!r#uX!w#uX#Q#uX#Y#uX#k#uX]#uX!p#uX!t#uX!u#uX~P+aOS#rO#f#Xa]#Xa!p#Xa!t#Xa!u#Xa~Oj#sO~P+aOu#lXx#lXy#lXz#lX!P#lX#s#lX#v#lX#w#lX#x#lX#y#lX#z#lX#{#lX#|#lX#}#lX$O#lX$P#lX$Q#lX$R#lX!b#lX!d#lX!f#lX!k#lX!m#lX$W#lX~P!bOg#oXi#oXl#oXo#oXq#oXx#oXy#oXz#oX!P#oX#m#oX#v#oX#w#oX#x#oX#y#oX#z#oX#{#oX#|#oX#}#oX$O#oX$P#oX$Q#oX$R#oX~O#r#tOu#oX#s#oX~P6_Ox&{Oy&}Oz&mO!P&uO#v!tO#w!uO#x!vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zO~OutX#stX~P8SOu#xO#s#vO~Om#zO~OlnOqmO#mjO~O$R!zOx!Tay!Taz!Ta!P!Ta#v!Ta#w!Ta#x!Ta#y!Ta#z!Ta#{!Ta#|!Ta#}!Ta$O!Ta$P!Ta$Q!Tav!Ta~Om!Ta[!Ta!s!TaS!Ta#f!Taj!TaU!TaW!TaY!Ta_!Tal!Ta!Z!Ta!o!Ta!r!Ta!w!Ta#Q!Ta#Y!Ta#k!Ta]!Ta!p!Ta!t!Ta!u!Ta~P9zOc$WOlTO#kPOm!]P~O!b$^O!d$_O!f$`O!k$aO!m$bO~O$W$cO~P<yOv#da#r#da~P#wO]RP!tRP!uRP~P]Ov#bO!{$Xa~Og#fOo$nOl#Oa~Ov#lOS$ZaU$ZaW$ZaY$Za[$Za_$Zal$Za!Z$Za!o$Za!r$Za!w$Za#Q$Za#Y$Za#f$Za#k$Za#r$Za]$Za!p$Za!t$Za!u$Za~Ov#pOS#ua#f#uam#uaU#uaW#uaY#ua[#ua_#ual#ua!Z#ua!o#ua!r#ua!w#ua#Q#ua#Y#ua#k#ua]#ua!p#ua!t#ua!u#ua~O`xOaxObxOcxOdxOlTOqmO!U&kO!ZyO!_zO#kPO#mjO#xuO#|tO$SvO~Oj$xO~P+aOu%TO~P'cOu%TO#s%UO~O$R!zOx!Tay!Taz!Ta!P!Ta#v!Ta#w!Ta#x!Ta#y!Ta#z!Ta#{!Ta#|!Ta#}!Ta$O!Ta$P!Ta$Q!Ta!b!Ta!d!Ta!f!Ta!k!Ta!m!Ta$W!Ta~Ou!Ta#s!Ta~PBnO!P#PO#w!uO#x!vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zOmwixwiywizwi[wi!swiSwivwi#fwijwiUwiWwiYwi_wilwi!Zwi!owi!rwi!wwi#Qwi#Ywi#kwi]wi!pwi!twi!uwi~O#v!tO~PD`O#vwi~PD`O!P#PO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zOmwixwiywizwi#vwi#xwi[wi!swiSwivwi#fwijwiUwiWwiYwi_wilwi!Zwi!owi!rwi!wwi#Qwi#Ywi#kwi]wi!pwi!twi!uwi~O#wwi~PGWO#w!uO~PGWO#}!yO$O!yO$P!yO$Q!yO$R!zOmwixwiywizwi#vwi#wwi#xwi#ywi#zwi[wi!swiSwivwi#fwijwiUwiWwiYwi_wilwi!Zwi!owi!rwi!wwi#Qwi#Ywi#kwi]wi!pwi!twi!uwi~O!P#PO#{!xO#|!xO~PJOO!Pwi#{wi#|wi~PJOO$R!zOmwixwiywi[wi!swiSwivwi#fwijwiUwiWwiYwi_wilwi!Zwi!owi!rwi!wwi#Qwi#Ywi#kwi]wi!pwi!twi!uwi~Ozwi!Pwi#vwi#wwi#xwi#ywi#zwi#{wi#|wi#}wi$Owi$Pwi$Qwi~PL|Oz!{O!P#PO#v!tO#w!uO#x!vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zOmwixwi[wi!swiSwivwi#fwijwiUwiWwiYwi_wilwi!Zwi!owi!rwi!wwi#Qwi#Ywi#kwi]wi!pwi!twi!uwi~Oy#TO~P! mOywi~P! mOv%WOm$TX~P#wOv%WOm$TX~Om%YO~O$W%[O~P<yO!g%`O~O`xOaxObxOcxOdxOlTOqmO!U&lO!ZyO!_zO#kPO#mjO#xuO#|tO$SvO~O]%dO~OS!nqU!nqW!nqY!nq[!nq_!nql!nq!Z!nq!o!nq!r!nq!w!nq#Q!nq#Y!nq#f!nq#k!nq]!nq!p!nq!t!nq!u!nq~P+aO]%gO!t%fO!u%hO~Ov%iO~P+aO]%jO~O$]%lO~OS#]av#]a#f#]am#]aU#]aW#]aY#]a[#]a_#]al#]a!Z#]a!o#]a!r#]a!w#]a#Q#]a#Y#]a#k#]a]#]a!p#]a!t#]a!u#]a~P+aOusi#ssi~P8SO#r%mO~O!P&uO#w!uO#x!vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zOuwixwiywizwi#swi!bwi!dwi!fwi!kwi!mwi$Wwi~O#v!tO~P!*RO#vwi~P!*RO!P&uO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zOuwixwiywizwi#swi#vwi#xwi!bwi!dwi!fwi!kwi!mwi$Wwi~O#wwi~P!+zO#w!uO~P!+zO#}!yO$O!yO$P!yO$Q!yO$R!zOuwixwiywizwi#swi#vwi#wwi#xwi#ywi#zwi!bwi!dwi!fwi!kwi!mwi$Wwi~O!P&uO#{!xO#|!xO~P!-sO!Pwi#{wi#|wi~P!-sO$R!zOxwiywizwi!Pwi#vwi#wwi#xwi#ywi#zwi#{wi#|wi#}wi$Owi$Pwi$Qwi!bwi!dwi!fwi!kwi!mwi$Wwi~Ouwi#swi~P!/rOz&mO!P&uO#v!tO#w!uO#x!vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zOuwixwi#swi!bwi!dwi!fwi!kwi!mwi$Wwi~Oy&}O~P!1dOywi~P!1dOu%nO~P'cOc%qOlTO#kPO~Ov%WOm$Ta~O#r%tO!b#oX!d#oX!f#oX!k#oX!m#oX$W#oX~P6_O!b!aa!d!aa!f!aa!k!aa!m!aa$W!aa~P8SO!b!ca!d!ca!f!ca!k!ca!m!ca$W!ca~P8SO!b!ja!d!ja!f!ja!k!ja!m!ja$W!ja~P8SOv#lXx#lXy#lXz#lX!P#lX!b#lX!d#lX!f#lX!k#lX!m#lX#v#lX#w#lX#x#lX#y#lX#z#lX#{#lX#|#lX#}#lX$O#lX$P#lX$Q#lX$R#lX$W#lX!i#lX~P!bOx&|Oy'OOz&nO!P&vO#v!tO#w!uO#x!vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zO~Ov%xO!b!la!d!la!f!la!k!la!m!la$W!la~P!7fO]%{O!t%fO!u%|O~Om#^av#^a~P#wO]&RO~O!i&TOv!hX!b!hX!d!hX!f!hX!k!hX!m!hX$W!hX~P!7fOv&UO!b$VX!d$VX!f$VX!k$VX!m$VX$W$VX~Ov!Ta!i!Ta~PBnO!s&cO~P+aO]&dO~Ov&eO[!xy~P+aOury#sry~P8SO!b!aq!d!aq!f!aq!k!aq!m!aq$W!aq~P8SOv&UO!b$Va!d$Va!f$Va!k$Va!m$Va$W$Va~O!P&vO#w!uO#x!vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zOvwixwiywizwi!bwi!dwi!fwi!kwi!mwi$Wwi!iwi~O#v!tO~P!<oO#vwi~P!<oO!P&vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zOvwixwiywizwi!bwi!dwi!fwi!kwi!mwi#vwi#xwi$Wwi!iwi~O#wwi~P!>hO#w!uO~P!>hO#}!yO$O!yO$P!yO$Q!yO$R!zOvwixwiywizwi!bwi!dwi!fwi!kwi!mwi#vwi#wwi#xwi#ywi#zwi$Wwi!iwi~O!P&vO#{!xO#|!xO~P!@aO!Pwi#{wi#|wi~P!@aOvwi!iwi~P!/rOz&nO!P&vO#v!tO#w!uO#x!vO#y!wO#z!wO#{!xO#|!xO#}!yO$O!yO$P!yO$Q!yO$R!zOvwixwi!bwi!dwi!fwi!kwi!mwi$Wwi!iwi~Oy'OO~P!BjOywi~P!BjO!b!lq!d!lq!f!lq!k!lq!m!lq$W!lq~P8SO]&hO~O[!x!Z~P+aOP#|~",
  goto: "CY$[PPP$]P${P%YP${P${PP${PPPPPP'v)_P)_PP*}PP,mP'vP._._._PP'}PPP.e/[0X1RP2R3U4['}P5h5h5h'}P6|7V'}P7Y7^P7^P7^PP7bP7^P7^P${PP${PPPP${P7h7h7kP7n${7z${P${7}${8[8_8e8hP8w9W9^9d9k9q9w9}:T:Z:aPPPP:gP:tP>`@UA|BVPPB_BfPPPPPPPPPPPPPBoPBrPBuBxCVQ_OQ!RaQ!TcQ$d#^Q$f#`Q$k#eQ%s%YQ&O%hQ&b%|R&i&cgZO]ac#^#`#e%Y%h%|&c$OSOT]abcdhlnw{}!k!{!|!}#O#P#Q#R#S#T#V#^#_#`#a#d#e#o#p#t$_$a$b%W%Y%`%f%h%i%m%t%x%|&U&c&e&k&l&m&n&o&p&q&r&s&t&u&v&w&x&y&z&{&|&}'OQiQQ!Q`Q!VeQ!ZfS!]g#lQ!gkW!jm#v%U%oQ!rqQ#n!`Q$h#bQ$l#fQ$o#hQ$p#jQ%]$^R%k$nYoRr!i!r%b#mxTbdhlmnw}!k!{!|!}#O#P#Q#R#S#T#_#a#d#o#p#t#v$^$_$a$b%U%`%f%i%m%o%t%x&U&e&k&l&m&n&o&p&q&r&s&t&u&v&w&x&y&z&{&|&}'O$ZSOT]abcdhlmnw{}!k!{!|!}#O#P#Q#R#S#T#V#^#_#`#a#d#e#o#p#t#v$^$_$a$b%U%W%Y%`%f%h%i%m%o%t%x%|&U&c&e&k&l&m&n&o&p&q&r&s&t&u&v&w&x&y&z&{&|&}'O$ZVOT]abcdhlmnw{}!k!{!|!}#O#P#Q#R#S#T#V#^#_#`#a#d#e#o#p#t#v$^$_$a$b%U%W%Y%`%f%h%i%m%o%t%x%|&U&c&e&k&l&m&n&o&p&q&r&s&t&u&v&w&x&y&z&{&|&}'O#rVTbdhlmnw{}!k!{!|!}#O#P#Q#R#S#T#V#_#a#d#o#p#t#v$^$_$a$b%U%W%`%f%i%m%o%t%x&U&e&k&l&m&n&o&p&q&r&s&t&u&v&w&x&y&z&{&|&}'OgWO]ac#^#`#e%Y%h%|&cX!mm#v%U%on!|s!S!U!b!h#u#|$T$U$e$g$u%}&P&jf&o!l$w$y%Q%R%^%_%a&Q&S&aZ&p%c%u&W&_&`r!}s!S!U!b!h#u#|#}$P$T$U$e$g$u%}&P&jj&q!l$w$y$z$|%Q%R%^%_%a&Q&S&a_&r%c%u&W&X&Z&_&`p#Os!S!U!b!h#u#|#}$T$U$e$g$u%}&P&jh&s!l$w$y$z%Q%R%^%_%a&Q&S&a]&t%c%u&W&X&_&`t#Ps!S!U!b!h#u#|#}$O$P$T$U$e$g$u%}&P&jl&u!l$w$y$z${$|%Q%R%^%_%a&Q&S&aa&v%c%u&W&X&Y&Z&_&`v#Qs!S!U!b!h#u#|#}$O$P$Q$T$U$e$g$u%}&P&jn&w!l$w$y$z${$|$}%Q%R%^%_%a&Q&S&ac&x%c%u&W&X&Y&Z&[&_&`x#Rs!S!U!b!h#u#|#}$O$P$Q$R$T$U$e$g$u%}&P&jp&y!l$w$y$z${$|$}%O%Q%R%^%_%a&Q&S&ae&z%c%u&W&X&Y&Z&[&]&_&`|#Rs!S!U!b!h#U#u#|#}$O$P$Q$R$S$T$U$e$g$u%}&P&jt&y!l#y$w$y$z${$|$}%O%P%Q%R%^%_%a&Q&S&ai&z%c%u%y&W&X&Y&Z&[&]&^&_&`!UwTbdhlnw}!k!{!|!}#O#P#Q#R#S#T#_#a#d#o#p%f%i&ez&km#t#v$^$_$a%U%m%o%t%x&k&m&o&q&s&u&w&y&{&}k&l$b%`&U&l&n&p&r&t&v&x&z&|'OQ#WyQ#i![R$s#nR$Y#VT$[#X$]T$Z#X$]Q%v%`R&f&UR!YeR!XeQ!ehQ#[}Q$j#dR$t#oR![fgYO]ac#^#`#e%Y%h%|&cR!agQ!^gR$q#lR#k!]d^Oac#^#`#e%Y%h%|&cR!P]d]Oac#^#`#e%Y%h%|&cR!O]Q#w!oR%V#wQ#q!bR$v#qS%X$V$WR%r%XQ$]#XR%Z$]Q&V%vR&g&VQ%e$fR%z%eQ#c!VR$i#cQ#g!ZR$m#gQ|UR#Z|Q#m!^R$r#mg[O]ac#^#`#e%Y%h%|&cQsTQ!SbQ!UdY!bhn}#d#oQ!hlW!lm#v%U%oQ#UwQ#u!kQ#y&kQ#|!{Q#}!|Q$O!}Q$P#OQ$Q#PQ$R#QQ$S#RQ$T#SQ$U#TQ$e#_Q$g#aQ$u#pQ$w#tQ$y&mQ$z&oQ${&qQ$|&sQ$}&uQ%O&wQ%P&yQ%Q&{Q%R&}Q%^$^Q%_$_Q%a$aQ%c$bS%u%`&UQ%y&lQ%}%fQ&P%iQ&Q%mQ&S%tQ&W&nQ&X&pQ&Y&rQ&Z&tQ&[&vQ&]&xQ&^&zQ&_&|Q&`'OQ&a%xR&j&elRO]ac{#V#^#`#e%W%Y%h%|&c!UrTbdhlnw}!k!{!|!}#O#P#Q#R#S#T#_#a#d#o#p%f%i&ez!im#t#v$^$_$a%U%m%o%t%x&k&m&o&q&s&u&w&y&{&}k%b$b%`&U&l&n&p&r&t&v&x&z&|'OfUO]ac#^#`#e%Y%h%|&c#lVTbdhlmnw}!k!{!|!}#O#P#Q#R#S#T#_#a#d#o#p#t#v$^$_$a$b%U%`%f%i%m%o%t%x&U&e&k&l&m&n&o&p&q&r&s&t&u&v&w&x&y&z&{&|&}'OQ#Y{Q$V#VR%p%WWpRr!i%bR#{!rQ!omV%S#v%U%oZoRr!i!r%bW!ch}#d#oR!qnR$X#VR%w%`R!WegXO]ac#^#`#e%Y%h%|&cR!_g",
  nodeNames: "⚠ Comment Chunk Block ; Label :: Name break Goto goto Scope do end WhileStatement while nil true false Ellipsis Number LiteralString Property . MemberExpression [ ] Parens ( ) FunctionCall : TableConstructor { FieldDynamic FieldProp FieldExp } , BinaryExpression or and CompareOp BitOp BitOp BitOp BitOp Concat ArithOp ArithOp ArithOp UnaryExpression not ArithOp BitOp LenOp FunctionDef function FuncBody ArgList Query query QueryClause FromClause from WhereClause where OrderByClause order by OrderBy desc SelectClause select LimitClause limit RepeatStatement repeat until IfStatement if then elseif else ForStatement for ForNumeric ForGeneric NameList in ExpList Function FuncName LocalFunction local Assign VarList Local AttNameList AttName Attrib ReturnStatement return",
  maxTerm: 151,
  nodeProps: [
    ["group", -14,4,5,8,9,11,14,30,76,79,84,91,93,95,97,"Statement",-3,34,35,36,"Field"]
  ],
  skippedNodes: [0,1],
  repeatNodeCount: 11,
  tokenData: "Dj~RuXY#fYZ$Q[]#f]^$_pq#fqr$grs$rst)`uv)evw)jwx)oxy.Wyz.]z{.b{|.g|}.l}!O.s!O!P/g!P!Q/|!Q!R0^!R![1u![!]3m!]!^3z!^!_4R!_!`4e!`!a4m!c!}5P!}#O5b#O#P#w#P#QCo#Q#RC|#R#S5P#T#o5P#o#pDR#p#qDW#q#rD]#r#sDb~#kS#i~XY#f[]#fpq#f#O#P#w~#zQYZ#f]^#f~$VP#h~]^$Y~$_O#h~~$dP#h~YZ$Ym$jP!_!`$mm$rOzmk$uXOY$rZ]$r^r$rrs%bs#O$r#O#P%g#P;'S$r;'S;=`'}<%lO$rk%gO#mkk%j]rs$rwx$r!Q![&c#O#P$r#T#U$r#U#V$r#Y#Z$r#b#c$r#f#g$r#h#i$r#i#j(T#l#m(v#n#o$rk&fZOY$rZ]$r^r$rrs%bs!Q$r!Q!['X![#O$r#O#P%g#P;'S$r;'S;=`'}<%lO$rk'[ZOY$rZ]$r^r$rrs%bs!Q$r!Q![$r![#O$r#O#P%g#P;'S$r;'S;=`'}<%lO$rk(QP;=`<%l$rk(WP#o#p(Zk(^R!Q![(g!c!i(g#T#Z(gk(jS!Q![(g!c!i(g#T#Z(g#q#r$rk(yR!Q![)S!c!i)S#T#Z)Sk)VR!Q![$r!c!i$r#T#Z$r~)eO$S~~)jO$P~~)oO#w~k)rXOY)oZ])o^w)owx%bx#O)o#O#P*_#P;'S)o;'S;=`,u<%lO)ok*b]rs)owx)o!Q![+Z#O#P)o#T#U)o#U#V)o#Y#Z)o#b#c)o#f#g)o#h#i)o#i#j,{#l#m-n#n#o)ok+^ZOY)oZ])o^w)owx%bx!Q)o!Q![,P![#O)o#O#P*_#P;'S)o;'S;=`,u<%lO)ok,SZOY)oZ])o^w)owx%bx!Q)o!Q![)o![#O)o#O#P*_#P;'S)o;'S;=`,u<%lO)ok,xP;=`<%l)ok-OP#o#p-Rk-UR!Q![-_!c!i-_#T#Z-_k-bS!Q![-_!c!i-_#T#Z-_#q#r)ok-qR!Q![-z!c!i-z#T#Z-zk-}R!Q![)o!c!i)o#T#Z)o~.]Ol~~.bOm~~.gO#}~~.lO#{~o.sOvc#s[~.xP#|~}!O.{~/QTP~OY.{Z].{^;'S.{;'S;=`/a<%lO.{~/dP;=`<%l.{o/lPgi!O!P/oo/tP!Pm!O!P/wQ/|OcQ~0RQ$O~!P!Q0X!_!`$m~0^O$Q~~0cUd~!O!P0u!Q![1u!g!h1Z!z!{2W#X#Y1Z#l#m2W~0xP!Q![0{~1QRd~!Q![0{!g!h1Z#X#Y1Z~1^R{|1g}!O1g!Q![1m~1jP!Q![1m~1rPd~!Q![1m~1zSd~!O!P0u!Q![1u!g!h1Z#X#Y1Z~2ZR!Q![2d!c!i2d#T#Z2d~2iUd~!O!P2{!Q![2d!c!i2d!r!s1Z#T#Z2d#d#e1Z~3OR!Q![3X!c!i3X#T#Z3X~3^Td~!Q![3X!c!i3X!r!s1Z#T#Z3X#d#e1Z~3rPo~![!]3u~3zOU~_4ROSR#s[o4YQ$[Qzm!^!_4`!_!`$mm4eO#ym~4jP#r~!_!`$mo4tQ$]Qzm!_!`$m!`!a4zm5PO#zm~5US#k~!Q![5P!c!}5P#R#S5P#T#o5Po5gQik!_!`5m!}#OB_k5pQ!_!`5v!}#O=ok5yP!}#O5|k6PTO#P5|#P#Q6`#Q;'S5|;'S;=`8W<%lO5|k6cVO!_6x!_!`:s!`#P6x#P#Q;w#Q;'S6x;'S;=`=i<%lO6xk6{VO!_7b!_!`8^!`#P7b#P#Q8v#Q;'S7b;'S;=`=c<%lO7bk7eTO#P5|#P#Q7t#Q;'S5|;'S;=`8W<%lO5|k7wP!_!`7zk7}P!_!`8Qk8TP#P#Q%bk8ZP;=`<%l5|k8aTO#P8^#P#Q7t#Q;'S8^;'S;=`8p<%lO8^k8sP;=`<%l8^k8yUO!_5|!_!`9]!`#P5|#Q;'S5|;'S;=`8W<%lO5|k9`VO!_5|!_!`9u!`#P5|#P#Q6`#Q;'S5|;'S;=`8W<%lO5|k9xTO#P5|#P#Q:X#Q;'S5|;'S;=`8W<%lO5|k:^V#mkO!_6x!_!`:s!`#P6x#P#Q;w#Q;'S6x;'S;=`=i<%lO6xk:vVO!_8^!_!`;]!`#P8^#P#Q7t#Q;'S8^;'S;=`8p<%lO8^k;`TO#P8^#P#Q;o#Q;'S8^;'S;=`8p<%lO8^k;tP#mk!_!`7zk;zVO!_7b!_!`:s!`#P7b#P#Q<a#Q;'S7b;'S;=`=c<%lO7bk<dVO!_5|!_!`9]!`#P5|#P#Q<y#Q;'S5|;'S;=`8W<%lO5|k<|VO!_8^!_!`:s!`#P8^#P#Q<y#Q;'S8^;'S;=`8p<%lO8^k=fP;=`<%l7bk=lP;=`<%l6xk=rTO#P=o#P#Q>R#Q;'S=o;'S;=`>}<%lO=ok>UVO!_>k!_!`?T!`#P>k#P#Q@X#Q;'S>k;'S;=`Ao<%lO>kk>nTO#P=o#P#Q7z#Q;'S=o;'S;=`>}<%lO=ok?QP;=`<%l=ok?WTO#P?g#P#Q@P#Q;'S?g;'S;=`?y<%lO?gk?jTO#P?g#P#Q7z#Q;'S?g;'S;=`?y<%lO?gk?|P;=`<%l?gk@UP#mk!_!`8Qk@[VO!_=o!_!`@q!`#P=o#P#QAu#Q;'S=o;'S;=`>}<%lO=ok@tTO#P=o#P#QAT#Q;'S=o;'S;=`>}<%lO=okAYV#mkO!_>k!_!`?T!`#P>k#P#Q@X#Q;'S>k;'S;=`Ao<%lO>kkArP;=`<%l>kkAxVO!_?g!_!`?T!`#P?g#P#QAu#Q;'S?g;'S;=`?y<%lO?goBdT$USO#PBs#P#QCV#Q;'SBs;'S;=`Ci<%lOBskBvTO#PBs#P#QCV#Q;'SBs;'S;=`Ci<%lOBskCYTO#PBs#P#Q%b#Q;'SBs;'S;=`Ci<%lOBskClP;=`<%lBsoCtPjP#P#QCwnC|O$Wn~DRO$R~~DWOq~~D]O#v~~DbOu~~DgP#x~!_!`$m",
  tokenizers: [0, 1, 2, 3, 4],
  topRules: {"Chunk":[0,2]},
  dynamicPrecedences: {"128":1},
  specialized: [{term: 119, get: (value) => spec_identifier[value] || -1}],
  tokenPrec: 3779
})


================================================
FILE: client/space_lua/parse-lua.terms.js
================================================
// This file was generated by lezer-generator. You probably shouldn't edit it.
export const
  Comment = 1,
  Chunk = 2,
  Block = 3,
  Label = 5,
  Name = 7,
  Number = 20,
  LiteralString = 21,
  FunctionCall = 30,
  TableConstructor = 32,
  BinaryExpression = 39,
  CompareOp = 42,
  UnaryExpression = 51,
  FuncBody = 58,
  ArgList = 59,
  Query = 60,
  QueryClause = 62,
  FromClause = 63,
  WhereClause = 65,
  OrderByClause = 67,
  OrderBy = 70,
  SelectClause = 72,
  LimitClause = 74,
  IfStatement = 79,
  ForStatement = 84,
  ForNumeric = 86,
  ForGeneric = 87,
  NameList = 88,
  ExpList = 90,
  FuncName = 92,
  VarList = 96,
  AttNameList = 98,
  AttName = 99,
  Attrib = 100,
  ReturnStatement = 101


================================================
FILE: client/space_lua/parse.test.ts
================================================
import { parse, parseExpressionString, stripLuaComments } from "./parse.ts";
import type { LuaNumberLiteral } from "./ast.ts";
import { assertEquals } from "@std/assert/equals";

Deno.test("Test Lua parser", () => {
  // Basic block test
  parse(`
        print("Hello, World!")
        print(10)
`);
  parse("");
  // Expression tests
  parse(
    `e(1, 1.2, -3.8, -4, #lst, true, false, nil, "string", "", "Hello there \x00", ...)`,
  );
  parse(`e([[hel]lo]], "Grinny face\\u{1F600}")`);
  parse(`e([=[Hello page [[index]] end scene]=], [[yo]])`);

  // console.log(
  //   JSON.stringify(
  parse(`e([==[Hello page [[index]] end scene]==], [==[yo]==])`),
    // null,
    // 2,
    //   ),
    // );
    console.log(
      JSON.stringify(
        parse(`e([==[Hello page [[bla]]
]==])`),
        null,
        2,
      ),
    );

  parse(`e(10 << 10, 10 >> 10, 10 & 10, 10 | 10, 10 ~ 10)`);

  parse(`e(true and false or true)`);
  parse(`e(a < 3 and b > 4 or b == 5 or c <= 6 and d >= 7 or a /= 8)`);
  parse(`e(a.b.c)`);
  parse(`e((1+2))`);

  // Use keywordy variables
  parse(`e(order, limit, where)`);

  // Table expressions
  parse(`e({})`);
  parse(`e({1, 2, 3, })`);
  parse(`e({1 ; 2 ; 3})`);
  parse(`e({a = 1, b = 2, c = 3})`);
  parse(`e({[3] = 1, [10 * 10] = "sup"})`);
  parse(`e(tbl.name)`);
  parse(`e(tbl["name" + 10])`);
  parse(`e(test().bla)`);

  // Function calls
  parse(`e(func(), func(1, 2, 3), a.b(), a.b.c:hello(), (a.b)(7))`);

  // Function expression
  parse(`e(function(a, b) test() end)`);
  parse(`e(function(a, b, ...) end)`);

  // Statements
  parse(`do end`);
  parse(`do print() end`);
  parse(`::hello::
        goto hello`);
  parse(`while true do print() end`);
  parse(`repeat print() until false`);
  parse(
    `if 1 == 2 then print() elseif 1 < 2 then print2() else print3() end`,
  );
  parse(`if true then print() end`);
  parse(`if true then print() else print2() end`);
  parse(`if true then print() elseif false then print2() end`);

  // For loops
  parse(`for i = 1, 10, 1 do print(i) end`);
  parse(`for i = 1, 10 do print(i) end`);
  parse(`for el in each({1, 2, 3}) do print(i) end`);
  parse(`for i, l in 1, pairs() do print(i) end`);

  // Function statements
  parse(`function a() end`);
  parse(`function a:b() end`);
  parse(`function a.b.c:d() end`);
  parse(`function a.b.c() end`);
  parse(`function hello(a, b) end`);
  parse(`function hello(a, b, ...) end`);
  parse(`local function hello() end`);

  // Assignments, local variables etc.
  parse(`a = 1`);
  parse(`a, b = 1, 2`);
  parse(`a.b.c = 1`);
  parse(`a["name"] = 1`);
  parse(`local a, b<const>`);
  parse(`local a = 1`);
  parse(`local a<const> = 4`);
  parse(`local a, b = 1, 2`);

  // Function calls
  parse(`a(1, 2, 3)`);
  parse(`print "Sup"`);
  parse(`e(1 + print "8")`);

  // Return statements
  parse(`return`);
  parse(`return 1`);
  parse(`return 1, 2, 3`);
});

Deno.test("Test comment handling", () => {
  const code = `
-- Single line comment
--[[ Multi
line
comment ]]
f([[
hello
-- yo
]])`;
  const code2 = stripLuaComments(code);
  assertEquals(code2.length, code.length);
  console.log(code2);
  console.log(stripLuaComments(`e([==[
    --- Hello
  ]==])`));
});

Deno.test("Test query parsing", () => {
  parse(
    `_(query[[from p = index.tag("page") where p.name == "John" limit 10, 3]])`,
  );
  parse(`_(query[[from index.tag("page") select {name="hello", age=10}]])`);
  parse(
    `_(query[[from p = index.tag("page") order by p.lastModified desc, p.name]])`,
  );
  parse(`_(query[[from p = index.tag("page") order by p.lastModified]])`);
});

Deno.test("Test numeric constant parsing", () => {
  // Examples from Lua 5.4 Reference Manual, except hexadecimal constants with fractional part
  assertEquals((parseExpressionString(`3`) as LuaNumberLiteral).value, 3);
  assertEquals((parseExpressionString(`345`) as LuaNumberLiteral).value, 345);
  assertEquals((parseExpressionString(`0xff`) as LuaNumberLiteral).value, 0xff);
  assertEquals(
    (parseExpressionString(`0xBEBADA`) as LuaNumberLiteral).value,
    0xBEBADA,
  );
  assertEquals((parseExpressionString(`3.0`) as LuaNumberLiteral).value, 3.0);
  assertEquals(
    (parseExpressionString(`3.1416`) as LuaNumberLiteral).value,
    3.1416,
  );
  assertEquals(
    (parseExpressionString(`314.16e-2`) as LuaNumberLiteral).value,
    314.16e-2,
  );
  assertEquals(
    (parseExpressionString(`0.31416E1`) as LuaNumberLiteral).value,
    0.31416E1,
  );
  assertEquals(
    (parseExpressionString(`34e1`) as LuaNumberLiteral).value,
    34e1,
  );
});


================================================
FILE: client/space_lua/parse.ts
================================================
import { lezerToParseTree } from "../../client/markdown_parser/parse_tree.ts";
import type { SyntaxNode } from "@lezer/common";
import {
  cleanTree,
  type ParseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import { parser } from "./parse-lua.js";
import { styleTags, tags as t } from "@lezer/highlight";
import { indentNodeProp, LRLanguage } from "@codemirror/language";
import type {
  ASTCtx,
  LuaAttName,
  LuaBlock,
  LuaExpression,
  LuaFunctionBody,
  LuaFunctionCallExpression,
  LuaFunctionCallStatement,
  LuaFunctionName,
  LuaIfStatement,
  LuaLValue,
  LuaOrderBy,
  LuaPrefixExpression,
  LuaQueryClause,
  LuaStatement,
  LuaTableField,
} from "./ast.ts";
import { LuaAttribute } from "./ast.ts";
import { getBlockGotoMeta } from "./labels.ts";
import { LuaRuntimeError, LuaStackFrame } from "./runtime.ts";

const luaStyleTags = styleTags({
  Name: t.variableName,
  LiteralString: t.string,
  Number: t.number,
  CompareOp: t.operator,
  "true false": t.bool,
  Comment: t.lineComment,
  "return break goto do end while repeat until function local if then else elseif in for nil or and not query from where limit select order by desc":
    t.keyword,
});

const customIndent = indentNodeProp.add({
  "IfStatement FuncBody WhileStatement ForStatement TableConstructor": (
    context,
  ) => {
    return context.lineIndent(context.node.from) + context.unit;
  },
});

// Use the customIndent in your language support
export const luaLanguage = LRLanguage.define({
  name: "space-lua",
  parser: parser.configure({
    props: [
      luaStyleTags,
      customIndent,
    ],
  }),
  languageData: {
    commentTokens: { line: "--", block: { open: "--[[", close: "--]]" } },
  },
});

function context(t: ParseTree, ctx: Record<string, any>): ASTCtx {
  return { ...ctx, from: t.from, to: t.to };
}

function parseChunk(t: ParseTree, ctx: ASTCtx): LuaBlock {
  if (t.type !== "Chunk") {
    throw new Error(`Expected Chunk, got ${t.type}`);
  }
  return parseBlock(t.children![0], ctx);
}

function hasCloseLocal(names: LuaAttName[] | undefined): boolean {
  if (!names) {
    return false;
  }
  for (const n of names) {
    if (n.attributes?.includes(LuaAttribute.Close) === true) {
      return true;
    }
  }
  return false;
}

function expressionHasFunctionDef(e: LuaExpression): boolean {
  if (!e) return false;
  switch (e.type) {
    case "FunctionDefinition":
      return true;
    case "FunctionCall":
      if (expressionHasFunctionDef(e.prefix)) return true;
      for (let i = 0; i < e.args.length; i++) {
        if (expressionHasFunctionDef(e.args[i])) return true;
      }
      return false;
    case "Binary":
      return expressionHasFunctionDef(e.left) ||
        expressionHasFunctionDef(e.right);
    case "Unary":
      return expressionHasFunctionDef(e.argument);
    case "Parenthesized":
      return expressionHasFunctionDef(e.expression);
    case "TableConstructor":
      for (let i = 0; i < e.fields.length; i++) {
        const f = e.fields[i];
        switch (f.type) {
          case "DynamicField":
            if (expressionHasFunctionDef(f.key)) return true;
            if (expressionHasFunctionDef(f.value)) return true;
            break;
          case "PropField":
          case "ExpressionField":
            if (expressionHasFunctionDef(f.value)) return true;
            break;
        }
      }
      return false;
    case "TableAccess":
      return expressionHasFunctionDef(e.object) ||
        expressionHasFunctionDef(e.key);
    case "PropertyAccess":
      return expressionHasFunctionDef(e.object);
    case "Query":
      for (let i = 0; i < e.clauses.length; i++) {
        const c = e.clauses[i];
        switch (c.type) {
          case "From":
            if (expressionHasFunctionDef(c.expression)) return true;
            break;
          case "Where":
          case "Select":
            if (expressionHasFunctionDef(c.expression)) return true;
            break;
          case "Limit":
            if (expressionHasFunctionDef(c.limit)) return true;
            if (c.offset && expressionHasFunctionDef(c.offset)) return true;
            break;
          case "OrderBy":
            for (let j = 0; j < c.orderBy.length; j++) {
              if (expressionHasFunctionDef(c.orderBy[j].expression)) {
                return true;
              }
            }
            break;
        }
      }
      return false;
    default:
      return false;
  }
}

function expressionsHaveFunctionDef(
  exprs: LuaExpression[] | undefined,
): boolean {
  if (!exprs) return false;
  for (let i = 0; i < exprs.length; i++) {
    if (expressionHasFunctionDef(exprs[i])) return true;
  }
  return false;
}

// Does the expression reference any of `names`?
// Note: It DOES NOT descend into `FunctionDefinition`.
function exprReferencesNames(e: LuaExpression, names: Set<string>): boolean {
  if (!e) return false;
  switch (e.type) {
    case "Variable":
      return names.has(e.name);
    case "Binary":
      return exprReferencesNames(e.left, names) ||
        exprReferencesNames(e.right, names);
    case "Unary":
      return exprReferencesNames(e.argument, names);
    case "Parenthesized":
      return exprReferencesNames(e.expression, names);
    case "FunctionCall":
      if (exprReferencesNames(e.prefix, names)) return true;
      for (let i = 0; i < e.args.length; i++) {
        if (exprReferencesNames(e.args[i], names)) return true;
      }
      return false;
    case "TableAccess":
      return exprReferencesNames(e.object, names) ||
        exprReferencesNames(e.key, names);
    case "PropertyAccess":
      return exprReferencesNames(e.object, names);
    case "TableConstructor":
      for (let i = 0; i < e.fields.length; i++) {
        const f = e.fields[i];
        switch (f.type) {
          case "DynamicField":
            if (exprReferencesNames(f.key, names)) return true;
            if (exprReferencesNames(f.value, names)) return true;
            break;
          case "PropField":
          case "ExpressionField":
            if (exprReferencesNames(f.value, names)) return true;
            break;
        }
      }
      return false;
    case "FunctionDefinition":
      return false;
    case "Query":
      for (let i = 0; i < e.clauses.length; i++) {
        const c = e.clauses[i];
        switch (c.type) {
          case "From":
            if (exprReferencesNames(c.expression, names)) return true;
            break;
          case "Where":
          case "Select":
            if (exprReferencesNames(c.expression, names)) return true;
            break;
          case "Limit":
            if (exprReferencesNames(c.limit, names)) return true;
            if (c.offset && exprReferencesNames(c.offset, names)) return true;
            break;
          case "OrderBy":
            for (let j = 0; j < c.orderBy.length; j++) {
              if (exprReferencesNames(c.orderBy[j].expression, names)) {
                return true;
              }
            }
            break;
        }
      }
      return false;
    default:
      return false;
  }
}

function lvalueReferencesNames(lv: LuaLValue, names: Set<string>): boolean {
  switch (lv.type) {
    case "Variable":
      return names.has(lv.name);
    case "PropertyAccess":
      return exprReferencesNames(lv.object as LuaExpression, names);
    case "TableAccess":
      return exprReferencesNames(lv.object as LuaExpression, names) ||
        exprReferencesNames(lv.key, names);
  }
}

// Does a function body reference any of `names` NOT shadowed by its
// parameters?
function functionBodyCapturesNames(
  body: LuaFunctionBody,
  names: Set<string>,
): boolean {
  let unshadowed: Set<string> | null = null;
  for (let i = 0; i < body.parameters.length; i++) {
    if (names.has(body.parameters[i])) {
      if (!unshadowed) unshadowed = new Set(names);
      unshadowed.delete(body.parameters[i]);
    }
  }
  const check = unshadowed ?? names;
  if (check.size === 0) return false;
  return blockReferencesNames(body.block, check);
}

// Walk block using `exprReferencesNames` (inside a function body).
function blockReferencesNames(block: LuaBlock, names: Set<string>): boolean {
  for (let i = 0; i < block.statements.length; i++) {
    if (statementReferencesNames(block.statements[i], names)) return true;
  }
  return false;
}

function statementReferencesNames(
  s: LuaStatement,
  names: Set<string>,
): boolean {
  switch (s.type) {
    case "Local": {
      const exprs = (s as any).expressions as LuaExpression[] | undefined;
      if (exprs) {
        for (let i = 0; i < exprs.length; i++) {
          if (exprReferencesNames(exprs[i], names)) return true;
        }
      }
      return false;
    }
    case "LocalFunction": {
      const lf = s as any;
      return functionBodyCapturesNames(lf.body as LuaFunctionBody, names);
    }
    case "Function": {
      const fn = s as any;
      return functionBodyCapturesNames(fn.body as LuaFunctionBody, names);
    }
    case "FunctionCallStatement": {
      const call = (s as any).call as LuaFunctionCallExpression;
      if (exprReferencesNames(call.prefix, names)) return true;
      for (let i = 0; i < call.args.length; i++) {
        if (exprReferencesNames(call.args[i], names)) return true;
      }
      return false;
    }
    case "Assignment": {
      const a = s as any;
      const vars = a.variables as LuaLValue[];
      if (vars) {
        for (let i = 0; i < vars.length; i++) {
          if (lvalueReferencesNames(vars[i], names)) return true;
        }
      }
      const exprs = a.expressions as LuaExpression[];
      for (let i = 0; i < exprs.length; i++) {
        if (exprReferencesNames(exprs[i], names)) return true;
      }
      return false;
    }
    case "Return": {
      const exprs = (s as any).expressions as LuaExpression[];
      for (let i = 0; i < exprs.length; i++) {
        if (exprReferencesNames(exprs[i], names)) return true;
      }
      return false;
    }
    case "Block":
      return blockReferencesNames(s as LuaBlock, names);
    case "If": {
      const iff = s as LuaIfStatement;
      for (const c of iff.conditions) {
        if (exprReferencesNames(c.condition, names)) return true;
        if (blockReferencesNames(c.block, names)) return true;
      }
      if (iff.elseBlock && blockReferencesNames(iff.elseBlock, names)) {
        return true;
      }
      return false;
    }
    case "While": {
      const w = s as any;
      if (exprReferencesNames(w.condition, names)) return true;
      return blockReferencesNames(w.block as LuaBlock, names);
    }
    case "Repeat": {
      const r = s as any;
      if (blockReferencesNames(r.block as LuaBlock, names)) return true;
      if (exprReferencesNames(r.condition, names)) return true;
      return false;
    }
    case "For": {
      const fr = s as any;
      if (exprReferencesNames(fr.start, names)) return true;
      if (exprReferencesNames(fr.end, names)) return true;
      if (fr.step && exprReferencesNames(fr.step, names)) return true;
      return blockReferencesNames(fr.block as LuaBlock, names);
    }
    case "ForIn": {
      const fi = s as any;
      const exprs = fi.expressions as LuaExpression[];
      for (let i = 0; i < exprs.length; i++) {
        if (exprReferencesNames(exprs[i], names)) return true;
      }
      return blockReferencesNames(fi.block as LuaBlock, names);
    }
    default:
      return false;
  }
}

// Walk block looking for `FunctionDefinition` nodes that capture `names`.
function blockCapturesNames(block: LuaBlock, names: Set<string>): boolean {
  for (let i = 0; i < block.statements.length; i++) {
    if (statementCapturesNames(block.statements[i], names)) return true;
  }
  return false;
}

function statementCapturesNames(
  s: LuaStatement,
  names: Set<string>,
): boolean {
  switch (s.type) {
    case "Local": {
      const exprs = (s as any).expressions as LuaExpression[] | undefined;
      if (exprs) {
        for (let i = 0; i < exprs.length; i++) {
          if (exprCapturesNames(exprs[i], names)) return true;
        }
      }
      return false;
    }
    case "LocalFunction": {
      const lf = s as any;
      return functionBodyCapturesNames(lf.body as LuaFunctionBody, names);
    }
    case "Function": {
      const fn = s as any;
      return functionBodyCapturesNames(fn.body as LuaFunctionBody, names);
    }
    case "FunctionCallStatement": {
      const call = (s as any).call as LuaFunctionCallExpression;
      if (exprCapturesNames(call.prefix, names)) return true;
      for (let i = 0; i < call.args.length; i++) {
        if (exprCapturesNames(call.args[i], names)) return true;
      }
      return false;
    }
    case "Assignment": {
      const exprs = (s as any).expressions as LuaExpression[];
      for (let i = 0; i < exprs.length; i++) {
        if (exprCapturesNames(exprs[i], names)) return true;
      }
      return false;
    }
    case "Return": {
      const exprs = (s as any).expressions as LuaExpression[];
      for (let i = 0; i < exprs.length; i++) {
        if (exprCapturesNames(exprs[i], names)) return true;
      }
      return false;
    }
    case "Block":
      return blockCapturesNames(s as LuaBlock, names);
    case "If": {
      const iff = s as LuaIfStatement;
      for (const c of iff.conditions) {
        if (exprCapturesNames(c.condition, names)) return true;
        if (blockCapturesNames(c.block, names)) return true;
      }
      if (iff.elseBlock && blockCapturesNames(iff.elseBlock, names)) {
        return true;
      }
      return false;
    }
    case "While": {
      const w = s as any;
      if (exprCapturesNames(w.condition, names)) return true;
      return blockCapturesNames(w.block as LuaBlock, names);
    }
    case "Repeat": {
      const r = s as any;
      if (blockCapturesNames(r.block as LuaBlock, names)) return true;
      if (exprCapturesNames(r.condition, names)) return true;
      return false;
    }
    case "For": {
      const fr = s as any;
      if (exprCapturesNames(fr.start, names)) return true;
      if (exprCapturesNames(fr.end, names)) return true;
      if (fr.step && exprCapturesNames(fr.step, names)) return true;
      return blockCapturesNames(fr.block as LuaBlock, names);
    }
    case "ForIn": {
      const fi = s as any;
      const exprs = fi.expressions as LuaExpression[];
      for (let i = 0; i < exprs.length; i++) {
        if (exprCapturesNames(exprs[i], names)) return true;
      }
      return blockCapturesNames(fi.block as LuaBlock, names);
    }
    default:
      return false;
  }
}

// At loop block level find `FunctionDefinition` and check if it
// captures `names`.
function exprCapturesNames(e: LuaExpression, names: Set<string>): boolean {
  if (!e) return false;
  switch (e.type) {
    case "FunctionDefinition":
      return functionBodyCapturesNames(e.body, names);
    case "Binary":
      return exprCapturesNames(e.left, names) ||
        exprCapturesNames(e.right, names);
    case "Unary":
      return exprCapturesNames(e.argument, names);
    case "Parenthesized":
      return exprCapturesNames(e.expression, names);
    case "FunctionCall":
      if (exprCapturesNames(e.prefix, names)) return true;
      for (let i = 0; i < e.args.length; i++) {
        if (exprCapturesNames(e.args[i], names)) return true;
      }
      return false;
    case "TableAccess":
      return exprCapturesNames(e.object, names) ||
        exprCapturesNames(e.key, names);
    case "PropertyAccess":
      return exprCapturesNames(e.object, names);
    case "TableConstructor":
      for (let i = 0; i < e.fields.length; i++) {
        const f = e.fields[i];
        switch (f.type) {
          case "DynamicField":
            if (exprCapturesNames(f.key, names)) return true;
            if (exprCapturesNames(f.value, names)) return true;
            break;
          case "PropField":
          case "ExpressionField":
            if (exprCapturesNames(f.value, names)) return true;
            break;
        }
      }
      return false;
    case "Query":
      for (let i = 0; i < e.clauses.length; i++) {
        const c = e.clauses[i];
        switch (c.type) {
          case "From":
            if (exprCapturesNames(c.expression, names)) return true;
            break;
          case "Where":
          case "Select":
            if (exprCapturesNames(c.expression, names)) return true;
            break;
          case "Limit":
            if (exprCapturesNames(c.limit, names)) return true;
            if (c.offset && exprCapturesNames(c.offset, names)) return true;
            break;
          case "OrderBy":
            for (let j = 0; j < c.orderBy.length; j++) {
              if (exprCapturesNames(c.orderBy[j].expression, names)) {
                return true;
              }
            }
            break;
        }
      }
      return false;
    default:
      return false;
  }
}

function parseBlock(t: ParseTree, ctx: ASTCtx): LuaBlock {
  if (t.type !== "Block") {
    throw new Error(`Expected Block, got ${t.type}`);
  }
  const stmtNodes = t.children!.filter((c) => c && c.type);
  const statements = stmtNodes.map((s) => parseStatement(s, ctx));
  const block: LuaBlock = { type: "Block", statements, ctx: context(t, ctx) };

  let hasLabel = false;
  let hasGoto = false;
  let hasLocalDecl = false;
  let dup: { name: string; ctx: ASTCtx } | undefined;
  let hasLabelHere = false;
  let hasCloseHere = false;
  let hasFunctionDef = false;

  const seen = new Set<string>();

  for (const s of statements) {
    switch (s.type) {
      case "Label": {
        hasLabel = true;
        hasLabelHere = true;
        // Duplicate labels in the same block are illegal
        const name = (s as any).name as string;
        if (!dup) {
          if (seen.has(name)) {
            dup = { name, ctx: (s as any).ctx as ASTCtx };
          } else {
            seen.add(name);
          }
        }
        break;
      }
      case "Goto": {
        hasGoto = true;
        break;
      }
      case "Local": {
        hasLocalDecl = true;
        if (!hasCloseHere) {
          hasCloseHere = hasCloseLocal((s as any).names as LuaAttName[]);
        }
        if (!hasFunctionDef) {
          hasFunctionDef = expressionsHaveFunctionDef(
            (s as any).expressions as LuaExpression[] | undefined,
          );
        }
        break;
      }
      case "LocalFunction": {
        hasLocalDecl = true;
        hasFunctionDef = true;
        break;
      }
      case "Function": {
        hasFunctionDef = true;
        break;
      }
      case "FunctionCallStatement": {
        if (!hasFunctionDef) {
          const call = (s as any).call as LuaFunctionCallExpression;
          hasFunctionDef = expressionHasFunctionDef(call.prefix) ||
            expressionsHaveFunctionDef(call.args);
        }
        break;
      }
      case "Assignment": {
        if (!hasFunctionDef) {
          hasFunctionDef = expressionsHaveFunctionDef(
            (s as any).expressions as LuaExpression[],
          );
        }
        break;
      }
      case "Return": {
        if (!hasFunctionDef) {
          hasFunctionDef = expressionsHaveFunctionDef(
            (s as any).expressions as LuaExpression[],
          );
        }
        break;
      }
      case "Block": {
        const child = s as LuaBlock;
        hasLabel = hasLabel || !!child.hasLabel;
        hasGoto = hasGoto || !!child.hasGoto;
        hasCloseHere = hasCloseHere || !!child.hasCloseHere;
        hasFunctionDef = hasFunctionDef || !!child.hasFunctionDef;
        break;
      }
      case "If": {
        const iff = s as LuaIfStatement;
        for (const c of iff.conditions) {
          hasLabel = hasLabel || !!c.block.hasLabel;
          hasGoto = hasGoto || !!c.block.hasGoto;
          hasCloseHere = hasCloseHere || !!c.block.hasCloseHere;
          hasFunctionDef = hasFunctionDef || !!c.block.hasFunctionDef;
          if (!hasFunctionDef) {
            hasFunctionDef = expressionHasFunctionDef(c.condition);
          }
        }
        if (iff.elseBlock) {
          hasLabel = hasLabel || !!iff.elseBlock.hasLabel;
          hasGoto = hasGoto || !!iff.elseBlock.hasGoto;
          hasCloseHere = hasCloseHere || !!iff.elseBlock.hasCloseHere;
          hasFunctionDef = hasFunctionDef || !!iff.elseBlock.hasFunctionDef;
        }
        break;
      }
      case "While":
      case "Repeat": {
        const child = (s as any).block as LuaBlock;
        hasLabel = hasLabel || !!child.hasLabel;
        hasGoto = hasGoto || !!child.hasGoto;
        hasCloseHere = hasCloseHere || !!child.hasCloseHere;
        hasFunctionDef = hasFunctionDef || !!child.hasFunctionDef;
        if (!hasFunctionDef) {
          hasFunctionDef = expressionHasFunctionDef((s as any).condition);
        }
        break;
      }
      case "For": {
        const child = (s as any).block as LuaBlock;
        hasLabel = hasLabel || !!child.hasLabel;
        hasGoto = hasGoto || !!child.hasGoto;
        hasCloseHere = hasCloseHere || !!child.hasCloseHere;
        hasFunctionDef = hasFunctionDef || !!child.hasFunctionDef;
        if (!hasFunctionDef) {
          hasFunctionDef = expressionHasFunctionDef((s as any).start) ||
            expressionHasFunctionDef((s as any).end) ||
            ((s as any).step
              ? expressionHasFunctionDef((s as any).step)
              : false);
        }
        break;
      }
      case "ForIn": {
        const child = (s as any).block as LuaBlock;
        hasLabel = hasLabel || !!child.hasLabel;
        hasGoto = hasGoto || !!child.hasGoto;
        hasCloseHere = true;
        hasFunctionDef = hasFunctionDef || !!child.hasFunctionDef;
        if (!hasFunctionDef) {
          hasFunctionDef = expressionsHaveFunctionDef(
            (s as any).expressions as LuaExpression[],
          );
        }
        break;
      }
      default: {
        break;
      }
    }
  }

  if (hasLabel) {
    block.hasLabel = true;
  }
  if (hasGoto) {
    block.hasGoto = true;
  }
  if (dup) {
    block.dupLabelError = dup;
  }
  if (hasLocalDecl) {
    block.needsEnv = true;
  }
  if (hasLabelHere) {
    block.hasLabelHere = true;
  }
  if (hasCloseHere) {
    block.hasCloseHere = true;
  }
  if (hasFunctionDef) {
    block.hasFunctionDef = true;
  }

  return block;
}

function parseStatement(t: ParseTree, ctx: ASTCtx): LuaStatement {
  if (!t || !t.type) {
    return {
      type: "Semicolon",
      ctx: context(t, ctx),
    };
  }
  switch (t.type) {
    case "Block":
      return parseChunk(t.children![0], ctx);
    case "Semicolon":
      return { type: "Semicolon", ctx: context(t, ctx) };
    case "Label":
      return {
        type: "Label",
        name: t.children![1].children![0].text!,
        ctx: context(t, ctx),
      };
    case "Break":
      return { type: "Break", ctx: context(t, ctx) };
    case "Goto":
      return {
        type: "Goto",
        name: t.children![1].children![0].text!,
        ctx: context(t, ctx),
      };
    case "Scope":
      return parseBlock(t.children![1], ctx);
    case ";":
      return { type: "Semicolon", ctx: context(t, ctx) };
    case "WhileStatement":
      return {
        type: "While",
        condition: parseExpression(t.children![1], ctx),
        block: parseBlock(t.children![3], ctx),
        ctx: context(t, ctx),
      };
    case "RepeatStatement":
      return {
        type: "Repeat",
        block: parseBlock(t.children![1], ctx),
        condition: parseExpression(t.children![3], ctx),
        ctx: context(t, ctx),
      };
    case "IfStatement": {
      const conditions: {
        condition: LuaExpression;
        block: LuaBlock;
        from?: number;
        to?: number;
      }[] = [];
      let elseBlock: LuaBlock | undefined = undefined;
      for (let i = 0; i < t.children!.length; i += 4) {
        const child = t.children![i];
        if (!child || !child.children || !child.children[0]) {
          continue;
        }
        const token = child.children![0].text;
        if (token === "if" || token === "elseif") {
          conditions.push({
            condition: parseExpression(t.children![i + 1], ctx),
            block: parseBlock(t.children![i + 3], ctx),
            from: child.from,
            to: child.to,
          });
        } else if (token === "else") {
          elseBlock = parseBlock(t.children![i + 1], ctx);
        } else if (token === "end") {
          break;
        } else {
          throw new Error(`Unknown if clause type: ${token}`);
        }
      }
      return {
        type: "If",
        conditions,
        elseBlock,
        ctx: context(t, ctx),
      };
    }
    case "ForStatement": {
      if (t.children![1].type === "ForNumeric") {
        const forNumeric = t.children![1];
        const name = forNumeric.children![0].children![0].text!;
        const block = parseBlock(t.children![3], ctx);
        const node: LuaStatement = {
          type: "For",
          name,
          start: parseExpression(forNumeric.children![2], ctx),
          end: parseExpression(forNumeric.children![4], ctx),
          step: forNumeric.children![5]
            ? parseExpression(forNumeric.children![6], ctx)
            : undefined,
          block,
          ctx: context(t, ctx),
        };
        if (block.hasFunctionDef) {
          const names = new Set([name]);
          (node as any).capturesLoopVar = blockCapturesNames(block, names);
        }
        return node;
      }
      const forGeneric = t.children![1];
      const names = parseNameList(forGeneric.children![0]);
      const block = parseBlock(t.children![3], ctx);
      const node: LuaStatement = {
        type: "ForIn",
        names,
        expressions: parseExpList(forGeneric.children![2], ctx),
        block,
        ctx: context(t, ctx),
      };
      if (block.hasFunctionDef) {
        const nameSet = new Set(names);
        (node as any).capturesLoopVar = blockCapturesNames(block, nameSet);
      }
      return node;
    }
    case "Function":
      return {
        type: "Function",
        name: parseFunctionName(t.children![1], ctx),
        body: parseFunctionBody(t.children![2], ctx),
        ctx: context(t, ctx),
      };
    case "LocalFunction":
      return {
        type: "LocalFunction",
        name: t.children![2].children![0].text!,
        body: parseFunctionBody(t.children![3], ctx),
        ctx: context(t, ctx),
      };
    case "FunctionCall":
      return {
        type: "FunctionCallStatement",
        call: parseFunctionCall(t, ctx),
        ctx: context(t, ctx),
      };
    case "Assign":
      return {
        type: "Assignment",
        variables: t.children![0].children!
          .filter((c) => c.type && c.type !== ",")
          .map((lvalue) => parseLValue(lvalue, ctx)),
        expressions: parseExpList(t.children![2], ctx),
        ctx: context(t, ctx),
      };
    case "Local": {
      const names = parseAttNames(t.children![1], ctx);

      let closeCount = 0;
      for (const n of names) {
        if (n.attributes?.includes(LuaAttribute.Close) === true) {
          closeCount++;
          if (closeCount > 1) {
            throw new Error("multiple <close> variables in local list");
          }
        }
      }

      return {
        type: "Local",
        names,
        expressions: t.children![3] ? parseExpList(t.children![3], ctx) : [],
        ctx: context(t, ctx),
      };
    }
    case "ReturnStatement": {
      const expressions = t.children![1]
        ? parseExpList(t.children![1], ctx)
        : [];
      return { type: "Return", expressions, ctx: context(t, ctx) };
    }
    case "break":
      return { type: "Break", ctx: context(t, ctx) };
    default:
      // Gracefully ignore unknown empty nodes
      if (!t.children || t.children.length === 0) {
        return {
          type: "Semicolon",
          ctx: context(t, ctx),
        };
      }
      console.error(t);
      throw new Error(
        `Unknown statement type: ${
          t.children![0] && t.children![0].text
            ? t.children![0].text
            : String(t.type)
        }`,
      );
  }
}

function parseFunctionCall(
  t: ParseTree,
  ctx: ASTCtx,
): LuaFunctionCallExpression {
  if (t.children![1] && t.children![1].type === ":") {
    return {
      type: "FunctionCall",
      prefix: parsePrefixExpression(t.children![0], ctx),
      name: t.children![2].children![0].text!,
      args: parseFunctionArgs(t.children!.slice(3), ctx),
      ctx: context(t, ctx),
    };
  }
  return {
    type: "FunctionCall",
    prefix: parsePrefixExpression(t.children![0], ctx),
    args: parseFunctionArgs(t.children!.slice(1), ctx),
    ctx: context(t, ctx),
  };
}

function parseAttNames(t: ParseTree, ctx: ASTCtx): LuaAttName[] {
  if (t.type !== "AttNameList") {
    throw new Error(`Expected AttNameList, got ${t.type}`);
  }
  return t.children!
    .filter((c) => c.type && c.type !== ",")
    .map((att) => parseAttName(att, ctx));
}

function parseAttName(t: ParseTree, ctx: ASTCtx): LuaAttName {
  if (t.type !== "AttName") {
    throw new Error(`Expected AttName, got ${t.type}`);
  }
  const attribute = t.children![1].children![1]
    ? t.children![1].children![1].children![0].text!
    : undefined;
  if (
    attribute &&
    attribute !== LuaAttribute.Const &&
    attribute !== LuaAttribute.Close
  ) {
    throw new Error(`unknown attribute '${attribute}'`);
  }
  const attributes = attribute ? [attribute as LuaAttribute] : undefined;
  return {
    type: "AttName",
    name: t.children![0].children![0].text!,
    attribute,
    attributes,
    ctx: context(t, ctx),
  };
}

function parseLValue(t: ParseTree, ctx: ASTCtx): LuaLValue {
  switch (t.type) {
    case "Name":
      return {
        type: "Variable",
        name: t.children![0].text!,
        ctx: context(t, ctx),
      };
    case "Property":
      return {
        type: "PropertyAccess",
        object: parsePrefixExpression(t.children![0], ctx),
        property: t.children![2].children![0].text!,
        ctx: context(t, ctx),
      };
    case "MemberExpression":
      return {
        type: "TableAccess",
        object: parsePrefixExpression(t.children![0], ctx),
        key: parseExpression(t.children![2], ctx),
        ctx: context(t, ctx),
      };
    default:
      console.error(t);
      throw new Error(`Unknown lvalue type: ${t.type}`);
  }
}

function parseFunctionName(t: ParseTree, ctx: ASTCtx): LuaFunctionName {
  if (t.type !== "FuncName") {
    throw new Error(`Expected FunctionName, got ${t.type}`);
  }
  const propNames: string[] = [];
  let colonName: string | undefined = undefined;
  for (let i = 0; i < t.children!.length; i += 2) {
    const prop = t.children![i];
    propNames.push(prop.children![0].text!);
    if (t.children![i + 1] && t.children![i + 1].type === ":") {
      colonName = t.children![i + 2].children![0].text!;
      break;
    }
  }
  return {
    type: "FunctionName",
    propNames,
    colonName,
    ctx: context(t, ctx),
  };
}

function parseNameList(t: ParseTree): string[] {
  if (t.type !== "NameList") {
    throw new Error(`Expected NameList, got ${t.type}`);
  }
  return t.children!
    .filter((c) => c.type === "Name")
    .map((c) => c.children![0].text!);
}

function parseExpList(t: ParseTree, ctx: ASTCtx): LuaExpression[] {
  if (t.type !== "ExpList") {
    throw new Error(`Expected ExpList, got ${t.type}`);
  }
  return t.children!
    .filter((c) => c.type && c.type !== ",")
    .map((e) => parseExpression(e, ctx));
}

const delimiterRegex = /^(\[=*\[)([\s\S]*)(\]=*\])$/;

// In case of quoted strings, remove the quotes and unescape the string
// In case of a [[ type ]] literal string, remove the brackets
function parseString(s: string): string {
  // Handle long strings with delimiters
  const delimiterMatch = s.match(delimiterRegex);
  if (delimiterMatch) {
    let text = delimiterMatch[2];
    // According to Lua semantics, whenever a [[ formatted string starts with a newline, that newline should be skipped
    if (text[0] === "\n") {
      text = text.slice(1);
    }
    return text;
  }
  return s.slice(1, -1).replace(
    /\\(x[0-9a-fA-F]{2}|u\{[0-9a-fA-F]+\}|[abfnrtv\\'"n])/g,
    (match, capture) => {
      switch (capture) {
        case "a":
          return "\x07"; // Bell
        case "b":
          return "\b"; // Backspace
        case "f":
          return "\f"; // Form feed
        case "n":
          return "\n"; // Newline
        case "r":
          return "\r"; // Carriage return
        case "t":
          return "\t"; // Horizontal tab
        case "v":
          return "\v"; // Vertical tab
        case "\\":
          return "\\"; // Backslash
        case '"':
          return '"'; // Double quote
        case "'":
          return "'"; // Single quote
        default:
          // Handle hexadecimal \x00
          if (capture.startsWith("x")) {
            return String.fromCharCode(parseInt(capture.slice(1), 16));
          }
          // Handle unicode \u{XXXX}
          if (capture.startsWith("u{")) {
            const codePoint = parseInt(capture.slice(2, -1), 16);
            return String.fromCodePoint(codePoint);
          }
          return match; // return the original match if nothing fits
      }
    },
  );
}

function parseExpression(t: ParseTree, ctx: ASTCtx): LuaExpression {
  if (!t || !t.type) {
    throw new Error("Undefined expression node");
  }
  switch (t.type) {
    case "LiteralString": {
      const cleanString = parseString(t.children![0].text!);
      return {
        type: "String",
        value: cleanString,
        ctx: context(t, ctx),
      };
    }
    case "Number": {
      const text = t.children![0].text!.toLowerCase();
      return {
        type: "Number",
        // Use the integer parser fox 0x literals
        value: text.includes("x") ? parseInt(text) : parseFloat(text),
        numericType: /[\.eEpP]/.test(text) ? "float" : "int",
        ctx: context(t, ctx),
      };
    }
    case "BinaryExpression":
      return {
        type: "Binary",
        operator: t.children![1].children![0].text!,
        left: parseExpression(t.children![0], ctx),
        right: parseExpression(t.children![2], ctx),
        ctx: context(t, ctx),
      };
    case "UnaryExpression": {
      const op = t.children![0].children![0].text!;
      if (op === "+") {
        const err = new Error("unexpected symbol near '+'");
        (err as any).astCtx = context(t.children![0], ctx);
        throw err;
      }
      return {
        type: "Unary",
        operator: op,
        argument: parseExpression(t.children![1], ctx),
        ctx: context(t, ctx),
      };
    }
    case "Property":
      return {
        type: "PropertyAccess",
        object: parsePrefixExpression(t.children![0], ctx),
        property: t.children![2].children![0].text!,
        ctx: context(t, ctx),
      };

    case "MemberExpression":
      return {
        type: "TableAccess",
        object: parsePrefixExpression(t.children![0], ctx),
        key: parseExpression(t.children![2], ctx),
        ctx: context(t, ctx),
      };

    case "Parens":
      return parseExpression(t.children![1], ctx);
    case "FunctionCall": {
      return parseFunctionCall(t, ctx);
    }
    case "FunctionDef": {
      const body = parseFunctionBody(t.children![1], ctx);
      return {
        type: "FunctionDefinition",
        body,
        ctx: context(t, ctx),
      };
    }
    case "Name":
      return {
        type: "Variable",
        name: t.children![0].text!,
        ctx: context(t, ctx),
      };
    case "Ellipsis":
      return { type: "Variable", name: "...", ctx: context(t, ctx) };
    case "true":
      return { type: "Boolean", value: true, ctx: context(t, ctx) };
    case "false":
      return { type: "Boolean", value: false, ctx: context(t, ctx) };
    case "TableConstructor":
      return {
        type: "TableConstructor",
        fields: t.children!
          .slice(1, -1)
          .filter((c) =>
            ["FieldExp", "FieldProp", "FieldDynamic"].includes(c.type!)
          )
          .map((tf) => parseTableField(tf, ctx)),
        ctx: context(t, ctx),
      };
    case "nil":
      return { type: "Nil", ctx: context(t, ctx) };
    case "Query":
      return {
        type: "Query",
        clauses: t.children!.slice(2, -1).map((c) => parseQueryClause(c, ctx)),
        ctx: context(t, ctx),
      };
    default:
      console.error(t);
      throw new Error(`Unknown expression type: ${t.type}`);
  }
}

function parseQueryClause(t: ParseTree, ctx: ASTCtx): LuaQueryClause {
  if (t.type !== "QueryClause") {
    throw new Error(`Expected QueryClause, got ${t.type}`);
  }
  t = t.children![0];
  switch (t.type) {
    case "FromClause": {
      if (t.children!.length === 4) {
        // From clause with a name
        return {
          type: "From",
          name: t.children![1].children![0].text!,
          expression: parseExpression(t.children![3], ctx),
          ctx: context(t, ctx),
        };
      }
      return {
        type: "From",
        expression: parseExpression(t.children![1], ctx),
        ctx: context(t, ctx),
      };
    }
    case "WhereClause":
      return {
        type: "Where",
        expression: parseExpression(t.children![1], ctx),
        ctx: context(t, ctx),
      };
    case "LimitClause": {
      const limit = parseExpression(t.children![1], ctx);
      const offset = t.children![2]
        ? parseExpression(t.children![3], ctx)
        : undefined;
      return {
        type: "Limit",
        limit,
        offset,
        ctx: context(t, ctx),
      };
    }
    case "OrderByClause": {
      const orderBy: LuaOrderBy[] = [];
      for (const child of t.children!) {
        if (child.type === "OrderBy") {
          orderBy.push({
            type: "Order",
            expression: parseExpression(child.children![0], ctx),
            direction: child.children![1]?.type === "desc" ? "desc" : "asc",
            ctx: context(child, ctx),
          });
        }
      }
      return {
        type: "OrderBy",
        orderBy,
        ctx: context(t, ctx),
      };
    }
    case "SelectClause": {
      return {
        type: "Select",
        expression: parseExpression(t.children![1], ctx),
        ctx: context(t, ctx),
      };
    }
    default:
      console.error(t);
      throw new Error(`Unknown query clause type: ${t.type}`);
  }
}

function parseFunctionArgs(ts: ParseTree[], ctx: ASTCtx): LuaExpression[] {
  return ts
    .filter((t) => t.type && ![",", "(", ")"].includes(t.type))
    .map((e) => parseExpression(e, ctx));
}

function parseFunctionBody(t: ParseTree, ctx: ASTCtx): LuaFunctionBody {
  if (t.type !== "FuncBody") {
    throw new Error(`Expected FunctionBody, got ${t.type}`);
  }
  return {
    type: "FunctionBody",
    parameters: t.children![1].children!
      .filter((c) => c.type && ["Name", "Ellipsis"].includes(c.type))
      .map((c) => c.children![0].text!),
    block: parseBlock(t.children![3], ctx),
    ctx: context(t, ctx),
  };
}

function parsePrefixExpression(t: ParseTree, ctx: ASTCtx): LuaPrefixExpression {
  if (!t || !t.type) {
    throw new Error("Undefined prefix expression node");
  }
  switch (t.type) {
    case "Name":
      return {
        type: "Variable",
        name: t.children![0].text!,
        ctx: context(t, ctx),
      };
    case "Property":
      return {
        type: "PropertyAccess",
        object: parsePrefixExpression(t.children![0], ctx),
        property: t.children![2].children![0].text!,
        ctx: context(t, ctx),
      };
    case "MemberExpression":
      return {
        type: "TableAccess",
        object: parsePrefixExpression(t.children![0], ctx),
        key: parseExpression(t.children![2], ctx),
        ctx: context(t, ctx),
      };
    case "Parens":
      return {
        type: "Parenthesized",
        expression: parseExpression(t.children![1], ctx),
        ctx: context(t, ctx),
      };
    case "FunctionCall": {
      return parseFunctionCall(t, ctx);
    }
    default:
      console.error(t);
      throw new Error(`Unknown prefix expression type: ${t.type}`);
  }
}

function parseTableField(t: ParseTree, ctx: ASTCtx): LuaTableField {
  switch (t.type) {
    case "FieldExp":
      return {
        type: "ExpressionField",
        value: parseExpression(t.children![0], ctx),
        ctx: context(t, ctx),
      };
    case "FieldProp":
      return {
        type: "PropField",
        key: t.children![0].children![0].text!,
        value: parseExpression(t.children![2], ctx),
        ctx: context(t, ctx),
      };
    case "FieldDynamic":
      return {
        type: "DynamicField",
        key: parseExpression(t.children![1], ctx),
        value: parseExpression(t.children![4], ctx),
        ctx: context(t, ctx),
      };
    default:
      console.error(t);
      throw new Error(`Unknown table field type: ${t.type}`);
  }
}

export function stripLuaComments(s: string): string {
  let result = "";
  let i = 0;

  while (i < s.length) {
    // Check for long string
    if (s[i] === "[") {
      let j = i + 1;
      let equalsCount = 0;
      while (s[j] === "=") {
        equalsCount++;
        j++;
      }
      if (s[j] === "[") {
        // Found long string start
        const openBracket = s.substring(i, j + 1);
        const closeBracket = "]" + "=".repeat(equalsCount) + "]";
        result += openBracket;
        i = j + 1;

        // Find matching closing bracket
        const content = s.substring(i);
        const closeIndex = content.indexOf(closeBracket);
        if (closeIndex !== -1) {
          // Copy string content verbatim, including any comment-like sequences
          result += content.substring(0, closeIndex) + closeBracket;
          i += closeIndex + closeBracket.length;
          continue;
        }
      }
    }

    // Check for single quoted string
    if (s[i] === '"' || s[i] === "'") {
      const quote = s[i];
      result += quote;
      i++;
      while (i < s.length && s[i] !== quote) {
        if (s[i] === "\\") {
          result += s[i] + s[i + 1];
          i += 2;
        } else {
          result += s[i];
          i++;
        }
      }
      if (i < s.length) {
        result += s[i]; // closing quote
        i++;
      }
      continue;
    }

    // Check for comments
    if (s[i] === "-" && s[i + 1] === "-") {
      // Replace the -- with spaces
      result += "  ";
      i += 2;

      // Check for long comment
      if (s[i] === "[") {
        let j = i + 1;
        let equalsCount = 0;
        while (s[j] === "=") {
          equalsCount++;
          j++;
        }
        if (s[j] === "[") {
          // Found long comment start
          const closeBracket = "]" + "=".repeat(equalsCount) + "]";
          // Replace opening bracket with spaces
          result += " ".repeat(j - i + 1);
          i = j + 1;

          // Find matching closing bracket
          const content = s.substring(i);
          const closeIndex = content.indexOf(closeBracket);
          if (closeIndex !== -1) {
            // Replace comment content and closing bracket with spaces
            result += " ".repeat(closeIndex) + " ".repeat(closeBracket.length);
            i += closeIndex + closeBracket.length;
            continue;
          }
        }
      }

      // Single line comment - replace rest of line with spaces
      while (i < s.length && s[i] !== "\n") {
        result += " ";
        i++;
      }
      continue;
    }

    result += s[i];
    i++;
  }

  return result;
}

export function parse(s: string, ctx: ASTCtx = {}): LuaBlock {
  try {
    const t = parseToAST(stripLuaComments(s));
    // console.log("Clean tree", JSON.stringify(t, null, 2));
    const result = parseChunk(t, ctx);
    // console.log("Parsed AST", JSON.stringify(result, null, 2));
    getBlockGotoMeta(result);
    return result;
  } catch (e: any) {
    if (e && typeof e === "object" && "astCtx" in e) {
      throw new LuaRuntimeError(
        e.message,
        LuaStackFrame.lostFrame.withCtx(
          (e as any).astCtx as ASTCtx,
        ),
      );
    }
    throw e;
  }
}

export function parseToAST(t: string): ParseTree {
  const tree = parser.parse(t);

  const errNode = findFirstParseError(tree.topNode);
  if (errNode) {
    const err = new Error(luaUnexpectedSymbolMessage(t, errNode.from));
    (err as any).astCtx = { from: errNode.from, to: errNode.to };
    throw err;
  }

  const n = lezerToParseTree(t, tree.topNode);
  return cleanTree(n, true);
}

function findFirstParseError(node: SyntaxNode): SyntaxNode | null {
  if (node.type.isError) {
    return node;
  }
  for (let ch = node.firstChild; ch; ch = ch.nextSibling) {
    const hit = findFirstParseError(ch);
    if (hit) {
      return hit;
    }
  }
  return null;
}

function luaUnexpectedSymbolMessage(src: string, from: number): string {
  let i = from;
  while (i < src.length && /\s/.test(src[i])) i++;
  const sym = i < src.length ? src[i] : "?";
  return `unexpected symbol near '${sym}'`;
}

/**
 * Helper function to parse a Lua expression string
 */
export function parseExpressionString(
  expr: string,
): LuaExpression {
  const parsedLua = parse(`_(${expr})`) as LuaBlock;
  return (parsedLua.statements[0] as LuaFunctionCallStatement).call.args[0];
}


================================================
FILE: client/space_lua/query_collection.test.ts
================================================
import { parseExpressionString } from "./parse.ts";
import { ArrayQueryCollection } from "./query_collection.ts";
import { LuaEnv, LuaNativeJSFunction, LuaStackFrame } from "./runtime.ts";
import { assert, assertEquals } from "@std/assert";

Deno.test("ArrayQueryCollection", async () => {
  const rootEnv = new LuaEnv();
  rootEnv.setLocal(
    "build_name",
    new LuaNativeJSFunction((a, b) => {
      return Promise.resolve(a + " " + b);
    }),
  );

  const collection = new ArrayQueryCollection([{ x: 1, y: 1 }, { x: 2, y: 2 }, {
    x: 3,
    y: 3,
  }]);
  const result = await collection.query(
    {
      objectVariable: "p",
      where: parseExpressionString("p.x >= 2"),
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    {}, // Default collation configuration, since the config API is unavailable
  );
  // console.log(result);
  assert(result.length === 2);

  // Test limit
  const result2 = await collection.query(
    {
      objectVariable: "p",
      limit: 1,
    },
    rootEnv,
    LuaStackFrame.lostFrame,
  );
  assert(result2.length === 1);
  assert(result2[0].x === 1);

  // Test offset
  const result3 = await collection.query(
    {
      objectVariable: "p",
      offset: 1,
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    {},
  );
  assert(result3.length === 2);
  assert(result3[0].x === 2);

  // Test order by
  const result4 = await collection.query(
    {
      objectVariable: "p",
      orderBy: [{ expr: parseExpressionString("p.x"), desc: false }],
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    {},
  );
  assert(result4.length === 3);
  assert(result4[0].x === 1);
  assert(result4[1].x === 2);
  assert(result4[2].x === 3);

  // Test order by desc
  const result5 = await collection.query(
    {
      objectVariable: "p",
      orderBy: [{ expr: parseExpressionString("p.x"), desc: true }],
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    {},
  );
  assert(result5.length === 3);
  assert(result5[0].x === 3);
  assert(result5[1].x === 2);
  assert(result5[2].x === 1);

  // Test order by multiple fields
  const collection2 = new ArrayQueryCollection([
    { firstName: "John", lastName: "Doe" },
    { firstName: "Alice", lastName: "Johnson" },
    { firstName: "Jane", lastName: "Doe" },
    { firstName: "Bob", lastName: "Johnson" },
  ]);
  const result6 = await collection2.query(
    {
      objectVariable: "p",
      orderBy: [
        { expr: parseExpressionString("p.lastName"), desc: false },
        { expr: parseExpressionString("p.firstName"), desc: true },
      ],
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    {},
  );
  assertEquals(result6[0].firstName, "John");
  assertEquals(result6[0].lastName, "Doe");
  assertEquals(result6[1].firstName, "Jane");
  assertEquals(result6[1].lastName, "Doe");
  assertEquals(result6[2].firstName, "Bob");
  assertEquals(result6[2].lastName, "Johnson");
  assertEquals(result6[3].firstName, "Alice");
  assertEquals(result6[3].lastName, "Johnson");

  // Test select with expression
  const result8 = await collection2.query(
    {
      objectVariable: "p",
      select: parseExpressionString("p.firstName .. ' ' .. p.lastName"),
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    {},
  );
  assertEquals(result8[0], "John Doe");
  assertEquals(result8[1], "Alice Johnson");
  assertEquals(result8[2], "Jane Doe");
  assertEquals(result8[3], "Bob Johnson");

  // Test select with native function and implicit object variable
  const result9 = await collection2.query(
    {
      select: parseExpressionString("build_name(firstName, lastName)"),
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    {},
  );
  assertEquals(result9[0], "John Doe");
  assertEquals(result9[1], "Alice Johnson");
  assertEquals(result9[2], "Jane Doe");
  assertEquals(result9[3], "Bob Johnson");

  // Test distinct
  const collectionWithDuplicates = new ArrayQueryCollection([
    { category: "fruit", name: "apple" },
    { category: "vegetable", name: "carrot" },
    { category: "fruit", name: "banana" },
    { category: "fruit", name: "apple" }, // Duplicate
    { category: "vegetable", name: "spinach" },
    { category: "fruit", name: "banana" }, // Duplicate
  ]);

  // Test distinct with select
  const distinctResult = await collectionWithDuplicates.query(
    {
      objectVariable: "item",
      select: parseExpressionString("item.category"),
      distinct: true,
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    {},
  );
  assertEquals(distinctResult.length, 2);
  assertEquals(distinctResult.includes("fruit"), true);
  assertEquals(distinctResult.includes("vegetable"), true);

  // Test distinct with objects
  const distinctObjectsResult = await collectionWithDuplicates.query(
    {
      objectVariable: "item",
      select: parseExpressionString(
        "{ category = item.category, name = item.name }",
      ),
      distinct: true,
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    {},
  );
  assertEquals(distinctObjectsResult.length, 4);

  // Test string sorting (collation) with example from MDN
  const letterCollection = new ArrayQueryCollection([
    { letter: "Z" },
    { letter: "z" },
    { letter: "ä" },
    { letter: "a" },
  ]);

  // Default ordering by codepoint
  const resultCodepoint = await letterCollection.query(
    {
      objectVariable: "item",
      orderBy: [{ expr: parseExpressionString("item.letter"), desc: false }],
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    { enabled: false },
  );
  assertEquals(resultCodepoint[0].letter, "Z");
  assertEquals(resultCodepoint[1].letter, "a");
  assertEquals(resultCodepoint[2].letter, "z");
  assertEquals(resultCodepoint[3].letter, "ä");

  // Defaults for German
  const resultGerman = await letterCollection.query(
    {
      objectVariable: "item",
      orderBy: [{ expr: parseExpressionString("item.letter"), desc: false }],
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    { enabled: true, locale: "de" },
  );
  assertEquals(resultGerman[0].letter, "a");
  assertEquals(resultGerman[1].letter, "ä");
  assertEquals(resultGerman[2].letter, "z");
  assertEquals(resultGerman[3].letter, "Z");

  // Defaults for Swedish
  const resultSwedish = await letterCollection.query(
    {
      objectVariable: "item",
      orderBy: [{ expr: parseExpressionString("item.letter"), desc: false }],
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    { enabled: true, locale: "sv" },
  );
  assertEquals(resultSwedish[0].letter, "a");
  assertEquals(resultSwedish[1].letter, "z");
  assertEquals(resultSwedish[2].letter, "Z");
  assertEquals(resultSwedish[3].letter, "ä");

  // Uppercase first
  const resultUpper = await letterCollection.query(
    {
      objectVariable: "item",
      orderBy: [{ expr: parseExpressionString("item.letter"), desc: false }],
    },
    rootEnv,
    LuaStackFrame.lostFrame,
    { enabled: true, locale: "de", options: { caseFirst: "upper" } },
  );
  assertEquals(resultUpper[0].letter, "a");
  assertEquals(resultUpper[1].letter, "ä");
  assertEquals(resultUpper[2].letter, "Z");
  assertEquals(resultUpper[3].letter, "z");
});


================================================
FILE: client/space_lua/query_collection.ts
================================================
import type { LuaExpression } from "./ast.ts";
import { LuaEnv, luaGet, luaKeys, LuaStackFrame, LuaTable } from "./runtime.ts";
import { evalExpression } from "./eval.ts";
import { asyncQuickSort } from "./util.ts";
import type { DataStore } from "../data/datastore.ts";
import type { KvPrimitives } from "../data/kv_primitives.ts";

import type { QueryCollationConfig } from "../../plug-api/types/config.ts";

import type { KvKey } from "../../plug-api/types/datastore.ts";

export function buildItemEnv(
  objectVariable: string | undefined,
  item: any,
  env: LuaEnv,
  sf: LuaStackFrame,
): LuaEnv {
  const itemEnv = new LuaEnv(env);
  if (!objectVariable) {
    // Inject all item keys as variables
    for (const key of luaKeys(item)) {
      itemEnv.setLocal(key, luaGet(item, key, sf.astCtx ?? null, sf));
    }
    // As well as _
    itemEnv.setLocal("_", item);
  } else {
    itemEnv.setLocal(objectVariable, item);
  }
  return itemEnv;
}

export type LuaOrderBy = {
  expr: LuaExpression;
  desc: boolean;
};

/**
 * Represents a query for a collection
 */
export type LuaCollectionQuery = {
  objectVariable?: string;
  // The filter expression evaluated with Lua
  where?: LuaExpression;
  // The order by expression evaluated with Lua
  orderBy?: LuaOrderBy[];
  // The select expression evaluated with Lua
  select?: LuaExpression;
  // The limit of the query
  limit?: number;
  // The offset of the query
  offset?: number;
  // Whether to return only distinct values
  distinct?: boolean;
};

export interface LuaQueryCollection {
  query(
    query: LuaCollectionQuery,
    env: LuaEnv,
    sf: LuaStackFrame,
  ): Promise<any[]>;
}

/**
 * Implements a query collection for a regular JavaScript array
 */
export class ArrayQueryCollection<T> implements LuaQueryCollection {
  constructor(private readonly array: T[]) {
  }

  query(
    query: LuaCollectionQuery,
    env: LuaEnv,
    sf: LuaStackFrame,
    collation?: QueryCollationConfig,
  ): Promise<any[]> {
    return applyQuery(this.array, query, env, sf, collation);
  }
}

/**
 * Applies a given query (where, order by, limit etc.) to a set of results
 */
export async function applyQuery(
  results: any[],
  query: LuaCollectionQuery,
  env: LuaEnv,
  sf: LuaStackFrame,
  collation?: QueryCollationConfig,
): Promise<any[]> {
  // Shallow copy to avoid updating underlying data structures
  results = results.slice();

  // Filter results based on `where` clause first
  if (query.where) {
    const filteredResults = [];
    for (const value of results) {
      // Enrich value
      const itemEnv = buildItemEnv(query.objectVariable, value, env, sf);
      if (await evalExpression(query.where, itemEnv, sf)) {
        filteredResults.push(value);
      }
    }
    results = filteredResults;
  }

  // Apply `order by` next
  if (query.orderBy) {
    // Retrieve from config API if not passed
    if (collation === undefined) {
      // @ts-ignore: Hack to access client via the browser
      const config = globalThis.client.config; // HACK: Shouldn't be using client here directly

      collation = config.get("queryCollation", {});
    }
    // Both arguments are optional, so passing undefined is fine
    const collator = Intl.Collator(collation?.locale, collation?.options);

    results = await asyncQuickSort(results, async (a, b) => {
      // Compare each orderBy clause until we find a difference
      for (const { expr, desc } of query.orderBy!) {
        const aEnv = buildItemEnv(query.objectVariable, a, env, sf);
        const bEnv = buildItemEnv(query.objectVariable, b, env, sf);

        const aVal = await evalExpression(expr, aEnv, sf);
        const bVal = await evalExpression(expr, bEnv, sf);

        if (
          collation?.enabled &&
          typeof aVal === "string" &&
          typeof bVal === "string"
        ) {
          const order = collator.compare(aVal, bVal);
          if (order != 0) {
            return desc ? -order : order;
          }
        } else if (aVal < bVal) {
          return desc ? 1 : -1;
        } else if (aVal > bVal) {
          return desc ? -1 : 1;
        }
        // If equal, continue to next orderBy clause
      }
      return 0; // All orderBy clauses were equal
    });
  }

  // Apply the select
  if (query.select) {
    const newResult = [];
    for (const item of results) {
      const itemEnv = buildItemEnv(query.objectVariable, item, env, sf);
      newResult.push(await evalExpression(query.select, itemEnv, sf));
    }
    results = newResult;
  }

  // Apply distinct filter (after select to filter on selected values)
  if (query.distinct) {
    const seen = new Set();
    const distinctResult = [];

    for (const item of results) {
      // For non-primitive values, we use a JSON string as the key for comparison
      const key = generateKey(item);

      if (!seen.has(key)) {
        seen.add(key);
        distinctResult.push(item);
      }
    }

    results = distinctResult;
  }

  // Apply the limit and offset
  if (query.limit !== undefined && query.offset !== undefined) {
    results = results.slice(query.offset, query.offset + query.limit);
  } else if (query.limit !== undefined) {
    results = results.slice(0, query.limit);
  } else if (query.offset !== undefined) {
    results = results.slice(query.offset);
  }

  return results;
}

export async function queryLua<T = any>(
  kv: KvPrimitives,
  prefix: KvKey,
  query: LuaCollectionQuery,
  env: LuaEnv,
  sf: LuaStackFrame = LuaStackFrame.lostFrame,
  enricher?: (key: KvKey, item: any) => any,
): Promise<T[]> {
  const results: T[] = [];
  // Accumulate all results into an array
  for await (
    let { key, value } of kv.query({ prefix })
  ) {
    if (enricher) {
      value = enricher(key, value);
    }
    results.push(value);
  }

  return applyQuery(results, query, env, sf);
}

function generateKey(value: any) {
  if (value instanceof LuaTable) {
    return JSON.stringify(value.toJS());
  }
  return typeof value === "object" && value !== null
    ? JSON.stringify(value)
    : value;
}
export class DataStoreQueryCollection implements LuaQueryCollection {
  constructor(
    private readonly dataStore: DataStore,
    readonly prefix: string[],
  ) {
  }

  query(
    query: LuaCollectionQuery,
    env: LuaEnv,
    sf: LuaStackFrame,
  ): Promise<any[]> {
    return queryLua(this.dataStore.kv, this.prefix, query, env, sf);
  }
}


================================================
FILE: client/space_lua/query_test.lua
================================================
local function assertEquals(a, b)
    if a ~= b then
        error("Assertion failed: " .. a .. " is not equal to " .. b)
    end
end

-- Test query functionality
local data = { { name = "John", lastModified = 1, age = 20 }, { name = "Jane", lastModified = 2, age = 21 } }

-- Basic limit query
local r = query [[from p = data limit 1]]
assertEquals(#r, 1)
assertEquals(r[1].name, "John")
assertEquals(r[1].lastModified, 1)

-- Order by descending
local r = query [[from p = data order by p.lastModified desc]]
assertEquals(#r, 2)
assertEquals(r[1].name, "Jane")
assertEquals(r[1].lastModified, 2)
assertEquals(r[2].name, "John")
assertEquals(r[2].lastModified, 1)

-- Order by ascending
local r = query [[from p = data order by p.lastModified]]
assertEquals(#r, 2)
assertEquals(r[1].name, "John")
assertEquals(r[1].lastModified, 1)
assertEquals(r[2].name, "Jane")
assertEquals(r[2].lastModified, 2)

-- Select specific fields
local r = query [[from p = data order by p.age select {name=p.name, age=p.age}]]
assertEquals(#r, 2)
assertEquals(r[1].name, "John")
assertEquals(r[1].age, 20)
assertEquals(r[2].name, "Jane")
assertEquals(r[2].age, 21)
assertEquals(r[1].lastModified, nil)
assertEquals(r[2].lastModified, nil)

-- Array transformation
local r = query [[from {1, 2, 3} select _ + 1]]
assertEquals(#r, 3)
assertEquals(r[1], 2)
assertEquals(r[2], 3)
assertEquals(r[3], 4) 

================================================
FILE: client/space_lua/rp.bench.ts
================================================
// Benchmark suite for Space Lua RP (Result-or-Promise) optimizations
// that exercises hot synchronous paths (binary ops, loops, function
// calls, argument lists, table get/set, concatenation).
//
// # NOTES
//
// * Parsing cost is excluded from measured time by compiling each
//   snippet once.
//
// * Each bench creates a fresh Lua environment to isolate state.
//
// * Minimal global environment (`_GLOBAL`) is installed with included:
//
//   * `string.format` (simple `%s` formatter) and
//   * `type`.
//
// * To add benches that need more stdlib, extend makeEnv() accordingly.

import { evalStatement } from "./eval.ts";
import {
  LuaBuiltinFunction,
  LuaEnv,
  LuaRuntimeError,
  LuaStackFrame,
  LuaTable,
  luaTypeOf,
} from "./runtime.ts";
import { parse as parseLua } from "./parse.ts";

const LOOP = 100000;
const SMALL = 20000;

function makeEnv(): { global: LuaEnv; sf: LuaStackFrame } {
  const global = new LuaEnv();

  const stringLib = new LuaTable({
    format: (fmt: string, ...args: any[]) => {
      let i = 0;
      return String(fmt).replace(/%s/g, () => String(args[i++]));
    },
  });

  global.setLocal("string", stringLib);

  global.setLocal(
    "type",
    new LuaBuiltinFunction((_sf, v) => luaTypeOf(v)),
  );

  const sf = LuaStackFrame.createWithGlobalEnv(global);

  return { global, sf };
}

async function run(ast: any) {
  const { global, sf } = makeEnv();
  try {
    const r = evalStatement(ast, global, sf, false);
    if (r instanceof Promise) {
      await r;
    }
  } catch (e) {
    if (e instanceof LuaRuntimeError) {
      throw e;
    }
    throw new Error(`Lua execution error: ${e && (e as any).message || e}`);
  }
}

// Snippets

const luaWhileSync = `
  local i = 0
  local s = 0
  while i < ${LOOP} do
    i = i + 1
    s = s + i
  end
`;

const luaForNumeric = `
  local s = 0
  for i = 1, ${LOOP} do
    s = s + i
  end
`;

const luaWhileFuncCondTruthy = `
  local n = 0
  local function next_or_nil()
    n = n + 1
    if n <= 2 then
      return 0  -- 0 is truthy; should loop exactly twice
    end
    return nil
  end
  local count = 0
  while next_or_nil() do
    count = count + 1
  end
`;

const luaFuncCallArgs = `
  local s = 0
  local function f(a, b, c) return a + b + c end
  for i = 1, ${LOOP}, 3 do
    s = s + f(i, i + 1, i + 2)
  end
`;

const luaTableGetSet = `
  local t = { a = 1 }
  for i = 1, ${LOOP} do
    t.a = t.a + 1
  end
`;

const luaTableIndexNumeric = `
  local t = {}
  for i = 1, ${LOOP} do
    t[i] = i
  end
  local s = 0
  for i = 1, ${LOOP} do
    s = s + t[i]
  end
`;

const luaConcatStrings = `
  local s = ""
  for i = 1, ${SMALL} do
    s = s .. "x"
  end
`;

const luaArithmeticBinary = `
  local a, b, c = 1, 2, 3
  local s = 0
  for i = 1, ${LOOP} do
    s = s + (a * b) - (c / a) + (a ^ 2) % 5
  end
`;

const luaWhileTruthinessMix = `
  local items = { 0, "", {}, 1, -0.0, "x" }
  local idx = 0
  local function next_item()
    idx = idx + 1
    local v = items[idx]
    if v ~= nil then return v end
    return nil
  end
  local cnt = 0
  while next_item() do
    cnt = cnt + 1
  end
`;

const luaTableDeepDotGetSet = `
  local t = { a = { b = { c = 1 } } }
  for i = 1, ${LOOP} do
    t.a.b.c = t.a.b.c + 1
  end
`;

const luaTableDotMissRead = `
  local t = { a = 1 }
  local s = 0
  for i = 1, ${LOOP} do
    if t.m == nil then
      s = s + 1
    end
  end
`;

// The truthiness_test.lua uses the `string.format`.
const truthinessPath =
  new URL("./truthiness_test.lua", import.meta.url).pathname;
const truthinessCode = await Deno.readTextFile(truthinessPath);

const astWhileSync = parseLua(luaWhileSync);
const astForNumeric = parseLua(luaForNumeric);
const astWhileFuncCondTruthy = parseLua(luaWhileFuncCondTruthy);
const astFuncCallArgs = parseLua(luaFuncCallArgs);
const astTableGetSet = parseLua(luaTableGetSet);
const astTableIndexNumeric = parseLua(luaTableIndexNumeric);
const astConcatStrings = parseLua(luaConcatStrings);
const astArithmeticBinary = parseLua(luaArithmeticBinary);
const astWhileTruthinessMix = parseLua(luaWhileTruthinessMix);
const astTableDeepDotGetSet = parseLua(luaTableDeepDotGetSet);
const astTableDotMissRead = parseLua(luaTableDotMissRead);
const astTruthiness = parseLua(truthinessCode);

Deno.bench(
  { name: "RP: while (sync cond) numeric sum", group: "space-lua" },
  async () => {
    await run(astWhileSync);
  },
);

Deno.bench({ name: "RP: for (numeric) sum", group: "space-lua" }, async () => {
  await run(astForNumeric);
});

Deno.bench({
  name: "RP: while (function cond -> truthy then nil)",
  group: "space-lua",
}, async () => {
  await run(astWhileFuncCondTruthy);
});

Deno.bench(
  { name: "RP: function calls + arg eval", group: "space-lua" },
  async () => {
    await run(astFuncCallArgs);
  },
);

Deno.bench({ name: "RP: table dot get/set", group: "space-lua" }, async () => {
  await run(astTableGetSet);
});

Deno.bench(
  { name: "RP: table numeric index get/set", group: "space-lua" },
  async () => {
    await run(astTableIndexNumeric);
  },
);

Deno.bench(
  { name: "RP: table deep dot get/set (3 levels)", group: "space-lua" },
  async () => {
    await run(astTableDeepDotGetSet);
  },
);

Deno.bench(
  { name: "RP: table dot miss (nil reads, no metatable)", group: "space-lua" },
  async () => {
    await run(astTableDotMissRead);
  },
);

Deno.bench(
  { name: "RP: string concatenation (..)", group: "space-lua" },
  async () => {
    await run(astConcatStrings);
  },
);

Deno.bench(
  { name: "RP: arithmetic (binary ops)", group: "space-lua" },
  async () => {
    await run(astArithmeticBinary);
  },
);

Deno.bench({
  name: "RP: while truthiness mix (0,'' ,{},...)",
  group: "space-lua",
}, async () => {
  await run(astWhileTruthinessMix);
});

Deno.bench(
  { name: "RP: truthiness_test.lua (end-to-end)", group: "space-lua" },
  async () => {
    await run(astTruthiness);
  },
);


================================================
FILE: client/space_lua/rp.ts
================================================
// Result-or-Promise helpers

export type RP<T> = T | Promise<T>;

// Returns true when v is a Promise or a has a then function.
export function isPromise<T>(v: RP<T>): v is Promise<T> {
  return v !== null &&
    (typeof v === "object" || typeof v === "function") &&
    typeof (v as any).then === "function";
}

export function rpThen<A, B>(v: RP<A>, f: (a: A) => RP<B>): RP<B> {
  return isPromise(v) ? (v as Promise<A>).then(f) : f(v as A);
}

/**
 * Collect an array of Result-or-Promise values into a single array
 * avoiding Promise allocation when all inputs are synchronous.
 */
export function rpAll<T>(arr: RP<T>[]): RP<T[]> {
  for (let i = 0; i < arr.length; i++) {
    if (isPromise(arr[i])) {
      return Promise.all(arr as Promise<T>[]);
    }
  }
  return arr as T[];
}


================================================
FILE: client/space_lua/runtime.test.ts
================================================
import { assertEquals } from "@std/assert/equals";
import {
  jsToLuaValue,
  luaLen,
  LuaMultiRes,
  LuaStackFrame,
  luaToString,
} from "./runtime.ts";

Deno.test("Test Lua Rutime", async () => {
  // Test LuaMultires
  assertEquals(new LuaMultiRes([]).flatten().values, []);
  assertEquals(new LuaMultiRes([1, 2, 3]).flatten().values, [1, 2, 3]);
  assertEquals(
    new LuaMultiRes([1, new LuaMultiRes([2, 3])]).flatten().values,
    [
      1,
      2,
      3,
    ],
  );

  // Test JavaScript to Lua conversion
  assertEquals(jsToLuaValue(1), 1);
  assertEquals(jsToLuaValue("hello"), "hello");
  // Arrays
  let luaVal = jsToLuaValue([1, 2, 3]);
  assertEquals(luaLen(luaVal), 3);
  assertEquals(luaVal.get(1), 1);
  // Objects
  luaVal = jsToLuaValue({ name: "Pete", age: 10 });
  assertEquals(luaVal.get("name"), "Pete");
  assertEquals(luaVal.get("age"), 10);
  // Nested objects
  luaVal = jsToLuaValue({ name: "Pete", list: [1, 2, 3] });
  assertEquals(luaVal.get("name"), "Pete");
  assertEquals(luaLen(luaVal.get("list")), 3);
  assertEquals(luaVal.get("list").get(2), 2);
  luaVal = jsToLuaValue([{ name: "Pete" }, { name: "John" }]);
  assertEquals(luaLen(luaVal), 2);
  assertEquals(luaVal.get(1).get("name"), "Pete");
  assertEquals(luaVal.get(2).get("name"), "John");
  // Functions in objects
  luaVal = jsToLuaValue({ name: "Pete", first: (l: any[]) => l[0] });
  assertEquals(luaVal.get("first").call(LuaStackFrame.lostFrame, [1, 2, 3]), 1);

  // Test luaToString
  assertEquals(await luaToString(new Promise((resolve) => resolve(1))), "1");
  assertEquals(await luaToString({ a: 1 }), "{a = 1}");
  assertEquals(await luaToString([{ a: 1 }]), "{{a = 1}}");
  // Ensure simple cases are not returning promises
  assertEquals(luaToString(10), "10");
  // Test circular references
  const circular: any = {};
  circular.self = circular;
  assertEquals(await luaToString(circular), "{self = <circular reference>}");
});


================================================
FILE: client/space_lua/runtime.ts
================================================
import type { ASTCtx, LuaFunctionBody, NumericType } from "./ast.ts";
import { evalStatement } from "./eval.ts";
import { asyncQuickSort } from "./util.ts";
import { isPromise, rpAll } from "./rp.ts";
import { isNegativeZero, isTaggedFloat } from "./numeric.ts";
import { luaFormat } from "./stdlib/format.ts";

export type LuaType =
  | "nil"
  | "boolean"
  | "number"
  | "string"
  | "table"
  | "function"
  | "userdata"
  | "thread";

// These types are for documentation only
export type LuaValue = any;
export type JSValue = any;

export interface ILuaFunction {
  call(sf: LuaStackFrame, ...args: LuaValue[]): Promise<LuaValue> | LuaValue;

  asString(): string;
}

export interface ILuaSettable {
  set(
    key: LuaValue,
    value: LuaValue,
    sf?: LuaStackFrame,
    numType?: NumericType,
  ): void | Promise<void>;
}

export interface ILuaGettable {
  get(key: LuaValue, sf?: LuaStackFrame): LuaValue | Promise<LuaValue> | null;
  getNumericType?(key: LuaValue): NumericType | undefined;
}

// Small helpers for type safety/readability
export function isILuaFunction(v: unknown): v is ILuaFunction {
  return !!v && typeof (v as any).call === "function";
}

export function isLuaTable(v: unknown): v is LuaTable {
  return v instanceof LuaTable;
}

export function toNumKey(key: unknown): string | number {
  if (isTaggedFloat(key)) {
    return key.value;
  }
  if (typeof key === "number" || typeof key === "string") {
    return key;
  }
  return key as unknown as string | number;
}

export function ctxOrNull(sf?: LuaStackFrame): ASTCtx | null {
  return sf?.astCtx ?? null;
}

// Reuse a single empty context to avoid allocating `{}` in hot paths
const EMPTY_CTX = {} as ASTCtx;

const MAX_TAG_LOOP = 200;

// Close-stack support
export type LuaCloseEntry = { value: LuaValue; ctx: ASTCtx };

type LuaThreadState = {
  closeStack?: LuaCloseEntry[];
};

function isLuaNumber(v: any): boolean {
  return typeof v === "number" || isTaggedFloat(v);
}

export function luaTypeName(val: any): LuaType {
  if (val === null || val === undefined) {
    return "nil";
  }

  const t = luaTypeOf(val);

  if (typeof t === "string") {
    return t;
  }

  const ty = typeof val;
  if (ty === "number") {
    return "number";
  }
  if (ty === "string") {
    return "string";
  }
  if (ty === "boolean") {
    return "boolean";
  }
  if (ty === "function") {
    return "function";
  }
  if (Array.isArray(val)) {
    return "table";
  }
  if (ty === "object" && (val as any).constructor === Object) {
    return "table";
  }

  return "userdata";
}

// Check whether a value is callable without invoking it.
export function luaIsCallable(
  v: LuaValue,
  sf: LuaStackFrame,
): boolean {
  if (v === null || v === undefined) {
    return false;
  }
  if (typeof v === "function") {
    return true;
  }
  if (isILuaFunction(v)) {
    return true;
  }
  if (v instanceof LuaTable) {
    const mt = getMetatable(v, sf);
    if (mt && mt.has("__call")) {
      const mm = mt.get("__call", sf);
      return !!mm && (typeof mm === "function" || isILuaFunction(mm));
    }
  }
  return false;
}

// In Lua, `__close` must be a function (no `__call` fallback).
function luaIsCloseMethod(
  v: LuaValue,
): boolean {
  return typeof v === "function" || isILuaFunction(v);
}

export function luaEnsureCloseStack(sf: LuaStackFrame): LuaCloseEntry[] {
  if (!sf.threadState.closeStack) {
    sf.threadState.closeStack = [];
  }
  return sf.threadState.closeStack as LuaCloseEntry[];
}

export function luaMarkToBeClosed(
  sf: LuaStackFrame,
  value: LuaValue,
  ctx: ASTCtx,
): void {
  const closeStack = luaEnsureCloseStack(sf);

  // In Lua, `nil` is not closed.
  if (value === null) {
    return;
  }

  const mt = getMetatable(value, sf);
  if (!mt || !mt.has("__close")) {
    throw new LuaRuntimeError(
      "variable got a non-closable value",
      sf.withCtx(ctx),
    );
  }

  const mm = mt.get("__close");
  if (!luaIsCloseMethod(mm)) {
    throw new LuaRuntimeError(
      "variable got a non-closable value",
      sf.withCtx(ctx),
    );
  }

  closeStack.push({ value, ctx });
}

// Close entries from a mark (LIFO) and shrink stack back to mark.  This
// is the core semantic for block exits and protected call boundaries.
export function luaCloseFromMark(
  sf: LuaStackFrame,
  mark: number,
  errObj: LuaValue | null,
): Promise<void> | void {
  const closeStack = sf.threadState?.closeStack as LuaCloseEntry[] | undefined;
  if (!closeStack) {
    return;
  }
  if (closeStack.length <= mark) {
    return;
  }

  const callClose = (entry: LuaCloseEntry): LuaValue | Promise<LuaValue> => {
    const mt = getMetatable(entry.value, sf);
    const mm = mt ? mt.get("__close", sf) : null;
    if (!luaIsCloseMethod(mm)) {
      throw new LuaRuntimeError(
        "metamethod '__close' is not callable",
        sf.withCtx(entry.ctx),
      );
    }
    if (errObj === null) {
      return luaCall(mm, [entry.value], entry.ctx, sf);
    }
    return luaCall(mm, [entry.value, errObj], entry.ctx, sf);
  };

  // Close all to-be-closed variables (LIFO) even if one close errors.
  // The reported error should be the first close error encountered.
  const runFrom = (i: number): void | Promise<void> => {
    let firstErr: unknown | null = null;

    const recordErr = (e: unknown) => {
      if (firstErr === null) {
        firstErr = e;
      }
    };

    const next = (idx: number): void | Promise<void> => {
      for (let j = idx; j >= mark; j--) {
        let r: LuaValue | Promise<LuaValue>;
        try {
          r = callClose(closeStack[j]);
        } catch (e) {
          recordErr(e);
          continue;
        }

        if (isPromise(r)) {
          return (r as Promise<any>).then(
            () => next(j - 1),
            (e: any) => {
              recordErr(e);
              return next(j - 1);
            },
          );
        }
      }

      closeStack.length = mark;
      if (firstErr !== null) {
        throw firstErr;
      }
    };

    return next(i);
  };

  return runFrom(closeStack.length - 1);
}

export class LuaEnv implements ILuaSettable, ILuaGettable {
  variables = new Map<string, LuaValue>();

  private readonly consts = new Set<string>();
  private readonly numericTypes = new Map<string, NumericType>();

  constructor(readonly parent?: LuaEnv) {
  }

  setLocal(name: string, value: LuaValue, numType?: NumericType) {
    this.variables.set(name, value);
    if (isLuaNumber(value) && numType) {
      this.numericTypes.set(name, numType);
    } else {
      this.numericTypes.delete(name);
    }
  }

  setLocalConst(name: string, value: LuaValue, numType?: NumericType) {
    this.variables.set(name, value);
    this.consts.add(name);
    if (isLuaNumber(value) && numType) {
      this.numericTypes.set(name, numType);
    } else {
      this.numericTypes.delete(name);
    }
  }

  set(
    key: string,
    value: LuaValue,
    sf?: LuaStackFrame,
    numType?: NumericType,
  ): void {
    if (this.variables.has(key) || !this.parent) {
      if (this.consts.has(key)) {
        throw new LuaRuntimeError(
          `attempt to assign to const variable '${key}'`,
          sf || LuaStackFrame.lostFrame,
        );
      }
      this.variables.set(key, value);
      if (isLuaNumber(value) && numType) {
        this.numericTypes.set(key, numType);
      } else {
        this.numericTypes.delete(key);
      }
    } else {
      this.parent.set(key, value, sf, numType);
    }
  }

  getNumericType(name: string): NumericType | undefined {
    if (this.numericTypes.has(name)) {
      return this.numericTypes.get(name);
    }
    if (this.parent) {
      return this.parent.getNumericType(name);
    }
    return undefined;
  }

  has(key: string): boolean {
    if (this.variables.has(key)) {
      return true;
    }
    if (this.parent) {
      return this.parent.has(key);
    }
    return false;
  }

  get(
    name: string,
    _sf?: LuaStackFrame,
  ): Promise<LuaValue> | LuaValue | null {
    if (this.variables.has(name)) {
      return this.variables.get(name);
    }
    if (this.parent) {
      return this.parent.get(name, _sf);
    }
    return null;
  }

  /**
   * Lists all keys in the environment including its parents
   */
  keys(): string[] {
    const keys = Array.from(this.variables.keys());
    if (this.parent) {
      return keys.concat(this.parent.keys());
    }
    return keys;
  }

  toJSON(omitKeys: string[] = []): Record<string, any> {
    const result: Record<string, any> = {};
    for (const key of this.keys()) {
      if (omitKeys.includes(key)) {
        continue;
      }
      result[key] = luaValueToJS(this.get(key), LuaStackFrame.lostFrame);
    }
    return result;
  }
}

export class LuaStackFrame {
  // Must not share mutable per-thread state across calls/tests. This is
  // a getter that returns a fresh frame each time.
  static get lostFrame(): LuaStackFrame {
    return new LuaStackFrame(new LuaEnv(), null, undefined, undefined, {
      closeStack: undefined,
    });
  }

  constructor(
    readonly threadLocal: LuaEnv,
    readonly astCtx: ASTCtx | null,
    readonly parent?: LuaStackFrame,
    readonly currentFunction?: LuaFunction,
    readonly threadState: LuaThreadState = { closeStack: undefined },
  ) {
  }

  static createWithGlobalEnv(
    globalEnv: LuaEnv,
    ctx: ASTCtx | null = null,
  ): LuaStackFrame {
    const env = new LuaEnv();
    env.setLocal("_GLOBAL", globalEnv);
    return new LuaStackFrame(env, ctx, undefined, undefined, {
      closeStack: undefined,
    });
  }

  withCtx(ctx: ASTCtx): LuaStackFrame {
    return new LuaStackFrame(
      this.threadLocal,
      ctx,
      this,
      this.currentFunction,
      this.threadState,
    );
  }

  withFunction(fn: LuaFunction): LuaStackFrame {
    return new LuaStackFrame(
      this.threadLocal,
      this.astCtx,
      this.parent,
      fn,
      this.threadState,
    );
  }
}

export class LuaMultiRes {
  values: any[];

  constructor(values: LuaValue[] | LuaValue) {
    if (values instanceof LuaMultiRes) {
      this.values = values.values;
    } else {
      this.values = Array.isArray(values) ? values : [values];
    }
  }

  unwrap(): any {
    if (this.values.length === 0) {
      return null;
    }
    return this.values[0];
  }

  // Takes an array of either LuaMultiRes or LuaValue and flattens them into a single LuaMultiRes
  flatten(): LuaMultiRes {
    const result: any[] = [];

    for (const value of this.values) {
      if (value instanceof LuaMultiRes) {
        result.push(...value.values);
      } else {
        result.push(value);
      }
    }

    return new LuaMultiRes(result);
  }
}

export function singleResult(value: any): any {
  if (value instanceof LuaMultiRes) {
    return value.unwrap();
  }
  return value;
}

export class LuaFunction implements ILuaFunction {
  private capturedEnv: LuaEnv;
  funcHasGotos?: boolean;

  constructor(readonly body: LuaFunctionBody, closure: LuaEnv) {
    this.capturedEnv = closure;
  }

  call(sf: LuaStackFrame, ...args: LuaValue[]): Promise<LuaValue> | LuaValue {
    // Create a new environment that chains to the captured environment
    const env = new LuaEnv(this.capturedEnv);
    if (!sf) {
      console.trace(sf);
    }
    // Set _CTX to the thread local environment from the stack frame
    env.setLocal("_CTX", sf.threadLocal);

    // Eval using a stack frame that knows the current function
    const sfWithFn = sf.currentFunction === this ? sf : sf.withFunction(this);

    // Resolve args (sync-first)
    const argsRP = rpAll(args as any[]);
    const resolveArgs = (resolvedArgs: any[]) => {
      // Assign parameter values to variable names in env
      let varargs: LuaValue[] = [];
      for (let i = 0; i < this.body.parameters.length; i++) {
        const paramName = this.body.parameters[i];
        if (paramName === "...") {
          // Vararg parameter, let's collect the remainder of the resolved args into the varargs array
          varargs = resolvedArgs.slice(i);
          // Done, break out of this loop
          break;
        }
        env.setLocal(paramName, resolvedArgs[i] ?? null);
      }
      env.setLocal("...", new LuaMultiRes(varargs));

      // Evaluate the function body with returnOnReturn set to true
      const r = evalStatement(this.body.block, env, sfWithFn, true);

      const map = (val: any) => {
        if (val === undefined) {
          return;
        }
        if (val && typeof val === "object" && val.ctrl === "return") {
          return mapFunctionReturnValue(val.values);
        }
        if (val && typeof val === "object" && val.ctrl === "break") {
          throw new LuaRuntimeError(
            "break outside loop",
            sfWithFn.withCtx(this.body.block.ctx),
          );
        }
        if (val && typeof val === "object" && val.ctrl === "goto") {
          throw new LuaRuntimeError(
            "unexpected goto signal",
            sfWithFn.withCtx(this.body.block.ctx),
          );
        }
      };

      if (isPromise(r)) {
        return r.then(map);
      }
      return map(r);
    };

    if (isPromise(argsRP)) {
      return argsRP.then(resolveArgs);
    }
    return resolveArgs(argsRP);
  }

  asString(): string {
    return `<lua function(${this.body.parameters.join(", ")})>`;
  }

  toString(): string {
    return this.asString();
  }
}

function mapFunctionReturnValue(values: any[]): any {
  if (values.length === 0) {
    return;
  }

  if (values.length === 1) {
    return values[0];
  }

  return new LuaMultiRes(values);
}

export class LuaNativeJSFunction implements ILuaFunction {
  constructor(readonly fn: (...args: JSValue[]) => JSValue) {
  }

  // Performs automatic conversion between Lua and JS values for arguments, but not for return values
  call(sf: LuaStackFrame, ...args: LuaValue[]): Promise<LuaValue> | LuaValue {
    const jsArgsRP = args.map((v) => luaValueToJS(v, sf));
    const resolved = rpAll(jsArgsRP);
    if (isPromise(resolved)) {
      return resolved.then((jsArgs) => this.fn(...jsArgs));
    }
    return this.fn(...resolved);
  }

  asString(): string {
    return `<native js function: ${this.fn.name}>`;
  }

  toString(): string {
    return this.asString();
  }
}

export class LuaBuiltinFunction implements ILuaFunction {
  constructor(
    readonly fn: (sf: LuaStackFrame, ...args: LuaValue[]) => LuaValue,
  ) {
  }

  call(sf: LuaStackFrame, ...args: LuaValue[]): Promise<LuaValue> | LuaValue {
    // _CTX is already available via the stack frame
    return this.fn(sf, ...args);
  }

  asString(): string {
    return `<builtin lua function>`;
  }

  toString(): string {
    return this.asString();
  }
}

export class LuaTable implements ILuaSettable, ILuaGettable {
  // To optimize the table implementation we use a combination of different data structures
  public metatable: LuaTable | null;

  // When tables are used as maps, the common case is that they are string keys, so we use a simple object for that
  private stringKeys: Record<string, any>;
  // Other keys we can support using a Map as a fallback
  private otherKeys: Map<any, any> | null;
  // When tables are used as arrays, we use a native JavaScript array for that
  private arrayPart: any[];

  // Numeric type metadata at storage boundaries
  private readonly stringKeyTypes = new Map<string, NumericType>();
  private otherKeyTypes: Map<any, NumericType> | null = null;
  private readonly arrayTypes: (NumericType | undefined)[] = [];

  constructor(init?: any[] | Record<string, any>) {
    // For efficiency and performance reasons we pre-allocate these (modern JS engines are very good at optimizing this)
    this.arrayPart = Array.isArray(init) ? init : [];
    this.stringKeys = init && !Array.isArray(init) ? init : {};

    if (init && !Array.isArray(init)) {
      for (const k in init) {
        if (Object.prototype.hasOwnProperty.call(init, k)) {
          this.stringKeys[k] = (init as any)[k];
        }
      }
    }
    this.otherKeys = null; // Only create this when needed
    this.metatable = null;
  }

  private static numKeyValue(key: any): number | null {
    if (isTaggedFloat(key)) {
      return key.value;
    }
    if (typeof key === "number") {
      return key;
    }
    return null;
  }

  // Normalize numeric keys for table indexing:
  // * negative zero becomes positive zero,
  // * integer-valued floats become plain integers,
  // * non-integer floats stay as-is.
  static normalizeNumericKey(key: any): any {
    if (typeof key === "string") {
      return key;
    }

    const numVal = LuaTable.numKeyValue(key);
    if (numVal !== null) {
      // Normalize -0 to +0
      if (isNegativeZero(numVal)) {
        return 0;
      }
      // Integer-valued numbers normalize to integers
      if (Number.isInteger(numVal)) {
        return numVal;
      }
      // Non-integer floats
      return numVal;
    }
    return key;
  }

  private static isIntegerKey(key: any): boolean {
    const norm = LuaTable.normalizeNumericKey(key);
    return typeof norm === "number" && Number.isInteger(norm) && norm >= 1;
  }

  private static toIndex(key: any): number {
    const norm = LuaTable.normalizeNumericKey(key);
    const k = typeof norm === "number" ? norm : (norm as number);
    return k - 1;
  }

  get rawLength(): number {
    return this.arrayPart.length;
  }

  get length(): number {
    let n = this.arrayPart.length;
    while (n > 0) {
      const v = this.arrayPart[n - 1];
      if (v === null || v === undefined) {
        n--;
        continue;
      }
      break;
    }
    return n;
  }

  keys(): any[] {
    const keys: any[] = [];
    for (const k in this.stringKeys) {
      if (Object.prototype.hasOwnProperty.call(this.stringKeys, k)) {
        keys.push(k);
      }
    }
    for (let i = 0; i < this.arrayPart.length; i++) {
      keys.push(i + 1);
    }
    if (this.otherKeys) {
      for (const key of this.otherKeys.keys()) {
        keys.push(key);
      }
    }
    return keys;
  }

  empty(): boolean {
    for (const k in this.stringKeys) {
      if (Object.prototype.hasOwnProperty.call(this.stringKeys, k)) {
        return false;
      }
    }
    if (this.arrayPart.length !== 0) {
      return false;
    }
    if (this.otherKeys && this.otherKeys.size !== 0) {
      return false;
    }
    return true;
  }

  has(key: LuaValue) {
    if (typeof key === "string") {
      return this.stringKeys[key] !== undefined;
    }

    const normalizedKey = LuaTable.normalizeNumericKey(key);

    if (
      typeof normalizedKey === "number" && Number.isInteger(normalizedKey) &&
      normalizedKey >= 1
    ) {
      const idx = normalizedKey - 1;
      const v = this.arrayPart[idx];
      if (v !== undefined) {
        return true;
      }
      return this.otherKeys ? this.otherKeys.has(normalizedKey) : false;
    }
    if (typeof normalizedKey === "string") {
      return this.stringKeys[normalizedKey] !== undefined;
    }
    if (this.otherKeys) {
      return this.otherKeys.has(normalizedKey);
    }

    return false;
  }

  // Used by table constructors to preserve positional semantics
  // including nils and ensure the array part grows to the final
  // constructor size.
  rawSetArrayIndex(
    index1: number,
    value: LuaValue,
    numType?: NumericType,
  ): void {
    const idx = index1 - 1;

    this.arrayPart[idx] = value;
    if (isLuaNumber(value) && numType) {
      this.arrayTypes[idx] = numType;
    } else {
      this.arrayTypes[idx] = undefined;
    }
  }

  private promoteIntegerKeysFromHash(): void {
    if (!this.otherKeys) return;

    while (true) {
      const nextIndex1 = this.arrayPart.length + 1;
      if (!this.otherKeys.has(nextIndex1)) {
        break;
      }

      const v = this.otherKeys.get(nextIndex1);
      const nt = this.otherKeyTypes
        ? this.otherKeyTypes.get(nextIndex1)
        : undefined;

      this.otherKeys.delete(nextIndex1);
      if (this.otherKeyTypes) {
        this.otherKeyTypes.delete(nextIndex1);
      }

      this.arrayPart.push(v);
      this.arrayTypes.push(nt);
    }
  }

  rawSet(
    key: LuaValue,
    value: LuaValue,
    numType?: NumericType,
  ): void | Promise<void> {
    if (isPromise(key)) {
      return key.then((key) => this.rawSet(key, value, numType));
    }
    if (isPromise(value)) {
      return value.then((v) => this.rawSet(key, v, numType));
    }

    // Fast path: string keys (the dominant case)
    if (typeof key === "string") {
      if (value === null || value === undefined) {
        delete this.stringKeys[key];
        this.stringKeyTypes.delete(key);
      } else {
        this.stringKeys[key] = value;
        if (isLuaNumber(value) && numType) {
          this.stringKeyTypes.set(key, numType);
        } else {
          this.stringKeyTypes.delete(key);
        }
      }
      return;
    }

    const normalizedKey = LuaTable.normalizeNumericKey(key);

    if (typeof normalizedKey === "string") {
      if (value === null || value === undefined) {
        delete this.stringKeys[normalizedKey];
        this.stringKeyTypes.delete(normalizedKey);
      } else {
        this.stringKeys[normalizedKey] = value;
        if (isLuaNumber(value) && numType) {
          this.stringKeyTypes.set(normalizedKey, numType);
        } else {
          this.stringKeyTypes.delete(normalizedKey);
        }
      }
      return;
    }

    if (
      typeof normalizedKey === "number" && Number.isInteger(normalizedKey) &&
      normalizedKey >= 1
    ) {
      const idx = normalizedKey - 1;

      // Sparse writes (e.g. `a[7]=4` when length is 3) go to the hash
      // part so that `#a` does not jump across holes.
      if (idx <= this.arrayPart.length) {
        this.arrayPart[idx] = value;
        if (isLuaNumber(value) && numType) {
          this.arrayTypes[idx] = numType;
        } else {
          this.arrayTypes[idx] = undefined;
        }

        // If we extended the array by appending, we may now be able to
        // promote subsequent integer keys from the hash part.
        if (idx === this.arrayPart.length - 1) {
          this.promoteIntegerKeysFromHash();
        }

        // Trailing nil shrink
        if (value === null || value === undefined) {
          let n = this.arrayPart.length;
          while (n > 0) {
            const v = this.arrayPart[n - 1];
            if (v === null || v === undefined) {
              n--;
              continue;
            }
            break;
          }
          if (n !== this.arrayPart.length) {
            this.arrayPart.length = n;
            this.arrayTypes.length = n;
          }
        }

        return;
      }

      // Sparse numeric key
      if (!this.otherKeys) {
        this.otherKeys = new Map();
      }
      if (!this.otherKeyTypes) {
        this.otherKeyTypes = new Map();
      }

      if (value === null || value === undefined) {
        this.otherKeys.delete(normalizedKey);
        this.otherKeyTypes.delete(normalizedKey);
      } else {
        this.otherKeys.set(normalizedKey, value);
        if (isLuaNumber(value) && numType) {
          this.otherKeyTypes.set(normalizedKey, numType);
        } else {
          this.otherKeyTypes.delete(normalizedKey);
        }
      }
      return;
    }

    if (!this.otherKeys) {
      this.otherKeys = new Map();
    }
    if (!this.otherKeyTypes) {
      this.otherKeyTypes = new Map();
    }

    if (value === null || value === undefined) {
      this.otherKeys.delete(normalizedKey);
      this.otherKeyTypes.delete(normalizedKey);
    } else {
      this.otherKeys.set(normalizedKey, value);
      if (isLuaNumber(value) && numType) {
        this.otherKeyTypes.set(normalizedKey, numType);
      } else {
        this.otherKeyTypes.delete(normalizedKey);
      }
    }
  }

  set(
    key: LuaValue,
    value: LuaValue,
    sf?: LuaStackFrame,
    numType?: NumericType,
  ): Promise<void> | void {
    const errSf = sf || LuaStackFrame.lostFrame;
    const ctx = sf?.astCtx ?? EMPTY_CTX;

    if (this.has(key)) {
      return this.rawSet(key, value, numType);
    }

    if (this.metatable === null) {
      return this.rawSet(key, value, numType);
    }

    const newIndexMM = this.metatable.rawGet("__newindex");

    if (newIndexMM === undefined || newIndexMM === null) {
      return this.rawSet(key, value, numType);
    }

    const k: LuaValue = key;
    const v: LuaValue = value;
    const nt: NumericType | undefined = numType;

    let target: LuaValue | null = null;

    for (let loop = 0; loop < MAX_TAG_LOOP; loop++) {
      const t = target === null ? this : target;

      if (t instanceof LuaTable) {
        if (t.has(k)) {
          return t.rawSet(k, v, nt);
        }

        const mt = t.metatable;
        if (!mt) {
          return t.rawSet(k, v, nt);
        }

        const mm = mt.rawGet("__newindex");
        const hasMM = !(mm === undefined || mm === null);

        if (!hasMM) {
          return t.rawSet(k, v, nt);
        }

        // Function metamethod: call and stop
        if (typeof mm === "function" || isILuaFunction(mm)) {
          return luaCall(mm, [t, k, v], ctx, errSf);
        }

        // Table/env metamethod: forward assignment
        if (mm instanceof LuaTable || mm instanceof LuaEnv) {
          target = mm;
          continue;
        }

        const ty = luaTypeOf(mm) as any as string;
        throw new LuaRuntimeError(
          `attempt to index a ${ty} value`,
          errSf.withCtx(ctx),
        );
      }

      const ty = luaTypeOf(t) as any as string;
      throw new LuaRuntimeError(
        `attempt to index a ${ty} value`,
        errSf.withCtx(ctx),
      );
    }

    throw new LuaRuntimeError(
      "'__newindex' chain too long; possible loop",
      errSf.withCtx(ctx),
    );
  }

  getNumericType(key: LuaValue): NumericType | undefined {
    if (typeof key === "string") {
      return this.stringKeyTypes.get(key);
    }
    if (LuaTable.isIntegerKey(key)) {
      return this.arrayTypes[LuaTable.toIndex(key)];
    }
    if (this.otherKeyTypes) {
      return this.otherKeyTypes.get(key);
    }
    return undefined;
  }

  rawGet(key: LuaValue): LuaValue | null {
    if (typeof key === "string") {
      return this.stringKeys[key];
    }

    const normalizedKey = LuaTable.normalizeNumericKey(key);

    if (typeof normalizedKey === "string") {
      return this.stringKeys[normalizedKey];
    }

    if (
      typeof normalizedKey === "number" && Number.isInteger(normalizedKey) &&
      normalizedKey >= 1
    ) {
      const idx = normalizedKey - 1;
      const v = this.arrayPart[idx];
      if (v !== undefined) {
        return v;
      }
      // Sparse integer keys can live in the hash part.
      if (this.otherKeys) {
        return this.otherKeys.get(normalizedKey);
      }
      return undefined;
    }

    if (this.otherKeys) {
      return this.otherKeys.get(normalizedKey);
    }
  }

  get(key: LuaValue, sf?: LuaStackFrame): LuaValue | Promise<LuaValue> | null {
    return luaIndexValue(this, key, sf);
  }

  insert(value: LuaValue, pos: number) {
    this.arrayPart.splice(pos - 1, 0, value);
  }

  remove(pos: number) {
    this.arrayPart.splice(pos - 1, 1);
  }

  async sort(fn?: ILuaFunction, sf?: LuaStackFrame) {
    if (fn && sf) {
      this.arrayPart = await asyncQuickSort(this.arrayPart, async (a, b) => {
        return (await fn.call(sf, a, b)) ? -1 : 1;
      });
    } else {
      this.arrayPart.sort();
    }
  }

  toJSObject(sf = LuaStackFrame.lostFrame): Record<string, any> {
    const result: Record<string, any> = {};
    for (const key of this.keys()) {
      result[key] = luaValueToJS(this.get(key, sf), sf);
    }
    return result;
  }

  toJSArray(sf = LuaStackFrame.lostFrame): any[] {
    return this.arrayPart.map((v) => luaValueToJS(v, sf));
  }

  toJS(sf = LuaStackFrame.lostFrame): Record<string, any> | any[] {
    if (this.length > 0) {
      return this.toJSArray(sf);
    }
    return this.toJSObject(sf);
  }

  async toStringAsync(): Promise<string> {
    const metatable = getMetatable(this);
    if (metatable) {
      const mm = metatable.rawGet("__tostring");
      if (!(mm === undefined || mm === null)) {
        const ctx = EMPTY_CTX;
        const sf = LuaStackFrame.lostFrame.withCtx(ctx);

        const r = luaCall(mm, [this], ctx, sf);
        const v = isPromise(r) ? await r : r;

        const s = singleResult(v);
        if (typeof s !== "string") {
          throw new LuaRuntimeError(
            "'__tostring' must return a string",
            sf,
          );
        }
        return s;
      }
    }

    let result = "{";
    let first = true;
    for (const key of this.keys()) {
      if (first) {
        first = false;
      } else {
        result += ", ";
      }
      if (typeof key === "number") {
        result += await luaToString(this.get(key));
        continue;
      }
      if (typeof key === "string") {
        result += key;
      } else {
        result += "[" + key + "]";
      }
      result += " = " + await luaToString(this.get(key));
    }
    result += "}";
    return result;
  }
}

/**
 * Lookup a key in a table or a metatable
 */
export function luaIndexValue(
  value: LuaValue,
  key: LuaValue,
  sf?: LuaStackFrame,
): LuaValue | Promise<LuaValue> | null {
  // `nil` handling is done by luaGet() which has better context;
  // keep this defensive for direct callers.
  if (value === null || value === undefined) {
    return null;
  }

  const errSf = sf || LuaStackFrame.lostFrame;
  const ctx = sf?.astCtx ?? EMPTY_CTX;

  let t: LuaValue = value;

  for (let loop = 0; loop < MAX_TAG_LOOP; loop++) {
    // Primitive get when table
    if (t instanceof LuaTable) {
      const raw = t.rawGet(key);
      if (raw !== undefined) {
        return raw;
      }
      // If no metatable, raw miss => nil
      if (t.metatable === null) {
        return null;
      }
    }

    const mt = getMetatable(t, errSf);
    const mm = mt ? mt.rawGet("__index") : null;

    if (mm === undefined || mm === null) {
      // Strict Lua: only tables are indexable without a metamethod.
      // For a table, raw miss yields nil; for non-table, it's a type error.
      if (t instanceof LuaTable) {
        return null;
      }
      const ty = luaTypeOf(t) as any as string;
      throw new LuaRuntimeError(
        `attempt to index a ${ty} value`,
        errSf.withCtx(ctx),
      );
    }

    // Function metamethod
    if (typeof mm === "function" || isILuaFunction(mm)) {
      return luaCall(mm, [t, key], ctx, errSf);
    }

    // Table/metatable delegation: repeat with mm as new "t"
    if (mm instanceof LuaTable || mm instanceof LuaEnv) {
      t = mm;
      continue;
    }

    // Bad metamethod type: make it a Lua-like type error
    const ty = luaTypeOf(mm) as any as string;
    throw new LuaRuntimeError(
      `attempt to index a ${ty} value`,
      errSf.withCtx(ctx),
    );
  }

  throw new LuaRuntimeError(
    "'__index' chain too long; possible loop",
    errSf.withCtx(ctx),
  );
}

export type LuaLValueContainer = { env: ILuaSettable; key: LuaValue };

export async function luaSet(
  obj: any,
  key: any,
  value: any,
  sf: LuaStackFrame,
  numType?: NumericType,
): Promise<void> {
  if (!obj) {
    throw new LuaRuntimeError(
      `Not a settable object: nil`,
      sf,
    );
  }

  const normKey = isTaggedFloat(key) ? key.value : key;

  if (obj instanceof LuaTable || obj instanceof LuaEnv) {
    await obj.set(normKey, value, sf, numType);
  } else {
    const k = toNumKey(normKey);
    (obj as Record<string | number, any>)[k] = await luaValueToJS(value, sf);
  }
}

export function luaGet(
  obj: any,
  key: any,
  ctx: ASTCtx | null,
  sf: LuaStackFrame,
): Promise<any> | any {
  const errSf = ctx ? sf.withCtx(ctx) : sf;

  if (obj === null || obj === undefined) {
    throw new LuaRuntimeError(
      `attempt to index a nil value`,
      errSf,
    );
  }
  if (key === null || key === undefined) {
    throw new LuaRuntimeError(
      `attempt to index with a nil key`,
      errSf,
    );
  }

  if (obj instanceof LuaTable || obj instanceof LuaEnv) {
    return obj.get(key, sf);
  }
  if (typeof key === "number") {
    return (obj as any[])[key - 1];
  }
  if (isTaggedFloat(key)) {
    return (obj as any[])[key.value - 1];
  }
  // Native JS object
  const k = toNumKey(key);
  const val = (obj as Record<string | number, any>)[k];
  if (typeof val === "function") {
    // Automatically bind the function to the object
    return val.bind(obj);
  }
  if (val === undefined) {
    return null;
  }
  return val;
}

export function luaLen(
  obj: any,
  sf?: LuaStackFrame,
): number {
  if (typeof obj === "string") {
    return obj.length;
  }
  if (Array.isArray(obj)) {
    return obj.length;
  }
  if (obj instanceof LuaTable) {
    return obj.rawLength;
  }

  const t = luaTypeOf(obj) as LuaType;
  throw new LuaRuntimeError(
    `bad argument #1 to 'rawlen' (table or string expected, got ${t})`,
    sf || LuaStackFrame.lostFrame,
  );
}

export function luaCall(
  callee: any,
  args: any[],
  ctx: ASTCtx,
  sf?: LuaStackFrame,
): any {
  if (!callee) {
    throw new LuaRuntimeError(
      `attempt to call a nil value`,
      (sf || LuaStackFrame.lostFrame).withCtx(ctx),
    );
  }

  // Fast path: native JS function
  if (typeof callee === "function") {
    const jsArgs = rpAll(
      args.map((v) => luaValueToJS(v, sf || LuaStackFrame.lostFrame)),
    );

    if (isPromise(jsArgs)) {
      return jsArgs.then((resolved) =>
        (callee as (...a: any[]) => any)(...resolved)
      );
    }
    return (callee as (...a: any[]) => any)(...jsArgs);
  }

  // Lua table: may be callable via __call metamethod
  if (callee instanceof LuaTable) {
    const metatable = getMetatable(callee, sf);

    // Metamethod lookup must be raw (no __index involvement).
    const mm = metatable ? metatable.rawGet("__call") : null;

    if (!(mm === undefined || mm === null)) {
      const isCallable = (v: any): boolean => {
        if (v === null || v === undefined) return false;
        if (typeof v === "function") return true;
        if (isILuaFunction(v)) return true;
        if (v instanceof LuaTable) {
          const mt2 = getMetatable(v, sf);
          const mm2 = mt2 ? mt2.rawGet("__call") : null;
          return !(mm2 === undefined || mm2 === null);
        }
        return false;
      };

      if (!isCallable(mm)) {
        throw new LuaRuntimeError(
          `attempt to call a ${luaTypeOf(mm)} value`,
          (sf || LuaStackFrame.lostFrame).withCtx(ctx),
        );
      }

      return luaCall(mm, [callee, ...args], ctx, sf);
    }
  }

  // ILuaFunction (LuaFunction/LuaBuiltinFunction/LuaNativeJSFunction/etc.)
  if (isILuaFunction(callee)) {
    const base = (sf || LuaStackFrame.lostFrame).withCtx(ctx);
    const frameForCall = callee instanceof LuaFunction
      ? base.withFunction(callee)
      : base;
    return callee.call(
      frameForCall,
      ...args,
    );
  }

  throw new LuaRuntimeError(
    `attempt to call a non-callable value of type: ${luaTypeOf(callee)}`,
    (sf || LuaStackFrame.lostFrame).withCtx(ctx),
  );
}

export function luaEquals(a: any, b: any): boolean {
  const an = isTaggedFloat(a) ? a.value : a;
  const bn = isTaggedFloat(b) ? b.value : b;
  return an === bn;
}

export function luaKeys(val: any): any[] {
  if (val instanceof LuaTable) {
    return val.keys();
  }
  if (Array.isArray(val)) {
    return val.map((_, i) => i + 1);
  }
  return Object.keys(val);
}

export function luaTypeOf(val: any): LuaType | Promise<LuaType> {
  if (val === null || val === undefined) {
    return "nil";
  }
  if (isPromise(val)) {
    return (val as Promise<any>).then((v) => luaTypeOf(v));
  }
  if (typeof val === "boolean") {
    return "boolean";
  }
  if (typeof val === "number") {
    return "number";
  }
  if (isTaggedFloat(val)) {
    return "number";
  }
  if (typeof val === "string") {
    return "string";
  }
  if (val instanceof LuaTable) {
    return "table";
  }
  if (Array.isArray(val)) {
    return "table";
  }
  if (typeof val === "function" || isILuaFunction(val)) {
    return "function";
  }
  if (typeof val === "object" && (val as any).constructor === Object) {
    return "table";
  }
  return "userdata";
}

export class LuaRuntimeError extends Error {
  constructor(
    override readonly message: string,
    public sf: LuaStackFrame,
    cause?: Error,
  ) {
    super(message, cause);
  }

  toPrettyString(code: string): string {
    if (!this.sf || !this.sf.astCtx?.from || !this.sf.astCtx?.to) {
      return this.toString();
    }
    let traceStr = "";
    let current: LuaStackFrame | undefined = this.sf;
    while (current) {
      const ctx = current.astCtx;
      if (!ctx || !ctx.from || !ctx.to) {
        break;
      }
      // Find the line and column
      let line = 1;
      let column = 0;
      let lastNewline = -1;
      for (let i = 0; i < ctx.from; i++) {
        if (code[i] === "\n") {
          line++;
          lastNewline = i;
          column = 0;
        } else {
          column++;
        }
      }

      // Get the full line of code for context
      const lineStart = lastNewline + 1;
      const lineEnd = code.indexOf("\n", ctx.from);
      const codeLine = code.substring(
        lineStart,
        lineEnd === -1 ? undefined : lineEnd,
      );

      // Add position indicator
      const pointer = " ".repeat(column) + "^";

      traceStr += `* ${ctx.ref || "(unknown source)"} @ ${line}:${column}:\n` +
        `   ${codeLine}\n` +
        `   ${pointer}\n`;
      current = current.parent;
    }

    return `LuaRuntimeError: ${this.message}\nStack trace:\n${traceStr}`;
  }

  override toString() {
    return `LuaRuntimeError: ${this.message} at ${this.sf.astCtx?.from}, ${this.sf.astCtx?.to}`;
  }
}

export function luaTruthy(value: any): boolean {
  if (value === undefined || value === null || value === false) {
    return false;
  }

  if (typeof value === "object" && value instanceof LuaMultiRes) {
    // for multi-return values, only the first result determines truthiness
    const first = value.unwrap();
    return !(first === null || first === undefined || first === false);
  }

  // all non-`nil`/non-`false` values are truthy (including empty tables)
  return true;
}

export function luaToString(
  value: any,
  visited: Set<any> = new Set(),
): string | Promise<string> {
  if (value === null || value === undefined) {
    return "nil";
  }
  if (isPromise(value)) {
    return (value as Promise<any>).then((v) => luaToString(v, visited));
  }

  if (isTaggedFloat(value)) {
    return luaFormatNumber(value.value, "float");
  }

  if (typeof value === "number") {
    return luaFormatNumber(value);
  }

  // Check for circular references
  if (typeof value === "object" && visited.has(value)) {
    return "<circular reference>";
  }
  if ((value as any).toStringAsync) {
    // Add to visited before recursing
    visited.add(value);
    return (value as any).toStringAsync();
  }
  if ((value as any).asString) {
    visited.add(value);
    return (value as any).asString();
  }
  if (value instanceof LuaFunction) {
    // Don't recurse into the function body, just show the function signature
    return `<lua-function (${value.body.parameters.join(", ")})>`;
  }
  // Handle plain JavaScript objects in a Lua-like format
  if (typeof value === "object") {
    // Add to visited before recursing
    visited.add(value);
    return (async () => {
      let result = "{";
      let first = true;

      // Handle arrays
      if (Array.isArray(value)) {
        for (const val of value) {
          if (first) {
            first = false;
          } else {
            result += ", ";
          }
          // Recursively stringify the value, passing the visited set
          const strVal = await luaToString(val, visited);
          result += strVal;
        }
        return result + "}";
      }

      // Handle objects
      for (const [key, val] of Object.entries(value)) {
        if (first) {
          first = false;
        } else {
          result += ", ";
        }
        if (/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(key)) {
          result += `${key} = `;
        } else {
          result += `["${key}"] = `;
        }
        // Recursively stringify the value, passing the visited set
        const strVal = await luaToString(val, visited);
        result += strVal;
      }
      result += "}";
      return result;
    })();
  }
  return String(value);
}

export function luaFormatNumber(n: number, kind?: "int" | "float"): string {
  if (kind !== "float" && Number.isInteger(n) && isFinite(n)) {
    return String(n);
  }
  if (n !== n) return "-nan";
  if (n === Infinity) return "inf";
  if (n === -Infinity) return "-inf";
  if (n === 0) {
    return (1 / n === -Infinity) ? "-0.0" : "0.0";
  }
  // Delegate to luaFormat for `%.14g`
  const s = luaFormat("%.14g", n);
  // Guarantee `.01 suffix for integer-valued floats
  if (s.indexOf(".") === -1 && s.indexOf("e") === -1) {
    return s + ".0";
  }
  return s;
}

export function getMetatable(
  value: LuaValue,
  sf?: LuaStackFrame,
): LuaTable | null {
  if (value === null || value === undefined) {
    return null;
  }
  if (typeof value === "string") {
    // Prefer per-thread cached string metatable if `_GLOBAL` available
    const thread = sf?.threadLocal;
    const globalEnv = thread?.get("_GLOBAL") as LuaEnv | null | undefined;

    if (thread && globalEnv instanceof LuaEnv) {
      const cached = thread.get("_STRING_MT") as LuaTable | undefined;
      if (cached instanceof LuaTable) {
        return cached;
      }

      const stringMetatable = new LuaTable();
      stringMetatable.set("__index", (globalEnv as any).get("string"));
      thread.setLocal("_STRING_MT", stringMetatable);

      return stringMetatable;
    }

    return null;
  }

  if ((value as any).metatable) {
    return (value as any).metatable as LuaTable;
  }
  return null;
}

export function jsToLuaValue(value: any): any {
  if (isPromise(value)) {
    return (value as Promise<any>).then(jsToLuaValue);
  }
  if (value instanceof LuaTable) {
    return value;
  }
  if (value instanceof Uint8Array || value instanceof ArrayBuffer) {
    return value;
  }
  if (Array.isArray(value) && "index" in value && "input" in value) {
    // This is a RegExpMatchArray
    const regexMatch = value as RegExpMatchArray;
    const regexMatchTable = new LuaTable();
    for (let i = 0; i < regexMatch.length; i++) {
      regexMatchTable.set(i + 1, regexMatch[i]);
    }
    regexMatchTable.set("index", regexMatch.index);
    regexMatchTable.set("input", regexMatch.input);
    regexMatchTable.set("groups", regexMatch.groups);
    return regexMatchTable;
  }
  if (Array.isArray(value)) {
    const table = new LuaTable();
    for (let i = 0; i < value.length; i++) {
      table.set(i + 1, jsToLuaValue(value[i]));
    }
    return table;
  }
  if (typeof value === "object") {
    const table = new LuaTable();
    for (const key in value) {
      table.set(key, jsToLuaValue((value as any)[key]));
    }
    return table;
  }
  if (typeof value === "function") {
    return new LuaNativeJSFunction(value);
  }
  return value;
}

// Inverse of jsToLuaValue
export function luaValueToJS(value: any, sf: LuaStackFrame): any {
  if (isPromise(value)) {
    return (value as Promise<any>).then((v) => luaValueToJS(v, sf));
  }
  if (value instanceof LuaTable) {
    return value.toJS(sf);
  }
  if (
    value instanceof LuaNativeJSFunction || value instanceof LuaFunction ||
    value instanceof LuaBuiltinFunction
  ) {
    return (...args: any[]) => {
      const jsArgs = rpAll(
        args.map((v) => luaValueToJS(v, sf)),
      );
      if (isPromise(jsArgs)) {
        return luaValueToJS(
          jsArgs.then((jsArgs) => (value as ILuaFunction).call(sf, ...jsArgs)),
          sf,
        );
      }
      return luaValueToJS((value as ILuaFunction).call(sf, ...jsArgs), sf);
    };
  }
  if (isTaggedFloat(value)) {
    return value.value;
  }
  return value;
}


================================================
FILE: client/space_lua/stdlib.ts
================================================
import {
  type ILuaFunction,
  isILuaFunction,
  isLuaTable,
  LuaBuiltinFunction,
  luaCall,
  luaCloseFromMark,
  luaEnsureCloseStack,
  LuaEnv,
  luaGet,
  luaKeys,
  luaLen,
  LuaMultiRes,
  LuaRuntimeError,
  type LuaStackFrame,
  type LuaTable,
  luaToString,
  luaTypeOf,
  type LuaValue,
} from "./runtime.ts";
import { stringApi } from "./stdlib/string.ts";
import { tableApi } from "./stdlib/table.ts";
import { osApi } from "./stdlib/os.ts";
import { jsApi } from "./stdlib/js.ts";
import { spaceluaApi } from "./stdlib/space_lua.ts";
import { mathApi } from "./stdlib/math.ts";
import { parse } from "./parse.ts";
import { evalStatement } from "./eval.ts";
import { encodingApi } from "./stdlib/encoding.ts";
import { luaToNumberDetailed } from "./tonumber.ts";
import { luaLoad } from "./stdlib/load.ts";
import { cryptoApi } from "./stdlib/crypto.ts";
import { netApi } from "./stdlib/net.ts";
import { isTaggedFloat, makeLuaFloat } from "./numeric.ts";

const printFunction = new LuaBuiltinFunction(async (_sf, ...args) => {
  console.log(
    "[Lua]",
    ...(await Promise.all(args.map((v) => luaToString(v)))),
  );
});

const assertFunction = new LuaBuiltinFunction(
  async (sf, value: any, message?: string) => {
    if (!await value) {
      throw new LuaRuntimeError(`Assertion failed: ${message}`, sf);
    }
  },
);

const ipairsFunction = new LuaBuiltinFunction((sf, t: LuaTable | any[]) => {
  let i = 0;

  return async () => {
    i = i + 1;

    const v = await luaGet(t, i, sf.astCtx ?? null, sf);
    if (v === null || v === undefined) {
      return;
    }

    return new LuaMultiRes([i, v]);
  };
});

const pairsFunction = new LuaBuiltinFunction(
  (sf, t: LuaTable | any[] | Record<string, any>) => {
    // Respect `__pairs` metamethod for Lua tables
    if (isLuaTable(t)) {
      const mt = (t as any).metatable as LuaTable | null | undefined;
      if (mt) {
        const mm = mt.get("__pairs", sf);
        if (mm && (typeof mm === "function" || isILuaFunction(mm))) {
          // __pairs must return (iter, state, control, closing)
          return luaCall(mm, [t], sf.astCtx ?? {}, sf);
        }
      }
    }

    let keys: (string | number)[];
    if (Array.isArray(t)) {
      keys = Array.from({ length: t.length }, (_, i) => i + 1); // For arrays, generate 1-based indices
    } else if (isLuaTable(t) || t instanceof LuaEnv) {
      keys = t.keys();
    } else {
      // For plain JavaScript objects case, note: this will also include keys from the prototype
      keys = [];
      for (const key in t) {
        keys.push(key);
      }
    }

    let i = 0;
    const iter = async () => {
      if (i >= keys.length) {
        return;
      }
      const key = keys[i];
      i++;
      const value = await luaGet(t, key, sf.astCtx ?? null, sf);
      return new LuaMultiRes([key, value]);
    };

    // Must return (iter, state, control) for generic for
    return new LuaMultiRes([iter, t, null]);
  },
);

export const eachFunction = new LuaBuiltinFunction(
  (sf, ar: LuaTable | any[]) => {
    let i = 1;
    const length = (ar as any).length;
    return async () => {
      if (i > length) {
        return;
      }
      const result = await luaGet(ar, i, sf.astCtx ?? null, sf);
      i++;
      return result;
    };
  },
);

const unpackFunction = new LuaBuiltinFunction(async (sf, t: LuaTable) => {
  const values: LuaValue[] = [];
  for (let i = 1; i <= (t as any).length; i++) {
    values.push(await luaGet(t, i, sf.astCtx ?? null, sf));
  }
  return new LuaMultiRes(values);
});

const typeFunction = new LuaBuiltinFunction(
  (_sf, value: LuaValue): string | Promise<string> => {
    return luaTypeOf(value);
  },
);

const tostringFunction = new LuaBuiltinFunction((_sf, value: any) => {
  return luaToString(value);
});

const tonumberFunction = new LuaBuiltinFunction(
  (sf, value: LuaValue, base?: number) => {
    if (base !== undefined) {
      if (!(typeof base === "number" && base >= 2 && base <= 36)) {
        throw new LuaRuntimeError(
          "bad argument #2 to 'tonumber' (base out of range)",
          sf,
        );
      }
    }

    if (typeof value === "number") {
      return value;
    }
    if (isTaggedFloat(value)) {
      return value;
    }

    if (typeof value !== "string") {
      return null;
    }

    const result = luaToNumberDetailed(value, base);
    if (result === null) {
      return null;
    }

    if (result.numericType === "float") {
      return makeLuaFloat(result.value);
    }

    return result.value;
  },
);

const errorFunction = new LuaBuiltinFunction((sf, message: string) => {
  throw new LuaRuntimeError(message, sf);
});

async function pcallBoundary(
  sf: LuaStackFrame,
  fn: ILuaFunction,
  args: LuaValue[],
): Promise<
  | { ok: true; values: LuaValue[] }
  | { ok: false; message: string }
> {
  const closeStack = luaEnsureCloseStack(sf);
  const mark = closeStack.length;

  const errMsgOf = (e: any): string =>
    e instanceof LuaRuntimeError ? e.message : (e?.message ?? String(e));

  try {
    const r = await luaCall(fn, args, sf.astCtx!, sf);
    await luaCloseFromMark(sf, mark, null);
    const values = r instanceof LuaMultiRes ? r.flatten().values : [r];
    return { ok: true, values };
  } catch (e: any) {
    const msg = errMsgOf(e);
    try {
      await luaCloseFromMark(sf, mark, msg);
      return { ok: false, message: msg };
    } catch (closeErr: any) {
      return { ok: false, message: errMsgOf(closeErr) };
    }
  }
}

const pcallFunction = new LuaBuiltinFunction(
  async (sf, fn: ILuaFunction, ...args) => {
    // To-be-closed variables must be closed when unwinding to the
    // protected call boundary. Space Lua uses a per-thread close
    // stack, so we snapshot its length and close anything pushed
    // after that.
    //
    // The protected call boundary must be established *before*
    // evaluating the function and its arguments.  Otherwise, any
    // `<close>` locals created while evaluating `pcall`'s arguments
    // will be wrongly treated as "inside" the protected call, and
    // `pcall` may end up closing them (or affecting close ordering).
    //
    // `threadState` is read-only on the stack frame; do not reassign!
    const res = await pcallBoundary(sf, fn, args);
    if (res.ok) {
      return new LuaMultiRes([true, ...res.values]);
    }
    return new LuaMultiRes([false, res.message]);
  },
);

const xpcallFunction = new LuaBuiltinFunction(
  async (sf, fn: ILuaFunction, errorHandler: ILuaFunction, ...args) => {
    // Same semantic as `pcall` (see comments there)
    const res = await pcallBoundary(sf, fn, args);
    if (res.ok) {
      return new LuaMultiRes([true, ...res.values]);
    }
    const hr = await luaCall(errorHandler, [res.message], sf.astCtx!, sf);
    const outVals = hr instanceof LuaMultiRes ? hr.flatten().values : [hr];
    return new LuaMultiRes([false, ...outVals]);
  },
);

const setmetatableFunction = new LuaBuiltinFunction(
  (sf, table: LuaTable, metatable: LuaTable) => {
    if (!metatable) {
      throw new LuaRuntimeError("metatable cannot be set to nil", sf);
    }
    table.metatable = metatable;
    return table;
  },
);

const rawlenFunction = new LuaBuiltinFunction(
  (_sf, value: LuaValue) => {
    return luaLen(value, _sf);
  },
);

const rawsetFunction = new LuaBuiltinFunction(
  (_sf, table: LuaTable, key: LuaValue, value: LuaValue) => {
    return (table as any).rawSet(key, value);
  },
);

const rawgetFunction = new LuaBuiltinFunction(
  (_sf, table: any, key: LuaValue) => {
    const isArray = Array.isArray(table);

    const isPlainObj = typeof table === "object" &&
      table !== null &&
      (table as any).constructor === Object;

    if (!isLuaTable(table) && !isArray && !isPlainObj) {
      let typeName = "userdata";
      if (table === null || table === undefined) {
        typeName = "nil";
      } else if (typeof table === "boolean") {
        typeName = "boolean";
      } else if (typeof table === "number" || isTaggedFloat(table)) {
        typeName = "number";
      } else if (typeof table === "string") {
        typeName = "string";
      } else if (
        typeof table === "function" ||
        (typeof table === "object" &&
          table !== null &&
          typeof (table as any).call === "function")
      ) {
        typeName = "function";
      }
      throw new LuaRuntimeError(
        `bad argument #1 to 'rawget' (table expected, got ${typeName})`,
        _sf,
      );
    }

    if (isLuaTable(table)) {
      const v = table.rawGet(key);
      return v === undefined ? null : v;
    }

    const k = isTaggedFloat(key) ? key.value : key;

    if (isArray) {
      if (typeof k === "number") {
        const v = (table as any[])[k - 1];
        return v === undefined ? null : v;
      }
      const v = (table as Record<string, any>)[k];
      return v === undefined ? null : v;
    }

    const v = (table as Record<string | number, any>)[k as any];
    return v === undefined ? null : v;
  },
);

const rawequalFunction = new LuaBuiltinFunction(
  (_sf, a: any, b: any) => {
    const av = isTaggedFloat(a) ? a.value : a;
    const bv = isTaggedFloat(b) ? b.value : b;
    return av === bv;
  },
);

const getmetatableFunction = new LuaBuiltinFunction((_sf, table: LuaTable) => {
  return (table as any).metatable;
});

const dofileFunction = new LuaBuiltinFunction(async (sf, filename: string) => {
  const global = sf.threadLocal.get("_GLOBAL") as LuaEnv;
  const file = await luaCall(
    (global.get("space") as any).get("readFile"),
    [filename],
    sf.astCtx!,
    sf,
  ) as Uint8Array;
  const code = new TextDecoder().decode(file);
  try {
    const parsedExpr = parse(code);
    const env = new LuaEnv(global);
    await evalStatement(parsedExpr, env, sf.withCtx(parsedExpr.ctx));
  } catch (e: any) {
    throw new LuaRuntimeError(
      `Error evaluating "${filename}": ${e.message}`,
      sf,
    );
  }
});

/**
 * From the Lua docs:
 *
 * If index is a number, returns all arguments after argument number
 * index; a negative number indexes from the end (-1 is the last
 * argument). Otherwise, index must be the string "#", and select
 * returns the total number of extra arguments it received.
 */
const selectFunction = new LuaBuiltinFunction(
  (_sf, index: number | "#", ...args: LuaValue[]) => {
    if (index === "#") {
      return args.length;
    }
    if (typeof index === "number") {
      if (index >= 0) {
        return new LuaMultiRes(args.slice(index - 1));
      }
      return new LuaMultiRes(args.slice(args.length + index));
    }
  },
);

/**
 * From the Lua docs:
 *
 * Allows a program to traverse all fields of a table. Its first
 * argument is a table and its second argument is an index in this
 * table. A call to next returns the next index of the table and its
 * associated value. When called with nil as its second argument, next
 * returns an initial index and its associated value. When called with
 * the last index, or with nil in an empty table, next returns nil. If
 * the second argument is absent, then it is interpreted as nil. In
 * particular, you can use next(t) to check whether a table is empty.
 *
 * The order in which the indices are enumerated is not specified, even
 * for numeric indices. (To traverse a table in numerical order, use
 * a numerical for.)
 *
 * You should not assign any value to a non-existent field in a table
 * during its traversal. You may however modify existing fields. In
 * particular, you may set existing fields to nil.
 */
const nextFunction = new LuaBuiltinFunction(
  (sf, table: LuaTable | Record<string, any>, index: number | null = null) => {
    if (!table) {
      // When nil value
      return null;
    }
    const keys = luaKeys(table);

    // Empty table -> null return value
    if (keys.length === 0) {
      return null;
    }

    if (index === null) {
      // Return the first key, value
      const key = keys[0];
      return new LuaMultiRes([key, luaGet(table, key, sf.astCtx ?? null, sf)]);
    }
    // Find index in the key list
    const idx = keys.indexOf(index);
    if (idx === -1) { // Not found
      throw new LuaRuntimeError("invalid key to 'next': key not found", sf);
    }
    const key = keys[idx + 1];
    if (key === undefined) {
      // When called with the last key, should return nil
      return null;
    }
    return new LuaMultiRes([key, luaGet(table, key, sf.astCtx ?? null, sf)]);
  },
);

// Non-standard, but useful
const someFunction = new LuaBuiltinFunction(async (_sf, value: any) => {
  switch (await luaTypeOf(value)) {
    case "number":
      if (!isFinite(value)) return null;
      break;
    case "string":
      if (value.trim() === "") return null;
      break;
    case "table":
      if (luaKeys(value).length === 0) return null;
  }
  return value;
});

const loadFunction = new LuaBuiltinFunction((sf, s) => luaLoad(s, sf));

export function luaBuildStandardEnv() {
  const env = new LuaEnv();
  // _G global
  env.set("_G", env);
  // Top-level builtins
  env.set("print", printFunction);
  env.set("assert", assertFunction);
  env.set("type", typeFunction);
  env.set("tostring", tostringFunction);
  env.set("tonumber", tonumberFunction);
  env.set("unpack", unpackFunction);
  env.set("select", selectFunction);
  env.set("next", nextFunction);
  // Iterators
  env.set("pairs", pairsFunction);
  env.set("ipairs", ipairsFunction);
  // meta table stuff
  env.set("setmetatable", setmetatableFunction);
  env.set("getmetatable", getmetatableFunction);
  env.set("rawlen", rawlenFunction);
  env.set("rawset", rawsetFunction);
  env.set("rawget", rawgetFunction);
  env.set("rawequal", rawequalFunction);
  env.set("dofile", dofileFunction);
  // Error handling
  env.set("error", errorFunction);
  env.set("pcall", pcallFunction);
  env.set("xpcall", xpcallFunction);
  // Evaluation
  env.set("load", loadFunction);
  // APIs
  env.set("string", stringApi);
  env.set("table", tableApi);
  env.set("os", osApi);
  env.set("js", jsApi);
  env.set("math", mathApi);
  // Non-standard
  env.set("each", eachFunction);
  env.set("spacelua", spaceluaApi);
  env.set("encoding", encodingApi);
  env.set("crypto", cryptoApi);
  env.set("net", netApi);
  env.set("some", someFunction);
  return env;
}


================================================
FILE: client/space_lua/stdlib/crypto.ts
================================================
import { LuaBuiltinFunction, LuaTable } from "../runtime.ts";
import { hashSHA256 } from "../../../plug-api/lib/crypto.ts";

export const cryptoApi = new LuaTable({
  sha256: new LuaBuiltinFunction(
    (_sf, s: string | Uint8Array): Promise<string> => {
      return hashSHA256(s);
    },
  ),
});


================================================
FILE: client/space_lua/stdlib/crypto_test.lua
================================================
local function assertEqual(a, b)
    if a ~= b then
        error("Assertion failed: " .. a .. " is not equal to " .. b)
    end
end


local s = crypto.sha256(encoding.utf8Encode("test"))
local s2 = crypto.sha256("test")
assertEqual(s, s2)


================================================
FILE: client/space_lua/stdlib/encoding.ts
================================================
import { LuaBuiltinFunction, LuaTable } from "../runtime.ts";
import { base64Decode, base64Encode } from "../../../plug-api/lib/crypto.ts";

export const encodingApi = new LuaTable({
  base64Encode: new LuaBuiltinFunction(
    (_sf, s: string | Uint8Array): string => {
      return base64Encode(s);
    },
  ),
  base64Decode: new LuaBuiltinFunction((_sf, s: string): Uint8Array => {
    return base64Decode(s);
  }),
  utf8Encode: new LuaBuiltinFunction((_sf, s: string): Uint8Array => {
    return new TextEncoder().encode(s);
  }),
  utf8Decode: new LuaBuiltinFunction((_sf, data: Uint8Array): string => {
    return new TextDecoder().decode(data);
  }),
});


================================================
FILE: client/space_lua/stdlib/encoding_test.lua
================================================
local function assertEqual(a, b)
    if a ~= b then
        error("Assertion failed: " .. a .. " is not equal to " .. b)
    end
end


assertEqual(encoding.utf8Decode(encoding.utf8Encode("test")), "test")
assertEqual(encoding.utf8Decode(encoding.base64Decode(encoding.base64Encode("test"))), "test")


================================================
FILE: client/space_lua/stdlib/format.ts
================================================
// Supported specifiers set: [diuoxXaAfeEgGcspq%]
// Supported flags set: [-+0# ]
// Width and precision via digits or `*`

type FormatSpec = {
  flags: number; // FLAG_*
  width: number;
  hasPrec: boolean;
  prec: number;
  spec: number;
};

const FLAG_MINUS = 1;
const FLAG_PLUS = 2;
const FLAG_ZERO = 4;
const FLAG_HASH = 8;
const FLAG_SPACE = 16;

function isDigit(c: number): boolean {
  return c >= 48 && c <= 57; // '0'..'9'
}

// Parse a format spec starting after '%' and return index of specifier.
function parseSpec(
  fmt: string,
  start: number,
): { spec: FormatSpec; end: number } {
  let i = start;
  const len = fmt.length;
  let flags = 0;

  outer: while (i < len) {
    switch (fmt.charCodeAt(i)) {
      case 45:
        flags |= FLAG_MINUS;
        i++;
        break; // '-'
      case 43:
        flags |= FLAG_PLUS;
        i++;
        break; // '+'
      case 48:
        flags |= FLAG_ZERO;
        i++;
        break; // '0'
      case 35:
        flags |= FLAG_HASH;
        i++;
        break; // '#'
      case 32:
        flags |= FLAG_SPACE;
        i++;
        break; // ' '
      default:
        break outer;
    }
  }

  // Parse width
  let width = 0;
  if (i < len && fmt.charCodeAt(i) === 42) { // '*'
    width = -1;
    i++;
  } else {
    while (i < len && isDigit(fmt.charCodeAt(i))) {
      width = width * 10 + (fmt.charCodeAt(i) - 48);
      i++;
    }
  }

  // Parse precision
  let hasPrec = false;
  let prec = 0;
  if (i < len && fmt.charCodeAt(i) === 46) { // '.'
    hasPrec = true;
    i++;
    if (i < len && fmt.charCodeAt(i) === 42) { // '*'
      prec = -1;
      i++;
    } else {
      while (i < len && isDigit(fmt.charCodeAt(i))) {
        prec = prec * 10 + (fmt.charCodeAt(i) - 48);
        i++;
      }
    }
  }

  // Skip length modifiers [hlL] ignored in Lua
  while (
    i < len &&
    (fmt.charCodeAt(i) === 104 || // 'h'
      fmt.charCodeAt(i) === 108 || // 'l'
      fmt.charCodeAt(i) === 76) // 'L'
  ) {
    i++;
  }

  if (i >= len) {
    throw new Error("invalid format (missing specifier)");
  }

  return {
    spec: { flags, width, hasPrec, prec, spec: fmt.charCodeAt(i) },
    end: i,
  };
}

// pad a string to `width` respecting `FLAG_MINUS` and `FLAG_ZERO`
function pad(s: string, width: number, flags: number, numPad: boolean): string {
  if (width <= 0 || s.length >= width) return s;
  const n = width - s.length;
  if (numPad && (flags & FLAG_ZERO) && !(flags & FLAG_MINUS)) {
    let signLen = 0;
    if (s.charCodeAt(0) === 45 || s.charCodeAt(0) === 43) { // '-' or '+'
      signLen = 1;
    } else if (
      s.charCodeAt(0) === 48 &&
      (s.charCodeAt(1) === 120 || s.charCodeAt(1) === 88)
    ) {
      signLen = 2; // '0x' or '0X'
    }
    return s.slice(0, signLen) + "0".repeat(n) + s.slice(signLen);
  }
  if (flags & FLAG_MINUS) {
    return s + " ".repeat(n);
  }
  return " ".repeat(n) + s;
}

function addSign(s: string, flags: number): string {
  if (flags & FLAG_PLUS) return "+" + s;
  if (flags & FLAG_SPACE) return " " + s;
  return s;
}

function formatInt(n: number, spec: FormatSpec): string {
  const code = spec.spec;
  const v = Math.trunc(n);

  let base = 10;
  let unsigned = false;
  let upper = false;

  switch (code) {
    case 100:
    case 105: // 'd', 'i'
      break;
    case 117: // 'u'
      unsigned = true;
      break;
    case 111: // 'o'
      base = 8;
      unsigned = true;
      break;
    case 120: // 'x'
      base = 16;
      unsigned = true;
      break;
    case 88: // 'X'
      base = 16;
      unsigned = true;
      upper = true;
      break;
  }

  let neg = false;
  let digits: string;

  if (unsigned && v < 0) {
    // Reinterpret as 64-bit unsigned
    const bv = BigInt(v) + (1n << 64n);
    digits = bv.toString(base);
  } else if (unsigned) {
    digits = v.toString(base);
  } else {
    neg = v < 0;
    digits = (neg ? -v : v).toString(base);
  }

  if (upper) digits = digits.toUpperCase();

  // Precision
  if (spec.hasPrec) {
    if (spec.prec === 0 && v === 0) {
      digits = "";
    } else if (digits.length < spec.prec) {
      digits = "0".repeat(spec.prec - digits.length) + digits;
    }
  }

  // Alt flag
  let prefix = "";
  if (spec.flags & FLAG_HASH) {
    if (base === 8 && (digits.length === 0 || digits.charCodeAt(0) !== 48)) {
      prefix = "0";
    } else if (base === 16 && v !== 0) {
      prefix = upper ? "0X" : "0x";
    }
  }

  let result: string;
  if (neg) {
    result = "-" + prefix + digits;
  } else {
    result = addSign(prefix + digits, spec.flags);
  }

  const numPad = !spec.hasPrec;
  return pad(result, spec.width, spec.flags, numPad);
}

function formatFloat(n: number, spec: FormatSpec): string {
  const code = spec.spec;
  const upper = code === 69 || code === 71 || code === 70;
  // 'E'=69 'G'=71 'F'=70 'e'=101 'g'=103 'f'=102
  const lower = code | 32; // to lowercase

  // Lua convention
  if (!isFinite(n)) {
    let s: string;
    if (n !== n) {
      s = upper ? "-NAN" : "-nan";
    } else if (n > 0) {
      s = upper ? "INF" : "inf";
      s = addSign(s, spec.flags);
    } else {
      s = upper ? "-INF" : "-inf";
    }
    return pad(s, spec.width, spec.flags, false);
  }

  const neg = n < 0 || (n === 0 && 1 / n === -Infinity);
  const abs = neg ? -n : n;
  const prec = spec.hasPrec ? spec.prec : 6;

  let body: string;

  if (lower === 102) { // 'f'
    body = abs.toFixed(prec);
  } else if (lower === 101) { // 'e'
    body = abs.toExponential(prec);
    // Ensure exponent has at least 2 digits
    body = ensureExpTwoDigits(body);
  } else { // 'g'
    const gPrec = (prec === 0) ? 1 : prec;
    if (abs === 0) {
      body = "0";
    } else {
      // C rule: use 'e' if exponent < -4 or exponent >= precision
      const exp = Math.floor(Math.log10(abs));
      if (exp < -4 || exp >= gPrec) {
        body = abs.toExponential(gPrec - 1);
        body = ensureExpTwoDigits(body);
      } else {
        // Number of decimals = precision - (exponent + 1)
        const decimals = gPrec - (exp + 1);
        body = abs.toFixed(decimals);
      }
    }
    // Strip trailing zeros unless '#' flag
    if (!(spec.flags & FLAG_HASH)) {
      body = stripTrailingZerosG(body);
    }
  }

  if (upper) {
    body = body.toUpperCase();
  }

  // Alt flag for 'f'/'e': ensure decimal point exists
  if ((spec.flags & FLAG_HASH) && lower !== 103) {
    if (body.indexOf(".") === -1) {
      // Insert dot before 'e' if present, else append
      const eIdx = body.indexOf("e");
      const EIdx = body.indexOf("E");
      const expIdx = eIdx !== -1 ? eIdx : EIdx;
      if (expIdx !== -1) {
        body = body.slice(0, expIdx) + "." + body.slice(expIdx);
      } else {
        body = body + ".";
      }
    }
  }

  // Alt flag for 'g': keep trailing zeros but ensure decimal point
  if ((spec.flags & FLAG_HASH) && lower === 103) {
    if (body.indexOf(".") === -1) {
      const expIdx = findExpIndex(body);
      if (expIdx !== -1) {
        body = body.slice(0, expIdx) + "." + body.slice(expIdx);
      } else {
        body = body + ".";
      }
    }
  }

  let result: string;
  if (neg) {
    result = "-" + body;
  } else {
    result = addSign(body, spec.flags);
  }

  return pad(result, spec.width, spec.flags, true);
}

function findExpIndex(s: string): number {
  for (let i = 0; i < s.length; i++) {
    const c = s.charCodeAt(i);
    if (c === 101 || c === 69) return i; // 'e' or 'E'
  }
  return -1;
}

// Ensure exponent part has at least 2 digits
function ensureExpTwoDigits(s: string): string {
  const idx = findExpIndex(s);
  if (idx === -1) return s;
  // idx+1 is sign, idx+2... are digits
  const signIdx = idx + 1;
  if (signIdx >= s.length) return s;
  const digitStart = signIdx + 1;
  const expLen = s.length - digitStart;
  if (expLen < 2) {
    return s.slice(0, digitStart) + "0" + s.slice(digitStart);
  }
  return s;
}

// Strip trailing zeros from '%g' output
function stripTrailingZerosG(s: string): string {
  const expIdx = findExpIndex(s);
  const mantissa = expIdx !== -1 ? s.slice(0, expIdx) : s;
  const exp = expIdx !== -1 ? s.slice(expIdx) : "";

  const dotIdx = mantissa.indexOf(".");
  if (dotIdx === -1) return s; // nothing to strip

  let end = mantissa.length;
  while (end > dotIdx + 1 && mantissa.charCodeAt(end - 1) === 48) { // '0'
    end--;
  }
  // Remove dot if nothing after it
  if (end === dotIdx + 1) {
    end = dotIdx;
  }

  return mantissa.slice(0, end) + exp;
}

// Format a number as hexadecimal floating-point (%a/%A)
function formatHexFloat(n: number, spec: FormatSpec): string {
  const code = spec.spec;
  const upper = code === 65; // 'A'

  if (!isFinite(n)) {
    let s: string;
    if (n !== n) {
      s = upper ? "-NAN" : "-nan";
    } else if (n > 0) {
      s = upper ? "INF" : "inf";
      s = addSign(s, spec.flags);
    } else {
      s = upper ? "-INF" : "-inf";
    }
    return pad(s, spec.width, spec.flags, false);
  }

  const neg = n < 0 || (n === 0 && 1 / n === -Infinity);
  const abs = neg ? -n : n;

  let body: string;
  if (abs === 0) {
    const prec = spec.hasPrec ? spec.prec : 0;
    if (prec > 0) {
      body = "0x0." + "0".repeat(prec) + "p+0";
    } else {
      body = "0x0p+0";
    }
  } else {
    body = hexFloatBody(abs, spec);
  }

  if (upper) body = body.toUpperCase();

  // Alt flag: ensure decimal point
  if (spec.flags & FLAG_HASH) {
    const pIdx = findPIndex(body);
    if (pIdx !== -1) {
      let hasDot = false;
      for (let k = 0; k < pIdx; k++) {
        if (body.charCodeAt(k) === 46) { // '.'
          hasDot = true;
          break;
        }
      }
      if (!hasDot) {
        body = body.slice(0, pIdx) + "." + body.slice(pIdx);
      }
    }
  }

  let result: string;
  if (neg) {
    result = "-" + body;
  } else {
    result = addSign(body, spec.flags);
  }

  return pad(result, spec.width, spec.flags, true);
}

// Find index of 'p' or 'P' in hex float string
function findPIndex(s: string): number {
  for (let i = 0; i < s.length; i++) {
    const c = s.charCodeAt(i);
    if (c === 112 || c === 80) return i; // 'p' or 'P'
  }
  return -1;
}

// Number of bits needed to represent a positive bigint
function bitLength(n: bigint): number {
  let bits = 0;
  let v = n;
  while (v > 0n) {
    bits++;
    v >>= 1n;
  }
  return bits;
}

// Decompose a positive non-zero finite float into `0xH.HHHpN` form
function hexFloatBody(abs: number, spec: FormatSpec): string {
  const buf = new Float64Array(1);
  const view = new DataView(buf.buffer);
  view.setFloat64(0, abs);
  const bits = view.getBigUint64(0);
  const biasedExp = Number((bits >> 52n) & 0x7FFn);
  const frac = bits & 0xFFFFFFFFFFFFFn;

  let exponent: number;
  let mantBits: bigint;

  if (biasedExp === 0) {
    // Subnormal
    if (frac === 0n) return "0x0p+0";
    const shift = 52 - bitLength(frac) + 1;
    mantBits = frac << BigInt(shift);
    exponent = -1022 - shift;
  } else {
    // Normal
    exponent = biasedExp - 1023;
    mantBits = frac | (1n << 52n);
  }

  let firstDigit = Number(mantBits >> 52n);
  const restBits = mantBits & ((1n << 52n) - 1n);

  // 13 hex digits from 52 bits
  let fracHex = hexDigits52(restBits);

  if (spec.hasPrec) {
    if (spec.prec < 13) {
      const carry = roundHexInPlace(fracHex, spec.prec);
      if (carry) firstDigit++;
      fracHex = truncHexDigits(fracHex, spec.prec);
    } else {
      fracHex = padHexRight(fracHex, spec.prec);
    }
  } else {
    fracHex = stripHexTrailingZeros(fracHex);
  }

  const expSign = exponent >= 0 ? "+" : "";
  if (fracHex.length > 0) {
    return "0x" + firstDigit + "." + fracHex + "p" + expSign + exponent;
  }
  return "0x" + firstDigit + "p" + expSign + exponent;
}

// Convert 52-bit value to 13 hex digits, zero-padded
function hexDigits52(bits: bigint): string {
  const s = bits.toString(16);
  if (s.length >= 13) return s;
  return "0".repeat(13 - s.length) + s;
}

// Parse one hex char to its numeric value
function hexVal(c: number): number {
  if (c >= 48 && c <= 57) return c - 48; // '0'..'9'
  if (c >= 97 && c <= 102) return c - 87; // 'a'..'f'
  if (c >= 65 && c <= 70) return c - 55; // 'A'..'F'
  return 0;
}

function roundHexInPlace(digits: string, prec: number): boolean {
  if (prec >= digits.length) return false;

  const nextVal = hexVal(digits.charCodeAt(prec));
  if (nextVal < 8) return false;

  if (prec === 0) return true;

  const arr = new Array<number>(prec);
  for (let i = 0; i < prec; i++) {
    arr[i] = hexVal(digits.charCodeAt(i));
  }

  let carry = 1;
  for (let i = prec - 1; i >= 0 && carry; i--) {
    arr[i] += carry;
    if (arr[i] >= 16) {
      arr[i] = 0;
      carry = 1;
    } else {
      carry = 0;
    }
  }

  return carry === 1;
}

function truncHexDigits(digits: string, prec: number): string {
  if (prec === 0) return "";

  const nextVal = hexVal(digits.charCodeAt(prec));
  if (nextVal < 8) return digits.slice(0, prec);

  const arr = new Array<number>(prec);
  for (let i = 0; i < prec; i++) {
    arr[i] = hexVal(digits.charCodeAt(i));
  }

  let carry = 1;
  for (let i = prec - 1; i >= 0 && carry; i--) {
    arr[i] += carry;
    if (arr[i] >= 16) {
      arr[i] = 0;
    } else {
      carry = 0;
    }
  }

  let out = "";
  for (let i = 0; i < prec; i++) {
    out += arr[i].toString(16);
  }

  return out;
}

function padHexRight(s: string, len: number): string {
  if (s.length >= len) return s;
  return s + "0".repeat(len - s.length);
}

function stripHexTrailingZeros(s: string): string {
  let end = s.length;
  while (end > 0 && s.charCodeAt(end - 1) === 48) { // '0'
    end--;
  }
  if (end === s.length) return s;
  return s.slice(0, end);
}

function quoteString(s: string): string {
  let out = '"';
  for (let i = 0; i < s.length; i++) {
    const c = s.charCodeAt(i);
    if (c === 34 || c === 92 || c === 10) {
      // '"', '\\', '\n': backslash + literal char
      out += "\\";
      out += String.fromCharCode(c);
    } else if (c < 32) {
      const next = i + 1 < s.length ? s.charCodeAt(i + 1) : -1;
      const isNextDigit = next >= 48 && next <= 57;
      if (isNextDigit) {
        const ds = c.toString();
        out += "\\";
        if (ds.length < 3) out += "0".repeat(3 - ds.length);
        out += ds;
      } else {
        out += "\\" + c.toString();
      }
    } else {
      out += String.fromCharCode(c);
    }
  }
  out += '"';
  return out;
}

// Format a float for %q: hex representation preserving full precision
function quoteFloat(n: number): string {
  if (n !== n) return "(0/0)";
  if (n === Infinity) return "1e9999";
  if (n === -Infinity) return "-1e9999";

  const spec: FormatSpec = {
    flags: 0,
    width: 0,
    hasPrec: false,
    prec: 0,
    spec: 97, // 'a'
  };
  return formatHexFloat(n, spec);
}

function formatQ(v: unknown): string {
  if (v === null || v === undefined) return "nil";
  if (v === true) return "true";
  if (v === false) return "false";

  if (typeof v === "number") {
    if (v === 0 && 1 / v === -Infinity) return quoteFloat(v);
    if (Number.isInteger(v) && Number.isFinite(v)) {
      return v.toString();
    }
    return quoteFloat(v);
  }

  return quoteString(String(v));
}

function formatChar(n: number): string {
  return String.fromCharCode(n & 0x7f);
}

const objectIds = new WeakMap<WeakKey, number>();
const stringIds = new Map<string, number>();
let nextId = 1;

function toPointer(v: unknown): string {
  if (v === null || v === undefined) return "(null)";
  if (typeof v === "boolean" || typeof v === "number") return "(null)";

  // Primitives (strings, symbols, etc.) cannot be `WeakMap` keys
  if (typeof v !== "object" && typeof v !== "function") {
    const key = String(v);
    let id = stringIds.get(key);
    if (id === undefined) {
      id = nextId++;
      stringIds.set(key, id);
    }
    return "0x" + id.toString(16).padStart(14, "0");
  }

  const obj = v as object;
  let id = objectIds.get(obj);
  if (id === undefined) {
    id = nextId++;
    objectIds.set(obj, id);
  }
  return "0x" + id.toString(16).padStart(14, "0");
}

function formatPointer(v: unknown, spec: FormatSpec): string {
  const s = toPointer(v);
  // `%p` only supports width and '-' flag, no precision
  return pad(s, spec.width, spec.flags, false);
}

export function luaFormat(fmt: string, ...args: any[]): string {
  let out = "";
  let ai = 0; // arg index
  const len = fmt.length;
  let i = 0;

  while (i < len) {
    const c = fmt.charCodeAt(i);
    if (c !== 37) { // not '%'
      // Fast path: scan for next '%' or end
      let j = i + 1;
      while (j < len && fmt.charCodeAt(j) !== 37) j++;
      out += fmt.slice(i, j);
      i = j;
      continue;
    }

    // '%' found
    i++;
    if (i >= len) {
      throw new Error("invalid format (ends with '%')");
    }

    // '%%' into literal '%'
    if (fmt.charCodeAt(i) === 37) {
      out += "%";
      i++;
      continue;
    }

    const { spec, end } = parseSpec(fmt, i);
    i = end + 1;

    // Resolve `*` width and precision from args
    let width = spec.width;
    if (width === -1) {
      width = Number(args[ai++]) || 0;
      if (width < 0) {
        spec.flags |= FLAG_MINUS;
        width = -width;
      }
      spec.width = width;
    }
    if (spec.prec === -1) {
      let p = Number(args[ai++]) || 0;
      if (p < 0) {
        spec.hasPrec = false;
        p = 0;
      }
      spec.prec = p;
    }

    const code = spec.spec;
    switch (code) {
      case 97:
      case 65: // 'a', 'A'
        out += formatHexFloat(Number(args[ai++]), spec);
        break;
      case 100:
      case 105:
      case 117: // 'd', 'i', 'u'
      case 111:
      case 120:
      case 88: // 'o', 'x', 'X'
        out += formatInt(Number(args[ai++]), spec);
        break;
      case 102:
      case 101:
      case 69: // 'f', 'e', 'E'
      case 103:
      case 71:
      case 70: // 'g', 'G', 'F'
        out += formatFloat(Number(args[ai++]), spec);
        break;
      case 99: // 'c'
        out += pad(
          formatChar(Number(args[ai++])),
          spec.width,
          spec.flags,
          false,
        );
        break;
      case 112: { // 'p'
        out += formatPointer(args[ai++], spec);
        break;
      }
      case 113: { // 'q'
        out += formatQ(args[ai++]);
        break;
      }
      case 115: { // 's'
        let s = String(args[ai++]);
        if (spec.hasPrec && s.length > spec.prec) {
          s = s.slice(0, spec.prec);
        }
        out += pad(s, spec.width, spec.flags, false);
        break;
      }
      default:
        throw new Error(
          `invalid format specifier '${String.fromCharCode(code)}'`,
        );
    }
  }

  return out;
}


================================================
FILE: client/space_lua/stdlib/format_test.lua
================================================
local function assertEquals(actual, expected, message)
  if actual ~= expected then
    error('Assertion failed: ' .. message
      .. '\n  actual:   ' .. tostring(actual)
      .. '\n  expected: ' .. tostring(expected), 2)
  end
end

local function assertThrows(msg_substr, fn)
  local ok, err = pcall(fn)
  if ok then
    error('Assertion failed: expected error containing "'
      .. msg_substr .. '"', 2)
  end
  if type(err) ~= 'string' then
    err = tostring(err)
  end
  if not string.find(err, msg_substr, 1, true) then
    error('Assertion failed: expected error message to contain "'
      .. msg_substr .. '", got: "' .. err .. '"', 2)
  end
end

-- %%: literal percent
assertEquals(string.format("%%"), "%", "%%: literal")
assertEquals(string.format("100%%"), "100%", "%%: 100%%")
assertEquals(string.format("%%%%"), "%%", "%%: %%%%")
assertEquals(string.format("a%%b%%c"), "a%b%c", "%%: a%%b%%c")

-- %d: basic integers
assertEquals(string.format("%d", 0), "0", "%d: 0")
assertEquals(string.format("%d", 1), "1", "%d: 1")
assertEquals(string.format("%d", -1), "-1", "%d: -1")
assertEquals(string.format("%d", 42), "42", "%d: 42")
assertEquals(string.format("%d", -42), "-42", "%d: -42")
assertEquals(string.format("%d", 2147483647), "2147483647", "%d: INT_MAX")
assertEquals(string.format("%d", -2147483648), "-2147483648", "%d: INT_MIN")

-- %i: alias for %d
assertEquals(string.format("%i", 42), "42", "%i: 42")
assertEquals(string.format("%i", -7), "-7", "%i: -7")

-- %d: width
assertEquals(string.format("%5d", 42), "   42", "%d: width 5")
assertEquals(string.format("%5d", -42), "  -42", "%d: width 5 neg")
assertEquals(string.format("%1d", 42), "42", "%d: width 1 no pad")

-- %d: left-justify
assertEquals(string.format("%-5d", 42), "42   ", "%d: left 42")
assertEquals(string.format("%-5d", -42), "-42  ", "%d: left -42")

-- %d: zero-pad
assertEquals(string.format("%05d", 42), "00042", "%d: zero-pad 42")
assertEquals(string.format("%05d", -42), "-0042", "%d: zero-pad -42")
assertEquals(string.format("%05d", 0), "00000", "%d: zero-pad 0")

-- %d: plus flag
assertEquals(string.format("%+d", 42), "+42", "%d: +42")
assertEquals(string.format("%+d", -42), "-42", "%d: + -42")
assertEquals(string.format("%+d", 0), "+0", "%d: +0")

-- %d: space flag
assertEquals(string.format("% d", 42), " 42", "%d: space 42")
assertEquals(string.format("% d", -42), "-42", "%d: space -42")
assertEquals(string.format("% d", 0), " 0", "%d: space 0")

-- %d: precision
assertEquals(string.format("%.5d", 42), "00042", "%d: prec5 42")
assertEquals(string.format("%.5d", -42), "-00042", "%d: prec5 -42")
assertEquals(string.format("%.0d", 0), "", "%d: prec0 0")
assertEquals(string.format("%.0d", 1), "1", "%d: prec0 1")
assertEquals(string.format("%.1d", 0), "0", "%d: prec1 0")

-- %d: width and precision
assertEquals(string.format("%8.5d", 42), "   00042", "%d: w8 p5 42")
assertEquals(string.format("%-8.5d", 42), "00042   ", "%d: left w8 p5 42")

-- %u: unsigned integers
assertEquals(string.format("%u", 0), "0", "%u: 0")
assertEquals(string.format("%u", 42), "42", "%u: 42")
assertEquals(string.format("%u", -1), "18446744073709551615", "%u: -1")
assertEquals(string.format("%u", -42), "18446744073709551574", "%u: -42")

-- %o: octal
assertEquals(string.format("%o", 0), "0", "%o: 0")
assertEquals(string.format("%o", 8), "10", "%o: 8")
assertEquals(string.format("%o", 255), "377", "%o: 255")
assertEquals(string.format("%o", -1), "1777777777777777777777", "%o: -1")

-- %o: alt flag
assertEquals(string.format("%#o", 0), "0", "%#o: 0")
assertEquals(string.format("%#o", 8), "010", "%#o: 8")
assertEquals(string.format("%#o", 255), "0377", "%#o: 255")

-- %x: hex lowercase
assertEquals(string.format("%x", 0), "0", "%x: 0")
assertEquals(string.format("%x", 255), "ff", "%x: 255")
assertEquals(string.format("%x", 4095), "fff", "%x: 4095")
assertEquals(string.format("%x", -1), "ffffffffffffffff", "%x: -1")

-- %X: hex uppercase
assertEquals(string.format("%X", 255), "FF", "%X: 255")
assertEquals(string.format("%X", -1), "FFFFFFFFFFFFFFFF", "%X: -1")

-- %x: alt flag
assertEquals(string.format("%#x", 0), "0", "%#x: 0")
assertEquals(string.format("%#x", 255), "0xff", "%#x: 255")
assertEquals(string.format("%#X", 255), "0XFF", "%#X: 255")

-- %x: width and zero-pad
assertEquals(string.format("%08x", 255), "000000ff", "%x: zero-pad 255")
assertEquals(string.format("%#08x", 255), "0x0000ff", "%#x: zero-pad 255")

-- %c: character
assertEquals(string.format("%c", 65), "A", "%c: 65")
assertEquals(string.format("%c", 97), "a", "%c: 97")
assertEquals(string.format("%c", 48), "0", "%c: 48")

-- %c: width
assertEquals(string.format("%3c", 65), "  A", "%c: width 3")
assertEquals(string.format("%-3c", 65), "A  ", "%c: left width 3")

-- %s: strings
assertEquals(string.format("%s", "hello"), "hello", "%s: hello")
assertEquals(string.format("%s", ""), "", "%s: empty")
assertEquals(string.format("%s %s", "hello", "world"), "hello world", "%s: two")

-- %s: width
assertEquals(string.format("%10s", "hello"), "     hello", "%s: width 10")
assertEquals(string.format("%-10s", "hello"), "hello     ", "%s: left width 10")

-- %s: precision truncates
assertEquals(string.format("%.3s", "hello"), "hel", "%s: prec3")
assertEquals(string.format("%.10s", "hello"), "hello", "%s: prec10")
assertEquals(string.format("%.0s", "hello"), "", "%s: prec0")

-- %s: width and precision
assertEquals(string.format("%10.3s", "hello"), "       hel", "%s: w10 p3")
assertEquals(string.format("%-10.3s", "hello"), "hel       ", "%s: left w10 p3")

-- %f: basic floats
assertEquals(string.format("%f", 0), "0.000000", "%f: 0")
assertEquals(string.format("%f", 1), "1.000000", "%f: 1")
assertEquals(string.format("%f", -1), "-1.000000", "%f: -1")
assertEquals(string.format("%f", 3.14), "3.140000", "%f: 3.14")
assertEquals(string.format("%f", 0.1), "0.100000", "%f: 0.1")

-- %f: precision
assertEquals(string.format("%.2f", 3.14159), "3.14", "%f: prec2")
assertEquals(string.format("%.0f", 3.14159), "3", "%f: prec0")
assertEquals(string.format("%.10f", 1.0), "1.0000000000", "%f: prec10")

-- %f: width and precision
assertEquals(string.format("%10.2f", 3.14), "      3.14", "%f: w10 p2")
assertEquals(string.format("%-10.2f", 3.14), "3.14      ", "%f: left w10 p2")
assertEquals(string.format("%010.2f", 3.14), "0000003.14", "%f: zero-pad w10 p2")

-- %f: sign flags
assertEquals(string.format("%+f", 3.14), "+3.140000", "%f: +3.14")
assertEquals(string.format("%+f", -3.14), "-3.140000", "%f: + -3.14")
assertEquals(string.format("% f", 3.14), " 3.140000", "%f: space 3.14")
assertEquals(string.format("% f", -3.14), "-3.140000", "%f: space -3.14")

-- %f: non-finite
assertEquals(string.format("%f", 1/0), "inf", "%f: inf")
assertEquals(string.format("%f", -1/0), "-inf", "%f: -inf")
assertEquals(string.format("%f", 0/0), "-nan", "%f: nan")
assertEquals(string.format("%+f", 1/0), "+inf", "%f: +inf")

-- %f: width with non-finite
assertEquals(string.format("%10f", 1/0), "       inf", "%f: w10 inf")
assertEquals(string.format("%-10f", 1/0), "inf       ", "%f: left w10 inf")

-- %e: basic
assertEquals(string.format("%e", 0), "0.000000e+00", "%e: 0")
assertEquals(string.format("%e", 1), "1.000000e+00", "%e: 1")
assertEquals(string.format("%e", 100), "1.000000e+02", "%e: 100")
assertEquals(string.format("%e", 0.001), "1.000000e-03", "%e: 0.001")
assertEquals(string.format("%e", -42), "-4.200000e+01", "%e: -42")

-- %E: uppercase
assertEquals(string.format("%E", 100), "1.000000E+02", "%E: 100")

-- %e: precision
assertEquals(string.format("%.2e", 100), "1.00e+02", "%e: prec2")
assertEquals(string.format("%.0e", 100), "1e+02", "%e: prec0")
assertEquals(string.format("%.14e", 1), "1.00000000000000e+00", "%e: prec14")

-- %e: non-finite
assertEquals(string.format("%e", 1/0), "inf", "%e: inf")
assertEquals(string.format("%e", -1/0), "-inf", "%e: -inf")
assertEquals(string.format("%e", 0/0), "-nan", "%e: nan")

-- %e: width and flags
assertEquals(string.format("%15e", 100), "   1.000000e+02", "%e: w15")
assertEquals(string.format("%-15e", 100), "1.000000e+02   ", "%e: left w15")
assertEquals(string.format("%+e", 100), "+1.000000e+02", "%e: +100")

-- %g: basic
assertEquals(string.format("%g", 0), "0", "%g: 0")
assertEquals(string.format("%g", 1), "1", "%g: 1")
assertEquals(string.format("%g", 100), "100", "%g: 100")
assertEquals(string.format("%g", 100000), "100000", "%g: 100000")
assertEquals(string.format("%g", 1000000), "1e+06", "%g: 1000000")
assertEquals(string.format("%g", 0.0001), "0.0001", "%g: 0.0001")
assertEquals(string.format("%g", 0.00001), "1e-05", "%g: 0.00001")
assertEquals(string.format("%g", -42), "-42", "%g: -42")

-- %G: uppercase
assertEquals(string.format("%G", 1e6), "1E+06", "%G: 1e6")
assertEquals(string.format("%G", 1e-5), "1E-05", "%G: 1e-5")

-- %g: precision
assertEquals(string.format("%.1g", 3.14), "3", "%g: prec1")
assertEquals(string.format("%.2g", 3.14), "3.1", "%g: prec2")
assertEquals(string.format("%.4g", 3.14159), "3.142", "%g: prec4")
assertEquals(string.format("%.0g", 3.14), "3", "%g: prec0")
assertEquals(string.format("%.10g", 1), "1", "%g: prec10 int")

-- %g: strips trailing zeros
assertEquals(string.format("%.6g", 1.0), "1", "%g: strip 1.0")
assertEquals(string.format("%.6g", 1.5), "1.5", "%g: strip 1.5")
assertEquals(string.format("%.6g", 1.50), "1.5", "%g: strip 1.50")
assertEquals(string.format("%.14g", 1.0), "1", "%g: strip prec14 1.0")
assertEquals(string.format("%.14g", 1.5), "1.5", "%g: strip prec14 1.5")

-- %g: alt flag keeps trailing zeros and dot
assertEquals(string.format("%#g", 1.0), "1.00000", "%#g: 1.0")
assertEquals(string.format("%#.2g", 1.0), "1.0", "%#g: prec2 1.0")
assertEquals(string.format("%#.4g", 100), "100.0", "%#g: prec4 100")

-- %g: non-finite
assertEquals(string.format("%g", 1/0), "inf", "%g: inf")
assertEquals(string.format("%g", -1/0), "-inf", "%g: -inf")
assertEquals(string.format("%g", 0/0), "-nan", "%g: nan")
assertEquals(string.format("%+g", 1/0), "+inf", "%g: +inf")

-- %g: width and flags
assertEquals(string.format("%10g", 42), "        42", "%g: w10 42")
assertEquals(string.format("%-10g", 42), "42        ", "%g: left w10 42")
assertEquals(string.format("%010g", 42), "0000000042", "%g: zero-pad w10 42")
assertEquals(string.format("%+g", 42), "+42", "%g: +42")
assertEquals(string.format("% g", 42), " 42", "%g: space 42")

-- %g: Lua 5.4 %.14g reference values
assertEquals(string.format("%.14g", 0.0), "0", "%.14g: 0.0")
assertEquals(string.format("%.14g", 1.0), "1", "%.14g: 1.0")
assertEquals(string.format("%.14g", 1/3), "0.33333333333333", "%.14g: 1/3")
assertEquals(string.format("%.14g", math.pi), "3.1415926535898", "%.14g: pi")
assertEquals(string.format("%.14g", 1e-10), "1e-10", "%.14g: 1e-10")
assertEquals(string.format("%.14g", 1e18), "1e+18", "%.14g: 1e18")
assertEquals(string.format("%.14g", 2^63), "9.2233720368548e+18", "%.14g: 2^63")
assertEquals(string.format("%.14g", 2^53), "9.007199254741e+15", "%.14g: 2^53")
assertEquals(string.format("%.14g", 1.7976931348623e+308), "1.7976931348623e+308", "%.14g: max")
assertEquals(string.format("%.14g", 5e-324), "4.9406564584125e-324", "%.14g: min subnormal")
assertEquals(string.format("%.14g", 0/0), "-nan", "%.14g: nan")
assertEquals(string.format("%.14g", 1/0), "inf", "%.14g: inf")
assertEquals(string.format("%.14g", -1/0), "-inf", "%.14g: -inf")

-- mixed: multiple specifiers
assertEquals(string.format("%d + %d = %d", 1, 2, 3), "1 + 2 = 3", "mixed: d+d=d")
assertEquals(string.format("%s is %d", "age", 42), "age is 42", "mixed: s is d")
assertEquals(string.format("[%05d] %s (%.2f)", 7, "test", 3.14), "[00007] test (3.14)", "mixed: complex")

-- mixed: no specifiers
assertEquals(string.format("hello world"), "hello world", "mixed: no spec")
assertEquals(string.format(""), "", "mixed: empty")

-- error: invalid specifier (Lua 5.4 says "invalid conversion", 5.5 may differ)
do
  local ok = pcall(string.format, "%z", 1)
  assertEquals(ok, false, "%z: should error")
end

-- error: trailing percent
do
  local ok = pcall(string.format, "abc%")
  assertEquals(ok, false, "trailing %%: should error")
end

-- %f: negative zero
assertEquals(string.format("%f", -0.0), "-0.000000", "%f: -0.0")
assertEquals(string.format("%e", -0.0), "-0.000000e+00", "%e: -0.0")
assertEquals(string.format("%g", -0.0), "-0", "%g: -0.0")

-- %f: large precision
do
  local s = string.format("%.20f", 1.0)
  assertEquals(string.find(s, "%.") ~= nil, true, "%f: large prec has dot")
  assertEquals(#s, 22, "%f: large prec length")
end

-- zero width is no-op
assertEquals(string.format("%0d", 42), "42", "%0d: no-op")

-- %a: basic hex floats
assertEquals(string.format("%a", 0), "0x0p+0", "%a: 0")
assertEquals(string.format("%a", 1), "0x1p+0", "%a: 1")
assertEquals(string.format("%a", -1), "-0x1p+0", "%a: -1")
assertEquals(string.format("%a", 2), "0x1p+1", "%a: 2")
assertEquals(string.format("%a", 0.5), "0x1p-1", "%a: 0.5")
assertEquals(string.format("%a", 1.5), "0x1.8p+0", "%a: 1.5")
assertEquals(string.format("%a", -0.0), "-0x0p+0", "%a: -0.0")

-- %A: uppercase
assertEquals(string.format("%A", 1.5), "0X1.8P+0", "%A: 1.5")
assertEquals(string.format("%A", 255), "0X1.FEP+7", "%A: 255")

-- %a: precision
assertEquals(string.format("%.0a", 1.5), "0x2p+0", "%a: prec0 1.5")
assertEquals(string.format("%.1a", 1.5), "0x1.8p+0", "%a: prec1 1.5")
assertEquals(string.format("%.4a", 1.5), "0x1.8000p+0", "%a: prec4 1.5")
assertEquals(string.format("%.4a", 1), "0x1.0000p+0", "%a: prec4 1")

-- %a: width and flags
assertEquals(string.format("%20a", 1.5), "            0x1.8p+0", "%a: w20 1.5")
assertEquals(string.format("%-20a", 1.5), "0x1.8p+0            ", "%a: left w20 1.5")
assertEquals(string.format("%+a", 1.5), "+0x1.8p+0", "%a: +1.5")
assertEquals(string.format("% a", 1.5), " 0x1.8p+0", "%a: space 1.5")

-- %a: non-finite
assertEquals(string.format("%a", 1/0), "inf", "%a: inf")
assertEquals(string.format("%a", -1/0), "-inf", "%a: -inf")
assertEquals(string.format("%a", 0/0), "-nan", "%a: nan")
assertEquals(string.format("%A", 1/0), "INF", "%A: inf")

-- %a: Math.PI
assertEquals(string.format("%a", math.pi), "0x1.921fb54442d18p+1", "%a: pi")

-- %a: 0.1
assertEquals(string.format("%a", 0.1), "0x1.999999999999ap-4", "%a: 0.1")

-- %q: nil and booleans
assertEquals(string.format("%q", nil), "nil", "%q: nil")
assertEquals(string.format("%q", true), "true", "%q: true")
assertEquals(string.format("%q", false), "false", "%q: false")

-- %q: integers
assertEquals(string.format("%q", 0), "0", "%q: 0")
assertEquals(string.format("%q", 1), "1", "%q: 1")
assertEquals(string.format("%q", -1), "-1", "%q: -1")
assertEquals(string.format("%q", 42), "42", "%q: 42")
assertEquals(string.format("%q", -42), "-42", "%q: -42")

-- %q: floats as hex
assertEquals(string.format("%q", 1.5), "0x1.8p+0", "%q: 1.5")
assertEquals(string.format("%q", 0.1), "0x1.999999999999ap-4", "%q: 0.1")
assertEquals(string.format("%q", math.pi), "0x1.921fb54442d18p+1", "%q: pi")
assertEquals(string.format("%q", -0.5), "-0x1p-1", "%q: -0.5")
assertEquals(string.format("%q", -0.0), "-0x0p+0", "%q: -0.0")

-- %q: non-finite numbers
assertEquals(string.format("%q", 0/0), "(0/0)", "%q: nan")
assertEquals(string.format("%q", 1/0), "1e9999", "%q: inf")
assertEquals(string.format("%q", -1/0), "-1e9999", "%q: -inf")

-- %q: simple strings
assertEquals(string.format("%q", "hello"), '"hello"', "%q: hello")
assertEquals(string.format("%q", ""), '""', "%q: empty")

-- %q: strings with special characters
assertEquals(string.format("%q", 'say "hi"'), '"say \\"hi\\""', '%q: quotes')
assertEquals(string.format("%q", "back\\slash"), '"back\\\\slash"', "%q: backslash")
assertEquals(string.format("%q", "line\nbreak"), '"line\\\nbreak"', "%q: newline")
assertEquals(string.format("%q", "cr\rreturn"), '"cr\\13return"', "%q: cr")
assertEquals(string.format("%q", "tab\there"), '"tab\\9here"', "%q: tab")

-- %q: Lua reference example
do
  local result = string.format("%q", 'a string with "quotes" and \n new line')
  assertEquals(result, '"a string with \\"quotes\\" and \\\n new line"', "%q: reference")
end

-- %q: mixed with other specifiers
assertEquals(
  string.format("name=%q age=%d", "Alice", 30),
  'name="Alice" age=30',
  "%q: mixed"
)

-- %p: pointer-like identity for tables
do
  local t1 = {}
  local t2 = {}
  local s1 = string.format("%p", t1)
  local s2 = string.format("%p", t2)
  -- starts with 0x
  assertEquals(s1:sub(1, 2), "0x", "%p: table prefix")
  -- same object gives same result
  assertEquals(string.format("%p", t1), s1, "%p: stable identity")
  -- different objects give different results
  assertEquals(s1 ~= s2, true, "%p: distinct tables differ")
end

-- %p: functions get an identity too
do
  local f1 = function() end
  local f2 = function() end
  local s1 = string.format("%p", f1)
  local s2 = string.format("%p", f2)
  assertEquals(s1:sub(1, 2), "0x", "%p: function prefix")
  assertEquals(s1 ~= s2, true, "%p: distinct functions differ")
end

-- %p: nil and primitives yield (null)
assertEquals(string.format("%p", nil), "(null)", "%p: nil")
assertEquals(string.format("%p", true), "(null)", "%p: boolean")
assertEquals(string.format("%p", 42), "(null)", "%p: number")

-- %p: strings get an identity (they are GC objects in Lua)
do
  local s = string.format("%p", "hello")
  assertEquals(s:sub(1, 2), "0x", "%p: string prefix")
end

-- %p: width
do
  local t = {}
  local s = string.format("%20p", t)
  assertEquals(#s >= 20, true, "%p: width pads")
end

-- %p: left-justify
do
  local t = {}
  local s = string.format("%-20p", t)
  assertEquals(#s >= 20, true, "%p: left width pads")
  -- trailing spaces
  assertEquals(s:sub(#s, #s), " ", "%p: left-justify trailing space")
end

-- %p: pointer-like identity for tables
do
  local t1 = {}
  local t2 = {}
  local s1 = string.format("%p", t1)
  local s2 = string.format("%p", t2)
  assertEquals(s1:sub(1, 2), "0x", "%p: table prefix")
  assertEquals(string.format("%p", t1), s1, "%p: table stable identity")
  assertEquals(s1 ~= s2, true, "%p: distinct tables differ")
end

-- %p: functions get an identity too
do
  local f1 = function() end
  local f2 = function() end
  local s1 = string.format("%p", f1)
  local s2 = string.format("%p", f2)
  assertEquals(s1:sub(1, 2), "0x", "%p: function prefix")
  assertEquals(string.format("%p", f1), s1, "%p: function stable identity")
  assertEquals(s1 ~= s2, true, "%p: distinct functions differ")
end

-- %p: strings get an identity (interned: same content = same id)
do
  local s1 = string.format("%p", "hello")
  local s2 = string.format("%p", "hello")
  local s3 = string.format("%p", "world")
  assertEquals(s1:sub(1, 2), "0x", "%p: string prefix")
  assertEquals(s1, s2, "%p: same string content = same id")
  assertEquals(s1 ~= s3, true, "%p: different string content differs")
end

-- %p: nil and primitives yield (null)
assertEquals(string.format("%p", nil), "(null)", "%p: nil")
assertEquals(string.format("%p", true), "(null)", "%p: boolean")
assertEquals(string.format("%p", 42), "(null)", "%p: number")

-- %p: repeated calls on same table are stable
do
  local t = {}
  local ids = {}
  for i = 1, 5 do
    ids[i] = string.format("%p", t)
  end
  for i = 2, 5 do
    assertEquals(ids[i], ids[1], "%p: repeated call #" .. i .. " stable")
  end
end

-- %p: table stored in another table keeps identity
do
  local inner = {}
  local outer = { ref = inner }
  local s1 = string.format("%p", inner)
  local s2 = string.format("%p", outer.ref)
  assertEquals(s1, s2, "%p: table via reference stable")
end

-- %p: width
do
  local t = {}
  local s = string.format("%20p", t)
  assertEquals(#s >= 20, true, "%p: width pads")
end

-- %p: left-justify
do
  local t = {}
  local s = string.format("%-20p", t)
  assertEquals(#s >= 20, true, "%p: left width pads")
  assertEquals(s:sub(#s, #s), " ", "%p: left-justify trailing space")
end


================================================
FILE: client/space_lua/stdlib/global_test.lua
================================================
local function assertEqual(a, b)
    if a ~= b then
        error("Assertion failed: " .. a .. " is not equal to " .. b)
    end
end

-- Logic values, shouldn't be affected
assertEqual(some(nil), nil)
assertEqual(some(true), true)
assertEqual(some(false), false)

-- Numbers
assertEqual(some(1), 1)
assertEqual(some(0), 0) -- 0 is not falsy unlike C
assertEqual(some(-1), -1)
assertEqual(some(1/0), nil) -- inf
assertEqual(some(0/0), nil) -- nan

-- Strings
assertEqual(some("foo bar"), "foo bar")
assertEqual(some(""), nil)
assertEqual(some(" \n"), nil)

-- Tables
assertEqual(some({}), nil)
assertEqual(some({"baz"})[1], "baz") -- compare an element to ensure passthrough
assertEqual(some({foo="bar"})["foo"], "bar")


================================================
FILE: client/space_lua/stdlib/js.ts
================================================
import {
  jsToLuaValue,
  LuaBuiltinFunction,
  LuaTable,
  luaValueToJS,
} from "../runtime.ts";

export const jsApi = new LuaTable({
  /**
   * Creates a new instance of a JavaScript class.
   * @param constructorFn - The constructor function.
   * @param args - The arguments to pass to the constructor.
   * @returns The new instance.
   */
  new: new LuaBuiltinFunction(
    (sf, constructorFn: any, ...args) => {
      return new constructorFn(
        ...args.map((v) => luaValueToJS(v, sf)),
      );
    },
  ),
  /**
   * Imports a JavaScript module.
   * @param url - The URL of the module to import.
   * @returns The imported module.
   */
  import: new LuaBuiltinFunction(async (_sf, url) => {
    let m = await import(url);
    // Unwrap default if it exists
    if (Object.keys(m).length === 1 && m.default) {
      m = m.default;
    }
    return m;
  }),
  eachIterable: new LuaBuiltinFunction((_sf, val) => {
    const iterator = val[Symbol.asyncIterator]();
    return async () => {
      const result = await iterator.next();
      if (result.done) {
        return;
      }
      return result.value;
    };
  }),
  /**
   * Converts a JavaScript value to a Lua value.
   * @param val - The JavaScript value to convert.
   * @returns The Lua value.
   */
  tolua: new LuaBuiltinFunction((_sf, val) => jsToLuaValue(val)),
  /**
   * Converts a Lua value to a JavaScript value.
   * @param val - The Lua value to convert.
   * @returns The JavaScript value.
   */
  tojs: new LuaBuiltinFunction((sf, val) => luaValueToJS(val, sf)),
  /**
   * Logs a message to the console.
   * @param args - The arguments to log.
   */
  log: new LuaBuiltinFunction((_sf, ...args) => {
    console.log(...args);
  }),
  /**
   * Converts a Lua value to a JSON string.
   * @param val - The Lua value to convert.
   * @returns The JSON string.
   */
  stringify: new LuaBuiltinFunction((_sf, val) => JSON.stringify(val)),

  // Expose the global window object
  window: globalThis,
});


================================================
FILE: client/space_lua/stdlib/js_test.lua
================================================
local function assertEquals(a, b)
    if a ~= b then
        error("Assertion failed: " .. a .. " is not equal to " .. b)
    end
end

-- Disabling these for now, too dependent on flaky esm.sh

-- local lodash = js.import("https://esm.sh/lodash@4.17.21")

-- assertEquals(js.stringify(lodash.chunk({1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 3)), '[[1,2,3],[4,5,6],[7,8,9],[10]]')

-- local moment = js.import("https://esm.sh/moment@2.30.1")

-- local day = moment("1995-12-25");
-- assertEquals(day.format("DD-MM-YYYY"), "25-12-1995")


================================================
FILE: client/space_lua/stdlib/load.ts
================================================
import {
  LuaBuiltinFunction,
  type LuaEnv,
  LuaMultiRes,
  type LuaStackFrame,
  type LuaValue,
} from "../runtime.ts";
import { parse } from "../parse.ts";
import { evalStatement } from "../eval.ts";

// Returns a function (callable chunk) or (nil, "error message") pair.
export function luaLoad(code: LuaValue, sf: LuaStackFrame): LuaValue {
  const s = typeof code === "string" ? code : String(code);

  try {
    const block = parse(s, sf.astCtx || {});
    const globalEnvMaybe = sf.threadLocal.get("_GLOBAL");

    // Be vocal when no _GLOBAL is set
    if (!globalEnvMaybe) {
      console.warn(
        "load() called without _GLOBAL in thread-local environment",
      );
      return new LuaMultiRes([null, "Global environment not set"]);
    }

    const globalEnv: LuaEnv = globalEnvMaybe as LuaEnv;

    const runner = new LuaBuiltinFunction(async (innerSf: LuaStackFrame) => {
      const res = await evalStatement(block, globalEnv, innerSf, true);
      if (res === undefined) {
        return null;
      } else {
        if (res && typeof res === "object" && (res as any).ctrl === "return") {
          return new LuaMultiRes((res as any).values);
        }
        if (res && typeof res === "object" && (res as any).ctrl === "break") {
          throw new Error("break outside loop");
        }
        if (res && typeof res === "object" && (res as any).ctrl === "goto") {
          throw new Error("unexpected goto signal");
        }
        return null;
      }
    });

    return runner;
  } catch (e: any) {
    const msg = e && typeof e.message === "string" ? e.message : String(e);
    return new LuaMultiRes([null, msg]);
  }
}


================================================
FILE: client/space_lua/stdlib/load_test.lua
================================================
local function assertEquals(actual, expected, msg)
  if actual ~= expected then
    error('assertEquals failed: ' .. msg)
  end
end

local function assertTrue(cond, msg)
  if not cond then
    error('assertTrue failed: ' .. msg)
  end
end

local function assertFalse(cond, msg)
  if cond then
    error('assertFalse failed: ' .. msg)
  end
end

-- 1. Successful compile -> returns function; call yields value

do
  local f, err = load("return 40 + 2")

  assertEquals(type(f), "function", "load must return function on success")
  assertEquals(err, nil, "load must not return error on success")
  assertEquals(f(), 42, "loaded chunk executes and returns")
end

-- 2. No explicit return -> nil on call

do
  local f, err = load("local x = 1; x = x + 1")

  assertEquals(type(f), "function", "load returns function even without returns")
  assertEquals(err, nil, "no error expected")
  assertEquals(f(), nil, "chunk without return yields nil")
end

-- 3. Syntax error -> (nil, "error message"), no throw on load

do
  local f, err = load("return 1 +")

  assertEquals(f, nil, "syntax error: first result is nil")
  assertEquals(type(err), "string", "syntax error: second result is error string")
end

-- 4. Runtime error when calling the returned function

do
  local f = load("return 1 + {}") -- arithmetic on non-number at runtime
  local ok, res = pcall(f)

  assertFalse(ok, "pcall must be false on runtime error")
  assertEquals(type(res), "string", "pcall error message is string")
end

-- 5. Global reads are visible in loaded chunk

do
  g = 41
  local f = load("return g + 1")

  assertEquals(f(), 42, "loaded chunk sees global env (read)")
end

-- 6. Global writes from loaded chunk update globals
do
  h = 0
  local f = load("h = 9")

  assertEquals(f(), nil, "no explicit return")
  assertEquals(h, 9, "global updated by loaded chunk (write)")
end


================================================
FILE: client/space_lua/stdlib/math.ts
================================================
import {
  LuaBuiltinFunction,
  LuaMultiRes,
  LuaRuntimeError,
  LuaTable,
} from "../runtime.ts";
import { isNegativeZero, isTaggedFloat } from "../numeric.ts";

// Fast unwrap: avoids function call overhead for the common plain-number case
function untagNumber(x: any): number {
  if (typeof x === "number") return x;
  if (isTaggedFloat(x)) return x.value;
  return Number(x);
}

export const mathApi = new LuaTable({
  // math constants
  huge: 1 / 0,
  pi: Math.PI,

  // math.type(x) => "integer" | "float" | nil
  type: new LuaBuiltinFunction((_sf, x?: any) => {
    if (x === undefined) {
      throw new LuaRuntimeError(
        "bad argument #1 to 'math.type' (value expected)",
        _sf,
      );
    }
    if (isTaggedFloat(x)) {
      return "float";
    }
    if (typeof x === "number") {
      if (!Number.isFinite(x) || isNegativeZero(x)) {
        return "float";
      }
      return Number.isInteger(x) ? "integer" : "float";
    }
    if (typeof x === "bigint") {
      return "integer";
    }
    return null;
  }),
  /**
   * When called without arguments, returns a pseudo-random float with
   * uniform distribution in the range [0,1). When called with two
   * integers m and n, math.random returns a pseudo-random integer
   * with uniform distribution in the range [m, n]. The call
   * math.random(n), for a positive n, is equivalent to
   * math.random(1,n). The call math.random(0) produces an integer
   * with all bits (pseudo)random.
   */
  random: new LuaBuiltinFunction((_sf, m?: number, n?: number) => {
    if (m !== undefined) m = untagNumber(m);
    if (n !== undefined) n = untagNumber(n);

    if (m === undefined && n === undefined) {
      return Math.random();
    }

    if (!Number.isInteger(m)) {
      throw new LuaRuntimeError(
        "bad argument #1 to 'math.random' (integer expected)",
        _sf,
      );
    }

    if (n === undefined) {
      if (m! == 0) {
        const high = Math.floor(Math.random() * 0x100000000);
        const low = Math.floor(Math.random() * 0x100000000);
        let result = (BigInt(high) << 32n) | BigInt(low);
        if (result & (1n << 63n)) {
          result -= 1n << 64n;
        }
        return result;
      }
      if (m! < 1) {
        throw new LuaRuntimeError(
          "bad argument #1 to 'math.random' (interval is empty)",
          _sf,
        );
      }
      return Math.floor(Math.random() * m!) + 1;
    }

    if (!Number.isInteger(n!)) {
      throw new LuaRuntimeError(
        "bad argument #2 to 'math.random' (integer expected)",
        _sf,
      );
    }

    if (n! < m!) {
      throw new LuaRuntimeError(
        "bad argument #1 to 'math.random' (interval is empty)",
        _sf,
      );
    }
    return Math.floor(Math.random() * (n! - m! + 1)) + m!;
  }),

  // Basic functions
  abs: new LuaBuiltinFunction((_sf, x: number) => Math.abs(untagNumber(x))),
  ceil: new LuaBuiltinFunction((_sf, x: number) => Math.ceil(untagNumber(x))),
  floor: new LuaBuiltinFunction((_sf, x: number) => Math.floor(untagNumber(x))),
  max: new LuaBuiltinFunction((_sf, ...args: number[]) =>
    Math.max(...args.map(untagNumber))
  ),
  min: new LuaBuiltinFunction((_sf, ...args: number[]) =>
    Math.min(...args.map(untagNumber))
  ),

  // Rounding and remainder
  fmod: new LuaBuiltinFunction((_sf, x: number, y: number) =>
    untagNumber(x) % untagNumber(y)
  ),
  modf: new LuaBuiltinFunction((_sf, x: number) => {
    const xn = untagNumber(x);
    const int = Math.trunc(xn);
    const frac = xn - int;
    return new LuaMultiRes([int, frac]);
  }),

  // Power and logarithms
  exp: new LuaBuiltinFunction((_sf, x: number) => Math.exp(untagNumber(x))),
  log: new LuaBuiltinFunction((_sf, x: number, base?: number) => {
    if (base === undefined) {
      return Math.log(untagNumber(x));
    }
    return Math.log(untagNumber(x)) / Math.log(untagNumber(base));
  }),
  pow: new LuaBuiltinFunction((_sf, x: number, y: number) =>
    Math.pow(untagNumber(x), untagNumber(y))
  ),
  sqrt: new LuaBuiltinFunction((_sf, x: number) => Math.sqrt(untagNumber(x))),

  // Trigonometric functions
  cos: new LuaBuiltinFunction((_sf, x: number) => Math.cos(untagNumber(x))),
  sin: new LuaBuiltinFunction((_sf, x: number) => Math.sin(untagNumber(x))),
  tan: new LuaBuiltinFunction((_sf, x: number) => Math.tan(untagNumber(x))),
  acos: new LuaBuiltinFunction((_sf, x: number) => Math.acos(untagNumber(x))),
  asin: new LuaBuiltinFunction((_sf, x: number) => Math.asin(untagNumber(x))),
  atan: new LuaBuiltinFunction((_sf, y: number, x?: number) => {
    if (x === undefined) {
      return Math.atan(untagNumber(y));
    }
    return Math.atan2(untagNumber(y), untagNumber(x));
  }),

  // Hyperbolic functions
  cosh: new LuaBuiltinFunction((_sf, x: number) => Math.cosh(untagNumber(x))),
  sinh: new LuaBuiltinFunction((_sf, x: number) => Math.sinh(untagNumber(x))),
  tanh: new LuaBuiltinFunction((_sf, x: number) => Math.tanh(untagNumber(x))),

  // Additional utility
  deg: new LuaBuiltinFunction((_sf, x: number) =>
    untagNumber(x) * 180 / Math.PI
  ),
  rad: new LuaBuiltinFunction((_sf, x: number) =>
    untagNumber(x) * Math.PI / 180
  ),
  ult: new LuaBuiltinFunction((_sf, m: number, n: number) => {
    return (untagNumber(m) >>> 0) < (untagNumber(n) >>> 0);
  }),

  // Keep the cosineSimilarity utility function
  cosineSimilarity: new LuaBuiltinFunction(
    (sf, vecA: LuaTable | number[], vecB: LuaTable | number[]) => {
      if (vecA instanceof LuaTable) {
        vecA = vecA.toJSArray();
      }
      if (vecB instanceof LuaTable) {
        vecB = vecB.toJSArray();
      }

      if (vecA.length !== vecB.length) {
        throw new LuaRuntimeError("Vectors must be of the same length", sf);
      }

      let dotProduct = 0;
      let normA = 0;
      let normB = 0;

      for (let i = 0; i < vecA.length; i++) {
        dotProduct += vecA[i] * vecB[i];
        normA += vecA[i] ** 2;
        normB += vecB[i] ** 2;
      }

      return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
    },
  ),
});


================================================
FILE: client/space_lua/stdlib/math_test.lua
================================================
local function assertEquals(a, b, msg)
  if a ~= b then
    error((msg or "assertEquals failed") .. ": expected " .. tostring(b) .. " got " .. tostring(a))
  end
end

local function assertTrue(v, msg)
  if not v then
    error(msg or "assertTrue failed")
  end
end

local function assertClose(a, b, eps, msg)
  eps = eps or 1e-12
  if a ~= a and b ~= b then
    return
  end
  if a == b then
    return
  end
  local d = a - b
  if d < 0 then d = -d end
  if d > eps then
    error((msg or "assertClose failed") .. ": expected " .. tostring(b) .. " got " .. tostring(a))
  end
end

-- math.type (basic)
do
  assertEquals(math.type(0), "integer", "math.type(0)")
  assertEquals(math.type(1), "integer", "math.type(1)")
  assertEquals(math.type(0.0), "float", "math.type(0.0)")
  assertEquals(math.type(-0.0), "float", "math.type(-0.0)")
  assertEquals(math.type(1.5), "float", "math.type(1.5)")
end

-- constants
do
  assertTrue(type(math.pi) == "number", "math.pi exists")
  assertTrue(type(math.huge) == "number", "math.huge exists")
end

-- abs
do
  assertEquals(math.abs(-5), 5, "abs(-5)")
  assertEquals(math.abs(-3), 3, "abs(-3)")
  assertEquals(math.abs(3), 3, "abs(3)")
  assertClose(math.abs(-3.25), 3.25, 1e-12, "abs(-3.25)")
end

-- floor/ceil (rounding correctness)
do
  assertEquals(math.floor(3.7), 3, "floor(3.7)")
  assertEquals(math.floor(3), 3, "floor(3)")
  assertEquals(math.floor(3.0), 3, "floor(3.0)")
  assertEquals(math.floor(3.1), 3, "floor(3.1)")
  assertEquals(math.floor(3.9), 3, "floor(3.9)")
  assertEquals(math.floor(-3.1), -4, "floor(-3.1)")
  assertEquals(math.floor(-3.9), -4, "floor(-3.9)")

  assertEquals(math.ceil(3.3), 4, "ceil(3.3)")
  assertEquals(math.ceil(3), 3, "ceil(3)")
  assertEquals(math.ceil(3.0), 3, "ceil(3.0)")
  assertEquals(math.ceil(3.1), 4, "ceil(3.1)")
  assertEquals(math.ceil(3.9), 4, "ceil(3.9)")
  assertEquals(math.ceil(-3.1), -3, "ceil(-3.1)")
  assertEquals(math.ceil(-3.9), -3, "ceil(-3.9)")
end

-- min/max
do
  assertEquals(math.max(1, 2, 3, 4), 4, "max(1,2,3,4)")
  assertEquals(math.min(1, 2, 3, 4), 1, "min(1,2,3,4)")

  assertEquals(math.max(1, 2, 3), 3, "max")
  assertEquals(math.min(1, 2, 3), 1, "min")
  assertEquals(math.max(-1, -2), -1, "max negative")
  assertEquals(math.min(-1, -2), -2, "min negative")
end

-- modf
do
  local i, f = math.modf(3.5)
  assertEquals(i, 3, "modf(3.5) int")
  assertClose(f, 0.5, 1e-12, "modf(3.5) frac")

  local i2, f2 = math.modf(-3.5)
  assertEquals(i2, -3, "modf(-3.5) int")
  assertClose(f2, -0.5, 1e-12, "modf(-3.5) frac")
end

-- sqrt/exp/log/pow
do
  assertEquals(math.exp(0), 1, "exp(0)")
  assertClose(math.log(math.exp(1)), 1, 1e-12, "log(exp(1))")
  assertClose(math.log(8, 2), 3, 1e-12, "log base 2 of 8")
  assertEquals(math.pow(2, 3), 8, "pow(2,3)")
  assertEquals(math.sqrt(9), 3, "sqrt(9)")
end

-- trigonomic
do
  assertEquals(math.cos(0), 1, "cos(0)")
  assertEquals(math.sin(0), 0, "sin(0)")
  assertEquals(math.tan(0), 0, "tan(0)")
  assertEquals(math.acos(1), 0, "acos(1)")
  assertEquals(math.asin(0), 0, "asin(0)")
  assertEquals(math.atan(0), 0, "atan(0)")
  assertEquals(math.atan(0, 1), 0, "atan(0,1)")
end

-- hyperbolic
do
  assertEquals(math.cosh(0), 1, "cosh(0)")
  assertEquals(math.sinh(0), 0, "sinh(0)")
  assertEquals(math.tanh(0), 0, "tanh(0)")
end

-- remainder
do
  assertEquals(math.fmod(7, 3), 1, "fmod(7,3)")
end

-- random
do
  local a1 = math.random()
  assertTrue(a1 >= 0 and a1 < 1, "random() range")

  local aN = math.random(10)
  assertTrue(aN >= 1 and aN <= 10, "random(10) range")

  local aR = math.random(5, 10)
  assertTrue(aR >= 5 and aR <= 10, "random(5,10) range")
end

-- unsigned less-than
do
  assertEquals(math.ult(1, 2), true, "ult(1,2)")
  assertEquals(math.ult(2, 1), false, "ult(2,1)")
end

-- reported regression
do
  local x1 = 8 * 0.5
  local x2 = 8 * 0.49
  local x3 = 9 * 0.5

  assertClose(x1, 4.0, 0, "8*0.5 == 4.0")
  assertClose(x2, 3.92, 1e-12, "8*0.49 == 3.92")
  assertClose(x3, 4.5, 1e-12, "9*0.5 == 4.5")

  local f1 = math.floor(x1)
  local f2 = math.floor(x2)
  local f3 = math.floor(x3)

  assertTrue(f1 == f1, "floor(8*0.5) not NaN")
  assertTrue(f2 == f2, "floor(8*0.49) not NaN")
  assertTrue(f3 == f3, "floor(9*0.5) not NaN")

  assertEquals(f1, 4, "floor(8*0.5)")
  assertEquals(f2, 3, "floor(8*0.49)")
  assertEquals(f3, 4, "floor(9*0.5)")
end


================================================
FILE: client/space_lua/stdlib/net.ts
================================================
import type { Client } from "../../client.ts";
import type {
  ProxyFetchRequest,
  ProxyFetchResponse,
} from "../../plugos/proxy_fetch.ts";
import { fsEndpoint } from "../../spaces/constants.ts";
import { LuaNativeJSFunction, LuaTable } from "../runtime.ts";

export const netApi = new LuaTable({
  proxyFetch: new LuaNativeJSFunction(
    async (
      url: string,
      options: ProxyFetchRequest = {},
    ): Promise<ProxyFetchResponse> => {
      // JSONify any non-serializable body
      if (
        options?.body && typeof options.body !== "string" &&
        !(options.body instanceof Uint8Array)
      ) {
        options.body = JSON.stringify(options.body);
      }
      const fetchOptions = options
        ? {
          method: options.method,
          headers: {} as Record<string, string>,
          body: options.body,
        }
        : {};
      fetchOptions.headers = buildProxyHeaders(options.headers);
      const resp = await client.httpSpacePrimitives.authenticatedFetch(
        buildProxyUrl(client, url),
        fetchOptions,
      );
      if (resp.status !== 200) {
        return {
          ok: false,
          status: resp.status,
          headers: extractProxyHeaders(resp.headers),
          body: await resp.text(),
        };
      }
      // Do sensible things with the body based on the content type
      let body: any;
      const contentTypeHeader = options.responseEncoding ||
        resp.headers.get("x-proxy-header-content-type");
      const statusCode = +(resp.headers.get("x-proxy-status-code") || "200");
      if (contentTypeHeader?.startsWith("application/json")) {
        body = await resp.json();
      } else if (
        contentTypeHeader?.startsWith("application/xml") ||
        contentTypeHeader?.startsWith("text/")
      ) {
        body = await resp.text();
      } else {
        body = new Uint8Array(await resp.arrayBuffer());
      }
      return {
        ok: resp.ok,
        status: statusCode,
        headers: extractProxyHeaders(resp.headers),
        body: body,
      };
    },
  ),
  readURI: new LuaNativeJSFunction(
    (uri: string, options: { uri?: string; encoding?: string } = {}) => {
      options.uri = uri;
      return client.clientSystem.serviceRegistry.invokeBestMatch(
        "net.readURI:" + uri,
        options,
      );
    },
  ),
  writeURI: new LuaNativeJSFunction(
    (uri: string, content: string | Uint8Array) => {
      return client.clientSystem.serviceRegistry.invokeBestMatch(
        "net.writeURI:" + uri,
        { uri, content },
      );
    },
  ),
});

// Utility functions
function buildProxyUrl(client: Client, url: string) {
  url = url.replace(/^https?:\/\//, "");
  // Strip off the /.fs and replace with /.proxy
  return client.httpSpacePrimitives.url.slice(0, -fsEndpoint.length) +
    "/.proxy/" + url;
}

function buildProxyHeaders(headers?: Record<string, any>): Record<string, any> {
  const newHeaders: Record<string, any> = { "X-Proxy-Request": "true" };
  if (!headers) {
    return newHeaders;
  }
  for (const [key, value] of Object.entries(headers)) {
    newHeaders[`X-Proxy-Header-${key}`] = value;
  }
  return newHeaders;
}

function extractProxyHeaders(
  headers: Headers,
): Record<string, any> {
  const newHeaders: Record<string, any> = {};
  for (const [key, value] of headers.entries()) {
    if (key.toLowerCase().startsWith("x-proxy-header-")) {
      newHeaders[key.slice("x-proxy-header-".length)] = value;
    }
  }
  return newHeaders;
}


================================================
FILE: client/space_lua/stdlib/os.ts
================================================
import { LuaBuiltinFunction, LuaTable } from "../runtime.ts";

const ONE_DAY = 1000 * 60 * 60 * 24;
const ONE_WEEK = ONE_DAY * 7;

function weekNumber(
  d: Date,
  utc: boolean,
  weekStartDay: number,
  iso: boolean,
): number {
  const year = utc ? d.getUTCFullYear() : d.getFullYear();
  const month = utc ? d.getUTCMonth() : d.getMonth();
  const day = utc ? d.getUTCDate() : d.getDate();
  const date = new Date(Date.UTC(year, month, day));

  if (iso) {
    const target = new Date(date);
    target.setUTCDate(
      target.getUTCDate() + 3 - ((target.getUTCDay() + 6) % 7),
    );

    const yearStart = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
    const weekStart = new Date(yearStart);
    weekStart.setUTCDate(
      yearStart.getUTCDate() - ((yearStart.getUTCDay() + 6) % 7),
    );

    return 1 + Math.floor((target.getTime() - weekStart.getTime()) / ONE_WEEK);
  }

  const yearStart = new Date(Date.UTC(year, 0, 1));
  const startDay = yearStart.getUTCDay();
  const offset = (7 + (startDay - weekStartDay)) % 7;
  const firstWeekStart = new Date(yearStart);

  firstWeekStart.setUTCDate(yearStart.getUTCDate() + (7 - offset) % 7);

  if (date < firstWeekStart) return 0;
  return 1 +
    Math.floor((date.getTime() - firstWeekStart.getTime()) / ONE_WEEK);
}

function dayOfYear(d: Date, utc: boolean): number {
  const year = utc ? d.getUTCFullYear() : d.getFullYear();
  const start = new Date(Date.UTC(year, 0, 0));

  const current = utc
    ? new Date(
      Date.UTC(
        year,
        d.getUTCMonth(),
        d.getUTCDate(),
        d.getUTCHours(),
        d.getUTCMinutes(),
        d.getUTCSeconds(),
      ),
    )
    : new Date(
      Date.UTC(
        year,
        d.getMonth(),
        d.getDate(),
        d.getHours(),
        d.getMinutes(),
        d.getSeconds(),
      ),
    );

  return Math.floor((current.getTime() - start.getTime()) / ONE_DAY);
}

function isoWeekYear(d: Date, utc: boolean): number {
  const year = utc ? d.getUTCFullYear() : d.getFullYear();
  const month = utc ? d.getUTCMonth() : d.getMonth();
  const day = utc ? d.getUTCDate() : d.getDate();
  const target = new Date(Date.UTC(year, month, day));

  target.setUTCDate(
    target.getUTCDate() + 3 - ((target.getUTCDay() + 6) % 7),
  );

  return target.getUTCFullYear();
}

function isDST(d: Date): boolean {
  const jan = new Date(d.getFullYear(), 0, 1);

  return d.getTimezoneOffset() < jan.getTimezoneOffset();
}

function yr(d: Date, utc: boolean): number {
  return utc ? d.getUTCFullYear() : d.getFullYear();
}

function mo(d: Date, utc: boolean): number {
  return utc ? d.getUTCMonth() : d.getMonth();
}

function da(d: Date, utc: boolean): number {
  return utc ? d.getUTCDate() : d.getDate();
}

function hr(d: Date, utc: boolean): number {
  return utc ? d.getUTCHours() : d.getHours();
}

function mi(d: Date, utc: boolean): number {
  return utc ? d.getUTCMinutes() : d.getMinutes();
}

function sc(d: Date, utc: boolean): number {
  return utc ? d.getUTCSeconds() : d.getSeconds();
}

function wd(d: Date, utc: boolean): number {
  return utc ? d.getUTCDay() : d.getDay();
}

function pad2(n: number): string {
  return n.toString().padStart(2, "0");
}

function pad3(n: number): string {
  return n.toString().padStart(3, "0");
}

function dateTable(d: Date, utc: boolean): LuaTable {
  const tbl = new LuaTable({
    year: yr(d, utc),
    month: mo(d, utc) + 1,
    day: da(d, utc),
    hour: hr(d, utc),
    min: mi(d, utc),
    sec: sc(d, utc),
    wday: wd(d, utc) + 1,
    yday: dayOfYear(d, utc),
  });

  if (!utc) {
    tbl.rawSet("isdst", isDST(d));
  }

  return tbl;
}

// Build the specifier map for a given `Date` and `utc` flag.
// Returns a record mapping single-char specifier to its output string.
function buildSpecMap(
  d: Date,
  utc: boolean,
): Record<string, () => string> {
  const h = () => hr(d, utc);
  const h12 = () => h() % 12 || 12;
  const dow = () => wd(d, utc);

  return {
    // Date
    "Y": () => yr(d, utc).toString(),
    "y": () => pad2(yr(d, utc) % 100),
    "C": () => pad2(Math.floor(yr(d, utc) / 100)),
    "m": () => pad2(mo(d, utc) + 1),
    "d": () => pad2(da(d, utc)),
    "e": () => da(d, utc).toString().padStart(2, " "),
    "j": () => pad3(dayOfYear(d, utc)),

    // Time
    "H": () => pad2(h()),
    "I": () => pad2(h12()),
    "M": () => pad2(mi(d, utc)),
    "S": () => pad2(sc(d, utc)),
    "p": () => h() >= 12 ? "PM" : "AM",

    // Weekday
    "A": () =>
      d.toLocaleString("en-US", {
        weekday: "long",
        ...(utc ? { timeZone: "UTC" } : {}),
      }),
    "a": () =>
      d.toLocaleString("en-US", {
        weekday: "short",
        ...(utc ? { timeZone: "UTC" } : {}),
      }),
    "w": () => dow().toString(),
    "u": () => (dow() === 0 ? 7 : dow()).toString(),

    // Month name
    "b": () =>
      d.toLocaleString("en-US", {
        month: "short",
        ...(utc ? { timeZone: "UTC" } : {}),
      }),
    "h": () =>
      d.toLocaleString("en-US", {
        month: "short",
        ...(utc ? { timeZone: "UTC" } : {}),
      }),
    "B": () =>
      d.toLocaleString("en-US", {
        month: "long",
        ...(utc ? { timeZone: "UTC" } : {}),
      }),

    // Week number
    "U": () => pad2(weekNumber(d, utc, 0, false)),
    "W": () => pad2(weekNumber(d, utc, 1, false)),
    "V": () => pad2(weekNumber(d, utc, 1, true)),
    "G": () => isoWeekYear(d, utc).toString(),
    "g": () => pad2(isoWeekYear(d, utc) % 100),

    // Composite specifiers
    "c": () =>
      d.toLocaleString("en-US", {
        ...(utc ? { timeZone: "UTC" } : {}),
      }),
    "x": () =>
      d.toLocaleDateString("en-US", {
        ...(utc ? { timeZone: "UTC" } : {}),
      }),
    "X": () =>
      d.toLocaleTimeString("en-US", {
        ...(utc ? { timeZone: "UTC" } : {}),
      }),
    "D": () =>
      `${pad2(mo(d, utc) + 1)}/${pad2(da(d, utc))}/${pad2(yr(d, utc) % 100)}`,
    "F": () =>
      `${yr(d, utc).toString()}-${pad2(mo(d, utc) + 1)}-${pad2(da(d, utc))}`,
    "R": () => `${pad2(h())}:${pad2(mi(d, utc))}`,
    "T": () => `${pad2(h())}:${pad2(mi(d, utc))}:${pad2(sc(d, utc))}`,
    "r": () =>
      `${pad2(h12())}:${pad2(mi(d, utc))}:${pad2(sc(d, utc))} ${
        h() >= 12 ? "PM" : "AM"
      }`,

    // Epoch
    "s": () => Math.floor(d.getTime() / 1000).toString(),

    // Whitespace
    "n": () => "\n",
    "t": () => "\t",

    // Timezone
    "Z": () => {
      if (utc) return "UTC";
      const match = d.toTimeString().match(/\((.*)\)/);
      return match ? match[1] : "";
    },
    "z": () => {
      if (utc) return "+0000";
      const offset = -d.getTimezoneOffset();
      const sign = offset >= 0 ? "+" : "-";
      const abs = Math.abs(offset);
      return `${sign}${pad2(Math.floor(abs / 60))}${pad2(abs % 60)}`;
    },

    // Literal
    "%": () => "%",
  };
}

function luaFormatTime(fmt: string, d: Date, utc: boolean): string {
  const specs = buildSpecMap(d, utc);

  let out = "";
  let i = 0;

  while (i < fmt.length) {
    if (fmt[i] !== "%") {
      out += fmt[i];
      i++;
      continue;
    }
    i++; // skip '%'
    if (i >= fmt.length) {
      throw new Error("invalid conversion specifier '%'");
    }

    const ch = fmt[i];
    const fn = specs[ch];
    if (!fn) {
      throw new Error(`invalid conversion specifier '%${ch}'`);
    }

    out += fn();
    i++;
  }

  return out;
}

export const osApi = new LuaTable({
  time: new LuaBuiltinFunction((_sf, tbl?: LuaTable) => {
    if (tbl) {
      if (!tbl.has("year")) {
        throw new Error("time(): year is required");
      }

      if (!tbl.has("month")) {
        throw new Error("time(): month is required");
      }

      if (!tbl.has("day")) {
        throw new Error("time(): day is required");
      }

      const year = tbl.get("year");
      const month = tbl.get("month");
      const day = tbl.get("day");
      const hour = tbl.get("hour") ?? 12;
      const min = tbl.get("min") ?? 0;
      const sec = tbl.get("sec") ?? 0;
      const date = new Date(year, month - 1, day, hour, min, sec);

      return Math.floor(date.getTime() / 1000);
    }

    return Math.floor(Date.now() / 1000);
  }),

  // Returns the difference, from time `t1` to time `t2` in seconds
  // In POSIX and some other systems, this value is exactly $t2-t1$.
  difftime: new LuaBuiltinFunction((_sf, t2: number, t1: number): number => {
    return t2 - t1;
  }),

  // Returns a string or a table containing date and time, formatted
  // according to the given string format.
  //
  // If format starts with '!', the date is formatted in UTC.
  //
  // If format is "*t" (or "!*t"), returns a table with fields:
  //
  // - `year`,
  // - `month` (1-12),
  // - `day` (1-31),
  // - `hour` (0-23),
  // - `min` (0-59),
  // - `sec` (0-61),
  // - `wday` (1-7, Sunday is 1),
  // - `yday` (1-366), and
  // - `isdst` (boolean).
  //
  // Otherwise, format specifiers follow ISO C `strftime`.
  //
  // If format is absent, it defaults to `%c`.
  date: new LuaBuiltinFunction(
    (_sf, format?: string, timestamp?: number) => {
      let fmt = format ?? "%c";
      let utc = false;

      if (fmt.startsWith("!")) {
        utc = true;
        fmt = fmt.slice(1);
      }

      const d = timestamp !== undefined && timestamp !== null
        ? new Date(timestamp * 1000)
        : new Date();

      if (fmt === "*t") {
        return dateTable(d, utc);
      }

      return luaFormatTime(fmt, d, utc);
    },
  ),

  // Returns an approximation of CPU time used by the program in seconds.
  clock: new LuaBuiltinFunction((_sf): number => {
    return performance.now() / 1000.0;
  }),
});


================================================
FILE: client/space_lua/stdlib/os_test.lua
================================================
local function assertEquals(a, b)
    if a ~= b then
        error("Assertion failed: " .. tostring(a) .. " ~= " .. tostring(b))
    end
end

-- os.time basics
assert(os.time() > 0)

-- os.difftime
local t = os.time()
assert(os.difftime(t + 10, t) == 10)
assert(os.difftime(t, t + 10) == -10)
assert(os.difftime(t, t) == 0)

-- os.date default format (no args) returns a string
assert(type(os.date()) == "string")

-- Empty format returns empty string
assertEquals(os.date(""), "")

-- "!" alone returns empty string
assertEquals(os.date("!"), "")

-- Literal %%
assertEquals(os.date("%%"), "%")
assertEquals(os.date("%%%%"), "%%")

-- Reference time: 2006-01-02 15:04:05 UTC (Go reference time)
-- All fields are distinct: Y=2006 m=01 d=02 H=15 M=04 S=05 wday=Mon
local ts = 1136214245

-- Basic date specifiers (UTC)
assertEquals(os.date("!%Y", ts), "2006")
assertEquals(os.date("!%m", ts), "01")
assertEquals(os.date("!%d", ts), "02")
assertEquals(os.date("!%H", ts), "15")
assertEquals(os.date("!%M", ts), "04")
assertEquals(os.date("!%S", ts), "05")

-- %y (2-digit year)
assertEquals(os.date("!%y", ts), "06")

-- %C (century)
assertEquals(os.date("!%C", ts), "20")

-- %I (12-hour clock): 15 -> 03
assertEquals(os.date("!%I", ts), "03")

-- %p (AM/PM): hour 15 -> PM
assertEquals(os.date("!%p", ts), "PM")

-- AM case: 2006-01-02 03:04:05 UTC = ts - 12*3600
local ts_am = ts - 12 * 3600
assertEquals(os.date("!%p", ts_am), "AM")
assertEquals(os.date("!%I", ts_am), "03")
assertEquals(os.date("!%H", ts_am), "03")

-- %w (weekday, 0=Sunday..6=Saturday); 2006-01-02 is Monday=1
assertEquals(os.date("!%w", ts), "1")

-- %u (weekday, 1=Monday..7=Sunday); Monday=1
assertEquals(os.date("!%u", ts), "1")

-- Sunday: 2006-01-01 is Sunday
local ts_sun = ts - 86400
assertEquals(os.date("!%w", ts_sun), "0")
assertEquals(os.date("!%u", ts_sun), "7")

-- %j (day of year, 001-366); Jan 2 = 002
assertEquals(os.date("!%j", ts), "002")

-- %e (day of month, space-padded); 2 -> " 2"
assertEquals(os.date("!%e", ts), " 2")

-- Composite: %F (ISO date)
assertEquals(os.date("!%F", ts), "2006-01-02")

-- Composite: %D (MM/DD/YY)
assertEquals(os.date("!%D", ts), "01/02/06")

-- Composite: %T (HH:MM:SS)
assertEquals(os.date("!%T", ts), "15:04:05")

-- Composite: %R (HH:MM)
assertEquals(os.date("!%R", ts), "15:04")

-- Composite: %r (12-hour with AM/PM)
assertEquals(os.date("!%r", ts), "03:04:05 PM")

-- %n and %t
assertEquals(os.date("!%n"), "\n")
assertEquals(os.date("!%t"), "\t")

-- %s (epoch seconds)
--
-- Note: POSIX extension; comment out to run with standard Lua
-- interpreters.
assertEquals(os.date("!%s", ts), "1136214245")

-- %z and %Z in UTC/GMT mode
--
-- Note: Uncomment the "GMT" variant to run with standard Lua interpreter
-- and comment out the "UTC" variant.
assertEquals(os.date("!%z", ts), "+0000")
--assertEquals(os.date("!%Z", ts), "GMT") -- on most platforms
assertEquals(os.date("!%Z", ts), "UTC") -- in Space Lua

-- %h is alias for %b
assertEquals(os.date("!%h", ts), os.date("!%b", ts))

-- Multiple specifiers combined
assertEquals(os.date("!%Y-%m-%d %H:%M:%S", ts), "2006-01-02 15:04:05")

-- Plain text mixed with specifiers
assertEquals(os.date("!year=%Y", ts), "year=2006")

-- *t table (UTC)
local dt = os.date("!*t", ts)
assertEquals(dt.year, 2006)
assertEquals(dt.month, 1)
assertEquals(dt.day, 2)
assertEquals(dt.hour, 15)
assertEquals(dt.min, 4)
assertEquals(dt.sec, 5)
assertEquals(dt.wday, 2) -- Monday; Lua: 1=Sunday, so Monday=2
assertEquals(dt.yday, 2)

-- *t table (local time) has isdst field
local dt_local = os.date("*t", ts)
assert(type(dt_local.isdst) == "boolean")

-- UTC *t should not have isdst
assert(not dt.isdst)

-- %G/%g (ISO week-based year)
-- 2006-01-02 is in ISO week 1 of 2006
assertEquals(os.date("!%G", ts), "2006")
assertEquals(os.date("!%g", ts), "06")

-- ISO week-year boundary: 2005-01-01 (Saturday) is in ISO week 53 of 2004
local ts_2005 = 1104537600  -- 2005-01-01 00:00:00 UTC
assertEquals(os.date("!%G", ts_2005), "2004")
assertEquals(os.date("!%V", ts_2005), "53")

-- Week number specifiers at reference time (2006-01-02, Monday)
-- %U (weeks starting Sunday): Jan 1 is Sunday -> week 01, Jan 2 still week 01
assertEquals(os.date("!%U", ts), "01")
-- %W (weeks starting Monday): Jan 2 is first Monday -> week 01
assertEquals(os.date("!%W", ts), "01")
-- %V (ISO week): week 01
assertEquals(os.date("!%V", ts), "01")

-- Preserved original week tests
assertEquals(
    os.date("%Y-%m-%d", os.time({ year = 2020, month = 1, day = 1 })),
    "2020-01-01"
)
assertEquals(
    os.date("%Y-%m-%d", os.time({ year = 2025, month = 2, day = 3 })),
    "2025-02-03"
)

-- Invalid specifier throws error
local ok, err = pcall(os.date, "%Q")
assert(not ok)
assert(type(err) == "string")

-- Repeated specifiers
assertEquals(os.date("!%Y%Y", ts), "20062006")

-- os.clock
-- Returns a number (seconds)
assert(type(os.clock()) == "number")

-- Must be non-negative
assert(os.clock() >= 0)

-- Monotonically non-decreasing
local c1 = os.clock()
local c2 = os.clock()
assert(c2 >= c1)


================================================
FILE: client/space_lua/stdlib/pattern.ts
================================================
// Lua Optimized Pattern Matching Engine Implementation

const CH_ESC = 37; // '%'
const CH_OPEN = 40; // '('
const CH_CLOSE = 41; // ')'
const CH_DOT = 46; // '.'
const CH_DOLLAR = 36; // '$'
const CH_LBRACKET = 91; // '['
const CH_RBRACKET = 93; // ']'
const CH_CARET = 94; // '^'
const CH_DASH = 45; // '-'
const CH_STAR = 42; // '*'
const CH_PLUS = 43; // '+'
const CH_QUESTION = 63; // '?'
const CH_0 = 48; // '0'
const CH_9 = 57; // '9'

const SPECIALS_SET = new Set<number>([
  CH_CARET,
  CH_DOLLAR,
  CH_STAR,
  CH_PLUS,
  CH_QUESTION,
  CH_DOT,
  CH_OPEN,
  CH_LBRACKET,
  CH_ESC,
  CH_DASH,
]);

const MAX_CAPTURES = 32;
const MAX_MATCH_DEPTH = 200;

const CAP_UNFINISHED = -1;
const CAP_POSITION = -2;

interface Capture {
  init: number;
  len: number;
}

export interface MatchState {
  src: string; // original source string (for substring extraction)
  s: Uint8Array; // source bytes
  slen: number;
  p: Uint8Array; // pattern bytes
  plen: number;
  level: number;
  capture: Capture[]; // pre-allocated, length `MAX_CAPTURES`
  matchdepth: number;
}

function toBytes(s: string): Uint8Array {
  const len = s.length;
  const arr = new Uint8Array(len);
  for (let i = 0; i < len; i++) {
    arr[i] = s.charCodeAt(i) & 0xFF;
  }
  return arr;
}

function matchClass(c: number, cl: number): boolean {
  const lcl = cl | 32;
  let res: boolean;
  switch (lcl) {
    case 97: // 'a'
      res = (c >= 65 && c <= 90) || (c >= 97 && c <= 122);
      break;
    case 99: // 'c'
      res = c < 32 || c === 127;
      break;
    case 100: // 'd'
      res = c >= 48 && c <= 57;
      break;
    case 103: // 'g'
      res = c > 32 && c < 127;
      break;
    case 108: // 'l'
      res = c >= 97 && c <= 122;
      break;
    case 112: // 'p'
      res = (c >= 33 && c <= 47) || (c >= 58 && c <= 64) ||
        (c >= 91 && c <= 96) || (c >= 123 && c <= 126);
      break;
    case 115: // 's'
      res = c === 32 || (c >= 9 && c <= 13);
      break;
    case 117: // 'u'
      res = c >= 65 && c <= 90;
      break;
    case 119: // 'w'
      res = (c >= 65 && c <= 90) || (c >= 97 && c <= 122) ||
        (c >= 48 && c <= 57);
      break;
    case 120: // 'x'
      res = (c >= 48 && c <= 57) || (c >= 65 && c <= 70) ||
        (c >= 97 && c <= 102);
      break;
    case 122: // 'z'
      res = c === 0;
      break;
    default:
      return cl === c;
  }
  return (cl >= 97 && cl <= 122) ? res : !res;
}

function classEnd(p: Uint8Array, plen: number, pi: number): number {
  const ch = p[pi];
  pi++;
  if (ch === CH_ESC) {
    if (pi >= plen) {
      throw new Error("malformed pattern (ends with '%')");
    }
    return pi + 1;
  }
  if (ch === CH_LBRACKET) {
    if (pi < plen && p[pi] === CH_CARET) pi++;
    do {
      if (pi >= plen) {
        throw new Error("malformed pattern (missing ']')");
      }
      if (p[pi] === CH_ESC && pi + 1 < plen) {
        pi++;
      }
      pi++;
    } while (pi < plen && p[pi] !== CH_RBRACKET);
    if (pi >= plen) {
      throw new Error("malformed pattern (missing ']')");
    }
    return pi + 1;
  }
  return pi;
}

function matchBracketClass(
  c: number,
  p: Uint8Array,
  pi: number,
  ec: number,
): boolean {
  let sig = true;
  if (p[pi + 1] === CH_CARET) {
    sig = false;
    pi++;
  }
  pi++;
  while (pi < ec) {
    const pch = p[pi];
    if (pch === CH_ESC) {
      pi++;
      if (matchClass(c, p[pi])) return sig;
    } else if (
      pi + 2 < ec && p[pi + 1] === CH_DASH
    ) {
      pi += 2;
      if (pch <= c && c <= p[pi]) return sig;
    } else if (pch === c) {
      return sig;
    }
    pi++;
  }
  return !sig;
}

function singleMatch(
  ms: MatchState,
  si: number,
  pi: number,
  ep: number,
): boolean {
  if (si >= ms.slen) return false;
  const c = ms.s[si];
  const pch = ms.p[pi];
  if (pch === CH_DOT) return true;
  if (pch === CH_ESC) {
    return matchClass(c, ms.p[pi + 1]);
  }
  if (pch === CH_LBRACKET) {
    return matchBracketClass(c, ms.p, pi, ep - 1);
  }
  return pch === c;
}

function matchBalance(
  ms: MatchState,
  si: number,
  pi: number,
): number {
  if (pi >= ms.plen - 1) {
    throw new Error("malformed pattern (missing arguments to '%b')");
  }
  if (si >= ms.slen || ms.s[si] !== ms.p[pi]) {
    return -1;
  }
  const b = ms.p[pi];
  const e = ms.p[pi + 1];
  let cont = 1;
  si++;
  while (si < ms.slen) {
    const sc = ms.s[si];
    if (sc === e) {
      if (--cont === 0) return si + 1;
    } else if (sc === b) {
      cont++;
    }
    si++;
  }
  return -1;
}

function maxExpand(
  ms: MatchState,
  si: number,
  pi: number,
  ep: number,
): number {
  let i = 0;
  while (singleMatch(ms, si + i, pi, ep)) i++;
  while (i >= 0) {
    const res = match(ms, si + i, ep + 1);
    if (res >= 0) return res;
    i--;
  }
  return -1;
}

function minExpand(
  ms: MatchState,
  si: number,
  pi: number,
  ep: number,
): number {
  for (;;) {
    const res = match(ms, si, ep + 1);
    if (res >= 0) return res;
    if (singleMatch(ms, si, pi, ep)) {
      si++;
    } else {
      return -1;
    }
  }
}

function checkCapture(ms: MatchState, l: number): number {
  l -= CH_0 + 1;
  if (l < 0 || l >= ms.level || ms.capture[l].len === CAP_UNFINISHED) {
    throw new Error(`invalid capture index %${l + 1}`);
  }
  return l;
}

function captureToClose(ms: MatchState): number {
  for (let level = ms.level - 1; level >= 0; level--) {
    if (ms.capture[level].len === CAP_UNFINISHED) return level;
  }
  throw new Error("invalid pattern capture");
}

function startCapture(
  ms: MatchState,
  si: number,
  pi: number,
  what: number,
): number {
  const level = ms.level;
  if (level >= MAX_CAPTURES) throw new Error("too many captures");
  ms.capture[level].init = si;
  ms.capture[level].len = what;
  ms.level = level + 1;
  const res = match(ms, si, pi);
  if (res < 0) ms.level--;
  return res;
}

function endCapture(ms: MatchState, si: number, pi: number): number {
  const l = captureToClose(ms);
  const savedLen = ms.capture[l].len;
  ms.capture[l].len = si - ms.capture[l].init;
  const res = match(ms, si, pi);
  if (res < 0) ms.capture[l].len = savedLen;
  return res;
}

function matchCapture(ms: MatchState, si: number, l: number): number {
  const idx = checkCapture(ms, l);
  const cap = ms.capture[idx];
  if (cap.len === CAP_POSITION) {
    throw new Error(`invalid capture index %${idx + 1}`);
  }
  const len = cap.len;
  if (ms.slen - si < len) return -1;
  for (let k = 0; k < len; k++) {
    if (ms.s[cap.init + k] !== ms.s[si + k]) return -1;
  }
  return si + len;
}

function match(ms: MatchState, si: number, pi: number): number {
  if (ms.matchdepth-- <= 0) {
    throw new Error("pattern too complex");
  }
  while (true) {
    if (pi >= ms.plen) {
      ms.matchdepth++;
      return si;
    }
    const pch = ms.p[pi];
    if (pch === CH_OPEN) {
      if (pi + 1 < ms.plen && ms.p[pi + 1] === CH_CLOSE) {
        si = startCapture(ms, si, pi + 2, CAP_POSITION);
      } else {
        si = startCapture(ms, si, pi + 1, CAP_UNFINISHED);
      }
      ms.matchdepth++;
      return si;
    }
    if (pch === CH_CLOSE) {
      si = endCapture(ms, si, pi + 1);
      ms.matchdepth++;
      return si;
    }
    if (pch === CH_DOLLAR && pi + 1 === ms.plen) {
      ms.matchdepth++;
      return si === ms.slen ? si : -1;
    }
    if (pch === CH_ESC && pi + 1 < ms.plen) {
      const next = ms.p[pi + 1];
      if (next === 98) { // 'b'
        si = matchBalance(ms, si, pi + 2);
        if (si >= 0) {
          pi += 4;
          continue;
        }
        ms.matchdepth++;
        return -1;
      }
      if (next === 102) { // 'f'
        pi += 2;
        if (pi >= ms.plen || ms.p[pi] !== CH_LBRACKET) {
          throw new Error("missing '[' after '%f' in pattern");
        }
        const ep = classEnd(ms.p, ms.plen, pi);
        const previous = si === 0 ? 0 : ms.s[si - 1];
        const current = si < ms.slen ? ms.s[si] : 0;
        if (
          !matchBracketClass(previous, ms.p, pi, ep - 1) &&
          matchBracketClass(current, ms.p, pi, ep - 1)
        ) {
          pi = ep;
          continue;
        }
        ms.matchdepth++;
        return -1;
      }
      if (next >= CH_0 && next <= CH_9) {
        si = matchCapture(ms, si, next);
        if (si >= 0) {
          pi += 2;
          continue;
        }
        ms.matchdepth++;
        return -1;
      }
    }
    // default: class[*+?-]?
    const ep = classEnd(ms.p, ms.plen, pi);
    const matched = singleMatch(ms, si, pi, ep);
    if (!matched) {
      if (ep < ms.plen) {
        const suffix = ms.p[ep];
        if (
          suffix === CH_STAR || suffix === CH_QUESTION || suffix === CH_DASH
        ) {
          pi = ep + 1;
          continue;
        }
      }
      ms.matchdepth++;
      return -1;
    }
    if (ep < ms.plen) {
      const suffix = ms.p[ep];
      if (suffix === CH_QUESTION) {
        const res = match(ms, si + 1, ep + 1);
        if (res >= 0) {
          ms.matchdepth++;
          return res;
        }
        pi = ep + 1;
        continue;
      }
      if (suffix === CH_PLUS) {
        si++;
        const res = maxExpand(ms, si, pi, ep);
        ms.matchdepth++;
        return res;
      }
      if (suffix === CH_STAR) {
        const res = maxExpand(ms, si, pi, ep);
        ms.matchdepth++;
        return res;
      }
      if (suffix === CH_DASH) {
        const res = minExpand(ms, si, pi, ep);
        ms.matchdepth++;
        return res;
      }
    }
    si++;
    pi = ep;
    continue;
  }
}

function createMatchState(s: string, p: string): MatchState {
  const sb = toBytes(s);
  const pb = toBytes(p);
  const capture: Capture[] = new Array(MAX_CAPTURES);
  for (let i = 0; i < MAX_CAPTURES; i++) {
    capture[i] = { init: 0, len: 0 };
  }
  return {
    src: s,
    s: sb,
    slen: sb.length,
    p: pb,
    plen: pb.length,
    level: 0,
    capture,
    matchdepth: MAX_MATCH_DEPTH,
  };
}

function resetMatchState(ms: MatchState): void {
  ms.level = 0;
  ms.matchdepth = MAX_MATCH_DEPTH;
}

function noSpecials(p: string): boolean {
  for (let i = 0; i < p.length; i++) {
    if (SPECIALS_SET.has(p.charCodeAt(i))) return false;
  }
  return true;
}

// Public API

export type CaptureResult = { s: string } | { position: number };

interface RawCapture {
  kind: 0 | 1; // 0 = string slice, 1 = position
  start: number;
  len: number; // kind = 0: substring length; kind = 1: 1-based position
}

function getOneRawCapture(
  ms: MatchState,
  i: number,
  matchStart: number,
  matchEnd: number,
): RawCapture {
  if (i >= ms.level) {
    if (i !== 0) {
      throw new Error(`invalid capture index %${i + 1}`);
    }
    return { kind: 0, start: matchStart, len: matchEnd - matchStart };
  }
  const cap = ms.capture[i];
  if (cap.len === CAP_UNFINISHED) {
    throw new Error("unfinished capture");
  }
  if (cap.len === CAP_POSITION) {
    return { kind: 1, start: cap.init + 1, len: 0 };
  }
  return { kind: 0, start: cap.init, len: cap.len };
}

function rawToResult(ms: MatchState, raw: RawCapture): CaptureResult {
  if (raw.kind === 1) {
    return { position: raw.start };
  }
  return { s: ms.src.substring(raw.start, raw.start + raw.len) };
}

function getCaptures(
  ms: MatchState,
  matchStart: number,
  matchEnd: number,
): CaptureResult[] {
  const nlevels = ms.level === 0 ? 1 : ms.level;
  const result: CaptureResult[] = [];
  for (let i = 0; i < nlevels; i++) {
    result.push(rawToResult(ms, getOneRawCapture(ms, i, matchStart, matchEnd)));
  }
  return result;
}

function getRawCaptureString(ms: MatchState, raw: RawCapture): string {
  if (raw.kind === 1) {
    return raw.start.toString();
  }
  return ms.src.substring(raw.start, raw.start + raw.len);
}

export function patternFind(
  s: string,
  pattern: string,
  init: number = 1,
  plain: boolean = false,
): { start: number; end: number; captures: CaptureResult[] } | null {
  if (init < 1) init = 1;
  if (init > s.length + 1) return null;
  const si0 = init - 1;
  if (plain || noSpecials(pattern)) {
    const idx = s.indexOf(pattern, si0);
    if (idx < 0) return null;
    return { start: idx + 1, end: idx + pattern.length, captures: [] };
  }
  let p = pattern;
  let anchor = false;
  if (p.length > 0 && p.charCodeAt(0) === CH_CARET) {
    anchor = true;
    p = p.substring(1);
  }
  const ms = createMatchState(s, p);
  for (let si = si0; si <= ms.slen; si++) {
    resetMatchState(ms);
    const res = match(ms, si, 0);
    if (res >= 0) {
      const caps = ms.level === 0 ? [] : getCaptures(ms, si, res);
      return { start: si + 1, end: res, captures: caps };
    }
    if (anchor) break;
  }
  return null;
}

export function patternMatch(
  s: string,
  pattern: string,
  init: number = 1,
): CaptureResult[] | null {
  if (init < 1) init = 1;
  if (init > s.length + 1) return null;
  const si0 = init - 1;
  let p = pattern;
  let anchor = false;
  if (p.length > 0 && p.charCodeAt(0) === CH_CARET) {
    anchor = true;
    p = p.substring(1);
  }
  const ms = createMatchState(s, p);
  for (let si = si0; si <= ms.slen; si++) {
    resetMatchState(ms);
    const res = match(ms, si, 0);
    if (res >= 0) {
      return getCaptures(ms, si, res);
    }
    if (anchor) break;
  }
  return null;
}

export function patternGmatch(
  s: string,
  pattern: string,
  init: number = 1,
): () => CaptureResult[] | null {
  if (init < 1) init = 1;
  let p = pattern;
  let anchor = false;
  if (p.length > 0 && p.charCodeAt(0) === CH_CARET) {
    anchor = true;
    p = p.substring(1);
  }
  const ms = createMatchState(s, p);
  let src = init - 1;
  let lastMatch: number | null = null;
  return () => {
    while (src <= ms.slen) {
      resetMatchState(ms);
      const e = match(ms, src, 0);
      if (e >= 0 && e !== lastMatch) {
        const captures = getCaptures(ms, src, e);
        src = e;
        lastMatch = e;
        return captures;
      }
      src++;
      if (anchor) break;
    }
    return null;
  };
}

function expandReplacementString(
  repl: string,
  ms: MatchState,
  matchStart: number,
  matchEnd: number,
): string {
  const parts: string[] = [];
  let i = 0;
  while (i < repl.length) {
    const ch = repl.charCodeAt(i);
    if (ch === CH_ESC) {
      i++;
      if (i >= repl.length) {
        throw new Error("invalid use of '%' in replacement string");
      }
      const rc = repl.charCodeAt(i);
      if (rc === CH_ESC) {
        parts.push("%");
      } else if (rc === CH_0) {
        parts.push(ms.src.substring(matchStart, matchEnd));
      } else if (rc >= 49 && rc <= CH_9) {
        parts.push(getRawCaptureString(
          ms,
          getOneRawCapture(ms, rc - 49, matchStart, matchEnd),
        ));
      } else {
        throw new Error("invalid use of '%' in replacement string");
      }
    } else {
      // Collect consecutive literal characters
      let j = i + 1;
      while (j < repl.length && repl.charCodeAt(j) !== CH_ESC) j++;
      parts.push(repl.substring(i, j));
      i = j;
      continue;
    }
    i++;
  }
  return parts.join("");
}

export interface GsubCallbacks {
  replString?: string;
  replFunction?: (
    ...captures: CaptureResult[]
  ) => Promise<string | null | undefined> | string | null | undefined;
  replTable?: (key: string) => string | null | undefined;
}

export async function patternGsub(
  s: string,
  pattern: string,
  callbacks: GsubCallbacks,
  maxN?: number,
): Promise<[string, number]> {
  const max_s = maxN !== undefined ? maxN : s.length + 1;
  let p = pattern;
  let anchor = false;
  if (p.length > 0 && p.charCodeAt(0) === CH_CARET) {
    anchor = true;
    p = p.substring(1);
  }
  const ms = createMatchState(s, p);
  let src = 0;
  let lastMatch: number | null = null;
  let n = 0;
  const resultParts: string[] = [];

  while (n < max_s) {
    resetMatchState(ms);
    const e = match(ms, src, 0);
    if (e >= 0 && e !== lastMatch) {
      n++;
      let replStr: string | null | undefined;
      if (callbacks.replString !== undefined) {
        replStr = expandReplacementString(callbacks.replString, ms, src, e);
      } else if (callbacks.replFunction) {
        const caps = getCaptures(ms, src, e);
        replStr = await callbacks.replFunction(...caps);
        if (replStr === null || replStr === undefined) {
          replStr = ms.src.substring(src, e);
        }
      } else if (callbacks.replTable) {
        const raw = getOneRawCapture(ms, 0, src, e);
        const key = getRawCaptureString(ms, raw);
        replStr = callbacks.replTable(key);
        if (replStr === null || replStr === undefined) {
          replStr = ms.src.substring(src, e);
        }
      }
      resultParts.push(replStr!);
      src = e;
      lastMatch = e;
    } else if (src < ms.slen) {
      resultParts.push(s[src]);
      src++;
      lastMatch = null;
    } else {
      break;
    }
    if (anchor) break;
  }
  if (src < s.length) {
    resultParts.push(s.substring(src));
  }
  return [resultParts.join(""), n];
}


================================================
FILE: client/space_lua/stdlib/pattern_test.lua
================================================
local function assertEqual(a, b)
    if a ~= b then
        error("Assertion failed: " .. tostring(a) .. " ~= " .. tostring(b))
    end
end

local function assertError(f, expectedMsg)
    local ok, err = pcall(f)
    assert(not ok, "Expected error but call succeeded")
    local msg = tostring(err)
    -- Strip stack trace prefix if present (e.g. "LuaRuntimeError: ...")
    local clean = msg:match(":%s*(.+)$") or msg
    assert(clean:find(expectedMsg, 1, true),
        "Expected error containing '" .. expectedMsg .. "' but got: " .. msg)
end

-- 1. Character classes

-- %a / %A
assertEqual(string.match("hello123", "%a+"), "hello")
assertEqual(string.match("123hello", "%A+"), "123")

-- %d / %D
assertEqual(string.match("abc42xyz", "%d+"), "42")
assertEqual(string.match("42abc", "%D+"), "abc")

-- %l / %L (lowercase)
assertEqual(string.match("Hello", "%l+"), "ello")
assertEqual(string.match("hello", "%L+"), nil)

-- %u / %U (uppercase)
assertEqual(string.match("helloWORLD", "%u+"), "WORLD")
assertEqual(string.match("HELLO", "%U+"), nil)

-- %w / %W (alphanumeric)
assertEqual(string.match("hello world", "%w+"), "hello")
assertEqual(string.match("hello world", "%W+"), " ")

-- %s / %S (whitespace)
assertEqual(string.match("hello world", "%s+"), " ")
assertEqual(string.match("  hello", "%S+"), "hello")

-- %p (punctuation)
assertEqual(string.match("hello, world!", "%p+"), ",")

-- %g (printable, non-space)
assertEqual(string.match("  abc  ", "%g+"), "abc")

-- %x (hex digits)
assertEqual(string.match("ghABCDij", "%x+"), "ABCD")

-- %. (dot matches any)
assertEqual(string.match("abc", "."), "a")
assertEqual(string.match("abc", ".."), "ab")

-- 2. Anchors

assertEqual(string.match("hello", "^hello$"), "hello")
assertEqual(string.match("hello world", "^hello$"), nil)
assertEqual(string.match("hello", "^h"), "h")
assertEqual(string.match("hello", "o$"), "o")
assertEqual(string.match("hello", "x$"), nil)

-- 3. Repetition: *, +, -, ?

-- * (greedy, 0 or more)
assertEqual(string.match("aaa", "a*"), "aaa")
assertEqual(string.match("bbb", "a*"), "")

-- + (greedy, 1 or more)
assertEqual(string.match("aaa", "a+"), "aaa")
assertEqual(string.match("bbb", "a+"), nil)

-- - (lazy, 0 or more)
assertEqual(string.match("aaa", "a-b"), nil)
assertEqual(string.match("aab", "a-b"), "aab")
-- lazy match takes shortest prefix
local cap = string.match("<tag>content</tag>", "<(.-)>")
assertEqual(cap, "tag")

-- ? (optional)
assertEqual(string.match("colour", "colou?r"), "colour")
assertEqual(string.match("color", "colou?r"), "color")

-- 4. Escaping magic characters with %

assertEqual(string.match("100%", "(%d+)%%"), "100")
assertEqual(string.match("hello.world", "(%a+)%.(%a+)"), "hello")
assertEqual(string.match("a+b", "a%+b"), "a+b")
assertEqual(string.match("a*b", "a%*b"), "a*b")
assertEqual(string.match("(x)", "%(x%)"), "(x)")

-- Non-magic chars after % are literal
assertEqual(string.match("*", "%*"), "*")
assertEqual(string.match("?", "%?"), "?")

-- 5. Character sets [...]

assertEqual(string.match("cat", "[abc]"), "c")
assertEqual(string.match("dog", "[abc]"), nil)

-- Ranges
assertEqual(string.match("m", "[a-z]"), "m")
assertEqual(string.match("M", "[a-z]"), nil)
assertEqual(string.match("5", "[0-9]"), "5")

-- Negated set
assertEqual(string.match("x", "[^abc]"), "x")
assertEqual(string.match("a", "[^abc]"), nil)

-- Classes inside sets
assertEqual(string.match("3", "[%d]"), "3")
assertEqual(string.match("a", "[%d]"), nil)

-- 6. Captures

-- Single capture
local y, m, d = string.match("2024-03-14", "(%d+)-(%d+)-(%d+)")
assertEqual(y, "2024")
assertEqual(m, "03")
assertEqual(d, "14")

-- No captures returns whole match
assertEqual(string.match("hello", "%a+"), "hello")

-- Nested captures
local outer, inner = string.match("hello world", "(h(ello))")
assertEqual(outer, "hello")
assertEqual(inner, "ello")

-- Captures with literal parens
local content = string.match("((test))", "%((%(%a+%))%)")
assertEqual(content, "(test)")

-- Multiple captures
local a, b = string.match("hello world", "(%a+) (%a+)")
assertEqual(a, "hello")
assertEqual(b, "world")

-- 7. Position captures ()

local p1, p2 = string.match("hello", "()()")
assertEqual(p1, 1)
assertEqual(p2, 1)

local pos = string.match("hello world", "()world")
assertEqual(pos, 7)

-- 8. Back-references %1-%9

-- Match repeated word
local word = string.match("hello hello", "(%a+) %1")
assertEqual(word, "hello")

-- No match if different
assertEqual(string.match("hello world", "(%a+) %1"), nil)

-- Match repeated char
local ch = string.match("aabcc", "(.)%1")
assertEqual(ch, "a")

-- 9. Balanced match %bxy

assertEqual(string.match("(hello (world))", "%b()"), "(hello (world))")
assertEqual(string.match("{a{b}c}", "%b{}"), "{a{b}c}")
assertEqual(string.match("(unbalanced", "%b()"), nil)

-- 10. Frontier pattern %f[set]

-- Transition from non-alpha to alpha
local fw = string.match("hello world", "%f[%a]%a+", 2)
assertEqual(fw, "world")

-- Transition at string start
local fs = string.match("hello", "%f[%a]%a+")
assertEqual(fs, "hello")

-- Word boundaries
local words = {}
for w in string.gmatch("one two three", "%f[%a]%a+") do
    table.insert(words, w)
end
assertEqual(#words, 3)
assertEqual(words[1], "one")
assertEqual(words[2], "two")
assertEqual(words[3], "three")

-- 11. string.find

-- Basic find
local s, e = string.find("hello world", "world")
assertEqual(s, 7)
assertEqual(e, 11)

-- Plain find
s, e = string.find("hello.world", ".", 1, true)
assertEqual(s, 6)
assertEqual(e, 6)

-- Find with captures
local s2, e2, c1 = string.find("hello world", "(%a+)")
assertEqual(s2, 1)
assertEqual(e2, 5)
assertEqual(c1, "hello")

-- Not found
assertEqual(string.find("hello", "xyz"), nil)

-- Find with init
s, e = string.find("abcabc", "abc", 2)
assertEqual(s, 4)
assertEqual(e, 6)

-- Find returns nil (not false)
assert(string.find("a", "b") == nil)
assert(not (string.find("a", "b") ~= nil))

-- Find with special chars
local bf = string.find("[", "[_%w]")
assert(bf == nil)

-- 12. string.match

-- Match from init position
assertEqual(string.match("abcdef", "%a+", 4), "def")

-- Match empty pattern
assertEqual(string.match("abc", ""), "")

-- Match returns nil (not false)
assert(string.match("a", "b") == nil)
assert(not (string.match("a", "b") ~= nil))

-- Match with init
local initMatch = string.match("hello world", "(world)", 7)
assertEqual(initMatch, "world")

-- 13. string.gmatch

-- Basic iteration
local t = {}
for w in string.gmatch("hello world lua", "%a+") do
    table.insert(t, w)
end
assertEqual(#t, 3)
assertEqual(t[1], "hello")
assertEqual(t[2], "world")
assertEqual(t[3], "lua")

-- With captures
local kv = {}
for k, v in string.gmatch("from=world, to=Lua", "(%w+)=(%w+)") do
    kv[k] = v
end
assertEqual(kv.from, "world")
assertEqual(kv.to, "Lua")

-- gmatch with empty matches (like Lua reference: ";a;" with "a*")
local r = {}
for mm in string.gmatch(";a;", "a*") do
    table.insert(r, mm)
end
assertEqual(r[1], "")
assertEqual(r[2], "a")
assertEqual(r[3], "")

-- gmatch without captures returns whole match
local t2 = {}
for mm in string.gmatch("from=world, to=Lua", "%w+=%w+") do
    table.insert(t2, mm)
end
assertEqual(t2[1], "from=world")
assertEqual(t2[2], "to=Lua")

-- 14. string.gsub

-- Simple string replacement
local res, count = string.gsub("hello world", "(%w+)", "%1-%1")
assertEqual(res, "hello-hello world-world")
assertEqual(count, 2)

-- Limited replacements
res, count = string.gsub("aaa", "a", "b", 2)
assertEqual(res, "bba")
assertEqual(count, 2)

-- Replacement with %0
res = string.gsub("hello", "%w+", "[%0]")
assertEqual(res, "[hello]")

-- Function replacement
res, count = string.gsub("hello world", "%w+", function(w)
    return w:upper()
end)
assertEqual(res, "HELLO WORLD")
assertEqual(count, 2)

-- Function returning nil keeps original
res = string.gsub("hello world", "%w+", function(w)
    if w == "hello" then return "HI" end
end)
assertEqual(res, "HI world")

-- Table replacement
local tbl = {hello = "HI", world = "EARTH"}
res = string.gsub("hello world", "(%w+)", tbl)
assertEqual(res, "HI EARTH")

-- Table with missing key keeps original
tbl = {hello = "HI"}
res = string.gsub("hello world", "(%w+)", tbl)
assertEqual(res, "HI world")

-- gsub with magic chars in pattern
res = string.gsub("hello.world", "%.", "-")
assertEqual(res, "hello-world")

-- gsub %% in replacement
res = string.gsub("hello", "hello", "100%%")
assertEqual(res, "100%")

-- Empty pattern match (inserts between every char)
res = string.gsub("abc", "", "-")
assertEqual(res, "-a-b-c-")

-- Anchored gsub
res, count = string.gsub("abc", "^a", "x")
assertEqual(res, "xbc")
assertEqual(count, 1)

-- gsub with XML pattern
local xmlpat = '<%?xml version="1.0" encoding="UTF%-8"%?>'
local xmlstr = '<?xml version="1.0" encoding="UTF-8"?><my-xml></my-xml>'
res = string.gsub(xmlstr, xmlpat, "moo")
assertEqual(res, "moo<my-xml></my-xml>")

-- gsub with %%1
res = string.gsub("Hello %1", "%%1", "world")
assertEqual(res, "Hello world")

-- gsub counting digits
res, count = string.gsub("ab5kfd8scf4lll", "%d", "")
assertEqual(res, "abkfdscflll")
assertEqual(count, 3)

-- 15. The dash (lazy) vs literal dash

assertEqual(string.match("2024-03-14", "%d+-(%d+)-%d+"), "03")
assertEqual(string.match("2024-03-14", "(%d+)-(%d+)-(%d+)"), "2024")

local y2, m2, d2 = string.match("2024-03-14", "(%d+)-(%d+)-(%d+)")
assertEqual(y2, "2024")
assertEqual(m2, "03")
assertEqual(d2, "14")

-- 16. Edge cases

-- Empty string
assertEqual(string.match("", ".*"), "")
local fs2, fe2 = string.find("", "")
assertEqual(fs2, 1)
assertEqual(fe2, 0)

-- Pattern matching entire string
assertEqual(string.match("abc", "^(.-)$"), "abc")

-- Pattern at end of string
assertEqual(string.match("test!", "!$"), "!")

-- Multiple position captures
local p3, p4 = string.match("abcd", "()ab()cd")
assertEqual(p3, 1)
assertEqual(p4, 3)

-- string.rep with separator
assertEqual(string.rep("ab", 3, ","), "ab,ab,ab")
assertEqual(string.rep("x", 1, ","), "x")
assertEqual(string.rep("x", 0), "")

-- 17. More complex patterns

-- CSV-like parsing
local fields = {}
for f in string.gmatch("one,two,,four", "([^,]*)") do
    table.insert(fields, f)
end
assertEqual(fields[1], "one")
assertEqual(fields[2], "two")
assertEqual(fields[3], "")
assertEqual(fields[4], "four")

-- Trim whitespace
local function trim(s2)
    return string.match(s2, "^%s*(.-)%s*$")
end
assertEqual(trim("  hello  "), "hello")
assertEqual(trim("hello"), "hello")
assertEqual(trim("  "), "")

-- Match identifier
assertEqual(string.match("my_var123", "^[%a_][%w_]*$"), "my_var123")
assertEqual(string.match("123bad", "^[%a_][%w_]*$"), nil)

-- Hex color
local hex = string.match("#FF00AA", "^#(%x%x)(%x%x)(%x%x)$")
assertEqual(hex, "FF")

local r2, g, b2 = string.match("#FF00AA", "^#(%x%x)(%x%x)(%x%x)$")
assertEqual(r2, "FF")
assertEqual(g, "00")
assertEqual(b2, "AA")

-- Email-like pattern
local user, domain = string.match("user@example.com", "([%w_.]+)@([%w_.]+)")
assertEqual(user, "user")
assertEqual(domain, "example.com")

-- 18. Error cases — malformed patterns

-- Pattern ending with lone %
assertError(function()
    string.find("abc", "abc%")
end, "malformed pattern (ends with '%')")

assertError(function()
    string.match("abc", "%")
end, "malformed pattern (ends with '%')")

assertError(function()
    local iter = string.gmatch("abc", "a%")
    iter()
end, "malformed pattern (ends with '%')")

-- %b with missing arguments
assertError(function()
    string.match("abc", "%b")
end, "malformed pattern (missing arguments to '%b')")

assertError(function()
    string.match("abc", "%b(")
end, "malformed pattern (missing arguments to '%b')")

-- %f not followed by [
assertError(function()
    string.match("abc", "%f")
end, "missing '[' after '%f' in pattern")

assertError(function()
    string.match("abc", "%fa")
end, "missing '[' after '%f' in pattern")

-- Lone % at end of replacement
assertError(function()
    string.gsub("abc", "a", "x%")
end, "invalid use of '%' in replacement string")

-- Invalid escape in replacement
assertError(function()
    string.gsub("abc", "a", "%z")
end, "invalid use of '%' in replacement string")

-- 19. Error cases — invalid captures

-- Unmatched close paren
assertError(function()
    string.match("abc", ")")
end, "invalid pattern capture")

-- Back-reference to non-existent capture
assertError(function()
    string.match("abc", "%1")
end, "invalid capture index")

-- Back-reference to capture not yet closed
assertError(function()
    string.match("abab", "(%a+%1)")
end, "invalid capture index")

-- Too high capture index in replacement
assertError(function()
    string.gsub("abc", "(%a+)", "%2")
end, "invalid capture index")

-- Unfinished capture used in match (tries to return it)
assertError(function()
    string.match("abc", "(abc")
end, "unfinished capture")

-- Unfinished capture used in find (tries to return it)
assertError(function()
    string.find("abc", "(abc")
end, "unfinished capture")

-- Nested unfinished capture
assertError(function()
    string.match("abc", "(ab(c)")
end, "unfinished capture")

-- 20. Error cases — %b and %f

-- %b with missing arguments
assertError(function()
    string.match("abc", "%b")
end, "malformed pattern (missing arguments to '%b')")

assertError(function()
    string.match("abc", "%b(")
end, "malformed pattern (missing arguments to '%b')")

-- %f not followed by [
assertError(function()
    string.match("abc", "%f")
end, "missing '[' after '%f' in pattern")

assertError(function()
    string.match("abc", "%fa")
end, "missing '[' after '%f' in pattern")

-- 21. Error cases — gsub replacement string

-- Lone % at end of replacement
assertError(function()
    string.gsub("abc", "a", "x%")
end, "invalid use of '%' in replacement string")

-- Invalid escape in replacement (not a digit or %)
assertError(function()
    string.gsub("abc", "a", "%z")
end, "invalid use of '%' in replacement string")

-- %2 when only one capture exists
assertError(function()
    string.gsub("abc", "(a)", "%2")
end, "invalid capture index")

-- %9 with no captures at all
assertError(function()
    string.gsub("abc", "a", "%9")
end, "invalid capture index")

-- 22. Error cases — too many captures

assertError(function()
    local pat = string.rep("(", 33) .. "a" .. string.rep(")", 33)
    string.match("a", pat)
end, "too many captures")

-- 23. Error cases — unfinished capture

-- Open paren never closed
assertError(function()
    string.find("abc", "(abc")
end, "unfinished capture")

assertError(function()
    string.match("abc", "(ab(c)")
end, "unfinished capture")


================================================
FILE: client/space_lua/stdlib/space_lua.ts
================================================
import { parseExpressionString } from "../parse.ts";
import type { LuaExpression } from "../ast.ts";
import { evalExpression } from "../eval.ts";
import {
  LuaBuiltinFunction,
  LuaEnv,
  LuaRuntimeError,
  type LuaStackFrame,
  LuaTable,
  luaToString,
  luaValueToJS,
  singleResult,
} from "../runtime.ts";

/**
 * These are Space Lua specific functions that are available to all scripts, but are not part of the standard Lua language.
 */

/**
 * Helper function to create an augmented environment
 */
function createAugmentedEnv(
  sf: LuaStackFrame,
  envAugmentation?: LuaTable,
): LuaEnv {
  const globalEnv = sf.threadLocal.get("_GLOBAL");
  if (!globalEnv) {
    throw new Error("_GLOBAL not defined");
  }
  const env = new LuaEnv(globalEnv);
  if (envAugmentation) {
    env.setLocal("_", envAugmentation);
    for (const key of envAugmentation.keys()) {
      env.setLocal(key, envAugmentation.rawGet(key));
    }
  }
  return env;
}

/**
 * Interpolates a string with lua expressions and returns the result.
 *
 * @param sf - The current space_lua state.
 * @param template - The template string to interpolate.
 * @param envAugmentation - An optional environment to augment the global environment with.
 * @returns The interpolated string.
 */
export async function interpolateLuaString(
  sf: LuaStackFrame,
  template: string,
  envAugmentation?: LuaTable,
): Promise<string> {
  let result = "";
  let currentIndex = 0;

  while (true) {
    const startIndex = template.indexOf("${", currentIndex);
    if (startIndex === -1) {
      result += template.slice(currentIndex);
      break;
    }

    result += template.slice(currentIndex, startIndex);

    // Find matching closing brace by counting nesting
    let nestLevel = 1;
    let endIndex = startIndex + 2;
    while (nestLevel > 0 && endIndex < template.length) {
      if (template[endIndex] === "{") {
        nestLevel++;
      } else if (template[endIndex] === "}") {
        nestLevel--;
      }
      if (nestLevel > 0) {
        endIndex++;
      }
    }

    if (nestLevel > 0) {
      throw new LuaRuntimeError("Unclosed interpolation expression", sf);
    }

    const expr = template.slice(startIndex + 2, endIndex);
    try {
      const parsedExpr = parseExpressionString(expr);
      const env = createAugmentedEnv(sf, envAugmentation);
      // Do `luaToString` before `luaValueToJS` to preserve tagged float
      // formatting.
      const luaResult = singleResult(await evalExpression(parsedExpr, env, sf));
      result += await luaToString(luaResult);
    } catch (e: any) {
      throw new LuaRuntimeError(
        `Error evaluating "${expr}": ${e.message}`,
        sf,
      );
    }

    currentIndex = endIndex + 1;
  }

  return result;
}

export const spaceluaApi = new LuaTable({
  /**
   * Parses a lua expression and returns the parsed expression.
   *
   * @param sf - The current space_lua state.
   * @param luaExpression - The lua expression to parse.
   * @returns The parsed expression.
   */
  parseExpression: new LuaBuiltinFunction(
    (_sf, luaExpression: string) => {
      return parseExpressionString(luaExpression);
    },
  ),
  /**
   * Evaluates a parsed lua expression and returns the result.
   *
   * @param sf - The current space_lua state.
   * @param parsedExpr - The parsed lua expression to evaluate.
   * @param envAugmentation - An optional environment to augment the global environment with.
   * @returns The result of the evaluated expression.
   */
  evalExpression: new LuaBuiltinFunction(
    async (sf, parsedExpr: LuaExpression, envAugmentation?: LuaTable) => {
      const env = createAugmentedEnv(sf, envAugmentation);
      return luaValueToJS(await evalExpression(parsedExpr, env, sf), sf);
    },
  ),
  /**
   * Interpolates a string with lua expressions and returns the result.
   */
  interpolate: new LuaBuiltinFunction(
    (sf, template: string, envAugmentation?: LuaTable) => {
      return interpolateLuaString(sf, template, envAugmentation);
    },
  ),
  /**
   * Returns your SilverBullet instance's base URL, or `undefined` when run on the server
   */
  baseUrl: new LuaBuiltinFunction(
    () => {
      // Deal with Deno
      if (typeof location === "undefined") {
        return null;
      } else {
        //NOTE: Removing trailing slash to stay compatible with original code: `location.protocol + "//" + location.host;`
        return document.baseURI.replace(/\/*$/, "");
      }
    },
  ),
});


================================================
FILE: client/space_lua/stdlib/space_lua_test.lua
================================================
local parsedExpr = spacelua.parseExpression("1 + 1")
local evalResult = spacelua.evalExpression(parsedExpr)
assert(evalResult == 2, "Eval should return 2")

-- Slightly more advanced example with augmented environment
local parsedExpr = spacelua.parseExpression("tostring(a + 1)")
local evalResult = spacelua.evalExpression(parsedExpr, { a = 1 })
assert(evalResult == "2", "Eval should return 2 as a string")


================================================
FILE: client/space_lua/stdlib/string.ts
================================================
import {
  jsToLuaValue,
  LuaBuiltinFunction,
  LuaMultiRes,
  LuaRuntimeError,
  LuaTable,
  luaToString,
} from "../runtime.ts";
import { untagNumber } from "../numeric.ts";
import { luaFormat } from "./format.ts";
import {
  type CaptureResult,
  type GsubCallbacks,
  patternFind,
  patternGmatch,
  patternGsub,
  patternMatch,
} from "./pattern.ts";

function capturesToLua(caps: CaptureResult[]): any {
  if (caps.length === 0) {return null;}
  if (caps.length === 1) {
    const c = caps[0];
    return "s" in c ? c.s : c.position;
  }
  return new LuaMultiRes(
    caps.map((c) => ("s" in c ? c.s : c.position)),
  );
}

export const stringApi = new LuaTable({
  byte: new LuaBuiltinFunction((_sf, s: string, i?: number, j?: number) => {
    i = i ?? 1;
    j = j ?? i;
    if (j > s.length) {j = s.length;}
    if (i < 1) {i = 1;}
    const result = [];
    for (let k = i; k <= j; k++) {
      result.push(s.charCodeAt(k - 1));
    }
    return new LuaMultiRes(result);
  }),
  char: new LuaBuiltinFunction((_sf, ...args: number[]) => {
    return String.fromCharCode(...args);
  }),
  find: new LuaBuiltinFunction(
    (_sf, s: string, pattern: string, init = 1, plain = false) => {
      const r = patternFind(s, pattern, init, plain);
      if (!r) {return null;}
      const result: any[] = [r.start, r.end];
      for (const c of r.captures) {
        result.push("s" in c ? c.s : c.position);
      }
      return new LuaMultiRes(result);
    },
  ),
  format: new LuaBuiltinFunction((_sf, format: string, ...args: any[]) => {
    for (let i = 0; i < args.length; i++) {
      args[i] = untagNumber(args[i]);
    }
    return luaFormat(format, ...args);
  }),
  gmatch: new LuaBuiltinFunction(
    (_sf, s: string, pattern: string, init = 1) => {
      const iter = patternGmatch(s, pattern, init);
      return () => {
        const caps = iter();
        if (!caps) {return;}
        return capturesToLua(caps);
      };
    },
  ),
  gsub: new LuaBuiltinFunction(
    async (
      sf,
      s: string,
      pattern: string,
      repl: any,
      n?: number,
    ) => {
      const callbacks: GsubCallbacks = {};
      if (typeof repl === "string") {
        callbacks.replString = repl;
      } else if (repl instanceof LuaTable) {
        callbacks.replTable = (key: string) => {
          const v = repl.get(key);
          if (v === null || v === undefined || v === false) {return null;}
          return String(v);
        };
      } else if (repl.call) {
        callbacks.replFunction = async (...caps: CaptureResult[]) => {
          const args = caps.map((c) => ("s" in c ? c.s : c.position));
          let result = await repl.call(sf, ...args);
          if (result instanceof LuaMultiRes) {
            result = result.values[0];
          }
          if (result === null || result === undefined || result === false) {
            return null;
          }
          return luaToString(result);
        };
      } else {
        throw new LuaRuntimeError(
          "string.gsub replacement argument should be a function, table or string",
          sf,
        );
      }
      const [result, count] = await patternGsub(s, pattern, callbacks, n);
      return new LuaMultiRes([result, count]);
    },
  ),
  len: new LuaBuiltinFunction((_sf, s: string) => {
    return s.length;
  }),
  lower: new LuaBuiltinFunction((_sf, s: string) => {
    return luaToString(s.toLowerCase());
  }),
  upper: new LuaBuiltinFunction((_sf, s: string) => {
    return luaToString(s.toUpperCase());
  }),
  match: new LuaBuiltinFunction(
    (_sf, s: string, pattern: string, init = 1) => {
      const caps = patternMatch(s, pattern, init);
      if (!caps) {return null;}
      return capturesToLua(caps);
    },
  ),
  rep: new LuaBuiltinFunction((_sf, s: string, n: number, sep?: string) => {
    if (n <= 0) {return "";}
    sep = sep ?? "";
    const parts: string[] = [];
    for (let i = 0; i < n; i++) {
      parts.push(s);
    }
    return parts.join(sep);
  }),
  reverse: new LuaBuiltinFunction((_sf, s: string) => {
    return s.split("").reverse().join("");
  }),
  sub: new LuaBuiltinFunction((_sf, s: string, i: number, j?: number) => {
    const len = s.length;
    let start: number;
    if (i > 0) {
      start = i;
    } else if (i < -len) {
      start = 1;
    } else {
      start = i === 0 ? 1 : len + i + 1;
    }
    let end: number;
    if (j === undefined || j === null || j > len) {
      end = len;
    } else if (j >= 0) {
      end = j;
    } else if (j < -len) {
      end = 0;
    } else {
      end = len + j + 1;
    }
    if (start <= end) {
      return s.substring(start - 1, end);
    }
    return "";
  }),
  split: new LuaBuiltinFunction((_sf, s: string, sep: string) => {
    return s.split(sep);
  }),

  // Non-standard extensions
  startsWith: new LuaBuiltinFunction((_sf, s: string, prefix: string) => {
    return s.startsWith(prefix);
  }),
  endsWith: new LuaBuiltinFunction((_sf, s: string, suffix: string) => {
    return s.endsWith(suffix);
  }),
  trim: new LuaBuiltinFunction((_sf, s: string) => {
    return s.trim();
  }),
  trimStart: new LuaBuiltinFunction((_sf, s: string) => {
    return s.trimStart();
  }),
  trimEnd: new LuaBuiltinFunction((_sf, s: string) => {
    return s.trimEnd();
  }),
  matchRegex: new LuaBuiltinFunction((_sf, s: string, pattern: string) => {
    const regex = new RegExp(pattern);
    const result = s.match(regex);
    return jsToLuaValue(result);
  }),
  matchRegexAll: new LuaBuiltinFunction((_sf, s: string, pattern: string) => {
    const regex = new RegExp(pattern, "g");
    return () => {
      const match = regex.exec(s);
      if (!match) {
        return;
      }
      return jsToLuaValue(match);
    };
  }),
});


================================================
FILE: client/space_lua/stdlib/string_test.lua
================================================
local function assertEqual(a, b)
    if a ~= b then
        error("Assertion failed: " .. tostring(a) .. " is not equal to " .. tostring(b))
    end
end

-- Basic string functions
assert(string.len("Hello") == 5)
assert(string.byte("Hello", 1) == 72)
assert(string.char(72) == "H")
assert(string.rep("Hello", 3) == "HelloHelloHello")
assert(string.sub("Hello", 2, 4) == "ell")
assert(string.upper("Hello") == "HELLO")
assert(string.lower("Hello") == "hello")

-- string.byte tests
local a, b = string.byte('Mo0')
assert(a == 77, 'string.byte() should return the code for the first char')
assert(b == nil, 'string.byte() should return only one item when no length is given')

a, b = string.byte('Mo0', 2)
assert(a == 111, 'string.byte() should return the code for the nth character')
assert(b == nil, 'string.byte() should return only one item when no length is given')

local a2, b2, c2 = string.byte('Mo0', 2, 3)
assert(a2 == 111, 'string.byte() multi-return [1]')
assert(b2 == 48, 'string.byte() multi-return [2]')
assert(c2 == nil, 'string.byte() should stop at end of string')

a2, b2 = string.byte('Mo0', 3, 20)
assert(a2 == 48, 'string.byte() should clamp to string length')
assert(b2 == nil, 'string.byte() should not return past end of string')

-- string.char tests
assertEqual(string.char(), '')
assertEqual(string.char(116, 101, 115, 116), 'test')

-- string.len tests
assertEqual(string.len(''), 0)
assertEqual(string.len('McLaren Mercedes'), 16)

-- string.lower tests
assertEqual(string.lower(''), '')
assertEqual(string.lower('McLaren Mercedes'), 'mclaren mercedes')

-- string.upper tests
assertEqual(string.upper(''), '')
assertEqual(string.upper('JavaScript'), 'JAVASCRIPT')

-- string.rep tests
assertEqual(string.rep('Ho', 0), '')
assertEqual(string.rep('Ho', 1), 'Ho')
assertEqual(string.rep('Ho', 3), 'HoHoHo')
assertEqual(string.rep("ab", 3, ","), "ab,ab,ab")
assertEqual(string.rep("x", 1, ","), "x")

-- string.reverse tests
assertEqual(string.reverse(''), '')
assertEqual(string.reverse('x'), 'x')
assertEqual(string.reverse('tpircSavaJ'), 'JavaScript')

-- string.sub tests
assertEqual(string.sub("hello", 1), "hello")
assertEqual(string.sub("hello", 1, 1), "h")
-- with negative indices
assertEqual(string.sub("hello", -1), "o")
assertEqual(string.sub("hello", -2), "lo")
assertEqual(string.sub("hello", -2, -1), "lo")

local s = 'Pub Standards'
assertEqual(string.sub(s, 1), 'Pub Standards')
assertEqual(string.sub(s, 5), 'Standards')
assertEqual(string.sub(s, -4), 'ards')
assertEqual(string.sub(s, 1, 3), 'Pub')
assertEqual(string.sub(s, 7, 9), 'and')
assertEqual(string.sub(s, 5, -2), 'Standard')
assertEqual(string.sub(s, 0), 'Pub Standards')

-- Invoke string metatable methods
assertEqual(("hello"):len(), 5)
assertEqual(("hello"):upper(), "HELLO")
assertEqual(('Hey'):lower(), 'hey')

-- Test string.split (non-standard)
local parts = string.split("a,b,c", ",")
assertEqual(parts[1], "a")
assertEqual(parts[2], "b")
assertEqual(parts[3], "c")

-- Test non-standard string extensions
assertEqual(string.startsWith("hello world", "hello"), true)
assertEqual(string.startsWith("hello world", "world"), false)
assertEqual(string.endsWith("hello world", "world"), true)
assertEqual(string.endsWith("hello world", "hello"), false)

-- Test matchRegexAll (non-standard, regex-based)
local matches = {}
for match in string.matchRegexAll("hellolllbl", "(l+)") do
    table.insert(matches, match)
end
assertEqual(#matches, 3)
assertEqual(matches[1][1], "ll")
assertEqual(matches[2][1], "lll")
assertEqual(matches[3][1], "l")


================================================
FILE: client/space_lua/stdlib/table.ts
================================================
import {
  getMetatable,
  type ILuaFunction,
  LuaBuiltinFunction,
  luaCall,
  type LuaEnv,
  luaEquals,
  luaGet,
  LuaMultiRes,
  LuaRuntimeError,
  luaSet,
  LuaTable,
  type LuaValue,
  luaValueToJS,
  singleResult,
} from "../runtime.ts";
import { asyncQuickSort, evalPromiseValues } from "../util.ts";
import { isTaggedFloat } from "../numeric.ts";

// For `LuaTable` honor `__len` when present; otherwise use raw array
// length.  For JS arrays use `.length`.
function luaLenForTableLib(
  sf: any,
  tbl: LuaTable | any[],
): number | Promise<number> {
  if (Array.isArray(tbl)) {
    return tbl.length;
  }
  if (!(tbl instanceof LuaTable)) {
    return 0;
  }

  const mt = getMetatable(tbl, sf);
  const mm = mt ? mt.rawGet("__len") : null;
  if (!(mm === undefined || mm === null)) {
    const r = luaCall(mm, [tbl], sf.astCtx ?? {}, sf);
    if (r instanceof Promise) {
      return r.then((v: any) => Number(singleResult(v)));
    }
    return Number(singleResult(r));
  }

  return tbl.length;
}

async function luaLenForTableLibAsync(sf: any, tbl: LuaTable | any[]) {
  const r = luaLenForTableLib(sf, tbl);
  return r instanceof Promise ? await r : r;
}

export const tableApi = new LuaTable({
  /**
   * Concatenates the elements of a table into a string, using a separator.
   * @param tbl - The table to concatenate.
   * @param sep - The separator to use between elements.
   * @param i - The start index.
   * @param j - The end index.
   * @returns The concatenated string.
   */
  concat: new LuaBuiltinFunction(
    async (sf, tbl: LuaTable | any[], sep?: string, i?: number, j?: number) => {
      sep = sep ?? "";
      i = i ?? 1;
      if (j === undefined || j === null) {
        j = await luaLenForTableLibAsync(sf, tbl);
      }

      const luaConcatElemToString = (v: any, idx: number): string => {
        // Concat errors on nil and non-string or non-number values.
        if (v === null || v === undefined) {
          throw new LuaRuntimeError(
            `invalid value (nil) at index ${idx} in table for 'concat'`,
            sf,
          );
        }
        if (typeof v === "string") {
          return v;
        }
        if (typeof v === "number") {
          return String(v);
        }
        if (isTaggedFloat(v)) {
          return String(v.value);
        }

        const ty = typeof v === "object" && v instanceof LuaTable
          ? "table"
          : typeof v;
        throw new LuaRuntimeError(
          `invalid value (${ty}) at index ${idx} in table for 'concat'`,
          sf,
        );
      };

      if (Array.isArray(tbl)) {
        const out: string[] = [];
        for (let k = i; k <= j; k++) {
          const v = tbl[k - 1];
          out.push(luaConcatElemToString(v, k));
        }
        return out.join(sep);
      }

      const out: string[] = [];
      for (let k = i; k <= j; k++) {
        const v = await luaGet(tbl, k, sf.astCtx ?? null, sf);
        out.push(luaConcatElemToString(v, k));
      }
      return out.join(sep);
    },
  ),

  /**
   * Inserts an element into a table at a specified position.
   * @param tbl - The table to insert the element into.
   * @param posOrValue - The position or value to insert.
   * @param value - The value to insert.
   */
  insert: new LuaBuiltinFunction(
    async (
      sf,
      tbl: LuaTable | any[],
      posOrValue: number | any,
      value?: any,
    ) => {
      if (Array.isArray(tbl)) {
        // Since we're inserting/appending to a native JS array, we'll also convert the value to a JS value on the fly
        // this seems like a reasonable heuristic
        if (value === undefined) {
          tbl.push(luaValueToJS(posOrValue, sf));
        } else {
          tbl.splice(posOrValue - 1, 0, luaValueToJS(value, sf));
        }
        return;
      }

      if (!(tbl instanceof LuaTable)) {
        return;
      }

      let pos: number;
      let v: any;

      if (value === undefined) {
        v = posOrValue;
        pos = (await luaLenForTableLibAsync(sf, tbl)) + 1;
      } else {
        pos = posOrValue;
        v = value;
      }

      const n = await luaLenForTableLibAsync(sf, tbl);

      // Shift up: for k = n, pos, -1 do t[k+1] = t[k] end
      for (let k = n; k >= pos; k--) {
        const cur = await luaGet(tbl, k, sf.astCtx ?? null, sf);
        await luaSet(tbl, k + 1, cur, sf);
      }

      await luaSet(tbl, pos, v, sf);
    },
  ),

  /**
   * Removes an element from a table at a specified position.
   * @param tbl - The table to remove the element from.
   * @param pos - The position of the element to remove.
   */
  remove: new LuaBuiltinFunction(
    async (sf, tbl: LuaTable | any[], pos?: number) => {
      if (Array.isArray(tbl)) {
        const n = tbl.length;
        const p = pos ?? n;
        if (p < 1 || p > n) {
          throw new LuaRuntimeError("position out of bounds", sf);
        }
        const idx = p - 1;
        const v = tbl[idx];
        tbl.splice(idx, 1);
        return v;
      }

      if (!(tbl instanceof LuaTable)) {
        return null;
      }

      const n = await luaLenForTableLibAsync(sf, tbl);
      const p = pos ?? n;

      if (p < 1 || p > n) {
        throw new LuaRuntimeError("position out of bounds", sf);
      }

      const v = await luaGet(tbl, p, sf.astCtx ?? null, sf);

      // Shift down: for k = p, n-1 do t[k] = t[k+1] end; t[n] = nil
      for (let k = p; k < n; k++) {
        const next = await luaGet(tbl, k + 1, sf.astCtx ?? null, sf);
        await luaSet(tbl, k, next, sf);
      }
      await luaSet(tbl, n, null, sf);

      return v;
    },
  ),

  /**
   * Sorts a table.
   * @param tbl - The table to sort.
   * @param comp - The comparison function.
   * @returns The sorted table.
   */
  sort: new LuaBuiltinFunction(
    async (sf, tbl: LuaTable | any[], comp?: ILuaFunction) => {
      if (Array.isArray(tbl)) {
        return await asyncQuickSort(tbl, async (a, b) => {
          if (comp) {
            return (await comp.call(sf, a, b)) ? -1 : 1;
          }
          return (a as any) < (b as any) ? -1 : 1;
        });
      }

      if (!(tbl instanceof LuaTable)) {
        return tbl;
      }

      const n = await luaLenForTableLibAsync(sf, tbl);

      const values: any[] = [];
      for (let i = 1; i <= n; i++) {
        values.push(await luaGet(tbl, i, sf.astCtx ?? null, sf));
      }

      const cmp = async (a: any, b: any): Promise<number> => {
        if (comp) {
          const r = await luaCall(comp, [a, b], sf.astCtx ?? {}, sf);
          return r ? -1 : 1;
        }

        const av = isTaggedFloat(a) ? a.value : a;
        const bv = isTaggedFloat(b) ? b.value : b;

        if (typeof av === "number" && typeof bv === "number") {
          return av < bv ? -1 : 1;
        }
        if (typeof av === "string" && typeof bv === "string") {
          return av < bv ? -1 : 1;
        }

        const ta = typeof av;
        const tb = typeof bv;
        throw new LuaRuntimeError(
          `attempt to compare ${ta} with ${tb}`,
          sf,
        );
      };

      const sorted = await asyncQuickSort(values, cmp);

      for (let i = 1; i <= n; i++) {
        await luaSet(tbl, i, sorted[i - 1], sf);
      }

      return tbl;
    },
  ),

  /**
   * Returns the keys of a table.
   * Note: Space Lua specific
   * @param tbl - The table to get the keys from.
   * @returns The keys of the table.
   */
  keys: new LuaBuiltinFunction((_sf, tbl: LuaTable | LuaEnv | any) => {
    if (tbl.keys) {
      return tbl.keys();
    }
    return Object.keys(tbl);
  }),

  /**
   * Checks if a table (used as an array) contains a value.
   * Note: Space Lua specific
   * @param tbl - The table to check.
   * @param value - The value to check for.
   * @returns True if the value is in the table, false otherwise.
   */
  includes: new LuaBuiltinFunction(
    (sf, tbl: LuaTable | any[], value: LuaValue) => {
      if (!tbl) {
        return false;
      }
      if (tbl instanceof LuaTable) {
        // Iterate over the table
        for (const key of tbl.keys()) {
          if (luaEquals(tbl.get(key), value)) {
            return true;
          }
        }
        return false;
      }
      if (Array.isArray(tbl)) {
        return !!tbl.find((item) => luaEquals(item, value));
      }
      throw new LuaRuntimeError(
        `Cannot use includes on a non-table or non-array value`,
        sf,
      );
    },
  ),

  /**
   * Returns a new table from an old one, only with selected keys
   * @param tbl a Lua table or JS object
   * @param keys a list of keys to select from the table, if keys[0] is a table or array, assumed to contain the keys to select
   * @returns a new table with only the selected keys
   */
  select: new LuaBuiltinFunction(
    (sf, tbl: LuaTable | Record<string, any>, ...keys: LuaValue[]) => {
      // Normalize arguments
      if (Array.isArray(keys[0])) {
        // First argument is key array, let's unpack
        keys = keys[0];
      } else if (keys[0] instanceof LuaTable) {
        keys = keys[0].toJSArray();
      }
      const resultTable = new LuaTable();
      const setPromises: (void | Promise<void>)[] = [];
      for (const key of keys) {
        setPromises.push(resultTable.set(key, luaGet(tbl, key, null, sf)));
      }
      const promised = evalPromiseValues(setPromises);
      if (promised instanceof Promise) {
        return promised.then(() => resultTable);
      }
      return resultTable;
    },
  ),

  pack: new LuaBuiltinFunction((_sf, ...args: any[]) => {
    const tbl = new LuaTable();
    for (let i = 0; i < args.length; i++) {
      tbl.set(i + 1, args[i]);
    }
    tbl.set("n", args.length);
    return tbl;
  }),

  unpack: new LuaBuiltinFunction(
    async (sf, tbl: LuaTable | any[], i?: number, j?: number) => {
      i = i ?? 1;
      if (j === undefined || j === null) {
        j = Array.isArray(tbl)
          ? tbl.length
          : await luaLenForTableLibAsync(sf, tbl);
      }

      const result: LuaValue[] = [];
      for (let k = i; k <= j; k++) {
        const v = Array.isArray(tbl)
          ? tbl[k - 1]
          : await luaGet(tbl, k, sf.astCtx ?? null, sf);
        result.push(v);
      }
      return new LuaMultiRes(result);
    },
  ),

  // Non-standard Lua functions
  /**
   * Finds an element in a table that matches a criteria function. Returns the first matching element.
   * @param tbl - The table to search.
   * @param criteriaFn - The criteria function.
   * @param fromIndex - The index to start searching from.
   * @returns Lua multi value of index, value, or nil if no element is found.
   */
  find: new LuaBuiltinFunction(
    async (
      sf,
      tbl: LuaTable | any[],
      criteriaFn: ILuaFunction,
      fromIndex = 1,
    ) => {
      if (!tbl) {
        return null;
      }
      const startIndex = fromIndex < 1 ? 1 : fromIndex;
      const n = Array.isArray(tbl)
        ? tbl.length
        : await luaLenForTableLibAsync(sf, tbl);
      for (let i = startIndex; i <= n; i++) {
        const val = await luaGet(tbl, i, sf.astCtx ?? null, sf);
        if (await luaCall(criteriaFn, [val], sf.astCtx!, sf)) {
          return new LuaMultiRes([i, val]);
        }
      }
      return null;
    },
  ),
});


================================================
FILE: client/space_lua/stdlib/table_test.lua
================================================
local function deepCompare(t1, t2, seen)
    if t1 == t2 then return true end
    if type(t1) ~= "table" or type(t2) ~= "table" then return false end

    seen = seen or {}
    if seen[t1] and seen[t1] == t2 then return true end
    seen[t1] = t2

    local n1, n2 = 0, 0
    for _ in pairs(t1) do n1 = n1 + 1 end
    for _ in pairs(t2) do n2 = n2 + 1 end
    if n1 ~= n2 then return false end

    for k, v in pairs(t1) do
        if not deepCompare(v, t2[k], seen) then
            return false
        end
    end
    return true
end

local function assertEqual(a, b)
    if not deepCompare(a, b) then
        error("Assertion failed: " .. tostring(a) .. " is not equal to " .. tostring(b))
    end
end

local function assertTrue(v, msg)
    if not v then error(msg or "Assertion failed") end
end

local function assertFalse(v, msg)
    if v then error(msg or "Assertion failed (expected false)") end
end

local function assertError(fn, msg)
    local ok, err = pcall(fn)
    if ok then
        error(msg or "Assertion failed (expected error)")
    end
    return err
end

-- Basic table operations (insert/remove)
do
    local t = { 1, 2, 3 }
    table.insert(t, 4)
    assertEqual(t[4], 4)

    table.insert(t, 1, 99)
    assertEqual(t[1], 99)
    assertEqual(#t, 5)

    local x = table.remove(t, 1)
    assertEqual(x, 99)

    table.remove(t) -- remove last
    assertEqual(#t, 3)
    assertEqual(t, { 1, 2, 3 })

    -- remove out of range should error
    assertError(function() table.remove(t, 0) end)
    assertError(function() table.remove(t, #t + 2) end)
end

-- Reference semantics / aliasing (tables are mutable references)
do
    local a = { x = 1 }
    local b = a
    b.x = 2
    assertEqual(a.x, 2)

    local nested = { inner = { 1, 2 } }
    local alias = nested.inner
    alias[1] = 10
    assertEqual(nested.inner[1], 10)
end

-- rawget/rawset/rawequal ignore metamethods
do
    local backing = {}
    local t = setmetatable({}, {
        __index = function(_, k)
            return backing[k]
        end,
        __newindex = function(_, k, v)
            backing[k] = v
        end,
    })

    t.a = 1
    assertEqual(backing.a, 1)
    assertEqual(t.a, 1)

    rawset(t, "a", 2)
    assertEqual(rawget(t, "a"), 2)
    assertEqual(backing.a, 1)

    assertEqual(rawget(t, "missing"), nil)

    local t2 = t
    local t3 = {}
    assertTrue(rawequal(t2, t))
    assertFalse(rawequal(t3, t))
end

-- Length operator semantics
do
    local seq = { 1, 2, 3 }
    assertEqual(#seq, 3)

    local holes = { 1, 2, 3, nil, nil, nil, 4 }
    assertEqual(rawlen(holes), 7)

    local t = { 1, 2, 3 }
    t[5] = 5
    local collected = {}
    for i, v in ipairs(t) do
        collected[i] = v
    end
    assertEqual(collected, { 1, 2, 3 })
end

-- __len metamethod can override #t
do
    local t = setmetatable({ 1, 2, 3 }, {
        __len = function(_) return 123 end
    })
    assertEqual(#t, 123)
end

-- pairs / __pairs
do
    local t = setmetatable({ a = 1, b = 2 }, {
        __pairs = function(_)
            local yielded = false
            return function()
                if yielded then return nil end
                yielded = true
                return "only", 42
            end
        end
    })

    local out = {}
    for k, v in pairs(t) do
        out[k] = v
    end
    assertEqual(out, { only = 42 })
end

-- next
do
    local t = { a = 1, b = 2, c = 3 }
    local k, v = next(t, nil)
    assertTrue(k ~= nil)
    assertTrue(v ~= nil)

    local seen = {}
    while k do
        seen[k] = v
        k, v = next(t, k)
    end
    assertEqual(seen.a, 1)
    assertEqual(seen.b, 2)
    assertEqual(seen.c, 3)
end

-- table.concat
do
    assertEqual(table.concat({ "Hello", "world" }, " "), "Hello world")
    assertEqual(table.concat({ "Hello", "world", "three" }, " ", 2, 3), "world three")
    assertEqual(table.concat({ "a", "b", "c" }), "abc")
    assertEqual(table.concat({ "a", "b", "c" }, "", 2, 1), "")

    assertError(function() table.concat({ "a", {} }, "") end)
end

-- table.sort
do
    local t = { 3, 1, 2 }
    table.sort(t)
    assertEqual(t, { 1, 2, 3 })

    table.sort(t, function(a, b) return a > b end)
    assertEqual(t, { 3, 2, 1 })

    local data = { { name = "John", age = 30 }, { name = "Jane", age = 25 } }
    table.sort(data, function(a, b) return a.age < b.age end)
    assertEqual(data[1].name, "Jane")
    assertEqual(data[2].name, "John")

    assertError(function() table.sort({ 1, "x" }) end)

    -- Portable error test: comparator itself throws -> sort must raise
    assertError(function()
        table.sort({ 2, 1 }, function()
            error("boom")
        end)
    end)

    -- NOTE: Do NOT require an error for comparator returning nil; Lua may treat it as false.
end

-- table.pack / table.unpack
do
    local t = { 1, 2, 3 }
    local packed = table.pack(table.unpack(t))
    assertEqual(packed[1], 1)
    assertEqual(packed[2], 2)
    assertEqual(packed[3], 3)
    assertEqual(packed.n, 3)

    local p = table.pack(1, nil, 3, nil)
    assertEqual(p.n, 4)
    assertEqual(p[1], 1)
    assertEqual(p[2], nil)
    assertEqual(p[3], 3)
    assertEqual(p[4], nil)

    local a, b, c, d = table.unpack(p, 1, p.n)
    assertEqual(a, 1)
    assertEqual(b, nil)
    assertEqual(c, 3)
    assertEqual(d, nil)

    local seq = { "x", "y" }
    local x, y = table.unpack(seq)
    assertEqual(x, "x")
    assertEqual(y, "y")
end

-- __index / __newindex semantics
do
    local backing = { a = 10 }
    local t = setmetatable({}, {
        __index = backing,
        __newindex = function(_, k, v)
            backing[k] = v * 2
        end,
    })

    assertEqual(t.a, 10)

    t.b = 5
    assertEqual(backing.b, 10)
    assertEqual(rawget(t, "b"), nil)
end

-- Equality semantics: tables compare by reference
do
    local a = { 1 }
    local b = { 1 }
    assertFalse(a == b)
    local c = a
    assertTrue(a == c)
end


================================================
FILE: client/space_lua/tonumber.ts
================================================
/**
 * Space Lua `tonumber` implementation
 *
 * Matches Lua 5.4 semantics:
 * - parses decimal or hex numerals
 * - if base is given parses signed integers in the given base (2..36)
 * - leading and trailing Lua spaces are trimmed (`\t\r\n\h\v`)
 * - returns null on failure
 *
 * Notes:
 * - hex without exponent is an integer
 * - hex float requires `p` or `P` exponent
 * - decimal float allows '.' and `e` or `E` exponent
 */
import type { NumericType } from "./ast.ts";

function skipSpace(s: string, i: number): number {
  const n = s.length;
  while (i < n) {
    const c = s.charCodeAt(i);
    if (c === 32 || (c >= 9 && c <= 13)) { // SP, HT, LF, VT, FF, CR
      i++;
    } else {
      break;
    }
  }
  return i;
}

function charToDigitBase(c: number, base: number): number {
  if (c >= 48 && c <= 57) { // '0'..'9'
    const v = c - 48;
    if (v < base) {
      return v;
    }
    return -1;
  }
  if (c >= 65 && c <= 90) { // 'A'..'Z'
    const v = 10 + (c - 65);
    if (v < base) {
      return v;
    }
    return -1;
  }
  if (c >= 97 && c <= 122) { // 'a'..'z'
    const v = 10 + (c - 97);
    if (v < base) {
      return v;
    }
    return -1;
  }
  return -1;
}

function parseIntWithBase(
  s: string,
  base: number,
): { ok: boolean; end: number; value: number } {
  const n = s.length;
  let i = 0;
  i = skipSpace(s, i);
  if (i >= n) {
    return { ok: false, end: i, value: 0 };
  }

  let sign = 1;
  if (s.charCodeAt(i) === 45) { // '-'
    sign = -1;
    i++;
  } else if (s.charCodeAt(i) === 43) { // '+'
    i++;
  }

  if (i >= n) {
    return { ok: false, end: i, value: 0 };
  }

  let acc = 0;
  let any = false;

  while (i < n) {
    const c = s.charCodeAt(i);
    const isAlnum = (c >= 48 && c <= 57) || // '0'..'9'
      (c >= 65 && c <= 90) || // 'A'..'Z'
      (c >= 97 && c <= 122); // 'a'..'z'

    if (!isAlnum) {
      break;
    }

    const d = charToDigitBase(c, base);
    if (d < 0) {
      return { ok: false, end: i, value: 0 };
    }

    acc = acc * base + d;
    any = true;
    i++;
  }

  if (!any) {
    return { ok: false, end: i, value: 0 };
  }

  i = skipSpace(s, i);
  if (i !== n) {
    return { ok: false, end: i, value: 0 };
  }

  return { ok: true, end: i, value: sign * acc };
}

function parseInt(s: string): { ok: boolean; value: number } {
  const n = s.length;
  let i = 0;
  i = skipSpace(s, i);
  if (i >= n) {
    return { ok: false, value: 0 };
  }

  let neg = false;
  if (s.charCodeAt(i) === 45) { // '-'
    neg = true;
    i++;
  } else if (s.charCodeAt(i) === 43) { // '+'
    i++;
  }

  if (i >= n) {
    return { ok: false, value: 0 };
  }

  let acc = 0;
  let any = false;

  // hex?
  if (s.charCodeAt(i) === 48 && i + 1 < n) { // '0'
    const x = s.charCodeAt(i + 1);
    if (x === 120 || x === 88) { // 'x' or 'X'
      i += 2;
      while (i < n) {
        const c = s.charCodeAt(i);
        let d = -1;
        if (c >= 48 && c <= 57) { // '0'..'9'
          d = c - 48; // '0'
        } else if (c >= 65 && c <= 70) { // 'A'..'F'
          d = 10 + (c - 65);
        } else if (c >= 97 && c <= 102) { // 'a'..'f'
          d = 10 + (c - 97);
        } else {
          d = -1;
        }
        if (d < 0) {
          break;
        }
        acc = acc * 16 + d;
        any = true;
        i++;
      }
      i = skipSpace(s, i);
      if (!any || i !== n) {
        return { ok: false, value: 0 };
      }
      return { ok: true, value: neg ? -acc : acc };
    }
  }

  // decimal integer
  while (i < n) {
    const c = s.charCodeAt(i);
    if (c < 48 || c > 57) { // not '0'..'9'
      break;
    }
    acc = acc * 10 + (c - 48);
    any = true;
    i++;
  }

  i = skipSpace(s, i);
  if (!any || i !== n) {
    return { ok: false, value: 0 };
  }
  return { ok: true, value: neg ? -acc : acc };
}

function parseDecFloat(s: string): { ok: boolean; value: number } {
  const n = s.length;
  let i = 0;
  i = skipSpace(s, i);
  if (i >= n) {
    return { ok: false, value: 0 };
  }

  let sign = 1;
  const c0 = s.charCodeAt(i);
  if (c0 === 45) { // '-'
    sign = -1;
    i++;
  } else if (c0 === 43) { // '+'
    i++;
  }

  let intAny = false;
  let fracAny = false;
  let val = 0;

  // integer part
  while (i < n) {
    const c = s.charCodeAt(i);
    if (c < 48 || c > 57) { // not '0'..'9'
      break;
    }
    val = val * 10 + (c - 48);
    intAny = true;
    i++;
  }

  // fractional part
  if (i < n) {
    if (s.charCodeAt(i) === 46) { // '.'
      i++;
      let scale = 1;
      while (i < n) {
        const c = s.charCodeAt(i);
        if (c < 48 || c > 57) { // not '0'..'9'
          break;
        }
        scale *= 0.1;
        val += (c - 48) * scale;
        fracAny = true;
        i++;
      }
    }
  }

  // exponent
  let exp = 0;
  let hasExp = false;
  if (i < n) {
    const ec = s.charCodeAt(i);
    if (ec === 101 || ec === 69) { // 'e' or 'E'
      hasExp = true;
      i++;
      if (i >= n) {
        return { ok: false, value: 0 };
      }
      let expSign = 1;
      if (i < n) {
        const es = s.charCodeAt(i);
        if (es === 45) { // '-'
          expSign = -1;
          i++;
        } else if (es === 43) { // '+'
          i++;
        }
      }
      if (i >= n) {
        return { ok: false, value: 0 };
      }
      let anyExp = false;
      while (i < n) {
        const c = s.charCodeAt(i);
        if (c < 48 || c > 57) { // not '0'..'9'
          break;
        }
        exp = exp * 10 + (c - 48);
        anyExp = true;
        i++;
      }
      if (!anyExp) {
        return { ok: false, value: 0 };
      }
      exp *= expSign;
    }
  }

  if (!intAny && !fracAny) {
    return { ok: false, value: 0 };
  }

  i = skipSpace(s, i);
  if (i !== n) {
    return { ok: false, value: 0 };
  }

  const result = sign * (hasExp ? val * Math.pow(10, exp) : val);
  return { ok: true, value: result };
}

function parseHexFloat(s: string): { ok: boolean; value: number } {
  const n = s.length;
  let i = 0;
  i = skipSpace(s, i);
  if (i >= n) {
    return { ok: false, value: 0 };
  }

  let sign = 1;
  const c0 = s.charCodeAt(i);
  if (c0 === 45) { // '-'
    sign = -1;
    i++;
  } else if (c0 === 43) { // '+'
    i++;
  }

  if (!(i + 1 < n && s.charCodeAt(i) === 48)) { // '0'
    return { ok: false, value: 0 };
  }
  const x = s.charCodeAt(i + 1);
  if (!(x === 120 || x === 88)) { // 'x' or 'X'
    return { ok: false, value: 0 };
  }
  i += 2;

  let intVal = 0;
  let fracVal = 0;
  let fracScale = 1;
  let anyHex = false;
  let sawDot = false;

  // integer hex digits
  while (i < n) {
    const c = s.charCodeAt(i);
    let d = -1;
    if (c >= 48 && c <= 57) { // '0'..'9'
      d = c - 48;
    } else if (c >= 65 && c <= 70) { // 'A'..'F'
      d = 10 + (c - 65);
    } else if (c >= 97 && c <= 102) { // 'a'..'f'
      d = 10 + (c - 97);
    } else {
      d = -1;
    }
    if (d < 0) {
      break;
    }
    intVal = intVal * 16 + d;
    anyHex = true;
    i++;
  }

  // optional fractional part
  if (i < n) {
    if (s.charCodeAt(i) === 46) { // '.'
      sawDot = true;
      i++;
      while (i < n) {
        const c = s.charCodeAt(i);
        let d = -1;
        if (c >= 48 && c <= 57) { // '0'..'9'
          d = c - 48;
        } else if (c >= 65 && c <= 70) { // 'A'..'F'
          d = 10 + (c - 65);
        } else if (c >= 97 && c <= 102) { // 'a'..'f'
          d = 10 + (c - 97);
        } else {
          d = -1;
        }
        if (d < 0) {
          break;
        }
        fracScale *= 16;
        fracVal = fracVal * 16 + d;
        anyHex = true;
        i++;
      }
    }
  }

  // exponent:
  // - required if there is no dot (so `0x10` stays an integer),
  // - optional if there was a dot (`0x1.2` means `0x1.2p0`).
  let expSign = 1;
  let exp = 0;

  if (i < n) {
    const ec = s.charCodeAt(i);
    if (ec === 112 || ec === 80) { // 'p' or 'P'
      i++;
      if (i < n) {
        const sc = s.charCodeAt(i);
        if (sc === 45) { // '-'
          expSign = -1;
          i++;
        } else if (sc === 43) { // '+'
          i++;
        }
      }
      if (i >= n) {
        return { ok: false, value: 0 };
      }

      let anyExp = false;
      while (i < n) {
        const c = s.charCodeAt(i);
        if (c < 48 || c > 57) { // not '0'..'9'
          break;
        }
        exp = exp * 10 + (c - 48);
        anyExp = true;
        i++;
      }
      if (!anyHex || !anyExp) {
        return { ok: false, value: 0 };
      }
    } else {
      // no exponent marker
      if (!sawDot) {
        // without dot we must reject so parseInt can claim it as integer
        return { ok: false, value: 0 };
      }
      if (!anyHex) {
        return { ok: false, value: 0 };
      }
      expSign = 1;
      exp = 0; // implicit p0
    }
  } else {
    // end of string
    if (!sawDot) {
      return { ok: false, value: 0 };
    }
    if (!anyHex) {
      return { ok: false, value: 0 };
    }
    expSign = 1;
    exp = 0;
  }

  i = skipSpace(s, i);
  if (i !== n) {
    return { ok: false, value: 0 };
  }

  const frac = fracVal === 0 ? 0 : (fracVal / fracScale);
  const result = sign * (intVal + frac) * Math.pow(2, expSign * exp);
  return { ok: true, value: result };
}

// Detailed string conversion that returns numeric type, returns null
// on failure, integer -0 is canonicalized to +0
export function luaToNumberDetailed(
  s: string,
  base?: number,
): { value: number; numericType: NumericType } | null {
  if (base !== undefined) {
    // Base range errors are handled by the stdlib `tonumber` builtin,
    // to match Lua's "bad argument #2 ..." error message.
    if (!(typeof base === "number" && base >= 2 && base <= 36)) {
      return null;
    }

    const parsed = parseIntWithBase(s, base);
    if (parsed.ok) {
      const v = parsed.value;
      return { value: v === 0 ? 0 : v, numericType: "int" };
    }
    return null;
  }

  {
    const parsed = parseInt(s);
    if (parsed.ok) {
      const v = parsed.value;
      return { value: v === 0 ? 0 : v, numericType: "int" };
    }
  }

  {
    const parsed = parseHexFloat(s);
    if (parsed.ok) {
      return { value: parsed.value, numericType: "float" };
    }
  }

  {
    const parsed = parseDecFloat(s);
    if (parsed.ok) {
      return { value: parsed.value, numericType: "float" };
    }
  }

  return null;
}

// Space Lua `tonumber` returning only the numeric value or null
export function luaToNumber(s: unknown, base?: number): number | null {
  if (typeof s === "number") {
    return s;
  }
  // Tagged float: { value: number, isFloat: true }
  if (s !== null && typeof s === "object" && (s as any).isFloat === true) {
    return (s as any).value;
  }
  if (typeof s !== "string") {
    return null;
  }
  const detailed = luaToNumberDetailed(s, base);
  return detailed ? detailed.value : null;
}


================================================
FILE: client/space_lua/tonumber_test.lua
================================================
local function assertEquals(a, b, message)
    if a ~= b then
        error('Assertion failed: ' ..
        tostring(a) .. ' is not equal to ' .. tostring(b) .. (message and (' | ' .. message) or ''))
    end
end

local function assertThrows(msg_substr, fn)
    local ok, err = pcall(fn)

    if ok then
        error('Assertion failed: expected error containing "'
        .. msg_substr .. '"')
    end

    if type(err) ~= 'string' then
        err = tostring(err)
    end

    if not string.find(err, msg_substr, 1, true) then
        error('Assertion failed: expected error message to contain "'
        .. msg_substr .. '", got: "' .. err .. '"')
    end
end

-- helper for whitespace handling tests
local chr = string.char
-- no base: whitespace handling
assertEquals(tonumber(chr(32, 9, 10, 11, 12, 13) .. '42'), 42)
assertEquals(tonumber('42' .. chr(32, 9, 10, 11, 12, 13)), 42)

-- converting numbers that are already numbers
assertEquals(tonumber(0), 0)
assertEquals(tonumber(10), 10)

-- no base: zeroes
assertEquals(tonumber('-0'), 0)
assertEquals(tonumber('0x0p0'), 0.0)
assertEquals(tonumber('0x.0p10'), 0.0)

-- no base: valid decimal integers and floats
assertEquals(tonumber('123'), 123)
assertEquals(tonumber('+123'), 123)
assertEquals(tonumber('-123'), -123)
assertEquals(tonumber('0'), 0)
assertEquals(tonumber('000123'), 123)
assertEquals(tonumber('123.45'), 123.45)
assertEquals(tonumber('5.'), 5.0)
assertEquals(tonumber('.5'), 0.5)
assertEquals(tonumber('5.e1'), 50.0)
assertEquals(tonumber('.5e2'), 50.0)
assertEquals(tonumber('-.5e2'), -50.0)
assertEquals(tonumber('1e-2'), 0.01)
assertEquals(tonumber('1E+2'), 100.0)

-- no base: valid hexadecimal integers (no exponent)
assertEquals(tonumber('0x10'), 16)
assertEquals(tonumber('0X10'), 16)
assertEquals(tonumber('+0x10'), 16)
assertEquals(tonumber('-0x10'), -16)
assertEquals(tonumber(' 0x0 '), 0)
assertEquals(tonumber('0x000F'), 15)
assertEquals(tonumber('0XdeadBEEF'), 3735928559)

-- no base: valid hexadecimal floats (with exponent)
assertEquals(tonumber('0x1p0'), 1.0)
assertEquals(tonumber('0x1p+0'), 1.0)
assertEquals(tonumber('0x1p-2'), 0.25)
assertEquals(tonumber('0x1.0p-2'), 0.25)
assertEquals(tonumber('0x1.8p1'), 3.0)
assertEquals(tonumber('0x.8p0'), 0.5)
assertEquals(tonumber('0x.Fp4'), 15.0)
assertEquals(tonumber('0XA.P-1'), 5.0)
assertEquals(tonumber('  0x10P0  '), 16.0)
assertEquals(tonumber('-0x10p0'), -16.0)
assertEquals(tonumber('0x10.2p0'), 16.125)
assertEquals(tonumber('0x10.3P-1'), 8.09375)
assertEquals(tonumber('-0X10.3P-1'), -8.09375)
assertEquals(tonumber('0x1.2'), 1.125)

-- no base: invalid
assertEquals(tonumber(''), nil)
assertEquals(tonumber(' '), nil)
assertEquals(tonumber('abc'), nil)
assertEquals(tonumber('12.34.56'), nil)
assertEquals(tonumber('e10'), nil)
assertEquals(tonumber('.e1'), nil)
assertEquals(tonumber('1e'), nil)
assertEquals(tonumber('1e+'), nil)
assertEquals(tonumber('0x'), nil)
assertEquals(tonumber('0xG'), nil)
assertEquals(tonumber('+ 10'), nil)
assertEquals(tonumber('- 10'), nil)
assertEquals(tonumber('123x'), nil)

-- with base: whitespace handling
assertEquals(tonumber(chr(32, 9, 10, 11, 12, 13) .. 'FF', 16), 255)
assertEquals(tonumber('FF' .. chr(32, 9, 10, 11, 12, 13), 16), 255)

-- with base: valid
assertEquals(tonumber('1010', 2), 10)
assertEquals(tonumber('+1011', 2), 11)
assertEquals(tonumber('ff', 16), 255)
assertEquals(tonumber('FF', 16), 255)
assertEquals(tonumber('fF', 16), 255)
assertEquals(tonumber('377', 8), 255)
assertEquals(tonumber('z', 36), 35)
assertEquals(tonumber('Aa', 11), 120)
assertEquals(tonumber('  ff  ', 16), 255)
assertEquals(tonumber('1010', 10), 1010)

-- with base: invalid
assertThrows("bad argument #2 to 'tonumber' (base out of range)",
    function()
        return tonumber('1010', 1)
    end
)

assertThrows("bad argument #2 to 'tonumber' (base out of range)",
    function()
        return tonumber('1010', 37)
    end
)

assertEquals(tonumber('FF', 10), nil)
assertEquals(tonumber('8', 8), nil)
assertEquals(tonumber('2', 2), nil)
assertEquals(tonumber('', 16), nil)
assertEquals(tonumber(' ', 16), nil)
assertEquals(tonumber('+', 16), nil)
assertEquals(tonumber('-', 16), nil)
assertEquals(tonumber('+ FF', 16), nil)
assertEquals(tonumber('1_0', 10), nil)
assertEquals(tonumber('123x', 10), nil)


================================================
FILE: client/space_lua/truthiness_test.lua
================================================
local function assertEquals(actual, expected, msg)
  if actual ~= expected then
    error('assertEquals failed: ' .. msg)
  end
end

local function assertTrue(cond, msg)
  if not cond then
    error('assertTrue failed: ' .. msg)
  end
end

local function assertFalse(cond, msg)
  if cond then
    error('assertFalse failed: ' .. msg)
  end
end

-- Helpers for multi-return tests

local function ret_nil_one()
  return nil, 1
end

local function ret_false_one()
  return false, 1
end

local function ret_zero_one()
  return 0, 1
end

local function ret_emptystr_one()
  return "", 1
end

local function ret_tbl_one()
  return {}, 1
end

local function ret_one_two()
  return 1, 2
end

-- 1. Basic boolean/nil

assertFalse(false, "false must be falsey")
assertTrue(true, "true must be truthy")
assertFalse(nil, "nil must be falsey")

-- 2. Numbers: all numbers are truthy

assertTrue(0, "0 must be truthy")
assertTrue(1, "1 must be truthy")
assertTrue(-1, "-1 must be truthy")
assertTrue(0.0, "0.0 must be truthy")
assertTrue(-0.0, "-0.0 must be truthy")

-- 3. Strings: empty and non-empty strings are truthy

assertTrue("", "empty string must be truthy")
assertTrue("0", '"0" must be truthy')
assertTrue("a", '"a" must be truthy')

-- 4. Tables: empty and non-empty tables are truthy

assertTrue({}, "empty table must be truthy")
assertTrue({1}, "non-empty table must be truthy")

-- 5. Functions are truthy

local fn = function() end

assertTrue(fn, "function must be truthy")
assertTrue(function() end, "function must be truthy")

-- 6. Multi-return: only the first value determines truthiness

if ret_nil_one() then
  error("ret_nil_one() must be falsey (first result nil)")
end

if ret_false_one() then
  error("ret_false_one() must be falsey (first result false)")
end

if not ret_zero_one() then
  error("ret_zero_one() must be truthy (first result 0)")
end

if not ret_emptystr_one() then
  error("ret_emptystr_one() must be truthy (first result empty string)")
end

if not ret_tbl_one() then
  error("ret_tbl_one() must be truthy (first result table)")
end

-- 7. `and`/`or` short-circuiting and value returns

-- 7.1. `and` returns first falsey value, otherwise last value

assertEquals((true and 2), 2, "and: true and 2 -> 2")
assertEquals((false and 2), false, "and: false and 2 -> false")
assertEquals((nil and 2), nil, "and: nil and 2 -> nil")
assertEquals((0 and "x"), "x", "and: 0 and 'x' -> 'x' (0 is truthy)")

-- 7.2. `or` returns first truthy value, otherwise last value

assertEquals((false or 3), 3, "or: false or 3 -> 3")
assertEquals((nil or 3), 3, "or: nil or 3 -> 3")
assertEquals((0 or 3), 0, "or: 0 or 3 -> 0 (0 is truthy)")
assertEquals(("" or "x"), "", "or: '' or 'x' -> '' ('' is truthy)")

-- 7.3. Multi-return: operand evaluation yields first value

assertEquals((true and ret_one_two()), 1,
  "and: true and (1,2) yields first result 1")

assertEquals((false or ret_one_two()), 1,
  "or: false or (1,2) yields first result 1")

-- 8. Litmus tests involving tables and `and`/`or`

-- {} and 2 or 3 -> 2
-- {} or 2 and 3 -> {} (because {} is truthy, or returns left operand)

local t = {}

assertEquals((t and 2 or 3), 2, "{} and 2 or 3 must be 2")

-- `v` should be the same table reference `t`

local v = (t or 2 and 3)

assertTrue(type(v) == "table",
  "{} or 2 and 3 must return the table (truthy left operand)")

-- changing `t` reflects in `v` (same reference)

t.key = "ok"

assertEquals(v.key, "ok",
  "{} or 2 and 3 returns original table reference")

-- 9. `if`/`while` with truthiness

local ran_if_truthy = false

if {} then
  ran_if_truthy = true
end

assertTrue(ran_if_truthy, "if {} must run branch")

local ran_if_falsey = false

if nil then
  ran_if_falsey = true
end

assertFalse(ran_if_falsey, "if nil must not run branch")

-- `while` must stop only on `false`/`nil`

local n = 0

local function next_or_nil()
  n = n + 1

  if n <= 2 then
    return 0
    -- 0 truthy -> loop two times
  end

  return nil -- stop
end

local count = 0

while next_or_nil() do
  count = count + 1
end

assertEquals(count, 2,
  "while should loop while condition is truthy")

-- 10. Short-circuit evaluation (no RHS evaluation when not needed)

local side = 0

local function bump()
  side = side + 1
  return true
end

-- For `true or bump()` RHS must not be evaluated

local s0 = side
local _ = (true or bump())

assertEquals(side, s0, "or short-circuit must avoid RHS when LHS truthy")

-- For `false and bump()` RHS must not be evaluated

local _ = (false and bump())

assertEquals(side, s0, "and short-circuit must avoid RHS when LHS falsey")

-- 11. Multi-return short-circuit

local function falsy_pair()
  return nil, "x"
end

local function truthy_pair()
  return 1, "x"
end

-- `or`: pick RHS only when LHS falsey (first result)

local s1 = (falsy_pair() or 5)

assertEquals(s1, 5, "or should see falsy first result and evaluate RHS")

local s2 = (truthy_pair() or 5)

assertEquals(s2, 1, "or should return first result of LHS when truthy")

-- 12. Only the last expression expands

local function ret() return "A", "B" end
local function a() return "A1", "A2" end
local function b() return "B1", "B2" end
local function c() return "C1", "C2" end

assertEquals(string.format("%s-%s", ret()), "A-B",
  "last-only: single arg expands")

assertEquals(string.format("%s-%s", ret(), "Z"), "A-Z",
  "last-only: earlier arg is single")

assertEquals(string.format("%s-%s-%s-%s", a(), b(), c()), "A1-B1-C1-C2",
  "last-only: only last expands, earlier args single")

-- 13. Only false and `nil` are falsey

assertFalse(not 0, "not 0 must be false")
assertFalse(not -0.0, "not -0.0 must be false")
assertFalse(not "", "not '' must be false")
assertFalse(not {}, "not {} must be false")

assertTrue(not nil, "not nil must be true")
assertTrue(not false, "not false must be true")


================================================
FILE: client/space_lua/util.test.ts
================================================
import { evalPromiseValues } from "./util.ts";
import { assertEquals } from "@std/assert/equals";
import { assert } from "@std/assert";

Deno.test("Test promise helpers", async () => {
  const r = evalPromiseValues([1, 2, 3]);
  // should return the same array not as a promise
  assertEquals(r, [1, 2, 3]);
  const asyncR = evalPromiseValues([
    new Promise((resolve) => {
      setTimeout(() => {
        resolve(1);
      }, 5);
    }),
    Promise.resolve(2),
    3,
  ]);
  // should return a promise
  assert(asyncR instanceof Promise);
  assertEquals(await asyncR, [1, 2, 3]);
});


================================================
FILE: client/space_lua/util.ts
================================================
import { isPromise } from "./rp.ts";

export function evalPromiseValues(vals: any[]): Promise<any[]> | any[] {
  const promises = [];
  const promiseResults = new Array(vals.length);
  for (let i = 0; i < vals.length; i++) {
    if (isPromise(vals[i])) {
      promises.push(
        (vals[i] as Promise<any>).then((v: any) => promiseResults[i] = v),
      );
    } else {
      promiseResults[i] = vals[i];
    }
  }
  if (promises.length === 0) {
    return promiseResults;
  } else {
    return Promise.all(promises).then(() => promiseResults);
  }
}

/**
 * return the mid value among x, y, and z
 * @param x
 * @param y
 * @param z
 * @param compare
 * @returns {Promise.<*>}
 */
async function getPivot(
  x: any,
  y: any,
  z: any,
  compare: (a: any, b: any) => Promise<number>,
) {
  if (await compare(x, y) < 0) {
    if (await compare(y, z) < 0) {
      return y;
    } else if (await compare(z, x) < 0) {
      return x;
    } else {
      return z;
    }
  } else if (await compare(y, z) > 0) {
    return y;
  } else if (await compare(z, x) > 0) {
    return x;
  } else {
    return z;
  }
}

/**
 * asynchronous quick sort
 * @param arr array to sort
 * @param compare asynchronous comparing function
 * @param left index where the range of elements to be sorted starts
 * @param right index where the range of elements to be sorted ends
 * @returns {Promise.<*>}
 */
export async function asyncQuickSort(
  arr: any[],
  compare: (a: any, b: any) => Promise<number>,
  left = 0,
  right = arr.length - 1,
) {
  if (left < right) {
    let i = left, j = right, tmp;
    const pivot = await getPivot(
      arr[i],
      arr[i + Math.floor((j - i) / 2)],
      arr[j],
      compare,
    );
    while (true) {
      while (await compare(arr[i], pivot) < 0) {
        i++;
      }
      while (await compare(pivot, arr[j]) < 0) {
        j--;
      }
      if (i >= j) {
        break;
      }
      tmp = arr[i];
      arr[i] = arr[j];
      arr[j] = tmp;

      i++;
      j--;
    }
    await asyncQuickSort(arr, compare, left, i - 1);
    await asyncQuickSort(arr, compare, j + 1, right);
  }
  return arr;
}


================================================
FILE: client/space_lua_api.ts
================================================
import { luaBuildStandardEnv } from "./space_lua/stdlib.ts";
import {
  LuaBuiltinFunction,
  LuaEnv,
  LuaNativeJSFunction,
  type LuaRuntimeError,
  LuaStackFrame,
  LuaTable,
} from "./space_lua/runtime.ts";
import type { System } from "./plugos/system.ts";
import { resolveASTReference } from "./space_lua.ts";

export function buildLuaEnv(system: System<any>) {
  const env = new LuaEnv(luaBuildStandardEnv());

  // Expose all syscalls to Lua
  exposeSyscalls(env, system);

  return env;
}

/**
 * Exposes all registered syscalls to Lua, automatically converting Lua arguments to JS values
 * If a syscall is prefixed with `lua:` it exposes the syscall as a native Lua function, skipping the argument conversion0
 */
export function exposeSyscalls(env: LuaEnv, system: System<any>) {
  // Expose all syscalls to Lua
  const nativeFs = new LuaStackFrame(env, null);
  for (const syscallName of system.registeredSyscalls.keys()) {
    const isLuaNativeSyscall = syscallName.startsWith("lua:");
    let cleanSyscallName = syscallName;
    if (isLuaNativeSyscall) {
      cleanSyscallName = syscallName.slice("lua:".length);
    }
    const [ns, fn] = cleanSyscallName.split(".");
    if (!env.has(ns)) {
      env.set(ns, new LuaTable(), nativeFs);
    }
    const luaFn = isLuaNativeSyscall
      ? new LuaBuiltinFunction((_sf, ...args) => {
        return system.localSyscall(syscallName, args);
      })
      : new LuaNativeJSFunction((...args) => {
        return system.localSyscall(syscallName, args);
      });
    env.get(ns, nativeFs).set(fn, luaFn, nativeFs);
  }
}

export async function buildThreadLocalEnv(
  system: System<any>,
  globalEnv: LuaEnv,
) {
  const tl = new LuaEnv();
  if (system.registeredSyscalls.has("editor.getCurrentPageMeta")) {
    const currentPageMeta = await system.localSyscall(
      "editor.getCurrentPageMeta",
      [],
    );
    if (currentPageMeta) {
      tl.setLocal("currentPage", currentPageMeta);
    } else {
      tl.setLocal("currentPage", {
        name: await system.localSyscall("editor.getCurrentPage", []),
      });
    }
  }
  tl.setLocal("_GLOBAL", globalEnv);
  return tl;
}

export async function handleLuaError(e: LuaRuntimeError, system: System<any>) {
  console.error(
    "Lua eval exception",
    e.message,
    e.sf?.astCtx,
  );
  if (e.sf?.astCtx && e.sf.astCtx.ref) {
    // We got an error and actually know where it came from, let's navigate there to help debugging
    await system.localSyscall(
      "editor.flashNotification",
      [
        `Lua error: ${e.message}`,
        "error",
      ],
    );

    const ref = resolveASTReference(e.sf.astCtx);
    if (!ref) return;

    await system.localSyscall(
      "editor.flashNotification",
      [
        `Navigating to the place in the code where this error occurred in ${ref.path}`,
        "info",
      ],
    );
    await system.localSyscall("editor.navigate", [ref]);
  }
}


================================================
FILE: client/spaces/checked_space_primitives.ts
================================================
import type { FileMeta } from "@silverbulletmd/silverbullet/type/index";
import type { SpacePrimitives } from "./space_primitives.ts";
import { isValidPath } from "@silverbulletmd/silverbullet/lib/ref";

/**
 * Adds checks for two things:
 * 1. Allowed path names
 * 2. Permissions
 */
export class CheckedSpacePrimitives implements SpacePrimitives {
  constructor(
    private wrapped: SpacePrimitives,
    private readOnly: boolean,
  ) {
  }

  async fetchFileList(): Promise<FileMeta[]> {
    return (await this.wrapped.fetchFileList()).filter(({ name }) =>
      this.isReadable(name)
    );
  }

  readFile(path: string): Promise<{ data: Uint8Array; meta: FileMeta }> {
    if (!this.isReadable(path)) {
      throw new Error("Couldn't write file, path isn't writable");
    }
    return this.wrapped.readFile(path);
  }

  getFileMeta(path: string, observing?: boolean): Promise<FileMeta> {
    if (!this.isReadable(path)) {
      throw new Error("Couldn't get file meta, path isn't writable");
    }
    return this.wrapped.getFileMeta(path, observing);
  }

  async writeFile(
    path: string,
    data: Uint8Array,
    meta?: FileMeta,
  ): Promise<FileMeta> {
    if (!await this.isWritable(path)) {
      throw new Error("Couldn't write file, path is not writable");
    }
    return this.wrapped.writeFile(path, data, meta);
  }

  async deleteFile(path: string): Promise<void> {
    // We allow deletion of paths we can't write to. This is for the case when
    // the user has an invalidly named file in their space and they need to
    // remove it/rename it
    if (!await this.isWritable(path)) {
      throw new Error("Couldn't delete file, path is not writable");
    }
    return this.wrapped.deleteFile(path);
  }

  private isReadable(path: string): boolean {
    return !path.startsWith(".");
  }

  private async isWritable(path: string): Promise<boolean> {
    if (this.readOnly) {
      return false;
    }
    try {
      const fileMeta = await this.getFileMeta(path);
      if (fileMeta.perm === "ro") {
        return false;
      }
    } catch {
      // Assumption, not found, that's ok
    }
    return this.isReadable(path) && isValidPath(path);
  }
}


================================================
FILE: client/spaces/constants.ts
================================================
export const stdLibPrefix = "Library/Std/";
export const fsEndpoint = "/.fs";


================================================
FILE: client/spaces/datastore_space_primitives.test.ts
================================================
import "fake-indexeddb/auto";
import { IndexedDBKvPrimitives } from "../data/indexeddb_kv_primitives.ts";
import { DataStoreSpacePrimitives } from "./datastore_space_primitives.ts";
import { testSpacePrimitives } from "./space_primitives.test.ts";

Deno.test("DataStoreSpacePrimitives", {
  sanitizeResources: false,
  sanitizeOps: false,
}, async () => {
  const db = new IndexedDBKvPrimitives("test");
  await db.init();

  const space = new DataStoreSpacePrimitives(db);
  await testSpacePrimitives(space);
  db.close();
});


================================================
FILE: client/spaces/datastore_space_primitives.ts
================================================
import type { SpacePrimitives } from "./space_primitives.ts";
import { mime } from "mimetypes";
import type { FileMeta } from "@silverbulletmd/silverbullet/type/index";
import { notFoundError } from "@silverbulletmd/silverbullet/constants";
import type { KvPrimitives } from "../data/kv_primitives.ts";

const filesMetaPrefix = ["meta"];
const filesContentPrefix = ["content"];

export class DataStoreSpacePrimitives implements SpacePrimitives {
  constructor(
    private kv: KvPrimitives,
  ) {
  }

  async fetchFileList(): Promise<FileMeta[]> {
    const fileMetas: FileMeta[] = [];
    // Iterate over all keys with the fileMetaPrefix
    for await (const meta of this.kv.query({ prefix: filesMetaPrefix })) {
      fileMetas.push(this.cleanFileMeta(meta.value as FileMeta));
    }
    return fileMetas;
  }

  async readFile(
    path: string,
  ): Promise<{ data: Uint8Array; meta: FileMeta }> {
    // Fetch content and metadata in parallel
    const [fileMeta, fileContent] = await this.kv.batchGet([
      [
        ...filesMetaPrefix,
        path,
      ],
      [
        ...filesContentPrefix,
        path,
      ],
    ]);
    if (!fileMeta) {
      throw notFoundError;
    }

    return {
      meta: this.cleanFileMeta(fileMeta),
      data: fileContent,
    };
  }

  async writeFile(
    path: string,
    data: Uint8Array,
    suggestedMeta?: FileMeta,
  ): Promise<FileMeta> {
    let meta: FileMeta | undefined;
    try {
      // Build off of the existing file meta, if file exists
      meta = await this.getFileMeta(path);
    } catch {
      // Not found, that's fine
    }
    if (!meta) {
      // No existing meta data, let's set some defaults
      meta = {
        name: path,
        created: suggestedMeta?.created || Date.now(),
        perm: "rw",
        contentType: mime.getType(path) || "application/octet-stream",
        // Overwritten in a sec
        lastModified: 0,
        size: 0,
      };
    }
    meta.lastModified = suggestedMeta?.lastModified || Date.now();
    meta.size = data.byteLength;
    if (suggestedMeta?.perm) {
      meta.perm = suggestedMeta.perm;
    }

    // Write metadata and content in same transaction
    await this.kv.batchSet([
      {
        key: [...filesMetaPrefix, path],
        value: meta,
      },
      {
        key: [...filesContentPrefix, path],
        value: data,
      },
    ]);
    return meta;
  }

  async deleteFile(path: string): Promise<void> {
    const [fileMeta] = await this.kv.batchGet([
      [...filesMetaPrefix, path],
    ]);
    if (!fileMeta) {
      throw notFoundError;
    }
    return this.kv.batchDelete([
      [...filesMetaPrefix, path],
      [...filesContentPrefix, path],
    ]);
  }

  async getFileMeta(path: string, _observing?: boolean): Promise<FileMeta> {
    const [fileMeta] = await this.kv.batchGet([[...filesMetaPrefix, path]]);
    if (!fileMeta) {
      throw notFoundError;
    }
    return this.cleanFileMeta(fileMeta);
  }

  cleanFileMeta(fileMeta: FileMeta): FileMeta {
    if (!fileMeta.created) {
      fileMeta.created = fileMeta.lastModified;
    }
    return fileMeta;
  }
}


================================================
FILE: client/spaces/evented_space_primitives.ts
================================================
import type { EventHook } from "../plugos/hooks/event.ts";

import type { SpacePrimitives } from "./space_primitives.ts";
import type { FileMeta } from "@silverbulletmd/silverbullet/type/index";
import type { DataStore } from "../data/datastore.ts";

/**
 * Events exposed:
 * - file:changed (string, oldHash, newHash)
 * - file:deleted (string)
 * - file:listed (FileMeta[])
 * - file:initial: triggered in case of an initially empty snapshot, after the first set of events has gone out
 * - page:saved (string, FileMeta)
 * - page:deleted (string)
 */
export class EventedSpacePrimitives implements SpacePrimitives {
  // Various operations may be going on at the same time, and we don't want to trigger events unnessarily.
  // Therefore, we use this variable to track if any operation is in flight, and if so, we skip event triggering.
  // This is ok, because any event will be picked up in a following iteration.
  operationInProgress = false;

  private enabled = false;

  // Snapshot state management
  private spaceSnapshot: Record<string, number> = {};
  private snapshotChanged = false;

  constructor(
    private wrapped: SpacePrimitives,
    private eventHook: EventHook,
    private ds: DataStore,
    private snapshotKey = ["$spaceSnapshot"],
  ) {
  }

  async enable() {
    console.log("Loading snapshot and enabling events");
    this.spaceSnapshot = (await this.ds.get(this.snapshotKey)) || {};
    this.snapshotChanged = false;
    this.enabled = true;
  }

  public isSnapshotEmpty() {
    return Object.keys(this.spaceSnapshot).length === 0;
  }

  public getSnapshot() {
    return this.spaceSnapshot;
  }

  private updateInSnapshot(key: string, value: number) {
    const oldValue = this.spaceSnapshot[key];
    this.spaceSnapshot[key] = value;
    this.snapshotChanged = this.snapshotChanged || oldValue !== value;
  }

  private deleteFromSnapshot(key: string) {
    delete this.spaceSnapshot[key];
    this.snapshotChanged = true;
  }

  private async saveSnapshot() {
    if (this.enabled && this.snapshotChanged) {
      await this.ds.set(this.snapshotKey, this.spaceSnapshot);
      this.snapshotChanged = false;
    }
  }

  dispatchEvent(name: string, ...args: any[]): Promise<any[]> {
    if (!this.enabled) {
      return Promise.resolve([]);
    }
    // console.log("Evented space, dispatching", name, args);
    return this.eventHook.dispatchEvent(name, ...args);
  }

  async fetchFileList(): Promise<FileMeta[]> {
    if (this.operationInProgress) {
      // Some other operation (read, write, list, meta) is already going on
      // this will likely trigger events, so let's not worry about any of that and avoid race condition and inconsistent data.
      console.info(
        "alreadyFetching is on, skipping even triggering for fetchFileList.",
      );
      return this.wrapped.fetchFileList();
    }
    if (!this.enabled) {
      return this.wrapped.fetchFileList();
    }
    // console.log("Fetching file list");
    // Fetching mutex
    this.operationInProgress = true;
    try {
      // Fetch the list
      const newFileList = await this.wrapped.fetchFileList();

      // Now we have the list, let's compare it to the snapshot and trigger events appropriately
      const deletedFiles = new Set<string>(Object.keys(this.spaceSnapshot));
      for (const meta of newFileList) {
        const oldHash = this.spaceSnapshot[meta.name];
        const newHash = meta.lastModified;
        // Update in snapshot
        this.updateInSnapshot(meta.name, newHash);

        // Check what happened to the file
        if (
          (
            // New file scenario
            !oldHash
          ) || (
            // Changed file scenario
            oldHash &&
            oldHash !== newHash
          )
        ) {
          console.log(
            "Detected file change during listing",
            meta.name,
            oldHash,
            newHash,
          );
          await this.dispatchEvent(
            "file:changed",
            meta.name,
            oldHash,
            newHash,
          );
        }
        // Page found, not deleted
        deletedFiles.delete(meta.name);
      }

      for (const deletedFile of deletedFiles) {
        this.deleteFromSnapshot(deletedFile);
        await this.dispatchEvent("file:deleted", deletedFile);

        if (deletedFile.endsWith(".md")) {
          const pageName = deletedFile.substring(0, deletedFile.length - 3);
          await this.dispatchEvent("page:deleted", pageName);
        }
      }

      await this.dispatchEvent("file:listed", newFileList);
      return newFileList;
    } finally {
      await this.saveSnapshot();
      this.operationInProgress = false;
    }
  }

  async readFile(
    path: string,
  ): Promise<{ data: Uint8Array; meta: FileMeta }> {
    if (!this.enabled) {
      return this.wrapped.readFile(path);
    }
    try {
      // Fetching mutex
      const wasFetching = this.operationInProgress;
      this.operationInProgress = true;

      // Fetch file
      const data = await this.wrapped.readFile(path);
      if (!wasFetching) {
        await this.triggerEventsAndCache(path, data.meta.lastModified);
      }
      return data;
    } finally {
      this.operationInProgress = false;
    }
  }

  async writeFile(
    path: string,
    data: Uint8Array,
    meta?: FileMeta,
  ): Promise<FileMeta> {
    if (!this.enabled) {
      return this.wrapped.writeFile(path, data, meta);
    }

    const wasFetching = this.operationInProgress;
    this.operationInProgress = true;
    try {
      const newMeta = await this.wrapped.writeFile(
        path,
        data,
        meta,
      );
      if (!wasFetching) {
        await this.triggerEventsAndCache(path, newMeta.lastModified);
      }
      if (path.endsWith(".md")) {
        const pageName = path.substring(0, path.length - 3);
        await this.dispatchEvent("page:saved", pageName, newMeta);
      }

      return newMeta;
    } finally {
      this.operationInProgress = false;
    }
  }

  /**
   * @param name
   * @param newHash
   * @return whether something changed in the snapshot
   */
  async triggerEventsAndCache(name: string, newHash: number) {
    const oldHash = this.spaceSnapshot[name];
    // if (oldHash && newHash && oldHash !== newHash) {
    if (oldHash !== newHash) {
      // Page changed since last cached metadata, trigger event
      await this.dispatchEvent("file:changed", name, oldHash, newHash);
    }
    this.updateInSnapshot(name, newHash);
    await this.saveSnapshot();
  }

  async getFileMeta(path: string, observing?: boolean): Promise<FileMeta> {
    if (!this.enabled) {
      return this.wrapped.getFileMeta(path, observing);
    }

    try {
      const wasFetching = this.operationInProgress;
      this.operationInProgress = true;
      const newMeta = await this.wrapped.getFileMeta(path, observing);
      if (!wasFetching) {
        await this.triggerEventsAndCache(path, newMeta.lastModified);
      }
      return newMeta;
    } finally {
      this.operationInProgress = false;
    }
  }

  async deleteFile(path: string): Promise<void> {
    if (!this.enabled) {
      return this.wrapped.deleteFile(path);
    }

    try {
      this.operationInProgress = true;
      if (path.endsWith(".md")) {
        const pageName = path.substring(0, path.length - 3);
        await this.dispatchEvent("page:deleted", pageName);
      }
      // await this.getPageMeta(path); // Check if page exists, if not throws Error
      await this.wrapped.deleteFile(path);
      this.deleteFromSnapshot(path);
      await this.dispatchEvent("file:deleted", path);
    } finally {
      await this.saveSnapshot();
      this.operationInProgress = false;
    }
  }
}


================================================
FILE: client/spaces/filtered_space_primitives.ts
================================================
import type { SpacePrimitives } from "./space_primitives.ts";
import type { FileMeta } from "../../plug-api/types/index.ts";

export class FilteredSpacePrimitives implements SpacePrimitives {
  constructor(
    private wrapped: SpacePrimitives,
    private filterFn: (name: FileMeta) => boolean,
    private onFetchList?: () => Promise<void>,
  ) {
  }

  async fetchFileList(): Promise<FileMeta[]> {
    if (this.onFetchList) {
      await this.onFetchList();
    }
    return (await this.wrapped.fetchFileList()).filter(this.filterFn);
  }

  readFile(path: string): Promise<{ data: Uint8Array; meta: FileMeta }> {
    return this.wrapped.readFile(path);
  }

  getFileMeta(path: string, observing?: boolean): Promise<FileMeta> {
    return this.wrapped.getFileMeta(path, observing);
  }

  writeFile(
    path: string,
    data: Uint8Array,
    meta?: FileMeta,
  ): Promise<FileMeta> {
    return this.wrapped.writeFile(path, data, meta);
  }

  deleteFile(path: string): Promise<void> {
    return this.wrapped.deleteFile(path);
  }
}


================================================
FILE: client/spaces/http_space_primitives.ts
================================================
import type { SpacePrimitives } from "./space_primitives.ts";
import { encodePageURI } from "@silverbulletmd/silverbullet/lib/ref";
import {
  flushCachesAndUnregisterServiceWorker,
} from "../../client/service_worker/util.ts";
import type { FileMeta } from "@silverbulletmd/silverbullet/type/index";
import {
  notFoundError,
  offlineError,
  offlineStatusCodes,
  pingTimeout,
  wrongSpacePathError,
} from "@silverbulletmd/silverbullet/constants";
import { headersToFileMeta } from "../lib/util.ts";

const defaultFetchTimeout = 30000; // 30 seconds

export class HttpSpacePrimitives implements SpacePrimitives {
  constructor(
    readonly url: string,
    readonly expectedSpacePath: string,
    private authErrorCallback: (message: string, ...args: any[]) => void,
    private bearerToken?: string,
  ) {
  }

  public async authenticatedFetch(
    url: string,
    options: RequestInit,
    fetchTimeout: number = defaultFetchTimeout,
  ): Promise<Response> {
    if (!options.headers) {
      options.headers = {};
    }
    options.headers = {
      ...options.headers,
      "X-Sync-Mode": "true",
    };
    if (this.bearerToken) {
      options.headers = {
        ...options.headers,
        "Authorization": `Bearer ${this.bearerToken}`,
      };
    }

    try {
      options.signal = AbortSignal.timeout(fetchTimeout);
      options.redirect = "manual";
      const result = await fetch(url, options);
      if (result.status in offlineStatusCodes) {
        throw offlineError;
      }
      const redirectHeader = result.headers.get("location");

      if (result.type === "opaqueredirect" && !redirectHeader) {
        console.log("Result", result, "for", url, JSON.stringify(options));
        // This is a scenario where the server sent a redirect, but this redirect is not visible to the client, likely due to CORS
        // The best we can do is to reload the page and hope that the server will redirect us to the correct location
        this.authErrorCallback(
          "You are not authenticated, reloading to reauthenticate",
          "reload",
        );
        // console.log("Unregistering service workers", redirectHeader);
        // await unregisterServiceWorkers();
        // location.reload();
        // Let's throw to avoid any further processing
        throw Error("Not authenticated");
      }

      // console.log("Got response", result.status, result.statusText, result.url);

      // Attempting to handle various authentication proxies
      if (result.status >= 300 && result.status < 400) {
        if (redirectHeader) {
          // Got a redirect
          this.authErrorCallback(
            "Received an authentication redirect",
            redirectHeader,
          );
          // location.href = redirectHeader;
          throw new Error("Redirected");
        } else {
          console.error("Got a redirect status but no location header", result);
        }
      }
      // Check for unauthorized status
      if (result.status === 401 || result.status === 403) {
        // If it came with a redirect header, we'll redirect to that URL
        if (redirectHeader) {
          console.log(
            "Received unauthorized status and got a redirect via the API so will redirect to URL",
            result.url,
          );
          this.authErrorCallback("You are not authenticated ", redirectHeader);
          // location.href = redirectHeader;
          throw new Error("Not authenticated");
        } else {
          // If not, let's reload
          this.authErrorCallback(
            "You are not authenticated, going to reload and hope that that kicks off authentication",
          );
          // location.reload();
          throw new Error("Not authenticated");
        }
      }
      return result;
    } catch (e: any) {
      // Errors when there is no internet connection:
      //
      // * Firefox: NetworkError when attempting to fetch resource (with SW and without)
      // * Safari (service worker enabled): FetchEvent.respondWith received an error: TypeError: Load failed
      // * Safari (no service worker): Load failed
      // * Chrome: Failed to fetch
      //
      // Common substrings: "fetch" "load failed"
      const errorMessage = e.message.toLowerCase();
      if (
        errorMessage.includes("fetch") ||
        errorMessage.includes("load failed") ||
        errorMessage.includes("unavailable")
      ) {
        console.error(
          "Got error fetching, throwing offline",
          url,
          e.message,
        );
        throw offlineError;
      }
      throw e;
    }
  }

  async fetchFileList(): Promise<FileMeta[]> {
    const resp = await this.authenticatedFetch(this.url, {
      method: "GET",
    });

    await this.validateSpacePathFromHeaders(resp);
    return resp.json();
  }

  /**
   * The /.fs file listing and /.ping endpoints both expose the currently exposed space path, if this doesn't match what the client expects, the client has to restart
   */
  async validateSpacePathFromHeaders(resp: Response) {
    if (
      resp.status === 200 &&
      this.expectedSpacePath &&
      resp.headers.get("X-Space-Path") &&
      resp.headers.get("X-Space-Path") !== this.expectedSpacePath
    ) {
      console.log("Expected space path", this.expectedSpacePath);
      console.log("Got space path", resp.headers.get("X-Space-Path"));
      await flushCachesAndUnregisterServiceWorker();
      this.authErrorCallback(
        wrongSpacePathError.message,
        "reload",
      );
    }
  }

  async readFile(
    path: string,
  ): Promise<{ data: Uint8Array; meta: FileMeta }> {
    const res = await this.authenticatedFetch(
      `${this.url}/${encodePageURI(path)}`,
      {
        method: "GET",
        headers: {
          // This header won't trigger CORS preflight requests but can be interpreted on the server
          Accept: "application/octet-stream",
        },
      },
    );
    if (res.status === 404) {
      throw notFoundError;
    }
    return {
      data: new Uint8Array(await res.arrayBuffer()),
      meta: headersToFileMeta(path, res.headers)!,
    };
  }

  async writeFile(
    path: string,
    data: Uint8Array,
    meta?: FileMeta,
  ): Promise<FileMeta> {
    const headers: Record<string, string> = {
      "Content-Type": "application/octet-stream",
    };
    if (meta) {
      headers["X-Created"] = "" + meta.created;
      headers["X-Last-Modified"] = "" + meta.lastModified;
      headers["X-Perm"] = "" + meta.perm;
    }

    const res = await this.authenticatedFetch(
      `${this.url}/${encodePageURI(path)}`,
      {
        method: "PUT",
        headers,
        // Casting to any because of weird Deno typing
        body: data as any,
      },
    );
    return headersToFileMeta(path, res.headers)!;
  }

  async deleteFile(path: string): Promise<void> {
    const req = await this.authenticatedFetch(
      `${this.url}/${encodePageURI(path)}`,
      {
        method: "DELETE",
      },
    );
    if (req.status !== 200) {
      throw Error(`Failed to delete file: ${req.statusText}`);
    }
  }

  async getFileMeta(path: string, observing?: boolean): Promise<FileMeta> {
    const res = await this.authenticatedFetch(
      `${this.url}/${encodePageURI(path)}`,
      // This used to use HEAD, but it seems that Safari on iOS is blocking cookies/credentials to be sent along with HEAD requests
      // so we'll use GET instead with a magic header which the server may or may not use to omit the body.
      {
        method: "GET",
        headers: {
          "X-Get-Meta": "true",
          ...(observing ? { "X-Observing": "true" } : {}),
        },
      },
    );
    if (res.status === 404) {
      throw notFoundError;
    }
    if (!res.ok) {
      throw new Error(`Failed to get file meta: ${res.statusText}`);
    }
    return headersToFileMeta(path, res.headers)!;
  }

  // If not: throws an error or invokes a redirect
  async ping() {
    const parentEndpoint = this.url.split("/").slice(0, -1).join("/") +
      "/.ping";
    const resp = await this.authenticatedFetch(parentEndpoint, {
      method: "GET",
      headers: {
        Accept: "application/json",
      },
    }, pingTimeout);

    if (!resp.ok) {
      throw new Error(`Ping failed: ${resp.status} ${resp.statusText}`);
    }

    await this.validateSpacePathFromHeaders(resp);

    // Consume the response body to avoid leaks
    await resp.text();
  }
}


================================================
FILE: client/spaces/space_primitives.test.ts
================================================
import { assert, assertEquals } from "@std/assert";
import type { SpacePrimitives } from "./space_primitives.ts";
import type { FileMeta } from "@silverbulletmd/silverbullet/type/index";
import { notFoundError } from "@silverbulletmd/silverbullet/constants";

export async function testSpacePrimitives(spacePrimitives: SpacePrimitives) {
  await testBasicOperations(spacePrimitives);
  await testFileOverwriting(spacePrimitives);
  await testEmptyFiles(spacePrimitives);
  await testUnicodeContent(spacePrimitives);
  await testSpecialFileNames(spacePrimitives);
  await testErrorHandling(spacePrimitives);
  await testLargeFiles(spacePrimitives);
  await testMetadataPreservation(spacePrimitives);

  // Ensure clean state at the end
  const finalFiles = await spacePrimitives.fetchFileList();
  assertEquals(finalFiles, []);
}

async function testBasicOperations(spacePrimitives: SpacePrimitives) {
  const files = await spacePrimitives.fetchFileList();
  assertEquals(files, []);

  // Write text file
  const fileMeta = await spacePrimitives.writeFile(
    "test.txt",
    stringToBytes("Hello World"),
    {
      name: "test.txt",
      perm: "rw",
      created: 10,
      contentType: "text/plain",
      lastModified: 20,
      size: 11,
    },
  );

  const { data: retrievedData, meta: retrievedMeta } = await spacePrimitives
    .readFile("test.txt");

  assertEquals(retrievedData, stringToBytes("Hello World"));
  // Check that the meta data is persisted
  assertEquals(retrievedMeta.lastModified, 20);

  const fbContent = (await spacePrimitives.readFile("test.txt"))
    .data;
  assertEquals(new TextDecoder().decode(fbContent), "Hello World");

  assertEquals(await spacePrimitives.fetchFileList(), [fileMeta]);

  // Write binary file
  const buf = new Uint8Array(1024 * 1024);
  buf.set([1, 2, 3, 4, 5]);
  await spacePrimitives.writeFile("test.bin", buf);
  const fileData = await spacePrimitives.readFile("test.bin");
  assertEquals(fileData.data.length, 1024 * 1024);
  assertEquals((await spacePrimitives.fetchFileList()).length, 2);

  await spacePrimitives.deleteFile("test.bin");
  assertEquals(await spacePrimitives.fetchFileList(), [fileMeta]);

  // Clean up
  await spacePrimitives.deleteFile("test.txt");
  assertEquals(await spacePrimitives.fetchFileList(), []);

  // Test weird file names
  await spacePrimitives.writeFile("test+'s.txt", stringToBytes("Hello world!"));
  assertEquals(
    stringToBytes("Hello world!"),
    (await spacePrimitives.readFile("test+'s.txt")).data,
  );
  await spacePrimitives.deleteFile("test+'s.txt");

  // Check deletion of weird file file name
  try {
    await spacePrimitives.getFileMeta("test+'s.txt");
    assert(false);
  } catch (e: any) {
    assertEquals(e, notFoundError);
  }
}

async function testFileOverwriting(spacePrimitives: SpacePrimitives) {
  // Test overwriting existing files
  await spacePrimitives.writeFile("overwrite.txt", stringToBytes("Original"));
  const _originalMeta = await spacePrimitives.getFileMeta("overwrite.txt");

  await spacePrimitives.writeFile("overwrite.txt", stringToBytes("Updated"));
  const updatedData = await spacePrimitives.readFile("overwrite.txt");
  assertEquals(new TextDecoder().decode(updatedData.data), "Updated");

  // File list should still have only one entry for this file
  const filesAfterOverwrite = await spacePrimitives.fetchFileList();
  const overwriteFiles = filesAfterOverwrite.filter((f) =>
    f.name === "overwrite.txt"
  );
  assertEquals(overwriteFiles.length, 1);

  await spacePrimitives.deleteFile("overwrite.txt");
}

async function testEmptyFiles(spacePrimitives: SpacePrimitives) {
  // Test empty file
  await spacePrimitives.writeFile("empty.txt", new Uint8Array(0));
  const emptyFile = await spacePrimitives.readFile("empty.txt");
  assertEquals(emptyFile.data.length, 0);
  assertEquals(emptyFile.meta.size, 0);
  await spacePrimitives.deleteFile("empty.txt");
}

async function testUnicodeContent(spacePrimitives: SpacePrimitives) {
  // Test files with Unicode characters
  const unicodeContent = "Hello 世界! 🌍 Здравствуй мир!";
  await spacePrimitives.writeFile("unicode.txt", stringToBytes(unicodeContent));
  const unicodeFile = await spacePrimitives.readFile("unicode.txt");
  assertEquals(new TextDecoder().decode(unicodeFile.data), unicodeContent);
  await spacePrimitives.deleteFile("unicode.txt");
}

async function testSpecialFileNames(spacePrimitives: SpacePrimitives) {
  // Test file names with various special characters
  const specialNames = [
    "file with spaces.txt",
    "file-with-hyphens.txt",
    "file_with_underscores.txt",
    "file.with.dots.txt",
    "UPPERCASE.TXT",
    "123numeric.txt",
    "émojis🚀file.txt",
  ];

  for (const fileName of specialNames) {
    await spacePrimitives.writeFile(
      fileName,
      stringToBytes(`Content of ${fileName}`),
    );
    const fileData = await spacePrimitives.readFile(fileName);
    assertEquals(
      new TextDecoder().decode(fileData.data),
      `Content of ${fileName}`,
    );
  }

  // Verify all special files are in the list
  const allFiles = await spacePrimitives.fetchFileList();
  for (const fileName of specialNames) {
    const found = allFiles.find((f) => f.name === fileName);
    assert(found, `File ${fileName} should be in the file list`);
  }

  // Clean up special files
  for (const fileName of specialNames) {
    await spacePrimitives.deleteFile(fileName);
  }
}

async function testErrorHandling(spacePrimitives: SpacePrimitives) {
  // Test error cases
  try {
    await spacePrimitives.readFile("nonexistent.txt");
    assert(false, "Should throw error for non-existent file");
  } catch (e: any) {
    assertEquals(e, notFoundError);
  }

  try {
    await spacePrimitives.deleteFile("nonexistent.txt");
    assert(false, "Should throw error when deleting non-existent file");
  } catch (e: any) {
    assertEquals(e, notFoundError);
  }
}

async function testLargeFiles(spacePrimitives: SpacePrimitives) {
  // Test large file content
  const largeContent = new Uint8Array(5 * 1024 * 1024); // 5MB
  for (let i = 0; i < largeContent.length; i++) {
    largeContent[i] = i % 256;
  }

  await spacePrimitives.writeFile("large.bin", largeContent);
  const largeFile = await spacePrimitives.readFile("large.bin");
  assertEquals(largeFile.data.length, largeContent.length);
  assertEquals(largeFile.meta.size, largeContent.length);

  // Verify content integrity
  for (let i = 0; i < Math.min(1000, largeContent.length); i++) {
    assertEquals(largeFile.data[i], largeContent[i]);
  }

  await spacePrimitives.deleteFile("large.bin");
}

async function testMetadataPreservation(spacePrimitives: SpacePrimitives) {
  // Test metadata preservation
  const testContent = stringToBytes("Hello meta!");
  const customMeta: FileMeta = {
    name: "meta-test.txt",
    perm: "rw",
    created: 1000000,
    contentType: "text/plain", // Use a content type that matches the file extension
    lastModified: 2000000,
    size: testContent.length, // Use actual content length
  };

  await spacePrimitives.writeFile(
    "meta-test.txt",
    testContent,
    customMeta,
  );
  const metaFile = await spacePrimitives.readFile("meta-test.txt");

  // Check that some metadata is preserved (implementations may handle timestamps differently)
  assert(
    metaFile.meta.lastModified > 0,
    "LastModified timestamp should be set",
  );
  assertEquals(metaFile.meta.name, "meta-test.txt");
  assertEquals(metaFile.meta.size, testContent.length);

  await spacePrimitives.deleteFile("meta-test.txt");
}

function stringToBytes(str: string): Uint8Array {
  return new TextEncoder().encode(str);
}


================================================
FILE: client/spaces/space_primitives.ts
================================================
import type { FileMeta } from "../../plug-api/types/index.ts";

/**
 * A generic interface used by `Space` to interact with the underlying storage, designed to be easy to implement for different storage backends
 */
export interface SpacePrimitives {
  fetchFileList(): Promise<FileMeta[]>;

  /**
   * Retrieves metadata for a specific file.
   * @param path The path of the file to retrieve metadata for.
   * @param observing used to hint at the sync engine this file is under regular observation (and may sync more aggressively)
   */
  getFileMeta(path: string, observing?: boolean): Promise<FileMeta>;

  readFile(
    path: string,
  ): Promise<{ data: Uint8Array; meta: FileMeta }>;

  writeFile(
    path: string,
    data: Uint8Array,
    // May be ignored, but ideally should be used to set the lastModified time
    meta?: FileMeta,
  ): Promise<FileMeta>;

  deleteFile(path: string): Promise<void>;
}


================================================
FILE: client/spaces/sync.test.ts
================================================
import { SpaceSync, SyncSnapshot } from "./sync.ts";
import { assertEquals } from "@std/assert";
import { sleep } from "@silverbulletmd/silverbullet/lib/async";
import { assert } from "node:console";
import { MemoryKvPrimitives } from "../data/memory_kv_primitives.ts";
import { DataStoreSpacePrimitives } from "./datastore_space_primitives.ts";

Deno.test("Test sync with no filtering", async () => {
  const primary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
  const secondary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
  const snapshot = new SyncSnapshot();
  const sync = new SpaceSync(primary, secondary, {
    conflictResolver: SpaceSync.primaryConflictResolver,
    isSyncCandidate: () => true, // Sync everything always
  });

  console.log("Write one page to primary");
  await primary.writeFile("index.md", stringToBytes("Hello"));
  assertEquals((await secondary.fetchFileList()).length, 0);
  console.log("Initial sync ops", await doSync());

  assertEquals((await secondary.fetchFileList()).length, 1);
  assertEquals(
    (await secondary.readFile("index.md")).data,
    stringToBytes("Hello"),
  );

  // Should be a no-op
  let ops = await doSync();
  assertEquals(ops, 0);
  assertEquals(snapshot.nonSyncedFiles.size, 0);

  // Now let's make a change on the secondary
  await secondary.writeFile("index.md", stringToBytes("Hello!!"));
  await secondary.writeFile("test.md", stringToBytes("Test page"));

  // And sync it
  ops = await doSync();
  assertEquals(ops, 2);
  assertEquals(snapshot.nonSyncedFiles.size, 0);

  assertEquals((await primary.fetchFileList()).length, 2);
  assertEquals((await secondary.fetchFileList()).length, 2);

  assertEquals(
    (await primary.readFile("index.md")).data,
    stringToBytes("Hello!!"),
  );

  // Let's make some random edits on both ends
  await primary.writeFile("index.md", stringToBytes("1"));
  await primary.writeFile("index2.md", stringToBytes("2"));
  await secondary.writeFile("index3.md", stringToBytes("3"));
  await secondary.writeFile("index4.md", stringToBytes("4"));
  await doSync();

  assertEquals((await primary.fetchFileList()).length, 5);
  assertEquals((await secondary.fetchFileList()).length, 5);

  ops = await doSync();
  assertEquals(ops, 0);

  console.log("Deleting pages");
  // Delete some pages
  await primary.deleteFile("index.md");
  await primary.deleteFile("index3.md");

  await doSync();

  assertEquals((await primary.fetchFileList()).length, 3);
  assertEquals((await secondary.fetchFileList()).length, 3);

  // No-op
  ops = await doSync();
  assertEquals(ops, 0);

  await secondary.deleteFile("index4.md");
  await primary.deleteFile("index2.md");

  await doSync();

  // Just "test" left
  assertEquals((await primary.fetchFileList()).length, 1);
  assertEquals((await secondary.fetchFileList()).length, 1);

  // No-op
  ops = await doSync();
  assertEquals(ops, 0);

  await secondary.writeFile("index.md", stringToBytes("I'm back"));

  await doSync();

  assertEquals(
    (await primary.readFile("index.md")).data,
    stringToBytes("I'm back"),
  );

  // Cause a conflict
  console.log("Introducing a conflict now");
  await primary.writeFile("index.md", stringToBytes("Hello 1"));
  await secondary.writeFile("index.md", stringToBytes("Hello 2"));

  await doSync();

  // Sync conflicting copy back
  await doSync();

  // Verify that primary won
  assertEquals(
    (await primary.readFile("index.md")).data,
    stringToBytes("Hello 1"),
  );
  assertEquals(
    (await secondary.readFile("index.md")).data,
    stringToBytes("Hello 1"),
  );

  // test + index + index.conflicting copy
  assertEquals((await primary.fetchFileList()).length, 3);
  assertEquals((await secondary.fetchFileList()).length, 3);

  // Introducing a fake conflict (same content, so not really conflicting)
  await primary.writeFile("index.md", stringToBytes("Hello 1"));
  await secondary.writeFile("index.md", stringToBytes("Hello 1"));

  await doSync();
  await doSync();

  // test + index + index.md + previous index.conflicting copy but nothing more
  assertEquals((await primary.fetchFileList()).length, 3);

  async function doSync() {
    await sleep(10);
    const r = await sync.syncFiles(snapshot);
    await sleep(10);
    return r;
  }
});

Deno.test("Test sync with filtering", async () => {
  const primary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
  const secondary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());

  const snapshot = new SyncSnapshot();
  let sync = new SpaceSync(
    primary,
    secondary,
    {
      conflictResolver: SpaceSync.primaryConflictResolver,
      isSyncCandidate: (path) => path.endsWith(".md"), // Only sync .md files
    },
  );

  console.log(
    "Write one non-sync file on the primary, which SHOULD sync to the secondary",
  );
  await primary.writeFile("index.txt", stringToBytes("Hello"));
  assertEquals((await secondary.fetchFileList()).length, 0);
  let ops = await doSync();
  assertEquals(ops, 1);
  // Note: this number should be 0 because the file DOES have a local representation so it's not technically non-synced
  assertEquals(snapshot.nonSyncedFiles.size, 0);

  assertEquals((await secondary.fetchFileList()).length, 1);
  assertEquals(
    (await secondary.readFile("index.txt")).data,
    stringToBytes("Hello"),
  );

  console.log("Updating on secondary");
  await secondary.writeFile("index.txt", stringToBytes("Hello Updated"));
  ops = await doSync();
  assertEquals(ops, 1);
  assertEquals(snapshot.nonSyncedFiles.size, 1);
  try {
    await primary.getFileMeta("index.md");
    assert(
      false,
      "Local file should have been deleted locally since it's out of date",
    );
  } catch {
    // Expected
  }

  console.log("Deleting remote files");
  await secondary.deleteFile("index.txt");
  ops = await doSync();
  assertEquals(ops, 1);
  assertEquals(snapshot.nonSyncedFiles.size, 0);

  console.log("Creating a remote non-synced file");
  await secondary.writeFile("index2.txt", stringToBytes("Hello 2"));
  ops = await doSync();
  assertEquals(ops, 0); // No-op, metadata only
  assertEquals(snapshot.nonSyncedFiles.size, 1);
  ops = await doSync();
  assertEquals(ops, 0); // No-op, metadata only
  assertEquals(snapshot.nonSyncedFiles.size, 1);

  await primary.writeFile("index2.txt", stringToBytes("Hello local"));
  ops = await doSync();
  assertEquals(ops, 1);
  assertEquals(snapshot.nonSyncedFiles.size, 0);

  console.log("Getting into a state with some synced and non-synced files");
  await secondary.writeFile("index.md", stringToBytes("This will sync"));
  await secondary.writeFile("index.txt", stringToBytes("This will not sync"));
  await secondary.writeFile("index2.txt", stringToBytes("This will not sync"));
  ops = await doSync();
  assertEquals(ops, 2);
  assertEquals(snapshot.nonSyncedFiles.size, 2);

  // Check file listings on both ends
  assertEquals((await secondary.fetchFileList()).length, 3);
  assertEquals((await primary.fetchFileList()).length, 1);

  ////////////
  // Now let's start another sync session, but now wanting to sync everything
  console.log("Going to switch to syncing everything now");
  sync = new SpaceSync(
    primary,
    secondary,
    {
      conflictResolver: SpaceSync.primaryConflictResolver,
      isSyncCandidate: () => true,
    },
  );

  ops = await doSync();
  // This should pull 2 files from remote to local
  assertEquals(ops, 2);
  assertEquals((await primary.fetchFileList()).length, 3);
  assertEquals((await secondary.fetchFileList()).length, 3);

  console.log("And now to syncing nothing");
  sync = new SpaceSync(
    primary,
    secondary,
    {
      conflictResolver: SpaceSync.primaryConflictResolver,
      isSyncCandidate: () => false,
    },
  );

  ops = await doSync();
  // This should delete 3 files from the primary
  assertEquals(ops, 3);
  // Leaving nothing on primary
  assertEquals((await primary.fetchFileList()).length, 0);
  // And everything unchanged on secondary
  assertEquals((await secondary.fetchFileList()).length, 3);

  // Ok, now we're going to sync everything again
  sync = new SpaceSync(
    primary,
    secondary,
    {
      conflictResolver: SpaceSync.primaryConflictResolver,
      isSyncCandidate: () => true,
    },
  );
  // await secondary.
  ops = await doSync();
  assertEquals(ops, 3);
  assertEquals((await primary.fetchFileList()).length, 3);
  assertEquals((await secondary.fetchFileList()).length, 3);

  async function doSync() {
    await sleep(10);
    const r = await sync.syncFiles(snapshot);
    return r;
  }
});

Deno.test("Local push sync", async () => {
  const primary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
  const secondary = new DataStoreSpacePrimitives(new MemoryKvPrimitives());
  const snapshot = new SyncSnapshot();
  const sync = new SpaceSync(primary, secondary, {
    conflictResolver: SpaceSync.primaryConflictResolver,
    isSyncCandidate: (path) => path.endsWith(".md"), // Only sync .md files
  });

  console.log(
    "Write one non-sync file on the primary, which SHOULD sync to the secondary",
  );

  const operations = await sync.syncFiles(snapshot);
  assertEquals(operations, 0);

  await primary.writeFile("index.md", stringToBytes("Hello"));
  assertEquals(1, await sync.syncSingleFile("index.md", snapshot));

  assertEquals(
    (await secondary.readFile("index.md")).data,
    stringToBytes("Hello"),
  );

  console.log("Let's write a new file on primary that is not a sync candidate");
  await primary.writeFile("test.txt", stringToBytes("Hello"));
  assertEquals(1, await sync.syncSingleFile("test.txt", snapshot));
  assertEquals(snapshot.nonSyncedFiles.size, 0);
});

function stringToBytes(s: string): Uint8Array {
  return new TextEncoder().encode(s);
}


================================================
FILE: client/spaces/sync.ts
================================================
/**
 * This is where the sync magic happens
 */
import type { SpacePrimitives } from "./space_primitives.ts";
import { EventEmitter } from "../plugos/event.ts";
import type { FileMeta } from "@silverbulletmd/silverbullet/type/index";
import { notFoundError } from "@silverbulletmd/silverbullet/constants";
import { processWithConcurrency } from "@silverbulletmd/silverbullet/lib/async";

const syncConcurrency = 3;

// In practice this is the lastModified timestamp
type SyncHash = number;

// Tuple where the first value represents a lastModified timestamp for the primary space
// and the second item the lastModified value of the secondary space
export type SyncStatusItem = [SyncHash, SyncHash];

export class SyncSnapshot {
  constructor(
    public files: Map<string, SyncStatusItem> = new Map(),
    public nonSyncedFiles: Map<string, FileMeta> = new Map(),
  ) {
    this.files = files;
    this.nonSyncedFiles = nonSyncedFiles;
  }

  toJSON(): any {
    return {
      files: Object.fromEntries(this.files),
      nonSyncedFiles: Object.fromEntries(this.nonSyncedFiles),
    };
  }

  static fromJSON(json: any | undefined): SyncSnapshot {
    return new SyncSnapshot(
      new Map(Object.entries(json?.files || {})),
      new Map(Object.entries(json?.nonSyncedFiles || {})),
    );
  }
}

export type SyncStatus = {
  filesProcessed: number;
  totalFiles: number;
};

export type SyncOptions = {
  conflictResolver: (
    path: string,
    snapshot: SyncSnapshot,
    primarySpace: SpacePrimitives,
    secondarySpace: SpacePrimitives,
  ) => Promise<number>;
  isSyncCandidate: (path: string) => boolean;
};

type SyncDirection = "primary->secondary" | "secondary->primary";

export type SyncEvents = {
  syncProgress: (
    syncStatus: SyncStatus,
    snapshot: SyncSnapshot,
  ) => void | Promise<void>;
  snapshotUpdated: (snapshot: SyncSnapshot) => void | Promise<void>;
};

// Implementation of this algorithm: https://unterwaditzer.net/2016/sync-algorithm.html
export class SpaceSync extends EventEmitter<SyncEvents> {
  // Sync mutex (only one sync operation at a time)
  private isSyncing = false;

  constructor(
    private primary: SpacePrimitives,
    private secondary: SpacePrimitives,
    readonly options: SyncOptions,
  ) {
    super();
  }

  /**
   * Syncs all files in space.
   * If this completes successfully (with operations >= 0), a full space sync completed successfully.
   * @param snapshot - The current snapshot of the space (will be updated in place)
   * @returns number of operations performed, or -1 when sync was already ongoing and nonSynced files
   */
  public async syncFiles(snapshot: SyncSnapshot): Promise<number> {
    let operations = 0;

    // Mutex behavior, only sync can happen at a time
    if (this.isSyncing) {
      console.warn("Sync already in progress...");
      return -1;
    }
    this.isSyncing = true;
    console.log("[sync]", "Performing a full sync cycle...");
    const startTime = Date.now();

    try {
      const primaryAllPages = await this.primary.fetchFileList();
      const secondaryAllPages = await this.secondary.fetchFileList();

      // Assumption: the primary is local, and _all_ files should be synced to the secondary
      // However, the secondary is remote, and only a subset of files should be synced from it
      const nonSyncCandidates = this.getNonSyncCandidates(secondaryAllPages);

      const primaryFileMap = new Map<string, FileMeta>(
        primaryAllPages.map((m) => [m.name, m]),
      );
      const secondaryFileMap = new Map<string, FileMeta>(
        secondaryAllPages.map((m) => [m.name, m]),
      );

      const allFilesToProcess = new Set([
        ...snapshot.files.keys(),
        ...primaryFileMap.keys(),
        ...secondaryFileMap.keys(),
      ]);

      const sortedPaths = [...allFilesToProcess];
      sortedPaths.sort((a) => {
        // Just make sure that plug files appear first
        // This is important for the initial sync: plugs are loaded the moment they are pulled into the space,
        // which would activate e.g. any indexing logic for the remaining space content
        // TODO: To verify if this is still true today
        return a.endsWith(".plug.js") ? -1 : 1;
      });
      // console.log("[sync]", "Iterating over all files");
      let filesProcessed = 0;
      await processWithConcurrency(sortedPaths, async (path) => {
        const fileOperations = await this.syncFile(
          path,
          primaryFileMap.get(path),
          secondaryFileMap.get(path),
          !nonSyncCandidates.has(path),
          snapshot,
        );
        operations = operations + fileOperations;
        filesProcessed++;
        if (fileOperations > 0) {
          // Only report something significant
          await this.emit("syncProgress", {
            filesProcessed,
            totalFiles: sortedPaths.length,
          }, snapshot);
        }
      }, syncConcurrency);
      console.log(
        "[Sync]",
        "Completed:",
        operations,
        "operations in",
        ((Date.now() - startTime) / 1000) + "s",
      );
    } finally {
      this.isSyncing = false;
      if (operations > 0) {
        this.emit("snapshotUpdated", snapshot);
      }
    }

    return operations;
  }

  /**
   * Syncs a single file from primary to secondary.
   * Used to more actively sync currently open files.
   * @returns number of operations performed, or -1 when sync was already ongoing
   */
  public async syncSingleFile(
    path: string,
    snapshot: SyncSnapshot,
  ): Promise<number> {
    // Mutex behavior, only sync cycle can happen at a time
    if (this.isSyncing) {
      console.warn("[sync]", "Sync already in progress...");
      return -1;
    }
    if (snapshot.nonSyncedFiles.has(path)) {
      console.info(
        "[sync]",
        "Was asked to sync marked as non-synced, skipping",
        path,
      );
      return 0;
    }

    this.isSyncing = true;
    console.log("[sync]", "Performing a single file sync", path);

    let operations = 0;

    try {
      const primaryMeta = await this.primary.getFileMeta(path);
      let secondaryMeta: FileMeta | undefined;
      try {
        secondaryMeta = await this.secondary.getFileMeta(path);
      } catch (e: any) {
        if (e.message === notFoundError.message) {
          // File doesn't exist remotely, that's ok
        } else {
          throw e;
        }
      }
      operations = await this.syncFile(
        path,
        primaryMeta,
        secondaryMeta,
        true,
        snapshot,
      );
    } finally {
      this.isSyncing = false;
      if (operations > 0) {
        this.emit("snapshotUpdated", snapshot);
      }
    }

    return operations;
  }

  /**
   * Syncs a single file
   * @param path The path of the file to sync
   * @param primaryMeta The metadata of the file on the primary space
   * @param secondaryMeta The metadata of the file on the secondary space
   * @param syncBack Whether this a file that is marked to be synced
   * @param snapshot The snapshot of the file status (updated in place!)
   * @returns The number of operations performed
   */
  private async syncFile(
    path: string,
    primaryMeta: FileMeta | undefined,
    secondaryMeta: FileMeta | undefined,
    syncBack: boolean,
    snapshot: SyncSnapshot,
  ): Promise<number> {
    let operations = 0;

    if (
      primaryMeta !== undefined && secondaryMeta === undefined &&
      !snapshot.files.has(path)
    ) {
      // New file, created on primary, copy from primary to secondary
      console.log(
        "[sync]",
        "New file created on primary, copying to secondary",
        path,
      );
      const { data, meta } = await this.primary.readFile(path);
      const writtenMeta = await this.secondary.writeFile(
        path,
        data,
        meta,
      );
      // Update file snapshot
      snapshot.files.set(path, [
        meta.lastModified,
        writtenMeta.lastModified,
      ]);
      // Let's make sure this file is not marked as nonSynced, because we have a local copy
      snapshot.nonSyncedFiles.delete(path);
      operations++;
    } else if (
      secondaryMeta !== undefined && ((
        // Scenario: a new file was created on the secondary
        primaryMeta === undefined &&
        !snapshot.files.has(path)
      ) || (
        // Scenario: a file that was previously unsynced, should now be synced
        snapshot.nonSyncedFiles.has(path) && syncBack &&
        // because we don't have it yet
        primaryMeta === undefined
      ))
    ) {
      // New file to sync, present on secondary
      if (syncBack) {
        // Copy from secondary to primary
        console.log(
          "[sync]",
          "New file to sync on secondary, copying from secondary to primary",
          path,
        );
        const { data, meta } = await this.secondary.readFile(path);
        const writtenMeta = await this.primary.writeFile(
          path,
          data,
          meta,
        );
        // Update file snapshot
        snapshot.files.set(path, [
          writtenMeta.lastModified,
          meta.lastModified,
        ]);
        // Make sure the file is not marked as nonSynced anymore
        snapshot.nonSyncedFiles.delete(path);
        operations++;
      } else { // !syncBack
        // This has syncBack set to false, which means we'll just update the nonSyncedFiles and snapshot
        console.log(
          "[sync]",
          "New file created on secondary, but only updating metadata",
          path,
        );
        snapshot.files.set(path, [
          secondaryMeta.lastModified,
          secondaryMeta.lastModified,
        ]);
        snapshot.nonSyncedFiles.set(path, secondaryMeta);
      }
    } else if (
      primaryMeta !== undefined && snapshot.files.has(path) &&
      secondaryMeta === undefined
    ) {
      // File deleted on secondary
      if (syncBack) {
        snapshot.files.delete(path);
        if (snapshot.nonSyncedFiles.has(path)) {
          // This is the scenario where in the previous sync this file was not synced while in this new one it is
          console.log(
            "[sync]",
            "File deleted on secondary, but wasn't synced on primary, so skipping",
            path,
          );
          // Keeping non-synced files up-to-date
          snapshot.nonSyncedFiles.delete(path);
        } else {
          console.log(
            "[sync]",
            "File deleted on secondary, deleting from primary",
            path,
          );
          await this.primary.deleteFile(path);
        }
        operations++;
      } else { // !syncBack
        console.log(
          "[sync]",
          "File deleted on secondary, only updating snapshot",
          path,
        );
        try {
          // It's possible that there is a local copy anyway (if it started out there, so let's attempt to delete it just in case, but fail silently)
          await this.primary.deleteFile(path);
          console.info("[sync]", "Deleted local copy on primary", path);
          operations++;
        } catch {
          // Fail silently, likely the file doesn't exist
        }
        snapshot.files.delete(path);
        snapshot.nonSyncedFiles.delete(path);
      }
    } else if (
      // The file is on the secondary, and not on the primary
      secondaryMeta !== undefined && snapshot.files.has(path) &&
      primaryMeta === undefined &&
      // And we're talking about a synced file
      syncBack
    ) {
      // File deleted on primary
      console.log(
        "[sync]",
        "File deleted on primary, deleting from secondary",
        path,
      );
      await this.secondary.deleteFile(path);
      snapshot.files.delete(path);
      snapshot.nonSyncedFiles.delete(path);
      operations++;
    } else if (
      snapshot.files.has(path) && primaryMeta === undefined &&
      secondaryMeta === undefined
    ) {
      // File deleted on both sides, :shrug:
      console.log(
        "[sync]",
        "File deleted on both ends, deleting from snapshot",
        path,
      );
      snapshot.files.delete(path);
      snapshot.nonSyncedFiles.delete(path);
      operations++;
    } else if (
      primaryMeta !== undefined && secondaryMeta !== undefined &&
      snapshot.files.get(path) &&
      primaryMeta.lastModified !== snapshot.files.get(path)![0] &&
      secondaryMeta.lastModified === snapshot.files.get(path)![1]
    ) {
      // File has changed on primary, but not secondary: copy from primary to secondary
      console.log(
        "[sync]",
        "File changed on primary, copying to secondary",
        path,
      );
      const { data, meta } = await this.primary.readFile(path);
      const writtenMeta = await this.secondary.writeFile(
        path,
        data,
        meta,
      );
      snapshot.files.set(path, [
        meta.lastModified,
        writtenMeta.lastModified,
      ]);
      // Delete from non-synced files just in case, because we clearly have a local copy
      snapshot.nonSyncedFiles.delete(path);
      operations++;
    } else if (
      primaryMeta !== undefined && secondaryMeta !== undefined &&
      snapshot.files.get(path) &&
      primaryMeta.lastModified === snapshot.files.get(path)![0] &&
      secondaryMeta.lastModified !== snapshot.files.get(path)![1]
    ) {
      // File has changed on secondary, but not primary
      if (syncBack) {
        // Copy from secondary to primary
        console.log(
          "[sync]",
          "File has changed on secondary, but not primary: copy from secondary to primary",
          path,
        );
        const { data, meta } = await this.secondary.readFile(path);
        const writtenMeta = await this.primary.writeFile(
          path,
          data,
          meta,
        );
        snapshot.files.set(path, [
          writtenMeta.lastModified,
          meta.lastModified,
        ]);
        // Make sure it's not in nonSyncedFiles
        snapshot.nonSyncedFiles.delete(path);
        operations++;
      } else { // !syncBack
        console.log(
          "[sync]",
          "File has changed on secondary, but not primary: shouldn't be synced",
          path,
        );
        snapshot.files.set(path, [
          secondaryMeta.lastModified,
          secondaryMeta.lastModified,
        ]);
        snapshot.nonSyncedFiles.set(path, secondaryMeta);
        try {
          // It may be that the file does exist locally, but it will now be out of date, just delete it
          await this.primary.deleteFile(path);
          console.info(
            "[sync]",
            "Deleted local (out of date) copy on primary",
            path,
          );
          operations++;
        } catch {
          // Fail silently, likely the file doesn't exist
        }
      }
    } else if (
      ( // File changed on both ends, but we don't have any info in the snapshot (resync scenario?): have to run through conflict handling
        primaryMeta !== undefined && secondaryMeta !== undefined &&
        !snapshot.files.has(path)
      ) ||
      ( // File changed on both ends, CONFLICT!
        primaryMeta !== undefined && secondaryMeta !== undefined &&
        snapshot.files.get(path) &&
        primaryMeta.lastModified !== snapshot.files.get(path)![0] &&
        secondaryMeta.lastModified !== snapshot.files.get(path)![1]
      )
    ) {
      console.log(
        "[sync]",
        "File changed on both ends, potential conflict",
        path,
      );
      operations += await this.options.conflictResolver(
        path,
        snapshot,
        this.primary,
        this.secondary,
      );
    } else if (primaryMeta && secondaryMeta && !syncBack) {
      console.log(
        "[sync]",
        "File present locally, but no longer synced, deleting local copy of",
        path,
      );
      await this.primary.deleteFile(path);
      snapshot.nonSyncedFiles.set(path, secondaryMeta);
      operations += 1;
    } else {
      // Nothing needs to happen
      if (!syncBack && secondaryMeta) {
        snapshot.nonSyncedFiles.set(path, secondaryMeta);
      }
    }
    // End scene
    return operations;
  }

  getNonSyncCandidates(files: FileMeta[]): Map<string, FileMeta> {
    const nonSyncCandidates: Map<string, FileMeta> = new Map();
    files.forEach((meta) => {
      if (!this.options.isSyncCandidate(meta.name)) {
        nonSyncCandidates.set(meta.name, meta);
      }
    });
    return nonSyncCandidates;
  }

  // Strategy: Primary wins
  public static async primaryConflictResolver(
    path: string,
    snapshot: SyncSnapshot,
    primary: SpacePrimitives,
    secondary: SpacePrimitives,
  ): Promise<number> {
    console.log("[sync]", "Starting conflict resolution for", path);
    const filePieces = path.split(".");
    const fileNameBase = filePieces.slice(0, -1).join(".");
    const fileNameExt = filePieces[filePieces.length - 1];
    const pageData1 = await primary.readFile(path);
    const pageData2 = await secondary.readFile(path);

    let byteWiseMatch = true;
    const arrayBuffer1 = pageData1.data;
    const arrayBuffer2 = pageData2.data;
    if (arrayBuffer1.byteLength !== arrayBuffer2.byteLength) {
      byteWiseMatch = false;
    }
    if (byteWiseMatch) {
      // Byte-wise comparison
      for (let i = 0; i < arrayBuffer1.byteLength; i++) {
        if (arrayBuffer1[i] !== arrayBuffer2[i]) {
          byteWiseMatch = false;
          break;
        }
      }
      // Byte wise they're still the same, so no confict
      if (byteWiseMatch) {
        console.log("[sync]", "Files are the same, no conflict");

        snapshot.files.set(path, [
          pageData1.meta.lastModified,
          pageData2.meta.lastModified,
        ]);
        return 0;
      }
    }

    let operations = 0;
    const revisionFileName = filePieces.length === 1
      ? `${path}.conflicted:${pageData2.meta.lastModified}`
      : `${fileNameBase}.conflicted:${pageData2.meta.lastModified}.${fileNameExt}`;
    console.log(
      "[sync]",
      "Going to create conflicting copy",
      revisionFileName,
    );

    // Copy secondary to conflict copy
    const localConflictMeta = await primary.writeFile(
      revisionFileName,
      pageData2.data,
    );
    operations++;
    const remoteConflictMeta = await secondary.writeFile(
      revisionFileName,
      pageData2.data,
    );
    operations++;

    // Write replacement on top
    const writeMeta = await secondary.writeFile(
      path,
      pageData1.data,
    );
    operations++;

    // Updating snapshot
    snapshot.files.set(revisionFileName, [
      localConflictMeta.lastModified,
      remoteConflictMeta.lastModified,
    ]);

    snapshot.files.set(path, [
      pageData1.meta.lastModified,
      writeMeta.lastModified,
    ]);
    return operations;
  }
}


================================================
FILE: client/style.ts
================================================
import { HighlightStyle } from "@codemirror/language";
import { tagHighlighter, tags as t } from "@lezer/highlight";
import * as ct from "./markdown_parser/customtags.ts";

export default function highlightStyles() {
  tagHighlighter;
  return HighlightStyle.define([
    { tag: t.heading1, class: "sb-h1" },
    { tag: t.heading2, class: "sb-h2" },
    { tag: t.heading3, class: "sb-h3" },
    { tag: t.link, class: "sb-link" },
    { tag: t.meta, class: "sb-meta" },
    { tag: t.quote, class: "sb-quote" },
    { tag: t.monospace, class: "sb-code" },
    { tag: ct.WikiLinkPartTag, class: "sb-wiki-link" },
    { tag: ct.AttributeTag, class: "sb-frontmatter" },
    { tag: ct.AttributeNameTag, class: "sb-attribute-name" },
    { tag: ct.AttributeValueTag, class: "sb-attribute-value" },
    { tag: ct.TaskTag, class: "sb-task" },
    { tag: ct.TaskMarkTag, class: "sb-task-mark" },
    { tag: ct.TaskStateTag, class: "sb-task-state" },
    { tag: ct.CodeInfoTag, class: "sb-code-info" },
    { tag: ct.CommentTag, class: "sb-comment" },
    { tag: ct.CommentMarkerTag, class: "sb-comment-marker" },
    { tag: ct.Highlight, class: "sb-highlight" },
    { tag: t.emphasis, class: "sb-emphasis" },
    { tag: t.strong, class: "sb-strong" },
    { tag: t.atom, class: "sb-atom" },
    { tag: t.bool, class: "sb-bool" },
    { tag: t.url, class: "sb-url" },
    { tag: t.inserted, class: "sb-inserted" },
    { tag: t.deleted, class: "sb-deleted" },
    { tag: t.literal, class: "sb-literal" },
    { tag: t.keyword, class: "sb-keyword" },
    { tag: t.list, class: "sb-list" },
    { tag: t.operator, class: "sb-operator" },
    { tag: t.regexp, class: "sb-string" },
    { tag: t.string, class: "sb-string" },
    { tag: t.number, class: "sb-number" },
    { tag: [t.regexp, t.escape, t.special(t.string)], class: "sb-string2" },
    { tag: t.variableName, class: "sb-variableName" },
    { tag: t.typeName, class: "sb-typeName" },
    { tag: t.strikethrough, class: "sb-strikethrough" },
    { tag: t.comment, class: "sb-comment" },
    { tag: t.invalid, class: "sb-invalid" },
    { tag: t.processingInstruction, class: "sb-meta" },
    { tag: t.punctuation, class: "sb-punctuation" },
    { tag: ct.HorizontalRuleTag, class: "sb-hr" },
    { tag: ct.HashtagTag, class: "sb-hashtag-text" },
    { tag: ct.NakedURLTag, class: "sb-naked-url" },
    { tag: ct.TaskDeadlineTag, class: "sb-task-deadline" },
    { tag: ct.SubscriptTag, class: "sb-sub" },
    { tag: ct.SuperscriptTag, class: "sb-sup" },

    { tag: ct.DirectiveMarkTag, class: "sb-directive-mark" },
    { tag: ct.DirectiveTag, class: "sb-directive" },
  ]);
}


================================================
FILE: client/styles/colors.scss
================================================
#sb-root {
  font-family: var(--ui-font);
  background-color: var(--root-background-color);
  color: var(--root-color);
}

.cm-cursor,
.cm-dropCursor {
  border-left: 1.2px solid var(--editor-caret-color) !important;
}

#sb-top {
  color: var(--top-color);
  background-color: var(--top-background-color);
  border-bottom: var(--top-border-color) 1px solid;
}

#sb-top.sb-sync-error {
  color: var(--top-sync-error-color);
  background-color: var(--top-sync-error-background-color);
}

.sb-saved {
  color: var(--top-saved-color);
}

.sb-unsaved {
  color: var(--top-unsaved-color);
}

.sb-loading {
  color: var(--top-loading-color);
}

#sb-main {
  .sb-panel:last-child {
    border-left: 1px solid var(--panel-border-color);
  }

  .sb-panel:first-child {
    border-right: 1px solid var(--panel-border-color);
  }

  .sb-panel {
    background-color: var(--panel-background-color);
  }
}

.sb-bhs {
  border-top: 1px solid var(--bhs-border-color);
  background-color: var(--bhs-background-color);
}

.sb-notifications {
  font-family: var(--editor-font);
}

.sb-notifications > div {
  border: var(--notifications-border-color) 1px solid;
}

.sb-notification-info {
  background-color: var(--notification-info-background-color);
}

.sb-notification-error {
  background-color: var(--notification-error-background-color);
}

.sb-actions button {
  width: 28px;
  height: 28px;
  border: 0;
  margin: 2px;
  margin-top: 6px;
  padding: 5px;
  background-color: var(--action-button-background-color);
  color: var(--action-button-color);
  cursor: pointer;
}

.sb-actions button.sb-enabled {
  background-color: var(--top-border-color);
  border-radius: 5px;
}

.sb-actions button:hover {
  color: var(--action-button-hover-color);
}

// Only color the expander button, when the hamburger menu is open
.sb-actions:not(.open) button.expander:hover {
  color: var(--action-button-color);
}

.sb-button,
.sb-button-primary {
  --color: var(--button-color);
  --background-color: var(--button-background-color);
  --hover-background-color: var(--button-hover-background-color);
  --border-color: var(--button-border-color);

  &.sb-button-primary {
    --color: var(--primary-button-color);
    --background-color: var(--primary-button-background-color);
    --hover-background-color: var(--primary-button-hover-background-color);
    --border-color: var(--primary-button-border-color);
  }

  background-color: var(--background-color);
  color: var(--color);

  box-shadow: 0 0 0.2em rgba(0, 0, 0, 0.05);

  border: 1px solid var(--border-color);
  border-radius: 0.5em;
  padding: 0.2em 0.5em;
  font-size: 0.9em;

  &:hover {
    background-color: var(--hover-background-color);
  }

  &:focus {
    outline: 2px solid var(--color);
    outline-offset: -3px;
  }
}

/* Modal boxes */
.sb-modal-box {
  color: var(--modal-color);
  background-color: var(--modal-background-color);
  border: var(--modal-border-color) 1px solid;
  box-shadow: rgba(0, 0, 0, 0.35) 0px 20px 20px;

  .cm-scroller {
    font-family: var(--ui-font);
  }

  .sb-header {
    border-bottom: 1px var(--modal-border-color) solid;

    label {
      color: var(--modal-header-label-color);
    }

    .sb-mini-editor {
      font-family: var(--ui-font);
    }
  }

  .sb-help-text {
    background-color: var(--modal-help-background-color);
    border-bottom: 1px var(--modal-border-color) solid;
    color: var(--modal-help-color);
  }

  .sb-result-list {
    .sb-hint:not(.sb-hint-inactive) {
      color: var(--modal-hint-color);
      background-color: var(--modal-hint-background-color);
    }

    .sb-hint.sb-hint-inactive {
      color: var(--modal-hint-inactive-color);
      background-color: var(--modal-hint-inactive-background-color);
    }

    .sb-description {
      color: var(--modal-description-color);
    }

    .sb-selected-option {
      background-color: var(--modal-selected-option-background-color);
      color: var(--modal-selected-option-color);

      .sb-description {
        color: var(--modal-selected-option-description-color);
      }
    }
  }
}

/* Editor */

#sb-editor {
  .cm-content {
    font-family: var(--editor-font);
  }

  .cm-selectionBackground {
    background-color: var(--editor-selection-background-color);
  }

  .cm-panels-bottom {
    color: var(--editor-panels-bottom-color);
    background-color: var(--editor-panels-bottom-background-color);
    border-top: var(--editor-panels-bottom-border-color) 1px solid;

    .cm-search {
      .cm-textfield {
        background-color: var(--editor-panels-bottom-input-background-color);
      }
      .cm-button {
        background-image: var(--editor-panels-bottom-button-background-image);
        &:active {
          background-image: var(
            --editor-panels-bottom-button-active-background-image
          );
        }
      }
    }
  }

  .cm-editor .cm-tooltip-autocomplete {
    background-color: var(--root-background-color);
    color: var(--modal-color);

    li .cm-completionDetail {
      color: var(--editor-completion-detail-color);
    }

    li[aria-selected] .cm-completionDetail {
      color: var(--editor-completion-detail-selected-color);
    }
  }

  .cm-list-bullet::after {
    color: var(--editor-list-bullet-color);
  }

  .sb-line-h1,
  .sb-line-h2,
  .sb-line-h3,
  .sb-line-h4,
  .sb-line-h5,
  .sb-line-h6 {
    // background-color: rgba(0, 30, 77, 0.5);
    color: var(--editor-heading-color);
  }

  .sb-line-h1 .sb-meta,
  .sb-line-h2 .sb-meta,
  .sb-line-h3 .sb-meta,
  .sb-line-h4 .sb-meta,
  .sb-line-h5 .sb-meta,
  .sb-line-h6 .sb-meta {
    color: var(--editor-heading-meta-color);
  }
}

.sb-hashtag,
.hashtag {
  color: var(--editor-hashtag-color);
  background-color: var(--editor-hashtag-background-color);
  border: 1px solid var(--editor-hashtag-border-color);
  cursor: pointer;
}

.sb-line-hr {
  border-top: var(--editor-ruler-color) solid 1px;
}

.sb-naked-url {
  color: var(--editor-naked-url-color);
}

/* Color list items when the cursor is on it */
.sb-li-cursor .sb-meta {
  color: var(--editor-line-meta-color);
}

thead tr {
  background-color: var(--editor-table-head-background-color);
  color: var(--editor-table-head-color);
}

tbody tr:nth-of-type(even) {
  background-color: var(--editor-table-even-background-color);
}

.sb-line-blockquote {
  background-color: var(--editor-blockquote-background-color);
  color: var(--editor-blockquote-color);
  border-left: 1px solid var(--editor-blockquote-border-color);
}

.sb-line-code {
  background-color: var(--editor-code-background-color);
}

.sb-struct {
  color: var(--editor-struct-color);
}

.sb-code {
  background-color: var(--editor-code-background-color);
}

.sb-line-fenced-code .sb-code {
  color: var(--editor-code-color);
}

.sb-directive-mark {
  color: var(--editor-directive-mark-color);
  font-weight: bold;
}

.sb-directive {
  color: var(--editor-directive-color);
  font-weight: bold;
}

.sb-highlight {
  background-color: var(--editor-highlight-background-color);
}

.sb-line-fenced-code {
  background-color: var(--editor-code-background-color);
}

.sb-line-fenced-code .sb-comment {
  color: var(--editor-code-comment-color);
}

.sb-variableName {
  color: var(--editor-code-variable-color);
}

.sb-typeName {
  color: var(--editor-code-typename-color);
}

.sb-string,
.sb-string2 {
  color: var(--editor-code-string-color);
}

.sb-number {
  color: var(--editor-code-number-color);
}

.sb-operator {
  color: var(--editor-code-operator-color);
}

.sb-meta {
  color: var(--editor-meta-color);
}

.sb-deleted {
  color: var(--editor-code-deleted-color, #dc2626);
  background-color: var(--editor-code-deleted-bg, rgba(254, 202, 202, 0.25));
}

.sb-inserted {
  color: var(--editor-code-inserted-color, #16a34a);
  background-color: var(--editor-code-inserted-bg, rgba(187, 247, 208, 0.25));
}

// Admonitions

.sb-admonition {
  border-left-color: var(--admonition-color);
}

.sb-admonition-title {
  background-color: color-mix(
    in srgb,
    var(--admonition-color),
    transparent 90%
  );
}

.sb-admonition-type::before {
  background-color: var(--admonition-color);
}

// Frontmatter

.sb-frontmatter {
  background-color: var(--editor-frontmatter-background-color);
  color: var(--editor-frontmatter-color);
}

.sb-frontmatter-marker {
  color: var(--editor-frontmatter-marker-color);
}

.sb-emphasis {
  font-style: italic;
}

.sb-strong {
  font-weight: 900;
}

.sb-sub {
  vertical-align: sub;
  font-size: smaller;
}

.sb-sup {
  vertical-align: super;
  font-size: smaller;
}

.sb-line-code-outside .sb-code-info {
  color: var(--editor-code-info-color);
}

a,
.sb-link:not(.sb-meta, .sb-url) {
  color: var(--editor-link-color);
}

.sb-link.sb-url {
  color: var(--editor-link-meta-color);
}

.sb-url:not(.sb-link) {
  color: var(--editor-link-url-color);
}

.sb-atom, .sb-attribute-name {
  color: var(--editor-code-atom-color);
}

.sb-wiki-link {
  color: var(--editor-wiki-link-page-color);
  background-color: var(--editor-wiki-link-page-background-color);
}

a.sb-wiki-link-missing,
.sb-wiki-link-missing > .sb-wiki-link {
  color: var(--editor-wiki-link-page-missing-color);
}

a.sb-wiki-link-invalid,
.sb-wiki-link-invalid > .sb-wiki-link {
  color: var(--editor-wiki-link-page-invalid-color);
}

.sb-task-mark {
  color: var(--editor-task-marker-color);
}

.sb-task-state {
  color: var(--editor-task-state-color);
}

.sb-line-comment {
  background-color: var(--editor-code-comment-color); // rgba(255, 255, 0, 0.5);
}


================================================
FILE: client/styles/editor.scss
================================================
.cm-focused {
  outline: none !important;
}

#sb-main .cm-editor {
  font-size: 18px;
  height: 100%;

  .cm-content {
    width: 100%;
    margin-left: auto;
    margin-right: auto;
    max-width: var(--#{"editor-width"});
    padding: 5px 20px;
  }

  .cm-line {
    padding: 0;

    &.sb-line-h1,
    &.sb-line-h2,
    &.sb-line-h3,
    &.sb-line-h4,
    &.sb-line-h5,
    &.sb-line-h6 {
      font-weight: bold;
      padding: 2px;
    }
  }

  .cm-line:has(.sb-directive) {
    background-color: var(--editor-directive-background-color);
  }

  .sb-line-h1,
  h1 {
    font-size: 1.5em;
  }

  .sb-line-h2,
  h2 {
    font-size: 1.2em;
  }

  .sb-line-h3,
  h3 {
    font-size: 1.1em;
  }

  .sb-line-h4,
  h4,
  .sb-line-h5,
  h5,
  .sb-line-h6,
  h6 {
    font-size: 1em;
  }

  // If a header only contains a tag, it's likely a line containging "#" which may turn into a hashtag, so style it as such instead of a header
  .sb-line-h1:has(> a.sb-hashtag:only-child) {
    font-size: 1em;
    padding: 0;
    font-weight: normal;

    .sb-meta {
      color: var(--editor-hashtag-color);
    }
  }

  .cm-panels-bottom .cm-vim-panel {
    padding: 0 20px;
    max-width: var(--editor-width);
    margin: auto;
    font-family: var(--editor-font);
    height: 1.5em;
    line-height: 1.5em;

    input {
      font-family: var(--editor-font);
      font-size: 1em;
    }
  }

  // Gutter styling
  .cm-gutters {
    background-color: transparent;
    border-right: none;
  }

  .cm-foldPlaceholder {
    background-color: transparent;
    border: 0;
  }

  // Indentation of follow-up lines
  @mixin lineOverflow($baseIndent, $bulletIndent: 0) {
    text-indent: calc(-1 * (#{$baseIndent}ch + 2ch));
    padding-left: calc(#{$baseIndent}ch + 2ch);

    &.sb-line-task {
      text-indent: calc(-1 * (#{$baseIndent}ch + 5ch));
      padding-left: calc(#{$baseIndent}ch + 5ch);

      .cm-list-bullet::after {
        left: calc(#{$baseIndent}ch + 5ch);
      }
    }

    &.sb-line-blockquote {
      text-indent: calc(-1 * (#{$baseIndent}ch + 4ch));
      padding-left: calc(#{$baseIndent}ch + 4ch);
    }

    .cm-list-bullet::after {
      left: calc(#{$baseIndent}ch + #{$bulletIndent}ch + 2ch);
    }
  }

  .sb-line-ul {
    &.sb-line-li-1 {
      @include lineOverflow(0);
    }

    &.sb-line-li-1.sb-line-li-2 {
      @include lineOverflow(2);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3 {
      @include lineOverflow(4);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4 {
      @include lineOverflow(6);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4.sb-line-li-5 {
      @include lineOverflow(8);
    }
  }

  .sb-line-ol {
    &.sb-line-li-1 {
      @include lineOverflow(1);
    }

    &.sb-line-li-1.sb-line-li-2 {
      @include lineOverflow(4);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3 {
      @include lineOverflow(7);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4 {
      @include lineOverflow(10);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4.sb-line-li-5 {
      @include lineOverflow(13);
    }
  }

  .sb-line-ol.sb-line-ul {
    // &.sb-line-li-1 {
    //   @include lineOverflow(1);
    // }

    &.sb-line-li-1.sb-line-li-2 {
      @include lineOverflow(3);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3 {
      @include lineOverflow(6);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4 {
      @include lineOverflow(9);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4.sb-line-li-5 {
      @include lineOverflow(12);
    }
  }

  .sb-line-blockquote {
    &.sb-line-li-1 {
      @include lineOverflow(-1, 2);
    }

    &.sb-line-li-1.sb-line-li-2 {
      @include lineOverflow(1, 2);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3 {
      @include lineOverflow(4);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4 {
      @include lineOverflow(6);
    }

    &.sb-line-li-1.sb-line-li-2.sb-line-li-3.sb-line-li-4.sb-line-li-5 {
      @include lineOverflow(8);
    }
  }

  .sb-line-comment {
    text-indent: -1 * 3ch;
    padding-left: 3ch;
  }

  .cm-list-bullet {
    position: relative;
    visibility: hidden;
  }

  .cm-list-bullet::after {
    visibility: visible;
    position: absolute;
    content: "\2022";
    /* U+2022 BULLET */
  }

  .cm-task-checked,
  .sb-line-task:has(.cm-task-checked) .sb-wiki-link {
    text-decoration: line-through !important;
  }

  .cm-tooltip-autocomplete {
    .cm-completionDetail {
      font-style: normal;
      display: block;
      font-size: 80%;
      margin-left: 5px;
    }

    .cm-completionLabel {
      display: block;
      margin-left: 5px;
    }

    .cm-completionIcon {
      display: none;
    }
  }

  .sb-header-inside.sb-line-h1 {
    text-indent: -2ch;
  }

  .sb-header-inside.sb-line-h2 {
    text-indent: -3ch;
  }

  .sb-header-inside.sb-line-h3 {
    text-indent: -4ch;
  }

  .sb-header-inside.sb-line-h4 {
    text-indent: -5ch;
  }

  .sb-header-inside.sb-line-h5 {
    text-indent: -6ch;
  }

  .sb-header-inside.sb-line-h6 {
    text-indent: -7ch;
  }

  .sb-checkbox > input[type="checkbox"] {
    width: 3ch;
  }

  .sb-hashtag,
  .hashtag {
    border-radius: 6px;
    padding: 0 3px;
    margin: 0 1px 0 0;
    font-size: 0.9em;
  }

  a.sb-hashtag {
    text-decoration: none;
  }

  .sb-strikethrough {
    text-decoration: line-through;

    &.sb-meta {
      text-decoration: none;
    }
  }

  .sb-line-hr {
    margin-top: 1em;
    margin-bottom: -1em;
  }

  .sb-hr {
    font-weight: bold;
  }

  .sb-naked-url {
    cursor: pointer;
  }

  .sb-link:not(.sb-url) {
    cursor: pointer;
  }

  .sb-link:not(.sb-meta, .sb-url) {
    text-decoration: underline;
  }

  .sb-url:not(.sb-link) {
    text-decoration: underline;
    cursor: pointer;
  }

  .sb-wiki-link {
    border-radius: 5px;
    padding: 0 5px;
    // white-space: nowrap;
    text-decoration: none;
    cursor: pointer;
  }

  .sb-lua-directive-inline {
    display: inline;
    padding: 2px;
  }

  .sb-lua-directive-block {
    display: block;
    margin: -1em 0;
  }

  .sb-widget-array {
    display: flex;
    flex-direction: column;
  }

  .sb-task-mark {
    font-size: 91%;
  }

  .sb-task-state {
    font-size: 91%;
  }

  .sb-task-deadline {
    background-color: rgba(22, 22, 22, 0.07);
  }

  .sb-line-frontmatter-outside,
  .sb-line-code-outside {
    .sb-meta {
      color: transparent;
    }

    color: transparent;
  }

  .sb-line-blockquote {
    text-indent: -2ch;
  }

  .sb-blockquote-outside {
    text-indent: -1ch;
    min-height: 1em;
  }

  .sb-line-table-outside {
    display: none;
  }

  .sb-line-tbl-header {
    font-weight: bold;
  }

  .sb-line-tbl-header .meta {
    font-weight: normal;
  }

  .sb-table-widget {
    display: block;
    font-weight: normal;
    margin-bottom: -3rem;
    overflow: auto;
  }

  table {
    width: 100%;
    border-spacing: 0;
  }

  thead tr {
    font-weight: bold;
  }

  thead tr td {
    // Primarily to make the column headers look reasonable with the button bar that may be put on top
    padding: 11px 8px;
  }

  th,
  td {
    padding: 8px;
    white-space: nowrap;
  }

  // Table alignment classes for justified tables
  .sb-table-align-left {
    text-align: left;
  }

  .sb-table-align-right {
    text-align: right;
  }

  .sb-table-align-center {
    text-align: center;
  }

  // dont apply background color twice for (fenced) code blocks
  .sb-line-code .sb-code {
    background-color: transparent;
  }

  .sb-line-code-outside .sb-code-info {
    display: block;
    float: right;
    font-size: 90%;
    padding-right: 7px;
  }

  .sb-code-copy-button {
    float: right;
    cursor: pointer;
    margin: 0 3px;
  }

  .sb-code-copy-button > svg {
    height: 1rem;
    width: 1rem;
  }

  .sb-line-fenced-code .sb-code {
    background-color: transparent;
  }

  .sb-line-fenced-code .sb-comment {
    border-radius: 0;
    font-style: inherit;
    font-size: inherit;
    line-height: inherit;
  }

  .sb-keyword {
    font-weight: bold;
  }

  // Allow for cursor movement into a code widget fenced code block from bottom-up
  .sb-fenced-code-hide {
    background-color: transparent;
    line-height: 0;
  }

  .sb-lua-wrapper {
    ::selection {
      background-color: var(--editor-selection-background-color) !important;
    }
  }

  .sb-lua-top-widget,
  .sb-lua-bottom-widget {
    border: 1px solid var(--editor-widget-background-color);
    border-radius: 5px;
    white-space: normal;
    position: relative;
    min-height: 48px;
  }

  .sb-lua-top-widget {
    margin: 0 0 10px 0;
  }

  .sb-lua-bottom-widget {
    margin: 10px 0 0 0;
  }

  .sb-lua-top-widget h1,
  .sb-lua-bottom-widget h1 {
    margin: -10px -10px 10px -10px !important;
    padding: 15px 10px !important;
    background-color: var(--editor-widget-background-color);
    font-size: 1.2em;
  }

  .sb-markdown-top-widget h1,
  .sb-markdown-bottom-widget h1 {
    margin: 0 0 5px 0;
    padding: 10px !important;
    background-color: var(--editor-widget-background-color);
    font-size: 1.2em;
  }

  .sb-markdown-top-widget:has(*) {
    margin-bottom: 10px;
  }

  .sb-markdown-bottom-widget {
    margin-top: 10px;
  }

  .sb-lua-top-widget .content,
  .sb-lua-bottom-widget .content {
    max-height: 500px;
    padding: 10px;
  }

  .sb-lua-bottom-widget p strong {
    display: block;
    padding: 10px 12px;
    background-color: var(--editor-widget-background-color);
    border: 1px solid var(--editor-widget-background-color);
    border-radius: 8px 8px 0 0;
    font-weight: 600;
  }

  .sb-lua-bottom-widget blockquote {
    margin: 0 5px;
    padding: 12px;
    border: 1px solid var(--editor-widget-background-color);
    border-radius: 8px;
    background-color: var(--editor-widget-background-color);
    color: var(--editor-text-color);
    border-top: 1px solid var(--editor-widget-background-color);
  }

  .sb-markdown-top-widget:has(*) .content {
    max-height: 500px;
  }

  @media screen and (max-height: 1000px) {
    .sb-markdown-top-widget:has(*) .content {
      max-height: 300px;
    }
  }

  .sb-markdown-widget,
  .sb-lua-directive-block,
  .sb-lua-directive-inline,
  .sb-markdown-top-widget:has(*),
  .sb-markdown-bottom-widget:has(*) {
    border: 1px solid var(--editor-widget-background-color);
    border-radius: 5px;
    white-space: normal;
    position: relative;
    min-height: 48px;

    .content {
      overflow-y: auto;
    }

    ul,
    ol {
      margin-top: 0;
      margin-bottom: 0;
    }

    ul {
      list-style: none;
      // padding-left: 1ch;
    }

    ul li::before {
      content: "\2022";
      /* Add content: \2022 is the CSS Code/unicode for a bullet */
      color: var(--editor-list-bullet-color);
      display: inline-block;
      /* Needed to add space between the bullet and the text */
      width: 1em;
      /* Also needed for space (tweak if needed) */
      margin-left: -1em;
    }

    h1,
    h2,
    h3,
    h4,
    h5 {
      margin: 0;
    }

    a.wiki-link {
      border-radius: 5px;
      padding: 0 5px;
      color: var(--editor-wiki-link-page-color);
      background-color: var(--editor-wiki-link-page-background-color);
      text-decoration: none;
    }

    span.task-deadline {
      background-color: rgba(22, 22, 22, 0.07);
    }

    tt {
      background-color: var(--editor-code-background-color);
    }

    // Only show the button bar on hover on non-touch devices
    &:hover .button-bar {
      display: block;
    }

    // Always show button bar on touch devices
    @media (hover: none) and (pointer: coarse) {
      .button-bar {
        display: block !important;
      }
    }

    .button-bar {
      position: absolute;
      right: 0;
      top: 0;
      display: none;
      background: var(--editor-widget-background-color);
      padding-inline: 3px;
      padding: 4px 0;
      border-radius: 0 0 0 5px;

      button {
        border: none;
        background: none;
        cursor: pointer;
        color: var(--root-color);
        margin-right: -8px;
        padding: 11px 8px;
      }

      button:last-of-type {
        margin-right: 2px;
      }
    }
  }

  .sb-lua-directive-inline:has(> button) {
    border: none;
    border-radius: 0;
  }

  .sb-inline-content > div > .content > * {
    max-width: 100%;
    display: block;
  }

  .sb-inline-content {
    margin: 0;
    overflow: hidden;
  }

  .sb-fenced-code-iframe {
    background-color: transparent;

    iframe {
      border: 0;
      width: 100%;
      max-width: 100%;
      padding: 0;
      margin: 0 0 -2ch 0;
      border: 1px solid var(--editor-widget-background-color);
      border-radius: 5px;
    }
  }

  .sb-line-blockquote.sb-line-ul.sb-line-li > .sb-quote.sb-meta:first-child {
    margin-left: -1ch;
  }

  .sb-admonition {
    border-left-width: 4px !important;
    border-left-style: solid;
    padding-left: 5px;
  }

  .sb-admonition-type::before {
    content: "";
    mask: var(--admonition-icon) no-repeat;
    -webkit-mask: var(--admonition-icon) no-repeat;
    mask-size: cover;
    -webkit-mask-size: cover;

    /* used in (custom) admonition definitions */
    --admonition-width: 1.1em;
    width: 0em;
    height: 1.1em;
    display: inline-flex;
    vertical-align: middle;
  }

  .sb-frontmatter-marker {
    float: right;
    font-size: 80%;
    padding-right: 7px;
  }

  .cm-scroller {
    // Give some breathing space at the bottom of the screen
    padding-bottom: 20em;
  }
}

div:not(.cm-focused).cm-fat-cursor {
  outline: none !important;
}


================================================
FILE: client/styles/main.scss
================================================
@use "editor";
@use "modals";
@use "theme";
@use "colors";
@use "top";

@font-face {
  font-family: "iA-Mono";
  src: url("iAWriterMonoS-Regular.woff2");
  font-weight: normal;
  font-style: normal;
}

@font-face {
  font-family: "iA-Mono";
  src: url("iAWriterMonoS-Bold.woff2");
  font-weight: bold;
  font-style: normal;
}

@font-face {
  font-family: "iA-Mono";
  src: url("iAWriterMonoS-Italic.woff2");
  font-weight: normal;
  font-style: italic;
}

@font-face {
  font-family: "iA-Mono";
  src: url("iAWriterMonoS-BoldItalic.woff2");
  font-weight: bold;
  font-style: italic;
}

html,
body {
  margin: 0;
  height: 100%;
  padding: 0;
  width: 100%;
  overflow: hidden;
  background-color: var(--top-background-color);
}

#sb-root {
  display: flex;
  flex-direction: column;
  width: 100%;
  height: 100%;
  background-color: var(--root-background-color);
}

.sb-panel {
  flex: 1;
}

.sb-bottom-iframe {
  width: 100%;
  margin-top: 10px;
  border: 1px solid var(--editor-widget-background-color);
  border-radius: 5px;
}

.sb-top-iframe {
  width: 100%;
  margin-top: 10px;
  border: 1px solid var(--editor-widget-background-color);
  border-radius: 5px;
}

#sb-main {
  display: flex;
  flex-direction: row;
  flex-grow: 1;
  height: 0;

  .sb-panel {
    flex: 1;
  }
}

#sb-editor {
  flex: 2;
  height: 100%;

  width: 100%;

  iframe {
    width: 100%;
    height: 100%;
    border: none;
  }
}

#sb-editor.hide-cm {
  .cm-editor {
    display: none !important;
  }
}

.sb-bhs {
  height: 300px;
  width: 100%;
  z-index: 10;

  .sb-panel {
    height: 100%;
  }
}

.sb-modal {
  position: absolute;
  z-index: 100;

  .sb-panel {
    height: 100%;

    iframe {
      background-color: initial;
    }
  }
}

.sb-page-prefix {
  display: flex;
  align-items: baseline;
  flex: 0 0 auto;
  text-align: left;
  padding-top: 3px;
  font-family: var(--ui-font);
  white-space: pre-wrap;
}

.sb-panel {
  iframe {
    background-color: var(--root-background-color);
    border: 0;
    width: 100%;
    height: 100%;
    padding: 0;
    margin: 0;
  }
}

.sb-preview {
  position: relative;
}

.sb-markdown-toolbar {
  position: absolute;
  opacity: 0;
  transition: opacity 0.2s;
  width: 100%;
  display: flex;
  justify-content: flex-end;
  box-sizing: border-box;
}

.sb-markdown-toolbar:hover {
  opacity: 1;
}


================================================
FILE: client/styles/modals.scss
================================================
.sb-modal-box {
  margin-top: 60px;
  padding: 0;
  width: 700px;
  max-width: 90%;

  border-radius: 8px;
  overflow: hidden;

  .cm-content {
    padding: 0;

    .cm-line {
      padding: 2px 0 0 3px;
    }
  }

  .sb-header {
    padding: 13px 10px 10px 10px;
    display: flex;

    label {
      margin: 3px;
    }

    .sb-mini-editor {
      flex-grow: 1;
      min-width: 0;
    }
  }

  .sb-prompt {
    padding: 13px 10px 10px 10px;

    label {
      font-weight: bold;
    }

    .sb-mini-editor {
      background-color: var(--text-field-background-color);
      margin: 10px 0;
      padding: 0.2em 0.5em;
      border: 0;
      border-radius: 0.5em;
      outline: none;
    }

    .sb-prompt-buttons {
      display: flex;
      justify-content: flex-end;
      margin-top: 10px;
      gap: 10px;
    }
  }

  .sb-help-text {
    padding: 5px;
  }

  .sb-result-list {
    max-height: 250px;
    overflow-y: scroll;

    .sb-icon {
      padding: 0 8px 0 5px;
      position: relative;
      top: 3px;
    }

    .sb-description {
      font-size: 75%;
    }
  }

  .sb-option,
  .sb-selected-option {
    padding: 8px;
    cursor: pointer;
    line-height: 20px;
  }

  .sb-option .sb-hint,
  .sb-selected-option .sb-hint {
    float: right;
    margin-right: 0;
    margin-top: -4px;
    padding-left: 5px;
    padding-right: 5px;
    padding-top: 3px;
    padding-bottom: 3px;
    border-radius: 5px;
  }
}


================================================
FILE: client/styles/theme.scss
================================================
html {
  --ui-accent-color: #464cfc;
  --ui-accent-text-color: var(--ui-accent-color);
  --ui-accent-contrast-color: #eee;
  --highlight-color: rgba(255, 255, 0, 0.5);
  --link-color: #0330cb;
  --link-missing-color: #9e4705;
  --link-invalid-color: #bb08e7;
  --meta-color: #650007;
  --meta-subtle-color: #959595;
  --subtle-color: #676767;
  --subtle-background-color: rgba(72, 72, 72, 0.1);

  --root-background-color: #fff;
  --root-color: inherit;

  --top-color: inherit;
  --top-background-color: #e1e1e1;
  --top-border-color: #cacaca;
  --top-sync-error-color: var(--top-color);
  --top-sync-error-background-color: #fdf8cb;
  --top-saved-color: #111;
  --top-unsaved-color: #5e5e5e;
  --top-loading-color: #7a7a7a;

  --panel-background-color: #fff;
  --panel-border-color: #fff;

  --bhs-background-color: #fff;
  --bhs-border-color: rgb(193, 193, 193);

  --modal-color: inherit;
  --modal-background-color: #fff;
  --modal-border-color: rgb(108, 108, 108);
  --modal-header-label-color: var(--ui-accent-text-color);
  --modal-help-background-color: #eee;
  --modal-help-color: #555;
  --modal-selected-option-background-color: var(--ui-accent-color);
  --modal-selected-option-color: var(--ui-accent-contrast-color);
  --modal-hint-background-color: #212476;
  --modal-hint-color: #eee;
  --modal-hint-inactive-background-color: #e1e1e1;
  --modal-hint-inactive-color: #111;
  --modal-description-color: #6b6b6b;
  --modal-selected-option-description-color: #e6e6e6;

  --notifications-background-color: inherit;
  --notifications-border-color: rgb(41, 41, 41);
  --notification-info-background-color: rgb(187, 221, 247);
  --notification-error-background-color: rgb(255, 84, 84);

  --button-background-color: #eee;
  --button-hover-background-color: inherit;
  --button-color: black;
  --button-border-color: #6c6c6c;
  --primary-button-background-color: var(--ui-accent-color);
  --primary-button-hover-background-color: color-mix(
    in srgb,
    var(--ui-accent-color),
    black 35%
  );
  --primary-button-color: var(--ui-accent-contrast-color);
  --primary-button-border-color: transparent;

  --text-field-background-color: var(--button-background-color);

  --progress-background-color: #eee;
  --progress-sync-color: black;
  --progress-index-color: #0772be;

  --action-button-background-color: transparent;
  --action-button-color: #292929;
  --action-button-hover-color: #0772be;
  --action-button-active-color: #0772be;

  --editor-caret-color: black;
  --editor-selection-background-color: #d7e1f6;
  --editor-panels-bottom-color: inherit;
  --editor-panels-bottom-background-color: #e1e1e1;
  --editor-panels-bottom-border-color: #cacaca;
  --editor-completion-detail-color: #555;
  --editor-completion-detail-selected-color: #d2d2d2;
  --editor-list-bullet-color: rgb(150, 150, 150);
  --editor-heading-color: #333;
  --editor-heading-meta-color: var(--meta-subtle-color);
  --editor-hashtag-background-color: #002b6aad;
  --editor-hashtag-color: #e2e9ff;
  --editor-hashtag-border-color: #0120416b;
  --editor-ruler-color: rgb(76, 75, 75);
  --editor-naked-url-color: var(--link-color);
  --editor-code-color: #7c828e;
  --editor-link-color: var(--link-color);
  --editor-link-url-color: var(--link-color);
  --editor-link-meta-color: var(--meta-subtle-color);
  --editor-wiki-link-page-background-color: rgba(77, 141, 255, 0.07);
  --editor-wiki-link-page-color: var(--link-color);
  --editor-wiki-link-page-missing-color: var(--link-missing-color);
  --editor-wiki-link-page-invalid-color: var(--link-invalid-color);
  --editor-wiki-link-color: #8f96c2;
  --editor-command-button-color: inherit;
  --editor-command-button-background-color: #e3dfdf;
  --editor-command-button-hover-background-color: inherit;
  --editor-command-button-meta-color: var(--meta-subtle-color);
  --editor-command-button-border-color: gray;
  --editor-line-meta-color: var(--meta-subtle-color);
  --editor-meta-color: var(--meta-color);
  --editor-table-head-background-color: #333;
  --editor-table-head-color: #eee;
  --editor-table-even-background-color: #f3f3f3;
  --editor-blockquote-background-color: var(--subtle-background-color);
  --editor-blockquote-color: var(--subtle-color);
  --editor-blockquote-border-color: rgb(74, 74, 74);
  --editor-code-background-color: var(--subtle-background-color);
  --editor-struct-color: darkred;
  --editor-highlight-background-color: var(--highlight-color);
  --editor-code-comment-color: var(--meta-subtle-color);
  --editor-code-variable-color: #024866;
  --editor-code-typename-color: #038138;
  --editor-code-string-color: #540293;
  --editor-code-number-color: #01833c;
  --editor-code-operator-color: #808080;
  --editor-code-info-color: var(--subtle-color);
  --editor-code-atom-color: #5a0000;
  --editor-frontmatter-background-color: rgba(255, 246, 189, 0.3);
  --editor-frontmatter-color: var(--subtle-color);
  --editor-frontmatter-marker-color: #89000080;
  --editor-widget-background-color: rgb(238, 238, 238);
  --editor-task-marker-color: var(--subtle-color);
  --editor-task-state-color: var(--subtle-color);

  --editor-directive-mark-color: #6c0101;
  --editor-directive-color: #696969;
  --editor-directive-background-color: #ebebeb7d;

  --editor-panels-bottom-input-background-color: #fff;
  --editor-panels-bottom-button-background-image: linear-gradient(
    #eff1f5,
    #d9d9df
  );
  --editor-panels-bottom-button-active-background-image: linear-gradient(
    #b4b4b4,
    #d0d3d6
  );

  --ui-font:
    -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue",
    Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji",
    "Segoe UI Symbol", "Noto Color Emoji";
  --editor-font: "iA-Mono", "Menlo";
  --editor-width: 800px;
}

html[data-theme="light"] {
  color-scheme: light;
}

html[data-theme="dark"] {
  color-scheme: dark;

  --ui-accent-color: #464cfc;
  --ui-accent-text-color: var(--ui-accent-color);
  --ui-accent-text-color: color-mix(
    in srgb,
    var(--ui-accent-color),
    white 50%
  );
  --highlight-color: rgba(255, 255, 0, 0.5);
  --link-color: #7e99fc;
  --link-missing-color: #9e4705;
  --link-invalid-color: #d444f8;
  --meta-color: #d6222e;
  --meta-subtle-color: #959595;
  --subtle-color: #959595;
  --subtle-background-color: rgba(105, 105, 105, 0.1);

  --root-background-color: #111;
  --root-color: #fff;

  --top-color: #fff;
  --top-background-color: #262626;
  --top-border-color: rgb(62, 62, 62);
  --top-sync-error-color: var(--top-color);
  --top-sync-error-background-color: #622626;
  --top-saved-color: #fff;
  --top-unsaved-color: #c7c7c7;
  --top-loading-color: #c7c7c7;

  --panel-background-color: #111;
  --panel-border-color: rgb(62, 62, 62);

  --bhs-background-color: #111;
  --bhs-border-color: rgb(62, 62, 62);

  --modal-color: #ccc;
  --modal-background-color: #262626;
  --modal-border-color: #6c6c6c;
  --modal-header-label-color: var(--ui-accent-text-color);
  --modal-help-background-color: #333;
  --modal-help-color: #ccc;
  --modal-selected-option-background-color: var(--ui-accent-color);
  --modal-selected-option-color: #eee;
  --modal-hint-background-color: #212476;
  --modal-hint-color: #eee;
  --modal-hint-inactive-background-color: #353535;
  --modal-hint-inactive-color: #ccc;
  --modal-description-color: #969696;
  --modal-selected-option-description-color: #e6e6e6;

  --notifications-background-color: #333;
  --notifications-border-color: rgb(197, 197, 197);
  --notification-info-background-color: #1b76bb;
  --notification-error-background-color: #a32121;

  --button-background-color: #555;
  --button-hover-background-color: #777;
  --button-color: white;
  --button-border-color: #666;
  --primary-button-background-color: var(--ui-accent-color);
  --primary-button-hover-background-color: color-mix(
    in srgb,
    var(--ui-accent-color),
    black 35%
  );
  --primary-button-color: var(--ui-accent-contrast-color);
  --primary-button-border-color: transparent;

  --progress-background-color: #555;
  --progress-sync-color: white;
  --progress-index-color: yellow;

  --text-field-background-color: var(--button-background-color);

  --action-button-background-color: transparent;
  --action-button-color: #adadad;
  --action-button-hover-color: #37a1ed;
  --action-button-active-color: #37a1ed;

  --editor-caret-color: #fff;
  --editor-selection-background-color: #d7e1f630;
  --editor-panels-bottom-color: #fff;
  --editor-panels-bottom-background-color: #262626;
  --editor-panels-bottom-border-color: rgb(62, 62, 62);
  --editor-completion-detail-color: #aaa;
  --editor-completion-detail-selected-color: #d2d2d2;
  --editor-list-bullet-color: rgb(150, 150, 150);
  --editor-heading-color: #d1d1d1;
  --editor-heading-meta-color: var(--meta-subtle-color);
  --editor-hashtag-background-color: #004bbb;
  --editor-hashtag-color: #e2e9ff;
  --editor-hashtag-border-color: #007bff6b;
  --editor-ruler-color: rgb(76, 75, 75);
  --editor-naked-url-color: var(--link-color);
  --editor-link-color: var(--link-color);
  --editor-link-url-color: var(--link-color);
  --editor-link-meta-color: var(--meta-subtle-color);
  --editor-wiki-link-page-background-color: #a3bce712;
  --editor-wiki-link-page-color: var(--link-color);
  --editor-wiki-link-page-missing-color: var(--link-missing-color);
  --editor-wiki-link-page-invalid-color: var(--link-invalid-color);
  --editor-wiki-link-color: #8f96c2;
  --editor-command-button-color: #fff;
  --editor-command-button-background-color: #555;
  --editor-command-button-hover-background-color: #777;
  --editor-command-button-meta-color: var(--meta-subtle-color);
  --editor-command-button-border-color: #666;
  --editor-line-meta-color: var(--meta-subtle-color);
  --editor-meta-color: var(--meta-color);
  --editor-table-head-background-color: rgba(72, 72, 72, 0.4);
  --editor-table-head-color: #eee;
  --editor-table-even-background-color: rgba(72, 72, 72, 0.3);
  --editor-blockquote-background-color: var(--subtle-background-color);
  --editor-blockquote-color: var(--subtle-color);
  --editor-blockquote-border-color: rgb(74, 74, 74);
  --editor-code-background-color: var(--subtle-background-color);
  --editor-struct-color: #d6222e;
  --editor-highlight-background-color: var(--highlight-color);
  --editor-code-comment-color: var(--meta-subtle-color);
  --editor-code-variable-color: #41a3ce;
  --editor-code-typename-color: #038138;
  --editor-code-string-color: #986db9;
  --editor-code-number-color: #986db9;
  --editor-code-info-color: var(--subtle-color);
  --editor-code-atom-color: #d6222e;
  --editor-frontmatter-background-color: rgb(41, 40, 35, 0.5);
  --editor-frontmatter-color: var(--subtle-color);
  --editor-frontmatter-marker-color: #fff;
  --editor-widget-background-color: rgba(72, 72, 72, 0.5);
  --editor-task-marker-color: var(--subtle-color);
  --editor-task-state-color: var(--subtle-color);

  --editor-directive-mark-color: #ba0303;
  --editor-directive-color: #898989;
  --editor-directive-background-color: #4c4c4c7d;

  --editor-panels-bottom-input-background-color: inherit;
  --editor-panels-bottom-button-background-image: linear-gradient(
    #393939,
    #111
  );
  --editor-panels-bottom-button-active-background-image: linear-gradient(
    #111,
    #333
  );
}

html[data-markdown-syntax-rendering="on"] {
  --editor-meta-color: unset;
  --editor-directive-mark-color: unset;

  #sb-main
    .cm-editor
    .sb-lua-directive-block:not(
      .sb-lua-top-widget
    ):not(
      .sb-lua-bottom-widget
    ):not(
      .sb-inline-content
    ) {
    margin-top: 0.5em;
  }
}

.sb-admonition[admonition="note" i] {
  .sb-admonition-type {
    &::before {
      width: var(--admonition-width) !important;
    }

    * {
      display: none;
    }
  }

  --admonition-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>');
  --admonition-color: #00b8d4;
}

.sb-admonition[admonition="warning" i] {
  .sb-admonition-type {
    &::before {
      width: var(--admonition-width) !important;
    }

    * {
      display: none;
    }
  }

  --admonition-icon: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>');
  --admonition-color: #ff9100;
}

.sb-admonition[admonition="danger" i] {
  .sb-admonition-type {
    &::before {
      width: var(--admonition-width) !important;
    }

    * {
      display: none;
    }
  }

  --admonition-icon: url('data:image/svg+xml,<svg  width="16" height="16" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-x-octagon"><polygon points="7.86 2 16.14 2 22 7.86 22 16.14 16.14 22 7.86 22 2 16.14 2 7.86 7.86 2"></polygon><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>');
  --admonition-color: #d50000;
}

.sb-admonition[admonition="success" i] {
  .sb-admonition-type {
    &::before {
      width: var(--admonition-width) !important;
    }

    * {
      display: none;
    }
  }

  --admonition-icon: url('data:image/svg+xml,<svg  width="16" height="16" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-check-circle"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline></svg>');
  --admonition-color: #00c853;
}


================================================
FILE: client/styles/top.scss
================================================
#sb-top {
  display: flex;
  flex-direction: row;
  z-index: 20;

  height: 55px;

  .main {
    flex: 2;
    max-width: 100%;

    .inner {
      // Hack to not have SCSS precompile this value but use proper CSS variables
      max-width: var(--#{"editor-width"});
      margin: auto;
      font-size: 28px;
      padding: 8px 0;
      display: flex;
      flex-direction: row;

      .wrapper {
        width: env(titlebar-area-width, 100%);
        padding: 0 20px;
        display: flex;
        box-sizing: border-box;
        position: relative;

        .sb-notifications {
          position: absolute;
          right: 20px;
          top: 50px;

          font-size: 15px;
          z-index: 100;

          > div {
            padding: 3px;
            margin-bottom: 3px;
            border-radius: 5px;
          }
        }
      }
    }

    #sb-current-page {
      flex: 1;

      overflow: hidden;
      white-space: nowrap;
      text-align: left;
      display: block;

      -webkit-app-region: drag;

      .cm-scroller {
        font-family: var(--ui-font);
      }

      .cm-content {
        padding: 0;

        .cm-line {
          padding: 0;
          caret-color: var(--editor-caret-color);
        }
      }
    }
  }

  .sb-actions {
    display: flex;
    flex: 0 0 auto;
    text-align: right;

    &.hamburger button.expander {
      display: none;
    }
  }

  .progress-wrapper {
    position: relative;
    margin-top: 4px;
    margin-right: 6px;
    padding: 4px;
    background-color: var(--top-background-color);
  }

  .progress-bar {
    display: flex;
    justify-content: center;
    align-items: center;

    width: 24px;
    height: 24px;
    border-radius: 50%;
    font-size: 8px;
  }
}

@media only screen and (max-width: 600px) {
  /* Style the menu as a vertical fly-out */
  #sb-top .cm-scroller:has(.hamburger) {
    position: relative;
  }

  #sb-top .sb-sync-progress:has(~ .hamburger) {
    margin-right: 1.8em;
  }

  #sb-top .sb-actions.hamburger {
    position: absolute;

    top: 3px;
    right: 20px;
    border-radius: 5px;
    border: 1px solid #444;
    width: 2.8rem;
    background: color-mix(
      in srgb,
      var(--top-background-color) 90%,
      transparent
    );
    backdrop-filter: blur(20px);
    overflow: hidden;
    flex-direction: column;
    align-items: center;
    z-index: 10;
    transition: max-height 0.3s ease-in-out, box-shadow 0.3s ease-in-out;

    /* Initial collapsed height */
    max-height: 2.2rem;

    button {
      &.expander {
        display: initial;
      }

      &:not(.sb-code-copy-button) {
        display: block;
        height: 1.8rem;
        text-align: center;
        margin: 4px 0;
        padding: 4px 0;
      }

      &:not(:first-of-type) {
        margin: 8px 0;
        opacity: 0;
        transform: translateY(-10px);
        transition: opacity 0.3s ease-in-out, transform 0.3s ease-in-out;
      }
    }

    // CSS class for toggling the hamburger menu, so it can be toggled via JS
    &.open:hover {
      /* Expanded height limited to the viewport height minus some padding */
      max-height: calc(100vh - 24px);
      box-shadow: 0 2px 15px rgba(0, 0, 0, 0.5);

      button {
        opacity: 1;
        transform: translateY(0) translateX(0);
      }
    }
  }

  /* Style the menu as a bottom bar */
  #sb-top .sb-actions.bottom-bar {
    position: fixed;
    bottom: 0;
    left: 0;
    padding: 10px 0;
    background: var(--top-background-color);
    width: 100vw;
    box-shadow: 0px 4px 8px black;
    justify-content: space-around;

    button {
      padding: 1em;
      margin: 0;
      height: unset;
      width: unset;

      svg {
        margin-bottom: -5px;
      }
    }
  }
}


================================================
FILE: client/types/command.ts
================================================
import type {
  CommandDef,
  SlashCommandDef,
} from "@silverbulletmd/silverbullet/type/manifest";

import type { SlashCompletions } from "@silverbulletmd/silverbullet/type/client";

export type Command = CommandDef & {
  run?: (args?: any[]) => Promise<any>;
  lastRun?: number;
};

export type SlashCommand = SlashCommandDef & {
  run: (...args: any[]) => Promise<SlashCompletions>;
};

export type CommandHookEvents = {
  commandsUpdated(commands: Map<string, Command>): void;
};


================================================
FILE: client/types/ui.ts
================================================
import type { Command } from "./command.ts";
import type {
  FilterOption,
  Notification,
  PanelMode,
} from "@silverbulletmd/silverbullet/type/client";

import type {
  DocumentMeta,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import type { Path } from "@silverbulletmd/silverbullet/lib/ref";
import type { SyncStatus } from "../spaces/sync.ts";

export type PanelConfig = {
  mode?: PanelMode;
  html?: string;
  script?: string;
};

export type AppViewState = {
  current?: {
    path: Path;
    meta: PageMeta | DocumentMeta;
  };

  allPages: PageMeta[];
  allDocuments: DocumentMeta[];

  isLoading: boolean;
  isMobile: boolean;
  showPageNavigator: boolean;
  showCommandPalette: boolean;
  showCommandPaletteContext?: string;
  unsavedChanges: boolean;
  isOnline: boolean;

  // Progress tracker
  progressPercentage?: number; // Used to show progress circle
  progressType?: string; // Used for styling

  panels: { [key: string]: PanelConfig };
  commands: Map<string, Command>;
  notifications: Notification[];

  uiOptions: {
    vimMode: boolean;
    darkMode?: boolean;
    markdownSyntaxRendering: boolean;
    forcedROMode: boolean;
    customStyles?: string;
  };

  // Page navigator mode
  pageNavigatorMode: "page" | "meta" | "document" | "all";

  // Filter box
  showFilterBox: boolean;
  filterBoxLabel: string;
  filterBoxPlaceHolder: string;
  filterBoxOptions: FilterOption[];
  filterBoxHelpText: string;
  filterBoxOnSelect: (option: FilterOption | undefined) => void;

  // Prompt
  showPrompt: boolean;
  promptMessage?: string;
  promptDefaultValue?: string;
  promptCallback?: (value: string | undefined) => void;

  // Confirm
  showConfirm: boolean;
  confirmMessage?: string;
  confirmCallback?: (value: boolean) => void;
};

export const initialViewState: AppViewState = {
  isLoading: false,
  showPageNavigator: false,
  showCommandPalette: false,
  pageNavigatorMode: "page",
  unsavedChanges: false,
  isOnline: true,
  uiOptions: {
    vimMode: false,
    darkMode: undefined,
    markdownSyntaxRendering: false,
    forcedROMode: false,
  },
  isMobile: false,
  panels: {
    lhs: {},
    rhs: {},
    bhs: {},
    modal: {},
  },
  allPages: [],
  allDocuments: [],
  commands: new Map(),

  notifications: [],
  showFilterBox: false,
  filterBoxHelpText: "",
  filterBoxLabel: "",
  filterBoxOnSelect: () => {
  },
  filterBoxOptions: [],
  filterBoxPlaceHolder: "",

  showPrompt: false,
  showConfirm: false,
};

export type Action =
  | { type: "page-loaded"; path: Path; meta: PageMeta }
  | { type: "page-changed" }
  | { type: "page-saved" }
  | { type: "document-editor-loaded"; path: Path; meta: DocumentMeta }
  | { type: "document-editor-changed" }
  | { type: "document-editor-saved" }
  | { type: "online-status-change"; isOnline: boolean }
  | { type: "update-current-page-meta"; meta: PageMeta }
  | { type: "update-page-list"; allPages: PageMeta[] }
  | { type: "update-document-list"; allDocuments: DocumentMeta[] }
  | { type: "start-navigate"; mode: "page" | "meta" | "document" | "all" }
  | { type: "stop-navigate" }
  | {
    type: "update-commands";
    commands: Map<string, Command>;
  }
  | { type: "show-palette"; context?: string; commands: Map<string, Command> }
  | { type: "hide-palette" }
  | { type: "show-notification"; notification: Notification }
  | { type: "dismiss-notification"; id: number }
  | {
    type: "show-panel";
    id: "rhs" | "lhs" | "bhs" | "modal";
    config: PanelConfig;
  }
  | { type: "hide-panel"; id: string }
  | {
    type: "show-filterbox";
    options: FilterOption[];
    placeHolder: string;
    helpText: string;
    label: string;
    onSelect: (option: FilterOption | undefined) => void;
  }
  | { type: "hide-filterbox" }
  | {
    type: "show-prompt";
    message: string;
    defaultValue: string;
    callback: (value: string | undefined) => void;
  }
  | { type: "hide-prompt" }
  | {
    type: "show-confirm";
    message: string;
    callback: (value: boolean) => void;
  }
  | { type: "hide-confirm" }
  | { type: "set-ui-option"; key: string; value: any }
  | {
    type: "set-progress";
    progressPercentage?: number;
    progressType?: string;
  };

/**
 * Client configuration that is set at boot time, doesn't change at runtime
 */
export type BootConfig = {
  spaceFolderPath: string;
  indexPage: string;
  readOnly: boolean;
  logPush?: boolean;
  // Sync configuration
  syncDocuments?: boolean;
  syncIgnore?: string;
  // These are all configured via ?query parameters, e.g. ?disableSpaceLua=1
  disableSpaceLua?: boolean;
  disableSpaceStyle?: boolean;
  disablePlugs?: boolean;
  performWipe?: boolean;
  performReset?: boolean;

  enableClientEncryption: boolean;
};

/**
 * Messages sent client -> service worker
 */
export type ServiceWorkerTargetMessage =
  | {
    type: "skip-waiting";
  }
  | { type: "config"; config: BootConfig }
  | { type: "flush-cache" }
  | { type: "shutdown" }
  | { type: "wipe-data" }
  | { type: "perform-file-sync"; path: string }
  | { type: "perform-space-sync" }
  | { type: "force-connection-status"; enabled: boolean }
  | { type: "get-encryption-key" }
  | { type: "set-encryption-key"; key: string };
/**
 * Events received from the service worker -> client
 */
export type ServiceWorkerSourceMessage = {
  type: "sync-status";
  status: Omit<SyncStatus, "snapshot">;
} | {
  type: "sync-conflict";
  path: string;
} | {
  type: "space-sync-complete";
  operations: number;
} | {
  type: "file-sync-complete";
  path: string;
  operations: number;
} | {
  type: "sync-error";
  message: string;
} | {
  type: "online-status";
  isOnline: boolean;
} | {
  type: "auth-error";
  message: string;
  actionOrRedirectHeader: string;
} | {
  type: "cacheFlushed";
} | {
  type: "dataWiped";
} | {
  type: "service-worker-started";
} | {
  type: "encryption-key";
  key: string;
};


================================================
FILE: client_bundle/bundle.go
================================================
package client_bundle

// Files in this folder are generated as part of the build process (except this one)
// The sole purpose of this file is to embed these generated client files into the server binary

import "embed"

//go:embed base_fs/*
//go:embed client/*
var BundledFiles embed.FS


================================================
FILE: libraries/Library/Std.md
================================================
---
tags: meta/library
---
Welcome to SilverBullet’s standard library. This library (all pages under `Library/Std`) ship with SilverBullet itself. Therefore, you will not find these pages in your space folder, even though they _appear_ to be located there. These pages are all read-only, so you cannot (directly) modify them.

The goal of the standard library is provide a base-level of useful commands, slash commands, page templates and scripts.

The remainder of this page documents what's included.

# Config
SilverBullet is configured via the [config APIs](https://silverbullet.md/API/config). Built-in configuration options and their default values are defined in [[^Library/Std/Config]].

# Page templates
Page templates can be triggered via the ${widgets.commandButton "Page: From Template"} command (some of them have command or keyboard shortcuts) and provide a convenient way to create pages of a certain type. You can create your own page templates using the [[^Library/Std/Page Templates/Page Template]] template.

The following page templates are part of the standard library:
${template.each(query[[
  from index.tag "page"
  where name:startsWith("Library/Std/Page Templates/")
]], template.new[==[
    * [[${_.name}]]: ${_.description}
]==])}

# Slash templates
Slash templates provide a quick way to insert frequently used snippets of code. You can create your own using the [[^Library/Std/Page Templates/Slash Template]] page template.

The following slash templates are part of the standard library:
${template.each(query[[
  from index.tag "page"
  where name:startsWith("Library/Std/Slash Templates/")
]], template.new[==[
    * [[${_.name}]]: ${_.description}
]==])}

# Meta pages 
The SilverBullet ships with a few generally useful meta pages (pages tagged with `#meta`) you can use:

${template.each(query[[
  from index.tag "page"
  where name:startsWith("Library/Std/Pages/")
]], templates.pageItem)}

# Infrastructure
Core functionality that is implemented using Space Lua:
${template.each(query[[
  from index.tag "page"
  where name:startsWith("Library/Std/Infrastructure/")
]], template.new[==[
    * [[${_.name}]]: ${_.description}
]==])}

# APIs
Whereas a lot of APIs in SilverBullet are built in, some of them have been implemented Space Lua:
${template.each(query[[
  from index.tag "page"
  where name:startsWith("Library/Std/APIs/")
]], templates.pageItem)}

# Editor support
Some editor features have been implemented in Space Lua:
${template.each(query[[
  from index.tag "page"
  where name:startsWith("Library/Std/Editor/")
]], templates.pageItem)}


================================================
FILE: libraries/Library/Std/APIs/Action Button.md
================================================
---
description: APIs to define custom action buttons
tags: meta/api
---

APIs to more conveniently define action buttons (alternative to setting them via regular [[^Library/Std/Config]]).

# API
## actionButton.define(spec)
Keys to define:

* `icon`: [feather icon](https://feathericons.com) to use for your button
* `run`: function to invoke once the button is pushed
* `description` (optional): description of button (appears on hover)
* `priority` (optional): determines priority of button (the higher, the earlier in the list)
* `mobile`: when set to `true` this button will only appear on mobile devices

# Example
```lua
-- Defines a new button that forces your UI into read-only mode
actionButton.define {
  icon = "eye",
  -- Uncomment the following line to only make this button appear 
  -- ONLY on mobile devices:
  --   mobile = true,
  run = function()
    editor.setUiOption("forcedROMode", true)
    editor.rebuildEditorState()
  end
}
```
# Implementation
```space-lua
-- priority: 100

actionButton = actionButton or {}

function actionButton.define(spec)
  local actionButtonConfig = config.get("actionButtons", {})
  table.insert(actionButtonConfig, spec)
end
```


================================================
FILE: libraries/Library/Std/APIs/Command.md
================================================
---
description: APIs to define custom commands and slash commands
tags: meta/api
---

APIs to define and patch commands and slash commands.

# API

## command.define(commandDef)
Registers a command.

Available keys:

* `name`: Name of the command
* `run`: Callback function
* `contexts`: AST node context in which this command should be available
* `priority`: Command priority (how high it appears in the list)
* `key`: Windows/Linux key binding (and mac, if not separately defined)
* `mac`: Mac-specific key binding
* `hide`: Hide this command from the Command Palette
* `requireMode`: `rw` or `ro` — only enable this command in a particular mode (read-write, or read-only)

Example:

```lua
command.define {
  name = "My custom command",
  run = function()
    editor.flashNotification "Triggered my custom command"
  end
}
```

## command.update(commandDef)
Equivalent to `command.define`, but can be used to update the definition of previously defined commands (including built-in ones).

Example:

```lua
-- To assign a new key binding and command priority to a built-in command
command.update {
  name = "Stats: Show",
  key = "Ctrl-Shift-t",
  priority = 100
}

-- To disable key bindings of an existing command
command.update {
  name = "Navigate: Document Picker",
  key = "",
  mac = "",
}
```

## slashCommand.define(spec)
Defines a custom slash command.

Supported keys in the spec:

* `name`: name of the command
* `description`: Description of the command
* `run`: The callback function that will be invoked once the command is run.

Example:

```lua
slashCommand.define {
  name = "hello-world",
  run = function()
    editor.insertAtCursor("Hello |^| world!", false, true)
  end
}
```

# Implementation
Most of the heavy lifting happens in SB itself. These are just wrappers around the config object API.

```space-lua
-- priority: 99
command = command or {}
slashCommand = slashCommand or {}

-- DEPRECATED: old name of API
slashcommand = slashCommand

function command.define(def)
  config.set({"commands", def.name}, def)
end

function command.update(newDef)
  local def = config.get({"commands", newDef.name}, {})
  for k, v in pairs(newDef) do
    def[k] = v
  end
  config.set({"commands", newDef.name}, def)
end

function slashCommand.define(def)
  config.set({"slashCommands", def.name}, def)
end
```


================================================
FILE: libraries/Library/Std/APIs/DOM.md
================================================
#meta/api

A library to easily build DOM objects through the magic of Lua meta tables.

# Usage

```lua
-- any HTML tag can be used here
dom.span {
  -- tag attributes can be set like this:
  class = "my-class",
  id = "my-id",
  -- Plain text body elements can be added like this
  "Span content",
  -- And elements can be nested
  dom.strong { "I am strong" },
  -- Widgets can also be embedded
  widget.html "<b>Bold</b>",
  widget.html(dom.marquee { "nested widget" })
}
```

# Examples
${widget.html(dom.marquee{
  "I'm in a ",
  dom.span {
    style="color:red;",
    "marquee"
  }
})}

# Implementation
```space-lua
-- priority: 50

local function appendHtmlNode(parent, html)
  local htmlNode = js.window.document.createElement("dummy")
  parent.appendChild(htmlNode)
  htmlNode.outerHTML = html
end

dom =  setmetatable({}, {
  __index = function(self, tag)
    return function(spec)
      local node = js.window.document.createElement(tag)
      for key, val in pairs(spec) do
        if type(key) == "string" then
          -- This is an attribute
          if key:startsWith("on") then
            node.addEventListener(key:sub(3), val)
          else
            node.setAttribute(key, val)
          end
        -- Handling body values
        elseif type(val) == "string" then
          -- Text (markdown) body, process through markdown renderer before injecting
          appendHtmlNode(node, markdown.markdownToHtml(val, {expand=true}))
        else
          if val._isWidget then
            -- It's a widget
            if type(val.html) == "string" then
              -- HTML string widget
              appendHtmlNode(node, val.html)
            else
              -- HTML DOM node, attach directly
              node.appendChild(val.html)
            end
          else
            -- It's likely another dom.* returned node, just add directly
            node.appendChild(val)
          end
        end
      end
      return node
    end
  end
})
```


================================================
FILE: libraries/Library/Std/APIs/Date.md
================================================
#meta/api

Useful date and time APIs as well as slash commands by default in `YYYY-MM-dd` format with times in `HH:mm:ss` (24h) format. 

Included slash commands:

* `/today` for today's date.
* `/tomorrow` for tomorrow's date.
* `/yesterday` for yesterday's date.

# Examples
Today: ${date.today()} and ${date.time()}

# Implementation
```space-lua
-- priority: 10

local DAY_SECONDS = 60 * 60 * 24

date = {
  date_format = "%Y-%m-%d",
  time_format = "%H:%M:%S",
}

function date.today()
  return os.date(date.date_format)
end

function date.tomorrow()
  return os.date(date.date_format, os.time() + DAY_SECONDS)
end

function date.yesterday()
  return os.date(date.date_format, os.time() - DAY_SECONDS)
end

function date.time()
  return os.date(date.time_format)
end

slashCommand.define {
  name = "today",
  run = function()
    editor.insertAtCursor(date.today())
  end
}

slashCommand.define {
  name = "yesterday",
  run = function()
    editor.insertAtCursor(date.yesterday())
  end
}

slashCommand.define {
  name = "tomorrow",
  run = function()
    editor.insertAtCursor(date.tomorrow())
  end
}
```


================================================
FILE: libraries/Library/Std/APIs/Schema.md
================================================
#meta/api

Utilities to succinctly define JSON schema types in Lua.

```space-lua
-- priority: 101
schema = schema or {}

function schema.string()
  return { type = "string" }
end

function schema.number()
  return { type = "number" }
end

function schema.boolean()
  return { type = "boolean" }
end

function schema.array(typ)
  if type(typ) == "string" then
    return {
      type = "array",
      items = { type = typ }
    }
  else
    return {
      type = "array",
      items = typ,
    }
  end
end

function schema.nullable(typ)
  if type(typ) == "string" then
    return {
      anyOf = {
        { type = typ },
        { type = "null" },
      }
    }
  else
    return {
      anyOf = {
        typ,
        { type = "null" },
      }
    }
  end
end

function schema.nullableArray(typ)
  if type(typ) == "string" then
    return {
      anyOf = {
        { type = "array",
          items = { type = typ } },
        { type =  "null" },
      }
    }
  else
    return {
      anyOf = {
        { type = "array",
          items = typ, },
        { type = "null" },
      }
    }
  end
end

-- Used to specify we're expecting a function, but doesn't deeply validate
function schema.func()
  return {}
end

-- Used to specify we're expecting a schema, but doesn't deeply validate
function schema.schema()
  return { type = "object" }
end

function schema.null()
  return { type = "null" }
end
```


================================================
FILE: libraries/Library/Std/APIs/Tag.md
================================================
#meta/api

Implements convenience functions for accessing tag objects.

Currently:

* `tags.someTag` is an alias for `index.tag "someTag`

Example:

${#query[[from tags.page]]}

# Implementation

```space-lua
-- priority: 50
tag = tag or {}

function tag.define(spec)
  local finalSpec = config.get({"tags", spec.name}, {})
  local metatable = nil
  for k, v in pairs(spec) do
    if k == "metatable" then
      metatable = v
    else
      finalSpec[k] = v
    end
  end
  config.set({"tags", spec.name}, finalSpec)
  if metatable then
    config.setLuaValue({"tags", spec.name, "metatable"}, metatable)
  end
end

-- Set up tags.* short cut via meta tables
tags = setmetatable({}, {
  __index = function(self, tag)
    return index.tag(tag)
  end
})
```


================================================
FILE: libraries/Library/Std/APIs/Task State.md
================================================
#meta/api #maturity/beta

Implements APIs for defining custom task states. Currently extremely basic.

# API

## taskState.define(def)
Defines a custom task state. Options:
* `name`: name of the state
* `done`: whether or not the state should be considered "done" or not, used by the `Task: Remove Completed` command

# Example
```lua
taskState.define {
  name = "TO DO"
}
taskState.define {
  name = "IN PROGRESS"
}
taskState.define {
  name = "DONE",
  done = true
}
```

# Implementation

```space-lua
-- priority: 50
taskState = taskState or {}

function taskState.define(spec)
  config.set({"taskStates", spec.name}, spec)
end
```


================================================
FILE: libraries/Library/Std/APIs/Template.md
================================================
#meta/api

Implements useful template functions

```space-lua
-- priority: 50
-- Template API root table
template = template or {}
-- Template storage table
templates = {}

-- Iterates over a table/array and applies a function to each element,
-- concatenating the results
function template.each(tbl, fn)
  local result = {}
  for _, item in ipairs(tbl) do
      table.insert(result, fn(item))
  end
  return table.concat(result)
end

-- Creates a new template function from a string template
function template.new(templateStr, stripIndent)
  -- Preprocess: strip indentation
  if stripIndent == nil or strpIndent == true then
    local lines = {}
    local splitLines = string.split(templateStr, "\n")
    for _, line in ipairs(splitLines) do
      line = string.gsub(line, "^    ", "")
      table.insert(lines, line)
    end
    templateStr = table.concat(lines, "\n")
  end
  return function(obj)
    return spacelua.interpolate(templateStr, obj)
  end
end

-- Reads a template from a page
-- Return value:
-- * The template itself
-- * The extracted frontmatter
function template.fromPage(name, raw)
  local fm = index.extractFrontmatter(space.readPage(name),  {
    removeFrontMatterSection = true,
    removeTags = true
  })

  local templateText = string.trimStart(fm.text)
  if raw then
    -- Don't actually parse this as a template, just pass it along
    return function()
      return templateText
    end, fm.frontmatter
  end
  return template.new(templateText, false), fm.frontmatter
end
```


================================================
FILE: libraries/Library/Std/APIs/Virtual Page.md
================================================
---
description: APIs to define virtual pages
tags: meta/api
---
Virtual pages are (read-only) pages that don't actually exist in your space, but are dynamically generated on the fly. Used to implement [[^Library/Std/Infrastructure/Tag Page]]

# API

## virtualPage.define(def)
Defines a virtual page for specified pattern. Options:
* `pattern`: a Lua regular expression, e.g. `"myprefix:(.+)"` the captured groups will be passed as arguments to the `run` function
* `run`: a callback function where each captured group in the regex will be passed as an argument

# Example
```lua
virtualPage.define {
  -- match any page name starting with "newtag:"
  pattern = "newtag:(.+)",
  run = function(name)
    return "Page:" .. name
  end
}
```

# Implementation
```space-lua
-- priority: 99
virtualPage = virtualPage or {}

-- options:
--   pattern
--   run()
function virtualPage.define(def)
  config.set({"virtualPages", def.pattern}, def)
end
```

```space-lua
-- priority: -1
event.listen {
  name = "editor:pageCreating",
  run = function(e)
    local pageName = e.data.name
    for _, def in pairs(config.get("virtualPages")) do
      local match = table.pack(pageName:match(def.pattern))
      if not(#match == 1 and match[1] == nil) then
        -- we got an actual match
        return {
          text = def.run(table.unpack(match)),
          perm = "ro"
        }
      end
    end
  end
}
```


================================================
FILE: libraries/Library/Std/APIs/Widget.md
================================================
#meta/api

This implements the widget API. For a Lua Directive to be rendered as a widget, you need to return it via `widget.new`. Consider using [[^Library/Std/APIs/DOM]] to construct these.

```space-lua
-- priority: 50

-- Widget APIs
widget = {}

-- Container for widgets
widgets = {}

-- Widget schema for validation
local widgetSchema = {
  type = "object",
  properties = {
    markdown = { type = "string"},
    html = {
      anyOf = {
        -- HTMLElement
        { type = "object"},
        -- Plain HTML code
        { type = "string" }
      }
    },
    cssClasses = {
      type = "array",
      items = { type = "string" },
    },
    display = {
      type = "string",
      enum = {"block", "inline"}
    },
    events = {
      type = "object",
      additionalProperties = true
    }
  }
}

-- Creates a widget
function widget.new(spec)
  -- Validate spec
  local validationResult = jsonschema.validateObject(widgetSchema, spec)
  if validationResult then
    error(validationResult)
  end
  -- Mark as a widget
  spec._isWidget = true
  return spec
end

-- Convenience function for HTML widgets
function widget.html(html)
  return widget.new {
    html = html
  }
end

function widget.htmlBlock(html)
  return widget.new {
    html = html,
    display = "block"
  }
end

-- Convenience function for markdown widgets
function widget.markdown(markdown)
  return widget.new {
    markdown = markdown
  }
end

function widget.markdownBlock(markdown)
  return widget.new {
    markdown = markdown,
    display = "block"
  }
end
```


================================================
FILE: libraries/Library/Std/Config.md
================================================
---
description: Defines the schemas and built-in values of built-in configuration options
tags: meta
---

SilverBullet uses the [config APIs](https://silverbullet.md/API/config) to manage its runtime configuration. You can set most of these anywhere in your space, but the convention is to use your [[CONFIG]] page for this purpose.

This meta page defines the JSON Schema of all built-in configuration settings, as well as their default values. You can override them by e.g. putting the following in your CONFIG page (use `space-lua` instead of `lua` in your actual page):

```lua
config.set("sync.documents", true)
```

# Built-in options (schema)

This defines the [JSON schema](https://json-schema.org/) for built-in configuration.

```space-lua
-- priority: 100

config.define("sync", {
  description = "Configure sync",
  type = "object",
  properties = {
    -- Defaults to false
    documents = schema.boolean(),
    -- In .gitignore format, either in a single string, or as a list of strings
    ignore = {
      oneOf = {
        schema.array "string",
        schema.string()
      }
    }
  },
  additionalProperties = false
})

config.define("index", {
  description = "Configure individual indexers",
  type = "object",
  properties = {
    paragraph = {
      type = "object",
      properties = {
        -- Index paragraphs without a hashtag
        all = schema.boolean(),
      },
    },
    item = {
      type = "object",
      properties = {
        -- Index items without a hashtag
        all = schema.boolean(),
      },
    },
    task = {
      type = "object",
      properties = {
        -- Index tasks without a hashtag
        all = schema.boolean(),
      },
    },
  },
  additionalProperties = true
})

config.define("plugs", {
  description = "List of plugs to enable",
  oneOf = {
    schema.array "string",
    { -- only as a fallback for an empty table in Lua
      type = "object",
    }
  },
})


-- Editor configuration options
config.define("autoCloseBrackets", {
  description = "List of brackets to auto close",
  type = "string",
})

config.define("shortWikiLinks", {
  description = "Render wiki links to just the last segment, e.g. Person/John becomes John",
  type = "boolean"
})

config.define("emoji", {
  description = "Additional emoji aliases",
  type = "object",
  properties = {
    aliases = {
      type = "object",
      additionalProperties = schema.string(),
    }
  }
})

config.define("smartQuotes", {
  description = "Configure smart quotes",
  type = "object",
  properties = {
    enabled = {
      type = "boolean",
      description = "Indicates whether smart quotes are enabled"
    },
    double = {
      type = "object",
      properties = {
        left = {
          type = "string",
          description = "Character for the left double quote"
        },
        right = {
          type = "string",
          description = "Character for the right double quote"
        }
      },
      additionalProperties = false
    },
    single = {
      type = "object",
      properties = {
        left = {
          type = "string",
          description = "Character for the left single quote"
        },
        right = {
          type = "string",
          description = "Character for the right single quote"
        }
      },
      additionalProperties = false
    },
  },
  additionalProperties = false
})

config.define("mobileMenuStyle", {
  type = "string",
})

config.define("vim", {
  description = "Vim mode configuration",
  type = "object",
  properties = {
    unmap = {
      description = "Keys to unmap",
      type = "array",
      items = {
        oneOf = {
          { type = "string" },
          {
            type = "object",
            properties = {
              key = { type = "string" },
              mode = {
                type = "string",
                enum = {"normal", "insert", "visual"}
              }
            },
            required = { "key" },
            additionalProperties = false
          }
        }
      }
    },
    map = {
      description = "Custom mappings",
      type = "array",
      items = {
        type = "object",
        properties = {
          map = { type = "string" },
          to = { type = "string" },
          mode = {
            type = "string",
            enum = {"normal", "insert", "visual"}
          }
        },
        required = { "map", "to" },
        additionalProperties = false
      }
    },
    noremap = {
      description = "Non-recursive custom mappings",
      type = "array",
      items = {
        type = "object",
        properties = {
          map = { type = "string" },
          to = { type = "string" },
          mode = {
            type = "string",
            enum = {"normal", "insert", "visual"}
          }
        },
        required = { "map", "to" },
        additionalProperties = false
      }
    },
    commands = {
      description = "Custom Ex commands",
      type = "array",
      items = {
        type = "object",
        properties = {
          command = { type = "string" },
          ex = { type = "string" }
        },
        required = { "command", "ex" },
        additionalProperties = false
      }
    },
  },
  additionalProperties = false
})

-- Query specific configuration options
config.define("queryCollation", {
  description = "Configure string ordering in queries",
  type = "object",
  properties = {
    enabled = {
      type = "boolean",
      description = "Indicates whether string collation should be used instead of simple codepoint ordering"
    },
    locale = {
      type = "string",
      description = "Language tag to specify sorting rules (from BCP 47)"
    },
    options = {
      type = "object",
      description = "Additional options passed to Intl.Collator constructor"
      -- See https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Collator/Collator#options
    },
  },
  additionalProperties = false
})

-- Configuration for internal use mostly

-- Don't use directly, use command.define instead
config.define("commands", {
  type = "object",
  additionalProperties = {
    type = "object",
    properties = {
      name = schema.string(),
      contexts = schema.nullableArray "string",
      priority = schema.nullable "number",
      key = {
        anyOf = {
          schema.string(),
          schema.array("string"),
          schema.null()
        }
      },
      mac = {
        anyOf = {
          schema.string(),
          schema.array("string"),
          schema.null()
        }
      },
      hide = schema.nullable "boolean",
      requireMode = schema.nullable {
        type = "string", 
        enum = {"rw", "ro"},
      },
      requireEditor = schema.nullable("string"),
      run = schema.func(),
    },
  },
})

-- Don't use directly, use slashCommands.define instead
config.define("slashCommands", {
  type = "object",
  additionalProperties = {
    type = "object",
    properties = {
      name = schema.string(),
      description = schema.nullable "string",
      priority = schema.nullable "number",
      onlyContexts = schema.nullableArray "string",
      exceptContexts = schema.nullableArray "string",
      run = schema.func(),
    },
  },
})

-- Don't use directly, use event.listen instead
config.define("eventHandlers", {
  type = "object",
  additionalProperties = schema.array(schema.func()),
})

-- Don't use directly, use mq.listen instead
config.define("mqSubscriptions", {
  type = "object",
  additionalProperties = {
    type = "array",
    items = {
      type = "object",
      properties = {
        batchSize = schema.number(),
        run = schema.func(),
      }
    }
  },
})

-- Task states
config.define("taskStates", {
  type = "object",
  additionalProperties = {
    type = "object",
    properties = {
      -- for future use
    },
  },
})

-- Don't use directly, WIP
config.define("tags", {
  type = "object",
  additionalProperties = {
    type = "object",
    properties = {
      name = schema.string(),
      schema = schema.schema(),
      -- Whether or not an object HAS to validate to be indexed (defaults to false), has a performance penalty
      mustValidate = schema.boolean(),
      -- Additional custom validation logic
      validate = schema.func(),
      -- Invoked by the object indexer, takes a proposed object as input, returns an array of objects (can be empty table to skip indexing altogether)
      transform = schema.func(),
    },
  },
})

-- Don't use directly, use actionButton.define instead
config.define("actionButtons", {
  type = "array",
  items = {
    type = "object",
    properties = {
      icon = {
        type = "string",
        description = "Icon for the action button, from https://feathericons.com"
      },
      description = {
        type = "string",
        description = "Optional description of the action button"
      },
      priority = {
        type = "number",
        description = "Optional priority: the higher the earlier the button will appear in the list"
      },
      mobile = {
        type = "boolean",
        description = "Optional boolean indicating if the action button is applicable for mobile"
      },
      run = schema.func(),
    },
    required = {"icon", "run"},
    additionalProperties = false
  }
})
```

# Default values
Default values for built-in configuration options.

```space-lua
-- priority: 99
config.set {
  index = {
    paragraph = { all = false },
    item = { all = true },
    task = { all = true },
  },
  taskStates = {},
  shortWikiLinks = true,
  actionButtons = {
    {
      icon = "home",
      description = "Go to the index page",
      priority = 3,
      run = function()
        editor.invokeCommand "Navigate: Home"
      end
    },
    {
      icon = "book",
      description = "Open page",
      priority = 2,
      run = function()
        editor.invokeCommand "Navigate: Page Picker"
      end
    },
    {
      icon = "terminal",
      description = "Run command",
      priority = 1,
      run = function()
        editor.invokeCommand "Open Command Palette"
      end,
    }
  },
}
```


================================================
FILE: libraries/Library/Std/Editor/Lua.md
================================================
#meta

Basic editor support for Space Lua, implemented in Space Lua.

Features:

* Basic code completion for globals, e.g. `string.gm` will be completed
* Navigation to global symbols implemented in Lua by Cmd-clicking (Mac) or Ctrl-clicking (other OSes)

# Code complete support
```space-lua
-- priority: 10
local LUA_KEYWORDS = {"do", "if", "then", "for", "else", "end", "function", "local", "return", "in"}

-- Are we in a comment?
local function inComment(line)
  return string.find(line, "%-%-")
end

-- Are we in a string?
local function inString(line)
  local singleQuotes = 0
  local doubleQuotes = 0
  local brackets = 0
  for i = 1, string.len(line) do
    local c = line[i]
    if c == "'" then
      singleQuotes = singleQuotes + 1
    elseif c == '"' then
      doubleQuotes = doubleQuotes + 1
    elseif c == "[" and line[i+1] == "[" and line:sub(i-5, i) != "query[" then
      brackets = brackets + 1
    elseif c == "]" and line[i-1] == "]" then
      brackets = brackets - 1
    end
  end
  return singleQuotes % 2 == 1 or doubleQuotes % 2 == 1 or brackets > 0
end

-- Converts (some) JSON schema to dummy Lua values for code complete to use
local function schemaToDummyValue(schema)
  if schema.type == "string" then
    return ""
  elseif schema.type == "number" then
    return 0
  elseif schema.type == "boolean" then
    return true
  elseif schema.type == "array" then
    return {}
  elseif schema.type == "object" then
    local t = {}
    for k, v in pairs(schema.properties) do
      t[k] = schemaToDummyValue(v)
    end
    return t
  else
    return nil
  end
end

-- Implements extremely simplistic global variable augmentation by scanning for local variables (declared via `local` and `from`)
local function augmentGlobal(localCode)
  local vars = {}
  -- Copy over all globals
  for k, v in pairs(_G) do
    vars[k] = v
  end
  -- Then approximate specific local tag-based object declarations in queries and add them
  -- matches `tags.something`, `index.tag "something"` and `index.tag("something")` patterns only
  for m in string.matchRegexAll(localCode, "(?:from|local)\\s+(\\w+)\\s+=\\s*(?:index\\.tag\\s*\"(\\w+)\"|index\\.tag\\s*\\(\"(\\w+)\"\\)|tags\\.(\\w+))") do
    local tag
    for i, t in ipairs(m) do
      if i > 2 and t then
        tag = t
      end
    end
    -- Do we have a schema for this tag?
    local schema = config.get({"tags", tag, "schema"})
    if schema then
      -- Schema exists, add to vars
      vars[m[2]] = schemaToDummyValue(schema)
    end
  end
  return vars
end

-- API code completion for Lua
-- Completes a.b-style property accesses 
event.listen {
  name = "editor:complete",
  run = function(e)
    local parents = e.data.parentNodes
    local luaCode = nil
    for _, parent in ipairs(parents) do
      if string.startsWith(parent, "FencedCode:space-lua") then
        luaCode = string.sub(parent, #"FencedCode:space-lua"+1)
      elseif string.startsWith(parent, "LuaDirective:") then
        luaCode = string.sub(parent, #"LuaDirective:"+1)
      end
    end
    -- Not in a Lua context
    if not luaCode then
      return
    end
    local linePrefix = e.data.linePrefix
    if inComment(linePrefix) or inString(linePrefix) then
      return
    end
    local pos = e.data.pos
    local propaccessPrefix = string.matchRegex(linePrefix, "([a-zA-Z_0-9]+\\.)*([a-zA-Z_0-9]*)$")
    if not propaccessPrefix or propaccessPrefix[1] == "" then
      -- No propaccess prefix, so we can't complete
      return
    end
    -- Split propaccess and traverse
    local propParts = string.split(propaccessPrefix[1], ".")
    local currentValue = augmentGlobal(luaCode)
    local failed = false
    for i = 1, #propParts-1 do
      local prop = propParts[i]
      if currentValue then
        currentValue = currentValue[prop]
      else
        failed = true
      end
    end
    if failed then
      return
    end
    local lastProp = propParts[#propParts]
    if table.includes(LUA_KEYWORDS, lastProp) then
      return
    end
    local options = {}
    if not currentValue then
      return
    end
    for key, val in pairs(currentValue) do
      if string.startsWith(key, lastProp) and val then
        if type(val) == "function" then
          -- We got a function
          if val.body then
            -- Function defined in Lua
            table.insert(options, {
              label = key .. "(" .. table.concat(val.body.parameters, ", ") ..")",
              apply = key,
              detail = "function"
            })
          else
            -- Builtin
            table.insert(options, {
              label = key .. "()",
              apply = key,
              detail = "built-in"
            })
          end
        else
          table.insert(options, {
            label = key,
            detail = type(val),
          })
        end
      end
    end
    if #options > 0 then
      return {
        from = pos - string.len(lastProp),
        options = options
      }
    end
  end
}
```

# Navigation
Ctrl/Cmd-click navigation to Lua function definition.

```space-lua
-- priority: 10
local function inLuaContext(parentNodes)
  for _, node in ipairs(parentNodes) do
    if node == "LuaDirective"
      or node:startsWith("FencedCode:space-lua") then
      return true
    end
  end
  return false
end

local function notDefinedinLua()
  return editor.flashNotification(
    "Cannot navigate to definition; not defined in Lua."
  )
end

event.listen {
  name = "page:click",
  run = function(e)
    -- Accept either Cmd-click (Mac) or Ctrl-click (Linux)
    if not (e.data.metaKey or e.data.ctrlKey) then
      return
    end
    if not inLuaContext(e.data.parentNodes) then
      return notDefinedinLua()
    end

    local pos = e.data.pos
    local text = editor.getText()
    -- Find start pos
    local startPos = pos
    while string.match(text[startPos], "[a-zA-Z0-9._]") do
      startPos = startPos - 1
      if startPos <= 0 then
        return
      end
    end
    -- Find end pos
    local endPos = pos
    while string.match(text[endPos], "[a-zA-Z0-9_]") do
      endPos = endPos + 1
      if startPos >= #text then
        return
      end
    end
    local callText = text:sub(startPos+1, endPos-1)
    local propParts = callText:split(".")
    local currentValue = _G
    for i = 1, #propParts do
      local prop = propParts[i]
      if currentValue then
        currentValue = currentValue[prop]
      else
        return
      end
    end
    -- Check if this is a Lua-defined API
    if currentValue and currentValue.body and currentValue.body.ctx then
      local ctx = currentValue.body.ctx
      -- Parse out the position in the doc
      local refBits = ctx.ref:split("@")
      -- Navigate there
      editor.navigate(refBits[1] .. "@" ..
        (tonumber(refBits[2]) + ctx.from + #"```space-lua\n")
      )
    else
      return notDefinedinLua()
    end
  end
}
```


================================================
FILE: libraries/Library/Std/Editor/Table.md
================================================
#meta

Implements utilities for working with Markdown tables

# Table
## Commands

```space-lua
command.define {
  name = "Table: Format",
  run = function()
    position = editor.getCursor()
    tree = markdown.parseMarkdown(editor.getText())
    tableNode = nodeParentOfType(tree, position, "Table")
    if tableNode then
      formatMarkdownTable(tableNode)
    else
      editor.flashNotification("This command must be run with cursor in a table", "error")
    end
  end
}
```

## Implementation

```space-lua
-- Find outermost node of given type that contains the position
-- similar to plug-api/lib/tree.ts nodeAtPos
function nodeParentOfType(tree, position, nodeType)
  if position < tree.from or position > tree.to then
    return nil
  end
  if tree.type == nodeType then
    return tree
  end
  if tree.children then
    for _, child in ipairs(tree.children) do
      if position >= child.from and position <= child.to then
        return nodeParentOfType(child, position, nodeType)
      end
    end
  end
  return nil
end
```

```space-lua
function formatMarkdownTable(tree)

  -- First find the desired length for each column
  local columnLengths = {}
  local columnAlignments = {}
  local tableHeader = nil
  local tableDelimiter = nil
  local tableRows = {}

  -- Separate parsing from output generation and identify table parts
  for childIdx, child in ipairs(tree.children) do
    if child.type == "TableDelimiter" then
      tableDelimiter = child
      local column = 1

      -- Extract delimiter text from children
      local delimiterText = ""
      for _, delimChild in ipairs(child.children or {}) do
        if delimChild.text and string.find(delimChild.text, "-") then
          delimiterText = delimChild.text
          break
        end
      end
      -- Split by | and process only non-empty parts, but skip first/last if empty
      local parts = string.split(delimiterText, "|")
      local startIdx = (parts[1] == "") and 2 or 1
      local endIdx = (parts[#parts] == "") and (#parts - 1) or #parts

      for i = startIdx, endIdx do
        local spec = string.trim(parts[i])
        local minLength = 1
        if #spec >= 2 and string.endsWith(spec, ":") then
          if string.startsWith(spec, ":") then
            columnAlignments[column] = "center"
            minLength = 3
          else
            columnAlignments[column] = "right"
            minLength = 2
          end
        elseif string.startsWith(spec, ":") then
          columnAlignments[column] = "left_explicit"
          minLength = 2
        else
          columnAlignments[column] = "left"
        end

        if minLength > (columnLengths[column] or 0) then
          columnLengths[column] = minLength
        end
        column = column + 1
      end
    elseif child.type == "TableHeader" then
      tableHeader = child
      local column = 1
      local isFirstCell = true

      for i, cell in ipairs(child.children) do
        local cellType = cell and cell.type or "nil"
        if cell and cell.type == "TableCell" then
          -- Found a TableCell directly - get its content
          local cellContent = ""
          for _, next_child in ipairs(cell.children) do
            cellContent = cellContent .. next_child.text
          end
          local trimmedContent = string.trim(cellContent)

          -- Skip empty first cell only
          if trimmedContent ~= "" or not isFirstCell then
            isFirstCell = false
            local len = #trimmedContent

            if len > (columnLengths[column] or 0) then
              columnLengths[column] = len
            end
            column = column + 1
          end
        end
      end
      -- columnLengths now contains width of each header column
    elseif child.type == "TableRow" then
      table.insert(tableRows, child)
      local column = 1
      local isFirstCell = true
      for i, cell in ipairs(child.children) do
        if cell and cell.type == "TableCell" then
          -- Get actual content length after trimming
          local cellContent = ""
          for _, next_child in ipairs(cell.children) do
            cellContent = cellContent .. next_child.text
          end
          local trimmedContent = string.trim(cellContent)

          -- Skip empty first cell only
          if trimmedContent ~= "" or not isFirstCell then
            isFirstCell = false
            local len = #trimmedContent

            if len > (columnLengths[column] or 0) then
              columnLengths[column] = len
            end
            column = column + 1
          end
        end
      end
    end
  end

  -- Helper function to format table row (one)
  local function formatTableRow(child)
    local rowOutput = "|"
    local column = 1
    local isFirstCell = true
    for i, cell in ipairs(child.children) do
      if cell and cell.type == "TableCell" then
        -- Get cell content
        local cellContent = ""
        for _, next_child in ipairs(cell.children) do
          cellContent = cellContent .. next_child.text
        end

        cellContent = string.trim(cellContent)

        -- Skip empty first cell only
        if cellContent ~= "" or not isFirstCell then
          isFirstCell = false
          local len = #cellContent

          -- Add exactly 1 space on each side (ignore alignment for content rows)
          rowOutput = rowOutput .. " " .. cellContent .. " |"

          column = column + 1
        end
      end
    end
    return rowOutput
  end


  -- Output in order: Header -> Delimiter -> Rows
  local output = ""

  if tableHeader then
    output = formatTableRow(tableHeader)
  end

  if tableDelimiter then
    if #output > 0 then
      output = output .. "\n"
    end
    output = output .. "|"
    for column, len in ipairs(columnLengths) do
      local align = columnAlignments[column]
      if align == "left" then
        output = output .. " " .. string.rep("-", len) .. " |"
      elseif align == "left_explicit" then
        output = output .. " :" .. string.rep("-", len - 1) .. " |"
      else
        output = output .. " " .. (align == "center" and ":" or "-") .. string.rep("-", len - 2) .. ": |"
      end
    end
  end

  for _, row in ipairs(tableRows) do
    if #output > 0 then
      output = output .. "\n"
    end
    output = output .. formatTableRow(row)
  end


  -- Replace the table node with the formatted content
  editor.replaceRange(tree.from, tree.to, output)
end
```


================================================
FILE: libraries/Library/Std/Infrastructure/Builtin Tags.md
================================================
---
description: Defines built-in tags.
tags: meta
---
Defined via [[^Library/Std/APIs/Tag]].

```space-lua
local function readOnlyType(type)
  return {
    type = type,
    readOnly = true
  }
end

local rangeType = {
  type = "array",
  items = { schema.number(), schema.number() },
  minItems = 2,
  maxItems = 2,
}

-- page
tag.define {
  name = "page",
  schema = {
    type = "object",
    additionalProperties = true,
    properties = {
      ref = readOnlyType("string"),
      tag = readOnlyType("string"),
      tags = {
        anyOf = {
          { type = "array", items = schema.string() },
          schema.string(),
        },
      },
      -- range of where fronmatter appears in the page (if present)
      range = rangeType,
      itags = {
        type = "array",
        readOnly = true,
        items = schema.string(),
        nullable = true,
      },
      name = schema.string(),
      pageDecoration = {
        type = "object",
        properties = {
          prefix = schema.nullable("string"),
          cssClasses = {
            type = "array",
            items = schema.string(),
            nullable = true,
          },
          hide = schema.nullable("boolean"),
          renderWidgets = schema.nullable("boolean"),
        },
        nullable = true,
      },
      displayName = schema.nullable("string"),
      aliases = {
        type = "array",
        items = schema.string(),
        nullable = true,
      },
      created = readOnlyType("string"),
      contentType = readOnlyType("string"),
      size = readOnlyType("number"),
      lastModified = readOnlyType("string"),
      perm = {
        type = "string",
        readOnly = true,
        enum = { "ro", "rw" },
      },
      lastOpened = readOnlyType("number"),
    },
  },
}

-- aspiring-page
tag.define {
  name = "aspiring-page",
  schema = {
    type = "object",
    additionalProperties = true,
    properties = {
      ref = readOnlyType("string"),
      tag = readOnlyType("string"),
      name = readOnlyType("string"),
      page = readOnlyType("string"),
      pos = readOnlyType("number"),
    },
  },
}

-- document
tag.define {
  name = "document",
  schema = {
    type = "object",
    additionalProperties = true,
    properties = {
      ref = readOnlyType("string"),
      tag = readOnlyType("string"),
      tags = {
        type = "array",
        readOnly = true,
        items = schema.string(),
        nullable = true,
      },
      itags = {
        type = "array",
        readOnly = true,
        items = schema.string(),
        nullable = true,
      },
      name = readOnlyType("string"),
      created = readOnlyType("string"),
      contentType = readOnlyType("string"),
      size = readOnlyType("number"),
      lastModified = readOnlyType("string"),
      perm = readOnlyType("string"),
      extension = readOnlyType("string"),
    },
  },
}

-- item
tag.define {
  name = "item",
  schema = {
    type = "object",
    additionalProperties = true,
    properties = {
      ref = readOnlyType("string"),
      tag = readOnlyType("string"),
      tags = schema.array("string"),
      itags = {
        type = "array",
        readOnly = true,
        items = schema.string(),
        nullable = true,
      },
      page = readOnlyType("string"),
      name = readOnlyType("string"),
      text = readOnlyType("string"),
      parent = readOnlyType("string"),
      range = rangeType,
      -- deprecated in favor of range
      pos = readOnlyType("number"),
    },
  },
}

-- tag
tag.define {
  name = "tag",
  schema = {
    type = "object",
    properties = {
      ref = readOnlyType("string"),
      tag = readOnlyType("string"),
      tags = schema.array("string"),
      itags = {
        type = "array",
        readOnly = true,
        items = schema.string(),
        nullable = true,
      },
      name = readOnlyType("string"),
      page = readOnlyType("string"),
      parent = readOnlyType("string"),
      context = readOnlyType("string"),
    },
  },
}

-- link
tag.define {
  name = "link",
  schema = {
    type = "object",
    properties = {
      ref = readOnlyType("string"),
      tag = schema.string(),
      -- type of link: page, file, url
      type = schema.string(),
      tags = schema.array("string"),
      itags = {
        type = "array",
        items = schema.string(),
        nullable = true,
        readOnly = true,
      },
      name = schema.string(),
      page = schema.string(),
      alias = schema.string(),
      pageLastModified = schema.string(),
      toFile = schema.nullable("string"),
      toPage = schema.nullable("string"),
      toURL = schema.nullable("string"),
      snippet = schema.string(),
      range = rangeType,
      -- deprecated in favor of range
      pos = schema.number(),
    },
  },
}

-- header
tag.define {
  name = "header",
  schema = {
    type = "object",
    properties = {
      ref = readOnlyType("string"),
      tag = readOnlyType("string"),
      tags = schema.array("string"),
      itags = {
        type = "array",
        items = schema.string(),
        nullable = true,
      },
      name = readOnlyType("string"),
      page = readOnlyType("string"),
      range = rangeType,
      level = readOnlyType("number"),
      -- deprecated in favor of range
      pos = readOnlyType("number"),
    },
  },
}

-- paragraph
tag.define {
  name = "paragraph",
  schema = {
    type = "object",
    properties = {
      ref = readOnlyType("string"),
      tag = readOnlyType("string"),
      tags = schema.array("string"),
      itags = {
        type = "array",
        items = schema.string(),
        readOnly = true,
        nullable = true,
      },
      text = readOnlyType("string"),
      page = readOnlyType("string"),
      range = rangeType,
      -- deprecated in favor of range
      pos = readOnlyType("number"),
    },
  },
}

-- table
tag.define {
  name = "table",
  schema = {
    type = "object",
    properties = {
      ref = readOnlyType("string"),
      tag = readOnlyType("string"),
      page = schema.string(),
      range = rangeType,
      -- deprecated in favor of range
      pos = schema.number(),
    },
  },
}

-- task
tag.define {
  name = "task",
  schema = {
    type = "object",
    additionalProperties = true,
    properties = {
      ref = readOnlyType("string"),
      tag = readOnlyType("string"),
      tags = schema.array("string"),
      itags = {
        type = "array",
        items = schema.string(),
        readOnly = true,
        nullable = true,
      },
      name = readOnlyType("string"),
      page = readOnlyType("string"),
      parent = readOnlyType("string"),
      range = rangeType,
      -- deprecated in favor of range
      pos = readOnlyType("number"),
      text = readOnlyType("string"),
      state = readOnlyType("string"),
      done = readOnlyType("boolean"),
    },
  },
}

-- page templates
tag.define {
  name = "meta/template/page",
  schema = {
    type = "object",
    properties = {
      tags = {
        anyOf = {
          { type = "array", items = schema.string() },
          schema.string(),
        },
      },
      frontmatter = schema.string(),
      suggestedName = schema.string(),
      confirmName = schema.boolean(),
      openIfExists = schema.boolean(),
      command = schema.string(),
      key = {
        anyOf = {
          { type = "array", items = schema.string() },
          schema.string(),
        },
      },
      mac = {
        anyOf = {
          { type = "array", items = schema.string() },
          schema.string(),
        },
      },
      priority = schema.number(),
      description = schema.string(),
    },
  }
}

-- slash templates
tag.define {
  name = "meta/template/slash",
  schema = {
    type = "object",
    properties = {
      tags = {
        anyOf = {
          { type = "array", items = schema.string() },
          schema.string(),
        },
      },
      description = schema.string(),
      priority = schema.number(),
      onlyContexts = schema.array("string"),
      exceptContexts = schema.array("string"),
    },
  }
}
```

================================================
FILE: libraries/Library/Std/Infrastructure/Export.md
================================================
---
description: Implements the infrastructure of the Export commands.
tags: meta
---
Provides infrastructure for exporting pages outside of your space. It standardizes the ${widgets.commandButton("Export: Page Or Selection")} to export the current page or selection in various ways.

# Architecture
Flow:

1. Service discovery with `export` selector with data:
  * `pageMeta`
  * `text` (either the selection text or the entire page text)
2. Respond with:
  * `name`
  * `description`
  * `priority`
3. Ask user to select option, then invokes the selected service.

# Implementation
```space-lua
command.define {
  name = "Export: Page Or Selection",
  key = "Ctrl-e",
  mac = "Cmd-e",
  run = function()
    editor.save()
    local text = editor.getText()
    local meta = editor.getCurrentPageMeta()
    local selection = editor.getSelection()
    local exportObj = {
      pageMeta = meta,
      text = selection.text != "" and selection.text or text
    }
    local services = service.discover("export", exportObj)
    if #services == 0 then
      editor.flashNotification("No exporters available", "error")
      return
    end
    local selectedOption = editor.filterBox("Export to",
      services, "Select your export mechanism")
    if not selectedOption then
      return
    end
    service.invoke(selectedOption, exportObj)
  end
}
```

# Clipboard exporters
Implements two exporters:
* Copy rich text (e.g. for pasting into a Google Docs)
* Copy clean markdown

```space-lua
service.define {
  selector = "export",
  match = {
    name = "Clipboard: Export Rich Text",
    description = "To paste into Google Docs or other WYSIWYG environment"
  },
  run = function(data)
    local mdTree = markdown.parseMarkdown(data.text)
    mdTree = markdown.expandMarkdown(mdTree)
    local html = markdown.markdownToHtml(markdown.renderParseTree(mdTree))
    editor.copyToClipboard(js.new(js.window.Blob, {html}, {type="text/html"}))
  end
}

service.define {
  selector = "export",
  match = {
    name = "Clipboard: Export Clean Markdown",
    description = "To paste into another markdown supporting tool"
  },
  run = function(data)
    local mdTree = markdown.parseMarkdown(data.text)
    mdTree = markdown.expandMarkdown(mdTree)
    local renderedMd = markdown.renderParseTree(mdTree)
    editor.copyToClipboard(renderedMd)
  end
}
```


================================================
FILE: libraries/Library/Std/Infrastructure/Github.md
================================================
---
description: Integration with Github repositories and gists.
tags: meta
---
Support for [SilverBullet Share](https://silverbullet.md/Share) for:

* Github repo files
* Github gists

As well as [URI](https://silverbullet.md/URIs) support (both read and write) for the following schemes:
* `https://github.com/username/repo/blob/branch/path`
* `https://gist.github.com/username/gist-id`
* `github:username/repo@branch/path`
* `github:username/repo/path` (defaults to `main` branch)
* `ghr:username/repo@version/path` (github release support)

# Configuration
If you only want to read from Github URLs, no configuration is required.

To _write_ to Github repos and gists, you need to get a [personal Github token](https://github.com/settings/personal-access-tokens) (with repo and gists permissions). Configure your token somewhere in Space Lua (use a `space-lua` block), ideally a `SECRETS` page.

```lua
config.set("github.token", "your token")
```

In addition, to push to Github repos you need to configure a name and email that will be part of the commit:

```lua
config.set("github.name", "John Doe")
config.set("github.email", "john@doe.com")
```

# Implementation

## Constants
```space-lua
-- priority: 50
github = {}
```

## Github Repo Files
```space-lua
-- returns (something/bla, branch, path)
function github.extractData(url)
  if url == nil then
    return nil
  end
  return url:match("github%.com/([^/]+/[^/]+)/[^/]+/([^/]+)/(.+)")
end

function github.buildAPIURL(repo, path)
  return "https://api.github.com/repos/" .. repo .. "/contents/" .. path
end

function github.buildAPIURLWithBranch(repo, branch, path)
  return github.buildAPIURL(repo, path) .. "?ref=" .. branch
end

function github.buildURI(repo, branch, path)
  return "https://github.com/" .. repo .. "/blob/" .. branch .. "/" .. path
end

function github.request(url, method, body)
  local token = config.get("github.token")
  if not token then
    error("github.token config not set")
  end
  return net.proxyFetch(url, {
    method = method,
    headers = {
      Authorization = "token " .. token,
      Accept = "application/vnd.github+json"
    },
    body = body
  })
end

function github.checkConfig()
  if not config.get("github.token") then
    error("github.token needs to be set")
  end
  if not config.get("github.name") then
    error("github.name needs to be set")
    return
  end
  if not config.get("github.email") then
    error("github.email needs to be set")
    return
  end
end

service.define {
  selector = "share:onboard",
  match = {
    name = "Github file",
    description = "Share this page as a file on a github repo",
  },
  run = function(data)
    local name = data.name
    local content = data.text
    -- Check configuration
    local checkOk, err = pcall(github.checkConfig)
    if not checkOk then
      editor.flashNotification(err, "error")
      return
    end
    repo = editor.prompt "Github repo (user/repo):"
    if not repo then
      return
    end
    branch = editor.prompt("Branch:", "main")
    if not branch then
      return
    end
    path = editor.prompt("File path:", name .. ".md")
    if not path then
      return
    end
    -- Ask for a commit message
    local message = editor.prompt("Commit message:", "Commit")
    -- Push the change
    local resp = github.request(github.buildAPIURL(repo, path), "PUT", {
      message = message,
      committer = {
        name = config.get("github.name"),
        email = config.get("github.email"),
      },
      branch = branch,
      content = encoding.base64Encode(content)
    })
    if resp.ok then
      local uri = github.buildURI(repo, branch, path)
      return {
        uri = uri,
        hash = share.contentHash(content),
        mode = "push"
      }
    else
      js.log("Error", resp)
      error("Error, check console")
    end
  end
}

-- writeURI support
service.define {
  selector = "net.writeURI:https://github.com/*",
  match = {priority=10},
  run = function(data)
    local uri = data.uri
    local content = data.content
    -- Check configuration
    local checkOk, err = pcall(github.checkConfig)
    if not checkOk then
      editor.flashNotification(err, "error")
      return
    end
    local repo, branch, path = github.extractData(uri)
    -- We did find an existing file, let's fetch it to get the SHA
    local oldContent = github.request(github.buildAPIURLWithBranch(repo, branch, path), "GET")
    if not oldContent.ok then
      error("Could not fetch existing file")
    end
    local sha = oldContent.body.sha
    -- Ask for a commit message
    local message = editor.prompt("Commit message:", "Commit")
    -- Push the change
    local resp = github.request(github.buildAPIURL(repo, path), "PUT", {
      message = message,
      committer = {
        name = config.get("github.name"),
        email = config.get("github.email"),
      },
      branch = branch,
      sha = sha,
      content = encoding.base64Encode(content)
    })
    if not resp.ok then
      js.log("Error", resp)
      error("Error, check console")
    end
  end
}

service.define {
  selector = "net.writeURI:github:*",
  match = {priority=10},
  run = function(data)
    local uri = data.uri:sub(#"github:"+1)
    local owner, repo, path = table.unpack(uri:split("/"))
    local repo, branch = table.unpack(repo:split("@"))
    if not branch then
      branch = "main"
    end
    local fullUrl = "https://github.com/" .. owner .. "/" .. repo .. "/blob/" .. branch .. "/" .. path
    -- Redirect to full URI implementation
    net.writeURI(fullUrl, data.content)
  end
}

-- readURI
--   ghr:owner/repo/path (latest)
--   ghr:owner/repo@version/path
service.define {
  selector = "net.readURI:ghr:*",
  match = { priority=10 },
  run = function(data)
    local uri = data.uri:sub(#"ghr:"+1)
    local owner, repo, path = table.unpack(uri:split("/"))
    local repoClean, version = table.unpack(repo:split("@"))
    local url
    if not version or version == "latest" then
      url = "https://api.github.com/repos/"
            .. owner .. "/" .. repoClean .. "/releases/latest"
    else
      url = "https://api.github.com/repos/" .. owner .. "/" .. repoClean .. "/releases/tags/" .. version
    end
    local res = net.proxyFetch(url)
    if res.status != 200 then
      print("Could not fetch", url, res)
      return
    end
    local releaseInfo = res.body
    version = releaseInfo.tag_name
    local url = "https://github.com/" .. owner .. "/" .. repoClean .. "/releases/download/" .. version .. "/" .. path
    local res = net.proxyFetch(url, {responseEncoding=data.encoding})
    if res.status != 200 then
      print("Failed to fetch", ur, res)
      return nil
    end
    return res.body
  end
}

-- readURI
--   github:owner/repo/path (defaults to "main" branch)
--   github:owner/repo@branch/path
service.define {
  selector = "net.readURI:github:*",
  match = { priority=10 },
  run = function(data)
    local uri = data.uri:sub(#"github:"+1)
    local owner, repo, path, branch
    owner, repo, path = table.unpack(uri:split("/"))
    repo, branch = table.unpack(repo:split("@"))
    if not branch then
      branch = "main"
    end
    local url = "https://raw.githubusercontent.com/" .. owner .. "/" .. repo .. "/" .. branch .. "/" .. path
    local res = net.proxyFetch(url)
    if res.status != 200 then
      return nil
    end
    return res.body
  end
}

-- readURI
--   https://github.com/owner/repo/blob/branch/path
service.define {
  selector = "net.readURI:https://github.com/*",
  match = {priority=10},
  run = function(data)
    local owner, repo, branch, path = data.uri:match("github%.com/([^/]+)/([^/]+)/[^/]+/([^/]+)/(.+)")
    local url = "https://raw.githubusercontent.com/" .. owner .. "/" .. repo .. "/" .. branch .. "/" .. path
    local res = net.proxyFetch(url)
    if res.status != 200 then
      return nil
    end
    return res.body
  end
}
```

## Gists
```space-lua
local function extractGistId(url)
  if not url:startsWith("https://gist.github.com/") then
    return nil
  end
  return url:match("([^/]+)$")
end

-- Share onboarding
service.define {
  selector = "share:onboard",
  match = {
    name = "Github Gist",
    description = "Share this page as a gist"
  },
  run = function(data)
    local filename = "content.md"
    local text = share.cleanFrontmatter(editor.getText())
    filename = editor.prompt("File name", filename)
    if not filename then
      return
    end
    local resp = github.request("https://api.github.com/gists", "POST", {
      public = true,
      files = {
        [filename] = {
          content = text
        }
      }
    })
    if resp.ok then
      return {
        uri = resp.body.html_url,
        hash = share.contentHash(text),
        mode = "push"
      }
    else
      editor.flashNotification("Error, check console")
      js.log("Error", resp)
    end
  end
}

-- readURI supports
--   https://gist.github.com/user/gistid
service.define {
  selector = "net.readURI:https://gist.github.com/*",
  match = {priority=10},
  run = function(data)
    local gistId = extractGistId(data.uri)
    local resp = net.proxyFetch("https://api.github.com/gists/" .. gistId)
    if resp.status != 200 then
      print("Failed to fetch gist", resp)
      return nil
    end
    local files = resp.body.files
    for filename, data in pairs(files) do
      if data.content then
        return data.content
      end
    end
    return nil
  end
}

-- writeURI
service.define {
  selector = "net.writeURI:https://gist.github.com/*",
  match = {priority=10},
  run = function(data)
    local gistId = extractGistId(data.uri)
    
    -- First fetch the gist to find the file name
    local resp = github.request("https://api.github.com/gists/" .. gistId, "GET")
    if not resp.ok then
      js.log("Error with gist writeURI", resp)
      error("Gist writeURI failed")
    end
    local files = resp.body.files
    local selectedFilename
    -- Find a .md file in the gist to overwrite
    for filename, meta in pairs(files) do
      if filename:endsWith(".md") then
        selectedFilename = filename
        break
      end
    end
    if not selectedFilename then
      error("Could not find markdown file in gist")
    end
    local resp = github.request("https://api.github.com/gists/" .. gistId, "PATCH", {
      public = true,
      files = {
        [selectedFilename] = {
          content = data.content
        }
      }
    })
    if not resp.ok then
      js.log("Error", resp)
      error("Error publishing gist, check console")
    end
  end
}
```


================================================
FILE: libraries/Library/Std/Infrastructure/Library.md
================================================
#meta

This implements library management

# Implementation
```space-lua
-- namespace
library = {
  libraryTag = "meta/library",
  remoteLibraryTag = "meta/library/remote",
  repositoryTag = "meta/repository"
}

local libraryPrefix = "Library/"
local repositoryPrefix = "Repositories/"

-- Schema of frontmatter of the library page
-- Schema of #meta/library objects
local librarySchema = {
  type = "object",
  properties = {
    name = schema.string(),
    files = schema.array(schema.string())
  },
  required = {"name"}
}

local remoteLibrarySchema = {
  type = "object",
  properties = {
    name = schema.string(),
    description = schema.string(),
    website = schema.string(),
    uri = schema.string(),
  },
  required = {"name", "uri"}
}

-- Repo management commands
command.define {
  name = "Library: Add Repository",
  run = function()
    local repoUri = editor.prompt("URI:")
    if not repoUri then
      return
    end
    local text = net.readURI(repoUri, {encoding="text/markdown"})
    local suggestedName = library.suggestRepoNameFromUri(repoUri)
    local repoPage = editor.prompt("Install into:", repositoryPrefix .. suggestedName)
    if not repoPage then
      return
    end
    if space.pageExists(repoPage) then
      editor.flashNotification(repoPage .. " already exists", "error")
      return
    end
    space.writePage(repoPage, share.setFrontmatter({
      uri = repoUri,
      hash = share.contentHash(text),
      mode = "pull"
    }, text))
    editor.flashNotification "Library installed"
    editor.navigate(repoPage)
    reloadEverything()
  end
}

command.define {
  name = "Library: Update All Repositories",
  run = function()
    for repo in query[[from index.tag(library.repositoryTag)]] do
      if repo.share then
        share.sharePage(repo.name)
      end
    end
    editor.flashNotification "All repositories updated"
  end
}

-- Library management commands
command.define {
  name = "Library: Install",
  run = function()
    local uri = editor.prompt("URI:")
    if not uri then
      return
    end
    local libraryPage = library.install(uri)
    if libraryPage then
      editor.flashNotification "Library installed"
      reloadEverything()
      editor.navigate(libraryPage)
    end
  end
}

command.define {
  name = "Library: Update All",
  run = function()
    for lib in query[[
      from index.tag(library.libraryTag)
      where _.share and _.share.mode == "pull"
    ]] do
      if library.update(lib.name, false) then
        editor.flashNotification("Updated " .. lib.name)
      end
    end
    editor.flashNotification "Update complete!"
    reloadEverything()
  end
}

command.define {
  name = "Library: Manager",
  run = function()
    editor.navigate("Library/Std/Pages/Library Manager")
  end
}

-- Look up an installed library by name
function library.getInstalled(uri)
  local installedFMs = query[[
    from index.tag(library.libraryTag)
    where _.share and _.share.uri == uri
  ]]
  if #installedFMs == 0 then
    return nil
  else
    return installedFMs[1]
  end
end

-- Install a library
function library.install(uri, currentHash)
  -- Fetch from remote URL
  local text = net.readURI(uri, {
    encoding = "text/markdown"
  })

  if not text then
    error("Could not fetch: " .. uri)
  end

  local remoteLibFM = index.extractFrontmatter(text).frontmatter
  local remoteHash = share.contentHash(text)
  
  local err = jsonschema.validateObject(librarySchema, remoteLibFM)
  if err then
    editor.flashNotification("Library frontmatter validation error: " .. err, "error")
    return
  end
  
  local pageName = remoteLibFM.name

  -- If this is a fresh install, don't override existing pages
  if not currentHash and space.pageExists(pageName) then
    error("Page already exists: " .. pageName)
  end

  -- Check if update is required based on hash
  if currentHash and currentHash == remoteHash then
    return false
  end

  -- Ok, let's do this
  local sourceBase = urlDir(uri)
  local targetBase = urlDir(pageName)
  text = share.setFrontmatter({
    uri = uri,
    hash = remoteHash,
    mode = "pull"
  }, text)
  
  -- First write the main file
  space.writePage(pageName, text)
  
  -- And download and write assets, if any
  if remoteLibFM.files then
    for file in remoteLibFM.files do
      print("Downloading file", file)
      local targetPath = targetBase .. file
      local data = net.readURI(sourceBase .. file, {encoding="application/octet-stream"})
      if not data then
        error("Could not fetch file: " .. sourceBase .. file)
      end
      space.writeFile(targetPath, data)
    end
  end
  return pageName
end

-- Update a library
function library.update(pageName, force)
  local text = space.readPage(pageName)
  local fm = index.extractFrontmatter(text).frontmatter
  if fm.share then
     return library.install(fm.share.uri, fm.share.hash)
  else
    print("No 'share' key found in frontmatter for " .. pageName)
  end
end

function library.remove(pageName)
  local text = space.readPage(pageName)

  if not text then
    error("Could not read: " .. pageName)
  end

  local fm = index.extractFrontmatter(text).frontmatter

  local targetBase = urlDir(pageName)

  -- Remove associated files first
  if fm.files then
    for file in fm.files do
      local p = targetBase .. file
      space.deleteFile(p)
    end
  end
  
  -- Then page itself
  space.deletePage(pageName)
end

function library.installedLibrariesWidget()
  local rows = {}
  for lib in query[[
    from index.tag "meta/library"
  ]] do
    table.insert(rows, dom.tr {
      dom.td { "[[" .. lib.name .. "|" .. lib.name .. "]]" },
      dom.td {
        (
          not lib.share and ""
        ) or (
          lib.share.mode == "pull" and
          dom.span {
            widgets.button("Update", function()
              local updated = library.update(lib.name, true)
              if updated then
                editor.flashNotification "Updated."
                reloadEverything()
              else
                editor.flashNotification "No update available."
              end
            end, { class = "update" }),
            widgets.button("Remove", function()
              if editor.confirm("Are you sure?") then
                library.remove(lib.name)
                editor.flashNotification "Done!"
                reloadEverything()
              end
            end, { class = "remove"})  
          }
        ) or (
          dom.span {
            widgets.button("Publish", function()
              local updated = library.update(lib.name, true)
              if updated then
                editor.flashNotification "Published."
                reloadEverything()
              else
                editor.flashNotification "No changes to publish."
              end
            end, { class = "update" })
          }
        )
      }
    })
  end
  if #rows == 0 then
    rows = {
      dom.tr {
        dom.td { colspan="2", "_Nothing yet_" }
      }
    }
  end
  return widget.htmlBlock(dom.table {
    class = "manage-library",
    dom.thead {
      dom.tr {
        dom.td {"Library"},
        dom.td {"Action"}
      }
    },
    dom.tbody(rows)
  })
end

function library.installableLibrariesWidget()
  local installableLibraries = query[[from index.tag "meta/library/remote"]]
  local rows = {}
  for lib in query[[
    from installableLibraries where not library.getInstalled(_.uri)
  ]] do
    table.insert(rows, dom.tr {
      dom.td {
        dom.a {
          href = lib.website or lib.uri:startsWith("https://") and lib.uri,
          "**" .. lib.name .. "**",
        },
        " _([[" .. lib.page .. "]])_",
        lib.description and dom.p { lib.description } or ""
      },
      dom.td {
        widgets.button("Install", function()
          editor.flashNotification "Installing..."
          local libraryPage = library.install(lib.uri)
          if libraryPage then
            editor.flashNotification "Done!"
            reloadEverything()
            editor.navigate(libraryPage)
          end
        end, { class = "install" })
      }
    })
  end
  
  return widget.htmlBlock(dom.table {
    class = "manage-library",
    dom.thead {
      dom.tr {
        dom.td {"Library"},
        dom.td {"Action"}
      }
    },
    dom.tbody(rows)
  })
end

function library.installedRepositoriesWidget()
  local rows = {}
  for repo in query[[from index.tag(library.repositoryTag)]] do
    table.insert(rows, dom.tr {
      dom.td { "[[" .. repo.name .. "|" .. repo.name .. "]]" },
      dom.td {
        repo.share and dom.span {
          widgets.button("Update", function()
            if share.sharePage(repo.name) then
              editor.flashNotification "Repository updated"
              reloadEverything()
            else
              editor.flashNotification "No changes"
            end
          end),
          widgets.button("Remove", function()
            if editor.confirm("Are you sure?") then
              space.deletePage(repo.name)
              editor.flashNotification "Done!"
              reloadEverything()
            end
          end, { class = "remove" })
        } or ""
      }
    })
  end
  return widget.htmlBlock(dom.table {
    class = "manage-library",
    dom.thead {
      dom.tr {
        dom.td {"Repository"},
        dom.td {"Action"}
      }
    },
    dom.tbody(rows)
  })
end

-- Utility functions
local function urlDir(url)
  return string.match(url, "^(.*/)[^/]+$")
end

-- Construct full library path from name
function library.libPath(name)
  return libraryPrefix .. name
end

-- Extract name from full path
function library.libName(path)
  return path:sub(#libraryPrefix + 1)
end

function library.repoName(path)
  return path:sub(#repositoryPrefix + 1)
end

function library.suggestRepoNameFromUri(uri)
  -- Take the bit after the last :
  local uriParts = uri:split(":")
  local suggestedName = uriParts[#uriParts]
  -- Strip some generic suffixes
  for strippableSuffix in {".md", "/REPO", "/main", "/blob", "/silverbullet-libraries"} do
    suggestedName = suggestedName:gsub(strippableSuffix .. "$", "")
  end
  -- Strip branch names (@something)
  suggestedName = suggestedName:gsub("@.+$", "")
  -- Take the the last bit
  local parts = suggestedName:split("/")
  return parts[#parts]
end

function reloadEverything()
  mq.awaitEmptyQueue("indexQueue")
  editor.reloadConfigAndCommands()
  codeWidget.refreshAll()
end
```

# Styles
```space-style
table.manage-library td {
  vertical-align: top;
  white-space: normal !important;
}

table.manage-library thead td {
  white-space: nowrap !important;
}

table.manage-library thead td:last-child {
  text-align: right;
}

table.manage-library tbody td:last-child {
  text-align: right;
}
```


================================================
FILE: libraries/Library/Std/Infrastructure/Page Templates.md
================================================
---
description: Implements the infrastructure of Page Templates.
tags: meta
---
Page templates can be used to automate or simplify the creation of pages based on a template.

# Creating a page template
To create a page template, create a page and tag it with `#meta/template/page`.

The last component of the page name will be used as the template name.

## Cursor placement
In the template’s body text, you can optionally use `|^|` as a placeholder for where the cursor should be after creating
the page.

## Configuration
Optional keys you can set in the page’s frontmatter:

* `suggestedName`: the proposed name for the new page, can use embedded Lua expressions, like `Daily/${date.today()}`.
* `confirmName`: Confirm the suggested page name before creating it (defaults to `true`). Only effects pages created by a key binding, otherwise you will always be prompted to confirm the new page name.
* `openIfExists`: If a page with the `suggestedName` already exists, open it rather than showing an error.
* `command`: expose the page template as a command with this name.
* `key`: Bind the snippet to a keyboard shortcut (note: this requires to _also_ specify the `command` configuration).
* `mac`: Bind the snippet to a Mac-specific keyboard shortcut.
* `frontmatter`: Frontmatter (encoded as a string) to set in the resulting page.
* `priority`: Similar to how space lua scripts are loaded, this controls the order in which page template _commands_ are
  created (see "overriding page templates" below)

## Example: Daily note
The following creates a page template that can be run using the `Journal: Daily Note` command, it automatically creates
`Daily/today’s date` if it does not already exist with a bulleted list, putting the cursor at the first bullet. If the
page already exists, it navigates there.

~~~
---
command: "Journal: Daily Note"
suggestedName: "Daily/${date.today()}"
confirmName: false
openIfExists: true
tags: meta/template/page
---
* |^|
~~~

# Currently active page templates

${template.each(query[[
  from index.tag "meta/template/page"
  where _.tag == "page"
]], templates.fullPageItem)}

# Instantiating page templates

You can create a page based on a page template via the ${widgets.commandButton("Page: From Template")} command, or via
the command name that you defined in your template’s frontmatter.

# Overriding page templates

If you would like to override an existing page template (for instance the Quick Note) template with your own, you can
take advantage of the load order determined by the `priority` frontmatter. Built in page templates will have a priority
set that is higher than the default. Therefore, their commands and keybindings will be set early. Therefore, by simply
defining your own version of the page template _with the same command name_ will let that version override the versions
that are built in.

## Example: overriding the Quick Note template

~~~
---
command: Quick Note
key: "Ctrl-q q"
suggestedName: "Quick notes/${os.date('%Y-%m-%d/%H-%M-%S')}"
confirmName: false
tags: meta/template/page
---
This is my quick note version
~~~

# Implementation
```space-lua
-- priority: 10

local function createPageFromTemplate(templatePage, pageName, openIfExists)
  -- Won't override an existing page
  if space.pageExists(pageName) then
    if openIfExists then
      editor.navigate(pageName)
      return
    else
      editor.flashNotification("Page " .. pageName .. " already exists", "error")
      return
    end
  end
  local tpl, fm = template.fromPage(templatePage)
  local initialText = ""
  if fm.frontmatter then
    initialText = "---\n"
      .. string.trim(template.new(fm.frontmatter)())
      .. "\n---\n"
  end
  -- Write an empty page to start
  space.writePage(pageName, initialText)
  editor.navigate(pageName)
  -- Insert there, supporting |^| cursor placeholder
  editor.insertAtPos(tpl(), #initialText, true)
end

-- Create commands for all page templates with a command key in frontmatter
for pt in query[[
    from index.tag "meta/template/page"
    where _.tag == "page" and _.command
    order by _.priority desc
  ]] do
  command.define {
    name = pt.command,
    key = pt.key,
    mac = pt.mac,
    run = function()
      local pageName
      if pt.suggestedName then
        pageName = (template.new(pt.suggestedName))()
      end
      if pt.confirmName != false then
        pageName = string.trim(some(editor.prompt("Page name", pageName)) or "")
      end
      if pageName == "" then
        editor.flashNotification("No page name given for '" .. pt.command .. "', unable to continue.", "error")
        return
      end
      createPageFromTemplate(pt.name, pageName, pt.openIfExists)
    end
  }
end

local function cleanName(path)
  -- Just pull the last part
  local parts = path:split("/")
  return parts[#parts]
end

command.define {
  name = "Page: From Template",
  key = "Ctrl-q t",
  run = function()
    local pageTemplates = query[[
      from index.tag "meta/template/page"
      where _.tag == "page"
      select {
        name = cleanName(_.name),
        fullName = _.name,
        suggestedName = _.suggestedName,
        openIfExists = _.openIfExists
      }
    ]]
    local selected = editor.filterBox("Page template", pageTemplates, "Pick the template you would like to instantiate")
    if not selected then
      return
    end
    local pageName
    if selected.suggestedName then
      pageName = (template.new(selected.suggestedName))()
    end
    pageName = string.trim(some(editor.prompt("Page name", pageName)) or "")
    if pageName == "" then
      editor.flashNotification("No page name given for template '" .. cleanName(selected.fullName) .. "', unable to continue.", "error")
      return
    end
    createPageFromTemplate(selected.fullName, pageName, selected.openIfExists)
  end
}
```


================================================
FILE: libraries/Library/Std/Infrastructure/Query Templates.md
================================================
---
description: Implements generally useful query templates.
tags: meta
---
A work-in-progress library of generally useful templates for rendering queries.

```space-lua
-- Renders a page object as a linked list item
templates.pageItem = template.new([==[
* [[${name}]]
]==])

-- Renders a page object as a linked list item with full path
templates.fullPageItem = template.new([==[
* [[${name}|${name}]]
]==])

-- Renders a task object as a togglable task
templates.taskItem = template.new([==[
* [${state}] [[${ref}]] ${name}
]==])

-- Renders an item object
templates.itemItem = template.new([==[
* [[${ref}]] ${name}
]==])

-- Renders a paragraph object
templates.paragraphItem = template.new([==[
* [[${ref}]] ${text}
]==])

-- Renders a tag object
templates.tagItem = template.new([==[
* [[tag:${name}|#${name}]]
]==])
```

# Examples
`template.pageItem`:
${template.each(query[[from index.tag "page" limit 3]], templates.pageItem)}

`template.taskItem`:

    * [ ] Task 1
    * [ ] Task 2

${template.each(query[[from index.tag "task" where page == _CTX.currentPage.name]], templates.taskItem)}

`template.itemItem`:

    * Item 1
    * Item 2

${template.each(query[[from index.tag "item" where page == _CTX.currentPage.name]], templates.itemItem)}

`template.tagItem`:

    * #tag1
    * #tag2
    * #tag3

${template.each(query[[from index.tag "tag" where page == _CTX.currentPage.name]], templates.tagItem)}


================================================
FILE: libraries/Library/Std/Infrastructure/Share.md
================================================
#meta

# Implementation
```space-lua
share = {}

local shareSchema = {
  type = "object",
  properties = {
    uri = schema.string(),
    hash = schema.string(),
    mode = schema.string(), -- push | pull | sync
  },
}

-- Content change detection hash of 8 characters
-- First strips out share frontmatter-related keys to avoid hash-induced change detections
function share.contentHash(content)
  content = share.cleanFrontmatter(content)
  return crypto.sha256(content):sub(1, 8)
end

function share.cleanFrontmatter(text)
  return index.patchFrontmatter(text, {
    {op="delete-key", path="share.uri"},
    {op="delete-key", path="share.hash"},
    {op="delete-key", path="share.mode"},
  })
end

function share.setFrontmatter(m, text)
  return index.patchFrontmatter(text, {
    {op="set-key", path="share.uri", value=m.uri},
    {op="set-key", path="share.hash", value=m.hash},
    {op="set-key", path="share.mode", value=m.mode}
  })
end


-- Returns true if actually performed share, false if it was a no-op
function share.sharePage(name)
  local updateCurrent = not name
  local text
  if updateCurrent then
    text = editor.getText()
    name = editor.getCurrentPage()
  else
    text = space.readPage(name)
  end
  
  local m = index.extractFrontmatter(text).frontmatter.share
  if not m or type(m) != "table" then
    error("Not configured")
  end

  print("Current share frontmatter", m)
  
  local mode = m.mode or "push"
  local oldHash = m.hash
  local newLocalHash = share.contentHash(text)
  local newHash = newLocalHash

  if mode == "sync" then
    local remoteText = net.readURI(m.uri, {encoding="text/markdown"})
    if not remoteText then
      error("Could not read " .. m.uri)
    end
    -- Two-way sync mode
    local newRemoteHash = share.contentHash(remoteText)
    -- Sync cases
    if oldHash == newLocalHash and oldHash == newRemoteHash then
      print("Both sides up to date: nothing to do")
      return false
    elseif oldHash != newLocalHash and oldHash == newRemoteHash then
      print("Local changes, not remote one: local -> remote")
      net.writeURI(m.uri, share.cleanFrontmatter(text))
    elseif oldHash == newLocalHash and oldHash != newRemoteHash then
      print("Remote changes, not local ones: remote -> local")
      text = remoteText
      newHash = newRemoteHash
    else
      print("Changes on both ends: conflict")
      error("Conflict: not implemented yet")
    end
  elseif mode == "push" then
    if oldHash == newLocalHash then
      print("No local changes: nothing to do")
      return false
    else
      print("Local changes in push mode: local -> remote")
      net.writeURI(m.uri, share.cleanFrontmatter(text))
    end 
  elseif mode == "pull" then
    local remoteText = net.readURI(m.uri, {encoding="text/markdown"})
    if not remoteText then
      error("Could not read " .. m.uri)
    end
    local newRemoteHash = share.contentHash(remoteText)
    if oldHash == newRemoteHash then
      print("No remote changes: nothing to do")
      return false
    else
      if oldHash != newLocalHash then
        -- Local changes made since last pull, warn user
        if not editor.confirm("Local changes made to " .. name .. " are you ok to overwrite them with updated remote content from " .. m.uri .. "?") then
          return false
        end
      end  
      print("Remote changes in pull mode: remote -> local")
      text = remoteText
      newHash = newRemoteHash
    end
  end

  -- Update frontmatter
  m.hash = newHash
  m.mode = mode
  local updatedText = share.setFrontmatter(m, text)
  if updateCurrent then
    editor.setText(updatedText)
  else
    space.writePage(name, updatedText)
  end
  return true
end

command.define {
  name = "Share: Page",
  key = "Ctrl-p",
  mac = "Cmd-p",
  run = function()
    local ok, ret = pcall(share.sharePage)
    if ok then
      if ret then
        editor.flashNotification "Share successful"
      else
        editor.flashNotification "No changes"
      end
    else -- error
      if ret == "Not configured" then
        local data = {
          name = editor.getCurrentPage(),
          text = editor.getText()
        }
        local shareProviders = service.discover("share:onboard", data)
        local provider = editor.filterBox("Share provider", shareProviders, "Select how you would like to share this page")
        if not provider then
          return
        end
  
        print("Selected", provider)
        local m = service.invoke(provider, data)
        if m then
          editor.setText(share.setFrontmatter(m, data.text))
          editor.flashNotification "Share complete!"
        end
      else
        editor.flashNotification("Error: ".. ret)
      end
    end
  end
}
```


================================================
FILE: libraries/Library/Std/Infrastructure/Slash Templates.md
================================================
---
description: Implements the infrastructure of Slash Templates.
tags: meta
---
Allows for quick definition of slash commands based on templates.

# Creating slash templates

Create a page with the `#meta/template/slash` tag. The last component of the page name (after the `/` if any) will be
used as the slash command’s name. The body of the page will be used as the text to be inserted, using Lua directives is
supported.

## Configuration

You can configure some specifics about your slash template in its template page’s frontmatter.

Optional keys:

* `description`: The description of the slash command
* `priority`: To prioritize the slash command higher in the list
* `onlyContexts` (advanced): To only make the slash command appear in certain (AST node based) contexts
* `exceptContexts` (advanced): To make the slash command appear everywhere _except_ in these (AST node) contexts

# Currently active slash templates

${template.each(query[[
  from index.tag "meta/template/slash"
  where _.tag == "page"
]], templates.fullPageItem)}

# Implementation

```space-lua
for st in query[[
    from index.tag "meta/template/slash"
    where _.tag == "page"
  ]] do
  local components = st.name:split("/")
  local name = components[#components]
  slashCommand.define {
    name = name,
    description = st.description,
    priority = st.priority,
    onlyContexts = st.onlyContexts,
    exceptContexts = st.exceptContexts,
    run = function()
      local tpl = template.fromPage(st.name, st.raw)
      editor.insertAtCursor(tpl(), false, true)
    end
  }
end
```


================================================
FILE: libraries/Library/Std/Infrastructure/Tag Page.md
================================================
---
description: Implements the tag page
tags: meta
---

Implements a virtual tag page, that renders when navigating to a hashtag: #meta based on the `tag:` prefix.

# Overriding
You can override the tag page with a custom implementation as follows:

```lua
virtualPage.define {
  -- Using the same pattern as the built-in definition
  pattern = "tag:(.+)",
  run = function(tagName)
    return "# Custom tag page for " .. tagName
  end
}
```

# Implementation
```space-lua
-- priority: 10
virtualPage.define {
  pattern = "tag:(.+)",
  run = function(tagName)
    local text = "# Objects tagged with " .. tagName .. "\n"
    local allObjects = query[[
      from index.tag(tagName)
      order by ref
    ]]
    local tagParts = tagName:split("/")
    local parentTags = {}
    for i in ipairs(tagParts) do
      local slice = table.pack(table.unpack(tagParts, 1, i))
      if i != #tagParts then
        table.insert(parentTags, {name=table.concat(slice, "/")})
      end
    end
    if #parentTags > 0 then
      text = text .. "## Parent tags\n"
        .. template.each(parentTags, templates.tagItem)
    end
    local subTags = query[[
      from index.tag "tag"
      where string.startsWith(_.name, tagName .. "/")
      select {name=_.name}
    ]]
    if #subTags > 0 then
      text = text .. "## Child tags\n"
        .. template.each(subTags, templates.tagItem)
    end
    local taggedPages = query[[
      from allObjects where table.includes(_.itags, "page")
    ]]
    if #taggedPages > 0 then
      text = text .. "## Pages\n"
        .. template.each(taggedPages, templates.pageItem)
    end
    local taggedTasks = query[[
      from allObjects where table.includes(_.itags, "task")
    ]]
    if #taggedTasks > 0 then
      text = text .. "## Tasks\n"
        .. template.each(taggedTasks, templates.taskItem)
    end
    local taggedItems = query[[
      from allObjects where table.includes(_.itags, "item")
    ]]
    if #taggedItems > 0 then
      text = text .. "## Items\n"
        .. template.each(taggedItems, templates.itemItem)
    end
    local taggedData = query[[
      from allObjects where table.includes(_.itags, "data")
    ]]
    if #taggedData > 0 then
      text = text .. "## Data\n"
        .. markdown.objectsToTable(taggedData) .. "\n"
    end
    local taggedParagraphs = query[[
      from allObjects where table.includes(_.itags, "paragraph")
    ]]
    if #taggedParagraphs > 0 then
      text = text .. "## Paragraphs\n"
        .. template.each(taggedParagraphs, templates.paragraphItem)
    end
    return text
  end
}
```


================================================
FILE: libraries/Library/Std/Infrastructure/URI.md
================================================
---
description: Implements the infrastructure for reading and writing to URIs
tags: meta
---

# Implementation
```space-lua
-- Virtual page (uri:URI)
virtualPage.define {
  pattern = "uri:.+",
  run = function(path)
    local uri = path:sub(#"uri:"+1)
    return net.readURI(uri .. ".md", {encoding="text/markdown"})
  end
}

service.define {
  selector = "net.readURI:https:*",
  match = {},
  run = function(data)
    return net.proxyFetch(data.uri).body
  end
}
```


================================================
FILE: libraries/Library/Std/Page Templates/Page Template.md
================================================
---
suggestedName: "Library/Page Templates/"
description: A page template to create your own page templates
confirmName: true
tags: meta/template/page
frontmatter: |
  tags: "meta/template/page"
  ## Uncomment any of the single-commented lines to set options
  #############################################################
  ## Offer a suggested name when triggered
  #suggestedName: "Meeting Notes/"
  ## Whether or not to confirm the suggestedName or go there directly
  #confirmName: false
  ## To open a page if it already exists rather than create it fresh
  #openIfExists: true
  ## To trigger the page template with a custom command
  #command: Create My Custom Page
  ## To trigger the page template with a custom key
  #key: "Ctrl-q c"
  #mac: "Cmd-Alt-q"
  ## To prefill frontmatter
  #frontmatter: |
  #  date: "${"$"}{date.today()}"
  ## To manage the order in which the page templates appear
  #priority: 10
---
# Your page template title
Page template body


================================================
FILE: libraries/Library/Std/Page Templates/Quick Note.md
================================================
---
command: Quick Note
description: A page template to create a quick note
key:
- "Ctrl-q q"
- "Ctrl-q Ctrl-q"
suggestedName: "Inbox/${os.date('%Y-%m-%d/%H-%M-%S')}"
confirmName: false
tags: meta/template/page
priority: 10
---


================================================
FILE: libraries/Library/Std/Page Templates/Slash Template.md
================================================
---
suggestedName: "Library/Slash Templates/"
description: A page template to create a custom slash command
confirmName: true
tags: meta/template/page
frontmatter: |
  tags: "meta/template/slash"
  ## Uncomment any of the single-commented lines to set options
  #############################################################
  ## Offer a description in the drop down
  #description: My awesome slash template
  ## Set the priority of the slash template in the completion list
  #priority: 10
  ## (Advanced) Only offer this slash template in certain (AST) contexts
  #onlyContexts:
  #- FencedCode:space-lua
  ## (Advanced) Exclude offering this slash template in certain (AST) contexts
  #exceptContexts:
  #- FencedCode:space-lua
---
My slash template body


================================================
FILE: libraries/Library/Std/Pages/Library Manager.md
================================================
#meta

From here you can extend SilverBullet with additional functionality throught he use of [libraries](https://silverbullet.md/Libraries).

# Installed Libraries
${widgets.commandButton("Update All", "Library: Update All")}
${library.installedLibrariesWidget()}
# Available Libraries
${widgets.commandButton("Install from URI", "Library: Install")}
${library.installableLibrariesWidget()}
To discover more libraries, [have a look at the community forums](https://community.silverbullet.md/c/plugs-libraries/14).
# Repositories
${widgets.commandButton("Add from URI", "Library: Add Repository")} ${widgets.commandButton("Update All", "Library: Update All Repositories")}
${library.installedRepositoriesWidget()}
To discover more repositories, [have a look at the community forums](https://community.silverbullet.md/c/plugs-libraries/14).


================================================
FILE: libraries/Library/Std/Pages/Maintenance.md
================================================
#meta

We would like to keep our space clean. These are some tools that help you do that.

# Aspiring pages
This shows page links (max 20 to keep things sensible) that link to a page that does not (yet) exist. These could be broken links or just pages _aspiring_ to be created.

${some(template.each(query[[
  from index.tag "aspiring-page"
  limit 20
]], template.new[==[
    * [[${ref}]]: broken link to [[${name}]]
]==])) or "No aspiring pages, all good!"}

# Conflicting copies
These are pages that have conflicting copies (as a result of sync). Have a look at them as well as their original (non-conflicting) versions and decide which one to keep.

${some(template.each(query[[
  from index.tag "page" where name:find("%.conflicted:")
]], template.new[==[
    * [[${name:gsub("%.conflicted:.+$", "")}]]: conflict copy [[${name}]]
]==])) or "No conflicting pages!"}


================================================
FILE: libraries/Library/Std/Pages/Space Overview.md
================================================
#meta

This page compiles some useful stats about your space. You may also like the [[^Library/Std/Pages/Maintenance]] page.

# Stats
**Total pages:** ${#query[[from index.tag "page" select name]]}
**Total documents:** ${#query[[from index.tag "document" select name]]}

# Active Plugs
These are all the plugs currently active in your space:
${query[[
  from space.listPlugs()
  select {name=string.gsub(_.name, "_plug/([^.]+).plug.js", "%1")}
]]}

# Active Space Lua
These are all Space Lua scripts and the order in which they are loaded:
${template.each(query[[
  from index.tag "space-lua"
  order by _.priority desc
]], template.new[==[
    * [[${ref}]] (priority: ${priority or "default"})
]==])}

# Active Space Style
${template.each(query[[
  from index.tag "space-style"
  order by _.priority desc
  select {name=ref}
]], templates.pageItem)}


================================================
FILE: libraries/Library/Std/Slash Commands/Slash.md
================================================
#meta

Some convenient slash commands for editing Markdown files.

```space-lua
-- priority: 10

local function headerSlashCommand(level)
  local line = editor.getCurrentLine()
  local cleanText = string.gsub(line.textWithCursor, "^#+%s*", "")
  editor.replaceRange(line.from, line.to,
    string.rep("#", level) .. " " .. cleanText, true)
end

slashCommand.define {
  name = "h1",
  run = function()
    headerSlashCommand(1)
  end
}

slashCommand.define {
  name = "h2",
  run = function()
    headerSlashCommand(2)
  end
}

slashCommand.define {
  name = "h3",
  run = function()
    headerSlashCommand(3)
  end
}

slashCommand.define {
  name = "h4",
  run = function()
    headerSlashCommand(4)
  end
}

slashCommand.define {
  name = "frontmatter",
  run = function()
    editor.insertAtPos([==[---
|^|
---
]==], 0, true)
  end
}

slashCommand.define {
  name = "task",
  run = function()
    local line = editor.getCurrentLine()
    local ws, prefix, rest = string.match(line.textWithCursor, "^(%s*)([%-%*]?)%s*(.+)$")
    editor.replaceRange(line.from, line.to, ws .. "* [ ] " .. rest, true)
  end
}

slashCommand.define {
  name = "space-lua",
  description = "Insert Space Lua script",
  run = function()
    editor.insertAtCursor([==[```space-lua
|^|
```]==], false, true)
  end
}

```


================================================
FILE: libraries/Library/Std/Slash Templates/code.md
================================================
---
description: Insert a fenced code block
tags: meta/template/slash
exceptContexts:
- "FencedCode"
- "LuaDirective"
---
```|^|

```

================================================
FILE: libraries/Library/Std/Slash Templates/danger-admonition.md
================================================
---
description: Insert a danger admonition
tags: meta/template/slash
---
> **danger** Danger
> |^|

================================================
FILE: libraries/Library/Std/Slash Templates/func.md
================================================
---
description: Create a new Lua function
tags: meta/template/slash
onlyContexts:
- FencedCode:space-lua
- LuaDirective
---
function |^|()
end

================================================
FILE: libraries/Library/Std/Slash Templates/hr.md
================================================
---
description: Insert a horizontal rule
tags: meta/template/slash
---
---


================================================
FILE: libraries/Library/Std/Slash Templates/lua-command.md
================================================
---
description: Create a Lua command
tags: meta/template/slash
onlyContexts:
- "FencedCode:space-lua"
---
command.define {
  name = "|^|",
  run = function()
    editor.flashNotification "Hello world!"
  end
}

================================================
FILE: libraries/Library/Std/Slash Templates/lua-query.md
================================================
---
description: Insert a Lua query
tags: meta/template/slash
onlyContexts:
- "FencedCode:space-lua"
- "LuaDirective"
---
query[[from o = index.tag "|^|"]]


================================================
FILE: libraries/Library/Std/Slash Templates/lua-slash-command.md
================================================
---
description: Create a Lua slash command
tags: meta/template/slash
onlyContexts:
- "FencedCode:space-lua"
---

slashCommand.define {
  name = "|^|",
  run = function()
    editor.insertAtCursor("Hello |^| world!", false, true)
  end
}

================================================
FILE: libraries/Library/Std/Slash Templates/note-admonition.md
================================================
---
description: Insert a note admonition
tags: meta/template/slash
---
> **note** Note
> |^|

================================================
FILE: libraries/Library/Std/Slash Templates/query.md
================================================
---
description: Insert a query
tags: meta/template/slash
exceptContexts:
- "FencedCode:space-lua"
- "LuaDirective"
raw: true
---
${query[[from o = index.tag "|^|"]]}


================================================
FILE: libraries/Library/Std/Slash Templates/success-admonition.md
================================================
---
description: Insert a success admonition
tags: meta/template/slash
---
> **success** Success
> |^|

================================================
FILE: libraries/Library/Std/Slash Templates/table.md
================================================
---
description: Insert a markdown table
tags: meta/template/slash
exceptContexts:
- FencedCode
---
| Header A | Header B |
|----------|----------|
| Cell A|^| | Cell B |


================================================
FILE: libraries/Library/Std/Slash Templates/tpl.md
================================================
---
description: Create a new template
tags: meta/template/slash
onlyContexts:
- FencedCode:space-lua
- LuaDirective
---
template.new[==[|^|]==]

================================================
FILE: libraries/Library/Std/Slash Templates/warning-admonition.md
================================================
---
description: Insert a warning admonition
tags: meta/template/slash
---
> **warning** Warning
> |^|

================================================
FILE: libraries/Library/Std/Widgets/Embed.md
================================================
#meta

Implements useful embed widgets.

# Youtube
## Example
${embed.youtube "https://youtu.be/t1oy_41bDAY?si=X76FvJEUlJnApwEg"}

# Peertube
## Example 
${embed.peertube "https://peertube.fr/w/kkGMgK9ZtnKfYAgnEtQxbv"}

# Vimeo
## Example
${embed.vimeo "https://vimeo.com/1084537"}

# Implementation
```space-lua
-- Schema
local embedVideoSpecSchema = {
  type = "object",
  properties = {
    url = { type = "string"},
    width = { type = "number"},
    height = { type = "number"},
  },
  required = {"url"}
}

-- Youtube widget
function embed.youtube(specOrUrl)
  if type(specOrUrl) == "string" then
    specOrUrl = { url = specOrUrl }
  end
  -- Validate spec
  local validationResult = jsonschema.validateObject(embedVideoSpecSchema, specOrUrl)
  if validationResult then
    error(validationResult)
  end
  local videoId = string.match(specOrUrl.url, "youtube%.com/watch%?v=(.+)")
  if not videoId then
    videoId = string.match(specOrUrl.url, "youtu%.be/(.+)")
  end
  if not videoId then
    videoId = string.match(specOrUrl.url, "youtube%.com/shorts/(.+)")
  end

  if not videoId then
    error("No video id found")
  end
  
  local width = specOrUrl.width or "100%"
  local height = specOrUrl.height or "400px"
  return widget.html(dom.iframe {
    src="https://www.youtube.com/embed/" .. videoId,
    style="width: " .. width .. "; height: " .. height,
    class = "sb-video-embed",
    referrerpolicy = "strict-origin-when-cross-origin"
  })
end

-- Peertube widget
function embed.peertube(specOrUrl)
  if type(specOrUrl) == "string" then
    specOrUrl = { url = specOrUrl }
  end
 -- Validate spec
  local validationResult = jsonschema.validateObject(embedVideoSpecSchema, specOrUrl)
  if validationResult then
    error(validationResult)
  end
  
  local tempHost = string.match(specOrUrl.url, "https://(.+)")
  local lastIndex = string.find(tempHost, "/w/")
  local peertubeInstance = string.sub(tempHost, 1, (lastIndex -1) )
  local videoId = string.match(specOrUrl.url, "/w/(.+)")

  if not videoId then
    error("No video id found")
  end
  
  local width = specOrUrl.width or "100%"
  local height = specOrUrl.height or "400px"
  return widget.html(dom.iframe {
    src = "https://" .. peertubeInstance .. "/videos/embed/" .. videoId,
    style = "width: " .. width .. "; height: " .. height,
    frameborder = "0",
    allowfullscreen = "",
    sandbox = "allow-same-origin allow-scripts allow-popups allow-forms",
    class = "sb-video-embed"
  })
end

-- Vimeo widget
function embed.vimeo(specOrUrl)
  if type(specOrUrl) == "string" then
    specOrUrl = { url = specOrUrl }
  end
  -- Validate spec
  local validationResult = jsonschema.validateObject(embedVideoSpecSchema, specOrUrl)
  if validationResult then
    error(validationResult)
  end
  local videoId = string.match(specOrUrl.url, "vimeo%.com/(.+)")

  if not videoId then
    error("No video id found")
  end
  
  local width = specOrUrl.width or "100%"
  local height = specOrUrl.height or "400px"
  return widget.html(dom.iframe {
    src = "https://player.vimeo.com/video/" .. videoId,
    style = "width: " .. width .. "; height: " .. height,
    frameborder = "0", 
    referrerpolicy = "strict-origin-when-cross-origin", 
    allow = "fullscreen; picture-in-picture; clipboard-write; encrypted-media; web-share",   
    allowfullscreen = "",
    class = "sb-video-embed"
  })
end
```

## Styling

```space-style
.sb-video-embed {
  border: 0 !important;
}
```

# General implementation
```space-lua
-- priority: 50
embed = {}
```


================================================
FILE: libraries/Library/Std/Widgets/Widgets.md
================================================
#meta

Implements some useful general purpose widgets. Specifically:

## Buttons
Types of button widgets:

* `widgets.button(text, callback)` renders a simple button running the callback when clicked
* `widgets.commandButton(commandName)` renders a button for a particular command (where the button text is the command name itself)
* `widgets.commandButton(text, commandName)` renders a button for a particular command with a custom button text
* `widgets.commandButton(text, commandName, args)` renders a button for a particular command and arguments (specified as a table list) with a custom button text

Examples:

${widgets.button("Hello", function()
  editor.flashNotification "Hi there!"
end)}

${widgets.commandButton("System: Reload")}

## Top and bottom widgets
* Table of contents: shows a table of contents for your page
* Linked mentions: show a list of links that link to the current page, at the bottom of your page
* Linked tasks: shows a list of tasks that link to the current page, at the top of the page

These can each be individually enabled/disabled and configured in your [[CONFIG]] page (use `space-lua` instead of `lua`):

```lua
-- Disable TOC altogether
config.set("std.widgets.toc.enabled", false)
-- Only render a TOC when there's >= 5 headers
config.set("std.widgets.toc.minHeaders", 5)
-- Disable linked mentions altogether
config.set("std.widgets.linkedMentions.enabled", false)
-- Disable linked tasks altogether
config.set("std.widgets.linkedTasks.enabled", false)
```

# Implementation

## Buttons
```space-lua
-- priority: 10
function widgets.button(text, callback, attrs)
  local buttonEl = {
    onclick = callback,
    text
  }

  -- attrs can be used for additional customization
  if attrs then
    for k, v in pairs(attrs) do
      buttonEl[k] = v
    end
  end

  return widget.html(dom.button(buttonEl))
end

function widgets.commandButton(text, commandName, args)
  if not commandName then
    -- When only passed one argument, then let's assume it's a command name
    commandName = text
  end
  return widget.html(dom.button {
    onclick = function()
      editor.invokeCommand(commandName, args)
    end,
    text
  })
end

function widgets.subPages(pageName)
  local prefix = (pageName or editor.getCurrentPage()) .. "/"
  return widget.markdown(template.each(query[[
    from index.tag "page"
    where string.startsWith(_.name, prefix)
  ]], templates.pageItem))
end
```

## Table of contents
```space-lua
-- priority: 10
widgets = widgets or {}

-- configuration schema
config.define("std.widgets.toc", {
  type = "object",
  properties = {
    enabled = schema.boolean(),
    minHeaders = schema.number(),
  }
})

-- configuration default values
config.set("std.widgets.toc", {
  enabled = true,
  minHeaders = 3
})

function widgets.toc(options)
  options = options or config.get("std.widgets.toc")
  options.minHeaders = options.minHeaders or 3
  local text = editor.getText()
  local pageName = editor.getCurrentPage()
  local parsedMarkdown = markdown.parseMarkdown(text)
  -- Collect all headers
  local headers = {}
  for topLevelChild in parsedMarkdown.children do
    if topLevelChild.type then
      local headerLevel = string.match(topLevelChild.type, "^ATXHeading(%d+)")
      if headerLevel then
        local text = ""
        table.remove(topLevelChild.children, 1)
        for child in topLevelChild.children do
          text = text .. string.trim(markdown.renderParseTree(child))
        end
        -- Strip link syntax to avoid nested brackets in TOC
        text = string.gsub(text, "%[%[(.-)%]%]", "%1")

        if text != "" then
          table.insert(headers, {
            name = text,
            pos = topLevelChild.from,
            level = tonumber(headerLevel)
          })
        end
      end
    end
  end
  if options.minHeaders and options.minHeaders > #headers then
    return widget.new{}
  end
  -- Find min level
  local minLevel = 6
  for _, header in ipairs(headers) do
    if header.level < minLevel then
      minLevel = header.level
    end
  end
  -- Build up markdown
  local md = (options.header or "# Table of Contents") .. "\n"
  for _, header in ipairs(headers) do
    if not(options.maxHeader and header.level > options.maxHeader or
           options.minLevel and header.level < options.minLevel) then
      md = md .. string.rep(" ", (header.level - minLevel) * 2) ..
         "* [[" .. pageName .. "@" .. header.pos .. "|" .. header.name .. "]]\n"
    end
  end
  return widget.new {
    markdown = md
  }
end
```

### Top widget
```space-lua
-- priority: -1
if config.get("std.widgets.toc.enabled") then
  event.listen {
    name = "hooks:renderTopWidgets",
    run = function(e)
      local pageText = editor.getText()
      local fm = index.extractFrontmatter(pageText)
      if fm.frontmatter.pageDecoration and fm.frontmatter.pageDecoration.disableTOC then
        return
      end
      return widgets.toc()
    end
  }
end
```

## Linked mentions
```space-lua
-- priority: 10
widgets = widgets or {}

local mentionTemplate = template.new [==[
**[[${_.ref}|${_.ref}]]**:
${_.snippet}

]==]

-- configuration schema
config.define("std.widgets.linkedMentions", {
  type = "object",
  properties = {
    enabled = schema.boolean(),
  }
})

-- configuration default values
config.set("std.widgets.linkedMentions", {
  enabled = true,
})

function widgets.linkedMentions(pageName)
  pageName = pageName or editor.getCurrentPage()
  local linkedMentions = query[[
    from l = index.tag "link"
    where l.page != pageName and l.toPage == pageName
    order by l.pageLastModified desc, l.pos
  ]]
  if #linkedMentions > 0 then
    return widget.new {
      markdown = "# Linked Mentions\n"
        .. template.each(linkedMentions, mentionTemplate)
    }
  end
end
```

### Bottom widget
```space-lua
-- priority: -1
if config.get("std.widgets.linkedMentions.enabled") then
  event.listen {
    name = "hooks:renderBottomWidgets",
    run = function(e)
      return widgets.linkedMentions()
    end
  }
end
```

## Linked tasks
```space-lua
-- priority: 10

-- configuration schema
config.define("std.widgets.linkedTasks", {
  type = "object",
  properties = {
    enabled = schema.boolean(),
  }
})

-- configuration default values
config.set("std.widgets.linkedTasks", {
  enabled = true,
})

function widgets.linkedTasks(pageName)
  pageName = pageName or editor.getCurrentPage()
  local tasks = query[[
    from t = index.tag "task"
    where not t.done and table.includes(t.ilinks, pageName)
    order by t.page
  ]]
  local md = ""
  if #tasks > 0 then
    md = "# Linked Tasks\n"
       .. template.each(tasks, templates.taskItem)
  else
    md = ""
  end
  return widget.new {
    markdown = md
  }
end
```

### Top widget
```space-lua
-- priority: -1
if config.get("std.widgets.linkedTasks.enabled") then
  event.listen {
    name = "hooks:renderTopWidgets",
    run = function(e)
      return widgets.linkedTasks()
    end
  }
end
```


================================================
FILE: libraries/Repositories/Std.md
================================================
#meta/repository

Here are a few vetted libraries to get you started.

```#meta/library/remote
name: Git
description: Rudamentary Git integration into SilverBullet
author: Zef Hemel
uri: https://github.com/zefhemel/silverbullet-libraries/blob/main/Git.md
---
name: Mermaid diagrams
author: Zef Hemel
description: Mermaid diagram support for SilverBullet
uri: https://github.com/silverbulletmd/silverbullet-mermaid/blob/main/PLUG.md
---
name: Excalidraw diagrams
author: Logesh
website: https://github.com/LogeshG5/silverbullet-excalidraw
description: Support for [Excalidraw](https://excalidraw.com/) in SilverBullet.
uri: https://github.com/LogeshG5/silverbullet-excalidraw/blob/main/PLUG.md
---
name: Silversearch
author: MrMugame
description: Powerful full-text search engine for SilverBullet
website: https://github.com/MrMugame/silversearch
uri: ghr:MrMugame/silversearch/PLUG.md
---
name: TreeView
author: Joe Krill
description: Adds a tree view that allows you to navigate your SilverBullet pages hierarchically
website: https://github.com/joekrill/silverbullet-treeview
uri: https://github.com/joekrill/silverbullet-treeview/blob/main/PLUG.md
---
name: PDF Viewer
author: MrMugame
website: https://github.com/MrMugame/silverbullet-pdf
description: PDF Viewer for Silverbullet
uri: ghr:MrMugame/silverbullet-pdf/PLUG.md
---
name: Math
author: MrMugame
description: LaTeX-style math widgets for SilverBullet
uri: https://github.com/MrMugame/silverbullet-math/blob/main/Math.md
---
name: Basic Full-Text Search
author: Zef Hemel
description: Basic full-text search engine for SilverBullet (previously a built-in plug)
website: https://github.com/silverbulletmd/basic-search
uri: https://github.com/silverbulletmd/basic-search/blob/main/PLUG.md
```


================================================
FILE: plug-api/README.md
================================================
This folder contains two things:

* Wrappers for `syscall`s to be used by Plugs
* Reusable library code that plugs may find useful

================================================
FILE: plug-api/constants.ts
================================================
export const maximumDocumentSize: number = 10; // MiB
export const defaultLinkStyle: string = "wikilink";
export const offlineError: Error = new Error("Offline");
export const notFoundError: Error = new Error("Not found");
export const notAuthenticatedError: Error = new Error("Unauthenticated");
export const wrongSpacePathError: Error = new Error(
  "Space folder path different on server, reloading the page",
);
export const pingTimeout: number = 2000;
export const pingInterval: number = 5000;

/**
 * HTTP status codes that should be treated as "offline" conditions.
 *
 * This is particularly useful for cases where a proxy (such as Cloudflare or other reverse proxies)
 * indicates that the backend server is down, but there is still network connectivity between
 * the user and the proxy. In these scenarios, we want to allow the user to continue working
 * with their cached data rather than showing an error, even though technically there is network
 * connectivity to the proxy.
 *
 * This enables SilverBullet to work in a true "offline-first" manner, falling back to cached
 * content when the backend is unavailable through no fault of the user's network connection.
 *
 * All 5xx server errors are included to prevent the client from caching error HTML pages
 * (e.g., Nginx 500 error pages) which would prevent the client from booting in offline mode.
 */
export const offlineStatusCodes = {
  500: "Internal Server Error", // Server encountered an unexpected condition
  501: "Not Implemented", // Server does not support the functionality required
  502: "Bad Gateway", // Proxy server received invalid response from upstream server
  503: "Service Unavailable", // Server is temporarily unable to handle the request
  504: "Gateway Timeout", // Proxy server did not receive a timely response from upstream server
  505: "HTTP Version Not Supported", // Server does not support the HTTP version
  506: "Variant Also Negotiates", // Server has an internal configuration error
  507: "Insufficient Storage", // Server is unable to store the representation
  508: "Loop Detected", // Server detected an infinite loop while processing
  509: "Bandwidth Limit Exceeded", // Server bandwidth limit has been exceeded
  510: "Not Extended", // Further extensions to the request are required
  511: "Network Authentication Required", // Client needs to authenticate to gain network access

  530: "Unable to resolve origin hostname", // Served when cloudflared is down on the host
} as const;


================================================
FILE: plug-api/lib/README.md
================================================
This folder contains useful library functions to be used by plugs, built-in or external.

================================================
FILE: plug-api/lib/async.test.ts
================================================
import { assert, assertEquals } from "@std/assert";
import {
  batchRequests,
  processWithConcurrency,
  PromiseQueue,
  sleep,
} from "./async.ts";

Deno.test("PromiseQueue test", async () => {
  const q = new PromiseQueue();
  let r1RanFirst = false;
  const r1 = q.runInQueue(async () => {
    await sleep(10);
    r1RanFirst = true;
    // console.log("1");
    return 1;
  });
  const r2 = q.runInQueue(async () => {
    // console.log("2");
    await sleep(4);
    return 2;
  });
  assertEquals(await Promise.all([r1, r2]), [1, 2]);
  assertEquals(r1RanFirst, true);
  let wasRun = false;
  await q.runInQueue(async () => {
    await sleep(4);
    wasRun = true;
  });
  assertEquals(wasRun, true);
});

Deno.test("processWithConcurrency test - basic functionality", async () => {
  const items = [1, 2, 3, 4, 5];
  const results = await processWithConcurrency(
    items,
    async (item) => {
      await sleep(10);
      return item * 2;
    },
    2,
  );
  assertEquals(results, [2, 4, 6, 8, 10]);
});

Deno.test("processWithConcurrency test - maintains order", async () => {
  const items = [1, 2, 3, 4, 5];
  const startTimes: number[] = [];
  const endTimes: number[] = [];

  const results = await processWithConcurrency(
    items,
    async (item) => {
      const start = Date.now();
      startTimes[item - 1] = start;
      // Longer sleep for higher numbers to test order preservation
      await sleep(item * 5);
      endTimes[item - 1] = Date.now();
      return `item-${item}`;
    },
    2,
  );

  // Results should be in original order despite different processing times
  assertEquals(results, ["item-1", "item-2", "item-3", "item-4", "item-5"]);
});

Deno.test("processWithConcurrency test - concurrency limit", async () => {
  const items = [1, 2, 3, 4, 5, 6];
  const activeCount = { value: 0 };
  const maxActive = { value: 0 };

  const results = await processWithConcurrency(
    items,
    async (item) => {
      activeCount.value++;
      maxActive.value = Math.max(maxActive.value, activeCount.value);
      await sleep(20);
      activeCount.value--;
      return item;
    },
    3,
  );

  assertEquals(results, [1, 2, 3, 4, 5, 6]);
  // Should never exceed concurrency limit of 3
  assertEquals(maxActive.value <= 3, true);
});

Deno.test("processWithConcurrency test - empty array", async () => {
  const results = await processWithConcurrency(
    [],
    (item) => Promise.resolve(item),
    2,
  );
  assertEquals(results, []);
});

Deno.test("processWithConcurrency test - concurrency higher than item count", async () => {
  const items = [1, 2, 3];
  const results = await processWithConcurrency(
    items,
    async (item) => {
      await sleep(10);
      return item + 10;
    },
    10, // Higher than items.length
  );
  assertEquals(results, [11, 12, 13]);
});

Deno.test("processWithConcurrency test - error handling", async () => {
  const items = [1, 2, 3, 4, 5];

  try {
    await processWithConcurrency(
      items,
      (item) => {
        if (item === 3) {
          throw new Error(`Error processing item ${item}`);
        }
        return Promise.resolve(item * 2);
      },
      2,
    );
    assert(false, "Should have thrown an error");
  } catch (error) {
    assert(
      error instanceof Error &&
        error.message.includes("Error processing item 3"),
    );
  }
});

Deno.test("processWithConcurrency test - performance with concurrency", async () => {
  const items = Array.from({ length: 6 }, (_, i) => i + 1);
  const delay = 30;

  // Test with concurrency of 1 (sequential)
  const start1 = Date.now();
  await processWithConcurrency(
    items,
    async (item) => {
      await sleep(delay);
      return item;
    },
    1,
  );
  const sequential = Date.now() - start1;

  // Test with concurrency of 3 (parallel)
  const start2 = Date.now();
  await processWithConcurrency(
    items,
    async (item) => {
      await sleep(delay);
      return item;
    },
    3,
  );
  const parallel = Date.now() - start2;

  // Parallel should be significantly faster than sequential
  // Allow some margin for timing variations
  assert(
    parallel < sequential * 0.8,
    `Parallel (${parallel}ms) should be faster than sequential (${sequential}ms)`,
  );
});

Deno.test("Batch test", async () => {
  // Generate an array with numbers up to 100
  const elements = Array.from(Array(100).keys());
  const multiplied = await batchRequests(elements, async (batch) => {
    await sleep(2);
    // Batches should be 9 or smaller (last batch will be smaller)
    assert(batch.length <= 9);
    return batch.map((e) => e * 2);
  }, 9);
  assertEquals(multiplied, elements.map((e) => e * 2));
  const multiplied2 = await batchRequests(elements, (batch) => {
    return Promise.resolve(batch.map((e) => e * 2));
  }, 10000);
  assertEquals(multiplied2, elements.map((e) => e * 2));
});


================================================
FILE: plug-api/lib/async.ts
================================================
export function throttle(func: () => void, limit: number): () => void {
  let timer: any = null;
  return function () {
    if (!timer) {
      timer = setTimeout(() => {
        func();
        timer = null;
      }, limit);
    }
  };
}

export function throttleImmediately(
  func: () => void,
  limit: number,
): () => void {
  let timer: any = null;
  return function () {
    if (!timer) {
      func();
      timer = setTimeout(() => {
        timer = null;
      }, limit);
    }
  };
}

// race for promises returns first promise that resolves
export function race<T>(promises: Promise<T>[]): Promise<T> {
  return new Promise((resolve, reject) => {
    for (const p of promises) {
      p.then(resolve, reject);
    }
  });
}

export function timeout(ms: number): Promise<never> {
  return new Promise((_resolve, reject) =>
    setTimeout(() => {
      reject(new Error("timeout"));
    }, ms)
  );
}

export function sleep(ms: number): Promise<void> {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

export class PromiseQueue {
  private queue: {
    fn: () => Promise<any>;
    resolve: (value: any) => void;
    reject: (error: any) => void;
  }[] = [];
  private processing = false;

  runInQueue(fn: () => Promise<any>): Promise<any> {
    return new Promise((resolve, reject) => {
      this.queue.push({ fn, resolve, reject });
      if (!this.processing) {
        this.process();
      }
    });
  }

  private async process(): Promise<void> {
    if (this.queue.length === 0) {
      this.processing = false;
      return;
    }

    this.processing = true;
    const { fn, resolve, reject } = this.queue.shift()!;

    try {
      const result = await fn();
      resolve(result);
    } catch (error) {
      reject(error);
    }

    this.process(); // Continue processing the next promise in the queue
  }
}

/**
 * Batches up values, and processes in batches of batchSize in parallel
 * then merges the results in the appropriate order.
 * @param values - The values to batch.
 * @param fn - The function to run on each batch.
 * @param batchSize - The size of each batch.
 */
export async function batchRequests<I, O>(
  values: I[],
  fn: (batch: I[]) => Promise<O[]>,
  batchSize: number,
): Promise<O[]> {
  const results: O[] = [];
  // Split values into batches of batchSize
  const batches: I[][] = [];
  for (let i = 0; i < values.length; i += batchSize) {
    batches.push(values.slice(i, i + batchSize));
  }
  // Run fn on them in parallel
  const batchResults = await Promise.all(batches.map(fn));
  // Flatten the results
  for (const batchResult of batchResults) {
    if (Array.isArray(batchResult)) { // If fn returns an array, collect them
      results.push(...batchResult);
    }
  }
  return results;
}

/**
 * Processes items in parallel with a specified concurrency limit.
 * @param items - The items to process.
 * @param handler - The function to run on each item.
 * @param concurrency - The maximum number of concurrent operations.
 */
export async function processWithConcurrency<I, O>(
  items: I[],
  handler: (item: I) => Promise<O>,
  concurrency: number,
): Promise<O[]> {
  const results: O[] = [];
  let idx = 0;

  async function worker() {
    while (idx < items.length) {
      const currentIdx = idx++;
      const item = items[currentIdx];
      const result = await handler(item);
      results[currentIdx] = result;
    }
  }

  const workers = [];
  for (let i = 0; i < Math.min(concurrency, items.length); i++) {
    workers.push(worker());
  }

  await Promise.all(workers);
  return results;
}

/**
 * Runs a function safely by catching any errors and logging them to the console.
 * @param fn - The function to run.
 */
export function safeRun(fn: () => Promise<void>): void {
  fn().catch((e) => {
    console.error(e);
  });
}

/**
 * Generates a random delay between 0 and 1000 milliseconds.
 */
export function jitter(maxLength = 1000): number {
  return Math.floor(Math.random() * maxLength);
}


================================================
FILE: plug-api/lib/crypto.test.ts
================================================
import {
  decryptAesGcm,
  decryptStringDeterministic,
  deriveCTRKeyFromPassword,
  deriveGCMKeyFromCTR,
  encryptAesGcm,
  encryptStringDeterministic,
} from "@silverbulletmd/silverbullet/lib/crypto";
import { assertEquals } from "@std/assert";

Deno.test("Crypto test", async () => {
  const salt = new Uint8Array(16); // zeroes for testing
  const ctr = await deriveCTRKeyFromPassword("12345", salt);
  const gcm = await deriveGCMKeyFromCTR(ctr);
  const text = "123";
  const encrypted = await encryptStringDeterministic(ctr, text);
  const encrypted2 = await encryptStringDeterministic(ctr, text);
  // Ensure determinism
  assertEquals(encrypted, encrypted2);
  const decrypted = await decryptStringDeterministic(ctr, encrypted);
  assertEquals(decrypted, text);

  // Now gcm
  const buffer = new Uint8Array(100).fill(32);
  const encryptedBuf = await encryptAesGcm(gcm, buffer);
  const decryptedBuf = await decryptAesGcm(gcm, encryptedBuf);
  assertEquals(decryptedBuf, buffer);
});


================================================
FILE: plug-api/lib/crypto.ts
================================================
export function base64Decode(s: string): Uint8Array {
  const binString = atob(s);
  const len = binString.length;
  const bytes = new Uint8Array(len);
  for (let i = 0; i < len; i++) {
    bytes[i] = binString.charCodeAt(i);
  }
  return bytes;
}

export function base64Encode(buffer: Uint8Array | string): string {
  if (typeof buffer === "string") {
    buffer = new TextEncoder().encode(buffer);
  }
  let binary = "";
  const len = buffer.byteLength;
  for (let i = 0; i < len; i++) {
    binary += String.fromCharCode(buffer[i]);
  }
  return btoa(binary);
}

export function base64EncodedDataUrl(
  mimeType: string,
  buffer: Uint8Array,
): string {
  return `data:${mimeType};base64,${base64Encode(buffer)}`;
}

export function base64DecodeDataUrl(dataUrl: string): Uint8Array {
  const b64Encoded = dataUrl.split(",", 2)[1];
  return base64Decode(b64Encoded);
}

/**
 * Perform sha256 hash using the browser's crypto APIs
 * Note: this will only work over HTTPS
 * @param message
 */
export async function hashSHA256(
  message: string | Uint8Array,
): Promise<string> {
  // Transform the string into an ArrayBuffer
  const encoder = new TextEncoder();
  const data: Uint8Array = typeof message === "string"
    ? encoder.encode(message)
    : message;

  // Generate the hash
  const hashBuffer = await globalThis.crypto.subtle.digest(
    "SHA-256",
    data as BufferSource,
  );

  // Transform the hash into a hex string
  return Array.from(new Uint8Array(hashBuffer)).map((b) =>
    b.toString(16).padStart(2, "0")
  ).join("");
}

/**
 * To avoid database clashes based on space folder path name, base URLs and encryption keys we derive
 * a database name from a hash of all these combined together
 */
export async function deriveDbName(
  type: "data" | "files",
  spaceFolderPath: string,
  baseURI: string,
  encryptionKey?: CryptoKey,
): Promise<string> {
  let keyPart = "";
  if (encryptionKey) {
    keyPart = await exportKey(encryptionKey);
  }
  const spaceHash = await hashSHA256(
    `${spaceFolderPath}:${baseURI}:${keyPart}`,
  );
  return `sb_${type}_${spaceHash}`;
}

// Fixed counter for AES-CTR all zeroes, for determinism
const fixedCounter = new Uint8Array(16);

export async function encryptStringDeterministic(
  key: CryptoKey,
  clearText: string,
): Promise<string> {
  const encrypted = await crypto.subtle.encrypt(
    { name: "AES-CTR", counter: fixedCounter, length: fixedCounter.length * 8 },
    key,
    new TextEncoder().encode(clearText),
  );
  return base64Encode(new Uint8Array(encrypted));
}

export async function decryptStringDeterministic(
  key: CryptoKey,
  cipherText: string,
): Promise<string> {
  const decrypted = await crypto.subtle.decrypt(
    { name: "AES-CTR", counter: fixedCounter, length: fixedCounter.length * 8 },
    key,
    base64Decode(cipherText) as BufferSource,
  );
  return new TextDecoder().decode(decrypted);
}

// Encrypt using AES-GCM with random IV; output = IV + ciphertext
export async function encryptAesGcm(
  key: CryptoKey,
  data: Uint8Array,
): Promise<Uint8Array> {
  const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV recommended for GCM
  const encryptedBuffer = await crypto.subtle.encrypt(
    { name: "AES-GCM", iv },
    key,
    data as BufferSource,
  );
  const encrypted = new Uint8Array(encryptedBuffer);

  // Prepend IV to ciphertext
  const result = new Uint8Array(iv.length + encrypted.length);
  result.set(iv, 0);
  result.set(encrypted, iv.length);
  return result;
}

// Decrypt using AES-GCM assuming input format IV + ciphertext
export async function decryptAesGcm(
  key: CryptoKey,
  encryptedData: Uint8Array,
): Promise<Uint8Array> {
  const iv = encryptedData.slice(0, 12); // extract IV (first 12 bytes)
  const ciphertext = encryptedData.slice(12);
  const decryptedBuffer = await crypto.subtle.decrypt(
    { name: "AES-GCM", iv },
    key,
    ciphertext,
  );
  return new Uint8Array(decryptedBuffer);
}

export async function deriveCTRKeyFromPassword(
  password: string,
  salt: Uint8Array,
): Promise<CryptoKey> {
  // Encode password to ArrayBuffer
  const passwordBytes = new TextEncoder().encode(password);

  // Import password as a CryptoKey
  const baseKey = await crypto.subtle.importKey(
    "raw",
    passwordBytes,
    { name: "PBKDF2" },
    false,
    ["deriveBits", "deriveKey"],
  );

  return crypto.subtle.deriveKey(
    {
      name: "PBKDF2",
      salt: salt as BufferSource,
      iterations: 100000,
      hash: "SHA-256",
    },
    baseKey,
    {
      name: "AES-CTR",
      length: 256,
    },
    true, // extractable
    ["encrypt", "decrypt"],
  );
}

export function importKey(b64EncodedKey: string): Promise<CryptoKey> {
  return crypto.subtle.importKey(
    "raw",
    base64Decode(b64EncodedKey) as BufferSource,
    { name: "AES-CTR" },
    true,
    ["encrypt", "decrypt"],
  );
}

export async function exportKey(ctrKey: CryptoKey): Promise<string> {
  const key = await crypto.subtle.exportKey("raw", ctrKey);
  return base64Encode(new Uint8Array(key));
}

export async function deriveGCMKeyFromCTR(
  ctrKey: CryptoKey,
): Promise<CryptoKey> {
  const rawKey = await crypto.subtle.exportKey("raw", ctrKey);
  return crypto.subtle.importKey(
    "raw",
    rawKey,
    { name: "AES-GCM" },
    true,
    ["encrypt", "decrypt"],
  );
}


================================================
FILE: plug-api/lib/dates.test.ts
================================================
import { localDateString } from "./dates.ts";

Deno.test("Dates", () => {
  console.log("Local date string", localDateString(new Date()));
});


================================================
FILE: plug-api/lib/dates.ts
================================================
export function niceDate(d: Date): string {
  return localDateString(d).split("T")[0];
}

export function localDateString(d: Date): string {
  return d.getFullYear() +
    "-" + String(d.getMonth() + 1).padStart(2, "0") +
    "-" + String(d.getDate()).padStart(2, "0") +
    "T" + String(d.getHours()).padStart(2, "0") +
    ":" + String(d.getMinutes()).padStart(2, "0") +
    ":" + String(d.getSeconds()).padStart(2, "0") +
    "." + String(d.getMilliseconds()).padStart(3, "0");
}


================================================
FILE: plug-api/lib/json.test.ts
================================================
import { assertEquals } from "@std/assert";
import { cleanupJSON, deepClone, deepEqual } from "./json.ts";

Deno.test("JSON utils", () => {
  assertEquals(deepEqual({ a: 1 }, { a: 1 }), true);
  assertEquals(deepEqual({ a: null }, { a: null }), true);
  assertEquals(deepEqual({ a: {} }, { a: null }), false);
  assertEquals(deepEqual({ a: {} }, { a: undefined }), false);
  assertEquals(deepEqual({ a: null }, { a: {} }), false);
  assertEquals(cleanupJSON({ "a.b": 1 }), { a: { b: 1 } });
  assertEquals(cleanupJSON({ a: { "a.b": 1 } }), {
    a: { a: { b: 1 } },
  });
  assertEquals(cleanupJSON({ a: [{ "a.b": 1 }] }), {
    a: [{ a: { b: 1 } }],
  });

  assertEquals(cleanupJSON(new Date("2023-05-03T00:00:00Z")), "2023-05-03");
});

Deno.test("JSON utils - deepObjectMerge", () => {
  // Tests for deepClone
  const obj1 = { a: 1, b: { c: 2, d: [3, 4] }, e: new Date("2023-08-21") };
  const clone1 = deepClone(obj1);
  assertEquals(clone1, obj1);
  assertEquals(clone1 === obj1, false); // Ensuring deep clone, not shallow
  assertEquals(clone1.b === obj1.b, false); // Nested object should be different reference
  assertEquals(clone1.e === obj1.e, false); // Date object should be different reference

  const arrayTest = [1, 2, { a: 3, b: [4, 5] }];
  const cloneArray = deepClone(arrayTest);
  assertEquals(cloneArray, arrayTest);
  assertEquals(cloneArray === arrayTest, false); // Array itself should be different reference
  assertEquals(cloneArray[2] === arrayTest[2], false); // Nested object in array should be different reference

  const nullTest = { a: null, b: undefined, c: { d: null } };
  const cloneNullTest = deepClone(nullTest);
  assertEquals(cloneNullTest, nullTest);
  assertEquals(cloneNullTest === nullTest, false); // Ensure it's a deep clone
  assertEquals(cloneNullTest.c === nullTest.c, false); // Nested object should be different reference

  const dateTest = new Date();
  const cloneDateTest = deepClone(dateTest);
  assertEquals(cloneDateTest.getTime(), dateTest.getTime());
  assertEquals(cloneDateTest === dateTest, false); // Date instance should be different reference
});


================================================
FILE: plug-api/lib/json.ts
================================================
/**
 * Performs a deep comparison of two objects, returning true if they are equal
 * @param a first object
 * @param b second object
 * @returns
 */
export function deepEqual(a: any, b: any): boolean {
  if (a === b) {
    return true;
  }
  if (typeof a !== typeof b) {
    return false;
  }
  if (a === null || b === null) {
    return false;
  }
  if (a === undefined || b === undefined) {
    return false;
  }
  if (typeof a === "object") {
    if (Array.isArray(a) && Array.isArray(b)) {
      if (a.length !== b.length) {
        return false;
      }
      for (let i = 0; i < a.length; i++) {
        if (!deepEqual(a[i], b[i])) {
          return false;
        }
      }
      return true;
    } else {
      const aKeys = Object.keys(a);
      const bKeys = Object.keys(b);
      if (aKeys.length !== bKeys.length) {
        return false;
      }
      for (const key of aKeys) {
        if (!deepEqual(a[key], b[key])) {
          return false;
        }
      }
      return true;
    }
  }
  return false;
}

/**
 * Converts a Date object to a date string in the format YYYY-MM-DD if it just contains a date (and no significant time), or a full ISO string otherwise
 * @param d the date to convert
 */
export function cleanStringDate(d: Date): string {
  // If no significant time, return a date string only
  if (
    d.getUTCHours() === 0 && d.getUTCMinutes() === 0 && d.getUTCSeconds() === 0
  ) {
    return d.getUTCFullYear() + "-" +
      String(d.getUTCMonth() + 1).padStart(2, "0") + "-" +
      String(d.getUTCDate()).padStart(2, "0");
  } else {
    return d.toISOString();
  }
}

/**
 * Processes a JSON (typically coming from parse YAML frontmatter) in two ways:
 * 1. Expands property names in an object containing a .-separated path
 * 2. Converts dates to strings in sensible ways
 * @param a
 * @returns
 */
export function cleanupJSON(a: any): any {
  if (!a) {
    return a;
  }
  if (typeof a !== "object") {
    return a;
  }
  if (Array.isArray(a)) {
    return a.map(cleanupJSON);
  }
  // If a is a date, convert to a string
  if (a instanceof Date) {
    return cleanStringDate(a);
  }
  const expanded: any = {};
  for (const key of Object.keys(a)) {
    const parts = key.split(".");
    let target = expanded;
    for (let i = 0; i < parts.length - 1; i++) {
      const part = parts[i];
      if (!target[part]) {
        target[part] = {};
      }
      target = target[part];
    }
    target[parts[parts.length - 1]] = cleanupJSON(a[key]);
  }
  return expanded;
}

export function deepClone<T>(obj: T, ignoreKeys: string[] = []): T {
  // Handle null, undefined, or primitive types (string, number, boolean, symbol, bigint)
  if (obj === null || typeof obj !== "object") {
    return obj;
  }

  // Handle Date
  if (obj instanceof Date) {
    return new Date(obj.getTime()) as any;
  }

  // Handle Array
  if (Array.isArray(obj)) {
    const arrClone: any[] = [];
    for (let i = 0; i < obj.length; i++) {
      arrClone[i] = deepClone(obj[i], ignoreKeys);
    }
    return arrClone as any;
  }

  // Handle Object
  if (obj instanceof Object) {
    const objClone: { [key: string]: any } = {};
    for (const key in obj) {
      if (ignoreKeys.includes(key)) {
        objClone[key] = obj[key];
      } else if (Object.prototype.hasOwnProperty.call(obj, key)) {
        objClone[key] = deepClone(obj[key], ignoreKeys);
      }
    }
    return objClone as T;
  }

  throw new Error("Unsupported data type.");
}


================================================
FILE: plug-api/lib/limited_map.test.ts
================================================
import { assertEquals } from "@std/assert";
import { LimitedMap } from "./limited_map.ts";
import { sleep } from "./async.ts";

Deno.test("limited map", async () => {
  const mp = new LimitedMap<string>(3);
  mp.set("a", "a");
  mp.set("b", "b", 5);
  mp.set("c", "c");
  assertEquals(mp.get("a"), "a");
  assertEquals(mp.get("b"), "b");
  assertEquals(mp.get("c"), "c");
  // Drops the first key
  mp.set("d", "d");
  // console.log(mp.toJSON());
  assertEquals(mp.get("a"), undefined);
  await sleep(10);
  // "b" should have been dropped
  assertEquals(mp.get("b"), undefined);
  assertEquals(mp.get("c"), "c");

  console.log(mp.toJSON());
});


================================================
FILE: plug-api/lib/limited_map.ts
================================================
type LimitedMapRecord<V> = {
  value: V;
  la: number;
  expTimer?: number;
};

export class LimitedMap<V> {
  private map: Map<string, LimitedMapRecord<V>>;

  constructor(
    private maxSize: number,
    initialJson: Record<string, LimitedMapRecord<V>> = {},
  ) {
    this.map = new Map(Object.entries(initialJson));
  }

  /**
   * @param key
   * @param value
   * @param ttl time to live (in ms)
   */
  set(key: string, value: V, ttl?: number) {
    const entry: LimitedMapRecord<V> = { value, la: Date.now() };
    if (ttl) {
      const existingEntry = this.map.get(key);
      if (existingEntry?.expTimer) {
        clearTimeout(existingEntry.expTimer);
      }
      entry.expTimer = setTimeout(() => {
        this.map.delete(key);
      }, ttl);
    }
    if (this.map.size >= this.maxSize) {
      // Remove the oldest key before adding a new one
      const oldestKey = this.getOldestKey();
      this.map.delete(oldestKey!);
    }
    this.map.set(key, entry);
  }

  get(key: string): V | undefined {
    const entry = this.map.get(key);
    if (entry) {
      // Update the last accessed timestamp
      entry.la = Date.now();
      return entry.value;
    }
    return undefined;
  }

  remove(key: string) {
    this.map.delete(key);
  }

  toJSON(): Record<string, any> {
    return Object.fromEntries(this.map.entries());
  }

  private getOldestKey(): string | undefined {
    let oldestKey: string | undefined;
    let oldestTimestamp: number | undefined;

    for (const [key, entry] of this.map.entries()) {
      if (!oldestTimestamp || entry.la < oldestTimestamp) {
        oldestKey = key;
        oldestTimestamp = entry.la;
      }
    }

    return oldestKey;
  }
}


================================================
FILE: plug-api/lib/memory_cache.test.ts
================================================
import { ttlCache } from "./memory_cache.ts";
import { sleep } from "./async.ts";
import { assertEquals } from "@std/assert";

Deno.test("Memory cache", async () => {
  let calls = 0;

  async function expensiveFunction(key: string) {
    calls++;
    await sleep(1);
    return key;
  }

  assertEquals("key", await ttlCache("key", expensiveFunction, 0.01));
  assertEquals(1, calls);
  assertEquals("key", await ttlCache("key", expensiveFunction, 0.01));
  assertEquals(1, calls);
  await sleep(10);
});


================================================
FILE: plug-api/lib/memory_cache.ts
================================================
import { LimitedMap } from "./limited_map.ts";

const cache = new LimitedMap<any>(50);

export async function ttlCache<K, V>(
  key: K,
  fn: (key: K) => Promise<V>,
  ttlSecs?: number,
): Promise<V> {
  if (!ttlSecs) {
    return fn(key);
  }
  const serializedKey = JSON.stringify(key);
  const cached = cache.get(serializedKey);
  if (cached) {
    return cached;
  }
  const result = await fn(key);
  cache.set(serializedKey, result, ttlSecs * 1000);
  return result;
}


================================================
FILE: plug-api/lib/native_fetch.ts
================================================
declare global {
  function nativeFetch(
    input: RequestInfo | URL,
    init?: RequestInit,
  ): Promise<Response>;
}


================================================
FILE: plug-api/lib/ref.test.ts
================================================
import {
  decodePageURI,
  encodePageURI,
  encodeRef,
  isValidName,
  isValidPath,
  parseToRef,
} from "./ref.ts";
import { assert, assertEquals } from "@std/assert";

Deno.test("parseToRef() default cases", () => {
  assertEquals(parseToRef("foo"), { path: "foo.md" });
  assertEquals(parseToRef("/foo"), { path: "foo.md" });
  assertEquals(parseToRef("foo/bar"), { path: "foo/bar.md" });
  assertEquals(parseToRef("foo.png"), { path: "foo.png" });
  assertEquals(parseToRef("foo.md"), { path: "foo.md" });
  assertEquals(parseToRef("foo."), { path: "foo..md" });
  assertEquals(parseToRef("foo.."), { path: "foo...md" });
  assertEquals(parseToRef(" .foo"), { path: " .foo" });
  assertEquals(parseToRef("foo[bar"), { path: "foo[bar.md" });
  assertEquals(parseToRef("foo]bar"), { path: "foo]bar.md" });
  assertEquals(parseToRef("foo(bar"), { path: "foo(bar.md" });
  assertEquals(parseToRef("foo)bar"), { path: "foo)bar.md" });
  assertEquals(parseToRef("/bar/.../foo"), { path: "bar/.../foo.md" });

  assertEquals(parseToRef("/foo/.bar.md"), null);
  assertEquals(parseToRef("foo.md.md"), null);
  assertEquals(parseToRef("/.../foo"), null);
  assertEquals(parseToRef("^.foo"), null);
  assertEquals(parseToRef(".foobar"), null);
  assertEquals(parseToRef("foo[[bar"), null);
  assertEquals(parseToRef("foo]]bar"), null);
  assertEquals(parseToRef("foo|bar"), null);
  assertEquals(parseToRef("foo@bar"), null);
  assertEquals(parseToRef("/../foo"), null);
  assertEquals(parseToRef("/./foo"), null);
  assertEquals(parseToRef("/bar/../foo"), null);
  assertEquals(parseToRef("/bar/./foo"), null);

  assertEquals(parseToRef(""), { path: "" });
  assertEquals(parseToRef("/"), { path: "" });
  assertEquals(parseToRef("/.md"), null);
  assertEquals(parseToRef("/.foo"), null);
  assertEquals(parseToRef("/@132"), {
    path: "",
    details: { type: "position", pos: 132 },
  });
});

Deno.test("parseToRef() link cases", () => {
  assertEquals(parseToRef("^foo"), { path: "foo.md", meta: true });

  assertEquals(parseToRef("foo# header"), {
    path: "foo.md",
    details: { type: "header", header: "header" },
  });
  assertEquals(parseToRef("foo# header@123"), {
    path: "foo.md",
    details: { type: "header", header: "header@123" },
  });
  assertEquals(parseToRef("foo@1231"), {
    path: "foo.md",
    details: { type: "position", pos: 1231 },
  });
  assertEquals(parseToRef("foo@l42c69"), {
    path: "foo.md",
    details: { type: "linecolumn", line: 42, column: 69 },
  });
  assertEquals(parseToRef("foo@L42C69"), {
    path: "foo.md",
    details: { type: "linecolumn", line: 42, column: 69 },
  });
  assertEquals(parseToRef("foo@L42"), {
    path: "foo.md",
    details: { type: "linecolumn", line: 42, column: 1 },
  });

  assertEquals(parseToRef("foo@ 123"), null);
  assertEquals(parseToRef("foo@c69"), null);
  assertEquals(parseToRef("foo@123#header"), null);
  assertEquals(parseToRef("foo@123@l29"), null);
});

Deno.test("encodeRef() cases", () => {
  // Encoding
  assertEquals(encodeRef({ path: "foo.md" }), "foo");
  assertEquals(
    encodeRef({ path: "foo.md", details: { type: "position", pos: 10 } }),
    "foo@10",
  );
  assertEquals(
    encodeRef({
      path: "foo.md",
      details: { type: "linecolumn", line: 10, column: 69 },
    }),
    "foo@L10C69",
  );
  assertEquals(
    encodeRef({
      path: "foo.md",
      details: { type: "header", header: "bar" },
    }),
    "foo#bar",
  );
});

Deno.test("isValidPath() and isValidName()", () => {
  assert(isValidPath("foo.md"));
  assert(!isValidPath("foo"));
  assert(!isValidPath("foo.md@123"));

  assert(isValidName("foo"));
  assert(!isValidName("foo.md"));
  assert(!isValidName("foo@123"));
  assert(!isValidName("^foo@123"));
  assert(!isValidName("foo[[bar"));

  // Disallow < and > in ref names
  assert(!isValidName("hello<there"));
  assert(!isValidName("hello>there"));
});

Deno.test("Page URI encoding", () => {
  assertEquals(encodePageURI("foo"), "foo");
  assertEquals(encodePageURI("folder/foo"), "folder/foo");
  assertEquals(encodePageURI("hello there"), "hello%20there");
  assertEquals(encodePageURI("hello?there"), "hello%3Fthere");
  // Now ensure all these cases are reversible
  assertEquals(decodePageURI("foo"), "foo");
  assertEquals(decodePageURI("folder/foo"), "folder/foo");
  assertEquals(decodePageURI("hello%20there"), "hello there");
  assertEquals(decodePageURI("hello%3Fthere"), "hello?there");
});


================================================
FILE: plug-api/lib/ref.ts
================================================
import {
  findNodeMatching,
  findNodeOfType,
  type ParseTree,
  renderToText,
} from "@silverbulletmd/silverbullet/lib/tree";

/**
 * Represents a path with an extension. This is a little cursed, but enforces
 * that people check the path before setting it. For navigation logic the empty
 * path will point to the index page. This could differ for e.g. for the
 * wikilink logic where it points to the currentPage
 */
export type Path = `${string}.${string}` | "";

/**
 * Represents a reference to a page or document, with optional position, anchor and header
 */
export type Ref = {
  path: Path;
  meta?: boolean;

  details?:
    | { type: "position"; pos: number }
    | { type: "linecolumn"; line: number; column: number }
    | { type: "header"; header: string };
};

/**
 * Determines the file extension of a ref. It will only return the last
 * extension, so `foo.tar.gz` resolves to `gz`
 * @returns The file extension WITHOUT the dot
 */
export function getPathExtension(path: Path): string {
  // If the ref links to the the file it's on (i.e. path === ""), it's safe to assume it's a link to a "md" page
  return path !== "" ? path.split(".").pop()!.toLowerCase() : "md";
}

/**
 * Renders a path into a "name". This means it removes the extension for `.md` path
 */
export function getNameFromPath(path: Path): string {
  return encodeRef({ path });
}

/**
 * Determines there a ref points to a markdown file
 */
export function isMarkdownPath(path: Path): boolean {
  return getPathExtension(path) === "md";
}

/**
 * Adds an `md` extension to any path without an extension or a path ending in
 * `.conflicted`, except to the empty path
 * @param path The path to normalize. Cannot contain any position or header
 * addons
 */
function normalizePath(path: string): Path {
  if (path.startsWith("/")) {
    path = path.slice(1);
  }

  if (/.+\.[a-zA-Z0-9]+$/.test(path) || path === "") {
    return path as Path;
  }

  return `${path}.md`;
}

/**
 * Determines wether a name conforms to all the requirments.
 */
export function isValidName(name: string): boolean {
  const ref = parseToRef(name);

  // If the name, parses as a link and doesn't provide any other info we can be
  // sure it was only parsed as a path and that the path then conforms to all
  // the requirements
  return !!ref && !ref.details && !ref.meta && name !== "" &&
    encodeRef(ref) === name;
}

/**
 * Determines wether a path conforms to all the requirments.
 */
export function isValidPath(path: string): path is Path {
  const ref = parseToRef(path);

  return !!ref && ref.path === path;
}

/**
 * ONLY TOUCH THIS IF YOU REALLY KNOW WHAT YOU ARE DOING. THIS REGEX IS INTEGRAL
 * TO THE INNER WORKINGS OF SILVERBULLET AND CHANGES COULD INTRODUCE MAJOR BUGS
 */
const refRegex =
  /^(?<meta>\^)?(?<path>(?!.*\.[a-zA-Z0-9]+\.md$)(?!\/?(\.|\^))(?!.*(?:\/|^)\.{1,2}(?:\/|$)|.*\/{2})(?!.*(?:\]\]|\[\[))[^@#\|<>]*)(@(?<pos>\d+)|@[Ll](?<line>\d+)(?:[Cc](?<col>\d+))?|#\s*(?<header>.*))?$/;

/**
 * Parses a reference string into a ref object.
 * @returns A ref or if the parsing fails null
 */
export function parseToRef(stringRef: string): Ref | null {
  const match = stringRef.match(refRegex);
  if (!match || !match.groups) {
    return null;
  }

  const groups = match.groups;

  const ref: Ref = { path: normalizePath(groups.path) };

  if (groups.meta) {
    ref.meta = true;
  }

  if (groups.pos !== undefined) {
    ref.details = {
      type: "position",
      pos: parseInt(groups.pos),
    };
  } else if (groups.line !== undefined) {
    ref.details = {
      type: "linecolumn",
      line: parseInt(groups.line),
      column: groups.col !== undefined ? parseInt(groups.col) : 1,
    };
  } else if (groups.header !== undefined) {
    ref.details = {
      type: "header",
      header: groups.header,
    };
  }

  return ref;
}

/**
 * The inverse of {@link parseToRef}, encodes a ref object into a reference string.
 * It tries to produce the shortest valid representation
 */
export function encodeRef(ref: Ref): string {
  let stringRef: string = ref.path;

  if (isMarkdownPath(ref.path)) {
    stringRef = stringRef.slice(0, -3);
  }

  if (ref.details?.type === "linecolumn") {
    stringRef += `@L${ref.details.line}`;

    if (ref.details.column !== 1) {
      stringRef += `C${ref.details.column}`;
    }
  } else if (ref.details?.type === "position") {
    stringRef += `@${ref.details.pos}`;
  } else if (ref.details?.type === "header") {
    stringRef += `#${ref.details.header}`;
  }

  return stringRef;
}

/**
 * Uses a parseTree and a ref pointing to a position inside it to determine the
 * offset from the start inside it, using {@link getOffsetFromHeader} and
 * {@link getOffsetFromLineColumn}
 * @param text If provided the parseTree won't be rendered back to text
 * @returns The offset in the file if it's able to determine it, otherwise -1
 */
export function getOffsetFromRef(
  parseTree: ParseTree,
  ref: Ref,
  text?: string,
): number {
  if (!ref.details) {
    return -1;
  }

  switch (ref.details.type) {
    case "position":
      return ref.details.pos;
    case "linecolumn":
      return getOffsetFromLineColumn(
        text ?? renderToText(parseTree),
        ref.details.line,
        ref.details.column,
      );
    case "header": {
      return getOffsetFromHeader(parseTree, ref.details.header);
    }
  }
}

/**
 * Find the header inside a {@link ParseTree} and returns the position the end
 * of the header
 * @param parseTree The parse tree. Can e.g. be generate using
 * `markdown.parseMarkdown`
 * @param header The header, spaces at the start or end are ignored
 * @returns The position of the header inside the document, if it can't be found
 * -1
 */
export function getOffsetFromHeader(
  parseTree: ParseTree,
  header: string,
): number {
  const node = findNodeMatching(
    parseTree,
    (subTree) => {
      if (!subTree.type || !subTree.type.startsWith("ATXHeading")) {
        return false;
      }

      const mark = findNodeOfType(subTree, "HeaderMark");
      if (!mark || mark.from === undefined || mark.to === undefined) {
        return false;
      }

      return renderToText(subTree)
        .slice(mark.to - mark.from)
        .trimStart() === header.trim();
    },
  );

  if (!node) {
    return -1;
  }

  return node.to ?? -1;
}

/**
 * Calculates the character offset from a line and column position. If the
 * position is out of bounds, it does a best-effort job returning a position.
 * @param text The text which is used to determine the offset. Only `\n` are
 * considered line breaks.
 * @param line The line number of the described position. Starts at 1
 * @param column The column number of the described position. Starts at 0
 */
export function getOffsetFromLineColumn(
  text: string,
  line: number,
  column: number,
): number {
  const lines = text.split("\n");

  const linePos = lines
    .slice(0, Math.max(line - 1, 0))
    .map((l) => l.length)
    .reduce((totalLen, len) => totalLen + len, 0);

  const columnPos = Math.max(
    0,
    Math.min(lines[line - 1]?.length ?? 0, column - 1),
  );

  return linePos + columnPos;
}

/**
 * Encodes a page name for use in a URI. Basically does
 * {@link encodeURIComponent}, but puts slashes back in place.
 */
export function encodePageURI(page: string): string {
  return encodeURIComponent(page).replace(/%2F/g, "/");
}

/**
 * Decodes a page name from a URI.
 */
export function decodePageURI(page: string): string {
  return decodeURIComponent(page);
}


================================================
FILE: plug-api/lib/resolve.test.ts
================================================
import { resolveMarkdownLink } from "@silverbulletmd/silverbullet/lib/resolve";
import { assertEquals } from "@std/assert";

Deno.test("Test URL resolver", () => {
  // Absolute paths
  assertEquals(
    resolveMarkdownLink("foo", "/bar"),
    "bar",
  );
  assertEquals(
    resolveMarkdownLink("/foo/bar/baz", "/qux"),
    "qux",
  );
  assertEquals(
    resolveMarkdownLink("foo", "/bar@123#456"),
    "bar@123#456",
  );
  assertEquals(
    resolveMarkdownLink("foo/bar", "/baz.jpg"),
    "baz.jpg",
  );

  // Relative paths
  assertEquals(
    resolveMarkdownLink("bar", "foo"),
    "foo",
  );
  assertEquals(
    resolveMarkdownLink("foo/bar.jpg", "baz"),
    "foo/baz",
  );
  assertEquals(
    resolveMarkdownLink("/foo/bar", "baz"),
    "/foo/baz",
  );
  assertEquals(
    resolveMarkdownLink("foo///bar", "baz"),
    "foo///baz",
  );
  assertEquals(
    resolveMarkdownLink("bar", "../foo/baz"),
    "foo/baz",
  );
  assertEquals(
    resolveMarkdownLink("bar", "../../foo/baz"),
    "foo/baz",
  );
  assertEquals(
    resolveMarkdownLink("bar/qux", "foo/../baz"),
    "bar/foo/../baz",
  );
});


================================================
FILE: plug-api/lib/resolve.ts
================================================
import type { Path } from "@silverbulletmd/silverbullet/lib/ref";

/**
 * Determines wether a url points into the world wide web or to the local SB instance
 */
export function isLocalURL(url: string): boolean {
  return !url.includes("://") &&
    !url.startsWith("mailto:") &&
    !url.startsWith("tel:");
}

/**
 * Extracts the folder name from a page or document name or a path
 */
export function folderName(name: string | Path): string {
  return name.split("/").slice(0, -1).join("/");
}

export function fileName(path: Path): Path;
export function fileName(name: string): string;
export function fileName(name: string | Path): string | Path {
  return name.split("/").pop()!;
}

const builtinPrefixes = [
  "tag:",
  "search:",
];

/**
 * Builtin pages are pages which SB should automatically consider as existing
 */
export function isBuiltinPath(path: Path): boolean {
  return builtinPrefixes.some((prefix) => path.startsWith(prefix));
}

/**
 * Resolves a markdown link url relative to an absolute url. It won't resolve
 * above the base of the absolute path in the file tree. This means excess `..`
 * will be dropped. It will also only resolve leading `..`
 */
export function resolveMarkdownLink(
  absolute: string,
  relative: string,
): string {
  // These are part of the commonmark spec for urls with spaces inbetween.
  if (relative.startsWith("<") && relative.endsWith(">")) {
    relative = relative.slice(1, -1);
  }

  if (relative.startsWith("/")) {
    return relative.slice(1);
  } else {
    const splitAbsolute = absolute
      .split("/")
      .slice(0, -1);
    const splitRelative = relative
      .split("/");

    while (splitRelative && splitRelative[0] === "..") {
      splitAbsolute.pop();
      splitRelative.shift();
    }

    return [...splitAbsolute, ...splitRelative].join("/") as Path;
  }
}

/**
 * Turns an absolute path into a relative path, relative to some base directory. USE WITH CAUTION, definitely buggy
 */
export function absoluteToRelativePath(base: string, absolute: string): string {
  // Remove leading /
  base = base.startsWith("/") ? base.slice(1) : base;
  absolute = absolute.startsWith("/") ? absolute.slice(1) : absolute;

  const splitAbsolute = absolute.split("/");
  const splitBase = base.split("/");
  splitBase.pop();

  // TODO: This is definitely not robust
  while (splitBase && splitBase[0] === splitAbsolute[0]) {
    splitBase.shift();
    splitAbsolute.shift();
  }

  splitBase.fill("..");

  return [...splitBase, ...splitAbsolute].join("/");
}


================================================
FILE: plug-api/lib/tags.ts
================================================
/** Extract the name from hashtag text, removing # prefix and <angle brackets> if necessary */
export function extractHashtag(text: string): string {
  if (text[0] !== "#") { // you shouldn't call this function at all
    console.error("extractHashtag called on already clean string", text);
    return text;
  } else if (text[1] === "<") {
    if (text.slice(-1) !== ">") { // this is malformed: #<name but maybe we're trying to autocomplete
      return text.slice(2);
    } else { // this is correct #<name>
      return text.slice(2, -1);
    }
  } else { // this is just #name
    return text.slice(1);
  }
}


================================================
FILE: plug-api/lib/transclusion.ts
================================================
import {
  isLocalURL,
  resolveMarkdownLink,
} from "@silverbulletmd/silverbullet/lib/resolve";
import {
  mdLinkRegex,
  wikiLinkRegex,
} from "../../client/markdown_parser/constants.ts";
import {
  getNameFromPath,
  parseToRef,
} from "@silverbulletmd/silverbullet/lib/ref";

export type LinkType = "wikilink" | "markdownlink";
/**
 * Represents a transclusion
 */
export type Transclusion = {
  url: string;
  alias: string;
  dimension?: ContentDimensions;
  linktype: LinkType;
};
/**
 * Describes the dimensions of a transclusion, if provided through the alias.
 * Can be parsed from the alias using {@link parseDimensionFromAlias}
 */
export type ContentDimensions = {
  width?: number;
  height?: number;
};

/**
 * Parse an alias, possibly containing dimensions into an object
 * @example "alias", "alias|100", "alias|100x200", "100", "100x200"
 */
export function parseDimensionFromAlias(
  text: string,
): { alias: string; dimension?: ContentDimensions } {
  let alias: string;
  let dim: ContentDimensions | undefined;
  if (text.includes("|")) {
    const [aliasPart, dimPart] = text.split("|");
    alias = aliasPart;
    const [width, height] = dimPart.split("x");
    dim = {};
    if (width) {
      dim.width = parseInt(width);
    }
    if (height) {
      dim.height = parseInt(height);
    }
  } else if (/^[x\d]/.test(text)) {
    const [width, height] = text.split("x");
    dim = {};
    if (width) {
      dim.width = parseInt(width);
    }
    if (height) {
      dim.height = parseInt(height);
    }
    alias = "";
  } else {
    alias = text;
  }

  return { alias, dimension: dim };
}

/**
 * Parses a transclusion of the type `![[]]` or `![]()`
 * @param text
 */
export function parseTransclusion(
  text: string,
): Transclusion | null {
  let url, alias = undefined;
  let linktype: LinkType = "markdownlink";
  // TODO: Take in the tree and use tree nodes to get url and alias (Applies to all regex uses)
  mdLinkRegex.lastIndex = 0;
  wikiLinkRegex.lastIndex = 0;
  let match: RegExpMatchArray | null = null;
  if ((match = mdLinkRegex.exec(text)) && match.groups) {
    ({ url, title: alias } = match.groups);

    if (isLocalURL(url)) {
      url = resolveMarkdownLink(
        client.currentName(),
        decodeURI(url),
      );
    }
    linktype = "markdownlink";
  } else if ((match = wikiLinkRegex.exec(text)) && match.groups) {
    ({ stringRef: url, alias } = match.groups);
    linktype = "wikilink";
  } else {
    // We found no match
    return null;
  }

  let dimension: ContentDimensions | undefined;
  if (alias) {
    ({ alias, dimension: dimension } = parseDimensionFromAlias(alias));
  } else {
    alias = "";
  }

  return {
    url,
    alias,
    dimension,
    linktype,
  };
}

export function nameFromTransclusion(t: Transclusion): string {
  const ref = parseToRef(t.url);
  if (!ref) {
    throw new Error(`Cannot extract name from transclusion: ${t.url}`);
  }
  return getNameFromPath(ref.path);
}


================================================
FILE: plug-api/lib/tree.test.ts
================================================
// import { parse } from "./parse_tree.ts";
import {
  addParentPointers,
  collectNodesMatching,
  findParentMatching,
  nodeAtPos,
  removeParentPointers,
  renderToText,
  replaceNodesMatching,
} from "./tree.ts";
import { assertEquals, assertNotEquals } from "@std/assert";
import { parse } from "../../client/markdown_parser/parse_tree.ts";
import { extendedMarkdownLanguage } from "../../client/markdown_parser/parser.ts";

const mdTest1 = `
# Heading
## Sub _heading_ cool

Hello, this is some **bold** text and *italic*. And [a link](http://zef.me).

%% My comment here
%% And second line

And an @mention

http://zef.plus

- This is a list [[PageLink]]
- With another item
- TODOs:
  - [ ] A task that's not yet done
  - [x] Hello
- And a _third_ one [[Wiki Page]] yo
`;

const mdTest3 = `
\`\`\`yaml
name: something
\`\`\`
`;

Deno.test("Test parsing", () => {
  const mdTree = parse(extendedMarkdownLanguage, mdTest1);
  addParentPointers(mdTree);
  // console.log(JSON.stringify(mdTree, null, 2));
  const wikiLink = nodeAtPos(mdTree, mdTest1.indexOf("Wiki Page"))!;
  assertEquals(wikiLink.type, "WikiLinkPage");
  assertNotEquals(
    findParentMatching(wikiLink, (n) => n.type === "BulletList"),
    null,
  );

  const allTodos = collectNodesMatching(mdTree, (n) => n.type === "Task");
  assertEquals(allTodos.length, 2);

  // Render back into markdown should be equivalent
  assertEquals(renderToText(mdTree), mdTest1);

  removeParentPointers(mdTree);
  replaceNodesMatching(mdTree, (n) => {
    if (n.type === "Task") {
      return {
        type: "Tosk",
      };
    }
  });
  // console.log(JSON.stringify(mdTree, null, 2));
  parse(extendedMarkdownLanguage, mdTest3);
  // console.log(JSON.stringify(mdTree3, null, 2));
});


================================================
FILE: plug-api/lib/tree.ts
================================================
import { deepClone } from "@silverbulletmd/silverbullet/lib/json";

export type ParseTree = {
  type?: string; // undefined === text node
  from?: number;
  to?: number;
  text?: string;
  children?: ParseTree[];
  // Only present after running addParentPointers
  parent?: ParseTree;
};

export function addParentPointers(tree: ParseTree) {
  if (!tree.children) {
    return;
  }
  for (const child of tree.children) {
    child.parent = tree;
    addParentPointers(child);
  }
}

export function removeParentPointers(tree: ParseTree) {
  delete tree.parent;
  if (!tree.children) {
    return;
  }
  for (const child of tree.children) {
    removeParentPointers(child);
  }
}

export function findParentMatching(
  tree: ParseTree,
  matchFn: (tree: ParseTree) => boolean,
): ParseTree | null {
  let node = tree.parent;
  while (node) {
    if (matchFn(node)) {
      return node;
    }
    node = node.parent;
  }
  return null;
}

export function collectNodesOfType(
  tree: ParseTree,
  nodeType: string,
): ParseTree[] {
  return collectNodesMatching(tree, (n) => n.type === nodeType);
}

export function collectNodesMatching(
  tree: ParseTree,
  matchFn: (tree: ParseTree) => boolean,
): ParseTree[] {
  if (matchFn(tree)) {
    return [tree];
  }
  let results: ParseTree[] = [];
  if (tree.children) {
    for (const child of tree.children) {
      results = [...results, ...collectNodesMatching(child, matchFn)];
    }
  }
  return results;
}

export async function collectNodesMatchingAsync(
  tree: ParseTree,
  matchFn: (tree: ParseTree) => Promise<boolean>,
): Promise<ParseTree[]> {
  if (await matchFn(tree)) {
    return [tree];
  }
  let results: ParseTree[] = [];
  if (tree.children) {
    for (const child of tree.children) {
      results = [
        ...results,
        ...await collectNodesMatchingAsync(child, matchFn),
      ];
    }
  }
  return results;
}

// return value: returning undefined = not matched, continue, null = delete, new node = replace
export function replaceNodesMatching(
  tree: ParseTree,
  substituteFn: (tree: ParseTree) => ParseTree | null | undefined,
) {
  if (tree && tree.children) {
    const children = tree.children.slice();
    for (const child of children) {
      const subst = substituteFn(child);
      if (subst !== undefined) {
        const pos = tree.children.indexOf(child);
        if (subst) {
          tree.children.splice(pos, 1, subst);
        } else {
          // null = delete
          tree.children.splice(pos, 1);
        }
      } else {
        replaceNodesMatching(child, substituteFn);
      }
    }
  }
}

export async function replaceNodesMatchingAsync(
  tree: ParseTree,
  substituteFn: (tree: ParseTree) => Promise<ParseTree | null | undefined>,
) {
  if (tree.children) {
    const children = tree.children.slice();
    for (const child of children) {
      const subst = await substituteFn(child);
      if (subst !== undefined) {
        const pos = tree.children.indexOf(child);
        if (subst) {
          tree.children.splice(pos, 1, subst);
        } else {
          // null = delete
          tree.children.splice(pos, 1);
        }
      } else {
        await replaceNodesMatchingAsync(child, substituteFn);
      }
    }
  }
}

export function findNodeMatching(
  tree: ParseTree,
  matchFn: (tree: ParseTree) => boolean,
): ParseTree | null {
  return collectNodesMatching(tree, matchFn)[0];
}

export function findNodeOfType(
  tree: ParseTree,
  nodeType: string,
): ParseTree | null {
  return collectNodesMatching(tree, (n) => n.type === nodeType)[0];
}

export function traverseTree(
  tree: ParseTree,
  // Return value = should stop traversal?
  matchFn: (tree: ParseTree) => boolean,
): void {
  // Do a collect, but ignore the result
  collectNodesMatching(tree, matchFn);
}

export async function traverseTreeAsync(
  tree: ParseTree,
  // Return value = should stop traversal?
  matchFn: (tree: ParseTree) => Promise<boolean>,
): Promise<void> {
  // Do a collect, but ignore the result
  await collectNodesMatchingAsync(tree, matchFn);
}

export function cloneTree(tree: ParseTree): ParseTree {
  return deepClone(tree, ["parent"]);
}

// Finds non-text node at position
export function nodeAtPos(tree: ParseTree, pos: number): ParseTree | null {
  if (pos < tree.from! || pos >= tree.to!) {
    return null;
  }
  if (!tree.children) {
    return tree;
  }
  for (const child of tree.children) {
    const n = nodeAtPos(child, pos);
    if (n && n.text !== undefined) {
      // Got a text node, let's return its parent
      return tree;
    } else if (n) {
      // Got it
      return n;
    }
  }
  return null;
}

// Turn ParseTree back into text
export function renderToText(tree?: ParseTree): string {
  if (!tree) {
    return "";
  }
  const pieces: string[] = [];
  if (tree.text !== undefined) {
    return tree.text;
  }
  for (const child of tree.children!) {
    pieces.push(renderToText(child));
  }
  return pieces.join("");
}

export function cleanTree(tree: ParseTree, omitTrimmable = true): ParseTree {
  if (tree.type === "⚠") {
    throw new Error(
      `Parse error at pos ${tree.from}`,
    );
  }
  if (tree.text !== undefined) {
    return tree;
  }
  const ast: ParseTree = {
    type: tree.type,
    children: [],
    from: tree.from,
    to: tree.to,
  };
  for (const node of tree.children!) {
    if (node.type && node.type !== "Comment") {
      ast.children!.push(cleanTree(node, omitTrimmable));
    }
    if (node.text && (omitTrimmable && node.text.trim() || !omitTrimmable)) {
      ast.children!.push(node);
    }
  }
  return ast;
}


================================================
FILE: plug-api/lib/yaml.test.ts
================================================
import { assertEquals, assertExists } from "@std/assert";
import { applyPatches, type YamlPatch } from "./yaml.ts";

Deno.test("YAML patching - basic operations and value types", () => {
  const initialYaml = `
name: my-package
version: 1.0.0
description: A sample package
`;

  const patches: YamlPatch[] = [
    { op: "set-key", path: "version", value: "1.1.0" },
    { op: "set-key", path: "description", value: "Updated description" },
    { op: "set-key", path: "empty", value: "" },
    { op: "set-key", path: "special", value: "value:with:colons" },
    { op: "set-key", path: "null", value: null },
    { op: "set-key", path: "emptyList", value: [] },
    { op: "set-key", path: "emptyObject", value: {} },
  ];

  const result = applyPatches(initialYaml, patches);

  assertExists(result);
  // Basic scalar updates
  assertEquals(result.includes("version: 1.1.0"), true);
  assertEquals(result.includes("description: Updated description"), true);
  assertEquals(result.includes("name: my-package"), true);

  // Special values and characters
  assertEquals(result.includes('empty: ""'), true);
  assertEquals(result.includes('special: "value:with:colons"'), true);
  assertEquals(result.includes("null: null"), true);

  // Empty collections
  assertEquals(result.includes("emptyList: []"), true);
  assertEquals(result.includes("emptyObject: {}"), true);
});

Deno.test("YAML patching - collections and nested structures", () => {
  const initialYaml = `
name: my-package
tags:
  - node
  - typescript
`;

  const patches: YamlPatch[] = [
    { op: "set-key", path: "tags", value: ["deno", "typescript", "yaml"] },
    { op: "set-key", path: "dependencies", value: ["std", "testing"] },
    {
      op: "set-key",
      path: "config",
      value: {
        port: 3000,
        host: "localhost",
        settings: {
          debug: true,
          timeout: 5000,
        },
      },
    },
  ];

  const result = applyPatches(initialYaml, patches);

  assertExists(result);
  // Array values
  assertEquals(result.includes("tags:"), true);
  assertEquals(result.includes("- deno"), true);
  assertEquals(result.includes("- typescript"), true);
  assertEquals(result.includes("- yaml"), true);
  assertEquals(result.includes("dependencies:"), true);
  assertEquals(result.includes("- std"), true);
  assertEquals(result.includes("- testing"), true);

  // Nested object values
  assertEquals(result.includes("config:"), true);
  assertEquals(result.includes("  port: 3000"), true);
  assertEquals(result.includes("  host: localhost"), true);
  assertEquals(result.includes("    debug: true"), true);
  assertEquals(result.includes("    timeout: 5000"), true);
});

Deno.test("YAML patching - comments and formatting preservation", () => {
  const initialYaml = `
# Main package configuration
name: my-package  # The package name
version: 1.0.0    # Current version
description: A sample package

# Additional metadata
author: John Doe  # Package maintainer
license: MIT      # License type

`;

  const patches: YamlPatch[] = [
    { op: "set-key", path: "version", value: "2.0.0" },
    { op: "set-key", path: "description", value: "An updated package" },
    { op: "set-key", path: "newProp", value: "new value" },
  ];

  const result = applyPatches(initialYaml, patches);

  // Verify all comments are preserved
  assertEquals(result.includes("# Main package configuration"), true);
  assertEquals(result.includes("# The package name"), true);
  assertEquals(result.includes("# Current version"), true);
  assertEquals(result.includes("# Additional metadata"), true);
  assertEquals(result.includes("# Package maintainer"), true);
  assertEquals(result.includes("# License type"), true);

  // Verify content is correct and in order
  assertEquals(result.includes("version: 2.0.0"), true);
  assertEquals(result.includes("description: An updated package"), true);
  assertEquals(result.includes("newProp: new value"), true);

  // Verify trailing newlines are preserved
  assertEquals(result.endsWith("\n"), true);
  const lines = result.split("\n");
  assertEquals(lines[lines.length - 1], "");
});

Deno.test("YAML patching - edge cases", () => {
  // Test empty input
  const emptyResult = applyPatches("", [
    { op: "set-key", path: "name", value: "new-package" },
  ]);
  assertEquals(emptyResult.trim(), "name: new-package");

  // Test single line with trailing newline
  const singleLineResult = applyPatches("key: value\n", [
    { op: "set-key", path: "key", value: "new value" },
  ]);
  assertEquals(singleLineResult, "key: new value\n");

  // Test multiple trailing newlines
  const multiNewlineResult = applyPatches("key: value\n\n\n", [
    { op: "set-key", path: "key", value: "new value" },
  ]);
  assertEquals(multiNewlineResult, "key: new value\n");
});

Deno.test("YAML patching - delete key basic operations", () => {
  const initialYaml = `
name: my-package
version: 1.0.0
description: A sample package
author: John Doe
license: MIT
`;

  const patches: YamlPatch[] = [
    { op: "delete-key", path: "version" },
    { op: "delete-key", path: "author" },
  ];

  const result = applyPatches(initialYaml, patches);

  assertExists(result);
  // Verify deleted keys are gone
  assertEquals(result.includes("version:"), false);
  assertEquals(result.includes("author:"), false);

  // Verify remaining keys are still present
  assertEquals(result.includes("name: my-package"), true);
  assertEquals(result.includes("description: A sample package"), true);
  assertEquals(result.includes("license: MIT"), true);
});

Deno.test("YAML patching - delete key with comments", () => {
  const initialYaml = `
# Package configuration
name: my-package

# Version number
version: 1.0.0  # Current version

# Description
description: A sample package
author: John Doe  # Main author
`;

  const patches: YamlPatch[] = [
    { op: "delete-key", path: "version" },
  ];

  const result = applyPatches(initialYaml, patches);

  // Verify the key and its comments are removed
  assertEquals(result.includes("version:"), false);
  assertEquals(result.includes("# Version number"), false);
  assertEquals(result.includes("# Current version"), false);

  // Verify other content remains
  assertEquals(result.includes("# Package configuration"), true);
  assertEquals(result.includes("name: my-package"), true);
  assertEquals(result.includes("# Description"), true);
  assertEquals(result.includes("description: A sample package"), true);
  assertEquals(result.includes("author: John Doe"), true);
  assertEquals(result.includes("# Main author"), true);
});

Deno.test("YAML patching - delete key with list values", () => {
  const initialYaml = `
name: my-package
tags:
  - node
  - typescript
  - yaml
dependencies:
  - std
  - testing
author: John Doe
`;

  const patches: YamlPatch[] = [
    { op: "delete-key", path: "tags" },
  ];

  const result = applyPatches(initialYaml, patches);

  // Verify the key and its list values are removed
  assertEquals(result.includes("tags:"), false);
  assertEquals(result.includes("- node"), false);
  assertEquals(result.includes("- typescript"), false);
  assertEquals(result.includes("- yaml"), false);

  // Verify other content remains
  assertEquals(result.includes("name: my-package"), true);
  assertEquals(result.includes("dependencies:"), true);
  assertEquals(result.includes("- std"), true);
  assertEquals(result.includes("- testing"), true);
  assertEquals(result.includes("author: John Doe"), true);
});

Deno.test("YAML patching - delete key with nested object values", () => {
  const initialYaml = `
name: my-package
config:
  port: 3000
  host: localhost
  settings:
    debug: true
    timeout: 5000
author: John Doe
`;

  const patches: YamlPatch[] = [
    { op: "delete-key", path: "config" },
  ];

  const result = applyPatches(initialYaml, patches);

  // Verify the key and its nested content are removed
  assertEquals(result.includes("config:"), false);
  assertEquals(result.includes("port: 3000"), false);
  assertEquals(result.includes("host: localhost"), false);
  assertEquals(result.includes("debug: true"), false);
  assertEquals(result.includes("timeout: 5000"), false);

  // Verify other content remains
  assertEquals(result.includes("name: my-package"), true);
  assertEquals(result.includes("author: John Doe"), true);
});

Deno.test("YAML patching - delete non-existent key", () => {
  const initialYaml = `
name: my-package
version: 1.0.0
`;

  const patches: YamlPatch[] = [
    { op: "delete-key", path: "nonexistent" },
  ];

  const result = applyPatches(initialYaml, patches);

  // Verify nothing changes when deleting a non-existent key
  assertEquals(result.includes("name: my-package"), true);
  assertEquals(result.includes("version: 1.0.0"), true);
  assertEquals(result, initialYaml);
});

Deno.test("YAML patching - mixed set and delete operations", () => {
  const initialYaml = `
name: my-package
version: 1.0.0
description: A sample package
author: John Doe
license: MIT
`;

  const patches: YamlPatch[] = [
    { op: "set-key", path: "version", value: "2.0.0" },
    { op: "delete-key", path: "author" },
    { op: "set-key", path: "maintainer", value: "Jane Smith" },
    { op: "delete-key", path: "description" },
  ];

  const result = applyPatches(initialYaml, patches);

  // Verify deletions
  assertEquals(result.includes("author:"), false);
  assertEquals(result.includes("description:"), false);

  // Verify updates and additions
  assertEquals(result.includes("version: 2.0.0"), true);
  assertEquals(result.includes("maintainer: Jane Smith"), true);

  // Verify unchanged content
  assertEquals(result.includes("name: my-package"), true);
  assertEquals(result.includes("license: MIT"), true);
});

Deno.test("YAML patching - delete all keys", () => {
  const initialYaml = `
name: my-package
version: 1.0.0
`;

  const patches: YamlPatch[] = [
    { op: "delete-key", path: "name" },
    { op: "delete-key", path: "version" },
  ];

  const result = applyPatches(initialYaml, patches);

  // Result should be effectively empty (just newlines)
  assertEquals(result.trim(), "");
});

Deno.test("YAML patching - delete key preserves surrounding structure", () => {
  const initialYaml = `
# Header comment
name: my-package
version: 1.0.0
description: A sample package

# Footer comment
license: MIT
`;

  const patches: YamlPatch[] = [
    { op: "delete-key", path: "version" },
  ];

  const result = applyPatches(initialYaml, patches);

  // Verify structure is maintained
  assertEquals(result.includes("# Header comment"), true);
  assertEquals(result.includes("name: my-package"), true);
  assertEquals(result.includes("description: A sample package"), true);
  assertEquals(result.includes("# Footer comment"), true);
  assertEquals(result.includes("license: MIT"), true);

  // Verify deleted key is gone
  assertEquals(result.includes("version:"), false);
});


================================================
FILE: plug-api/lib/yaml.ts
================================================
// Define the patch structure
export interface YamlPatch {
  op: "set-key" | "delete-key";
  path: string; // Still assuming simple, top-level key names
  value?: any; // Required for set-key, not used for delete-key
}

// Helper function specifically for serializing scalar types
function serializeToYamlScalar(
  value: string | number | boolean | null,
): string {
  if (typeof value === "string") {
    // Always quote empty strings and strings with special characters
    if (
      value === "" || // Empty string
      value.match(/[:{#}[],&*!|>'"%@`]/) || // Special YAML characters (added % and ")
      /^\d+(\.\d+)?([eE][+-]?\d+)?$/.test(value) || // Looks like a number
      value.includes(":") || // Contains colons
      ["true", "false", "null", "yes", "no", "on", "off"].includes(
        value.toLowerCase(),
      )
    ) {
      return JSON.stringify(value);
    }
    // Simple strings without special chars/meaning don't need quotes
    return value;
  } else if (
    typeof value === "number" || typeof value === "boolean" || value === null
  ) {
    return String(value);
  }
  // Default for unsupported scalar types (e.g., undefined)
  return "null";
}

// Updated helper function to serialize various JS types to YAML string representations
// Added baseIndentation parameter for handling nested lists correctly (though we only use it for top-level lists here)
function serializeToYamlValue(
  value: any,
  baseIndentation: string = "",
): string {
  if (Array.isArray(value)) {
    if (value.length === 0) {
      return "[]"; // Use flow style for empty arrays for simplicity
    }
    // Determine indentation for list items (base + 2 spaces)
    const itemIndentation = baseIndentation + "  ";
    // Format each item recursively, preceded by '- ' marker
    return "\n" +
      value.map((item) =>
        `${itemIndentation}- ${serializeToYamlValue(item, itemIndentation)}`
      ).join("\n");
    // Note: serializeToYamlValue is used recursively here to handle nested arrays/objects if needed in future
    // However, the current `applyMinimalSetKeyPatches` only handles top-level keys.
  } else if (typeof value === "object" && value !== null) {
    // Basic object serialization (not requested, but good to consider)
    // This is highly simplified and doesn't handle nesting well without more context
    const itemIndentation = baseIndentation + "  ";
    const entries = Object.entries(value);
    if (entries.length === 0) return "{}"; // Flow style empty objects
    return "\n" +
      entries.map(([key, val]) =>
        `${itemIndentation}${key}: ${
          serializeToYamlValue(val, itemIndentation)
        }`
      ).join("\n");
  } else {
    // Handle scalars using the dedicated function
    return serializeToYamlScalar(value);
  }
}

export function applyPatches(
  yamlString: string,
  patches: YamlPatch[],
): string {
  let currentYaml = yamlString;

  for (const patch of patches) {
    if (patch.op === "delete-key") {
      // Handle delete operation
      const key = patch.path;
      const lines = currentYaml.split("\n");
      let keyLineIndex = -1;
      let startDeleteIndex = -1;
      let endDeleteIndex = -1;

      // Find the key line
      for (let i = 0; i < lines.length; i++) {
        const line = lines[i];
        const trimmedLine = line.trim();
        if (trimmedLine.startsWith(key + ":")) {
          keyLineIndex = i;
          startDeleteIndex = i;
          endDeleteIndex = i;

          // Look backwards for preceding comments (delete them too)
          for (let j = i - 1; j >= 0; j--) {
            const prevLine = lines[j].trim();
            if (prevLine.startsWith("#")) {
              startDeleteIndex = j;
            } else if (prevLine !== "") {
              break;
            } else {
              // Empty line - include it in deletion if followed by comments
              if (startDeleteIndex < i) {
                startDeleteIndex = j;
              } else {
                break;
              }
            }
          }

          // Determine the indentation of the key
          const keyIndent = lines[i].match(/^(\s*)/)?.[1] || "";

          // Look forwards to find all content belonging to this key
          // This includes list items and nested objects
          for (let j = i + 1; j < lines.length; j++) {
            const nextLine = lines[j];
            const trimmedNextLine = nextLine.trim();

            // Empty line - continue looking
            if (trimmedNextLine === "") {
              continue;
            }

            // Comment at same or greater indentation - might be trailing comment
            if (trimmedNextLine.startsWith("#")) {
              const commentIndent = nextLine.match(/^(\s*)/)?.[1] || "";
              if (commentIndent.length > keyIndent.length) {
                endDeleteIndex = j;
                continue;
              } else {
                // Comment at same or less indentation - not part of this key
                break;
              }
            }

            // Check indentation of next non-empty, non-comment line
            const nextIndent = nextLine.match(/^(\s*)/)?.[1] || "";
            if (nextIndent.length > keyIndent.length) {
              // This line is indented more than the key, so it belongs to the key
              endDeleteIndex = j;
            } else {
              // This line is at same or less indentation, so it's a new key
              break;
            }
          }

          break;
        }
      }

      if (keyLineIndex !== -1) {
        // Delete the lines from startDeleteIndex to endDeleteIndex (inclusive)
        const beforeDelete = lines.slice(0, startDeleteIndex);
        const afterDelete = lines.slice(endDeleteIndex + 1);

        // Clean up excessive empty lines at the boundary
        // Remove trailing empty lines from beforeDelete
        while (
          beforeDelete.length > 0 &&
          beforeDelete[beforeDelete.length - 1].trim() === ""
        ) {
          beforeDelete.pop();
        }

        // Remove leading empty lines from afterDelete (but keep one if there's content after)
        let leadingEmptyCount = 0;
        for (const line of afterDelete) {
          if (line.trim() === "") {
            leadingEmptyCount++;
          } else {
            break;
          }
        }
        const cleanedAfterDelete = afterDelete.slice(
          Math.min(leadingEmptyCount, 1),
        );

        currentYaml = [...beforeDelete, ...cleanedAfterDelete].join("\n");
        if (currentYaml && !currentYaml.endsWith("\n")) {
          currentYaml += "\n";
        }
      }
      // If key not found, do nothing
      continue;
    }

    if (patch.op !== "set-key") continue;

    const key = patch.path;

    // Split the YAML into lines for easier processing
    const lines = currentYaml.split("\n");
    let keyLineIndex = -1;
    let commentBlock = "";
    let trailingComments = "";
    let inlineComment = "";

    // Find the key line and collect comments
    for (let i = 0; i < lines.length; i++) {
      const line = lines[i];
      const trimmedLine = line.trim();
      if (trimmedLine.startsWith(key + ":")) {
        keyLineIndex = i;
        // Extract inline comment if present
        const commentMatch = line.match(/#.*$/);
        if (commentMatch) {
          inlineComment = commentMatch[0];
        }
        // Look backwards for comments
        for (let j = i - 1; j >= 0; j--) {
          const prevLine = lines[j].trim();
          if (prevLine.startsWith("#")) {
            commentBlock = lines[j] + "\n" + commentBlock;
          } else if (prevLine !== "") {
            break;
          }
        }
        // Look forwards for comments
        for (let j = i + 1; j < lines.length; j++) {
          const nextLine = lines[j].trim();
          if (nextLine.startsWith("#")) {
            trailingComments += lines[j] + "\n";
          } else if (nextLine !== "") {
            break;
          }
        }
        break;
      }
    }

    // Serialize the new value
    const serializedNewValue = serializeToYamlValue(patch.value);

    // Create the replacement line
    let replacementLine: string;
    if (serializedNewValue.startsWith("\n")) {
      // For lists/objects, the key line ends with just ':'
      replacementLine = `${key}:${inlineComment}${serializedNewValue}`;
    } else {
      // For scalars, format as key: value
      replacementLine = `${key}: ${serializedNewValue}${
        inlineComment ? " " + inlineComment : ""
      }`;
    }

    if (keyLineIndex !== -1) {
      // Replace the existing line while preserving comments
      const beforeKey = lines.slice(
        0,
        keyLineIndex - commentBlock.split("\n").filter(Boolean).length,
      );
      const afterKey = lines.slice(
        keyLineIndex + 1 + trailingComments.split("\n").filter(Boolean).length,
      );

      // Build the new content
      const newContent = [
        ...beforeKey,
        ...commentBlock.split("\n").filter(Boolean),
        replacementLine,
        ...trailingComments.split("\n").filter(Boolean),
        ...afterKey,
      ];

      // Join lines and ensure proper newlines
      currentYaml = newContent.join("\n").replace(/\n*$/, "\n") + "\n";
    } else {
      // Key not found: Add the new key-value pair to the end
      const newLineBlock = replacementLine;
      if (currentYaml.trim() === "") {
        currentYaml = newLineBlock + "\n";
      } else {
        currentYaml = currentYaml.replace(/\n*$/, "\n") + newLineBlock + "\n";
      }
    }
  }

  // Ensure the result ends with a newline
  return currentYaml.replace(/\n*$/, "\n");
}


================================================
FILE: plug-api/syscall.ts
================================================
// declare global {
//   function syscall(name: string, ...args: any[]): Promise<any>;
// }

// This is the case when running tests only, so giving it a dummy syscall function
if (typeof self === "undefined") {
  (self as any) = {
    syscall: () => {
      throw new Error("Not implemented here");
    },
  };
}

// Late binding syscall
export function syscall(name: string, ...args: any[]): Promise<any> {
  return (globalThis as any).syscall(name, ...args);
}


================================================
FILE: plug-api/syscalls.ts
================================================
export * as editor from "./syscalls/editor.ts";
export * as markdown from "./syscalls/markdown.ts";
export * as space from "./syscalls/space.ts";
export * as system from "./syscalls/system.ts";
export * as clientStore from "./syscalls/client_store.ts";
export * as sync from "./syscalls/sync.ts";
export * as language from "./syscalls/language.ts";
export * as codeWidget from "./syscalls/code_widget.ts";
export * as asset from "./syscalls/asset.ts";
export * as events from "./syscalls/event.ts";
export * as shell from "./syscalls/shell.ts";
export * as mq from "./syscalls/mq.ts";
export * as datastore from "./syscalls/datastore.ts";
export * as jsonschema from "./syscalls/jsonschema.ts";
export * as lua from "./syscalls/lua.ts";
export * as config from "./syscalls/config.ts";

// Not technically syscalls, but we want to export them for convenience
export * as index from "./syscalls/index.ts";

export * from "./syscall.ts";


================================================
FILE: plug-api/syscalls/asset.ts
================================================
import { base64DecodeDataUrl } from "../lib/crypto.ts";
import { syscall } from "../syscall.ts";

import type { FileMeta } from "../../plug-api/types/index.ts";

/**
 * Reads an asset embedded in a plug (via the `assets` field in the plug manifest).
 * @param plugName name of the plug to read asset from
 * @param name name of the asset to read
 * @param encoding either "utf8" or "dataurl"
 * @returns the content of the asset in the requested encoding
 */
export async function readAsset(
  plugName: string,
  name: string,
  encoding: "utf8" | "dataurl" = "utf8",
): Promise<string> {
  const dataUrl = await syscall("asset.readAsset", plugName, name) as string;
  switch (encoding) {
    case "utf8":
      return new TextDecoder().decode(base64DecodeDataUrl(dataUrl));
    case "dataurl":
      return dataUrl;
  }
}

export async function listFiles(plugName: string): Promise<FileMeta[]> {
  return await syscall("asset.listFiles", plugName);
}

export async function getFileMeta(
  plugName: string,
  name: string,
): Promise<FileMeta> {
  return await syscall("asset.getFileMeta", plugName, name);
}


================================================
FILE: plug-api/syscalls/client_store.ts
================================================
import { syscall } from "../syscall.ts";

/**
 * Implements a very simple (string) key value store for the client.
 * Generally should only be used to set some client-specific states, such as preferences.
 * @module
 */

/**
 * Sets a value in the client store.
 * @param key the key to set
 * @param value the value to set
 */
export function set(key: string, value: any): Promise<void> {
  return syscall("clientStore.set", key, value);
}

/**
 * Gets a value from the client store.
 * @param key the key to get
 * @returns the value associated with the key
 */
export function get(key: string): Promise<any> {
  return syscall("clientStore.get", key);
}

/**
 * Deletes a value from the client store.
 * @param key the key to delete
 */
export function del(key: string): Promise<void> {
  return syscall("clientStore.delete", key);
}


================================================
FILE: plug-api/syscalls/code_widget.ts
================================================
import { syscall } from "../syscall.ts";

/**
 * Refreshes all code widgets on the page that support it.
 */
export function refreshAll(): Promise<void> {
  return syscall("codeWidget.refreshAll");
}


================================================
FILE: plug-api/syscalls/config.ts
================================================
import { syscall } from "../syscall.ts";

/**
 * Gets a config value by path, with support for dot notation.
 * @param path The path to get the value from
 * @param defaultValue The default value to return if the path doesn't exist
 * @returns The value at the path, or the default value
 */
export function get<T>(path: string | string[], defaultValue: T): Promise<T> {
  return syscall("config.get", path, defaultValue);
}

/**
 * Sets a config value by path, with support for dot notation.
 * @param path The path to set the value at
 * @param value The value to set
 */
export function set<T>(path: string, value: T): Promise<void>;
/**
 * Sets multiple config values at once.
 * @param values An object containing key-value pairs to set
 */
export function set(values: Record<string, any>): Promise<void>;
export function set<T>(
  pathOrValues: string | Record<string, any>,
  value?: T,
): Promise<void> {
  return syscall("config.set", pathOrValues, value);
}

/**
 * Inserts a config value into an array
 */
export function insert<T>(
  path: string | string[],
  value: T,
): Promise<void> {
  return syscall("config.insert", path, value);
}

/**
 * Checks if a config path exists.
 * @param path The path to check
 * @returns True if the path exists, false otherwise
 */
export function has(path: string): Promise<boolean> {
  return syscall("config.has", path);
}

/**
 * Defines a JSON schema for a configuration key.
 * The schema will be used to validate values when setting this key.
 * @param key The configuration key to define a schema for
 * @param schema The JSON schema to validate against
 */
export function define(key: string, schema: any): Promise<void> {
  return syscall("config.define", key, schema);
}


================================================
FILE: plug-api/syscalls/datastore.ts
================================================
import type { LuaCollectionQuery } from "../../client/space_lua/query_collection.ts";
import { syscall } from "../syscall.ts";

import type { KV, KvKey, KvQuery } from "../../plug-api/types/datastore.ts";

/**
 * Exposes a key value story with query capabilities.
 * @module
 */

/**
 * Sets a value in the key value store.
 * @param key the key to set
 * @param value the value to set
 */
export function set(key: KvKey, value: any): Promise<void> {
  return syscall("datastore.set", key, value);
}

/**
 * Sets multiple values in the key value store.
 * @param kvs the key value pairs to set
 */
export function batchSet(kvs: KV[]): Promise<void> {
  return syscall("datastore.batchSet", kvs);
}

/**
 * Gets a value from the key value store.
 * @param key the key to get
 * @returns the value associated with the key (or undefined if not found)
 */
export function get(key: KvKey): Promise<any | undefined> {
  return syscall("datastore.get", key);
}

/**
 * Gets multiple values from the key value store.
 * @param keys the keys to get
 * @returns the values associated with the keys (or undefined if not found)
 */
export function batchGet(keys: KvKey[]): Promise<(any | undefined)[]> {
  return syscall("datastore.batchGet", keys);
}

/**
 * Deletes a value from the key value store.
 * @param key the key to delete
 */
export function del(key: KvKey): Promise<void> {
  return syscall("datastore.delete", key);
}

/**
 * Deletes multiple values from the key value store.
 * @param keys the keys to delete
 */
export function batchDel(keys: KvKey[]): Promise<void> {
  return syscall("datastore.batchDelete", keys);
}

export function query(options: KvQuery): Promise<KV[]> {
  return syscall("datastore.query", options);
}

export function queryLua(
  prefix: string[],
  query: LuaCollectionQuery,
  scopeVariables?: Record<string, any>,
): Promise<any[]> {
  return syscall("datastore.queryLua", prefix, query, scopeVariables);
}

export function batchDeletePrefix(prefix: KvKey): Promise<void> {
  return syscall("datastore.batchDeletePrefix", prefix);
}

/**
 * Queries the key value store and deletes all matching items
 * @param query the query to run
 * @param variables the variables that can be referenced inside the query
 */
export function queryDelete(
  query: KvQuery,
  variables?: Record<string, any>,
): Promise<void> {
  return syscall("datastore.queryDelete", query, variables);
}

/**
 * Lists all functions currently defined and available for use in queries
 * @returns the names of all functions in the key value store
 */
export function listFunctions(): Promise<string[]> {
  return syscall("datastore.listFunctions");
}


================================================
FILE: plug-api/syscalls/editor.ts
================================================
import { syscall } from "../syscall.ts";
import type { FilterOption, UploadFile } from "../../plug-api/types/client.ts";
import type { Path, Ref } from "../lib/ref.ts";
import type { PageMeta } from "../../plug-api/types/index.ts";

/**
 * Exposes various editor utilities.
 * Important: These syscalls are only available in the client.
 * @module
 */

/**
 * Returns the name of the page or document currently open in the editor.
 * @returns the current page name
 */
export function getCurrentPage(): Promise<string> {
  return syscall("editor.getCurrentPage");
}

/**
 * Returns the meta data of the page or document currently open in the editor.
 * @returns the current page meta data
 */
export function getCurrentPageMeta(): Promise<PageMeta | undefined> {
  return syscall("editor.getCurrentPageMeta");
}

/**
 * Returns the path of the page or document currently open in the editor.
 * @returns the current page path
 */
export function getCurrentPath(): Promise<Path> {
  return syscall("editor.getCurrentPath");
}

/**
 * Returns the names of the pages that have been recently opened
 * @returns the names of the recently opened pages
 */
export function getRecentlyOpenedPages(): Promise<PageMeta[]> {
  return syscall("editor.getRecentlyOpenedPages");
}

/**
 * Returns the name of the currently open editor.
 * @returns the name of the current editor
 */
export function getCurrentEditor(): Promise<string> {
  return syscall("editor.getCurrentEditor");
}

/**
 * Returns the full text of the currently open page
 */
export function getText(): Promise<string> {
  return syscall("editor.getText");
}

/**
 * This updates the editor text, but in a minimal-diff way:
 * it compares the current editor text with the new text, and only sends the changes to the editor, thereby preserving cursor location
 */
export function setText(
  newText: string,
  isolateHistory = false,
): Promise<void> {
  return syscall("editor.setText", newText, isolateHistory);
}

/**
 * Returns the position (in # of characters from the beginning of the file) of the cursor in the editor
 */
export function getCursor(): Promise<number> {
  return syscall("editor.getCursor");
}

/**
 * Returns the line number and column number of the cursor in the editor
 */
export function getSelection(): Promise<
  { from: number; to: number; text: string }
> {
  return syscall("editor.getSelection");
}

/**
 * Sets the position of the cursor in the editor
 * @param from the start position of the selection
 * @param to the end position of the selection
 */
export function setSelection(from: number, to: number): Promise<void> {
  return syscall("editor.setSelection", from, to);
}

/**
 * Invoke a client command by name
 * Note: only available on the client
 * @param name name of the command
 * @param args arguments to pass to the command
 */
export function invokeCommand(name: string, args?: string[]): Promise<any> {
  return syscall("editor.invokeCommand", name, args);
}

/**
 * Forces a save of the current page
 */
export function save(): Promise<void> {
  return syscall("editor.save");
}

/**
 * Navigates to the specified page reference
 * @param pageRef the page reference to navigate to
 * @param replaceState whether to replace the current history state in the browser history
 * @param newWindow whether to open the page in a new window
 */
export function navigate(
  ref: Ref | string,
  replaceState = false,
  newWindow = false,
): Promise<void> {
  return syscall("editor.navigate", ref, replaceState, newWindow);
}

/**
 * Opens the page navigator
 * @param mode the mode to open the navigator in
 */
export function openPageNavigator(
  mode: "page" | "meta" | "document" | "all" = "page",
): Promise<void> {
  return syscall("editor.openPageNavigator", mode);
}

/**
 * Opens the command palette
 */
export function openCommandPalette(): Promise<void> {
  return syscall("editor.openCommandPalette");
}

/**
 * Force reloads the current page
 */
export function reloadPage(): Promise<void> {
  return syscall("editor.reloadPage");
}

/**
 * Force reloads the browser UI
 */
export function reloadUI(): Promise<void> {
  return syscall("editor.reloadUI");
}

/**
 * Rebuilds the editor state to ensure the dispatch updates the state.
 */
export function rebuildEditorState(): Promise<void> {
  return syscall("editor.rebuildEditorState");
}

/**
 * Reloads the config and commands, also in the server
 */
export function reloadConfigAndCommands(): Promise<void> {
  return syscall("editor.reloadConfigAndCommands");
}

/**
 * Opens the specified URL in the browser
 * @param url the URL to open
 * @param existingWindow whether to open the URL in an existing window
 */
export function openUrl(url: string, existingWindow = false): Promise<void> {
  return syscall("editor.openUrl", url, existingWindow);
}

export function newWindow(): Promise<void> {
  return syscall("editor.newWindow");
}

/**
 * This is calling the `go()` method from the History Web API.
 * @param delta Position in history to move to relative to the current page,
 * where a negative value moves backwards, and positive forwards
 */
export function goHistory(delta: number): Promise<void> {
  return syscall("editor.goHistory", delta);
}

/**
 * Force the client to download the file in dataUrl with filename as file name
 * @param filename the name of the file to download
 * @param dataUrl the dataUrl of the file to download
 */
export function downloadFile(filename: string, dataUrl: string): Promise<void> {
  return syscall("editor.downloadFile", filename, dataUrl);
}

/**
 * Triggers the browser's native file upload dialog/popup
 * @param accept the file types to accept
 * @param capture the capture mode for the file input
 */
export function uploadFile(
  accept?: string,
  capture?: string,
): Promise<UploadFile> {
  return syscall("editor.uploadFile", accept, capture);
}

/**
 * Shows a flash notification to the user (top right corner)
 * @param message the message to show
 * @param type the type of notification to show
 */
export function flashNotification(
  message: string,
  type: "info" | "error" = "info",
): Promise<void> {
  return syscall("editor.flashNotification", message, type);
}

/**
 * Exposes a filter box UI (similar to the page navigator and command palette)
 * @param label the label to show left of the input box
 * @param options the options to show and to filter on
 * @param helpText the help text to show below the input box
 * @param placeHolder the placeholder text to show in the input box
 */
export function filterBox(
  label: string,
  options: FilterOption[],
  helpText = "",
  placeHolder = "",
): Promise<FilterOption | undefined> {
  return syscall("editor.filterBox", label, options, helpText, placeHolder);
}

/**
 * Shows a panel in the editor
 * @param id the location of the panel to show
 * @param mode the mode or "size" of the panel
 * @param html the html content of the panel
 * @param script the script content of the panel
 */
export function showPanel(
  id: "lhs" | "rhs" | "bhs" | "modal",
  mode: number,
  html: string,
  script = "",
): Promise<void> {
  return syscall("editor.showPanel", id, mode, html, script);
}

/**
 * Hides a panel in the editor
 * @param id the location of the panel to hide
 */
export function hidePanel(
  id: "lhs" | "rhs" | "bhs" | "modal",
): Promise<void> {
  return syscall("editor.hidePanel", id);
}

export function showProgress(
  progressPercentage?: number,
  progressType?: string,
): Promise<void> {
  return syscall("editor.showProgress", progressPercentage, progressType);
}

/**
 * Insert text at the specified position into the editor
 * @param text the text to insert
 * @param pos
 */
export function insertAtPos(text: string, pos: number): Promise<void> {
  return syscall("editor.insertAtPos", text, pos);
}

/**
 * Replace the text in the specified range in the editor
 * @param from the start position of the range
 * @param to the end position of the range
 * @param text the text to replace with
 */
export function replaceRange(
  from: number,
  to: number,
  text: string,
): Promise<void> {
  return syscall("editor.replaceRange", from, to, text);
}

/**
 * Move the cursor to the specified position in the editor
 * @param pos the position to move the cursor to
 * @param center whether to center the cursor in the editor after moving
 */
export function moveCursor(pos: number, center = false): Promise<void> {
  return syscall("editor.moveCursor", pos, center);
}

/**
 * Move the cursor to the specified line and column in the editor
 * @param line the line number to move the cursor to
 * @param column the column number to move the cursor to
 * @param center whether to center the cursor in the editor after moving
 */
export function moveCursorToLine(
  line: number,
  column = 1,
  center = false,
): Promise<void> {
  return syscall("editor.moveCursorToLine", line, column, center);
}

/**
 * Insert text at the cursor position in the editor
 * @param text the text to insert
 */
export function insertAtCursor(
  text: string,
  scrollIntoView = false,
  cursorPlaceHolder = false,
): Promise<void> {
  return syscall(
    "editor.insertAtCursor",
    text,
    scrollIntoView,
    cursorPlaceHolder,
  );
}

/**
 * Dispatch a CodeMirror transaction: https://codemirror.net/docs/ref/#state.Transaction
 */
export function dispatch(change: any): Promise<void> {
  return syscall("editor.dispatch", change);
}

/**
 * Prompt the user for text input
 * @param message the message to show in the prompt
 * @param defaultValue a default value pre-filled in the prompt
 * @returns
 */
export function prompt(
  message: string,
  defaultValue = "",
): Promise<string | undefined> {
  return syscall("editor.prompt", message, defaultValue);
}

/**
 * Prompt the user for confirmation
 * @param message the message to show in the confirmation dialog
 * @returns
 */
export function confirm(
  message: string,
): Promise<boolean> {
  return syscall("editor.confirm", message);
}

/**
 * Prompt the user for confirmation
 * @param message the message to show in the confirmation dialog
 * @returns
 */
export function alert(
  message: string,
): Promise<boolean> {
  return syscall("editor.alert", message);
}

/**
 * Get the value of a UI option
 * @param key the key of the UI option to get
 * @returns
 */
export function getUiOption(key: string): Promise<any> {
  return syscall("editor.getUiOption", key);
}

/**
 * Set the value of a UI option
 * @param key the key of the UI option to set
 * @param value the value to set the UI option to
 */
export function setUiOption(key: string, value: any): Promise<void> {
  return syscall("editor.setUiOption", key, value);
}

/**
 * Perform a fold at the current cursor position
 */
export function fold(): Promise<void> {
  return syscall("editor.fold");
}

/**
 * Perform an unfold at the current cursor position
 */
export function unfold(): Promise<void> {
  return syscall("editor.unfold");
}

/**
 * Toggle the fold at the current cursor position
 */
export function toggleFold(): Promise<void> {
  return syscall("editor.toggleFold");
}

/**
 * Fold all code blocks in the editor
 */
export function foldAll(): Promise<void> {
  return syscall("editor.foldAll");
}

/**
 * Unfold all code blocks in the editor
 */
export function unfoldAll(): Promise<void> {
  return syscall("editor.unfoldAll");
}

/**
 * Perform an undo operation of the last edit in the editor
 */
export function undo(): Promise<void> {
  return syscall("editor.undo");
}

/**
 * Perform a redo operation of the last undo in the editor
 */
export function redo(): Promise<void> {
  return syscall("editor.redo");
}

/**
 * Open the editor's native search panel
 */
export function openSearchPanel(): Promise<void> {
  return syscall("editor.openSearchPanel");
}

/**
 * Copy the specified data to the clipboard
 * @param data the data to copy
 */
export function copyToClipboard(data: string | Blob): Promise<void> {
  return syscall("editor.copyToClipboard", data);
}

/**
 * Delete the current line in the editor
 */
export function deleteLine(): Promise<void> {
  return syscall("editor.deleteLine");
}

/**
 * Comment or uncomment the current line in the editor
 */
export function toggleComment(): Promise<void> {
  return syscall("editor.toggleComment");
}

export function moveLineUp(): Promise<void> {
  return syscall("editor.moveLineUp");
}

export function moveLineDown(): Promise<void> {
  return syscall("editor.moveLineDown");
}

// Vim-mode specific syscalls

/**
 * Execute a Vim ex command
 * @param exCommand the ex command to execute
 */
export function vimEx(exCommand: string): Promise<any> {
  return syscall("editor.vimEx", exCommand);
}

/**
 * Execute a vim config using the CodeMirror Vim Mode API
 */
export function configureVimMode(): Promise<any> {
  return syscall("editor.configureVimMode");
}

// Document editor specific syscalls

/**
 * Send a message - or event if you will - to the currently used document editor
 * @param type the message type, which can be listend to
 * @param data data attached to the message
 */
export function sendMessage(type: string, data?: any): Promise<void> {
  return syscall("editor.sendMessage", type, data);
}

/**
 * Check if the editor is running on a mobile device
 */
export function isMobile(): Promise<boolean> {
  return syscall("editor.isMobile");
}


================================================
FILE: plug-api/syscalls/event.ts
================================================
import { syscall } from "../syscall.ts";

/**
 * Exposes the SilverBullet event bus.
 * @module
 */

/**
 * Triggers an event on the SilverBullet event bus.
 * This can be used to implement an RPC-style system too, because event handlers can return values,
 * which are then accumulated in an array and returned to the caller.
 * @param eventName the name of the event to trigger
 * @param data payload to send with the event
 * @param timeout optional timeout in milliseconds to wait for a response
 * @returns an array of responses from the event handlers (if any)
 */
export function dispatchEvent(
  eventName: string,
  data: any,
  timeout?: number,
): Promise<any[]> {
  return new Promise((resolve, reject) => {
    let timeouter: any = -1;
    if (timeout) {
      timeouter = setTimeout(() => {
        console.log("Timeout!");
        reject("timeout");
      }, timeout);
    }
    syscall("event.dispatch", eventName, data)
      .then((r: any) => {
        if (timeouter !== -1) {
          clearTimeout(timeouter);
        }
        resolve(r);
      })
      .catch(reject);
  });
}

/**
 * List all events currently registered (listened to) on the SilverBullet event bus.
 * @returns an array of event names
 */
export function listEvents(): Promise<string[]> {
  return syscall("event.listEvents");
}


================================================
FILE: plug-api/syscalls/index.ts
================================================
import type { LuaCollectionQuery } from "../../client/space_lua/query_collection.ts";
import { syscall } from "@silverbulletmd/silverbullet/syscall";
import type { ObjectValue } from "../../plug-api/types/index.ts";

/**
 * Exposes the SilverBullet object indexing system
 * @module
 */

/**
 * Indexes objects for a specific page
 * @param page - The page identifier where objects will be indexed
 * @param objects - Array of objects to be indexed
 * @returns Promise that resolves when indexing is complete
 */
export function indexObjects<T>(
  page: string,
  objects: ObjectValue<T>[],
): Promise<void> {
  return syscall("index.indexObjects", page, objects);
}

export function validateObjects<T>(
  page: string,
  objects: ObjectValue<T>[],
): Promise<{ error: string; object: ObjectValue } | null> {
  return syscall("index.validateObjects", page, objects);
}

/**
 * Queries objects using a Lua-based collection query
 * @param tag - The tag to filter objects by
 * @param query - Lua query parameters to filter objects
 * @param scopedVariables - Optional variables to be used in the Lua query
 * @returns Promise that resolves with an array of matching objects
 */
export function queryLuaObjects<T>(
  tag: string,
  query: LuaCollectionQuery,
  scopedVariables?: Record<string, any>,
): Promise<ObjectValue<T>[]> {
  return syscall("index.queryLuaObjects", tag, query, scopedVariables);
}

/**
 * Retrieves a specific object by its reference
 * @param page - The page identifier where the object is located
 * @param tag - The tag of the object
 * @param ref - The reference identifier of the object
 * @returns Promise that resolves with the matching object or undefined if not found
 */
export function getObjectByRef<T>(
  page: string,
  tag: string,
  ref: string,
): Promise<ObjectValue<T> | undefined> {
  return syscall("index.getObjectByRef", page, tag, ref);
}

/**
 * Ensures that the full index is built and up-to-date
 */
export function ensureFullIndex(): Promise<void> {
  return syscall("index.ensureFullIndex");
}

export function reindexSpace(): Promise<void> {
  return syscall("index.reindexSpace");
}

export function deleteObject(
  page: string,
  tag: string,
  ref: string,
): Promise<void> {
  return syscall("index.deleteObject", page, tag, ref);
}


================================================
FILE: plug-api/syscalls/jsonschema.ts
================================================
import { syscall } from "../syscall.ts";

/**
 * Validates a JSON object against a JSON schema.
 * @param schema the JSON schema to validate against
 * @param object the JSON object to validate
 * @returns an error message if the object is invalid, or undefined if it is valid
 */
export function validateObject(
  schema: any,
  object: any,
): Promise<string | undefined> {
  return syscall("jsonschema.validateObject", schema, object);
}

/**
 * Validates a JSON schema.
 * @param schema the JSON schema to validate
 * @returns an error message if the schema is invalid, or undefined if it is valid
 */
export function validateSchema(
  schema: any,
): Promise<string | undefined> {
  return syscall("jsonschema.validateSchema", schema);
}


================================================
FILE: plug-api/syscalls/language.ts
================================================
import { syscall } from "../syscall.ts";
import type { ParseTree } from "../lib/tree.ts";

/**
 * Parses a piece of code using any of the supported SB languages, see `common/languages.ts` for a list
 * @param language the language to parse
 * @param code the code to parse
 * @returns a ParseTree representation of the code
 */
export function parseLanguage(
  language: string,
  code: string,
): Promise<ParseTree> {
  return syscall("language.parseLanguage", language, code);
}

/**
 * Lists all supported languages in fenced code blocks
 * @returns a list of all supported languages
 */
export function listLanguages(): Promise<string[]> {
  return syscall("language.listLanguages");
}


================================================
FILE: plug-api/syscalls/lua.ts
================================================
import { syscall } from "../syscall.ts";
import type { LuaBlock, LuaExpression } from "../../client/space_lua/ast.ts";

export function parse(
  code: string,
): Promise<LuaBlock> {
  return syscall("lua.parse", code);
}

export function parseExpression(
  expression: string,
): Promise<LuaExpression> {
  return syscall("lua.parseExpression", expression);
}

export function evalExpression(
  expression: string,
): Promise<any> {
  return syscall("lua.evalExpression", expression);
}


================================================
FILE: plug-api/syscalls/markdown.ts
================================================
import { syscall } from "../syscall.ts";
import type { ParseTree } from "../lib/tree.ts";

/**
 * Parses a piece of markdown text into a ParseTree.
 * @param text the markdown text to parse
 * @returns a ParseTree representation of the markdown text
 */
export function parseMarkdown(text: string): Promise<ParseTree> {
  return syscall("markdown.parseMarkdown", text);
}

/**
 * Renders a ParseTree to markdown.
 * @param tree the parse tree
 * @returns the rendered markdown of a passed parse tree
 */
export function renderParseTree(tree: ParseTree): Promise<string> {
  return syscall("markdown.renderParseTree", tree);
}

/**
 * Expands custom markdown Lua directives and transclusions into plain markdown
 * @param tree the parse tree
 * @returns the expaneded markdown
 */
export function expandMarkdown(tree: ParseTree): Promise<ParseTree> {
  return syscall("markdown.expandMarkdown", tree);
}

/**
 * Renders markdown text to HTML.
 * @param markdownText the markdown text to render
 * @returns HTML representation of the markdown
 */
export function markdownToHtml(markdownText: string): Promise<string> {
  return syscall("markdown.markdownToHtml", markdownText);
}


================================================
FILE: plug-api/syscalls/mq.ts
================================================
import { syscall } from "../syscall.ts";

import type { MQStats } from "../../plug-api/types/datastore.ts";

/**
 * Implements a simple Message Queue system.
 * @module
 */

/**
 * Sends a message to a queue.
 * @param queue the name of the queue to send the message to
 * @param body the body of the message to send
 */
export function send(queue: string, body: any): Promise<void> {
  return syscall("mq.send", queue, body);
}

/**
 * Sends a batch of messages to a queue.
 * @param queue the name of the queue
 * @param bodies the bodies of the messages to send
 */
export function batchSend(
  queue: string,
  bodies: any[],
): Promise<void> {
  return syscall("mq.batchSend", queue, bodies);
}

/**
 * Flushes all messages from a queue.
 * @param queue the name of the queue to subscribe to
 */
export function flushQueue(queue: string): Promise<void> {
  return syscall("mq.flushQueue", queue);
}

/**
 * Flushes all messages from all queues.
 */
export function flushAllQueues(): Promise<void> {
  return syscall("mq.flushAllQueues");
}

/**
 * Acknowledges a message from a queue, in case it needs to be explicitly acknowledged.
 * @param queue the name of the queue the message came from
 * @param id the id of the message to acknowledge
 */
export function ack(queue: string, id: string): Promise<void> {
  return syscall("mq.ack", queue, id);
}

/**
 * Acknowledges a batch of messages from a queue, in case they need to be explicitly acknowledged.
 * @param queue the name of the queue the messages came from
 * @param ids the ids of the messages to acknowledge
 */
export function batchAck(queue: string, ids: string[]): Promise<void> {
  return syscall("mq.batchAck", queue, ids);
}

/**
 * Retrieves stats on a particular queue.
 * @param queue the name of the queue
 */
export function getQueueStats(queue?: string): Promise<MQStats> {
  return syscall("mq.getQueueStats", queue);
}

/**
 * Waits for a queue to become empty.
 * @param queue the name of the queue
 * @returns a promise that resolves when the queue is empty
 */
export function awaitEmptyQueue(queue: string): Promise<void> {
  return syscall("mq.awaitEmptyQueue", queue);
}


================================================
FILE: plug-api/syscalls/shell.ts
================================================
import { syscall } from "../syscall.ts";

/**
 * Runs a shell command.
 * @param cmd the command to run
 * @param args the arguments to pass to the command
 * @returns the stdout, stderr, and exit code of the command
 */
export function run(
  cmd: string,
  args: string[],
): Promise<{ stdout: string; stderr: string; code: number }> {
  return syscall("shell.run", cmd, args);
}


================================================
FILE: plug-api/syscalls/space.ts
================================================
import { syscall } from "../syscall.ts";

import type {
  DocumentMeta,
  FileMeta,
  PageMeta,
} from "../../plug-api/types/index.ts";
import type { Ref } from "@silverbulletmd/silverbullet/lib/ref";

/**
 * Exposes the space with its pages, documents and plugs.
 * @module
 */

/**
 * Lists all pages (files ending in .md) in the space.
 * @param unfiltered
 * @returns a list of all pages in the space represented as PageMeta objects
 */
export function listPages(): Promise<PageMeta[]> {
  return syscall("space.listPages");
}

/**
 * Get metadata for a page in the space.
 * @param name the name of the page to get metadata for
 * @returns the metadata for the page
 */
export function getPageMeta(name: string): Promise<PageMeta> {
  return syscall("space.getPageMeta", name);
}

/**
 * Check if a page exists in the space.
 * @param name the name of the page to check
 * @returns true if the page exists, false otherwise
 */
export function pageExists(name: string): Promise<boolean> {
  return syscall("space.pageExists", name);
}

/**
 * Read a page from the space as text.
 * @param name the name of the page to read
 * @returns the text of the page
 */
export function readPage(
  name: string,
): Promise<string> {
  return syscall("space.readPage", name);
}

/**
 * Read a page from the space returning both its text and meta data
 * @param name the name of the page to read
 * @returns
 *  - text: page text as a string
 *  - meta: pageMeta
 */
export function readPageWithMeta(
  name: string,
): Promise<{ text: string; meta: PageMeta }> {
  return syscall("space.readPageWithMeta", name);
}

/**
 * Write a page to the space.
 * @param name the name of the page to write
 * @param text the text of the page to write
 * @returns the metadata for the written page
 */
export function writePage(name: string, text: string): Promise<PageMeta> {
  return syscall("space.writePage", name, text);
}

/**
 * Delete a page from the space.
 * @param name the name of the page to delete
 */
export function deletePage(name: string): Promise<void> {
  return syscall("space.deletePage", name);
}

/**
 * List all plugs in the space.
 * @returns a list of all plugs in the space represented as FileMeta objects
 */
export function listPlugs(): Promise<FileMeta[]> {
  return syscall("space.listPlugs");
}

/**
 * Lists all documents in the space (all files not ending in .md).
 * @returns a list of all documents in the space represented as DocumentMeta objects
 */
export function listDocuments(): Promise<DocumentMeta[]> {
  return syscall("space.listDocuments");
}

/**
 * Get metadata for an document in the space.
 * @param name the path of the document to get metadata for
 * @returns the metadata for the document
 */
export function getDocumentMeta(name: string): Promise<DocumentMeta> {
  return syscall("space.getDocumentMeta", name);
}

/**
 * Read an document from the space
 * @param name path of the document to read
 * @returns the document data as a UInt8Array
 */
export function readDocument(
  name: string,
): Promise<Uint8Array> {
  return syscall("space.readDocument", name);
}

/**
 * Writes a document to the space
 * @param name path of the document to write
 * @param data data itself
 * @returns
 */
export function writeDocument(
  name: string,
  data: Uint8Array,
): Promise<DocumentMeta> {
  return syscall("space.writeDocument", name, data);
}

/**
 * Deletes a document from the space
 * @param name path of the document to delete
 */
export function deleteDocument(name: string): Promise<void> {
  return syscall("space.deleteDocument", name);
}

// Lower level-file operations

/**
 * List all files in the space (pages, documents and plugs).
 * @returns a list of all files in the space represented as FileMeta objects
 */
export function listFiles(): Promise<FileMeta[]> {
  return syscall("space.listFiles");
}

/**
 * Read a file from the space as a Uint8Array.
 * @param name the name of the file to read
 * @returns the data of the file
 */
export function readFile(name: string): Promise<Uint8Array> {
  return syscall("space.readFile", name);
}

/**
 * Reads a reference (e.g. page#header or page@20) and returns it as a string
 */
export function readRef(ref: string | Ref): Promise<string> {
  return syscall("space.readRef", ref);
}

/**
 * Read a file from the space returning both its data and meta data
 * @param name the name of the page to read
 * @returns
 *  - data: file content
 *  - meta: pageMeta
 */
export function readFileWithMeta(
  name: string,
): Promise<{ data: Uint8Array; meta: FileMeta }> {
  return syscall("space.readFileWithMeta", name);
}

/**
 * Get metadata for a file in the space.
 * @param name the name of the file to get metadata for
 * @returns the metadata for the file
 */
export function getFileMeta(name: string): Promise<FileMeta> {
  return syscall("space.getFileMeta", name);
}

/**
 * Write a file to the space.
 * @param name the name of the file to write
 * @param data the data of the file to write
 * @returns the metadata for the written file
 */
export function writeFile(
  name: string,
  data: Uint8Array,
): Promise<FileMeta> {
  return syscall("space.writeFile", name, data);
}

/**
 * Delete a file from the space.
 * @param name the name of the file to delete
 */
export function deleteFile(name: string): Promise<void> {
  return syscall("space.deleteFile", name);
}

export function fileExists(name: string): Promise<boolean> {
  return syscall("space.fileExists", name);
}


================================================
FILE: plug-api/syscalls/sync.ts
================================================
import { syscall } from "../syscall.ts";

/**
 * Syscalls that interact with the sync engine (when the client runs in Sync mode)
 * @module
 */

/**
 * Checks if an initial sync has completed
 */
export function hasInitialSyncCompleted(): Promise<boolean> {
  return syscall("sync.hasInitialSyncCompleted");
}

/**
 * Syncs a file immediately. Sync would happen automatically, but this prioritizes the file.
 * @param path the path to the file to sync
 */
export function performFileSync(path: string): Promise<void> {
  return syscall("sync.performFileSync", path);
}

/**
 * Performs an immediate full sync.
 */
export function performSpaceSync(): Promise<number> {
  return syscall("sync.performSpaceSync");
}


================================================
FILE: plug-api/syscalls/system.ts
================================================
import { syscall } from "../syscall.ts";
import type { CommandDef } from "../types/manifest.ts";
import type { SyscallMeta } from "@silverbulletmd/silverbullet/type/index";

/**
 * System level syscalls
 * @module
 */

/**
 * Invoke a plug function
 * @param name a string representing the name of the function to invoke ("plug.functionName")
 * @param args arguments to pass to the function
 * @returns
 */
export function invokeFunction(
  name: string,
  ...args: any[]
): Promise<any> {
  return syscall("system.invokeFunction", name, ...args);
}

/**
 * Invoke a client command by name
 * Note: only available on the client
 * @param name name of the command
 * @param args arguments to pass to the command
 */
export function invokeCommand(name: string, args?: string[]): Promise<any> {
  return syscall("system.invokeCommand", name, args);
}

/**
 * Lists all commands available
 * @returns a map of all available commands
 */
export function listCommands(): Promise<Record<string, CommandDef>> {
  return syscall("system.listCommands");
}

/**
 * Lists all syscalls available
 * @returns a list of all available syscalls
 */
export function listSyscalls(): Promise<SyscallMeta[]> {
  return syscall("system.listSyscalls");
}

/**
 * Trigger a reload of all plugs
 * @returns
 */
export function reloadPlugs(): Promise<void> {
  return syscall("system.reloadPlugs");
}

/**
 * Returns the current mode of the system, either "ro" (read-only) or "rw" (read-write)
 */
export function getMode(): Promise<"ro" | "rw"> {
  return syscall("system.getMode");
}

/**
 * Returns the prefix set by SB_URL_PREFIX or "/" if the variable isn't set
 */
export function getURLPrefix(): Promise<string> {
  return syscall("system.getURLPrefix");
}

/**
 * Returns the base URI for this SilverBullet isntance
 */
export function getBaseURI(): Promise<string> {
  return syscall("system.getBaseURI");
}

/**
 * Returns the SilverBullet version
 */
export function getVersion(): Promise<string> {
  return syscall("system.getVersion");
}

export function getConfig<T = any>(
  key: string,
  defaultValue: any = undefined,
): Promise<T> {
  return syscall("system.getConfig", key, defaultValue);
}

export function wipeClient(logout = false): Promise<void> {
  return syscall("system.wipeClient", logout);
}

/**
 * DEPRECATED
 * Deletes all IndexedDB databases that are not connected to client (e.g. legacy databases)
 */
export function cleanDatabases(): Promise<boolean> {
  return syscall("system.cleanDatabases");
}


================================================
FILE: plug-api/syscalls/yaml.ts
================================================
import { syscall } from "../syscall.ts";

/**
 * YAML operations, exposed via `index` plug
 * @module
 */

/**
 * Parses a YAML string into a JavaScript object.
 * @param text the YAML text to parse
 * @returns a JavaScript object representation of the YAML text
 */
export function parse(
  text: string,
): Promise<any> {
  return syscall("yaml.parse", text);
}

/**
 * Converts a JavaScript object into a YAML string.
 * @param obj the object to stringify
 * @returns a YAML string representation of the object
 */
export function stringify(
  obj: any,
): Promise<string> {
  return syscall("yaml.stringify", obj);
}


================================================
FILE: plug-api/system_mock.ts
================================================
import { Config } from "../client/config.ts";
import { DataStore } from "../client/data/datastore.ts";
import { MemoryKvPrimitives } from "../client/data/memory_kv_primitives.ts";
import { DataStoreMQ } from "../client/data/mq.datastore.ts";
import { ObjectIndex } from "../client/data/object_index.ts";
import { EventHook } from "../client/plugos/hooks/event.ts";
import {
  dataStoreReadSyscalls,
  dataStoreWriteSyscalls,
} from "../client/plugos/syscalls/datastore.ts";
import { eventSyscalls } from "../client/plugos/syscalls/event.ts";
import { indexSyscalls } from "../client/plugos/syscalls/index.ts";
import { jsonschemaSyscalls } from "../client/plugos/syscalls/jsonschema.ts";
import { languageSyscalls } from "../client/plugos/syscalls/language.ts";
import { luaSyscalls } from "../client/plugos/syscalls/lua.ts";
import { markdownSyscalls } from "../client/plugos/syscalls/markdown.ts";
import { mqSyscalls } from "../client/plugos/syscalls/mq.ts";
import {
  spaceReadSyscalls,
  spaceWriteSyscalls,
} from "../client/plugos/syscalls/space.ts";
import { systemSyscalls } from "../client/plugos/syscalls/system.ts";
import { System } from "../client/plugos/system.ts";
import { Space } from "../client/space.ts";
import { SpaceLuaEnvironment } from "../client/space_lua.ts";
import { DataStoreSpacePrimitives } from "../client/spaces/datastore_space_primitives.ts";
import { EventedSpacePrimitives } from "../client/spaces/evented_space_primitives.ts";

export function createMockSystem() {
  const system = new System<any>();
  const eventHook = new EventHook();
  system.addHook(eventHook);
  const kv = new MemoryKvPrimitives();
  const ds = new DataStore(kv);
  const spacePrimitives = new EventedSpacePrimitives(
    new DataStoreSpacePrimitives(kv),
    eventHook,
    ds,
  );

  const space = new Space(spacePrimitives, eventHook);
  const mq = new DataStoreMQ(ds, eventHook);
  const config = new Config();
  const clientMock: any = {
    space,
    config,
    eventedSpacePrimitives: spacePrimitives,
  };

  const objectIndex = new ObjectIndex(ds, config, eventHook, mq);

  const clientSystemMock: any = {
    system: system,
    spaceLuaEnv: new SpaceLuaEnvironment(system, objectIndex),
  };
  clientMock.clientSystem = clientSystemMock;

  system.registerSyscalls(
    [],
    eventSyscalls(eventHook, clientMock),
    spaceReadSyscalls(clientMock),
    spaceWriteSyscalls(clientMock),
    markdownSyscalls(clientMock),
    languageSyscalls(),
    jsonschemaSyscalls(),
    indexSyscalls(objectIndex, clientMock),
    luaSyscalls(clientSystemMock),
    mqSyscalls(mq),
    dataStoreReadSyscalls(ds, clientSystemMock),
    dataStoreWriteSyscalls(ds),
    systemSyscalls(clientMock, false),
  );

  // @ts-ignore: global
  globalThis.syscall = (name: string, ...args: any): Promise<any> => {
    return system.localSyscall(name, args);
  };

  return {
    system,
    eventHook,
    config,
    kv,
    spacePrimitives,
    mq,
    ds,
    space,
  };
}


================================================
FILE: plug-api/types/client.ts
================================================
import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";

export type FilterOption = {
  name: string;
  description?: string;
  orderId?: number;
  hint?: string;
  hintInactive?: boolean;
  classes?: string;
  prefix?: string;
} & Record<string, any>;

export type Notification = {
  id: number;
  message: string;
  type: "info" | "error";
  date: Date;
};

export type PanelMode = number;

export type CodeWidgetContent = {
  html?: string;
  script?: string;
  width?: number;
  height?: number;
  url?: string;
};

// Document editors stuff
export type DocumentEditorCallback = () => Promise<DocumentEditorContent>;
export type DocumentEditorContent = {
  html: string;
};

export type LintDiagnostic = {
  from: number;
  to: number;
  severity: "error" | "warning" | "info" | "hint";
  message: string;
};

export type UploadFile = {
  name: string;
  contentType: string;
  content: Uint8Array;
};

export type AppEvent =
  | "page:click"
  | "slash:complete"
  | "editor:complete"
  | "editor:lint"
  | "editor:init"
  | "editor:pageLoaded" // args: pageName, previousPage, isSynced
  | "editor:pageReloaded"
  | "editor:pageSaving"
  | "editor:pageSaved"
  | "editor:pageCreating"
  | "editor:pageModified"
  | "editor:documentSaving"
  | "editor:documentSaved"
  | "editor:modeswitch"
  | "editor:fold"
  | "editor:unfold"
  | "plugs:loaded"
  | "cron:secondPassed"
  | "hooks:renderTopWidgets"
  | "hooks:renderBottomWidgets";

export type ClickEvent = {
  page: string;
  pos: number;
  metaKey: boolean;
  ctrlKey: boolean;
  altKey: boolean;
};

export type EnrichedClickEvent = ClickEvent & {
  parentNodes: string[];
};

export type LintEvent = {
  name: string;
  pageMeta: PageMeta;
  tree: ParseTree;
  text: string;
};

export type CompleteEvent = {
  pageName: string;
  linePrefix: string;
  pos: number;
  parentNodes: string[];
};

export type SlashCompletionOption = {
  label: string;
  detail?: string;
  invoke: string;
  order?: number;
} & Record<string, any>;

export type SlashCompletions = {
  // Ignore this one, only for compatibility with regular completions
  from?: number;
  // The actual completions
  options: SlashCompletionOption[];
};

// Code widget stuff
export type CodeWidgetCallback = (
  bodyText: string,
  pageName: string,
) => Promise<CodeWidgetContent | null>;


================================================
FILE: plug-api/types/config.ts
================================================
export type EmojiConfig = {
  aliases: string[][];
};
export type QueryCollationConfig = {
  enabled?: boolean;
  locale?: string;
  options?: object;
};

type vimMode = "normal" | "insert" | "visual";

export type VimConfig = {
  unmap?: (string | { key: string; mode?: vimMode })[];
  map?: { map: string; to: string; mode?: vimMode }[];
  noremap?: { map: string; to: string; mode?: vimMode }[];
  commands?: { ex: string; command: string }[];
};
export type SmartQuotesConfig = {
  enabled?: boolean;
  double?: { left?: string; right?: string };
  single?: { left?: string; right?: string };
};


================================================
FILE: plug-api/types/datastore.ts
================================================
// Message Queue related types
export type MQMessage = {
  id: string;
  queue: string;
  body: any;
  retries?: number;
};
export type MQStats = {
  queued: number;
  processing: number;
  dlq: number;
};
export type MQSubscribeOptions = {
  batchSize?: number;
  pollInterval?: number;
};

// KV types
export type KvKey = string[];

export type KV<T = any> = {
  key: KvKey;
  value: T;
};

export type KvQuery = {
  prefix?: KvKey;
};


================================================
FILE: plug-api/types/event.ts
================================================
import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree";
import type { EventSubscriptionDef } from "./manifest.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";

export type IndexTreeEvent = {
  name: string;
  tree: ParseTree;
  meta: PageMeta;
  text: string;
};

export type PageCreatingEvent = {
  name: string;
};

export type PageCreatingContent = {
  text: string;
  perm: "ro" | "rw";
};
export type EventSubscription = EventSubscriptionDef & {
  run: (...args: any[]) => Promise<any>;
};

export type ResolvedPlug = {
  code: string;
  name?: string; // This will only dictate the filename
} | string;


================================================
FILE: plug-api/types/index.ts
================================================
export type FileMeta = {
  name: string;
  created: number;
  lastModified: number;
  contentType: string;
  size: number;
  perm: "ro" | "rw";
};
/**
 * Decorates a page when it matches certain criteria
 */
export type PageDecoration = {
  prefix?: string;
  cssClasses?: string[];
  hide?: boolean;
  renderWidgets?: boolean; // Defaults to true
};
export type PageMeta = ObjectValue<
  {
    name: string;
    created: string; // indexing it as a string
    lastModified: string; // indexing it as a string
    perm: "ro" | "rw";
    lastOpened?: number;
    pageDecoration?: PageDecoration;
  } & Record<string, any>
>;
export type DocumentMeta = ObjectValue<
  {
    name: string;
    contentType: string;
    created: string;
    lastModified: string;
    size: number;
    perm: "ro" | "rw";
    extension: string;
  } & Record<string, any>
>;
export type SyscallMeta = {
  name: string;
  requiredPermissions: string[];
  argCount: number;
};
/**
 * An ObjectValue that can be indexed by the `index` plug, needs to have a minimum of
 * of two fields:
 * - ref: a unique reference (id) for the object, ideally a page reference
 * - tags: a list of tags that the object belongs to
 */
export type ObjectValue<T = any> = {
  ref: string;
  tag: string; // main tag
  range?: [number, number];
  tags?: string[];
  itags?: string[]; // implicit or inherited tags (inherited from the page for instance)
} & T;


================================================
FILE: plug-api/types/manifest.ts
================================================
import type { Manifest as PlugosManifest } from "../../client/plugos/types.ts";
import type { NamespaceOperation } from "./namespace.ts";

export type CodeWidgetT = {
  codeWidget?: string;
  renderMode?: "iframe";
};

export type CommandDef = {
  name: string;

  contexts?: string[];

  // Default 0, higher is higher priority = higher in the list
  priority?: number;

  // Bind to keyboard shortcut
  key?: string | string[];
  mac?: string | string[];

  hide?: boolean;
  requireMode?: "rw" | "ro";
  requireEditor?: "any" | "page" | "notpage" | string;
};
export type CommandHookT = {
  command?: CommandDef;
};

export type EventHookT = {
  events?: string[];
};

export type EventSubscriptionDef = {
  name: string;
};

type MQSubscription = {
  queue: string;
  batchSize?: number;
  pollInterval?: number;
  autoAck?: boolean;
};
export type MQHookT = {
  mqSubscriptions?: MQSubscription[];
};

export type PlugNamespaceDef = {
  pattern: string;
  operation: NamespaceOperation;
};

export type PlugNamespaceHookT = {
  pageNamespace?: PlugNamespaceDef;
};

export type SlashCommandDef = {
  name: string;
  description?: string;
  priority?: number;
  // Parent AST nodes in which this slash command is available, defaults to everywhere
  onlyContexts?: string[];
  // Parent AST nodes in which this slash command is not available
  exceptContexts?: string[];
};
export type SlashCommandHookT = {
  slashCommand?: SlashCommandDef;
};

export type SyscallHookT = {
  syscall?: string;
};

export type DocumentEditorT = {
  editor?: string | string[];
};

/** Silverbullet hooks give plugs access to silverbullet core systems.
 *
 * Hooks are associated with typescript functions through a manifest file.
 * On various triggers (user enters a slash command, an HTTP endpoint is hit, user clicks, etc) the typescript function is called.
 *
 * related: plugos/ui_types.ts#FunctionDef
 */
export type SilverBulletHooks =
  & CommandHookT
  & SlashCommandHookT
  & MQHookT
  & EventHookT
  & CodeWidgetT
  & PlugNamespaceHookT
  & DocumentEditorT
  & SyscallHookT;

/** A plug manifest configures hooks, declares syntax extensions, and describes plug metadata.
 *
 * Typically the manifest file is in a plug's root directory, named `${plugName}.plug.yaml`.
 */
export type Manifest = PlugosManifest<SilverBulletHooks>;


================================================
FILE: plug-api/types/namespace.ts
================================================
export type NamespaceOperation =
  | "readFile"
  | "writeFile"
  | "listFiles"
  | "getFileMeta"
  | "deleteFile";


================================================
FILE: plugs/README.md
================================================
This folder contains the implementation of SilverBullet's built-in plugs — the plugs that ship with SB itself.

================================================
FILE: plugs/builtin_plugs.ts
================================================
// TODO: Figure out how to keep this up-to-date automatically
export const builtinPlugNames = [
  "core",
  "editor",
  "index",
  "sync",
  "plug-manager",
  "emoji",
  "image-viewer",
];

export const builtinPlugPaths = builtinPlugNames.map((name) =>
  `Library/Std/Plugs/${name}.plug.js`
);


================================================
FILE: plugs/editor/complete.ts
================================================
import { folderName } from "@silverbulletmd/silverbullet/lib/resolve";
import { index, language, lua } from "@silverbulletmd/silverbullet/syscalls";
import type {
  DocumentMeta,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import type { CompleteEvent } from "@silverbulletmd/silverbullet/type/client";

// Page completion
export async function pageComplete(completeEvent: CompleteEvent) {
  const isDocumentQuery = {
    objectVariable: "_",
    where: await lua.parseExpression(
      `not string.startsWith(_.name, "_") and not string.endsWith(_.name, ".plug.js")`,
    ),
  };
  const isMetaPageQuery = {
    objectVariable: "_",
    where: await lua.parseExpression(`table.find(_.tags, function(tag)
           return string.startsWith(tag, "meta")
          end)`),
  };
  const isntMetaPageQuery = {
    objectVariable: "_",
    where: await lua.parseExpression(`not table.find(_.tags, function(tag)
           return string.startsWith(tag, "meta")
          end)`),
  };
  // Try to match [[wikilink]]
  let isWikilink = true;
  // This negative lookbehind is to prevent matching query[[. This requires negative lookbehind, which generally supported now (it seems), in versions of iOS Safari 13.1 and later
  // https://caniuse.com/js-regexp-lookbehind
  let match = /(?<!query)\[\[([^\]@$#:\{}]*)$/.exec(completeEvent.linePrefix);
  if (!match) {
    // Try to match [markdown link]()
    match = /\[.*\]\(([^\]\)@$#:\{}]*)$/.exec(completeEvent.linePrefix);
    isWikilink = false;
  }
  if (!match) {
    return null;
  }

  const prefix = match[1];

  let allPages: (PageMeta | DocumentMeta)[] = [];

  if (prefix.startsWith("^")) {
    // A carrot prefix means we're looking for a meta page
    allPages = await index.queryLuaObjects<PageMeta>("page", isMetaPageQuery);
    // Let's prefix the names with a caret to make them match
    allPages = allPages.map((page) => ({
      ...page,
      name: "^" + page.name,
    }));
  } else {
    // This is the most common case, we're combining three types of completions here:
    allPages = (await Promise.all([
      // All non-meta pages
      index.queryLuaObjects<PageMeta>("page", isntMetaPageQuery),
      // All documents
      index.queryLuaObjects<DocumentMeta>("document", isDocumentQuery),
      // And all links to non-existing pages (to augment the existing ones)
      index.queryLuaObjects<string>(
        "aspiring-page",
        {
          select: { type: "Variable", name: "name", ctx: {} as any },
          distinct: true,
        },
      ).then((aspiringPages) =>
        // Rewrite them to PageMeta shaped objects
        aspiringPages.map((aspiringPage: string): PageMeta => ({
          ref: aspiringPage,
          tag: "page",
          tags: ["non-existing"], // Picked up later in completion
          name: aspiringPage,
          created: "",
          lastModified: "",
          perm: "rw",
        }))
      ),
    ])).flat();
  }

  // Don't complete hidden pages
  allPages = allPages.filter((page) => !(page.pageDecoration?.hide === true));

  const folder = folderName(completeEvent.pageName);

  return {
    from: completeEvent.pos - prefix.length,
    options: allPages.map((pageMeta) => {
      const completions: any[] = [];
      const namePrefix = (pageMeta as PageMeta).pageDecoration?.prefix || "";
      const cssClass = ((pageMeta as PageMeta).pageDecoration?.cssClasses || [])
        .join(" ").replaceAll(/[^a-zA-Z0-9-_ ]/g, "");

      if (isWikilink) {
        // A [[wikilink]]
        const linkAlias = pageMeta.linkName || pageMeta.displayName;
        if (linkAlias) {
          const decoratedName = namePrefix + linkAlias;
          let boost = new Date(pageMeta.lastModified).getTime();
          if (pageMeta._isAspiring) {
            boost = -Infinity;
          }
          completions.push({
            label: linkAlias,
            displayLabel: decoratedName,
            boost,
            apply: pageMeta.tag === "template"
              ? pageMeta.name
              : `${pageMeta.name}|${linkAlias}`,
            detail: pageMeta.linkName
              ? `linkName for: ${pageMeta.name}`
              : `displayName for: ${pageMeta.name}`,
            type: "page",
            cssClass,
          });
        }
        if (Array.isArray(pageMeta.aliases)) {
          for (const alias of pageMeta.aliases) {
            const decoratedName = namePrefix + alias;
            completions.push({
              label: "" + alias,
              displayLabel: decoratedName,
              boost: new Date(pageMeta.lastModified).getTime(),
              apply: pageMeta.tag === "template"
                ? pageMeta.name
                : `${pageMeta.name}|${alias}`,
              detail: `alias to: ${pageMeta.name}`,
              type: "page",
              cssClass,
            });
          }
        }
        const decoratedName = namePrefix + pageMeta.name;
        completions.push({
          label: pageMeta.name,
          displayLabel: decoratedName,
          boost: new Date(pageMeta.lastModified).getTime(),
          detail: pageMeta.tags?.includes("non-existing")
            ? "Linked but not created"
            : undefined,
          type: "page",
          cssClass,
        });
      } else {
        // A markdown link []()
        let labelText = pageMeta.name;
        let boost = new Date(pageMeta.lastModified).getTime();
        // Relative path if in the same folder or a subfolder
        if (folder.length > 0 && labelText.startsWith(folder)) {
          labelText = labelText.slice(folder.length + 1);
          boost = boost * 1.1;
        } else {
          // Absolute path otherwise
          labelText = "/" + labelText;
        }
        completions.push({
          label: labelText,
          displayLabel: namePrefix + labelText,
          boost: boost,
          apply: labelText.includes(" ") ? "<" + labelText + ">" : labelText,
          type: "page",
          cssClass,
        });
      }
      return completions;
    }).flat(),
  };
}

export async function languageComplete(completeEvent: CompleteEvent) {
  const languagePrefix = /^(?:```+|~~~+)(\w*)$/.exec(
    completeEvent.linePrefix,
  );
  if (!languagePrefix) {
    return null;
  }

  const allLanguages = await language.listLanguages();
  return {
    from: completeEvent.pos - languagePrefix[1].length,
    options: allLanguages.map(
      (lang) => ({
        label: lang,
        type: "language",
      }),
    ),
  };
}


================================================
FILE: plugs/editor/document.ts
================================================
import { editor, space } from "@silverbulletmd/silverbullet/syscalls";

export async function deleteDocument() {
  const documentName = await editor.getCurrentPath();
  if (
    !await editor.confirm(
      `Are you sure you would like to delete ${documentName}?`,
    )
  ) {
    return;
  }
  console.log("Navigating to index page");
  await editor.navigate("");
  console.log("Deleting document from space");
  await space.deleteDocument(documentName);
}


================================================
FILE: plugs/editor/editor.ts
================================================
import {
  clientStore,
  codeWidget,
  editor,
  index,
  system,
} from "@silverbulletmd/silverbullet/syscalls";
import type { FilterOption } from "@silverbulletmd/silverbullet/type/client";

// Run on "editor:init"
export async function setEditorMode() {
  // TODO: Remove at some point: temporary upgrade code
  const allSyscalls = await system.listSyscalls();
  // console.log("All syscalls", allSyscalls);
  const queryLuaObjects = allSyscalls.find((sc) =>
    sc.name === "index.queryLuaObjects"
  );

  // console.log(readPageWithMetaCall);

  if (!queryLuaObjects) {
    await editor.alert(
      "Client needs reloading to update the cache, required syscalls are not available in this version. This message may appear a few times. Reloading now.",
    );
    editor.reloadUI();
  }

  if (await clientStore.get("vimMode")) {
    await editor.setUiOption("vimMode", true);
  }
  // Only set the darkmode value if it was deliberatly set in the clientstore,
  // otherwise leave it so the client can choose depending on the system
  // settings
  const darkMode = await clientStore.get("darkMode");
  if (darkMode != null) {
    await editor.setUiOption("darkMode", darkMode);
  }
  const markdownSyntaxRendering = await clientStore.get(
    "markdownSyntaxRendering",
  );
  if (markdownSyntaxRendering != null) {
    await editor.setUiOption(
      "markdownSyntaxRendering",
      markdownSyntaxRendering,
    );
    await editor.rebuildEditorState();
  }
}

export function openCommandPalette() {
  return editor.openCommandPalette();
}

export async function openPageNavigator() {
  await editor.openPageNavigator("page");
}

export async function openMetaNavigator() {
  await editor.openPageNavigator("meta");
}

export async function openTagNavigator() {
  // Query all tags with a matching parent
  const allTags: FilterOption[] = (await index.queryLuaObjects<string>("tag", {
    select: { type: "Variable", name: "name", ctx: {} as any },
    distinct: true,
  })).map((name) => ({ name }));

  const selectedTag = await editor.filterBox(
    "Open",
    allTags,
    "Press <tt>enter</tt> to go to the tag page of the selected tag.",
    "Tag",
  );
  if (!selectedTag) {
    return;
  }
  await editor.navigate(`tag:${selectedTag.name}`);
}

export async function openDocumentNavigator() {
  await editor.openPageNavigator("document");
}

export async function openAllNavigator() {
  await editor.openPageNavigator("all");
}

export async function toggleDarkMode() {
  let darkMode = await editor.getUiOption("darkMode");
  darkMode = !darkMode;
  await clientStore.set("darkMode", darkMode);
  await editor.reloadUI();
}

export async function toggleMarkdownSyntaxRendering() {
  let renderingSyntax = await editor.getUiOption(
    "markdownSyntaxRendering",
  );
  renderingSyntax = !renderingSyntax;
  await clientStore.set("markdownSyntaxRendering", renderingSyntax);
  await editor.setUiOption("markdownSyntaxRendering", renderingSyntax);
  await editor.rebuildEditorState();
}

export async function centerCursorCommand() {
  const pos = await editor.getCursor();
  await editor.moveCursor(pos, true);
}

export async function moveToPosCommand() {
  const posString = await editor.prompt("Move to position:");
  if (!posString) {
    return;
  }
  const pos = +posString;
  await editor.moveCursor(pos, true); // showing the movement for better UX
}

export async function copyRefCommand() {
  const page = await editor.getCurrentPage();
  const pos = await editor.getCursor();
  await editor.copyToClipboard(`[[${page}@${pos}]]`);
  await editor.flashNotification("Ref copied to clipboard");
}

export async function copyLinkCommand() {
  const page = await editor.getCurrentPage();
  const pos = await editor.getCursor();
  await editor.copyToClipboard(`${await system.getBaseURI()}${page}@${pos}`);
  await editor.flashNotification("Link copied to clipboard");
}

export async function moveToLineCommand() {
  const lineString = await editor.prompt(
    "Move to line (and optionally column):",
  );
  if (!lineString) {
    return;
  }
  // Match sequence of digits at the start, optionally another sequence
  const numberRegex = /^(\d+)(?:[^\d]+(\d+))?/;
  const match = lineString.match(numberRegex);
  if (!match) {
    await editor.flashNotification(
      "Could not parse line number in prompt",
      "error",
    );
    return;
  }
  let column = 1;
  const line = parseInt(match[1]);
  if (match[2]) {
    column = parseInt(match[2]);
  }
  await editor.moveCursorToLine(line, column, true); // showing the movement for better UX
}

export async function customFlashMessage(_def: any, message: string) {
  await editor.flashNotification(message);
}

export async function reloadSystem() {
  await editor.save();
  await editor.reloadConfigAndCommands();
  await codeWidget.refreshAll();
  await editor.flashNotification("System and widgets reloaded!");
}

export function refreshAllWidgets() {
  codeWidget.refreshAll();
}

export async function findInPageCommand() {
  await editor.openSearchPanel();
  return false;
}

export function undoCommand() {
  return editor.undo();
}

export function redoCommand() {
  return editor.redo();
}

export function deleteLineCommand() {
  return editor.deleteLine();
}

export function newWindowCommand() {
  return editor.newWindow();
}


================================================
FILE: plugs/editor/help.ts
================================================
import { editor } from "@silverbulletmd/silverbullet/syscalls";
import { publicVersion } from "../../public_version.ts";

export async function versionCommand() {
  await editor.flashNotification(
    `You are currently running SilverBullet ${publicVersion}`,
  );
}


================================================
FILE: plugs/editor/navigate.ts
================================================
import { editor, markdown, space } from "@silverbulletmd/silverbullet/syscalls";
import {
  addParentPointers,
  findNodeOfType,
  findParentMatching,
  nodeAtPos,
  type ParseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import {
  isLocalURL,
  resolveMarkdownLink,
} from "@silverbulletmd/silverbullet/lib/resolve";
import { parseToRef } from "@silverbulletmd/silverbullet/lib/ref";
import { tagPrefix } from "../index/constants.ts";
import type { ClickEvent } from "@silverbulletmd/silverbullet/type/client";
import { extractHashtag } from "@silverbulletmd/silverbullet/lib/tags";

async function actionClickOrActionEnter(
  mdTree: ParseTree | null,
  inNewWindow = false,
) {
  if (!mdTree) {
    return;
  }
  const navigationNodeFinder = (t: ParseTree) =>
    [
      "WikiLink",
      "Link",
      "Image",
      "Autolink",
      "NakedURL",
      "Hashtag",
    ]
      .includes(
        t.type!,
      );
  if (!navigationNodeFinder(mdTree)) {
    mdTree = findParentMatching(mdTree, navigationNodeFinder);
    if (!mdTree) {
      return;
    }
  }
  const currentPage = await editor.getCurrentPage();
  switch (mdTree.type) {
    case "WikiLink": {
      const link = mdTree.children![1]!.children![0].text!;
      const currentPath = await editor.getCurrentPath();
      const ref = parseToRef(link);

      if (!ref) {
        return editor.flashNotification(
          `Couldn't navigate to ${link}, WikiLink is invalid`,
          "error",
        );
      }

      if (ref.path === "") {
        ref.path = currentPath;
      }

      // TODO: Navigate behind frontmatter?
      // This is an explicit navigate, move to the top
      if (!ref.details) {
        ref.details = {
          type: "position",
          pos: 0,
        };
      }
      return editor.navigate(ref, false, inNewWindow);
    }
    // https://example.org
    case "NakedURL":
      return editor.openUrl(mdTree.children![0].text!);
    // <https://example.org>
    case "Autolink": {
      const urlNode = findNodeOfType(mdTree, "URL");
      if (!urlNode) {
        return;
      }

      return editor.openUrl(urlNode.children![0].text!);
    }
    case "Image":
    case "Link": {
      const urlNode = findNodeOfType(mdTree, "URL");
      if (!urlNode) {
        return;
      }
      const url = urlNode.children![0].text!;
      if (url.length <= 1) {
        return editor.flashNotification("Empty link, ignoring", "error");
      }
      if (isLocalURL(url)) {
        const link = resolveMarkdownLink(currentPage, decodeURI(url));
        // Parse the ref explicitly to throw a nice error message
        const ref = parseToRef(link);

        if (!ref) {
          return editor.flashNotification(
            `Couldn't navigate to ${link}, Link is invalid`,
            "error",
          );
        }

        return editor.navigate(ref);
      } else {
        return editor.openUrl(url);
      }
    }
    case "Hashtag": {
      const hashtag = extractHashtag(mdTree.children![0].text!);
      await editor.navigate(
        `${tagPrefix}${hashtag}`,
        false,
        inNewWindow,
      );
      break;
    }
  }
}

export async function linkNavigate() {
  const mdTree = await markdown.parseMarkdown(await editor.getText());
  const newNode = nodeAtPos(mdTree, await editor.getCursor());
  addParentPointers(mdTree);
  await actionClickOrActionEnter(newNode);
}

export async function clickNavigate(event: ClickEvent) {
  // Navigate by default, don't navigate when Alt is held
  if (event.altKey) {
    return;
  }
  const mdTree = await markdown.parseMarkdown(await editor.getText());
  addParentPointers(mdTree);
  const newNode = nodeAtPos(mdTree, event.pos);
  await actionClickOrActionEnter(newNode, event.ctrlKey || event.metaKey);
}

export async function navigateCommand(cmdDef: any) {
  await navigateToPage(cmdDef, cmdDef.page);
}

export async function navigateToPage(_cmdDef: any, pageName: string) {
  const ref = parseToRef(pageName);
  if (!ref) {
    await editor.flashNotification(
      `Couldn't navigate to ${pageName}, page name is invalid`,
      "error",
    );
    return;
  }

  if (!ref?.details) {
    ref.details = {
      type: "position",
      pos: 0,
    };
  }

  await editor.navigate(ref);
}

export async function createPageUnderCursorCommand() {
  const mdTree = await markdown.parseMarkdown(await editor.getText());
  addParentPointers(mdTree);
  let newNode = nodeAtPos(mdTree, await editor.getCursor());
  if (!newNode) {
    await editor.flashNotification("No page link under cursor", "error");
    return;
  }
  newNode = findParentMatching(newNode, (n) => n.type === "WikiLink");
  if (!newNode) {
    await editor.flashNotification("No page link under cursor", "error");
    return;
  }
  const wikiLinkPage = findNodeOfType(newNode, "WikiLinkPage")!;
  const pageName = wikiLinkPage.children![0].text!;
  if (pageName) {
    if (await space.pageExists(pageName)) {
      await editor.flashNotification(
        "Page under cursor already exists",
        "error",
      );
    } else {
      await space.writePage(pageName, "");
      await editor.dispatch({});
      await editor.flashNotification(`Empty page ${pageName} created.`);
    }
  }
}

export async function navigateToURL(_cmdDef: any, url: string) {
  await editor.openUrl(url, false);
}

export async function navigateBack() {
  await editor.goHistory(-1);
}

export async function navigateForward() {
  await editor.goHistory(1);
}


================================================
FILE: plugs/editor/outline.ts
================================================
import { editor } from "@silverbulletmd/silverbullet/syscalls";

export async function moveItemUp() {
  const cursorPos = await editor.getCursor();
  const text = await editor.getText();

  try {
    let currentItemBounds: ReturnType<typeof determineItemBounds> | undefined;
    try {
      currentItemBounds = determineItemBounds(text, cursorPos);
    } catch {
      // If `determineItemBounds()` throws, that likely means the cursor is NOT in a bullet list,
      // so we fall back to `moveLineUp()`
      editor.moveLineUp();
      return;
    }
    let previousItemBounds: ReturnType<typeof determineItemBounds> | undefined;

    try {
      previousItemBounds = determineItemBounds(
        text,
        currentItemBounds.from - 1,
        currentItemBounds.indentLevel,
      );
      if (currentItemBounds.from === previousItemBounds.from) {
        throw new Error("Already at the top");
      }
    } catch {
      // Ok, top of the list, let's find the previous item at any other indent level and adapt
      previousItemBounds = determineItemBounds(
        text,
        currentItemBounds.from - 1,
      );
    }

    let newPreviousText = text.slice(
      previousItemBounds.from,
      previousItemBounds.to,
    );
    // If the current item is embedded inside the previous item, we need to strip it out
    if (
      currentItemBounds.from >= previousItemBounds.from &&
      currentItemBounds.to <= previousItemBounds.to
    ) {
      newPreviousText =
        text.slice(previousItemBounds.from, currentItemBounds.from) +
        text.slice(currentItemBounds.to, previousItemBounds.to);
    }

    const newText =
      ensureNewLine(text.slice(currentItemBounds.from, currentItemBounds.to)) +
      newPreviousText;
    const newCursorPos = (cursorPos - currentItemBounds.from) +
      previousItemBounds.from;

    // console.log("New replacement text", newText);

    await editor.dispatch({
      changes: [
        {
          from: Math.min(previousItemBounds.from, currentItemBounds.from),
          to: Math.max(currentItemBounds.to, previousItemBounds.to),
          insert: newText,
        },
      ],
      selection: {
        anchor: newCursorPos,
      },
    });
  } catch (e: any) {
    await editor.flashNotification(e.message, "error");
  }
}

export async function moveItemDown() {
  const cursorPos = await editor.getCursor();
  const text = await editor.getText();

  try {
    let currentItemBounds: ReturnType<typeof determineItemBounds> | undefined;
    try {
      currentItemBounds = determineItemBounds(text, cursorPos);
    } catch {
      // If `determineItemBounds()` throws, that likely means the cursor is NOT in a bullet list,
      // so we fall back to `moveLineDown()`
      editor.moveLineDown();
      return;
    }
    let nextItemBounds: ReturnType<typeof determineItemBounds> | undefined;
    try {
      nextItemBounds = determineItemBounds(
        text,
        currentItemBounds.to + 1,
        currentItemBounds.indentLevel,
      );

      if (currentItemBounds.from === nextItemBounds.from) {
        throw new Error("Already at the bottom");
      }
    } catch {
      nextItemBounds = determineItemBounds(
        text,
        currentItemBounds.to + 1,
        undefined,
        false,
      );
    }

    if (currentItemBounds.to === nextItemBounds.to) {
      throw new Error("Already at the bottom");
    }

    const nextItemText = ensureNewLine(
      text.slice(nextItemBounds.from, nextItemBounds.to),
    );
    // console.log("Next item text", nextItemText);
    const newText = nextItemText +
      text.slice(currentItemBounds.from, currentItemBounds.to);
    const newCursorPos = (cursorPos - currentItemBounds.from) +
      currentItemBounds.from + nextItemText.length;
    await editor.dispatch({
      changes: [
        {
          from: Math.min(nextItemBounds.from, currentItemBounds.from),
          to: Math.max(nextItemBounds.to, currentItemBounds.to),
          insert: newText,
        },
      ],
      selection: {
        anchor: newCursorPos,
      },
    });
  } catch (e: any) {
    await editor.flashNotification(e.message, "error");
  }
}

export async function indentItem() {
  const cursorPos = await editor.getCursor();
  const text = await editor.getText();

  try {
    const currentItemBounds = determineItemBounds(text, cursorPos);
    const itemText = text.slice(currentItemBounds.from, currentItemBounds.to);
    const newText = itemText.split("\n").map((line) =>
      line ? "  " + line : line
    ).join("\n");
    const preText = text.slice(currentItemBounds.from, cursorPos);
    const newCursorPos = cursorPos + preText.split("\n").length * 2;
    await editor.dispatch({
      changes: [
        {
          from: currentItemBounds.from,
          to: currentItemBounds.to,
          insert: newText,
        },
      ],
      selection: {
        anchor: newCursorPos,
      },
    });
  } catch (e: any) {
    await editor.flashNotification(e.message, "error");
  }
}

export async function outdentItem() {
  const cursorPos = await editor.getCursor();
  const text = await editor.getText();

  try {
    const currentItemBounds = determineItemBounds(text, cursorPos);
    const itemText = text.slice(currentItemBounds.from, currentItemBounds.to);
    if (!itemText.startsWith("  ")) {
      throw new Error("Cannot outdent further");
    }
    const newText = itemText.split("\n").map((line) =>
      line.startsWith("  ") ? line.substring(2) : line
    ).join("\n");
    const preText = text.slice(currentItemBounds.from, cursorPos);
    const newCursorPos = cursorPos - preText.split("\n").length * 2;
    await editor.dispatch({
      changes: [
        {
          from: currentItemBounds.from,
          to: currentItemBounds.to,
          insert: newText,
        },
      ],
      selection: {
        anchor: newCursorPos,
      },
    });
  } catch (e: any) {
    await editor.flashNotification(e.message, "error");
  }
}

export function ensureNewLine(s: string) {
  if (!s.endsWith("\n")) {
    return s + "\n";
  } else {
    return s;
  }
}

export function determineItemBounds(
  text: string,
  pos: number,
  minIndentLevel?: number,
  withChildren = true,
): { from: number; to: number; indentLevel: number } {
  // Find the start of the item marked with a bullet
  let currentItemStart = pos;
  let indentLevel = 0;
  while (true) {
    while (currentItemStart > 0 && text[currentItemStart - 1] !== "\n") {
      currentItemStart--;
    }
    // Check if the line is a bullet and determine the indent level
    indentLevel = 0;
    while (text[currentItemStart + indentLevel] === " ") {
      indentLevel++;
    }
    if (minIndentLevel !== undefined && indentLevel < minIndentLevel) {
      throw new Error("No item found at minimum indent level");
    }
    if (minIndentLevel !== undefined && indentLevel > minIndentLevel) {
      // Not at the desired indent level yet, let's go up another line
      currentItemStart--;
      if (currentItemStart <= 0) {
        // We've reached the top of the document, no bullet found
        throw new Error("No item found");
      }
      continue;
    }
    if (["-", "*"].includes(text[currentItemStart + indentLevel])) {
      // This is a bullet line, found it, let's break out of this loop
      break;
    } else {
      // Not a bullet line, let's go up another line
      currentItemStart--;
      if (currentItemStart <= 0) {
        // We've reached the top of the document, no bullet found
        throw new Error("No item found");
      }
    }
  }

  // Ok, so at this point we have determine the starting point of our item
  // Relevant variables are currentItemStart and indentLevel
  // Now let's find the end point
  let currentItemEnd = currentItemStart + 1;
  while (true) {
    // Let's traverse to the end of the line
    while (currentItemEnd < text.length && text[currentItemEnd - 1] !== "\n") {
      currentItemEnd++;
    }
    if (!withChildren) {
      // We're not interested in the children, so let's stop here
      break;
    }
    // Check the indent level of the next line
    let nextIndentLevel = 0;
    while (text[currentItemEnd + nextIndentLevel] === " ") {
      nextIndentLevel++;
    }
    if (nextIndentLevel <= indentLevel) {
      // This is a line indentend less than the current item, found it, let's break out of this loop
      break;
    } else {
      // Not a bullet line, let's go up another line
      currentItemEnd++;
      if (currentItemEnd >= text.length) {
        // End of the document, mark this as the end of the item
        currentItemEnd = text.length - 1;
        break;
      }
    }
  }
  return {
    from: currentItemStart,
    to: currentItemEnd,
    indentLevel,
  };
}

export async function foldCommand() {
  await editor.fold();
}

export async function unfoldCommand() {
  await editor.unfold();
}

export async function toggleFoldCommand() {
  await editor.toggleFold();
}

export async function foldAllCommand() {
  await editor.foldAll();
}

export async function unfoldAllCommand() {
  await editor.unfoldAll();
}


================================================
FILE: plugs/editor/page.ts
================================================
import { editor, space } from "@silverbulletmd/silverbullet/syscalls";
import { notFoundError } from "@silverbulletmd/silverbullet/constants";

export async function deletePage() {
  const pageName = await editor.getCurrentPage();
  if (
    !await editor.confirm(`Are you sure you would like to delete ${pageName}?`)
  ) {
    return;
  }
  // Query for last
  const recentlyOpenedPages = await editor.getRecentlyOpenedPages();
  const allPages = await space.listPages();
  const existingPageNames = new Set(allPages.map((p) => p.name));

  // Find the first recently opened page that still exists and is not the current page
  const firstRecentlyOpenedPage = recentlyOpenedPages.find(
    (page) => page.name !== pageName && existingPageNames.has(page.name),
  );
  await space.deletePage(pageName);
  console.log("Navigating to previous page");
  await editor.navigate(firstRecentlyOpenedPage?.name || "");
}

export async function copyPage(
  _def: any,
  sourcePage?: string,
  toName?: string,
) {
  const currentPage = await editor.getCurrentPage();
  const fromName = sourcePage || currentPage;
  const suggestedName = toName || fromName;

  const newName = await editor.prompt(`Copy to page:`, suggestedName);

  if (!newName) {
    return;
  }

  try {
    // This throws an error if the page does not exist, which we expect to be the case
    await space.getPageMeta(newName);
    // So when we get to this point, we error out
    throw new Error(
      `"${newName}" already exists, cannot copy to existing page.`,
    );
  } catch (e: any) {
    if (e.message === notFoundError.message) {
      // Expected not found error, so we can continue
    } else {
      await editor.flashNotification(e.message, "error");
      throw e;
    }
  }

  const text = await space.readPage(fromName);

  console.log("Writing new page to space");
  await space.writePage(newName, text);

  if (currentPage === fromName) {
    // If we're copying the current page, navigate there
    console.log("Navigating to new page");
    await editor.navigate(newName);
  } else {
    // Otherwise just notify of success
    await editor.flashNotification("Page copied successfully");
  }
}


================================================
FILE: plugs/editor/stats.ts
================================================
import { editor, space } from "@silverbulletmd/silverbullet/syscalls";

function countWords(str: string): number {
  const matches = str.match(/[\w\d\'-]+/gi);
  return matches ? matches.length : 0;
}

function readingTime(wordCount: number): number {
  // 225 is average word reading speed for adults
  return Math.ceil(wordCount / 225);
}

export async function statsCommand() {
  const text = await editor.getText();
  const allPages = await space.listPages();
  const wordCount = countWords(text);
  const time = readingTime(wordCount);
  await editor.flashNotification(
    `${text.length} characters; ${wordCount} words; ${time} minutes read; ${allPages.length} total pages in space.`,
  );
}


================================================
FILE: plugs/editor/system.ts
================================================
import {
  editor,
  markdown,
  system,
} from "@silverbulletmd/silverbullet/syscalls";

export async function parsePageCommand() {
  console.log(
    "AST",
    JSON.stringify(
      await markdown.parseMarkdown(await editor.getText()),
      null,
      2,
    ),
  );
}

export async function wipeClientCommand() {
  if (
    !await editor.confirm(
      "Are you sure you want to wipe the client? This will clean the local cache.",
    )
  ) {
    return;
  }
  await system.wipeClient();
}

export async function wipeAndLogoutCommand() {
  await system.wipeClient(true);
}

export async function reloadUICommand() {
  await editor.reloadUI();
}


================================================
FILE: plugs/editor/text.ts
================================================
import { editor } from "@silverbulletmd/silverbullet/syscalls";

export async function quoteSelection() {
  let text = await editor.getText();
  const selection = await editor.getSelection();
  let from = selection.from;
  while (from >= 0 && text[from] !== "\n") {
    from--;
  }
  from++;
  if (text[from] === ">" && text[from + 1] === " ") {
    // Already quoted, we have to unquote
    text = text.slice(from + 2, selection.to);
    text = text.replaceAll("\n> ", "\n");
  } else {
    text = text.slice(from, selection.to);
    text = `> ${text.replaceAll("\n", "\n> ")}`;
  }
  await editor.replaceRange(from, selection.to, text);
}

export async function listifySelection() {
  let text = await editor.getText();
  const selection = await editor.getSelection();

  //if very first of doc, just add a bullet and end
  if (selection.to == 0 && selection.from == 0) {
    await editor.insertAtCursor("* ");
    return;
  }

  let from = selection.from;
  if (text[from] == "\n") {
    //end of line, need to find previous line break
    from--;
  }
  while (from >= 0 && text[from] !== "\n") {
    from--;
  }
  from++;
  text = text.slice(from, selection.to);
  text = `* ${text.replaceAll(/\n(?!\n)/g, "\n* ")}`;
  await editor.replaceRange(from, selection.to, text);
}

export async function numberListifySelection() {
  let text = await editor.getText();
  const selection = await editor.getSelection();
  let from = selection.from;
  while (from >= 0 && text[from] !== "\n") {
    from--;
  }
  from++;
  text = text.slice(from, selection.to);
  let counter = 1;
  text = `1. ${
    text.replaceAll(/\n(?!\n)/g, () => {
      counter++;
      return `\n${counter}. `;
    })
  }`;
  await editor.replaceRange(from, selection.to, text);
}

export async function linkSelection() {
  const text = await editor.getText();
  const selection = await editor.getSelection();
  const textSelection = text.slice(selection.from, selection.to);
  let linkedText = `[]()`;
  let pos = 1;
  if (textSelection.length > 0) {
    try {
      new URL(textSelection);
      linkedText = `[](${textSelection})`;
    } catch {
      linkedText = `[${textSelection}]()`;
      pos = linkedText.length - 1;
    }
  }
  await editor.replaceRange(selection.from, selection.to, linkedText);
  await editor.moveCursor(selection.from + pos);
}

export function wrapSelection(cmdDef: any) {
  return insertMarker(cmdDef.wrapper);
}

async function insertMarker(marker: string) {
  const text = await editor.getText();
  const selection = await editor.getSelection();
  if (selection.from === selection.to) {
    // empty selection
    if (markerAt(selection.from)) {
      // Already there, skipping ahead
      await editor.moveCursor(selection.from + marker.length);
    } else {
      // Not there, inserting
      await editor.insertAtCursor(marker + marker);
      await editor.moveCursor(selection.from + marker.length);
    }
  } else {
    let from = selection.from;
    let to = selection.to;
    let hasMarker = markerAt(from);
    if (!markerAt(from)) {
      // Maybe just before the cursor? We'll accept that
      from = selection.from - marker.length;
      to = selection.to + marker.length;
      hasMarker = markerAt(from);
    }

    if (!hasMarker) {
      // Adding
      await editor.replaceRange(
        selection.from,
        selection.to,
        marker + text.slice(selection.from, selection.to) + marker,
      );
      await editor.setSelection(
        selection.from + marker.length,
        selection.to + marker.length,
      );
    } else {
      // Removing
      await editor.replaceRange(
        from,
        to,
        text.substring(from + marker.length, to - marker.length),
      );
      await editor.setSelection(from, to - marker.length * 2);
    }
  }

  function markerAt(pos: number) {
    for (let i = 0; i < marker.length; i++) {
      if (text[pos + i] !== marker[i]) {
        return false;
      }
    }
    return true;
  }
}


================================================
FILE: plugs/editor/upload.ts
================================================
import { editor, space, system } from "@silverbulletmd/silverbullet/syscalls";
import {
  defaultLinkStyle,
  maximumDocumentSize,
} from "@silverbulletmd/silverbullet/constants";
import { resolveMarkdownLink } from "@silverbulletmd/silverbullet/lib/resolve";
import {
  encodePageURI,
  isValidPath,
} from "@silverbulletmd/silverbullet/lib/ref";
import type { UploadFile } from "@silverbulletmd/silverbullet/type/client";

export async function saveFile(file: UploadFile) {
  const maxSize = await system.getConfig<number>(
    "maximumDocumentSize",
    maximumDocumentSize,
  );
  if (typeof maxSize !== "number") {
    await editor.flashNotification(
      "The setting 'maximumDocumentSize' must be a number",
      "error",
    );
  }
  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(
      await editor.getCurrentPath(),
      isValidPath(file.name)
        ? file.name
        : `file.${
          file.name.indexOf(".") !== -1 ? file.name.split(".").pop() : "txt"
        }`,
    ),
  );
  if (!finalFilePath || !isValidPath(finalFilePath)) {
    return;
  }

  await space.writeDocument(finalFilePath, file.content);

  if (await editor.getCurrentEditor() === "page") {
    const linkStyle = await system.getConfig(
      "defaultLinkStyle",
      defaultLinkStyle,
    );
    let documentMarkdown = "";
    if (linkStyle === "wikilink") {
      documentMarkdown = `[[${finalFilePath}]]`;
    } else {
      documentMarkdown = `[${finalFilePath}](${encodePageURI(finalFilePath)})`;
    }
    if (file.contentType.startsWith("image/")) {
      documentMarkdown = "!" + documentMarkdown;
    }
    editor.insertAtCursor(documentMarkdown);
  }
}

export async function uploadFile(_ctx: any, accept?: string, capture?: string) {
  const uploadFile = await editor.uploadFile(accept, capture);
  await saveFile(uploadFile);
}


================================================
FILE: plugs/editor/vim.ts
================================================
import { clientStore, editor } from "@silverbulletmd/silverbullet/syscalls";

export async function toggleVimMode() {
  let vimMode = await clientStore.get("vimMode");
  vimMode = !vimMode;
  await editor.setUiOption("vimMode", vimMode);
  await clientStore.set("vimMode", vimMode);
}

export async function loadVimConfig() {
  const vimMode = await editor.getUiOption("vimMode");
  if (!vimMode) {
    console.log("Not in vim mode");
    return;
  }
  try {
    await editor.save();
    await editor.reloadConfigAndCommands();
    await editor.configureVimMode();
  } catch (e: any) {
    await editor.flashNotification(e.message, "error");
  }
}


================================================
FILE: plugs/emoji/build.ts
================================================
// Generates emoji.json from emoji-data.txt
const emojiRe = /#\s([^\s]+)\s+E[^\s]+\s+(.+)$/;

const text = Deno.readTextFileSync("emoji-data.txt");
const lines = text.split("\n").filter((line) => !line.startsWith("#"));

const emojis: string[] = [];
for (const line of lines) {
  const match = emojiRe.exec(line);
  if (match) {
    const emoji = match[1];
    const name = match[2].toLowerCase().replaceAll(/\W+/g, "_");
    emojis.push(`${name} ${emoji}`);
  }
}

Deno.writeFileSync(
  "emoji.json",
  new TextEncoder().encode(JSON.stringify(emojis.join("|"))),
);


================================================
FILE: plugs/emoji/emoji.ts
================================================
import emojiBlob from "./emoji.json" with { type: "json" };
import { editor, system } from "@silverbulletmd/silverbullet/syscalls";

import type { EmojiConfig } from "../../plug-api/types/config.ts";
import type { CompleteEvent } from "@silverbulletmd/silverbullet/type/client";

let emojiConfig: EmojiConfig = { aliases: [] };

const emojis = emojiBlob.split("|").map((line) => line.split(" "));

export function emojiCompleter(
  { linePrefix, pos, parentNodes }: CompleteEvent,
) {
  updateConfig(); // no need to await, will be ready for completion by next keystrokes

  const match = /:([\w]+)$/.exec(linePrefix);
  if (!match) {
    return null;
  }

  // Check if we're not in a Lua directive or space-lua block
  if (
    parentNodes.find((node) =>
      node === "LuaDirective" || node.startsWith("FencedCode")
    )
  ) {
    return;
  }

  const [fullMatch, emojiName] = match;

  const filteredEmoji = [...emojiConfig.aliases, ...emojis].filter(
    ([shortcode]) => shortcode.includes(emojiName),
  );

  return {
    from: pos - fullMatch.length,
    filter: false,
    options: filteredEmoji.map(([shortcode, emoji]) => ({
      detail: shortcode,
      label: emoji,
      type: "emoji",
    })),
  };
}

let lastConfigUpdate = 0;

async function updateConfig() {
  // Update at most every 5 seconds
  if (Date.now() < lastConfigUpdate + 5000) return;
  lastConfigUpdate = Date.now();
  const config = await system.getConfig("emoji");
  if (!config) {
    return;
  }

  const errorMsg =
    "Emoji aliases in space config should be a map with entries 'name: 😀'";

  let aliasMap: Record<string, any> = {};
  if (config.aliases && typeof config.aliases !== "object") {
    await editor.flashNotification(errorMsg, "error");
  } else {
    aliasMap = config.aliases;
  }

  const aliases = [];
  const badAliases = [];
  for (const alias in aliasMap) {
    if (typeof aliasMap[alias] !== "string") {
      badAliases.push(alias);
      continue;
    }
    const emoji: string = aliasMap[alias].trim();
    // For detecting misconfiguration like 'smile: grinning_face' which wouldn't work.
    // Side effect: can't use for phrases like 'br: Best regards', but Slash Commands are meant for that
    if ([...emoji].find((c) => c.charCodeAt(0) <= 127)) {
      // ASCII characters somewhere in text
      badAliases.push(alias);
      continue;
    }

    aliases.push([alias, emoji]);
  }
  if (badAliases.length > 0) {
    await editor.flashNotification(
      errorMsg + `, need to fix: ${badAliases.join(",")}`,
      "error",
    );
  }

  emojiConfig = {
    aliases: aliases,
  };
}


================================================
FILE: plugs/image-viewer/viewer.ts
================================================
import { editor } from "@silverbulletmd/silverbullet/syscalls";

export function viewer(): { html: string } {
  return {
    html: `
<html>
<head>
<style>
html {
  --ui-button-background-color: #e1e1e1;
  --ui-button-text-color: black;
  --background-color: #fff;
}
html[data-theme="dark"] {
  --ui-button-background-color: #262626;
  --ui-button-text-color: white;
  --background-color: #111;
}
html, body {
  margin: 0;
  height: 100%;
}
#container {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  background-color: var(--background-color);
}
#image {
  width: 100vw;
}
#toolbar {
  position: absolute;
  bottom: 1rem;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  flex-direction: row;
  gap: 0.5rem;
}
.ui-button {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 2.5rem;
  height: 2.5rem;
  cursor: pointer;
  outline: none;
  padding: 5px;
  border: 1px solid rgb(108, 108, 108);
  background-color: var(--ui-button-background-color);
  border-radius: 5px;
  color: var(--ui-button-text-color);
}
</style>
<script type="module">
/**
* Panzoom 4.6.0 for panning and zooming elements using CSS transforms
* Copyright Timmy Willison and other contributors
* https://github.com/timmywil/panzoom/blob/main/MIT-License.txt
*/
((t,e)=>{"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Panzoom=e()})(this,function(){var a,X=function(){return(X=Object.assign||function(t){for(var e,n=1,o=arguments.length;n<o;n++)for(var r in e=arguments[n])Object.prototype.hasOwnProperty.call(e,r)&&(t[r]=e[r]);return t}).apply(this,arguments)},i=("undefined"!=typeof window&&(window.NodeList&&!NodeList.prototype.forEach&&(NodeList.prototype.forEach=Array.prototype.forEach),"function"!=typeof window.CustomEvent)&&(window.CustomEvent=function(t,e){e=e||{bubbles:!1,cancelable:!1,detail:null};var n=document.createEvent("CustomEvent");return n.initCustomEvent(t,e.bubbles,e.cancelable,e.detail),n}),"undefined"!=typeof document&&!!document.documentMode);var c=["webkit","moz","ms"],l={};function Y(t){if(l[t])return l[t];var e=a=a||document.createElement("div").style;if(t in e)return l[t]=t;for(var n=t[0].toUpperCase()+t.slice(1),o=c.length;o--;){var r="".concat(c[o]).concat(n);if(r in e)return l[t]=r}}function o(t,e){return parseFloat(e[Y(t)])||0}function s(t,e,n){void 0===n&&(n=window.getComputedStyle(t));t="border"===e?"Width":"";return{left:o("".concat(e,"Left").concat(t),n),right:o("".concat(e,"Right").concat(t),n),top:o("".concat(e,"Top").concat(t),n),bottom:o("".concat(e,"Bottom").concat(t),n)}}function C(t,e,n){t.style[Y(e)]=n}function N(t){var e=t.parentNode,n=window.getComputedStyle(t),o=window.getComputedStyle(e),r=t.getBoundingClientRect(),a=e.getBoundingClientRect();return{elem:{style:n,width:r.width,height:r.height,top:r.top,bottom:r.bottom,left:r.left,right:r.right,margin:s(t,"margin",n),border:s(t,"border",n)},parent:{style:o,width:a.width,height:a.height,top:a.top,bottom:a.bottom,left:a.left,right:a.right,padding:s(e,"padding",o),border:s(e,"border",o)}}}var T={down:"mousedown",move:"mousemove",up:"mouseup mouseleave"};function L(t,e,n,o){T[t].split(" ").forEach(function(t){e.addEventListener(t,n,o)})}function V(t,e,n){T[t].split(" ").forEach(function(t){e.removeEventListener(t,n)})}function G(t,e){for(var n=t.length;n--;)if(t[n].pointerId===e.pointerId)return n;return-1}function I(t,e){if(e.touches)for(var n=0,o=0,r=e.touches;o<r.length;o++){var a=r[o];a.pointerId=n++,I(t,a)}else-1<(n=G(t,e))&&t.splice(n,1),t.push(e)}function R(t){for(var e,n=(t=t.slice(0)).pop();e=t.pop();)n={clientX:(e.clientX-n.clientX)/2+n.clientX,clientY:(e.clientY-n.clientY)/2+n.clientY};return n}function W(t){var e;return t.length<2?0:(e=t[0],t=t[1],Math.sqrt(Math.pow(Math.abs(t.clientX-e.clientX),2)+Math.pow(Math.abs(t.clientY-e.clientY),2)))}"undefined"!=typeof window&&("function"==typeof window.PointerEvent?T={down:"pointerdown",move:"pointermove",up:"pointerup pointerleave pointercancel"}:"function"==typeof window.TouchEvent&&(T={down:"touchstart",move:"touchmove",up:"touchend touchcancel"}));var Z=/^http:[\w\.\/]+svg$/;var q={animate:!1,canvas:!1,cursor:"move",disablePan:!1,disableZoom:!1,disableXAxis:!1,disableYAxis:!1,duration:200,easing:"ease-in-out",exclude:[],excludeClass:"panzoom-exclude",handleStartEvent:function(t){t.preventDefault(),t.stopPropagation()},maxScale:4,minScale:.125,overflow:"hidden",panOnlyWhenZoomed:!1,pinchAndPan:!1,relative:!1,setTransform:function(t,e,n){var o=e.x,r=e.y,a=e.isSVG;C(t,"transform","scale(".concat(e.scale,") translate(").concat(o,"px, ").concat(r,"px)")),a&&i&&(e=window.getComputedStyle(t).getPropertyValue("transform"),t.setAttribute("transform",e))},startX:0,startY:0,startScale:1,step:.3,touchAction:"none"};function t(u,f){if(!u)throw new Error("Panzoom requires an element as an argument");if(1!==u.nodeType)throw new Error("Panzoom requires an element with a nodeType of 1");if(!(t=>{for(var e=t;e&&e.parentNode;){if(e.parentNode===document)return 1;e=e.parentNode instanceof ShadowRoot?e.parentNode.host:e.parentNode}})(u))throw new Error("Panzoom should be called on elements that have been attached to the DOM");f=X(X({},q),f);t=u;var t,l=Z.test(t.namespaceURI)&&"svg"!==t.nodeName.toLowerCase(),n=u.parentNode;n.style.overflow=f.overflow,n.style.userSelect="none",n.style.touchAction=f.touchAction,(f.canvas?n:u).style.cursor=f.cursor,u.style.userSelect="none",u.style.touchAction=f.touchAction,C(u,"transformOrigin","string"==typeof f.origin?f.origin:l?"0 0":"50% 50%");var r,a,i,c,s,d,m=0,h=0,v=1,p=!1;function g(t,e,n){n.silent||(n=new CustomEvent(t,{detail:e}),u.dispatchEvent(n))}function y(o,r,t){var a={x:m,y:h,scale:v,isSVG:l,originalEvent:t};return requestAnimationFrame(function(){var t,e,n;"boolean"==typeof r.animate&&(r.animate?(t=u,e=r,n=Y("transform"),C(t,"transition","".concat(n," ").concat(e.duration,"ms ").concat(e.easing))):C(u,"transition","none")),r.setTransform(u,a,r),g(o,a,r),g("panzoomchange",a,r)}),a}function w(t,e,n,o){var r,a,i,c,l,s,d,o=X(X({},f),o),p={x:m,y:h,opts:o};return!o.force&&(o.disablePan||o.panOnlyWhenZoomed&&v===o.startScale)||(t=parseFloat(t),e=parseFloat(e),o.disableXAxis||(p.x=(o.relative?m:0)+t),o.disableYAxis||(p.y=(o.relative?h:0)+e),o.contain&&(e=((r=(e=(t=N(u)).elem.width/v)*n)-e)/2,i=((a=(i=t.elem.height/v)*n)-i)/2,"inside"===o.contain?(c=(-t.elem.margin.left-t.parent.padding.left+e)/n,l=(t.parent.width-r-t.parent.padding.left-t.elem.margin.left-t.parent.border.left-t.parent.border.right+e)/n,p.x=Math.max(Math.min(p.x,l),c),s=(-t.elem.margin.top-t.parent.padding.top+i)/n,d=(t.parent.height-a-t.parent.padding.top-t.elem.margin.top-t.parent.border.top-t.parent.border.bottom+i)/n,p.y=Math.max(Math.min(p.y,d),s)):"outside"===o.contain&&(c=(-(r-t.parent.width)-t.parent.padding.left-t.parent.border.left-t.parent.border.right+e)/n,l=(e-t.parent.padding.left)/n,p.x=Math.max(Math.min(p.x,l),c),s=(-(a-t.parent.height)-t.parent.padding.top-t.parent.border.top-t.parent.border.bottom+i)/n,d=(i-t.parent.padding.top)/n,p.y=Math.max(Math.min(p.y,d),s))),o.roundPixels&&(p.x=Math.round(p.x),p.y=Math.round(p.y))),p}function b(t,e){var n,o,r,a,e=X(X({},f),e),i={scale:v,opts:e};return!e.force&&e.disableZoom||(n=f.minScale,o=f.maxScale,e.contain&&(a=(e=N(u)).elem.width/v,r=e.elem.height/v,1<a)&&1<r&&(a=(e.parent.width-e.parent.border.left-e.parent.border.right)/a,e=(e.parent.height-e.parent.border.top-e.parent.border.bottom)/r,"inside"===f.contain?o=Math.min(o,a,e):"outside"===f.contain&&(n=Math.max(n,a,e))),i.scale=Math.min(Math.max(t,n),o)),i}function x(t,e,n,o){t=w(t,e,v,n);return m!==t.x||h!==t.y?(m=t.x,h=t.y,y("panzoompan",t.opts,o)):{x:m,y:h,scale:v,isSVG:l,originalEvent:o}}function S(t,e,n){var o,r,e=b(t,e),a=e.opts;if(a.force||!a.disableZoom)return t=e.scale,e=m,o=h,a.focal&&(e=((r=a.focal).x/t-r.x/v+m*t)/t,o=(r.y/t-r.y/v+h*t)/t),r=w(e,o,t,{relative:!1,force:!0}),m=r.x,h=r.y,v=t,y("panzoomzoom",a,n)}function e(t,e){e=X(X(X({},f),{animate:!0}),e);return S(v*Math.exp((t?1:-1)*e.step),e)}function E(t,e,n,o){var r=N(u),a=r.parent.width-r.parent.padding.left-r.parent.padding.right-r.parent.border.left-r.parent.border.right,i=r.parent.height-r.parent.padding.top-r.parent.padding.bottom-r.parent.border.top-r.parent.border.bottom,c=e.clientX-r.parent.left-r.parent.padding.left-r.parent.border.left-r.elem.margin.left,e=e.clientY-r.parent.top-r.parent.padding.top-r.parent.border.top-r.elem.margin.top,r=(l||(c-=r.elem.width/v/2,e-=r.elem.height/v/2),{x:c/a*(a*t),y:e/i*(i*t)});return S(t,X(X({},n),{animate:!1,focal:r}),o)}S(f.startScale,{animate:!1,force:!0}),setTimeout(function(){x(f.startX,f.startY,{animate:!1,force:!0})});var M=[];function o(t){((t,e)=>{for(var n,o,r=t;null!=r;r=r.parentNode)if(n=r,o=e.excludeClass,1===n.nodeType&&-1<" ".concat((n.getAttribute("class")||"").trim()," ").indexOf(" ".concat(o," "))||-1<e.exclude.indexOf(r))return 1})(t.target,f)||(I(M,t),p=!0,f.handleStartEvent(t),g("panzoomstart",{x:r=m,y:a=h,scale:v,isSVG:l,originalEvent:t},f),t=R(M),i=t.clientX,c=t.clientY,s=v,d=W(M))}function A(t){var e,n,o;p&&void 0!==r&&void 0!==a&&void 0!==i&&void 0!==c&&(I(M,t),e=R(M),n=1<M.length,o=v,n&&(0===d&&(d=W(M)),E(o=b((W(M)-d)*f.step/80+s).scale,e,{animate:!1},t)),n&&!f.pinchAndPan||x(r+(e.clientX-i)/o,a+(e.clientY-c)/o,{animate:!1},t))}function P(t){1===M.length&&g("panzoomend",{x:m,y:h,scale:v,isSVG:l,originalEvent:t},f);var e=M;if(t.touches)for(;e.length;)e.pop();else{t=G(e,t);-1<t&&e.splice(t,1)}p&&(p=!1,r=a=i=c=void 0)}var O=!1;function z(){O||(O=!0,L("down",f.canvas?n:u,o),L("move",document,A,{passive:!0}),L("up",document,P,{passive:!0}))}return f.noBind||z(),{bind:z,destroy:function(){O=!1,V("down",f.canvas?n:u,o),V("move",document,A),V("up",document,P)},eventNames:T,getPan:function(){return{x:m,y:h}},getScale:function(){return v},getOptions:function(){var t,e=f,n={};for(t in e)e.hasOwnProperty(t)&&(n[t]=e[t]);return n},handleDown:o,handleMove:A,handleUp:P,pan:x,reset:function(t){var t=X(X(X({},f),{animate:!0,force:!0}),t),e=(v=b(t.startScale,t).scale,w(t.startX,t.startY,v,t));return m=e.x,h=e.y,y("panzoomreset",t)},resetStyle:function(){n.style.overflow="",n.style.userSelect="",n.style.touchAction="",n.style.cursor="",u.style.cursor="",u.style.userSelect="",u.style.touchAction="",C(u,"transformOrigin","")},setOptions:function(t){for(var e in t=void 0===t?{}:t)t.hasOwnProperty(e)&&(f[e]=t[e]);(t.hasOwnProperty("cursor")||t.hasOwnProperty("canvas"))&&(n.style.cursor=u.style.cursor="",(f.canvas?n:u).style.cursor=f.cursor),t.hasOwnProperty("overflow")&&(n.style.overflow=t.overflow),t.hasOwnProperty("touchAction")&&(n.style.touchAction=t.touchAction,u.style.touchAction=t.touchAction)},setStyle:function(t,e){return C(u,t,e)},zoom:S,zoomIn:function(t){return e(!0,t)},zoomOut:function(t){return e(!1,t)},zoomToPoint:E,zoomWithWheel:function(t,e){t.preventDefault();var e=X(X(X({},f),e),{animate:!1}),n=0===t.deltaY&&t.deltaX?t.deltaX:t.deltaY;return E(b(v*Math.exp((n<0?1:-1)*e.step/3),e).scale,t,e,t)}}}return t.defaultOptions=q,t});

let imageRotation = 0;
let info = undefined;

const image = document.getElementById("image");
const element = document.getElementById("panzoom");

const panzoom = Panzoom(element, {
  maxScale: 5,
  startScale: 0.5,
});

image.parentElement.addEventListener("wheel", panzoom.zoomWithWheel);

function openFile(data, extension) {
  imageRotation = 0;

  const FileToMime = {
    "jpg": "image/jpg",
    "jpeg": "image/jpg",
    "png": "image/png",
    "gif": "image/gif",
    "svg": "image/svg+xml",
    "webp": "image/webp",
    "avif": "image/avif",
    "apng": "image/apng"
  }

  const img = document.getElementById("image");

  img.src = URL.createObjectURL(
    new Blob([data], { type: FileToMime[extension] })
  );

  img.addEventListener("load", () => {
    info = {
      type: FileToMime[extension],
      size: data.length,
      width: img.naturalWidth,
      height: img.naturalHeight
    };
  });
}

globalThis.silverbullet.addEventListener("file-open", (event) => openFile(event.detail.data, event.detail.meta.extension));
globalThis.silverbullet.addEventListener("file-update", (event) => openFile(event.detail.data, event.detail.meta.extension));

function updateRotation() {
  image.style.transform = "rotate(" + imageRotation + "deg)"
}

document.getElementById("rotateLeft").addEventListener("click", () => {
  imageRotation -= 90;
  updateRotation();
});

document.getElementById("rotateRight").addEventListener("click", () => {
  imageRotation += 90;
  updateRotation();
});

document.getElementById("zoomIn").addEventListener("click", () => {
  panzoom.zoomIn();
});

document.getElementById("zoomOut").addEventListener("click", () => {
  panzoom.zoomOut();
});

globalThis.silverbullet.addEventListener("flash-info", () => {
  if (!info) return;

  globalThis.silverbullet.syscall("editor.flashNotification", \`Type: \${info.type}; Size: \${Math.round(info.size / 1000)}kB; Dimensions: \${info.width}x\${info.height}\`);
});
</script>
</head>
<body>
<div id="container">
  <div id="panzoom">
    <img id="image"></img>
  </div>

  <div id="toolbar">
    <button class="ui-button" id="rotateLeft">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="1 4 1 10 7 10"></polyline><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path></svg>
    </button>
    <button class="ui-button" id="rotateRight">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"></polyline><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"></path></svg>
    </button>
    <button class="ui-button" id="zoomOut">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line><line x1="8" y1="11" x2="14" y2="11"></line></svg>
    </button>
    <button class="ui-button" id="zoomIn">
      <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line><line x1="11" y1="8" x2="11" y2="14"></line><line x1="8" y1="11" x2="14" y2="11"></line></svg>
    </button>
  <div/>
</div>
</body>
</html>
`,
  };
}

export function showInfoMessage() {
  editor.sendMessage("flash-info");
}


================================================
FILE: plugs/index/api.ts
================================================
import { index, markdown } from "@silverbulletmd/silverbullet/syscalls";
import {
  extractFrontMatter as extractFrontmatterFromTree,
  type FrontMatter,
  type FrontMatterExtractOptions,
} from "./frontmatter.ts";
import {
  collectNodesOfType,
  renderToText,
} from "@silverbulletmd/silverbullet/lib/tree";
import { applyPatches, type YamlPatch } from "../../plug-api/lib/yaml.ts";
import type { LuaCollectionQuery } from "../../client/space_lua/query_collection.ts";
import type { ObjectValue } from "@silverbulletmd/silverbullet/type/index";

/*
 * Key namespace:
 * [indexKey, type, ...key, page] -> value
 * [pageKey, page, ...key] -> true // for fast page clearing
 * ["type", type] -> true // for fast type listing
 */

export async function extractFrontmatter(
  text: string,
  extractOptions: FrontMatterExtractOptions = {},
): Promise<{ frontmatter: FrontMatter; text: string }> {
  const tree = await markdown.parseMarkdown(text);
  const frontmatter = extractFrontmatterFromTree(tree, extractOptions);
  return { frontmatter, text: renderToText(tree) };
}

export async function patchFrontmatter(
  text: string,
  patches: YamlPatch[],
): Promise<string> {
  const tree = await markdown.parseMarkdown(text);
  const frontmatter = collectNodesOfType(tree, "FrontMatter");
  if (frontmatter.length === 0) {
    // No frontmatter found, create from patches
    const patchedFrontmatter = applyPatches("", patches).trim();
    if (patchedFrontmatter) {
      return "---\n" + patchedFrontmatter + "\n---\n" + text;
    } else {
      return text;
    }
  } else {
    // Existing frontmatter found, patch it
    const frontmatterText = renderToText(frontmatter[0].children![1]);
    const patchedFrontmatter = applyPatches(frontmatterText, patches).trim();

    if (patchedFrontmatter) {
      // Replace the frontmatter with the patched frontmatter in the original string
      return "---\n" + patchedFrontmatter + "\n---" +
        text.slice(frontmatter[0].to);
    } else {
      // Nothing left, let's just return the text content
      return text.slice(frontmatter[0].to! + 1); // +1 to skip the initial \n
    }
  }
}

// DEPRECATED: use index.queryLuaObjects directly
export function queryLuaObjects<T>(
  tag: string,
  query: LuaCollectionQuery,
  scopedVariables?: Record<string, any>,
): Promise<ObjectValue<T>[]> {
  return index.queryLuaObjects(tag, query, scopedVariables);
}

// DEPRECATED: use index.getObjectByRef directly
export function getObjectByRef<T>(
  page: string,
  tag: string,
  ref: string,
): Promise<ObjectValue<T> | undefined> {
  return index.getObjectByRef(page, tag, ref);
}


================================================
FILE: plugs/index/attribute.ts
================================================
import {
  findNodeOfType,
  type ParseTree,
  replaceNodesMatching,
  traverseTree,
} from "@silverbulletmd/silverbullet/lib/tree";

import { cleanupJSON } from "@silverbulletmd/silverbullet/lib/json";

import YAML from "js-yaml";

/**
 * Collects all attributes from a parse tree
 * @param tree to clean attributes from
 * @return
 */
export function collectAttributes(
  tree: ParseTree,
): Record<string, any> {
  const attributes: Record<string, any> = {};

  traverseTree(tree, (n) => {
    if (tree !== n && n.type === "ListItem") {
      // Find top-level only, no nested lists
      return true;
    }
    if (n.type === "Attribute") {
      const nameNode = findNodeOfType(n, "AttributeName");
      const valueNode = findNodeOfType(n, "AttributeValue");
      if (nameNode && valueNode) {
        const name = nameNode.children![0].text!;
        const val = valueNode.children![0].text!;
        try {
          attributes[name] = cleanupJSON(YAML.load(val));
        } catch (e: any) {
          console.error("Error parsing attribute value as YAML", val, e);
        }
      }
      return true;
    }
    return false;
  });

  return attributes;
}

/**
 * Cleans attributes from a tree (as a side effect)
 * @param tree to clean attributes from
 */
export function cleanAttributes(tree: ParseTree) {
  replaceNodesMatching(tree, (n) => {
    if (n.type === "Attribute") {
      return null;
    }
    return;
  });
}


================================================
FILE: plugs/index/cheap_yaml.test.ts
================================================
import { assertEquals } from "@std/assert";
import { determineTags } from "./cheap_yaml.ts";

Deno.test("cheap yaml", () => {
  assertEquals([], determineTags(""));
  assertEquals([], determineTags("hank: bla"));
  assertEquals(["template"], determineTags("tags: template"));
  assertEquals(["bla", "template"], determineTags("tags: bla,template"));
  assertEquals(["bla", "template"], determineTags("tags:\n- bla\n- template"));
  assertEquals(["bla", "template"], determineTags(`tags: "#bla,#template"`));
  assertEquals(["bla", "template"], determineTags(`tags: '#bla, #template'`));
  assertEquals(
    ["bla", "template"],
    determineTags(`tags:\n- "#bla"\n- template`),
  );
});


================================================
FILE: plugs/index/cheap_yaml.ts
================================================
const yamlKvRegex = /^\s*(\w+):\s*["']?([^'"]*)["']?$/;
const yamlListItemRegex = /^\s*-\s+["']?([^'"]+)["']?$/;

/**
 * Cheap YAML parser to determine tags (ugly, regex based but fast) and fault tolerant
 * @param yamlText
 * @returns
 */
export function determineTags(yamlText: string): string[] {
  const lines = yamlText.split("\n");
  let inTagsSection = false;
  const tags: string[] = [];
  for (const line of lines) {
    const yamlKv = yamlKvRegex.exec(line);
    if (yamlKv) {
      const [key, value] = yamlKv.slice(1);
      // Looking for a 'tags' key
      if (key === "tags") {
        inTagsSection = true;
        // 'template' there? Yay!
        if (value) {
          tags.push(
            ...value.split(/,\s*|\s+/).map((t) => t.replace(/^#/, "")),
          );
        }
      } else {
        inTagsSection = false;
      }
    }
    const yamlListem = yamlListItemRegex.exec(line);
    if (yamlListem && inTagsSection) {
      tags.push(yamlListem[1].replace(/^#/, ""));
    }
  }
  return tags;
}


================================================
FILE: plugs/index/command.ts
================================================
import { editor, index } from "@silverbulletmd/silverbullet/syscalls";

export async function reindexCommand() {
  await editor.flashNotification("Performing full page reindex...");
  await index.reindexSpace();
  await editor.flashNotification("Done with page index!");
}


================================================
FILE: plugs/index/complete.ts
================================================
import { config } from "@silverbulletmd/silverbullet/syscalls";
import type { CompleteEvent } from "@silverbulletmd/silverbullet/type/client";
import type { Completion } from "@codemirror/autocomplete";

export async function attributeCompletion(
  tags: string[],
): Promise<Completion[]> {
  const completions: Completion[] = [];

  for (const tag of tags) {
    const schema = await config.get<any>(["tags", tag, "schema"], null);
    if (!schema) {
      continue;
    }
    for (const [attr, val] of Object.entries(schema.properties)) {
      const def = val as any;

      if (def.readOnly) {
        continue;
      }

      completions.push({
        label: attr,
        type: `attribute`,
        detail: `for #${tag} (type: ${humanReadableSchemaType(def)})${
          def.description ? ": " + def.description : ""
        }`,
        apply: `${attr}: `,
      });
    }
  }

  return completions;
}

function humanReadableSchemaType(type: any): string {
  if (!type) {
    return "any";
  }
  if (type.type) {
    return type.type;
  }
  if (type.anyOf) {
    return type.anyOf.map(humanReadableSchemaType).join(" | ");
  }
  return "any";
}

/**
 * Task state completion
 */
export async function completeTaskState(completeEvent: CompleteEvent) {
  const taskMatch = /([\-\*]\s+\[)([^\[\]]+)$/.exec(
    completeEvent.linePrefix,
  );
  if (!taskMatch) {
    return null;
  }
  const allStates = Object.keys(await config.get("taskStates", {}));

  return {
    from: completeEvent.pos - taskMatch[2].length,
    options: allStates.map((state) => ({
      label: state,
    })),
  };
}


================================================
FILE: plugs/index/constants.ts
================================================
// This page is actually implemented in Library/Std/Tag.md
export const tagPrefix = "tag:";


================================================
FILE: plugs/index/data.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import { extractFrontMatter } from "./frontmatter.ts";
import { assertEquals } from "@std/assert";
import { indexData } from "./data.ts";

const testPage = `
Some test

\`\`\`#superduper
name: Pete
age: 100
\`\`\`

\`\`\`#superduper
name: Hank
age: 101
\`\`\`
`.trim();

Deno.test("Test indexers", async () => {
  createMockSystem();
  const tree = parseMarkdown(testPage);
  const frontmatter = extractFrontMatter(tree);

  const pageMeta: PageMeta = {
    ref: "test",
    name: "test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };

  const datas: ObjectValue<any>[] = await indexData(
    pageMeta,
    frontmatter,
    tree,
  );
  // 1 data 1 tag
  assertEquals(datas.length, 3);

  // data object first
  assertEquals(datas[0].tag, "superduper");
  assertEquals(datas[0].itags, ["superduper", "data"]);
  assertEquals(datas[0].name, "Pete");
  assertEquals(datas[0].age, 100);

  // data object first
  assertEquals(datas[1].tag, "superduper");
  assertEquals(datas[1].itags, ["superduper", "data"]);
  assertEquals(datas[1].name, "Hank");
  assertEquals(datas[1].age, 101);
});


================================================
FILE: plugs/index/data.ts
================================================
import YAML from "js-yaml";
import {
  collectNodesOfType,
  findNodeOfType,
  type ParseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import type { TagObject } from "./tags.ts";
import type { FrontMatter } from "./frontmatter.ts";
import { updateITags } from "./tags.ts";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";

type DataObject = ObjectValue<
  {
    pos: number;
    page: string;
  } & Record<string, any>
>;

export function indexData(
  pageMeta: PageMeta,
  frontmatter: FrontMatter,
  tree: ParseTree,
) {
  const separator = "---";
  const dataObjects: ObjectValue<DataObject>[] = [];
  const tagObjects: Map<string, ObjectValue<TagObject>> = new Map();

  collectNodesOfType(tree, "FencedCode").map((t) => {
    const codeInfoNode = findNodeOfType(t, "CodeInfo");
    if (!codeInfoNode) {
      return;
    }
    const fenceType = codeInfoNode.children![0].text!;
    if (fenceType !== "data" && !fenceType.startsWith("#")) {
      return;
    }
    const codeTextNode = findNodeOfType(t, "CodeText");
    if (!codeTextNode) {
      // Honestly, this shouldn't happen
      return;
    }
    const codeText = codeTextNode.children![0].text!;
    const dataType = fenceType === "data" ? "data" : fenceType.substring(1);
    try {
      const docs = codeText.split(separator);
      let cursor = 0;
      // We support multiple YAML documents in one block
      for (let i = 0; i < docs.length; i++) {
        const docStart = t.from! + cursor;
        const docEnd = docStart + docs[i].length;
        const doc = YAML.load(docs[i]);
        if (!doc) {
          cursor += docs[i].length;
          continue;
        }
        const dataObj = {
          ref: `${pageMeta.name}@${docStart}`,
          tag: dataType,
          itags: ["data"],
          pos: docStart,
          range: [docStart, docEnd],
          ...doc,
          page: pageMeta.name,
        };
        updateITags(dataObj, frontmatter);
        dataObjects.push(dataObj);
        cursor += docs[i].length;
      }
      tagObjects.set(dataType, {
        ref: dataType,
        tag: "tag",
        name: dataType,
        page: pageMeta.name,
        parent: "data",
      });
    } catch (e) {
      console.error("Could not parse data", codeText, "error:", e);
      return;
    }
  });

  return Promise.resolve([...dataObjects, ...tagObjects.values()]);
}


================================================
FILE: plugs/index/document.ts
================================================
import { index, space } from "@silverbulletmd/silverbullet/syscalls";
import type { DocumentMeta } from "@silverbulletmd/silverbullet/type/index";

const nonDocumentFileExtensions = [".md", ".plug.js", ".js.map"];

// Note: clearFileIndex is not called but since this is the only document:index listener, this should be fine (famous last words)
export async function indexDocument(name: string) {
  // Check if path doesn't end with a non-document file extension
  if (nonDocumentFileExtensions.find((ext) => name.endsWith(ext))) {
    return;
  }

  const fileMeta = await space.getDocumentMeta(name);
  await index.indexObjects<DocumentMeta>(fileMeta.name, [fileMeta]);
}


================================================
FILE: plugs/index/frontmatter.ts
================================================
import {
  addParentPointers,
  findNodeOfType,
  findParentMatching,
  type ParseTree,
  renderToText,
  replaceNodesMatching,
} from "@silverbulletmd/silverbullet/lib/tree";
import { cleanupJSON } from "@silverbulletmd/silverbullet/lib/json";
import YAML from "js-yaml";
import { extractHashtag } from "@silverbulletmd/silverbullet/lib/tags";
import type { CompleteEvent } from "@silverbulletmd/silverbullet/type/client";
import { determineTags } from "./cheap_yaml.ts";
import { attributeCompletion } from "./complete.ts";

export type FrontMatter = {
  tags?: string[];
  // Location in the document where the frontmatter appears
  range?: [number, number];
} & Record<string, any>;

export type FrontMatterExtractOptions = {
  removeKeys?: string[];
  removeTags?: string[] | true;
  removeFrontMatterSection?: boolean;
};

/**
 * Extracts frontmatter from a markdown tree, as well as extracting tags and attributes that are to apply to the page
 * optionally removes certain keys from the front matter
 * Side effect: adds parent pointers to tree
 */
export function extractFrontMatter(
  tree: ParseTree,
  options: FrontMatterExtractOptions = {},
): FrontMatter {
  addParentPointers(tree);
  let frontmatter: FrontMatter = {
    tags: [],
  };
  const tags: string[] = [];

  replaceNodesMatching(tree, (t) => {
    // Find tags in paragraphs directly nested under the document where the only content is tags
    if (t.type === "Paragraph" && t.parent?.type === "Document") {
      let onlyTags = true;
      const collectedTags = new Set<string>();
      for (const child of t.children!) {
        if (child.text) {
          if (child.text.startsWith("\n") && child.text !== "\n") {
            // Multi line paragraph, cut it off here
            break;
          }
          if (child.text.trim()) {
            // Text node with actual text (not just whitespace): not a page tag line!
            onlyTags = false;
            break;
          }
        } else if (child.type === "Hashtag") {
          const tagname = extractHashtag(child.children![0].text!);
          collectedTags.add(tagname);

          if (
            options.removeTags === true || options.removeTags?.includes(tagname)
          ) {
            // Ugly hack to remove the hashtag
            child.children![0].text = "";
          }
        } else if (child.type) {
          // Found something else than tags, so... nope!
          onlyTags = false;
          break;
        }
      }
      if (onlyTags) {
        tags.push(...collectedTags);
      }
    }
    // Find FrontMatter and parse it
    if (t.type === "FrontMatter") {
      const yamlNode = t.children![1].children![0];
      const yamlText = renderToText(yamlNode);
      frontmatter.range = [t.from!, t.to!];
      try {
        const parsedData: any = cleanupJSON(YAML.load(yamlText));
        // console.log("Parsed front matter", parsedData);
        const newData = { ...parsedData };
        frontmatter = { ...frontmatter, ...parsedData };
        // Make sure we have a tags array
        if (!frontmatter.tags) {
          frontmatter.tags = [];
        }
        // Normalize tags to an array
        // support "tag1, tag2" as well as "tag1 tag2" as well as "#tag1 #tag2" notations
        if (typeof frontmatter.tags === "string") {
          tags.push(...(frontmatter.tags as string).split(/,\s*|\s+/));
        }
        if (Array.isArray(frontmatter.tags)) {
          tags.push(...frontmatter.tags);
        }

        if (options.removeKeys && options.removeKeys.length > 0) {
          let removedOne = false;

          for (const key of options.removeKeys) {
            if (key in newData) {
              delete newData[key];
              removedOne = true;
            }
          }
          if (removedOne) {
            yamlNode.text = YAML.stringify(newData);
          }
        }
        // If nothing is left, let's just delete this whole block
        if (
          Object.keys(newData).length === 0 || options.removeFrontMatterSection
        ) {
          return null;
        }
      } catch {
        // console.warn("Could not parse frontmatter", e.message);
      }
    }
    if (t.type === "Attribute") {
      if (findParentMatching(t, (n) => n.type === "ListItem")) {
        return;
      }
      const nameNode = findNodeOfType(t, "AttributeName");
      const valueNode = findNodeOfType(t, "AttributeValue");
      if (nameNode && valueNode) {
        const name = nameNode.children![0].text!;
        const val = valueNode.children![0].text!;
        try {
          frontmatter[name] = cleanupJSON(YAML.load(val));
        } catch (e: any) {
          console.error("Error parsing attribute value as YAML", val, e);
        }
      }
      return;
    }

    return;
  });

  try {
    frontmatter.tags = [
      ...new Set([...tags.map((t) => {
        // Always treat tags as strings
        const tagAsString = String(t);
        // Strip # from tags
        return tagAsString.replace(/^#/, "");
      })]),
    ];
  } catch (e) {
    console.error("Error while processing tags", e);
  }

  // Expand property names (e.g. "foo.bar" => { foo: { bar: true } })
  frontmatter = cleanupJSON(frontmatter);

  return frontmatter;
}

const attributeRegex = /^[\w\-_]+$/;

export async function frontmatterComplete(completeEvent: CompleteEvent) {
  const frontmatterCode = completeEvent.parentNodes.find((nt) =>
    nt.startsWith("FrontMatter:")
  );

  if (!frontmatterCode) {
    return;
  }

  const attributeName = completeEvent.linePrefix;

  if (!attributeRegex.exec(attributeName)) {
    return;
  }

  const tags = determineTags(frontmatterCode);
  tags.push("page");

  return {
    from: completeEvent.pos - attributeName.length,
    options: await attributeCompletion(tags),
  };
}


================================================
FILE: plugs/index/header.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import { extractFrontMatter } from "./frontmatter.ts";
import { assertEquals } from "@std/assert";
import { indexHeaders } from "./header.ts";

const testPage = `
# Header 1
## Header 1.1
# Header 2 [testAttr: 10]
`.trim();

Deno.test("Test header indexing", async () => {
  createMockSystem();
  const tree = parseMarkdown(testPage);
  const frontmatter = await extractFrontMatter(tree);

  const pageMeta: PageMeta = {
    ref: "test",
    name: "test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };

  const headers = await indexHeaders(
    pageMeta,
    frontmatter,
    tree,
  );
  // 1 data 1 tag
  assertEquals(headers.length, 3);

  assertEquals(headers[0].name, "Header 1");
  assertEquals(headers[0].level, 1);
  assertEquals(headers[1].name, "Header 1.1");
  assertEquals(headers[1].level, 2);
});


================================================
FILE: plugs/index/header.ts
================================================
import {
  cloneTree,
  collectNodesMatching,
  type ParseTree,
  renderToText,
} from "@silverbulletmd/silverbullet/lib/tree";
import {
  getNameFromPath,
  parseToRef,
} from "@silverbulletmd/silverbullet/lib/ref";
import { index, lua } from "@silverbulletmd/silverbullet/syscalls";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import type { CompleteEvent } from "@silverbulletmd/silverbullet/type/client";
import type { FrontMatter } from "./frontmatter.ts";
import { cleanAttributes, collectAttributes } from "./attribute.ts";
import { cleanTags, collectTags } from "./tags.ts";

type HeaderObject = ObjectValue<
  {
    name: string;
    text: string;
    page: string;
    level: number;
    pos: number;
  } & Record<string, any>
>;

export function indexHeaders(
  pageMeta: PageMeta,
  _frontmatter: FrontMatter,
  tree: ParseTree,
): Promise<HeaderObject[]> {
  const headers: ObjectValue<HeaderObject>[] = [];

  for (
    const n of collectNodesMatching(
      tree,
      (t) => !!t.type?.startsWith("ATXHeading"),
    )
  ) {
    const level = +n.type!.substring("ATXHeading".length);
    const name = renderToText(n).slice(level + 1);
    const tags = collectTags(n);
    const attributes = collectAttributes(n);
    const nClone = cloneTree(n);
    cleanTags(nClone);
    cleanAttributes(nClone);
    const text = renderToText(nClone).slice(level + 1);

    headers.push({
      ref: `${pageMeta.name}@${n.from}`,
      tag: "header",
      tags: [...tags],
      level,
      name,
      text,
      page: pageMeta.name,
      pos: n.from!,
      range: [n.from!, n.to!],
      ...attributes,
    });
  }

  return Promise.resolve(headers);
}

export async function headerComplete(completeEvent: CompleteEvent) {
  const match = /(?:\[\[|\[.*?\]\()(?<path>[^\[]*)$/.exec(
    completeEvent.linePrefix,
  );
  if (!match || !match.groups?.path) {
    return;
  }

  const ref = parseToRef(match.groups.path);
  if (!ref || ref.details?.type !== "header") {
    return;
  }

  const headers = await index.queryLuaObjects<HeaderObject>(
    "header",
    {
      objectVariable: "_",
      where: await lua.parseExpression(`_.page == name`),
    },
    { name: getNameFromPath(ref.path) || completeEvent.pageName },
  );

  return {
    from: completeEvent.pos - match.groups.path.length,
    options: headers.map((header) => ({
      label: `${
        (ref.meta ? "^" : "") + getNameFromPath(ref.path)
      }#${header.name}`,
      type: "header",
    })),
  };
}


================================================
FILE: plugs/index/indexer.ts
================================================
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import { extractFrontMatter, type FrontMatter } from "./frontmatter.ts";
import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree";
import type { IndexTreeEvent } from "@silverbulletmd/silverbullet/type/event";
import { indexPage as pageIndexPage } from "./page.ts";
import { indexData } from "./data.ts";
import { indexItems } from "./item.ts";
import { indexHeaders } from "./header.ts";
import { indexParagraphs } from "./paragraph.ts";
import { indexLinks } from "./link.ts";
import { indexTables } from "./table.ts";
import { indexSpaceLua } from "./space_lua.ts";
import { indexSpaceStyle } from "./space_style.ts";
import { indexTags } from "./tags.ts";
import { index, markdown } from "@silverbulletmd/silverbullet/syscalls";

export type IndexerFunction = (
  pageMeta: PageMeta,
  frontmatter: FrontMatter,
  tree: ParseTree,
  text: string,
) => Promise<ObjectValue<any>[]>;

export const allIndexers: IndexerFunction[] = [
  pageIndexPage,
  indexData,
  indexItems,
  indexHeaders,
  indexParagraphs,
  indexLinks,
  indexTables,
  indexSpaceLua,
  indexSpaceStyle,
  indexTags,
];

/**
 * Ad-hoc index a piece of markdown text
 * @return a list of indexed objects
 */
export async function indexMarkdown(text: string, pageMeta: PageMeta = {
  ref: "",
  tag: "",
  name: "",
  perm: "ro",
  lastModified: "",
  created: "",
}): Promise<ObjectValue<any>> {
  const tree = await markdown.parseMarkdown(text);
  const frontmatter = extractFrontMatter(tree);
  const index = await Promise.all(
    allIndexers.filter((indexer) => indexer !== pageIndexPage).map(
      (indexer) => {
        return indexer(pageMeta, frontmatter, tree, text);
      },
    ),
  );
  return index.flat();
}

export async function indexPage({ name, tree, meta, text }: IndexTreeEvent) {
  const frontmatter = extractFrontMatter(tree);

  // console.log("Now going to index page", name);

  // Index the page
  const indexResults = await Promise.all(allIndexers.map((indexer) => {
    return indexer(meta, frontmatter, tree, text);
  }));

  // console.log("Found these objects", index.flat());

  await index.indexObjects<any>(name, indexResults.flat());
}


================================================
FILE: plugs/index/item.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { indexItems } from "./item.ts";
import { assertEquals } from "@std/assert";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import { extractFrontMatter } from "./frontmatter.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";

const itemsMd = `
* Item 1 #tag1 #tag2 [age: 100]
  * Item 1.1 #tag3 #tag1
    * Item 1.1.1
* [ ] Task 1
* [x] Task 2
* Item 2 #tag4
  * [ ] Sub task
* [[link]]
  * Child second [[link 2|alias]]
`;

Deno.test("Test item indexing", async () => {
  createMockSystem();
  const tree = parseMarkdown(itemsMd);
  const frontmatter = extractFrontMatter(tree);
  const pageMeta: PageMeta = {
    ref: "test",
    name: "test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };
  const items = await indexItems(pageMeta, frontmatter, tree);
  assertEquals(items.length, 9);
  // Test regular items
  assertEquals(items[0].name, "Item 1");
  assertEquals(items[0].age, 100);
  assertEquals(items[0].page, "test");
  assertEquals(items[0].parent, undefined);
  assertEquals(items[0].text, "Item 1 #tag1 #tag2 [age: 100]");
  assertEquals(new Set(items[0].tags), new Set(["tag1", "tag2"]));
  assertEquals(new Set(items[0].itags), new Set(["item", "tag1", "tag2"]));

  assertEquals(items[1].name, "Item 1.1");
  assertEquals(new Set(items[1].tags), new Set(["tag3", "tag1"]));
  assertEquals(
    new Set(items[1].itags),
    new Set(["tag3", "tag2", "tag1", "item"]),
  );
  assertEquals(items[1].parent, items[0].ref);

  assertEquals(items[2].name, "Item 1.1.1");
  assertEquals(items[2].parent, items[1].ref);

  // Test tasks
  assertEquals(items[3].tag, "task");
  assertEquals(items[3].name, "Task 1");
  assertEquals(items[3].done, false);
  assertEquals(items[3].state, " ");

  assertEquals(items[4].tag, "task");
  assertEquals(items[4].name, "Task 2");
  assertEquals(items[4].done, true);
  assertEquals(items[4].state, "x");

  assertEquals(items[6].tag, "task");
  assertEquals(items[6].name, "Sub task");
  assertEquals(items[6].done, false);
  assertEquals(items[6].parent, items[5].ref);
  assertEquals(new Set(items[6].itags), new Set(["task", "tag4"]));

  assertEquals(items[7].links, ["link"]);
  assertEquals(items[7].ilinks, ["link"]);
  assertEquals(items[8].links, ["link 2"]);
  assertEquals(new Set(items[8].ilinks), new Set(["link", "link 2"]));
});


================================================
FILE: plugs/index/item.ts
================================================
import {
  cloneTree,
  findParentMatching,
  type ParseTree,
  renderToText,
  traverseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import { cleanTags, collectTags, updateITags } from "./tags.ts";
import type { FrontMatter } from "./frontmatter.ts";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import { system } from "@silverbulletmd/silverbullet/syscalls";
import { cleanAttributes, collectAttributes } from "./attribute.ts";
import { collectPageLinks } from "./link.ts";

export type ItemObject = ObjectValue<
  {
    page: string;
    name: string;
    text: string;
    // Deprecated, use range instead
    pos: number;
    // Deprecated, use range instead
    toPos: number;
    parent?: string;
    links?: string[];
    ilinks?: string[];
  } & Record<string, any>
>;

export type TaskObject = ObjectValue<
  // "Inherit" everyting from item
  & ItemObject
  // And add a few more attributes
  & {
    done: boolean;
    state: string;
  }
  & Record<string, any>
>;

const completeStates = ["x", "X"];

export async function indexItems(
  pageMeta: PageMeta,
  frontmatter: FrontMatter,
  tree: ParseTree,
) {
  const shouldIndexAllItems = await system.getConfig(
    "index.item.all",
    true,
  );
  const shouldIndexAllTasks = await system.getConfig(
    "index.task.all",
    true,
  );

  let items: ObjectValue<ItemObject | TaskObject>[] = [];

  traverseTree(tree, (n) => {
    if (n.type !== "ListItem") {
      return false;
    }

    if (!n.children) {
      // Weird, let's jump out
      return true;
    }

    items.push(extractItemFromNode(
      pageMeta.name,
      n,
      frontmatter,
    ));

    // Traversal continue into child items (potentially)
    return false;
  });

  if (!shouldIndexAllItems) {
    items = items.filter((item) => item.tag !== "item" || item.tags?.length);
  }
  if (!shouldIndexAllTasks) {
    items = items.filter((item) => item.tag !== "task" || item.tags?.length);
  }

  return items;
}

export function extractItemFromNode(
  name: string,
  itemNode: ParseTree,
  frontmatter: FrontMatter,
  withParents = true,
): ItemObject | TaskObject {
  const item: ItemObject | TaskObject = {
    ref: `${name}@${itemNode.from}`,
    tag: "item",
    pos: itemNode.from!,
    toPos: itemNode.to!,
    range: [itemNode.from!, itemNode.to!],
    name: "", // to be replaced
    text: "", // to be replaced
    page: name,
  };

  // This will only be valid for items, not task
  let nameNode = itemNode.children!.find((n) => n.type === "Paragraph");

  // Is this a task?
  const taskNode = itemNode.children!.find((n) => n.type === "Task");
  if (taskNode) {
    item.tag = "task";
    item.state = taskNode.children![0].children![1].text!;
    item.done = completeStates.includes(item.state);
    // Fake a paragraph node for text rendering later
    nameNode = { type: "Paragraph", children: taskNode.children!.slice(1) };
  }

  // Now let's extract tags and attributes
  const tags = collectTags(itemNode);
  const attributes = collectAttributes(itemNode);
  const links = collectPageLinks(itemNode);

  item.text = renderToText(nameNode).trim();

  const nameNodeClone = cloneTree(nameNode!);
  cleanTags(nameNodeClone);
  cleanAttributes(nameNodeClone);
  item.name = renderToText(nameNodeClone).trim();

  if (tags.length > 0) {
    item.tags = tags;
  }

  if (links.length > 0) {
    item.links = links;
    item.ilinks = links;
  }

  for (const [key, value] of Object.entries(attributes)) {
    item[key] = value;
  }

  updateITags(item, frontmatter);

  if (withParents) {
    enrichItemFromParents(itemNode, item, name, frontmatter);
  }

  return item;
}

export function enrichItemFromParents(
  n: ParseTree,
  item: ItemObject,
  pageName: string,
  frontmatter: FrontMatter,
) {
  let directParent = true;
  let parentItemNode = findParentMatching(n, (n) => n.type === "ListItem");
  while (parentItemNode) {
    const parentItem = extractItemFromNode(
      pageName,
      parentItemNode,
      frontmatter,
      false,
    );
    if (directParent) {
      item.parent = parentItem.ref;
      directParent = false;
    }
    // Merge tags
    item.itags = [
      ...new Set([
        ...item.itags || [],
        ...(parentItem.itags!.filter((t) => !["item", "task"].includes(t))),
      ]),
    ];

    // And links
    const ilinks = [
      ...new Set([
        ...item.ilinks || [],
        ...parentItem.ilinks || [],
      ]),
    ];
    if (ilinks.length > 0) {
      item.ilinks = ilinks;
    }

    parentItemNode = findParentMatching(
      parentItemNode,
      (n) => n.type === "ListItem",
    );
  }
}


================================================
FILE: plugs/index/link.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import { extractFrontMatter } from "./frontmatter.ts";
import { assertEquals } from "@std/assert";
import { indexLinks } from "./link.ts";

const testPage = `
---
attribute: "[[fm-link]]"
---
This is a [[page-link]] to [[aliased-link|aliased]], or [this](md-link), and [[broken]], or [external](https://example.com), or [document](test.jpg), or [[test2.jpg]]
`.trim();

Deno.test("Test page link indexing", async () => {
  const { space } = createMockSystem();

  // Create dummy targets to avoid a lot of aspiring pages
  await space.writePage("page-link", "");
  await space.writePage("fm-link", "");
  await space.writePage("aliased-link", "");
  await space.writePage("folder/md-link", "");

  const tree = parseMarkdown(testPage);
  const frontmatter = extractFrontMatter(tree);

  const pageMeta: PageMeta = {
    ref: "folder/test",
    name: "folder/test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };

  const objects = await indexLinks(pageMeta, frontmatter, tree, testPage);
  assertEquals(objects.length, 9); // 7 links + 1 aspiring page
  assertEquals(objects[0].toPage, "fm-link");
  assertEquals(objects[0].type, "page");
  assertEquals(objects[1].toPage, "page-link");
  assertEquals(objects[1].type, "page");
  assertEquals(objects[2].toPage, "aliased-link");
  assertEquals(objects[2].type, "page");
  assertEquals(objects[2].alias, "aliased");
  assertEquals(objects[3].toPage, "folder/md-link"); // relative
  assertEquals(objects[3].type, "page");
  assertEquals(objects[4].toPage, "broken");
  assertEquals(objects[4].type, "page");
  assertEquals(objects[5].toURL, "https://example.com");
  assertEquals(objects[5].type, "url");
  assertEquals(objects[6].toFile, "folder/test.jpg");
  assertEquals(objects[6].type, "file");
  assertEquals(objects[7].toFile, "test2.jpg");
  assertEquals(objects[7].type, "file");
});


================================================
FILE: plugs/index/link.ts
================================================
import {
  collectNodesOfType,
  findNodeOfType,
  type ParseTree,
  renderToText,
  traverseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import {
  isLocalURL,
  resolveMarkdownLink,
} from "@silverbulletmd/silverbullet/lib/resolve";
import type { FrontMatter } from "./frontmatter.ts";
import { updateITags } from "./tags.ts";
import {
  getNameFromPath,
  isMarkdownPath,
  parseToRef,
} from "@silverbulletmd/silverbullet/lib/ref";
import {
  mdLinkRegex,
  wikiLinkRegex,
} from "../../client/markdown_parser/constants.ts";
import { index, lua, space } from "@silverbulletmd/silverbullet/syscalls";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import { extractSnippet } from "./snippet.ts";

export type LinkObject = ObjectValue<{
  // Common to all links
  page: string;
  pos: number;
  type: "page" | "file" | "url";
  snippet: string;
  alias?: string;
  pageLastModified: string;
  // Page Link
  toPage?: string;
  // File Link
  toFile?: string;
  // External URL
  toURL?: string;
}>;

/**
 * Represents a page that does not yet exist, but is being linked to. A page "aspiring" to be created.
 */
export type AspiringPageObject = ObjectValue<{
  // ref: page@pos
  // The page the link appears on
  page: string;
  // And the position
  pos: number;
  // The page the link points to
  name: string;
}>;

export async function indexLinks(
  pageMeta: PageMeta,
  frontmatter: FrontMatter,
  tree: ParseTree,
  pageText: string,
) {
  const objects: ObjectValue<any>[] = [];

  // If this is a meta template page, we don't want to index links
  if (frontmatter.tags?.find((t) => t.startsWith("meta/template"))) {
    return [];
  }

  const name = pageMeta.name;

  traverseTree(tree, (n): boolean => {
    // Index [[WikiLinks]]
    if (n.type === "WikiLink") {
      const wikiLinkPage = findNodeOfType(n, "WikiLinkPage")!;
      const wikiLinkAlias = findNodeOfType(n, "WikiLinkAlias");
      const url = wikiLinkPage.children![0].text!;
      const pos = wikiLinkPage.from!;

      const link: LinkObject = {
        ref: `${name}@${pos}`,
        type: "page", // can be swapped later
        tag: "link",
        snippet: extractSnippet(name, pageText, pos),
        pos,
        range: [n.from!, n.to!],
        page: name,
        pageLastModified: pageMeta.lastModified,
      };

      const ref = parseToRef(url);
      if (!ref) {
        // Invalid links aren't indexed
        return true;
      } else if (isMarkdownPath(ref.path)) {
        link.toPage = getNameFromPath(ref.path);
        link.type = "page";
      } else {
        link.toFile = ref.path;
        link.type = "file";
      }

      if (wikiLinkAlias) {
        link.alias = wikiLinkAlias.children![0].text!;
      }
      updateITags(link as LinkObject, frontmatter);
      objects.push(link);
      return true;
    }

    // Index [markdown style]() links
    if (n.type === "Link" || n.type === "Image") {
      // The [[Wiki links]] also have a wrapping Image node, but this just fails at the regex
      mdLinkRegex.lastIndex = 0;
      const match = mdLinkRegex.exec(renderToText(n));
      if (!match) {
        return false;
      }
      const { title: alias, url } = match.groups as {
        url: string;
        title: string;
      };

      // Check if local link
      const pos = n.from!;
      const link: LinkObject = {
        ref: `${name}@${pos}`,
        tag: "link",
        type: "page", // swapped out later if needed
        snippet: extractSnippet(name, pageText, pos),
        pos,
        range: [n.from!, n.to!],
        page: name,
        pageLastModified: pageMeta.lastModified,
      };

      if (isLocalURL(url)) {
        const ref = parseToRef(resolveMarkdownLink(name, decodeURI(url)));
        if (!ref) {
          // Invalid links aren't indexed
          return true;
        } else if (isMarkdownPath(ref.path)) {
          link.toPage = getNameFromPath(ref.path);
          link.type = "page";
        } else {
          link.toFile = ref.path;
          link.type = "file";
        }
      } else {
        // External URL
        link.type = "url";
        link.toURL = url;
      }

      if (alias) {
        link.alias = alias;
      }
      updateITags(link as LinkObject, frontmatter);
      objects.push(link);
      return true;
    }

    // Also index links used inside quoted frontmatter strings like "[[Page]]"
    // must match the full string node, only allowing for quotes and whitespace around it
    if (n.type === "FrontMatter") {
      // The YAML in frontmatter is parsed by CodeMirror itself
      for (const textNode of collectNodesOfType(n, "string")) {
        const text = textNode.children![0].text!;
        const trimmed = text.replace(/^["'\s]*/, "").replace(/["'\s]*$/, "");
        // Make sure we search from the beginning, when reusing a Regex object with global flag
        wikiLinkRegex.lastIndex = 0;
        const match = wikiLinkRegex.exec(text);
        // Search in entire node text to get correct position, but check for full match against trimmed
        if (match && match.groups && match[0] === trimmed) {
          const { leadingTrivia, stringRef, alias } = match.groups;
          const pos = textNode.from! + match.index! + leadingTrivia.length;
          const link: LinkObject = {
            ref: `${name}@${pos}`,
            tag: "link",
            type: "page", // final value set later
            page: name,
            snippet: extractSnippet(name, pageText, pos),
            pos: pos,
            range: [
              textNode.from! + match.index!,
              textNode.from! + match.index! + match[0].length,
            ],
            pageLastModified: pageMeta.lastModified,
          };

          const ref = parseToRef(stringRef);
          if (!ref) {
            // Invalid links aren't indexed
            return true;
          } else if (isMarkdownPath(ref.path)) {
            link.toPage = getNameFromPath(ref.path);
            link.type = "page";
          } else {
            link.toFile = ref.path;
            link.type = "file";
          }

          if (alias) {
            link.alias = alias;
          }
          updateITags(link, frontmatter);
          objects.push(link);
        }
      }
    }
    return false;
  });

  // Now let's check which are aspiring pages
  for (const link of objects.slice()) {
    if (link.toPage) {
      if (!await space.fileExists(`${link.toPage}.md`)) {
        objects.push({
          ref: `${name}@${link.pos}`,
          tag: "aspiring-page",
          page: name,
          pos: link.pos,
          range: link.range,
          name: link.toPage,
        } as AspiringPageObject);
        console.info(
          "Link from",
          name,
          "to",
          link.toPage,
          "is broken, indexing as aspiring page",
        );
      }
    }
  }

  return objects;
}

/**
 * Collects wiki links from a tree
 * @param n
 * @return found links
 */
export function collectPageLinks(n: ParseTree): string[] {
  const links = new Set<string>();
  traverseTree(n, (n) => {
    if (n.type === "WikiLink") {
      links.add(findNodeOfType(n, "WikiLinkPage")!.children![0].text!);
      return true;
    } else if (n.type === "OrderedList" || n.type === "BulletList") {
      // Don't traverse into sub-lists
      return true;
    }
    return false;
  });
  return [...links];
}

export async function getBackLinks(
  name: string,
): Promise<LinkObject[]> {
  return (await index.queryLuaObjects<LinkObject>("link", {
    objectVariable: "_",
    where: await lua.parseExpression(`_.toPage == name or _.toFile == name`),
  }, {
    name,
  }));
}


================================================
FILE: plugs/index/lint.ts
================================================
import { index, lua } from "@silverbulletmd/silverbullet/syscalls";
import {
  findNodeOfType,
  renderToText,
  traverseTree,
  traverseTreeAsync,
} from "@silverbulletmd/silverbullet/lib/tree";
import type {
  LintDiagnostic,
  LintEvent,
} from "@silverbulletmd/silverbullet/type/client";

import YAML from "js-yaml";
import { extractFrontMatter } from "./frontmatter.ts";
import { allIndexers } from "./indexer.ts";

/**
 * Lint YAML syntax in frontmatter and fenced code blocks
 */
export function lintYAML(
  { tree, name }: LintEvent,
): LintDiagnostic[] {
  const diagnostics: LintDiagnostic[] = [];

  traverseTree(tree, (node) => {
    if (node.type === "FrontMatterCode") {
      const yamlText = renderToText(node);
      const lintResult = lintYamlBlock(
        yamlText,
        node.from!,
        name,
      );
      if (lintResult) {
        diagnostics.push(lintResult);
      }
      return true;
    }
    if (node.type === "FencedCode") {
      const codeInfo = findNodeOfType(node, "CodeInfo")!;
      if (!codeInfo) {
        return true;
      }
      const codeLang = codeInfo.children![0].text!;
      // All known YAML formats
      if (
        ["yaml"].includes(codeLang) || codeLang.startsWith("#")
      ) {
        const codeText = findNodeOfType(node, "CodeText");
        if (!codeText) {
          return true;
        }
        const yamlCode = renderToText(codeText);
        const lintResult = lintYamlBlock(
          yamlCode,
          codeText.from!,
        );
        if (lintResult) {
          diagnostics.push(lintResult);
        }
        return true;
      }
    }
    return false;
  });
  return diagnostics;
}

const errorRegex = /\((\d+):(\d+)\)/;

/**
 * Lint a YAML block
 * @param yamlText - The YAML text to lint
 * @param startPos - The start position of the YAML block
 * @param pageName - The page name to check against
 * @returns A LintDiagnostic if there is an error, undefined otherwise
 */
function lintYamlBlock(
  yamlText: string,
  startPos: number,
  pageName?: string,
): LintDiagnostic | undefined {
  try {
    const parsed = YAML.load(yamlText);
    if (pageName && parsed.name && parsed.name != pageName) {
      return {
        from: startPos,
        to: startPos + yamlText.length,
        severity: "error",
        message: "'name' attribute has to match page name",
      };
    }
  } catch (e: any) {
    const errorMatch = errorRegex.exec(e.message);
    if (errorMatch) {
      console.log("YAML error", e.message);
      const line = parseInt(errorMatch[1], 10) - 1;
      const yamlLines = yamlText.split("\n");
      let pos = startPos;
      for (let i = 0; i < line; i++) {
        pos += yamlLines[i].length + 1;
      }
      const endPos = pos + yamlLines[line]?.length || pos;

      return {
        from: pos,
        to: endPos,
        severity: "error",
        message: e.message,
      };
    }
  }
}

/**
 * Lint Lua code in fenced code blocks
 * @returns A list of LintDiagnostics for any errors found
 */
export async function lintLua({ tree }: LintEvent): Promise<LintDiagnostic[]> {
  const diagnostics: LintDiagnostic[] = [];
  await traverseTreeAsync(tree, async (node) => {
    if (node.type === "FencedCode") {
      const codeInfo = findNodeOfType(node, "CodeInfo")!;
      if (!codeInfo) {
        return true;
      }
      const codeLang = codeInfo.children![0].text!;
      if (codeLang !== "space-lua") {
        return true;
      }
      const codeText = findNodeOfType(node, "CodeText");
      if (!codeText) {
        return true;
      }
      const luaCode = renderToText(codeText);
      try {
        await lua.parse(luaCode);
      } catch (e: any) {
        const offset = codeText.from!;
        let from = codeText.from!;
        let to = codeText.to!;
        let message = e.message;
        if (message.includes("Parse error")) {
          const pos = +message.slice("Parse error at pos ".length);
          from = offset + pos;
          to = offset + pos;
          message = "Parse error";
        }
        diagnostics.push({
          from,
          to,
          severity: "error",
          message,
        });
        console.log("Lua error", e);
      }
      return true;
    }

    return false;
  });
  return diagnostics;
}

/**
 * Lint objects in the page
 */
export async function lintObjects(
  { tree, pageMeta: meta, text, name }: LintEvent,
): Promise<LintDiagnostic[]> {
  const frontmatter = extractFrontMatter(tree);

  // Index the page
  const allObjects = (await Promise.all(allIndexers.map((indexer) => {
    return indexer(meta, frontmatter, tree, text);
  }))).flat();
  const result = await index.validateObjects(name, allObjects);
  // If validation failed, return the error
  if (result?.object?.range) {
    return [{
      from: result.object.range[0],
      to: result.object.range[1],
      severity: "error",
      message: result.error,
    }];
  }
  return [];
}


================================================
FILE: plugs/index/page.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import { indexPage } from "./page.ts";
import { extractFrontMatter } from "./frontmatter.ts";
import { assertEquals } from "@std/assert";

const testPage = `
---
hello: attribute
---
[hello2: 12]

# Hello world!

`.trim();

Deno.test("Test page indexing", async () => {
  createMockSystem();
  const tree = parseMarkdown(testPage);
  const frontmatter = extractFrontMatter(tree);

  const pageMeta: PageMeta = {
    ref: "test",
    name: "test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };

  const [pm] = await indexPage(pageMeta, frontmatter, tree);
  assertEquals(pm.hello, "attribute");
  assertEquals(pm.hello2, 12);
  assertEquals(pm.itags, ["page"]);
});


================================================
FILE: plugs/index/page.ts
================================================
import {
  editor,
  index,
  lua,
  markdown,
} from "@silverbulletmd/silverbullet/syscalls";

import type { FrontMatter } from "./frontmatter.ts";
import {
  findNodeOfType,
  type ParseTree,
  renderToText,
  traverseTreeAsync,
} from "@silverbulletmd/silverbullet/lib/tree";
import { updateITags } from "./tags.ts";
import type { AspiringPageObject } from "./link.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import type { LintDiagnostic } from "@silverbulletmd/silverbullet/type/client";

import YAML from "js-yaml";

export async function indexPage(
  pageMeta: PageMeta,
  frontmatter: FrontMatter,
  _tree: ParseTree,
) {
  // Push them all into the page object
  // Note the order here, making sure that the actual page meta data overrules
  // any attempt to manually set built-in attributes like 'name' or 'lastModified'
  // pageMeta appears at the beginning and the end due to the ordering behavior of ojects in JS (making builtin attributes appear first)
  const combinedPageMeta: PageMeta = {
    ...pageMeta,
    ...frontmatter,
    ...pageMeta,
  };

  combinedPageMeta.tags = [...new Set([...frontmatter.tags || []])];

  combinedPageMeta.tag = "page";

  if (combinedPageMeta.aliases && !Array.isArray(combinedPageMeta.aliases)) {
    console.warn(
      "Aliases must be an array",
      combinedPageMeta.aliases,
      "falling back to empty array",
    );
    combinedPageMeta.aliases = [];
  }

  updateITags(combinedPageMeta, frontmatter);

  // Make sure this page is no (longer) in the aspiring pages list
  // TODO: This can possibly done more optimally
  const aspiringPages = await index.queryLuaObjects<AspiringPageObject>(
    "aspiring-page",
    {
      objectVariable: "_",
      where: await lua.parseExpression(`_.name == pageRef`),
    },
    { pageRef: pageMeta.name },
  );
  for (const aspiringPage of aspiringPages) {
    console.log("Deleting aspiring page", aspiringPage);
    await index.deleteObject(
      "aspiring-page",
      aspiringPage.page,
      aspiringPage.ref,
    );
  }

  return [combinedPageMeta];
}

export async function lintFrontmatter(): Promise<LintDiagnostic[]> {
  const text = await editor.getText();
  const tree = await markdown.parseMarkdown(text);
  const diagnostics: LintDiagnostic[] = [];
  await traverseTreeAsync(tree, async (node) => {
    if (node.type === "FrontMatterCode") {
      const lintResult = await lintYaml(
        renderToText(node),
        node.from!,
        node.to!,
      );
      if (lintResult) {
        diagnostics.push(lintResult);
      }
      return true;
    }
    if (node.type === "FencedCode") {
      const codeInfo = findNodeOfType(node, "CodeInfo")!;
      if (!codeInfo) {
        return true;
      }
      const codeLang = codeInfo.children![0].text!;
      // All known YAML formats
      if (
        codeLang === "template" || codeLang === "yaml" ||
        codeLang.startsWith("#")
      ) {
        const codeText = findNodeOfType(node, "CodeText");
        if (!codeText) {
          return true;
        }
        const yamlCode = renderToText(codeText);
        const lintResult = await lintYaml(
          yamlCode,
          codeText.from!,
          codeText.to!,
        );
        if (lintResult) {
          diagnostics.push(lintResult);
        }
        return true;
      }
    }
    return false;
  });
  return diagnostics;
}

const errorRegex = /at line (\d+),? column (\d+)/;

async function lintYaml(
  yamlText: string,
  from: number,
  to: number,
): Promise<LintDiagnostic | undefined> {
  try {
    await YAML.load(yamlText);
  } catch (e: any) {
    const errorMatch = errorRegex.exec(e.message);
    if (errorMatch) {
      console.log("YAML error", e.message);

      return {
        from,
        to,
        severity: "error",
        message: e.message,
      };
    }
  }
}

export async function loadPageObject(pageName?: string): Promise<PageMeta> {
  if (!pageName) {
    return {
      ref: "",
      name: "",
      tags: ["page"],
      lastModified: "",
      created: "",
    } as PageMeta;
  }
  return (await index.getObjectByRef<PageMeta>(
    pageName,
    "page",
    pageName,
  )) || {
    ref: pageName,
    name: pageName,
    tags: ["page"],
    lastModified: "",
    created: "",
  } as PageMeta;
}


================================================
FILE: plugs/index/paragraph.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import { extractFrontMatter } from "./frontmatter.ts";
import { assertEquals } from "@std/assert";
import { indexParagraphs } from "./paragraph.ts";

const testPage = `
#tag-only

Some paragraph

Another paragraph that is #tagged

* Item paragraph (don't index)
`.trim();

Deno.test("Test paragraph indexing", async () => {
  const { config } = createMockSystem();

  config.set("index.paragraph.all", true);
  let tree = parseMarkdown(testPage);
  let frontmatter = extractFrontMatter(tree);

  const pageMeta: PageMeta = {
    ref: "test",
    name: "test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };

  let paragraphs = await indexParagraphs(pageMeta, frontmatter, tree);
  console.log(paragraphs);

  assertEquals(paragraphs.length, 2);
  assertEquals([...new Set(paragraphs[0].itags)], ["paragraph", "tag-only"]);
  assertEquals(paragraphs[1].tags, ["tagged"]);

  config.set("index.paragraph.all", false);
  tree = parseMarkdown(testPage);
  frontmatter = extractFrontMatter(tree);

  paragraphs = await indexParagraphs(pageMeta, frontmatter, tree);
  assertEquals(paragraphs.length, 1);
});


================================================
FILE: plugs/index/paragraph.ts
================================================
import {
  cloneTree,
  findParentMatching,
  type ParseTree,
  renderToText,
  traverseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import { cleanTags, collectTags, updateITags } from "./tags.ts";
import type { FrontMatter } from "./frontmatter.ts";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import { system } from "@silverbulletmd/silverbullet/syscalls";
import { cleanAttributes, collectAttributes } from "./attribute.ts";

/** ParagraphObject  An index object for the top level text nodes */
export type ParagraphObject = ObjectValue<
  {
    page: string;
    pos: number;
    text: string;
  } & Record<string, any>
>;

export async function indexParagraphs(
  pageMeta: PageMeta,
  frontmatter: FrontMatter,
  tree: ParseTree,
) {
  const shouldIndexAll = await system.getConfig(
    "index.paragraph.all",
    false,
  );

  const objects: ParagraphObject[] = [];

  traverseTree(tree, (p) => {
    if (p.type !== "Paragraph") {
      return false;
    }

    if (findParentMatching(p, (n) => n.type === "ListItem")) {
      // Not looking at paragraphs nested in a list
      return true;
    }

    const fullText = renderToText(p);

    // Collect tags
    const tags = collectTags(p);

    if (tags.length === 0 && !shouldIndexAll) {
      // Don't index paragraphs without a hashtag
      return true;
    }

    // Extract attributes
    const attrs = collectAttributes(p);

    // Clean tree, just to check if it's effectively empty or not
    const pClone = cloneTree(p);
    cleanTags(pClone);
    cleanAttributes(pClone);
    const text = renderToText(pClone);

    if (!text.trim()) {
      // Empty paragraph, just tags and attributes maybe
      return true;
    }

    const pos = p.from!;
    const paragraph: ParagraphObject = {
      tag: "paragraph",
      ref: `${pageMeta.name}@${pos}`,
      text: fullText,
      page: pageMeta.name,
      pos,
      range: [p.from!, p.to!],
      ...attrs,
    };
    if (tags.length > 0) {
      paragraph.tags = [...tags];
      paragraph.itags = [...tags];
    }

    updateITags(paragraph, frontmatter);
    objects.push(paragraph);

    // stop on every element except document, including paragraphs
    return true;
  });

  return objects;
}


================================================
FILE: plugs/index/plug_api.ts
================================================
import { system } from "@silverbulletmd/silverbullet/syscalls";
import type { ObjectValue } from "../../plug-api/types/index.ts";

import type { KV } from "../../plug-api/types/datastore.ts";

export function indexObjects<T>(
  page: string,
  objects: ObjectValue<T>[],
): Promise<void> {
  return system.invokeFunction("index.indexObjects", page, objects);
}

export function batchSet(page: string, kvs: KV[]): Promise<void> {
  return system.invokeFunction("index.batchSet", page, kvs);
}

export function getObjectByRef<T>(
  page: string,
  tag: string,
  ref: string,
): Promise<T | undefined> {
  return system.invokeFunction("index.getObjectByRef", page, tag, ref);
}


================================================
FILE: plugs/index/queue.ts
================================================
import {
  editor,
  events,
  markdown,
  mq,
  space,
} from "@silverbulletmd/silverbullet/syscalls";
import { sleep } from "@silverbulletmd/silverbullet/lib/async";
import type { MQMessage } from "@silverbulletmd/silverbullet/type/datastore";
import type { IndexTreeEvent } from "@silverbulletmd/silverbullet/type/event";

/// QUEUE PROCESSING

export async function processIndexQueue(messages: MQMessage[]) {
  for (const message of messages) {
    const path: string = message.body;
    console.log("[index]", `Indexing file ${path}`);
    await indexFile(path);
  }
}

async function indexFile(path: string) {
  if (path.endsWith(".md")) {
    // Page
    const name = path.slice(0, -3);
    // Read and parse the file
    const { text, meta } = await space.readPageWithMeta(name);
    const tree = await markdown.parseMarkdown(text);

    // Emit the event which will be picked up by indexers
    await events.dispatchEvent("page:index", {
      name,
      meta,
      tree,
      text,
    } as IndexTreeEvent);
  } else {
    await events.dispatchEvent("document:index", path);
  }
}

/// UI PROGRESS UPDATE LOGIC

const uiUpdateInterval = 5000;

// There is no reliable way to know the total number of queue items, so we'll keep track of the maximum observed queue size
// and use that to calculate the progress percentage.
let maximumObservedQueueSize = 0;

setTimeout(updateIndexProgressInUI, uiUpdateInterval);

// Returns the total number of items queued, updating the maximum observed queue size if necessary
async function totalItemsQueued() {
  const queueStats = await mq.getQueueStats();
  const total = queueStats.queued + queueStats.processing;
  if (total > maximumObservedQueueSize) {
    maximumObservedQueueSize = total;
  } else if (total === 0) {
    // Empty queue, let's reset the maximum observed queue size
    maximumObservedQueueSize = 0;
  }
  return total;
}

async function updateIndexProgressInUI() {
  // Let's see if there's anything in the index queue
  let totalQueued = await totalItemsQueued();
  while (totalQueued > 0) {
    const percentage = Math.round(
      (maximumObservedQueueSize - totalQueued) / maximumObservedQueueSize * 100,
    );
    if (percentage > 0 && percentage <= 99) {
      await editor.showProgress(percentage, "index");
    } else {
      // Hide progress circle
      await editor.showProgress();
    }
    await sleep(1000);
    totalQueued = await totalItemsQueued();
  }
  // Schedule again
  setTimeout(updateIndexProgressInUI, uiUpdateInterval);
}


================================================
FILE: plugs/index/refactor.ts
================================================
import {
  editor,
  index,
  lua,
  markdown,
  mq,
  space,
} from "@silverbulletmd/silverbullet/syscalls";
import { getBackLinks, type LinkObject } from "./link.ts";
import {
  absoluteToRelativePath,
  folderName,
} from "@silverbulletmd/silverbullet/lib/resolve";
import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree";
import {
  addParentPointers,
  findNodeOfType,
  findParentMatching,
  nodeAtPos,
} from "@silverbulletmd/silverbullet/lib/tree";
import type { ObjectValue } from "@silverbulletmd/silverbullet/type/index";
import { isValidPath } from "@silverbulletmd/silverbullet/lib/ref";
import { notFoundError } from "@silverbulletmd/silverbullet/constants";

/**
 * Renames a single page.
 * @param cmdDef Optional command arguments
 * @param cmdDef.oldPage The current name of the page to rename. Defaults to
 *   the current page selected in the editor.
 * @param cmdDef.page The name to rename the page to. If not provided the
 *   user will be prompted to enter a new name.
 * @returns True if the rename succeeded; otherwise, false.
 */
export async function renamePageCommand(cmdDef: any) {
  const oldName: string = cmdDef.oldPage || await editor.getCurrentPage();
  const newName: string = cmdDef.page ||
    await editor.prompt(`Rename ${oldName} to:`, oldName);
  if (!newName) {
    return false;
  }
  const pageList: [string, string][] = [[oldName + ".md", newName + ".md"]];
  await batchRenameFiles(pageList);
  return true;
}

export async function renamePageLinkCommand() {
  const mdTree = await markdown.parseMarkdown(await editor.getText());
  const link = nodeAtPos(mdTree, await editor.getCursor());
  if (!link) {
    console.error("No link found at cursor position...");
    return;
  }
  addParentPointers(mdTree);
  let node: ParseTree | null = link;
  if (node.type !== "WikiLink") {
    node = findParentMatching(node, (t) => t.type === "WikiLink");
    if (!node) {
      console.error("No link found at cursor position");
      return;
    }
  }
  const wikiLinkPage = findNodeOfType(node, "WikiLinkPage");
  if (!wikiLinkPage) {
    console.error("No link found at cursor position");
    return;
  }
  const oldName = wikiLinkPage.children![0].text!;

  const newName = await editor.prompt(`Rename ${oldName} to:`, oldName);
  if (!newName) {
    return false;
  }
  const pageList: [string, string][] = [[oldName + ".md", newName + ".md"]];
  await batchRenameFiles(pageList);
}

/**
 * Renames a single document.
 * @param cmdDef Optional command arguments
 * @param cmdDef.oldDocument The current name of the document to rename.
 * @param cmdDef.document The name to rename the document to. If not provided the
 *   user will be prompted to enter a new name.
 * @returns True if the rename succeeded; otherwise, false.
 */
export async function renameDocumentCommand(cmdDef: any) {
  const oldName: string = cmdDef.oldDocument || await editor.getCurrentPath();
  const newName: string = cmdDef.document ||
    await editor.prompt(`Rename ${oldName} to:`, oldName);
  if (!newName) {
    return false;
  }
  const pageList: [string, string][] = [[oldName, newName]];
  await batchRenameFiles(pageList);
  return true;
}

/**
 * Renames any amount of files.
 * If renaming pages, names should be passed with a .md extension
 * @param fileList An array of tuples containing [FileToBeRenamed, NewFileName]
 * @returns True if the rename succeeded; otherwise, false.
 */
export async function batchRenameFiles(fileList: [string, string][]) {
  await editor.save();

  // Skip unchanged names
  fileList = fileList.filter(([oldName, newName]) => {
    if (oldName.trim() === newName.trim()) {
      console.log(`${oldName}'s name unchanged, skipping`);
    } else {
      return [oldName, newName];
    }
  });

  try {
    // Pre-flight checks
    await Promise.all(fileList.map(async ([_oldName, newName]) => {
      try {
        // It's a FILEname not a PAGEname.
        if (!isValidPath(newName)) {
          throw new Error(`Name invalid: ${newName}`);
        }
        // Check if target file already exists
        await space.getFileMeta(newName);
        // If we got here, the file exists, so we error out
        throw new Error(
          `${newName} already exists, cannot rename to existing file.`,
        );
      } catch (e: any) {
        if (e.message === notFoundError.message) {
          // Expected not found error, so we can continue
        } else {
          throw e;
        }
      }
    }));

    // All new names are available, proceeding with rename
    for (const [oldName, newName] of fileList) {
      console.log("Renaming", oldName, "to", newName);
      try {
        if (newName.endsWith(".md") && oldName.endsWith(".md")) {
          await renamePage(oldName.slice(0, -3), newName.slice(0, -3));
        } else {
          await renameDocument(oldName, newName);
        }
      } catch (e: any) {
        if (e.message === notFoundError.message) {
          console.log(`${oldName} does not exist, skipping`);
        } else {
          throw e;
        }
      }
    }

    return true;
  } catch (e: any) {
    await editor.flashNotification(e.message, "error");
    return false;
  }
}

// Rename a page, update any backlinks and linked documents
async function renamePage(oldName: string, newName: string) {
  let text = await space.readPage(oldName);

  // Update relative links and documents on this page
  const oldFolder = folderName(oldName);
  const newFolder = folderName(newName);
  const documentsToMove = new Set<string>();
  // Links only need to be updated if the folder changes
  if (oldFolder !== newFolder) {
    const linksInPage = await index.queryLuaObjects<LinkObject>("link", {
      objectVariable: "_",
      where: await lua.parseExpression(`_.page == oldName`),
    }, {
      oldName,
    });

    const linksToUpdate: ObjectValue<LinkObject>[] = [];
    for (const link of linksInPage) {
      if (link.toFile && folderName(link.toFile) === oldFolder) {
        const documentBackLinks = await getBackLinks(link.toFile);
        if (documentBackLinks.filter((a) => a.page !== oldName).length === 0) {
          // Documents is in the same folder as the page
          // and is only linked to on this page, move it along with the page
          documentsToMove.add(link.toFile);
          continue;
        }
      }
      linksToUpdate.push(link);
    }

    // Sort links by position
    linksToUpdate.sort((a, b) => {
      // Backwards to prevent errors from position changes
      return b.pos - a.pos;
    });

    for (const link of linksToUpdate) {
      let newLink = link.toPage || link.toFile!;
      let newTail = text.substring(link.pos);

      // Only relative links need to be updated
      if (/^[^/][^\]]+?(?<!]])\)/.test(newTail)) {
        newLink = absoluteToRelativePath(newName, newLink);
        newTail = newTail.replace(/^.*?(?=@\d*|#|\$|\))/, newLink);
        // Wrap in <> if link has spaces
        if (newLink.includes(" ")) {
          newTail = "<" + newTail.replace(")", ">)");
        }
        text = text.substring(0, link.pos) + newTail;
      }
    }
  }

  // Write the new page
  const newPageMeta = await space.writePage(newName, text);

  // Move documents along with page
  const batchRenameDocuments: [string, string][] = [];
  for (const document of documentsToMove) {
    const newAttName = oldFolder.length === 0
      ? newFolder + "/" + document
      : document.replace(oldFolder, newFolder).replace(/^\//, "");
    batchRenameDocuments.push([document, newAttName]);
  }
  if (batchRenameDocuments.length > 0) {
    await batchRenameFiles(batchRenameDocuments);
  }

  // Handling the edge case of a changing page name just in casing on a case insensitive FS
  const oldPageMeta = await space.getPageMeta(oldName);
  if (oldPageMeta.lastModified !== newPageMeta.lastModified) {
    // If they're the same, let's assume it's the same file (case insensitive FS) and not delete, otherwise...
    await space.deletePage(oldName);
  }

  // Update backlinks to this page
  const updatedRefences = await updateBacklinks(oldName, newName);

  // Navigate to new page if currently viewing old page
  if (await editor.getCurrentPage() === oldName) {
    // Wait for index queue to be processed so that widgets are updated with up-to-date information
    await mq.awaitEmptyQueue("indexQueue");
    await editor.navigate(newName, true);
  }

  let message = `Renamed ${oldName} to ${newName}`;
  if (updatedRefences > 0) {
    message = `${message}, updated ${updatedRefences} backlinks`;
  }
  if (documentsToMove.size > 0) {
    message = `${message}, moved ${documentsToMove.size} documents`;
  }
  await editor.flashNotification(message, "info");
}

// Rename a document and update any backlinks
async function renameDocument(
  oldPath: string,
  newPath: string,
) {
  // Move the file
  const oldFile = await space.readDocument(oldPath);
  const newFileMeta = await space.writeDocument(newPath, oldFile);

  if (await editor.getCurrentPath() === oldPath) {
    await editor.navigate(newPath, true);
  }

  // Handling the edge case of a changing file name just in casing on a case insensitive FS
  const oldFileMeta = await space.getDocumentMeta(oldPath);
  if (oldFileMeta.lastModified !== newFileMeta.lastModified) {
    // If they're the same, let's assume it's the same file (case insensitive FS) and not delete, otherwise...
    await space.deleteDocument(oldPath);
  }

  // Update any backlinks
  const updatedRefences = await updateBacklinks(oldPath, newPath);
  let message = `Renamed ${oldPath} to ${newPath}`;
  if (updatedRefences > 0) {
    message = `${message}, updated ${updatedRefences} backlinks`;
  }
  await editor.flashNotification(message, "info");
}

/**
 * Renames pages based on a prefix string.
 * @param cmdDef Optional command arguments
 * @param cmdDef.oldPrefix The prefix to rename from. If not provided the
 *   user will be prompted to enter a prefix.
 * @param cmdDef.newPrefix The prefix with which to replace the `oldPrefix`
 *   value. If not provided the user will be prompted to enter a new prefix.
 * @param cmdDef.disableConfirmation If false, the user will be prompted
 *   to confirm the rename action; Otherwise no confirmation dialog will
 *   be shown before renaming. Defaults to false.
 * @returns True if the rename succeeded; otherwise, false.
 */
export async function renamePrefixCommand(cmdDef: any) {
  const oldPrefix = cmdDef.oldPrefix ??
    await editor.prompt("Prefix to rename:", "");
  if (!oldPrefix) {
    return false;
  }

  const newPrefix = cmdDef.newPrefix ??
    await editor.prompt("New prefix:", oldPrefix);
  if (!newPrefix) {
    return false;
  }

  const allDocuments = await space.listDocuments();
  const allPages = await space.listPages();
  let allAffectedFiles = allDocuments.map((file) => file.name).filter((
    file,
  ) => file.startsWith(oldPrefix));
  allAffectedFiles = allAffectedFiles.concat(
    allPages.map((page) => page.name + ".md").filter((page) =>
      page.startsWith(oldPrefix)
    ),
  );

  if (
    cmdDef.disableConfirmation !== true && !(await editor.confirm(
      `This will affect ${allAffectedFiles.length} files. Are you sure?`,
    ))
  ) {
    return false;
  }

  const allNewNames: [string, string][] = allAffectedFiles.map((name) => // This may seem naive, but it's actually fine, because we're only renaming the first occurrence (which will be the prefix)
  [name, name.replace(oldPrefix, newPrefix)]);
  await batchRenameFiles(allNewNames);
}

export async function extractToPageCommand() {
  const selection = await editor.getSelection();
  let text = await editor.getText();
  text = text.slice(selection.from, selection.to);

  const match = text.match("#{1,6}\\s+([^\n]*)");

  let newName;
  if (match) {
    newName = match[1];
  } else {
    newName = "new page";
  }
  newName = await editor.prompt(`New page title:`, newName);
  if (!newName) {
    return;
  }

  try {
    // This throws an error if the page does not exist, which we expect to be the case
    await space.getPageMeta(newName);
    // So when we get to this point, we error out
    throw new Error(
      `Page ${newName} already exists, cannot rename to existing page.`,
    );
  } catch (e: any) {
    if (e.message === notFoundError.message) {
      // Expected not found error, so we can continue
    } else {
      await editor.flashNotification(e.message, "error");
      throw e;
    }
  }
  await editor.replaceRange(selection.from, selection.to, `[[${newName}]]`);
  console.log("Writing new page to space");
  await space.writePage(newName, text);
  console.log("Navigating to new page");
  await editor.navigate(newName);
}

/**
 * Updates backlinks across all pages
 * @param oldName Full path to old page/file
 * @param newName Full path to new page/file
 * @returns The number of references updated
 */
async function updateBacklinks(
  oldName: string,
  newName: string,
): Promise<number> {
  // This is the bit where we update all the links
  const backLinks = await getBackLinks(oldName);
  let updatedReferences = 0;

  // Group by page to edit entire page at once
  const backLinksByPage = backLinks.reduce(
    (group: Record<string, LinkObject[]>, link) => {
      const { page } = link;
      group[page] = group[page] ?? [];
      group[page].push(link);
      return group;
    },
    {},
  );

  console.log("All pages containing backlinks", backLinks);
  for (const [pageToEdit, linksInPage] of Object.entries(backLinksByPage)) {
    if (pageToEdit === oldName) {
      continue;
    }

    let text = await space.readPage(pageToEdit);
    if (!text) {
      // Page likely does not exist, but at least we can skip it
      continue;
    }

    // Use indexed positions to replace links
    linksInPage.sort((a, b) => {
      // Backwards to prevent errors from position changes
      return b.pos - a.pos;
    });

    for (const link of linksInPage) {
      let newTail = text.substring(link.pos);
      let newLink = newName;
      if (/^[^\]]+?(?<!]])\)/.test(newTail)) {
        // Is [Markdown link]()
        if (newTail.startsWith("/") || newTail.startsWith("</")) {
          // Is absolute mdlink, update with full path with leading /
          newLink = "/" + newLink;
        } else {
          // Is relative mdlink
          newLink = absoluteToRelativePath(pageToEdit, newLink);
        }
        newTail = newTail.replace(/^.*?(?=@\d*|#|\$|\))/, newLink);

        // Wrap in <> if link has spaces
        if (newLink.includes(" ")) {
          newTail = "<" + newTail.replace(")", ">)");
        }
      } else {
        // Is wikilink, replace with full path
        newTail = newLink + newTail.slice(oldName.length);
      }

      text = text.substring(0, link.pos) + newTail;
      updatedReferences++;
    }
    await space.writePage(pageToEdit, text);
  }
  return updatedReferences;
}


================================================
FILE: plugs/index/snippet.test.ts
================================================
import { assertEquals } from "@std/assert";
import { extractSnippet } from "./snippet.ts";

Deno.test("extractSnippetByIndentation", () => {
  const text = `* Item 1
  * Item 1.1
    * Item 1.1.1
      * Item 1.1.1.1
    * Item 1.1.2
  * Item 1.2
* Item 2
  * Item 2.1`;

  // Unindented line with children
  assertEquals(
    extractSnippet("test", text, text.indexOf("* Item 1")),
    "* Item 1\n  * Item 1.1\n    * Item 1.1.1\n      * Item 1.1.1.1\n    * Item 1.1.2\n  * Item 1.2",
  );

  // Indented line with children (left-aligned)
  assertEquals(
    extractSnippet("test", text, text.indexOf("Item 1.1")),
    "* Item 1.1\n  * Item 1.1.1\n    * Item 1.1.1.1\n  * Item 1.1.2",
  );

  // Deeply nested line (left-aligned)
  assertEquals(
    extractSnippet("test", text, text.indexOf("Item 1.1.1.1")),
    "* Item 1.1.1.1",
  );

  // Stops at sibling
  assertEquals(
    extractSnippet("test", text, text.indexOf("Item 1.2")),
    "* Item 1.2",
  );

  // Stops at empty line
  const text2 = `* Item A
  * Item A.1
    Content

  * Item A.2`;
  assertEquals(
    extractSnippet("test", text2, text2.indexOf("Item A.1")),
    "* Item A.1\n  Content",
  );

  // Header extracts only itself and strips # marker
  const text3 = `## Sub Header
More text`;
  assertEquals(
    extractSnippet("test", text3, text3.indexOf("## Sub Header")),
    "Sub Header",
  );

  // maxLines limits the number of lines
  const text4 = `* Item X
  * Item X.1
  * Item X.2
  * Item X.3
  * Item X.4`;
  assertEquals(
    extractSnippet("test", text4, text4.indexOf("* Item X"), 3),
    "* Item X\n  * Item X.1\n  * Item X.2\n...",
  );

  // maxLines limits the number of lines
  const text5 = `  * Item X
    * [ ] Hello`;
  const taskPos = text5.indexOf("* [ ]");
  assertEquals(
    extractSnippet("test", text5, text4.indexOf("* Item X")),
    `* Item X\n  * [ ] [[test@${taskPos}]] Hello`,
  );
});


================================================
FILE: plugs/index/snippet.ts
================================================
/**
 * Extracts a snippet around a given index in markdown text based on indentation rules.
 *
 * If the line at the given index is indented, the snippet will include:
 * - The entire line containing the index
 * - All subsequent lines with indentation level greater than the current line
 *
 * @param text - The full markdown text
 * @param index - The position within the text where extraction should be centered
 * @param maxLines - Maximum number of lines to include in the snippet (default: Infinity)
 * @returns The extracted snippet
 */
export function extractSnippet(
  pageName: string,
  text: string,
  index: number,
  maxLines: number = 10,
): string {
  const lines = text.split("\n");
  const lineLengths = lines.map((line) => line.length);
  const targetLineIndex = text.substring(0, index).split("\n").length - 1;
  const targetLine = lines[targetLineIndex];
  const targetIndent = getIndentationLevel(targetLine);

  // Start with the target line
  const snippetLines = [targetLine.substring(targetIndent)];

  // Add all subsequent lines that have greater indentation than the target line
  for (
    let i = targetLineIndex + 1;
    i < lines.length;
    i++
  ) {
    let line = lines[i];
    const lineIndent = getIndentationLevel(line);

    if (snippetLines.length >= maxLines) {
      snippetLines.push("...");
      break;
    }

    // Stop if we hit an empty line
    if (line.trim() === "") {
      break;
    }

    // Stop if we hit a line with indentation equal to or less than the target line
    if (lineIndent <= targetIndent) {
      break;
    }

    // Find tasks that don't have a page reference, and add one
    const taskMatch = line.match(
      /^(\s*)([\*\-]\s+\[[^\]]+\]\s+)([^\[][^\[].+)$/,
    );
    if (taskMatch) {
      const pos = lineLengths.slice(0, i).reduce((acc, len) =>
        acc + len + 1, 0) +
        taskMatch[1].length;
      line = taskMatch[1] + taskMatch[2] + `[[${pageName}@${pos}]] ` +
        taskMatch[3];
    }
    snippetLines.push(line.substring(targetIndent));
  }

  let result = snippetLines.join("\n");

  // Specific cases: because headers look bad in snippets, let's strip those leading `#`
  result = result.replace(/^(#+)\s+/, "");

  return result;
}

/**
 * Gets the indentation level of a line (number of leading spaces).
 *
 * @param line - The line to measure
 * @returns The number of leading spaces
 */
function getIndentationLevel(line: string): number {
  const match = line.match(/^( *)/);
  return match ? match[1].length : 0;
}


================================================
FILE: plugs/index/space_lua.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import { extractFrontMatter } from "./frontmatter.ts";
import { assertEquals } from "@std/assert";
import { indexSpaceLua } from "./space_lua.ts";

const testPage = `
Hello
\`\`\`space-lua
function sup()
end
\`\`\`

\`\`\`space-lua
-- priority: 10
function sup()
end
\`\`\`
`.trim();

Deno.test("Test space lua indexing", async () => {
  createMockSystem();

  const tree = parseMarkdown(testPage);
  const frontmatter = extractFrontMatter(tree);

  const pageMeta: PageMeta = {
    ref: "folder/test",
    name: "folder/test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };

  const objects = await indexSpaceLua(pageMeta, frontmatter, tree);
  assertEquals(objects.length, 2);
  assertEquals(objects[0].priority, undefined);
  assertEquals(objects[1].priority, 10);
});


================================================
FILE: plugs/index/space_lua.ts
================================================
import {
  collectNodesOfType,
  findNodeOfType,
  type ParseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import type { FrontMatter } from "./frontmatter.ts";

export type SpaceLuaObject = ObjectValue<{
  script: string;
  priority?: number;
}>;

export function indexSpaceLua(
  pageMeta: PageMeta,
  _frontmatter: FrontMatter,
  tree: ParseTree,
) {
  const allSpaceLuas: SpaceLuaObject[] = [];
  collectNodesOfType(tree, "FencedCode").map((t) => {
    const codeInfoNode = findNodeOfType(t, "CodeInfo");
    if (!codeInfoNode) {
      return;
    }
    const fenceType = codeInfoNode.children![0].text!;
    if (fenceType !== "space-lua") {
      return;
    }
    const codeTextNode = findNodeOfType(t, "CodeText");
    if (!codeTextNode) {
      // Honestly, this shouldn't happen
      return;
    }
    const codeText = codeTextNode.children![0].text!;
    // Parse out "-- priority: <number>"
    const priority = codeText.match(/--\s*priority:\s*(-?\d+)/)?.[1];

    allSpaceLuas.push({
      ref: `${pageMeta.name}@${t.from!}`,
      tag: "space-lua",
      range: [t.from!, t.to!],
      script: codeText,
      priority: priority !== undefined ? +priority : undefined,
    });
  });
  return Promise.resolve(allSpaceLuas);
}


================================================
FILE: plugs/index/space_style.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import { extractFrontMatter } from "./frontmatter.ts";
import { assertEquals } from "@std/assert";
import { indexSpaceStyle } from "./space_style.ts";

const testPage = `
Hello
\`\`\`space-style
.hello {}
\`\`\`

\`\`\`space-style
/* priority: 10 */
.hello2 {}
\`\`\`
`.trim();

Deno.test("Test space style indexing", async () => {
  createMockSystem();

  const tree = parseMarkdown(testPage);
  const frontmatter = extractFrontMatter(tree);

  const pageMeta: PageMeta = {
    ref: "folder/test",
    name: "folder/test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };

  const objects = await indexSpaceStyle(pageMeta, frontmatter, tree);
  assertEquals(objects.length, 2);
  assertEquals(objects[0].priority, undefined);
  assertEquals(objects[1].priority, 10);
});


================================================
FILE: plugs/index/space_style.ts
================================================
import {
  collectNodesOfType,
  findNodeOfType,
  type ParseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import type { FrontMatter } from "./frontmatter.ts";

export type StyleObject = ObjectValue<{
  style: string;
  priority?: number;
}>;

export function indexSpaceStyle(
  pageMeta: PageMeta,
  _frontmatter: FrontMatter,
  tree: ParseTree,
) {
  const allStyles: StyleObject[] = [];

  // Also collect CSS from custom styles in config
  collectNodesOfType(tree, "FencedCode").map((t) => {
    const codeInfoNode = findNodeOfType(t, "CodeInfo");
    if (!codeInfoNode) {
      return;
    }

    const fenceType = codeInfoNode.children![0].text!;
    if (fenceType !== "space-style") {
      return;
    }

    const codeTextNode = findNodeOfType(t, "CodeText");
    if (!codeTextNode) {
      // Honestly, this shouldn't happen
      return;
    }
    const codeText = codeTextNode.children![0].text!;

    // Parse out /* priority: */
    const priority = codeText.match(/\/\*+\s*priority:\s*(-?\d+)/)?.[1];

    allStyles.push({
      ref: `${pageMeta.name}@${t.from!}`,
      tag: "space-style",
      range: [t.from!, t.to!],
      style: codeText,
      priority: priority !== undefined ? +priority : undefined,
    });
  });

  return Promise.resolve(allStyles);
}


================================================
FILE: plugs/index/table.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import { extractFrontMatter } from "./frontmatter.ts";
import { assertEquals } from "@std/assert";
import { indexTables } from "./table.ts";

const testPage = `
| name | age |
|----------|----------|
| Frank | 20 |
| Fred | 21 |
`.trim();

Deno.test("Test table indexing", async () => {
  createMockSystem();

  const tree = parseMarkdown(testPage);
  const frontmatter = extractFrontMatter(tree);

  const pageMeta: PageMeta = {
    ref: "test",
    name: "test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };

  const datas = await indexTables(pageMeta, frontmatter, tree);
  assertEquals(datas.length, 2);
  assertEquals(datas[0].name, "Frank");
  assertEquals(datas[0].age, "20");
  assertEquals(datas[1].name, "Fred");
  assertEquals(datas[1].age, "21");
});


================================================
FILE: plugs/index/table.ts
================================================
import {
  renderToText,
  replaceNodesMatching,
} from "@silverbulletmd/silverbullet/lib/tree";
import {
  collectNodesMatching,
  collectNodesOfType,
  type ParseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import { extractHashtag } from "@silverbulletmd/silverbullet/lib/tags";
import type { FrontMatter } from "./frontmatter.ts";

type TableRowObject =
  & ObjectValue<{
    tableref: string;
    page: string;
    pos: number;
  }>
  & Record<string, any>;

/**
 * Replace any invalid characters in a string so it can serve as indexed field name in a query
 * @param str the input string
 * @returns All lowercase string with special chars replaced with underscore
 */
function cleanHeaderFieldName(str: string): string {
  return str.replace(/[\W_]+/g, "_").toLowerCase();
}

/**
 * Concat text properties of all child nodes
 * @param nodes
 * @returns text of all child nodes
 */
function concatChildrenTexts(nodes: ParseTree[]): string {
  return nodes.map((c) => c.text).join("").trim();
}

/**
 * Concat text properties of all child nodes, preserving links
 * @param nodes
 * @returns text, preserving links, of all child nodes
 */
function concatChildrenTextsPreserveLinks(nodes: ParseTree[]): string {
  return nodes.map((c) => renderToText(c)).join("").trim();
}

export function indexTables(
  pageMeta: PageMeta,
  _frontmatter: FrontMatter,
  tree: ParseTree,
) {
  const result: ObjectValue<TableRowObject>[] = [];

  collectNodesMatching(
    tree,
    (t) => !!t.type?.startsWith("Table") && t.type !== "TableConstructor",
  ).forEach(
    (table) => {
      const rows = collectNodesOfType(table, "TableRow");
      const header = collectNodesOfType(table, "TableHeader")[0]; //Use first header. As per markdown spec there can only be exactly one
      const headerLabels = collectNodesOfType(header, "TableCell").map((cell) =>
        concatChildrenTexts(cell.children!)
      ).map(cleanHeaderFieldName);
      //console.log("Header labels", headerLabels);

      for (const row of rows) {
        const tags = new Set<string>();
        collectNodesOfType(row, "Hashtag").forEach((h) => {
          // Push tag to the list, removing the initial #
          tags.add(extractHashtag(h.children![0].text!));
        });

        const cells = collectNodesOfType(row, "TableCell");

        const tableRow: TableRowObject = {
          tableref: `${pageMeta.name}@${table.from}`,
          ref: `${pageMeta.name}@${row.from}`,
          tag: "table",
          tags: [...tags],
          page: pageMeta.name,
          pos: row.from!,
          range: [row.from!, row.to!],
        };
        cells.forEach((c, i) => {
          replaceNodesMatching(c, (tree) => {
            if (tree.type === "Hashtag") {
              return null;
            }
          });
          const content = concatChildrenTextsPreserveLinks(c.children!);
          const label = headerLabels[i];
          tableRow[label!] = content;
        });
        result.push(tableRow);
      }
    },
  );

  return Promise.resolve(result);
}


================================================
FILE: plugs/index/tags.test.ts
================================================
import { parseMarkdown } from "../../client/markdown_parser/parser.ts";
import { createMockSystem } from "../../plug-api/system_mock.ts";
import type { PageMeta } from "@silverbulletmd/silverbullet/type/index";
import { extractFrontMatter } from "./frontmatter.ts";
import { assertEquals } from "@std/assert";
import { indexTags } from "./tags.ts";

const testPage = `
#page-tag

* Item #item-tag
* [ ] Task #task-tag
`.trim();

Deno.test("Test tag indexing", async () => {
  createMockSystem();

  const tree = parseMarkdown(testPage);
  const frontmatter = extractFrontMatter(tree);

  const pageMeta: PageMeta = {
    ref: "folder/test",
    name: "folder/test",
    tag: "page",
    created: "",
    lastModified: "",
    perm: "rw",
  };

  const objects = await indexTags(pageMeta, frontmatter, tree);
  assertEquals(objects.length, 3);
  assertEquals(objects[0].name, "page-tag");
  assertEquals(objects[0].parent, "page");
  assertEquals(objects[1].name, "item-tag");
  assertEquals(objects[1].parent, "item");
  assertEquals(objects[2].name, "task-tag");
  assertEquals(objects[2].parent, "task");
});


================================================
FILE: plugs/index/tags.ts
================================================
import type { FrontMatter } from "./frontmatter.ts";
import {
  collectNodesOfType,
  findParentMatching,
  type ParseTree,
  replaceNodesMatching,
  traverseTree,
} from "@silverbulletmd/silverbullet/lib/tree";
import type {
  ObjectValue,
  PageMeta,
} from "@silverbulletmd/silverbullet/type/index";
import type { CompleteEvent } from "@silverbulletmd/silverbullet/type/client";
import { tagRegex } from "../../client/markdown_parser/constants.ts";
import { extractHashtag } from "@silverbulletmd/silverbullet/lib/tags";
import { index } from "@silverbulletmd/silverbullet/syscalls";

export type TagObject = ObjectValue<{
  name: string;
  page: string;
  parent: string;
}>;

/**
 * Handles indexing of page, item and task level tags, data tags are handled in data.ts
 */
export function indexTags(
  pageMeta: PageMeta,
  frontmatter: FrontMatter,
  tree: ParseTree,
) {
  const tags = new Set<string>(); // name:parent
  const pageTags: string[] = frontmatter.tags || [];
  for (const pageTag of pageTags) {
    tags.add(`${pageTag}:page`);
  }
  collectNodesOfType(tree, "Hashtag").forEach((h) => {
    const tagName = extractHashtag(h.children![0].text!);
    // Check if this occurs in the context of a task
    if (findParentMatching(h, (n) => n.type === "Task")) {
      tags.add(`${tagName}:task`);
    } else if (findParentMatching(h, (n) => n.type === "ListItem")) {
      // Or an item
      tags.add(`${tagName}:item`);
    } else if (findParentMatching(h, (n) => n.type === "Paragraph")) {
      // Still indexing this as a page tag
      tags.add(`${tagName}:page`);
    }
  });
  return Promise.resolve([...tags].map((tag) => {
    const [tagName, parent] = tag.split(":");
    return {
      ref: tag,
      tag: "tag",
      name: tagName,
      page: pageMeta.name,
      parent,
    };
  }));
}

export async function tagComplete(completeEvent: CompleteEvent) {
  const inLinkMatch = /(?:\[\[|\[.*\]\()([^\]]*)$/.exec(
    completeEvent.linePrefix,
  );
  if (inLinkMatch) {
    return null;
  }

  const match = /#[^#\s\[\]]+\w*$/.exec(completeEvent.linePrefix);
  if (!match) {
    return null;
  }

  // Query all tags with a matching parent
  const allTags: string[] = await index.queryLuaObjects<string>(
    "tag",
    {
      distinct: true,
      select: { type: "Variable", name: "name", ctx: {} as any },
    },
  );

  return {
    from: completeEvent.pos - match[0].length,
    options: allTags.map((tag) => ({
      label: renderHashtag(tag),
      type: "tag",
    })),
  };
}

export function updateITags<T>(obj: ObjectValue<T>, frontmatter: FrontMatter) {
  const itags = new Set<string>([obj.tag, ...frontmatter.tags || []]);
  for (const tag of obj.tags || []) {
    itags.add(tag);
  }
  for (const tag of obj.itags || []) {
    itags.add(tag);
  }
  obj.itags = [...itags];
}

/** Get markup for a hashtag name with # prefix and angle brackets if necessary */
export function renderHashtag(name: string): string {
  // detect with the same regex as the parser
  const simple: string = "#" + name;
  const match = simple.match(tagRegex);
  if (!match || match[0].length !== simple.length) {
    return `#<${name}>`;
  } else return simple;
}

/**
 * Cleans hashtags from a tree as a side effect
 * @param n
 * @return found hashtags
 */
export function collectTags(n: ParseTree): string[] {
  const tags = new Set<string>();
  traverseTree(n, (n) => {
    if (n.type === "Hashtag") {
      tags.add(extractHashtag(n.children![0].text!));
      return true;
    } else if (n.type === "OrderedList" || n.type === "BulletList") {
      // Don't traverse into sub-lists
      return true;
    }
    return false;
  });
  return [...tags];
}

/**
 * Cleans hashtags from a tree as a side effect
 * @param n
 */
export function cleanTags(n: ParseTree) {
  return replaceNodesMatching(n, (n) => {
    if (n.type === "Hashtag") {
      return null;
    }
    return;
  });
}


================================================
FILE: plugs/index/task.ts
================================================
import {
  config,
  editor,
  events,
  markdown,
  space,
  sync,
} from "@silverbulletmd/silverbullet/syscalls";

import {
  addParentPointers,
  collectNodesMatching,
  findNodeMatching,
  findNodeOfType,
  findParentMatching,
  nodeAtPos,
  type ParseTree,
  renderToText,
} from "@silverbulletmd/silverbullet/lib/tree";
import {
  getNameFromPath,
  getOffsetFromLineColumn,
  isMarkdownPath,
  parseToRef,
} from "@silverbulletmd/silverbullet/lib/ref";
import type { ClickEvent } from "@silverbulletmd/silverbullet/type/client";

export const completeStates = ["x", "X"];

export const incompleteStates = [" "];

export function taskToggle(event: ClickEvent) {
  if (event.altKey) {
    return;
  }
  return taskCycleAtPos(event.pos);
}

async function convertListItemToTask(node: ParseTree) {
  const listMark = node.children![0];
  const originalMark = renderToText(listMark);

  // Determine the task marker based on the original list type
  let taskMarker: string;
  if (originalMark.match(/^\d+\./)) {
    // Numbered list: preserve the number
    taskMarker = originalMark + " [ ]";
  } else {
    // Bullet list: use standard bullet
    taskMarker = "* [ ]";
  }

  await editor.dispatch({
    changes: {
      from: listMark.from,
      to: listMark.to,
      insert: taskMarker,
    },
  });
}

async function removeTaskCheckbox(listItemNode: ParseTree) {
  const taskNode = findNodeOfType(listItemNode, "Task");
  if (!taskNode) {
    console.error("No task node found in list item");
    return;
  }

  //  Task node contains: TaskMark, TaskState, and text content. Keep just list marker and content after the checkbox
  const listMark = listItemNode.children![0];
  const contentAfterCheckbox = taskNode.children!.slice(1); // Skip TaskMark which contains [ ]

  const textContent = contentAfterCheckbox.map(renderToText).join("");

  // Replace entire list item content
  await editor.dispatch({
    changes: {
      from: listItemNode.from!,
      to: listItemNode.to!,
      insert: renderToText(listMark) + textContent,
    },
  });
}

async function cycleTaskState(
  node: ParseTree,
  removeCheckbox: boolean = false,
) {
  const stateText = node.children![1].text!;

  // If removeCheckbox is true and task is complete, remove checkbox entirely
  if (removeCheckbox && completeStates.includes(stateText)) {
    // Convert back to regular list item
    const taskNode = node.parent!;
    const listItemNode = taskNode.parent!;
    await removeTaskCheckbox(listItemNode);
    return;
  }

  let changeTo: string | undefined;
  if (completeStates.includes(stateText)) {
    changeTo = " ";
  } else if (incompleteStates.includes(stateText)) {
    changeTo = "x";
  } else {
    // Not a checkbox, but a custom state
    const allStates = await config.get("taskStates", {});
    const states = Object.keys(allStates);
    console.log("All states", states);
    states.sort();
    // Select a next state
    let currentStateIndex = states.indexOf(stateText);
    if (currentStateIndex === -1) {
      console.error("Unknown state", stateText);
      currentStateIndex = 0;
    }
    const nextStateIndex = (currentStateIndex + 1) % states.length;
    changeTo = states[nextStateIndex];
  }
  await editor.dispatch({
    changes: {
      from: node.children![1].from,
      to: node.children![1].to,
      insert: changeTo,
    },
  });

  const parentWikiLinks = collectNodesMatching(
    node.parent!,
    (n) => n.type === "WikiLinkPage",
  );
  for (const wikiLink of parentWikiLinks) {
    const ref = wikiLink.children![0].text!;
    if (ref.includes("@")) {
      await updateTaskState(ref, stateText, changeTo);
    }
  }

  await events.dispatchEvent("task:stateChange", {
    from: node.parent!.from,
    to: node.parent!.to,
    newState: changeTo,
    text: renderToText(node.parent),
  });
}

export async function updateTaskState(
  path: string,
  oldState: string,
  newState: string,
) {
  const currentPath = await editor.getCurrentPath();
  const ref = parseToRef(path);

  if (
    !ref || !ref.details || !isMarkdownPath(ref.path) ||
    (ref.details.type !== "linecolumn" && ref.details.type !== "position")
  ) {
    console.log("No position found in page ref, skipping", ref);
    return;
  }

  if (ref.path === currentPath) {
    // In current page, just update the task marker with dispatch
    const editorText = await editor.getText();

    const targetPos = ref.details.type === "position"
      ? ref.details.pos
      : getOffsetFromLineColumn(
        editorText,
        ref.details.line,
        ref.details.column,
      );

    // Check if the task state marker is still there
    const targetText = editorText.substring(
      targetPos + 3, // 3 because: "* ["
      targetPos + 3 + oldState.length,
    );
    if (targetText !== oldState) {
      console.error(
        "Reference not a task marker, out of date?",
        targetText,
      );
      return;
    }
    await editor.dispatch({
      changes: {
        from: targetPos + 3,
        to: targetPos + 3 + oldState.length,
        insert: newState,
      },
    });
  } else {
    const pageName = getNameFromPath(ref.path);
    let text = await space.readPage(pageName);

    const referenceMdTree = await markdown.parseMarkdown(text);
    const targetPos = ref.details.type === "position"
      ? ref.details.pos
      : getOffsetFromLineColumn(
        text,
        ref.details.line,
        ref.details.column,
      );

    const itemNode = nodeAtPos(referenceMdTree, targetPos + 1);
    if (!itemNode) {
      console.error(
        "Reference not a valid item, out of date?",
        itemNode,
      );
      return;
    }
    const taskStateNode = findNodeOfType(itemNode, "TaskState");
    if (!taskStateNode) {
      console.error(
        "Cannot find a task state",
        taskStateNode,
      );
      return;
    }
    taskStateNode.children![1].text = newState;
    text = renderToText(referenceMdTree);
    await space.writePage(pageName, text);
    sync.performFileSync(`${pageName}.md`);
  }
}

export async function taskCycleAtPos(pos: number) {
  const text = await editor.getText();
  const mdTree = await markdown.parseMarkdown(text);
  addParentPointers(mdTree);

  let node = nodeAtPos(mdTree, pos);
  if (node) {
    if (node.type === "TaskMark") {
      node = node.parent!;
    }
    if (node.type === "TaskState") {
      await cycleTaskState(node, false);
    }
  }
}

export async function taskCycleCommand() {
  const text = await editor.getText();
  const pos = await editor.getCursor();
  const tree = await markdown.parseMarkdown(text);
  addParentPointers(tree);

  let node = nodeAtPos(tree, pos);
  if (!node) {
    await editor.flashNotification("No task at cursor");
    return;
  }
  if (["BulletList", "Document"].includes(node.type!)) {
    // Likely at the end of the line, let's back up a position
    node = nodeAtPos(tree, pos - 1);
  }
  if (!node) {
    await editor.flashNotification("No task at cursor");
    return;
  }
  const taskNode = node.type === "Task"
    ? node
    : findParentMatching(node!, (n) => n.type === "Task");

  if (taskNode) {
    const taskState = findNodeOfType(taskNode!, "TaskState");
    if (taskState) {
      // Cycle states: [ ] -> [x] -> (remove checkbox) -> [ ]
      await cycleTaskState(taskState, true);
    }
    return;
  }

  // Convert a bullet point to a task
  const listItem = findParentMatching(node!, (n) => n.type === "ListItem");
  if (!listItem) {
    await editor.flashNotification("No task at cursor");
    return;
  }

  // Check if this ListItem already contains a Task (cursor might be at beginning of line)
  const existingTask = findNodeOfType(listItem, "Task");
  if (existingTask) {
    const taskState = findNodeOfType(existingTask, "TaskState");
    if (taskState) {
      await cycleTaskState(taskState, true);
    }
    return;
  }

  convertListItemToTask(listItem);
}

export async function removeCompletedTasksCommand() {
  const tree = await markdown.parseMarkdown(await editor.getText());
  addParentPointers(tree);

  // Taking this ugly approach because the tree is modified in place
  // Just finding and removing one task at a time and then repeating until nothing changes
  const allCompletedStates = completeStates.concat(
    Object.values(await config.get("taskStates", {})).filter((ts: any) =>
      ts.done
    ).map((ts: any) => ts.name),
  );
  while (true) {
    const completedTaskNode = findNodeMatching(tree, (node) => {
      return node.type === "Task" &&
        allCompletedStates.includes(node.children![0].children![1].text!);
    });
    if (completedTaskNode) {
      // Ok got one, let's remove it
      const listItemNode = completedTaskNode.parent!;
      const bulletListNode = listItemNode.parent!;
      // Remove the list item
      const listItemIdx = bulletListNode.children!.indexOf(listItemNode);
      let removeItems = 1;
      if (bulletListNode.children![listItemIdx + 1]?.text === "\n") {
        removeItems++;
      }
      bulletListNode.children!.splice(listItemIdx, removeItems);
    } else {
      // No completed tasks left, we're done
      break;
    }
  }

  await editor.setText(renderToText(tree));
}


================================================
FILE: plugs/index/widget.ts
================================================
import { codeWidget } from "@silverbulletmd/silverbullet/syscalls";

export async function refreshWidgets() {
  await codeWidget.refreshAll();
}


================================================
FILE: plugs/index/yaml.ts
================================================
import YAML from "js-yaml";

export type YamlStringifyOptions = {
  /** indentation width to use (in spaces). */
  indent?: number;
  /** when true, will not add an indentation level to array elements */
  noArrayIndent?: boolean;
  /** do not throw on invalid types (like function in the safe schema) and skip pairs and single values with such types. */
  skipInvalid?: boolean;
  /** specifies level of nesting, when to switch from block to flow style for collections. -1 means block style everwhere */
  flowLevel?: number;
  /** if true, sort keys when dumping YAML. If a function, use the function to sort the keys. (default: false) */
  sortKeys?: boolean;
  /** set max line width. (default: 80) */
  lineWidth?: number;
  /** if true, don't convert duplicate objects into references (default: false) */
  noRefs?: boolean;
  /** if true don't try to be compatible with older yaml versions. Currently: don't quote "yes", "no" and so on, as required for YAML 1.1 (default: false) */
  noCompatMode?: boolean;
  /**
   * if true flow sequences will be condensed, omitting the space between `key: value` or `a, b`. Eg. `'[a,b]'` or `{a:{b:c}}`.
   * Can be useful when using yaml for pretty URL query params as spaces are %-encoded. (default: false).
   */
  condenseFlow?: boolean;
  /** strings will be quoted using this quoting style. If you specify single quotes, double quotes will still be used for non-printable characters. (default: `'`) */
  quotingType?: "'" | '"';
  /** if true, all non-key strings will be quoted even if they normally don't need to. (default: false) */
  forceQuotes?: boolean;
};

export function parse(text: string): any {
  return YAML.load(text);
}

export function stringify(
  text: string,
  options: YamlStringifyOptions = {},
): any {
  return YAML.dump(text, {
    quotingType: '"',
    noCompatMode: true,
    ...options,
  });
}


================================================
FILE: plugs/plug-manager/plugmanager.ts
================================================
import {
  editor,
  events,
  space,
  system,
} from "@silverbulletmd/silverbullet/syscalls";
import { builtinPlugPaths } from "../builtin_plugs.ts";
import type { ResolvedPlug } from "@silverbulletmd/silverbullet/type/event";

export async function reloadPlugsCommand() {
  console.log("Reloading plug...");
  await system.reloadPlugs();
}

export async function updatePlugsCommand() {
  // Save the current file (could be a config page)
  await editor.save();
  // Reload the config and commands
  await editor.reloadConfigAndCommands();
  await editor.flashNotification("Updating plugs...");
  try {
    const plugList: string[] = [];
    let configPlugs: any[] = await system.getConfig("plugs", []);
    if (Object.keys(configPlugs).length === 0) {
      // Handle case of empty Lua table
      configPlugs = [];
    }
    if (!Array.isArray(configPlugs)) {
      throw new Error("Expected 'plugs' in Space Config to be an array");
    }
    const stringPlugs = configPlugs.filter((plug) => typeof plug === "string");
    if (stringPlugs.length !== configPlugs.length) {
      throw new Error(
        `${
          configPlugs.length - stringPlugs.length
        } plugs in Space Config aren't set as strings`,
      );
    }
    plugList.push(...stringPlugs);

    // De-duplicate URIs, this is safe because by definition they point to the same plug
    plugList.forEach((uri, index) => {
      if (plugList.indexOf(uri) !== index) {
        plugList.splice(index, 1);
      }
    });

    console.log("Found Plug URIs:", plugList);
    const allCustomPlugPaths: string[] = [];
    for (const plugUri of plugList) {
      const [protocol, ...rest] = plugUri.split(":");

      const manifests = await events.dispatchEvent(
        `get-plug:${protocol}`,
        rest.join(":"),
      );

      if (manifests.length === 0) {
        console.error("Could not resolve plug uri", plugUri);
      } else if (manifests.length > 1) {
        console.error(
          `Got multiple results for plug uri ${plugUri}. Proceeding with the first result`,
        );
      }

      const manifest = manifests[0] as unknown;

      let code: string;
      if (typeof manifest === "string") {
        // "Legacy" syntax
        code = manifest;
      } else if (
        manifest &&
        typeof manifest === "object" &&
        "code" in manifest &&
        typeof manifest.code === "string"
      ) {
        code = manifest.code;
      } else {
        console.error(
          "Invalid return from `get-plug` event. Please return value of type `{ code: string, name?: string } | string`",
        );

        continue;
      }

      let name: string;
      if (
        typeof manifest !== "object" ||
        !("name" in manifest) ||
        typeof manifest.name !== "string"
      ) {
        // Try taking a good guess at a name if it isn't provided
        const match = /\/([^\/]+)\.plug\.js$/.exec(plugUri);
        if (!match) {
          console.error(
            `No plug name provided and could not extract name from ${plugUri} ignoring...`,
          );

          continue;
        }

        name = match[1];
      } else {
        name = manifest.name;
      }

      const path = `_plug/${name}.plug.js`;

      // Validate the extracted path
      if (builtinPlugPaths.includes(path)) {
        throw new Error(
          `Plug '${path}' is conflicting with a built-in plug`,
        );
      }
      if (allCustomPlugPaths.includes(path)) {
        throw new Error(
          `Plug '${path}' defined by more than one URI`,
        );
      }

      allCustomPlugPaths.push(path);

      console.log("Writing", path);
      await space.writeDocument(path, new TextEncoder().encode(code));
    }

    const allPlugPaths = [...builtinPlugPaths, ...allCustomPlugPaths];
    // And delete extra ones
    for (const { name: existingPlug } of await space.listPlugs()) {
      // Only delete plugs installed (for legacy reason) installed under _plug
      if (
        existingPlug.startsWith("_plug/") &&
        !allPlugPaths.includes(existingPlug)
      ) {
        console.log("Deleting", existingPlug);
        await space.deleteDocument(existingPlug);
      }
    }
    await editor.flashNotification("All done!");
    system.reloadPlugs();
  } catch (e: any) {
    editor.flashNotification("Error updating plugs: " + e.message, "error");
  }
}

export async function getPlugHTTPS(url: string): Promise<ResolvedPlug> {
  const fullUrl = `https:${url}`;
  console.log("Now fetching plug code from", fullUrl);
  const req = await fetch(fullUrl);
  if (req.status !== 200) {
    throw new Error(`Could not fetch plug code from ${fullUrl}`);
  }
  return req.text();
}

export function getPlugGithub(identifier: string): Promise<ResolvedPlug> {
  const [owner, repo, path] = identifier.split("/");
  let [repoClean, branch] = repo.split("@");
  if (!branch) {
    branch = "main"; // or "master"?
  }
  return getPlugHTTPS(
    `//raw.githubusercontent.com/${owner}/${repoClean}/${branch}/${path}`,
  );
}

export async function getPlugGithubRelease(
  identifier: string,
): Promise<ResolvedPlug> {
  let [owner, repo, version] = identifier.split("/");
  let releaseInfo: any = {};
  let req: Response;
  if (!version || version === "latest") {
    console.log(`Fetching release manifest of latest version for ${repo}`);
    req = await fetch(
      `https://api.github.com/repos/${owner}/${repo}/releases/latest`,
    );
  } else {
    console.log(`Fetching release manifest of version ${version} for ${repo}`);
    req = await fetch(
      `https://api.github.com/repos/${owner}/${repo}/releases/tags/${version}`,
    );
  }
  if (req.status !== 200) {
    throw new Error(
      `Could not fetch release manifest from ${identifier}`,
    );
  }
  releaseInfo = await req.json();
  version = releaseInfo.tag_name;

  let assetName: string | undefined;
  // Support plug like foo.plug.js are in repo silverbullet-foo
  const shortName = repo.startsWith("silverbullet-")
    ? repo.slice("silverbullet-".length)
    : undefined;
  for (const asset of releaseInfo.assets ?? []) {
    if (
      asset.name === `${repo}.plug.js` ||
      shortName && asset.name === `${shortName}.plug.js`
    ) {
      assetName = asset.name;
      break;
    }
  }
  if (!assetName) {
    throw new Error(
      `Could not find "${repo}.plug.js"` +
        (shortName ? ` or "${shortName}.plug.js"` : "") +
        ` in release ${version}`,
    );
  }

  const finalUrl =
    `//github.com/${owner}/${repo}/releases/download/${version}/${assetName}`;
  const code = await getPlugHTTPS(finalUrl);
  return {
    code: typeof code === "string" ? code : code.code,
    name: shortName ?? repo,
  };
}


================================================
FILE: plugs/sync/sync.ts
================================================
import {
  editor,
  index,
  space,
  sync,
} from "@silverbulletmd/silverbullet/syscalls";

export async function syncSpaceCommand() {
  await editor.flashNotification("Syncing space...");
  await sync.performSpaceSync();
  await editor.flashNotification("Done.");
}

export async function syncFileCommand() {
  await editor.flashNotification("Syncing file...");
  await sync.performFileSync(await editor.getCurrentPath());
  await editor.flashNotification("Done.");
}

export async function spaceSyncComplete(message: { operations: number }) {
  if (message.operations > 0) {
    // Update the page list
    await space.listFiles();
  }

  index.ensureFullIndex();
}

export async function updateSyncStatus(
  event: {
    status: {
      filesProcessed: number;
      totalFiles: number;
    };
  },
) {
  // Update the status in the UI
  const percentage = Math.round(
    (event.status.filesProcessed / event.status.totalFiles) * 100,
  );
  if (percentage >= 99) {
    // Just hide it
    await editor.showProgress();
  } else {
    await editor.showProgress(
      percentage,
      "sync",
    );
  }
}

export async function reportSyncConflict({ path }: { path: string }) {
  await editor.flashNotification(
    `Sync: conflict detected for ${path} - conflict copy created`,
    "error",
  );
}


================================================
FILE: scripts/deploy_silverbullet_md.sh
================================================
#!/bin/bash -e

cd $(realpath $(dirname $(dirname $0)))

IMAGE_NAME="ghcr.io/silverbulletmd/silverbullet:v2"
WEBSITE_SPACE="./website"
WEBSITE_PORT=3002

# Get the current image ID
BEFORE_PULL=$(docker images -q $IMAGE_NAME)

# Pull the Docker image
docker pull $IMAGE_NAME

# Get the new image ID
AFTER_PULL=$(docker images -q $IMAGE_NAME)

# Compare the image IDs
if [ "$BEFORE_PULL" == "$AFTER_PULL" ]; then
    echo "The Docker image has not been updated. Done!"
    exit 0
fi

echo "The Docker image has been updated, let's update the website content."
git pull

# Check if a silverbullet container is running already
if [ "$(docker ps -q -f name=silverbullet-v2)" ]; then
    echo "A silverbullet container is running, let's stop it."
    docker kill silverbullet-v2
    docker rm silverbullet-v2
fi

# Remove the old database
echo "Removing old databases"
rm -f $WEBSITE_SPACE/.silverbullet.db*

echo "Starting new silverbullet container"
docker run -d --name silverbullet-v2 --restart unless-stopped -v $WEBSITE_SPACE:/space -e SB_INDEX_PAGE=SilverBullet -e SB_READ_ONLY=1 -p $WEBSITE_PORT:3000 $IMAGE_NAME


echo "Waiting for the servers to start"

# Repeatedly try to connect to the server until it responds
until $(curl --output /dev/null --silent --head --fail http://localhost:$WEBSITE_PORT/); do
    printf '.'
    sleep 1
done
echo
echo "We're back up!"

================================================
FILE: scripts/deploy_silverbullet_playground.sh
================================================
#!/bin/bash -e

cd $(realpath $(dirname $(dirname $0)))

IMAGE_NAME="ghcr.io/silverbulletmd/silverbullet:v2"
PLAYGROUND_SPACE="/tmp/silverbullet-playground"
PLAYGROUND_PORT=3001
SB_USER=silverbullet:silverbullet

# Now do the same for the playground
echo "Now resetting the playground"

# Check if a silverbullet container is running already
if [ "$(docker ps -q -f name=silverbullet-playground)" ]; then
    echo "A playground container is running, let's stop it."
    docker kill silverbullet-playground
    docker rm silverbullet-playground
fi

# Remove the old space
echo "Removing old playground space"
rm -rf $PLAYGROUND_SPACE

echo "Creating fresh playground space"
mkdir -p $PLAYGROUND_SPACE
cp -r scripts/playground_space/* $PLAYGROUND_SPACE/

echo "Starting new playground container"
docker run -d --name silverbullet-playground --restart unless-stopped -v $PLAYGROUND_SPACE:/space -e SB_SHELL_BACKEND=off -e SB_USER=$SB_USER -p $PLAYGROUND_PORT:3000 $IMAGE_NAME

echo "Waiting for the server to start"

until $(curl --output /dev/null --silent --head --fail http://localhost:$PLAYGROUND_PORT/); do
    printf '.'
    sleep 1
done
echo
echo "We're back up!"

================================================
FILE: scripts/playground_space/index.md
================================================
# Welcome to the SilverBullet Playground
This is a fully functional SilverBullet instance that **resets every 15 minutes.**

So go ahead, play around, but don't keep anything valuable here. It will be gone in a snap.

## Don’t just sit there, try it!
* Click on the page picker (book icon) icon at the top right, or hit `Cmd-k` (Mac) or `Ctrl-k` (Linux and Windows) to open the **page switcher**. Type the name of a non-existent page to create it (although it won’t save in this environment).
* Click on the terminal button (top right) or hit `Cmd-/` (Mac) or `Ctrl-/` (Linux and Windows) to open the **command palette** (note that not all commands will work in this mode).
* Select some text and hit `Cmd-b` (Mac) or `Ctrl-b` (Windows/Linux) to make it **bold**, or `Cmd-i` (Mac) or `Ctrl-i` (Windows/Linux) to make it _italic_.
* Click a link somewhere on this page to navigate there.
* Start typing `[[` somewhere to insert a page link (with completion).
* [ ] Tap this box 👈 to mark this task as done.
* Start typing `:party` to trigger the emoji picker 🎉
* Type `/` somewhere in the text to invoke a **slash command**.
* Click this button {[Editor: Toggle Vim Mode]} to toggle Vim mode
* Open this site on your phone or tablet and... it just works!
* Are you using a browser with **PWA support** (e.g., any Chromium-based
  browser or recent version of Safari)? Click on that little icon to the right of your location bar that says “Install SilverBullet” to give SB its own window frame and desktop icon, like it is a stand-alone app. Now, unplug your network cable and reload the page. It still works!


================================================
FILE: scripts/release.sh
================================================
#!/bin/bash -e

VERSION=$1

# Patch version is version.ts and deno.json
echo "export const version = \"$VERSION\";" > version.ts
sed -i '' "s/\(\"version\": \"\)[^\"]*\(\"\)/\1$VERSION\2/" deno.json
git commit -am $VERSION
git tag $VERSION
git push && git push --tags


================================================
FILE: server/auth.go
================================================
package server

import (
	"fmt"
	"html/template"
	"log"
	"math"
	"net/http"
	"path"
	"strings"
	"sync"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/render"
)

// path to auth page in the client bundle
const authPagePath = ".client/auth.html"

// These endpoints are not protected by auth
var excludedPaths = []string{
	"/service_worker.js", // because browser fetch this without sending any cookie (auth) headers
	"/.client/",          // to allow a basic UI to load, not 100% sure this is necessary to exclude
	"/.auth",             // otherwise the authentication UI would be behind authentication
	"/.ping",             // because docker needs to be able to access this for its health check
}

func addAuthEndpoints(r chi.Router, config *ServerConfig) {
	// Logout
	r.Get("/.logout", func(w http.ResponseWriter, r *http.Request) {
		host := extractHost(r)
		cookieOptions := CookieOptions{
			Path: fmt.Sprintf("%s/", config.HostURLPrefix),
		}

		deleteCookie(w, authCookieName(host), cookieOptions)
		deleteCookie(w, "refreshLogin", cookieOptions)

		http.Redirect(w, r, applyURLPrefix("/.auth", config.HostURLPrefix), http.StatusFound)
	})

	// Auth page
	r.Get("/.auth", func(w http.ResponseWriter, r *http.Request) {
		spaceConfig := spaceConfigFromContext(r.Context())
		if spaceConfig.Auth == nil {
			http.Error(w, "Authentication not enabled", http.StatusForbidden)
			return
		}
		if err := spaceConfig.InitAuth(); err != nil {
			http.Error(w, "Failed to initialize authentication", http.StatusInternalServerError)
			return
		}

		data, _, err := config.ClientBundle.ReadFile(authPagePath)
		if err != nil {
			http.Error(w, "Auth page not found", http.StatusNotFound)
			return
		}

		tpl := template.Must(template.New("auth").Parse(string(data)))

		templateData := map[string]any{
			"HostPrefix":      config.HostURLPrefix,
			"SpaceName":       spaceConfig.SpaceName,
			"EncryptionSalt":  spaceConfig.JwtIssuer.Salt,
			"RememberMeDays":  spaceConfig.Auth.RememberMeHours / 24,
		}

		w.Header().Set("Content-type", "text/html")
		w.WriteHeader(http.StatusOK)
		if err := tpl.Execute(w, templateData); err != nil {
			log.Printf("Could not render auth page: %v", err)
			w.Write([]byte("Server error"))
		}
	})

	// Auth POST endpoint
	r.Post("/.auth", func(w http.ResponseWriter, r *http.Request) {
		spaceConfig := spaceConfigFromContext(r.Context())
		if err := spaceConfig.InitAuth(); err != nil {
			http.Error(w, "Failed to initialize authentication", http.StatusInternalServerError)
			return
		}
		if err := r.ParseForm(); err != nil {
			http.Error(w, "Failed to parse form", http.StatusBadRequest)
			return
		}

		username := r.FormValue("username")
		password := r.FormValue("password")
		rememberMe := r.FormValue("rememberMe")
		from := r.FormValue("from")

		if username == "" || password == "" {
			http.Redirect(w, r, applyURLPrefix("/.auth?error=0", config.HostURLPrefix), http.StatusFound)
			return
		}

		if spaceConfig.LockoutTimer.IsLocked() {
			log.Println("Authentication locked out, redirecting to auth page.")
			http.Redirect(w, r, applyURLPrefix("/.auth?error=2", config.HostURLPrefix), http.StatusFound)
			return
		}

		if spaceConfig.Authorize(username, password) {
			// Generate JWT with username (not currently used)
			payload := map[string]any{
				"username": username,
			}

			var jwt string
			var err error
			if rememberMe != "" {
				rememberMeExpirySeconds := spaceConfig.Auth.RememberMeHours * 60 * 60
				jwt, err = spaceConfig.JwtIssuer.CreateJWT(payload, rememberMeExpirySeconds)
			} else {
				jwt, err = spaceConfig.JwtIssuer.CreateJWT(payload, authenticationExpirySeconds)
			}

			if err != nil {
				log.Printf("Failed to create JWT: %v\n", err)
				http.Error(w, "Internal server error", http.StatusInternalServerError)
				return
			}

			host := extractHost(r)
			expirySeconds := authenticationExpirySeconds
			if rememberMe != "" {
				expirySeconds = spaceConfig.Auth.RememberMeHours * 60 * 60
			}
			expires := time.Now().Add(time.Duration(expirySeconds) * time.Second)			

			cookieOptions := CookieOptions{
				Path:    fmt.Sprintf("%s/", config.HostURLPrefix),
				Expires: expires,
			}

			setCookie(w, authCookieName(host), jwt, cookieOptions)

			if rememberMe != "" {
				setCookie(w, "refreshLogin", "true", cookieOptions)
			}

			redirectPath := applyURLPrefix("/", config.HostURLPrefix)
			if from != "" {
				redirectPath = from
			}

			render.JSON(w, r, map[string]any{
				"status":   "ok",
				"redirect": redirectPath,
			})
		} else {
			log.Println("Authentication failed, redirecting to auth page.")
			spaceConfig.LockoutTimer.AddCount()

			render.JSON(w, r, map[string]any{
				"status": "error",
				"error":  "Invalid username and/or password",
			})
		}
	})
}

func (spaceConfig *SpaceConfig) InitAuth() error {
	if spaceConfig.JwtIssuer == nil {
		spaceConfig.authMutex.Lock()
		defer spaceConfig.authMutex.Unlock()

		var err error
		// Need to do some initialization
		spaceConfig.JwtIssuer, err = CreateAuthenticator(path.Join(spaceConfig.SpaceFolderPath, ".silverbullet.auth.json"), spaceConfig.Auth)
		if err != nil {
			return err
		}

		// Initialize lockout timer
		if spaceConfig.Auth.LockoutLimit > 0 {
			spaceConfig.LockoutTimer = NewLockoutTimer(spaceConfig.Auth.LockoutTime*1000, spaceConfig.Auth.LockoutLimit)
		} else {
			spaceConfig.LockoutTimer = NewLockoutTimer(0, 0) // disabled
		}
	}
	return nil
}

// authMiddleware provides authentication middleware
func authMiddleware(config *ServerConfig) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			var err error
			spaceConfig := spaceConfigFromContext(r.Context())
			if spaceConfig.Auth == nil {
				// No authentication to do, moving on
				next.ServeHTTP(w, r)
				return
			}

			path := removeURLPrefix(r.URL.Path, config.HostURLPrefix)
			host := extractHost(r)

			if isExcludedPath(path) {
				next.ServeHTTP(w, r)
				return
			}

			if err := spaceConfig.InitAuth(); err != nil {
				http.Error(w, "Failed to initialize authentication", http.StatusInternalServerError)
				return
			}

			authCookie := getCookie(r, authCookieName(host))

			// Try Bearer token authentication if no cookie
			if authCookie == "" && spaceConfig.Auth.AuthToken != "" {
				authHeader := r.Header.Get("Authorization")
				if after, ok := strings.CutPrefix(authHeader, "Bearer "); ok {
					authToken := after
					if authToken == spaceConfig.Auth.AuthToken {
						next.ServeHTTP(w, r)
						return
					} else {
						log.Println("Unauthorized token access")
						http.Error(w, "Unauthorized", http.StatusUnauthorized)
						return
					}
				}
			}

			if authCookie == "" {
				log.Printf("Unauthorized access to %s, redirecting to auth page", path)
				redirectToAuth(w, "/.auth", path, config.HostURLPrefix)
				return
			}

			// Verify JWT
			claims, err := spaceConfig.JwtIssuer.VerifyAndDecodeJWT(authCookie)
			if err != nil {
				log.Printf("Error verifying JWT on %s, redirecting to auth page: %v\n", path, err)
				redirectToAuth(w, "/.auth", path, config.HostURLPrefix)
				return
			}

			_, ok := claims["username"].(string)
			if !ok {
				log.Printf("Username mismatch in JWT on %s", path)
				redirectToAuth(w, "/.auth", path, config.HostURLPrefix)
				return
			}

			refreshLogin(w, r, config, host, spaceConfig)
			next.ServeHTTP(w, r)
		})
	}
}

// refreshLogin refreshes the login cookie if needed
func refreshLogin(w http.ResponseWriter, r *http.Request, config *ServerConfig, host string, spaceConfig *SpaceConfig) {
	// if cookie doesn't exist, return
	if getCookie(r, "refreshLogin") != "" {
		return
	}
	oldJwt := getCookie(r, authCookieName(host))
	if oldJwt == "" {
		return
	}
	
	claims, err := spaceConfig.JwtIssuer.VerifyAndDecodeJWT(oldJwt)
	if err != nil {
		return
	}

	// Create new JWT with fresh expiry
	expirySeconds := spaceConfig.Auth.RememberMeHours * 60 * 60
	payload := map[string]any{"username": claims["username"]}
	newJwt, err := spaceConfig.JwtIssuer.CreateJWT(payload, expirySeconds)
	if err != nil {
		return
	}

	expires := time.Now().Add(time.Duration(expirySeconds) * time.Second)
	cookieOptions := CookieOptions{
		Path:    fmt.Sprintf("%s/", config.HostURLPrefix),
		Expires: expires,
	}

	setCookie(w, authCookieName(host), newJwt, cookieOptions)
	setCookie(w, "refreshLogin", "true", cookieOptions)
}

// LockoutTimer implements a simple rate limiter to prevent brute force attacks
type LockoutTimer struct {
	mutex       sync.Mutex
	bucketTime  int64
	bucketCount int
	bucketSize  int64 // duration in milliseconds
	limit       int
	disabled    bool
}

// NewLockoutTimer creates a new lockout timer
// countPeriodMs: time window in milliseconds
// limit: maximum attempts allowed in the time window
func NewLockoutTimer(countPeriodMs int, limit int) *LockoutTimer {
	disabled := math.IsNaN(float64(countPeriodMs)) || math.IsNaN(float64(limit)) ||
		countPeriodMs < 1 || limit < 1

	return &LockoutTimer{
		bucketSize: int64(countPeriodMs),
		limit:      limit,
		disabled:   disabled,
	}
}

// updateBucketTime updates the current bucket time and resets count if needed
func (lt *LockoutTimer) updateBucketTime() {
	currentBucketTime := time.Now().UnixMilli() / lt.bucketSize
	if lt.bucketTime == currentBucketTime {
		return
	}
	// the bucket is too old - empty it
	lt.bucketTime = currentBucketTime
	lt.bucketCount = 0
}

// IsLocked checks if the timer is currently locked due to too many attempts
func (lt *LockoutTimer) IsLocked() bool {
	if lt.disabled {
		return false
	}

	lt.mutex.Lock()
	defer lt.mutex.Unlock()

	lt.updateBucketTime()
	return lt.bucketCount >= lt.limit
}

// AddCount increments the attempt counter
// IsLocked() should be called first to keep bucketTime current
func (lt *LockoutTimer) AddCount() {
	if lt.disabled {
		return
	}

	lt.mutex.Lock()
	defer lt.mutex.Unlock()

	// updateBucketTime should have been called by IsLocked first
	lt.bucketCount++
}


================================================
FILE: server/auth_utils.go
================================================
package server

import (
	"fmt"
	"net/http"
	"regexp"
	"strings"
	"time"
)

const authenticationExpirySeconds = 60 * 60 * 24 * 7 // 1 week

// authCookieName generates a cookie name based on the host
func authCookieName(host string) string {
	// Replace non-word characters with underscores
	re := regexp.MustCompile(`\W`)
	cleanHost := re.ReplaceAllString(host, "_")
	return fmt.Sprintf("auth_%s", cleanHost)
}

// utcDateString converts a timestamp to UTC date string
func utcDateString(mtime int64) string {
	return time.Unix(mtime/1000, 0).UTC().Format(time.RFC1123)
}

// setCookie sets an HTTP cookie
func setCookie(w http.ResponseWriter, name, value string, options CookieOptions) {
	cookie := &http.Cookie{
		Name:  name,
		Value: value,
		Path:  options.Path,
	}

	if !options.Expires.IsZero() {
		cookie.Expires = options.Expires
	}

	http.SetCookie(w, cookie)
}

// getCookie gets an HTTP cookie value
func getCookie(r *http.Request, name string) string {
	cookie, err := r.Cookie(name)
	if err != nil {
		return ""
	}
	return cookie.Value
}

// deleteCookie deletes an HTTP cookie
func deleteCookie(w http.ResponseWriter, name string, options CookieOptions) {
	cookie := &http.Cookie{
		Name:    name,
		Value:   "",
		Path:    options.Path,
		Expires: time.Unix(0, 0),
		MaxAge:  -1,
	}
	http.SetCookie(w, cookie)
}

// CookieOptions represents cookie configuration options
type CookieOptions struct {
	Path     string
	Expires  time.Time
	HttpOnly bool
	Secure   bool
	SameSite string
}

func extractHost(r *http.Request) string {
	host := r.Host
	if host == "" {
		host = r.Header.Get("Host")
	}
	return host
}

// redirectToAuth creates a redirect response to the auth page
func redirectToAuth(w http.ResponseWriter, authPath, fromPath, hostURLPrefix string) {
	var redirectURL string

	// Try filtering api paths
	if strings.HasPrefix(fromPath, "/.") || strings.HasSuffix(fromPath, ".md") {
		w.Header().Set("Location", applyURLPrefix(authPath, hostURLPrefix))
		w.WriteHeader(http.StatusUnauthorized)
		return
	} else {
		redirectURL = fmt.Sprintf("%s?from=%s", authPath, fromPath)
	}

	w.Header().Set("Location", applyURLPrefix(redirectURL, hostURLPrefix))
	w.WriteHeader(http.StatusFound)
}

// applyURLPrefix applies a URL prefix to a path
func applyURLPrefix(path, prefix string) string {
	if prefix == "" {
		return path
	}
	return prefix + path
}

// removeURLPrefix removes a URL prefix from a path
func removeURLPrefix(path, prefix string) string {
	if prefix == "" {
		return path
	}
	if strings.HasPrefix(path, prefix) {
		return path[len(prefix):]
	}
	return path
}

// isExcludedPath checks if a path should be excluded from authentication
func isExcludedPath(path string) bool {
	if path == "/" {
		return true
	}
	// Check if path starts with any item in excludedPaths
	for _, excludedPath := range excludedPaths {
		if strings.HasPrefix(path, excludedPath) {
			return true
		}
	}
	return false
}

// parseFormValue safely extracts a form value
func parseFormValue(r *http.Request, key string) string {
	return r.FormValue(key)
}


================================================
FILE: server/cmd/server.go
================================================
package cmd

import (
	_ "embed"
	"errors"
	"fmt"
	"html/template"
	"io/fs"
	"log"
	"net/http"
	"os"
	"strconv"
	"strings"
	"time"

	"github.com/silverbulletmd/silverbullet/server"
	"github.com/spf13/cobra"
)

func buildConfig(bundledFiles fs.FS, args []string) *server.ServerConfig {
	serverConfig := &server.ServerConfig{
		BindHost: "127.0.0.1",
		Port:     3000,
	}

	rootSpaceConfig := &server.SpaceConfig{
		IndexPage:        "index",
		SpaceName:        "SilverBullet",
		SpaceDescription: "Powerful and programmable note taking app",
	}

	// For now just point every request to the rootSpaceConfig
	serverConfig.SpaceConfigResolver = func(r *http.Request) (*server.SpaceConfig, error) {
		return rootSpaceConfig, nil
	}

	if os.Getenv("SB_HOSTNAME") != "" {
		serverConfig.BindHost = os.Getenv("SB_HOSTNAME")
	}

	if os.Getenv("SB_PORT") != "" {
		// Parse integer from string
		port, err := strconv.ParseInt(os.Getenv("SB_PORT"), 10, 32)
		if err != nil {
			log.Fatalf("Could not parse numeric port from SB_PORT: %v", err)
		}
		serverConfig.Port = int(port)
	}

	if os.Getenv("SB_INDEX_PAGE") != "" {
		rootSpaceConfig.IndexPage = os.Getenv("SB_INDEX_PAGE")
	}
	if len(args) > 0 {
		rootSpaceConfig.SpaceFolderPath = args[0]
	}
	if os.Getenv("SB_FOLDER") != "" {
		rootSpaceConfig.SpaceFolderPath = os.Getenv("SB_FOLDER")
	}

	if os.Getenv("SB_LOG_PUSH") != "" {
		rootSpaceConfig.LogPush = true
		log.Println("Client log push enabled")
	}

	rootSpaceConfig.GitIgnore = os.Getenv("SB_SPACE_IGNORE")

	if os.Getenv("SB_SPACE_IGNORE") != "" {
		log.Printf("Ignoring files matching: %s", os.Getenv("SB_SPACE_IGNORE"))
	}

	if rootSpaceConfig.SpaceFolderPath == "" {
		log.Fatal("No folder specified. Please pass a folder as an argument or set SB_FOLDER environment variable.")
	}

	var spacePrimitives server.SpacePrimitives
	spacePrimitives, err := server.NewDiskSpacePrimitives(rootSpaceConfig.SpaceFolderPath, rootSpaceConfig.GitIgnore)

	rootSpaceConfig.ReadOnlyMode = os.Getenv("SB_READ_ONLY") != ""

	if rootSpaceConfig.ReadOnlyMode {
		log.Println("Starting in read-only mode.")
	}

	if rootSpaceConfig.ReadOnlyMode {
		// Wrap the space primitives in read only mode
		spacePrimitives = server.NewReadOnlySpacePrimitives(spacePrimitives)
	}

	if err != nil {
		log.Fatal(err)
	}

	serverConfig.EnableHTTPLogging = os.Getenv("SB_HTTP_LOGGING") != ""

	if os.Getenv("SB_METRICS_PORT") != "" {
		serverConfig.MetricsPort, err = strconv.Atoi(os.Getenv("SB_METRICS_PORT"))
		if err != nil {
			log.Fatalf("Could not parse SB_METRICS_PORT as number: %v", err)
		}
	}

	if os.Getenv("SB_USER") != "" {
		pieces := strings.Split(os.Getenv("SB_USER"), ":")
		if len(pieces) != 2 {
			log.Fatal("SB_USER must be in the format user:pass")
		}

		rootSpaceConfig.Auth = &server.AuthOptions{
			User:         pieces[0],
			Pass:         pieces[1],
			AuthToken:    os.Getenv("SB_AUTH_TOKEN"),
			LockoutLimit: 10,
			LockoutTime:  60,
		}

		rootSpaceConfig.Authorize = func(username, password string) bool {
			return username == rootSpaceConfig.Auth.User && password == rootSpaceConfig.Auth.Pass
		}

		if os.Getenv("SB_LOCKOUT_LIMIT") != "" {
			rootSpaceConfig.Auth.LockoutLimit, err = strconv.Atoi(os.Getenv("SB_LOCKOUT_LIMIT"))
			if err != nil {
				log.Fatalf("Could not parse SB_LOCKOUT_LIMIT as number: %v", err)
			}

		}

		if os.Getenv("SB_LOCKOUT_TIME") != "" {
			rootSpaceConfig.Auth.LockoutTime, err = strconv.Atoi(os.Getenv("SB_LOCKOUT_TIME"))
			if err != nil {
				log.Fatalf("Could not parse SB_LOCKOUT_TIME as number: %v", err)
			}
		}

		rootSpaceConfig.Auth.RememberMeHours = 168 // default 7 days
		if os.Getenv("SB_REMEMBER_ME_HOURS") != "" {
			rootSpaceConfig.Auth.RememberMeHours, err = strconv.Atoi(os.Getenv("SB_REMEMBER_ME_HOURS"))
			if err != nil {
				log.Fatalf("Could not parse SB_REMEMBER_ME_HOURS as number: %v", err)
			}
		}

		log.Printf("User authentication enabled for user \"%s\" with lockout limit %d and lockout time %ds",
			pieces[0], rootSpaceConfig.Auth.LockoutLimit, rootSpaceConfig.Auth.LockoutTime)
	}

	if os.Getenv("SB_NAME") != "" {
		rootSpaceConfig.SpaceName = os.Getenv("SB_NAME")
	}

	if os.Getenv("SB_DESCRIPTION") != "" {
		rootSpaceConfig.SpaceDescription = os.Getenv("SB_DESCRIPTION")
	}

	rootSpaceConfig.AdditionalHeadHTML = template.HTML(os.Getenv("SB_HEAD_HTML"))

	if os.Getenv("SB_URL_PREFIX") != "" {
		hostUrlPrefix := os.Getenv("SB_URL_PREFIX")
		// Normalize the host URL prefix (if set)
		if !strings.HasPrefix(hostUrlPrefix, "/") {
			hostUrlPrefix = "/" + hostUrlPrefix
		}
		hostUrlPrefix = strings.TrimRight(hostUrlPrefix, "/")

		if hostUrlPrefix != "" {
			log.Printf("Host URL Prefix: %s", hostUrlPrefix)
			serverConfig.HostURLPrefix = hostUrlPrefix
		}
	}

	// Extract the last modified time from the main binary, best effort
	bundlePathDate := time.Now()
	if executablePath, err := os.Executable(); err == nil {
		if stat, err := os.Stat(executablePath); err == nil {
			bundlePathDate = stat.ModTime()
		}
	}

	serverConfig.ClientBundle = server.NewReadOnlyFallthroughSpacePrimitives(bundledFiles, "client", bundlePathDate, nil)
	rootSpaceConfig.SpacePrimitives = server.NewReadOnlyFallthroughSpacePrimitives(bundledFiles, "base_fs", bundlePathDate, spacePrimitives)

	if serverConfig.BindHost == "127.0.0.1" {
		log.Println("SilverBullet will only be available locally, to allow outside connections, pass -L0.0.0.0 as a flag, and put a TLS terminator on top.")
	}

	// Initialize shell backend
	backendConfig := os.Getenv("SB_SHELL_BACKEND")
	if backendConfig == "" && !rootSpaceConfig.ReadOnlyMode {
		localShell := server.NewLocalShell(rootSpaceConfig.SpaceFolderPath, os.Getenv("SB_SHELL_WHITELIST"))
		rootSpaceConfig.ShellBackend = localShell
		if localShell.AllowAllCmds {
			log.Println("Local shell command execution enabled for ALL commands.")
		} else {
			log.Printf("Local shell command execution enabled for: %v", localShell.CmdWhiteList)
		}
	} else {
		log.Println("Shell running disabled.")
		rootSpaceConfig.ShellBackend = server.NewNotSupportedShell()
	}

	// Ensure at least the index page and config page exist
	ensureIndexAndConfig(rootSpaceConfig)

	return serverConfig
}

//go:embed space_template/index.md
var indexPageContent []byte

//go:embed space_template/CONFIG.md
var configPageContent []byte

func ensureIndexAndConfig(rootSpaceConfig *server.SpaceConfig) {
	// Index page first
	indexPagePath := fmt.Sprintf("%s.md", rootSpaceConfig.IndexPage)
	_, err := rootSpaceConfig.SpacePrimitives.GetFileMeta(indexPagePath)
	if err == server.ErrNotFound {
		log.Printf("Index page %s does not yet exist, creating...", indexPagePath)
		if _, err := rootSpaceConfig.SpacePrimitives.WriteFile(indexPagePath, indexPageContent, nil); err != nil {
			log.Fatalf("Could not write index page %s: %v", indexPagePath, err)
		}
	}
	// Now let's check for a CONFIG.md
	configPagePath := "CONFIG.md"
	_, err = rootSpaceConfig.SpacePrimitives.GetFileMeta(configPagePath)
	if errors.Is(err, server.ErrNotFound) {
		log.Printf("Config page %s does not yet exist, creating...", configPagePath)
		if _, err := rootSpaceConfig.SpacePrimitives.WriteFile(configPagePath, configPageContent, nil); err != nil {
			log.Fatalf("Could not write config page %s: %v", configPagePath, err)
		}
	}
}

func ServerCommand(bundledFiles fs.FS) *cobra.Command {
	var hostname string
	var port int
	var c = &cobra.Command{
		Use:   "silverbullet",
		Short: "Run the Silverbullet server",
		Args:  cobra.MinimumNArgs(0),
		Run: func(cmd *cobra.Command, args []string) {
			serverConfig := buildConfig(bundledFiles, args)
			if port != 3000 {
				serverConfig.Port = port
			}
			if hostname != "" {
				serverConfig.BindHost = hostname
			}
			if err := server.RunServer(serverConfig); err != nil {
				log.Fatal(err)
			}
		},
	}
	c.Flags().StringVarP(&hostname, "hostname", "L", "", "Host or address to listen to")
	c.Flags().IntVarP(&port, "port", "p", 3000, "Port to listen to")

	return c
}


================================================
FILE: server/cmd/space_template/CONFIG.md
================================================
This is where you configure SilverBullet to your liking. See [[^Library/Std/Config]] for a full list of configuration options.

```space-lua
config.set("plugs", {
  -- Add your plugs here (https://silverbullet.md/Plugs)
  -- Then run the `Plugs: Update` command to update them
})
```


================================================
FILE: server/cmd/space_template/index.md
================================================
# Hello 👋
Welcome to the wondrous world of SilverBullet. A world that once you discover and appreciate, you’ll never want to leave.

_One of us!_

You can go ahead and delete this placeholder content in a second, but before you do, let me quickly show you around.

We’ll do this through a list of **silver bullets**.

_Ah, so that explains the name!_

* Click on the page picker (book icon) icon at the top right, or hit `Cmd-k` (Mac) or `Ctrl-k` (Linux and Windows) to open the **page picker**.
  * Type the name of a non-existent page to create it.
  * Folders are implicitly created by putting slashes (`/`) in the name (even on Windows), e.g. `My Folder/My Page`. Don’t worry about folders existing, SilverBullet will automatically create them if they don’t.
* Click on the terminal icon (top right), or hit `Cmd-/` (Mac) or `Ctrl-/` (Linux and Windows) to open the **command palette**. From here you can run various useful and perhaps less useful commands.
* Select some text and hit `Cmd-b` (Mac) or `Ctrl-b` (Windows/Linux) to make it **bold**, or `Cmd-i` (Mac) or `Ctrl-i` (Windows/Linux) to make it _italic_.
* You can create links to pages using the `[[other page]]` syntax, for instance: `[[aspiring page]]`. When you link to a non-existent page it will initially show up in orange (to indicate it does not yet exist), but once you click it — you will create the page automatically (only for real when you actually enter some text).
* Start typing `:party` to trigger the emoji picker 🎉
* Type `/` somewhere in the text to invoke a **slash command**.
* If this matches your personality type, there is also an `Editor: Toggle Vim Mode` command to switch to Vim mode. If you cannot figure out how to exit it (classic vim issue), just run that same command again. _Phew!_
* As you may have noticed, while SilverBullet uses [markdown](https://www.markdownguide.org/) underneath, it also “live previews” it to give a bit more of a WYSIWYG feel. You can always move your cursor “into” the underlying code by `Alt-clicking` it (even links and widgets, which we’ll get to later).

While SilverBullet implements (most) of [CommonMark](https://commonmark.org/), it also adds a few extensions that are SilverBullet-specific. Most notably, it adds the `${Lua expression}` syntax to render Lua expressions inline.

Using SilverBullet’s Lua APIs, you can do all kinds of cool stuff. For instance, query your space for the last 3 modified pages. _Exciting!_

${query[[from index.tag "page" order by lastModified desc limit 3]]}

There’s a whole world out there to explore, but let’s not get ahead of ourselves. First, have some fun and add some content to your fresh space.

Then we’ll talk.

# What next?
You can find more information on SilverBullet’s feature set on its [official website](https://silverbullet.md/). Also, be sure to join the [SilverBullet community](https://community.silverbullet.md/) to interact with fellow SilverBullet explorers.


================================================
FILE: server/cmd/upgrade.go
================================================
package cmd

import (
	"archive/zip"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"log"
	"net/http"
	"os"
	"path/filepath"
	"runtime"

	"github.com/spf13/cobra"
)

// We can add more now that this will be Go compiled (we can do ARM32 too, for instance)
var archMappings map[string]string = map[string]string{
	"amd64": "x86_64",
	"arm64": "aarch64",
}

func UpgradeCommand() *cobra.Command {
	return &cobra.Command{
		Use: "upgrade",
		Run: func(cmd *cobra.Command, args []string) {
			fmt.Println("Now going to attempt an upgrade...")
			if err := upgrade("https://github.com/silverbulletmd/silverbullet/releases/latest/download"); err != nil {
				log.Fatal(err)
			}
		},
	}
}

func UpgradeEdgeCommand() *cobra.Command {
	return &cobra.Command{
		Use: "upgrade-edge",
		Run: func(cmd *cobra.Command, args []string) {
			fmt.Println("Now going to attempt an upgrade...")
			if err := upgrade("https://github.com/silverbulletmd/silverbullet/releases/download/edge"); err != nil {
				log.Fatal(err)
			}
		},
	}
}

func upgrade(urlPrefix string) error {
	// Get the directory of the current executable
	execPath, err := os.Executable()
	if err != nil {
		return fmt.Errorf("failed to get executable path: %w", err)
	}
	installDir := filepath.Dir(execPath)

	fmt.Println("Install dir:", installDir)

	// Create temporary directory
	tmpDir, err := os.MkdirTemp("", "silverbullet-upgrade")
	if err != nil {
		return fmt.Errorf("failed to create temp dir: %w", err)
	}
	defer os.RemoveAll(tmpDir)

	// Construct download URL
	zipURL := fmt.Sprintf("%s/silverbullet-server-%s-%s.zip", urlPrefix, runtime.GOOS, archMappings[runtime.GOARCH])
	zipPath := filepath.Join(tmpDir, "silverbullet.zip")

	fmt.Println("Downloading from", zipURL)

	// Download the zip file
	resp, err := http.Get(zipURL)
	if err != nil {
		return fmt.Errorf("failed to download: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		return fmt.Errorf("download failed with status: %d", resp.StatusCode)
	}

	// Create the zip file
	zipFile, err := os.Create(zipPath)
	if err != nil {
		return fmt.Errorf("failed to create zip file: %w", err)
	}
	defer zipFile.Close()

	// Copy response body to file
	_, err = io.Copy(zipFile, resp.Body)
	if err != nil {
		return fmt.Errorf("failed to save zip file: %w", err)
	}
	zipFile.Close()

	fmt.Printf("Now going to replace the existing silverbullet binary in %s\n", installDir)

	// Extract the zip file
	err = extractZip(zipPath, installDir)
	if err != nil {
		return fmt.Errorf("failed to extract zip: %w", err)
	}

	// Set executable permissions on the binary
	binaryPath := filepath.Join(installDir, "silverbullet")
	if runtime.GOOS == "windows" {
		binaryPath += ".exe"
	}
	err = os.Chmod(binaryPath, 0755)
	if err != nil {
		return fmt.Errorf("failed to set executable permissions: %w", err)
	}

	fmt.Println("And done! Restart your server to get the latest and greatest!")
	return nil
}

func extractZip(src, dest string) error {
	reader, err := zip.OpenReader(src)
	if err != nil {
		return err
	}
	defer reader.Close()

	for _, file := range reader.File {
		path := filepath.Join(dest, file.Name)

		// Handle directories first, report errors here too
		if file.FileInfo().IsDir() {
			if err := os.MkdirAll(path, file.FileInfo().Mode()); err != nil {
				return err
			}
			continue
		}

		rc, err := file.Open()
		if err != nil {
			return err
		}
		defer rc.Close()

		// Create parent directories
		if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
			return err
		}

		// First, attempt to remove the file to prevent "text file busy" error
		err = os.Remove(path)
		if err != nil && !errors.Is(err, fs.ErrNotExist) {
			return err
		}

		// Create the file. It will create a new inode, so we can write to it while the executable still runs
		outFile, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.FileInfo().Mode())
		if err != nil {
			return err
		}
		defer outFile.Close()

		// Extract to the file
		_, err = io.Copy(outFile, rc)
		if err != nil {
			return err
		}
	}

	return nil
}


================================================
FILE: server/cmd/version.go
================================================
package cmd

import (
	"fmt"
	"log"

	"github.com/silverbulletmd/silverbullet/server"
	"github.com/spf13/cobra"
)

func VersionCommand(versionFileText string) *cobra.Command {
	return &cobra.Command{
		Use: "version",
		Run: func(cmd *cobra.Command, args []string) {
			version, err := server.ParseVersionFromTypeScript(versionFileText)
			if err != nil {
				log.Fatal(err)
			}
			fmt.Println(version)
		},
	}
}


================================================
FILE: server/crypto.go
================================================
package server

import (
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"log"
	"maps"
	"os"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

// Authenticator handles JWT creation and verification
type Authenticator struct {
	path      string
	SecretKey string `json:"secret_key"`
	AuthHash  string `json:"auth_hash"`
	Salt      string `json:"salt"` // base64 encoded 16 byte randomized salt used for encryption
}

func CreateAuthenticator(path string, authOptions *AuthOptions) (*Authenticator, error) {
	// Load from JSON
	jsonBytes, err := os.ReadFile(path)
	// If the file is not found, just start fresh
	if os.IsNotExist(err) {
		auth := &Authenticator{path: path}

		if err := auth.init(authOptions); err != nil {
			return nil, err
		}
		return auth, nil
	}
	if err != nil {
		return nil, err
	}

	var auth Authenticator
	auth.path = path
	if err := json.Unmarshal(jsonBytes, &auth); err != nil {
		log.Printf("Could not parse .silverbullet.auth.json, resetting")
	}
	if err := auth.init(authOptions); err != nil {
		return nil, err
	}

	return &auth, nil
}

func (j *Authenticator) save() error {
	jsonBytes, err := json.Marshal(j)
	if err != nil {
		return err
	}

	return os.WriteFile(j.path, jsonBytes, 0600)
}

// Init initializes the JWT issuer with an auth string for validation
func (j *Authenticator) init(authConfig *AuthOptions) error {
	if j.SecretKey == "" {
		log.Println("Generating new JWT secret key")
		if err := j.generateNewKey(); err != nil {
			return err
		}
	}

	newAuthHash := j.hashOptions(authConfig)
	if j.AuthHash != newAuthHash {
		log.Println("Authentication has changed since last run, so invalidating all existing tokens")
		// Generate new key to invalidate all existing tokens
		if err := j.generateNewKey(); err != nil {
			return err
		}
	}

	j.AuthHash = newAuthHash

	if j.Salt == "" {
		b := make([]byte, 16)
		rand.Read(b)
		j.Salt = base64.StdEncoding.EncodeToString(b)
	}

	return j.save()

}

// generateNewKey creates and stores a new secret key
func (j *Authenticator) generateNewKey() error {
	// Generate 32 bytes (256 bits) of random data for the secret
	secretKey := make([]byte, 32)
	if _, err := rand.Read(secretKey); err != nil {
		return fmt.Errorf("failed to generate secret key: %v", err)
	}

	j.SecretKey = base64.StdEncoding.EncodeToString(secretKey)

	return nil
}

func (j *Authenticator) hashOptions(authOptions *AuthOptions) string {
	data, err := json.Marshal(authOptions)
	if err != nil {
		panic(err)
	}
	hash := sha256.Sum256(data)
	return hex.EncodeToString(hash[:])
}

// CreateJWT creates a new JWT token with the given payload
func (j *Authenticator) CreateJWT(payload map[string]any, expirySeconds ...int) (string, error) {
	claims := jwt.MapClaims{}

	// Copy payload to claims
	maps.Copy(claims, payload)

	// Add expiry if specified
	if len(expirySeconds) > 0 && expirySeconds[0] > 0 {
		claims["exp"] = time.Now().Add(time.Duration(expirySeconds[0]) * time.Second).Unix()
	}

	token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	key, err := base64.StdEncoding.DecodeString(j.SecretKey)
	if err != nil {
		return "", err
	}
	return token.SignedString(key)
}

// VerifyAndDecodeJWT verifies and decodes a JWT token
func (j *Authenticator) VerifyAndDecodeJWT(tokenString string) (map[string]any, error) {
	token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) {
		// Validate the signing method
		if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
			return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
		}
		return base64.StdEncoding.DecodeString(j.SecretKey)
	})

	if err != nil {
		return nil, err
	}

	if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
		return claims, nil
	}

	return nil, fmt.Errorf("invalid token")
}


================================================
FILE: server/crypto_test.go
================================================
package server

import (
	"os"
	"path/filepath"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

func TestCreateAuthenticator_NewFile(t *testing.T) {
	tmpDir := t.TempDir()
	authFile := filepath.Join(tmpDir, "auth.json")

	authOptions := &AuthOptions{
		User:         "testuser",
		Pass:         "testpass",
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	auth, err := CreateAuthenticator(authFile, authOptions)
	assert.NoError(t, err, "Should create new authenticator without error")
	assert.NotNil(t, auth, "Authenticator should not be nil")
	assert.NotEmpty(t, auth.SecretKey, "Secret key should be generated")
	assert.NotEmpty(t, auth.AuthHash, "Auth hash should be generated")

	// Verify file was created
	_, err = os.Stat(authFile)
	assert.False(t, os.IsNotExist(err), "Auth file should be created")
}

func TestCreateAuthenticator_ExistingFile(t *testing.T) {
	tmpDir := t.TempDir()
	authFile := filepath.Join(tmpDir, "auth.json")

	// Create initial authenticator
	authOptions := &AuthOptions{
		User:         "testuser",
		Pass:         "testpass",
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	auth1, err := CreateAuthenticator(authFile, authOptions)
	require.NoError(t, err)
	originalKey := auth1.SecretKey

	// Load from existing file with same options
	auth2, err := CreateAuthenticator(authFile, authOptions)
	assert.NoError(t, err, "Should load existing authenticator without error")
	assert.Equal(t, originalKey, auth2.SecretKey, "Secret key should remain the same")
}

func TestCreateAuthenticator_AuthOptionsChanged(t *testing.T) {
	tmpDir := t.TempDir()
	authFile := filepath.Join(tmpDir, "auth.json")

	// Create initial authenticator
	authOptions1 := &AuthOptions{
		User:         "testuser",
		Pass:         "testpass",
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	auth1, err := CreateAuthenticator(authFile, authOptions1)
	require.NoError(t, err)
	originalKey := auth1.SecretKey

	// Load with different auth options
	authOptions2 := &AuthOptions{
		User:         "testuser",
		Pass:         "newpass", // Changed password
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	auth2, err := CreateAuthenticator(authFile, authOptions2)
	assert.NoError(t, err, "Should load authenticator with changed options")
	assert.NotEqual(t, originalKey, auth2.SecretKey, "Secret key should change when auth options change")
}

func TestAuthenticator_CreateJWT(t *testing.T) {
	tmpDir := t.TempDir()
	authFile := filepath.Join(tmpDir, "auth.json")

	authOptions := &AuthOptions{
		User:         "testuser",
		Pass:         "testpass",
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	auth, err := CreateAuthenticator(authFile, authOptions)
	require.NoError(t, err)

	payload := map[string]any{
		"user": "testuser",
		"role": "admin",
	}

	token, err := auth.CreateJWT(payload)
	assert.NoError(t, err, "Should create JWT without error")
	assert.NotEmpty(t, token, "Token should not be empty")

	// Verify token structure (should have 3 parts separated by dots)
	parts := len([]rune(token))
	assert.Greater(t, parts, 0, "Token should not be empty")
}

func TestAuthenticator_CreateJWT_WithExpiry(t *testing.T) {
	tmpDir := t.TempDir()
	authFile := filepath.Join(tmpDir, "auth.json")

	authOptions := &AuthOptions{
		User:         "testuser",
		Pass:         "testpass",
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	auth, err := CreateAuthenticator(authFile, authOptions)
	require.NoError(t, err)

	payload := map[string]any{
		"user": "testuser",
	}

	token, err := auth.CreateJWT(payload, 3600) // 1 hour expiry
	assert.NoError(t, err, "Should create JWT with expiry without error")
	assert.NotEmpty(t, token, "Token should not be empty")

	// Verify the token and check expiry
	claims, err := auth.VerifyAndDecodeJWT(token)
	assert.NoError(t, err, "Should verify token")

	exp, ok := claims["exp"]
	assert.True(t, ok, "Token should have expiry claim")
	assert.NotNil(t, exp, "Expiry should not be nil")
}

func TestAuthenticator_VerifyAndDecodeJWT(t *testing.T) {
	tmpDir := t.TempDir()
	authFile := filepath.Join(tmpDir, "auth.json")

	authOptions := &AuthOptions{
		User:         "testuser",
		Pass:         "testpass",
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	auth, err := CreateAuthenticator(authFile, authOptions)
	require.NoError(t, err)

	payload := map[string]any{
		"user": "testuser",
		"role": "admin",
		"id":   123,
	}

	token, err := auth.CreateJWT(payload)
	require.NoError(t, err)

	// Verify and decode the token
	claims, err := auth.VerifyAndDecodeJWT(token)
	assert.NoError(t, err, "Should verify and decode JWT without error")
	assert.Equal(t, "testuser", claims["user"], "Should preserve user claim")
	assert.Equal(t, "admin", claims["role"], "Should preserve role claim")
	assert.Equal(t, float64(123), claims["id"], "Should preserve id claim")
}

func TestAuthenticator_VerifyAndDecodeJWT_InvalidToken(t *testing.T) {
	tmpDir := t.TempDir()
	authFile := filepath.Join(tmpDir, "auth.json")

	authOptions := &AuthOptions{
		User:         "testuser",
		Pass:         "testpass",
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	auth, err := CreateAuthenticator(authFile, authOptions)
	require.NoError(t, err)

	// Test invalid token
	_, err = auth.VerifyAndDecodeJWT("invalid.token.here")
	assert.Error(t, err, "Should return error for invalid token")

	// Test empty token
	_, err = auth.VerifyAndDecodeJWT("")
	assert.Error(t, err, "Should return error for empty token")
}

func TestAuthenticator_VerifyAndDecodeJWT_ExpiredToken(t *testing.T) {
	tmpDir := t.TempDir()
	authFile := filepath.Join(tmpDir, "auth.json")

	authOptions := &AuthOptions{
		User:         "testuser",
		Pass:         "testpass",
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	auth, err := CreateAuthenticator(authFile, authOptions)
	require.NoError(t, err)

	payload := map[string]any{
		"user": "testuser",
	}

	// Create token with 1 second expiry
	token, err := auth.CreateJWT(payload, 1)
	require.NoError(t, err)

	// Wait for token to expire
	time.Sleep(2 * time.Second)

	// Try to verify expired token
	_, err = auth.VerifyAndDecodeJWT(token)
	assert.Error(t, err, "Should return error for expired token")
}

func TestAuthenticator_VerifyAndDecodeJWT_WrongKey(t *testing.T) {
	tmpDir := t.TempDir()
	authFile1 := filepath.Join(tmpDir, "auth1.json")
	authFile2 := filepath.Join(tmpDir, "auth2.json")

	authOptions := &AuthOptions{
		User:         "testuser",
		Pass:         "testpass",
		LockoutTime:  300,
		LockoutLimit: 5,
	}

	// Create two different authenticators (different keys)
	auth1, err := CreateAuthenticator(authFile1, authOptions)
	require.NoError(t, err)

	auth2, err := CreateAuthenticator(authFile2, authOptions)
	require.NoError(t, err)

	payload := map[string]any{
		"user": "testuser",
	}

	// Create token with first authenticator
	token, err := auth1.CreateJWT(payload)
	require.NoError(t, err)

	// Try to verify with second authenticator (different key)
	_, err = auth2.VerifyAndDecodeJWT(token)
	assert.Error(t, err, "Should return error when verifying token with wrong key")
}


================================================
FILE: server/disk_space_primitives.go
================================================
package server

import (
	"fmt"
	"io/fs"
	"log"
	"os"
	"path/filepath"
	"strings"
	"sync"
	"time"

	ignore "github.com/Diogenesoftoronto/go-gitignore"
	"github.com/charlievieth/fastwalk"
	"github.com/djherbis/times"
	"github.com/prometheus/client_golang/prometheus"
)

// DiskSpacePrimitives implements SpacePrimitives for local disk storage
type DiskSpacePrimitives struct {
	rootPath  string
	gitIgnore *ignore.GitIgnore
}

var _ SpacePrimitives = &DiskSpacePrimitives{}

var spaceFilesTotal = prometheus.NewGauge(prometheus.GaugeOpts{
	Name: "silverbullet_space_files_count",
	Help: "Total number of files in the SilverBullet space",
})

func init() {
	prometheus.MustRegister(spaceFilesTotal)
}

// NewDiskSpacePrimitives creates a new DiskSpacePrimitives instance
func NewDiskSpacePrimitives(rootPath string, gitIgnore string) (*DiskSpacePrimitives, error) {
	// Resolve to absolute path
	absPath, err := filepath.Abs(rootPath)
	if err != nil {
		return nil, fmt.Errorf("failed to resolve root path: %w", err)
	}

	// Ensure the directory exists
	if stat, err := os.Stat(absPath); err != nil || !stat.IsDir() {
		return nil, fmt.Errorf("no such directory: %w", err)
	}
	ignore := ignore.CompileIgnoreLines(strings.Split(gitIgnore, "\n")...)

	return &DiskSpacePrimitives{
		rootPath:  absPath,
		gitIgnore: ignore,
	}, nil
}

// safePath ensures the path is within the root directory
func (d *DiskSpacePrimitives) safePath(p string) (string, error) {
	// Clean the path to resolve any .. or . components
	cleanPath := filepath.Clean(p)

	// If the path is absolute and tries to escape, reject it
	if filepath.IsAbs(cleanPath) {
		return "", ErrPathOutsideRoot
	}

	// Make it absolute relative to root
	fullPath := filepath.Join(d.rootPath, cleanPath)

	// Clean the full path again to resolve any remaining .. components
	fullPath = filepath.Clean(fullPath)

	// Ensure the resolved path is still within root
	if !strings.HasPrefix(fullPath, d.rootPath) {
		return "", ErrPathOutsideRoot
	}

	// Additional check: ensure we don't have .. components that could escape
	relPath, err := filepath.Rel(d.rootPath, fullPath)
	if err != nil {
		return "", ErrPathOutsideRoot
	}
	if strings.HasPrefix(relPath, "..") {
		return "", ErrPathOutsideRoot
	}

	return fullPath, nil
}

// filenameToPath converts a relative filename to an absolute path
func (d *DiskSpacePrimitives) filenameToPath(filename string) (string, error) {
	return d.safePath(filename)
}

// pathToFilename converts an absolute path back to a relative filename
func (d *DiskSpacePrimitives) pathToFilename(fullPath string) string {
	if !strings.HasPrefix(fullPath, d.rootPath) {
		return fullPath
	}

	relativePath := strings.TrimPrefix(fullPath, d.rootPath)
	relativePath = strings.TrimPrefix(relativePath, string(filepath.Separator))

	// Normalize path separators to forward slashes
	return strings.ReplaceAll(relativePath, string(filepath.Separator), "/")
}

// fileInfoToFileMeta converts os.FileInfo to FileMeta
func (d *DiskSpacePrimitives) fileInfoToFileMeta(path string, info os.FileInfo) FileMeta {
	return FileMeta{
		Name:         path,
		Size:         info.Size(),
		ContentType:  LookupContentTypeFromPath(path),
		Created:      getCreationTime(info).UnixMilli(),
		LastModified: info.ModTime().UnixMilli(),
		Perm:         "rw",
	}
}

// FetchFileList implements SpacePrimitives.FetchFileList
// Returns a list of files in the space, with the following filtering rules:
// - Hidden files and directories (starting with '.') are excluded at any depth
// - Files without extensions are excluded
// - Files matching gitignore patterns are excluded
func (d *DiskSpacePrimitives) FetchFileList() ([]FileMeta, error) {
	var l sync.Mutex
	var allFiles []FileMeta

	cnf := fastwalk.Config{
		// Follow symlinks
		Follow: true,
	}

	err := fastwalk.Walk(&cnf, d.rootPath, func(path string, entry fs.DirEntry, err error) error {
		if err != nil {
			// Skip files that can't be accessed
			return nil
		}

		// Skip hidden directories and stop traversal into them
		if entry.IsDir() {
			if strings.HasPrefix(entry.Name(), ".") {
				return filepath.SkipDir
			}
			return nil
		}

		// Skip hidden files
		if strings.HasPrefix(entry.Name(), ".") {
			return nil
		}

		relativePath := d.pathToFilename(path)

		// Exclude ignored files
		if d.gitIgnore.MatchesPath(relativePath) {
			return nil
		}

		// Skip files without extensions
		if filepath.Ext(relativePath) == "" {
			return nil
		}

		// Get file info
		info, err := entry.Info()
		if err != nil {
			// Skip files we can't stat
			return nil
		}

		fileMeta := d.fileInfoToFileMeta(relativePath, info)
		// Mutex to handle concurrent directory walking
		l.Lock()
		allFiles = append(allFiles, fileMeta)
		l.Unlock()

		return nil
	})

	if err != nil {
		return nil, fmt.Errorf("failed to walk directory: %w", err)
	}

	spaceFilesTotal.Set(float64(len(allFiles)))

	return allFiles, nil
}

// GetFileMeta implements SpacePrimitives.GetFileMeta
func (d *DiskSpacePrimitives) GetFileMeta(path string) (FileMeta, error) {
	localPath, err := d.filenameToPath(path)
	if err != nil {
		return FileMeta{}, err
	}

	info, err := os.Stat(localPath)

	if err != nil {
		if os.IsNotExist(err) {
			return FileMeta{}, ErrNotFound
		}
		// Check if this is a filesystem syntax error (e.g., invalid characters)
		// This typically happens with virtual pages like "tag:Something" which contain
		// characters that are invalid in filesystem paths
		if isSyntaxError(err) {
			return FileMeta{}, ErrNotFound
		}
		return FileMeta{}, fmt.Errorf("%w: %s", ErrCouldNotGetMeta, path)
	}

	return d.fileInfoToFileMeta(path, info), nil
}

// ReadFile implements SpacePrimitives.ReadFile
func (d *DiskSpacePrimitives) ReadFile(path string) ([]byte, FileMeta, error) {
	localPath, err := d.filenameToPath(path)
	if err != nil {
		return nil, FileMeta{}, err
	}

	// Get file info first
	info, err := os.Stat(localPath)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, FileMeta{}, ErrNotFound
		}
		// Check if this is a filesystem syntax error (e.g., invalid characters)
		if isSyntaxError(err) {
			return nil, FileMeta{}, ErrNotFound
		}
		return nil, FileMeta{}, fmt.Errorf("failed to stat file %s: %w", path, err)
	}

	// Read file content
	data, err := os.ReadFile(localPath)
	if err != nil {
		// Check if this is a filesystem syntax error
		if isSyntaxError(err) {
			return nil, FileMeta{}, ErrNotFound
		}
		return nil, FileMeta{}, fmt.Errorf("failed to read file %s: %w", path, err)
	}

	meta := d.fileInfoToFileMeta(path, info)
	return data, meta, nil
}

// WriteFile implements SpacePrimitives.WriteFile
func (d *DiskSpacePrimitives) WriteFile(path string, data []byte, meta *FileMeta) (FileMeta, error) {
	localPath, err := d.filenameToPath(path)
	if err != nil {
		return FileMeta{}, err
	}

	// Ensure parent directory exists
	parentDir := filepath.Dir(localPath)
	if err := os.MkdirAll(parentDir, 0755); err != nil {
		return FileMeta{}, fmt.Errorf("%w: %s", ErrCouldNotWrite, path)
	}

	// Write file
	if err := os.WriteFile(localPath, data, 0644); err != nil {
		return FileMeta{}, fmt.Errorf("%w: %s", ErrCouldNotWrite, path)
	}

	// Set modification time if provided
	if meta != nil && meta.LastModified > 0 {
		modTime := time.UnixMilli(meta.LastModified)
		if err := os.Chtimes(localPath, modTime, modTime); err != nil {
			log.Printf("Failed to set the mtime for %s: %v", localPath, err)
		}
	}

	// Return actual metadata
	return d.GetFileMeta(path)
}

// DeleteFile implements SpacePrimitives.DeleteFile
func (d *DiskSpacePrimitives) DeleteFile(path string) error {
	localPath, err := d.filenameToPath(path)
	if err != nil {
		return err
	}

	if err := os.Remove(localPath); err != nil {
		if os.IsNotExist(err) {
			return ErrNotFound
		}
		return fmt.Errorf("failed to delete file %s: %w", path, err)
	}

	// Clean up empty parent directories
	d.cleanOrphaned(localPath)

	return nil
}

// cleanOrphaned removes empty parent directories up to rootPath
func (d *DiskSpacePrimitives) cleanOrphaned(deletedFilePath string) {
	current := filepath.Dir(deletedFilePath)

	for strings.HasPrefix(current, d.rootPath) && current != d.rootPath {
		err := os.Remove(current)
		if err != nil {
			// Directory not empty or other error, stop cleaning
			break
		}
		current = filepath.Dir(current)
	}
}

// This is tricky and OS specific, luckily we found the "times" package that abstracts from the OS-specific details
func getCreationTime(info os.FileInfo) time.Time {
	t := times.Get(info)
	if t.HasBirthTime() {
		return t.BirthTime()
	} else {
		return t.ChangeTime()
	}
}

// isSyntaxError checks if the error is due to invalid filename syntax
// This typically happens on virtual filesystems when trying to access files with characters
// like colons (e.g., "tag:Something") which are valid in SilverBullet virtual pages
// but not valid in some virtual filesystem paths
func isSyntaxError(err error) bool {
	if err == nil {
		return false
	}
	// Check for specific syntax errors in the error message
	// We need to check the entire error chain, not just the top-level error
	errMsg := err.Error()
	return strings.Contains(errMsg, "syntax is incorrect") ||
		strings.Contains(errMsg, "syntax incorrect") ||
		(strings.Contains(errMsg, "syntax") && strings.Contains(errMsg, "incorrect")) ||
		strings.Contains(errMsg, "invalid argument") ||
		strings.Contains(errMsg, "bad file descriptor")
}


================================================
FILE: server/disk_space_primitives_test.go
================================================
package server

import (
	"os"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestDiskSpacePrimitives(t *testing.T) {
	// Create DiskSpacePrimitives instance
	space, err := NewDiskSpacePrimitives(t.TempDir(), "")
	assert.NoError(t, err, "Failed to create DiskSpacePrimitives")

	// Run all test suites using local testing utilities
	TestSpacePrimitives(t, space)
}

// Test path safety - specific to disk implementation
func TestSafePath(t *testing.T) {
	space, err := NewDiskSpacePrimitives(t.TempDir(), "")
	assert.NoError(t, err, "Failed to create DiskSpacePrimitives")

	// Test that paths outside root are rejected
	_, err = space.safePath("../outside")
	assert.Equal(t, ErrPathOutsideRoot, err, "Expected ErrPathOutsideRoot for '../outside'")

	_, err = space.safePath("/etc/passwd")
	assert.Equal(t, ErrPathOutsideRoot, err, "Expected ErrPathOutsideRoot for '/etc/passwd'")

	// Test that valid paths work
	_, err = space.safePath("valid/path.txt")
	assert.NoError(t, err, "Valid path should not error")

	_, err = space.safePath("./valid.txt")
	assert.NoError(t, err, "Valid relative path should not error")
}

func TestGitIgnore(t *testing.T) {
	space, err := NewDiskSpacePrimitives(t.TempDir(), "*.txt\n*.jpg")
	assert.NoError(t, err, "Failed to create DiskSpacePrimitives")

	// Initially no files
	fileList, err := space.FetchFileList()
	assert.NoError(t, err)
	assert.Len(t, fileList, 0)

	// Write a file matching the gitignore pattern
	_, err = space.WriteFile("test.txt", []byte("Test"), nil)
	assert.NoError(t, err)
	// And one that doesn't
	_, err = space.WriteFile("test.md", []byte("Test"), nil)
	assert.NoError(t, err)

	fileList, err = space.FetchFileList()
	assert.NoError(t, err)
	// Only 1 file
	assert.Len(t, fileList, 1)
	assert.Equal(t, fileList[0].Name, "test.md")

}

func TestHiddenFilesAndDirectories(t *testing.T) {
	space, err := NewDiskSpacePrimitives(t.TempDir(), "")
	assert.NoError(t, err, "Failed to create DiskSpacePrimitives")

	// Create various files and directories
	_, err = space.WriteFile("visible.txt", []byte("visible"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile(".hidden.txt", []byte("hidden"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile("dir/.hidden.txt", []byte("hidden in dir"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile("dir/visible.txt", []byte("visible in dir"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile(".hidden/visible.txt", []byte("visible in hidden dir"), nil)
	assert.NoError(t, err)

	// Test deeply nested hidden directory
	_, err = space.WriteFile(".hidden/.nested/deep.txt", []byte("deep nested hidden"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile("visible/.hidden/nested.txt", []byte("hidden in visible dir"), nil)
	assert.NoError(t, err)

	fileList, err := space.FetchFileList()
	assert.NoError(t, err)

	// Should only contain visible files (not hidden files or files in hidden directories)
	assert.Len(t, fileList, 2)

	fileNames := make([]string, len(fileList))
	for i, file := range fileList {
		fileNames[i] = file.Name
	}

	assert.Contains(t, fileNames, "visible.txt")
	assert.Contains(t, fileNames, "dir/visible.txt")
	assert.NotContains(t, fileNames, ".hidden.txt")
	assert.NotContains(t, fileNames, "dir/.hidden.txt")
	assert.NotContains(t, fileNames, ".hidden/visible.txt")
	assert.NotContains(t, fileNames, ".hidden/.nested/deep.txt")
	assert.NotContains(t, fileNames, "visible/.hidden/nested.txt")
}

func TestFileExtensionRequirement(t *testing.T) {
	space, err := NewDiskSpacePrimitives(t.TempDir(), "")
	assert.NoError(t, err, "Failed to create DiskSpacePrimitives")

	// Create files with and without extensions
	_, err = space.WriteFile("file.txt", []byte("with extension"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile("file.md", []byte("with extension"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile("noextension", []byte("without extension"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile("README", []byte("without extension"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile("dir/file.json", []byte("with extension in dir"), nil)
	assert.NoError(t, err)

	_, err = space.WriteFile("dir/noext", []byte("without extension in dir"), nil)
	assert.NoError(t, err)

	fileList, err := space.FetchFileList()
	assert.NoError(t, err)

	// Should only contain files with extensions
	assert.Len(t, fileList, 3)

	fileNames := make([]string, len(fileList))
	for i, file := range fileList {
		fileNames[i] = file.Name
	}

	assert.Contains(t, fileNames, "file.txt")
	assert.Contains(t, fileNames, "file.md")
	assert.Contains(t, fileNames, "dir/file.json")
	assert.NotContains(t, fileNames, "noextension")
	assert.NotContains(t, fileNames, "README")
	assert.NotContains(t, fileNames, "dir/noext")
}

// Test directory cleanup - specific to disk implementation
func TestCleanOrphaned(t *testing.T) {
	tmpDir := t.TempDir()
	space, err := NewDiskSpacePrimitives(tmpDir, "")
	assert.NoError(t, err, "Failed to create DiskSpacePrimitives")

	// Create nested directory structure with a file
	_, err = space.WriteFile("deep/nested/dir/file.txt", []byte("test"), nil)
	assert.NoError(t, err, "Failed to write nested file")

	// Verify the directories were created
	nestedPath := filepath.Join(tmpDir, "deep", "nested", "dir")
	_, err = os.Stat(nestedPath)
	assert.False(t, os.IsNotExist(err), "Nested directory should exist")

	// Delete the file
	err = space.DeleteFile("deep/nested/dir/file.txt")
	assert.NoError(t, err, "Failed to delete nested file")

	_, err = os.Stat(nestedPath)
	assert.True(t, os.IsNotExist(err), "Empty nested directory should be cleaned up")

	deepPath := filepath.Join(tmpDir, "deep")
	_, err = os.Stat(deepPath)
	assert.True(t, os.IsNotExist(err), "Empty parent directory should be cleaned up")
}

func TestSymlinksAndInfiniteLoops(t *testing.T) {
	tmpDir := t.TempDir()
	space, err := NewDiskSpacePrimitives(tmpDir, "")
	assert.NoError(t, err, "Failed to create DiskSpacePrimitives")

	// Create a regular file to symlink to
	_, err = space.WriteFile("target.txt", []byte("target content"), nil)
	assert.NoError(t, err, "Failed to create target file")

	// Create a directory with files to symlink to
	_, err = space.WriteFile("targetdir/file.md", []byte("dir content"), nil)
	assert.NoError(t, err, "Failed to create target directory file")

	targetPath := filepath.Join(tmpDir, "target.txt")
	targetDirPath := filepath.Join(tmpDir, "targetdir")

	// Test 1: Create symlink to file
	symlinkPath := filepath.Join(tmpDir, "link.txt")
	err = os.Symlink(targetPath, symlinkPath)
	assert.NoError(t, err, "Failed to create file symlink")

	// Test 2: Create symlink to directory
	symlinkDirPath := filepath.Join(tmpDir, "linkdir")
	err = os.Symlink(targetDirPath, symlinkDirPath)
	assert.NoError(t, err, "Failed to create directory symlink")

	// Test 3: Create infinite loop - symlink pointing to itself
	loopPath := filepath.Join(tmpDir, "loop.txt")
	err = os.Symlink(loopPath, loopPath)
	assert.NoError(t, err, "Failed to create self-referencing symlink")

	// Test 4: Create circular symlinks
	circleAPath := filepath.Join(tmpDir, "circleA.txt")
	circleBPath := filepath.Join(tmpDir, "circleB.txt")
	err = os.Symlink(circleBPath, circleAPath)
	assert.NoError(t, err, "Failed to create first circular symlink")
	err = os.Symlink(circleAPath, circleBPath)
	assert.NoError(t, err, "Failed to create second circular symlink")

	// Test that FetchFileList doesn't hang and returns expected files
	fileList, err := space.FetchFileList()
	assert.NoError(t, err, "FetchFileList should handle symlinks without error")

	// Collect file names for verification
	fileNames := make([]string, len(fileList))
	for i, file := range fileList {
		fileNames[i] = file.Name
	}

	// Should contain the original files and symlinked files (if they have extensions)
	assert.Contains(t, fileNames, "target.txt", "Original file should be present")
	assert.Contains(t, fileNames, "targetdir/file.md", "File in target directory should be present")
	assert.Contains(t, fileNames, "link.txt", "Symlinked file should be present")
	assert.Contains(t, fileNames, "linkdir/file.md", "File in symlinked directory should be present")

	// Infinite loop symlinks should not cause issues (they may or may not appear in the list)
	// The important thing is that FetchFileList completes without hanging

	// Test that we can read through symlinks
	content, meta, err := space.ReadFile("link.txt")
	assert.NoError(t, err, "Should be able to read through file symlink")
	assert.Equal(t, []byte("target content"), content, "Symlinked file should have correct content")
	assert.Equal(t, "link.txt", meta.Name, "Meta should use the symlink name")

	content, meta, err = space.ReadFile("linkdir/file.md")
	assert.NoError(t, err, "Should be able to read through directory symlink")
	assert.Equal(t, []byte("dir content"), content, "File in symlinked directory should have correct content")
	assert.Equal(t, "linkdir/file.md", meta.Name, "Meta should use the symlinked path")

	// Test that we can write through symlinks
	_, err = space.WriteFile("link.txt", []byte("new content"), nil)
	assert.NoError(t, err, "Should be able to write through symlink")

	// Verify the original file was updated
	content, _, err = space.ReadFile("target.txt")
	assert.NoError(t, err, "Should be able to read original file")
	assert.Equal(t, []byte("new content"), content, "Original file should be updated through symlink")
}

// Test that virtual pages (like tag:Something) return ErrNotFound instead of possible filesystem errors
func TestVirtualPages(t *testing.T) {
	space, err := NewDiskSpacePrimitives(t.TempDir(), "")
	assert.NoError(t, err, "Failed to create DiskSpacePrimitives")

	// Virtual pages with colons should return ErrNotFound, not a filesystem error
	_, err = space.GetFileMeta("tag:Todo.md")
	assert.Equal(t, ErrNotFound, err, "Virtual page should return ErrNotFound")

	_, err = space.GetFileMeta("search:this")
	assert.Equal(t, ErrNotFound, err, "Virtual page should return ErrNotFound")

	_, _, err = space.ReadFile("tag:Todo.md")
	assert.Equal(t, ErrNotFound, err, "Reading virtual page should return ErrNotFound")

}


================================================
FILE: server/embed_space_primitives.go
================================================
package server

import (
	"errors"
	"fmt"
	"io/fs"
	"log"
	pathLib "path"
	"strings"
	"time"
)

// Implements a simple read-only fallthrough implementation of SpacePrimitives, used to serve static files embedde in the Go binary
type ReadOnlyFallthroughSpacePrimitives struct {
	fsys                       fs.FS
	fallthroughSpacePrimitives SpacePrimitives
	rootPath                   string    // Root path within the fs.FS
	timeStamp                  time.Time // Fake timestamp to use for all files
}

var _ SpacePrimitives = &ReadOnlyFallthroughSpacePrimitives{}

func NewReadOnlyFallthroughSpacePrimitives(fsys fs.FS, rootPath string, timeStamp time.Time, wrapped SpacePrimitives) *ReadOnlyFallthroughSpacePrimitives {
	return &ReadOnlyFallthroughSpacePrimitives{
		fsys:                       fsys,
		fallthroughSpacePrimitives: wrapped,
		rootPath:                   rootPath,
		timeStamp:                  timeStamp,
	}
}

// pathToEmbedPath converts a relative path to an fs.FS path
func (e *ReadOnlyFallthroughSpacePrimitives) pathToEmbedPath(path string) string {
	return pathLib.Join(e.rootPath, path)
}

// Inverse of pathToEmbedPath
func (e *ReadOnlyFallthroughSpacePrimitives) embedPathToPath(path string) string {
	return strings.TrimPrefix(path, e.rootPath+"/")
}

// fileInfoToFileMeta converts fs.FileInfo to FileMeta for fs.FS files
func (e *ReadOnlyFallthroughSpacePrimitives) fileInfoToFileMeta(path string, info fs.FileInfo) FileMeta {
	return FileMeta{
		Name:         path,
		Size:         info.Size(),
		ContentType:  LookupContentTypeFromPath(path),
		Created:      e.timeStamp.UnixMilli(),
		LastModified: e.timeStamp.UnixMilli(),
		Perm:         "ro",
	}
}

// FetchFileList implements SpacePrimitives.FetchFileList
// Lists all files from the filesystem first, then combines files from the fallback.
func (e *ReadOnlyFallthroughSpacePrimitives) FetchFileList() ([]FileMeta, error) {
	allFiles := make([]FileMeta, 0, 1000)

	// First, collect files from the filesystem
	err := fs.WalkDir(e.fsys, e.rootPath, func(path string, entry fs.DirEntry, err error) error {
		if err != nil {
			// Skip files that can't be accessed
			return nil
		}

		// Skip directories
		if entry.IsDir() {
			return nil
		}

		// Get file info
		info, err := entry.Info()
		if err != nil {
			// Skip files we can't stat
			return nil
		}

		// Convert to our path format (forward slashes)
		relativePath := e.embedPathToPath(path)
		fileMeta := e.fileInfoToFileMeta(relativePath, info)
		allFiles = append(allFiles, fileMeta)

		return nil
	})

	if err != nil {
		log.Printf("Something went wrong listing files in the FS: %v", err)
	}

	wrappedFiles, err := e.fallthroughSpacePrimitives.FetchFileList()
	if err != nil {
		return allFiles, err
	}

	allFiles = append(allFiles, wrappedFiles...)

	return allFiles, nil
}

// GetFileMeta implements SpacePrimitives.GetFileMeta
func (e *ReadOnlyFallthroughSpacePrimitives) GetFileMeta(path string) (FileMeta, error) {
	embedPath := e.pathToEmbedPath(path)

	// Try to get file info from filesystem
	info, err := fs.Stat(e.fsys, embedPath)
	if err == nil && !info.IsDir() {
		return e.fileInfoToFileMeta(path, info), nil
	}

	if e.fallthroughSpacePrimitives == nil {
		return FileMeta{}, errors.New("Not found")
	}

	// If not found in fs.FS, fall back
	return e.fallthroughSpacePrimitives.GetFileMeta(path)
}

// ReadFile implements SpacePrimitives.ReadFile
func (e *ReadOnlyFallthroughSpacePrimitives) ReadFile(path string) ([]byte, FileMeta, error) {
	embedPath := e.pathToEmbedPath(path)

	// Try to read from filesystem
	data, err := fs.ReadFile(e.fsys, embedPath)
	if err == nil {
		// Get file info for metadata
		info, statErr := fs.Stat(e.fsys, embedPath)
		if statErr != nil {
			return nil, FileMeta{}, statErr
		}

		return data, e.fileInfoToFileMeta(path, info), nil
	}

	if e.fallthroughSpacePrimitives == nil {
		return nil, FileMeta{}, errors.New("Not found")
	}

	// If not found in fs.FS, fall back
	return e.fallthroughSpacePrimitives.ReadFile(path)
}

// WriteFile implements SpacePrimitives.WriteFile
// Fails if file exists in filesystem, otherwise delegates to fallback
func (e *ReadOnlyFallthroughSpacePrimitives) WriteFile(path string, data []byte, meta *FileMeta) (FileMeta, error) {
	embedPath := e.pathToEmbedPath(path)

	// Check if file exists in filesystem
	_, err := fs.Stat(e.fsys, embedPath)
	if err == nil {
		// File exists in filesystem, cannot write
		return FileMeta{}, fmt.Errorf("Cannot write file %s: read-only", path)
	}

	if e.fallthroughSpacePrimitives == nil {
		return FileMeta{}, errors.New("Not found")
	}

	return e.fallthroughSpacePrimitives.WriteFile(path, data, meta)
}

// DeleteFile implements SpacePrimitives.DeleteFile
// Fails if file exists in filesystem, otherwise delegates to fallback
func (e *ReadOnlyFallthroughSpacePrimitives) DeleteFile(path string) error {
	embedPath := e.pathToEmbedPath(path)

	// Check if file exists in filesystem
	_, err := fs.Stat(e.fsys, embedPath)
	if err == nil {
		// File exists in filesystem, cannot delete
		return fmt.Errorf("cannot delete file %s: read-only file", path)
	}

	if e.fallthroughSpacePrimitives == nil {
		return errors.New("Not found")
	}

	return e.fallthroughSpacePrimitives.DeleteFile(path)
}


================================================
FILE: server/embed_space_primitives_test.go
================================================
package server

import (
	"testing"
	"testing/fstest"
	"time"

	"github.com/stretchr/testify/assert"
)

func TestFSSpacePrimitivesWithMapFS(t *testing.T) {
	// Test with a MapFS that has embedded files
	mapFS := fstest.MapFS{
		"readme.txt":  &fstest.MapFile{Data: []byte("Hello World")},
		"config.json": &fstest.MapFile{Data: []byte(`{"test": true}`)},
	}

	// Note: t.TempDir() automatically deletes the folder after the tests runs
	fallback, err := NewDiskSpacePrimitives(t.TempDir(), "")
	assert.NoError(t, err, "Failed to create fallback")

	primitives := NewReadOnlyFallthroughSpacePrimitives(mapFS, "", time.Now(), fallback)

	// Test reading embedded files
	data, meta, err := primitives.ReadFile("readme.txt")
	assert.NoError(t, err, "Should read embedded file")
	assert.Equal(t, []byte("Hello World"), data, "Content should match")
	assert.Equal(t, "readme.txt", meta.Name, "Name should match")
	assert.Equal(t, "ro", meta.Perm, "Embedded files should be read-only")

	// Test that writing to embedded file names fails
	_, err = primitives.WriteFile("readme.txt", []byte("new content"), nil)
	assert.Error(t, err, "Should not overwrite embedded files")
	assert.Contains(t, err.Error(), "read-only")

	// Test that deleting embedded files fails
	err = primitives.DeleteFile("config.json")
	assert.Error(t, err, "Should not delete embedded files")
	assert.Contains(t, err.Error(), "read-only")

	// Test writing to non-embedded file names (should work via fallback)
	_, err = primitives.WriteFile("new_file.txt", []byte("fallback content"), nil)
	assert.NoError(t, err, "Should write to fallback for non-embedded files")

	// Test that fallback file can be read back
	data, meta, err = primitives.ReadFile("new_file.txt")
	assert.NoError(t, err, "Should read fallback file")
	assert.Equal(t, []byte("fallback content"), data, "Fallback content should match")
	assert.Equal(t, "new_file.txt", meta.Name, "Fallback file name should match")
}


================================================
FILE: server/fs.go
================================================
package server

import (
	"io"
	"log"
	"net/http"
	"net/url"
	"strconv"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/render"
)

// buildFsRoutes creates the filesystem API routes
func buildFsRoutes() http.Handler {
	fsRouter := chi.NewRouter()

	// File list endpoint
	fsRouter.Get("/", handleFsList)

	// File operations
	fsRouter.Get("/*", handleFsGet)
	fsRouter.Put("/*", handleFsPut)
	fsRouter.Delete("/*", handleFsDelete)
	fsRouter.Options("/*", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Allow", "GET, PUT, DELETE, OPTIONS")
		w.WriteHeader(http.StatusOK)
	})

	return fsRouter
}

func handleFsList(w http.ResponseWriter, r *http.Request) {
	spaceConfig := spaceConfigFromContext(r.Context())
	if r.Header.Get("X-Sync-Mode") != "" {
		// Handle direct requests for JSON representation of file list
		files, err := spaceConfig.SpacePrimitives.FetchFileList()
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError)
			return
		}
		w.Header().Set("X-Space-Path", spaceConfig.SpaceFolderPath)
		w.Header().Set("Cache-Control", "no-cache")
		render.JSON(w, r, files)
	} else {
		// Otherwise, redirect to the UI
		http.Redirect(w, r, "/", http.StatusTemporaryRedirect)
	}
}

// handleFsGet handles GET requests for individual files
func handleFsGet(w http.ResponseWriter, r *http.Request) {
	path := DecodeURLParam(r, "*")
	spaceConfig := spaceConfigFromContext(r.Context())

	// log.Printf("Got this path: %s", path)

	if r.Header.Get("X-Get-Meta") != "" {
		// Getting meta via GET request
		meta, err := spaceConfig.SpacePrimitives.GetFileMeta(path)
		if err != nil {
			if err == ErrNotFound {
				http.NotFound(w, r)
			} else {
				http.Error(w, err.Error(), http.StatusInternalServerError)
			}
			return
		}

		setFileMetaHeaders(w, meta)
		w.WriteHeader(http.StatusOK)
		return
	}

	// Read file content
	data, meta, err := spaceConfig.SpacePrimitives.ReadFile(path)
	if err != nil {
		if err == ErrNotFound {
			http.NotFound(w, r)
		} else {
			http.Error(w, err.Error(), http.StatusInternalServerError)
		}
		return
	}

	setFileMetaHeaders(w, meta)
	w.WriteHeader(http.StatusOK)
	w.Write(data)
}

// handleFsPut handles PUT requests for writing files
func handleFsPut(w http.ResponseWriter, r *http.Request) {
	path := DecodeURLParam(r, "*")
	spaceConfig := spaceConfigFromContext(r.Context())

	// Read request body
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Failed to read request body", http.StatusBadRequest)
		return
	}

	// Write file
	meta, err := spaceConfig.SpacePrimitives.WriteFile(path, body, getFileMetaFromHeaders(r.Header, path))
	if err != nil {
		log.Printf("Write failed: %v\n", err)
		http.Error(w, "Write failed", http.StatusInternalServerError)
		return
	}

	setFileMetaHeaders(w, meta)
	w.WriteHeader(http.StatusOK)
	w.Write([]byte("OK"))
}

// handleFsDelete handles DELETE requests for removing files
func handleFsDelete(w http.ResponseWriter, r *http.Request) {
	path := DecodeURLParam(r, "*")
	spaceConfig := spaceConfigFromContext(r.Context())

	if err := spaceConfig.SpacePrimitives.DeleteFile(path); err != nil {
		if err == ErrNotFound {
			http.NotFound(w, r)
		} else {
			log.Printf("Error deleting file: %v\n", err)
			http.Error(w, err.Error(), http.StatusInternalServerError)
		}
		return
	}

	w.WriteHeader(http.StatusOK)
	w.Write([]byte("OK"))
}

// setFileMetaHeaders sets HTTP headers based on FileMeta
func setFileMetaHeaders(w http.ResponseWriter, meta FileMeta) {
	w.Header().Set("Content-Type", meta.ContentType)
	w.Header().Set("X-Created", strconv.FormatInt(meta.Created, 10))
	w.Header().Set("X-Last-Modified", strconv.FormatInt(meta.LastModified, 10))
	w.Header().Set("X-Content-Length", strconv.FormatInt(meta.Size, 10))
	w.Header().Set("X-Permission", meta.Perm)
	w.Header().Set("Cache-Control", "no-cache")
}

// Build FileMeta from HTTP headers (reverse of setFileMetaHeaders)
func getFileMetaFromHeaders(h http.Header, path string) *FileMeta {
	var err error

	fm := &FileMeta{
		Name:        path,
		ContentType: h.Get("Content-Type"),
		Perm:        h.Get("X-Permission"),
	}
	if fm.Perm == "" {
		fm.Perm = "ro"
	}
	if h.Get("X-Content-Length") != "" {
		fm.Size, err = strconv.ParseInt(h.Get("X-Content-Length"), 10, 64)
		if err != nil {
			log.Printf("Could not parse content length: %v", err)
		}
	} else if h.Get("Content-Length") != "" {
		fm.Size, err = strconv.ParseInt(h.Get("Content-Length"), 10, 64)
		if err != nil {
			log.Printf("Could not parse content length: %v", err)
		}
	}
	if h.Get("X-Created") != "" {
		fm.Created, err = strconv.ParseInt(h.Get("X-Created"), 10, 64)
		if err != nil {
			log.Printf("Could not parse created time: %v", err)
		}
	}
	if h.Get("X-Last-Modified") != "" {
		fm.LastModified, err = strconv.ParseInt(h.Get("X-Last-Modified"), 10, 64)
		if err != nil {
			log.Printf("Could not parse modified time: %v", err)
		}
	}

	return fm
}

func DecodeURLParam(r *http.Request, name string) string {
	// Source: https://github.com/go-chi/chi/issues/642
	value := chi.URLParam(r, name)
	if r.URL.RawPath != "" {
		value, _ = url.PathUnescape(value)
	}
	return value
}


================================================
FILE: server/logs_endpoint.go
================================================
package server

import (
	"log"
	"net/http"
	"strings"

	"github.com/go-chi/render"
)

type LogEntry struct {
	Source    string `json:"source"`
	Level     string `json:"level"`
	Message   string `json:"message"`
	Timestamp int64  `json:"timestamp"`
}

func handleLogsEndpoint(w http.ResponseWriter, r *http.Request) {
	var messages []LogEntry

	if err := render.DecodeJSON(r.Body, &messages); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	for _, message := range messages {
		remoteAddrPieces := strings.Split(r.RemoteAddr, ":")
		log.Printf("[Client %s] [%s %s] %s (%s)", remoteAddrPieces[0], message.Source, message.Level, message.Message, utcDateString(message.Timestamp))
	}
	render.PlainText(w, r, "OK")
}


================================================
FILE: server/manifest_endpoint.go
================================================
package server

import (
	"net/http"

	"github.com/go-chi/render"
)

// ManifestIcon represents an icon in the PWA manifest
type ManifestIcon struct {
	Src   string `json:"src"`
	Type  string `json:"type"`
	Sizes string `json:"sizes"`
}

// Manifest represents the structure of a PWA manifest.json file
type Manifest struct {
	ShortName       string         `json:"short_name"`
	Name            string         `json:"name"`
	Icons           []ManifestIcon `json:"icons"`
	CaptureLinks    string         `json:"capture_links"`
	StartURL        string         `json:"start_url"`
	Display         string         `json:"display"`
	DisplayOverride []string       `json:"display_override"`
	Scope           string         `json:"scope"`
	ThemeColor      string         `json:"theme_color"`
	Description     string         `json:"description"`
}

// Generate PWA manifest.json with dynamic values
func manifestHandler(w http.ResponseWriter, r *http.Request) {
	spaceConfig := spaceConfigFromContext(r.Context())
	config := serverConfigFromContext(r.Context())
	manifest := Manifest{
		ShortName: spaceConfig.SpaceName,
		Name:      spaceConfig.SpaceName,
		Icons: []ManifestIcon{
			{
				Src:   "/.client/logo-dock.png",
				Type:  "image/png",
				Sizes: "512x512",
			},
		},
		CaptureLinks:    "new-client",
		StartURL:        "/#boot",
		Display:         "standalone",
		DisplayOverride: []string{"window-controls-overlay"},
		Scope:           "/",
		ThemeColor:      "#e1e1e1",
		Description:     spaceConfig.SpaceDescription,
	}

	if config.HostURLPrefix != "" {
		manifest.Icons[0].Src = config.HostURLPrefix + manifest.Icons[0].Src
		manifest.StartURL = config.HostURLPrefix + manifest.StartURL
		manifest.Scope = config.HostURLPrefix + manifest.Scope
	}

	render.JSON(w, r, manifest)
}


================================================
FILE: server/prometheus.go
================================================
package server

import (
	"fmt"
	"log"
	"net/http"

	"github.com/go-chi/chi/v5"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

var httpRequestsTotal = prometheus.NewCounter(
	prometheus.CounterOpts{
		Name: "silverbullet_http_requests",
		Help: "Total number of HTTP requests to SilverBullet",
	},
)

func init() {
	prometheus.MustRegister(httpRequestsTotal)
}

func httpStatsMiddleware(config *ServerConfig) func(http.Handler) http.Handler {
	if config.MetricsPort == 0 {
		// NOOP
		return func(next http.Handler) http.Handler {
			return next
		}
	} else {
		return func(next http.Handler) http.Handler {
			return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
				httpRequestsTotal.Inc()
				next.ServeHTTP(w, r)
			})
		}
	}
}

func runMetricsServer(config *ServerConfig) {
	if config.MetricsPort != 0 {
		metricRouter := chi.NewRouter()
		metricsServer := &http.Server{
			Addr:    fmt.Sprintf("%s:%d", config.BindHost, config.MetricsPort),
			Handler: metricRouter,
		}
		metricRouter.Handle("/metrics", promhttp.Handler())
		log.Printf("Metrics server started on %s:%d", config.BindHost, config.MetricsPort)
		metricsServer.ListenAndServe()
	}
}


================================================
FILE: server/proxy.go
================================================
package server

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"regexp"
	"strconv"
	"strings"

	"github.com/go-chi/chi/v5"
	"github.com/prometheus/client_golang/prometheus"
)

var localhostRegex *regexp.Regexp = regexp.MustCompile(`^(localhost|127\.0\.0\.1|\d+\.\d+\.\d+\.\d+|host\.docker\.internal)`)

var proxyRequestsTotal = prometheus.NewCounter(prometheus.CounterOpts{
	Name: "silverbullet_proxy_requests",
	Help: "Total number of proxy requests in the SilverBullet space",
})

func init() {
	prometheus.MustRegister(proxyRequestsTotal)
}

func proxyHandler(w http.ResponseWriter, r *http.Request) {
	spaceConfig := spaceConfigFromContext(r.Context())
	if spaceConfig.ReadOnlyMode {
		http.Error(w, "Read only mode, no proxy allowed", http.StatusMethodNotAllowed)
		return
	}

	// Extract the URI from the path
	path := chi.URLParam(r, "*")
	if path == "" {
		http.Error(w, "No URI provided", http.StatusBadRequest)
		return
	}

	// Add query parameters if they exist
	if r.URL.RawQuery != "" {
		path += "?" + r.URL.RawQuery
	}

	// Determine protocol based on hostname
	var targetURL string
	if localhostRegex.MatchString(path) {
		targetURL = "http://" + path
	} else {
		targetURL = "https://" + path
	}

	log.Printf("Proxying to %s\n", targetURL)

	// Create the proxy request
	req, err := http.NewRequest(r.Method, targetURL, r.Body)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}

	// Copy headers starting with X-Proxy-Header-, removing the prefix
	for key, values := range r.Header {
		if strings.HasPrefix(strings.ToLower(key), "x-proxy-header-") {
			headerName := key[len("x-proxy-header-"):]
			for _, value := range values {
				req.Header.Set(headerName, value)
			}
		}
	}

	// TODO: Replaced with specifically configured client
	client := http.DefaultClient

	resp, err := client.Do(req)
	if err != nil {
		log.Printf("Error fetching proxied URL: %v\n", err)
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	defer resp.Body.Close()

	// Copy response response header with x-proxy-header prefix to keep things clean
	for key, values := range resp.Header {
		for _, value := range values {
			w.Header().Set(fmt.Sprintf("x-proxy-header-%s", key), value)
		}
	}

	// Put status code in header
	w.Header().Set("x-proxy-status-code", strconv.Itoa(resp.StatusCode))
	w.Header().Set("content-type", resp.Header.Get("content-type"))
	// Always return 200, to avoid interference, actual status code is in x-proxy-status-code
	w.WriteHeader(http.StatusOK)
	// Copy body
	if _, err := io.Copy(w, resp.Body); err != nil {
		log.Printf("Proxy: failed to copy response body: %v", err)
	}
	proxyRequestsTotal.Inc()
}


================================================
FILE: server/proxy_test.go
================================================
package server

import (
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestLocalhostRegex(t *testing.T) {
	// Should match (use HTTP)
	shouldMatch := []string{
		"localhost:8080/api",
		"127.0.0.1:8080/api",
		"192.168.1.1:8080/api",
		"host.docker.internal:11434/api",
	}

	// Should NOT match (use HTTPS)
	shouldNotMatch := []string{
		"api.openai.com/v1",
		"example.com:8443/api",
		"",
	}

	for _, input := range shouldMatch {
		assert.True(t, localhostRegex.MatchString(input), "should match: %s", input)
	}

	for _, input := range shouldNotMatch {
		assert.False(t, localhostRegex.MatchString(input), "should not match: %s", input)
	}
}


================================================
FILE: server/read_only_space_primitives.go
================================================
package server

import (
	"errors"
)

type ReadOnlySpacePrimitives struct {
	wrapped SpacePrimitives
}

var _ SpacePrimitives = &ReadOnlySpacePrimitives{}
var NotAllowedError = errors.New("Not allowed")

func NewReadOnlySpacePrimitives(wrapped SpacePrimitives) *ReadOnlySpacePrimitives {
	return &ReadOnlySpacePrimitives{wrapped: wrapped}
}

// FetchFileList retrieves a list of all files in the space
func (ro *ReadOnlySpacePrimitives) FetchFileList() ([]FileMeta, error) {
	return ro.wrapped.FetchFileList()
}

// GetFileMeta retrieves metadata for a specific file
func (ro *ReadOnlySpacePrimitives) GetFileMeta(path string) (FileMeta, error) {
	return ro.wrapped.GetFileMeta(path)
}

// ReadFile reads a file and returns its data and metadata
func (ro *ReadOnlySpacePrimitives) ReadFile(path string) ([]byte, FileMeta, error) {
	return ro.wrapped.ReadFile(path)
}

// WriteFile returns an error since this is a read-only implementation
func (ro *ReadOnlySpacePrimitives) WriteFile(path string, data []byte, meta *FileMeta) (FileMeta, error) {
	return FileMeta{}, NotAllowedError
}

// DeleteFile returns an error since this is a read-only implementation
func (ro *ReadOnlySpacePrimitives) DeleteFile(path string) error {
	return NotAllowedError
}


================================================
FILE: server/server.go
================================================
package server

import (
	"context"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/go-chi/render"
)

const indexHtmlPath = ".client/index.html"

// BootConfig represents the client configuration
type BootConfig struct {
	SpaceFolderPath string `json:"spaceFolderPath"`
	IndexPage       string `json:"indexPage"`
	ReadOnly        bool   `json:"readOnly"`

	// Whether or not the client should push logs to the server
	LogPush bool `json:"logPush"`

	// Encryption
	EnableClientEncryption bool `json:"enableClientEncryption"`
}

func Router(config *ServerConfig) chi.Router {
	r := chi.NewRouter()

	if config.EnableHTTPLogging {
		r.Use(middleware.Logger)
	}

	r.Use(middleware.RealIP)
	r.Use(middleware.Compress(5, "application/json", "text/markdown", "text/javascript", "text/html", "text/css", "text/plain"))

	// Expose space primitives and path to the request
	r.Use(spaceMiddleware(config))

	r.Use(httpStatsMiddleware(config))

	// Authentication middleware (applies to all routes after this point)
	r.Use(authMiddleware(config))

	routes := chi.NewRouter()

	// Authentication endpoints (must come before auth middleware)
	addAuthEndpoints(routes, config)

	routes.Get("/.ping", func(w http.ResponseWriter, r *http.Request) {
		spaceConfig := spaceConfigFromContext(r.Context())
		w.Header().Set("Cache-Control", "no-cache")
		w.Header().Set("X-Space-Path", spaceConfig.SpaceFolderPath)
		w.WriteHeader(http.StatusOK)
		w.Write([]byte("OK"))
	})

	// Mount filesystem routes under /.fs
	routes.Mount("/.fs", buildFsRoutes())

	// Config endpoint
	routes.Get("/.config", func(w http.ResponseWriter, r *http.Request) {
		spaceConfig := spaceConfigFromContext(r.Context())
		clientConfig := &BootConfig{
			SpaceFolderPath: spaceConfig.SpaceFolderPath,
			IndexPage:       spaceConfig.IndexPage,
			ReadOnly:        spaceConfig.ReadOnlyMode,
			LogPush:         spaceConfig.LogPush,
			// Client encryption is offered as an option when auth is enabled only
			EnableClientEncryption: spaceConfig.Auth != nil,
		}

		w.Header().Set("Cache-Control", "no-cache")
		render.JSON(w, r, clientConfig)
	})

	// Shell endpoint
	routes.Post("/.shell", handleShellEndpoint)

	// Log collection endpoint
	routes.Post("/.logs", handleLogsEndpoint)

	// Proxy endpoint
	routes.HandleFunc("/.proxy/*", proxyHandler)

	// Manifest endpoint
	routes.HandleFunc("/.client/manifest.json", manifestHandler)

	routes.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
		path := DecodeURLParam(r, "*")

		spaceConfig := spaceConfigFromContext(r.Context())

		// See if it's in the client bundle
		data, meta, err := config.ClientBundle.ReadFile(path)
		if err == nil {
			// File is in the bundle, let's serve it
			if r.Header.Get("If-Modified-Since") == utcDateString(meta.LastModified) {
				w.WriteHeader(304)
				return
			}
			w.Header().Set("Content-Type", meta.ContentType)
			w.Header().Set("Last-Modified", utcDateString(meta.LastModified))
			w.WriteHeader(200)
			w.Write(data)
			return
		}

		// TODO: handle request types
		ServerSideRender(config, spaceConfig, path, w, r)
	})

	if config.HostURLPrefix == "" {
		r.Mount("/", routes)
	} else {
		r.Mount(config.HostURLPrefix, routes)
	}
	return r
}

func RunServer(config *ServerConfig) error {
	r := Router(config)
	// Display the final server running message
	visibleHostname := config.BindHost
	if config.BindHost == "127.0.0.1" {
		visibleHostname = "localhost"
	}
	log.Printf("SilverBullet is now running: http://%s:%d", visibleHostname, config.Port)

	server := &http.Server{
		Addr:    fmt.Sprintf("%s:%d", config.BindHost, config.Port),
		Handler: r,
	}

	shutdownChannel := make(chan bool, 1)

	go func() {
		if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("HTTP server error: %v", err)
		}
		log.Println("Stopped serving new connections.")
		shutdownChannel <- true
	}()

	go runMetricsServer(config)

	signalChannel := make(chan os.Signal, 1)
	signal.Notify(signalChannel, syscall.SIGINT, syscall.SIGTERM)
	// Block on incoming signals.
	s := <-signalChannel
	log.Println("Received signal:", s)

	shutdownCtx, shutdownRelease := context.WithTimeout(context.Background(), 10*time.Second)
	defer shutdownRelease()

	if err := server.Shutdown(shutdownCtx); err != nil {
		log.Fatalf("HTTP shutdown error: %v", err)
	}
	<-shutdownChannel
	log.Println("Graceful shutdown complete.")
	return nil
}


================================================
FILE: server/shell_backend.go
================================================
package server

import (
	"bytes"
	"log"
	"os/exec"
	"slices"
	"strings"
)

// LocalShell implements shell execution on the local system
type LocalShell struct {
	Cwd          string
	CmdWhiteList []string
	AllowAllCmds bool
}

// NewLocalShell creates a new LocalShell instance
func NewLocalShell(cwd string, cmdWhiteList string) *LocalShell {
	whiteListedCommands := strings.Split(cmdWhiteList, " ")
	if cmdWhiteList == "" {
		whiteListedCommands = []string{}
	}
	return &LocalShell{
		Cwd:          cwd,
		CmdWhiteList: whiteListedCommands,
		AllowAllCmds: len(whiteListedCommands) == 0,
	}
}

// Handle executes a shell command and returns the result
func (ls *LocalShell) Handle(request ShellRequest) (ShellResponse, error) {
	// Check if command is whitelisted
	if !ls.AllowAllCmds && !slices.Contains(ls.CmdWhiteList, request.Cmd) {
		log.Printf("Not running shell command because not in whitelist: %s\n", request.Cmd)
		return ShellResponse{
			Code:   -1,
			Stdout: "",
			Stderr: "Not allowed, command not in whitelist",
		}, nil
	}

	log.Printf("Running shell command: %s %s\n", request.Cmd, strings.Join(request.Args, " "))

	// Create the command
	cmd := exec.Command(request.Cmd, request.Args...)
	cmd.Dir = ls.Cwd

	// Set up stdin if provided
	if request.Stdin != nil {
		cmd.Stdin = strings.NewReader(*request.Stdin)
	}

	// Set up separate buffers for stdout and stderr
	var stdout, stderr bytes.Buffer
	cmd.Stdout = &stdout
	cmd.Stderr = &stderr

	// Execute the command
	err := cmd.Run()

	var exitCode int
	if err != nil {
		// Try to get the exit code
		if exitError, ok := err.(*exec.ExitError); ok {
			exitCode = exitError.ExitCode()
		} else {
			// Command failed to start
			return ShellResponse{
				Code:   -1,
				Stdout: "",
				Stderr: err.Error(),
			}, nil
		}
	}

	return ShellResponse{
		Code:   exitCode,
		Stdout: stdout.String(),
		Stderr: stderr.String(),
	}, nil
}

// NotSupportedShell implements a shell backend that doesn't support execution
type NotSupportedShell struct{}

// NewNotSupportedShell creates a new NotSupportedShell instance
func NewNotSupportedShell() *NotSupportedShell {
	return &NotSupportedShell{}
}

// Handle always returns a "not supported" error
func (nss *NotSupportedShell) Handle(request ShellRequest) (ShellResponse, error) {
	return ShellResponse{
		Code:   1,
		Stdout: "",
		Stderr: "Not supported",
	}, nil
}


================================================
FILE: server/shell_backend_test.go
================================================
package server

import (
	"os"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestLocalShell_HandleValidCommand(t *testing.T) {
	tmpDir := t.TempDir()
	shell := NewLocalShell(tmpDir, "") // No whitelist = allow all commands

	request := ShellRequest{
		Cmd:  "echo",
		Args: []string{"Hello World"},
	}

	response, err := shell.Handle(request)
	assert.NoError(t, err, "Should handle valid command without error")
	assert.Equal(t, 0, response.Code, "Echo command should exit with code 0")
	assert.Equal(t, "Hello World\n", response.Stdout, "Should return stdout")
	assert.Empty(t, response.Stderr, "Should have empty stderr")
}

func TestLocalShell_HandleCommandWithStdin(t *testing.T) {
	tmpDir := t.TempDir()
	shell := NewLocalShell(tmpDir, "")

	input := "test input"
	request := ShellRequest{
		Cmd:   "cat",
		Args:  []string{},
		Stdin: &input,
	}

	response, err := shell.Handle(request)
	assert.NoError(t, err, "Should handle command with stdin without error")
	assert.Equal(t, 0, response.Code, "cat command should exit with code 0")
	assert.Equal(t, "test input", response.Stdout, "Should echo stdin to stdout")
	assert.Empty(t, response.Stderr, "Should have empty stderr")
}

func TestLocalShell_HandleWorkingDirectory(t *testing.T) {
	tmpDir := t.TempDir()
	shell := NewLocalShell(tmpDir, "")

	// Create a test file in the temp directory
	testFile := filepath.Join(tmpDir, "test.txt")
	err := os.WriteFile(testFile, []byte("test content"), 0644)
	assert.NoError(t, err, "Should create test file")

	request := ShellRequest{
		Cmd:  "ls",
		Args: []string{},
	}

	response, err := shell.Handle(request)
	assert.NoError(t, err, "Should handle ls command without error")
	assert.Equal(t, 0, response.Code, "ls command should exit with code 0")
	assert.Contains(t, response.Stdout, "test.txt", "Should list the test file")
	assert.Empty(t, response.Stderr, "Should have empty stderr")
}

func TestLocalShell_HandleWithWhitelist(t *testing.T) {
	tmpDir := t.TempDir()
	shell := NewLocalShell(tmpDir, "echo pwd")

	// Test allowed command
	request := ShellRequest{
		Cmd:  "echo",
		Args: []string{"allowed"},
	}

	response, err := shell.Handle(request)
	assert.NoError(t, err, "Should handle whitelisted command without error")
	assert.Equal(t, 0, response.Code, "Whitelisted command should succeed")
	assert.Equal(t, "allowed\n", response.Stdout, "Should return expected output")

	// Test disallowed command
	request = ShellRequest{
		Cmd:  "ls",
		Args: []string{},
	}

	response, err = shell.Handle(request)
	assert.NoError(t, err, "Should handle request without error")
	assert.Equal(t, -1, response.Code, "Non-whitelisted command should return -1")
	assert.Empty(t, response.Stdout, "Should have empty stdout")
	assert.Equal(t, "Not allowed, command not in whitelist", response.Stderr, "Should return whitelist error")
}

func TestLocalShell_HandleCommandWithBothStreams(t *testing.T) {
	tmpDir := t.TempDir()
	shell := NewLocalShell(tmpDir, "")

	// Create a shell script that outputs to both stdout and stderr
	script := `#!/bin/sh
echo "stdout message"
echo "stderr message" >&2
exit 0`

	scriptPath := filepath.Join(tmpDir, "test_script.sh")
	err := os.WriteFile(scriptPath, []byte(script), 0755)
	assert.NoError(t, err, "Should create test script")

	request := ShellRequest{
		Cmd:  "sh",
		Args: []string{scriptPath},
	}

	response, err := shell.Handle(request)
	assert.NoError(t, err, "Should handle script without error")
	assert.Equal(t, 0, response.Code, "Script should exit with code 0")
	assert.Equal(t, "stdout message\n", response.Stdout, "Should capture stdout")
	assert.Equal(t, "stderr message\n", response.Stderr, "Should capture stderr")
}


================================================
FILE: server/shell_endpoint.go
================================================
package server

import (
	"encoding/json"
	"log"
	"net/http"

	"github.com/prometheus/client_golang/prometheus"
)

var shellCommandsTotal = prometheus.NewCounter(prometheus.CounterOpts{
	Name: "silverbullet_shell_executions",
	Help: "Total number of shell commands executions",
})

func init() {
	prometheus.MustRegister(shellCommandsTotal)
}

// handleShellEndpoint handles POST requests to /.shell for executing shell commands
func handleShellEndpoint(w http.ResponseWriter, r *http.Request) {
	spaceConfig := spaceConfigFromContext(r.Context())
	// Parse the request body
	var shellRequest ShellRequest
	if err := json.NewDecoder(r.Body).Decode(&shellRequest); err != nil {
		http.Error(w, "Invalid JSON request", http.StatusBadRequest)
		return
	}

	// Execute the shell command
	shellResponse, err := spaceConfig.ShellBackend.Handle(shellRequest)
	if err != nil {
		log.Printf("Shell error: %v\n", err)
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	shellCommandsTotal.Inc()

	// Return the response as JSON
	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(shellResponse); err != nil {
		log.Printf("Error encoding shell response: %v\n", err)
		http.Error(w, "Internal server error", http.StatusInternalServerError)
		return
	}
}


================================================
FILE: server/space_primitive_testing.go
================================================
package server

import (
	"bytes"
	"testing"

	"github.com/stretchr/testify/assert"
)

// TestSpacePrimitives runs a comprehensive test suite against any SpacePrimitives implementation
func TestSpacePrimitives(t *testing.T, spacePrimitives SpacePrimitives) {
	t.Run("BasicOperations", func(t *testing.T) {
		testBasicOperations(t, spacePrimitives)
	})
	t.Run("FileOverwriting", func(t *testing.T) {
		testFileOverwriting(t, spacePrimitives)
	})
	t.Run("EmptyFiles", func(t *testing.T) {
		testEmptyFiles(t, spacePrimitives)
	})
	t.Run("UnicodeContent", func(t *testing.T) {
		testUnicodeContent(t, spacePrimitives)
	})
	t.Run("SpecialFileNames", func(t *testing.T) {
		testSpecialFileNames(t, spacePrimitives)
	})
	t.Run("ErrorHandling", func(t *testing.T) {
		testErrorHandling(t, spacePrimitives)
	})
	t.Run("LargeFiles", func(t *testing.T) {
		testLargeFiles(t, spacePrimitives)
	})
	t.Run("MetadataPreservation", func(t *testing.T) {
		testMetadataPreservation(t, spacePrimitives)
	})

	// Ensure clean state at the end
	finalFiles, err := spacePrimitives.FetchFileList()
	assert.NoError(t, err, "Failed to fetch final file list")
	assert.Empty(t, finalFiles, "Expected empty file list at end")
}

func testBasicOperations(t *testing.T, spacePrimitives SpacePrimitives) {
	// Test initial empty state
	files, err := spacePrimitives.FetchFileList()
	assert.NoError(t, err, "Failed to fetch file list")
	assert.Empty(t, files, "Expected empty file list")

	// Write text file
	testContent := []byte("Hello World")
	fileMeta := &FileMeta{
		Name:         "test.txt",
		Perm:         "rw",
		Created:      10,
		ContentType:  "text/plain",
		LastModified: 20,
		Size:         11,
	}

	_, err = spacePrimitives.WriteFile("test.txt", testContent, fileMeta)
	assert.NoError(t, err, "Failed to write file")

	// Read the file back
	retrievedData, retrievedMeta, err := spacePrimitives.ReadFile("test.txt")
	assert.NoError(t, err, "Failed to read file")

	assert.Equal(t, testContent, retrievedData, "Retrieved data doesn't match")

	// Check that the meta data is persisted (lastModified should be preserved)
	assert.Equal(t, int64(20), retrievedMeta.LastModified, "Expected lastModified to be 20")

	// Check file list
	fileList, err := spacePrimitives.FetchFileList()
	assert.NoError(t, err, "Failed to fetch file list")
	assert.Len(t, fileList, 1, "Expected 1 file in list")
	assert.Equal(t, "test.txt", fileList[0].Name, "Expected file name 'test.txt'")

	// Write binary file
	buf := make([]byte, 1024*1024)
	buf[0] = 1
	buf[1] = 2
	buf[2] = 3
	buf[3] = 4
	buf[4] = 5

	_, err = spacePrimitives.WriteFile("test.bin", buf, nil)
	assert.NoError(t, err, "Failed to write binary file")

	fileData, _, err := spacePrimitives.ReadFile("test.bin")
	assert.NoError(t, err, "Failed to read binary file")

	assert.Len(t, fileData, 1024*1024, "Expected binary file size to match")

	fileList, err = spacePrimitives.FetchFileList()
	assert.NoError(t, err, "Failed to fetch file list")
	assert.Len(t, fileList, 2, "Expected 2 files in list")

	// Delete binary file
	err = spacePrimitives.DeleteFile("test.bin")
	assert.NoError(t, err, "Failed to delete binary file")

	fileList, err = spacePrimitives.FetchFileList()
	assert.NoError(t, err, "Failed to fetch file list")
	assert.Len(t, fileList, 1, "Expected 1 file in list after deletion")

	// Clean up
	err = spacePrimitives.DeleteFile("test.txt")
	assert.NoError(t, err, "Failed to delete test file")

	fileList, err = spacePrimitives.FetchFileList()
	assert.NoError(t, err, "Failed to fetch file list")
	assert.Empty(t, fileList, "Expected empty file list after cleanup")

	// Test weird file names
	weirdContent := []byte("Hello world!")
	_, err = spacePrimitives.WriteFile("test+'s.txt", weirdContent, nil)
	assert.NoError(t, err, "Failed to write file with weird name")

	readWeirdData, _, err := spacePrimitives.ReadFile("test+'s.txt")
	assert.NoError(t, err, "Failed to read file with weird name")

	assert.Equal(t, weirdContent, readWeirdData, "Weird file content doesn't match")

	err = spacePrimitives.DeleteFile("test+'s.txt")
	assert.NoError(t, err, "Failed to delete weird file")

	// Check deletion of weird file name
	_, _, err = spacePrimitives.ReadFile("test+'s.txt")
	assert.Equal(t, ErrNotFound, err, "Expected ErrNotFound when reading deleted file")
}

func testFileOverwriting(t *testing.T, spacePrimitives SpacePrimitives) {
	// Test overwriting existing files
	originalContent := []byte("Original")
	_, err := spacePrimitives.WriteFile("overwrite.txt", originalContent, nil)
	assert.NoError(t, err, "Failed to write original file")

	_, err = spacePrimitives.GetFileMeta("overwrite.txt")
	assert.NoError(t, err, "Failed to get original meta")

	updatedContent := []byte("Updated")
	_, err = spacePrimitives.WriteFile("overwrite.txt", updatedContent, nil)
	assert.NoError(t, err, "Failed to overwrite file")

	updatedData, _, err := spacePrimitives.ReadFile("overwrite.txt")
	assert.NoError(t, err, "Failed to read overwritten file")

	assert.Equal(t, updatedContent, updatedData, "Overwritten content doesn't match")

	// File list should still have only one entry for this file
	filesAfterOverwrite, err := spacePrimitives.FetchFileList()
	assert.NoError(t, err, "Failed to fetch files after overwrite")

	overwriteFiles := 0
	for _, f := range filesAfterOverwrite {
		if f.Name == "overwrite.txt" {
			overwriteFiles++
		}
	}

	assert.Equal(t, 1, overwriteFiles, "Expected 1 instance of overwrite.txt")

	err = spacePrimitives.DeleteFile("overwrite.txt")
	assert.NoError(t, err, "Failed to delete overwritten file")

	// Test writing files to new folders
	writeMeta, err := spacePrimitives.WriteFile("some/folder/test.dat", []byte("Testing"), nil)
	assert.NoError(t, err, "Writing to a nested folder should work")

	// Read it back
	data, meta, err := spacePrimitives.ReadFile("some/folder/test.dat")
	assert.NoError(t, err, "Reading should work")
	assert.Equal(t, writeMeta.LastModified, meta.LastModified, "Meta should be the same")
	assert.Equal(t, []byte("Testing"), data, "Did not get back what I put in")

	err = spacePrimitives.DeleteFile("some/folder/test.dat")
	assert.NoError(t, err, "Deletion should work")
}

func testEmptyFiles(t *testing.T, spacePrimitives SpacePrimitives) {
	// Test empty file
	emptyData := []byte{}
	_, err := spacePrimitives.WriteFile("empty.txt", emptyData, nil)
	assert.NoError(t, err, "Failed to write empty file")

	emptyFileData, emptyFileMeta, err := spacePrimitives.ReadFile("empty.txt")
	assert.NoError(t, err, "Failed to read empty file")

	assert.Empty(t, emptyFileData, "Expected empty file data")
	assert.Equal(t, int64(0), emptyFileMeta.Size, "Expected empty file meta size 0")

	err = spacePrimitives.DeleteFile("empty.txt")
	assert.NoError(t, err, "Failed to delete empty file")
}

func testUnicodeContent(t *testing.T, spacePrimitives SpacePrimitives) {
	// Test files with Unicode characters
	unicodeContent := []byte("Hello 世界! 🌍 Здравствуй мир!")
	_, err := spacePrimitives.WriteFile("unicode.txt", unicodeContent, nil)
	assert.NoError(t, err, "Failed to write unicode file")

	unicodeData, _, err := spacePrimitives.ReadFile("unicode.txt")
	assert.NoError(t, err, "Failed to read unicode file")

	assert.Equal(t, unicodeContent, unicodeData, "Unicode content doesn't match")

	err = spacePrimitives.DeleteFile("unicode.txt")
	assert.NoError(t, err, "Failed to delete unicode file")
}

func testSpecialFileNames(t *testing.T, spacePrimitives SpacePrimitives) {
	// Test file names with various special characters
	specialNames := []string{
		"file with spaces.txt",
		"file-with-hyphens.txt",
		"file_with_underscores.txt",
		"file.with.dots.txt",
		"UPPERCASE.TXT",
		"123numeric.txt",
		"émojis🚀file.txt",
	}

	for _, fileName := range specialNames {
		content := []byte("Content of " + fileName)
		_, err := spacePrimitives.WriteFile(fileName, content, nil)
		assert.NoError(t, err, "Failed to write file %s", fileName)

		fileData, _, err := spacePrimitives.ReadFile(fileName)
		assert.NoError(t, err, "Failed to read file %s", fileName)

		expectedContent := "Content of " + fileName
		assert.Equal(t, expectedContent, string(fileData), "Content mismatch for %s", fileName)
	}

	// Verify all special files are in the list
	allFiles, err := spacePrimitives.FetchFileList()
	assert.NoError(t, err, "Failed to fetch file list")

	for _, fileName := range specialNames {
		found := false
		for _, f := range allFiles {
			if f.Name == fileName {
				found = true
				break
			}
		}
		assert.True(t, found, "File %s should be in the file list", fileName)
	}

	// Clean up special files
	for _, fileName := range specialNames {
		err := spacePrimitives.DeleteFile(fileName)
		assert.NoError(t, err, "Failed to delete file %s", fileName)
	}
}

func testErrorHandling(t *testing.T, spacePrimitives SpacePrimitives) {
	// Test error cases
	_, _, err := spacePrimitives.ReadFile("nonexistent.txt")
	assert.Equal(t, ErrNotFound, err, "Expected ErrNotFound for non-existent file")

	err = spacePrimitives.DeleteFile("nonexistent.txt")
	assert.Equal(t, ErrNotFound, err, "Expected ErrNotFound when deleting non-existent file")

	_, err = spacePrimitives.GetFileMeta("nonexistent.txt")
	assert.Equal(t, ErrNotFound, err, "Expected ErrNotFound for non-existent file meta")
}

func testLargeFiles(t *testing.T, spacePrimitives SpacePrimitives) {
	// Test large file content
	largeContent := make([]byte, 5*1024*1024) // 5MB
	for i := 0; i < len(largeContent); i++ {
		largeContent[i] = byte(i % 256)
	}

	_, err := spacePrimitives.WriteFile("large.bin", largeContent, nil)
	assert.NoError(t, err, "Failed to write large file")

	largeData, largeMeta, err := spacePrimitives.ReadFile("large.bin")
	assert.NoError(t, err, "Failed to read large file")

	assert.Len(t, largeData, len(largeContent), "Large file size mismatch")
	assert.Equal(t, int64(len(largeContent)), largeMeta.Size, "Large file meta size mismatch")

	// Verify content integrity (check first 1000 bytes)
	checkSize := 1000
	if len(largeContent) < checkSize {
		checkSize = len(largeContent)
	}

	assert.True(t, bytes.Equal(largeData[:checkSize], largeContent[:checkSize]), "Large file content mismatch in first %d bytes", checkSize)

	err = spacePrimitives.DeleteFile("large.bin")
	assert.NoError(t, err, "Failed to delete large file")
}

func testMetadataPreservation(t *testing.T, spacePrimitives SpacePrimitives) {
	// Test metadata preservation
	testContent := []byte("Hello meta!")
	customMeta := &FileMeta{
		Name:         "meta-test.txt",
		Perm:         "rw",
		Created:      1000000,
		ContentType:  "text/plain",
		LastModified: 2000000,
		Size:         int64(len(testContent)),
	}

	_, err := spacePrimitives.WriteFile("meta-test.txt", testContent, customMeta)
	assert.NoError(t, err, "Failed to write file with custom meta")

	_, metaFile, err := spacePrimitives.ReadFile("meta-test.txt")
	assert.NoError(t, err, "Failed to read file with meta")

	// Check that some metadata is preserved (implementations may handle timestamps differently)
	assert.Greater(t, metaFile.LastModified, int64(0), "LastModified timestamp should be set")
	assert.Equal(t, "meta-test.txt", metaFile.Name, "Expected name 'meta-test.txt'")
	assert.Equal(t, int64(len(testContent)), metaFile.Size, "Expected size to match content length")

	err = spacePrimitives.DeleteFile("meta-test.txt")
	assert.NoError(t, err, "Failed to delete meta test file")
}


================================================
FILE: server/spaces.go
================================================
package server

import (
	"context"
	"net/http"
)

const spaceConfigKey = "spaceConfig"
const serverConfigKey = "serverConfig"

func spaceMiddleware(config *ServerConfig) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			spaceConfig, err := config.SpaceConfigResolver(r)
			if err != nil {
				http.Error(w, err.Error(), http.StatusInternalServerError)
				return
			}
			ctx := context.WithValue(r.Context(), spaceConfigKey, spaceConfig)
			ctx = context.WithValue(ctx, serverConfigKey, config)
			augmentedRequest := r.WithContext(ctx)
			next.ServeHTTP(w, augmentedRequest)
		})
	}
}

func spaceConfigFromContext(ctx context.Context) *SpaceConfig {
	return ctx.Value(spaceConfigKey).(*SpaceConfig)
}

func serverConfigFromContext(ctx context.Context) *ServerConfig {
	return ctx.Value(serverConfigKey).(*ServerConfig)
}


================================================
FILE: server/ssr.go
================================================
package server

import (
	"fmt"
	"html/template"
	"log"
	"net/http"
	pathLib "path"
	"regexp"
	"strings"

	"github.com/gomarkdown/markdown"
	"github.com/gomarkdown/markdown/html"
	"github.com/gomarkdown/markdown/parser"
)

// This implements very crude server-side rendered markdown using a Go markdown library
// Note: this not even nearly supports all SilverBullet markdown features. It's best effort.
// The ONLY use case of this is search engine indexing
// It is only activated when the space is both read-only and has no authentication, so basically the silverbullet.md scenario.

// Very crude markdown link replacement
func convertWikiLinksToMarkdown(input string) string {
	// Regular expression to match [[text]] patterns
	re := regexp.MustCompile(`\[\[([^\]]+)\]\]`)

	// Replace all matches with [text](text) format
	result := re.ReplaceAllStringFunc(input, func(match string) string {
		// Extract the text between [[ and ]]
		text := strings.TrimPrefix(match, "[[")
		text = strings.TrimSuffix(text, "]]")

		return fmt.Sprintf("[%s](%s)", text, text)
	})

	return result
}

func ServerSideRender(config *ServerConfig, spaceConfig *SpaceConfig, pageName string, w http.ResponseWriter, r *http.Request) {
	// Let's fetch the index.html text
	indexHtmlBuf, _, _ := config.ClientBundle.ReadFile(indexHtmlPath)

	if pageName == "" {
		pageName = spaceConfig.IndexPage
	}

	tpl := template.New("index_html")
	tpl.Funcs(template.FuncMap{
		"markdown": func(text string) template.HTML {
			if text == "" {
				// Let's not even bother doing anything
				return ""
			}

			// Setup markdown renderer
			var markdownParser = parser.NewWithExtensions(parser.CommonExtensions)
			var markdownRenderer = html.NewRenderer(html.RendererOptions{})
			text = convertWikiLinksToMarkdown(text)
			doc := markdownParser.Parse([]byte(text))
			return template.HTML(markdown.Render(doc, markdownRenderer))
		},
	})
	tpl = template.Must(tpl.Parse(string(indexHtmlBuf)))

	templateData := struct {
		HostPrefix         string
		Title              string
		Description        string
		Content            string
		AdditionalHeadHTML template.HTML
	}{
		Title:              "SilverBullet",
		HostPrefix:         config.HostURLPrefix,
		AdditionalHeadHTML: spaceConfig.AdditionalHeadHTML,
	}

	if spaceConfig.ReadOnlyMode && spaceConfig.Auth == nil {
		// Read only and without out, let's do some server side rendering
		templateData.Title = pathLib.Base(pageName)

		// Read page content
		pageData, _, err := spaceConfig.SpacePrimitives.ReadFile(fmt.Sprintf("%s.md", pageName))
		if err != nil {
			log.Printf("Could not read content for page %s", pageName)
		} else {
			templateData.Content = string(pageData)
		}
	}

	w.Header().Set("Content-type", "text/html")
	w.WriteHeader(http.StatusOK)
	if err := tpl.Execute(w, templateData); err != nil {
		log.Printf("SSR error for %s: %v", pageName, err)
		w.Write([]byte("Server error"))
	}
}


================================================
FILE: server/types.go
================================================
package server

import (
	"errors"
	"html/template"
	"net/http"
	"sync"
)

type ServerConfig struct {
	SpaceConfigResolver ConfigResolver

	BindHost          string
	Port              int
	MetricsPort       int
	EnableHTTPLogging bool
	// TODO: Ideally this is configurable per space, but kinda hard
	HostURLPrefix string

	ClientBundle SpacePrimitives
}

type UserPasswordAuthorizer func(username, password string) bool

type SpaceConfig struct {
	Hostname string
	Auth     *AuthOptions

	Authorize UserPasswordAuthorizer

	SpacePrimitives SpacePrimitives

	SpaceFolderPath string
	IndexPage       string
	GitIgnore       string
	ReadOnlyMode    bool
	LogPush         bool

	// Used for the PWA manifest and login page
	SpaceName        string
	SpaceDescription string

	// Used to inject additional HTML into the <head> of index.html
	AdditionalHeadHTML template.HTML

	// Shell configuration
	ShellBackend ShellBackend

	// Auth temporary objects
	JwtIssuer    *Authenticator
	LockoutTimer *LockoutTimer
	authMutex    sync.Mutex
}

type ConfigResolver func(r *http.Request) (*SpaceConfig, error)

// FileMeta represents metadata for a file in the space
type FileMeta struct {
	Name         string `json:"name"`
	Created      int64  `json:"created"`
	LastModified int64  `json:"lastModified"`
	ContentType  string `json:"contentType"`
	Size         int64  `json:"size"`
	Perm         string `json:"perm"` // "ro" or "rw"
}

// SpacePrimitives defines the interface for storage backends
type SpacePrimitives interface {
	// FetchFileList retrieves a list of all files in the space
	FetchFileList() ([]FileMeta, error)

	// GetFileMeta retrieves metadata for a specific file
	GetFileMeta(path string) (FileMeta, error)

	// ReadFile reads a file and returns its data and metadata
	ReadFile(path string) ([]byte, FileMeta, error)

	// WriteFile writes data to a file with optional metadata
	// Returns the actual metadata of the written file
	WriteFile(path string, data []byte, meta *FileMeta) (FileMeta, error)

	// DeleteFile removes a file from the space
	DeleteFile(path string) error
}

// Shell request/response types
type ShellRequest struct {
	Cmd   string   `json:"cmd"`
	Args  []string `json:"args"`
	Stdin *string  `json:"stdin,omitempty"`
}

type ShellResponse struct {
	Stdout string `json:"stdout"`
	Stderr string `json:"stderr"`
	Code   int    `json:"code"`
}

// Shell backend interface
type ShellBackend interface {
	Handle(request ShellRequest) (ShellResponse, error)
}

// Auth options for user authentication
type AuthOptions struct {
	AuthToken       string `json:"authToken,omitempty"`
	User            string `json:"user"`
	Pass            string `json:"pass"`
	LockoutTime     int    `json:"lockoutTime"`  // in seconds
	LockoutLimit    int    `json:"lockoutLimit"`
	RememberMeHours int    `json:"rememberMeHours"` // duration for "remember me" sessions
}

// Common errors
var (
	ErrNotFound        = errors.New("Not found")
	ErrPathOutsideRoot = errors.New("Path not in space")
	ErrCouldNotWrite   = errors.New("Could not write file")
	ErrCouldNotGetMeta = errors.New("Could not get file metadata")
)


================================================
FILE: server/util.go
================================================
package server

import (
	"mime"
	"path/filepath"
	"strings"
)

// Custom MIME type mappings for file extensions not in the system database
var mimeTypeExtMapping = map[string]string{
	".md":   "text/markdown",
	".heic": "image/heic",
	".heif": "image/heic",
}

// lookupContentTypeFromPath determines MIME type based on file extension
func LookupContentTypeFromPath(path string) string {
	ext := strings.ToLower(filepath.Ext(path))

	// Check our custom MIME type mappings first
	if mimeType, exists := mimeTypeExtMapping[ext]; exists {
		return mimeType
	}

	// Fall back to system MIME type detection
	contentType := mime.TypeByExtension(ext)
	if contentType == "" {
		return "application/octet-stream"
	}
	return contentType
}


================================================
FILE: server/util_test.go
================================================
package server

import (
	"testing"
)

func TestLookupContentTypeFromPath(t *testing.T) {
	tests := []struct {
		name     string
		path     string
		expected string
	}{
		// Specific file types we handle
		{
			name:     "markdown file",
			path:     "document.md",
			expected: "text/markdown",
		},
		{
			name:     "HEIF file",
			path:     "photo.heif",
			expected: "image/heic",
		},
		// Mixed case and path and specific file type
		{
			name:     "mixed case HEIC",
			path:     "/path/to/image.HeIc",
			expected: "image/heic",
		},

		// Special characters
		{
			name:     "unicode filename",
			path:     "фото.heic",
			expected: "image/heic",
		},
		{
			name:     "filename with hyphen underscore",
			path:     "my-file_v2.md",
			expected: "text/markdown",
		},

		// Well-known extensions (system handled)
		{
			name:     "JPEG file",
			path:     "/path/to/image.jpg",
			expected: "image/jpeg",
		},
		{
			name:     "PNG file",
			path:     "image.png",
			expected: "image/png",
		},
		{
			name:     "PDF file",
			path:     "document.pdf",
			expected: "application/pdf",
		},

		// Unknown extension
		{
			name:     "unknown extension",
			path:     "file.unknownext",
			expected: "application/octet-stream",
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			result := LookupContentTypeFromPath(tt.path)
			if result != tt.expected {
				t.Errorf("LookupContentTypeFromPath(%q) = %q, expected %q", tt.path, result, tt.expected)
			}
		})
	}
}


================================================
FILE: server/version.go
================================================
package server

import (
	"fmt"
	"regexp"
	"strings"
)

func ParseVersionFromTypeScript(code string) (string, error) {
	// Parse the version using regex
	re := regexp.MustCompile(`"([^"]+)"`)
	matches := re.FindStringSubmatch(code)

	if len(matches) < 2 {
		return "", fmt.Errorf("version not found in file")
	}

	version := strings.TrimSpace(matches[1])
	if version == "" {
		return "", fmt.Errorf("empty version found")
	}

	return version, nil
}


================================================
FILE: server/version_test.go
================================================
package server

import (
	"testing"

	"github.com/stretchr/testify/assert"
)

func TestVersionParsing(t *testing.T) {
	version, err := ParseVersionFromTypeScript(`export const publicVersion = "2.0.0"`)
	assert.NoError(t, err)
	assert.Equal(t, "2.0.0", version)
}


================================================
FILE: website/API.md
================================================
This describes the APIs available in [[Space Lua]]:
${widgets.subPages("API")}


================================================
FILE: website/API/asset.md
================================================
#api/syscall

The Asset API provides functions for reading and managing assets embedded in plugs.

### asset.readAsset(plugName, name, encoding?)
Reads an asset embedded in a plug.

Parameters:
- `plugName`: Name of the plug to read asset from
- `name`: Name of the asset to read
- `encoding`: Optional encoding type, either "utf8" (default) or "dataurl"

Returns the content of the asset in the requested encoding.

Example:
```lua
-- Read a text file as UTF-8
local text = asset.readAsset("myplug", "data.txt")
print(text)

-- Read an image as data URL
local imageData = asset.readAsset("myplug", "image.png", "dataurl")
print(imageData)
```

### asset.listFiles(plugName)
Lists all files in a plug.

Parameters:
- `plugName`: Name of the plug to list files from

Returns an array of FileMeta objects containing information about each file.

Example:
```lua
local files = asset.listFiles("myplug")
for _, file in ipairs(files) do
    print(file.name, file.size)
end
```

### asset.getFileMeta(plugName, name)
Gets metadata about a specific file in a plug.

Parameters:
- `plugName`: Name of the plug
- `name`: Name of the file to get metadata for

Returns a FileMeta object containing information about the file.

Example:
```lua
local meta = asset.getFileMeta("myplug", "data.txt")
print("File size:", meta.size)
print("Last modified:", meta.lastModified)
``` 

================================================
FILE: website/API/clientStore.md
================================================
#api/syscall

The Client Store API provides a simple key-value store for client-specific states and preferences.

## clientStore.set(key, value)
Sets a value in the client store.

Example:
```lua
clientStore.set("theme", "dark")
```

## clientStore.get(key)
Gets a value from the client store.

Example:
```lua
local theme = clientStore.get("theme")
print("Current theme: " .. theme)
```

## clientStore.del(key)
Deletes a value from the client store.

Example:
```lua
clientStore.del("theme")
``` 

================================================
FILE: website/API/codeWidget.md
================================================
The Code Widget API provides functions for managing code widgets in the editor.

### codeWidget.refreshAll()
Refreshes all code widgets on the current page that support refreshing.

Example:
```lua
codeWidget.refreshAll()  -- Refresh all code widgets on the page
``` 

================================================
FILE: website/API/command.md
================================================
#api/space-lua

See [[^Library/Std/APIs/Command]]

================================================
FILE: website/API/config.md
================================================
#api/syscall

The Config API provides functions for managing configuration values.

### config.get(path, defaultValue)
Gets a config value by path, with support for dot notation.

Parameters:
- `path`: The path to get the value from
- `defaultValue`: The default value to return if the path doesn't exist

Example:
```lua
local theme = config.get("theme", "light")
print("Current theme: " .. theme)
```

### config.set(path, value)
Sets a config value by path, with support for dot notation.

Parameters:
- `path`: The path to set the value at
- `value`: The value to set

Example:
```lua
config.set("theme", "dark")
```

### config.set(values)
Sets multiple config values at once.

Parameters:
- `values`: An object containing key-value pairs to set

Example:
```lua
config.set({
    theme = "dark",
    fontSize = 14
})
```

### config.has(path)
Checks if a config path exists.

Parameters:
- `path`: The path to check

Example:
```lua
if config.has("theme") then
    print("Theme is configured")
end
```

### config.define(key, schema)
Defines a JSON schema for a configuration key. The schema will be used to validate values when setting this key.

Parameters:
- `key`: The configuration key to define a schema for
- `schema`: The JSON schema to validate against

Example:
```lua
config.define("theme", {
    type = "string",
    enum = {"light", "dark"}
})
```

================================================
FILE: website/API/datastore.md
================================================
#api/syscall

The Datastore API provides functions for interacting with a key-value store that has query capabilities.

* **Keys** are represented as a list (Lua table) of strings.
* **Values** can be any persistable value.

# Key-Value Operations

## datastore.set(key, value)
Sets a value in the key-value store.

Example:
```lua
datastore.set({"user","123"}, {name = "John", age = 30})
```

## datastore.get(key)
Gets a value from the key-value store.

Example:
```lua
local user = datastore.get({"user","123"})
print(user.name)  -- prints "John"
```

## datastore.del(key)
Deletes a value from the key-value store.

Example:
```lua
datastore.del({"user", "123"})
```

# Batch Operations

## datastore.batchSet(kvs)
Sets multiple key-value pairs in a single operation.

Example:
```lua
local kvs = {
    {key = {"user", "1"}, value = {name = "Alice"}},
    {key = {"user", "2"}, value = {name = "Bob"}}
}
datastore.batchSet(kvs)
```

## datastore.batchGet(keys)
Gets multiple values in a single operation.

Example:
```lua
local keys = {{"user", "1"}, {"user", "2"}}
local values = datastore.batchGet(keys)
for _, value in ipairs(values) do
    print(value.name)
end
```

## datastore.batchDel(keys)
Deletes multiple values in a single operation.

Example:
```lua
local keys = {{"user", "1"}, {"user", "2"}}
datastore.batchDel(keys)


================================================
FILE: website/API/dom.md
================================================
An API to easily build DOM objects through the magic of Lua meta tables.

# Usage

```lua
-- any HTML tag can be used here
dom.span {
  -- tag attributes can be set like this:
  class = "my-class",
  id = "my-id",
  -- Plain text body elements can be added like this
  "Span content",
  -- And elements can be nested
  dom.strong { "I am strong" },
  -- Widgets can also be embedded
  widget.html "<b>Bold</b>",
  widget.html(dom.marquee { "nested widget" })
}
```

Example:
${widget.html(dom.marquee {
  "I'm in a ",
  dom.span {
    style="color:red;",
    "marquee"
  }
})}

# API
## dom.* {attribute1=value, attribute2=value, childElement1, childElement2}
Renders a HTML DOM.

* `attribute=value` key/value mappings are translated to HTML DOM attribtutes.
* Plain text elements such as `"Hello **world**"` are parsed and rendered as markdown translated to HTML.
* DOM elements (for instance those resulting from additional `dom.*` calls) are injected in place.
* [[API/widget|widgets]] are rendered in place.

For instance:

```lua
dom.span {
  class = "class-attribute",
  dom.span {
    "A first nested span"
  },
  dom.span {
    "A second nested span"
  },
}
```

Would be roughly equivalent to the following HTML:

```html
<span class="class-attribute">
  <span>A first nested span</span>
  <span>A second nested span</span>
</span>
```

This API is implemented using Lua metatables, its implementation lives here: [[^Library/Std/APIs/DOM]]

================================================
FILE: website/API/editor.md
================================================
#api/syscall

The Editor API provides functions for interacting with the editor interface.

### editor.getCurrentPage()
Returns the [[Names|name]] of the page (or document) currently open in the editor.

Example: ${editor.getCurrentPage()}

### editor.getCurrentPageMeta()
Returns the meta data of the page (or document) currently open in the editor.

Example:
${editor.getCurrentPageMeta()}

### editor.getCurrentPath()
Returns the [[Paths|path]] of the page or document currently open in the editor.

Example:
${editor.getCurrentPath()}

### editor.getCurrentEditor()
Returns the name of the currently open editor.

Example:
```lua
local editorName = editor.getCurrentEditor()
print(editorName)
```

### editor.getText()
Returns the full text of the currently open page.

Example:
```lua
local text = editor.getText()
print("Document length: " .. #text)
```

### editor.getCurrentLine()
Returns the current line range and text.

Example:
```lua
local line = editor.getCurrentLine()
print("from " .. line.from .. " to " .. line.to .. " text " .. line.text .. " text with cursor " .. line.textWithCursor)
```

### editor.setText(text, isolateHistory)
Updates the editor text while preserving cursor location.

Example:
```lua
local text = editor.getText()
editor.setText(text:upper(), false)  -- Convert to uppercase
```

### editor.insertAtPos(text, pos)
Insert text at the specified position.

Example:
```lua
editor.insertAtPos("Hello!", 0)  -- Insert at beginning
```

### editor.replaceRange(from, to, text)
Replace text in the specified range.

Example:
```lua
editor.replaceRange(0, 5, "New text")
```

### editor.insertAtCursor(text, scrollIntoView?)
Insert text at the current cursor position.

Example:
```lua
editor.insertAtCursor("Inserted at cursor")
```

### editor.getCursor()
Returns the cursor position as character offset.

Example:
```lua
local pos = editor.getCursor()
print("Cursor at position: " .. pos)
```

### editor.getSelection()
Returns the current selection range.

Example:
```lua
local sel = editor.getSelection()
print("Selection from " .. sel.from .. " to " .. sel.to)
```

### editor.setSelection(from, to)
Sets the current selection range.

Example:
```lua
editor.setSelection(0, 10)  -- Select first 10 characters
```

### editor.moveCursor(pos, center)
Move the cursor to a specific position.

Example:
```lua
editor.moveCursor(0, true)  -- Move to start and center
```

### editor.moveCursorToLine(line, column, center)
Move the cursor to a specific line and column.

Example:
```lua
editor.moveCursorToLine(1, 1, true)  -- Move to start of first line
```

### editor.invokeCommand(name, args?)
Invokes a client command by name.

Example:
```lua
editor.invokeCommand("Stats: Show")
```

### editor.save()
Force saves the current page.

Example:
```lua
editor.save()
```

### editor.navigate(ref, replaceState?, newWindow?)
Navigates to the specified page reference.

Parameters:
- `ref`: The (string) reference to navigate to, see [[../Link#Link syntax (String refs)|string refs]]
- `replaceState`: Whether to replace the current history state
- `newWindow`: Whether to open in a new window

Example:
```lua
editor.navigate("CHANGELOG@123")
```

### editor.openPageNavigator(mode)
Opens the page navigator.

Example:
```lua
editor.openPageNavigator("page")
```

### editor.openCommandPalette()
Opens the command palette.

Example:
```lua
editor.openCommandPalette()
```

### editor.reloadPage()
Force reloads the current page.

Example:
```lua
editor.reloadPage()
```

### editor.reloadUI()
Force reloads the browser UI.

Example:
```lua
editor.reloadUI()
```

### editor.rebuildEditorState()
Rebuilds the editor state to ensure the dispatch updates the state.

Example:
```lua
editor.rebuildEditorState()
```

### editor.reloadConfigAndCommands()
Reloads the config and commands, also in the server.

Example:
```lua
editor.reloadConfigAndCommands()
```

### editor.openUrl(url, existingWindow?)
Opens the specified URL in the browser.

Example:
```lua
editor.openUrl("https://example.com")
```

### editor.newWindow()
Opens a new window.

Example:
```lua
editor.newWindow()
```

### editor.goHistory(delta)
Moves in the browser history.

Example:
```lua
editor.goHistory(-1)  -- Go back
```

### editor.showPanel(id, mode, html, script)
Shows a panel in the editor.

Example:
```lua
editor.showPanel("rhs", 1, "<h1>Hello</h1>")
```

### editor.hidePanel(id)
Hides a panel in the editor.

Example:
```lua
editor.hidePanel("rhs")
```

### editor.flashNotification(message, type)
Shows a flash notification.

Example:
```lua
editor.flashNotification("Operation completed", "info")
```

### editor.downloadFile(filename, dataUrl)
Triggers a file download in the browser.

Example:
```lua
editor.downloadFile("test.txt", "data:text/plain;base64,SGVsbG8=")
```

### editor.uploadFile(accept, capture)
Opens a file upload dialog.

Example:
```lua
local file = editor.uploadFile(".txt", nil)
print("Uploaded: " .. file.name)
```

### editor.copyToClipboard(data)
Copies data to the clipboard.

> **note** Note
> Requires HTTPS, see [[Install/Network and Internet]]

Example:
```lua
editor.copyToClipboard("Copied text")
```

### editor.filterBox(label, options, helpText?, placeHolder?)
Shows a filter box UI.

Example:
```lua
local result = editor.filterBox("Select:", {
    { name="Option 1", value="1" },
    { name="Option 2", value="2", description="More details about 2" }
})
```

### editor.toggleFold()
Toggles code folding at the current position.

Example:
```lua
editor.toggleFold()
```

### editor.foldAll()
Folds all foldable regions.

Example:
```lua
editor.foldAll()
```

### editor.unfoldAll()
Unfolds all folded regions.

Example:
```lua
editor.unfoldAll()
```

### editor.undo()
Undoes the last change.

Example:
```lua
editor.undo()
```

### editor.redo()
Redoes the last undone change.

Example:
```lua
editor.redo()
```

### editor.openSearchPanel()
Opens the editor's search panel.

Example:
```lua
editor.openSearchPanel()
```

### editor.deleteLine()
Deletes the current line.

Example:
```lua
editor.deleteLine()
```

### editor.toggleComment()
Comments or uncomments the current line.

Example:
```lua
editor.toggleComment()
```

### editor.moveLineUp()
Moves the current line up.

Example:
```lua
editor.moveLineUp()
```

### editor.moveLineDown()
Moves the current line down.

Example:
```lua
editor.moveLineDown()
```

### editor.vimEx(exCommand)
Executes a Vim ex command.

Example:
```lua
editor.vimEx(":w")
```

### editor.sendMessage(type, data?)
Sends a message to the editor.

Example:
```lua
editor.sendMessage("custom-event", { data: "value" })
```

### editor.prompt(message, defaultValue?)
Shows a prompt dialog.

Example:
```lua
local result = editor.prompt("Enter your name:", "John")
```

### editor.confirm(message)
Shows a confirmation dialog.

Example:
```lua
local confirmed = editor.confirm("Are you sure?")
```

### editor.alert(message)
Shows an alert dialog.

Example:
```lua
editor.alert("Operation completed")
```

### editor.getUiOption(key)
Gets a UI option value.

Example:
```lua
local theme = editor.getUiOption("theme")
```

### editor.setUiOption(key, value)
Sets a UI option value.

Example:
```lua
editor.setUiOption("theme", "dark")
```


================================================
FILE: website/API/encoding.md
================================================
#api/space-lua

The `encoding` API implements a few useful functions to handle various types of encoding.

### encoding.base64Encode(data)
Encodes data (either a string or byte buffer) as a base64 encoded string.

### encoding.base64Decode(s)
Decodes a base64 encoded string into a byte buffer.

### encoding.utf8Encode(s)
Encodes a (UTF-8) string into a byte buffer.

### encoding.utf8Decode(data)
Decodes a byte buffer into a (UTF-8) string.

================================================
FILE: website/API/event.md
================================================
#api/syscall

The Event API provides functions for working with SilverBullet's event bus system, allowing communication between different parts of the application.

# API

### event.listen(listenerDef)
Register an event listener.

```lua
event.listen {
  name = "my-event",
  run = function(e)
    print("Data", e.data)
  end
}
```

### event.dispatch(eventName, data, timeout)
Triggers an event on the SilverBullet event bus. Event handlers can return values, which are accumulated and returned to the caller.

Example:
```lua
-- Simple event dispatch
event.dispatch("custom.event", {message = "Hello"})

-- Event dispatch with timeout and response handling
local responses = event.dispatchEvent("data.request", {id = 123}, 5000)
for _, response in ipairs(responses) do
    print(response)
end
```

### event.listEvents()
Lists all events currently registered (listened to) on the SilverBullet event bus.

Example:
```lua
local events = event.listEvents()
for _, eventName in ipairs(events) do
    print("Registered event: " .. eventName)
end
```

================================================
FILE: website/API/global.md
================================================
#api/lua

These are Lua functions defined in the global namespace:

# Standard Lua
## print(...)
Prints to your log (browser or server log).

Example:

```lua
print("Hello, world!")
```

## assert(expr, message?)
Asserts `expr` to be true otherwise raises an [[#error(message)]]

Example:

```lua
assert(1 == 2, "1 is not equal to 2")
```

## ipairs
Returns an iterator for array-like tables that iterates over numeric indices in order.

Example:
```lua
local fruits = {"apple", "banana", "orange"}
for i, fruit in ipairs(fruits) do
    print(i, fruit)
end
-- Output:
-- 1 apple
-- 2 banana
-- 3 orange
```

## pairs
Returns an iterator for tables that traverses all keys and values.

Example:
```lua
local person = {name = "John", age = 30, city = "New York"}
for key, value in pairs(person) do
    print(key, value)
end
-- Output (order not guaranteed):
-- name John
-- age 30
-- city New York
```

## unpack
Unpacks a table into individual values.

Example:
```lua
local numbers = {10, 20, 30}
print(unpack(numbers))  -- prints: 10 20 30

local function sum(a, b, c)
    return a + b + c
end
print(sum(unpack(numbers)))  -- prints: 60
```

## type
Returns the type of a value as a string.

Example:
```lua
print(type("hello"))    -- string
print(type(42))         -- number
print(type({}))         -- table
print(type(print))      -- function
print(type(nil))        -- nil
print(type(true))       -- boolean
```

## tostring
Converts a value to a string representation.

Example:
```lua
print(tostring(42))           -- "42"
print(tostring(true))         -- "true"
print(tostring({1, 2, 3}))    -- "{1, 2, 3}"
```

## tonumber
Converts a string to a number, returns nil if conversion fails.

Example:
```lua
print(tonumber("42"))      -- 42
print(tonumber("3.14"))    -- 3.14
print(tonumber("abc"))     -- nil
```

## error(message)
Throw an error.

Example: 
```lua
error("FAIL")
```

## pcall
Protected call - executes a function in protected mode, catching errors.

Example:
```lua
local status, result = pcall(function()
    return 10/0  -- will cause an error
end)
print(status)  -- false
print(result)  -- "attempt to divide by zero"

status, result = pcall(function()
    return 10/2  -- will succeed
end)
print(status)  -- true
print(result)  -- 5
```

## xpcall
Like pcall, but allows you to specify an error handler function.

Example:
```lua
local function errorHandler(err)
    return "Error occurred: " .. tostring(err)
end

local status, result = xpcall(function()
    error("something went wrong")
end, errorHandler)
print(status)  -- false
print(result)  -- "Error occurred: something went wrong"
```

## setmetatable
Sets the metatable for a table.

Example:
```lua
local t1 = {value = 10}
local t2 = {value = 20}
local mt = {
    __add = function(a, b)
        return a.value + b.value
    end
}
setmetatable(t1, mt)
setmetatable(t2, mt)

-- Now we can add the tables together using the + operator
print(t1 + t2)  -- prints: 30
```

## getmetatable
Gets the metatable of a table.

Example:
```lua
local t = {}
local mt = {}
setmetatable(t, mt)
print(getmetatable(t) == mt)  -- true
```

## rawset
Sets a table index without invoking metamethods.

Example:
```lua
local t = {}
local mt = {
    __newindex = function(t, k, v)
        print("Blocked setting:", k, v)
    end
}
setmetatable(t, mt)

t.foo = "bar"  -- prints: "Blocked setting: foo bar"
rawset(t, "foo", "bar")  -- bypasses the metamethod
print(t.foo)  -- prints: "bar"
```

## dofile(path)
Loads a Lua file from a path in your space, e.g. if you uploaded a `test.lua` file, you can load it with `dofile("test.lua")`.

# Non-standard Extensions
## each
Returns an iterator for array-like tables that iterates over values only (without indices).

Example:
```lua
local fruits = {"apple", "banana", "orange"}
for fruit in each(fruits) do
    print(fruit)
end
-- Output:
-- apple
-- banana
-- orange
```

## some
Returns nil if the value is empty, otherwise returns the value unchanged. Empty tables, strings containing only whitespace, `inf` and `nan` numeric value are considered empty.

Example:
```lua
print(some("hello"))    -- hello
print(some(""))         -- nil
print(some("  "))       -- nil
print(some({}))         -- nil
print(some(0))          -- 0
print(some(1/0))        -- nil

print(some({}) or "empty") -- empty
```


================================================
FILE: website/API/http.md
================================================
#api/space-lua

HTTP APIs

> **warning** Warning
> Deprecated: use [[API/net]] instead.

### http.request(url, options?)
Deprecated, use [[API/net#net.proxyFetch(url, options?)]] instead.


================================================
FILE: website/API/index.md
================================================
#api/syscall

The `index` API provides functions for interacting with SilverBullet's [[Object|Objects]], allowing you to store and query data.

## Object Operations
## index.tag(name)
Returns a given [[Object#Tags]] as a query collection, to be queried using [[Space Lua/Lua Integrated Query]].

Example:
${query[[from index.tag("page") limit 1]]}

## index.markdown(text, pageMeta?)
Ad-hoc indexes `text` (represented as a markdown string) in memory, and returns all objects found there for further query. When no `pageMeta` is supplied dummy (empty) values will be used.

Example:
${query[[
  from index.markdown("* Item 1\n* [ ] Task 1")
  where _.tag == "item"
]]}

## index.indexObjects(page, objects)
Indexes an array of objects for a specific page and stores it in the data store.

Example:
```lua
local objects = {
    {tag = "mytask", ref="task1", content = "Buy groceries"},
    {tag = "mytask", ref="task2", content = "Write docs"}
}
index.indexObjects("my page", objects)
```

## index.queryLuaObjects(tag, query, scopedVariables?)
Queries objects using a Lua-based collection query.

Example:
```lua
local tasks = index.queryLuaObjects("mytask", {limit=3})
```

## index.getObjectByRef(page, tag, ref)
Retrieves a specific object by its reference.

Example:
```lua
local task = index.getObjectByRef("my page", "mytask", "task1")
if task then
    print("Found task: " .. task.content)
end
```

## index.extractFrontmatter(text, extractOptions)
Extracts frontmatter from a markdown document (whose text is provided as argument), possibly cleaning it up. It also parses top-level tags consistent with SilverBullet's tag indexing system.

It returns a table with two keys:
- `frontmatter`: A table containing the parsed frontmatter.
- `text`: The text of the document, with any changes applied requested with the `extractOptions`.

The `extractOptions` is an optional table that can contain the following keys (which will affect the returned `text`):
- `removeKeys`: An array of keys to remove from the frontmatter.
- `removeTags`: A boolean or array of tags to remove from the frontmatter.
- `removeFrontMatterSection`: A boolean to remove the frontmatter section from the document.

Example applied to this page:
${(index.extractFrontmatter(editor.getText())).frontmatter}


================================================
FILE: website/API/js.md
================================================
#api/space-lua

API docs for Space Lua's `js` module, which provides JavaScript interoperability.

## js.import(url)
Imports a JavaScript module from a URL. Returns the imported module.

Example:
```lua
-- Import lodash library
local lodashLib = js.import("https://esm.sh/lodash@4.17.21")
local result = lodashLib.chunk({1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, 3)

-- Import moment.js for date handling
local momentLib = js.import("https://esm.sh/moment@2.30.1")
local dateObj = momentLib("1995-12-25")
print(dateObj.format("DD-MM-YYYY"))  -- prints: 25-12-1995
```

## js.new(constructor, ...)
Creates a new instance of a JavaScript class. Takes a constructor function and its arguments.

Example:
```lua
local DateClass = js.import("https://esm.sh/date-fns")
local dateObj = js.new(DateClass, "2024-03-14")
```

## js.stringify(value)
Converts a Lua value to a JSON string representation.

Example:
```lua
local dataArray = {1, 2, 3}
print(js.stringify(dataArray))  -- prints: [1,2,3]

local nestedArray = lodashLib.chunk({1, 2, 3, 4, 5, 6}, 2)
print(js.stringify(nestedArray))  -- prints: [[1,2],[3,4],[5,6]]
```

## js.tolua(value)
Converts a JavaScript value to its Lua equivalent.

Example:
```lua
local jsArray = someJsFunction()
local luaTable = js.tolua(jsArray)
```

## js.tojs(value)
Converts a Lua value to its JavaScript equivalent.

Example:
```lua
local luaTable = {1, 2, 3}
local jsArray = js.tojs(luaTable)
```

## js.log(...)
Logs messages to the JavaScript console.

Example:
```lua
js.log("Debug message")
js.log("User data:", {name = "John", age = 30})
```

## js.eachIterable(iterable)
Creates an iterator for JavaScript async iterables.

Example:
```lua
local asyncIterator = js.eachIterable(someJsAsyncIterable)
for value in asyncIterator do
    print(value)
end


================================================
FILE: website/API/jsonschema.md
================================================
#api/space-lua

The JSON Schema API provides functions for validating JSON objects against JSON schemas.

## Validation Operations

### jsonschema.validateObject(schema, object)
Validates a JSON object against a JSON schema.

Example:
```lua
local schema = {
    type = "object",
    properties = {
        name = {type = "string"},
        age = {type = "number", minimum = 0}
    },
    required = {"name"}
}

local object = {name = "John", age = 30}
local error = jsonschema.validateObject(schema, object)
if error then
    print("Validation error: " .. error)
else
    print("Object is valid")
end
```

### jsonschema.validateSchema(schema)
Validates a JSON schema itself to ensure it's well-formed.

Example:
```lua
local schema = {
    type = "object",
    properties = {
        name = {type = "string"}
    }
}

local error = jsonschema.validateSchema(schema)
if error then
    print("Schema error: " .. error)
else
    print("Schema is valid")
end


================================================
FILE: website/API/language.md
================================================
#api/syscall

The Language API provides functions for parsing code in various programming languages and listing supported languages.

## Language Operations

### language.parseLanguage(language, code)
Parses a piece of code using any of the supported SilverBullet languages.

Example:
```lua
local code = [[
function hello() {
    console.log("Hello, world!");
}
]]

local tree = language.parseLanguage("javascript", [[
function hello() {
    console.log("Hello, world!");
}
]])
print("Parsed syntax tree:", tree)
```

### language.listLanguages()
Lists all supported languages in fenced code blocks.

Example:
${language.listLanguages()}


================================================
FILE: website/API/lua.md
================================================
#api/space-lua

The Lua API provides functions for parsing and evaluating Lua code.

### lua.parse(code)
Parses a string of Lua code into an abstract syntax tree (AST).

Parameters:
- `code`: The Lua code to parse

Returns a LuaBlock object representing the parsed code.

Example:
```lua
local ast = lua.parse("print('Hello')")
-- ast contains the parsed syntax tree
```

### lua.parseExpression(expression)
Parses a Lua expression into an abstract syntax tree (AST).

Parameters:
- `expression`: The Lua expression to parse

Returns a LuaExpression object representing the parsed expression.

Example:
```lua
local expr = lua.parseExpression("1 + 2 * 3")
-- expr contains the parsed expression tree
```

### lua.evalExpression(expression)
Evaluates a Lua expression and returns its result.

Parameters:
- `expression`: The Lua expression to evaluate

Returns the result of evaluating the expression.

Example:
```lua
local result = lua.evalExpression("1 + 2 * 3")
print(result)  -- prints: 7
``` 

================================================
FILE: website/API/markdown.md
================================================
#api/syscall

The Markdown API provides functions for parsing and rendering Markdown content.

## Markdown Operations
### markdown.parseMarkdown(text)
Parses a piece of markdown text into a ParseTree.

Example:
```lua
local text = [[
# Hello World

This is a **bold** statement.
]]

local tree = markdown.parseMarkdown(text)
print("Parsed markdown tree:", tree)
```

### markdown.renderParseTree(tree)
Renders a ParseTree back to markdown text.

Example:
```lua
local text = "# Title\n\nSome text"
local tree = markdown.parseMarkdown(text)
-- Modify tree if needed
local rendered = markdown.renderParseTree(tree)
print("Rendered markdown:", rendered)
```

### markdown.markdownToHtml(text)
Renders a piece of markdown text into HTML

Example:
```lua
local text = "# Title\n\nSome text"
local html = markdown.markdownToHtml(text)
print("Rendered html:", html)
```

### markdown.expandMarkdown(textOrTree, options?)
Expands custom markdown Lua directives and transclusions into plain markdown. Accepts either a markdown ParseTree or string.

Options (all default to `true`):
* `expandTransclusions`: Replace (markdown transclusions) with their content
* `expandLuaDirectives`: Replace Lua directives with their evaluated values
* `rewriteTasks`: Rewrite tasks to include references so that they can be updated

Example:
```lua
local text = "This is a some lua ${os.time()}"
print("Expanded markdown:", markdown.expandMarkdown(text))
```

### markdown.objectsToTable(data, options?)
Transforms a list of tables into a markdown table.

Supported options:
* `renderCell(val, key)` custom cell renderer

Example:
${markdown.objectsToTable({{name="Pete", age=20}, {name="Jane", age=32}}, {
  renderCell=function(v, k)
  if k == "age" and v > 20 then
    return "*" .. v .. "*"
  else
    return v
  end
end})}



================================================
FILE: website/API/math.md
================================================
#api/lua

API docs for Lua's `math` module.

## math.random(m?, n?)
Returns a random number.
- `random()` returns a random float in range [0,1)
- `random(m)` returns a random integer in range [1,m]
- `random(m,n)` returns a random integer in range [m,n]

Example:
```lua
print(math.random())      -- prints: 0.123456789 (random float between 0 and 1)
print(math.random(10))    -- prints: 5 (random integer between 1 and 10)
print(math.random(5, 10)) -- prints: 7 (random integer between 5 and 10)
```

## math.abs(x)
Returns the absolute value of `x`.

Example:
```lua
print(math.abs(-5))  -- prints: 5
print(math.abs(5))   -- prints: 5
```

## math.ceil(x)
Returns the smallest integer greater than or equal to `x`.

Example:
```lua
print(math.ceil(4.2))   -- prints: 5
print(math.ceil(-4.2))  -- prints: -4
```

## math.floor(x)
Returns the largest integer less than or equal to `x`.

Example:
```lua
print(math.floor(4.8))   -- prints: 4
print(math.floor(-4.8))  -- prints: -5
```

Note: `math.floor(x + 0.5)` rounds to closest integer, similar to `round(x)` function from other languages

## math.max(...)
Returns the maximum value among its arguments.

Example:
```lua
print(math.max(1, 2, 3, 4, 5))  -- prints: 5
print(math.max(-10, -5, -1))    -- prints: -1
```

## math.min(...)
Returns the minimum value among its arguments.

Example:
```lua
print(math.min(1, 2, 3, 4, 5))  -- prints: 1
print(math.min(-10, -5, -1))    -- prints: -10
```

## math.fmod(x, y)
Returns the remainder of the division of `x` by `y` that rounds the quotient towards zero.

Example:
```lua
print(math.fmod(10, 3))    -- prints: 1
print(math.fmod(-10, 3))   -- prints: -1
```

## math.modf(x)
Returns two numbers, the integral part of `x` and the fractional part of `x`.

Example:
```lua
local intPart, fracPart = math.modf(3.14)
print(intPart, fracPart)   -- prints: 3    0.14
```

## math.exp(x)
Returns the value e^x (where e is the base of natural logarithms).

Example:
```lua
print(math.exp(0))    -- prints: 1
print(math.exp(1))    -- prints: 2.7182818284590455
```

## math.log(x, base?)
Returns the logarithm of `x` in the given base. If base is not specified, returns the natural logarithm of `x`.

Example:
```lua
print(math.log(10))       -- prints: 2.302585092994046 (natural log)
print(math.log(100, 10))  -- prints: 2 (log base 10)
```

## math.pow(x, y)
Returns x^y.

Example:
```lua
print(math.pow(2, 3))   -- prints: 8
print(math.pow(10, 2))  -- prints: 100
```

## math.sqrt(x)
Returns the square root of `x`.

Example:
```lua
print(math.sqrt(16))   -- prints: 4
print(math.sqrt(2))    -- prints: 1.4142135623730951
```

## math.cos(x)
Returns the cosine of `x` (in radians).

Example:
```lua
print(math.cos(0))          -- prints: 1
print(math.cos(math.pi))    -- prints: -1
```

## math.sin(x)
Returns the sine of `x` (in radians).

Example:
```lua
print(math.sin(0))                  -- prints: 0
print(math.sin(math.pi / 2))        -- prints: 1
```

## math.tan(x)
Returns the tangent of `x` (in radians).

Example:
```lua
print(math.tan(0))             -- prints: 0
print(math.tan(math.pi / 4))   -- prints: 1
```

## math.acos(x)
Returns the arc cosine of `x` (in radians).

Example:
```lua
print(math.acos(1))    -- prints: 0
print(math.acos(0))    -- prints: 1.5707963267948966 (pi/2)
```

## math.asin(x)
Returns the arc sine of `x` (in radians).

Example:
```lua
print(math.asin(0))    -- prints: 0
print(math.asin(1))    -- prints: 1.5707963267948966 (pi/2)
```

## math.atan(y, x?)
Returns the arc tangent of `y/x` (in radians). If `x` is not provided, returns the arc tangent of `y` (in radians).

Example:
```lua
print(math.atan(1))        -- prints: 0.7853981633974483 (pi/4)
print(math.atan(1, 1))     -- prints: 0.7853981633974483 (pi/4)
```

## math.cosh(x)
Returns the hyperbolic cosine of `x`.

Example:
```lua
print(math.cosh(0))   -- prints: 1
```

## math.sinh(x)
Returns the hyperbolic sine of `x`.

Example:
```lua
print(math.sinh(0))   -- prints: 0
```

## math.tanh(x)
Returns the hyperbolic tangent of `x`.

Example:
```lua
print(math.tanh(0))   -- prints: 0
```

## math.deg(x)
Converts angle `x` from radians to degrees.

Example:
```lua
print(math.deg(math.pi))      -- prints: 180
print(math.deg(math.pi/2))    -- prints: 90
```

## math.rad(x)
Converts angle `x` from degrees to radians.

Example:
```lua
print(math.rad(180))    -- prints: 3.141592653589793
print(math.rad(90))     -- prints: 1.5707963267948966
```

## math.ult(m, n)
Returns a boolean, true if integer `m` is below integer `n` when they are compared as unsigned integers.

Example:
```lua
print(math.ult(2, 3))     -- prints: true
print(math.ult(-1, 1))    -- prints: false (as unsigned integers)
```

# Non-standard Extensions
## math.cosineSimilarity(vecA, vecB)
Returns the cosine similarity between two vectors.

Example:
```lua
local vec1 = {1, 2, 3}
local vec2 = {4, 5, 6}
print(math.cosineSimilarity(vec1, vec2))  -- prints: 0.9746318461970762
```


================================================
FILE: website/API/mq.md
================================================
#api/space-lua

The Message Queue API provides functions for implementing a simple message queue system.

## Example
```space-lua
mq.subscribe {
  queue = "testqueue",
  batchSize = 1,
  run = function(messages)
    for _, msg in ipairs(messages) do
      editor.flashNotification("Received message: " .. msg.body)
    end
  end
}
```

${widgets.button("Send message on queue", function()
  mq.send("testqueue", "Hello world")
end)}

## API
### mq.subscribe(spec)
Subscribe to a queue. Spec keys:

* `queue`: name of the queue to subscribe to
* `batchSize`: maximum number of messages to ingest per run
* `autoAck`: (defaults to `true`), whether to automatically acknowledge a message if no error occurs during processing
* `run`: callback with the function to run, will receive a `messages` argument where each message is a table containing:
  * `queue`: name of the queue the message was sent to
  * `id`: message id
  * `body`: message body

### mq.send(queue, body)
Sends a message to a queue.

Example:
```lua
mq.send("tasks", "my task")
```

### mq.batchSend(queue, bodies)
Sends multiple messages to a queue in a single operation.

Example:
```lua
mq.batchSend("tasks", {"task 1", "task 2" })
```

### mq.ack(queue, id)
Acknowledges a message from a queue, marking it as processed.

Example:
```lua
mq.ack("tasks", "message-123")
```

### mq.batchAck(queue, ids)
Acknowledges multiple messages from a queue in a single operation.

Example:
```lua
mq.batchAck("tasks", {"msg1", "msg2", "msg3"})
```

## Queue Management

### mq.getQueueStats(queue)
Retrieves statistics about a particular queue.

Example:
```lua
local stats = mq.getQueueStats("tasks")
print("Queue size: " .. stats.size)
print("Processing: " .. stats.processing)
```

### mq.awaitEmptyQueue(queue)
Waits for a queue to become empty.

Example:
```lua
mq.awaitEmptyQueue("tasks")
```

================================================
FILE: website/API/net.md
================================================
#api/space-lua

Network-related APIs.

### net.proxyFetch(url, options?)
Performs a HTTP call, proxied via the server (to avoid CORS issues, see [[HTTP API]]).

Options:
* `method`: GET, POST, PUT, DELETE (GET is default)
* `headers`: table with header -> value mappings
* `body`: either a string or table (which will be JSON stringified)

Returns:
* `ok`: boolean if the request went ok
* `status`: HTTP status code
* `headers`: HTTP headers
* `body`: for content types:
  * `text/*`: string
  * `application/json`: parsed JSON object
  * anything else: UInt8Array

## net.readURI(uri, options?)
Fetches the content of a [[URI]].

Options:
* `encoding` force an encoding for the result, e.g. `{encoding = "text/markdown"}`

## net.writeURI(uri, content)
Writes content to a specific [[URI|URI]].

================================================
FILE: website/API/os.md
================================================
#api/lua

API docs for Lua's `os` module.

## os.time(table?)
Returns the current time when called without arguments, or a timestamp for a specific date when given a table. The table can contain the following fields: year (required), month (required), day (required), hour (defaults to 12), min (defaults to 0), and sec (defaults to 0).

Example:
```lua
-- Get current timestamp
print(os.time())  -- prints: current Unix timestamp

-- Get timestamp for specific date
local timestamp = os.time({
    year = 2020,
    month = 1,
    day = 1
})
```

## os.date(format?, timestamp?)
Returns a string or table containing date and time, formatted according to the given format string. If timestamp is not provided, formats the current time.

Format specifiers:
- `%Y`: Full year (e.g., "2024")
- `%y`: Year without century (e.g., "24")
- `%m`: Month (01-12)
- `%b`: Abbreviated month name (e.g., "Jan")
- `%B`: Full month name (e.g., "January")
- `%d`: Day of month (01-31)
- `%e`: Day of month (1-31)
- `%H`: Hour (00-23)
- `%I`: Hour (01-12)
- `%M`: Minute (00-59)
- `%S`: Second (00-59)
- `%p`: AM/PM
- `%A`: Full weekday name (e.g., "Sunday")
- `%a`: Abbreviated weekday name (e.g., "Sun")
- `%w`: Weekday (0-6, Sunday is 0)
- `%U`: Week of the year, starting with the first Sunday as the first day of week 01 (00-53)
- `%W`: Week of the year, starting with the first Monday as the first day of week 01 (00-53)
- `%V`: ISO 8601 week of the year (01-53) (see [Wikipedia](https://en.wikipedia.org/wiki/ISO_week_date))
- `%j`: Day of year (001-366)
- `%Z`: Time zone name
- `%z`: Time zone offset from UTC
- `%%`: Literal "%"

Example:
```lua
-- Format specific date
local date = os.date("%Y-%m-%d", os.time({
    year = 2020,
    month = 1,
    day = 1
}))
print(date)  -- prints: 2020-01-01

-- Current date in different formats
print(os.date("%Y-%m-%d"))         -- prints: current date (e.g., "2024-03-14")
print(os.date("%B %d, %Y"))        -- prints: month day, year (e.g., "March 14, 2024")
print(os.date("%I:%M %p"))         -- prints: time in 12-hour format (e.g., "02:30 PM")
print(os.date("%A, %B %d, %Y"))    -- prints: full date (e.g., "Thursday, March 14, 2024")
``` 

================================================
FILE: website/API/service.md
================================================
#api/syscall

Exposes a simple service registry API leveraged by various parts of SilverBullet, see [[Service]].

## Example
```space-lua
service.define {
  selector = "greeter-service",
  -- no priority set
  match = {},
  run = function(name)
    return "Hello " .. name
  end
}

service.define {
  selector = "greeter-service",
  match = function(name)
    if name == "Pete" then
      -- takes precendence with an exact match
      return {priority=10}
    else
      return nil
    end
  end,
  run = function(name)
    return "Hello Pete, so happy to see you!"
  end
}
```

To invoke: ${service.invokeBestMatch("greeter-service", "Pete")} and ${service.invokeBestMatch("greeter-service", "Hank")}

## API
### service.define(spec)
Defines a service matching a selector.

Spec arguments:
* `selector`: service selector, can contain wildcards (e.g. `hello:*`)
* `match(data)`: callback (`data` argument), returns a match table if this service is a match or `nil` otherwise. A match table is service specific, but at least contains a `priority` key to set the service priority (used for sorting by `service.discover` and `service.invokeBestMatch`). Instead of a function can also simply be a match object directly.
* `run(data)`: callback to be invoked when the service is invoked

### service.discover(selector, data)
Discovers previously defined service matching a given selector. 

Return value is a list of tables (can be empty) with keys:
* `id`: guid of the service (automatically generated)
* `priority`: priority of service match (list will already be pre-sorted based on this key if present)

### service.invoke(match, data)
Invokes a service match (as returned by [[#service.discover(selector, options)]]).

### service.invokeBest(selector, options)
Performs a `discover` based on the selector, then immediately performs and `invoke` on the best match (based on priority).

================================================
FILE: website/API/shell.md
================================================
#api/syscall

The Shell API provides functions for running shell commands and interacting with processes.

### shell.run(cmd, args, stdin?)
Runs a shell command and returns its output.

Parameters:
- `cmd`: The command to run
- `args`: Array of arguments to pass to the command
- `stdin`: stdin string (optional)

Returns an object with:
- `stdout`: The standard output of the command
- `stderr`: The standard error of the command
- `code`: The exit code of the command

Example:
```lua
local result = shell.run("ls", {"-l"})
print("Output:", result.stdout)
print("Error:", result.stderr)
print("Exit code:", result.code)

local result = shell.run("cat", {}, "hello")
print("Output:", result.stdout) -- "hello"
```

================================================
FILE: website/API/slashCommand.md
================================================
#api/space-lua

APIs to create [[Slash Command]]. For simple cases it is recommended to use [[Library/Std/Infrastructure/Slash Templates]] instead.

## slashCommand.define(spec)

Define a custom slash command.

Supported keys in the spec:

* `name`: name of the command
* `description`: Description of the command
* `run`: The callback function that will be invoked once the command is run.

Example:

```lua
slashCommand.define {
  name = "hello-world",
  run = function()
    editor.insertAtCursor("Hello |^| world!", false, true)
  end
}
```



================================================
FILE: website/API/space.md
================================================
#api/syscall

The Space API provides functions for interacting with pages, documents, and files in the space.

# Page Operations

## space.listPages()
Returns a list of all pages in the space.

Example:
```lua
local pages = space.listPages()
for page in each(pages) do
    print(page.name)
end
```

## space.readPage(name)
Reads the content of a page.

Example:
```lua
local content = space.readPage("welcome")
print(content)  -- prints the content of the "welcome" page
```

## space.readRef(ref)
Reads a reference and returns it as a string, works for:
* `page`: reads the entire page
* `page#header` reads the referenced header
* `page@pos` where `pos` points to an item or task: reads the item/task and its children

## space.getPageMeta(name)
Gets metadata for a specific page.

Example:
```lua
local meta = space.getPageMeta("welcome")
print(meta.name, meta.lastModified)  -- prints page name and last modified date
```

## space.readPageWithMeta(name)
Combines readPage and getPageMeta in a single call, returning both in a table:
* `text`: the binary content
* `meta`: the meta data

## space.writePage(name, text)
Writes content to a page.

Example:
```lua
local meta = space.writePage("notes", "My new note content")
print("Page updated at: " .. meta.lastModified)
```

## space.deletePage(name)
Deletes a page from the space.

Example:
```lua
space.deletePage("old-notes")
```

## space.pageExists(name)
Checks if a page exists in the space.

Example:
```lua
if space.pageExists("Hello") then
    print("Page exists!")
else
    print("Page not found")
end
```

# Document Operations

## space.listDocuments()
Returns a list of all documents in the space.

Example:
```lua
local documents = space.listDocuments()
for doc in each(documents) do
    print(doc.name, doc.size)
end
```

## space.readDocument(name)
Reads the content of a document.

Example:
```lua
local data = space.readDocument("image.png")
print("Document size: " .. #data .. " bytes")
```

## space.writeDocument(name, data)
Writes binary data to a document.

Example:
```lua
local binaryData = string.char(72, 69, 76, 76, 79)  -- "HELLO" in binary
local meta = space.writeDocument("test.bin", binaryData)
print("Document saved with size: " .. meta.size)
```

## space.deleteDocument(name)
Deletes a document from the space.

Example:
```lua
space.deleteDocument("old-image.png")
```

# File Operations

## space.listFiles()
Returns a list of all files in the space.

Example:
```lua
local files = space.listFiles()
for _, file in ipairs(files) do
    print(file.name, file.size)
end
```

## space.getFileMeta(name)
Gets metadata for a specific file.

Example:
```lua
local meta = space.getFileMeta("document.txt")
print(meta.name, meta.modified, meta.size)
```

## space.readFile(name)
Reads the content of a file.

Example:
```lua
local content = space.readFile("document.txt")
print("File size: " .. #content .. " bytes")
```

## space.readFileWithMeta(name)
Combines readFile and getFileMeta in a single call, returning both in a table:
* `data`: the binary content
* `meta`: the meta data

## space.writeFile(name, data)
Writes binary data to a file.

Example:
```lua
local text = "Hello, World!"
local meta = space.writeFile("greeting.txt", text)
print("File written with size: " .. meta.size)
```

## space.deleteFile(name)
Deletes a file from the space.

Example:
```lua
space.deleteFile("old-document.txt")
```

## space.fileExists(name)
Checks if a file exists in the space.

Example:
```lua
if space.fileExists("config.json") then
    print("Config file exists!")
else
    print("Config file not found")
end


================================================
FILE: website/API/spacelua.md
================================================
#api/space-lua

The Space Lua API provides functions for working with Lua expressions and templates.

## spacelua.parseExpression(luaExpression)
Parses a lua expression and returns the parsed expression as an AST.

Example:
```lua
local parsedExpression = spacelua.parseExpression("1 + 1")
```

## spacelua.evalExpression(parsedExpr, envAugmentation?)
Evaluates a parsed Lua expression and returns the result. Optionally accepts an environment table to augment the global environment.

Example:
```lua
local parsedExpr = spacelua.parseExpression("x + y")
local result = spacelua.evalExpression(parsedExpr, {x = 1, y = 2})
print(result)  -- prints: 3
```

## spacelua.interpolate(template, envAugmentation?)
Interpolates a string with lua expressions and returns the result. Expressions are wrapped in ${...} syntax. Optionally accepts an environment table to augment the global environment.

Example:
```lua
local greeting = spacelua.interpolate("Hello ${name}!", {name="Pete"})
print(greeting)  -- prints: Hello Pete!
```

## spacelua.baseUrl()
Returns your SilverBullet instance's base URL, or `nil` when run on the server.

Example:
```lua
local url = spacelua.baseUrl()
print(url)  -- prints something like: https://example.com
```


================================================
FILE: website/API/string.md
================================================
#api/lua

API docs for Space Lua's `string` module.

**Note:** since string values set `string` as their meta table, these APIs can also be called as method calls on strings directly. For instance: `someString:startsWith("h")` is equivalent to `string.startsWith(someString, "h")`.

## Lua Pattern Matching

Lua patterns are not regular expressions. Space Lua makes a good effort at translating Lua patterns to regex to run in a Javascript environment, but there are two main differences:

1. Magic characters `^$()%.[]*+-?` must be escaped to represent their character. Standard Lua patterns do not require escaping magic characters when they are not contextually magic (e.g. `%d--` is a valid Lua pattern where the second hyphen is not magic). Space Lua may have unexpected results when expecting an un-escaped magic character to behave like a character.
2. In Space Lua, the repetition magic characters (`?`, `*`, `+`, and `-`) will apply to captures in patterns. They do not in standard Lua.

Additionally, the patterns for the *n*th captured string (`%*n*`), balanced match (`%b*xy*`), and frontier pattern (`%f[set]`) in [Lua](https://www.lua.org/manual/5.4/manual.html#6.4.1) will likely not be supported.

As noted below, the operations `string.matchRegex` and `string.matchRegexAll`
leverage regex in Javascript--not Space Lua patterns.

Here are some valid Lua patterns with different matches (or outright errors) in Space Lua:

```lua
print(string.match("1234", "(%d)+"))
-- prints "4" in Space Lua (last match of the captures)
-- prints "nil" in Lua (repetition magic does not work on captures)

print(string.match("*", "*"))
-- invalid regex in Space Lua ("*" is not escaped)
-- prints "*" in Lua

print(string.match("2024-03-14", "%d+-(%d+)-%d+"))
-- invalid regex in Space Lua (the "-"s are not escaped")
-- prints "03" in Lua
```

## String Operations

### string.byte(s, i?, j?)
Returns the numeric codes of characters in string `s` from position `i` to `j`. If `j` is not provided, defaults to `i`.

Example:
```lua
print(string.byte("Hello", 1))  -- prints: 72 (ASCII code for 'H')
```

### string.char(...)
Returns a string from given ASCII codes.

Example:
```lua
print(string.char(72))  -- prints: H
```

### string.find(s, pattern, init?, plain?)
Looks for the first match of `pattern` in string `s`. Returns start and end indices of match.

Example:
```lua
local start, end_ = string.find("Hello", "l")
print(start)  -- prints: 3 (first 'l' position)
```

### string.format(format, ...)
Returns a formatted string using C-style format specifiers.

Example:
```lua
print(string.format("Name: %s, Age: %d", "John", 30))  -- prints: Name: John, Age: 30
print(string.format("Pi: %.2f", 3.14159))             -- prints: Pi: 3.14
```

### string.gsub(s, pattern, repl, n?)
Returns a copy of `s` in which all (or the first `n`) occurrences of `pattern` have been replaced by `repl`.

Example:
```lua
-- Simple string replacement
local result, count = string.gsub("hello world", "hello", "hi")
print(result, count)  -- prints: hi world 1

-- Multiple replacements with limit
result = string.gsub("hello hello hello", "hello", "hi", 2)
print(result)  -- prints: hi hi hello

-- Function replacement
result = string.gsub("hello world", "(h)ello", function(h)
    return string.upper(h) .. "i"
end)
print(result)  -- prints: Hi world

-- Pattern with magic characters
result = string.gsub("hello.world", "%.", "-")
print(result)  -- prints: hello-world
```

### string.match(s, pattern, init?)
Returns the captures from the first match of `pattern` in string `s`.

Example:
```lua
-- Basic pattern matching
print(string.match("hello", "h"))  -- prints: h

-- Multiple captures
local year, month, day = string.match("2024-03-14", "(%d+)%-(%d+)%-(%d+)")
print(year, month, day)  -- prints: 2024 03 14

-- With init position
print(string.match("hello world", "(world)", 7))  -- prints: world

-- Pattern characters
print(string.match("123", "%d+"))      -- prints: 123
print(string.match("abc123", "%a+"))   -- prints: abc
print(string.match("   abc", "%s+"))   -- prints: "   "
```

### string.gmatch(s, pattern)
Returns an iterator function that returns successive captures from pattern matches in string `s`.

Example:
```lua
local words = {}
for word in string.gmatch("hello world lua", "%w+") do
    table.insert(words, word)
end
print(words[1], words[2], words[3])  -- prints: hello world lua
```

### string.len(s)
Returns the length of string `s`.

Example:
```lua
print(string.len("Hello"))  -- prints: 5
```

### string.lower(s)
Returns a copy of `s` with all characters converted to lowercase.

Example:
```lua
print(string.lower("Hello"))  -- prints: hello
```

### string.upper(s)
Returns a copy of `s` with all characters converted to uppercase.

Example:
```lua
print(string.upper("Hello"))  -- prints: HELLO
```

### string.rep(s, n, sep?)
Returns a string that is the concatenation of `n` copies of string `s`.

Example:
```lua
print(string.rep("Hello", 3))  -- prints: HelloHelloHello
```

### string.reverse(s)
Returns a string with the characters of `s` in reverse order.

Example:
```lua
print(string.reverse("hello"))  -- prints: olleh
print(string.reverse(""))       -- prints: "" (empty string)
```

### string.sub(s, i, j?)
Returns the substring of `s` from position `i` to `j`.

Example:
```lua
print(string.sub("Hello", 2, 4))  -- prints: ell
```

### string.split(s, sep)
Splits string `s` using separator `sep` and returns a table of substrings.

Example:
```lua
local parts = string.split("a,b,c", ",")
for i, part in ipairs(parts) do
    print(part)
end
-- Output:
-- a
-- b
-- c
```

# Non-standard Extensions
### string.startsWith(s, prefix)
Returns true if string `s` starts with `prefix`.

Example:
```lua
print(string.startsWith("hello world", "hello"))  -- prints: true
print(string.startsWith("hello world", "world"))  -- prints: false
```

### string.endsWith(s, suffix)
Returns true if string `s` ends with `suffix`.

Example:
```lua
print(string.endsWith("hello world", "world"))  -- prints: true
print(string.endsWith("hello world", "hello"))  -- prints: false
```

### string.trim(s)
Returns a copy of string `s` with whitespace removed from both ends.

Example:
```lua
print(string.trim("  hello  "))  -- prints: hello
```

### string.trimStart(s)
Returns a copy of string `s` with whitespace removed from the beginning.

Example:
```lua
print(string.trimStart("  hello  "))  -- prints: hello  
```

### string.trimEnd(s)
Returns a copy of string `s` with whitespace removed from the end.

Example:
```lua
print(string.trimEnd("  hello  "))  -- prints:   hello
```

### string.matchRegex(s, pattern)
Matches string `s` against a JavaScript regular expression pattern and returns the result. This uses JavaScript's native regex capabilities rather than Lua patterns.

Example:
```lua
local match = string.matchRegex("hello123", "([a-z]+)([0-9]+)")
print(match[1], match[2])  -- prints: hello 123
```

### string.matchRegexAll(s, pattern)
Returns an iterator that finds all matches of a JavaScript regular expression pattern in string `s`.

Example:
```lua
for match in string.matchRegexAll("a1b2c3", "([a-z])([0-9])") do
    print(match[0], match[1], match[2])  -- prints each full match and its capture groups
end
-- Output:
-- a1 a 1
-- b2 b 2
-- c3 c 3
```

================================================
FILE: website/API/sync.md
================================================
#api/syscall

The Sync API provides functions for interacting with the sync engine when the client runs in Sync mode.

## Sync Operations

### sync.isSyncing()
Checks if a sync is currently in progress.

Example:
```lua
if sync.isSyncing() then
    print("Sync in progress...")
end
```

### sync.hasInitialSyncCompleted()
Checks if an initial sync has completed.

Example:
```lua
if sync.hasInitialSyncCompleted() then
    print("Initial sync completed")
else
    print("Waiting for initial sync...")
end
```

### sync.performFileSync(path)
Immediately synchronizes a file with the server. Returns once the synchronization has completed.

Example:
```lua
sync.performFileSync("notes/important.md")
```

### sync.performSpaceSync()
Immediately triggers a full space sync. Returns `-1` if a sync was already ongoing, or the number of sync operations performed.

Example:
```lua
local changes = sync.scheduleSpaceSync()
print("Number of changes synced: " .. changes)
```

================================================
FILE: website/API/system.md
================================================
#api/syscall

The System API provides system-level functions for interacting with the SilverBullet environment.

## Function Operations

### system.invokeFunction(name, ...)
Invokes a plug function by name.

Example:
```lua
-- Invoke a function from a plug
system.invokeFunction("myplug.processData", "input", 123)
```

## System Information

### system.listCommands()
Lists all available commands.

Example:
```lua
local commands = system.listCommands()
for name, def in pairs(commands) do
    print(name .. ": " .. def.description)
end
```

### system.listSyscalls()
Lists all available syscalls.

Example:
```lua
local syscalls = system.listSyscalls()
for _, syscall in ipairs(syscalls) do
    print(syscall.name)
end
```

### system.getMode()
Returns the current mode of the system ("ro" or "rw").

Example:
```lua
local mode = system.getMode()
print("System mode: " .. mode)
```

### system.getURLPrefix()
Returns the prefix set by [[Install/Configuration|SB_URL_PREFIX]] or "/" if the variable isn't set

Example:
```lua
local prefix = system.getURLPrefix()
print("Prefix: " .. prefix)
```

### system.getVersion()
Returns the SilverBullet version.

Example:
```lua
local version = system.getVersion()
print("SilverBullet version: " .. version)
```

## Configuration

### system.reloadConfig()
Triggers an explicit reload of the configuration.

Example:
```lua
system.reloadConfig()
print("Configuration reloaded")
```

### system.reloadPlugs()
Triggers a reload of all plugs.

Example:
```lua
system.reloadPlugs()
print("All plugs reloaded")
```

### system.wipeClient(logout?)
Completely wipes the client state, including cached files, service worker and databases.

Parameters:
- `logout`: Optional boolean to also log out the user

Example:
```lua
system.wipeClient(true)  -- Wipe client and log out
print("Client state has been reset")
```


================================================
FILE: website/API/table.md
================================================
#api/lua

These are Lua functions defined in the `table` namespace:

## table.concat(table, sep?, i?, j?)
Concatenates the elements of a table into a string using a separator.

Example:
```lua
local fruits = {"apple", "banana", "orange"}
print(table.concat(fruits, ", "))  -- prints: apple, banana, orange
print(table.concat(fruits, "", 1, 2))  -- prints: applebanana
```

## table.insert(table, pos, value)
## table.insert(table, value)
Inserts a value into a table at the specified position, shifting elements up. If position is not provided, appends the value at the end of the table.

Example:
```lua
local fruits = {"apple", "orange"}
table.insert(fruits, "banana")  -- appends at end
print(table.concat(fruits, ", "))  -- prints: apple, orange, banana

table.insert(fruits, 2, "grape")  -- inserts at position 2
print(table.concat(fruits, ", "))  -- prints: apple, grape, orange, banana
```

## table.remove(table, pos?)
Removes an element from a table at the specified position, shifting elements down. If position is not provided, removes the last element.

Example:
```lua
local fruits = {"apple", "grape", "orange", "banana"}
table.remove(fruits, 2)  -- removes "grape"
print(table.concat(fruits, ", "))  -- prints: apple, orange, banana

table.remove(fruits)  -- removes last element
print(table.concat(fruits, ", "))  -- prints: apple, orange
```

## table.sort(table, comp?)
Sorts a table in-place using the optional comparison function. Without a comparison function, sorts in ascending order.

Example:
```lua
local numbers = {3, 1, 4, 1, 5, 9}
table.sort(numbers)  -- ascending order
print(table.concat(numbers, ", "))  -- prints: 1, 1, 3, 4, 5, 9

-- Custom comparison (descending order)
table.sort(numbers, function(a, b) return a > b end)
print(table.concat(numbers, ", "))  -- prints: 9, 5, 4, 3, 1, 1
```

## table.pack(...)
Creates a new table with the given arguments. The resulting table has all arguments stored at integer keys starting with 1, and a field "n" with the total number of arguments.

Example:
```lua
local args = table.pack("apple", "banana", "orange")
print(args[1], args[2], args[3])  -- prints: apple banana orange
print(args.n)  -- prints: 3
```

## table.unpack(table, i?, j?)
Returns all elements from the table as separate values. The optional `i` and `j` parameters specify the range of elements to return (defaults to 1 and the table length).

Example:
```lua
local fruits = {"apple", "banana", "orange"}
local a, b, c = table.unpack(fruits)
print(a, b, c)  -- prints: apple banana orange

-- With range specification
local x, y = table.unpack(fruits, 2, 3)
print(x, y)  -- prints: banana orange
```

# Non-standard APIs
## table.keys(table)
Returns an array containing all the keys in the table.

Example:
```lua
local person = {name = "John", age = 30, city = "New York"}
local keys = table.keys(person)
print(table.concat(keys, ", "))  -- prints: name, age, city
```

## table.includes(table, value)
Checks if a list-table contains a specific value.

Example:
```lua
local fruits = {"apple", "banana", "orange"}
print(table.includes(fruits, "banana"))  -- prints: true
print(table.includes(fruits, "grape"))   -- prints: false
```

## table.find(table, criteriaFn, fromIndex?)
Finds an element in a table that matches a criteria function. Returns a Lua multi value of index and first element or nil if no element is found.

Example:
```lua
local numbers = {1, 2, 3, 4, 5}
local _, firstEven = table.find(numbers, function(n) return n % 2 == 0 end)
print(firstEven)  -- prints: 2

-- With fromIndex parameter
local _, firstEvenAfter3 = table.find(numbers, function(n) return n % 2 == 0 end, 3)
print(firstEvenAfter3)  -- prints: 4

-- No match case
local result = table.find(numbers, function(n) return n > 10 end)
print(result)  -- prints: nil
```

## table.select(table, keys...)
Returns a new table from an old one, only with selected keys.

Example:
${query[[
  from p = index.tag "page"
  limit 3
  select table.select(p, "name", "lastModified")
]]}


================================================
FILE: website/API/tag.md
================================================
#api/space-lua #maturity/experimental

Enables customization of [[Tag|tags]] and its [[Object|objects]] in a few ways:

* Restrict and validates [[Metadata]] against a [[Schema]]
* Change how objects are indexed in the [[Object Index]]
* Tweak styling of tags in the editor using [[Space Style]]

> **success** Success
> Put all your `tag.define` calls in your [[CONFIG]] page so that they will be picked up during the index process.

# API
## tag.define(spec)
Defines a tag explicitly.

`spec` is a table that can contain:
* `name` (required) the name of the tag
* `metatable` to set a custom Lua metatable for objects with this tag.
* `mustValidate` a boolean defining whether or not schema validation must pass for the object to be indexed
* `schema` [[Schema]] to validate against
* `validate` callback function invoked when an objects needs to be validated, returns `nil` or an error message.
* `transform` callback function invoked when an object with tag `name` has been indexed. Allows you to make changes to it, skip indexing altogether or generate additional objects.

When `tag.define` is called multiple times with the same `name`, the specs will be merged. This e.g. enables overriding the schema of a built-in tag, or augment it with a custom `transform` callback.

This is a very powerful API, with a wide range of potential use cases.

# Use cases
## Schema validation
To define a [[Schema]] for validating an object (e.g. a page) tagged with `#person` ensuring that the `age` attribute is always a number, you can do the following:
```lua
tag.define {
  name = "person",
  -- mustValidate = true,
  schema = {
    type = "object",
    properties = {
      age = schema.number()
    }
  }
}
```
**Effect:** When editing a page tagged with `#person` where this schema does not validate (e.g. you set the `age` to be a string), you will see this error being highlighted in the editor.

If you set `mustValidate` to `true`, schema validation will happen during the index phase (in addition to linting in the editor) and non-validating objects will not be indexed (with errors being reported in the JavaScript console). Use this to ensure all your objects conform to your schema (it does come at a slight performance penalty at the indexing phase). Errors are reported to the [[Log]].

## Index augmentation
Based on your page’s markdown, an indexer produces a list of objects to be indexed. If you define a `transform` callback function for a tag, this function will be invoked for each object with that tag, when the indexer encounters one. `transform` can inspect the object and do a few things:

* Make changes to the object in place and return it: this is the most common scenario. It allows you to attach additional attributes to the object before it's persisted to the database.
* Return an empty table (`{}`): in this case the object will not be indexed at all.
* Return `nil` in which case indexing proceeds as usual.
* Return a list of objects that should be indexed _instead_: this may include the original object or a modification of it. This allows you to generate a set of custom objects, e.g. based on further parsing of the data in the object. A use case here could be to extract additional attributes from an existing attribute.
  **Note:** one of the returned objects _must_ have the same `ref` attribute as the object passed in.

> **warning** Warning
> For custom `transform` functions to be picked up during the initial client indexing phase they **have to be defined** in your [[CONFIG]] page.

> **note** Note
> `transform` will only be invoked when a page is indexed. This generally happens after making a change. To apply newly defined `transform` functionality to all pages in your space, you have to reindex the entire space using `Space: Reindex`.

### Example: adding [[Page Decorations]] dynamically
The following dynamically adds a 🧑 prefix [[Page Decorations|page decoration]] to all pages tagged with `#person`, such as [[Person/John]] and [[Person/Zef]].

```lua
tag.define {
  name = "person",
  transform = function(o)
    o.pageDecoration = { prefix = "🧑 " }
    return o
  end
}
```

### Example: a ‘deadline’ attribute for tasks
Let’s say that you’d like the encode a deadline attribute for tasks without using the `[deadline: "2026-12-31"]` syntax, instead you’d like to use an emoji as follows:

* [ ] Hello 📅 2026-12-31

In addition:

We’d like to highlight tasks that use a 📅 but then don’t follow the correct date format, like here:

* [ ] Hello task 📅 31-12-2026

And we’d like the `name` attribute to be cleaned from the deadline syntax.

This can be implemented by defining a custom `transform` for tasks:
 
```lua
local deadlinePattern = "📅%s*(%d%d%d%d%-%d%d%-%d%d)"

tag.define {
  name = "task",
  validate = function(o)
    if o.name:find("📅") then
      if not o.name:match(deadlinePattern) then
        return "Found 📅, but did not match YYYY-mm-dd format"
      end
    end
  end,
  transform = function(o)
    -- Use a regular expression to find a deadline
    local date = o.name:match(deadlinePattern)
    if date then
      -- Remove the deadline from the name
      o.name = o.name:gsub(deadlinePattern, "")
      -- And put it in as attribute
      o.deadline = date
    end
    return o
  end
}
```

The result is the following:
${query[[
  from t = tags.task
  where t.deadline
  select table.select(t, "name", "done", "deadline")
]]}

## Styling
Tags get assigned a `data-tag-name` attribute in the DOM, which you can use to do custom styling with [[Space Style]].

### Example: colorful tags
Example: #my-red-tag

```space-style
a[data-tag-name="my-red-tag"] {
  background-color: red;
}
```


================================================
FILE: website/API/taskState.md
================================================
#maturity/beta

APIs to define [[Task#Custom states]].

# API
## taskState.define(def)
Defines a custom task state. Options:
* `name`: name of the state
* `done`: whether or not the state should be considered "done" or not, used by the `Task: Remove Completed` command

Primarily used to offer code completion for custom task states for now.

## Example
```space-lua
taskState.define {
  name = "TO DO"
}

taskState.define {
  name = "IN PROGRESS"
}

taskState.define {
  name = "DONE",
  done = true
}
```
Use:
* [TO DO] Still to do
* [DONE] Task already completed


================================================
FILE: website/API/template.md
================================================
#api/space-lua

Template functions that use the [[API/template#template.new(template)]] function.

## template.new(template, stripIndent)
Returns a template function that can be used to render a template. Conventionally, a template string is put between `[==[` and `]==]` as string delimiters.
If `stripIndent` is set to true or omitted, the function will try to remove indentation at the start of the line. This can be useful for multiline strings.

Example:

```space-lua
examples = examples or {}

examples.sayHello = template.new[==[Hello ${name}!]==]
```

And its use: ${examples.sayHello {name="Pete"}}

## template.each(collection, template)
Iterates over a collection and renders a template for each item.

Example:

${template.each(query[[from index.tag "page" limit 3]], template.new[==[
    * ${name}
]==])}


================================================
FILE: website/API/widget.md
================================================
APIs to define widgets in SilverBullet, often used through [[Space Lua#Expressions]].

# Widget types
## Markdown widgets
When setting a `markdown` key, or using the `widget.markdown` API, a markdown-based widget can be created.

Example:

```space-lua
function helloWorld(name)
  return widget.markdown("Hello world, *" .. name .. "*!")
end
```

Can be used as follows:

${helloWorld("Pete")}

## DOM widgets
To render a custom HTML-based widget, use the [[API/dom]] elements passed as an argument to `widget.html`:

```space-lua
function marquee(text)
  return widget.html(dom.marquee {
    class = "my-marquee",
    onclick = function()
      editor.flashNotification "You clicked me"
    end,
    text
  })
end
```

We can combine this with some [[Space Style]] to style it:

```space-style
.my-marquee {
  color: purple;
}
```

This can be used as follows:
${marquee "Finally, marqeeeeeee!"}
# API
## widget.new(spec)
To render a widget, call `widget.new` with a `spec` table setting any of the following keys:

* `markdown`: Renders the value as markdown
* `html`: Renders a HTML DOM as a widget. It is usually used in conjunction with the [[API/dom]] API.
* `display`: Render the value either `inline` or as a `block` (defaults to `inline`).

## widget.markdown(text)
Shortcut for `widget.new { markdown = text }`

## widget.html(htmlOrDOM)
Shortcut for `widget.new { html = htmlOrDOM }`

Usually used in conjunction with [[API/dom]].


================================================
FILE: website/API/yaml.md
================================================
#api/syscall

The YAML API provides functions for parsing and stringifying YAML content.

### yaml.parse(text)
Parses a YAML string into a Lua table.

Example:
```lua
local text = [[
name: John
age: 30
hobbies:
  - reading
  - hiking
]]

local data = yaml.parse(text)
print(data.name)  -- prints: John
print(data.hobbies[1])  -- prints: reading
```

### yaml.stringify(obj)
Converts a Lua table into a YAML string.

Example:
```lua
local data = {
    name = "John",
    age = 30,
    hobbies = {"reading", "hiking"}
}

local yamlText = yaml.stringify(data)
print(yamlText)
```


================================================
FILE: website/Anything Picker.md
================================================
The anything picker can be used to open any page, including [[Meta Page]] and pages hidden with [[Page Decorations]]. Otherwise it’s equivalent to the [[Page Picker]].

To open, run the command ${widgets.commandButton("Navigate: Anything Picker")} using this button or [[Command Palette]]. Alternatively cycle from any other Picker by typing `^`.


================================================
FILE: website/Architecture.md
================================================
Here is a big-picture (conceptual) architecture of SilverBullet:
```excalidraw
url:architecture.excalidraw
height:800px
```

# Client
The client is what you see when you open SilverBullet in a browser tab or window. It renders the UI, interacts with the user and runs most of the logic. 90%+ of logic in SilverBullet lives here.

## Editor
The editor is built on [CodeMirror](https://codemirror.net/) with a lot of [[Markdown/Extensions]] to its default Markdown mode.

## Space Lua
SilverBullet contains a custom [[Lua]] runtime called [[Space Lua]]. It should be your first go-to when you are interested in extending SilverBullet to your like. Space Lua implements the majority of the Lua 5.4 standard library (where it makes sense), but also includes some additional Space Lua-specific [[API]]s. In addition all [[#Syscalls]] are accessible from Space Lua.

When Lua code gets too complicated or performance is more of a concern, the next level is to build your extension in TypeScript as a plug.

## Plugs
[[Plugs]] and the PlugOS library that implements are a generic means to extend SilverBullet. Code is written in TypeScript and compiled into a single JavaScript `.plug.js` bundle that can be distributed as part of a [[Library|Library]]. 

A lot of [“built in” functionality](https://github.com/silverbulletmd/silverbullet/tree/main/plugs) in SilverBullet in in fact implemented as plugs, partially to “stress test” this infrastructure. 

## Syscalls
Syscalls (system calls) are the abstraction used in SilverBullet to cleanly create an API boundary between SilverBullet and extension code (Plugs or Space Lua). A lot of functionality core to SilverBullet (in the editor, the event system, datastore) are exposed through syscalls.

## Events and services
[[Event]]

## Datastore


The client interacts with the service worker and server primarily via the [[HTTP API]], which exposes CRUD-style operations on files.

# Service Worker
The [service worker](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)’s role is to make SilverBullet offline capable. When enabled (it is enabled by default) it:
* Caches and serves the SilverBullet client code
* [[Sync|Syncs]] files with a local database
* Implements the [[HTTP API]], but handles it locally (where appropriate)

It does this by intercepting HTTP calls coming from the client aimed at the server.

The service worker embeds a [[Sync]] engine, that based on configuration constantly keeps a local copy of your files in sync with the server. To make the sync status visible, it emits events to the Client.

For debugging purposes you can disable the service worker by adding `?enableSW=0` to your URL. This disabling is persistent, to re-enable it use `?enableSW=1`.

# Server
The server has only three jobs:

* Handle authentication
* Serve the (static) client code
* Implement the [[HTTP API]], which mainly means listing, reading, writing and deleting files, and executing shell commands.

Note that all indexing, querying etc. happens in the client. The server effectively acts as a file store.


================================================
FILE: website/Aspiring Pages.md
================================================
An aspiring page is a [[Page|page]] that does not yet exist, but is already linked to.

Aspiring pages appear in the [[Page Picker]] (with a `Create page` hint) as well as in auto complete when creating [[Link]].

================================================
FILE: website/Attribute.md
================================================
Attribute syntax can contribute additional [[Metadata]] to various [[Object|Objects]], including:

* Pages (although the preferred way is [[Frontmatter]])
* Items (bulleted and numbered list items)
* [[Task|Tasks]]
* Paragraphs

## Syntax
The syntax is as follows:

```
[attributeName: value]
```

For Obsidian/LogSeq compatibility, you can also double the colon like this: `[attributeName:: value]`
 
Attribute names need to be alpha-numeric. Values are interpreted as [[YAML]] values. So here are some examples of valid attribute definitions:

* string: [attribute1: sup]
* number: [attribute2: 10]
* array: [attribute3: [sup, yo]]

Multiple attributes can be attached to a single entity, e.g. like so:

* Some item [attribute1: sup][attribute2: 22]

## Scope
Depending on where these attributes appear, they attach to different things. For instance, this attaches an attribute to a page:

[pageAttribute: hello]

However, usually, [[Frontmatter]] is used for this purpose instead.

Example query:

${query[[
  from index.tag "page"
  where _.name == editor.getCurrentPage()
  select {name=_.name, pageAttribute=_.pageAttribute}
]]}

This attaches an attribute to an item:

* Item [itemAttribute: hello] #specialitem

Example query:

${query[[
  from index.tag "specialitem"
  where itemAttribute == "hello"
  select {
    name = _.name,
    itemAttribute = _.itemAttribute
  }
]]}

This attaches an attribute to a task:

* [ ] Task with an attribute, I’m so cool [taskAttribute: hello]

Example query:

${query[[
  from index.tag "task"
  where taskAttribute == "hello"
  select {
    name = _.name,
    taskAttribute = _.taskAttribute
  }
]]}


================================================
FILE: website/Authelia.md
================================================
How to set up SilverBullet with Authelia

For SilverBullet to work as intended, some files will need to be excluded from your authentication method of choice. These files always need to be accessible, for example for offline or [[PWA]] support.

The files are the following:
- The web manifest
- The app icon
- The service worker

These files can be whitelisted by adjusting your Authelia configuration to something like this:

```yaml
access_control:
  default_policy: deny
  
  rules:
    - domain: silverbullet.yourdomain.com
      resources:
        - '/\.client/manifest\.json$'
        - '/\.client/[a-zA-Z0-9_-]+\.png$'
        - '/service_worker\.js$'
      policy: bypass
    - domain: yourdomain.com
      policy: two_factor
```

Please adjust this to fit your specific needs. The important part is that the files are associated with `policy: bypass`.


================================================
FILE: website/Authentication Proxy.md
================================================
In case you would like to run SilverBullet behind an authentication proxy (such as Authelia, Authentik or the ones integrated with Cloudflare Zero Trust or Pangolin) there is one key configuration tweak you need to make:

**You must exclude a few paths from authentication**. If you don’t do this a lot of PWA functionality may not work and SilverBullet may break in unexpected ways. Doing this is perfectly safe, it just gives browsers unauthorized access to some client code, not any of your content.

In your configuration add **rules to allow unauthenticated access** for the following paths:

* `/service_worker.js`
* `/.client/*`


================================================
FILE: website/Authentication.md
================================================
SilverBullet supports simple authentication for a single user.

By setting the `SB_USER` environment variable with a username:password combination, you enable authentication for a single user. For instance:

```shell
docker run -e SB_USER=pete:1234 ...
```

Will let `pete` authenticate with password `1234`. 

For more options see [[Install/Configuration]].

Alternatively, or in addition, you can also use an [[Authentication Proxy]].

================================================
FILE: website/Browser.md
================================================
SilverBullet is a web application. You access it via any modern browser, and it leverages a lot of recent technological advances built into modern browsers, such as service workers (for offline capabilities), IndexedDB (for storing and querying data) and cryptography APIs.

_Because_ it is a browser-based web application, you can access it from any device with a modern web browser, this includes desktops, laptops, phones and tablets. 

_While_ it’s a browser-based web application, many browsers (such as Chromium-based and Safari-based ones) allow you to install it as a [[PWA]], giving it an icon on your home screen, or icon in your dock, and removing any unnecessary browser Chrome, making it visually indistinguishable from a “native” desktop app:

![[pwa-screenshot.png]]


================================================
FILE: website/CONFIG.md
================================================
This is where you configure SilverBullet to your liking. See [[^Library/Std/Config]] for a full list of configuration options. 

# Main configuration
```space-lua
config.set {
  actionButtons = {
    {
      icon = "home",
      description = "Go to the index page",
      run = function()
        editor.invokeCommand("Navigate: Home")
      end
    },
    {
      icon = "activity",
      description = "What's new",
      run = function()
        editor.navigate "CHANGELOG"
      end
    },
    {
      icon = "message-circle",
      description = "Community",
      run = function()
        editor.openUrl "https://community.silverbullet.md"
      end
    },
    {
      icon = "github",
      description = "Github",
      run = function()
        editor.openUrl "https://github.com/silverbulletmd/silverbullet"
      end
    },
    {
      icon = "heart",
      description = "Funding",
      run = function()
        editor.navigate "Funding"
      end
    },
    {
      icon = "book",
      description = "Open page",
      run = function()
        editor.invokeCommand("Navigate: Page Picker")
      end
    },
    {
      icon = "search",
      description = "Search",
      run = function()
        editor.invokeCommand("Silversearch: Search")
      end
    },
    {
      icon = "terminal",
      description = "Run command",
      run = function()
        editor.invokeCommand "Open Command Palette"
      end,
    }
  }
}
```

# Custom tag definitions
(further detailed in [[API/tag#Use cases]])
```space-lua
tag.define {
  name = "person",
  transform = function(o)
    o.pageDecoration = { prefix = "🧑 " }
    return o
  end
}

local deadlinePattern = "📅%s*(%d%d%d%d%-%d%d%-%d%d)"

tag.define {
  name = "task",
  validate = function(o)
    if o.name:find("📅") then
      if not o.name:match(deadlinePattern) then
        return "Found 📅, but did not match YYYY-mm-dd format"
      end
    end
  end,
  transform = function(o)
    -- Use a regular expression to find a deadline
    local date = o.name:match(deadlinePattern)
    if date then
      -- Remove the deadline from the name
      o.name = o.name:gsub(deadlinePattern, "")
      -- And put it in as attribute
      o.deadline = date
    end
    return o
  end
}
```

================================================
FILE: website/CONTAINER_BOOT.md
================================================
## Uncomment the following line to install pandoc:
#apk add pandoc


================================================
FILE: website/Client Encryption.md
================================================
#maturity/beta

By default SilverBullet keeps a copy of all your files as well as the index, unencrypted in your browser’s IndexedDB. As long as you control your device and other people do not have access to it, this should be perfectly safe. Browsers do not allow access to these databases from other websites and domains.

However, if you are accessing SilverBullet from an “untrusted” device, such as a public computer, this is not a great option, because even after closing your browser all content remains stored on the device unencrypted. You can use commands like `Client: Wipe` and `Client: Logout` to remove data locally, but there’s a chance you will forget at some point.

> **note** Note
> Client encryption right now is only available for users of SilverBullet’s native [[Authentication]] system.

This is what SilverBullet’s **client encryption** is for. When you authenticate using SilverBullet’s [[Authentication]] system, you will have a checkbox “Enable client encryption”. When checked, _all_ your content kept on the client will be encrypted (both your synced files and index). Since no encryption keys are written to disk at any stage, closing your browser or even closing all SilverBullet tabs will flush the key. While content remains on the device, it cannot be viewed without the encryption key derived from your username and password, leaving it useless to malicious actors.

This safety does come at a cost:

1. **Performance:** due to constant encryption and decryption SilverBullet will be slower.
2. **Convenience:** the client-side encryption key (derived from your username and password) will only ever be kept in memory, which means that if you close all your SilverBullet browser tabs and windows, the key will be flushed and you will have to login again.

# Details
Some technical details on how client encryption works.

Note that this is _client_ encryption only, not end-to-end encryption. Data on the server is only encrypted if your space folder is kept on some encrypted volume on the server side. _Transfer_ of data from the client to server is always encrypted via TLS, whether you use client encryption or not.

On the client, all of SilverBullet’s local data storage is built on a small key-value based [abstract interface](https://github.com/silverbulletmd/silverbullet/blob/main/client/data/kv_primitives.ts). By default this interface is implemented to [directly](https://github.com/silverbulletmd/silverbullet/blob/main/client/data/indexeddb_kv_primitives.ts) communicate with your browser’s [IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API). On top of this we build the sync engine and all our database indexing features. When you enable client encryption, we put a layer in-between: the encryption layer, which based on a cryptographic key will encrypt and decrypt both keys and values on the fly.

A strong 256-bit cryptographic key is derived (using _PBKDF2_) on the client from your username/password combo entered upon login. This key is kept in the service worker for SilverBullet clients to obtain so that the user is not required to constant log in when refreshing a tab, or opening new SilverBullet tabs and windows.

Since we need deterministic and stable encryption for data store keys, we use _AES-CTR_ with a fixed counter. For values we use _AES-GCM_ with randomized ivs.


================================================
FILE: website/Command Palette.md
================================================
The Command Palette is used to explore SilverBullet’s numerous [[Command]] as well as execute them.

The UI and its operation is largely the same as the [[Page Picker]]‘s, with a few differences:

* If a keyboard shortcut is configured for the given command, it is listed along the command name to the right.
* The ordering is decided based on two factors:
  * The last time the command was invoked via the [[Command Palette]] in this client session.
  * The `priority` configured for the command (in the plug, or via [[^Library/Std/Config]] under `shortcuts`).


================================================
FILE: website/Command.md
================================================
Commands define actions that SilverBullet can perform. They range from simple edit commands, such as `Text: Bold`, but may be more elaborate such as `Page: Rename`. 

SilverBullet ships with a lot of commands built in, but custom ones can also be defined using [[Space Lua]] and [[Plugs]].

Beside the list below, all commands available commands appear in the [[Command Palette]] but may have key bindings as well (these key bindings appear in the [[Command Palette]] and are configurable in [[^Library/Std/Config]]).

You can create custom command using the [[^Library/Std/APIs/Command]] API.


================================================
FILE: website/Data Sovereignty.md
================================================
#value

Data Sovereignty (in our definition) is the principle that your data ought to be controlled by _you_. _You_ ought to have control over your own data, how it is accessed, processed and used.

SilverBullet supports data sovereignty by taking the approach of [[Self Hosted]], combined with [[Open Source]], [[Zero Tracking]], and leveraging the [[Browser]] (rather than apps) for universal access.


================================================
FILE: website/Deployments/Caddy.md
================================================
The most straightforward way to add TLS on top of SilverBullet is to use [Caddy](https://caddyserver.com/). Caddy can automatically provision an SSL certificate for you.

When you’re deploying on a public server accessible to the Internet, you can do this as follows:

```shell
sudo caddy reverse-proxy --to :3000 --from yourdomain.com:443
```

If you’re deploying on a local network and accessing your server via a VPN, this is a bit more tricky. The recommended setup here is to use [Tailscale](https://tailscale.com/), which now [supports TLS certificates for your VPN servers](https://tailscale.com/kb/1153/enabling-https/). Once you have this enabled, get a certificate via:

```shell
tailscale cert yourserver.yourtsdomain.ts.net
```

Caddy can automatically find these certificates once provisioned, so you can just run:

```shell
sudo caddy reverse-proxy --to :3000 --from yourserver.yourtsdomain.ts.net:443
```


================================================
FILE: website/Development.md
================================================
SilverBullet development can happen at various level.

At its most basic level, you can do a lot using [[Space Lua]]. If this does not give enough power, you can upgrade to [[Plugs]]. If _that_ doesn’t suffice, you can contribute to SilverBullet’s core (see below).

# Stack
SilverBullet’s client 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/). [ES Build](https://esbuild.github.io) is used to build the frontend.

[[Plugs]] are also written in TypeScript.

The SilverBullet server is written in [Go](https://go.dev/).

# 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

# Development
Requirements: 
* [Deno](https://deno.land/) 2.4 or newer.
* [Go](https://go.dev/) 1.25 or newer
* Make

It's convenient to also install [air](https://github.com/air-verse/air) for development, this tool will watch your code base for changes and automatically rebuild:

```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 using air:

```shell
air <PATH-TO-YOUR-SPACE>
```

Note, that if you want to pass arguments to your SilverBullet binary like `-p` or `-L` you need to this as follows:

```shell
air -- -L 0.0.0.0 <PATH-TO-YOUR-SPACE>
```


Alternatively, to build the project without air:

```shell
make build
```

To run the resulting server:

```shell
./silverbullet <PATH-TO-YOUR-SPACE>
```

### 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 <PATH-TO-YOUR-SPACE>:/space silverbullet
```



================================================
FILE: website/Document Editor.md
================================================
Document editors allow users to not only view but also edit documents. This means that Silverbullet can (theoretically) be used for much more than just markdown, e.g. PDF, asciidoc, org and a lot more. Currently the feature is still very young and the only editors available are proof of concepts, but this should hopefully change soon. If you want to contribute and make your own editor look at [[#Internals]]

# Usage
Document editors are provided by [[Plugs]]. To install a document editor just install the plug. The editor will then be available for documents with certain file extensions which are specified by the plug, e.g. an image viewer would be available for `jpg`, `jpeg` and `png`.
To edit or view a document, use `Cmd-o` (Mac) or `Ctrl-o` (Windows, Linux) to open the document navigator. Files for which a document editor is available will have their extension highlighted in blue.

# Available editors
Currently only small demos are available
- [TXT editor](https://github.com/MrMugame/silverbullet-txt/)
- [PDF viewer](https://github.com/MrMugame/silverbullet-pdf/)

# Development
First the editor is defined inside the plug manifest. The `editor` field is used to specify the file extensions this editor can handle.

```yaml
name: txteditor
functions:
  TXTEditor:
    path: ./editor.ts:editor
    editor: ["txt"]
```

The function provided should look like this. The html tag will be directly inserted into an iframe as the `srcdoc`. This means you can also use the `<head>` or `<body>` tags.

```typescript
export async function editor(): Promise<{ html: string }> {
  return {
    html: "<h1>Document Editor example</h1>"
  }
}
```

To communicate with silverbullet events/messages are used. You can subscribe to an event inside a script tag as follows

```javascript
window.silverbullet.addEventLister("file-open", (event) => {
  console.log("Got an event:", event);
});
```

SB will dispatch the following events:
- `file-open`: A file was just navigated to, the document editor needs to open it. The details contain the meta and data: `{ data: Uint8Array, meta: DocumentMeta }`
- `request-save`: Silverbullet is requesting a save, you should send a `file-saved` event as soon as possible
- `focus`: You should focus the editor if possible (i.e. highlight cursor)

To send events/messages like the `file-saved` message, you can use the `sendMessage` function.

```javascript
window.silverbullet.sendMessage("file-saved", { data: new TextEncoder().encode("We saved a text file") })
```

If a the document changed and a save is necessary you can send the `file-changed` message. Silverbullet will request a save automatically.
You can also send events from the outside using the `editor.sendMessage` syscall. If you want to communicate the other way around you can call syscalls using `window.silverbullet.syscall(name: string, ...args: any[])`.


================================================
FILE: website/Document.md
================================================
While SilverBullet is aimed at primarily text-based content, life can not fully be represented in text always. Therefore, SilverBullet supports documents. Documents, like [[Page]] ultimately are — once again — just files on disk. Using plugs, documents of specific file types can also be viewed and edited.

# Uploading
To create a document, you have a few options:

* Use the ${widgets.commandButton("Upload: File")} command (especially useful on mobile devices)
* Drag & drop files or images onto a page
* Copy & paste files or images onto a page

All options will prompt you for a file name to use to store the document, and then include the document as an embedded image (if it was an image) or link to the file.

# Managment
Using `Cmd-o`/`Ctrl-o` you can open the document navigator, which will display all documents in your Silverbullet space. You can either:
  - Delete or rename documents for which no [[Document Editor]] is available (indicated by the grey hint)
  - Open and view documents for which a [[Document Editor]] is available (indicated by the blue hint)

# Linking
Documents can be linked to in two ways:
* Via the regular link syntax: `[link text](document.pdf)`. URLs here are relative to the page, so on a page named `MyFolder/Hello`, `[link text](document.pdf)` would refer to a document stored in `MyFolder/document.pdf`.
* Via the wiki link syntax: `[[document.pdf]]`. These paths are absolute and relative to your space’s root, just like regular page links. That is: on a page `MyFolder/Hello` a document link `[[document.pdf]]` would link to the file `document.pdf` in the space’s root folder.

# Embedding
Media can also be embedded using the [[#Linking]] syntax, but prefixed with an `!`:
Images, videos, audio and PDFs are currently supported.

* `![alternate text](image.png)`
* `![[image.png]]`

These follow the same relative/absolute path rules as links described before.

## Media resizing
In addition, media can be _sized_ using the following syntax:
* Specifying only a width: `![Alt text|300](image.png)` or `![[image.png|300]]`
* Specifying only a height: `![Alt text|x300](image.png)` or `![[image.png|x300]]`
* Specifying both width and height: `![Hello|300x300](image.png)` or `![[image.png|300x300]]`


================================================
FILE: website/Editor.md
================================================
By design, the vast majority of the SilverBullet UI is taken up by the editor. This is where the magic happens.

This is where you write in [[Markdown]].

================================================
FILE: website/End-User Programming.md
================================================
An excellent [essay from Ink & Switch](https://www.inkandswitch.com/end-user-programming/) introduces **end-user programming** as follows:

> Today’s software apps are like appliances: we can only use the capabilities exactly as programmed by the developer. What if we, and all computer users, could reach in and modify our favorite apps? Or even create new apps on the fly according to our needs in the moment?
> 
> This is end-user programming, a vision for empowered computing pursued by bright-eyed computer science visionaries. Its rich history reaches back to the 1960s with programming environments like Smalltalk and Logo. Notable successes since then include Unix, the spreadsheet, Hypercard, and HTML. And today, newcomers like Zapier, Coda, and Siri Shortcuts are trying their own approaches to automation and dynamic modeling.

SilverBullet is working towards supporting more end-user programming features. Some of them currently available include:

* [[Space Lua]]
* [[Object]]
* [[Metadata]]

================================================
FILE: website/Event.md
================================================
SilverBullet has its own event bus.

Events can be subscribed to and dispatched via the [[API/event]] API. Other features, such as [[Service]] are built on top of Events.

# Built-in events
Here is a list of built-in events (triggered by SilverBullet’s core).
To use them, simply subscribe to them via [[API/event#event.listen(listenerDef)]], and do a `print` to see what data you receive. Alternatively, grep the code base to see exactly where they are triggered.

* `page:click`: user clicks a location on the page
* `page:index`: page has changed and requested to be indexed (used by [[Object]] indexing)
* `editor:complete`: editor completion triggered, returns completion results
* `slash:complete`: slash completion triggered, returns completion results
* `editor:lint`: request to lint, returns errors
* `editor:init`: editor initialized
* `editor:pageLoaded`: page has loaded in the editor
* `editor:pageReloaded`: page reloaded in the editor (e.g. when it was changed on disk)
* `editor:pageSaving`: page is about to save
* `editor:pageSaved`: page has saved
* `editor:pageCreating`: page is creating (can return a page content object)
* `editor:pageModified`: a change was made to the document (real-time)
* `editor:documentSaving`: a document is about to be saved
* `editor:documentSaved`: a document was saved
* `editor:modeswitch`: triggered between vim mode and non-vim mode
* `editor:fold`: code was folded in the editor
* `editor:unfold`: code was unfolded in the editor
* `plugs:loaded`: plugs were loaded
* `cron:secondPassed`: a second has passed (useful to implement cron-like features)
* `hooks:renderTopWidgets`: top widgets have requested to render (return widgets)
* `hooks:renderBottomWidgets`: bottom widgets have requested to render (return widgets)

# All subscribed events
Here’s a dynamically generated list of events that this SilverBullet instance has subscribed to, to give a sense of what’s there:
${query[[
  from event.listEvents()
  where not _:startsWith("service:")
  order by _
]]}


================================================
FILE: website/Export.md
================================================
If you would like to export content (a full page or a selection), you can use SilverBullet’s _Export_ functionality triggered with ${widgets.commandButton "Export: Page Or Selection"}.

**Note:** Exports are one-offs. If you are interested in sharing content onto other platforms and keeping things in sync, have a look at [[Share]].

# Support
Out of the box SilverBullet ships with the following exporters:

* Copy rich text: e.g. for pasting into a Google Docs or some other WYSIWYG environment.
* Copy clean markdown: stripping SilverBullet-specific markdown and rendering e.g. [[Space Lua#Expressions]]: e.g. to paste into a Github issue or other markdown friendly platform.

The export infrastructure as well as the above exporters are implemented in Space Lua here: [[^Library/Std/Infrastructure/Export]]. If you would like to implement your own exporters, have a look at the built-in ones to understand how they work.

================================================
FILE: website/Extensions.md
================================================
There are various ways the functionality of SilverBullet can be extended:

* [[Library]]
* [[Plugs]]


================================================
FILE: website/Folder.md
================================================
While folders technically exist in SilverBullet, they’re not necessarily the key organizational tool that they are in other tools. SilverBullet doesn’t have a file and folder tree as part of its UI, for instance.

There’s no explicit way to create a folder, but you can do so by simply putting slashes `/` in your page name (also on Windows). SilverBullet will automatically create the folder hierarchy on disk, if necessary.

What are some alternatives to using folders for organization? [[Tag]], primarily.

================================================
FILE: website/Frontmatter.md
================================================
---
status: Complete
---
Frontmatter is a common format to attach additional metadata (data about data) to markdown documents. Many tools support it as a markdown [[Markdown/Extensions|extension]].

In SilverBullet, there are multiple ways to attach [[Metadata]] to a page; frontmatter is the most popular one.

You create frontmatter by starting your markdown document with `---` followed by [[YAML]] encoded attributes and then ending with `---` again. Followed by the regular body of your document. This very page contains some frontmatter, click on it to see the underlying code.

Here is another example:

    ---
    status: Draft
    tags:
    - tag1
    - tag2
    seeAlso: "[[YAML]]"
    ---
    ## This is a section
    This is content

For convenience, you may use the `attribute.subAttribute` notation, which internally will expand:

```yaml
attribute.subAttribute: 10
```

to

```yaml
attribute:
   subAttribute: 10
```

# Special attributes
While SilverBullet allows arbitrary metadata to be added to pages, there are a few attributes with special meaning:

* `name` (==DISALLOWED==): is an attribute used for page names, _you should not set it_.
* `displayName` (`string`): very similar in effect as `aliases` but will use this name for the page in certain contexts.
* `aliases` (`array of strings`): allow you to specify a list of alternative names for this page, which can be used to navigate or link to this page
* `tags` (`array of strings` or `string`): an alternative (and perhaps preferred) way to assign [[Tag]] to a page. There are various ways to define these:
  ```yaml
  tags: tag1, tag2 # with commas
  tags: tag1 tag2 # with spaces
  tags: "#tag1 #tag2" # with pound signs and quotes (with auto completion)
  tags: # as a list
  - tag1
  - tag2
  tags: # as a list with pound signs and quotes
  - "#tag1"
  - "#tag2"
  ```

For specific use cases, like [[^Library/Std/Infrastructure/Page Templates]] or [[^Library/Std/Infrastructure/Slash Templates]], frontmatter may have specific meaning.


================================================
FILE: website/Full Text Search.md
================================================
While, honestly not amazing, SilverBullet does have full-text search support.

To use it, run the ${widgets.commandButton("Search Space")} command.

================================================
FILE: website/Funding.md
================================================
While SilverBullet is free to use, it is not free to develop. A lot of time is poured into evolving and maintaining the product: building new features, reviewing contributions, and fixing bugs.

If you are a SilverBullet user, consider sponsoring the project financially. You likely already pay for other (non-open source) software, why not SilverBullet as well?

# Why?
While the project is largely a labor of love, and a lot of volunteers contribute significantly, it does benefit from funding. The more funding, the more time can be spent to make this product as good as it can be and to recoup some of its costs.

# How?
There are a few ways to financially support the project at the moment, ordered by preference:

1. [LiberaPay](https://liberapay.com/zef) (preferred)
2. [Github sponsors](https://github.com/sponsors/silverbulletmd)
3. [Patreon](https://www.patreon.com/cw/silverbulletmd)
4. [PayPal](https://paypal.me/zefhemel)

If you are interested in becoming a corporate sponsor, reach out to [Zef Hemel directly](mailto:zef@zef.me) to discuss options.

# What?
There are two costs of the project right now:
1. Infrastructure cost to run SilverBullet websites ([silverbullet.md](https://silverbullet.md) and [community.silverbullet.md](https://community.silverbullet.md), primarily)
2. [[Zef]]’s time for development and maintenance of the product

Infrastructure costs are relatively low, but [[Zef]]’s time is not. He has a family to support, and needs to reasonably share his time between SilverBullet and other activities that function as a source of income. The more funding coming in for SilverBullet, the more of his time can be dedicated to SilverBullet, the better the product becomes. It’s as simple as that.


================================================
FILE: website/HTTP API.md
================================================
The server API is relatively small. The client primarily communicates with the server for file “CRUD” (Create, Read, Update, Delete) style operations.

All API requests from the client will always set the `X-Sync-Mode` request header set to `true`. The server _will_ use this fact to distinguish between requests coming from the client and regular e.g. `GET` requests from the browser (through navigation) and redirect appropriately (for instance to the UI URL associated with a specific `.md` file).

# File system
The space file system is exposed under the `/.fs` prefix:

* `GET /.fs` will return a full listing of all files in your space including metadata like when the file was last modified, as well as permissions in JSON format. This is primarily used for sync purposes with the client.
* `GET /.fs/*`: _Reads_ and returns the content of the file at the given path. This means that if you `GET /.fs/index.md` you will receive the content of your `index` page. If the optional `X-Get-Meta` _request header_ is set, the server does not _need to_ return the body of the file (but it can). The `GET` _response_ will have a few additional SB-specific headers:
  * (optional) `X-Last-Modified` the last modified time of the file as a UNIX timestamp in ms since the epoch (as coming from `Data.now()`). This timestamp _has_ to match the `lastModified` listed for this file in `/.fs` otherwise syncing issues may occur. When this header is missing, frequent polling-based sync will be disabled for this file.
  * (optional) `X-Created` the created time of the file as a UNIX timestamp in ms since the epoch (as coming from `Data.now()`).
  * (optional) `X-Permission`: either `rw` or `ro` which will change whether the editor opens in read-only or edit mode. When missing, `ro` is assumed.
  * (optional) `X-Content-Length`: which will be the same as `Content-Length` except if the request was sent with a `X-Get-Meta` header and the body is not returned (then `Content-Length` will be `0` and `X-Content-Length` will be the size of the file)
* `PUT /.fs/*`: The same as `GET` except that it takes the body of the request and _writes_ it to a file.
* `DELETE /.fs/*`: Again the same, except this will _delete_ the given file.

# RPC
Some functionality is exposed as RPC calls

* `GET /.ping`: Returns 200 if the server is available
* `POST /.shell`: Run a shell command on the server side and return the result
* `* /.proxy/<uri>`: Proxy a HTTP request (to avoid CORS issues) 

# Client
* `GET /.config`: Retrieve client configuration, JSON with the following keys:
  * `readOnly`: Run the client in read-only mode
  * `spaceFolderPath`: Path of the space (used to prefix client database names to support switching space folders)
  * `indexPage`: name of the index page
* `GET /.client/*`: Retrieve files implementing the client
* `GET /*` (every other path) will serve the HTML of the SilverBullet UI


================================================
FILE: website/Index Page.md
================================================
The index page is the [[Page|page]] that opens by default when either clicking the “home” button in the [[Top Bar]], or when you visit your SilverBullet’s URL without a page name in the URL.

The default index page’s name is `index`, but this is configurable via [[Install/Configuration]]’s `SB_INDEX_PAGE` variable.

# How to set a dynamic index page
Some people want to set their index page to be e.g. their daily note, and the daily note changes every... day. 

Doing this can be achieved with some [[Space Lua]] magic (in the code below we’ve set this to the `lua` language in order not to actually activate this functionality, in your own copy be sure to use `space-lua` instead):

```lua
local function redirectToCustomIndex()
  if editor.getCurrentPage() == "index" then
    -- Change this to whatever page you want your index page to be
    editor.navigate("Daily/" .. date.today())
  end
end

-- Trigger the above function when the editor is first loaded
event.listen {
  name = "editor:init",
  run = redirectToCustomIndex
}

-- And also when any page is loaded (because it may be the index page)
event.listen {
  name = "editor:pageLoaded",
  run = redirectToCustomIndex
}
```

================================================
FILE: website/Install.md
================================================
Excited to use SilverBullet? Here are three ways for you to deploy it. Also have a look at the note on [[#File systems]] at the end.

# localhost (desktop, laptop)
While this is not an ideal deployment (it limits accessing your space to _just your own machine_), it is an easy way to get started: simply run the SilverBullet server on your own laptop or desktop.

Steps:
1. Install SilverBullet either as a single [[Install/Binary]] or run it as a [[Install/Docker]] container
2. Access it via `http://localhost:3000`

Is that working out for you? Great, then proceed to deploy SilverBullet _properly_ on a server so you can also access it from other devices (like your phone).

# Self hosted (server)
You can self host SilverBullet on essentially any Intel-compatible 64-bit or ARM 64-bit machine you have terminal access to. The server needs very limited resources (a few hundred megabytes of RAM is sufficient), so even a Raspberry Pi (with a 64-bit OS) is sufficient.

The main hurdle to overcome in deploying SilverBullet on a server is that _it requires [[TLS]]_.

Steps:
1. Login to your server of choice and install SilverBullet there as a single [[Install/Binary]] or as a [[Install/Docker]] container.
2. Decide on your [[TLS]] approach to access it.

Here are a few [community guides](https://community.silverbullet.md/c/guides/6) on how to setup SilverBullet in various (TLS-enabled) setups:

* [Cloudflare Zero Trust](https://community.silverbullet.md/t/use-silverbullet-with-cloudflare-zero-trust/3618): requires a (free) Cloudflare account and domain name, covers TLS and tunneling.
* [Tailscale](https://community.silverbullet.md/t/install-silverbullet-on-a-64-bit-debian-ubuntu-raspianos-internet-accessible-via-tailscale/48): requires a (free) Tailscale account, covers TLS and tunneling and VPN access.
* [Caddy and self-signed certificates](https://community.silverbullet.md/t/level-1-local-https-with-caddy-and-self-signed-certificates/3531): no accounts required, but requires manually accepting certificates.
* [Twingate](https://community.silverbullet.md/t/level-1-access-your-silverbullet-from-outside-your-home-network-using-twingate/3541): requires a Twingate account, covers TLS and tunneling.

# Cloud
While [[Self Hosted]] is the intended path, if this is too much hassle for you. There is a simpler option by using [PikaPods](https://www.pikapods.com/pods?run=silverbullet). For a small fee (about $1.50 per month and you get $5 in credit signing up, so the first months are _free_), you can run your instance there. PikaPods handles deployment, upgrades and backups and exposes SilverBullet securely via TLS.

PikaPods contribute a part of their revenue back to the projects they host, so it’s a source of [[Funding]] for SilverBullet itself.

# File systems
## Case insensitive file systems (Mac and Windows)
It is _highly discouraged_ to run SilverBullet (in real use) on a _case insensitive_ file system. SilverBullet assumes your file system is _case sensitive_ and acts accordingly.

> **note** Note
> This only applies to where the SilverBullet _server_ runs, **not** the clients you connect to it (via your browser).

While SilverBullet will run on a case insensitive file system, and everything may _generally_ seem fine, issues start to arise when:
* You edit pages via page names that do not match (case-wise) with their file names on disk.
* When you try to create or edit pages that already exist on disk with different casing.

If you’re deploying **Linux**, you’re likely using a file system that is case sensitive. This is the recommended deployment operating system.

**macOS**’s default file system (APFS) is _case insensitive_ by default as well. However, if you insist on running SilverBullet on macOS, you can create a new APFS Volume for which you enable case sensitivity. You can then use this new volume with SilverBullet.

**Windows**’ native NTFS file system is _case insensitive_ by default, [however there are apparently ways to mark specific folders as case sensitive](https://www.howtogeek.com/354220/how-to-enable-case-sensitive-folders-on-windows-10/). Still, this makes deploying SilverBullet on Windows _not recommended_.

## NAS, sync engines, experimental file systems
While using a NAS to store your space files should be fine, if you encounter issues (unreliable saving, sync issues) it is worth switching to a local file system to see if this resolves those issues. The same goes for synchronizing your files from your server elsewhere, e.g. using tools like SyncThing. They _should_ work fine, but if you run into [[Sync]] issues, disable everything, switch to a local file system and see if the issues persist to [[Troubleshooting]].

SilverBullet’s sync engine relies on reliably persisting **last modified timestamps** for your files, and those timestamps only being touched when actual changes to those file occur. If your file system (or NAS) does not persist these consistently, or you have some external sync process that updates them — this may lead to problems. 


================================================
FILE: website/Install/Binary.md
================================================
SilverBullet is distributed as a single server binary available for the following platforms:

* macOS (Intel and Apple Silicon) (platform name “darwin”)
* Linux (x86_64 and arm64) (note that this will run on Raspberry Pi as well, but requires a 64-bit Linux distribution)
* Windows (64-bit)

Steps:
1. [Download the binary for your platform from Github](https://github.com/silverbulletmd/silverbullet/releases).
2. Unzip the file in a convenient place
3. Run it from the command line (see below)

You need to create a folder to hold your [[Space|space]] files. This folder can be kept anywhere. You can then run `silverbullet` taking the folder path as an argument:

```bash
mkdir space
# Run SilverBullet on port 3000 (default)
./silverbullet -p3000 space
```

There are a bunch of [[Install/Configuration]] options you can pass as environment variables.

# MacOS security
SilverBullet binaries are not signed, therefore when you run `silverbullet` on a modern macOS, it will likely refuse to run it.

To work around this, go into your Mac’s System Settings. In the Privacy & Security section, make sure you allow applications from App Store & Known Developers. Once you’ve run `silverbullet` from the CLI already and failed, you’ll see it listed here with an option to override. Select that option and run it again from the CLI, you should now have an addition “Run anyway” button. Click it, authenticate and from this point all should be good.

# Upgrading
You can upgrade your SilverBullet install based on the version you’d like to run.

To upgrade to the latest **stable** release:

```bash
./silverbullet upgrade
```

To upgrade to the latest **edge** release (which keeps in sync with the `main` development branch):

```bash
./silverbullet upgrade-edge
```

To upgrade your client, be sure to refresh your page _twice_ somewhat slowly.

# Version
To check which version of SilverBullet you’re running, run the ${widgets.commandButton("Client: Version")} command.


================================================
FILE: website/Install/Configuration.md
================================================
SilverBullet is primarily configured via environment variables. This page gives a comprehensive overview of all configuration options. You can set these ad-hoc when running the SilverBullet server, or e.g. in your [[Install/Docker|docker-compose file]].

# General configuration

* `SB_INDEX_PAGE`: Sets the default page to load, defaults to `index`.
* `SB_SPACE_IGNORE`: Ignore certain path patterns based on a .gitignore style format, e.g. `SB_SPACE_IGNORE="IgnoreMe/*"`.
* `SB_HTTP_LOGGING`: Set to any value to enable HTTP logging
* `SB_LOG_PUSH`: Set to any value to ask clients to push their logs to the server (for debugging purposes)

# Network
* `SB_HOSTNAME`: Set to the hostname to bind to (defaults to `127.0.0.0`, set to `0.0.0.0` to accept outside connections for the local deno setup, defaults to `0.0.0.0` for docker)
* `SB_PORT`: Sets the port to listen to, e.g. `SB_PORT=1234`, default is `3000`
* `SB_URL_PREFIX`: Host SilverBullet on a particular URL prefix, e.g. `SB_URL_PREFIX=/notes`

# Authentication
SilverBullet supports basic authentication for a single user.

* `SB_USER`: Sets single-user credentials, e.g. `SB_USER=pete:1234` allows you to login with username “pete” and password “1234”.
* `SB_AUTH_TOKEN`: Enables `Authorization: Bearer <token>` style authentication on the [[HTTP API]].
* `SB_LOCKOUT_LIMIT`: Specifies the number of failed login attempt before locking the user out (for a `SB_LOCKOUT_TIME` specified amount of seconds), defaults to `10`
* `SB_LOCKOUT_TIME`: Specifies the amount of time (in seconds) a client will be blocked until attempting to log back in.
* `SB_REMEMBER_ME_HOURS`: Sets the session duration in hours when "Remember me" is checked during login, defaults to 7 days.

# Storage
SilverBullet supports storage backends for keeping your [[Space]] content. Right now the only supported backend is to use your local disk.

## Disk storage
This is the default and simplest backend to use: a folder on disk. It is configured as follows:

* `SB_FOLDER`: Sets the folder to expose. In the docker container, this defaults to `/space`.


# Run mode
* `SB_READ_ONLY` (==Experimental==): If you want to run the SilverBullet client and server in read-only mode (you get the full SilverBullet client, but all edit functionality and commands are disabled), you can do this by setting this environment variable to `true`. Upon the server start a full space index will happen, after which all write operations will be disabled.

# Security
SilverBullet enables plugs to run shell commands. This is potentially unsafe. If you don’t need this, you can disable this functionality:

* `SB_SHELL_BACKEND`: Enable/disable running of shell commands from plugs, defaults to `local` (enabled), set to `off` to disable. It is only enabled when using a local folder for [[#Storage]].
* `SB_SHELL_WHITELIST`: Allow only a specific list of shell commands (just the first command name, not arguments). When not set, allows all shell commands. Example: `SB_SHELL_WHITELIST="git pandoc"`

# Docker
Configuration only relevant to docker deployments:

* `PUID`: Runs the server process with the specified UID (default: whatever user owns the `/space` mapped folder)
* `PGID`: Runs the server process with the specified GID (default: whatever group owns the `/space` mapped folder)\

# Metrics
SilverBullet offers a few basic Prometheus metrics, these can be configured with the following environment variables:

* `SB_METRICS_PORT`: HTTP port to expose metrics (under the default `/metrics` endpoint) on

# Web app manifest
Configure aspects of web app appearance as well as the authentication page:

* `SB_NAME`: Sets `name` and `short_name` members of web app manifest to whatever specified in `SB_NAME`
* `SB_DESCRIPTION`: Sets `description` member of web app manifest to whatever specified in `SB_DESCRIPTION`


================================================
FILE: website/Install/Docker.md
================================================
[Docker](https://www.docker.com/) is a convenient and secure way to install server applications either locally or on a server you control. If you don’t have docker already running on your machine and are macOS user, consider giving [OrbStack](https://orbstack.dev/) a try — it’s a super nice docker experience.

Conveniently, SilverBullet is published as a [docker image on GHCR](https://github.com/silverbulletmd/silverbullet/pkgs/container/silverbullet). The image comes in two flavors:

* 64-bit Intel
* 64-bit ARM (e.g. for Raspberry Pis and Apple Silicon macs)

There is no 32-bit version of SilverBullet. Most people run 64-bit OSes these days, an exception may be Raspberry Pis. Recent (RPI 3 and later) can run 64-bit Linux as well.

> **warning** Warning
> To access SilverBullet outside of `localhost` you will need to set up [[TLS]].

# Release channels
Every release version of SilverBullet is tagged with its version number, but there are two release channels you can use (they automatically update):

* `:latest` always points to the latest _release_
* `:v2` always points to the latest _edge build_ (the last commit to `main`), use this if you want to live on the bleeding edge.

# Container
* The container binds to port `3000`, so be sure to port-map that, e.g. via `-p 3000:3000` (note: the first `3000` is the external port)
* By default SilverBullet runs _unauthenticated_, this is not safe at it allows anybody on your network to access your instance freely. Therefore, in a docker setup **always** set the `SB_USER=username:password` environment variable (see below).
* The container uses whatever is volume-mapped to `/space` as the space root folder. You can connect a docker volume, or a host folder to this, e.g. `-v /home/myuser/space:/space`
* SilverBullet will detect the UNIX owner (UID and GID) of the folder mapped into `/space` and run the server process with the same UID and GID so that permissions will just magically work. If you’d like to override this UID, set the `PUID` and `PGID` environment variables (see [[Install/Configuration]] for details).
* The Docker image is based on [Alpine](https://alpinelinux.org/). If you'd like to install additional packages into it, see [[#Installing additional packages]] below.

> **note** Note
> The same docker images are both available from [GHCR](https://github.com/silverbulletmd/silverbullet/pkgs/container/silverbullet) and [Docker Hub](https://hub.docker.com/r/zefhemel/silverbullet), use whichever you prefer.

## Versions
To check the version you’re running, use the ${widgets.commandButton("Client: Version")} command. Note that after an upgrade you may have to reload your (browser) client **twice**, to fully activate the new version.

# Docker Compose
[Docker compose](https://docs.docker.com/compose/) is a simple tool to manage running of multiple containers on a server you control, it is distributed with (modern) versions of docker. It is the recommended way to manage SilverBullet.

Below is a basic `compose.yml` that runs SilverBullet, check [[Install/Configuration]] for additional configuration options.

Instructions:
* Replace the password defined in `SB_USER` with something sensible such as `admin:b3stp4ssword3vah`
* This volume uses the `./space` directory (will be auto-created if it doesn’t already exist) in the same directory as the `compose.yml` file as the place where SB will keep its space. You may replace this with whatever location you keep your notes.

```yaml
services:
  silverbullet:
    image: ghcr.io/silverbulletmd/silverbullet:latest
    restart: unless-stopped
    environment:
    - SB_USER=admin:password
    volumes:
      - ./space:/space
    ports:
      - 3000:3000
```

Boot this up via:

```shell
docker compose up -d
```

And watch for logs with:

```shell
docker compose logs -f
```

## Upgrading
To upgrade, change the specific version you point to in your `compose.yml` file (not necessary when using `:latest` or `:v2`) and then:

```shell
docker compose pull
docker compose stop
docker compose up -d
```

# Plain Docker
If you don’t want to use docker compose, you can run SilverBullet “raw” as follows:

```shell
# Create a local folder "space" to keep files in
mkdir -p space
# Run the docker container in the background
docker run -d --restart unless-stopped \
  --name silverbullet \
  -p 3000:3000 \
  -v ./space:/space \
  -e SB_USER=user:password \
  ghcr.io/silverbulletmd/silverbullet:latest
```

There you go!

## Upgrades
To upgrade your version of SilverBullet, first pull the new image:

```shell
docker pull ghcr.io/silverbulletmd/silverbullet:latest
# Then stop the current container
docker stop silverbullet
# Remove the existing container
docker rm silverbullet
```

Then start the container again as before.

# Installing additional packages
If you would like to install additional packages into your docker container (e.g. to call via [[API/shell]]), you can do so by creating a [[CONTAINER_BOOT]] page in your space. Whatever you put in this page, will be run as a bash script upon container boot (hence its name).

In practice, you’ll likely want to put `apk add` commands install the (Alpine) packages you would like to install.


================================================
FILE: website/Install/Network and Internet.md
================================================
Running SilverBullet locally on your machine is nice, but you likely want to access it from elsewhere as well (other machines on your network, your mobile device), perhaps even from outside your home. 

> **note** IMPORTANT
> To use SilverBullet from anywhere other than localhost, you have to set up [[TLS]]. SilverBullet does not work (other than via localhost) on URLs other than `https://` ones.

For this, be sure to enable [[Authentication]].

There’s two parts to this process:
1. Run the SilverBullet server itself somewhere, following the [[Install]] instructions
2. Exposing this server to the network/Internet via [[TLS]]


================================================
FILE: website/Keyboard Shortcuts.md
================================================
SilverBullet uses [CodeMirror](https://codemirror.net) under the hood, which does the majority of the keyboard handling. In addition, it allows to for customization of (most) keys on a per-command basis. When you open the [[Command Palette]], you will see the currently assigned keyboard shortcut(s).

# Modifier keys
Your keyboard shortcut will typically consist of a combination of modifier keys as well as a letter or (special) character or number. The following modifier keys are supported:

* `Alt`
* `Ctrl`
* `Shift`
* `Cmd`: Command key (Mac only)
* `Meta`: usually the “Windows” key
* `Mod`: An alias for `Ctrl` on Linux and Windows, to `Cmd` on Mac

The keyboard shortcut is then combined to e.g. `Mod-Alt-t`.

# Constraints
SilverBullet is constrained in what keyboard can be used in a few ways:
* By the browser: you cannot (usually) override browser keyboard shortcuts, or at least shouldn’t (e.g. `Cmd-t` or `Ctrl-t` for opening a new tab)
* By already used keyboard shortcuts, see [[#Disabling keyboard shortcuts]] below to disable keyboard shortcuts for existing commands.
* By CodeMirror’s restrictions. There a few, here are some we know about:
  * `Alt-letter` is not allowed (e.g. `Alt-a`), because it clashes with various (international) keyboards

# Combos
Generally keyboard shortcuts are single stroke, however it possible to configure them as “combos” (multiple shortcuts entered in sequence). Out of the box, SilverBullet reserves two prefix keys for this “combo” use:

* `Mod-.` (so “command dot” or Mac and “Ctrl dot” on Linux/Windows), used for various [[Outlines]] related commands
* `Ctrl-q` used and reserved for [[Page Template]] use cases
  * `Ctrl-q q` creates a [[^Library/Std/Page Templates/Quick Note]]
  * `Ctrl-q t` runs the `Page: From Template` command
* `Ctrl-g` reserved for navigational commands:
  * `Ctrl-g h` navigates to the index (home) page

# Assigning keyboard shortcuts
Keyboard shortcuts can be assigned to [[Command|Commands]] with `key` or `mac`. If only `key` is defined, this keyboard shortcut will be used for all operating systems, if `mac` is set, this keyboard will be used for macOS.

To (re)assign a keyboard shortcut for an _existing_ command, you can use the [[^Library/Std/APIs/Command#command.update(commandDef)]] API as follows:
```lua
command.update {
  name = "Task: Cycle State",
  key = "Ctrl-Shift-t",
}
```

When you define your own command, you can simply make them part of your command definition:

```lua
command.define {
  name = "My command",
  key = "Ctrl-Shift-t",
  run = function()
    editor.flashNotification "Hello world "
  end
}
```

You can also specify multiple keyboard shortcuts by passing in a table to either `key` or `mac`:

```lua
command.define {
  name = "My command",
  key = {"Ctrl-Shift-t", "Ctrl-Shift-o"},
  run = function()
    editor.flashNotification "Hello world "
  end
}
```

# Disabling keyboard shortcuts
You can use the [[^Library/Std/APIs/Command#command.update(commandDef)]] API to disable keyboard shortcuts of existing commands by setting them to an empty string, for example:

```lua
command.update {
  name = "Task: Cycle State",
  key = "",
  mac = ""
}
```


================================================
FILE: website/Knowledge Management.md
================================================
[Wikipedia](https://en.wikipedia.org/wiki/Personal_knowledge_management):

> Personal knowledge management (PKM) is a process of collecting information that a person uses to gather, classify, store, search, retrieve and share knowledge in their daily activities and the way in which these processes support work activities. It is a response to the idea that knowledge workers need to be responsible for their own growth and learning. It is a bottom-up approach to knowledge management.

There you go.

================================================
FILE: website/LLM Use.md
================================================
**TL;DR:** Please do not (heavily) use LLM-based coding tools to contribute code to the [main SilverBullet code base](https://github.com/silverbulletmd/silverbullet). Also don’t heavily rely on LLMs to contribute to issues or community discussions (except for language support, if you need it).

The topic of how to deal with (largely) LLM generated contributions comes up from time to time in SilverBullet. Different open source project take different approaches: some allow them because “it’s the future,” some [decidedly do not](https://ziglang.org/code-of-conduct/). After much deliberation, I decided that for now I’ll put SilverBullet in the latter camp, as in: no thank you. 

There are two main reasons for this, the first is _moral_, a second more _practical_.

# The moral reason
I have been open sourcing code for over 25 years. My goals for doing so include helping people solve similar problems that I have, enjoy and benefit from the software I build, to allow them to adapt, improve, change and learn from it, and to build community. I have always distributed code under the most liberal of licenses (usually MIT), and have been lucky enough that those licenses were not exploited. Until recently, when LLMs came around.

Specifically _not_ on my list of goals was allowing companies to take my code (or content in general) as input to a _ridiculously_ resource-intensive (money, energy, cheap labor) algorithm, and have the result be sold back to me in the form of tokens. Yet, here we are. And while some may have the sentiment that this is fine, and perhaps even legal, I’m not too happy about it.

There are various areas in my life where I have to make more pragmatic choices, but in my open source work I feel I can afford to take a stand.

Therefore, the first reason to ask you not to contribute to SilverBullet using AI tools is a **moral** one: these tools are built by scraping all text (natural and code) LLM companies can get their hands on (often in sketchy ways), use scarce resources to train and operate them, and disproportionally benefit companies with highly questionable morals. This is not a cause I am interested in associating or supporting in my leisure time.

# The practical reason
The second reason is more practical. While there is a bubble of people that claim that the quality of the work produced by LLMs is amazing, this has not been my experience based on using these tools myself and reviewing the work of these tools done by others. Too often, it is _sloppy_.

From time to time people “contribute” to issues or the community forums with largely LLM generated text. At best it’s just a lot of text easily detected as LLM slop and ignored, at worst it contains inaccurate information that simply detracts the conversation or results in investing time in things that are simply hallucinated. This is not helpful.

In the context of code contributions, while sloppy work is not unique to LLMs, the pace and ease in which it produces it definitely is. If I would have enough faith in LLMs to either feel I do not have to do code reviews, or I can delegate all reviews to LLMs — this could _hypothetically_ be ok. However, again, this is not my experience. The work produced by LLMs if often bad, and I have little reason to believe that an LLM code review would detect it.

This means that _I still have to review everything myself._

There is an important difference in reviewing code produced by an LLM and a human.

With humans, even in an open source setting, you build some level of relationship over time. You build trust. In the SilverBullet project I have a few people where I’ve seen their work, I know they can be trusted to do great, and I don’t have to zoom in on every single change. This is great, and is one of the reasons why a project with the scope size of SilverBullet can still be run by a single person. When I invest time in giving a detailed code review, I think _learning_ can happen. The person on the other end, or so I would like to believe, considers things they hadn’t considered before, learns more about the internal workings of SilverBullet, or potentially about how to write code in general.

This is not the case with an LLM. Sure, in “best practice” scenarios, the LLM output is first reviewed by a skilled human before being submitted for a code review. My lived experience (also observing my own behavior) is that people get lazy quickly. I have had a period in which I was more trusting of AI coding agents and let them go rampant on the SilverBullet code base. I’m still cleaning up the technical debt and mess that it produced in just a few months, _and I completely missed it at the time_. If I cannot trust _myself_ to do this — and I definitely have an incentive — how can I trust random _others_ to do this?

This means that I have to review every single line of LLM produced code and never get lazy. I have seen cases where a _comment_ produced by the LLM “documented” a certain behavior, and implemented the opposite. I’ve seen LLMs make mistakes a human could not conceivably make. I’ve seen LLMs making code more “robust” by simply littering it with null checks rather than reasoning about why those may actually happen. The level of attention I have to pay in an LLM generated pull request is _higher_ than when it is produced by a human. There is often a disorienting big discrepancy between how it _looks_ and what it actually _is_.

The ease and pace at which an AI agent produces a full pull request with meticulous sounding description and thousands of lines of code added (many of which are “tests”) is impressive. However, because of reasons listed before, now _my part starts_. I now have to go through this code for _hours or even days_ that only took _minutes_ to produce. 

This is not a fair game, and I’m not here for it. I’m sure your intention is good, but it’s often simply counter productive.

# Enforcement
I’m not the type of person that is interested in strict rules and policing. My approach is always to focus on explaining the _why_ and giving context so that people can draw their own conclusions.

If I suspect that your contribution is AI generated, I will likely close it pointing to this policy. I may get it wrong sometimes, I’m sorry about that. I’m only human, consider being the same.

# What this means for you
So, is the target audience of SilverBullet just #NoAI people, then? No. _Especially_ a tool like SilverBullet is all about self-expression: your notes, your thoughts, your system, your crazy experiments. I know that people use LLMs to write [[Space Lua]] scripts, and I’m happy for them. I know that LLMs help people do things that they could not do before, and even learn things in the process. That’s great. Open source is all about freedom. Do with it what you like, use it as you like. There is a solid [SilverBullet AI library](https://github.com/justyns/silverbullet-ai) if you’re interested.

This policy only applies to the SilverBullet code base itself and its community. It is the code that I feel I should be able to understand and own, and the conversations that I decide to spend my time on contributing to. I have to make decisions that keep the project going and sustainable long term, and excessive use of AI tools in contributions puts that at risk.

I hope that makes sense.

— [[Zef]]


================================================
FILE: website/Library Manager.md
================================================
#maturity/beta

The library manager makes it easy to manage (install, update, remove) [[Library]].

The library is accessed via the ${widgets.commandButton "Library: Manager"} command.

================================================
FILE: website/Library.md
================================================
#maturity/beta

SilverBullet is designed to be [[Extensions|extensible]]. In fact, a lot of functionality in SilverBullet is implemented through [[Space Lua]] embedded in [[Meta Page]], other functionality is implemented using [[Plugs]]. 

A big part of the fun of SilverBullet is building your own extensions, big or small. Libraries are the way to [[Share]] those extensions. Both for you to [[Library/Development|publish your own]] libraries, and to pull in other people’s creations.

# Terms
Let’s define terms:

The [[Library]] is the **unit of distribution**, typically implemented as [[Meta Page]]. Most libraries will be implemented in a single page (e.g. with a big [[Space Lua]] block), but more elaborate libraries may span multiple pages, some may wrap [[Plugs]].

A [[Repository]] is a **discovery and curation mechanism** for libraries. They are collections of _pointers_ of _where_ to find libraries to install. SilverBullet ships with the [[^Repositories/Std]] repository, but you can install additional ones.

[[Share]] is the general-purpose mechanism built into SilverBullet to both _push_, _pull_ and _sync_ content in your space with the outside world. In the context of Libraries this mechanism is used (partially under the hood, but you’ll recognize traces of it) both for you to _publish_ your libraries, as well as to _install_ and _update_ other people’s creations.

To make discovery and installation of libraries easier, SilverBullet includes a basic [[Library Manager]].

By convention, libraries are kept under the `Library/` prefix (folder) in your space.

# What’s in a library
Here are some things that a library may provide:
* Various [[Slash Command|Slash Commands]], such as `/today`, `/task`, `/table`.
* Useful [[Page Template|Page Templates]]
* Useful [[^Library/Std/Widgets/Widgets|Widgets]]
* Useful pages that help you perform maintenance on your space, like detecting broken links, such as [[^Library/Std/Pages/Maintenance]].
* Additional functionality with compiled [[Plugs]]

# Can I change libraries locally?
Libraries you manually installed can be freely changed. Do note that when you attempt to update them, your local changes may be overwritten.

The [[^Library/Std]] library comes “baked in” with SilverBullet and is read only. You cannot change it. However, for most things there are ways to override or disable standard behavior, check [[Space Lua#Load order]] for some hints.

# How do I develop my own library?
See [[Library/Development]]


================================================
FILE: website/Library/Development.md
================================================
To develop your own SilverBullet library follow these steps:

# Decorate your library with frontmatter
Put all functionality you like in a [[Meta Page|Meta Page]] somewhere under `Library/`. For name spacing purposes it’s good form to put your (Github) username in the path as well, so create, for instance `Library/myuser/My Library`.

Then, decorate your library page with some [[Frontmatter]]. Here is an minimal example library that defines a new “Hello world” command:

    ---
    name: Library/myuser/My Library
    tags: meta/library
    ---
    This implements my super awesome hello world library!
    
    ```space-lua
    command.define {
      name = "Hello world",
      run = function()
        editor.flashNotification "Hello world!"
      end
    }
    ```

> **note** Important
> The `name` key _has_ to match your page’s full (path) name. Otherwise a validation error will appear on the frontmatter. When another user installs your library, they will install it to the location specified here.

If you want to distribute additional pages or files with the library (such as `.plug.js` files) make sure that they are kept at the same folder level as your library page. For instance if your library page is `Library/myuser/My Library` you can have a `Library/myuser/myplug.plug.js` and then mark it for distribution by adding it to the `files` key in your frontmatter, e.g.:

```
---
name: Library/myuser/My Library
tags: meta/library
files:
- myplug.plug.js
---
```

# Share the library
At this stage you can put your library file anywhere you like with a URL. However, for convenience, it’s good to use [[Share]] support for publishing.

For the purposes of this example, let’s use Github. Create a [[^Library/Std/Infrastructure/Github]] token and configure it as specified in the [[^Library/Std/Infrastructure/Github#Configuration|instructions]].

[[^Library/Std/Infrastructure/Github]] share support offers two options: Gists and Github repo files. Let’s use a Github repo.

[Create a new github repo](https://github.com/new), you can name it something like `silverbullet-libraries`.

In your library to be shared, run the ${widgets.commandButton "Share: Page"} command, then select “Github file”. This will ask for a few things:
1. The repo name: enter your `user/repo` here
2. The branch name: you can keep this as `main`
3. The file name: while this defaults to using your full page, remove that and just name it e.g. `My Library.md`
4. A commit message

If all went well, an initial version of your page should now be uploaded to github, check your repo page.

You’ll notice that a few [[Share]] related frontmatter keys were set in your local copy. You can now make further changes to your library and run the ${widgets.commandButton "Share: Page"} command again. After entering another commit message your page will be pushed again. This is how you publish new versions of your library for other people to use.

If you used `files` to include additional assets, you have to commit those to the repository through other means.

Your library is now ready to install, you can test this by running ${widgets.commandButton "Library: Install"} in a fresh space. As URI use the value of what is generated under `share.uri` in your library’s frontmatter. 

You can now [broadcast that your library is ready to install](https://community.silverbullet.md/c/plugs-libraries/14)!

# Create a repository
If you develop a few libraries, it may be good to make them more discoverable. To do so, you can group them in a [[Repository|Repository]].

For this, create another page in your space, this time under `Repository/`, e.g. `Repository/myuser` and tag it with `#meta/repository`. 

In the page body, put a list of all your libraries, encoded as [[Object#data|data objects]] as follows:

    ---
    tags: meta/repository
    ---
    This is my curated list of awesome libraries!
    
    ```#meta/library/remote
    name: Library1
    description: This is my first awesome library
    website: http://url.com/to/docs
    uri: https://github.com/myuser/silverbullet-libraries/blob/main/Library1.md
    ---
    name: Library2
    description: This is my second awesome library
    uri: https://github.com/myuser/silverbullet-libraries/blob/main/Library2.md
    ```

Required keys are:
* `name`: a descriptive name of your library.
  **Note**: this name is _not_ used as an installation location, the installation location is determined by the `name` key in the library itself.
* `uri`: The URI to install the library from.

Additional recommended keys are:
* `description`: a more elaborate description of the library and its functionality
* `author`: who developed this library
* `website`: a website URL for more information

Now share this repo page using [[Share]] to the same github repo (or any other, it doesn’t really matter), and call it `REPO.md` (by convention).

Then announce to the [community](https://community.silverbullet.md/c/plugs-libraries/14) that your repo is open for business and they can add it via the ${widgets.commandButton "Library: Add Repository"} command (using the same `share.uri` set in your frontmatter).


================================================
FILE: website/Library/Website Templates.md
================================================
#meta

A few templates used on the https://silverbullet.md website.

```space-lua
templates.featureItem = template.new[==[
    * [[${name}]]: ${description}
]==]
```

================================================
FILE: website/Library/Website.md
================================================
#meta

Some silverbullet.md specific widgets etc.

```space-lua
event.listen {
  name = "hooks:renderTopWidgets",
  run = function(e)
    local meta = editor.getCurrentPageMeta()
    if not meta then
      return
    end
    local maturityTag = nil
    for _, tagName in ipairs(meta.tags) do
      if tagName:startsWith("maturity/") then
        maturityTag = tagName
      end
    end
    if maturityTag then
      return widget.new {
        markdown = spacelua.interpolate([==[
**Note:** This is a #${maturityTag} feature. Feel free to use it, but it may change (significantly) in the future or potentially be replaced.
]==], {maturityTag=maturityTag}),
        cssClasses = {"website-warning"},
        display = "block"
      }
    end
  end
}
```

```space-style
.website-warning {
  background-color: #fff1d8;
  padding: 10px;
  margin: 0px !important;
}
```


================================================
FILE: website/Link.md
================================================
In SilverBullet, you can create links to pages or documents inside your space as well as to external resources, using different link formats.

# External links
- _Markdown links_, using the `[title](URL)` syntax, for instance: [SilverBullet community](https://community.silverbullet.md). If the URL contains a space or closing parenthesis, you can enclose the URL in angled brackets.
* _"[AutoLinks](https://spec.commonmark.org/current/#autolinks)"_, like <https://community.silverbullet.md>
* _“Naked” URL links_ (AKA _"Bare URLs"_), like https://community.silverbullet.md

# Internal links
- _Relative internal links_, using the `[title](<relative specifier><link>)` format. The _relative specifier_ can be used to move up the file tree (or down, if you picture a file tree like this 🌳) relative to the page the link is on, similar to POSIX file systems `/../`. The _link_ has to conform to the [[#Link syntax (String refs)|link syntax]]
* _Absolute internal links_ (also called wikilinks) using the `[[<link>]]` syntax. Again the _link_ has to conform to the link syntax. A alias can be added like this `[[<link>|This link points to <link>]]`

# Link syntax (String refs)
The link or “string refs” has to follow some specific formatting:
* A string ref starting with `^` links to a meta page, see [[#Caret page links|caret page links]]
* The "core" (After a possible `^` and before a `@` or `#`) of a string ref is a [[Names|name]] or [[Paths|path]].
  * The core of a  string ref can also be empty, an empty path points to the current page for links, and to the index page for `editor.navigate`
* A string ref can end three ways:
  * `#` followed by a string (which can also contain `#`). This will point to the header equal to that string. (Notably you can't link to every possible header, because e.g. `]]` will restrict you inside a wikilink for example.), e.g. [[SilverBullet#Introduction]]
  - `@` followed by an upper or lowercase `L` followed by a number, optionally followed by an upper or lowercase `C` followed by a number (e.g. `@l12c13`). This will point to the corresponding line and column inside a page (both 1-based), e.g. [[CHANGELOG@L12c42]]
  - `@` followed by a number _n_. This will point to the _nth_ character in the page (0-based), e.g. [[CHANGELOG@123]]

# Caret page links
[[Meta Page]] are excluded from link auto complete in many contexts. However, you may still want to reference a meta page outside of a “meta context.” To make it easier to reference, you can use the caret syntax: `[[^Library/Std]]`. Semantically this has the same meaning as `[[LIbrary/Std]]`. The only difference is that auto complete will _only_ complete meta pages.


================================================
FILE: website/Linked Mention.md
================================================
On this site you see a section “Linked Mentions” appear along the bottom of every page that has incoming links. 👇🏻 This section lists all pages that contain a _link to_ the current page. This allows for easy navigation, and may help you discover linked concepts through these links.

More details: [[^Library/Std/Widgets/Widgets]]

================================================
FILE: website/Linked Tasks.md
================================================
It is a common SilverBullet pattern to give people, groups of people and places dedicated pages so you can easily reference them. It then becomes very convenient to mention these pages elsewhere in your space, and using [[Linked Mention]] you can easily find what _pages_ link back to that page.

The equivalent of this idea the context of [[Task]] is Linked Tasks. These are tasks that _mention_ a particular page.

# Example workflow
Let’s say you keep notes of 1-on-1 meetings with people. You have a page for each person where you keep notes for each of your sessions. As you talk to person _A_, something comes up that you need to discuss with person _B_. B _also_ has their own page. What you can do is simply create a task on your note page with _A_ (so you don’t have to context switch) that includes a [[Link|link]] to _B_.

# Example
Below are two tasks that mention this very page (this is not the general pattern, just for demonstration purposes). Note that the not completed task is listed at the top of this page as well in the “Linked Tasks” widget.

* [ ] [[Linked Tasks]] This is a task
* [x] [[Linked Tasks]] This is a completed task

**Implementation:** [[^Library/Std/Widgets/Widgets]]

================================================
FILE: website/Live Preview.md
================================================
SilverBullet uses a “live preview” markdown editor. This mechanism is heavily inspired by [Obsidian’s live preview mode](https://help.obsidian.md/Live+preview+update).

It reduces visual noise by not constantly showing [[Markdown]] codes such as `[SilverBullet website](https://silverbullet.md)`, only showing the underlying Markdown formatting code when the cursor is placed inside.

In SilverBullet, you can always see the underlying format by moving your cursor “inside.” **Alt-clicking** on any piece of formatted text will move your cursor there and reveal the underlying (markdown) code, or you can reveal it permanently with the `Toggle Markdown Syntax Rendering` command.


================================================
FILE: website/Local First.md
================================================
---
description: SilverBullet synchronizes all your content locally, allowing full access even when not connected to (reliable) Internet.
awesomeness: 7
tags: feature
---

From the original [Local First](https://www.inkandswitch.com/essay/local-first/) article:

> Local first is a set of principles for software that enables both collaboration *and* ownership for users. Local-first ideals include the ability to work offline and collaborate across multiple devices, while also improving the security, privacy, long-term preservation, and user control of data.



================================================
FILE: website/Log.md
================================================
To better understand what SilverBullet is doing, especially when [[Troubleshooting]], it is good to know where and how to look at SilverBullet logs.

Most valuable logs are likely going to be in your browser’s logs, so especially if you’re a power user, get comfortable opening your JavaScript console.

* Client logs: Check your browser’s JavaScript console. Usually these are part of “(Web) Developer Tools” buried somewhere in a Tools or Developer menu.
* Service worker logs (especially useful when debugging [[Sync]] issues):
  * In Chromium-based browsers these logs are mixed with the regular JavaScript console.
  * In Firefox start at `about:debugging#/runtime/this-firefox` then find it under the Service Worker section and click “Inspect”.
  * In Safari they can be accessed via `Developer > Service workers`.
* Server logs: Server logs are written to the standard output of the server process.
* Reverse proxy logs: if you use some sort of reverse proxy, check those as well. Reverse proxies may do some surprising stuff, like block certain requests.


================================================
FILE: website/Lua.md
================================================
[Lua](https://www.lua.org/) is the programming language that SilverBullet leverages to make it extensible. It has a custom implementation of a Lua dialect called [[Space Lua]].

================================================
FILE: website/Manual.md
================================================
Welcome to the wonderful world of SilverBullet. The goal of this manual is to give you a broad sense of how to use this tool and what it’s capable of. However, its full capabilities are yet to be discovered. You too may find new and creative ways to use the various SilverBullet features in ways nobody previously thought of.

However, that is all unlikely to happen unless you understand what SilverBullet can actually do. So let’s give you a bit of a sense.

# Videos
${embed.youtube "https://youtu.be/bb1USz_cEBY"}
${embed.youtube "https://youtu.be/7hyLvEfw34w"}
${embed.youtube "https://youtu.be/bZ79-RbyNoU"}
${embed.youtube "https://www.youtube.com/watch?v=Of7zE0AVApc"}
${embed.youtube "https://www.youtube.com/watch?v=cH9cs8fowhY"}
${embed.youtube "https://youtu.be/qkLJ3viVq8I"}

# Keeping up-to-date
* [[CHANGELOG]]: what’s new in SilverBullet? This page will give you the latest details. It’s worth monitoring this page.

# Installation and deployment
The biggest hurdle to get over with SilverBullet is that you need to get this thing running. And as of yet, the only way to do that is to install and deploy it yourself. 🤷

* [[Install]]: Installation instructions for various setups
* [[TLS]]: the part where we walk through options to expose SilverBullet via HTTPS.

For additional guides, check out [our community guides](https://community.silverbullet.md/c/guides/6).

# Core Concepts
These are the core concepts used in SilverBullet:

* [[Space]]
* [[Page]] (and [[Meta Page]])
  * [[Frontmatter]]
  * [[Names]]
* [[Folder]]
* [[Document]]

# User interface
SilverBullet’s UI is minimalist by design. Let’s look at the few UI elements and how to use them.

* The [[Top Bar]] contains:
  * [[Page Namer]]
  * [[Index Page]] (the 🏠 button)
  * [[Page Picker]]
  * [[Command Palette]]
* The main [[Editor]] component contains your currently selected page’s text

# Editing and Formatting
* Content is written using [[Markdown]] and rendered using [[Live Preview]]
  * Markdown [[Markdown/Basics]]
  * Markdown [[Markdown/Extensions]]
    * [[Link]]
    * [[Markdown/Admonition]]
    * [[Task]]
    * [[Markdown/Syntax Highlighting]]
    * [[Markdown/Fenced Code Block]]
* [[Slash Command]]
* [[Outlines]]

# Navigation
The main ways to roam your space, beside following page links, are:

* [[Page Picker]]
* [[Meta Picker]]
* [[Tag Picker]]
* [[Full Text Search]]

# Advanced topics
* [[Object]]
  * [[Frontmatter]]
  * [[Attribute]]
* [[Space Lua]]
* [[Library]]
* [[Space Style]]
* [[Troubleshooting]]

# Customization
* [[^Library/Std/Config]]
* [[Page Decorations]]
* [[Space Style]]

# Extending SilverBullet
SilverBullet’s implementation is built on three things:

* The core is implemented as a TypeScript-based web app
* A lot of core functionality is implemented in [[Plugs]].
* An increasing amount of additional functionality is now being implemented in “native” [[Space Lua]].

# Contributing

SilverBullet is free and open-source software. You can contribute to
it [via Github](https://github.com/silverbulletmd/silverbullet). For some details on how to do this, have a look
at [[Development]].


================================================
FILE: website/Markdown.md
================================================
Markdown is a plain text formatting system [originally developed by John Gruber](https://daringfireball.net/projects/markdown/). It has since been standardized into [CommonMark](https://commonmark.org/), which is what SilverBullet uses (with [[Markdown/Extensions]]). While a bit more technical than [WYSIWYG](https://pl.wikipedia.org/wiki/WYSIWYG)-style editing (like MS Word), the nice thing about markdown is that it is a relatively easy-to-learn format, and you can read files even without special tools (like SilverBullet). 

This means that _you will always have access to your content_ even if you switch tools. It also means that you can use multiple tools at the same time to edit these files. You don’t have to use SilverBullet exclusively.

There is a bit of a caveat here: Markdown is limited in certain ways, and various tools using markdown as the underlying file format (like SilverBullet) need features that are not directly supported by markdown. As a result, different tools introduce extensions to markdown that are not standard nor interoperable. While in some cases, these tools converge on particular pieces of syntax (such as the non-standard `[[page link]]` syntax), some level of divergence in the markdown that each tool supports is unavoidable. SilverBullet is complicit in this as well. It adds a few extensions that are not widely supported and assigns new meaning to certain markdown features to implement novel features. In its defense, all these features are optional. If you want to just write plain markdown, you can.

See [[Markdown/Extensions]] for more details on these SilverBullet-specific extensions.

More about markdown:

* [[Markdown/Basics]]: learn some the markdown basics
* [[Markdown/Extensions]]: learn about SilverBullet’s set of markdown extensions
* [[Markdown/Syntax Highlighting]]: languages that SilverBullet supports syntax highlighting for


================================================
FILE: website/Markdown/Admonition.md
================================================
#level/intermediate

Silverbullet supports [admonitions](https://github.com/community/community/discussions/16925) using GitHub syntax (`note` and `warning`).

> **note** This is a
> note admonition

> **warning** This is a
> warning admonition

> **danger** This is a
> danger admonition

> **success** This is a
> success admonition

Custom admonitions can be added in a [[Space Style]] using the following format:

```space-style
/* Replace the keyword with a word or phrase of your choice */
.sb-admonition[admonition="keyword" i] {
  .sb-admonition-type * { display: none; }
  .sb-admonition-type::before { 
    width: var(--admonition-width) !important;
  }

    /* The icon can be a link or an embedded image like shown here */
  --admonition-icon: url('data:image/svg+xml,<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><g id="SVGRepo_bgCarrier" stroke-width="0"></g><g id="SVGRepo_tracerCarrier" stroke-linecap="round" stroke-linejoin="round"></g><g id="SVGRepo_iconCarrier"><path d="M19.5 12L14.5 17M19.5 12L14.5 7M19.5 12L9.5 12C7.83333 12 4.5 11 4.5 7" stroke="%231C274C" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></g></svg>'); 
  /* The accent color */
  --admonition-color: green;
}
```


================================================
FILE: website/Markdown/Basics.md
================================================
The idea of markdown is that you write plain text with some additional markup that even without further processing (like rendering it to HTML, or [[Live Preview]]) you could just read and understand. It was inspired by conventions used in plain-text e-mails, before e-mail supported rich formatting.

# Basic markup
To write markdown, you just write text. But then to emphasize something you can add `_underscores_` around a phrase to make look _italic_, or `**asterisks**` to make it **bold**. You can also use `~~tildes~~` for ~~strikethrough~~ and `==double equals==` for ==highlighting==.

# Links
To add external links you use the `[site link](https://silverbullet.md)` syntax, which will appear as [site link](https://silverbullet.md). If you want to link to other pages in your space you use the `[[wiki link syntax]]`, e.g. [[SilverBullet]]. To change the link text you can use the `[[SilverBullet|best PKM evah]]` syntax: [[SilverBullet|best PKM evah]].

# Lists and tasks
You can create three types of lists:

Unordered lists are created by prefixing a line with `*` or `-`. For instance:

* This is an unordered list
* And this is a second item

Since this tool is called SilverBullet, we prefer you to use the `*` bullet (which will even appear in _silver_ — clever huh?).

Ordered lists are created by simply putting a number followed by a period at the beginning of a line:

1. This is the first item
2. This is the second item

SilverBullet also supports a variant of the unordered list item to define task. Tasks are defined using the `* [ ] Task name` syntax:

* [ ] This is a task
* [ ] And this is another

When you click the checkbox, it will toggle its state and replace the ` ` inside the box with `x`. SilverBullet also supports custom task statuses by putting text in between `[` and `]`. When you click on such custom task states, it will cycle through all the task states it’s seen in your space:

* [IN PROGRESS] This task is in progress
* [DONE] This task is done
* [TO DO] This task is still to be done

# Headers
Markdown supports various levels of headings, which generally are created by prefixing a line with one or more `#`. The more `#`‘s the deeper the header nesting. 

# Quotes
You can use block quotes by prefixing lines with `>`:

> “If you don’t know where you’re going, you may not get there.”
> — Yogi Berra

# Code
For the programmers among us, there are three ways to mark up code. If you want to write some code inline, you can use backticks: `this is code`. For long code snippets, you can either use a four-space indent:

    This is code
    And another line

or (preferably) the triple-back tick notation, which also allows you to (optionally) specify a coding language:

```javascript
function hello() {
   return "sup";
}
```

SilverBullet supports [[Markdown/Syntax Highlighting]] for many languages out of the box.



================================================
FILE: website/Markdown/Extensions.md
================================================
In addition to supporting [[Markdown/Basics|markdown basics]] as standardized by [CommonMark](https://commonmark.org/), SilverBullet relies on the following extensions:

* [[Link]]
* [[Frontmatter]] and [[Attribute]]
* [[Markdown/Fenced Code Block]]
* [[Transclusions]] syntax
* [[Markdown/Admonition]]
* [[Markdown/Hashtags]]
* [[Space Lua]] expressions
* [Tables](https://www.markdownguide.org/extended-syntax/#tables)
* [Task lists](https://www.markdownguide.org/extended-syntax/#task-lists)
* [Highlight](https://www.markdownguide.org/extended-syntax/#highlight)
* [Automatic URL linking](https://www.markdownguide.org/extended-syntax/#automatic-url-linking)
* [Subscript](https://www.markdownguide.org/extended-syntax/#subscript) and [superscript](https://www.markdownguide.org/extended-syntax/#superscript)


================================================
FILE: website/Markdown/Fenced Code Block.md
================================================
Blocks use the fenced code block notation of [[Markdown]], and assign special behavior to it.

The general syntax is:

    ```language
    block configuration
    ```

These are the block types that ship with SilverBullet, but [[Plugs]] can define their own:

* `space-lua`: [[Space Lua]]
* `space-style`: [[Space Style]]

The fenced code block syntax is also used to get [[Markdown/Syntax Highlighting]] for numerous programming languages.

================================================
FILE: website/Markdown/Hashtags.md
================================================
Hashtag syntax (`#my-tag`) can be used to explicitly assign a [[Tag]] to an [[Object]].

# Scope rules
* If a hashtag is the only content in a paragraph (at most with additional hashtags), it applies to the **page**.
* If a hashtag is used inside of an item (bullet point, or ordered list), it applies to that **item**.
* If a hashtag is used inside of a [[Task]], it applies to that **task**.
* If a hashtag is used inside of a paragraph, they apply to the **paragraph**.
* If you create a [[Markdown/Fenced Code Block]] with a hashtag as language, it applies the tag to that [[Object/data]].

## Examples
#page-tag-example

Paragraph #paragraph-tag-example

* Item #item-tag-example
* [ ] Task #task-tag-example

```#data-example
name: Hank
```

# Naming rules
Hashtags can contain letters, dashes, underscores and other characters, but not:
- Whitespace (space, newline, etc.)
- Characters from this list: `!@#$%^&*(),.?":{}|<>\`
- Digits only (e.g. #123)

If you need your tags to contain these characters, you have to surround the tag content with angle brackets like this: #<my tag>


================================================
FILE: website/Markdown/Syntax Highlighting.md
================================================
SilverBullet comes with syntax highlighters for various programming languages. This page demonstrates some:

```yaml
name: Pete
```

```javascript
function myFunc() {
   console.log("Hello World!");
}
```

```typescript
function myFunc(name: string) {
   console.log("Hello", name);
}
```

```sql
select * from my_table;
```

```c++
class MyClass {

}
```

```rust
fn main() {
    // Print text to the console
    println!("Hello World!");
}
```

```swift
func myFunc() {
   print("Hello World!")
}
```

```xml
<MyTag attribute="cool">

</MyTag>
```

```html
<b>Regular HTML</b>
```

```kotlin
class MyClass {


}
```

```dart
void main() {
  print('Hello, World!');
}
```

```c
void main() {

}
```

```ruby
class BankAccount
  attr_accessor :balance, :account_holder
end
```


================================================
FILE: website/Meta Page.md
================================================
Meta pages are pages not core to your content, but function as a way to configure, or extend your [[Space]]. You can think of them as “tooling” pages. 

The most obvious examples are part of SilverBullet’s [[^Library/Std]] library and include generally useful [[Library/Std/Infrastructure/Slash Templates]]. 

If you use [[Space Lua]] or [[Space Style]], you may want to put those on separate pages and tag them as `#meta` as well, so they don’t mingle with your main content.

# How are meta pages identified?
Meta pages at a technical level are [[Page]] like any other, the only technical difference is that they are tagged with any tag _starting_ with `#meta`. This is picked up by the [[Page Picker]] and [[Meta Picker]].

# How do you link to meta pages?
You can link to a meta page like any other page, that is using the `[[page name]]` syntax. However, in the context of regular content, meta pages will not appear in auto complete. To get auto completion for meta pages, you can use the `[[^page name]]` caret syntax. More information: [[Link#Caret page links]].


================================================
FILE: website/Meta Picker.md
================================================
The meta picker is used to navigate between [[Meta Page]]. Functionally it’s equivalent to the [[Page Picker]] except that it only shows pages tagged with a `meta` tag prefix.


================================================
FILE: website/Metadata.md
================================================
Metadata is data about data. Most [[Object]] have a set of default attributes that can be augmented in a few additional ways:

* [[Tag]]: to tag the object (and add to the `tags` attribute directly)
* [[Frontmatter]]: at the top of [[Page]], a [[YAML]] encoded block can be used to define additional attributes to a page
* [[Attribute]] syntax


================================================
FILE: website/Migrate from v1.md
================================================
SilverBullet v2 removed a slew of features that were still present in the 0.x series (also known as “v1”). Sadly, we just had to rip the bandaid on this one.

Here are some pointers on what was removed and how to adapt.

# Queries
v2 does not have support for old-style [queries](https://v1.silverbullet.md/Query%20Language) (live queries) anymore. They have been replaced with [[Space Lua/Lua Integrated Query]]. Give the linked page a read, but generally there’s a few differences:

1. Lua Integrated Queries tend to start with `from index.tag "tag-name"` instead of plain `tag-name`. This is a bit longer, but since whatever comes after `from` is a Lua expression, you can not just query [[Object]], you can query any Lua table as well in the same way.
2. In the old query language, you access attribute simply by their name, this works in LIQ too, but stylistically it’s nicer to use either `_.attribute`, or to give the object you’re iterating over a name, using `from page = index.tag "page"`, for instance.
3. The `=` equals operator is `==` in Lua 😄

# Templates
In v1 there were various types of templates:

* [Live Templates](https://v1.silverbullet.md/Live%20Templates) (in `template` blocks)
* [Live Template Widgets](https://v1.silverbullet.md/Live%20Template%20Widgets) (using special [[Frontmatter]]-based hooks)
* [Snippets](https://v1.silverbullet.md/Snippets) (using special [[Frontmatter]]-based hooks)
* [Page Templates](https://v1.silverbullet.md/Page%20Templates) (using special [[Frontmatter]]-based hooks)

_Live templates_ are now better expressed by simply putting `${lua expressions}` in text. Iterating and rendering queries using templates is generally done using the `${template.each(query[[...]], someTemplateExpression)}` pattern.

_Live Template Widgets_ can now be implemented using an event listener:
```lua
event.listen {
  name = "hooks:renderTopWidgets", -- or hooks:renderBottomWidgets
  run = function(e)
    return widget.new {
      markdown = "Showing up at the top"
    }
  end
}
```

See [[^Library/Std/Widgets/Widgets]] for some examples.

_Snippets_ can be implemented as follows: [[Library/Std/Infrastructure/Slash Templates]].

_Page templates_ now work slightly differently: [[^Library/Std/Infrastructure/Page Templates]].

# Plugs
Many existing plugs _should_ keep working, but some may need updating.
However, since Space Config is removed. You now specify the plugs you like to use with Space Lua + the [[API/config]] API:

```lua
config.set("plugs", {
  "github:joekrill/silverbullet-treeview/treeview.plug.js"
})
```

After which you run the `Plugs: Update` to download and/or update them. `Plugs: Add` has not been (re)implemented, so you have to do this in code.

# Other changes
* Since no database is kept on the server, you can safely delete any `.silverbullet.db` file from your space — this isn’t used anymore.
* Since space config is no longer a thing, `SETTINGS`  is now gone. Instead the [[CONFIG]] page is now the preferred place to configure things. However, this is just a convention. You can use the `config.set` [[Space Lua]] API in any [[Space Lua]] block.

# Removed features
Certain features have been removed:

* Online mode: v2 is sync mode only, meaning all your content will be synced into your browser. A simpler Online mode may be added back later. If you like to wipe your local content at any time, use the `System: Wipe Client` command.
* [Federation](https://v1.silverbullet.md/Federation)
* [Share](https://v1.silverbullet.md/Plugs/Share) replaced by [[^Library/Std/Infrastructure/Export]]
* Space Script: replaced by [[Space Lua]]
* Space Config: replaced by the [[API/config]] APIs.
* Command link syntax (`{[...]})`) replaced by the `${widgets.commandButton(...)}` API.


================================================
FILE: website/Names.md
================================================
In [[SilverBullet]] every [[Page|page]] or [[Document|document]] has a name. Names are (currently) unique, meaning no two pages or documents can share the same name.

# Rules
Names _must_ also follow certain rules:
- Names cannot be empty
- Names cannot start with a `.`, `^` nor `/`
* Names cannot contain the characters `|`, `@` or `#`
* Names cannot contain the sequences `[[` or `]]`
* Names cannot contain one or two `.` enclosed by a combination of the start/end of the name or `/`
* Names cannot contain `//`
* Names cannot end in `.md` (See [[Paths#Relation to names]] section)
- (Names are case-sensitive and contrary to most filesystem, `/` is allowed)

## Valid Examples
- “foo”
- “this/is/a/page/name”
- “this/.../is/also/a/page”
- “this/is/a/document.png”

## Invalid Examples
- “foo@bar”
- “.foo”
- “foo//bar”
- “foo/../bar”

# Special characters
Certain HTTP reverse proxies may block “suspicious” characters (such as `?`, `#` and `;`) by default, including Traefik, [see this thread](https://community.silverbullet.md/t/traefik-proxied-setups-block-page-names-with-fix/3724/2) on how to work around this.


================================================
FILE: website/Object Index.md
================================================
The Object Index stores [[Object|Objects]] found in your [[Space]].

You interact with it in a few ways:

* Indirectly via various other SilverBullet features such as [[Page Picker]], [[Meta Picker]], [[Linked Mention]] etc.
* Via the [[API/index]] API directly

# Indexing
## Initial indexing process
When you launch a fresh client for the first time, the object index will be built from scratch. Depending on the size of your space this can take anything between a few seconds and minutes. If the process takes longer than a few seconds, you will see progress with a blue status circle. Until this initial indexing process finishes, you will notice that things like [[API/widget|Widgets]] and [[Space Lua/Lua Integrated Query]] are not yet rendered, this is to avoid errors and invalid data.

After the initial index process, the index will be kept up-to-date incrementally.

To forcefully reindex your space, run the `Space: Reindex` command.

## Indexing process
Objects are stored in your browser’s IndexedDB, implemented as a thin abstraction layer on top of [[API/datastore]]. Objects are always attached to a particular page.

When SilverBullet detects a page change (either via the editor or based on watching the file system for changes), it will queue reindexing the page.

This process follows the following steps:
1. Remove all existing page-connected objects from the database.
2. Parse the markdown, and broadcast it via the `page:index` event.
3. Various indexers do their magic, resulting in a list of found objects.
4. These objects are stored via the [[API/index#index.indexObjects(page, objects)]] API.

The indexObject API looks at the `tags` of the found objects, and for each tag:

* Looks up the tag spec (see [[API/tag#tag.define(spec)]])
* If the tag spec defines a `schema` and `mustValidate` is set to true, it will validate the object against the schema and only index the object if it passes. If `mustValidate` is not set (the default for performance reasons), no validation will happen at the indexing stage.
* If the tag spec defines a `transform` callback, it is invoked, see [[API/tag#Index augmentation]] for details.
* Stores the resulting objects with for the given tag

# Query
The Object Index is generally queried using [[Space Lua/Lua Integrated Query]]. 

Entry points are:

* [[API/index#index.tag(name)]], e.g.: `index.tag "page"`
* `tags.*`: as a convenience — `tags.page` is equivalent to `index.tag "page"`

If a `metatable` is defined for a particular tag with [[API/tag#tag.define(spec)]], the metatable is set for each object for the tag.

================================================
FILE: website/Object.md
================================================
SilverBullet automatically maintains an [[Object Index]] extracted from all [[Markdown]] [[Page|pages]] in your [[Space|Space]].

Objects are a feature that powers a lot of SilverBullet functionality, including the [[Page Picker]], [[Linked Mention|Linked Mentions]] and many others. They can also be queried by the user directly, typically via [[Space Lua/Lua Integrated Query]].

# Terminology
* [[Object]]: represent _things_ in your space at various level of granularity. Examples include [[Object/page]] at the highest level, but also more granular things like [[Object/task]] and [[Object/link]]. In relational database parlance, you can think of Objects as **database rows**.
* [[Tag]]: represent Object **types** or **tables** (in relational database parlance). Every Object has at least one tag, but can have additional tags attached explicitly, usually through the [[Markdown/Hashtags]] syntax. 

# Principles
**Markdown always is the source of truth**: all data indexed as objects will have some representation in markdown text as well. 

As a result, the [[Object Index]] can be flushed at any time and be rebuilt from its source markdown files kept in your space (and you can do so on demand using the `Space: Reindex` command).

# Object representation
Every object has a set of [[Attribute|Attributes]], some predefined, but you can add additional custom attributes.

The following attributes are predefined, you can expect all objects to have them:
* `ref`: a globally unique _identifier_, often represented as a pointer to the place (page, position) in your space where the object is defined. For instance, a _page_ object will use the page name as its `ref` attribute, and a `task` will use `page@pos` (where `pos` is the location the task appears in `page`).
* `tag`: the main type, or “tag” of the object.

In addition, most objects will also contain:
* `tags`: an optional set of additional, explicitly assigned tags.
* `itags`: a set of _implicit_ or _inherited_ tags: including the object’s `tag`, `tags` as well as any tags _assigned to its containing page_.

Beside these, any number of additional tag-specific and custom [[Attribute|Attributes]] can be defined. It is also possible to restrict this set of attributes via [[Schema]].


================================================
FILE: website/Object/aspiring-page.md
================================================
[[Aspiring Pages]] (codified using the `aspiring-page` tag) are pages that are linked to, but not yet created. [[Like this one]].

Internally SilverBullet uses these in [[^Library/Std/Pages/Maintenance]], page complete and the page picker (to hint at pages to still be created).

Example query (lists all aspiring pages in this space, which should just be the one created at the top of this page):

${query[[from index.tag "aspiring-page"]]}

================================================
FILE: website/Object/data.md
================================================
You can also embed arbitrary YAML data blocks in pages via fenced code blocks and use a tag as a coding language, e.g.

```#contact
name: Pete
age: 55
```

Which then becomes queryable via the `contact` tag:
${query[[from index.tag "contact"]]}


================================================
FILE: website/Object/header.md
================================================
Headers (lines starting with `#`, `##` etc.) are indexed as well and queryable via the `header` tag.

${query[[from index.tag "header" where page == _CTX.currentPage.name limit 3]]}


================================================
FILE: website/Object/item.md
================================================
List items (both bullet point and numbered items) are indexed with the `item` tag, additional tags can be added using [[Tag]].

Here is an example of a #quote item using a custom [[Attribute|attribute]]:

* “If you don’t know where you’re going you may not get there.” [by: Yogi Berra] #quote

And then queried via the #quote tag:
${query[[
  from index.tag "quote"
  where table.includes(_.itags, "item")
]]}

Additional attributes:

* `parent` will contain a ref to the item’s direct parent `item` if any
* `links` will contain a list of all the wiki-style [[Link|links]] in the item
* `ilinks` (inherited links) will contain a list of all the wiki-style links in the item _and its parents_.
* `itags` will also inherit their ancestors’ tags

Example query showing all attributes of items on this page:
${query[[from index.tag "item" where _.page == editor.getCurrentPage()]]}


================================================
FILE: website/Object/link.md
================================================
All page _links_ are tagged with `link`. You cannot attach additional tags to links. The main two attributes of a link are:

* `toPage` the page the link is linking _to_
* `page` the page the link appears on

In addition, the `snippet` attribute attempts to capture a little bit of context on where the link appears.

Here is a query that shows some links that appear in the [[Object]] page:

${query[[from index.tag "link" where page == "Object"]]}


================================================
FILE: website/Object/page.md
================================================
Every page in your space is available via the `page` tag. You can attach _additional_ tags to a page, by either specifying them in the `tags` attribute [[Frontmatter]], or by putting additional [[Tag|tags]] in a standalone paragraph with no other (textual) content in them.

In addition to `ref` and `tags`, the `page` tag defines a bunch of additional attributes as can be seen in this example query:

${query[[from index.tag "page" where name == _CTX.currentPage.name]]}


================================================
FILE: website/Object/paragraph.md
================================================
Top-level paragraphs (that is: paragraphs not embedded in a list) are indexed using the `paragraph` tag, any additional tags can be added using [[Tag]].

By default, paragraphs are only indexed when they contain a tag. However, you can enable indexing _all_ paragraphs by adding the following to your [[CONFIG]]:

```lua
config.set("index.paragraph.all", true)
```

Example query, querying a paragraph with a #paragraph-tag:

${query[[from index.tag "paragraph-tag"]]}


================================================
FILE: website/Object/space-lua.md
================================================
Every `space-lua` block across your space is indexed with the `space-lua` tag. Upon client boot (or after using `System: Reload`), all space lua scripts are executed in sequence.

================================================
FILE: website/Object/table.md
================================================
Markdown table rows are indexed using the `table` tag, any additional tags can be added using [[Tag]] in any of its cells.

| Title | Description Text |
| --- | ----- |
| This is some key | The value contains a #table-tag |
| Some Row | This is an example row in between two others |
| Another key | This time without a tag |

${query[[from index.tag "table" where page == _CTX.currentPage.name]]}

Table headers will be normalized by converting them to lowercase and replacing all non alphanumeric characters with `_`.

================================================
FILE: website/Object/tag.md
================================================
The ultimate meta tag is `tag` itself, which indexes for all tags used, in which page they appear and what their “parent tag” is (the context of the tag: either `page`, `item` or `task`).

Here are the tags used/defined in this space:
${query[[from index.tag "tag" select {name=name, parent=parent}]]}


================================================
FILE: website/Object/task.md
================================================
Every task in your space is tagged with the `task` tag by default. You tag it with additional tags by using [[Tag]] in the task name, e.g.

* [ ] My task #upnext 

And can then be queried via either `task` or `upnext`. 

The following query shows all attributes available for tasks:
${query[[from index.tag "upnext"]]}

Although you may want to render it using a template instead:
${template.each(query[[from index.tag "upnext"]], templates.taskItem)}

Specific attributes:
* `done`: set to true of the state of the task is “checked”
* `state`: the text of the state (useful for custom states), see [[^Library/Std/APIs/Task State]]

Additionally, tasks inherit all attributes of [[Object/item]].

================================================
FILE: website/Open Source.md
================================================
---
description: SilverBullet is MIT licensed, open source software.
awesomeness: 10
tags: feature
---

SilverBullet is [MIT licensed](https://github.com/silverbulletmd/silverbullet/blob/main/LICENSE.md), open source software. This license allows you to basically use it however you like, for whatever purpose. No restrictions. Enjoy.

However [[Funding]]

================================================
FILE: website/Outlines.md
================================================
While SilverBullet is not a “proper” outliner like e.g. [LogSeq](https://logseq.com), it does offer a useful commands to manage outlines.

An outline is simply a (nested) bulleted list, for instance:

* Introduction
  * Context
  * Problem space
  * Solution space
* Problem statement
  * Problem A
    * Sub problem 1
    * Sub problem 2
  * Problem B
  * Problem C
* Related work
  * Area A
  * Area B
  * Area C
* Solution
* Conclusion

# Commands
SilverBullet offers a number of `Outline` commands to make manipulating such outlines easier, they are (note that on Mac `Mod` binds to `Cmd`, on Linux and Windows it’s `Ctrl`):

* `Outline: Move Up` (`Alt-ArrowUp`): moves the current item and its children up
* `Outline: Move Down` (`Alt-ArrowDown`): moves the current item and its children down
* `Outline: Move Right` (`Mod-. l`): indents the current item and its children one level deeper
* `Outline: Move Left` (`Mod-. h`): outdents the current item and its children one level higher


And for folding outlines:

* `Outline: Fold`: folds the current item’s children
* `Outline: Unfold`: unfolds the current item’s children
* `Outline: Toggle Fold` (`Mod-. Mod-.`): toggles the current item’s fold state
* `Outline: Fold All`: folds all sections in the entire page
* `Outline: Unfold All`: unfolds all sections in the entire page


================================================
FILE: website/PWA.md
================================================
SilverBullet is developed as a [Progressive Web App](https://web.dev/progressive-web-apps/), a web application that is fully offline capable.

After your first load and sync, (practically) all functionality is available even without a network connection.

================================================
FILE: website/Page Decorations.md
================================================
---
tags: maturity/beta
pageDecoration:
  prefix: "🎄 "
  disableTOC: true
  cssClasses:
  - christmas-decoration
---
Page decorations allow you to “decorate” pages in various fun ways.
 
# Supported decorations
* `prefix`: A (visual) string prefix (often an emoji) to add to all page names. This prefix will appear in the top bar as well as in (live preview) links to this page. For example, the name of this page is actually “Page Decorations”, but when you link to it, you’ll see it’s prefixed with a 🎄: [[Page Decorations]]
* `cssClasses`: (list of strings) Attaches one or more CSS classes the page's `<body>` tag, wiki links, auto complete items and [[Page Picker]] entries for more advanced styling through a [[Space Style]] (see [[#Use case: pimp my page]] for an example).
* `disableTOC` (not technically built-in, but a feature of the [[^Library/Std/Widgets/Widgets|Table of Content]] widget): disable the TOC for this particular page.

# Apply with [[Frontmatter]]
This is demonstrated in the [[Frontmatter]] at the top of this page, by using the special `pageDecoration` attribute. This is how we get the fancy tree (🎄) in front of the page name. Sweet.

## Use case: pimp my page
Let’s say you feel you want to pimp up a specific page with some Christmas-level decoration, and prefixing it with a tree just doesn’t do it for you. The `cssClasses` decoration is saying: hold my beer. 

Note that in this page’s [[Frontmatter]] the `christmas-decoration` class is attached via `pageDecoration.cssClasses`. Now let’s hook into that with some [[Space Style]]:

```space-style

/* Style page links */
a.christmas-decoration {
  background-color: #b4e46e;
}

/* Style main editor components */
body.christmas-decoration #sb-top {
  background-color: #b4e46e;
}

/* Style auto complete items */
.cm-tooltip-autocomplete li.christmas-decoration {
  background-color: #b4e46e;
}

/* Style page picker item */
.sb-result-list .sb-option.christmas-decoration {
  background-color: #b4e46e;  
}
```

And _boom_! Ain’t that pretty?


================================================
FILE: website/Page Namer.md
================================================
At the header of each page, you see the currently open page name. Let’s call this user interface element the “Page Namer.” This element of the user interface serves a few roles:

* It indicates the **currently open name** (duh)
* Its font color indicates the current **page status**:
  * Black (in light mode) indicates the page is currently saved
  * Gray (in light mode) indicates the page is currently not saved
* By changing the page name and hitting `Enter` (or clicking/tapping outside of it) you **rename** the [[Page|page]], automatically updating all references to it as well.

================================================
FILE: website/Page Picker.md
================================================
The page picker has two main functions:

1. Enables quick navigation between [[Page]]
2. Enables creation of new pages
   * Based on the entered name
   * Based on [[Link|linked]] to, but not yet created page names

The page picker can be invoked by clicking the 📔 icon in the top bar, or by pressing `Cmd-k` on Mac, or `Ctrl-k` on Windows/Linux.

The main input is the **filter phrase** and can be used to narrow down the list of page results.

If the filter phrase contains `#tags` the results will be filtered based on matching those tags.

> **note** Note
> Any page tagged with a tag starting with `#meta`, even though technically regular pages, will **not** appear in the page picker. To navigate to them, use the [[Meta Picker]] instead.

> **note** Pro-tip
> To cycle between the three pickers [[Page Picker]], [[Meta Picker]] and [[Anything Picker]], type `^` in the filter phrase box.

Pressing the `Enter` key will open/create the selected page.
Pressing `Shift-Enter` will always open or create (if it doesn't already exist) the page _exactly matching_ the filter phrase.

Therefore, if you _intend to create a new page_, simply type the name of the new page and hit `Shift-Enter`.

# Result ordering
When no filter phrase is entered, pages are ordered by either _last opened_, or _last modified_ date in descending order. This makes it convenient to switch between recently edited pages.

When entering a filter phrase, the best matches should appear closer to the top, however the second option will always be an option to create a new page with _exactly_ the page name entered as the filter phrase.

# Keyboard shortcuts
* `Enter`: selects the highlighted page from the list and navigate there. If that page is marked with “Create page” it will create that page.
* `Shift-Enter`: navigate to the page entered in as the filter phrase.
* `Space`: with an empty filter phrase will attempt to do something intelligent:
  * If the currently opened page is nested in a [[Folder|folder]], it will auto complete the current folder name in its place.
  * If the currently opened page name starts with an emoji, it will complete that emoji in its place.
  * Otherwise, it will complete the full page name of the currently open page.
* `ArrowUp`/`ArrowDown`: move up and down the highlighted page list
* `PageUp`/`PageDown`: move up and down 5 entries in the page list in one go
* `Home`: moves to the start of the list
* `End`: moves to the end of the list
* `Escape`: closes the page picker UI
* Typing `^` when filter phrase is empty will cycle to the next picker, first [[Meta Picker]], then [[Anything Picker]]

# Mouse/touch operation
You can scroll and select an item from the list by clicking with the mouse, as well as close the page picker by clicking outside of it.



================================================
FILE: website/Page Template.md
================================================
Page templates can be used to automate or simplify the creation of pages based on a template.

# Creating a page template
To create a page template, create a page and tag it with `#meta/template/page`.

The last component of the page name will be used as the template name.

## Cursor placement
In the template’s body text, you can optionally use `|^|` as a placeholder for where the cursor should be after creating the page.

## Configuration
Optional keys you can set in the page’s frontmatter:

* `suggestedName`: the proposed name for the new page, can use embedded Lua expressions, like `Daily/${date.today()}`.
* `confirmName`: Confirm the suggested page name before creating it (defaults to `true`).
* `openIfExists`: If a page with the `suggestedName` already exists, open it rather than attempting to create it anew.
* `command`: expose the page template as a command with this name.
* `key`: Bind the snippet to a keyboard shortcut (note: this requires to _also_ specify the `command` configuration).
* `mac`: Bind the snippet to a Mac-specific keyboard shortcut.
* `frontmatter`: Frontmatter (encoded as a string) to set in the resulting page.
* `priority`: Similar to how space lua scripts are loaded, this controls the order in which page template _commands_ are
  created (see "overriding page templates" below)

## Example: Daily note
The following creates a page template that can be run using the `Journal: Daily Note` command, it automatically creates `Daily/today’s date` if it does not already exist with a bulleted list, putting the cursor at the first bullet. If the page already exists, it navigates there.

~~~
---
command: "Journal: Daily Note"
suggestedName: "Daily/${date.today()}"
confirmName: false
openIfExists: true
tags: meta/template/page
---
* |^|
~~~

# Currently active page templates

${template.each(query[[
  from index.tag "meta/template/page"
  where _.tag == "page"
]], templates.fullPageItem)}

# Instantiating page templates

You can create a page based on a page template via the ${widgets.commandButton("Page: From Template")} command, or via the command name that you defined in your template’s frontmatter.

# Overriding page templates

If you would like to override an existing page template (for instance the Quick Note) template with your own, you can take advantage of the load order determined by the `priority` frontmatter. Built in page templates will have a priority set that is higher than the default. Therefore, their commands and keybindings will be set early. Therefore, by simply defining your own version of the page template _with the same command name_ will let that version override the versions that are built in.

## Example: overriding the Quick Note template

~~~
---
command: Quick Note
key: "Ctrl-q q"
suggestedName: "Quick notes/${os.date('%Y-%m-%d/%H-%M-%S')}"
confirmName: false
tags: meta/template/page
---
This is my quick note version
~~~


================================================
FILE: website/Page.md
================================================
The primary thing you’ll be doing in SilverBullet (hopefully) is creating and editing pages. Pages are written in [[Markdown]], can contain [[Metadata]] and are kept on the server as text files.

Page _names_ are relatively free-form, but have to adhere to a few [[Names#Rules]].

# Types of pages
Your [[Space]] will contain two types of pages:

* **Content pages:** this is the core of your space — your notes. You navigate between these using the [[Page Picker]].
* [[Meta Page]] these are pages that contain implementation details _about_ your space (they are “meta”). A lot of these will be part of [[Library]]. You navigate between these using the [[Meta Picker]].


================================================
FILE: website/Paths.md
================================================
Every document or page in SilverBullet is identified by a path. The path represents the location where the corresponding page or document is stored inside your space folder. Paths are always absolute and always have to end in an [[#Extensions|extension]].

# Extensions
A valid extension is a `.` followed by one or more digits or letters, followed by the end of the string.

# Relation to names
The name of a page or document is very closely related to it’s path, specifically you get from the name to the path by adding `.md` to names not ending in a valid extension.
Inversely you can get the name from a path by removing any `.md` extension, which may exist (This is also the reason, why names cannot end in `.md`).
This entails that paths have to conform to the same [[Names#Rules|rules]] names have to.

# Valid Examples
- “foo.md”
- “foo.png”
- “bar.tar.gz”
- “bar.foo bar.baz”
- “this/is/a/path.md”

# Invalid Examples
- “foo”
- “/foo”
- “.foo”
- “foo@bar”
- “foo.md.md”

================================================
FILE: website/Person/John.md
================================================
---
firstName: John
lastName: Doe
location: America
age: 23
tags: person
---
John Doe is a engineer living in the US.

# Notable achievements
* None

================================================
FILE: website/Person/Zef.md
================================================
---
firstName: Zef
lastName: Hemel
location: Poland
tags: person
---

Zef Hemel is a Dutch engineer living in Poland.

# Notable achievements
* Created [[SilverBullet]]
* That’s about it


================================================
FILE: website/Personal.md
================================================
SilverBullet is designed to be a single-user application. That user is _you_. 

It’s a place for you to express yourself. To keep notes, structure ideas, customize, extend and do whatever you like in the [[Private]] of your own [[Space]].


================================================
FILE: website/Platform.md
================================================
While SilverBullet at its core is a notes application, it is [[Architecture|architected]] to be capable of doing much more than that. 

Many people use it effectively as a personal application platform and build crazy stuff on it. Have a look at the [community forums](https://community.silverbullet.md) to get a feel.

More information on SilverBullet’s technical [[Architecture]].

================================================
FILE: website/Plugs.md
================================================
SilverBullet at its core is bare bones in terms of functionality, most of its power it gains from **plugs** and [[Space Lua]].

Plugs are an extension mechanism that runs “plug” code in the browser using [web workers](https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Using_web_workers).

Plugs can hook into SB in various ways:
* Define new commands and keybindings
* Respond to various events triggered either on the server or client-side
* Run recurring and background tasks.
* Define their own extension mechanisms through custom events

Each plug runs in its own _sandboxed environment_ and communicates with SB via _syscalls_ that expose a vast range of functionality. Plugs can be loaded, unloaded, and updated without having to restart SilverBullet itself.

Plugs are distributed as self-contained JavaScript bundles (ending with `.plug.js`). SilverBullet will load all core plugs bundled with SB itself (listed below), as well as any additional plugs stored in the `_plug` folder in your [[Space|space]].

# Available plugs
The [SilverBullet ‘Plugs’ category has a list of third-party plugs you can try](https://community.silverbullet.md/c/plugs/14).

* [SilverSearch](https://github.com/MrMugame/silversearch): implements a better full text search
* [TreeView](https://github.com/joekrill/silverbullet-treeview): adds a tree-view to your UI
* [GraphView](https://github.com/deepkn/silverbullet-graphview): adds a graph view to your UI, similar to the one in Obsidian
* [PDF](https://github.com/MrMugame/silverbullet-pdf): enables viewing and annotation of PDF documents with PDF.js
* [Mermaid](https://github.com/silverbulletmd/silverbullet-mermaid): adds mermaid diagram rendering
* [PlantUML](https://github.com/LogeshG5/silverbullet-plantuml): adds plantuml diagram rendering
* [Draw.io](https://github.com/LogeshG5/silverbullet-drawio): adds drawio diagram rendering and editing
* [Excalidraw](https://github.com/LogeshG5/silverbullet-excalidraw): adds excalidraw diagram rendering and editing

# Development
Want to develop your own plugs? Have a look at [[Plugs/Development]].


================================================
FILE: website/Plugs/Development.md
================================================
The easiest way to get started is to click the “Use this template” on the [silverbullet-plug-template](https://github.com/silverbulletmd/silverbullet-plug-template) repo.

Generally, every plug consists of a YAML manifest file named `yourplugname.plug.yaml`. This file defines all functions that form your plug. To be loadable by SilverBullet, it needs to be compiled into a bundle (ending with `.plug.js`).

For this you need to have [Deno](https://deno.com) installed, after which you can run the `deno task build` command specified in the plug template repo:

```shell
deno task build
```

For development it’s easiest to simply copy the `.plug.js` file into your space’s `_plug/` folder after it’s built:

```shell
cp myplug.plug.js ~/myspace/_plug/
```

Within seconds (watch your browser’s JavaScript console), your plug should be picked up both on the server and, synced to your browser and loaded. No need to even reload the page.

## Debugging
Since plugs run in your browser, you can use the usual browser debugging tools. When you `console.log` things, these logs will appear in your browser’s JavaScript console.

## Distribution
Once you’re happy with your plug, you can distribute it in various ways:

- You can put it on github by simply committing the resulting `.plug.js` file there and instructing users to point to by adding
  `- github:yourgithubuser/yourrepo/yourplugname.plug.js` to their `PLUGS` file
- Add a release in your github repo and instruct users to add the release as `- ghr:yourgithubuser/yourrepo` or if they need a specific release `- ghr:yourgithubuser/yourrepo/release-name`
  - You need to upload the plug file as asset in the release, with a name matching your repository, e.g, `yourrepo.plug.js`
  - If your repository name starts with "silverbullet-", like `silverbullet-foo`, both `silverbullet-foo.plug.js` and `foo.plug.js` asset names will be tried
- You can put it on any other web server, and tell people to load it via https, e.g., `- https://mydomain.com/mypugname.plug.js`.


================================================
FILE: website/Private.md
================================================
---
description: Your content is yours. You store it, nobody else gets access.
awesomeness: 7
tags: feature
---
SilverBullet is private because you [[Self Hosted|self host]] it, enabled [[Authentication]] and are not giving anybody else the username and password (_right!?_).

_You_ are in control of the server it runs on, and communication between your browser and your server is secured via [[TLS]]. It is a way to be [[Data Sovereignty|data sovereign]].

SilverBullet does not support end-to-end encryption yet (although you can enable [[Client Encryption]]). Therefore, you are in charge of securing your server using standard security practices (e.g. by enabling disk encryption to encrypt your disk).

================================================
FILE: website/Programmable.md
================================================
---
description: SilverBullet has been designed to be programmable from the ground up.
awesomeness: 10
tags: feature
---
SilverBullet has been designed to be programmable from the ground up. The core of SilverBullet is relatively small, see [[Architecture]].

On top of this core, a lot of functionality is built as [[Plugs]] and [[Space Lua]] scripts.

While not as fundamental in this approach as editors like [Emacs](https://www.gnu.org/software/emacs/), it draws a lot of inspiration from it.

================================================
FILE: website/Repository.md
================================================
#maturity/beta

Repositories are a collection of _pointers_ to [[Library|Libraries]] to install. Their purpose is to offer a curated list of libraries to install.

By default, SilverBullet ships with one repository ([[^Repositories/Std]]), more can be installed via the [[Library Manager]].

Repositories are implemented on top of a few SilverBullet mechanisms, specifically they are simply [[Meta Page|Meta Pages]] tagged with `#meta/repository` that contain [[Object#data]] objects tagged with `#meta/library/remote` with a `name` and `uri` attribute. Libraries are kept up to date using the [[Share]] mechanism.



================================================
FILE: website/Schema.md
================================================
SilverBullet relies on [JSON Schema](https://json-schema.org) for various types of validation, specifically:

* [[Tag#Custom tags]]
* [[^Library/Std/Config]] options

Often these schemas are encoded using [[Space Lua]], so take the shape of:

```lua
local schema = {
  type = "object",
  properties = {
    -- ...
  }
}
```

There are is the [[^Library/Std/APIs/Schema]] API for convenience.

================================================
FILE: website/Self Hosted.md
================================================
---
description: Rather than relying on the servers of a third-party provider, you run SilverBullet on your own server.
awesomeness: 10
tags: feature
---
According to [Wikipedia](https://en.wikipedia.org/wiki/Self-hosting_(web_services)):

> Self-hosting is the practice of running and maintaining a [website](https://en.wikipedia.org/wiki/Website) or [service](https://en.wikipedia.org/wiki/Service_\(systems_architecture\) "Service (systems architecture)") using a private [web server](https://en.wikipedia.org/wiki/Web_server "Web server"), instead of using a service outside of the administrator's own control. Self-hosting allows users to have [[Data Sovereignty|more control over their data, privacy, and computing infrastructure]], as well as potentially saving costs and improving skills.

SilverBullet is the ultimate gateway to practice hosting your own stuff. 

Had a taste and want more? Here are some excellent resources:

* [Self hosting Reddit](https://www.reddit.com/r/selfhosted/)
* [Awesome Self Hosted](https://github.com/awesome-selfhosted/awesome-selfhosted)



================================================
FILE: website/Service.md
================================================
The SilverBullet service bus is an extension mechanism built into SilverBullet.

# Concepts
* Discovery: the process of discovering available services based on a _selector_.
* Selector: a string (possibly with a wildcard `*`) used to advertise as well as discover services.
* Service: defined via [[API/service#service.define(spec)]]), implements a service advertised via a selector.
* Invocation: running a discovered service.

# Architecture
Services are an abstraction built on top of [[Event]]. When a new service is defined, it registers itself listening to the `discover:<<selector>>` event (to be discovered), and under `service:<<guid>>` to be invoked.

Service discovery happens by broadcasting an event on the event bus with the given selector, returning a list of services that are a match, ordered by (self assigned) priority. One (or all) of these services can then be invoked based on their returned ID.

See [[API/service]] for API details.

================================================
FILE: website/Share.md
================================================
#maturity/beta

SilverBullet is aimed for single-user, private use. Nevertheless, many have the need to _share_ some content kept in SilverBullet with the outside world: to push content out, pull it in, or even sync between different locations. This is where SilverBullet _share_ functionality comes in. 

**Note:** If you are interested in one-off ways to get content out, have a look at [[Export]].

# Modes
Sharing may be desirable in different directions:
* _Push_: produce content in SilverBullet, then push it to some external location, while being able to keep the external place up to date with changes, keeping SilverBullet as the source of truth. Example use cases:
  * Blog posts
  * Social network posts
  * [[Library]] you want to distribute to others
* _Pull_: pull in content from an external location and import it into your space and be able to keep pulling in new versions on demand. Use cases:
  * [[Library]] that you install, e.g. via the [[Library Manager]]
* _Sync_: For bi-directional push and pull. Example use cases:
  * Collaborate on a page with other SilverBullet users (not implemented yet)

# Operation
SilverBullet has [[^Library/Std/Infrastructure/Share|infrastructural support]] to solve this problem in a general way. It leverages [[Frontmatter]], specifically the following three keys:

* `share.uri`: specifies the external location to push, pull or sync your content with, represented as a [[URI|URI]].
* `share.mode`: specifies the _mode_ this sharing should happen, options are: `push`, `pull` or `sync`
* `share.hash`: automatically calculated and updated content hash of your local version to detect whether local changes were made since the last share operation.

# Workflow
On the page you’re interested to share, run the ${widgets.commandButton "Share: Page"} (bound to `Cmd-p`/`Ctrl-p` by default) command. This will pop up a list of enabled “Share providers”. Pick one and follow the provider-specific setup steps. At the end, your page will be shared and the `share.*` frontmatter keys automatically set.

Subsequent shares can be done simply by running the `Share: Page` command again.

# Support
Out of the box, sharing is supported for:

* Plain `https://` URIs (_Pull_ mode only) (implemented in [[^Library/Std/Infrastructure/URI]]).
* [[^Library/Std/Infrastructure/Github]] (`https://github.com/*` for repository files and `https://gist.github.com/*` URLs): both in _Pull_ mode, and _Push_ mode when a token is configured. Technically _Sync_ should work except that Github aggressively caches files, making this impractical.

To implement your own share provider, have a look at the implementations linked to understand how they work.


================================================
FILE: website/SilverBullet.md
================================================
# Introduction
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]] [[Page|Pages]] (called a [[Space]]). You navigate your space using the [[Page Picker]] like a traditional notes app, or through [[Link|Links]] like a wiki (except they are [[Linked Mention|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 [[Outlines|Outlining]] tools for you. Productivity freak? Have a look at [[Task|Tasks]]. More of a **database** person? You will appreciate [[Object|Objects]] and [[Space Lua/Lua Integrated Query|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 [[Command|Commands]], [[Page Template|Page Templates]] or [[API/widget|Widgets]].

# Programmable notes
Dynamically generating content, _programmable notes_... why would you want that, and how does it work?

Let’s say you have documented a set of product features in individual pages that you’ve [[Tag|tagged]] with a #feature tag, and annotated with a few custom [[Frontmatter]] [[Attribute|Attributes]].

With a simple [[Space Lua/Lua Integrated Query|Query]] and [[Template]], you can now dynamically build a product feature list, ordered by _awesomeness_ (`Alt-click` or hover and click the edit button to see the underlying code):

${template.each(query[[
  from tags.feature
  where tag == "page"
  order by awesomeness desc
]], templates.featureItem)}

_(The template generating the feature bullet items can be found in [[^Library/Website Templates]])_

Neat huh? A few more use cases.

## Active pages
Let’s say you want to have a list of your 5 modified pages. We can do that!
${template.each(query[[
  from tags.page
  order by lastModified desc
  limit 5
]], templates.pageItem)}

## To do items 
Maybe you want to collect all [[Task|Tasks]] that you have not yet completed from across your space? No problem:
${template.each(query[[
  from index.tag "task"
  where not _.done
  limit 3
]], templates.taskItem)}

# Tour
That all sounds nice, but what does that look like in practice? Well, if you’re wondering purely about _looks_: have a look around — this very website is hosted as a _read-only_ SilverBullet instance. You probably already figured this out.

If you’d like a bit of a tour and demo, give this a watch:
${embed.youtube "https://www.youtube.com/watch?v=mik1EbTshX4"}

Want to see even more? Here is a whole [playlist with instruction videos](https://www.youtube.com/watch?v=bb1USz_cEBY&list=PLxFAb_vXRcEp4465MVI6Ha9wzNiX5VevQ) that go more in depth.

# [[Install]]
As mentioned, SilverBullet is a [[Self Hosted]] web application. This is great if you care about [[Data Sovereignty]], but it does mean you need to [[Install]] it on a server yourself. Perhaps you do this on a Raspberry Pi you didn’t have a use for, or a VPS somewhere in the cloud. SilverBullet is distributed as a single self-contained server [[Install/Binary]] or [[Install/Docker]] container.

While this is a bit more complicated to set up than simply downloading desktop app or signing up for an account with some online service, self hosting is a path to both [[Data Sovereignty]] and to access your content from any device with a modern [[Browser]].

Ready? Proceed to [[Install]].

# [[Architecture]]
The SilverBullet client is built as a [[Local First]], [[PWA|Progressive Web Application]], syncing all your content into your browser’s local storage, enabling **instant access** to your entire space whether you are **online** or **offline**. Simply opt to “Install SilverBullet” from your browser (in any Chrome-based browser), add it to your Dock or home screen (Safari and Android), and voila: SilverBullet becomes indistinguishable from a regular desktop or mobile app. You can try it right here on silverbullet.md.
![[pwa-screenshot.png]]

Unplug your (hypothetical) network cable, and everything still works!

Learn more about SilverBullet’s technical [[Architecture]].

# Project status
SilverBullet has been in development since early 2022, but is ever evolving. The current iteration is **SilverBullet v2**. We had a little bit of a reboot, rebuilding some of the foundations, and replacing some of the previous ([v1](https://v1.silverbullet.md)) features. 

# What’s next?
* [[Manual]]: SilverBullet’s official manual.
* [[CHANGELOG]]: we’re in active development, so things change rapidly. Watch this page to keep up.
* [Community](https://community.silverbullet.md): join our community: ask questions, share your experiences.
* [Issues](https://github.com/silverbulletmd/silverbullet/issues): if you have ideas or find bugs, please report them.
* [Mastodon](https://fosstodon.org/@silverbulletmd): follow SilverBullet development on [Mastodon](https://joinmastodon.org/).


================================================
FILE: website/Slash Command.md
================================================
Slash commands are quick ways to perform repetitive tasks. 99% of the time this will mean using a [[^Library/Std/Infrastructure/Slash Templates]], but you can also create them in a more low-level way using [[Space Lua#Slash commands]].

# Invoking a slash command
You can trigger a slash command by typing a `/` in your text (after some white space) followed by its name (this will auto complete).

Technically, no slash commands ship with SilverBullet directly. However, you can easily add useful ones via [[Library]].


================================================
FILE: website/Slash Templates.md
================================================
See [[^Library/Std/Infrastructure/Slash Templates]]

================================================
FILE: website/Space Lua.md
================================================
Space Lua is a custom dialect and implementation of the [Lua programming language](https://lua.org/), embedded in SilverBullet. It aims to be a largely complete Lua implementation, but adds a few non-standard features while remaining syntactically compatible with “real” Lua.

In its essence, Space Lua adds two features to SilverBullet’s [[Markdown]] language:

* [[#Definitions]]: Code written in `space-lua` code blocks are enabled across your entire space.
* [[#Expressions]]: The `${expression}` syntax will [[Live Preview]] to its evaluated value. It is common to use this mechanism to render custom [[API/widget|Widgets]].

Have a look at [[Space Lua/Conventions]] for best practices around code style.

> **note** Note
> Many examples in the documentation use `lua` as a [[Markdown/Fenced Code Block]] language rather than `space-lua`, this is to be able to give example of Space Lua code without actually “activating” it as such on the website. When you use these snippets yourself, replace `lua` with `space-lua`.

# Definitions
Space Lua definitions are defined in fenced code blocks with the `space-lua` language. These blocks are active **across your entire space** (hence _Space_ Lua), not just on the page they appear on.

A simple example:

```space-lua
-- adds two numbers
function adder(a, b)
  return a + b
end
```

Each `space-lua` block has its own local scope. However, following Lua semantics, when functions and variables are not explicitly defined as `local` they will be available globally across your [[Space|space]]. This means that the `adder` function defined, can be called from anywhere in your space.

## Definition loading
Your `space-lua` definitions are constantly being indexed as part of the [[Object Index]] with the [[Object/space-lua]] tag. There is nothing you have to do for this, other than be a bit patient for things to start working when you initialize a fresh client.

When your client boots, or if you explicitly run the `System: Reload` command, all these scripts are executed in sequence. 

It is possible to **control load order** of space lua scripts using the special `-- priority: <number>` comment in Space Lua code. For instance:

```space-lua
-- priority: 10
local myCodeHere
```

Scripts are loaded in _reverse priority_ order. When you set no priority (the default) your scripts will be run last.

The order used is determined by this [[Space Lua/Lua Integrated Query|query]] (also part of your [[^Library/Std/Pages/Space Overview]]) page:

    query[[
      from t = index.tag "space-lua"
      order by t.priority desc
    ]]

This means that the higher the priority, the earlier the script is loaded. That also means that if you want to override previously defined definitions you need to a set a _lower_ priority (or in most cases: simply omit the priority comment).

Here are the conventions used by the [[Library/Std]] library:

* `priority: 100` for config definitions (schemas)
* `priority: 50` for setting really core and root variables (like `template.*` APIs) that will be used by other scripts
* `priority: 10`: for standard library definitions that may be overriden (by scripts with lower priority)

> **note** Tip
> All your space-lua scripts are loaded on boot, to reload them without reloading the page, simply run the ${widgets.commandButton("System: Reload")} (Ctrl-Alt-r) command.

# Expressions
One SilverBullet specific [[Markdown]] [[Markdown/Extensions]] is the `${lua expression}` syntax that you can use in your pages. This syntax will [[Live Preview]] to the evaluation of that Lua expression.

For example: 10 + 2 = ${adder(10, 2)} (Alt-click, or select to see the expression) is using the just defined `adder` function.

This mechanism is often used in conjunction with [[Space Lua/Lua Integrated Query]] and [[API/widget|Widgets]].

# API
![[API]]
# Space Lua vs “OG” Lua
Space Lua is a _custom Lua implementation_. It does not use the official [Lua](https://www.lua.org) nor [LuaJIT](https://luajit.org) implementations. Rather than using a WebAssembly or other implementation of Lua that could run in the browser, we have opted for a custom implementation. There are various (partially historical) reasons for this decision, but taking this path has given us a very smooth interoperability story allowing for very friction-free access to a lot of SilverBullet and browser functionality. It also enables relatively deep integration into the Lua runtime, enabling features like (some) code complete support, (some) jump to definition and various other features.

While the aim is to be 95% (let’s say) compatible with regular Lua, there are a few [[Space Lua/Quirks]] to be aware of.

In addition to quirks, Space introduces a (minimal) set of new features on top core Lua:

1. [[Space Lua/Lua Integrated Query]], embedding a query language into Lua itself
2. [[Space Lua/Thread Locals]]

================================================
FILE: website/Space Lua/Conventions.md
================================================
#maturity/beta

Some coding conventions for [[Space Lua]]. Work in progress.

# Indentation
Use 2 spaces for indentation.

# Variable naming
For variables and methods alike use camelCasing as opposed to snake_case. That is: `myVariable` and `myFunction` instead of `my_variable` and `my_function`.

# Name spacing
Since Space Lua has a single global namespace across your entire space, it is good practice to manually namespace functions using the following pattern:

```lua
-- This initializes the stuff variable with an empty table if it's not already defined
stuff = stuff or {}

function stuff.adder(a, b)
  return a + b
end
```

And to use `local` if you don’t need to access outside your code block:

```lua
local myVariable

local function myFunction()
  
end
```

## Use singular top-level namespaces for APIs
Such as [[API/widget]], [[API/tag]].

## Use plural namespaces for collections
Such as [[^Library/Std/Widgets/Widgets|widgets]].

# API naming conventions
## *.define
This is a common Space Lua API naming convention used to define new entities of some type globally. Such APIs typically take a single table with arguments as an argument.

Internally, these APIs typically update the [[API/config]] as a side effect, which is then read elsewhere.

Examples:
* [[API/tag#tag.define(spec)]]
* [[API/taskState#taskState.define(def)]]
* [[API/config#config.define(key, schema)]]
* [[API/slashCommand#slashCommand.define(spec)]]
* [[^Library/Std/APIs/Command#command.define(commandDef)]]

## *.new
These like constructors for objects and do not have side effects.

Examples:

[[API/widget#widget.new(spec)]]



================================================
FILE: website/Space Lua/Lua Integrated Query.md
================================================
Lua Integrated Query (LIQ) is a SilverBullet specific Lua extension. It adds a convenient query syntax to the language in a backwards compatible way. It does so by overloading Lua’s default function call + single argument syntax when using `query` as the function call. As a result, Lua programs using LIQ are still syntactically valid Lua.

The syntax for LIQ is `query[[my query]]`. In regular Lua `[[my query]]` is just another way of writing `"my query"` (it is an alternative string syntax). Function calls that only take a string argument can omit parentheses, therefore `query[[my query]]` is equivalent to `query("my query")`.

However, in [[Space Lua]] it interpreted as an SQL (and [LINQ](https://learn.microsoft.com/en-us/dotnet/csharp/linq/))-inspired integrated query language. 

General syntax:

    query[[
      from <var> in <expression>
      where <expression>
      order by <expression>
      limit <expression>, <expression>
      select <expression>
    ]]

LIQ operates on any Lua collection.

For instance, to sort a list of numbers in descending order:
${query[[from n = {1, 2, 3} order by n desc]]}

However, in most cases you’ll use it in conjunction with [[API/index#index.tag(name)]]. Here’s an example querying the 3 pages that were last modified:

${query[[
  from p = index.tag "page"
  order by p.lastModified desc
  select p.name
  limit 3
]]}

Note that the query returns a regular Lua table, so it can be part of a bigger expression:

${some(query[[
  from p = index.tag "page"
  limit 0
]]) or "Matched no pages"}

# Clauses
Here are the clauses that are currently supported:

## from <expression>
The `from` clause specifies the source of your data. There are two syntactic variants:

**Recommended:** With explicit variable binding:

    from v = <<expression>>

binding each item to the variable `v`.

However, there is also the more concise:

    from <<expression>>

implicitly binding each item to the variable `_` as well as making all attributes directly available as variables. The latter, while shorter, is less performant and will block future optimizations, so the variable-binding variant is preferred.

> **warning** Warning
> When you use a `from` clause without explicit variable binding (so without thee `v in` syntax), note that any attribute of the object you’re iterating over will shadow global variables. For instance, if you have an object with a `table` attribute, regular `table` APIs will become inaccessible within the query.
> 
> **Recommendation:** Use the explicit variable binding syntax

Example without variable binding:
${query[[from {1, 2, 3} select _]]}

With variable binding:
${query[[from n = {1, 2, 3} select n]]}

A more realistic example using `index.tag`:
${query[[from p = index.tag "page" order by p.lastModified select p.name limit 3]]}

## where <expression>
The `where` clause allows you to filter data. When the expression evaluated to a truthy value, the item is included in the result.

Example:

${query[[from n = {1, 2, 3, 4, 5} where n > 2]]}

Or to select 5 pages tagged with `#meta`:

${query[[from p = index.tag "page" where table.includes(p.tags, "meta") limit 5]]}

Or select based on name (including folder) and a [[API/string|string function]]

${query[[from p = index.tag "page" where p.name:startsWith("Person")]]}

## order by <expression> [desc]
The `order by` clause allows you to sort data, when `desc` is specified it reverts the sort order.

As an example, the last 3 modified pages:
${query[[
  from p = index.tag "page"
  order by p.lastModified desc
  select p.name
  limit 3
]]}

You can order based on multiple expressions by specifying multiple expressions separated by commas:

${query[[
  from p = index.tag "page"
  order by p.lastModified desc, p.name
  select p.name
  limit 3
]]}

Sorting of strings can be adjusted with `queryCollation` in [[^Library/Std/Config]]

## limit <expression>[, <expression>]
The `limit` clause allows you to limit the number of results, optionally with an offset.

Example:

${query[[from {1, 2, 3, 4, 5} limit 3]]}

You can also specify an offset to skip some results:

${query[[from {1, 2, 3, 4, 5} limit 3, 2]]}

## select <expression>
The `select` clause allows you to transform each item in the result set. If omitted, it defaults to returning the item itself.

Some examples:

Double each number:
${query[[from n = {1, 2, 3} select n * 2]]}

It is convenient to combine it with the [[API/table#table.select(table, keys...)]] API:
${query[[
  from p = index.tag "page" 
  select table.select(p, "name", "lastModified")
  limit 3
]]}

## Rendering the output
To render the output as a template, you can rely on the fact that queries return Lua tables. For example, to apply a template to render every page as a link:

${template.each(query[[
  from p = index.tag "page"
  order by p.lastModified desc
  limit 3
]], templates.pageItem)}

To render pages as links with their full local URL, use `templates.fullPageItem`. For more information on available templates, see [[^Library/Std/Infrastructure/Query Templates]].


================================================
FILE: website/Space Lua/Quirks.md
================================================
A few notable [[Space Lua]] quirks.

# Lua implementation notes
Space Lua is intended to be a more or less complete implementation of [Lua 5.4](https://www.lua.org/manual/5.4/). However, a few features are (still) missing:

* coroutines (not planned, not useful in the SilverBullet context)
* _ENV (planned)
* Hexadecimal numeric constants with a fractional part, or binary exponents (not supported by JavaScript number parser either)


# Conversion of Strings to Numbers

Space Lua converts strings to numbers like standard Lua.

Syntax:
* Leading and trailing whitespace (space, tab, newline, carriage return, form feed and vertical tab) is trimmed.
* The entire string after trimming trailing whitespace must form a number.
* Optional `+` or `-` signs are accepted before the number.
* _Decimal integers_ and _decimal floats_ are supported with optional exponent (e.g., `42`, `-3.5`, `.5`, `5.`, `1e3`, `-2.5E-2`).
* _Hexadecimal integers_ and _hexadecimal floats_ are also supported:
  * integers (e.g., `0x10`, `-0XFF`),
  * floats require `p` or `P` exponent (e.g., `0x1.8p1`, `-0X10.3P-1`).

Failure handling:
* In _arithmetic_ and _unary minus_ expressions invalid strings cause an Lua exception:
  `attempt to perform arithmetic on a non-number`.
* `tonumber(s)` returns `nil` on failure.
* `tonumber(s, base)` parses _signed integers_ in bases 2..36 (without decimal points or exponents) and returns `nil` on invalid input string or base.

Examples (with `tonumber` function):

Code                   | Result                  | Expected
-----------------------|-------------------------|---------
`tonumber(' 42 ')`     | ${tonumber(' 42 ')}     | `42`
`tonumber('-0xFf ')`   | ${tonumber('-0xFf ')}   | `-255`
`tonumber('0x1.8p1')`  | ${tonumber('0x1.8p1')}  | `3.0`
`tonumber('1e-2')`     | ${tonumber('1e-2')}     | `0.01`
`tonumber('abc')`      | ${tonumber('abc')}      | `nil`
`tonumber('1010', 2)`  | ${tonumber('1010', 2)}  | `10`
`tonumber(' +fF', 16)` | ${tonumber(' +fF', 16)} | `255`
`tonumber('8', 8)`     | ${tonumber('8', 8)}     | `nil`

Examples (with arithmetic operations):

Code              | Result             | Expected
------------------|--------------------|--------------------
`'0xfFfFp1'`      | ${'0xfFfFp1'}      | string: `0xfFfFp1`
`'0xffffP-3' + 0` | ${'0xffffP-3' + 0} | number: `8191.875`
`-'123E-12'`      | ${-'123E-12'}      | number: `-1.23e-10`


================================================
FILE: website/Space Lua/Thread Locals.md
================================================
#maturity/experimental

There is “magic” `_CTX` global variable available from which you can access some context-specific values. Currently the following keys are available:

* `_CTX.currentPage` providing access to the currently open page (PageMeta object)


================================================
FILE: website/Space Lua/Widget.md
================================================
The `${lua expression}` syntax can be used to implement custom widgets. If the Lua expression evaluates to a simple string, it will live preview as that string rendered as markdown. However, if the expression returns a `widget.new`-generated result value, you can do some fancier stuff.

To render a widget, call `widget.new` with a[text](https://meet.google.com/dtz-nbtq-wwf)ny of the following keys:

* `markdown`: Renders the value as markdown
* `html`: Renders a HTML string as a widget. This is somewhat brittle. Therefore, it’s preferred to use the [[^Library/Std/APIs/DOM]] API.
* `display`: Render the value either `inline` or as a `block` (defaults to `inline`)

For convenience there are `widget.markdown` and `widget.html` wrappers available, see below for examples.

## Markdown widgets

## DOM widgets
To render a custom HTML-based widget, use the [[^Library/Std/APIs/DOM]] API:

```space-lua
function marquee(text)
  return widget.html(dom.marquee {
    class = "my-marquee",
    onclick = function()
      editor.flashNotification "You clicked me"
    end,
    text
  })
end
```

We can combine this with some [[Space Style]] to style it:

```space-style
.my-marquee {
  color: purple;
}
```

Now, let’s use it (try clicking):
${marquee "Finally, marqeeeeeee!"}


================================================
FILE: website/Space Style.md
================================================
Space Style is [[Space Lua]]’s stylish sibling. It enables you to add your own styling to SilverBullet with `space-style` [[Markdown/Fenced Code Block]].

This can be used to achieve various things, such as overriding the default editor font or setting wider page widths. It is also possible to develop custom themes this way.

To apply the updated styles, either reload the client or run the ${widgets.commandButton("System: Reload")} command.

Many styles can be set with [variables](https://github.com/silverbulletmd/silverbullet/blob/main/client/styles/theme.scss) but not everything is covered. You’ll have to reverse-engineer those parts, unfortunately.

# Load order
You can tweak the CSS load order style by including a `/* priority: number */` comment:

```space-style
/* priority: 10 */
somestyle {

}
```

The following [[Space Lua/Lua Integrated Query]] is used to determine the order in which Space Style is loaded:

```lua
query[[from index.tag "space-style" order by _.priority desc]]
```

# Tag Styling
You can add custom styles to a tag by leveraging the `data-tag-name` attribute, [CSS Attribute Selectors](https://developer.mozilla.org/en-US/docs/Web/CSS/Attribute_selectors) and custom [[Space Style]]'s. Every tag gets an attribute added to it called `data-tag-name` that is set to the tag name with the `#` symbol stripped out. So given the tag #my-cool-tag the `data-tag-name` attribute would look like:

    data-tag-name="my-cool-tag"

This allows us to do things like change the color of the #my-cool-tag
to have a purple background, limegreen text and bold font by adding the following [[Space Style]]:

```css
.sb-hashtag[data-tag-name="my-cool-tag"] {
  background: purple;
  color: limegreen;
  font-weight: bolder;
}
```

Additionally tags written using angle brackets, such as...

    #<my cool tag>

...can be styled via [[Space Style]] like this:
```css
.sb-hashtag[data-tag-name="my cool tag"] {
  background: purple;
  color: limegreen;
  font-weight: bolder;
}
```
# More examples
All the actual CSS in these examples is commented out as to not affect this very website.
```space-style
html {
  /* Changes to the default theme */
  /* Such as the accent color */
  /*--ui-accent-color: #464cfc;*/
}

html[data-theme="dark"] {
  /* Changes to the dark theme */
  /*--ui-accent-color: #464cfc;*/
}

html {
  /* Uncomment the next line to set the editor font to Courier */
  /* --editor-font: "Courier" !important; */
  /* Uncomment the next line to set the editor width to 1400px */
  /* --editor-width: 1400px !important; */
}

/* Choose another header color */
html {
  /*--top-background-color: #eee;*/
}
/* Or modify the element directly */
#sb-top {
  /*background-color: #eee !important;*/
}

/* You can even change the appearance of buttons */
button {
  /* align-items: center;
  background-color: #fff;
  border-radius: 6px;
  box-shadow: transparent 0 0 0 3px,rgba(18, 18, 18, .1) 0 6px 20px;
  box-sizing: border-box;
  color: #121212;
  cursor: pointer;
  display: inline-flex;
  flex: 1 1 auto;
  font-family: Inter,sans-serif;
  font-size: 0.6rem;
  justify-content: center;
  line-height: 1;
  margin: 0.2rem;
  outline: none;
  padding: 0.3rem 0.4rem;
  text-align: center;
  text-decoration: none;
  transition: box-shadow .2s,-webkit-box-shadow .2s;
  white-space: nowrap;
  border: 0;
  user-select: none;
  -webkit-user-select: none;
  touch-action: manipulation; */
}

button:hover {
  /* box-shadow: #121212 0 0 0 3px, transparent 0 0 0 0; */
}

/* Add custom styling to a specific tag
--> Example Tag: #my-cool-tag */
.sb-hashtag[data-tag-name="my-cool-tag"] {
  /* background: purple;
  color: limegreen;
  font-weight: bolder; */
}
/* --> Example Tag with angle brackets: #<my cool tag> */
.sb-hashtag[data-tag-name="my cool tag"] {
  /* background: purple;
  color: limegreen;
  font-weight: bolder; */
}
/* Custom Styling to all tags */
.sb-hashtag[data-tag-name] {
  /* background: yellow;
  color: goldenrod;
  border: 2px dashed goldenrod; */
}
```

Another example can be found in [[Page Decorations#Use case: pimp my page]]


================================================
FILE: website/Space.md
================================================
A _space_ is SilverBullet terminology for a workspace, or project, or instance. [Obsidian](https://obsidian.md/) calls this a vault, [LogSeq](https://logseq.com/) calls it a graph. You may think of it as a [[Folder]] or a directory — because in practical terms, that’s all it is.

Feel free to back up or manipulate your space’s folder and its files with whatever tool you like — you don’t have to use SilverBullet exclusively. You may want to turn your space’s folder into a git repository, for instance, and do version control and back-ups that way.

A space consists of
* [[Folder]]
* [[Page]] and [[Meta Page]]
* [[Document]]

# Folder layout
Every space in SilverBullet at the very least has an index page (by default named `index.md`) and a [[CONFIG]] page (named `CONFIG.md`). If you use [[Library]], you will likely have a `Library/` folder as well.


================================================
FILE: website/Special Pages.md
================================================
SilverBullet has a few “special pages.” The convention is to always use ALL CAPS for these page names. Generally the format of these pages is that they can contain arbitrary text, but embedded in them is some sort of fenced code block (triple backtick blocks) that encode something special. What _exactly_ depends on the page.

All special pages except [[CONFIG]] are optional: if they don’t exist, well, they don’t do anything.

Here are the current list of “special pages” known to humankind:

* [[CONFIG]] for setting various settings


================================================
FILE: website/Sync.md
================================================
SilverBullet is an offline-first web application. Therefore it keeps a copy of your space locally in your browser. It implements a sync engine to continuously keep this local copy in sync with the server (for technical details, see [[Architecture]]).

Sync happens:
* For the entire space: roughly every 20 seconds
* For the currently open file: every 4-5 seconds

In addition, you can use the ${widgets.commandButton "Sync: Space"} and ${widgets.commandButton "Sync: File"} commands to trigger these manually.

Sync runs in a service worker and therefore only runs when the service worker is active. The service worker is automatically kept active for as long as you have a SilverBullet tab or window open. Some browsers slow web apps down when they are in the background or not in an active tab. This may also affect whether sync happens in the background. Most browsers (including mobile ones), keep service workers running for a little while (perhaps up to a minute), even when e.g. the screen is locked, or another app is activated.

## Status
If sync takes longer than a second, a black circle progress indicator will appear in the [[Top Bar]].

## Conflicts
When you use multiple clients and make changes to the same files simultaneously conflicts may happen. SilverBullet will not attempt to try to merge these conflicts, but rather create a conflicting copy when this happens. You will be notified in the UI when this occurs.

## Configuration
You can tweak what files are synced locally via a few `config.*` configuration options.

> **note** Note
> Sync related configuration **need** to be done in the [[CONFIG]] page specifically. If they are configured elsewhere they will not be picked up.

By default all your pages are synced, but [[Document]] are not. They are fetched on demand. If you would also like to sync documents (so you can access them when offline), you can do so by setting the following in a Space Lua block in [[CONFIG]]:

    config.set("sync.documents", true)

In addition, you can fine-tune what files you do _not_ want to sync using [gitignore](https://git-scm.com/docs/gitignore) syntax assigned to the `sync.ignore` option, which accepts either a single string, or a list of strings:

    config.set("sync.ignore", {
      -- Don't sync PDFs and MP4 files
      "*.pdf",
      "*.mp4"
    })

After making changes to these options, you need to reload your client and wait for a sync cycle to kick in until they are applied.


================================================
FILE: website/TLS.md
================================================
SilverBullet relies on a few advanced browser features to operate (specifically service workers, crypto APIs and clipboard APIs). These features are _only_ enabled by browsers when websites are accessed via `http://localhost` or via [TLS](https://en.wikipedia.org/wiki/Transport_Layer_Security), that is — a `https://` URL.

This is not a limitation imposed by SilverBullet, it is a restriction encoded in web standards. Therefore, to use SilverBullet you need to either access it via `localhost` or put a TLS certificate on it.

# localhost (http)
You can do this in a few ways:

## Run SilverBullet locally
If you run SilverBullet _locally_ on your machine, this is the easiest option. Everything runs fine as long as the browser sees `localhost` or `127.0.0.1` appear in the URL, even with `http://`.

The obvious drawback of this approach is that your SilverBullet instance is only accessible from the machine you run it on.

## SSH tunnel
If you run SilverBullet on a remote server in your network that you have SSH access to, you can “fake” a localhost URL by creating an SSH tunnel.

You can do this as follows:
```bash
ssh -N -L 3002:localhost:3000 user@someip
```

This will tunnel your localhost port 3002 (accessible via http://localhost:3002) to port 3000 on the `someip` server. Stop the tunnel with `Ctrl-c`. To keep the tunnel running in the background, add the `-f` option.

Compared to running it locally this is _slightly_ better, because you can now access SilverBullet from any machine you can create an SSH tunnel from.

## Local (Caddy) reverse proxy
If you don’t (or can’t) use SSH, you can create a [Caddy](https://caddyserver.com/) reverse proxy. [Install Caddy](https://caddyserver.com/docs/install) first, then create a `Caddyfile`:

```
localhost:3002 {
    reverse_proxy someip:3000
}
```
And run Caddy with:
```bash
caddy run --config /path/to/Caddyfile
```
Now you can access your remote server via `http://localhost:3002`

# TLS (https)
For this, you need to get your hands on a TLS certificate.

A few options:

## Tailscale SSL certificate
If you’re a [Tailscale](https://tailscale.com/) user, this a simple solution. If not, you may consider becoming one — it’s a solid service, very friendly to [[Self Hosted|self hosters]], and _free_ for this use case.

Part of the [guide to setup SilverBullet on Linux](https://community.silverbullet.md/t/install-silverbullet-on-a-64-bit-debian-ubuntu-raspianos-internet-accessible-via-tailscale/48) are instructions on how to install  (a free service) and use it to expose a local server (like SilverBullet) locally on your VPN, or the Internet — a setup that gives you a `.ts.net` subdomain with TLS certificate. 

The advantage of this approach is that you have the choice to expose your SilverBullet to the wide Internet, or limit it to just your Tailscale VPN. The disadvantage is that you now rely on a third party (Tailscale).

## Cloud VM with Caddy
There a various affordable providers of cloud servers that can be used to self-host SilverBullet in the cloud. It relatively easy to get a TLS certificate issued on a publicly exposed server.

The recommended approach for this requires two things:

1. An Internet exposed cloud server. Affordable options include:
   * [Hetzner Cloud](https://hetzner.cloud/?ref=6jW03LSGlJKf) (referral link, EU based) starting from about 3.5 euro/month.
   * [Vultr](https://www.vultr.com/products/cloud-compute/) starting from about $2.5/month.
2. A domain name of your own that you can configure DNS records on to point to your server, or using a service like [DuckDNS](https://www.duckdns.org/) which gives you a `*.duckdns.org` sub-domain for free.

After deploying SilverBullet on the VM (with [[Authentication]] enabled, obviously), you can deploy [Caddy](https://caddyserver.com/) next to it as a reverse proxy. Caddy can automatically request TLS certificates using [Let’s Encrypt](https://letsencrypt.org/). 

For this, [install Caddy](https://caddyserver.com/docs/install) into your VM. Then, in your Caddyfile (usually located `/etc/caddy/Caddyfile`) put:

```
silverbullet.mydomain.com {
    reverse_proxy localhost:3000
}
```

Replace `silverbullet.mydomain.com` with any domain that you have configured to resolve to the IP of your server, and the `:3000` port with whatever local port you run SilverBullet on.

Restart Caddy and access SilverBullet via `https://silverbullet.mydomain.com`. On first load, Caddy will work with Let’s Encrypt to issue a TLS certificate and install (and update) it automatically, this may take a minute, so be patient.

## Self-signed certificate
It is possible to self sign certificates and use those. Search the Internet for instructions on how to do this, [here is website that describes the way it works and how to do it](https://www.ssldragon.com/blog/what-is-self-signed-certificate/).


================================================
FILE: website/Tag Picker.md
================================================
Triggered via ${widgets.commandButton("Navigate: Tag Picker")}, allows you to quickly navigate to [[Tag|tag pages]].

================================================
FILE: website/Tag.md
================================================
Tags in SilverBullet are used to encode types of [[Object|Objects]], they’re similar to tables in relational databases, or classes in object-oriented programming.

Every [[Object]] has a main `tag`, which signifies the type of object being described. In addition, any number of additional tags can be assigned as well via the `tags` attribute. You can use either the main `tag` or any of the `tags` as query sources in [[Space Lua/Lua Integrated Query]].

# Built-in tags
${widgets.subPages("Object")}

# Custom tags
Tags are impliclty created when you use the [[Markdown/Hashtags]] syntax. Their behavior and functionality can be further expanded via the [[API/tag]] API.



================================================
FILE: website/Task.md
================================================
The task notation syntax is a [[Markdown]] extension to write down tasks.

Its most basic form is:

    * [ ] This is my first task

which renders as follows:

* [ ] This is my first task

SilverBullet allows you to simply toggle the complete state of a task by clicking the checkbox.

All tasks across your space are automatically [[Object/task|indexed]] and can therefore be [[Space Lua/Lua Integrated Query|queried]].

# Custom states
Tasks support the default `x` and ` ` states (done and not done), but custom states as well. Support for this is still basic, however.

Example:

* [NOT STARTED] Task 1
  Fix having global variables work in markdown.expandMarkdown
* [IN PROGRESS] Task 2

Restrictions:

* Task states cannot contain `:` to avoid ambiguity with [[Attribute]] syntax.

To define custom states explicitly (and get code completion for them), use the [[API/taskState]] API.


================================================
FILE: website/Template.md
================================================
A template in SilverBullet is simply a page or string using [[Space Lua#Expressions]].

They’re typically used to dynamically render parts of pages using [[Space Lua]] and in [[Page Template]] and [[Slash Templates]].

================================================
FILE: website/Top Bar.md
================================================
The top bar contains:

* [[Page Namer]]
* The home button, navigating to your [[Index Page]]
* [[Page Picker]]
* [[Command Palette]]

In addition the top bar color indicates the current online status:

* **Gray** indicates SilverBullet is connected to the server
* **Yellow** indicates not having a server connection

================================================
FILE: website/Transclusions.md
================================================
Transclusions are an extension of the [[Markdown]] syntax enabling inline embedding of content.

The general syntax is `![[path]]`. Two types of transclusions are currently supported:

# Media
Syntax: `![[path/to/image.jpg]]` see [[Document#Embedding]] for more details.

Media resizing is also supported:
![[Document#Media resizing]]
# Pages
Syntax:
* `![[page name]]` embed an entire page
* `![[page name#header]]` embed only a section (guarded by the given header)


================================================
FILE: website/Troubleshooting.md
================================================
Running into trouble? Sorry to hear it. Let’s figure this out.

Here are some things you can try when things don’t work, if you’re still stuck — ask the [community for help](https://community.silverbullet.md/).

The starting point is always [[Log|Logs]] to see if you see anything suspicious.

# Disable features
Depending on the situation you may (temporarily) disable various client features by appending any of the following to any SilverBullet URL (e.g. `http://localhost:3000/?disableSpaceLua=1`):
* `?disableSync=1` disables synchronization with the server
* `?disableSpaceLua=1` disables loading of all [[Space Lua]] scripts
* `?disableSpaceStyle=1` disables loading of all [[Space Style]]
* `?disablePlugs=1` disables loading of all non built-in [[Plugs]]

# Client reset
If all else fails, you may try to perform a full client reset, simply add `?resetClient=1` to any SilverBullet URL.

This will do the following:
1. Confirm you want to perform a full client reset
2. Wipe all data stored locally in your browser (IndexedDB, unregister the service worker)
3. Reload the client initiating a full resync


================================================
FILE: website/URI.md
================================================
URIs in SilverBullet are a means to point to locations outside of your [[Space|Space]], APIs to read and write to URIs are available via [[API/net]].

Use cases:
* [[Repository]]
* [[Share]]

================================================
FILE: website/Vim.md
================================================
SilverBullet has a basic Vim mode. You can toggle it using the ${widgets.commandButton("Editor: Toggle Vim Mode")} command.

In addition, it supports various ex commands that you can run as you would expect, for instance: `:imap jj <Esc>`.

The [[CONFIG]] file can also be used to define and extend these commands. It supports the following:

- **Key unmapping** - disable existing keybindings
- **Custom mappings** - using `map` and `noremap`
- **Ex command definitions** - map custom ex commands to built-in or custom SilverBullet commands

SilverBullet [[Command]] bindings take precedence over Vim bindings, you may wish to update or disable some [[Keyboard Shortcuts]] or remap the desired functionality in Vim as shown below.

To manually reload the `vim` section of your [[CONFIG]], use the ${widgets.commandButton("Editor: Vim: Load Vim Config")} command.

# Configuration
Using Space Lua:

```lua
config.set {
  vim = {
    unmap = {
      "<Space>",
      {
        key = "<C-c>",
        mode = "insert",
      },
    },
    map = {
      {
        map = "jj",
        to = "<Esc>",
        mode = "insert",
      },
    },
    noremap = {
      {
        map = "<",
        to = "<gv",
        mode = "visual",
      },
      {
        map = "<Space>ff",
        to = ":findfile<CR>",
        mode = "normal",
      },
      {
        map = "<Space>rc",
        to = ":runcommand<CR>",
        mode = "normal",
      },
    },
    commands = {
      {
        command = "Navigate: Page Picker",
        ex = "findfile",
      },
      {
        command = "Open Command Palette",
        ex = "runcommand",
      },
    }
  }
}
```


================================================
FILE: website/YAML.md
================================================
YAML stands for “YAML Ain’t Markup Language.” More information can be found at [the YAML website](https://yaml.org/).

SilverBullet uses YAML in various contexts, specifically [[Frontmatter]].

# Internal links
Many string values can be written directly in YAML without any quoting, like:
```yaml
property: value
```

However when you want to reference [[Link|a page]] you will need to quote the full link:
```yaml
some page: "[[Pages]]"
list of pages:
  - "[[Pages]]"
  - "[[Links]]"
```

This is because the square brackets used in the internal link format have a meaning in YAML as well. So an unquoted link is parsed as list inside a list:
```yaml
some page: [[Pages]]
equivalent yaml: [
    [ "Pages" ]
]
```


================================================
FILE: website/Zef.md
================================================
Hi! My name is [Zef Hemel](https://github.com/zefhemel/), and I have been working on SilverBullet since February 2022. For most of this time it’s been purely a labor of love and was (implicitly) funded by the companies I worked for. 

Today, I try more deliberately to spend time on making SilverBullet everything it can be, and for this I rely on [[Funding]].


================================================
FILE: website/Zero Tracking.md
================================================
---
description: No tracking or private data is sent to SilverBullet (or any other) servers at any time.
awesomeness: 3
tags: feature
---
SilverBullet does not include any trackers or telemetry in its software. No tracking or private data is sent to SilverBullet (or any other) servers at any time.

The (mild) exception to this are the silverbullet.md and community.silverbullet.md websites where we do use [Plausible](https://plausible.io/) to get _some_ sense of what people are interested in.

================================================
FILE: website/index.md
================================================
You want to be here, trust me: [[SilverBullet]]
description: No tracking or private data is sent to SilverBullet (or any other) servers at any time.
awesomeness: 3
tags: feature
---
SilverBullet does not include any trackers or telemetry in its software. No tracking or private data is sent to SilverBullet (or any other) servers at any time.

The (mild) exception to this are the silverbullet.md and community.silverbullet.md websites where we do use [Plausible](https://plausible.io/) to get _some_ sense of what people are interested in.

================================================
FILE: website/index.md
================================================
You want to be here, trust me: [[SilverBullet]]
