@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 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 [`examples/hello-world`](../examples/hello-world).
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/Anthodpnt/hodi-plugin-template), which depends on this package,
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 `examples/hello-world` is: `tsconfig.plugins.json` maps `hodi:sdk` to its source, and
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` and `ctx.dataDir` (a folder of its own, kept in backups).
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
- `hodi:sdk` (`web/src/plugins/sdk.ts`) has Hodi's components (`Button`, `Dialog`, `SettingRow`, `Toggle`, …), its
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
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as buildPlugin } from "./build-Bbd9DMak.mjs";
2
+ import { t as buildPlugin } from "./build-KyLGWV-_.mjs";
3
3
  import { readFileSync } from "node:fs";
4
4
  import path from "node:path";
5
5
  // cli.ts
@@ -135,6 +135,7 @@ const buildPlugin = async (source, { out = path.join(source, "dist"), key } = {}
135
135
  if (server) await build({
136
136
  configFile: false,
137
137
  logLevel: "warn",
138
+ ssr: { noExternal: true },
138
139
  build: {
139
140
  ssr: server,
140
141
  outDir: stage,
package/dist/build.mjs CHANGED
@@ -1 +1 @@
1
- export { t as buildPlugin } from "./build-Bbd9DMak.mjs";
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.1.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/Anthodpnt/hodi.git",
8
+ "url": "git+https://github.com/leclaudeur/hodi.git",
9
9
  "directory": "plugin-sdk"
10
10
  },
11
- "homepage": "https://github.com/Anthodpnt/hodi/tree/main/plugin-sdk#readme",
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"