* enhance(plugins): add block properties and block renderers with conditional rendering * chore(libs): bump version * enhance(docs): update Experiments API guide with detailed usage examples and best practices * enhance(ui): implement plugin renderer effects and enhance action bar visibility * enhance(plugin): improve router refresh mechanism for dynamic plugin routes * chore(libs): rename development notes and add SKILL.md for plugin SDK * enhance(plugin): enhance PluginLogger with structured log entries and UI for viewing logs * fix: lint * Update src/main/frontend/components/block.cljs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/main/frontend/handler/plugin.cljs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/main/frontend/handler/plugin.cljs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update libs/src/modules/LSPlugin.Experiments.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update src/main/frontend/handler/plugin.cljs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * enhance(plugin): add rich text interpolation to i18n translation * fix: lint * fix: lint * fix: remove unnecessary newlines and clean up code formatting * fix: lint * fix(libs): incorrect entry path for js mode * enhance(docs): add custom theme plugin guide and update SKILL.md * enhance(plugin): add 'open logs' option in multiple languages --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
19 KiB
Logseq Experiments API Guide
This guide covers the experimental APIs exposed as logseq.Experiments in the Logseq Plugin SDK.
These APIs are intentionally lower-level than the stable SDK. They let plugins:
- reuse host React/ReactDOM
- render internal Logseq components
- convert between JS and ClojureScript data structures
- load scripts dynamically
- register custom renderers for fenced code, routes, sidebars, properties, and block bodies
- hook internal extensions such as KaTeX
- access host/plugin internals when absolutely necessary
⚠️ Warning
Everything in
logseq.Experimentsis unstable. Signatures, render props, and behaviors may change without a normal deprecation window. Plugins using these APIs may be temporarily unsupported on the Marketplace.
Overview
The current experimental surface includes:
- React integration:
React,ReactDOM - Internal components:
Components.Editor - Interop utilities:
Utils.toClj,toJs,jsxToClj,toKeyword,toSymbol - Script loading:
loadScripts(...) - Renderer registration:
registerFencedCodeRenderer(...)registerDaemonRenderer(...)registerRouteRenderer(...)registerHostedRenderer(...)registerSidebarRenderer(...)registerBlockPropertiesRenderer(...)registerBlockRenderer(...)
- Extension enhancers:
registerExtensionsEnhancer(...) - Host/plugin internals:
pluginLocalensureHostScope()invokeExperMethod(...)
1. React Integration
Use the host's React runtime instead of bundling your own copy.
logseq.Experiments.React
Returns the React instance from the host scope.
const React = logseq.Experiments.React
logseq.Experiments.ReactDOM
Returns the ReactDOM instance from the host scope.
const ReactDOM = logseq.Experiments.ReactDOM
Example
const React = logseq.Experiments.React
const MyComponent = React.createElement(
'div',
{ className: 'my-plugin-card' },
'Hello from a host React tree'
)
2. Components
logseq.Experiments.Components.Editor
Renders Logseq page content using an internal page editor component.
Type
(props: { page: string } & Record<string, any>) => any
Parameters
page: page name to render
const Editor = logseq.Experiments.Components.Editor
const preview = Editor({ page: 'My Page Name' })
3. Utilities
logseq.Experiments.Utils exposes host interop helpers.
toClj(input: any)
Convert JavaScript data into ClojureScript data structures.
const cljData = logseq.Experiments.Utils.toClj({ key: 'value' })
jsxToClj(input: any)
Convert JS/JSX-style input to ClojureScript while preserving JSX-ish structures better than a plain conversion.
const view = { type: 'div', props: { children: 'Content' } }
const cljView = logseq.Experiments.Utils.jsxToClj(view)
toJs(input: any)
Convert ClojureScript values back into plain JavaScript.
const jsData = logseq.Experiments.Utils.toJs(cljData)
toKeyword(input: any)
Convert a string into a ClojureScript keyword.
const keyword = logseq.Experiments.Utils.toKeyword('my-key')
toSymbol(input: any)
Convert a string into a ClojureScript symbol.
const symbol = logseq.Experiments.Utils.toSymbol('my-symbol')
4. Script Loading
logseq.Experiments.loadScripts(...scripts: string[])
Dynamically load scripts into the host environment.
Parameters
scripts: HTTP(S) URLs or relative plugin resource paths
Returns
Promise<void>
Behavior
- relative paths are resolved against the current plugin resource root
- HTTP/HTTPS URLs are used as-is
- scripts are loaded in the given order
await logseq.Experiments.loadScripts(
'https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.umd.min.js'
)
await logseq.Experiments.loadScripts('./vendor/local-helper.js')
await logseq.Experiments.loadScripts(
'https://cdn.example.com/lib1.js',
'https://cdn.example.com/lib2.js',
'./local-script.js'
)
5. Custom Renderers
Experimental renderers are where most of the newer APIs live.
5.1 Fenced Code Renderer
Register a custom renderer for fenced code blocks such as:
```my-lang
...
```
logseq.Experiments.registerFencedCodeRenderer(lang, opts)
registerFencedCodeRenderer(
lang: string,
opts: {
edit?: boolean
before?: () => Promise<void>
subs?: string[]
render: (props: { content: string }) => any
}
): any
Options
edit: whether the fenced block remains editablebefore: async preload hook, usually for loading scripts/assetssubs: experimental subscription listrender: React renderer receiving{ content }
logseq.Experiments.registerFencedCodeRenderer('my-chart', {
edit: false,
before: async () => {
await logseq.Experiments.loadScripts(
'https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.umd.min.js'
)
},
render: ({ content }) => {
const React = logseq.Experiments.React
return React.createElement('canvas', {
ref: (canvas: HTMLCanvasElement | null) => {
if (!canvas || !window.Chart) return
try {
const config = JSON.parse(content)
new window.Chart(canvas, config)
} catch (error) {
console.error('Chart renderer error', error)
}
},
})
},
})
5.2 Daemon Renderer
Register a renderer that stays mounted in a global daemon container.
logseq.Experiments.registerDaemonRenderer(key, opts)
registerDaemonRenderer(
key: string,
opts: {
before?: () => Promise<void>
subs?: string[]
render: (props: {}) => any
}
): any
Notes
- use
subs, notsub beforeis supported by the host even if older typings may not show it yet- daemon renderers are useful for lightweight always-on UI, not large app shells
logseq.Experiments.registerDaemonRenderer('my-status-bar', {
subs: ['ui/theme'],
render: () => {
const React = logseq.Experiments.React
return React.createElement(
'div',
{
style: {
position: 'fixed',
right: 12,
bottom: 12,
padding: '6px 10px',
borderRadius: 8,
background: 'var(--ls-secondary-background-color)',
},
},
'Plugin active'
)
},
})
5.3 Route Renderer
Register a custom route view.
logseq.Experiments.registerRouteRenderer(key, opts)
registerRouteRenderer(
key: string,
opts: {
path: string
name?: string
subs?: string[]
render: (props: {}) => any
}
): any
Options
path: route path, e.g.'/my-plugin-dashboard'name: optional display name; if omitted, the internal key is reusedsubs: experimental subscription listrender: route component
logseq.Experiments.registerRouteRenderer('my-custom-page', {
path: '/my-plugin-dashboard',
name: 'Dashboard',
render: () => {
const React = logseq.Experiments.React
return React.createElement('div', { className: 'my-plugin-dashboard' }, [
React.createElement('h1', { key: 'title' }, 'Plugin Dashboard'),
React.createElement('p', { key: 'body' }, 'Custom content here'),
])
},
})
Route navigation is handled by Logseq's router. In docs and examples, prefer describing the registered
pathrather than relying on page navigation APIs, which are not the same thing.
5.4 Hosted Renderer
Low-level API for host-managed render targets.
Today, the main built-in consumer is the right sidebar, so most plugins should prefer registerSidebarRenderer(...) unless they specifically need the lower-level primitive.
logseq.Experiments.registerHostedRenderer(key, opts)
registerHostedRenderer(
key: string,
opts: {
title?: string
mode?: string
type?: string
subs?: string[]
render: (props: {}) => any
}
): any
Options
title: display title when the host surfaces the renderertype: host-specific placement typemode: host-specific placement modesubs: experimental subscription listrender: React renderer
The host currently passes the registered renderer record back into the render function in some placements. Treat that as implementation detail, not a stable contract.
5.5 Sidebar Renderer
Convenience wrapper over registerHostedRenderer(...) for right-sidebar tools.
logseq.Experiments.registerSidebarRenderer(key, opts)
registerSidebarRenderer(
key: string,
opts: {
title?: string
subs?: string[]
render: (props: {}) => any
[key: string]: any
}
): any
Behavior
- your key is automatically namespaced internally as
_sidebar.${key} typeis forced to'sidebar'- the renderer appears in the right-sidebar plugin menu
logseq.Experiments.registerSidebarRenderer('inspector', {
title: 'Inspector',
render: () => {
const React = logseq.Experiments.React
return React.createElement('div', null, 'Hello from the sidebar renderer')
},
})
5.6 Block Properties Renderer
Render custom UI inside a block's properties area.
logseq.Experiments.registerBlockPropertiesRenderer(key, opts)
type BlockPropertiesCondition =
| { has: string }
| { equals: [string, any] }
| { in: [string, any[]] }
| { not: BlockPropertiesCondition }
| { any: BlockPropertiesCondition[] }
| { all: BlockPropertiesCondition[] }
type BlockPropertiesRendererProps = {
blockId: string
properties: Record<string, any>
}
registerBlockPropertiesRenderer(
key: string,
opts: {
when?: BlockPropertiesCondition | ((props: BlockPropertiesRendererProps) => boolean)
mode?: 'prepend' | 'append' | 'replace'
priority?: number
subs?: string[]
render: (props: BlockPropertiesRendererProps) => any
}
): any
Behavior
whenmay be omitted, a declarative condition, or a synchronous predicatemodecontrols placement in the properties area:prepend: before native propertiesappend: after native propertiesreplace: replace native properties UI
- higher
prioritywins for conflicts - for
replace, the highest-priority matching replace renderer wins - for
prepend/append, all matching renderers are rendered in priority order
Render props
blockId: block UUID stringproperties: plain JS object keyed by property names without the leading:
Property serialization details
Before data is passed into plugins, Logseq normalizes some values:
- keywords become strings like
'logseq.property/status' - UUIDs become strings
- entity references become small objects such as
{ uuid, title } - sets / collections of entity references become arrays of those objects
logseq.Experiments.registerBlockPropertiesRenderer('priority-pill', {
when: { has: 'priority' },
mode: 'prepend',
priority: 10,
render: ({ properties }) => {
const React = logseq.Experiments.React
const priority = properties.priority
if (!priority) return null
return React.createElement(
'span',
{
style: {
display: 'inline-flex',
marginRight: 8,
padding: '2px 8px',
borderRadius: 9999,
fontSize: 12,
background: 'var(--ls-tertiary-background-color)',
},
},
`Priority: ${priority}`
)
},
})
5.7 Block Renderer
Replace a block's main outline body with plugin UI.
logseq.Experiments.registerBlockRenderer(key, opts)
type BlockRendererChild = Record<string, any> & {
children?: BlockRendererChild[]
}
type BlockRendererProps = {
blockId: string
properties: Record<string, any>
uuid?: string
page?: string
content?: string
format?: string
children?: BlockRendererChild[]
}
registerBlockRenderer(
key: string,
opts: {
when?: (props: BlockRendererProps) => boolean
includeChildren?: boolean
priority?: number
subs?: string[]
render: (props: BlockRendererProps) => any
}
): any
Behavior
whenmust be a synchronous predicate function if provided- declarative conditions are not supported here
- highest
prioritymatch wins - when the plugin renderer is active, users can switch back to the native outline view via built-in UI on that block
- when
includeChildrenistrue, Logseq passes a recursive child tree and hides native outline children while the plugin renderer is active
Render props
blockId: block UUID stringuuid: same block UUIDpage: page titlecontent: block content/title textformat:'markdown','org', etc.properties: normalized property objectchildren: recursive normalized child tree whenincludeChildrenis enabled
logseq.Experiments.registerBlockRenderer('kanban-card', {
when: ({ properties }) => properties.view === 'kanban-card',
includeChildren: true,
priority: 20,
render: ({ content, children = [] }) => {
const React = logseq.Experiments.React
return React.createElement('section', { className: 'my-kanban-card' }, [
React.createElement('h3', { key: 'title' }, content || 'Untitled'),
React.createElement(
'ul',
{ key: 'children' },
children.map((child, index) =>
React.createElement('li', { key: child.uuid || index }, child.title || child.content)
)
),
])
},
})
6. Extension Enhancers
logseq.Experiments.registerExtensionsEnhancer(type, enhancer)
Enhance host libraries such as KaTeX.
registerExtensionsEnhancer(
type: 'katex' | 'codemirror',
enhancer: (value: any) => Promise<any>
): any
For katex, the host immediately invokes the enhancer if KaTeX is already present.
logseq.Experiments.registerExtensionsEnhancer('katex', async (katex) => {
katex.macros = {
...katex.macros,
'\\RR': '\\mathbb{R}',
'\\NN': '\\mathbb{N}',
'\\ZZ': '\\mathbb{Z}',
}
})
7. Host / Plugin Internals
7.1 logseq.Experiments.pluginLocal
Returns the internal PluginLocal instance for the current plugin.
const pluginLocal = logseq.Experiments.pluginLocal
console.log(pluginLocal.id)
Use this sparingly. It is intentionally internal.
7.2 logseq.Experiments.ensureHostScope()
Returns the host scope, currently window.top, after attempting an access check.
const host = logseq.Experiments.ensureHostScope()
This is mostly useful when you need direct access to host globals and understand the risks.
7.3 logseq.Experiments.invokeExperMethod(type, ...args)
Direct escape hatch for calling experimental host methods.
const result = logseq.Experiments.invokeExperMethod(
'someExperimentalFeature',
arg1,
arg2
)
type is normalized to snake_case before resolution.
8. Complete Example: Fenced Code Renderer
import '@logseq/libs'
async function main() {
logseq.Experiments.registerFencedCodeRenderer('chart', {
edit: false,
before: async () => {
await logseq.Experiments.loadScripts(
'https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.umd.min.js'
)
},
render: ({ content }) => {
const React = logseq.Experiments.React
return React.createElement('canvas', {
ref: (canvas: HTMLCanvasElement | null) => {
if (!canvas || !window.Chart) return
try {
const config = JSON.parse(content)
new window.Chart(canvas, config)
} catch (error) {
console.error('Chart rendering error:', error)
}
},
})
},
})
}
logseq.ready(main).catch(console.error)
Usage
```chart
{
"type": "line",
"data": {
"labels": ["Jan", "Feb", "Mar", "Apr"],
"datasets": [{
"label": "Sales",
"data": [10, 20, 15, 30],
"borderColor": "rgb(75, 192, 192)"
}]
}
}
```
9. Complete Example: Block Properties Badge
import '@logseq/libs'
async function main() {
logseq.Experiments.registerBlockPropertiesRenderer('task-status-chip', {
when: {
any: [
{ equals: ['status', 'todo'] },
{ equals: ['status', 'doing'] },
],
},
mode: 'append',
priority: 5,
render: ({ properties }) => {
const React = logseq.Experiments.React
const value = properties.status
return React.createElement(
'span',
{
style: {
marginLeft: 8,
padding: '2px 8px',
borderRadius: 9999,
fontSize: 12,
background: 'var(--ls-secondary-background-color)',
},
},
`Status: ${value}`
)
},
})
}
logseq.ready(main).catch(console.error)
10. Best Practices
- Prefer stable APIs first. Only use
Experimentswhen the stable SDK cannot solve the problem. - Use host React. Avoid bundling a second React runtime into the same tree.
- Keep
whenpredicates synchronous. This is especially important forregisterBlockRenderer(...). - Use
beforeto preload dependencies instead of doing ad hoc script injection inside render. - Treat
subsas experimental. Reactive semantics may change. - Keep renderers lightweight. Block and daemon renderers can affect overall app responsiveness.
- Handle bad input defensively. Render props often contain user-authored content and properties.
- Document your experimental usage in the plugin README so users understand the risk.
- Prefer
registerSidebarRenderer(...)over raw hosted renderers when your goal is a right-sidebar tool. - Test against real graphs. Property values, references, and child trees can vary a lot.
11. Limitations and Notes
- Experimental status: no stability guarantee
- Marketplace support: may be restricted temporarily
- Security: be careful with external scripts and direct host access
- Performance: custom renderers run inside the app UI, so poor implementations are noticeable
- Typings may lag behavior: some newer runtime options can land before every generated wrapper/type is refreshed
ClojureScript SDK note
The generated ClojureScript wrapper namespace com.logseq.experiments currently includes wrappers for:
load-scriptsregister-fenced-code-rendererregister-daemon-rendererregister-hosted-rendererregister-sidebar-rendererregister-route-rendererregister-extensions-enhancer
At the time of writing, register-block-properties-renderer and register-block-renderer are not yet present in that generated wrapper, so ClojureScript plugins may need to call them via invoke-exper-method until the wrapper is regenerated.
See Also
- Starter Guide - getting started with plugin development
- DB Properties Guide - working with database properties
- DB Query Guide - querying the Logseq database
Support
For questions and issues:
- Logseq Discord -
#plugin-dev - GitHub Discussions
- Plugin API Documentation
Remember: these are experimental features. Use them carefully and test thoroughly.