Initial import of LogDarioSeq baseline
Establishes version control for the personal Logseq DB fork before starting the server-client architecture migration and domain features described in docs/agent-guide and the approved implementation plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
628
libs/guides/AGENTS.md
Normal file
628
libs/guides/AGENTS.md
Normal file
@@ -0,0 +1,628 @@
|
||||
# AGENTS.md - Logseq Plugin Development Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This document provides guidance for AI agents (Copilot, coding assistants, etc.) to help develop Logseq plugins using
|
||||
the `@logseq/libs` SDK.
|
||||
|
||||
Logseq is a privacy-first, open-source knowledge management and note-taking application built on top of local plain-text
|
||||
Markdown and Org-mode files. The plugin system extends Logseq's functionality through a JavaScript/TypeScript SDK.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
### Core SDK (`logseq/logseq` - `libs/` folder)
|
||||
|
||||
The official SDK source code is located in the `libs/` directory of the main Logseq repository:
|
||||
|
||||
```
|
||||
libs/
|
||||
├── src/
|
||||
│ ├── LSPlugin.ts # Main type definitions & interfaces
|
||||
│ ├── LSPlugin.user.ts # User-facing plugin API implementation
|
||||
│ ├── LSPlugin.core.ts # Core plugin system
|
||||
│ ├── LSPlugin. caller.ts # IPC message caller
|
||||
│ ├── LSPlugin.shadow.ts # Shadow DOM support
|
||||
│ ├── helpers.ts # Utility functions
|
||||
│ ├── modules/ # Additional modules (Experiments, Storage, Request)
|
||||
│ └── postmate/ # Cross-frame communication
|
||||
├── package.json # NPM package config (@logseq/libs)
|
||||
├── README.md # SDK documentation
|
||||
└── CHANGELOG.md # Version history
|
||||
```
|
||||
|
||||
### Plugin Samples (`logseq/logseq-plugin-samples`)
|
||||
|
||||
Official sample plugins demonstrating various SDK features:
|
||||
|
||||
| Sample | Description | Key APIs |
|
||||
|-------------------------------|----------------------------------|-------------------------------------------------|
|
||||
| `logseq-slash-commands` | Basic slash command registration | `Editor.registerSlashCommand` |
|
||||
| `logseq-pomodoro-timer` | Macro renderer & slot UI | `App.onMacroRendererSlotted`, `provideUI` |
|
||||
| `logseq-a-translator` | Selection hooks & float UI | `Editor.onInputSelectionEnd`, `provideUI` |
|
||||
| `logseq-awesome-fonts` | Custom styles & settings | `provideStyle`, `useSettingsSchema` |
|
||||
| `logseq-bujo-themes` | Custom theme development | `provideTheme` |
|
||||
| `logseq-journals-calendar` | Calendar integration | `App.getCurrentGraph`, `Editor.getPage` |
|
||||
| `logseq-emoji-picker` | UI overlay with 3rd party libs | `showMainUI`, `hideMainUI` |
|
||||
| `logseq-reddit-hot-news` | Batch block insertion | `Editor.insertBatchBlock`, `App.registerUIItem` |
|
||||
| `logseq-imdb-top250-importer` | DB graph data import | DB-specific APIs |
|
||||
|
||||
## Plugin Architecture
|
||||
|
||||
### Entry Point Pattern
|
||||
|
||||
Every Logseq plugin follows this basic pattern:
|
||||
|
||||
```typescript
|
||||
import '@logseq/libs'
|
||||
|
||||
async function main() {
|
||||
// Plugin initialization code
|
||||
console.log('Plugin loaded!')
|
||||
}
|
||||
|
||||
// Bootstrap
|
||||
logseq.ready(main).catch(console.error)
|
||||
```
|
||||
|
||||
### Plugin Modes
|
||||
|
||||
Plugins can run in two modes defined in `package.json`:
|
||||
|
||||
- **`iframe`** (default): Plugin runs in an isolated iframe sandbox
|
||||
- **`shadow`** (still draft): Plugin runs in a Shadow DOM container (faster, less isolation)
|
||||
|
||||
> **⚠️ Sandbox Environment:** All Logseq plugins run in an isolated iframe sandbox.
|
||||
> This provides security isolation between plugins and the
|
||||
> main application. Communication between the plugin iframe and
|
||||
> Logseq happens through a postMessage-based RPC mechanism.
|
||||
|
||||
### package.json Configuration
|
||||
|
||||
## Package. json Configuration
|
||||
|
||||
### Basic Structure
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "logseq-my-plugin",
|
||||
"version": "0.1.0",
|
||||
"description": "A brief description of your plugin",
|
||||
"author": "Your Name",
|
||||
"license": "MIT",
|
||||
"main": "dist/index.html",
|
||||
"scripts": {
|
||||
"dev": "parcel ./index.html --public-url ./",
|
||||
"build": "parcel build --public-url . --no-source-maps index.html"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@logseq/libs": "^0.0.17",
|
||||
"parcel": "^2.0.0"
|
||||
},
|
||||
"logseq": {
|
||||
"id": "my-unique-plugin-id",
|
||||
"main": "dist/index.html",
|
||||
"icon": "./icon.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Logseq Configuration Block
|
||||
|
||||
The `logseq` field in `package.json` defines plugin metadata and behavior:
|
||||
|
||||
```json
|
||||
{
|
||||
"logseq": {
|
||||
"id": "my-unique-plugin-id",
|
||||
"main": "dist/index.html",
|
||||
"icon": "./icon. png",
|
||||
"title": "My Plugin Display Name",
|
||||
"effect": true,
|
||||
"themes": [
|
||||
{
|
||||
"name": "My Dark Theme",
|
||||
"url": "./css/dark.css",
|
||||
"mode": "dark",
|
||||
"description": "A beautiful dark theme"
|
||||
},
|
||||
{
|
||||
"name": "My Light Theme",
|
||||
"url": "./css/light.css",
|
||||
"mode": "light",
|
||||
"description": "A clean light theme"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Fields
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|------------|-----------|----------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `id` | `string` | Yes | Unique plugin identifier. Must be unique across all plugins. Use lowercase with hyphens. |
|
||||
| `main` | `string` | Yes | Entry point HTML file path (relative to package.json). |
|
||||
| `entry` | `string` | No | Alias for `main`. |
|
||||
| `icon` | `string` | No | Plugin icon path. Displayed in plugin marketplace and settings. |
|
||||
| `title` | `string` | No | Display name shown in UI. Falls back to package `name` if not set. |
|
||||
| `effect` | `boolean` | No | When `true`, the plugin runs as a background effect without user interaction. Useful for plugins that only inject styles/themes or run automated tasks. Default: `false`. |
|
||||
| `themes` | `Theme[]` | No | Array of theme definitions this plugin provides. |
|
||||
| `devEntry` | `string` | No | Alternative entry point for development (e.g., with HMR). |
|
||||
|
||||
### Theme Configuration
|
||||
|
||||
For theme-only plugins, define themes in the `themes` array:
|
||||
|
||||
```json
|
||||
{
|
||||
"logseq": {
|
||||
"id": "my-theme-pack",
|
||||
"main": "dist/index.html",
|
||||
"effect": true,
|
||||
"themes": [
|
||||
{
|
||||
"name": "Nord Dark",
|
||||
"url": "./themes/nord-dark.css",
|
||||
"mode": "dark",
|
||||
"description": "Nord-inspired dark color scheme"
|
||||
},
|
||||
{
|
||||
"name": "Nord Light",
|
||||
"url": "./themes/nord-light.css",
|
||||
"mode": "light",
|
||||
"description": "Nord-inspired light color scheme"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Theme Object Fields:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|---------------|----------|----------|--------------------------------------------------|
|
||||
| `name` | `string` | Yes | Theme display name shown in theme selector. |
|
||||
| `url` | `string` | Yes | Path to the CSS file (relative to package.json). |
|
||||
| `mode` | `light` | `dark` | Yes | Theme mode. Determines when theme appears in selector. |
|
||||
| `description` | `string` | No | Brief description of the theme. |
|
||||
|
||||
## Core API Namespaces
|
||||
|
||||
The SDK exposes the following primary namespaces through the global `logseq` object:
|
||||
|
||||
### `logseq. App` - Application APIs
|
||||
|
||||
Application-level operations and hooks:
|
||||
|
||||
```typescript
|
||||
// Get app/user information
|
||||
await logseq.App.getInfo()
|
||||
await logseq.App.getUserConfigs()
|
||||
await logseq.App.getCurrentGraph()
|
||||
|
||||
// Graph operations
|
||||
await logseq.App.getCurrentGraphFavorites()
|
||||
await logseq.App.getCurrentGraphRecent()
|
||||
|
||||
// Navigation
|
||||
logseq.App.pushState('page', { name: 'my-page' })
|
||||
|
||||
// UI Registration
|
||||
logseq.App.registerUIItem('toolbar', {
|
||||
key: 'my-button',
|
||||
template: `<a data-on-click="myHandler">Click</a>`
|
||||
})
|
||||
|
||||
// Command Registration
|
||||
logseq.App.registerCommandPalette({
|
||||
key: 'my-command',
|
||||
label: 'My Command',
|
||||
keybinding: { binding: 'mod+shift+m' }
|
||||
}, () => { /* action */ })
|
||||
|
||||
// Event Hooks
|
||||
logseq.App.onCurrentGraphChanged(() => {})
|
||||
logseq.App.onThemeModeChanged(({ mode }) => {})
|
||||
logseq.App.onRouteChanged(({ path }) => {})
|
||||
logseq.App.onMacroRendererSlotted(({ slot, payload }) => {})
|
||||
```
|
||||
|
||||
### `logseq.Editor` - Editor APIs
|
||||
|
||||
Block and page manipulation:
|
||||
|
||||
```typescript
|
||||
// Slash Commands
|
||||
logseq.Editor.registerSlashCommand('My Command', async ({ uuid }) => {
|
||||
await logseq.Editor.insertAtEditingCursor('Hello!')
|
||||
})
|
||||
|
||||
// Block Context Menu
|
||||
logseq.Editor.registerBlockContextMenuItem('My Action', async ({ uuid }) => {
|
||||
const block = await logseq.Editor.getBlock(uuid)
|
||||
})
|
||||
|
||||
// Block Operations
|
||||
await logseq.Editor.getBlock(uuid)
|
||||
await logseq.Editor.insertBlock(uuid, 'content', { sibling: true })
|
||||
await logseq.Editor.updateBlock(uuid, 'new content')
|
||||
await logseq.Editor.removeBlock(uuid)
|
||||
await logseq.Editor.insertBatchBlock(uuid, [
|
||||
{ content: 'Block 1', children: [{ content: 'Child' }] }
|
||||
])
|
||||
|
||||
// Page Operations
|
||||
await logseq.Editor.getPage('page-name')
|
||||
await logseq.Editor.createPage('new-page', { prop: 'value' })
|
||||
await logseq.Editor.deletePage('page-name')
|
||||
await logseq.Editor.getCurrentPageBlocksTree()
|
||||
|
||||
// Cursor & Selection
|
||||
await logseq.Editor.checkEditing()
|
||||
await logseq.Editor.getEditingCursorPosition()
|
||||
await logseq.Editor.insertAtEditingCursor('text')
|
||||
await logseq.Editor.exitEditingMode()
|
||||
|
||||
// Properties (DB graphs)
|
||||
await logseq.Editor.upsertBlockProperty(uuid, 'key', 'value')
|
||||
await logseq.Editor.getBlockProperties(uuid)
|
||||
```
|
||||
|
||||
### `logseq.DB` - Database APIs
|
||||
|
||||
Query and data operations:
|
||||
|
||||
```typescript
|
||||
// DSL Query (Logseq query language)
|
||||
const results = await logseq.DB.q('[[my-page]]')
|
||||
|
||||
// Datascript Query
|
||||
const results1 = await logseq.DB.datascriptQuery(`
|
||||
[: find (pull ?b [*])
|
||||
:where [?b :block/marker "TODO"]]
|
||||
`)
|
||||
|
||||
// Watch for changes
|
||||
logseq.DB.onChanged(({ blocks, txData }) => {
|
||||
console.log('Database changed:', blocks)
|
||||
})
|
||||
|
||||
// Watch specific block
|
||||
logseq.DB.onBlockChanged(uuid, (block, txData) => {
|
||||
console.log('Block changed:', block)
|
||||
})
|
||||
|
||||
// File operations
|
||||
await logseq.DB.getFileContent('logseq/custom.css')
|
||||
await logseq.DB.setFileContent('logseq/custom.js', 'console.log("hi")')
|
||||
```
|
||||
|
||||
### `logseq.UI` - UI Utilities
|
||||
|
||||
```typescript
|
||||
// Toast messages
|
||||
await logseq.UI.showMsg('Success! ', 'success')
|
||||
await logseq.UI.showMsg('Warning!', 'warning', { timeout: 5000 })
|
||||
logseq.UI.closeMsg(key)
|
||||
|
||||
// DOM queries
|
||||
const rect = await logseq.UI.queryElementRect('.my-selector')
|
||||
const exists = await logseq.UI.queryElementById('my-id')
|
||||
```
|
||||
|
||||
### `logseq.Git` - Git Operations
|
||||
Only supported in file-based graphs for desktop Logseq.
|
||||
|
||||
```typescript
|
||||
const result = await logseq.Git.execCommand(['status'])
|
||||
const ignoreContent = await logseq.Git.loadIgnoreFile()
|
||||
await logseq.Git.saveIgnoreFile('. DS_Store\nnode_modules')
|
||||
```
|
||||
|
||||
### `logseq.Assets` - Asset Management
|
||||
|
||||
```typescript
|
||||
const files = await logseq.Assets.listFilesOfCurrentGraph(['png', 'jpg'])
|
||||
const storage = logseq.Assets.makeSandboxStorage()
|
||||
const url = await logseq.Assets.makeUrl('path/to/file')
|
||||
await logseq.Assets.builtInOpen('path/to/asset. pdf')
|
||||
```
|
||||
|
||||
## UI Injection Patterns
|
||||
|
||||
### provideUI - Inject Custom UI
|
||||
|
||||
```typescript
|
||||
logseq.provideUI({
|
||||
key: 'my-ui',
|
||||
path: '#my-target', // or use `slot` for macro renderers
|
||||
template: `<div data-on-click="myHandler">Click me</div>`,
|
||||
style: { backgroundColor: 'white', padding: '10px' },
|
||||
attrs: { title: 'My UI' },
|
||||
close: 'outside', // close when clicking outside
|
||||
})
|
||||
```
|
||||
|
||||
### provideStyle - Inject CSS
|
||||
|
||||
```typescript
|
||||
logseq.provideStyle(`
|
||||
.my-class {
|
||||
color: var(--ls-primary-text-color);
|
||||
background: var(--ls-primary-background-color);
|
||||
}
|
||||
`)
|
||||
|
||||
// Or with a key for updates
|
||||
logseq.provideStyle({
|
||||
key: 'my-styles',
|
||||
style: `.my-class { color: red; }`
|
||||
})
|
||||
```
|
||||
|
||||
### provideModel - Event Handlers
|
||||
|
||||
```typescript
|
||||
logseq.provideModel({
|
||||
myHandler(e) {
|
||||
console.log('Clicked!', e.dataset)
|
||||
},
|
||||
async asyncHandler(e) {
|
||||
const block = await logseq.Editor.getBlock(e.dataset.blockUuid)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### Main UI (Full-screen Overlay)
|
||||
|
||||
```typescript
|
||||
// Show plugin's main UI
|
||||
logseq.showMainUI({ autoFocus: true })
|
||||
|
||||
// Hide it
|
||||
logseq.hideMainUI({ restoreEditingCursor: true })
|
||||
|
||||
// Toggle
|
||||
logseq.toggleMainUI()
|
||||
|
||||
// Style the main UI container
|
||||
logseq.setMainUIInlineStyle({
|
||||
position: 'fixed',
|
||||
top: '100px',
|
||||
left: '50%',
|
||||
zIndex: 11,
|
||||
})
|
||||
```
|
||||
|
||||
## Settings Schema
|
||||
|
||||
Define user-configurable settings:
|
||||
|
||||
```typescript
|
||||
logseq.useSettingsSchema([
|
||||
{
|
||||
key: 'apiKey',
|
||||
type: 'string',
|
||||
default: '',
|
||||
title: 'API Key',
|
||||
description: 'Enter your API key'
|
||||
},
|
||||
{
|
||||
key: 'enableFeature',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
title: 'Enable Feature',
|
||||
description: 'Toggle this feature on/off'
|
||||
},
|
||||
{
|
||||
key: 'theme',
|
||||
type: 'enum',
|
||||
enumChoices: ['light', 'dark', 'auto'],
|
||||
enumPicker: 'select',
|
||||
default: 'auto',
|
||||
title: 'Theme',
|
||||
description: 'Select theme mode'
|
||||
},
|
||||
{
|
||||
key: 'fontSize',
|
||||
type: 'number',
|
||||
default: 14,
|
||||
title: 'Font Size',
|
||||
description: 'Customize font size',
|
||||
inputAs: 'range'
|
||||
}
|
||||
])
|
||||
|
||||
// Access settings
|
||||
const apiKey = logseq.settings?.apiKey
|
||||
|
||||
// Update settings
|
||||
logseq.updateSettings({ apiKey: 'new-key' })
|
||||
|
||||
// Listen for changes
|
||||
logseq.onSettingsChanged((newSettings, oldSettings) => {
|
||||
console.log('Settings changed:', newSettings)
|
||||
})
|
||||
```
|
||||
|
||||
## Macro Renderer Pattern
|
||||
|
||||
Create custom block renderers using `{{renderer : type, arg1, arg2}}`:
|
||||
|
||||
```typescript
|
||||
logseq.App.onMacroRendererSlotted(({ slot, payload }) => {
|
||||
const [type, ...args] = payload.arguments
|
||||
|
||||
if (type !== ':my-renderer') return
|
||||
|
||||
logseq.provideUI({
|
||||
key: `my-renderer-${payload.uuid}`,
|
||||
slot,
|
||||
template: `
|
||||
<div class="my-renderer">
|
||||
<span>${args.join(', ')}</span>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
})
|
||||
|
||||
// Register slash command to insert the macro
|
||||
logseq.Editor.registerSlashCommand('Insert My Renderer', async () => {
|
||||
await logseq.Editor.insertAtEditingCursor(
|
||||
`{{renderer :my-renderer, arg1, arg2}}`
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
## Key Types Reference
|
||||
|
||||
### Block Entity
|
||||
|
||||
```typescript
|
||||
interface BlockEntity {
|
||||
id: number // Database entity ID
|
||||
uuid: string // Block UUID
|
||||
title: string // Block content
|
||||
content?: string // @deprecated, use title
|
||||
format: 'markdown' | 'org'
|
||||
parent: { id: number }
|
||||
page: { id: number }
|
||||
properties?: Record<string, any>
|
||||
children?: BlockEntity[]
|
||||
'collapsed? ': boolean
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
```
|
||||
|
||||
### Page Entity
|
||||
|
||||
```typescript
|
||||
interface PageEntity {
|
||||
id: number
|
||||
uuid: string
|
||||
name: string
|
||||
type: 'page' | 'journal' | 'whiteboard' | 'class' | 'property' | 'hidden'
|
||||
format: 'markdown' | 'org'
|
||||
'journal? ': boolean
|
||||
journalDay?: number // YYYYMMDD format for journals
|
||||
properties?: Record<string, any>
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Clone sample repository
|
||||
git clone https://github.com/logseq/logseq-plugin-samples
|
||||
cd logseq-plugin-samples/logseq-slash-commands
|
||||
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Build
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### Loading in Logseq
|
||||
|
||||
1. Open Logseq Desktop
|
||||
2. Enable **Developer mode** in Settings
|
||||
3. Press `t p` to open Plugins dashboard
|
||||
4. Click **Load unpacked plugin**
|
||||
5. Select your plugin directory
|
||||
|
||||
### Debugging
|
||||
|
||||
- Use browser DevTools (Ctrl/Cmd + Shift + I)
|
||||
- Check console for `@logseq/libs` debug output
|
||||
- Use `logseq.UI.showMsg()` for quick debugging
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Performance
|
||||
|
||||
1. **Debounce frequent operations** - especially DB queries and UI updates
|
||||
2. **Use `onBlockChanged` sparingly** - it fires on every edit
|
||||
3. **Batch block operations** - use `insertBatchBlock` instead of multiple `insertBlock` calls
|
||||
4. **Clean up listeners** - store and call off-hooks in `beforeunload`
|
||||
|
||||
```typescript
|
||||
const offHooks: (() => void)[] = []
|
||||
|
||||
offHooks.push(
|
||||
logseq.DB.onChanged(() => { /* ... */ })
|
||||
)
|
||||
|
||||
logseq.beforeunload(async () => {
|
||||
offHooks.forEach(off => off())
|
||||
})
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const block = await logseq.Editor.getBlock(uuid)
|
||||
if (!block) {
|
||||
await logseq.UI.showMsg('Block not found', 'warning')
|
||||
return
|
||||
}
|
||||
// ... proceed
|
||||
} catch (error) {
|
||||
console.error('Plugin error:', error)
|
||||
await logseq.UI.showMsg('An error occurred', 'error')
|
||||
}
|
||||
```
|
||||
|
||||
### CSS Variables
|
||||
|
||||
Use Logseq's CSS custom properties for consistent theming:
|
||||
|
||||
```css
|
||||
.my-plugin-ui {
|
||||
color: var(--ls-primary-text-color);
|
||||
background: var(--ls-primary-background-color);
|
||||
border: 1px solid var(--ls-border-color);
|
||||
}
|
||||
```
|
||||
|
||||
### DB vs File Graphs
|
||||
|
||||
Check graph type before using DB-specific features:
|
||||
|
||||
```typescript
|
||||
const isDbGraph = await logseq.App.checkCurrentIsDbGraph()
|
||||
if (isDbGraph) {
|
||||
// Use DB-specific APIs
|
||||
await logseq.Editor.upsertProperty('my-prop', { type: 'string' })
|
||||
}
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- **API Documentation**: https://plugins-doc.logseq.com
|
||||
- **Plugin Samples**: https://github.com/logseq/logseq-plugin-samples
|
||||
- **SDK Source**: https://github.com/logseq/logseq/tree/master/libs
|
||||
- **NPM Package**: https://www.npmjs.com/package/@logseq/libs
|
||||
- **Discord Community**: https://discord.gg/KpN4eHY
|
||||
- **Community Templates**:
|
||||
- ClojureScript: https://github.com/logseq/cljs-plugin-example
|
||||
- TypeScript: https://github.com/YU000jp/logseq-plugin-sample-kit-typescript
|
||||
|
||||
## Version Compatibility
|
||||
|
||||
Always check the minimum Logseq version required for specific APIs. Keep `@logseq/libs` updated to the latest version
|
||||
for best compatibility and performance.
|
||||
|
||||
```bash
|
||||
# Check current version
|
||||
npm info @logseq/libs version
|
||||
|
||||
# Update
|
||||
npm update @logseq/libs
|
||||
```
|
||||
400
libs/guides/commands_api_guide.md
Normal file
400
libs/guides/commands_api_guide.md
Normal file
@@ -0,0 +1,400 @@
|
||||
# `logseq.Commands` API Guide
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import '@logseq/libs'
|
||||
|
||||
async function main() {
|
||||
const unregister = logseq.Commands.register(
|
||||
'insert-hello',
|
||||
{
|
||||
title: 'Insert hello',
|
||||
placement: 'slash',
|
||||
},
|
||||
async () => {
|
||||
await logseq.Editor.insertAtEditingCursor('Hello from command!')
|
||||
}
|
||||
)
|
||||
|
||||
logseq.beforeunload(async () => {
|
||||
unregister && unregister()
|
||||
})
|
||||
}
|
||||
|
||||
logseq.ready(main).catch(console.error)
|
||||
```
|
||||
|
||||
## API surface
|
||||
|
||||
### `Commands.register(id, options, action?)`
|
||||
|
||||
Register a command and return an unregister handler.
|
||||
|
||||
```ts
|
||||
const unregister = logseq.Commands.register(id, options, action)
|
||||
```
|
||||
|
||||
The same command can also put its handler in `options.handler`:
|
||||
|
||||
```ts
|
||||
const unregister = logseq.Commands.register('my-command', {
|
||||
title: 'My command',
|
||||
placement: 'palette',
|
||||
handler: async () => {
|
||||
await logseq.UI.showMsg('Run')
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
Return value:
|
||||
|
||||
- `() => void`: unregister handler.
|
||||
- `false`: registration failed, usually because handler/action is missing or invalid.
|
||||
|
||||
The unregister handler is idempotent. Calling it multiple times is safe.
|
||||
|
||||
```ts
|
||||
const unregister = logseq.Commands.register('temporary-command', {
|
||||
title: 'Temporary command',
|
||||
placement: 'palette',
|
||||
handler: () => console.log('run'),
|
||||
})
|
||||
|
||||
unregister && unregister()
|
||||
unregister && unregister() // safe no-op
|
||||
```
|
||||
|
||||
### `Commands.execute(id, ...args)`
|
||||
|
||||
Execute a built-in or plugin command.
|
||||
|
||||
```ts
|
||||
await logseq.Commands.execute('logseq.go/home')
|
||||
await logseq.Commands.execute('my-plugin/some-command')
|
||||
await logseq.Commands.execute('my-plugin.commands.some-command')
|
||||
await logseq.Commands.execute('local-command')
|
||||
```
|
||||
|
||||
Supported id forms:
|
||||
|
||||
| Form | Meaning |
|
||||
| ------------------------ | ----------------------------------------------------------- |
|
||||
| `logseq.*` | Built-in command, forwarded to `App.invokeExternalCommand`. |
|
||||
| `plugin-id/key` | Command registered by another plugin. |
|
||||
| `plugin-id.commands.key` | Backward-compatible plugin command address. |
|
||||
| `key` | Command registered by the current plugin. |
|
||||
|
||||
Plugin command keys are normalized to match host registration rules:
|
||||
|
||||
- trim whitespace;
|
||||
- whitespace becomes `_`;
|
||||
- lower-case;
|
||||
- `:` becomes `-`;
|
||||
- numeric first character becomes `_1`, `_2`, etc.
|
||||
|
||||
## Register options
|
||||
|
||||
```ts
|
||||
type CommandRegisterOptions = {
|
||||
key?: string
|
||||
title?: string
|
||||
label?: string
|
||||
desc?: string
|
||||
handler?: CommandCallback | BlockCommandCallback | Array<SlashCommandAction>
|
||||
when?: string | string[]
|
||||
placement?: CommandPlacement
|
||||
placements?: CommandPlacement[]
|
||||
keybinding?: SimpleCommandKeybinding | string
|
||||
extras?: Record<string, any>
|
||||
type?: string
|
||||
palette?: boolean
|
||||
}
|
||||
```
|
||||
|
||||
Important fields:
|
||||
|
||||
- `key`: stable command key. Defaults to `id`.
|
||||
- `title`: display title. `label` is also accepted for compatibility.
|
||||
- `desc`: command palette description. Defaults to title/label in host palette rendering.
|
||||
- `handler`: command callback. Can be passed here or as third argument.
|
||||
- `placement`: single UI surface.
|
||||
- `placements`: multiple UI surfaces for one logical command.
|
||||
- `keybinding`: string or `{ mode, binding, mac }` shortcut descriptor.
|
||||
- `when`: v1 stores this as metadata for future host-side condition evaluation.
|
||||
- `extras`: placement-specific metadata, e.g. PDF highlight menu options.
|
||||
|
||||
## Placements
|
||||
|
||||
```ts
|
||||
type CommandPlacement =
|
||||
| 'palette'
|
||||
| 'shortcut'
|
||||
| 'slash'
|
||||
| 'block-context-menu'
|
||||
| 'highlight-context-menu'
|
||||
| 'page-menu'
|
||||
| 'simple'
|
||||
```
|
||||
|
||||
### `palette`
|
||||
|
||||
Register a command palette item.
|
||||
|
||||
```ts
|
||||
const unregister = logseq.Commands.register(
|
||||
'open-dashboard',
|
||||
{
|
||||
title: 'Open dashboard',
|
||||
placement: 'palette',
|
||||
},
|
||||
async () => {
|
||||
logseq.showMainUI()
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### `shortcut`
|
||||
|
||||
Register a keyboard shortcut without necessarily showing it in the command palette.
|
||||
|
||||
```ts
|
||||
const unregister = logseq.Commands.register(
|
||||
'toggle-panel',
|
||||
{
|
||||
title: 'Toggle panel',
|
||||
placement: 'shortcut',
|
||||
keybinding: {
|
||||
mode: 'global',
|
||||
binding: 'mod+shift+p',
|
||||
mac: 'cmd+shift+p',
|
||||
},
|
||||
},
|
||||
() => {
|
||||
logseq.toggleMainUI()
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Shortcut modes:
|
||||
|
||||
| Mode | Meaning |
|
||||
| ------------- | -------------------------------- |
|
||||
| `global` | Active globally. |
|
||||
| `non-editing` | Active when not editing a block. |
|
||||
| `editing` | Active when editing a block. |
|
||||
|
||||
### `slash`
|
||||
|
||||
Register a slash command.
|
||||
|
||||
```ts
|
||||
const unregister = logseq.Commands.register(
|
||||
'insert-current-date',
|
||||
{
|
||||
title: 'Insert current date',
|
||||
placement: 'slash',
|
||||
},
|
||||
async () => {
|
||||
await logseq.Editor.insertAtEditingCursor(
|
||||
new Date().toISOString().slice(0, 10)
|
||||
)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Slash commands also support the legacy action-step form:
|
||||
|
||||
```ts
|
||||
const unregister = logseq.Commands.register(
|
||||
'big-bang',
|
||||
{ title: 'Big Bang', placement: 'slash' },
|
||||
[['editor/input', 'Hello'], ['editor/clear-current-slash']]
|
||||
)
|
||||
```
|
||||
|
||||
### `block-context-menu`
|
||||
|
||||
Register a block context menu item.
|
||||
|
||||
```ts
|
||||
const unregister = logseq.Commands.register(
|
||||
'copy-block-uuid',
|
||||
{
|
||||
title: 'Copy block UUID',
|
||||
placement: 'block-context-menu',
|
||||
},
|
||||
async ({ uuid }) => {
|
||||
await navigator.clipboard.writeText(uuid)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### `page-menu`
|
||||
|
||||
Register a page menu item.
|
||||
|
||||
```ts
|
||||
const unregister = logseq.Commands.register(
|
||||
'analyze-page',
|
||||
{
|
||||
title: 'Analyze page',
|
||||
placement: 'page-menu',
|
||||
},
|
||||
async ({ page }) => {
|
||||
console.log('Analyze page', page)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### `highlight-context-menu`
|
||||
|
||||
Register a PDF highlight context menu item.
|
||||
|
||||
```ts
|
||||
const unregister = logseq.Commands.register(
|
||||
'send-highlight-to-inbox',
|
||||
{
|
||||
title: 'Send highlight to inbox',
|
||||
placement: 'highlight-context-menu',
|
||||
extras: { clearSelection: true },
|
||||
},
|
||||
async ({ content }) => {
|
||||
await logseq.Editor.appendBlockInPage('Inbox', content)
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### `simple`
|
||||
|
||||
Register a command without visible UI placement. This is useful when the command is intended to be executed by another plugin through `Commands.execute`.
|
||||
|
||||
```ts
|
||||
const unregister = logseq.Commands.register(
|
||||
'sync-now',
|
||||
{
|
||||
title: 'Sync now',
|
||||
placement: 'simple',
|
||||
},
|
||||
async () => {
|
||||
await syncNow()
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
## Multiple placements
|
||||
|
||||
Use `placements` when one logical command should appear in multiple places.
|
||||
|
||||
```ts
|
||||
const unregister = logseq.Commands.register(
|
||||
'toggle-focus-mode',
|
||||
{
|
||||
title: 'Toggle focus mode',
|
||||
placements: ['palette', 'shortcut'],
|
||||
keybinding: 'mod+shift+f',
|
||||
},
|
||||
() => {
|
||||
toggleFocusMode()
|
||||
}
|
||||
)
|
||||
|
||||
// Removes both palette and shortcut registrations.
|
||||
unregister && unregister()
|
||||
```
|
||||
|
||||
## Dynamic registration patterns
|
||||
|
||||
### Enable or disable a command from settings
|
||||
|
||||
```ts
|
||||
let unregisterExport: (() => void) | false | undefined
|
||||
|
||||
function registerExportCommand() {
|
||||
unregisterExport = logseq.Commands.register(
|
||||
'export-current-page',
|
||||
{ title: 'Export current page', placement: 'palette' },
|
||||
exportCurrentPage
|
||||
)
|
||||
}
|
||||
|
||||
function unregisterExportCommand() {
|
||||
unregisterExport && unregisterExport()
|
||||
unregisterExport = undefined
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (logseq.settings?.enableExport) registerExportCommand()
|
||||
|
||||
logseq.onSettingsChanged((next) => {
|
||||
unregisterExportCommand()
|
||||
if (next.enableExport) registerExportCommand()
|
||||
})
|
||||
|
||||
logseq.beforeunload(async () => {
|
||||
unregisterExportCommand()
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Register temporary commands
|
||||
|
||||
```ts
|
||||
const unregister = logseq.Commands.register(
|
||||
'temporary-review-action',
|
||||
{ title: 'Approve review item', placement: 'palette' },
|
||||
approveCurrentReviewItem
|
||||
)
|
||||
|
||||
setTimeout(() => {
|
||||
unregister && unregister()
|
||||
}, 60_000)
|
||||
```
|
||||
|
||||
## Compatibility with existing APIs
|
||||
|
||||
Old APIs remain supported. Internally, they share the same command registration path as v1 `logseq.Commands`.
|
||||
|
||||
| Existing API | Commands equivalent |
|
||||
| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
|
||||
| `App.registerCommandPalette({ key, label }, action)` | `Commands.register(key, { title: label, placement: 'palette' }, action)` |
|
||||
| `App.registerCommandShortcut(keybinding, action, opts)` | `Commands.register(opts.key, { ...opts, placement: 'shortcut', keybinding }, action)` |
|
||||
| `Editor.registerSlashCommand(tag, action)` | `Commands.register(tag, { title: tag, placement: 'slash' }, action)` |
|
||||
| `Editor.registerBlockContextMenuItem(label, action)` | `Commands.register(key, { title: label, placement: 'block-context-menu' }, action)` |
|
||||
| `Editor.registerHighlightContextMenuItem(label, action, opts)` | `Commands.register(key, { title: label, placement: 'highlight-context-menu', extras: opts }, action)` |
|
||||
| `App.registerPageMenuItem(tag, action)` | `Commands.register(key, { title: tag, placement: 'page-menu' }, action)` |
|
||||
|
||||
These old APIs also return unregister handlers after v1.
|
||||
|
||||
## Cleanup semantics
|
||||
|
||||
Commands are cleaned in two ways:
|
||||
|
||||
1. **Manual unregister**: call the function returned by `register`.
|
||||
2. **Plugin lifecycle cleanup**: when the plugin is disabled, reloaded, unloaded, or uninstalled, the host removes all commands registered by that plugin.
|
||||
|
||||
Manual unregister removes:
|
||||
|
||||
- local SDK hook listener;
|
||||
- simple command registry entry;
|
||||
- command palette entry;
|
||||
- shortcut registry entry;
|
||||
- slash command entry, if any.
|
||||
|
||||
Plugin lifecycle cleanup removes all commands by plugin id, including palette-only commands without shortcuts.
|
||||
|
||||
## v1 limitations
|
||||
|
||||
- `when` is metadata only. The host does not yet evaluate VSCode-like context expressions such as `editor.inCodeBlock` or `graph.isDb`.
|
||||
- `Commands.onDidExecute(listener)` is not implemented in v1.
|
||||
- Command collision handling still follows existing host behavior. Prefer stable, plugin-scoped keys.
|
||||
- `Commands.execute` works with registered plugin commands and built-in `logseq.*` commands; it is not a general RPC replacement for arbitrary plugin model methods.
|
||||
|
||||
## Best practices
|
||||
|
||||
- Use a stable `key` for commands that other plugins may call.
|
||||
- Keep `title` user-facing and concise.
|
||||
- Store unregister handlers for dynamic commands and call them before re-registering.
|
||||
- Always clean up dynamic registrations in `logseq.beforeunload`.
|
||||
- Prefer `placements` over registering the same handler several times manually.
|
||||
- Use `placement: 'simple'` for plugin-to-plugin automation commands that should not appear in UI.
|
||||
594
libs/guides/custom_theme_guide.md
Normal file
594
libs/guides/custom_theme_guide.md
Normal file
@@ -0,0 +1,594 @@
|
||||
# Custom Theme Plugin Guide
|
||||
|
||||
This guide summarizes Logseq's current UI theme architecture and gives a practical workflow for building a Logseq **theme plugin** with `@logseq/libs`.
|
||||
|
||||
It is based on the current repository structure and these theme-related entry points:
|
||||
|
||||
- CSS load order: `tailwind.all.css`
|
||||
- Design tokens and theme variables: `src/main/frontend/shui/radix.css`, `src/main/frontend/shui/radix-hsl.css`, `src/main/frontend/shui/vars-classic.css`, `src/main/frontend/shui/colors.css`
|
||||
- Shared shui/Base UI component CSS: `resources/css/shui.css`
|
||||
- CodeMirror theme bridge: `resources/css/codemirror.lsradix.css`
|
||||
- Frontend component CSS: `src/main/frontend/**/*.css`
|
||||
- Theme runtime attributes: `src/main/frontend/components/theme.cljs`, `src/main/frontend/state.cljs`, `src/main/frontend/ui.cljs`
|
||||
- Plugin theme registration/injection: `libs/src/LSPlugin.ts`, `libs/src/LSPlugin.core.ts`, `libs/src/LSPlugin.user.ts`, `libs/src/common.ts`
|
||||
|
||||
## Mental model
|
||||
|
||||
Logseq themes are mostly CSS-variable based. A theme plugin should first override the semantic variables that the app already consumes, and only then add targeted selector overrides for areas that are not fully tokenized.
|
||||
|
||||
### CSS loading order
|
||||
|
||||
The main stylesheet imports theme foundations before app component CSS:
|
||||
|
||||
1. `src/main/frontend/shui/radix.css` — color scales as `--rx-*` variables, for example `--rx-gray-01` through `--rx-gray-12` and alpha variants.
|
||||
2. `src/main/frontend/shui/radix-hsl.css` — HSL forms such as `--rx-gray-01-hsl` for Tailwind/shui tokens.
|
||||
3. `src/main/frontend/shui/vars-classic.css` — Logseq semantic variables, layout variables, default light/dark values for `data-color=logseq`.
|
||||
4. `src/main/frontend/shui/colors.css` — accent palettes selected by `html[data-color=...]`, mapping `--lx-accent-*`, `--lx-gray-*`, shui tokens, and many `--ls-*` values.
|
||||
5. `src/main/frontend/shui/index.css` — Tailwind base.
|
||||
6. `resources/css/shui.css` — shared UI components built on shui/Base UI/Tailwind tokens.
|
||||
7. Third-party CSS: Inter, PhotoSwipe, KaTeX, CodeMirror, PDF.js, Tabler, `codemirror.lsradix.css`.
|
||||
8. `src/main/frontend/**/[!_]*.css` — component and extension CSS.
|
||||
9. A selected plugin theme is injected later as a `<link rel="stylesheet">`, so it can override earlier rules when specificity is equal or higher.
|
||||
|
||||
### Runtime attributes and classes
|
||||
|
||||
Theme CSS should scope by attributes on `html` and compatibility classes on `body`:
|
||||
|
||||
| Hook | Set by | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `html[data-theme="light"]` / `html[data-theme="dark"]` | `frontend.components.theme/container` | Current light/dark mode. Prefer this for theme CSS. |
|
||||
| `html.dark` | same component | Tailwind dark-mode hook. |
|
||||
| `body.light-theme`, `body.white-theme`, `body.dark-theme` | same component | Backward-compatible hooks for older custom CSS/themes. |
|
||||
| `html[data-color="logseq"]` and other colors | same component | Accent color selection. A theme plugin can override or ignore this. |
|
||||
| `html[data-font="serif"|"mono"]`, `html[data-font-global="true"]` | same component | Editor/global font preferences. Avoid fighting these unless your theme intentionally owns typography. |
|
||||
| Platform classes such as `html.is-mobile`, `html.is-electron`, `html.is-mac`, `html.is-native-iphone` | `frontend.ui/inject-document-devices-envs!` | Useful for responsive/mobile-safe fixes. |
|
||||
|
||||
### Plugin theme lifecycle
|
||||
|
||||
A theme can be registered in either of two ways:
|
||||
|
||||
1. Declaratively in `package.json > logseq.themes`. During package preparation, Logseq resolves relative `url` values to plugin resources, registers each theme, and shows them in the theme selector.
|
||||
2. Programmatically with `logseq.provideTheme(theme)`. This is useful for generated themes or settings-driven variants.
|
||||
|
||||
When a user selects a theme, Logseq:
|
||||
|
||||
- removes the previously injected custom theme link;
|
||||
- injects the selected theme CSS link into `document.head`;
|
||||
- persists the selected theme separately for `light` and `dark` modes;
|
||||
- updates `:plugin/selected-theme` and emits plugin app hooks such as `:theme-changed`.
|
||||
|
||||
## Minimal theme plugin
|
||||
|
||||
### Directory layout
|
||||
|
||||
```text
|
||||
logseq-my-theme/
|
||||
├── package.json
|
||||
├── index.html
|
||||
└── themes/
|
||||
├── my-theme-light.css
|
||||
└── my-theme-dark.css
|
||||
```
|
||||
|
||||
### `package.json`
|
||||
|
||||
Use a stable lowercase-kebab `logseq.id`. For a theme-only plugin, set `effect: true` and declare each CSS file in `themes`.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "logseq-my-theme",
|
||||
"version": "0.1.0",
|
||||
"description": "A custom Logseq theme pack",
|
||||
"license": "MIT",
|
||||
"main": "index.html",
|
||||
"logseq": {
|
||||
"id": "my-theme",
|
||||
"main": "index.html",
|
||||
"title": "My Theme",
|
||||
"icon": "./icon.png",
|
||||
"effect": true,
|
||||
"themes": [
|
||||
{
|
||||
"name": "My Theme Light",
|
||||
"url": "./themes/my-theme-light.css",
|
||||
"mode": "light",
|
||||
"description": "Light variant of My Theme"
|
||||
},
|
||||
{
|
||||
"name": "My Theme Dark",
|
||||
"url": "./themes/my-theme-dark.css",
|
||||
"mode": "dark",
|
||||
"description": "Dark variant of My Theme"
|
||||
}
|
||||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@logseq/libs": "^0.0.17"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `index.html`
|
||||
|
||||
Declarative themes do not need runtime code, but keeping a tiny entry file makes the plugin package explicit and easy to extend later.
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>My Logseq Theme</title>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Programmatic variant with `provideTheme`
|
||||
|
||||
If the theme URL or metadata is generated at runtime, use the SDK. Always wait for Logseq to be ready.
|
||||
|
||||
```ts
|
||||
import '@logseq/libs'
|
||||
|
||||
async function main() {
|
||||
logseq.provideTheme({
|
||||
name: 'My Generated Dark Theme',
|
||||
url: './themes/generated-dark.css',
|
||||
mode: 'dark',
|
||||
description: 'Generated from plugin settings',
|
||||
})
|
||||
}
|
||||
|
||||
logseq.ready(main).catch(console.error)
|
||||
```
|
||||
|
||||
## CSS strategy
|
||||
|
||||
Start with variables. Add selector overrides only for UI that cannot be changed via variables.
|
||||
|
||||
### Dark theme starter
|
||||
|
||||
```css
|
||||
/* themes/my-theme-dark.css */
|
||||
|
||||
html[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
/* Core backgrounds */
|
||||
--ls-primary-background-color: #111827;
|
||||
--ls-secondary-background-color: #172033;
|
||||
--ls-tertiary-background-color: #202b42;
|
||||
--ls-quaternary-background-color: #2a3650;
|
||||
--ls-table-tr-even-background-color: var(--ls-secondary-background-color);
|
||||
--ls-slide-background-color: var(--ls-primary-background-color);
|
||||
|
||||
/* Text */
|
||||
--ls-primary-text-color: #d6deeb;
|
||||
--ls-secondary-text-color: #eef2ff;
|
||||
--ls-title-text-color: #f8fafc;
|
||||
--ls-left-sidebar-text-color: #cbd5e1;
|
||||
|
||||
/* Links and refs */
|
||||
--ls-link-text-color: #7dd3fc;
|
||||
--ls-link-text-hover-color: #bae6fd;
|
||||
--ls-link-ref-text-color: var(--ls-link-text-color);
|
||||
--ls-link-ref-text-hover-color: var(--ls-link-text-hover-color);
|
||||
--ls-block-ref-link-text-color: #38bdf8;
|
||||
--ls-tag-text-color: #93c5fd;
|
||||
--ls-tag-text-hover-color: #bfdbfe;
|
||||
|
||||
/* Borders, guidelines, focus */
|
||||
--ls-border-color: #334155;
|
||||
--ls-secondary-border-color: #475569;
|
||||
--ls-tertiary-border-color: rgb(148 163 184 / 0.18);
|
||||
--ls-guideline-color: rgb(148 163 184 / 0.18);
|
||||
--ls-focus-ring-color: rgb(56 189 248 / 0.45);
|
||||
|
||||
/* Blocks and properties */
|
||||
--ls-block-properties-background-color: #1e293b;
|
||||
--ls-page-properties-background-color: #1e293b;
|
||||
--ls-block-bullet-color: #64748b;
|
||||
--ls-block-bullet-border-color: #475569;
|
||||
--ls-block-highlight-color: rgb(14 165 233 / 0.22);
|
||||
--ls-a-chosen-bg: rgb(56 189 248 / 0.16);
|
||||
--ls-menu-hover-color: var(--ls-a-chosen-bg);
|
||||
|
||||
/* Selection, checkbox, quote, mark, inline code */
|
||||
--ls-selection-background-color: rgb(56 189 248 / 0.32);
|
||||
--ls-selection-text-color: #f8fafc;
|
||||
--ls-page-checkbox-color: #64748b;
|
||||
--ls-page-checkbox-border-color: #475569;
|
||||
--ls-page-blockquote-color: var(--ls-primary-text-color);
|
||||
--ls-page-blockquote-bg-color: #172033;
|
||||
--ls-page-blockquote-border-color: #38bdf8;
|
||||
--ls-page-mark-color: #111827;
|
||||
--ls-page-mark-bg-color: #fde68a;
|
||||
--ls-page-inline-code-color: #e0f2fe;
|
||||
--ls-page-inline-code-bg-color: #0f172a;
|
||||
|
||||
/* Scrollbars and notifications */
|
||||
--ls-scrollbar-foreground-color: rgb(148 163 184 / 0.35);
|
||||
--ls-scrollbar-background-color: rgb(15 23 42 / 0.35);
|
||||
--ls-scrollbar-thumb-hover-color: rgb(148 163 184 / 0.55);
|
||||
--ls-notification-background: #1e293b;
|
||||
--ls-notification-text-color: #f8fafc;
|
||||
|
||||
/* shui/Tailwind HSL tokens: use space-separated HSL channels, not hsl(). */
|
||||
--background: 222 47% 11%;
|
||||
--foreground: 210 40% 96%;
|
||||
--card: 222 47% 13%;
|
||||
--card-foreground: 210 40% 96%;
|
||||
--popover: 222 47% 10%;
|
||||
--popover-foreground: 210 40% 96%;
|
||||
--primary: 199 89% 48%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 217 33% 18%;
|
||||
--secondary-foreground: 210 40% 96%;
|
||||
--muted: 217 33% 16%;
|
||||
--border: 217 33% 24%;
|
||||
--input: 217 33% 24%;
|
||||
--ring: 199 89% 48%;
|
||||
--accent: 199 89% 48%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] body {
|
||||
background: var(--ls-primary-background-color);
|
||||
color: var(--ls-primary-text-color);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] ::selection {
|
||||
background: var(--ls-selection-background-color);
|
||||
color: var(--ls-selection-text-color);
|
||||
}
|
||||
```
|
||||
|
||||
### Light theme starter
|
||||
|
||||
```css
|
||||
/* themes/my-theme-light.css */
|
||||
|
||||
html[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
|
||||
--ls-primary-background-color: #ffffff;
|
||||
--ls-secondary-background-color: #f8fafc;
|
||||
--ls-tertiary-background-color: #eef2f7;
|
||||
--ls-quaternary-background-color: #e2e8f0;
|
||||
|
||||
--ls-primary-text-color: #1e293b;
|
||||
--ls-secondary-text-color: #0f172a;
|
||||
--ls-title-text-color: #0f172a;
|
||||
--ls-left-sidebar-text-color: #334155;
|
||||
|
||||
--ls-link-text-color: #0369a1;
|
||||
--ls-link-text-hover-color: #075985;
|
||||
--ls-link-ref-text-color: var(--ls-link-text-color);
|
||||
--ls-link-ref-text-hover-color: var(--ls-link-text-hover-color);
|
||||
--ls-block-ref-link-text-color: #0284c7;
|
||||
--ls-tag-text-color: #0369a1;
|
||||
--ls-tag-text-hover-color: #075985;
|
||||
|
||||
--ls-border-color: #cbd5e1;
|
||||
--ls-secondary-border-color: #e2e8f0;
|
||||
--ls-tertiary-border-color: rgb(15 23 42 / 0.08);
|
||||
--ls-guideline-color: rgb(15 23 42 / 0.08);
|
||||
--ls-focus-ring-color: rgb(14 165 233 / 0.35);
|
||||
|
||||
--ls-block-properties-background-color: #f1f5f9;
|
||||
--ls-page-properties-background-color: #f1f5f9;
|
||||
--ls-block-bullet-color: #94a3b8;
|
||||
--ls-block-bullet-border-color: #cbd5e1;
|
||||
--ls-block-highlight-color: #e0f2fe;
|
||||
--ls-a-chosen-bg: #e0f2fe;
|
||||
--ls-menu-hover-color: var(--ls-a-chosen-bg);
|
||||
|
||||
--ls-selection-background-color: #dbeafe;
|
||||
--ls-selection-text-color: #0f172a;
|
||||
--ls-page-checkbox-color: #94a3b8;
|
||||
--ls-page-checkbox-border-color: #94a3b8;
|
||||
--ls-page-blockquote-color: var(--ls-primary-text-color);
|
||||
--ls-page-blockquote-bg-color: #f8fafc;
|
||||
--ls-page-blockquote-border-color: #38bdf8;
|
||||
--ls-page-mark-color: #0f172a;
|
||||
--ls-page-mark-bg-color: #fef3c7;
|
||||
--ls-page-inline-code-color: #0f172a;
|
||||
--ls-page-inline-code-bg-color: #f1f5f9;
|
||||
|
||||
--ls-scrollbar-foreground-color: rgb(15 23 42 / 0.12);
|
||||
--ls-scrollbar-background-color: rgb(15 23 42 / 0.05);
|
||||
--ls-scrollbar-thumb-hover-color: rgb(15 23 42 / 0.22);
|
||||
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222 47% 11%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222 47% 11%;
|
||||
--primary: 199 89% 40%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 222 47% 11%;
|
||||
--muted: 210 40% 96%;
|
||||
--border: 214 32% 91%;
|
||||
--input: 214 32% 91%;
|
||||
--ring: 199 89% 40%;
|
||||
--accent: 199 89% 40%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
}
|
||||
```
|
||||
|
||||
## Important variable groups
|
||||
|
||||
Prefer these variables before reaching for selectors.
|
||||
|
||||
### Global layout and typography
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `--ls-page-text-size` | Base page text size under `#root`. |
|
||||
| `--ls-page-title-size` | Page title size. Mobile may override title layout. |
|
||||
| `--ls-main-content-max-width`, `--ls-main-content-max-width-wide` | Main content width. |
|
||||
| `--ls-font-family` | Global app font when not overridden by user font settings. |
|
||||
| `--ls-scrollbar-width` | Custom scrollbar width. |
|
||||
| `--ls-border-radius-low`, `--ls-border-radius-medium` | Classic radius tokens. |
|
||||
| `--ls-headbar-height`, `--ls-headbar-inner-top-padding` | Header sizing. |
|
||||
| `--ls-left-sidebar-width`, `--ls-left-sidebar-sm-width`, `--ls-left-sidebar-nav-btn-size` | Left sidebar sizing. |
|
||||
|
||||
### Logseq semantic colors
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `--ls-primary-background-color` | Main background. |
|
||||
| `--ls-secondary-background-color` | Secondary surfaces, editors, menus. |
|
||||
| `--ls-tertiary-background-color` | Nested/raised surfaces. |
|
||||
| `--ls-quaternary-background-color` | Active/hover surfaces. |
|
||||
| `--ls-primary-text-color`, `--ls-secondary-text-color`, `--ls-title-text-color` | Body, stronger text, titles. |
|
||||
| `--ls-border-color`, `--ls-secondary-border-color`, `--ls-tertiary-border-color` | Borders. |
|
||||
| `--ls-guideline-color` | Block indentation guide lines. |
|
||||
| `--ls-active-primary-color`, `--ls-active-secondary-color` | Active states. |
|
||||
| `--ls-a-chosen-bg`, `--ls-menu-hover-color` | Chosen menu/list item backgrounds. |
|
||||
| `--ls-focus-ring-color` | Focus rings. |
|
||||
|
||||
### Links, references, tags, and blocks
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `--ls-link-text-color`, `--ls-link-text-hover-color` | General links. |
|
||||
| `--ls-link-ref-text-color`, `--ls-link-ref-text-hover-color` | Page references. |
|
||||
| `--ls-block-ref-link-text-color` | Block references. |
|
||||
| `--ls-tag-text-color`, `--ls-tag-text-hover-color`, `--ls-tag-text-opacity`, `--ls-tag-text-hover-opacity` | Tags. |
|
||||
| `--ls-block-bullet-color`, `--ls-block-bullet-border-color`, `--ls-block-bullet-active-color` | Block bullets. |
|
||||
| `--ls-block-highlight-color` | Block highlight. |
|
||||
| `--ls-block-properties-background-color`, `--ls-page-properties-background-color` | Property panels. |
|
||||
|
||||
### Content tokens
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `--ls-selection-background-color`, `--ls-selection-text-color` | Text selection. |
|
||||
| `--ls-page-checkbox-color`, `--ls-page-checkbox-border-color` | Markdown task checkboxes/radios. |
|
||||
| `--ls-page-blockquote-color`, `--ls-page-blockquote-bg-color`, `--ls-page-blockquote-border-color` | Blockquotes. |
|
||||
| `--ls-page-mark-color`, `--ls-page-mark-bg-color` | Highlight/mark text. |
|
||||
| `--ls-page-inline-code-color`, `--ls-page-inline-code-bg-color` | Inline code. |
|
||||
| `--ls-table-tr-even-background-color` | Alternating table rows. |
|
||||
| `--ls-cloze-text-color` | Cloze text. |
|
||||
| `--ls-slide-background-color` | Slide mode background. |
|
||||
|
||||
### shui/Tailwind HSL tokens
|
||||
|
||||
Newer shared UI components use HSL channel tokens, often through Tailwind classes such as `bg-background`, `bg-popover`, `border`, `text-foreground`, and `bg-primary`.
|
||||
|
||||
Set these as space-separated HSL channels:
|
||||
|
||||
```css
|
||||
html[data-theme="dark"] {
|
||||
--background: 222 47% 11%;
|
||||
--foreground: 210 40% 96%;
|
||||
--card: 222 47% 13%;
|
||||
--card-foreground: 210 40% 96%;
|
||||
--popover: 222 47% 10%;
|
||||
--popover-foreground: 210 40% 96%;
|
||||
--primary: 199 89% 48%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 217 33% 18%;
|
||||
--secondary-foreground: 210 40% 96%;
|
||||
--muted: 217 33% 16%;
|
||||
--border: 217 33% 24%;
|
||||
--input: 217 33% 24%;
|
||||
--ring: 199 89% 48%;
|
||||
--accent: 199 89% 48%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
}
|
||||
```
|
||||
|
||||
Do not write `--primary: hsl(199 89% 48%)`; consumers call `hsl(var(--primary))`.
|
||||
|
||||
### `--lx-*` and `--rx-*` color scales
|
||||
|
||||
- `--rx-*` variables are Radix-style raw palettes. They are broad and already available.
|
||||
- `--lx-gray-01` through `--lx-gray-12` and `--lx-gray-*-alpha` represent the active neutral scale.
|
||||
- `--lx-accent-01` through `--lx-accent-12` and `--lx-accent-*-alpha` represent the active accent scale.
|
||||
|
||||
If your theme is a complete palette, map `--lx-gray-*` and `--lx-accent-*` as well as `--ls-*`. This makes Radix/shui-heavy UI more consistent:
|
||||
|
||||
```css
|
||||
html[data-theme="dark"] {
|
||||
--lx-gray-01: #0f172a;
|
||||
--lx-gray-02: #111827;
|
||||
--lx-gray-03: #1e293b;
|
||||
--lx-gray-04: #273449;
|
||||
--lx-gray-05: #334155;
|
||||
--lx-gray-06: #475569;
|
||||
--lx-gray-07: #64748b;
|
||||
--lx-gray-08: #94a3b8;
|
||||
--lx-gray-09: #cbd5e1;
|
||||
--lx-gray-10: #dbeafe;
|
||||
--lx-gray-11: #e2e8f0;
|
||||
--lx-gray-12: #f8fafc;
|
||||
|
||||
--lx-accent-09: #0ea5e9;
|
||||
--lx-accent-10: #0284c7;
|
||||
--lx-accent-11: #38bdf8;
|
||||
--lx-accent-12: #e0f2fe;
|
||||
}
|
||||
```
|
||||
|
||||
## UI selector map
|
||||
|
||||
Use this as a last-mile map after variables. Keep overrides narrow and prefer `:where(...)` to avoid specificity wars.
|
||||
|
||||
### App shell and layout
|
||||
|
||||
| Area | Useful selectors | Notes |
|
||||
| --- | --- | --- |
|
||||
| Root/app | `#root`, `#app-container`, `#root-container.theme-container`, `main.theme-container-inner` | `#root` uses `--ls-page-text-size`; `main.theme-container-inner` defines `--left-sidebar-bg-color`. |
|
||||
| Main content | `#main-container`, `#main-content`, `#main-content-container`, `.page-blocks-inner` | Avoid hard-coded viewport hacks; test desktop and mobile. |
|
||||
| Header | `.cp__header`, `.head`, `.button`, `.ui__button` | Mostly tokenized through shui/Tailwind variables. |
|
||||
| Left sidebar | `.left-sidebar-inner`, `.sidebar-header-container`, `.sidebar-contents-container`, `.sidebar-content-group`, `.sidebar-navigations` | Prefer `--left-sidebar-bg-color`, `--ls-left-sidebar-text-color`, and `--ls-left-sidebar-width`. |
|
||||
| Right sidebar | `#right-sidebar`, `.cp__right-sidebar-inner`, `.sidebar-item`, `.references-blocks-item` | Nested reference cards may need extra contrast. |
|
||||
|
||||
### Pages, blocks, and editor
|
||||
|
||||
| Area | Useful selectors | Notes |
|
||||
| --- | --- | --- |
|
||||
| Page title | `.ls-page-title`, `.page-title`, `.journal-title`, `.page-title-sizer-wrapper` | Prefer `--ls-page-title-size` and title text variables. |
|
||||
| Blocks | `.ls-block`, `.block-main-container`, `.block-content-wrapper`, `.block-content`, `.block-body` | Avoid altering core layout unless necessary. |
|
||||
| Block tree guides | `.block-children-container`, `.block-children`, `.block-children-left-border` | Governed by `--ls-guideline-color` and related border variables. |
|
||||
| Bullets/control | `.block-control-wrap`, `.block-control`, `.bullet-container`, `.bullet-link-wrap` | Governed by bullet variables; keep hit areas accessible. |
|
||||
| Page refs | `.page-ref`, `.page-reference`, `.breadcrumb` | Use link/reference variables first. |
|
||||
| Block refs | `.block-ref`, `.block-ref-no-title`, `.open-block-ref-link` | Use `--ls-block-ref-link-text-color`, property surface variables. |
|
||||
| Properties | `.block-properties`, `.page-properties`, `.property-value-inner`, `.property-key` | Use property background variables. |
|
||||
| Editor textarea | `.editor-inner textarea`, `.edit-input`, `.non-block-editor textarea` | Background usually comes from `--ls-secondary-background-color`. |
|
||||
| Autocomplete/slash popup | `#ui__ac-inner`, `.menu-link`, `.absolute-modal[data-modal-name]`, `.cp__commands-slash` | Use menu hover and popover tokens. |
|
||||
|
||||
### Shared UI, dialogs, command palette
|
||||
|
||||
| Area | Useful selectors | Notes |
|
||||
| --- | --- | --- |
|
||||
| Buttons | `.ui__button`, `.button`, `.ui__toggle`, `.ui__toggle-background-on`, `.ui__toggle-background-off` | Set `--primary`, `--primary-foreground`, `--accent`, `--ring`. |
|
||||
| Dialogs/modals | `.ui__modal`, `.ui__modal-panel`, `.ui__dialog-overlay`, `.ui__dialog-content`, `.ui__alert-dialog-content` | `--ls-modal-overlay-gradient-start/end` can control modal overlays. |
|
||||
| Dropdowns/popovers | `.ui__dropdown-menu-content`, `.ui__popover-content`, `div[data-radix-menu-content]`, `div[data-radix-popper-content-wrapper]` | Usually driven by `--popover`, `--border`, `--accent`, `--lx-popover-bg`. |
|
||||
| Select/calendar | `.ui__select-content`, `.ui__calendar`, `.rc-datepicker` | shui tokens and `--accent` matter. |
|
||||
| Notifications | `.ui__notifications .notification-area` | Use `--ls-notification-background`, `--ls-notification-text-color`. |
|
||||
| Command palette | `.cp__cmdk` | Border and hint colors use `--ls-border-color`, `--lx-gray-*`, `--accent`. |
|
||||
| Context menu | `#custom-context-menu`, `.ls-context-menu-content`, `.menu-links-wrapper` | Popover/menu variables usually cover it. |
|
||||
|
||||
### Extensions and special surfaces
|
||||
|
||||
| Area | Useful selectors/variables | Notes |
|
||||
| --- | --- | --- |
|
||||
| CodeMirror | `.cm-s-lsradix`, `.cm-s-lsradix.cm-s-dark`, `.cm-s-lsradix.cm-s-light`, `.CodeMirror-*`, `.cm-*` | `resources/css/codemirror.lsradix.css` already bridges many `--lx-*`/`--ls-*` variables. Override only syntax colors if needed. |
|
||||
| PDF | `.extensions__pdf-container`, `.extensions__pdf-toolbar`, `.extensions__pdf-outline`, `--ph-highlight-color-*`, `--ph-link-color`, `--ph-view-container-width`, `--lx-pdf-container-dark-bg` | PDF highlight colors are `--ph-*`, not `--ls-highlight-color-*`. |
|
||||
| Graph | `.graph-layout`, `.graph-filters`, graph extension CSS under `src/main/frontend/extensions/graph.css` | Some graph styles are canvas/SVG-driven; inspect DOM before overriding. |
|
||||
| Whiteboard/tldraw | `.tl-container`, `.tl-button`, `--ls-wb-stroke-color-default`, `--ls-wb-background-color-default`, `--ls-wb-text-color-default` | Accent colors in `colors.css` set default whiteboard tokens. |
|
||||
| Tables | `.table-wrapper`, `.table-auto`, table rows | Start with `--ls-table-tr-even-background-color`, borders, text tokens. |
|
||||
| Cards/flashcards | `.ls-card`, `.ui__dialog-content[label=flashcards__cp]` | Check dialog contrast and card minimum sizes. |
|
||||
| Plugins UI | `.cp__plugins-page`, `.cp__themes-installed` | Useful when styling the installed theme picker itself. |
|
||||
|
||||
## Scoped overrides: examples
|
||||
|
||||
### Keep overrides mode-specific
|
||||
|
||||
```text
|
||||
html[data-theme="dark"] :where(.cp__right-sidebar-inner .references-blocks-item) {
|
||||
background: color-mix(in srgb, var(--ls-secondary-background-color) 88%, white 12%);
|
||||
}
|
||||
```
|
||||
|
||||
### Prefer low specificity for optional polish
|
||||
|
||||
```text
|
||||
html[data-theme="light"] :where(.page-reference:hover) {
|
||||
background: rgb(14 165 233 / 0.12);
|
||||
}
|
||||
```
|
||||
|
||||
### Avoid global resets
|
||||
|
||||
Avoid rules like this in a theme plugin:
|
||||
|
||||
```css
|
||||
/* Avoid */
|
||||
* {
|
||||
transition: all 200ms ease;
|
||||
}
|
||||
```
|
||||
|
||||
They can slow editing, affect CodeMirror/PDF.js, and break subtle interaction states.
|
||||
|
||||
## Mobile and desktop considerations
|
||||
|
||||
- Test `html.is-mobile`, `html.is-native-iphone`, `html.is-native-android`, and `html.is-electron` layouts if your theme changes spacing or header/sidebar dimensions.
|
||||
- Respect safe-area insets in mobile-specific layout. Existing CSS uses `env(safe-area-inset-*)` in some places.
|
||||
- Avoid changing `-webkit-app-region` on Electron headers/PDF toolbars unless you are fixing a drag-region issue.
|
||||
- Do not shrink block control or bullet hit areas below the existing touch-friendly sizes.
|
||||
- Avoid fixed pixel widths for the main content unless they are variables such as `--ls-main-content-max-width`.
|
||||
|
||||
## Assets and fonts
|
||||
|
||||
Prefer local assets packaged with the plugin. Relative URLs inside the selected theme CSS are resolved relative to the CSS file by the browser.
|
||||
|
||||
```css
|
||||
@font-face {
|
||||
font-family: "MyThemeText";
|
||||
src: url("../fonts/MyThemeText.woff2") format("woff2");
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
--ls-font-family: "MyThemeText", Inter, ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
```
|
||||
|
||||
Be careful with remote `@import` URLs: they can slow startup, fail offline, and leak network requests from a local-first app.
|
||||
|
||||
## Debugging workflow
|
||||
|
||||
1. Enable Logseq Developer mode.
|
||||
2. Load the unpacked theme plugin.
|
||||
3. Open DevTools and inspect `document.documentElement`:
|
||||
- `data-theme`
|
||||
- `data-color`
|
||||
- `data-font`
|
||||
- platform classes
|
||||
4. In the theme selector, choose each declared light/dark theme.
|
||||
5. Verify the selected CSS appears as a `<link rel="stylesheet">` in `document.head`.
|
||||
6. Inspect computed variables on `html`, `body`, `.theme-container`, and the specific UI area you are styling.
|
||||
7. If a variable is not taking effect, check whether the component is using `--lx-*`, shui HSL tokens, or a targeted class instead of `--ls-*`.
|
||||
|
||||
## Validation checklist
|
||||
|
||||
Before publishing a theme plugin, test at least these screens and states:
|
||||
|
||||
- [ ] Light and dark mode selection; switching between modes keeps each custom theme selection.
|
||||
- [ ] Default accent color and at least one non-default `data-color` accent.
|
||||
- [ ] Page title, journal title, normal blocks, nested blocks, block refs, page refs, tags, properties.
|
||||
- [ ] Editing state: textarea, slash command menu, autocomplete, date picker, block context menu.
|
||||
- [ ] Left sidebar, right sidebar, search, command palette, settings dialogs.
|
||||
- [ ] Notifications, confirmation dialogs, dropdowns, popovers, tooltips.
|
||||
- [ ] Code blocks / CodeMirror, inline code, marks, blockquotes, tables.
|
||||
- [ ] PDF viewer highlights and toolbar if PDF is in scope.
|
||||
- [ ] Graph and whiteboard/tldraw surfaces if your palette changes accents broadly.
|
||||
- [ ] Desktop Electron and at least one mobile/narrow viewport if the theme changes layout/spacing.
|
||||
- [ ] High contrast of text against every background; visible focus rings; visible selected/hover states.
|
||||
- [ ] Reduced-motion friendliness if you add animations.
|
||||
|
||||
## Common pitfalls
|
||||
|
||||
- Overriding only `--ls-*` while newer shui components still read `--background`, `--popover`, `--border`, `--primary`, or `--lx-*` variables.
|
||||
- Writing HSL tokens as full CSS colors instead of channel values.
|
||||
- Using high-specificity selectors or `!important` everywhere, making user custom CSS and future Logseq changes harder to coexist with.
|
||||
- Styling by generated Tailwind class names instead of stable Logseq/Radix selectors and variables.
|
||||
- Forgetting to scope a dark theme to `html[data-theme="dark"]` or a light theme to `html[data-theme="light"]`.
|
||||
- Hard-coding external font/image URLs without offline fallback.
|
||||
- Changing editor layout, block bullets, or mobile sidebars without testing touch interactions.
|
||||
|
||||
## Quick reference: priority order for theme authors
|
||||
|
||||
1. Set `--ls-*` semantic variables for Logseq-specific UI.
|
||||
2. Set shui/Tailwind HSL tokens (`--background`, `--foreground`, `--popover`, `--border`, `--primary`, etc.).
|
||||
3. Map `--lx-gray-*` and `--lx-accent-*` for newer shared UI consistency.
|
||||
4. Add extension variables such as `--ph-*` and `--ls-wb-*` only if your theme covers PDF/whiteboard.
|
||||
5. Add low-specificity, mode-scoped selector overrides for the remaining gaps.
|
||||
|
||||
293
libs/guides/db_properties_guide.md
Normal file
293
libs/guides/db_properties_guide.md
Normal file
@@ -0,0 +1,293 @@
|
||||
# Logseq DB Properties (Developer Guide)
|
||||
|
||||
This note explains how **properties** work in Logseq when using a **DB graph** (database mode), how that differs from a **file graph** (Markdown/Org), and how to use the **plugin SDK** APIs defined in `libs/src/LSPlugin.ts`.
|
||||
|
||||
> Scope: This is written for plugin developers. It focuses on `logseq.Editor.*` property APIs and tag/class modeling.
|
||||
|
||||
---
|
||||
|
||||
## 1) Two worlds: File graph vs DB graph
|
||||
|
||||
### File graph (Markdown / Org)
|
||||
- “Properties” are primarily **text syntax** stored in the file:
|
||||
- Markdown frontmatter (`---`)
|
||||
- Org property drawers (`:PROPERTIES:`)
|
||||
- Inline props (`key:: value`)
|
||||
- When you update a property, you’re often ultimately **editing text**.
|
||||
- SDK calls like `Editor.insertBlock`/`updateBlock` have `opts.properties`, but note this warning in the typings:
|
||||
- `IBatchBlock.properties` is **not supported for DB graph**.
|
||||
|
||||
### DB graph (Database mode)
|
||||
DB mode treats properties as **first-class DB entities**.
|
||||
|
||||
Think of it as a 2-layer model:
|
||||
|
||||
1) **Property definitions (schema)**
|
||||
- A property key (e.g. `rating`, `authors`, `zotero_key`) exists as an entity.
|
||||
- It may have a schema: type, cardinality, visibility.
|
||||
|
||||
2) **Property values (data)**
|
||||
- Blocks/pages can store values for property keys.
|
||||
- Persisted structurally in the DB (not by emitting `key:: value` text).
|
||||
|
||||
In DB graphs, prefer the dedicated APIs:
|
||||
- Property schema: `Editor.getProperty / upsertProperty / removeProperty`
|
||||
- Values on blocks: `Editor.upsertBlockProperty / removeBlockProperty / getBlockProperties`
|
||||
|
||||
---
|
||||
|
||||
## 2) Important types (from `LSPlugin.ts`)
|
||||
|
||||
### `PropertySchema`
|
||||
```ts
|
||||
export type PropertySchema = {
|
||||
type: 'default' | 'number' | 'node' | 'date' | 'checkbox' | 'url' | string,
|
||||
cardinality: 'many' | 'one',
|
||||
hide: boolean
|
||||
public: boolean
|
||||
}
|
||||
```
|
||||
|
||||
Practical meaning:
|
||||
- `type`
|
||||
- Controls editor/UI behavior and (in DB graphs) how values are interpreted.
|
||||
- Common: `default`, `number`, `date`, `checkbox`, `url`, `node`.
|
||||
- `cardinality`
|
||||
- `'one'`: a single value
|
||||
- `'many'`: multiple values (typically passed as an array)
|
||||
- `hide`
|
||||
- Hide in UI property panels.
|
||||
- `public`
|
||||
- Expose property in UI (and typically configure discoverability).
|
||||
|
||||
### Entities you’ll see
|
||||
- `BlockEntity`: blocks, but also used for some “special blocks” such as property entities.
|
||||
- `PageEntity`: pages, tags (classes), property pages (`type: 'property'`), etc.
|
||||
- `BlockIdentity`: a block uuid string OR `{ uuid }`.
|
||||
|
||||
---
|
||||
|
||||
## 3) DB-only vs graph-agnostic checks
|
||||
|
||||
Before doing DB-only work, check graph type:
|
||||
|
||||
```ts
|
||||
const isDbGraph = await logseq.App.checkCurrentIsDbGraph()
|
||||
if (!isDbGraph) {
|
||||
await logseq.UI.showMsg('This feature requires a DB graph.', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
// Also check app-level capability:
|
||||
const { supportDb } = await logseq.App.getInfo()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4) Property schema APIs (DB only)
|
||||
|
||||
### Get a property definition
|
||||
```ts
|
||||
const propEntity = await logseq.Editor.getProperty('zotero_key')
|
||||
// -> BlockEntity | null
|
||||
```
|
||||
|
||||
### Create or update a property definition (idempotent)
|
||||
Use `upsertProperty` to ensure the property exists and has the schema you expect.
|
||||
|
||||
```ts
|
||||
await logseq.Editor.upsertProperty(
|
||||
'zotero_key',
|
||||
{
|
||||
type: 'default',
|
||||
cardinality: 'one',
|
||||
hide: false,
|
||||
public: true,
|
||||
},
|
||||
{ name: 'Zotero Key' }
|
||||
)
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `key` is your stable identifier (recommend `snake_case`).
|
||||
- `opts.name` can be used as a **display name** for users.
|
||||
|
||||
### Remove a property definition
|
||||
```ts
|
||||
await logseq.Editor.removeProperty('zotero_key')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5) Block/page property value APIs
|
||||
|
||||
### Set (upsert) a property value on a block
|
||||
```ts
|
||||
const block = await logseq.Editor.getCurrentBlock()
|
||||
if (!block) return
|
||||
|
||||
await logseq.Editor.upsertBlockProperty(block.uuid, 'zotero_key', 'ABCD1234')
|
||||
```
|
||||
|
||||
### Set multi-value properties (`cardinality: 'many'`)
|
||||
For `many`, pass an array. Use `{ reset: true }` when you want to overwrite vs merge.
|
||||
|
||||
```ts
|
||||
await logseq.Editor.upsertBlockProperty(
|
||||
block.uuid,
|
||||
'authors',
|
||||
['Ada Lovelace', 'Alan Turing'],
|
||||
{ reset: true }
|
||||
)
|
||||
```
|
||||
|
||||
### Remove a value
|
||||
```ts
|
||||
await logseq.Editor.removeBlockProperty(block.uuid, 'zotero_key')
|
||||
```
|
||||
|
||||
### Read properties
|
||||
```ts
|
||||
const props = await logseq.Editor.getBlockProperties(block.uuid)
|
||||
// -> Record<string, any> | null
|
||||
|
||||
const pageProps = await logseq.Editor.getPageProperties('My Page')
|
||||
```
|
||||
|
||||
### Read a single property (returns a `BlockEntity | null`)
|
||||
`getBlockProperty` is useful when you want the DB entity wrapper for a key.
|
||||
|
||||
```ts
|
||||
const v = await logseq.Editor.getBlockProperty(block.uuid, 'zotero_key')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6) Tags as “classes” + attaching properties (DB modeling)
|
||||
|
||||
In DB graphs, tags can behave like **classes**.
|
||||
|
||||
Relevant APIs:
|
||||
- `Editor.createTag(tagName, opts)`
|
||||
- `Editor.addTagProperty(tagId, propertyIdOrName)` / `removeTagProperty`
|
||||
- `Editor.addTagExtends(tagId, parentTagIdOrName)` / `removeTagExtends`
|
||||
- `Editor.addBlockTag(blockId, tagId)` / `removeBlockTag`
|
||||
|
||||
### Create a tag with tagProperties
|
||||
This is the most developer-friendly way to ship a “schema bundle”:
|
||||
|
||||
```ts
|
||||
const tag = await logseq.Editor.createTag('ZoteroItem', {
|
||||
tagProperties: [
|
||||
{
|
||||
name: 'zotero_key',
|
||||
schema: { type: 'default', cardinality: 'one', public: true, hide: false },
|
||||
},
|
||||
{
|
||||
name: 'authors',
|
||||
schema: { type: 'node', cardinality: 'many', public: true, hide: false },
|
||||
},
|
||||
{
|
||||
name: 'published_at',
|
||||
schema: { type: 'date', cardinality: 'one', public: true, hide: false },
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (tag) {
|
||||
const block = await logseq.Editor.getCurrentBlock()
|
||||
if (block) await logseq.Editor.addBlockTag(block.uuid, tag.uuid)
|
||||
}
|
||||
```
|
||||
|
||||
Why this pattern is popular:
|
||||
- Users see a consistent “record type” (`ZoteroItem`) with fields.
|
||||
- Plugins can be idempotent: create if missing, otherwise just reuse.
|
||||
|
||||
### Add properties to an existing tag later
|
||||
```ts
|
||||
const tag = await logseq.Editor.getTag('ZoteroItem')
|
||||
if (tag) {
|
||||
await logseq.Editor.addTagProperty(tag.uuid, 'zotero_key')
|
||||
await logseq.Editor.addTagProperty(tag.uuid, 'authors')
|
||||
}
|
||||
```
|
||||
|
||||
### Tag inheritance
|
||||
```ts
|
||||
await logseq.Editor.addTagExtends('ZoteroItem', 'Reference')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7) Recommended conventions for plugin authors
|
||||
|
||||
### Prefer DB APIs over text editing in DB graphs
|
||||
- Don’t generate `key:: value` strings as your “source of truth” in DB mode.
|
||||
- Use `upsertProperty` + `upsertBlockProperty`.
|
||||
|
||||
### Keep keys stable; use display names for UX
|
||||
- Use safe keys (`snake_case`, ASCII) for `key`.
|
||||
- Use `upsertProperty(key, schema, { name })` to show a nice label.
|
||||
|
||||
### Be explicit about cardinality
|
||||
- If you intend a list, set `cardinality: 'many'` and always write arrays.
|
||||
- Use `{ reset: true }` when you want to overwrite.
|
||||
|
||||
### Guard DB-only APIs
|
||||
- Always check `App.checkCurrentIsDbGraph()`.
|
||||
|
||||
---
|
||||
|
||||
## 8) A tiny helper wrapper (optional)
|
||||
|
||||
If you’re building a small internal “DB properties SDK” for your plugin, a minimal shape looks like:
|
||||
|
||||
```ts
|
||||
export async function ensureDbGraph() {
|
||||
if (!(await logseq.App.checkCurrentIsDbGraph())) {
|
||||
throw new Error('DB graph required')
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureProperty(
|
||||
key: string,
|
||||
schema: Partial<import('../src/LSPlugin').PropertySchema>,
|
||||
name?: string
|
||||
) {
|
||||
return logseq.Editor.upsertProperty(key, schema, name ? { name } : undefined)
|
||||
}
|
||||
|
||||
export async function setProp(
|
||||
block: string,
|
||||
key: string,
|
||||
value: any,
|
||||
reset = false
|
||||
) {
|
||||
return logseq.Editor.upsertBlockProperty(block, key, value, reset ? { reset: true } : undefined)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Appendix: API quick reference (from `LSPlugin.ts`)
|
||||
|
||||
**Property schema (DB only)**
|
||||
- `Editor.getProperty(key)`
|
||||
- `Editor.upsertProperty(key, schema?, opts?)`
|
||||
- `Editor.removeProperty(key)`
|
||||
|
||||
**Block/page values**
|
||||
- `Editor.upsertBlockProperty(block, key, value, { reset? })`
|
||||
- `Editor.removeBlockProperty(block, key)`
|
||||
- `Editor.getBlockProperty(block, key)`
|
||||
- `Editor.getBlockProperties(block)`
|
||||
- `Editor.getPageProperties(page)`
|
||||
|
||||
**Tags / Classes**
|
||||
- `Editor.createTag(tagName, { tagProperties? })`
|
||||
- `Editor.getTag(nameOrIdent)` / `Editor.getAllTags()`
|
||||
- `Editor.addTagProperty(tagId, propertyIdOrName)` / `removeTagProperty`
|
||||
- `Editor.addTagExtends(tagId, parentTagIdOrName)` / `removeTagExtends`
|
||||
- `Editor.addBlockTag(blockId, tagId)` / `removeBlockTag`
|
||||
|
||||
680
libs/guides/db_properties_references.md
Normal file
680
libs/guides/db_properties_references.md
Normal file
@@ -0,0 +1,680 @@
|
||||
# Logseq DB Properties SDK Reference
|
||||
|
||||
## Overview
|
||||
|
||||
Logseq has two storage modes:
|
||||
- **File-based**: Properties stored as `key:: value` in markdown files
|
||||
- **DB-based**: Properties stored in SQLite with structured schema, types, and relationships
|
||||
|
||||
This guide covers **DB-based properties API** for plugin development.
|
||||
|
||||
---
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### 1. Property Schema (Global Definition)
|
||||
Properties must be defined globally before use. Think of it as creating columns in a database.
|
||||
|
||||
### 2. Tags as Classes
|
||||
Tags act as classes/types. Pages/blocks can be tagged to inherit property schemas.
|
||||
|
||||
### 3. Property Values
|
||||
After defining properties, assign values to specific blocks/pages.
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### Property Schema APIs
|
||||
|
||||
#### `upsertProperty(name, schema?, opts?)`
|
||||
Define or update a property schema globally.
|
||||
|
||||
**Parameters:**
|
||||
- `name` (string): Property name
|
||||
- `schema` (optional object):
|
||||
- `type`: `'default'` | `'node'` | `'date'` | `'number'` | `'checkbox'` | `'url'`
|
||||
- `cardinality`: `'one'` | `'many'` (for `node` type)
|
||||
- `hide`: boolean - hide property in UI
|
||||
- `public`: boolean - make property public
|
||||
- `opts` (optional object):
|
||||
- `name`: string - display name override
|
||||
|
||||
**Returns:** `IEntityID` object with `id`
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// Simple text property
|
||||
await logseq.Editor.upsertProperty('author', { type: 'default' })
|
||||
|
||||
// Reference property (links to other pages)
|
||||
await logseq.Editor.upsertProperty('tags', {
|
||||
type: 'node',
|
||||
cardinality: 'many'
|
||||
})
|
||||
|
||||
// Hidden property with custom display name
|
||||
await logseq.Editor. upsertProperty('internalKey',
|
||||
{ type: 'default', hide: true },
|
||||
{ name: 'Internal Key' }
|
||||
)
|
||||
```
|
||||
|
||||
#### `getProperty(key)`
|
||||
Retrieve an existing property by key.
|
||||
|
||||
**Returns:** `BlockEntity` or `null`
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const prop = await logseq.Editor. getProperty('author')
|
||||
if (!prop) {
|
||||
await logseq.Editor.upsertProperty('author', { type: 'default' })
|
||||
}
|
||||
```
|
||||
|
||||
#### `removeProperty(key)`
|
||||
Remove a property definition globally.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
await logseq.Editor.removeProperty('deprecatedField')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Tag (Class) Management APIs
|
||||
|
||||
#### `createTag(name, opts?)`
|
||||
Create a tag as a class/type definition with properties in one call.
|
||||
|
||||
**Parameters:**
|
||||
- `name` (string): Tag name
|
||||
- `opts` (optional object):
|
||||
- `uuid`: string - custom UUID
|
||||
- `tagProperties`: Array of property definitions
|
||||
- `name`: string - property name
|
||||
- `schema`: Partial<PropertySchema> - property schema
|
||||
- `properties`: object - additional properties
|
||||
|
||||
**Returns:** `PageEntity` or `null`
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// Simple tag
|
||||
const bookTag = await logseq.Editor.createTag('book')
|
||||
|
||||
// Tag with custom UUID
|
||||
const articleTag = await logseq.Editor.createTag('article', {
|
||||
uuid: 'custom-uuid-123'
|
||||
})
|
||||
|
||||
// Tag with properties defined in one call
|
||||
const bookTag = await logseq.Editor. createTag('book', {
|
||||
uuid: generateStableUUID('book'),
|
||||
tagProperties: [
|
||||
{
|
||||
name: 'author',
|
||||
schema: { type: 'default' }
|
||||
},
|
||||
{
|
||||
name: 'isbn',
|
||||
schema: { type: 'default' }
|
||||
},
|
||||
{
|
||||
name: 'tags',
|
||||
schema: { type: 'node', cardinality: 'many' }
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
#### `getTag(nameOrIdent)`
|
||||
Retrieve an existing tag by name or entity ID.
|
||||
|
||||
**Returns:** `PageEntity` or `null`
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const tag = await logseq.Editor.getTag('book')
|
||||
if (!tag) {
|
||||
// Tag doesn't exist, create it
|
||||
}
|
||||
```
|
||||
|
||||
#### `getTagsByName(tagName)`
|
||||
Retrieve all tags matching a name (useful for namespaced tags).
|
||||
|
||||
**Returns:** `Array<PageEntity>` or `null`
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const tags = await logseq.Editor.getTagsByName('book')
|
||||
```
|
||||
|
||||
#### `getAllTags()`
|
||||
Get all tags in the current graph.
|
||||
|
||||
**Returns:** `Array<PageEntity>` or `null`
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const allTags = await logseq. Editor.getAllTags()
|
||||
```
|
||||
|
||||
#### `addTagProperty(tagId, propertyIdOrName)`
|
||||
Add a property to a tag's schema. Property must be defined via `upsertProperty` first.
|
||||
|
||||
**Parameters:**
|
||||
- `tagId` (BlockIdentity): Tag UUID
|
||||
- `propertyIdOrName` (BlockIdentity): Property UUID or name
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// Define property globally
|
||||
await logseq.Editor. upsertProperty('author', { type: 'default' })
|
||||
|
||||
// Add to tag
|
||||
const bookTag = await logseq. Editor.createTag('book')
|
||||
await logseq.Editor. addTagProperty(bookTag.uuid, 'author')
|
||||
```
|
||||
|
||||
#### `removeTagProperty(tagId, propertyIdOrName)`
|
||||
Remove a property from a tag's schema.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
await logseq.Editor.removeTagProperty(bookTag.uuid, 'deprecatedField')
|
||||
```
|
||||
|
||||
#### `addTagExtends(tagId, parentTagIdOrName)`
|
||||
Set tag inheritance (class extends).
|
||||
|
||||
**Parameters:**
|
||||
- `tagId` (BlockIdentity): Child tag UUID
|
||||
- `parentTagIdOrName` (BlockIdentity): Parent tag UUID or name
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const mediaTag = await logseq.Editor.createTag('Media')
|
||||
const bookTag = await logseq. Editor.createTag('book')
|
||||
|
||||
// book extends Media
|
||||
await logseq.Editor. addTagExtends(bookTag. uuid, mediaTag.uuid)
|
||||
```
|
||||
|
||||
#### `removeTagExtends(tagId, parentTagIdOrName)`
|
||||
Remove tag inheritance.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
await logseq.Editor.removeTagExtends(bookTag.uuid, mediaTag.uuid)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Block/Page Tagging APIs
|
||||
|
||||
#### `addBlockTag(blockId, tagId)`
|
||||
Tag a block or page, making it an instance of that tag's class.
|
||||
|
||||
**Parameters:**
|
||||
- `blockId` (BlockIdentity): Block or page UUID
|
||||
- `tagId` (BlockIdentity): Tag UUID
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const page = await logseq.Editor. createPage('Moby Dick')
|
||||
const bookTag = await logseq.Editor.getTag('book')
|
||||
await logseq.Editor.addBlockTag(page.uuid, bookTag.uuid)
|
||||
```
|
||||
|
||||
#### `removeBlockTag(blockId, tagId)`
|
||||
Remove a tag from a block or page.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
await logseq.Editor.removeBlockTag(page.uuid, bookTag.uuid)
|
||||
```
|
||||
|
||||
#### `getTagObjects(nameOrIdent)`
|
||||
Get all blocks/pages tagged with a specific tag.
|
||||
|
||||
**Returns:** `Array<BlockEntity>` or `null`
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const allBooks = await logseq.Editor.getTagObjects('book')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Block/Page Property Value APIs
|
||||
|
||||
#### `upsertBlockProperty(blockUUID, key, value, options?)`
|
||||
Set property value for a specific block or page.
|
||||
|
||||
**Parameters:**
|
||||
- `blockUUID` (BlockIdentity | EntityID): Block or page UUID or entity ID
|
||||
- `key` (string): Property name
|
||||
- `value` (any): Property value
|
||||
- For `node` type with `cardinality: 'many'`: Use array of page IDs (`.id` not `.uuid`)
|
||||
- For `node` type with `cardinality: 'one'`: Use single page ID
|
||||
- For other types: Use primitive values
|
||||
- `options` (optional object):
|
||||
- `reset`: boolean - replace instead of merge
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const page = await logseq.Editor.getPage('my-page-uuid')
|
||||
|
||||
// Set simple value
|
||||
await logseq.Editor.upsertBlockProperty(page.uuid, 'author', 'John Doe')
|
||||
|
||||
// Set number
|
||||
await logseq.Editor.upsertBlockProperty(page.uuid, 'year', 2024)
|
||||
|
||||
// Set single reference (use . id not .uuid)
|
||||
await logseq.Editor.upsertBlockProperty(page.uuid, 'category', categoryPage.id)
|
||||
|
||||
// Set multiple references (use . id not .uuid)
|
||||
await logseq.Editor.upsertBlockProperty(
|
||||
page.uuid,
|
||||
'tags',
|
||||
[tagPage1.id, tagPage2.id, tagPage3.id]
|
||||
)
|
||||
|
||||
// Reset property value completely
|
||||
await logseq.Editor. upsertBlockProperty(
|
||||
page.uuid,
|
||||
'tags',
|
||||
[newTag1.id],
|
||||
{ reset: true }
|
||||
)
|
||||
```
|
||||
|
||||
#### `getBlockProperty(blockId, key)`
|
||||
Get a single property value from a block or page.
|
||||
|
||||
**Returns:** `BlockEntity` or `null`
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const author = await logseq.Editor. getBlockProperty(page.uuid, 'author')
|
||||
```
|
||||
|
||||
#### `getBlockProperties(blockId)`
|
||||
Get all properties from a block.
|
||||
|
||||
**Returns:** `Record<string, any>` or `null`
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const props = await logseq.Editor. getBlockProperties(page.uuid)
|
||||
console.log(props. author, props.isbn)
|
||||
```
|
||||
|
||||
#### `getPageProperties(pageId)`
|
||||
Get all properties from a page.
|
||||
|
||||
**Returns:** `Record<string, any>` or `null`
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
const props = await logseq.Editor.getPageProperties(page.uuid)
|
||||
```
|
||||
|
||||
#### `removeBlockProperty(blockId, key)`
|
||||
Remove a property from a block or page.
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
await logseq.Editor.removeBlockProperty(page.uuid, 'deprecatedField')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Special Properties & Utilities
|
||||
|
||||
#### Hide Empty Values
|
||||
Use special property key `:logseq. property/hide-empty-value`:
|
||||
|
||||
```typescript
|
||||
const prop = await logseq.Editor.upsertProperty('optionalField', { type: 'default' })
|
||||
await logseq.Editor.upsertBlockProperty(
|
||||
prop.id, // Use property's entity ID
|
||||
': logseq.property/hide-empty-value',
|
||||
true
|
||||
)
|
||||
```
|
||||
|
||||
#### Set Block Icon
|
||||
```typescript
|
||||
// Tabler icon
|
||||
await logseq.Editor.setBlockIcon(blockId, 'tabler-icon', 'book')
|
||||
|
||||
// Emoji (from emoji-mart)
|
||||
await logseq.Editor.setBlockIcon(blockId, 'emoji', 'books')
|
||||
```
|
||||
|
||||
#### Remove Block Icon
|
||||
```typescript
|
||||
await logseq.Editor.removeBlockIcon(blockId)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Define Schema with Tag and Properties (Simple)
|
||||
|
||||
```typescript
|
||||
async function setupBookSchema() {
|
||||
// Create tag with all properties in one call
|
||||
const bookTag = await logseq.Editor.createTag('book', {
|
||||
uuid: generateStableUUID('book'),
|
||||
tagProperties: [
|
||||
{ name: 'title', schema: { type: 'default' } },
|
||||
{ name: 'author', schema: { type: 'default' } },
|
||||
{ name: 'isbn', schema: { type: 'default' } },
|
||||
{ name: 'year', schema: { type: 'number' } },
|
||||
{ name: 'tags', schema: { type: 'node', cardinality: 'many' } }
|
||||
]
|
||||
})
|
||||
|
||||
return bookTag
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Define Schema with Inheritance
|
||||
|
||||
```typescript
|
||||
async function setupSchemaWithInheritance() {
|
||||
// 1. Create parent tag
|
||||
const mediaTag = await logseq.Editor.createTag('Media', {
|
||||
tagProperties: [
|
||||
{ name: 'title', schema: { type: 'default' } },
|
||||
{ name: 'year', schema: { type: 'number' } },
|
||||
{ name: 'tags', schema: { type: 'node', cardinality: 'many' } }
|
||||
]
|
||||
})
|
||||
|
||||
// 2. Create child tag with specific properties
|
||||
const bookTag = await logseq.Editor.createTag('book', {
|
||||
tagProperties: [
|
||||
{ name: 'author', schema: { type: 'default' } },
|
||||
{ name: 'isbn', schema: { type: 'default' } }
|
||||
]
|
||||
})
|
||||
|
||||
// 3. Set inheritance
|
||||
await logseq. Editor.addTagExtends(bookTag.uuid, mediaTag.uuid)
|
||||
|
||||
return { mediaTag, bookTag }
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Create Instance with Properties
|
||||
|
||||
```typescript
|
||||
async function createBookInstance(bookData) {
|
||||
// 1. Create page with stable UUID
|
||||
const pageUUID = generateStableUUID(bookData.id)
|
||||
let page = await logseq.Editor.getPage(pageUUID)
|
||||
|
||||
if (!page) {
|
||||
page = await logseq.Editor. createPage(
|
||||
bookData.title,
|
||||
{},
|
||||
{ customUUID: pageUUID, redirect: false }
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Tag the page
|
||||
const bookTag = await logseq. Editor.getTag('book')
|
||||
await logseq.Editor.addBlockTag(page.uuid, bookTag. uuid)
|
||||
|
||||
// 3. Set property values
|
||||
await logseq. Editor.upsertBlockProperty(page.uuid, 'title', bookData.title)
|
||||
await logseq.Editor.upsertBlockProperty(page.uuid, 'author', bookData. author)
|
||||
await logseq.Editor.upsertBlockProperty(page.uuid, 'isbn', bookData.isbn)
|
||||
await logseq.Editor.upsertBlockProperty(page.uuid, 'year', bookData. year)
|
||||
|
||||
return page. uuid
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Link Related Entities
|
||||
|
||||
```typescript
|
||||
async function linkBookToCategories(bookPageUUID, categoryKeys) {
|
||||
// Get or create category pages
|
||||
const categoryIDs = await Promise.all(
|
||||
categoryKeys. map(async (key) => {
|
||||
const catUUID = generateStableUUID(key)
|
||||
let catPage = await logseq.Editor.getPage(catUUID)
|
||||
|
||||
if (!catPage) {
|
||||
catPage = await logseq.Editor.createPage(key, {}, {
|
||||
customUUID: catUUID
|
||||
})
|
||||
}
|
||||
|
||||
return catPage.id // ⚠️ Use .id not .uuid for references
|
||||
})
|
||||
)
|
||||
|
||||
// Link book to categories
|
||||
await logseq.Editor.upsertBlockProperty(
|
||||
bookPageUUID,
|
||||
'tags', // Must be 'node' type with 'many' cardinality
|
||||
categoryIDs
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 5: Batch Schema Setup from Configuration
|
||||
|
||||
```typescript
|
||||
async function setupSchemaFromConfig(config) {
|
||||
// config example:
|
||||
// {
|
||||
// "Media": {
|
||||
// "properties": ["title", "year"],
|
||||
// "parent": null
|
||||
// },
|
||||
// "book": {
|
||||
// "properties": ["author", "isbn"],
|
||||
// "parent": "Media"
|
||||
// }
|
||||
// }
|
||||
|
||||
for (const [tagName, tagConfig] of Object. entries(config)) {
|
||||
let tag = await logseq.Editor. getTag(tagName)
|
||||
|
||||
if (!tag) {
|
||||
// Create tag with properties
|
||||
tag = await logseq.Editor.createTag(tagName, {
|
||||
tagProperties: tagConfig.properties. map(name => ({
|
||||
name,
|
||||
schema: { type: 'default' }
|
||||
}))
|
||||
})
|
||||
|
||||
// Set parent if specified
|
||||
if (tagConfig.parent) {
|
||||
const parentTag = await logseq. Editor.getTag(tagConfig. parent)
|
||||
if (parentTag) {
|
||||
await logseq.Editor.addTagExtends(tag.uuid, parentTag.uuid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 6: Migrate Existing Pages to New Schema
|
||||
|
||||
```typescript
|
||||
async function migrateToBookSchema() {
|
||||
// 1. Setup schema
|
||||
const bookTag = await logseq.Editor.createTag('book', {
|
||||
tagProperties: [
|
||||
{ name: 'author', schema: { type: 'default' } },
|
||||
{ name: 'year', schema: { type: 'number' } }
|
||||
]
|
||||
})
|
||||
|
||||
// 2. Find all pages with old structure
|
||||
const allPages = await logseq.Editor.getAllPages()
|
||||
|
||||
// 3. Migrate each page
|
||||
for (const page of allPages) {
|
||||
const props = await logseq.Editor. getPageProperties(page.uuid)
|
||||
|
||||
// Check if page has book-like properties
|
||||
if (props?. author || props?.isbn) {
|
||||
// Add book tag
|
||||
await logseq.Editor.addBlockTag(page.uuid, bookTag.uuid)
|
||||
|
||||
// Migrate properties if needed
|
||||
if (props. author) {
|
||||
await logseq.Editor.upsertBlockProperty(
|
||||
page.uuid,
|
||||
'author',
|
||||
props. author
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Stable UUIDs
|
||||
Generate deterministic UUIDs from stable identifiers:
|
||||
|
||||
```typescript
|
||||
import { v5 as uuidv5 } from 'uuid'
|
||||
|
||||
const NAMESPACE = 'your-plugin-namespace-uuid'
|
||||
|
||||
function generateStableUUID(id: string): string {
|
||||
return uuidv5(id, NAMESPACE)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Check Before Creating
|
||||
Always check if tags/pages/properties exist before creating:
|
||||
|
||||
```typescript
|
||||
let tag = await logseq.Editor.getTag('book')
|
||||
if (!tag) {
|
||||
tag = await logseq.Editor.createTag('book', {
|
||||
tagProperties: [/* ... */]
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Property Definition Order (Manual Approach)
|
||||
If not using `createTag` with `tagProperties`:
|
||||
|
||||
1. Define property globally with `upsertProperty`
|
||||
2. Create tag with `createTag`
|
||||
3. Add property to tag with `addTagProperty`
|
||||
4. Create page and tag it with `addBlockTag`
|
||||
5. Set values with `upsertBlockProperty`
|
||||
|
||||
### 4. Use . id for References ⚠️
|
||||
When setting `node` type properties, use `.id` not `.uuid`:
|
||||
|
||||
```typescript
|
||||
const categoryPage = await logseq.Editor.getPage('category-uuid')
|
||||
|
||||
// ✅ Correct
|
||||
await logseq.Editor.upsertBlockProperty(
|
||||
page.uuid,
|
||||
'category',
|
||||
categoryPage.id
|
||||
)
|
||||
|
||||
// ❌ Wrong
|
||||
await logseq.Editor.upsertBlockProperty(
|
||||
page.uuid,
|
||||
'category',
|
||||
categoryPage.uuid
|
||||
)
|
||||
```
|
||||
|
||||
### 5. Error Handling
|
||||
Wrap API calls in try-catch for robustness:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await logseq.Editor.upsertBlockProperty(uuid, 'field', value)
|
||||
} catch (error) {
|
||||
console.error(`Failed to set property: ${error}`)
|
||||
await logseq.UI.showMsg(`Error: ${error. message}`, 'error')
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Use createTag with tagProperties
|
||||
Prefer the streamlined approach for better readability:
|
||||
|
||||
```typescript
|
||||
// ✅ Better: One call
|
||||
const tag = await logseq.Editor. createTag('book', {
|
||||
tagProperties: [
|
||||
{ name: 'author', schema: { type: 'default' } },
|
||||
{ name: 'isbn', schema: { type: 'default' } }
|
||||
]
|
||||
})
|
||||
|
||||
// ❌ Verbose: Multiple calls
|
||||
await logseq. Editor.upsertProperty('author', { type: 'default' })
|
||||
await logseq.Editor.upsertProperty('isbn', { type: 'default' })
|
||||
const tag = await logseq.Editor. createTag('book')
|
||||
await logseq.Editor. addTagProperty(tag.uuid, 'author')
|
||||
await logseq.Editor.addTagProperty(tag.uuid, 'isbn')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Real-World Example: Zotero Plugin
|
||||
|
||||
See complete implementation: [xyhp915/logseq-zotero-plugin](https://github.com/xyhp915/logseq-zotero-plugin/blob/main/src/handlers. ts#L19-L207)
|
||||
|
||||
Key techniques:
|
||||
- Schema defined from `z_item_types. json` metadata
|
||||
- Tag hierarchy: `Zotero` → `book`, `article`, etc.
|
||||
- Stable UUIDs via `v5(itemKey, namespace)`
|
||||
- Relationship properties for collections
|
||||
- Nested blocks for attachments
|
||||
|
||||
---
|
||||
|
||||
## Type Definitions
|
||||
|
||||
```typescript
|
||||
type PropertySchema = {
|
||||
type: 'default' | 'number' | 'node' | 'date' | 'checkbox' | 'url' | string
|
||||
cardinality: 'many' | 'one'
|
||||
hide: boolean
|
||||
public: boolean
|
||||
}
|
||||
|
||||
type BlockIdentity = BlockUUID | Pick<BlockEntity, 'uuid'>
|
||||
type PageIdentity = BlockPageName | BlockIdentity
|
||||
type EntityID = number
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reference
|
||||
|
||||
- **Type Definitions**: `@logseq/libs` - [LSPlugin. ts](https://github.com/logseq/logseq/blob/master/libs/src/LSPlugin.ts)
|
||||
- **Real Example**: [logseq-zotero-plugin/handlers.ts](https://github.com/xyhp915/logseq-zotero-plugin/blob/main/src/handlers. ts)
|
||||
- **Search More**: [GitHub Code Search](https://github.com/search?type=code&q=repo:xyhp915/logseq-zotero-plugin+upsertBlockProperty)
|
||||
550
libs/guides/db_query_guide.md
Normal file
550
libs/guides/db_query_guide.md
Normal file
@@ -0,0 +1,550 @@
|
||||
## Logseq Datascript Query Skills Guide
|
||||
|
||||
### 1. Logseq SDK Query APIs
|
||||
|
||||
```typescript
|
||||
// DSL Query - Logseq's query language
|
||||
const results0 = await logseq.DB.q('[[page-name]]')
|
||||
const todos = await logseq.DB.q('(task TODO DOING)')
|
||||
|
||||
// Datascript Query - Datalog syntax (more powerful)
|
||||
const results1 = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?b [*])
|
||||
:where [?b :block/marker "TODO"]]
|
||||
`)
|
||||
|
||||
// Query with parameters
|
||||
const results2 = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?b [*])
|
||||
:in $ ?name
|
||||
:where
|
||||
[?p :block/name ?name]
|
||||
[? b :block/page ?p]]
|
||||
`, `"my-page"`)
|
||||
|
||||
// Watch database changes
|
||||
logseq.DB.onChanged(({ blocks, txData }) => {
|
||||
console.log('Changed blocks:', blocks)
|
||||
})
|
||||
|
||||
// Watch specific block changes
|
||||
logseq.DB.onBlockChanged(uuid, (block, txData) => {
|
||||
console.log('Block changed:', block)
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Datalog Query Syntax Basics
|
||||
|
||||
#### 2.1 Basic Structure
|
||||
|
||||
```clojure
|
||||
[:find <return-value> ;; what to return
|
||||
:in $ <params... > ;; input parameters ($ = database)
|
||||
:where <clauses...>] ;; query conditions
|
||||
```
|
||||
|
||||
#### 2.2 : find Clause
|
||||
|
||||
```clojure
|
||||
;; Return entity IDs
|
||||
[:find ?b :where ...]
|
||||
|
||||
;; Return multiple values
|
||||
[:find ?b ?name :where ...]
|
||||
|
||||
;; Return full entity (commonly used)
|
||||
[:find (pull ?b [*]) :where ...]
|
||||
|
||||
;; Return specific attributes
|
||||
[:find (pull ?b [:block/uuid :block/content]) :where ...]
|
||||
|
||||
;; Aggregate functions
|
||||
[:find (count ?b) :where ...]
|
||||
[:find (min ?d) (max ?d) :where ...]
|
||||
```
|
||||
|
||||
#### 2.3 :where Clause - Data Patterns
|
||||
|
||||
```clojure
|
||||
;; Basic triple: [entity attribute value]
|
||||
[?b :block/marker "TODO"] ;; ? b's marker equals "TODO"
|
||||
[? b :block/page ?p] ;; ?b belongs to page ? p
|
||||
[?p :block/name ?name] ;; ? p's name binds to ?name
|
||||
|
||||
;; Multiple conditions (implicit AND)
|
||||
[?b :block/marker "TODO"]
|
||||
[?b :block/page ?p]
|
||||
[?p :block/journal? true] ;; all three must be satisfied
|
||||
```
|
||||
|
||||
#### 2.4 :in Clause - Parameter Passing
|
||||
|
||||
```clojure
|
||||
;; $ is the database (required)
|
||||
[:find (pull ?b [*])
|
||||
:in $
|
||||
:where ...]
|
||||
|
||||
;; Single parameter
|
||||
[:find (pull ?b [*])
|
||||
:in $ ?marker
|
||||
:where [?b :block/marker ? marker]]
|
||||
|
||||
;; Multiple parameters
|
||||
[:find (pull ?b [*])
|
||||
:in $ ?marker ? page-name
|
||||
:where
|
||||
[?b :block/marker ?marker]
|
||||
[? p :block/name ?page-name]
|
||||
[?b :block/page ?p]]
|
||||
|
||||
;; Collection parameter (match multiple values)
|
||||
[:find (pull ?b [*])
|
||||
:in $ [?marker ...]
|
||||
:where [?b :block/marker ?marker]]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Schema Reference: File Graph vs DB Graph
|
||||
|
||||
#### 3.1 Block Attributes
|
||||
|
||||
| Attribute | File Graph | DB Graph | Type | Description |
|
||||
|---------------------|:----------:|:--------:|--------|-------------------------------------|
|
||||
| `:block/uuid` | ✅ | ✅ | UUID | Unique block identifier |
|
||||
| `:block/content` | ✅ | ❌ | String | Raw block content (File Graph only) |
|
||||
| `:block/title` | ❌ | ✅ | String | Block title/content (DB Graph only) |
|
||||
| `:block/page` | ✅ | ✅ | Ref | Parent page reference |
|
||||
| `:block/parent` | ✅ | ✅ | Ref | Parent block reference |
|
||||
| `:block/left` | ✅ | ❌ | Ref | Left sibling block |
|
||||
| `:block/order` | ❌ | ✅ | String | Block order in DB Graph |
|
||||
| `:block/refs` | ✅ | ✅ | Ref[] | Referenced pages/blocks |
|
||||
| `:block/marker` | ✅ | ❌ | String | Task marker (TODO/DOING/DONE) |
|
||||
| `:block/priority` | ✅ | ❌ | String | Priority (A/B/C) |
|
||||
| `:block/scheduled` | ✅ | ❌ | Int | Scheduled date (YYYYMMDD) |
|
||||
| `:block/deadline` | ✅ | ❌ | Int | Deadline date (YYYYMMDD) |
|
||||
| `:block/properties` | ✅ | ❌ | Map | Properties as key-value map |
|
||||
| `:block/tags` | ✅ | ✅ | Ref[] | Tag references |
|
||||
| `:block/link` | ❌ | ✅ | Ref | Link to class/tag in DB Graph |
|
||||
| `:block/created-at` | ✅ | ❌ | Int | Creation timestamp (File Graph) |
|
||||
| `:block/updated-at` | ✅ | ❌ | Int | Update timestamp (File Graph) |
|
||||
|
||||
#### 3.2 Page Attributes
|
||||
|
||||
| Attribute | File Graph | DB Graph | Type | Description |
|
||||
|------------------------|:----------:|:--------:|---------|------------------------------------------|
|
||||
| `:block/name` | ✅ | ✅ | String | Page name (lowercase) |
|
||||
| `:block/original-name` | ✅ | ✅ | String | Original page name |
|
||||
| `:block/journal?` | ✅ | ✅ | Boolean | Is journal page |
|
||||
| `:block/journal-day` | ✅ | ✅ | Int | Journal date (YYYYMMDD) |
|
||||
| `:block/type` | ❌ | ✅ | String | Type ("page", "class", "property", etc.) |
|
||||
| `:block/format` | ✅ | ❌ | Keyword | Format (:markdown or :org) |
|
||||
| `:block/file` | ✅ | ❌ | Ref | Associated file reference |
|
||||
|
||||
#### 3.3 DB Graph Specific - System Properties (Idents)
|
||||
|
||||
| Attribute | Type | Description |
|
||||
|--------------------------------------|----------|------------------------------|
|
||||
| `:logseq.property/created-at` | Int (ms) | Creation timestamp |
|
||||
| `:logseq.property/updated-at` | Int (ms) | Update timestamp |
|
||||
| `:logseq.property/icon` | Object | Icon definition `{type, id}` |
|
||||
| `:logseq.property/hide-empty-value` | Boolean | Hide property if empty |
|
||||
| `:logseq.property/closed-value-mode` | Boolean | Enum mode for property |
|
||||
| `:logseq.property/closed-values` | Array | Allowed enum values |
|
||||
| `:logseq.property/schema` | Object | Property schema definition |
|
||||
| `:logseq.property. class/extends` | Ref[] | Parent class references |
|
||||
| `:logseq.property.class/properties` | Ref[] | Class property references |
|
||||
|
||||
#### 3.4 DB Graph Task System
|
||||
|
||||
In DB Graph, tasks use a different system based on tags/classes:
|
||||
|
||||
| Attribute | Description |
|
||||
|--------------------------|-------------------------------------------------------------|
|
||||
| `:block/tags` | Reference to task status class (e.g., `#logseq.class/Todo`) |
|
||||
| `:logseq.task/status` | Task status property |
|
||||
| `:logseq.task/priority` | Task priority property |
|
||||
| `:logseq.task/deadline` | Task deadline property |
|
||||
| `:logseq.task/scheduled` | Task scheduled property |
|
||||
|
||||
---
|
||||
|
||||
### 4. Common Query Examples
|
||||
|
||||
#### 4.1 Task Queries
|
||||
|
||||
**File Graph:**
|
||||
|
||||
```typescript
|
||||
// All TODOs
|
||||
const todos = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?b [*])
|
||||
:where [?b :block/marker "TODO"]]
|
||||
`)
|
||||
|
||||
// TODO or DOING
|
||||
const activeTasks = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?b [*])
|
||||
:in $ [?m ...]
|
||||
:where [?b :block/marker ? m]]
|
||||
`, `["TODO" "DOING"]`)
|
||||
|
||||
// Tasks with priority A
|
||||
const priorityTasks = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?b [*])
|
||||
:where
|
||||
[?b :block/marker ? m]
|
||||
[?b : block/priority "A"]]
|
||||
`)
|
||||
|
||||
// Scheduled for today
|
||||
const today = new Date().toISOString().slice(0, 10).replace(/-/g, '')
|
||||
const scheduled = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?b [*])
|
||||
:in $ ? today
|
||||
:where [?b : block/scheduled ?today]]
|
||||
`, parseInt(today))
|
||||
```
|
||||
|
||||
**DB Graph:**
|
||||
|
||||
```typescript
|
||||
// All tasks with TODO status
|
||||
const todos = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?b [*])
|
||||
:where
|
||||
[?b :block/tags ?tag]
|
||||
[?tag :db/ident : logseq.class/Todo]]
|
||||
`)
|
||||
|
||||
// Tasks by status property
|
||||
const tasks = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?b [*])
|
||||
:where
|
||||
[?b :logseq.task/status ?s]
|
||||
[?s :db/ident :logseq. task/status. todo]]
|
||||
`)
|
||||
```
|
||||
|
||||
#### 4.2 Page Queries
|
||||
|
||||
**Both File Graph and DB Graph:**
|
||||
|
||||
```typescript
|
||||
// All journal pages
|
||||
const journals = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?p [*])
|
||||
:where [?p :block/journal? true]]
|
||||
`)
|
||||
|
||||
// Journals in specific month
|
||||
const monthJournals = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?p [*])
|
||||
:where
|
||||
[?p :block/journal? true]
|
||||
[?p : block/journal-day ?d]
|
||||
[(>= ?d 20250101)]
|
||||
[(<= ?d 20250131)]]
|
||||
`)
|
||||
|
||||
// Blocks in a specific page
|
||||
const blocks = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?b [*])
|
||||
:in $ ? name
|
||||
:where
|
||||
[? p :block/name ?name]
|
||||
[?b :block/page ?p]]
|
||||
`, `"my-page"`)
|
||||
```
|
||||
|
||||
**DB Graph only:**
|
||||
|
||||
```typescript
|
||||
// Pages by type
|
||||
const pages = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?p [*])
|
||||
:where [?p :block/type "page"]]
|
||||
`)
|
||||
|
||||
// Class/Tag pages
|
||||
const classes = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?p [*])
|
||||
:where [?p : block/type "class"]]
|
||||
`)
|
||||
|
||||
// Recently created pages (last 7 days)
|
||||
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000
|
||||
const recentPages = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?p [*])
|
||||
:where
|
||||
[?p :block/type "page"]
|
||||
[?p :logseq.property/created-at ?created]
|
||||
[(> ? created ${sevenDaysAgo})]]
|
||||
`)
|
||||
```
|
||||
|
||||
#### 4.3 Reference Queries
|
||||
|
||||
**Both File Graph and DB Graph:**
|
||||
|
||||
```typescript
|
||||
// Blocks referencing a specific page
|
||||
const refs = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?b [*])
|
||||
:in $ ?page
|
||||
:where
|
||||
[? p :block/name ?page]
|
||||
[?b :block/refs ?p]]
|
||||
`, `"target-page"`)
|
||||
|
||||
// Block references (backlinks)
|
||||
const blockRefs = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?b [*])
|
||||
:in $ ?uuid
|
||||
:where
|
||||
[? target :block/uuid ?uuid]
|
||||
[?b :block/refs ?target]]
|
||||
`, `#uuid "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"`)
|
||||
```
|
||||
|
||||
#### 4.4 Property Queries
|
||||
|
||||
**File Graph:**
|
||||
|
||||
```typescript
|
||||
// Blocks with specific property
|
||||
const withProp = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?b [*])
|
||||
:where
|
||||
[?b :block/properties ?props]
|
||||
[(get ?props :status)]]
|
||||
`)
|
||||
|
||||
// Property equals specific value
|
||||
const statusDone = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?b [*])
|
||||
:where
|
||||
[?b :block/properties ?props]
|
||||
[(get ?props :status) ?v]
|
||||
[(= ?v "done")]]
|
||||
`)
|
||||
```
|
||||
|
||||
**DB Graph:**
|
||||
|
||||
```typescript
|
||||
// Blocks with property value (properties are direct attributes)
|
||||
const withProp = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?b [*])
|
||||
:where
|
||||
[?b : user. property/status ?v]]
|
||||
`)
|
||||
|
||||
// Query by property ident
|
||||
const results = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?b [*])
|
||||
:where
|
||||
[? prop :db/ident : user.property/status]
|
||||
[?b ? prop ? v]
|
||||
[?v :block/title "done"]]
|
||||
`)
|
||||
```
|
||||
|
||||
#### 4.5 Content Search
|
||||
|
||||
**File Graph:**
|
||||
|
||||
```typescript
|
||||
// Content contains keyword
|
||||
const results = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?b [*])
|
||||
:where
|
||||
[? b :block/content ?c]
|
||||
[(clojure.string/includes? ?c "keyword")]]
|
||||
`)
|
||||
```
|
||||
|
||||
**DB Graph:**
|
||||
|
||||
```typescript
|
||||
// Title contains keyword
|
||||
const results = await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?b [*])
|
||||
:where
|
||||
[?b :block/title ?t]
|
||||
[(clojure.string/includes? ?t "keyword")]]
|
||||
`)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Predicate Functions
|
||||
|
||||
```clojure
|
||||
;; Comparison
|
||||
[(> ? d 20250101)]
|
||||
[(>= ?d 20250101)]
|
||||
[(< ?d 20250131)]
|
||||
[(<= ?d 20250131)]
|
||||
[(= ?v "value")]
|
||||
[(not= ?v "value")]
|
||||
|
||||
;; String operations
|
||||
[(clojure.string/includes? ?s "text")]
|
||||
[(clojure.string/starts-with? ?s "prefix")]
|
||||
[(clojure.string/ends-with? ?s "suffix")]
|
||||
[(clojure.string/blank? ?s)]
|
||||
|
||||
;; Collection operations
|
||||
[(contains? #{"A" "B" "C"} ?v)]
|
||||
|
||||
;; Null checks
|
||||
[(some? ?v)]
|
||||
[(nil? ?v)]
|
||||
|
||||
;; Math operations
|
||||
[(+ ?a ?b) ? sum]
|
||||
[(- ?a ?b) ?diff]
|
||||
[(* ?a ?b) ?product]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. NOT and OR Clauses
|
||||
|
||||
```clojure
|
||||
;; NOT - exclude conditions
|
||||
[:find (pull ?b [*])
|
||||
:where
|
||||
[? b :block/marker ? m]
|
||||
(not [?b :block/priority "C"])]
|
||||
|
||||
;; OR - any condition matches
|
||||
[:find (pull ?b [*])
|
||||
:where
|
||||
(or [?b :block/marker "TODO"]
|
||||
[?b :block/marker "DOING"])]
|
||||
|
||||
;; OR with AND (or-join)
|
||||
[:find (pull ?b [*])
|
||||
:where
|
||||
(or-join [? b]
|
||||
(and [?b :block/marker "TODO"]
|
||||
[?b :block/priority "A"])
|
||||
(and [?b :block/marker "DOING"]))]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Aggregation Queries
|
||||
|
||||
```typescript
|
||||
// Count
|
||||
const count = await logseq.DB.datascriptQuery(`
|
||||
[:find (count ?b)
|
||||
:where [?b : block/marker "TODO"]]
|
||||
`)
|
||||
|
||||
// Group by page
|
||||
const grouped = await logseq.DB.datascriptQuery(`
|
||||
[:find ? name (count ?b)
|
||||
:where
|
||||
[?b :block/marker "TODO"]
|
||||
[?b :block/page ?p]
|
||||
[?p :block/name ?name]]
|
||||
`)
|
||||
|
||||
// Min/Max
|
||||
const range = await logseq.DB.datascriptQuery(`
|
||||
[:find (min ?d) (max ?d)
|
||||
:where
|
||||
[?p :block/journal? true]
|
||||
[?p :block/journal-day ?d]]
|
||||
`)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. Cross-Compatible Plugin Example
|
||||
|
||||
```typescript
|
||||
import '@logseq/libs'
|
||||
|
||||
async function detectGraphType(): Promise<'db' | 'file'> {
|
||||
const result = await logseq.DB.datascriptQuery(`
|
||||
[:find ? type .
|
||||
:where [_ :block/type ?type]]
|
||||
`)
|
||||
return result ? 'db' : 'file'
|
||||
}
|
||||
|
||||
async function queryTodos(graphType: 'db' | 'file') {
|
||||
if (graphType === 'file') {
|
||||
return await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?b [*])
|
||||
:where [?b :block/marker "TODO"]]
|
||||
`)
|
||||
} else {
|
||||
return await logseq.DB.datascriptQuery(`
|
||||
[:find (pull ?b [*])
|
||||
:where
|
||||
[?b :block/tags ?tag]
|
||||
[?tag :db/ident :logseq.class/Todo]]
|
||||
`)
|
||||
}
|
||||
}
|
||||
|
||||
async function queryBlockContent(uuid: string, graphType: 'db' | 'file') {
|
||||
const attr = graphType === 'file' ? ': block/content' : ':block/title'
|
||||
return await logseq.DB.datascriptQuery(`
|
||||
[:find ? content .
|
||||
:in $ ?uuid
|
||||
:where
|
||||
[? b : block/uuid ?uuid]
|
||||
[?b ${attr} ?content]]
|
||||
`, `#uuid "${uuid}"`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const graphType = await detectGraphType()
|
||||
console.log('Graph type:', graphType)
|
||||
|
||||
logseq.Editor.registerSlashCommand('Query TODOs', async () => {
|
||||
try {
|
||||
const results = await queryTodos(graphType)
|
||||
const todos = results?.flat() || []
|
||||
|
||||
if (todos.length) {
|
||||
const content = todos.map(t => `- ((${t.uuid}))`).join('\n')
|
||||
await logseq.Editor.insertAtEditingCursor(content)
|
||||
} else {
|
||||
await logseq.UI.showMsg('No TODOs found', 'warning')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Query failed:', e)
|
||||
await logseq.UI.showMsg('Query failed', 'error')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logseq.ready().then(main)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9. Important Notes
|
||||
|
||||
1. **Page names are lowercase**: `:block/name` stores lowercase names; convert before querying
|
||||
2. **Parameter format**:
|
||||
- Strings: `"value"`
|
||||
- Numbers: `123`
|
||||
- UUIDs: `#uuid "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"`
|
||||
- Keywords: `:keyword`
|
||||
- Collections: `["a" "b" "c"]`
|
||||
3. **Flatten results**: Query results usually need `.flat()` processing
|
||||
4. **Error handling**: Always wrap queries in try-catch
|
||||
5. **Performance**: Use `pull` with specific attributes instead of `[*]` when possible
|
||||
6. **Graph compatibility**: Check graph type before using graph-specific attributes
|
||||
352
libs/guides/db_tag_property_idents_guide.md
Normal file
352
libs/guides/db_tag_property_idents_guide.md
Normal file
@@ -0,0 +1,352 @@
|
||||
# Property & Tag Idents
|
||||
|
||||
## What is an Ident?
|
||||
|
||||
An **ident** is a unique keyword identifier for a property or tag in the database. It's distinct from:
|
||||
- **name**: Human-readable display name (e.g., "Created At")
|
||||
- **uuid**: Universal unique identifier for the entity
|
||||
- **ident**: Internal database identifier (e.g., `:logseq.property/created-at`)
|
||||
|
||||
**Key characteristics:**
|
||||
- Idents use kebab-case with namespace prefixes
|
||||
- System properties use `:logseq.property/` namespace
|
||||
- System class properties use `:logseq.class/` namespace
|
||||
- Plugin created class use `:plugin.class.xxx-plugin-id/` namespace
|
||||
- Plugin created property use `:plugin.property.xxx-plugin-id/` namespace
|
||||
- Custom properties typically don't have explicit idents (auto-generated)
|
||||
|
||||
## When to Use Idents
|
||||
|
||||
Use idents when:
|
||||
1. Working with **built-in system properties**
|
||||
2. Setting **special behaviors** for properties
|
||||
3. Querying the database directly with datascript
|
||||
4. Creating **stable references** that survive renames
|
||||
|
||||
**Example:**
|
||||
```typescript
|
||||
// Using ident for system property
|
||||
await logseq.Editor. upsertBlockProperty(
|
||||
blockId,
|
||||
':logseq. property/created-at', // ident
|
||||
new Date().getTime()
|
||||
)
|
||||
|
||||
// Using regular property name
|
||||
await logseq.Editor. upsertBlockProperty(
|
||||
blockId,
|
||||
'author', // regular name
|
||||
'John Doe'
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Built-in System Properties
|
||||
|
||||
### Common System Properties
|
||||
|
||||
#### `:logseq.property/created-at`
|
||||
Timestamp when the block/page was created.
|
||||
|
||||
```typescript
|
||||
await logseq.Editor.upsertBlockProperty(
|
||||
page.uuid,
|
||||
':logseq.property/created-at',
|
||||
Date.now()
|
||||
)
|
||||
```
|
||||
|
||||
#### `:logseq.property/updated-at`
|
||||
Timestamp when the block/page was last updated.
|
||||
|
||||
```typescript
|
||||
await logseq.Editor.upsertBlockProperty(
|
||||
block.uuid,
|
||||
':logseq.property/updated-at',
|
||||
Date.now()
|
||||
)
|
||||
```
|
||||
|
||||
#### `:logseq.property/icon`
|
||||
Icon for a page or block (set via `setBlockIcon` API).
|
||||
|
||||
```typescript
|
||||
// Don't set directly, use API:
|
||||
await logseq.Editor. setBlockIcon(page.uuid, 'emoji', 'book')
|
||||
|
||||
// Behind the scenes, this sets:
|
||||
// :logseq.property/icon {: type "emoji", :id "book"}
|
||||
```
|
||||
|
||||
#### `:logseq.property/hide-empty-value`
|
||||
Hide property when value is empty.
|
||||
|
||||
```typescript
|
||||
const prop = await logseq.Editor.upsertProperty('optional-field', {
|
||||
type: 'default'
|
||||
})
|
||||
|
||||
await logseq.Editor.upsertBlockProperty(
|
||||
prop. id, // Apply to property entity itself
|
||||
':logseq.property/hide-empty-value',
|
||||
true
|
||||
)
|
||||
```
|
||||
|
||||
#### `:logseq.property/closed-value-mode`
|
||||
Property only accepts predefined values (enum-like).
|
||||
|
||||
```typescript
|
||||
const prop = await logseq.Editor. upsertProperty('status', { type: 'default' })
|
||||
|
||||
await logseq.Editor.upsertBlockProperty(
|
||||
prop.id,
|
||||
':logseq.property/closed-value-mode',
|
||||
true
|
||||
)
|
||||
|
||||
// Define allowed values
|
||||
await logseq.Editor.upsertBlockProperty(
|
||||
prop. id,
|
||||
':logseq.property/closed-values',
|
||||
['todo', 'in-progress', 'done']
|
||||
)
|
||||
```
|
||||
|
||||
#### `:logseq.property/schema`
|
||||
Property schema definition (type, cardinality, etc.).
|
||||
|
||||
```typescript
|
||||
// Automatically set by upsertProperty, don't set manually
|
||||
await logseq.Editor. upsertProperty('tags', {
|
||||
type: 'node',
|
||||
cardinality: 'many'
|
||||
})
|
||||
|
||||
// Behind the scenes sets:
|
||||
// :logseq. property/schema {:type "node", :cardinality "many"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Built-in Class (Tag) Properties
|
||||
|
||||
### Tag Inheritance & Schema
|
||||
|
||||
#### `:logseq.property. class/extends`
|
||||
Define parent classes (tag inheritance).
|
||||
|
||||
```typescript
|
||||
const mediaTag = await logseq.Editor.createTag('Media')
|
||||
const bookTag = await logseq. Editor.createTag('book')
|
||||
|
||||
// Use addTagExtends API (recommended)
|
||||
await logseq.Editor. addTagExtends(bookTag. uuid, mediaTag.uuid)
|
||||
|
||||
// Or set directly:
|
||||
await logseq.Editor. upsertBlockProperty(
|
||||
bookTag.uuid,
|
||||
': logseq.property.class/extends',
|
||||
[mediaTag.id] // Array of parent tag IDs
|
||||
)
|
||||
```
|
||||
|
||||
#### `:logseq.property.class/properties`
|
||||
Properties available for this class.
|
||||
|
||||
```typescript
|
||||
// Use addTagProperty API (recommended)
|
||||
await logseq.Editor.addTagProperty(bookTag.uuid, 'author')
|
||||
|
||||
// Or set directly:
|
||||
const authorProp = await logseq.Editor.getProperty('author')
|
||||
await logseq.Editor.upsertBlockProperty(
|
||||
bookTag.uuid,
|
||||
':logseq.property. class/properties',
|
||||
[authorProp.id]
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Working with Idents
|
||||
|
||||
### Pattern 1: Query by Ident
|
||||
|
||||
```typescript
|
||||
// Get property entity by ident
|
||||
const createdAtProp = await logseq. DB.datascriptQuery(`
|
||||
[: find (pull ?p [*])
|
||||
:where
|
||||
[?p :block/type "property"]
|
||||
[?p :block/ident : logseq.property/created-at]]
|
||||
`)
|
||||
|
||||
console.log(createdAtProp)
|
||||
```
|
||||
|
||||
### Pattern 2: Check if Property Has Ident
|
||||
|
||||
```typescript
|
||||
async function getPropertyIdent(propertyName: string) {
|
||||
const prop = await logseq.Editor. getProperty(propertyName)
|
||||
return prop?.ident || null
|
||||
}
|
||||
|
||||
const ident = await getPropertyIdent('created-at')
|
||||
// Returns: ": logseq.property/created-at" or null
|
||||
```
|
||||
|
||||
### Pattern 3: Create Custom Namespaced Property
|
||||
|
||||
```typescript
|
||||
// For plugin-specific properties, use your own namespace
|
||||
const pluginNamespace = 'myplugin'
|
||||
|
||||
async function createPluginProperty(name: string, schema: any) {
|
||||
// Note: Custom idents are not directly settable via API
|
||||
// They're auto-generated, but you can query by name
|
||||
|
||||
const prop = await logseq.Editor.upsertProperty(name, schema)
|
||||
|
||||
// Custom properties won't have idents unless they're system properties
|
||||
return prop
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Set Multiple System Properties
|
||||
|
||||
```typescript
|
||||
async function setupPageMetadata(pageUUID: string, metadata: any) {
|
||||
const now = Date.now()
|
||||
|
||||
// Set creation timestamp
|
||||
await logseq. Editor.upsertBlockProperty(
|
||||
pageUUID,
|
||||
':logseq.property/created-at',
|
||||
metadata.createdAt || now
|
||||
)
|
||||
|
||||
// Set update timestamp
|
||||
await logseq. Editor.upsertBlockProperty(
|
||||
pageUUID,
|
||||
':logseq.property/updated-at',
|
||||
now
|
||||
)
|
||||
|
||||
// Set custom properties
|
||||
await logseq. Editor.upsertBlockProperty(pageUUID, 'author', metadata.author)
|
||||
await logseq.Editor.upsertBlockProperty(pageUUID, 'source', metadata.source)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 5: Configure Property Behavior
|
||||
|
||||
```typescript
|
||||
async function createEnumProperty(name: string, allowedValues: string[]) {
|
||||
// 1. Create property
|
||||
const prop = await logseq.Editor.upsertProperty(name, { type: 'default' })
|
||||
|
||||
// 2. Enable closed-value mode
|
||||
await logseq.Editor.upsertBlockProperty(
|
||||
prop.id,
|
||||
':logseq.property/closed-value-mode',
|
||||
true
|
||||
)
|
||||
|
||||
// 3. Set allowed values
|
||||
await logseq.Editor. upsertBlockProperty(
|
||||
prop.id,
|
||||
':logseq.property/closed-values',
|
||||
allowedValues
|
||||
)
|
||||
|
||||
// 4. Optionally hide when empty
|
||||
await logseq. Editor.upsertBlockProperty(
|
||||
prop.id,
|
||||
':logseq.property/hide-empty-value',
|
||||
true
|
||||
)
|
||||
|
||||
return prop
|
||||
}
|
||||
|
||||
// Usage
|
||||
await createEnumProperty('priority', ['high', 'medium', 'low'])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## System Property Reference Table
|
||||
|
||||
| Ident | Purpose | Value Type | Applied To |
|
||||
|-------|---------|------------|------------|
|
||||
| `:logseq.property/created-at` | Creation timestamp | number (ms) | Block/Page |
|
||||
| `:logseq.property/updated-at` | Update timestamp | number (ms) | Block/Page |
|
||||
| `:logseq.property/icon` | Icon definition | `{type, id}` object | Block/Page |
|
||||
| `:logseq.property/hide-empty-value` | Hide if empty | boolean | Property entity |
|
||||
| `:logseq.property/closed-value-mode` | Enum mode | boolean | Property entity |
|
||||
| `:logseq.property/closed-values` | Allowed values | array | Property entity |
|
||||
| `:logseq.property/schema` | Schema definition | object | Property entity |
|
||||
| `:logseq.property. class/extends` | Parent classes | array of IDs | Tag entity |
|
||||
| `:logseq.property.class/properties` | Class properties | array of IDs | Tag entity |
|
||||
|
||||
---
|
||||
|
||||
## Best Practices for Idents
|
||||
|
||||
### 1. Prefer High-Level APIs Over Direct Ident Manipulation
|
||||
Use specialized APIs instead of setting idents directly:
|
||||
|
||||
```typescript
|
||||
// ✅ Better: Use API
|
||||
await logseq.Editor.addTagExtends(childTag.uuid, parentTag.uuid)
|
||||
|
||||
// ❌ Avoid: Direct ident manipulation
|
||||
await logseq. Editor.upsertBlockProperty(
|
||||
childTag.uuid,
|
||||
': logseq.property. class/extends',
|
||||
[parentTag.id]
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Only Use Idents for System Properties
|
||||
Custom properties should use regular names:
|
||||
|
||||
```typescript
|
||||
// ✅ Correct: System property with ident
|
||||
await logseq. Editor.upsertBlockProperty(
|
||||
page.uuid,
|
||||
': logseq.property/created-at',
|
||||
Date.now()
|
||||
)
|
||||
|
||||
// ✅ Correct: Custom property with name
|
||||
await logseq.Editor. upsertBlockProperty(
|
||||
page.uuid,
|
||||
'author',
|
||||
'John Doe'
|
||||
)
|
||||
|
||||
// ❌ Wrong: Don't create custom idents
|
||||
await logseq.Editor. upsertBlockProperty(
|
||||
page.uuid,
|
||||
':myplugin/custom-prop', // Not supported
|
||||
'value'
|
||||
)
|
||||
```
|
||||
|
||||
### 3. Query System Properties by Ident in Datascript
|
||||
When using advanced queries, idents provide stable references:
|
||||
|
||||
```typescript
|
||||
// Query all pages created in the last 7 days
|
||||
const recentPages = await logseq. DB.datascriptQuery(`
|
||||
[:find (pull ?p [*])
|
||||
:where
|
||||
[?p :block/type "page"]
|
||||
[?p : logseq.property/created-at ? created]
|
||||
[(> ?created ${Date.now() - 7 * 24 * 60 * 60 * 1000})]]
|
||||
`)
|
||||
```
|
||||
804
libs/guides/experiments_api_guide.md
Normal file
804
libs/guides/experiments_api_guide.md
Normal file
@@ -0,0 +1,804 @@
|
||||
# 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.Experiments` is 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:
|
||||
|
||||
1. **React integration**: `React`, `ReactDOM`
|
||||
2. **Internal components**: `Components.Editor`
|
||||
3. **Interop utilities**: `Utils.toClj`, `toJs`, `jsxToClj`, `toKeyword`, `toSymbol`
|
||||
4. **Script loading**: `loadScripts(...)`
|
||||
5. **Renderer registration**:
|
||||
- `registerFencedCodeRenderer(...)`
|
||||
- `registerDaemonRenderer(...)`
|
||||
- `registerRouteRenderer(...)`
|
||||
- `registerHostedRenderer(...)`
|
||||
- `registerSidebarRenderer(...)`
|
||||
- `registerBlockPropertiesRenderer(...)`
|
||||
- `registerBlockRenderer(...)`
|
||||
6. **Extension enhancers**: `registerExtensionsEnhancer(...)`
|
||||
7. **Host/plugin internals**:
|
||||
- `pluginLocal`
|
||||
- `ensureHostScope()`
|
||||
- `invokeExperMethod(...)`
|
||||
|
||||
---
|
||||
|
||||
## 1. React Integration
|
||||
|
||||
Use the host's React runtime instead of bundling your own copy.
|
||||
|
||||
> **React version note**
|
||||
>
|
||||
> Current Logseq hosts ship React 19.x and ReactDOM 19.x. Treat
|
||||
> `logseq.Experiments.React` and `logseq.Experiments.ReactDOM` as the
|
||||
> host's concrete runtime, not as version-neutral compatibility shims.
|
||||
>
|
||||
> Compatibility guidance:
|
||||
>
|
||||
> - Prefer the host React runtime for elements returned from experimental
|
||||
> renderers.
|
||||
> - Do not mix host React elements, hooks, contexts, or portals with a
|
||||
> separately bundled React copy.
|
||||
> - Audit third-party integrations before attaching them to the host
|
||||
> runtime. Older helpers that rely on legacy root APIs such as
|
||||
> `ReactDOM.render`, `ReactDOM.hydrate`, `ReactDOM.unmountComponentAtNode`,
|
||||
> or `findDOMNode` may need updates for React 19.
|
||||
> - In most cases you do **not** need to mount manually with `ReactDOM`.
|
||||
> Return React elements from the experimental `render` callback and let
|
||||
> Logseq own the root.
|
||||
|
||||
### `logseq.Experiments.React`
|
||||
|
||||
Returns the React instance from the host scope.
|
||||
|
||||
```typescript
|
||||
const React = logseq.Experiments.React
|
||||
```
|
||||
|
||||
### `logseq.Experiments.ReactDOM`
|
||||
|
||||
Returns the ReactDOM instance from the host scope.
|
||||
|
||||
```typescript
|
||||
const ReactDOM = logseq.Experiments.ReactDOM
|
||||
```
|
||||
|
||||
### Example
|
||||
|
||||
```typescript
|
||||
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**
|
||||
|
||||
```typescript
|
||||
(props: { page: string } & Record<string, any>) => any
|
||||
```
|
||||
|
||||
**Parameters**
|
||||
|
||||
- `page`: page name to render
|
||||
|
||||
```typescript
|
||||
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.
|
||||
|
||||
```typescript
|
||||
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.
|
||||
|
||||
```typescript
|
||||
const view = { type: 'div', props: { children: 'Content' } }
|
||||
const cljView = logseq.Experiments.Utils.jsxToClj(view)
|
||||
```
|
||||
|
||||
### `toJs(input: any)`
|
||||
|
||||
Convert ClojureScript values back into plain JavaScript.
|
||||
|
||||
```typescript
|
||||
const jsData = logseq.Experiments.Utils.toJs(cljData)
|
||||
```
|
||||
|
||||
### `toKeyword(input: any)`
|
||||
|
||||
Convert a string into a ClojureScript keyword.
|
||||
|
||||
```typescript
|
||||
const keyword = logseq.Experiments.Utils.toKeyword('my-key')
|
||||
```
|
||||
|
||||
### `toSymbol(input: any)`
|
||||
|
||||
Convert a string into a ClojureScript symbol.
|
||||
|
||||
```typescript
|
||||
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**
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
```typescript
|
||||
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:
|
||||
|
||||
````markdown
|
||||
```my-lang
|
||||
...
|
||||
```
|
||||
````
|
||||
|
||||
### `logseq.Experiments.registerFencedCodeRenderer(lang, opts)`
|
||||
|
||||
```text
|
||||
registerFencedCodeRenderer(
|
||||
lang: string,
|
||||
opts: {
|
||||
edit?: boolean
|
||||
before?: () => Promise<void>
|
||||
subs?: string[]
|
||||
render: (props: { content: string }) => any
|
||||
}
|
||||
): any
|
||||
```
|
||||
|
||||
**Options**
|
||||
|
||||
- `edit`: whether the fenced block remains editable
|
||||
- `before`: async preload hook, usually for loading scripts/assets
|
||||
- `subs`: experimental subscription list
|
||||
- `render`: React renderer receiving `{ content }`
|
||||
|
||||
```typescript
|
||||
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)`
|
||||
|
||||
```text
|
||||
registerDaemonRenderer(
|
||||
key: string,
|
||||
opts: {
|
||||
before?: () => Promise<void>
|
||||
subs?: string[]
|
||||
render: (props: {}) => any
|
||||
}
|
||||
): any
|
||||
```
|
||||
|
||||
**Notes**
|
||||
|
||||
- use `subs`, not `sub`
|
||||
- `before` is 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
|
||||
|
||||
```typescript
|
||||
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)`
|
||||
|
||||
```text
|
||||
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 reused
|
||||
- `subs`: experimental subscription list
|
||||
- `render`: route component
|
||||
|
||||
```typescript
|
||||
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 `path` rather 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)`
|
||||
|
||||
```text
|
||||
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 renderer
|
||||
- `type`: host-specific placement type
|
||||
- `mode`: host-specific placement mode
|
||||
- `subs`: experimental subscription list
|
||||
- `render`: 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)`
|
||||
|
||||
```text
|
||||
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}`
|
||||
- `type` is forced to `'sidebar'`
|
||||
- the renderer appears in the right-sidebar plugin menu
|
||||
|
||||
```typescript
|
||||
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)`
|
||||
|
||||
```text
|
||||
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**
|
||||
|
||||
- `when` may be omitted, a declarative condition, or a synchronous predicate
|
||||
- `mode` controls placement in the properties area:
|
||||
- `prepend`: before native properties
|
||||
- `append`: after native properties
|
||||
- `replace`: replace native properties UI
|
||||
- higher `priority` wins 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 string
|
||||
- `properties`: 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
|
||||
|
||||
```typescript
|
||||
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)`
|
||||
|
||||
```text
|
||||
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**
|
||||
|
||||
- `when` must be a **synchronous predicate function** if provided
|
||||
- declarative conditions are **not** supported here
|
||||
- highest `priority` match wins
|
||||
- when the plugin renderer is active, users can switch back to the native outline view via built-in UI on that block
|
||||
- when `includeChildren` is `true`, Logseq passes a recursive child tree and hides native outline children while the plugin renderer is active
|
||||
|
||||
**Render props**
|
||||
|
||||
- `blockId`: block UUID string
|
||||
- `uuid`: same block UUID
|
||||
- `page`: page title
|
||||
- `content`: block content/title text
|
||||
- `format`: `'markdown'`, `'org'`, etc.
|
||||
- `properties`: normalized property object
|
||||
- `children`: recursive normalized child tree when `includeChildren` is enabled
|
||||
|
||||
```typescript
|
||||
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.
|
||||
|
||||
```text
|
||||
registerExtensionsEnhancer(
|
||||
type: 'katex' | 'codemirror',
|
||||
enhancer: (value: any) => Promise<any>
|
||||
): any
|
||||
```
|
||||
|
||||
For `katex`, the host immediately invokes the enhancer if KaTeX is already present.
|
||||
|
||||
```typescript
|
||||
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.
|
||||
|
||||
```typescript
|
||||
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.
|
||||
|
||||
```typescript
|
||||
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.
|
||||
|
||||
```typescript
|
||||
const result = logseq.Experiments.invokeExperMethod(
|
||||
'someExperimentalFeature',
|
||||
arg1,
|
||||
arg2
|
||||
)
|
||||
```
|
||||
|
||||
`type` is normalized to snake_case before resolution.
|
||||
|
||||
---
|
||||
|
||||
## 8. Complete Example: Fenced Code Renderer
|
||||
|
||||
```typescript
|
||||
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**
|
||||
|
||||
````markdown
|
||||
```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
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
1. **Prefer stable APIs first**. Only use `Experiments` when the stable SDK cannot solve the problem.
|
||||
2. **Use host React**. Avoid bundling a second React runtime into the same tree.
|
||||
3. **Keep `when` predicates synchronous**. This is especially important for `registerBlockRenderer(...)`.
|
||||
4. **Use `before` to preload dependencies** instead of doing ad hoc script injection inside render.
|
||||
5. **Treat `subs` as experimental**. Reactive semantics may change.
|
||||
6. **Keep renderers lightweight**. Block and daemon renderers can affect overall app responsiveness.
|
||||
7. **Handle bad input defensively**. Render props often contain user-authored content and properties.
|
||||
8. **Document your experimental usage** in the plugin README so users understand the risk.
|
||||
9. **Prefer `registerSidebarRenderer(...)` over raw hosted renderers** when your goal is a right-sidebar tool.
|
||||
10. **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-scripts`
|
||||
- `register-fenced-code-renderer`
|
||||
- `register-daemon-renderer`
|
||||
- `register-hosted-renderer`
|
||||
- `register-sidebar-renderer`
|
||||
- `register-route-renderer`
|
||||
- `register-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](./starter_guide.md) - getting started with plugin development
|
||||
- [DB Properties Guide](./db_properties_guide.md) - working with database properties
|
||||
- [DB Query Guide](./db_query_guide.md) - querying the Logseq database
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For questions and issues:
|
||||
|
||||
- [Logseq Discord](https://discord.gg/logseq) - `#plugin-dev`
|
||||
- [GitHub Discussions](https://github.com/logseq/logseq/discussions)
|
||||
- [Plugin API Documentation](https://plugins-doc.logseq.com/)
|
||||
|
||||
Remember: these are experimental features. Use them carefully and test thoroughly.
|
||||
125
libs/guides/starter_guide.md
Normal file
125
libs/guides/starter_guide.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Logseq Plugin Starter Guide
|
||||
|
||||
# For Developers
|
||||
|
||||
In this short guide, it will walk you through the steps needed to set up your development environment for writing and
|
||||
running a hello world simple inside of Logseq Desktop Client. You know Logseq Plugin based on Web Technologies composed
|
||||
of JS & HTML & CSS, but neither are mandatory, you can develop plugins use any language which can be translated to
|
||||
javascript (Logseq made by Clojurescript!). We will use Typescript to demonstrate this sample. Because there
|
||||
is [a type definition file](https://www.npmjs.com/package/@logseq/libs) to make development experience even better.
|
||||
|
||||
## Install Node.js and NPM
|
||||
|
||||
You can download Node.js here, which will include NPM: https://nodejs.org/en/download/.
|
||||
|
||||
## Install TypeScript
|
||||
|
||||
To install TypeScript, run `npm install -g typescript` in a terminal.
|
||||
|
||||
## Get Logseq desktop app
|
||||
|
||||
At this time, plugin development and testing needs to be done using the Logseq desktop app. This is because Logseq needs
|
||||
to read your code saved as a local file. The Logseq desktop app can be downloaded
|
||||
here: https://github.com/logseq/logseq/releases.
|
||||
|
||||
If you already have the desktop app, please make sure to update to the latest version, as several features have been
|
||||
added specifically in order to provide a better plugin development experience.
|
||||
|
||||
## Go to Settings > Turn on Developer Mode
|
||||
|
||||
This will bring up the "Plugins" entry in three dots more menu list on top right of head bar. Go to Plugins page, and
|
||||
you will get a button with `Load unpacked plugin` label.
|
||||
|
||||
## Create plugin package from scaffold
|
||||
|
||||
We will use [logseq-hello-world](https://github.com/logseq/logseq-plugin-samples/tree/master/logseq-hello-world) as
|
||||
scaffold. Download this package files using Git or download zip straight to your own plugin package directory.
|
||||
|
||||
## Explain `package.json`
|
||||
|
||||
Generally Logseq plugin package is described by `package.json` file located root. Let's checkout these primary keys.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "logseq-plugin-hello-world",
|
||||
"version": "0.0.1",
|
||||
"main": "dist/index.html",
|
||||
"logseq": {
|
||||
"id": "yet-another-hello-world-plugin",
|
||||
"title": "Say Hi to Logseq",
|
||||
"icon": "logseq.png"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- __name__ [required] a name of plugin.
|
||||
- it starts with `logseq-plugin` is better for Googling.
|
||||
- __version__ [required] semantic version
|
||||
- related link: https://semver.org/
|
||||
- __main__ [optional] entry of plugin, optional for theme plugin only.
|
||||
- `.html` file entry indicate that the plugin provide main iframe ui.
|
||||
- `.js` file entry indicate that the plugin without main iframe ui.
|
||||
- __logseq__ [required] specs of plugin & indicate it's a Logseq package.
|
||||
- `id` [optional] the identity key of plugin. if you don't provide it that will be auto generated on load
|
||||
development package.
|
||||
- `title` [optional] the title display of plugin list card. if not provided, value of `pkg#name` would be used.
|
||||
- `icon` [optional] plugin logo for your own brand :D.
|
||||
- `minSDKVersion` - [optional] the minimum sdk version what your plugin required. eg: `0.1.0`.
|
||||
- `supportsDB` - [optional] the boolean value indicate whether your plugin support the database feature.
|
||||
- `supportsDBOnly` - [optional] the boolean value indicate whether your plugin only support the database feature.
|
||||
|
||||
Apart from these primary keys, you might find other useful fields, e.g. `author`, `description`, `repository`.
|
||||
Although these are not necessary, but completed fields are kind for your plugin users.
|
||||
|
||||
## Develop your code
|
||||
|
||||
This scaffold using [Parcel](https://v2.parceljs.org/) as `ts` bundler, just it works out of box. Of Course, you could
|
||||
choose another one what you prefer. Then install dependencies `npm install` and watch your code in development mode
|
||||
`npm run dev`. It will build the development version outputs to the dist directory. Go to plugins page and pick your
|
||||
plugin root directory by `Load unpacked plugin` button. You will get a message from this plugin. Let's explain the entry
|
||||
file of `index.ts`.
|
||||
|
||||
### 1. import user plugin SDK [required]
|
||||
|
||||
```js
|
||||
import '@logseq/libs'
|
||||
```
|
||||
|
||||
It provides TS types and global sdk namespace `logseq`.
|
||||
|
||||
### 2. bootstrap your main function [required]
|
||||
|
||||
```js
|
||||
logseq.ready(main).catch(console.error)
|
||||
```
|
||||
|
||||
Limited by the plugin system mechanism, user main function should wait for some preparations to make sure the SDK api
|
||||
works correctly!
|
||||
|
||||
### 3. show your code [required]
|
||||
|
||||
```js
|
||||
function main () {
|
||||
logseq.UI.showMsg('❤️ Message from Hello World Plugin :)')
|
||||
}
|
||||
```
|
||||
|
||||
Change your code and reload the plugin to make it works.
|
||||
|
||||
> If you develop main ui in iframe, [HMR](https://webpack.js.org/concepts/hot-module-replacement/) feature provided by
|
||||
> some ui frameworks is convenient that can avoid reloading plugin again and again.
|
||||
|
||||
🎉 Actually, you're done!
|
||||
|
||||
## Next steps
|
||||
|
||||
- Awesome samples
|
||||
- https://github.com/logseq/logseq-plugin-samples
|
||||
- https://github.com/logseq/awesome-logseq
|
||||
- Community plugins
|
||||
- https://github.com/search?q=logseq-plugin
|
||||
|
||||
# For users
|
||||
|
||||
Currently, Logseq plugin can only run in developer mode. Under this mode, plugin has ability to access notes data. So if
|
||||
you're working with sensitive data, you'd better confirm the plugin is from trusted resource before installing it.
|
||||
Reference in New Issue
Block a user