@hodi-app/plugin-sdk 0.0.0-stage → 0.2.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 CHANGED
@@ -1,3 +1,163 @@
1
- # Temporary Holding Version
1
+ # Writing a Hodi plugin
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
6
+ [`hodi-plugin-template`](https://github.com/leclaudeur/hodi-plugin-template).
7
+
8
+ ## A plugin's folder
9
+
10
+ ```text
11
+ my-plugin/
12
+ plugin.json what it is: id, name, version, api, who it is for, its settings
13
+ server/index.ts optional: its server part, bundled into server.js
14
+ web/index.tsx optional: its web part, bundled into web.js (and web.css from the CSS it imports)
15
+ locales/fr.json its texts, one file per language (fr is the fallback)
16
+ assets/ optional: pictures and other files its web part asks for
17
+ ```
18
+
19
+ ## Two ways to write one
20
+
21
+ **In its own repository** (the usual way): start from
22
+ [`hodi-plugin-template`](https://github.com/leclaudeur/hodi-plugin-template), which depends on this package,
23
+ `@hodi-app/plugin-sdk`:
24
+
25
+ ```bash
26
+ npm install --save-dev @hodi-app/plugin-sdk
27
+ npx hodi-plugin build # the current folder into dist/<id>-<version>.hodi-plugin
28
+ ```
29
+
30
+ Its `tsconfig.json` lists `"@hodi-app/plugin-sdk/hodi-sdk"` in `"types"`, so `import … from 'hodi:sdk'` is typed, and the
31
+ server part takes its types from `@hodi-app/plugin-sdk/server`.
32
+
33
+ **Inside a clone of Hodi**, as the test plugin `plugin-sdk/test/hello-world` is: `tsconfig.plugins.json` maps `hodi:sdk` to its source, and
34
+
35
+ ```bash
36
+ npm run plugin:build -- path/to/my-plugin
37
+ ```
38
+
39
+ ## Signing
40
+
41
+ A package is unsigned unless a key is given: `hodi-plugin build --key private.pem` (an ed25519 key, PEM). Hodi says
42
+ "Vérifié" only when the key that signed it is one of the keys it trusts (`TRUSTED_KEYS` in
43
+ `server/plugins/package.ts`): that is the maintainer's key, for Hodi's official plugins. Every other plugin installs as
44
+ "Code non vérifié", after a second question. Keep a private key out of every repository and plugin folder.
45
+
46
+ ## `plugin.json`
47
+
48
+ ```json
49
+ {
50
+ "id": "my-plugin",
51
+ "name": "My plugin",
52
+ "version": "1.0.0",
53
+ "description": "One sentence, shown in Réglages › Plugins.",
54
+ "api": 1,
55
+ "audience": "all",
56
+ "settings": [{ "key": "greeting", "type": "text", "default": "Bonjour" }]
57
+ }
58
+ ```
59
+
60
+ - `id`: lowercase letters, digits and dashes. It names the plugin's folder, its routes (`/api/plugins/<id>/…`) and its
61
+ tables (`plugin_<id>_…`, with dashes as underscores).
62
+ - `api`: the plugin API it is written for. Hodi does not load a plugin written for another major version.
63
+ - `audience`: `all` (every profile) or `admin` (the household's admin only): who sees its pages and reaches its routes.
64
+ - `settings`: `boolean`, `number` (`min`, `max`), `text` (`maxLength`) or `choice` (`options`), each with a `default`.
65
+ Hodi draws them on the plugin's page; their labels are `settings.<key>`, `settings.<key>Hint` and
66
+ `settings.<key>Options.<option>` in the plugin's locales.
67
+
68
+ `server/plugins/manifest.ts` is the full reference.
69
+
70
+ ## The server part
71
+
72
+ `server/index.ts` exports a `ServerPlugin` by default (types in `@hodi-app/plugin-sdk/server`, `plugin-sdk/server.ts` here):
73
+
74
+ - `migrations`: its own `CREATE TABLE`/`ALTER TABLE` steps, in order. Each runs once. Never edit or reorder one
75
+ that is released: add a new one.
76
+ - `routes(router, ctx)`: an Express router served at `/api/plugins/<id>/`, to the signed-in profiles the plugin is
77
+ for, while it is on.
78
+
79
+ - `onArticle(article, ctx)`: told of each article Hodi reads from its site for the first time (in the reader or in
80
+ the background), while the plugin is on. It must return at once; slow work goes in the background.
81
+
82
+ It never imports Hodi's modules. Its context gives it what it may use: `ctx.db` (its own tables only), `ctx.settings()`,
83
+ `ctx.fetchText` (Hodi's guarded outgoing requests), `ctx.log`, `ctx.dataDir` (a folder of its own, kept in backups) and
84
+ `ctx.articles`:
85
+
86
+ - `ctx.articles.get(id)`: an article a source listed, with its reader text when it has one (`status` `ok`,
87
+ `unreadable` or `failed`); `null` for an id Hodi never listed.
88
+ - `ctx.articles.page(id)`: its page as the site serves it now, fetched again from the source's own domains; `null`
89
+ when it cannot be had.
90
+
91
+ A JSON body sent to the plugin's routes is read for it, up to 64 KB. Anything else, such as an image, reaches the
92
+ route unread: read the request stream yourself and set your own size limit.
93
+
94
+ ## The web part
95
+
96
+ `web/index.tsx` imports React and `hodi:sdk`, which the page gives it, and calls `definePlugin()` once:
97
+
98
+ ```tsx
99
+ import { Button, definePlugin, usePluginApi, usePluginTranslation } from 'hodi:sdk'
100
+
101
+ const Page = () => {
102
+ const { t } = usePluginTranslation()
103
+ const api = usePluginApi() // api('/hello') asks /api/plugins/<id>/hello
104
+ return <Button onClick={() => void api('/hello', { method: 'POST' })}>{t('say')}</Button>
105
+ }
106
+
107
+ definePlugin({ settingsPage: Page })
108
+ ```
109
+
110
+ `definePlugin()` takes:
111
+
112
+ - `settingsPage`: drawn under the plugin's options in Réglages › Plugins.
113
+ - `page`: a page of its own, an entry of Réglages placed by the manifest's `menu` (`group`: `reading`, `subjects`, `you`,
114
+ `household`, `device` or `about`; `after`: the entry it follows, such as `ai`). An unknown group or entry puts it at the end of the
115
+ household's group. It gets `layout` (`desktop` or `phone`): draw it with `SectionPage`, as Hodi's own pages.
116
+ - `menuIcon`: that entry's icon, drawn at 18 px (a Lucide icon the plugin bundles); a puzzle piece without one.
117
+ - `menuHint`: the line under the entry's name, as Hodi's entries say what they hold ("12 articles signalés"); the
118
+ plugin's description without one.
119
+ - `readerAction`: drawn at the end of an article beside "Lire l'original". It gets `article` (`id`, `title`, `url`,
120
+ `sourceId`, `sourceName`) and usually draws a `ReaderButton`, which looks like that link, with `done` once the action
121
+ is done for this article.
122
+
123
+ `hodi:sdk` (`web/src/plugins/sdk.ts`) has Hodi's components (`Button`, `Dialog`, `FilterChip`, `SettingsCard`,
124
+ `SettingRow`, `SourceAvatar`, `Toggle`, …), `shortAgo` to date an article as cards do, its
125
+ confirmations and toasts, and hooks scoped to the plugin. Use Hodi's CSS variables for colours, and prefix your class
126
+ names with the plugin's id: its styles share the page with Hodi's.
127
+
128
+ ### Links and screens
129
+
130
+ A plugin never writes one of Hodi's addresses (`#/article/…`, `#/settings/…`): Hodi's routes may change, and a plugin
131
+ built against them would break. It asks Hodi for them instead, and they follow Hodi:
132
+
133
+ ```tsx
134
+ import { Button, navigate, usePluginLinks, usePluginPath } from 'hodi:sdk'
135
+
136
+ const Page = () => {
137
+ const links = usePluginLinks()
138
+ // Its own screens live under its page: #/settings/plugin/<id>/<path>, '' at the root.
139
+ const path = usePluginPath()
140
+ return path === 'history' ? (
141
+ <a href={links.page()}>Back</a>
142
+ ) : (
143
+ <>
144
+ <a href={links.article(articleId)}>Open the article</a>
145
+ <Button onClick={() => navigate(links.page('history'))}>History</Button>
146
+ </>
147
+ )
148
+ }
149
+ ```
150
+
151
+ `usePluginLinks()` gives `frontPage`, `article(id)`, `settings`, `plugins` (Réglages › Plugins) and `page(path?)`, the
152
+ plugin's own page or one of its screens. `navigate(href)` goes there from a button or after an action.
153
+
154
+ Hodi's rules hold for plugins too: no AI call without a click, every text in the locales, French says « tu ».
155
+
156
+ ## The package itself
157
+
158
+ `@hodi-app/plugin-sdk` is built from this folder with `npm run plugin-sdk:build` (tsdown): the build command with the parts of
159
+ Hodi it needs bundled in, and the types of the server part and of `hodi:sdk` generated from Hodi's own code, so they
160
+ never drift from it. To publish a version, raise `version` in `plugin-sdk/package.json` in a pull request, then, once
161
+ 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
162
+ plugin SDK" workflow checks, builds and publishes it. Removing or changing anything in `hodi:sdk` or the server context needs a new
163
+ 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-KyLGWV-_.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,188 @@
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
+ ssr: { noExternal: true },
139
+ build: {
140
+ ssr: server,
141
+ outDir: stage,
142
+ emptyOutDir: false,
143
+ minify: false,
144
+ rollupOptions: { output: {
145
+ format: "es",
146
+ entryFileNames: "server.js"
147
+ } }
148
+ }
149
+ });
150
+ const web = firstOf(source, ["web/index.tsx", "web/index.ts"]);
151
+ if (web) await build({
152
+ configFile: false,
153
+ logLevel: "warn",
154
+ define: { "process.env.NODE_ENV": "\"production\"" },
155
+ build: {
156
+ outDir: stage,
157
+ emptyOutDir: false,
158
+ cssCodeSplit: false,
159
+ lib: {
160
+ entry: web,
161
+ formats: ["iife"],
162
+ name: "__hodiPlugin",
163
+ fileName: () => "web.js",
164
+ cssFileName: "web"
165
+ },
166
+ rollupOptions: {
167
+ external: Object.keys(WEB_GLOBALS),
168
+ output: { globals: WEB_GLOBALS }
169
+ }
170
+ }
171
+ });
172
+ for (const folder of ["locales", "assets"]) if (existsSync(path.join(source, folder))) cpSync(path.join(source, folder), path.join(stage, folder), { recursive: true });
173
+ if (key) writeFileSync(path.join(stage, "signature"), sign(null, packageDigest(stage), key).toString("base64"));
174
+ const file = path.join(out, `${manifest.id}-${manifest.version}.hodi-plugin`);
175
+ await create({
176
+ gzip: true,
177
+ file,
178
+ cwd: stage,
179
+ portable: true
180
+ }, ["."]);
181
+ return {
182
+ id: manifest.id,
183
+ version: manifest.version,
184
+ file,
185
+ signed: Boolean(key)
186
+ };
187
+ };
188
+ export { buildPlugin as t };
@@ -0,0 +1,10 @@
1
+ export interface BuiltPlugin {
2
+ id: string;
3
+ version: string;
4
+ file: string;
5
+ signed: boolean;
6
+ }
7
+ export declare const buildPlugin: (source: string, { out, key }?: {
8
+ out?: string;
9
+ key?: string;
10
+ }) => Promise<BuiltPlugin>;
package/dist/build.mjs ADDED
@@ -0,0 +1 @@
1
+ export { t as buildPlugin } from "./build-KyLGWV-_.mjs";
@@ -0,0 +1,91 @@
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
+ interface PluginArticle {
13
+ id: string;
14
+ title: string;
15
+ url: string;
16
+ sourceId: string;
17
+ sourceName: string;
18
+ publishedAt: string | null;
19
+ status: 'ok' | 'unreadable' | 'failed';
20
+ text: {
21
+ title: string | null;
22
+ byline: string | null;
23
+ contentHtml: string;
24
+ textLength: number;
25
+ } | null;
26
+ }
27
+ interface PluginArticles {
28
+ get: (id: string) => Promise<PluginArticle | null>;
29
+ page: (id: string) => Promise<{
30
+ url: string;
31
+ html: string;
32
+ } | null>;
33
+ }
34
+ declare const CAPABILITIES: readonly ["profile-kind:child", "shell:child", "speech"];
35
+ type Capability = (typeof CAPABILITIES)[number];
36
+ declare const AUDIENCES: readonly ["admin", "all"];
37
+ type Audience = (typeof AUDIENCES)[number];
38
+ type SettingSpec = {
39
+ key: string;
40
+ type: 'boolean';
41
+ default: boolean;
42
+ } | {
43
+ key: string;
44
+ type: 'number';
45
+ default: number;
46
+ min?: number;
47
+ max?: number;
48
+ } | {
49
+ key: string;
50
+ type: 'text';
51
+ default: string;
52
+ maxLength?: number;
53
+ } | {
54
+ key: string;
55
+ type: 'choice';
56
+ default: string;
57
+ options: string[];
58
+ };
59
+ type SettingValue = boolean | number | string;
60
+ type SettingValues = Record<string, SettingValue>;
61
+ interface MenuPlacement {
62
+ group: string;
63
+ after?: string;
64
+ icon?: string;
65
+ }
66
+ interface Manifest {
67
+ id: string;
68
+ name: string;
69
+ version: string;
70
+ description: string;
71
+ api: number;
72
+ audience: Audience;
73
+ provides: Capability[];
74
+ menu: MenuPlacement | null;
75
+ settings: SettingSpec[];
76
+ }
77
+ interface PluginContext {
78
+ id: string;
79
+ db: Database;
80
+ settings: () => SettingValues;
81
+ fetchText: FetchText;
82
+ log: (message: string) => void;
83
+ dataDir: string;
84
+ articles: PluginArticles;
85
+ }
86
+ interface ServerPlugin {
87
+ migrations?: readonly string[];
88
+ routes?: (router: Router, context: PluginContext) => void;
89
+ onArticle?: (article: PluginArticle, context: PluginContext) => void;
90
+ }
91
+ export type { Manifest, PluginArticle, PluginArticles, PluginContext, ServerPlugin, SettingSpec, SettingValues };
package/dist/web.d.mts ADDED
@@ -0,0 +1,190 @@
1
+ import { ButtonHTMLAttributes, ComponentType, ForwardRefExoticComponent, InputHTMLAttributes, ReactNode, RefAttributes, SVGProps } from "react";
2
+ interface ReaderArticle {
3
+ id: string;
4
+ title: string;
5
+ url: string;
6
+ sourceId: string;
7
+ sourceName: string;
8
+ }
9
+ interface WebPlugin {
10
+ readerAction?: ComponentType<{
11
+ article: ReaderArticle;
12
+ }>;
13
+ settingsPage?: ComponentType;
14
+ page?: ComponentType<{
15
+ layout: 'desktop' | 'phone';
16
+ }>;
17
+ menuIcon?: ComponentType<{
18
+ size?: number;
19
+ }>;
20
+ menuHint?: ComponentType;
21
+ }
22
+ export declare const usePluginCapability: (capability: string) => boolean;
23
+ type ButtonLook = 'neutral' | 'primary' | 'danger' | 'ai' | 'glass';
24
+ interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
25
+ children: ReactNode;
26
+ look?: ButtonLook;
27
+ icon?: ComponentType<{
28
+ size?: number;
29
+ 'aria-hidden'?: boolean | 'true';
30
+ }>;
31
+ busy?: boolean;
32
+ wide?: boolean;
33
+ compact?: boolean;
34
+ href?: string;
35
+ external?: boolean;
36
+ }
37
+ declare const Button: ({ children, look, icon: Icon, busy, wide, compact, href, external, className, disabled, ...props }: ButtonProps) => import("react").JSX.Element;
38
+ declare const Chip: ({ children, label, title, tone, size }: {
39
+ children: ReactNode;
40
+ label?: string;
41
+ title?: string;
42
+ tone?: "success" | "warning" | "danger" | "accent";
43
+ size?: "capsule" | "row";
44
+ }) => import("react").JSX.Element;
45
+ interface DialogProps {
46
+ open: boolean;
47
+ onClose: () => void;
48
+ size?: 'compact' | 'wide';
49
+ title?: ReactNode;
50
+ onBack?: () => void;
51
+ labelledBy?: string;
52
+ describedBy?: string;
53
+ intro?: ReactNode;
54
+ className?: string;
55
+ children: ReactNode;
56
+ }
57
+ declare const Dialog: ({ open, onClose, size, title, onBack, labelledBy, describedBy, intro, className, children }: DialogProps) => import("react").JSX.Element;
58
+ declare const DialogActions: ({ children }: {
59
+ children: ReactNode;
60
+ }) => import("react").JSX.Element;
61
+ interface EmptyStateProps {
62
+ icon: ReactNode;
63
+ title: string;
64
+ children?: ReactNode;
65
+ actions?: ReactNode;
66
+ tone?: 'default' | 'danger';
67
+ compact?: boolean;
68
+ }
69
+ declare const EmptyState: ({ icon, title, children, actions, tone, compact }: EmptyStateProps) => import("react").JSX.Element;
70
+ type SVGAttributes = Partial<SVGProps<SVGSVGElement>>;
71
+ type ElementAttributes = RefAttributes<SVGSVGElement> & SVGAttributes;
72
+ interface LucideProps extends ElementAttributes {
73
+ size?: string | number;
74
+ /**
75
+ * @deprecated Use `nonScalingStroke` instead.
76
+ */
77
+ absoluteStrokeWidth?: boolean;
78
+ nonScalingStroke?: boolean;
79
+ }
80
+ type LucideIcon = ForwardRefExoticComponent<Omit<LucideProps, 'ref'> & RefAttributes<SVGSVGElement>>;
81
+ interface FilterChipProps {
82
+ selected: boolean;
83
+ onClick?: () => void;
84
+ href?: string;
85
+ count?: number;
86
+ icon?: LucideIcon;
87
+ disabled?: boolean;
88
+ children: ReactNode;
89
+ }
90
+ declare const FilterChip: ({ selected, onClick, href, count, icon: Icon, disabled, children }: FilterChipProps) => import("react").JSX.Element;
91
+ type RoundVariant = 'default' | 'on' | 'glass' | 'image' | 'ai' | 'danger' | 'primary';
92
+ interface RoundButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
93
+ icon: LucideIcon;
94
+ label: string;
95
+ variant?: RoundVariant;
96
+ size?: 28 | 34 | 44;
97
+ filled?: boolean;
98
+ busy?: boolean;
99
+ href?: string;
100
+ external?: boolean;
101
+ children?: ReactNode;
102
+ }
103
+ declare const RoundButton: ({ icon: Icon, label, variant, size, filled, busy, href, external, className, ...props }: RoundButtonProps) => import("react").JSX.Element;
104
+ interface SourceAvatarProps {
105
+ sourceId: string;
106
+ sourceName: string;
107
+ size?: 18 | 20 | 22 | 26 | 44;
108
+ }
109
+ declare const SourceAvatar: ({ sourceId, sourceName, size }: SourceAvatarProps) => import("react").JSX.Element;
110
+ interface StatusLineProps {
111
+ tone: 'info' | 'error';
112
+ children: string;
113
+ className?: string;
114
+ }
115
+ declare const StatusLine: ({ tone, children, className }: StatusLineProps) => import("react").JSX.Element;
116
+ interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
117
+ icon: LucideIcon;
118
+ 'aria-label': string;
119
+ onClear?: () => void;
120
+ }
121
+ declare const TextField: ({ icon: Icon, type, onClear, ...props }: TextFieldProps) => import("react").JSX.Element;
122
+ interface ToggleProps {
123
+ checked: boolean;
124
+ label: string;
125
+ disabled?: boolean;
126
+ onChange: (checked: boolean) => void;
127
+ }
128
+ declare const Toggle: ({ checked, label, disabled, onChange }: ToggleProps) => import("react").JSX.Element;
129
+ interface SectionPageProps {
130
+ title: string;
131
+ intro?: ReactNode;
132
+ aside?: ReactNode;
133
+ layout: 'desktop' | 'phone';
134
+ children: ReactNode;
135
+ }
136
+ declare const SectionPage: ({ title, intro, aside, layout, children }: SectionPageProps) => import("react").JSX.Element;
137
+ interface ReaderButtonProps {
138
+ children: ReactNode;
139
+ icon?: ReactNode;
140
+ onClick: () => void;
141
+ done?: boolean;
142
+ label?: string;
143
+ }
144
+ declare const ReaderButton: ({ children, icon, onClick, done, label }: ReaderButtonProps) => import("react").JSX.Element;
145
+ interface SettingRowProps {
146
+ label: ReactNode;
147
+ meta?: ReactNode;
148
+ hint?: ReactNode;
149
+ lead?: ReactNode;
150
+ as?: 'div' | 'li';
151
+ className?: string;
152
+ children?: ReactNode;
153
+ }
154
+ declare const SettingRow: ({ label, meta, hint, lead, as: Tag, className, children }: SettingRowProps) => import("react").JSX.Element;
155
+ declare const SettingsCard: ({ label, children }: {
156
+ label?: string;
157
+ children: ReactNode;
158
+ }) => import("react").JSX.Element;
159
+ interface Confirmation {
160
+ title: string;
161
+ message: ReactNode;
162
+ label: string;
163
+ onConfirm: () => void;
164
+ }
165
+ export declare const askToConfirm: (confirmation: Confirmation) => void;
166
+ interface ToastAction {
167
+ label: string;
168
+ onClick: () => void;
169
+ }
170
+ export declare const showDone: (message: string, undo?: () => void) => undefined;
171
+ export declare const showSaved: (message?: string) => undefined;
172
+ export declare const showError: (message: string, action?: ToastAction, id?: string) => undefined;
173
+ export declare const shortAgo: (iso: string | null, now?: number) => string | null;
174
+ interface PluginLinks {
175
+ frontPage: string;
176
+ article: (id: string) => string;
177
+ settings: string;
178
+ plugins: string;
179
+ page: (path?: string) => string;
180
+ }
181
+ export declare const usePluginLinks: () => PluginLinks;
182
+ export declare const navigate: (href: string) => void;
183
+ export declare const usePluginPath: () => string;
184
+ export declare const definePlugin: (definition: WebPlugin) => void;
185
+ export declare const usePluginTranslation: () => {
186
+ t: (key: string, options?: Record<string, unknown>) => string;
187
+ language: string;
188
+ };
189
+ export declare const usePluginApi: () => (path: string, init?: RequestInit) => Promise<Response>;
190
+ export { Button, Chip, Dialog, DialogActions, EmptyState, FilterChip, type PluginLinks, type ReaderArticle, ReaderButton, RoundButton, SectionPage, SettingRow, SettingsCard, SourceAvatar, 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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.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/leclaudeur/hodi.git",
9
+ "directory": "plugin-sdk"
10
+ },
11
+ "homepage": "https://github.com/leclaudeur/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
+ }