From ff294fbfc1aeefb3ec6c106014bcc726543ee24f Mon Sep 17 00:00:00 2001 From: isaackogan Date: Tue, 20 Oct 2026 14:55:48 -0400 Subject: [PATCH 01/31] docs: add Starlight documentation site design and implementation plan Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- .../plans/2026-10-20-starlight-docs.md | 305 ++++++++++++++++++ .../specs/2026-10-20-starlight-docs-design.md | 125 +++++++ 2 files changed, 430 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-20-starlight-docs.md create mode 100644 docs/superpowers/specs/2026-10-20-starlight-docs-design.md diff --git a/docs/superpowers/plans/2026-10-20-starlight-docs.md b/docs/superpowers/plans/2026-10-20-starlight-docs.md new file mode 100644 index 00000000..186e4389 --- /dev/null +++ b/docs/superpowers/plans/2026-10-20-starlight-docs.md @@ -0,0 +1,305 @@ +# Starlight Documentation Site Implementation Plan (TikTokLiveJava) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give the Java library a Starlight documentation site with generated API and protobuf schema references, content and SEO gates, hosted on Cloudflare Workers at `https://ttlj.docs.isaackogan.com/`, bootstrapped by the fork's own GitHub Actions run and offered upstream as a pull request. + +**Architecture:** `docs/` is a self-contained pnpm project (Astro + Starlight + lucode) whose `build` script first builds and runs a standalone Maven tool in `docs/tools/` (`check`, then `generate`) and then `astro build`. The tool parses Java sources with JavaParser and the three `.proto` files with a small proto3 parser, writes Starlight Markdown, and compiles every Java example fence against the built library jars. `docs.yml` validates on pull requests, deploys on pushes to `master` when the Cloudflare secret exists, and on a daily schedule deploys from a checkout of upstream `master`. The Python sibling at `/Users/isaackogan/Documents/PycharmProjects/TikTokLive/docs` is the reference implementation for everything web-side; copy its structure and prose register. + +**Tech Stack:** Java 17 in CI (JDK 21 locally; library target 16), Maven, JavaParser 3.26.x, JUnit 5, `javax.tools.JavaCompiler`; Node 24, pnpm 10.34.2, astro 7.4.1, @astrojs/starlight 0.42.6, lucode-starlight 1.0.0, starlight-links-validator 0.27.0, wrangler 4.151.2; Cloudflare Workers static assets; GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-10-20-starlight-docs-design.md` + +## Global Constraints + +- Public hostname is exactly `ttlj.docs.isaackogan.com`; Worker name `tiktoklivejava-docs` (internal only); no other public name contains "tiktok". +- Exact pins, no ranges: `astro 7.4.1`, `@astrojs/starlight 0.42.6`, `lucode-starlight 1.0.0`, `starlight-links-validator 0.27.0`, `wrangler 4.151.2`, `@astrojs/markdown-remark 7.3.3`, `unist-util-visit 5.0.0`, `pnpm@10.34.2`, Node `>=22.22.0`; `javaparser-core 3.26.4`, `junit-jupiter 5.11.4`. +- Docs tooling lives only under `docs/`; nothing is added to the root `pom.xml` modules, `API/pom.xml`, `Client/pom.xml`, `jitpack.yml`, or `maven-publish.yml`. +- Generated content (`docs/src/content/docs/api/`, `docs/src/content/docs/schema/`, `docs/dist/`, `docs/node_modules/`, `docs/.astro/`, `docs/tools/target/`) is never committed. +- No `discord.com` / `discord.gg` link in any authored Markdown page; the only Discord link is the header social button `https://discord.gg/e2XwPNTBBr`. +- Every `eulerstream.com` link is dofollow; every built page carries at least one (footer); the homepage carries at least three with distinct anchor texts. +- Java examples use ```` ```java ```` fences; a fence with a top-level `class`/`interface`/`record`/`enum` compiles as-is, any other fence is a fragment the checker wraps; shell commands use ```` ```sh ````; build files use ```` ```xml ```` or ```` ```groovy ````. +- Internal links are site-relative with trailing slashes. Prose register matches the sibling sites: short declarative sentences, no marketing. +- Library facts come from the sources at `1.13.1-Release`: entry `TikTokLive.newClient(String hostName)` → `LiveClientBuilder` (handlers on `EventsBuilder`, 73 `on*` methods); `LiveClientSettings` (16 fields), `HttpClientSettings`; 78 event classes under `API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/`, each annotated `@EventMeta(eventType = EventType.Control|Message|Debug)`; 11 classes under `api/exceptions/`; listener classes extend `TikTokEventListenerBase` with `@TikTokEventObserver` methods; extensions `TikTokLiveRecorder` (`extension-recorder`) and `TikTokLiveCollector`/`DataCollector` (`extension-collector`); proto Java package `io.github.jwdeveloper.tiktok.api.messages.webcast` from `API/src/main/proto/{webcast,data,enums}.proto` (proto3, `package TikTok`). +- JitPack coordinates: group `com.github.jwdeveloper.TikTok-Live-Java`, artifact `Client`, version `1.13.1-Release`; Java 16 or newer. +- Work on branch `docs/starlight-site` in `/Users/isaackogan/Documents/IdeaProjects/TikTokLiveJava` (origin = the fork `isaackogan/TikTokLiveJava`; add `upstream = https://github.com/jwdeveloper/TikTokLiveJava.git`). Never push to upstream; never force-push. Commit messages end with `Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ`. +- Local commands: `npx -y pnpm@10.34.2` for pnpm; `mvn` 3.9.12 and JDK 21 are on PATH; build the library once with `mvn -q -pl API,Client,extension-recorder,extension-collector -am -DskipTests package` so `*/target/classes` exist for the example compiler. + +## Review Focus + +1. A new event class added upstream with no docs entry: the build must fail naming the class. Pinned in Task 3 (`checkEventsReportsMissingEvent`). +2. A fragment example calling a method that does not exist (for example `event.getComent()`): the build must fail with the page and example number. Pinned in Task 3 (`fragmentWithUnknownMethodFails`). +3. A nested proto message (for example a message declared inside another) must get a schema page and the links from event pages must resolve. Pinned in Task 2 (`nestedMessageGetsPage`) and Task 6 (links validator run). +4. A README handler name with no matching event class (a typo in the README list): the `readme` section must fail. Pinned in Task 3 (`readmeHandlerWithoutClassFails`). +5. The scheduled deploy must build upstream's sources, not the fork's, while using the fork's workflow: pinned in Task 11 by asserting the schedule job's checkout uses `repository: jwdeveloper/TikTokLiveJava` and `ref: master` and the push job's checkout does not. + +--- + +### Task 1: Scaffold the Starlight project + +**Files:** +- Create: `docs/package.json`, `docs/pnpm-workspace.yaml`, `docs/tsconfig.json`, `docs/astro.config.mjs`, `docs/src/content.config.ts`, `docs/src/content/docs/index.md`, `docs/src/content/docs/404.md`, placeholder pages `docs/src/content/docs/getting-started/install.md`, `getting-started/connection.md`, `events/index.md`, `troubleshooting.md`, `contributing.md`, `hosting.md`, `docs/public/favicon.png`, `docs/public/robots.txt` +- Modify: `.gitignore` + +**Interfaces:** +- Produces: `pnpm --dir docs build` = `pnpm run tools:build && pnpm run check && pnpm run generate && astro build`, with `tools:build` = `mvn -q -f tools/pom.xml -DskipTests package`, `check` = `java -jar tools/target/docs-tools.jar check ..`, `generate` = `java -jar tools/target/docs-tools.jar generate ..` (the argument is the repository root relative to `docs/`). Until Tasks 2–3 exist, verify with `pnpm --dir docs exec astro build`. Sidebar fixed here: + +```js +sidebar: [ + { label: 'Start here', items: [ + { label: 'Getting Started', slug: '' }, + { label: 'Install', slug: 'getting-started/install' }, + { label: 'Connection lifecycle', slug: 'getting-started/connection' }, + ] }, + { label: 'Configuration', items: [{ autogenerate: { directory: 'configuration' } }] }, + { label: 'Guides', items: [{ autogenerate: { directory: 'guides' } }] }, + { label: 'Events', items: [{ autogenerate: { directory: 'events' } }], collapsed: true }, + { label: 'Advanced', items: [{ autogenerate: { directory: 'advanced' } }], collapsed: true }, + { label: 'Troubleshooting', slug: 'troubleshooting' }, + { label: 'Contributing to the docs', slug: 'contributing' }, + { label: 'Hosting', slug: 'hosting' }, + { label: 'API reference', items: [{ autogenerate: { directory: 'api' } }], collapsed: true }, + { label: 'Protobuf schema', items: [{ autogenerate: { directory: 'schema' } }], collapsed: true }, +], +``` + +- [ ] **Step 1: Copy the Python sibling's `docs/` web files and adapt** + +From `/Users/isaackogan/Documents/PycharmProjects/TikTokLive/docs/` copy `astro.config.mjs`, `tsconfig.json`, `pnpm-workspace.yaml`, `src/content.config.ts`, `src/content/docs/404.md`, `public/favicon.png`. In `astro.config.mjs` set `site` default `https://ttlj.docs.isaackogan.com`, `title: 'TikTokLiveJava'`, `description: 'Unofficial TikTok LIVE API for Java. Guides, events, and generated API reference.'`, social GitHub `https://github.com/jwdeveloper/TikTokLiveJava` and Discord `https://discord.gg/e2XwPNTBBr`, `editLink.baseUrl` `https://github.com/jwdeveloper/TikTokLiveJava/edit/master/docs/`, lucode `navLinks` Docs/Events/API, `footerText` `'Unofficial project, not affiliated with TikTok or ByteDance Ltd. Signing by [Euler Stream](https://www.eulerstream.com/). Source on [GitHub](https://github.com/jwdeveloper/TikTokLiveJava).'`, `head` entries `og:image` `https://raw.githubusercontent.com/jwdeveloper/TikTokLiveJava/master/tools-readme/src/main/resources/logo.svg` with `og:image:alt` `TikTokLiveJava, TikTok LIVE API for Java` and `twitter:card` `summary_large_image`, and the sidebar above. `package.json`: name `tiktoklivejava-docs`, scripts `dev`, `tools:build`, `check`, `generate`, `build`, `preview`, `deploy:cloudflare` (`wrangler deploy`), devDependencies pinned per Global Constraints. `public/robots.txt`: `User-agent: *`, `Allow: /`, blank line, `Sitemap: https://ttlj.docs.isaackogan.com/sitemap-index.xml`. + +- [ ] **Step 2: Placeholder pages and `.gitignore`** + +`index.md` title `Getting Started` with one paragraph; the other placeholders are frontmatter plus the sentence "This page is written in a later task of the documentation plan." Append to `.gitignore`: `docs/node_modules/`, `docs/.astro/`, `docs/dist/`, `docs/src/content/docs/api/`, `docs/src/content/docs/schema/`, `docs/tools/target/`. Create gitignored placeholder `api/index.md` and `schema/index.md` (title only) so the autogenerated groups exist. Add the `upstream` git remote. + +- [ ] **Step 3: Install and build** + +Run: `npx -y pnpm@10.34.2 --dir docs install && npx -y pnpm@10.34.2 --dir docs exec astro build 2>&1 | tail -3` +Expected: `All internal links are valid.`; `docs/dist/index.html` has `Getting Started | TikTokLiveJava`; `grep -c 'discord.gg/e2XwPNTBBr' docs/dist/index.html` is 1; `docs/dist/robots.txt` present. + +- [ ] **Step 4: Commit** + +```bash +git add -A docs .gitignore +git commit -m "docs: scaffold the Starlight documentation site" +``` + +### Task 2: Docs tool, `generate` command + +**Files:** +- Create: `docs/tools/pom.xml`, `docs/tools/src/main/java/docs/Main.java`, `docs/tools/src/main/java/docs/JavaSources.java`, `docs/tools/src/main/java/docs/ApiPages.java`, `docs/tools/src/main/java/docs/ProtoParser.java`, `docs/tools/src/main/java/docs/SchemaPages.java`, `docs/tools/src/main/java/docs/Markdown.java` +- Test: `docs/tools/src/test/java/docs/ApiPagesTest.java`, `docs/tools/src/test/java/docs/ProtoParserTest.java`, `docs/tools/src/test/java/docs/SchemaPagesTest.java` + +**Interfaces:** +- `docs/tools/pom.xml`: standalone project (`groupId docs`, `artifactId docs-tools`, Java release 17), dependencies `com.github.javaparser:javaparser-core:3.26.4`, `org.junit.jupiter:junit-jupiter:5.11.4` (test), `maven-shade-plugin` producing `target/docs-tools.jar` with `Main-Class: docs.Main`. +- `docs.Main.main(String[] args)`: `args[0]` is `generate` or `check`, `args[1]` is the repository root; exit code 0/1. +- `JavaSources.load(Path repoRoot) -> List` parsing every `.java` under `API/src/main/java` and `Client/src/main/java` (JavaParser, language level Java 17, Lombok annotations kept as annotations). +- `ApiPages.slug(String fqcn) -> String`: `io.github.jwdeveloper.tiktok.api.live.LiveClient` → `io/github/jwdeveloper/tiktok/api/live/liveclient`; package index pages at `/index.md`, type pages at `/.md`; nested types get `-` names. +- `ApiPages.render(List units, String version, String sourceBaseUrl, Path outDir) -> List`: one page per public top-level or nested type: frontmatter `title` (simple name), `description` (first Javadoc sentence or `" "`), `sidebar.label`; sections Signature (modifiers, kind, name, type parameters, extends, implements), Javadoc body, Fields (name, type, Javadoc, Lombok `@Getter`/`@Setter`/`@Builder` noted), Constructors, Methods (signature, parameter and return types, Javadoc `@param`/`@return`), Source (`//src/main/java/#L`). Deterministic ordering (packages and members sorted by name). Index page title `TikTokLiveJava `, version read from the root `pom.xml` ``. +- `ProtoParser.parse(Path protoFile) -> ProtoFile` with records `ProtoFile(String pkg, String javaPackage, List messages, List enums)`, `ProtoMessage(String name, String parent, List fields, List nested, List nestedEnums)`, `ProtoField(String label, String type, String name, int number)`, `ProtoEnum(String name, List values)`. Handles proto3 `message`, `enum`, `repeated`, `optional`, `map`, `oneof`, nested types, comments; ignores `option` lines and `reserved`. +- `SchemaPages.render(List files, String version, Path outDir) -> List`: one page per message and enum (nested ones named `.` with slug `-`), slug under `schema//.md` (`webcast/webcastchatmessage.md`), fields table (name, type with link when it is another message/enum, number, label), index page titled `TikTok Webcast schema ()`. +- Consumed by Task 6 (event pages link `/schema///`) and Task 10 (nothing). + +- [ ] **Step 1: Write failing tests** + +```java +class ApiPagesTest { + @Test void slugLowercasesAndNestsPackages() { + assertEquals("io/github/jwdeveloper/tiktok/api/live/liveclient", ApiPages.slug("io.github.jwdeveloper.tiktok.api.live.LiveClient")); + } + @Test void liveClientSettingsPageListsFields(@TempDir Path out) { + var units = JavaSources.load(REPO); + var paths = ApiPages.render(units, "1.13.1-Release", "https://github.com/jwdeveloper/TikTokLiveJava/blob/master", out); + var page = out.resolve("io/github/jwdeveloper/tiktok/api/data/settings/liveclientsettings.md"); + assertTrue(Files.exists(page)); + var md = Files.readString(page); + assertTrue(md.startsWith("---\ntitle: LiveClientSettings\n")); + for (var f : List.of("offline", "fetchGifts", "retryOnConnectionFailure", "pingInterval", "apiKey")) assertTrue(md.contains("`" + f + "`"), f); + assertTrue(paths.size() > 150); + } + @Test void typeWithoutJavadocStillRenders(@TempDir Path out) { /* EventType enum has no Javadoc: page exists, description is "enum io.github...EventType" */ } + @Test void outputIsDeterministic(@TempDir Path a, @TempDir Path b) { /* render twice, compare file lists and contents */ } +} +class ProtoParserTest { + @Test void parsesMessagesEnumsAndNested() { + var f = ProtoParser.parse(REPO.resolve("API/src/main/proto/webcast.proto")); + assertEquals("io.github.jwdeveloper.tiktok.api.messages.webcast", f.javaPackage()); + assertTrue(f.messages().stream().anyMatch(m -> m.name().equals("WebcastChatMessage"))); + assertTrue(f.messages().stream().anyMatch(m -> !m.nested().isEmpty())); + } + @Test void parsesRepeatedMapAndOneof() { /* fixture string with repeated, map, oneof; assert labels/types */ } +} +class SchemaPagesTest { + @Test void nestedMessageGetsPage(@TempDir Path out) { + var files = List.of(ProtoParser.parse(REPO.resolve("API/src/main/proto/webcast.proto")), ProtoParser.parse(REPO.resolve("API/src/main/proto/data.proto")), ProtoParser.parse(REPO.resolve("API/src/main/proto/enums.proto"))); + var paths = SchemaPages.render(files, "1.13.1-Release", out); + assertTrue(Files.exists(out.resolve("webcast/webcastchatmessage.md"))); + assertTrue(paths.stream().anyMatch(p -> p.getFileName().toString().contains("-")), "a nested type page named outer-inner"); + assertTrue(paths.size() > 300); + } +} +``` + +`REPO` is `Path.of(System.getProperty("repo.root"))`, set in the surefire configuration to `${project.basedir}/../..`. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cd docs/tools && mvn -q test 2>&1 | tail -5` +Expected: compilation errors for the missing classes. + +- [ ] **Step 3: Implement the classes per Interfaces** (JavaParser `StaticJavaParser` with `ParserConfiguration.LanguageLevel.JAVA_17`; the proto parser is a line-oriented recursive-descent parser, no grammar library). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `cd docs/tools && mvn -q test 2>&1 | tail -5` +Expected: all tests pass, no warnings. + +- [ ] **Step 5: Wire and build the site** + +Run: `npx -y pnpm@10.34.2 --dir docs run tools:build && npx -y pnpm@10.34.2 --dir docs run generate && npx -y pnpm@10.34.2 --dir docs exec astro build 2>&1 | tail -4` +Expected: `All internal links are valid.`, page count above 500; `docs/dist/api/io/github/jwdeveloper/tiktok/api/live/liveclient/index.html` renders the signature block; the sidebar groups API pages by package directory. + +- [ ] **Step 6: Commit** + +```bash +git add docs/tools docs/package.json +git commit -m "docs: generate API and protobuf schema reference pages" +``` + +### Task 3: Docs tool, `check` command + +**Files:** +- Create: `docs/tools/src/main/java/docs/ContentCheck.java`, `docs/tools/src/main/java/docs/ExampleCompiler.java` +- Test: `docs/tools/src/test/java/docs/ContentCheckTest.java`, `docs/tools/src/test/java/docs/ExampleCompilerTest.java` + +**Interfaces:** +- `ContentCheck.run(Path repoRoot, PrintStream out) -> int` printing exactly these sections in order: `events`, `settings`, `builder`, `exceptions`, `extensions`, `discord`, `readme`, `examples`, each `PASS
` or `FAIL
: `, then `Checked N pages, E events, S settings fields, H handlers, X exceptions, J Java examples.`; returns 1 if any section failed. +- Extraction (JavaParser over the sources): `events(repoRoot) -> Map` event simple name → `@EventMeta` category (`Control`/`Message`/`Debug`) for every class under `api/data/events`; `settingsFields(repoRoot) -> List` from `LiveClientSettings` and `HttpClientSettings`; `handlers(repoRoot) -> List` every public method named `on*` declared on `EventsBuilder` plus every public method of `LiveClient`; `exceptions(repoRoot) -> List` class names under `api/exceptions`; extensions = `TikTokLiveRecorder`, `TikTokLiveCollector`. +- Page rules: authored pages are every `.md`/`.mdx` under `docs/src/content/docs` except `api/` and `schema/`; `events`: every event name appears in backticks somewhere and the catalog `events/index.md` has a row ``| `TikTokXEvent` | | description |`` whose type equals the annotation; `settings`, `builder`, `exceptions`, `extensions`: each name in backticks somewhere; `discord`: no `discord.com`/`discord.gg`; `readme`: every `- [onX](#...)` item in the repository `README.md` events list names a handler that exists on `EventsBuilder` and whose event class (the heading's `TikTokXEvent`) is in `events`; `examples`: every ```` ```java ```` fence (any indentation, dedented) compiles. +- `ExampleCompiler.compile(List examples, Path repoRoot) -> List` where `Example(String label, String code)`; a fence containing a top-level type declaration (regex `^(public\s+)?(final\s+)?(class|interface|record|enum)\s+\w+` on a dedented line) compiles as-is in its own file named after the first declared type; any other fence is wrapped as `import io.github.jwdeveloper.tiktok.*; import io.github.jwdeveloper.tiktok.api.*; …` (one wildcard import per package found under `API` and `Client` sources, plus `java.util.*`, `java.time.*`, `java.util.function.*`, `java.util.logging.*`) around `public class Example { void run() throws Exception { } }`; compiled with `ToolProvider.getSystemJavaCompiler()`, `--release 17`, classpath = `API/target/classes`, `Client/target/classes`, `extension-recorder/target/classes`, `extension-collector/target/classes` plus every jar under `~/.m2` that those modules depend on, resolved by running `mvn -q -pl API,Client,extension-recorder,extension-collector dependency:build-classpath -Dmdep.outputFile=docs/tools/target/classpath.txt` from `tools:build` (the `check` command reads that file; failing when it is missing). A canary fragment `TikTokLive.newClient("x").onComment((c, e) -> e.getComent());` is always compiled and must fail, else `examples` reports `example compiler canary not flagged`. + +- [ ] **Step 1: Write failing tests** + +```java +class ContentCheckTest { + @Test void eventsMapIncludesCategories() { var e = ContentCheck.events(REPO); assertEquals("Message", e.get("TikTokCommentEvent")); assertEquals("Control", e.get("TikTokConnectedEvent")); assertTrue(e.size() >= 78); } + @Test void settingsHandlersExceptions() { assertTrue(ContentCheck.settingsFields(REPO).contains("retryConnectionTimeout")); assertTrue(ContentCheck.handlers(REPO).contains("onComment")); assertEquals(11, ContentCheck.exceptions(REPO).size()); } + @Test void checkEventsReportsMissingEvent(@TempDir Path site) { /* one page mentioning `TikTokCommentEvent` only; events map {TikTokCommentEvent→Message, TikTokNewShinyEvent→Message} → failure "Missing event documentation: TikTokNewShinyEvent" */ } + @Test void catalogRowTypeMustMatchAnnotation() { /* row "| `TikTokCommentEvent` | Control | x |" vs Message → "Event catalog row is stale: TikTokCommentEvent" */ } + @Test void readmeHandlerWithoutClassFails() { /* README text "- [onComent](#oncoment-tiktokcomentevent)" → "README handler has no event class: onComent" */ } + @Test void discordLinkFails() { /* "https://discord.gg/abc" → "t.md: published pages must not reference Discord" */ } +} +class ExampleCompilerTest { + @Test void fragmentCompiles() { assertTrue(ExampleCompiler.compile(List.of(new Example("p.md, example 1", "TikTokLive.newClient(\"user\").onComment((client, event) -> System.out.println(event.getText())).buildAndConnect();")), REPO).isEmpty()); } + @Test void fragmentWithUnknownMethodFails() { var f = ExampleCompiler.compile(List.of(new Example("p.md, example 1", "TikTokLive.newClient(\"user\").onComment((client, event) -> event.getComent());")), REPO); assertEquals(1, f.size()); assertTrue(f.get(0).startsWith("p.md, example 1")); } + @Test void fullUnitCompilesAsIs() { /* a fence declaring `public class Demo extends TikTokEventListenerBase` with a @TikTokEventObserver method compiles */ } + @Test void canaryGuardsVacuousPass() { /* with an empty classpath file, compile returns the canary failure message */ } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** (`cd docs/tools && mvn -q test`): compilation errors for the missing classes. + +- [ ] **Step 3: Implement `ContentCheck` and `ExampleCompiler` per Interfaces; wire `check` in `Main`.** + +- [ ] **Step 4: Run tests to verify they pass** (`cd docs/tools && mvn -q test`): all pass. + +- [ ] **Step 5: Run the check against the scaffold** + +Run: `npx -y pnpm@10.34.2 --dir docs run tools:build && npx -y pnpm@10.34.2 --dir docs run check; echo exit=$?` +Expected: exit 1 with `FAIL events`, `FAIL settings`, `FAIL builder`, `FAIL exceptions`, `FAIL extensions`, `FAIL readme`, and `PASS discord`, `PASS examples` (zero fences, canary flagged). + +- [ ] **Step 6: Commit** + +```bash +git add docs/tools +git commit -m "docs: add content coverage and example compilation checks" +``` + +### Task 4: Start here pages + +**Files:** Modify `docs/src/content/docs/index.md`; create/replace `getting-started/install.md`, `getting-started/connection.md`. + +**Interfaces:** Consumes README `Introduction`, `Getting started` (Maven and Gradle snippets, the three-line connect example), `examples/ConnectionExample.java`, `LiveClient`/`LiveClientBuilder`/`EventsBuilder` sources, `TikTokConnectedEvent`/`TikTokDisconnectedEvent`/`TikTokReconnectingEvent`/`TikTokErrorEvent`/`TikTokPreConnectionEvent` classes, `LiveClientSettings` reconnect fields. Produces root headings `#prerequisites`, `#install`, `#listen-to-a-livestream`, `#next-steps`; the root page carries at least three distinct Euler Stream anchors (`Euler Stream`, `TikTok LIVE signing API`, `TikTok LIVE WebSocket API`) and a Signing section naming `apiKey` and `useEulerstreamWebsocket`. + +- [ ] **Step 1: Write the three pages** (Install: JitPack repository block for Maven and Gradle, both coordinates, Java 16+; Connection: `build()` vs `buildAndConnect()` vs `buildAndConnectAsync()`, `connect()`/`connectAsync()`/`disconnect()` on `LiveClient`, retry settings, the four lifecycle events, `getRoomInfo()`). +- [ ] **Step 2: Verify**: `npx -y pnpm@10.34.2 --dir docs run check | grep -E '^(PASS|FAIL) (examples|discord)'` → both PASS; `npx -y pnpm@10.34.2 --dir docs exec astro build 2>&1 | grep -E 'links are valid|error'` → valid (add one-sentence placeholders for `/configuration/client-settings/`, `/configuration/signing/`, `/events/`, `/guides/gifts/` only if linked and missing; list them in the report). +- [ ] **Step 3: Commit** `docs: write Getting Started, Install, and connection lifecycle pages`. + +### Task 5: Configuration pages + +**Files:** Create `configuration/client-settings.md`, `http-settings.md`, `signing.md`, `authentication.md`, `proxies-and-host.md`, `logging.md` (frontmatter `sidebar.order` 1–6 in that order). + +**Interfaces:** Consumes `ContentCheck.settingsFields` (every `LiveClientSettings` and `HttpClientSettings` field must appear in backticks; get the list with `java -jar docs/tools/target/docs-tools.jar check ..` output or a one-off JShell call), `examples/CustomizationExample.java`, `ProxyExample.java`, `Client/.../websocket/euler/*` for the Euler Stream WebSocket path. Produces: `client-settings.md` has one table row per `LiveClientSettings` field (name, type, default, effect); `http-settings.md` one per `HttpClientSettings` field; `signing.md` carries two distinct Euler Stream anchors. + +- [ ] **Step 1: Write the six pages.** +- [ ] **Step 2: Verify**: `check` → `PASS settings`, `PASS examples`, `PASS discord`; build links valid. +- [ ] **Step 3: Commit** `docs: write configuration pages`. + +### Task 6: Event catalog and grouped event pages + +**Files:** Replace `events/index.md`; create `events/control.md`, `chat-and-audience.md`, `gifts-and-goals.md`, `battles-and-cohosting.md`, `subscriptions-and-fans.md`, `room-and-display.md`, `rankings.md`, `moderation-and-access.md`, `games-and-commerce.md`, `stream-control.md`, `debugging.md` (orders 0–11). + +**Interfaces:** Consumes `ContentCheck.events` (78 names with categories), the spec's grouping list (7.1), README per-event sections for descriptions, each event class's fields (Lombok getters) and, where an event wraps a proto message, the message class name for the schema link `/schema///` (find the file by `find docs/src/content/docs/schema -name '.md'`). Produces exactly one `## TikTokXEvent` heading per event (slug `#tiktokxevent`), catalog rows per the Task 3 contract, Java examples for `TikTokCommentEvent`, `TikTokGiftEvent`, `TikTokGiftComboEvent`, `TikTokLikeEvent`, `TikTokConnectedEvent`, `TikTokDisconnectedEvent` (fragments using `TikTokLive.newClient(...)`). + +- [ ] **Step 1: Write `events/index.md`** (intro with the `onX` fluent handler and the `@TikTokEventObserver` listener forms, counts, `## Find an event` with one sub-table per page). +- [ ] **Step 2: Write the eleven grouped pages.** +- [ ] **Step 3: Verify**: `check` → `PASS events`, `PASS readme`, previous sections still PASS; `generate` then `astro build` → links valid (schema links resolve). +- [ ] **Step 4: Commit** `docs: write the event catalog and grouped event pages`. + +### Task 7: Guides + +**Files:** Create `guides/gifts.md`, `listeners.md`, `custom-events.md`, `recorder.md`, `collector.md`, `offline-testing.md`, `debugging.md` (orders 1–7). + +**Interfaces:** Consumes `examples/*.java` (one per guide), `extension-recorder` and `extension-collector` sources (`TikTokLiveRecorder`, `TikTokLiveCollector`, `DataCollector`), `TikTokEventListenerBase`, `@TikTokEventObserver`, `LiveClientSettings.offline`, `Events_And_Gifts_Testing_Example.java`. Produces `PASS extensions` (both extension class names in backticks) and at least one compiling Java example per guide (full listener classes compile as units). + +- [ ] **Step 1: Write the seven guides.** - [ ] **Step 2: Verify** `check` → `PASS extensions`, `PASS examples`; build links valid. - [ ] **Step 3: Commit** `docs: write guides`. + +### Task 8: Advanced, troubleshooting, contributing, hosting; full green build + +**Files:** Create `advanced/http-client.md`, `advanced/websocket.md`, `advanced/protocol.md`; replace `troubleshooting.md`, `contributing.md`, `hosting.md`; create `docs/README.md`; add a `Documentation` section to `collaboration.md`. + +**Interfaces:** Consumes `ContentCheck.exceptions` (all 11 exception classes in backticks on troubleshooting or advanced pages), `Client/.../http/*`, `Client/.../websocket/*`, `api/mappers/*`, the Python sibling's `troubleshooting.md`, `contributing.md`, `hosting.md`, `docs/README.md` as templates, and the plan's Tasks 9 and 11 for the hosting facts (hostname, Worker name, workflow shape, secrets, scheduled upstream checkout, SEO gate). Produces the first fully green build. + +- [ ] **Step 1: Write the pages** (contributing covers: JDK 17+, Maven, Node 24, pnpm 10.34.2, `pnpm --dir docs tools:build | check | generate | build | dev`, fence rules including fragment wrapping, no Discord, Euler links, generated dirs never committed, the eight check sections, fact sources). +- [ ] **Step 2: Full build**: `npx -y pnpm@10.34.2 --dir docs build 2>&1 | grep -E '^(PASS|FAIL)|Checked|links are valid|page\(s\) built'` → all eight `PASS`, summary line, links valid, pages above 500. +- [ ] **Step 3: Commit** `docs: write advanced, troubleshooting, contributing, and hosting pages`. + +### Task 9: SEO gate (Node) + +**Files:** Create `docs/scripts/verify-seo.mjs`, `docs/scripts/verify-seo.test.mjs`; add `package.json` scripts `seo` (`node scripts/verify-seo.mjs dist`) and `test` (`node --test scripts/`). + +**Interfaces:** `verifySeo(buildDir) -> { passes: string[], failures: string[] }` and a CLI exiting 1 on failures; `BASE_URL = 'https://ttlj.docs.isaackogan.com/'`; checks identical to the Python sibling's `scripts/seo/verify_seo.py`: homepage `` contains `TikTok LIVE API`; every page (`**/index.html` plus `404.html`) has `rel="canonical"` with its own URL (`BASE_URL + dir + '/'`, `404.html` → `404/`); homepage `og:title`, `og:description`, `og:image`, meta description; `sitemap-index.xml` and `sitemap-0.xml` exist, the latter uses `BASE_URL` with more than one `<loc>`; `robots.txt` names the sitemap; no eulerstream link has `rel="nofollow"`; every page has at least one eulerstream link; homepage has at least three distinct eulerstream anchor texts (whitespace-collapsed, lowercased); every page contains `class="sidebar-entries`. + +- [ ] **Step 1: Write failing tests** (`node:test`): `gatePassesOnFixtureSite`, `failsWhenNestedPageLacksEulerLink`, `failsWhenRobotsHasNoSitemap`, `failsWhenCanonicalPointsAtAnotherPage`, with a `writeFixtureSite(dir, overrides)` helper. +- [ ] **Step 2: Run to verify they fail** (`npx -y pnpm@10.34.2 --dir docs test`). - [ ] **Step 3: Implement.** - [ ] **Step 4: Tests pass; then `npx -y pnpm@10.34.2 --dir docs run seo` on the real build prints only PASS lines.** If the homepage title check fails, add the `head` title override on `index.md` as the Python sibling does (`TikTok LIVE API for Java: TikTokLiveJava Documentation`). +- [ ] **Step 5: Commit** `docs: add the SEO gate for the built site`. + +### Task 10: README + +**Files:** Modify `README.md`. + +**Interfaces:** Consumes event heading slugs from Task 6 (`/events/<page>/#tiktokxevent`) and `ContentCheck` `readme` section. Produces: a documentation line after the introduction and a `Documentation` entry at the top of the events/contents area pointing at `https://ttlj.docs.isaackogan.com/`; every `- [onX](#onx-tiktokxevent)` item in the events list keeps its in-page anchor and gains a trailing ` · [docs](https://ttlj.docs.isaackogan.com/events/<page>/#tiktokxevent)` link; each `## onX [TikTokXEvent](<github source url>)` heading's source URL gains the missing `api/` path segment (they 404 today) and a following line `Documentation: https://ttlj.docs.isaackogan.com/events/<page>/#tiktokxevent`; no relative links remain. Structure and copy otherwise unchanged. + +- [ ] **Step 1: Edit.** - [ ] **Step 2: Verify** with a one-off audit (every `ttlj.docs.isaackogan.com` link maps to `docs/dist/<path>/index.html` containing `id="<anchor>"`; every GitHub source link resolves via `gh api repos/jwdeveloper/TikTokLiveJava/contents/<path>` returning 200; `check` → `PASS readme`). - [ ] **Step 3: Commit** `docs: point the README at the hosted documentation`. + +### Task 11: Worker config and workflow + +**Files:** Create `docs/wrangler.jsonc`, `.github/workflows/docs.yml`. + +**Interfaces:** +- `wrangler.jsonc`: `name tiktoklivejava-docs`, `compatibility_date 2026-10-20`, assets `./dist`, `auto-trailing-slash`, `404-page`, `workers_dev false`, route `ttlj.docs.isaackogan.com` `custom_domain true`. +- `docs.yml`: name `Documentation`; triggers `pull_request` and `push` to `master` with paths `docs/**`, `API/**`, `Client/**`, `extension-recorder/**`, `extension-collector/**`, `README.md`, `.github/workflows/docs.yml`; `workflow_dispatch`; `schedule: - cron: '17 6 * * *'`; `permissions: contents: read`; concurrency `docs-${{ github.ref }}`. Job `build` (env `DEPLOY_ENABLED: ${{ secrets.CLOUDFLARE_API_TOKEN != '' }}`): checkout, where `with.repository` is `jwdeveloper/TikTokLiveJava` and `ref: master` **only when** `github.event_name == 'schedule'` (use two checkout steps with opposite `if`s); `actions/setup-java@v4` (`temurin`, `17`, `cache: maven`); `mvn -q -pl API,Client,extension-recorder,extension-collector -am -DskipTests package`; `actions/setup-node@v4` (24); `npm install --global pnpm@10.34.2`; `pnpm --dir docs install --frozen-lockfile`; `pnpm --dir docs test`; `pnpm --dir docs build`; `pnpm --dir docs run seo`; `actions/upload-artifact@v4` (`documentation`, `docs/dist`); deploy `cloudflare/wrangler-action@v4` (`apiToken`, `accountId`, `workingDirectory: docs`, `packageManager: pnpm`, `command: deploy`) with `if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/master' || github.event_name == 'schedule') && env.DEPLOY_ENABLED == 'true'`. +- Validation: `npx -y js-yaml .github/workflows/docs.yml > /dev/null` parses; `grep -c 'repository: jwdeveloper/TikTokLiveJava' .github/workflows/docs.yml` prints 1 and that line sits in the checkout step whose `if` is `github.event_name == 'schedule'` (the other checkout step's `if` is `github.event_name != 'schedule'` and has no `repository` input); `cd docs && npx -y pnpm@10.34.2 exec wrangler deploy --dry-run --outdir /tmp/wrangler-dry` succeeds. + +- [ ] **Step 1: Write both files.** - [ ] **Step 2: Validate as above; reconcile `hosting.md`, `contributing.md`, `docs/README.md` with the real files.** - [ ] **Step 3: Commit** `docs: deploy the site to Cloudflare Workers from GitHub Actions`. + +### Task 12: Bootstrap on the fork (controller, with the maintainer) + +- [ ] **Step 1: Secrets on the fork.** The maintainer creates the four-permission token (Workers Scripts Edit, Account Settings Read, Workers Routes Edit and Zone Read on isaackogan.com). `gh secret set CLOUDFLARE_API_TOKEN -R isaackogan/TikTokLiveJava` (value via stdin) and `gh secret set CLOUDFLARE_ACCOUNT_ID -R isaackogan/TikTokLiveJava --body f00699f0017e82344b8bed18c38dab38`. +- [ ] **Step 2: Push the branch; open a fork-internal PR `docs/starlight-site` → `isaackogan:master` to get a CI run; when green, merge it with a merge commit (no squash, no force).** +- [ ] **Step 3: Watch the fork's `master` run**: `build` succeeds, `Deploy to Cloudflare Workers` ran; `curl -s -o /dev/null -w '%{http_code}' https://ttlj.docs.isaackogan.com/events/` becomes 200 within a few minutes; `/events` → 307; `/missing/` → 404. +- [ ] **Step 4: Scheduled path.** A schedule cannot be triggered by hand; the Task 11 YAML assertion is the pre-merge verification, and the first scheduled run (06:17 UTC the next day) is checked afterwards with `gh run list --workflow=docs.yml -R isaackogan/TikTokLiveJava --event schedule --limit 1`. + +### Task 13: Pull request upstream (controller, with the maintainer) + +- [ ] **Step 1:** `gh pr create -R jwdeveloper/TikTokLiveJava --base master --head isaackogan:docs/starlight-site --title "docs: Starlight documentation site at ttlj.docs.isaackogan.com" --body-file <summary>` describing the site, the gates, the hosting (maintained on the fork; upstream deploys only if the two secrets are added), and the README changes; end the body with `https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ`. +- [ ] **Step 2:** Confirm upstream's `pull_request` run validates (no deploy). Merge is the upstream maintainer's. diff --git a/docs/superpowers/specs/2026-10-20-starlight-docs-design.md b/docs/superpowers/specs/2026-10-20-starlight-docs-design.md new file mode 100644 index 00000000..4b6f7ff5 --- /dev/null +++ b/docs/superpowers/specs/2026-10-20-starlight-docs-design.md @@ -0,0 +1,125 @@ +# Starlight Documentation Site for TikTokLiveJava + +**Date:** 2026-10-20 +**Status:** Draft, awaiting approval +**Upstream:** `jwdeveloper/TikTokLiveJava` (default branch `master`); the maintainer of this effort has no push rights there. +**Working fork:** `isaackogan/TikTokLiveJava` (a real GitHub fork, in sync with upstream at `81f54d7`), local checkout `/Users/isaackogan/Documents/IdeaProjects/TikTokLiveJava`. +**Reference implementations:** Node.js site https://ttlc.docs.isaackogan.com/ (repo `zerodytrash/TikTok-Live-Connector`, `docs/`), Python site https://ttl.docs.isaackogan.com/ (repo `isaackogan/TikTokLive`, `docs/`, design at `docs/superpowers/specs/2026-10-19-starlight-docs-design.md`). + +## 1. Intent + +Give the Java library the same documentation site its Node.js and Python siblings have: an Astro Starlight site with the lucode theme, a generated API reference, content-coverage checks that fail the build when the docs drift from the code, Euler Stream SEO links, hosted on Cloudflare Workers static assets under a trademark-free hostname, deployed by GitHub Actions. The work is developed on the maintainer's fork, where the first CI run creates the Cloudflare Worker, and then offered upstream as a pull request. + +The maintainer's words: "do the same for the Java app, except … run the initial action on my personal remote and init the CF worker from there and then PR on JWDeveloper's branch." + +## 2. Current state (verified 2026-10-20) + +- Maven multi-module build: `API`, `Client`, `examples`, `tools-readme`, `extension-recorder`, `extension-collector`; Java 16 target; published through JitPack (`jitpack.yml`); version `1.13.1-Release`. Local toolchain: JDK 21, Maven 3.9.12. CI: one workflow, `maven-publish.yml` (manual dispatch, JDK 16 Corretto). +- No documentation site, no GitHub Pages (`has_pages: false`), a GitHub wiki exists. README (758 lines) is the only documentation: introduction, getting started, an events list, per-event examples, extensions, listeners, contributing. It links the Euler Stream quickstart and the Discord invite `discord.gg/e2XwPNTBBr`. +- The `tools-readme` module once generated the README (`ReadmeGenerator`, `EventsInfoGenerator`, templates under `tools-readme/src/main/resources/`), but the checked-in README differs from the generator's `output.md` by 378 diff lines: the README is hand-maintained now (the maintainer's own PR #158 revised it directly). `tools-readme` is left untouched. +- Protobuf definitions ship in the repository: `API/src/main/proto/webcast.proto`, `data.proto`, `enums.proto`; the Java message classes are generated at build time. Listener handler methods are marked with `@TikTokEventObserver`; `@EventMeta` annotates event classes. +- Public surface: entry point `TikTokLive.newClient(...)` returning a `LiveClientBuilder` with fluent `on<Event>` handlers; `LiveClientSettings` (16 fields such as `offline`, `fetchGifts`, `retryOnConnectionFailure`, `retryConnectionTimeout`, `pingInterval`, `useEulerstreamWebsocket`, `sessionId`, `apiKey`, `hostName`, `httpSettings`), `HttpClientSettings`; 78 event classes under `io.github.jwdeveloper.tiktok.api.data.events` in sub-packages `common`, `control`, `envelop`, `gift`, `http`, `link`, `poll`, `room`, `social`, `websocket`; listener base `TikTokEventListenerBase` with annotated handler methods; data models under `api.data.models` and `api.data.dto`; exceptions under `api.exceptions`; extensions (`extension-recorder`, `extension-collector`). +- Javadoc coverage is thin: 32 of 154 API source files carry a `/** */` comment. +- `ttlj.docs.isaackogan.com` has no DNS record; zone `isaackogan.com` is on Cloudflare in the maintainer's account, which already hosts the two sibling Workers. + +## 3. Goals + +1. A Starlight site in `docs/` mirroring the siblings' structure, theme, header (GitHub and Discord buttons), footer, favicon, Getting Started root page, and Euler Stream link profile. +2. A generated **API reference** from the Java sources of `API` and `Client` (signatures, fields, Javadoc where present, including `api.data.models` and `api.data.dto`) and a **Protobuf schema** reference generated from the three `.proto` files (every message and enum with its fields), regenerated on every build and never committed. +3. A **content check** that fails the build when any event class, `LiveClientSettings` or `HttpClientSettings` field, `LiveClientBuilder` handler method, exception class, or extension is undocumented, and that compiles every Java example in the docs against the library. +4. **Hosting** on Cloudflare Workers static assets at **https://ttlj.docs.isaackogan.com/** (my proposal; see open question 1), Worker `tiktoklivejava-docs`, deployed by `docs.yml` on pushes to `master`, validated on pull requests, gated on the `CLOUDFLARE_API_TOKEN` secret so a repository without the secret still validates. +5. **Bootstrapped from the fork:** the first deployment is performed by the workflow running in `isaackogan/TikTokLiveJava` (secrets set there), which creates the Worker and attaches the custom domain; no deploy is run from a laptop. +6. **README** links point at the hosted site, made through the `tools-readme` template if the README is still generated from it. +7. A pull request from the fork's branch to `jwdeveloper/TikTokLiveJava:master` carrying everything, with a description the upstream maintainer can act on. + +## 4. Non-goals + +- Changing library behaviour or adding Javadoc beyond what a generated page needs to be readable. +- Replacing or restructuring `tools-readme`; the README keeps its structure and only gains links and corrections. +- Publishing anything to JitPack or Maven Central; the docs tooling never enters the root reactor's published modules. +- Documenting the wiki or migrating it. +- Internationalisation, blog, versioned docs. + +## 5. Anti-goals + +- No "tiktok" in any public hostname. The Worker name is internal. +- No docs dependencies in the published artifacts: nothing enters `API/pom.xml` or `Client/pom.xml`; docs tooling lives under `docs/` with its own build. +- No generated content in Git. +- No Discord link in page text; the header button is the only Discord link. + +## 6. Constraints + +- Same pinned web stack as the siblings: `astro 7.4.1`, `@astrojs/starlight 0.42.6`, `lucode-starlight 1.0.0`, `starlight-links-validator 0.27.0`, `wrangler 4.151.2`, `pnpm@10.34.2`, Node 24 in CI. +- Java tooling in CI: JDK 17 (Temurin) to build the library modules with the project's Java 16 target and to run the docs tools; Maven with dependency caching. +- The upstream `maven-publish.yml` is untouched. `jitpack.yml` is untouched. +- Work on branch `docs/starlight-site` in the local checkout, pushed to the fork `origin`. Nothing is force-pushed. The upstream remote is added as `upstream` for syncing only. + +## 7. Design + +### 7.1 Site structure (mirrors the siblings) + +- **Start here**: Getting Started (root `/`), Install (JitPack repository and coordinates for Maven and Gradle, Java 16 or newer, the two JitPack group names that both resolve), Connection lifecycle (`connect`, `connectAsync`, reconnect settings, `TikTokConnectedEvent`/`TikTokDisconnectedEvent`/`TikTokReconnectingEvent`/`TikTokErrorEvent`). There is no Migration page: the README carries no migration notes and GitHub releases are the only changelog (builder-check question 4, my call). +- **Configuration**: Client settings (`LiveClientSettings`), HTTP settings (`HttpClientSettings`), Signing (Euler Stream, `apiKey`, `useEulerstreamWebsocket`), Authentication (`sessionId`, `ttTargetIdc`), Proxies and host (`hostName`, `httpSettings`), Logging (`printToConsole`, `logLevel`). +- **Guides**: Gifts and combos (`TikTokGiftEvent`, `TikTokGiftComboEvent`), Listeners (`TikTokEventListenerBase`, annotated handlers), Custom events and mappings (`CustomEventExample`, `CustomMappingExample`), Recorder and Collector extensions, Offline testing (`offline`, `Events_And_Gifts_Testing_Example`), Debugging (`TikTokWebsocketResponseEvent`, `TikTokWebsocketUnhandledMessageEvent`, `TikTokHttpResponseEvent`). +- **Events**: catalog with one row per event class, columns ``| `TikTokXEvent` | type | description |`` where `type` is the `@EventMeta` category (`Control`, `Message`, or `Debug`) read from the class, and grouped pages **by theme using the siblings' page names** (builder-check question 1, my call; 32 of the 78 events sit directly in `api.data.events` and `link/` alone holds 23, so sub-packages cannot be the grouping): `control` (connecting, connected, disconnected, reconnecting, error, pre-connection), `chat-and-audience` (comment, emote, join, like, follow, share, question, barrage, caption), `gifts-and-goals` (gift, gift combo, goal update, envelope, chest), `battles-and-cohosting` (every `TikTokLink*` event and `TikTokLinkMic*`), `subscriptions-and-fans` (subscribe, sub notify), `room-and-display` (room info, room pin, in-room banner, header, poll events), `rankings` (rank text, rank update), `moderation-and-access` (IM delete, unauthorized member, detect, unhandled member/social/control), `games-and-commerce` (shop), `stream-control` (live ended, paused, unpaused), `debugging` (websocket message/response/unhandled, http response, empty, unhandled). Each event gets one `## TikTokXEvent` heading with description, key fields, and a short Java example where behaviour is non-obvious. +- **Advanced**: HTTP client and mappers, WebSocket layer, Protocol notes (what the library maps from TikTok's messages). +- **Troubleshooting**, **Contributing to the docs**, **Hosting**. +- **API reference** (generated), **Protobuf schema** (generated). + +Header: nav links Docs, Events, API; social links GitHub (`https://github.com/jwdeveloper/TikTokLiveJava`) and Discord (open question 2). Footer: the unofficial disclaimer with an Euler Stream link and a GitHub link. Favicon: the same `favicon.png` as the siblings. + +### 7.2 Generated references (my call) + +A standalone Maven project `docs/tools/` (its own `pom.xml`, not a module of the root reactor) containing one Java program with two commands: + +- `generate`: parses `API/src/main/java` and `Client/src/main/java` with **JavaParser** (no compilation needed) and writes Starlight Markdown: `src/content/docs/api/` with one page per public class, interface, enum, and record (signature, Javadoc summary when present, fields, constructors, methods with parameter and return types, extends/implements, source link to GitHub `blob/master/<path>#L<line>`), grouped by package directory so the sidebar groups by package. It also parses `API/src/main/proto/*.proto` (a small hand-written parser is enough for proto3 messages, enums, and nested types) and writes `src/content/docs/schema/` with one page per message and enum, titled `TikTok Webcast schema (<library version>)`. Pages are files (`<slug>.md`), package index pages are `<package>/index.md`, output is deterministic (sorted), and both directories are wiped before writing. +- `check`: the content check in 7.3. + +Why JavaParser rather than a Javadoc doclet: it needs no compile of the library, tolerates the thin Javadoc, and gives full control of the Markdown layout, matching what `starlight-typedoc` and griffe produced for the siblings. The builder may switch to a doclet if JavaParser cannot resolve something the pages need, keeping the output contract. + +### 7.3 Content check + +`docs/tools` `check` runs before the Astro build and exits non-zero on any failing section, printing `PASS <section>` / `FAIL <section>: …` per section: `events` (every class under `api/data/events` appears in backticks on an authored page and has a catalog row whose `type` cell equals its `@EventMeta` category), `settings` (every field of `LiveClientSettings` and `HttpClientSettings`), `builder` (every public `on*` handler method declared on `EventsBuilder<T>`, the super-interface of `LiveClientBuilder`, and every public method of `LiveClient`), `exceptions` (every class under `api/exceptions`), `extensions` (both extension modules named), `discord` (no `discord.com`/`discord.gg` in authored pages), `readme` (every handler the README's events list names has a matching event class and a catalog row; the README carries no descriptions to compare), `examples` (every ```` ```java ```` fence in authored pages compiles with `javax.tools.JavaCompiler` against the `API`, `Client`, `extension-recorder`, and `extension-collector` classes: a fence containing a top-level `class`, `interface`, `record`, or `enum` declaration is compiled as-is; any other fence is a **fragment** and is wrapped in a synthetic class with wildcard imports of every `io.github.jwdeveloper.tiktok` package and its statements placed in a method body, so README-style snippets stay short (builder-check question 3, my call); a known-bad canary must fail so the gate cannot pass vacuously). `pnpm --dir docs build` runs `check`, then `generate`, then `astro build`. + +### 7.4 Hosting and deployment + +- `docs/wrangler.jsonc`: Worker `tiktoklivejava-docs`, `assets.directory ./dist`, `auto-trailing-slash`, `404-page`, `workers_dev false`, custom domain route `ttlj.docs.isaackogan.com`. +- `.github/workflows/docs.yml`: on `pull_request` and `push` to `master` (paths `docs/**`, `API/**`, `Client/**`, `extension-*/**`, `README.md`, `tools-readme/**`, `.github/workflows/docs.yml`) and `workflow_dispatch`; JDK 17 with Maven cache; `mvn -q -pl API,Client -am -DskipTests install` so the docs tools can compile examples against the library; Node 24 and pnpm 10.34.2; `pnpm --dir docs install --frozen-lockfile`; `pnpm --dir docs build`; SEO gate; upload the artifact; deploy with `cloudflare/wrangler-action@v4` when `github.ref == 'refs/heads/master'`, the event is not a pull request, and `DEPLOY_ENABLED` (derived from the `CLOUDFLARE_API_TOKEN` secret) is true. +- **Bootstrap from the fork:** the branch is first merged into the fork's `master` (fork-local; upstream unaffected) with the secrets set on the fork, so the fork's first `master` run creates the Worker and attaches the domain. That first run needs a token that can create Workers and routes: Account → Workers Scripts → Edit, Account → Account Settings → Read, Zone → Workers Routes → Edit and Zone → Zone → Read on `isaackogan.com` (the same four-permission recipe as the Node site's first token). After the Worker exists the token can be replaced by one scoped to Editor on `tiktoklivejava-docs`, since later deploys only reconcile an existing domain. +- **After the upstream merge** (open question 3): upstream squash-merges, so the fork's `master`, which already carries the docs commits, can never fast-forward to upstream `master` (builder-check question 2). Therefore no sync-by-push exists. If the fork keeps deploying, `docs.yml` gains a `schedule` (daily) and `workflow_dispatch` path that checks out `jwdeveloper/TikTokLiveJava` at `master` with `actions/checkout` (`repository:` and `ref:` inputs; the repo is public), builds, and deploys from that checkout without pushing anything to the fork; the push-triggered path keeps deploying the fork's own `master`. If instead the upstream maintainer adds the secrets, the same workflow deploys from upstream directly and the fork's scheduled path can be disabled. No force pushes anywhere. + +### 7.5 SEO + +Same profile as the siblings: one sitewide dofollow Euler Stream footer link plus contextual links with distinct anchors on the Getting Started and Signing pages; `sitemap-index.xml`, `robots.txt` with the sitemap line, canonical on every page, Open Graph tags; a `docs/scripts/verify_seo.py`-equivalent is written as a Node script (`docs/scripts/verify-seo.mjs`) so the Java repo needs no Python; it asserts the same invariants as the Python gate and runs in CI after the build. No Search Console file exists for this project; the maintainer adds the property by DNS verification. + +### 7.6 README + +The README is hand-maintained (section 2), so it is edited directly and `tools-readme` is not touched. Changes: a documentation line and table-of-contents entry pointing at the site, every event name in the events list linked to its hosted anchor, per-event example headings linking to the hosted event pages in addition to the GitHub source link they carry today, no relative links. Structure and copy otherwise unchanged. + +### 7.7 Repository changes + +`docs/` (Starlight project: `astro.config.mjs`, `package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `tsconfig.json`, `wrangler.jsonc`, `public/`, `scripts/verify-seo.mjs`, `src/content/docs/**`, `README.md`), `docs/tools/` (Maven project with the generator and check plus unit tests), `.github/workflows/docs.yml`, `.gitignore` additions (`docs/node_modules/`, `docs/.astro/`, `docs/dist/`, `docs/src/content/docs/api/`, `docs/src/content/docs/schema/`, `docs/tools/target/`), README (direct edits; its per-event source links currently 404 because they lack the `api/` path segment and are corrected in passing), a `Documentation` section in `collaboration.md` (the repo's contributing file). The build of `extension-recorder` and `extension-collector` is included in CI so the Recorder and Collector guides' examples compile. + +## 10. Builder-check resolutions (2026-10-20) + +1. Event grouping: by theme with the siblings' page names, catalog `type` column from `@EventMeta` (7.1, 7.3). +2. Fork sync after a squash merge: scheduled checkout of upstream `master` inside `docs.yml`, no pushes (7.4). +3. Java example fences: fragments are wrapped by the checker; full compilation units compile as-is (7.3). +4. Migration page dropped in favour of an Install page (7.1). +Accepted builder defaults: extensions on the example classpath; `readme` check reduced to handler existence; handlers read from `EventsBuilder<T>`; ignore `api/` and `schema/`; README source links fixed. + +## 8. Left to the builder + +- JavaParser page layout details; how generics, Lombok-generated accessors (`@Getter`/`@Setter` on settings classes), and records render. +- Prose for every page, sourced from the README, `examples/`, and the sources. +- Grouping of 78 events into the ten sub-package pages and the catalog descriptions (the README describes a subset; the rest are authored from the event classes). +- The exact `check` implementation and its test fixtures. +- Whether `docs/tools` is invoked through `mvn exec:java` or a shaded jar. + +## 9. Decisions (the maintainer delegated these on 2026-10-20: "decide yourself based on the most reasonable") + +1. **Hostname:** `ttlj.docs.isaackogan.com`. +2. **Discord button:** the README's `https://discord.gg/e2XwPNTBBr`. +3. **Deploys after the upstream merge:** the fork keeps deploying. The docs commits are merged into the fork's `master` (a normal merge, no force push) so `docs.yml` lives on the fork's default branch; its scheduled path checks out upstream `master` and deploys from it. The upstream copy of the workflow is gated on the secret and only validates there until jwdeveloper chooses to add secrets. +4. **First-deploy token:** the fork's first `master` run creates the Worker, so the fork's `CLOUDFLARE_API_TOKEN` is the four-permission token (Workers Scripts Edit, Account Settings Read, Workers Routes Edit and Zone Read on isaackogan.com); the maintainer creates it when the bootstrap task asks and may swap it for a Worker-scoped Editor token afterwards. +5. **PR target:** `jwdeveloper/TikTokLiveJava:master` from `isaackogan:docs/starlight-site`. From 14a85fa52549f9145c78165a97b1635854c47bbe Mon Sep 17 00:00:00 2001 From: isaackogan <isaacikogan@gmail.com> Date: Tue, 20 Oct 2026 14:58:53 -0400 Subject: [PATCH 02/31] docs: scaffold the Starlight documentation site Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- .gitignore | 9 +- docs/astro.config.mjs | 68 + docs/package.json | 28 + docs/pnpm-lock.yaml | 4733 +++++++++++++++++ docs/pnpm-workspace.yaml | 7 + docs/public/favicon.png | Bin 0 -> 2684 bytes docs/public/robots.txt | 4 + docs/src/content.config.ts | 8 + docs/src/content/docs/404.md | 8 + docs/src/content/docs/contributing.md | 6 + docs/src/content/docs/events/index.md | 6 + .../docs/getting-started/connection.md | 6 + .../content/docs/getting-started/install.md | 6 + docs/src/content/docs/hosting.md | 6 + docs/src/content/docs/index.md | 6 + docs/src/content/docs/troubleshooting.md | 6 + docs/tsconfig.json | 5 + 17 files changed, 4911 insertions(+), 1 deletion(-) create mode 100644 docs/astro.config.mjs create mode 100644 docs/package.json create mode 100644 docs/pnpm-lock.yaml create mode 100644 docs/pnpm-workspace.yaml create mode 100644 docs/public/favicon.png create mode 100644 docs/public/robots.txt create mode 100644 docs/src/content.config.ts create mode 100644 docs/src/content/docs/404.md create mode 100644 docs/src/content/docs/contributing.md create mode 100644 docs/src/content/docs/events/index.md create mode 100644 docs/src/content/docs/getting-started/connection.md create mode 100644 docs/src/content/docs/getting-started/install.md create mode 100644 docs/src/content/docs/hosting.md create mode 100644 docs/src/content/docs/index.md create mode 100644 docs/src/content/docs/troubleshooting.md create mode 100644 docs/tsconfig.json diff --git a/.gitignore b/.gitignore index 6265f1e2..c674cdd0 100644 --- a/.gitignore +++ b/.gitignore @@ -246,4 +246,11 @@ $RECYCLE.BIN/ # End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode /python-app-backend/samconfig.toml -/java-app-backend/BackendFunction/dependency-reduced-pom.xml \ No newline at end of file +/java-app-backend/BackendFunction/dependency-reduced-pom.xml +# Starlight documentation site (docs/) +docs/node_modules/ +docs/.astro/ +docs/dist/ +docs/src/content/docs/api/ +docs/src/content/docs/schema/ +docs/tools/target/ diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs new file mode 100644 index 00000000..ab304212 --- /dev/null +++ b/docs/astro.config.mjs @@ -0,0 +1,68 @@ +import { defineConfig } from 'astro/config'; +import starlight from '@astrojs/starlight'; +import starlightLinksValidator from 'starlight-links-validator'; +import lucode from 'lucode-starlight'; +import { visit } from 'unist-util-visit'; +import { unified } from '@astrojs/markdown-remark'; + +const base = `/${(process.env.DOCS_BASE || '').replace(/^\/+|\/+$/g, '')}/`.replace('//', '/'); + +// Authored Markdown uses root-relative links; prefix them with the deploy base (a no-op when base is '/'). +function prefixLinks() { + return (tree) => visit(tree, 'link', (node) => { + if (node.url.startsWith('/') && !node.url.startsWith('//') && !node.url.startsWith(base)) { + node.url = `${base}${node.url.slice(1)}`; + } + }); +} + +export default defineConfig({ + site: process.env.DOCS_SITE || 'https://ttlj.docs.isaackogan.com', + base, + trailingSlash: 'always', + markdown: { processor: unified({ remarkPlugins: [prefixLinks] }) }, + integrations: [ + starlight({ + title: 'TikTokLiveJava', + description: 'Unofficial TikTok LIVE API for Java. Guides, events, and generated API reference.', + favicon: '/favicon.png', + head: [ + { tag: 'meta', attrs: { property: 'og:image', content: 'https://raw.githubusercontent.com/jwdeveloper/TikTokLiveJava/master/tools-readme/src/main/resources/logo.svg' } }, + { tag: 'meta', attrs: { property: 'og:image:alt', content: 'TikTokLiveJava, TikTok LIVE API for Java' } }, + { tag: 'meta', attrs: { name: 'twitter:card', content: 'summary_large_image' } }, + ], + social: [ + { icon: 'github', label: 'GitHub', href: 'https://github.com/jwdeveloper/TikTokLiveJava' }, + { icon: 'discord', label: 'Discord', href: 'https://discord.gg/e2XwPNTBBr' }, + ], + editLink: { baseUrl: 'https://github.com/jwdeveloper/TikTokLiveJava/edit/master/docs/' }, + sidebar: [ + { label: 'Start here', items: [ + { label: 'Getting Started', slug: '' }, + { label: 'Install', slug: 'getting-started/install' }, + { label: 'Connection lifecycle', slug: 'getting-started/connection' }, + ] }, + { label: 'Configuration', items: [{ autogenerate: { directory: 'configuration' } }] }, + { label: 'Guides', items: [{ autogenerate: { directory: 'guides' } }] }, + { label: 'Events', items: [{ autogenerate: { directory: 'events' } }], collapsed: true }, + { label: 'Advanced', items: [{ autogenerate: { directory: 'advanced' } }], collapsed: true }, + { label: 'Troubleshooting', slug: 'troubleshooting' }, + { label: 'Contributing to the docs', slug: 'contributing' }, + { label: 'Hosting', slug: 'hosting' }, + { label: 'API reference', items: [{ autogenerate: { directory: 'api' } }], collapsed: true }, + { label: 'Protobuf schema', items: [{ autogenerate: { directory: 'schema' } }], collapsed: true }, + ], + plugins: [ + lucode({ + navLinks: [ + { label: 'Docs', link: base }, + { label: 'Events', link: `${base}events/` }, + { label: 'API', link: `${base}api/` }, + ], + footerText: 'Unofficial project, not affiliated with TikTok or ByteDance Ltd. Signing by [Euler Stream](https://www.eulerstream.com/). Source on [GitHub](https://github.com/jwdeveloper/TikTokLiveJava).', + }), + starlightLinksValidator(), + ], + }), + ], +}); diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 00000000..8a04bfbf --- /dev/null +++ b/docs/package.json @@ -0,0 +1,28 @@ +{ + "name": "tiktoklivejava-docs", + "private": true, + "type": "module", + "packageManager": "pnpm@10.34.2", + "engines": { + "node": ">=22.22.0" + }, + "scripts": { + "dev": "astro dev --host 127.0.0.1", + "tools:build": "mvn -q -f tools/pom.xml -DskipTests package", + "check": "java -jar tools/target/docs-tools.jar check ..", + "generate": "java -jar tools/target/docs-tools.jar generate ..", + "build": "pnpm run tools:build && pnpm run check && pnpm run generate && astro build", + "preview": "astro preview --host 127.0.0.1", + "deploy:cloudflare": "wrangler deploy" + }, + "devDependencies": { + "@astrojs/markdown-remark": "7.3.3", + "@astrojs/starlight": "0.42.6", + "astro": "7.4.1", + "lucode-starlight": "1.0.0", + "starlight-links-validator": "0.27.0", + "typescript": "5.9.3", + "unist-util-visit": "5.0.0", + "wrangler": "4.151.2" + } +} diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml new file mode 100644 index 00000000..2a0ad29c --- /dev/null +++ b/docs/pnpm-lock.yaml @@ -0,0 +1,4733 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@astrojs/markdown-remark': + specifier: 7.3.3 + version: 7.3.3 + '@astrojs/starlight': + specifier: 0.42.6 + version: 0.42.6(@astrojs/markdown-remark@7.3.3)(astro@7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1))(typescript@5.9.3) + astro: + specifier: 7.4.1 + version: 7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1) + lucode-starlight: + specifier: 1.0.0 + version: 1.0.0(@astrojs/starlight@0.42.6(@astrojs/markdown-remark@7.3.3)(astro@7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1))(typescript@5.9.3)) + starlight-links-validator: + specifier: 0.27.0 + version: 0.27.0(@astrojs/starlight@0.42.6(@astrojs/markdown-remark@7.3.3)(astro@7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1))(typescript@5.9.3))(astro@7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1)) + typescript: + specifier: 5.9.3 + version: 5.9.3 + unist-util-visit: + specifier: 5.0.0 + version: 5.0.0 + wrangler: + specifier: 4.151.2 + version: 4.151.2(@types/node@24.19.2) + +packages: + + '@astrojs/compiler-binding-android-arm64@0.5.2': + resolution: {integrity: sha512-kckIxon6Rm/Bxzh9mGPYpRRqUbToUQoHptCmpMmE+Yt0Xumq8CqrKUidyYhrBsrHVO7bxuRzzWUUqvpf+iYceQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@astrojs/compiler-binding-darwin-arm64@0.5.2': + resolution: {integrity: sha512-1ZG5Ar3dD8HtodX9XH1mRmE0qBXZlvjYXSFfpgFwocqfWnvhFuqtGUNseL8wgfImiRMztIjpQqlogQFQQtgiag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@astrojs/compiler-binding-darwin-x64@0.5.2': + resolution: {integrity: sha512-+kl35S7hiHJ2cb7eujwEsPFYYuSgE11uEE2Z20lAJ+undpgulMcW+weQqFPOSSZccEKHOiLIY+kQHkb4e4092w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@astrojs/compiler-binding-linux-arm64-gnu@0.5.2': + resolution: {integrity: sha512-BqlR0EHNQLPLwgL9+xogjsAc7zGf0cHho2CPdhv0P1yqbVMwGVaGAqrANP6VYAXCqSC43AF4Czcxc2n+oqdesg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-arm64-musl@0.5.2': + resolution: {integrity: sha512-GRGyby3AZWw5d6JQzidyxPyvGgHWsSusrpgKyFVpDTKctABF7L3Pd5V1cRJbV36/rcu+Eh292/wL7pF7MkmGkQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-linux-x64-gnu@0.5.2': + resolution: {integrity: sha512-9XAqGTMQAtIslMsjK3xdyY6OI9d59OZqcLBB6pvD/DcoR0daYswfrKx2azdDERDY4AqAnWPC7odVM1BmxYv3Vw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@astrojs/compiler-binding-linux-x64-musl@0.5.2': + resolution: {integrity: sha512-jOViewGeTh9bSfS8vK72ECxoy2yvmA+Pd/ZcEvBI5c7+7IcfHwZ5qA4L9fRYuOhSrAifvQ2dV4vm1XAtloaWBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@astrojs/compiler-binding-wasm32-wasi@0.5.2': + resolution: {integrity: sha512-Z7p64doar1uvR5C9Xs6j1CV9lGlPVQX7OQ2aAtZ/xKC5aGS6GB13nyc2UfQXTQLvZrvVwsyaflTQy6ziY5Bekw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@astrojs/compiler-binding-win32-arm64-msvc@0.5.2': + resolution: {integrity: sha512-Tr13bh+ahTWsjXjzctK7pFpWV+dUU4FtBfETlR7gO+ueex0F+RDch7eRr0YfKysaRhk9tWm14lsCJ5uWyFPNHQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@astrojs/compiler-binding-win32-x64-msvc@0.5.2': + resolution: {integrity: sha512-SfKkI+jOQYLh6wojPvVPV0lFfQnxisweN9B+qCzRvZp7DHAuGif8Y0WcC5PUgN4jb8VKy57LggQDtggR46Du1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@astrojs/compiler-binding@0.5.2': + resolution: {integrity: sha512-lEn76+0JcMQ5RDXU0wSMWJg2ZppiZvSULpghC29cfv4o9bhvWFDbQjsCtvjjXN9iVLFQ7fL3wTR2L2UU13mP8g==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@astrojs/compiler-rs@0.5.2': + resolution: {integrity: sha512-IvwYLB8t1M86rKO4oPdBi9GP4gzXcgrVlNgocjwos1jeeUuEIV8d9Crn1PvYD/5IKteWOxpIHomf7e8iYlkE+Q==} + engines: {node: '>=22.12.0'} + + '@astrojs/internal-helpers@0.13.0': + resolution: {integrity: sha512-20jxQqnSmpskcGyad7N5l6Xf1gz0UCa6lNde44bEBdaXGpncEYfAZJ142r2ZgzBaG4dUyzGmhqZWv1INU2eiHQ==} + + '@astrojs/markdown-remark@7.3.3': + resolution: {integrity: sha512-yC7XCJtRztiR3GiP8e0WwUAGJeKm5goMDDzcZykfbOOpQUDztBWG66fBeIOfl1uVxvhdI0iLzkeaHgqPhaRfUg==} + + '@astrojs/markdown-satteri@0.4.4': + resolution: {integrity: sha512-Byhe9CAiGFJrhMACKpgN/FPf4lcwapqwFQAB7E1sPELTEJINfBIpgnEYOBTXzr//fvSSNhfp/X3AqN4mUV9p+A==} + + '@astrojs/mdx@8.1.0': + resolution: {integrity: sha512-xzrlXrjpBYvC27MTkP+2NMiJG4Jjl2DcoNc/EF9RNxAp0dwhvXC3b22+7OO4kHJQKfISk2uy38HYp1UxX7JfSg==} + engines: {node: '>=22.22.0'} + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + '@astrojs/markdown-satteri': ^0.4.0 + astro: ^7.2.10 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + '@astrojs/prism@4.1.0': + resolution: {integrity: sha512-zvRaL3MKTAXWfVQrHAlYtocRZWXBM6ruF3Bl7Lc7QgFHLRTfwMKo33KczlkvkXDY+qcytKhAWOT9dwUUYLUxAw==} + engines: {node: '>=22.22.0'} + + '@astrojs/sitemap@3.7.4': + resolution: {integrity: sha512-LbKNC24bdUWcQf/pThB6qLlSqHojxGjZDURIzFocY8rlWnAn2t74nnhnK6S5x0NHriHoAduLEpVjRykmeGiVvA==} + + '@astrojs/starlight@0.42.6': + resolution: {integrity: sha512-Cfs7GdgzgNRCauDxQVsFUVL1thrGHK/rQdbv/SURv/xhGBXx+j5ypOHW9ENPraQxbIzUC8KAnJV7L9mvEVRuyQ==} + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + astro: ^7.2.10 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + '@astrojs/telemetry@3.4.0': + resolution: {integrity: sha512-Qt2T6RGtYacyV8OZU3IcyFbsp69s6qIcnWvQvn7/p9MKebPCpEswP5Ji4RhifG3DfeBX7e07Pjj5smkd+Tfp7Q==} + engines: {node: '>=22.22.0'} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bruits/satteri-darwin-arm64@0.10.5': + resolution: {integrity: sha512-27KTVl4TJkVahMy/ohyA7qd4938G5UNneFUz/PsScYfpIhj0IVAS23mpcJXdPF44sa6nva198lmV/cKIb2YPyA==} + cpu: [arm64] + os: [darwin] + + '@bruits/satteri-darwin-x64@0.10.5': + resolution: {integrity: sha512-IjnLe3nKspq6qaeqGgjT7MT8VrTV74yWRlaag7ZdNsI8TDAYZ0iPxMCo+9KQZHUk5EyVB+reBI/PFWL5KuFw9Q==} + cpu: [x64] + os: [darwin] + + '@bruits/satteri-linux-arm64-gnu@0.10.5': + resolution: {integrity: sha512-glkYXZCJywjP13v67eAyAMSJdF+ncvEbYvgi/wOtffL9tQ27lr/zsyzUfgs+ovjJ9d8JNQKiXeiArJcX8PJL9w==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-arm64-musl@0.10.5': + resolution: {integrity: sha512-yWdgG1g17Nh2QyGVlFUxGRa3FEFwiMcpZEyMNWkbM3deC94cmVc+/i9OuyFpdKuWo3GkgoCtYVOoxk1uCnCZIA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@bruits/satteri-linux-x64-gnu@0.10.5': + resolution: {integrity: sha512-FVaLoPT1fBgGl0J+AYebyyXJYBachGl8Oyyrf1lye4RTqCB4S0Gwkj1uM9RJyThUOvx5VUmAT1CnNh1SFHA+kw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@bruits/satteri-linux-x64-musl@0.10.5': + resolution: {integrity: sha512-EHpVAx2bqW3GINHTKkljtxVfQmVDGWIuwOYOP5YghTj+0PkBa2o8oKPRtQ9Kbsr1Fye8jtUcDjhwj2jMNugZKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@bruits/satteri-wasm32-wasi@0.10.5': + resolution: {integrity: sha512-ypz8c/Zmipxp4IoeDa228Gstv6TLzVmNs3yC6wKCoNSOjx1iwpgzu87Y3hTkXFdwChVGU85qeUDuOIarGUZQLw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@bruits/satteri-win32-arm64-msvc@0.10.5': + resolution: {integrity: sha512-siTV88nb0LRqNpkL2gXboqCwVdq95sLtzMHS1/3eONV2gLbB3NAK46wmSMvCO/yquBvI2lvaFIfd8P12ecsxBw==} + cpu: [arm64] + os: [win32] + + '@bruits/satteri-win32-x64-msvc@0.10.5': + resolution: {integrity: sha512-C3IfPvfvMXmlzBxaMPKFS1XiuV9pu2mC7YqkPk7PSvTgPZ8gbdASIpHpztDLvTTQjqZ0z1Ol8tK5X+V6XXC0wQ==} + cpu: [x64] + os: [win32] + + '@capsizecss/unpack@4.0.1': + resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} + engines: {node: '>=18'} + + '@clack/core@1.5.1': + resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.8.1': + resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} + engines: {node: '>= 20.12.0'} + + '@cloudflare/kv-asset-handler@0.5.0': + resolution: {integrity: sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==} + engines: {node: '>=22.0.0'} + + '@cloudflare/unenv-preset@2.16.2': + resolution: {integrity: sha512-JBP1+Z7ZSNG/d4mRP+y8VC5dka3tZVMLEZRvS+rzQ4DGV1EoxRFQckcJTTkXbHSQiTj0DtNI01Zwb/V2fX0mvQ==} + peerDependencies: + unenv: 2.0.0-rc.24 + workerd: '>1.20260305.0 <2.0.0-0' + peerDependenciesMeta: + workerd: + optional: true + + '@cloudflare/workerd-darwin-64@1.20261019.1': + resolution: {integrity: sha512-5ZILbAta/fBQvhmAYeCE7dKMMYIr7N9wJEX50fEGLKSIwGtvT0HzSfHNIK/O7cBQ4gHQ2WfIiPClvwcye6E0fA==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@cloudflare/workerd-darwin-arm64@1.20261019.1': + resolution: {integrity: sha512-cww2Ab6lVMxpTiEEjKmPI58p5EMo0vYTZSH+3WkqXJdQgc3ES66GuL247+U2B2YWajhhu2tqj7jySrXneU749g==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@cloudflare/workerd-linux-64@1.20261019.1': + resolution: {integrity: sha512-4++VRi9vkQROYqrpZYOixIJuVDmujkwrT4kszsxNFlvcmsX6Mr2GumvxIN3gIixS3vJUh3Umdtkthqj5X0S3Xw==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@cloudflare/workerd-linux-arm64@1.20261019.1': + resolution: {integrity: sha512-75PVtJOwfjScQ53sbLtQXI7D8yP1c21t+WEUPaYbqkzaz4ZqfR5wAd+zuWs8G5Q7kOj2SQIvEfY5Sn8Y+UVrnA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@cloudflare/workerd-windows-64@1.20261019.1': + resolution: {integrity: sha512-z49fvKTETMCnG6wnMcQwxx9CpryFceiKz04FRjq26EkhCyzRNJ6c3UXhyZz3r5f1CXnnBZc5DcU8ZouPkUJCqw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@cspotcode/source-map-support@0.8.1': + resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} + engines: {node: '>=12'} + + '@ctrl/tinycolor@4.2.1': + resolution: {integrity: sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==} + engines: {node: '>=14'} + + '@emnapi/core@1.11.1': + resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + + '@emnapi/core@1.11.3': + resolution: {integrity: sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==} + + '@emnapi/runtime@1.11.1': + resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + + '@emnapi/wasi-threads@1.2.2': + resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} + + '@emnapi/wasi-threads@1.2.3': + resolution: {integrity: sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==} + + '@esbuild/aix-ppc64@0.28.2': + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.28.2': + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.28.2': + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.28.2': + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.28.2': + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.2': + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.28.2': + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.2': + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.28.2': + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.28.2': + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.28.2': + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.28.2': + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.28.2': + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.28.2': + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.2': + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.28.2': + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.28.2': + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.28.2': + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.2': + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.28.2': + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.2': + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.28.2': + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.28.2': + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.28.2': + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.28.2': + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.28.2': + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@expressive-code/core@0.44.2': + resolution: {integrity: sha512-Yw5KonQCD1HoOO2P89y4wifqS9qrJcGVY0LkVMUsVZjhi++bhz4WwQ37inenKFNOEVo50P7cjoVsCdaVnUbq8w==} + + '@expressive-code/plugin-frames@0.44.2': + resolution: {integrity: sha512-gVLm6TQMNIRWIaM7o+mCtxkGNjLZY5/2faSTLoXeo9wv/Pkj/0QAtZyXPVPrRsPARqiipATNFmfvQFLJudxPHQ==} + + '@expressive-code/plugin-shiki@0.44.2': + resolution: {integrity: sha512-ebWIvCDzXvjyD8rH6T6DfytYJw1GoArLR4nusIAE4VxXrCQf3d+owytiv010KU/dUg+SRLZskDn0N+HaFXeuSg==} + + '@expressive-code/plugin-text-markers@0.44.2': + resolution: {integrity: sha512-SNI7EgUSiADqbqUzzMbj1ZeC/r2pa5eg3KyFOGAd3a7EsIA4qzjXQLp+ODWQtHVDKFCdGs4J3ovSJUqvKIK96g==} + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.5': + resolution: {integrity: sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.5': + resolution: {integrity: sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.5': + resolution: {integrity: sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.4': + resolution: {integrity: sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.4': + resolution: {integrity: sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.4': + resolution: {integrity: sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.4': + resolution: {integrity: sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.4': + resolution: {integrity: sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.4': + resolution: {integrity: sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.4': + resolution: {integrity: sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.4': + resolution: {integrity: sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + resolution: {integrity: sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + resolution: {integrity: sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.5': + resolution: {integrity: sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.5': + resolution: {integrity: sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.5': + resolution: {integrity: sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.5': + resolution: {integrity: sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.5': + resolution: {integrity: sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.5': + resolution: {integrity: sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.5': + resolution: {integrity: sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.5': + resolution: {integrity: sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.5': + resolution: {integrity: sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.5': + resolution: {integrity: sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.5': + resolution: {integrity: sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.5': + resolution: {integrity: sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.5': + resolution: {integrity: sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.9': + resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + + '@mdx-js/mdx@3.1.1': + resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} + + '@napi-rs/wasm-runtime@1.2.5': + resolution: {integrity: sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} + peerDependencies: + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4 + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oxc-project/types@0.154.0': + resolution: {integrity: sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==} + + '@pagefind/darwin-arm64@1.5.2': + resolution: {integrity: sha512-MXpI+7HsAdPkvJ0gk9xj9g541BCqBZOBbdwj9g6lB5LCj6kSV6nqDSjzcAJwvOsfu0fjwvC8hQU+ecfhp+MpiQ==} + cpu: [arm64] + os: [darwin] + + '@pagefind/darwin-x64@1.5.2': + resolution: {integrity: sha512-IojxFWMEJe0RQ7PQ3KXQsPIImNsbpPYpoZ+QUDrL8fAl/O27IX+LVLs74/UzEZy5uA2LD8Nz1AiwKr72vrkZQw==} + cpu: [x64] + os: [darwin] + + '@pagefind/default-ui@1.5.2': + resolution: {integrity: sha512-pm1LMnQg8N2B3n2TnjKlhaFihpz6zTiA4HiGQ6/slKO/+8K9CAU5kcjdSSPgpuk1PMuuN4hxLipUIifnrkl3Sg==} + + '@pagefind/freebsd-x64@1.5.2': + resolution: {integrity: sha512-7EVzo9+0w+2cbe671BtMj10UlNo83I+HrLVLfRxO731svHRJKUfJ/mo05gU14pe9PCfpKNQT8FS3Xc/oDN6pOA==} + cpu: [x64] + os: [freebsd] + + '@pagefind/linux-arm64@1.5.2': + resolution: {integrity: sha512-Ovt9+K35sqzn8H3ZMXGwls4TD/wMJuvRtShHIsmUQREmaxjrDEX7gHckRCrwYJ4XE1H1p6HkLz3wukrAnsfXQw==} + cpu: [arm64] + os: [linux] + + '@pagefind/linux-x64@1.5.2': + resolution: {integrity: sha512-V+tFqHKXhQKq/WqPBD67AFy7scn1/aZID00ws4fSDd+1daSi5UHR9VVlRrOUYKxn3VuFQYRD7lYXdZK1WED1YA==} + cpu: [x64] + os: [linux] + + '@pagefind/windows-arm64@1.5.2': + resolution: {integrity: sha512-hN9Nh90fNW61nNRCW9ZyQrAj/mD0eRvmJ8NlTUzkbuW8kIzGJUi3cxjFkEcMZ5h/8FsKWD/VcouZl4yo1F7B6g==} + cpu: [arm64] + os: [win32] + + '@pagefind/windows-x64@1.5.2': + resolution: {integrity: sha512-Fa2Iyw7kaDRzGMfNYNUXNW2zbL5FQVDgSOcbDHdzBrDEdpqOqg8TcZ68F22ol6NJ9IGzvUdmeyZypLW5dyhqsg==} + cpu: [x64] + os: [win32] + + '@poppinss/colors@4.1.6': + resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} + + '@poppinss/dumper@0.6.5': + resolution: {integrity: sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw==} + + '@poppinss/exception@1.2.3': + resolution: {integrity: sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw==} + + '@rolldown/binding-android-arm-eabi@1.2.14': + resolution: {integrity: sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.14': + resolution: {integrity: sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.14': + resolution: {integrity: sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.14': + resolution: {integrity: sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.14': + resolution: {integrity: sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.14': + resolution: {integrity: sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.14': + resolution: {integrity: sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.14': + resolution: {integrity: sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.14': + resolution: {integrity: sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.14': + resolution: {integrity: sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.14': + resolution: {integrity: sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.14': + resolution: {integrity: sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.14': + resolution: {integrity: sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.14': + resolution: {integrity: sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.14': + resolution: {integrity: sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@shikijs/core@4.5.0': + resolution: {integrity: sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw==} + engines: {node: '>=20'} + + '@shikijs/engine-javascript@4.5.0': + resolution: {integrity: sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA==} + engines: {node: '>=20'} + + '@shikijs/engine-oniguruma@4.5.0': + resolution: {integrity: sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw==} + engines: {node: '>=20'} + + '@shikijs/langs@4.5.0': + resolution: {integrity: sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA==} + engines: {node: '>=20'} + + '@shikijs/primitive@4.5.0': + resolution: {integrity: sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg==} + engines: {node: '>=20'} + + '@shikijs/themes@4.5.0': + resolution: {integrity: sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q==} + engines: {node: '>=20'} + + '@shikijs/types@4.5.0': + resolution: {integrity: sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==} + engines: {node: '>=20'} + + '@shikijs/vscode-textmate@10.1.0': + resolution: {integrity: sha512-cVBuhDQjBZaNMTwBquYfPydrUP+E3hE6oYGasx8h6ZrODIM7/r+gxLDXdd66oszbrggZHEntdE6j+5gYIVyMWQ==} + + '@sindresorhus/is@7.2.0': + resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} + engines: {node: '>=18'} + + '@speed-highlight/core@1.2.24': + resolution: {integrity: sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw==} + + '@tybys/wasm-util@0.10.4': + resolution: {integrity: sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdx@2.0.14': + resolution: {integrity: sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + + '@types/node@24.19.2': + resolution: {integrity: sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==} + + '@types/sax@1.2.7': + resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} + + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} + engines: {node: '>=0.4.0'} + hasBin: true + + am-i-vibing@0.5.0: + resolution: {integrity: sha512-KLcsDhqf1XRrJmJr+9I/C0EX2K+sGrAGZFgkmd84c8TJO9zJBu+/kFInjazu1KfJWFhWDKdRWkpBzEs7s35DVA==} + hasBin: true + + ansi-escapes@7.3.0: + resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} + engines: {node: '>=18'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + arg@5.0.2: + resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-iterate@2.0.1: + resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} + + astring@1.9.0: + resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} + hasBin: true + + astro-expressive-code@0.44.2: + resolution: {integrity: sha512-avop7nZcRwC7quvIxVguhwEZ9Tp6IoTzKUCLwP30Fb7+er+pWQgcysP01dcuzRIJo81kLGg30q/YAI2Yb1Sdzg==} + peerDependencies: + astro: ^4.0.0-beta || ^5.0.0-beta || ^3.3.0 || ^6.0.0-beta || ^7.0.0 + + astro@7.4.1: + resolution: {integrity: sha512-mzWNEWSef1VQDgFku6Gp0v8Qx53NOkNlOIDQcX9ovxAZ1QXic6cks/FFo+gxPM9anXuat4X1FhB61KTk3XFvgg==} + engines: {node: '>=22.22.0', npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + peerDependencies: + '@astrojs/markdown-remark': ^7.3.0 + peerDependenciesMeta: + '@astrojs/markdown-remark': + optional: true + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + bcp-47-match@2.0.3: + resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} + + bcp-47@2.1.1: + resolution: {integrity: sha512-KLw+H/gd2p4zly1X7Yh/qziuyae5/w/QFnvTng9eZL5fvszL7Whl3MBoWF8yxL7ksUjBfOD+OxkytiqbBpG+Fw==} + + blake3-wasm@2.1.5: + resolution: {integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + common-ancestor-path@2.0.0: + resolution: {integrity: sha512-dnN3ibLeoRf2HNC+OlCiNc5d2zxbLJXOtiZUudNFSXZrNSydxcCsSpRzXwfu7BBWCIfHPw+xTayeBvJCP/D8Ng==} + engines: {node: '>= 18'} + + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + cookie@2.0.1: + resolution: {integrity: sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==} + engines: {node: '>=22'} + + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + css-select@6.0.0: + resolution: {integrity: sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw==} + + css-selector-parser@3.3.0: + resolution: {integrity: sha512-Y2asgMGFqJKF4fq4xHDSlFYIkeVfRsm69lQC1q9kbEsH5XtnINTMrweLkjYMeaUgiXBy/uvKeO/a1JHTNnmB2g==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@7.0.0: + resolution: {integrity: sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + devalue@6.0.3: + resolution: {integrity: sha512-3woMEITftG1wKeO/4d/QXH1DVKctTSZg3jNGPpjp9hNr5Hos8MCFF5McTML5ZtgKDObm2R591B0spRp2IVernA==} + engines: {node: '>=18'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + + direction@2.0.1: + resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} + hasBin: true + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + environment@1.1.0: + resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} + engines: {node: '>=18'} + + error-stack-parser-es@1.0.5: + resolution: {integrity: sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA==} + + es-module-lexer@3.0.4: + resolution: {integrity: sha512-BldNY5mwkVu+cQ5Ymn3e6hB9Sx8XHx4ZevWbiUyZbfKxJxYO27Hf0wfzkxIaD5CAJMaGl3YspKELj0ff/7nCMA==} + + esast-util-from-estree@2.0.0: + resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} + + esast-util-from-js@2.0.1: + resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + + esbuild@0.28.2: + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} + engines: {node: '>=18'} + hasBin: true + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-util-attach-comments@3.0.0: + resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} + + estree-util-build-jsx@3.0.1: + resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-util-scope@1.0.1: + resolution: {integrity: sha512-B0np3dcdxqILX5e9nEi5/Fr4K7gL4oYFVPV1zRa2e9wRCbQoZZNWOZFYyoInvXUPJXBXjss+QXlWLJChDEHDkA==} + + estree-util-to-js@2.0.0: + resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expressive-code@0.44.2: + resolution: {integrity: sha512-MLNghkvhyFgVW4oCD5DxBuYnnTXIf0PygzGmfT6OZeOOYWh/D4sH1j931biElkA5RG/J9wY/iuSMGAPF5vzHOg==} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + + fnv1a-64@0.1.2: + resolution: {integrity: sha512-mJbcILTPybAR1jdOwt/lVv8N2cffeJFFP+gVbSAwLEx1ujXH27RpPd8Rokec/KX9c76UYIB6Co+H4lQzxPnJfA==} + + fontace@0.4.1: + resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + + fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-tsconfig@5.0.0-beta.6: + resolution: {integrity: sha512-X6fBC0pmImC70gvX2zm56go9hx0MyoGVdG0tUCkg/D+Xnh5TJsOZ7iDbOdI3PvmtrDxnu1YdDufpK2QJX1Meqw==} + engines: {node: '>=20.20.0'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + + has-flag@5.0.1: + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} + engines: {node: '>=12'} + + hast-util-embedded@3.0.0: + resolution: {integrity: sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==} + + hast-util-format@1.1.0: + resolution: {integrity: sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-has-property@3.0.0: + resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==} + + hast-util-is-body-ok-link@3.0.1: + resolution: {integrity: sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-minify-whitespace@1.0.1: + resolution: {integrity: sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-phrasing@3.0.1: + resolution: {integrity: sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-select@6.0.4: + resolution: {integrity: sha512-RqGS1ZgI0MwxLaKLDxjprynNzINEkRHY2i8ln4DDjgv9ZhcYVIHN9rlpiYsqtFwrgpYU361SyWDQcGNIBVu3lw==} + + hast-util-to-estree@3.1.3: + resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + + hast-util-to-string@3.0.1: + resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + html-whitespace-sensitive-tag-names@3.0.1: + resolution: {integrity: sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==} + + http-cache-semantics@4.3.0: + resolution: {integrity: sha512-M5t5LlJpS1UHMjvwRQVdFHvPISGeLAxNcrWuJkeGh0KxsqCHZ1O3NXZU/8x7cD0BDcGW8kapxMKTvwlqrNkHkA==} + + i18next@26.5.3: + resolution: {integrity: sha512-6pRwOwJ6lBCBB66fqNZaSoUmvsALHMord7FL6GsDhubT1CrPabTMEJIqddm0nn2YEbKofdI/3VAtGrQPuztY1Q==} + peerDependencies: + typescript: ^5 || ^6 || ^7 + peerDependenciesMeta: + typescript: + optional: true + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-absolute-url@5.0.0: + resolution: {integrity: sha512-sdJyNpBnQHuVnBunfzjAecOhZr2+A30ywfFvu3EnxtKLUWfwGgyWUmqHbGZiU6vTfHpCPm5GvLe4BAvlU9n8VQ==} + engines: {node: '>=20'} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-docker@4.0.0: + resolution: {integrity: sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA==} + engines: {node: '>=20'} + hasBin: true + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + klona@2.0.6: + resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} + engines: {node: '>= 8'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + lru-cache@11.5.3: + resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} + engines: {node: 20 || >=22} + + lucode-starlight@1.0.0: + resolution: {integrity: sha512-VOWIUFZnbfdXm45MbjnbJ79KlKqcNr66ADpb67meesw6lUZOKDjpkzF8Gb4oZE8gY8UhS1d2qRzDN7KBRqzmIA==} + peerDependencies: + '@astrojs/starlight': '>=0.41.4' + + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + + magicast@0.5.5: + resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} + + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + marked@18.1.1: + resolution: {integrity: sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==} + engines: {node: '>= 20'} + hasBin: true + + mdast-util-definitions@6.0.0: + resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} + + mdast-util-directive@3.1.1: + resolution: {integrity: sha512-Gqpa3MHorXYEZ0VfbroyvBlVKUfz371V6mIERqOYMcb0W1D3LwmFq1ZW4O2TDrbn+4ZmGLwMWeFRJgYZ+bc5fQ==} + + mdast-util-find-and-replace@3.0.3: + resolution: {integrity: sha512-xPgpDNl0/OXHsI7TlaIs22lWnH3KlpvZdXG1ET/m/YT2Hhdkx8lJV4kLphE6l9simyM4KPDFBwas08BgEob6Jw==} + + mdast-util-from-markdown@2.1.0: + resolution: {integrity: sha512-KRLgmPaP2H1ul2bpoJkOS+9pAcDAAj9Nu1mqLnh8NLeJG5tlvj42TH80PIGBpROWlRXLk0lDrWNuZCFy/tsB4A==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.1: + resolution: {integrity: sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.2.0: + resolution: {integrity: sha512-2Jn/ADrHFFyyniBMi0bncTbJqU78K3/TEYvzvwrWWzD9MYPUa/Gm4r3nOeKFQg/PDM5BsbDYdc8ackW4NTY6NA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + micromark-core-commonmark@2.0.4: + resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} + + micromark-extension-directive@4.0.0: + resolution: {integrity: sha512-/C2nqVmXXmiseSSuCdItCMho7ybwwop6RrrRPk0KbOHW21JKoCldC+8rFOaundDoRBUWBnJJcxeA/Kvi34WQXg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.2: + resolution: {integrity: sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-mdx-expression@3.0.1: + resolution: {integrity: sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==} + + micromark-extension-mdx-jsx@3.0.2: + resolution: {integrity: sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-mdx-expression@2.0.3: + resolution: {integrity: sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==} + + micromark-factory-space@2.1.0: + resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-edit-map@1.0.0: + resolution: {integrity: sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-events-to-acorn@2.0.3: + resolution: {integrity: sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} + + micromark@4.0.3: + resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} + + miniflare@5.20261019.0-beta: + resolution: {integrity: sha512-NmE6Yz7MSdjrfr3lMaMAVO55rIm3+6YXllOkJOMy6pPQITDuDeo0PYRedp7pju2olNWmrAlf1i1LIWYfGQvvuw==} + engines: {node: '>=22.0.0'} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + neotraverse@1.0.1: + resolution: {integrity: sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==} + engines: {node: '>= 10'} + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-mock-http@1.0.5: + resolution: {integrity: sha512-KQyt/wLjG3TAc7DOUhpqWzgd4ERxR80JOlTK5VE5R1S12IaPVN5qkj4klBce9HPG1Njuup4Sb5bljaT34lIyjw==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + obug@3.0.0: + resolution: {integrity: sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==} + engines: {node: '>=12.20.0'} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + p-limit@7.3.3: + resolution: {integrity: sha512-SKgVvDU5TNIxQ4r30U75BNLC6a+6GOEjngrk/ysM5+Zu3oJuk1SC5ApzIzY/7PjCEjoVPMWJ0zho/4QUiEB7TQ==} + engines: {node: '>=20'} + + p-queue@9.3.5: + resolution: {integrity: sha512-Lki/zhXFVmDhesBUn/yHRLW9Bte7mE7xoFHzTR8sIomV7a2z+LxYAPkGvdfidNiu4fKSfjtawixxSHcZci5QcQ==} + engines: {node: '>=20'} + + p-timeout@7.0.2: + resolution: {integrity: sha512-prbX4Z3YszrFNgH+MW5Zoeq3baXrMtP/MQnFeET90UB/GtGcGDQ5Usg9OCy6ETjTTntOw1SL2z9fMPUppN3Guw==} + engines: {node: '>=20'} + + package-manager-detector@1.9.0: + resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==} + + pagefind@1.5.2: + resolution: {integrity: sha512-XTUaK0hXMCu2jszWE584JGQT7y284TmMV9l/HX3rnG5uo3rHI/uHU56XTyyyPFjeWEBxECbAi0CaFDJOONtG0Q==} + hasBin: true + + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + + parse-latin@7.0.0: + resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.2.14 + + postcss-selector-parser@6.1.5: + resolution: {integrity: sha512-TEHFzb5KRDKaOgJ4TYE+0jyJtFbjLvkdVOu0fZ2b6MTWzoqZd4Le+61aEppWkvTlYePgOX4ATL1wsAIPMLTw6Q==} + engines: {node: '>=4'} + + postcss@8.5.31: + resolution: {integrity: sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==} + engines: {node: ^10 || ^12 || >=14} + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + process-ancestry@0.1.0: + resolution: {integrity: sha512-tGqJW/UnclpYASFcM6Xh8D8l/BMtaQ9+CSG0vlJSJTcdMM4lDRv4c6H0Pdcsfted+bVczdYSfk2fdukg2gQkZg==} + engines: {node: '>=18.0.0'} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.1: + resolution: {integrity: sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rehype-expressive-code@0.44.2: + resolution: {integrity: sha512-jvjQpsfWKBquDrEl5YQA3CJybRRIuDOYsui1BJTs5oKDUK8pPkJSSxY8FjxNlIFIdtgrTjC1zuahuY4Cc1j9SQ==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + + rehype-stringify@10.0.1: + resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} + + remark-directive@4.0.0: + resolution: {integrity: sha512-7sxn4RfF1o3izevPV1DheyGDD6X4c9hrGpfdUpm7uC++dqrnJxIZVkk7CoKqcLm0VUMAuOol7Mno3m6g8cfMuA==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-mdx@3.1.1: + resolution: {integrity: sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-smartypants@3.0.3: + resolution: {integrity: sha512-gCaK+ndZ0hYezlqFegHFCVh2CQemsi0Npdh1qVM9bxlUFknjkbP6VmojWhddOCrbK0PbbacmYLWfTULRiT1eWA==} + engines: {node: '>=16.0.0'} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + + retext-latin@4.0.0: + resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + retext-stringify@4.0.0: + resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==} + + retext@9.0.0: + resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + + rolldown@1.2.14: + resolution: {integrity: sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + satteri@0.10.5: + resolution: {integrity: sha512-Ao1LKpAEa9Wdg0otgbVKViZHEq9ebdXe4DMrp3s9vQAU0HNIuHnFEuMuOcm0ZIXyV0Yzxj91NvhLpvXZJO/5ZQ==} + + sax@1.6.1: + resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} + engines: {node: '>=11.0.0'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.35.5: + resolution: {integrity: sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + + shiki@4.5.0: + resolution: {integrity: sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ==} + engines: {node: '>=20'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + sitemap@9.0.1: + resolution: {integrity: sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==} + engines: {node: '>=20.19.5', npm: '>=10.8.2'} + hasBin: true + + smol-toml@1.9.1: + resolution: {integrity: sha512-j7WyPdf0CJoutMzMoPLF/bVXHtKZIT9Rb4eGeEbWw8X2t5oirjPgpL+OpaENZrILYaXqYwnVH+qgUwVbl+pUJQ==} + engines: {node: '>= 18'} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + source-map@0.8.0: + resolution: {integrity: sha512-d8EqvL+k/SOXCreS/SUzg2ciyHqBBLcN/yuRjFsbvVhHTE2pgei7oAhmPM7kWFbkX6OSMQfUq4KbkF3au9lhYQ==} + engines: {node: '>= 12'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + starlight-links-validator@0.27.0: + resolution: {integrity: sha512-UvPl8L9hfWR+yWbLOEtljLmpJCGcM6AVseVy5IO9dmqdOjW4R+Y8FaYyVA5axCC8RvxM5xU7M1WTerpwU8rYbw==} + peerDependencies: + '@astrojs/starlight': '>=0.42.0' + astro: '>=7.2.10' + + std-env@4.3.0: + resolution: {integrity: sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + + supports-hyperlinks@4.6.0: + resolution: {integrity: sha512-FL/q/YePS4tFFTNiBOR+zPk2ZrKheqNtmTJ/9OUZ4e1wKHJofDbGQSmSsxS0XNnabSkqexryCaXGvEKz1vf9UQ==} + engines: {node: '>=20'} + + svgo@4.1.0: + resolution: {integrity: sha512-bkxnTg1kSU0guhIBmibA6UUhrQmPVA1XsQLN+ylCd+UWzbnLkySOcXpyk1mrl05f+pcaCx2eHb+sp6BgMZWX+Q==} + engines: {node: '>=16'} + hasBin: true + + terminal-link@5.0.0: + resolution: {integrity: sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA==} + engines: {node: '>=20'} + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tinyclip@1.0.3: + resolution: {integrity: sha512-nTv2raKFMzGG6JJp0lpNDsRPdfFE6UHlu2rVEjXo8vkx1ZS3LW2CVXqIaLu8xZlu+6tbFs9V58Qc/Jmk/Ii7wA==} + engines: {node: ^16.14.0 || >= 17.3.0} + + tinyexec@1.3.1: + resolution: {integrity: sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyps@0.5.0: + resolution: {integrity: sha512-Llu7nEtVOGHh7jok32l6+udlu+4Np5vsYVB7zeTK614coSsro2qjwte7MFt/ouLM/h4kXqbGXktSMMvMsttSDw==} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ultrahtml@1.7.0: + resolution: {integrity: sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + undici@7.29.1: + resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} + engines: {node: '>=20.18.1'} + + undici@8.12.3: + resolution: {integrity: sha512-+fdndUTwBP86cVjihTp21hZHo7d6oj1c/47XPA3lL8mM0XHs+C4EkcjmgRsFnFk2f/UONGDy9ItBItmXsLOQHw==} + engines: {node: '>=22.19.0'} + + unenv@2.0.0-rc.24: + resolution: {integrity: sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unifont@1.0.3: + resolution: {integrity: sha512-XGnEwj5sWdCbiBX01mWLSBncS0yMRjnLygau3IfdOlfjMacNIQvTGW4VH7pWBq+gGWArfSef/CensMLjMmIr/A==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-modify-children@4.0.0: + resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-children@3.0.0: + resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.0.0: + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + unstorage@1.17.6: + resolution: {integrity: sha512-gjKtcmn/KwZuJZO9o2sc0esAQnhmTw0LviDHwevTXbJMYs0dRTp8ZEEWXHU0vTpzbN3cA0yxlTeyXssMp3zDqw==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + + url-extras@0.1.0: + resolution: {integrity: sha512-8tzwTeXFPuX/5PHuCDQE5Dd9Ts4rwoq2t9aIT+HS4iAVpmj5l4Ao7Q+BuuFjvWRqrLswBhQDk8O96ZicgCqQqw==} + engines: {node: '>=20'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + verkit@0.5.0: + resolution: {integrity: sha512-u4J1hr6Y+C/hit34GwL3eIcUf3wngT8TlYUjK7tk0jxydKLAq3sYvifjdTDA6U3KCKRcQCQDGf/srjxfZF3l7g==} + engines: {node: '>=18.12.0'} + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + workerd@1.20261019.1: + resolution: {integrity: sha512-UUYRUq/7DmeCl11YriNbcVbjUZVLtt8uuCXFu+nz77hGFf8d4DEVXoCYtnA+MlBatE4+wxoj47DVqzK3xGBAiw==} + engines: {node: '>=16'} + hasBin: true + + wrangler@4.151.2: + resolution: {integrity: sha512-yIT5Q08eVP2ZSE4XHGZ81Y+Y1b25uzp7LANwV2siaEt/O7TpeiFiEih/xHwARHGQo8sufs2ZaILGxCccHgjQPg==} + engines: {node: '>=22.0.0'} + hasBin: true + peerDependencies: + '@cloudflare/workers-types': ^5.20261019.1 + peerDependenciesMeta: + '@cloudflare/workers-types': + optional: true + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + + yaml@2.9.1: + resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + youch-core@0.3.3: + resolution: {integrity: sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA==} + + youch@4.1.0-beta.10: + resolution: {integrity: sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ==} + + zod@4.6.7: + resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@astrojs/compiler-binding-android-arm64@0.5.2': + optional: true + + '@astrojs/compiler-binding-darwin-arm64@0.5.2': + optional: true + + '@astrojs/compiler-binding-darwin-x64@0.5.2': + optional: true + + '@astrojs/compiler-binding-linux-arm64-gnu@0.5.2': + optional: true + + '@astrojs/compiler-binding-linux-arm64-musl@0.5.2': + optional: true + + '@astrojs/compiler-binding-linux-x64-gnu@0.5.2': + optional: true + + '@astrojs/compiler-binding-linux-x64-musl@0.5.2': + optional: true + + '@astrojs/compiler-binding-wasm32-wasi@0.5.2': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3) + optional: true + + '@astrojs/compiler-binding-win32-arm64-msvc@0.5.2': + optional: true + + '@astrojs/compiler-binding-win32-x64-msvc@0.5.2': + optional: true + + '@astrojs/compiler-binding@0.5.2': + optionalDependencies: + '@astrojs/compiler-binding-android-arm64': 0.5.2 + '@astrojs/compiler-binding-darwin-arm64': 0.5.2 + '@astrojs/compiler-binding-darwin-x64': 0.5.2 + '@astrojs/compiler-binding-linux-arm64-gnu': 0.5.2 + '@astrojs/compiler-binding-linux-arm64-musl': 0.5.2 + '@astrojs/compiler-binding-linux-x64-gnu': 0.5.2 + '@astrojs/compiler-binding-linux-x64-musl': 0.5.2 + '@astrojs/compiler-binding-wasm32-wasi': 0.5.2 + '@astrojs/compiler-binding-win32-arm64-msvc': 0.5.2 + '@astrojs/compiler-binding-win32-x64-msvc': 0.5.2 + + '@astrojs/compiler-rs@0.5.2': + dependencies: + '@astrojs/compiler-binding': 0.5.2 + + '@astrojs/internal-helpers@0.13.0': + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + picomatch: 4.0.7 + retext-smartypants: 6.2.0 + shiki: 4.5.0 + smol-toml: 1.9.1 + unified: 11.0.5 + yaml: 2.9.1 + + '@astrojs/markdown-remark@7.3.3': + dependencies: + '@astrojs/internal-helpers': 0.13.0 + '@astrojs/prism': 4.1.0 + '@mdx-js/mdx': 3.1.1 + acorn: 8.19.0 + estree-util-visit: 2.0.0 + github-slugger: 2.0.0 + hast-util-from-html: 2.0.3 + hast-util-to-html: 9.0.5 + hast-util-to-text: 4.0.2 + mdast-util-definitions: 6.0.0 + rehype-raw: 7.0.0 + rehype-stringify: 10.0.1 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remark-smartypants: 3.0.3 + source-map: 0.8.0 + unified: 11.0.5 + unist-util-remove-position: 5.0.0 + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/markdown-satteri@0.4.4': + dependencies: + '@astrojs/internal-helpers': 0.13.0 + '@astrojs/prism': 4.1.0 + github-slugger: 2.0.0 + satteri: 0.10.5 + + '@astrojs/mdx@8.1.0(@astrojs/markdown-remark@7.3.3)(@astrojs/markdown-satteri@0.4.4)(astro@7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1))': + dependencies: + '@astrojs/internal-helpers': 0.13.0 + '@astrojs/markdown-satteri': 0.4.4 + astro: 7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1) + es-module-lexer: 3.0.4 + optionalDependencies: + '@astrojs/markdown-remark': 7.3.3 + + '@astrojs/prism@4.1.0': + dependencies: + prismjs: 1.30.0 + + '@astrojs/sitemap@3.7.4': + dependencies: + sitemap: 9.0.1 + zod: 4.6.7 + + '@astrojs/starlight@0.42.6(@astrojs/markdown-remark@7.3.3)(astro@7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1))(typescript@5.9.3)': + dependencies: + '@astrojs/markdown-satteri': 0.4.4 + '@astrojs/mdx': 8.1.0(@astrojs/markdown-remark@7.3.3)(@astrojs/markdown-satteri@0.4.4)(astro@7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1)) + '@astrojs/sitemap': 3.7.4 + '@pagefind/default-ui': 1.5.2 + '@types/hast': 3.0.5 + '@types/js-yaml': 4.0.9 + '@types/mdast': 4.0.4 + astro: 7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1) + astro-expressive-code: 0.44.2(astro@7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1)) + bcp-47: 2.1.1 + hast-util-format: 1.1.0 + hast-util-select: 6.0.4 + hast-util-to-html: 9.0.5 + hast-util-to-string: 3.0.1 + hastscript: 9.0.1 + i18next: 26.5.3(typescript@5.9.3) + js-yaml: 4.3.2 + klona: 2.0.6 + magic-string: 1.4.3 + mdast-util-directive: 3.1.1 + mdast-util-to-markdown: 2.2.0 + mdast-util-to-string: 4.0.0 + pagefind: 1.5.2 + remark-directive: 4.0.0 + satteri: 0.10.5 + ultrahtml: 1.7.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + optionalDependencies: + '@astrojs/markdown-remark': 7.3.3 + transitivePeerDependencies: + - supports-color + - typescript + + '@astrojs/telemetry@3.4.0': + dependencies: + ci-info: 4.4.0 + dset: 3.1.4 + is-docker: 4.0.0 + package-manager-detector: 1.9.0 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bruits/satteri-darwin-arm64@0.10.5': + optional: true + + '@bruits/satteri-darwin-x64@0.10.5': + optional: true + + '@bruits/satteri-linux-arm64-gnu@0.10.5': + optional: true + + '@bruits/satteri-linux-arm64-musl@0.10.5': + optional: true + + '@bruits/satteri-linux-x64-gnu@0.10.5': + optional: true + + '@bruits/satteri-linux-x64-musl@0.10.5': + optional: true + + '@bruits/satteri-wasm32-wasi@0.10.5': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@napi-rs/wasm-runtime': 1.2.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + optional: true + + '@bruits/satteri-win32-arm64-msvc@0.10.5': + optional: true + + '@bruits/satteri-win32-x64-msvc@0.10.5': + optional: true + + '@capsizecss/unpack@4.0.1': + dependencies: + fontkitten: 1.0.3 + + '@clack/core@1.5.1': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.8.1': + dependencies: + '@clack/core': 1.5.1 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@cloudflare/kv-asset-handler@0.5.0': {} + + '@cloudflare/unenv-preset@2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261019.1)': + dependencies: + unenv: 2.0.0-rc.24 + optionalDependencies: + workerd: 1.20261019.1 + + '@cloudflare/workerd-darwin-64@1.20261019.1': + optional: true + + '@cloudflare/workerd-darwin-arm64@1.20261019.1': + optional: true + + '@cloudflare/workerd-linux-64@1.20261019.1': + optional: true + + '@cloudflare/workerd-linux-arm64@1.20261019.1': + optional: true + + '@cloudflare/workerd-windows-64@1.20261019.1': + optional: true + + '@cspotcode/source-map-support@0.8.1': + dependencies: + '@jridgewell/trace-mapping': 0.3.9 + + '@ctrl/tinycolor@4.2.1': {} + + '@emnapi/core@1.11.1': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + + '@emnapi/core@1.11.3': + dependencies: + '@emnapi/wasi-threads': 1.2.3 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.3': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.28.2': + optional: true + + '@esbuild/android-arm64@0.28.2': + optional: true + + '@esbuild/android-arm@0.28.2': + optional: true + + '@esbuild/android-x64@0.28.2': + optional: true + + '@esbuild/darwin-arm64@0.28.2': + optional: true + + '@esbuild/darwin-x64@0.28.2': + optional: true + + '@esbuild/freebsd-arm64@0.28.2': + optional: true + + '@esbuild/freebsd-x64@0.28.2': + optional: true + + '@esbuild/linux-arm64@0.28.2': + optional: true + + '@esbuild/linux-arm@0.28.2': + optional: true + + '@esbuild/linux-ia32@0.28.2': + optional: true + + '@esbuild/linux-loong64@0.28.2': + optional: true + + '@esbuild/linux-mips64el@0.28.2': + optional: true + + '@esbuild/linux-ppc64@0.28.2': + optional: true + + '@esbuild/linux-riscv64@0.28.2': + optional: true + + '@esbuild/linux-s390x@0.28.2': + optional: true + + '@esbuild/linux-x64@0.28.2': + optional: true + + '@esbuild/netbsd-arm64@0.28.2': + optional: true + + '@esbuild/netbsd-x64@0.28.2': + optional: true + + '@esbuild/openbsd-arm64@0.28.2': + optional: true + + '@esbuild/openbsd-x64@0.28.2': + optional: true + + '@esbuild/openharmony-arm64@0.28.2': + optional: true + + '@esbuild/sunos-x64@0.28.2': + optional: true + + '@esbuild/win32-arm64@0.28.2': + optional: true + + '@esbuild/win32-ia32@0.28.2': + optional: true + + '@esbuild/win32-x64@0.28.2': + optional: true + + '@expressive-code/core@0.44.2': + dependencies: + '@ctrl/tinycolor': 4.2.1 + hast-util-select: 6.0.4 + hast-util-to-html: 9.0.5 + hast-util-to-text: 4.0.2 + hastscript: 9.0.1 + postcss: 8.5.31 + postcss-nested: 6.2.0(postcss@8.5.31) + unist-util-visit: 5.0.0 + unist-util-visit-parents: 6.0.2 + + '@expressive-code/plugin-frames@0.44.2': + dependencies: + '@expressive-code/core': 0.44.2 + + '@expressive-code/plugin-shiki@0.44.2': + dependencies: + '@expressive-code/core': 0.44.2 + shiki: 4.5.0 + + '@expressive-code/plugin-text-markers@0.44.2': + dependencies: + '@expressive-code/core': 0.44.2 + + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.4 + optional: true + + '@img/sharp-darwin-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.4 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.4': + optional: true + + '@img/sharp-linux-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.4 + optional: true + + '@img/sharp-linux-arm@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.4 + optional: true + + '@img/sharp-linux-ppc64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.4 + optional: true + + '@img/sharp-linux-riscv64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.4 + optional: true + + '@img/sharp-linux-s390x@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.4 + optional: true + + '@img/sharp-linux-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + optional: true + + '@img/sharp-wasm32@0.35.5': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.5': + dependencies: + '@img/sharp-wasm32': 0.35.5 + optional: true + + '@img/sharp-win32-arm64@0.35.5': + optional: true + + '@img/sharp-win32-ia32@0.35.5': + optional: true + + '@img/sharp-win32-x64@0.35.5': + optional: true + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.9': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@mdx-js/mdx@3.1.1': + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdx': 2.0.14 + acorn: 8.19.0 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.1 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.6 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.1(acorn@8.19.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + source-map: 0.7.6 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + dependencies: + '@emnapi/core': 1.11.1 + '@emnapi/runtime': 1.11.1 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)': + dependencies: + '@emnapi/core': 1.11.3 + '@emnapi/runtime': 1.11.3 + '@tybys/wasm-util': 0.10.4 + optional: true + + '@oslojs/encoding@1.1.0': {} + + '@oxc-project/types@0.154.0': {} + + '@pagefind/darwin-arm64@1.5.2': + optional: true + + '@pagefind/darwin-x64@1.5.2': + optional: true + + '@pagefind/default-ui@1.5.2': {} + + '@pagefind/freebsd-x64@1.5.2': + optional: true + + '@pagefind/linux-arm64@1.5.2': + optional: true + + '@pagefind/linux-x64@1.5.2': + optional: true + + '@pagefind/windows-arm64@1.5.2': + optional: true + + '@pagefind/windows-x64@1.5.2': + optional: true + + '@poppinss/colors@4.1.6': + dependencies: + kleur: 4.1.5 + + '@poppinss/dumper@0.6.5': + dependencies: + '@poppinss/colors': 4.1.6 + '@sindresorhus/is': 7.2.0 + supports-color: 10.2.2 + + '@poppinss/exception@1.2.3': {} + + '@rolldown/binding-android-arm-eabi@1.2.14': + optional: true + + '@rolldown/binding-android-arm64@1.2.14': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.14': + optional: true + + '@rolldown/binding-darwin-x64@1.2.14': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.14': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.14': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.14': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.14': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.14': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.14': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.14': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.14': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@shikijs/core@4.5.0': + dependencies: + '@shikijs/primitive': 4.5.0 + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.1.0 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.1.0 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.1.0 + + '@shikijs/langs@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + + '@shikijs/primitive@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.1.0 + '@types/hast': 3.0.5 + + '@shikijs/themes@4.5.0': + dependencies: + '@shikijs/types': 4.5.0 + + '@shikijs/types@4.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.1.0 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.1.0': {} + + '@sindresorhus/is@7.2.0': {} + + '@speed-highlight/core@1.2.24': {} + + '@tybys/wasm-util@0.10.4': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/js-yaml@4.0.9': {} + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdx@2.0.14': {} + + '@types/ms@2.1.0': {} + + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + + '@types/node@24.19.2': + dependencies: + undici-types: 7.24.6 + + '@types/sax@1.2.7': + dependencies: + '@types/node': 24.19.2 + + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.4.0': {} + + acorn-jsx@5.3.2(acorn@8.19.0): + dependencies: + acorn: 8.19.0 + + acorn@8.19.0: {} + + am-i-vibing@0.5.0: + dependencies: + process-ancestry: 0.1.0 + + ansi-escapes@7.3.0: + dependencies: + environment: 1.1.0 + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + arg@5.0.2: {} + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + array-iterate@2.0.1: {} + + astring@1.9.0: {} + + astro-expressive-code@0.44.2(astro@7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1)): + dependencies: + astro: 7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1) + rehype-expressive-code: 0.44.2 + url-extras: 0.1.0 + + astro@7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1): + dependencies: + '@astrojs/compiler-rs': 0.5.2 + '@astrojs/internal-helpers': 0.13.0 + '@astrojs/markdown-satteri': 0.4.4 + '@astrojs/telemetry': 3.4.0 + '@capsizecss/unpack': 4.0.1 + '@clack/prompts': 1.8.1 + '@oslojs/encoding': 1.1.0 + am-i-vibing: 0.5.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 2.0.0 + cookie: 2.0.1 + devalue: 6.0.3 + diff: 9.0.0 + dset: 3.1.4 + es-module-lexer: 3.0.4 + flattie: 1.1.1 + fontace: 0.4.1 + get-tsconfig: 5.0.0-beta.6 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.3.0 + jsonc-parser: 3.3.1 + magic-string: 1.4.3 + magicast: 0.5.5 + mrmime: 2.0.1 + neotraverse: 1.0.1 + obug: 3.0.0 + p-limit: 7.3.3 + p-queue: 9.3.5 + package-manager-detector: 1.9.0 + piccolore: 0.1.3 + picomatch: 4.0.7 + rolldown: 1.2.14 + shiki: 4.5.0 + smol-toml: 1.9.1 + svgo: 4.1.0 + tinyclip: 1.0.3 + tinyexec: 1.3.1 + tinyglobby: 0.2.17 + tinyps: 0.5.0 + ultrahtml: 1.7.0 + unifont: 1.0.3 + unstorage: 1.17.6 + verkit: 0.5.0 + vite: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1) + vitefu: 1.1.3(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1)) + xxhash-wasm: 1.1.0 + yargs-parser: 22.0.0 + zod: 4.6.7 + optionalDependencies: + '@astrojs/markdown-remark': 7.3.3 + sharp: 0.35.5(@types/node@24.19.2) + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - '@vitejs/devtools' + - aws4fetch + - db0 + - esbuild + - idb-keyval + - ioredis + - jiti + - less + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - uploadthing + - yaml + + axobject-query@4.1.0: {} + + bail@2.0.2: {} + + bcp-47-match@2.0.3: {} + + bcp-47@2.1.1: + dependencies: + is-alphabetical: 2.0.1 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + + blake3-wasm@2.1.5: {} + + boolbase@1.0.0: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + ci-info@4.4.0: {} + + clsx@2.1.1: {} + + collapse-white-space@2.1.0: {} + + comma-separated-tokens@2.0.3: {} + + commander@11.1.0: {} + + common-ancestor-path@2.0.0: {} + + cookie-es@1.2.3: {} + + cookie@1.1.1: {} + + cookie@2.0.1: {} + + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + css-select@6.0.0: + dependencies: + boolbase: 1.0.0 + css-what: 7.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-selector-parser@3.3.0: {} + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.2 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.2 + + css-what@7.0.0: {} + + cssesc@3.0.0: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + defu@6.1.7: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: {} + + devalue@6.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff@9.0.0: {} + + direction@2.0.1: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + domelementtype@2.3.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + dset@3.1.4: {} + + entities@4.5.0: {} + + entities@6.0.1: {} + + environment@1.1.0: {} + + error-stack-parser-es@1.0.5: {} + + es-module-lexer@3.0.4: {} + + esast-util-from-estree@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.19.0 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.3 + + esbuild@0.28.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.2 + '@esbuild/android-arm': 0.28.2 + '@esbuild/android-arm64': 0.28.2 + '@esbuild/android-x64': 0.28.2 + '@esbuild/darwin-arm64': 0.28.2 + '@esbuild/darwin-x64': 0.28.2 + '@esbuild/freebsd-arm64': 0.28.2 + '@esbuild/freebsd-x64': 0.28.2 + '@esbuild/linux-arm': 0.28.2 + '@esbuild/linux-arm64': 0.28.2 + '@esbuild/linux-ia32': 0.28.2 + '@esbuild/linux-loong64': 0.28.2 + '@esbuild/linux-mips64el': 0.28.2 + '@esbuild/linux-ppc64': 0.28.2 + '@esbuild/linux-riscv64': 0.28.2 + '@esbuild/linux-s390x': 0.28.2 + '@esbuild/linux-x64': 0.28.2 + '@esbuild/netbsd-arm64': 0.28.2 + '@esbuild/netbsd-x64': 0.28.2 + '@esbuild/openbsd-arm64': 0.28.2 + '@esbuild/openbsd-x64': 0.28.2 + '@esbuild/openharmony-arm64': 0.28.2 + '@esbuild/sunos-x64': 0.28.2 + '@esbuild/win32-arm64': 0.28.2 + '@esbuild/win32-ia32': 0.28.2 + '@esbuild/win32-x64': 0.28.2 + + escape-string-regexp@5.0.0: {} + + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.9 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + + estree-util-is-identifier-name@3.0.0: {} + + estree-util-scope@1.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.6 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + eventemitter3@5.0.4: {} + + expressive-code@0.44.2: + dependencies: + '@expressive-code/core': 0.44.2 + '@expressive-code/plugin-frames': 0.44.2 + '@expressive-code/plugin-shiki': 0.44.2 + '@expressive-code/plugin-text-markers': 0.44.2 + + extend@3.0.2: {} + + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + flattie@1.1.1: {} + + fnv1a-64@0.1.2: {} + + fontace@0.4.1: + dependencies: + fontkitten: 1.0.3 + + fontkitten@1.0.3: + dependencies: + tiny-inflate: 1.0.3 + + fsevents@2.3.3: + optional: true + + get-tsconfig@5.0.0-beta.6: + dependencies: + resolve-pkg-maps: 1.0.0 + + github-slugger@2.0.0: {} + + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.5 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + + has-flag@5.0.1: {} + + hast-util-embedded@3.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-is-element: 3.0.0 + + hast-util-format@1.1.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-minify-whitespace: 1.0.1 + hast-util-phrasing: 3.0.1 + hast-util-whitespace: 3.0.0 + html-whitespace-sensitive-tag-names: 3.0.1 + unist-util-visit-parents: 6.0.2 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.5 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.2.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-has-property@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-is-body-ok-link@3.0.1: + dependencies: + '@types/hast': 3.0.5 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-minify-whitespace@1.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-is-element: 3.0.0 + hast-util-whitespace: 3.0.0 + unist-util-is: 6.0.1 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-phrasing@3.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-embedded: 3.0.0 + hast-util-has-property: 3.0.0 + hast-util-is-body-ok-link: 3.0.1 + hast-util-is-element: 3.0.0 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.4.0 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-select@6.0.4: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + bcp-47-match: 2.0.3 + comma-separated-tokens: 2.0.3 + css-selector-parser: 3.3.0 + devlop: 1.1.0 + direction: 2.0.1 + hast-util-has-property: 3.0.0 + hast-util-to-string: 3.0.1 + hast-util-whitespace: 3.0.0 + nth-check: 2.1.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + unist-util-visit: 5.0.0 + zwitch: 2.0.4 + + hast-util-to-estree@3.1.3: + dependencies: + '@types/estree': 1.0.9 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-attach-comments: 3.0.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-string@3.0.1: + dependencies: + '@types/hast': 3.0.5 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + + html-escaper@3.0.3: {} + + html-void-elements@3.0.0: {} + + html-whitespace-sensitive-tag-names@3.0.1: {} + + http-cache-semantics@4.3.0: {} + + i18next@26.5.3(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + inline-style-parser@0.2.7: {} + + iron-webcrypto@1.2.1: {} + + is-absolute-url@5.0.0: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + + is-docker@4.0.0: {} + + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + jsonc-parser@3.3.1: {} + + kleur@4.1.5: {} + + klona@2.0.6: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + longest-streak@3.1.0: {} + + lru-cache@11.5.3: {} + + lucode-starlight@1.0.0(@astrojs/starlight@0.42.6(@astrojs/markdown-remark@7.3.3)(astro@7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1))(typescript@5.9.3)): + dependencies: + '@astrojs/starlight': 0.42.6(@astrojs/markdown-remark@7.3.3)(astro@7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1))(typescript@5.9.3) + '@pagefind/default-ui': 1.5.2 + marked: 18.1.1 + + magic-string@1.4.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magicast@0.5.5: + dependencies: + '@babel/parser': 7.29.9 + '@babel/types': 7.29.8 + source-map-js: 1.2.2 + + markdown-extensions@2.0.0: {} + + markdown-table@3.0.4: {} + + marked@18.1.1: {} + + mdast-util-definitions@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + unist-util-visit: 5.0.0 + + mdast-util-directive@3.1.1: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 + micromark-util-character: 2.1.1 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-visit-parents: 6.0.2 + transitivePeerDependencies: + - supports-color + + mdast-util-find-and-replace@3.0.3: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.3 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.3 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.1.0 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.1 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.2.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.1.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.2.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.1.0 + mdast-util-to-markdown: 2.2.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.2.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-character: 2.1.1 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + unist-util-visit: 5.0.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + + micromark-core-commonmark@2.0.4: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.1.0 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-edit-map: 1.0.0 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-directive@4.0.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + parse-entities: 4.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-table@2.1.2: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.2 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-mdx-expression@3.0.1: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-mdx-jsx@3.0.2: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.3 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + vfile-message: 4.0.3 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.19.0 + acorn-jsx: 5.3.2(acorn@8.19.0) + micromark-extension-mdx-expression: 3.0.1 + micromark-extension-mdx-jsx: 3.0.2 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-mdx-expression@2.0.3: + dependencies: + '@types/estree': 1.0.9 + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-events-to-acorn: 2.0.3 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.3 + + micromark-factory-space@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.3 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-edit-map@1.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-encode@2.0.1: {} + + micromark-util-events-to-acorn@2.0.3: + dependencies: + '@types/estree': 1.0.9 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + vfile-message: 4.0.3 + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.3: {} + + micromark@4.0.3: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-edit-map: 1.0.0 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + transitivePeerDependencies: + - supports-color + + miniflare@5.20261019.0-beta(@types/node@24.19.2): + dependencies: + '@cspotcode/source-map-support': 0.8.1 + sharp: 0.35.5(@types/node@24.19.2) + undici: 7.29.1 + workerd: 1.20261019.1 + ws: 8.21.0 + youch: 4.1.0-beta.10 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.20: {} + + neotraverse@1.0.1: {} + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + + node-fetch-native@1.6.7: {} + + node-mock-http@1.0.5: {} + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + obug@3.0.0: {} + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + p-limit@7.3.3: + dependencies: + yocto-queue: 1.2.2 + + p-queue@9.3.5: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 7.0.2 + + p-timeout@7.0.2: {} + + package-manager-detector@1.9.0: {} + + pagefind@1.5.2: + optionalDependencies: + '@pagefind/darwin-arm64': 1.5.2 + '@pagefind/darwin-x64': 1.5.2 + '@pagefind/freebsd-x64': 1.5.2 + '@pagefind/linux-arm64': 1.5.2 + '@pagefind/linux-x64': 1.5.2 + '@pagefind/windows-arm64': 1.5.2 + '@pagefind/windows-x64': 1.5.2 + + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + + parse-latin@7.0.0: + dependencies: + '@types/nlcst': 2.0.3 + '@types/unist': 3.0.3 + nlcst-to-string: 4.0.0 + unist-util-modify-children: 4.0.0 + unist-util-visit-children: 3.0.0 + vfile: 6.0.3 + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + piccolore@0.1.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.7: {} + + postcss-nested@6.2.0(postcss@8.5.31): + dependencies: + postcss: 8.5.31 + postcss-selector-parser: 6.1.5 + + postcss-selector-parser@6.1.5: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss@8.5.31: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + prismjs@1.30.0: {} + + process-ancestry@0.1.0: {} + + property-information@7.2.0: {} + + radix3@1.1.2: {} + + readdirp@5.1.1: {} + + recma-build-jsx@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-build-jsx: 3.0.1 + vfile: 6.0.3 + + recma-jsx@1.0.1(acorn@8.19.0): + dependencies: + acorn: 8.19.0 + acorn-jsx: 5.3.2(acorn@8.19.0) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + + recma-parse@1.0.0: + dependencies: + '@types/estree': 1.0.9 + esast-util-from-js: 2.0.1 + unified: 11.0.5 + vfile: 6.0.3 + + recma-stringify@1.0.0: + dependencies: + '@types/estree': 1.0.9 + estree-util-to-js: 2.0.0 + unified: 11.0.5 + vfile: 6.0.3 + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rehype-expressive-code@0.44.2: + dependencies: + expressive-code: 0.44.2 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-recma@1.0.0: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + hast-util-to-estree: 3.1.3 + transitivePeerDependencies: + - supports-color + + rehype-stringify@10.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + unified: 11.0.5 + + remark-directive@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-directive: 3.1.1 + micromark-extension-directive: 4.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-mdx@3.1.1: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.1.0 + micromark-util-types: 2.0.3 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-smartypants@3.0.3: + dependencies: + retext: 9.0.0 + retext-smartypants: 6.2.0 + unified: 11.0.5 + unist-util-visit: 5.0.0 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.2.0 + unified: 11.0.5 + + resolve-pkg-maps@1.0.0: {} + + retext-latin@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + parse-latin: 7.0.0 + unified: 11.0.5 + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.0.0 + + retext-stringify@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unified: 11.0.5 + + retext@9.0.0: + dependencies: + '@types/nlcst': 2.0.3 + retext-latin: 4.0.0 + retext-stringify: 4.0.0 + unified: 11.0.5 + + rolldown@1.2.14: + dependencies: + '@oxc-project/types': 0.154.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.14 + '@rolldown/binding-android-arm64': 1.2.14 + '@rolldown/binding-darwin-arm64': 1.2.14 + '@rolldown/binding-darwin-x64': 1.2.14 + '@rolldown/binding-freebsd-x64': 1.2.14 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.14 + '@rolldown/binding-linux-arm64-gnu': 1.2.14 + '@rolldown/binding-linux-arm64-musl': 1.2.14 + '@rolldown/binding-linux-ppc64-gnu': 1.2.14 + '@rolldown/binding-linux-s390x-gnu': 1.2.14 + '@rolldown/binding-linux-x64-gnu': 1.2.14 + '@rolldown/binding-linux-x64-musl': 1.2.14 + '@rolldown/binding-openharmony-arm64': 1.2.14 + '@rolldown/binding-win32-arm64-msvc': 1.2.14 + '@rolldown/binding-win32-x64-msvc': 1.2.14 + + satteri@0.10.5: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + optionalDependencies: + '@bruits/satteri-darwin-arm64': 0.10.5 + '@bruits/satteri-darwin-x64': 0.10.5 + '@bruits/satteri-linux-arm64-gnu': 0.10.5 + '@bruits/satteri-linux-arm64-musl': 0.10.5 + '@bruits/satteri-linux-x64-gnu': 0.10.5 + '@bruits/satteri-linux-x64-musl': 0.10.5 + '@bruits/satteri-wasm32-wasi': 0.10.5 + '@bruits/satteri-win32-arm64-msvc': 0.10.5 + '@bruits/satteri-win32-x64-msvc': 0.10.5 + + sax@1.6.1: {} + + semver@7.8.5: {} + + sharp@0.35.5(@types/node@24.19.2): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.5 + '@img/sharp-darwin-x64': 0.35.5 + '@img/sharp-freebsd-wasm32': 0.35.5 + '@img/sharp-libvips-darwin-arm64': 1.3.4 + '@img/sharp-libvips-darwin-x64': 1.3.4 + '@img/sharp-libvips-linux-arm': 1.3.4 + '@img/sharp-libvips-linux-arm64': 1.3.4 + '@img/sharp-libvips-linux-ppc64': 1.3.4 + '@img/sharp-libvips-linux-riscv64': 1.3.4 + '@img/sharp-libvips-linux-s390x': 1.3.4 + '@img/sharp-libvips-linux-x64': 1.3.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.4 + '@img/sharp-libvips-linuxmusl-x64': 1.3.4 + '@img/sharp-linux-arm': 0.35.5 + '@img/sharp-linux-arm64': 0.35.5 + '@img/sharp-linux-ppc64': 0.35.5 + '@img/sharp-linux-riscv64': 0.35.5 + '@img/sharp-linux-s390x': 0.35.5 + '@img/sharp-linux-x64': 0.35.5 + '@img/sharp-linuxmusl-arm64': 0.35.5 + '@img/sharp-linuxmusl-x64': 0.35.5 + '@img/sharp-webcontainers-wasm32': 0.35.5 + '@img/sharp-win32-arm64': 0.35.5 + '@img/sharp-win32-ia32': 0.35.5 + '@img/sharp-win32-x64': 0.35.5 + '@types/node': 24.19.2 + + shiki@4.5.0: + dependencies: + '@shikijs/core': 4.5.0 + '@shikijs/engine-javascript': 4.5.0 + '@shikijs/engine-oniguruma': 4.5.0 + '@shikijs/langs': 4.5.0 + '@shikijs/themes': 4.5.0 + '@shikijs/types': 4.5.0 + '@shikijs/vscode-textmate': 10.1.0 + '@types/hast': 3.0.5 + + sisteransi@1.0.5: {} + + sitemap@9.0.1: + dependencies: + '@types/node': 24.19.2 + '@types/sax': 1.2.7 + arg: 5.0.2 + sax: 1.6.1 + + smol-toml@1.9.1: {} + + source-map-js@1.2.2: {} + + source-map@0.7.6: {} + + source-map@0.8.0: {} + + space-separated-tokens@2.0.2: {} + + starlight-links-validator@0.27.0(@astrojs/starlight@0.42.6(@astrojs/markdown-remark@7.3.3)(astro@7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1))(typescript@5.9.3))(astro@7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1)): + dependencies: + '@astrojs/markdown-satteri': 0.4.4 + '@astrojs/starlight': 0.42.6(@astrojs/markdown-remark@7.3.3)(astro@7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1))(typescript@5.9.3) + astro: 7.4.1(@astrojs/markdown-remark@7.3.3)(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1) + github-slugger: 2.0.0 + hast-util-from-html: 2.0.3 + is-absolute-url: 5.0.0 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-to-hast: 13.2.1 + picomatch: 4.0.7 + satteri: 0.10.5 + terminal-link: 5.0.0 + unist-util-visit: 5.1.0 + yaml: 2.9.1 + transitivePeerDependencies: + - supports-color + + std-env@4.3.0: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + + supports-color@10.2.2: {} + + supports-hyperlinks@4.6.0: + dependencies: + has-flag: 5.0.1 + supports-color: 10.2.2 + + svgo@4.1.0: + dependencies: + commander: 11.1.0 + css-select: 6.0.0 + css-tree: 3.2.1 + css-what: 7.0.0 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.1 + + terminal-link@5.0.0: + dependencies: + ansi-escapes: 7.3.0 + supports-hyperlinks: 4.6.0 + + tiny-inflate@1.0.3: {} + + tinyclip@1.0.3: {} + + tinyexec@1.3.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinyps@0.5.0: {} + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + ultrahtml@1.7.0: {} + + uncrypto@0.1.3: {} + + undici-types@7.24.6: {} + + undici@7.29.1: {} + + undici@8.12.3: {} + + unenv@2.0.0-rc.24: + dependencies: + pathe: 2.0.3 + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unifont@1.0.3: + dependencies: + css-tree: 3.2.1 + fnv1a-64: 0.1.2 + std-env: 4.3.0 + undici: 8.12.3 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-modify-children@4.0.0: + dependencies: + '@types/unist': 3.0.3 + array-iterate: 2.0.1 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.0.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-children@3.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unstorage@1.17.6: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.5.3 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + + url-extras@0.1.0: {} + + util-deprecate@1.0.2: {} + + verkit@0.5.0: {} + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.31 + rolldown: 1.2.14 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.19.2 + esbuild: 0.28.2 + fsevents: 2.3.3 + yaml: 2.9.1 + + vitefu@1.1.3(vite@8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1)): + optionalDependencies: + vite: 8.3.4(@types/node@24.19.2)(esbuild@0.28.2)(yaml@2.9.1) + + web-namespaces@2.0.1: {} + + workerd@1.20261019.1: + optionalDependencies: + '@cloudflare/workerd-darwin-64': 1.20261019.1 + '@cloudflare/workerd-darwin-arm64': 1.20261019.1 + '@cloudflare/workerd-linux-64': 1.20261019.1 + '@cloudflare/workerd-linux-arm64': 1.20261019.1 + '@cloudflare/workerd-windows-64': 1.20261019.1 + + wrangler@4.151.2(@types/node@24.19.2): + dependencies: + '@cloudflare/kv-asset-handler': 0.5.0 + '@cloudflare/unenv-preset': 2.16.2(unenv@2.0.0-rc.24)(workerd@1.20261019.1) + blake3-wasm: 2.1.5 + esbuild: 0.28.2 + miniflare: 5.20261019.0-beta(@types/node@24.19.2) + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.24 + workerd: 1.20261019.1 + optionalDependencies: + fsevents: 2.3.3 + transitivePeerDependencies: + - '@types/node' + - bufferutil + - utf-8-validate + + ws@8.21.0: {} + + xxhash-wasm@1.1.0: {} + + yaml@2.9.1: {} + + yargs-parser@22.0.0: {} + + yocto-queue@1.2.2: {} + + youch-core@0.3.3: + dependencies: + '@poppinss/exception': 1.2.3 + error-stack-parser-es: 1.0.5 + + youch@4.1.0-beta.10: + dependencies: + '@poppinss/colors': 4.1.6 + '@poppinss/dumper': 0.6.5 + '@speed-highlight/core': 1.2.24 + cookie: 1.1.1 + youch-core: 0.3.3 + + zod@4.6.7: {} + + zwitch@2.0.4: {} diff --git a/docs/pnpm-workspace.yaml b/docs/pnpm-workspace.yaml new file mode 100644 index 00000000..577ec00c --- /dev/null +++ b/docs/pnpm-workspace.yaml @@ -0,0 +1,7 @@ +packages: + - . + +onlyBuiltDependencies: + - esbuild + - sharp + - workerd diff --git a/docs/public/favicon.png b/docs/public/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..f862c44dd549d2efbf768199e4df6e101a084d2c GIT binary patch literal 2684 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 literal 0 HcmV?d00001 diff --git a/docs/public/robots.txt b/docs/public/robots.txt new file mode 100644 index 00000000..b2d2628e --- /dev/null +++ b/docs/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://ttlj.docs.isaackogan.com/sitemap-index.xml diff --git a/docs/src/content.config.ts b/docs/src/content.config.ts new file mode 100644 index 00000000..6226f384 --- /dev/null +++ b/docs/src/content.config.ts @@ -0,0 +1,8 @@ +import { defineCollection } from 'astro:content'; +import { docsLoader } from '@astrojs/starlight/loaders'; +import { docsSchema } from '@astrojs/starlight/schema'; +import { ExtendDocsSchema } from 'lucode-starlight/schema'; + +export const collections = { + docs: defineCollection({ loader: docsLoader(), schema: docsSchema({ extend: ExtendDocsSchema }) }), +}; diff --git a/docs/src/content/docs/404.md b/docs/src/content/docs/404.md new file mode 100644 index 00000000..c6fb5293 --- /dev/null +++ b/docs/src/content/docs/404.md @@ -0,0 +1,8 @@ +--- +title: Page not found +description: Find the documentation page you need. +sidebar: + hidden: true +--- + +This page is not available in this documentation build. Use search, return to [Getting Started](/), or browse the [event catalog](/events/). diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md new file mode 100644 index 00000000..8bcac6d3 --- /dev/null +++ b/docs/src/content/docs/contributing.md @@ -0,0 +1,6 @@ +--- +title: Contributing to the docs +description: How to edit, build, and preview this documentation. +--- + +This page is written in a later task of the documentation plan. diff --git a/docs/src/content/docs/events/index.md b/docs/src/content/docs/events/index.md new file mode 100644 index 00000000..ba12cf06 --- /dev/null +++ b/docs/src/content/docs/events/index.md @@ -0,0 +1,6 @@ +--- +title: Events +description: Catalog of the events TikTokLiveJava emits. +--- + +This page is written in a later task of the documentation plan. diff --git a/docs/src/content/docs/getting-started/connection.md b/docs/src/content/docs/getting-started/connection.md new file mode 100644 index 00000000..a6a3b8ad --- /dev/null +++ b/docs/src/content/docs/getting-started/connection.md @@ -0,0 +1,6 @@ +--- +title: Connection lifecycle +description: How a TikTokLiveJava client connects, reconnects, and disconnects. +--- + +This page is written in a later task of the documentation plan. diff --git a/docs/src/content/docs/getting-started/install.md b/docs/src/content/docs/getting-started/install.md new file mode 100644 index 00000000..0f794fe2 --- /dev/null +++ b/docs/src/content/docs/getting-started/install.md @@ -0,0 +1,6 @@ +--- +title: Install +description: Add TikTokLiveJava to a Maven or Gradle project. +--- + +This page is written in a later task of the documentation plan. diff --git a/docs/src/content/docs/hosting.md b/docs/src/content/docs/hosting.md new file mode 100644 index 00000000..988c29bf --- /dev/null +++ b/docs/src/content/docs/hosting.md @@ -0,0 +1,6 @@ +--- +title: Hosting +description: How this documentation site is built and deployed. +--- + +This page is written in a later task of the documentation plan. diff --git a/docs/src/content/docs/index.md b/docs/src/content/docs/index.md new file mode 100644 index 00000000..10b8c4c3 --- /dev/null +++ b/docs/src/content/docs/index.md @@ -0,0 +1,6 @@ +--- +title: Getting Started +description: Install TikTokLiveJava and receive comments, gifts, and other events from a TikTok livestream with Java. +--- + +TikTokLiveJava receives comments, gifts, likes, follows, shares, viewer counts, and other events from a TikTok livestream. Create a client with `TikTokLive.newClient("host-name")`, register listeners for the events you need, and connect. Start with [Install](/getting-started/install/), then read the [connection lifecycle](/getting-started/connection/). diff --git a/docs/src/content/docs/troubleshooting.md b/docs/src/content/docs/troubleshooting.md new file mode 100644 index 00000000..ed7a7151 --- /dev/null +++ b/docs/src/content/docs/troubleshooting.md @@ -0,0 +1,6 @@ +--- +title: Troubleshooting +description: Diagnose common TikTokLiveJava connection and runtime problems. +--- + +This page is written in a later task of the documentation plan. diff --git a/docs/tsconfig.json b/docs/tsconfig.json new file mode 100644 index 00000000..8bf91d3b --- /dev/null +++ b/docs/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "astro/tsconfigs/strict", + "include": [".astro/types.d.ts", "**/*"], + "exclude": ["dist"] +} From c110b1ba7d0cef20b70e2815b8465f3f92e4a2c1 Mon Sep 17 00:00:00 2001 From: isaackogan <isaacikogan@gmail.com> Date: Tue, 20 Oct 2026 15:28:17 -0400 Subject: [PATCH 03/31] docs: generate API and protobuf schema reference pages Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- docs/tools/pom.xml | 91 +++ docs/tools/src/main/java/docs/ApiPages.java | 741 ++++++++++++++++++ .../tools/src/main/java/docs/JavaSources.java | 68 ++ docs/tools/src/main/java/docs/Main.java | 119 +++ docs/tools/src/main/java/docs/Markdown.java | 251 ++++++ docs/tools/src/main/java/docs/ProtoEnum.java | 6 + .../src/main/java/docs/ProtoEnumValue.java | 4 + docs/tools/src/main/java/docs/ProtoField.java | 12 + docs/tools/src/main/java/docs/ProtoFile.java | 13 + .../src/main/java/docs/ProtoMessage.java | 20 + .../tools/src/main/java/docs/ProtoParser.java | 329 ++++++++ .../tools/src/main/java/docs/SchemaPages.java | 266 +++++++ .../src/test/java/docs/ApiPagesTest.java | 93 +++ .../src/test/java/docs/ProtoParserTest.java | 97 +++ .../src/test/java/docs/SchemaPagesTest.java | 70 ++ 15 files changed, 2180 insertions(+) create mode 100644 docs/tools/pom.xml create mode 100644 docs/tools/src/main/java/docs/ApiPages.java create mode 100644 docs/tools/src/main/java/docs/JavaSources.java create mode 100644 docs/tools/src/main/java/docs/Main.java create mode 100644 docs/tools/src/main/java/docs/Markdown.java create mode 100644 docs/tools/src/main/java/docs/ProtoEnum.java create mode 100644 docs/tools/src/main/java/docs/ProtoEnumValue.java create mode 100644 docs/tools/src/main/java/docs/ProtoField.java create mode 100644 docs/tools/src/main/java/docs/ProtoFile.java create mode 100644 docs/tools/src/main/java/docs/ProtoMessage.java create mode 100644 docs/tools/src/main/java/docs/ProtoParser.java create mode 100644 docs/tools/src/main/java/docs/SchemaPages.java create mode 100644 docs/tools/src/test/java/docs/ApiPagesTest.java create mode 100644 docs/tools/src/test/java/docs/ProtoParserTest.java create mode 100644 docs/tools/src/test/java/docs/SchemaPagesTest.java diff --git a/docs/tools/pom.xml b/docs/tools/pom.xml new file mode 100644 index 00000000..6e905d26 --- /dev/null +++ b/docs/tools/pom.xml @@ -0,0 +1,91 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + Documentation tools for the Starlight site in docs/: `generate` writes the API and protobuf + schema reference pages, `check` validates the authored pages. Standalone on purpose: it is not + a module of the library build and only reads the library sources. +--> +<project xmlns="http://maven.apache.org/POM/4.0.0" + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> + <modelVersion>4.0.0</modelVersion> + + <groupId>docs</groupId> + <artifactId>docs-tools</artifactId> + <version>1.0.0</version> + <packaging>jar</packaging> + + <properties> + <maven.compiler.release>17</maven.compiler.release> + <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> + <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding> + </properties> + + <dependencies> + <dependency> + <groupId>com.github.javaparser</groupId> + <artifactId>javaparser-core</artifactId> + <version>3.26.4</version> + </dependency> + <dependency> + <groupId>org.junit.jupiter</groupId> + <artifactId>junit-jupiter</artifactId> + <version>5.11.4</version> + <scope>test</scope> + </dependency> + </dependencies> + + <build> + <finalName>docs-tools</finalName> + <plugins> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-compiler-plugin</artifactId> + <version>3.13.0</version> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-surefire-plugin</artifactId> + <version>3.5.2</version> + <configuration> + <systemPropertyVariables> + <!-- The repository root, two levels above docs/tools. --> + <repo.root>${project.basedir}/../..</repo.root> + </systemPropertyVariables> + </configuration> + </plugin> + <plugin> + <groupId>org.apache.maven.plugins</groupId> + <artifactId>maven-shade-plugin</artifactId> + <version>3.6.0</version> + <executions> + <execution> + <phase>package</phase> + <goals> + <goal>shade</goal> + </goals> + <configuration> + <createDependencyReducedPom>false</createDependencyReducedPom> + <filters> + <filter> + <artifact>*:*</artifact> + <excludes> + <exclude>module-info.class</exclude> + <exclude>META-INF/*.SF</exclude> + <exclude>META-INF/*.DSA</exclude> + <exclude>META-INF/*.RSA</exclude> + <exclude>META-INF/MANIFEST.MF</exclude> + </excludes> + </filter> + </filters> + <transformers> + <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> + <mainClass>docs.Main</mainClass> + </transformer> + </transformers> + </configuration> + </execution> + </executions> + </plugin> + </plugins> + </build> +</project> diff --git a/docs/tools/src/main/java/docs/ApiPages.java b/docs/tools/src/main/java/docs/ApiPages.java new file mode 100644 index 00000000..9a054366 --- /dev/null +++ b/docs/tools/src/main/java/docs/ApiPages.java @@ -0,0 +1,741 @@ +package docs; + +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.Modifier; +import com.github.javaparser.ast.NodeList; +import com.github.javaparser.ast.body.AnnotationDeclaration; +import com.github.javaparser.ast.body.AnnotationMemberDeclaration; +import com.github.javaparser.ast.body.CallableDeclaration; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.ConstructorDeclaration; +import com.github.javaparser.ast.body.EnumDeclaration; +import com.github.javaparser.ast.body.FieldDeclaration; +import com.github.javaparser.ast.body.MethodDeclaration; +import com.github.javaparser.ast.body.Parameter; +import com.github.javaparser.ast.body.RecordDeclaration; +import com.github.javaparser.ast.body.TypeDeclaration; +import com.github.javaparser.ast.expr.Expression; +import com.github.javaparser.ast.nodeTypes.NodeWithAnnotations; +import com.github.javaparser.ast.nodeTypes.NodeWithModifiers; +import com.github.javaparser.ast.type.ClassOrInterfaceType; +import com.github.javaparser.ast.type.ReferenceType; +import com.github.javaparser.ast.type.TypeParameter; +import com.github.javaparser.javadoc.Javadoc; +import com.github.javaparser.javadoc.JavadocBlockTag; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Renders the API reference: {@code index.md} at the root, {@code <package path>/index.md} per package and + * {@code <package path>/<type slug>.md} per public type, so Starlight's autogenerated sidebar groups pages by + * package directory. Everything is sorted by name, so the output is deterministic. + */ +final class ApiPages { + + static final String URL_ROOT = "/api/"; + private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z_$][A-Za-z0-9_$]*(?:\\.[A-Za-z_$][A-Za-z0-9_$]*)*"); + private static final int VALUE_WIDTH = 60; + private static final List<String> KINDS = List.of("class", "interface", "enum", "record", "annotation"); + + private ApiPages() {} + + /** + * {@code io.github.jwdeveloper.tiktok.api.live.LiveClient} to {@code io/github/jwdeveloper/tiktok/api/live/liveclient}. + * A nested type is named in binary form, {@code pkg.Outer$Inner}, and slugs to {@code pkg/outer-inner}. + */ + static String slug(String fqcn) { + return fqcn.toLowerCase(Locale.ROOT).replace('.', '/').replace('$', '-'); + } + + static List<Path> render(List<CompilationUnit> units, String version, String sourceBaseUrl, Path outDir) { + return render(units, version, sourceBaseUrl, outDir, Map.of()); + } + + /** + * Renders every page and returns their paths, the root index first. + * + * @param externalLinks root-relative URLs for types documented elsewhere (the protobuf schema pages), keyed by + * fully qualified Java class name with nested classes as {@code pkg.Outer.Inner} + */ + static List<Path> render(List<CompilationUnit> units, String version, String sourceBaseUrl, Path outDir, Map<String, String> externalLinks) { + var types = collect(units); + var linker = new Linker(types, externalLinks); + var packages = packages(types); + var written = new ArrayList<Path>(); + try { + for (var entry : packages.entrySet()) { + var pkg = entry.getKey(); + written.add(Markdown.write(outDir, slug(pkg) + "/index.md", packagePage(pkg, entry.getValue(), packages.keySet()))); + for (var type : entry.getValue()) { + written.add(Markdown.write(outDir, type.slug() + ".md", typePage(type, sourceBaseUrl, linker))); + } + } + written.add(0, Markdown.write(outDir, "index.md", rootPage(version, packages, types))); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return written; + } + + // --- Model ------------------------------------------------------------------------------ + + /** A documented type: its declaration, the unit it lives in, its package and its nesting chain of simple names. */ + record TypeInfo(TypeDeclaration<?> decl, CompilationUnit cu, String pkg, List<String> nesting, TypeInfo outer) { + + String simpleName() { + return nesting.get(nesting.size() - 1); + } + + /** {@code Outer.Inner} for nested types. */ + String displayName() { + return String.join(".", nesting); + } + + String fqcn() { + return pkg + "." + displayName(); + } + + String slug() { + return ApiPages.slug(pkg + "." + String.join("$", nesting)); + } + + String url() { + return URL_ROOT + slug() + "/"; + } + + String kind() { + if (decl instanceof AnnotationDeclaration) { + return "annotation"; + } + if (decl instanceof EnumDeclaration) { + return "enum"; + } + if (decl instanceof RecordDeclaration) { + return "record"; + } + return ((ClassOrInterfaceDeclaration) decl).isInterface() ? "interface" : "class"; + } + + String keyword() { + return kind().equals("annotation") ? "@interface" : kind(); + } + + boolean isInterfaceLike() { + return decl instanceof AnnotationDeclaration || (decl instanceof ClassOrInterfaceDeclaration c && c.isInterface()); + } + } + + private static List<TypeInfo> collect(List<CompilationUnit> units) { + var out = new ArrayList<TypeInfo>(); + for (var cu : units) { + var pkg = cu.getPackageDeclaration().map(p -> p.getNameAsString()).orElse(""); + for (var decl : cu.getTypes()) { + if (decl.isPublic()) { + if (pkg.isEmpty()) { + throw new IllegalStateException("types in the default package are not supported: " + decl.getNameAsString()); + } + collectType(decl, cu, pkg, List.of(), null, out); + } + } + } + out.sort(Comparator.comparing(TypeInfo::pkg).thenComparing(TypeInfo::displayName)); + return out; + } + + private static void collectType(TypeDeclaration<?> decl, CompilationUnit cu, String pkg, List<String> outerNames, TypeInfo outer, List<TypeInfo> out) { + var nesting = new ArrayList<>(outerNames); + nesting.add(decl.getNameAsString()); + var info = new TypeInfo(decl, cu, pkg, List.copyOf(nesting), outer); + out.add(info); + for (var member : decl.getMembers()) { + if (member instanceof TypeDeclaration<?> nested && exposed(nested, info)) { + collectType(nested, cu, pkg, info.nesting(), info, out); + } + } + } + + /** Public, or an implicitly public member of an interface or annotation type. */ + private static boolean exposed(NodeWithModifiers<?> member, TypeInfo owner) { + if (member.hasModifier(Modifier.Keyword.PUBLIC)) { + return true; + } + if (member.hasModifier(Modifier.Keyword.PRIVATE) || member.hasModifier(Modifier.Keyword.PROTECTED)) { + return false; + } + return owner.isInterfaceLike(); + } + + /** Every package with its types, plus the intermediate packages above them (with no types), sorted by name. */ + private static TreeMap<String, List<TypeInfo>> packages(List<TypeInfo> types) { + var map = new TreeMap<String, List<TypeInfo>>(); + for (var type : types) { + map.computeIfAbsent(type.pkg(), k -> new ArrayList<>()).add(type); + var parent = type.pkg(); + while (parent.contains(".")) { + parent = parent.substring(0, parent.lastIndexOf('.')); + map.computeIfAbsent(parent, k -> new ArrayList<>()); + } + } + return map; + } + + // --- Linking ---------------------------------------------------------------------------- + + /** Resolves type names as they appear in signatures to the URL of the page documenting them. */ + static final class Linker { + private final Map<String, TypeInfo> byFqcn = new HashMap<>(); + private final Map<String, List<TypeInfo>> bySimple = new HashMap<>(); + private final Map<String, String> external; + private final Map<String, String> externalBySimple = new HashMap<>(); + private final Map<String, List<TypeInfo>> children = new HashMap<>(); + + Linker(List<TypeInfo> types, Map<String, String> external) { + this.external = external; + for (var type : types) { + byFqcn.put(type.fqcn(), type); + bySimple.computeIfAbsent(type.simpleName(), k -> new ArrayList<>()).add(type); + if (type.outer() != null) { + children.computeIfAbsent(type.outer().fqcn(), k -> new ArrayList<>()).add(type); + } + } + var ambiguous = new TreeSet<String>(); + for (var fqcn : new TreeSet<>(external.keySet())) { + var simple = fqcn.substring(fqcn.lastIndexOf('.') + 1); + if (externalBySimple.put(simple, fqcn) != null) { + ambiguous.add(simple); + } + } + ambiguous.forEach(externalBySimple::remove); + } + + List<TypeInfo> children(TypeInfo type) { + return children.getOrDefault(type.fqcn(), List.of()); + } + + /** The URL for a type name written in {@code scope}, or null when no page documents it. */ + String url(String name, TypeInfo scope) { + var fqcn = resolve(name, scope); + return fqcn == null ? null : urlOf(fqcn); + } + + private String urlOf(String fqcn) { + var type = byFqcn.get(fqcn); + return type != null ? type.url() : external.get(fqcn); + } + + private String resolve(String name, TypeInfo scope) { + if (byFqcn.containsKey(name) || external.containsKey(name)) { + return name; + } + int dot = name.indexOf('.'); + var head = dot < 0 ? name : name.substring(0, dot); + var rest = dot < 0 ? "" : name.substring(dot); + var headFqcn = resolveSimple(head, scope); + if (headFqcn == null) { + return null; + } + var full = headFqcn + rest; + return urlOf(full) != null ? full : null; + } + + /** Java's name resolution, approximately: explicit imports, same package, enclosing types, wildcard imports. */ + private String resolveSimple(String name, TypeInfo scope) { + var wildcards = new ArrayList<String>(); + for (var imp : scope.cu().getImports()) { + if (imp.isStatic()) { + continue; + } + if (imp.isAsterisk()) { + wildcards.add(imp.getNameAsString()); + } else if (imp.getName().getIdentifier().equals(name)) { + return imp.getNameAsString(); + } + } + for (var s = scope; s != null; s = s.outer()) { + if (s.simpleName().equals(name)) { + return s.fqcn(); + } + var nested = s.fqcn() + "." + name; + if (byFqcn.containsKey(nested)) { + return nested; + } + } + var samePackage = scope.pkg() + "." + name; + if (byFqcn.containsKey(samePackage) || external.containsKey(samePackage)) { + return samePackage; + } + for (var wildcard : wildcards) { + var candidate = wildcard + "." + name; + if (urlOf(candidate) != null) { + return candidate; + } + } + var candidates = bySimple.getOrDefault(name, List.of()); + if (candidates.size() == 1) { + return candidates.get(0).fqcn(); + } + return candidates.isEmpty() ? externalBySimple.get(name) : null; + } + + /** A type expression as code spans, with every documented name linked. */ + String markdown(String type, TypeInfo scope) { + var sb = new StringBuilder(); + var run = new StringBuilder(); + var m = IDENTIFIER.matcher(type); + int last = 0; + while (m.find()) { + run.append(type, last, m.start()); + var url = url(m.group(), scope); + if (url == null) { + run.append(m.group()); + } else { + flush(sb, run); + var shown = byFqcn.containsKey(m.group()) ? byFqcn.get(m.group()).displayName() : m.group(); + sb.append(Markdown.link(Markdown.code(shown), url)); + } + last = m.end(); + } + run.append(type.substring(last)); + flush(sb, run); + return sb.toString(); + } + + private static void flush(StringBuilder sb, StringBuilder run) { + if (run.length() > 0) { + sb.append(Markdown.code(run.toString())); + run.setLength(0); + } + } + } + + // --- Pages ------------------------------------------------------------------------------ + + private static String rootPage(String version, TreeMap<String, List<TypeInfo>> packages, List<TypeInfo> types) { + var blurb = "API reference generated from the sources and Javadoc of TikTokLiveJava " + version + + ": every public type of the `API` and `Client` modules."; + var sb = new StringBuilder(Markdown.frontmatter("TikTokLiveJava " + version, blurb, "Overview", 0)); + sb.append(blurb).append("\n\n"); + sb.append("## Packages\n\n"); + for (var entry : packages.entrySet()) { + if (!entry.getValue().isEmpty()) { + sb.append("- ").append(Markdown.link(Markdown.code(entry.getKey()), URL_ROOT + slug(entry.getKey()) + "/")).append('\n'); + } + } + sb.append('\n'); + var byName = new ArrayList<>(types); + byName.sort(Comparator.comparing(TypeInfo::displayName).thenComparing(TypeInfo::pkg)); + for (var kind : KINDS) { + sb.append(listing(heading(kind), byName, t -> t.kind().equals(kind), true)); + } + return sb.toString().stripTrailing() + "\n"; + } + + private static String packagePage(String pkg, List<TypeInfo> types, Set<String> allPackages) { + var label = pkg.substring(pkg.lastIndexOf('.') + 1); + var sb = new StringBuilder(Markdown.frontmatter(pkg, "package " + pkg, label, 0)); + var subpackages = allPackages.stream() + .filter(p -> p.startsWith(pkg + ".") && !p.substring(pkg.length() + 1).contains(".")) + .toList(); + if (!subpackages.isEmpty()) { + sb.append("## Subpackages\n\n"); + for (var sub : subpackages) { + sb.append("- ").append(Markdown.link(Markdown.code(sub.substring(pkg.length() + 1)), URL_ROOT + slug(sub) + "/")).append('\n'); + } + sb.append('\n'); + } + for (var kind : KINDS) { + sb.append(listing(heading(kind), types, t -> t.outer() == null && t.kind().equals(kind), false)); + } + return sb.toString().stripTrailing() + "\n"; + } + + private static String heading(String kind) { + return "## " + switch (kind) { + case "class" -> "Classes"; + case "interface" -> "Interfaces"; + case "enum" -> "Enums"; + case "record" -> "Records"; + default -> "Annotations"; + }; + } + + private static String listing(String heading, List<TypeInfo> types, Predicate<TypeInfo> filter, boolean qualified) { + var lines = new ArrayList<String>(); + for (var type : types) { + if (!filter.test(type)) { + continue; + } + var line = "- " + Markdown.link(Markdown.code(type.displayName()), type.url()); + if (qualified) { + line += " (" + Markdown.code(type.pkg()) + ")"; + } + var summary = Markdown.summary(type.decl().getJavadoc()); + if (!summary.isEmpty()) { + line += " — " + summary; + } + lines.add(line); + } + return lines.isEmpty() ? "" : heading + "\n\n" + String.join("\n", lines) + "\n\n"; + } + + private static String typePage(TypeInfo type, String sourceBaseUrl, Linker linker) { + var javadoc = type.decl().getJavadoc(); + var summary = Markdown.summary(javadoc); + var description = summary.isEmpty() ? type.kind() + " " + type.fqcn() : summary; + var sb = new StringBuilder(Markdown.frontmatter(type.displayName(), description, type.displayName(), null)); + if (type.outer() != null) { + sb.append("Nested in ").append(Markdown.link(Markdown.code(type.outer().displayName()), type.outer().url())).append(".\n\n"); + } + var body = Markdown.body(javadoc); + if (!body.isEmpty()) { + sb.append(body).append("\n\n"); + } + sb.append("## Signature\n\n").append(Markdown.codeBlock("java", typeSignature(type))); + sb.append(valuesSection(type)); + sb.append(fieldsSection(type, linker)); + sb.append(constructorsSection(type, linker)); + sb.append(methodsSection(type, linker)); + sb.append(nestedSection(type, linker)); + sb.append(sourceSection(type, sourceBaseUrl)); + return sb.toString().stripTrailing() + "\n"; + } + + // --- Signatures ------------------------------------------------------------------------- + + private static String typeSignature(TypeInfo type) { + var decl = type.decl(); + var sb = new StringBuilder(annotations(decl)); + var modifiers = modifiers(decl); + if (!modifiers.isEmpty()) { + sb.append(modifiers).append(' '); + } + sb.append(type.keyword()).append(' ').append(type.simpleName()); + if (decl instanceof ClassOrInterfaceDeclaration c) { + sb.append(typeParameters(c.getTypeParameters())); + sb.append(clause(" extends ", c.getExtendedTypes())); + sb.append(clause(" implements ", c.getImplementedTypes())); + } else if (decl instanceof EnumDeclaration e) { + sb.append(clause(" implements ", e.getImplementedTypes())); + } else if (decl instanceof RecordDeclaration r) { + sb.append(typeParameters(r.getTypeParameters())); + sb.append('(').append(parameters(r.getParameters())).append(')'); + sb.append(clause(" implements ", r.getImplementedTypes())); + } + return sb.toString(); + } + + private static String callableSignature(CallableDeclaration<?> callable) { + var sb = new StringBuilder(annotations(callable)); + var modifiers = modifiers(callable); + if (!modifiers.isEmpty()) { + sb.append(modifiers).append(' '); + } + if (!callable.getTypeParameters().isEmpty()) { + sb.append(typeParameters(callable.getTypeParameters())).append(' '); + } + if (callable instanceof MethodDeclaration m) { + sb.append(m.getType().toString()).append(' '); + } + sb.append(callable.getNameAsString()).append('(').append(parameters(callable.getParameters())).append(')'); + if (!callable.getThrownExceptions().isEmpty()) { + sb.append(" throws ").append(callable.getThrownExceptions().stream().map(ReferenceType::toString).collect(Collectors.joining(", "))); + } + return sb.toString(); + } + + private static String annotationMemberSignature(AnnotationMemberDeclaration member) { + var sb = new StringBuilder(annotations(member)); + sb.append(member.getType().toString()).append(' ').append(member.getNameAsString()).append("()"); + member.getDefaultValue().ifPresent(value -> sb.append(" default ").append(value)); + return sb.toString(); + } + + private static String annotations(NodeWithAnnotations<?> node) { + var sb = new StringBuilder(); + for (var annotation : node.getAnnotations()) { + sb.append(annotation).append('\n'); + } + return sb.toString(); + } + + private static String modifiers(NodeWithModifiers<?> node) { + return node.getModifiers().stream().map(m -> m.getKeyword().asString()).collect(Collectors.joining(" ")); + } + + private static String typeParameters(NodeList<TypeParameter> parameters) { + if (parameters.isEmpty()) { + return ""; + } + return "<" + parameters.stream().map(TypeParameter::toString).collect(Collectors.joining(", ")) + ">"; + } + + private static String clause(String keyword, NodeList<ClassOrInterfaceType> types) { + if (types.isEmpty()) { + return ""; + } + return keyword + types.stream().map(ClassOrInterfaceType::toString).collect(Collectors.joining(", ")); + } + + private static String parameters(NodeList<Parameter> parameters) { + return parameters.stream().map(p -> parameterType(p) + " " + p.getNameAsString()).collect(Collectors.joining(", ")); + } + + private static String parameterType(Parameter parameter) { + return parameter.getType().toString() + (parameter.isVarArgs() ? "..." : ""); + } + + // --- Sections --------------------------------------------------------------------------- + + private static String valuesSection(TypeInfo type) { + if (!(type.decl() instanceof EnumDeclaration e) || e.getEntries().isEmpty()) { + return ""; + } + var sb = new StringBuilder("## Values\n\n"); + for (var entry : e.getEntries()) { + sb.append("- **").append(Markdown.code(entry.getNameAsString())).append("**"); + if (!entry.getArguments().isEmpty()) { + sb.append(" (").append(Markdown.code(entry.getArguments().stream().map(Object::toString).collect(Collectors.joining(", ")))).append(')'); + } + var text = Markdown.line(entry.getJavadoc()); + if (!text.isEmpty()) { + sb.append(" — ").append(text); + } + sb.append('\n'); + } + return sb.append('\n').toString(); + } + + private static String fieldsSection(TypeInfo type, Linker linker) { + var entries = new TreeMap<String, String>(); + if (type.decl() instanceof RecordDeclaration r) { + for (var component : r.getParameters()) { + entries.put(component.getNameAsString(), "- **" + Markdown.code(component.getNameAsString()) + "** (" + + linker.markdown(parameterType(component), type) + ")"); + } + } + var classLombok = lombok(type.decl()); + for (var member : type.decl().getMembers()) { + if (!(member instanceof FieldDeclaration field)) { + continue; + } + var fieldLombok = lombok(field); + boolean isStatic = field.isStatic(); + boolean getter = !isStatic && (fieldLombok.contains("Getter") ? !accessLevelNone(field, "Getter") + : classLombok.contains("Getter") || classLombok.contains("Data") || classLombok.contains("Value")); + boolean setter = !isStatic && !field.isFinal() && !classLombok.contains("Value") + && (fieldLombok.contains("Setter") ? !accessLevelNone(field, "Setter") : classLombok.contains("Setter") || classLombok.contains("Data")); + boolean builder = !isStatic && classLombok.contains("Builder"); + if (!(exposed(field, type) || getter || setter || builder)) { + continue; + } + for (var variable : field.getVariables()) { + var meta = new ArrayList<String>(); + meta.add(linker.markdown(variable.getType().toString(), type)); + if (getter) { + meta.add("getter"); + } + if (setter) { + meta.add("setter"); + } + if (builder) { + meta.add("builder"); + } + var line = new StringBuilder("- **").append(Markdown.code(variable.getNameAsString())).append("** (").append(String.join(", ", meta)).append(')'); + variable.getInitializer().filter(ApiPages::showable).ifPresent(value -> line.append(" = ").append(Markdown.code(Markdown.oneLine(value.toString())))); + var text = Markdown.line(field.getJavadoc()); + if (!text.isEmpty()) { + line.append(" — ").append(text); + } + entries.put(variable.getNameAsString(), line.toString()); + } + } + if (entries.isEmpty()) { + return ""; + } + return "## Fields\n\n" + String.join("\n", entries.values()) + "\n\n"; + } + + private static boolean showable(Expression value) { + return (value.isLiteralExpr() || value.isUnaryExpr() || value.isFieldAccessExpr()) && value.toString().length() <= VALUE_WIDTH; + } + + private static String constructorsSection(TypeInfo type, Linker linker) { + var sb = new StringBuilder(); + var lombok = lombok(type.decl()); + var generated = new ArrayList<String>(); + if (lombok.contains("NoArgsConstructor")) { + generated.add("- Lombok `@NoArgsConstructor` generates a constructor with no arguments."); + } + if (lombok.contains("RequiredArgsConstructor")) { + generated.add("- Lombok `@RequiredArgsConstructor` generates a constructor taking the final and `@NonNull` fields."); + } + if (lombok.contains("AllArgsConstructor")) { + generated.add("- Lombok `@AllArgsConstructor` generates a constructor taking every field."); + } else if (lombok.contains("Value")) { + generated.add("- Lombok `@Value` generates a constructor taking every field."); + } + if (lombok.contains("Data") && !lombok.contains("AllArgsConstructor") && !lombok.contains("NoArgsConstructor") && !lombok.contains("RequiredArgsConstructor")) { + generated.add("- Lombok `@Data` generates a constructor taking the final and `@NonNull` fields."); + } + if (lombok.contains("Builder")) { + generated.add("- Lombok `@Builder` generates a builder: `" + type.simpleName() + ".builder()`."); + } + if (!generated.isEmpty()) { + sb.append(String.join("\n", generated)).append("\n\n"); + } + var constructors = new ArrayList<ConstructorDeclaration>(); + for (var member : type.decl().getMembers()) { + if (member instanceof ConstructorDeclaration c && exposed(c, type)) { + constructors.add(c); + } + } + constructors.sort(Comparator.comparingInt((ConstructorDeclaration c) -> c.getParameters().size()).thenComparing(ApiPages::callableSignature)); + for (var constructor : constructors) { + sb.append("### ").append(type.simpleName()).append("\n\n"); + sb.append(callable(constructor, null, type, linker)); + } + return sb.length() == 0 ? "" : "## Constructors\n\n" + sb; + } + + private static String methodsSection(TypeInfo type, Linker linker) { + var sb = new StringBuilder(); + var methods = new ArrayList<MethodDeclaration>(); + var members = new ArrayList<AnnotationMemberDeclaration>(); + for (var member : type.decl().getMembers()) { + if (member instanceof MethodDeclaration m && exposed(m, type)) { + methods.add(m); + } else if (member instanceof AnnotationMemberDeclaration a) { + members.add(a); + } + } + methods.sort(Comparator.comparing(MethodDeclaration::getNameAsString) + .thenComparingInt(m -> m.getParameters().size()) + .thenComparing(ApiPages::callableSignature)); + members.sort(Comparator.comparing(AnnotationMemberDeclaration::getNameAsString)); + for (var member : members) { + sb.append("### ").append(member.getNameAsString()).append("\n\n"); + sb.append(Markdown.codeBlock("java", annotationMemberSignature(member))); + var body = Markdown.body(member.getJavadoc()); + if (!body.isEmpty()) { + sb.append(body).append("\n\n"); + } + } + for (var method : methods) { + sb.append("### ").append(method.getNameAsString()).append("\n\n"); + sb.append(callable(method, method.getType().toString(), type, linker)); + } + return sb.length() == 0 ? "" : "## Methods\n\n" + sb; + } + + /** Signature block, Javadoc body, then parameters, returns and throws with level-4 headings. */ + private static String callable(CallableDeclaration<?> callable, String returnType, TypeInfo type, Linker linker) { + var sb = new StringBuilder(Markdown.codeBlock("java", callableSignature(callable))); + var javadoc = callable.getJavadoc(); + var body = Markdown.body(javadoc); + if (!body.isEmpty()) { + sb.append(body).append("\n\n"); + } + if (!callable.getParameters().isEmpty()) { + sb.append("#### Parameters\n\n"); + for (var parameter : callable.getParameters()) { + sb.append("- **").append(Markdown.code(parameter.getNameAsString())).append("** (").append(linker.markdown(parameterType(parameter), type)).append(')'); + var text = tag(javadoc, JavadocBlockTag.Type.PARAM, parameter.getNameAsString()); + if (!text.isEmpty()) { + sb.append(" — ").append(text); + } + sb.append('\n'); + } + sb.append('\n'); + } + if (returnType != null) { + var text = tag(javadoc, JavadocBlockTag.Type.RETURN, null); + if (!returnType.equals("void") || !text.isEmpty()) { + sb.append("#### Returns\n\n").append(linker.markdown(returnType, type)); + if (!text.isEmpty()) { + sb.append(" — ").append(text); + } + sb.append("\n\n"); + } + } + var thrown = new TreeMap<String, String>(); + for (var exception : callable.getThrownExceptions()) { + thrown.put(exception.toString(), ""); + } + javadoc.ifPresent(j -> j.getBlockTags().stream() + .filter(t -> t.getType() == JavadocBlockTag.Type.THROWS || t.getType() == JavadocBlockTag.Type.EXCEPTION) + .forEach(t -> thrown.put(t.getName().orElse("Exception"), Markdown.tagText(t)))); + if (!thrown.isEmpty()) { + sb.append("#### Throws\n\n"); + thrown.forEach((name, text) -> sb.append("- ").append(linker.markdown(name, type)).append(text.isEmpty() ? "" : " — " + text).append('\n')); + sb.append('\n'); + } + return sb.toString(); + } + + private static String tag(Optional<Javadoc> javadoc, JavadocBlockTag.Type kind, String name) { + if (javadoc.isEmpty()) { + return ""; + } + for (var tag : javadoc.get().getBlockTags()) { + if (tag.getType() == kind && (name == null || tag.getName().map(name::equals).orElse(false))) { + return Markdown.tagText(tag); + } + } + return ""; + } + + private static String nestedSection(TypeInfo type, Linker linker) { + var children = linker.children(type); + if (children.isEmpty()) { + return ""; + } + var sb = new StringBuilder("## Nested types\n\n"); + for (var child : children) { + sb.append("- ").append(Markdown.link(Markdown.code(child.displayName()), child.url())).append(" (").append(child.kind()).append(')'); + var summary = Markdown.summary(child.decl().getJavadoc()); + if (!summary.isEmpty()) { + sb.append(" — ").append(summary); + } + sb.append('\n'); + } + return sb.append('\n').toString(); + } + + private static String sourceSection(TypeInfo type, String sourceBaseUrl) { + var path = JavaSources.sourcePath(type.cu()); + if (path == null) { + return ""; + } + var line = type.decl().getBegin().map(p -> p.line).orElse(0); + var label = line > 0 ? path + ":" + line : path; + var url = sourceBaseUrl + "/" + path + (line > 0 ? "#L" + line : ""); + return "## Source\n\n" + Markdown.link(label, url) + "\n"; + } + + // --- Lombok ----------------------------------------------------------------------------- + + /** Simple names of the Lombok (or any) annotations on a node. */ + private static Set<String> lombok(NodeWithAnnotations<?> node) { + return node.getAnnotations().stream().map(a -> a.getName().getIdentifier()).collect(Collectors.toSet()); + } + + private static boolean accessLevelNone(NodeWithAnnotations<?> node, String annotation) { + return node.getAnnotations().stream() + .anyMatch(a -> a.getName().getIdentifier().equals(annotation) && a.toString().contains("NONE")); + } + +} diff --git a/docs/tools/src/main/java/docs/JavaSources.java b/docs/tools/src/main/java/docs/JavaSources.java new file mode 100644 index 00000000..4a4f6e14 --- /dev/null +++ b/docs/tools/src/main/java/docs/JavaSources.java @@ -0,0 +1,68 @@ +package docs; + +import com.github.javaparser.ParseProblemException; +import com.github.javaparser.ParserConfiguration; +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.ast.CompilationUnit; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +/** Loads the library's Java sources with JavaParser; nothing is compiled, so Lombok annotations stay annotations. */ +final class JavaSources { + + /** The modules whose {@code src/main/java} trees are documented. */ + static final List<String> MODULES = List.of("API", "Client"); + + private JavaSources() {} + + /** Every {@code .java} file under the documented modules, parsed at language level Java 17, in path order. */ + static List<CompilationUnit> load(Path repoRoot) { + StaticJavaParser.setConfiguration(new ParserConfiguration().setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_17)); + var units = new ArrayList<CompilationUnit>(); + for (var module : MODULES) { + var root = repoRoot.resolve(module).resolve("src/main/java"); + if (!Files.isDirectory(root)) { + throw new IllegalStateException("missing source tree " + root); + } + List<Path> files; + try (Stream<Path> walk = Files.walk(root)) { + files = walk.filter(p -> p.toString().endsWith(".java")).sorted().toList(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + for (var file : files) { + try { + units.add(StaticJavaParser.parse(file)); + } catch (ParseProblemException e) { + throw new IllegalStateException("cannot parse " + file + ": " + e.getProblems(), e); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + } + return units; + } + + /** {@code API/src/main/java/io/.../Type.java}: the unit's path from its module directory, or null when unknown. */ + static String sourcePath(CompilationUnit unit) { + var path = unit.getStorage().map(CompilationUnit.Storage::getPath).orElse(null); + if (path == null) { + return null; + } + var names = new ArrayList<String>(); + for (var part : path) { + names.add(part.toString()); + } + for (int i = 1; i + 2 < names.size(); i++) { + if (names.get(i).equals("src") && names.get(i + 1).equals("main") && names.get(i + 2).equals("java")) { + return String.join("/", names.subList(i - 1, names.size())); + } + } + return null; + } +} diff --git a/docs/tools/src/main/java/docs/Main.java b/docs/tools/src/main/java/docs/Main.java new file mode 100644 index 00000000..336a7d41 --- /dev/null +++ b/docs/tools/src/main/java/docs/Main.java @@ -0,0 +1,119 @@ +package docs; + +import java.io.IOException; +import java.io.PrintStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * Entry point of the docs tool: {@code generate <repo root>} writes the API and schema reference pages under + * {@code docs/src/content/docs}; {@code check <repo root>} validates the authored pages. + */ +public final class Main { + + static final String SOURCE_BASE_URL = "https://github.com/jwdeveloper/TikTokLiveJava/blob/master"; + static final Path CONTENT_DIR = Path.of("docs", "src", "content", "docs"); + static final Path PROTO_DIR = Path.of("API", "src", "main", "proto"); + private static final Pattern PARENT_BLOCK = Pattern.compile("(?s)<parent>.*?</parent>"); + private static final Pattern VERSION = Pattern.compile("<version>\\s*([^<\\s]+)\\s*</version>"); + + private Main() {} + + public static void main(String[] args) { + System.exit(run(args, System.out, System.err)); + } + + /** Runs a command; exit code 0 on success, 1 on failure, 2 on usage errors or an unimplemented command. */ + static int run(String[] args, PrintStream out, PrintStream err) { + if (args.length != 2) { + err.println("usage: docs-tools <generate|check> <repository root>"); + return 2; + } + var repoRoot = Path.of(args[1]).toAbsolutePath().normalize(); + switch (args[0]) { + case "generate": + try { + return generate(repoRoot, out); + } catch (RuntimeException e) { + err.println("generate failed: " + e.getMessage()); + e.printStackTrace(err); + return 1; + } + case "check": + err.println("check: not implemented"); + return 2; + default: + err.println("unknown command: " + args[0]); + return 2; + } + } + + /** Wipes and rewrites {@code api/} and {@code schema/} under the content directory. */ + static int generate(Path repoRoot, PrintStream out) { + var version = projectVersion(repoRoot.resolve("pom.xml")); + var units = JavaSources.load(repoRoot); + var protoFiles = protoFiles(repoRoot.resolve(PROTO_DIR)); + var content = repoRoot.resolve(CONTENT_DIR); + var apiDir = content.resolve("api"); + var schemaDir = content.resolve("schema"); + wipe(apiDir); + wipe(schemaDir); + var schemaPages = SchemaPages.render(protoFiles, version, schemaDir); + var apiPages = ApiPages.render(units, version, SOURCE_BASE_URL, apiDir, SchemaPages.javaLinks(protoFiles)); + out.println("Wrote " + apiPages.size() + " API pages to " + apiDir + " and " + schemaPages.size() + " schema pages to " + schemaDir); + return 0; + } + + /** The project's own {@code <version>} in a pom.xml: the first one outside any {@code <parent>} block. */ + static String projectVersion(Path pom) { + String text; + try { + text = Files.readString(pom); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + var m = VERSION.matcher(PARENT_BLOCK.matcher(text).replaceFirst("")); + if (!m.find()) { + throw new IllegalStateException("no <version> in " + pom); + } + return m.group(1); + } + + private static List<ProtoFile> protoFiles(Path dir) { + List<Path> paths; + try (Stream<Path> files = Files.list(dir)) { + paths = files.filter(p -> p.getFileName().toString().endsWith(".proto")).sorted().toList(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + if (paths.isEmpty()) { + throw new IllegalStateException("no .proto files in " + dir); + } + var files = new ArrayList<ProtoFile>(); + for (var path : paths) { + files.add(ProtoParser.parse(path)); + } + return files; + } + + private static void wipe(Path dir) { + try { + if (Files.exists(dir)) { + try (Stream<Path> walk = Files.walk(dir)) { + for (var path : walk.sorted(Comparator.reverseOrder()).toList()) { + Files.delete(path); + } + } + } + Files.createDirectories(dir); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/docs/tools/src/main/java/docs/Markdown.java b/docs/tools/src/main/java/docs/Markdown.java new file mode 100644 index 00000000..59a49fa3 --- /dev/null +++ b/docs/tools/src/main/java/docs/Markdown.java @@ -0,0 +1,251 @@ +package docs; + +import com.github.javaparser.javadoc.Javadoc; +import com.github.javaparser.javadoc.JavadocBlockTag; +import com.github.javaparser.javadoc.description.JavadocDescription; +import com.github.javaparser.javadoc.description.JavadocInlineTag; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** Markdown, YAML and Javadoc-to-Markdown helpers shared by the page generators. */ +final class Markdown { + + private static final Pattern PLAIN_YAML = Pattern.compile("[A-Za-z_][A-Za-z0-9_ .()\\-]*[A-Za-z0-9_.)]"); + private static final Set<String> YAML_RESERVED = Set.of("true", "false", "null", "yes", "no", "on", "off"); + /** A {@code <} that Markdown would read as the start of an HTML tag. */ + private static final Pattern TAG_LIKE = Pattern.compile("<(?=[A-Za-z/])"); + private static final Pattern CODE_SPAN = Pattern.compile("(`[^`]*`)"); + private static final Pattern FIRST_SENTENCE = Pattern.compile("^(.*?[.!?])(?:\\s|$)"); + private static final Pattern A_TAG = Pattern.compile("(?is)<a\\s+[^>]*href=\"([^\"]*)\"[^>]*>(.*?)</a>"); + private static final Pattern CODE_TAG = Pattern.compile("(?is)<(code|tt)>(.*?)</\\1>"); + private static final Pattern MARKDOWN_LINK = Pattern.compile("\\[([^\\]]*)\\]\\([^)]*\\)"); + + private Markdown() {} + + // --- YAML and Markdown blocks --------------------------------------------------------- + + /** Starlight frontmatter: title, description, sidebar label and an optional sidebar order. */ + static String frontmatter(String title, String description, String label, Integer order) { + var sb = new StringBuilder("---\n"); + sb.append("title: ").append(yamlScalar(title)).append('\n'); + sb.append("description: ").append(jsonString(description)).append('\n'); + sb.append("sidebar:\n label: ").append(yamlScalar(label)).append('\n'); + if (order != null) { + sb.append(" order: ").append(order).append('\n'); + } + return sb.append("---\n\n").toString(); + } + + /** A plain YAML scalar when it is unambiguous, else a double-quoted one. */ + static String yamlScalar(String value) { + if (PLAIN_YAML.matcher(value).matches() && !YAML_RESERVED.contains(value.toLowerCase(Locale.ROOT))) { + return value; + } + return jsonString(value); + } + + /** A JSON string literal, which is also a valid double-quoted YAML scalar. */ + static String jsonString(String value) { + var sb = new StringBuilder("\""); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + case '\n' -> sb.append("\\n"); + case '\r' -> sb.append("\\r"); + case '\t' -> sb.append("\\t"); + default -> { + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + } + return sb.append('"').toString(); + } + + static String codeBlock(String language, String source) { + return "```" + language + "\n" + source + "\n```\n\n"; + } + + /** An inline code span; text containing a backtick gets a double-backtick span. */ + static String code(String text) { + return text.contains("`") ? "`` " + text + " ``" : "`" + text + "`"; + } + + static String link(String text, String url) { + return "[" + text + "](" + url + ")"; + } + + /** Text for a Markdown table cell: no pipes, no line breaks. */ + static String cell(String text) { + return oneLine(text).replace("|", "\\|"); + } + + static String oneLine(String text) { + return text.trim().replaceAll("\\s+", " "); + } + + /** Escapes tag-like {@code <} outside code spans so generics in prose survive the Markdown renderer. */ + static String escape(String text) { + var sb = new StringBuilder(); + var m = CODE_SPAN.matcher(text); + int last = 0; + while (m.find()) { + sb.append(TAG_LIKE.matcher(text.substring(last, m.start())).replaceAll("\\\\<")).append(m.group(1)); + last = m.end(); + } + return sb.append(TAG_LIKE.matcher(text.substring(last)).replaceAll("\\\\<")).toString(); + } + + // --- Javadoc ---------------------------------------------------------------------------- + + /** The first sentence of the Javadoc description as one plain line, or an empty string. */ + static String summary(Optional<Javadoc> javadoc) { + var text = javadoc.map(j -> description(j.getDescription())).orElse(""); + if (text.isEmpty()) { + return ""; + } + var line = oneLine(text.split("\\n\\s*\\n", 2)[0]); + var m = FIRST_SENTENCE.matcher(line); + var sentence = m.find() ? m.group(1) : line; + return MARKDOWN_LINK.matcher(sentence).replaceAll("$1").replace("\\<", "<").replace("**", ""); + } + + /** The whole Javadoc description on one line, with {@code @apiNote} appended, or an empty string. */ + static String line(Optional<Javadoc> javadoc) { + if (javadoc.isEmpty()) { + return ""; + } + var sb = new StringBuilder(oneLine(description(javadoc.get().getDescription()))); + for (var tag : javadoc.get().getBlockTags()) { + if (isNote(tag)) { + if (sb.length() > 0) { + sb.append(' '); + } + sb.append("Note: ").append(oneLine(description(tag.getContent()))); + } + } + return sb.toString(); + } + + /** The Javadoc description as Markdown paragraphs, followed by note, deprecation and see-also paragraphs. */ + static String body(Optional<Javadoc> javadoc) { + if (javadoc.isEmpty()) { + return ""; + } + var sb = new StringBuilder(description(javadoc.get().getDescription())); + for (var tag : javadoc.get().getBlockTags()) { + var content = description(tag.getContent()); + if (isNote(tag)) { + paragraph(sb, "**Note:** " + content); + } else if (tag.getType() == JavadocBlockTag.Type.DEPRECATED) { + paragraph(sb, "**Deprecated.** " + content); + } else if (tag.getType() == JavadocBlockTag.Type.SEE && !content.isEmpty()) { + paragraph(sb, "See also: " + (content.contains("`") || content.contains("[") ? content : code(oneLine(content)))); + } + } + return sb.toString().trim(); + } + + /** The text of a block tag ({@code @param}, {@code @return}, {@code @throws}) as one Markdown line. */ + static String tagText(JavadocBlockTag tag) { + return oneLine(description(tag.getContent())); + } + + private static boolean isNote(JavadocBlockTag tag) { + return tag.getType() == JavadocBlockTag.Type.UNKNOWN + && Set.of("apiNote", "implNote", "implSpec").contains(tag.getTagName()); + } + + private static void paragraph(StringBuilder sb, String text) { + if (sb.length() > 0) { + sb.append("\n\n"); + } + sb.append(text); + } + + /** Javadoc description elements as Markdown: inline tags become code spans, HTML becomes Markdown. */ + static String description(JavadocDescription description) { + var sb = new StringBuilder(); + for (var element : description.getElements()) { + if (element instanceof JavadocInlineTag tag) { + sb.append(inlineTag(tag)); + } else { + sb.append(html(element.toText())); + } + } + return tidy(sb.toString()); + } + + private static String inlineTag(JavadocInlineTag tag) { + var content = tag.getContent().trim(); + return switch (tag.getType()) { + case CODE, VALUE -> code(oneLine(content)); + case LITERAL -> escape(content); + case LINK, LINKPLAIN -> { + var parts = content.split("\\s+", 2); + var reference = parts[0].startsWith("#") ? parts[0].substring(1) : parts[0].replace('#', '.'); + yield code(parts.length > 1 ? parts[1] : reference); + } + case INHERIT_DOC -> ""; + default -> escape(content); + }; + } + + /** The HTML subset Javadoc authors use, as Markdown; anything tag-like that remains is escaped. */ + static String html(String text) { + var s = text.replace("<", "<").replace(">", ">").replace(""", "\"").replace("@", "@") + .replace(" ", " ").replace("&", "&"); + s = A_TAG.matcher(s).replaceAll(m -> Matcher.quoteReplacement(link(oneLine(m.group(2)), m.group(1)))); + s = CODE_TAG.matcher(s).replaceAll(m -> Matcher.quoteReplacement(code(oneLine(m.group(2))))); + s = s.replaceAll("(?i)<pre>\\s*", "\n\n```\n").replaceAll("(?i)\\s*</pre>", "\n```\n\n"); + s = s.replaceAll("(?i)</?p\\s*/?>", "\n\n"); + s = s.replaceAll("(?i)<br\\s*/?>", "\n"); + s = s.replaceAll("(?i)\\s*<li>\\s*", "\n- ").replaceAll("(?i)</li>\\s*", "\n"); + s = s.replaceAll("(?i)\\s*</?(ul|ol)>\\s*", "\n\n"); + s = s.replaceAll("(?i)</?(b|strong)>", "**").replaceAll("(?i)</?(i|em)>", "*"); + s = s.replaceAll("(?i)<h[1-6]>", "\n\n**").replaceAll("(?i)</h[1-6]>", "**\n\n"); + return escape(s); + } + + /** Trims every line and collapses runs of blank lines. */ + private static String tidy(String text) { + var lines = text.split("\n", -1); + var sb = new StringBuilder(); + for (var line : lines) { + sb.append(line.strip()).append('\n'); + } + return sb.toString().replaceAll("\n{3,}", "\n\n").trim(); + } + + // --- Files ------------------------------------------------------------------------------ + + /** Writes one page, refusing a {@code name.md} / {@code name/index.md} pair, which would share a URL. */ + static Path write(Path outDir, String relative, String content) throws IOException { + var path = outDir.resolve(relative); + var name = path.getFileName().toString(); + Path twin; + if (name.equals("index.md")) { + var dir = path.getParent(); + twin = dir.resolveSibling(dir.getFileName() + ".md"); + } else { + twin = path.resolveSibling(name.substring(0, name.length() - ".md".length())).resolve("index.md"); + } + if (Files.exists(path) || Files.exists(twin)) { + throw new IllegalStateException(path + " would publish the same URL as an existing page"); + } + Files.createDirectories(path.getParent()); + Files.writeString(path, content); + return path; + } +} diff --git a/docs/tools/src/main/java/docs/ProtoEnum.java b/docs/tools/src/main/java/docs/ProtoEnum.java new file mode 100644 index 00000000..de4cd79b --- /dev/null +++ b/docs/tools/src/main/java/docs/ProtoEnum.java @@ -0,0 +1,6 @@ +package docs; + +import java.util.List; + +/** A protobuf enum with its values in declaration order. */ +record ProtoEnum(String name, List<ProtoEnumValue> values) {} diff --git a/docs/tools/src/main/java/docs/ProtoEnumValue.java b/docs/tools/src/main/java/docs/ProtoEnumValue.java new file mode 100644 index 00000000..37bb4b6f --- /dev/null +++ b/docs/tools/src/main/java/docs/ProtoEnumValue.java @@ -0,0 +1,4 @@ +package docs; + +/** One enum value. */ +record ProtoEnumValue(String name, int number) {} diff --git a/docs/tools/src/main/java/docs/ProtoField.java b/docs/tools/src/main/java/docs/ProtoField.java new file mode 100644 index 00000000..5e6382a0 --- /dev/null +++ b/docs/tools/src/main/java/docs/ProtoField.java @@ -0,0 +1,12 @@ +package docs; + +/** + * A message field. + * + * @param label {@code "repeated"}, {@code "optional"}, {@code "map"}, {@code "oneof"} (a member of a {@code oneof} group) + * or an empty string for a plain singular field + * @param type the type as written ({@code int64}, {@code Image}, {@code Outer.Inner}), or {@code map<K, V>} for maps + * @param name the field name + * @param number the field number + */ +record ProtoField(String label, String type, String name, int number) {} diff --git a/docs/tools/src/main/java/docs/ProtoFile.java b/docs/tools/src/main/java/docs/ProtoFile.java new file mode 100644 index 00000000..6ead0a1d --- /dev/null +++ b/docs/tools/src/main/java/docs/ProtoFile.java @@ -0,0 +1,13 @@ +package docs; + +import java.util.List; + +/** + * One parsed {@code .proto} file. + * + * @param pkg the {@code package} statement, or an empty string + * @param javaPackage the {@code java_package} option, or an empty string + * @param messages top-level messages in declaration order + * @param enums top-level enums in declaration order + */ +record ProtoFile(String pkg, String javaPackage, List<ProtoMessage> messages, List<ProtoEnum> enums) {} diff --git a/docs/tools/src/main/java/docs/ProtoMessage.java b/docs/tools/src/main/java/docs/ProtoMessage.java new file mode 100644 index 00000000..ada873ec --- /dev/null +++ b/docs/tools/src/main/java/docs/ProtoMessage.java @@ -0,0 +1,20 @@ +package docs; + +import java.util.List; + +/** + * A protobuf message. + * + * @param name the simple name + * @param parent the qualified name of the enclosing message ({@code Outer} or {@code Outer.Inner}), or null at top level + * @param fields fields in declaration order, {@code oneof} members included + * @param nested messages declared inside this one + * @param nestedEnums enums declared inside this one + */ +record ProtoMessage(String name, String parent, List<ProtoField> fields, List<ProtoMessage> nested, List<ProtoEnum> nestedEnums) { + + /** {@code Outer.Inner} for a nested message, the simple name at top level. */ + String qualifiedName() { + return parent == null ? name : parent + "." + name; + } +} diff --git a/docs/tools/src/main/java/docs/ProtoParser.java b/docs/tools/src/main/java/docs/ProtoParser.java new file mode 100644 index 00000000..124cb0cc --- /dev/null +++ b/docs/tools/src/main/java/docs/ProtoParser.java @@ -0,0 +1,329 @@ +package docs; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; + +/** + * A hand-written recursive-descent parser for the proto3 subset the library's schema uses: {@code message}, + * {@code enum}, nested types, {@code repeated}/{@code optional} labels, {@code map<K, V>}, {@code oneof}, and + * both comment styles. {@code option}, {@code reserved}, {@code extensions}, {@code import} and {@code syntax} + * statements are skipped (the {@code java_package} option is kept). + */ +final class ProtoParser { + + private static final Pattern IDENTIFIER = Pattern.compile("[A-Za-z_.][A-Za-z0-9_.]*"); + + private record Token(String text, int line) {} + + private final List<Token> tokens; + private final String origin; + private int pos; + + private ProtoParser(List<Token> tokens, String origin) { + this.tokens = tokens; + this.origin = origin; + } + + static ProtoFile parse(Path protoFile) { + String source; + try { + source = Files.readString(protoFile); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return parse(source, protoFile.getFileName().toString()); + } + + /** Parses proto source text; {@code origin} names it in error messages. */ + static ProtoFile parse(String source, String origin) { + return new ProtoParser(lex(source, origin), origin).file(); + } + + // --- Grammar ---------------------------------------------------------------------------- + + private ProtoFile file() { + var pkg = ""; + var javaPackage = ""; + var messages = new ArrayList<ProtoMessage>(); + var enums = new ArrayList<ProtoEnum>(); + while (!eof()) { + var t = peek().text(); + switch (t) { + case "syntax", "import" -> skipStatement(); + case "package" -> { + next(); + pkg = identifier(); + expect(";"); + } + case "option" -> { + next(); + var name = identifier(); + expect("="); + var value = peekIs("{") ? "" : next().text(); + if (name.equals("java_package")) { + javaPackage = unquote(value); + } + skipStatement(); + } + case "message" -> messages.add(message(null)); + case "enum" -> enums.add(enumeration()); + case ";" -> next(); + default -> throw error("unexpected '" + t + "' at top level"); + } + } + return new ProtoFile(pkg, javaPackage, List.copyOf(messages), List.copyOf(enums)); + } + + private ProtoMessage message(String parent) { + expect("message"); + var name = identifier(); + var qualified = parent == null ? name : parent + "." + name; + expect("{"); + var fields = new ArrayList<ProtoField>(); + var nested = new ArrayList<ProtoMessage>(); + var nestedEnums = new ArrayList<ProtoEnum>(); + while (!peekIs("}")) { + var t = peek().text(); + switch (t) { + case "message" -> nested.add(message(qualified)); + case "enum" -> nestedEnums.add(enumeration()); + case "oneof" -> { + next(); + identifier(); + expect("{"); + while (!peekIs("}")) { + if (peekIs("option")) { + skipStatement(); + } else if (peekIs(";")) { + next(); + } else { + fields.add(field("oneof")); + } + } + expect("}"); + } + case "option", "reserved", "extensions", "extend" -> skipStatement(); + case ";" -> next(); + case "map" -> fields.add(mapField()); + case "repeated", "optional", "required" -> { + next(); + fields.add(field(t)); + } + default -> fields.add(field("")); + } + } + expect("}"); + return new ProtoMessage(name, parent, List.copyOf(fields), List.copyOf(nested), List.copyOf(nestedEnums)); + } + + private ProtoField field(String label) { + var type = identifier(); + var name = identifier(); + expect("="); + var number = number(); + options(); + expect(";"); + return new ProtoField(label, type, name, number); + } + + private ProtoField mapField() { + expect("map"); + expect("<"); + var key = identifier(); + expect(","); + var value = identifier(); + expect(">"); + var name = identifier(); + expect("="); + var number = number(); + options(); + expect(";"); + return new ProtoField("map", "map<" + key + ", " + value + ">", name, number); + } + + private ProtoEnum enumeration() { + expect("enum"); + var name = identifier(); + expect("{"); + var values = new ArrayList<ProtoEnumValue>(); + while (!peekIs("}")) { + if (peekIs("option") || peekIs("reserved")) { + skipStatement(); + } else if (peekIs(";")) { + next(); + } else { + var valueName = identifier(); + expect("="); + var number = number(); + options(); + expect(";"); + values.add(new ProtoEnumValue(valueName, number)); + } + } + expect("}"); + return new ProtoEnum(name, List.copyOf(values)); + } + + /** Skips a {@code [ ... ]} option list when one follows. */ + private void options() { + if (!peekIs("[")) { + return; + } + int depth = 0; + do { + var t = next().text(); + if (t.equals("[")) { + depth++; + } else if (t.equals("]")) { + depth--; + } + } while (depth > 0 && !eof()); + if (depth != 0) { + throw error("unterminated field options"); + } + } + + /** Skips through the next top-level {@code ;}, stepping over any braced aggregate on the way. */ + private void skipStatement() { + int depth = 0; + while (!eof()) { + var t = next().text(); + if (t.equals("{")) { + depth++; + } else if (t.equals("}")) { + depth--; + } else if (t.equals(";") && depth == 0) { + return; + } + } + throw error("unterminated statement"); + } + + // --- Tokens ----------------------------------------------------------------------------- + + private String identifier() { + var t = next(); + if (!IDENTIFIER.matcher(t.text()).matches()) { + throw error("expected an identifier, found '" + t.text() + "'", t); + } + return t.text(); + } + + private int number() { + var t = next(); + try { + return Integer.decode(t.text()); + } catch (NumberFormatException e) { + throw error("expected a number, found '" + t.text() + "'", t); + } + } + + private void expect(String text) { + var t = next(); + if (!t.text().equals(text)) { + throw error("expected '" + text + "', found '" + t.text() + "'", t); + } + } + + private boolean eof() { + return pos >= tokens.size(); + } + + private Token peek() { + if (eof()) { + throw error("unexpected end of file"); + } + return tokens.get(pos); + } + + private boolean peekIs(String text) { + return peek().text().equals(text); + } + + private Token next() { + var t = peek(); + pos++; + return t; + } + + private IllegalArgumentException error(String message) { + var line = eof() ? (tokens.isEmpty() ? 0 : tokens.get(tokens.size() - 1).line()) : tokens.get(pos).line(); + return new IllegalArgumentException(origin + ":" + line + ": " + message); + } + + private IllegalArgumentException error(String message, Token at) { + return new IllegalArgumentException(origin + ":" + at.line() + ": " + message); + } + + private static String unquote(String literal) { + if (literal.length() >= 2 && (literal.startsWith("\"") || literal.startsWith("'"))) { + return literal.substring(1, literal.length() - 1); + } + return literal; + } + + /** Splits source into identifiers, numbers, string literals and single-character punctuation; comments are dropped. */ + private static List<Token> lex(String source, String origin) { + var tokens = new ArrayList<Token>(); + int i = 0; + int line = 1; + int n = source.length(); + while (i < n) { + char c = source.charAt(i); + if (c == '\n') { + line++; + i++; + } else if (Character.isWhitespace(c)) { + i++; + } else if (source.startsWith("//", i)) { + while (i < n && source.charAt(i) != '\n') { + i++; + } + } else if (source.startsWith("/*", i)) { + int end = source.indexOf("*/", i + 2); + if (end < 0) { + throw new IllegalArgumentException(origin + ":" + line + ": unterminated block comment"); + } + for (int j = i; j < end; j++) { + if (source.charAt(j) == '\n') { + line++; + } + } + i = end + 2; + } else if (c == '"' || c == '\'') { + int j = i + 1; + while (j < n && source.charAt(j) != c) { + j += source.charAt(j) == '\\' ? 2 : 1; + } + if (j >= n) { + throw new IllegalArgumentException(origin + ":" + line + ": unterminated string literal"); + } + tokens.add(new Token(source.substring(i, j + 1), line)); + i = j + 1; + } else if (Character.isLetter(c) || c == '_' || c == '.') { + int j = i + 1; + while (j < n && (Character.isLetterOrDigit(source.charAt(j)) || source.charAt(j) == '_' || source.charAt(j) == '.')) { + j++; + } + tokens.add(new Token(source.substring(i, j), line)); + i = j; + } else if (Character.isDigit(c) || (c == '-' && i + 1 < n && Character.isDigit(source.charAt(i + 1)))) { + int j = i + 1; + while (j < n && (Character.isLetterOrDigit(source.charAt(j)) || source.charAt(j) == '.')) { + j++; + } + tokens.add(new Token(source.substring(i, j), line)); + i = j; + } else { + tokens.add(new Token(String.valueOf(c), line)); + i++; + } + } + return tokens; + } +} diff --git a/docs/tools/src/main/java/docs/SchemaPages.java b/docs/tools/src/main/java/docs/SchemaPages.java new file mode 100644 index 00000000..0120b0bd --- /dev/null +++ b/docs/tools/src/main/java/docs/SchemaPages.java @@ -0,0 +1,266 @@ +package docs; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +/** + * Renders the protobuf schema reference: {@code index.md} at the root, {@code <file stem>/index.md} per + * {@code .proto} file and {@code <file stem>/<slug>.md} per message and enum, nested ones as + * {@code <outer>-<inner>}. Field types link to the page of the message or enum they name, across files. + */ +final class SchemaPages { + + static final String URL_ROOT = "/schema/"; + private static final Set<String> SCALARS = Set.of("double", "float", "int32", "int64", "uint32", "uint64", "sint32", + "sint64", "fixed32", "fixed64", "sfixed32", "sfixed64", "bool", "string", "bytes"); + + private SchemaPages() {} + + /** A message or enum with the file it comes from and its qualified name ({@code Outer.Inner} when nested). */ + record Entry(ProtoFile file, String stem, String qualified, String parent, ProtoMessage message, ProtoEnum enumeration) { + + boolean isMessage() { + return message != null; + } + + String kind() { + return isMessage() ? "message" : "enum"; + } + + String url() { + return URL_ROOT + stem + "/" + slug(qualified) + "/"; + } + + /** The generated Java class, {@code pkg.Outer.Inner} when nested. */ + String javaName() { + return file.javaPackage().isEmpty() ? qualified : file.javaPackage() + "." + qualified; + } + } + + /** The output directory for a file: the last segment of its {@code java_package} (or {@code package}). */ + static String fileStem(ProtoFile file) { + var pkg = file.javaPackage().isEmpty() ? file.pkg() : file.javaPackage(); + return pkg.substring(pkg.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT); + } + + /** {@code Outer.Inner} to {@code outer-inner}. */ + static String slug(String qualifiedName) { + return qualifiedName.toLowerCase(Locale.ROOT).replace('.', '-'); + } + + /** Root-relative page URLs keyed by generated Java class name, for linking API pages to the schema. */ + static Map<String, String> javaLinks(List<ProtoFile> files) { + var links = new TreeMap<String, String>(); + for (var entry : entries(files)) { + links.put(entry.javaName(), entry.url()); + } + return links; + } + + /** Renders every page and returns their paths, the root index first. */ + static List<Path> render(List<ProtoFile> files, String version, Path outDir) { + var entries = entries(files); + var index = new TreeMap<String, Entry>(); + for (var entry : entries) { + if (index.put(entry.qualified(), entry) != null) { + throw new IllegalStateException("duplicate type " + entry.qualified() + " across proto files"); + } + } + var byFile = new LinkedHashMap<String, List<Entry>>(); + for (var entry : entries) { + byFile.computeIfAbsent(entry.stem(), k -> new ArrayList<>()).add(entry); + } + var written = new ArrayList<Path>(); + try { + for (var fileEntries : byFile.values()) { + var stem = fileEntries.get(0).stem(); + written.add(Markdown.write(outDir, stem + "/index.md", filePage(fileEntries))); + for (var entry : fileEntries) { + written.add(Markdown.write(outDir, stem + "/" + slug(entry.qualified()) + ".md", entry.isMessage() ? messagePage(entry, index) : enumPage(entry))); + } + } + written.add(0, Markdown.write(outDir, "index.md", rootPage(version, byFile.keySet(), entries))); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return written; + } + + // --- Model ------------------------------------------------------------------------------ + + /** Every message and enum of every file, files by stem, entries by qualified name. */ + private static List<Entry> entries(List<ProtoFile> files) { + var sortedFiles = new ArrayList<>(files); + sortedFiles.sort(Comparator.comparing(SchemaPages::fileStem)); + var entries = new ArrayList<Entry>(); + for (var file : sortedFiles) { + var stem = fileStem(file); + var fileEntries = new ArrayList<Entry>(); + for (var message : file.messages()) { + collect(file, stem, message, fileEntries); + } + for (var enumeration : file.enums()) { + fileEntries.add(new Entry(file, stem, enumeration.name(), null, null, enumeration)); + } + fileEntries.sort(Comparator.comparing(Entry::qualified)); + entries.addAll(fileEntries); + } + return entries; + } + + private static void collect(ProtoFile file, String stem, ProtoMessage message, List<Entry> out) { + var qualified = message.qualifiedName(); + out.add(new Entry(file, stem, qualified, message.parent(), message, null)); + for (var nested : message.nested()) { + collect(file, stem, nested, out); + } + for (var nestedEnum : message.nestedEnums()) { + out.add(new Entry(file, stem, qualified + "." + nestedEnum.name(), qualified, null, nestedEnum)); + } + } + + /** The entry a type reference names from inside {@code scope}, searching enclosing messages first. */ + private static Entry resolve(String type, Entry scope, Map<String, Entry> index) { + var name = type.startsWith(".") ? type.substring(1) : type; + var pkg = scope.file().pkg(); + if (!pkg.isEmpty() && name.startsWith(pkg + ".")) { + name = name.substring(pkg.length() + 1); + } + for (var s = scope.qualified(); s != null; s = parentOf(s)) { + var candidate = index.get(s + "." + name); + if (candidate != null) { + return candidate; + } + } + return index.get(name); + } + + private static String parentOf(String qualified) { + int dot = qualified.lastIndexOf('.'); + return dot < 0 ? null : qualified.substring(0, dot); + } + + private static String typeMarkdown(String type, Entry scope, Map<String, Entry> index) { + if (type.startsWith("map<") && type.endsWith(">")) { + var parts = type.substring(4, type.length() - 1).split(",\\s*", 2); + var value = resolve(parts[1], scope, index); + if (value == null) { + return Markdown.code(type); + } + return Markdown.code("map<" + parts[0] + ", ") + Markdown.link(Markdown.code(parts[1]), value.url()) + Markdown.code(">"); + } + if (SCALARS.contains(type)) { + return Markdown.code(type); + } + var target = resolve(type, scope, index); + return target == null ? Markdown.code(type) : Markdown.link(Markdown.code(type), target.url()); + } + + // --- Pages ------------------------------------------------------------------------------ + + private static String rootPage(String version, Set<String> stems, List<Entry> entries) { + var blurb = "Every protobuf message and enum in the schema TikTokLiveJava " + version + + " decodes events from, generated from the `.proto` files in `API/src/main/proto`."; + var sb = new StringBuilder(Markdown.frontmatter("TikTok Webcast schema (" + version + ")", blurb, "Overview", 0)); + sb.append(blurb).append("\n\n## Files\n\n"); + for (var stem : stems) { + sb.append("- ").append(Markdown.link(Markdown.code(stem + ".proto"), URL_ROOT + stem + "/")).append('\n'); + } + sb.append('\n'); + var byName = new ArrayList<>(entries); + byName.sort(Comparator.comparing(Entry::qualified).thenComparing(Entry::stem)); + sb.append(listing("## Messages", byName, true, true)); + sb.append(listing("## Enums", byName, false, true)); + return sb.toString().stripTrailing() + "\n"; + } + + private static String filePage(List<Entry> entries) { + var first = entries.get(0); + var file = first.file(); + var title = first.stem() + ".proto"; + var blurb = "Messages and enums declared in `" + title + "` (package `" + file.pkg() + "`, Java package `" + file.javaPackage() + "`)."; + var sb = new StringBuilder(Markdown.frontmatter(title, "Protobuf definitions in " + title, first.stem(), 0)); + sb.append(blurb).append("\n\n"); + sb.append(listing("## Messages", entries, true, false)); + sb.append(listing("## Enums", entries, false, false)); + return sb.toString().stripTrailing() + "\n"; + } + + private static String listing(String heading, List<Entry> entries, boolean messages, boolean qualified) { + var lines = new ArrayList<String>(); + for (var entry : entries) { + if (entry.isMessage() != messages) { + continue; + } + var line = "- " + Markdown.link(Markdown.code(entry.qualified()), entry.url()); + if (qualified) { + line += " (" + Markdown.code(entry.stem() + ".proto") + ")"; + } + lines.add(line); + } + return lines.isEmpty() ? "" : heading + "\n\n" + String.join("\n", lines) + "\n\n"; + } + + private static String header(Entry entry, Map<String, Entry> index) { + var kind = entry.kind(); + var sb = new StringBuilder(Markdown.frontmatter(entry.qualified(), "Protobuf " + kind + " " + entry.qualified() + " (" + entry.stem() + ".proto)", entry.qualified(), null)); + if (entry.parent() != null) { + var parent = index == null ? null : index.get(entry.parent()); + sb.append("Nested in ").append(parent == null ? Markdown.code(entry.parent()) : Markdown.link(Markdown.code(entry.parent()), parent.url())).append(".\n\n"); + } + sb.append("Protobuf ").append(kind).append(' ').append(Markdown.code(entry.qualified())).append(" declared in ") + .append(Markdown.link(Markdown.code(entry.stem() + ".proto"), URL_ROOT + entry.stem() + "/")) + .append("; generated Java ").append(kind.equals("enum") ? "enum" : "class").append(' ').append(Markdown.code(entry.javaName())).append(".\n\n"); + return sb.toString(); + } + + private static String messagePage(Entry entry, Map<String, Entry> index) { + var sb = new StringBuilder(header(entry, index)); + var fields = entry.message().fields(); + sb.append("## Fields\n\n"); + if (fields.isEmpty()) { + sb.append("This message has no fields.\n\n"); + } else { + sb.append("| Field | Type | Number | Label |\n| --- | --- | --- | --- |\n"); + for (var field : fields) { + sb.append("| ").append(Markdown.code(field.name())).append(" | ").append(typeMarkdown(field.type(), entry, index)) + .append(" | ").append(field.number()).append(" | ").append(field.label()).append(" |\n"); + } + sb.append('\n'); + } + var nested = new ArrayList<Entry>(); + for (var child : entry.message().nested()) { + nested.add(index.get(child.qualifiedName())); + } + for (var child : entry.message().nestedEnums()) { + nested.add(index.get(entry.qualified() + "." + child.name())); + } + nested.sort(Comparator.comparing(Entry::qualified)); + if (!nested.isEmpty()) { + sb.append("## Nested types\n\n"); + for (var child : nested) { + sb.append("- ").append(Markdown.link(Markdown.code(child.qualified()), child.url())).append(" (").append(child.kind()).append(")\n"); + } + sb.append('\n'); + } + return sb.toString().stripTrailing() + "\n"; + } + + private static String enumPage(Entry entry) { + var sb = new StringBuilder(header(entry, null)); + sb.append("## Values\n\n| Name | Number |\n| --- | --- |\n"); + for (var value : entry.enumeration().values()) { + sb.append("| ").append(Markdown.code(value.name())).append(" | ").append(value.number()).append(" |\n"); + } + return sb.toString().stripTrailing() + "\n"; + } +} diff --git a/docs/tools/src/test/java/docs/ApiPagesTest.java b/docs/tools/src/test/java/docs/ApiPagesTest.java new file mode 100644 index 00000000..38049e20 --- /dev/null +++ b/docs/tools/src/test/java/docs/ApiPagesTest.java @@ -0,0 +1,93 @@ +package docs; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ApiPagesTest { + + static final Path REPO = Path.of(System.getProperty("repo.root")).toAbsolutePath().normalize(); + static final String VERSION = "1.13.1-Release"; + static final String SOURCE_BASE = "https://github.com/jwdeveloper/TikTokLiveJava/blob/master"; + + @Test + void slugLowercasesAndNestsPackages() { + assertEquals("io/github/jwdeveloper/tiktok/api/live/liveclient", ApiPages.slug("io.github.jwdeveloper.tiktok.api.live.LiveClient")); + } + + @Test + void nestedTypeSlugJoinsOuterAndInnerWithHyphen() { + assertEquals("io/github/jwdeveloper/tiktok/api/data/requests/livedata-request", + ApiPages.slug("io.github.jwdeveloper.tiktok.api.data.requests.LiveData$Request")); + } + + @Test + void liveClientSettingsPageListsFields(@TempDir Path out) throws IOException { + var units = JavaSources.load(REPO); + var paths = ApiPages.render(units, VERSION, SOURCE_BASE, out); + var page = out.resolve("io/github/jwdeveloper/tiktok/api/data/settings/liveclientsettings.md"); + assertTrue(Files.exists(page), "page exists"); + var md = Files.readString(page); + assertTrue(md.startsWith("---\ntitle: LiveClientSettings\n"), "frontmatter starts with the title"); + assertTrue(md.contains("\nsidebar:\n label: LiveClientSettings\n"), "sidebar label"); + for (var f : List.of("offline", "fetchGifts", "retryOnConnectionFailure", "pingInterval", "apiKey")) { + assertTrue(md.contains("`" + f + "`"), f); + } + assertTrue(md.contains("@Data"), "Lombok annotation kept in the signature"); + assertTrue(md.contains("getter"), "Lombok accessors noted"); + assertTrue(md.contains("## Source"), "source section"); + assertTrue(md.contains(SOURCE_BASE + "/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/settings/LiveClientSettings.java#L"), "source link"); + assertTrue(paths.size() > 150, "pages: " + paths.size()); + assertTrue(Files.exists(out.resolve("index.md")), "root index"); + assertTrue(Files.readString(out.resolve("index.md")).startsWith("---\ntitle: TikTokLiveJava " + VERSION + "\n"), "root index title"); + assertTrue(Files.exists(out.resolve("io/github/jwdeveloper/tiktok/api/data/settings/index.md")), "package index"); + assertTrue(Files.exists(out.resolve("io/github/jwdeveloper/tiktok/api/data/requests/livedata-request.md")), "nested type page"); + } + + @Test + void typeWithoutJavadocStillRenders(@TempDir Path out) throws IOException { + ApiPages.render(JavaSources.load(REPO), VERSION, SOURCE_BASE, out); + var page = out.resolve("io/github/jwdeveloper/tiktok/api/annotations/eventtype.md"); + assertTrue(Files.exists(page), "page exists"); + var md = Files.readString(page); + assertTrue(md.startsWith("---\ntitle: EventType\n"), "title"); + assertTrue(md.contains("\ndescription: \"enum io.github.jwdeveloper.tiktok.api.annotations.EventType\"\n"), "fallback description"); + assertTrue(md.contains("`Control`"), "enum constants listed"); + } + + @Test + void everyPageHasFrontmatter(@TempDir Path out) throws IOException { + var paths = ApiPages.render(JavaSources.load(REPO), VERSION, SOURCE_BASE, out); + for (var p : paths) { + var md = Files.readString(p); + assertTrue(md.startsWith("---\ntitle: "), p + " starts with frontmatter"); + assertTrue(md.contains("\nsidebar:\n label: "), p + " has a sidebar label"); + } + } + + @Test + void outputIsDeterministic(@TempDir Path a, @TempDir Path b) throws IOException { + var units = JavaSources.load(REPO); + var first = ApiPages.render(units, VERSION, SOURCE_BASE, a); + var second = ApiPages.render(JavaSources.load(REPO), VERSION, SOURCE_BASE, b); + assertEquals(relative(a, first), relative(b, second), "same file list in the same order"); + for (var p : first) { + var twin = b.resolve(a.relativize(p)); + assertEquals(Files.readString(p), Files.readString(twin), "same content for " + a.relativize(p)); + } + try (Stream<Path> files = Files.walk(a)) { + assertEquals(first.size(), files.filter(Files::isRegularFile).count(), "render returns every file it wrote"); + } + } + + private static List<String> relative(Path root, List<Path> paths) { + return paths.stream().map(p -> root.relativize(p).toString()).toList(); + } +} diff --git a/docs/tools/src/test/java/docs/ProtoParserTest.java b/docs/tools/src/test/java/docs/ProtoParserTest.java new file mode 100644 index 00000000..841b41b5 --- /dev/null +++ b/docs/tools/src/test/java/docs/ProtoParserTest.java @@ -0,0 +1,97 @@ +package docs; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ProtoParserTest { + + static final Path REPO = Path.of(System.getProperty("repo.root")).toAbsolutePath().normalize(); + + @Test + void parsesMessagesEnumsAndNested() { + var f = ProtoParser.parse(REPO.resolve("API/src/main/proto/webcast.proto")); + assertEquals("TikTok", f.pkg()); + assertEquals("io.github.jwdeveloper.tiktok.api.messages.webcast", f.javaPackage()); + assertTrue(f.messages().stream().anyMatch(m -> m.name().equals("WebcastChatMessage"))); + assertTrue(f.messages().stream().anyMatch(m -> !m.nested().isEmpty()), "a message with nested messages"); + assertTrue(f.messages().stream().anyMatch(m -> !m.nestedEnums().isEmpty()), "a message with nested enums"); + assertTrue(f.messages().stream().allMatch(m -> m.parent() == null), "top-level messages have no parent"); + var pin = f.messages().stream().filter(m -> m.name().equals("WebcastRoomPinMessage")).findFirst().orElseThrow(); + assertTrue(pin.fields().stream().anyMatch(x -> x.name().equals("chat_message") && x.label().equals("oneof")), "oneof member"); + assertTrue(f.messages().stream().noneMatch(m -> m.name().equals("RoomMessage")), "block-commented messages are ignored"); + var enums = ProtoParser.parse(REPO.resolve("API/src/main/proto/enums.proto")); + assertTrue(enums.messages().isEmpty()); + assertTrue(enums.enums().size() > 90, "enums: " + enums.enums().size()); + } + + @Test + void parsesRepeatedMapAndOneof(@TempDir Path dir) throws IOException { + var source = """ + syntax = "proto3"; + package Fixture; // trailing comment + option java_package = "fixture.gen"; + /* a block comment + message Ghost { int32 x = 1; } */ + import "other.proto"; + + // A message. + message Outer { + option deprecated = true; + reserved 9, 10 to 12; + reserved "old"; + string name = 1; + repeated Inner items = 2; + map<string, int32> counts = 3 [deprecated = true]; + optional bool flag = 4;; + oneof choice + { + Inner first = 5; + int64 second = 6; + } + message Inner { + Kind kind = 1; + enum Kind { UNKNOWN = 0; ODD = -1; } + } + } + enum Color { RED = 0; GREEN = 1; } + """; + var file = dir.resolve("fixture.proto"); + Files.writeString(file, source); + var f = ProtoParser.parse(file); + assertEquals("Fixture", f.pkg()); + assertEquals("fixture.gen", f.javaPackage()); + assertEquals(1, f.messages().size()); + assertEquals(1, f.enums().size()); + assertEquals("Color", f.enums().get(0).name()); + assertEquals(2, f.enums().get(0).values().size()); + assertEquals(1, f.enums().get(0).values().get(1).number()); + + var outer = f.messages().get(0); + assertEquals("Outer", outer.name()); + assertNull(outer.parent()); + var fields = outer.fields(); + assertEquals(6, fields.size(), "fields: " + fields); + assertEquals(new ProtoField("", "string", "name", 1), fields.get(0)); + assertEquals(new ProtoField("repeated", "Inner", "items", 2), fields.get(1)); + assertEquals(new ProtoField("map", "map<string, int32>", "counts", 3), fields.get(2)); + assertEquals(new ProtoField("optional", "bool", "flag", 4), fields.get(3)); + assertEquals(new ProtoField("oneof", "Inner", "first", 5), fields.get(4)); + assertEquals(new ProtoField("oneof", "int64", "second", 6), fields.get(5)); + + assertEquals(1, outer.nested().size()); + var inner = outer.nested().get(0); + assertEquals("Inner", inner.name()); + assertEquals("Outer", inner.parent()); + assertEquals(1, inner.nestedEnums().size()); + var kind = inner.nestedEnums().get(0); + assertEquals("Kind", kind.name()); + assertEquals(new ProtoEnumValue("ODD", -1), kind.values().get(1)); + } +} diff --git a/docs/tools/src/test/java/docs/SchemaPagesTest.java b/docs/tools/src/test/java/docs/SchemaPagesTest.java new file mode 100644 index 00000000..d4a0fee1 --- /dev/null +++ b/docs/tools/src/test/java/docs/SchemaPagesTest.java @@ -0,0 +1,70 @@ +package docs; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class SchemaPagesTest { + + static final Path REPO = Path.of(System.getProperty("repo.root")).toAbsolutePath().normalize(); + static final String VERSION = "1.13.1-Release"; + + private static List<ProtoFile> files() { + return List.of( + ProtoParser.parse(REPO.resolve("API/src/main/proto/webcast.proto")), + ProtoParser.parse(REPO.resolve("API/src/main/proto/data.proto")), + ProtoParser.parse(REPO.resolve("API/src/main/proto/enums.proto"))); + } + + @Test + void nestedMessageGetsPage(@TempDir Path out) throws IOException { + var paths = SchemaPages.render(files(), VERSION, out); + assertTrue(Files.exists(out.resolve("webcast/webcastchatmessage.md"))); + assertTrue(paths.stream().anyMatch(p -> p.getFileName().toString().contains("-")), "a nested type page named outer-inner"); + assertTrue(paths.size() > 300, "pages: " + paths.size()); + var index = Files.readString(out.resolve("index.md")); + assertTrue(index.startsWith("---\ntitle: TikTok Webcast schema (" + VERSION + ")\n"), "index title"); + assertTrue(Files.exists(out.resolve("webcast/index.md")), "per-file index"); + assertTrue(Files.exists(out.resolve("enums/auditstatus.md")), "enum page"); + } + + @Test + void fieldsTableLinksTypesAcrossFiles(@TempDir Path out) throws IOException { + SchemaPages.render(files(), VERSION, out); + var chat = Files.readString(out.resolve("webcast/webcastchatmessage.md")); + assertTrue(chat.startsWith("---\ntitle: WebcastChatMessage\n"), "title"); + assertTrue(chat.contains("| Field | Type | Number | Label |"), "fields table header"); + assertTrue(chat.contains("[`CommonMessageData`](/schema/data/commonmessagedata/)"), "cross-file link to data.proto: " + chat); + var pin = Files.readString(out.resolve("webcast/webcastroompinmessage.md")); + assertTrue(pin.contains("| oneof |"), "oneof label column"); + assertTrue(pin.contains("[`WebcastChatMessage`](/schema/webcast/webcastchatmessage/)"), "same-file link"); + } + + @Test + void nestedPageIsTitledOuterDotInner(@TempDir Path out) throws IOException { + var paths = SchemaPages.render(files(), VERSION, out); + var nested = paths.stream().filter(p -> p.getFileName().toString().contains("-")).findFirst().orElseThrow(); + var md = Files.readString(nested); + assertTrue(md.startsWith("---\ntitle: "), "frontmatter"); + var title = md.lines().skip(1).findFirst().orElseThrow(); + assertTrue(title.matches("title: \"?[A-Za-z0-9_]+\\.[A-Za-z0-9_.]+\"?"), "Outer.Inner title: " + title); + assertTrue(md.contains("Nested in ["), "link back to the enclosing message: " + nested); + } + + @Test + void outputIsDeterministic(@TempDir Path a, @TempDir Path b) throws IOException { + var first = SchemaPages.render(files(), VERSION, a); + var second = SchemaPages.render(files(), VERSION, b); + assertEquals(first.stream().map(p -> a.relativize(p).toString()).toList(), + second.stream().map(p -> b.relativize(p).toString()).toList()); + for (var p : first) { + assertEquals(Files.readString(p), Files.readString(b.resolve(a.relativize(p))), a.relativize(p).toString()); + } + } +} From 86b7d30c6df4c35f60557b7a0c596f16c628836f Mon Sep 17 00:00:00 2001 From: isaackogan <isaacikogan@gmail.com> Date: Tue, 20 Oct 2026 15:50:23 -0400 Subject: [PATCH 04/31] docs: add content coverage and example compilation checks Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- docs/package.json | 3 +- .../src/main/java/docs/ContentCheck.java | 418 ++++++++++++++++++ docs/tools/src/main/java/docs/Example.java | 4 + .../src/main/java/docs/ExampleCompiler.java | 311 +++++++++++++ docs/tools/src/main/java/docs/Main.java | 11 +- .../src/test/java/docs/ContentCheckTest.java | 178 ++++++++ .../test/java/docs/ExampleCompilerTest.java | 106 +++++ 7 files changed, 1027 insertions(+), 4 deletions(-) create mode 100644 docs/tools/src/main/java/docs/ContentCheck.java create mode 100644 docs/tools/src/main/java/docs/Example.java create mode 100644 docs/tools/src/main/java/docs/ExampleCompiler.java create mode 100644 docs/tools/src/test/java/docs/ContentCheckTest.java create mode 100644 docs/tools/src/test/java/docs/ExampleCompilerTest.java diff --git a/docs/package.json b/docs/package.json index 8a04bfbf..15ef21d4 100644 --- a/docs/package.json +++ b/docs/package.json @@ -8,10 +8,11 @@ }, "scripts": { "dev": "astro dev --host 127.0.0.1", + "lib:build": "cd .. && mvn -q -pl API,Client,extension-recorder,extension-collector -am -DskipTests install dependency:build-classpath -Dmdep.outputFile=target/classpath.txt", "tools:build": "mvn -q -f tools/pom.xml -DskipTests package", "check": "java -jar tools/target/docs-tools.jar check ..", "generate": "java -jar tools/target/docs-tools.jar generate ..", - "build": "pnpm run tools:build && pnpm run check && pnpm run generate && astro build", + "build": "pnpm run lib:build && pnpm run tools:build && pnpm run check && pnpm run generate && astro build", "preview": "astro preview --host 127.0.0.1", "deploy:cloudflare": "wrangler deploy" }, diff --git a/docs/tools/src/main/java/docs/ContentCheck.java b/docs/tools/src/main/java/docs/ContentCheck.java new file mode 100644 index 00000000..c5eb0827 --- /dev/null +++ b/docs/tools/src/main/java/docs/ContentCheck.java @@ -0,0 +1,418 @@ +package docs; + +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.PackageDeclaration; +import com.github.javaparser.ast.body.ClassOrInterfaceDeclaration; +import com.github.javaparser.ast.body.MethodDeclaration; +import com.github.javaparser.ast.body.TypeDeclaration; +import com.github.javaparser.ast.expr.AnnotationExpr; +import com.github.javaparser.ast.expr.Expression; +import java.io.IOException; +import java.io.PrintStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * The {@code check} command: verifies that the authored pages cover the library (events, settings, handlers, + * exceptions, extensions), reference no Discord link, agree with the README's events list, and that every Java + * example compiles. + */ +final class ContentCheck { + + static final List<String> SECTIONS = List.of("events", "settings", "builder", "exceptions", "extensions", "discord", "readme", "examples"); + static final List<String> EXTENSIONS = List.of("TikTokLiveRecorder", "TikTokLiveCollector"); + static final String EVENTS_PACKAGE = "io.github.jwdeveloper.tiktok.api.data.events"; + static final String EXCEPTIONS_PACKAGE = "io.github.jwdeveloper.tiktok.api.exceptions"; + static final String EVENTS_BUILDER = "io.github.jwdeveloper.tiktok.api.live.builder.EventsBuilder"; + static final String LIVE_CLIENT = "io.github.jwdeveloper.tiktok.api.live.LiveClient"; + static final List<String> SETTINGS_CLASSES = List.of("io.github.jwdeveloper.tiktok.api.data.settings.LiveClientSettings", + "io.github.jwdeveloper.tiktok.api.data.settings.HttpClientSettings"); + static final String CATALOG_PAGE = "events/index.md"; + /** Category of an event class under the events package that carries no {@code @EventMeta}. */ + static final String NO_CATEGORY = "none"; + /** Generated directories under the content root; their pages are not authored documentation. */ + static final Set<String> GENERATED_DIRS = Set.of("api", "schema"); + + private static final Pattern CATALOG_ROW = Pattern.compile("^\\s*\\|\\s*`(\\w+)`\\s*\\|\\s*(\\w+)\\s*\\|", Pattern.MULTILINE); + private static final Pattern README_ITEM = Pattern.compile("^\\s*-\\s+\\[(on\\w+)]\\(#([\\w-]+)\\)", Pattern.MULTILINE); + private static final Pattern DISCORD = Pattern.compile("(?i)discord\\.(com|gg)"); + private static final Pattern FENCE_OPEN = Pattern.compile("^\\s*(`{3,}|~{3,})\\s*(\\w*)(.*)$"); + private static final Pattern FENCE_CLOSE = Pattern.compile("^\\s*(`{3,}|~{3,})\\s*$"); + + private ContentCheck() {} + + /** An authored page: its path relative to the content root with {@code /} separators, and its text. */ + record Page(String path, String text) {} + + // --- command ------------------------------------------------------------------------------ + + /** Prints one line per section in order, then the summary; returns 1 when any section failed. */ + static int run(Path repoRoot, PrintStream out) { + var units = JavaSources.load(repoRoot); + var events = events(units); + var settings = settingsFields(units); + var handlers = handlers(units); + var exceptions = exceptions(units); + var pages = pages(repoRoot.resolve(Main.CONTENT_DIR)); + var examples = examples(pages); + var results = new LinkedHashMap<String, List<String>>(); + results.put("events", checkEvents(events, pages)); + results.put("settings", checkNames("settings field", settings, pages)); + results.put("builder", checkNames("handler", handlers, pages)); + results.put("exceptions", checkNames("exception", exceptions, pages)); + results.put("extensions", checkNames("extension", EXTENSIONS, pages)); + results.put("discord", checkDiscord(pages)); + results.put("readme", checkReadme(read(repoRoot.resolve("README.md")), builderHandlers(units), eventClassNames(units))); + results.put("examples", ExampleCompiler.compile(examples, repoRoot, ExampleCompiler.packages(units))); + var failed = false; + for (var section : SECTIONS) { + var failures = results.get(section); + if (failures.isEmpty()) { + out.println("PASS " + section); + } else { + failed = true; + out.println("FAIL " + section + ": " + String.join("; ", failures.subList(0, Math.min(3, failures.size())))); + } + } + out.println("Checked " + pages.size() + " pages, " + events.size() + " events, " + settings.size() + " settings fields, " + + handlers.size() + " handlers, " + exceptions.size() + " exceptions, " + examples.size() + " Java examples."); + return failed ? 1 : 0; + } + + // --- extraction --------------------------------------------------------------------------- + + /** Concrete event class → {@code @EventMeta} category ({@code Control}, {@code Message}, {@code Debug}) or {@code none}. */ + static Map<String, String> events(Path repoRoot) { + return events(JavaSources.load(repoRoot)); + } + + static Map<String, String> events(List<CompilationUnit> units) { + var events = new TreeMap<String, String>(); + for (var type : eventTypes(units)) { + if (type instanceof ClassOrInterfaceDeclaration c && !c.isInterface() && !c.isAbstract()) { + events.put(c.getNameAsString(), c.getAnnotationByName("EventMeta").map(ContentCheck::category).orElse(NO_CATEGORY)); + } + } + return events; + } + + /** Every top-level type under the events package, abstract base classes included: what a README anchor may name. */ + static Set<String> eventClassNames(Path repoRoot) { + return eventClassNames(JavaSources.load(repoRoot)); + } + + static Set<String> eventClassNames(List<CompilationUnit> units) { + var names = new TreeSet<String>(); + for (var type : eventTypes(units)) { + names.add(type.getNameAsString()); + } + return names; + } + + /** Instance fields of {@code LiveClientSettings} and {@code HttpClientSettings}, in declaration order. */ + static List<String> settingsFields(Path repoRoot) { + return settingsFields(JavaSources.load(repoRoot)); + } + + static List<String> settingsFields(List<CompilationUnit> units) { + var fields = new ArrayList<String>(); + for (var name : SETTINGS_CLASSES) { + for (var field : type(units, name).getFields()) { + if (!field.isStatic()) { + for (var variable : field.getVariables()) { + fields.add(variable.getNameAsString()); + } + } + } + } + return fields; + } + + /** Distinct names of the public {@code on*} methods of {@code EventsBuilder}, in declaration order. */ + static List<String> builderHandlers(Path repoRoot) { + return builderHandlers(JavaSources.load(repoRoot)); + } + + static List<String> builderHandlers(List<CompilationUnit> units) { + return publicMethods(type(units, EVENTS_BUILDER), true); + } + + /** {@link #builderHandlers} followed by the distinct public methods of {@code LiveClient}. */ + static List<String> handlers(Path repoRoot) { + return handlers(JavaSources.load(repoRoot)); + } + + static List<String> handlers(List<CompilationUnit> units) { + var handlers = new LinkedHashSet<>(builderHandlers(units)); + handlers.addAll(publicMethods(type(units, LIVE_CLIENT), false)); + return new ArrayList<>(handlers); + } + + /** Names of the top-level types under the exceptions package, sorted. */ + static List<String> exceptions(Path repoRoot) { + return exceptions(JavaSources.load(repoRoot)); + } + + static List<String> exceptions(List<CompilationUnit> units) { + var names = new TreeSet<String>(); + for (var unit : units) { + if (packageOf(unit).equals(EXCEPTIONS_PACKAGE)) { + for (var type : unit.getTypes()) { + names.add(type.getNameAsString()); + } + } + } + return new ArrayList<>(names); + } + + private static List<TypeDeclaration<?>> eventTypes(List<CompilationUnit> units) { + var types = new ArrayList<TypeDeclaration<?>>(); + for (var unit : units) { + var pkg = packageOf(unit); + if (pkg.equals(EVENTS_PACKAGE) || pkg.startsWith(EVENTS_PACKAGE + ".")) { + types.addAll(unit.getTypes()); + } + } + return types; + } + + /** {@code EventType.Message} → {@code Message}; an {@code @EventMeta} without the member is {@code none}. */ + private static String category(AnnotationExpr annotation) { + Expression value = null; + if (annotation.isNormalAnnotationExpr()) { + for (var pair : annotation.asNormalAnnotationExpr().getPairs()) { + if (pair.getNameAsString().equals("eventType")) { + value = pair.getValue(); + } + } + } else if (annotation.isSingleMemberAnnotationExpr()) { + value = annotation.asSingleMemberAnnotationExpr().getMemberValue(); + } + if (value == null) { + return NO_CATEGORY; + } + var text = value.toString(); + return text.substring(text.lastIndexOf('.') + 1); + } + + private static List<String> publicMethods(TypeDeclaration<?> type, boolean onlyHandlers) { + var isInterface = type instanceof ClassOrInterfaceDeclaration c && c.isInterface(); + var names = new LinkedHashSet<String>(); + for (MethodDeclaration method : type.getMethods()) { + var isPublic = method.isPublic() || (isInterface && !method.isPrivate()); + var name = method.getNameAsString(); + if (isPublic && (!onlyHandlers || (name.startsWith("on") && name.length() > 2 && Character.isUpperCase(name.charAt(2))))) { + names.add(name); + } + } + return new ArrayList<>(names); + } + + private static TypeDeclaration<?> type(List<CompilationUnit> units, String qualifiedName) { + var dot = qualifiedName.lastIndexOf('.'); + var pkg = qualifiedName.substring(0, dot); + var name = qualifiedName.substring(dot + 1); + for (var unit : units) { + if (packageOf(unit).equals(pkg)) { + for (var type : unit.getTypes()) { + if (type.getNameAsString().equals(name)) { + return type; + } + } + } + } + throw new IllegalStateException("missing type " + qualifiedName); + } + + private static String packageOf(CompilationUnit unit) { + return unit.getPackageDeclaration().map(PackageDeclaration::getNameAsString).orElse(""); + } + + // --- pages -------------------------------------------------------------------------------- + + /** Every {@code .md}/{@code .mdx} page under the content root except the generated {@code api/} and {@code schema/} trees. */ + static List<Page> pages(Path contentDir) { + if (!Files.isDirectory(contentDir)) { + throw new IllegalStateException("missing content directory " + contentDir); + } + var pages = new ArrayList<Page>(); + try (Stream<Path> walk = Files.walk(contentDir)) { + for (var path : walk.filter(Files::isRegularFile).sorted().toList()) { + var relative = contentDir.relativize(path); + var name = relative.getFileName().toString(); + if (!(name.endsWith(".md") || name.endsWith(".mdx")) || GENERATED_DIRS.contains(relative.getName(0).toString())) { + continue; + } + var parts = new ArrayList<String>(); + relative.forEach(p -> parts.add(p.toString())); + pages.add(new Page(String.join("/", parts), Files.readString(path))); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return pages; + } + + /** Every {@code ```java} fence of every page, dedented, labelled "{@code <page>, example <n>}". */ + static List<Example> examples(List<Page> pages) { + var examples = new ArrayList<Example>(); + for (var page : pages) { + var n = 0; + for (var body : fences(page.text(), "java")) { + examples.add(new Example(page.path() + ", example " + ++n, body)); + } + } + return examples; + } + + /** The dedented bodies of the fences with the given language, at any indentation; other fences are skipped. */ + static List<String> fences(String text, String language) { + var bodies = new ArrayList<String>(); + String fence = null; + String lang = null; + var body = new ArrayList<String>(); + for (var line : text.split("\n", -1)) { + if (fence == null) { + var open = FENCE_OPEN.matcher(line); + if (open.matches()) { + fence = open.group(1); + lang = open.group(2); + body.clear(); + } + continue; + } + var close = FENCE_CLOSE.matcher(line); + if (close.matches() && close.group(1).charAt(0) == fence.charAt(0) && close.group(1).length() >= fence.length()) { + if (lang.equals(language)) { + bodies.add(dedent(body)); + } + fence = null; + continue; + } + body.add(line); + } + return bodies; + } + + private static String dedent(List<String> lines) { + var indent = Integer.MAX_VALUE; + for (var line : lines) { + if (!line.isBlank()) { + indent = Math.min(indent, line.length() - line.stripLeading().length()); + } + } + var out = new ArrayList<String>(); + for (var line : lines) { + out.add(line.isBlank() ? "" : line.substring(indent)); + } + return String.join("\n", out).stripTrailing(); + } + + // --- rules -------------------------------------------------------------------------------- + + /** Every event is named in backticks on some page and has a catalog row whose type equals its category. */ + static List<String> checkEvents(Map<String, String> events, List<Page> pages) { + var failures = new ArrayList<String>(); + var catalog = pages.stream().filter(p -> p.path().equals(CATALOG_PAGE)).findFirst().map(Page::text).orElse(null); + if (catalog == null) { + failures.add("Event catalog page missing: " + CATALOG_PAGE); + } + var rows = new HashMap<String, String>(); + var m = CATALOG_ROW.matcher(catalog == null ? "" : catalog); + while (m.find()) { + rows.putIfAbsent(m.group(1), m.group(2)); + } + for (var entry : new TreeMap<>(events).entrySet()) { + var name = entry.getKey(); + if (!mentioned(name, pages)) { + failures.add("Missing event documentation: " + name); + } + var type = rows.get(name); + if (type == null) { + failures.add("Event catalog row missing: " + name); + } else if (!type.equals(entry.getValue())) { + failures.add("Event catalog row is stale: " + name); + } + } + return failures; + } + + /** Every name is mentioned in backticks on some page; {@code kind} names the failure. */ + static List<String> checkNames(String kind, Collection<String> names, List<Page> pages) { + var failures = new ArrayList<String>(); + for (var name : names) { + if (!mentioned(name, pages)) { + failures.add("Missing " + kind + " documentation: " + name); + } + } + return failures; + } + + /** No authored page links to Discord. */ + static List<String> checkDiscord(List<Page> pages) { + var failures = new ArrayList<String>(); + for (var page : pages) { + if (DISCORD.matcher(page.text()).find()) { + failures.add(page.path() + ": published pages must not reference Discord"); + } + } + return failures; + } + + /** + * Every {@code - [onX](#onx-tiktokxevent)} item of the README, whatever follows the link, names an event class + * (the anchor's last segment, compared case-insensitively) and an {@code EventsBuilder} handler. + */ + static List<String> checkReadme(String readme, Collection<String> builderHandlers, Collection<String> eventClasses) { + var lower = new HashMap<String, String>(); + for (var name : eventClasses) { + lower.put(name.toLowerCase(Locale.ROOT), name); + } + var failures = new LinkedHashSet<String>(); + var m = README_ITEM.matcher(readme); + while (m.find()) { + var handler = m.group(1); + var anchor = m.group(2); + var event = anchor.substring(anchor.lastIndexOf('-') + 1); + if (!lower.containsKey(event.toLowerCase(Locale.ROOT))) { + failures.add("README handler has no event class: " + handler); + } + if (!builderHandlers.contains(handler)) { + failures.add("README handler is not on EventsBuilder: " + handler); + } + } + return new ArrayList<>(failures); + } + + private static boolean mentioned(String name, List<Page> pages) { + var span = "`" + name + "`"; + for (var page : pages) { + if (page.text().contains(span)) { + return true; + } + } + return false; + } + + private static String read(Path file) { + try { + return Files.readString(file); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/docs/tools/src/main/java/docs/Example.java b/docs/tools/src/main/java/docs/Example.java new file mode 100644 index 00000000..171137b4 --- /dev/null +++ b/docs/tools/src/main/java/docs/Example.java @@ -0,0 +1,4 @@ +package docs; + +/** One {@code ```java} fence from an authored page: {@code label} is "{@code <page>, example <n>}", {@code code} the dedented body. */ +record Example(String label, String code) {} diff --git a/docs/tools/src/main/java/docs/ExampleCompiler.java b/docs/tools/src/main/java/docs/ExampleCompiler.java new file mode 100644 index 00000000..19745024 --- /dev/null +++ b/docs/tools/src/main/java/docs/ExampleCompiler.java @@ -0,0 +1,311 @@ +package docs; + +import com.github.javaparser.ast.CompilationUnit; +import com.github.javaparser.ast.PackageDeclaration; +import java.io.File; +import java.io.IOException; +import java.io.StringWriter; +import java.io.UncheckedIOException; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import javax.tools.Diagnostic; +import javax.tools.DiagnosticCollector; +import javax.tools.JavaCompiler; +import javax.tools.JavaFileObject; +import javax.tools.SimpleJavaFileObject; +import javax.tools.StandardLocation; +import javax.tools.ToolProvider; + +/** + * Compiles the Java examples of the authored pages against the built library. A fence that declares a top-level + * type is compiled as-is (its own imports, a file named after its public type); any other fence is a fragment, + * wrapped in a method with a wildcard import of every library package. A canary fragment with a misspelled + * method is always compiled and must be flagged, so a broken classpath cannot make the check pass vacuously. + */ +final class ExampleCompiler { + + /** The modules whose {@code target/classes} and {@code target/classpath.txt} form the compile classpath. */ + static final List<String> MODULES = List.of("API", "Client", "extension-recorder", "extension-collector"); + /** The command, run from the repository root, that produces every file the compiler reads. */ + static final String BUILD_COMMAND = "mvn -q -pl API,Client,extension-recorder,extension-collector -am -DskipTests install" + + " dependency:build-classpath -Dmdep.outputFile=target/classpath.txt"; + static final String CANARY_LABEL = "canary"; + static final String CANARY = "TikTokLive.newClient(\"x\").onComment((c, e) -> e.getComent());"; + static final String CANARY_NOT_FLAGGED = "example compiler canary not flagged"; + /** JDK packages imported into every fragment besides the library's own. */ + static final List<String> JDK_PACKAGES = List.of("java.util", "java.time", "java.util.function", "java.util.logging", "java.io"); + + private static final Pattern TYPE_DECLARATION = Pattern.compile("^(public\\s+)?(final\\s+|abstract\\s+)?(class|interface|record|enum)\\s+(\\w+)"); + private static final Pattern PUBLIC_TYPE = Pattern.compile("^public\\s+(final\\s+|abstract\\s+)?(class|interface|record|enum)\\s+(\\w+)"); + private static final Pattern PACKAGE = Pattern.compile("^\\s*package\\s+([\\w.]+)\\s*;"); + private static final Pattern IMPORT = Pattern.compile("^\\s*import\\s+(static\\s+)?[\\w.]+(\\.\\*)?\\s*;\\s*$"); + + private ExampleCompiler() {} + + /** One source file handed to javac: which example it came from and how many wrapper lines precede its body. */ + private record Unit(Example example, String key, Source source, int offset, int bodyLines) {} + + private static final class Source extends SimpleJavaFileObject { + private final String code; + + Source(String path, String code) { + super(URI.create("string:///" + path), Kind.SOURCE); + this.code = code; + } + + @Override + public CharSequence getCharContent(boolean ignoreEncodingErrors) { + return code; + } + } + + /** Compiles the examples; returns one failure line per compiler error, or the reason the compiler could not run. */ + static List<String> compile(List<Example> examples, Path repoRoot) { + return compile(examples, repoRoot, packages(JavaSources.load(repoRoot))); + } + + /** As {@link #compile(List, Path)} with the library packages already known. */ + static List<String> compile(List<Example> examples, Path repoRoot, Collection<String> packages) { + List<Path> classpath; + try { + classpath = classpath(repoRoot); + } catch (IllegalStateException e) { + return List.of(e.getMessage()); + } + var compiler = ToolProvider.getSystemJavaCompiler(); + if (compiler == null) { + return List.of("no system Java compiler: run the docs tool on a JDK, not a JRE"); + } + var units = new ArrayList<Unit>(); + units.add(unit(new Example(CANARY_LABEL, CANARY), 0, packages)); + for (int i = 0; i < examples.size(); i++) { + units.add(unit(examples.get(i), i + 1, packages)); + } + var failures = new ArrayList<String>(); + var canaryFlagged = false; + Path outDir; + try { + outDir = Files.createTempDirectory("docs-examples"); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + try { + for (var batch : batches(units)) { + var byUri = new HashMap<String, Unit>(); + for (var unit : batch) { + byUri.put(unit.source().toUri().toString(), unit); + } + for (var d : run(compiler, classpath, outDir, batch)) { + if (d.getKind() != Diagnostic.Kind.ERROR) { + continue; + } + var message = oneLine(d.getMessage(Locale.ROOT)); + var unit = d.getSource() == null ? null : byUri.get(d.getSource().toUri().toString()); + if (unit == null) { + failures.add("javac: " + message); + } else if (unit.example().label().equals(CANARY_LABEL)) { + canaryFlagged |= message.contains("getComent"); + } else { + failures.add(unit.example().label() + ": line " + line(d, unit) + ": " + message); + } + } + } + } finally { + delete(outDir); + } + if (!canaryFlagged) { + failures.add(0, CANARY_NOT_FLAGGED); + } + return failures; + } + + /** Every package declared by the parsed library sources, sorted. */ + static Set<String> packages(List<CompilationUnit> units) { + var packages = new TreeSet<String>(); + for (var unit : units) { + unit.getPackageDeclaration().map(PackageDeclaration::getNameAsString).ifPresent(packages::add); + } + return packages; + } + + /** True when the fence declares a top-level type and so compiles as its own file. */ + static boolean isFullUnit(String code) { + return code.lines().anyMatch(line -> TYPE_DECLARATION.matcher(line).find()); + } + + // --- classpath ---------------------------------------------------------------------------- + + private static List<Path> classpath(Path repoRoot) { + var classpath = new LinkedHashSet<Path>(); + var missing = new ArrayList<String>(); + for (var module : MODULES) { + var classes = repoRoot.resolve(module).resolve("target").resolve("classes"); + var file = repoRoot.resolve(module).resolve("target").resolve("classpath.txt"); + if (Files.isDirectory(classes)) { + classpath.add(classes); + } else { + missing.add(module + "/target/classes"); + } + if (!Files.isRegularFile(file)) { + missing.add(module + "/target/classpath.txt"); + continue; + } + String text; + try { + text = Files.readString(file); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + for (var entry : text.split("[" + File.pathSeparator + "\\r\\n]+")) { + if (!entry.isBlank()) { + classpath.add(Path.of(entry.strip())); + } + } + } + if (!missing.isEmpty()) { + throw new IllegalStateException("missing " + String.join(", ", missing) + + "; build the library first, from the repository root: " + BUILD_COMMAND); + } + return new ArrayList<>(classpath); + } + + // --- sources ------------------------------------------------------------------------------ + + private static Unit unit(Example example, int index, Collection<String> packages) { + var code = example.code(); + var dir = "ex" + index + "/"; + if (isFullUnit(code)) { + var name = declaredType(code); + var source = code.endsWith("\n") ? code : code + "\n"; + return new Unit(example, packageOf(code) + "/" + name, new Source(dir + name + ".java", source), 0, (int) code.lines().count()); + } + var imports = new LinkedHashSet<String>(); + for (var pkg : packages) { + imports.add("import " + pkg + ".*;"); + } + for (var pkg : JDK_PACKAGES) { + imports.add("import " + pkg + ".*;"); + } + var body = new StringBuilder(); + var bodyLines = 0; + for (var line : code.split("\n", -1)) { + if (IMPORT.matcher(line).matches()) { + imports.add(line.strip()); + line = ""; + } + body.append(line).append('\n'); + bodyLines++; + } + var name = "Example" + index; + var source = new StringBuilder(); + for (var line : imports) { + source.append(line).append('\n'); + } + source.append("public class ").append(name).append(" {\n"); + source.append(" void run() throws Exception {\n"); + source.append(body); + source.append(" }\n}\n"); + return new Unit(example, "/" + name, new Source(dir + name + ".java", source.toString()), imports.size() + 2, bodyLines); + } + + /** The declared package of a full unit, or an empty string for the default package. */ + private static String packageOf(String code) { + for (var line : code.split("\n")) { + var m = PACKAGE.matcher(line); + if (m.find()) { + return m.group(1); + } + } + return ""; + } + + /** The public top-level type's name, else the first declared type's: the file must be named after it. */ + private static String declaredType(String code) { + for (var line : code.split("\n")) { + var m = PUBLIC_TYPE.matcher(line); + if (m.find()) { + return m.group(3); + } + } + for (var line : code.split("\n")) { + var m = TYPE_DECLARATION.matcher(line); + if (m.find()) { + return m.group(4); + } + } + throw new IllegalArgumentException("no type declaration"); + } + + /** Splits the units into batches in which no two files declare the same package-qualified type. */ + private static List<List<Unit>> batches(List<Unit> units) { + var batches = new ArrayList<List<Unit>>(); + var keys = new ArrayList<Set<String>>(); + for (var unit : units) { + int i = 0; + while (i < batches.size() && keys.get(i).contains(unit.key())) { + i++; + } + if (i == batches.size()) { + batches.add(new ArrayList<>()); + keys.add(new HashSet<>()); + } + batches.get(i).add(unit); + keys.get(i).add(unit.key()); + } + return batches; + } + + // --- javac -------------------------------------------------------------------------------- + + private static List<Diagnostic<? extends JavaFileObject>> run(JavaCompiler compiler, List<Path> classpath, Path outDir, List<Unit> batch) { + var diagnostics = new DiagnosticCollector<JavaFileObject>(); + var sources = new ArrayList<JavaFileObject>(); + for (var unit : batch) { + sources.add(unit.source()); + } + try (var fileManager = compiler.getStandardFileManager(diagnostics, Locale.ROOT, StandardCharsets.UTF_8)) { + fileManager.setLocationFromPaths(StandardLocation.CLASS_PATH, classpath); + fileManager.setLocationFromPaths(StandardLocation.CLASS_OUTPUT, List.of(outDir)); + var options = List.of("--release", "17", "-proc:none", "-implicit:none", "-Xlint:none"); + compiler.getTask(new StringWriter(), fileManager, diagnostics, options, null, sources).call(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return diagnostics.getDiagnostics(); + } + + /** The diagnostic's line relative to the fence body, clamped into the body for errors reported on wrapper lines. */ + private static long line(Diagnostic<?> d, Unit unit) { + var line = d.getLineNumber() - unit.offset(); + return Math.max(1, Math.min(line, Math.max(1, unit.bodyLines()))); + } + + private static String oneLine(String message) { + return String.join("; ", message.lines().map(String::strip).filter(s -> !s.isEmpty()).toList()); + } + + private static void delete(Path dir) { + try (Stream<Path> walk = Files.walk(dir)) { + for (var path : walk.sorted(Comparator.reverseOrder()).toList()) { + Files.deleteIfExists(path); + } + } catch (IOException ignored) { + // A leftover temp directory is not worth failing the check over. + } + } +} diff --git a/docs/tools/src/main/java/docs/Main.java b/docs/tools/src/main/java/docs/Main.java index 336a7d41..9df8adc9 100644 --- a/docs/tools/src/main/java/docs/Main.java +++ b/docs/tools/src/main/java/docs/Main.java @@ -29,7 +29,7 @@ public static void main(String[] args) { System.exit(run(args, System.out, System.err)); } - /** Runs a command; exit code 0 on success, 1 on failure, 2 on usage errors or an unimplemented command. */ + /** Runs a command; exit code 0 on success, 1 on failure, 2 on usage errors. */ static int run(String[] args, PrintStream out, PrintStream err) { if (args.length != 2) { err.println("usage: docs-tools <generate|check> <repository root>"); @@ -46,8 +46,13 @@ static int run(String[] args, PrintStream out, PrintStream err) { return 1; } case "check": - err.println("check: not implemented"); - return 2; + try { + return ContentCheck.run(repoRoot, out); + } catch (RuntimeException e) { + err.println("check failed: " + e.getMessage()); + e.printStackTrace(err); + return 1; + } default: err.println("unknown command: " + args[0]); return 2; diff --git a/docs/tools/src/test/java/docs/ContentCheckTest.java b/docs/tools/src/test/java/docs/ContentCheckTest.java new file mode 100644 index 00000000..21087126 --- /dev/null +++ b/docs/tools/src/test/java/docs/ContentCheckTest.java @@ -0,0 +1,178 @@ +package docs; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ContentCheckTest { + + static final Path REPO = Path.of(System.getProperty("repo.root")).toAbsolutePath().normalize(); + + @Test + void eventsMapIncludesCategories() { + var e = ContentCheck.events(REPO); + assertEquals("Message", e.get("TikTokCommentEvent")); + assertEquals("Control", e.get("TikTokConnectedEvent")); + assertTrue(e.size() >= 73, "events: " + e.size()); + // Ruling J7: abstract classes are not events; concrete classes without @EventMeta have the category `none`. + assertFalse(e.containsKey("TikTokEvent"), "abstract base class is excluded"); + assertEquals("none", e.get("TikTokHeaderEvent")); + } + + @Test + void unannotatedConcreteEventsGetCategoryNoneAndAbstractBaseIsExcluded() { + var e = ContentCheck.events(REPO); + assertEquals("none", e.get("TikTokHeaderEvent")); + assertEquals("none", e.get("TikTokUnhandledEvent")); + assertFalse(e.containsKey("TikTokEvent"), "abstract base class is not an event"); + assertFalse(e.containsKey("CaptionContent"), "nested types are not events"); + assertTrue(ContentCheck.eventClassNames(REPO).contains("TikTokEvent"), "README anchors may name the base class"); + } + + @Test + void settingsHandlersExceptions() { + assertTrue(ContentCheck.settingsFields(REPO).contains("retryConnectionTimeout")); + assertTrue(ContentCheck.settingsFields(REPO).contains("timeout"), "HttpClientSettings field"); + assertTrue(ContentCheck.handlers(REPO).contains("onComment")); + assertTrue(ContentCheck.handlers(REPO).contains("sendChat"), "LiveClient method"); + assertFalse(ContentCheck.builderHandlers(REPO).contains("sendChat"), "builder handlers are on* methods of EventsBuilder only"); + assertEquals(11, ContentCheck.exceptions(REPO).size()); + } + + @Test + void checkEventsReportsMissingEvent(@TempDir Path site) throws IOException { + write(site, "t.md", "The `TikTokCommentEvent` event.\n"); + write(site, "events/index.md", "| Event | Type | Description |\n|---|---|---|\n| `TikTokCommentEvent` | Message | A comment. |\n"); + var events = Map.of("TikTokCommentEvent", "Message", "TikTokNewShinyEvent", "Message"); + var failures = ContentCheck.checkEvents(events, ContentCheck.pages(site)); + assertTrue(failures.contains("Missing event documentation: TikTokNewShinyEvent"), failures.toString()); + assertTrue(failures.contains("Event catalog row missing: TikTokNewShinyEvent"), failures.toString()); + assertFalse(failures.stream().anyMatch(f -> f.contains("TikTokCommentEvent")), failures.toString()); + } + + @Test + void catalogRowTypeMustMatchAnnotation(@TempDir Path site) throws IOException { + write(site, "events/index.md", "| Event | Type | Description |\n|---|---|---|\n| `TikTokCommentEvent` | Control | x |\n"); + var failures = ContentCheck.checkEvents(Map.of("TikTokCommentEvent", "Message"), ContentCheck.pages(site)); + assertEquals(List.of("Event catalog row is stale: TikTokCommentEvent"), failures); + } + + @Test + void catalogRowAcceptsNoneForUnannotatedEvents(@TempDir Path site) throws IOException { + write(site, "events/index.md", "| `TikTokHeaderEvent` | none | base |\n"); + assertEquals(List.of(), ContentCheck.checkEvents(Map.of("TikTokHeaderEvent", "none"), ContentCheck.pages(site))); + } + + @Test + void namesMustAppearInBackticks(@TempDir Path site) throws IOException { + write(site, "t.md", "Set `retryConnectionTimeout`; plain pingInterval is not documentation.\n"); + var failures = ContentCheck.checkNames("settings field", List.of("retryConnectionTimeout", "pingInterval"), ContentCheck.pages(site)); + assertEquals(List.of("Missing settings field documentation: pingInterval"), failures); + } + + @Test + void generatedPagesDoNotCount(@TempDir Path site) throws IOException { + write(site, "api/x.md", "`TikTokLiveException`\n"); + write(site, "schema/y.md", "`TikTokLiveException`\n"); + write(site, "z.txt", "`TikTokLiveException`\n"); + assertEquals(List.of("Missing exception documentation: TikTokLiveException"), + ContentCheck.checkNames("exception", List.of("TikTokLiveException"), ContentCheck.pages(site))); + } + + @Test + void readmeHandlerWithoutClassFails() { + var failures = ContentCheck.checkReadme("- [onComent](#oncoment-tiktokcomentevent)\n", Set.of("onComment"), Set.of("TikTokCommentEvent")); + assertTrue(failures.contains("README handler has no event class: onComent"), failures.toString()); + } + + @Test + void readmeHandlerNotOnBuilderFails() { + var failures = ContentCheck.checkReadme("- [onCommentary](#oncommentary-tiktokcommentevent)\n", Set.of("onComment"), Set.of("TikTokCommentEvent")); + assertEquals(List.of("README handler is not on EventsBuilder: onCommentary"), failures); + } + + @Test + void readmeItemWithTrailingTextPasses() { + var readme = "**Message**:\n\n- [onComment](#oncomment-tiktokcommentevent) · [docs](https://example.com/events/chat/#tiktokcommentevent)\n" + + "- [onWebsocketUnhandledMessage](#onwebsocketunhandledmessage-tiktokwebsocketunhandledmessageevent)\n"; + var events = Set.of("TikTokCommentEvent", "TikTokWebsocketUnhandledMessageEvent"); + assertEquals(List.of(), ContentCheck.checkReadme(readme, Set.of("onComment", "onWebsocketUnhandledMessage"), events)); + } + + @Test + void repositoryReadmeItemsAreParsedAndPassAgainstTheSources() throws IOException { + var readme = Files.readString(REPO.resolve("README.md")); + var unresolved = ContentCheck.checkReadme(readme, Set.of(), Set.of()); + assertTrue(unresolved.contains("README handler has no event class: onComment"), unresolved.toString()); + assertTrue(unresolved.contains("README handler is not on EventsBuilder: onEvent"), unresolved.toString()); + assertTrue(unresolved.size() >= 40, "every item yields two failures when nothing resolves: " + unresolved.size()); + assertEquals(List.of(), ContentCheck.checkReadme(readme, ContentCheck.builderHandlers(REPO), ContentCheck.eventClassNames(REPO))); + } + + @Test + void discordLinkFails(@TempDir Path site) throws IOException { + write(site, "t.md", "Join https://discord.gg/abc\n"); + write(site, "ok.md", "Nothing here.\n"); + assertEquals(List.of("t.md: published pages must not reference Discord"), ContentCheck.checkDiscord(ContentCheck.pages(site))); + } + + @Test + void indentedFencesAreExtractedAndDedented(@TempDir Path site) throws IOException { + write(site, "a/b.md", """ + Intro. + + ```java + int a = 1; + ``` + + 1. Step + ```java title="Demo.java" + int b = 2; + int c = 3; + ``` + + ```ts + const notJava = ```; + ``` + """); + var examples = ContentCheck.examples(ContentCheck.pages(site)); + assertEquals(2, examples.size(), examples.toString()); + assertEquals("a/b.md, example 1", examples.get(0).label()); + assertEquals("int a = 1;", examples.get(0).code()); + assertEquals("a/b.md, example 2", examples.get(1).label()); + assertEquals("int b = 2;\n int c = 3;", examples.get(1).code()); + } + + @Test + void runPrintsEverySectionInOrderThenTheSummary() { + var bytes = new ByteArrayOutputStream(); + var code = ContentCheck.run(REPO, new PrintStream(bytes, true, StandardCharsets.UTF_8)); + var lines = bytes.toString(StandardCharsets.UTF_8).strip().split("\n"); + var sections = List.of("events", "settings", "builder", "exceptions", "extensions", "discord", "readme", "examples"); + assertEquals(sections.size() + 1, lines.length, String.join("\n", lines)); + for (int i = 0; i < sections.size(); i++) { + assertTrue(lines[i].matches("(PASS " + sections.get(i) + "|FAIL " + sections.get(i) + ": .+)"), lines[i]); + } + assertTrue(lines[sections.size()].matches("Checked \\d+ pages, \\d+ events, \\d+ settings fields, \\d+ handlers, \\d+ exceptions, \\d+ Java examples\\."), lines[sections.size()]); + var anyFailed = String.join("\n", lines).contains("\nFAIL ") || lines[0].startsWith("FAIL "); + assertEquals(anyFailed ? 1 : 0, code); + } + + private static void write(Path root, String relative, String text) throws IOException { + var path = root.resolve(relative); + Files.createDirectories(path.getParent()); + Files.writeString(path, text); + } +} diff --git a/docs/tools/src/test/java/docs/ExampleCompilerTest.java b/docs/tools/src/test/java/docs/ExampleCompilerTest.java new file mode 100644 index 00000000..162fd74d --- /dev/null +++ b/docs/tools/src/test/java/docs/ExampleCompilerTest.java @@ -0,0 +1,106 @@ +package docs; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class ExampleCompilerTest { + + static final Path REPO = Path.of(System.getProperty("repo.root")).toAbsolutePath().normalize(); + static final String FRAGMENT = "TikTokLive.newClient(\"user\").onComment((client, event) -> System.out.println(event.getText())).buildAndConnect();"; + + @Test + void fragmentCompiles() { + assertEquals(List.of(), ExampleCompiler.compile(List.of(new Example("p.md, example 1", FRAGMENT)), REPO)); + } + + @Test + void fragmentWithUnknownMethodFails() { + var f = ExampleCompiler.compile(List.of(new Example("p.md, example 1", + "TikTokLive.newClient(\"user\").onComment((client, event) -> event.getComent());")), REPO); + assertEquals(1, f.size(), f.toString()); + assertTrue(f.get(0).startsWith("p.md, example 1: line 1: "), f.get(0)); + assertTrue(f.get(0).contains("getComent"), f.get(0)); + } + + @Test + void fragmentLineNumbersAreRelativeToTheFence() { + var f = ExampleCompiler.compile(List.of( + new Example("a.md, example 1", FRAGMENT), + new Example("b.md, example 3", "int a = 1;\n\nString s = a;")), REPO); + assertEquals(1, f.size(), f.toString()); + assertTrue(f.get(0).startsWith("b.md, example 3: line 3: "), f.get(0)); + } + + @Test + void fragmentImportsAreHoisted() { + var code = "import java.util.concurrent.TimeUnit;\n\nTimeUnit.SECONDS.sleep(1);\n" + FRAGMENT; + assertEquals(List.of(), ExampleCompiler.compile(List.of(new Example("p.md, example 1", code)), REPO)); + } + + @Test + void fullUnitCompilesAsIs() { + var unit = """ + import io.github.jwdeveloper.tiktok.api.annotations.TikTokEventObserver; + import io.github.jwdeveloper.tiktok.api.data.events.TikTokCommentEvent; + import io.github.jwdeveloper.tiktok.api.listener.TikTokEventListenerBase; + import io.github.jwdeveloper.tiktok.api.live.LiveClient; + + public class Demo extends TikTokEventListenerBase { + + @TikTokEventObserver + public void onComment(LiveClient client, TikTokCommentEvent event) { + System.out.println(event.getText()); + } + } + """; + var broken = unit.replace("event.getText()", "event.getComent()"); + assertEquals(List.of(), ExampleCompiler.compile(List.of(new Example("l.md, example 1", unit)), REPO)); + var f = ExampleCompiler.compile(List.of(new Example("l.md, example 1", broken)), REPO); + assertEquals(1, f.size(), f.toString()); + assertTrue(f.get(0).startsWith("l.md, example 1: line 10: "), f.get(0)); + } + + @Test + void fullUnitsWithTheSameClassNameDoNotCollide() { + var main = "import io.github.jwdeveloper.tiktok.client.TikTokLive;\n\npublic class Main {\n public static void main(String[] args) {\n " + + FRAGMENT + "\n }\n}\n"; + var other = main.replace("newClient(\"user\")", "newClient(\"other\")"); + assertEquals(List.of(), ExampleCompiler.compile(List.of(new Example("a.md, example 1", main), new Example("b.md, example 1", other)), REPO)); + var broken = other.replace("event.getText()", "event.getComent()"); + var f = ExampleCompiler.compile(List.of(new Example("a.md, example 1", main), new Example("b.md, example 1", broken)), REPO); + assertEquals(1, f.size(), f.toString()); + assertTrue(f.get(0).startsWith("b.md, example 1: line 5: "), f.get(0)); + } + + @Test + void canaryGuardsVacuousPass(@TempDir Path fake) throws IOException { + for (var module : ExampleCompiler.MODULES) { + Files.createDirectories(fake.resolve(module).resolve("target/classes")); + Files.writeString(fake.resolve(module).resolve("target/classpath.txt"), ""); + } + for (var module : JavaSources.MODULES) { + Files.createDirectories(fake.resolve(module).resolve("src/main/java")); + } + var f = ExampleCompiler.compile(List.of(), fake); + assertEquals(List.of("example compiler canary not flagged"), f); + } + + @Test + void missingClasspathFileFails(@TempDir Path fake) throws IOException { + for (var module : JavaSources.MODULES) { + Files.createDirectories(fake.resolve(module).resolve("src/main/java")); + } + var f = ExampleCompiler.compile(List.of(new Example("p.md, example 1", FRAGMENT)), fake); + assertEquals(1, f.size(), f.toString()); + assertTrue(f.get(0).contains("API/target/classpath.txt"), f.get(0)); + assertTrue(f.get(0).contains(ExampleCompiler.BUILD_COMMAND), f.get(0)); + assertTrue(ExampleCompiler.BUILD_COMMAND.startsWith("mvn -q -pl API,Client,extension-recorder,extension-collector -am -DskipTests install dependency:build-classpath -Dmdep.outputFile=target/classpath.txt")); + } +} From 58a2a926e50867a54e6b0f87c60fbd5db9cba0cb Mon Sep 17 00:00:00 2001 From: isaackogan <isaacikogan@gmail.com> Date: Tue, 20 Oct 2026 16:12:15 -0400 Subject: [PATCH 05/31] docs: write Getting Started, Install, and connection lifecycle pages Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- .../docs/configuration/client-settings.md | 6 + .../src/content/docs/configuration/signing.md | 6 + .../docs/getting-started/connection.md | 163 +++++++++++++++++- .../content/docs/getting-started/install.md | 66 ++++++- docs/src/content/docs/guides/gifts.md | 6 + docs/src/content/docs/guides/listeners.md | 6 + docs/src/content/docs/index.md | 104 ++++++++++- 7 files changed, 352 insertions(+), 5 deletions(-) create mode 100644 docs/src/content/docs/configuration/client-settings.md create mode 100644 docs/src/content/docs/configuration/signing.md create mode 100644 docs/src/content/docs/guides/gifts.md create mode 100644 docs/src/content/docs/guides/listeners.md diff --git a/docs/src/content/docs/configuration/client-settings.md b/docs/src/content/docs/configuration/client-settings.md new file mode 100644 index 00000000..4d942b48 --- /dev/null +++ b/docs/src/content/docs/configuration/client-settings.md @@ -0,0 +1,6 @@ +--- +title: Client settings +description: Every field of LiveClientSettings, its type, default, and effect. +--- + +This page is written in a later task of the documentation plan. diff --git a/docs/src/content/docs/configuration/signing.md b/docs/src/content/docs/configuration/signing.md new file mode 100644 index 00000000..e71229b8 --- /dev/null +++ b/docs/src/content/docs/configuration/signing.md @@ -0,0 +1,6 @@ +--- +title: Signing +description: How TikTokLiveJava signs its connections with Euler Stream, and the apiKey and useEulerstreamWebsocket settings. +--- + +This page is written in a later task of the documentation plan. diff --git a/docs/src/content/docs/getting-started/connection.md b/docs/src/content/docs/getting-started/connection.md index a6a3b8ad..4a61d7c8 100644 --- a/docs/src/content/docs/getting-started/connection.md +++ b/docs/src/content/docs/getting-started/connection.md @@ -1,6 +1,165 @@ --- title: Connection lifecycle -description: How a TikTokLiveJava client connects, reconnects, and disconnects. +description: Build and connect a client, follow the connect sequence and its lifecycle events, disconnect, retry an offline creator, read room information, and check whether a creator is live. --- -This page is written in a later task of the documentation plan. +`TikTokLive.newClient(hostName)` returns a `LiveClientBuilder`. Its `on<Event>` methods register handlers, `configure` edits the client's `LiveClientSettings`, and one of three terminal methods produces the `LiveClient`. + +## Build, connect, or both + +| Method | Returns | Use it when | +| --- | --- | --- | +| `build()` | The `LiveClient`, not connected | You want to keep the client, register listeners through `getListenersManager()`, or connect later. | +| `buildAndConnect()` | The connected `LiveClient` | A program that connects once. It throws when the connection fails. | +| `buildAndConnectAsync()` | A `CompletableFuture<LiveClient>` | The calling thread must not wait. The future completes with the client, or exceptionally when the connection fails. | + +`LiveClient` has the matching methods. `connect()` blocks the calling thread while the client fetches the room data, signs, and starts the socket. `connectAsync()` runs `connect()` on the common pool and returns a `CompletableFuture<LiveClient>`; `connectAsync(Consumer<LiveClient>)` passes the connected client to the consumer. One connection per client: calling `connect()` while the state is `CONNECTING` or `CONNECTED` throws `TikTokLiveException` with the message `Already connected`. After a disconnect the same client can `connect()` again. + +```java +var client = TikTokLive.newClient("isaackogz") + .onComment((liveClient, event) -> System.out.println(event.getText())) + .build(); + +client.connect(); +``` + +The common pool's threads are daemon threads, so after `buildAndConnectAsync()` keep the main thread alive until the socket is open; the repository's `ConnectionExample` blocks on `System.in.read()`. + +```java +TikTokLive.newClient("isaackogz") + .onComment((client, event) -> System.out.println(event.getText())) + .buildAndConnectAsync() + .thenAccept(client -> System.out.println("Connected to " + client.getRoomInfo().getHostName())) + .exceptionally(error -> { + System.err.println("Connection failed: " + error.getMessage()); + return null; + }); + +System.in.read(); +``` + +## The connect sequence + +In order, `connect()`: + +1. Sets the state to `CONNECTING` and publishes `TikTokConnectingEvent`. +2. Fetches the creator's user data from TikTok. An offline creator throws `TikTokLiveOfflineHostException`; an unknown username throws `TikTokLiveUnknownHostException`. +3. Fetches the room's live data and fills `getRoomInfo()` with the room id, title, host, viewer counts, and age restriction. An age-restricted room throws `TikTokLiveException` when `throwOnAgeRestriction` is `true`; a room reported offline or not found throws the two exceptions above. +4. Publishes `TikTokPreConnectionEvent` with both responses. A handler that calls `setCancelConnection(true, reason)` aborts the connection with `TikTokLivePreConnectionException`. +5. Downloads the room's gift list when `fetchGifts` is `true`, so `getGiftManager()` knows the gifts before the first `TikTokGiftEvent`. +6. Requests a signed WebSocket URL from the signing provider; a failed or rate-limited request throws `TikTokSignServerException`. The response already carries messages, and they are dispatched to your handlers now, before the socket opens. +7. Starts the WebSocket on its own thread, sets the state to `CONNECTED`, and publishes `TikTokRoomInfoEvent`. `TikTokConnectedEvent` fires from the socket thread once the socket is open, which can be before or after `connect()` returns. + +When a step throws a `TikTokLiveException`, `connect()` sets the state back to `DISCONNECTED`, publishes `TikTokErrorEvent` with the exception and then `TikTokDisconnectedEvent` with code `-1` and the reason `Exception: <message>`, and rethrows. `buildAndConnect()` and `connect()` throw it to the caller; `buildAndConnectAsync()` and `connectAsync()` complete their future exceptionally. Any other exception is logged with its stack trace and the client disconnects without throwing. + +With `useEulerstreamWebsocket` set to `true`, steps 2 to 6 are skipped: the client publishes `TikTokConnectingEvent`, opens `wss://ws.eulerstream.com` with your `apiKey`, and sets the state to `CONNECTED`. See [signing](/configuration/signing/). + +## Lifecycle events + +Six control events mark the lifecycle. Register them like any other event; every handler receives the `LiveClient` first and the event second. + +| Event | Handler | When | Fields | +| --- | --- | --- | --- | +| `TikTokConnectingEvent` | `onConnecting` | First thing `connect()` does | None | +| `TikTokPreConnectionEvent` | `onPreConnection` | After the room data is fetched, before signing | `getUserData()`, `getRoomData()`, `setCancelConnection(boolean, String)`, `getReason()` | +| `TikTokConnectedEvent` | `onConnected` | The WebSocket is open | None | +| `TikTokReconnectingEvent` | `onReconnecting` | Just before a scheduled retry calls `connect()` | None | +| `TikTokDisconnectedEvent` | `onDisconnected` | The socket closed, or a connect attempt failed | `getCode()`, `getReason()`, `isUnknownCloseCode()` | +| `TikTokErrorEvent` | `onError` | An exception in the client, in the socket, or thrown by a handler while a message is processed | `getException()` | + +`TikTokDisconnectedEvent.getCode()` is the WebSocket close code when TikTok or `disconnect()` closed the socket, and `-1` (`isUnknownCloseCode()` is `true`) when a connect attempt failed; `getReason()` is `None` when the close frame carried no reason. The end of a stream is a message event, not a control event: when TikTok reports the stream ended or suspended, the client publishes `TikTokLiveEndedEvent` (`onLiveEnded`) and does not disconnect by itself. Call `disconnect()` from the handler when the connection should close with the stream. + +```java +TikTokLive.newClient("isaackogz") + .onConnecting((client, event) -> System.out.println("Connecting")) + .onPreConnection((client, event) -> { + if (event.getRoomData().isAgeRestricted()) { + event.setCancelConnection(true, "Age-restricted room"); + } + }) + .onConnected((client, event) -> System.out.println("Connected to room " + client.getRoomInfo().getRoomId())) + .onLiveEnded((client, event) -> client.disconnect()) + .onDisconnected((client, event) -> System.out.println("Disconnected: " + event.getCode() + " " + event.getReason())) + .onError((client, event) -> event.getException().printStackTrace()) + .buildAndConnect(); +``` + +## Disconnect + +`disconnect()` is `disconnect(LiveClientStopType.NORMAL)`: it stops the heartbeat, starts the WebSocket close handshake without waiting for it, and sets the state to `DISCONNECTED`. `LiveClientStopType.CLOSE_BLOCKING` waits until the socket has closed, and `LiveClientStopType.DISCONNECT` drops the connection without a close handshake. `TikTokDisconnectedEvent` is published when the socket reports the close. Calling `disconnect()` on a client that is not connected does nothing. + +```java +var client = TikTokLive.newClient("isaackogz").buildAndConnect(); +Thread.sleep(Duration.ofMinutes(1).toMillis()); +client.disconnect(LiveClientStopType.CLOSE_BLOCKING); +``` + +## Retry when the creator is offline + +Two settings control the client's own retry. `retryOnConnectionFailure` (default `false`) makes the client try again when `connect()` fails with `TikTokLiveOfflineHostException`, and `retryConnectionTimeout` (default one second) is the wait before each attempt. The client schedules `connect()` on a daemon thread, publishes `TikTokReconnectingEvent` just before the attempt, and repeats while the creator stays offline. + +- Each failed attempt publishes `TikTokErrorEvent` and `TikTokDisconnectedEvent` before scheduling the next. +- The call that started the sequence still throws: catch `TikTokLiveOfflineHostException` around `buildAndConnect()`, or use `buildAndConnectAsync()`. +- The retry thread is a daemon thread, so keep the main thread alive, as the example below does with `System.in.read()`. +- Only an offline creator at connect time is retried. Other failures, and a socket that closes after connecting, are not: handle `onDisconnected`, wait, and call `connect()` again. Waiting matters; a tight loop runs into the signing provider's rate limit. +- The default one-second wait polls TikTok once a second. Set a longer `retryConnectionTimeout` for a creator who may stay offline for hours. + +```java +TikTokLive.newClient("isaackogz") + .configure(settings -> { + settings.setRetryOnConnectionFailure(true); + settings.setRetryConnectionTimeout(Duration.ofSeconds(30)); + }) + .onReconnecting((client, event) -> System.out.println("Trying again")) + .onConnected((client, event) -> System.out.println("Connected")) + .buildAndConnectAsync(); + +System.in.read(); +``` + +To reconnect after a drop, poll `getRoomInfo().getConnectionState()` and connect again once the creator is live. `TikTokLive.isLiveOnline` asks TikTok directly and spends no signing request. + +```java +var client = TikTokLive.newClient("isaackogz") + .onComment((liveClient, event) -> System.out.println(event.getText())) + .build(); + +while (true) { + if (client.getRoomInfo().getConnectionState() == ConnectionState.DISCONNECTED + && TikTokLive.isLiveOnline("isaackogz")) { + try { + client.connect(); + } catch (TikTokLiveException e) { + System.err.println("Connect failed: " + e.getMessage()); + } + } + Thread.sleep(Duration.ofSeconds(60).toMillis()); +} +``` + +## Room information + +`getRoomInfo()` returns the client's `LiveRoomInfo`. Before connecting it holds only `getHostName()` and the state `DISCONNECTED`. `connect()` fills it from TikTok's responses: `getRoomId()`, `getTitle()`, `getHost()`, `getStartTime()`, `getViewersCount()`, `getTotalViewersCount()`, and `isAgeRestricted()`, all set before `TikTokPreConnectionEvent` is published. While connected, the same object is updated in place and `TikTokRoomInfoEvent` (`onRoomInfo`) is published with it each time: viewer counts and `getUsersRanking()` from TikTok's room user messages, `getLikesCount()` from like messages, the host and `getLanguage()` from the live intro message. `getConnectionState()` is `CONNECTING`, `CONNECTED`, or `DISCONNECTED`. + +```java +TikTokLive.newClient("isaackogz") + .onRoomInfo((client, event) -> { + var info = event.getRoomInfo(); + System.out.println(info.getTitle() + ": " + info.getViewersCount() + " viewers, " + info.getLikesCount() + " likes"); + }) + .buildAndConnect(); +``` + +## Check whether a creator is live + +`TikTokLive.isLiveOnline(hostName)` asks TikTok whether the creator is live; it uses no signing request and opens no socket, so it is the right call for polling. `TikTokLive.isHostNameValid(hostName)` checks that the username exists. Both have `Async` variants, `isLiveOnlineAsync` and `isHostNameValidAsync`, that return a `CompletableFuture<Boolean>`, and `TikTokLive.requests()` returns the `LiveHttpClient` behind them for the full responses. + +```java +if (TikTokLive.isLiveOnline("isaackogz")) { + TikTokLive.newClient("isaackogz").buildAndConnect(); +} +``` + +## Keep the connection alive + +Once the socket is open, the client sends a heartbeat every `pingInterval` milliseconds (default `10000`). `build()` rejects values under `250`. The heartbeat stops with the socket. No setting reconnects after a drop; see the retry section above. diff --git a/docs/src/content/docs/getting-started/install.md b/docs/src/content/docs/getting-started/install.md index 0f794fe2..c421d347 100644 --- a/docs/src/content/docs/getting-started/install.md +++ b/docs/src/content/docs/getting-started/install.md @@ -1,6 +1,68 @@ --- title: Install -description: Add TikTokLiveJava to a Maven or Gradle project. +description: Add TikTokLiveJava to a Maven or Gradle project from JitPack, pick a version, and check that the dependency resolves. --- -This page is written in a later task of the documentation plan. +TikTokLiveJava is published on [JitPack](https://jitpack.io/#jwdeveloper/TikTok-Live-Java), which builds the library from the tags of its GitHub repository. Add the JitPack repository to your build, then depend on the `Client` artifact; it brings the `API` module, protobuf, Gson, SLF4J, and Java-WebSocket with it. + +## Requirements + +- Java 16 or newer. The library is compiled for Java 16. +- Maven or Gradle with access to `https://jitpack.io`. + +## Maven + +Add the repository and the dependency to `pom.xml`: + +```xml +<repositories> + <repository> + <id>jitpack.io</id> + <url>https://jitpack.io</url> + </repository> +</repositories> + +<dependencies> + <dependency> + <groupId>com.github.jwdeveloper.TikTok-Live-Java</groupId> + <artifactId>Client</artifactId> + <version>1.13.1-Release</version> + </dependency> +</dependencies> +``` + +## Gradle + +Add the repository and the dependency to `build.gradle`: + +```groovy +repositories { + mavenCentral() + maven { url 'https://jitpack.io' } +} + +dependencies { + implementation 'com.github.jwdeveloper.TikTok-Live-Java:Client:1.13.1-Release' +} +``` + +If the project declares its repositories in `settings.gradle` under `dependencyResolutionManagement`, add `maven { url 'https://jitpack.io' }` there instead, as the repository's README does. + +## Group names + +JitPack resolves two group names to the same artifact: `com.github.jwdeveloper.TikTok-Live-Java`, used in the README and above, and `com.github.jwdeveloper.TikTokLiveJava`, the repository's current name. Either works with the same `Client` artifact and version. + +## Versions + +Releases are tagged `<major>.<minor>.<patch>-Release`. The current release is `1.13.1-Release`, the version in the repository's `pom.xml`; the [GitHub releases](https://github.com/jwdeveloper/TikTokLiveJava/releases) page is the changelog. These docs are generated from the `master` sources, so a method shown here can be newer than the release you depend on; the [API reference](/api/) index names the version it was generated from. + +## Check the installation + +Build a client without connecting. `build()` validates the settings and wires the client; it makes no network request, so this runs offline and prints `isaackogz`. + +```java +var client = TikTokLive.newClient("isaackogz").build(); +System.out.println(client.getRoomInfo().getHostName()); +``` + +`TikTokLive` is `io.github.jwdeveloper.tiktok.client.TikTokLive`; the event classes live under `io.github.jwdeveloper.tiktok.api.data.events`. Next, run the example on [Getting Started](/#listen-to-a-livestream), then read the [connection lifecycle](/getting-started/connection/). diff --git a/docs/src/content/docs/guides/gifts.md b/docs/src/content/docs/guides/gifts.md new file mode 100644 index 00000000..b3337fb1 --- /dev/null +++ b/docs/src/content/docs/guides/gifts.md @@ -0,0 +1,6 @@ +--- +title: Gifts and combos +description: Handle TikTokGiftEvent and TikTokGiftComboEvent, and count streaked gifts. +--- + +This page is written in a later task of the documentation plan. diff --git a/docs/src/content/docs/guides/listeners.md b/docs/src/content/docs/guides/listeners.md new file mode 100644 index 00000000..1f1a2640 --- /dev/null +++ b/docs/src/content/docs/guides/listeners.md @@ -0,0 +1,6 @@ +--- +title: Listeners +description: Register event handlers as annotated methods of a listener class with addListener. +--- + +This page is written in a later task of the documentation plan. diff --git a/docs/src/content/docs/index.md b/docs/src/content/docs/index.md index 10b8c4c3..633d69cb 100644 --- a/docs/src/content/docs/index.md +++ b/docs/src/content/docs/index.md @@ -1,6 +1,108 @@ --- title: Getting Started description: Install TikTokLiveJava and receive comments, gifts, and other events from a TikTok livestream with Java. +head: + - tag: title + content: "TikTok LIVE API for Java: TikTokLiveJava Documentation" --- -TikTokLiveJava receives comments, gifts, likes, follows, shares, viewer counts, and other events from a TikTok livestream. Create a client with `TikTokLive.newClient("host-name")`, register listeners for the events you need, and connect. Start with [Install](/getting-started/install/), then read the [connection lifecycle](/getting-started/connection/). +TikTokLiveJava receives comments, gifts, likes, follows, shares, viewer counts, battles, and other events from a TikTok livestream. Create a client for a creator's username with `TikTokLive.newClient`, register a handler for each event you need, and connect. + +## Prerequisites + +- Java 16 or newer and a Maven or Gradle project. The library is published on JitPack, not Maven Central, so the build needs the JitPack repository; see [Install](/getting-started/install/). +- No TikTok login for public livestreams. Signing uses a third-party provider, [Euler Stream](https://www.eulerstream.com/) by default. Age-restricted rooms and sending chat messages need a TikTok session (`sessionId` and `ttTargetIdc`); see [client settings](/configuration/client-settings/). + +This is an unofficial reverse-engineering project, not a supported vendor API. TikTok can change the Webcast protocol without notice, and a defined event may not be sent in every room or region. The [protobuf schema](/schema/) lists what the protocol defines; the [event catalog](/events/) lists what the client emits. + +## Install + +Maven: + +```xml +<repositories> + <repository> + <id>jitpack.io</id> + <url>https://jitpack.io</url> + </repository> +</repositories> + +<dependencies> + <dependency> + <groupId>com.github.jwdeveloper.TikTok-Live-Java</groupId> + <artifactId>Client</artifactId> + <version>1.13.1-Release</version> + </dependency> +</dependencies> +``` + +Gradle: + +```groovy +repositories { + mavenCentral() + maven { url 'https://jitpack.io' } +} + +dependencies { + implementation 'com.github.jwdeveloper.TikTok-Live-Java:Client:1.13.1-Release' +} +``` + +[Install](/getting-started/install/) covers the second JitPack group name, versions, and a check that the dependency resolves. + +## Listen to a livestream + +Save this as `ChatReader.java`, replace `isaackogz` with a creator who is live, and run it. + +```java +import io.github.jwdeveloper.tiktok.client.TikTokLive; + +public class ChatReader { + public static void main(String[] args) { + TikTokLive.newClient("isaackogz") + .onConnected((client, event) -> + System.out.println("Connected to " + client.getRoomInfo().getHostName())) + .onComment((client, event) -> + System.out.println(event.getUser().getProfileName() + ": " + event.getText())) + .onGift((client, event) -> + System.out.println(event.getUser().getProfileName() + " sent " + event.getGift().getName() + " x" + event.getCombo())) + .onDisconnected((client, event) -> + System.out.println("Disconnected: " + event.getReason())) + .buildAndConnect(); + } +} +``` + +`TikTokLive.newClient` takes the creator's username, the part after `@` in `https://www.tiktok.com/@isaackogz/live`; a leading `@` is removed. Each `on<Event>` method of the builder registers a handler for one event class, called with the `LiveClient` and the event; `onEvent` receives every event, and `addListener` registers an object whose methods handle events ([listeners](/guides/listeners/)). Register handlers before connecting: the signing response already carries messages, and they are dispatched before the socket opens. + +`buildAndConnect()` builds the client, connects, and returns the `LiveClient`. It fetches the room data, requests a signed WebSocket URL, starts the socket, and returns; `TikTokConnectedEvent` fires from the socket thread once the socket is open. That thread keeps the process alive until the connection closes; call `client.disconnect()` to close it. When the creator is offline, `buildAndConnect()` throws `TikTokLiveOfflineHostException`. `TikTokLive.isLiveOnline("isaackogz")` checks first, and `buildAndConnectAsync()` connects on another thread. The [connection lifecycle](/getting-started/connection/) covers both, the retry settings, and each lifecycle event. + +## Read event payloads + +Each event is a class under `io.github.jwdeveloper.tiktok.api.data.events`, mapped from a Webcast protobuf message: `TikTokCommentEvent` from `WebcastChatMessage`, `TikTokGiftEvent` from `WebcastGiftMessage`. For a comment, the text is `event.getText()` and the sender is `event.getUser()`, a `User` whose `getName()` is the username and `getProfileName()` the display name. For a gift, `event.getGift().getName()` names the gift and `event.getCombo()` is the streak count. Nested objects such as `getUser()` are mapped from the message and are not `null`; a user TikTok did not send has id `0` and an empty name. Events with a message header also carry `getRoomId()`, `getMessageId()`, and `getTimeStamp()`. + +The [event catalog](/events/) lists each event with its type and fields, the [API reference](/api/) documents every public class in this checkout, and the [schema reference](/schema/) describes the protobuf messages the events are mapped from. + +## Signing + +The client requests its WebSocket URL from Euler Stream, a hosted [TikTok LIVE signing API](https://www.eulerstream.com/), at `tiktok.eulerstream.com`. A TikTok login is unnecessary for public rooms; provider rate limits still apply, and an API key raises them. Set `apiKey` in `configure`, and the client sends it as the `x-api-key` header. A rate-limited or failed signing request throws `TikTokSignServerException` from `connect()`. For production workloads that need an uptime guarantee, the managed [TikTok LIVE WebSocket API](https://www.eulerstream.com/websockets) handles signing, scaling, and protocol changes for you: set `useEulerstreamWebsocket` to `true` together with the API key, and the client receives its events from `wss://ws.eulerstream.com` instead of connecting to TikTok itself. See [signing](/configuration/signing/) and [troubleshooting](/troubleshooting/). + +```java +TikTokLive.newClient("isaackogz") + .configure(settings -> settings.setApiKey("your-euler-stream-api-key")) + .onComment((client, event) -> System.out.println(event.getText())) + .buildAndConnect(); +``` + +## Next steps + +- [Connection lifecycle](/getting-started/connection/): `build`, `buildAndConnect`, and `buildAndConnectAsync`, the connect sequence, lifecycle events, disconnects, retries, and room information. +- [Event catalog](/events/): find an event by its name or what it does. +- [Gifts and combos](/guides/gifts/): `TikTokGiftEvent`, `TikTokGiftComboEvent`, and gift streaks. +- [Client settings](/configuration/client-settings/): every `LiveClientSettings` field and its default. +- [API reference](/api/): generated from the Java sources in this checkout. + +These docs describe the library on `master`. The guides and the generated API reference come from the same checkout, so compare your dependency version with the version on the [API reference](/api/) index when something differs from your installed release. + +For the Node.js implementation, see [TikTok LIVE Connector](https://ttlc.docs.isaackogan.com/); for the Python implementation, see [TikTokLive](https://ttl.docs.isaackogan.com/). For the managed Euler Stream service, see [Euler Stream's documentation](https://www.eulerstream.com/docs). Their names and payload shapes differ from this Java library. From 7d4ec2309968d8c743dddb7a55b8d45c09166841 Mon Sep 17 00:00:00 2001 From: isaackogan <isaacikogan@gmail.com> Date: Tue, 20 Oct 2026 16:23:43 -0400 Subject: [PATCH 06/31] docs: correct reconnect and Euler Stream path behaviour on the connection page Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- docs/src/content/docs/getting-started/connection.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/src/content/docs/getting-started/connection.md b/docs/src/content/docs/getting-started/connection.md index 4a61d7c8..e71df284 100644 --- a/docs/src/content/docs/getting-started/connection.md +++ b/docs/src/content/docs/getting-started/connection.md @@ -13,7 +13,7 @@ description: Build and connect a client, follow the connect sequence and its lif | `buildAndConnect()` | The connected `LiveClient` | A program that connects once. It throws when the connection fails. | | `buildAndConnectAsync()` | A `CompletableFuture<LiveClient>` | The calling thread must not wait. The future completes with the client, or exceptionally when the connection fails. | -`LiveClient` has the matching methods. `connect()` blocks the calling thread while the client fetches the room data, signs, and starts the socket. `connectAsync()` runs `connect()` on the common pool and returns a `CompletableFuture<LiveClient>`; `connectAsync(Consumer<LiveClient>)` passes the connected client to the consumer. One connection per client: calling `connect()` while the state is `CONNECTING` or `CONNECTED` throws `TikTokLiveException` with the message `Already connected`. After a disconnect the same client can `connect()` again. +`LiveClient` has the matching methods. `connect()` blocks the calling thread while the client fetches the room data, signs, and starts the socket. `connectAsync()` runs `connect()` on the common pool and returns a `CompletableFuture<LiveClient>`; `connectAsync(Consumer<LiveClient>)` passes the connected client to the consumer. One connection per client: calling `connect()` while the state is `CONNECTING` or `CONNECTED` throws `TikTokLiveException` with the message `Already connected`. That exception goes through the same failure branch as any other: the client sets the state to `DISCONNECTED` and publishes `TikTokErrorEvent` and `TikTokDisconnectedEvent` with the reason `Exception: Already connected`, while the existing socket stays open and keeps delivering events. The state no longer matches the socket, and the next `connect()` passes the state check and replaces the socket, closing the old one first. After a disconnect the same client can `connect()` again. ```java var client = TikTokLive.newClient("isaackogz") @@ -52,7 +52,7 @@ In order, `connect()`: When a step throws a `TikTokLiveException`, `connect()` sets the state back to `DISCONNECTED`, publishes `TikTokErrorEvent` with the exception and then `TikTokDisconnectedEvent` with code `-1` and the reason `Exception: <message>`, and rethrows. `buildAndConnect()` and `connect()` throw it to the caller; `buildAndConnectAsync()` and `connectAsync()` complete their future exceptionally. Any other exception is logged with its stack trace and the client disconnects without throwing. -With `useEulerstreamWebsocket` set to `true`, steps 2 to 6 are skipped: the client publishes `TikTokConnectingEvent`, opens `wss://ws.eulerstream.com` with your `apiKey`, and sets the state to `CONNECTED`. See [signing](/configuration/signing/). +With `useEulerstreamWebsocket` set to `true`, `connect()` does none of steps 2 to 7: it publishes `TikTokConnectingEvent`, opens `wss://ws.eulerstream.com` with your `apiKey`, and sets the state to `CONNECTED`. It fills no room information and publishes no `TikTokRoomInfoEvent`; both come from the `roomInfo` message Euler Stream sends after the socket opens, so `getRoomInfo()` is still empty when `TikTokConnectedEvent` fires on that path. See [signing](/configuration/signing/). ## Lifecycle events @@ -84,6 +84,8 @@ TikTokLive.newClient("isaackogz") .buildAndConnect(); ``` +The `onPreConnection` handler and the room id printed by `onConnected` apply to the default path; with `useEulerstreamWebsocket`, the pre-connection event is not published and `getRoomId()` is `null` until the first `TikTokRoomInfoEvent`. + ## Disconnect `disconnect()` is `disconnect(LiveClientStopType.NORMAL)`: it stops the heartbeat, starts the WebSocket close handshake without waiting for it, and sets the state to `DISCONNECTED`. `LiveClientStopType.CLOSE_BLOCKING` waits until the socket has closed, and `LiveClientStopType.DISCONNECT` drops the connection without a close handshake. `TikTokDisconnectedEvent` is published when the socket reports the close. Calling `disconnect()` on a client that is not connected does nothing. @@ -139,7 +141,7 @@ while (true) { ## Room information -`getRoomInfo()` returns the client's `LiveRoomInfo`. Before connecting it holds only `getHostName()` and the state `DISCONNECTED`. `connect()` fills it from TikTok's responses: `getRoomId()`, `getTitle()`, `getHost()`, `getStartTime()`, `getViewersCount()`, `getTotalViewersCount()`, and `isAgeRestricted()`, all set before `TikTokPreConnectionEvent` is published. While connected, the same object is updated in place and `TikTokRoomInfoEvent` (`onRoomInfo`) is published with it each time: viewer counts and `getUsersRanking()` from TikTok's room user messages, `getLikesCount()` from like messages, the host and `getLanguage()` from the live intro message. `getConnectionState()` is `CONNECTING`, `CONNECTED`, or `DISCONNECTED`. +`getRoomInfo()` returns the client's `LiveRoomInfo`. Before connecting it holds only `getHostName()` and the state `DISCONNECTED`. On the default path, `connect()` fills it from TikTok's responses: `getRoomId()`, `getTitle()`, `getHost()`, `getStartTime()`, `getViewersCount()`, `getTotalViewersCount()`, and `isAgeRestricted()`, all set before `TikTokPreConnectionEvent` is published. With `useEulerstreamWebsocket`, `connect()` leaves it empty; the `roomInfo` message Euler Stream sends after the socket opens copies those fields in and publishes the first `TikTokRoomInfoEvent`. While connected, the same object is updated in place and `TikTokRoomInfoEvent` (`onRoomInfo`) is published with it each time: viewer counts and `getUsersRanking()` from TikTok's room user messages, `getLikesCount()` from like messages, the host and `getLanguage()` from the live intro message. `getConnectionState()` is `CONNECTING`, `CONNECTED`, or `DISCONNECTED`. ```java TikTokLive.newClient("isaackogz") From bfe99d1dbf728c30eb522daf1f1b28a6d3171ea2 Mon Sep 17 00:00:00 2001 From: isaackogan <isaacikogan@gmail.com> Date: Tue, 20 Oct 2026 16:38:59 -0400 Subject: [PATCH 07/31] docs: write configuration pages Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- .../docs/configuration/authentication.md | 76 +++++++++++++++++++ .../docs/configuration/client-settings.md | 72 +++++++++++++++++- .../docs/configuration/http-settings.md | 59 ++++++++++++++ .../src/content/docs/configuration/logging.md | 63 +++++++++++++++ .../docs/configuration/proxies-and-host.md | 71 +++++++++++++++++ .../src/content/docs/configuration/signing.md | 74 +++++++++++++++++- 6 files changed, 411 insertions(+), 4 deletions(-) create mode 100644 docs/src/content/docs/configuration/authentication.md create mode 100644 docs/src/content/docs/configuration/http-settings.md create mode 100644 docs/src/content/docs/configuration/logging.md create mode 100644 docs/src/content/docs/configuration/proxies-and-host.md diff --git a/docs/src/content/docs/configuration/authentication.md b/docs/src/content/docs/configuration/authentication.md new file mode 100644 index 00000000..5347fc34 --- /dev/null +++ b/docs/src/content/docs/configuration/authentication.md @@ -0,0 +1,76 @@ +--- +title: Authentication +description: When a TikTok session is needed, how to give the client your sessionId and ttTargetIdc cookies, where the client sends them, how sendChat and throwOnAgeRestriction use them, and the risks. +sidebar: + order: 4 +--- + +Reading a public livestream needs no TikTok account. The client's session settings are for: + +- `sendChat`, which posts as the account the session belongs to. +- Age-restricted rooms. The settings' Javadoc describes a session as the way to receive 18+ content; `throwOnAgeRestriction`, below, decides what `connect()` does with such a room. +- Chat completeness. The `sessionId` Javadoc notes that TikTok does not always send every chat message to a logged-out client, and the signing request forwards `sessionId`; the code comment there says this allows receiving all comments and subscribe events. + +## Session cookies + +TikTok identifies a logged-in session by the `sessionid` cookie together with `tt-target-idc`, the data center that holds the account. Copy both from your browser's cookies for `tiktok.com` while logged in and set `sessionId` and `ttTargetIdc` in `configure`. Set both: the Javadoc notes that `sessionId` is effective only with a correct `ttTargetIdc`. + +```java +TikTokLive.newClient("isaackogz") + .configure(settings -> { + settings.setSessionId(System.getenv("TIKTOK_SESSION_ID")); + settings.setTtTargetIdc(System.getenv("TIKTOK_TARGET_IDC")); + }) + .onComment((client, event) -> System.out.println(event.getText())) + .buildAndConnect(); +``` + +`TIKTOK_SESSION_ID` and `TIKTOK_TARGET_IDC` are names this example reads itself; the library reads no environment variables. + +## Where the session goes + +While the two settings are set, the client sends them with these requests and nowhere else: + +| Request | Destination | What is sent | +| --- | --- | --- | +| Creator's user data, `api-live/user/room` | `www.tiktok.com` | `sessionid` and `tt-target-idc` as cookies | +| Room info, `webcast/room/info` | `webcast.tiktok.com` | `sessionid` and `tt-target-idc` as cookies | +| Signing request | The signing provider | `sessionid` as the `session_id` query parameter | +| `sendChat` | The provider's chat endpoint | `sessionId`, `ttTargetIdc`, `roomId`, and `content` as a JSON body | + +The gift list request carries no session, the WebSocket handshake sends the cookies from the signing response, and the Euler Stream WebSocket URL carries the `apiKey` only. A `null` value is skipped, so with no session the cookies are not sent at all. See [signing](/configuration/signing/) for the rest of the signing request. + +## Sending chat messages + +`client.sendChat(content)` posts a message to the connected room with the `sessionId` and `ttTargetIdc` from the settings; `sendChat(content, sessionId, ttTargetIdc)` takes them per call. The request goes to the provider's chat endpoint with the client's `roomId`, so call it after `connect()` has filled the room info, and it carries the `x-api-key` header when `apiKey` is set. The method returns `true` when the provider answers HTTP 200. The interface's Javadoc warns that it is known to return `true` for some session ids although the message was not posted, a TikTok-side behaviour the library cannot detect. In [offline mode](/configuration/client-settings/#offline-mode) it returns `false`. + +```java +var client = TikTokLive.newClient("isaackogz") + .configure(settings -> { + settings.setSessionId(System.getenv("TIKTOK_SESSION_ID")); + settings.setTtTargetIdc(System.getenv("TIKTOK_TARGET_IDC")); + }) + .buildAndConnect(); + +boolean accepted = client.sendChat("Hello from TikTokLiveJava"); +System.out.println(accepted ? "Sent" : "Rejected"); +``` + +## Age-restricted rooms + +`throwOnAgeRestriction` is `false` by default. When it is `true`, `connect()` throws `TikTokLiveException` with the message `Livestream for <hostName> is 18+ or age restricted!` as soon as the room info reports the restriction, before `TikTokPreConnectionEvent` and before any signing request. When it is `false`, the connection continues; `getRoomInfo().isAgeRestricted()` and `TikTokPreConnectionEvent.getRoomData().isAgeRestricted()` report the restriction, and a pre-connection handler can cancel the connection itself, as shown on the [connection lifecycle](/getting-started/connection/#lifecycle-events) page. + +```java +TikTokLive.newClient("isaackogz") + .configure(settings -> settings.setThrowOnAgeRestriction(true)) + .onComment((client, event) -> System.out.println(event.getText())) + .buildAndConnect(); +``` + +## Risks + +A TikTok session id grants full access to the account it belongs to. + +- It leaves your machine. Every request in the table above sends it, to TikTok and to the signing provider; the provider receives it with each signing request while the setting is set. How the provider handles it is described in [Euler Stream's documentation](https://www.eulerstream.com/docs), not here. +- Keep it out of source control and logs. Read it from the environment or a secret store, as the examples above do, and clear the settings when a client no longer needs them. +- Prefer a secondary account over your own, and do not set a session unless you need one of the three uses above. diff --git a/docs/src/content/docs/configuration/client-settings.md b/docs/src/content/docs/configuration/client-settings.md index 4d942b48..8dd0e155 100644 --- a/docs/src/content/docs/configuration/client-settings.md +++ b/docs/src/content/docs/configuration/client-settings.md @@ -1,6 +1,74 @@ --- title: Client settings -description: Every field of LiveClientSettings, its type, default, and effect. +description: Every field of LiveClientSettings, its type, default, and effect, how configure applies them, what build validates, and offline mode for tests. +sidebar: + order: 1 --- -This page is written in a later task of the documentation plan. +`LiveClientSettings` holds the configuration of one client. `TikTokLive.newClient(hostName)` starts from `LiveClientSettings.createDefault()` with `hostName` set, and the builder's `configure(Consumer<LiveClientSettings>)` hands you that object to change. Each `configure` call runs at once and can be repeated; `build()` validates the result and shares the same object with the built client, so set everything before building. + +```java +TikTokLive.newClient("isaackogz") + .configure(settings -> { + settings.setFetchGifts(true); + settings.setClientLanguage("de-DE"); + settings.setPingInterval(5000); + }) + .onComment((client, event) -> System.out.println(event.getText())) + .buildAndConnect(); +``` + +The fields are Lombok properties: `fetchGifts` is read with `isFetchGifts()` and written with `setFetchGifts(boolean)`, `logLevel` with `getLogLevel()` and `setLogLevel(Level)`. `TikTokLive.requests(Consumer<LiveClientSettings>)` accepts the same settings for a standalone `LiveHttpClient`, the kind of client behind `isLiveOnline`; see [HTTP settings](/configuration/http-settings/). + +## Fields + +The defaults are the values after `TikTokLive.newClient`, which calls `createDefault()`. Two of them differ from the field initialisers, as noted. + +| Field | Type | Default | Effect | +| --- | --- | --- | --- | +| `offline` | `boolean` | `false` | Replaces the HTTP and WebSocket clients with stand-ins that send nothing to TikTok or the signing provider. See [Offline mode](#offline-mode). | +| `fetchGifts` | `boolean` | `false` | When `true`, `connect()` downloads the room's gift list after `TikTokPreConnectionEvent`, so `getGiftManager()` knows every gift before the first `TikTokGiftEvent`. When `false`, the manager starts empty and adds each gift from the first message that carries it. See [gifts](/guides/gifts/). | +| `clientLanguage` | `String` | `"en-US"` | Sent to TikTok as the `app_language` and `webcast_language` request parameters. `build()` replaces `null` or an empty string with `"en"`. | +| `retryOnConnectionFailure` | `boolean` | `false` | Call `connect()` again when the creator is offline. See [connection lifecycle](/getting-started/connection/#retry-when-the-creator-is-offline). | +| `retryConnectionTimeout` | `Duration` | 1 second | The wait before each retry. See [connection lifecycle](/getting-started/connection/#retry-when-the-creator-is-offline). | +| `printToConsole` | `boolean` | `false` | When `false`, the client's logger is set to `Level.OFF`. The field initialiser is `true`; `createDefault()` sets `false`. See [logging](/configuration/logging/). | +| `logLevel` | `java.util.logging.Level` | `Level.ALL` | The level of the client's logger when `printToConsole` is `true`. See [logging](/configuration/logging/). | +| `hostName` | `String` | The `newClient` argument | The creator's username. `build()` removes a leading `@` and throws `TikTokLiveException` when it is `null` or empty. See [proxies and host](/configuration/proxies-and-host/). | +| `httpSettings` | `HttpClientSettings` | TikTok's web-client parameters and headers, a 3-second timeout, no proxy | Parameters, headers, cookies, timeout, proxies, and transport hooks for every HTTP request and the WebSocket handshake. See [HTTP settings](/configuration/http-settings/). | +| `pingInterval` | `long` | `10000` | Milliseconds between heartbeats once the socket is open. `build()` throws `TikTokLiveException` for a value under `250`. Not used on the Euler Stream WebSocket path. | +| `throwOnAgeRestriction` | `boolean` | `false` | When `true`, `connect()` throws `TikTokLiveException` for an age-restricted room instead of connecting. See [authentication](/configuration/authentication/). | +| `useEulerstreamWebsocket` | `boolean` | `false` | Receive events from Euler Stream's WebSocket instead of connecting to TikTok. Needs `apiKey`. See [signing](/configuration/signing/). | +| `useEulerstreamEnterprise` | `boolean` | `false` | Use Euler Stream's enterprise endpoints for signing, chat, and the WebSocket. See [signing](/configuration/signing/). | +| `sessionId` | `String` | `null` | Your TikTok `sessionid` cookie. Sent with TikTok's user and room requests, the signing request, and `sendChat`. See [authentication](/configuration/authentication/). | +| `ttTargetIdc` | `String` | `null` | Your TikTok `tt-target-idc` cookie, needed with `sessionId`. See [authentication](/configuration/authentication/). | +| `roomId` | `String` | `null` | Not read by the client in this checkout: `connect()` always takes the room id from the creator's user data. Setting it has no effect. | +| `apiKey` | `String` | `null` | Your Euler Stream API key. Sent as the `x-api-key` header of the signing and chat requests and in the Euler Stream WebSocket URL. See [signing](/configuration/signing/). | + +## What `build()` validates + +`build()` runs these checks and adjustments before wiring the client, in this order: + +1. An empty or `null` `clientLanguage` becomes `"en"`. +2. An empty or `null` `hostName` throws `TikTokLiveException` with the message `HostName can not be null`; a leading `@` is removed. +3. A `pingInterval` under `250` throws `TikTokLiveException` with the message `Minimum allowed ping interval is 250 milliseconds`. +4. `clientLanguage` is written into the HTTP parameters `app_language` and `webcast_language`. + +Nothing else is validated: an `apiKey` or a session is checked only by the server that receives it. + +## Offline mode + +With `offline` set to `true`, `build()` wires `TikTokLiveHttpOfflineClient` and `TikTokWebSocketOfflineClient` in place of the real clients. No request leaves the process, and `sendChat` returns `false`. Use it to test handlers with `publishEvent`, which dispatches an event object to your handlers, and `publishMessage`, which runs a raw Webcast message through the same mapping as a socket message; both work whether or not the client is connected. + +In this checkout, `connect()` in offline mode stops with a logged `NullPointerException` before the stand-in socket starts, because the offline HTTP client returns no room info. `connect()` does not throw; it logs `Unhandled exception`, prints the stack trace, and sets the state back to `DISCONNECTED` without publishing `TikTokConnectedEvent`. `build()` and `publishEvent` are enough for a test, so skip `connect()`. + +```java +var client = TikTokLive.newClient("isaackogz") + .configure(settings -> settings.setOffline(true)) + .onComment((liveClient, event) -> + System.out.println(event.getUser().getProfileName() + ": " + event.getText())) + .build(); + +client.publishEvent(TikTokCommentEvent.of("Mark", "Hello world")); +``` + +The event classes have `of` factories for tests: `TikTokCommentEvent.of(userName, message)`, `TikTokGiftEvent.of(gift)`, `TikTokLikeEvent.of(userName, likes)`, `TikTokFollowEvent.of(userName)`, among others. The repository's `Events_And_Gifts_Testing_Example` also shows `publishMessage` with a Base64 `WebcastLikeMessage` payload. diff --git a/docs/src/content/docs/configuration/http-settings.md b/docs/src/content/docs/configuration/http-settings.md new file mode 100644 index 00000000..699cd431 --- /dev/null +++ b/docs/src/content/docs/configuration/http-settings.md @@ -0,0 +1,59 @@ +--- +title: HTTP settings +description: Every field of HttpClientSettings, request parameters, headers, cookies, timeout, proxy settings, and the hooks on the HTTP transport and on each request. +sidebar: + order: 2 +--- + +`HttpClientSettings` is the `httpSettings` field of `LiveClientSettings`, reached in `configure` with `settings.getHttpSettings()`. It shapes every HTTP request the client sends, the user, room, and gift requests to TikTok and the signing and chat requests to the provider, and it supplies the headers and connect timeout of the WebSocket handshake. + +Each request starts from a `clone()` of the settings, so the values you set are the baseline for every request and nothing one request adds to the maps leaks into the next. The `java.net.http.HttpClient` behind the requests is created once per client, on its first request, from the settings at that moment. + +```java +TikTokLive.newClient("isaackogz") + .configure(settings -> { + var http = settings.getHttpSettings(); + http.setTimeout(Duration.ofSeconds(10)); + http.getHeaders().put("Accept-Language", "de-DE,de;q=0.9"); + http.getParams().put("tz_name", "Europe/Berlin"); + http.onRequestCreating(request -> request.header("X-Request-Source", "my-bot")); + }) + .buildAndConnect(); +``` + +## Fields + +| Field | Type | Default | Effect | +| --- | --- | --- | --- | +| `params` | `Map<String, Object>` | `LiveClientSettings.DefaultClientParams()`: `aid`, `app_name`, `browser_name`, `browser_version`, `device_platform`, `screen_width`, `tz_name`, and the other parameters of TikTok's web client | Appended to every request URL as its URL-encoded query string. The map is a `TreeMap`, so the parameters are sent sorted by name. `build()` sets `app_language` and `webcast_language` from `clientLanguage`. | +| `headers` | `Map<String, String>` | `LiveClientSettings.DefaultRequestHeaders()`: `User-Agent`, `Referer`, `Origin`, `Accept`, `Accept-Language`, `Cache-Control`, `authority` | Set on every request, and sent in the WebSocket handshake on both the TikTok socket and the Euler Stream socket. | +| `cookies` | `Map<String, String>` | Empty | Joined into one `Cookie` header, `name=value; name=value`, when the map is not empty. The client adds `sessionid` and `tt-target-idc` from `LiveClientSettings` to TikTok's user and room requests. The WebSocket handshake sends the cookies from the signing response, not this map. | +| `proxyClientSettings` | `ProxyClientSettings` | Disabled, no proxies | Proxy list, type, rotation, and failure handling; `configureProxy` enables it. See [proxies and host](/configuration/proxies-and-host/). | +| `onClientCreating` | `Consumer<HttpClient.Builder>` | No-op | Called once, when the client's `java.net.http.HttpClient` is created on the first request, after redirect following, a `CookieManager`, the connect timeout, and the proxy selector are set. Set it with `onClientCreating(Consumer)`. | +| `onRequestCreating` | `Consumer<HttpRequest.Builder>` | No-op | Called for every request after the URI, timeout, and headers are set and before the request is built. Set it with `onRequestCreating(Consumer)`. | +| `timeout` | `Duration` | 3 seconds | The connect timeout of the HTTP transport and the timeout of each request. The constructor's own default is 2 seconds; `createDefault()` sets 3. See [the timeout and sub-second parts](#the-timeout-and-sub-second-parts). | + +`params`, `headers`, and `cookies` are final maps with getters only: change their entries with `put` and `remove`. `proxyClientSettings` and `timeout` have setters; the two hooks are set through the methods named after them. + +## The timeout and sub-second parts + +The HTTP transport and each request receive the whole `Duration`. The WebSocket handshake, and the SOCKS proxy path, receive `timeout.toMillisPart()`, the millisecond fraction of the duration (0 to 999), as a connect timeout in milliseconds. With a whole-second value such as the default that is `0`, which `Socket.connect` treats as no timeout. `Duration.ofMillis(2500)` gives the HTTP requests 2.5 seconds and the WebSocket handshake 500 milliseconds. + +## Default parameters and headers + +The defaults present the client as TikTok's web client in Chrome 102 on Windows: the `User-Agent` header and the `browser_version` parameter carry that string, `aid` is `1988`, `app_name` is `tiktok_web`, and `tz_name` is `Europe/Berlin`. `DefaultClientParams()` and `DefaultRequestHeaders()` are public static methods of `LiveClientSettings`, so you can print the full lists: + +```java +LiveClientSettings.DefaultClientParams().forEach((name, value) -> System.out.println(name + "=" + value)); +LiveClientSettings.DefaultRequestHeaders().forEach((name, value) -> System.out.println(name + ": " + value)); +``` + +## Requests outside a client + +`TikTokLive.requests(Consumer<LiveClientSettings>)` returns the `LiveHttpClient` the static checks use, configured with the same settings, so a live check can go through your proxy or carry your headers. `TikTokLive.isLiveOnline` and `isHostNameValid` use the defaults. + +```java +var requests = TikTokLive.requests(settings -> settings.getHttpSettings().setTimeout(Duration.ofSeconds(10))); +var userData = requests.fetchLiveUserData(new LiveUserData.Request("isaackogz")); +System.out.println(userData.isLiveOnline()); +``` diff --git a/docs/src/content/docs/configuration/logging.md b/docs/src/content/docs/configuration/logging.md new file mode 100644 index 00000000..2e8f8811 --- /dev/null +++ b/docs/src/content/docs/configuration/logging.md @@ -0,0 +1,63 @@ +--- +title: Logging +description: The client's java.util.logging logger, the printToConsole and logLevel settings, what the client logs, and how to route the log to your own handler. +sidebar: + order: 6 +--- + +Each client logs through a `java.util.logging.Logger` named after its `hostName`, returned by `client.getLogger()`. The client is silent by default: `printToConsole` is `false` after `TikTokLive.newClient`, and that sets the logger to `Level.OFF`. + +```java +TikTokLive.newClient("isaackogz") + .configure(settings -> { + settings.setPrintToConsole(true); + settings.setLogLevel(Level.INFO); + }) + .onConnected((client, event) -> + client.getLogger().info("Connected to " + client.getRoomInfo().getHostName())) + .buildAndConnect(); +``` + +## Settings + +| Field | Type | Default | Effect | +| --- | --- | --- | --- | +| `printToConsole` | `boolean` | `false` | When `false`, the logger's level is set to `Level.OFF` and nothing is logged. The field initialiser is `true`; `createDefault()` sets `false`. | +| `logLevel` | `java.util.logging.Level` | `Level.ALL` | The logger's level when `printToConsole` is `true`. | + +Both are read once, when `build()` creates the logger. If a logger with that name already has a handler, from an earlier client for the same `hostName` in the same JVM, `build()` reuses it as it is and both settings of the new client are ignored. + +## The console handler + +When `build()` creates the logger, it adds a `ConsoleHandler`, which writes to `System.err`, with a formatter that prints `[hostName] [LEVEL]: message` in ANSI colours, and it stops the record from reaching the parent loggers, so the root logger's configuration does not apply. The handler keeps the JDK's default handler level, `INFO`: with `logLevel` at `Level.ALL`, records below `INFO` reach the logger but the handler drops them. Lower the handler's level to see them. + +```java +var client = TikTokLive.newClient("isaackogz") + .configure(settings -> settings.setPrintToConsole(true)) + .build(); + +for (var handler : client.getLogger().getHandlers()) { + handler.setLevel(Level.ALL); +} +``` + +## What the client logs + +At `INFO`: every state change as `TikTokLive client state: CONNECTING`, `CONNECTED`, or `DISCONNECTED`; `Reconnecting` before each retry; the `workerInfo` message on the Euler Stream WebSocket path. At `WARNING`: the signing response's headers when it lacks the `x-set-tt-cookie` header, and malformed JSON in the creator's user data, whether from TikTok or from the Euler Stream `roomInfo` message, with the exception attached. The HTTP client logs through a second logger named `HttpClient-<hash>`, created with the same settings. + +Exceptions thrown by a handler, by the socket, or by the connect sequence are published as `TikTokErrorEvent` rather than logged; register `onError` to see them, see [connection lifecycle](/getting-started/connection/#lifecycle-events). An unexpected exception in `connect()` that is not a `TikTokLiveException` logs `Unhandled exception` at `INFO` and prints its stack trace to `System.err` regardless of the settings. + +## Your own handler + +The logger is a standard `java.util.logging.Logger`: remove the console handler and add your own, or forward it to a bridge for another logging framework. Set the logger's level too, because it is `Level.OFF` while `printToConsole` is `false`. + +```java +var client = TikTokLive.newClient("isaackogz").build(); +var logger = client.getLogger(); + +for (var handler : logger.getHandlers()) { + logger.removeHandler(handler); +} +logger.addHandler(new FileHandler("tiktok-live.log", true)); +logger.setLevel(Level.INFO); +``` diff --git a/docs/src/content/docs/configuration/proxies-and-host.md b/docs/src/content/docs/configuration/proxies-and-host.md new file mode 100644 index 00000000..0c8baa0e --- /dev/null +++ b/docs/src/content/docs/configuration/proxies-and-host.md @@ -0,0 +1,71 @@ +--- +title: Proxies and host +description: The hostName setting, and ProxyClientSettings, the proxy list, type, rotation, discard and fallback behaviour, and how the HTTP requests and the WebSocket use it. +sidebar: + order: 5 +--- + +## Host name + +`hostName` is the creator's username, the part after `@` in `https://www.tiktok.com/@isaackogz/live`. `TikTokLive.newClient(hostName)` sets it, and `setHostName` in `configure` replaces it. `build()` removes a leading `@` and throws `TikTokLiveException` with the message `HostName can not be null` when the value is `null` or empty. The built client reports it as `getRoomInfo().getHostName()`, names its logger after it, and sends it as `uniqueId` to TikTok's user-data request and to the Euler Stream WebSocket. + +```java +var client = TikTokLive.newClient("@isaackogz").build(); +System.out.println(client.getRoomInfo().getHostName()); +``` + +`TikTokLive.isHostNameValid(hostName)` asks TikTok whether a username exists; see [connection lifecycle](/getting-started/connection/#check-whether-a-creator-is-live). + +## Proxies + +`ProxyClientSettings` is the `proxyClientSettings` field of the [HTTP settings](/configuration/http-settings/). `configureProxy(Consumer<ProxyClientSettings>)` on `HttpClientSettings` sets `enabled` to `true` and runs your consumer; with `enabled` left `false`, the other fields have no effect. + +```java +import java.net.Proxy; + +TikTokLive.newClient("isaackogz") + .configure(settings -> settings.getHttpSettings().configureProxy(proxy -> { + proxy.setType(Proxy.Type.HTTP); + proxy.addProxy("203.0.113.10:8080"); + proxy.addProxy("203.0.113.11", 8080); + proxy.setRotation(ProxyClientSettings.Rotation.RANDOM); + proxy.setOnProxyUpdated(proxyData -> + System.out.println("Using " + proxyData.getAddress() + ":" + proxyData.getPort())); + })) + .onComment((client, event) -> System.out.println(event.getText())) + .buildAndConnect(); +``` + +### Fields + +| Field | Type | Default | Effect | +| --- | --- | --- | --- | +| `enabled` | `boolean` | `false` | Whether requests use the proxy list. `configureProxy` sets it. | +| `type` | `java.net.Proxy.Type` | `DIRECT` | `HTTP`, and `DIRECT` once proxies are enabled, send requests through the JDK HTTP client with the selected endpoint as an HTTP proxy. `SOCKS` opens an `HttpsURLConnection` through the endpoint as a SOCKS proxy; see [SOCKS and certificates](#socks-and-certificates). | +| `proxyList` | `List<ProxyData>` | Empty | The endpoints. Add them with `addProxy("host:port")`, `addProxy(host, port)`, `addProxy(InetSocketAddress)`, or `addProxies(List<String>)`. `getProxiesSnapshot()` returns a copy for iteration. | +| `rotation` | `Rotation` | `CONSECUTIVE` | How `next()` picks an endpoint: `CONSECUTIVE` walks the list in order and wraps, `RANDOM` picks any entry, `NONE` stays on `index`. | +| `index` | `int` | `0` | The current position in the list. `setIndex` throws `IndexOutOfBoundsException` for a value outside the list. | +| `autoDiscard` | `boolean` | `true` | Remove an endpoint from the list when a connection through it fails. | +| `fallback` | `boolean` | `true` | When a request through a proxy fails with an `IOException` whose message contains `503`, send it again without a proxy. | +| `allowWebsocket` | `boolean` | `true` | Whether the WebSocket also connects through the proxy list. | +| `onProxyUpdated` | `Consumer<ProxyData>` | No-op | Called with each endpoint `next()` selects. | + +`ProxyData` holds an `address` and a `port` and nothing else; there is no field for proxy credentials. `addProxy("host:port")` parses with `ProxyData.map`, which throws `IllegalArgumentException` when the port is not an integer or the address does not resolve, and `IndexOutOfBoundsException` when the port is outside 0 to 65535. + +### How a request uses the list + +For each HTTP request, the client builds `HttpProxyClient` when proxies are enabled and the list is not empty, and the plain `HttpClient` otherwise. `HttpProxyClient` selects an endpoint with `next()` and sends the request through it: + +- With `HTTP` or `DIRECT`, a response other than 200 moves on to the next endpoint; `hasNext()` is `true` while the list is not empty, so endpoints that answer with another status are tried again in rotation. A connect timeout or `ConnectException` removes the endpoint when `autoDiscard` is `true` and throws `TikTokProxyRequestException`; the client's `fetch` methods catch that and start the request again while endpoints remain, then make one last attempt without a proxy. Any other `IOException` throws it too, unless `fallback` applies. +- With `SOCKS`, one endpoint is tried per attempt; an `IOException` removes it when `autoDiscard` is `true` and throws `TikTokProxyRequestException`, with the same retry by the `fetch` methods. +- `TikTokLiveRequestException` with the message `No more proxies available!` is thrown when the list runs empty inside one attempt. + +The signing request is repeated on `TikTokSignServerException` as well as on `TikTokProxyRequestException`, while endpoints remain and then once without a proxy; see [signing](/configuration/signing/). + +### The WebSocket + +When proxies are enabled and `allowWebsocket` is `true`, the socket to TikTok connects through the list: the client walks the endpoints with `next()`, sets each as the socket's `java.net.Proxy` of the configured `type`, and tries to connect; a failure removes the endpoint when `autoDiscard` is `true`. When the list runs empty, `connect()` throws `TikTokLiveException` with the message `Failed to connect to the websocket`. The Euler Stream WebSocket (`useEulerstreamWebsocket`) does not use the proxy settings; the HTTP requests of that client, such as `sendChat`, still do. + +### SOCKS and certificates + +With `type` set to `SOCKS`, both the HTTP requests and the WebSocket install an SSL context whose trust manager accepts every certificate, so the TLS connections made through a SOCKS proxy are not verified against the server's certificate chain. Use `HTTP` proxies when that matters. diff --git a/docs/src/content/docs/configuration/signing.md b/docs/src/content/docs/configuration/signing.md index e71229b8..864a6dd0 100644 --- a/docs/src/content/docs/configuration/signing.md +++ b/docs/src/content/docs/configuration/signing.md @@ -1,6 +1,76 @@ --- title: Signing -description: How TikTokLiveJava signs its connections with Euler Stream, and the apiKey and useEulerstreamWebsocket settings. +description: How TikTokLiveJava obtains its WebSocket connection from Euler Stream, what the apiKey setting does, how rate limits and provider errors surface, and the useEulerstreamWebsocket and useEulerstreamEnterprise paths. +sidebar: + order: 3 --- -This page is written in a later task of the documentation plan. +TikTokLiveJava does not build the Webcast WebSocket URL itself. During `connect()`, after the room data is fetched, it asks a signing provider for the first fetch result and the signed WebSocket URL. The provider is [Euler Stream](https://www.eulerstream.com/), reached at `https://tiktok.eulerstream.com/webcast/fetch`; the URL is a constant of `TikTokLiveHttpClient`, and there is no setting for another provider. Signing works without an account; an API key raises the limits that apply to you. Plans, limits, and endpoint permissions are set by the provider and described in [Euler Stream's documentation](https://www.eulerstream.com/docs), not here. + +## What is sent to the provider + +Each connection attempt makes a GET request to the provider. It carries the default [HTTP settings](/configuration/http-settings/) parameters and headers, including the `User-Agent`, plus: + +- `client=ttlive-java` and `room_id` as query parameters. +- `session_id`, only when `sessionId` is set; see [authentication](/configuration/authentication/). +- The `x-api-key` header, only when `apiKey` is set. + +The response's `x-set-tt-cookie` header holds the cookies the WebSocket handshake sends to TikTok. Its body is a `ProtoMessageFetchResult`: the client builds the WebSocket URL from its push server, cursor, internal ext, and route parameters, and dispatches the messages it already contains to your handlers before the socket opens. + +The requests to TikTok's own API, the creator's user data at `www.tiktok.com`, and the room info and the gift list at `webcast.tiktok.com`, are not signed and do not touch the provider. `sendChat` is the other request that does: it posts to the provider's chat endpoint, `https://tiktok.eulerstream.com/webcast/chat`. + +## API key + +Set `apiKey` in `configure`. The client sends it as the `x-api-key` header of the signing and chat requests and as the `apiKey` query parameter of the Euler Stream WebSocket URL. The library reads no environment variable itself; the example reads one and passes it on. + +```java +TikTokLive.newClient("isaackogz") + .configure(settings -> settings.setApiKey(System.getenv("EULER_API_KEY"))) + .onComment((client, event) -> System.out.println(event.getText())) + .buildAndConnect(); +``` + +## Rate limits and provider errors + +The signing request must answer HTTP 200. Any other status makes `connect()` throw `TikTokSignServerException`, whose message starts with `Unable to get websocket connection credentials` and carries the status code and one of these notes: + +| Status | Note | +| --- | --- | +| `429` | `Sign server rate limit reached. Try again in mm:ss.`, computed from the `ratelimit-reset` header in seconds, or `Try again later.` when the header is missing. | +| `420` | `IP Cloudflare Blocked.` | +| `500` to `503` | `Sign server Error. Try again later.` | +| `504` | `Sign server Timeout. Try again later.` | +| Any other | The status code alone. | + +`TikTokSignServerException` is also thrown when a 200 response has no `x-set-tt-cookie` header, after the client logs the response headers at `WARNING`, and when its body is not a valid `ProtoMessageFetchResult`. Without proxies, the client does not retry a failed signing request; `retryOnConnectionFailure` covers an offline creator only, see [connection lifecycle](/getting-started/connection/#retry-when-the-creator-is-offline). Each connection attempt spends one signing request, so poll with `TikTokLive.isLiveOnline`, which asks TikTok directly, rather than by connecting. + +```java +try { + TikTokLive.newClient("isaackogz").buildAndConnect(); +} catch (TikTokSignServerException e) { + System.err.println("Signing failed: " + e.getMessage()); +} +``` + +With proxies enabled, the signing request is repeated through the proxy list on `TikTokProxyRequestException` and `TikTokSignServerException` while endpoints remain, then once without a proxy; see [proxies and host](/configuration/proxies-and-host/). + +## Euler Stream WebSocket + +With `useEulerstreamWebsocket` set to `true`, the client receives its events from the managed [TikTok LIVE WebSocket API](https://www.eulerstream.com/websockets) instead of connecting to TikTok. `connect()` publishes `TikTokConnectingEvent`, opens `wss://ws.eulerstream.com` with `uniqueId` set to `hostName`, `apiKey` set to your key, and `features.rawMessages=true`, sends the HTTP settings' headers in the handshake, and sets the state to `CONNECTED`. On this path the client makes no request to TikTok and no signing request, publishes no `TikTokPreConnectionEvent`, downloads no gift list, starts no `pingInterval` heartbeat (the socket listener answers each message with a ping instead), and ignores the proxy settings. An `apiKey` is required; without one the URL carries `apiKey=null`. + +The first two messages are JSON: `workerInfo`, which the client logs at `INFO`, and `roomInfo`, which fills `getRoomInfo()` and publishes the first `TikTokRoomInfoEvent`. Binary frames go through the same message handler as on the TikTok socket, so your handlers receive the same events. See [connection lifecycle](/getting-started/connection/#the-connect-sequence) for what differs in the lifecycle. + +```java +TikTokLive.newClient("isaackogz") + .configure(settings -> { + settings.setApiKey(System.getenv("EULER_API_KEY")); + settings.setUseEulerstreamWebsocket(true); + }) + .onRoomInfo((client, event) -> System.out.println("Room " + event.getRoomInfo().getRoomId())) + .onComment((client, event) -> System.out.println(event.getText())) + .buildAndConnect(); +``` + +## Enterprise endpoints + +With `useEulerstreamEnterprise` set to `true`, the signing request goes to `https://tiktok.enterprise.eulerstream.com/webcast/fetch`, `sendChat` posts to `https://tiktok.enterprise.eulerstream.com/webcast/chat`, and the Euler Stream WebSocket URL gains `features.useEnterpriseApi=true`. The setting's Javadoc notes that it requires an API key; what that key must be entitled to is decided by the provider. From cd561d848d8394fff262fa034453a46a0a9681d7 Mon Sep 17 00:00:00 2001 From: isaackogan <isaacikogan@gmail.com> Date: Tue, 20 Oct 2026 17:04:12 -0400 Subject: [PATCH 08/31] docs: write the event catalog and grouped event pages Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- .../docs/events/battles-and-cohosting.md | 177 ++++++++++++++ .../content/docs/events/chat-and-audience.md | 80 +++++++ docs/src/content/docs/events/control.md | 68 ++++++ docs/src/content/docs/events/debugging.md | 49 ++++ .../content/docs/events/games-and-commerce.md | 14 ++ .../content/docs/events/gifts-and-goals.md | 64 ++++++ docs/src/content/docs/events/index.md | 216 +++++++++++++++++- .../docs/events/moderation-and-access.md | 52 +++++ docs/src/content/docs/events/rankings.md | 20 ++ .../content/docs/events/room-and-display.md | 67 ++++++ .../src/content/docs/events/stream-control.md | 35 +++ .../docs/events/subscriptions-and-fans.md | 28 +++ 12 files changed, 867 insertions(+), 3 deletions(-) create mode 100644 docs/src/content/docs/events/battles-and-cohosting.md create mode 100644 docs/src/content/docs/events/chat-and-audience.md create mode 100644 docs/src/content/docs/events/control.md create mode 100644 docs/src/content/docs/events/debugging.md create mode 100644 docs/src/content/docs/events/games-and-commerce.md create mode 100644 docs/src/content/docs/events/gifts-and-goals.md create mode 100644 docs/src/content/docs/events/moderation-and-access.md create mode 100644 docs/src/content/docs/events/rankings.md create mode 100644 docs/src/content/docs/events/room-and-display.md create mode 100644 docs/src/content/docs/events/stream-control.md create mode 100644 docs/src/content/docs/events/subscriptions-and-fans.md diff --git a/docs/src/content/docs/events/battles-and-cohosting.md b/docs/src/content/docs/events/battles-and-cohosting.md new file mode 100644 index 00000000..96f1d098 --- /dev/null +++ b/docs/src/content/docs/events/battles-and-cohosting.md @@ -0,0 +1,177 @@ +--- +title: Battles and cohosting +description: Battles and their points, the Power-Up card notice, and every link-mic event the client derives from WebcastLinkMessage, with the fields each one carries. +sidebar: + order: 4 +--- + +A battle is two or more hosts competing for gift points; link-mic is the signalling behind cohosting and multi-guest sessions. `TikTokLinkMicBattleEvent` and `TikTokLinkMicArmiesEvent` have their own messages. The `TikTokLink*` events share one message, [`WebcastLinkMessage`](/schema/webcast/webcastlinkmessage/): the client reads its [`LinkMessageType`](/schema/enums/linkmessagetype/) and builds the matching subclass of `TikTokLinkEvent`, or `TikTokLinkEvent` itself for a type with no subclass. A subclass that needs the message's content throws when it is absent, and the client publishes that as `TikTokErrorEvent`. + +None of these events has a builder method; register them with `onEvent(TikTokXEvent.class, handler)`. Each section names the Webcast message and links its schema page. Replace `isaackogz` with a creator who is live. + +## TikTokLinkMicBattleEvent + +A battle started or finished, with its teams, hosts, and final scores. Message: [`WebcastLinkMicBattle`](/schema/webcast/webcastlinkmicbattle/). + +Key fields: `getBattleId()`; `isFinished()`, `true` when the message's [`BattleAction`](/schema/enums/battleaction/) is `BATTLE_ACTION_FINISH`; `getBattleType()`, a [`BattleType`](/schema/enums/battletype/), with the helpers `is1v1()`, `is2v2()`, `isIndividual()` (1v1v1 or 1v1v1v1), and `isMultiTeam()` (1 against many); and `getTeams()`, a list of `Team` with `getTeamId()`, `getHosts()`, `getTotalPoints()` (`-1` until the battle has finished), `getWinStreak()` (the host's combo count in a 1v1 battle, otherwise `-1`), and `getViewers()`, the contributors and their points, filled when the battle has finished. `isTie()` compares the finished teams' points, and `getTeam(hostName)`, `getOpponentTeams(hostName)`, and `getTeams(hostName)` pick teams by a host's username. For a 1-against-many battle the teams list stays empty: the source notes that no data has been collected for it yet. + +```java +TikTokLive.newClient("isaackogz") + .onEvent(TikTokLinkMicBattleEvent.class, (client, event) -> { + if (!event.isFinished()) { + System.out.println("Battle " + event.getBattleId() + " started with " + event.getTeams().size() + " teams"); + return; + } + for (var team : event.getTeams()) { + var hosts = team.getHosts().stream().map(host -> host.getName()).toList(); + System.out.println(hosts + ": " + team.getTotalPoints() + " points"); + } + }) + .buildAndConnect(); +``` + +## TikTokLinkMicArmiesEvent + +A battle's points were updated, per host or team with the top contributors. Message: [`WebcastLinkMicArmies`](/schema/webcast/webcastlinkmicarmies/). + +Key fields: `getBattleId()`; `isFinished()`, `true` when the message's [`TriggerReason`](/schema/enums/triggerreason/) is the battle's end; `getPicture()`, the animated icon; `getBattleType()`; and `getArmies()`, a map from the host's id in a 1v1 battle or the team's id otherwise to a `LinkMicArmy` with `getArmyId()`, `getTotalPoints()`, and `getArmies()`, the contributors as `User` objects mapped to their points. The map stays empty for a 1-against-many battle. + +## TikTokLinkMicBattleItemCard + +A battle Power-Up card notice; the event carries only the message header. Message: [`WebcastLinkMicBattleItemCard`](/schema/webcast/webcastlinkmicbattleitemcard/). The class name has no `Event` suffix. + +## TikTokLinkMicFanTicketEvent + +A fan-ticket notice of a link-mic session; the event carries only the message header. Message: [`WebcastLinkMicFanTicketMethod`](/schema/webcast/webcastlinkmicfanticketmethod/). Not produced by the default mapper; see [unmapped events](/events/#unmapped-events). + +## TikTokLinkMicMethodEvent + +Link-mic signalling between hosts; the event carries only the message header. Message: [`WebcastLinkMicMethod`](/schema/webcast/webcastlinkmicmethod/). Not produced by the default mapper; see [unmapped events](/events/#unmapped-events). + +## TikTokLinkLayerEvent + +A link layer change; the event carries only the message header. Message: [`WebcastLinkLayerMessage`](/schema/webcast/webcastlinklayermessage/). Not produced by the default mapper; see [unmapped events](/events/#unmapped-events). + +## TikTokLinkEvent + +A link message of a type with no dedicated event, and the base class of the events below. Message: [`WebcastLinkMessage`](/schema/webcast/webcastlinkmessage/) with a `LinkMessageType` the mapping has no case for, among them `TYPE_LINKER_APPLY`, `TYPE_LINKER_REPLY`, `TYPE_LINKER_PERMIT`, and `TYPE_LINKER_LEAVE_V2`. + +Key fields, inherited by every subclass: `getExtra()` and `getTransferExtra()`, the message's extra strings, and `getExpireTimestamp()`. A handler registered for `TikTokLinkEvent.class` receives only these generic events, not the subclasses. + +## TikTokLinkAcceptNoticeEvent + +An invitation was accepted. Message type `TYPE_LINKER_ACCEPT_NOTICE`. + +Key fields: `getFromUserId()`, `getFromRoomId()`, and `getToUserId()`. + +## TikTokLinkApplyEvent + +A guest applied to join. Message type `TYPE_LINKER_APPLY`. Not produced by the default mapper, whose switch has no case for this type, so an application arrives as `TikTokLinkEvent`; see [unmapped events](/events/#unmapped-events). The class adds no fields. + +## TikTokLinkCancelEvent + +An invitation or application was cancelled. Message types `TYPE_LINKER_CANCEL_INVITE` and `TYPE_LINKER_CANCEL_APPLY`. + +Key fields: `getFromUserId()`, `getToUserId()`, `getCancelType()`, and `getActionId()`. + +## TikTokLinkCloseEvent + +The link session was closed. Message type `TYPE_LINKER_CLOSE`. The class adds no fields. + +## TikTokLinkCohostListChangeEvent + +The cohost list changed. Message type `TYPE_LINKER_COHOST_LIST_CHANGE`. The class adds no fields. + +## TikTokLinkCreateEvent + +A link session was created. Message type `TYPE_LINKER_CREATE`. + +Key fields: `getHostId()`, the owner; `getRoomId()`, the owner's room, which on this class replaces the header's room id; and `getLinkType()`. + +## TikTokLinkEnterEvent + +Users entered the link session, with the linked users list. Message type `TYPE_LINKER_ENTER`. + +Key fields: `getListUsers()`, a list of `ListUser` with `getUser()`, `getLinkType()`, `getLinkMicId()`, `getLinkStatus()`, `getModifyTime()`, `getLinkerId()`, `getUserPosition()`, `getSilenceStatus()`, and `getRoleType()`; and `getAnchorMultiLiveEnum()`, a [`LinkmicMultiLiveEnum`](/schema/enums/linkmicmultiliveenum/). + +## TikTokLinkInviteEvent + +A host invited another host. Message type `TYPE_LINKER_INVITE`. + +Key fields: `getInviter()`, a `User`, and `getRoomId()`, the inviter's room, which on this class replaces the header's room id. + +## TikTokLinkKickOutEvent + +A guest was removed, with the reason. Message type `TYPE_LINKER_KICK_OUT`. + +Key fields: `getFromUserId()` and `getKickOutReason()`, the nested enum `TikTokLinkKickOutEvent.KickOutReason`: `KICKOUT_REASON_UNKNOWN`, `KICKOUT_REASON_FIRST_FRAME_TIMEOUT`, `KICKOUT_REASON_BY_HOST`, `KICKOUT_REASON_RTC_LOST_CONNECTION`, `KICKOUT_REASON_BY_PUNISH`, `KICKOUT_REASON_BY_ADMIN`, or `KICKOUT_REASON_HOST_REMOVE_ALL_GUESTS`, in the order of the schema's [`KickOutReason`](/schema/enums/kickoutreason/). + +## TikTokLinkLeaveEvent + +A guest left. Message type `TYPE_LINKER_LEAVE`. + +Key fields: `getUserId()`, `getSendLeaveUid()`, `getLeaveReason()`, and `getLinkmicIdStr()`. + +## TikTokLinkLinkedListChangeEvent + +The linked users list changed, first version of the message. Message type `TYPE_LINKER_LINKED_LIST_CHANGE`. The class adds no fields; the second version, `TYPE_LINKER_LINKED_LIST_CHANGE_V2`, carries the lists and becomes `TikTokLinkListChangeEvent`. + +## TikTokLinkListChangeEvent + +The linked, applied, and connecting user lists changed. Message type `TYPE_LINKER_LINKED_LIST_CHANGE_V2`. + +Key fields: `getLinkedUsers()`, `getAppliedUsers()`, and `getConnectingUsers()`, each a list of `ListUser` as on `TikTokLinkEnterEvent`. + +## TikTokLinkMediaChangeEvent + +A guest's microphone or camera was opened or closed. Message type `TYPE_LINKER_MEDIA_CHANGE`. + +Key fields: `getOp()`, a [`GuestMicCameraManageOp`](/schema/enums/guestmiccameramanageop/); `getToUserId()`; `getAnchorId()`; `getRoomId()`, which on this class replaces the header's room id; and `getChangeScene()`, a [`GuestMicCameraChangeScene`](/schema/enums/guestmiccamerachangescene/). + +## TikTokLinkMicIdxUpdateEvent + +A guest's microphone position changed. Message type `TYPE_LINKER_MIC_IDX_UPDATE`. The class adds no fields. + +## TikTokLinkMuteEvent + +A guest was muted or unmuted. Message type `TYPE_LINKER_MUTE`. + +Key fields: `getUserId()`, `getStatus()`, a [`MuteStatus`](/schema/enums/mutestatus/), and `isMuted()`. + +## TikTokLinkRandomMatchEvent + +A random match paired the host with another host. Message type `TYPE_LINKER_MATCH`. + +Key fields: `getUser()`, `getRoomId()` (the matched room, which on this class replaces the header's room id), `getInviteType()`, `getMatchId()`, and `getInnerChannelId()`. + +## TikTokLinkReplyEvent + +A host replied to an invitation. Message type `TYPE_LINKER_REPLY`. Not produced by the default mapper, whose switch has no case for this type, so a reply arrives as `TikTokLinkEvent`; see [unmapped events](/events/#unmapped-events). + +Key fields: `getRoomId()`, `getFromUser()`, and `getToUser()`. + +## TikTokLinkSysKickOutEvent + +The system removed a guest. Message type `TYPE_LINKER_SYS_KICK_OUT`. + +Key fields: `getUserId()` and `getLinkmicIdStr()`. + +## TikTokLinkUpdateUserEvent + +A linked user's state was updated. Message type `TYPE_LINKER_UPDATE_USER`. + +Key fields: `getFromUserId()` and `getToUserId()`. + +## TikTokLinkUpdateUserSettingEvent + +A linked user's settings were updated. Message type `TYPE_LINKER_UPDATE_USER_SETTING`. The class adds no fields. + +## TikTokLinkUserToastEvent + +A toast message about a linked user. Message type `TYPE_LINKMIC_USER_TOAST`. + +Key fields: `getUserId()`; `getRoomId()`, which on this class replaces the header's room id; and `getDisplayText()`, a `Text` with `getKey()`, `getPattern()`, `getValue()` (the pattern with its pieces filled in), and `getTextPieces()`. + +## TikTokLinkWaitListChangeEvent + +The waiting list changed. Message types `TYPE_LINKER_WAITING_LIST_CHANGE` and `TYPE_LINKER_WAITING_LIST_CHANGE_V2`. The class adds no fields. diff --git a/docs/src/content/docs/events/chat-and-audience.md b/docs/src/content/docs/events/chat-and-audience.md new file mode 100644 index 00000000..7ac547d8 --- /dev/null +++ b/docs/src/content/docs/events/chat-and-audience.md @@ -0,0 +1,80 @@ +--- +title: Chat and audience +description: Comments, emotes, joins, likes, follows, shares, questions, barrages, and captions, with the fields each event carries and examples for comments and likes. +sidebar: + order: 2 +--- + +Chat, membership, reactions, and social activity. The sender is `getUser()`, a `User` with `getId()`, `getName()` (the username), `getProfileName()` (the display name), `getPicture()`, `getFollowers()`, `getFollowing()`, `getBadges()`, and the attribute checks `isModerator()`, `isSubscriber()`, `isGiftGiver()`, `isFollower()`, `isLiveHost()`, `isMutualFollowingWithHost()`, `isAdmin()`, `isMuted()`, and `isBlocked()`. Which attributes are set depends on the message: comments and gifts carry a user identity that fills the first six, the other messages fill `isFollower()`, `isSubscriber()`, `isAdmin()`, `isMuted()`, and `isBlocked()` from the user object alone. + +Each section names the Webcast message and links its schema page, which lists every field. Events with a builder method name it; the others are registered with `onEvent(TikTokXEvent.class, handler)`. Replace `isaackogz` with a creator who is live. + +## TikTokCommentEvent + +Triggered every time a new chat comment arrives. Handler: `onComment`. Message: [`WebcastChatMessage`](/schema/webcast/webcastchatmessage/). + +Key fields: `getText()`, the comment; `getUser()`, the sender with the identity attributes; `getUserLanguage()`, the comment's content language; `getMentionedUser()`, the user a reply is addressed to, with id `0` when there is none; `getPictures()`, the images of the emotes in the comment as `Picture` objects whose `getLink()` is the URL; and `isVisibleToSender()`, set when TikTok showed the comment only to its sender. A pinned comment arrives again inside `TikTokRoomPinEvent` on [room and display](/events/room-and-display/#tiktokroompinevent). `TikTokCommentEvent.of(userName, text)` builds one for offline tests. + +```java +TikTokLive.newClient("isaackogz") + .onComment((client, event) -> { + var user = event.getUser(); + var badge = user.isModerator() ? " [mod]" : user.isSubscriber() ? " [sub]" : ""; + System.out.println(user.getProfileName() + badge + ": " + event.getText()); + }) + .buildAndConnect(); +``` + +## TikTokEmoteEvent + +Triggered every time a subscriber sends an emote (sticker). Handler: `onEmote`. Message: [`WebcastEmoteChatMessage`](/schema/webcast/webcastemotechatmessage/). + +Key fields: `getUser()` and `getEmotes()`, a list of `Emote` with `getEmoteId()` and `getPicture()`. Emotes sent inline in a comment arrive as `getPictures()` on `TikTokCommentEvent` instead. + +## TikTokJoinEvent + +A viewer joined the livestream. Handler: `onJoin`. Messages: [`WebcastMemberMessage`](/schema/webcast/webcastmembermessage/) with the action `JOINED` of [`MemberMessageAction`](/schema/enums/membermessageaction/), and [`WebcastSocialMessage`](/schema/webcast/webcastsocialmessage/) whose display text key is `pm_mt_join_message_other_viewer`. + +Key fields: `getUser()` and `getTotalUsers()`, the member count the member message carries, or the room's current viewer count for the social form. Every member message also publishes a `TikTokRoomInfoEvent` with that count. TikTok does not send a join for every viewer, so counting joins does not give the viewer count; read `getRoomInfo().getViewersCount()`. `TikTokJoinEvent.of(userName)` builds one for offline tests. + +## TikTokLikeEvent + +Triggered when a viewer sends likes to the streamer. For streams with many viewers, this event is not always triggered by TikTok. Handler: `onLike`. Messages: [`WebcastLikeMessage`](/schema/webcast/webcastlikemessage/), and [`WebcastSocialMessage`](/schema/webcast/webcastsocialmessage/) whose display text key is `pm_mt_msg_viewer`. + +Key fields: `getUser()`; `getLikes()`, the likes in this update; and `getTotalLikes()`, the room's running total. From a like message both counts come from the message, and the client also updates `getRoomInfo().getLikesCount()` and publishes a `TikTokRoomInfoEvent`. From a social message `getLikes()` is `1` and `getTotalLikes()` is the room's count at that moment. TikTok batches likes, so add `getLikes()` and reconcile with `getTotalLikes()` rather than counting events. `TikTokLikeEvent.of(userName, likes)` builds one for offline tests. + +```java +TikTokLive.newClient("isaackogz") + .onLike((client, event) -> System.out.println(event.getUser().getProfileName() + " sent " + event.getLikes() + " likes, " + event.getTotalLikes() + " in total")) + .buildAndConnect(); +``` + +## TikTokFollowEvent + +Triggers when a user follows the streamer. Based on social event. Handler: `onFollow`. Message: [`WebcastSocialMessage`](/schema/webcast/webcastsocialmessage/) whose display text key is `pm_main_follow_message_viewer_2`. + +Key fields: `getUser()` and `getTotalFollowers()`, the follow count the message carries. `TikTokFollowEvent.of(userName)` builds one for offline tests. + +## TikTokShareEvent + +Triggers when a user shares the stream. Based on social event. Handler: `onShare`. Message: [`WebcastSocialMessage`](/schema/webcast/webcastsocialmessage/) whose display text key is `pm_mt_guidance_share`, or `pm_mt_guidance_viewer_<n>_share`, from which the client reads `n`. + +Key fields: `getUser()` and `getTotalShares()`, `1` for the first key and `n` for the second. + +## TikTokQuestionEvent + +Triggered every time someone asks a new question via the question feature. Handler: `onQuestion`. Message: [`WebcastQuestionNewMessage`](/schema/webcast/webcastquestionnewmessage/). + +Key fields: `getQuestionId()`, `getText()`, `getTime()` (the question's creation time), and `getUser()`. + +## TikTokBarrageEvent + +An animated banner notice, such as a high-grade viewer entering, a fan-level upgrade, or a subscription gift. Message: [`WebcastBarrageMessage`](/schema/webcast/webcastbarragemessage/), whose [`BarrageType`](/schema/enums/barragetype/) says which. + +Key fields: `getEventName()`; `getDuration()`; `getIcon()`, `getBackGround()`, and `getRightIcon()`, the banner's pictures; `getBarrageParam()`, a `UserGradeParam` for a grade entrance notification, a `FansLevelParam` for a fan-level upgrade, a `SubscribeGiftParam` for a subscription gift, or a plain `BarrageParam` for every other type; and `getBarrageMessage()`, the whole message. The three parameter classes expose no getters in this version, so read the user and grade from `getBarrageMessage()`. + +## TikTokCaptionEvent + +Captions of the host's speech. Message: [`WebcastCaptionMessage`](/schema/webcast/webcastcaptionmessage/). + +Key fields: `getCaptionTimeStamp()` and `getContents()`, a list of `CaptionContent` with `getISOLanguage()` and `getText()`, one per language the message carries. diff --git a/docs/src/content/docs/events/control.md b/docs/src/content/docs/events/control.md new file mode 100644 index 00000000..4f4005b9 --- /dev/null +++ b/docs/src/content/docs/events/control.md @@ -0,0 +1,68 @@ +--- +title: Control events +description: The six events the client publishes about its own connection, connecting, pre-connection, connected, reconnecting, disconnected, and error, with their fields and when they fire. +sidebar: + order: 1 +--- + +Control events come from the client rather than from a Webcast message. They mark the connection's lifecycle and carry no message header; all six extend `TikTokLiveClientEvent`. The end of a stream is not a control event: TikTok reports it in a message, and the client publishes `TikTokLiveEndedEvent` without disconnecting; see [stream control](/events/stream-control/). The [connection lifecycle](/getting-started/connection/) page walks through the connect sequence that publishes these events, the retry settings, and the Euler Stream WebSocket path, on which `TikTokPreConnectionEvent` is not published. + +Every control event has a builder method, named below. Replace `isaackogz` with a creator who is live. + +## TikTokConnectingEvent + +The first thing `connect()` does, before any request to TikTok or the signing provider. Handler: `onConnecting`. No message, no fields. + +It is published on both connection paths. On the default path the user data and room data are fetched next; on the Euler Stream WebSocket path the socket is opened directly. + +## TikTokPreConnectionEvent + +The room data is fetched and the connection can still be cancelled. Handler: `onPreConnection`. No message; the fields hold the two HTTP responses `connect()` has made so far. + +Key fields: `getUserData()`, the `LiveUserData.Response` for the creator; `getRoomData()`, the `LiveData.Response` for the room, with `isAgeRestricted()` among its fields; `setCancelConnection(boolean, String)`, which aborts the connection with the given reason; `isCancelConnection()` and `getReason()`, the current values. A cancelled connection throws `TikTokLivePreConnectionException` from `connect()`, which then publishes `TikTokErrorEvent` and `TikTokDisconnectedEvent` like any other failure. The event is published after `getRoomInfo()` has been filled and before the gift list and the signed URL are requested; it is not published with `useEulerstreamWebsocket`. + +## TikTokConnectedEvent + +Triggered when the connection is successfully established. Handler: `onConnected`. No message, no fields. + +The client publishes it from the socket thread once the WebSocket is open, which can be before or after `connect()` returns. On the default path `getRoomInfo()` is already filled; on the Euler Stream WebSocket path it is still empty until the first `TikTokRoomInfoEvent`. + +```java +TikTokLive.newClient("isaackogz") + .onConnected((client, event) -> System.out.println("Connected to room " + client.getRoomInfo().getRoomId())) + .buildAndConnect(); +``` + +## TikTokReconnectingEvent + +A scheduled retry is about to call `connect()` again. Handler: `onReconnecting`. No message, no fields. + +It is published only by the client's own retry, which runs when `retryOnConnectionFailure` is `true` and `connect()` failed because the creator was offline; the wait before each attempt is `retryConnectionTimeout`. A socket that drops after connecting is not retried, so this event never follows a `TikTokDisconnectedEvent` of a live connection. See [client settings](/configuration/client-settings/). + +## TikTokDisconnectedEvent + +Triggered when the connection gets disconnected. Handler: `onDisconnected`. No message. + +Key fields: `getCode()`, the WebSocket close code when TikTok or `disconnect()` closed the socket, or `TikTokDisconnectedEvent.UNKNOWN_CLOSE_CODE` (`-1`) when a connect attempt failed; `isUnknownCloseCode()`, `true` in that second case; and `getReason()`, the close frame's reason, `None` when it carried none, or `Exception: <message>` after a failed connect. The same client can `connect()` again after a disconnect; wait before doing so, since a tight loop runs into the signing provider's rate limit. + +```java +TikTokLive.newClient("isaackogz") + .onDisconnected((client, event) -> { + if (event.isUnknownCloseCode()) { + System.out.println("Connect attempt failed: " + event.getReason()); + } else { + System.out.println("Socket closed with code " + event.getCode() + ": " + event.getReason()); + } + }) + .buildAndConnect(); +``` + +## TikTokErrorEvent + +General error event. You should handle this. Handler: `onError`. No message. + +Key field: `getException()`, a `Throwable`. It is a `TikTokLiveException` or one of its subclasses when `connect()` failed, after which `TikTokDisconnectedEvent` follows; a `TikTokLiveMessageException` when a message could not be mapped or a handler threw while the message was processed, in which case the client carries on with the next message; and a `TikTokEventListenerMethodException` when a listener method threw. The client does not log these exceptions itself, so an `onError` handler that logs the exception is the usual first step in [troubleshooting](/troubleshooting/). + +## TikTokLiveClientEvent + +The base class of the six control events. It has no `@EventMeta` category, no fields, and the client never publishes it on its own; subscribe to the concrete events. The abstract `TikTokEvent` above it is the root of every event and the parameter type of the catch-all `onEvent(handler)`. diff --git a/docs/src/content/docs/events/debugging.md b/docs/src/content/docs/events/debugging.md new file mode 100644 index 00000000..0ff27d3b --- /dev/null +++ b/docs/src/content/docs/events/debugging.md @@ -0,0 +1,49 @@ +--- +title: Debugging +description: The debug events that expose every batch, every message with the event it became, and messages without a mapping, plus the HTTP response event and the generic base classes. +sidebar: + order: 11 +--- + +The four `Debug` events show the traffic behind the message events. For every batch of messages the client processes, from the signing response and from each WebSocket frame, it publishes `TikTokWebsocketResponseEvent`, then for each message in the batch either a `TikTokWebsocketMessageEvent` before each event the message maps to, or a `TikTokWebsocketUnhandledMessageEvent` when no mapping is registered for the message's name. A message `publishMessage` injects goes through the same mapping without a batch event. A message whose mapping throws is published as `TikTokErrorEvent` with a `TikTokLiveMessageException` instead. + +Each debug event has a builder method, named below. Replace `isaackogz` with a creator who is live. + +## TikTokWebsocketResponseEvent + +A batch of messages arrived, before any of them is mapped. Handler: `onWebsocketResponse`. No Webcast message of its own; it wraps the batch. + +Key field: `getResponse()`, the [`ProtoMessageFetchResult`](/schema/webcast/protomessagefetchresult/) with `getMessagesList()`, the batch, and the cursor and timing fields the schema page lists. + +## TikTokWebsocketMessageEvent + +Triggered every time TikTok sends data, with the message, its event, and the mapping time. Handler: `onWebsocketMessage`. No Webcast message of its own; it wraps one. + +Key fields: `getMessage()`, the [`BaseProtoMessage`](/schema/webcast/protomessagefetchresult-baseprotomessage/) with `getMethod()`, the message's name, and `getPayload()`, its protobuf bytes; `getEvent()`, the event the message was mapped to, published right after this one; and `getMetaData()`, whose `getHandlingTime()` is the mapping's duration. A message that maps to two events, such as a like message, produces two of these, each wrapping one event. + +```java +TikTokLive.newClient("isaackogz") + .onWebsocketUnhandledMessage((client, event) -> System.out.println("No mapping for " + event.getMessage().getMethod())) + .onWebsocketMessage((client, event) -> System.out.println(event.getMessage().getMethod() + " -> " + event.getEvent().getClass().getSimpleName() + " in " + event.getMetaData().getHandlingTime().toNanos() + " ns")) + .buildAndConnect(); +``` + +## TikTokWebsocketUnhandledMessageEvent + +A protobuf-encoded Webcast message arrived that no mapping is registered for; you can deserialize the binary payload yourself. Handler: `onWebsocketUnhandledMessage`. No Webcast message of its own; it wraps one. + +Key field: `getMessage()`, the [`BaseProtoMessage`](/schema/webcast/protomessagefetchresult-baseprotomessage/) with `getMethod()` and `getPayload()`; `getData()` returns the same object. The method name tells you which message TikTok sent, and the [schema reference](/schema/) whether the library has a class for it; a custom mapping turns it into an event, as the [catalog](/events/#unmapped-events) shows. This event, not `TikTokErrorEvent`, is where a message type the library does not know ends up, so a handler that logs the method names is the quickest way to see what a room sends. + +## TikTokHttpResponseEvent + +An HTTP request and its response. Handler: `onHttpResponse`. No Webcast message. + +Key fields: `getUrl()`, `getRequest()`, and `getResponse()`, the last two as `HttpData` with `getUrl()`, `getMethod()`, `getHeaders()`, `getParameters()`, `getStatus()`, and `getBody()`. No class in this checkout publishes the event: the handler and `TikTokEventListenerBase.onHttpResponse` exist, but the HTTP client does not call them. It can only reach a handler through your own `publishEvent`. + +## TikTokUnhandledEvent + +The generic base class of the unhandled wrappers, holding the raw message. It has no `@EventMeta` category and one field, `getData()`, typed by the subclass: `TikTokWebsocketUnhandledMessageEvent` above, and `TikTokUnhandledMemberEvent`, `TikTokUnhandledSocialEvent`, and `TikTokUnhandledControlEvent` on [moderation and access](/events/moderation-and-access/). The client never publishes it on its own, and a handler for `TikTokUnhandledEvent.class` receives none of the subclasses. + +## TikTokEmptyEvent + +An event with no fields. It has no `@EventMeta` category and nothing in the library references it; it is a placeholder for your own `publishEvent` calls, for instance to trigger a listener from a test. diff --git a/docs/src/content/docs/events/games-and-commerce.md b/docs/src/content/docs/events/games-and-commerce.md new file mode 100644 index 00000000..1bb6ecb4 --- /dev/null +++ b/docs/src/content/docs/events/games-and-commerce.md @@ -0,0 +1,14 @@ +--- +title: Games and commerce +description: The live shopping event, which exists in the API but needs a custom mapping to be produced. +sidebar: + order: 9 +--- + +One event describes live shopping. The default mapper does not register its message, and the class's own source marks its constructor as not working, so treat it as a starting point for a custom mapping rather than a finished feature. Register it with `onEvent(TikTokShopEvent.class, handler)`. + +## TikTokShopEvent + +A product shown in live shopping. Message: [`WebcastOecLiveShoppingMessage`](/schema/webcast/webcastoecliveshoppingmessage/). Not produced by the default mapper; see [unmapped events](/events/#unmapped-events). + +Key fields, read from the message's shop data: `getTitle()`, `getPrice()` (the price string), `getPicture()`, `getShopUrl()`, and `getShopName()`. diff --git a/docs/src/content/docs/events/gifts-and-goals.md b/docs/src/content/docs/events/gifts-and-goals.md new file mode 100644 index 00000000..82a91ab9 --- /dev/null +++ b/docs/src/content/docs/events/gifts-and-goals.md @@ -0,0 +1,64 @@ +--- +title: Gifts and goals +description: TikTokGiftEvent and TikTokGiftComboEvent with the streak rules, goal updates, envelopes, and treasure chests. +sidebar: + order: 3 +--- + +A sent gift arrives as `TikTokGiftEvent`, and every frame of a streak as `TikTokGiftComboEvent`. The two classes share the gift, sender, recipient, and streak count; the combo event adds the streak state. The other three events on this page describe goals and treasure chests and are not produced by the default mapper. The [gifts guide](/guides/gifts/) covers counting streaked gifts and the gift list. + +The `Gift` on both gift events has `getId()`, `getName()`, `getDiamondCost()`, `getPicture()`, `getProperties()` (a `JsonObject` of extra properties, empty for a gift built from a message), and the helpers `hasDiamondCost(int)` and `hasDiamondCostRange(int, int)`. The client resolves it from `getGiftManager()` by id, then by name, and otherwise builds it from the message and attaches it to the manager; with `fetchGifts` set to `true`, the manager knows the room's gifts before the first event. + +Each section names the Webcast message and links its schema page, which lists every field. Replace `isaackogz` with a creator who is live. + +## TikTokGiftEvent + +Triggered when a user sends a gift that has no combo (most of the expensive gifts), or when a combo has finished. Handler: `onGift`. Message: [`WebcastGiftMessage`](/schema/webcast/webcastgiftmessage/). + +Key fields: `getGift()`; `getUser()`, the sender, with the identity attributes such as `isSubscriber()`; `getToUser()`, the recipient, which is the room's host when the message names none; and `getCombo()`, the message's combo count, the number of gifts in the streak. For a gift whose type is not streakable the event is published at once. For a streakable gift it is published once, when TikTok sends the streak's final frame or, when no final frame arrives, at the first gift message more than five minutes after the streak's last update, so one `TikTokGiftEvent` is one completed streak and `getCombo()` times `getDiamondCost()` is its value. A final frame TikTok repeats within a minute is dropped. `TikTokGiftEvent.of(gift)` and `of(name, id, diamonds)` build one for offline tests. + +```java +TikTokLive.newClient("isaackogz") + .onGift((client, event) -> { + var gift = event.getGift(); + var diamonds = gift.getDiamondCost() * event.getCombo(); + System.out.println(event.getUser().getProfileName() + " sent " + event.getCombo() + " x " + gift.getName() + " (" + diamonds + " diamonds)"); + }) + .buildAndConnect(); +``` + +## TikTokGiftComboEvent + +Triggered every time a gift is sent, with the streak's state. Handler: `onGiftCombo`. Message: [`WebcastGiftMessage`](/schema/webcast/webcastgiftmessage/). + +Key fields: the four of `TikTokGiftEvent`, and `getComboState()`, a `GiftComboStateType`: `Begin` on the first frame of a streak, `Active` on each further frame while the viewer holds the button, and `Finished` on the final frame, on a non-streakable gift, and when a streak times out. A `Finished` combo event is followed by the `TikTokGiftEvent` for the same gift, so count gifts in one handler or the other. Although the class extends `TikTokGiftEvent`, `onGift` is not called for it: handlers match the exact class. `TikTokGiftComboEvent.of(gift, combo, state)` builds one for offline tests. + +```java +TikTokLive.newClient("isaackogz") + .onGiftCombo((client, event) -> { + switch (event.getComboState()) { + case Begin -> System.out.println(event.getUser().getProfileName() + " started a " + event.getGift().getName() + " streak"); + case Active -> System.out.println("Streak at " + event.getCombo()); + case Finished -> System.out.println("Streak ended at " + event.getCombo()); + } + }) + .buildAndConnect(); +``` + +## TikTokGoalUpdateEvent + +A goal's progress was updated. Message: [`WebcastGoalUpdateMessage`](/schema/webcast/webcastgoalupdatemessage/). Not produced by the default mapper; see [unmapped events](/events/#unmapped-events). + +Key fields: `getGoalId()`, `getDescription()`, `getPicture()` (the contributor's avatar), and `getUsers()`, the goal's contributors as `User` objects with id, display id, and avatar. + +## TikTokEnvelopeEvent + +An envelope, the sender of a treasure chest. Message: [`WebcastEnvelopeMessage`](/schema/webcast/webcastenvelopemessage/). Not produced by the default mapper; see [unmapped events](/events/#unmapped-events). + +Key field: `getUser()`, built from the envelope's sender name and avatar; its id is `0`. + +## TikTokChestEvent + +A treasure chest with its diamond total and participant count. Message: [`WebcastEnvelopeMessage`](/schema/webcast/webcastenvelopemessage/). Not produced by the default mapper; see [unmapped events](/events/#unmapped-events). + +Key fields: `getChest()`, a `Chest` with `getTotalDiamonds()` and `getTotalUsers()`; `getUser()`, the sender taken from the message's display text; and `getOpenedAt()`, a `Date` from the envelope's unpack time. The client has a handler for it, `TikTokCommonEventHandler.handleEnvelop`, which publishes the event only for an envelope whose [`EnvelopeDisplay`](/schema/enums/envelopedisplay/) is `ENVELOPE_DISPLAY_NEW`, but the factory does not register it; a custom mapping can call it. diff --git a/docs/src/content/docs/events/index.md b/docs/src/content/docs/events/index.md index ba12cf06..e8bc4ae7 100644 --- a/docs/src/content/docs/events/index.md +++ b/docs/src/content/docs/events/index.md @@ -1,6 +1,216 @@ --- -title: Events -description: Catalog of the events TikTokLiveJava emits. +title: Event catalog +description: Every event TikTokLiveJava emits, its category, what it means, and the page that documents it, with both ways to subscribe. +sidebar: + order: 0 --- -This page is written in a later task of the documentation plan. +A handler is registered in one of two forms. The builder's `on<Event>` methods take an `EventConsumer`, a lambda that receives the `LiveClient` and the event; `onEvent` does the same for any event class, including the ones without a dedicated method, as `onEvent(TikTokXEvent.class, handler)`, and `onEvent(handler)` receives every event. The other form is a listener: any object whose methods carry `@TikTokEventObserver` and take a parameter that extends `TikTokEvent`, added with `addListener` on the builder or on `getListenersManager()`; the `LiveClient` is an optional second parameter in either position. See [listeners](/guides/listeners/) for priorities and asynchronous methods. + +```java +import io.github.jwdeveloper.tiktok.api.annotations.TikTokEventObserver; +import io.github.jwdeveloper.tiktok.api.data.events.social.TikTokLikeEvent; +import io.github.jwdeveloper.tiktok.api.data.events.social.TikTokShareEvent; +import io.github.jwdeveloper.tiktok.api.live.LiveClient; +import io.github.jwdeveloper.tiktok.client.TikTokLive; + +public class EventForms { + public static void main(String[] args) { + TikTokLive.newClient("isaackogz") + .onComment((client, event) -> System.out.println(event.getUser().getProfileName() + ": " + event.getText())) + .onEvent(TikTokShareEvent.class, (client, event) -> System.out.println(event.getUser().getProfileName() + " shared the stream")) + .addListener(new ReactionListener()) + .buildAndConnect(); + } + + public static class ReactionListener { + @TikTokEventObserver + public void onLike(LiveClient client, TikTokLikeEvent event) { + client.getLogger().info(event.getUser().getProfileName() + " sent " + event.getLikes() + " likes"); + } + } +} +``` + +Handlers are matched by the event's exact class, plus the `TikTokEvent` catch-all. A handler for a base class does not receive its subclasses: `onGift` is not called for a `TikTokGiftComboEvent` although that class extends `TikTokGiftEvent`, and `onEvent(TikTokLinkEvent.class, ...)` receives only the generic link event, not `TikTokLinkInviteEvent`. An exception thrown by a handler while a message is processed is published as `TikTokErrorEvent`, and the client carries on with the next message. Register handlers before connecting: the signing response already carries messages, and they are dispatched before the socket opens. + +This checkout defines 78 event classes under `io.github.jwdeveloper.tiktok.api.data.events`. The `@EventMeta` annotation on each gives its category: 6 `Control` events, which the client publishes about its own connection; 64 `Message` events, mapped from TikTok's Webcast protobuf messages; and 4 `Debug` events, which expose the raw traffic. Four classes carry no category: `TikTokLiveClientEvent` and `TikTokHeaderEvent` are the base classes of the control and message events, `TikTokUnhandledEvent` is the base of the unhandled wrappers, and `TikTokEmptyEvent` is a placeholder. `TikTokEvent` itself is abstract and is never published; it is the type `onEvent(handler)` receives, and every event inherits its `toJson()`. + +A message event extends `TikTokHeaderEvent`, so it carries `getMessageId()`, `getRoomId()`, and `getTimeStamp()` from the message's common header, and each grouped page links the message's [schema page](/schema/) for the full field list. Nested objects such as `getUser()` and `getGift()` are mapped from the message and are not `null`; a user TikTok did not send has id `0` and an empty name. Some messages produce more than one event: a like message publishes `TikTokLikeEvent` and then `TikTokRoomInfoEvent`, and a finished gift streak publishes `TikTokGiftComboEvent` and then `TikTokGiftEvent`. Every mapped event is preceded by a `TikTokWebsocketMessageEvent` that wraps it, and every batch by a `TikTokWebsocketResponseEvent`; see [debugging](/events/debugging/). + +## Unmapped events + +The client maps a message only when `MessagesMapperFactory` registers a handler for its name; any other message is published as `TikTokWebsocketUnhandledMessageEvent` with its raw payload. Fifteen event classes exist for messages the default mapper does not register, or for a case its switch does not cover, so the default client never produces them: `TikTokChestEvent`, `TikTokDetectEvent`, `TikTokEnvelopeEvent`, `TikTokGoalUpdateEvent`, `TikTokIMDeleteEvent`, `TikTokInRoomBannerEvent`, `TikTokLinkApplyEvent`, `TikTokLinkLayerEvent`, `TikTokLinkMicFanTicketEvent`, `TikTokLinkMicMethodEvent`, `TikTokLinkReplyEvent`, `TikTokRankTextEvent`, `TikTokRankUpdateEvent`, `TikTokShopEvent`, and `TikTokSubNotifyEvent`. Each section below says so where it applies. Two more are never published by the client for another reason: `TikTokHttpResponseEvent` has a handler but no publisher in this checkout, and `TikTokEmptyEvent` is referenced nowhere. + +To produce one of them, register a mapping with `mappings` on the builder. The mapping receives the message bytes, deserialises them with the helper, and returns a `MappingResult` holding the message and the events to publish. The protobuf classes live in `io.github.jwdeveloper.tiktok.api.messages.webcast`. + +```java +import io.github.jwdeveloper.tiktok.api.messages.webcast.WebcastRankTextMessage; + +TikTokLive.newClient("isaackogz") + .mappings(mapper -> mapper.forMessage(WebcastRankTextMessage.class, (bytes, messageName, helper) -> { + var message = helper.bytesToWebcastObject(bytes, WebcastRankTextMessage.class); + return MappingResult.of(message, new TikTokRankTextEvent(message)); + })) + .onEvent(TikTokRankTextEvent.class, (client, event) -> System.out.println(event.getLabel())) + .buildAndConnect(); +``` + +A mapping registered this way replaces the default one for that message, so a custom `WebcastLinkMessage` mapping must reproduce the cases it still wants. + +## Find an event + +### Control + +The client's own connection lifecycle. Details: [Control events](/events/control/). + +| Event | Type | Description | +| --- | --- | --- | +| `TikTokConnectingEvent` | Control | The first thing `connect()` does, before any request | +| `TikTokPreConnectionEvent` | Control | The room data is fetched and the connection can still be cancelled | +| `TikTokConnectedEvent` | Control | Triggered when the connection is successfully established | +| `TikTokReconnectingEvent` | Control | A scheduled retry is about to call `connect()` again | +| `TikTokDisconnectedEvent` | Control | Triggered when the connection gets disconnected | +| `TikTokErrorEvent` | Control | General error event; you should handle this | +| `TikTokLiveClientEvent` | none | Base class of the control events, never published on its own | + +### Chat and audience + +Comments, emotes, joins, likes, follows, shares, questions, barrages, and captions. Details: [Chat and audience](/events/chat-and-audience/). + +| Event | Type | Description | +| --- | --- | --- | +| `TikTokCommentEvent` | Message | Triggered every time a new chat comment arrives | +| `TikTokEmoteEvent` | Message | Triggered every time a subscriber sends an emote (sticker) | +| `TikTokJoinEvent` | Message | A viewer joined the livestream | +| `TikTokLikeEvent` | Message | Triggered when a viewer sends likes to the streamer | +| `TikTokFollowEvent` | Message | Triggers when a user follows the streamer | +| `TikTokShareEvent` | Message | Triggers when a user shares the stream | +| `TikTokQuestionEvent` | Message | Triggered every time someone asks a new question via the question feature | +| `TikTokBarrageEvent` | Message | An animated banner notice, such as a high-grade viewer entering, a fan-level upgrade, or a subscription gift | +| `TikTokCaptionEvent` | Message | Captions of the host's speech | + +### Gifts and goals + +Gifts, streaks, goals, envelopes, and treasure chests. Details: [Gifts and goals](/events/gifts-and-goals/). + +| Event | Type | Description | +| --- | --- | --- | +| `TikTokGiftEvent` | Message | Triggered when a user sends a gift that has no combo, or when a combo has finished | +| `TikTokGiftComboEvent` | Message | Triggered every time a gift is sent, with the streak's state | +| `TikTokGoalUpdateEvent` | Message | A goal's progress was updated ([unmapped](/events/#unmapped-events)) | +| `TikTokEnvelopeEvent` | Message | An envelope, the sender of a treasure chest ([unmapped](/events/#unmapped-events)) | +| `TikTokChestEvent` | Message | A treasure chest with its diamond total and participant count ([unmapped](/events/#unmapped-events)) | + +### Battles and cohosting + +Battles, battle points, and link-mic (cohost and multi-guest) signalling. Details: [Battles and cohosting](/events/battles-and-cohosting/). + +| Event | Type | Description | +| --- | --- | --- | +| `TikTokLinkMicBattleEvent` | Message | A battle started or finished, with its teams, hosts, and final scores | +| `TikTokLinkMicArmiesEvent` | Message | A battle's points were updated, per host or team with the top contributors | +| `TikTokLinkMicBattleItemCard` | Message | A battle Power-Up card notice; carries only the message header | +| `TikTokLinkMicFanTicketEvent` | Message | A fan-ticket notice of a link-mic session; carries only the header ([unmapped](/events/#unmapped-events)) | +| `TikTokLinkMicMethodEvent` | Message | Link-mic signalling between hosts; carries only the header ([unmapped](/events/#unmapped-events)) | +| `TikTokLinkLayerEvent` | Message | A link layer change; carries only the header ([unmapped](/events/#unmapped-events)) | +| `TikTokLinkEvent` | Message | A link message of a type with no dedicated event, and the base class of the ones below | +| `TikTokLinkAcceptNoticeEvent` | Message | An invitation was accepted | +| `TikTokLinkApplyEvent` | Message | A guest applied to join ([unmapped](/events/#unmapped-events)) | +| `TikTokLinkCancelEvent` | Message | An invitation or application was cancelled | +| `TikTokLinkCloseEvent` | Message | The link session was closed | +| `TikTokLinkCohostListChangeEvent` | Message | The cohost list changed | +| `TikTokLinkCreateEvent` | Message | A link session was created | +| `TikTokLinkEnterEvent` | Message | Users entered the link session, with the linked users list | +| `TikTokLinkInviteEvent` | Message | A host invited another host | +| `TikTokLinkKickOutEvent` | Message | A guest was removed, with the reason | +| `TikTokLinkLeaveEvent` | Message | A guest left | +| `TikTokLinkLinkedListChangeEvent` | Message | The linked users list changed (first version of the message) | +| `TikTokLinkListChangeEvent` | Message | The linked, applied, and connecting user lists changed | +| `TikTokLinkMediaChangeEvent` | Message | A guest's microphone or camera was opened or closed | +| `TikTokLinkMicIdxUpdateEvent` | Message | A guest's microphone position changed | +| `TikTokLinkMuteEvent` | Message | A guest was muted or unmuted | +| `TikTokLinkRandomMatchEvent` | Message | A random match paired the host with another host | +| `TikTokLinkReplyEvent` | Message | A host replied to an invitation ([unmapped](/events/#unmapped-events)) | +| `TikTokLinkSysKickOutEvent` | Message | The system removed a guest | +| `TikTokLinkUpdateUserEvent` | Message | A linked user's state was updated | +| `TikTokLinkUpdateUserSettingEvent` | Message | A linked user's settings were updated | +| `TikTokLinkUserToastEvent` | Message | A toast message about a linked user | +| `TikTokLinkWaitListChangeEvent` | Message | The waiting list changed | + +### Subscriptions and fans + +Subscriptions and subscription notices. Details: [Subscriptions and fans](/events/subscriptions-and-fans/). + +| Event | Type | Description | +| --- | --- | --- | +| `TikTokSubscribeEvent` | Message | Triggers when a user creates a subscription | +| `TikTokSubNotifyEvent` | Message | A subscription notice with its user ([unmapped](/events/#unmapped-events)) | + +### Room and display + +Room information, pinned messages, banners, polls, and the message header. Details: [Room and display](/events/room-and-display/). + +| Event | Type | Description | +| --- | --- | --- | +| `TikTokRoomInfoEvent` | Message | Triggered when `LiveRoomInfo` got updated, such as likes, viewers, or ranking | +| `TikTokRoomPinEvent` | Message | A comment was pinned | +| `TikTokInRoomBannerEvent` | Message | An in-room banner, as the message's extra data ([unmapped](/events/#unmapped-events)) | +| `TikTokHeaderEvent` | none | Base class of the message events: message id, room id, and timestamp | +| `TikTokPollEvent` | Message | A poll message of a type with no dedicated event, and the base class of the three below | +| `TikTokPollStartEvent` | Message | A poll started, with its title and options | +| `TikTokPollUpdateEvent` | Message | A poll's vote counts were updated | +| `TikTokPollEndEvent` | Message | A poll ended, with the final options | + +### Rankings + +Rank text and rank updates. Details: [Rankings](/events/rankings/). + +| Event | Type | Description | +| --- | --- | --- | +| `TikTokRankTextEvent` | Message | A rank notice's text, such as a viewer entering the top contributors ([unmapped](/events/#unmapped-events)) | +| `TikTokRankUpdateEvent` | Message | The room's rank or hourly rank was updated; carries only the header ([unmapped](/events/#unmapped-events)) | + +### Moderation and access + +Deleted messages, unauthorized members, detection probes, and the fallbacks for member, social, and control messages. Details: [Moderation and access](/events/moderation-and-access/). + +| Event | Type | Description | +| --- | --- | --- | +| `TikTokIMDeleteEvent` | Message | Messages or users' messages were deleted ([unmapped](/events/#unmapped-events)) | +| `TikTokUnauthorizedMemberEvent` | Message | A viewer without access entered, with a nickname only ([unmapped](/events/#unmapped-events)) | +| `TikTokDetectEvent` | Message | A message-detection probe with its region ([unmapped](/events/#unmapped-events)) | +| `TikTokUnhandledMemberEvent` | Message | A member message with an action other than joined or subscribed | +| `TikTokUnhandledSocialEvent` | Message | A social message that is not a like, follow, share, or join | +| `TikTokUnhandledControlEvent` | Message | A control message with an action other than pause, unpause, end, or suspend | + +### Games and commerce + +Live shopping. Details: [Games and commerce](/events/games-and-commerce/). + +| Event | Type | Description | +| --- | --- | --- | +| `TikTokShopEvent` | Message | A product shown in live shopping ([unmapped](/events/#unmapped-events)) | + +### Stream control + +Stream pause, resume, end, and suspension. Details: [Stream control](/events/stream-control/). + +| Event | Type | Description | +| --- | --- | --- | +| `TikTokLiveEndedEvent` | Message | Triggered when the live stream gets terminated by the host, or suspended by TikTok | +| `TikTokLivePausedEvent` | Message | The host paused the stream | +| `TikTokLiveUnpausedEvent` | Message | The host resumed a paused stream | + +### Debugging + +The raw traffic, the mapping of each message, and the generic base classes. Details: [Debugging](/events/debugging/). + +| Event | Type | Description | +| --- | --- | --- | +| `TikTokWebsocketResponseEvent` | Debug | A batch of messages arrived, before any of them is mapped | +| `TikTokWebsocketMessageEvent` | Debug | Triggered every time TikTok sends data, with the message, its event, and the mapping time | +| `TikTokWebsocketUnhandledMessageEvent` | Debug | A protobuf-encoded Webcast message arrived that no mapping is registered for | +| `TikTokHttpResponseEvent` | Debug | An HTTP request and its response; not published in this checkout | +| `TikTokUnhandledEvent` | none | Generic base class of the unhandled wrappers, holding the raw message | +| `TikTokEmptyEvent` | none | An event with no fields, for your own `publishEvent` calls | diff --git a/docs/src/content/docs/events/moderation-and-access.md b/docs/src/content/docs/events/moderation-and-access.md new file mode 100644 index 00000000..e53869b1 --- /dev/null +++ b/docs/src/content/docs/events/moderation-and-access.md @@ -0,0 +1,52 @@ +--- +title: Moderation and access +description: Deleted messages, unauthorized members, detection probes, and the three fallback events for member, social, and control messages the client does not recognise. +sidebar: + order: 8 +--- + +Three events on this page describe moderation and access and are not produced by the default mapper. The other three are fallbacks: the member, social, and control messages each map to several events by their action or display text, and a value the client does not recognise becomes an unhandled wrapper holding the whole message. Each wrapper extends `TikTokUnhandledEvent` and exposes the message as `getData()`, so a new TikTok action can be inspected without a custom mapping. + +Each section names the Webcast message and links its schema page. Register these events with `onEvent(TikTokXEvent.class, handler)`. Replace `isaackogz` with a creator who is live. + +## TikTokIMDeleteEvent + +Messages or users' messages were deleted. Message: [`WebcastImDeleteMessage`](/schema/webcast/webcastimdeletemessage/). Not produced by the default mapper; see [unmapped events](/events/#unmapped-events). + +Key fields: `getMsgIds()`, the ids of the deleted messages, and `getUserIds()`, the users whose messages were deleted; both are lists of `Long`. + +## TikTokUnauthorizedMemberEvent + +A viewer without access entered, with a nickname only. Message: [`WebcastUnauthorizedMemberMessage`](/schema/webcast/webcastunauthorizedmembermessage/). Not produced by the default mapper; see [unmapped events](/events/#unmapped-events). + +Key field: `getUserNickName()`. + +## TikTokDetectEvent + +A message-detection probe with its region. Message: [`WebcastMsgDetectMessage`](/schema/webcast/webcastmsgdetectmessage/). Not produced by the default mapper; see [unmapped events](/events/#unmapped-events). + +Key field: `getLanguage()`, which holds the message's from-region value. + +## TikTokUnhandledMemberEvent + +A member message with an action other than joined or subscribed. Message: [`WebcastMemberMessage`](/schema/webcast/webcastmembermessage/) whose [`MemberMessageAction`](/schema/enums/membermessageaction/) is neither `JOINED`, which becomes `TikTokJoinEvent`, nor `SUBSCRIBED`, which becomes `TikTokSubscribeEvent`. + +Key field: `getData()`, the `WebcastMemberMessage`. The client still publishes a `TikTokRoomInfoEvent` with the message's member count. + +## TikTokUnhandledSocialEvent + +A social message that is not a like, follow, share, or join. Message: [`WebcastSocialMessage`](/schema/webcast/webcastsocialmessage/) whose display text key is none of `pm_mt_msg_viewer`, `pm_main_follow_message_viewer_2`, `pm_mt_guidance_share`, `pm_mt_join_message_other_viewer`, or the `pm_mt_guidance_viewer_<n>_share` pattern. + +Key field: `getData()`, the `WebcastSocialMessage`; its common display text carries the key. + +## TikTokUnhandledControlEvent + +A control message with an action other than pause, unpause, end, or suspend. Message: [`WebcastControlMessage`](/schema/webcast/webcastcontrolmessage/) whose [`ControlAction`](/schema/enums/controlaction/) is not one of the four that [stream control](/events/stream-control/) maps. + +Key field: `getData()`, the `WebcastControlMessage`. + +```java +TikTokLive.newClient("isaackogz") + .onEvent(TikTokUnhandledControlEvent.class, (client, event) -> System.out.println("Control action " + event.getData().getAction())) + .buildAndConnect(); +``` diff --git a/docs/src/content/docs/events/rankings.md b/docs/src/content/docs/events/rankings.md new file mode 100644 index 00000000..cf78d80e --- /dev/null +++ b/docs/src/content/docs/events/rankings.md @@ -0,0 +1,20 @@ +--- +title: Rankings +description: Rank text and rank update events, which exist in the API but need a custom mapping to be produced. +sidebar: + order: 7 +--- + +Ranking messages describe the room's position on TikTok's leaderboards and a viewer's position among the room's contributors. The default mapper registers neither message, so both events below reach your handlers only through a custom mapping; the [catalog](/events/#unmapped-events) shows one for `TikTokRankTextEvent`. The room's own contributor ranking arrives without a mapping, as `getUsersRanking()` on `TikTokRoomInfoEvent`; see [room and display](/events/room-and-display/#tiktokroominfoevent). + +Each section names the Webcast message and links its schema page. Register these events with `onEvent(TikTokXEvent.class, handler)`. + +## TikTokRankTextEvent + +A rank notice's text, such as a viewer entering the top contributors. Message: [`WebcastRankTextMessage`](/schema/webcast/webcastranktextmessage/). Not produced by the default mapper; see [unmapped events](/events/#unmapped-events). + +Key fields: `getEventType()`, the key of the message's self-get-badge text, and `getLabel()`, that text's default pattern, with placeholders unfilled. + +## TikTokRankUpdateEvent + +The room's rank or hourly rank was updated; the event carries only the message header. Messages: [`WebcastRankUpdateMessage`](/schema/webcast/webcastrankupdatemessage/) and [`WebcastHourlyRankMessage`](/schema/webcast/webcasthourlyrankmessage/), with one constructor for each. Not produced by the default mapper; see [unmapped events](/events/#unmapped-events). diff --git a/docs/src/content/docs/events/room-and-display.md b/docs/src/content/docs/events/room-and-display.md new file mode 100644 index 00000000..be0ae9aa --- /dev/null +++ b/docs/src/content/docs/events/room-and-display.md @@ -0,0 +1,67 @@ +--- +title: Room and display +description: Room information updates, pinned comments, in-room banners, the message header every message event carries, and polls. +sidebar: + order: 6 +--- + +Room information is kept in one `LiveRoomInfo` that the client updates in place and republishes; the other events here describe what is shown in the room. `TikTokHeaderEvent`, the base class of every message event, is documented here as well. + +Each section names the Webcast message and links its schema page. Events with a builder method name it; the others are registered with `onEvent(TikTokXEvent.class, handler)`. Replace `isaackogz` with a creator who is live. + +## TikTokRoomInfoEvent + +Triggered when `LiveRoomInfo` got updated, such as likes, viewers, or ranking. Handler: `onRoomInfo`. Messages: [`WebcastRoomUserSeqMessage`](/schema/webcast/webcastroomuserseqmessage/) for the viewer counts and ranking, [`WebcastLiveIntroMessage`](/schema/webcast/webcastliveintromessage/) for the host and language, [`WebcastMemberMessage`](/schema/webcast/webcastmembermessage/) for the viewer count, and [`WebcastLikeMessage`](/schema/webcast/webcastlikemessage/) for the like count. The client also publishes it once at the end of `connect()` on the default path. + +Key field: `getRoomInfo()`, the client's `LiveRoomInfo`, the same object `getRoomInfo()` returns, with `getRoomId()`, `getHostName()`, `getTitle()`, `getHost()`, `getStartTime()`, `isAgeRestricted()`, `getViewersCount()`, `getTotalViewersCount()`, `getLikesCount()`, `getLanguage()`, `getConnectionState()`, and `getUsersRanking()`, a list of `RankingUser` with `getRank()`, `getUser()`, and `getScore()`, sorted by score. The event extends `TikTokEvent` directly and has no message header. The [connection lifecycle](/getting-started/connection/#room-information) page describes which fields are filled before connecting. + +## TikTokRoomPinEvent + +A comment was pinned. Message: [`WebcastRoomPinMessage`](/schema/webcast/webcastroompinmessage/). + +Key fields: `getPinnedMessage()`, a `TikTokCommentEvent` built from the pinned chat message, with its `getUser()` and `getText()`, and `getTimestamp()`, the pin time. + +```java +TikTokLive.newClient("isaackogz") + .onEvent(TikTokRoomPinEvent.class, (client, event) -> { + var pinned = event.getPinnedMessage(); + System.out.println("Pinned: " + pinned.getUser().getProfileName() + ": " + pinned.getText()); + }) + .buildAndConnect(); +``` + +## TikTokInRoomBannerEvent + +An in-room banner, as the message's extra data. Message: [`WebcastInRoomBannerMessage`](/schema/webcast/webcastinroombannermessage/). Not produced by the default mapper; see [unmapped events](/events/#unmapped-events). + +Key field: `getJson()`, the message's extra map rendered with `toString()`, not JSON despite the name. + +## TikTokHeaderEvent + +The base class of the message events: message id, room id, and timestamp. It is mapped from the [`CommonMessageData`](/schema/data/commonmessagedata/) header every Webcast message carries and has no `@EventMeta` category; the client never publishes it on its own. + +Key fields: `getMessageId()`, `getRoomId()`, and `getTimeStamp()`, the message's creation time. A few link events declare their own `getRoomId()` for the room the message is about; the pages note which. + +## TikTokPollEvent + +A poll message of a type with no dedicated event, and the base class of the three below. Message: [`WebcastPollMessage`](/schema/webcast/webcastpollmessage/). + +Key field: `getPollId()`. The client picks the subclass from the message's [`MessageType`](/schema/enums/messagetype/): `MESSAGETYPE_SUBSUCCESS` becomes `TikTokPollStartEvent`, `MESSAGETYPE_ANCHORREMINDER` becomes `TikTokPollEndEvent`, `MESSAGETYPE_ENTERROOMEXPIRESOON` becomes `TikTokPollUpdateEvent`, and anything else this event. The library's source marks that mapping as unverified. A handler registered for `TikTokPollEvent.class` receives only the generic event. + +## TikTokPollStartEvent + +A poll started, with its title and options. Message: [`WebcastPollMessage`](/schema/webcast/webcastpollmessage/). + +Key fields: `getPollId()`; `getTitle()`; `getOperator()`, the user who started it; and `getOptions()`, a list of `PollOption` with `getOptionId()`, `getContent()`, `getVotes()`, and `getUsers()`, the voters. + +## TikTokPollUpdateEvent + +A poll's vote counts were updated. Message: [`WebcastPollMessage`](/schema/webcast/webcastpollmessage/). + +Key fields: `getPollId()` and `getOptions()`, the options with their current votes. + +## TikTokPollEndEvent + +A poll ended, with the final options. Message: [`WebcastPollMessage`](/schema/webcast/webcastpollmessage/). + +Key fields: `getPollId()`, `getOperator()`, and `getOptions()`. diff --git a/docs/src/content/docs/events/stream-control.md b/docs/src/content/docs/events/stream-control.md new file mode 100644 index 00000000..6bb63aad --- /dev/null +++ b/docs/src/content/docs/events/stream-control.md @@ -0,0 +1,35 @@ +--- +title: Stream control +description: The three events TikTok's control message becomes, live ended, paused, and unpaused, and what the client does when the stream ends. +sidebar: + order: 10 +--- + +TikTok reports the stream's state in [`WebcastControlMessage`](/schema/webcast/webcastcontrolmessage/), and the client maps its [`ControlAction`](/schema/enums/controlaction/) to one of three events, or to `TikTokUnhandledControlEvent` on [moderation and access](/events/moderation-and-access/#tiktokunhandledcontrolevent) for any other action. None of the three carries a field; they extend `TikTokEvent` directly and have no message header. They are message events, not control events: the client's own connection events are on [control events](/events/control/). + +Each event has a builder method, named below. Replace `isaackogz` with a creator who is live. + +## TikTokLiveEndedEvent + +Triggered when the live stream gets terminated by the host, or suspended by TikTok. Handler: `onLiveEnded`. Message: [`WebcastControlMessage`](/schema/webcast/webcastcontrolmessage/) with the action `STREAM_ENDED` or `STREAM_SUSPENDED`. + +The client does not disconnect by itself when the stream ends; `TikTokDisconnectedEvent` follows only when the socket closes. Call `disconnect()` from the handler when the connection should close with the stream. + +```java +TikTokLive.newClient("isaackogz") + .onLivePaused((client, event) -> System.out.println("Paused")) + .onLiveUnpaused((client, event) -> System.out.println("Resumed")) + .onLiveEnded((client, event) -> { + System.out.println("Stream ended"); + client.disconnect(); + }) + .buildAndConnect(); +``` + +## TikTokLivePausedEvent + +The host paused the stream. Handler: `onLivePaused`. Message: [`WebcastControlMessage`](/schema/webcast/webcastcontrolmessage/) with the action `STREAM_PAUSED`. The connection stays open while the stream is paused. + +## TikTokLiveUnpausedEvent + +The host resumed a paused stream. Handler: `onLiveUnpaused`. Message: [`WebcastControlMessage`](/schema/webcast/webcastcontrolmessage/) with the action `STREAM_UNPAUSED`. diff --git a/docs/src/content/docs/events/subscriptions-and-fans.md b/docs/src/content/docs/events/subscriptions-and-fans.md new file mode 100644 index 00000000..4558416b --- /dev/null +++ b/docs/src/content/docs/events/subscriptions-and-fans.md @@ -0,0 +1,28 @@ +--- +title: Subscriptions and fans +description: TikTokSubscribeEvent from both messages that carry a subscription, and the unmapped TikTokSubNotifyEvent. +sidebar: + order: 5 +--- + +A subscription reaches the client in two messages, and both become `TikTokSubscribeEvent`. Emotes, which only subscribers can send, are on [chat and audience](/events/chat-and-audience/#tiktokemoteevent); a subscription gift shown as a banner is a `TikTokBarrageEvent` on the same page. + +Each section names the Webcast message and links its schema page. Replace `isaackogz` with a creator who is live. + +## TikTokSubscribeEvent + +Triggers when a user creates a subscription. Handler: `onSubscribe`. Messages: [`WebcastSubNotifyMessage`](/schema/webcast/webcastsubnotifymessage/), and [`WebcastMemberMessage`](/schema/webcast/webcastmembermessage/) with the action `SUBSCRIBED` of [`MemberMessageAction`](/schema/enums/membermessageaction/). + +Key field: `getUser()`, with the `Subscriber` attribute added so that `isSubscriber()` is `true`. The member-message form also publishes a `TikTokRoomInfoEvent` with the member count. `TikTokSubscribeEvent.of(userName)` builds one for offline tests. + +```java +TikTokLive.newClient("isaackogz") + .onSubscribe((client, event) -> System.out.println(event.getUser().getProfileName() + " subscribed")) + .buildAndConnect(); +``` + +## TikTokSubNotifyEvent + +A subscription notice with its user. Message: [`WebcastSubNotifyMessage`](/schema/webcast/webcastsubnotifymessage/). Not produced by the default mapper, which maps that message to `TikTokSubscribeEvent`; see [unmapped events](/events/#unmapped-events). + +Key field: `getUser()`, without the `Subscriber` attribute that `TikTokSubscribeEvent` adds. From 17d0748843f66d0047c8f2d2e852aa3b7e0f3b9c Mon Sep 17 00:00:00 2001 From: isaackogan <isaacikogan@gmail.com> Date: Tue, 20 Oct 2026 17:15:17 -0400 Subject: [PATCH 09/31] docs: add the sixteenth unmapped event to the catalog Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- docs/src/content/docs/events/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/content/docs/events/index.md b/docs/src/content/docs/events/index.md index e8bc4ae7..34ac63ca 100644 --- a/docs/src/content/docs/events/index.md +++ b/docs/src/content/docs/events/index.md @@ -40,7 +40,7 @@ A message event extends `TikTokHeaderEvent`, so it carries `getMessageId()`, `ge ## Unmapped events -The client maps a message only when `MessagesMapperFactory` registers a handler for its name; any other message is published as `TikTokWebsocketUnhandledMessageEvent` with its raw payload. Fifteen event classes exist for messages the default mapper does not register, or for a case its switch does not cover, so the default client never produces them: `TikTokChestEvent`, `TikTokDetectEvent`, `TikTokEnvelopeEvent`, `TikTokGoalUpdateEvent`, `TikTokIMDeleteEvent`, `TikTokInRoomBannerEvent`, `TikTokLinkApplyEvent`, `TikTokLinkLayerEvent`, `TikTokLinkMicFanTicketEvent`, `TikTokLinkMicMethodEvent`, `TikTokLinkReplyEvent`, `TikTokRankTextEvent`, `TikTokRankUpdateEvent`, `TikTokShopEvent`, and `TikTokSubNotifyEvent`. Each section below says so where it applies. Two more are never published by the client for another reason: `TikTokHttpResponseEvent` has a handler but no publisher in this checkout, and `TikTokEmptyEvent` is referenced nowhere. +The client maps a message only when `MessagesMapperFactory` registers a handler for its name; any other message is published as `TikTokWebsocketUnhandledMessageEvent` with its raw payload. Sixteen event classes exist for messages the default mapper does not register, or for a case its switch does not cover, so the default client never produces them: `TikTokChestEvent`, `TikTokDetectEvent`, `TikTokEnvelopeEvent`, `TikTokGoalUpdateEvent`, `TikTokIMDeleteEvent`, `TikTokInRoomBannerEvent`, `TikTokLinkApplyEvent`, `TikTokLinkLayerEvent`, `TikTokLinkMicFanTicketEvent`, `TikTokLinkMicMethodEvent`, `TikTokLinkReplyEvent`, `TikTokRankTextEvent`, `TikTokRankUpdateEvent`, `TikTokShopEvent`, `TikTokSubNotifyEvent`, and `TikTokUnauthorizedMemberEvent`. Each section below says so where it applies. Two more are never published by the client for another reason: `TikTokHttpResponseEvent` has a handler but no publisher in this checkout, and `TikTokEmptyEvent` is referenced nowhere. To produce one of them, register a mapping with `mappings` on the builder. The mapping receives the message bytes, deserialises them with the helper, and returns a `MappingResult` holding the message and the events to publish. The protobuf classes live in `io.github.jwdeveloper.tiktok.api.messages.webcast`. From 5f1478f418790b1e28f7fa6fa796eadf59004d48 Mon Sep 17 00:00:00 2001 From: isaackogan <isaacikogan@gmail.com> Date: Tue, 20 Oct 2026 17:37:10 -0400 Subject: [PATCH 10/31] docs: write guides Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- docs/src/content/docs/guides/collector.md | 105 +++++++++++ docs/src/content/docs/guides/custom-events.md | 163 ++++++++++++++++++ docs/src/content/docs/guides/debugging.md | 118 +++++++++++++ docs/src/content/docs/guides/gifts.md | 86 ++++++++- docs/src/content/docs/guides/listeners.md | 156 ++++++++++++++++- .../content/docs/guides/offline-testing.md | 85 +++++++++ docs/src/content/docs/guides/recorder.md | 97 +++++++++++ 7 files changed, 806 insertions(+), 4 deletions(-) create mode 100644 docs/src/content/docs/guides/collector.md create mode 100644 docs/src/content/docs/guides/custom-events.md create mode 100644 docs/src/content/docs/guides/debugging.md create mode 100644 docs/src/content/docs/guides/offline-testing.md create mode 100644 docs/src/content/docs/guides/recorder.md diff --git a/docs/src/content/docs/guides/collector.md b/docs/src/content/docs/guides/collector.md new file mode 100644 index 00000000..eacdec8b --- /dev/null +++ b/docs/src/content/docs/guides/collector.md @@ -0,0 +1,105 @@ +--- +title: Collect live data +description: Store every batch, message, event, and error of one or many livestreams as documents in JSON files or MongoDB with the extension-collector module's TikTokLiveCollector, filter what is stored, and reuse the stored payloads. +sidebar: + order: 5 +--- + +`TikTokLiveCollector`, in the `extension-collector` module, builds a `DataCollector` that writes what a client receives to a storage: each WebSocket batch, each message in it, each event, and each error, as one document apiece. One collector serves any number of clients through `newListener()`, so a program can archive several rooms into the same files or collection. The message payloads it stores are what `publishMessage` replays; see [offline testing](/guides/offline-testing/). This guide is based on the repository's `CollectorExample.java`. Replace `isaackogz` with a creator who is live. + +## Install + +The module is published on JitPack with the same group and version as the `Client` artifact; see [install](/getting-started/install/) for the repository declaration. It depends on `API`, `protobuf-java`, and `mongodb-driver-sync` 4.4.0, whose `Document` class is the document type, so the driver is pulled in even when writing to files. It does not depend on `Client`, so declare `Client` as well. + +```xml +<dependency> + <groupId>com.github.jwdeveloper.TikTok-Live-Java</groupId> + <artifactId>extension-collector</artifactId> + <version>1.13.1-Release</version> +</dependency> +``` + +```groovy +implementation 'com.github.jwdeveloper.TikTok-Live-Java:extension-collector:1.13.1-Release' +``` + +## Record to files + +`TikTokLiveCollector.useFile(settings -> ...)` returns a `DataCollector` that writes JSON files. Call `connect()` before the clients start and `disconnect()` at the end; both are no-ops for files, but the Mongo storage needs them, so keep the calls. `newListener()` returns the listener to add to a client; `newListener(extraFields)` adds the map's entries to every document that listener writes, and `newListener(extraFields, filter)` also takes a `CollectorEvent`, `(client, document) -> boolean`, consulted before each document is stored. + +| Setter | Type | Default | Effect | +| --- | --- | --- | --- | +| `setParentFile` | `File` | none | The directory the files go in. It must exist: a missing directory makes every write fail with a printed `IOException`. | +| `setTypeFilter` | `BiPredicate<String, String>` | accept all | Receives the document's `dataType` and `dataTypeName`; a `false` skips the document. | +| `setUserFilter` | `Predicate<String>` | accept all | Receives the document's `tiktokUser`. | +| `setUseFileLocks` | `boolean` | `false` | One lock per file around each write. Set it when several clients share the collector. | +| `setAppendUserName` | `boolean` | `false` | Adds `_<tiktokUser>` to the file names, one set of files per creator. | + +Each document goes to `<dataType>_<dataTypeName>.json`, for example `message_WebcastChatMessage.json` or `event_TikTokCommentEvent.json`, appended as pretty-printed JSON followed by a newline. The file is a sequence of documents, not a JSON array, so read it with a parser that accepts concatenated values. + +```java +import io.github.jwdeveloper.tiktok.extension.collector.TikTokLiveCollector; + +var directory = new File("captures"); +directory.mkdirs(); + +var collector = TikTokLiveCollector.useFile(settings -> { + settings.setParentFile(directory); + settings.setTypeFilter((dataType, dataTypeName) -> dataType.equals("message") || dataType.equals("error")); +}); +collector.connect(); + +Map<String, Object> extraFields = Map.of("sessionTag", "monday-stream"); + +var client = TikTokLive.newClient("isaackogz") + .onError((liveClient, event) -> event.getException().printStackTrace()) + .addListener(collector.newListener(extraFields)) + .buildAndConnect(); + +Thread.sleep(60_000); +client.disconnect(); +collector.disconnect(); +``` + +To archive several rooms at once, build one client per creator with the same collector, as the repository example does with `buildAndConnectAsync()`, and set `useFileLocks` or `appendUserName`. + +## Record to MongoDB + +`TikTokLiveCollector.useMongo(settings -> ...)` takes a `MongoDataCollectorSettings`: `setConnectionUrl(url)`, or `connectionBuilder(b -> b.setUsername(...).setPassword(...).setCluster(...).setDatabase(...))`, which builds a `mongodb+srv://` URL with `retryWrites=true&w=majority`; `setDatabaseName` (default `tiktok`); and `setCollectionName` (default `data`). `connect()` opens the client and creates hashed indexes on `session` and `dataType`; no document carries a `session` field unless you add one through the extra fields. Each document is stored with `insertOne`, and `disconnect()` closes the client. + +```java +import io.github.jwdeveloper.tiktok.extension.collector.TikTokLiveCollector; + +var collector = TikTokLiveCollector.useMongo(settings -> { + settings.setConnectionUrl(System.getenv("MONGODB_URL")); + settings.setDatabaseName("tiktok"); + settings.setCollectionName("live-data"); +}); +collector.connect(); + +var eventsOnly = collector.newListener(Map.of(), (liveClient, document) -> "event".equals(document.getString("dataType"))); + +TikTokLive.newClient("isaackogz") + .addListener(eventsOnly) + .buildAndConnect(); +``` + +## What is stored + +The listener has three observer methods, for `TikTokWebsocketResponseEvent`, every `TikTokEvent`, and `TikTokErrorEvent`. Every document has the same shape: + +| Field | Value | +| --- | --- | +| `roomId` | The room id, `null` until the first `TikTokRoomInfoEvent`; the signing response's messages are dispatched before it, so the first documents of a connection have no room id. | +| extra fields | The entries of the map given to `newListener`. | +| `tiktokUser` | The creator's name, taken at `TikTokConnectingEvent`. | +| `dataType` | `response`, `message`, `event`, or `error`. | +| `dataTypeName` | `webcast` for a response; the Webcast message name for a message; the event's class name for an event; the exception's class name for an error. | +| `content` | Base64: the whole `ProtoMessageFetchResult` for a response; the message's payload bytes for a message; the event's JSON, from `toJson()`, for an event; the stack trace for an error. | +| `createdAt` | The local date-time, ISO format. | + +A batch produces one `response` document and one `message` document per message in it. Every event produces an `event` document, including the control events, the debug events, and the ones you publish yourself, except `TikTokErrorEvent`, which produces an `error` document instead; for a `TikTokLiveMessageException` the error document also carries `message` and `response`, the failing message and its batch in Base64. The error observer prints the stack trace to `System.err` as well. + +## Reuse the data + +A `message` document is a replay unit: `dataTypeName` is the Webcast message name and `content` the Base64 payload that `client.publishMessage(dataTypeName, content)` takes, so a captured room can be run through handlers in offline mode. A `response` document holds a whole batch: decode it with `ProtoMessageFetchResult.parseFrom(Base64.getDecoder().decode(content))` and replay `getMessagesList()` in order. An `event` document decodes to the JSON that `toJson()` produced, which is for inspection; the event classes have no JSON constructor. The payloads of messages with no mapping are the material for a bug report, see [debugging](/guides/debugging/). diff --git a/docs/src/content/docs/guides/custom-events.md b/docs/src/content/docs/guides/custom-events.md new file mode 100644 index 00000000..147d0f62 --- /dev/null +++ b/docs/src/content/docs/guides/custom-events.md @@ -0,0 +1,163 @@ +--- +title: Custom events and mappings +description: Publish your own TikTokEvent subclasses with publishEvent, map a Webcast message the default client leaves unmapped with mappings and forMessage, and wrap or replace a default mapping. +sidebar: + order: 3 +--- + +Two extension points add events. `publishEvent` dispatches any `TikTokEvent` subclass to the handlers and listeners, so your code can raise its own events from a handler or from anywhere else. `mappings` on the builder edits the table that turns Webcast messages into events, so a message the default client leaves unmapped can become an event, and a default mapping can be wrapped or replaced. This guide is based on the repository's `CustomEventExample.java` and `CustomMappingExample.java`. Replace `isaackogz` with a creator who is live. + +## Publish your own events + +A custom event is a class that extends `TikTokEvent`, an abstract class with no fields and a `toJson()` method. `client.publishEvent(event)` runs the handlers for the event's exact class and then the `TikTokEvent` catch-all, on the calling thread, before it returns, whether or not the client is connected. Subscribe with `onEvent(MyEvent.class, handler)` on the builder or with a listener method whose parameter is `MyEvent`; see [listeners](/guides/listeners/). `TikTokEmptyEvent` is a ready-made event with no fields for a plain trigger. + +The example sorts gifts into two tiers and publishes a tier event from the gift handler. The repository's version declares the event classes with Lombok's `@AllArgsConstructor`; plain constructors do the same without annotation processing. + +```java +import io.github.jwdeveloper.tiktok.api.data.events.common.TikTokEvent; +import io.github.jwdeveloper.tiktok.api.data.models.gifts.Gift; +import io.github.jwdeveloper.tiktok.client.TikTokLive; + +public class GiftTiers { + public static class CheapGiftEvent extends TikTokEvent { + private final Gift gift; + + public CheapGiftEvent(Gift gift) { + this.gift = gift; + } + + public Gift getGift() { + return gift; + } + } + + public static class ExpensiveGiftEvent extends TikTokEvent { + private final Gift gift; + private final int combo; + + public ExpensiveGiftEvent(Gift gift, int combo) { + this.gift = gift; + this.combo = combo; + } + + public Gift getGift() { + return gift; + } + + public int getCombo() { + return combo; + } + } + + public static void main(String[] args) { + TikTokLive.newClient("isaackogz") + .onGift((client, event) -> { + if (event.getGift().getDiamondCost() * event.getCombo() >= 100) { + client.publishEvent(new ExpensiveGiftEvent(event.getGift(), event.getCombo())); + } else { + client.publishEvent(new CheapGiftEvent(event.getGift())); + } + }) + .onEvent(CheapGiftEvent.class, (client, event) -> + System.out.println("Thanks for the " + event.getGift().getName())) + .onEvent(ExpensiveGiftEvent.class, (client, event) -> + System.out.println("Thank you for " + event.getCombo() + " x " + event.getGift().getName() + "!")) + .buildAndConnect(); + } +} +``` + +An event published from inside a handler is dispatched before that handler returns. An exception thrown by a handler during `publishEvent` propagates to the caller of `publishEvent`; during message handling that is the message pipeline, which reports it as a `TikTokErrorEvent`, see [debugging](/guides/debugging/). + +## Map a message the default client does not + +The client maps a message only when a mapping is registered for its name; any other message is published as `TikTokWebsocketUnhandledMessageEvent`. Sixteen event classes exist for messages the default mapper does not register, listed under [unmapped events](/events/#unmapped-events). `mappings(mapper -> ...)` on the builder runs after the default mappings are registered and receives the `LiveMapper`: + +- `forMessage(WebcastXMessage.class, action)` or `forMessage("WebcastXMessage", action)` registers `action` as the message's mapping; a class is keyed by its simple name. +- The action is a `MappingAction<MappingResult>`: it receives the payload bytes, the message name, and a `LiveMapperHelper`, and returns a `MappingResult` built with `MappingResult.of(message, event)` or `MappingResult.of(message, events)` for a list; `MappingResult.of(message)` publishes nothing. +- The helper deserialises: `bytesToWebcastObject(bytes, WebcastXMessage.class)`, or `bytesToWebcastObject(bytes, name)`, which looks the class up by name in `io.github.jwdeveloper.tiktok.api.messages.webcast`; `isMessageHasProtoClass(name)` says whether that class exists; `bytesToProtoBufferStructure(bytes)` decodes any payload into a `ProtoBufferObject` tree with `toJson()`; `toJson(object)` prints an object. + +Each event in the result is published after a `TikTokWebsocketMessageEvent` that wraps it, like the events of a default mapping. The example maps `WebcastRankTextMessage` to `TikTokRankTextEvent`, whose constructor takes the message, as the catalog's example does; the other events on the unmapped list have the same kind of constructor, see the [API reference](/api/). + +```java +import io.github.jwdeveloper.tiktok.api.messages.webcast.WebcastRankTextMessage; + +TikTokLive.newClient("isaackogz") + .mappings(mapper -> mapper.forMessage(WebcastRankTextMessage.class, (bytes, messageName, helper) -> { + var message = helper.bytesToWebcastObject(bytes, WebcastRankTextMessage.class); + return MappingResult.of(message, new TikTokRankTextEvent(message)); + })) + .onEvent(TikTokRankTextEvent.class, (client, event) -> + System.out.println(event.getEventType() + ": " + event.getLabel())) + .buildAndConnect(); +``` + +A mapping can also be a place to look at a message you do not have a class for yet. Registering a name takes the message out of the unhandled path, so `TikTokWebsocketUnhandledMessageEvent` no longer fires for it; return `MappingResult.of(bytes)` to publish nothing. + +```java +TikTokLive.newClient("isaackogz") + .mappings(mapper -> mapper.forMessage("WebcastSomeNewMessage", (bytes, messageName, helper) -> { + if (helper.isMessageHasProtoClass(messageName)) { + System.out.println(helper.bytesToWebcastObject(bytes, messageName)); + } else { + System.out.println(helper.bytesToProtoBufferStructure(bytes).toJson()); + } + return MappingResult.of(bytes); + })) + .buildAndConnect(); +``` + +`forMessage(name)` without an action registers the name with a mapping that publishes nothing, so a call made only to set `onBeforeMapping` on a message the defaults do not map silences that message: it stops arriving as `TikTokWebsocketUnhandledMessageEvent` and produces no event. Give such a message an `onMapping` too. + +## Wrap or replace a default mapping + +`forMessage` returns the message's `TikTokMapperModel`, the existing one for a message the defaults already map, with three hooks. `onBeforeMapping(action)` receives the bytes and returns the bytes to map. `onMapping(action)` replaces the mapping, and the default is lost: after `forMessage(WebcastGiftMessage.class).onMapping(...)`, `onGift` no longer fires unless your mapping builds the gift events itself. `onAfterMapping(function)` receives the `MappingResult`, with `getSource()` (the deserialised message, as `Object`) and `getEvents()`, and returns the list of events to publish, so it can add, drop, or replace events while the default mapping keeps running. `forAnyMessage()` returns a model whose hooks run for every registered message around the message's own; its `onMapping` runs only when the message's mapping returns `null`, and it never sees messages without a mapping. + +```java +import io.github.jwdeveloper.tiktok.api.data.events.common.TikTokEvent; +import io.github.jwdeveloper.tiktok.api.messages.webcast.WebcastChatMessage; +import io.github.jwdeveloper.tiktok.client.TikTokLive; + +import java.util.ArrayList; + +public class CommentLanguages { + public static class ForeignCommentEvent extends TikTokEvent { + private final String language; + private final String text; + + public ForeignCommentEvent(String language, String text) { + this.language = language; + this.text = text; + } + + public String getLanguage() { + return language; + } + + public String getText() { + return text; + } + } + + public static void main(String[] args) { + TikTokLive.newClient("isaackogz") + .mappings(mapper -> mapper.forMessage(WebcastChatMessage.class) + .onAfterMapping(result -> { + var message = (WebcastChatMessage) result.getSource(); + var events = new ArrayList<>(result.getEvents()); + var language = message.getContentLanguage(); + if (!language.isEmpty() && !language.equals("en")) { + events.add(new ForeignCommentEvent(language, message.getContent())); + } + return events; + })) + .onComment((client, event) -> + System.out.println(event.getUser().getProfileName() + ": " + event.getText())) + .onEvent(ForeignCommentEvent.class, (client, event) -> + System.out.println("[" + event.getLanguage() + "] " + event.getText())) + .buildAndConnect(); + } +} +``` + +A mapping that throws is reported as a `TikTokErrorEvent` carrying a `TikTokLiveMessageException` when the message came from the socket, and thrown to the caller when it came from `publishMessage`; see [debugging](/guides/debugging/) and [offline testing](/guides/offline-testing/). diff --git a/docs/src/content/docs/guides/debugging.md b/docs/src/content/docs/guides/debugging.md new file mode 100644 index 00000000..775019db --- /dev/null +++ b/docs/src/content/docs/guides/debugging.md @@ -0,0 +1,118 @@ +--- +title: Debugging +description: Turn on the client's log, read every exception through TikTokErrorEvent, watch the raw traffic with the WebSocket debug events, catch messages the library has no mapping for, decode their payloads, and report a new message type. +sidebar: + order: 7 +--- + +Four tools cover most event debugging: the client's logger, `TikTokErrorEvent` for every exception the client catches, the three WebSocket debug events for the raw traffic, and `publishMessage` for replaying a payload offline. The debug events' fields are on the [debugging events page](/events/debugging/); this guide is about using them. Replace `isaackogz` with a creator who is live. + +## Turn on the log + +The client is silent until `printToConsole` is set; `logLevel` then chooses what the logger named after the creator lets through, and `client.getLogger()` is the same `java.util.logging.Logger` for your own lines. The client logs its state changes, reconnects, and the warnings of the connect sequence; the handler's level and how to route the log elsewhere are on the [logging page](/configuration/logging/). + +```java +TikTokLive.newClient("isaackogz") + .configure(settings -> { + settings.setPrintToConsole(true); + settings.setLogLevel(Level.INFO); + }) + .onConnected((client, event) -> client.getLogger().info("Connected to room " + client.getRoomInfo().getRoomId())) + .onDisconnected((client, event) -> client.getLogger().info("Disconnected: " + event.getReason())) + .buildAndConnect(); +``` + +## Read every exception + +The client reports exceptions as `TikTokErrorEvent`, through `onError` or a listener method, with `getException()`. What arrives depends on where it was thrown: + +- The connect sequence: `TikTokLiveOfflineHostException`, `TikTokLiveUnknownHostException`, `TikTokSignServerException`, `TikTokLivePreConnectionException`, or a `TikTokLiveException` such as `Already connected`; the event is published and then `connect()` throws the same exception. See [lifecycle events](/getting-started/connection/#lifecycle-events). +- Message handling: when a mapping, or a lambda handler called for a message, throws while a batch is processed, the event carries a `TikTokLiveMessageException` with `messageMethod()`, the message name, `messageToBase64()`, its payload, `webcastResponseToBase64()`, the whole batch, and `getCause()`, the original exception; a mapping that cannot deserialise the payload has a `TikTokMessageMappingException` as that cause. The client skips the message and continues with the next one. +- Listener methods: a `TikTokEventListenerMethodException` wrapping the method's exception, from any event; see [listeners](/guides/listeners/#when-a-method-throws). + +The message name and payload in a `TikTokLiveMessageException` are what a bug report needs when the library fails on a message it does have a mapping for. + +```java +TikTokLive.newClient("isaackogz") + .onError((client, event) -> { + if (event.getException() instanceof TikTokLiveMessageException e) { + System.err.println("Failed on " + e.messageMethod() + ": " + e.getCause()); + System.err.println("Payload: " + e.messageToBase64()); + } else { + event.getException().printStackTrace(); + } + }) + .buildAndConnect(); +``` + +## Watch the raw traffic + +For every batch of messages, from the signing response and from each WebSocket frame, the client publishes `TikTokWebsocketResponseEvent` with the whole `ProtoMessageFetchResult`, then, per message, either a `TikTokWebsocketMessageEvent` before each event the message maps to, with the message, the event, and the mapping's duration, or a `TikTokWebsocketUnhandledMessageEvent` when no mapping is registered for the message's name. `TikTokHttpResponseEvent` has a handler but no publisher in this release. This handler counts each batch by message name, which shows at a glance what a room sends and how much of it the client maps: + +```java +TikTokLive.newClient("isaackogz") + .onWebsocketResponse((client, event) -> { + var counts = new TreeMap<String, Integer>(); + for (var message : event.getResponse().getMessagesList()) { + counts.merge(message.getMethod(), 1, Integer::sum); + } + System.out.println("Batch of " + event.getResponse().getMessagesCount() + ": " + counts); + }) + .onWebsocketMessage((client, event) -> + System.out.println(event.getMessage().getMethod() + " -> " + event.getEvent().getClass().getSimpleName() + + " in " + event.getMetaData().getHandlingTime().toNanos() + " ns")) + .buildAndConnect(); +``` + +## Messages without a mapping + +A message type the library does not know ends up in `TikTokWebsocketUnhandledMessageEvent`, not in `TikTokErrorEvent`. `getMessage()` is the raw message with `getMethod()`, the name, and `getPayload()`, the protobuf bytes. This handler prints each new name once, with its payload in Base64, which is the form a bug report and a `publishMessage` replay both take: + +```java +var reported = new HashSet<String>(); + +TikTokLive.newClient("isaackogz") + .onWebsocketUnhandledMessage((client, event) -> { + var method = event.getMessage().getMethod(); + if (!reported.add(method)) { + return; + } + var payload = event.getMessage().getPayload(); + System.out.println("Unhandled " + method + " (" + payload.size() + " bytes): " + + Base64.getEncoder().encodeToString(payload.toByteArray())); + }) + .buildAndConnect(); +``` + +## Decode a payload + +Two cases. When the [schema reference](/schema/) has a class for the message, which is so for the sixteen [unmapped events](/events/#unmapped-events), parse the payload with the class's `parseFrom` and, where an event class exists, construct it; the next step is a custom mapping, see [custom events and mappings](/guides/custom-events/). When no class exists, `ProtocolUtils.getProtocolBufferStructure(bytes)` decodes the raw protobuf into a `ProtoBufferObject` whose `toJson()` lists the field numbers, wire types, and values, which is enough to see what the message carries and to write a `.proto` definition; `toProtoFile()` drafts one. + +```java +import com.google.protobuf.InvalidProtocolBufferException; +import io.github.jwdeveloper.tiktok.api.messages.webcast.WebcastRankTextMessage; + +TikTokLive.newClient("isaackogz") + .onWebsocketUnhandledMessage((client, event) -> { + var payload = event.getMessage().getPayload(); + if (event.getMessage().getMethod().equals("WebcastRankTextMessage")) { + try { + var rankText = new TikTokRankTextEvent(WebcastRankTextMessage.parseFrom(payload)); + System.out.println("Rank text: " + rankText.getLabel()); + } catch (InvalidProtocolBufferException e) { + System.err.println("Not a WebcastRankTextMessage: " + e.getMessage()); + } + } else { + System.out.println(ProtocolUtils.getProtocolBufferStructure(payload.toByteArray()).toJson()); + } + }) + .buildAndConnect(); +``` + +## Replay a payload offline + +A payload captured with the handlers above, or with the [collector](/guides/collector/), can be fed to an offline client with `publishMessage(name, payloadBase64)` and goes through the same mapping as a socket message, so a mapping or a handler can be debugged without a live room, and a failing payload fails right at the `publishMessage` call with the exception. See [offline testing](/guides/offline-testing/#replay-recorded-messages). + +## Report a new message type + +Check the [schema reference](/schema/) first: when the library already has a class for the message, a custom mapping is the fix and needs no report. When it has none, open an issue on the [TikTokLiveJava GitHub repository](https://github.com/jwdeveloper/TikTokLiveJava/issues) with the message name, one or more Base64 payloads from the unhandled-message handler, the library version (`1.13.1-Release` for these pages), and what was happening in the stream when the message arrived, such as a gift, a battle, or a poll. The payload is what lets the message be decoded and a schema written; a name alone is not enough. For a message the library does map but fails on, report the name and payload from the `TikTokLiveMessageException` instead. The `Unhandled exception` line the client logs when `connect()` hits an unexpected error asks for the same report. diff --git a/docs/src/content/docs/guides/gifts.md b/docs/src/content/docs/guides/gifts.md index b3337fb1..78076708 100644 --- a/docs/src/content/docs/guides/gifts.md +++ b/docs/src/content/docs/guides/gifts.md @@ -1,6 +1,88 @@ --- title: Gifts and combos -description: Handle TikTokGiftEvent and TikTokGiftComboEvent, and count streaked gifts. +description: Count a streaked gift once with TikTokGiftEvent, follow a streak with TikTokGiftComboEvent, read the room's gift list from the gift manager, and test gift handlers offline. +sidebar: + order: 1 --- -This page is written in a later task of the documentation plan. +A gift reaches your code as two events. `TikTokGiftEvent` is published once per gift: at once for a gift that cannot streak, and once per completed streak for one that can. `TikTokGiftComboEvent` is published for every frame of a streak, with the streak's state. Both carry `getGift()`, `getUser()` (the sender), `getToUser()` (the recipient, which is the host when the message names none), and `getCombo()`, the streak's count; the field lists are on the [gifts and goals page](/events/gifts-and-goals/). This guide is based on the repository's `Events_And_Gifts_Testing_Example.java` and the README's `onGift` and `onGiftCombo` sections. Replace `isaackogz` with a creator who is live. + +## Count a gift once + +A viewer can hold the gift button to send the same gift repeatedly. TikTok reports the streak as a series of `WebcastGiftMessage`s whose combo count climbs, then a final frame with the total. The client's gift handler keeps the state of each streak, keyed by sender, gift, and group, and publishes `TikTokGiftEvent` only when the streak is complete: on the final frame or, when TikTok sends none, at the first gift message that arrives more than five minutes after the streak's last frame. A final frame that TikTok repeats within a minute is dropped. A gift whose type cannot streak is published at once. So `onGift` sees each gift exactly once, and `getCombo()` times `getDiamondCost()` is the value of the whole streak. + +```java +TikTokLive.newClient("isaackogz") + .onGift((client, event) -> { + var gift = event.getGift(); + var diamonds = (long) gift.getDiamondCost() * event.getCombo(); + System.out.println(event.getUser().getProfileName() + " sent " + event.getCombo() + " x " + gift.getName() + + " (" + diamonds + " diamonds) to " + event.getToUser().getProfileName()); + }) + .buildAndConnect(); +``` + +Handlers are matched by the event's exact class, so `onGift` is never called for a `TikTokGiftComboEvent`, although that class extends `TikTokGiftEvent`; a `Finished` combo event and the `TikTokGiftEvent` for the same gift are two separate publications, in that order. The diamond cost is TikTok's price metadata for the gift, not what the creator is paid. + +## Follow a streak while it runs + +`onGiftCombo` receives every frame. `getComboState()` is a `GiftComboStateType`: `Begin` on the first frame of a streak, `Active` on each later frame, and `Finished` on the final frame, on a non-streakable gift, and when a streak times out. `getCombo()` is the count so far and is cumulative, so display the latest value rather than adding the frames up. Count gifts in `onGift` or in the `Finished` branch here, not in both. + +```java +TikTokLive.newClient("isaackogz") + .onGiftCombo((client, event) -> { + var who = event.getUser().getProfileName(); + var what = event.getGift().getName(); + switch (event.getComboState()) { + case Begin -> System.out.println(who + " started a " + what + " streak"); + case Active -> System.out.println(who + ": " + event.getCombo() + " x " + what + " so far"); + case Finished -> System.out.println(who + " finished at " + event.getCombo() + " x " + what); + } + }) + .buildAndConnect(); +``` + +A streakable gift produces `Begin`, zero or more `Active` frames, then `Finished`; a non-streakable gift produces a single `Finished` frame, followed by its `TikTokGiftEvent`. Whether a gift can streak is the gift type in the message, which the handler reads before it builds the events; the `Gift` object has no field for it, so tell the two kinds apart by whether a `Begin` frame arrived. + +## The gift list + +`client.getGiftManager()` returns the `GiftsManager`, the client's index of known gifts by id: `getById(int)`, `getByName(String)` (case-insensitive), `getByFilter(Predicate<Gift>)`, `getManyByFilter(Predicate<Gift>)`, `toList()`, and `toMap()`. A lookup that finds nothing returns `Gift.UNDEFINED`, a gift with id `-1`, name `undefined`, and cost `-1`, rather than `null`. `attachGift(Gift)` and `attachGiftsList(List<Gift>)` add your own entries. + +The manager starts empty. With `fetchGifts` set to `true`, `connect()` downloads the room's gift list after `TikTokPreConnectionEvent` and before the socket opens, so every gift is known before the first event; see [client settings](/configuration/client-settings/). Otherwise the handler resolves each gift message by id, then by name, and when neither is known it builds the `Gift` from the message and attaches it, so the manager fills up as gifts arrive. + +A `Gift` has `getId()`, `getName()`, `getDiamondCost()`, `getPicture()` (a `Picture` with `getLink()` and `downloadImage()`), `getProperties()` (a `JsonObject` with the extra fields of the fetched list, empty for a gift built from a message), and the helpers `hasDiamondCost(int)` and `hasDiamondCostRange(int, int)`. + +```java +TikTokLive.newClient("isaackogz") + .configure(settings -> settings.setFetchGifts(true)) + .onConnected((client, event) -> { + var gifts = client.getGiftManager(); + System.out.println(gifts.toList().size() + " gifts in this room"); + for (var gift : gifts.getManyByFilter(g -> g.getDiamondCost() >= 1000)) { + System.out.println(gift.getName() + ": " + gift.getDiamondCost() + " diamonds, " + gift.getPicture().getLink()); + } + }) + .buildAndConnect(); +``` + +## Test gift handlers offline + +`TikTokGiftEvent.of(gift)`, `TikTokGiftEvent.of(name, id, diamonds)`, and `TikTokGiftComboEvent.of(gift, combo, state)` build events without a message, and `publishEvent` dispatches them to your handlers without a connection. In offline mode nothing fetches the gift list, so `getGiftManager().getByName(...)` returns `Gift.UNDEFINED` until you attach the gifts the test needs; the repository's example looks `Rose` up without attaching it and so publishes the undefined gift. See [offline testing](/guides/offline-testing/) for what the mode can and cannot do. + +```java +var client = TikTokLive.newClient("isaackogz") + .configure(settings -> settings.setOffline(true)) + .onGift((liveClient, event) -> + System.out.println(event.getCombo() + " x " + event.getGift().getName() + " from " + event.getUser().getName())) + .onGiftCombo((liveClient, event) -> + System.out.println(event.getComboState() + " at " + event.getCombo())) + .build(); + +var rose = new Gift(5655, "Rose", 1, ""); +client.getGiftManager().attachGift(rose); + +client.publishEvent(TikTokGiftComboEvent.of(rose, 1, GiftComboStateType.Begin)); +client.publishEvent(TikTokGiftComboEvent.of(rose, 12, GiftComboStateType.Finished)); +client.publishEvent(TikTokGiftEvent.of(client.getGiftManager().getByName("rose"))); +client.publishEvent(TikTokGiftEvent.of("Galaxy", 7122, 1000)); +``` diff --git a/docs/src/content/docs/guides/listeners.md b/docs/src/content/docs/guides/listeners.md index 1f1a2640..4e1ac509 100644 --- a/docs/src/content/docs/guides/listeners.md +++ b/docs/src/content/docs/guides/listeners.md @@ -1,6 +1,158 @@ --- title: Listeners -description: Register event handlers as annotated methods of a listener class with addListener. +description: Register handlers as annotated methods of a listener class with addListener, choose the method parameters, set priority and async on @TikTokEventObserver, add and remove listeners at runtime, and what happens when a method throws. +sidebar: + order: 2 --- -This page is written in a later task of the documentation plan. +A listener is any object whose methods carry `@TikTokEventObserver`. `addListener(listener)` on the builder registers it when `build()` runs; `client.getListenersManager().addListener(listener)` registers one on a built client. Each annotated method becomes a handler for the event class of its parameter, the same as a lambda passed to `onEvent`, so a listener is the way to keep related handlers, and the state they share, together in one class instead of a chain of lambdas. This guide is based on the repository's `ListenerExample.java`. Replace `isaackogz` with a creator who is live. + +## Write a listener + +The listeners manager scans the methods declared on the listener's class and registers every one that carries `@TikTokEventObserver` and has at least one parameter. The event class is the first parameter whose type extends `TikTokEvent`; the other parameters are resolved from the client's container, so a `LiveClient` parameter can come first, second, or be left out. The method may be private: the manager makes it accessible. A method that carries the annotation but has no `TikTokEvent` parameter fails registration with `TikTokEventListenerMethodException`, which `addListener` throws, so from `build()` for a listener added on the builder. + +```java +import io.github.jwdeveloper.tiktok.api.annotations.TikTokEventObserver; +import io.github.jwdeveloper.tiktok.api.data.events.TikTokCommentEvent; +import io.github.jwdeveloper.tiktok.api.data.events.TikTokErrorEvent; +import io.github.jwdeveloper.tiktok.api.data.events.common.TikTokEvent; +import io.github.jwdeveloper.tiktok.api.data.events.gift.TikTokGiftEvent; +import io.github.jwdeveloper.tiktok.api.data.events.social.TikTokLikeEvent; +import io.github.jwdeveloper.tiktok.api.live.LiveClient; +import io.github.jwdeveloper.tiktok.client.TikTokLive; + +public class ChatListener { + private int likes; + + @TikTokEventObserver + public void onComment(LiveClient client, TikTokCommentEvent event) { + client.getLogger().info(event.getUser().getName() + ": " + event.getText()); + } + + @TikTokEventObserver + public void onGift(LiveClient client, TikTokGiftEvent event) { + var message = switch (event.getGift().getName()) { + case "Rose" -> "Thanks for the rose!"; + case "TikTok" -> "A TikTok gift, nice!"; + default -> "Thanks for the " + event.getGift().getName(); + }; + client.getLogger().info(message); + } + + @TikTokEventObserver + public void onLike(TikTokLikeEvent event) { + likes += event.getLikes(); + } + + @TikTokEventObserver + private void onError(TikTokErrorEvent event, LiveClient client) { + client.getLogger().warning("Error: " + event.getException().getMessage()); + } + + @TikTokEventObserver + public void onAnyEvent(LiveClient client, TikTokEvent event) { + client.getLogger().info(event.getClass().getSimpleName() + ", " + likes + " likes so far"); + } + + public static void main(String[] args) { + TikTokLive.newClient("isaackogz") + .configure(settings -> settings.setPrintToConsole(true)) + .addListener(new ChatListener()) + .buildAndConnect(); + } +} +``` + +A method is called for events of exactly its parameter's class, plus the `TikTokEvent` catch-all: `onGift` above is not called for a `TikTokGiftComboEvent`, and a method taking `TikTokLinkEvent` receives only the generic link event, not `TikTokLinkInviteEvent`. A method whose parameter is `TikTokEvent` receives every event, including the debug events and the ones you publish yourself; see the [event catalog](/events/). Listener methods run on the thread that publishes the event, which for socket messages is the socket's thread, so a slow method delays the messages behind it. + +## Priority and asynchronous methods + +`@TikTokEventObserver` has two attributes. `priority`, a `Priority` from `HIGHEST` to `LOWEST` (default `NORMAL`), sorts the listener's methods when the listener is registered. In this release that is as far as it goes: the event handler stores the handlers of each event class in a `HashSet` and runs them by iterating it, so the order in which several handlers for one event run, lambdas or listener methods, from one listener or several, is unspecified, and `priority` does not fix it. Write handlers that do not depend on each other's order. + +`async = true` runs the method on an `ExecutorService` instead of the publishing thread. The executor is the one set with `getListenersManager().setAsyncExecutor(executor)` or, when none is set, a cached thread pool created the first time an asynchronous method is registered. The executor is captured when the listener is registered, and listeners added on the builder are registered during `build()`, before the manager can be reached; so to use your own executor, build the client, set the executor, then add the listener through the manager. + +```java +import io.github.jwdeveloper.tiktok.api.annotations.Priority; +import io.github.jwdeveloper.tiktok.api.annotations.TikTokEventObserver; +import io.github.jwdeveloper.tiktok.api.data.events.TikTokCommentEvent; +import io.github.jwdeveloper.tiktok.api.live.LiveClient; +import io.github.jwdeveloper.tiktok.client.TikTokLive; + +import java.util.concurrent.Executors; + +public class AsyncListener { + @TikTokEventObserver(async = true, priority = Priority.LOW) + public void onComment(LiveClient client, TikTokCommentEvent event) { + // A slow lookup here no longer delays the next message + client.getLogger().info(Thread.currentThread().getName() + " handled: " + event.getText()); + } + + public static void main(String[] args) { + var client = TikTokLive.newClient("isaackogz") + .configure(settings -> settings.setPrintToConsole(true)) + .build(); + client.getListenersManager().setAsyncExecutor(Executors.newFixedThreadPool(4)); + client.getListenersManager().addListener(new AsyncListener()); + client.connect(); + } +} +``` + +## Add and remove listeners at runtime + +`client.getListenersManager()` returns the `ListenersManager`: `addListener(Object)`, `removeListener(Object)`, which unsubscribes every method of that object, and `getListeners()`. Registering the same object twice throws `TikTokLiveException`; a second instance of the same class is a separate listener. Removing an object that was never added does nothing. Any object with annotated methods qualifies, an anonymous class included. + +```java +var client = TikTokLive.newClient("isaackogz").build(); +var manager = client.getListenersManager(); + +var greeter = new Object() { + @TikTokEventObserver + public void onJoin(LiveClient liveClient, TikTokJoinEvent event) { + liveClient.getLogger().info("Welcome " + event.getUser().getProfileName()); + } +}; + +manager.addListener(greeter); +client.connect(); +System.out.println(manager.getListeners().size() + " listener(s) registered"); +manager.removeListener(greeter); +``` + +## When a method throws + +An exception thrown by a listener method does not reach the publisher: the manager catches it and publishes a `TikTokErrorEvent` whose exception is a `TikTokEventListenerMethodException` wrapping the reflective call's `InvocationTargetException`, so your exception is at the end of the cause chain. The client then carries on with the next handler and the next message. Do not throw from a method that handles `TikTokErrorEvent`: its failure publishes another `TikTokErrorEvent`, which calls it again. + +```java +TikTokLive.newClient("isaackogz") + .onError((client, event) -> { + var exception = event.getException(); + if (exception instanceof TikTokEventListenerMethodException) { + var cause = exception; + while (cause.getCause() != null) { + cause = cause.getCause(); + } + System.err.println("A listener method failed: " + cause); + } + }) + .buildAndConnect(); +``` + +## TikTokEventListenerBase + +`TikTokEventListenerBase` is an abstract class with an empty method per event, `onComment(LiveClient, TikTokCommentEvent)`, `onGift(LiveClient, TikTokGiftEvent)`, and so on, as a list of the signatures to override. Extending it registers nothing by itself: the manager scans only the annotated methods declared on your class, and the base's methods carry no annotation. Annotate each override. + +```java +import io.github.jwdeveloper.tiktok.api.annotations.TikTokEventObserver; +import io.github.jwdeveloper.tiktok.api.data.events.social.TikTokFollowEvent; +import io.github.jwdeveloper.tiktok.api.listener.TikTokEventListenerBase; +import io.github.jwdeveloper.tiktok.api.live.LiveClient; + +public class FollowListener extends TikTokEventListenerBase { + @Override + @TikTokEventObserver + public void onFollow(LiveClient client, TikTokFollowEvent event) { + client.getLogger().info(event.getUser().getProfileName() + " followed"); + } +} +``` diff --git a/docs/src/content/docs/guides/offline-testing.md b/docs/src/content/docs/guides/offline-testing.md new file mode 100644 index 00000000..94bbc389 --- /dev/null +++ b/docs/src/content/docs/guides/offline-testing.md @@ -0,0 +1,85 @@ +--- +title: Offline testing +description: Build a client in offline mode and drive handlers and listeners with publishEvent and publishMessage, the of factories for test events, replaying recorded Webcast payloads, and what offline mode cannot do. +sidebar: + order: 6 +--- + +`offline` on `LiveClientSettings` makes `build()` wire stand-in HTTP and WebSocket clients, so no request leaves the process, and `publishEvent` and `publishMessage` then drive handlers and listeners exactly as live traffic would. Do not call `connect()` on an offline client: in this release it fails before the stand-in socket starts, see [offline mode](/configuration/client-settings/#offline-mode); `build()` is all a test needs. This guide is based on the repository's `Events_And_Gifts_Testing_Example.java`. + +## Publish events + +`publishEvent(event)` runs the handlers for the event's exact class and the `TikTokEvent` catch-all, on the calling thread, before it returns; an exception thrown by a handler propagates to the caller. Several event classes have `of` factories that build an event without a Webcast message: + +| Factory | Builds | +| --- | --- | +| `TikTokCommentEvent.of(userName, text)` | A comment whose `getUser().getName()` is `userName` | +| `TikTokLikeEvent.of(userName, likes)`, `of(user, likes)` | A like event with that count | +| `TikTokFollowEvent.of(userName)`, `of(user)` | A follow | +| `TikTokShareEvent.of(userName, shares)`, `of(user, shares)` | A share | +| `TikTokJoinEvent.of(userName)`, `of(user)` | A join | +| `TikTokSubscribeEvent.of(userName)` | A subscription | +| `TikTokGiftEvent.of(gift)`, `of(name, id, diamonds)`, `of(gift, user, toUser)` | A gift; see [gifts](/guides/gifts/#test-gift-handlers-offline) | +| `TikTokGiftComboEvent.of(gift, combo, state)`, `of(gift, host, user, combo, state)` | A streak frame with that count and `GiftComboStateType` | +| `TikTokConnectedEvent.of()`, `TikTokConnectingEvent.of()`, `TikTokLiveEndedEvent.of()` | The control event with no fields | +| `TikTokErrorEvent.of(message)` | An error carrying a `TikTokLiveException` with that message | + +Any other message event has a constructor that takes its Webcast message, which the protobuf builders can create: `new TikTokCommentEvent(WebcastChatMessage.newBuilder().setContent("hi").build())` is a comment from a user with id `0` and an empty name. Fields the builder leaves unset read as protobuf defaults, not `null`. + +```java +var client = TikTokLive.newClient("isaackogz") + .configure(settings -> settings.setOffline(true)) + .onComment((liveClient, event) -> + System.out.println(event.getUser().getName() + ": " + event.getText())) + .onLike((liveClient, event) -> + System.out.println(event.getUser().getName() + " sent " + event.getLikes() + " likes")) + .onFollow((liveClient, event) -> + System.out.println(event.getUser().getName() + " followed")) + .onGift((liveClient, event) -> + System.out.println(event.getCombo() + " x " + event.getGift().getName())) + .build(); + +client.publishEvent(TikTokCommentEvent.of("mark", "Hello world")); +client.publishEvent(TikTokLikeEvent.of("mark", 12)); +client.publishEvent(TikTokFollowEvent.of("mark")); +client.publishEvent(TikTokGiftEvent.of("Rose", 5655, 1)); +``` + +Listeners are tested the same way: add them on the builder or through `getListenersManager()`, then publish; see [listeners](/guides/listeners/). A listener method that does not use its `LiveClient` parameter can also be called directly from a test with `null`, without a client at all. + +## Replay recorded messages + +`publishMessage(name, payloadBase64)` and `publishMessage(name, payload)` wrap the bytes in a Webcast message with that name and run it through the mapper, as a message from the socket would be: a name with no mapping publishes `TikTokWebsocketUnhandledMessageEvent`; a mapped one publishes a `TikTokWebsocketMessageEvent` and then the event, for each event the mapping returns. No `TikTokWebsocketResponseEvent` is published, since there is no batch, and a mapping that throws is not reported as a `TikTokErrorEvent` but thrown to the caller, so a bad payload fails the test at the `publishMessage` call. Custom mappings registered with `mappings` apply; see [custom events and mappings](/guides/custom-events/). + +Payloads come from three places: a handler on `onWebsocketMessage` or `onWebsocketUnhandledMessage` that encodes `event.getMessage().getPayload().toByteArray()` with `Base64.getEncoder()`, the [collector](/guides/collector/), whose `message` documents hold the name and the Base64 payload, and the repository's example, which carries a `WebcastLikeMessage` payload. A message can also be built in code with the protobuf builders. + +```java +import io.github.jwdeveloper.tiktok.api.messages.webcast.WebcastChatMessage; + +var client = TikTokLive.newClient("isaackogz") + .configure(settings -> settings.setOffline(true)) + .onWebsocketMessage((liveClient, event) -> + System.out.println(event.getMessage().getMethod() + " -> " + event.getEvent().getClass().getSimpleName())) + .onWebsocketUnhandledMessage((liveClient, event) -> + System.out.println("No mapping for " + event.getMessage().getMethod())) + .onComment((liveClient, event) -> System.out.println("Comment: " + event.getText())) + .onLike((liveClient, event) -> System.out.println("Likes: " + event.getLikes() + ", total " + event.getTotalLikes())) + .build(); + +var chat = WebcastChatMessage.newBuilder().setContent("Replayed from bytes").setContentLanguage("en").build(); +client.publishMessage("WebcastChatMessage", chat.toByteArray()); + +client.publishMessage("WebcastLikeMessage", "EAUYZA=="); + +client.publishMessage("WebcastNotYetKnownMessage", new byte[0]); +``` + +The Base64 string is a `WebcastLikeMessage` with `count` 5 and `total` 100, built with the same builders and encoded; a like message maps to a `TikTokLikeEvent` and then a `TikTokRoomInfoEvent`, so the handler above runs after one `TikTokWebsocketMessageEvent` for each. + +## What offline mode cannot do + +- `connect()` fails before the stand-in socket starts, so no `TikTokConnectedEvent` or `TikTokDisconnectedEvent` comes from the lifecycle. A handler that waits for one gets it from `publishEvent(TikTokConnectedEvent.of())`. +- `getRoomInfo()` holds the `hostName` and nothing else: `getRoomId()` and `getHost()` are `null`. The gift mapping uses `getHost()` as the recipient of a gift message that names none, so a `WebcastGiftMessage` replayed offline has a `null` `getToUser()` unless the message sets `toUser`. +- The gift manager is empty, since no gift list is fetched; attach the gifts a test needs, see [gifts](/guides/gifts/#test-gift-handlers-offline). +- `sendChat` returns `false` without sending. +- `TikTokLive.isLiveOnline` and `isHostNameValid` are static and make their requests regardless of any client's settings. diff --git a/docs/src/content/docs/guides/recorder.md b/docs/src/content/docs/guides/recorder.md new file mode 100644 index 00000000..72b824e0 --- /dev/null +++ b/docs/src/content/docs/guides/recorder.md @@ -0,0 +1,97 @@ +--- +title: Record a livestream +description: Save the live video with the extension-recorder module's TikTokLiveRecorder listener, set the output file and stop behaviour, react to the recorder's events, and what the recorder needs from the connection. +sidebar: + order: 4 +--- + +`TikTokLiveRecorder`, in the `extension-recorder` module, is a listener that downloads the stream's video to a file while the client is connected. It needs no FFmpeg: it reads the stream URL from the room page the client fetches while connecting and writes the bytes it receives, so the output is the FLV stream as TikTok sends it. This guide is based on the repository's `RecorderExample.java`. Replace `isaackogz` with a creator who is live. + +## Install + +The module is published on JitPack next to the `Client` artifact, with the same group and version, and depends on `Client`, so it brings the client with it; see [install](/getting-started/install/) for the repository declaration. + +```xml +<dependency> + <groupId>com.github.jwdeveloper.TikTok-Live-Java</groupId> + <artifactId>extension-recorder</artifactId> + <version>1.13.1-Release</version> +</dependency> +``` + +```groovy +implementation 'com.github.jwdeveloper.TikTok-Live-Java:extension-recorder:1.13.1-Release' +``` + +## Record from connect to disconnect + +`TikTokLiveRecorder.use((recorderSettings, client) -> ...)` returns the listener; pass it to `addListener`. The consumer runs on `TikTokPreConnectionEvent`, when the room information is already filled, so the settings can depend on the room. What happens, in order: + +1. On `TikTokPreConnectionEvent` the listener runs your consumer, then reads the room page JSON from the event and picks the stream: the first rendition present in the order `origin`, `hd`, `sd`, `ld`, `hd_60`, `uhd_60`, `ao`, and its `flv` URL, together with the session id. When no URL is found it logs a warning and, with `cancelConnectionIfNotFound` set, cancels the connection, so `connect()` throws `TikTokLivePreConnectionException`. +2. On `TikTokConnectedEvent` it publishes `TikTokLiveRecorderStartedEvent`; a handler can call `setCanceled(true)` on it to skip the recording. Otherwise it starts a background task that requests the URL with the client's default headers and appends every byte received to `outputFile`, creating the parent directories first. +3. The task stops when the client's state leaves `CONNECTED` (with `stopOnDisconnect`, the default), when `TikTokDisconnectedEvent` or `TikTokLiveEndedEvent` arrives, or when TikTok closes the stream; then it logs `Stopped recording` and publishes `TikTokLiveRecorderEndedEvent`. + +The two events live in `io.github.jwdeveloper.tiktok.extension.recorder.impl.event` and have no builder method; subscribe with `onEvent`. `TikTokLiveRecorderStartedEvent` has `getDownloadData()`, with `getDownloadLiveUrl()`, `getSessionId()`, and `getFullUrl()`, the URL requested; both events have `getSettings()`. + +```java +import io.github.jwdeveloper.tiktok.extension.recorder.TikTokLiveRecorder; +import io.github.jwdeveloper.tiktok.extension.recorder.impl.event.TikTokLiveRecorderEndedEvent; +import io.github.jwdeveloper.tiktok.extension.recorder.impl.event.TikTokLiveRecorderStartedEvent; + +TikTokLive.newClient("isaackogz") + .configure(settings -> settings.setPrintToConsole(true)) + .onError((client, event) -> event.getException().printStackTrace()) + .addListener(TikTokLiveRecorder.use((recorderSettings, client) -> { + recorderSettings.setOutputFile(new File("recordings", client.getRoomInfo().getHostName() + ".flv")); + recorderSettings.setCancelConnectionIfNotFound(true); + })) + .onEvent(TikTokLiveRecorderStartedEvent.class, (client, event) -> + System.out.println("Recording " + event.getDownloadData().getFullUrl())) + .onEvent(TikTokLiveRecorderEndedEvent.class, (client, event) -> + System.out.println("Saved " + event.getSettings().getOutputFile())) + .buildAndConnect(); +``` + +The socket thread keeps the process alive while the client is connected, and the download runs on the common pool, so a `main` that ends with `buildAndConnect()` records until the stream ends or the client disconnects. + +## Settings + +`RecorderSettings` is the object the consumer receives. + +| Setter | Type | Default | Effect | +| --- | --- | --- | --- | +| `setOutputFile` | `File` | none | The file the stream is appended to. Required: without it the download task fails with a printed `NullPointerException` and no ended event. A second recording by the same listener appends to the same file. | +| `setStopOnDisconnect` | `boolean` | `true` | Stop writing as soon as the client's state is no longer `CONNECTED`, and on `TikTokDisconnectedEvent`. With `false`, the recording runs until the stream ends or TikTok closes it. | +| `setCancelConnectionIfNotFound` | `boolean` | `false` | Cancel the connection from `TikTokPreConnectionEvent` when no stream URL is found, instead of connecting without recording. | +| `setPrepareDownloadData` | `Function<String, DownloadData>` | none | Replaces the URL lookup. Receives the room page JSON and returns `new DownloadData(url, sessionId)`; `getFullUrl()` appends `_webnoredir=1&_session_id=<sessionId>` to the URL. | + +`TikTokLiveRecorder.use()` without a consumer exists, but it leaves `outputFile` unset, so use the consumer form. `LiveQuality` and `LiveFormat` enums exist in `impl.enums`, but the quality and format settings are commented out in this release: the first rendition found in the order above is used, and the output is always the FLV stream. + +## Stop a recording + +`LiveRecorder`, the type `use` returns, has no stop method. Disconnecting the client stops the recording when `stopOnDisconnect` is `true`: the download loop checks the connection state on every chunk, so it ends at the next one. While a recording runs, a further `TikTokConnectedEvent` is ignored, so one listener records one stream at a time. + +```java +import io.github.jwdeveloper.tiktok.extension.recorder.TikTokLiveRecorder; + +var client = TikTokLive.newClient("isaackogz") + .addListener(TikTokLiveRecorder.use((recorderSettings, liveClient) -> + recorderSettings.setOutputFile(new File("clip.flv")))) + .buildAndConnect(); + +Thread.sleep(60_000); +client.disconnect(); +``` + +The file is the FLV stream as received, which VLC and `ffplay` open directly. To change the container without re-encoding: + +```sh +ffmpeg -i clip.flv -c copy clip.mp4 +``` + +## Requirements and limits + +- The recorder needs the default connection path. With `useEulerstreamWebsocket`, no `TikTokPreConnectionEvent` is published, so the URL is never looked up, and the listener's `TikTokConnectedEvent` method then fails with a `NullPointerException`, reported as a `TikTokErrorEvent`; see [signing](/configuration/signing/). +- The stream URL comes from `data.liveRoom.streamData.pull_data.stream_data` in the room page. When TikTok changes that layout the lookup finds nothing and logs `Unable to find download live url!`; `setPrepareDownloadData` is the way to supply the URL yourself. +- Everything the listener logs goes through the client's logger, so it is silent unless `printToConsole` is on; see [logging](/configuration/logging/). +- The repository example sets `setFfmpegPath` in a comment; no such setter exists in this release. From 25dd9ccb3065d0ef078f67fce0c3c4b7ba843c0b Mon Sep 17 00:00:00 2001 From: isaackogan <isaacikogan@gmail.com> Date: Tue, 20 Oct 2026 17:49:44 -0400 Subject: [PATCH 11/31] docs: fix the recorder output path and gift combo lifecycle in the guides Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- docs/src/content/docs/guides/gifts.md | 4 ++-- docs/src/content/docs/guides/recorder.md | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/src/content/docs/guides/gifts.md b/docs/src/content/docs/guides/gifts.md index 78076708..bc415dea 100644 --- a/docs/src/content/docs/guides/gifts.md +++ b/docs/src/content/docs/guides/gifts.md @@ -26,7 +26,7 @@ Handlers are matched by the event's exact class, so `onGift` is never called for ## Follow a streak while it runs -`onGiftCombo` receives every frame. `getComboState()` is a `GiftComboStateType`: `Begin` on the first frame of a streak, `Active` on each later frame, and `Finished` on the final frame, on a non-streakable gift, and when a streak times out. `getCombo()` is the count so far and is cumulative, so display the latest value rather than adding the frames up. Count gifts in `onGift` or in the `Finished` branch here, not in both. +`onGiftCombo` receives every frame. `getComboState()` is a `GiftComboStateType`: `Begin` on the first frame of a streak, `Active` on each later frame, and `Finished` on the final frame, on a non-streakable gift, and when a streak times out. `getCombo()` is the count so far and is cumulative, so display the latest value rather than adding the frames up. Count gifts in `onGift`, not in the `Finished` branch here: a streakable gift whose first frame is already its final one produces no combo frame at all, so counting here misses it. ```java TikTokLive.newClient("isaackogz") @@ -42,7 +42,7 @@ TikTokLive.newClient("isaackogz") .buildAndConnect(); ``` -A streakable gift produces `Begin`, zero or more `Active` frames, then `Finished`; a non-streakable gift produces a single `Finished` frame, followed by its `TikTokGiftEvent`. Whether a gift can streak is the gift type in the message, which the handler reads before it builds the events; the `Gift` object has no field for it, so tell the two kinds apart by whether a `Begin` frame arrived. +A streakable gift produces `Begin`, zero or more `Active` frames, then `Finished`, but the handler publishes that `Finished` frame only when a `Begin` preceded it: a streakable gift whose first frame is already its final one produces no combo frame at all, only its `TikTokGiftEvent`. A non-streakable gift produces a single `Finished` frame, followed by its `TikTokGiftEvent`. Whether a gift can streak is the gift type in the message, which the handler reads before it builds the events; the `Gift` object has no field for it, so tell the two kinds apart by whether a `Begin` frame arrived. ## The gift list diff --git a/docs/src/content/docs/guides/recorder.md b/docs/src/content/docs/guides/recorder.md index 72b824e0..0b8580d4 100644 --- a/docs/src/content/docs/guides/recorder.md +++ b/docs/src/content/docs/guides/recorder.md @@ -28,7 +28,7 @@ implementation 'com.github.jwdeveloper.TikTok-Live-Java:extension-recorder:1.13. `TikTokLiveRecorder.use((recorderSettings, client) -> ...)` returns the listener; pass it to `addListener`. The consumer runs on `TikTokPreConnectionEvent`, when the room information is already filled, so the settings can depend on the room. What happens, in order: 1. On `TikTokPreConnectionEvent` the listener runs your consumer, then reads the room page JSON from the event and picks the stream: the first rendition present in the order `origin`, `hd`, `sd`, `ld`, `hd_60`, `uhd_60`, `ao`, and its `flv` URL, together with the session id. When no URL is found it logs a warning and, with `cancelConnectionIfNotFound` set, cancels the connection, so `connect()` throws `TikTokLivePreConnectionException`. -2. On `TikTokConnectedEvent` it publishes `TikTokLiveRecorderStartedEvent`; a handler can call `setCanceled(true)` on it to skip the recording. Otherwise it starts a background task that requests the URL with the client's default headers and appends every byte received to `outputFile`, creating the parent directories first. +2. On `TikTokConnectedEvent` it publishes `TikTokLiveRecorderStartedEvent`; a handler can call `setCanceled(true)` on it to skip the recording. Otherwise it starts a background task that requests the URL with the client's default headers and appends every byte received to `outputFile`, creating its parent directories first, which is why the path must include a directory component: a bare filename has no parent to create, and the task fails before writing anything. 3. The task stops when the client's state leaves `CONNECTED` (with `stopOnDisconnect`, the default), when `TikTokDisconnectedEvent` or `TikTokLiveEndedEvent` arrives, or when TikTok closes the stream; then it logs `Stopped recording` and publishes `TikTokLiveRecorderEndedEvent`. The two events live in `io.github.jwdeveloper.tiktok.extension.recorder.impl.event` and have no builder method; subscribe with `onEvent`. `TikTokLiveRecorderStartedEvent` has `getDownloadData()`, with `getDownloadLiveUrl()`, `getSessionId()`, and `getFullUrl()`, the URL requested; both events have `getSettings()`. @@ -60,7 +60,7 @@ The socket thread keeps the process alive while the client is connected, and the | Setter | Type | Default | Effect | | --- | --- | --- | --- | -| `setOutputFile` | `File` | none | The file the stream is appended to. Required: without it the download task fails with a printed `NullPointerException` and no ended event. A second recording by the same listener appends to the same file. | +| `setOutputFile` | `File` | none | The file the stream is appended to; its parent directories are created. Required, and the path must include a directory component, such as `new File("recordings", "clip.flv")`: without a file, or with a bare filename such as `new File("clip.flv")`, the download task fails with a printed `NullPointerException` and no ended event. A second recording by the same listener appends to the same file. | | `setStopOnDisconnect` | `boolean` | `true` | Stop writing as soon as the client's state is no longer `CONNECTED`, and on `TikTokDisconnectedEvent`. With `false`, the recording runs until the stream ends or TikTok closes it. | | `setCancelConnectionIfNotFound` | `boolean` | `false` | Cancel the connection from `TikTokPreConnectionEvent` when no stream URL is found, instead of connecting without recording. | | `setPrepareDownloadData` | `Function<String, DownloadData>` | none | Replaces the URL lookup. Receives the room page JSON and returns `new DownloadData(url, sessionId)`; `getFullUrl()` appends `_webnoredir=1&_session_id=<sessionId>` to the URL. | @@ -76,7 +76,7 @@ import io.github.jwdeveloper.tiktok.extension.recorder.TikTokLiveRecorder; var client = TikTokLive.newClient("isaackogz") .addListener(TikTokLiveRecorder.use((recorderSettings, liveClient) -> - recorderSettings.setOutputFile(new File("clip.flv")))) + recorderSettings.setOutputFile(new File("recordings", "clip.flv")))) .buildAndConnect(); Thread.sleep(60_000); From 05718bd202b1a02784c58905db71f897e412c07b Mon Sep 17 00:00:00 2001 From: isaackogan <isaacikogan@gmail.com> Date: Tue, 20 Oct 2026 18:10:37 -0400 Subject: [PATCH 12/31] docs: write advanced, troubleshooting, contributing, and hosting pages Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- collaboration.md | 29 ++++- docs/README.md | 44 +++++++ docs/src/content/docs/advanced/http-client.md | 105 +++++++++++++++ docs/src/content/docs/advanced/protocol.md | 77 +++++++++++ docs/src/content/docs/advanced/websocket.md | 122 ++++++++++++++++++ docs/src/content/docs/contributing.md | 86 +++++++++++- docs/src/content/docs/hosting.md | 85 +++++++++++- docs/src/content/docs/troubleshooting.md | 113 +++++++++++++++- 8 files changed, 654 insertions(+), 7 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/src/content/docs/advanced/http-client.md create mode 100644 docs/src/content/docs/advanced/protocol.md create mode 100644 docs/src/content/docs/advanced/websocket.md diff --git a/collaboration.md b/collaboration.md index 42de7242..a9ec7a34 100644 --- a/collaboration.md +++ b/collaboration.md @@ -200,4 +200,31 @@ need to also include it to interface `EventsBuilder` Finally we are good to go, our event has been included! -![image](https://github.com/jwdeveloper/TikTokLiveJava/assets/79764581/36ad6f1f-b38c-4cf7-93bd-b4cc0638cba0) \ No newline at end of file +![image](https://github.com/jwdeveloper/TikTokLiveJava/assets/79764581/36ad6f1f-b38c-4cf7-93bd-b4cc0638cba0) + + +### Documentation + +The documentation site at https://ttlj.docs.isaackogan.com/ is built from the `docs/` directory +of this repository: a Starlight project whose reference pages (`api/`, `schema/`) are generated +from the `API` and `Client` sources and the `.proto` files, and whose authored pages are Markdown +under `docs/src/content/docs/`. It needs JDK 17 or newer, Maven, Node.js 24, and pnpm 10.34.2. +From the repository root: + +``` +npx -y pnpm@10.34.2 --dir docs install --frozen-lockfile +npx -y pnpm@10.34.2 --dir docs build +``` + +`build` builds the library modules, packages the tool under `docs/tools/`, runs the content +check, generates the reference pages, and builds the site; `pnpm --dir docs dev` serves it +locally after `pnpm --dir docs generate`. The content check fails the build when an event, +settings field, builder handler, exception, or extension is not named on a page, when a page +links to Discord, when the README's events list and the `EventsBuilder` handlers disagree, or +when a Java example does not compile. So when you add an event as described above, also add its +catalog row on `docs/src/content/docs/events/index.md` and a section on the grouped event page. +Never commit the generated `api/` and `schema/` directories; they are ignored by Git. + +The full rules are on the site's contributing page, https://ttlj.docs.isaackogan.com/contributing/, +and `docs/README.md` lists the commands. The site is deployed to Cloudflare Workers by +`.github/workflows/docs.yml`; see https://ttlj.docs.isaackogan.com/hosting/. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 00000000..40adcb14 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,44 @@ +# Documentation + +The site uses Astro Starlight, with reference pages generated from the library sources and the `.proto` files by the Maven tool under `tools/`, and internal links validated by Starlight Links Validator. Its Node dependencies are isolated from the library build. + +Use JDK 17 or newer, Maven, Node.js 24, and pnpm 10.34.2 (`npx -y pnpm@10.34.2` runs the pinned version without a global install). From the repository root: + +```sh +npx -y pnpm@10.34.2 --dir docs install --frozen-lockfile +pnpm --dir docs lib:build +pnpm --dir docs tools:build +pnpm --dir docs generate +pnpm --dir docs dev +``` + +`lib:build` builds the `API`, `Client`, `extension-recorder`, and `extension-collector` modules and writes their classpaths (`mvn -q -pl API,Client,extension-recorder,extension-collector -am -DskipTests install dependency:build-classpath -Dmdep.outputFile=target/classpath.txt`); the content check compiles every Java example against them. `tools:build` packages the tool as `tools/target/docs-tools.jar`. + +Validate and build: + +```sh +pnpm --dir docs check +pnpm --dir docs build +pnpm --dir docs preview +pnpm --dir docs test +mvn -q -f docs/tools/pom.xml test +``` + +Authored pages live in `src/content/docs/`. The `api/` and `schema/` directories there are generated on every build from the `API` and `Client` sources and from `API/src/main/proto`; they are ignored by Git. Do not edit the generated Markdown; change the Javadoc at the source declaration or the `.proto` file instead. + +`check` runs the tool's content check, which compares the pages with the code in eight sections (`events`, `settings`, `builder`, `exceptions`, `extensions`, `discord`, `readme`, `examples`) and compiles every Java fence: a fence with a top-level type declaration as its own file, any other fence as a fragment wrapped in a method with wildcard imports of the library packages. `generate` writes the reference pages. `build` runs `lib:build`, `tools:build`, `check`, `generate`, then `astro build`, which validates links and builds the search index. + +Facts come from the library source first, then the README, then `examples/`. Java fences must compile, shell commands use `sh` fences, pages carry no Discord links, links to Euler Stream are plain Markdown links, and the generated directories are never committed. See the site's contributing page for the full rules. + +## Hosting + +The site is served from Cloudflare Workers static assets at <https://ttlj.docs.isaackogan.com/>. `wrangler.jsonc` in this directory declares the asset directory and the custom domain for the `tiktoklivejava-docs` Worker; there is no Worker script. The GitHub workflow `.github/workflows/docs.yml` validates every pull request, runs the SEO gate (`pnpm --dir docs run seo`, which runs `scripts/verify-seo.mjs` over `dist`), and on pushes to `master` deploys when the repository has the `CLOUDFLARE_API_TOKEN` secret; the deploy also needs `CLOUDFLARE_ACCOUNT_ID`. A daily scheduled run checks out the upstream repository's `master` and deploys it, so the hosted site tracks upstream from the fork that holds the secrets. + +To deploy from a checkout after the installs above: + +```sh +pnpm --dir docs build +pnpm --dir docs run deploy:cloudflare +``` + +See [Hosting](src/content/docs/hosting.md) for the configuration, secrets, and verification steps. `DOCS_SITE` defaults to the custom domain and `DOCS_BASE` to `/`; neither needs to be set. diff --git a/docs/src/content/docs/advanced/http-client.md b/docs/src/content/docs/advanced/http-client.md new file mode 100644 index 00000000..cbda4bad --- /dev/null +++ b/docs/src/content/docs/advanced/http-client.md @@ -0,0 +1,105 @@ +--- +title: HTTP client and mappers +description: The requests TikTokLiveJava makes to TikTok and to the signing provider, how each request is built and retried, the response mappers, and the message-mapping layer that turns Webcast messages into events. +sidebar: + order: 1 +--- + +Two layers sit between the socket and your handlers. `LiveHttpClient`, implemented by `TikTokLiveHttpClient`, makes the HTTP requests: the creator's user data, the room information, the gift list, the signing request to [Euler Stream](https://www.eulerstream.com/), and the chat endpoint. `LiveMapper`, implemented by `TikTokLiveMapper`, turns each Webcast message into events. `connect()` drives both in a fixed order, described on the [connection page](/getting-started/connection/#the-connect-sequence); this page describes what they do so you can call them yourself or change what they produce. + +## The requests + +`TikTokLive.requests()` returns a `LiveHttpClient` with default settings, and `TikTokLive.requests(Consumer<LiveClientSettings>)` one configured the way a client is, so the requests go through your proxy, headers, and timeout; see [HTTP settings](/configuration/http-settings/#requests-outside-a-client). The interface has a `String` overload and a `Request` overload for each of the three TikTok requests. + +| Method | Request | Returns | +| --- | --- | --- | +| `fetchLiveUserData(userName)` | `GET https://www.tiktok.com/api-live/user/room` with `uniqueId` and `sourceType=54`, plus the `sessionid` and `tt-target-idc` cookies when set | `LiveUserData.Response`: `getUserStatus()` (`Live`, `LivePaused`, `Offline`, `NotFound`), `getRoomInfo()` with the room id, title, start time, viewer counts, age restriction, and host, `isLiveOnline()`, `isHostNameValid()`, and `getJson()` | +| `fetchLiveData(roomId)` | `GET https://webcast.tiktok.com/webcast/room/info` with `room_id` and the same cookies | `LiveData.Response`: `getLiveStatus()` (`HostOnline`, `HostPaused`, `HostOffline`, `HostNotFound`), `isAgeRestricted()`, `getTitle()`, `getLikes()`, `getViewers()`, `getTotalViewers()`, `getHost()`, `getLiveType()` (`SOLO`, `BOX`, `CO_HOST`, `BATTLE`), and `getJson()` | +| `fetchRoomGiftsData(roomId)` | `GET https://webcast.tiktok.com/webcast/gift/list/` with `room_id`, no cookies | `GiftsData.Response`: `getGifts()`, a `List<Gift>` with id, name, diamond cost, picture, and the gift's JSON object, and `getJson()`; a body without `data.gifts` gives an empty list | +| `fetchLiveConnectionData(roomId)` | `GET https://tiktok.eulerstream.com/webcast/fetch` with `client=ttlive-java`, `room_id`, `session_id` when set, and the `x-api-key` header when set | `LiveConnectionData.Response`: `getWebsocketCookies()`, the `x-set-tt-cookie` header; `getWebsocketUrl()`, built from the response's push server, `room_id`, `cursor`, `resp_content_type=protobuf`, `internal_ext`, and the route parameters; `getWebcastResponse()`, the parsed `ProtoMessageFetchResult` | +| `sendChat(roomInfo, content, sessionId, ttTargetIdc)` | `POST https://tiktok.eulerstream.com/webcast/chat` with a JSON body of `content`, `sessionId`, `ttTargetIdc`, and `roomId`, and the `x-api-key` header when set | `true` when the provider answered 200; `false` otherwise, without an exception | + +With `useEulerstreamEnterprise` the two provider requests go to `tiktok.enterprise.eulerstream.com` instead; see [signing](/configuration/signing/#enterprise-endpoints). Every request carries the parameters, headers, and cookies of the [HTTP settings](/configuration/http-settings/), so `app_language`, `aid`, the `User-Agent`, and the rest of TikTok's web-client parameters are on each URL and request. + +```java +var requests = TikTokLive.requests(settings -> settings.getHttpSettings().setTimeout(Duration.ofSeconds(10))); +var userData = requests.fetchLiveUserData("isaackogz"); +System.out.println("Status: " + userData.getUserStatus()); + +if (userData.getUserStatus() == LiveUserData.UserStatus.Live) { + var roomId = userData.getRoomInfo().getRoomId(); + var liveData = requests.fetchLiveData(roomId); + System.out.println(liveData.getTitle() + " (" + liveData.getLiveType() + "): " + liveData.getViewers() + " viewers"); + + var gifts = requests.fetchRoomGiftsData(roomId); + System.out.println(gifts.getGifts().size() + " gifts in the room's list"); +} +``` + +### Errors + +A TikTok request that answers with a status other than 200, or that fails in transport, throws `TikTokLiveRequestException`; the signing request throws `TikTokSignServerException`, a subclass, for a non-200 status, a response without the `x-set-tt-cookie` header, or a body that is not a `ProtoMessageFetchResult`. The message carries the status and a note chosen by the status code: `429` reads `Sign server rate limit reached`, with the wait computed from the `ratelimit-reset` header when present; `420` reads `IP Cloudflare Blocked`; `500` to `503` read `Sign server Error`; `504` reads `Sign server Timeout`. The notes are chosen in the shared `HttpClient`, so a 429 from TikTok's own API carries the same `Sign server` wording as one from the provider. The status table is on the [signing page](/configuration/signing/#rate-limits-and-provider-errors). + +The response mappers add their own failures. `LiveUserDataMapper` throws `TikTokLiveRequestException` when TikTok answers `params_error`, returns `NotFound` with a `null` room info when it answers `user_not_found` or with no `data`, and, when the body is not the JSON it expects, logs `Malformed Json` at `WARNING` and returns `NotFound` as well; a captcha or block page therefore surfaces as an unknown host. `LiveDataMapper` throws `TikTokLiveRequestException` when the body is not a JSON object or has no `data` section. The [troubleshooting page](/troubleshooting/) lists every exception. + +### Proxies + +Each `fetch` method and `sendChat` wrap their request in a loop: while the proxy list is not empty, send through the next endpoint and, on `TikTokProxyRequestException`, try again; after the loop, send once without a proxy. The signing request also repeats on `TikTokSignServerException`. Inside one attempt, `HttpProxyClient` handles the endpoint selection, the discard of failed endpoints, and the fallback; with `type` `HTTP` or `DIRECT`, a response other than 200 moves on to the next endpoint without removing the current one, so a provider that keeps answering 429 through working proxies is retried in rotation until the list empties or the response changes. See [proxies and host](/configuration/proxies-and-host/#how-a-request-uses-the-list). + +## How a request is built + +`HttpClientFactory` holds the client's `LiveClientSettings` and the one `java.net.http.HttpClient` behind every request. `client(url)` returns an `HttpClientBuilder` over a `clone()` of the HTTP settings; `withParam`, `withParams`, `withHeader`, `withHeaders`, `withCookie` (which skips a `null` name or value), `withBody`, `withUrl`, and `withHttpClientSettings` edit that copy, and `build()` returns an `HttpProxyClient` when proxies are enabled and the list is not empty, otherwise an `HttpClient`. + +`HttpClient.prepareRequest()` assembles the `HttpRequest`: `POST` when a body was given, the URL with the parameters URL-encoded and sorted as its query string (`toUri()`), the settings' `timeout` as the request timeout, the cookies joined into one `Cookie` header when the map is not empty, every header, and finally the `onRequestCreating` hook. `toHttpResponse(handler)` sends it and wraps the response in an `ActionResult`, a success for status 200 and a failure with the note above for anything else; `toJsonResponse()` and `toBinaryResponse()` are the `String` and `byte[]` forms. The JDK client itself is created once, on the first request: it follows redirects, keeps cookies in a `CookieManager`, uses `timeout` as its connect timeout, installs a `RotatingProxySelector` when proxies are enabled with a type other than `SOCKS`, and runs the `onClientCreating` hook. The selector binds the endpoint chosen for a request to the sending thread for the duration of that one `send`, which is how a single JDK client rotates proxies per request. + +These classes are public in `io.github.jwdeveloper.tiktok.client.http`, so a request the client does not make can use the same machinery: + +```java +import io.github.jwdeveloper.tiktok.client.http.HttpClientBuilder; + +var settings = LiveClientSettings.createDefault().getHttpSettings(); +var result = new HttpClientBuilder("https://www.tiktok.com/api-live/user/room", settings) + .withParam("uniqueId", "isaackogz") + .withParam("sourceType", "54") + .build() + .toJsonResponse(); + +if (result.isSuccess()) { + System.out.println(result.getContent()); +} else { + System.out.println("Failed: " + result); +} +``` + +`new HttpClientBuilder(url)` without settings starts from an empty `HttpClientSettings`, with no parameters and no headers; `LiveClientSettings.createDefault().getHttpSettings()` gives the client's defaults. + +## Response mappers + +The JSON bodies are turned into the response objects by static methods in `io.github.jwdeveloper.tiktok.client.http.mappers`: `LiveUserDataMapper.map` reads the user, status, and `liveRoom` sections of the user request, `LiveDataMapper.map` reads the room information (its `getUser` builds the host as a `User` with the `LiveHost` attribute), and `GiftsDataMapper.mapRoom` reads the gift list, replacing a `.webp` picture URL with `.png`. `HttpRequestJsonMapper` and `HttpResponseJsonMapper` are Gson serializers for the JDK's `HttpRequest` and `HttpResponse`, for printing a request as JSON. + +`LiveUserDataMapper.mapEulerstream` is the mapper of the Euler Stream WebSocket's `roomInfo` message. It fills the room id, title, start time, viewer count, and host, and reads the host's id, username, nickname, signature, avatar, and follower counts; it sets no total viewer count and no age restriction, and it writes the message's `totalViewers` into the viewer count as well. The [WebSocket page](/advanced/websocket/#the-euler-stream-socket) describes when it runs. + +## The message mappers + +Once the socket delivers a batch, `TikTokLiveMessageHandler` publishes `TikTokWebsocketResponseEvent` with the whole `ProtoMessageFetchResult` and handles each message in turn. A message whose `method` has no registered mapping is published as `TikTokWebsocketUnhandledMessageEvent`; a registered one goes through `LiveMapper.handleMapping(name, bytes)`, and each event it returns is published after a `TikTokWebsocketMessageEvent` that wraps it. An exception during one message is caught, wrapped in `TikTokLiveMessageException` with the message and the batch, and published as `TikTokErrorEvent`; the handler then continues with the next message. `publishMessage` on the client calls the same single-message path without a batch event and lets the exception propagate. + +Three interfaces in `io.github.jwdeveloper.tiktok.api.mappers` make up the layer: + +- `LiveMapper` keeps one `TikTokMapperModel` per message name. `forMessage(name)` or `forMessage(WebcastXMessage.class)` creates or returns the model, keyed by the class's simple name; the overloads with a `MappingAction` or a `Function<byte[], TikTokEvent>` set its mapping at the same time. `forAnyMessage()` returns the global model, `isRegistered` says whether a name has a model, and `handleMapping` runs the pipeline below. +- `TikTokMapperModel` has three hooks: `onBeforeMapping(MappingAction<byte[]>)` receives the bytes and returns the bytes to map; `onMapping(MappingAction<MappingResult>)` deserialises and builds the events; `onAfterMapping(Function<MappingResult, List<TikTokEvent>>)` receives the result and returns the events to publish. `getSourceMessageName()` is the key. A `MappingAction` receives the bytes, the message name, and the helper. +- `LiveMapperHelper` deserialises: `bytesToWebcastObject(bytes, WebcastXMessage.class)` and `bytesToWebcastObject(bytes, name)`, which looks the class up in `io.github.jwdeveloper.tiktok.api.messages.webcast`; `isMessageHasProtoClass(name)`; `bytesToProtoBufferStructure(bytes)`, which decodes any payload into a `ProtoBufferObject` without a schema; and `toJson(object)`. + +`handleMapping` runs, in order, the message's `onBeforeMapping`, the global `onBeforeMapping`, the message's `onMapping`, the global `onMapping` only when the message's returned `null`, the message's `onAfterMapping`, and the global `onAfterMapping`. A model created by `forMessage(name)` alone maps to no events until `onMapping` is set, and the defaults of the hooks pass the bytes and the result through unchanged. + +```java +TikTokLive.newClient("isaackogz") + .mappings(mapper -> mapper.forAnyMessage().onBeforeMapping((bytes, messageName, helper) -> { + System.out.println(messageName + ": " + bytes.length + " bytes"); + return bytes; + })) + .buildAndConnect(); +``` + +The implementations live in `io.github.jwdeveloper.tiktok.client.mappers`. `MessagesMapperFactory.create` registers the default mappings: the control, poll, and pin messages go to `TikTokCommonEventHandler`, the live-intro, room-user, like, and member messages to `TikTokRoomInfoEventHandler`, the gift message to `TikTokGiftEventHandler`, the social message to `TikTokSocialMediaEventHandler`, and the chat, caption, subscription, emote, question, barrage, battle, and link messages are mapped inline, the link message by a `switch` on its type. The registrations of several other messages are commented out in the factory or absent, and that `switch` has no case for the apply and reply types, which is why the default client never produces the sixteen [unmapped events](/events/#unmapped-events). `mappings(mapper -> ...)` on the builder runs after the factory, so it can add to or replace any of them; see [custom events and mappings](/guides/custom-events/). + +`TikTokLiveMapperHelper` is the helper; it throws `TikTokMessageMappingException` when a class has no `parseFrom` method or the bytes do not parse (the message reads `can't find parsing method` in both cases) and when a name has no class in the webcast package. `TikTokGenericEventMapper`, which it uses to look up `parseFrom`, also has a `mapToEvent(messageClass, eventClass, bytes)` method that finds the event constructor taking the message by reflection; nothing in this release calls it, so there is no name-based fallback from `WebcastXMessage` to `TikTokXEvent`. diff --git a/docs/src/content/docs/advanced/protocol.md b/docs/src/content/docs/advanced/protocol.md new file mode 100644 index 00000000..aa47db32 --- /dev/null +++ b/docs/src/content/docs/advanced/protocol.md @@ -0,0 +1,77 @@ +--- +title: Protocol +description: The Webcast protobuf schema TikTokLiveJava is built from, the transport messages, how a message name becomes an event class, how to decode and replay a payload, and the difference between a schema definition and verified behaviour. +sidebar: + order: 3 +--- + +TikTok's Webcast protocol is not documented by TikTok. TikTokLiveJava carries its own description of it as three proto3 files in `API/src/main/proto/`, and the `API` module's Maven build generates the Java classes from them with `protoc` (`protoc-jar-maven-plugin`, protoc 4.26.1), so the classes are in `API/target/generated-sources` rather than in `src/`. The [schema reference](/schema/) is generated from the same files on every documentation build. + +| File | Contents | Java package | +| --- | --- | --- | +| `webcast.proto` | 39 top-level messages: the transport messages below and the `Webcast*` messages that become events; imports the other two files | `io.github.jwdeveloper.tiktok.api.messages.webcast` | +| `data.proto` | 100 messages for the nested data: `User`, `CommonMessageData`, `GiftStruct`, the battle, link, and badge structures | `io.github.jwdeveloper.tiktok.api.messages.data` | +| `enums.proto` | 94 enums | `io.github.jwdeveloper.tiktok.api.messages.enums` | + +Each file sets `java_multiple_files`, so every message is its own class, and nested messages are inner classes: `ProtoMessageFetchResult.BaseProtoMessage`, `WebcastBarrageMessage.BarrageType`. + +## Transport messages + +Three messages carry everything else. The [WebSocket page](/advanced/websocket/#frames) describes when each is sent and received. + +| Message | Fields | Role | +| --- | --- | --- | +| [`WebcastPushFrame`](/schema/webcast/webcastpushframe/) | `SeqId`, `LogId`, `Service`, `Method`, `headers`, `PayloadEncoding`, `PayloadType`, `Payload` | One WebSocket message in either direction. Incoming frames carry a batch in `Payload`; the client sends `ack` frames and `hb` heartbeats as frames with no batch. | +| [`ProtoMessageFetchResult`](/schema/webcast/protomessagefetchresult/) | `messages`, `cursor`, `fetchInterval`, `now`, `internalExt`, `fetchType`, `routeParamsMap`, `heartBeatDuration`, `needsAck`, `pushServer`, `isFirst`, `historyCommentCursor`, `historyNoMore` | A batch of messages: the body of the signing response from [Euler Stream](https://www.eulerstream.com/), whose `pushServer`, `cursor`, `internalExt`, and `routeParamsMap` build the WebSocket URL, and the payload of every incoming frame. `TikTokWebsocketResponseEvent.getResponse()` is one of these. | +| [`BaseProtoMessage`](/schema/webcast/protomessagefetchresult-baseprotomessage/) | `method`, `payload`, `msgId`, `msgType`, `offset`, `isHistory` | One message in a batch: the name of its type and its serialized bytes. `TikTokWebsocketMessageEvent.getMessage()` and `TikTokWebsocketUnhandledMessageEvent.getMessage()` are these. | + +## From a message to an event + +`method` is the name of a message type, `WebcastGiftMessage` for example, and `payload` is that message serialized. The name matches the generated class in the webcast package, which is how `LiveMapperHelper.bytesToWebcastObject(bytes, name)` finds a class for a name and how `isMessageHasProtoClass` answers. The mappings `MessagesMapperFactory` registers are keyed by the same names; a message whose name has no mapping is published as `TikTokWebsocketUnhandledMessageEvent` with the raw `BaseProtoMessage`, which is where a type the library does not know, or one of the sixteen [unmapped events](/events/#unmapped-events), ends up. The [HTTP client and mappers page](/advanced/http-client/#the-message-mappers) describes the pipeline. + +An event class wraps its message: `TikTokGiftEvent` is constructed from a `WebcastGiftMessage`, `TikTokCommentEvent` from a `WebcastChatMessage`, and so on, and the `Webcast*` messages that become events share a `CommonMessageData common = 1` field, from which `TikTokHeaderEvent` takes `getMessageId()` (`msgId`), `getRoomId()`, and `getTimeStamp()` (`createTime`). The event's other getters read the message's fields and convert the nested protobuf objects, such as the `User` and `GiftStruct` messages, into the library's own `User` and `Gift` models. The [event catalog](/events/) names the message behind each event and links its schema page; the generated [API reference](/api/) has every event's getters. + +## Decode a message + +The generated classes have the standard protobuf-java API: `parseFrom(byte[])` and `parseFrom(ByteString)` decode, `toByteArray()` encodes, and `newBuilder()` builds a message by hand. An event class takes the decoded message, so a payload can be turned into the same event the client would publish. For bytes whose type the library has no class for, `ProtocolUtils.getProtocolBufferStructure(bytes)` decodes the wire format without a schema into a `ProtoBufferObject`, whose `toJson()` lists field numbers, wire types, and values and whose `toProtoFile()` drafts a `.proto` definition. + +```java +import com.google.protobuf.InvalidProtocolBufferException; +import io.github.jwdeveloper.tiktok.api.messages.webcast.WebcastChatMessage; + +var message = WebcastChatMessage.newBuilder().setContent("hello").build(); +byte[] payload = message.toByteArray(); + +try { + var decoded = WebcastChatMessage.parseFrom(payload); + var event = new TikTokCommentEvent(decoded); + System.out.println(event.getText() + " from user id " + event.getUser().getId()); + System.out.println(ProtocolUtils.getProtocolBufferStructure(payload).toJson()); +} catch (InvalidProtocolBufferException e) { + System.err.println("Not a WebcastChatMessage: " + e.getMessage()); +} +``` + +A payload captured from a live session is handled the same way. `ProtocolUtils.toBase64(bytes)` and `fromBase64(text)` convert payloads for a report or a fixture, `toBase64(BaseProtoMessage)` encodes a whole message with its name, and `fromBase64ToMessage(text)` reads one back. `publishMessage(name, payloadBase64)` on any client, including an offline one, runs a payload through the mapping as if it had arrived on the socket; see [replay recorded messages](/guides/offline-testing/#replay-recorded-messages) and the [debugging guide](/guides/debugging/#decode-a-payload) for capturing them. + +Fields keep their protobuf types and defaults: an absent nested message reads as its default instance, not `null`, an absent string is empty, and an absent number is zero. The event models convert on top of that, so a user TikTok did not send has id `0` and an empty name. Enums are the generated enum classes, such as `ControlAction` on `WebcastControlMessage`; read them by name rather than by number in your own code, since the schema can be renumbered between releases. + +## Schema definitions and verified behaviour + +A message in the schema proves that TikTok's protocol can carry it and that this version can decode it. It does not prove that every room, region, or stream type sends it, that it arrives at the moment its name suggests, or what an undocumented integer field means. The schema defines messages for which no event exists, and event classes exist for messages the default mapper never registers; the grouped event pages say what each mapping produces, and a field that is always empty in your rooms may be filled elsewhere. Two mappings in the source carry a `not working` note from the maintainers, the poll events and `TikTokShopEvent`; see [room and display](/events/room-and-display/) and [games and commerce](/events/games-and-commerce/). + +TikTok changes the protocol without notice. When a message stops arriving, a field moves, or a new name shows up in `TikTokWebsocketUnhandledMessageEvent`, compare with the latest release before assuming a bug in your code, and read the schema page of the message rather than the README for the current field list. + +## Report a protocol issue + +For a message the library does not know, or one it decodes wrongly, open an issue on the [TikTokLiveJava repository](https://github.com/jwdeveloper/TikTokLiveJava/issues) with the message's `method`, one or more Base64 payloads, the library version (`1.13.1-Release` for these pages), and what was happening in the stream. The payload is what lets the message be decoded and a schema written; a name alone is not enough. This handler prints both for every unmapped message: + +```java +TikTokLive.newClient("isaackogz") + .onWebsocketUnhandledMessage((client, event) -> + System.out.println(event.getMessage().getMethod() + " " + + ProtocolUtils.toBase64(event.getMessage().getPayload().toByteArray()))) + .buildAndConnect(); +``` + +Base64 is an encoding, not anonymization: a payload can contain usernames, ids, and other personal data, so review it before posting. See [troubleshooting](/troubleshooting/#report-a-reproducible-issue) for what else a report needs. diff --git a/docs/src/content/docs/advanced/websocket.md b/docs/src/content/docs/advanced/websocket.md new file mode 100644 index 00000000..ad918b07 --- /dev/null +++ b/docs/src/content/docs/advanced/websocket.md @@ -0,0 +1,122 @@ +--- +title: WebSocket layer +description: The socket clients behind connect(), how the signed URL becomes a connection, the push frames, acknowledgements, and heartbeats, how a connection ends, the Euler Stream and offline sockets, and reading the frames without the client. +sidebar: + order: 2 +--- + +`LiveSocketClient` is the interface `TikTokLiveClient` drives: `start(connectionData, liveClient)` opens the connection, `stop(LiveClientStopType)` closes it, and `isConnected()` reports whether the socket is open. `build()` picks the implementation from the settings: `TikTokWebSocketClient` by default, `TikTokWebSocketEulerClient` with `useEulerstreamWebsocket`, and `TikTokWebSocketOfflineClient` with `offline`. The sockets are [Java-WebSocket](https://github.com/TooTallNate/Java-WebSocket) 1.5.5 clients, a compile-scope dependency of the `Client` module. + +## Connection sequence + +`TikTokWebSocketClient.start` runs on the thread that called `connect()`, after the signing request to [Euler Stream](https://www.eulerstream.com/) has returned a `LiveConnectionData.Response`: + +1. `stop(LiveClientStopType.NORMAL)` closes any socket the client still holds, so a second `start` replaces the first. +2. The messages already in the signing response are handed to the message handler, so handlers registered before `connect()` receive them before the socket exists. +3. A `TikTokWebSocketListener` is created for the signed URL with the HTTP settings' headers plus a `Cookie` header holding the cookies from the signing response, and the `timeout` of the HTTP settings as its connect timeout, after `toMillisPart()`: a whole-second timeout, such as the default, gives `0`, which is no timeout. See [the timeout and sub-second parts](/configuration/http-settings/#the-timeout-and-sub-second-parts). +4. With proxies enabled and `allowWebsocket` left `true`, the listener connects through the proxy list, as the [proxies page](/configuration/proxies-and-host/#the-websocket) describes; otherwise it connects directly. Java-WebSocket's `connect()` starts a non-daemon thread named `WebSocketConnectReadThread-<id>` and returns at once. +5. `connect()` sets the state to `CONNECTED` and publishes `TikTokRoomInfoEvent` without waiting for the handshake. `TikTokConnectedEvent` is published from the socket thread when the handshake completes. + +A handshake that fails is reported on the socket thread rather than thrown from `connect()`. When the host does not resolve or the TCP or TLS connection fails, the listener's `onError` publishes `TikTokErrorEvent` with the exception, then its `onClose` runs with code `-1`, the client's state goes to `DISCONNECTED`, and `TikTokDisconnectedEvent` is published with that code, for which `isUnknownCloseCode()` is `true`. When TikTok answers the upgrade request with an HTTP error instead, Java-WebSocket closes with its protocol-error code `1002` through the same `onClose` path. `connect()` has already returned normally in both cases. + +```java +TikTokLive.newClient("isaackogz") + .onError((client, event) -> System.err.println("Socket error: " + event.getException())) + .onDisconnected((client, event) -> { + if (event.isUnknownCloseCode()) { + System.err.println("The connection never opened or the connect attempt failed: " + event.getReason()); + } else { + System.out.println("Closed with code " + event.getCode()); + } + }) + .buildAndConnect(); +``` + +## Frames + +Every binary message from TikTok is a [`WebcastPushFrame`](/schema/webcast/webcastpushframe/), whose `Payload` is a [`ProtoMessageFetchResult`](/schema/webcast/protomessagefetchresult/), the same batch type the signing response carries. The listener parses the frame, drops frames with an empty payload, parses the batch, and hands it to the message handler. A frame or payload that does not parse throws `TikTokProtocolBufferException`, which carries the bytes in `getBytes()`; `onMessage` catches it and every other exception from handling and publishes `TikTokErrorEvent`. There is no decompression step: frames are parsed as received. + +When a batch has `needsAck` set, the listener sends an acknowledgement before handling it: a `WebcastPushFrame` with `PayloadType` `ack`, the incoming frame's `LogId`, and the batch's `internalExt` as the payload. After every message, after the handshake, and after an error the listener also sends a WebSocket ping frame through Java-WebSocket's `sendPing()`, when the socket is open. Text frames are ignored on the TikTok socket. + +## Heartbeats + +`WebSocketHeartbeatTask` sends the heartbeat. `onOpen` starts it with `pingInterval` from the client settings, `10000` milliseconds by default and at least `250`: a task on the daemon `heartbeat-pool` scheduler sends a fixed frame at once and then at that interval while the socket is open. The frame is the eight bytes `MgJwYjoCaGI=` in Base64, a `WebcastPushFrame` with `PayloadEncoding` `pb` and `PayloadType` `hb` and no payload. The task stops when the socket is no longer open, when `stop` is called, and when the socket closes; `start` replaces it with the new socket's task. The Euler Stream socket does not run it. + +## How a connection ends + +`stop(type)` cancels the heartbeat and detaches the socket from the client before closing it, so the close that follows is not treated as the current socket's. A socket whose handshake has not completed is closed with code `1000` without waiting. An open socket is closed according to the type: `NORMAL` (what `disconnect()` uses) starts the close handshake and returns; `CLOSE_BLOCKING` waits for it, and throws `TikTokLiveException` with the message `Failed to stop the websocket` if the wait is interrupted; `DISCONNECT` drops the connection with no handshake. The types are on the [connection page](/getting-started/connection/#disconnect). + +When the socket reports its close, the listener's `onClose` checks the socket against the client's. If it is still the client's, the client's `disconnect()` runs, which sets the state to `DISCONNECTED`, and `TikTokDisconnectedEvent` is published with the close code and reason. If `stop` had already detached it, your own `disconnect()` for instance, the event is published without a second `disconnect()`. A socket replaced by a newer `start` publishes nothing. The code is `1000` after a normal close from either side, `1006` when the connection dropped without a close frame, `1002` when TikTok rejected the handshake, and `-1` when the connection never opened. The client does not reconnect on its own; the [retry section](/getting-started/connection/#retry-when-the-creator-is-offline) shows a loop that does. `TikTokLiveEndedEvent` is a message, not a close: the socket stays open until you call `disconnect()` or TikTok closes it, see [stream control](/events/stream-control/). + +## The Euler Stream socket + +With `useEulerstreamWebsocket`, `TikTokWebSocketEulerClient.start` ignores the connection data, which is `null` on that path, and opens `wss://ws.eulerstream.com?uniqueId=<hostName>&apiKey=<apiKey>&features.rawMessages=true`, plus `&features.useEnterpriseApi=true` with `useEulerstreamEnterprise`, with the HTTP settings' headers and the same `toMillisPart()` timeout. It uses no proxy and no heartbeat task. The listener is a `TikTokWebSocketEulerListener`, which inherits the binary-frame handling above and adds text frames: a JSON object whose `messages` array holds a `workerInfo` message, logged at `INFO`, and a `roomInfo` message, mapped with `LiveUserDataMapper.mapEulerstream`, copied into `getRoomInfo()`, and published as the first `TikTokRoomInfoEvent`. Any other text frame is reported as `TikTokErrorEvent`. These two messages arrive after the handshake, so `getRoomInfo()` is empty when `TikTokConnectedEvent` fires on this path; the fields the message fills are listed on the [HTTP client page](/advanced/http-client/#response-mappers). + +`stop` on this client closes the socket only when it is already open; a socket still in its handshake is dropped from the client without being closed. The Euler path is described from the settings side on the [signing page](/configuration/signing/#euler-stream-websocket). + +## The offline socket + +With `offline`, `TikTokWebSocketOfflineClient.start` publishes `TikTokConnectedEvent` at once and `stop` publishes `TikTokDisconnectedEvent` with the reason `Stopping` and code `-1`; `isConnected()` is always `false`. In this release `connect()` fails before `start` is reached, see [offline mode](/configuration/client-settings/#offline-mode), so these events only come from `disconnect()` or from your own `publishEvent`. The [offline testing guide](/guides/offline-testing/) covers what the mode is for. + +## Read the frames without the client + +The pieces are public, so a program can fetch the connection data with `TikTokLive.requests()` and open the signed URL with a Java-WebSocket client of its own, which shows the raw batches without any mapping. This reader prints the method name of every message, first from the signing response and then from each frame: + +```java +import com.google.protobuf.InvalidProtocolBufferException; +import io.github.jwdeveloper.tiktok.api.messages.webcast.ProtoMessageFetchResult; +import io.github.jwdeveloper.tiktok.api.messages.webcast.WebcastPushFrame; +import org.java_websocket.client.WebSocketClient; +import org.java_websocket.handshake.ServerHandshake; + +import java.nio.ByteBuffer; + +var requests = TikTokLive.requests(); +var userData = requests.fetchLiveUserData("isaackogz"); +var connection = requests.fetchLiveConnectionData(userData.getRoomInfo().getRoomId()); + +for (var message : connection.getWebcastResponse().getMessagesList()) { + System.out.println("initial " + message.getMethod()); +} + +var headers = new HashMap<>(LiveClientSettings.DefaultRequestHeaders()); +headers.put("Cookie", connection.getWebsocketCookies()); + +var socket = new WebSocketClient(connection.getWebsocketUrl(), headers) { + @Override + public void onOpen(ServerHandshake handshake) { + System.out.println("Open"); + } + + @Override + public void onMessage(String text) { + } + + @Override + public void onMessage(ByteBuffer bytes) { + try { + var frame = WebcastPushFrame.parseFrom(bytes.array()); + var batch = ProtoMessageFetchResult.parseFrom(frame.getPayload()); + for (var message : batch.getMessagesList()) { + System.out.println(message.getMethod()); + } + } catch (InvalidProtocolBufferException e) { + System.err.println("Not a push frame: " + e.getMessage()); + } + } + + @Override + public void onClose(int code, String reason, boolean remote) { + System.out.println("Closed " + code + " " + reason); + } + + @Override + public void onError(Exception e) { + e.printStackTrace(); + } +}; + +socket.connect(); +``` + +`fetchLiveUserData` throws for an unknown creator and returns `NotFound` with a `null` room info for a malformed answer, so check `getUserStatus()` before reading the room id in anything but a sketch. The reader sends neither the `ack` frames nor the heartbeat the client sends; add both for anything beyond a look at the traffic. What the batches contain, and how a message becomes an event, is on the [protocol page](/advanced/protocol/). diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index 8bcac6d3..4df68e50 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -1,6 +1,88 @@ --- title: Contributing to the docs -description: How to edit, build, and preview this documentation. +description: Build, check, and maintain the documentation site alongside the library source, with the authoring rules the build enforces. --- -This page is written in a later task of the documentation plan. +## Run locally + +The documentation is a Starlight project under `docs/`, with its own `package.json` and lockfile. The reference generator and the content check are a small Maven project under `docs/tools/` that reads the library's sources, and the content check compiles every Java example against the built library, so the site is built from a checkout of the repository with the library built. + +Prerequisites: + +- JDK 17 or newer and Maven. The library targets Java 16; the documentation tool compiles with `--release 17`, and the content check needs a JDK rather than a JRE for its compiler. CI uses Temurin 17. +- Node.js 24 and pnpm 10.34.2. `npx -y pnpm@10.34.2` runs the pinned pnpm without a global install. + +From the repository root: + +```sh +npx -y pnpm@10.34.2 --dir docs install --frozen-lockfile +npx -y pnpm@10.34.2 --dir docs build +``` + +| Command | What it does | +| --- | --- | +| `pnpm --dir docs lib:build` | Builds the `API`, `Client`, `extension-recorder`, and `extension-collector` modules and writes each module's classpath to `target/classpath.txt`: `mvn -q -pl API,Client,extension-recorder,extension-collector -am -DskipTests install dependency:build-classpath -Dmdep.outputFile=target/classpath.txt`. About a minute; the example compiler needs it. | +| `pnpm --dir docs tools:build` | Packages the documentation tool as `docs/tools/target/docs-tools.jar`. | +| `pnpm --dir docs check` | Runs the tool's `check` command: the content check described below. Needs both builds above. | +| `pnpm --dir docs generate` | Runs the tool's `generate` command: writes the `api/` and `schema/` reference pages. | +| `pnpm --dir docs build` | `lib:build`, `tools:build`, `check`, `generate`, then `astro build` with link validation. This is what CI runs. | +| `pnpm --dir docs dev` | Starts the Astro dev server on `127.0.0.1`. Run `generate` once first so the reference sections exist. | +| `pnpm --dir docs preview` | Serves the last build from `docs/dist`. | +| `pnpm --dir docs test` | The SEO gate's own tests, with `node --test`. | +| `pnpm --dir docs run seo` | The SEO gate over `docs/dist`, see [hosting](/hosting/#seo-gate). | +| `mvn -q -f docs/tools/pom.xml test` | The documentation tool's JUnit tests: the generator, the proto parser, the content check, and the example compiler. | + +With `npx` in place of a global pnpm: `npx -y pnpm@10.34.2 --dir docs check`. + +## Where to edit + +- Authored pages are Markdown under `docs/src/content/docs/`: the start-here pages, `configuration/`, `guides/`, `events/`, `advanced/`, and the top-level pages. +- `api/` and `schema/` inside that directory are generated on every build from the `API` and `Client` sources and the `.proto` files, and are ignored by Git. Never edit or commit them. To change a reference page, edit the Javadoc or the signature at the source declaration; to change a schema page, edit the `.proto` file. +- The event catalog on `events/index.md` and the grouped event pages are authored. A catalog row's type cell is compared with the event class's `@EventMeta` category, and the README's events list is compared with the `EventsBuilder` handlers, so a new event needs its class, its handler, its catalog row, and its section. +- The sidebar, the site title and description, the header links, and the footer are in `docs/astro.config.mjs`. +- The tool is in `docs/tools/src/main/java/docs/`, with tests in `docs/tools/src/test/java/docs/`: `ApiPages` and `SchemaPages` generate, `ContentCheck` and `ExampleCompiler` check. + +## Authoring rules + +The build enforces these; a page that breaks one fails `pnpm --dir docs build`. + +- Every ```` ```java ```` fence compiles against the built library. A fence that declares a top-level `class`, `interface`, `record`, or `enum` is compiled as its own file with the imports it carries, so a listener class or a `main` example is written complete. Any other fence is a fragment: its statements are placed in a method body, `void run() throws Exception`, with a wildcard import of every package of the `API` and `Client` sources and of `java.util`, `java.time`, `java.util.function`, `java.util.logging`, and `java.io`, and `import` lines inside the fragment are hoisted above the class. The generated protobuf packages are not wildcard-imported, so a fragment that names `WebcastChatMessage` or another message class carries its own `import` line. Checked exceptions need no handling in a fragment; a lambda parameter must not reuse the name of a local variable. A snippet that should not compile goes in prose or in another fence language. +- Shell commands use ```` ```sh ```` fences; Maven coordinates use ```` ```xml ````, Gradle ```` ```groovy ````. Only Java fences are compiled. +- No Discord links in page text. The header button is the one Discord link on the site, and the report channel named on pages is GitHub issues. +- Links to [Euler Stream](https://www.eulerstream.com/) are plain Markdown links, dofollow, with no `rel` attribute. Every page carries at least one; the SEO gate described on the [hosting page](/hosting/) checks it. +- Internal links are site-relative with a trailing slash, `/configuration/signing/`, and point at pages that exist; the build validates them. Event anchors are the lowercase class name on the grouped page, `/events/gifts-and-goals/#tiktokgiftevent`. +- Frontmatter has `title` and `description`; pages in an autogenerated sidebar group also set `sidebar.order`. +- An event, settings field, handler, exception, or extension name counts as documented only as its own code span: `` `onComment` `` counts, `` `client.onComment(...)` `` does not. + +## Where facts come from + +Describe the library as it is in the checkout, not as the README or an older page says it was. In order: + +1. The library source: `API/src/main/java` for the interfaces, events, settings, and exceptions, `Client/src/main/java` for the client, the HTTP and WebSocket layers, and the mappers, and `API/src/main/proto` for the schema. A signature, a default, an exception, or a published event is what the code does. +2. The README, for the event descriptions and the wording of the landing page. +3. `examples/src/main/java`, which shows the intended use of each feature; where an example disagrees with the code, the code wins and the guide says so. + +When a behaviour cannot be read from the code, run it against a live room and say what was observed rather than what the schema allows; see [protocol](/advanced/protocol/#schema-definitions-and-verified-behaviour). When the code has a bug, describe the behaviour as it is and work around it in the example, as the [troubleshooting page](/troubleshooting/#known-limitations-in-this-release) does for this release. + +## The content check + +`check` runs first in every build. It parses the `API` and `Client` sources with JavaParser, reads the authored pages, skips `api/` and `schema/`, and prints `PASS` or `FAIL` for eight sections in this order, then a `Checked ... pages` summary line: + +| Section | Passes when | +| --- | --- | +| `events` | Every concrete event class under `api/data/events` is named in backticks on some page and has a catalog row on `events/index.md` whose type cell equals its `@EventMeta` category (`Control`, `Message`, `Debug`, or `none`). | +| `settings` | Every field of `LiveClientSettings` and `HttpClientSettings` is named in backticks on some page. | +| `builder` | Every public `on*` handler of `EventsBuilder` and every public method of `LiveClient` is named in backticks on some page. | +| `exceptions` | Every class under `api/exceptions` is named in backticks on some page. | +| `extensions` | `TikTokLiveRecorder` and `TikTokLiveCollector` are named in backticks on some page. | +| `discord` | No page contains a Discord link domain. | +| `readme` | Every `- [onX](#anchor)` item of the README's events list names an event class, the anchor's last segment, and an `EventsBuilder` handler. | +| `examples` | Every Java fence compiles as described above; a deliberately wrong canary must fail, so an empty classpath cannot pass the section. | + +A failing section prints its first three failures with the offending name. The check tells you what is missing; it cannot tell whether a sentence is true, so verify behaviour in the source before describing it. + +## Deployment + +`.github/workflows/docs.yml` runs the tests, the full build, and the SEO gate on every pull request that touches the docs or the library, uploads the built site as a review artifact, and on pushes to `master` deploys it to Cloudflare Workers at `https://ttlj.docs.isaackogan.com/`; a daily scheduled run builds and deploys the upstream repository's `master`. See [hosting](/hosting/) for the Worker configuration, the secrets, and how to deploy from a checkout. + +`DOCS_SITE` sets the public origin and defaults to the custom domain; `DOCS_BASE` sets the deployment subpath and defaults to `/`. Neither needs to be set for a normal build. diff --git a/docs/src/content/docs/hosting.md b/docs/src/content/docs/hosting.md index 988c29bf..c9bd832d 100644 --- a/docs/src/content/docs/hosting.md +++ b/docs/src/content/docs/hosting.md @@ -1,6 +1,87 @@ --- title: Hosting -description: How this documentation site is built and deployed. +description: Host the static documentation on Cloudflare Workers static assets under a custom domain, deploy it from CI or a checkout, track the upstream repository on a schedule, and gate deploys on SEO checks. --- -This page is written in a later task of the documentation plan. +The site is deployed to Cloudflare Workers as static assets and served at `https://ttlj.docs.isaackogan.com/`. Hosting lives in a maintainer's Cloudflare account while the documentation source stays in the public repository: the fork `isaackogan/TikTokLiveJava` holds the deployment secrets and runs the deploys, and a scheduled run there builds the upstream `jwdeveloper/TikTokLiveJava` `master` every day, so the site tracks upstream without the upstream repository holding any credentials. The same workflow in the upstream repository validates every pull request and would deploy too if its maintainer added the secrets. There is no Worker script: `docs/wrangler.jsonc` only declares the asset directory, HTML handling, and the custom domain. The site needs no Cloudflare Astro adapter and no TikTok or signing credentials; its only external link requirement is the dofollow links to [Euler Stream](https://www.eulerstream.com/) the SEO gate checks. + +## Configuration + +```jsonc +{ + "name": "tiktoklivejava-docs", + "compatibility_date": "2026-10-20", + "assets": { + "directory": "./dist", + "html_handling": "auto-trailing-slash", + "not_found_handling": "404-page" + }, + "workers_dev": false, + "routes": [ + { "pattern": "ttlj.docs.isaackogan.com", "custom_domain": true } + ] +} +``` + +- `html_handling: auto-trailing-slash` matches Astro's `trailingSlash: 'always'`. A request for `/events` redirects to `/events/`, which serves `events/index.html`. +- `not_found_handling: 404-page` serves Starlight's generated `404.html` with a 404 status. +- `workers_dev: false` keeps the site off the `workers.dev` hostname so the custom domain is the only public origin. +- The `routes` entry with `custom_domain: true` makes Cloudflare create the DNS record and the certificate for the hostname on deploy. The `isaackogan.com` zone must be in the same Cloudflare account as the Worker, and the hostname must not already have a DNS record. + +`DOCS_SITE` overrides the canonical origin and defaults to the custom domain. `DOCS_BASE` sets the deployment subpath and defaults to `/`. Neither needs to be set for a production build. + +See Cloudflare's [static assets](https://developers.cloudflare.com/workers/static-assets/) and [custom domains](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) documentation for the underlying behaviour. + +## Build inputs + +The build needs both toolchains, because the reference pages are generated from the library sources and the examples are compiled against the built library: + +- JDK 17 or newer and Maven; `pnpm --dir docs build` runs the Maven build of the four library modules itself. +- Node.js 24 and pnpm 10.34.2 with `pnpm --dir docs install --frozen-lockfile`. + +The [contributing page](/contributing/) has the full setup and the commands. + +## Deploy from a checkout + +Wrangler is pinned in `docs/package.json`, so the documentation install provides it. Run `pnpm --dir docs exec wrangler login` once on a new machine; the login is an OAuth session in the maintainer's account. + +```sh +pnpm --dir docs install --frozen-lockfile +pnpm --dir docs build +pnpm --dir docs run deploy:cloudflare +``` + +The first deploy creates the Worker and attaches the custom domain; wrangler reports `Deployed tiktoklivejava-docs triggers` and `ttlj.docs.isaackogan.com (custom domain)`, and the certificate takes a few minutes to issue. Later deploys upload only the files that changed. + +## Continuous deployment + +`.github/workflows/docs.yml`, named `Documentation`, runs on pull requests and on pushes to `master` that touch `docs/`, `API/`, `Client/`, `extension-recorder/`, `extension-collector/`, `README.md`, or the workflow itself; it can be started by hand with `workflow_dispatch`; and it runs on a schedule, `17 6 * * *`, 06:17 UTC every day. Its permissions are `contents: read`, and one run at a time per ref: the concurrency group is `docs-${{ github.ref }}`. + +The `build` job has one of two checkout steps: on a scheduled run it checks out `jwdeveloper/TikTokLiveJava` at `master`, and on every other event the repository and ref that triggered it. It then sets up Temurin JDK 17 with the Maven cache, runs `mvn -q -pl API,Client,extension-recorder,extension-collector -am -DskipTests package`, sets up Node.js 24, installs pnpm with `npm install --global pnpm@10.34.2`, runs `pnpm --dir docs install --frozen-lockfile`, `pnpm --dir docs test`, `pnpm --dir docs build`, and the SEO gate `pnpm --dir docs run seo`, and uploads `docs/dist` as the `documentation` artifact for review. The last step deploys with `cloudflare/wrangler-action@v4` (`workingDirectory: docs`, `packageManager: pnpm`, `command: deploy`) when the event is not a pull request, the ref is `refs/heads/master` or the event is the schedule, and `DEPLOY_ENABLED` is `true`; that job variable is derived from whether the `CLOUDFLARE_API_TOKEN` secret is set. The deploy needs both of these secrets: + +| Secret | Value | +| --- | --- | +| `CLOUDFLARE_API_TOKEN` | A token with Workers Scripts Edit, Account Settings Read, Workers Routes Edit, and Zone Read on `isaackogan.com`. | +| `CLOUDFLARE_ACCOUNT_ID` | The hosting account's id, shown by `wrangler whoami`. | + +Without `CLOUDFLARE_API_TOKEN` the deploy step is skipped and the workflow still validates the build, so the upstream repository, forks, and pull requests are unaffected. The gate checks only the token; `CLOUDFLARE_ACCOUNT_ID` is passed to wrangler and is required for the deploy to succeed, so set both. The maintainer sets them with `gh secret set CLOUDFLARE_API_TOKEN -R isaackogan/TikTokLiveJava` and `gh secret set CLOUDFLARE_ACCOUNT_ID -R isaackogan/TikTokLiveJava`. Because the scheduled run checks out the upstream repository, the workflow file must be on the fork's default branch for the schedule to exist, and a change to the documentation reaches the site either by being merged upstream and picked up by the next scheduled run, or by being pushed to the fork's `master`. + +## SEO gate + +`pnpm --dir docs run seo` runs `docs/scripts/verify-seo.mjs` over `docs/dist` after every build in CI and prints a `PASS` line per check; any failure fails the workflow. The checks are the invariants the sibling documentation sites carry: the homepage `<title>` contains `TikTok LIVE API`; every page, `404.html` included, has a `rel="canonical"` link to its own URL on `https://ttlj.docs.isaackogan.com/`; the homepage has `og:title`, `og:description`, `og:image`, and a meta description; `sitemap-index.xml` and `sitemap-0.xml` exist, the latter on the custom domain with more than one URL; `robots.txt` names the sitemap; no link to Euler Stream is nofollowed, every page has at least one, and the homepage has at least three distinct anchor texts; and every page carries the theme's sidebar marker, so a theme change that drops the navigation is caught. The gate's tests run with `pnpm --dir docs test`. + +The Open Graph and Twitter card tags come from `head` in `docs/astro.config.mjs`; `robots.txt` is in `docs/public/`; the sitemap is generated from `site`. + +## Verify a deployment + +```sh +curl -I https://ttlj.docs.isaackogan.com/events/ +curl -I https://ttlj.docs.isaackogan.com/events +curl -I https://ttlj.docs.isaackogan.com/missing/ +``` + +Expect a 200, a 307 redirect to the trailing-slash form, and a 404 in that order. Then open the site, check an event page and the generated API and schema pages, and search for a gift event. The canonical link in the page head must use the custom domain. A scheduled run shows up as `gh run list --workflow=docs.yml -R isaackogan/TikTokLiveJava --event schedule --limit 1`. + +## Changing the hostname + +Edit `pattern` in `docs/wrangler.jsonc`, change the default `site` in `docs/astro.config.mjs`, update `BASE_URL` in `docs/scripts/verify-seo.mjs` and the `Sitemap` line in `docs/public/robots.txt`, update the hosted links in the repository README, and deploy. Cloudflare provisions the new hostname on the next deploy. Remove the old one under the Worker's **Settings → Domains & Routes** in the Cloudflare dashboard. diff --git a/docs/src/content/docs/troubleshooting.md b/docs/src/content/docs/troubleshooting.md index ed7a7151..1191c32a 100644 --- a/docs/src/content/docs/troubleshooting.md +++ b/docs/src/content/docs/troubleshooting.md @@ -1,6 +1,115 @@ --- title: Troubleshooting -description: Diagnose common TikTokLiveJava connection and runtime problems. +description: Diagnose connection failures, signing errors, sockets that close, missing events, and the known limitations of this release, with every exception the library throws and when. --- -This page is written in a later task of the documentation plan. +Start with the smallest [Getting Started](/) example, the exact dependency version from your build file (`1.13.1-Release` for these pages), the call that failed, and the original exception with its cause chain. A connection has four stages that fail differently: the HTTP requests to TikTok that resolve the room and check that the creator is live, the signing request to [Euler Stream](https://www.eulerstream.com/), the WebSocket handshake with TikTok, and the decoding and mapping of messages once connected. Succeeding at one does not establish that the next works, and a proxy or a session can affect each stage separately. + +`LiveClient` has three connection methods, `connect`, `connectAsync` (with or without a consumer), and `disconnect` (with or without a `LiveClientStopType`); two injection methods, `publishEvent` and `publishMessage`; `sendChat`; and four getters, `getRoomInfo`, `getGiftManager`, `getListenersManager`, and `getLogger`. Name the one involved in a report. Every exception the library throws is unchecked and extends `TikTokLiveException`, which extends `RuntimeException`. When a stage of `connect` throws one, the client publishes `TikTokErrorEvent` with it and `TikTokDisconnectedEvent` with the reason `Exception: <message>`, then rethrows it: `buildAndConnect()` and `connect()` throw to the caller, and the `Async` forms complete their future exceptionally. Exceptions raised after the connection is up, on the socket thread or in a handler, reach you only through `onError`; see [read every exception](/guides/debugging/#read-every-exception). + +## Exceptions + +All eleven classes are in `io.github.jwdeveloper.tiktok.api.exceptions`. + +| Exception | Thrown when | +| --- | --- | +| `TikTokLiveException` | The base class, and the one thrown directly for `Already connected`, `HostName can not be null`, `Minimum allowed ping interval is 250 milliseconds`, `Livestream for <host> is 18+ or age restricted!` with `throwOnAgeRestriction`, `Failed to connect to the websocket` when the proxy list is exhausted, `Failed to stop the websocket` when a `CLOSE_BLOCKING` wait is interrupted, `Listener ... has already been registered`, and a `Picture` whose image download fails. `TikTokErrorEvent.of(message)` builds one for tests. | +| `TikTokLiveRequestException` | An HTTP request to TikTok answered with a status other than 200, failed in transport, or returned a body the mapper cannot read: `params_error` from the user request, or a room-information body that is not a JSON object or has no `data` section. Also `No more proxies available!` when the proxy list empties inside one attempt. | +| `TikTokSignServerException` | A subclass of the above for the signing request: a status other than 200, with the note described under [signing](/configuration/signing/#rate-limits-and-provider-errors); a 200 without the `x-set-tt-cookie` header; or a body that is not a `ProtoMessageFetchResult`. | +| `TikTokProxyRequestException` | A request through a proxy endpoint failed with a connect timeout, a `ConnectException`, or another `IOException`, or the SOCKS SSL context could not be set up. The `fetch` methods catch it and move to the next endpoint, so it reaches you only when every attempt failed. | +| `TikTokLiveOfflineHostException` | TikTok reported the creator offline, from the user request (`User is offline`) or from the room information (`not found, is the Host offline?`). `getUserData()` holds the user response; `getLiveData()` holds the room response or `null` in the first case. The only failure `retryOnConnectionFailure` retries. | +| `TikTokLiveUnknownHostException` | TikTok reported the creator or the room not found (`User not found`, `could not be found`). Same two getters. Also what a block or captcha page produces, see below. | +| `TikTokLivePreConnectionException` | A `TikTokPreConnectionEvent` handler called `setCancelConnection(true, reason)`; the message is the reason and `getPreconnectEvent()` is the event. | +| `TikTokLiveMessageException` | Any exception thrown while one message of a batch was mapped or handled. Never thrown to you: it is the exception of a `TikTokErrorEvent`, with `messageMethod()`, `messageToBase64()`, `webcastResponseToBase64()`, and the original exception as `getCause()`. | +| `TikTokMessageMappingException` | The mapper helper could not deserialise: the message class has no `parseFrom` method or the bytes do not parse (both read `can't find parsing method`), or a message name has no class in the webcast package. The cause of a `TikTokLiveMessageException` on the socket, and thrown directly by `publishMessage`. | +| `TikTokProtocolBufferException` | A WebSocket frame was not a `WebcastPushFrame`, or its payload not a `ProtoMessageFetchResult`; `getBytes()` holds the bytes. Published as `TikTokErrorEvent` from the socket thread. | +| `TikTokEventListenerMethodException` | Thrown by `addListener` when a `@TikTokEventObserver` method has no parameter that extends `TikTokEvent`, and the exception of a `TikTokErrorEvent` when a listener method throws, wrapping the reflective call's exception; see [when a method throws](/guides/listeners/#when-a-method-throws). | + +```java +TikTokLive.newClient("isaackogz") + .onError((client, event) -> { + var exception = event.getException(); + if (exception instanceof TikTokLiveMessageException e) { + System.err.println("Message " + e.messageMethod() + " failed: " + e.getCause()); + } else if (exception instanceof TikTokProtocolBufferException e) { + System.err.println("Unreadable frame of " + e.getBytes().length + " bytes: " + e.getCause()); + } else if (exception instanceof TikTokEventListenerMethodException e) { + System.err.println("A listener method failed: " + e.getCause()); + } else { + exception.printStackTrace(); + } + }) + .buildAndConnect(); +``` + +## connect() fails + +```java +try { + TikTokLive.newClient("isaackogz").buildAndConnect(); +} catch (TikTokLiveOfflineHostException e) { + System.err.println("Not live: " + e.getMessage()); +} catch (TikTokLiveUnknownHostException e) { + System.err.println("No such creator: " + e.getMessage()); +} catch (TikTokSignServerException e) { + System.err.println("Signing failed: " + e.getMessage()); +} catch (TikTokLiveException e) { + System.err.println("Connect failed: " + e.getMessage()); +} +``` + +**`User is offline` or `not found, is the Host offline?`** (`TikTokLiveOfflineHostException`): the creator is not live. Poll with `TikTokLive.isLiveOnline(hostName)`, which asks TikTok directly and spends no signing request, and connect when it returns `true`; or set `retryOnConnectionFailure` with a `retryConnectionTimeout` of more than the default one second. See [retry when the creator is offline](/getting-started/connection/#retry-when-the-creator-is-offline). + +**`User not found`** (`TikTokLiveUnknownHostException`): the username does not exist, or TikTok did not answer with the JSON the client expects. `hostName` is the username after the `@` in the profile URL, not the display name; a leading `@` is removed. When the name is right, turn on the log: the client logs `Malformed Json` at `WARNING` with the body it received, and returns the same `NotFound` status for a captcha or block page as for a missing user. Try again later, change the address the requests come from, or go through a [proxy](/configuration/proxies-and-host/). + +**`is 18+ or age restricted!`** (`TikTokLiveException`): the room is age-restricted and `throwOnAgeRestriction` is set. Without the setting the client connects; whether it receives the room's messages depends on the session, see [age-restricted rooms](/configuration/authentication/#age-restricted-rooms). + +**`Unable to get websocket connection credentials`** (`TikTokSignServerException`): the signing request failed. The message carries the status: `429` is the provider's rate limit, with the wait when the provider sent a `ratelimit-reset` header; `420`, `500` to `504`, and other statuses are described on the [signing page](/configuration/signing/#rate-limits-and-provider-errors). Each connection attempt spends one signing request, so poll with `isLiveOnline` rather than by connecting, and set an `apiKey` for higher limits. The client does not retry a signing failure without proxies. + +**`Already connected`** (`TikTokLiveException`): `connect` was called while the state is `CONNECTING` or `CONNECTED`. The failure branch still runs: the state flips to `DISCONNECTED` and `TikTokErrorEvent` and `TikTokDisconnectedEvent` are published while the existing socket stays open, and the next `connect` replaces that socket. Use one connection per client, or `disconnect` first; see [build, connect, or both](/getting-started/connection/#build-connect-or-both). + +**`Unhandled exception report this bug to github`** in the log: `connect` hit an exception that is not a `TikTokLiveException`. The client prints the stack trace, calls `disconnect`, and returns normally with the state `DISCONNECTED`, without publishing `TikTokDisconnectedEvent` unless a socket was open. In this release the known cause is `connect` on an offline client, which fails with `NullPointerException` at `TikTokLiveClient.java:136`; see [known limitations](#known-limitations-in-this-release). Anything else is worth a report. + +**Nothing happens after `buildAndConnectAsync()`**: the connection runs on the common pool, whose threads are daemon threads, so a `main` that returns ends the program before the socket opens. Keep the main thread alive, as the repository's `ConnectionExample` does with `System.in.read()`. + +## The socket closes + +`TikTokDisconnectedEvent.getCode()` says what happened. `-1` (`isUnknownCloseCode()`) means the connection never opened: either a stage of `connect` threw, with the reason `Exception: <message>`, or the WebSocket handshake failed on the socket thread, in which case a `TikTokErrorEvent` with the handshake exception precedes it and `connect` returned normally. `1000` is a normal close from either side, including your own `disconnect`; `1006` means the connection dropped without a close frame; `1002` means TikTok answered the handshake with an HTTP error rather than the upgrade. See the [WebSocket page](/advanced/websocket/#how-a-connection-ends). + +The client does not reconnect after a close. Handle `onDisconnected`, wait, check `isLiveOnline`, and call `connect` again; the [connection page](/getting-started/connection/#retry-when-the-creator-is-offline) has the loop. A tight loop runs into the signing provider's rate limit. + +A handshake that hangs rather than fails is consistent with the connect timeout the socket receives: `timeout.toMillisPart()`, which is `0`, no timeout, for whole-second values such as the default three seconds. Set `Duration.ofMillis(2500)` or similar to give the handshake a timeout; see [the timeout and sub-second parts](/configuration/http-settings/#the-timeout-and-sub-second-parts). With proxies enabled, the socket also goes through the list unless `allowWebsocket` is `false`; a proxy that passes HTTP but not WebSocket traffic shows up here, see [the WebSocket through proxies](/configuration/proxies-and-host/#the-websocket). + +`TikTokLiveEndedEvent` does not close the socket: when TikTok reports the stream ended or suspended, the client publishes the event and keeps the connection until you call `disconnect` or TikTok closes it. Call `disconnect` from `onLiveEnded` when the connection should end with the stream. + +## Connected, but no expected events + +1. Register handlers and listeners before `connect`. The signing response already carries messages, and they are dispatched before the socket opens; a handler added afterwards misses them. +2. Dispatch is by exact class. A handler receives the events of its own class and, for `onEvent(TikTokEvent.class, ...)`, every event; it does not receive subclasses. `onGift` never sees `TikTokGiftComboEvent`, and `onEvent(TikTokLinkEvent.class, ...)` sees only the generic link event, not `TikTokLinkInviteEvent` and the other link subclasses. Subscribe to each class you need. The order in which several handlers for one class run is unspecified. +3. Sixteen event classes are never produced by the default client because their mappings are not registered; the [unmapped events](/events/#unmapped-events) list names them and shows the custom mapping that enables one. `TikTokHttpResponseEvent` has a handler but no publisher in this release. +4. Listen for `TikTokWebsocketUnhandledMessageEvent` to see every message the client has no mapping for, with its name and payload, and for `TikTokWebsocketResponseEvent` to see every batch. The [debugging guide](/guides/debugging/) shows both. +5. The setting's Javadoc and a comment in the HTTP client say that without a `sessionId` TikTok does not send every chat message; the client cannot verify that, but a session is what the maintainers recommend for complete comments, see [authentication](/configuration/authentication/). +6. With `useEulerstreamWebsocket`, `getRoomInfo()` is empty until the provider's `roomInfo` message arrives, and no `TikTokPreConnectionEvent` is published; see [signing](/configuration/signing/#euler-stream-websocket). +7. Confirm that the room produces the feature. A message defined in the schema is not sent in every room or region; see [protocol](/advanced/protocol/#schema-definitions-and-verified-behaviour). + +## Known limitations in this release + +These describe the checked-in `1.13.1-Release` code; recheck them against a newer release. + +- `connect` on a client with `offline` set fails with `NullPointerException` at `TikTokLiveClient.java:136`, because the offline HTTP client returns a user response with no room info; it is logged, not thrown. Use `build()` with `publishEvent` and `publishMessage` instead, see [offline testing](/guides/offline-testing/). +- With proxies of type `HTTP` or `DIRECT`, a response other than 200 moves to the next endpoint without removing the current one, and the loop runs while the list is not empty, so a provider answering 429 through working proxies is retried in rotation indefinitely. See [how a request uses the list](/configuration/proxies-and-host/#how-a-request-uses-the-list). +- The `roomId` setting on `LiveClientSettings` is never read; the room id always comes from the user request. +- The WebSocket handshake and the SOCKS proxy path receive `timeout.toMillisPart()` as their connect timeout, so a whole-second `timeout` gives them none. +- There is no name-based fallback mapping: `TikTokGenericEventMapper.mapToEvent` exists but nothing calls it, so the sixteen unmapped events stay unmapped until you register them. +- Event dispatch is by exact class; subclasses do not reach a superclass handler. +- `TikTokHttpResponseEvent` is never published by the HTTP client. +- On the Euler Stream path the `roomInfo` message fills only the room id, title, start time, viewer count, and host; the total viewer count and the age restriction stay unset, and the message's `totalViewers` is written into the viewer count. +- `stop` on the Euler Stream socket closes the socket only when it is already open; one still in its handshake is dropped without being closed. +- The recorder's `setOutputFile` needs a path with a directory component; a bare filename such as `new File("clip.flv")` fails the download task with a `NullPointerException` that is printed and swallowed, and no ended event is published. See the [recorder settings](/guides/recorder/#settings). +- A second `connect` while connected flips the state to `DISCONNECTED` and publishes error and disconnected events while the socket stays open. +- The status notes in request failures name the sign server even for TikTok's own API: a 429 from `www.tiktok.com` reads `Sign server rate limit reached`. +- The poll mapping and `TikTokShopEvent` carry a `not working` note in the source; see [room and display](/events/room-and-display/) and [games and commerce](/events/games-and-commerce/). + +## Report a reproducible issue + +File reports on the [TikTokLiveJava GitHub issues](https://github.com/jwdeveloper/TikTokLiveJava/issues) page. Include the library version, the Java version and operating system, the settings with secrets removed, a minimal example, the exact exception with its cause chain, and which stage failed: user data, room information, signing, WebSocket handshake, or message handling. For an event that does not arrive or a message that fails to map, add the message `method` and a Base64 payload, from `TikTokWebsocketUnhandledMessageEvent` or from `TikTokLiveMessageException.messageToBase64()`, reviewed for personal data; see [report a new message type](/guides/debugging/#report-a-new-message-type). Never include session cookies, API keys, or signed WebSocket URLs. From 8a14ed801f03103e393ed002ffad94ce4dd57d02 Mon Sep 17 00:00:00 2001 From: isaackogan <isaacikogan@gmail.com> Date: Tue, 20 Oct 2026 18:11:59 -0400 Subject: [PATCH 13/31] docs: record the pre-created Worker and the scoped CI token in the design and plan Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- docs/superpowers/plans/2026-10-20-starlight-docs.md | 2 +- docs/superpowers/specs/2026-10-20-starlight-docs-design.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-10-20-starlight-docs.md b/docs/superpowers/plans/2026-10-20-starlight-docs.md index 186e4389..b78489d8 100644 --- a/docs/superpowers/plans/2026-10-20-starlight-docs.md +++ b/docs/superpowers/plans/2026-10-20-starlight-docs.md @@ -294,7 +294,7 @@ git commit -m "docs: add content coverage and example compilation checks" ### Task 12: Bootstrap on the fork (controller, with the maintainer) -- [ ] **Step 1: Secrets on the fork.** The maintainer creates the four-permission token (Workers Scripts Edit, Account Settings Read, Workers Routes Edit and Zone Read on isaackogan.com). `gh secret set CLOUDFLARE_API_TOKEN -R isaackogan/TikTokLiveJava` (value via stdin) and `gh secret set CLOUDFLARE_ACCOUNT_ID -R isaackogan/TikTokLiveJava --body f00699f0017e82344b8bed18c38dab38`. +- [ ] **Step 1: Secrets on the fork.** The Worker `tiktoklivejava-docs` already exists with the custom domain attached (created from the maintainer's wrangler login on 2026-10-20 with a placeholder page), so the maintainer creates an account-owned token scoped to Editor on that Worker. `gh secret set CLOUDFLARE_API_TOKEN -R isaackogan/TikTokLiveJava` (value via stdin) and `gh secret set CLOUDFLARE_ACCOUNT_ID -R isaackogan/TikTokLiveJava --body f00699f0017e82344b8bed18c38dab38`. Verify with a local deploy of the current build using that token before relying on CI. - [ ] **Step 2: Push the branch; open a fork-internal PR `docs/starlight-site` → `isaackogan:master` to get a CI run; when green, merge it with a merge commit (no squash, no force).** - [ ] **Step 3: Watch the fork's `master` run**: `build` succeeds, `Deploy to Cloudflare Workers` ran; `curl -s -o /dev/null -w '%{http_code}' https://ttlj.docs.isaackogan.com/events/` becomes 200 within a few minutes; `/events` → 307; `/missing/` → 404. - [ ] **Step 4: Scheduled path.** A schedule cannot be triggered by hand; the Task 11 YAML assertion is the pre-merge verification, and the first scheduled run (06:17 UTC the next day) is checked afterwards with `gh run list --workflow=docs.yml -R isaackogan/TikTokLiveJava --event schedule --limit 1`. diff --git a/docs/superpowers/specs/2026-10-20-starlight-docs-design.md b/docs/superpowers/specs/2026-10-20-starlight-docs-design.md index 4b6f7ff5..91a6c019 100644 --- a/docs/superpowers/specs/2026-10-20-starlight-docs-design.md +++ b/docs/superpowers/specs/2026-10-20-starlight-docs-design.md @@ -121,5 +121,5 @@ Accepted builder defaults: extensions on the example classpath; `readme` check r 1. **Hostname:** `ttlj.docs.isaackogan.com`. 2. **Discord button:** the README's `https://discord.gg/e2XwPNTBBr`. 3. **Deploys after the upstream merge:** the fork keeps deploying. The docs commits are merged into the fork's `master` (a normal merge, no force push) so `docs.yml` lives on the fork's default branch; its scheduled path checks out upstream `master` and deploys from it. The upstream copy of the workflow is gated on the secret and only validates there until jwdeveloper chooses to add secrets. -4. **First-deploy token:** the fork's first `master` run creates the Worker, so the fork's `CLOUDFLARE_API_TOKEN` is the four-permission token (Workers Scripts Edit, Account Settings Read, Workers Routes Edit and Zone Read on isaackogan.com); the maintainer creates it when the bootstrap task asks and may swap it for a Worker-scoped Editor token afterwards. +4. **First-deploy token (revised 2026-10-20):** the maintainer had the Worker `tiktoklivejava-docs` created from their own wrangler login with a placeholder page and the custom domain already attached, so the fork's CI only deploys to an existing Worker. The fork's `CLOUDFLARE_API_TOKEN` is therefore an account-owned token scoped to Editor on `tiktoklivejava-docs` (the same recipe as the sibling sites); no zone or account-wide permission is needed. 5. **PR target:** `jwdeveloper/TikTokLiveJava:master` from `isaackogan:docs/starlight-site`. From 98e16f203adfd4c3a25f84a42484a4acc8b20fc0 Mon Sep 17 00:00:00 2001 From: isaackogan <isaacikogan@gmail.com> Date: Tue, 20 Oct 2026 18:28:57 -0400 Subject: [PATCH 14/31] docs: correct proxy exception, socket thread, and offline disconnect claims Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- docs/src/content/docs/advanced/websocket.md | 4 ++-- docs/src/content/docs/troubleshooting.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/src/content/docs/advanced/websocket.md b/docs/src/content/docs/advanced/websocket.md index ad918b07..b2c6ccad 100644 --- a/docs/src/content/docs/advanced/websocket.md +++ b/docs/src/content/docs/advanced/websocket.md @@ -14,7 +14,7 @@ sidebar: 1. `stop(LiveClientStopType.NORMAL)` closes any socket the client still holds, so a second `start` replaces the first. 2. The messages already in the signing response are handed to the message handler, so handlers registered before `connect()` receive them before the socket exists. 3. A `TikTokWebSocketListener` is created for the signed URL with the HTTP settings' headers plus a `Cookie` header holding the cookies from the signing response, and the `timeout` of the HTTP settings as its connect timeout, after `toMillisPart()`: a whole-second timeout, such as the default, gives `0`, which is no timeout. See [the timeout and sub-second parts](/configuration/http-settings/#the-timeout-and-sub-second-parts). -4. With proxies enabled and `allowWebsocket` left `true`, the listener connects through the proxy list, as the [proxies page](/configuration/proxies-and-host/#the-websocket) describes; otherwise it connects directly. Java-WebSocket's `connect()` starts a non-daemon thread named `WebSocketConnectReadThread-<id>` and returns at once. +4. With proxies enabled and `allowWebsocket` left `true`, the listener connects through the proxy list, as the [proxies page](/configuration/proxies-and-host/#the-websocket) describes; otherwise it connects directly. Java-WebSocket's `connect()` starts a thread named `WebSocketConnectReadThread-<id>` and returns at once. The library sets no daemon flag on it, so the thread inherits the flag of the thread that called `connect()`: a user thread when you call `connect()` or `buildAndConnect()` from your main thread, which then keeps the process alive while the socket is open; a daemon thread when you use `connectAsync()` or `buildAndConnectAsync()`, which run `connect()` on the common pool, so the process ends with your last non-daemon thread unless you keep one alive. 5. `connect()` sets the state to `CONNECTED` and publishes `TikTokRoomInfoEvent` without waiting for the handshake. `TikTokConnectedEvent` is published from the socket thread when the handshake completes. A handshake that fails is reported on the socket thread rather than thrown from `connect()`. When the host does not resolve or the TCP or TLS connection fails, the listener's `onError` publishes `TikTokErrorEvent` with the exception, then its `onClose` runs with code `-1`, the client's state goes to `DISCONNECTED`, and `TikTokDisconnectedEvent` is published with that code, for which `isUnknownCloseCode()` is `true`. When TikTok answers the upgrade request with an HTTP error instead, Java-WebSocket closes with its protocol-error code `1002` through the same `onClose` path. `connect()` has already returned normally in both cases. @@ -56,7 +56,7 @@ With `useEulerstreamWebsocket`, `TikTokWebSocketEulerClient.start` ignores the c ## The offline socket -With `offline`, `TikTokWebSocketOfflineClient.start` publishes `TikTokConnectedEvent` at once and `stop` publishes `TikTokDisconnectedEvent` with the reason `Stopping` and code `-1`; `isConnected()` is always `false`. In this release `connect()` fails before `start` is reached, see [offline mode](/configuration/client-settings/#offline-mode), so these events only come from `disconnect()` or from your own `publishEvent`. The [offline testing guide](/guides/offline-testing/) covers what the mode is for. +With `offline`, `TikTokWebSocketOfflineClient.start` publishes `TikTokConnectedEvent` at once and `stop` publishes `TikTokDisconnectedEvent` with the reason `Stopping` and code `-1`, but only once `start` has given it the client; `isConnected()` is always `false`. In this release `connect()` fails before `start` is reached, see [offline mode](/configuration/client-settings/#offline-mode), so `stop` has no client and `disconnect()` publishes nothing: on an offline client these two events come only from your own `publishEvent`. The [offline testing guide](/guides/offline-testing/) covers what the mode is for. ## Read the frames without the client diff --git a/docs/src/content/docs/troubleshooting.md b/docs/src/content/docs/troubleshooting.md index 1191c32a..319489b0 100644 --- a/docs/src/content/docs/troubleshooting.md +++ b/docs/src/content/docs/troubleshooting.md @@ -16,7 +16,7 @@ All eleven classes are in `io.github.jwdeveloper.tiktok.api.exceptions`. | `TikTokLiveException` | The base class, and the one thrown directly for `Already connected`, `HostName can not be null`, `Minimum allowed ping interval is 250 milliseconds`, `Livestream for <host> is 18+ or age restricted!` with `throwOnAgeRestriction`, `Failed to connect to the websocket` when the proxy list is exhausted, `Failed to stop the websocket` when a `CLOSE_BLOCKING` wait is interrupted, `Listener ... has already been registered`, and a `Picture` whose image download fails. `TikTokErrorEvent.of(message)` builds one for tests. | | `TikTokLiveRequestException` | An HTTP request to TikTok answered with a status other than 200, failed in transport, or returned a body the mapper cannot read: `params_error` from the user request, or a room-information body that is not a JSON object or has no `data` section. Also `No more proxies available!` when the proxy list empties inside one attempt. | | `TikTokSignServerException` | A subclass of the above for the signing request: a status other than 200, with the note described under [signing](/configuration/signing/#rate-limits-and-provider-errors); a 200 without the `x-set-tt-cookie` header; or a body that is not a `ProtoMessageFetchResult`. | -| `TikTokProxyRequestException` | A request through a proxy endpoint failed with a connect timeout, a `ConnectException`, or another `IOException`, or the SOCKS SSL context could not be set up. The `fetch` methods catch it and move to the next endpoint, so it reaches you only when every attempt failed. | +| `TikTokProxyRequestException` | Raised inside `HttpProxyClient` when a request through a proxy endpoint fails with a connect timeout, a `ConnectException`, or another `IOException`; with `autoDiscard` left `true`, its default, the endpoint is removed from the list first. It does not reach your code: the `fetch` methods and `sendChat` catch it and send again while the list is not empty, then send once more, and that last request gets the plain `HttpClient` because the list is empty by then. What you see instead is that direct request's outcome, a result or a `TikTokLiveRequestException` (`TikTokSignServerException` for signing); with `autoDiscard` set to `false` the loop retries indefinitely. The WebSocket client has one more `throw` of it, for a SOCKS SSL context that cannot be created, which the source marks as unreachable. | | `TikTokLiveOfflineHostException` | TikTok reported the creator offline, from the user request (`User is offline`) or from the room information (`not found, is the Host offline?`). `getUserData()` holds the user response; `getLiveData()` holds the room response or `null` in the first case. The only failure `retryOnConnectionFailure` retries. | | `TikTokLiveUnknownHostException` | TikTok reported the creator or the room not found (`User not found`, `could not be found`). Same two getters. Also what a block or captcha page produces, see below. | | `TikTokLivePreConnectionException` | A `TikTokPreConnectionEvent` handler called `setCancelConnection(true, reason)`; the message is the reason and `getPreconnectEvent()` is the event. | From fbaabf81aedb84e7e066735ddb077413daf92c95 Mon Sep 17 00:00:00 2001 From: isaackogan <isaacikogan@gmail.com> Date: Tue, 20 Oct 2026 18:30:21 -0400 Subject: [PATCH 15/31] docs: note that the proxy exception surfaces from direct HttpClientBuilder requests Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- docs/src/content/docs/troubleshooting.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/content/docs/troubleshooting.md b/docs/src/content/docs/troubleshooting.md index 319489b0..51f289bf 100644 --- a/docs/src/content/docs/troubleshooting.md +++ b/docs/src/content/docs/troubleshooting.md @@ -16,7 +16,7 @@ All eleven classes are in `io.github.jwdeveloper.tiktok.api.exceptions`. | `TikTokLiveException` | The base class, and the one thrown directly for `Already connected`, `HostName can not be null`, `Minimum allowed ping interval is 250 milliseconds`, `Livestream for <host> is 18+ or age restricted!` with `throwOnAgeRestriction`, `Failed to connect to the websocket` when the proxy list is exhausted, `Failed to stop the websocket` when a `CLOSE_BLOCKING` wait is interrupted, `Listener ... has already been registered`, and a `Picture` whose image download fails. `TikTokErrorEvent.of(message)` builds one for tests. | | `TikTokLiveRequestException` | An HTTP request to TikTok answered with a status other than 200, failed in transport, or returned a body the mapper cannot read: `params_error` from the user request, or a room-information body that is not a JSON object or has no `data` section. Also `No more proxies available!` when the proxy list empties inside one attempt. | | `TikTokSignServerException` | A subclass of the above for the signing request: a status other than 200, with the note described under [signing](/configuration/signing/#rate-limits-and-provider-errors); a 200 without the `x-set-tt-cookie` header; or a body that is not a `ProtoMessageFetchResult`. | -| `TikTokProxyRequestException` | Raised inside `HttpProxyClient` when a request through a proxy endpoint fails with a connect timeout, a `ConnectException`, or another `IOException`; with `autoDiscard` left `true`, its default, the endpoint is removed from the list first. It does not reach your code: the `fetch` methods and `sendChat` catch it and send again while the list is not empty, then send once more, and that last request gets the plain `HttpClient` because the list is empty by then. What you see instead is that direct request's outcome, a result or a `TikTokLiveRequestException` (`TikTokSignServerException` for signing); with `autoDiscard` set to `false` the loop retries indefinitely. The WebSocket client has one more `throw` of it, for a SOCKS SSL context that cannot be created, which the source marks as unreachable. | +| `TikTokProxyRequestException` | Raised inside `HttpProxyClient` when a request through a proxy endpoint fails with a connect timeout, a `ConnectException`, or another `IOException`; with `autoDiscard` left `true`, its default, the endpoint is removed from the list first. It does not reach your code through the client: the `fetch` methods and `sendChat` catch it and send again while the list is not empty, then send once more, and that last request gets the plain `HttpClient` because the list is empty by then. What you see instead is that direct request's outcome, a result or a `TikTokLiveRequestException` (`TikTokSignServerException` for signing); with `autoDiscard` set to `false` the loop retries indefinitely. It reaches a caller only from a request made directly with `HttpClientBuilder` over settings with proxies enabled, see the [HTTP client page](/advanced/http-client/#how-a-request-is-built). The WebSocket client has one more `throw` of it, for a SOCKS SSL context that cannot be created, which the source marks as unreachable. | | `TikTokLiveOfflineHostException` | TikTok reported the creator offline, from the user request (`User is offline`) or from the room information (`not found, is the Host offline?`). `getUserData()` holds the user response; `getLiveData()` holds the room response or `null` in the first case. The only failure `retryOnConnectionFailure` retries. | | `TikTokLiveUnknownHostException` | TikTok reported the creator or the room not found (`User not found`, `could not be found`). Same two getters. Also what a block or captcha page produces, see below. | | `TikTokLivePreConnectionException` | A `TikTokPreConnectionEvent` handler called `setCancelConnection(true, reason)`; the message is the reason and `getPreconnectEvent()` is the event. | From 259d2fd485c3c26f3efd704522ce07555cad5854 Mon Sep 17 00:00:00 2001 From: isaackogan <isaacikogan@gmail.com> Date: Tue, 20 Oct 2026 18:37:48 -0400 Subject: [PATCH 16/31] docs: serve a raster social-preview image from the site Social scrapers need a raster og:image; the raw GitHub SVG the config pointed at is not one. Render the project logo to a 1200x630 PNG with a white background (logo contained and centred) under docs/public and point og:image at it on the site's own origin. Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- docs/astro.config.mjs | 3 ++- docs/public/og-image.png | Bin 0 -> 106575 bytes 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 docs/public/og-image.png diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index ab304212..cdcf6f96 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -27,7 +27,8 @@ export default defineConfig({ description: 'Unofficial TikTok LIVE API for Java. Guides, events, and generated API reference.', favicon: '/favicon.png', head: [ - { tag: 'meta', attrs: { property: 'og:image', content: 'https://raw.githubusercontent.com/jwdeveloper/TikTokLiveJava/master/tools-readme/src/main/resources/logo.svg' } }, + // public/og-image.png is tools-readme/src/main/resources/logo.svg rendered with sharp: sharp(svg, { density: 144 }).resize(1200, 630, { fit: 'contain', background: '#fff' }).flatten({ background: '#fff' }).png() + { tag: 'meta', attrs: { property: 'og:image', content: 'https://ttlj.docs.isaackogan.com/og-image.png' } }, { tag: 'meta', attrs: { property: 'og:image:alt', content: 'TikTokLiveJava, TikTok LIVE API for Java' } }, { tag: 'meta', attrs: { name: 'twitter:card', content: 'summary_large_image' } }, ], diff --git a/docs/public/og-image.png b/docs/public/og-image.png new file mode 100644 index 0000000000000000000000000000000000000000..215827b8dfe6a195fabcf37774997c7c98aaf1d2 GIT binary patch literal 106575 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%0Autr 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%0D*m5x~54z2s7X_Z)d{ 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 literal 0 HcmV?d00001 From 6432c15252fcc0bcc9a313cc2f27482c84e8c946 Mon Sep 17 00:00:00 2001 From: isaackogan <isaacikogan@gmail.com> Date: Tue, 20 Oct 2026 18:37:48 -0400 Subject: [PATCH 17/31] docs: add the SEO gate for the built site Port the Python sibling's post-build checks to Node (ESM, node: built-ins only): homepage title, per-page canonical equal to the page's own URL, Open Graph and meta description, sitemap index and pages, robots.txt, dofollow Euler Stream links on every page with varied homepage anchor text, and the sidebar navigation marker. The CLI prints one PASS/FAIL line per check and exits 1 on any failure; CI runs it as a separate step after the build via `pnpm --dir docs run seo`. The test script uses Node's own glob (node --test "scripts/**/*.test.mjs") because node --test treats a bare directory argument as a file to run. Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- docs/package.json | 4 +- docs/scripts/verify-seo.mjs | 183 +++++++++++++++++++++++++++++++ docs/scripts/verify-seo.test.mjs | 132 ++++++++++++++++++++++ 3 files changed, 318 insertions(+), 1 deletion(-) create mode 100644 docs/scripts/verify-seo.mjs create mode 100644 docs/scripts/verify-seo.test.mjs diff --git a/docs/package.json b/docs/package.json index 15ef21d4..72a000e2 100644 --- a/docs/package.json +++ b/docs/package.json @@ -14,7 +14,9 @@ "generate": "java -jar tools/target/docs-tools.jar generate ..", "build": "pnpm run lib:build && pnpm run tools:build && pnpm run check && pnpm run generate && astro build", "preview": "astro preview --host 127.0.0.1", - "deploy:cloudflare": "wrangler deploy" + "deploy:cloudflare": "wrangler deploy", + "seo": "node scripts/verify-seo.mjs dist", + "test": "node --test \"scripts/**/*.test.mjs\"" }, "devDependencies": { "@astrojs/markdown-remark": "7.3.3", diff --git a/docs/scripts/verify-seo.mjs b/docs/scripts/verify-seo.mjs new file mode 100644 index 00000000..889d2111 --- /dev/null +++ b/docs/scripts/verify-seo.mjs @@ -0,0 +1,183 @@ +#!/usr/bin/env node +// Assert the SEO invariants of the built documentation. +// +// Run against a built Starlight site from the docs directory: +// +// node scripts/verify-seo.mjs dist (pnpm run seo) +// +// Prints one PASS/FAIL line per check and exits 1 if any check fails, so an +// SEO regression fails the CI build instead of silently degrading rankings. +// The gate only reads the build output; nothing here ships with the site. + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const BASE_URL = 'https://ttlj.docs.isaackogan.com/'; +const EULER_HOST = 'eulerstream.com'; +const SITEMAP_INDEX = 'sitemap-index.xml'; +const SITEMAP_PAGES = 'sitemap-0.xml'; + +// The lucode theme renders the sidebar as <ul class="sidebar-entries ...">. +// A theme or Starlight upgrade that restructures the sidebar would silently +// delete site navigation while every other check still passed, so every page +// must carry this marker. +const NAV_MARKER = 'class="sidebar-entries'; + +// One sitewide footer link is the floor; the homepage must also vary its +// anchor text so the link profile does not read as a single repeated anchor. +const MIN_HOMEPAGE_ANCHORS = 3; + +const ANCHOR_TAG = /<a\b[^>]*>/gi; +const CANONICAL_TAG = /<link\b[^>]*\brel="canonical"[^>]*>/gi; +const HREF = /\bhref="([^"]*)"/i; +const EULER_ANCHOR = new RegExp( + `<a\\b[^>]*href="[^"]*${EULER_HOST.replace('.', '\\.')}[^"]*"[^>]*>([\\s\\S]*?)</a>`, + 'gi', +); + +function read(file) { + return fs.readFileSync(file, 'utf8'); +} + +function isFile(file) { + return fs.existsSync(file) && fs.statSync(file).isFile(); +} + +/** Astro emits one index.html per route plus a root 404.html; nothing else is a page. */ +function findPages(root) { + const pages = fs + .readdirSync(root, { recursive: true, withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name === 'index.html') + .map((entry) => path.relative(root, path.join(entry.parentPath, entry.name)).split(path.sep).join('/')) + .sort(); + if (isFile(path.join(root, '404.html'))) pages.push('404.html'); + return new Map(pages.map((name) => [name, read(path.join(root, name))])); +} + +/** The URL a built page must declare as canonical: `BASE_URL + dir + '/'`, `404.html` → `404/`. */ +function expectedUrl(name) { + const route = name === 'index.html' + ? '' + : name.endsWith('/index.html') ? name.slice(0, -'index.html'.length) : `${name.replace(/\.html$/, '')}/`; + return BASE_URL + route; +} + +function eulerAnchorTexts(html) { + const texts = new Set(); + for (const [, inner] of html.matchAll(EULER_ANCHOR)) { + const text = inner.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim().toLowerCase(); + if (text) texts.add(text); + } + return texts; +} + +const sample = (list, n) => JSON.stringify(list.slice(0, n)); + +/** + * Run every check against a built site. + * @param {string} buildDir directory holding the Astro build output + * @param {{ log?: (line: string) => void }} [options] `log` receives each PASS/FAIL line as it is decided + * @returns {{ passes: string[], failures: string[] }} check names, in the order they ran + */ +export function verifySeo(buildDir, { log = () => {} } = {}) { + const passes = []; + const failures = []; + const check = (name, ok, detail = '') => { + (ok ? passes : failures).push(name); + log(`${ok ? 'PASS' : 'FAIL'} ${name}${!ok && detail ? ` :: ${detail}` : ''}`); + }; + + const root = path.resolve(buildDir); + if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) { + check(`build directory exists: ${buildDir}`, false, 'run the build first'); + return { passes, failures }; + } + + const indexExists = isFile(path.join(root, 'index.html')); + check('index.html exists', indexExists); + if (!indexExists) return { passes, failures }; + + const pages = findPages(root); + const indexHtml = pages.get('index.html'); + const pageNames = [...pages.keys()]; + + // -- Title --------------------------------------------------------------- + const titleText = (indexHtml.match(/<title>([\s\S]*?)<\/title>/)?.[1] ?? '').trim(); + check("homepage title contains 'TikTok LIVE API'", titleText.toLowerCase().includes('tiktok live api'), titleText); + + // -- Canonical ----------------------------------------------------------- + const wrongCanonical = pageNames.filter((name) => { + const hrefs = [...pages.get(name).matchAll(CANONICAL_TAG)].map((m) => m[0].match(HREF)?.[1]); + return !hrefs.includes(expectedUrl(name)); + }); + check('every page has rel=canonical pointing at its own URL', wrongCanonical.length === 0, + `${wrongCanonical.length} wrong or missing, e.g. ${sample(wrongCanonical, 3)}`); + + // -- Open Graph + meta description --------------------------------------- + check('homepage has og:title', indexHtml.includes('property="og:title"')); + check('homepage has og:description', indexHtml.includes('property="og:description"')); + check('homepage has og:image', indexHtml.includes('property="og:image"')); + check('homepage has meta description', indexHtml.includes('name="description"')); + + // -- Sitemap ------------------------------------------------------------- + const sitemapIndex = path.join(root, SITEMAP_INDEX); + check(`${SITEMAP_INDEX} exists`, isFile(sitemapIndex)); + if (isFile(sitemapIndex)) { + check(`${SITEMAP_INDEX} references ${SITEMAP_PAGES}`, + read(sitemapIndex).includes(`<loc>${BASE_URL}${SITEMAP_PAGES}</loc>`)); + } + + const sitemapPages = path.join(root, SITEMAP_PAGES); + check(`${SITEMAP_PAGES} exists`, isFile(sitemapPages)); + if (isFile(sitemapPages)) { + const xml = read(sitemapPages); + const locs = (xml.match(/<loc>/g) ?? []).length; + check(`${SITEMAP_PAGES} uses the canonical base URL`, xml.includes(BASE_URL)); + check(`${SITEMAP_PAGES} lists more than one URL`, locs > 1, `${locs} <loc> entries`); + } + + // -- robots.txt ---------------------------------------------------------- + const robots = path.join(root, 'robots.txt'); + const sitemapLine = `Sitemap: ${BASE_URL}${SITEMAP_INDEX}`; + check('robots.txt points at the sitemap index', + isFile(robots) && read(robots).includes(sitemapLine), + isFile(robots) ? `no '${sitemapLine}' line` : 'robots.txt missing'); + + // -- Dofollow link profile ----------------------------------------------- + const nofollowed = []; + const withoutEuler = []; + for (const [name, html] of pages) { + const eulerAnchors = (html.match(ANCHOR_TAG) ?? []).filter((a) => a.includes(EULER_HOST)); + if (eulerAnchors.length === 0) withoutEuler.push(name); + for (const a of eulerAnchors) { + if (a.toLowerCase().includes('nofollow')) nofollowed.push(`${name}: ${a.slice(0, 90)}`); + } + } + check('no eulerstream.com link is nofollowed', nofollowed.length === 0, + `${nofollowed.length} nofollowed, e.g. ${sample(nofollowed, 2)}`); + check('eulerstream.com linked from every page', withoutEuler.length === 0, + `${withoutEuler.length}/${pages.size} pages missing, e.g. ${sample(withoutEuler, 3)}`); + + // -- Anchor diversity ---------------------------------------------------- + const anchors = eulerAnchorTexts(indexHtml); + check(`homepage uses >= ${MIN_HOMEPAGE_ANCHORS} distinct eulerstream anchor texts`, + anchors.size >= MIN_HOMEPAGE_ANCHORS, `${anchors.size} distinct: ${sample([...anchors].sort(), 5)}`); + + // -- Navigation integrity ------------------------------------------------ + const missingNav = pageNames.filter((name) => !pages.get(name).includes(NAV_MARKER)); + check('every page retains sidebar navigation', missingNav.length === 0, + `${missingNav.length} missing '${NAV_MARKER}', e.g. ${sample(missingNav, 3)}`); + + return { passes, failures }; +} + +function main(buildDir = 'dist') { + const { passes, failures } = verifySeo(buildDir, { log: console.log }); + console.log(`\n${passes.length} passed, ${failures.length} failed`); + return failures.length > 0 ? 1 : 0; +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + process.exitCode = main(process.argv[2]); +} diff --git a/docs/scripts/verify-seo.test.mjs b/docs/scripts/verify-seo.test.mjs new file mode 100644 index 00000000..5c816b2b --- /dev/null +++ b/docs/scripts/verify-seo.test.mjs @@ -0,0 +1,132 @@ +// Unit tests for the SEO gate. Each test writes a minimal built site into a +// temporary directory and runs verifySeo against it, so the gate's checks are +// exercised without a real Astro build. +// +// pnpm run test (node --test scripts/) + +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import { BASE_URL, verifySeo } from './verify-seo.mjs'; + +/** Render one fixture page. `route` is the path under BASE_URL ('' for the homepage). */ +export function pageHtml({ + route, + title = 'Guide', + canonical = BASE_URL + route, + eulerLinks = ['Euler Stream'], + openGraph = false, + sidebar = true, +} = {}) { + const og = openGraph + ? [ + '<meta property="og:title" content="Getting Started"/>', + '<meta property="og:description" content="Install TikTokLiveJava."/>', + '<meta property="og:image" content="https://ttlj.docs.isaackogan.com/og-image.png"/>', + '<meta name="description" content="Install TikTokLiveJava."/>', + ].join('\n') + : ''; + const links = eulerLinks + .map((text) => `<a href="https://www.eulerstream.com/">${text}</a>`) + .join('\n'); + return `<!DOCTYPE html> +<html lang="en"> +<head> +<meta charset="utf-8"/> +<title>${title} + +${og} + + + +

${title}

Body text.

+
${links}
+ + +`; +} + +function defaultFiles() { + return { + 'index.html': pageHtml({ + route: '', + title: 'TikTok LIVE API for Java: TikTokLiveJava Documentation', + openGraph: true, + eulerLinks: ['Euler Stream', 'TikTok LIVE signing API', "Euler Stream's documentation"], + }), + 'guide/index.html': pageHtml({ route: 'guide/' }), + 'guide/nested/index.html': pageHtml({ route: 'guide/nested/' }), + '404.html': pageHtml({ route: '404/', title: 'Page not found' }), + 'sitemap-index.xml': + '' + + `${BASE_URL}sitemap-0.xml`, + 'sitemap-0.xml': + '' + + `${BASE_URL}${BASE_URL}guide/` + + `${BASE_URL}guide/nested/`, + 'robots.txt': `User-agent: *\nAllow: /\n\nSitemap: ${BASE_URL}sitemap-index.xml\n`, + }; +} + +/** + * Write a minimal built site into `dir`. `overrides` maps a relative path to + * its content; a `null` value omits that file from the fixture. + */ +export function writeFixtureSite(dir, overrides = {}) { + const files = { ...defaultFiles(), ...overrides }; + for (const [relative, content] of Object.entries(files)) { + if (content === null) continue; + const target = path.join(dir, relative); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content, 'utf8'); + } + return dir; +} + +function fixtureDir(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'verify-seo-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +test('gatePassesOnFixtureSite', (t) => { + const dir = writeFixtureSite(fixtureDir(t)); + + const { passes, failures } = verifySeo(dir); + + assert.deepEqual(failures, []); + assert.ok(passes.length >= 15, `expected every check to run, got ${passes.length}`); +}); + +test('failsWhenNestedPageLacksEulerLink', (t) => { + const dir = writeFixtureSite(fixtureDir(t), { + 'guide/nested/index.html': pageHtml({ route: 'guide/nested/', eulerLinks: [] }), + }); + + const { failures } = verifySeo(dir); + + assert.deepEqual(failures, ['eulerstream.com linked from every page']); +}); + +test('failsWhenRobotsHasNoSitemap', (t) => { + const dir = writeFixtureSite(fixtureDir(t), { + 'robots.txt': 'User-agent: *\nAllow: /\n', + }); + + const { failures } = verifySeo(dir); + + assert.deepEqual(failures, ['robots.txt points at the sitemap index']); +}); + +test('failsWhenCanonicalPointsAtAnotherPage', (t) => { + const dir = writeFixtureSite(fixtureDir(t), { + 'guide/nested/index.html': pageHtml({ route: 'guide/nested/', canonical: BASE_URL + 'guide/' }), + }); + + const { failures } = verifySeo(dir); + + assert.deepEqual(failures, ['every page has rel=canonical pointing at its own URL']); +}); From 99e4f73a1ba52b1bddcb0711390a459ce11f196d Mon Sep 17 00:00:00 2001 From: isaackogan Date: Tue, 20 Oct 2026 18:41:29 -0400 Subject: [PATCH 18/31] docs: inset the logo on the social-preview image Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- docs/astro.config.mjs | 2 +- docs/public/og-image.png | Bin 106575 -> 87778 bytes 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index cdcf6f96..b51bdbb9 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -27,7 +27,7 @@ export default defineConfig({ description: 'Unofficial TikTok LIVE API for Java. Guides, events, and generated API reference.', favicon: '/favicon.png', head: [ - // public/og-image.png is tools-readme/src/main/resources/logo.svg rendered with sharp: sharp(svg, { density: 144 }).resize(1200, 630, { fit: 'contain', background: '#fff' }).flatten({ background: '#fff' }).png() + // public/og-image.png is tools-readme/src/main/resources/logo.svg rendered with sharp: resize(542, 542, { fit: 'contain' }) at density 144, composited centred on a white 1200x630 canvas, then flatten() to drop alpha { tag: 'meta', attrs: { property: 'og:image', content: 'https://ttlj.docs.isaackogan.com/og-image.png' } }, { tag: 'meta', attrs: { property: 'og:image:alt', content: 'TikTokLiveJava, TikTok LIVE API for Java' } }, { tag: 'meta', attrs: { name: 'twitter:card', content: 'summary_large_image' } }, diff --git a/docs/public/og-image.png b/docs/public/og-image.png index 215827b8dfe6a195fabcf37774997c7c98aaf1d2..3e65ca60e430a3827ea1ae64b5b48d4447f4e897 100644 GIT binary patch literal 87778 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%0D*m5x~54z2s7X_Z)d{ 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 l`>O}Q&Q9gwkGb;gmv2BwaWV~A@&ez Date: Tue, 20 Oct 2026 18:42:46 -0400 Subject: [PATCH 19/31] docs: guard the scheduled upstream build until upstream carries the docs Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- docs/superpowers/plans/2026-10-20-starlight-docs.md | 4 ++-- docs/superpowers/specs/2026-10-20-starlight-docs-design.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-10-20-starlight-docs.md b/docs/superpowers/plans/2026-10-20-starlight-docs.md index b78489d8..1b4b44c0 100644 --- a/docs/superpowers/plans/2026-10-20-starlight-docs.md +++ b/docs/superpowers/plans/2026-10-20-starlight-docs.md @@ -287,8 +287,8 @@ git commit -m "docs: add content coverage and example compilation checks" **Interfaces:** - `wrangler.jsonc`: `name tiktoklivejava-docs`, `compatibility_date 2026-10-20`, assets `./dist`, `auto-trailing-slash`, `404-page`, `workers_dev false`, route `ttlj.docs.isaackogan.com` `custom_domain true`. -- `docs.yml`: name `Documentation`; triggers `pull_request` and `push` to `master` with paths `docs/**`, `API/**`, `Client/**`, `extension-recorder/**`, `extension-collector/**`, `README.md`, `.github/workflows/docs.yml`; `workflow_dispatch`; `schedule: - cron: '17 6 * * *'`; `permissions: contents: read`; concurrency `docs-${{ github.ref }}`. Job `build` (env `DEPLOY_ENABLED: ${{ secrets.CLOUDFLARE_API_TOKEN != '' }}`): checkout, where `with.repository` is `jwdeveloper/TikTokLiveJava` and `ref: master` **only when** `github.event_name == 'schedule'` (use two checkout steps with opposite `if`s); `actions/setup-java@v4` (`temurin`, `17`, `cache: maven`); `mvn -q -pl API,Client,extension-recorder,extension-collector -am -DskipTests package`; `actions/setup-node@v4` (24); `npm install --global pnpm@10.34.2`; `pnpm --dir docs install --frozen-lockfile`; `pnpm --dir docs test`; `pnpm --dir docs build`; `pnpm --dir docs run seo`; `actions/upload-artifact@v4` (`documentation`, `docs/dist`); deploy `cloudflare/wrangler-action@v4` (`apiToken`, `accountId`, `workingDirectory: docs`, `packageManager: pnpm`, `command: deploy`) with `if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/master' || github.event_name == 'schedule') && env.DEPLOY_ENABLED == 'true'`. -- Validation: `npx -y js-yaml .github/workflows/docs.yml > /dev/null` parses; `grep -c 'repository: jwdeveloper/TikTokLiveJava' .github/workflows/docs.yml` prints 1 and that line sits in the checkout step whose `if` is `github.event_name == 'schedule'` (the other checkout step's `if` is `github.event_name != 'schedule'` and has no `repository` input); `cd docs && npx -y pnpm@10.34.2 exec wrangler deploy --dry-run --outdir /tmp/wrangler-dry` succeeds. +- `docs.yml`: name `Documentation`; triggers `pull_request` and `push` to `master` with paths `docs/**`, `API/**`, `Client/**`, `extension-recorder/**`, `extension-collector/**`, `README.md`, `.github/workflows/docs.yml`; `workflow_dispatch`; `schedule: - cron: '17 6 * * *'`; `permissions: contents: read`; concurrency `docs-${{ github.ref }}`. Job `build` (env `DEPLOY_ENABLED: ${{ secrets.CLOUDFLARE_API_TOKEN != '' }}`): checkout, where `with.repository` is `jwdeveloper/TikTokLiveJava` and `ref: master` **only when** `github.event_name == 'schedule'` (use two checkout steps with opposite `if`s); `actions/setup-java@v4` (`temurin`, `17`, `cache: maven`); `mvn -q -pl API,Client,extension-recorder,extension-collector -am -DskipTests package`; `actions/setup-node@v4` (24); `npm install --global pnpm@10.34.2`; `pnpm --dir docs install --frozen-lockfile`; `pnpm --dir docs test`; `pnpm --dir docs build`; `pnpm --dir docs run seo`; `actions/upload-artifact@v4` (`documentation`, `docs/dist`); deploy `cloudflare/wrangler-action@v4` (`apiToken`, `accountId`, `workingDirectory: docs`, `packageManager: pnpm`, `command: deploy`) with `if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/master' || github.event_name == 'schedule') && env.DEPLOY_ENABLED == 'true'`. A first job `upstream` (ubuntu-latest, output `has_docs`) whose single step sets `has_docs=true` unless `github.event_name == 'schedule'` and `gh api repos/jwdeveloper/TikTokLiveJava/contents/docs/package.json --silent` fails (env `GH_TOKEN: ${{ github.token }}`; print a `::notice::` when skipping); job `build` has `needs: upstream` and `if: needs.upstream.outputs.has_docs == 'true'` (ruling J10). +- Validation: `npx -y js-yaml .github/workflows/docs.yml > /dev/null` parses; `grep -c 'repository: jwdeveloper/TikTokLiveJava' .github/workflows/docs.yml` prints 1 and that line sits in the checkout step whose `if` is `github.event_name == 'schedule'` (the other checkout step's `if` is `github.event_name != 'schedule'` and has no `repository` input); `cd docs && npx -y pnpm@10.34.2 exec wrangler deploy --dry-run --outdir /tmp/wrangler-dry` succeeds. `grep -c "needs.upstream.outputs.has_docs == 'true'" .github/workflows/docs.yml` prints 1. - [ ] **Step 1: Write both files.** - [ ] **Step 2: Validate as above; reconcile `hosting.md`, `contributing.md`, `docs/README.md` with the real files.** - [ ] **Step 3: Commit** `docs: deploy the site to Cloudflare Workers from GitHub Actions`. diff --git a/docs/superpowers/specs/2026-10-20-starlight-docs-design.md b/docs/superpowers/specs/2026-10-20-starlight-docs-design.md index 91a6c019..78863e0c 100644 --- a/docs/superpowers/specs/2026-10-20-starlight-docs-design.md +++ b/docs/superpowers/specs/2026-10-20-starlight-docs-design.md @@ -120,6 +120,6 @@ Accepted builder defaults: extensions on the example classpath; `readme` check r 1. **Hostname:** `ttlj.docs.isaackogan.com`. 2. **Discord button:** the README's `https://discord.gg/e2XwPNTBBr`. -3. **Deploys after the upstream merge:** the fork keeps deploying. The docs commits are merged into the fork's `master` (a normal merge, no force push) so `docs.yml` lives on the fork's default branch; its scheduled path checks out upstream `master` and deploys from it. The upstream copy of the workflow is gated on the secret and only validates there until jwdeveloper chooses to add secrets. +3. **Deploys after the upstream merge:** the fork keeps deploying. The docs commits are merged into the fork's `master` (a normal merge, no force push) so `docs.yml` lives on the fork's default branch; its scheduled path checks out upstream `master` and deploys from it. The upstream copy of the workflow is gated on the secret and only validates there until jwdeveloper chooses to add secrets. Until upstream carries `docs/`, a scheduled run would check out a tree with nothing to build, so the workflow's first job probes upstream for `docs/package.json` and the build job is skipped on a schedule when it is absent (ruling J10, 2026-10-20). 4. **First-deploy token (revised 2026-10-20):** the maintainer had the Worker `tiktoklivejava-docs` created from their own wrangler login with a placeholder page and the custom domain already attached, so the fork's CI only deploys to an existing Worker. The fork's `CLOUDFLARE_API_TOKEN` is therefore an account-owned token scoped to Editor on `tiktoklivejava-docs` (the same recipe as the sibling sites); no zone or account-wide permission is needed. 5. **PR target:** `jwdeveloper/TikTokLiveJava:master` from `isaackogan:docs/starlight-site`. From 26ddce8fe2de0be622772609d006472f284b5699 Mon Sep 17 00:00:00 2001 From: isaackogan Date: Tue, 20 Oct 2026 18:46:03 -0400 Subject: [PATCH 20/31] docs: attach the custom domain out of band so CI needs only a Worker-scoped token Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- docs/superpowers/plans/2026-10-20-starlight-docs.md | 2 +- docs/superpowers/specs/2026-10-20-starlight-docs-design.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-10-20-starlight-docs.md b/docs/superpowers/plans/2026-10-20-starlight-docs.md index 1b4b44c0..c09cd6fe 100644 --- a/docs/superpowers/plans/2026-10-20-starlight-docs.md +++ b/docs/superpowers/plans/2026-10-20-starlight-docs.md @@ -286,7 +286,7 @@ git commit -m "docs: add content coverage and example compilation checks" **Files:** Create `docs/wrangler.jsonc`, `.github/workflows/docs.yml`. **Interfaces:** -- `wrangler.jsonc`: `name tiktoklivejava-docs`, `compatibility_date 2026-10-20`, assets `./dist`, `auto-trailing-slash`, `404-page`, `workers_dev false`, route `ttlj.docs.isaackogan.com` `custom_domain true`. +- `wrangler.jsonc`: `name tiktoklivejava-docs`, `compatibility_date 2026-10-20`, assets `./dist`, `auto-trailing-slash`, `404-page`, `workers_dev false`, and no `routes` block (the custom domain `ttlj.docs.isaackogan.com` is already attached to the Worker and survives deploys; wrangler prints `No targets deployed`, which is expected; ruling J12). `hosting.md` states the one-time domain attachment and that the CI token is scoped to the Worker only. - `docs.yml`: name `Documentation`; triggers `pull_request` and `push` to `master` with paths `docs/**`, `API/**`, `Client/**`, `extension-recorder/**`, `extension-collector/**`, `README.md`, `.github/workflows/docs.yml`; `workflow_dispatch`; `schedule: - cron: '17 6 * * *'`; `permissions: contents: read`; concurrency `docs-${{ github.ref }}`. Job `build` (env `DEPLOY_ENABLED: ${{ secrets.CLOUDFLARE_API_TOKEN != '' }}`): checkout, where `with.repository` is `jwdeveloper/TikTokLiveJava` and `ref: master` **only when** `github.event_name == 'schedule'` (use two checkout steps with opposite `if`s); `actions/setup-java@v4` (`temurin`, `17`, `cache: maven`); `mvn -q -pl API,Client,extension-recorder,extension-collector -am -DskipTests package`; `actions/setup-node@v4` (24); `npm install --global pnpm@10.34.2`; `pnpm --dir docs install --frozen-lockfile`; `pnpm --dir docs test`; `pnpm --dir docs build`; `pnpm --dir docs run seo`; `actions/upload-artifact@v4` (`documentation`, `docs/dist`); deploy `cloudflare/wrangler-action@v4` (`apiToken`, `accountId`, `workingDirectory: docs`, `packageManager: pnpm`, `command: deploy`) with `if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/master' || github.event_name == 'schedule') && env.DEPLOY_ENABLED == 'true'`. A first job `upstream` (ubuntu-latest, output `has_docs`) whose single step sets `has_docs=true` unless `github.event_name == 'schedule'` and `gh api repos/jwdeveloper/TikTokLiveJava/contents/docs/package.json --silent` fails (env `GH_TOKEN: ${{ github.token }}`; print a `::notice::` when skipping); job `build` has `needs: upstream` and `if: needs.upstream.outputs.has_docs == 'true'` (ruling J10). - Validation: `npx -y js-yaml .github/workflows/docs.yml > /dev/null` parses; `grep -c 'repository: jwdeveloper/TikTokLiveJava' .github/workflows/docs.yml` prints 1 and that line sits in the checkout step whose `if` is `github.event_name == 'schedule'` (the other checkout step's `if` is `github.event_name != 'schedule'` and has no `repository` input); `cd docs && npx -y pnpm@10.34.2 exec wrangler deploy --dry-run --outdir /tmp/wrangler-dry` succeeds. `grep -c "needs.upstream.outputs.has_docs == 'true'" .github/workflows/docs.yml` prints 1. diff --git a/docs/superpowers/specs/2026-10-20-starlight-docs-design.md b/docs/superpowers/specs/2026-10-20-starlight-docs-design.md index 78863e0c..03549b28 100644 --- a/docs/superpowers/specs/2026-10-20-starlight-docs-design.md +++ b/docs/superpowers/specs/2026-10-20-starlight-docs-design.md @@ -83,7 +83,7 @@ Why JavaParser rather than a Javadoc doclet: it needs no compile of the library, ### 7.4 Hosting and deployment -- `docs/wrangler.jsonc`: Worker `tiktoklivejava-docs`, `assets.directory ./dist`, `auto-trailing-slash`, `404-page`, `workers_dev false`, custom domain route `ttlj.docs.isaackogan.com`. +- `docs/wrangler.jsonc`: Worker `tiktoklivejava-docs`, `assets.directory ./dist`, `auto-trailing-slash`, `404-page`, `workers_dev false`, and no `routes` block: the custom domain `ttlj.docs.isaackogan.com` is attached to the Worker once from a maintainer's wrangler login and persists across deploys, so the CI token needs no zone permission (ruling J12, 2026-10-20). - `.github/workflows/docs.yml`: on `pull_request` and `push` to `master` (paths `docs/**`, `API/**`, `Client/**`, `extension-*/**`, `README.md`, `tools-readme/**`, `.github/workflows/docs.yml`) and `workflow_dispatch`; JDK 17 with Maven cache; `mvn -q -pl API,Client -am -DskipTests install` so the docs tools can compile examples against the library; Node 24 and pnpm 10.34.2; `pnpm --dir docs install --frozen-lockfile`; `pnpm --dir docs build`; SEO gate; upload the artifact; deploy with `cloudflare/wrangler-action@v4` when `github.ref == 'refs/heads/master'`, the event is not a pull request, and `DEPLOY_ENABLED` (derived from the `CLOUDFLARE_API_TOKEN` secret) is true. - **Bootstrap from the fork:** the branch is first merged into the fork's `master` (fork-local; upstream unaffected) with the secrets set on the fork, so the fork's first `master` run creates the Worker and attaches the domain. That first run needs a token that can create Workers and routes: Account → Workers Scripts → Edit, Account → Account Settings → Read, Zone → Workers Routes → Edit and Zone → Zone → Read on `isaackogan.com` (the same four-permission recipe as the Node site's first token). After the Worker exists the token can be replaced by one scoped to Editor on `tiktoklivejava-docs`, since later deploys only reconcile an existing domain. - **After the upstream merge** (open question 3): upstream squash-merges, so the fork's `master`, which already carries the docs commits, can never fast-forward to upstream `master` (builder-check question 2). Therefore no sync-by-push exists. If the fork keeps deploying, `docs.yml` gains a `schedule` (daily) and `workflow_dispatch` path that checks out `jwdeveloper/TikTokLiveJava` at `master` with `actions/checkout` (`repository:` and `ref:` inputs; the repo is public), builds, and deploys from that checkout without pushing anything to the fork; the push-triggered path keeps deploying the fork's own `master`. If instead the upstream maintainer adds the secrets, the same workflow deploys from upstream directly and the fork's scheduled path can be disabled. No force pushes anywhere. @@ -121,5 +121,5 @@ Accepted builder defaults: extensions on the example classpath; `readme` check r 1. **Hostname:** `ttlj.docs.isaackogan.com`. 2. **Discord button:** the README's `https://discord.gg/e2XwPNTBBr`. 3. **Deploys after the upstream merge:** the fork keeps deploying. The docs commits are merged into the fork's `master` (a normal merge, no force push) so `docs.yml` lives on the fork's default branch; its scheduled path checks out upstream `master` and deploys from it. The upstream copy of the workflow is gated on the secret and only validates there until jwdeveloper chooses to add secrets. Until upstream carries `docs/`, a scheduled run would check out a tree with nothing to build, so the workflow's first job probes upstream for `docs/package.json` and the build job is skipped on a schedule when it is absent (ruling J10, 2026-10-20). -4. **First-deploy token (revised 2026-10-20):** the maintainer had the Worker `tiktoklivejava-docs` created from their own wrangler login with a placeholder page and the custom domain already attached, so the fork's CI only deploys to an existing Worker. The fork's `CLOUDFLARE_API_TOKEN` is therefore an account-owned token scoped to Editor on `tiktoklivejava-docs` (the same recipe as the sibling sites); no zone or account-wide permission is needed. +4. **First-deploy token (revised 2026-10-20):** the maintainer had the Worker `tiktoklivejava-docs` created from their own wrangler login with a placeholder page and the custom domain already attached, so the fork's CI only deploys to an existing Worker. The fork's `CLOUDFLARE_API_TOKEN` is therefore an account-owned token scoped to Editor on `tiktoklivejava-docs` (the same recipe as the sibling sites); no zone or account-wide permission is needed. Verified 2026-10-20: a token with Worker-only rights uploads fine but cannot reconcile a `routes` custom domain (403 on the zone lookup), which is why the repository config omits `routes` (ruling J12). 5. **PR target:** `jwdeveloper/TikTokLiveJava:master` from `isaackogan:docs/starlight-site`. From bf9888022b6b9820c7bfb40157f3f2f5b064f901 Mon Sep 17 00:00:00 2001 From: isaackogan Date: Tue, 20 Oct 2026 18:51:51 -0400 Subject: [PATCH 21/31] docs: point the README at the hosted documentation Add a documentation pointer after the introduction and a Documentation entry at the top of the events list, both to https://ttlj.docs.isaackogan.com/. Each events-list item keeps its in-page anchor and gains a docs link to the event's section on the hosted event pages, and each event section carries a Documentation line with the same link. Each section heading's source link gains the api/ path segment the files moved under, so the links resolve again. The two links to the Node original now point at zerodytrash/TikTok-Live-Connector, where that repository lives. Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- README.md | 152 ++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 102 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 86f91dcf..9cea04f5 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ A Java library inspired by [TikTokLive](https://github.com/isaackogan/TikTokLive The library includes a wrapper that connects to the WebCast service using just the username (`uniqueId`). This allows you to connect to your own live chat as well as the live chat of other streamers. No credentials are required. Events such as [Members Joining](#member), [Gifts](#gift), [Subscriptions](#subscribe), [Viewers](#roomuser), [Follows](#social), [Shares](#social), [Questions](#questionnew), [Likes](#like) and [Battles](#linkmicbattle) can be tracked. +Full documentation, guides and the complete event reference: [ttlj.docs.isaackogan.com](https://ttlj.docs.isaackogan.com/) +

@@ -38,7 +40,7 @@ No credentials are required. Events such as [Members Joining](#member), [Gifts]( Join the support [discord](https://discord.gg/e2XwPNTBBr) and visit the `#java-support` channel for questions, contributions and ideas. Feel free to make pull requests with missing/new features, fixes, etc Do you prefer other programming languages? -- **Node** orginal: [TikTok-Live-Connector](https://github.com/isaackogan/TikTok-Live-Connector) by [@zerodytrash](https://github.com/zerodytrash) +- **Node** orginal: [TikTok-Live-Connector](https://github.com/zerodytrash/TikTok-Live-Connector) by [@zerodytrash](https://github.com/zerodytrash) - **Rust** rewrite: [TikTokLiveRust](https://github.com/jwdeveloper/TikTokLiveRust) - **Python** rewrite: [TikTokLive](https://github.com/isaackogan/TikTokLive) by [@isaackogan](https://github.com/isaackogan) - **Go** rewrite: [GoTikTokLive](https://github.com/Davincible/gotiktoklive) by [@Davincible](https://github.com/Davincible) @@ -151,7 +153,7 @@ TikTokLive.newClient("bangbetmenygy") settings.setRetryConnectionTimeout(Duration.ofSeconds(1)); // Timeout before next reconnection //Optional: Sometimes not every message from chat are send to TikTokLiveJava to fix this issue you can set sessionId - // documentation how to obtain sessionId https://github.com/isaackogan/TikTok-Live-Connector#send-chat-messages + // documentation how to obtain sessionId https://github.com/zerodytrash/TikTok-Live-Connector#send-chat-messages settings.setSessionId("86c3c8bf4b17ebb2d74bb7fa66fd0000"); //Optional: @@ -172,42 +174,46 @@ TikTokLive.newClient("bangbetmenygy") +- [Documentation](https://ttlj.docs.isaackogan.com/) + **Control**: -- [onReconnecting](#onreconnecting-tiktokreconnectingevent) -- [onError](#onerror-tiktokerrorevent) -- [onConnected](#onconnected-tiktokconnectedevent) -- [onDisconnected](#ondisconnected-tiktokdisconnectedevent) +- [onReconnecting](#onreconnecting-tiktokreconnectingevent) · [docs](https://ttlj.docs.isaackogan.com/events/control/#tiktokreconnectingevent) +- [onError](#onerror-tiktokerrorevent) · [docs](https://ttlj.docs.isaackogan.com/events/control/#tiktokerrorevent) +- [onConnected](#onconnected-tiktokconnectedevent) · [docs](https://ttlj.docs.isaackogan.com/events/control/#tiktokconnectedevent) +- [onDisconnected](#ondisconnected-tiktokdisconnectedevent) · [docs](https://ttlj.docs.isaackogan.com/events/control/#tiktokdisconnectedevent) **Message**: -- [onEvent](#onevent-tiktokevent) -- [onEvent](#onevent-tiktokevent) -- [onComment](#oncomment-tiktokcommentevent) -- [onRoomInfo](#onroominfo-tiktokroominfoevent) -- [onGift](#ongift-tiktokgiftevent) -- [onSubscribe](#onsubscribe-tiktoksubscribeevent) -- [onFollow](#onfollow-tiktokfollowevent) -- [onGiftCombo](#ongiftcombo-tiktokgiftcomboevent) -- [onLiveEnded](#onliveended-tiktokliveendedevent) -- [onQuestion](#onquestion-tiktokquestionevent) -- [onShare](#onshare-tiktokshareevent) -- [onLiveUnpaused](#onliveunpaused-tiktokliveunpausedevent) -- [onEmote](#onemote-tiktokemoteevent) -- [onJoin](#onjoin-tiktokjoinevent) -- [onLike](#onlike-tiktoklikeevent) -- [onLivePaused](#onlivepaused-tiktoklivepausedevent) +- [onEvent](#onevent-tiktokevent) · [docs](https://ttlj.docs.isaackogan.com/events/) +- [onEvent](#onevent-tiktokevent) · [docs](https://ttlj.docs.isaackogan.com/events/) +- [onComment](#oncomment-tiktokcommentevent) · [docs](https://ttlj.docs.isaackogan.com/events/chat-and-audience/#tiktokcommentevent) +- [onRoomInfo](#onroominfo-tiktokroominfoevent) · [docs](https://ttlj.docs.isaackogan.com/events/room-and-display/#tiktokroominfoevent) +- [onGift](#ongift-tiktokgiftevent) · [docs](https://ttlj.docs.isaackogan.com/events/gifts-and-goals/#tiktokgiftevent) +- [onSubscribe](#onsubscribe-tiktoksubscribeevent) · [docs](https://ttlj.docs.isaackogan.com/events/subscriptions-and-fans/#tiktoksubscribeevent) +- [onFollow](#onfollow-tiktokfollowevent) · [docs](https://ttlj.docs.isaackogan.com/events/chat-and-audience/#tiktokfollowevent) +- [onGiftCombo](#ongiftcombo-tiktokgiftcomboevent) · [docs](https://ttlj.docs.isaackogan.com/events/gifts-and-goals/#tiktokgiftcomboevent) +- [onLiveEnded](#onliveended-tiktokliveendedevent) · [docs](https://ttlj.docs.isaackogan.com/events/stream-control/#tiktokliveendedevent) +- [onQuestion](#onquestion-tiktokquestionevent) · [docs](https://ttlj.docs.isaackogan.com/events/chat-and-audience/#tiktokquestionevent) +- [onShare](#onshare-tiktokshareevent) · [docs](https://ttlj.docs.isaackogan.com/events/chat-and-audience/#tiktokshareevent) +- [onLiveUnpaused](#onliveunpaused-tiktokliveunpausedevent) · [docs](https://ttlj.docs.isaackogan.com/events/stream-control/#tiktokliveunpausedevent) +- [onEmote](#onemote-tiktokemoteevent) · [docs](https://ttlj.docs.isaackogan.com/events/chat-and-audience/#tiktokemoteevent) +- [onJoin](#onjoin-tiktokjoinevent) · [docs](https://ttlj.docs.isaackogan.com/events/chat-and-audience/#tiktokjoinevent) +- [onLike](#onlike-tiktoklikeevent) · [docs](https://ttlj.docs.isaackogan.com/events/chat-and-audience/#tiktoklikeevent) +- [onLivePaused](#onlivepaused-tiktoklivepausedevent) · [docs](https://ttlj.docs.isaackogan.com/events/stream-control/#tiktoklivepausedevent) **Debug**: -- [onWebsocketResponse](#onwebsocketresponse-tiktokwebsocketresponseevent) -- [onWebsocketUnhandledMessage](#onwebsocketunhandledmessage-tiktokwebsocketunhandledmessageevent) -- [onHttpResponse](#onhttpresponse-tiktokhttpresponseevent) -- [onWebsocketMessage](#onwebsocketmessage-tiktokwebsocketmessageevent) +- [onWebsocketResponse](#onwebsocketresponse-tiktokwebsocketresponseevent) · [docs](https://ttlj.docs.isaackogan.com/events/debugging/#tiktokwebsocketresponseevent) +- [onWebsocketUnhandledMessage](#onwebsocketunhandledmessage-tiktokwebsocketunhandledmessageevent) · [docs](https://ttlj.docs.isaackogan.com/events/debugging/#tiktokwebsocketunhandledmessageevent) +- [onHttpResponse](#onhttpresponse-tiktokhttpresponseevent) · [docs](https://ttlj.docs.isaackogan.com/events/debugging/#tiktokhttpresponseevent) +- [onWebsocketMessage](#onwebsocketmessage-tiktokwebsocketmessageevent) · [docs](https://ttlj.docs.isaackogan.com/events/debugging/#tiktokwebsocketmessageevent) # Examples
-## onReconnecting [TikTokReconnectingEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/TikTokReconnectingEvent.java) +## onReconnecting [TikTokReconnectingEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/TikTokReconnectingEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/control/#tiktokreconnectingevent @@ -224,7 +230,9 @@ TikTokLive.newClient("host-name")
-## onError [TikTokErrorEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/TikTokErrorEvent.java) +## onError [TikTokErrorEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/TikTokErrorEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/control/#tiktokerrorevent General error event. You should handle this. @@ -243,7 +251,9 @@ TikTokLive.newClient("host-name")
-## onConnected [TikTokConnectedEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/TikTokConnectedEvent.java) +## onConnected [TikTokConnectedEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/TikTokConnectedEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/control/#tiktokconnectedevent Triggered when the connection is successfully established. @@ -262,7 +272,9 @@ TikTokLive.newClient("host-name")
-## onDisconnected [TikTokDisconnectedEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/TikTokDisconnectedEvent.java) +## onDisconnected [TikTokDisconnectedEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/TikTokDisconnectedEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/control/#tiktokdisconnectedevent Triggered when the connection gets disconnected. In that case you can call connect() again to have a reconnect logic. @@ -282,7 +294,9 @@ TikTokLive.newClient("host-name")
-## onEvent [TikTokEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/common/TikTokEvent.java) +## onEvent [TikTokEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/common/TikTokEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/ Base class for all events @@ -301,7 +315,9 @@ TikTokLive.newClient("host-name")
-## onEvent [TikTokEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/common/TikTokEvent.java) +## onEvent [TikTokEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/common/TikTokEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/ Base class for all events @@ -320,7 +336,9 @@ TikTokLive.newClient("host-name")
-## onComment [TikTokCommentEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/TikTokCommentEvent.java) +## onComment [TikTokCommentEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/TikTokCommentEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/chat-and-audience/#tiktokcommentevent Triggered every time a new chat comment arrives. @@ -339,7 +357,9 @@ TikTokLive.newClient("host-name")
-## onRoomInfo [TikTokRoomInfoEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/room/TikTokRoomInfoEvent.java) +## onRoomInfo [TikTokRoomInfoEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/room/TikTokRoomInfoEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/room-and-display/#tiktokroominfoevent Triggered when LiveRoomInfo got updated such as likes, viewers, ranking .... @@ -358,7 +378,9 @@ TikTokLive.newClient("host-name")
-## onGift [TikTokGiftEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/gift/TikTokGiftEvent.java) +## onGift [TikTokGiftEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/gift/TikTokGiftEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/gifts-and-goals/#tiktokgiftevent Triggered when user sends gifts that has @@ -379,7 +401,9 @@ TikTokLive.newClient("host-name")
-## onSubscribe [TikTokSubscribeEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/TikTokSubscribeEvent.java) +## onSubscribe [TikTokSubscribeEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/TikTokSubscribeEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/subscriptions-and-fans/#tiktoksubscribeevent Triggers when a user creates a subscription. @@ -398,7 +422,9 @@ TikTokLive.newClient("host-name")
-## onFollow [TikTokFollowEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/social/TikTokFollowEvent.java) +## onFollow [TikTokFollowEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/social/TikTokFollowEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/chat-and-audience/#tiktokfollowevent Triggers when a user follows the streamer. Based on social event. @@ -417,7 +443,9 @@ TikTokLive.newClient("host-name")
-## onGiftCombo [TikTokGiftComboEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/gift/TikTokGiftComboEvent.java) +## onGiftCombo [TikTokGiftComboEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/gift/TikTokGiftComboEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/gifts-and-goals/#tiktokgiftcomboevent Triggered every time gift is sent @@ -446,7 +474,9 @@ TikTokLive.newClient("host-name")
-## onLiveEnded [TikTokLiveEndedEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/TikTokLiveEndedEvent.java) +## onLiveEnded [TikTokLiveEndedEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/TikTokLiveEndedEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/stream-control/#tiktokliveendedevent Triggered when the live stream gets terminated by the host. Will also trigger the TikTokDisconnectedEvent event. @@ -465,7 +495,9 @@ TikTokLive.newClient("host-name")
-## onQuestion [TikTokQuestionEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/TikTokQuestionEvent.java) +## onQuestion [TikTokQuestionEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/TikTokQuestionEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/chat-and-audience/#tiktokquestionevent Triggered every time someone asks a new question via the question feature. @@ -484,7 +516,9 @@ TikTokLive.newClient("host-name")
-## onShare [TikTokShareEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/social/TikTokShareEvent.java) +## onShare [TikTokShareEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/social/TikTokShareEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/chat-and-audience/#tiktokshareevent Triggers when a user shares the stream. Based on social event. @@ -503,7 +537,9 @@ TikTokLive.newClient("host-name")
-## onLiveUnpaused [TikTokLiveUnpausedEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/TikTokLiveUnpausedEvent.java) +## onLiveUnpaused [TikTokLiveUnpausedEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/TikTokLiveUnpausedEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/stream-control/#tiktokliveunpausedevent @@ -520,7 +556,9 @@ TikTokLive.newClient("host-name")
-## onEmote [TikTokEmoteEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/TikTokEmoteEvent.java) +## onEmote [TikTokEmoteEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/TikTokEmoteEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/chat-and-audience/#tiktokemoteevent Triggered every time a subscriber sends an emote (sticker). @@ -539,7 +577,9 @@ TikTokLive.newClient("host-name")
-## onJoin [TikTokJoinEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/social/TikTokJoinEvent.java) +## onJoin [TikTokJoinEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/social/TikTokJoinEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/chat-and-audience/#tiktokjoinevent @@ -556,7 +596,9 @@ TikTokLive.newClient("host-name")
-## onLike [TikTokLikeEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/social/TikTokLikeEvent.java) +## onLike [TikTokLikeEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/social/TikTokLikeEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/chat-and-audience/#tiktoklikeevent Triggered when a viewer sends likes to the streamer. For streams with many viewers, this event is not always triggered by TikTok. @@ -575,7 +617,9 @@ TikTokLive.newClient("host-name")
-## onLivePaused [TikTokLivePausedEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/TikTokLivePausedEvent.java) +## onLivePaused [TikTokLivePausedEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/TikTokLivePausedEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/stream-control/#tiktoklivepausedevent @@ -592,7 +636,9 @@ TikTokLive.newClient("host-name")
-## onWebsocketResponse [TikTokWebsocketResponseEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/websocket/TikTokWebsocketResponseEvent.java) +## onWebsocketResponse [TikTokWebsocketResponseEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/websocket/TikTokWebsocketResponseEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/debugging/#tiktokwebsocketresponseevent @@ -609,7 +655,9 @@ TikTokLive.newClient("host-name")
-## onWebsocketUnhandledMessage [TikTokWebsocketUnhandledMessageEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/websocket/TikTokWebsocketUnhandledMessageEvent.java) +## onWebsocketUnhandledMessage [TikTokWebsocketUnhandledMessageEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/websocket/TikTokWebsocketUnhandledMessageEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/debugging/#tiktokwebsocketunhandledmessageevent Triggered every time a protobuf encoded webcast message arrives. You can deserialize the binary object depending on the use case. @@ -628,7 +676,9 @@ TikTokLive.newClient("host-name")
-## onHttpResponse [TikTokHttpResponseEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/http/TikTokHttpResponseEvent.java) +## onHttpResponse [TikTokHttpResponseEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/http/TikTokHttpResponseEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/debugging/#tiktokhttpresponseevent @@ -645,7 +695,9 @@ TikTokLive.newClient("host-name")
-## onWebsocketMessage [TikTokWebsocketMessageEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/data/events/websocket/TikTokWebsocketMessageEvent.java) +## onWebsocketMessage [TikTokWebsocketMessageEvent](https://github.com/jwdeveloper/TikTokLiveJava/blob/master/API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/websocket/TikTokWebsocketMessageEvent.java) + +Documentation: https://ttlj.docs.isaackogan.com/events/debugging/#tiktokwebsocketmessageevent Triggered every time TikTok sends data. Data incoming as protobuf message. From e89cc76dd7e53c600922197feafd17f174d6e325 Mon Sep 17 00:00:00 2001 From: isaackogan Date: Tue, 20 Oct 2026 19:02:09 -0400 Subject: [PATCH 22/31] docs: apply the repository license header to the docs tool sources Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- docs/tools/src/main/java/docs/ApiPages.java | 22 +++++++++++++++++++ .../src/main/java/docs/ContentCheck.java | 22 +++++++++++++++++++ docs/tools/src/main/java/docs/Example.java | 22 +++++++++++++++++++ .../src/main/java/docs/ExampleCompiler.java | 22 +++++++++++++++++++ .../tools/src/main/java/docs/JavaSources.java | 22 +++++++++++++++++++ docs/tools/src/main/java/docs/Main.java | 22 +++++++++++++++++++ docs/tools/src/main/java/docs/Markdown.java | 22 +++++++++++++++++++ docs/tools/src/main/java/docs/ProtoEnum.java | 22 +++++++++++++++++++ .../src/main/java/docs/ProtoEnumValue.java | 22 +++++++++++++++++++ docs/tools/src/main/java/docs/ProtoField.java | 22 +++++++++++++++++++ docs/tools/src/main/java/docs/ProtoFile.java | 22 +++++++++++++++++++ .../src/main/java/docs/ProtoMessage.java | 22 +++++++++++++++++++ .../tools/src/main/java/docs/ProtoParser.java | 22 +++++++++++++++++++ .../tools/src/main/java/docs/SchemaPages.java | 22 +++++++++++++++++++ .../src/test/java/docs/ApiPagesTest.java | 22 +++++++++++++++++++ .../src/test/java/docs/ContentCheckTest.java | 22 +++++++++++++++++++ .../test/java/docs/ExampleCompilerTest.java | 22 +++++++++++++++++++ .../src/test/java/docs/ProtoParserTest.java | 22 +++++++++++++++++++ .../src/test/java/docs/SchemaPagesTest.java | 22 +++++++++++++++++++ 19 files changed, 418 insertions(+) diff --git a/docs/tools/src/main/java/docs/ApiPages.java b/docs/tools/src/main/java/docs/ApiPages.java index 9a054366..757e8160 100644 --- a/docs/tools/src/main/java/docs/ApiPages.java +++ b/docs/tools/src/main/java/docs/ApiPages.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package docs; import com.github.javaparser.ast.CompilationUnit; diff --git a/docs/tools/src/main/java/docs/ContentCheck.java b/docs/tools/src/main/java/docs/ContentCheck.java index c5eb0827..61455c5a 100644 --- a/docs/tools/src/main/java/docs/ContentCheck.java +++ b/docs/tools/src/main/java/docs/ContentCheck.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package docs; import com.github.javaparser.ast.CompilationUnit; diff --git a/docs/tools/src/main/java/docs/Example.java b/docs/tools/src/main/java/docs/Example.java index 171137b4..10c11fc5 100644 --- a/docs/tools/src/main/java/docs/Example.java +++ b/docs/tools/src/main/java/docs/Example.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package docs; /** One {@code ```java} fence from an authored page: {@code label} is "{@code , example }", {@code code} the dedented body. */ diff --git a/docs/tools/src/main/java/docs/ExampleCompiler.java b/docs/tools/src/main/java/docs/ExampleCompiler.java index 19745024..64dc49f5 100644 --- a/docs/tools/src/main/java/docs/ExampleCompiler.java +++ b/docs/tools/src/main/java/docs/ExampleCompiler.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package docs; import com.github.javaparser.ast.CompilationUnit; diff --git a/docs/tools/src/main/java/docs/JavaSources.java b/docs/tools/src/main/java/docs/JavaSources.java index 4a4f6e14..475fcd00 100644 --- a/docs/tools/src/main/java/docs/JavaSources.java +++ b/docs/tools/src/main/java/docs/JavaSources.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package docs; import com.github.javaparser.ParseProblemException; diff --git a/docs/tools/src/main/java/docs/Main.java b/docs/tools/src/main/java/docs/Main.java index 9df8adc9..828f8c89 100644 --- a/docs/tools/src/main/java/docs/Main.java +++ b/docs/tools/src/main/java/docs/Main.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package docs; import java.io.IOException; diff --git a/docs/tools/src/main/java/docs/Markdown.java b/docs/tools/src/main/java/docs/Markdown.java index 59a49fa3..a570347a 100644 --- a/docs/tools/src/main/java/docs/Markdown.java +++ b/docs/tools/src/main/java/docs/Markdown.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package docs; import com.github.javaparser.javadoc.Javadoc; diff --git a/docs/tools/src/main/java/docs/ProtoEnum.java b/docs/tools/src/main/java/docs/ProtoEnum.java index de4cd79b..01cd9598 100644 --- a/docs/tools/src/main/java/docs/ProtoEnum.java +++ b/docs/tools/src/main/java/docs/ProtoEnum.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package docs; import java.util.List; diff --git a/docs/tools/src/main/java/docs/ProtoEnumValue.java b/docs/tools/src/main/java/docs/ProtoEnumValue.java index 37bb4b6f..7ce81edb 100644 --- a/docs/tools/src/main/java/docs/ProtoEnumValue.java +++ b/docs/tools/src/main/java/docs/ProtoEnumValue.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package docs; /** One enum value. */ diff --git a/docs/tools/src/main/java/docs/ProtoField.java b/docs/tools/src/main/java/docs/ProtoField.java index 5e6382a0..c0394ec8 100644 --- a/docs/tools/src/main/java/docs/ProtoField.java +++ b/docs/tools/src/main/java/docs/ProtoField.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package docs; /** diff --git a/docs/tools/src/main/java/docs/ProtoFile.java b/docs/tools/src/main/java/docs/ProtoFile.java index 6ead0a1d..954c2cac 100644 --- a/docs/tools/src/main/java/docs/ProtoFile.java +++ b/docs/tools/src/main/java/docs/ProtoFile.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package docs; import java.util.List; diff --git a/docs/tools/src/main/java/docs/ProtoMessage.java b/docs/tools/src/main/java/docs/ProtoMessage.java index ada873ec..7028bb8b 100644 --- a/docs/tools/src/main/java/docs/ProtoMessage.java +++ b/docs/tools/src/main/java/docs/ProtoMessage.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package docs; import java.util.List; diff --git a/docs/tools/src/main/java/docs/ProtoParser.java b/docs/tools/src/main/java/docs/ProtoParser.java index 124cb0cc..e1af9c8d 100644 --- a/docs/tools/src/main/java/docs/ProtoParser.java +++ b/docs/tools/src/main/java/docs/ProtoParser.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package docs; import java.io.IOException; diff --git a/docs/tools/src/main/java/docs/SchemaPages.java b/docs/tools/src/main/java/docs/SchemaPages.java index 0120b0bd..bbcf9819 100644 --- a/docs/tools/src/main/java/docs/SchemaPages.java +++ b/docs/tools/src/main/java/docs/SchemaPages.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package docs; import java.io.IOException; diff --git a/docs/tools/src/test/java/docs/ApiPagesTest.java b/docs/tools/src/test/java/docs/ApiPagesTest.java index 38049e20..762dce27 100644 --- a/docs/tools/src/test/java/docs/ApiPagesTest.java +++ b/docs/tools/src/test/java/docs/ApiPagesTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package docs; import static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/docs/tools/src/test/java/docs/ContentCheckTest.java b/docs/tools/src/test/java/docs/ContentCheckTest.java index 21087126..5bfc1de6 100644 --- a/docs/tools/src/test/java/docs/ContentCheckTest.java +++ b/docs/tools/src/test/java/docs/ContentCheckTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package docs; import static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/docs/tools/src/test/java/docs/ExampleCompilerTest.java b/docs/tools/src/test/java/docs/ExampleCompilerTest.java index 162fd74d..45c3278b 100644 --- a/docs/tools/src/test/java/docs/ExampleCompilerTest.java +++ b/docs/tools/src/test/java/docs/ExampleCompilerTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package docs; import static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/docs/tools/src/test/java/docs/ProtoParserTest.java b/docs/tools/src/test/java/docs/ProtoParserTest.java index 841b41b5..bd690ee5 100644 --- a/docs/tools/src/test/java/docs/ProtoParserTest.java +++ b/docs/tools/src/test/java/docs/ProtoParserTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package docs; import static org.junit.jupiter.api.Assertions.assertEquals; diff --git a/docs/tools/src/test/java/docs/SchemaPagesTest.java b/docs/tools/src/test/java/docs/SchemaPagesTest.java index d4a0fee1..85fabafb 100644 --- a/docs/tools/src/test/java/docs/SchemaPagesTest.java +++ b/docs/tools/src/test/java/docs/SchemaPagesTest.java @@ -1,3 +1,25 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ package docs; import static org.junit.jupiter.api.Assertions.assertEquals; From df81f816e664ff22e5eb19a638578b8fe4f77c8e Mon Sep 17 00:00:00 2001 From: isaackogan Date: Tue, 20 Oct 2026 19:06:03 -0400 Subject: [PATCH 23/31] docs: deploy the site to Cloudflare Workers from GitHub Actions Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- .github/workflows/docs.yml | 103 ++++++++++++++++++++++++++ docs/README.md | 4 +- docs/src/content/docs/contributing.md | 2 +- docs/src/content/docs/hosting.md | 24 +++--- docs/wrangler.jsonc | 13 ++++ 5 files changed, 132 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/docs.yml create mode 100644 docs/wrangler.jsonc diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 00000000..a72abccb --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,103 @@ +name: Documentation + +on: + pull_request: + paths: + - 'docs/**' + - 'API/**' + - 'Client/**' + - 'extension-recorder/**' + - 'extension-collector/**' + - 'README.md' + - '.github/workflows/docs.yml' + push: + branches: [master] + paths: + - 'docs/**' + - 'API/**' + - 'Client/**' + - 'extension-recorder/**' + - 'extension-collector/**' + - 'README.md' + - '.github/workflows/docs.yml' + workflow_dispatch: + schedule: + - cron: '17 6 * * *' + +permissions: + contents: read + +concurrency: + group: docs-${{ github.ref }} + cancel-in-progress: true + +jobs: + upstream: + runs-on: ubuntu-latest + outputs: + has_docs: ${{ steps.probe.outputs.has_docs }} + steps: + - name: Check whether upstream carries the documentation + id: probe + env: + GH_TOKEN: ${{ github.token }} + # A scheduled run builds upstream master, which has nothing to build until docs/ is merged there. + run: | + if [ "$GITHUB_EVENT_NAME" = "schedule" ] && ! gh api repos/jwdeveloper/TikTokLiveJava/contents/docs/package.json --silent; then + echo "::notice::jwdeveloper/TikTokLiveJava has no docs/package.json yet; skipping the scheduled build." + echo "has_docs=false" >> "$GITHUB_OUTPUT" + else + echo "has_docs=true" >> "$GITHUB_OUTPUT" + fi + + build: + needs: upstream + if: needs.upstream.outputs.has_docs == 'true' + runs-on: ubuntu-latest + env: + # Deployment runs only where the Cloudflare secrets exist; forks and pull requests skip it. + DEPLOY_ENABLED: ${{ secrets.CLOUDFLARE_API_TOKEN != '' }} + steps: + - name: Check out upstream master + if: github.event_name == 'schedule' + uses: actions/checkout@v4 + with: + repository: jwdeveloper/TikTokLiveJava + ref: master + - name: Check out the repository + if: github.event_name != 'schedule' + uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven + - name: Build the library + run: mvn -q -pl API,Client,extension-recorder,extension-collector -am -DskipTests package + - uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Install pnpm + run: npm install --global pnpm@10.34.2 + - name: Install documentation dependencies + run: pnpm --dir docs install --frozen-lockfile + - name: Test the documentation scripts + run: pnpm --dir docs test + - name: Validate and build documentation + run: pnpm --dir docs build + - name: Verify SEO invariants + run: pnpm --dir docs run seo + - name: Upload site for review + uses: actions/upload-artifact@v4 + with: + name: documentation + path: docs/dist + - name: Deploy to Cloudflare Workers + if: github.event_name != 'pull_request' && (github.ref == 'refs/heads/master' || github.event_name == 'schedule') && env.DEPLOY_ENABLED == 'true' + uses: cloudflare/wrangler-action@v4 + with: + apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} + accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + workingDirectory: docs + packageManager: pnpm + command: deploy diff --git a/docs/README.md b/docs/README.md index 40adcb14..86edae6a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,7 +32,7 @@ Facts come from the library source first, then the README, then `examples/`. Jav ## Hosting -The site is served from Cloudflare Workers static assets at . `wrangler.jsonc` in this directory declares the asset directory and the custom domain for the `tiktoklivejava-docs` Worker; there is no Worker script. The GitHub workflow `.github/workflows/docs.yml` validates every pull request, runs the SEO gate (`pnpm --dir docs run seo`, which runs `scripts/verify-seo.mjs` over `dist`), and on pushes to `master` deploys when the repository has the `CLOUDFLARE_API_TOKEN` secret; the deploy also needs `CLOUDFLARE_ACCOUNT_ID`. A daily scheduled run checks out the upstream repository's `master` and deploys it, so the hosted site tracks upstream from the fork that holds the secrets. +The site is served from Cloudflare Workers static assets at . `wrangler.jsonc` in this directory declares the asset directory and the HTML handling for the `tiktoklivejava-docs` Worker; there is no Worker script and no `routes` block, because a maintainer attached the custom domain to the Worker once and it survives deploys. The GitHub workflow `.github/workflows/docs.yml` validates every pull request, runs the SEO gate (`pnpm --dir docs run seo`, which runs `scripts/verify-seo.mjs` over `dist`), and on pushes to `master` redeploys the existing Worker when the repository has the `CLOUDFLARE_API_TOKEN` secret, a token scoped to Editor on that Worker only; the deploy also needs `CLOUDFLARE_ACCOUNT_ID`. A daily scheduled run checks out the upstream repository's `master` and deploys it, so the hosted site tracks upstream from the fork that holds the secrets; until upstream carries `docs/`, the workflow's `upstream` job detects that and the build is skipped. To deploy from a checkout after the installs above: @@ -41,4 +41,4 @@ pnpm --dir docs build pnpm --dir docs run deploy:cloudflare ``` -See [Hosting](src/content/docs/hosting.md) for the configuration, secrets, and verification steps. `DOCS_SITE` defaults to the custom domain and `DOCS_BASE` to `/`; neither needs to be set. +Wrangler uploads to the existing Worker and prints `No targets deployed`, which is expected: the config declares no routes and the custom domain stays attached. See [Hosting](src/content/docs/hosting.md) for the configuration, the one-time domain attachment, the secrets, and the verification steps. `DOCS_SITE` defaults to the custom domain and `DOCS_BASE` to `/`; neither needs to be set. diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index 4df68e50..7b83ec03 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -83,6 +83,6 @@ A failing section prints its first three failures with the offending name. The c ## Deployment -`.github/workflows/docs.yml` runs the tests, the full build, and the SEO gate on every pull request that touches the docs or the library, uploads the built site as a review artifact, and on pushes to `master` deploys it to Cloudflare Workers at `https://ttlj.docs.isaackogan.com/`; a daily scheduled run builds and deploys the upstream repository's `master`. See [hosting](/hosting/) for the Worker configuration, the secrets, and how to deploy from a checkout. +`.github/workflows/docs.yml` runs the tests, the full build, and the SEO gate on every pull request that touches the docs or the library, uploads the built site as a review artifact, and on pushes to `master` redeploys the existing `tiktoklivejava-docs` Worker at `https://ttlj.docs.isaackogan.com/` where the repository holds a token scoped to that Worker; a daily scheduled run builds and deploys the upstream repository's `master` once it carries `docs/`, and until then its `upstream` probe job skips the build. See [hosting](/hosting/) for the Worker configuration, the one-time domain attachment, the secrets, and how to deploy from a checkout. `DOCS_SITE` sets the public origin and defaults to the custom domain; `DOCS_BASE` sets the deployment subpath and defaults to `/`. Neither needs to be set for a normal build. diff --git a/docs/src/content/docs/hosting.md b/docs/src/content/docs/hosting.md index c9bd832d..575873f2 100644 --- a/docs/src/content/docs/hosting.md +++ b/docs/src/content/docs/hosting.md @@ -3,12 +3,13 @@ title: Hosting description: Host the static documentation on Cloudflare Workers static assets under a custom domain, deploy it from CI or a checkout, track the upstream repository on a schedule, and gate deploys on SEO checks. --- -The site is deployed to Cloudflare Workers as static assets and served at `https://ttlj.docs.isaackogan.com/`. Hosting lives in a maintainer's Cloudflare account while the documentation source stays in the public repository: the fork `isaackogan/TikTokLiveJava` holds the deployment secrets and runs the deploys, and a scheduled run there builds the upstream `jwdeveloper/TikTokLiveJava` `master` every day, so the site tracks upstream without the upstream repository holding any credentials. The same workflow in the upstream repository validates every pull request and would deploy too if its maintainer added the secrets. There is no Worker script: `docs/wrangler.jsonc` only declares the asset directory, HTML handling, and the custom domain. The site needs no Cloudflare Astro adapter and no TikTok or signing credentials; its only external link requirement is the dofollow links to [Euler Stream](https://www.eulerstream.com/) the SEO gate checks. +The site is deployed to Cloudflare Workers as static assets and served at `https://ttlj.docs.isaackogan.com/`. Hosting lives in a maintainer's Cloudflare account while the documentation source stays in the public repository: the fork `isaackogan/TikTokLiveJava` holds the deployment secrets and runs the deploys, and a scheduled run there builds the upstream `jwdeveloper/TikTokLiveJava` `master` every day, so the site tracks upstream without the upstream repository holding any credentials. The same workflow in the upstream repository validates every pull request and would deploy too if its maintainer added the secrets. There is no Worker script: `docs/wrangler.jsonc` only declares the asset directory and the HTML handling; the custom domain is attached to the Worker once by a maintainer, not by the config, so CI deploys with a token scoped to that Worker alone. The site needs no Cloudflare Astro adapter and no TikTok or signing credentials; its only external link requirement is the dofollow links to [Euler Stream](https://www.eulerstream.com/) the SEO gate checks. ## Configuration ```jsonc { + "$schema": "node_modules/wrangler/config-schema.json", "name": "tiktoklivejava-docs", "compatibility_date": "2026-10-20", "assets": { @@ -16,17 +17,16 @@ The site is deployed to Cloudflare Workers as static assets and served at `https "html_handling": "auto-trailing-slash", "not_found_handling": "404-page" }, - "workers_dev": false, - "routes": [ - { "pattern": "ttlj.docs.isaackogan.com", "custom_domain": true } - ] + // The custom domain ttlj.docs.isaackogan.com is attached to the Worker once by a maintainer + // and survives deploys, so no routes are declared here and the CI token needs no zone permission. + "workers_dev": false } ``` - `html_handling: auto-trailing-slash` matches Astro's `trailingSlash: 'always'`. A request for `/events` redirects to `/events/`, which serves `events/index.html`. - `not_found_handling: 404-page` serves Starlight's generated `404.html` with a 404 status. - `workers_dev: false` keeps the site off the `workers.dev` hostname so the custom domain is the only public origin. -- The `routes` entry with `custom_domain: true` makes Cloudflare create the DNS record and the certificate for the hostname on deploy. The `isaackogan.com` zone must be in the same Cloudflare account as the Worker, and the hostname must not already have a DNS record. +- There is no `routes` block. A maintainer created the Worker `tiktoklivejava-docs` from their own wrangler login and attached the custom domain `ttlj.docs.isaackogan.com` to it once, in the Cloudflare dashboard under the Worker's **Settings → Domains & Routes** or with a one-off `wrangler deploy` from a logged-in session using a `routes` entry in a local copy of the config; Cloudflare created the DNS record and the certificate then, and the attachment survives every later deploy. Because the config declares no routes, a deploy prints `No targets deployed`, which is expected, and the CI token needs no zone permission: a token with Worker-only rights uploads the site but gets a 403 reconciling a `routes` custom domain, which is why the entry is left out. The `isaackogan.com` zone is in the same Cloudflare account as the Worker. `DOCS_SITE` overrides the canonical origin and defaults to the custom domain. `DOCS_BASE` sets the deployment subpath and defaults to `/`. Neither needs to be set for a production build. @@ -51,17 +51,19 @@ pnpm --dir docs build pnpm --dir docs run deploy:cloudflare ``` -The first deploy creates the Worker and attaches the custom domain; wrangler reports `Deployed tiktoklivejava-docs triggers` and `ttlj.docs.isaackogan.com (custom domain)`, and the certificate takes a few minutes to issue. Later deploys upload only the files that changed. +The Worker and its custom domain already exist, so a deploy only uploads the files that changed to the existing Worker. Wrangler reports the upload and then `No targets deployed`, because the config declares no routes; the custom domain stays attached and keeps serving the new build. `pnpm --dir docs exec wrangler deploy --dry-run --outdir /tmp/wrangler-dry` validates the config and the asset directory without uploading. ## Continuous deployment `.github/workflows/docs.yml`, named `Documentation`, runs on pull requests and on pushes to `master` that touch `docs/`, `API/`, `Client/`, `extension-recorder/`, `extension-collector/`, `README.md`, or the workflow itself; it can be started by hand with `workflow_dispatch`; and it runs on a schedule, `17 6 * * *`, 06:17 UTC every day. Its permissions are `contents: read`, and one run at a time per ref: the concurrency group is `docs-${{ github.ref }}`. -The `build` job has one of two checkout steps: on a scheduled run it checks out `jwdeveloper/TikTokLiveJava` at `master`, and on every other event the repository and ref that triggered it. It then sets up Temurin JDK 17 with the Maven cache, runs `mvn -q -pl API,Client,extension-recorder,extension-collector -am -DskipTests package`, sets up Node.js 24, installs pnpm with `npm install --global pnpm@10.34.2`, runs `pnpm --dir docs install --frozen-lockfile`, `pnpm --dir docs test`, `pnpm --dir docs build`, and the SEO gate `pnpm --dir docs run seo`, and uploads `docs/dist` as the `documentation` artifact for review. The last step deploys with `cloudflare/wrangler-action@v4` (`workingDirectory: docs`, `packageManager: pnpm`, `command: deploy`) when the event is not a pull request, the ref is `refs/heads/master` or the event is the schedule, and `DEPLOY_ENABLED` is `true`; that job variable is derived from whether the `CLOUDFLARE_API_TOKEN` secret is set. The deploy needs both of these secrets: +The workflow has two jobs. The first, `upstream`, has a single step, `Check whether upstream carries the documentation`: on a scheduled run it calls `gh api repos/jwdeveloper/TikTokLiveJava/contents/docs/package.json` with the run's own `GITHUB_TOKEN`, and when that file is absent it prints a `::notice::` and sets the job output `has_docs` to `false`; on every other event, and on a scheduled run once upstream carries `docs/`, the output is `true`. The `build` job has `needs: upstream` and runs only when `has_docs` is `true`, so until the documentation is merged upstream a scheduled run is green with `upstream` succeeded, `build` skipped, the notice in the run's annotations, and nothing deployed. + +The `build` job has one of two checkout steps: `Check out upstream master` on a scheduled run, which checks out `jwdeveloper/TikTokLiveJava` at `master`, and `Check out the repository` on every other event, which checks out the repository and ref that triggered it. It then sets up Temurin JDK 17 with the Maven cache; `Build the library` runs `mvn -q -pl API,Client,extension-recorder,extension-collector -am -DskipTests package`; it sets up Node.js 24; `Install pnpm` runs `npm install --global pnpm@10.34.2`; `Install documentation dependencies` runs `pnpm --dir docs install --frozen-lockfile`; `Test the documentation scripts` runs `pnpm --dir docs test`; `Validate and build documentation` runs `pnpm --dir docs build`; `Verify SEO invariants` runs the SEO gate `pnpm --dir docs run seo`; and `Upload site for review` uploads `docs/dist` as the `documentation` artifact. The last step, `Deploy to Cloudflare Workers`, runs `cloudflare/wrangler-action@v4` (`workingDirectory: docs`, `packageManager: pnpm`, `command: deploy`) when the event is not a pull request, the ref is `refs/heads/master` or the event is the schedule, and `DEPLOY_ENABLED` is `true`; that job variable is derived from whether the `CLOUDFLARE_API_TOKEN` secret is set. The deploy redeploys the existing Worker and needs both of these secrets: | Secret | Value | | --- | --- | -| `CLOUDFLARE_API_TOKEN` | A token with Workers Scripts Edit, Account Settings Read, Workers Routes Edit, and Zone Read on `isaackogan.com`. | +| `CLOUDFLARE_API_TOKEN` | An account-owned token scoped to Editor on the `tiktoklivejava-docs` Worker only. It can upload to that Worker and nothing else: no Worker creation, no zone or route permission, which the config does not need. | | `CLOUDFLARE_ACCOUNT_ID` | The hosting account's id, shown by `wrangler whoami`. | Without `CLOUDFLARE_API_TOKEN` the deploy step is skipped and the workflow still validates the build, so the upstream repository, forks, and pull requests are unaffected. The gate checks only the token; `CLOUDFLARE_ACCOUNT_ID` is passed to wrangler and is required for the deploy to succeed, so set both. The maintainer sets them with `gh secret set CLOUDFLARE_API_TOKEN -R isaackogan/TikTokLiveJava` and `gh secret set CLOUDFLARE_ACCOUNT_ID -R isaackogan/TikTokLiveJava`. Because the scheduled run checks out the upstream repository, the workflow file must be on the fork's default branch for the schedule to exist, and a change to the documentation reaches the site either by being merged upstream and picked up by the next scheduled run, or by being pushed to the fork's `master`. @@ -80,8 +82,8 @@ curl -I https://ttlj.docs.isaackogan.com/events curl -I https://ttlj.docs.isaackogan.com/missing/ ``` -Expect a 200, a 307 redirect to the trailing-slash form, and a 404 in that order. Then open the site, check an event page and the generated API and schema pages, and search for a gift event. The canonical link in the page head must use the custom domain. A scheduled run shows up as `gh run list --workflow=docs.yml -R isaackogan/TikTokLiveJava --event schedule --limit 1`. +Expect a 200, a 307 redirect to the trailing-slash form, and a 404 in that order. Then open the site, check an event page and the generated API and schema pages, and search for a gift event. The canonical link in the page head must use the custom domain. A scheduled run shows up as `gh run list --workflow=docs.yml -R isaackogan/TikTokLiveJava --event schedule --limit 1`, and `gh run view -R isaackogan/TikTokLiveJava` lists its jobs: while upstream has no `docs/`, `upstream` succeeded and `build` skipped, with the notice `jwdeveloper/TikTokLiveJava has no docs/package.json yet; skipping the scheduled build.` in the annotations; once it does, both jobs succeed and the `Deploy to Cloudflare Workers` step ran. ## Changing the hostname -Edit `pattern` in `docs/wrangler.jsonc`, change the default `site` in `docs/astro.config.mjs`, update `BASE_URL` in `docs/scripts/verify-seo.mjs` and the `Sitemap` line in `docs/public/robots.txt`, update the hosted links in the repository README, and deploy. Cloudflare provisions the new hostname on the next deploy. Remove the old one under the Worker's **Settings → Domains & Routes** in the Cloudflare dashboard. +Attach the new hostname to the Worker the same way the current one was attached: under the Worker's **Settings → Domains & Routes** in the Cloudflare dashboard, or with a one-off `wrangler deploy` from a logged-in maintainer session using a `routes` entry in a local copy of the config; Cloudflare provisions the DNS record and the certificate then, and the repository config stays without a `routes` block. Then change the default `site` in `docs/astro.config.mjs`, update `BASE_URL` in `docs/scripts/verify-seo.mjs` and the `Sitemap` line in `docs/public/robots.txt`, update the hosted links in the repository README and the hostname named in the `docs/wrangler.jsonc` comment, and deploy. Remove the old hostname in the same dashboard section. diff --git a/docs/wrangler.jsonc b/docs/wrangler.jsonc new file mode 100644 index 00000000..7dc06854 --- /dev/null +++ b/docs/wrangler.jsonc @@ -0,0 +1,13 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "tiktoklivejava-docs", + "compatibility_date": "2026-10-20", + "assets": { + "directory": "./dist", + "html_handling": "auto-trailing-slash", + "not_found_handling": "404-page" + }, + // The custom domain ttlj.docs.isaackogan.com is attached to the Worker once by a maintainer + // and survives deploys, so no routes are declared here and the CI token needs no zone permission. + "workers_dev": false +} From 16d83c5485604a872ffec489cf216e7fb8acaa19 Mon Sep 17 00:00:00 2001 From: isaackogan Date: Tue, 20 Oct 2026 19:09:35 -0400 Subject: [PATCH 24/31] docs: mark the superseded bootstrap paragraph in the design Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- docs/superpowers/specs/2026-10-20-starlight-docs-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/specs/2026-10-20-starlight-docs-design.md b/docs/superpowers/specs/2026-10-20-starlight-docs-design.md index 03549b28..851ef9e7 100644 --- a/docs/superpowers/specs/2026-10-20-starlight-docs-design.md +++ b/docs/superpowers/specs/2026-10-20-starlight-docs-design.md @@ -85,7 +85,7 @@ Why JavaParser rather than a Javadoc doclet: it needs no compile of the library, - `docs/wrangler.jsonc`: Worker `tiktoklivejava-docs`, `assets.directory ./dist`, `auto-trailing-slash`, `404-page`, `workers_dev false`, and no `routes` block: the custom domain `ttlj.docs.isaackogan.com` is attached to the Worker once from a maintainer's wrangler login and persists across deploys, so the CI token needs no zone permission (ruling J12, 2026-10-20). - `.github/workflows/docs.yml`: on `pull_request` and `push` to `master` (paths `docs/**`, `API/**`, `Client/**`, `extension-*/**`, `README.md`, `tools-readme/**`, `.github/workflows/docs.yml`) and `workflow_dispatch`; JDK 17 with Maven cache; `mvn -q -pl API,Client -am -DskipTests install` so the docs tools can compile examples against the library; Node 24 and pnpm 10.34.2; `pnpm --dir docs install --frozen-lockfile`; `pnpm --dir docs build`; SEO gate; upload the artifact; deploy with `cloudflare/wrangler-action@v4` when `github.ref == 'refs/heads/master'`, the event is not a pull request, and `DEPLOY_ENABLED` (derived from the `CLOUDFLARE_API_TOKEN` secret) is true. -- **Bootstrap from the fork:** the branch is first merged into the fork's `master` (fork-local; upstream unaffected) with the secrets set on the fork, so the fork's first `master` run creates the Worker and attaches the domain. That first run needs a token that can create Workers and routes: Account → Workers Scripts → Edit, Account → Account Settings → Read, Zone → Workers Routes → Edit and Zone → Zone → Read on `isaackogan.com` (the same four-permission recipe as the Node site's first token). After the Worker exists the token can be replaced by one scoped to Editor on `tiktoklivejava-docs`, since later deploys only reconcile an existing domain. +- **Bootstrap from the fork (superseded on 2026-10-20 by decision 4 below: the Worker and its domain were created from the maintainer's wrangler login before the bootstrap, so the fork's token is Worker-scoped from the start):** the branch is first merged into the fork's `master` (fork-local; upstream unaffected) with the secrets set on the fork, so the fork's first `master` run creates the Worker and attaches the domain. That first run needs a token that can create Workers and routes: Account → Workers Scripts → Edit, Account → Account Settings → Read, Zone → Workers Routes → Edit and Zone → Zone → Read on `isaackogan.com` (the same four-permission recipe as the Node site's first token). After the Worker exists the token can be replaced by one scoped to Editor on `tiktoklivejava-docs`, since later deploys only reconcile an existing domain. - **After the upstream merge** (open question 3): upstream squash-merges, so the fork's `master`, which already carries the docs commits, can never fast-forward to upstream `master` (builder-check question 2). Therefore no sync-by-push exists. If the fork keeps deploying, `docs.yml` gains a `schedule` (daily) and `workflow_dispatch` path that checks out `jwdeveloper/TikTokLiveJava` at `master` with `actions/checkout` (`repository:` and `ref:` inputs; the repo is public), builds, and deploys from that checkout without pushing anything to the fork; the push-triggered path keeps deploying the fork's own `master`. If instead the upstream maintainer adds the secrets, the same workflow deploys from upstream directly and the fork's scheduled path can be disabled. No force pushes anywhere. ### 7.5 SEO From 2f9034d3413d98a4e7d75b30aa1d3e7c962b1bff Mon Sep 17 00:00:00 2001 From: isaackogan Date: Tue, 20 Oct 2026 19:10:09 -0400 Subject: [PATCH 25/31] docs: keep the Cloudflare account id out of the committed plan Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- docs/superpowers/plans/2026-10-20-starlight-docs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-10-20-starlight-docs.md b/docs/superpowers/plans/2026-10-20-starlight-docs.md index c09cd6fe..94c49a7a 100644 --- a/docs/superpowers/plans/2026-10-20-starlight-docs.md +++ b/docs/superpowers/plans/2026-10-20-starlight-docs.md @@ -294,7 +294,7 @@ git commit -m "docs: add content coverage and example compilation checks" ### Task 12: Bootstrap on the fork (controller, with the maintainer) -- [ ] **Step 1: Secrets on the fork.** The Worker `tiktoklivejava-docs` already exists with the custom domain attached (created from the maintainer's wrangler login on 2026-10-20 with a placeholder page), so the maintainer creates an account-owned token scoped to Editor on that Worker. `gh secret set CLOUDFLARE_API_TOKEN -R isaackogan/TikTokLiveJava` (value via stdin) and `gh secret set CLOUDFLARE_ACCOUNT_ID -R isaackogan/TikTokLiveJava --body f00699f0017e82344b8bed18c38dab38`. Verify with a local deploy of the current build using that token before relying on CI. +- [ ] **Step 1: Secrets on the fork.** The Worker `tiktoklivejava-docs` already exists with the custom domain attached (created from the maintainer's wrangler login on 2026-10-20 with a placeholder page), so the maintainer creates an account-owned token scoped to Editor on that Worker. `gh secret set CLOUDFLARE_API_TOKEN -R isaackogan/TikTokLiveJava` (value via stdin) and `gh secret set CLOUDFLARE_ACCOUNT_ID -R isaackogan/TikTokLiveJava --body `. Verify with a local deploy of the current build using that token before relying on CI. - [ ] **Step 2: Push the branch; open a fork-internal PR `docs/starlight-site` → `isaackogan:master` to get a CI run; when green, merge it with a merge commit (no squash, no force).** - [ ] **Step 3: Watch the fork's `master` run**: `build` succeeds, `Deploy to Cloudflare Workers` ran; `curl -s -o /dev/null -w '%{http_code}' https://ttlj.docs.isaackogan.com/events/` becomes 200 within a few minutes; `/events` → 307; `/missing/` → 404. - [ ] **Step 4: Scheduled path.** A schedule cannot be triggered by hand; the Task 11 YAML assertion is the pre-merge verification, and the first scheduled run (06:17 UTC the next day) is checked afterwards with `gh run list --workflow=docs.yml -R isaackogan/TikTokLiveJava --event schedule --limit 1`. From 4f64eb55135d73949824218c4723f41d31bdc40b Mon Sep 17 00:00:00 2001 From: isaackogan Date: Tue, 20 Oct 2026 19:29:19 -0400 Subject: [PATCH 26/31] docs: scrub local paths and process notes from the committed design and plan Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- docs/superpowers/plans/2026-10-20-starlight-docs.md | 10 ++++------ .../specs/2026-10-20-starlight-docs-design.md | 4 ++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/plans/2026-10-20-starlight-docs.md b/docs/superpowers/plans/2026-10-20-starlight-docs.md index 94c49a7a..aa909b5c 100644 --- a/docs/superpowers/plans/2026-10-20-starlight-docs.md +++ b/docs/superpowers/plans/2026-10-20-starlight-docs.md @@ -1,10 +1,8 @@ # Starlight Documentation Site Implementation Plan (TikTokLiveJava) -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - **Goal:** Give the Java library a Starlight documentation site with generated API and protobuf schema references, content and SEO gates, hosted on Cloudflare Workers at `https://ttlj.docs.isaackogan.com/`, bootstrapped by the fork's own GitHub Actions run and offered upstream as a pull request. -**Architecture:** `docs/` is a self-contained pnpm project (Astro + Starlight + lucode) whose `build` script first builds and runs a standalone Maven tool in `docs/tools/` (`check`, then `generate`) and then `astro build`. The tool parses Java sources with JavaParser and the three `.proto` files with a small proto3 parser, writes Starlight Markdown, and compiles every Java example fence against the built library jars. `docs.yml` validates on pull requests, deploys on pushes to `master` when the Cloudflare secret exists, and on a daily schedule deploys from a checkout of upstream `master`. The Python sibling at `/Users/isaackogan/Documents/PycharmProjects/TikTokLive/docs` is the reference implementation for everything web-side; copy its structure and prose register. +**Architecture:** `docs/` is a self-contained pnpm project (Astro + Starlight + lucode) whose `build` script first builds and runs a standalone Maven tool in `docs/tools/` (`check`, then `generate`) and then `astro build`. The tool parses Java sources with JavaParser and the three `.proto` files with a small proto3 parser, writes Starlight Markdown, and compiles every Java example fence against the built library jars. `docs.yml` validates on pull requests, deploys on pushes to `master` when the Cloudflare secret exists, and on a daily schedule deploys from a checkout of upstream `master`. The Python sibling (the `docs/` directory of the TikTokLive repository) is the reference implementation for everything web-side; copy its structure and prose register. **Tech Stack:** Java 17 in CI (JDK 21 locally; library target 16), Maven, JavaParser 3.26.x, JUnit 5, `javax.tools.JavaCompiler`; Node 24, pnpm 10.34.2, astro 7.4.1, @astrojs/starlight 0.42.6, lucode-starlight 1.0.0, starlight-links-validator 0.27.0, wrangler 4.151.2; Cloudflare Workers static assets; GitHub Actions. @@ -22,7 +20,7 @@ - Internal links are site-relative with trailing slashes. Prose register matches the sibling sites: short declarative sentences, no marketing. - Library facts come from the sources at `1.13.1-Release`: entry `TikTokLive.newClient(String hostName)` → `LiveClientBuilder` (handlers on `EventsBuilder`, 73 `on*` methods); `LiveClientSettings` (16 fields), `HttpClientSettings`; 78 event classes under `API/src/main/java/io/github/jwdeveloper/tiktok/api/data/events/`, each annotated `@EventMeta(eventType = EventType.Control|Message|Debug)`; 11 classes under `api/exceptions/`; listener classes extend `TikTokEventListenerBase` with `@TikTokEventObserver` methods; extensions `TikTokLiveRecorder` (`extension-recorder`) and `TikTokLiveCollector`/`DataCollector` (`extension-collector`); proto Java package `io.github.jwdeveloper.tiktok.api.messages.webcast` from `API/src/main/proto/{webcast,data,enums}.proto` (proto3, `package TikTok`). - JitPack coordinates: group `com.github.jwdeveloper.TikTok-Live-Java`, artifact `Client`, version `1.13.1-Release`; Java 16 or newer. -- Work on branch `docs/starlight-site` in `/Users/isaackogan/Documents/IdeaProjects/TikTokLiveJava` (origin = the fork `isaackogan/TikTokLiveJava`; add `upstream = https://github.com/jwdeveloper/TikTokLiveJava.git`). Never push to upstream; never force-push. Commit messages end with `Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ`. +- Work on branch `docs/starlight-site` in the local checkout of the fork (origin = the fork `isaackogan/TikTokLiveJava`; add `upstream = https://github.com/jwdeveloper/TikTokLiveJava.git`). Never push to upstream; never force-push. - Local commands: `npx -y pnpm@10.34.2` for pnpm; `mvn` 3.9.12 and JDK 21 are on PATH; build the library once with `mvn -q -pl API,Client,extension-recorder,extension-collector -am -DskipTests package` so `*/target/classes` exist for the example compiler. ## Review Focus @@ -65,7 +63,7 @@ sidebar: [ - [ ] **Step 1: Copy the Python sibling's `docs/` web files and adapt** -From `/Users/isaackogan/Documents/PycharmProjects/TikTokLive/docs/` copy `astro.config.mjs`, `tsconfig.json`, `pnpm-workspace.yaml`, `src/content.config.ts`, `src/content/docs/404.md`, `public/favicon.png`. In `astro.config.mjs` set `site` default `https://ttlj.docs.isaackogan.com`, `title: 'TikTokLiveJava'`, `description: 'Unofficial TikTok LIVE API for Java. Guides, events, and generated API reference.'`, social GitHub `https://github.com/jwdeveloper/TikTokLiveJava` and Discord `https://discord.gg/e2XwPNTBBr`, `editLink.baseUrl` `https://github.com/jwdeveloper/TikTokLiveJava/edit/master/docs/`, lucode `navLinks` Docs/Events/API, `footerText` `'Unofficial project, not affiliated with TikTok or ByteDance Ltd. Signing by [Euler Stream](https://www.eulerstream.com/). Source on [GitHub](https://github.com/jwdeveloper/TikTokLiveJava).'`, `head` entries `og:image` `https://raw.githubusercontent.com/jwdeveloper/TikTokLiveJava/master/tools-readme/src/main/resources/logo.svg` with `og:image:alt` `TikTokLiveJava, TikTok LIVE API for Java` and `twitter:card` `summary_large_image`, and the sidebar above. `package.json`: name `tiktoklivejava-docs`, scripts `dev`, `tools:build`, `check`, `generate`, `build`, `preview`, `deploy:cloudflare` (`wrangler deploy`), devDependencies pinned per Global Constraints. `public/robots.txt`: `User-agent: *`, `Allow: /`, blank line, `Sitemap: https://ttlj.docs.isaackogan.com/sitemap-index.xml`. +From the Python TikTokLive repository's `docs/` copy `astro.config.mjs`, `tsconfig.json`, `pnpm-workspace.yaml`, `src/content.config.ts`, `src/content/docs/404.md`, `public/favicon.png`. In `astro.config.mjs` set `site` default `https://ttlj.docs.isaackogan.com`, `title: 'TikTokLiveJava'`, `description: 'Unofficial TikTok LIVE API for Java. Guides, events, and generated API reference.'`, social GitHub `https://github.com/jwdeveloper/TikTokLiveJava` and Discord `https://discord.gg/e2XwPNTBBr`, `editLink.baseUrl` `https://github.com/jwdeveloper/TikTokLiveJava/edit/master/docs/`, lucode `navLinks` Docs/Events/API, `footerText` `'Unofficial project, not affiliated with TikTok or ByteDance Ltd. Signing by [Euler Stream](https://www.eulerstream.com/). Source on [GitHub](https://github.com/jwdeveloper/TikTokLiveJava).'`, `head` entries `og:image` `https://raw.githubusercontent.com/jwdeveloper/TikTokLiveJava/master/tools-readme/src/main/resources/logo.svg` with `og:image:alt` `TikTokLiveJava, TikTok LIVE API for Java` and `twitter:card` `summary_large_image`, and the sidebar above. `package.json`: name `tiktoklivejava-docs`, scripts `dev`, `tools:build`, `check`, `generate`, `build`, `preview`, `deploy:cloudflare` (`wrangler deploy`), devDependencies pinned per Global Constraints. `public/robots.txt`: `User-agent: *`, `Allow: /`, blank line, `Sitemap: https://ttlj.docs.isaackogan.com/sitemap-index.xml`. - [ ] **Step 2: Placeholder pages and `.gitignore`** @@ -301,5 +299,5 @@ git commit -m "docs: add content coverage and example compilation checks" ### Task 13: Pull request upstream (controller, with the maintainer) -- [ ] **Step 1:** `gh pr create -R jwdeveloper/TikTokLiveJava --base master --head isaackogan:docs/starlight-site --title "docs: Starlight documentation site at ttlj.docs.isaackogan.com" --body-file
` describing the site, the gates, the hosting (maintained on the fork; upstream deploys only if the two secrets are added), and the README changes; end the body with `https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ`. +- [ ] **Step 1:** `gh pr create -R jwdeveloper/TikTokLiveJava --base master --head isaackogan:docs/starlight-site --title "docs: Starlight documentation site at ttlj.docs.isaackogan.com" --body-file ` describing the site, the gates, the hosting (maintained on the fork; upstream deploys only if the two secrets are added), and the README changes. - [ ] **Step 2:** Confirm upstream's `pull_request` run validates (no deploy). Merge is the upstream maintainer's. diff --git a/docs/superpowers/specs/2026-10-20-starlight-docs-design.md b/docs/superpowers/specs/2026-10-20-starlight-docs-design.md index 851ef9e7..533f2596 100644 --- a/docs/superpowers/specs/2026-10-20-starlight-docs-design.md +++ b/docs/superpowers/specs/2026-10-20-starlight-docs-design.md @@ -1,9 +1,9 @@ # Starlight Documentation Site for TikTokLiveJava **Date:** 2026-10-20 -**Status:** Draft, awaiting approval +**Status:** Implemented (approved 2026-10-20; rulings made during implementation are noted inline) **Upstream:** `jwdeveloper/TikTokLiveJava` (default branch `master`); the maintainer of this effort has no push rights there. -**Working fork:** `isaackogan/TikTokLiveJava` (a real GitHub fork, in sync with upstream at `81f54d7`), local checkout `/Users/isaackogan/Documents/IdeaProjects/TikTokLiveJava`. +**Working fork:** `isaackogan/TikTokLiveJava` (a real GitHub fork, in sync with upstream at `81f54d7`). **Reference implementations:** Node.js site https://ttlc.docs.isaackogan.com/ (repo `zerodytrash/TikTok-Live-Connector`, `docs/`), Python site https://ttl.docs.isaackogan.com/ (repo `isaackogan/TikTokLive`, `docs/`, design at `docs/superpowers/specs/2026-10-19-starlight-docs-design.md`). ## 1. Intent From 88f09c34d0be85e862ac55f71c1c32bae88ad71b Mon Sep 17 00:00:00 2001 From: isaackogan Date: Tue, 20 Oct 2026 19:51:44 -0400 Subject: [PATCH 27/31] docs: reconcile page wording with the library's thread, exception, and mapping behaviour Keep the connection, WebSocket, and troubleshooting pages consistent on the daemon flag the socket's read thread inherits; say "every exception the library defines"; tag the collaboration fence and list the dev prerequisites; and apply the deferred wording minors (room-info defaults and update paths, Euler roomInfo fields, offline + Euler path, Euler text frames, connectAsync(Consumer) failures, unhandled-exception order, autoDiscard split, SOCKS propagation and read timeout, catch-all dispatch order, forAnyMessage hook order, mapping-exception causes, mapping fragment imports, user attributes, Gradle url assignment, ffmpeg path, README blank line, .gitignore separator). Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- .gitignore | 1 + README.md | 1 - collaboration.md | 5 +++-- docs/src/content/docs/advanced/websocket.md | 4 ++-- .../content/docs/configuration/client-settings.md | 4 ++-- .../content/docs/configuration/http-settings.md | 6 +++--- .../content/docs/configuration/proxies-and-host.md | 2 +- docs/src/content/docs/events/chat-and-audience.md | 2 +- docs/src/content/docs/events/index.md | 2 ++ .../src/content/docs/getting-started/connection.md | 10 +++++----- docs/src/content/docs/getting-started/install.md | 2 +- docs/src/content/docs/guides/custom-events.md | 4 ++-- docs/src/content/docs/guides/debugging.md | 2 +- docs/src/content/docs/guides/recorder.md | 2 +- docs/src/content/docs/troubleshooting.md | 14 +++++++------- 15 files changed, 32 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index c674cdd0..63317ab6 100644 --- a/.gitignore +++ b/.gitignore @@ -247,6 +247,7 @@ $RECYCLE.BIN/ # End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode /python-app-backend/samconfig.toml /java-app-backend/BackendFunction/dependency-reduced-pom.xml + # Starlight documentation site (docs/) docs/node_modules/ docs/.astro/ diff --git a/README.md b/README.md index 9cea04f5..4860e299 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,6 @@ No credentials are required. Events such as [Members Joining](#member), [Gifts]( Full documentation, guides and the complete event reference: [ttlj.docs.isaackogan.com](https://ttlj.docs.isaackogan.com/) - diff --git a/collaboration.md b/collaboration.md index a9ec7a34..f61fdf29 100644 --- a/collaboration.md +++ b/collaboration.md @@ -211,14 +211,15 @@ from the `API` and `Client` sources and the `.proto` files, and whose authored p under `docs/src/content/docs/`. It needs JDK 17 or newer, Maven, Node.js 24, and pnpm 10.34.2. From the repository root: -``` +```sh npx -y pnpm@10.34.2 --dir docs install --frozen-lockfile npx -y pnpm@10.34.2 --dir docs build ``` `build` builds the library modules, packages the tool under `docs/tools/`, runs the content check, generates the reference pages, and builds the site; `pnpm --dir docs dev` serves it -locally after `pnpm --dir docs generate`. The content check fails the build when an event, +locally after `pnpm --dir docs lib:build`, `pnpm --dir docs tools:build`, and +`pnpm --dir docs generate`. The content check fails the build when an event, settings field, builder handler, exception, or extension is not named on a page, when a page links to Discord, when the README's events list and the `EventsBuilder` handlers disagree, or when a Java example does not compile. So when you add an event as described above, also add its diff --git a/docs/src/content/docs/advanced/websocket.md b/docs/src/content/docs/advanced/websocket.md index b2c6ccad..fe404cf4 100644 --- a/docs/src/content/docs/advanced/websocket.md +++ b/docs/src/content/docs/advanced/websocket.md @@ -50,13 +50,13 @@ When the socket reports its close, the listener's `onClose` checks the socket ag ## The Euler Stream socket -With `useEulerstreamWebsocket`, `TikTokWebSocketEulerClient.start` ignores the connection data, which is `null` on that path, and opens `wss://ws.eulerstream.com?uniqueId=&apiKey=&features.rawMessages=true`, plus `&features.useEnterpriseApi=true` with `useEulerstreamEnterprise`, with the HTTP settings' headers and the same `toMillisPart()` timeout. It uses no proxy and no heartbeat task. The listener is a `TikTokWebSocketEulerListener`, which inherits the binary-frame handling above and adds text frames: a JSON object whose `messages` array holds a `workerInfo` message, logged at `INFO`, and a `roomInfo` message, mapped with `LiveUserDataMapper.mapEulerstream`, copied into `getRoomInfo()`, and published as the first `TikTokRoomInfoEvent`. Any other text frame is reported as `TikTokErrorEvent`. These two messages arrive after the handshake, so `getRoomInfo()` is empty when `TikTokConnectedEvent` fires on this path; the fields the message fills are listed on the [HTTP client page](/advanced/http-client/#response-mappers). +With `useEulerstreamWebsocket`, `TikTokWebSocketEulerClient.start` ignores the connection data, which is `null` on that path, and opens `wss://ws.eulerstream.com?uniqueId=&apiKey=&features.rawMessages=true`, plus `&features.useEnterpriseApi=true` with `useEulerstreamEnterprise`, with the HTTP settings' headers and the same `toMillisPart()` timeout. It uses no proxy and no heartbeat task. The listener is a `TikTokWebSocketEulerListener`, which inherits the binary-frame handling above and adds text frames: a JSON object whose `messages` array holds a `workerInfo` message, logged at `INFO`, and a `roomInfo` message, mapped with `LiveUserDataMapper.mapEulerstream`, copied into `getRoomInfo()`, and published as the first `TikTokRoomInfoEvent`. A text frame that is not a JSON object, or that does not parse, is reported as `TikTokErrorEvent`; a JSON object without a `messages` array, or a message of another type, is ignored. These two messages arrive after the handshake, so `getRoomInfo()` is empty when `TikTokConnectedEvent` fires on this path; the fields the message fills are listed on the [HTTP client page](/advanced/http-client/#response-mappers). `stop` on this client closes the socket only when it is already open; a socket still in its handshake is dropped from the client without being closed. The Euler path is described from the settings side on the [signing page](/configuration/signing/#euler-stream-websocket). ## The offline socket -With `offline`, `TikTokWebSocketOfflineClient.start` publishes `TikTokConnectedEvent` at once and `stop` publishes `TikTokDisconnectedEvent` with the reason `Stopping` and code `-1`, but only once `start` has given it the client; `isConnected()` is always `false`. In this release `connect()` fails before `start` is reached, see [offline mode](/configuration/client-settings/#offline-mode), so `stop` has no client and `disconnect()` publishes nothing: on an offline client these two events come only from your own `publishEvent`. The [offline testing guide](/guides/offline-testing/) covers what the mode is for. +With `offline`, `TikTokWebSocketOfflineClient.start` publishes `TikTokConnectedEvent` at once and `stop` publishes `TikTokDisconnectedEvent` with the reason `Stopping` and code `-1`, but only once `start` has given it the client; `isConnected()` is always `false`. In this release `connect()` fails before `start` is reached, see [offline mode](/configuration/client-settings/#offline-mode), so `stop` has no client and `disconnect()` publishes nothing: on an offline client these two events come only from your own `publishEvent`. The exception is `offline` together with `useEulerstreamWebsocket`: that path makes no HTTP request, so `start` runs, `connect()` succeeds, and `disconnect()` publishes the `Stopping` event. The [offline testing guide](/guides/offline-testing/) covers what the mode is for. ## Read the frames without the client diff --git a/docs/src/content/docs/configuration/client-settings.md b/docs/src/content/docs/configuration/client-settings.md index 8dd0e155..0611664e 100644 --- a/docs/src/content/docs/configuration/client-settings.md +++ b/docs/src/content/docs/configuration/client-settings.md @@ -22,7 +22,7 @@ The fields are Lombok properties: `fetchGifts` is read with `isFetchGifts()` and ## Fields -The defaults are the values after `TikTokLive.newClient`, which calls `createDefault()`. Two of them differ from the field initialisers, as noted. +The defaults are the values after `TikTokLive.newClient`, which calls `createDefault()`. One of them, `printToConsole`, differs from the field initialiser, as noted; the other such default, the HTTP `timeout`, is on the [HTTP settings](/configuration/http-settings/) page. | Field | Type | Default | Effect | | --- | --- | --- | --- | @@ -59,7 +59,7 @@ Nothing else is validated: an `apiKey` or a session is checked only by the serve With `offline` set to `true`, `build()` wires `TikTokLiveHttpOfflineClient` and `TikTokWebSocketOfflineClient` in place of the real clients. No request leaves the process, and `sendChat` returns `false`. Use it to test handlers with `publishEvent`, which dispatches an event object to your handlers, and `publishMessage`, which runs a raw Webcast message through the same mapping as a socket message; both work whether or not the client is connected. -In this checkout, `connect()` in offline mode stops with a logged `NullPointerException` before the stand-in socket starts, because the offline HTTP client returns no room info. `connect()` does not throw; it logs `Unhandled exception`, prints the stack trace, and sets the state back to `DISCONNECTED` without publishing `TikTokConnectedEvent`. `build()` and `publishEvent` are enough for a test, so skip `connect()`. +In this checkout, `connect()` in offline mode stops with a logged `NullPointerException` before the stand-in socket starts, because the offline HTTP client returns no room info. `connect()` does not throw; it logs `Unhandled exception`, prints the stack trace, and sets the state back to `DISCONNECTED` without publishing `TikTokConnectedEvent`. The one exception is `useEulerstreamWebsocket` set as well, which skips the HTTP requests, so `connect()` reaches the stand-in socket and succeeds. `build()` and `publishEvent` are enough for a test, so skip `connect()`. ```java var client = TikTokLive.newClient("isaackogz") diff --git a/docs/src/content/docs/configuration/http-settings.md b/docs/src/content/docs/configuration/http-settings.md index 699cd431..9e6bfb19 100644 --- a/docs/src/content/docs/configuration/http-settings.md +++ b/docs/src/content/docs/configuration/http-settings.md @@ -14,8 +14,8 @@ TikTokLive.newClient("isaackogz") .configure(settings -> { var http = settings.getHttpSettings(); http.setTimeout(Duration.ofSeconds(10)); - http.getHeaders().put("Accept-Language", "de-DE,de;q=0.9"); - http.getParams().put("tz_name", "Europe/Berlin"); + http.getHeaders().put("Accept-Language", "fr-FR,fr;q=0.9"); + http.getParams().put("tz_name", "Europe/Paris"); http.onRequestCreating(request -> request.header("X-Request-Source", "my-bot")); }) .buildAndConnect(); @@ -37,7 +37,7 @@ TikTokLive.newClient("isaackogz") ## The timeout and sub-second parts -The HTTP transport and each request receive the whole `Duration`. The WebSocket handshake, and the SOCKS proxy path, receive `timeout.toMillisPart()`, the millisecond fraction of the duration (0 to 999), as a connect timeout in milliseconds. With a whole-second value such as the default that is `0`, which `Socket.connect` treats as no timeout. `Duration.ofMillis(2500)` gives the HTTP requests 2.5 seconds and the WebSocket handshake 500 milliseconds. +The HTTP transport and each request receive the whole `Duration`. The WebSocket handshake, and the SOCKS proxy path, receive `timeout.toMillisPart()`, the millisecond fraction of the duration (0 to 999), as a connect timeout in milliseconds, and the SOCKS path uses the same value as its read timeout. With a whole-second value such as the default that is `0`, which `Socket.connect` treats as no timeout. `Duration.ofMillis(2500)` gives the HTTP requests 2.5 seconds and the WebSocket handshake 500 milliseconds. ## Default parameters and headers diff --git a/docs/src/content/docs/configuration/proxies-and-host.md b/docs/src/content/docs/configuration/proxies-and-host.md index 0c8baa0e..43b99171 100644 --- a/docs/src/content/docs/configuration/proxies-and-host.md +++ b/docs/src/content/docs/configuration/proxies-and-host.md @@ -58,7 +58,7 @@ For each HTTP request, the client builds `HttpProxyClient` when proxies are enab - With `HTTP` or `DIRECT`, a response other than 200 moves on to the next endpoint; `hasNext()` is `true` while the list is not empty, so endpoints that answer with another status are tried again in rotation. A connect timeout or `ConnectException` removes the endpoint when `autoDiscard` is `true` and throws `TikTokProxyRequestException`; the client's `fetch` methods catch that and start the request again while endpoints remain, then make one last attempt without a proxy. Any other `IOException` throws it too, unless `fallback` applies. - With `SOCKS`, one endpoint is tried per attempt; an `IOException` removes it when `autoDiscard` is `true` and throws `TikTokProxyRequestException`, with the same retry by the `fetch` methods. -- `TikTokLiveRequestException` with the message `No more proxies available!` is thrown when the list runs empty inside one attempt. +- `TikTokLiveRequestException` with the message `No more proxies available!` is raised when the list runs empty inside one attempt. The `HTTP` and `DIRECT` path throws it; the `SOCKS` path catches it, prints its stack trace, and returns a failed result instead. The signing request is repeated on `TikTokSignServerException` as well as on `TikTokProxyRequestException`, while endpoints remain and then once without a proxy; see [signing](/configuration/signing/). diff --git a/docs/src/content/docs/events/chat-and-audience.md b/docs/src/content/docs/events/chat-and-audience.md index 7ac547d8..95c4d037 100644 --- a/docs/src/content/docs/events/chat-and-audience.md +++ b/docs/src/content/docs/events/chat-and-audience.md @@ -5,7 +5,7 @@ sidebar: order: 2 --- -Chat, membership, reactions, and social activity. The sender is `getUser()`, a `User` with `getId()`, `getName()` (the username), `getProfileName()` (the display name), `getPicture()`, `getFollowers()`, `getFollowing()`, `getBadges()`, and the attribute checks `isModerator()`, `isSubscriber()`, `isGiftGiver()`, `isFollower()`, `isLiveHost()`, `isMutualFollowingWithHost()`, `isAdmin()`, `isMuted()`, and `isBlocked()`. Which attributes are set depends on the message: comments and gifts carry a user identity that fills the first six, the other messages fill `isFollower()`, `isSubscriber()`, `isAdmin()`, `isMuted()`, and `isBlocked()` from the user object alone. +Chat, membership, reactions, and social activity. The sender is `getUser()`, a `User` with `getId()`, `getName()` (the username), `getProfileName()` (the display name), `getPicture()`, `getFollowers()`, `getFollowing()`, `getBadges()`, and the attribute checks `isModerator()`, `isSubscriber()`, `isGiftGiver()`, `isFollower()`, `isLiveHost()`, `isMutualFollowingWithHost()`, `isAdmin()`, `isMuted()`, and `isBlocked()`. Which attributes are set depends on the message: every message sets `isFollower()`, `isSubscriber()`, `isAdmin()`, `isMuted()`, and `isBlocked()` from the user object; comments and gifts also carry a user identity, which adds `isModerator()`, `isGiftGiver()`, `isLiveHost()`, and `isMutualFollowingWithHost()`, so those four are `false` on every other message. Each section names the Webcast message and links its schema page, which lists every field. Events with a builder method name it; the others are registered with `onEvent(TikTokXEvent.class, handler)`. Replace `isaackogz` with a creator who is live. diff --git a/docs/src/content/docs/events/index.md b/docs/src/content/docs/events/index.md index 34ac63ca..a09919f4 100644 --- a/docs/src/content/docs/events/index.md +++ b/docs/src/content/docs/events/index.md @@ -45,6 +45,8 @@ The client maps a message only when `MessagesMapperFactory` registers a handler To produce one of them, register a mapping with `mappings` on the builder. The mapping receives the message bytes, deserialises them with the helper, and returns a `MappingResult` holding the message and the events to publish. The protobuf classes live in `io.github.jwdeveloper.tiktok.api.messages.webcast`. ```java +import io.github.jwdeveloper.tiktok.api.data.events.TikTokRankTextEvent; +import io.github.jwdeveloper.tiktok.api.mappers.data.MappingResult; import io.github.jwdeveloper.tiktok.api.messages.webcast.WebcastRankTextMessage; TikTokLive.newClient("isaackogz") diff --git a/docs/src/content/docs/getting-started/connection.md b/docs/src/content/docs/getting-started/connection.md index e71df284..5aaa43bd 100644 --- a/docs/src/content/docs/getting-started/connection.md +++ b/docs/src/content/docs/getting-started/connection.md @@ -13,7 +13,7 @@ description: Build and connect a client, follow the connect sequence and its lif | `buildAndConnect()` | The connected `LiveClient` | A program that connects once. It throws when the connection fails. | | `buildAndConnectAsync()` | A `CompletableFuture` | The calling thread must not wait. The future completes with the client, or exceptionally when the connection fails. | -`LiveClient` has the matching methods. `connect()` blocks the calling thread while the client fetches the room data, signs, and starts the socket. `connectAsync()` runs `connect()` on the common pool and returns a `CompletableFuture`; `connectAsync(Consumer)` passes the connected client to the consumer. One connection per client: calling `connect()` while the state is `CONNECTING` or `CONNECTED` throws `TikTokLiveException` with the message `Already connected`. That exception goes through the same failure branch as any other: the client sets the state to `DISCONNECTED` and publishes `TikTokErrorEvent` and `TikTokDisconnectedEvent` with the reason `Exception: Already connected`, while the existing socket stays open and keeps delivering events. The state no longer matches the socket, and the next `connect()` passes the state check and replaces the socket, closing the old one first. After a disconnect the same client can `connect()` again. +`LiveClient` has the matching methods. `connect()` blocks the calling thread while the client fetches the room data, signs, and starts the socket. `connectAsync()` runs `connect()` on the common pool and returns a `CompletableFuture`; `connectAsync(Consumer)` passes the connected client to the consumer and discards a failure: the consumer is not called and nothing is thrown. One connection per client: calling `connect()` while the state is `CONNECTING` or `CONNECTED` throws `TikTokLiveException` with the message `Already connected`. That exception goes through the same failure branch as any other: the client sets the state to `DISCONNECTED` and publishes `TikTokErrorEvent` and `TikTokDisconnectedEvent` with the reason `Exception: Already connected`, while the existing socket stays open and keeps delivering events. The state no longer matches the socket, and the next `connect()` passes the state check and replaces the socket, closing the old one first. After a disconnect the same client can `connect()` again. ```java var client = TikTokLive.newClient("isaackogz") @@ -23,7 +23,7 @@ var client = TikTokLive.newClient("isaackogz") client.connect(); ``` -The common pool's threads are daemon threads, so after `buildAndConnectAsync()` keep the main thread alive until the socket is open; the repository's `ConnectionExample` blocks on `System.in.read()`. +The common pool's threads are daemon threads, and the socket's read thread inherits that flag from the thread that called `connect()`, so after `buildAndConnectAsync()` keep a non-daemon thread alive for as long as the connection should live; the repository's `ConnectionExample` blocks on `System.in.read()`. See the [WebSocket layer](/advanced/websocket/#connection-sequence). ```java TikTokLive.newClient("isaackogz") @@ -50,7 +50,7 @@ In order, `connect()`: 6. Requests a signed WebSocket URL from the signing provider; a failed or rate-limited request throws `TikTokSignServerException`. The response already carries messages, and they are dispatched to your handlers now, before the socket opens. 7. Starts the WebSocket on its own thread, sets the state to `CONNECTED`, and publishes `TikTokRoomInfoEvent`. `TikTokConnectedEvent` fires from the socket thread once the socket is open, which can be before or after `connect()` returns. -When a step throws a `TikTokLiveException`, `connect()` sets the state back to `DISCONNECTED`, publishes `TikTokErrorEvent` with the exception and then `TikTokDisconnectedEvent` with code `-1` and the reason `Exception: `, and rethrows. `buildAndConnect()` and `connect()` throw it to the caller; `buildAndConnectAsync()` and `connectAsync()` complete their future exceptionally. Any other exception is logged with its stack trace and the client disconnects without throwing. +When a step throws a `TikTokLiveException`, `connect()` sets the state back to `DISCONNECTED`, publishes `TikTokErrorEvent` with the exception and then `TikTokDisconnectedEvent` with code `-1` and the reason `Exception: `, and rethrows. `buildAndConnect()` and `connect()` throw it to the caller; `buildAndConnectAsync()` and `connectAsync()` complete their future exceptionally. Any other exception is logged as `Unhandled exception report this bug to github`, the client disconnects, and the stack trace is printed to stderr; nothing is thrown. With `useEulerstreamWebsocket` set to `true`, `connect()` does none of steps 2 to 7: it publishes `TikTokConnectingEvent`, opens `wss://ws.eulerstream.com` with your `apiKey`, and sets the state to `CONNECTED`. It fills no room information and publishes no `TikTokRoomInfoEvent`; both come from the `roomInfo` message Euler Stream sends after the socket opens, so `getRoomInfo()` is still empty when `TikTokConnectedEvent` fires on that path. See [signing](/configuration/signing/). @@ -65,7 +65,7 @@ Six control events mark the lifecycle. Register them like any other event; every | `TikTokConnectedEvent` | `onConnected` | The WebSocket is open | None | | `TikTokReconnectingEvent` | `onReconnecting` | Just before a scheduled retry calls `connect()` | None | | `TikTokDisconnectedEvent` | `onDisconnected` | The socket closed, or a connect attempt failed | `getCode()`, `getReason()`, `isUnknownCloseCode()` | -| `TikTokErrorEvent` | `onError` | An exception in the client, in the socket, or thrown by a handler while a message is processed | `getException()` | +| `TikTokErrorEvent` | `onError` | An exception in the client, in the socket, or thrown by a handler while a message is processed | `getException()`: a `TikTokLiveMessageException` wrapping the cause when a mapping or handler threw while a message was processed | `TikTokDisconnectedEvent.getCode()` is the WebSocket close code when TikTok or `disconnect()` closed the socket, and `-1` (`isUnknownCloseCode()` is `true`) when a connect attempt failed; `getReason()` is `None` when the close frame carried no reason. The end of a stream is a message event, not a control event: when TikTok reports the stream ended or suspended, the client publishes `TikTokLiveEndedEvent` (`onLiveEnded`) and does not disconnect by itself. Call `disconnect()` from the handler when the connection should close with the stream. @@ -141,7 +141,7 @@ while (true) { ## Room information -`getRoomInfo()` returns the client's `LiveRoomInfo`. Before connecting it holds only `getHostName()` and the state `DISCONNECTED`. On the default path, `connect()` fills it from TikTok's responses: `getRoomId()`, `getTitle()`, `getHost()`, `getStartTime()`, `getViewersCount()`, `getTotalViewersCount()`, and `isAgeRestricted()`, all set before `TikTokPreConnectionEvent` is published. With `useEulerstreamWebsocket`, `connect()` leaves it empty; the `roomInfo` message Euler Stream sends after the socket opens copies those fields in and publishes the first `TikTokRoomInfoEvent`. While connected, the same object is updated in place and `TikTokRoomInfoEvent` (`onRoomInfo`) is published with it each time: viewer counts and `getUsersRanking()` from TikTok's room user messages, `getLikesCount()` from like messages, the host and `getLanguage()` from the live intro message. `getConnectionState()` is `CONNECTING`, `CONNECTED`, or `DISCONNECTED`. +`getRoomInfo()` returns the client's `LiveRoomInfo`. Before connecting it holds `getHostName()`, the state `DISCONNECTED`, and the defaults: `getLanguage()` is `en`, `getUsersRanking()` is an empty list, the counts are `0`, and the other fields are `null`. On the default path, `connect()` fills it from TikTok's responses: `getRoomId()`, `getTitle()`, `getHost()`, `getStartTime()`, `getViewersCount()`, `getTotalViewersCount()`, and `isAgeRestricted()`, all set before `TikTokPreConnectionEvent` is published. With `useEulerstreamWebsocket`, `connect()` leaves it empty; the `roomInfo` message Euler Stream sends after the socket opens copies the room id, title, host, start time, and viewer count in, not the total viewer count or the age restriction (see [known limitations](/troubleshooting/#known-limitations-in-this-release)), and publishes the first `TikTokRoomInfoEvent`. While connected, the same object is updated in place and `TikTokRoomInfoEvent` (`onRoomInfo`) is published with it each time: viewer counts and `getUsersRanking()` from TikTok's room user messages, `getViewersCount()` from member messages too, `getLikesCount()` from like messages, `getLanguage()` and, while it is still `null`, the host from the live intro message. `getConnectionState()` is `CONNECTING`, `CONNECTED`, or `DISCONNECTED`. ```java TikTokLive.newClient("isaackogz") diff --git a/docs/src/content/docs/getting-started/install.md b/docs/src/content/docs/getting-started/install.md index c421d347..880e3b4f 100644 --- a/docs/src/content/docs/getting-started/install.md +++ b/docs/src/content/docs/getting-started/install.md @@ -38,7 +38,7 @@ Add the repository and the dependency to `build.gradle`: ```groovy repositories { mavenCentral() - maven { url 'https://jitpack.io' } + maven { url = 'https://jitpack.io' } } dependencies { diff --git a/docs/src/content/docs/guides/custom-events.md b/docs/src/content/docs/guides/custom-events.md index 147d0f62..7b7317d5 100644 --- a/docs/src/content/docs/guides/custom-events.md +++ b/docs/src/content/docs/guides/custom-events.md @@ -9,7 +9,7 @@ Two extension points add events. `publishEvent` dispatches any `TikTokEvent` sub ## Publish your own events -A custom event is a class that extends `TikTokEvent`, an abstract class with no fields and a `toJson()` method. `client.publishEvent(event)` runs the handlers for the event's exact class and then the `TikTokEvent` catch-all, on the calling thread, before it returns, whether or not the client is connected. Subscribe with `onEvent(MyEvent.class, handler)` on the builder or with a listener method whose parameter is `MyEvent`; see [listeners](/guides/listeners/). `TikTokEmptyEvent` is a ready-made event with no fields for a plain trigger. +A custom event is a class that extends `TikTokEvent`, an abstract class with no fields and a `toJson()` method. `client.publishEvent(event)` runs the `TikTokEvent` catch-all handlers first and then the handlers for the event's exact class, on the calling thread, before it returns, whether or not the client is connected. Subscribe with `onEvent(MyEvent.class, handler)` on the builder or with a listener method whose parameter is `MyEvent`; see [listeners](/guides/listeners/). `TikTokEmptyEvent` is a ready-made event with no fields for a plain trigger. The example sorts gifts into two tiers and publishes a tier event from the gift handler. The repository's version declares the event classes with Lombok's `@AllArgsConstructor`; plain constructors do the same without annotation processing. @@ -111,7 +111,7 @@ TikTokLive.newClient("isaackogz") ## Wrap or replace a default mapping -`forMessage` returns the message's `TikTokMapperModel`, the existing one for a message the defaults already map, with three hooks. `onBeforeMapping(action)` receives the bytes and returns the bytes to map. `onMapping(action)` replaces the mapping, and the default is lost: after `forMessage(WebcastGiftMessage.class).onMapping(...)`, `onGift` no longer fires unless your mapping builds the gift events itself. `onAfterMapping(function)` receives the `MappingResult`, with `getSource()` (the deserialised message, as `Object`) and `getEvents()`, and returns the list of events to publish, so it can add, drop, or replace events while the default mapping keeps running. `forAnyMessage()` returns a model whose hooks run for every registered message around the message's own; its `onMapping` runs only when the message's mapping returns `null`, and it never sees messages without a mapping. +`forMessage` returns the message's `TikTokMapperModel`, the existing one for a message the defaults already map, with three hooks. `onBeforeMapping(action)` receives the bytes and returns the bytes to map. `onMapping(action)` replaces the mapping, and the default is lost: after `forMessage(WebcastGiftMessage.class).onMapping(...)`, `onGift` no longer fires unless your mapping builds the gift events itself. `onAfterMapping(function)` receives the `MappingResult`, with `getSource()` (the deserialised message, as `Object`) and `getEvents()`, and returns the list of events to publish, so it can add, drop, or replace events while the default mapping keeps running. `forAnyMessage()` returns a model whose hooks run for every registered message after the message's own at each stage: its `onBeforeMapping` receives the bytes the message's hook returned, its `onAfterMapping` receives the events the message's hook returned, and its `onMapping` runs only when the message's mapping returns `null`. It never sees messages without a mapping. ```java import io.github.jwdeveloper.tiktok.api.data.events.common.TikTokEvent; diff --git a/docs/src/content/docs/guides/debugging.md b/docs/src/content/docs/guides/debugging.md index 775019db..73657112 100644 --- a/docs/src/content/docs/guides/debugging.md +++ b/docs/src/content/docs/guides/debugging.md @@ -27,7 +27,7 @@ TikTokLive.newClient("isaackogz") The client reports exceptions as `TikTokErrorEvent`, through `onError` or a listener method, with `getException()`. What arrives depends on where it was thrown: - The connect sequence: `TikTokLiveOfflineHostException`, `TikTokLiveUnknownHostException`, `TikTokSignServerException`, `TikTokLivePreConnectionException`, or a `TikTokLiveException` such as `Already connected`; the event is published and then `connect()` throws the same exception. See [lifecycle events](/getting-started/connection/#lifecycle-events). -- Message handling: when a mapping, or a lambda handler called for a message, throws while a batch is processed, the event carries a `TikTokLiveMessageException` with `messageMethod()`, the message name, `messageToBase64()`, its payload, `webcastResponseToBase64()`, the whole batch, and `getCause()`, the original exception; a mapping that cannot deserialise the payload has a `TikTokMessageMappingException` as that cause. The client skips the message and continues with the next one. +- Message handling: when a mapping, or a lambda handler called for a message, throws while a batch is processed, the event carries a `TikTokLiveMessageException` with `messageMethod()`, the message name, `messageToBase64()`, its payload, `webcastResponseToBase64()`, the whole batch, and `getCause()`, the original exception; a mapping that cannot deserialise the payload has as that cause a `TikTokMessageMappingException` when it used the helper's `bytesToWebcastObject`, or protobuf's `InvalidProtocolBufferException` when it called the message class's `parseFrom` directly, as the default handler-based mappings do. The client skips the message and continues with the next one. - Listener methods: a `TikTokEventListenerMethodException` wrapping the method's exception, from any event; see [listeners](/guides/listeners/#when-a-method-throws). The message name and payload in a `TikTokLiveMessageException` are what a bug report needs when the library fails on a message it does have a mapping for. diff --git a/docs/src/content/docs/guides/recorder.md b/docs/src/content/docs/guides/recorder.md index 0b8580d4..6e978b0d 100644 --- a/docs/src/content/docs/guides/recorder.md +++ b/docs/src/content/docs/guides/recorder.md @@ -86,7 +86,7 @@ client.disconnect(); The file is the FLV stream as received, which VLC and `ffplay` open directly. To change the container without re-encoding: ```sh -ffmpeg -i clip.flv -c copy clip.mp4 +ffmpeg -i recordings/clip.flv -c copy clip.mp4 ``` ## Requirements and limits diff --git a/docs/src/content/docs/troubleshooting.md b/docs/src/content/docs/troubleshooting.md index 51f289bf..35c3f0fd 100644 --- a/docs/src/content/docs/troubleshooting.md +++ b/docs/src/content/docs/troubleshooting.md @@ -1,11 +1,11 @@ --- title: Troubleshooting -description: Diagnose connection failures, signing errors, sockets that close, missing events, and the known limitations of this release, with every exception the library throws and when. +description: Diagnose connection failures, signing errors, sockets that close, missing events, and the known limitations of this release, with every exception the library defines and when. --- Start with the smallest [Getting Started](/) example, the exact dependency version from your build file (`1.13.1-Release` for these pages), the call that failed, and the original exception with its cause chain. A connection has four stages that fail differently: the HTTP requests to TikTok that resolve the room and check that the creator is live, the signing request to [Euler Stream](https://www.eulerstream.com/), the WebSocket handshake with TikTok, and the decoding and mapping of messages once connected. Succeeding at one does not establish that the next works, and a proxy or a session can affect each stage separately. -`LiveClient` has three connection methods, `connect`, `connectAsync` (with or without a consumer), and `disconnect` (with or without a `LiveClientStopType`); two injection methods, `publishEvent` and `publishMessage`; `sendChat`; and four getters, `getRoomInfo`, `getGiftManager`, `getListenersManager`, and `getLogger`. Name the one involved in a report. Every exception the library throws is unchecked and extends `TikTokLiveException`, which extends `RuntimeException`. When a stage of `connect` throws one, the client publishes `TikTokErrorEvent` with it and `TikTokDisconnectedEvent` with the reason `Exception: `, then rethrows it: `buildAndConnect()` and `connect()` throw to the caller, and the `Async` forms complete their future exceptionally. Exceptions raised after the connection is up, on the socket thread or in a handler, reach you only through `onError`; see [read every exception](/guides/debugging/#read-every-exception). +`LiveClient` has three connection methods, `connect`, `connectAsync` (with or without a consumer), and `disconnect` (with or without a `LiveClientStopType`); two injection methods, `publishEvent` and `publishMessage`; `sendChat`; and four getters, `getRoomInfo`, `getGiftManager`, `getListenersManager`, and `getLogger`. Name the one involved in a report. Every exception the library defines is unchecked and extends `TikTokLiveException`, which extends `RuntimeException`. When a stage of `connect` throws one, the client publishes `TikTokErrorEvent` with it and `TikTokDisconnectedEvent` with the reason `Exception: `, then rethrows it: `buildAndConnect()` and `connect()` throw to the caller; `buildAndConnectAsync()` and `connectAsync()` complete their future exceptionally; `connectAsync(Consumer)` discards the failed future, so its consumer is not called and the two events are the only report. Exceptions raised after the connection is up, on the socket thread or in a handler, reach you only through `onError`; see [read every exception](/guides/debugging/#read-every-exception). ## Exceptions @@ -14,9 +14,9 @@ All eleven classes are in `io.github.jwdeveloper.tiktok.api.exceptions`. | Exception | Thrown when | | --- | --- | | `TikTokLiveException` | The base class, and the one thrown directly for `Already connected`, `HostName can not be null`, `Minimum allowed ping interval is 250 milliseconds`, `Livestream for is 18+ or age restricted!` with `throwOnAgeRestriction`, `Failed to connect to the websocket` when the proxy list is exhausted, `Failed to stop the websocket` when a `CLOSE_BLOCKING` wait is interrupted, `Listener ... has already been registered`, and a `Picture` whose image download fails. `TikTokErrorEvent.of(message)` builds one for tests. | -| `TikTokLiveRequestException` | An HTTP request to TikTok answered with a status other than 200, failed in transport, or returned a body the mapper cannot read: `params_error` from the user request, or a room-information body that is not a JSON object or has no `data` section. Also `No more proxies available!` when the proxy list empties inside one attempt. | +| `TikTokLiveRequestException` | An HTTP request to TikTok answered with a status other than 200, failed in transport, or returned a body the mapper cannot read: `params_error` from the user request, or a room-information body that is not a JSON object or has no `data` section. Also `No more proxies available!` when the proxy list empties inside one attempt on the `HTTP` or `DIRECT` path; the `SOCKS` path prints that exception and returns a failed result instead. | | `TikTokSignServerException` | A subclass of the above for the signing request: a status other than 200, with the note described under [signing](/configuration/signing/#rate-limits-and-provider-errors); a 200 without the `x-set-tt-cookie` header; or a body that is not a `ProtoMessageFetchResult`. | -| `TikTokProxyRequestException` | Raised inside `HttpProxyClient` when a request through a proxy endpoint fails with a connect timeout, a `ConnectException`, or another `IOException`; with `autoDiscard` left `true`, its default, the endpoint is removed from the list first. It does not reach your code through the client: the `fetch` methods and `sendChat` catch it and send again while the list is not empty, then send once more, and that last request gets the plain `HttpClient` because the list is empty by then. What you see instead is that direct request's outcome, a result or a `TikTokLiveRequestException` (`TikTokSignServerException` for signing); with `autoDiscard` set to `false` the loop retries indefinitely. It reaches a caller only from a request made directly with `HttpClientBuilder` over settings with proxies enabled, see the [HTTP client page](/advanced/http-client/#how-a-request-is-built). The WebSocket client has one more `throw` of it, for a SOCKS SSL context that cannot be created, which the source marks as unreachable. | +| `TikTokProxyRequestException` | Raised inside `HttpProxyClient` when a request through a proxy endpoint fails with a connect timeout, a `ConnectException`, or another `IOException`; with `autoDiscard` left `true`, its default, the endpoint is removed from the list first for a connect failure, or for any `IOException` on the `SOCKS` path; an `HTTP` endpoint failing with another `IOException` stays in the list. It does not reach your code through the client: the `fetch` methods and `sendChat` catch it and send again while the list is not empty, then send once more, and that last request gets the plain `HttpClient` because the list is empty by then. What you see instead is that direct request's outcome, a result or a `TikTokLiveRequestException` (`TikTokSignServerException` for signing); with `autoDiscard` set to `false`, or with endpoints that are never removed, the loop retries indefinitely while every endpoint keeps failing. It reaches a caller only from a request made directly with `HttpClientBuilder` over settings with proxies enabled, see the [HTTP client page](/advanced/http-client/#how-a-request-is-built). The WebSocket client has one more `throw` of it, for a SOCKS SSL context that cannot be created, which the source marks as unreachable. | | `TikTokLiveOfflineHostException` | TikTok reported the creator offline, from the user request (`User is offline`) or from the room information (`not found, is the Host offline?`). `getUserData()` holds the user response; `getLiveData()` holds the room response or `null` in the first case. The only failure `retryOnConnectionFailure` retries. | | `TikTokLiveUnknownHostException` | TikTok reported the creator or the room not found (`User not found`, `could not be found`). Same two getters. Also what a block or captcha page produces, see below. | | `TikTokLivePreConnectionException` | A `TikTokPreConnectionEvent` handler called `setCancelConnection(true, reason)`; the message is the reason and `getPreconnectEvent()` is the event. | @@ -68,9 +68,9 @@ try { **`Already connected`** (`TikTokLiveException`): `connect` was called while the state is `CONNECTING` or `CONNECTED`. The failure branch still runs: the state flips to `DISCONNECTED` and `TikTokErrorEvent` and `TikTokDisconnectedEvent` are published while the existing socket stays open, and the next `connect` replaces that socket. Use one connection per client, or `disconnect` first; see [build, connect, or both](/getting-started/connection/#build-connect-or-both). -**`Unhandled exception report this bug to github`** in the log: `connect` hit an exception that is not a `TikTokLiveException`. The client prints the stack trace, calls `disconnect`, and returns normally with the state `DISCONNECTED`, without publishing `TikTokDisconnectedEvent` unless a socket was open. In this release the known cause is `connect` on an offline client, which fails with `NullPointerException` at `TikTokLiveClient.java:136`; see [known limitations](#known-limitations-in-this-release). Anything else is worth a report. +**`Unhandled exception report this bug to github`** in the log: `connect` hit an exception that is not a `TikTokLiveException`. The client logs that line, calls `disconnect`, prints the stack trace to stderr, and returns normally with the state `DISCONNECTED`, without publishing `TikTokDisconnectedEvent` unless a socket was open. In this release the known cause is `connect` on an offline client, which fails with `NullPointerException` at `TikTokLiveClient.java:136`; see [known limitations](#known-limitations-in-this-release). Anything else is worth a report. -**Nothing happens after `buildAndConnectAsync()`**: the connection runs on the common pool, whose threads are daemon threads, so a `main` that returns ends the program before the socket opens. Keep the main thread alive, as the repository's `ConnectionExample` does with `System.in.read()`. +**Nothing happens after `buildAndConnectAsync()`**: the connection runs on the common pool, whose threads are daemon threads, and the socket's read thread inherits that flag, so a `main` that returns ends the program whether or not the socket has opened. Keep the main thread alive for as long as the connection should live, as the repository's `ConnectionExample` does with `System.in.read()`. ## The socket closes @@ -96,7 +96,7 @@ A handshake that hangs rather than fails is consistent with the connect timeout These describe the checked-in `1.13.1-Release` code; recheck them against a newer release. -- `connect` on a client with `offline` set fails with `NullPointerException` at `TikTokLiveClient.java:136`, because the offline HTTP client returns a user response with no room info; it is logged, not thrown. Use `build()` with `publishEvent` and `publishMessage` instead, see [offline testing](/guides/offline-testing/). +- `connect` on a client with `offline` set fails with `NullPointerException` at `TikTokLiveClient.java:136`, because the offline HTTP client returns a user response with no room info; it is logged, not thrown. With `useEulerstreamWebsocket` also set, `connect` makes no HTTP request and succeeds against the stand-in socket. Use `build()` with `publishEvent` and `publishMessage` instead, see [offline testing](/guides/offline-testing/). - With proxies of type `HTTP` or `DIRECT`, a response other than 200 moves to the next endpoint without removing the current one, and the loop runs while the list is not empty, so a provider answering 429 through working proxies is retried in rotation indefinitely. See [how a request uses the list](/configuration/proxies-and-host/#how-a-request-uses-the-list). - The `roomId` setting on `LiveClientSettings` is never read; the room id always comes from the user request. - The WebSocket handshake and the SOCKS proxy path receive `timeout.toMillisPart()` as their connect timeout, so a whole-second `timeout` gives them none. From fc126f921918852e7644b1e46e438232e878743f Mon Sep 17 00:00:00 2001 From: isaackogan Date: Tue, 20 Oct 2026 19:54:30 -0400 Subject: [PATCH 28/31] docs: make the scheduled probe explicit about secrets and HTTP status, queue master deploys The upstream job now sets has_docs=false with a notice when a scheduled run has no Cloudflare secrets, treats only a 404 from the GitHub API as "no docs", and fails the job on any other status or a failed request. Pull-request runs still cancel each other; master pushes and scheduled runs queue in the shared group instead of cancelling a deploy. The hosting page, docs README, and contributing page describe the new step, both notices, the failure mode, and that a fork-only change is overwritten by the 06:17 UTC upstream deploy until merged upstream. Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ --- .github/workflows/docs.yml | 35 +++++++++++++++++++++++---- docs/README.md | 2 +- docs/src/content/docs/contributing.md | 2 +- docs/src/content/docs/hosting.md | 12 ++++----- 4 files changed, 38 insertions(+), 13 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index a72abccb..f5facc94 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -29,11 +29,16 @@ permissions: concurrency: group: docs-${{ github.ref }} - cancel-in-progress: true + # A newer pull-request push replaces the running check. Master pushes and scheduled runs share the + # refs/heads/master group and queue instead, so neither cancels the other's deploy mid-upload. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: upstream: runs-on: ubuntu-latest + env: + # The scheduled build exists only to deploy; a repository without the Cloudflare secrets skips it. + DEPLOY_ENABLED: ${{ secrets.CLOUDFLARE_API_TOKEN != '' }} outputs: has_docs: ${{ steps.probe.outputs.has_docs }} steps: @@ -42,13 +47,33 @@ jobs: env: GH_TOKEN: ${{ github.token }} # A scheduled run builds upstream master, which has nothing to build until docs/ is merged there. + # Only a 404 means "no docs"; any other failure to answer is reported, not treated as a skip. run: | - if [ "$GITHUB_EVENT_NAME" = "schedule" ] && ! gh api repos/jwdeveloper/TikTokLiveJava/contents/docs/package.json --silent; then - echo "::notice::jwdeveloper/TikTokLiveJava has no docs/package.json yet; skipping the scheduled build." - echo "has_docs=false" >> "$GITHUB_OUTPUT" - else + if [ "$GITHUB_EVENT_NAME" != "schedule" ]; then echo "has_docs=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [ "$DEPLOY_ENABLED" != "true" ]; then + echo "::notice::The Cloudflare deploy secrets are absent; skipping the scheduled build, which exists only to deploy." + echo "has_docs=false" >> "$GITHUB_OUTPUT" + exit 0 fi + status=$(curl -sS -o /dev/null -w '%{http_code}' \ + -H "Authorization: Bearer $GH_TOKEN" -H "Accept: application/vnd.github+json" \ + https://api.github.com/repos/jwdeveloper/TikTokLiveJava/contents/docs/package.json) || status="curl exit $?" + case "$status" in + 200) + echo "has_docs=true" >> "$GITHUB_OUTPUT" + ;; + 404) + echo "::notice::jwdeveloper/TikTokLiveJava has no docs/package.json yet; skipping the scheduled build." + echo "has_docs=false" >> "$GITHUB_OUTPUT" + ;; + *) + echo "::error::Could not check jwdeveloper/TikTokLiveJava for docs/package.json: HTTP $status" + exit 1 + ;; + esac build: needs: upstream diff --git a/docs/README.md b/docs/README.md index 86edae6a..2245d876 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,7 +32,7 @@ Facts come from the library source first, then the README, then `examples/`. Jav ## Hosting -The site is served from Cloudflare Workers static assets at . `wrangler.jsonc` in this directory declares the asset directory and the HTML handling for the `tiktoklivejava-docs` Worker; there is no Worker script and no `routes` block, because a maintainer attached the custom domain to the Worker once and it survives deploys. The GitHub workflow `.github/workflows/docs.yml` validates every pull request, runs the SEO gate (`pnpm --dir docs run seo`, which runs `scripts/verify-seo.mjs` over `dist`), and on pushes to `master` redeploys the existing Worker when the repository has the `CLOUDFLARE_API_TOKEN` secret, a token scoped to Editor on that Worker only; the deploy also needs `CLOUDFLARE_ACCOUNT_ID`. A daily scheduled run checks out the upstream repository's `master` and deploys it, so the hosted site tracks upstream from the fork that holds the secrets; until upstream carries `docs/`, the workflow's `upstream` job detects that and the build is skipped. +The site is served from Cloudflare Workers static assets at . `wrangler.jsonc` in this directory declares the asset directory and the HTML handling for the `tiktoklivejava-docs` Worker; there is no Worker script and no `routes` block, because a maintainer attached the custom domain to the Worker once and it survives deploys. The GitHub workflow `.github/workflows/docs.yml` validates every pull request, runs the SEO gate (`pnpm --dir docs run seo`, which runs `scripts/verify-seo.mjs` over `dist`), and on pushes to `master` redeploys the existing Worker when the repository has the `CLOUDFLARE_API_TOKEN` secret, a token scoped to Editor on that Worker only; the deploy also needs `CLOUDFLARE_ACCOUNT_ID`. A daily scheduled run checks out the upstream repository's `master` and deploys it, so the hosted site tracks upstream from the fork that holds the secrets, and a change pushed only to the fork's `master` is overwritten by it until merged upstream; until upstream carries `docs/`, the workflow's `upstream` job detects that (a `404` from the GitHub API; any other failure fails the job) and the build is skipped, as it is for a scheduled run in a repository without the deploy secrets. To deploy from a checkout after the installs above: diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index 7b83ec03..ae80a7db 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -83,6 +83,6 @@ A failing section prints its first three failures with the offending name. The c ## Deployment -`.github/workflows/docs.yml` runs the tests, the full build, and the SEO gate on every pull request that touches the docs or the library, uploads the built site as a review artifact, and on pushes to `master` redeploys the existing `tiktoklivejava-docs` Worker at `https://ttlj.docs.isaackogan.com/` where the repository holds a token scoped to that Worker; a daily scheduled run builds and deploys the upstream repository's `master` once it carries `docs/`, and until then its `upstream` probe job skips the build. See [hosting](/hosting/) for the Worker configuration, the one-time domain attachment, the secrets, and how to deploy from a checkout. +`.github/workflows/docs.yml` runs the tests, the full build, and the SEO gate on every pull request that touches the docs or the library, uploads the built site as a review artifact, and on pushes to `master` redeploys the existing `tiktoklivejava-docs` Worker at `https://ttlj.docs.isaackogan.com/` where the repository holds a token scoped to that Worker; a daily scheduled run, where the deploy secrets exist, builds and deploys the upstream repository's `master` once it carries `docs/`, and until then its `upstream` probe job skips the build. See [hosting](/hosting/) for the Worker configuration, the one-time domain attachment, the secrets, and how to deploy from a checkout. `DOCS_SITE` sets the public origin and defaults to the custom domain; `DOCS_BASE` sets the deployment subpath and defaults to `/`. Neither needs to be set for a normal build. diff --git a/docs/src/content/docs/hosting.md b/docs/src/content/docs/hosting.md index 575873f2..f93c099a 100644 --- a/docs/src/content/docs/hosting.md +++ b/docs/src/content/docs/hosting.md @@ -3,7 +3,7 @@ title: Hosting description: Host the static documentation on Cloudflare Workers static assets under a custom domain, deploy it from CI or a checkout, track the upstream repository on a schedule, and gate deploys on SEO checks. --- -The site is deployed to Cloudflare Workers as static assets and served at `https://ttlj.docs.isaackogan.com/`. Hosting lives in a maintainer's Cloudflare account while the documentation source stays in the public repository: the fork `isaackogan/TikTokLiveJava` holds the deployment secrets and runs the deploys, and a scheduled run there builds the upstream `jwdeveloper/TikTokLiveJava` `master` every day, so the site tracks upstream without the upstream repository holding any credentials. The same workflow in the upstream repository validates every pull request and would deploy too if its maintainer added the secrets. There is no Worker script: `docs/wrangler.jsonc` only declares the asset directory and the HTML handling; the custom domain is attached to the Worker once by a maintainer, not by the config, so CI deploys with a token scoped to that Worker alone. The site needs no Cloudflare Astro adapter and no TikTok or signing credentials; its only external link requirement is the dofollow links to [Euler Stream](https://www.eulerstream.com/) the SEO gate checks. +The site is deployed to Cloudflare Workers as static assets and served at `https://ttlj.docs.isaackogan.com/`. Hosting lives in a maintainer's Cloudflare account while the documentation source stays in the public repository: the fork `isaackogan/TikTokLiveJava` holds the deployment secrets and runs the deploys, and a scheduled run there builds and deploys the upstream `jwdeveloper/TikTokLiveJava` `master` every day once upstream carries `docs/` (until then the run checks and skips), so the site tracks upstream without the upstream repository holding any credentials. The same workflow in the upstream repository validates every pull request, skips its scheduled run while it has no deploy secrets, and would deploy too if its maintainer added them. There is no Worker script: `docs/wrangler.jsonc` only declares the asset directory and the HTML handling; the custom domain is attached to the Worker once by a maintainer, not by the config, so CI deploys with a token scoped to that Worker alone. The site needs no Cloudflare Astro adapter and no TikTok or signing credentials; its only external link requirement is the dofollow links to [Euler Stream](https://www.eulerstream.com/) the SEO gate checks. ## Configuration @@ -55,18 +55,18 @@ The Worker and its custom domain already exist, so a deploy only uploads the fil ## Continuous deployment -`.github/workflows/docs.yml`, named `Documentation`, runs on pull requests and on pushes to `master` that touch `docs/`, `API/`, `Client/`, `extension-recorder/`, `extension-collector/`, `README.md`, or the workflow itself; it can be started by hand with `workflow_dispatch`; and it runs on a schedule, `17 6 * * *`, 06:17 UTC every day. Its permissions are `contents: read`, and one run at a time per ref: the concurrency group is `docs-${{ github.ref }}`. +`.github/workflows/docs.yml`, named `Documentation`, runs on pull requests and on pushes to `master` that touch `docs/`, `API/`, `Client/`, `extension-recorder/`, `extension-collector/`, `README.md`, or the workflow itself; it can be started by hand with `workflow_dispatch`; and it runs on a schedule, `17 6 * * *`, 06:17 UTC every day. Its permissions are `contents: read`, and one run at a time per ref: the concurrency group is `docs-${{ github.ref }}`, with `cancel-in-progress` only for pull requests, so a new push to a pull request replaces the running check, while `master` pushes and scheduled runs, which share the `refs/heads/master` group, queue behind each other and no deploy is cancelled mid-upload. -The workflow has two jobs. The first, `upstream`, has a single step, `Check whether upstream carries the documentation`: on a scheduled run it calls `gh api repos/jwdeveloper/TikTokLiveJava/contents/docs/package.json` with the run's own `GITHUB_TOKEN`, and when that file is absent it prints a `::notice::` and sets the job output `has_docs` to `false`; on every other event, and on a scheduled run once upstream carries `docs/`, the output is `true`. The `build` job has `needs: upstream` and runs only when `has_docs` is `true`, so until the documentation is merged upstream a scheduled run is green with `upstream` succeeded, `build` skipped, the notice in the run's annotations, and nothing deployed. +The workflow has two jobs. The first, `upstream`, has a single step, `Check whether upstream carries the documentation`, and a job variable `DEPLOY_ENABLED` derived from whether the `CLOUDFLARE_API_TOKEN` secret is set. On every event other than the schedule the step sets the job output `has_docs` to `true`. On a scheduled run it first looks at `DEPLOY_ENABLED`: where the secret is absent it prints the notice `The Cloudflare deploy secrets are absent; skipping the scheduled build, which exists only to deploy.` and sets `has_docs` to `false`, so the upstream repository's own daily run does not build a site it cannot deploy. Otherwise it requests `https://api.github.com/repos/jwdeveloper/TikTokLiveJava/contents/docs/package.json` with the run's own `GITHUB_TOKEN` and reads the HTTP status: `200` sets `has_docs` to `true`; `404` prints the notice `jwdeveloper/TikTokLiveJava has no docs/package.json yet; skipping the scheduled build.` and sets it to `false`; any other status, or a request that fails, prints an `::error::` annotation and fails the job, so a rate limit or an outage is a red run rather than a silently skipped deploy. The `build` job has `needs: upstream` and runs only when `has_docs` is `true`, so until the documentation is merged upstream a scheduled run on the fork is green with `upstream` succeeded, `build` skipped, the notice in the run's annotations, and nothing deployed. -The `build` job has one of two checkout steps: `Check out upstream master` on a scheduled run, which checks out `jwdeveloper/TikTokLiveJava` at `master`, and `Check out the repository` on every other event, which checks out the repository and ref that triggered it. It then sets up Temurin JDK 17 with the Maven cache; `Build the library` runs `mvn -q -pl API,Client,extension-recorder,extension-collector -am -DskipTests package`; it sets up Node.js 24; `Install pnpm` runs `npm install --global pnpm@10.34.2`; `Install documentation dependencies` runs `pnpm --dir docs install --frozen-lockfile`; `Test the documentation scripts` runs `pnpm --dir docs test`; `Validate and build documentation` runs `pnpm --dir docs build`; `Verify SEO invariants` runs the SEO gate `pnpm --dir docs run seo`; and `Upload site for review` uploads `docs/dist` as the `documentation` artifact. The last step, `Deploy to Cloudflare Workers`, runs `cloudflare/wrangler-action@v4` (`workingDirectory: docs`, `packageManager: pnpm`, `command: deploy`) when the event is not a pull request, the ref is `refs/heads/master` or the event is the schedule, and `DEPLOY_ENABLED` is `true`; that job variable is derived from whether the `CLOUDFLARE_API_TOKEN` secret is set. The deploy redeploys the existing Worker and needs both of these secrets: +The `build` job has one of two checkout steps: `Check out upstream master` on a scheduled run, which checks out `jwdeveloper/TikTokLiveJava` at `master`, and `Check out the repository` on every other event, which checks out the repository and ref that triggered it. It then sets up Temurin JDK 17 with the Maven cache; `Build the library` runs `mvn -q -pl API,Client,extension-recorder,extension-collector -am -DskipTests package`; it sets up Node.js 24; `Install pnpm` runs `npm install --global pnpm@10.34.2`; `Install documentation dependencies` runs `pnpm --dir docs install --frozen-lockfile`; `Test the documentation scripts` runs `pnpm --dir docs test`; `Validate and build documentation` runs `pnpm --dir docs build`; `Verify SEO invariants` runs the SEO gate `pnpm --dir docs run seo`; and `Upload site for review` uploads `docs/dist` as the `documentation` artifact. The last step, `Deploy to Cloudflare Workers`, runs `cloudflare/wrangler-action@v4` (`workingDirectory: docs`, `packageManager: pnpm`, `command: deploy`) when the event is not a pull request, the ref is `refs/heads/master` or the event is the schedule, and `DEPLOY_ENABLED` is `true`; the `build` job derives that variable the same way as `upstream`. The deploy redeploys the existing Worker and needs both of these secrets: | Secret | Value | | --- | --- | | `CLOUDFLARE_API_TOKEN` | An account-owned token scoped to Editor on the `tiktoklivejava-docs` Worker only. It can upload to that Worker and nothing else: no Worker creation, no zone or route permission, which the config does not need. | | `CLOUDFLARE_ACCOUNT_ID` | The hosting account's id, shown by `wrangler whoami`. | -Without `CLOUDFLARE_API_TOKEN` the deploy step is skipped and the workflow still validates the build, so the upstream repository, forks, and pull requests are unaffected. The gate checks only the token; `CLOUDFLARE_ACCOUNT_ID` is passed to wrangler and is required for the deploy to succeed, so set both. The maintainer sets them with `gh secret set CLOUDFLARE_API_TOKEN -R isaackogan/TikTokLiveJava` and `gh secret set CLOUDFLARE_ACCOUNT_ID -R isaackogan/TikTokLiveJava`. Because the scheduled run checks out the upstream repository, the workflow file must be on the fork's default branch for the schedule to exist, and a change to the documentation reaches the site either by being merged upstream and picked up by the next scheduled run, or by being pushed to the fork's `master`. +Without `CLOUDFLARE_API_TOKEN` the deploy step is skipped and the workflow still validates the build on pull requests, pushes, and manual runs, so the upstream repository, forks, and pull requests are unaffected; only the scheduled run is skipped there, as described above. The gate checks only the token; `CLOUDFLARE_ACCOUNT_ID` is passed to wrangler and is required for the deploy to succeed, so set both. The maintainer sets them with `gh secret set CLOUDFLARE_API_TOKEN -R isaackogan/TikTokLiveJava` and `gh secret set CLOUDFLARE_ACCOUNT_ID -R isaackogan/TikTokLiveJava`. Because the scheduled run checks out the upstream repository, the workflow file must be on the fork's default branch for the schedule to exist, and a change to the documentation reaches the site either by being merged upstream and picked up by the next scheduled run, or by being pushed to the fork's `master`. Both deploy to the same Worker, so the two sources are not independent: a change pushed to the fork's `master` but not merged upstream is overwritten by the next scheduled run at 06:17 UTC, which deploys upstream `master`, and stays overwritten until it is merged upstream. ## SEO gate @@ -82,7 +82,7 @@ curl -I https://ttlj.docs.isaackogan.com/events curl -I https://ttlj.docs.isaackogan.com/missing/ ``` -Expect a 200, a 307 redirect to the trailing-slash form, and a 404 in that order. Then open the site, check an event page and the generated API and schema pages, and search for a gift event. The canonical link in the page head must use the custom domain. A scheduled run shows up as `gh run list --workflow=docs.yml -R isaackogan/TikTokLiveJava --event schedule --limit 1`, and `gh run view -R isaackogan/TikTokLiveJava` lists its jobs: while upstream has no `docs/`, `upstream` succeeded and `build` skipped, with the notice `jwdeveloper/TikTokLiveJava has no docs/package.json yet; skipping the scheduled build.` in the annotations; once it does, both jobs succeed and the `Deploy to Cloudflare Workers` step ran. +Expect a 200, a 307 redirect to the trailing-slash form, and a 404 in that order. Then open the site, check an event page and the generated API and schema pages, and search for a gift event. The canonical link in the page head must use the custom domain. A scheduled run shows up as `gh run list --workflow=docs.yml -R isaackogan/TikTokLiveJava --event schedule --limit 1`, and `gh run view -R isaackogan/TikTokLiveJava` lists its jobs: while upstream has no `docs/`, `upstream` succeeded and `build` skipped, with the notice `jwdeveloper/TikTokLiveJava has no docs/package.json yet; skipping the scheduled build.` in the annotations (in a repository without the deploy secrets the notice reads `The Cloudflare deploy secrets are absent; skipping the scheduled build, which exists only to deploy.` instead); once it does, both jobs succeed and the `Deploy to Cloudflare Workers` step ran. A scheduled run whose `upstream` job failed could not get an answer from the GitHub API; its error annotation carries the HTTP status, and the next day's run tries again. ## Changing the hostname From 5f04af44f905f416c3020ba10235a5773ffd9dbf Mon Sep 17 00:00:00 2001 From: isaackogan Date: Tue, 20 Oct 2026 20:00:24 -0400 Subject: [PATCH 29/31] docs: keep Javadoc generics escaped in listings and render
 blocks raw

summary() no longer un-escapes "\<"; the type page's description
frontmatter, which is JSON-quoted, gets the plain form through
Markdown.plain(). 
 blocks are cut out of the description before
the HTML conversion, escaping and tidying, and emitted as a fenced block
with entities decoded, {@code} content verbatim (JavaParser's cut at the
first brace is repaired) and common indentation removed; single-line
contexts (field bullets, enum values, @param/@return) render them as a
code span instead of a broken fence. MarkdownTest pins the listing
line, the description frontmatter, the fenced block, the bullets with
pipes, the link and the {@link} on a synthetic compilation unit.

Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ
---
 docs/tools/src/main/java/docs/ApiPages.java   |   2 +-
 docs/tools/src/main/java/docs/Markdown.java   | 182 ++++++++++++++++--
 .../src/test/java/docs/MarkdownTest.java      | 126 ++++++++++++
 3 files changed, 294 insertions(+), 16 deletions(-)
 create mode 100644 docs/tools/src/test/java/docs/MarkdownTest.java

diff --git a/docs/tools/src/main/java/docs/ApiPages.java b/docs/tools/src/main/java/docs/ApiPages.java
index 757e8160..0fa93bac 100644
--- a/docs/tools/src/main/java/docs/ApiPages.java
+++ b/docs/tools/src/main/java/docs/ApiPages.java
@@ -417,7 +417,7 @@ private static String listing(String heading, List types, Predicate]*href=\"([^\"]*)\"[^>]*>(.*?)");
     private static final Pattern CODE_TAG = Pattern.compile("(?is)<(code|tt)>(.*?)");
     private static final Pattern MARKDOWN_LINK = Pattern.compile("\\[([^\\]]*)\\]\\([^)]*\\)");
+    /** A {@code 
} or {@code 
} tag; group 1 is the slash of a closing tag. */ + private static final Pattern PRE_TAG = Pattern.compile("(?i)<(/?)pre>"); + /** Stands in for a {@code
} block while the surrounding text is escaped and tidied; private-use characters survive {@code trim()}. */
+    private static final String PRE_MARK = "\uE000PRE";
 
     private Markdown() {}
 
@@ -131,30 +137,42 @@ static String escape(String text) {
 
     // --- Javadoc ----------------------------------------------------------------------------
 
-    /** The first sentence of the Javadoc description as one plain line, or an empty string. */
+    /**
+     * The first sentence of the Javadoc description as one Markdown line, or an empty string. Links are reduced to
+     * their text and emphasis is dropped; tag-like {@code <} stays escaped, so the line can be written into a
+     * listing as it is. {@link #plain} removes the escapes for a quoted frontmatter value.
+     */
     static String summary(Optional javadoc) {
-        var text = javadoc.map(j -> description(j.getDescription())).orElse("");
+        var text = javadoc.map(j -> description(j.getDescription(), false)).orElse("");
         if (text.isEmpty()) {
             return "";
         }
         var line = oneLine(text.split("\\n\\s*\\n", 2)[0]);
         var m = FIRST_SENTENCE.matcher(line);
         var sentence = m.find() ? m.group(1) : line;
-        return MARKDOWN_LINK.matcher(sentence).replaceAll("$1").replace("\\<", "<").replace("**", "");
+        return MARKDOWN_LINK.matcher(sentence).replaceAll("$1").replace("**", "");
     }
 
-    /** The whole Javadoc description on one line, with {@code @apiNote} appended, or an empty string. */
+    /** A {@link #summary} with its Markdown escapes removed, for a value {@link #jsonString} quotes. */
+    static String plain(String summary) {
+        return summary.replace("\\<", "<");
+    }
+
+    /**
+     * The whole Javadoc description on one line, with {@code @apiNote} appended, or an empty string. A
+     * {@code 
} block becomes a code span, since a fence cannot sit on a bullet.
+     */
     static String line(Optional javadoc) {
         if (javadoc.isEmpty()) {
             return "";
         }
-        var sb = new StringBuilder(oneLine(description(javadoc.get().getDescription())));
+        var sb = new StringBuilder(oneLine(description(javadoc.get().getDescription(), false)));
         for (var tag : javadoc.get().getBlockTags()) {
             if (isNote(tag)) {
                 if (sb.length() > 0) {
                     sb.append(' ');
                 }
-                sb.append("Note: ").append(oneLine(description(tag.getContent())));
+                sb.append("Note: ").append(oneLine(description(tag.getContent(), false)));
             }
         }
         return sb.toString();
@@ -181,7 +199,7 @@ static String body(Optional javadoc) {
 
     /** The text of a block tag ({@code @param}, {@code @return}, {@code @throws}) as one Markdown line. */
     static String tagText(JavadocBlockTag tag) {
-        return oneLine(description(tag.getContent()));
+        return oneLine(description(tag.getContent(), false));
     }
 
     private static boolean isNote(JavadocBlockTag tag) {
@@ -196,17 +214,144 @@ private static void paragraph(StringBuilder sb, String text) {
         sb.append(text);
     }
 
-    /** Javadoc description elements as Markdown: inline tags become code spans, HTML becomes Markdown. */
+    /**
+     * Javadoc description elements as Markdown: inline tags become code spans, HTML becomes Markdown, and each
+     * {@code 
} block becomes a fenced block holding its text raw.
+     */
     static String description(JavadocDescription description) {
+        return description(description, true);
+    }
+
+    /**
+     * As {@link #description(JavadocDescription)}; with {@code fences} false, for single-line contexts, each
+     * {@code 
} block becomes a code span instead.
+     */
+    static String description(JavadocDescription description, boolean fences) {
         var sb = new StringBuilder();
+        var blocks = new ArrayList();
+        PreBlock pre = null;
         for (var element : description.getElements()) {
             if (element instanceof JavadocInlineTag tag) {
-                sb.append(inlineTag(tag));
+                if (pre == null) {
+                    sb.append(inlineTag(tag));
+                } else {
+                    pre.tag(tag);
+                }
+                continue;
+            }
+            var text = element.toText();
+            var m = PRE_TAG.matcher(text);
+            int last = 0;
+            while (m.find()) {
+                var chunk = text.substring(last, m.start());
+                if (pre == null) {
+                    sb.append(html(chunk));
+                } else {
+                    pre.text(chunk);
+                }
+                var closing = !m.group(1).isEmpty();
+                if (closing && pre != null) {
+                    blocks.add(pre.toString());
+                    sb.append("\n\n").append(PRE_MARK).append(blocks.size() - 1).append("\uE001\n\n");
+                    pre = null;
+                } else if (!closing && pre == null) {
+                    pre = new PreBlock();
+                }
+                last = m.end();
+            }
+            var tail = text.substring(last);
+            if (pre == null) {
+                sb.append(html(tail));
             } else {
-                sb.append(html(element.toText()));
+                pre.text(tail);
+            }
+        }
+        if (pre != null) {
+            blocks.add(pre.toString());
+            sb.append("\n\n").append(PRE_MARK).append(blocks.size() - 1).append('\uE001');
+        }
+        var out = tidy(sb.toString());
+        for (int i = 0; i < blocks.size(); i++) {
+            var block = preBlock(blocks.get(i));
+            out = out.replace(PRE_MARK + i + '\uE001', fences ? "```\n" + block + "\n```" : code(oneLine(block)));
+        }
+        return out;
+    }
+
+    /**
+     * The raw text of one {@code 
} block as it is read: snippets as they are, inline tags by their content.
+     * JavaParser ends an inline tag at the first {@code }}, so a {@code {@code}} block containing braces arrives cut
+     * short; the brace it consumed is restored and the tag's real closing brace, the first one that unbalances the
+     * block, is dropped.
+     */
+    private static final class PreBlock {
+        private final StringBuilder sb = new StringBuilder();
+        /** Unmatched {@code {} of a cut-short {@code {@code}} tag still open, or -1 when none is pending. */
+        private int openBraces = -1;
+
+        void text(String text) {
+            if (openBraces < 0) {
+                sb.append(text);
+                return;
+            }
+            for (int i = 0; i < text.length(); i++) {
+                char c = text.charAt(i);
+                if (c == '{') {
+                    openBraces++;
+                } else if (c == '}' && --openBraces < 0) {
+                    sb.append(text, i + 1, text.length());
+                    return;
+                }
+                sb.append(c);
+            }
+        }
+
+        void tag(JavadocInlineTag tag) {
+            var content = tag.getContent();
+            switch (tag.getType()) {
+                case LINK, LINKPLAIN -> {
+                    var parts = content.trim().split("\\s+", 2);
+                    sb.append(parts.length > 1 ? parts[1] : parts[0]);
+                }
+                case INHERIT_DOC -> {
+                }
+                default -> {
+                    sb.append(content);
+                    var open = content.chars().filter(c -> c == '{').count() - content.chars().filter(c -> c == '}').count();
+                    if (open > 0) {
+                        sb.append('}');
+                        openBraces = (int) open - 1;
+                    }
+                }
             }
         }
-        return tidy(sb.toString());
+
+        @Override
+        public String toString() {
+            return sb.toString();
+        }
+    }
+
+    /** A {@code 
} block's text for a fence: entities decoded, blank edges and common indentation removed, nothing escaped. */
+    private static String preBlock(String raw) {
+        var lines = new ArrayList<>(List.of(decode(raw).replace("\r", "").split("\n", -1)));
+        while (!lines.isEmpty() && lines.get(0).isBlank()) {
+            lines.remove(0);
+        }
+        while (!lines.isEmpty() && lines.get(lines.size() - 1).isBlank()) {
+            lines.remove(lines.size() - 1);
+        }
+        var indent = Integer.MAX_VALUE;
+        for (var line : lines) {
+            if (!line.isBlank()) {
+                indent = Math.min(indent, line.length() - line.stripLeading().length());
+            }
+        }
+        var sb = new StringBuilder();
+        for (var line : lines) {
+            sb.append(line.isBlank() ? "" : line.substring(indent)).append('\n');
+        }
+        return sb.toString().stripTrailing();
     }
 
     private static String inlineTag(JavadocInlineTag tag) {
@@ -224,13 +369,14 @@ private static String inlineTag(JavadocInlineTag tag) {
         };
     }
 
-    /** The HTML subset Javadoc authors use, as Markdown; anything tag-like that remains is escaped. */
+    /**
+     * The HTML subset Javadoc authors use, as Markdown; anything tag-like that remains is escaped. {@code 
}
+     * blocks are cut out before this runs, see {@link #description(JavadocDescription, boolean)}.
+     */
     static String html(String text) {
-        var s = text.replace("<", "<").replace(">", ">").replace(""", "\"").replace("@", "@")
-                .replace(" ", " ").replace("&", "&");
+        var s = decode(text);
         s = A_TAG.matcher(s).replaceAll(m -> Matcher.quoteReplacement(link(oneLine(m.group(2)), m.group(1))));
         s = CODE_TAG.matcher(s).replaceAll(m -> Matcher.quoteReplacement(code(oneLine(m.group(2)))));
-        s = s.replaceAll("(?i)
\\s*", "\n\n```\n").replaceAll("(?i)\\s*
", "\n```\n\n"); s = s.replaceAll("(?i)", "\n\n"); s = s.replaceAll("(?i)", "\n"); s = s.replaceAll("(?i)\\s*
  • \\s*", "\n- ").replaceAll("(?i)
  • \\s*", "\n"); @@ -240,6 +386,12 @@ static String html(String text) { return escape(s); } + /** The HTML entities Javadoc authors use, decoded. */ + private static String decode(String text) { + return text.replace("<", "<").replace(">", ">").replace(""", "\"").replace("@", "@") + .replace(" ", " ").replace("&", "&"); + } + /** Trims every line and collapses runs of blank lines. */ private static String tidy(String text) { var lines = text.split("\n", -1); diff --git a/docs/tools/src/test/java/docs/MarkdownTest.java b/docs/tools/src/test/java/docs/MarkdownTest.java new file mode 100644 index 00000000..04f98016 --- /dev/null +++ b/docs/tools/src/test/java/docs/MarkdownTest.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2023-2024 jwdeveloper jacekwoln@gmail.com + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package docs; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.github.javaparser.ParserConfiguration; +import com.github.javaparser.StaticJavaParser; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** Pins how Javadoc becomes Markdown: raw generics, {@code
    } blocks, links, pipes and {@code {@link}}. */
    +class MarkdownTest {
    +
    +    /** A unit whose Javadoc carries each construct once; the library's own sources have none of the awkward ones. */
    +    static final String SOURCE = """
    +            package demo.pkg;
    +
    +            import java.util.List;
    +
    +            /**
    +             * Holds a List per room, keyed by id. A second sentence with a pipe | and bold.
    +             * 

    + * Example: + *

    {@code
    +             * var holder = new Holder();
    +             * if (holder.size("a") < 2) {
    +             *     holder.gifts.add("x");
    +             * }
    +             * }
    + * See the docs and {@link Holder#size(String)}. + */ + public class Holder { + /** The gifts, a List of names | nothing else.
    {@code holder.gifts.get(0)}
    */ + public final List gifts = List.of(); + + /** + * Counts entries. + * + * @param filter a Predicate text with a | pipe + * @return the count, a | b + */ + public int size(String filter) { + return 0; + } + + /** A nested Map holder. */ + public static class Nested { + } + } + """; + + @Test + void syntheticJavadocRendersGenericsPreLinksAndPipes(@TempDir Path out) throws IOException { + StaticJavaParser.setConfiguration(new ParserConfiguration().setLanguageLevel(ParserConfiguration.LanguageLevel.JAVA_17)); + var unit = StaticJavaParser.parse(SOURCE); + ApiPages.render(List.of(unit), "0.0", ApiPagesTest.SOURCE_BASE, out); + + var root = Files.readString(out.resolve("index.md")); + assertTrue(root.contains("\n- [`Holder`](/api/demo/pkg/holder/) (`demo.pkg`) — Holds a List\\ per room, keyed by id.\n"), + "root listing keeps the generic escaped:\n" + root); + var pkg = Files.readString(out.resolve("demo/pkg/index.md")); + assertTrue(pkg.contains("\n- [`Holder`](/api/demo/pkg/holder/) — Holds a List\\ per room, keyed by id.\n"), + "package listing keeps the generic escaped:\n" + pkg); + + var page = Files.readString(out.resolve("demo/pkg/holder.md")); + assertTrue(page.startsWith("---\ntitle: Holder\ndescription: \"Holds a List per room, keyed by id.\"\n"), + "description frontmatter is the plain sentence:\n" + page); + assertTrue(page.contains("\nHolds a List\\ per room, keyed by id. A second sentence with a pipe | and **bold**.\n\nExample:\n\n" + + "```\n" + + "var holder = new Holder();\n" + + "if (holder.size(\"a\") < 2) {\n" + + " holder.gifts.add(\"x\");\n" + + "}\n" + + "```\n\n" + + "See [the docs](https://example.com/docs) and `Holder.size(String)`.\n"), + "body: escaped generic, bold, raw fenced block with braces and indentation, link, {@link}:\n" + page); + assertTrue(page.contains("\n- **`gifts`** (`List`) — The gifts, a List\\ of names | nothing else. `holder.gifts.get(0)`\n"), + "field bullet: generic escaped, pipe kept,
     as a code span:\n" + page);
    +        assertFalse(page.contains("```\n- ") || page.contains(" ```"), "no fence on a bullet:\n" + page);
    +        assertTrue(page.contains("\n- **`filter`** (`String`) — a Predicate\\ text with a | pipe\n"), "parameter bullet:\n" + page);
    +        assertTrue(page.contains("\n#### Returns\n\n`int` — the count, a | b\n"), "returns line:\n" + page);
    +        assertTrue(page.contains("\n- [`Holder.Nested`](/api/demo/pkg/holder-nested/) (class) — A nested Map\\ holder.\n"),
    +                "nested-type list keeps the generic escaped:\n" + page);
    +
    +        var nested = Files.readString(out.resolve("demo/pkg/holder-nested.md"));
    +        assertTrue(nested.contains("\ndescription: \"A nested Map holder.\"\n"), "nested description is plain:\n" + nested);
    +    }
    +
    +    @Test
    +    void cellEscapesPipes() {
    +        assertEquals("the count, a \\| b", Markdown.cell(" the count,\n a | b "));
    +    }
    +
    +    @Test
    +    void plainRemovesTheEscapeSummaryKeeps() {
    +        assertEquals("Holds a List.", Markdown.plain("Holds a List\\."));
    +        assertEquals("a `code` span", Markdown.plain("a `code` span"));
    +    }
    +}
    
    From 7c3c4d5b0c4d5d051481e6fc63a0408280b97340 Mon Sep 17 00:00:00 2001
    From: isaackogan 
    Date: Tue, 20 Oct 2026 20:03:20 -0400
    Subject: [PATCH 30/31] docs: require an event section per class and resolve
     the README's hosted links in the check
    
    The events section now also requires a "## " heading on
    a grouped page under events/ other than the catalog, so the row alone
    no longer passes; the readme section resolves every hosted link in the
    README to an authored page and, with a fragment, to a heading whose
    slug equals it. Fence languages are compared case-insensitively. Tests
    cover a passing and a failing case for each rule and pin the real
    README against the real pages.
    
    Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ
    ---
     docs/src/content/docs/contributing.md         |  4 +-
     docs/src/content/docs/hosting.md              |  2 +-
     .../src/main/java/docs/ContentCheck.java      | 93 +++++++++++++++++--
     .../src/test/java/docs/ContentCheckTest.java  | 72 ++++++++++++++
     4 files changed, 162 insertions(+), 9 deletions(-)
    
    diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md
    index ae80a7db..67b8e32f 100644
    --- a/docs/src/content/docs/contributing.md
    +++ b/docs/src/content/docs/contributing.md
    @@ -70,13 +70,13 @@ When a behaviour cannot be read from the code, run it against a live room and sa
     
     | Section | Passes when |
     | --- | --- |
    -| `events` | Every concrete event class under `api/data/events` is named in backticks on some page and has a catalog row on `events/index.md` whose type cell equals its `@EventMeta` category (`Control`, `Message`, `Debug`, or `none`). |
    +| `events` | Every concrete event class under `api/data/events` is named in backticks on some page, has a catalog row on `events/index.md` whose type cell equals its `@EventMeta` category (`Control`, `Message`, `Debug`, or `none`), and has a `## ` section on a grouped page under `events/` other than the catalog, which is what the README's hosted anchors point at. |
     | `settings` | Every field of `LiveClientSettings` and `HttpClientSettings` is named in backticks on some page. |
     | `builder` | Every public `on*` handler of `EventsBuilder` and every public method of `LiveClient` is named in backticks on some page. |
     | `exceptions` | Every class under `api/exceptions` is named in backticks on some page. |
     | `extensions` | `TikTokLiveRecorder` and `TikTokLiveCollector` are named in backticks on some page. |
     | `discord` | No page contains a Discord link domain. |
    -| `readme` | Every `- [onX](#anchor)` item of the README's events list names an event class, the anchor's last segment, and an `EventsBuilder` handler. |
    +| `readme` | Every `- [onX](#anchor)` item of the README's events list names an event class, the anchor's last segment, and an `EventsBuilder` handler; and every `https://ttlj.docs.isaackogan.com/...` link in the README resolves to an authored page and, with a fragment, to a heading on it whose slug (lowercase, non-alphanumerics as hyphens) equals the fragment. |
     | `examples` | Every Java fence compiles as described above; a deliberately wrong canary must fail, so an empty classpath cannot pass the section. |
     
     A failing section prints its first three failures with the offending name. The check tells you what is missing; it cannot tell whether a sentence is true, so verify behaviour in the source before describing it.
    diff --git a/docs/src/content/docs/hosting.md b/docs/src/content/docs/hosting.md
    index f93c099a..79ad4f15 100644
    --- a/docs/src/content/docs/hosting.md
    +++ b/docs/src/content/docs/hosting.md
    @@ -86,4 +86,4 @@ Expect a 200, a 307 redirect to the trailing-slash form, and a 404 in that order
     
     ## Changing the hostname
     
    -Attach the new hostname to the Worker the same way the current one was attached: under the Worker's **Settings → Domains & Routes** in the Cloudflare dashboard, or with a one-off `wrangler deploy` from a logged-in maintainer session using a `routes` entry in a local copy of the config; Cloudflare provisions the DNS record and the certificate then, and the repository config stays without a `routes` block. Then change the default `site` in `docs/astro.config.mjs`, update `BASE_URL` in `docs/scripts/verify-seo.mjs` and the `Sitemap` line in `docs/public/robots.txt`, update the hosted links in the repository README and the hostname named in the `docs/wrangler.jsonc` comment, and deploy. Remove the old hostname in the same dashboard section.
    +Attach the new hostname to the Worker the same way the current one was attached: under the Worker's **Settings → Domains & Routes** in the Cloudflare dashboard, or with a one-off `wrangler deploy` from a logged-in maintainer session using a `routes` entry in a local copy of the config; Cloudflare provisions the DNS record and the certificate then, and the repository config stays without a `routes` block. Then change the default `site` in `docs/astro.config.mjs`, update `BASE_URL` in `docs/scripts/verify-seo.mjs` and the `Sitemap` line in `docs/public/robots.txt`, update the hosted links in the repository README, `SITE` in `docs/tools/src/main/java/docs/ContentCheck.java` (the content check resolves the README's hosted links against it), and the hostname named in the `docs/wrangler.jsonc` comment, and deploy. Remove the old hostname in the same dashboard section.
    diff --git a/docs/tools/src/main/java/docs/ContentCheck.java b/docs/tools/src/main/java/docs/ContentCheck.java
    index 61455c5a..a2e200a4 100644
    --- a/docs/tools/src/main/java/docs/ContentCheck.java
    +++ b/docs/tools/src/main/java/docs/ContentCheck.java
    @@ -37,6 +37,7 @@
     import java.util.ArrayList;
     import java.util.Collection;
     import java.util.HashMap;
    +import java.util.HashSet;
     import java.util.LinkedHashMap;
     import java.util.LinkedHashSet;
     import java.util.List;
    @@ -49,9 +50,9 @@
     import java.util.stream.Stream;
     
     /**
    - * The {@code check} command: verifies that the authored pages cover the library (events, settings, handlers,
    - * exceptions, extensions), reference no Discord link, agree with the README's events list, and that every Java
    - * example compiles.
    + * The {@code check} command: verifies that the authored pages cover the library (events, each with a section on a
    + * grouped events page, settings, handlers, exceptions, extensions), reference no Discord link, agree with the
    + * README's events list and resolve its hosted links, and that every Java example compiles.
      */
     final class ContentCheck {
     
    @@ -64,13 +65,20 @@ final class ContentCheck {
         static final List SETTINGS_CLASSES = List.of("io.github.jwdeveloper.tiktok.api.data.settings.LiveClientSettings",
                 "io.github.jwdeveloper.tiktok.api.data.settings.HttpClientSettings");
         static final String CATALOG_PAGE = "events/index.md";
    +    /** The directory of the grouped event pages, each event's {@code ## } section lives on one of them. */
    +    static final String EVENTS_DIR = "events/";
         /** Category of an event class under the events package that carries no {@code @EventMeta}. */
         static final String NO_CATEGORY = "none";
         /** Generated directories under the content root; their pages are not authored documentation. */
         static final Set GENERATED_DIRS = Set.of("api", "schema");
    +    /** The site's origin: the README's hosted links start with it and are resolved against the authored pages. */
    +    static final String SITE = "https://ttlj.docs.isaackogan.com/";
     
         private static final Pattern CATALOG_ROW = Pattern.compile("^\\s*\\|\\s*`(\\w+)`\\s*\\|\\s*(\\w+)\\s*\\|", Pattern.MULTILINE);
         private static final Pattern README_ITEM = Pattern.compile("^\\s*-\\s+\\[(on\\w+)]\\(#([\\w-]+)\\)", Pattern.MULTILINE);
    +    private static final Pattern SITE_LINK = Pattern.compile(Pattern.quote(SITE) + "([\\w./-]*)(?:#([\\w-]+))?");
    +    private static final Pattern EVENT_HEADING = Pattern.compile("^##\\s+`?(\\w+)`?\\s*$", Pattern.MULTILINE);
    +    private static final Pattern HEADING = Pattern.compile("^#{1,6}\\s+(.+?)\\s*$", Pattern.MULTILINE);
         private static final Pattern DISCORD = Pattern.compile("(?i)discord\\.(com|gg)");
         private static final Pattern FENCE_OPEN = Pattern.compile("^\\s*(`{3,}|~{3,})\\s*(\\w*)(.*)$");
         private static final Pattern FENCE_CLOSE = Pattern.compile("^\\s*(`{3,}|~{3,})\\s*$");
    @@ -98,7 +106,10 @@ static int run(Path repoRoot, PrintStream out) {
             results.put("exceptions", checkNames("exception", exceptions, pages));
             results.put("extensions", checkNames("extension", EXTENSIONS, pages));
             results.put("discord", checkDiscord(pages));
    -        results.put("readme", checkReadme(read(repoRoot.resolve("README.md")), builderHandlers(units), eventClassNames(units)));
    +        var readme = read(repoRoot.resolve("README.md"));
    +        var readmeFailures = new ArrayList<>(checkReadme(readme, builderHandlers(units), eventClassNames(units)));
    +        readmeFailures.addAll(checkReadmeLinks(readme, pages));
    +        results.put("readme", readmeFailures);
             results.put("examples", ExampleCompiler.compile(examples, repoRoot, ExampleCompiler.packages(units)));
             var failed = false;
             for (var section : SECTIONS) {
    @@ -319,7 +330,7 @@ static List fences(String text, String language) {
                 }
                 var close = FENCE_CLOSE.matcher(line);
                 if (close.matches() && close.group(1).charAt(0) == fence.charAt(0) && close.group(1).length() >= fence.length()) {
    -                if (lang.equals(language)) {
    +                if (lang.equalsIgnoreCase(language)) {
                         bodies.add(dedent(body));
                     }
                     fence = null;
    @@ -346,7 +357,11 @@ private static String dedent(List lines) {
     
         // --- rules --------------------------------------------------------------------------------
     
    -    /** Every event is named in backticks on some page and has a catalog row whose type equals its category. */
    +    /**
    +     * Every event is named in backticks on some page, has a catalog row whose type equals its category, and has a
    +     * {@code ## } section on a grouped page under {@code events/} other than the catalog: the
    +     * README's hosted anchors point at those headings.
    +     */
         static List checkEvents(Map events, List pages) {
             var failures = new ArrayList();
             var catalog = pages.stream().filter(p -> p.path().equals(CATALOG_PAGE)).findFirst().map(Page::text).orElse(null);
    @@ -358,11 +373,23 @@ static List checkEvents(Map events, List pages) {
             while (m.find()) {
                 rows.putIfAbsent(m.group(1), m.group(2));
             }
    +        var headings = new HashSet();
    +        for (var page : pages) {
    +            if (page.path().startsWith(EVENTS_DIR) && !page.path().equals(CATALOG_PAGE)) {
    +                var h = EVENT_HEADING.matcher(page.text());
    +                while (h.find()) {
    +                    headings.add(h.group(1));
    +                }
    +            }
    +        }
             for (var entry : new TreeMap<>(events).entrySet()) {
                 var name = entry.getKey();
                 if (!mentioned(name, pages)) {
                     failures.add("Missing event documentation: " + name);
                 }
    +            if (!headings.contains(name)) {
    +                failures.add("Event section heading missing: " + name);
    +            }
                 var type = rows.get(name);
                 if (type == null) {
                     failures.add("Event catalog row missing: " + name);
    @@ -420,6 +447,60 @@ static List checkReadme(String readme, Collection builderHandler
             return new ArrayList<>(failures);
         }
     
    +    /**
    +     * Every hosted link in the README, {@code https://ttlj.docs.isaackogan.com/#}, names an authored
    +     * page ({@code .md} or {@code /index.md}; {@code /} is {@code index.md}) and, when it has a fragment,
    +     * a heading on that page whose slug equals it: lowercase, each run of non-alphanumerics one hyphen, as the README
    +     * anchors are written.
    +     */
    +    static List checkReadmeLinks(String readme, List pages) {
    +        var byPath = new HashMap();
    +        for (var page : pages) {
    +            byPath.put(page.path(), page);
    +        }
    +        var failures = new LinkedHashSet();
    +        var m = SITE_LINK.matcher(readme);
    +        while (m.find()) {
    +            var fragment = m.group(2);
    +            var url = SITE + m.group(1) + (fragment == null ? "" : "#" + fragment);
    +            var page = page(byPath, m.group(1).replaceAll("/+$", ""));
    +            if (page == null) {
    +                failures.add("README link has no page: " + url);
    +            } else if (fragment != null && !slugs(page.text()).contains(fragment)) {
    +                failures.add("README link has no heading: " + url);
    +            }
    +        }
    +        return new ArrayList<>(failures);
    +    }
    +
    +    private static Page page(Map byPath, String path) {
    +        if (path.isEmpty()) {
    +            return byPath.get("index.md");
    +        }
    +        for (var candidate : List.of(path + ".md", path + ".mdx", path + "/index.md", path + "/index.mdx")) {
    +            var page = byPath.get(candidate);
    +            if (page != null) {
    +                return page;
    +            }
    +        }
    +        return null;
    +    }
    +
    +    /** The slug of every heading on a page. */
    +    static Set slugs(String text) {
    +        var slugs = new HashSet();
    +        var m = HEADING.matcher(text);
    +        while (m.find()) {
    +            slugs.add(slug(m.group(1)));
    +        }
    +        return slugs;
    +    }
    +
    +    /** Starlight's anchor for a heading: lowercase, each run of non-alphanumerics one hyphen, none at the ends. */
    +    static String slug(String heading) {
    +        return heading.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]+", "-").replaceAll("^-+|-+$", "");
    +    }
    +
         private static boolean mentioned(String name, List pages) {
             var span = "`" + name + "`";
             for (var page : pages) {
    diff --git a/docs/tools/src/test/java/docs/ContentCheckTest.java b/docs/tools/src/test/java/docs/ContentCheckTest.java
    index 5bfc1de6..7012b75c 100644
    --- a/docs/tools/src/test/java/docs/ContentCheckTest.java
    +++ b/docs/tools/src/test/java/docs/ContentCheckTest.java
    @@ -77,16 +77,19 @@ void settingsHandlersExceptions() {
         void checkEventsReportsMissingEvent(@TempDir Path site) throws IOException {
             write(site, "t.md", "The `TikTokCommentEvent` event.\n");
             write(site, "events/index.md", "| Event | Type | Description |\n|---|---|---|\n| `TikTokCommentEvent` | Message | A comment. |\n");
    +        write(site, "events/chat.md", "## TikTokCommentEvent\n\nA comment.\n");
             var events = Map.of("TikTokCommentEvent", "Message", "TikTokNewShinyEvent", "Message");
             var failures = ContentCheck.checkEvents(events, ContentCheck.pages(site));
             assertTrue(failures.contains("Missing event documentation: TikTokNewShinyEvent"), failures.toString());
             assertTrue(failures.contains("Event catalog row missing: TikTokNewShinyEvent"), failures.toString());
    +        assertTrue(failures.contains("Event section heading missing: TikTokNewShinyEvent"), failures.toString());
             assertFalse(failures.stream().anyMatch(f -> f.contains("TikTokCommentEvent")), failures.toString());
         }
     
         @Test
         void catalogRowTypeMustMatchAnnotation(@TempDir Path site) throws IOException {
             write(site, "events/index.md", "| Event | Type | Description |\n|---|---|---|\n| `TikTokCommentEvent` | Control | x |\n");
    +        write(site, "events/chat.md", "## TikTokCommentEvent\n");
             var failures = ContentCheck.checkEvents(Map.of("TikTokCommentEvent", "Message"), ContentCheck.pages(site));
             assertEquals(List.of("Event catalog row is stale: TikTokCommentEvent"), failures);
         }
    @@ -94,9 +97,28 @@ void catalogRowTypeMustMatchAnnotation(@TempDir Path site) throws IOException {
         @Test
         void catalogRowAcceptsNoneForUnannotatedEvents(@TempDir Path site) throws IOException {
             write(site, "events/index.md", "| `TikTokHeaderEvent` | none | base |\n");
    +        write(site, "events/debugging.md", "## `TikTokHeaderEvent`\n");
             assertEquals(List.of(), ContentCheck.checkEvents(Map.of("TikTokHeaderEvent", "none"), ContentCheck.pages(site)));
         }
     
    +    @Test
    +    void eventSectionHeadingMustBeOnAGroupedEventsPage(@TempDir Path site) throws IOException {
    +        // The catalog row alone used to satisfy the section: `mentioned()` counts the row's backticks.
    +        write(site, "events/index.md", "| `TikTokCommentEvent` | Message | A comment. |\n\n## TikTokCommentEvent\n");
    +        write(site, "guides/chat.md", "## TikTokCommentEvent\n");
    +        write(site, "events/chat.md", "### TikTokCommentEvent\n\nA level-3 heading is not a section.\n");
    +        var failures = ContentCheck.checkEvents(Map.of("TikTokCommentEvent", "Message"), ContentCheck.pages(site));
    +        assertEquals(List.of("Event section heading missing: TikTokCommentEvent"), failures);
    +
    +        write(site, "events/chat.md", "## TikTokCommentEvent\n");
    +        assertEquals(List.of(), ContentCheck.checkEvents(Map.of("TikTokCommentEvent", "Message"), ContentCheck.pages(site)));
    +    }
    +
    +    @Test
    +    void repositoryEventsHaveSectionsCatalogRowsAndMentions() {
    +        assertEquals(List.of(), ContentCheck.checkEvents(ContentCheck.events(REPO), ContentCheck.pages(REPO.resolve(Main.CONTENT_DIR))));
    +    }
    +
         @Test
         void namesMustAppearInBackticks(@TempDir Path site) throws IOException {
             write(site, "t.md", "Set `retryConnectionTimeout`; plain pingInterval is not documentation.\n");
    @@ -143,6 +165,56 @@ void repositoryReadmeItemsAreParsedAndPassAgainstTheSources() throws IOException
             assertEquals(List.of(), ContentCheck.checkReadme(readme, ContentCheck.builderHandlers(REPO), ContentCheck.eventClassNames(REPO)));
         }
     
    +    @Test
    +    void readmeLinksResolveToPagesAndHeadings(@TempDir Path site) throws IOException {
    +        write(site, "index.md", "# Home\n");
    +        write(site, "events/index.md", "## Catalog\n");
    +        write(site, "events/control.md", "## TikTokConnectedEvent\n\n## connect() fails\n\n## The timeout and sub-second parts\n");
    +        var readme = "Docs: https://ttlj.docs.isaackogan.com/\n"
    +                + "- [onEvent](#onevent-tiktokevent) · [docs](https://ttlj.docs.isaackogan.com/events/)\n"
    +                + "- [onConnected](#onconnected) · [docs](https://ttlj.docs.isaackogan.com/events/control/#tiktokconnectedevent)\n"
    +                + "See https://ttlj.docs.isaackogan.com/events/control/#connect-fails and "
    +                + "https://ttlj.docs.isaackogan.com/events/control#the-timeout-and-sub-second-parts.\n";
    +        assertEquals(List.of(), ContentCheck.checkReadmeLinks(readme, ContentCheck.pages(site)));
    +    }
    +
    +    @Test
    +    void readmeLinkToMissingPageOrHeadingFails(@TempDir Path site) throws IOException {
    +        write(site, "events/control.md", "## TikTokConnectedEvent\n");
    +        var readme = "- [onComment](#oncomment) · [docs](https://ttlj.docs.isaackogan.com/events/chat/#tiktokcommentevent)\n"
    +                + "- [onError](#onerror) · [docs](https://ttlj.docs.isaackogan.com/events/control/#tiktokerrorevent)\n"
    +                + "- [onConnected](#onconnected) · [docs](https://ttlj.docs.isaackogan.com/events/control/#tiktokconnectedevent)\n";
    +        assertEquals(List.of(
    +                "README link has no page: https://ttlj.docs.isaackogan.com/events/chat/#tiktokcommentevent",
    +                "README link has no heading: https://ttlj.docs.isaackogan.com/events/control/#tiktokerrorevent"),
    +                ContentCheck.checkReadmeLinks(readme, ContentCheck.pages(site)));
    +    }
    +
    +    @Test
    +    void repositoryReadmeLinksResolve() throws IOException {
    +        var readme = Files.readString(REPO.resolve("README.md"));
    +        assertTrue(readme.contains(ContentCheck.SITE + "events/control/#tiktokconnectedevent"), "the README links the hosted event sections");
    +        assertEquals(List.of(), ContentCheck.checkReadmeLinks(readme, ContentCheck.pages(REPO.resolve(Main.CONTENT_DIR))));
    +    }
    +
    +    @Test
    +    void slugMatchesStarlightAnchors() {
    +        assertEquals("tiktokcommentevent", ContentCheck.slug("TikTokCommentEvent"));
    +        assertEquals("tiktokcommentevent", ContentCheck.slug("`TikTokCommentEvent`"));
    +        assertEquals("connect-fails", ContentCheck.slug("connect() fails"));
    +        assertEquals("build-connect-or-both", ContentCheck.slug("Build, connect, or both"));
    +        assertEquals(Set.of("a", "b-c"), ContentCheck.slugs("# A\n\ntext\n\n### B c\n"));
    +    }
    +
    +    @Test
    +    void fenceLanguageIsCaseInsensitive(@TempDir Path site) throws IOException {
    +        write(site, "a.md", "```Java\nint a = 1;\n```\n\n```JAVA\nint b = 2;\n```\n\n```javascript\nconst c = 3;\n```\n");
    +        var examples = ContentCheck.examples(ContentCheck.pages(site));
    +        assertEquals(2, examples.size(), examples.toString());
    +        assertEquals("int a = 1;", examples.get(0).code());
    +        assertEquals("int b = 2;", examples.get(1).code());
    +    }
    +
         @Test
         void discordLinkFails(@TempDir Path site) throws IOException {
             write(site, "t.md", "Join https://discord.gg/abc\n");
    
    From 00c8374b2853dd3b887daa35378fdbd0f4e7eb84 Mon Sep 17 00:00:00 2001
    From: isaackogan 
    Date: Tue, 20 Oct 2026 20:07:08 -0400
    Subject: [PATCH 31/31] docs: apply the deferred tool minors from the task
     reviews
    
    Schema pages take their directory from the .proto file's name, threaded
    through ProtoFile, with the package-derived stem as the fallback. The
    content check scans README handler items only within the README's
    events list, reports duplicated catalog rows, and fails the examples
    section on an unclosed fence instead of dropping it. The example
    compiler maps javac errors on hoisted imports back to the line they
    were written on, batches full units by every type they declare so
    same-named helper classes do not collide, and lifts javac's error cap.
    The SEO gate's tests assert the exact check count, drop two unused
    exports and fix the run comment; the Euler host regex escapes every dot.
    
    Claude-Session: https://claude.ai/code/session_014bbgdemLuSLpxB3kaK7zfJ
    ---
     docs/scripts/verify-seo.mjs                   |  2 +-
     docs/scripts/verify-seo.test.mjs              |  8 +-
     docs/src/content/docs/contributing.md         |  2 +-
     .../src/main/java/docs/ContentCheck.java      | 76 ++++++++++++++++---
     .../src/main/java/docs/ExampleCompiler.java   | 55 +++++++++++---
     docs/tools/src/main/java/docs/ProtoFile.java  |  4 +-
     .../tools/src/main/java/docs/ProtoParser.java | 13 +++-
     .../tools/src/main/java/docs/SchemaPages.java |  9 ++-
     .../src/test/java/docs/ContentCheckTest.java  | 26 +++++++
     .../test/java/docs/ExampleCompilerTest.java   | 20 +++++
     .../src/test/java/docs/ProtoParserTest.java   |  4 +
     11 files changed, 187 insertions(+), 32 deletions(-)
    
    diff --git a/docs/scripts/verify-seo.mjs b/docs/scripts/verify-seo.mjs
    index 889d2111..3d38ec9b 100644
    --- a/docs/scripts/verify-seo.mjs
    +++ b/docs/scripts/verify-seo.mjs
    @@ -32,7 +32,7 @@ const ANCHOR_TAG = /]*>/gi;
     const CANONICAL_TAG = /]*\brel="canonical"[^>]*>/gi;
     const HREF = /\bhref="([^"]*)"/i;
     const EULER_ANCHOR = new RegExp(
    -  `]*href="[^"]*${EULER_HOST.replace('.', '\\.')}[^"]*"[^>]*>([\\s\\S]*?)`,
    +  `]*href="[^"]*${EULER_HOST.replace(/\./g, '\\.')}[^"]*"[^>]*>([\\s\\S]*?)`,
       'gi',
     );
     
    diff --git a/docs/scripts/verify-seo.test.mjs b/docs/scripts/verify-seo.test.mjs
    index 5c816b2b..6ac0e25e 100644
    --- a/docs/scripts/verify-seo.test.mjs
    +++ b/docs/scripts/verify-seo.test.mjs
    @@ -2,7 +2,7 @@
     // temporary directory and runs verifySeo against it, so the gate's checks are
     // exercised without a real Astro build.
     //
    -//   pnpm run test            (node --test scripts/)
    +//   pnpm run test            (node --test "scripts/**/*.test.mjs")
     
     import assert from 'node:assert/strict';
     import fs from 'node:fs';
    @@ -13,7 +13,7 @@ import { test } from 'node:test';
     import { BASE_URL, verifySeo } from './verify-seo.mjs';
     
     /** Render one fixture page. `route` is the path under BASE_URL ('' for the homepage). */
    -export function pageHtml({
    +function pageHtml({
       route,
       title = 'Guide',
       canonical = BASE_URL + route,
    @@ -75,7 +75,7 @@ function defaultFiles() {
      * Write a minimal built site into `dir`. `overrides` maps a relative path to
      * its content; a `null` value omits that file from the fixture.
      */
    -export function writeFixtureSite(dir, overrides = {}) {
    +function writeFixtureSite(dir, overrides = {}) {
       const files = { ...defaultFiles(), ...overrides };
       for (const [relative, content] of Object.entries(files)) {
         if (content === null) continue;
    @@ -98,7 +98,7 @@ test('gatePassesOnFixtureSite', (t) => {
       const { passes, failures } = verifySeo(dir);
     
       assert.deepEqual(failures, []);
    -  assert.ok(passes.length >= 15, `expected every check to run, got ${passes.length}`);
    +  assert.equal(passes.length, 17, `every check runs once on the fixture, got ${passes.length}`);
     });
     
     test('failsWhenNestedPageLacksEulerLink', (t) => {
    diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md
    index 67b8e32f..9343e51e 100644
    --- a/docs/src/content/docs/contributing.md
    +++ b/docs/src/content/docs/contributing.md
    @@ -77,7 +77,7 @@ When a behaviour cannot be read from the code, run it against a live room and sa
     | `extensions` | `TikTokLiveRecorder` and `TikTokLiveCollector` are named in backticks on some page. |
     | `discord` | No page contains a Discord link domain. |
     | `readme` | Every `- [onX](#anchor)` item of the README's events list names an event class, the anchor's last segment, and an `EventsBuilder` handler; and every `https://ttlj.docs.isaackogan.com/...` link in the README resolves to an authored page and, with a fragment, to a heading on it whose slug (lowercase, non-alphanumerics as hyphens) equals the fragment. |
    -| `examples` | Every Java fence compiles as described above; a deliberately wrong canary must fail, so an empty classpath cannot pass the section. |
    +| `examples` | Every Java fence compiles as described above, and no fence on any page is left unclosed; a deliberately wrong canary must fail, so an empty classpath cannot pass the section. |
     
     A failing section prints its first three failures with the offending name. The check tells you what is missing; it cannot tell whether a sentence is true, so verify behaviour in the source before describing it.
     
    diff --git a/docs/tools/src/main/java/docs/ContentCheck.java b/docs/tools/src/main/java/docs/ContentCheck.java
    index a2e200a4..4e45571a 100644
    --- a/docs/tools/src/main/java/docs/ContentCheck.java
    +++ b/docs/tools/src/main/java/docs/ContentCheck.java
    @@ -76,6 +76,9 @@ final class ContentCheck {
     
         private static final Pattern CATALOG_ROW = Pattern.compile("^\\s*\\|\\s*`(\\w+)`\\s*\\|\\s*(\\w+)\\s*\\|", Pattern.MULTILINE);
         private static final Pattern README_ITEM = Pattern.compile("^\\s*-\\s+\\[(on\\w+)]\\(#([\\w-]+)\\)", Pattern.MULTILINE);
    +    private static final Pattern EVENTS_HEADING = Pattern.compile("^##\\s+Events\\s*$", Pattern.MULTILINE);
    +    /** A level-1 or level-2 heading other than a repeated {@code ## Events}: where the README's events list ends. */
    +    private static final Pattern NEXT_SECTION = Pattern.compile("^#{1,2}\\s+(?!Events\\s*$).*$", Pattern.MULTILINE);
         private static final Pattern SITE_LINK = Pattern.compile(Pattern.quote(SITE) + "([\\w./-]*)(?:#([\\w-]+))?");
         private static final Pattern EVENT_HEADING = Pattern.compile("^##\\s+`?(\\w+)`?\\s*$", Pattern.MULTILINE);
         private static final Pattern HEADING = Pattern.compile("^#{1,6}\\s+(.+?)\\s*$", Pattern.MULTILINE);
    @@ -99,6 +102,7 @@ static int run(Path repoRoot, PrintStream out) {
             var exceptions = exceptions(units);
             var pages = pages(repoRoot.resolve(Main.CONTENT_DIR));
             var examples = examples(pages);
    +        var exampleFailures = new ArrayList<>(unclosedFences(pages));
             var results = new LinkedHashMap>();
             results.put("events", checkEvents(events, pages));
             results.put("settings", checkNames("settings field", settings, pages));
    @@ -110,7 +114,8 @@ static int run(Path repoRoot, PrintStream out) {
             var readmeFailures = new ArrayList<>(checkReadme(readme, builderHandlers(units), eventClassNames(units)));
             readmeFailures.addAll(checkReadmeLinks(readme, pages));
             results.put("readme", readmeFailures);
    -        results.put("examples", ExampleCompiler.compile(examples, repoRoot, ExampleCompiler.packages(units)));
    +        exampleFailures.addAll(ExampleCompiler.compile(examples, repoRoot, ExampleCompiler.packages(units)));
    +        results.put("examples", exampleFailures);
             var failed = false;
             for (var section : SECTIONS) {
                 var failures = results.get(section);
    @@ -312,33 +317,64 @@ static List examples(List pages) {
             return examples;
         }
     
    -    /** The dedented bodies of the fences with the given language, at any indentation; other fences are skipped. */
    +    /** The dedented bodies of the closed fences with the given language (compared ignoring case), at any indentation. */
         static List fences(String text, String language) {
             var bodies = new ArrayList();
    +        for (var fence : scan(text)) {
    +            if (fence.closed() && fence.language().equalsIgnoreCase(language)) {
    +                bodies.add(dedent(fence.body()));
    +            }
    +        }
    +        return bodies;
    +    }
    +
    +    /** One failure per fence that is never closed: Markdown renders the rest of the page as code, and no example compiles. */
    +    static List unclosedFences(List pages) {
    +        var failures = new ArrayList();
    +        for (var page : pages) {
    +            for (var fence : scan(page.text())) {
    +                if (!fence.closed()) {
    +                    failures.add(page.path() + ": the fence opened at line " + fence.line() + " is never closed");
    +                }
    +            }
    +        }
    +        return failures;
    +    }
    +
    +    /** One fenced block: its language tag, body lines, the line number of its opening fence, and whether it was closed. */
    +    private record Fence(String language, List body, int line, boolean closed) {}
    +
    +    private static List scan(String text) {
    +        var fences = new ArrayList();
             String fence = null;
             String lang = null;
    +        var openLine = 0;
             var body = new ArrayList();
    -        for (var line : text.split("\n", -1)) {
    +        var lines = text.split("\n", -1);
    +        for (int i = 0; i < lines.length; i++) {
    +            var line = lines[i];
                 if (fence == null) {
                     var open = FENCE_OPEN.matcher(line);
                     if (open.matches()) {
                         fence = open.group(1);
                         lang = open.group(2);
    -                    body.clear();
    +                    openLine = i + 1;
    +                    body = new ArrayList<>();
                     }
                     continue;
                 }
                 var close = FENCE_CLOSE.matcher(line);
                 if (close.matches() && close.group(1).charAt(0) == fence.charAt(0) && close.group(1).length() >= fence.length()) {
    -                if (lang.equalsIgnoreCase(language)) {
    -                    bodies.add(dedent(body));
    -                }
    +                fences.add(new Fence(lang, body, openLine, true));
                     fence = null;
                     continue;
                 }
                 body.add(line);
             }
    -        return bodies;
    +        if (fence != null) {
    +            fences.add(new Fence(lang, body, openLine, false));
    +        }
    +        return fences;
         }
     
         private static String dedent(List lines) {
    @@ -371,7 +407,9 @@ static List checkEvents(Map events, List pages) {
             var rows = new HashMap();
             var m = CATALOG_ROW.matcher(catalog == null ? "" : catalog);
             while (m.find()) {
    -            rows.putIfAbsent(m.group(1), m.group(2));
    +            if (rows.putIfAbsent(m.group(1), m.group(2)) != null) {
    +                failures.add("Event catalog row duplicated: " + m.group(1));
    +            }
             }
             var headings = new HashSet();
             for (var page : pages) {
    @@ -423,8 +461,8 @@ static List checkDiscord(List pages) {
         }
     
         /**
    -     * Every {@code - [onX](#onx-tiktokxevent)} item of the README, whatever follows the link, names an event class
    -     * (the anchor's last segment, compared case-insensitively) and an {@code EventsBuilder} handler.
    +     * Every {@code - [onX](#onx-tiktokxevent)} item of the README's events list, whatever follows the link, names an
    +     * event class (the anchor's last segment, compared case-insensitively) and an {@code EventsBuilder} handler.
          */
         static List checkReadme(String readme, Collection builderHandlers, Collection eventClasses) {
             var lower = new HashMap();
    @@ -432,7 +470,7 @@ static List checkReadme(String readme, Collection builderHandler
                 lower.put(name.toLowerCase(Locale.ROOT), name);
             }
             var failures = new LinkedHashSet();
    -        var m = README_ITEM.matcher(readme);
    +        var m = README_ITEM.matcher(eventsSection(readme));
             while (m.find()) {
                 var handler = m.group(1);
                 var anchor = m.group(2);
    @@ -447,6 +485,20 @@ static List checkReadme(String readme, Collection builderHandler
             return new ArrayList<>(failures);
         }
     
    +    /**
    +     * The README's events list: from its {@code ## Events} heading to the next level-1 or level-2 heading with another
    +     * title (a repeated {@code ## Events} does not end it), or the whole text when it has no such heading.
    +     */
    +    static String eventsSection(String readme) {
    +        var heading = EVENTS_HEADING.matcher(readme);
    +        if (!heading.find()) {
    +            return readme;
    +        }
    +        var next = NEXT_SECTION.matcher(readme);
    +        next.region(heading.end(), readme.length());
    +        return next.find() ? readme.substring(heading.start(), next.start()) : readme.substring(heading.start());
    +    }
    +
         /**
          * Every hosted link in the README, {@code https://ttlj.docs.isaackogan.com/#}, names an authored
          * page ({@code .md} or {@code /index.md}; {@code /} is {@code index.md}) and, when it has a fragment,
    diff --git a/docs/tools/src/main/java/docs/ExampleCompiler.java b/docs/tools/src/main/java/docs/ExampleCompiler.java
    index 64dc49f5..aa85f9d2 100644
    --- a/docs/tools/src/main/java/docs/ExampleCompiler.java
    +++ b/docs/tools/src/main/java/docs/ExampleCompiler.java
    @@ -34,9 +34,11 @@
     import java.nio.file.Path;
     import java.util.ArrayList;
     import java.util.Collection;
    +import java.util.Collections;
     import java.util.Comparator;
     import java.util.HashMap;
     import java.util.HashSet;
    +import java.util.LinkedHashMap;
     import java.util.LinkedHashSet;
     import java.util.List;
     import java.util.Locale;
    @@ -79,8 +81,12 @@ final class ExampleCompiler {
     
         private ExampleCompiler() {}
     
    -    /** One source file handed to javac: which example it came from and how many wrapper lines precede its body. */
    -    private record Unit(Example example, String key, Source source, int offset, int bodyLines) {}
    +    /**
    +     * One source file handed to javac: which example it came from, the package-qualified types it declares (no two
    +     * files of one batch may share one), how many wrapper lines precede its body, and for a fragment the wrapper line
    +     * of each hoisted {@code import} mapped to the body line it came from.
    +     */
    +    private record Unit(Example example, Set keys, Source source, int offset, int bodyLines, Map hoisted) {}
     
         private static final class Source extends SimpleJavaFileObject {
             private final String code;
    @@ -214,7 +220,11 @@ private static Unit unit(Example example, int index, Collection packages
             if (isFullUnit(code)) {
                 var name = declaredType(code);
                 var source = code.endsWith("\n") ? code : code + "\n";
    -            return new Unit(example, packageOf(code) + "/" + name, new Source(dir + name + ".java", source), 0, (int) code.lines().count());
    +            var keys = new HashSet();
    +            for (var declared : declaredTypes(code)) {
    +                keys.add(packageOf(code) + "/" + declared);
    +            }
    +            return new Unit(example, keys, new Source(dir + name + ".java", source), 0, (int) code.lines().count(), Map.of());
             }
             var imports = new LinkedHashSet();
             for (var pkg : packages) {
    @@ -223,11 +233,13 @@ private static Unit unit(Example example, int index, Collection packages
             for (var pkg : JDK_PACKAGES) {
                 imports.add("import " + pkg + ".*;");
             }
    +        var hoistedLines = new LinkedHashMap();
             var body = new StringBuilder();
             var bodyLines = 0;
             for (var line : code.split("\n", -1)) {
                 if (IMPORT.matcher(line).matches()) {
                     imports.add(line.strip());
    +                hoistedLines.putIfAbsent(line.strip(), bodyLines + 1);
                     line = "";
                 }
                 body.append(line).append('\n');
    @@ -235,14 +247,29 @@ private static Unit unit(Example example, int index, Collection packages
             }
             var name = "Example" + index;
             var source = new StringBuilder();
    -        for (var line : imports) {
    +        var ordered = new ArrayList<>(imports);
    +        for (var line : ordered) {
                 source.append(line).append('\n');
             }
    +        var hoisted = new HashMap();
    +        hoistedLines.forEach((statement, bodyLine) -> hoisted.put(ordered.indexOf(statement) + 1, bodyLine));
             source.append("public class ").append(name).append(" {\n");
             source.append("    void run() throws Exception {\n");
             source.append(body);
             source.append("    }\n}\n");
    -        return new Unit(example, "/" + name, new Source(dir + name + ".java", source.toString()), imports.size() + 2, bodyLines);
    +        return new Unit(example, Set.of("/" + name), new Source(dir + name + ".java", source.toString()), imports.size() + 2, bodyLines, hoisted);
    +    }
    +
    +    /** Every top-level type a full unit declares, the public one and any package-private helpers. */
    +    private static List declaredTypes(String code) {
    +        var names = new ArrayList();
    +        for (var line : code.split("\n")) {
    +            var m = TYPE_DECLARATION.matcher(line);
    +            if (m.find()) {
    +                names.add(m.group(4));
    +            }
    +        }
    +        return names;
         }
     
         /** The declared package of a full unit, or an empty string for the default package. */
    @@ -273,13 +300,13 @@ private static String declaredType(String code) {
             throw new IllegalArgumentException("no type declaration");
         }
     
    -    /** Splits the units into batches in which no two files declare the same package-qualified type. */
    +    /** Splits the units into batches in which no two files declare the same package-qualified type, helpers included. */
         private static List> batches(List units) {
             var batches = new ArrayList>();
             var keys = new ArrayList>();
             for (var unit : units) {
                 int i = 0;
    -            while (i < batches.size() && keys.get(i).contains(unit.key())) {
    +            while (i < batches.size() && !Collections.disjoint(keys.get(i), unit.keys())) {
                     i++;
                 }
                 if (i == batches.size()) {
    @@ -287,7 +314,7 @@ private static List> batches(List units) {
                     keys.add(new HashSet<>());
                 }
                 batches.get(i).add(unit);
    -            keys.get(i).add(unit.key());
    +            keys.get(i).addAll(unit.keys());
             }
             return batches;
         }
    @@ -303,7 +330,8 @@ private static List> run(JavaCompiler compi
             try (var fileManager = compiler.getStandardFileManager(diagnostics, Locale.ROOT, StandardCharsets.UTF_8)) {
                 fileManager.setLocationFromPaths(StandardLocation.CLASS_PATH, classpath);
                 fileManager.setLocationFromPaths(StandardLocation.CLASS_OUTPUT, List.of(outDir));
    -            var options = List.of("--release", "17", "-proc:none", "-implicit:none", "-Xlint:none");
    +            // -Xmaxerrs lifts javac's default cap of 100 errors, so a batch with many broken examples reports them all.
    +            var options = List.of("--release", "17", "-proc:none", "-implicit:none", "-Xlint:none", "-Xmaxerrs", "10000");
                 compiler.getTask(new StringWriter(), fileManager, diagnostics, options, null, sources).call();
             } catch (IOException e) {
                 throw new UncheckedIOException(e);
    @@ -311,8 +339,15 @@ private static List> run(JavaCompiler compi
             return diagnostics.getDiagnostics();
         }
     
    -    /** The diagnostic's line relative to the fence body, clamped into the body for errors reported on wrapper lines. */
    +    /**
    +     * The diagnostic's line relative to the fence body: a hoisted {@code import} maps back to the line it was written on,
    +     * and an error on any other wrapper line is clamped into the body.
    +     */
         private static long line(Diagnostic d, Unit unit) {
    +        var hoisted = unit.hoisted().get((int) d.getLineNumber());
    +        if (hoisted != null) {
    +            return hoisted;
    +        }
             var line = d.getLineNumber() - unit.offset();
             return Math.max(1, Math.min(line, Math.max(1, unit.bodyLines())));
         }
    diff --git a/docs/tools/src/main/java/docs/ProtoFile.java b/docs/tools/src/main/java/docs/ProtoFile.java
    index 954c2cac..4a19a16e 100644
    --- a/docs/tools/src/main/java/docs/ProtoFile.java
    +++ b/docs/tools/src/main/java/docs/ProtoFile.java
    @@ -27,9 +27,11 @@
     /**
      * One parsed {@code .proto} file.
      *
    + * @param name        the file's stem, lowercase: {@code webcast} for {@code webcast.proto}; it names the file's
    + *                    directory of schema pages
      * @param pkg         the {@code package} statement, or an empty string
      * @param javaPackage the {@code java_package} option, or an empty string
      * @param messages    top-level messages in declaration order
      * @param enums       top-level enums in declaration order
      */
    -record ProtoFile(String pkg, String javaPackage, List messages, List enums) {}
    +record ProtoFile(String name, String pkg, String javaPackage, List messages, List enums) {}
    diff --git a/docs/tools/src/main/java/docs/ProtoParser.java b/docs/tools/src/main/java/docs/ProtoParser.java
    index e1af9c8d..688d835e 100644
    --- a/docs/tools/src/main/java/docs/ProtoParser.java
    +++ b/docs/tools/src/main/java/docs/ProtoParser.java
    @@ -61,11 +61,20 @@ static ProtoFile parse(Path protoFile) {
             return parse(source, protoFile.getFileName().toString());
         }
     
    -    /** Parses proto source text; {@code origin} names it in error messages. */
    +    /** Parses proto source text; {@code origin}, a file name or any label, names it in error messages and gives the file its stem. */
         static ProtoFile parse(String source, String origin) {
             return new ProtoParser(lex(source, origin), origin).file();
         }
     
    +    /** {@code webcast} for {@code API/src/main/proto/webcast.proto} or {@code webcast.proto}. */
    +    static String stem(String origin) {
    +        var name = origin.substring(Math.max(origin.lastIndexOf('/'), origin.lastIndexOf('\\')) + 1);
    +        if (name.endsWith(".proto")) {
    +            name = name.substring(0, name.length() - ".proto".length());
    +        }
    +        return name.toLowerCase(java.util.Locale.ROOT);
    +    }
    +
         // --- Grammar ----------------------------------------------------------------------------
     
         private ProtoFile file() {
    @@ -98,7 +107,7 @@ private ProtoFile file() {
                     default -> throw error("unexpected '" + t + "' at top level");
                 }
             }
    -        return new ProtoFile(pkg, javaPackage, List.copyOf(messages), List.copyOf(enums));
    +        return new ProtoFile(stem(origin), pkg, javaPackage, List.copyOf(messages), List.copyOf(enums));
         }
     
         private ProtoMessage message(String parent) {
    diff --git a/docs/tools/src/main/java/docs/SchemaPages.java b/docs/tools/src/main/java/docs/SchemaPages.java
    index bbcf9819..ecd2a3a1 100644
    --- a/docs/tools/src/main/java/docs/SchemaPages.java
    +++ b/docs/tools/src/main/java/docs/SchemaPages.java
    @@ -68,8 +68,15 @@ String javaName() {
             }
         }
     
    -    /** The output directory for a file: the last segment of its {@code java_package} (or {@code package}). */
    +    /**
    +     * The output directory for a file: its name without the extension, so two files whose packages share a last
    +     * segment keep separate directories; a file without a name falls back to the last segment of its
    +     * {@code java_package} (or {@code package}).
    +     */
         static String fileStem(ProtoFile file) {
    +        if (!file.name().isEmpty()) {
    +            return file.name();
    +        }
             var pkg = file.javaPackage().isEmpty() ? file.pkg() : file.javaPackage();
             return pkg.substring(pkg.lastIndexOf('.') + 1).toLowerCase(Locale.ROOT);
         }
    diff --git a/docs/tools/src/test/java/docs/ContentCheckTest.java b/docs/tools/src/test/java/docs/ContentCheckTest.java
    index 7012b75c..a2cbac0b 100644
    --- a/docs/tools/src/test/java/docs/ContentCheckTest.java
    +++ b/docs/tools/src/test/java/docs/ContentCheckTest.java
    @@ -147,6 +147,32 @@ void readmeHandlerNotOnBuilderFails() {
             assertEquals(List.of("README handler is not on EventsBuilder: onCommentary"), failures);
         }
     
    +    @Test
    +    void readmeItemsOutsideTheEventsListAreIgnored() {
    +        var readme = "# Intro\n\n- [onBogus](#onbogus-tiktokbogusevent) in the intro\n\n## Events\n\n\n## Events\n\n**Message**:\n\n"
    +                + "- [onComment](#oncomment-tiktokcommentevent)\n\n# Examples\n\n- [onOther](#onother-tiktokotherevent)\n\n## Listeners\n\n- [onMore](#onmore-tiktokmoreevent)\n";
    +        assertEquals(List.of(), ContentCheck.checkReadme(readme, Set.of("onComment"), Set.of("TikTokCommentEvent")));
    +        assertEquals("## Events\n\n\n## Events\n\n**Message**:\n\n- [onComment](#oncomment-tiktokcommentevent)\n\n", ContentCheck.eventsSection(readme));
    +        assertEquals("- [onX](#onx-tiktokxevent)\n", ContentCheck.eventsSection("- [onX](#onx-tiktokxevent)\n"), "no heading: the whole text");
    +    }
    +
    +    @Test
    +    void duplicateCatalogRowsFail(@TempDir Path site) throws IOException {
    +        write(site, "events/index.md", "| `TikTokCommentEvent` | Message | A comment. |\n| `TikTokCommentEvent` | Message | Again. |\n");
    +        write(site, "events/chat.md", "## TikTokCommentEvent\n");
    +        assertEquals(List.of("Event catalog row duplicated: TikTokCommentEvent"),
    +                ContentCheck.checkEvents(Map.of("TikTokCommentEvent", "Message"), ContentCheck.pages(site)));
    +    }
    +
    +    @Test
    +    void unclosedFenceIsReportedAndNotCompiled(@TempDir Path site) throws IOException {
    +        write(site, "a.md", "Intro.\n\n```java\nint a = 1;\n```\n\nMore.\n\n```java\nint b = 2;\n\nThe page ends without a closing fence.\n");
    +        write(site, "b.md", "```sh\necho ok\n```\n");
    +        var pages = ContentCheck.pages(site);
    +        assertEquals(List.of("a.md: the fence opened at line 9 is never closed"), ContentCheck.unclosedFences(pages));
    +        assertEquals(1, ContentCheck.examples(pages).size(), "the unclosed fence is not an example");
    +    }
    +
         @Test
         void readmeItemWithTrailingTextPasses() {
             var readme = "**Message**:\n\n- [onComment](#oncomment-tiktokcommentevent) · [docs](https://example.com/events/chat/#tiktokcommentevent)\n"
    diff --git a/docs/tools/src/test/java/docs/ExampleCompilerTest.java b/docs/tools/src/test/java/docs/ExampleCompilerTest.java
    index 45c3278b..c7d59dc4 100644
    --- a/docs/tools/src/test/java/docs/ExampleCompilerTest.java
    +++ b/docs/tools/src/test/java/docs/ExampleCompilerTest.java
    @@ -66,6 +66,26 @@ void fragmentImportsAreHoisted() {
             assertEquals(List.of(), ExampleCompiler.compile(List.of(new Example("p.md, example 1", code)), REPO));
         }
     
    +    @Test
    +    void errorsOnHoistedImportsPointAtTheImportLine() {
    +        var code = "int a = 1;\n\nimport does.not.Exist;\n\n" + FRAGMENT;
    +        var f = ExampleCompiler.compile(List.of(new Example("p.md, example 2", code)), REPO);
    +        assertEquals(1, f.size(), f.toString());
    +        assertTrue(f.get(0).startsWith("p.md, example 2: line 3: "), f.get(0));
    +        assertTrue(f.get(0).contains("does.not"), f.get(0));
    +    }
    +
    +    @Test
    +    void fullUnitsWithTheSameHelperClassDoNotCollide() {
    +        var a = "public class MainA {\n    Object run() {\n        return new Helper();\n    }\n}\n\nclass Helper {\n}\n";
    +        var b = a.replace("MainA", "MainB");
    +        assertEquals(List.of(), ExampleCompiler.compile(List.of(new Example("a.md, example 1", a), new Example("b.md, example 1", b)), REPO));
    +        var broken = b.replace("return new Helper();", "return new Helper().missing();");
    +        var f = ExampleCompiler.compile(List.of(new Example("a.md, example 1", a), new Example("b.md, example 1", broken)), REPO);
    +        assertEquals(1, f.size(), f.toString());
    +        assertTrue(f.get(0).startsWith("b.md, example 1: line 3: "), f.get(0));
    +    }
    +
         @Test
         void fullUnitCompilesAsIs() {
             var unit = """
    diff --git a/docs/tools/src/test/java/docs/ProtoParserTest.java b/docs/tools/src/test/java/docs/ProtoParserTest.java
    index bd690ee5..abf98e79 100644
    --- a/docs/tools/src/test/java/docs/ProtoParserTest.java
    +++ b/docs/tools/src/test/java/docs/ProtoParserTest.java
    @@ -39,6 +39,7 @@ class ProtoParserTest {
         @Test
         void parsesMessagesEnumsAndNested() {
             var f = ProtoParser.parse(REPO.resolve("API/src/main/proto/webcast.proto"));
    +        assertEquals("webcast", f.name());
             assertEquals("TikTok", f.pkg());
             assertEquals("io.github.jwdeveloper.tiktok.api.messages.webcast", f.javaPackage());
             assertTrue(f.messages().stream().anyMatch(m -> m.name().equals("WebcastChatMessage")));
    @@ -87,7 +88,10 @@ enum Color { RED = 0; GREEN = 1; }
             var file = dir.resolve("fixture.proto");
             Files.writeString(file, source);
             var f = ProtoParser.parse(file);
    +        assertEquals("fixture", f.name());
             assertEquals("Fixture", f.pkg());
    +        assertEquals("notes", ProtoParser.parse("syntax = \"proto3\";", "dir/sub/Notes.proto").name(), "stem from a path");
    +        assertEquals("inline", ProtoParser.parse("syntax = \"proto3\";", "inline").name(), "a bare label is its own stem");
             assertEquals("fixture.gen", f.javaPackage());
             assertEquals(1, f.messages().size());
             assertEquals(1, f.enums().size());