From e4b505564c167e6e57fc42bd49b05d2197aa8a91 Mon Sep 17 00:00:00 2001 From: rays Date: Tue, 13 Oct 2026 14:03:01 +0000 Subject: [PATCH] Docs: a page each, and API references for ipx and the directory laid out like docs.rs (#223) site/gen.mjs writes them from site/api.mjs, which holds each endpoint and type as the handlers have them, and site/docs.mjs, the guides. A misspelt type in api.mjs fails the build rather than leaving a name that links nowhere. Co-Authored-By: Claude Opus 5.5 --- site/api.mjs | 655 +++++++++++++++++++++++++++++++++++++ site/build.sh | 4 +- site/docs.html | 218 ------------ site/docs.mjs | 24 ++ site/gen.mjs | 212 ++++++++++++ site/guides/cli.html | 22 ++ site/guides/directory.html | 32 ++ site/guides/install.html | 67 ++++ site/index.html | 2 +- site/site.css | 75 ++++- 10 files changed, 1081 insertions(+), 230 deletions(-) create mode 100644 site/api.mjs delete mode 100644 site/docs.html create mode 100644 site/docs.mjs create mode 100644 site/gen.mjs create mode 100644 site/guides/cli.html create mode 100644 site/guides/directory.html create mode 100644 site/guides/install.html diff --git a/site/api.mjs b/site/api.mjs new file mode 100644 index 0000000..62b695a --- /dev/null +++ b/site/api.mjs @@ -0,0 +1,655 @@ +// The API reference's content: each service's endpoints, grouped, and the types they take and +// give, as the handlers in crates/ipx/src/web.rs and crates/directory/src/lib.rs have them. A +// change to a route or a field there wants its line here; gen.mjs turns this into pages. +// Types are written as Rust writes them, the way docs.rs shows a signature; a name in TYPES links +// to its page. + +const U = 'http://:8099'; +const D = 'https://directory.ipxreader.app'; +const ipxCurl = (m, p, body) => + `curl${m === 'GET' ? '' : ` -X ${m}`} -H "Authorization: Bearer ipx_…"${body ? ` \\\n -H "Content-Type: application/json" -d '${body}'` : ''} \\\n "${U}${p}"`; +const dirCurl = (m, p, body) => + `curl${m === 'GET' ? '' : ` -X ${m}`} -H "Authorization: Bearer "${body ? ` \\\n -H "Content-Type: application/json" -d '${body}'` : ''} \\\n "${D}${p}"`; + +const NOT_SUBSCRIBED = ['404 Not Found', 'You do not subscribe to the feed, or it does not exist. A feed someone else reads is not yours to see.']; +const NOT_ADMIN = ['403 Forbidden', 'You are not an admin.']; +const NO_DIRECTORY = ['503 Service Unavailable', 'No directory is set up on this server.']; + +export const ipx = { + name: 'ipx', + root: '/docs/api/ipx', + changelog: 'crates/ipx/CHANGELOG.md', + intro: `

The HTTP API of an iPX server: everything the page does, a script can do. Every request + and answer is JSON unless an item says otherwise. The page is served by ipx-web, which passes + /api and /media on to ipx at the same address, so a script talks to + the address people open in a browser.

+

Signing in

+

Send an API token, made in Settings under API tokens or with POST /api/tokens:

+
Authorization: Bearer ipx_…
+

A token acts as the person who made it, and is an admin only if they are. A browser signs in + with POST /api/login instead and is + given a cookie. Behind Cloudflare Access, Access stops a request with no Access sign-in before + iPX sees it: send an Access service token's CF-Access-Client-Id and + CF-Access-Client-Secret beside the API token.

+

Errors

+

A request that fails answers with its status and a sentence saying why, as plain text: + 401 not signed in, 403 an admin's to do, 404 not there or + not yours, 400 a request that cannot be done as asked, 409 a conflict + the answer explains.

+

Ids

+

A feed's id is a short name made from its title, such as 99-invisible, and + appears in the page's address when the feed is open. An episode is its feed's id and its + guid, the guid URL-encoded in a path. A file (an enclosure) has a number. Times are + Unix seconds.

`, + groups: [ + { + id: 'auth', name: 'auth', title: 'Signing in and tokens', + summary: 'Who you are, signing in and out, and API tokens.', + items: [ + { + method: 'POST', path: '/api/login', slug: 'post-login', access: 'none', + summary: 'Sign in with a name and password, for a session cookie.', + doc: '

Answers who signed in and sets the session cookie, which lasts session_days from the last request. A script is better off with an API token, which needs no password kept.

', + body: 'Credentials', returns: 'Me', + errors: [['401 Unauthorized', 'The name or password is wrong.']], + example: `curl -c cookies -H "Content-Type: application/json" \\\n -d '{"name":"sam","password":"…"}' "${U}/api/login"`, + }, + { + method: 'POST', path: '/api/logout', slug: 'post-logout', + summary: 'Sign out: end the session and clear its cookies.', + returns: '204 No Content', + }, + { + method: 'GET', path: '/api/me', slug: 'get-me', + summary: 'Who you are, your words that hide items, and the server’s version.', + returns: 'Me', + example: ipxCurl('GET', '/api/me'), + }, + { + method: 'PATCH', path: '/api/me', slug: 'patch-me', + summary: 'Change the words that hide an item in every feed you read.', + body: 'MePatch', returns: '204 No Content', + errors: [['400 Bad Request', 'More than 500 words, or one over 200 characters.']], + example: ipxCurl('PATCH', '/api/me', '{"blocked":["sponsor","ad read"]}'), + }, + { + method: 'GET', path: '/api/tokens', slug: 'get-tokens', + summary: 'Your API tokens: names and when each was made and last used, never the token.', + returns: 'Vec', + }, + { + method: 'POST', path: '/api/tokens', slug: 'post-tokens', + summary: 'Make an API token that acts as you.', + doc: '

The answer is the only time the token is shown: only its SHA-256 is kept.

', + body: 'NewToken', returns: 'NewTokenMade', + errors: [['400 Bad Request', 'No name.']], + example: ipxCurl('POST', '/api/tokens', '{"name":"my script"}'), + }, + { + method: 'DELETE', path: '/api/tokens/{id}', slug: 'delete-token', + summary: 'Revoke one of your API tokens.', + pathParams: [['id', 'i64', 'The token’s id, from GET /api/tokens.']], + returns: '204 No Content', + errors: [['404 Not Found', 'No token of yours has that id.']], + }, + ], + }, + { + id: 'feeds', name: 'feeds', title: 'Feeds', + summary: 'Your subscriptions: list, subscribe, change, unsubscribe, check, OPML.', + items: [ + { + method: 'GET', path: '/api/feeds', slug: 'get-feeds', + summary: 'Your subscriptions, each with its counts and your settings for it.', + returns: 'Vec', + example: ipxCurl('GET', '/api/feeds'), + }, + { + method: 'POST', path: '/api/feeds', slug: 'post-feeds', + summary: 'Subscribe to a feed, by its address or a site that links one.', + doc: '

A feed someone else on the server already has costs nothing to add: it is fetched once and each file is kept once. An OPML address subscribes to the list, kept in step as a folder.

', + body: 'NewFeed', returns: 'Added', + errors: [['400 Bad Request', 'The address is not a feed and links none; the answer says what was found.']], + example: ipxCurl('POST', '/api/feeds', '{"url":"https://feeds.99percentinvisible.org/99percentinvisible"}'), + }, + { + method: 'PATCH', path: '/api/feeds/{id}', slug: 'patch-feed', + summary: 'Change your settings for a feed, or (an admin) the feed’s own.', + doc: '

Send only the fields to change. Which episodes you want, whether to download them, how many at a time, DeadAir and pinning are yours, and change for nobody else. The feed’s address, folder, schedule and category, and marking it shareable again, are the same for everyone and an admin’s to change.

', + pathParams: [['id', 'String', 'The feed’s id.']], + body: 'FeedPatch', returns: '204 No Content', + errors: [['400 Bad Request', 'A value out of range, such as a DeadAir pause that is not 200, 300, 400 or 500.'], ['403 Forbidden', 'A field only an admin may change.'], NOT_SUBSCRIBED], + example: ipxCurl('PATCH', '/api/feeds/99-invisible', '{"keywords":["mini-stories"],"pinned":true}'), + }, + { + method: 'DELETE', path: '/api/feeds/{id}', slug: 'delete-feed', + summary: 'Unsubscribe. The files stay for anyone else who reads the feed.', + pathParams: [['id', 'String', 'The feed’s id.']], + returns: '204 No Content', errors: [NOT_SUBSCRIBED], + }, + { + method: 'POST', path: '/api/feeds/{id}/read-all', slug: 'post-read-all', + summary: 'Mark every episode of a feed read, and of every feed in it for an OPML list.', + pathParams: [['id', 'String', 'The feed’s id.']], + returns: 'Marked', + }, + { + method: 'POST', path: '/api/feeds/{id}/download-latest', slug: 'post-download-latest', + summary: 'Download a feed’s newest episodes that are not downloaded yet.', + pathParams: [['id', 'String', 'The feed’s id.']], + body: 'HowMany', returns: 'Queued', errors: [NOT_SUBSCRIBED], + example: ipxCurl('POST', '/api/feeds/99-invisible/download-latest', '{"count":3}'), + }, + { + method: 'POST', path: '/api/fetch', slug: 'post-fetch', + summary: 'Check one feed, or all of yours, for new episodes now.', + doc: '

The check runs in the background; GET /api/events shows it as it goes.

', + body: 'FetchBody', returns: '202 Accepted', + example: ipxCurl('POST', '/api/fetch', '{"feed":"99-invisible","force":true}'), + }, + { + method: 'GET', path: '/api/opml', slug: 'get-opml', + summary: 'Your subscriptions as an OPML file, for another podcast app.', + returns: 'OPML (text/x-opml)', + }, + { + method: 'POST', path: '/api/opml', slug: 'post-opml', + summary: 'Subscribe to every feed in an OPML file.', + body: 'OpmlBody', returns: 'Imported', + errors: [['400 Bad Request', 'Not OPML.']], + }, + ], + }, + { + id: 'entries', name: 'entries', title: 'Episodes', + summary: 'Episodes and posts: list, search, mark read or kept, save where you are.', + items: [ + { + method: 'GET', path: '/api/feeds/{id}/entries', slug: 'get-feed-entries', + summary: 'A page of one feed’s episodes, newest first unless sorted.', + pathParams: [['id', 'String', 'The feed’s id.']], + query: 'Page', returns: 'EntryPage', errors: [NOT_SUBSCRIBED], + example: ipxCurl('GET', '/api/feeds/99-invisible/entries?filter=unread&limit=10'), + }, + { + method: 'GET', path: '/api/entries', slug: 'get-entries', + summary: 'The same, across every feed you subscribe to.', + query: 'Page', returns: 'EntryPage', + example: ipxCurl('GET', '/api/entries?q=bridges&sort=published&dir=desc'), + }, + { + method: 'GET', path: '/api/entries/{feed}/{guid}', slug: 'get-entry', + summary: 'One episode.', + pathParams: [['feed', 'String', 'The feed’s id.'], ['guid', 'String', 'The episode’s guid, URL-encoded.']], + returns: 'EntryRow', errors: [NOT_SUBSCRIBED], + }, + { + method: 'POST', path: '/api/entries/{feed}/{guid}/flags', slug: 'post-flags', + summary: 'Mark an episode read or unread, kept or not.', + doc: '

Answers with the feed’s row, its counts changed, so a list can redraw one line.

', + pathParams: [['feed', 'String', 'The feed’s id.'], ['guid', 'String', 'The episode’s guid, URL-encoded.']], + body: 'Flags', returns: 'Option', errors: [NOT_SUBSCRIBED], + example: ipxCurl('POST', '/api/entries/99-invisible/{guid}/flags', '{"read":true}'), + }, + { + method: 'POST', path: '/api/entries/{feed}/{guid}/position', slug: 'post-position', + summary: 'Save where you are in an episode, for every device you listen on.', + pathParams: [['feed', 'String', 'The feed’s id.'], ['guid', 'String', 'The episode’s guid, URL-encoded.']], + body: 'Position', returns: '204 No Content', errors: [NOT_SUBSCRIBED], + example: ipxCurl('POST', '/api/entries/99-invisible/{guid}/position', '{"secs":1234}'), + }, + { + method: 'POST', path: '/api/read-all', slug: 'post-read-all-mine', + summary: 'Mark every episode of every feed you subscribe to read.', + returns: 'Marked', + }, + ], + }, + { + id: 'files', name: 'files', title: 'Files', + summary: 'Downloading, deleting and playing an episode’s file.', + items: [ + { + method: 'POST', path: '/api/enclosures/{id}/download', slug: 'post-download', + summary: 'Download a file now.', + pathParams: [['id', 'i64', 'The file’s id, an EncRow’s id.']], + returns: '202 Accepted, or 204 No Content when it is already here', errors: [NOT_SUBSCRIBED], + }, + { + method: 'DELETE', path: '/api/enclosures/{id}', slug: 'delete-file', + summary: 'Delete a downloaded file. It is not downloaded again.', + doc: '

There is one copy of each file, so deleting it deletes it for everyone. If someone else has kept the episode or not played it yet, the first try is refused with who: send it again with ?force=true to delete it anyway.

', + pathParams: [['id', 'i64', 'The file’s id.']], + query: 'Force', returns: '204 No Content', + errors: [['409 Conflict', 'Someone else still wants it; the answer says who. Send force=true to go ahead.'], NOT_SUBSCRIBED], + }, + { + method: 'GET', path: '/media/{id}', slug: 'get-media', + summary: 'The file itself, with Range requests, so a player can seek.', + pathParams: [['id', 'i64', 'The file’s id.']], + returns: 'The file, with its own Content-Type', errors: [NOT_SUBSCRIBED], + }, + { + method: 'GET', path: '/media/{id}/deadair.m3u8', slug: 'get-deadair', + summary: 'The episode with its long silences shortened by DeadAir, as an HLS playlist.', + doc: '

Only once DeadAir has looked at the file: an EncRow with deadair set says how many seconds it saves. /media/{id}/deadair gives the shortened episode as one file to save.

', + pathParams: [['id', 'i64', 'The file’s id.']], + returns: 'An HLS playlist (application/vnd.apple.mpegurl)', errors: [NOT_SUBSCRIBED], + }, + ], + }, + { + id: 'directory', name: 'directory', title: 'The directory, through iPX', + summary: 'Browse the directory and subscribe from it; iPX asks it on your behalf.', + items: [ + { + method: 'GET', path: '/api/popular', slug: 'get-popular', + summary: 'The directory’s ten most subscribed feeds.', + returns: 'Vec', errors: [NO_DIRECTORY], + }, + { + method: 'GET', path: '/api/directory', slug: 'get-directory', + summary: 'Every feed the directory lists, A to Z.', + returns: 'Vec', errors: [NO_DIRECTORY], + }, + { + method: 'GET', path: '/api/directory/{id}', slug: 'get-listed', + summary: 'A feed’s page in the directory: what it says it is and its latest items.', + pathParams: [['id', 'String', 'The directory’s id for the feed.']], + returns: 'Listed', errors: [['404 Not Found', 'Not in the directory.'], NO_DIRECTORY], + }, + { + method: 'POST', path: '/api/popular/{id}', slug: 'post-popular', + summary: 'Subscribe to a feed from the directory, by the directory’s id.', + pathParams: [['id', 'String', 'The directory’s id for the feed.']], + returns: 'Subscribed', errors: [['400 Bad Request', 'Not in the directory.'], NO_DIRECTORY], + }, + ], + }, + { + id: 'server', name: 'server', title: 'The server', + summary: 'Its counts and version, and live updates.', + items: [ + { + method: 'GET', path: '/api/status', slug: 'get-status', + summary: 'How many feeds, files waiting and files downloaded, and the version.', + doc: '

The numbers ipx status prints, for a dashboard.

', + returns: 'Status', example: ipxCurl('GET', '/api/status'), + }, + { + method: 'GET', path: '/api/events', slug: 'get-events', + summary: 'A live stream of checks and downloads as they happen (server-sent events).', + doc: '

Each message’s data is one JSON Event. Every event of a check reaches everyone; a feed’s new row (feed_row) only those who subscribe to it.

', + returns: 'text/event-stream of Event', + example: `curl -N -H "Authorization: Bearer ipx_…" "${U}/api/events"\ndata: {"ev":"feed_start","feed":"99-invisible"}\ndata: {"ev":"feed_done","feed":"99-invisible","new":1,"downloaded":1,"failed":0,"torrents":0}`, + }, + ], + }, + { + id: 'admin', name: 'admin', title: 'Admin', + summary: 'Server settings, accounts, the log and the directory’s administration. Admins only.', + items: [ + { + method: 'GET', path: '/api/settings', slug: 'get-settings', access: 'signed in', + summary: 'The server’s settings. Anyone signed in may read them.', + returns: 'Settings', + }, + { + method: 'PATCH', path: '/api/settings', slug: 'patch-settings', access: 'admin', + summary: 'Change the server’s settings. They take effect at once.', + body: 'SettingsPatch', returns: '204 No Content', + errors: [['400 Bad Request', 'A value that does not parse, such as a schedule.'], NOT_ADMIN], + example: ipxCurl('PATCH', '/api/settings', '{"schedule":"every 2h","max_total_gb":200}'), + }, + { + method: 'GET', path: '/api/users', slug: 'get-users', access: 'admin', + summary: 'Every account.', returns: 'Vec', errors: [NOT_ADMIN], + }, + { + method: 'POST', path: '/api/users', slug: 'post-users', access: 'admin', + summary: 'Add an account.', + doc: '

With no password, the account signs in only through single sign-on.

', + body: 'NewUser', returns: '201 Created', + errors: [['400 Bad Request', 'No name, or one that exists already.'], NOT_ADMIN], + example: ipxCurl('POST', '/api/users', '{"name":"sam","password":"…"}'), + }, + { + method: 'PATCH', path: '/api/users/{id}', slug: 'patch-user', access: 'admin', + summary: 'Make an account an admin, or not.', + pathParams: [['id', 'i64', 'The account’s id.']], + body: 'UserPatch', returns: '204 No Content', + errors: [['400 Bad Request', 'No such account, or the only admin, who cannot stop being one.'], NOT_ADMIN], + }, + { + method: 'DELETE', path: '/api/users/{id}', slug: 'delete-user', access: 'admin', + summary: 'Remove an account, with its subscriptions and read state. Files stay.', + pathParams: [['id', 'i64', 'The account’s id.']], + returns: '204 No Content', + errors: [['400 Bad Request', 'No such account, or the only admin, who cannot be removed.'], NOT_ADMIN], + }, + { + method: 'GET', path: '/api/logs', slug: 'get-logs', access: 'admin', + summary: 'The newest lines of the server’s log, or those after a given one.', + query: 'LogQuery', returns: 'LogPage', errors: [NOT_ADMIN], + }, + { + method: 'GET', path: '/api/directory-status', slug: 'get-directory-status', access: 'admin', + summary: 'Who the directory takes this server’s token to be, and how the last share went.', + returns: 'DirectoryStatus', errors: [NOT_ADMIN], + }, + { + method: 'DELETE', path: '/api/directory/{id}', slug: 'delete-listed', access: 'admin', + summary: 'Remove a feed from the directory and keep it out.', + doc: '

Only when this server’s directory token is rwa; the directory refuses otherwise.

', + pathParams: [['id', 'String', 'The directory’s id for the feed.']], + returns: '204 No Content', errors: [NOT_ADMIN, NO_DIRECTORY], + }, + { + method: 'POST', path: '/api/directory/{id}/refresh', slug: 'post-listed-refresh', access: 'admin', + summary: 'Have the directory check a feed now. Needs an rwa token.', + pathParams: [['id', 'String', 'The directory’s id for the feed.']], + returns: '204 No Content', errors: [NOT_ADMIN, NO_DIRECTORY], + }, + { + method: 'GET', path: '/api/directory-admin', slug: 'get-directory-admin', access: 'admin', + summary: 'The servers sharing with the directory, and the feeds it keeps out. Needs rwa.', + returns: 'DirectoryAdmin', errors: [NOT_ADMIN, NO_DIRECTORY], + }, + { + method: 'DELETE', path: '/api/directory-blocked/{id}', slug: 'delete-directory-blocked', access: 'admin', + summary: 'Let a feed the directory keeps out back in. Needs rwa.', + pathParams: [['id', 'String', 'The directory’s id for the feed.']], + returns: '204 No Content', errors: [NOT_ADMIN, NO_DIRECTORY], + }, + { + method: 'DELETE', path: '/api/directory-instances/{name}', slug: 'delete-directory-instance', access: 'admin', + summary: 'Drop a server’s share from the directory until it next shares. Needs rwa.', + pathParams: [['name', 'String', 'The server’s token name.']], + returns: '204 No Content', errors: [NOT_ADMIN, NO_DIRECTORY], + }, + ], + }, + ], + types: [ + { name: 'Credentials', doc: 'A name and password.', fields: [['name', 'String', ''], ['password', 'String', '']] }, + { name: 'Me', doc: 'Who is signed in.', fields: [ + ['name', 'String', 'The account’s name, often an email address.'], + ['admin', 'bool', ''], + ['blocked', 'Vec', 'Words that hide an item in every feed you read. GET /api/me only, as are the fields below.'], + ['sign_out', 'Option', 'Where signing out should go, for someone single sign-on signed in.'], + ['deadair_saved', 'i64', 'Seconds DeadAir has saved you, all told.'], + ['version', 'String', 'ipx’s version.'], + ] }, + { name: 'MePatch', doc: 'What PATCH /api/me changes.', fields: [['blocked', 'Option>', 'Replaces the list.']] }, + { name: 'Token', doc: 'An API token, as listed.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['created', 'i64', ''], ['last_used', 'Option', '']] }, + { name: 'NewToken', doc: 'A token to make.', fields: [['name', 'String', 'What it is for, to know it by when revoking it.']] }, + { name: 'NewTokenMade', doc: 'A token just made: the only time it is shown.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['token', 'String', 'Begins ipx_. Send it as Authorization: Bearer.']] }, + { name: 'FeedRow', doc: 'A feed as you see it: the feed, your settings for it and its counts for you.', fields: [ + ['id', 'String', ''], ['url', 'String', ''], ['title', 'Option', ''], ['image', 'Option', ''], + ['folder', 'Option', ''], + ['category', 'Option', 'The directory category an admin gave it.'], + ['feed_category', 'Option', 'The one the feed names itself, which wins.'], + ['keywords', 'Vec', 'Your words: with any, only episodes that mention one are downloaded.'], + ['blocked', 'Vec', 'Your words that hide an episode of this feed.'], + ['deadair', 'bool', 'Played with its silences shortened.'], + ['deadair_pause', 'i64', 'The pause DeadAir leaves where it cuts, in milliseconds.'], + ['allow_explicit', 'bool', ''], ['auto_download', 'bool', ''], + ['max_new_per_check', 'Option', 'Your own limit, or none for the server’s.'], + ['group', 'Option', 'The OPML list it came from.'], + ['orphaned', 'bool', 'In a list that no longer has it, kept for its downloads.'], + ['managed', 'bool', ''], + ['schedule', 'Option', 'Its own schedule, such as every 2h.'], + ['schedule_mins', 'Option', 'The same in minutes.'], + ['every_mins', 'u64', 'How often it is checked, all things considered.'], + ['last_checked', 'Option', ''], ['next_check', 'Option', ''], + ['last_error', 'Option', ''], + ['failing', 'Option', 'Set once an error has held for a day.'], + ['entries', 'i64', ''], ['downloaded', 'i64', ''], ['unread', 'i64', ''], + ['subscribers', 'i64', 'People on this server, you included.'], + ['pinned', 'bool', 'At the top of your list.'], + ['private', 'bool', 'Kept out of the directory.'], + ['private_by_address', 'bool', 'Private whatever it is marked: a login, or a key in its address.'], + ] }, + { name: 'FailingRow', doc: 'Why a feed has been failing.', fields: [['reason', 'String', 'In words.'], ['new_url', 'Option', 'Where it seems to have moved.']] }, + { name: 'NewFeed', doc: 'A feed to subscribe to.', fields: [ + ['url', 'String', 'A feed’s address, an OPML list’s, or a site’s page that links its feed.'], + ['folder', 'Option', ''], + ['keywords', 'Vec', 'Download only episodes that mention one. Empty for all.'], + ['allow_explicit', 'bool', 'Download explicit episodes too. False if left out.'], + ] }, + { name: 'Added', doc: 'A feed subscribed to.', fields: [['id', 'String', 'Its id here.'], ['existing', 'bool', 'You subscribed to it already.']] }, + { name: 'FeedPatch', doc: 'What PATCH /api/feeds/{id} changes. Leave out what stays; null clears a field that can be cleared.', fields: [ + ['keywords', 'Option>', 'Yours.'], ['blocked', 'Option>', 'Yours.'], + ['allow_explicit', 'Option', 'Yours.'], ['auto_download', 'Option', 'Yours.'], + ['max_new_per_check', 'Option>', 'Yours; null for the server’s.'], + ['pinned', 'Option', 'Yours.'], + ['deadair', 'Option>', 'Yours; null follows its OPML list’s.'], + ['deadair_pause', 'Option', 'Yours: 200, 300, 400 or 500.'], + ['private', 'Option', 'Anyone subscribed may mark it private; only an admin unmarks it.'], + ['url', 'Option', 'Admin.'], ['schedule', 'Option>', 'Admin.'], + ['folder', 'Option>', 'Admin.'], ['category', 'Option>', 'Admin.'], + ] }, + { name: 'Marked', doc: '', fields: [['marked', 'i64', 'How many episodes were marked.']] }, + { name: 'HowMany', doc: '', fields: [['count', 'i64', 'From 1 to 100; 5 if left out.']] }, + { name: 'Queued', doc: '', fields: [['queued', 'usize', 'How many files were queued.']] }, + { name: 'FetchBody', doc: 'What to check.', fields: [['feed', 'Option', 'One feed; all of yours if left out.'], ['force', 'bool', 'Check even if it is not due.']] }, + { name: 'OpmlBody', doc: '', fields: [['xml', 'String', 'The OPML file’s text.']] }, + { name: 'Imported', doc: '', fields: [['added', 'usize', ''], ['already', 'usize', 'Feeds you had.']] }, + { name: 'Page', doc: 'Which episodes, as query parameters.', fields: [ + ['offset', 'i64', '0 if left out.'], ['limit', 'i64', '50 if left out.'], + ['filter', 'Option', 'unread, downloaded or flagged (kept).'], + ['q', 'Option', 'Words to look for in titles and show notes.'], + ['sort', 'Option', 'published, title, feed, type, size or kept. Newest first if left out.'], + ['dir', 'Option', 'asc or desc.'], + ] }, + { name: 'EntryPage', doc: 'A page of episodes.', fields: [['total', 'i64', 'How many match, over every page.'], ['entries', 'Vec', '']] }, + { name: 'EntryRow', doc: 'An episode or post, with your state for it.', fields: [ + ['guid', 'String', ''], ['feed_id', 'String', ''], ['title', 'Option', ''], ['link', 'Option', ''], + ['published', 'Option', ''], + ['description', 'Option', 'Show notes, HTML, already sanitized.'], + ['read', 'bool', ''], ['flagged', 'bool', 'Kept: its file is never cleared to save space.'], + ['image', 'Option', ''], ['duration', 'Option', 'Seconds.'], + ['episode', 'Option', ''], ['season', 'Option', ''], + ['position', 'i64', 'Where you are in it, in seconds.'], + ['enclosures', 'Vec', 'Its files.'], + ] }, + { name: 'EncRow', doc: 'An episode’s file.', fields: [ + ['id', 'i64', 'What /media/{id} and the file routes take.'], + ['feed_id', 'String', ''], ['guid', 'String', ''], ['url', 'String', 'Where the publisher has it.'], + ['mime', 'Option', ''], ['length', 'Option', 'Bytes.'], + ['path', 'Option', 'Where it is on the server, once downloaded.'], + ['state', 'String', 'pending (a check will download it), held (back catalogue, downloaded on request), downloading, done, skipped (a filter or a failure) or reaped (deleted).'], + ['last_error', 'Option', ''], + ['deadair', 'Option', 'Seconds DeadAir saves, when you play it shortened. Absent otherwise.'], + ['deadair_pending', 'bool', 'DeadAir is still working on it. Absent when false.'], + ] }, + { name: 'Flags', doc: '', fields: [['read', 'Option', ''], ['flagged', 'Option', 'Kept.']] }, + { name: 'Position', doc: '', fields: [['secs', 'i64', 'Seconds in.'], ['duration', 'Option', 'The length the player measured, for an episode whose feed gives none.']] }, + { name: 'Force', doc: 'A query parameter.', fields: [['force', 'bool', 'Delete it even though someone else wants it.']] }, + { name: 'PopularRow', doc: 'A feed in the directory. Never its address: subscribe by its id.', fields: [ + ['id', 'String', 'The directory’s id for it.'], ['title', 'Option', ''], ['image', 'Option', ''], + ['subscribers', 'i64', 'On every server sharing with the directory.'], + ['subscribed', 'bool', 'Yours already.'], ['feed', 'Option', 'Its id here, when it is yours.'], + ['category', 'Option', ''], ['subcategory', 'Option', ''], + ['podcast', 'bool', 'Has audio or video.'], + ] }, + { name: 'Listed', doc: 'A feed’s page in the directory.', fields: [ + ['description', 'Option', 'What it says it is, sanitized.'], + ['failing', 'Option', 'Why its last check failed, when it did.'], + ['items', 'Vec', 'Its latest twenty.'], + ['can_admin', 'bool', 'You may remove or refresh it.'], + ] }, + { name: 'ListedItem', doc: '', fields: [['guid', 'String', ''], ['title', 'Option', ''], ['link', 'Option', ''], ['published', 'Option', ''], ['duration', 'Option', ''], ['description', 'Option', 'Sanitized.']] }, + { name: 'Subscribed', doc: '', fields: [['id', 'String', 'The feed’s id here.']] }, + { name: 'Status', doc: '', fields: [['feeds', 'usize', ''], ['pending', 'usize', 'Files a check will download.'], ['downloaded', 'usize', ''], ['version', 'String', '']] }, + { name: 'Event', doc: 'One live update. ev says which, and the rest depends on it.', fields: [ + ['feed_start', '{ feed }', 'A feed is being checked.'], + ['feed_skip', '{ feed }', 'Not due.'], + ['feed_done', '{ feed, new, downloaded, failed, torrents }', ''], + ['feed_error', '{ feed, msg }', ''], + ['feed_row', '{ row: FeedRow }', 'A feed you subscribe to changed.'], + ['progress', '{ feed, enclosure, file, done, total }', 'Bytes of a download, in whole percents.'], + ['download_done', '{ feed, enclosure, path, bytes }', ''], + ['download_error', '{ feed, enclosure, msg, url }', ''], + ['deadair_done', '{ feed, enclosure, saved_s }', 'DeadAir finished a file.'], + ['scan_done', '{ feeds }', 'A check finished; how many feeds it looked at.'], + ] }, + { name: 'Settings', doc: 'The server’s settings.', fields: [ + ['schedule', 'String', 'How often feeds are checked, such as every 60m.'], ['every_mins', 'u64', 'The same in minutes.'], + ['max_new_per_check', 'usize', 'The newest episodes a check downloads, a feed.'], + ['media_types', 'Vec', 'What is downloaded, such as audio, video.'], + ['download_dir', 'String', ''], ['max_total_gb', 'f64', 'Disk to use; 0 for no limit.'], + ['max_age_days', 'u64', '0 for no limit.'], ['art_cache_mb', 'u64', 'Artwork kept.'], + ['directory_url', 'String', ''], ['directory_token_set', 'bool', 'The token itself is never sent back.'], + ['directory_share', 'bool', ''], + ] }, + { name: 'SettingsPatch', doc: 'What PATCH /api/settings changes. Leave out what stays.', fields: [ + ['schedule', 'Option', ''], ['max_new_per_check', 'Option', ''], ['media_types', 'Option>', ''], + ['max_total_gb', 'Option', ''], ['max_age_days', 'Option', ''], ['art_cache_mb', 'Option', ''], + ['directory_url', 'Option', 'Empty for no directory.'], ['directory_token', 'Option', 'Replaces it; empty clears it.'], + ['directory_share', 'Option', ''], + ] }, + { name: 'UserRow', doc: 'An account.', fields: [['id', 'i64', ''], ['name', 'String', ''], ['admin', 'bool', ''], ['password', 'bool', 'Has one; without, it signs in only by single sign-on.'], ['created', 'Option', ''], ['last_login', 'Option', '']] }, + { name: 'NewUser', doc: '', fields: [['name', 'String', 'No commas, semicolons or line breaks.'], ['password', 'String', 'Empty for single sign-on only.'], ['admin', 'bool', '']] }, + { name: 'UserPatch', doc: '', fields: [['admin', 'bool', '']] }, + { name: 'LogQuery', doc: 'Query parameters.', fields: [['after', 'u64', 'The last seq you have; 0 for the newest lines.'], ['limit', 'usize', '200 if left out.']] }, + { name: 'LogPage', doc: '', fields: [['lines', 'Vec', ''], ['latest', 'u64', 'The newest seq, to ask after next time.']] }, + { name: 'LogLine', doc: '', fields: [['seq', 'u64', ''], ['ts', 'i64', ''], ['level', 'String', ''], ['target', 'String', ''], ['msg', 'String', '']] }, + { name: 'DirectoryStatus', doc: '', fields: [ + ['configured', 'bool', 'An address and token are set.'], ['name', 'Option', 'The token’s name, as the directory knows it.'], + ['access', 'Option', 'ro, rw or rwa.'], ['error', 'Option', 'Why the directory would not say.'], + ['shared', 'Option', 'How the last share went.'], + ] }, + { name: 'DirectoryAdmin', doc: '', fields: [['instances', 'Vec', 'Servers sharing.'], ['blocked', 'Vec', '']] }, + { name: 'BlockedRow', doc: 'A feed the directory keeps out. Never its address.', fields: [['id', 'String', ''], ['title', 'Option', ''], ['at', 'i64', 'When.']] }, + { name: 'Instance', doc: 'A server sharing with the directory.', fields: [['name', 'String', 'Its token’s name.'], ['feeds', 'i64', ''], ['subscribers', 'i64', '']] }, + { name: 'SnapshotResult', doc: 'What the directory made of a share.', fields: [['accepted', 'usize', ''], ['refused_private', 'usize', 'Addresses that look like someone’s paid access.'], ['refused_blocked', 'usize', ''], ['refused_invalid', 'usize', 'Not http(s).']] }, + ], +}; + +export const directory = { + name: 'ipx_directory', + label: 'ipx-directory', + root: '/docs/api/directory', + changelog: 'crates/directory/CHANGELOG.md', + intro: `

The HTTP API of the directory of public podcasts that iPX servers share, at + ${D}/v1. Its pages at ${D.replace('https://', '')} are anyone’s + to browse; the API needs a token for everything.

+

Tokens

+
Authorization: Bearer <token>
+

A token has a name and one of three levels of access: ro reads, + rw also shares, and rwa also removes and blocks feeds and drops a + server’s share. Tokens are given out by whoever runs the directory, and none can be made or + revoked over the API.

+

Errors

+

A request that fails answers with its status and {"error": "…"}: + 401 no token or an unknown one, 403 a token without the access, + 404 not listed.

+

Ids

+

A feed’s id is the first 16 hex digits of the SHA-256 of its address with the + scheme and host in lower case and no default port or fragment, so every server arrives at the + same one. Descriptions are the publisher’s own HTML, unsanitized: sanitize them before you + show them.

`, + groups: [ + { + id: 'feeds', name: 'feeds', title: 'Feeds', + summary: 'What the directory lists.', + items: [ + { + method: 'GET', path: '/v1/me', slug: 'get-me', access: 'ro', + summary: 'The token’s name and access.', returns: 'Me', example: dirCurl('GET', '/v1/me'), + }, + { + method: 'GET', path: '/v1/feeds', slug: 'get-feeds', access: 'ro', + summary: 'Every feed listed, A to Z.', returns: 'Vec', example: dirCurl('GET', '/v1/feeds'), + }, + { + method: 'GET', path: '/v1/popular', slug: 'get-popular', access: 'ro', + summary: 'The most subscribed feeds.', query: 'Limit', returns: 'Vec', + example: dirCurl('GET', '/v1/popular?limit=5'), + }, + { + method: 'GET', path: '/v1/feeds/{id}', slug: 'get-feed', access: 'ro', + summary: 'One feed, with its description and latest twenty items.', + pathParams: [['id', 'String', 'The feed’s id.']], + returns: 'FeedPage', errors: [['404 Not Found', 'Not listed.']], + }, + { + method: 'GET', path: '/healthz', slug: 'get-healthz', access: 'none', + summary: 'ok when the directory can read its database.', returns: 'text/plain', + }, + ], + }, + { + id: 'share', name: 'share', title: 'Sharing', + summary: 'A server telling the directory what its people follow.', + items: [ + { + method: 'POST', path: '/v1/snapshot', slug: 'post-snapshot', access: 'rw', + summary: 'Share: every public feed this server’s people follow, and how many follow each.', + doc: '

Replaces everything this token shared before, so send the whole list each time. At most 20,000 feeds. Addresses that look private (a login, a key in the query, a paid-feed host) are refused and counted, never stored. A feed is listed once the directory has read it as one.

', + body: 'Vec', returns: 'SnapshotResult', + errors: [['413 Payload Too Large', 'More than 20,000 feeds.']], + example: dirCurl('POST', '/v1/snapshot', '[{"url":"https://feeds.99percentinvisible.org/99percentinvisible","subscribers":3}]'), + }, + ], + }, + { + id: 'admin', name: 'admin', title: 'Administering it', + summary: 'Removing and blocking feeds, and the servers sharing. rwa tokens only.', + items: [ + { + method: 'DELETE', path: '/v1/feeds/{id}', slug: 'delete-feed', access: 'rwa', + summary: 'Remove a feed and block it, so no server’s share brings it back.', + pathParams: [['id', 'String', 'The feed’s id.']], returns: '204 No Content', + }, + { + method: 'POST', path: '/v1/feeds/{id}/refresh', slug: 'post-refresh', access: 'rwa', + summary: 'Check a feed now, answering with its page.', + pathParams: [['id', 'String', 'The feed’s id.']], returns: 'FeedPage', + }, + { + method: 'GET', path: '/v1/blocked', slug: 'get-blocked', access: 'rwa', + summary: 'The feeds kept out.', returns: 'Vec', + }, + { + method: 'DELETE', path: '/v1/blocked/{id}', slug: 'delete-blocked', access: 'rwa', + summary: 'Let a blocked feed back in.', + pathParams: [['id', 'String', 'The feed’s id.']], returns: '204 No Content', + }, + { + method: 'GET', path: '/v1/instances', slug: 'get-instances', access: 'rwa', + summary: 'Each token holding a share, with how many feeds and subscribers it gave.', + returns: 'Vec', + }, + { + method: 'DELETE', path: '/v1/instances/{name}', slug: 'delete-instance', access: 'rwa', + summary: 'Drop one token’s share, its counts with it, until it next shares.', + doc: '

To keep a server out for good, revoke its token instead.

', + pathParams: [['name', 'String', 'The token’s name.']], returns: '204 No Content', + }, + ], + }, + ], + types: [ + { name: 'Me', doc: 'A token, as the directory knows it.', fields: [['name', 'String', ''], ['access', 'Access', '']] }, + { name: 'Access', doc: 'A token’s access: "ro", "rw" or "rwa".', fields: [] }, + { name: 'Limit', doc: 'A query parameter.', fields: [['limit', 'Option', 'From 1 to 100; 10 if left out.']] }, + { name: 'Listing', doc: 'A feed listed.', fields: [ + ['id', 'String', ''], ['url', 'String', 'Its address, to subscribe to anywhere.'], ['title', 'Option', ''], ['image', 'Option', ''], + ['category', 'Option', 'Apple’s category, from the feed’s own.'], ['subcategory', 'Option', ''], + ['podcast', 'bool', 'Audio or video in its latest items.'], + ['subscribers', 'i64', 'Summed over every server’s latest share.'], + ] }, + { name: 'FeedPage', doc: 'A feed’s page: every field of Listing, and these.', fields: [ + ['description', 'Option', 'The feed’s own HTML, unsanitized.'], + ['failing', 'Option', 'Why its last check failed, in words.'], + ['items', 'Vec', 'Its latest twenty.'], + ] }, + { name: 'Item', doc: 'An episode or post. Never its file.', fields: [['guid', 'String', ''], ['title', 'Option', ''], ['link', 'Option', ''], ['published', 'Option', 'Unix seconds.'], ['duration', 'Option', 'Seconds.'], ['description', 'Option', 'Unsanitized.']] }, + { name: 'SnapshotEntry', doc: 'One feed in a share.', fields: [['url', 'String', ''], ['subscribers', 'i64', ''], ['category', 'Option', 'A category for a feed that names none itself.']] }, + { name: 'SnapshotResult', doc: 'What the directory made of a share.', fields: [['accepted', 'usize', ''], ['refused_private', 'usize', ''], ['refused_blocked', 'usize', ''], ['refused_invalid', 'usize', 'Not http(s).']] }, + { name: 'Blocked', doc: '', fields: [['id', 'String', ''], ['url', 'Option', ''], ['title', 'Option', ''], ['at', 'i64', 'When.']] }, + { name: 'Instance', doc: '', fields: [['name', 'String', 'The token’s name.'], ['feeds', 'i64', ''], ['subscribers', 'i64', '']] }, + ], +}; diff --git a/site/build.sh b/site/build.sh index 187053a..1d6bd05 100755 --- a/site/build.sh +++ b/site/build.sh @@ -5,8 +5,10 @@ set -e here=$(dirname "$0") rm -rf "$here/dist" mkdir -p "$here/dist/fonts" -cp "$here/index.html" "$here/docs.html" "$here/site.css" "$here/wave.svg" "$here/gitea.svg" "$here/_headers" "$here/dist/" +cp "$here/index.html" "$here/site.css" "$here/wave.svg" "$here/gitea.svg" "$here/_headers" "$here/dist/" # The reader's own typeface, the same file ipx-web serves. cp "$here/../web/InterVariable.woff2" "$here/dist/fonts/inter.woff2" cp "$here/../web/logo.svg" "$here/../web/favicon.png" "$here/dist/" cp -r "$here/shots" "$here/dist/" +# The docs: the guides and the API references, generated (#223). +node "$here/gen.mjs" diff --git a/site/docs.html b/site/docs.html deleted file mode 100644 index c988d31..0000000 --- a/site/docs.html +++ /dev/null @@ -1,218 +0,0 @@ - - - - - -iPX docs - - - - - - - -
- iPX - -
- -
-

Docs

-

iPX is two containers: ipx, which checks your podcasts, downloads them and answers the API, and ipx-web, which serves the page and passes the API on to ipx. The directory is a third, which you only need to run if you want a directory of your own.

- - - -
-

Install ipx and ipx-web

-

You need Docker with Compose. There are no published images yet, so you build both from the source:

-
git clone https://git.sdf1.net/rays/ipx.git
-cd ipx
-docker build -t ipx .
-docker build -f Dockerfile.web -t ipx-web .
-

The repository has a sample docker-compose.yml that runs both, with the database in SQLite and everything ipx keeps in three folders beside the file:

-
services:
-  ipx:
-    image: ipx
-    container_name: ipx
-    restart: unless-stopped
-    environment:
-      PUID: "99"          # who owns the files it writes
-      PGID: "100"
-      TZ: "UTC"
-    ports:
-      - "6881:6881/tcp"   # BitTorrent
-      - "6881:6881/udp"
-    volumes:
-      - ./config:/config  # config.toml, written on first start
-      - ./data:/data      # the database
-      - ./downloads:/downloads
-
-  ipx-web:
-    image: ipx-web
-    container_name: ipx-web
-    restart: unless-stopped
-    environment:
-      IPX_UPSTREAM: "ipx:8099"
-    ports:
-      - "8099:8080"       # the page
-    depends_on:
-      - ipx
-
docker compose up -d
-

ipx has no port of its own: everything reaches it through ipx-web, on port 8099. Open http://<your server>:8099 and sign in as admin with the password ipodderx, then change it:

-
echo -n 'a good password' | docker exec -i ipx ipx user passwd admin
-

The password is read from what you pipe in, so it stays out of your shell's history.

- -

Adding people

-

An admin adds people in Admin, Accounts, or from the command line:

-
echo -n 'their password' | docker exec -i ipx ipx user add sam
-

Each person starts with no podcasts and subscribes to their own. Two people subscribing to the same show cost nothing extra: it is fetched once and each file is kept once.

- -

Settings

-

How often podcasts are checked, how much disk to use, how many new episodes to download and the rest are in Admin on the page, and take effect straight away. /config/config.toml holds only what ipx needs before it can read its database: where downloads go, BitTorrent, and who gets in.

-
- - - - - - - - -
Environment variableWhat it does
IPX_DATABASE_URLA postgres://user:password@host:5432/database address, to keep everything in Postgres instead of SQLite
PUID, PGIDThe user and group that own the files ipx writes
IPX_LOG_FORMATjson logs one JSON object a line, for Loki and the like
OTEL_EXPORTER_OTLP_ENDPOINTSends traces of requests, checks and downloads to an OpenTelemetry collector; set WEB_TRACING=on on ipx-web too
IPX_UPSTREAMOn ipx-web: where ipx is, ipx:8099 by default
- -

Reaching it from outside

-

The page is plain HTTP. If anyone will reach it from outside your network, put HTTPS in front of it: a reverse proxy you already run, or a Cloudflare tunnel. People can then sign in through Cloudflare Access or Authentik instead of a password; single sign-on says how.

- -

Updating

-
git pull
-docker build -t ipx .
-docker build -f Dockerfile.web -t ipx-web .
-docker compose up -d
-

Your podcasts, accounts and downloads are in the three folders and are kept.

-
- -
-

Use ipx from a script

-

Anything the page does, a script can do through the same API.

- -

A token

-

In Settings, under API tokens, give a token a name and press Make. It is shown once, so copy it then. It acts as you: it sees your podcasts and changes them, and it is an admin only if you are. Revoke it in the same place.

-
curl -H "Authorization: Bearer ipx_…" http://<your server>:8099/api/feeds
-

Behind Cloudflare Access, a token alone does not get past Access. Give the script an Access service token as well, and send its CF-Access-Client-Id and CF-Access-Client-Secret beside the token.

- -

The API

-

Requests and answers are JSON. A feed's id is the one in its list's address, such as 99-invisible; an episode is named by its feed and its guid, the guid URL-encoded.

-
- - - - - - - - - - - - - - - - - - -
RequestWhat it does
GET /api/meWho you are, and the version
GET /api/feedsYour podcasts, each with its unread and downloaded counts
POST /api/feedsSubscribe: {"url": "…"}, a feed's address or a site that links one; optional folder, keywords
DELETE /api/feeds/{id}Unsubscribe. The files stay for anyone else who subscribes
PATCH /api/feeds/{id}Your settings for a podcast
GET /api/feeds/{id}/entriesA podcast's episodes, 50 at a time: offset, limit, filter (unread, downloaded or flagged), q to search, sort and dir
GET /api/entriesThe same, across all your podcasts
POST /api/entries/{feed}/{guid}/flagsMark an episode read or kept: {"read": true}, {"flagged": true}
POST /api/entries/{feed}/{guid}/positionWhere you are in it: {"secs": 1234}
POST /api/feeds/{id}/read-allMark a podcast's episodes read; POST /api/read-all marks them all
POST /api/enclosures/{id}/downloadDownload an episode's file now
GET /media/{id}The file itself, with seeking
POST /api/fetchCheck for new episodes now
GET /api/opml, POST /api/opmlYour subscriptions as OPML, out or in
GET /api/eventsA live stream (server-sent events) of checks and downloads as they happen
-

For example, the newest unread episodes across everything you follow:

-
curl -H "Authorization: Bearer ipx_…" \
-  "http://<your server>:8099/api/entries?filter=unread&limit=10"
- -

The command line

-

The same ipx program that runs the server is its command line. Run it in the container with docker exec ipx ipx …; when the server is running, it does the work and the command shows what happened.

-
- - - - - - - - - - -
CommandWhat it does
ipx listEvery podcast and its state
ipx statusHow many podcasts, how many files waiting and downloaded
ipx fetch [id] [--force]Check everything that is due, or one podcast; --force checks it even if it is not due
ipx add <url>, ipx rm <id>Add a podcast, or take it out
ipx import <file>, ipx export <file>OPML in or out
ipx reap [--dry-run]Clear old files now, to stay within the space you set
ipx user add|list|passwd|rmAccounts
-

A program on the same machine can also talk to the running server over its control socket, one JSON command a line, and read back one event a line:

-
printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/ipx.sock
-{"ev":"feed_start","feed":"atp"}
-{"ev":"download_done","feed":"atp","enclosure":42,"path":"…","bytes":3000000}
-{"ev":"feed_done","feed":"atp","new":1,"downloaded":1,"failed":0,"torrents":0}
-{"ev":"scan_done","feeds":1}
-

The commands are fetch (optional feed, force), download (enclosure), reap (optional dry_run) and status.

-
- -
-

Use the directory

-

The directory gathers the public podcasts that iPX servers' listeners follow, and how many follow each. It never holds a file and never learns who listens.

- -

Browsing it

-

Anyone can browse directory.ipxreader.app without signing in: the most followed, the categories, everything A to Z, and a page for each podcast with its latest episodes and its address, to subscribe to in any podcast app.

- -

Connecting your iPX

-

In iPX, an admin opens Admin, Directory and enters the directory's address, https://directory.ipxreader.app, and the token its operator gave your server. Everyone on your server then has the Directory at the top of their list, and subscribes from it in one click.

-

Turn on sharing to add your server's podcasts to it. iPX sends only the podcasts' addresses and how many people on your server follow each, never who. It leaves out anything that looks private: a podcast someone marked Private, one with a login or a key in its address, and paid feeds such as Patreon's.

- -

Its API

-

Everything under /v1 takes a token, sent as Authorization: Bearer <token>. A token reads (ro), reads and shares (rw), or also removes and blocks podcasts (rwa).

-
- - - - - - - - - - - - -
RequestTokenWhat it does
GET /v1/meroThe token's name and access
GET /v1/feedsroEvery podcast, A to Z: id, url, title, image, category, subscribers
GET /v1/popular?limit=10roThe most followed, up to 100
GET /v1/feeds/{id}roOne podcast, with its description and latest twenty episodes
POST /v1/snapshotrwShare: [{"url": "…", "subscribers": 3}], replacing what this token shared before
DELETE /v1/feeds/{id}rwaRemove a podcast and keep it out
POST /v1/feeds/{id}/refreshrwaCheck a podcast now
GET /v1/blocked, DELETE /v1/blocked/{id}rwaWhat is kept out; let one back in
GET /v1/instances, DELETE /v1/instances/{name}rwaThe servers sharing; drop one's share
-

A podcast's id is the first 16 hex digits of the SHA-256 of its address, with the scheme and host in lower case and no default port or fragment, so every server arrives at the same one. Descriptions are the publisher's own HTML: sanitize them before you show them.

-
curl -H "Authorization: Bearer <token>" "https://directory.ipxreader.app/v1/popular?limit=5"
- -

Running your own

-

The directory is in the same repository. Build it, give it a database (Postgres or SQLite) and a config file, and make a token for each server that will use it:

-
docker build -f Dockerfile.directory -t ipx-directory .
-
services:
-  ipx-directory:
-    image: ipx-directory
-    container_name: ipx-directory
-    restart: unless-stopped
-    environment:
-      DIRECTORY_DATABASE_URL: "postgres://ipx_directory:password@db-host:5432/ipx_directory"
-    ports:
-      - "8100:8100"
-    volumes:
-      - ./directory:/config:ro   # directory.toml
-
# directory/directory.toml
-bind = "0.0.0.0:8100"
-database_url_env = "DIRECTORY_DATABASE_URL"
-
docker exec ipx-directory ipx-directory token new my-server --access rw
-

That prints the token, to give to the server, and the lines to add to directory.toml, which keeps only its SHA-256. The directory reads the file again within a minute, so adding or revoking a token needs no restart. It checks each podcast every six hours.

-
-
- -
-

iPX is free and open source under the MIT licence. The full documentation is in the repository.

-
- - diff --git a/site/docs.mjs b/site/docs.mjs new file mode 100644 index 0000000..41e4160 --- /dev/null +++ b/site/docs.mjs @@ -0,0 +1,24 @@ +// The guides: a page each, from site/guides, with the docs' front page written here (#223). +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const read = name => fs.readFileSync(path.join(here, 'guides', name), 'utf8'); + +const front = `

Docs

+

How to run iPX on your own server, and how to talk to it and to the directory from a program of your own.

+
+
Install ipx and ipx-web
Build the two containers, start them, sign in and add people.
+
The command line
The ipx commands, and the control socket a program on the same machine can use.
+
The directory
Browsing it, connecting your iPX to it, and running one of your own.
+
ipx API reference
Every endpoint of an iPX server, what it takes and what it gives.
+
ipx-directory API reference
The directory's API, for reading what it lists and sharing with it.
+
`; + +export const guides = [ + { href: '/docs/', file: 'docs/index.html', name: 'Overview', title: 'iPX docs', description: 'How to run iPX on your own server, and how to use its API and the directory’s.', body: front }, + { href: '/docs/install', file: 'docs/install.html', name: 'Install', title: 'Install ipx and ipx-web - iPX docs', description: 'Run iPX and its page on your own server with Docker.', body: read('install.html') }, + { href: '/docs/cli', file: 'docs/cli.html', name: 'Command line', title: 'The command line - iPX docs', description: 'The ipx commands, and its control socket.', body: read('cli.html') }, + { href: '/docs/directory', file: 'docs/directory.html', name: 'Directory', title: 'The directory - iPX docs', description: 'Browse the directory, connect your iPX to it, or run your own.', body: read('directory.html') }, +]; diff --git a/site/gen.mjs b/site/gen.mjs new file mode 100644 index 0000000..469ca1a --- /dev/null +++ b/site/gen.mjs @@ -0,0 +1,212 @@ +// The docs pages, into site/dist/docs: the guides in docs.mjs, and an API reference for each +// service from api.mjs, laid out as docs.rs lays out a crate (#223): a root page listing its +// modules and types, a page a module listing its endpoints, a page an endpoint and a page a type. +// No script: the site's Content-Security-Policy allows none of its own. +// +// node site/gen.mjs (site/build.sh runs it) +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { ipx, directory } from './api.mjs'; +import { guides } from './docs.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const dist = path.join(here, 'dist'); +const SRC = 'https://git.sdf1.net/rays/ipx/src/branch/main'; + +const esc = s => String(s).replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +/// The version a reference documents: its service's last release, from its changelog, not the +/// -dev version between releases. +function released(changelog) { + const m = fs.readFileSync(path.join(here, '..', changelog), 'utf8').match(/^## \[(\d+\.\d+\.\d+)\]/m); + return m ? m[1] : ''; +} + +function write(rel, html) { + const file = path.join(dist, rel); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, html); +} + +function shell({ title, description, sidebar, body, current }) { + return ` + + + + +${esc(title)} + + + + + + + +
+ iPX + +
+
+ +
${body}
+
+
+

iPX is free and open source under the MIT licence.

+
+ + +`; +} + +/* ---------------- the guides ---------------- */ + +const REFS = [[ipx, 'ipx'], [directory, 'ipx-directory']]; + +function guideSidebar(cur) { + const li = (href, name, on) => `
  • ${name}
  • `; + return ``; +} + +for (const g of guides) { + write(g.file, shell({ title: g.title, description: g.description, sidebar: guideSidebar(g.href), body: g.body, current: 'docs' })); +} + +/* ---------------- the references ---------------- */ + +const METHOD_CLASS = { GET: 'm-get', POST: 'm-post', PATCH: 'm-patch', PUT: 'm-patch', DELETE: 'm-delete' }; + +function reference(api) { + const label = api.label || api.name; + const version = released(api.changelog); + const typeNames = new Set(api.types.map(t => t.name)); + const typeHref = n => `${api.root}/types/${n}`; + const itemHref = (g, it) => `${api.root}/${g.id}/${it.slug}`; + const fnName = it => it.slug.replace(/-/g, '_'); + // A Rust type, its names that are types of this API linked. + const ty = s => esc(s).replace(/\b[A-Z][A-Za-z]+\b/g, n => typeNames.has(n) ? `${n}` : n); + const isType = s => /^[A-Z]/.test(s) && /[A-Za-z>]$/.test(s) && !/\s/.test(s.replace(/, /g, ',')); + const access = it => it.access || 'signed in'; + + // Which endpoints mention each type, for its "Used by", as docs.rs lists where a type turns up. + const usedBy = new Map(); + for (const g of api.groups) for (const it of g.items) { + for (const s of [it.body, it.query, it.returns].filter(Boolean)) { + for (const n of s.match(/\b[A-Z][A-Za-z]+\b/g) || []) if (typeNames.has(n)) { + if (!usedBy.has(n)) usedBy.set(n, []); + if (!usedBy.get(n).some(([, x]) => x === it)) usedBy.get(n).push([g, it]); + } + } + } + // Types inside types: a type used only as a field is reached from the type that holds it. + for (const t of api.types) for (const [, ft] of t.fields) { + for (const n of ft.match(/\b[A-Z][A-Za-z]+\b/g) || []) if (typeNames.has(n) && n !== t.name) { + if (!usedBy.has(n)) usedBy.set(n, []); + if (!usedBy.get(n).some(([, x]) => x === t)) usedBy.get(n).push([null, t]); + } + } + + const crumbs = (...parts) => `

    ${esc(api.name)}${parts.map(([h, n]) => `::${n}`).join('')}

    `; + const top = `${esc(label)}${version ? `${version}` : ''}`; + const back = `

    All docs

    `; + + function sidebar({ group, item, type } = {}) { + let s = top; + if (group) { + s += `

    Module ${group.name}

    +`; + } + s += `

    Modules

    `; + s += `

    Types

    `; + return `${back}`; + } + + const page = (rel, title, description, side, body) => + write(rel, shell({ title, description, sidebar: side, body, current: 'docs' })); + + // The root: what the crate page shows on docs.rs, its modules and types. + page(`${api.root.slice(1)}/index.html`, `${label} API`, `The HTTP API of ${label}, endpoint by endpoint.`, sidebar(), + `

    API ${esc(label)}${version ? ` ${version}` : ''}

    +${api.intro} +

    Modules

    +
    ${api.groups.map(g => `
    ${g.name}
    ${g.summary}
    `).join('')}
    +

    Types

    +
    ${api.types.map(t => `
    ${t.name}
    ${t.doc || ''}
    `).join('')}
    `); + + for (const g of api.groups) { + page(`${api.root.slice(1)}/${g.id}/index.html`, `${api.name}::${g.name}`, g.title, sidebar({ group: g }), + `${crumbs()}

    Module ${esc(api.name)}::${g.name}

    +

    ${g.summary}

    +

    Endpoints

    +
    ${g.items.map(it => `
    ${it.method} ${esc(it.path)}
    ${it.summary}
    `).join('')}
    `); + + for (const it of g.items) { + const decl = [ + `${it.method} ${esc(it.path)}`, + it.query && ` query ${ty(it.query)}`, + it.body && ` body ${ty(it.body)}`, + ` -> ${isType(it.returns) ? ty(it.returns) : esc(it.returns)}`, + ].filter(Boolean).join('\n'); + const fieldsOf = n => api.types.find(t => t.name === n)?.fields || []; + const rows = list => `
    ${list.map(([n, t, d]) => `
    ${esc(n)}: ${ty(t)}
    ${d || ''}
    `).join('')}
    `; + let body = `${crumbs([`${api.root}/${g.id}/`, g.name])} +

    Endpoint ${esc(api.name)}::${g.name}::${fnName(it)}Source

    +
    ${decl}
    +

    ${it.summary}

    +${it.doc || ''} +

    Access: ${esc(access(it))}

    `; + if (it.pathParams) body += `

    Path parameters

    ${rows(it.pathParams)}`; + if (it.query) body += `

    Query

    ${ty(it.query)}, as query parameters:

    ${rows(fieldsOf(it.query))}`; + if (it.body) body += `

    Body

    ${ty(it.body)}, as JSON.

    `; + body += `

    Returns

    ${isType(it.returns) ? `${ty(it.returns)}, as JSON.` : esc(it.returns)}

    `; + if (it.errors?.length) body += `

    Errors

    ${it.errors.map(([c, d]) => `
    ${esc(c)}
    ${d}
    `).join('')}
    `; + if (it.example) body += `

    Example

    ${esc(it.example)}
    `; + page(`${api.root.slice(1)}/${g.id}/${it.slug}.html`, `${it.method} ${it.path} - ${label}`, it.summary.replace(/<[^>]+>/g, ''), sidebar({ group: g, item: it }), body); + } + } + + page(`${api.root.slice(1)}/types/index.html`, `${api.name}::types`, `The types ${label}'s API takes and gives.`, sidebar(), + `${crumbs()}

    Module ${esc(api.name)}::types

    +
    ${api.types.map(t => `
    ${t.name}
    ${t.doc || ''}
    `).join('')}
    `); + + for (const t of api.types) { + const decl = t.name === 'Event' + ? `enum Event {\n${t.fields.map(([n, f]) => ` ${n} ${ty(f)},`).join('\n')}\n}` + : t.fields.length + ? `struct ${t.name} {\n${t.fields.map(([n, f]) => ` ${n}: ${ty(f)},`).join('\n')}\n}` + : `enum ${t.name}`; + const uses = usedBy.get(t.name) || []; + let body = `${crumbs([`${api.root}/types/`, 'types'])} +

    ${t.name === 'Event' || !t.fields.length ? 'Enum' : 'Struct'} ${esc(api.name)}::types::${t.name}

    +
    ${decl}
    +${t.doc ? `

    ${t.doc}

    ` : ''}`; + if (t.fields.length) { + body += `

    ${t.name === 'Event' ? 'Variants' : 'Fields'}

    ${t.fields.map(([n, f, d]) => + `
    §${esc(n)}${t.name === 'Event' ? ' ' : ': '}${ty(f)}
    ${d || ''}
    `).join('')}
    `; + } + if (uses.length) { + body += `

    Used by

    `; + } + page(`${api.root.slice(1)}/types/${t.name}.html`, `${t.name} - ${label}`, (t.doc || `${t.name} in ${label}'s API`).replace(/<[^>]+>/g, ''), sidebar({ type: t }), body); + } + + // Every link to a type must name one there is: a misspelt type in api.mjs is otherwise a + // name in plain text, which looks right and leads nowhere. + for (const g of api.groups) for (const it of g.items) for (const s of [it.body, it.query].filter(Boolean)) { + for (const n of s.match(/\b[A-Z][A-Za-z]+\b/g)) if (!typeNames.has(n) && !['Vec', 'Option'].includes(n)) throw new Error(`${it.path}: no type ${n}`); + } +} + +reference(ipx); +reference(directory); diff --git a/site/guides/cli.html b/site/guides/cli.html new file mode 100644 index 0000000..44607b6 --- /dev/null +++ b/site/guides/cli.html @@ -0,0 +1,22 @@ +

    The command line

    +

    The same ipx program that runs the server is its command line. Run it in the container with docker exec ipx ipx …; when the server is running, it does the work and the command shows what happened.

    +
    + + + + + + + + + + +
    CommandWhat it does
    ipx listEvery podcast and its state
    ipx statusHow many podcasts, how many files waiting and downloaded
    ipx fetch [id] [--force]Check everything that is due, or one podcast; --force checks it even if it is not due
    ipx add <url>, ipx rm <id>Add a podcast, or take it out
    ipx import <file>, ipx export <file>OPML in or out
    ipx reap [--dry-run]Clear old files now, to stay within the space you set
    ipx user add|list|passwd|rmAccounts
    +

    The control socket

    +

    A program on the same machine can also talk to the running server over its control socket, one JSON command a line, and read back one event a line:

    +
    printf '{"cmd":"fetch","force":true}\n' | socat - UNIX-CONNECT:$XDG_RUNTIME_DIR/ipx.sock
    +{"ev":"feed_start","feed":"atp"}
    +{"ev":"download_done","feed":"atp","enclosure":42,"path":"…","bytes":3000000}
    +{"ev":"feed_done","feed":"atp","new":1,"downloaded":1,"failed":0,"torrents":0}
    +{"ev":"scan_done","feeds":1}
    +

    The commands are fetch (optional feed, force), download (enclosure), reap (optional dry_run) and status.

    diff --git a/site/guides/directory.html b/site/guides/directory.html new file mode 100644 index 0000000..7720961 --- /dev/null +++ b/site/guides/directory.html @@ -0,0 +1,32 @@ +

    The directory

    +

    The directory gathers the public podcasts that iPX servers' listeners follow, and how many follow each. It never holds a file and never learns who listens.

    + +

    Browsing it

    +

    Anyone can browse directory.ipxreader.app without signing in: the most followed, the categories, everything A to Z, and a page for each podcast with its latest episodes and its address, to subscribe to in any podcast app.

    + +

    Connecting your iPX

    +

    In iPX, an admin opens Admin, Directory and enters the directory's address, https://directory.ipxreader.app, and the token its operator gave your server. Everyone on your server then has the Directory at the top of their list, and subscribes from it in one click.

    +

    Turn on sharing to add your server's podcasts to it. iPX sends only the podcasts' addresses and how many people on your server follow each, never who. It leaves out anything that looks private: a podcast someone marked Private, one with a login or a key in its address, and paid feeds such as Patreon's.

    + +

    Its API

    +

    Anything the pages show, a program can read through the directory's API with a token: see the ipx-directory API reference.

    + +

    Running your own

    +

    The directory is in the same repository. Build it, give it a database (Postgres or SQLite) and a config file, and make a token for each server that will use it:

    +
    docker build -f Dockerfile.directory -t ipx-directory .
    +
    services:
    +  ipx-directory:
    +    image: ipx-directory
    +    container_name: ipx-directory
    +    restart: unless-stopped
    +    environment:
    +      DIRECTORY_DATABASE_URL: "postgres://ipx_directory:password@db-host:5432/ipx_directory"
    +    ports:
    +      - "8100:8100"
    +    volumes:
    +      - ./directory:/config:ro   # directory.toml
    +
    # directory/directory.toml
    +bind = "0.0.0.0:8100"
    +database_url_env = "DIRECTORY_DATABASE_URL"
    +
    docker exec ipx-directory ipx-directory token new my-server --access rw
    +

    That prints the token, to give to the server, and the lines to add to directory.toml, which keeps only its SHA-256. The directory reads the file again within a minute, so adding or revoking a token needs no restart. It checks each podcast every six hours.

    diff --git a/site/guides/install.html b/site/guides/install.html new file mode 100644 index 0000000..d24df6a --- /dev/null +++ b/site/guides/install.html @@ -0,0 +1,67 @@ +

    Install ipx and ipx-web

    +

    iPX is two containers: ipx, which checks your podcasts, downloads them and answers the API, and ipx-web, which serves the page and passes the API on to ipx.

    +

    You need Docker with Compose. There are no published images yet, so you build both from the source:

    +
    git clone https://git.sdf1.net/rays/ipx.git
    +cd ipx
    +docker build -t ipx .
    +docker build -f Dockerfile.web -t ipx-web .
    +

    The repository has a sample docker-compose.yml that runs both, with the database in SQLite and everything ipx keeps in three folders beside the file:

    +
    services:
    +  ipx:
    +    image: ipx
    +    container_name: ipx
    +    restart: unless-stopped
    +    environment:
    +      PUID: "99"          # who owns the files it writes
    +      PGID: "100"
    +      TZ: "UTC"
    +    ports:
    +      - "6881:6881/tcp"   # BitTorrent
    +      - "6881:6881/udp"
    +    volumes:
    +      - ./config:/config  # config.toml, written on first start
    +      - ./data:/data      # the database
    +      - ./downloads:/downloads
    +
    +  ipx-web:
    +    image: ipx-web
    +    container_name: ipx-web
    +    restart: unless-stopped
    +    environment:
    +      IPX_UPSTREAM: "ipx:8099"
    +    ports:
    +      - "8099:8080"       # the page
    +    depends_on:
    +      - ipx
    +
    docker compose up -d
    +

    ipx has no port of its own: everything reaches it through ipx-web, on port 8099. Open http://<your server>:8099 and sign in as admin with the password ipodderx, then change it:

    +
    echo -n 'a good password' | docker exec -i ipx ipx user passwd admin
    +

    The password is read from what you pipe in, so it stays out of your shell's history.

    + +

    Adding people

    +

    An admin adds people in Admin, Accounts, or from the command line:

    +
    echo -n 'their password' | docker exec -i ipx ipx user add sam
    +

    Each person starts with no podcasts and subscribes to their own. Two people subscribing to the same show cost nothing extra: it is fetched once and each file is kept once.

    + +

    Settings

    +

    How often podcasts are checked, how much disk to use, how many new episodes to download and the rest are in Admin on the page, and take effect straight away. /config/config.toml holds only what ipx needs before it can read its database: where downloads go, BitTorrent, and who gets in.

    +
    + + + + + + + + +
    Environment variableWhat it does
    IPX_DATABASE_URLA postgres://user:password@host:5432/database address, to keep everything in Postgres instead of SQLite
    PUID, PGIDThe user and group that own the files ipx writes
    IPX_LOG_FORMATjson logs one JSON object a line, for Loki and the like
    OTEL_EXPORTER_OTLP_ENDPOINTSends traces of requests, checks and downloads to an OpenTelemetry collector; set WEB_TRACING=on on ipx-web too
    IPX_UPSTREAMOn ipx-web: where ipx is, ipx:8099 by default
    + +

    Reaching it from outside

    +

    The page is plain HTTP. If anyone will reach it from outside your network, put HTTPS in front of it: a reverse proxy you already run, or a Cloudflare tunnel. People can then sign in through Cloudflare Access or Authentik instead of a password; single sign-on says how.

    + +

    Updating

    +
    git pull
    +docker build -t ipx .
    +docker build -f Dockerfile.web -t ipx-web .
    +docker compose up -d
    +

    Your podcasts, accounts and downloads are in the three folders and are kept.

    diff --git a/site/index.html b/site/index.html index 5165ee3..873fe43 100644 --- a/site/index.html +++ b/site/index.html @@ -14,7 +14,7 @@
    iPX diff --git a/site/site.css b/site/site.css index 103ecec..bf7eddb 100644 --- a/site/site.css +++ b/site/site.css @@ -90,28 +90,83 @@ h2{margin:0 0 1.5rem;color:var(--navy);font-weight:800;font-size:clamp(1.75rem,4 .also dd{margin:.45rem 0 0;max-width:34rem} -/* Docs: one column of prose at a reading width; code and tables scroll inside themselves at - phone width rather than the page scrolling sideways. */ -.doc{max-width:50rem} -.doc .title{font-size:clamp(3.5rem,12vw,6rem);line-height:.9;letter-spacing:-.05em;margin:2rem 0 0} +/* Docs (#223): a sidebar and a column, as docs.rs lays out a crate. The sidebar is the dial's + sky; an endpoint's name is the scale's blue, a type's green, and each method has its own + colour, every one AA on the page. Code to read is dark, as on the home page; a declaration is + light, as docs.rs shows one. On a phone the sidebar follows the page instead of leading it. */ +.docs{max-width:72rem;margin:0 auto;padding-inline:1.5rem;display:grid;grid-template-columns:15rem minmax(0,1fr);gap:3rem;align-items:start} +.docs main.doc{max-width:50rem;margin:0;padding:0} +.side{position:sticky;top:1rem;max-height:calc(100vh - 2rem);overflow:auto;padding:1rem .75rem;background:var(--sky);border-radius:1rem;font-size:.92rem} +.side ul{list-style:none;margin:0;padding:0} +.side li a{display:block;padding:.2rem .5rem;border-radius:.4rem;color:var(--ink);text-decoration:none;overflow-wrap:anywhere} +.side li a:hover{background:#fff} +.side a[aria-current=page]{background:#fff;font-weight:700} +.side-h{margin:1.1rem .5rem .35rem;color:var(--muted);font-size:.78rem;font-weight:700;letter-spacing:.06em;text-transform:uppercase} +.side-h a{color:inherit} +.side-back{margin:1rem .5rem 0} +.crate{display:flex;align-items:center;gap:.6rem;padding:.25rem .5rem;color:var(--navy);text-decoration:none} +.crate img{border-radius:9px} +.crate b{display:block;font-size:1.1rem} +.ver{color:var(--muted);font-size:.85rem;font-weight:500} +.mono,.items a,.uses a,.list dt,.path,.decl,.fields dt,.item .name{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} +.items a,.uses a{font-size:.82rem} +.items .verb{display:inline-block;min-width:3.6em} +.verb{font-weight:700} +.m-get .verb,.verb.m-get{color:#2f6aa0} +.m-post .verb,.verb.m-post{color:#1d6b44} +.m-patch .verb,.verb.m-patch{color:#875400} +.m-delete .verb,.verb.m-delete{color:#b3261e} +a.t,.item .name.t{color:#1d6b44} + +.doc h1{margin:2rem 0 0;color:var(--navy);font-weight:900;font-size:clamp(2.6rem,8vw,4rem);line-height:1;letter-spacing:-.04em} +.doc h1.item{margin-top:.25rem;font-size:clamp(1.35rem,4vw,1.85rem);font-weight:500;letter-spacing:-.01em;line-height:1.3;overflow-wrap:anywhere} +.item .kind{font-family:Inter,system-ui,sans-serif;color:var(--muted)} +.item .name{font-weight:700;color:var(--scale)} +.srclink{float:right;margin-left:1rem;font:500 .9rem/2.4 Inter,system-ui,sans-serif} +.path{margin:2rem 0 0;font-size:.9rem;color:var(--muted)} +.path a{color:inherit} .lede{margin:1.5rem 0 0;color:var(--muted);font-size:1.125rem} -.toc{margin:2rem 0 0;padding:1rem 1.25rem;background:var(--sky);border-radius:.9rem} -.toc ol{margin:0;padding-left:1.25rem} -.toc li{padding-block:.15rem} -.doc section{padding-block:3.5rem 0} -.doc h2{scroll-margin-top:1rem} -.doc h3{margin:2.25rem 0 .5rem;color:var(--navy);font-weight:750;font-size:1.25rem;line-height:1.25} +.doc h2{margin:2.5rem 0 .75rem;padding-bottom:.35rem;border-bottom:1px solid var(--rule);font-size:1.35rem;letter-spacing:-.01em} .doc p{margin:.75rem 0} +.summary{font-size:1.06rem} +.access{color:var(--muted)} +.access b{color:var(--ink)} .doc code{font:500 .9em/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace} .doc :not(pre)>code{padding:.08em .3em;background:var(--sky);border-radius:.3em;overflow-wrap:anywhere} +.doc dt>code,.doc a>code{background:none;padding:0} .doc pre{margin:1rem 0;padding:1rem 1.15rem;background:var(--navy);color:#e6f1fa;border-radius:.8rem;overflow-x:auto} .doc pre code{font-size:.85rem} +.doc pre.decl{background:#fff;color:var(--ink);border:1px solid var(--rule)} +.decl a{text-decoration:none} .scroll{margin:1rem 0;overflow-x:auto} .doc table{width:100%;border-collapse:collapse;font-size:.95rem} .doc th,.doc td{padding:.55rem .75rem .55rem 0;border-bottom:1px solid var(--rule);text-align:left;vertical-align:top} .doc th{color:var(--navy);font-weight:700} .doc td:first-child code{white-space:nowrap} +/* An item table: the name, and the first line of what it is. */ +.list{display:grid;grid-template-columns:minmax(0,max-content) minmax(0,1fr);gap:.4rem 1.5rem;margin:0} +.list dt{font-size:.88rem;overflow-wrap:anywhere} +.list dt a{text-decoration:none} +.list dt a:hover{text-decoration:underline} +.list dd{margin:0} +.fields dt{position:relative;margin-top:1rem;font-size:.92rem;overflow-wrap:anywhere} +.fields dd{margin:.25rem 0 0 1.5rem} +.anchor{position:absolute;left:-1.1rem;color:var(--muted);text-decoration:none;opacity:0} +.fields dt:hover .anchor,.anchor:focus-visible{opacity:1} +.uses{padding-left:1.25rem} +.cards{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1rem;margin:2rem 0 0} +.cards div{padding:1.1rem 1.25rem;background:#fff;border:1px solid var(--rule);border-radius:1rem} +.cards dt{font-weight:750;font-size:1.1rem} +.cards dd{margin:.35rem 0 0;color:var(--muted)} .bar nav a[aria-current]{text-decoration:underline;text-decoration-color:var(--needle);text-decoration-thickness:2px} +@media (max-width:800px){ + .docs{grid-template-columns:minmax(0,1fr);gap:2rem;padding-inline:1rem} + .side{position:static;max-height:none;order:2} + .srclink{float:none;display:block;margin:0;line-height:1.8} + .list{grid-template-columns:minmax(0,1fr);gap:.15rem} + .list dd{margin-bottom:.6rem} + .cards{grid-template-columns:minmax(0,1fr)} +} footer{margin-top:5rem;padding-block:1.75rem 3rem;border-top:1px solid var(--rule);color:var(--muted);font-size:.95rem} footer p{margin:0}