@nuxtblog/plugin-sdk 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +849 -308
  2. package/README.zh.md +857 -0
  3. package/package.json +4 -2
package/README.md CHANGED
@@ -1,308 +1,849 @@
1
- # @nuxtblog/plugin-sdk
2
-
3
- TypeScript type definitions and base `tsconfig` for [nuxtblog](https://github.com/nuxtblog/nuxtblog) plugins.
4
-
5
- ## Installation
6
-
7
- ```bash
8
- pnpm add -D @nuxtblog/plugin-sdk
9
- # or
10
- npm install -D @nuxtblog/plugin-sdk
11
- ```
12
-
13
- ## Usage
14
-
15
- ### tsconfig.json
16
-
17
- ```json
18
- {
19
- "extends": "@nuxtblog/plugin-sdk",
20
- "include": ["src"]
21
- }
22
- ```
23
-
24
- This gives you strict TypeScript compiler options and the global `nuxtblog` object with full type coverage.
25
-
26
- ---
27
-
28
- ## Plugin manifest (package.json)
29
-
30
- Every plugin declares its metadata and permissions in `package.json` under the `"plugin"` field.
31
- Capabilities must be explicitly declared — only declared APIs are injected into the plugin VM.
32
-
33
- ```json
34
- {
35
- "name": "owner/my-plugin",
36
- "version": "1.0.0",
37
- "description": "What it does",
38
- "author": "owner",
39
- "license": "MIT",
40
- "plugin": {
41
- "title": "My Plugin",
42
- "icon": "i-tabler-plug",
43
- "entry": "dist/index.js",
44
- "priority": 10,
45
- "capabilities": {
46
- "http": { "allow": ["hooks.slack.com"], "timeout_ms": 5000 },
47
- "store": { "read": true, "write": true }
48
- },
49
- "settings": [
50
- { "key": "webhook_url", "label": "Slack Webhook URL", "type": "string", "required": true }
51
- ]
52
- }
53
- }
54
- ```
55
-
56
- ---
57
-
58
- ## Plugin API
59
-
60
- ### `nuxtblog.filter` — synchronous interceptor
61
-
62
- Runs **before** data is written to the database. Modify `ctx.data` or call `ctx.abort()` to cancel.
63
- HTTP requests are **not allowed** inside filter handlers.
64
-
65
- ```ts
66
- // src/index.ts
67
-
68
- nuxtblog.filter('post.create', (ctx) => {
69
- if (!ctx.data.title) {
70
- ctx.abort('title is required')
71
- return
72
- }
73
- ctx.data.title = ctx.data.title.trim()
74
- // ctx.next() is optional — the chain continues unless abort() is called
75
- })
76
-
77
- nuxtblog.filter('content.render', (ctx) => {
78
- // ctx.input — read-only snapshot before the chain (for diff/audit)
79
- // ctx.data — mutable, changes are returned to the caller
80
- // ctx.meta — shared KV across all plugins in the same chain
81
- ctx.data.content = ctx.data.content.replace(/\bfoo\b/g, 'bar')
82
- })
83
- ```
84
-
85
- ### `nuxtblog.on` async event handler
86
-
87
- Runs **after** the operation completes. HTTP requests are allowed.
88
-
89
- ```ts
90
- nuxtblog.on('post.published', (data) => {
91
- const url = nuxtblog.settings.get('webhook_url') as string
92
- nuxtblog.http.fetch(url, {
93
- method: 'POST',
94
- body: { text: `New post: ${data.title}` },
95
- })
96
- })
97
-
98
- nuxtblog.on('user.registered', (data) => {
99
- nuxtblog.log.info(`New user: ${data.username} (${data.email})`)
100
- })
101
- ```
102
-
103
- ### `nuxtblog.http.fetch` — synchronous HTTP
104
-
105
- Available when `capabilities.http` is declared. Returns immediately (not a Promise).
106
- Blocked inside `filter` handlers.
107
-
108
- ```ts
109
- const res = nuxtblog.http.fetch<{ id: string }>('https://api.example.com/create', {
110
- method: 'POST',
111
- body: { title: 'Hello' },
112
- headers: { Authorization: 'Bearer token' },
113
- })
114
-
115
- if (res.ok) {
116
- nuxtblog.log.info('created: ' + res.body.id)
117
- } else {
118
- nuxtblog.log.error(res.error ?? `HTTP ${res.status}`)
119
- }
120
- ```
121
-
122
- ### `nuxtblog.store` persistent key-value store
123
-
124
- Available when `capabilities.store` is declared. Keys are namespaced per plugin.
125
-
126
- ```ts
127
- nuxtblog.store.set('last_run', new Date().toISOString())
128
- const last = nuxtblog.store.get('last_run') // unknown
129
- nuxtblog.store.delete('last_run')
130
- ```
131
-
132
- ### `nuxtblog.settings.get` — admin-configured settings
133
-
134
- Always available. Cached for 30 seconds.
135
-
136
- ```ts
137
- const apiKey = nuxtblog.settings.get('api_key') as string
138
- ```
139
-
140
- ### `nuxtblog.log` — server logging
141
-
142
- ```ts
143
- nuxtblog.log.info('hello')
144
- nuxtblog.log.warn('something looks off')
145
- nuxtblog.log.error('this failed')
146
- nuxtblog.log.debug('verbose details')
147
- ```
148
-
149
- ---
150
-
151
- ## Declarative webhooks (no JS needed)
152
-
153
- Simple outbound notifications can be declared in the manifest instead of writing JS.
154
-
155
- **Never hardcode secrets in the manifest.** Use `{{settings.key}}` placeholders in `url`
156
- and header values — they are resolved at dispatch time from admin-configured settings:
157
-
158
- ```json
159
- {
160
- "plugin": {
161
- "settings": [
162
- { "key": "webhook_url", "label": "Webhook URL", "type": "string", "placeholder": "https://hooks.slack.com/..." },
163
- { "key": "webhook_token", "label": "Webhook Token", "type": "password", "placeholder": "xoxb-..." }
164
- ],
165
- "webhooks": [
166
- {
167
- "url": "{{settings.webhook_url}}",
168
- "events": ["post.published", "comment.created"],
169
- "headers": { "Authorization": "Bearer {{settings.webhook_token}}" }
170
- }
171
- ]
172
- }
173
- }
174
- ```
175
-
176
- Event patterns: `"post.*"` matches all post events; `"*"` matches everything.
177
-
178
- ---
179
-
180
- ## Declarative pipelines (multi-step async workflows)
181
-
182
- For multi-step workflows with conditionals and retries, declare a pipeline in the manifest.
183
- JS functions exported at module scope are called by name.
184
-
185
- ```json
186
- {
187
- "plugin": {
188
- "capabilities": {
189
- "http": { "allow": ["ai-api.example.com", "hooks.slack.com"] }
190
- },
191
- "pipelines": [
192
- {
193
- "name": "post-publish",
194
- "trigger": "post.published",
195
- "steps": [
196
- {
197
- "type": "js",
198
- "name": "Generate summary",
199
- "fn": "generateSummary",
200
- "timeout_ms": 8000,
201
- "retry": 1
202
- },
203
- {
204
- "type": "condition",
205
- "name": "Branch by category",
206
- "if": "ctx.data.category === 'tech'",
207
- "then": [
208
- { "type": "webhook", "name": "Post to Twitter", "url": "https://api.twitter.com/..." }
209
- ],
210
- "else": [
211
- { "type": "js", "name": "Notify Slack", "fn": "notifySlack" }
212
- ]
213
- }
214
- ]
215
- }
216
- ]
217
- }
218
- }
219
- ```
220
-
221
- ```ts
222
- // src/index.ts functions called by pipeline steps must be exported at module scope
223
-
224
- function generateSummary(ctx: StepContext) {
225
- const res = nuxtblog.http.fetch<{ summary: string }>('https://ai-api.example.com/summarize', {
226
- method: 'POST',
227
- body: { content: ctx.data.content as string },
228
- })
229
- if (res.ok) {
230
- ctx.data.excerpt = res.body.summary
231
- }
232
- }
233
-
234
- function notifySlack(ctx: StepContext) {
235
- nuxtblog.http.fetch('https://hooks.slack.com/services/xxx', {
236
- method: 'POST',
237
- body: { text: `New post: ${ctx.data.title}` },
238
- })
239
- }
240
- ```
241
-
242
- Step types:
243
- - `"js"` call an exported JS function; supports `timeout_ms` and `retry`
244
- - `"webhook"` POST the event payload to a URL; supports `timeout_ms` and `retry`
245
- - `"condition"` evaluate a JS boolean expression, branch to `then` or `else`
246
-
247
- Retry backoff: 200 ms → 400 ms → 800 ms … capped at 8 s.
248
-
249
- ---
250
-
251
- ## Event reference
252
-
253
- ### Fire-and-forget events (`nuxtblog.on`)
254
-
255
- | Event | Payload type |
256
- |-------|-------------|
257
- | `post.created` | `PostCreatedPayload` |
258
- | `post.updated` | `PostUpdatedPayload` |
259
- | `post.published` | `PostPublishedPayload` |
260
- | `post.deleted` | `PostDeletedPayload` |
261
- | `post.viewed` | `PostViewedPayload` |
262
- | `comment.created` | `CommentCreatedPayload` |
263
- | `comment.deleted` | `CommentDeletedPayload` |
264
- | `comment.status_changed` | `CommentStatusChangedPayload` |
265
- | `comment.approved` | `CommentApprovedPayload` |
266
- | `user.registered` | `UserRegisteredPayload` |
267
- | `user.updated` | `UserUpdatedPayload` |
268
- | `user.deleted` | `UserDeletedPayload` |
269
- | `user.followed` | `UserFollowedPayload` |
270
- | `user.login` | `UserLoginPayload` |
271
- | `user.logout` | `UserLogoutPayload` |
272
- | `media.uploaded` | `MediaUploadedPayload` |
273
- | `media.deleted` | `MediaDeletedPayload` |
274
- | `taxonomy.created` | `TaxonomyCreatedPayload` |
275
- | `taxonomy.deleted` | `TaxonomyDeletedPayload` |
276
- | `term.created` | `TermCreatedPayload` |
277
- | `term.deleted` | `TermDeletedPayload` |
278
- | `reaction.added` | `ReactionPayload` |
279
- | `reaction.removed` | `ReactionPayload` |
280
- | `checkin.done` | `CheckinPayload` |
281
- | `option.updated` | `OptionUpdatedPayload` |
282
- | `plugin.installed` | `PluginInstalledPayload` |
283
- | `plugin.uninstalled` | `PluginUninstalledPayload` |
284
-
285
- ### Filter events (`nuxtblog.filter`)
286
-
287
- | Event | `ctx.data` type | Notes |
288
- |-------|----------------|-------|
289
- | `post.create` | `FilterPostCreateData` | |
290
- | `post.update` | `FilterPostUpdateData` | Only updated fields are present |
291
- | `post.delete` | `FilterPostDeleteData` | `abort()` cancels the deletion |
292
- | `comment.create` | `FilterCommentCreateData` | |
293
- | `comment.delete` | `FilterCommentDeleteData` | `abort()` cancels the deletion |
294
- | `term.create` | `FilterTermCreateData` | |
295
- | `user.register` | `FilterUserRegisterData` | |
296
- | `user.update` | `FilterUserUpdateData` | Only updated fields are present |
297
- | `media.upload` | `FilterMediaUploadData` | |
298
- | `content.render` | `FilterContentRenderData` | Modify `ctx.data.content` to change what readers see |
299
-
300
- ---
301
-
302
- ## Publishing a plugin
303
-
304
- See the [nuxtblog plugin registry](https://github.com/nuxtblog/registry) for how to submit your plugin to the marketplace.
305
-
306
- ## License
307
-
308
- MIT
1
+ # @nuxtblog/plugin-sdk
2
+
3
+ TypeScript type definitions and developer guide for [nuxtblog](https://github.com/nuxtblog/nuxtblog) plugins.
4
+
5
+ English(README.md)|(中文文档](README.zh.md)
6
+
7
+ ---
8
+
9
+ ## Table of Contents
10
+
11
+ - [Overview](#overview)
12
+ - [Plugin Structure](#plugin-structure)
13
+ - [Manifest (`package.json`)](#manifest-packagejson)
14
+ - [Settings Fields](#settings-fields)
15
+ - [Capabilities &amp; Permissions](#capabilities--permissions)
16
+ - [Plugin API Reference](#plugin-api-reference)
17
+ - [nuxtblog.on — Event Subscription](#nuxtblogon--event-subscription)
18
+ - [nuxtblog.filter — Data Interception](#nuxtblogfilter--data-interception)
19
+ - [nuxtblog.http — HTTP Requests](#nuxtbloghttp--http-requests)
20
+ - [nuxtblog.store — Persistent KV Store](#nuxtblogstore--persistent-kv-store)
21
+ - [nuxtblog.settings — Read Settings](#nuxtblogsettings--read-settings)
22
+ - [nuxtblog.log — Server Logging](#nuxtbloglog--server-logging)
23
+ - [Declarative Webhooks](#declarative-webhooks)
24
+ - [Declarative Pipelines](#declarative-pipelines)
25
+ - [Event Reference](#event-reference)
26
+ - [Fire-and-forget Events](#fire-and-forget-events-nuxtblogon)
27
+ - [Filter Events](#filter-events-nuxtblogfilter)
28
+ - [Execution Model &amp; Concurrency](#execution-model--concurrency)
29
+ - [Timeouts &amp; Retry](#timeouts--retry)
30
+ - [Observability](#observability)
31
+ - [Building &amp; Installing](#building--installing)
32
+ - [TypeScript Support](#typescript-support)
33
+
34
+ ---
35
+
36
+ ## Overview
37
+
38
+ Plugins are **server-side JavaScript scripts** executed by the [goja](https://github.com/dop251/goja) engine (ES2015+ compatible). Each plugin runs in an isolated VM with its own `nuxtblog` global object.
39
+
40
+ **What plugins can do:**
41
+
42
+ - Subscribe to system events (posts, comments, users, media, etc.) asynchronously — fire-and-forget
43
+ - Intercept and modify data synchronously before it is written to the database, or abort the operation entirely
44
+ - Read admin-configured settings (API tokens, webhook URLs, feature flags, etc.)
45
+ - Make outbound HTTP requests to external services
46
+ - Persist runtime state in a per-plugin key-value store backed by the database
47
+ - Declare outbound webhooks and multi-step async pipelines entirely in the manifest — no JS required
48
+
49
+ **What plugins cannot do:**
50
+
51
+ - Call `http.fetch` inside `filter` handlers (this would stall request processing — use `nuxtblog.on` for async side-effects)
52
+ - Access APIs not declared in `capabilities` (undeclared APIs are simply absent in the VM — `undefined`, not an error)
53
+ - Share VM state with other plugins (each plugin has a completely isolated runtime)
54
+
55
+ ---
56
+
57
+ ## Plugin Structure
58
+
59
+ ```
60
+ my-plugin/
61
+ ├── package.json ← Plugin manifest (required, contains "plugin" field)
62
+ ├── index.js ← Bundled single-file entry script (loaded by the server)
63
+ └── src/
64
+ └── index.ts ← TypeScript source (optional, for development)
65
+ ```
66
+
67
+ The installation archive only needs to contain `package.json` and `index.js`:
68
+
69
+ ```
70
+ my-plugin.zip
71
+ ├── package.json
72
+ └── index.js (or the path declared in plugin.entry)
73
+ ```
74
+
75
+ ---
76
+
77
+ ## Manifest (`package.json`)
78
+
79
+ The manifest lives in the standard `package.json`. All plugin-specific configuration is nested under the `"plugin"` key.
80
+
81
+ ```json
82
+ {
83
+ "name": "owner/my-plugin",
84
+ "version": "1.0.0",
85
+ "description": "A short description of what the plugin does",
86
+ "author": "owner",
87
+ "license": "MIT",
88
+ "homepage": "https://github.com/owner/my-plugin",
89
+ "keywords": ["nuxtblog-plugin"],
90
+ "plugin": {
91
+ "title": "My Plugin",
92
+ "icon": "i-tabler-plug",
93
+ "entry": "index.js",
94
+ "priority": 10,
95
+ "capabilities": {
96
+ "http": { "allow": ["hooks.slack.com"], "timeout_ms": 5000 },
97
+ "store": { "read": true, "write": true }
98
+ },
99
+ "settings": [
100
+ { "key": "webhook_url", "label": "Slack Webhook URL", "type": "string", "required": true }
101
+ ],
102
+ "webhooks": [],
103
+ "pipelines": []
104
+ }
105
+ }
106
+ ```
107
+
108
+ ### Top-level fields (standard npm)
109
+
110
+ | Field | Type | Required | Description |
111
+ | --------------- | -------- | -------- | ------------------------------------------------------------------------------------------ |
112
+ | `name` | string | ✅ | Unique plugin ID. Recommended format:`owner/repo`. Cannot be changed after installation. |
113
+ | `version` | string | ✅ | Semantic version, e.g.`1.0.0` |
114
+ | `description` | string | | Short description shown in the admin UI |
115
+ | `author` | string | | Author name |
116
+ | `license` | string | | License identifier, e.g.`MIT` |
117
+ | `homepage` | string | | Plugin homepage or repository URL |
118
+ | `keywords` | string[] | | Tags for discovery; include `nuxtblog-plugin` |
119
+
120
+ ### `"plugin"` field
121
+
122
+ | Field | Type | Required | Description |
123
+ | ---------------- | ------ | -------- | -------------------------------------------------------------------------------------- |
124
+ | `title` | string | ✅ | Display name shown in the admin panel |
125
+ | `icon` | string | | [Tabler Icons](https://tabler.io/icons) identifier, e.g. `i-tabler-bell` |
126
+ | `entry` | string | | Entry script path inside the archive. Default:`index.js` |
127
+ | `priority` | number | | Execution order among plugins. Lower = runs first. Default:`10` |
128
+ | `capabilities` | object | | Declared API permissions. See[Capabilities &amp; Permissions](#capabilities--permissions) |
129
+ | `settings` | array | | Admin-configurable parameters. See[Settings Fields](#settings-fields) |
130
+ | `webhooks` | array | | Declarative outbound webhooks. See[Declarative Webhooks](#declarative-webhooks) |
131
+ | `pipelines` | array | | Declarative async pipelines. See[Declarative Pipelines](#declarative-pipelines) |
132
+
133
+ ---
134
+
135
+ ## Settings Fields
136
+
137
+ The `settings` array declares parameters that administrators configure in the plugin settings UI. Plugins read them at runtime via `nuxtblog.settings.get(key)`.
138
+
139
+ ### Field types
140
+
141
+ | `type` | UI control | Use case |
142
+ | ------------ | ----------------------- | ----------------------------------------- |
143
+ | `string` | Single-line text input | URLs, names, arbitrary strings |
144
+ | `password` | Password input (masked) | API keys, tokens, secrets |
145
+ | `number` | Numeric input | Timeouts, limits, counts |
146
+ | `boolean` | Toggle switch | Feature flags |
147
+ | `select` | Dropdown | Enumerated options (use with `options`) |
148
+ | `textarea` | Multi-line text | Templates, JSON config, long strings |
149
+
150
+ ### Field properties
151
+
152
+ ```json
153
+ {
154
+ "key": "api_token",
155
+ "label": "API Token",
156
+ "type": "password",
157
+ "required": true,
158
+ "default": "",
159
+ "placeholder": "sk-xxxxxxxx",
160
+ "description": "Copy from your provider's dashboard",
161
+ "options": []
162
+ }
163
+ ```
164
+
165
+ | Property | Type | Description |
166
+ | --------------- | -------- | ----------------------------------------------------- |
167
+ | `key` | string | Key used in `nuxtblog.settings.get(key)` |
168
+ | `label` | string | Form label shown in the admin UI |
169
+ | `type` | string | Control type (see table above) |
170
+ | `required` | boolean | Whether the field is required (visual indicator only) |
171
+ | `default` | any | Default value populated on install |
172
+ | `placeholder` | string | Input placeholder text |
173
+ | `description` | string | Help text shown below the field |
174
+ | `options` | string[] | Dropdown options (only for `type: "select"`) |
175
+
176
+ ### Complete example
177
+
178
+ ```json
179
+ "settings": [
180
+ { "key": "enabled", "label": "Enable plugin", "type": "boolean", "default": true },
181
+ { "key": "api_token", "label": "API Token", "type": "password", "required": true, "placeholder": "sk-xxxxxxxx" },
182
+ { "key": "webhook_url", "label": "Webhook URL", "type": "string", "placeholder": "https://example.com/hook" },
183
+ { "key": "timeout", "label": "Timeout (seconds)", "type": "number", "default": 10 },
184
+ { "key": "log_level", "label": "Log level", "type": "select", "default": "info", "options": ["debug","info","warn","error"] },
185
+ { "key": "template", "label": "Message template", "type": "textarea", "placeholder": "New post: {{title}}" }
186
+ ]
187
+ ```
188
+
189
+ ---
190
+
191
+ ## Capabilities & Permissions
192
+
193
+ Capabilities follow a **whitelist model**: only APIs you explicitly declare in `capabilities` are injected into the VM. Undeclared APIs are simply `undefined` — accessing them does not throw.
194
+
195
+ ```json
196
+ "capabilities": {
197
+ "http": {
198
+ "allow": ["api.openai.com", "hooks.slack.com"],
199
+ "timeout_ms": 8000
200
+ },
201
+ "store": {
202
+ "read": true,
203
+ "write": true
204
+ }
205
+ }
206
+ ```
207
+
208
+ ### `http`
209
+
210
+ | Property | Type | Default | Description |
211
+ | -------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------- |
212
+ | `allow` | string[] | `[]` (any) | Domain allowlist. Subdomains are allowed automatically (e.g.`example.com` also permits `api.example.com`). Empty list = any domain permitted. |
213
+ | `timeout_ms` | number | `15000` | Per-request timeout in milliseconds. |
214
+
215
+ ### `store`
216
+
217
+ | Property | Type | Description |
218
+ | --------- | ------- | --------------------------------------------------------------------- |
219
+ | `read` | boolean | Grants access to `nuxtblog.store.get` |
220
+ | `write` | boolean | Grants access to `nuxtblog.store.set` and `nuxtblog.store.delete` |
221
+
222
+ > **Security note:** `nuxtblog.on` and `nuxtblog.filter` are always available regardless of capabilities, as is `nuxtblog.log` and `nuxtblog.settings`. These require no declaration.
223
+
224
+ ---
225
+
226
+ ## Plugin API Reference
227
+
228
+ ### `nuxtblog.on` — Event Subscription
229
+
230
+ ```ts
231
+ nuxtblog.on(event: string, handler: (payload: object) => void): void
232
+ ```
233
+
234
+ Subscribes to a fire-and-forget system event. The handler runs **asynchronously** after the operation completes. Errors in the handler are logged and written to the error ring buffer; they never affect the original operation.
235
+
236
+ **Key constraints:**
237
+
238
+ - Handler timeout: **3 seconds** (configurable in manifest)
239
+ - `http.fetch` is allowed inside `nuxtblog.on` handlers
240
+ - Multiple handlers for the same event execute in **priority order** (lower plugin priority = runs first)
241
+
242
+ ```ts
243
+ // Notify Slack when a post is published
244
+ nuxtblog.on('post.published', (data) => {
245
+ const url = nuxtblog.settings.get('webhook_url') as string
246
+ if (!url) return
247
+
248
+ const res = nuxtblog.http.fetch(url, {
249
+ method: 'POST',
250
+ body: { text: `New post: ${data.title} — ${data.slug}` },
251
+ })
252
+ if (!res.ok) {
253
+ nuxtblog.log.warn(`Slack notify failed: HTTP ${res.status}`)
254
+ }
255
+ })
256
+
257
+ // Track a per-plugin post counter
258
+ nuxtblog.on('post.created', (_data) => {
259
+ const count = ((nuxtblog.store.get('post_count') as number) || 0) + 1
260
+ nuxtblog.store.set('post_count', count)
261
+ nuxtblog.log.info(`Total posts tracked: ${count}`)
262
+ })
263
+ ```
264
+
265
+ ---
266
+
267
+ ### `nuxtblog.filter` Data Interception
268
+
269
+ ```ts
270
+ nuxtblog.filter(event: string, handler: (ctx: PluginCtx) => void): void
271
+ ```
272
+
273
+ Intercepts data **synchronously** before it is written to the database. The handler receives a `ctx` object. Modify `ctx.data` to change what gets saved; call `ctx.abort(reason)` to cancel the entire operation.
274
+
275
+ **Key constraints:**
276
+
277
+ - Handler timeout: **50 milliseconds** (hard limit — keeps request latency acceptable)
278
+ - `http.fetch` is **blocked** inside filter handlers. Use `nuxtblog.on` for async side-effects.
279
+ - All plugins run in priority order; `ctx.meta` is shared across plugins in the same chain
280
+
281
+ **`ctx` object:**
282
+
283
+ | Property | Type | Description |
284
+ | --------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------- |
285
+ | `ctx.event` | string | The filter event name, e.g.`"filter:post.create"` |
286
+ | `ctx.data` | object | Mutable payload. Changes are returned to the caller. |
287
+ | `ctx.input` | object | Read-only deep-copy snapshot of `ctx.data` taken before the chain starts. Use for audit/diff. |
288
+ | `ctx.meta` | object | Shared KV store across all plugins in this chain. Earlier plugins can leave data for later ones. |
289
+ | `ctx.next()` | function | Optional: explicitly signal that this handler is done. The chain continues regardless unless `abort()` is called. |
290
+ | `ctx.abort(reason)` | function | Cancel the operation immediately. All subsequent plugin handlers are skipped. The caller receives an error wrapping `reason`. |
291
+
292
+ ```ts
293
+ // Trim whitespace and enforce title length
294
+ nuxtblog.filter('post.create', (ctx) => {
295
+ ctx.data.title = (ctx.data.title as string).trim()
296
+ if ((ctx.data.title as string).length > 200) {
297
+ ctx.abort('Title must not exceed 200 characters')
298
+ return
299
+ }
300
+ // Leave a slug hint for the next plugin in the chain
301
+ ctx.meta.computed_slug = (ctx.data.title as string).toLowerCase().replace(/\s+/g, '-')
302
+ })
303
+
304
+ // Auto-generate slug from meta set by the previous plugin
305
+ nuxtblog.filter('post.create', (ctx) => {
306
+ if (!ctx.data.slug && ctx.meta.computed_slug) {
307
+ ctx.data.slug = ctx.meta.computed_slug
308
+ }
309
+ })
310
+
311
+ // Block comments containing specific words
312
+ nuxtblog.filter('comment.create', (ctx) => {
313
+ const blocked = ['spam', 'advertisement']
314
+ const content = (ctx.data.content as string).toLowerCase()
315
+ if (blocked.some(w => content.includes(w))) {
316
+ ctx.abort('Comment contains prohibited content')
317
+ }
318
+ })
319
+
320
+ // Prevent deletion of posts created in the last hour
321
+ nuxtblog.filter('post.delete', (ctx) => {
322
+ // ctx.data: { id }
323
+ // Fetch additional data from store if needed — store.get is allowed in filters
324
+ nuxtblog.log.info(`Post ${ctx.data.id} deletion requested`)
325
+ // ctx.next() — optional
326
+ })
327
+ ```
328
+
329
+ ---
330
+
331
+ ### `nuxtblog.http` — HTTP Requests
332
+
333
+ ```ts
334
+ nuxtblog.http.fetch(url: string, options?: FetchOptions): FetchResult
335
+ ```
336
+
337
+ Synchronous HTTP request (not a Promise). Returns immediately with the result.
338
+
339
+ > Requires `capabilities.http` to be declared in the manifest.
340
+ > **Blocked inside `filter` handlers.** Use in `nuxtblog.on` handlers or pipeline `js` steps.
341
+
342
+ **Options:**
343
+
344
+ | Property | Type | Default | Description |
345
+ | ----------- | --------------- | --------- | --------------------------------------------------------------------------------------------------------------- |
346
+ | `method` | string | `"GET"` | HTTP method:`GET`, `POST`, `PUT`, `PATCH`, `DELETE` |
347
+ | `body` | object\| string | — | Request body. Objects are automatically JSON-serialized. |
348
+ | `headers` | object | — | Custom request headers. Setting a body automatically adds `Content-Type: application/json` if not overridden. |
349
+
350
+ **Return value:**
351
+
352
+ | Property | Type | Description |
353
+ | ---------- | ------- | ------------------------------------------------------------------------------------------- |
354
+ | `ok` | boolean | `true` if HTTP status is 200–299 |
355
+ | `status` | number | HTTP status code |
356
+ | `body` | any | Response body, JSON-parsed automatically. Falls back to raw string if parsing fails. |
357
+ | `error` | string | Error message if the request failed (network error, timeout, domain not in allowlist, etc.) |
358
+
359
+ ```ts
360
+ // POST with JSON body and auth header
361
+ const res = nuxtblog.http.fetch('https://api.example.com/notify', {
362
+ method: 'POST',
363
+ headers: { 'Authorization': `Bearer ${nuxtblog.settings.get('api_token')}` },
364
+ body: { title: 'Hello', id: 42 },
365
+ })
366
+
367
+ if (res.ok) {
368
+ nuxtblog.log.info(`Created remote record: ${(res.body as any).id}`)
369
+ } else {
370
+ nuxtblog.log.error(`Request failed: ${res.error ?? `HTTP ${res.status}`}`)
371
+ }
372
+ ```
373
+
374
+ ---
375
+
376
+ ### `nuxtblog.store` — Persistent KV Store
377
+
378
+ ```ts
379
+ nuxtblog.store.get(key: string): unknown
380
+ nuxtblog.store.set(key: string, value: unknown): void
381
+ nuxtblog.store.delete(key: string): void
382
+ ```
383
+
384
+ Per-plugin persistent key-value store backed by the database `options` table. Keys are automatically namespaced per plugin — you cannot read another plugin's data.
385
+
386
+ Values can be any JSON-serializable type: strings, numbers, booleans, arrays, objects.
387
+
388
+ > Requires `capabilities.store.read` and/or `capabilities.store.write` to be declared.
389
+
390
+ ```ts
391
+ // Track how many posts have been pushed to an external service
392
+ nuxtblog.on('post.published', (data) => {
393
+ const count = ((nuxtblog.store.get('push_count') as number) || 0) + 1
394
+ nuxtblog.store.set('push_count', count)
395
+
396
+ // Cache the last pushed post ID
397
+ nuxtblog.store.set('last_post', { id: data.id, title: data.title, at: new Date().toISOString() })
398
+ })
399
+
400
+ // On demand: clear cached state
401
+ nuxtblog.on('plugin.installed', (_data) => {
402
+ nuxtblog.store.delete('push_count')
403
+ nuxtblog.store.delete('last_post')
404
+ })
405
+ ```
406
+
407
+ > **Note:** `nuxtblog.store` is for runtime state. For admin-configured values (API keys, URLs, toggles), use `nuxtblog.settings`.
408
+
409
+ ---
410
+
411
+ ### `nuxtblog.settings` — Read Settings
412
+
413
+ ```ts
414
+ nuxtblog.settings.get(key: string): unknown
415
+ ```
416
+
417
+ Reads a value from the plugin's admin-configured settings. Results are cached for **30 seconds** to avoid per-call database hits; changes made in the admin UI take effect within the next cache expiry.
418
+
419
+ Always available — no capability declaration required.
420
+
421
+ ```ts
422
+ const token = nuxtblog.settings.get('api_token') as string | null
423
+ const enabled = nuxtblog.settings.get('enabled') as boolean | null
424
+ const timeout = nuxtblog.settings.get('timeout') as number | null
425
+
426
+ if (!token) {
427
+ nuxtblog.log.warn('api_token not configured — skipping')
428
+ return
429
+ }
430
+ ```
431
+
432
+ ---
433
+
434
+ ### `nuxtblog.log` — Server Logging
435
+
436
+ ```ts
437
+ nuxtblog.log.info(msg: string): void
438
+ nuxtblog.log.warn(msg: string): void
439
+ nuxtblog.log.error(msg: string): void
440
+ nuxtblog.log.debug(msg: string): void
441
+ ```
442
+
443
+ Writes to the server log. Each message is automatically prefixed with `[plugin:<id>]`.
444
+
445
+ Always available — no capability declaration required.
446
+
447
+ ```ts
448
+ nuxtblog.log.info('Plugin initialized')
449
+ nuxtblog.log.debug(`Processing event with data: ${JSON.stringify(data)}`)
450
+ nuxtblog.log.warn('api_token is missing — some features are disabled')
451
+ nuxtblog.log.error(`Unexpected response: ${res.status}`)
452
+ ```
453
+
454
+ ---
455
+
456
+ ## Declarative Webhooks
457
+
458
+ Simple outbound HTTP notifications can be declared entirely in the manifest — no JS required. The platform POSTs the event payload as JSON to the configured URL whenever a matching event fires.
459
+
460
+ Webhooks fire **asynchronously** (never block the originating request). Failures are written to the plugin's error ring buffer and do not retry.
461
+
462
+ **Never hardcode secrets.** Use `{{settings.key}}` placeholders in `url` and header values. They are resolved at dispatch time from admin-configured settings (30-second cache).
463
+
464
+ ```json
465
+ {
466
+ "plugin": {
467
+ "settings": [
468
+ { "key": "webhook_url", "label": "Webhook URL", "type": "string", "required": true },
469
+ { "key": "webhook_token", "label": "Webhook Token", "type": "password", "required": true }
470
+ ],
471
+ "webhooks": [
472
+ {
473
+ "url": "{{settings.webhook_url}}",
474
+ "events": ["post.published", "comment.created"],
475
+ "headers": {
476
+ "Authorization": "Bearer {{settings.webhook_token}}",
477
+ "X-Source": "nuxtblog"
478
+ }
479
+ },
480
+ {
481
+ "url": "https://example.com/static-endpoint",
482
+ "events": ["user.registered"]
483
+ }
484
+ ]
485
+ }
486
+ }
487
+ ```
488
+
489
+ ### `WebhookDef` properties
490
+
491
+ | Property | Type | Description |
492
+ | ----------- | -------- | ---------------------------------------------------------------------- |
493
+ | `url` | string | Endpoint to POST to. Supports `{{settings.key}}` interpolation. |
494
+ | `events` | string[] | Event names or patterns to match. |
495
+ | `headers` | object | Extra HTTP headers. Values support `{{settings.key}}` interpolation. |
496
+
497
+ ### Event patterns
498
+
499
+ | Pattern | Matches |
500
+ | -------------------- | -------------------------------------------------------------------------- |
501
+ | `"post.published"` | Exact match only |
502
+ | `"post.*"` | All events with `post.` prefix: `post.created`, `post.updated`, etc. |
503
+ | `"*"` | Every event |
504
+
505
+ ---
506
+
507
+ ## Declarative Pipelines
508
+
509
+ Multi-step async workflows with conditionals, retries, and timeouts can be declared in the manifest. Pipelines fire asynchronously and never block the originating event.
510
+
511
+ JS functions called from pipeline steps must be exported at module scope (top-level `function` declarations in your script).
512
+
513
+ ```json
514
+ {
515
+ "plugin": {
516
+ "capabilities": {
517
+ "http": { "allow": ["ai-api.example.com", "hooks.slack.com"] }
518
+ },
519
+ "pipelines": [
520
+ {
521
+ "name": "post-publish",
522
+ "trigger": "post.published",
523
+ "steps": [
524
+ {
525
+ "type": "js",
526
+ "name": "Generate AI summary",
527
+ "fn": "generateSummary",
528
+ "timeout_ms": 8000,
529
+ "retry": 1
530
+ },
531
+ {
532
+ "type": "condition",
533
+ "name": "Branch by post type",
534
+ "if": "ctx.data.post_type === 0",
535
+ "then": [
536
+ { "type": "js", "name": "Notify Slack", "fn": "notifySlack" }
537
+ ],
538
+ "else": [
539
+ { "type": "webhook", "name": "Page webhook", "url": "https://hooks.example.com/pages" }
540
+ ]
541
+ }
542
+ ]
543
+ }
544
+ ]
545
+ }
546
+ }
547
+ ```
548
+
549
+ ```ts
550
+ // src/index.ts — functions called by steps must be at module scope
551
+
552
+ function generateSummary(ctx: StepContext) {
553
+ const res = nuxtblog.http.fetch<{ summary: string }>('https://ai-api.example.com/summarize', {
554
+ method: 'POST',
555
+ body: { content: ctx.data.content as string },
556
+ })
557
+ if (res.ok) {
558
+ ctx.data.excerpt = res.body.summary // passes to the next step
559
+ } else {
560
+ ctx.abort(`AI API error: HTTP ${res.status}`)
561
+ }
562
+ }
563
+
564
+ function notifySlack(ctx: StepContext) {
565
+ nuxtblog.http.fetch('https://hooks.slack.com/services/xxx', {
566
+ method: 'POST',
567
+ body: { text: `New post published: ${ctx.data.title}` },
568
+ })
569
+ }
570
+ ```
571
+
572
+ ### Step types
573
+
574
+ | `type` | Description |
575
+ | --------------- | ------------------------------------------------------------------------------------- |
576
+ | `"js"` | Call an exported JS function by name (`fn`). Supports `timeout_ms` and `retry`. |
577
+ | `"webhook"` | POST `StepContext.data` as JSON to `url`. Supports `timeout_ms` and `retry`. |
578
+ | `"condition"` | Evaluate a JS boolean expression (`if`), then run `then` or `else` branches. |
579
+
580
+ ### `StepContext` object
581
+
582
+ | Property | Type | Description |
583
+ | --------------------- | -------- | ------------------------------------------------ |
584
+ | `ctx.event` | string | The trigger event name |
585
+ | `ctx.data` | object | Shared mutable payload flowing through all steps |
586
+ | `ctx.meta` | object | Step-to-step KV store |
587
+ | `ctx.abort(reason)` | function | Stop the pipeline; subsequent steps are skipped |
588
+
589
+ ### Retry backoff
590
+
591
+ When `retry` > 0, failed steps are retried with exponential backoff:
592
+
593
+ | Attempt | Wait before retry |
594
+ | --------- | ----------------------------------------------- |
595
+ | 1st retry | 200 ms |
596
+ | 2nd retry | 400 ms |
597
+ | 3rd retry | 800 ms |
598
+ | … | Doubles each time, capped at**8 seconds** |
599
+
600
+ ### Step defaults
601
+
602
+ | Property | Default |
603
+ | -------------- | -------------------- |
604
+ | `timeout_ms` | `5000` (5 seconds) |
605
+ | `retry` | `0` (no retries) |
606
+
607
+ ---
608
+
609
+ ## Event Reference
610
+
611
+ ### Fire-and-forget Events (`nuxtblog.on`)
612
+
613
+ #### Post
614
+
615
+ | Event | Trigger | Payload fields |
616
+ | ------------------ | ------------------------------------------ | ---------------------------------------------------------- |
617
+ | `post.created` | After a post is created | `id, title, slug, excerpt, post_type, author_id, status` |
618
+ | `post.updated` | After a post is updated | `id, title, slug, excerpt, post_type, author_id, status` |
619
+ | `post.published` | After a post's status changes to published | `id, title, slug, excerpt, post_type, author_id` |
620
+ | `post.deleted` | After a post is deleted / moved to trash | `id, title, slug, post_type, author_id` |
621
+ | `post.viewed` | After a post is viewed | `id, user_id` |
622
+
623
+ `post_type`: `0` = post, `1` = page
624
+ `status`: `0` = draft, `1` = published, `2` = trash
625
+
626
+ #### Comment
627
+
628
+ | Event | Trigger | Payload fields |
629
+ | -------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
630
+ | `comment.created` | After a comment is submitted | `id, status, object_type, object_id, object_title, object_slug, post_author_id, parent_id?, parent_author_id, author_id, author_name, author_email, content` |
631
+ | `comment.deleted` | After a comment is deleted | `id, object_type, object_id` |
632
+ | `comment.status_changed` | After a comment's moderation status changes | `id, object_type, object_id, old_status, new_status, moderator_id` |
633
+ | `comment.approved` | After a comment is approved | `id, object_type, object_id, moderator_id` |
634
+
635
+ `status`: `0` = pending, `1` = approved, `2` = spam
636
+
637
+ #### User
638
+
639
+ | Event | Trigger | Payload fields |
640
+ | ------------------- | ------------------------------- | ------------------------------------------------------------- |
641
+ | `user.registered` | After a user registers | `id, username, email, display_name, locale, role` |
642
+ | `user.updated` | After a user profile is updated | `id, username, email, display_name, locale, role, status` |
643
+ | `user.deleted` | After a user is deleted | `id, username, email` |
644
+ | `user.followed` | After a user follows another | `follower_id, follower_name, follower_avatar, following_id` |
645
+ | `user.login` | After a user logs in | `id, username, email, role` |
646
+ | `user.logout` | After a user logs out | `id` |
647
+
648
+ `role`: `0` = subscriber, `1` = contributor, `2` = editor, `3` = admin
649
+ `status`: `0` = active, `1` = banned
650
+
651
+ #### Media
652
+
653
+ | Event | Trigger | Payload fields |
654
+ | ------------------ | ------------------------ | --------------------------------------------------------------------------------- |
655
+ | `media.uploaded` | After a file is uploaded | `id, uploader_id, filename, mime_type, file_size, url, category, width, height` |
656
+ | `media.deleted` | After a file is deleted | `id, uploader_id, filename, mime_type, category` |
657
+
658
+ #### Taxonomy / Term
659
+
660
+ | Event | Trigger | Payload fields |
661
+ | -------------------- | --------------------------------------- | ----------------------------------------------- |
662
+ | `taxonomy.created` | After a taxonomy association is created | `id, term_id, term_name, term_slug, taxonomy` |
663
+ | `taxonomy.deleted` | After a taxonomy association is deleted | `id, term_name, term_slug, taxonomy` |
664
+ | `term.created` | After a term is created | `id, name, slug` |
665
+ | `term.deleted` | After a term is deleted | `id, name, slug` |
666
+
667
+ #### Reactions & Check-in
668
+
669
+ | Event | Trigger | Payload fields |
670
+ | -------------------- | ----------------------------------- | ----------------------------------------- |
671
+ | `reaction.added` | After a like or bookmark is added | `user_id, object_type, object_id, type` |
672
+ | `reaction.removed` | After a like or bookmark is removed | `user_id, object_type, object_id, type` |
673
+ | `checkin.done` | After a user checks in | `user_id, streak, already_checked_in` |
674
+
675
+ `type`: `"like"` or `"bookmark"`
676
+
677
+ #### System
678
+
679
+ | Event | Trigger | Payload fields |
680
+ | ---------------------- | ------------------------------ | ------------------------------ |
681
+ | `option.updated` | After a site option is changed | `key, value` |
682
+ | `plugin.installed` | After a plugin is installed | `id, title, version, author` |
683
+ | `plugin.uninstalled` | After a plugin is uninstalled | `id` |
684
+
685
+ ---
686
+
687
+ ### Filter Events (`nuxtblog.filter`)
688
+
689
+ | Event | Trigger | `ctx.data` fields | Notes |
690
+ | ------------------ | -------------------------------------- | -------------------------------------------------- | ------------------------------------------------------ |
691
+ | `post.create` | Before a post is written to DB | `title, slug, content, excerpt, status` | |
692
+ | `post.update` | Before a post update is written | partial fields only | Only fields being changed are present |
693
+ | `post.delete` | Before a post is deleted | `id` | `abort()` cancels the deletion |
694
+ | `comment.create` | Before a comment is written | `content, author_name, author_email` | |
695
+ | `comment.delete` | Before a comment is deleted | `id` | `abort()` cancels the deletion |
696
+ | `term.create` | Before a term is written | `name, slug` | |
697
+ | `user.register` | Before a user is written | `username, email, display_name` | |
698
+ | `user.update` | Before a user update is written | partial fields only | Only fields being changed are present |
699
+ | `media.upload` | Before media metadata is written | `filename, mime_type, category, alt_text, title` | |
700
+ | `content.render` | Before content is rendered for readers | `content` | Modify `ctx.data.content` to change what readers see |
701
+
702
+ ---
703
+
704
+ ## Execution Model & Concurrency
705
+
706
+ - Each plugin has exactly **one goja VM** and **one mutex**. All VM operations (script execution, handler calls, `ToValue`, etc.) must hold the mutex.
707
+ - The `nuxtblog.on` handler timeout is **3 seconds** by default. Timeouts are enforced via `vm.Interrupt` (goroutine-safe; does not require the mutex).
708
+ - The `nuxtblog.filter` handler timeout is **50 milliseconds** by default. This is intentionally short to keep request latency predictable.
709
+ - Multiple plugins run in ascending **priority** order (lower number = earlier). Within the same priority, plugins are sorted alphabetically by ID.
710
+ - `inFilter` is an atomic flag set during filter chain execution. `http.fetch` checks this flag and returns an error object (not a panic/throw) when called inside a filter.
711
+ - Pipeline goroutines and webhook goroutines are fire-and-forget — they never block `fanOut`.
712
+
713
+ ---
714
+
715
+ ## Timeouts & Retry
716
+
717
+ | Context | Default timeout | Configurable |
718
+ | --------------------------- | --------------- | -------------------------------- |
719
+ | `nuxtblog.on` handler | 3 seconds | In manifest (future) |
720
+ | `nuxtblog.filter` handler | 50 ms | In manifest (future) |
721
+ | Pipeline `js` step | 5 seconds | `timeout_ms` in step |
722
+ | Pipeline `webhook` step | 5 seconds | `timeout_ms` in step |
723
+ | Pipeline `condition` step | 50 ms | Not configurable |
724
+ | `http.fetch` request | 15 seconds | `capabilities.http.timeout_ms` |
725
+ | Declarative webhook POST | 10 seconds | Not configurable |
726
+
727
+ ---
728
+
729
+ ## Observability
730
+
731
+ The plugin engine exposes runtime metrics via the server's internal API.
732
+
733
+ ### Stats (`GetStats`)
734
+
735
+ | Field | Description |
736
+ | ------------------- | ------------------------------------------ |
737
+ | `plugin_id` | Plugin identifier |
738
+ | `invocations` | Total handler/filter executions |
739
+ | `errors` | Total executions that resulted in an error |
740
+ | `avg_duration_ms` | Average execution time in milliseconds |
741
+ | `last_error` | Most recent error message |
742
+ | `last_error_at` | Timestamp of the most recent error |
743
+
744
+ ### Sliding window (`GetHistory`)
745
+
746
+ 60 one-minute buckets covering the last hour. Each bucket contains `invocations` and `errors` for that minute. Buckets with no activity return zero counters.
747
+
748
+ ### Error ring buffer (`GetErrors`)
749
+
750
+ Stores the last 100 error entries. Each entry contains:
751
+
752
+ | Field | Description |
753
+ | -------------- | ------------------------------------------------------ |
754
+ | `at` | Timestamp |
755
+ | `event` | Event name that triggered the error |
756
+ | `message` | Error message |
757
+ | `input_diff` | JSON diff of `ctx.data` changes (filter errors only) |
758
+
759
+ The diff format uses prefixes: `+key` = added, `-key` = removed, `~key` = changed (`{ before, after }`).
760
+
761
+ ---
762
+
763
+ ## Building & Installing
764
+
765
+ ### Bundling with esbuild (recommended)
766
+
767
+ ```bash
768
+ npm install -D esbuild
769
+
770
+ npx esbuild src/index.ts \
771
+ --bundle \
772
+ --platform=neutral \
773
+ --main-fields=browser,module,main \
774
+ --target=es2015 \
775
+ --outfile=index.js
776
+ ```
777
+
778
+ ### Creating the archive
779
+
780
+ The server uses [mholt/archives](https://github.com/mholt/archives) for extraction. Supported formats:
781
+
782
+ | Format | Extension |
783
+ | ----------- | ---------------------- |
784
+ | ZIP | `.zip` |
785
+ | Tar + Gzip | `.tar.gz` / `.tgz` |
786
+ | Tar + Bzip2 | `.tar.bz2` |
787
+ | Tar + XZ | `.tar.xz` |
788
+ | Tar + Zstd | `.tar.zst` |
789
+ | 7-Zip | `.7z` |
790
+ | RAR | `.rar` |
791
+
792
+ **ZIP (Python):**
793
+
794
+ ```python
795
+ import zipfile
796
+ with zipfile.ZipFile("my-plugin.zip", "w", zipfile.ZIP_DEFLATED) as z:
797
+ z.write("package.json")
798
+ z.write("index.js")
799
+ ```
800
+
801
+ **tar.gz (Shell):**
802
+
803
+ ```bash
804
+ tar -czf my-plugin.tar.gz package.json index.js
805
+ ```
806
+
807
+ **PowerShell:**
808
+
809
+ ```powershell
810
+ Compress-Archive -Path package.json, index.js -DestinationPath my-plugin.zip
811
+ ```
812
+
813
+ ### Installation methods
814
+
815
+ 1. **Local archive** — Admin panel → Plugins → Install → Local ZIP → Upload
816
+ 2. **GitHub** — Admin panel → Plugins → Install → GitHub → Enter `owner/repo` (the server automatically downloads the latest Release asset named `plugin.zip`)
817
+
818
+ ---
819
+
820
+ ## TypeScript Support
821
+
822
+ Install the SDK package to get full type coverage for the `nuxtblog` global object.
823
+
824
+ ```bash
825
+ pnpm add -D @nuxtblog/plugin-sdk
826
+ # or
827
+ npm install -D @nuxtblog/plugin-sdk
828
+ ```
829
+
830
+ **`tsconfig.json`:**
831
+
832
+ ```json
833
+ {
834
+ "extends": "@nuxtblog/plugin-sdk",
835
+ "include": ["src"]
836
+ }
837
+ ```
838
+
839
+ **Or add a reference at the top of your entry file:**
840
+
841
+ ```ts
842
+ /// <reference path="../../node_modules/@nuxtblog/plugin-sdk/index.d.ts" />
843
+ ```
844
+
845
+ ---
846
+
847
+ ## License
848
+
849
+ MIT