clone

a deno framework for local-first web apps that open instantly, work offline and sync when users choose

release: v0.3.0

Dami committed a5a9cf1
commit
a5a9cf18087a90c17e1a9101324f26cc49c816ce
parent
21bf7a5ac0a39c3d0d3b13751655a573134a6055
author
Dami <dami.etoile@gmail.com>
40 files changed, +418 -119
M.github/workflows/ci.yml2+-
M.github/workflows/publish.yml2+-
MREADME.md59+++++++++++++++++++++---------
Mapps/reference/README.md9+++--
Mapps/reference/deno.json24++++++------
Mapps/reference/deno.lock4+-
Mdeno.json9+++--
Mdocs/README.md6++--
Mdocs/auth.md11+++---
Mdocs/devx-contract.md52++++++++++++++-------------
Mdocs/getting-started.md1+
Mpackage/access/errors.ts5+++
Mpackage/access/identity.ts6++-
Mpackage/access/mod.ts19+++++++++-
Mpackage/access/operations.ts13++++++-
Mpackage/access/policies.ts20++++++++--
Mpackage/access/schema.ts2+
Mpackage/astro/mod.ts10+++++
Mpackage/create_core.ts8++++
Mpackage/create_core_test.ts8++--
Mpackage/preact/DeviceStatus.tsx5++-
Mpackage/preact/PwaActions.tsx9++++-
Mpackage/preact/mod.ts25+++++++++++--
Mpackage/preact/use-device-capabilities.ts3++
Mpackage/runtime/auth.ts3++
Mpackage/runtime/device-capabilities.ts21++++++++---
Mpackage/runtime/mod.ts19+++++++++-
Mpackage/runtime/passkey-recovery.ts4++
Mpackage/runtime/pwa.ts23++++++++++--
Mpackage/runtime/recovery.ts3++
Mpackage/runtime/runtime.ts7++++
Mpackage/runtime/session.ts23+++++++-----
Mpackage/runtime/table-store.ts10+++++
Mpackage/runtime/ui-mutation.ts1+
Mpackage/starter_template.ts4+-
Mpackage/testdata/starter.snapshot.json4+-
Mpackage/testing/webauthn.ts7++++
Mpackage/version.ts2+-
Atools/doc_lint.ts50+++++++++++++++++++++++++
Atools/docs_links.ts44++++++++++++++++++++++
M .github/workflows/ci.yml +1 -1
M .github/workflows/publish.yml +1 -1
M README.md +41 -18
@@ -40,7 +40,11 @@ task example with your own schema, permissions, hook, and UI.
4040 - **Identity from the first launch.** Each user begins with a private on-device account, even when4141 the app has no sync service configured.4242 - **Optional sync and recovery.** Users can make that same account portable when you connect the app43- to managed Jazz sync.43+ to managed Jazz sync, using a recoverable passkey when available and a recovery phrase as the44+ portable fallback.45+- **Narrow collaboration templates.** Private resources, direct sharing, and fixed-role groups sit46+ behind a small `@nzip/lofi/access` API while raw Jazz permissions remain available as an escape47+ hatch.4448 - **A narrow authoring surface.** Product code owns the schema, permissions, configuration, Preact4549 islands, styles, and tests. lofi owns storage, identity, sync, lifecycle, and PWA plumbing.4650 - **Local-first test helpers.** Playwright-backed fixtures cover offline writes, multiple clients,
@@ -66,8 +70,10 @@ Provisioning creates a managed Jazz app and writes the public `JAZZ_APP_ID` and
6670 configuration to the project's `.env`. Server-only credentials remain outside client output.6771 6872 Once sync is configured, a user can choose to back up and sync their existing account, then recover69-it on another device with a 24-word recovery phrase. Enabling sync preserves data created while the70-app was local-only because the account identity does not change.73+it with a recoverable passkey or a 24-word recovery phrase. Passkey portability depends on the74+browser, provider, and configured RP-ID; the phrase remains the provider-independent fallback.75+Enabling sync preserves data created while the app was local-only because the account identity does76+not change.7177 7278 ## Where lofi fits7379
@@ -87,19 +93,23 @@ Keep these current constraints in mind:
8793 8894 A generated project keeps application source separate from the versioned framework package:8995 90-```mermaid91-flowchart TB92- Root["my-app/"] --> Config["deno.json"]93- Root --> Public["public/<br/>manifest · icons"]94- Root --> Src["src/"]95- Root --> Tests["tests/<br/>your tests + lofi testing contract"]96- Root --> Framework["@nzip/lofi<br/>versioned framework package"]97-98- Src --> Model["schema.ts + permissions.ts<br/>your model and access policy"]99- Src --> App["app.ts<br/>your application config"]100- Src --> UI["pages + islands + styles<br/>your product experience"]101- Src --> Layout["layouts/<br/>your document shell"]102- Framework --> Runtime["storage · identity · sync<br/>PWA · lifecycle · diagnostics"]96+```text97+my-app/98+├── deno.json # tasks and one pinned @nzip/lofi version99+├── public/ # manifest and replaceable product icons100+├── src/101+│ ├── app.ts # app, storage, sync, and passkey configuration102+│ ├── schema.ts # persisted data model103+│ ├── permissions.ts # private, shared, or group access policy104+│ ├── islands/ # interactive Preact product UI105+│ ├── layouts/ # document shell106+│ ├── pages/ # Astro routes107+│ └── styles/ # product styling108+├── tests/ # app tests and local-first browser journeys109+├── .lofi/ # generated package tooling (ignored)110+└── dist/ # generated production PWA (ignored)111+112+@nzip/lofi # versioned storage, identity, sync, PWA, and tooling103113 ```104114 105115 Product work stays in `schema.ts`, `permissions.ts`, `app.ts`, `pages/`, `layouts/`, `islands/`, and
@@ -109,6 +119,18 @@ code without copying it into your source tree.
109119 See the [exact generated-project map](docs/reference/project-layout.md) for every source-controlled110120 path, its ownership category, and the separately regenerated `.lofi/` and `dist/` trees.111121 122+## Sharing and groups123+124+`@nzip/lofi/access` provides schema helpers, policy templates, app-scoped sharing identities, and125+operations for direct shares and fixed-role groups. Collaboration requires configured sync; the126+helpers fail explicitly in local-only mode rather than implying that another device can see local127+data.128+129+Start with the [account and access API](docs/reference/access.md), then copy the130+[direct-share](docs/examples/shared.md) or [group](docs/examples/group.md) example. The templates131+compile ordinary Jazz policies, so unusual authorization models can still use raw Jazz schema and132+permission APIs.133+112134 ## Testing local-first behavior113135 114136 `@nzip/lofi/testing` provides Playwright-backed helpers for behavior that is difficult to test with
@@ -155,8 +177,9 @@ validated before it becomes part of the generated project.
155177 ## Identity and recovery model156178 157179 A new app opens immediately on a private, on-device account. When managed sync is configured, the158-user can back up and sync that same account and recover it elsewhere with the recovery phrase. The159-phrase is the portable backup; keeping it safe is the user's responsibility.180+user can back up and sync that same account and recover it with a recoverable passkey or the181+recovery phrase. The phrase remains the portable bearer-secret fallback; keeping it safe is the182+user's responsibility.160183 161184 See [Sync and recovery](docs/sync-and-recovery.md) for setup and shipping guidance, and162185 [Local-first accounts: open now, back up later](docs/auth-identity.md) for the detailed account
M apps/reference/README.md +6 -3
M apps/reference/deno.json +12 -12
@@ -2,18 +2,18 @@
22 "imports": {33 "@astrojs/check": "npm:@astrojs/check@0.9.9",44 "@astrojs/preact": "npm:@astrojs/preact@6.0.1",5- "@nzip/lofi": "jsr:@nzip/lofi@0.2.0",6- "@nzip/lofi/": "jsr:@nzip/lofi@0.2.0/",7- "@nzip/lofi/access": "jsr:@nzip/lofi@0.2.0/access",8- "@nzip/lofi/astro": "jsr:@nzip/lofi@0.2.0/astro",9- "@nzip/lofi/build": "jsr:@nzip/lofi@0.2.0/build",10- "@nzip/lofi/dev": "jsr:@nzip/lofi@0.2.0/dev",11- "@nzip/lofi/doctor": "jsr:@nzip/lofi@0.2.0/doctor",12- "@nzip/lofi/preview": "jsr:@nzip/lofi@0.2.0/preview",13- "@nzip/lofi/preact": "jsr:@nzip/lofi@0.2.0/preact",14- "@nzip/lofi/provision": "jsr:@nzip/lofi@0.2.0/provision",15- "@nzip/lofi/test": "jsr:@nzip/lofi@0.2.0/test",16- "@nzip/lofi/testing": "jsr:@nzip/lofi@0.2.0/testing",5+ "@nzip/lofi": "jsr:@nzip/lofi@0.3.0",6+ "@nzip/lofi/": "jsr:@nzip/lofi@0.3.0/",7+ "@nzip/lofi/access": "jsr:@nzip/lofi@0.3.0/access",8+ "@nzip/lofi/astro": "jsr:@nzip/lofi@0.3.0/astro",9+ "@nzip/lofi/build": "jsr:@nzip/lofi@0.3.0/build",10+ "@nzip/lofi/dev": "jsr:@nzip/lofi@0.3.0/dev",11+ "@nzip/lofi/doctor": "jsr:@nzip/lofi@0.3.0/doctor",12+ "@nzip/lofi/preview": "jsr:@nzip/lofi@0.3.0/preview",13+ "@nzip/lofi/preact": "jsr:@nzip/lofi@0.3.0/preact",14+ "@nzip/lofi/provision": "jsr:@nzip/lofi@0.3.0/provision",15+ "@nzip/lofi/test": "jsr:@nzip/lofi@0.3.0/test",16+ "@nzip/lofi/testing": "jsr:@nzip/lofi@0.3.0/testing",1717 "astro": "npm:astro@7.0.9",1818 "astro/config": "npm:astro@7.0.9/config",1919 "jazz-tools": "npm:jazz-tools@2.0.0-alpha.53",
M apps/reference/deno.lock +2 -2
M deno.json +6 -3
@@ -1,8 +1,8 @@
11 {22 "name": "@nzip/lofi",3- "version": "0.2.0",3+ "version": "0.3.0",44 "license": "MIT",5- "description": "A local-first mobile PWA meta-framework for Deno: declare a schema, build Preact islands, and ship an installable offline-capable app whose UI always hydrates from local data.",5+ "description": "A local-first Deno PWA framework with durable local data, recoverable accounts, optional Jazz sync, and private, direct-share, or group access templates.",66 "exports": {77 ".": "./package/runtime/mod.ts",88 "./astro": "./package/astro/mod.ts",
@@ -44,7 +44,9 @@
4444 },4545 "tasks": {4646 "build": "deno task --cwd apps/reference build",47- "check": "deno fmt --check . && deno lint && deno task test:unit && deno task test:access",47+ "check": "deno fmt --check . && deno lint && deno task doc && deno task docs:links && deno task test:unit && deno task test:access",48+ "doc": "deno run --allow-run tools/doc_lint.ts",49+ "docs:links": "deno run --allow-read tools/docs_links.ts",4850 "test:unit": "deno test --allow-env=LOFI_UPDATE_SNAPSHOT,CI --allow-net=127.0.0.1 --allow-read --allow-write=. --allow-run tools package/astro package/create_core_test.ts package/preact package/project_layout_doc_test.ts package/pwa_assets_test.ts package/runtime package/testing package/tooling apps/reference/tests",4951 "test:access": "deno test --allow-env --allow-net=127.0.0.1 --allow-read --allow-write --allow-run --allow-ffi package/access/*_test.ts",5052 "test:update:create": "LOFI_UPDATE_SNAPSHOT=1 deno test --allow-env=LOFI_UPDATE_SNAPSHOT --allow-read --allow-write=. --allow-run package/create_core_test.ts",
@@ -69,6 +71,7 @@
6971 "LICENSE",7072 "README.md",7173 "docs/README.md",74+ "docs/assets/brand/lofi-github-banner.png",7275 "docs/auth-identity.md",7376 "docs/auth.md",7477 "docs/decisions/passkey-backup-alpha53.md",
M docs/README.md +3 -3
M docs/auth.md +6 -5
@@ -12,7 +12,7 @@ lofi device auth is a small, honest **primitive**, not a mandated identity or re
1212 Local-first identity is device-local and cryptographic — there is no central store to authenticate1313 against — so this module does exactly three things: enroll a device passkey, authenticate with it,1414 and derive a credential-bound key to encrypt data **at rest**. The generated session runtime handles15-accounts, recovery phrases, and optional multi-device sync separately.15+accounts, recoverable-passkey backup, recovery phrases, and optional multi-device sync separately.1616 1717 The runtime lives at `package/runtime/auth.ts` and is exposed through `@nzip/lofi`.1818
@@ -29,10 +29,11 @@ The runtime lives at `package/runtime/auth.ts` and is exposed through `@nzip/lof
2929 HKDF-SHA-256. PRF support is feature-detected (`getAuthCapability`); if the client or3030 authenticator does not return a PRF result, the derive path throws `prf-unavailable` rather than3131 inventing a key.32-- **No cross-device recovery claim.** A lost device is lost access — the same custody the33- device-local identity already has. This is intentional and consistent with the earlier rejection34- of the Jazz alpha's passkey-backup ceremony: lofi does not smuggle a recovery/escrow model in35- through the auth primitive.32+- **No recovery claim for this primitive.** `enrollDeviceCredential` and the PRF-derived key do not33+ contain the Jazz account secret, so they cannot restore an account. Account recovery uses the34+ separate `createRecoverablePasskeyBackup` / `restoreFromPasskey` flow documented in35+ [Sync and recovery](sync-and-recovery.md), with an RP-ID- and provider-independent phrase36+ fallback.3637 3738 ## Usage3839
M docs/devx-contract.md +27 -25
@@ -104,28 +104,28 @@ required global runtime for a generated application.
104104 105105 ## Measurable promises106106 107-| ID | Promise | v0 budget or condition | Evidence owner | M1 gate | Status |108-| --------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- | ------- | --------- |109-| DX-CREATE-01 | Named creation is non-interactive. | Deno is the only required global runtime; zero prompts; uncached time reported. | M2 source and release smoke | no | validated |110-| DX-CMD-01 | The public command surface is small. | create, dev, doctor, test, build, preview, plus explicit sync/schema/deploy tasks. | #2, #6, and M2 | yes | revised |111-| DX-TTFW-01 | Generated create-to-retained-write is fast. | At most three shell commands and two minutes on the recorded machine/network. | M2 source and release smoke | no | validated |112-| DX-PROTOTYPE-01 | The M1 checkout reaches a retained write quickly. | `deno task dev` to retained write within 60 seconds after cached setup. | integrated M1 prototype | yes | validated |113-| DX-START-01 | Cached development startup feels immediate. | Warm median at most 2 seconds; slowest at most 5 seconds across five samples. | #6 | yes | validated |114-| DX-HMR-01 | UI edits retain state and runtime cardinality. | Median feedback at most 300 ms; one client and one vendor subscription per shared query after five edits. | #5 and #6 | yes | validated |115-| DX-AUTHOR-01 | Product edits avoid framework plumbing. | The integrated task changes only schema, app config, page/island, style, or test files. | integrated prototype | yes | validated |116-| DX-LEAK-01 | Runtime machinery stays outside product UI. | No provider, raw client, worker, transport URL, Workbox config, or browser branch in product UI. | #5, #6, integration | yes | validated |117-| DX-LOCAL-01 | Local work does not await network/auth round trips. | Subscribed UI reflects an offline write in the same event turn or next render; reload retains it. | #7 | yes | validated |118-| DX-DUR-01 | Durable mode never silently becomes ephemeral. | Boot reports durable, unsupported, or explicitly opted-in memory mode. | #4 and #7 | yes | validated |119-| DX-SYNC-01 | Application-data transport has one lofi-owned surface. | App config selects a named adapter; product UI never constructs peers or transports. | #7 and integration | yes | validated |120-| DX-OBS-01 | Diagnostics expose only truthful signals. | Configured state and per-write durability map to public APIs; unavailable transport detail is named. | #7 | yes | validated |121-| DX-ENV-01 | Environment handling is safe by construction. | Real env ignored; allowlisted loader; server values absent from client projection and built output. | #3, #7, final build | yes | validated |122-| DX-ERROR-01 | Failures state capability, impact, and action. | No secret values; common config recovery is one edit plus rerun. | #3, #4, #6 | yes | validated |123-| DX-AUTH-01 | Identity wording matches custody/recovery. | UI identifies the device-local key, blocks the rejected alpha passkey path, and defines any future phrase as an identity bearer secret rather than data backup. | #8 | yes | revised |124-| DX-DEVICE-01 | Device/auth tests use a stable, appropriately isolated origin. | M1 proves a stable HTTPS PWA scope for OPFS; installed-app RP-ID preservation and identity isolation remain required before any replacement passkey ceremony. | #4 and #8 | yes | revised |125-| DX-DEVICE-UX-01 | Device preview is one productized command. | Primary path prints stable URL, capability report, and remediation. | M3 | no | proposed |126-| DX-OFFLINE-01 | Installed production cold-start renders retained data offline. | Airplane-mode launch renders shell and data. | feasibility #4; full M3 | no | proposed |127-| DX-BUILD-01 | Development and production share the Deno command contract. | `deno task build` and `preview`; no undocumented Node command. | #6 | yes | validated |128-| DX-TEST-01 | Local-first tests avoid hand-timed sleeps. | Readiness-based offline/two-client primitives. | #7 and M2 Layer 3 | no | validated |107+| ID | Promise | v0 budget or condition | Evidence owner | M1 gate | Status |108+| --------------- | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------- | ------- | --------- |109+| DX-CREATE-01 | Named creation is non-interactive. | Deno is the only required global runtime; zero prompts; uncached time reported. | M2 source and release smoke | no | validated |110+| DX-CMD-01 | The public command surface is small. | create, dev, doctor, test, build, preview, plus explicit sync/schema/deploy tasks. | #2, #6, and M2 | yes | revised |111+| DX-TTFW-01 | Generated create-to-retained-write is fast. | At most three shell commands and two minutes on the recorded machine/network. | M2 source and release smoke | no | validated |112+| DX-PROTOTYPE-01 | The M1 checkout reaches a retained write quickly. | `deno task dev` to retained write within 60 seconds after cached setup. | integrated M1 prototype | yes | validated |113+| DX-START-01 | Cached development startup feels immediate. | Warm median at most 2 seconds; slowest at most 5 seconds across five samples. | #6 | yes | validated |114+| DX-HMR-01 | UI edits retain state and runtime cardinality. | Median feedback at most 300 ms; one client and one vendor subscription per shared query after five edits. | #5 and #6 | yes | validated |115+| DX-AUTHOR-01 | Product edits avoid framework plumbing. | The integrated task changes only schema, app config, page/island, style, or test files. | integrated prototype | yes | validated |116+| DX-LEAK-01 | Runtime machinery stays outside product UI. | No provider, raw client, worker, transport URL, Workbox config, or browser branch in product UI. | #5, #6, integration | yes | validated |117+| DX-LOCAL-01 | Local work does not await network/auth round trips. | Subscribed UI reflects an offline write in the same event turn or next render; reload retains it. | #7 | yes | validated |118+| DX-DUR-01 | Durable mode never silently becomes ephemeral. | Boot reports durable, unsupported, or explicitly opted-in memory mode. | #4 and #7 | yes | validated |119+| DX-SYNC-01 | Application-data transport has one lofi-owned surface. | App config selects a named adapter; product UI never constructs peers or transports. | #7 and integration | yes | validated |120+| DX-OBS-01 | Diagnostics expose only truthful signals. | Configured state and per-write durability map to public APIs; unavailable transport detail is named. | #7 | yes | validated |121+| DX-ENV-01 | Environment handling is safe by construction. | Real env ignored; allowlisted loader; server values absent from client projection and built output. | #3, #7, final build | yes | validated |122+| DX-ERROR-01 | Failures state capability, impact, and action. | No secret values; common config recovery is one edit plus rerun. | #3, #4, #6 | yes | validated |123+| DX-AUTH-01 | Identity wording matches custody/recovery. | UI distinguishes the local account, recoverable passkey, local phrase guard, and phrase bearer secret; restore confirms replacement and states provider/RP-ID limits. | #8 history and generated golden | yes | validated |124+| DX-DEVICE-01 | Device/auth tests use a stable, appropriately isolated origin. | RP-ID follows the exact stable hostname; credential retrieval succeeds there and a sibling RP is rejected without storing secret material. | #4, #8, M3, golden gate | yes | validated |125+| DX-DEVICE-UX-01 | Device preview is one productized command. | Primary path prints stable URL, capability report, and remediation. | M3 | no | validated |126+| DX-OFFLINE-01 | Installed production cold-start renders retained data offline. | Installed PWA retains a local write across reload and airplane-mode cold start. | feasibility #4 and M3 | no | validated |127+| DX-BUILD-01 | Development and production share the Deno command contract. | `deno task build` and `preview`; no undocumented Node command. | #6 | yes | validated |128+| DX-TEST-01 | Local-first tests avoid hand-timed sleeps. | Readiness-based offline/two-client primitives. | #7 and M2 Layer 3 | no | validated |129129 130130 ## Canonical command surface and output contract131131
@@ -292,9 +292,11 @@ Jazz version, Preact strategy, Astro/Deno path, durability policy, and identity
292292 runtime alternatives are removed from the final tree; their evidence remains in decisions and293293 merge-commit history.294294 295-M2 has graduated the single-package command surface, generator, development inspector, and296-readiness-first browser-testing helpers. Production PWA/device hardening remains M3 work; remaining297-runtime package seams and a second consumer remain M4 work.295+M2 graduated the single-package command surface, generator, development inspector, and296+readiness-first browser-testing helpers. M3 graduated representative mobile PWA validation, and the297+current generated-app release gate covers install/update output, package-owned runtime seams,298+recoverable account replacement, and collaboration policy checks. A second independent consumer299+remains later adoption work.298300 299301 ## M2 layered graduation300302
M docs/getting-started.md +1 -0
M package/access/errors.ts +5 -0
M package/access/identity.ts +4 -2
M package/access/mod.ts +18 -1
M package/access/operations.ts +11 -2
@@ -5,15 +5,19 @@ import { AccessError } from "./errors.ts";
55 import { decodeSharingIdentity, type SharingIdentity } from "./identity.ts";66 import { type GroupRole, groupRoleCapabilities, groupRoles } from "./schema.ts";77 8-type Identified = { id: string };9-type AccessRuntimeTable<Row, Init> = TableProxy<Row, Init> & {8+/** Minimum row shape accepted by collaboration operations. */9+export type Identified = { id: string };10+/** Jazz table shape required by the access operation helpers. */11+export type AccessRuntimeTable<Row, Init> = TableProxy<Row, Init> & {1012 where(input: unknown): QueryBuilder<Row>;1113 };14+/** Conventional direct-share grant row. */1215 export type SharedGrantRow = Identified & {1316 resourceId: string;1417 user_id: string;1518 can_edit: boolean;1619 };20+/** Conventional fixed-role group membership row. */1721 export type GroupMembershipRow = Identified & {1822 groupId: string;1923 user_id: string;
@@ -56,8 +60,10 @@ function assertRole(role: string): asserts role is GroupRole {
5660 }5761 }5862 63+/** Access level assigned by a direct share. */5964 export type ShareLevel = "read" | "edit";6065 66+/** Direct-share operations bound to one resource and grant table pair. */6167 export type SharingOperations<Resource, Grant> = {6268 share(resourceId: string, recipient: SharingIdentity | string, level: ShareLevel): Promise<Grant>;6369 revoke(resourceId: string, recipient: SharingIdentity | string): Promise<void>;
@@ -65,6 +71,7 @@ export type SharingOperations<Resource, Grant> = {
6571 sharedWithMe(): Promise<Resource[]>;6672 };6773 74+/** Creates direct-share operations that wait for local and global durability. */6875 export function createSharingOperations<6976 Resource extends Identified,7077 ResourceInit,
@@ -135,6 +142,7 @@ export function createSharingOperations<
135142 };136143 }137144 145+/** Creates fixed-role group membership operations for a declared table pair. */138146 export function createGroupOperations<139147 Group extends Identified,140148 GroupInit,
@@ -242,6 +250,7 @@ export function createGroupOperations<
242250 };243251 }244252 253+/** Fixed-role group creation and membership operations. */245254 export type GroupOperations<Group, GroupInit, Member> = {246255 createGroup(values: GroupInit): Promise<{ group: Group; membership: Member }>;247256 addMember(
M package/access/policies.ts +16 -4
@@ -2,17 +2,21 @@ import { schema as s } from "jazz-tools";
22 import type { CompiledPermissions } from "jazz-tools";33 import { AccessError } from "./errors.ts";44 5-type AccessTable = {5+/** Minimum declared Jazz table metadata consumed by access templates. */6+export type AccessTable = {67 readonly _table: string;78 readonly _schema: Record<string, { columns?: unknown[] }>;89 };910 11+/** Owner-only resource policy template. */1012 export type PrivateAccessTemplate = { readonly kind: "private"; readonly resource: AccessTable };13+/** Direct-share resource and grant-table policy template. */1114 export type SharedAccessTemplate = {1215 readonly kind: "shared";1316 readonly resource: AccessTable;1417 readonly grants: AccessTable;1518 };19+/** Fixed-role group, membership, and resource policy template. */1620 export type GroupAccessTemplate = {1721 readonly kind: "group";1822 readonly groups: AccessTable;
@@ -20,12 +24,15 @@ export type GroupAccessTemplate = {
2024 readonly resources: readonly AccessTable[];2125 readonly groupId: string;2226 };27+/** Any built-in access policy template accepted by {@link defineAccessPolicies}. */2328 export type AccessTemplate = PrivateAccessTemplate | SharedAccessTemplate | GroupAccessTemplate;2429 30+/** Declares owner-only read and mutation policy for one resource table. */2531 export function privateAccess(config: { resource: AccessTable }): PrivateAccessTemplate {2632 return { kind: "private", ...config };2733 }2834 35+/** Declares owner plus explicit read/edit grants for one resource table. */2936 export function sharedAccess(config: {3037 resource: AccessTable;3138 grants: AccessTable;
@@ -33,6 +40,7 @@ export function sharedAccess(config: {
3340 return { kind: "shared", ...config };3441 }3542 43+/** Declares fixed-role membership policy for one or more group-owned resources. */3644 export function groupAccess(config: {3745 groups: AccessTable;3846 members: AccessTable;
@@ -95,18 +103,21 @@ function validateTemplate(template: AccessTemplate): void {
95103 }96104 }97105 98-type RuleBuilder = {106+/** Minimal Jazz rule builder exposed to raw access-policy extensions. */107+export type RuleBuilder = {99108 where(input: unknown): unknown;100109 always(): unknown;101110 };102-type TablePolicy = {111+/** Read and mutation policy builders for one declared table. */112+export type TablePolicy = {103113 allowRead: RuleBuilder;104114 allowInsert: RuleBuilder;105115 allowUpdate: RuleBuilder;106116 allowDelete: RuleBuilder;107117 exists: { where(input: unknown): unknown };108118 };109-type RawAccessPolicyContext = {119+/** Raw Jazz policy-builder context exposed to advanced policy extensions. */120+export type RawAccessPolicyContext = {110121 policy: Record<string, TablePolicy>;111122 session: { user_id: unknown };112123 anyOf(conditions: readonly unknown[]): unknown;
@@ -116,6 +127,7 @@ type RawAccessPolicyContext = {
116127 };117128 };118129 130+/** Callback for app-specific rules that do not fit the built-in templates. */119131 export type RawAccessPolicyExtension = (context: RawAccessPolicyContext) => void;120132 121133 /**
M package/access/schema.ts +2 -0
M package/astro/mod.ts +10 -0
M package/create_core.ts +8 -0
M package/create_core_test.ts +4 -4
M package/preact/DeviceStatus.tsx +3 -2
M package/preact/PwaActions.tsx +7 -2
M package/preact/mod.ts +22 -3
M package/preact/use-device-capabilities.ts +3 -0
M package/runtime/auth.ts +3 -0
M package/runtime/device-capabilities.ts +15 -6
@@ -1,3 +1,4 @@
1+/** Browser capabilities that determine whether lofi can provide its runtime guarantees. */12 export type DeviceCapabilityReport = {23 // Package-owned device capability checks.34 secureContext: boolean;
@@ -12,6 +13,9 @@ export type DeviceCapabilityReport = {
1213 displayMode: "standalone" | "browser";1314 };1415 16+/** Capability report that excludes the separately requested persistence permission. */17+export type DurableCapabilityReport = Omit<DeviceCapabilityReport, "persistentPermission">;18+1519 type StorageManagerWithOpfs = StorageManager & {1620 getDirectory?: () => Promise<FileSystemDirectoryHandle>;1721 };
@@ -40,10 +44,8 @@ async function readPrfCapability(
4044 }4145 }4246 43-export function durableCapabilityReport(): Omit<44- DeviceCapabilityReport,45- "persistentPermission"46-> {47+/** Reads synchronous browser capabilities needed by the durable Jazz driver. */48+export function durableCapabilityReport(): DurableCapabilityReport {4749 const storage = browserStorage();4850 const locks = typeof navigator === "undefined" ? undefined : navigator.locks;4951 const secureContext = globalThis.isSecureContext === true;
@@ -67,6 +69,7 @@ export function durableCapabilityReport(): Omit<
6769 };6870 }6971 72+/** Reads the complete capability report without requesting new browser permission. */7073 export async function readDeviceCapabilityReport(): Promise<DeviceCapabilityReport> {7174 const base = durableCapabilityReport();7275 const storage = browserStorage();
@@ -85,6 +88,7 @@ export async function readDeviceCapabilityReport(): Promise<DeviceCapabilityRepo
8588 }8689 }8790 91+/** Requests eviction protection, then returns the browser's authoritative capability report. */8892 export async function requestPersistentStorage(): Promise<DeviceCapabilityReport> {8993 const storage = browserStorage();9094 if (typeof storage?.persist !== "function") return await readDeviceCapabilityReport();
@@ -96,11 +100,15 @@ export async function requestPersistentStorage(): Promise<DeviceCapabilityReport
96100 return await readDeviceCapabilityReport();97101 }98102 103+/** Raised when the browser cannot provide the durable local-storage contract. */99104 export class DurableStorageUnsupportedError extends Error {105+ /** Stable error class name for diagnostics and error boundaries. */100106 override name = "DurableStorageUnsupportedError";101- readonly report: ReturnType<typeof durableCapabilityReport>;107+ /** Capabilities observed when the runtime rejected startup. */108+ readonly report: DurableCapabilityReport;102109 103- constructor(report: ReturnType<typeof durableCapabilityReport> = durableCapabilityReport()) {110+ /** Creates an error that identifies every missing durable-driver capability. */111+ constructor(report: DurableCapabilityReport = durableCapabilityReport()) {104112 const missing = Object.entries({105113 secureContext: report.secureContext,106114 opfs: report.opfs,
@@ -117,6 +125,7 @@ export class DurableStorageUnsupportedError extends Error {
117125 }118126 }119127 128+/** Throws unless the current browser can open lofi's persistent local driver. */120129 export function assertDurableBrowser(): void {121130 const report = durableCapabilityReport();122131 if (!report.durableDriverSupported) throw new DurableStorageUnsupportedError(report);
M package/runtime/mod.ts +18 -1
@@ -1,15 +1,21 @@
11 /**2- * The supported `@nzip/lofi` browser/runtime surface.2+ * Build local-first browser applications with durable storage, reactive tables,3+ * optional Jazz sync, recoverable accounts, and an installable PWA lifecycle.34 *45 * Application code composes these APIs; storage, identity, sync, PWA, and56 * lifecycle implementations remain versioned package code.67 *8+ * Start with {@link defineLofiApp} and {@link bootLofi}. Use9+ * {@link LofiRuntime.store} to bind declared Jazz tables to application UI, and10+ * add account backup only after managed sync is configured.11+ *712 * @module813 */914 1015 export { defineLofiApp, type LofiAppConfig } from "./app.ts";1116 export {1217 type AuthCapability,18+ type AuthDependencies,1319 authenticateDeviceCredential,1420 AuthError,1521 classifyCredentialOrigin,
@@ -17,14 +23,19 @@ export {
1723 decryptAtRest,1824 deriveAtRestKey,1925 derivePrfSecret,26+ type DeviceCredential,2027 encryptAtRest,2128 enrollDeviceCredential,29+ type EnrollOptions,2230 getAuthCapability,31+ type PrfSupport,2332 } from "./auth.ts";2433 export { bootLofi } from "./boot.ts";2534 export {2635 assertDurableBrowser,2736 type DeviceCapabilityReport,37+ type DurableCapabilityReport,38+ durableCapabilityReport,2839 DurableStorageUnsupportedError,2940 readDeviceCapabilityReport,3041 requestPersistentStorage,
@@ -33,9 +44,11 @@ export {
3344 applyPwaUpdate,3445 createPwaController,3546 getPwaState,47+ type InstallEnvironment,3648 type InstallPromptEvent,3749 type PwaController,3850 pwaController,51+ type PwaControllerDependencies,3952 type PwaFailureCode,4053 pwaFailureMessage,4154 type PwaInstallState,
@@ -44,6 +57,7 @@ export {
4457 requestPwaInstall,4558 subscribePwaState,4659 } from "./pwa.ts";60+export { type RuntimeDiagnostics } from "./diagnostics.ts";4761 export {4862 getRuntime,4963 getRuntimeDiagnostics,
@@ -56,6 +70,7 @@ export {
5670 subscribeRuntimeDiagnostics,5771 } from "./runtime.ts";5872 export {73+ AccountReplacementError,5974 type AccountReplacementOptions,6075 confirmPhraseAccess,6176 createBackupPasskey,
@@ -74,11 +89,13 @@ export {
7489 type Session,7590 stopSyncBackup,7691 } from "./session.ts";92+export { RecoveryError } from "./recovery.ts";7793 export { RecoverablePasskeyError, type RecoverablePasskeyErrorCode } from "./passkey-recovery.ts";7894 export {7995 type TableHandle,8096 type TableRow,8197 type TableSnapshot,8298 type TableStore,99+ type TableStoreOptions,83100 } from "./table-store.ts";84101 export { settleUiMutation } from "./ui-mutation.ts";
M package/runtime/passkey-recovery.ts +4 -0
M package/runtime/pwa.ts +19 -4
@@ -1,5 +1,6 @@
11 /// <reference path="./env.d.ts" />22 // Package-owned PWA lifecycle.3+/** Service-worker lifecycle states exposed to application UI. */34 export type PwaWorkerState =45 | "development-disabled"56 | "unsupported"
@@ -8,6 +9,7 @@ export type PwaWorkerState =
89 | "update-available"910 | "failed";1011 12+/** Browser installation states exposed to application UI. */1113 export type PwaInstallState =1214 | "installed"1315 | "available"
@@ -17,8 +19,10 @@ export type PwaInstallState =
1719 | "manual-ios"1820 | "unavailable";1921 22+/** Stable categories for recoverable offline/PWA failures. */2023 export type PwaFailureCode = "registration" | "installation" | "precache" | "runtime-cache";2124 25+/** Current install, service-worker, and offline-cache state. */2226 export type PwaState = {2327 worker: PwaWorkerState;2428 install: PwaInstallState;
@@ -28,11 +32,13 @@ export type PwaState = {
2832 };2933 };3034 35+/** Chromium install event retained until application UI requests the prompt. */3136 export type InstallPromptEvent = Event & {3237 prompt(): Promise<void>;3338 userChoice: Promise<{ outcome: "accepted" | "dismissed" }>;3439 };3540 41+/** Browser signals used to classify installed and manual-install experiences. */3642 export type InstallEnvironment = {3743 displayModeStandalone: boolean;3844 navigatorStandalone: boolean;
@@ -53,11 +59,13 @@ const actionableFailureMessages: Record<PwaFailureCode, string> = {
5359 "A recently opened file was not saved for offline use. Reconnect and open it again before going offline.",5460 };5561 62+/** Returns actionable, non-technical recovery guidance for a PWA failure. */5663 export function pwaFailureMessage(code: PwaFailureCode): string {5764 return actionableFailureMessages[code];5865 }5966 60-export interface PwaControllerDependencies {67+/** Injectable browser surfaces used to test the PWA lifecycle deterministically. */68+export type PwaControllerDependencies = {6169 readonly eventTarget?: () => EventTarget;6270 readonly serviceWorker?: () => ServiceWorkerContainer | undefined;6371 readonly installEnvironment?: () => InstallEnvironment;
@@ -65,15 +73,16 @@ export interface PwaControllerDependencies {
6573 readonly documentBaseURI?: () => string;6674 readonly reload?: () => void;6775 readonly exposeState?: (state: PwaState) => void;68-}76+};6977 70-export interface PwaController {78+/** Stateful controller for browser installation and service-worker updates. */79+export type PwaController = {7180 getState(): PwaState;7281 subscribe(subscriber: (state: PwaState) => void): () => void;7382 requestInstall(): Promise<PwaInstallState>;7483 applyUpdate(): boolean;7584 initialize(): void;76-}85+};7786 7887 export function classifyInstallExperience(environment: InstallEnvironment): PwaInstallState {7988 if (environment.displayModeStandalone || environment.navigatorStandalone) return "installed";
@@ -101,6 +110,7 @@ export function waitForActivation(worker: ServiceWorker): Promise<void> {
101110 });102111 }103112 113+/** Creates an isolated PWA controller, primarily for custom integration and tests. */104114 export function createPwaController(dependencies: PwaControllerDependencies = {}): PwaController {105115 const subscribers = new Set<(state: PwaState) => void>();106116 let deferredInstallPrompt: InstallPromptEvent | undefined;
@@ -247,24 +257,29 @@ export function createPwaController(dependencies: PwaControllerDependencies = {}
247257 };248258 }249259 260+/** Shared controller used by the root runtime and optional Preact bindings. */250261 export const pwaController: PwaController = createPwaController({251262 exposeState(next) {252263 if (typeof document !== "undefined") (globalThis as LofiPwaGlobal).__LOFI_PWA_STATE__ = next;253264 },254265 });255266 267+/** Returns the shared controller's current state snapshot. */256268 export function getPwaState(): PwaState {257269 return pwaController.getState();258270 }259271 272+/** Subscribes to shared PWA state and returns an idempotent unsubscribe function. */260273 export function subscribePwaState(subscriber: (state: PwaState) => void): () => void {261274 return pwaController.subscribe(subscriber);262275 }263276 277+/** Requests the deferred browser installation prompt when one is available. */264278 export function requestPwaInstall(): Promise<PwaInstallState> {265279 return pwaController.requestInstall();266280 }267281 282+/** Activates a waiting service worker; returns false when no update is ready. */268283 export function applyPwaUpdate(): boolean {269284 return pwaController.applyUpdate();270285 }
M package/runtime/recovery.ts +3 -0
M package/runtime/runtime.ts +7 -0
@@ -21,6 +21,7 @@ import {
2121 shutdownResource,2222 } from "./resource-lifecycle.ts";2323 24+/** The shared, lazily opened Jazz client and its application-facing adapters. */2425 export type LofiRuntime = {2526 db: Db;2627 diagnostics: RuntimeDiagnostics;
@@ -32,6 +33,7 @@ export type LofiRuntime = {
3233 shutdown(): Promise<void>;3334 };3435 36+/** Event dispatched after account, sync, or runtime replacement changes active state. */3537 export const runtimeRecreatedEvent = "lofi:runtime-recreated";3638 3739 type RuntimeSlot = {
@@ -199,6 +201,7 @@ function attachRuntime(state: RuntimeSlot, db: Db): LofiRuntime {
199201 return runtime;200202 }201203 204+/** Opens or reuses the one package runtime for the current browser document. */202205 export function getRuntime(): Promise<LofiRuntime> {203206 if (recreationPromise) return recreationPromise;204207 const state = slot();
@@ -208,6 +211,7 @@ export function getRuntime(): Promise<LofiRuntime> {
208211 );209212 }210213 214+/** Returns a value-only snapshot of runtime resource and durability counters. */211215 export function getRuntimeDiagnostics(): RuntimeDiagnostics {212216 return { ...slot().diagnostics };213217 }
@@ -222,6 +226,7 @@ export function runtimeCanReconnect(): boolean {
222226 return slot().serverConfigured;223227 }224228 229+/** Subscribes to diagnostics changes and returns an idempotent unsubscribe function. */225230 export function subscribeRuntimeDiagnostics(listener: () => void): () => void {226231 const listeners = slot().diagnosticListeners;227232 listeners.add(listener);
@@ -233,6 +238,7 @@ export function subscribeRuntimeDiagnostics(listener: () => void): () => void {
233238 };234239 }235240 241+/** Replaces the active Jazz client while preserving the configured account secret. */236242 export function recreateRuntime(): Promise<LofiRuntime> {237243 if (recreationPromise) return recreationPromise;238244 const state = slot();
@@ -294,6 +300,7 @@ export function replaceRuntimePrincipal(secret: string): Promise<LofiRuntime> {
294300 return tracked;295301 }296302 303+/** Releases stores, subscriptions, the Jazz client, and persistent worker resources. */297304 export function shutdownRuntime(): Promise<void> {298305 if (shutdownPromise) return shutdownPromise;299306 const state = slot();
M package/runtime/session.ts +13 -10
@@ -154,6 +154,7 @@ export async function createBackupPasskey(label?: string): Promise<boolean> {
154154 }155155 }156156 157+/** Non-secret confirmation that a recoverable passkey was created for an account and RP-ID. */157158 export type PasskeyBackupReceipt = { user_id: string; rpId: string };158159 159160 /**
@@ -240,24 +241,19 @@ export async function stopSyncBackup(): Promise<Session> {
240241 return readSession();241242 }242243 243-/**244- * Recovers an account from its recovery phrase: the phrase reconstructs the245- * exact account secret, which replaces the one on this device. Sync is elected246- * so the recovered account's data syncs back down, and the runtime is recreated247- * to open it. Throws {@link RecoveryError} on a malformed phrase — the account is248- * never replaced with a fabricated secret.249- *250- * This overwrites whatever account this device currently holds, so a caller251- * should confirm the intent before discarding a local-only account's data.252- */244+/** Confirmation required before recovery may replace a different local-only account. */253245 export type AccountReplacementOptions = {254246 /** Explicit acknowledgement that a different local-only account may be discarded. */255247 confirmLocalReplacement?: boolean;256248 };257249 250+/** Raised when account recovery needs explicit acknowledgement of local replacement. */258251 export class AccountReplacementError extends Error {252+ /** Stable error class name for diagnostics and error boundaries. */259253 override readonly name = "AccountReplacementError";254+ /** Stable category for user-facing confirmation flows. */260255 readonly code = "confirmation-required";256+ /** Creates the non-secret replacement warning. */261257 constructor() {262258 super(263259 "Restoring would replace this device's local-only account. Confirm replacement only after saving any data that has not synced.",
@@ -284,6 +280,11 @@ async function replaceAccountSecret(
284280 return readSession();285281 }286282 283+/**284+ * Reconstructs an account from its recovery phrase, elects sync, and replaces285+ * the active runtime. Throws {@link RecoveryError} for malformed phrases and286+ * {@link AccountReplacementError} when confirmation is required.287+ */287288 export async function restoreFromRecoveryPhrase(288289 phrase: string,289290 options: AccountReplacementOptions = {},
@@ -310,10 +311,12 @@ export function isAuthError(error: unknown): error is AuthError {
310311 return error instanceof AuthError;311312 }312313 314+/** True when an error came from a recoverable passkey backup or restore ceremony. */313315 export function isRecoverablePasskeyError(error: unknown): error is RecoverablePasskeyError {314316 return error instanceof RecoverablePasskeyError;315317 }316318 319+/** True when recovery requires explicit confirmation before replacing a local account. */317320 export function isAccountReplacementError(error: unknown): error is AccountReplacementError {318321 return error instanceof AccountReplacementError;319322 }
M package/runtime/table-store.ts +10 -0
@@ -12,6 +12,7 @@ export type TableRow = { id: string };
1212 */1313 export type TableHandle<T extends TableRow, Init> = TableProxy<T, Init> & QueryBuilder<T>;1414 15+/** Reactive table state including rows and the last observed durability tier. */1516 export type TableSnapshot<T extends TableRow> = {1617 status: "loading" | "ready" | "error";1718 rows: T[];
@@ -24,6 +25,7 @@ type MutationHandle = {
2425 wait(options: { tier: "local" | "global" }): Promise<unknown>;2526 };2627 28+/** Runtime options controlling durability waits and diagnostics notifications. */2729 export type TableStoreOptions = {2830 /** Whether managed sync is configured; gates global-durability waits. */2931 syncConfigured?: boolean;
@@ -53,6 +55,7 @@ export class TableStore<T extends TableRow, Init> {
5355 error: null,5456 };5557 58+ /** Creates a store over one declared Jazz table and shared runtime diagnostics. */5659 constructor(5760 db: Db,5861 table: TableHandle<T, Init>,
@@ -72,8 +75,10 @@ export class TableStore<T extends TableRow, Init> {
7275 this.#diagnosticsChanged();7376 }7477 78+ /** Returns the current immutable snapshot. */7579 getSnapshot = (): TableSnapshot<T> => this.#snapshot;7680 81+ /** Subscribes to snapshot changes and opens the vendor subscription on first use. */7782 subscribe = (listener: Listener): () => void => {7883 this.#listeners.add(listener);7984 this.#diagnostics.activeConsumers = this.#listeners.size;
@@ -92,24 +97,29 @@ export class TableStore<T extends TableRow, Init> {
9297 };9398 };9499 100+ /** Inserts a row and waits for local durability before resolving. */95101 async insert(values: Init): Promise<void> {96102 await this.#settle(this.#db.insert(this.#table, values));97103 }98104 105+ /** Updates a row and waits for local durability before resolving. */99106 async update(id: string, patch: Partial<Init>): Promise<void> {100107 await this.#settle(this.#db.update(this.#table, id, patch));101108 }102109 110+ /** Deletes a row and waits for local durability before resolving. */103111 async delete(id: string): Promise<void> {104112 await this.#settle(this.#db.delete(this.#table, id));105113 }106114 115+ /** Projects an asynchronous Jazz mutation rejection into diagnostics and store state. */107116 reportMutationError(event: MutationErrorEvent): void {108117 this.#diagnostics.mutationErrors += 1;109118 this.#diagnosticsChanged();110119 this.#fail(new Error(`${event.code}: ${event.reason}`));111120 }112121 122+ /** Releases vendor subscriptions and mutation listeners owned by this store. */113123 close(): void {114124 this.#listeners.clear();115125 this.#diagnostics.activeConsumers = 0;
M package/runtime/ui-mutation.ts +1 -0
M package/starter_template.ts +2 -2
M package/testdata/starter.snapshot.json +2 -2
M package/testing/webauthn.ts +7 -0
M package/version.ts +1 -1
A tools/doc_lint.ts +50 -0
@@ -0,0 +1,50 @@
1+const entrypoints = [2+ "package/runtime/mod.ts",3+ "package/astro/mod.ts",4+ "package/access/mod.ts",5+ "package/commands/build.ts",6+ "package/create.ts",7+ "package/commands/dev.ts",8+ "package/commands/doctor.ts",9+ "package/commands/preview.ts",10+ "package/preact/mod.ts",11+ "package/commands/provision.ts",12+ "package/testing/mod.ts",13+ "package/commands/run_tests.ts",14+] as const;15+16+const command = new Deno.Command(Deno.execPath(), {17+ args: ["doc", "--lint", ...entrypoints],18+ stdout: "piped",19+ stderr: "piped",20+});21+const result = await command.output();22+const decoder = new TextDecoder();23+const diagnostics = `${decoder.decode(result.stdout)}\n${decoder.decode(result.stderr)}`;24+const root = `${Deno.cwd()}/`;25+26+const blocks = diagnostics.split(/(?=^error\[)/m).filter((block) => block.startsWith("error["));27+const failures = blocks.filter((block) => {28+ const code = block.match(/^error\[([^\]]+)\]/)?.[1];29+ const locations = [...block.matchAll(/^\s*-->\s+([^:]+):\d+:\d+$/gm)].map((match) => match[1]);30+ if (code === "missing-jsdoc") return locations[0]?.startsWith(`${root}package/`) ?? true;31+ if (code === "private-type-ref") {32+ // `deno doc` cannot currently redirect npm dependency symbols, so a public33+ // Playwright, Preact, or Jazz type is reported as private even though34+ // `deno publish` resolves and fast-checks it. Keep local-to-local leaks fatal.35+ return locations.slice(1).some((path) => path.startsWith(`${root}package/`));36+ }37+ return true;38+});39+40+if (failures.length > 0) {41+ console.error(failures.join("\n"));42+ console.error(`Documentation lint failed with ${failures.length} local diagnostic(s).`);43+ Deno.exit(1);44+}45+46+const ignored = blocks.length - failures.length;47+console.log(48+ `Documentation lint passed for ${entrypoints.length} JSR entrypoints` +49+ (ignored > 0 ? ` (${ignored} external type-reference diagnostic(s) ignored).` : "."),50+);
A tools/docs_links.ts +44 -0
@@ -0,0 +1,44 @@
1+import { dirname, resolve } from "node:path";2+import { fileURLToPath } from "node:url";3+4+const root = fileURLToPath(new URL("../", import.meta.url));5+const markdownFiles = [6+ resolve(root, "README.md"),7+ resolve(root, "CONTRIBUTING.md"),8+ resolve(root, "apps/reference/README.md"),9+];10+11+async function collectMarkdown(directory: string): Promise<void> {12+ for await (const entry of Deno.readDir(directory)) {13+ const path = resolve(directory, entry.name);14+ if (entry.isDirectory) await collectMarkdown(path);15+ else if (entry.isFile && entry.name.endsWith(".md")) markdownFiles.push(path);16+ }17+}18+19+await collectMarkdown(resolve(root, "docs"));20+21+const broken: string[] = [];22+const linkPattern = /!?\[[^\]]*\]\((<[^>]+>|[^\s)]+)(?:\s+[^)]*)?\)/g;23+for (const markdown of markdownFiles) {24+ const source = await Deno.readTextFile(markdown);25+ for (const match of source.matchAll(linkPattern)) {26+ const raw = match[1].replace(/^<|>$/g, "");27+ if (raw.startsWith("#") || /^[a-z][a-z0-9+.-]*:/i.test(raw)) continue;28+ const target = decodeURIComponent(raw.split("#", 1)[0]);29+ if (!target) continue;30+ try {31+ await Deno.stat(resolve(dirname(markdown), target));32+ } catch (error) {33+ if (!(error instanceof Deno.errors.NotFound)) throw error;34+ broken.push(`${markdown.slice(root.length + 1)} -> ${raw}`);35+ }36+ }37+}38+39+if (broken.length > 0) {40+ console.error(`Broken local Markdown links:\n${broken.map((link) => `- ${link}`).join("\n")}`);41+ Deno.exit(1);42+}43+44+console.log(`Validated local links in ${markdownFiles.length} Markdown files.`);