| name | hspa |
| description | Use this skill when building iframe-based HTML UI pages for a SiYuan plugin with @frostime/siyuan-hspa, including Vite asset copying, openIframeTab/openIframeDialog integration, pluginSdk/customSdk contracts, hspa-mini.css, Alpine pages, examples, or troubleshooting HSPA page loading and SDK injection. |
HSPA for SiYuan Plugin UI
HSPA = HTML Single Page Application loaded in a plugin-owned iframe. Use @frostime/siyuan-hspa when a SiYuan plugin feature needs an isolated HTML UI page opened as a tab/dialog, with a host-injected window.pluginSdk bridge.
Core Contract
Host plugin TypeScript
→ openIframeTab(plugin, options) or openIframeDialog(plugin, options)
→ iframe loads /plugins/<plugin-name>/pages/<file>.html or html-text
→ package injects window.pluginSdk
→ iframe receives pluginSdkReady
→ HTML page calls preset SDK helpers and customSdk functions
Hard boundaries:
- HSPA is for iframe UI, not Protyle editor internals, main-window DOM surgery, or event-bus-heavy plugin logic.
- Put SiYuan/editor/business operations in the host plugin; expose narrow functions through
customSdk.
- The package does not provide page storage helpers such as
loadConfig, saveConfig, loadAsset, or saveAsset.
- The package does not transform HTML and does not provide
hspa-client.js.
- The package ships
hspa-mini.css and Alpine; it does not ship Vue.
Install and Asset Copy
Host plugin dependencies:
pnpm add @frostime/siyuan-hspa
pnpm add -D siyuan vite-plugin-static-copy
Project-local agent setup:
siyuan-hspa init --dry-run
siyuan-hspa init --yes
init copies .agents/skills/siyuan-hspa/SKILL.md and src/pages/hspa-demo.html by default. It writes only inside --cwd, requires --yes for writes, and never overwrites existing files unless --force is set. It does not create docs/.
Use siyuan-hspa doctor for read-only checks after wiring Vite/pages.
Add HSPA assets to the host plugin build:
import { defineConfig } from 'vite';
import { viteStaticCopy } from 'vite-plugin-static-copy';
import { hspaStaticCopyTargets } from '@frostime/siyuan-hspa/vite';
export default defineConfig({
plugins: [
viteStaticCopy({
targets: [
...hspaStaticCopyTargets(),
{ src: 'src/pages/*.html', dest: 'pages' },
],
}),
],
});
Default copy result:
dist/hspa/styles/hspa-mini.css
dist/hspa/scripts/alpine.min.js
HTML pages run under SiYuan's plugin asset route. Use the plugin manifest name from plugin.json, not a relative filesystem path:
<link rel="stylesheet" href="/plugins/<plugin-name>/hspa/styles/hspa-mini.css">
<script src="/plugins/<plugin-name>/hspa/scripts/alpine.min.js" defer></script>
pnpm exec siyuan-hspa init --yes generates src/pages/hspa-demo.html with <plugin-name> replaced from plugin.json#name when available.
If hspaStaticCopyTargets({ dest: 'vendor/hspa' }) is used, update HTML links accordingly:
<link rel="stylesheet" href="/plugins/<plugin-name>/vendor/hspa/styles/hspa-mini.css">
<script src="/plugins/<plugin-name>/vendor/hspa/scripts/alpine.min.js" defer></script>
Runtime API
Main imports:
import {
createIframePage,
createSiyuanIframePage,
openIframeTab,
openIframeDialog,
buildPresetSdk,
pluginAssetUrl,
hspaPageUrl,
} from '@frostime/siyuan-hspa';
Use cases:
| API | Use when | Preset SDK |
|---|
openIframeTab(plugin, options) | open/focus an HSPA page in a SiYuan tab | yes by default |
openIframeDialog(plugin, options) | open an HSPA page in a SiYuan dialog | yes by default |
createSiyuanIframePage(plugin, container, config) | mount an HSPA iframe into an existing host container | yes by default |
createIframePage(container, config) | low-level iframe only; caller owns SDK | no SiYuan preset factory |
hspaPageUrl(plugin, 'foo.html') | build /plugins/<name>/pages/foo.html | n/a |
pluginAssetUrl(plugin, path) | build /plugins/<name>/<path> | n/a |
Low-level warning: do not call createIframePage(container, { inject: { presetSdk: true } }) expecting SiYuan helpers. Use createSiyuanIframePage or set presetSdk: false and provide all SDK fields through customSdk.
Open a Page from Plugin Code
import { hspaPageUrl, openIframeTab } from '@frostime/siyuan-hspa';
import type { Plugin } from 'siyuan';
export function openDemo(plugin: Plugin) {
const tab = openIframeTab(plugin, {
tabId: 'demo-hspa',
title: 'Demo HSPA',
icon: 'iconHTML5',
iframeConfig: {
type: 'url',
source: hspaPageUrl(plugin, 'demo.html'),
inject: {
presetSdk: true,
customSdk: {
getGreeting: () => 'hello from host plugin',
saveItems: async (items: unknown[]) => saveItems(items),
},
},
onLoadEvents: {
'host-message': { text: 'initial event from host plugin' },
},
},
});
const sendRefresh = () => {
if (tab.isAlive()) {
tab.dispatchEvent('host-message', { text: 'refresh from host plugin' });
}
};
return { sendRefresh };
}
customSdk is flat-merged into window.pluginSdk:
await window.pluginSdk.saveItems(items);
await window.pluginSdk.customSdk.saveItems(items);
Iframe Config
interface IframePageConfig {
type: 'url' | 'html-text';
source: string;
iframeStyle?: Record<string, unknown>;
inject?: {
presetSdk?: boolean;
siyuanCss?: boolean;
customSdk?: Record<string, unknown>;
customCss?: Record<string, string>;
};
onLoadEvents?: Record<string, unknown>;
onLoad?: (iframe: HTMLIFrameElement) => void;
onDestroy?: () => void;
}
customCss is merged into injected root CSS variables, e.g. { '--my-accent': '#f00' }. Use page-local <style> for selectors and full CSS rules.
Returned handles expose:
interface IframePageHandle {
cleanup(): void;
dispatchEvent(eventName: string, detail?: unknown): void;
iframeRef: WeakRef<HTMLIFrameElement>;
isAlive(): boolean;
}
Preset SDK
With presetSdk: true, window.pluginSdk includes stable core helpers:
request(endpoint, data)
querySQL(sql)
getBlockByID(id)
getMarkdown(id)
lsNotebooks()
openBlock(id)
confirm(title, text, onConfirm?, onCancel?)
showMessage(message, type?, duration?)
showDialog(options)
themeMode
styleVar
lute
Rules:
- Use
pluginSdk.request('/api/...', payload) for SiYuan kernel APIs not covered by helpers.
- Use
customSdk for host-plugin business state, persistence, permissions, and side effects.
- Do not assume storage helpers exist unless the host plugin explicitly injects them.
HTML Authoring Rules
A page must wait for pluginSdkReady before using window.pluginSdk:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="/plugins/<plugin-name>/hspa/styles/hspa-mini.css">
</head>
<body>
<main class="page">
<button id="ping" class="btn btn-primary">Ping</button>
</main>
<script>
window.addEventListener('pluginSdkReady', async () => {
const sdk = window.pluginSdk;
document.documentElement.setAttribute('data-theme-mode', sdk.themeMode || 'light');
document.getElementById('ping').addEventListener('click', () => {
sdk.showMessage('pong');
});
});
</script>
</body>
</html>
Checklist for every HSPA HTML page:
- Load from same-origin plugin pages (
/plugins/<plugin-name>/pages/*.html) or html-text; cross-origin iframes cannot receive SDK injection.
- For
onLoadEvents, register listeners before awaiting pluginSdkReady; otherwise the first host event can be missed.
- Use
pluginSdk.showMessage() and pluginSdk.confirm() instead of native alert(), confirm(), or prompt().
- Avoid CDN; use bundled assets or host-plugin assets.
- Use only classes provided by
hspa-mini.css; it is not Tailwind.
- Use
.text-muted, not .muted.
Alpine Page Pattern
Use Alpine for medium-complexity pages. Load Alpine last.
<link rel="stylesheet" href="/plugins/<plugin-name>/hspa/styles/hspa-mini.css">
<style>[x-cloak] { display: none !important; }</style>
<main class="page" x-data="app()" x-init="init()" x-cloak>
<section class="card">
<h1>HSPA</h1>
<p class="text-muted">Theme: <span x-text="themeMode"></span></p>
<button class="btn btn-primary" @click="ping">Ping</button>
</section>
</main>
<script>
function app() {
return {
_initialized: false,
themeMode: 'unknown',
async init() {
if (this._initialized) return;
this._initialized = true;
window.addEventListener('host-message', (event) => {
console.log('host-message', event.detail);
});
await new Promise((resolve) => {
if (window.pluginSdk) return resolve();
window.addEventListener('pluginSdkReady', resolve, { once: true });
});
this.themeMode = window.pluginSdk.themeMode || 'light';
document.documentElement.setAttribute('data-theme-mode', this.themeMode);
},
ping() {
window.pluginSdk.showMessage('pong');
}
};
}
</script>
<script src="/plugins/<plugin-name>/hspa/scripts/alpine.min.js" defer></script>
Alpine gotchas:
| Rule | Why |
|---|
[x-cloak] { display: none !important; } | prevents template flash |
_initialized guard | protects against duplicate init |
define app() before Alpine script | Alpine parses after load |
load Alpine last with defer | predictable startup |
use Alpine.raw(data) before host persistence calls | strips Alpine proxies |
hspa-mini.css
hspa-mini.css provides a small semantic class set and CSS variables. It is intentionally not Tailwind.
Common classes:
page, card, flex, flex-row, flex-col, flex-wrap, grid, gap-1..gap-8,
items-center, justify-between, flex-center, flex-between,
btn, btn-primary, btn-danger, btn-success, btn-ghost, btn-icon, btn-sm, btn-lg,
input, select, textarea,
badge, badge-primary, badge-success, badge-warning, badge-error, tag,
table,
text-muted, text-accent, text-success, text-error, text-warning, text-sm, text-lg,
mt-*, mb-*, p-*, hidden
If a class is not defined by hspa-mini.css, it silently does nothing. Prefer page-local CSS for feature-specific styling.
Examples in This Package
Use these as copyable references:
examples/vanilla-page.html
examples/alpine-page.html
examples/mock-plugin/
The mock plugin uses workspace:* so it resolves to the local package while developing this repository. If copying the mock plugin outside the repository, replace workspace:* with the published version range, for example ^0.1.1.
Validation Before Reporting Done
Run package checks after editing HSPA package code, examples, CLI, or this SKILL:
cd <siyuan-hspa-repo>
pnpm run type-check
pnpm build
node dist/cli.js --help
node dist/cli.js init --dry-run --cwd examples/mock-plugin
node dist/cli.js doctor --cwd examples/mock-plugin
pnpm pack --dry-run
cd examples/mock-plugin
pnpm exec tsc --noEmit --pretty false
pnpm build
Expected pack contents include dist, assets, examples, skill, README.md, and no docs/ directory.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|
window.pluginSdk is undefined | page used SDK before pluginSdkReady | wait for pluginSdkReady |
| custom API missing | code used pluginSdk.customSdk.foo | call pluginSdk.foo |
| CSS 404 | assets not copied or wrong relative path | configure hspaStaticCopyTargets() and use /plugins/<plugin-name>/hspa/... |
| Alpine page flashes templates | missing [x-cloak] style | add cloak style |
dispatchEvent warning before ready | tab iframe not alive yet | use onLoadEvents, wait, or check handle.isAlive() |
| preset SDK missing in low-level iframe | used createIframePage directly | use createSiyuanIframePage or provide full custom SDK |