@hodi-app/plugin-sdk 0.1.0 → 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 +58 -5
- package/dist/bin.mjs +1 -1
- package/dist/{build-Bbd9DMak.mjs → build-KyLGWV-_.mjs} +1 -0
- package/dist/build.mjs +1 -1
- package/dist/server.d.mts +25 -1
- package/dist/web.d.mts +57 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
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
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
|
|
5
|
+
Réglages › Plugins with a `.hodi-plugin` file. Start from
|
|
6
|
+
[`hodi-plugin-template`](https://github.com/leclaudeur/hodi-plugin-template).
|
|
6
7
|
|
|
7
8
|
## A plugin's folder
|
|
8
9
|
|
|
@@ -18,7 +19,7 @@ my-plugin/
|
|
|
18
19
|
## Two ways to write one
|
|
19
20
|
|
|
20
21
|
**In its own repository** (the usual way): start from
|
|
21
|
-
[`hodi-plugin-template`](https://github.com/
|
|
22
|
+
[`hodi-plugin-template`](https://github.com/leclaudeur/hodi-plugin-template), which depends on this package,
|
|
22
23
|
`@hodi-app/plugin-sdk`:
|
|
23
24
|
|
|
24
25
|
```bash
|
|
@@ -29,7 +30,7 @@ npx hodi-plugin build # the current folder into dist/<id>-
|
|
|
29
30
|
Its `tsconfig.json` lists `"@hodi-app/plugin-sdk/hodi-sdk"` in `"types"`, so `import … from 'hodi:sdk'` is typed, and the
|
|
30
31
|
server part takes its types from `@hodi-app/plugin-sdk/server`.
|
|
31
32
|
|
|
32
|
-
**Inside a clone of Hodi**, as `
|
|
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
|
|
33
34
|
|
|
34
35
|
```bash
|
|
35
36
|
npm run plugin:build -- path/to/my-plugin
|
|
@@ -75,8 +76,20 @@ A package is unsigned unless a key is given: `hodi-plugin build --key private.pe
|
|
|
75
76
|
- `routes(router, ctx)`: an Express router served at `/api/plugins/<id>/`, to the signed-in profiles the plugin is
|
|
76
77
|
for, while it is on.
|
|
77
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
|
+
|
|
78
82
|
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
|
|
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.
|
|
80
93
|
|
|
81
94
|
## The web part
|
|
82
95
|
|
|
@@ -94,10 +107,50 @@ const Page = () => {
|
|
|
94
107
|
definePlugin({ settingsPage: Page })
|
|
95
108
|
```
|
|
96
109
|
|
|
97
|
-
`
|
|
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
|
|
98
125
|
confirmations and toasts, and hooks scoped to the plugin. Use Hodi's CSS variables for colours, and prefix your class
|
|
99
126
|
names with the plugin's id: its styles share the page with Hodi's.
|
|
100
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
|
+
|
|
101
154
|
Hodi's rules hold for plugins too: no AI call without a click, every text in the locales, French says « tu ».
|
|
102
155
|
|
|
103
156
|
## The package itself
|
package/dist/bin.mjs
CHANGED
package/dist/build.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { t as buildPlugin } from "./build-
|
|
1
|
+
export { t as buildPlugin } from "./build-KyLGWV-_.mjs";
|
package/dist/server.d.mts
CHANGED
|
@@ -9,6 +9,28 @@ type FetchText = (url: string, options?: {
|
|
|
9
9
|
userAgent?: string;
|
|
10
10
|
headers?: Record<string, string>;
|
|
11
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
|
+
}
|
|
12
34
|
declare const CAPABILITIES: readonly ["profile-kind:child", "shell:child", "speech"];
|
|
13
35
|
type Capability = (typeof CAPABILITIES)[number];
|
|
14
36
|
declare const AUDIENCES: readonly ["admin", "all"];
|
|
@@ -59,9 +81,11 @@ interface PluginContext {
|
|
|
59
81
|
fetchText: FetchText;
|
|
60
82
|
log: (message: string) => void;
|
|
61
83
|
dataDir: string;
|
|
84
|
+
articles: PluginArticles;
|
|
62
85
|
}
|
|
63
86
|
interface ServerPlugin {
|
|
64
87
|
migrations?: readonly string[];
|
|
65
88
|
routes?: (router: Router, context: PluginContext) => void;
|
|
89
|
+
onArticle?: (article: PluginArticle, context: PluginContext) => void;
|
|
66
90
|
}
|
|
67
|
-
export type { Manifest, PluginContext, ServerPlugin, SettingSpec, SettingValues };
|
|
91
|
+
export type { Manifest, PluginArticle, PluginArticles, PluginContext, ServerPlugin, SettingSpec, SettingValues };
|
package/dist/web.d.mts
CHANGED
|
@@ -1,6 +1,23 @@
|
|
|
1
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
|
+
}
|
|
2
9
|
interface WebPlugin {
|
|
10
|
+
readerAction?: ComponentType<{
|
|
11
|
+
article: ReaderArticle;
|
|
12
|
+
}>;
|
|
3
13
|
settingsPage?: ComponentType;
|
|
14
|
+
page?: ComponentType<{
|
|
15
|
+
layout: 'desktop' | 'phone';
|
|
16
|
+
}>;
|
|
17
|
+
menuIcon?: ComponentType<{
|
|
18
|
+
size?: number;
|
|
19
|
+
}>;
|
|
20
|
+
menuHint?: ComponentType;
|
|
4
21
|
}
|
|
5
22
|
export declare const usePluginCapability: (capability: string) => boolean;
|
|
6
23
|
type ButtonLook = 'neutral' | 'primary' | 'danger' | 'ai' | 'glass';
|
|
@@ -61,6 +78,16 @@ interface LucideProps extends ElementAttributes {
|
|
|
61
78
|
nonScalingStroke?: boolean;
|
|
62
79
|
}
|
|
63
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;
|
|
64
91
|
type RoundVariant = 'default' | 'on' | 'glass' | 'image' | 'ai' | 'danger' | 'primary';
|
|
65
92
|
interface RoundButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
66
93
|
icon: LucideIcon;
|
|
@@ -74,6 +101,12 @@ interface RoundButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
|
|
74
101
|
children?: ReactNode;
|
|
75
102
|
}
|
|
76
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;
|
|
77
110
|
interface StatusLineProps {
|
|
78
111
|
tone: 'info' | 'error';
|
|
79
112
|
children: string;
|
|
@@ -101,6 +134,14 @@ interface SectionPageProps {
|
|
|
101
134
|
children: ReactNode;
|
|
102
135
|
}
|
|
103
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;
|
|
104
145
|
interface SettingRowProps {
|
|
105
146
|
label: ReactNode;
|
|
106
147
|
meta?: ReactNode;
|
|
@@ -111,6 +152,10 @@ interface SettingRowProps {
|
|
|
111
152
|
children?: ReactNode;
|
|
112
153
|
}
|
|
113
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;
|
|
114
159
|
interface Confirmation {
|
|
115
160
|
title: string;
|
|
116
161
|
message: ReactNode;
|
|
@@ -125,10 +170,21 @@ interface ToastAction {
|
|
|
125
170
|
export declare const showDone: (message: string, undo?: () => void) => undefined;
|
|
126
171
|
export declare const showSaved: (message?: string) => undefined;
|
|
127
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;
|
|
128
184
|
export declare const definePlugin: (definition: WebPlugin) => void;
|
|
129
185
|
export declare const usePluginTranslation: () => {
|
|
130
186
|
t: (key: string, options?: Record<string, unknown>) => string;
|
|
131
187
|
language: string;
|
|
132
188
|
};
|
|
133
189
|
export declare const usePluginApi: () => (path: string, init?: RequestInit) => Promise<Response>;
|
|
134
|
-
export { Button, Chip, Dialog, DialogActions, EmptyState, RoundButton, SectionPage, SettingRow, StatusLine, TextField, Toggle };
|
|
190
|
+
export { Button, Chip, Dialog, DialogActions, EmptyState, FilterChip, type PluginLinks, type ReaderArticle, ReaderButton, RoundButton, SectionPage, SettingRow, SettingsCard, SourceAvatar, StatusLine, TextField, Toggle };
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hodi-app/plugin-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Build and type plugins for Hodi, the self-hosted news reader for a household.",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
|
-
"url": "git+https://github.com/
|
|
8
|
+
"url": "git+https://github.com/leclaudeur/hodi.git",
|
|
9
9
|
"directory": "plugin-sdk"
|
|
10
10
|
},
|
|
11
|
-
"homepage": "https://github.com/
|
|
11
|
+
"homepage": "https://github.com/leclaudeur/hodi/tree/main/plugin-sdk#readme",
|
|
12
12
|
"type": "module",
|
|
13
13
|
"bin": {
|
|
14
14
|
"hodi-plugin": "dist/bin.mjs"
|