@hodi-app/plugin-sdk 0.0.0-stage → 0.1.0
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.
- package/README.md +109 -2
- package/dist/bin.mjs +22 -0
- package/dist/build-Bbd9DMak.mjs +187 -0
- package/dist/build.d.mts +10 -0
- package/dist/build.mjs +1 -0
- package/dist/server.d.mts +67 -0
- package/dist/web.d.mts +134 -0
- package/hodi-sdk.d.ts +5 -0
- package/package.json +61 -4
package/README.md
CHANGED
|
@@ -1,3 +1,110 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Writing a Hodi plugin
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A plugin adds to Hodi without changing its code: a server part with its own routes and tables, a web part drawn with
|
|
4
|
+
Hodi's own components, settings that Hodi draws for it, and its own texts. The admin installs it from
|
|
5
|
+
Réglages › Plugins with a `.hodi-plugin` file. Start from [`examples/hello-world`](../examples/hello-world).
|
|
6
|
+
|
|
7
|
+
## A plugin's folder
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
my-plugin/
|
|
11
|
+
plugin.json what it is: id, name, version, api, who it is for, its settings
|
|
12
|
+
server/index.ts optional: its server part, bundled into server.js
|
|
13
|
+
web/index.tsx optional: its web part, bundled into web.js (and web.css from the CSS it imports)
|
|
14
|
+
locales/fr.json its texts, one file per language (fr is the fallback)
|
|
15
|
+
assets/ optional: pictures and other files its web part asks for
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Two ways to write one
|
|
19
|
+
|
|
20
|
+
**In its own repository** (the usual way): start from
|
|
21
|
+
[`hodi-plugin-template`](https://github.com/Anthodpnt/hodi-plugin-template), which depends on this package,
|
|
22
|
+
`@hodi-app/plugin-sdk`:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
npm install --save-dev @hodi-app/plugin-sdk
|
|
26
|
+
npx hodi-plugin build # the current folder into dist/<id>-<version>.hodi-plugin
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Its `tsconfig.json` lists `"@hodi-app/plugin-sdk/hodi-sdk"` in `"types"`, so `import … from 'hodi:sdk'` is typed, and the
|
|
30
|
+
server part takes its types from `@hodi-app/plugin-sdk/server`.
|
|
31
|
+
|
|
32
|
+
**Inside a clone of Hodi**, as `examples/hello-world` is: `tsconfig.plugins.json` maps `hodi:sdk` to its source, and
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm run plugin:build -- path/to/my-plugin
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Signing
|
|
39
|
+
|
|
40
|
+
A package is unsigned unless a key is given: `hodi-plugin build --key private.pem` (an ed25519 key, PEM). Hodi says
|
|
41
|
+
"Vérifié" only when the key that signed it is one of the keys it trusts (`TRUSTED_KEYS` in
|
|
42
|
+
`server/plugins/package.ts`): that is the maintainer's key, for Hodi's official plugins. Every other plugin installs as
|
|
43
|
+
"Code non vérifié", after a second question. Keep a private key out of every repository and plugin folder.
|
|
44
|
+
|
|
45
|
+
## `plugin.json`
|
|
46
|
+
|
|
47
|
+
```json
|
|
48
|
+
{
|
|
49
|
+
"id": "my-plugin",
|
|
50
|
+
"name": "My plugin",
|
|
51
|
+
"version": "1.0.0",
|
|
52
|
+
"description": "One sentence, shown in Réglages › Plugins.",
|
|
53
|
+
"api": 1,
|
|
54
|
+
"audience": "all",
|
|
55
|
+
"settings": [{ "key": "greeting", "type": "text", "default": "Bonjour" }]
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
- `id`: lowercase letters, digits and dashes. It names the plugin's folder, its routes (`/api/plugins/<id>/…`) and its
|
|
60
|
+
tables (`plugin_<id>_…`, with dashes as underscores).
|
|
61
|
+
- `api`: the plugin API it is written for. Hodi does not load a plugin written for another major version.
|
|
62
|
+
- `audience`: `all` (every profile) or `admin` (the household's admin only): who sees its pages and reaches its routes.
|
|
63
|
+
- `settings`: `boolean`, `number` (`min`, `max`), `text` (`maxLength`) or `choice` (`options`), each with a `default`.
|
|
64
|
+
Hodi draws them on the plugin's page; their labels are `settings.<key>`, `settings.<key>Hint` and
|
|
65
|
+
`settings.<key>Options.<option>` in the plugin's locales.
|
|
66
|
+
|
|
67
|
+
`server/plugins/manifest.ts` is the full reference.
|
|
68
|
+
|
|
69
|
+
## The server part
|
|
70
|
+
|
|
71
|
+
`server/index.ts` exports a `ServerPlugin` by default (types in `@hodi-app/plugin-sdk/server`, `plugin-sdk/server.ts` here):
|
|
72
|
+
|
|
73
|
+
- `migrations`: its own `CREATE TABLE`/`ALTER TABLE` steps, in order. Each runs once. Never edit or reorder one
|
|
74
|
+
that is released: add a new one.
|
|
75
|
+
- `routes(router, ctx)`: an Express router served at `/api/plugins/<id>/`, to the signed-in profiles the plugin is
|
|
76
|
+
for, while it is on.
|
|
77
|
+
|
|
78
|
+
It never imports Hodi's modules. Its context gives it what it may use: `ctx.db` (its own tables only), `ctx.settings()`,
|
|
79
|
+
`ctx.fetchText` (Hodi's guarded outgoing requests), `ctx.log` and `ctx.dataDir` (a folder of its own, kept in backups).
|
|
80
|
+
|
|
81
|
+
## The web part
|
|
82
|
+
|
|
83
|
+
`web/index.tsx` imports React and `hodi:sdk`, which the page gives it, and calls `definePlugin()` once:
|
|
84
|
+
|
|
85
|
+
```tsx
|
|
86
|
+
import { Button, definePlugin, usePluginApi, usePluginTranslation } from 'hodi:sdk'
|
|
87
|
+
|
|
88
|
+
const Page = () => {
|
|
89
|
+
const { t } = usePluginTranslation()
|
|
90
|
+
const api = usePluginApi() // api('/hello') asks /api/plugins/<id>/hello
|
|
91
|
+
return <Button onClick={() => void api('/hello', { method: 'POST' })}>{t('say')}</Button>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
definePlugin({ settingsPage: Page })
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`hodi:sdk` (`web/src/plugins/sdk.ts`) has Hodi's components (`Button`, `Dialog`, `SettingRow`, `Toggle`, …), its
|
|
98
|
+
confirmations and toasts, and hooks scoped to the plugin. Use Hodi's CSS variables for colours, and prefix your class
|
|
99
|
+
names with the plugin's id: its styles share the page with Hodi's.
|
|
100
|
+
|
|
101
|
+
Hodi's rules hold for plugins too: no AI call without a click, every text in the locales, French says « tu ».
|
|
102
|
+
|
|
103
|
+
## The package itself
|
|
104
|
+
|
|
105
|
+
`@hodi-app/plugin-sdk` is built from this folder with `npm run plugin-sdk:build` (tsdown): the build command with the parts of
|
|
106
|
+
Hodi it needs bundled in, and the types of the server part and of `hodi:sdk` generated from Hodi's own code, so they
|
|
107
|
+
never drift from it. To publish a version, raise `version` in `plugin-sdk/package.json` in a pull request, then, once
|
|
108
|
+
it is merged, push a tag `sdk-v<version>` on main (`git tag sdk-v0.2.0 && git push origin sdk-v0.2.0`): the "Publish
|
|
109
|
+
plugin SDK" workflow checks, builds and publishes it. Removing or changing anything in `hodi:sdk` or the server context needs a new
|
|
110
|
+
major version of the plugin API.
|
package/dist/bin.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { t as buildPlugin } from "./build-Bbd9DMak.mjs";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
// cli.ts
|
|
6
|
+
const usage = "Usage: hodi-plugin build [plugin folder] [--key private.pem]";
|
|
7
|
+
const run = async (args) => {
|
|
8
|
+
const rest = args[0] === "build" ? args.slice(1) : args;
|
|
9
|
+
const keyAt = rest.indexOf("--key");
|
|
10
|
+
const keyFile = keyAt === -1 ? void 0 : rest[keyAt + 1];
|
|
11
|
+
if (keyAt !== -1 && !keyFile) throw new Error(usage);
|
|
12
|
+
const folders = rest.filter((_arg, index) => keyAt === -1 || index !== keyAt && index !== keyAt + 1);
|
|
13
|
+
if (folders.length > 1 || folders.some((folder) => folder.startsWith("-"))) throw new Error(usage);
|
|
14
|
+
const built = await buildPlugin(path.resolve(folders[0] ?? "."), { key: keyFile ? readFileSync(keyFile, "utf8") : void 0 });
|
|
15
|
+
console.log(`${built.signed ? "Signed" : "Unsigned"} package: ${path.relative(process.cwd(), built.file)}`);
|
|
16
|
+
};
|
|
17
|
+
// bin.ts
|
|
18
|
+
await run(process.argv.slice(2)).catch((error) => {
|
|
19
|
+
console.error(error instanceof Error ? error.message : error);
|
|
20
|
+
process.exit(1);
|
|
21
|
+
});
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash, sign } from "node:crypto";
|
|
4
|
+
import { create } from "tar";
|
|
5
|
+
import { build } from "vite";
|
|
6
|
+
const CAPABILITIES = [
|
|
7
|
+
"profile-kind:child",
|
|
8
|
+
"shell:child",
|
|
9
|
+
"speech"
|
|
10
|
+
];
|
|
11
|
+
const AUDIENCES = ["admin", "all"];
|
|
12
|
+
const ID = /^[a-z][a-z0-9-]{1,31}$/;
|
|
13
|
+
const RESERVED_IDS = ["packages"];
|
|
14
|
+
const KEY = /^[a-zA-Z][a-zA-Z0-9]{0,31}$/;
|
|
15
|
+
const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
16
|
+
const MAX_SETTINGS = 32;
|
|
17
|
+
const MAX_TEXT = 2e3;
|
|
18
|
+
var ManifestError = class extends Error {};
|
|
19
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
const text = (value, field, max) => {
|
|
21
|
+
if (typeof value !== "string" || !value.trim() || value.length > max) throw new ManifestError(`${field} must be a non-empty text of at most ${max} characters`);
|
|
22
|
+
return value.trim();
|
|
23
|
+
};
|
|
24
|
+
const parseSetting = (value, index) => {
|
|
25
|
+
if (!isRecord(value)) throw new ManifestError(`settings[${index}] must be an object`);
|
|
26
|
+
const { key, type } = value;
|
|
27
|
+
if (typeof key !== "string" || !KEY.test(key)) throw new ManifestError(`settings[${index}].key is not a valid key`);
|
|
28
|
+
if (type === "boolean" && typeof value.default === "boolean") return {
|
|
29
|
+
key,
|
|
30
|
+
type,
|
|
31
|
+
default: value.default
|
|
32
|
+
};
|
|
33
|
+
if (type === "number" && typeof value.default === "number" && Number.isFinite(value.default)) {
|
|
34
|
+
const min = typeof value.min === "number" ? value.min : void 0;
|
|
35
|
+
const max = typeof value.max === "number" ? value.max : void 0;
|
|
36
|
+
if (min !== void 0 && value.default < min || max !== void 0 && value.default > max) throw new ManifestError(`settings[${index}].default is out of its range`);
|
|
37
|
+
return {
|
|
38
|
+
key,
|
|
39
|
+
type,
|
|
40
|
+
default: value.default,
|
|
41
|
+
min,
|
|
42
|
+
max
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (type === "text" && typeof value.default === "string") {
|
|
46
|
+
const maxLength = typeof value.maxLength === "number" ? Math.min(value.maxLength, MAX_TEXT) : MAX_TEXT;
|
|
47
|
+
if (value.default.length > maxLength) throw new ManifestError(`settings[${index}].default is too long`);
|
|
48
|
+
return {
|
|
49
|
+
key,
|
|
50
|
+
type,
|
|
51
|
+
default: value.default,
|
|
52
|
+
maxLength
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (type === "choice" && Array.isArray(value.options) && value.options.length > 0 && value.options.every((option) => typeof option === "string" && KEY.test(option)) && value.options.includes(value.default)) return {
|
|
56
|
+
key,
|
|
57
|
+
type,
|
|
58
|
+
default: value.default,
|
|
59
|
+
options: value.options
|
|
60
|
+
};
|
|
61
|
+
throw new ManifestError(`settings[${index}] has an unknown type or a default that does not fit it`);
|
|
62
|
+
};
|
|
63
|
+
const parseManifest = (value) => {
|
|
64
|
+
if (!isRecord(value)) throw new ManifestError("plugin.json must hold an object");
|
|
65
|
+
const { id, version, api } = value;
|
|
66
|
+
if (typeof id !== "string" || !ID.test(id)) throw new ManifestError("id must be lowercase letters, digits and dashes");
|
|
67
|
+
if (RESERVED_IDS.includes(id)) throw new ManifestError(`${id} is a name Hodi keeps for itself`);
|
|
68
|
+
if (typeof version !== "string" || !VERSION.test(version)) throw new ManifestError("version must be like 1.2.3");
|
|
69
|
+
if (api !== 1) throw new ManifestError(`api must be 1, this plugin was written for ${api}`);
|
|
70
|
+
const audience = value.audience ?? "all";
|
|
71
|
+
if (!AUDIENCES.includes(audience)) throw new ManifestError("audience must be admin or all");
|
|
72
|
+
const provides = value.provides ?? [];
|
|
73
|
+
if (!Array.isArray(provides) || !provides.every((entry) => CAPABILITIES.includes(entry))) throw new ManifestError(`provides may only hold ${CAPABILITIES.join(", ")}`);
|
|
74
|
+
let menu = null;
|
|
75
|
+
if (value.menu !== void 0) {
|
|
76
|
+
if (!isRecord(value.menu) || typeof value.menu.group !== "string" || !KEY.test(value.menu.group)) throw new ManifestError("menu.group must name a group of Réglages");
|
|
77
|
+
const { after, icon } = value.menu;
|
|
78
|
+
if (after !== void 0 && (typeof after !== "string" || !KEY.test(after))) throw new ManifestError("menu.after must name an entry of Réglages");
|
|
79
|
+
if (icon !== void 0 && (typeof icon !== "string" || !/^[A-Z][A-Za-z0-9]{0,47}$/.test(icon))) throw new ManifestError("menu.icon must be the name of a Lucide icon");
|
|
80
|
+
menu = {
|
|
81
|
+
group: value.menu.group,
|
|
82
|
+
...after ? { after } : {},
|
|
83
|
+
...icon ? { icon } : {}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const settings = value.settings ?? [];
|
|
87
|
+
if (!Array.isArray(settings) || settings.length > MAX_SETTINGS) throw new ManifestError(`settings must be a list of at most ${MAX_SETTINGS}`);
|
|
88
|
+
const specs = settings.map(parseSetting);
|
|
89
|
+
if (new Set(specs.map((spec) => spec.key)).size !== specs.length) throw new ManifestError("settings keys must differ");
|
|
90
|
+
return {
|
|
91
|
+
id,
|
|
92
|
+
name: text(value.name, "name", 60),
|
|
93
|
+
version,
|
|
94
|
+
description: text(value.description, "description", 280),
|
|
95
|
+
api,
|
|
96
|
+
audience,
|
|
97
|
+
provides: [...new Set(provides)],
|
|
98
|
+
menu,
|
|
99
|
+
settings: specs
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
// ../server/plugins/package.ts
|
|
103
|
+
const SIGNATURE_FILE = "signature";
|
|
104
|
+
const packageDigest = (root) => {
|
|
105
|
+
const lines = [];
|
|
106
|
+
const walk = (dir) => {
|
|
107
|
+
for (const name of readdirSync(dir).sort()) {
|
|
108
|
+
const file = path.join(dir, name);
|
|
109
|
+
const relative = path.relative(root, file).split(path.sep).join("/");
|
|
110
|
+
if (lstatSync(file).isDirectory()) walk(file);
|
|
111
|
+
else if (relative !== SIGNATURE_FILE) lines.push(`${relative}\t${createHash("sha256").update(readFileSync(file)).digest("hex")}`);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
walk(root);
|
|
115
|
+
return Buffer.from(lines.join("\n"));
|
|
116
|
+
};
|
|
117
|
+
// build.ts
|
|
118
|
+
const WEB_GLOBALS = {
|
|
119
|
+
react: "__hodi.react",
|
|
120
|
+
"react/jsx-runtime": "__hodi.jsxRuntime",
|
|
121
|
+
"react-dom": "__hodi.reactDom",
|
|
122
|
+
"hodi:sdk": "__hodi.sdk"
|
|
123
|
+
};
|
|
124
|
+
const firstOf = (dir, names) => names.map((name) => path.join(dir, name)).find((file) => existsSync(file)) ?? null;
|
|
125
|
+
const buildPlugin = async (source, { out = path.join(source, "dist"), key } = {}) => {
|
|
126
|
+
const manifest = parseManifest(JSON.parse(readFileSync(path.join(source, "plugin.json"), "utf8")));
|
|
127
|
+
const stage = path.join(out, manifest.id);
|
|
128
|
+
rmSync(stage, {
|
|
129
|
+
recursive: true,
|
|
130
|
+
force: true
|
|
131
|
+
});
|
|
132
|
+
mkdirSync(stage, { recursive: true });
|
|
133
|
+
cpSync(path.join(source, "plugin.json"), path.join(stage, "plugin.json"));
|
|
134
|
+
const server = firstOf(source, ["server/index.ts", "server/index.js"]);
|
|
135
|
+
if (server) await build({
|
|
136
|
+
configFile: false,
|
|
137
|
+
logLevel: "warn",
|
|
138
|
+
build: {
|
|
139
|
+
ssr: server,
|
|
140
|
+
outDir: stage,
|
|
141
|
+
emptyOutDir: false,
|
|
142
|
+
minify: false,
|
|
143
|
+
rollupOptions: { output: {
|
|
144
|
+
format: "es",
|
|
145
|
+
entryFileNames: "server.js"
|
|
146
|
+
} }
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
const web = firstOf(source, ["web/index.tsx", "web/index.ts"]);
|
|
150
|
+
if (web) await build({
|
|
151
|
+
configFile: false,
|
|
152
|
+
logLevel: "warn",
|
|
153
|
+
define: { "process.env.NODE_ENV": "\"production\"" },
|
|
154
|
+
build: {
|
|
155
|
+
outDir: stage,
|
|
156
|
+
emptyOutDir: false,
|
|
157
|
+
cssCodeSplit: false,
|
|
158
|
+
lib: {
|
|
159
|
+
entry: web,
|
|
160
|
+
formats: ["iife"],
|
|
161
|
+
name: "__hodiPlugin",
|
|
162
|
+
fileName: () => "web.js",
|
|
163
|
+
cssFileName: "web"
|
|
164
|
+
},
|
|
165
|
+
rollupOptions: {
|
|
166
|
+
external: Object.keys(WEB_GLOBALS),
|
|
167
|
+
output: { globals: WEB_GLOBALS }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
for (const folder of ["locales", "assets"]) if (existsSync(path.join(source, folder))) cpSync(path.join(source, folder), path.join(stage, folder), { recursive: true });
|
|
172
|
+
if (key) writeFileSync(path.join(stage, "signature"), sign(null, packageDigest(stage), key).toString("base64"));
|
|
173
|
+
const file = path.join(out, `${manifest.id}-${manifest.version}.hodi-plugin`);
|
|
174
|
+
await create({
|
|
175
|
+
gzip: true,
|
|
176
|
+
file,
|
|
177
|
+
cwd: stage,
|
|
178
|
+
portable: true
|
|
179
|
+
}, ["."]);
|
|
180
|
+
return {
|
|
181
|
+
id: manifest.id,
|
|
182
|
+
version: manifest.version,
|
|
183
|
+
file,
|
|
184
|
+
signed: Boolean(key)
|
|
185
|
+
};
|
|
186
|
+
};
|
|
187
|
+
export { buildPlugin as t };
|
package/dist/build.d.mts
ADDED
package/dist/build.mjs
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { t as buildPlugin } from "./build-Bbd9DMak.mjs";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { DatabaseSync } from "node:sqlite";
|
|
3
|
+
type Database = DatabaseSync;
|
|
4
|
+
interface FetchedText {
|
|
5
|
+
url: string;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
type FetchText = (url: string, options?: {
|
|
9
|
+
userAgent?: string;
|
|
10
|
+
headers?: Record<string, string>;
|
|
11
|
+
}) => Promise<FetchedText>;
|
|
12
|
+
declare const CAPABILITIES: readonly ["profile-kind:child", "shell:child", "speech"];
|
|
13
|
+
type Capability = (typeof CAPABILITIES)[number];
|
|
14
|
+
declare const AUDIENCES: readonly ["admin", "all"];
|
|
15
|
+
type Audience = (typeof AUDIENCES)[number];
|
|
16
|
+
type SettingSpec = {
|
|
17
|
+
key: string;
|
|
18
|
+
type: 'boolean';
|
|
19
|
+
default: boolean;
|
|
20
|
+
} | {
|
|
21
|
+
key: string;
|
|
22
|
+
type: 'number';
|
|
23
|
+
default: number;
|
|
24
|
+
min?: number;
|
|
25
|
+
max?: number;
|
|
26
|
+
} | {
|
|
27
|
+
key: string;
|
|
28
|
+
type: 'text';
|
|
29
|
+
default: string;
|
|
30
|
+
maxLength?: number;
|
|
31
|
+
} | {
|
|
32
|
+
key: string;
|
|
33
|
+
type: 'choice';
|
|
34
|
+
default: string;
|
|
35
|
+
options: string[];
|
|
36
|
+
};
|
|
37
|
+
type SettingValue = boolean | number | string;
|
|
38
|
+
type SettingValues = Record<string, SettingValue>;
|
|
39
|
+
interface MenuPlacement {
|
|
40
|
+
group: string;
|
|
41
|
+
after?: string;
|
|
42
|
+
icon?: string;
|
|
43
|
+
}
|
|
44
|
+
interface Manifest {
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
version: string;
|
|
48
|
+
description: string;
|
|
49
|
+
api: number;
|
|
50
|
+
audience: Audience;
|
|
51
|
+
provides: Capability[];
|
|
52
|
+
menu: MenuPlacement | null;
|
|
53
|
+
settings: SettingSpec[];
|
|
54
|
+
}
|
|
55
|
+
interface PluginContext {
|
|
56
|
+
id: string;
|
|
57
|
+
db: Database;
|
|
58
|
+
settings: () => SettingValues;
|
|
59
|
+
fetchText: FetchText;
|
|
60
|
+
log: (message: string) => void;
|
|
61
|
+
dataDir: string;
|
|
62
|
+
}
|
|
63
|
+
interface ServerPlugin {
|
|
64
|
+
migrations?: readonly string[];
|
|
65
|
+
routes?: (router: Router, context: PluginContext) => void;
|
|
66
|
+
}
|
|
67
|
+
export type { Manifest, PluginContext, ServerPlugin, SettingSpec, SettingValues };
|
package/dist/web.d.mts
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { ButtonHTMLAttributes, ComponentType, ForwardRefExoticComponent, InputHTMLAttributes, ReactNode, RefAttributes, SVGProps } from "react";
|
|
2
|
+
interface WebPlugin {
|
|
3
|
+
settingsPage?: ComponentType;
|
|
4
|
+
}
|
|
5
|
+
export declare const usePluginCapability: (capability: string) => boolean;
|
|
6
|
+
type ButtonLook = 'neutral' | 'primary' | 'danger' | 'ai' | 'glass';
|
|
7
|
+
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
8
|
+
children: ReactNode;
|
|
9
|
+
look?: ButtonLook;
|
|
10
|
+
icon?: ComponentType<{
|
|
11
|
+
size?: number;
|
|
12
|
+
'aria-hidden'?: boolean | 'true';
|
|
13
|
+
}>;
|
|
14
|
+
busy?: boolean;
|
|
15
|
+
wide?: boolean;
|
|
16
|
+
compact?: boolean;
|
|
17
|
+
href?: string;
|
|
18
|
+
external?: boolean;
|
|
19
|
+
}
|
|
20
|
+
declare const Button: ({ children, look, icon: Icon, busy, wide, compact, href, external, className, disabled, ...props }: ButtonProps) => import("react").JSX.Element;
|
|
21
|
+
declare const Chip: ({ children, label, title, tone, size }: {
|
|
22
|
+
children: ReactNode;
|
|
23
|
+
label?: string;
|
|
24
|
+
title?: string;
|
|
25
|
+
tone?: "success" | "warning" | "danger" | "accent";
|
|
26
|
+
size?: "capsule" | "row";
|
|
27
|
+
}) => import("react").JSX.Element;
|
|
28
|
+
interface DialogProps {
|
|
29
|
+
open: boolean;
|
|
30
|
+
onClose: () => void;
|
|
31
|
+
size?: 'compact' | 'wide';
|
|
32
|
+
title?: ReactNode;
|
|
33
|
+
onBack?: () => void;
|
|
34
|
+
labelledBy?: string;
|
|
35
|
+
describedBy?: string;
|
|
36
|
+
intro?: ReactNode;
|
|
37
|
+
className?: string;
|
|
38
|
+
children: ReactNode;
|
|
39
|
+
}
|
|
40
|
+
declare const Dialog: ({ open, onClose, size, title, onBack, labelledBy, describedBy, intro, className, children }: DialogProps) => import("react").JSX.Element;
|
|
41
|
+
declare const DialogActions: ({ children }: {
|
|
42
|
+
children: ReactNode;
|
|
43
|
+
}) => import("react").JSX.Element;
|
|
44
|
+
interface EmptyStateProps {
|
|
45
|
+
icon: ReactNode;
|
|
46
|
+
title: string;
|
|
47
|
+
children?: ReactNode;
|
|
48
|
+
actions?: ReactNode;
|
|
49
|
+
tone?: 'default' | 'danger';
|
|
50
|
+
compact?: boolean;
|
|
51
|
+
}
|
|
52
|
+
declare const EmptyState: ({ icon, title, children, actions, tone, compact }: EmptyStateProps) => import("react").JSX.Element;
|
|
53
|
+
type SVGAttributes = Partial<SVGProps<SVGSVGElement>>;
|
|
54
|
+
type ElementAttributes = RefAttributes<SVGSVGElement> & SVGAttributes;
|
|
55
|
+
interface LucideProps extends ElementAttributes {
|
|
56
|
+
size?: string | number;
|
|
57
|
+
/**
|
|
58
|
+
* @deprecated Use `nonScalingStroke` instead.
|
|
59
|
+
*/
|
|
60
|
+
absoluteStrokeWidth?: boolean;
|
|
61
|
+
nonScalingStroke?: boolean;
|
|
62
|
+
}
|
|
63
|
+
type LucideIcon = ForwardRefExoticComponent<Omit<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>>;
|
|
64
|
+
type RoundVariant = 'default' | 'on' | 'glass' | 'image' | 'ai' | 'danger' | 'primary';
|
|
65
|
+
interface RoundButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
66
|
+
icon: LucideIcon;
|
|
67
|
+
label: string;
|
|
68
|
+
variant?: RoundVariant;
|
|
69
|
+
size?: 28 | 34 | 44;
|
|
70
|
+
filled?: boolean;
|
|
71
|
+
busy?: boolean;
|
|
72
|
+
href?: string;
|
|
73
|
+
external?: boolean;
|
|
74
|
+
children?: ReactNode;
|
|
75
|
+
}
|
|
76
|
+
declare const RoundButton: ({ icon: Icon, label, variant, size, filled, busy, href, external, className, ...props }: RoundButtonProps) => import("react").JSX.Element;
|
|
77
|
+
interface StatusLineProps {
|
|
78
|
+
tone: 'info' | 'error';
|
|
79
|
+
children: string;
|
|
80
|
+
className?: string;
|
|
81
|
+
}
|
|
82
|
+
declare const StatusLine: ({ tone, children, className }: StatusLineProps) => import("react").JSX.Element;
|
|
83
|
+
interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
|
|
84
|
+
icon: LucideIcon;
|
|
85
|
+
'aria-label': string;
|
|
86
|
+
onClear?: () => void;
|
|
87
|
+
}
|
|
88
|
+
declare const TextField: ({ icon: Icon, type, onClear, ...props }: TextFieldProps) => import("react").JSX.Element;
|
|
89
|
+
interface ToggleProps {
|
|
90
|
+
checked: boolean;
|
|
91
|
+
label: string;
|
|
92
|
+
disabled?: boolean;
|
|
93
|
+
onChange: (checked: boolean) => void;
|
|
94
|
+
}
|
|
95
|
+
declare const Toggle: ({ checked, label, disabled, onChange }: ToggleProps) => import("react").JSX.Element;
|
|
96
|
+
interface SectionPageProps {
|
|
97
|
+
title: string;
|
|
98
|
+
intro?: ReactNode;
|
|
99
|
+
aside?: ReactNode;
|
|
100
|
+
layout: 'desktop' | 'phone';
|
|
101
|
+
children: ReactNode;
|
|
102
|
+
}
|
|
103
|
+
declare const SectionPage: ({ title, intro, aside, layout, children }: SectionPageProps) => import("react").JSX.Element;
|
|
104
|
+
interface SettingRowProps {
|
|
105
|
+
label: ReactNode;
|
|
106
|
+
meta?: ReactNode;
|
|
107
|
+
hint?: ReactNode;
|
|
108
|
+
lead?: ReactNode;
|
|
109
|
+
as?: 'div' | 'li';
|
|
110
|
+
className?: string;
|
|
111
|
+
children?: ReactNode;
|
|
112
|
+
}
|
|
113
|
+
declare const SettingRow: ({ label, meta, hint, lead, as: Tag, className, children }: SettingRowProps) => import("react").JSX.Element;
|
|
114
|
+
interface Confirmation {
|
|
115
|
+
title: string;
|
|
116
|
+
message: ReactNode;
|
|
117
|
+
label: string;
|
|
118
|
+
onConfirm: () => void;
|
|
119
|
+
}
|
|
120
|
+
export declare const askToConfirm: (confirmation: Confirmation) => void;
|
|
121
|
+
interface ToastAction {
|
|
122
|
+
label: string;
|
|
123
|
+
onClick: () => void;
|
|
124
|
+
}
|
|
125
|
+
export declare const showDone: (message: string, undo?: () => void) => undefined;
|
|
126
|
+
export declare const showSaved: (message?: string) => undefined;
|
|
127
|
+
export declare const showError: (message: string, action?: ToastAction, id?: string) => undefined;
|
|
128
|
+
export declare const definePlugin: (definition: WebPlugin) => void;
|
|
129
|
+
export declare const usePluginTranslation: () => {
|
|
130
|
+
t: (key: string, options?: Record<string, unknown>) => string;
|
|
131
|
+
language: string;
|
|
132
|
+
};
|
|
133
|
+
export declare const usePluginApi: () => (path: string, init?: RequestInit) => Promise<Response>;
|
|
134
|
+
export { Button, Chip, Dialog, DialogActions, EmptyState, RoundButton, SectionPage, SettingRow, StatusLine, TextField, Toggle };
|
package/hodi-sdk.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// A plugin imports Hodi's components from 'hodi:sdk', a name the build reads from the page. This gives that name the
|
|
2
|
+
// types of @hodi-app/plugin-sdk/web: a plugin lists "@hodi-app/plugin-sdk/hodi-sdk" in its tsconfig's "types".
|
|
3
|
+
declare module 'hodi:sdk' {
|
|
4
|
+
export * from '@hodi-app/plugin-sdk/web'
|
|
5
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hodi-app/plugin-sdk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Build and type plugins for Hodi, the self-hosted news reader for a household.",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Anthodpnt/hodi.git",
|
|
9
|
+
"directory": "plugin-sdk"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/Anthodpnt/hodi/tree/main/plugin-sdk#readme",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"bin": {
|
|
14
|
+
"hodi-plugin": "dist/bin.mjs"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/build.d.mts",
|
|
19
|
+
"default": "./dist/build.mjs"
|
|
20
|
+
},
|
|
21
|
+
"./server": {
|
|
22
|
+
"types": "./dist/server.d.mts"
|
|
23
|
+
},
|
|
24
|
+
"./web": {
|
|
25
|
+
"types": "./dist/web.d.mts"
|
|
26
|
+
},
|
|
27
|
+
"./hodi-sdk": {
|
|
28
|
+
"types": "./hodi-sdk.d.ts"
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"dist",
|
|
33
|
+
"hodi-sdk.d.ts",
|
|
34
|
+
"README.md"
|
|
35
|
+
],
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "tsdown",
|
|
38
|
+
"prepublishOnly": "tsdown"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=24"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"tar": "^7.5.22",
|
|
45
|
+
"vite": "^8.3.5"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@types/express": "^5.0.0",
|
|
49
|
+
"@types/node": "^24.0.0",
|
|
50
|
+
"@types/react": "^19.0.0"
|
|
51
|
+
},
|
|
52
|
+
"peerDependenciesMeta": {
|
|
53
|
+
"@types/express": {
|
|
54
|
+
"optional": true
|
|
55
|
+
},
|
|
56
|
+
"@types/node": {
|
|
57
|
+
"optional": true
|
|
58
|
+
},
|
|
59
|
+
"@types/react": {
|
|
60
|
+
"optional": true
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|