From 0c80a8098073f53a28ab63026f04610eb800502a Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Sun, 6 Sep 2026 20:27:52 +0800 Subject: [PATCH 01/16] =?UTF-8?q?feat(web):=20=E5=AE=8C=E6=88=90=20Registr?= =?UTF-8?q?y=20=E5=8F=91=E7=8E=B0=E9=A1=B5=E9=9D=A2=E4=B8=8E=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E8=80=85=E5=B7=A5=E4=BD=9C=E5=8F=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 5 + .github/workflows/packages-release.yml | 10 + .github/workflows/production.yml | 5 + .npmrc | 1 + .prettierignore | 2 + contracts/openapi.json | 173 +++ docs/30-unit-tdd/README.md | 2 + docs/30-unit-tdd/registry-web.md | 69 + docs/40-deployment/local-development.md | 25 +- package.json | 8 +- pdm.lock | 10 +- pnpm-lock.yaml | 217 +++ pnpm-workspace.yaml | 1 + pyproject.toml | 1 + runtimes/client-web/src/generated/index.ts | 8 + runtimes/client-web/src/generated/sdk.gen.ts | 19 + .../client-web/src/generated/types.gen.ts | 92 ++ runtimes/client-web/src/generated/zod.gen.ts | 51 + .../generated/registry.py | 50 +- scripts/check-web-styles.mjs | 11 + .../contracts/models.py | 12 + src/inkcre_extension_registry/service/app.py | 72 +- .../service/repository.py | 43 + .../service/static/registry.css | 1 + .../service/static/registry.js | 398 ++++++ .../service/templates/base.html | 30 + .../service/templates/catalog.html | 23 + .../service/templates/detail.html | 17 + .../service/templates/error.html | 2 + .../service/templates/publish.html | 11 + src/inkcre_extension_registry/service/ui.py | 167 ++- tasks/registry-web-lovable/packet.md | 26 + web/registry.scss | 1193 +++++++++++++++++ wrangler.jsonc | 7 + 34 files changed, 2642 insertions(+), 120 deletions(-) create mode 100644 .npmrc create mode 100644 docs/30-unit-tdd/registry-web.md create mode 100644 scripts/check-web-styles.mjs create mode 100644 src/inkcre_extension_registry/service/static/registry.css create mode 100644 src/inkcre_extension_registry/service/static/registry.js create mode 100644 src/inkcre_extension_registry/service/templates/base.html create mode 100644 src/inkcre_extension_registry/service/templates/catalog.html create mode 100644 src/inkcre_extension_registry/service/templates/detail.html create mode 100644 src/inkcre_extension_registry/service/templates/error.html create mode 100644 src/inkcre_extension_registry/service/templates/publish.html create mode 100644 tasks/registry-web-lovable/packet.md create mode 100644 web/registry.scss diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3efa097..8e078fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,7 @@ on: permissions: contents: read + packages: read concurrency: group: registry-checks-${{ github.workflow }}-${{ github.ref }} @@ -52,8 +53,12 @@ jobs: with: node-version-file: package.json cache: pnpm + registry-url: https://npm.pkg.github.com + scope: '@inkcre' - name: Install frozen dependencies + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | pdm install --frozen-lockfile pnpm install --frozen-lockfile diff --git a/.github/workflows/packages-release.yml b/.github/workflows/packages-release.yml index ebcacbb..6f61cd2 100644 --- a/.github/workflows/packages-release.yml +++ b/.github/workflows/packages-release.yml @@ -17,6 +17,7 @@ jobs: name: Reconcile Web Runtime release plan permissions: contents: write + packages: read pull-requests: write runs-on: ubuntu-latest steps: @@ -35,8 +36,12 @@ jobs: with: node-version-file: package.json cache: pnpm + registry-url: https://npm.pkg.github.com + scope: '@inkcre' - name: Install the frozen JavaScript workspace + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: pnpm install --frozen-lockfile - name: Create or update the package Version PR @@ -56,6 +61,7 @@ jobs: if: "${{ startsWith(github.event.head_commit.message, 'chore(release): version Extension packages') }}" permissions: contents: write + packages: read runs-on: ubuntu-latest timeout-minutes: 20 environment: @@ -95,8 +101,12 @@ jobs: with: node-version-file: package.json cache: pnpm + registry-url: https://npm.pkg.github.com + scope: '@inkcre' - name: Install frozen dependencies + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | pdm install --frozen-lockfile pnpm install --frozen-lockfile diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 34033e7..0351eb8 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -18,6 +18,7 @@ on: permissions: contents: read + packages: read concurrency: group: registry-production @@ -66,8 +67,12 @@ jobs: with: node-version-file: package.json cache: pnpm + registry-url: https://npm.pkg.github.com + scope: '@inkcre' - name: Install frozen delivery dependencies + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | pdm install --frozen-lockfile pnpm install --frozen-lockfile diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..77862ad --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +@inkcre:registry=https://npm.pkg.github.com diff --git a/.prettierignore b/.prettierignore index 9ed9c8e..765a0aa 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,3 +9,5 @@ dist/ node_modules/ pnpm-lock.yaml tasks/ +src/inkcre_extension_registry/service/templates/ +src/inkcre_extension_registry/service/static/registry.css diff --git a/contracts/openapi.json b/contracts/openapi.json index dcd399c..b2d519c 100644 --- a/contracts/openapi.json +++ b/contracts/openapi.json @@ -184,6 +184,121 @@ "title": "PrepareReleaseRequest", "type": "object" }, + "PublisherRelease": { + "additionalProperties": false, + "properties": { + "module_federation": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModuleFederationDistribution" + }, + { + "type": "null" + } + ] + }, + "name": { + "maxLength": 129, + "minLength": 3, + "pattern": "^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?/[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$", + "title": "Name", + "type": "string" + }, + "nickname": { + "title": "Nickname", + "type": "string" + }, + "python": { + "anyOf": [ + { + "$ref": "#/components/schemas/PythonDistribution" + }, + { + "type": "null" + } + ] + }, + "python_uploaded": { + "title": "Python Uploaded", + "type": "boolean" + }, + "state": { + "enum": [ + "preparing", + "published", + "yanked", + "blocked" + ], + "title": "State", + "type": "string" + }, + "version": { + "maxLength": 128, + "minLength": 5, + "pattern": "^(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)\\.(?:0|[1-9][0-9]*)(?:-(?:(?:0|[1-9][0-9]*)|(?:[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))(?:\\.(?:(?:0|[1-9][0-9]*)|(?:[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)))*)?$", + "title": "Version", + "type": "string" + }, + "web_uploaded": { + "title": "Web Uploaded", + "type": "boolean" + } + }, + "required": [ + "name", + "nickname", + "version", + "state", + "python_uploaded", + "web_uploaded" + ], + "title": "PublisherRelease", + "type": "object" + }, + "PublisherWorkspace": { + "additionalProperties": false, + "properties": { + "namespace": { + "maxLength": 64, + "minLength": 1, + "pattern": "^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$", + "title": "Namespace", + "type": "string" + }, + "next_offset": { + "anyOf": [ + { + "minimum": 0.0, + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Next Offset" + }, + "offset": { + "minimum": 0.0, + "title": "Offset", + "type": "integer" + }, + "releases": { + "items": { + "$ref": "#/components/schemas/PublisherRelease" + }, + "title": "Releases", + "type": "array" + } + }, + "required": [ + "namespace", + "releases", + "offset", + "next_offset" + ], + "title": "PublisherWorkspace", + "type": "object" + }, "PythonAssociationInput": { "additionalProperties": false, "properties": { @@ -1250,6 +1365,64 @@ }, "summary": "Yank Release" } + }, + "/v1/publisher": { + "get": { + "operationId": "publisher_workspace_v1_publisher_get", + "parameters": [ + { + "in": "query", + "name": "offset", + "required": false, + "schema": { + "default": 0, + "maximum": 100000, + "minimum": 0, + "title": "Offset", + "type": "integer" + } + }, + { + "in": "header", + "name": "authorization", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Authorization" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublisherWorkspace" + } + } + }, + "description": "Successful Response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + }, + "description": "Validation Error" + } + }, + "summary": "Publisher Workspace" + } } } } diff --git a/docs/30-unit-tdd/README.md b/docs/30-unit-tdd/README.md index 702bcbd..2a9bed9 100644 --- a/docs/30-unit-tdd/README.md +++ b/docs/30-unit-tdd/README.md @@ -10,3 +10,5 @@ generated contracts and build checks remain executable authority. Shared product behavior and cross-unit Host contracts remain in the InKCre documentation Hub rather than being copied here. + +- [Registry Web](registry-web.md): discovery, publisher workspace, and design consumption. diff --git a/docs/30-unit-tdd/registry-web.md b/docs/30-unit-tdd/registry-web.md new file mode 100644 index 0000000..d36ab47 --- /dev/null +++ b/docs/30-unit-tdd/registry-web.md @@ -0,0 +1,69 @@ +# Registry Web + +Registry Web is a presentation surface of the Python Worker. FastAPI reads the +existing Registry repository and renders autoescaped Jinja templates. Small +browser enhancements cover theme selection, copying an Extension ID, and the +publisher workspace. There is no separately deployed frontend or browser +framework runtime. + +## Public discovery + +`/` lists published Extensions with server-rendered name search and publisher +filtering. Search is a case-insensitive substring of the canonical name or +nickname; filtering combines with search. An empty Registry and empty search +results have different recovery actions. The page remains useful without JavaScript. + +`/explore/{namespace}/{name}` displays published releases and their admitted +native distribution descriptors. Versions are sorted by SemVer; the default +selection is the highest stable version, or the highest pre-release if there +is no stable release. The optional `version` query selects an exact published +version. Missing or withdrawn versions return a human-readable 404. Existing +`/v1`, `/simple`, `/packages`, and Module Federation asset paths retain their +machine-facing semantics. + +The UI does not invent descriptions, popularity, trust badges, compatibility +verdicts, installation state, or download counts that Registry does not own. +Package inspection and copying an ID do not install or activate an Extension. + +## Publisher workspace + +`/publish` connects a namespace credential kept only in the current page's +memory. A reload, navigation away, or disconnect clears it. Credentials are +sent in Authorization headers to the same origin, never in URLs, local storage, +HTML, or logs. Only the theme preference is persisted in local storage. + +`GET /v1/publisher?offset=0` authenticates through the existing namespace +credential dependency and returns only that namespace's releases, including +preparing, withdrawn, and operator-blocked releases. Pages contain at most ten +records to bound the existing descriptor reads within the D1 query budget. +`next_offset` indicates another page; concurrent publication can shift this +non-snapshot listing, and Refresh reloads current state. Private responses, +including rejected credentials, are not cached. Per-distribution uploaded flags +come from the admitted files/manifest association, not a browser assumption. + +The Web release form calls the existing prepare endpoint, uploads a Module +Federation ZIP through the existing multipart endpoint, and asks for a separate +publication decision. Failed preparation or upload keeps the form available +for correction; published bytes and metadata remain immutable. The workspace +also withdraws and restores releases through existing lifecycle endpoints. +A failed refresh after a successful mutation is reported separately from a +failed mutation. Timeout messages tell publishers to check state before retrying. + +Python releases continue to use the Developer Toolkit/native Python upload +protocol. They appear in the same workspace for publication and lifecycle +management. Namespace and credential issuance remains an operator responsibility. + +## Design ownership + +`@inkcre/ui-web` is the existing package released by the sibling `design` +repository (`InKCre/ui`). Registry imports its public Sass styles, token maps, +and functions. Colors, typography families, and common spacing/radius vocabulary +remain design-owned; `web/registry.scss` owns Registry page composition. There +is no Registry copy of the token source or a competing design package. + +`pnpm web:build` compiles the pinned package and local Sass into the committed +`service/static/registry.css`. `pnpm web:check` compares it to a fresh compilation +and checks browser JavaScript syntax. Committing generated CSS makes Python wheel +and static-preview builds self-contained; it is never edited manually. Wrangler +Text rules explicitly include CSS and JavaScript in the Python module filesystem. +The same HTML renderer supplies the existing read-only Pages catalog preview. diff --git a/docs/40-deployment/local-development.md b/docs/40-deployment/local-development.md index 057ca99..add61b2 100644 --- a/docs/40-deployment/local-development.md +++ b/docs/40-deployment/local-development.md @@ -12,7 +12,8 @@ For the real local Worker seam, use isolated Wrangler state or a clean checkout: ```bash pnpm exec wrangler d1 migrations apply DB --local --config wrangler.jsonc -pdm run pywrangler dev --port 8791 \ +pnpm build +UV_FIND_LINKS="$PWD/dist" pdm run pywrangler dev --port 8791 \ --var PUBLIC_ORIGIN:http://127.0.0.1:8791 ``` @@ -30,3 +31,25 @@ later from the Version PR's merged current-main revision in the protected The package workflow skips an already existing package tag, so a rerun can finish a partially completed three-package release without replacing an existing Release. Registry Worker deployment remains a separate workflow. + +## Registry Web development + +Use Node 22 as declared by `package.json`; newer Node majors may not support the +Wasm stack-switching flag required by the locked Pyodide toolchain. Dependencies +include `@inkcre/ui-web@1.4.0` from GitHub Packages. Local npm authentication needs +`read:packages`; never commit a token to `.npmrc`. CI uses its workflow token with +`packages: read`, and the design package must admit this repository for Actions +reads. Browser users need no package credentials: compiled CSS ships in the Worker. + +Edit `web/registry.scss`, then run `pnpm web:build`. Generated CSS is committed and +verified by `pnpm web:check`. Templates and browser JavaScript live under +`src/inkcre_extension_registry/service/`. After changing packaged files, rebuild +with `pnpm build` and restart the local Worker using `UV_FIND_LINKS="$PWD/dist"` so +the Worker consumes the current local wheel. Do not test an older installed wheel. + +Open `/`, `/explore//`, and `/publish` on the local Worker. Apply +credentials and fixtures only to isolated local D1; use the existing native APIs +for prepare/upload/publish acceptance. Verify search, exact version selection, +empty results, namespace scoping, upload readiness, publish/withdraw/restore, +reconnection, keyboard navigation, and narrow/light/dark pages. The task packet +records the actual black-box evidence; no new mock-based browser suite is required. diff --git a/package.json b/package.json index ca0c7e0..bdef241 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "scripts": { "build": "SOURCE_DATE_EPOCH=315532800 pdm build --no-sdist --dest \"$INIT_CWD/dist\" && SOURCE_DATE_EPOCH=315532800 pdm build --project toolkit --no-sdist --no-clean --dest \"$INIT_CWD/dist\" && SOURCE_DATE_EPOCH=315532800 pdm build --project runtimes/core-py --no-sdist --no-clean --dest \"$INIT_CWD/dist\" && pnpm --filter @inkcre/extension-runtime-client-web build", "changeset": "changeset", - "check": "pnpm contracts:check && pnpm format:check && pnpm lint && pnpm type-check && pnpm build && pnpm worker:build", + "check": "pnpm web:check && pnpm contracts:check && pnpm format:check && pnpm lint && pnpm type-check && pnpm build && pnpm worker:build", "contracts:check": "pdm run python scripts/generate_contracts.py --check && pdm run python runtimes/core-py/scripts/generate_contracts.py --check && pnpm --filter @inkcre/extension-runtime-client-web check:bindings", "contracts:generate": "pdm run python scripts/generate_contracts.py && pdm run python runtimes/core-py/scripts/generate_contracts.py && pnpm --filter @inkcre/extension-runtime-client-web generate:bindings", "format": "pdm run ruff format . && prettier --write .", @@ -17,12 +17,16 @@ "lint": "pdm run ruff check .", "release:version": "changeset version", "type-check": "pdm run pyright && pnpm --filter @inkcre/extension-runtime-client-web type-check", - "worker:build": "lock_backup=$(mktemp) && cp pylock.toml \"$lock_backup\" && trap 'cp \"$lock_backup\" pylock.toml; rm -f \"$lock_backup\"' EXIT && SOURCE_DATE_EPOCH=315532800 pdm build --project toolkit --no-sdist --dest \"$INIT_CWD/dist\" && SOURCE_DATE_EPOCH=315532800 pdm build --no-sdist --no-clean --dest \"$INIT_CWD/dist\" && UV_FIND_LINKS=\"$INIT_CWD/dist\" pdm run pywrangler deploy --dry-run --outdir dist/worker" + "worker:build": "lock_backup=$(mktemp) && cp pylock.toml \"$lock_backup\" && trap 'cp \"$lock_backup\" pylock.toml; rm -f \"$lock_backup\"' EXIT && SOURCE_DATE_EPOCH=315532800 pdm build --project toolkit --no-sdist --dest \"$INIT_CWD/dist\" && SOURCE_DATE_EPOCH=315532800 pdm build --no-sdist --no-clean --dest \"$INIT_CWD/dist\" && UV_FIND_LINKS=\"$INIT_CWD/dist\" pdm run pywrangler deploy --dry-run --outdir dist/worker", + "web:build": "sass --pkg-importer=node --no-source-map --style=compressed web/registry.scss src/inkcre_extension_registry/service/static/registry.css", + "web:check": "node --check src/inkcre_extension_registry/service/static/registry.js && node scripts/check-web-styles.mjs" }, "devDependencies": { "@changesets/cli": "3.0.0", "@hey-api/openapi-ts": "0.99.0", + "@inkcre/ui-web": "1.4.0", "prettier": "3.6.2", + "sass": "1.94.2", "typescript": "5.9.3", "wrangler": "4.120.0" } diff --git a/pdm.lock b/pdm.lock index 711a81c..24dd824 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:9c15c6aa65161bee06ed785cbc2ac4aca50ccc54d9499125ba51b417d027fb96" +content_hash = "sha256:8567468fc6bef4e8051d0184e013c914ae91fd9b9fae670a03d6524fb7765eae" [[metadata.targets]] requires_python = ">=3.12,<3.14" @@ -333,7 +333,7 @@ files = [ [[package]] name = "inkcre-extension-runtime-core-py" -version = "0.1.0" +version = "0.1.2" requires_python = ">=3.12,<3.14" editable = true path = "./runtimes/core-py" @@ -349,7 +349,7 @@ dependencies = [ [[package]] name = "inkcre-extension-toolkit" -version = "0.2.0" +version = "0.2.1" requires_python = ">=3.12,<3.14" editable = true path = "./toolkit" @@ -380,7 +380,7 @@ name = "jinja2" version = "3.1.6" requires_python = ">=3.7" summary = "A very fast and expressive template engine." -groups = ["dev"] +groups = ["default", "dev"] dependencies = [ "MarkupSafe>=2.0", ] @@ -408,7 +408,7 @@ name = "markupsafe" version = "3.0.3" requires_python = ">=3.9" summary = "Safely add untrusted strings to HTML/XML markup." -groups = ["dev"] +groups = ["default", "dev"] files = [ {file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"}, {file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"}, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e92ba2b..6a86157 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,15 @@ importers: '@hey-api/openapi-ts': specifier: 0.99.0 version: 0.99.0(typescript@5.9.3) + '@inkcre/ui-web': + specifier: 1.4.0 + version: 1.4.0 prettier: specifier: 3.6.2 version: 3.6.2 + sass: + specifier: 1.94.2 + version: 1.94.2 typescript: specifier: 5.9.3 version: 5.9.3 @@ -543,6 +549,23 @@ packages: cpu: [x64] os: [win32] + '@inkcre/ui-web@1.4.0': + resolution: {integrity: sha512-Sq5f/hFpOtuGNZwVB36JuMPoAJUvkWvrHogl/PauI4OuV2M/qExdVXrEZa76pbbn35Ic/dleEPyze6lJghxI5w==, tarball: https://npm.pkg.github.com/download/@inkcre/ui-web/1.4.0/356469307437642b43b7e67e82c9b60e52d4f926} + peerDependencies: + '@codemirror/autocomplete': ^6.20.0 + '@codemirror/commands': ^6.10.0 + '@codemirror/lang-json': ^6.0.2 + '@codemirror/lint': ^6.9.2 + '@codemirror/state': ^6.5.2 + '@codemirror/view': ^6.38.8 + '@vueuse/core': ^14.0.0 + dayjs: ^1.11.0 + unocss: ^66.5.0 + vscode-json-languageservice: ^5.6.4 + vscode-languageserver-textdocument: ^1.0.12 + vue: ^3.0.0 + vue-router: ^4.0.0 + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -603,6 +626,88 @@ packages: '@oxc-project/types@0.95.0': resolution: {integrity: sha512-vACy7vhpMPhjEJhULNxrdR0D943TkA/MigMpJCHmBHvMXxRStRi/dPtTlfQ3uDwWSzRpT8z+7ImjZVf8JWBocQ==} + '@parcel/watcher-android-arm64@2.6.0': + resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.6.0': + resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.6.0': + resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.6.0': + resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.6.0': + resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.6.0': + resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.6.0': + resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.6.0': + resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.6.0': + resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.6.0': + resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-x64@2.6.0': + resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.6.0': + resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} + engines: {node: '>= 10.0.0'} + '@pnpm/deps.graph-sequencer@1100.0.1': resolution: {integrity: sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A==} engines: {node: '>=22.13'} @@ -823,6 +928,9 @@ packages: '@types/semver@7.7.1': resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + '@vue/shared@3.5.25': + resolution: {integrity: sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -994,6 +1102,9 @@ packages: resolution: {integrity: sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==} hasBin: true + immutable@5.1.9: + resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==} + import-meta-resolve@4.2.0: resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} @@ -1002,6 +1113,14 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + is-in-ssh@1.0.0: resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} engines: {node: '>=20'} @@ -1054,6 +1173,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + obug@2.1.4: resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} @@ -1150,6 +1272,11 @@ packages: resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} engines: {node: '>=18'} + sass@1.94.2: + resolution: {integrity: sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==} + engines: {node: '>=14.0.0'} + hasBin: true + semver@7.8.4: resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} engines: {node: '>=10'} @@ -1179,6 +1306,10 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + supports-color@10.2.2: resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} engines: {node: '>=18'} @@ -1719,6 +1850,10 @@ snapshots: '@img/sharp-win32-x64@0.35.2': optional: true + '@inkcre/ui-web@1.4.0': + dependencies: + '@vue/shared': 3.5.25 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1790,6 +1925,63 @@ snapshots: '@oxc-project/types@0.95.0': {} + '@parcel/watcher-android-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-x64@2.6.0': + optional: true + + '@parcel/watcher-freebsd-x64@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-musl@2.6.0': + optional: true + + '@parcel/watcher-win32-arm64@2.6.0': + optional: true + + '@parcel/watcher-win32-x64@2.6.0': + optional: true + + '@parcel/watcher@2.6.0': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.5 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.6.0 + '@parcel/watcher-darwin-arm64': 2.6.0 + '@parcel/watcher-darwin-x64': 2.6.0 + '@parcel/watcher-freebsd-x64': 2.6.0 + '@parcel/watcher-linux-arm-glibc': 2.6.0 + '@parcel/watcher-linux-arm-musl': 2.6.0 + '@parcel/watcher-linux-arm64-glibc': 2.6.0 + '@parcel/watcher-linux-arm64-musl': 2.6.0 + '@parcel/watcher-linux-x64-glibc': 2.6.0 + '@parcel/watcher-linux-x64-musl': 2.6.0 + '@parcel/watcher-win32-arm64': 2.6.0 + '@parcel/watcher-win32-x64': 2.6.0 + optional: true + '@pnpm/deps.graph-sequencer@1100.0.1': {} '@poppinss/colors@4.1.6': @@ -1921,6 +2113,8 @@ snapshots: '@types/semver@7.7.1': {} + '@vue/shared@3.5.25': {} + ansi-colors@4.1.3: {} ansis@4.3.1: {} @@ -2072,10 +2266,20 @@ snapshots: human-id@4.2.0: {} + immutable@5.1.9: {} + import-meta-resolve@4.2.0: {} is-docker@3.0.0: {} + is-extglob@2.1.1: + optional: true + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + optional: true + is-in-ssh@1.0.0: {} is-inside-container@1.0.0: @@ -2125,6 +2329,9 @@ snapshots: ms@2.1.3: {} + node-addon-api@7.1.1: + optional: true + obug@2.1.4: {} ohash@2.0.12: {} @@ -2238,6 +2445,14 @@ snapshots: run-applescript@7.1.0: {} + sass@1.94.2: + dependencies: + chokidar: 4.0.3 + immutable: 5.1.9 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.6.0 + semver@7.8.4: {} semver@7.8.5: {} @@ -2284,6 +2499,8 @@ snapshots: sisteransi@1.0.5: {} + source-map-js@1.2.1: {} + supports-color@10.2.2: {} tinyexec@1.3.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 2b2253f..c14e55d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: autoInstallPeers: false allowBuilds: + '@parcel/watcher': false esbuild: true workerd: true diff --git a/pyproject.toml b/pyproject.toml index 2ee11b3..daa972b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "inkcre-extension-toolkit>=0.2,<0.3", "packaging>=25,<27", "python-multipart>=0.0.22,<0.1", + "jinja2<4,>=3.1.6", ] [project.urls] diff --git a/runtimes/client-web/src/generated/index.ts b/runtimes/client-web/src/generated/index.ts index 2ab98b1..9bbd789 100644 --- a/runtimes/client-web/src/generated/index.ts +++ b/runtimes/client-web/src/generated/index.ts @@ -9,6 +9,7 @@ export { moduleFederationFileExtensionsNamespaceNameVersionModuleFederationRelativePathGet, type Options, prepareReleaseV1ExtensionsNamespaceNameReleasesPost, + publisherWorkspaceV1PublisherGet, publishReleaseV1ExtensionsNamespaceNameReleasesVersionPublishPost, pythonFilePackagesProjectProjectVersionFilenameGet, simpleProjectSimpleProjectGet, @@ -54,6 +55,13 @@ export type { PrepareReleaseV1ExtensionsNamespaceNameReleasesPostErrors, PrepareReleaseV1ExtensionsNamespaceNameReleasesPostResponse, PrepareReleaseV1ExtensionsNamespaceNameReleasesPostResponses, + PublisherRelease, + PublisherWorkspace, + PublisherWorkspaceV1PublisherGetData, + PublisherWorkspaceV1PublisherGetError, + PublisherWorkspaceV1PublisherGetErrors, + PublisherWorkspaceV1PublisherGetResponse, + PublisherWorkspaceV1PublisherGetResponses, PublishReleaseV1ExtensionsNamespaceNameReleasesVersionPublishPostData, PublishReleaseV1ExtensionsNamespaceNameReleasesVersionPublishPostError, PublishReleaseV1ExtensionsNamespaceNameReleasesVersionPublishPostErrors, diff --git a/runtimes/client-web/src/generated/sdk.gen.ts b/runtimes/client-web/src/generated/sdk.gen.ts index ae84c19..039a9cf 100644 --- a/runtimes/client-web/src/generated/sdk.gen.ts +++ b/runtimes/client-web/src/generated/sdk.gen.ts @@ -22,6 +22,9 @@ import type { PrepareReleaseV1ExtensionsNamespaceNameReleasesPostData, PrepareReleaseV1ExtensionsNamespaceNameReleasesPostErrors, PrepareReleaseV1ExtensionsNamespaceNameReleasesPostResponses, + PublisherWorkspaceV1PublisherGetData, + PublisherWorkspaceV1PublisherGetErrors, + PublisherWorkspaceV1PublisherGetResponses, PublishReleaseV1ExtensionsNamespaceNameReleasesVersionPublishPostData, PublishReleaseV1ExtensionsNamespaceNameReleasesVersionPublishPostErrors, PublishReleaseV1ExtensionsNamespaceNameReleasesVersionPublishPostResponses, @@ -311,3 +314,19 @@ export const yankReleaseV1ExtensionsNamespaceNameReleasesVersionYankPost = < ...options.headers, }, }) + +/** + * Publisher Workspace + */ +export const publisherWorkspaceV1PublisherGet = ( + options?: Options, +): RequestResult< + PublisherWorkspaceV1PublisherGetResponses, + PublisherWorkspaceV1PublisherGetErrors, + ThrowOnError +> => + (options?.client ?? client).get< + PublisherWorkspaceV1PublisherGetResponses, + PublisherWorkspaceV1PublisherGetErrors, + ThrowOnError + >({ url: '/v1/publisher', ...options }) diff --git a/runtimes/client-web/src/generated/types.gen.ts b/runtimes/client-web/src/generated/types.gen.ts index 9e15942..5e67a13 100644 --- a/runtimes/client-web/src/generated/types.gen.ts +++ b/runtimes/client-web/src/generated/types.gen.ts @@ -106,6 +106,60 @@ export type PrepareReleaseRequest = { version: string } +/** + * PublisherRelease + */ +export type PublisherRelease = { + module_federation?: ModuleFederationDistribution | null + /** + * Name + */ + name: string + /** + * Nickname + */ + nickname: string + python?: PythonDistribution | null + /** + * Python Uploaded + */ + python_uploaded: boolean + /** + * State + */ + state: 'preparing' | 'published' | 'yanked' | 'blocked' + /** + * Version + */ + version: string + /** + * Web Uploaded + */ + web_uploaded: boolean +} + +/** + * PublisherWorkspace + */ +export type PublisherWorkspace = { + /** + * Namespace + */ + namespace: string + /** + * Next Offset + */ + next_offset: number | null + /** + * Offset + */ + offset: number + /** + * Releases + */ + releases: Array +} + /** * PythonAssociationInput */ @@ -740,3 +794,41 @@ export type YankReleaseV1ExtensionsNamespaceNameReleasesVersionYankPostResponses export type YankReleaseV1ExtensionsNamespaceNameReleasesVersionYankPostResponse = YankReleaseV1ExtensionsNamespaceNameReleasesVersionYankPostResponses[keyof YankReleaseV1ExtensionsNamespaceNameReleasesVersionYankPostResponses] + +export type PublisherWorkspaceV1PublisherGetData = { + body?: never + headers?: { + /** + * Authorization + */ + authorization?: string | null + } + path?: never + query?: { + /** + * Offset + */ + offset?: number + } + url: '/v1/publisher' +} + +export type PublisherWorkspaceV1PublisherGetErrors = { + /** + * Validation Error + */ + 422: HttpValidationError +} + +export type PublisherWorkspaceV1PublisherGetError = + PublisherWorkspaceV1PublisherGetErrors[keyof PublisherWorkspaceV1PublisherGetErrors] + +export type PublisherWorkspaceV1PublisherGetResponses = { + /** + * Successful Response + */ + 200: PublisherWorkspace +} + +export type PublisherWorkspaceV1PublisherGetResponse = + PublisherWorkspaceV1PublisherGetResponses[keyof PublisherWorkspaceV1PublisherGetResponses] diff --git a/runtimes/client-web/src/generated/zod.gen.ts b/runtimes/client-web/src/generated/zod.gen.ts index 4380461..7208fc2 100644 --- a/runtimes/client-web/src/generated/zod.gen.ts +++ b/runtimes/client-web/src/generated/zod.gen.ts @@ -99,6 +99,44 @@ export const zPythonDistribution = z.object({ simple_url: z.string(), }) +/** + * PublisherRelease + */ +export const zPublisherRelease = z.object({ + module_federation: zModuleFederationDistribution.nullish(), + name: z + .string() + .min(3) + .max(129) + .regex(/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?\/[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/), + nickname: z.string(), + python: zPythonDistribution.nullish(), + python_uploaded: z.boolean(), + state: z.enum(['preparing', 'published', 'yanked', 'blocked']), + version: z + .string() + .min(5) + .max(128) + .regex( + /^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-(?:(?:0|[1-9][0-9]*)|(?:[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))(?:\.(?:(?:0|[1-9][0-9]*)|(?:[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)))*)?$/, + ), + web_uploaded: z.boolean(), +}) + +/** + * PublisherWorkspace + */ +export const zPublisherWorkspace = z.object({ + namespace: z + .string() + .min(1) + .max(64) + .regex(/^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/), + next_offset: z.int().gte(0).nullable(), + offset: z.int().gte(0), + releases: z.array(zPublisherRelease), +}) + /** * ReleaseRecord */ @@ -402,3 +440,16 @@ export const zYankReleaseV1ExtensionsNamespaceNameReleasesVersionYankPostPath = * Successful Response */ export const zYankReleaseV1ExtensionsNamespaceNameReleasesVersionYankPostResponse = zReleaseRecord + +export const zPublisherWorkspaceV1PublisherGetHeaders = z.object({ + authorization: z.string().nullish(), +}) + +export const zPublisherWorkspaceV1PublisherGetQuery = z.object({ + offset: z.int().gte(0).lte(100000).optional().default(0), +}) + +/** + * Successful Response + */ +export const zPublisherWorkspaceV1PublisherGetResponse = zPublisherWorkspace diff --git a/runtimes/core-py/src/inkcre_extension_runtime_core_py/generated/registry.py b/runtimes/core-py/src/inkcre_extension_runtime_core_py/generated/registry.py index 03b9f3f..b36bf7c 100644 --- a/runtimes/core-py/src/inkcre_extension_runtime_core_py/generated/registry.py +++ b/runtimes/core-py/src/inkcre_extension_runtime_core_py/generated/registry.py @@ -6,7 +6,7 @@ from enum import Enum from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, constr +from pydantic import BaseModel, ConfigDict, Field, conint, constr class ExtensionSummary(BaseModel): @@ -41,6 +41,13 @@ class ModuleFederationDistribution(BaseModel): manifest_url: str = Field(..., title="Manifest Url") +class State(Enum): + preparing = "preparing" + published = "published" + yanked = "yanked" + blocked = "blocked" + + class PythonEntryPoint(BaseModel): model_config = ConfigDict( extra="forbid", @@ -62,13 +69,6 @@ class PythonEntryPoint(BaseModel): ) = Field(..., title="Object") -class State(Enum): - preparing = "preparing" - published = "published" - yanked = "yanked" - blocked = "blocked" - - class ValidationError(BaseModel): ctx: dict[str, Any] | None = Field(None, title="Context") input: Any | None = Field(None, title="Input") @@ -165,3 +165,37 @@ class PrepareReleaseRequest(BaseModel): min_length=5, max_length=128, ) = Field(..., title="Version") + + +class PublisherRelease(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + module_federation: ModuleFederationDistribution | None = None + name: constr( + pattern=r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?/[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$", + min_length=3, + max_length=129, + ) = Field(..., title="Name") + nickname: str = Field(..., title="Nickname") + python: PythonDistribution | None = None + python_uploaded: bool = Field(..., title="Python Uploaded") + state: State = Field(..., title="State") + version: constr( + pattern=r"^(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)(?:-(?:(?:0|[1-9][0-9]*)|(?:[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))(?:\.(?:(?:0|[1-9][0-9]*)|(?:[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)))*)?$", + min_length=5, + max_length=128, + ) = Field(..., title="Version") + web_uploaded: bool = Field(..., title="Web Uploaded") + + +class PublisherWorkspace(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + namespace: constr( + pattern=r"^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$", min_length=1, max_length=64 + ) = Field(..., title="Namespace") + next_offset: conint(ge=0) | None = Field(..., title="Next Offset") + offset: conint(ge=0) = Field(..., title="Offset") + releases: list[PublisherRelease] = Field(..., title="Releases") diff --git a/scripts/check-web-styles.mjs b/scripts/check-web-styles.mjs new file mode 100644 index 0000000..73e62ff --- /dev/null +++ b/scripts/check-web-styles.mjs @@ -0,0 +1,11 @@ +import { readFileSync } from 'node:fs' +import * as sass from 'sass' +const output = sass.compile('web/registry.scss', { + importers: [new sass.NodePackageImporter(process.cwd())], + style: 'compressed', +}) +const committed = readFileSync('src/inkcre_extension_registry/service/static/registry.css', 'utf8') +if (output.css.trim() !== committed.trim()) { + throw new Error('Registry CSS is stale. Run pnpm web:build and commit the generated stylesheet.') +} +console.log('Registry styles match the pinned @inkcre/ui-web package and local Sass.') diff --git a/src/inkcre_extension_registry/contracts/models.py b/src/inkcre_extension_registry/contracts/models.py index 55e37fe..6a4298d 100644 --- a/src/inkcre_extension_registry/contracts/models.py +++ b/src/inkcre_extension_registry/contracts/models.py @@ -232,6 +232,18 @@ class ExtensionRecord(ContractModel): releases: tuple[ReleaseRecord, ...] = () +class PublisherRelease(ReleaseRecord): + python_uploaded: bool + web_uploaded: bool + + +class PublisherWorkspace(ContractModel): + namespace: RegistrySegment + releases: tuple[PublisherRelease, ...] + offset: int = Field(ge=0) + next_offset: int | None = Field(ge=0) + + class YankRequest(ContractModel): reason: str = Field(default="Yanked by publisher", min_length=1, max_length=400) diff --git a/src/inkcre_extension_registry/service/app.py b/src/inkcre_extension_registry/service/app.py index a603847..be6dcaa 100644 --- a/src/inkcre_extension_registry/service/app.py +++ b/src/inkcre_extension_registry/service/app.py @@ -8,7 +8,7 @@ from typing import Annotated, Any from urllib.parse import urlparse -from fastapi import Depends, FastAPI, Header, HTTPException, Request, Response, status +from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, Response, status from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, JSONResponse from packaging.version import InvalidVersion @@ -21,6 +21,7 @@ ExtensionRecord, ExtensionSummary, PrepareReleaseRequest, + PublisherWorkspace, PythonEntryPoint, RegistrySegment, ReleaseRecord, @@ -58,7 +59,7 @@ root_html, root_json, ) -from .ui import extension_catalog_html +from .ui import SCRIPT, extension_catalog_html, extension_detail_html, html_response, page MAX_UPLOAD_BYTES = 20 * 1024 * 1024 MultiPartParser.spool_max_size = MAX_UPLOAD_BYTES + 1 @@ -232,7 +233,7 @@ async def bound_uploads(request: Request, call_next: Any) -> Response: content={"detail": "multipart upload exceeds 20 MiB"}, ) response = await call_next(request) - if response.status_code in { + if request.url.path == "/v1/publisher" or response.status_code in { status.HTTP_404_NOT_FOUND, status.HTTP_451_UNAVAILABLE_FOR_LEGAL_REASONS, }: @@ -244,17 +245,64 @@ async def livez() -> dict[str, str]: return {"status": "ok"} @app.get("/", response_class=HTMLResponse, include_in_schema=False) - async def extension_catalog(request: Request) -> HTMLResponse: + async def extension_catalog( + request: Request, + q: Annotated[str, Query(max_length=200)] = "", + publisher: Annotated[str, Query(max_length=64)] = "", + ) -> HTMLResponse: extensions = await _repository(request).list_extensions() - response = HTMLResponse(extension_catalog_html(extensions)) - response.headers["Cache-Control"] = "no-store" - response.headers["Content-Security-Policy"] = ( - "default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; " - "form-action 'none'; frame-ancestors 'none'" + return html_response( + extension_catalog_html(extensions, query=q.strip(), namespace=publisher) ) - response.headers["Referrer-Policy"] = "no-referrer" - response.headers["X-Content-Type-Options"] = "nosniff" - return response + + @app.get("/assets/registry.js", include_in_schema=False) + async def registry_script() -> Response: + return Response( + SCRIPT, + media_type="text/javascript", + headers={ + "Cache-Control": "public, max-age=0, must-revalidate", + "X-Content-Type-Options": "nosniff", + }, + ) + + @app.get("/explore/{namespace}/{name}", response_class=HTMLResponse, include_in_schema=False) + async def extension_detail( + namespace: RegistrySegment, + name: RegistrySegment, + request: Request, + version: Annotated[str | None, Query(max_length=128)] = None, + ) -> HTMLResponse: + extension_name = _validate_identity(namespace, name) + extension = await _repository(request).get_extension(extension_name) + document = extension_detail_html(extension, version) if extension else None + if document is None: + return html_response( + page( + "error.html", + title="This release isn't available.", + code="404", + message="It may be unpublished, withdrawn, or no longer available. " + "Explore the catalog to find a published version.", + active="catalog", + ), + status_code=404, + ) + return html_response(document) + + @app.get("/publish", response_class=HTMLResponse, include_in_schema=False) + async def publisher_page() -> HTMLResponse: + return html_response(page("publish.html", title="Publish", active="publish", noindex=True)) + + @app.get("/v1/publisher", response_model=PublisherWorkspace) + async def publisher_workspace( + request: Request, + response: Response, + namespace: Annotated[str, Depends(_publisher_namespace)], + offset: Annotated[int, Query(ge=0, le=100000)] = 0, + ) -> PublisherWorkspace: + response.headers["Cache-Control"] = "no-store" + return await _repository(request).publisher_workspace(namespace, offset) @app.get("/v1/extensions", response_model=list[ExtensionSummary]) async def list_extensions(request: Request, response: Response) -> list[ExtensionSummary]: diff --git a/src/inkcre_extension_registry/service/repository.py b/src/inkcre_extension_registry/service/repository.py index 265527f..e740789 100644 --- a/src/inkcre_extension_registry/service/repository.py +++ b/src/inkcre_extension_registry/service/repository.py @@ -12,6 +12,8 @@ ModuleFederationAssociationInput, ModuleFederationDistribution, PrepareReleaseRequest, + PublisherRelease, + PublisherWorkspace, PythonAssociationInput, PythonDistribution, PythonEntryPoint, @@ -375,6 +377,47 @@ async def get_release( module_federation=module_federation, ) + async def publisher_workspace(self, namespace: str, offset: int) -> PublisherWorkspace: + # Ten records keep the existing descriptor reads within D1's per-request query budget. + result = ( + await self.db.prepare( + "SELECT r.extension_name, r.version, " + "EXISTS (SELECT 1 FROM python_distributions pd JOIN python_files pf " + "ON pf.normalized_project = pd.normalized_project AND pf.project_version = " + "pd.project_version WHERE pd.extension_name = r.extension_name " + "AND pd.release_version = r.version) AS python_uploaded, " + "EXISTS (SELECT 1 FROM module_federation_distributions mf " + "WHERE mf.extension_name = r.extension_name AND mf.release_version = r.version " + "AND mf.manifest_r2_key IS NOT NULL) AS web_uploaded " + "FROM releases r JOIN extensions e ON e.name = r.extension_name " + "WHERE e.namespace = ?1 ORDER BY r.created_at DESC, " + "r.extension_name, r.version DESC " + "LIMIT 11 OFFSET ?2" + ) + .bind(namespace, offset) + .all() + ) + rows = _results(result) + releases = [] + for row in rows[:10]: + release = await self.get_release( + _column(row, "extension_name"), _column(row, "version"), public=False + ) + if release is not None: + releases.append( + PublisherRelease( + **release.model_dump(), + python_uploaded=bool(_column(row, "python_uploaded")), + web_uploaded=bool(_column(row, "web_uploaded")), + ) + ) + return PublisherWorkspace( + namespace=namespace, + releases=tuple(releases), + offset=offset, + next_offset=offset + 10 if len(rows) > 10 else None, + ) + async def list_extensions(self) -> list[ExtensionSummary]: result = await self.db.prepare( "SELECT e.name, e.nickname FROM extensions e " diff --git a/src/inkcre_extension_registry/service/static/registry.css b/src/inkcre_extension_registry/service/static/registry.css new file mode 100644 index 0000000..2ea14dc --- /dev/null +++ b/src/inkcre_extension_registry/service/static/registry.css @@ -0,0 +1 @@ +:root{--ref-elevation-raised-low-shadow-type:dropShadow;--ref-elevation-raised-low-radius:2;--ref-elevation-raised-low-color:rgba(12, 21, 48, 0.0784313725);--ref-elevation-raised-low-offset-x:0;--ref-elevation-raised-low-offset-y:1;--ref-elevation-raised-low-spread:0;--ref-elevation-raised-md-shadow-type:dropShadow;--ref-elevation-raised-md-radius:20;--ref-elevation-raised-md-color:rgba(12, 21, 48, 0.1215686275);--ref-elevation-raised-md-offset-x:0;--ref-elevation-raised-md-offset-y:6;--ref-elevation-raised-md-spread:0;--ref-elevation-raised-high-shadow-type:dropShadow;--ref-elevation-raised-high-radius:32;--ref-elevation-raised-high-color:rgba(12, 21, 84, 0.1607843137);--ref-elevation-raised-high-offset-x:0;--ref-elevation-raised-high-offset-y:12;--ref-elevation-raised-high-spread:0;--ref-color-neutral-2:#070707;--ref-color-neutral-6:#131313;--ref-color-neutral-12:#1f1f1f;--ref-color-neutral-20:#303030;--ref-color-neutral-30:#474747;--ref-color-neutral-40:#5e5e5e;--ref-color-neutral-50:#777777;--ref-color-neutral-60:#919191;--ref-color-neutral-70:#ababab;--ref-color-neutral-80:#c6c6c6;--ref-color-neutral-90:#e2e2e2;--ref-color-neutral-94:#eeeeee;--ref-color-neutral-98:#f9f9f9;--ref-color-neutral-100:#ffffff;--ref-color-tertiary-40:#1f9aa5;--ref-color-tertiary-90:#d0f4f4;--ref-color-danger-10:#410008;--ref-color-danger-30:#92001e;--ref-color-danger-35:#a80324;--ref-color-danger-40:#b9192f;--ref-color-danger-60:#ff525c;--ref-color-danger-65:#ff7073;--ref-color-danger-70:#ff8889;--ref-color-danger-80:#ffb3b1;--ref-color-danger-90:#ffdad8;--ref-color-danger-98:#fff8f7;--ref-color-brand-2:#0b1220;--ref-color-brand-6:#121928;--ref-color-brand-12:#1f2635;--ref-color-brand-20:#303747;--ref-color-brand-30:#474e5f;--ref-color-brand-40:#5f6677;--ref-color-brand-50:#787e91;--ref-color-brand-60:#9198ac;--ref-color-brand-70:#acb3c7;--ref-color-brand-80:#b2bccf;--ref-color-brand-90:#c8cee3;--ref-color-brand-94:#f7f7fb;--ref-color-brand-98:#fbfcff;--ref-radius-xs:4px;--ref-radius-sm:8px;--ref-radius-md:16px;--ref-radius-lg:32px;--ref-radius-none:0px;--ref-radius-full:999px;--ref-space-md:16px;--ref-space-lg:32px;--ref-space-xl:56px;--ref-space-xs:4px;--ref-space-sm:8px;--ref-typo-family-sans:system-ui, -apple-system, sans-serif;--ref-typo-family-mono: SF Mono , Monaco, Roboto Mono , monospace;--ref-breakpoint-mobile:768px;--ref-breakpoint-tablet:1024px;--ref-breakpoint-desktop:1280px;--ref-opacity-muted:0.72;--ref-opacity-medium:0.84;--ref-opacity-strong:0.92;--ref-size-lg:48px;--ref-size-md:36px;--ref-size-sm:24px;--ref-size-icon-sm:20px;--ref-size-icon-md:24px;--ref-size-icon-lg:36px;--ref-size-iconfont-sm:8px;--ref-size-iconfont-md:12px;--ref-size-iconfont-lg:18px;--ref-size-xl:58px;--ref-size-xs:18px;--ref-font-label-lg-font-size:14px;--ref-font-label-lg-text-decoration:none;--ref-font-label-lg-font-family:Inter;--ref-font-label-lg-font-weight:400;--ref-font-label-lg-font-style:normal;--ref-font-label-lg-font-stretch:normal;--ref-font-label-lg-letter-spacing:0px;--ref-font-label-lg-line-height:16px;--ref-font-label-lg-paragraph-indent:0px;--ref-font-label-lg-paragraph-spacing:0px;--ref-font-label-lg-text-case:none;--ref-font-label-md-font-size:12px;--ref-font-label-md-text-decoration:none;--ref-font-label-md-font-family:Inter;--ref-font-label-md-font-weight:400;--ref-font-label-md-font-style:normal;--ref-font-label-md-font-stretch:normal;--ref-font-label-md-letter-spacing:0px;--ref-font-label-md-line-height:16px;--ref-font-label-md-paragraph-indent:0px;--ref-font-label-md-paragraph-spacing:0px;--ref-font-label-md-text-case:none;--ref-font-label-sm-font-size:10px;--ref-font-label-sm-text-decoration:none;--ref-font-label-sm-font-family:Roboto;--ref-font-label-sm-font-weight:700;--ref-font-label-sm-font-style:normal;--ref-font-label-sm-font-stretch:normal;--ref-font-label-sm-letter-spacing:0.7px;--ref-font-label-sm-line-height:14px;--ref-font-label-sm-paragraph-indent:0px;--ref-font-label-sm-paragraph-spacing:0px;--ref-font-label-sm-text-case:none;--ref-font-label-lg-underlined-font-size:14px;--ref-font-label-lg-underlined-text-decoration:underline;--ref-font-label-lg-underlined-font-family:Inter;--ref-font-label-lg-underlined-font-weight:400;--ref-font-label-lg-underlined-font-style:normal;--ref-font-label-lg-underlined-font-stretch:normal;--ref-font-label-lg-underlined-letter-spacing:0px;--ref-font-label-lg-underlined-line-height:16px;--ref-font-label-lg-underlined-paragraph-indent:0px;--ref-font-label-lg-underlined-paragraph-spacing:0px;--ref-font-label-lg-underlined-text-case:none;--ref-font-label-lg-mono-font-size:14px;--ref-font-label-lg-mono-text-decoration:none;--ref-font-label-lg-mono-font-family:JetBrains Mono;--ref-font-label-lg-mono-font-weight:400;--ref-font-label-lg-mono-font-style:normal;--ref-font-label-lg-mono-font-stretch:normal;--ref-font-label-lg-mono-letter-spacing:0px;--ref-font-label-lg-mono-line-height:16px;--ref-font-label-lg-mono-paragraph-indent:0px;--ref-font-label-lg-mono-paragraph-spacing:0px;--ref-font-label-lg-mono-text-case:none;--ref-font-label-sm-mono-font-size:10px;--ref-font-label-sm-mono-text-decoration:none;--ref-font-label-sm-mono-font-family:JetBrains Mono;--ref-font-label-sm-mono-font-weight:700;--ref-font-label-sm-mono-font-style:normal;--ref-font-label-sm-mono-font-stretch:normal;--ref-font-label-sm-mono-letter-spacing:0.4px;--ref-font-label-sm-mono-line-height:14px;--ref-font-label-sm-mono-paragraph-indent:0px;--ref-font-label-sm-mono-paragraph-spacing:0px;--ref-font-label-sm-mono-text-case:none;--ref-font-title-sm-font-size:22px;--ref-font-title-sm-text-decoration:none;--ref-font-title-sm-font-family:Inter;--ref-font-title-sm-font-weight:400;--ref-font-title-sm-font-style:normal;--ref-font-title-sm-font-stretch:normal;--ref-font-title-sm-letter-spacing:0px;--ref-font-title-sm-line-height:28px;--ref-font-title-sm-paragraph-indent:0px;--ref-font-title-sm-paragraph-spacing:0px;--ref-font-title-sm-text-case:none;--ref-font-title-sm-mono-font-size:22px;--ref-font-title-sm-mono-text-decoration:none;--ref-font-title-sm-mono-font-family:IBM Plex Mono;--ref-font-title-sm-mono-font-weight:400;--ref-font-title-sm-mono-font-style:normal;--ref-font-title-sm-mono-font-stretch:normal;--ref-font-title-sm-mono-letter-spacing:0.66px;--ref-font-title-sm-mono-line-height:28px;--ref-font-title-sm-mono-paragraph-indent:0px;--ref-font-title-sm-mono-paragraph-spacing:0px;--ref-font-title-sm-mono-text-case:none;--ref-font-body-lg-font-size:18px;--ref-font-body-lg-text-decoration:none;--ref-font-body-lg-font-family:Inter;--ref-font-body-lg-font-weight:400;--ref-font-body-lg-font-style:normal;--ref-font-body-lg-font-stretch:normal;--ref-font-body-lg-letter-spacing:0px;--ref-font-body-lg-line-height:24px;--ref-font-body-lg-paragraph-indent:0px;--ref-font-body-lg-paragraph-spacing:0px;--ref-font-body-lg-text-case:none;--ref-font-body-lg-mono-font-size:18px;--ref-font-body-lg-mono-text-decoration:none;--ref-font-body-lg-mono-font-family:JetBrains Mono;--ref-font-body-lg-mono-font-weight:400;--ref-font-body-lg-mono-font-style:normal;--ref-font-body-lg-mono-font-stretch:normal;--ref-font-body-lg-mono-letter-spacing:0px;--ref-font-body-lg-mono-line-height:24px;--ref-font-body-lg-mono-paragraph-indent:0px;--ref-font-body-lg-mono-paragraph-spacing:0px;--ref-font-body-lg-mono-text-case:none;--ref-font-headline-lg-font-size:36px;--ref-font-headline-lg-text-decoration:none;--ref-font-headline-lg-font-family:Inter;--ref-font-headline-lg-font-weight:400;--ref-font-headline-lg-font-style:normal;--ref-font-headline-lg-font-stretch:normal;--ref-font-headline-lg-letter-spacing:0px;--ref-font-headline-lg-line-height:48px;--ref-font-headline-lg-paragraph-indent:0px;--ref-font-headline-lg-paragraph-spacing:0px;--ref-font-headline-lg-text-case:none;--sys-elevation-raised-low-shadow-type:dropShadow;--sys-elevation-raised-low-radius:2;--sys-elevation-raised-low-color:rgba(12, 21, 48, 0.0784313725);--sys-elevation-raised-low-offset-x:0;--sys-elevation-raised-low-offset-y:1;--sys-elevation-raised-low-spread:0;--sys-elevation-raised-md-shadow-type:dropShadow;--sys-elevation-raised-md-radius:20;--sys-elevation-raised-md-color:rgba(12, 21, 48, 0.1215686275);--sys-elevation-raised-md-offset-x:0;--sys-elevation-raised-md-offset-y:6;--sys-elevation-raised-md-spread:0;--sys-elevation-raised-high-shadow-type:dropShadow;--sys-elevation-raised-high-radius:32;--sys-elevation-raised-high-color:rgba(12, 21, 84, 0.1607843137);--sys-elevation-raised-high-offset-x:0;--sys-elevation-raised-high-offset-y:12;--sys-elevation-raised-high-spread:0;--sys-radius-xs:4px;--sys-radius-sm:8px;--sys-radius-md:16px;--sys-radius-lg:32px;--sys-radius-none:0px;--sys-radius-full:999px;--sys-space-md:16px;--sys-space-lg:32px;--sys-space-xl:56px;--sys-space-xs:4px;--sys-space-sm:8px;--sys-typo-family-sans:system-ui, -apple-system, sans-serif;--sys-typo-family-mono: SF Mono , Monaco, Roboto Mono , monospace;--sys-breakpoint-mobile:768px;--sys-breakpoint-tablet:1024px;--sys-breakpoint-desktop:1280px;--sys-opacity-muted:0.72;--sys-opacity-medium:0.84;--sys-opacity-strong:0.92;--sys-size-lg:48px;--sys-size-md:36px;--sys-size-sm:24px;--sys-size-icon-sm:20px;--sys-size-icon-md:24px;--sys-size-icon-lg:36px;--sys-size-iconfont-sm:8px;--sys-size-iconfont-md:12px;--sys-size-iconfont-lg:18px;--sys-size-xl:58px;--sys-size-xs:18px;--sys-font-label-lg-font-size:14px;--sys-font-label-lg-text-decoration:none;--sys-font-label-lg-font-family:Inter;--sys-font-label-lg-font-weight:400;--sys-font-label-lg-font-style:normal;--sys-font-label-lg-font-stretch:normal;--sys-font-label-lg-letter-spacing:0px;--sys-font-label-lg-line-height:16px;--sys-font-label-lg-paragraph-indent:0px;--sys-font-label-lg-paragraph-spacing:0px;--sys-font-label-lg-text-case:none;--sys-font-label-md-font-size:12px;--sys-font-label-md-text-decoration:none;--sys-font-label-md-font-family:Inter;--sys-font-label-md-font-weight:400;--sys-font-label-md-font-style:normal;--sys-font-label-md-font-stretch:normal;--sys-font-label-md-letter-spacing:0px;--sys-font-label-md-line-height:16px;--sys-font-label-md-paragraph-indent:0px;--sys-font-label-md-paragraph-spacing:0px;--sys-font-label-md-text-case:none;--sys-font-label-sm-font-size:10px;--sys-font-label-sm-text-decoration:none;--sys-font-label-sm-font-family:Roboto;--sys-font-label-sm-font-weight:700;--sys-font-label-sm-font-style:normal;--sys-font-label-sm-font-stretch:normal;--sys-font-label-sm-letter-spacing:0.7px;--sys-font-label-sm-line-height:14px;--sys-font-label-sm-paragraph-indent:0px;--sys-font-label-sm-paragraph-spacing:0px;--sys-font-label-sm-text-case:none;--sys-font-label-lg-underlined-font-size:14px;--sys-font-label-lg-underlined-text-decoration:underline;--sys-font-label-lg-underlined-font-family:Inter;--sys-font-label-lg-underlined-font-weight:400;--sys-font-label-lg-underlined-font-style:normal;--sys-font-label-lg-underlined-font-stretch:normal;--sys-font-label-lg-underlined-letter-spacing:0px;--sys-font-label-lg-underlined-line-height:16px;--sys-font-label-lg-underlined-paragraph-indent:0px;--sys-font-label-lg-underlined-paragraph-spacing:0px;--sys-font-label-lg-underlined-text-case:none;--sys-font-label-lg-mono-font-size:14px;--sys-font-label-lg-mono-text-decoration:none;--sys-font-label-lg-mono-font-family:JetBrains Mono;--sys-font-label-lg-mono-font-weight:400;--sys-font-label-lg-mono-font-style:normal;--sys-font-label-lg-mono-font-stretch:normal;--sys-font-label-lg-mono-letter-spacing:0px;--sys-font-label-lg-mono-line-height:16px;--sys-font-label-lg-mono-paragraph-indent:0px;--sys-font-label-lg-mono-paragraph-spacing:0px;--sys-font-label-lg-mono-text-case:none;--sys-font-label-sm-mono-font-size:10px;--sys-font-label-sm-mono-text-decoration:none;--sys-font-label-sm-mono-font-family:JetBrains Mono;--sys-font-label-sm-mono-font-weight:700;--sys-font-label-sm-mono-font-style:normal;--sys-font-label-sm-mono-font-stretch:normal;--sys-font-label-sm-mono-letter-spacing:0.4px;--sys-font-label-sm-mono-line-height:14px;--sys-font-label-sm-mono-paragraph-indent:0px;--sys-font-label-sm-mono-paragraph-spacing:0px;--sys-font-label-sm-mono-text-case:none;--sys-font-title-sm-font-size:22px;--sys-font-title-sm-text-decoration:none;--sys-font-title-sm-font-family:Inter;--sys-font-title-sm-font-weight:400;--sys-font-title-sm-font-style:normal;--sys-font-title-sm-font-stretch:normal;--sys-font-title-sm-letter-spacing:0px;--sys-font-title-sm-line-height:28px;--sys-font-title-sm-paragraph-indent:0px;--sys-font-title-sm-paragraph-spacing:0px;--sys-font-title-sm-text-case:none;--sys-font-title-sm-mono-font-size:22px;--sys-font-title-sm-mono-text-decoration:none;--sys-font-title-sm-mono-font-family:IBM Plex Mono;--sys-font-title-sm-mono-font-weight:400;--sys-font-title-sm-mono-font-style:normal;--sys-font-title-sm-mono-font-stretch:normal;--sys-font-title-sm-mono-letter-spacing:0.66px;--sys-font-title-sm-mono-line-height:28px;--sys-font-title-sm-mono-paragraph-indent:0px;--sys-font-title-sm-mono-paragraph-spacing:0px;--sys-font-title-sm-mono-text-case:none;--sys-font-body-lg-font-size:18px;--sys-font-body-lg-text-decoration:none;--sys-font-body-lg-font-family:Inter;--sys-font-body-lg-font-weight:400;--sys-font-body-lg-font-style:normal;--sys-font-body-lg-font-stretch:normal;--sys-font-body-lg-letter-spacing:0px;--sys-font-body-lg-line-height:24px;--sys-font-body-lg-paragraph-indent:0px;--sys-font-body-lg-paragraph-spacing:0px;--sys-font-body-lg-text-case:none;--sys-font-body-lg-mono-font-size:18px;--sys-font-body-lg-mono-text-decoration:none;--sys-font-body-lg-mono-font-family:JetBrains Mono;--sys-font-body-lg-mono-font-weight:400;--sys-font-body-lg-mono-font-style:normal;--sys-font-body-lg-mono-font-stretch:normal;--sys-font-body-lg-mono-letter-spacing:0px;--sys-font-body-lg-mono-line-height:24px;--sys-font-body-lg-mono-paragraph-indent:0px;--sys-font-body-lg-mono-paragraph-spacing:0px;--sys-font-body-lg-mono-text-case:none;--sys-font-headline-lg-font-size:36px;--sys-font-headline-lg-text-decoration:none;--sys-font-headline-lg-font-family:Inter;--sys-font-headline-lg-font-weight:400;--sys-font-headline-lg-font-style:normal;--sys-font-headline-lg-font-stretch:normal;--sys-font-headline-lg-letter-spacing:0px;--sys-font-headline-lg-line-height:48px;--sys-font-headline-lg-paragraph-indent:0px;--sys-font-headline-lg-paragraph-spacing:0px;--sys-font-headline-lg-text-case:none}:root,html[data-theme=light]{color-scheme:light;--sys-color-text-base:#070707;--sys-color-text-muted:#919191;--sys-color-text-danger-on:#fff8f7;--sys-color-text-primary:#fbfcff;--sys-color-text-subtle:#5e5e5e;--sys-color-surface-base:#ffffff;--sys-color-surface-muted:#e2e2e2;--sys-color-surface-subtle:#f9f9f9;--sys-color-surface-primary:#121928;--sys-color-surface-primary-hover:#474e5f;--sys-color-surface-subtle-hover:#eeeeee;--sys-color-surface-danger:#a80324;--sys-color-surface-danger-hover:#ff7073;--sys-color-surface-base-hover:#eeeeee;--sys-color-border-base:#303030;--sys-color-border-strong:#070707;--sys-color-border-subtle:#c6c6c6;--sys-color-border-muted:#919191;--sys-color-feedback-error:#b9192f;--comp-switch-track-bg:#070707;--comp-switch-handle-bg:#f9f9f9;--comp-switch-label-color:#070707}@media(prefers-color-scheme: dark){:root:not([data-theme]),html[data-theme=dark]{color-scheme:dark;--sys-color-text-base:#f9f9f9;--sys-color-text-muted:#f9f9f9;--sys-color-text-danger-on:#fff8f7;--sys-color-text-primary:#0b1220;--sys-color-text-subtle:#c6c6c6;--sys-color-surface-base:#474747;--sys-color-surface-muted:#131313;--sys-color-surface-subtle:#1f1f1f;--sys-color-surface-primary:#c8cee3;--sys-color-surface-primary-hover:#9198ac;--sys-color-surface-subtle-hover:#5e5e5e;--sys-color-surface-danger:#b9192f;--sys-color-surface-danger-hover:#b9192f;--sys-color-surface-base-hover:#474747;--sys-color-border-base:#5e5e5e;--sys-color-border-strong:#c6c6c6;--sys-color-border-subtle:#5e5e5e;--sys-color-border-muted:#5e5e5e;--sys-color-feedback-error:#ff525c;--comp-switch-track-bg:#f9f9f9;--comp-switch-handle-bg:#070707;--comp-switch-label-color:#f9f9f9}}:root{--page-width: 1160px}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--sys-color-surface-subtle);color:var(--sys-color-text-base);font-family:var(--ref-typo-family-sans);font-size:15px;line-height:1.6;-webkit-font-smoothing:antialiased}a{color:inherit;text-underline-offset:4px}button,input,select,textarea{font:inherit}button,a,input,select,textarea{-webkit-tap-highlight-color:rgba(0,0,0,0)}button,select{cursor:pointer}button:disabled{cursor:wait;opacity:.6}button{color:inherit}:focus-visible{outline:2px solid var(--sys-color-border-strong);outline-offset:4px}[hidden]{display:none !important}h1,h2,h3,p{margin:0}h1,h2,h3{line-height:1.2;font-weight:550}h1{letter-spacing:-0.055em}h2{font-size:26px;letter-spacing:-0.035em}h3{font-size:18px;letter-spacing:-0.02em}code,.extension-name,.art-caption,.eyebrow,kbd,.publisher-name{font-family:var(--ref-typo-family-mono)}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.skip-link{position:fixed;top:-100px;left:16px;padding:12px;background:var(--sys-color-surface-base);z-index:20}.skip-link:focus{top:12px}.site-header{border-bottom:1px solid var(--sys-color-border-subtle);background:var(--sys-color-surface-subtle)}.header-inner{max-width:var(--page-width);min-height:88px;margin:auto;display:flex;align-items:center;gap:36px}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;font-weight:650;font-size:17px;letter-spacing:-0.04em}.brand-mark,.art-core{background:var(--sys-color-surface-primary);color:var(--sys-color-text-primary);font-weight:500;letter-spacing:-0.13em}.brand-mark{width:36px;height:36px;display:grid;grid-auto-flow:column;justify-content:center;align-items:center;font-size:26px;padding-right:4px}.brand-mark span,.art-core span{font-style:italic}.brand-label{color:var(--sys-color-text-subtle);margin-left:8px;padding-left:13px;border-left:1px solid var(--sys-color-border-subtle);font-size:14px;font-weight:400;letter-spacing:0}nav{display:flex;align-self:stretch;align-items:center;gap:32px;margin-left:auto}nav a{text-decoration:none;font-size:13px;color:var(--sys-color-text-subtle);height:100%;display:flex;align-items:center;gap:8px;border-bottom:2px solid rgba(0,0,0,0);padding-top:2px}nav a[aria-current]{color:var(--sys-color-text-base);border-color:var(--sys-color-border-strong)}.theme-button{border:1px solid var(--sys-color-border-subtle);background:rgba(0,0,0,0);border-radius:var(--sys-radius-full);width:32px;height:32px;display:grid;place-items:center;font-size:20px;line-height:1}.hero{max-width:var(--page-width);margin:auto;min-height:462px;display:grid;grid-template-columns:1.15fr 1fr;align-items:center;gap:40px;padding:64px 0}.eyebrow{font-size:10px;letter-spacing:.12em;font-weight:500;color:var(--sys-color-text-subtle);display:flex;align-items:center;gap:8px}.tiny-square{display:inline-block;width:5px;height:5px;background:currentColor;flex:none}.hero h1{font-size:clamp(42px,4.8vw,64px);line-height:1.08;margin-top:24px}.hero h1>span,.publisher-heading h1>span{color:var(--sys-color-text-subtle)}.hero-description{margin-top:24px;color:var(--sys-color-text-subtle);font-size:16px;line-height:1.8}.text-link{font-size:13px;text-decoration:none;display:inline-flex;gap:18px;align-items:center;font-weight:500}.text-link:hover{text-decoration:underline}.hero .text-link{margin-top:24px}.hero-art{position:relative;height:330px;background-image:radial-gradient(var(--sys-color-border-subtle) 0.7px, transparent 0.7px);background-size:20px 20px;mask-image:linear-gradient(to right, transparent, #000 12%, #000 88%, transparent)}.orbit{position:absolute;border:1px solid var(--sys-color-border-subtle);border-radius:50%;top:50%;left:50%;transform:translate(-50%, -50%) rotate(-25deg)}.orbit-one{width:320px;height:170px}.orbit-two{width:220px;height:270px;transform:translate(-50%, -50%) rotate(40deg)}.art-core{position:absolute;left:50%;top:50%;transform:translate(-50%, -50%) rotate(-8deg);width:98px;height:98px;border-radius:22px;display:flex;align-items:center;justify-content:center;font-size:72px;padding-right:7px;box-shadow:10px 12px 0 var(--sys-color-surface-muted)}.art-tile{position:absolute;display:grid;place-items:center;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);width:60px;height:60px;border-radius:var(--sys-radius-md);font-family:var(--ref-typo-family-mono);font-size:22px}.tile-code{top:36px;left:26%;transform:rotate(-12deg)}.tile-feed{top:100px;right:8%;transform:rotate(12deg);font-size:38px}.tile-mail{bottom:46px;left:15%;transform:rotate(8deg)}.tile-plus{bottom:43px;right:21%;transform:rotate(-10deg)}.art-caption{position:absolute;bottom:0;left:0;width:100%;text-align:center;font-size:8px;letter-spacing:.15em;color:var(--sys-color-text-subtle)}.catalog-section{max-width:var(--page-width);margin:auto;padding:36px 0 64px;border-top:1px solid var(--sys-color-border-subtle);scroll-margin-top:24px}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:24px}.section-heading .eyebrow{margin-bottom:12px}.count{display:inline-block;vertical-align:middle;font-size:11px;font-weight:400;padding:2px 7px;border:1px solid var(--sys-color-border-subtle);border-radius:5px;letter-spacing:0;margin-left:8px;line-height:1.5}.section-note{font-size:12px;color:var(--sys-color-text-subtle)}.search-form{display:flex;gap:12px;margin:28px 0}.search-input{display:flex;align-items:center;flex:1;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);padding:0 14px;gap:12px;border-radius:var(--sys-radius-xs)}.search-input:focus-within{outline:2px solid var(--sys-color-border-strong);outline-offset:2px}.search-input>span{font-size:25px}.search-input input{border:0;background:rgba(0,0,0,0);width:100%;padding:11px 0;outline:none}.search-input kbd{border:1px solid var(--sys-color-border-subtle);color:var(--sys-color-text-subtle);border-radius:3px;padding:0 6px;font-size:11px}input,select,textarea{color:var(--sys-color-text-base);background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-xs);padding:11px 12px;min-width:0;max-width:100%}input::placeholder,textarea::placeholder{color:var(--sys-color-text-subtle);opacity:.85}.button{display:inline-flex;align-items:center;justify-content:center;gap:18px;padding:11px 18px;border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-xs);background:var(--sys-color-surface-base);color:var(--sys-color-text-base);text-decoration:none;font-size:12px;font-weight:500;line-height:1.5;white-space:nowrap}.button:hover{background:var(--sys-color-surface-subtle-hover)}.button-primary{background:var(--sys-color-surface-primary);color:var(--sys-color-text-primary);border-color:rgba(0,0,0,0)}.button-primary:hover{background:var(--sys-color-surface-primary-hover)}.search-summary{display:flex;gap:20px;font-size:13px;margin-bottom:20px}.catalog-grid{display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:16px;list-style:none;padding:0;margin:0}.extension-card{display:flex;flex-direction:column;gap:24px;min-height:252px;padding:24px;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm);text-decoration:none;transition:transform 160ms,border-color 160ms}.extension-card:hover{transform:translateY(-3px);border-color:var(--sys-color-border-strong)}.card-top{display:flex;justify-content:space-between;align-items:flex-start}.extension-icon{display:grid;place-items:center;width:48px;height:48px;border-radius:12px;background:var(--sys-color-surface-subtle);border:1px solid var(--sys-color-border-subtle);font-size:15px;font-weight:600;letter-spacing:-0.08em}.card-arrow{color:var(--sys-color-text-subtle);font-size:19px}.publisher-name{font-size:10px;letter-spacing:.02em;color:var(--sys-color-text-subtle);margin-bottom:7px;text-decoration:none}.extension-card h3{font-size:20px;overflow-wrap:anywhere}.extension-name{color:var(--sys-color-text-subtle);font-size:11px;margin-top:9px;overflow-wrap:anywhere}.card-bottom{margin-top:auto;padding-top:17px;border-top:1px solid var(--sys-color-border-subtle);display:flex;justify-content:space-between;gap:12px;font-size:10px;color:var(--sys-color-text-subtle)}.card-bottom>span:first-child{display:flex;gap:6px;align-items:center}.builder-callout{max-width:var(--page-width);margin:0 auto 64px;padding:36px;border:1px solid var(--sys-color-border-subtle);background:var(--sys-color-surface-muted);border-radius:var(--sys-radius-sm);display:flex;align-items:center;justify-content:space-between;gap:24px}.builder-callout h2{margin:12px 0 8px}.builder-callout p:not(.eyebrow){color:var(--sys-color-text-subtle);font-size:13px}.site-footer{max-width:var(--page-width);margin:auto;padding:28px 0 36px;border-top:1px solid var(--sys-color-border-subtle);display:flex;justify-content:space-between;gap:20px;color:var(--sys-color-text-subtle);font-size:11px}.site-footer>div{display:flex;align-items:center;gap:24px}.site-footer a{text-decoration:none}.footer-wordmark{font-size:15px;font-weight:650;letter-spacing:-0.05em;color:var(--sys-color-text-base)}.empty-state{text-align:center;border:1px dashed var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm);padding:56px 24px}.empty-symbol{font-size:38px;display:block;margin-bottom:16px}.empty-state p{color:var(--sys-color-text-subtle);margin:14px auto 24px;max-width:480px}.empty-state .eyebrow{justify-content:center}.preview-banner{text-align:center;padding:10px;font-size:12px;background:var(--sys-color-surface-muted)}.page-container{max-width:var(--page-width);margin:auto;padding:44px 0 80px}.back-link{display:inline-block;font-size:12px;text-decoration:none;color:var(--sys-color-text-subtle);margin-bottom:36px}.detail-heading{display:flex;align-items:center;gap:24px;padding-bottom:40px;border-bottom:1px solid var(--sys-color-border-subtle)}.extension-icon.large{width:88px;height:88px;border-radius:var(--sys-radius-md);font-size:28px;flex-shrink:0;background:var(--sys-color-surface-base)}.detail-heading h1{font-size:clamp(30px,4vw,46px);overflow-wrap:anywhere}.detail-heading .button{margin-left:auto}.detail-layout{display:grid;grid-template-columns:minmax(0, 1fr) 290px;gap:64px;padding-top:40px}.detail-content h2{font-size:24px}.lead{color:var(--sys-color-text-subtle);font-size:16px;line-height:1.8}.badge{background:var(--sys-color-surface-muted);font-size:10px;border-radius:var(--sys-radius-xs);padding:4px 8px;white-space:nowrap}.distribution-panel{background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm);padding:24px;margin-top:24px}.distribution-title{display:flex;align-items:center;gap:14px}.distribution-title p{color:var(--sys-color-text-subtle);font-size:12px;margin-top:4px}.format-icon{font-family:var(--ref-typo-family-mono);background:var(--sys-color-surface-muted);border-radius:var(--sys-radius-xs);width:40px;height:40px;display:grid;place-items:center}.metadata{margin:24px 0;font-size:12px}.metadata>div{display:grid;grid-template-columns:140px minmax(0, 1fr);padding:9px 0;border-top:1px solid var(--sys-color-border-subtle);gap:16px}.metadata dt{color:var(--sys-color-text-subtle)}.metadata dd{margin:0;overflow-wrap:anywhere}.info-note{padding:20px;border-left:2px solid var(--sys-color-border-subtle);margin-top:28px;font-size:12px;color:var(--sys-color-text-subtle)}.info-note strong{color:var(--sys-color-text-base);font-weight:500}.info-note p{margin-top:6px}.sidebar-panel{border-bottom:1px solid var(--sys-color-border-subtle);padding-bottom:24px;margin-bottom:24px}.sidebar-panel h2{font-size:32px;margin:12px 0 8px;overflow-wrap:anywhere}.sidebar-panel p:not(.eyebrow){font-size:12px;color:var(--sys-color-text-subtle);margin-bottom:20px}.sidebar-panel h3{font-size:14px}.version-list{list-style:none;padding:0;margin:16px 0 0;max-height:400px;overflow:auto}.version-list a{display:flex;justify-content:space-between;gap:8px;text-decoration:none;font-size:12px;padding:10px;border-radius:var(--sys-radius-xs);overflow-wrap:anywhere}.version-list a[aria-current]{background:var(--sys-color-surface-muted)}.version-list span{font-size:10px;color:var(--sys-color-text-subtle)}.publisher-heading{margin:16px 0 48px}.publisher-heading h1{font-size:clamp(40px,5vw,60px);margin:20px 0}.connect-layout{display:grid;grid-template-columns:1fr 1fr;gap:80px;align-items:center}.connect-card{padding:32px;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm)}.connect-card h2{margin:12px 0}.connect-card>p:not(.eyebrow){color:var(--sys-color-text-subtle);font-size:13px}.connect-card form{margin-top:24px}label{display:block;font-size:12px;font-weight:500;margin-bottom:7px}.connect-card input{width:100%}.field-help{color:var(--sys-color-text-subtle);font-size:11px;margin-top:8px}.connect-card button{margin-top:24px;width:100%}.steps{list-style:none;padding:0;margin:28px 0}.steps li{display:flex;gap:20px;margin-bottom:28px}.steps li>span{font-family:var(--ref-typo-family-mono);color:var(--sys-color-text-subtle);font-size:12px;padding-top:3px}.steps h3{font-size:16px}.steps p{color:var(--sys-color-text-subtle);font-size:13px;margin-top:8px}.workspace-heading,.workspace-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:28px}.workspace-heading{border-bottom:1px solid var(--sys-color-border-subtle);padding-bottom:24px}.workspace-heading h2{margin-top:8px}.workspace-toolbar>div{display:flex;gap:8px}.release-row{display:flex;align-items:center;justify-content:space-between;gap:24px;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm);padding:20px;margin-bottom:12px}.release-row h3{font-size:16px;overflow-wrap:anywhere}.release-row p{color:var(--sys-color-text-subtle);font-size:12px;margin:6px 0;overflow-wrap:anywhere}.release-actions{display:flex;gap:8px;flex-wrap:wrap}.form-status{font-size:12px;margin-top:12px;overflow-wrap:anywhere}.form-status.error{color:var(--sys-color-feedback-error)}.form-status:empty{display:none}dialog{color:var(--sys-color-text-base);background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-md);padding:28px;width:min(620px,100% - 32px);max-height:calc(100dvh - 48px);overflow:auto}dialog::backdrop{background:rgba(0,0,0,.5333333333)}.dialog-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:24px}.dialog-heading h2{margin-top:8px;font-size:24px}.icon-button{background:rgba(0,0,0,0);border:1px solid var(--sys-color-border-subtle);width:32px;height:32px;flex-shrink:0;font-size:24px;display:grid;place-items:center;line-height:1;border-radius:var(--sys-radius-xs)}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.form-grid input,dialog textarea{width:100%}.full-width{grid-column:1/-1}dialog .info-note{margin:24px 0}dialog form>.button{margin-top:20px}#action-description{margin-bottom:24px;font-size:13px;color:var(--sys-color-text-subtle)}.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);padding:12px 20px;border-radius:var(--sys-radius-sm);background:var(--sys-color-surface-primary);color:var(--sys-color-text-primary);font-size:12px;z-index:100}.toast:empty{display:none}@media(min-width: 1500px){.hero{min-height:510px}}@media(max-width: 1240px){.header-inner,.hero,.catalog-section,.builder-callout,.site-footer,.page-container{margin-left:32px;margin-right:32px}}@media(max-width: 900px){.hero{grid-template-columns:1.2fr 1fr;gap:0}.hero-art{transform:scale(0.85);transform-origin:right}.section-note{display:none}.catalog-grid{grid-template-columns:repeat(2, minmax(0, 1fr))}.detail-layout{gap:32px;grid-template-columns:minmax(0, 1fr) 220px}.connect-layout{gap:32px}.builder-callout{padding:28px}}@media(max-width: 640px){.header-inner,.hero,.catalog-section,.builder-callout,.site-footer,.page-container{margin-left:20px;margin-right:20px}.header-inner{min-height:72px;gap:16px}.brand{font-size:16px;gap:8px}.brand-label{display:none}nav{gap:20px}nav .docs-link{display:none}.hero{grid-template-columns:1fr;padding:48px 0 36px}.hero h1{font-size:44px}.hero-art{height:260px;transform:none;margin-top:32px}.hero-description{font-size:14px}.art-core{width:76px;height:76px;font-size:54px}.art-tile{width:48px;height:48px}.tile-code{top:20px}.tile-mail{bottom:30px}.tile-plus{bottom:26px}.orbit-one{width:270px;height:140px}.orbit-two{width:180px;height:210px}.catalog-section{padding-top:28px;padding-bottom:36px}h2{font-size:23px}.search-form{flex-wrap:wrap;gap:10px}.search-input{flex-basis:100%}.search-form select{flex:1}.search-form .button{min-width:88px}.catalog-grid{grid-template-columns:1fr;gap:12px}.extension-card{min-height:222px;padding:20px;gap:20px}.builder-callout{flex-direction:column;align-items:flex-start;margin-bottom:36px;padding:24px}.builder-callout .eyebrow{font-size:8px}.site-footer{flex-direction:column;padding-top:24px}.site-footer>div{justify-content:space-between}.detail-heading{flex-wrap:wrap;gap:16px}.extension-icon.large{width:64px;height:64px;font-size:22px}.detail-heading>div:nth-child(2){flex:1;min-width:0}.detail-heading .button{margin:8px 0 0}.detail-layout{grid-template-columns:1fr}.detail-content .section-heading{align-items:flex-start}.detail-content h2{font-size:22px}.metadata>div{grid-template-columns:100px minmax(0, 1fr)}.distribution-panel{padding:20px}.detail-sidebar{border-top:1px solid var(--sys-color-border-subtle);padding-top:24px}.page-container{padding:32px 0 48px}.publisher-heading{margin-bottom:32px}.connect-layout{grid-template-columns:1fr;gap:40px}.connect-card{padding:24px}.workspace-toolbar{align-items:flex-start;flex-direction:column}.release-row{align-items:flex-start;flex-direction:column;gap:12px}.form-grid{grid-template-columns:1fr}dialog{padding:20px}.search-summary{flex-wrap:wrap}.lead{font-size:14px}}@media(prefers-reduced-motion: reduce){html{scroll-behavior:auto}.extension-card{transition:none}}.release-pagination{display:flex;margin-top:24px;gap:16px;justify-content:flex-end;font-size:12px}.release-pagination button:disabled,.release-actions button:disabled{cursor:default} diff --git a/src/inkcre_extension_registry/service/static/registry.js b/src/inkcre_extension_registry/service/static/registry.js new file mode 100644 index 0000000..e21995d --- /dev/null +++ b/src/inkcre_extension_registry/service/static/registry.js @@ -0,0 +1,398 @@ +/* Browser enhancements share the Python Worker's origin and release API. */ +const $ = (selector) => document.querySelector(selector) +const announce = (message) => { + $('#announcement').textContent = message + clearTimeout(announce.timer) + announce.timer = setTimeout(() => { + $('#announcement').textContent = '' + }, 4500) +} + +const themeButton = $('.theme-button') +function applyTheme(theme) { + if (theme) document.documentElement.dataset.theme = theme + const dark = theme === 'dark' || (!theme && matchMedia('(prefers-color-scheme: dark)').matches) + themeButton?.setAttribute('aria-label', `Switch to ${dark ? 'light' : 'dark'} theme`) +} +try { + applyTheme(localStorage.getItem('registry-theme')) +} catch { + applyTheme(null) +} +themeButton?.addEventListener('click', () => { + const dark = + document.documentElement.dataset.theme === 'dark' || + (!document.documentElement.dataset.theme && matchMedia('(prefers-color-scheme: dark)').matches) + const theme = dark ? 'light' : 'dark' + applyTheme(theme) + try { + localStorage.setItem('registry-theme', theme) + } catch { + /* Theme still works for this page. */ + } +}) +document.addEventListener('keydown', (event) => { + if ( + event.key === '/' && + !event.ctrlKey && + !event.metaKey && + !event.target.closest('input, textarea, select, [contenteditable]') && + $('#search') + ) { + event.preventDefault() + $('#search').focus() + } +}) +for (const button of document.querySelectorAll('.copy-button')) { + button.addEventListener('click', async () => { + try { + await navigator.clipboard.writeText(button.dataset.copy) + announce('Extension ID copied.') + } catch { + announce('Couldn’t copy. Select the extension ID above to copy it manually.') + } + }) +} + +if ($('#connect-form')) { + let credential = '' + let namespace = '' + let offset = 0 + let nextOffset = null + let connection = new AbortController() + let action = null + const status = (selector, message, error = false) => { + const element = $(selector) + element.textContent = message + element.classList.toggle('error', error) + } + const element = (tag, text, className) => { + const node = document.createElement(tag) + if (text) node.textContent = text + if (className) node.className = className + return node + } + function disconnect() { + connection.abort() + connection = new AbortController() + credential = '' + namespace = '' + offset = 0 + nextOffset = null + action = null + $('#token').value = '' + $('#namespace-name').textContent = '' + $('#release-list').replaceChildren() + $('#workspace').hidden = true + $('#connect-panel').hidden = false + for (const dialog of document.querySelectorAll('dialog')) dialog.close() + } + async function api(path, { method = 'GET', body } = {}) { + const headers = { Authorization: `Bearer ${credential}` } + if (body && !(body instanceof FormData)) { + headers['Content-Type'] = 'application/json' + body = JSON.stringify(body) + } + let response + try { + response = await fetch(path, { + method, + headers, + body, + cache: 'no-store', + credentials: 'omit', + signal: AbortSignal.any([connection.signal, AbortSignal.timeout(60000)]), + }) + } catch (error) { + if (error.name === 'AbortError') throw error + throw new Error( + 'The Registry could not be reached. If you were making a change, refresh to check its state before retrying.', + ) + } + const data = await response.json().catch(() => null) + if (!response.ok) { + if (response.status === 401) { + disconnect() + status( + '#connect-status', + 'Your credential wasn’t accepted. Reconnect with a valid publisher credential.', + true, + ) + } + const detail = Array.isArray(data?.detail) + ? data.detail + .map((item) => `${item.loc?.slice(1).join(' / ') || 'Release'}: ${item.msg}`) + .join('; ') + : data?.detail + throw new Error( + typeof detail === 'string' + ? detail + : `Request failed (${response.status}). Please refresh and try again.`, + ) + } + return data + } + function openAction(release, kind) { + action = { release, kind } + const descriptions = { + publish: [ + 'Publish this release?', + 'This version will be public and appear in the catalog. At least one distribution must have been uploaded successfully.', + 'Publish release', + ], + yank: [ + 'Withdraw this release?', + 'This version will leave the catalog and new discovery. Existing distribution URLs remain available; withdrawal does not uninstall it from anyone’s workspace.', + 'Withdraw release', + ], + unyank: [ + 'Restore this release?', + 'This version will be available for discovery again.', + 'Restore release', + ], + 'module-federation': [ + 'Upload a Web snapshot', + 'Upload the built ZIP for this exact version. An identical retry is safe; different bytes need a new version.', + 'Upload snapshot', + ], + } + const [title, description, label] = descriptions[kind] + $('#action-title').textContent = title + $('#action-description').textContent = `${release.name} · ${release.version}. ${description}` + $('#confirm-action').textContent = label + $('#reason-field').hidden = kind !== 'yank' + $('#yank-reason').required = kind === 'yank' + $('#upload-field').hidden = kind !== 'module-federation' + $('#snapshot').required = kind === 'module-federation' + $('#action-form').reset() + status('#action-status', '') + $('#action-dialog').showModal() + } + function renderWorkspace(data) { + namespace = data.namespace + offset = data.offset + nextOffset = data.next_offset + $('#previous-page').disabled = offset === 0 + $('#next-page').disabled = nextOffset === null + $('#page-position').textContent = `Page ${Math.floor(offset / 10) + 1}` + $('#release-pagination').hidden = offset === 0 && nextOffset === null + $('#namespace-name').textContent = namespace + $('#release-count').textContent = `${data.releases.length} on this page` + const list = $('#release-list') + list.replaceChildren() + if (!data.releases.length) { + const empty = element('div', null, 'empty-state') + empty.append( + element('h3', 'Your next release starts here.'), + element( + 'p', + 'Prepare a Web release here, or publish a Python distribution with the Toolkit.', + ), + ) + list.append(empty) + } + for (const release of data.releases) { + const row = element('article', null, 'release-row') + const info = element('div') + info.append( + element('h3', release.nickname), + element('p', `${release.name} · ${release.version}`), + ) + const state = { + preparing: 'Preparing', + published: 'Published', + yanked: 'Withdrawn', + blocked: 'Blocked by operator', + }[release.state] + info.append(element('span', state, 'badge')) + const distributions = [release.python && 'Python', release.module_federation && 'Web'] + .filter(Boolean) + .join(' + ') + const uploaded = [ + release.python_uploaded && 'Python uploaded', + release.web_uploaded && 'Web uploaded', + ] + .filter(Boolean) + .join(' · ') + info.append(element('p', uploaded || `${distributions} declared · awaiting upload`)) + const actions = element('div', null, 'release-actions') + const button = (label, kind) => { + const node = element('button', label, 'button') + node.type = 'button' + node.addEventListener('click', () => openAction(release, kind)) + actions.append(node) + return node + } + if (release.state === 'preparing') { + if (release.module_federation) button('Upload Web ZIP', 'module-federation') + const publish = button('Publish', 'publish') + publish.disabled = !release.python_uploaded && !release.web_uploaded + if (publish.disabled) publish.title = 'Upload a distribution before publishing' + } + if (release.state === 'published') { + const link = element('a', 'View release ↗', 'button') + link.href = `/explore/${release.name}?version=${encodeURIComponent(release.version)}` + actions.append(link) + button('Withdraw', 'yank') + } + if (release.state === 'yanked') button('Restore', 'unyank') + row.append(info, actions) + list.append(row) + } + $('#connect-panel').hidden = true + $('#workspace').hidden = false + } + async function refresh(pageOffset = offset) { + const current = connection + const data = await api(`/v1/publisher?offset=${pageOffset}`) + if (current !== connection || current.signal.aborted) return + renderWorkspace(data) + } + async function submit(form, statusSelector, work) { + const button = form.querySelector('[type="submit"]') + const dialog = form.closest('dialog') + const close = dialog?.querySelector('.close-dialog') + const original = button.textContent + button.disabled = true + button.textContent = 'Working…' + if (close) close.disabled = true + form.setAttribute('aria-busy', 'true') + status(statusSelector, '') + try { + await work() + } catch (error) { + if (error.name !== 'AbortError') status(statusSelector, error.message, true) + } finally { + button.disabled = false + button.textContent = original + if (close) close.disabled = false + form.removeAttribute('aria-busy') + } + } + $('#connect-form').addEventListener('submit', (event) => { + event.preventDefault() + submit(event.currentTarget, '#connect-status', async () => { + credential = $('#token').value.trim() + await refresh() + $('#token').value = '' + if (namespace) { + $('#new-release').focus() + announce('Publisher workspace connected.') + } + }) + }) + $('#disconnect').addEventListener('click', () => { + disconnect() + status('#connect-status', 'Disconnected. Credential cleared.') + $('#token').focus() + }) + window.addEventListener('pagehide', disconnect) + $('#refresh').addEventListener('click', async (event) => { + event.currentTarget.disabled = true + status('#workspace-status', 'Refreshing…') + try { + await refresh() + status('#workspace-status', 'Up to date.') + } catch (error) { + if (error.name !== 'AbortError') status('#workspace-status', error.message, true) + } finally { + $('#refresh').disabled = false + } + }) + for (const [selector, direction] of [ + ['#previous-page', -1], + ['#next-page', 1], + ]) { + $(selector).addEventListener('click', async () => { + const target = direction < 0 ? Math.max(0, offset - 10) : nextOffset + if (target === null) return + $('#previous-page').disabled = true + $('#next-page').disabled = true + status('#workspace-status', 'Loading releases…') + try { + await refresh(target) + status('#workspace-status', '') + } catch (error) { + if (error.name !== 'AbortError') status('#workspace-status', error.message, true) + } finally { + $('#previous-page').disabled = offset === 0 + $('#next-page').disabled = nextOffset === null + } + }) + } + $('#new-release').addEventListener('click', () => { + $('#release-form').reset() + status('#release-status', '') + $('#release-dialog').showModal() + }) + for (const dialog of document.querySelectorAll('dialog')) { + dialog.querySelector('.close-dialog').addEventListener('click', () => dialog.close()) + dialog.addEventListener('cancel', (event) => { + if (dialog.querySelector('[aria-busy="true"]')) event.preventDefault() + }) + } + async function updateAfterChange(message) { + announce(message) + status('#workspace-status', message) + try { + await refresh() + } catch (error) { + if (error.name !== 'AbortError') + status( + '#workspace-status', + `${message} Couldn’t refresh the list. Use Refresh to check its current state.`, + true, + ) + } + } + $('#release-form').addEventListener('submit', (event) => { + event.preventDefault() + const form = event.currentTarget + submit(form, '#release-status', async () => { + const fields = new FormData(form) + const value = (key) => fields.get(key).trim() + await api(`/v1/extensions/${namespace}/${encodeURIComponent(value('slug'))}/releases`, { + method: 'POST', + body: { + nickname: value('nickname'), + version: value('version'), + module_federation: { + host_sdk: '@inkcre/core', + host_sdk_version: value('host_range'), + source_repository: value('repository'), + source_revision: value('revision'), + }, + }, + }) + $('#release-dialog').close() + await updateAfterChange('Release prepared. Upload your Web ZIP, then publish when ready.') + }) + }) + $('#action-form').addEventListener('submit', (event) => { + event.preventDefault() + const { release, kind } = action + submit(event.currentTarget, '#action-status', async () => { + let body + if (kind === 'yank') body = { reason: $('#yank-reason').value.trim() } + if (kind === 'module-federation') { + const file = $('#snapshot').files[0] + if (!file || file.size > 20 * 1024 * 1024 - 4096) + throw new Error('Choose a ZIP smaller than 20 MiB, including upload overhead.') + body = new FormData() + body.append('content', file) + } + await api( + `/v1/extensions/${release.name}/releases/${encodeURIComponent(release.version)}/${kind}`, + { method: 'POST', body }, + ) + $('#action-dialog').close() + const messages = { + publish: 'Release published. It’s now in the catalog.', + yank: 'Release withdrawn from discovery.', + unyank: 'Release restored to the catalog.', + 'module-federation': 'Snapshot uploaded. Your release is ready to publish.', + } + await updateAfterChange(messages[kind]) + }) + }) +} diff --git a/src/inkcre_extension_registry/service/templates/base.html b/src/inkcre_extension_registry/service/templates/base.html new file mode 100644 index 0000000..964c7d8 --- /dev/null +++ b/src/inkcre_extension_registry/service/templates/base.html @@ -0,0 +1,30 @@ + + + + + + + {% if noindex %}{% endif %} + {{ title }} · InKCre Extension Registry + + {% if not preview %}{% endif %} + + + + + {% if preview %}
Design preview · Sample catalog. Extension links open the live Registry.
{% endif %} +
{% block content %}{% endblock %}
+ +
+ + diff --git a/src/inkcre_extension_registry/service/templates/catalog.html b/src/inkcre_extension_registry/service/templates/catalog.html new file mode 100644 index 0000000..a18868d --- /dev/null +++ b/src/inkcre_extension_registry/service/templates/catalog.html @@ -0,0 +1,23 @@ +{% extends "base.html" %} +{% block content %} +
+

THE INKCRE EXTENSION REGISTRY

Your workspace.
More possibilities.

Bring your tools, sources, and ideas together.
Find the next addition to your InKCre.

Find your extension
+ +
+
+

MADE FOR YOUR WORKSPACE

Explore extensions {{ total }}

Independent tools. Connected possibilities.
+ {% if not preview %} + + {% if query or namespace %}

{{ extensions | length }} result{{ '' if extensions | length == 1 else 's' }}{% if query %} for “{{ query }}”{% endif %}{% if namespace %} by {{ namespace }}{% endif %}Clear filters

{% endif %} + {% endif %} + {% if extensions %} + + {% else %}

{% if total %}Nothing here just yet.{% else %}Be the first to build something useful.{% endif %}

{% if total %}Try another name or choose a different publisher.{% else %}Published extensions will appear here, ready for everyone to discover.{% endif %}

{% if total %}Clear filters{% else %}Publish an extension{% endif %}
{% endif %} +
+

BUILT BY YOU. SHARED WITH EVERYONE.

Have something to add?

Give your idea a place in someone else’s workspace.

Publish an extension
+{% endblock %} diff --git a/src/inkcre_extension_registry/service/templates/detail.html b/src/inkcre_extension_registry/service/templates/detail.html new file mode 100644 index 0000000..73aa64f --- /dev/null +++ b/src/inkcre_extension_registry/service/templates/detail.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} +{% block content %} +
+ ← All extensions +
{{ publisher }}

{{ extension.nickname }}

{{ extension.name }}

+
+
+

Make it part of your workspace.

Published
+

Use this extension’s ID in your InKCre deployment. Choose a distribution below to inspect its package and host requirements.

+ {% if release.python %}

Python

For your Core host

Browse package files ↗
{% endif %} + {% if release.module_federation %}

Web

For your browser host

View manifest ↗
{% endif %} + +
+ +
+
+{% endblock %} diff --git a/src/inkcre_extension_registry/service/templates/error.html b/src/inkcre_extension_registry/service/templates/error.html new file mode 100644 index 0000000..d0097b1 --- /dev/null +++ b/src/inkcre_extension_registry/service/templates/error.html @@ -0,0 +1,2 @@ +{% extends "base.html" %} +{% block content %}
← All extensions

{{ code }}

{{ title }}

{{ message }}

Explore extensions →
{% endblock %} diff --git a/src/inkcre_extension_registry/service/templates/publish.html b/src/inkcre_extension_registry/service/templates/publish.html new file mode 100644 index 0000000..2e7220a --- /dev/null +++ b/src/inkcre_extension_registry/service/templates/publish.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block content %} +
+

THE PUBLISHER WORKSPACE

Made by you.
Ready for everyone.

Take your extension from a local idea to a published release.

+ +

YOUR WORKSPACE

Connect as a publisher

Use the credential issued for your publisher namespace.

Kept in this tab’s memory. Reloading or disconnecting clears it.

A CLEAR PATH TO PUBLISHED

  1. 01

    Prepare a release

    Give your extension a name, version, and source.

  2. 02

    Add your distribution

    Upload a Web snapshot here, or use the Toolkit for Python.

  3. 03

    Make it discoverable

    Review and publish. Manage your releases in one place.

New publisher? Request a namespace ↗

+ +

NEW WEB RELEASE

Give your idea a home.

Lowercase letters, numbers, and hyphens.

SemVer, such as 1.0.0 or 1.0.0-rc.1.

Supported @inkcre/core SemVer range.

Preparing reserves this version and its source metadata. Upload a ZIP with mf-manifest.json at its root next. You’ll publish in a separate step.

+

+
+{% endblock %} diff --git a/src/inkcre_extension_registry/service/ui.py b/src/inkcre_extension_registry/service/ui.py index a6efdfc..b0b2062 100644 --- a/src/inkcre_extension_registry/service/ui.py +++ b/src/inkcre_extension_registry/service/ui.py @@ -1,10 +1,22 @@ from __future__ import annotations from collections.abc import Sequence -from html import escape +from pathlib import Path from urllib.parse import urlsplit -from ..contracts.models import ExtensionSummary +from fastapi.responses import HTMLResponse +from jinja2 import Environment, FileSystemLoader, select_autoescape +from semantic_version import Version + +from ..contracts.models import ExtensionRecord, ExtensionSummary, ReleaseRecord + +ROOT = Path(__file__).parent +TEMPLATES = Environment( + loader=FileSystemLoader(ROOT / "templates"), + autoescape=select_autoescape(["html"]), +) +STYLES = (ROOT / "static" / "registry.css").read_text() +SCRIPT = (ROOT / "static" / "registry.js").read_text() def _canonical_api_origin(value: str) -> str: @@ -22,104 +34,75 @@ def _canonical_api_origin(value: str) -> str: return value +def page(template: str, **context: object) -> str: + return TEMPLATES.get_template(template).render(styles=STYLES, **context) + + +def html_response(document: str, *, status_code: int = 200) -> HTMLResponse: + return HTMLResponse( + document, + status_code=status_code, + headers={ + "Cache-Control": "no-store", + "Content-Security-Policy": ( + "default-src 'none'; style-src 'unsafe-inline'; script-src 'self'; " + "connect-src 'self'; img-src 'self' data:; base-uri 'none'; " + "form-action 'self'; frame-ancestors 'none'" + ), + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + }, + ) + + def extension_catalog_html( extensions: Sequence[ExtensionSummary], *, api_origin: str | None = None, noindex: bool = False, + query: str = "", + namespace: str = "", ) -> str: origin = _canonical_api_origin(api_origin) if api_origin is not None else "" - cards = "".join( - ( - f'
  • ' - f'{escape(extension.nickname)}' - f'{escape(extension.name)}' - "
  • " - ) + publishers = sorted({extension.name.split("/")[0] for extension in extensions}) + filtered = [ + extension for extension in extensions + if query.casefold() in f"{extension.name} {extension.nickname}".casefold() + and (not namespace or extension.name.split("/")[0] == namespace) + ] + return page( + "catalog.html", + title="Extensions", + active="catalog", + origin=origin, + noindex=noindex, + preview=api_origin is not None, + extensions=filtered, + total=len(extensions), + publishers=publishers, + query=query, + namespace=namespace, + ) + + +def extension_detail_html(extension: ExtensionRecord, version: str | None = None) -> str | None: + releases = sorted(extension.releases, key=lambda item: Version(item.version), reverse=True) + release: ReleaseRecord | None = next( + (item for item in releases if item.version == version), None ) - catalog = ( - f'
      {cards}
    ' - if cards - else ( - '
    ' - "

    No Extensions published yet.

    " - "Published Releases will appear here." - "
    " + if version is not None and release is None: + return None + if release is None: + release = next( + (item for item in releases if not Version(item.version).prerelease), releases[0] ) + return page( + "detail.html", + title=extension.nickname, + active="catalog", + extension=extension, + release=release, + releases=releases, + publisher=extension.name.split("/")[0], ) - robots = '\n ' if noindex else "" - return f""" - - - - - {robots} - InKCre Extension Registry - - - -
    -
    -

    InKCre · Public Registry

    -

    Extensions

    -

    Published Extensions for InKCre peers, distributed through native - Python and Module Federation package surfaces.

    -
    -
    -

    Available now

    - {catalog} -
    - -
    - - -""" diff --git a/tasks/registry-web-lovable/packet.md b/tasks/registry-web-lovable/packet.md new file mode 100644 index 0000000..e663e3a --- /dev/null +++ b/tasks/registry-web-lovable/packet.md @@ -0,0 +1,26 @@ +# Registry Web:Minimum Lovable Product + +## 目标与授权 + +Sir 要求将 Registry Web 提升为可喜爱、可持续维护的产品,允许加入 Publisher 端;复用 ../design,保持 Python 与单一部署流程。已授权独立分支/worktree、实现、提交、推送和 PR;不包含生产资源变更或合并。 + +- 工作树:`.worktrees/registry-web/ext-reg`,分支 `feat/registry-web-lovable`,基线 `422a6cb`。 +- 本 packet 是唯一任务控制入口;原仓库分叉及未提交内容保留。 + +## 方案与边界 + +- 静态名称列表 → Python/Jinja 服务端目录、搜索/发布者筛选、版本详情和 Publisher 工作台。 +- 直接消费已发布的 `@inkcre/ui-web` Sass 公共接口,构建 CSS 随 Python 包/Worker 交付。无独立前端服务。 +- Publisher 复用 namespace bearer 凭证与既有 prepare/upload/publish/yank/unyank 协议;增加自己的私有版本读取接口。浏览器支持 Module Federation ZIP 发布,Python 原生发布仍交给现有 Toolkit。 +- 不改变 Release/installed/enabled/running 权威,不改变公共 API 既有语义,不修改 D1 schema。 +- 资产/边界:发布者凭证授予命名空间写权限;公开昵称是外部数据。模板自动转义、浏览器 textContent、同源请求、仅内存凭证避免将公开元数据变成脚本或凭证泄露路径。依据 Hub security-boundary-model 和本地 registry-control-plane。 +- 静态 Pages preview 保持无凭证、只读目录性质;详情/发布指向生产,不声称运行候选 API。 + +## 验收与当前进度 + +1. 完成目录、详情、Publisher 浏览器流程,空态、失败态、移动端和键盘可用。 +2. 真正消费 design 样式,检查浅/深色与窄屏页面。 +3. 完整 `pnpm check`、Worker 本地 D1/R2 黑盒验证、构建包包含模板/资源、`git diff --check`。 +4. 更新拥有长期事实的本地文档,提交推送 PR,观察 CI 并修复本次失败。 + +当前:已完成仓库/设计/治理/原生协议调查,正在安装依赖并实现。待验证 Jinja 打包与 Worker 运行,GitHub Packages CI 读取权限。暂无需 Sir 决策项。 diff --git a/web/registry.scss b/web/registry.scss new file mode 100644 index 0000000..2420e70 --- /dev/null +++ b/web/registry.scss @@ -0,0 +1,1193 @@ +@use 'pkg:@inkcre/ui-web/styles'; + +:root { + --page-width: 1160px; +} +* { + box-sizing: border-box; +} +html { + scroll-behavior: smooth; +} +body { + margin: 0; + background: var(--sys-color-surface-subtle); + color: var(--sys-color-text-base); + font-family: var(--ref-typo-family-sans); + font-size: 15px; + line-height: 1.6; + -webkit-font-smoothing: antialiased; +} +a { + color: inherit; + text-underline-offset: 4px; +} +button, +input, +select, +textarea { + font: inherit; +} +button, +a, +input, +select, +textarea { + -webkit-tap-highlight-color: transparent; +} +button, +select { + cursor: pointer; +} +button:disabled { + cursor: wait; + opacity: 0.6; +} +button { + color: inherit; +} +:focus-visible { + outline: 2px solid var(--sys-color-border-strong); + outline-offset: 4px; +} +[hidden] { + display: none !important; +} +h1, +h2, +h3, +p { + margin: 0; +} +h1, +h2, +h3 { + line-height: 1.2; + font-weight: 550; +} +h1 { + letter-spacing: -0.055em; +} +h2 { + font-size: 26px; + letter-spacing: -0.035em; +} +h3 { + font-size: 18px; + letter-spacing: -0.02em; +} +code, +.extension-name, +.art-caption, +.eyebrow, +kbd, +.publisher-name { + font-family: var(--ref-typo-family-mono); +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; +} +.skip-link { + position: fixed; + top: -100px; + left: 16px; + padding: 12px; + background: var(--sys-color-surface-base); + z-index: 20; +} +.skip-link:focus { + top: 12px; +} +.site-header { + border-bottom: 1px solid var(--sys-color-border-subtle); + background: var(--sys-color-surface-subtle); +} +.header-inner { + max-width: var(--page-width); + min-height: 88px; + margin: auto; + display: flex; + align-items: center; + gap: 36px; +} +.brand { + display: flex; + align-items: center; + gap: 12px; + text-decoration: none; + font-weight: 650; + font-size: 17px; + letter-spacing: -0.04em; +} +.brand-mark, +.art-core { + background: var(--sys-color-surface-primary); + color: var(--sys-color-text-primary); + font-weight: 500; + letter-spacing: -0.13em; +} +.brand-mark { + width: 36px; + height: 36px; + display: grid; + grid-auto-flow: column; + justify-content: center; + align-items: center; + font-size: 26px; + padding-right: 4px; +} +.brand-mark span, +.art-core span { + font-style: italic; +} +.brand-label { + color: var(--sys-color-text-subtle); + margin-left: 8px; + padding-left: 13px; + border-left: 1px solid var(--sys-color-border-subtle); + font-size: 14px; + font-weight: 400; + letter-spacing: 0; +} +nav { + display: flex; + align-self: stretch; + align-items: center; + gap: 32px; + margin-left: auto; +} +nav a { + text-decoration: none; + font-size: 13px; + color: var(--sys-color-text-subtle); + height: 100%; + display: flex; + align-items: center; + gap: 8px; + border-bottom: 2px solid transparent; + padding-top: 2px; +} +nav a[aria-current] { + color: var(--sys-color-text-base); + border-color: var(--sys-color-border-strong); +} +.theme-button { + border: 1px solid var(--sys-color-border-subtle); + background: transparent; + border-radius: var(--sys-radius-full); + width: 32px; + height: 32px; + display: grid; + place-items: center; + font-size: 20px; + line-height: 1; +} +.hero { + max-width: var(--page-width); + margin: auto; + min-height: 462px; + display: grid; + grid-template-columns: 1.15fr 1fr; + align-items: center; + gap: 40px; + padding: 64px 0; +} +.eyebrow { + font-size: 10px; + letter-spacing: 0.12em; + font-weight: 500; + color: var(--sys-color-text-subtle); + display: flex; + align-items: center; + gap: 8px; +} +.tiny-square { + display: inline-block; + width: 5px; + height: 5px; + background: currentColor; + flex: none; +} +.hero h1 { + font-size: clamp(42px, 4.8vw, 64px); + line-height: 1.08; + margin-top: 24px; +} +.hero h1 > span, +.publisher-heading h1 > span { + color: var(--sys-color-text-subtle); +} +.hero-description { + margin-top: 24px; + color: var(--sys-color-text-subtle); + font-size: 16px; + line-height: 1.8; +} +.text-link { + font-size: 13px; + text-decoration: none; + display: inline-flex; + gap: 18px; + align-items: center; + font-weight: 500; +} +.text-link:hover { + text-decoration: underline; +} +.hero .text-link { + margin-top: 24px; +} +.hero-art { + position: relative; + height: 330px; + background-image: radial-gradient(var(--sys-color-border-subtle) 0.7px, transparent 0.7px); + background-size: 20px 20px; + mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent); +} +.orbit { + position: absolute; + border: 1px solid var(--sys-color-border-subtle); + border-radius: 50%; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) rotate(-25deg); +} +.orbit-one { + width: 320px; + height: 170px; +} +.orbit-two { + width: 220px; + height: 270px; + transform: translate(-50%, -50%) rotate(40deg); +} +.art-core { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%) rotate(-8deg); + width: 98px; + height: 98px; + border-radius: 22px; + display: flex; + align-items: center; + justify-content: center; + font-size: 72px; + padding-right: 7px; + box-shadow: 10px 12px 0 var(--sys-color-surface-muted); +} +.art-tile { + position: absolute; + display: grid; + place-items: center; + background: var(--sys-color-surface-base); + border: 1px solid var(--sys-color-border-subtle); + width: 60px; + height: 60px; + border-radius: var(--sys-radius-md); + font-family: var(--ref-typo-family-mono); + font-size: 22px; +} +.tile-code { + top: 36px; + left: 26%; + transform: rotate(-12deg); +} +.tile-feed { + top: 100px; + right: 8%; + transform: rotate(12deg); + font-size: 38px; +} +.tile-mail { + bottom: 46px; + left: 15%; + transform: rotate(8deg); +} +.tile-plus { + bottom: 43px; + right: 21%; + transform: rotate(-10deg); +} +.art-caption { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + text-align: center; + font-size: 8px; + letter-spacing: 0.15em; + color: var(--sys-color-text-subtle); +} +.catalog-section { + max-width: var(--page-width); + margin: auto; + padding: 36px 0 64px; + border-top: 1px solid var(--sys-color-border-subtle); + scroll-margin-top: 24px; +} +.section-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + margin-bottom: 24px; +} +.section-heading .eyebrow { + margin-bottom: 12px; +} +.count { + display: inline-block; + vertical-align: middle; + font-size: 11px; + font-weight: 400; + padding: 2px 7px; + border: 1px solid var(--sys-color-border-subtle); + border-radius: 5px; + letter-spacing: 0; + margin-left: 8px; + line-height: 1.5; +} +.section-note { + font-size: 12px; + color: var(--sys-color-text-subtle); +} +.search-form { + display: flex; + gap: 12px; + margin: 28px 0; +} +.search-input { + display: flex; + align-items: center; + flex: 1; + background: var(--sys-color-surface-base); + border: 1px solid var(--sys-color-border-subtle); + padding: 0 14px; + gap: 12px; + border-radius: var(--sys-radius-xs); +} +.search-input:focus-within { + outline: 2px solid var(--sys-color-border-strong); + outline-offset: 2px; +} +.search-input > span { + font-size: 25px; +} +.search-input input { + border: 0; + background: transparent; + width: 100%; + padding: 11px 0; + outline: none; +} +.search-input kbd { + border: 1px solid var(--sys-color-border-subtle); + color: var(--sys-color-text-subtle); + border-radius: 3px; + padding: 0 6px; + font-size: 11px; +} +input, +select, +textarea { + color: var(--sys-color-text-base); + background: var(--sys-color-surface-base); + border: 1px solid var(--sys-color-border-subtle); + border-radius: var(--sys-radius-xs); + padding: 11px 12px; + min-width: 0; + max-width: 100%; +} +input::placeholder, +textarea::placeholder { + color: var(--sys-color-text-subtle); + opacity: 0.85; +} +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 18px; + padding: 11px 18px; + border: 1px solid var(--sys-color-border-subtle); + border-radius: var(--sys-radius-xs); + background: var(--sys-color-surface-base); + color: var(--sys-color-text-base); + text-decoration: none; + font-size: 12px; + font-weight: 500; + line-height: 1.5; + white-space: nowrap; +} +.button:hover { + background: var(--sys-color-surface-subtle-hover); +} +.button-primary { + background: var(--sys-color-surface-primary); + color: var(--sys-color-text-primary); + border-color: transparent; +} +.button-primary:hover { + background: var(--sys-color-surface-primary-hover); +} +.search-summary { + display: flex; + gap: 20px; + font-size: 13px; + margin-bottom: 20px; +} +.catalog-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 16px; + list-style: none; + padding: 0; + margin: 0; +} +.extension-card { + display: flex; + flex-direction: column; + gap: 24px; + min-height: 252px; + padding: 24px; + background: var(--sys-color-surface-base); + border: 1px solid var(--sys-color-border-subtle); + border-radius: var(--sys-radius-sm); + text-decoration: none; + transition: + transform 160ms, + border-color 160ms; +} +.extension-card:hover { + transform: translateY(-3px); + border-color: var(--sys-color-border-strong); +} +.card-top { + display: flex; + justify-content: space-between; + align-items: flex-start; +} +.extension-icon { + display: grid; + place-items: center; + width: 48px; + height: 48px; + border-radius: 12px; + background: var(--sys-color-surface-subtle); + border: 1px solid var(--sys-color-border-subtle); + font-size: 15px; + font-weight: 600; + letter-spacing: -0.08em; +} +.card-arrow { + color: var(--sys-color-text-subtle); + font-size: 19px; +} +.publisher-name { + font-size: 10px; + letter-spacing: 0.02em; + color: var(--sys-color-text-subtle); + margin-bottom: 7px; + text-decoration: none; +} +.extension-card h3 { + font-size: 20px; + overflow-wrap: anywhere; +} +.extension-name { + color: var(--sys-color-text-subtle); + font-size: 11px; + margin-top: 9px; + overflow-wrap: anywhere; +} +.card-bottom { + margin-top: auto; + padding-top: 17px; + border-top: 1px solid var(--sys-color-border-subtle); + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 10px; + color: var(--sys-color-text-subtle); +} +.card-bottom > span:first-child { + display: flex; + gap: 6px; + align-items: center; +} +.builder-callout { + max-width: var(--page-width); + margin: 0 auto 64px; + padding: 36px; + border: 1px solid var(--sys-color-border-subtle); + background: var(--sys-color-surface-muted); + border-radius: var(--sys-radius-sm); + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} +.builder-callout h2 { + margin: 12px 0 8px; +} +.builder-callout p:not(.eyebrow) { + color: var(--sys-color-text-subtle); + font-size: 13px; +} +.site-footer { + max-width: var(--page-width); + margin: auto; + padding: 28px 0 36px; + border-top: 1px solid var(--sys-color-border-subtle); + display: flex; + justify-content: space-between; + gap: 20px; + color: var(--sys-color-text-subtle); + font-size: 11px; +} +.site-footer > div { + display: flex; + align-items: center; + gap: 24px; +} +.site-footer a { + text-decoration: none; +} +.footer-wordmark { + font-size: 15px; + font-weight: 650; + letter-spacing: -0.05em; + color: var(--sys-color-text-base); +} +.empty-state { + text-align: center; + border: 1px dashed var(--sys-color-border-subtle); + border-radius: var(--sys-radius-sm); + padding: 56px 24px; +} +.empty-symbol { + font-size: 38px; + display: block; + margin-bottom: 16px; +} +.empty-state p { + color: var(--sys-color-text-subtle); + margin: 14px auto 24px; + max-width: 480px; +} +.empty-state .eyebrow { + justify-content: center; +} +.preview-banner { + text-align: center; + padding: 10px; + font-size: 12px; + background: var(--sys-color-surface-muted); +} +.page-container { + max-width: var(--page-width); + margin: auto; + padding: 44px 0 80px; +} +.back-link { + display: inline-block; + font-size: 12px; + text-decoration: none; + color: var(--sys-color-text-subtle); + margin-bottom: 36px; +} +.detail-heading { + display: flex; + align-items: center; + gap: 24px; + padding-bottom: 40px; + border-bottom: 1px solid var(--sys-color-border-subtle); +} +.extension-icon.large { + width: 88px; + height: 88px; + border-radius: var(--sys-radius-md); + font-size: 28px; + flex-shrink: 0; + background: var(--sys-color-surface-base); +} +.detail-heading h1 { + font-size: clamp(30px, 4vw, 46px); + overflow-wrap: anywhere; +} +.detail-heading .button { + margin-left: auto; +} +.detail-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 290px; + gap: 64px; + padding-top: 40px; +} +.detail-content h2 { + font-size: 24px; +} +.lead { + color: var(--sys-color-text-subtle); + font-size: 16px; + line-height: 1.8; +} +.badge { + background: var(--sys-color-surface-muted); + font-size: 10px; + border-radius: var(--sys-radius-xs); + padding: 4px 8px; + white-space: nowrap; +} +.distribution-panel { + background: var(--sys-color-surface-base); + border: 1px solid var(--sys-color-border-subtle); + border-radius: var(--sys-radius-sm); + padding: 24px; + margin-top: 24px; +} +.distribution-title { + display: flex; + align-items: center; + gap: 14px; +} +.distribution-title p { + color: var(--sys-color-text-subtle); + font-size: 12px; + margin-top: 4px; +} +.format-icon { + font-family: var(--ref-typo-family-mono); + background: var(--sys-color-surface-muted); + border-radius: var(--sys-radius-xs); + width: 40px; + height: 40px; + display: grid; + place-items: center; +} +.metadata { + margin: 24px 0; + font-size: 12px; +} +.metadata > div { + display: grid; + grid-template-columns: 140px minmax(0, 1fr); + padding: 9px 0; + border-top: 1px solid var(--sys-color-border-subtle); + gap: 16px; +} +.metadata dt { + color: var(--sys-color-text-subtle); +} +.metadata dd { + margin: 0; + overflow-wrap: anywhere; +} +.info-note { + padding: 20px; + border-left: 2px solid var(--sys-color-border-subtle); + margin-top: 28px; + font-size: 12px; + color: var(--sys-color-text-subtle); +} +.info-note strong { + color: var(--sys-color-text-base); + font-weight: 500; +} +.info-note p { + margin-top: 6px; +} +.sidebar-panel { + border-bottom: 1px solid var(--sys-color-border-subtle); + padding-bottom: 24px; + margin-bottom: 24px; +} +.sidebar-panel h2 { + font-size: 32px; + margin: 12px 0 8px; + overflow-wrap: anywhere; +} +.sidebar-panel p:not(.eyebrow) { + font-size: 12px; + color: var(--sys-color-text-subtle); + margin-bottom: 20px; +} +.sidebar-panel h3 { + font-size: 14px; +} +.version-list { + list-style: none; + padding: 0; + margin: 16px 0 0; + max-height: 400px; + overflow: auto; +} +.version-list a { + display: flex; + justify-content: space-between; + gap: 8px; + text-decoration: none; + font-size: 12px; + padding: 10px; + border-radius: var(--sys-radius-xs); + overflow-wrap: anywhere; +} +.version-list a[aria-current] { + background: var(--sys-color-surface-muted); +} +.version-list span { + font-size: 10px; + color: var(--sys-color-text-subtle); +} +.publisher-heading { + margin: 16px 0 48px; +} +.publisher-heading h1 { + font-size: clamp(40px, 5vw, 60px); + margin: 20px 0; +} +.connect-layout { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 80px; + align-items: center; +} +.connect-card { + padding: 32px; + background: var(--sys-color-surface-base); + border: 1px solid var(--sys-color-border-subtle); + border-radius: var(--sys-radius-sm); +} +.connect-card h2 { + margin: 12px 0; +} +.connect-card > p:not(.eyebrow) { + color: var(--sys-color-text-subtle); + font-size: 13px; +} +.connect-card form { + margin-top: 24px; +} +label { + display: block; + font-size: 12px; + font-weight: 500; + margin-bottom: 7px; +} +.connect-card input { + width: 100%; +} +.field-help { + color: var(--sys-color-text-subtle); + font-size: 11px; + margin-top: 8px; +} +.connect-card button { + margin-top: 24px; + width: 100%; +} +.steps { + list-style: none; + padding: 0; + margin: 28px 0; +} +.steps li { + display: flex; + gap: 20px; + margin-bottom: 28px; +} +.steps li > span { + font-family: var(--ref-typo-family-mono); + color: var(--sys-color-text-subtle); + font-size: 12px; + padding-top: 3px; +} +.steps h3 { + font-size: 16px; +} +.steps p { + color: var(--sys-color-text-subtle); + font-size: 13px; + margin-top: 8px; +} +.workspace-heading, +.workspace-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 28px; +} +.workspace-heading { + border-bottom: 1px solid var(--sys-color-border-subtle); + padding-bottom: 24px; +} +.workspace-heading h2 { + margin-top: 8px; +} +.workspace-toolbar > div { + display: flex; + gap: 8px; +} +.release-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; + background: var(--sys-color-surface-base); + border: 1px solid var(--sys-color-border-subtle); + border-radius: var(--sys-radius-sm); + padding: 20px; + margin-bottom: 12px; +} +.release-row h3 { + font-size: 16px; + overflow-wrap: anywhere; +} +.release-row p { + color: var(--sys-color-text-subtle); + font-size: 12px; + margin: 6px 0; + overflow-wrap: anywhere; +} +.release-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; +} +.form-status { + font-size: 12px; + margin-top: 12px; + overflow-wrap: anywhere; +} +.form-status.error { + color: var(--sys-color-feedback-error); +} +.form-status:empty { + display: none; +} +dialog { + color: var(--sys-color-text-base); + background: var(--sys-color-surface-base); + border: 1px solid var(--sys-color-border-subtle); + border-radius: var(--sys-radius-md); + padding: 28px; + width: min(620px, calc(100% - 32px)); + max-height: calc(100dvh - 48px); + overflow: auto; +} +dialog::backdrop { + background: #0008; +} +.dialog-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + margin-bottom: 24px; +} +.dialog-heading h2 { + margin-top: 8px; + font-size: 24px; +} +.icon-button { + background: transparent; + border: 1px solid var(--sys-color-border-subtle); + width: 32px; + height: 32px; + flex-shrink: 0; + font-size: 24px; + display: grid; + place-items: center; + line-height: 1; + border-radius: var(--sys-radius-xs); +} +.form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 18px; +} +.form-grid input, +dialog textarea { + width: 100%; +} +.full-width { + grid-column: 1 / -1; +} +dialog .info-note { + margin: 24px 0; +} +dialog form > .button { + margin-top: 20px; +} +#action-description { + margin-bottom: 24px; + font-size: 13px; + color: var(--sys-color-text-subtle); +} +.toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + padding: 12px 20px; + border-radius: var(--sys-radius-sm); + background: var(--sys-color-surface-primary); + color: var(--sys-color-text-primary); + font-size: 12px; + z-index: 100; +} +.toast:empty { + display: none; +} +@media (min-width: 1500px) { + .hero { + min-height: 510px; + } +} +@media (max-width: 1240px) { + .header-inner, + .hero, + .catalog-section, + .builder-callout, + .site-footer, + .page-container { + margin-left: 32px; + margin-right: 32px; + } +} +@media (max-width: 900px) { + .hero { + grid-template-columns: 1.2fr 1fr; + gap: 0; + } + .hero-art { + transform: scale(0.85); + transform-origin: right; + } + .section-note { + display: none; + } + .catalog-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .detail-layout { + gap: 32px; + grid-template-columns: minmax(0, 1fr) 220px; + } + .connect-layout { + gap: 32px; + } + .builder-callout { + padding: 28px; + } +} +@media (max-width: 640px) { + .header-inner, + .hero, + .catalog-section, + .builder-callout, + .site-footer, + .page-container { + margin-left: 20px; + margin-right: 20px; + } + .header-inner { + min-height: 72px; + gap: 16px; + } + .brand { + font-size: 16px; + gap: 8px; + } + .brand-label { + display: none; + } + nav { + gap: 20px; + } + nav .docs-link { + display: none; + } + .hero { + grid-template-columns: 1fr; + padding: 48px 0 36px; + } + .hero h1 { + font-size: 44px; + } + .hero-art { + height: 260px; + transform: none; + margin-top: 32px; + } + .hero-description { + font-size: 14px; + } + .art-core { + width: 76px; + height: 76px; + font-size: 54px; + } + .art-tile { + width: 48px; + height: 48px; + } + .tile-code { + top: 20px; + } + .tile-mail { + bottom: 30px; + } + .tile-plus { + bottom: 26px; + } + .orbit-one { + width: 270px; + height: 140px; + } + .orbit-two { + width: 180px; + height: 210px; + } + .catalog-section { + padding-top: 28px; + padding-bottom: 36px; + } + h2 { + font-size: 23px; + } + .search-form { + flex-wrap: wrap; + gap: 10px; + } + .search-input { + flex-basis: 100%; + } + .search-form select { + flex: 1; + } + .search-form .button { + min-width: 88px; + } + .catalog-grid { + grid-template-columns: 1fr; + gap: 12px; + } + .extension-card { + min-height: 222px; + padding: 20px; + gap: 20px; + } + .builder-callout { + flex-direction: column; + align-items: flex-start; + margin-bottom: 36px; + padding: 24px; + } + .builder-callout .eyebrow { + font-size: 8px; + } + .site-footer { + flex-direction: column; + padding-top: 24px; + } + .site-footer > div { + justify-content: space-between; + } + .detail-heading { + flex-wrap: wrap; + gap: 16px; + } + .extension-icon.large { + width: 64px; + height: 64px; + font-size: 22px; + } + .detail-heading > div:nth-child(2) { + flex: 1; + min-width: 0; + } + .detail-heading .button { + margin: 8px 0 0; + } + .detail-layout { + grid-template-columns: 1fr; + } + .detail-content .section-heading { + align-items: flex-start; + } + .detail-content h2 { + font-size: 22px; + } + .metadata > div { + grid-template-columns: 100px minmax(0, 1fr); + } + .distribution-panel { + padding: 20px; + } + .detail-sidebar { + border-top: 1px solid var(--sys-color-border-subtle); + padding-top: 24px; + } + .page-container { + padding: 32px 0 48px; + } + .publisher-heading { + margin-bottom: 32px; + } + .connect-layout { + grid-template-columns: 1fr; + gap: 40px; + } + .connect-card { + padding: 24px; + } + .workspace-toolbar { + align-items: flex-start; + flex-direction: column; + } + .release-row { + align-items: flex-start; + flex-direction: column; + gap: 12px; + } + .form-grid { + grid-template-columns: 1fr; + } + dialog { + padding: 20px; + } + .search-summary { + flex-wrap: wrap; + } + .lead { + font-size: 14px; + } +} +@media (prefers-reduced-motion: reduce) { + html { + scroll-behavior: auto; + } + .extension-card { + transition: none; + } +} + +.release-pagination { + display: flex; + margin-top: 24px; + gap: 16px; + justify-content: flex-end; + font-size: 12px; +} +.release-pagination button:disabled, +.release-actions button:disabled { + cursor: default; +} diff --git a/wrangler.jsonc b/wrangler.jsonc index 948511c..620c60c 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -5,6 +5,13 @@ "compatibility_date": "2026-07-28", "compatibility_flags": ["python_workers"], "workers_dev": false, + "rules": [ + { + "type": "Text", + "globs": ["**/service/static/registry.css", "**/service/static/registry.js"], + "fallthrough": true, + }, + ], "routes": [ { "pattern": "registry.inkcre.dev", From 29d56866e8cb17027e9d8de36a1e2af7177824cf Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Sun, 6 Sep 2026 20:32:05 +0800 Subject: [PATCH 02/16] =?UTF-8?q?fix(build):=20=E4=BB=8E=E5=85=AC=E5=BC=80?= =?UTF-8?q?=E8=AE=BE=E8=AE=A1=E5=8F=91=E5=B8=83=E6=8F=90=E4=BA=A4=E6=B6=88?= =?UTF-8?q?=E8=B4=B9=20Sass=20=E5=B9=B6=E9=94=81=E5=AE=9A=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E8=BF=90=E8=A1=8C=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 5 ----- .github/workflows/packages-release.yml | 10 ---------- .github/workflows/production.yml | 5 ----- .npmrc | 1 - docs/30-unit-tdd/registry-web.md | 6 ++++-- docs/40-deployment/local-development.md | 14 +++++++++----- package.json | 2 +- pnpm-lock.yaml | 16 +++++++++++----- pylock.toml | 13 +++++++++++++ tasks/registry-web-lovable/packet.md | 2 +- 10 files changed, 39 insertions(+), 35 deletions(-) delete mode 100644 .npmrc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8e078fc..3efa097 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,7 +6,6 @@ on: permissions: contents: read - packages: read concurrency: group: registry-checks-${{ github.workflow }}-${{ github.ref }} @@ -53,12 +52,8 @@ jobs: with: node-version-file: package.json cache: pnpm - registry-url: https://npm.pkg.github.com - scope: '@inkcre' - name: Install frozen dependencies - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | pdm install --frozen-lockfile pnpm install --frozen-lockfile diff --git a/.github/workflows/packages-release.yml b/.github/workflows/packages-release.yml index 6f61cd2..ebcacbb 100644 --- a/.github/workflows/packages-release.yml +++ b/.github/workflows/packages-release.yml @@ -17,7 +17,6 @@ jobs: name: Reconcile Web Runtime release plan permissions: contents: write - packages: read pull-requests: write runs-on: ubuntu-latest steps: @@ -36,12 +35,8 @@ jobs: with: node-version-file: package.json cache: pnpm - registry-url: https://npm.pkg.github.com - scope: '@inkcre' - name: Install the frozen JavaScript workspace - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: pnpm install --frozen-lockfile - name: Create or update the package Version PR @@ -61,7 +56,6 @@ jobs: if: "${{ startsWith(github.event.head_commit.message, 'chore(release): version Extension packages') }}" permissions: contents: write - packages: read runs-on: ubuntu-latest timeout-minutes: 20 environment: @@ -101,12 +95,8 @@ jobs: with: node-version-file: package.json cache: pnpm - registry-url: https://npm.pkg.github.com - scope: '@inkcre' - name: Install frozen dependencies - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | pdm install --frozen-lockfile pnpm install --frozen-lockfile diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 0351eb8..34033e7 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -18,7 +18,6 @@ on: permissions: contents: read - packages: read concurrency: group: registry-production @@ -67,12 +66,8 @@ jobs: with: node-version-file: package.json cache: pnpm - registry-url: https://npm.pkg.github.com - scope: '@inkcre' - name: Install frozen delivery dependencies - env: - NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | pdm install --frozen-lockfile pnpm install --frozen-lockfile diff --git a/.npmrc b/.npmrc deleted file mode 100644 index 77862ad..0000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -@inkcre:registry=https://npm.pkg.github.com diff --git a/docs/30-unit-tdd/registry-web.md b/docs/30-unit-tdd/registry-web.md index d36ab47..42689cb 100644 --- a/docs/30-unit-tdd/registry-web.md +++ b/docs/30-unit-tdd/registry-web.md @@ -56,8 +56,10 @@ management. Namespace and credential issuance remains an operator responsibility ## Design ownership `@inkcre/ui-web` is the existing package released by the sibling `design` -repository (`InKCre/ui`). Registry imports its public Sass styles, token maps, -and functions. Colors, typography families, and common spacing/radius vocabulary +repository (`InKCre/ui`). Registry installs the package from its immutable public release commit +using pnpm’s Git subdirectory support, and imports its public Sass styles, token maps, +and functions. This avoids requiring private GitHub Packages access in public CI; +compiled Vue exports are not part of this consumer contract. Colors, typography families, and common spacing/radius vocabulary remain design-owned; `web/registry.scss` owns Registry page composition. There is no Registry copy of the token source or a competing design package. diff --git a/docs/40-deployment/local-development.md b/docs/40-deployment/local-development.md index add61b2..2a74927 100644 --- a/docs/40-deployment/local-development.md +++ b/docs/40-deployment/local-development.md @@ -35,11 +35,15 @@ existing Release. Registry Worker deployment remains a separate workflow. ## Registry Web development Use Node 22 as declared by `package.json`; newer Node majors may not support the -Wasm stack-switching flag required by the locked Pyodide toolchain. Dependencies -include `@inkcre/ui-web@1.4.0` from GitHub Packages. Local npm authentication needs -`read:packages`; never commit a token to `.npmrc`. CI uses its workflow token with -`packages: read`, and the design package must admit this repository for Actions -reads. Browser users need no package credentials: compiled CSS ships in the Worker. +Wasm stack-switching flag required by the locked Pyodide toolchain. Registry +consumes only the public Sass exports of `@inkcre/ui-web`. pnpm installs +`packages/web` from the public `InKCre/ui` Git repository at commit +`4eceec4c60345a52a08545555ebce9ab95053beb` (release `@inkcre/ui-web@1.4.0`). +The immutable commit and subdirectory are pinned in the manifest and lockfile; +no GitHub Packages credential or additional release workflow is needed. +The source package is suitable for Sass consumption; its Vue runtime/dist +exports are not used by Registry. To upgrade, select the next protected-main +release commit, update the dependency, and rebuild/review the CSS. Edit `web/registry.scss`, then run `pnpm web:build`. Generated CSS is committed and verified by `pnpm web:check`. Templates and browser JavaScript live under diff --git a/package.json b/package.json index bdef241..2db2c03 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "devDependencies": { "@changesets/cli": "3.0.0", "@hey-api/openapi-ts": "0.99.0", - "@inkcre/ui-web": "1.4.0", + "@inkcre/ui-web": "github:InKCre/ui#4eceec4c60345a52a08545555ebce9ab95053beb&path:/packages/web", "prettier": "3.6.2", "sass": "1.94.2", "typescript": "5.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a86157..a733744 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,8 +18,8 @@ importers: specifier: 0.99.0 version: 0.99.0(typescript@5.9.3) '@inkcre/ui-web': - specifier: 1.4.0 - version: 1.4.0 + specifier: github:InKCre/ui#4eceec4c60345a52a08545555ebce9ab95053beb&path:/packages/web + version: https://codeload.github.com/InKCre/ui/tar.gz/4eceec4c60345a52a08545555ebce9ab95053beb#path:/packages/web prettier: specifier: 3.6.2 version: 3.6.2 @@ -549,8 +549,9 @@ packages: cpu: [x64] os: [win32] - '@inkcre/ui-web@1.4.0': - resolution: {integrity: sha512-Sq5f/hFpOtuGNZwVB36JuMPoAJUvkWvrHogl/PauI4OuV2M/qExdVXrEZa76pbbn35Ic/dleEPyze6lJghxI5w==, tarball: https://npm.pkg.github.com/download/@inkcre/ui-web/1.4.0/356469307437642b43b7e67e82c9b60e52d4f926} + '@inkcre/ui-web@https://codeload.github.com/InKCre/ui/tar.gz/4eceec4c60345a52a08545555ebce9ab95053beb#path:/packages/web': + resolution: {gitHosted: true, integrity: sha512-DGaEKHQhalTLpClcIyTYBvCugpkb5uYHcyOhWCUuujGoPLj0ICvOrjkfxgE8sXJuhQhTT+VXzbiEOXsLMxH79w==, path: /packages/web, tarball: https://codeload.github.com/InKCre/ui/tar.gz/4eceec4c60345a52a08545555ebce9ab95053beb} + version: 1.4.0 peerDependencies: '@codemirror/autocomplete': ^6.20.0 '@codemirror/commands': ^6.10.0 @@ -565,6 +566,11 @@ packages: vscode-languageserver-textdocument: ^1.0.12 vue: ^3.0.0 vue-router: ^4.0.0 + peerDependenciesMeta: + unocss: + optional: true + vue-router: + optional: true '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1850,7 +1856,7 @@ snapshots: '@img/sharp-win32-x64@0.35.2': optional: true - '@inkcre/ui-web@1.4.0': + '@inkcre/ui-web@https://codeload.github.com/InKCre/ui/tar.gz/4eceec4c60345a52a08545555ebce9ab95053beb#path:/packages/web': dependencies: '@vue/shared': 3.5.25 diff --git a/pylock.toml b/pylock.toml index a67f389..f2a670e 100644 --- a/pylock.toml +++ b/pylock.toml @@ -32,6 +32,19 @@ name = "inkcre-extension-toolkit" version = "0.2.0" archive = { url = "https://github.com/InKCre/ext-reg/releases/download/toolkit-v0.2.0/inkcre_extension_toolkit-0.2.0-py3-none-any.whl", hashes = { sha256 = "1250d05885834954d67c3ec331e28fd572ee232570aa0407fe958ba6c6301f5b" } } +[[packages]] +name = "jinja2" +version = "3.1.6" +wheels = [ + { url = "https://cdn.jsdelivr.net/pyodide/v0.28.3/full/jinja2-3.1.6-py3-none-any.whl", hashes = { sha256 = "24fc60a3ea3669f4d0d69a82f4b8032188b61ae1840dcb5dbe7525b6613ebbdd" } }, + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hashes = { sha256 = "85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67" } }, +] + +[[packages]] +name = "markupsafe" +version = "3.0.2" +wheels = [{ url = "https://cdn.jsdelivr.net/pyodide/v0.28.3/full/markupsafe-3.0.2-cp313-cp313-pyodide_2025_0_wasm32.whl", hashes = { sha256 = "31a48bf0c5914d64d442175520d803c6488681882ff152c3a07838736b1f3b60" } }] + [[packages]] name = "packaging" version = "26.3" diff --git a/tasks/registry-web-lovable/packet.md b/tasks/registry-web-lovable/packet.md index e663e3a..746f534 100644 --- a/tasks/registry-web-lovable/packet.md +++ b/tasks/registry-web-lovable/packet.md @@ -23,4 +23,4 @@ Sir 要求将 Registry Web 提升为可喜爱、可持续维护的产品,允 3. 完整 `pnpm check`、Worker 本地 D1/R2 黑盒验证、构建包包含模板/资源、`git diff --check`。 4. 更新拥有长期事实的本地文档,提交推送 PR,观察 CI 并修复本次失败。 -当前:已完成仓库/设计/治理/原生协议调查,正在安装依赖并实现。待验证 Jinja 打包与 Worker 运行,GitHub Packages CI 读取权限。暂无需 Sir 决策项。 +当前:已完成仓库/设计/治理/原生协议调查,实现与本地完整检查已通过,正在完成浏览器验收和 CI。首次 CI 确认私有 npm 包缺少读取授权;改用 pnpm 原生 Git 子目录依赖锁定 design 的已发布 1.4.0 提交,公共 Sass 导出保持一致,无需扩大权限。PR #33 已创建为 draft。暂无需 Sir 决策项。 From 68cb0e419017fb99231eed6c03e8da709b2cf4e4 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Sun, 6 Sep 2026 20:44:57 +0800 Subject: [PATCH 03/16] =?UTF-8?q?fix(web):=20=E5=AE=8C=E5=96=84=E4=B8=BB?= =?UTF-8?q?=E9=A2=98=E5=88=87=E6=8D=A2=E3=80=81=E5=88=86=E9=A1=B5=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=E4=B8=8E=E9=A2=84=E8=A7=88=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/30-unit-tdd/README.md | 3 +- docs/40-deployment/pull-request-previews.md | 3 +- .../service/static/registry.css | 2 +- .../service/static/registry.js | 10 +- .../service/templates/base.html | 77 +++++- .../service/templates/catalog.html | 162 +++++++++++-- .../service/templates/detail.html | 130 ++++++++++- .../service/templates/error.html | 13 +- .../service/templates/publish.html | 220 +++++++++++++++++- tasks/registry-web-lovable/packet.md | 15 +- web/registry.scss | 8 + 11 files changed, 589 insertions(+), 54 deletions(-) diff --git a/docs/30-unit-tdd/README.md b/docs/30-unit-tdd/README.md index 2a9bed9..a8ac4a7 100644 --- a/docs/30-unit-tdd/README.md +++ b/docs/30-unit-tdd/README.md @@ -5,10 +5,9 @@ independently released units. Public schemas, routes, package metadata, and generated contracts and build checks remain executable authority. - [Registry control plane](registry-control-plane.md) +- [Registry Web](registry-web.md): discovery, publisher workspace, and design consumption. - [Extension Developer Toolkit](developer-toolkit.md) - [Core Python Extension Host Runtime](core-python-runtime.md) Shared product behavior and cross-unit Host contracts remain in the InKCre documentation Hub rather than being copied here. - -- [Registry Web](registry-web.md): discovery, publisher workspace, and design consumption. diff --git a/docs/40-deployment/pull-request-previews.md b/docs/40-deployment/pull-request-previews.md index 5429145..67c9364 100644 --- a/docs/40-deployment/pull-request-previews.md +++ b/docs/40-deployment/pull-request-previews.md @@ -16,7 +16,8 @@ project `inkcre-extension-registry-ui-preview` on `preview/ext-reg/pr-`. Protected-main code adds source identity and noindex, no-store, CSP, nosniff, and no-referrer policy. The Pages project has no Git provider, custom domain, Functions, Worker, D1, R2, or production token. -Its links point to production public reads; it never claims to host candidate +Its Extension links point to production public API metadata, and Publisher +links open the Toolkit guide; it never claims to host candidate Registry APIs. Closing an internal PR deploys the checked-in tombstone, verifies the stable diff --git a/src/inkcre_extension_registry/service/static/registry.css b/src/inkcre_extension_registry/service/static/registry.css index 2ea14dc..035e720 100644 --- a/src/inkcre_extension_registry/service/static/registry.css +++ b/src/inkcre_extension_registry/service/static/registry.css @@ -1 +1 @@ -:root{--ref-elevation-raised-low-shadow-type:dropShadow;--ref-elevation-raised-low-radius:2;--ref-elevation-raised-low-color:rgba(12, 21, 48, 0.0784313725);--ref-elevation-raised-low-offset-x:0;--ref-elevation-raised-low-offset-y:1;--ref-elevation-raised-low-spread:0;--ref-elevation-raised-md-shadow-type:dropShadow;--ref-elevation-raised-md-radius:20;--ref-elevation-raised-md-color:rgba(12, 21, 48, 0.1215686275);--ref-elevation-raised-md-offset-x:0;--ref-elevation-raised-md-offset-y:6;--ref-elevation-raised-md-spread:0;--ref-elevation-raised-high-shadow-type:dropShadow;--ref-elevation-raised-high-radius:32;--ref-elevation-raised-high-color:rgba(12, 21, 84, 0.1607843137);--ref-elevation-raised-high-offset-x:0;--ref-elevation-raised-high-offset-y:12;--ref-elevation-raised-high-spread:0;--ref-color-neutral-2:#070707;--ref-color-neutral-6:#131313;--ref-color-neutral-12:#1f1f1f;--ref-color-neutral-20:#303030;--ref-color-neutral-30:#474747;--ref-color-neutral-40:#5e5e5e;--ref-color-neutral-50:#777777;--ref-color-neutral-60:#919191;--ref-color-neutral-70:#ababab;--ref-color-neutral-80:#c6c6c6;--ref-color-neutral-90:#e2e2e2;--ref-color-neutral-94:#eeeeee;--ref-color-neutral-98:#f9f9f9;--ref-color-neutral-100:#ffffff;--ref-color-tertiary-40:#1f9aa5;--ref-color-tertiary-90:#d0f4f4;--ref-color-danger-10:#410008;--ref-color-danger-30:#92001e;--ref-color-danger-35:#a80324;--ref-color-danger-40:#b9192f;--ref-color-danger-60:#ff525c;--ref-color-danger-65:#ff7073;--ref-color-danger-70:#ff8889;--ref-color-danger-80:#ffb3b1;--ref-color-danger-90:#ffdad8;--ref-color-danger-98:#fff8f7;--ref-color-brand-2:#0b1220;--ref-color-brand-6:#121928;--ref-color-brand-12:#1f2635;--ref-color-brand-20:#303747;--ref-color-brand-30:#474e5f;--ref-color-brand-40:#5f6677;--ref-color-brand-50:#787e91;--ref-color-brand-60:#9198ac;--ref-color-brand-70:#acb3c7;--ref-color-brand-80:#b2bccf;--ref-color-brand-90:#c8cee3;--ref-color-brand-94:#f7f7fb;--ref-color-brand-98:#fbfcff;--ref-radius-xs:4px;--ref-radius-sm:8px;--ref-radius-md:16px;--ref-radius-lg:32px;--ref-radius-none:0px;--ref-radius-full:999px;--ref-space-md:16px;--ref-space-lg:32px;--ref-space-xl:56px;--ref-space-xs:4px;--ref-space-sm:8px;--ref-typo-family-sans:system-ui, -apple-system, sans-serif;--ref-typo-family-mono: SF Mono , Monaco, Roboto Mono , monospace;--ref-breakpoint-mobile:768px;--ref-breakpoint-tablet:1024px;--ref-breakpoint-desktop:1280px;--ref-opacity-muted:0.72;--ref-opacity-medium:0.84;--ref-opacity-strong:0.92;--ref-size-lg:48px;--ref-size-md:36px;--ref-size-sm:24px;--ref-size-icon-sm:20px;--ref-size-icon-md:24px;--ref-size-icon-lg:36px;--ref-size-iconfont-sm:8px;--ref-size-iconfont-md:12px;--ref-size-iconfont-lg:18px;--ref-size-xl:58px;--ref-size-xs:18px;--ref-font-label-lg-font-size:14px;--ref-font-label-lg-text-decoration:none;--ref-font-label-lg-font-family:Inter;--ref-font-label-lg-font-weight:400;--ref-font-label-lg-font-style:normal;--ref-font-label-lg-font-stretch:normal;--ref-font-label-lg-letter-spacing:0px;--ref-font-label-lg-line-height:16px;--ref-font-label-lg-paragraph-indent:0px;--ref-font-label-lg-paragraph-spacing:0px;--ref-font-label-lg-text-case:none;--ref-font-label-md-font-size:12px;--ref-font-label-md-text-decoration:none;--ref-font-label-md-font-family:Inter;--ref-font-label-md-font-weight:400;--ref-font-label-md-font-style:normal;--ref-font-label-md-font-stretch:normal;--ref-font-label-md-letter-spacing:0px;--ref-font-label-md-line-height:16px;--ref-font-label-md-paragraph-indent:0px;--ref-font-label-md-paragraph-spacing:0px;--ref-font-label-md-text-case:none;--ref-font-label-sm-font-size:10px;--ref-font-label-sm-text-decoration:none;--ref-font-label-sm-font-family:Roboto;--ref-font-label-sm-font-weight:700;--ref-font-label-sm-font-style:normal;--ref-font-label-sm-font-stretch:normal;--ref-font-label-sm-letter-spacing:0.7px;--ref-font-label-sm-line-height:14px;--ref-font-label-sm-paragraph-indent:0px;--ref-font-label-sm-paragraph-spacing:0px;--ref-font-label-sm-text-case:none;--ref-font-label-lg-underlined-font-size:14px;--ref-font-label-lg-underlined-text-decoration:underline;--ref-font-label-lg-underlined-font-family:Inter;--ref-font-label-lg-underlined-font-weight:400;--ref-font-label-lg-underlined-font-style:normal;--ref-font-label-lg-underlined-font-stretch:normal;--ref-font-label-lg-underlined-letter-spacing:0px;--ref-font-label-lg-underlined-line-height:16px;--ref-font-label-lg-underlined-paragraph-indent:0px;--ref-font-label-lg-underlined-paragraph-spacing:0px;--ref-font-label-lg-underlined-text-case:none;--ref-font-label-lg-mono-font-size:14px;--ref-font-label-lg-mono-text-decoration:none;--ref-font-label-lg-mono-font-family:JetBrains Mono;--ref-font-label-lg-mono-font-weight:400;--ref-font-label-lg-mono-font-style:normal;--ref-font-label-lg-mono-font-stretch:normal;--ref-font-label-lg-mono-letter-spacing:0px;--ref-font-label-lg-mono-line-height:16px;--ref-font-label-lg-mono-paragraph-indent:0px;--ref-font-label-lg-mono-paragraph-spacing:0px;--ref-font-label-lg-mono-text-case:none;--ref-font-label-sm-mono-font-size:10px;--ref-font-label-sm-mono-text-decoration:none;--ref-font-label-sm-mono-font-family:JetBrains Mono;--ref-font-label-sm-mono-font-weight:700;--ref-font-label-sm-mono-font-style:normal;--ref-font-label-sm-mono-font-stretch:normal;--ref-font-label-sm-mono-letter-spacing:0.4px;--ref-font-label-sm-mono-line-height:14px;--ref-font-label-sm-mono-paragraph-indent:0px;--ref-font-label-sm-mono-paragraph-spacing:0px;--ref-font-label-sm-mono-text-case:none;--ref-font-title-sm-font-size:22px;--ref-font-title-sm-text-decoration:none;--ref-font-title-sm-font-family:Inter;--ref-font-title-sm-font-weight:400;--ref-font-title-sm-font-style:normal;--ref-font-title-sm-font-stretch:normal;--ref-font-title-sm-letter-spacing:0px;--ref-font-title-sm-line-height:28px;--ref-font-title-sm-paragraph-indent:0px;--ref-font-title-sm-paragraph-spacing:0px;--ref-font-title-sm-text-case:none;--ref-font-title-sm-mono-font-size:22px;--ref-font-title-sm-mono-text-decoration:none;--ref-font-title-sm-mono-font-family:IBM Plex Mono;--ref-font-title-sm-mono-font-weight:400;--ref-font-title-sm-mono-font-style:normal;--ref-font-title-sm-mono-font-stretch:normal;--ref-font-title-sm-mono-letter-spacing:0.66px;--ref-font-title-sm-mono-line-height:28px;--ref-font-title-sm-mono-paragraph-indent:0px;--ref-font-title-sm-mono-paragraph-spacing:0px;--ref-font-title-sm-mono-text-case:none;--ref-font-body-lg-font-size:18px;--ref-font-body-lg-text-decoration:none;--ref-font-body-lg-font-family:Inter;--ref-font-body-lg-font-weight:400;--ref-font-body-lg-font-style:normal;--ref-font-body-lg-font-stretch:normal;--ref-font-body-lg-letter-spacing:0px;--ref-font-body-lg-line-height:24px;--ref-font-body-lg-paragraph-indent:0px;--ref-font-body-lg-paragraph-spacing:0px;--ref-font-body-lg-text-case:none;--ref-font-body-lg-mono-font-size:18px;--ref-font-body-lg-mono-text-decoration:none;--ref-font-body-lg-mono-font-family:JetBrains Mono;--ref-font-body-lg-mono-font-weight:400;--ref-font-body-lg-mono-font-style:normal;--ref-font-body-lg-mono-font-stretch:normal;--ref-font-body-lg-mono-letter-spacing:0px;--ref-font-body-lg-mono-line-height:24px;--ref-font-body-lg-mono-paragraph-indent:0px;--ref-font-body-lg-mono-paragraph-spacing:0px;--ref-font-body-lg-mono-text-case:none;--ref-font-headline-lg-font-size:36px;--ref-font-headline-lg-text-decoration:none;--ref-font-headline-lg-font-family:Inter;--ref-font-headline-lg-font-weight:400;--ref-font-headline-lg-font-style:normal;--ref-font-headline-lg-font-stretch:normal;--ref-font-headline-lg-letter-spacing:0px;--ref-font-headline-lg-line-height:48px;--ref-font-headline-lg-paragraph-indent:0px;--ref-font-headline-lg-paragraph-spacing:0px;--ref-font-headline-lg-text-case:none;--sys-elevation-raised-low-shadow-type:dropShadow;--sys-elevation-raised-low-radius:2;--sys-elevation-raised-low-color:rgba(12, 21, 48, 0.0784313725);--sys-elevation-raised-low-offset-x:0;--sys-elevation-raised-low-offset-y:1;--sys-elevation-raised-low-spread:0;--sys-elevation-raised-md-shadow-type:dropShadow;--sys-elevation-raised-md-radius:20;--sys-elevation-raised-md-color:rgba(12, 21, 48, 0.1215686275);--sys-elevation-raised-md-offset-x:0;--sys-elevation-raised-md-offset-y:6;--sys-elevation-raised-md-spread:0;--sys-elevation-raised-high-shadow-type:dropShadow;--sys-elevation-raised-high-radius:32;--sys-elevation-raised-high-color:rgba(12, 21, 84, 0.1607843137);--sys-elevation-raised-high-offset-x:0;--sys-elevation-raised-high-offset-y:12;--sys-elevation-raised-high-spread:0;--sys-radius-xs:4px;--sys-radius-sm:8px;--sys-radius-md:16px;--sys-radius-lg:32px;--sys-radius-none:0px;--sys-radius-full:999px;--sys-space-md:16px;--sys-space-lg:32px;--sys-space-xl:56px;--sys-space-xs:4px;--sys-space-sm:8px;--sys-typo-family-sans:system-ui, -apple-system, sans-serif;--sys-typo-family-mono: SF Mono , Monaco, Roboto Mono , monospace;--sys-breakpoint-mobile:768px;--sys-breakpoint-tablet:1024px;--sys-breakpoint-desktop:1280px;--sys-opacity-muted:0.72;--sys-opacity-medium:0.84;--sys-opacity-strong:0.92;--sys-size-lg:48px;--sys-size-md:36px;--sys-size-sm:24px;--sys-size-icon-sm:20px;--sys-size-icon-md:24px;--sys-size-icon-lg:36px;--sys-size-iconfont-sm:8px;--sys-size-iconfont-md:12px;--sys-size-iconfont-lg:18px;--sys-size-xl:58px;--sys-size-xs:18px;--sys-font-label-lg-font-size:14px;--sys-font-label-lg-text-decoration:none;--sys-font-label-lg-font-family:Inter;--sys-font-label-lg-font-weight:400;--sys-font-label-lg-font-style:normal;--sys-font-label-lg-font-stretch:normal;--sys-font-label-lg-letter-spacing:0px;--sys-font-label-lg-line-height:16px;--sys-font-label-lg-paragraph-indent:0px;--sys-font-label-lg-paragraph-spacing:0px;--sys-font-label-lg-text-case:none;--sys-font-label-md-font-size:12px;--sys-font-label-md-text-decoration:none;--sys-font-label-md-font-family:Inter;--sys-font-label-md-font-weight:400;--sys-font-label-md-font-style:normal;--sys-font-label-md-font-stretch:normal;--sys-font-label-md-letter-spacing:0px;--sys-font-label-md-line-height:16px;--sys-font-label-md-paragraph-indent:0px;--sys-font-label-md-paragraph-spacing:0px;--sys-font-label-md-text-case:none;--sys-font-label-sm-font-size:10px;--sys-font-label-sm-text-decoration:none;--sys-font-label-sm-font-family:Roboto;--sys-font-label-sm-font-weight:700;--sys-font-label-sm-font-style:normal;--sys-font-label-sm-font-stretch:normal;--sys-font-label-sm-letter-spacing:0.7px;--sys-font-label-sm-line-height:14px;--sys-font-label-sm-paragraph-indent:0px;--sys-font-label-sm-paragraph-spacing:0px;--sys-font-label-sm-text-case:none;--sys-font-label-lg-underlined-font-size:14px;--sys-font-label-lg-underlined-text-decoration:underline;--sys-font-label-lg-underlined-font-family:Inter;--sys-font-label-lg-underlined-font-weight:400;--sys-font-label-lg-underlined-font-style:normal;--sys-font-label-lg-underlined-font-stretch:normal;--sys-font-label-lg-underlined-letter-spacing:0px;--sys-font-label-lg-underlined-line-height:16px;--sys-font-label-lg-underlined-paragraph-indent:0px;--sys-font-label-lg-underlined-paragraph-spacing:0px;--sys-font-label-lg-underlined-text-case:none;--sys-font-label-lg-mono-font-size:14px;--sys-font-label-lg-mono-text-decoration:none;--sys-font-label-lg-mono-font-family:JetBrains Mono;--sys-font-label-lg-mono-font-weight:400;--sys-font-label-lg-mono-font-style:normal;--sys-font-label-lg-mono-font-stretch:normal;--sys-font-label-lg-mono-letter-spacing:0px;--sys-font-label-lg-mono-line-height:16px;--sys-font-label-lg-mono-paragraph-indent:0px;--sys-font-label-lg-mono-paragraph-spacing:0px;--sys-font-label-lg-mono-text-case:none;--sys-font-label-sm-mono-font-size:10px;--sys-font-label-sm-mono-text-decoration:none;--sys-font-label-sm-mono-font-family:JetBrains Mono;--sys-font-label-sm-mono-font-weight:700;--sys-font-label-sm-mono-font-style:normal;--sys-font-label-sm-mono-font-stretch:normal;--sys-font-label-sm-mono-letter-spacing:0.4px;--sys-font-label-sm-mono-line-height:14px;--sys-font-label-sm-mono-paragraph-indent:0px;--sys-font-label-sm-mono-paragraph-spacing:0px;--sys-font-label-sm-mono-text-case:none;--sys-font-title-sm-font-size:22px;--sys-font-title-sm-text-decoration:none;--sys-font-title-sm-font-family:Inter;--sys-font-title-sm-font-weight:400;--sys-font-title-sm-font-style:normal;--sys-font-title-sm-font-stretch:normal;--sys-font-title-sm-letter-spacing:0px;--sys-font-title-sm-line-height:28px;--sys-font-title-sm-paragraph-indent:0px;--sys-font-title-sm-paragraph-spacing:0px;--sys-font-title-sm-text-case:none;--sys-font-title-sm-mono-font-size:22px;--sys-font-title-sm-mono-text-decoration:none;--sys-font-title-sm-mono-font-family:IBM Plex Mono;--sys-font-title-sm-mono-font-weight:400;--sys-font-title-sm-mono-font-style:normal;--sys-font-title-sm-mono-font-stretch:normal;--sys-font-title-sm-mono-letter-spacing:0.66px;--sys-font-title-sm-mono-line-height:28px;--sys-font-title-sm-mono-paragraph-indent:0px;--sys-font-title-sm-mono-paragraph-spacing:0px;--sys-font-title-sm-mono-text-case:none;--sys-font-body-lg-font-size:18px;--sys-font-body-lg-text-decoration:none;--sys-font-body-lg-font-family:Inter;--sys-font-body-lg-font-weight:400;--sys-font-body-lg-font-style:normal;--sys-font-body-lg-font-stretch:normal;--sys-font-body-lg-letter-spacing:0px;--sys-font-body-lg-line-height:24px;--sys-font-body-lg-paragraph-indent:0px;--sys-font-body-lg-paragraph-spacing:0px;--sys-font-body-lg-text-case:none;--sys-font-body-lg-mono-font-size:18px;--sys-font-body-lg-mono-text-decoration:none;--sys-font-body-lg-mono-font-family:JetBrains Mono;--sys-font-body-lg-mono-font-weight:400;--sys-font-body-lg-mono-font-style:normal;--sys-font-body-lg-mono-font-stretch:normal;--sys-font-body-lg-mono-letter-spacing:0px;--sys-font-body-lg-mono-line-height:24px;--sys-font-body-lg-mono-paragraph-indent:0px;--sys-font-body-lg-mono-paragraph-spacing:0px;--sys-font-body-lg-mono-text-case:none;--sys-font-headline-lg-font-size:36px;--sys-font-headline-lg-text-decoration:none;--sys-font-headline-lg-font-family:Inter;--sys-font-headline-lg-font-weight:400;--sys-font-headline-lg-font-style:normal;--sys-font-headline-lg-font-stretch:normal;--sys-font-headline-lg-letter-spacing:0px;--sys-font-headline-lg-line-height:48px;--sys-font-headline-lg-paragraph-indent:0px;--sys-font-headline-lg-paragraph-spacing:0px;--sys-font-headline-lg-text-case:none}:root,html[data-theme=light]{color-scheme:light;--sys-color-text-base:#070707;--sys-color-text-muted:#919191;--sys-color-text-danger-on:#fff8f7;--sys-color-text-primary:#fbfcff;--sys-color-text-subtle:#5e5e5e;--sys-color-surface-base:#ffffff;--sys-color-surface-muted:#e2e2e2;--sys-color-surface-subtle:#f9f9f9;--sys-color-surface-primary:#121928;--sys-color-surface-primary-hover:#474e5f;--sys-color-surface-subtle-hover:#eeeeee;--sys-color-surface-danger:#a80324;--sys-color-surface-danger-hover:#ff7073;--sys-color-surface-base-hover:#eeeeee;--sys-color-border-base:#303030;--sys-color-border-strong:#070707;--sys-color-border-subtle:#c6c6c6;--sys-color-border-muted:#919191;--sys-color-feedback-error:#b9192f;--comp-switch-track-bg:#070707;--comp-switch-handle-bg:#f9f9f9;--comp-switch-label-color:#070707}@media(prefers-color-scheme: dark){:root:not([data-theme]),html[data-theme=dark]{color-scheme:dark;--sys-color-text-base:#f9f9f9;--sys-color-text-muted:#f9f9f9;--sys-color-text-danger-on:#fff8f7;--sys-color-text-primary:#0b1220;--sys-color-text-subtle:#c6c6c6;--sys-color-surface-base:#474747;--sys-color-surface-muted:#131313;--sys-color-surface-subtle:#1f1f1f;--sys-color-surface-primary:#c8cee3;--sys-color-surface-primary-hover:#9198ac;--sys-color-surface-subtle-hover:#5e5e5e;--sys-color-surface-danger:#b9192f;--sys-color-surface-danger-hover:#b9192f;--sys-color-surface-base-hover:#474747;--sys-color-border-base:#5e5e5e;--sys-color-border-strong:#c6c6c6;--sys-color-border-subtle:#5e5e5e;--sys-color-border-muted:#5e5e5e;--sys-color-feedback-error:#ff525c;--comp-switch-track-bg:#f9f9f9;--comp-switch-handle-bg:#070707;--comp-switch-label-color:#f9f9f9}}:root{--page-width: 1160px}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--sys-color-surface-subtle);color:var(--sys-color-text-base);font-family:var(--ref-typo-family-sans);font-size:15px;line-height:1.6;-webkit-font-smoothing:antialiased}a{color:inherit;text-underline-offset:4px}button,input,select,textarea{font:inherit}button,a,input,select,textarea{-webkit-tap-highlight-color:rgba(0,0,0,0)}button,select{cursor:pointer}button:disabled{cursor:wait;opacity:.6}button{color:inherit}:focus-visible{outline:2px solid var(--sys-color-border-strong);outline-offset:4px}[hidden]{display:none !important}h1,h2,h3,p{margin:0}h1,h2,h3{line-height:1.2;font-weight:550}h1{letter-spacing:-0.055em}h2{font-size:26px;letter-spacing:-0.035em}h3{font-size:18px;letter-spacing:-0.02em}code,.extension-name,.art-caption,.eyebrow,kbd,.publisher-name{font-family:var(--ref-typo-family-mono)}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.skip-link{position:fixed;top:-100px;left:16px;padding:12px;background:var(--sys-color-surface-base);z-index:20}.skip-link:focus{top:12px}.site-header{border-bottom:1px solid var(--sys-color-border-subtle);background:var(--sys-color-surface-subtle)}.header-inner{max-width:var(--page-width);min-height:88px;margin:auto;display:flex;align-items:center;gap:36px}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;font-weight:650;font-size:17px;letter-spacing:-0.04em}.brand-mark,.art-core{background:var(--sys-color-surface-primary);color:var(--sys-color-text-primary);font-weight:500;letter-spacing:-0.13em}.brand-mark{width:36px;height:36px;display:grid;grid-auto-flow:column;justify-content:center;align-items:center;font-size:26px;padding-right:4px}.brand-mark span,.art-core span{font-style:italic}.brand-label{color:var(--sys-color-text-subtle);margin-left:8px;padding-left:13px;border-left:1px solid var(--sys-color-border-subtle);font-size:14px;font-weight:400;letter-spacing:0}nav{display:flex;align-self:stretch;align-items:center;gap:32px;margin-left:auto}nav a{text-decoration:none;font-size:13px;color:var(--sys-color-text-subtle);height:100%;display:flex;align-items:center;gap:8px;border-bottom:2px solid rgba(0,0,0,0);padding-top:2px}nav a[aria-current]{color:var(--sys-color-text-base);border-color:var(--sys-color-border-strong)}.theme-button{border:1px solid var(--sys-color-border-subtle);background:rgba(0,0,0,0);border-radius:var(--sys-radius-full);width:32px;height:32px;display:grid;place-items:center;font-size:20px;line-height:1}.hero{max-width:var(--page-width);margin:auto;min-height:462px;display:grid;grid-template-columns:1.15fr 1fr;align-items:center;gap:40px;padding:64px 0}.eyebrow{font-size:10px;letter-spacing:.12em;font-weight:500;color:var(--sys-color-text-subtle);display:flex;align-items:center;gap:8px}.tiny-square{display:inline-block;width:5px;height:5px;background:currentColor;flex:none}.hero h1{font-size:clamp(42px,4.8vw,64px);line-height:1.08;margin-top:24px}.hero h1>span,.publisher-heading h1>span{color:var(--sys-color-text-subtle)}.hero-description{margin-top:24px;color:var(--sys-color-text-subtle);font-size:16px;line-height:1.8}.text-link{font-size:13px;text-decoration:none;display:inline-flex;gap:18px;align-items:center;font-weight:500}.text-link:hover{text-decoration:underline}.hero .text-link{margin-top:24px}.hero-art{position:relative;height:330px;background-image:radial-gradient(var(--sys-color-border-subtle) 0.7px, transparent 0.7px);background-size:20px 20px;mask-image:linear-gradient(to right, transparent, #000 12%, #000 88%, transparent)}.orbit{position:absolute;border:1px solid var(--sys-color-border-subtle);border-radius:50%;top:50%;left:50%;transform:translate(-50%, -50%) rotate(-25deg)}.orbit-one{width:320px;height:170px}.orbit-two{width:220px;height:270px;transform:translate(-50%, -50%) rotate(40deg)}.art-core{position:absolute;left:50%;top:50%;transform:translate(-50%, -50%) rotate(-8deg);width:98px;height:98px;border-radius:22px;display:flex;align-items:center;justify-content:center;font-size:72px;padding-right:7px;box-shadow:10px 12px 0 var(--sys-color-surface-muted)}.art-tile{position:absolute;display:grid;place-items:center;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);width:60px;height:60px;border-radius:var(--sys-radius-md);font-family:var(--ref-typo-family-mono);font-size:22px}.tile-code{top:36px;left:26%;transform:rotate(-12deg)}.tile-feed{top:100px;right:8%;transform:rotate(12deg);font-size:38px}.tile-mail{bottom:46px;left:15%;transform:rotate(8deg)}.tile-plus{bottom:43px;right:21%;transform:rotate(-10deg)}.art-caption{position:absolute;bottom:0;left:0;width:100%;text-align:center;font-size:8px;letter-spacing:.15em;color:var(--sys-color-text-subtle)}.catalog-section{max-width:var(--page-width);margin:auto;padding:36px 0 64px;border-top:1px solid var(--sys-color-border-subtle);scroll-margin-top:24px}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:24px}.section-heading .eyebrow{margin-bottom:12px}.count{display:inline-block;vertical-align:middle;font-size:11px;font-weight:400;padding:2px 7px;border:1px solid var(--sys-color-border-subtle);border-radius:5px;letter-spacing:0;margin-left:8px;line-height:1.5}.section-note{font-size:12px;color:var(--sys-color-text-subtle)}.search-form{display:flex;gap:12px;margin:28px 0}.search-input{display:flex;align-items:center;flex:1;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);padding:0 14px;gap:12px;border-radius:var(--sys-radius-xs)}.search-input:focus-within{outline:2px solid var(--sys-color-border-strong);outline-offset:2px}.search-input>span{font-size:25px}.search-input input{border:0;background:rgba(0,0,0,0);width:100%;padding:11px 0;outline:none}.search-input kbd{border:1px solid var(--sys-color-border-subtle);color:var(--sys-color-text-subtle);border-radius:3px;padding:0 6px;font-size:11px}input,select,textarea{color:var(--sys-color-text-base);background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-xs);padding:11px 12px;min-width:0;max-width:100%}input::placeholder,textarea::placeholder{color:var(--sys-color-text-subtle);opacity:.85}.button{display:inline-flex;align-items:center;justify-content:center;gap:18px;padding:11px 18px;border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-xs);background:var(--sys-color-surface-base);color:var(--sys-color-text-base);text-decoration:none;font-size:12px;font-weight:500;line-height:1.5;white-space:nowrap}.button:hover{background:var(--sys-color-surface-subtle-hover)}.button-primary{background:var(--sys-color-surface-primary);color:var(--sys-color-text-primary);border-color:rgba(0,0,0,0)}.button-primary:hover{background:var(--sys-color-surface-primary-hover)}.search-summary{display:flex;gap:20px;font-size:13px;margin-bottom:20px}.catalog-grid{display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:16px;list-style:none;padding:0;margin:0}.extension-card{display:flex;flex-direction:column;gap:24px;min-height:252px;padding:24px;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm);text-decoration:none;transition:transform 160ms,border-color 160ms}.extension-card:hover{transform:translateY(-3px);border-color:var(--sys-color-border-strong)}.card-top{display:flex;justify-content:space-between;align-items:flex-start}.extension-icon{display:grid;place-items:center;width:48px;height:48px;border-radius:12px;background:var(--sys-color-surface-subtle);border:1px solid var(--sys-color-border-subtle);font-size:15px;font-weight:600;letter-spacing:-0.08em}.card-arrow{color:var(--sys-color-text-subtle);font-size:19px}.publisher-name{font-size:10px;letter-spacing:.02em;color:var(--sys-color-text-subtle);margin-bottom:7px;text-decoration:none}.extension-card h3{font-size:20px;overflow-wrap:anywhere}.extension-name{color:var(--sys-color-text-subtle);font-size:11px;margin-top:9px;overflow-wrap:anywhere}.card-bottom{margin-top:auto;padding-top:17px;border-top:1px solid var(--sys-color-border-subtle);display:flex;justify-content:space-between;gap:12px;font-size:10px;color:var(--sys-color-text-subtle)}.card-bottom>span:first-child{display:flex;gap:6px;align-items:center}.builder-callout{max-width:var(--page-width);margin:0 auto 64px;padding:36px;border:1px solid var(--sys-color-border-subtle);background:var(--sys-color-surface-muted);border-radius:var(--sys-radius-sm);display:flex;align-items:center;justify-content:space-between;gap:24px}.builder-callout h2{margin:12px 0 8px}.builder-callout p:not(.eyebrow){color:var(--sys-color-text-subtle);font-size:13px}.site-footer{max-width:var(--page-width);margin:auto;padding:28px 0 36px;border-top:1px solid var(--sys-color-border-subtle);display:flex;justify-content:space-between;gap:20px;color:var(--sys-color-text-subtle);font-size:11px}.site-footer>div{display:flex;align-items:center;gap:24px}.site-footer a{text-decoration:none}.footer-wordmark{font-size:15px;font-weight:650;letter-spacing:-0.05em;color:var(--sys-color-text-base)}.empty-state{text-align:center;border:1px dashed var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm);padding:56px 24px}.empty-symbol{font-size:38px;display:block;margin-bottom:16px}.empty-state p{color:var(--sys-color-text-subtle);margin:14px auto 24px;max-width:480px}.empty-state .eyebrow{justify-content:center}.preview-banner{text-align:center;padding:10px;font-size:12px;background:var(--sys-color-surface-muted)}.page-container{max-width:var(--page-width);margin:auto;padding:44px 0 80px}.back-link{display:inline-block;font-size:12px;text-decoration:none;color:var(--sys-color-text-subtle);margin-bottom:36px}.detail-heading{display:flex;align-items:center;gap:24px;padding-bottom:40px;border-bottom:1px solid var(--sys-color-border-subtle)}.extension-icon.large{width:88px;height:88px;border-radius:var(--sys-radius-md);font-size:28px;flex-shrink:0;background:var(--sys-color-surface-base)}.detail-heading h1{font-size:clamp(30px,4vw,46px);overflow-wrap:anywhere}.detail-heading .button{margin-left:auto}.detail-layout{display:grid;grid-template-columns:minmax(0, 1fr) 290px;gap:64px;padding-top:40px}.detail-content h2{font-size:24px}.lead{color:var(--sys-color-text-subtle);font-size:16px;line-height:1.8}.badge{background:var(--sys-color-surface-muted);font-size:10px;border-radius:var(--sys-radius-xs);padding:4px 8px;white-space:nowrap}.distribution-panel{background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm);padding:24px;margin-top:24px}.distribution-title{display:flex;align-items:center;gap:14px}.distribution-title p{color:var(--sys-color-text-subtle);font-size:12px;margin-top:4px}.format-icon{font-family:var(--ref-typo-family-mono);background:var(--sys-color-surface-muted);border-radius:var(--sys-radius-xs);width:40px;height:40px;display:grid;place-items:center}.metadata{margin:24px 0;font-size:12px}.metadata>div{display:grid;grid-template-columns:140px minmax(0, 1fr);padding:9px 0;border-top:1px solid var(--sys-color-border-subtle);gap:16px}.metadata dt{color:var(--sys-color-text-subtle)}.metadata dd{margin:0;overflow-wrap:anywhere}.info-note{padding:20px;border-left:2px solid var(--sys-color-border-subtle);margin-top:28px;font-size:12px;color:var(--sys-color-text-subtle)}.info-note strong{color:var(--sys-color-text-base);font-weight:500}.info-note p{margin-top:6px}.sidebar-panel{border-bottom:1px solid var(--sys-color-border-subtle);padding-bottom:24px;margin-bottom:24px}.sidebar-panel h2{font-size:32px;margin:12px 0 8px;overflow-wrap:anywhere}.sidebar-panel p:not(.eyebrow){font-size:12px;color:var(--sys-color-text-subtle);margin-bottom:20px}.sidebar-panel h3{font-size:14px}.version-list{list-style:none;padding:0;margin:16px 0 0;max-height:400px;overflow:auto}.version-list a{display:flex;justify-content:space-between;gap:8px;text-decoration:none;font-size:12px;padding:10px;border-radius:var(--sys-radius-xs);overflow-wrap:anywhere}.version-list a[aria-current]{background:var(--sys-color-surface-muted)}.version-list span{font-size:10px;color:var(--sys-color-text-subtle)}.publisher-heading{margin:16px 0 48px}.publisher-heading h1{font-size:clamp(40px,5vw,60px);margin:20px 0}.connect-layout{display:grid;grid-template-columns:1fr 1fr;gap:80px;align-items:center}.connect-card{padding:32px;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm)}.connect-card h2{margin:12px 0}.connect-card>p:not(.eyebrow){color:var(--sys-color-text-subtle);font-size:13px}.connect-card form{margin-top:24px}label{display:block;font-size:12px;font-weight:500;margin-bottom:7px}.connect-card input{width:100%}.field-help{color:var(--sys-color-text-subtle);font-size:11px;margin-top:8px}.connect-card button{margin-top:24px;width:100%}.steps{list-style:none;padding:0;margin:28px 0}.steps li{display:flex;gap:20px;margin-bottom:28px}.steps li>span{font-family:var(--ref-typo-family-mono);color:var(--sys-color-text-subtle);font-size:12px;padding-top:3px}.steps h3{font-size:16px}.steps p{color:var(--sys-color-text-subtle);font-size:13px;margin-top:8px}.workspace-heading,.workspace-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:28px}.workspace-heading{border-bottom:1px solid var(--sys-color-border-subtle);padding-bottom:24px}.workspace-heading h2{margin-top:8px}.workspace-toolbar>div{display:flex;gap:8px}.release-row{display:flex;align-items:center;justify-content:space-between;gap:24px;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm);padding:20px;margin-bottom:12px}.release-row h3{font-size:16px;overflow-wrap:anywhere}.release-row p{color:var(--sys-color-text-subtle);font-size:12px;margin:6px 0;overflow-wrap:anywhere}.release-actions{display:flex;gap:8px;flex-wrap:wrap}.form-status{font-size:12px;margin-top:12px;overflow-wrap:anywhere}.form-status.error{color:var(--sys-color-feedback-error)}.form-status:empty{display:none}dialog{color:var(--sys-color-text-base);background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-md);padding:28px;width:min(620px,100% - 32px);max-height:calc(100dvh - 48px);overflow:auto}dialog::backdrop{background:rgba(0,0,0,.5333333333)}.dialog-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:24px}.dialog-heading h2{margin-top:8px;font-size:24px}.icon-button{background:rgba(0,0,0,0);border:1px solid var(--sys-color-border-subtle);width:32px;height:32px;flex-shrink:0;font-size:24px;display:grid;place-items:center;line-height:1;border-radius:var(--sys-radius-xs)}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.form-grid input,dialog textarea{width:100%}.full-width{grid-column:1/-1}dialog .info-note{margin:24px 0}dialog form>.button{margin-top:20px}#action-description{margin-bottom:24px;font-size:13px;color:var(--sys-color-text-subtle)}.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);padding:12px 20px;border-radius:var(--sys-radius-sm);background:var(--sys-color-surface-primary);color:var(--sys-color-text-primary);font-size:12px;z-index:100}.toast:empty{display:none}@media(min-width: 1500px){.hero{min-height:510px}}@media(max-width: 1240px){.header-inner,.hero,.catalog-section,.builder-callout,.site-footer,.page-container{margin-left:32px;margin-right:32px}}@media(max-width: 900px){.hero{grid-template-columns:1.2fr 1fr;gap:0}.hero-art{transform:scale(0.85);transform-origin:right}.section-note{display:none}.catalog-grid{grid-template-columns:repeat(2, minmax(0, 1fr))}.detail-layout{gap:32px;grid-template-columns:minmax(0, 1fr) 220px}.connect-layout{gap:32px}.builder-callout{padding:28px}}@media(max-width: 640px){.header-inner,.hero,.catalog-section,.builder-callout,.site-footer,.page-container{margin-left:20px;margin-right:20px}.header-inner{min-height:72px;gap:16px}.brand{font-size:16px;gap:8px}.brand-label{display:none}nav{gap:20px}nav .docs-link{display:none}.hero{grid-template-columns:1fr;padding:48px 0 36px}.hero h1{font-size:44px}.hero-art{height:260px;transform:none;margin-top:32px}.hero-description{font-size:14px}.art-core{width:76px;height:76px;font-size:54px}.art-tile{width:48px;height:48px}.tile-code{top:20px}.tile-mail{bottom:30px}.tile-plus{bottom:26px}.orbit-one{width:270px;height:140px}.orbit-two{width:180px;height:210px}.catalog-section{padding-top:28px;padding-bottom:36px}h2{font-size:23px}.search-form{flex-wrap:wrap;gap:10px}.search-input{flex-basis:100%}.search-form select{flex:1}.search-form .button{min-width:88px}.catalog-grid{grid-template-columns:1fr;gap:12px}.extension-card{min-height:222px;padding:20px;gap:20px}.builder-callout{flex-direction:column;align-items:flex-start;margin-bottom:36px;padding:24px}.builder-callout .eyebrow{font-size:8px}.site-footer{flex-direction:column;padding-top:24px}.site-footer>div{justify-content:space-between}.detail-heading{flex-wrap:wrap;gap:16px}.extension-icon.large{width:64px;height:64px;font-size:22px}.detail-heading>div:nth-child(2){flex:1;min-width:0}.detail-heading .button{margin:8px 0 0}.detail-layout{grid-template-columns:1fr}.detail-content .section-heading{align-items:flex-start}.detail-content h2{font-size:22px}.metadata>div{grid-template-columns:100px minmax(0, 1fr)}.distribution-panel{padding:20px}.detail-sidebar{border-top:1px solid var(--sys-color-border-subtle);padding-top:24px}.page-container{padding:32px 0 48px}.publisher-heading{margin-bottom:32px}.connect-layout{grid-template-columns:1fr;gap:40px}.connect-card{padding:24px}.workspace-toolbar{align-items:flex-start;flex-direction:column}.release-row{align-items:flex-start;flex-direction:column;gap:12px}.form-grid{grid-template-columns:1fr}dialog{padding:20px}.search-summary{flex-wrap:wrap}.lead{font-size:14px}}@media(prefers-reduced-motion: reduce){html{scroll-behavior:auto}.extension-card{transition:none}}.release-pagination{display:flex;margin-top:24px;gap:16px;justify-content:flex-end;font-size:12px}.release-pagination button:disabled,.release-actions button:disabled{cursor:default} +:root{--ref-elevation-raised-low-shadow-type:dropShadow;--ref-elevation-raised-low-radius:2;--ref-elevation-raised-low-color:rgba(12, 21, 48, 0.0784313725);--ref-elevation-raised-low-offset-x:0;--ref-elevation-raised-low-offset-y:1;--ref-elevation-raised-low-spread:0;--ref-elevation-raised-md-shadow-type:dropShadow;--ref-elevation-raised-md-radius:20;--ref-elevation-raised-md-color:rgba(12, 21, 48, 0.1215686275);--ref-elevation-raised-md-offset-x:0;--ref-elevation-raised-md-offset-y:6;--ref-elevation-raised-md-spread:0;--ref-elevation-raised-high-shadow-type:dropShadow;--ref-elevation-raised-high-radius:32;--ref-elevation-raised-high-color:rgba(12, 21, 84, 0.1607843137);--ref-elevation-raised-high-offset-x:0;--ref-elevation-raised-high-offset-y:12;--ref-elevation-raised-high-spread:0;--ref-color-neutral-2:#070707;--ref-color-neutral-6:#131313;--ref-color-neutral-12:#1f1f1f;--ref-color-neutral-20:#303030;--ref-color-neutral-30:#474747;--ref-color-neutral-40:#5e5e5e;--ref-color-neutral-50:#777777;--ref-color-neutral-60:#919191;--ref-color-neutral-70:#ababab;--ref-color-neutral-80:#c6c6c6;--ref-color-neutral-90:#e2e2e2;--ref-color-neutral-94:#eeeeee;--ref-color-neutral-98:#f9f9f9;--ref-color-neutral-100:#ffffff;--ref-color-tertiary-40:#1f9aa5;--ref-color-tertiary-90:#d0f4f4;--ref-color-danger-10:#410008;--ref-color-danger-30:#92001e;--ref-color-danger-35:#a80324;--ref-color-danger-40:#b9192f;--ref-color-danger-60:#ff525c;--ref-color-danger-65:#ff7073;--ref-color-danger-70:#ff8889;--ref-color-danger-80:#ffb3b1;--ref-color-danger-90:#ffdad8;--ref-color-danger-98:#fff8f7;--ref-color-brand-2:#0b1220;--ref-color-brand-6:#121928;--ref-color-brand-12:#1f2635;--ref-color-brand-20:#303747;--ref-color-brand-30:#474e5f;--ref-color-brand-40:#5f6677;--ref-color-brand-50:#787e91;--ref-color-brand-60:#9198ac;--ref-color-brand-70:#acb3c7;--ref-color-brand-80:#b2bccf;--ref-color-brand-90:#c8cee3;--ref-color-brand-94:#f7f7fb;--ref-color-brand-98:#fbfcff;--ref-radius-xs:4px;--ref-radius-sm:8px;--ref-radius-md:16px;--ref-radius-lg:32px;--ref-radius-none:0px;--ref-radius-full:999px;--ref-space-md:16px;--ref-space-lg:32px;--ref-space-xl:56px;--ref-space-xs:4px;--ref-space-sm:8px;--ref-typo-family-sans:system-ui, -apple-system, sans-serif;--ref-typo-family-mono: SF Mono , Monaco, Roboto Mono , monospace;--ref-breakpoint-mobile:768px;--ref-breakpoint-tablet:1024px;--ref-breakpoint-desktop:1280px;--ref-opacity-muted:0.72;--ref-opacity-medium:0.84;--ref-opacity-strong:0.92;--ref-size-lg:48px;--ref-size-md:36px;--ref-size-sm:24px;--ref-size-icon-sm:20px;--ref-size-icon-md:24px;--ref-size-icon-lg:36px;--ref-size-iconfont-sm:8px;--ref-size-iconfont-md:12px;--ref-size-iconfont-lg:18px;--ref-size-xl:58px;--ref-size-xs:18px;--ref-font-label-lg-font-size:14px;--ref-font-label-lg-text-decoration:none;--ref-font-label-lg-font-family:Inter;--ref-font-label-lg-font-weight:400;--ref-font-label-lg-font-style:normal;--ref-font-label-lg-font-stretch:normal;--ref-font-label-lg-letter-spacing:0px;--ref-font-label-lg-line-height:16px;--ref-font-label-lg-paragraph-indent:0px;--ref-font-label-lg-paragraph-spacing:0px;--ref-font-label-lg-text-case:none;--ref-font-label-md-font-size:12px;--ref-font-label-md-text-decoration:none;--ref-font-label-md-font-family:Inter;--ref-font-label-md-font-weight:400;--ref-font-label-md-font-style:normal;--ref-font-label-md-font-stretch:normal;--ref-font-label-md-letter-spacing:0px;--ref-font-label-md-line-height:16px;--ref-font-label-md-paragraph-indent:0px;--ref-font-label-md-paragraph-spacing:0px;--ref-font-label-md-text-case:none;--ref-font-label-sm-font-size:10px;--ref-font-label-sm-text-decoration:none;--ref-font-label-sm-font-family:Roboto;--ref-font-label-sm-font-weight:700;--ref-font-label-sm-font-style:normal;--ref-font-label-sm-font-stretch:normal;--ref-font-label-sm-letter-spacing:0.7px;--ref-font-label-sm-line-height:14px;--ref-font-label-sm-paragraph-indent:0px;--ref-font-label-sm-paragraph-spacing:0px;--ref-font-label-sm-text-case:none;--ref-font-label-lg-underlined-font-size:14px;--ref-font-label-lg-underlined-text-decoration:underline;--ref-font-label-lg-underlined-font-family:Inter;--ref-font-label-lg-underlined-font-weight:400;--ref-font-label-lg-underlined-font-style:normal;--ref-font-label-lg-underlined-font-stretch:normal;--ref-font-label-lg-underlined-letter-spacing:0px;--ref-font-label-lg-underlined-line-height:16px;--ref-font-label-lg-underlined-paragraph-indent:0px;--ref-font-label-lg-underlined-paragraph-spacing:0px;--ref-font-label-lg-underlined-text-case:none;--ref-font-label-lg-mono-font-size:14px;--ref-font-label-lg-mono-text-decoration:none;--ref-font-label-lg-mono-font-family:JetBrains Mono;--ref-font-label-lg-mono-font-weight:400;--ref-font-label-lg-mono-font-style:normal;--ref-font-label-lg-mono-font-stretch:normal;--ref-font-label-lg-mono-letter-spacing:0px;--ref-font-label-lg-mono-line-height:16px;--ref-font-label-lg-mono-paragraph-indent:0px;--ref-font-label-lg-mono-paragraph-spacing:0px;--ref-font-label-lg-mono-text-case:none;--ref-font-label-sm-mono-font-size:10px;--ref-font-label-sm-mono-text-decoration:none;--ref-font-label-sm-mono-font-family:JetBrains Mono;--ref-font-label-sm-mono-font-weight:700;--ref-font-label-sm-mono-font-style:normal;--ref-font-label-sm-mono-font-stretch:normal;--ref-font-label-sm-mono-letter-spacing:0.4px;--ref-font-label-sm-mono-line-height:14px;--ref-font-label-sm-mono-paragraph-indent:0px;--ref-font-label-sm-mono-paragraph-spacing:0px;--ref-font-label-sm-mono-text-case:none;--ref-font-title-sm-font-size:22px;--ref-font-title-sm-text-decoration:none;--ref-font-title-sm-font-family:Inter;--ref-font-title-sm-font-weight:400;--ref-font-title-sm-font-style:normal;--ref-font-title-sm-font-stretch:normal;--ref-font-title-sm-letter-spacing:0px;--ref-font-title-sm-line-height:28px;--ref-font-title-sm-paragraph-indent:0px;--ref-font-title-sm-paragraph-spacing:0px;--ref-font-title-sm-text-case:none;--ref-font-title-sm-mono-font-size:22px;--ref-font-title-sm-mono-text-decoration:none;--ref-font-title-sm-mono-font-family:IBM Plex Mono;--ref-font-title-sm-mono-font-weight:400;--ref-font-title-sm-mono-font-style:normal;--ref-font-title-sm-mono-font-stretch:normal;--ref-font-title-sm-mono-letter-spacing:0.66px;--ref-font-title-sm-mono-line-height:28px;--ref-font-title-sm-mono-paragraph-indent:0px;--ref-font-title-sm-mono-paragraph-spacing:0px;--ref-font-title-sm-mono-text-case:none;--ref-font-body-lg-font-size:18px;--ref-font-body-lg-text-decoration:none;--ref-font-body-lg-font-family:Inter;--ref-font-body-lg-font-weight:400;--ref-font-body-lg-font-style:normal;--ref-font-body-lg-font-stretch:normal;--ref-font-body-lg-letter-spacing:0px;--ref-font-body-lg-line-height:24px;--ref-font-body-lg-paragraph-indent:0px;--ref-font-body-lg-paragraph-spacing:0px;--ref-font-body-lg-text-case:none;--ref-font-body-lg-mono-font-size:18px;--ref-font-body-lg-mono-text-decoration:none;--ref-font-body-lg-mono-font-family:JetBrains Mono;--ref-font-body-lg-mono-font-weight:400;--ref-font-body-lg-mono-font-style:normal;--ref-font-body-lg-mono-font-stretch:normal;--ref-font-body-lg-mono-letter-spacing:0px;--ref-font-body-lg-mono-line-height:24px;--ref-font-body-lg-mono-paragraph-indent:0px;--ref-font-body-lg-mono-paragraph-spacing:0px;--ref-font-body-lg-mono-text-case:none;--ref-font-headline-lg-font-size:36px;--ref-font-headline-lg-text-decoration:none;--ref-font-headline-lg-font-family:Inter;--ref-font-headline-lg-font-weight:400;--ref-font-headline-lg-font-style:normal;--ref-font-headline-lg-font-stretch:normal;--ref-font-headline-lg-letter-spacing:0px;--ref-font-headline-lg-line-height:48px;--ref-font-headline-lg-paragraph-indent:0px;--ref-font-headline-lg-paragraph-spacing:0px;--ref-font-headline-lg-text-case:none;--sys-elevation-raised-low-shadow-type:dropShadow;--sys-elevation-raised-low-radius:2;--sys-elevation-raised-low-color:rgba(12, 21, 48, 0.0784313725);--sys-elevation-raised-low-offset-x:0;--sys-elevation-raised-low-offset-y:1;--sys-elevation-raised-low-spread:0;--sys-elevation-raised-md-shadow-type:dropShadow;--sys-elevation-raised-md-radius:20;--sys-elevation-raised-md-color:rgba(12, 21, 48, 0.1215686275);--sys-elevation-raised-md-offset-x:0;--sys-elevation-raised-md-offset-y:6;--sys-elevation-raised-md-spread:0;--sys-elevation-raised-high-shadow-type:dropShadow;--sys-elevation-raised-high-radius:32;--sys-elevation-raised-high-color:rgba(12, 21, 84, 0.1607843137);--sys-elevation-raised-high-offset-x:0;--sys-elevation-raised-high-offset-y:12;--sys-elevation-raised-high-spread:0;--sys-radius-xs:4px;--sys-radius-sm:8px;--sys-radius-md:16px;--sys-radius-lg:32px;--sys-radius-none:0px;--sys-radius-full:999px;--sys-space-md:16px;--sys-space-lg:32px;--sys-space-xl:56px;--sys-space-xs:4px;--sys-space-sm:8px;--sys-typo-family-sans:system-ui, -apple-system, sans-serif;--sys-typo-family-mono: SF Mono , Monaco, Roboto Mono , monospace;--sys-breakpoint-mobile:768px;--sys-breakpoint-tablet:1024px;--sys-breakpoint-desktop:1280px;--sys-opacity-muted:0.72;--sys-opacity-medium:0.84;--sys-opacity-strong:0.92;--sys-size-lg:48px;--sys-size-md:36px;--sys-size-sm:24px;--sys-size-icon-sm:20px;--sys-size-icon-md:24px;--sys-size-icon-lg:36px;--sys-size-iconfont-sm:8px;--sys-size-iconfont-md:12px;--sys-size-iconfont-lg:18px;--sys-size-xl:58px;--sys-size-xs:18px;--sys-font-label-lg-font-size:14px;--sys-font-label-lg-text-decoration:none;--sys-font-label-lg-font-family:Inter;--sys-font-label-lg-font-weight:400;--sys-font-label-lg-font-style:normal;--sys-font-label-lg-font-stretch:normal;--sys-font-label-lg-letter-spacing:0px;--sys-font-label-lg-line-height:16px;--sys-font-label-lg-paragraph-indent:0px;--sys-font-label-lg-paragraph-spacing:0px;--sys-font-label-lg-text-case:none;--sys-font-label-md-font-size:12px;--sys-font-label-md-text-decoration:none;--sys-font-label-md-font-family:Inter;--sys-font-label-md-font-weight:400;--sys-font-label-md-font-style:normal;--sys-font-label-md-font-stretch:normal;--sys-font-label-md-letter-spacing:0px;--sys-font-label-md-line-height:16px;--sys-font-label-md-paragraph-indent:0px;--sys-font-label-md-paragraph-spacing:0px;--sys-font-label-md-text-case:none;--sys-font-label-sm-font-size:10px;--sys-font-label-sm-text-decoration:none;--sys-font-label-sm-font-family:Roboto;--sys-font-label-sm-font-weight:700;--sys-font-label-sm-font-style:normal;--sys-font-label-sm-font-stretch:normal;--sys-font-label-sm-letter-spacing:0.7px;--sys-font-label-sm-line-height:14px;--sys-font-label-sm-paragraph-indent:0px;--sys-font-label-sm-paragraph-spacing:0px;--sys-font-label-sm-text-case:none;--sys-font-label-lg-underlined-font-size:14px;--sys-font-label-lg-underlined-text-decoration:underline;--sys-font-label-lg-underlined-font-family:Inter;--sys-font-label-lg-underlined-font-weight:400;--sys-font-label-lg-underlined-font-style:normal;--sys-font-label-lg-underlined-font-stretch:normal;--sys-font-label-lg-underlined-letter-spacing:0px;--sys-font-label-lg-underlined-line-height:16px;--sys-font-label-lg-underlined-paragraph-indent:0px;--sys-font-label-lg-underlined-paragraph-spacing:0px;--sys-font-label-lg-underlined-text-case:none;--sys-font-label-lg-mono-font-size:14px;--sys-font-label-lg-mono-text-decoration:none;--sys-font-label-lg-mono-font-family:JetBrains Mono;--sys-font-label-lg-mono-font-weight:400;--sys-font-label-lg-mono-font-style:normal;--sys-font-label-lg-mono-font-stretch:normal;--sys-font-label-lg-mono-letter-spacing:0px;--sys-font-label-lg-mono-line-height:16px;--sys-font-label-lg-mono-paragraph-indent:0px;--sys-font-label-lg-mono-paragraph-spacing:0px;--sys-font-label-lg-mono-text-case:none;--sys-font-label-sm-mono-font-size:10px;--sys-font-label-sm-mono-text-decoration:none;--sys-font-label-sm-mono-font-family:JetBrains Mono;--sys-font-label-sm-mono-font-weight:700;--sys-font-label-sm-mono-font-style:normal;--sys-font-label-sm-mono-font-stretch:normal;--sys-font-label-sm-mono-letter-spacing:0.4px;--sys-font-label-sm-mono-line-height:14px;--sys-font-label-sm-mono-paragraph-indent:0px;--sys-font-label-sm-mono-paragraph-spacing:0px;--sys-font-label-sm-mono-text-case:none;--sys-font-title-sm-font-size:22px;--sys-font-title-sm-text-decoration:none;--sys-font-title-sm-font-family:Inter;--sys-font-title-sm-font-weight:400;--sys-font-title-sm-font-style:normal;--sys-font-title-sm-font-stretch:normal;--sys-font-title-sm-letter-spacing:0px;--sys-font-title-sm-line-height:28px;--sys-font-title-sm-paragraph-indent:0px;--sys-font-title-sm-paragraph-spacing:0px;--sys-font-title-sm-text-case:none;--sys-font-title-sm-mono-font-size:22px;--sys-font-title-sm-mono-text-decoration:none;--sys-font-title-sm-mono-font-family:IBM Plex Mono;--sys-font-title-sm-mono-font-weight:400;--sys-font-title-sm-mono-font-style:normal;--sys-font-title-sm-mono-font-stretch:normal;--sys-font-title-sm-mono-letter-spacing:0.66px;--sys-font-title-sm-mono-line-height:28px;--sys-font-title-sm-mono-paragraph-indent:0px;--sys-font-title-sm-mono-paragraph-spacing:0px;--sys-font-title-sm-mono-text-case:none;--sys-font-body-lg-font-size:18px;--sys-font-body-lg-text-decoration:none;--sys-font-body-lg-font-family:Inter;--sys-font-body-lg-font-weight:400;--sys-font-body-lg-font-style:normal;--sys-font-body-lg-font-stretch:normal;--sys-font-body-lg-letter-spacing:0px;--sys-font-body-lg-line-height:24px;--sys-font-body-lg-paragraph-indent:0px;--sys-font-body-lg-paragraph-spacing:0px;--sys-font-body-lg-text-case:none;--sys-font-body-lg-mono-font-size:18px;--sys-font-body-lg-mono-text-decoration:none;--sys-font-body-lg-mono-font-family:JetBrains Mono;--sys-font-body-lg-mono-font-weight:400;--sys-font-body-lg-mono-font-style:normal;--sys-font-body-lg-mono-font-stretch:normal;--sys-font-body-lg-mono-letter-spacing:0px;--sys-font-body-lg-mono-line-height:24px;--sys-font-body-lg-mono-paragraph-indent:0px;--sys-font-body-lg-mono-paragraph-spacing:0px;--sys-font-body-lg-mono-text-case:none;--sys-font-headline-lg-font-size:36px;--sys-font-headline-lg-text-decoration:none;--sys-font-headline-lg-font-family:Inter;--sys-font-headline-lg-font-weight:400;--sys-font-headline-lg-font-style:normal;--sys-font-headline-lg-font-stretch:normal;--sys-font-headline-lg-letter-spacing:0px;--sys-font-headline-lg-line-height:48px;--sys-font-headline-lg-paragraph-indent:0px;--sys-font-headline-lg-paragraph-spacing:0px;--sys-font-headline-lg-text-case:none}:root,html[data-theme=light]{color-scheme:light;--sys-color-text-base:#070707;--sys-color-text-muted:#919191;--sys-color-text-danger-on:#fff8f7;--sys-color-text-primary:#fbfcff;--sys-color-text-subtle:#5e5e5e;--sys-color-surface-base:#ffffff;--sys-color-surface-muted:#e2e2e2;--sys-color-surface-subtle:#f9f9f9;--sys-color-surface-primary:#121928;--sys-color-surface-primary-hover:#474e5f;--sys-color-surface-subtle-hover:#eeeeee;--sys-color-surface-danger:#a80324;--sys-color-surface-danger-hover:#ff7073;--sys-color-surface-base-hover:#eeeeee;--sys-color-border-base:#303030;--sys-color-border-strong:#070707;--sys-color-border-subtle:#c6c6c6;--sys-color-border-muted:#919191;--sys-color-feedback-error:#b9192f;--comp-switch-track-bg:#070707;--comp-switch-handle-bg:#f9f9f9;--comp-switch-label-color:#070707}@media(prefers-color-scheme: dark){:root:not([data-theme]),html[data-theme=dark]{color-scheme:dark;--sys-color-text-base:#f9f9f9;--sys-color-text-muted:#f9f9f9;--sys-color-text-danger-on:#fff8f7;--sys-color-text-primary:#0b1220;--sys-color-text-subtle:#c6c6c6;--sys-color-surface-base:#474747;--sys-color-surface-muted:#131313;--sys-color-surface-subtle:#1f1f1f;--sys-color-surface-primary:#c8cee3;--sys-color-surface-primary-hover:#9198ac;--sys-color-surface-subtle-hover:#5e5e5e;--sys-color-surface-danger:#b9192f;--sys-color-surface-danger-hover:#b9192f;--sys-color-surface-base-hover:#474747;--sys-color-border-base:#5e5e5e;--sys-color-border-strong:#c6c6c6;--sys-color-border-subtle:#5e5e5e;--sys-color-border-muted:#5e5e5e;--sys-color-feedback-error:#ff525c;--comp-switch-track-bg:#f9f9f9;--comp-switch-handle-bg:#070707;--comp-switch-label-color:#f9f9f9}}html[data-theme=dark]{color-scheme:dark;--sys-color-text-base:#f9f9f9;--sys-color-text-muted:#f9f9f9;--sys-color-text-danger-on:#fff8f7;--sys-color-text-primary:#0b1220;--sys-color-text-subtle:#c6c6c6;--sys-color-surface-base:#474747;--sys-color-surface-muted:#131313;--sys-color-surface-subtle:#1f1f1f;--sys-color-surface-primary:#c8cee3;--sys-color-surface-primary-hover:#9198ac;--sys-color-surface-subtle-hover:#5e5e5e;--sys-color-surface-danger:#b9192f;--sys-color-surface-danger-hover:#b9192f;--sys-color-surface-base-hover:#474747;--sys-color-border-base:#5e5e5e;--sys-color-border-strong:#c6c6c6;--sys-color-border-subtle:#5e5e5e;--sys-color-border-muted:#5e5e5e;--sys-color-feedback-error:#ff525c}:root{--page-width: 1160px}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--sys-color-surface-subtle);color:var(--sys-color-text-base);font-family:var(--ref-typo-family-sans);font-size:15px;line-height:1.6;-webkit-font-smoothing:antialiased}a{color:inherit;text-underline-offset:4px}button,input,select,textarea{font:inherit}button,a,input,select,textarea{-webkit-tap-highlight-color:rgba(0,0,0,0)}button,select{cursor:pointer}button:disabled{cursor:wait;opacity:.6}button{color:inherit}:focus-visible{outline:2px solid var(--sys-color-border-strong);outline-offset:4px}[hidden]{display:none !important}h1,h2,h3,p{margin:0}h1,h2,h3{line-height:1.2;font-weight:550}h1{letter-spacing:-0.055em}h2{font-size:26px;letter-spacing:-0.035em}h3{font-size:18px;letter-spacing:-0.02em}code,.extension-name,.art-caption,.eyebrow,kbd,.publisher-name{font-family:var(--ref-typo-family-mono)}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.skip-link{position:fixed;top:-100px;left:16px;padding:12px;background:var(--sys-color-surface-base);z-index:20}.skip-link:focus{top:12px}.site-header{border-bottom:1px solid var(--sys-color-border-subtle);background:var(--sys-color-surface-subtle)}.header-inner{max-width:var(--page-width);min-height:88px;margin:auto;display:flex;align-items:center;gap:36px}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;font-weight:650;font-size:17px;letter-spacing:-0.04em}.brand-mark,.art-core{background:var(--sys-color-surface-primary);color:var(--sys-color-text-primary);font-weight:500;letter-spacing:-0.13em}.brand-mark{width:36px;height:36px;display:grid;grid-auto-flow:column;justify-content:center;align-items:center;font-size:26px;padding-right:4px}.brand-mark span,.art-core span{font-style:italic}.brand-label{color:var(--sys-color-text-subtle);margin-left:8px;padding-left:13px;border-left:1px solid var(--sys-color-border-subtle);font-size:14px;font-weight:400;letter-spacing:0}nav{display:flex;align-self:stretch;align-items:center;gap:32px;margin-left:auto}nav a{text-decoration:none;font-size:13px;color:var(--sys-color-text-subtle);height:100%;display:flex;align-items:center;gap:8px;border-bottom:2px solid rgba(0,0,0,0);padding-top:2px}nav a[aria-current]{color:var(--sys-color-text-base);border-color:var(--sys-color-border-strong)}.theme-button{border:1px solid var(--sys-color-border-subtle);background:rgba(0,0,0,0);border-radius:var(--sys-radius-full);width:32px;height:32px;display:grid;place-items:center;font-size:20px;line-height:1}.hero{max-width:var(--page-width);margin:auto;min-height:462px;display:grid;grid-template-columns:1.15fr 1fr;align-items:center;gap:40px;padding:64px 0}.eyebrow{font-size:10px;letter-spacing:.12em;font-weight:500;color:var(--sys-color-text-subtle);display:flex;align-items:center;gap:8px}.tiny-square{display:inline-block;width:5px;height:5px;background:currentColor;flex:none}.hero h1{font-size:clamp(42px,4.8vw,64px);line-height:1.08;margin-top:24px}.hero h1>span,.publisher-heading h1>span{color:var(--sys-color-text-subtle)}.hero-description{margin-top:24px;color:var(--sys-color-text-subtle);font-size:16px;line-height:1.8}.text-link{font-size:13px;text-decoration:none;display:inline-flex;gap:18px;align-items:center;font-weight:500}.text-link:hover{text-decoration:underline}.hero .text-link{margin-top:24px}.hero-art{position:relative;height:330px;background-image:radial-gradient(var(--sys-color-border-subtle) 0.7px, transparent 0.7px);background-size:20px 20px;mask-image:linear-gradient(to right, transparent, #000 12%, #000 88%, transparent)}.orbit{position:absolute;border:1px solid var(--sys-color-border-subtle);border-radius:50%;top:50%;left:50%;transform:translate(-50%, -50%) rotate(-25deg)}.orbit-one{width:320px;height:170px}.orbit-two{width:220px;height:270px;transform:translate(-50%, -50%) rotate(40deg)}.art-core{position:absolute;left:50%;top:50%;transform:translate(-50%, -50%) rotate(-8deg);width:98px;height:98px;border-radius:22px;display:flex;align-items:center;justify-content:center;font-size:72px;padding-right:7px;box-shadow:10px 12px 0 var(--sys-color-surface-muted)}.art-tile{position:absolute;display:grid;place-items:center;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);width:60px;height:60px;border-radius:var(--sys-radius-md);font-family:var(--ref-typo-family-mono);font-size:22px}.tile-code{top:36px;left:26%;transform:rotate(-12deg)}.tile-feed{top:100px;right:8%;transform:rotate(12deg);font-size:38px}.tile-mail{bottom:46px;left:15%;transform:rotate(8deg)}.tile-plus{bottom:43px;right:21%;transform:rotate(-10deg)}.art-caption{position:absolute;bottom:0;left:0;width:100%;text-align:center;font-size:8px;letter-spacing:.15em;color:var(--sys-color-text-subtle)}.catalog-section{max-width:var(--page-width);margin:auto;padding:36px 0 64px;border-top:1px solid var(--sys-color-border-subtle);scroll-margin-top:24px}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:24px}.section-heading .eyebrow{margin-bottom:12px}.count{display:inline-block;vertical-align:middle;font-size:11px;font-weight:400;padding:2px 7px;border:1px solid var(--sys-color-border-subtle);border-radius:5px;letter-spacing:0;margin-left:8px;line-height:1.5}.section-note{font-size:12px;color:var(--sys-color-text-subtle)}.search-form{display:flex;gap:12px;margin:28px 0}.search-input{display:flex;align-items:center;flex:1;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);padding:0 14px;gap:12px;border-radius:var(--sys-radius-xs)}.search-input:focus-within{outline:2px solid var(--sys-color-border-strong);outline-offset:2px}.search-input>span{font-size:25px}.search-input input{border:0;background:rgba(0,0,0,0);width:100%;padding:11px 0;outline:none}.search-input kbd{border:1px solid var(--sys-color-border-subtle);color:var(--sys-color-text-subtle);border-radius:3px;padding:0 6px;font-size:11px}input,select,textarea{color:var(--sys-color-text-base);background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-xs);padding:11px 12px;min-width:0;max-width:100%}input::placeholder,textarea::placeholder{color:var(--sys-color-text-subtle);opacity:.85}.button{display:inline-flex;align-items:center;justify-content:center;gap:18px;padding:11px 18px;border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-xs);background:var(--sys-color-surface-base);color:var(--sys-color-text-base);text-decoration:none;font-size:12px;font-weight:500;line-height:1.5;white-space:nowrap}.button:hover{background:var(--sys-color-surface-subtle-hover)}.button-primary{background:var(--sys-color-surface-primary);color:var(--sys-color-text-primary);border-color:rgba(0,0,0,0)}.button-primary:hover{background:var(--sys-color-surface-primary-hover)}.search-summary{display:flex;gap:20px;font-size:13px;margin-bottom:20px}.catalog-grid{display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:16px;list-style:none;padding:0;margin:0}.extension-card{display:flex;flex-direction:column;gap:24px;min-height:252px;padding:24px;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm);text-decoration:none;transition:transform 160ms,border-color 160ms}.extension-card:hover{transform:translateY(-3px);border-color:var(--sys-color-border-strong)}.card-top{display:flex;justify-content:space-between;align-items:flex-start}.extension-icon{display:grid;place-items:center;width:48px;height:48px;border-radius:12px;background:var(--sys-color-surface-subtle);border:1px solid var(--sys-color-border-subtle);font-size:15px;font-weight:600;letter-spacing:-0.08em}.card-arrow{color:var(--sys-color-text-subtle);font-size:19px}.publisher-name{font-size:10px;letter-spacing:.02em;color:var(--sys-color-text-subtle);margin-bottom:7px;text-decoration:none}.extension-card h3{font-size:20px;overflow-wrap:anywhere}.extension-name{color:var(--sys-color-text-subtle);font-size:11px;margin-top:9px;overflow-wrap:anywhere}.card-bottom{margin-top:auto;padding-top:17px;border-top:1px solid var(--sys-color-border-subtle);display:flex;justify-content:space-between;gap:12px;font-size:10px;color:var(--sys-color-text-subtle)}.card-bottom>span:first-child{display:flex;gap:6px;align-items:center}.builder-callout{max-width:var(--page-width);margin:0 auto 64px;padding:36px;border:1px solid var(--sys-color-border-subtle);background:var(--sys-color-surface-muted);border-radius:var(--sys-radius-sm);display:flex;align-items:center;justify-content:space-between;gap:24px}.builder-callout h2{margin:12px 0 8px}.builder-callout p:not(.eyebrow){color:var(--sys-color-text-subtle);font-size:13px}.site-footer{max-width:var(--page-width);margin:auto;padding:28px 0 36px;border-top:1px solid var(--sys-color-border-subtle);display:flex;justify-content:space-between;gap:20px;color:var(--sys-color-text-subtle);font-size:11px}.site-footer>div{display:flex;align-items:center;gap:24px}.site-footer a{text-decoration:none}.footer-wordmark{font-size:15px;font-weight:650;letter-spacing:-0.05em;color:var(--sys-color-text-base)}.empty-state{text-align:center;border:1px dashed var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm);padding:56px 24px}.empty-symbol{font-size:38px;display:block;margin-bottom:16px}.empty-state p{color:var(--sys-color-text-subtle);margin:14px auto 24px;max-width:480px}.empty-state .eyebrow{justify-content:center}.preview-banner{text-align:center;padding:10px;font-size:12px;background:var(--sys-color-surface-muted)}.page-container{max-width:var(--page-width);margin:auto;padding:44px 0 80px}.back-link{display:inline-block;font-size:12px;text-decoration:none;color:var(--sys-color-text-subtle);margin-bottom:36px}.detail-heading{display:flex;align-items:center;gap:24px;padding-bottom:40px;border-bottom:1px solid var(--sys-color-border-subtle)}.extension-icon.large{width:88px;height:88px;border-radius:var(--sys-radius-md);font-size:28px;flex-shrink:0;background:var(--sys-color-surface-base)}.detail-heading h1{font-size:clamp(30px,4vw,46px);overflow-wrap:anywhere}.detail-heading .button{margin-left:auto}.detail-layout{display:grid;grid-template-columns:minmax(0, 1fr) 290px;gap:64px;padding-top:40px}.detail-content h2{font-size:24px}.lead{color:var(--sys-color-text-subtle);font-size:16px;line-height:1.8}.badge{background:var(--sys-color-surface-muted);font-size:10px;border-radius:var(--sys-radius-xs);padding:4px 8px;white-space:nowrap}.distribution-panel{background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm);padding:24px;margin-top:24px}.distribution-title{display:flex;align-items:center;gap:14px}.distribution-title p{color:var(--sys-color-text-subtle);font-size:12px;margin-top:4px}.format-icon{font-family:var(--ref-typo-family-mono);background:var(--sys-color-surface-muted);border-radius:var(--sys-radius-xs);width:40px;height:40px;display:grid;place-items:center}.metadata{margin:24px 0;font-size:12px}.metadata>div{display:grid;grid-template-columns:140px minmax(0, 1fr);padding:9px 0;border-top:1px solid var(--sys-color-border-subtle);gap:16px}.metadata dt{color:var(--sys-color-text-subtle)}.metadata dd{margin:0;overflow-wrap:anywhere}.info-note{padding:20px;border-left:2px solid var(--sys-color-border-subtle);margin-top:28px;font-size:12px;color:var(--sys-color-text-subtle)}.info-note strong{color:var(--sys-color-text-base);font-weight:500}.info-note p{margin-top:6px}.sidebar-panel{border-bottom:1px solid var(--sys-color-border-subtle);padding-bottom:24px;margin-bottom:24px}.sidebar-panel h2{font-size:32px;margin:12px 0 8px;overflow-wrap:anywhere}.sidebar-panel p:not(.eyebrow){font-size:12px;color:var(--sys-color-text-subtle);margin-bottom:20px}.sidebar-panel h3{font-size:14px}.version-list{list-style:none;padding:0;margin:16px 0 0;max-height:400px;overflow:auto}.version-list a{display:flex;justify-content:space-between;gap:8px;text-decoration:none;font-size:12px;padding:10px;border-radius:var(--sys-radius-xs);overflow-wrap:anywhere}.version-list a[aria-current]{background:var(--sys-color-surface-muted)}.version-list span{font-size:10px;color:var(--sys-color-text-subtle)}.publisher-heading{margin:16px 0 48px}.publisher-heading h1{font-size:clamp(40px,5vw,60px);margin:20px 0}.connect-layout{display:grid;grid-template-columns:1fr 1fr;gap:80px;align-items:center}.connect-card{padding:32px;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm)}.connect-card h2{margin:12px 0}.connect-card>p:not(.eyebrow){color:var(--sys-color-text-subtle);font-size:13px}.connect-card form{margin-top:24px}label{display:block;font-size:12px;font-weight:500;margin-bottom:7px}.connect-card input{width:100%}.field-help{color:var(--sys-color-text-subtle);font-size:11px;margin-top:8px}.connect-card button{margin-top:24px;width:100%}.steps{list-style:none;padding:0;margin:28px 0}.steps li{display:flex;gap:20px;margin-bottom:28px}.steps li>span{font-family:var(--ref-typo-family-mono);color:var(--sys-color-text-subtle);font-size:12px;padding-top:3px}.steps h3{font-size:16px}.steps p{color:var(--sys-color-text-subtle);font-size:13px;margin-top:8px}.workspace-heading,.workspace-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:28px}.workspace-heading{border-bottom:1px solid var(--sys-color-border-subtle);padding-bottom:24px}.workspace-heading h2{margin-top:8px}.workspace-toolbar>div{display:flex;gap:8px}.release-row{display:flex;align-items:center;justify-content:space-between;gap:24px;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm);padding:20px;margin-bottom:12px}.release-row h3{font-size:16px;overflow-wrap:anywhere}.release-row p{color:var(--sys-color-text-subtle);font-size:12px;margin:6px 0;overflow-wrap:anywhere}.release-actions{display:flex;gap:8px;flex-wrap:wrap}.form-status{font-size:12px;margin-top:12px;overflow-wrap:anywhere}.form-status.error{color:var(--sys-color-feedback-error)}.form-status:empty{display:none}dialog{color:var(--sys-color-text-base);background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-md);padding:28px;width:min(620px,100% - 32px);max-height:calc(100dvh - 48px);overflow:auto}dialog::backdrop{background:rgba(0,0,0,.5333333333)}.dialog-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:24px}.dialog-heading h2{margin-top:8px;font-size:24px}.icon-button{background:rgba(0,0,0,0);border:1px solid var(--sys-color-border-subtle);width:32px;height:32px;flex-shrink:0;font-size:24px;display:grid;place-items:center;line-height:1;border-radius:var(--sys-radius-xs)}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.form-grid input,dialog textarea{width:100%}.full-width{grid-column:1/-1}dialog .info-note{margin:24px 0}dialog form>.button{margin-top:20px}#action-description{margin-bottom:24px;font-size:13px;color:var(--sys-color-text-subtle)}.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);padding:12px 20px;border-radius:var(--sys-radius-sm);background:var(--sys-color-surface-primary);color:var(--sys-color-text-primary);font-size:12px;z-index:100}.toast:empty{display:none}@media(min-width: 1500px){.hero{min-height:510px}}@media(max-width: 1240px){.header-inner,.hero,.catalog-section,.builder-callout,.site-footer,.page-container{margin-left:32px;margin-right:32px}}@media(max-width: 900px){.hero{grid-template-columns:1.2fr 1fr;gap:0}.hero-art{transform:scale(0.85);transform-origin:right}.section-note{display:none}.catalog-grid{grid-template-columns:repeat(2, minmax(0, 1fr))}.detail-layout{gap:32px;grid-template-columns:minmax(0, 1fr) 220px}.connect-layout{gap:32px}.builder-callout{padding:28px}}@media(max-width: 640px){.header-inner,.hero,.catalog-section,.builder-callout,.site-footer,.page-container{margin-left:20px;margin-right:20px}.header-inner{min-height:72px;gap:16px}.brand{font-size:16px;gap:8px}.brand-label{display:none}nav{gap:20px}nav .docs-link{display:none}.hero{grid-template-columns:1fr;padding:48px 0 36px}.hero h1{font-size:44px}.hero-art{height:260px;transform:none;margin-top:32px}.hero-description{font-size:14px}.art-core{width:76px;height:76px;font-size:54px}.art-tile{width:48px;height:48px}.tile-code{top:20px}.tile-mail{bottom:30px}.tile-plus{bottom:26px}.orbit-one{width:270px;height:140px}.orbit-two{width:180px;height:210px}.catalog-section{padding-top:28px;padding-bottom:36px}h2{font-size:23px}.search-form{flex-wrap:wrap;gap:10px}.search-input{flex-basis:100%}.search-form select{flex:1}.search-form .button{min-width:88px}.catalog-grid{grid-template-columns:1fr;gap:12px}.extension-card{min-height:222px;padding:20px;gap:20px}.builder-callout{flex-direction:column;align-items:flex-start;margin-bottom:36px;padding:24px}.builder-callout .eyebrow{font-size:8px}.site-footer{flex-direction:column;padding-top:24px}.site-footer>div{justify-content:space-between}.detail-heading{flex-wrap:wrap;gap:16px}.extension-icon.large{width:64px;height:64px;font-size:22px}.detail-heading>div:nth-child(2){flex:1;min-width:0}.detail-heading .button{margin:8px 0 0}.detail-layout{grid-template-columns:1fr}.detail-content .section-heading{align-items:flex-start}.detail-content h2{font-size:22px}.metadata>div{grid-template-columns:100px minmax(0, 1fr)}.distribution-panel{padding:20px}.detail-sidebar{border-top:1px solid var(--sys-color-border-subtle);padding-top:24px}.page-container{padding:32px 0 48px}.publisher-heading{margin-bottom:32px}.connect-layout{grid-template-columns:1fr;gap:40px}.connect-card{padding:24px}.workspace-toolbar{align-items:flex-start;flex-direction:column}.release-row{align-items:flex-start;flex-direction:column;gap:12px}.form-grid{grid-template-columns:1fr}dialog{padding:20px}.search-summary{flex-wrap:wrap}.lead{font-size:14px}}@media(prefers-reduced-motion: reduce){html{scroll-behavior:auto}.extension-card{transition:none}}.release-pagination{display:flex;margin-top:24px;gap:16px;justify-content:flex-end;font-size:12px}.release-pagination button:disabled,.release-actions button:disabled{cursor:default} diff --git a/src/inkcre_extension_registry/service/static/registry.js b/src/inkcre_extension_registry/service/static/registry.js index e21995d..4f5ac9c 100644 --- a/src/inkcre_extension_registry/service/static/registry.js +++ b/src/inkcre_extension_registry/service/static/registry.js @@ -88,6 +88,7 @@ if ($('#connect-form')) { for (const dialog of document.querySelectorAll('dialog')) dialog.close() } async function api(path, { method = 'GET', body } = {}) { + const current = connection const headers = { Authorization: `Bearer ${credential}` } if (body && !(body instanceof FormData)) { headers['Content-Type'] = 'application/json' @@ -110,6 +111,9 @@ if ($('#connect-form')) { ) } const data = await response.json().catch(() => null) + if (current !== connection || current.signal.aborted) { + throw new DOMException('Publisher disconnected', 'AbortError') + } if (!response.ok) { if (response.status === 401) { disconnect() @@ -331,11 +335,11 @@ if ($('#connect-form')) { if (dialog.querySelector('[aria-busy="true"]')) event.preventDefault() }) } - async function updateAfterChange(message) { + async function updateAfterChange(message, pageOffset = offset) { announce(message) status('#workspace-status', message) try { - await refresh() + await refresh(pageOffset) } catch (error) { if (error.name !== 'AbortError') status( @@ -365,7 +369,7 @@ if ($('#connect-form')) { }, }) $('#release-dialog').close() - await updateAfterChange('Release prepared. Upload your Web ZIP, then publish when ready.') + await updateAfterChange('Release prepared. Upload your Web ZIP, then publish when ready.', 0) }) }) $('#action-form').addEventListener('submit', (event) => { diff --git a/src/inkcre_extension_registry/service/templates/base.html b/src/inkcre_extension_registry/service/templates/base.html index 964c7d8..fee601b 100644 --- a/src/inkcre_extension_registry/service/templates/base.html +++ b/src/inkcre_extension_registry/service/templates/base.html @@ -1,30 +1,83 @@ - - - - {% if noindex %}{% endif %} + + + + {% if noindex %} + + {% endif %} {{ title }} · InKCre Extension Registry - {% if not preview %}{% endif %} + {% if not preview %} + + {% endif %} - {% if preview %}
    Design preview · Sample catalog. Extension links open the live Registry.
    {% endif %} + {% if preview %} +
    + Design preview · Sample catalog. Extension links open live API metadata; Publish opens the + Toolkit guide. +
    + {% endif %}
    {% block content %}{% endblock %}
    - +
    diff --git a/src/inkcre_extension_registry/service/templates/catalog.html b/src/inkcre_extension_registry/service/templates/catalog.html index a18868d..518aa09 100644 --- a/src/inkcre_extension_registry/service/templates/catalog.html +++ b/src/inkcre_extension_registry/service/templates/catalog.html @@ -1,23 +1,157 @@ -{% extends "base.html" %} -{% block content %} +{% extends "base.html" %} {% block content %}
    -

    THE INKCRE EXTENSION REGISTRY

    Your workspace.
    More possibilities.

    Bring your tools, sources, and ideas together.
    Find the next addition to your InKCre.

    Find your extension
    - +
    +

    + + THE INKCRE EXTENSION REGISTRY +

    +

    + Your workspace. +
    + More possibilities. +

    +

    + Bring your tools, sources, and ideas together. +
    + Find the next addition to your InKCre. +

    + + Find your extension + + +
    +
    -

    MADE FOR YOUR WORKSPACE

    Explore extensions {{ total }}

    Independent tools. Connected possibilities.
    +
    +
    +

    MADE FOR YOUR WORKSPACE

    +

    + Explore extensions + {{ total }} +

    +
    + Independent tools. Connected possibilities. +
    {% if not preview %} - {% if query or namespace %}

    {{ extensions | length }} result{{ '' if extensions | length == 1 else 's' }}{% if query %} for “{{ query }}”{% endif %}{% if namespace %} by {{ namespace }}{% endif %}Clear filters

    {% endif %} + {% if query or namespace %} +

    + {{ extensions | length }} result{{ '' if extensions | length == 1 else 's' }}{% if query %} for + “{{ query }}”{% endif %}{% if namespace %} by + {{ namespace }}{% endif %} + Clear filters +

    + {% endif %} {% endif %} {% if extensions %} + + {% else %} +
    + +

    + {% if total %}Nothing here just yet.{% else %}Be the first to build + something useful.{% endif %} +

    +

    + {% if total %}Try another name or choose a different + publisher.{% else %}Published extensions will appear here, ready for everyone to + discover.{% endif %} +

    + + {% if total %}Clear filters{% else %}Publish an + extension{% endif %} + + +
    {% endif %} - {% if extensions %} - - {% else %}

    {% if total %}Nothing here just yet.{% else %}Be the first to build something useful.{% endif %}

    {% if total %}Try another name or choose a different publisher.{% else %}Published extensions will appear here, ready for everyone to discover.{% endif %}

    {% if total %}Clear filters{% else %}Publish an extension{% endif %}
    {% endif %}
    -

    BUILT BY YOU. SHARED WITH EVERYONE.

    Have something to add?

    Give your idea a place in someone else’s workspace.

    Publish an extension
    +
    +
    +

    BUILT BY YOU. SHARED WITH EVERYONE.

    +

    Have something to add?

    +

    Give your idea a place in someone else’s workspace.

    +
    + + Publish an extension + + +
    {% endblock %} diff --git a/src/inkcre_extension_registry/service/templates/detail.html b/src/inkcre_extension_registry/service/templates/detail.html index 73aa64f..b5f1cdf 100644 --- a/src/inkcre_extension_registry/service/templates/detail.html +++ b/src/inkcre_extension_registry/service/templates/detail.html @@ -1,17 +1,129 @@ -{% extends "base.html" %} -{% block content %} +{% extends "base.html" %} {% block content %}
    ← All extensions -
    {{ publisher }}

    {{ extension.nickname }}

    {{ extension.name }}

    +
    + +
    + + {{ publisher }} + +

    {{ extension.nickname }}

    +

    {{ extension.name }}

    +
    + +
    -

    Make it part of your workspace.

    Published
    -

    Use this extension’s ID in your InKCre deployment. Choose a distribution below to inspect its package and host requirements.

    - {% if release.python %}

    Python

    For your Core host

    Browse package files ↗
    {% endif %} - {% if release.module_federation %}

    Web

    For your browser host

    View manifest ↗
    {% endif %} - +
    +

    Make it part of your workspace.

    + Published +
    +

    + Use this extension’s ID in your InKCre deployment. Choose a distribution below to inspect + its package and host requirements. +

    + {% if release.python %} +
    +
    + +
    +

    Python

    +

    For your Core host

    +
    +
    + + Browse package files ↗ +
    + {% endif %} {% if release.module_federation %} +
    +
    + +
    +

    Web

    +

    For your browser host

    +
    +
    + + View manifest ↗ +
    + {% endif %} +
    - +
    {% endblock %} diff --git a/src/inkcre_extension_registry/service/templates/error.html b/src/inkcre_extension_registry/service/templates/error.html index d0097b1..759c617 100644 --- a/src/inkcre_extension_registry/service/templates/error.html +++ b/src/inkcre_extension_registry/service/templates/error.html @@ -1,2 +1,11 @@ -{% extends "base.html" %} -{% block content %}
    ← All extensions

    {{ code }}

    {{ title }}

    {{ message }}

    Explore extensions →
    {% endblock %} +{% extends "base.html" %} {% block content %} +
    + ← All extensions +
    +

    {{ code }}

    +

    {{ title }}

    +

    {{ message }}

    + Explore extensions → +
    +
    +{% endblock %} diff --git a/src/inkcre_extension_registry/service/templates/publish.html b/src/inkcre_extension_registry/service/templates/publish.html index 2e7220a..bf94f6d 100644 --- a/src/inkcre_extension_registry/service/templates/publish.html +++ b/src/inkcre_extension_registry/service/templates/publish.html @@ -1,11 +1,215 @@ -{% extends "base.html" %} -{% block content %} +{% extends "base.html" %} {% block content %}
    -

    THE PUBLISHER WORKSPACE

    Made by you.
    Ready for everyone.

    Take your extension from a local idea to a published release.

    - -

    YOUR WORKSPACE

    Connect as a publisher

    Use the credential issued for your publisher namespace.

    Kept in this tab’s memory. Reloading or disconnecting clears it.

    A CLEAR PATH TO PUBLISHED

    1. 01

      Prepare a release

      Give your extension a name, version, and source.

    2. 02

      Add your distribution

      Upload a Web snapshot here, or use the Toolkit for Python.

    3. 03

      Make it discoverable

      Review and publish. Manage your releases in one place.

    New publisher? Request a namespace ↗

    - -

    NEW WEB RELEASE

    Give your idea a home.

    Lowercase letters, numbers, and hyphens.

    SemVer, such as 1.0.0 or 1.0.0-rc.1.

    Supported @inkcre/core SemVer range.

    Preparing reserves this version and its source metadata. Upload a ZIP with mf-manifest.json at its root next. You’ll publish in a separate step.

    -

    +
    +

    THE PUBLISHER WORKSPACE

    +

    + Made by you. +
    + Ready for everyone. +

    +

    Take your extension from a local idea to a published release.

    +
    + +
    +
    +

    YOUR WORKSPACE

    +

    Connect as a publisher

    +

    Use the credential issued for your publisher namespace.

    +
    + + +

    Kept in this tab’s memory. Reloading or disconnecting clears it.

    + +

    +
    +
    +
    +

    A CLEAR PATH TO PUBLISHED

    +
      +
    1. + 01 +
      +

      Prepare a release

      +

      Give your extension a name, version, and source.

      +
      +
    2. +
    3. + 02 +
      +

      Add your distribution

      +

      Upload a Web snapshot here, or use the Toolkit for Python.

      +
      +
    4. +
    5. + 03 +
      +

      Make it discoverable

      +

      Review and publish. Manage your releases in one place.

      +
      +
    6. +
    +

    + New publisher? + Request a namespace ↗ +

    +
    +
    + + +
    +
    +

    NEW WEB RELEASE

    +

    Give your idea a home.

    +
    + +
    +
    +
    +
    + + +

    Lowercase letters, numbers, and hyphens.

    +
    +
    + + +
    +
    + + +

    SemVer, such as 1.0.0 or 1.0.0-rc.1.

    +
    +
    + + +

    Supported @inkcre/core SemVer range.

    +
    +
    + + +
    +
    + + +
    +
    +

    + Preparing reserves this version and its source metadata. Upload a ZIP with + mf-manifest.json + at its root next. You’ll publish in a separate step. +

    +

    + +
    +
    + +
    +

    + +
    +

    +
    + + +

    + +
    +
    {% endblock %} diff --git a/tasks/registry-web-lovable/packet.md b/tasks/registry-web-lovable/packet.md index 746f534..7f7a566 100644 --- a/tasks/registry-web-lovable/packet.md +++ b/tasks/registry-web-lovable/packet.md @@ -14,7 +14,7 @@ Sir 要求将 Registry Web 提升为可喜爱、可持续维护的产品,允 - Publisher 复用 namespace bearer 凭证与既有 prepare/upload/publish/yank/unyank 协议;增加自己的私有版本读取接口。浏览器支持 Module Federation ZIP 发布,Python 原生发布仍交给现有 Toolkit。 - 不改变 Release/installed/enabled/running 权威,不改变公共 API 既有语义,不修改 D1 schema。 - 资产/边界:发布者凭证授予命名空间写权限;公开昵称是外部数据。模板自动转义、浏览器 textContent、同源请求、仅内存凭证避免将公开元数据变成脚本或凭证泄露路径。依据 Hub security-boundary-model 和本地 registry-control-plane。 -- 静态 Pages preview 保持无凭证、只读目录性质;详情/发布指向生产,不声称运行候选 API。 +- 静态 Pages preview 保持无凭证、只读目录性质;卡片指向已上线的 API 元数据,Publish 指向 Toolkit 文档,不声称运行候选 API。 ## 验收与当前进度 @@ -23,4 +23,15 @@ Sir 要求将 Registry Web 提升为可喜爱、可持续维护的产品,允 3. 完整 `pnpm check`、Worker 本地 D1/R2 黑盒验证、构建包包含模板/资源、`git diff --check`。 4. 更新拥有长期事实的本地文档,提交推送 PR,观察 CI 并修复本次失败。 -当前:已完成仓库/设计/治理/原生协议调查,实现与本地完整检查已通过,正在完成浏览器验收和 CI。首次 CI 确认私有 npm 包缺少读取授权;改用 pnpm 原生 Git 子目录依赖锁定 design 的已发布 1.4.0 提交,公共 Sass 导出保持一致,无需扩大权限。PR #33 已创建为 draft。暂无需 Sir 决策项。 +当前:实现与本地验收完成;[PR #33](https://github.com/InKCre/ext-reg/pull/33) 已提交,CI 与 Pages preview 在 `29d5686` 均通过。主题、分页反馈、模板可读性和预览链接修正已纳入交付;最终提交的自动化结果以 PR checks 与 preview.json 的 source_sha 为准。暂无需 Sir 决策项。 + +## 验收证据与交付 + +- Node 22 下完整 `pnpm check` 通过,包含合同生成、格式、lint、类型、各 wheel/客户端构建、Worker dry-run;确认模板、CSS、JS 与 Jinja 运行依赖进入 Worker。 +- 使用本地 Worker 和隔离的 D1/R2:匿名 Publisher 401/no-store、自身命名空间分页、其他命名空间不可读取/修改;真实 Web prepare/upload/publish 和 Python 原生 wheel 上传、发布、详情均通过。 +- 浏览器完成 Web 新建(含错误修正与字段保留)、ZIP 上传、发布、撤回、恢复、断开凭证;未上传版本不可发布。从第二页新建后回到第一页并展示新版本。 +- 验证目录搜索、空态、精确版本选择、SemVer 稳定版优先、详情 404、ID 复制、390px 窄屏无水平溢出、浅/深色与控制台;公开昵称自动转义。 +- 首次 CI 暴露 GitHub Packages 403;改用 pnpm 原生 Git 子目录依赖,锁定 design 已发布 1.4.0 的公共提交 `4eceec4c60345a52a08545555ebce9ab95053beb`。Sass 产物一致,无需扩大包权限或新增发布流程。 +- 长期设计归属 `docs/30-unit-tdd/registry-web.md`;构建/预览归属 `docs/40-deployment/`。无 shared 文档、数据库 schema 或生产资源变更。 +- [目录视觉预览](https://preview-ext-reg-pr-33.inkcre-extension-registry-ui-preview.pages.dev) 复用现有 Pages 流程;发布者交互在真实本地 Worker 验收。Python prepare/upload 仍由现有 Toolkit 执行,网页负责发布与生命周期管理。 +- 回退整个 PR 恢复旧 Web 与读取合同,无数据回滚步骤。PR 未关闭前保留本 packet。 diff --git a/web/registry.scss b/web/registry.scss index 2420e70..ed4daaa 100644 --- a/web/registry.scss +++ b/web/registry.scss @@ -1,4 +1,12 @@ @use 'pkg:@inkcre/ui-web/styles'; +@use 'pkg:@inkcre/ui-web/tokens/sys' as sys; +@use 'pkg:@inkcre/ui-web/styles/functions' as tokens; + +// The package's automatic dark palette follows the OS; also support explicit selection. +html[data-theme='dark'] { + color-scheme: dark; + @include tokens.emit-css-variables(sys.$color-dark, 'sys', 'color'); +} :root { --page-width: 1160px; From e8ca628e6f4c4aea0ae1126a457ff635611ef3aa Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Sun, 6 Sep 2026 21:17:30 +0800 Subject: [PATCH 04/16] =?UTF-8?q?fix(web):=20=E5=AF=B9=E9=BD=90=E4=BA=A7?= =?UTF-8?q?=E5=93=81=E8=A7=86=E8=A7=89=E5=B9=B6=E7=B2=BE=E7=AE=80=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=EF=BC=8C=E8=A1=A5=E9=BD=90=E5=8F=AF=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pages-preview/extensions.json | 164 +- docs/30-unit-tdd/registry-web.md | 9 +- docs/40-deployment/pull-request-previews.md | 12 +- scripts/build_ui_preview.py | 26 +- .../service/static/registry.css | 2 +- .../service/static/registry.js | 19 +- .../service/templates/base.html | 90 +- .../service/templates/brand.html | 11 + .../service/templates/catalog-content.html | 67 + .../service/templates/catalog.html | 158 +- .../service/templates/detail-content.html | 106 ++ .../service/templates/detail.html | 130 +- .../service/templates/error.html | 12 +- .../service/templates/preview.html | 8 + .../service/templates/publish.html | 84 +- src/inkcre_extension_registry/service/ui.py | 46 +- tasks/registry-web-lovable/packet.md | 38 +- web/registry.scss | 1437 +++++------------ 18 files changed, 861 insertions(+), 1558 deletions(-) create mode 100644 src/inkcre_extension_registry/service/templates/brand.html create mode 100644 src/inkcre_extension_registry/service/templates/catalog-content.html create mode 100644 src/inkcre_extension_registry/service/templates/detail-content.html create mode 100644 src/inkcre_extension_registry/service/templates/preview.html diff --git a/.github/pages-preview/extensions.json b/.github/pages-preview/extensions.json index 5e6378c..fa78cc1 100644 --- a/.github/pages-preview/extensions.json +++ b/.github/pages-preview/extensions.json @@ -1,11 +1,163 @@ { "schema_version": 1, "extensions": [ - { "name": "inkcre/github", "nickname": "GitHub" }, - { "name": "inkcre/learn-english", "nickname": "Learn English" }, - { "name": "inkcre/mail", "nickname": "Mail" }, - { "name": "inkcre/rss", "nickname": "RSS/Atom Feeds" }, - { "name": "inkcre/telegram", "nickname": "Telegram" }, - { "name": "inkcre/twitter", "nickname": "Twitter" } + { + "name": "inkcre/github", + "nickname": "GitHub", + "releases": [ + { + "name": "inkcre/github", + "nickname": "GitHub", + "version": "1.1.0", + "state": "published", + "python": { + "project": "inkcre-ext-github", + "simple_url": "https://registry.example/simple/inkcre-ext-github/", + "host_sdk": "core-py", + "host_sdk_version": "^0.1.0", + "entry_point": { + "group": "inkcre.extensions", + "name": "github", + "object": "inkcre_ext_github:extension" + } + }, + "module_federation": { + "manifest_url": "https://registry.example/extensions/inkcre/github/1.1.0/mf-manifest.json", + "host_sdk": "@inkcre/core", + "host_sdk_version": "^0.1.2" + } + }, + { + "name": "inkcre/github", + "nickname": "GitHub", + "version": "1.0.0", + "state": "published", + "python": { + "project": "inkcre-ext-github", + "simple_url": "https://registry.example/simple/inkcre-ext-github/", + "host_sdk": "core-py", + "host_sdk_version": "^0.1.0", + "entry_point": { + "group": "inkcre.extensions", + "name": "github", + "object": "inkcre_ext_github:extension" + } + }, + "module_federation": { + "manifest_url": "https://registry.example/extensions/inkcre/github/1.0.0/mf-manifest.json", + "host_sdk": "@inkcre/core", + "host_sdk_version": "^0.1.2" + } + } + ] + }, + { + "name": "inkcre/learn-english", + "nickname": "Learn English", + "releases": [ + { + "name": "inkcre/learn-english", + "nickname": "Learn English", + "version": "1.0.0", + "state": "published", + "module_federation": { + "manifest_url": "https://registry.example/extensions/inkcre/learn-english/1.0.0/mf-manifest.json", + "host_sdk": "@inkcre/core", + "host_sdk_version": "^0.1.2" + } + } + ] + }, + { + "name": "inkcre/mail", + "nickname": "Mail", + "releases": [ + { + "name": "inkcre/mail", + "nickname": "Mail", + "version": "1.0.0", + "state": "published", + "python": { + "project": "inkcre-ext-mail", + "simple_url": "https://registry.example/simple/inkcre-ext-mail/", + "host_sdk": "core-py", + "host_sdk_version": "^0.1.0", + "entry_point": { + "group": "inkcre.extensions", + "name": "mail", + "object": "inkcre_ext_mail:extension" + } + }, + "module_federation": { + "manifest_url": "https://registry.example/extensions/inkcre/mail/1.0.0/mf-manifest.json", + "host_sdk": "@inkcre/core", + "host_sdk_version": "^0.1.2" + } + } + ] + }, + { + "name": "inkcre/rss", + "nickname": "RSS/Atom Feeds", + "releases": [ + { + "name": "inkcre/rss", + "nickname": "RSS/Atom Feeds", + "version": "1.0.0", + "state": "published", + "python": { + "project": "inkcre-ext-rss", + "simple_url": "https://registry.example/simple/inkcre-ext-rss/", + "host_sdk": "core-py", + "host_sdk_version": "^0.1.0", + "entry_point": { + "group": "inkcre.extensions", + "name": "rss", + "object": "inkcre_ext_rss:extension" + } + } + } + ] + }, + { + "name": "inkcre/telegram", + "nickname": "Telegram", + "releases": [ + { + "name": "inkcre/telegram", + "nickname": "Telegram", + "version": "1.0.0", + "state": "published", + "python": { + "project": "inkcre-ext-telegram", + "simple_url": "https://registry.example/simple/inkcre-ext-telegram/", + "host_sdk": "core-py", + "host_sdk_version": "^0.1.0", + "entry_point": { + "group": "inkcre.extensions", + "name": "telegram", + "object": "inkcre_ext_telegram:extension" + } + } + } + ] + }, + { + "name": "inkcre/twitter", + "nickname": "Twitter", + "releases": [ + { + "name": "inkcre/twitter", + "nickname": "Twitter", + "version": "1.0.0", + "state": "published", + "module_federation": { + "manifest_url": "https://registry.example/extensions/inkcre/twitter/1.0.0/mf-manifest.json", + "host_sdk": "@inkcre/core", + "host_sdk_version": "^0.1.2" + } + } + ] + } ] } diff --git a/docs/30-unit-tdd/registry-web.md b/docs/30-unit-tdd/registry-web.md index 42689cb..16bd6ce 100644 --- a/docs/30-unit-tdd/registry-web.md +++ b/docs/30-unit-tdd/registry-web.md @@ -68,4 +68,11 @@ is no Registry copy of the token source or a competing design package. and checks browser JavaScript syntax. Committing generated CSS makes Python wheel and static-preview builds self-contained; it is never edited manually. Wrangler Text rules explicitly include CSS and JavaScript in the Python module filesystem. -The same HTML renderer supplies the existing read-only Pages catalog preview. +The same catalog and detail templates supply the read-only Pages preview. + +Registry follows the client-web product surface: the 32px geometric InKCre logo, +InkHeader spacing and typography, square InkButton-style controls, bordered +extension rows, and compact metadata. `templates/brand.html` contains the SVG +from client-web's `apps/client-web/public/logo/32.svg`. Page content consists of +catalog, release metadata, and publishing controls; it has no marketing hero, +illustration, promotional footer, or persistent tutorial panels. diff --git a/docs/40-deployment/pull-request-previews.md b/docs/40-deployment/pull-request-previews.md index 67c9364..88d684b 100644 --- a/docs/40-deployment/pull-request-previews.md +++ b/docs/40-deployment/pull-request-previews.md @@ -1,6 +1,6 @@ # Pull-Request Previews -Pull-request Preview covers only the read-only Extension-list page. Registry +Pull-request Preview covers the read-only catalog and Extension release details. Registry APIs, publishing, native Distribution consumption, installation, and runtime behavior remain local black-box responsibilities. @@ -16,9 +16,13 @@ project `inkcre-extension-registry-ui-preview` on `preview/ext-reg/pr-`. Protected-main code adds source identity and noindex, no-store, CSP, nosniff, and no-referrer policy. The Pages project has no Git provider, custom domain, Functions, Worker, D1, R2, or production token. -Its Extension links point to production public API metadata, and Publisher -links open the Toolkit guide; it never claims to host candidate -Registry APIs. +The fixture includes explicitly sampled published release descriptors. Catalog +links open details and version links select release views inside that same +HTML document, using URL fragments and CSS without scripts. Both surfaces +reuse the Worker's templates. The header marks the document as Preview; native +artifact links and Publisher controls are omitted because they require the +Registry service. No sample version is sent to a production endpoint. +The existing 64 KiB bound applies to the entire document. Closing an internal PR deploys the checked-in tombstone, verifies the stable alias, and deletes older deployments only for that project and branch. The diff --git a/scripts/build_ui_preview.py b/scripts/build_ui_preview.py index 9b70ab2..c7bcee1 100644 --- a/scripts/build_ui_preview.py +++ b/scripts/build_ui_preview.py @@ -7,8 +7,8 @@ from pydantic import BaseModel, ConfigDict, field_validator -from inkcre_extension_registry.contracts.models import ExtensionSummary -from inkcre_extension_registry.service.ui import extension_catalog_html +from inkcre_extension_registry.contracts.models import ExtensionRecord +from inkcre_extension_registry.service.ui import extension_preview_html MAX_DOCUMENT_BYTES = 64 * 1024 @@ -17,16 +17,25 @@ class PreviewFixture(BaseModel): model_config = ConfigDict(extra="forbid", frozen=True) schema_version: Literal[1] - extensions: tuple[ExtensionSummary, ...] + extensions: tuple[ExtensionRecord, ...] @field_validator("extensions") @classmethod def extension_names_are_unique( - cls, extensions: tuple[ExtensionSummary, ...] - ) -> tuple[ExtensionSummary, ...]: + cls, extensions: tuple[ExtensionRecord, ...] + ) -> tuple[ExtensionRecord, ...]: names = [extension.name for extension in extensions] if len(names) != len(set(names)): raise ValueError("extension names must be unique") + for extension in extensions: + if not extension.releases or any( + release.name != extension.name or release.state != "published" + for release in extension.releases + ): + raise ValueError("preview details must be published releases of their extension") + versions = [release.version for release in extension.releases] + if len(versions) != len(set(versions)): + raise ValueError("preview release versions must be unique") return extensions @@ -36,10 +45,9 @@ def build_preview(*, fixture_path: Path, output_directory: Path, api_origin: str raise ValueError("output directory must be empty") output_directory.mkdir(parents=True, exist_ok=True) - document = extension_catalog_html( + document = extension_preview_html( fixture.extensions, api_origin=api_origin, - noindex=True, ).encode() if len(document) > MAX_DOCUMENT_BYTES: raise ValueError(f"preview document exceeds {MAX_DOCUMENT_BYTES} bytes") @@ -50,7 +58,9 @@ def build_preview(*, fixture_path: Path, output_directory: Path, api_origin: str def main() -> None: - parser = argparse.ArgumentParser(description="Build the static Extension-list PR preview") + parser = argparse.ArgumentParser( + description="Build the static Registry catalog and detail PR preview" + ) parser.add_argument("--fixture", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) parser.add_argument("--api-origin", required=True) diff --git a/src/inkcre_extension_registry/service/static/registry.css b/src/inkcre_extension_registry/service/static/registry.css index 035e720..9fd9443 100644 --- a/src/inkcre_extension_registry/service/static/registry.css +++ b/src/inkcre_extension_registry/service/static/registry.css @@ -1 +1 @@ -:root{--ref-elevation-raised-low-shadow-type:dropShadow;--ref-elevation-raised-low-radius:2;--ref-elevation-raised-low-color:rgba(12, 21, 48, 0.0784313725);--ref-elevation-raised-low-offset-x:0;--ref-elevation-raised-low-offset-y:1;--ref-elevation-raised-low-spread:0;--ref-elevation-raised-md-shadow-type:dropShadow;--ref-elevation-raised-md-radius:20;--ref-elevation-raised-md-color:rgba(12, 21, 48, 0.1215686275);--ref-elevation-raised-md-offset-x:0;--ref-elevation-raised-md-offset-y:6;--ref-elevation-raised-md-spread:0;--ref-elevation-raised-high-shadow-type:dropShadow;--ref-elevation-raised-high-radius:32;--ref-elevation-raised-high-color:rgba(12, 21, 84, 0.1607843137);--ref-elevation-raised-high-offset-x:0;--ref-elevation-raised-high-offset-y:12;--ref-elevation-raised-high-spread:0;--ref-color-neutral-2:#070707;--ref-color-neutral-6:#131313;--ref-color-neutral-12:#1f1f1f;--ref-color-neutral-20:#303030;--ref-color-neutral-30:#474747;--ref-color-neutral-40:#5e5e5e;--ref-color-neutral-50:#777777;--ref-color-neutral-60:#919191;--ref-color-neutral-70:#ababab;--ref-color-neutral-80:#c6c6c6;--ref-color-neutral-90:#e2e2e2;--ref-color-neutral-94:#eeeeee;--ref-color-neutral-98:#f9f9f9;--ref-color-neutral-100:#ffffff;--ref-color-tertiary-40:#1f9aa5;--ref-color-tertiary-90:#d0f4f4;--ref-color-danger-10:#410008;--ref-color-danger-30:#92001e;--ref-color-danger-35:#a80324;--ref-color-danger-40:#b9192f;--ref-color-danger-60:#ff525c;--ref-color-danger-65:#ff7073;--ref-color-danger-70:#ff8889;--ref-color-danger-80:#ffb3b1;--ref-color-danger-90:#ffdad8;--ref-color-danger-98:#fff8f7;--ref-color-brand-2:#0b1220;--ref-color-brand-6:#121928;--ref-color-brand-12:#1f2635;--ref-color-brand-20:#303747;--ref-color-brand-30:#474e5f;--ref-color-brand-40:#5f6677;--ref-color-brand-50:#787e91;--ref-color-brand-60:#9198ac;--ref-color-brand-70:#acb3c7;--ref-color-brand-80:#b2bccf;--ref-color-brand-90:#c8cee3;--ref-color-brand-94:#f7f7fb;--ref-color-brand-98:#fbfcff;--ref-radius-xs:4px;--ref-radius-sm:8px;--ref-radius-md:16px;--ref-radius-lg:32px;--ref-radius-none:0px;--ref-radius-full:999px;--ref-space-md:16px;--ref-space-lg:32px;--ref-space-xl:56px;--ref-space-xs:4px;--ref-space-sm:8px;--ref-typo-family-sans:system-ui, -apple-system, sans-serif;--ref-typo-family-mono: SF Mono , Monaco, Roboto Mono , monospace;--ref-breakpoint-mobile:768px;--ref-breakpoint-tablet:1024px;--ref-breakpoint-desktop:1280px;--ref-opacity-muted:0.72;--ref-opacity-medium:0.84;--ref-opacity-strong:0.92;--ref-size-lg:48px;--ref-size-md:36px;--ref-size-sm:24px;--ref-size-icon-sm:20px;--ref-size-icon-md:24px;--ref-size-icon-lg:36px;--ref-size-iconfont-sm:8px;--ref-size-iconfont-md:12px;--ref-size-iconfont-lg:18px;--ref-size-xl:58px;--ref-size-xs:18px;--ref-font-label-lg-font-size:14px;--ref-font-label-lg-text-decoration:none;--ref-font-label-lg-font-family:Inter;--ref-font-label-lg-font-weight:400;--ref-font-label-lg-font-style:normal;--ref-font-label-lg-font-stretch:normal;--ref-font-label-lg-letter-spacing:0px;--ref-font-label-lg-line-height:16px;--ref-font-label-lg-paragraph-indent:0px;--ref-font-label-lg-paragraph-spacing:0px;--ref-font-label-lg-text-case:none;--ref-font-label-md-font-size:12px;--ref-font-label-md-text-decoration:none;--ref-font-label-md-font-family:Inter;--ref-font-label-md-font-weight:400;--ref-font-label-md-font-style:normal;--ref-font-label-md-font-stretch:normal;--ref-font-label-md-letter-spacing:0px;--ref-font-label-md-line-height:16px;--ref-font-label-md-paragraph-indent:0px;--ref-font-label-md-paragraph-spacing:0px;--ref-font-label-md-text-case:none;--ref-font-label-sm-font-size:10px;--ref-font-label-sm-text-decoration:none;--ref-font-label-sm-font-family:Roboto;--ref-font-label-sm-font-weight:700;--ref-font-label-sm-font-style:normal;--ref-font-label-sm-font-stretch:normal;--ref-font-label-sm-letter-spacing:0.7px;--ref-font-label-sm-line-height:14px;--ref-font-label-sm-paragraph-indent:0px;--ref-font-label-sm-paragraph-spacing:0px;--ref-font-label-sm-text-case:none;--ref-font-label-lg-underlined-font-size:14px;--ref-font-label-lg-underlined-text-decoration:underline;--ref-font-label-lg-underlined-font-family:Inter;--ref-font-label-lg-underlined-font-weight:400;--ref-font-label-lg-underlined-font-style:normal;--ref-font-label-lg-underlined-font-stretch:normal;--ref-font-label-lg-underlined-letter-spacing:0px;--ref-font-label-lg-underlined-line-height:16px;--ref-font-label-lg-underlined-paragraph-indent:0px;--ref-font-label-lg-underlined-paragraph-spacing:0px;--ref-font-label-lg-underlined-text-case:none;--ref-font-label-lg-mono-font-size:14px;--ref-font-label-lg-mono-text-decoration:none;--ref-font-label-lg-mono-font-family:JetBrains Mono;--ref-font-label-lg-mono-font-weight:400;--ref-font-label-lg-mono-font-style:normal;--ref-font-label-lg-mono-font-stretch:normal;--ref-font-label-lg-mono-letter-spacing:0px;--ref-font-label-lg-mono-line-height:16px;--ref-font-label-lg-mono-paragraph-indent:0px;--ref-font-label-lg-mono-paragraph-spacing:0px;--ref-font-label-lg-mono-text-case:none;--ref-font-label-sm-mono-font-size:10px;--ref-font-label-sm-mono-text-decoration:none;--ref-font-label-sm-mono-font-family:JetBrains Mono;--ref-font-label-sm-mono-font-weight:700;--ref-font-label-sm-mono-font-style:normal;--ref-font-label-sm-mono-font-stretch:normal;--ref-font-label-sm-mono-letter-spacing:0.4px;--ref-font-label-sm-mono-line-height:14px;--ref-font-label-sm-mono-paragraph-indent:0px;--ref-font-label-sm-mono-paragraph-spacing:0px;--ref-font-label-sm-mono-text-case:none;--ref-font-title-sm-font-size:22px;--ref-font-title-sm-text-decoration:none;--ref-font-title-sm-font-family:Inter;--ref-font-title-sm-font-weight:400;--ref-font-title-sm-font-style:normal;--ref-font-title-sm-font-stretch:normal;--ref-font-title-sm-letter-spacing:0px;--ref-font-title-sm-line-height:28px;--ref-font-title-sm-paragraph-indent:0px;--ref-font-title-sm-paragraph-spacing:0px;--ref-font-title-sm-text-case:none;--ref-font-title-sm-mono-font-size:22px;--ref-font-title-sm-mono-text-decoration:none;--ref-font-title-sm-mono-font-family:IBM Plex Mono;--ref-font-title-sm-mono-font-weight:400;--ref-font-title-sm-mono-font-style:normal;--ref-font-title-sm-mono-font-stretch:normal;--ref-font-title-sm-mono-letter-spacing:0.66px;--ref-font-title-sm-mono-line-height:28px;--ref-font-title-sm-mono-paragraph-indent:0px;--ref-font-title-sm-mono-paragraph-spacing:0px;--ref-font-title-sm-mono-text-case:none;--ref-font-body-lg-font-size:18px;--ref-font-body-lg-text-decoration:none;--ref-font-body-lg-font-family:Inter;--ref-font-body-lg-font-weight:400;--ref-font-body-lg-font-style:normal;--ref-font-body-lg-font-stretch:normal;--ref-font-body-lg-letter-spacing:0px;--ref-font-body-lg-line-height:24px;--ref-font-body-lg-paragraph-indent:0px;--ref-font-body-lg-paragraph-spacing:0px;--ref-font-body-lg-text-case:none;--ref-font-body-lg-mono-font-size:18px;--ref-font-body-lg-mono-text-decoration:none;--ref-font-body-lg-mono-font-family:JetBrains Mono;--ref-font-body-lg-mono-font-weight:400;--ref-font-body-lg-mono-font-style:normal;--ref-font-body-lg-mono-font-stretch:normal;--ref-font-body-lg-mono-letter-spacing:0px;--ref-font-body-lg-mono-line-height:24px;--ref-font-body-lg-mono-paragraph-indent:0px;--ref-font-body-lg-mono-paragraph-spacing:0px;--ref-font-body-lg-mono-text-case:none;--ref-font-headline-lg-font-size:36px;--ref-font-headline-lg-text-decoration:none;--ref-font-headline-lg-font-family:Inter;--ref-font-headline-lg-font-weight:400;--ref-font-headline-lg-font-style:normal;--ref-font-headline-lg-font-stretch:normal;--ref-font-headline-lg-letter-spacing:0px;--ref-font-headline-lg-line-height:48px;--ref-font-headline-lg-paragraph-indent:0px;--ref-font-headline-lg-paragraph-spacing:0px;--ref-font-headline-lg-text-case:none;--sys-elevation-raised-low-shadow-type:dropShadow;--sys-elevation-raised-low-radius:2;--sys-elevation-raised-low-color:rgba(12, 21, 48, 0.0784313725);--sys-elevation-raised-low-offset-x:0;--sys-elevation-raised-low-offset-y:1;--sys-elevation-raised-low-spread:0;--sys-elevation-raised-md-shadow-type:dropShadow;--sys-elevation-raised-md-radius:20;--sys-elevation-raised-md-color:rgba(12, 21, 48, 0.1215686275);--sys-elevation-raised-md-offset-x:0;--sys-elevation-raised-md-offset-y:6;--sys-elevation-raised-md-spread:0;--sys-elevation-raised-high-shadow-type:dropShadow;--sys-elevation-raised-high-radius:32;--sys-elevation-raised-high-color:rgba(12, 21, 84, 0.1607843137);--sys-elevation-raised-high-offset-x:0;--sys-elevation-raised-high-offset-y:12;--sys-elevation-raised-high-spread:0;--sys-radius-xs:4px;--sys-radius-sm:8px;--sys-radius-md:16px;--sys-radius-lg:32px;--sys-radius-none:0px;--sys-radius-full:999px;--sys-space-md:16px;--sys-space-lg:32px;--sys-space-xl:56px;--sys-space-xs:4px;--sys-space-sm:8px;--sys-typo-family-sans:system-ui, -apple-system, sans-serif;--sys-typo-family-mono: SF Mono , Monaco, Roboto Mono , monospace;--sys-breakpoint-mobile:768px;--sys-breakpoint-tablet:1024px;--sys-breakpoint-desktop:1280px;--sys-opacity-muted:0.72;--sys-opacity-medium:0.84;--sys-opacity-strong:0.92;--sys-size-lg:48px;--sys-size-md:36px;--sys-size-sm:24px;--sys-size-icon-sm:20px;--sys-size-icon-md:24px;--sys-size-icon-lg:36px;--sys-size-iconfont-sm:8px;--sys-size-iconfont-md:12px;--sys-size-iconfont-lg:18px;--sys-size-xl:58px;--sys-size-xs:18px;--sys-font-label-lg-font-size:14px;--sys-font-label-lg-text-decoration:none;--sys-font-label-lg-font-family:Inter;--sys-font-label-lg-font-weight:400;--sys-font-label-lg-font-style:normal;--sys-font-label-lg-font-stretch:normal;--sys-font-label-lg-letter-spacing:0px;--sys-font-label-lg-line-height:16px;--sys-font-label-lg-paragraph-indent:0px;--sys-font-label-lg-paragraph-spacing:0px;--sys-font-label-lg-text-case:none;--sys-font-label-md-font-size:12px;--sys-font-label-md-text-decoration:none;--sys-font-label-md-font-family:Inter;--sys-font-label-md-font-weight:400;--sys-font-label-md-font-style:normal;--sys-font-label-md-font-stretch:normal;--sys-font-label-md-letter-spacing:0px;--sys-font-label-md-line-height:16px;--sys-font-label-md-paragraph-indent:0px;--sys-font-label-md-paragraph-spacing:0px;--sys-font-label-md-text-case:none;--sys-font-label-sm-font-size:10px;--sys-font-label-sm-text-decoration:none;--sys-font-label-sm-font-family:Roboto;--sys-font-label-sm-font-weight:700;--sys-font-label-sm-font-style:normal;--sys-font-label-sm-font-stretch:normal;--sys-font-label-sm-letter-spacing:0.7px;--sys-font-label-sm-line-height:14px;--sys-font-label-sm-paragraph-indent:0px;--sys-font-label-sm-paragraph-spacing:0px;--sys-font-label-sm-text-case:none;--sys-font-label-lg-underlined-font-size:14px;--sys-font-label-lg-underlined-text-decoration:underline;--sys-font-label-lg-underlined-font-family:Inter;--sys-font-label-lg-underlined-font-weight:400;--sys-font-label-lg-underlined-font-style:normal;--sys-font-label-lg-underlined-font-stretch:normal;--sys-font-label-lg-underlined-letter-spacing:0px;--sys-font-label-lg-underlined-line-height:16px;--sys-font-label-lg-underlined-paragraph-indent:0px;--sys-font-label-lg-underlined-paragraph-spacing:0px;--sys-font-label-lg-underlined-text-case:none;--sys-font-label-lg-mono-font-size:14px;--sys-font-label-lg-mono-text-decoration:none;--sys-font-label-lg-mono-font-family:JetBrains Mono;--sys-font-label-lg-mono-font-weight:400;--sys-font-label-lg-mono-font-style:normal;--sys-font-label-lg-mono-font-stretch:normal;--sys-font-label-lg-mono-letter-spacing:0px;--sys-font-label-lg-mono-line-height:16px;--sys-font-label-lg-mono-paragraph-indent:0px;--sys-font-label-lg-mono-paragraph-spacing:0px;--sys-font-label-lg-mono-text-case:none;--sys-font-label-sm-mono-font-size:10px;--sys-font-label-sm-mono-text-decoration:none;--sys-font-label-sm-mono-font-family:JetBrains Mono;--sys-font-label-sm-mono-font-weight:700;--sys-font-label-sm-mono-font-style:normal;--sys-font-label-sm-mono-font-stretch:normal;--sys-font-label-sm-mono-letter-spacing:0.4px;--sys-font-label-sm-mono-line-height:14px;--sys-font-label-sm-mono-paragraph-indent:0px;--sys-font-label-sm-mono-paragraph-spacing:0px;--sys-font-label-sm-mono-text-case:none;--sys-font-title-sm-font-size:22px;--sys-font-title-sm-text-decoration:none;--sys-font-title-sm-font-family:Inter;--sys-font-title-sm-font-weight:400;--sys-font-title-sm-font-style:normal;--sys-font-title-sm-font-stretch:normal;--sys-font-title-sm-letter-spacing:0px;--sys-font-title-sm-line-height:28px;--sys-font-title-sm-paragraph-indent:0px;--sys-font-title-sm-paragraph-spacing:0px;--sys-font-title-sm-text-case:none;--sys-font-title-sm-mono-font-size:22px;--sys-font-title-sm-mono-text-decoration:none;--sys-font-title-sm-mono-font-family:IBM Plex Mono;--sys-font-title-sm-mono-font-weight:400;--sys-font-title-sm-mono-font-style:normal;--sys-font-title-sm-mono-font-stretch:normal;--sys-font-title-sm-mono-letter-spacing:0.66px;--sys-font-title-sm-mono-line-height:28px;--sys-font-title-sm-mono-paragraph-indent:0px;--sys-font-title-sm-mono-paragraph-spacing:0px;--sys-font-title-sm-mono-text-case:none;--sys-font-body-lg-font-size:18px;--sys-font-body-lg-text-decoration:none;--sys-font-body-lg-font-family:Inter;--sys-font-body-lg-font-weight:400;--sys-font-body-lg-font-style:normal;--sys-font-body-lg-font-stretch:normal;--sys-font-body-lg-letter-spacing:0px;--sys-font-body-lg-line-height:24px;--sys-font-body-lg-paragraph-indent:0px;--sys-font-body-lg-paragraph-spacing:0px;--sys-font-body-lg-text-case:none;--sys-font-body-lg-mono-font-size:18px;--sys-font-body-lg-mono-text-decoration:none;--sys-font-body-lg-mono-font-family:JetBrains Mono;--sys-font-body-lg-mono-font-weight:400;--sys-font-body-lg-mono-font-style:normal;--sys-font-body-lg-mono-font-stretch:normal;--sys-font-body-lg-mono-letter-spacing:0px;--sys-font-body-lg-mono-line-height:24px;--sys-font-body-lg-mono-paragraph-indent:0px;--sys-font-body-lg-mono-paragraph-spacing:0px;--sys-font-body-lg-mono-text-case:none;--sys-font-headline-lg-font-size:36px;--sys-font-headline-lg-text-decoration:none;--sys-font-headline-lg-font-family:Inter;--sys-font-headline-lg-font-weight:400;--sys-font-headline-lg-font-style:normal;--sys-font-headline-lg-font-stretch:normal;--sys-font-headline-lg-letter-spacing:0px;--sys-font-headline-lg-line-height:48px;--sys-font-headline-lg-paragraph-indent:0px;--sys-font-headline-lg-paragraph-spacing:0px;--sys-font-headline-lg-text-case:none}:root,html[data-theme=light]{color-scheme:light;--sys-color-text-base:#070707;--sys-color-text-muted:#919191;--sys-color-text-danger-on:#fff8f7;--sys-color-text-primary:#fbfcff;--sys-color-text-subtle:#5e5e5e;--sys-color-surface-base:#ffffff;--sys-color-surface-muted:#e2e2e2;--sys-color-surface-subtle:#f9f9f9;--sys-color-surface-primary:#121928;--sys-color-surface-primary-hover:#474e5f;--sys-color-surface-subtle-hover:#eeeeee;--sys-color-surface-danger:#a80324;--sys-color-surface-danger-hover:#ff7073;--sys-color-surface-base-hover:#eeeeee;--sys-color-border-base:#303030;--sys-color-border-strong:#070707;--sys-color-border-subtle:#c6c6c6;--sys-color-border-muted:#919191;--sys-color-feedback-error:#b9192f;--comp-switch-track-bg:#070707;--comp-switch-handle-bg:#f9f9f9;--comp-switch-label-color:#070707}@media(prefers-color-scheme: dark){:root:not([data-theme]),html[data-theme=dark]{color-scheme:dark;--sys-color-text-base:#f9f9f9;--sys-color-text-muted:#f9f9f9;--sys-color-text-danger-on:#fff8f7;--sys-color-text-primary:#0b1220;--sys-color-text-subtle:#c6c6c6;--sys-color-surface-base:#474747;--sys-color-surface-muted:#131313;--sys-color-surface-subtle:#1f1f1f;--sys-color-surface-primary:#c8cee3;--sys-color-surface-primary-hover:#9198ac;--sys-color-surface-subtle-hover:#5e5e5e;--sys-color-surface-danger:#b9192f;--sys-color-surface-danger-hover:#b9192f;--sys-color-surface-base-hover:#474747;--sys-color-border-base:#5e5e5e;--sys-color-border-strong:#c6c6c6;--sys-color-border-subtle:#5e5e5e;--sys-color-border-muted:#5e5e5e;--sys-color-feedback-error:#ff525c;--comp-switch-track-bg:#f9f9f9;--comp-switch-handle-bg:#070707;--comp-switch-label-color:#f9f9f9}}html[data-theme=dark]{color-scheme:dark;--sys-color-text-base:#f9f9f9;--sys-color-text-muted:#f9f9f9;--sys-color-text-danger-on:#fff8f7;--sys-color-text-primary:#0b1220;--sys-color-text-subtle:#c6c6c6;--sys-color-surface-base:#474747;--sys-color-surface-muted:#131313;--sys-color-surface-subtle:#1f1f1f;--sys-color-surface-primary:#c8cee3;--sys-color-surface-primary-hover:#9198ac;--sys-color-surface-subtle-hover:#5e5e5e;--sys-color-surface-danger:#b9192f;--sys-color-surface-danger-hover:#b9192f;--sys-color-surface-base-hover:#474747;--sys-color-border-base:#5e5e5e;--sys-color-border-strong:#c6c6c6;--sys-color-border-subtle:#5e5e5e;--sys-color-border-muted:#5e5e5e;--sys-color-feedback-error:#ff525c}:root{--page-width: 1160px}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--sys-color-surface-subtle);color:var(--sys-color-text-base);font-family:var(--ref-typo-family-sans);font-size:15px;line-height:1.6;-webkit-font-smoothing:antialiased}a{color:inherit;text-underline-offset:4px}button,input,select,textarea{font:inherit}button,a,input,select,textarea{-webkit-tap-highlight-color:rgba(0,0,0,0)}button,select{cursor:pointer}button:disabled{cursor:wait;opacity:.6}button{color:inherit}:focus-visible{outline:2px solid var(--sys-color-border-strong);outline-offset:4px}[hidden]{display:none !important}h1,h2,h3,p{margin:0}h1,h2,h3{line-height:1.2;font-weight:550}h1{letter-spacing:-0.055em}h2{font-size:26px;letter-spacing:-0.035em}h3{font-size:18px;letter-spacing:-0.02em}code,.extension-name,.art-caption,.eyebrow,kbd,.publisher-name{font-family:var(--ref-typo-family-mono)}.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.skip-link{position:fixed;top:-100px;left:16px;padding:12px;background:var(--sys-color-surface-base);z-index:20}.skip-link:focus{top:12px}.site-header{border-bottom:1px solid var(--sys-color-border-subtle);background:var(--sys-color-surface-subtle)}.header-inner{max-width:var(--page-width);min-height:88px;margin:auto;display:flex;align-items:center;gap:36px}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;font-weight:650;font-size:17px;letter-spacing:-0.04em}.brand-mark,.art-core{background:var(--sys-color-surface-primary);color:var(--sys-color-text-primary);font-weight:500;letter-spacing:-0.13em}.brand-mark{width:36px;height:36px;display:grid;grid-auto-flow:column;justify-content:center;align-items:center;font-size:26px;padding-right:4px}.brand-mark span,.art-core span{font-style:italic}.brand-label{color:var(--sys-color-text-subtle);margin-left:8px;padding-left:13px;border-left:1px solid var(--sys-color-border-subtle);font-size:14px;font-weight:400;letter-spacing:0}nav{display:flex;align-self:stretch;align-items:center;gap:32px;margin-left:auto}nav a{text-decoration:none;font-size:13px;color:var(--sys-color-text-subtle);height:100%;display:flex;align-items:center;gap:8px;border-bottom:2px solid rgba(0,0,0,0);padding-top:2px}nav a[aria-current]{color:var(--sys-color-text-base);border-color:var(--sys-color-border-strong)}.theme-button{border:1px solid var(--sys-color-border-subtle);background:rgba(0,0,0,0);border-radius:var(--sys-radius-full);width:32px;height:32px;display:grid;place-items:center;font-size:20px;line-height:1}.hero{max-width:var(--page-width);margin:auto;min-height:462px;display:grid;grid-template-columns:1.15fr 1fr;align-items:center;gap:40px;padding:64px 0}.eyebrow{font-size:10px;letter-spacing:.12em;font-weight:500;color:var(--sys-color-text-subtle);display:flex;align-items:center;gap:8px}.tiny-square{display:inline-block;width:5px;height:5px;background:currentColor;flex:none}.hero h1{font-size:clamp(42px,4.8vw,64px);line-height:1.08;margin-top:24px}.hero h1>span,.publisher-heading h1>span{color:var(--sys-color-text-subtle)}.hero-description{margin-top:24px;color:var(--sys-color-text-subtle);font-size:16px;line-height:1.8}.text-link{font-size:13px;text-decoration:none;display:inline-flex;gap:18px;align-items:center;font-weight:500}.text-link:hover{text-decoration:underline}.hero .text-link{margin-top:24px}.hero-art{position:relative;height:330px;background-image:radial-gradient(var(--sys-color-border-subtle) 0.7px, transparent 0.7px);background-size:20px 20px;mask-image:linear-gradient(to right, transparent, #000 12%, #000 88%, transparent)}.orbit{position:absolute;border:1px solid var(--sys-color-border-subtle);border-radius:50%;top:50%;left:50%;transform:translate(-50%, -50%) rotate(-25deg)}.orbit-one{width:320px;height:170px}.orbit-two{width:220px;height:270px;transform:translate(-50%, -50%) rotate(40deg)}.art-core{position:absolute;left:50%;top:50%;transform:translate(-50%, -50%) rotate(-8deg);width:98px;height:98px;border-radius:22px;display:flex;align-items:center;justify-content:center;font-size:72px;padding-right:7px;box-shadow:10px 12px 0 var(--sys-color-surface-muted)}.art-tile{position:absolute;display:grid;place-items:center;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);width:60px;height:60px;border-radius:var(--sys-radius-md);font-family:var(--ref-typo-family-mono);font-size:22px}.tile-code{top:36px;left:26%;transform:rotate(-12deg)}.tile-feed{top:100px;right:8%;transform:rotate(12deg);font-size:38px}.tile-mail{bottom:46px;left:15%;transform:rotate(8deg)}.tile-plus{bottom:43px;right:21%;transform:rotate(-10deg)}.art-caption{position:absolute;bottom:0;left:0;width:100%;text-align:center;font-size:8px;letter-spacing:.15em;color:var(--sys-color-text-subtle)}.catalog-section{max-width:var(--page-width);margin:auto;padding:36px 0 64px;border-top:1px solid var(--sys-color-border-subtle);scroll-margin-top:24px}.section-heading{display:flex;align-items:center;justify-content:space-between;gap:24px;margin-bottom:24px}.section-heading .eyebrow{margin-bottom:12px}.count{display:inline-block;vertical-align:middle;font-size:11px;font-weight:400;padding:2px 7px;border:1px solid var(--sys-color-border-subtle);border-radius:5px;letter-spacing:0;margin-left:8px;line-height:1.5}.section-note{font-size:12px;color:var(--sys-color-text-subtle)}.search-form{display:flex;gap:12px;margin:28px 0}.search-input{display:flex;align-items:center;flex:1;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);padding:0 14px;gap:12px;border-radius:var(--sys-radius-xs)}.search-input:focus-within{outline:2px solid var(--sys-color-border-strong);outline-offset:2px}.search-input>span{font-size:25px}.search-input input{border:0;background:rgba(0,0,0,0);width:100%;padding:11px 0;outline:none}.search-input kbd{border:1px solid var(--sys-color-border-subtle);color:var(--sys-color-text-subtle);border-radius:3px;padding:0 6px;font-size:11px}input,select,textarea{color:var(--sys-color-text-base);background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-xs);padding:11px 12px;min-width:0;max-width:100%}input::placeholder,textarea::placeholder{color:var(--sys-color-text-subtle);opacity:.85}.button{display:inline-flex;align-items:center;justify-content:center;gap:18px;padding:11px 18px;border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-xs);background:var(--sys-color-surface-base);color:var(--sys-color-text-base);text-decoration:none;font-size:12px;font-weight:500;line-height:1.5;white-space:nowrap}.button:hover{background:var(--sys-color-surface-subtle-hover)}.button-primary{background:var(--sys-color-surface-primary);color:var(--sys-color-text-primary);border-color:rgba(0,0,0,0)}.button-primary:hover{background:var(--sys-color-surface-primary-hover)}.search-summary{display:flex;gap:20px;font-size:13px;margin-bottom:20px}.catalog-grid{display:grid;grid-template-columns:repeat(3, minmax(0, 1fr));gap:16px;list-style:none;padding:0;margin:0}.extension-card{display:flex;flex-direction:column;gap:24px;min-height:252px;padding:24px;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm);text-decoration:none;transition:transform 160ms,border-color 160ms}.extension-card:hover{transform:translateY(-3px);border-color:var(--sys-color-border-strong)}.card-top{display:flex;justify-content:space-between;align-items:flex-start}.extension-icon{display:grid;place-items:center;width:48px;height:48px;border-radius:12px;background:var(--sys-color-surface-subtle);border:1px solid var(--sys-color-border-subtle);font-size:15px;font-weight:600;letter-spacing:-0.08em}.card-arrow{color:var(--sys-color-text-subtle);font-size:19px}.publisher-name{font-size:10px;letter-spacing:.02em;color:var(--sys-color-text-subtle);margin-bottom:7px;text-decoration:none}.extension-card h3{font-size:20px;overflow-wrap:anywhere}.extension-name{color:var(--sys-color-text-subtle);font-size:11px;margin-top:9px;overflow-wrap:anywhere}.card-bottom{margin-top:auto;padding-top:17px;border-top:1px solid var(--sys-color-border-subtle);display:flex;justify-content:space-between;gap:12px;font-size:10px;color:var(--sys-color-text-subtle)}.card-bottom>span:first-child{display:flex;gap:6px;align-items:center}.builder-callout{max-width:var(--page-width);margin:0 auto 64px;padding:36px;border:1px solid var(--sys-color-border-subtle);background:var(--sys-color-surface-muted);border-radius:var(--sys-radius-sm);display:flex;align-items:center;justify-content:space-between;gap:24px}.builder-callout h2{margin:12px 0 8px}.builder-callout p:not(.eyebrow){color:var(--sys-color-text-subtle);font-size:13px}.site-footer{max-width:var(--page-width);margin:auto;padding:28px 0 36px;border-top:1px solid var(--sys-color-border-subtle);display:flex;justify-content:space-between;gap:20px;color:var(--sys-color-text-subtle);font-size:11px}.site-footer>div{display:flex;align-items:center;gap:24px}.site-footer a{text-decoration:none}.footer-wordmark{font-size:15px;font-weight:650;letter-spacing:-0.05em;color:var(--sys-color-text-base)}.empty-state{text-align:center;border:1px dashed var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm);padding:56px 24px}.empty-symbol{font-size:38px;display:block;margin-bottom:16px}.empty-state p{color:var(--sys-color-text-subtle);margin:14px auto 24px;max-width:480px}.empty-state .eyebrow{justify-content:center}.preview-banner{text-align:center;padding:10px;font-size:12px;background:var(--sys-color-surface-muted)}.page-container{max-width:var(--page-width);margin:auto;padding:44px 0 80px}.back-link{display:inline-block;font-size:12px;text-decoration:none;color:var(--sys-color-text-subtle);margin-bottom:36px}.detail-heading{display:flex;align-items:center;gap:24px;padding-bottom:40px;border-bottom:1px solid var(--sys-color-border-subtle)}.extension-icon.large{width:88px;height:88px;border-radius:var(--sys-radius-md);font-size:28px;flex-shrink:0;background:var(--sys-color-surface-base)}.detail-heading h1{font-size:clamp(30px,4vw,46px);overflow-wrap:anywhere}.detail-heading .button{margin-left:auto}.detail-layout{display:grid;grid-template-columns:minmax(0, 1fr) 290px;gap:64px;padding-top:40px}.detail-content h2{font-size:24px}.lead{color:var(--sys-color-text-subtle);font-size:16px;line-height:1.8}.badge{background:var(--sys-color-surface-muted);font-size:10px;border-radius:var(--sys-radius-xs);padding:4px 8px;white-space:nowrap}.distribution-panel{background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm);padding:24px;margin-top:24px}.distribution-title{display:flex;align-items:center;gap:14px}.distribution-title p{color:var(--sys-color-text-subtle);font-size:12px;margin-top:4px}.format-icon{font-family:var(--ref-typo-family-mono);background:var(--sys-color-surface-muted);border-radius:var(--sys-radius-xs);width:40px;height:40px;display:grid;place-items:center}.metadata{margin:24px 0;font-size:12px}.metadata>div{display:grid;grid-template-columns:140px minmax(0, 1fr);padding:9px 0;border-top:1px solid var(--sys-color-border-subtle);gap:16px}.metadata dt{color:var(--sys-color-text-subtle)}.metadata dd{margin:0;overflow-wrap:anywhere}.info-note{padding:20px;border-left:2px solid var(--sys-color-border-subtle);margin-top:28px;font-size:12px;color:var(--sys-color-text-subtle)}.info-note strong{color:var(--sys-color-text-base);font-weight:500}.info-note p{margin-top:6px}.sidebar-panel{border-bottom:1px solid var(--sys-color-border-subtle);padding-bottom:24px;margin-bottom:24px}.sidebar-panel h2{font-size:32px;margin:12px 0 8px;overflow-wrap:anywhere}.sidebar-panel p:not(.eyebrow){font-size:12px;color:var(--sys-color-text-subtle);margin-bottom:20px}.sidebar-panel h3{font-size:14px}.version-list{list-style:none;padding:0;margin:16px 0 0;max-height:400px;overflow:auto}.version-list a{display:flex;justify-content:space-between;gap:8px;text-decoration:none;font-size:12px;padding:10px;border-radius:var(--sys-radius-xs);overflow-wrap:anywhere}.version-list a[aria-current]{background:var(--sys-color-surface-muted)}.version-list span{font-size:10px;color:var(--sys-color-text-subtle)}.publisher-heading{margin:16px 0 48px}.publisher-heading h1{font-size:clamp(40px,5vw,60px);margin:20px 0}.connect-layout{display:grid;grid-template-columns:1fr 1fr;gap:80px;align-items:center}.connect-card{padding:32px;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm)}.connect-card h2{margin:12px 0}.connect-card>p:not(.eyebrow){color:var(--sys-color-text-subtle);font-size:13px}.connect-card form{margin-top:24px}label{display:block;font-size:12px;font-weight:500;margin-bottom:7px}.connect-card input{width:100%}.field-help{color:var(--sys-color-text-subtle);font-size:11px;margin-top:8px}.connect-card button{margin-top:24px;width:100%}.steps{list-style:none;padding:0;margin:28px 0}.steps li{display:flex;gap:20px;margin-bottom:28px}.steps li>span{font-family:var(--ref-typo-family-mono);color:var(--sys-color-text-subtle);font-size:12px;padding-top:3px}.steps h3{font-size:16px}.steps p{color:var(--sys-color-text-subtle);font-size:13px;margin-top:8px}.workspace-heading,.workspace-toolbar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin-bottom:28px}.workspace-heading{border-bottom:1px solid var(--sys-color-border-subtle);padding-bottom:24px}.workspace-heading h2{margin-top:8px}.workspace-toolbar>div{display:flex;gap:8px}.release-row{display:flex;align-items:center;justify-content:space-between;gap:24px;background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-sm);padding:20px;margin-bottom:12px}.release-row h3{font-size:16px;overflow-wrap:anywhere}.release-row p{color:var(--sys-color-text-subtle);font-size:12px;margin:6px 0;overflow-wrap:anywhere}.release-actions{display:flex;gap:8px;flex-wrap:wrap}.form-status{font-size:12px;margin-top:12px;overflow-wrap:anywhere}.form-status.error{color:var(--sys-color-feedback-error)}.form-status:empty{display:none}dialog{color:var(--sys-color-text-base);background:var(--sys-color-surface-base);border:1px solid var(--sys-color-border-subtle);border-radius:var(--sys-radius-md);padding:28px;width:min(620px,100% - 32px);max-height:calc(100dvh - 48px);overflow:auto}dialog::backdrop{background:rgba(0,0,0,.5333333333)}.dialog-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:24px}.dialog-heading h2{margin-top:8px;font-size:24px}.icon-button{background:rgba(0,0,0,0);border:1px solid var(--sys-color-border-subtle);width:32px;height:32px;flex-shrink:0;font-size:24px;display:grid;place-items:center;line-height:1;border-radius:var(--sys-radius-xs)}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px}.form-grid input,dialog textarea{width:100%}.full-width{grid-column:1/-1}dialog .info-note{margin:24px 0}dialog form>.button{margin-top:20px}#action-description{margin-bottom:24px;font-size:13px;color:var(--sys-color-text-subtle)}.toast{position:fixed;bottom:24px;left:50%;transform:translateX(-50%);padding:12px 20px;border-radius:var(--sys-radius-sm);background:var(--sys-color-surface-primary);color:var(--sys-color-text-primary);font-size:12px;z-index:100}.toast:empty{display:none}@media(min-width: 1500px){.hero{min-height:510px}}@media(max-width: 1240px){.header-inner,.hero,.catalog-section,.builder-callout,.site-footer,.page-container{margin-left:32px;margin-right:32px}}@media(max-width: 900px){.hero{grid-template-columns:1.2fr 1fr;gap:0}.hero-art{transform:scale(0.85);transform-origin:right}.section-note{display:none}.catalog-grid{grid-template-columns:repeat(2, minmax(0, 1fr))}.detail-layout{gap:32px;grid-template-columns:minmax(0, 1fr) 220px}.connect-layout{gap:32px}.builder-callout{padding:28px}}@media(max-width: 640px){.header-inner,.hero,.catalog-section,.builder-callout,.site-footer,.page-container{margin-left:20px;margin-right:20px}.header-inner{min-height:72px;gap:16px}.brand{font-size:16px;gap:8px}.brand-label{display:none}nav{gap:20px}nav .docs-link{display:none}.hero{grid-template-columns:1fr;padding:48px 0 36px}.hero h1{font-size:44px}.hero-art{height:260px;transform:none;margin-top:32px}.hero-description{font-size:14px}.art-core{width:76px;height:76px;font-size:54px}.art-tile{width:48px;height:48px}.tile-code{top:20px}.tile-mail{bottom:30px}.tile-plus{bottom:26px}.orbit-one{width:270px;height:140px}.orbit-two{width:180px;height:210px}.catalog-section{padding-top:28px;padding-bottom:36px}h2{font-size:23px}.search-form{flex-wrap:wrap;gap:10px}.search-input{flex-basis:100%}.search-form select{flex:1}.search-form .button{min-width:88px}.catalog-grid{grid-template-columns:1fr;gap:12px}.extension-card{min-height:222px;padding:20px;gap:20px}.builder-callout{flex-direction:column;align-items:flex-start;margin-bottom:36px;padding:24px}.builder-callout .eyebrow{font-size:8px}.site-footer{flex-direction:column;padding-top:24px}.site-footer>div{justify-content:space-between}.detail-heading{flex-wrap:wrap;gap:16px}.extension-icon.large{width:64px;height:64px;font-size:22px}.detail-heading>div:nth-child(2){flex:1;min-width:0}.detail-heading .button{margin:8px 0 0}.detail-layout{grid-template-columns:1fr}.detail-content .section-heading{align-items:flex-start}.detail-content h2{font-size:22px}.metadata>div{grid-template-columns:100px minmax(0, 1fr)}.distribution-panel{padding:20px}.detail-sidebar{border-top:1px solid var(--sys-color-border-subtle);padding-top:24px}.page-container{padding:32px 0 48px}.publisher-heading{margin-bottom:32px}.connect-layout{grid-template-columns:1fr;gap:40px}.connect-card{padding:24px}.workspace-toolbar{align-items:flex-start;flex-direction:column}.release-row{align-items:flex-start;flex-direction:column;gap:12px}.form-grid{grid-template-columns:1fr}dialog{padding:20px}.search-summary{flex-wrap:wrap}.lead{font-size:14px}}@media(prefers-reduced-motion: reduce){html{scroll-behavior:auto}.extension-card{transition:none}}.release-pagination{display:flex;margin-top:24px;gap:16px;justify-content:flex-end;font-size:12px}.release-pagination button:disabled,.release-actions button:disabled{cursor:default} +:root{--ref-elevation-raised-low-shadow-type:dropShadow;--ref-elevation-raised-low-radius:2;--ref-elevation-raised-low-color:rgba(12, 21, 48, 0.0784313725);--ref-elevation-raised-low-offset-x:0;--ref-elevation-raised-low-offset-y:1;--ref-elevation-raised-low-spread:0;--ref-elevation-raised-md-shadow-type:dropShadow;--ref-elevation-raised-md-radius:20;--ref-elevation-raised-md-color:rgba(12, 21, 48, 0.1215686275);--ref-elevation-raised-md-offset-x:0;--ref-elevation-raised-md-offset-y:6;--ref-elevation-raised-md-spread:0;--ref-elevation-raised-high-shadow-type:dropShadow;--ref-elevation-raised-high-radius:32;--ref-elevation-raised-high-color:rgba(12, 21, 84, 0.1607843137);--ref-elevation-raised-high-offset-x:0;--ref-elevation-raised-high-offset-y:12;--ref-elevation-raised-high-spread:0;--ref-color-neutral-2:#070707;--ref-color-neutral-6:#131313;--ref-color-neutral-12:#1f1f1f;--ref-color-neutral-20:#303030;--ref-color-neutral-30:#474747;--ref-color-neutral-40:#5e5e5e;--ref-color-neutral-50:#777777;--ref-color-neutral-60:#919191;--ref-color-neutral-70:#ababab;--ref-color-neutral-80:#c6c6c6;--ref-color-neutral-90:#e2e2e2;--ref-color-neutral-94:#eeeeee;--ref-color-neutral-98:#f9f9f9;--ref-color-neutral-100:#ffffff;--ref-color-tertiary-40:#1f9aa5;--ref-color-tertiary-90:#d0f4f4;--ref-color-danger-10:#410008;--ref-color-danger-30:#92001e;--ref-color-danger-35:#a80324;--ref-color-danger-40:#b9192f;--ref-color-danger-60:#ff525c;--ref-color-danger-65:#ff7073;--ref-color-danger-70:#ff8889;--ref-color-danger-80:#ffb3b1;--ref-color-danger-90:#ffdad8;--ref-color-danger-98:#fff8f7;--ref-color-brand-2:#0b1220;--ref-color-brand-6:#121928;--ref-color-brand-12:#1f2635;--ref-color-brand-20:#303747;--ref-color-brand-30:#474e5f;--ref-color-brand-40:#5f6677;--ref-color-brand-50:#787e91;--ref-color-brand-60:#9198ac;--ref-color-brand-70:#acb3c7;--ref-color-brand-80:#b2bccf;--ref-color-brand-90:#c8cee3;--ref-color-brand-94:#f7f7fb;--ref-color-brand-98:#fbfcff;--ref-radius-xs:4px;--ref-radius-sm:8px;--ref-radius-md:16px;--ref-radius-lg:32px;--ref-radius-none:0px;--ref-radius-full:999px;--ref-space-md:16px;--ref-space-lg:32px;--ref-space-xl:56px;--ref-space-xs:4px;--ref-space-sm:8px;--ref-typo-family-sans:system-ui, -apple-system, sans-serif;--ref-typo-family-mono: SF Mono , Monaco, Roboto Mono , monospace;--ref-breakpoint-mobile:768px;--ref-breakpoint-tablet:1024px;--ref-breakpoint-desktop:1280px;--ref-opacity-muted:0.72;--ref-opacity-medium:0.84;--ref-opacity-strong:0.92;--ref-size-lg:48px;--ref-size-md:36px;--ref-size-sm:24px;--ref-size-icon-sm:20px;--ref-size-icon-md:24px;--ref-size-icon-lg:36px;--ref-size-iconfont-sm:8px;--ref-size-iconfont-md:12px;--ref-size-iconfont-lg:18px;--ref-size-xl:58px;--ref-size-xs:18px;--ref-font-label-lg-font-size:14px;--ref-font-label-lg-text-decoration:none;--ref-font-label-lg-font-family:Inter;--ref-font-label-lg-font-weight:400;--ref-font-label-lg-font-style:normal;--ref-font-label-lg-font-stretch:normal;--ref-font-label-lg-letter-spacing:0px;--ref-font-label-lg-line-height:16px;--ref-font-label-lg-paragraph-indent:0px;--ref-font-label-lg-paragraph-spacing:0px;--ref-font-label-lg-text-case:none;--ref-font-label-md-font-size:12px;--ref-font-label-md-text-decoration:none;--ref-font-label-md-font-family:Inter;--ref-font-label-md-font-weight:400;--ref-font-label-md-font-style:normal;--ref-font-label-md-font-stretch:normal;--ref-font-label-md-letter-spacing:0px;--ref-font-label-md-line-height:16px;--ref-font-label-md-paragraph-indent:0px;--ref-font-label-md-paragraph-spacing:0px;--ref-font-label-md-text-case:none;--ref-font-label-sm-font-size:10px;--ref-font-label-sm-text-decoration:none;--ref-font-label-sm-font-family:Roboto;--ref-font-label-sm-font-weight:700;--ref-font-label-sm-font-style:normal;--ref-font-label-sm-font-stretch:normal;--ref-font-label-sm-letter-spacing:0.7px;--ref-font-label-sm-line-height:14px;--ref-font-label-sm-paragraph-indent:0px;--ref-font-label-sm-paragraph-spacing:0px;--ref-font-label-sm-text-case:none;--ref-font-label-lg-underlined-font-size:14px;--ref-font-label-lg-underlined-text-decoration:underline;--ref-font-label-lg-underlined-font-family:Inter;--ref-font-label-lg-underlined-font-weight:400;--ref-font-label-lg-underlined-font-style:normal;--ref-font-label-lg-underlined-font-stretch:normal;--ref-font-label-lg-underlined-letter-spacing:0px;--ref-font-label-lg-underlined-line-height:16px;--ref-font-label-lg-underlined-paragraph-indent:0px;--ref-font-label-lg-underlined-paragraph-spacing:0px;--ref-font-label-lg-underlined-text-case:none;--ref-font-label-lg-mono-font-size:14px;--ref-font-label-lg-mono-text-decoration:none;--ref-font-label-lg-mono-font-family:JetBrains Mono;--ref-font-label-lg-mono-font-weight:400;--ref-font-label-lg-mono-font-style:normal;--ref-font-label-lg-mono-font-stretch:normal;--ref-font-label-lg-mono-letter-spacing:0px;--ref-font-label-lg-mono-line-height:16px;--ref-font-label-lg-mono-paragraph-indent:0px;--ref-font-label-lg-mono-paragraph-spacing:0px;--ref-font-label-lg-mono-text-case:none;--ref-font-label-sm-mono-font-size:10px;--ref-font-label-sm-mono-text-decoration:none;--ref-font-label-sm-mono-font-family:JetBrains Mono;--ref-font-label-sm-mono-font-weight:700;--ref-font-label-sm-mono-font-style:normal;--ref-font-label-sm-mono-font-stretch:normal;--ref-font-label-sm-mono-letter-spacing:0.4px;--ref-font-label-sm-mono-line-height:14px;--ref-font-label-sm-mono-paragraph-indent:0px;--ref-font-label-sm-mono-paragraph-spacing:0px;--ref-font-label-sm-mono-text-case:none;--ref-font-title-sm-font-size:22px;--ref-font-title-sm-text-decoration:none;--ref-font-title-sm-font-family:Inter;--ref-font-title-sm-font-weight:400;--ref-font-title-sm-font-style:normal;--ref-font-title-sm-font-stretch:normal;--ref-font-title-sm-letter-spacing:0px;--ref-font-title-sm-line-height:28px;--ref-font-title-sm-paragraph-indent:0px;--ref-font-title-sm-paragraph-spacing:0px;--ref-font-title-sm-text-case:none;--ref-font-title-sm-mono-font-size:22px;--ref-font-title-sm-mono-text-decoration:none;--ref-font-title-sm-mono-font-family:IBM Plex Mono;--ref-font-title-sm-mono-font-weight:400;--ref-font-title-sm-mono-font-style:normal;--ref-font-title-sm-mono-font-stretch:normal;--ref-font-title-sm-mono-letter-spacing:0.66px;--ref-font-title-sm-mono-line-height:28px;--ref-font-title-sm-mono-paragraph-indent:0px;--ref-font-title-sm-mono-paragraph-spacing:0px;--ref-font-title-sm-mono-text-case:none;--ref-font-body-lg-font-size:18px;--ref-font-body-lg-text-decoration:none;--ref-font-body-lg-font-family:Inter;--ref-font-body-lg-font-weight:400;--ref-font-body-lg-font-style:normal;--ref-font-body-lg-font-stretch:normal;--ref-font-body-lg-letter-spacing:0px;--ref-font-body-lg-line-height:24px;--ref-font-body-lg-paragraph-indent:0px;--ref-font-body-lg-paragraph-spacing:0px;--ref-font-body-lg-text-case:none;--ref-font-body-lg-mono-font-size:18px;--ref-font-body-lg-mono-text-decoration:none;--ref-font-body-lg-mono-font-family:JetBrains Mono;--ref-font-body-lg-mono-font-weight:400;--ref-font-body-lg-mono-font-style:normal;--ref-font-body-lg-mono-font-stretch:normal;--ref-font-body-lg-mono-letter-spacing:0px;--ref-font-body-lg-mono-line-height:24px;--ref-font-body-lg-mono-paragraph-indent:0px;--ref-font-body-lg-mono-paragraph-spacing:0px;--ref-font-body-lg-mono-text-case:none;--ref-font-headline-lg-font-size:36px;--ref-font-headline-lg-text-decoration:none;--ref-font-headline-lg-font-family:Inter;--ref-font-headline-lg-font-weight:400;--ref-font-headline-lg-font-style:normal;--ref-font-headline-lg-font-stretch:normal;--ref-font-headline-lg-letter-spacing:0px;--ref-font-headline-lg-line-height:48px;--ref-font-headline-lg-paragraph-indent:0px;--ref-font-headline-lg-paragraph-spacing:0px;--ref-font-headline-lg-text-case:none;--sys-elevation-raised-low-shadow-type:dropShadow;--sys-elevation-raised-low-radius:2;--sys-elevation-raised-low-color:rgba(12, 21, 48, 0.0784313725);--sys-elevation-raised-low-offset-x:0;--sys-elevation-raised-low-offset-y:1;--sys-elevation-raised-low-spread:0;--sys-elevation-raised-md-shadow-type:dropShadow;--sys-elevation-raised-md-radius:20;--sys-elevation-raised-md-color:rgba(12, 21, 48, 0.1215686275);--sys-elevation-raised-md-offset-x:0;--sys-elevation-raised-md-offset-y:6;--sys-elevation-raised-md-spread:0;--sys-elevation-raised-high-shadow-type:dropShadow;--sys-elevation-raised-high-radius:32;--sys-elevation-raised-high-color:rgba(12, 21, 84, 0.1607843137);--sys-elevation-raised-high-offset-x:0;--sys-elevation-raised-high-offset-y:12;--sys-elevation-raised-high-spread:0;--sys-radius-xs:4px;--sys-radius-sm:8px;--sys-radius-md:16px;--sys-radius-lg:32px;--sys-radius-none:0px;--sys-radius-full:999px;--sys-space-md:16px;--sys-space-lg:32px;--sys-space-xl:56px;--sys-space-xs:4px;--sys-space-sm:8px;--sys-typo-family-sans:system-ui, -apple-system, sans-serif;--sys-typo-family-mono: SF Mono , Monaco, Roboto Mono , monospace;--sys-breakpoint-mobile:768px;--sys-breakpoint-tablet:1024px;--sys-breakpoint-desktop:1280px;--sys-opacity-muted:0.72;--sys-opacity-medium:0.84;--sys-opacity-strong:0.92;--sys-size-lg:48px;--sys-size-md:36px;--sys-size-sm:24px;--sys-size-icon-sm:20px;--sys-size-icon-md:24px;--sys-size-icon-lg:36px;--sys-size-iconfont-sm:8px;--sys-size-iconfont-md:12px;--sys-size-iconfont-lg:18px;--sys-size-xl:58px;--sys-size-xs:18px;--sys-font-label-lg-font-size:14px;--sys-font-label-lg-text-decoration:none;--sys-font-label-lg-font-family:Inter;--sys-font-label-lg-font-weight:400;--sys-font-label-lg-font-style:normal;--sys-font-label-lg-font-stretch:normal;--sys-font-label-lg-letter-spacing:0px;--sys-font-label-lg-line-height:16px;--sys-font-label-lg-paragraph-indent:0px;--sys-font-label-lg-paragraph-spacing:0px;--sys-font-label-lg-text-case:none;--sys-font-label-md-font-size:12px;--sys-font-label-md-text-decoration:none;--sys-font-label-md-font-family:Inter;--sys-font-label-md-font-weight:400;--sys-font-label-md-font-style:normal;--sys-font-label-md-font-stretch:normal;--sys-font-label-md-letter-spacing:0px;--sys-font-label-md-line-height:16px;--sys-font-label-md-paragraph-indent:0px;--sys-font-label-md-paragraph-spacing:0px;--sys-font-label-md-text-case:none;--sys-font-label-sm-font-size:10px;--sys-font-label-sm-text-decoration:none;--sys-font-label-sm-font-family:Roboto;--sys-font-label-sm-font-weight:700;--sys-font-label-sm-font-style:normal;--sys-font-label-sm-font-stretch:normal;--sys-font-label-sm-letter-spacing:0.7px;--sys-font-label-sm-line-height:14px;--sys-font-label-sm-paragraph-indent:0px;--sys-font-label-sm-paragraph-spacing:0px;--sys-font-label-sm-text-case:none;--sys-font-label-lg-underlined-font-size:14px;--sys-font-label-lg-underlined-text-decoration:underline;--sys-font-label-lg-underlined-font-family:Inter;--sys-font-label-lg-underlined-font-weight:400;--sys-font-label-lg-underlined-font-style:normal;--sys-font-label-lg-underlined-font-stretch:normal;--sys-font-label-lg-underlined-letter-spacing:0px;--sys-font-label-lg-underlined-line-height:16px;--sys-font-label-lg-underlined-paragraph-indent:0px;--sys-font-label-lg-underlined-paragraph-spacing:0px;--sys-font-label-lg-underlined-text-case:none;--sys-font-label-lg-mono-font-size:14px;--sys-font-label-lg-mono-text-decoration:none;--sys-font-label-lg-mono-font-family:JetBrains Mono;--sys-font-label-lg-mono-font-weight:400;--sys-font-label-lg-mono-font-style:normal;--sys-font-label-lg-mono-font-stretch:normal;--sys-font-label-lg-mono-letter-spacing:0px;--sys-font-label-lg-mono-line-height:16px;--sys-font-label-lg-mono-paragraph-indent:0px;--sys-font-label-lg-mono-paragraph-spacing:0px;--sys-font-label-lg-mono-text-case:none;--sys-font-label-sm-mono-font-size:10px;--sys-font-label-sm-mono-text-decoration:none;--sys-font-label-sm-mono-font-family:JetBrains Mono;--sys-font-label-sm-mono-font-weight:700;--sys-font-label-sm-mono-font-style:normal;--sys-font-label-sm-mono-font-stretch:normal;--sys-font-label-sm-mono-letter-spacing:0.4px;--sys-font-label-sm-mono-line-height:14px;--sys-font-label-sm-mono-paragraph-indent:0px;--sys-font-label-sm-mono-paragraph-spacing:0px;--sys-font-label-sm-mono-text-case:none;--sys-font-title-sm-font-size:22px;--sys-font-title-sm-text-decoration:none;--sys-font-title-sm-font-family:Inter;--sys-font-title-sm-font-weight:400;--sys-font-title-sm-font-style:normal;--sys-font-title-sm-font-stretch:normal;--sys-font-title-sm-letter-spacing:0px;--sys-font-title-sm-line-height:28px;--sys-font-title-sm-paragraph-indent:0px;--sys-font-title-sm-paragraph-spacing:0px;--sys-font-title-sm-text-case:none;--sys-font-title-sm-mono-font-size:22px;--sys-font-title-sm-mono-text-decoration:none;--sys-font-title-sm-mono-font-family:IBM Plex Mono;--sys-font-title-sm-mono-font-weight:400;--sys-font-title-sm-mono-font-style:normal;--sys-font-title-sm-mono-font-stretch:normal;--sys-font-title-sm-mono-letter-spacing:0.66px;--sys-font-title-sm-mono-line-height:28px;--sys-font-title-sm-mono-paragraph-indent:0px;--sys-font-title-sm-mono-paragraph-spacing:0px;--sys-font-title-sm-mono-text-case:none;--sys-font-body-lg-font-size:18px;--sys-font-body-lg-text-decoration:none;--sys-font-body-lg-font-family:Inter;--sys-font-body-lg-font-weight:400;--sys-font-body-lg-font-style:normal;--sys-font-body-lg-font-stretch:normal;--sys-font-body-lg-letter-spacing:0px;--sys-font-body-lg-line-height:24px;--sys-font-body-lg-paragraph-indent:0px;--sys-font-body-lg-paragraph-spacing:0px;--sys-font-body-lg-text-case:none;--sys-font-body-lg-mono-font-size:18px;--sys-font-body-lg-mono-text-decoration:none;--sys-font-body-lg-mono-font-family:JetBrains Mono;--sys-font-body-lg-mono-font-weight:400;--sys-font-body-lg-mono-font-style:normal;--sys-font-body-lg-mono-font-stretch:normal;--sys-font-body-lg-mono-letter-spacing:0px;--sys-font-body-lg-mono-line-height:24px;--sys-font-body-lg-mono-paragraph-indent:0px;--sys-font-body-lg-mono-paragraph-spacing:0px;--sys-font-body-lg-mono-text-case:none;--sys-font-headline-lg-font-size:36px;--sys-font-headline-lg-text-decoration:none;--sys-font-headline-lg-font-family:Inter;--sys-font-headline-lg-font-weight:400;--sys-font-headline-lg-font-style:normal;--sys-font-headline-lg-font-stretch:normal;--sys-font-headline-lg-letter-spacing:0px;--sys-font-headline-lg-line-height:48px;--sys-font-headline-lg-paragraph-indent:0px;--sys-font-headline-lg-paragraph-spacing:0px;--sys-font-headline-lg-text-case:none}:root,html[data-theme=light]{color-scheme:light;--sys-color-text-base:#070707;--sys-color-text-muted:#919191;--sys-color-text-danger-on:#fff8f7;--sys-color-text-primary:#fbfcff;--sys-color-text-subtle:#5e5e5e;--sys-color-surface-base:#ffffff;--sys-color-surface-muted:#e2e2e2;--sys-color-surface-subtle:#f9f9f9;--sys-color-surface-primary:#121928;--sys-color-surface-primary-hover:#474e5f;--sys-color-surface-subtle-hover:#eeeeee;--sys-color-surface-danger:#a80324;--sys-color-surface-danger-hover:#ff7073;--sys-color-surface-base-hover:#eeeeee;--sys-color-border-base:#303030;--sys-color-border-strong:#070707;--sys-color-border-subtle:#c6c6c6;--sys-color-border-muted:#919191;--sys-color-feedback-error:#b9192f;--comp-switch-track-bg:#070707;--comp-switch-handle-bg:#f9f9f9;--comp-switch-label-color:#070707}@media(prefers-color-scheme: dark){:root:not([data-theme]),html[data-theme=dark]{color-scheme:dark;--sys-color-text-base:#f9f9f9;--sys-color-text-muted:#f9f9f9;--sys-color-text-danger-on:#fff8f7;--sys-color-text-primary:#0b1220;--sys-color-text-subtle:#c6c6c6;--sys-color-surface-base:#474747;--sys-color-surface-muted:#131313;--sys-color-surface-subtle:#1f1f1f;--sys-color-surface-primary:#c8cee3;--sys-color-surface-primary-hover:#9198ac;--sys-color-surface-subtle-hover:#5e5e5e;--sys-color-surface-danger:#b9192f;--sys-color-surface-danger-hover:#b9192f;--sys-color-surface-base-hover:#474747;--sys-color-border-base:#5e5e5e;--sys-color-border-strong:#c6c6c6;--sys-color-border-subtle:#5e5e5e;--sys-color-border-muted:#5e5e5e;--sys-color-feedback-error:#ff525c;--comp-switch-track-bg:#f9f9f9;--comp-switch-handle-bg:#070707;--comp-switch-label-color:#f9f9f9}}html[data-theme=dark]{color-scheme:dark;--sys-color-text-base:#f9f9f9;--sys-color-text-muted:#f9f9f9;--sys-color-text-danger-on:#fff8f7;--sys-color-text-primary:#0b1220;--sys-color-text-subtle:#c6c6c6;--sys-color-surface-base:#474747;--sys-color-surface-muted:#131313;--sys-color-surface-subtle:#1f1f1f;--sys-color-surface-primary:#c8cee3;--sys-color-surface-primary-hover:#9198ac;--sys-color-surface-subtle-hover:#5e5e5e;--sys-color-surface-danger:#b9192f;--sys-color-surface-danger-hover:#b9192f;--sys-color-surface-base-hover:#474747;--sys-color-border-base:#5e5e5e;--sys-color-border-strong:#c6c6c6;--sys-color-border-subtle:#5e5e5e;--sys-color-border-muted:#5e5e5e;--sys-color-feedback-error:#ff525c}*{box-sizing:border-box}body{margin:0;font-family:var(--ref-typo-family-sans);font-size:14px;line-height:1.5;color:var(--sys-color-text-base);background:var(--sys-color-surface-base)}[hidden]{display:none !important}a{color:inherit;text-underline-offset:3px}button,input,select,textarea{font:inherit}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid var(--sys-color-border-strong);outline-offset:3px}h1,h2,h3,p{margin:0}h1{font-size:var(--sys-font-title-sm-font-size);font-weight:var(--sys-font-title-sm-font-weight);line-height:var(--sys-font-title-sm-line-height);letter-spacing:var(--sys-font-title-sm-letter-spacing)}h2,h3{font-size:var(--sys-font-body-lg-font-size);font-weight:var(--sys-font-body-lg-font-weight);line-height:var(--sys-font-body-lg-line-height);letter-spacing:var(--sys-font-body-lg-letter-spacing)}code,.extension-name{font-family:var(--ref-typo-family-mono);overflow-wrap:anywhere}.sr-only,.skip-link:not(:focus){position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.skip-link:focus{display:block;padding:8px}.site-header{display:flex;align-items:center;justify-content:space-between;gap:var(--sys-space-md);padding:var(--sys-space-md) var(--sys-space-lg);background:var(--sys-color-surface-subtle);border-bottom:1px solid var(--sys-color-border-base)}.site-header nav{display:flex;align-items:center;gap:var(--sys-space-md)}.site-header nav a{text-decoration:none}.site-header [aria-current=page]{text-decoration:underline}.brand{display:flex;align-items:center;gap:var(--sys-space-sm);text-decoration:none;font-size:var(--sys-font-title-sm-font-size);font-weight:var(--sys-font-title-sm-font-weight);line-height:var(--sys-font-title-sm-line-height);letter-spacing:var(--sys-font-title-sm-letter-spacing);font-family:var(--ref-typo-family-mono)}.brand svg,.brand>span:first-child{display:block;width:32px;height:32px}.preview-label,.count,.extension-name,.field-help{color:var(--sys-color-text-subtle);font-size:12px}main{padding:var(--sys-space-lg) var(--sys-space-xl)}.section-heading,.detail-heading,.workspace-heading,.workspace-toolbar,.dialog-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--sys-space-md);margin-bottom:var(--sys-space-md)}.count{margin-left:var(--sys-space-sm);font-weight:400}.button,.icon-button{display:inline-flex;align-items:center;justify-content:center;gap:var(--sys-space-sm);padding:var(--sys-space-sm) var(--sys-space-md);border:1px solid var(--sys-color-border-base);border-radius:0;background:var(--sys-color-surface-subtle);color:var(--sys-color-text-base);text-decoration:none;cursor:pointer;font-size:var(--sys-font-label-lg-font-size);font-weight:var(--sys-font-label-lg-font-weight);line-height:var(--sys-font-label-lg-line-height);letter-spacing:var(--sys-font-label-lg-letter-spacing)}.button:hover,.icon-button:hover{background:var(--sys-color-surface-subtle-hover)}.button:disabled,.icon-button:disabled{opacity:.5;cursor:not-allowed}.icon-button{width:32px;height:32px;padding:0;border-color:rgba(0,0,0,0)}.button-primary{background:var(--sys-color-surface-primary);color:var(--sys-color-text-primary)}.button-primary:hover{background:var(--sys-color-surface-primary-hover)}input,select,textarea{width:100%;min-width:0;padding:var(--sys-space-sm);border:1px solid var(--sys-color-border-subtle);border-radius:0;background:var(--sys-color-surface-base);color:var(--sys-color-text-base)}input,select{min-height:36px}.search-form{display:flex;gap:var(--sys-space-sm);margin-bottom:var(--sys-space-md)}.search-form input{flex:1}.search-form select{width:auto;max-width:240px}.search-summary{margin-bottom:var(--sys-space-md)}.catalog-list,.version-list{list-style:none;margin:0;padding:0}.catalog-list{display:grid;gap:var(--sys-space-md)}.extension-card{display:flex;align-items:center;justify-content:space-between;gap:var(--sys-space-md);border:1px solid var(--sys-color-border-base);padding:var(--sys-space-md);text-decoration:none}.extension-card h2{font-size:var(--sys-font-label-lg-font-size);font-weight:var(--sys-font-label-lg-font-weight);line-height:var(--sys-font-label-lg-line-height);letter-spacing:var(--sys-font-label-lg-letter-spacing);margin-bottom:var(--sys-space-sm)}.extension-card:hover{background:var(--sys-color-surface-subtle)}.card-action{flex-shrink:0;font-size:12px}.back-link{display:inline-block;margin-bottom:var(--sys-space-lg)}.detail-heading{margin-bottom:var(--sys-space-lg)}.detail-heading h1{margin-bottom:var(--sys-space-sm)}.detail-layout{display:grid;grid-template-columns:minmax(0, 1fr) 280px;gap:var(--sys-space-lg)}.distribution-panel,.sidebar-panel,.release-row,.connect-card{border:1px solid var(--sys-color-border-base);padding:var(--sys-space-md);margin-bottom:var(--sys-space-md)}.metadata{margin:var(--sys-space-md) 0}.metadata>div{display:grid;grid-template-columns:120px minmax(0, 1fr);gap:var(--sys-space-md);margin-bottom:var(--sys-space-sm)}.metadata dt{color:var(--sys-color-text-subtle)}.metadata dd{margin:0;overflow-wrap:anywhere}.version-list{margin-top:var(--sys-space-md)}.version-list a{display:flex;justify-content:space-between;gap:var(--sys-space-sm);padding:var(--sys-space-sm);text-decoration:none}.version-list [aria-current],.version-list a:hover{background:var(--sys-color-surface-subtle-hover)}.version-list span{font-size:12px}.publisher-meta>div{grid-template-columns:auto 1fr}.connect-layout{max-width:480px}label{display:block;margin-bottom:var(--sys-space-sm)}form>.button{margin-top:var(--sys-space-md)}.search-form>.button{margin-top:0}.workspace-toolbar>div,.release-actions,.release-pagination{display:flex;flex-wrap:wrap;align-items:center;gap:var(--sys-space-sm)}.release-row{display:flex;align-items:center;justify-content:space-between;gap:var(--sys-space-md)}.release-row h3,.release-row p{margin-bottom:var(--sys-space-sm);overflow-wrap:anywhere}.release-row p{color:var(--sys-color-text-subtle);font-size:12px}.badge{display:inline-block;margin-bottom:var(--sys-space-sm);padding:0 var(--sys-space-xs);border:1px solid var(--sys-color-border-subtle);font-size:12px}.release-pagination{margin-bottom:var(--sys-space-md)}.form-status:not(:empty),.empty-state,noscript p{margin:var(--sys-space-md) 0}.error{color:var(--sys-color-feedback-error)}dialog{width:min(560px,100% - 32px);max-height:calc(100% - 32px);padding:var(--sys-space-lg);border:1px solid var(--sys-color-border-base);background:var(--sys-color-surface-base);color:var(--sys-color-text-base)}dialog::backdrop{background:rgba(0,0,0,.3)}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--sys-space-md);margin-top:var(--sys-space-md)}.full-width{grid-column:1/-1}.field-help{margin-top:var(--sys-space-xs)}.toast:not(:empty){position:fixed;bottom:var(--sys-space-md);right:var(--sys-space-md);max-width:calc(100% - 32px);padding:var(--sys-space-sm) var(--sys-space-md);border:1px solid var(--sys-color-border-base);background:var(--sys-color-surface-subtle)}.preview-detail{display:none}.preview-detail:target{display:block}main:has(>.preview-detail:target)>#catalog{display:none}@media(max-width: 768px){.site-header,main{padding:var(--sys-space-md)}.site-header nav{gap:var(--sys-space-sm)}.brand{font-size:18px}.search-form{flex-wrap:wrap}.search-form input{flex-basis:100%}.search-form select{flex:1;max-width:none}.detail-layout,.form-grid{grid-template-columns:minmax(0, 1fr)}.detail-heading,.workspace-toolbar,.release-row{align-items:flex-start;flex-wrap:wrap}dialog{padding:var(--sys-space-md)}.metadata>div{grid-template-columns:100px minmax(0, 1fr);gap:var(--sys-space-sm)}} diff --git a/src/inkcre_extension_registry/service/static/registry.js b/src/inkcre_extension_registry/service/static/registry.js index 4f5ac9c..bc28706 100644 --- a/src/inkcre_extension_registry/service/static/registry.js +++ b/src/inkcre_extension_registry/service/static/registry.js @@ -139,11 +139,7 @@ if ($('#connect-form')) { function openAction(release, kind) { action = { release, kind } const descriptions = { - publish: [ - 'Publish this release?', - 'This version will be public and appear in the catalog. At least one distribution must have been uploaded successfully.', - 'Publish release', - ], + publish: ['Publish this release?', 'This version will become public.', 'Publish release'], yank: [ 'Withdraw this release?', 'This version will leave the catalog and new discovery. Existing distribution URLs remain available; withdrawal does not uninstall it from anyone’s workspace.', @@ -156,7 +152,7 @@ if ($('#connect-form')) { ], 'module-federation': [ 'Upload a Web snapshot', - 'Upload the built ZIP for this exact version. An identical retry is safe; different bytes need a new version.', + 'Different bytes require a new version.', 'Upload snapshot', ], } @@ -186,13 +182,7 @@ if ($('#connect-form')) { list.replaceChildren() if (!data.releases.length) { const empty = element('div', null, 'empty-state') - empty.append( - element('h3', 'Your next release starts here.'), - element( - 'p', - 'Prepare a Web release here, or publish a Python distribution with the Toolkit.', - ), - ) + empty.append(element('p', 'No releases.')) list.append(empty) } for (const release of data.releases) { @@ -336,7 +326,6 @@ if ($('#connect-form')) { }) } async function updateAfterChange(message, pageOffset = offset) { - announce(message) status('#workspace-status', message) try { await refresh(pageOffset) @@ -369,7 +358,7 @@ if ($('#connect-form')) { }, }) $('#release-dialog').close() - await updateAfterChange('Release prepared. Upload your Web ZIP, then publish when ready.', 0) + await updateAfterChange('Release prepared.', 0) }) }) $('#action-form').addEventListener('submit', (event) => { diff --git a/src/inkcre_extension_registry/service/templates/base.html b/src/inkcre_extension_registry/service/templates/base.html index fee601b..3760260 100644 --- a/src/inkcre_extension_registry/service/templates/base.html +++ b/src/inkcre_extension_registry/service/templates/base.html @@ -3,11 +3,11 @@ - - {% if noindex %} + + {% if preview or noindex %} {% endif %} - {{ title }} · InKCre Extension Registry + {{ title }} · InKCre Registry {% if not preview %} @@ -16,68 +16,38 @@ - {% if preview %} -
    - Design preview · Sample catalog. Extension links open live API metadata; Publish opens the - Toolkit guide. -
    - {% endif %}
    {% block content %}{% endblock %}
    -
    diff --git a/src/inkcre_extension_registry/service/templates/brand.html b/src/inkcre_extension_registry/service/templates/brand.html new file mode 100644 index 0000000..2727331 --- /dev/null +++ b/src/inkcre_extension_registry/service/templates/brand.html @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/inkcre_extension_registry/service/templates/catalog-content.html b/src/inkcre_extension_registry/service/templates/catalog-content.html new file mode 100644 index 0000000..7c73c0d --- /dev/null +++ b/src/inkcre_extension_registry/service/templates/catalog-content.html @@ -0,0 +1,67 @@ +
    +
    +

    + Extensions + {{ extensions | length }} +

    + {% if not preview %} + Publish + {% endif %} +
    + {% if not preview %} + + {% if query or namespace %} +

    Clear filters

    + {% endif %} {% endif %} {% if extensions %} + + {% else %} +

    + {% if total %}No matching extensions.{% else %}No published + extensions.{% endif %} +

    + {% endif %} +
    diff --git a/src/inkcre_extension_registry/service/templates/catalog.html b/src/inkcre_extension_registry/service/templates/catalog.html index 518aa09..5b6fcaf 100644 --- a/src/inkcre_extension_registry/service/templates/catalog.html +++ b/src/inkcre_extension_registry/service/templates/catalog.html @@ -1,157 +1 @@ -{% extends "base.html" %} {% block content %} -
    -
    -

    - - THE INKCRE EXTENSION REGISTRY -

    -

    - Your workspace. -
    - More possibilities. -

    -

    - Bring your tools, sources, and ideas together. -
    - Find the next addition to your InKCre. -

    - - Find your extension - - -
    - -
    -
    -
    -
    -

    MADE FOR YOUR WORKSPACE

    -

    - Explore extensions - {{ total }} -

    -
    - Independent tools. Connected possibilities. -
    - {% if not preview %} - - {% if query or namespace %} -

    - {{ extensions | length }} result{{ '' if extensions | length == 1 else 's' }}{% if query %} for - “{{ query }}”{% endif %}{% if namespace %} by - {{ namespace }}{% endif %} - Clear filters -

    - {% endif %} {% endif %} {% if extensions %} - - {% else %} -
    - -

    - {% if total %}Nothing here just yet.{% else %}Be the first to build - something useful.{% endif %} -

    -

    - {% if total %}Try another name or choose a different - publisher.{% else %}Published extensions will appear here, ready for everyone to - discover.{% endif %} -

    - - {% if total %}Clear filters{% else %}Publish an - extension{% endif %} - - -
    - {% endif %} -
    -
    -
    -

    BUILT BY YOU. SHARED WITH EVERYONE.

    -

    Have something to add?

    -

    Give your idea a place in someone else’s workspace.

    -
    - - Publish an extension - - -
    -{% endblock %} +{% extends "base.html" %} {% block content %}{% include "catalog-content.html" %}{% endblock %} diff --git a/src/inkcre_extension_registry/service/templates/detail-content.html b/src/inkcre_extension_registry/service/templates/detail-content.html new file mode 100644 index 0000000..8e6aa2c --- /dev/null +++ b/src/inkcre_extension_registry/service/templates/detail-content.html @@ -0,0 +1,106 @@ +
    + + ← Extensions + +
    +
    +

    {{ extension.nickname }}

    +

    {{ extension.name }}

    +
    + {% if not preview %} + + {% endif %} +
    +
    +
    +
    +

    Distributions

    + {{ release.version }} +
    + {% if release.python %} +
    +

    Python

    + + {% if not preview %} + Package files ↗ + {% endif %} +
    + {% endif %} {% if release.module_federation %} +
    +

    Web

    + + {% if not preview %} + Manifest ↗ + {% endif %} +
    + {% endif %} +
    + +
    +
    diff --git a/src/inkcre_extension_registry/service/templates/detail.html b/src/inkcre_extension_registry/service/templates/detail.html index b5f1cdf..ee5ca15 100644 --- a/src/inkcre_extension_registry/service/templates/detail.html +++ b/src/inkcre_extension_registry/service/templates/detail.html @@ -1,129 +1 @@ -{% extends "base.html" %} {% block content %} -
    - ← All extensions -
    - -
    - - {{ publisher }} - -

    {{ extension.nickname }}

    -

    {{ extension.name }}

    -
    - -
    -
    -
    -
    -

    Make it part of your workspace.

    - Published -
    -

    - Use this extension’s ID in your InKCre deployment. Choose a distribution below to inspect - its package and host requirements. -

    - {% if release.python %} -
    -
    - -
    -

    Python

    -

    For your Core host

    -
    -
    - - Browse package files ↗ -
    - {% endif %} {% if release.module_federation %} -
    -
    - -
    -

    Web

    -

    For your browser host

    -
    -
    - - View manifest ↗ -
    - {% endif %} - -
    - -
    -
    -{% endblock %} +{% extends "base.html" %} {% block content %}{% include "detail-content.html" %}{% endblock %} diff --git a/src/inkcre_extension_registry/service/templates/error.html b/src/inkcre_extension_registry/service/templates/error.html index 759c617..aa8c7f8 100644 --- a/src/inkcre_extension_registry/service/templates/error.html +++ b/src/inkcre_extension_registry/service/templates/error.html @@ -1,11 +1,7 @@ {% extends "base.html" %} {% block content %} -
    - ← All extensions -
    -

    {{ code }}

    -

    {{ title }}

    -

    {{ message }}

    - Explore extensions → -
    +
    +

    {{ title }}

    +

    {{ message }}

    + Extensions
    {% endblock %} diff --git a/src/inkcre_extension_registry/service/templates/preview.html b/src/inkcre_extension_registry/service/templates/preview.html new file mode 100644 index 0000000..f746422 --- /dev/null +++ b/src/inkcre_extension_registry/service/templates/preview.html @@ -0,0 +1,8 @@ +{% extends "base.html" %} {% block content %} {% for detail in details %} +
    + {% with extension=detail.extension, release=detail.release, releases=detail.releases, publisher=detail.publisher %} {% include "detail-content.html" %} {% endwith %} +
    +{% endfor %} {% include "catalog-content.html" %} {% endblock %} diff --git a/src/inkcre_extension_registry/service/templates/publish.html b/src/inkcre_extension_registry/service/templates/publish.html index bf94f6d..544c90d 100644 --- a/src/inkcre_extension_registry/service/templates/publish.html +++ b/src/inkcre_extension_registry/service/templates/publish.html @@ -1,14 +1,6 @@ {% extends "base.html" %} {% block content %}
    -
    -

    THE PUBLISHER WORKSPACE

    -

    - Made by you. -
    - Ready for everyone. -

    -

    Take your extension from a local idea to a published release.

    -
    +

    Publisher

    -
    -

    A CLEAR PATH TO PUBLISHED

    -
      -
    1. - 01 -
      -

      Prepare a release

      -

      Give your extension a name, version, and source.

      -
      -
    2. -
    3. - 02 -
      -

      Add your distribution

      -

      Upload a Web snapshot here, or use the Toolkit for Python.

      -
      -
    4. -
    5. - 03 -
      -

      Make it discoverable

      -

      Review and publish. Manage your releases in one place.

      -
      -
    6. -
    -

    - New publisher? - Request a namespace ↗ -

    -
    + Request a namespace ↗
    -

    NEW WEB RELEASE

    -

    Give your idea a home.

    +

    New Web release

    @@ -147,12 +93,10 @@

    Give your idea a home.

    -

    SemVer, such as 1.0.0 or 1.0.0-rc.1.

    - + -

    Supported @inkcre/core SemVer range.

    @@ -176,13 +120,9 @@

    Give your idea a home.

    />
    -

    - Preparing reserves this version and its source metadata. Upload a ZIP with - mf-manifest.json - at its root next. You’ll publish in a separate step. -

    +

    - +
    @@ -199,13 +139,13 @@

    name="reason" maxlength="400" rows="3" - placeholder="Let consumers know why this version is being withdrawn." + placeholder="Withdrawal reason" >

    diff --git a/src/inkcre_extension_registry/service/ui.py b/src/inkcre_extension_registry/service/ui.py index b0b2062..99f4f74 100644 --- a/src/inkcre_extension_registry/service/ui.py +++ b/src/inkcre_extension_registry/service/ui.py @@ -58,12 +58,9 @@ def html_response(document: str, *, status_code: int = 200) -> HTMLResponse: def extension_catalog_html( extensions: Sequence[ExtensionSummary], *, - api_origin: str | None = None, - noindex: bool = False, query: str = "", namespace: str = "", ) -> str: - origin = _canonical_api_origin(api_origin) if api_origin is not None else "" publishers = sorted({extension.name.split("/")[0] for extension in extensions}) filtered = [ extension @@ -75,9 +72,6 @@ def extension_catalog_html( "catalog.html", title="Extensions", active="catalog", - origin=origin, - noindex=noindex, - preview=api_origin is not None, extensions=filtered, total=len(extensions), publishers=publishers, @@ -86,8 +80,12 @@ def extension_catalog_html( ) -def extension_detail_html(extension: ExtensionRecord, version: str | None = None) -> str | None: +def _detail_context( + extension: ExtensionRecord, version: str | None = None +) -> dict[str, object] | None: releases = sorted(extension.releases, key=lambda item: Version(item.version), reverse=True) + if not releases: + return None release: ReleaseRecord | None = next( (item for item in releases if item.version == version), None ) @@ -97,12 +95,38 @@ def extension_detail_html(extension: ExtensionRecord, version: str | None = None release = next( (item for item in releases if not Version(item.version).prerelease), releases[0] ) - return page( - "detail.html", - title=extension.nickname, - active="catalog", + return dict( extension=extension, release=release, releases=releases, publisher=extension.name.split("/")[0], ) + + +def extension_detail_html(extension: ExtensionRecord, version: str | None = None) -> str | None: + context = _detail_context(extension, version) + if context is None: + return None + return page("detail.html", title=extension.nickname, active="catalog", **context) + + +def extension_preview_html(extensions: Sequence[ExtensionRecord], *, api_origin: str) -> str: + """Render the catalog and sample release views within the bounded Pages document.""" + _canonical_api_origin(api_origin) + details = [] + for extension in extensions: + default = _detail_context(extension) + if default is None: + raise ValueError("preview extensions require a release") + details.append({**default, "default": True}) + for release in extension.releases: + details.append({**default, "release": release, "default": False}) + return page( + "preview.html", + title="Registry preview", + active="catalog", + preview=True, + extensions=extensions, + details=details, + total=len(extensions), + ) diff --git a/tasks/registry-web-lovable/packet.md b/tasks/registry-web-lovable/packet.md index 7f7a566..1ac452d 100644 --- a/tasks/registry-web-lovable/packet.md +++ b/tasks/registry-web-lovable/packet.md @@ -7,31 +7,27 @@ Sir 要求将 Registry Web 提升为可喜爱、可持续维护的产品,允 - 工作树:`.worktrees/registry-web/ext-reg`,分支 `feat/registry-web-lovable`,基线 `422a6cb`。 - 本 packet 是唯一任务控制入口;原仓库分叉及未提交内容保留。 -## 方案与边界 +## 当前纠正与边界 -- 静态名称列表 → Python/Jinja 服务端目录、搜索/发布者筛选、版本详情和 Publisher 工作台。 -- 直接消费已发布的 `@inkcre/ui-web` Sass 公共接口,构建 CSS 随 Python 包/Worker 交付。无独立前端服务。 -- Publisher 复用 namespace bearer 凭证与既有 prepare/upload/publish/yank/unyank 协议;增加自己的私有版本读取接口。浏览器支持 Module Federation ZIP 发布,Python 原生发布仍交给现有 Toolkit。 -- 不改变 Release/installed/enabled/running 权威,不改变公共 API 既有语义,不修改 D1 schema。 -- 资产/边界:发布者凭证授予命名空间写权限;公开昵称是外部数据。模板自动转义、浏览器 textContent、同源请求、仅内存凭证避免将公开元数据变成脚本或凭证泄露路径。依据 Hub security-boundary-model 和本地 registry-control-plane。 -- 静态 Pages preview 保持无凭证、只读目录性质;卡片指向已上线的 API 元数据,Publish 指向 Toolkit 文档,不声称运行候选 API。 +Sir 指出上一版偏离产品风格、增加过多 hero/tips/notices 且 logo 错误,预览看不到详情。此前 minimum 判断作废,以 `client-web` 的实际产品语言为准。 -## 验收与当前进度 +- 参照 `client-web/apps/client-web/src/App.vue`、extensionCard、design InkHeader/InkButton:32px 官方几何 logo、普通字号、直角边框、紧凑列表、白色工作区。删除宣传 hero、装饰图、假 logo、重复提示和页脚 CTA。 +- 变更对象:Registry 模板/CSS 和预览生成器。From → To:营销目录 → 简洁产品界面;只读目录/API 跳转 → 同一文档内的目录、详情、版本切换与返回。 +- 继续消费 design 已发布 Sass 公共接口,保留 Python/Jinja 与单 Worker;logo 精确来自 client-web 的 public/logo/32.svg。发布 API、原生上传协议、D1/R2 语义不变。 +- Pages 的现有默认分支 controller 只交付 index.html 且禁止脚本。预览复用生产详情模板和显式样例 release,通过锚点和 CSS 显示当前详情;保持 64 KiB 文档上限,无新增预览服务、权限或部署流程。页头用 Preview 标识样例数据;不向线上 API 导航假版本。 +- 本轮不编辑 client-web、design、shared 文档或生产资源。沿用已授权的同一 PR 提交/推送。 -1. 完成目录、详情、Publisher 浏览器流程,空态、失败态、移动端和键盘可用。 -2. 真正消费 design 样式,检查浅/深色与窄屏页面。 -3. 完整 `pnpm check`、Worker 本地 D1/R2 黑盒验证、构建包包含模板/资源、`git diff --check`。 -4. 更新拥有长期事实的本地文档,提交推送 PR,观察 CI 并修复本次失败。 +## 验收与状态 -当前:实现与本地验收完成;[PR #33](https://github.com/InKCre/ext-reg/pull/33) 已提交,CI 与 Pages preview 在 `29d5686` 均通过。主题、分页反馈、模板可读性和预览链接修正已纳入交付;最终提交的自动化结果以 PR checks 与 preview.json 的 source_sha 为准。暂无需 Sir 决策项。 +修正已完成,完整 `pnpm check` 通过(含 Worker dry-run 的 506 个模块与全部新模板)。浏览器验证目录→详情→版本→返回、实际 Worker 搜索→Python 详情、复制 ID、深浅色、发布者连接/新建表单/Escape/断开;390px 页面无横向溢出,控制台无错误。 -## 验收证据与交付 +最终静态预览保留 64 KiB 上限;已检查所有锚点存在且唯一、不包含脚本。logo 与 client-web 源 SVG 比对一致。最终提交的 CI、预览交付状态和 source_sha 以 PR checks / preview.json 为准。 -- Node 22 下完整 `pnpm check` 通过,包含合同生成、格式、lint、类型、各 wheel/客户端构建、Worker dry-run;确认模板、CSS、JS 与 Jinja 运行依赖进入 Worker。 -- 使用本地 Worker 和隔离的 D1/R2:匿名 Publisher 401/no-store、自身命名空间分页、其他命名空间不可读取/修改;真实 Web prepare/upload/publish 和 Python 原生 wheel 上传、发布、详情均通过。 -- 浏览器完成 Web 新建(含错误修正与字段保留)、ZIP 上传、发布、撤回、恢复、断开凭证;未上传版本不可发布。从第二页新建后回到第一页并展示新版本。 -- 验证目录搜索、空态、精确版本选择、SemVer 稳定版优先、详情 404、ID 复制、390px 窄屏无水平溢出、浅/深色与控制台;公开昵称自动转义。 -- 首次 CI 暴露 GitHub Packages 403;改用 pnpm 原生 Git 子目录依赖,锁定 design 已发布 1.4.0 的公共提交 `4eceec4c60345a52a08545555ebce9ab95053beb`。Sass 产物一致,无需扩大包权限或新增发布流程。 -- 长期设计归属 `docs/30-unit-tdd/registry-web.md`;构建/预览归属 `docs/40-deployment/`。无 shared 文档、数据库 schema 或生产资源变更。 -- [目录视觉预览](https://preview-ext-reg-pr-33.inkcre-extension-registry-ui-preview.pages.dev) 复用现有 Pages 流程;发布者交互在真实本地 Worker 验收。Python prepare/upload 仍由现有 Toolkit 执行,网页负责发布与生命周期管理。 +上一轮的 Web/Python 发布、撤回恢复、私有命名空间分页和隔离验证仍是后端证据;不作为本轮视觉验收依据。 + +## 交付 + +- [PR #33](https://github.com/InKCre/ext-reg/pull/33) +- [Registry 预览](https://preview-ext-reg-pr-33.inkcre-extension-registry-ui-preview.pages.dev) +- 长期设计归属 `docs/30-unit-tdd/registry-web.md`;预览交付归属 `docs/40-deployment/pull-request-previews.md`。 - 回退整个 PR 恢复旧 Web 与读取合同,无数据回滚步骤。PR 未关闭前保留本 packet。 diff --git a/web/registry.scss b/web/registry.scss index ed4daaa..0ff0f00 100644 --- a/web/registry.scss +++ b/web/registry.scss @@ -1,1201 +1,508 @@ @use 'pkg:@inkcre/ui-web/styles'; @use 'pkg:@inkcre/ui-web/tokens/sys' as sys; @use 'pkg:@inkcre/ui-web/styles/functions' as tokens; +@use 'pkg:@inkcre/ui-web/styles/mixins' as *; -// The package's automatic dark palette follows the OS; also support explicit selection. +// Match client-web's InkHeader, InkButton and extension-card geometry. html[data-theme='dark'] { color-scheme: dark; @include tokens.emit-css-variables(sys.$color-dark, 'sys', 'color'); } -:root { - --page-width: 1160px; -} * { box-sizing: border-box; } -html { - scroll-behavior: smooth; -} + body { margin: 0; - background: var(--sys-color-surface-subtle); - color: var(--sys-color-text-base); font-family: var(--ref-typo-family-sans); - font-size: 15px; - line-height: 1.6; - -webkit-font-smoothing: antialiased; + font-size: 14px; + line-height: 1.5; + color: var(--sys-color-text-base); + background: var(--sys-color-surface-base); +} + +[hidden] { + display: none !important; } + a { color: inherit; - text-underline-offset: 4px; + text-underline-offset: 3px; } + button, input, select, textarea { font: inherit; } + button, a, input, select, textarea { - -webkit-tap-highlight-color: transparent; -} -button, -select { - cursor: pointer; -} -button:disabled { - cursor: wait; - opacity: 0.6; -} -button { - color: inherit; -} -:focus-visible { - outline: 2px solid var(--sys-color-border-strong); - outline-offset: 4px; -} -[hidden] { - display: none !important; + &:focus-visible { + outline: 2px solid var(--sys-color-border-strong); + outline-offset: 3px; + } } + h1, h2, h3, p { margin: 0; } -h1, -h2, -h3 { - line-height: 1.2; - font-weight: 550; -} + h1 { - letter-spacing: -0.055em; -} -h2 { - font-size: 26px; - letter-spacing: -0.035em; + @include apply-font(title-sm); } + +h2, h3 { - font-size: 18px; - letter-spacing: -0.02em; + @include apply-font(body-lg); } + code, -.extension-name, -.art-caption, -.eyebrow, -kbd, -.publisher-name { +.extension-name { font-family: var(--ref-typo-family-mono); + overflow-wrap: anywhere; } -.sr-only { + +.sr-only, +.skip-link:not(:focus) { position: absolute; width: 1px; height: 1px; + padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; } -.skip-link { - position: fixed; - top: -100px; - left: 16px; - padding: 12px; - background: var(--sys-color-surface-base); - z-index: 20; -} + .skip-link:focus { - top: 12px; + display: block; + padding: 8px; } + .site-header { - border-bottom: 1px solid var(--sys-color-border-subtle); - background: var(--sys-color-surface-subtle); -} -.header-inner { - max-width: var(--page-width); - min-height: 88px; - margin: auto; display: flex; align-items: center; - gap: 36px; + justify-content: space-between; + gap: var(--sys-space-md); + padding: var(--sys-space-md) var(--sys-space-lg); + background: var(--sys-color-surface-subtle); + border-bottom: 1px solid var(--sys-color-border-base); + + nav { + display: flex; + align-items: center; + gap: var(--sys-space-md); + } + + nav a { + text-decoration: none; + } + + [aria-current='page'] { + text-decoration: underline; + } } + .brand { display: flex; align-items: center; - gap: 12px; + gap: var(--sys-space-sm); text-decoration: none; - font-weight: 650; - font-size: 17px; - letter-spacing: -0.04em; -} -.brand-mark, -.art-core { - background: var(--sys-color-surface-primary); - color: var(--sys-color-text-primary); - font-weight: 500; - letter-spacing: -0.13em; -} -.brand-mark { - width: 36px; - height: 36px; - display: grid; - grid-auto-flow: column; - justify-content: center; - align-items: center; - font-size: 26px; - padding-right: 4px; -} -.brand-mark span, -.art-core span { - font-style: italic; + @include apply-font(title-sm, mono); + + svg, + > span:first-child { + display: block; + width: 32px; + height: 32px; + } } -.brand-label { + +.preview-label, +.count, +.extension-name, +.field-help { color: var(--sys-color-text-subtle); - margin-left: 8px; - padding-left: 13px; - border-left: 1px solid var(--sys-color-border-subtle); - font-size: 14px; - font-weight: 400; - letter-spacing: 0; + font-size: 12px; } -nav { - display: flex; - align-self: stretch; - align-items: center; - gap: 32px; - margin-left: auto; + +main { + padding: var(--sys-space-lg) var(--sys-space-xl); } -nav a { - text-decoration: none; - font-size: 13px; - color: var(--sys-color-text-subtle); - height: 100%; + +.section-heading, +.detail-heading, +.workspace-heading, +.workspace-toolbar, +.dialog-heading { display: flex; align-items: center; - gap: 8px; - border-bottom: 2px solid transparent; - padding-top: 2px; + justify-content: space-between; + gap: var(--sys-space-md); + margin-bottom: var(--sys-space-md); +} + +.count { + margin-left: var(--sys-space-sm); + font-weight: 400; } -nav a[aria-current] { + +.button, +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--sys-space-sm); + padding: var(--sys-space-sm) var(--sys-space-md); + border: 1px solid var(--sys-color-border-base); + border-radius: 0; + background: var(--sys-color-surface-subtle); color: var(--sys-color-text-base); - border-color: var(--sys-color-border-strong); + text-decoration: none; + cursor: pointer; + @include apply-font(label-lg); + + &:hover { + background: var(--sys-color-surface-subtle-hover); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } } -.theme-button { - border: 1px solid var(--sys-color-border-subtle); - background: transparent; - border-radius: var(--sys-radius-full); + +.icon-button { width: 32px; height: 32px; - display: grid; - place-items: center; - font-size: 20px; - line-height: 1; + padding: 0; + border-color: transparent; } -.hero { - max-width: var(--page-width); - margin: auto; - min-height: 462px; - display: grid; - grid-template-columns: 1.15fr 1fr; - align-items: center; - gap: 40px; - padding: 64px 0; + +.button-primary { + background: var(--sys-color-surface-primary); + color: var(--sys-color-text-primary); + + &:hover { + background: var(--sys-color-surface-primary-hover); + } } -.eyebrow { - font-size: 10px; - letter-spacing: 0.12em; - font-weight: 500; - color: var(--sys-color-text-subtle); - display: flex; - align-items: center; - gap: 8px; + +input, +select, +textarea { + width: 100%; + min-width: 0; + padding: var(--sys-space-sm); + border: 1px solid var(--sys-color-border-subtle); + border-radius: 0; + background: var(--sys-color-surface-base); + color: var(--sys-color-text-base); } -.tiny-square { - display: inline-block; - width: 5px; - height: 5px; - background: currentColor; - flex: none; + +input, +select { + min-height: 36px; } -.hero h1 { - font-size: clamp(42px, 4.8vw, 64px); - line-height: 1.08; - margin-top: 24px; + +.search-form { + display: flex; + gap: var(--sys-space-sm); + margin-bottom: var(--sys-space-md); + + input { + flex: 1; + } + + select { + width: auto; + max-width: 240px; + } } -.hero h1 > span, -.publisher-heading h1 > span { - color: var(--sys-color-text-subtle); + +.search-summary { + margin-bottom: var(--sys-space-md); } -.hero-description { - margin-top: 24px; - color: var(--sys-color-text-subtle); - font-size: 16px; - line-height: 1.8; + +.catalog-list, +.version-list { + list-style: none; + margin: 0; + padding: 0; } -.text-link { - font-size: 13px; - text-decoration: none; - display: inline-flex; - gap: 18px; - align-items: center; - font-weight: 500; + +.catalog-list { + display: grid; + gap: var(--sys-space-md); } -.text-link:hover { - text-decoration: underline; + +.extension-card { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--sys-space-md); + border: 1px solid var(--sys-color-border-base); + padding: var(--sys-space-md); + text-decoration: none; + + h2 { + @include apply-font(label-lg); + margin-bottom: var(--sys-space-sm); + } + + &:hover { + background: var(--sys-color-surface-subtle); + } } -.hero .text-link { - margin-top: 24px; + +.card-action { + flex-shrink: 0; + font-size: 12px; } -.hero-art { - position: relative; - height: 330px; - background-image: radial-gradient(var(--sys-color-border-subtle) 0.7px, transparent 0.7px); - background-size: 20px 20px; - mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent); + +.back-link { + display: inline-block; + margin-bottom: var(--sys-space-lg); } -.orbit { - position: absolute; - border: 1px solid var(--sys-color-border-subtle); - border-radius: 50%; - top: 50%; - left: 50%; - transform: translate(-50%, -50%) rotate(-25deg); + +.detail-heading { + margin-bottom: var(--sys-space-lg); + + h1 { + margin-bottom: var(--sys-space-sm); + } } -.orbit-one { - width: 320px; - height: 170px; + +.detail-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 280px; + gap: var(--sys-space-lg); } -.orbit-two { - width: 220px; - height: 270px; - transform: translate(-50%, -50%) rotate(40deg); + +.distribution-panel, +.sidebar-panel, +.release-row, +.connect-card { + border: 1px solid var(--sys-color-border-base); + padding: var(--sys-space-md); + margin-bottom: var(--sys-space-md); } -.art-core { - position: absolute; - left: 50%; - top: 50%; - transform: translate(-50%, -50%) rotate(-8deg); - width: 98px; - height: 98px; - border-radius: 22px; - display: flex; - align-items: center; - justify-content: center; - font-size: 72px; - padding-right: 7px; - box-shadow: 10px 12px 0 var(--sys-color-surface-muted); + +.metadata { + margin: var(--sys-space-md) 0; + + > div { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + gap: var(--sys-space-md); + margin-bottom: var(--sys-space-sm); + } + + dt { + color: var(--sys-color-text-subtle); + } + + dd { + margin: 0; + overflow-wrap: anywhere; + } } -.art-tile { - position: absolute; - display: grid; - place-items: center; - background: var(--sys-color-surface-base); - border: 1px solid var(--sys-color-border-subtle); - width: 60px; - height: 60px; - border-radius: var(--sys-radius-md); - font-family: var(--ref-typo-family-mono); - font-size: 22px; + +.version-list { + margin-top: var(--sys-space-md); + + a { + display: flex; + justify-content: space-between; + gap: var(--sys-space-sm); + padding: var(--sys-space-sm); + text-decoration: none; + } + + [aria-current], + a:hover { + background: var(--sys-color-surface-subtle-hover); + } + + span { + font-size: 12px; + } } -.tile-code { - top: 36px; - left: 26%; - transform: rotate(-12deg); + +.publisher-meta > div { + grid-template-columns: auto 1fr; } -.tile-feed { - top: 100px; - right: 8%; - transform: rotate(12deg); - font-size: 38px; + +.connect-layout { + max-width: 480px; } -.tile-mail { - bottom: 46px; - left: 15%; - transform: rotate(8deg); + +label { + display: block; + margin-bottom: var(--sys-space-sm); } -.tile-plus { - bottom: 43px; - right: 21%; - transform: rotate(-10deg); + +form > .button { + margin-top: var(--sys-space-md); } -.art-caption { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - text-align: center; - font-size: 8px; - letter-spacing: 0.15em; - color: var(--sys-color-text-subtle); + +.search-form > .button { + margin-top: 0; } -.catalog-section { - max-width: var(--page-width); - margin: auto; - padding: 36px 0 64px; - border-top: 1px solid var(--sys-color-border-subtle); - scroll-margin-top: 24px; + +.workspace-toolbar > div, +.release-actions, +.release-pagination { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--sys-space-sm); } -.section-heading { + +.release-row { display: flex; align-items: center; justify-content: space-between; - gap: 24px; - margin-bottom: 24px; -} -.section-heading .eyebrow { - margin-bottom: 12px; + gap: var(--sys-space-md); + + h3, + p { + margin-bottom: var(--sys-space-sm); + overflow-wrap: anywhere; + } + + p { + color: var(--sys-color-text-subtle); + font-size: 12px; + } } -.count { + +.badge { display: inline-block; - vertical-align: middle; - font-size: 11px; - font-weight: 400; - padding: 2px 7px; + margin-bottom: var(--sys-space-sm); + padding: 0 var(--sys-space-xs); border: 1px solid var(--sys-color-border-subtle); - border-radius: 5px; - letter-spacing: 0; - margin-left: 8px; - line-height: 1.5; -} -.section-note { font-size: 12px; - color: var(--sys-color-text-subtle); } -.search-form { - display: flex; - gap: 12px; - margin: 28px 0; + +.release-pagination { + margin-bottom: var(--sys-space-md); } -.search-input { - display: flex; - align-items: center; - flex: 1; - background: var(--sys-color-surface-base); - border: 1px solid var(--sys-color-border-subtle); - padding: 0 14px; - gap: 12px; - border-radius: var(--sys-radius-xs); + +.form-status:not(:empty), +.empty-state, +noscript p { + margin: var(--sys-space-md) 0; } -.search-input:focus-within { - outline: 2px solid var(--sys-color-border-strong); - outline-offset: 2px; + +.error { + color: var(--sys-color-feedback-error); } -.search-input > span { - font-size: 25px; -} -.search-input input { - border: 0; - background: transparent; - width: 100%; - padding: 11px 0; - outline: none; -} -.search-input kbd { - border: 1px solid var(--sys-color-border-subtle); - color: var(--sys-color-text-subtle); - border-radius: 3px; - padding: 0 6px; - font-size: 11px; -} -input, -select, -textarea { - color: var(--sys-color-text-base); - background: var(--sys-color-surface-base); - border: 1px solid var(--sys-color-border-subtle); - border-radius: var(--sys-radius-xs); - padding: 11px 12px; - min-width: 0; - max-width: 100%; -} -input::placeholder, -textarea::placeholder { - color: var(--sys-color-text-subtle); - opacity: 0.85; -} -.button { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 18px; - padding: 11px 18px; - border: 1px solid var(--sys-color-border-subtle); - border-radius: var(--sys-radius-xs); - background: var(--sys-color-surface-base); - color: var(--sys-color-text-base); - text-decoration: none; - font-size: 12px; - font-weight: 500; - line-height: 1.5; - white-space: nowrap; -} -.button:hover { - background: var(--sys-color-surface-subtle-hover); -} -.button-primary { - background: var(--sys-color-surface-primary); - color: var(--sys-color-text-primary); - border-color: transparent; -} -.button-primary:hover { - background: var(--sys-color-surface-primary-hover); -} -.search-summary { - display: flex; - gap: 20px; - font-size: 13px; - margin-bottom: 20px; -} -.catalog-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 16px; - list-style: none; - padding: 0; - margin: 0; -} -.extension-card { - display: flex; - flex-direction: column; - gap: 24px; - min-height: 252px; - padding: 24px; - background: var(--sys-color-surface-base); - border: 1px solid var(--sys-color-border-subtle); - border-radius: var(--sys-radius-sm); - text-decoration: none; - transition: - transform 160ms, - border-color 160ms; -} -.extension-card:hover { - transform: translateY(-3px); - border-color: var(--sys-color-border-strong); -} -.card-top { - display: flex; - justify-content: space-between; - align-items: flex-start; -} -.extension-icon { - display: grid; - place-items: center; - width: 48px; - height: 48px; - border-radius: 12px; - background: var(--sys-color-surface-subtle); - border: 1px solid var(--sys-color-border-subtle); - font-size: 15px; - font-weight: 600; - letter-spacing: -0.08em; -} -.card-arrow { - color: var(--sys-color-text-subtle); - font-size: 19px; -} -.publisher-name { - font-size: 10px; - letter-spacing: 0.02em; - color: var(--sys-color-text-subtle); - margin-bottom: 7px; - text-decoration: none; -} -.extension-card h3 { - font-size: 20px; - overflow-wrap: anywhere; -} -.extension-name { - color: var(--sys-color-text-subtle); - font-size: 11px; - margin-top: 9px; - overflow-wrap: anywhere; -} -.card-bottom { - margin-top: auto; - padding-top: 17px; - border-top: 1px solid var(--sys-color-border-subtle); - display: flex; - justify-content: space-between; - gap: 12px; - font-size: 10px; - color: var(--sys-color-text-subtle); -} -.card-bottom > span:first-child { - display: flex; - gap: 6px; - align-items: center; -} -.builder-callout { - max-width: var(--page-width); - margin: 0 auto 64px; - padding: 36px; - border: 1px solid var(--sys-color-border-subtle); - background: var(--sys-color-surface-muted); - border-radius: var(--sys-radius-sm); - display: flex; - align-items: center; - justify-content: space-between; - gap: 24px; -} -.builder-callout h2 { - margin: 12px 0 8px; -} -.builder-callout p:not(.eyebrow) { - color: var(--sys-color-text-subtle); - font-size: 13px; -} -.site-footer { - max-width: var(--page-width); - margin: auto; - padding: 28px 0 36px; - border-top: 1px solid var(--sys-color-border-subtle); - display: flex; - justify-content: space-between; - gap: 20px; - color: var(--sys-color-text-subtle); - font-size: 11px; -} -.site-footer > div { - display: flex; - align-items: center; - gap: 24px; -} -.site-footer a { - text-decoration: none; -} -.footer-wordmark { - font-size: 15px; - font-weight: 650; - letter-spacing: -0.05em; - color: var(--sys-color-text-base); -} -.empty-state { - text-align: center; - border: 1px dashed var(--sys-color-border-subtle); - border-radius: var(--sys-radius-sm); - padding: 56px 24px; -} -.empty-symbol { - font-size: 38px; - display: block; - margin-bottom: 16px; -} -.empty-state p { - color: var(--sys-color-text-subtle); - margin: 14px auto 24px; - max-width: 480px; -} -.empty-state .eyebrow { - justify-content: center; -} -.preview-banner { - text-align: center; - padding: 10px; - font-size: 12px; - background: var(--sys-color-surface-muted); -} -.page-container { - max-width: var(--page-width); - margin: auto; - padding: 44px 0 80px; -} -.back-link { - display: inline-block; - font-size: 12px; - text-decoration: none; - color: var(--sys-color-text-subtle); - margin-bottom: 36px; -} -.detail-heading { - display: flex; - align-items: center; - gap: 24px; - padding-bottom: 40px; - border-bottom: 1px solid var(--sys-color-border-subtle); -} -.extension-icon.large { - width: 88px; - height: 88px; - border-radius: var(--sys-radius-md); - font-size: 28px; - flex-shrink: 0; - background: var(--sys-color-surface-base); -} -.detail-heading h1 { - font-size: clamp(30px, 4vw, 46px); - overflow-wrap: anywhere; -} -.detail-heading .button { - margin-left: auto; -} -.detail-layout { - display: grid; - grid-template-columns: minmax(0, 1fr) 290px; - gap: 64px; - padding-top: 40px; -} -.detail-content h2 { - font-size: 24px; -} -.lead { - color: var(--sys-color-text-subtle); - font-size: 16px; - line-height: 1.8; -} -.badge { - background: var(--sys-color-surface-muted); - font-size: 10px; - border-radius: var(--sys-radius-xs); - padding: 4px 8px; - white-space: nowrap; -} -.distribution-panel { - background: var(--sys-color-surface-base); - border: 1px solid var(--sys-color-border-subtle); - border-radius: var(--sys-radius-sm); - padding: 24px; - margin-top: 24px; -} -.distribution-title { - display: flex; - align-items: center; - gap: 14px; -} -.distribution-title p { - color: var(--sys-color-text-subtle); - font-size: 12px; - margin-top: 4px; -} -.format-icon { - font-family: var(--ref-typo-family-mono); - background: var(--sys-color-surface-muted); - border-radius: var(--sys-radius-xs); - width: 40px; - height: 40px; - display: grid; - place-items: center; -} -.metadata { - margin: 24px 0; - font-size: 12px; -} -.metadata > div { - display: grid; - grid-template-columns: 140px minmax(0, 1fr); - padding: 9px 0; - border-top: 1px solid var(--sys-color-border-subtle); - gap: 16px; -} -.metadata dt { - color: var(--sys-color-text-subtle); -} -.metadata dd { - margin: 0; - overflow-wrap: anywhere; -} -.info-note { - padding: 20px; - border-left: 2px solid var(--sys-color-border-subtle); - margin-top: 28px; - font-size: 12px; - color: var(--sys-color-text-subtle); -} -.info-note strong { - color: var(--sys-color-text-base); - font-weight: 500; -} -.info-note p { - margin-top: 6px; -} -.sidebar-panel { - border-bottom: 1px solid var(--sys-color-border-subtle); - padding-bottom: 24px; - margin-bottom: 24px; -} -.sidebar-panel h2 { - font-size: 32px; - margin: 12px 0 8px; - overflow-wrap: anywhere; -} -.sidebar-panel p:not(.eyebrow) { - font-size: 12px; - color: var(--sys-color-text-subtle); - margin-bottom: 20px; -} -.sidebar-panel h3 { - font-size: 14px; -} -.version-list { - list-style: none; - padding: 0; - margin: 16px 0 0; - max-height: 400px; - overflow: auto; -} -.version-list a { - display: flex; - justify-content: space-between; - gap: 8px; - text-decoration: none; - font-size: 12px; - padding: 10px; - border-radius: var(--sys-radius-xs); - overflow-wrap: anywhere; -} -.version-list a[aria-current] { - background: var(--sys-color-surface-muted); -} -.version-list span { - font-size: 10px; - color: var(--sys-color-text-subtle); -} -.publisher-heading { - margin: 16px 0 48px; -} -.publisher-heading h1 { - font-size: clamp(40px, 5vw, 60px); - margin: 20px 0; -} -.connect-layout { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 80px; - align-items: center; -} -.connect-card { - padding: 32px; - background: var(--sys-color-surface-base); - border: 1px solid var(--sys-color-border-subtle); - border-radius: var(--sys-radius-sm); -} -.connect-card h2 { - margin: 12px 0; -} -.connect-card > p:not(.eyebrow) { - color: var(--sys-color-text-subtle); - font-size: 13px; -} -.connect-card form { - margin-top: 24px; -} -label { - display: block; - font-size: 12px; - font-weight: 500; - margin-bottom: 7px; -} -.connect-card input { - width: 100%; -} -.field-help { - color: var(--sys-color-text-subtle); - font-size: 11px; - margin-top: 8px; -} -.connect-card button { - margin-top: 24px; - width: 100%; -} -.steps { - list-style: none; - padding: 0; - margin: 28px 0; -} -.steps li { - display: flex; - gap: 20px; - margin-bottom: 28px; -} -.steps li > span { - font-family: var(--ref-typo-family-mono); - color: var(--sys-color-text-subtle); - font-size: 12px; - padding-top: 3px; -} -.steps h3 { - font-size: 16px; -} -.steps p { - color: var(--sys-color-text-subtle); - font-size: 13px; - margin-top: 8px; -} -.workspace-heading, -.workspace-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - margin-bottom: 28px; -} -.workspace-heading { - border-bottom: 1px solid var(--sys-color-border-subtle); - padding-bottom: 24px; -} -.workspace-heading h2 { - margin-top: 8px; -} -.workspace-toolbar > div { - display: flex; - gap: 8px; -} -.release-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 24px; - background: var(--sys-color-surface-base); - border: 1px solid var(--sys-color-border-subtle); - border-radius: var(--sys-radius-sm); - padding: 20px; - margin-bottom: 12px; -} -.release-row h3 { - font-size: 16px; - overflow-wrap: anywhere; -} -.release-row p { - color: var(--sys-color-text-subtle); - font-size: 12px; - margin: 6px 0; - overflow-wrap: anywhere; -} -.release-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; -} -.form-status { - font-size: 12px; - margin-top: 12px; - overflow-wrap: anywhere; -} -.form-status.error { - color: var(--sys-color-feedback-error); -} -.form-status:empty { - display: none; -} -dialog { - color: var(--sys-color-text-base); - background: var(--sys-color-surface-base); - border: 1px solid var(--sys-color-border-subtle); - border-radius: var(--sys-radius-md); - padding: 28px; - width: min(620px, calc(100% - 32px)); - max-height: calc(100dvh - 48px); - overflow: auto; -} -dialog::backdrop { - background: #0008; -} -.dialog-heading { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 20px; - margin-bottom: 24px; -} -.dialog-heading h2 { - margin-top: 8px; - font-size: 24px; -} -.icon-button { - background: transparent; - border: 1px solid var(--sys-color-border-subtle); - width: 32px; - height: 32px; - flex-shrink: 0; - font-size: 24px; - display: grid; - place-items: center; - line-height: 1; - border-radius: var(--sys-radius-xs); + +dialog { + width: min(560px, calc(100% - 32px)); + max-height: calc(100% - 32px); + padding: var(--sys-space-lg); + border: 1px solid var(--sys-color-border-base); + background: var(--sys-color-surface-base); + color: var(--sys-color-text-base); + + &::backdrop { + background: rgb(0 0 0 / 30%); + } } + .form-grid { display: grid; grid-template-columns: 1fr 1fr; - gap: 18px; -} -.form-grid input, -dialog textarea { - width: 100%; + gap: var(--sys-space-md); + margin-top: var(--sys-space-md); } + .full-width { grid-column: 1 / -1; } -dialog .info-note { - margin: 24px 0; -} -dialog form > .button { - margin-top: 20px; -} -#action-description { - margin-bottom: 24px; - font-size: 13px; - color: var(--sys-color-text-subtle); + +.field-help { + margin-top: var(--sys-space-xs); } -.toast { + +.toast:not(:empty) { position: fixed; - bottom: 24px; - left: 50%; - transform: translateX(-50%); - padding: 12px 20px; - border-radius: var(--sys-radius-sm); - background: var(--sys-color-surface-primary); - color: var(--sys-color-text-primary); - font-size: 12px; - z-index: 100; + bottom: var(--sys-space-md); + right: var(--sys-space-md); + max-width: calc(100% - 32px); + padding: var(--sys-space-sm) var(--sys-space-md); + border: 1px solid var(--sys-color-border-base); + background: var(--sys-color-surface-subtle); } -.toast:empty { + +// The existing Pages controller delivers one HTML file with no script permission. +.preview-detail { display: none; -} -@media (min-width: 1500px) { - .hero { - min-height: 510px; - } -} -@media (max-width: 1240px) { - .header-inner, - .hero, - .catalog-section, - .builder-callout, - .site-footer, - .page-container { - margin-left: 32px; - margin-right: 32px; + + &:target { + display: block; } } -@media (max-width: 900px) { - .hero { - grid-template-columns: 1.2fr 1fr; - gap: 0; - } - .hero-art { - transform: scale(0.85); - transform-origin: right; - } - .section-note { - display: none; - } - .catalog-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - .detail-layout { - gap: 32px; - grid-template-columns: minmax(0, 1fr) 220px; - } - .connect-layout { - gap: 32px; - } - .builder-callout { - padding: 28px; - } + +main:has(> .preview-detail:target) > #catalog { + display: none; } -@media (max-width: 640px) { - .header-inner, - .hero, - .catalog-section, - .builder-callout, - .site-footer, - .page-container { - margin-left: 20px; - margin-right: 20px; + +@media (max-width: 768px) { + .site-header, + main { + padding: var(--sys-space-md); } - .header-inner { - min-height: 72px; - gap: 16px; + + .site-header nav { + gap: var(--sys-space-sm); } + .brand { - font-size: 16px; - gap: 8px; - } - .brand-label { - display: none; - } - nav { - gap: 20px; - } - nav .docs-link { - display: none; - } - .hero { - grid-template-columns: 1fr; - padding: 48px 0 36px; - } - .hero h1 { - font-size: 44px; - } - .hero-art { - height: 260px; - transform: none; - margin-top: 32px; - } - .hero-description { - font-size: 14px; - } - .art-core { - width: 76px; - height: 76px; - font-size: 54px; - } - .art-tile { - width: 48px; - height: 48px; - } - .tile-code { - top: 20px; - } - .tile-mail { - bottom: 30px; - } - .tile-plus { - bottom: 26px; - } - .orbit-one { - width: 270px; - height: 140px; - } - .orbit-two { - width: 180px; - height: 210px; - } - .catalog-section { - padding-top: 28px; - padding-bottom: 36px; - } - h2 { - font-size: 23px; + font-size: 18px; } + .search-form { flex-wrap: wrap; - gap: 10px; - } - .search-input { - flex-basis: 100%; - } - .search-form select { - flex: 1; - } - .search-form .button { - min-width: 88px; - } - .catalog-grid { - grid-template-columns: 1fr; - gap: 12px; + + input { + flex-basis: 100%; + } + + select { + flex: 1; + max-width: none; + } } - .extension-card { - min-height: 222px; - padding: 20px; - gap: 20px; + + .detail-layout, + .form-grid { + grid-template-columns: minmax(0, 1fr); } - .builder-callout { - flex-direction: column; + + .detail-heading, + .workspace-toolbar, + .release-row { align-items: flex-start; - margin-bottom: 36px; - padding: 24px; - } - .builder-callout .eyebrow { - font-size: 8px; - } - .site-footer { - flex-direction: column; - padding-top: 24px; - } - .site-footer > div { - justify-content: space-between; - } - .detail-heading { flex-wrap: wrap; - gap: 16px; - } - .extension-icon.large { - width: 64px; - height: 64px; - font-size: 22px; - } - .detail-heading > div:nth-child(2) { - flex: 1; - min-width: 0; } - .detail-heading .button { - margin: 8px 0 0; - } - .detail-layout { - grid-template-columns: 1fr; - } - .detail-content .section-heading { - align-items: flex-start; - } - .detail-content h2 { - font-size: 22px; + + dialog { + padding: var(--sys-space-md); } + .metadata > div { grid-template-columns: 100px minmax(0, 1fr); + gap: var(--sys-space-sm); } - .distribution-panel { - padding: 20px; - } - .detail-sidebar { - border-top: 1px solid var(--sys-color-border-subtle); - padding-top: 24px; - } - .page-container { - padding: 32px 0 48px; - } - .publisher-heading { - margin-bottom: 32px; - } - .connect-layout { - grid-template-columns: 1fr; - gap: 40px; - } - .connect-card { - padding: 24px; - } - .workspace-toolbar { - align-items: flex-start; - flex-direction: column; - } - .release-row { - align-items: flex-start; - flex-direction: column; - gap: 12px; - } - .form-grid { - grid-template-columns: 1fr; - } - dialog { - padding: 20px; - } - .search-summary { - flex-wrap: wrap; - } - .lead { - font-size: 14px; - } -} -@media (prefers-reduced-motion: reduce) { - html { - scroll-behavior: auto; - } - .extension-card { - transition: none; - } -} - -.release-pagination { - display: flex; - margin-top: 24px; - gap: 16px; - justify-content: flex-end; - font-size: 12px; -} -.release-pagination button:disabled, -.release-actions button:disabled { - cursor: default; } From de6f664c42dcf6e6fc6c6dcd60773c017e1cff28 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Sun, 6 Sep 2026 21:56:21 +0800 Subject: [PATCH 05/16] =?UTF-8?q?refactor(web):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E4=BA=A7=E5=93=81=E7=8E=AF=E5=A2=83=E5=88=86=E6=94=AF=EF=BC=8C?= =?UTF-8?q?=E5=B0=86=E9=9D=99=E6=80=81=E6=A0=B7=E5=BC=A0=E5=BD=92=E8=BF=98?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pages-preview.yml | 5 +- docs/30-unit-tdd/registry-web.md | 8 +- docs/40-deployment/pull-request-previews.md | 29 +++-- scripts/build_ui_preview.py | 54 +++++++-- scripts/templates/ui-snapshots.html | 52 +++++++++ .../service/static/registry.css | 2 +- .../service/templates/base.html | 25 +---- .../service/templates/catalog-content.html | 67 ----------- .../service/templates/catalog.html | 64 ++++++++++- .../service/templates/detail-content.html | 106 ------------------ .../service/templates/detail.html | 94 +++++++++++++++- .../service/templates/preview.html | 8 -- src/inkcre_extension_registry/service/ui.py | 54 +-------- tasks/registry-web-lovable/packet.md | 18 +-- web/registry.scss | 14 --- 15 files changed, 297 insertions(+), 303 deletions(-) create mode 100644 scripts/templates/ui-snapshots.html delete mode 100644 src/inkcre_extension_registry/service/templates/catalog-content.html delete mode 100644 src/inkcre_extension_registry/service/templates/detail-content.html delete mode 100644 src/inkcre_extension_registry/service/templates/preview.html diff --git a/.github/workflows/pages-preview.yml b/.github/workflows/pages-preview.yml index aaf5260..c75a1c5 100644 --- a/.github/workflows/pages-preview.yml +++ b/.github/workflows/pages-preview.yml @@ -116,13 +116,12 @@ jobs: with: node-version-file: controller/package.json - - name: Build the exact-head Extension-list preview + - name: Build exact-head product page samples working-directory: candidate run: >- pdm run python scripts/build_ui_preview.py --fixture .github/pages-preview/extensions.json --output "$GITHUB_WORKSPACE/.pages-preview/candidate" - --api-origin https://registry.inkcre.dev - name: Prepare the bounded static deployment env: @@ -187,6 +186,6 @@ jobs: echo echo "- Source: \`${{ needs.identity.outputs.head_sha }}\`" echo "- Preview: $PREVIEW_URL" - echo '- Surface: static Extension list only' + echo '- Surface: non-interactive product page samples' echo '- Registry API and production data: unchanged' } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/30-unit-tdd/registry-web.md b/docs/30-unit-tdd/registry-web.md index 16bd6ce..a700a17 100644 --- a/docs/30-unit-tdd/registry-web.md +++ b/docs/30-unit-tdd/registry-web.md @@ -68,7 +68,13 @@ is no Registry copy of the token source or a competing design package. and checks browser JavaScript syntax. Committing generated CSS makes Python wheel and static-preview builds self-contained; it is never edited manually. Wrangler Text rules explicitly include CSS and JavaScript in the Python module filesystem. -The same catalog and detail templates supply the read-only Pages preview. +Rendering depends on product data, selected release, and the current route. +Templates, links, controls, and browser styles contain no preview/production +switch. Instance origin and storage bindings belong to runtime configuration. + +The external page-sample builder calls the same catalog/detail renderers. Its +navigation and static carrier live under `scripts/` and do not enter the Worker +package. The product does not know whether its HTML is used as acceptance evidence. Registry follows the client-web product surface: the 32px geometric InKCre logo, InkHeader spacing and typography, square InkButton-style controls, bordered diff --git a/docs/40-deployment/pull-request-previews.md b/docs/40-deployment/pull-request-previews.md index 88d684b..a411cb5 100644 --- a/docs/40-deployment/pull-request-previews.md +++ b/docs/40-deployment/pull-request-previews.md @@ -1,8 +1,18 @@ # Pull-Request Previews -Pull-request Preview covers the read-only catalog and Extension release details. Registry -APIs, publishing, native Distribution consumption, installation, and runtime -behavior remain local black-box responsibilities. +Pages hosts non-interactive samples of the catalog and Extension detail pages. +The build command invokes the same Python renderers used by the Worker, with +explicit fixture data. It preserves product controls and URLs and embeds the +rendered bodies as inert samples. The external sample selector is evidence UI, +not Registry navigation. The builder namespaces element IDs solely to embed +independent documents in one carrier. + +Full product journeys run on the real Python Worker with isolated D1/R2 state, +as described in [Local Development](local-development.md). Preview limitations +must not introduce environment switches, alternative routes, hidden controls, +or preview renderers into product code. A fully interactive remote preview +would require the same Worker and isolated bindings; static Pages samples do +not establish that capability. Every pull request runs secret-free checks. Fork pull requests receive no remote Preview authority. For an eligible same-repository pull request, the trusted @@ -16,13 +26,12 @@ project `inkcre-extension-registry-ui-preview` on `preview/ext-reg/pr-`. Protected-main code adds source identity and noindex, no-store, CSP, nosniff, and no-referrer policy. The Pages project has no Git provider, custom domain, Functions, Worker, D1, R2, or production token. -The fixture includes explicitly sampled published release descriptors. Catalog -links open details and version links select release views inside that same -HTML document, using URL fragments and CSS without scripts. Both surfaces -reuse the Worker's templates. The header marks the document as Preview; native -artifact links and Publisher controls are omitted because they require the -Registry service. No sample version is sent to a production endpoint. -The existing 64 KiB bound applies to the entire document. +The fixture contains sample published release descriptors. The builder owns its +64 KiB document bound, static navigation and inert behavior under `scripts/`; +the Worker package owns none of them. Controller-added headers disable scripts +and requests in this evidence document. The default-branch controller's legacy +`--api-origin` argument remains accepted during its transition, but it no longer +rewrites product links. Closing an internal PR deploys the checked-in tombstone, verifies the stable alias, and deletes older deployments only for that project and branch. The diff --git a/scripts/build_ui_preview.py b/scripts/build_ui_preview.py index c7bcee1..57d7ab8 100644 --- a/scripts/build_ui_preview.py +++ b/scripts/build_ui_preview.py @@ -2,13 +2,19 @@ import argparse import json +import re from pathlib import Path from typing import Literal +from jinja2 import Environment, FileSystemLoader, select_autoescape from pydantic import BaseModel, ConfigDict, field_validator -from inkcre_extension_registry.contracts.models import ExtensionRecord -from inkcre_extension_registry.service.ui import extension_preview_html +from inkcre_extension_registry.contracts.models import ExtensionRecord, ExtensionSummary +from inkcre_extension_registry.service.ui import ( + STYLES, + extension_catalog_html, + extension_detail_html, +) MAX_DOCUMENT_BYTES = 64 * 1024 @@ -21,7 +27,7 @@ class PreviewFixture(BaseModel): @field_validator("extensions") @classmethod - def extension_names_are_unique( + def releases_match_extensions( cls, extensions: tuple[ExtensionRecord, ...] ) -> tuple[ExtensionRecord, ...]: names = [extension.name for extension in extensions] @@ -39,16 +45,42 @@ def extension_names_are_unique( return extensions -def build_preview(*, fixture_path: Path, output_directory: Path, api_origin: str) -> Path: +def build_preview(*, fixture_path: Path, output_directory: Path) -> Path: fixture = PreviewFixture.model_validate_json(fixture_path.read_bytes()) if output_directory.exists() and any(output_directory.iterdir()): raise ValueError("output directory must be empty") output_directory.mkdir(parents=True, exist_ok=True) - document = extension_preview_html( - fixture.extensions, - api_origin=api_origin, - ).encode() + documents = [ + ( + "Catalog", + extension_catalog_html( + [ + ExtensionSummary(name=item.name, nickname=item.nickname) + for item in fixture.extensions + ] + ), + ) + ] + for extension in fixture.extensions: + detail = extension_detail_html(extension) + assert detail is not None # The fixture requires published releases. + documents.append((extension.nickname, detail)) + + samples = [] + for index, (label, document) in enumerate(documents): + body = document.split("", 1)[1].split("", 1)[0] + # Each inert sample keeps the product's links and controls intact. Only + # IDs are namespaced so independent documents can share the evidence page. + body = re.sub(r' id="([^"]+)"', rf' id="sample-{index}-\1"', body) + samples.append({"label": label, "body": body}) + templates = Environment( + loader=FileSystemLoader(Path(__file__).parent / "templates"), + autoescape=select_autoescape(["html"]), + ) + document = ( + templates.get_template("ui-snapshots.html").render(styles=STYLES, samples=samples).encode() + ) if len(document) > MAX_DOCUMENT_BYTES: raise ValueError(f"preview document exceeds {MAX_DOCUMENT_BYTES} bytes") @@ -59,17 +91,17 @@ def build_preview(*, fixture_path: Path, output_directory: Path, api_origin: str def main() -> None: parser = argparse.ArgumentParser( - description="Build the static Registry catalog and detail PR preview" + description="Build static samples of the Registry product pages" ) parser.add_argument("--fixture", type=Path, required=True) parser.add_argument("--output", type=Path, required=True) - parser.add_argument("--api-origin", required=True) + # Accept the old default-branch controller's argument until it is updated. + parser.add_argument("--api-origin", help=argparse.SUPPRESS) arguments = parser.parse_args() output = build_preview( fixture_path=arguments.fixture, output_directory=arguments.output, - api_origin=arguments.api_origin, ) print( json.dumps( diff --git a/scripts/templates/ui-snapshots.html b/scripts/templates/ui-snapshots.html new file mode 100644 index 0000000..223c7b1 --- /dev/null +++ b/scripts/templates/ui-snapshots.html @@ -0,0 +1,52 @@ + + + + + + + Registry UI snapshots + + + + + + + {% for sample in samples %} + + {% endfor %} + + diff --git a/src/inkcre_extension_registry/service/static/registry.css b/src/inkcre_extension_registry/service/static/registry.css index 9fd9443..42c3c19 100644 --- a/src/inkcre_extension_registry/service/static/registry.css +++ b/src/inkcre_extension_registry/service/static/registry.css @@ -1 +1 @@ -:root{--ref-elevation-raised-low-shadow-type:dropShadow;--ref-elevation-raised-low-radius:2;--ref-elevation-raised-low-color:rgba(12, 21, 48, 0.0784313725);--ref-elevation-raised-low-offset-x:0;--ref-elevation-raised-low-offset-y:1;--ref-elevation-raised-low-spread:0;--ref-elevation-raised-md-shadow-type:dropShadow;--ref-elevation-raised-md-radius:20;--ref-elevation-raised-md-color:rgba(12, 21, 48, 0.1215686275);--ref-elevation-raised-md-offset-x:0;--ref-elevation-raised-md-offset-y:6;--ref-elevation-raised-md-spread:0;--ref-elevation-raised-high-shadow-type:dropShadow;--ref-elevation-raised-high-radius:32;--ref-elevation-raised-high-color:rgba(12, 21, 84, 0.1607843137);--ref-elevation-raised-high-offset-x:0;--ref-elevation-raised-high-offset-y:12;--ref-elevation-raised-high-spread:0;--ref-color-neutral-2:#070707;--ref-color-neutral-6:#131313;--ref-color-neutral-12:#1f1f1f;--ref-color-neutral-20:#303030;--ref-color-neutral-30:#474747;--ref-color-neutral-40:#5e5e5e;--ref-color-neutral-50:#777777;--ref-color-neutral-60:#919191;--ref-color-neutral-70:#ababab;--ref-color-neutral-80:#c6c6c6;--ref-color-neutral-90:#e2e2e2;--ref-color-neutral-94:#eeeeee;--ref-color-neutral-98:#f9f9f9;--ref-color-neutral-100:#ffffff;--ref-color-tertiary-40:#1f9aa5;--ref-color-tertiary-90:#d0f4f4;--ref-color-danger-10:#410008;--ref-color-danger-30:#92001e;--ref-color-danger-35:#a80324;--ref-color-danger-40:#b9192f;--ref-color-danger-60:#ff525c;--ref-color-danger-65:#ff7073;--ref-color-danger-70:#ff8889;--ref-color-danger-80:#ffb3b1;--ref-color-danger-90:#ffdad8;--ref-color-danger-98:#fff8f7;--ref-color-brand-2:#0b1220;--ref-color-brand-6:#121928;--ref-color-brand-12:#1f2635;--ref-color-brand-20:#303747;--ref-color-brand-30:#474e5f;--ref-color-brand-40:#5f6677;--ref-color-brand-50:#787e91;--ref-color-brand-60:#9198ac;--ref-color-brand-70:#acb3c7;--ref-color-brand-80:#b2bccf;--ref-color-brand-90:#c8cee3;--ref-color-brand-94:#f7f7fb;--ref-color-brand-98:#fbfcff;--ref-radius-xs:4px;--ref-radius-sm:8px;--ref-radius-md:16px;--ref-radius-lg:32px;--ref-radius-none:0px;--ref-radius-full:999px;--ref-space-md:16px;--ref-space-lg:32px;--ref-space-xl:56px;--ref-space-xs:4px;--ref-space-sm:8px;--ref-typo-family-sans:system-ui, -apple-system, sans-serif;--ref-typo-family-mono: SF Mono , Monaco, Roboto Mono , monospace;--ref-breakpoint-mobile:768px;--ref-breakpoint-tablet:1024px;--ref-breakpoint-desktop:1280px;--ref-opacity-muted:0.72;--ref-opacity-medium:0.84;--ref-opacity-strong:0.92;--ref-size-lg:48px;--ref-size-md:36px;--ref-size-sm:24px;--ref-size-icon-sm:20px;--ref-size-icon-md:24px;--ref-size-icon-lg:36px;--ref-size-iconfont-sm:8px;--ref-size-iconfont-md:12px;--ref-size-iconfont-lg:18px;--ref-size-xl:58px;--ref-size-xs:18px;--ref-font-label-lg-font-size:14px;--ref-font-label-lg-text-decoration:none;--ref-font-label-lg-font-family:Inter;--ref-font-label-lg-font-weight:400;--ref-font-label-lg-font-style:normal;--ref-font-label-lg-font-stretch:normal;--ref-font-label-lg-letter-spacing:0px;--ref-font-label-lg-line-height:16px;--ref-font-label-lg-paragraph-indent:0px;--ref-font-label-lg-paragraph-spacing:0px;--ref-font-label-lg-text-case:none;--ref-font-label-md-font-size:12px;--ref-font-label-md-text-decoration:none;--ref-font-label-md-font-family:Inter;--ref-font-label-md-font-weight:400;--ref-font-label-md-font-style:normal;--ref-font-label-md-font-stretch:normal;--ref-font-label-md-letter-spacing:0px;--ref-font-label-md-line-height:16px;--ref-font-label-md-paragraph-indent:0px;--ref-font-label-md-paragraph-spacing:0px;--ref-font-label-md-text-case:none;--ref-font-label-sm-font-size:10px;--ref-font-label-sm-text-decoration:none;--ref-font-label-sm-font-family:Roboto;--ref-font-label-sm-font-weight:700;--ref-font-label-sm-font-style:normal;--ref-font-label-sm-font-stretch:normal;--ref-font-label-sm-letter-spacing:0.7px;--ref-font-label-sm-line-height:14px;--ref-font-label-sm-paragraph-indent:0px;--ref-font-label-sm-paragraph-spacing:0px;--ref-font-label-sm-text-case:none;--ref-font-label-lg-underlined-font-size:14px;--ref-font-label-lg-underlined-text-decoration:underline;--ref-font-label-lg-underlined-font-family:Inter;--ref-font-label-lg-underlined-font-weight:400;--ref-font-label-lg-underlined-font-style:normal;--ref-font-label-lg-underlined-font-stretch:normal;--ref-font-label-lg-underlined-letter-spacing:0px;--ref-font-label-lg-underlined-line-height:16px;--ref-font-label-lg-underlined-paragraph-indent:0px;--ref-font-label-lg-underlined-paragraph-spacing:0px;--ref-font-label-lg-underlined-text-case:none;--ref-font-label-lg-mono-font-size:14px;--ref-font-label-lg-mono-text-decoration:none;--ref-font-label-lg-mono-font-family:JetBrains Mono;--ref-font-label-lg-mono-font-weight:400;--ref-font-label-lg-mono-font-style:normal;--ref-font-label-lg-mono-font-stretch:normal;--ref-font-label-lg-mono-letter-spacing:0px;--ref-font-label-lg-mono-line-height:16px;--ref-font-label-lg-mono-paragraph-indent:0px;--ref-font-label-lg-mono-paragraph-spacing:0px;--ref-font-label-lg-mono-text-case:none;--ref-font-label-sm-mono-font-size:10px;--ref-font-label-sm-mono-text-decoration:none;--ref-font-label-sm-mono-font-family:JetBrains Mono;--ref-font-label-sm-mono-font-weight:700;--ref-font-label-sm-mono-font-style:normal;--ref-font-label-sm-mono-font-stretch:normal;--ref-font-label-sm-mono-letter-spacing:0.4px;--ref-font-label-sm-mono-line-height:14px;--ref-font-label-sm-mono-paragraph-indent:0px;--ref-font-label-sm-mono-paragraph-spacing:0px;--ref-font-label-sm-mono-text-case:none;--ref-font-title-sm-font-size:22px;--ref-font-title-sm-text-decoration:none;--ref-font-title-sm-font-family:Inter;--ref-font-title-sm-font-weight:400;--ref-font-title-sm-font-style:normal;--ref-font-title-sm-font-stretch:normal;--ref-font-title-sm-letter-spacing:0px;--ref-font-title-sm-line-height:28px;--ref-font-title-sm-paragraph-indent:0px;--ref-font-title-sm-paragraph-spacing:0px;--ref-font-title-sm-text-case:none;--ref-font-title-sm-mono-font-size:22px;--ref-font-title-sm-mono-text-decoration:none;--ref-font-title-sm-mono-font-family:IBM Plex Mono;--ref-font-title-sm-mono-font-weight:400;--ref-font-title-sm-mono-font-style:normal;--ref-font-title-sm-mono-font-stretch:normal;--ref-font-title-sm-mono-letter-spacing:0.66px;--ref-font-title-sm-mono-line-height:28px;--ref-font-title-sm-mono-paragraph-indent:0px;--ref-font-title-sm-mono-paragraph-spacing:0px;--ref-font-title-sm-mono-text-case:none;--ref-font-body-lg-font-size:18px;--ref-font-body-lg-text-decoration:none;--ref-font-body-lg-font-family:Inter;--ref-font-body-lg-font-weight:400;--ref-font-body-lg-font-style:normal;--ref-font-body-lg-font-stretch:normal;--ref-font-body-lg-letter-spacing:0px;--ref-font-body-lg-line-height:24px;--ref-font-body-lg-paragraph-indent:0px;--ref-font-body-lg-paragraph-spacing:0px;--ref-font-body-lg-text-case:none;--ref-font-body-lg-mono-font-size:18px;--ref-font-body-lg-mono-text-decoration:none;--ref-font-body-lg-mono-font-family:JetBrains Mono;--ref-font-body-lg-mono-font-weight:400;--ref-font-body-lg-mono-font-style:normal;--ref-font-body-lg-mono-font-stretch:normal;--ref-font-body-lg-mono-letter-spacing:0px;--ref-font-body-lg-mono-line-height:24px;--ref-font-body-lg-mono-paragraph-indent:0px;--ref-font-body-lg-mono-paragraph-spacing:0px;--ref-font-body-lg-mono-text-case:none;--ref-font-headline-lg-font-size:36px;--ref-font-headline-lg-text-decoration:none;--ref-font-headline-lg-font-family:Inter;--ref-font-headline-lg-font-weight:400;--ref-font-headline-lg-font-style:normal;--ref-font-headline-lg-font-stretch:normal;--ref-font-headline-lg-letter-spacing:0px;--ref-font-headline-lg-line-height:48px;--ref-font-headline-lg-paragraph-indent:0px;--ref-font-headline-lg-paragraph-spacing:0px;--ref-font-headline-lg-text-case:none;--sys-elevation-raised-low-shadow-type:dropShadow;--sys-elevation-raised-low-radius:2;--sys-elevation-raised-low-color:rgba(12, 21, 48, 0.0784313725);--sys-elevation-raised-low-offset-x:0;--sys-elevation-raised-low-offset-y:1;--sys-elevation-raised-low-spread:0;--sys-elevation-raised-md-shadow-type:dropShadow;--sys-elevation-raised-md-radius:20;--sys-elevation-raised-md-color:rgba(12, 21, 48, 0.1215686275);--sys-elevation-raised-md-offset-x:0;--sys-elevation-raised-md-offset-y:6;--sys-elevation-raised-md-spread:0;--sys-elevation-raised-high-shadow-type:dropShadow;--sys-elevation-raised-high-radius:32;--sys-elevation-raised-high-color:rgba(12, 21, 84, 0.1607843137);--sys-elevation-raised-high-offset-x:0;--sys-elevation-raised-high-offset-y:12;--sys-elevation-raised-high-spread:0;--sys-radius-xs:4px;--sys-radius-sm:8px;--sys-radius-md:16px;--sys-radius-lg:32px;--sys-radius-none:0px;--sys-radius-full:999px;--sys-space-md:16px;--sys-space-lg:32px;--sys-space-xl:56px;--sys-space-xs:4px;--sys-space-sm:8px;--sys-typo-family-sans:system-ui, -apple-system, sans-serif;--sys-typo-family-mono: SF Mono , Monaco, Roboto Mono , monospace;--sys-breakpoint-mobile:768px;--sys-breakpoint-tablet:1024px;--sys-breakpoint-desktop:1280px;--sys-opacity-muted:0.72;--sys-opacity-medium:0.84;--sys-opacity-strong:0.92;--sys-size-lg:48px;--sys-size-md:36px;--sys-size-sm:24px;--sys-size-icon-sm:20px;--sys-size-icon-md:24px;--sys-size-icon-lg:36px;--sys-size-iconfont-sm:8px;--sys-size-iconfont-md:12px;--sys-size-iconfont-lg:18px;--sys-size-xl:58px;--sys-size-xs:18px;--sys-font-label-lg-font-size:14px;--sys-font-label-lg-text-decoration:none;--sys-font-label-lg-font-family:Inter;--sys-font-label-lg-font-weight:400;--sys-font-label-lg-font-style:normal;--sys-font-label-lg-font-stretch:normal;--sys-font-label-lg-letter-spacing:0px;--sys-font-label-lg-line-height:16px;--sys-font-label-lg-paragraph-indent:0px;--sys-font-label-lg-paragraph-spacing:0px;--sys-font-label-lg-text-case:none;--sys-font-label-md-font-size:12px;--sys-font-label-md-text-decoration:none;--sys-font-label-md-font-family:Inter;--sys-font-label-md-font-weight:400;--sys-font-label-md-font-style:normal;--sys-font-label-md-font-stretch:normal;--sys-font-label-md-letter-spacing:0px;--sys-font-label-md-line-height:16px;--sys-font-label-md-paragraph-indent:0px;--sys-font-label-md-paragraph-spacing:0px;--sys-font-label-md-text-case:none;--sys-font-label-sm-font-size:10px;--sys-font-label-sm-text-decoration:none;--sys-font-label-sm-font-family:Roboto;--sys-font-label-sm-font-weight:700;--sys-font-label-sm-font-style:normal;--sys-font-label-sm-font-stretch:normal;--sys-font-label-sm-letter-spacing:0.7px;--sys-font-label-sm-line-height:14px;--sys-font-label-sm-paragraph-indent:0px;--sys-font-label-sm-paragraph-spacing:0px;--sys-font-label-sm-text-case:none;--sys-font-label-lg-underlined-font-size:14px;--sys-font-label-lg-underlined-text-decoration:underline;--sys-font-label-lg-underlined-font-family:Inter;--sys-font-label-lg-underlined-font-weight:400;--sys-font-label-lg-underlined-font-style:normal;--sys-font-label-lg-underlined-font-stretch:normal;--sys-font-label-lg-underlined-letter-spacing:0px;--sys-font-label-lg-underlined-line-height:16px;--sys-font-label-lg-underlined-paragraph-indent:0px;--sys-font-label-lg-underlined-paragraph-spacing:0px;--sys-font-label-lg-underlined-text-case:none;--sys-font-label-lg-mono-font-size:14px;--sys-font-label-lg-mono-text-decoration:none;--sys-font-label-lg-mono-font-family:JetBrains Mono;--sys-font-label-lg-mono-font-weight:400;--sys-font-label-lg-mono-font-style:normal;--sys-font-label-lg-mono-font-stretch:normal;--sys-font-label-lg-mono-letter-spacing:0px;--sys-font-label-lg-mono-line-height:16px;--sys-font-label-lg-mono-paragraph-indent:0px;--sys-font-label-lg-mono-paragraph-spacing:0px;--sys-font-label-lg-mono-text-case:none;--sys-font-label-sm-mono-font-size:10px;--sys-font-label-sm-mono-text-decoration:none;--sys-font-label-sm-mono-font-family:JetBrains Mono;--sys-font-label-sm-mono-font-weight:700;--sys-font-label-sm-mono-font-style:normal;--sys-font-label-sm-mono-font-stretch:normal;--sys-font-label-sm-mono-letter-spacing:0.4px;--sys-font-label-sm-mono-line-height:14px;--sys-font-label-sm-mono-paragraph-indent:0px;--sys-font-label-sm-mono-paragraph-spacing:0px;--sys-font-label-sm-mono-text-case:none;--sys-font-title-sm-font-size:22px;--sys-font-title-sm-text-decoration:none;--sys-font-title-sm-font-family:Inter;--sys-font-title-sm-font-weight:400;--sys-font-title-sm-font-style:normal;--sys-font-title-sm-font-stretch:normal;--sys-font-title-sm-letter-spacing:0px;--sys-font-title-sm-line-height:28px;--sys-font-title-sm-paragraph-indent:0px;--sys-font-title-sm-paragraph-spacing:0px;--sys-font-title-sm-text-case:none;--sys-font-title-sm-mono-font-size:22px;--sys-font-title-sm-mono-text-decoration:none;--sys-font-title-sm-mono-font-family:IBM Plex Mono;--sys-font-title-sm-mono-font-weight:400;--sys-font-title-sm-mono-font-style:normal;--sys-font-title-sm-mono-font-stretch:normal;--sys-font-title-sm-mono-letter-spacing:0.66px;--sys-font-title-sm-mono-line-height:28px;--sys-font-title-sm-mono-paragraph-indent:0px;--sys-font-title-sm-mono-paragraph-spacing:0px;--sys-font-title-sm-mono-text-case:none;--sys-font-body-lg-font-size:18px;--sys-font-body-lg-text-decoration:none;--sys-font-body-lg-font-family:Inter;--sys-font-body-lg-font-weight:400;--sys-font-body-lg-font-style:normal;--sys-font-body-lg-font-stretch:normal;--sys-font-body-lg-letter-spacing:0px;--sys-font-body-lg-line-height:24px;--sys-font-body-lg-paragraph-indent:0px;--sys-font-body-lg-paragraph-spacing:0px;--sys-font-body-lg-text-case:none;--sys-font-body-lg-mono-font-size:18px;--sys-font-body-lg-mono-text-decoration:none;--sys-font-body-lg-mono-font-family:JetBrains Mono;--sys-font-body-lg-mono-font-weight:400;--sys-font-body-lg-mono-font-style:normal;--sys-font-body-lg-mono-font-stretch:normal;--sys-font-body-lg-mono-letter-spacing:0px;--sys-font-body-lg-mono-line-height:24px;--sys-font-body-lg-mono-paragraph-indent:0px;--sys-font-body-lg-mono-paragraph-spacing:0px;--sys-font-body-lg-mono-text-case:none;--sys-font-headline-lg-font-size:36px;--sys-font-headline-lg-text-decoration:none;--sys-font-headline-lg-font-family:Inter;--sys-font-headline-lg-font-weight:400;--sys-font-headline-lg-font-style:normal;--sys-font-headline-lg-font-stretch:normal;--sys-font-headline-lg-letter-spacing:0px;--sys-font-headline-lg-line-height:48px;--sys-font-headline-lg-paragraph-indent:0px;--sys-font-headline-lg-paragraph-spacing:0px;--sys-font-headline-lg-text-case:none}:root,html[data-theme=light]{color-scheme:light;--sys-color-text-base:#070707;--sys-color-text-muted:#919191;--sys-color-text-danger-on:#fff8f7;--sys-color-text-primary:#fbfcff;--sys-color-text-subtle:#5e5e5e;--sys-color-surface-base:#ffffff;--sys-color-surface-muted:#e2e2e2;--sys-color-surface-subtle:#f9f9f9;--sys-color-surface-primary:#121928;--sys-color-surface-primary-hover:#474e5f;--sys-color-surface-subtle-hover:#eeeeee;--sys-color-surface-danger:#a80324;--sys-color-surface-danger-hover:#ff7073;--sys-color-surface-base-hover:#eeeeee;--sys-color-border-base:#303030;--sys-color-border-strong:#070707;--sys-color-border-subtle:#c6c6c6;--sys-color-border-muted:#919191;--sys-color-feedback-error:#b9192f;--comp-switch-track-bg:#070707;--comp-switch-handle-bg:#f9f9f9;--comp-switch-label-color:#070707}@media(prefers-color-scheme: dark){:root:not([data-theme]),html[data-theme=dark]{color-scheme:dark;--sys-color-text-base:#f9f9f9;--sys-color-text-muted:#f9f9f9;--sys-color-text-danger-on:#fff8f7;--sys-color-text-primary:#0b1220;--sys-color-text-subtle:#c6c6c6;--sys-color-surface-base:#474747;--sys-color-surface-muted:#131313;--sys-color-surface-subtle:#1f1f1f;--sys-color-surface-primary:#c8cee3;--sys-color-surface-primary-hover:#9198ac;--sys-color-surface-subtle-hover:#5e5e5e;--sys-color-surface-danger:#b9192f;--sys-color-surface-danger-hover:#b9192f;--sys-color-surface-base-hover:#474747;--sys-color-border-base:#5e5e5e;--sys-color-border-strong:#c6c6c6;--sys-color-border-subtle:#5e5e5e;--sys-color-border-muted:#5e5e5e;--sys-color-feedback-error:#ff525c;--comp-switch-track-bg:#f9f9f9;--comp-switch-handle-bg:#070707;--comp-switch-label-color:#f9f9f9}}html[data-theme=dark]{color-scheme:dark;--sys-color-text-base:#f9f9f9;--sys-color-text-muted:#f9f9f9;--sys-color-text-danger-on:#fff8f7;--sys-color-text-primary:#0b1220;--sys-color-text-subtle:#c6c6c6;--sys-color-surface-base:#474747;--sys-color-surface-muted:#131313;--sys-color-surface-subtle:#1f1f1f;--sys-color-surface-primary:#c8cee3;--sys-color-surface-primary-hover:#9198ac;--sys-color-surface-subtle-hover:#5e5e5e;--sys-color-surface-danger:#b9192f;--sys-color-surface-danger-hover:#b9192f;--sys-color-surface-base-hover:#474747;--sys-color-border-base:#5e5e5e;--sys-color-border-strong:#c6c6c6;--sys-color-border-subtle:#5e5e5e;--sys-color-border-muted:#5e5e5e;--sys-color-feedback-error:#ff525c}*{box-sizing:border-box}body{margin:0;font-family:var(--ref-typo-family-sans);font-size:14px;line-height:1.5;color:var(--sys-color-text-base);background:var(--sys-color-surface-base)}[hidden]{display:none !important}a{color:inherit;text-underline-offset:3px}button,input,select,textarea{font:inherit}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid var(--sys-color-border-strong);outline-offset:3px}h1,h2,h3,p{margin:0}h1{font-size:var(--sys-font-title-sm-font-size);font-weight:var(--sys-font-title-sm-font-weight);line-height:var(--sys-font-title-sm-line-height);letter-spacing:var(--sys-font-title-sm-letter-spacing)}h2,h3{font-size:var(--sys-font-body-lg-font-size);font-weight:var(--sys-font-body-lg-font-weight);line-height:var(--sys-font-body-lg-line-height);letter-spacing:var(--sys-font-body-lg-letter-spacing)}code,.extension-name{font-family:var(--ref-typo-family-mono);overflow-wrap:anywhere}.sr-only,.skip-link:not(:focus){position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.skip-link:focus{display:block;padding:8px}.site-header{display:flex;align-items:center;justify-content:space-between;gap:var(--sys-space-md);padding:var(--sys-space-md) var(--sys-space-lg);background:var(--sys-color-surface-subtle);border-bottom:1px solid var(--sys-color-border-base)}.site-header nav{display:flex;align-items:center;gap:var(--sys-space-md)}.site-header nav a{text-decoration:none}.site-header [aria-current=page]{text-decoration:underline}.brand{display:flex;align-items:center;gap:var(--sys-space-sm);text-decoration:none;font-size:var(--sys-font-title-sm-font-size);font-weight:var(--sys-font-title-sm-font-weight);line-height:var(--sys-font-title-sm-line-height);letter-spacing:var(--sys-font-title-sm-letter-spacing);font-family:var(--ref-typo-family-mono)}.brand svg,.brand>span:first-child{display:block;width:32px;height:32px}.preview-label,.count,.extension-name,.field-help{color:var(--sys-color-text-subtle);font-size:12px}main{padding:var(--sys-space-lg) var(--sys-space-xl)}.section-heading,.detail-heading,.workspace-heading,.workspace-toolbar,.dialog-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--sys-space-md);margin-bottom:var(--sys-space-md)}.count{margin-left:var(--sys-space-sm);font-weight:400}.button,.icon-button{display:inline-flex;align-items:center;justify-content:center;gap:var(--sys-space-sm);padding:var(--sys-space-sm) var(--sys-space-md);border:1px solid var(--sys-color-border-base);border-radius:0;background:var(--sys-color-surface-subtle);color:var(--sys-color-text-base);text-decoration:none;cursor:pointer;font-size:var(--sys-font-label-lg-font-size);font-weight:var(--sys-font-label-lg-font-weight);line-height:var(--sys-font-label-lg-line-height);letter-spacing:var(--sys-font-label-lg-letter-spacing)}.button:hover,.icon-button:hover{background:var(--sys-color-surface-subtle-hover)}.button:disabled,.icon-button:disabled{opacity:.5;cursor:not-allowed}.icon-button{width:32px;height:32px;padding:0;border-color:rgba(0,0,0,0)}.button-primary{background:var(--sys-color-surface-primary);color:var(--sys-color-text-primary)}.button-primary:hover{background:var(--sys-color-surface-primary-hover)}input,select,textarea{width:100%;min-width:0;padding:var(--sys-space-sm);border:1px solid var(--sys-color-border-subtle);border-radius:0;background:var(--sys-color-surface-base);color:var(--sys-color-text-base)}input,select{min-height:36px}.search-form{display:flex;gap:var(--sys-space-sm);margin-bottom:var(--sys-space-md)}.search-form input{flex:1}.search-form select{width:auto;max-width:240px}.search-summary{margin-bottom:var(--sys-space-md)}.catalog-list,.version-list{list-style:none;margin:0;padding:0}.catalog-list{display:grid;gap:var(--sys-space-md)}.extension-card{display:flex;align-items:center;justify-content:space-between;gap:var(--sys-space-md);border:1px solid var(--sys-color-border-base);padding:var(--sys-space-md);text-decoration:none}.extension-card h2{font-size:var(--sys-font-label-lg-font-size);font-weight:var(--sys-font-label-lg-font-weight);line-height:var(--sys-font-label-lg-line-height);letter-spacing:var(--sys-font-label-lg-letter-spacing);margin-bottom:var(--sys-space-sm)}.extension-card:hover{background:var(--sys-color-surface-subtle)}.card-action{flex-shrink:0;font-size:12px}.back-link{display:inline-block;margin-bottom:var(--sys-space-lg)}.detail-heading{margin-bottom:var(--sys-space-lg)}.detail-heading h1{margin-bottom:var(--sys-space-sm)}.detail-layout{display:grid;grid-template-columns:minmax(0, 1fr) 280px;gap:var(--sys-space-lg)}.distribution-panel,.sidebar-panel,.release-row,.connect-card{border:1px solid var(--sys-color-border-base);padding:var(--sys-space-md);margin-bottom:var(--sys-space-md)}.metadata{margin:var(--sys-space-md) 0}.metadata>div{display:grid;grid-template-columns:120px minmax(0, 1fr);gap:var(--sys-space-md);margin-bottom:var(--sys-space-sm)}.metadata dt{color:var(--sys-color-text-subtle)}.metadata dd{margin:0;overflow-wrap:anywhere}.version-list{margin-top:var(--sys-space-md)}.version-list a{display:flex;justify-content:space-between;gap:var(--sys-space-sm);padding:var(--sys-space-sm);text-decoration:none}.version-list [aria-current],.version-list a:hover{background:var(--sys-color-surface-subtle-hover)}.version-list span{font-size:12px}.publisher-meta>div{grid-template-columns:auto 1fr}.connect-layout{max-width:480px}label{display:block;margin-bottom:var(--sys-space-sm)}form>.button{margin-top:var(--sys-space-md)}.search-form>.button{margin-top:0}.workspace-toolbar>div,.release-actions,.release-pagination{display:flex;flex-wrap:wrap;align-items:center;gap:var(--sys-space-sm)}.release-row{display:flex;align-items:center;justify-content:space-between;gap:var(--sys-space-md)}.release-row h3,.release-row p{margin-bottom:var(--sys-space-sm);overflow-wrap:anywhere}.release-row p{color:var(--sys-color-text-subtle);font-size:12px}.badge{display:inline-block;margin-bottom:var(--sys-space-sm);padding:0 var(--sys-space-xs);border:1px solid var(--sys-color-border-subtle);font-size:12px}.release-pagination{margin-bottom:var(--sys-space-md)}.form-status:not(:empty),.empty-state,noscript p{margin:var(--sys-space-md) 0}.error{color:var(--sys-color-feedback-error)}dialog{width:min(560px,100% - 32px);max-height:calc(100% - 32px);padding:var(--sys-space-lg);border:1px solid var(--sys-color-border-base);background:var(--sys-color-surface-base);color:var(--sys-color-text-base)}dialog::backdrop{background:rgba(0,0,0,.3)}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--sys-space-md);margin-top:var(--sys-space-md)}.full-width{grid-column:1/-1}.field-help{margin-top:var(--sys-space-xs)}.toast:not(:empty){position:fixed;bottom:var(--sys-space-md);right:var(--sys-space-md);max-width:calc(100% - 32px);padding:var(--sys-space-sm) var(--sys-space-md);border:1px solid var(--sys-color-border-base);background:var(--sys-color-surface-subtle)}.preview-detail{display:none}.preview-detail:target{display:block}main:has(>.preview-detail:target)>#catalog{display:none}@media(max-width: 768px){.site-header,main{padding:var(--sys-space-md)}.site-header nav{gap:var(--sys-space-sm)}.brand{font-size:18px}.search-form{flex-wrap:wrap}.search-form input{flex-basis:100%}.search-form select{flex:1;max-width:none}.detail-layout,.form-grid{grid-template-columns:minmax(0, 1fr)}.detail-heading,.workspace-toolbar,.release-row{align-items:flex-start;flex-wrap:wrap}dialog{padding:var(--sys-space-md)}.metadata>div{grid-template-columns:100px minmax(0, 1fr);gap:var(--sys-space-sm)}} +:root{--ref-elevation-raised-low-shadow-type:dropShadow;--ref-elevation-raised-low-radius:2;--ref-elevation-raised-low-color:rgba(12, 21, 48, 0.0784313725);--ref-elevation-raised-low-offset-x:0;--ref-elevation-raised-low-offset-y:1;--ref-elevation-raised-low-spread:0;--ref-elevation-raised-md-shadow-type:dropShadow;--ref-elevation-raised-md-radius:20;--ref-elevation-raised-md-color:rgba(12, 21, 48, 0.1215686275);--ref-elevation-raised-md-offset-x:0;--ref-elevation-raised-md-offset-y:6;--ref-elevation-raised-md-spread:0;--ref-elevation-raised-high-shadow-type:dropShadow;--ref-elevation-raised-high-radius:32;--ref-elevation-raised-high-color:rgba(12, 21, 84, 0.1607843137);--ref-elevation-raised-high-offset-x:0;--ref-elevation-raised-high-offset-y:12;--ref-elevation-raised-high-spread:0;--ref-color-neutral-2:#070707;--ref-color-neutral-6:#131313;--ref-color-neutral-12:#1f1f1f;--ref-color-neutral-20:#303030;--ref-color-neutral-30:#474747;--ref-color-neutral-40:#5e5e5e;--ref-color-neutral-50:#777777;--ref-color-neutral-60:#919191;--ref-color-neutral-70:#ababab;--ref-color-neutral-80:#c6c6c6;--ref-color-neutral-90:#e2e2e2;--ref-color-neutral-94:#eeeeee;--ref-color-neutral-98:#f9f9f9;--ref-color-neutral-100:#ffffff;--ref-color-tertiary-40:#1f9aa5;--ref-color-tertiary-90:#d0f4f4;--ref-color-danger-10:#410008;--ref-color-danger-30:#92001e;--ref-color-danger-35:#a80324;--ref-color-danger-40:#b9192f;--ref-color-danger-60:#ff525c;--ref-color-danger-65:#ff7073;--ref-color-danger-70:#ff8889;--ref-color-danger-80:#ffb3b1;--ref-color-danger-90:#ffdad8;--ref-color-danger-98:#fff8f7;--ref-color-brand-2:#0b1220;--ref-color-brand-6:#121928;--ref-color-brand-12:#1f2635;--ref-color-brand-20:#303747;--ref-color-brand-30:#474e5f;--ref-color-brand-40:#5f6677;--ref-color-brand-50:#787e91;--ref-color-brand-60:#9198ac;--ref-color-brand-70:#acb3c7;--ref-color-brand-80:#b2bccf;--ref-color-brand-90:#c8cee3;--ref-color-brand-94:#f7f7fb;--ref-color-brand-98:#fbfcff;--ref-radius-xs:4px;--ref-radius-sm:8px;--ref-radius-md:16px;--ref-radius-lg:32px;--ref-radius-none:0px;--ref-radius-full:999px;--ref-space-md:16px;--ref-space-lg:32px;--ref-space-xl:56px;--ref-space-xs:4px;--ref-space-sm:8px;--ref-typo-family-sans:system-ui, -apple-system, sans-serif;--ref-typo-family-mono: SF Mono , Monaco, Roboto Mono , monospace;--ref-breakpoint-mobile:768px;--ref-breakpoint-tablet:1024px;--ref-breakpoint-desktop:1280px;--ref-opacity-muted:0.72;--ref-opacity-medium:0.84;--ref-opacity-strong:0.92;--ref-size-lg:48px;--ref-size-md:36px;--ref-size-sm:24px;--ref-size-icon-sm:20px;--ref-size-icon-md:24px;--ref-size-icon-lg:36px;--ref-size-iconfont-sm:8px;--ref-size-iconfont-md:12px;--ref-size-iconfont-lg:18px;--ref-size-xl:58px;--ref-size-xs:18px;--ref-font-label-lg-font-size:14px;--ref-font-label-lg-text-decoration:none;--ref-font-label-lg-font-family:Inter;--ref-font-label-lg-font-weight:400;--ref-font-label-lg-font-style:normal;--ref-font-label-lg-font-stretch:normal;--ref-font-label-lg-letter-spacing:0px;--ref-font-label-lg-line-height:16px;--ref-font-label-lg-paragraph-indent:0px;--ref-font-label-lg-paragraph-spacing:0px;--ref-font-label-lg-text-case:none;--ref-font-label-md-font-size:12px;--ref-font-label-md-text-decoration:none;--ref-font-label-md-font-family:Inter;--ref-font-label-md-font-weight:400;--ref-font-label-md-font-style:normal;--ref-font-label-md-font-stretch:normal;--ref-font-label-md-letter-spacing:0px;--ref-font-label-md-line-height:16px;--ref-font-label-md-paragraph-indent:0px;--ref-font-label-md-paragraph-spacing:0px;--ref-font-label-md-text-case:none;--ref-font-label-sm-font-size:10px;--ref-font-label-sm-text-decoration:none;--ref-font-label-sm-font-family:Roboto;--ref-font-label-sm-font-weight:700;--ref-font-label-sm-font-style:normal;--ref-font-label-sm-font-stretch:normal;--ref-font-label-sm-letter-spacing:0.7px;--ref-font-label-sm-line-height:14px;--ref-font-label-sm-paragraph-indent:0px;--ref-font-label-sm-paragraph-spacing:0px;--ref-font-label-sm-text-case:none;--ref-font-label-lg-underlined-font-size:14px;--ref-font-label-lg-underlined-text-decoration:underline;--ref-font-label-lg-underlined-font-family:Inter;--ref-font-label-lg-underlined-font-weight:400;--ref-font-label-lg-underlined-font-style:normal;--ref-font-label-lg-underlined-font-stretch:normal;--ref-font-label-lg-underlined-letter-spacing:0px;--ref-font-label-lg-underlined-line-height:16px;--ref-font-label-lg-underlined-paragraph-indent:0px;--ref-font-label-lg-underlined-paragraph-spacing:0px;--ref-font-label-lg-underlined-text-case:none;--ref-font-label-lg-mono-font-size:14px;--ref-font-label-lg-mono-text-decoration:none;--ref-font-label-lg-mono-font-family:JetBrains Mono;--ref-font-label-lg-mono-font-weight:400;--ref-font-label-lg-mono-font-style:normal;--ref-font-label-lg-mono-font-stretch:normal;--ref-font-label-lg-mono-letter-spacing:0px;--ref-font-label-lg-mono-line-height:16px;--ref-font-label-lg-mono-paragraph-indent:0px;--ref-font-label-lg-mono-paragraph-spacing:0px;--ref-font-label-lg-mono-text-case:none;--ref-font-label-sm-mono-font-size:10px;--ref-font-label-sm-mono-text-decoration:none;--ref-font-label-sm-mono-font-family:JetBrains Mono;--ref-font-label-sm-mono-font-weight:700;--ref-font-label-sm-mono-font-style:normal;--ref-font-label-sm-mono-font-stretch:normal;--ref-font-label-sm-mono-letter-spacing:0.4px;--ref-font-label-sm-mono-line-height:14px;--ref-font-label-sm-mono-paragraph-indent:0px;--ref-font-label-sm-mono-paragraph-spacing:0px;--ref-font-label-sm-mono-text-case:none;--ref-font-title-sm-font-size:22px;--ref-font-title-sm-text-decoration:none;--ref-font-title-sm-font-family:Inter;--ref-font-title-sm-font-weight:400;--ref-font-title-sm-font-style:normal;--ref-font-title-sm-font-stretch:normal;--ref-font-title-sm-letter-spacing:0px;--ref-font-title-sm-line-height:28px;--ref-font-title-sm-paragraph-indent:0px;--ref-font-title-sm-paragraph-spacing:0px;--ref-font-title-sm-text-case:none;--ref-font-title-sm-mono-font-size:22px;--ref-font-title-sm-mono-text-decoration:none;--ref-font-title-sm-mono-font-family:IBM Plex Mono;--ref-font-title-sm-mono-font-weight:400;--ref-font-title-sm-mono-font-style:normal;--ref-font-title-sm-mono-font-stretch:normal;--ref-font-title-sm-mono-letter-spacing:0.66px;--ref-font-title-sm-mono-line-height:28px;--ref-font-title-sm-mono-paragraph-indent:0px;--ref-font-title-sm-mono-paragraph-spacing:0px;--ref-font-title-sm-mono-text-case:none;--ref-font-body-lg-font-size:18px;--ref-font-body-lg-text-decoration:none;--ref-font-body-lg-font-family:Inter;--ref-font-body-lg-font-weight:400;--ref-font-body-lg-font-style:normal;--ref-font-body-lg-font-stretch:normal;--ref-font-body-lg-letter-spacing:0px;--ref-font-body-lg-line-height:24px;--ref-font-body-lg-paragraph-indent:0px;--ref-font-body-lg-paragraph-spacing:0px;--ref-font-body-lg-text-case:none;--ref-font-body-lg-mono-font-size:18px;--ref-font-body-lg-mono-text-decoration:none;--ref-font-body-lg-mono-font-family:JetBrains Mono;--ref-font-body-lg-mono-font-weight:400;--ref-font-body-lg-mono-font-style:normal;--ref-font-body-lg-mono-font-stretch:normal;--ref-font-body-lg-mono-letter-spacing:0px;--ref-font-body-lg-mono-line-height:24px;--ref-font-body-lg-mono-paragraph-indent:0px;--ref-font-body-lg-mono-paragraph-spacing:0px;--ref-font-body-lg-mono-text-case:none;--ref-font-headline-lg-font-size:36px;--ref-font-headline-lg-text-decoration:none;--ref-font-headline-lg-font-family:Inter;--ref-font-headline-lg-font-weight:400;--ref-font-headline-lg-font-style:normal;--ref-font-headline-lg-font-stretch:normal;--ref-font-headline-lg-letter-spacing:0px;--ref-font-headline-lg-line-height:48px;--ref-font-headline-lg-paragraph-indent:0px;--ref-font-headline-lg-paragraph-spacing:0px;--ref-font-headline-lg-text-case:none;--sys-elevation-raised-low-shadow-type:dropShadow;--sys-elevation-raised-low-radius:2;--sys-elevation-raised-low-color:rgba(12, 21, 48, 0.0784313725);--sys-elevation-raised-low-offset-x:0;--sys-elevation-raised-low-offset-y:1;--sys-elevation-raised-low-spread:0;--sys-elevation-raised-md-shadow-type:dropShadow;--sys-elevation-raised-md-radius:20;--sys-elevation-raised-md-color:rgba(12, 21, 48, 0.1215686275);--sys-elevation-raised-md-offset-x:0;--sys-elevation-raised-md-offset-y:6;--sys-elevation-raised-md-spread:0;--sys-elevation-raised-high-shadow-type:dropShadow;--sys-elevation-raised-high-radius:32;--sys-elevation-raised-high-color:rgba(12, 21, 84, 0.1607843137);--sys-elevation-raised-high-offset-x:0;--sys-elevation-raised-high-offset-y:12;--sys-elevation-raised-high-spread:0;--sys-radius-xs:4px;--sys-radius-sm:8px;--sys-radius-md:16px;--sys-radius-lg:32px;--sys-radius-none:0px;--sys-radius-full:999px;--sys-space-md:16px;--sys-space-lg:32px;--sys-space-xl:56px;--sys-space-xs:4px;--sys-space-sm:8px;--sys-typo-family-sans:system-ui, -apple-system, sans-serif;--sys-typo-family-mono: SF Mono , Monaco, Roboto Mono , monospace;--sys-breakpoint-mobile:768px;--sys-breakpoint-tablet:1024px;--sys-breakpoint-desktop:1280px;--sys-opacity-muted:0.72;--sys-opacity-medium:0.84;--sys-opacity-strong:0.92;--sys-size-lg:48px;--sys-size-md:36px;--sys-size-sm:24px;--sys-size-icon-sm:20px;--sys-size-icon-md:24px;--sys-size-icon-lg:36px;--sys-size-iconfont-sm:8px;--sys-size-iconfont-md:12px;--sys-size-iconfont-lg:18px;--sys-size-xl:58px;--sys-size-xs:18px;--sys-font-label-lg-font-size:14px;--sys-font-label-lg-text-decoration:none;--sys-font-label-lg-font-family:Inter;--sys-font-label-lg-font-weight:400;--sys-font-label-lg-font-style:normal;--sys-font-label-lg-font-stretch:normal;--sys-font-label-lg-letter-spacing:0px;--sys-font-label-lg-line-height:16px;--sys-font-label-lg-paragraph-indent:0px;--sys-font-label-lg-paragraph-spacing:0px;--sys-font-label-lg-text-case:none;--sys-font-label-md-font-size:12px;--sys-font-label-md-text-decoration:none;--sys-font-label-md-font-family:Inter;--sys-font-label-md-font-weight:400;--sys-font-label-md-font-style:normal;--sys-font-label-md-font-stretch:normal;--sys-font-label-md-letter-spacing:0px;--sys-font-label-md-line-height:16px;--sys-font-label-md-paragraph-indent:0px;--sys-font-label-md-paragraph-spacing:0px;--sys-font-label-md-text-case:none;--sys-font-label-sm-font-size:10px;--sys-font-label-sm-text-decoration:none;--sys-font-label-sm-font-family:Roboto;--sys-font-label-sm-font-weight:700;--sys-font-label-sm-font-style:normal;--sys-font-label-sm-font-stretch:normal;--sys-font-label-sm-letter-spacing:0.7px;--sys-font-label-sm-line-height:14px;--sys-font-label-sm-paragraph-indent:0px;--sys-font-label-sm-paragraph-spacing:0px;--sys-font-label-sm-text-case:none;--sys-font-label-lg-underlined-font-size:14px;--sys-font-label-lg-underlined-text-decoration:underline;--sys-font-label-lg-underlined-font-family:Inter;--sys-font-label-lg-underlined-font-weight:400;--sys-font-label-lg-underlined-font-style:normal;--sys-font-label-lg-underlined-font-stretch:normal;--sys-font-label-lg-underlined-letter-spacing:0px;--sys-font-label-lg-underlined-line-height:16px;--sys-font-label-lg-underlined-paragraph-indent:0px;--sys-font-label-lg-underlined-paragraph-spacing:0px;--sys-font-label-lg-underlined-text-case:none;--sys-font-label-lg-mono-font-size:14px;--sys-font-label-lg-mono-text-decoration:none;--sys-font-label-lg-mono-font-family:JetBrains Mono;--sys-font-label-lg-mono-font-weight:400;--sys-font-label-lg-mono-font-style:normal;--sys-font-label-lg-mono-font-stretch:normal;--sys-font-label-lg-mono-letter-spacing:0px;--sys-font-label-lg-mono-line-height:16px;--sys-font-label-lg-mono-paragraph-indent:0px;--sys-font-label-lg-mono-paragraph-spacing:0px;--sys-font-label-lg-mono-text-case:none;--sys-font-label-sm-mono-font-size:10px;--sys-font-label-sm-mono-text-decoration:none;--sys-font-label-sm-mono-font-family:JetBrains Mono;--sys-font-label-sm-mono-font-weight:700;--sys-font-label-sm-mono-font-style:normal;--sys-font-label-sm-mono-font-stretch:normal;--sys-font-label-sm-mono-letter-spacing:0.4px;--sys-font-label-sm-mono-line-height:14px;--sys-font-label-sm-mono-paragraph-indent:0px;--sys-font-label-sm-mono-paragraph-spacing:0px;--sys-font-label-sm-mono-text-case:none;--sys-font-title-sm-font-size:22px;--sys-font-title-sm-text-decoration:none;--sys-font-title-sm-font-family:Inter;--sys-font-title-sm-font-weight:400;--sys-font-title-sm-font-style:normal;--sys-font-title-sm-font-stretch:normal;--sys-font-title-sm-letter-spacing:0px;--sys-font-title-sm-line-height:28px;--sys-font-title-sm-paragraph-indent:0px;--sys-font-title-sm-paragraph-spacing:0px;--sys-font-title-sm-text-case:none;--sys-font-title-sm-mono-font-size:22px;--sys-font-title-sm-mono-text-decoration:none;--sys-font-title-sm-mono-font-family:IBM Plex Mono;--sys-font-title-sm-mono-font-weight:400;--sys-font-title-sm-mono-font-style:normal;--sys-font-title-sm-mono-font-stretch:normal;--sys-font-title-sm-mono-letter-spacing:0.66px;--sys-font-title-sm-mono-line-height:28px;--sys-font-title-sm-mono-paragraph-indent:0px;--sys-font-title-sm-mono-paragraph-spacing:0px;--sys-font-title-sm-mono-text-case:none;--sys-font-body-lg-font-size:18px;--sys-font-body-lg-text-decoration:none;--sys-font-body-lg-font-family:Inter;--sys-font-body-lg-font-weight:400;--sys-font-body-lg-font-style:normal;--sys-font-body-lg-font-stretch:normal;--sys-font-body-lg-letter-spacing:0px;--sys-font-body-lg-line-height:24px;--sys-font-body-lg-paragraph-indent:0px;--sys-font-body-lg-paragraph-spacing:0px;--sys-font-body-lg-text-case:none;--sys-font-body-lg-mono-font-size:18px;--sys-font-body-lg-mono-text-decoration:none;--sys-font-body-lg-mono-font-family:JetBrains Mono;--sys-font-body-lg-mono-font-weight:400;--sys-font-body-lg-mono-font-style:normal;--sys-font-body-lg-mono-font-stretch:normal;--sys-font-body-lg-mono-letter-spacing:0px;--sys-font-body-lg-mono-line-height:24px;--sys-font-body-lg-mono-paragraph-indent:0px;--sys-font-body-lg-mono-paragraph-spacing:0px;--sys-font-body-lg-mono-text-case:none;--sys-font-headline-lg-font-size:36px;--sys-font-headline-lg-text-decoration:none;--sys-font-headline-lg-font-family:Inter;--sys-font-headline-lg-font-weight:400;--sys-font-headline-lg-font-style:normal;--sys-font-headline-lg-font-stretch:normal;--sys-font-headline-lg-letter-spacing:0px;--sys-font-headline-lg-line-height:48px;--sys-font-headline-lg-paragraph-indent:0px;--sys-font-headline-lg-paragraph-spacing:0px;--sys-font-headline-lg-text-case:none}:root,html[data-theme=light]{color-scheme:light;--sys-color-text-base:#070707;--sys-color-text-muted:#919191;--sys-color-text-danger-on:#fff8f7;--sys-color-text-primary:#fbfcff;--sys-color-text-subtle:#5e5e5e;--sys-color-surface-base:#ffffff;--sys-color-surface-muted:#e2e2e2;--sys-color-surface-subtle:#f9f9f9;--sys-color-surface-primary:#121928;--sys-color-surface-primary-hover:#474e5f;--sys-color-surface-subtle-hover:#eeeeee;--sys-color-surface-danger:#a80324;--sys-color-surface-danger-hover:#ff7073;--sys-color-surface-base-hover:#eeeeee;--sys-color-border-base:#303030;--sys-color-border-strong:#070707;--sys-color-border-subtle:#c6c6c6;--sys-color-border-muted:#919191;--sys-color-feedback-error:#b9192f;--comp-switch-track-bg:#070707;--comp-switch-handle-bg:#f9f9f9;--comp-switch-label-color:#070707}@media(prefers-color-scheme: dark){:root:not([data-theme]),html[data-theme=dark]{color-scheme:dark;--sys-color-text-base:#f9f9f9;--sys-color-text-muted:#f9f9f9;--sys-color-text-danger-on:#fff8f7;--sys-color-text-primary:#0b1220;--sys-color-text-subtle:#c6c6c6;--sys-color-surface-base:#474747;--sys-color-surface-muted:#131313;--sys-color-surface-subtle:#1f1f1f;--sys-color-surface-primary:#c8cee3;--sys-color-surface-primary-hover:#9198ac;--sys-color-surface-subtle-hover:#5e5e5e;--sys-color-surface-danger:#b9192f;--sys-color-surface-danger-hover:#b9192f;--sys-color-surface-base-hover:#474747;--sys-color-border-base:#5e5e5e;--sys-color-border-strong:#c6c6c6;--sys-color-border-subtle:#5e5e5e;--sys-color-border-muted:#5e5e5e;--sys-color-feedback-error:#ff525c;--comp-switch-track-bg:#f9f9f9;--comp-switch-handle-bg:#070707;--comp-switch-label-color:#f9f9f9}}html[data-theme=dark]{color-scheme:dark;--sys-color-text-base:#f9f9f9;--sys-color-text-muted:#f9f9f9;--sys-color-text-danger-on:#fff8f7;--sys-color-text-primary:#0b1220;--sys-color-text-subtle:#c6c6c6;--sys-color-surface-base:#474747;--sys-color-surface-muted:#131313;--sys-color-surface-subtle:#1f1f1f;--sys-color-surface-primary:#c8cee3;--sys-color-surface-primary-hover:#9198ac;--sys-color-surface-subtle-hover:#5e5e5e;--sys-color-surface-danger:#b9192f;--sys-color-surface-danger-hover:#b9192f;--sys-color-surface-base-hover:#474747;--sys-color-border-base:#5e5e5e;--sys-color-border-strong:#c6c6c6;--sys-color-border-subtle:#5e5e5e;--sys-color-border-muted:#5e5e5e;--sys-color-feedback-error:#ff525c}*{box-sizing:border-box}body{margin:0;font-family:var(--ref-typo-family-sans);font-size:14px;line-height:1.5;color:var(--sys-color-text-base);background:var(--sys-color-surface-base)}[hidden]{display:none !important}a{color:inherit;text-underline-offset:3px}button,input,select,textarea{font:inherit}button:focus-visible,a:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid var(--sys-color-border-strong);outline-offset:3px}h1,h2,h3,p{margin:0}h1{font-size:var(--sys-font-title-sm-font-size);font-weight:var(--sys-font-title-sm-font-weight);line-height:var(--sys-font-title-sm-line-height);letter-spacing:var(--sys-font-title-sm-letter-spacing)}h2,h3{font-size:var(--sys-font-body-lg-font-size);font-weight:var(--sys-font-body-lg-font-weight);line-height:var(--sys-font-body-lg-line-height);letter-spacing:var(--sys-font-body-lg-letter-spacing)}code,.extension-name{font-family:var(--ref-typo-family-mono);overflow-wrap:anywhere}.sr-only,.skip-link:not(:focus){position:absolute;width:1px;height:1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap}.skip-link:focus{display:block;padding:8px}.site-header{display:flex;align-items:center;justify-content:space-between;gap:var(--sys-space-md);padding:var(--sys-space-md) var(--sys-space-lg);background:var(--sys-color-surface-subtle);border-bottom:1px solid var(--sys-color-border-base)}.site-header nav{display:flex;align-items:center;gap:var(--sys-space-md)}.site-header nav a{text-decoration:none}.site-header [aria-current=page]{text-decoration:underline}.brand{display:flex;align-items:center;gap:var(--sys-space-sm);text-decoration:none;font-size:var(--sys-font-title-sm-font-size);font-weight:var(--sys-font-title-sm-font-weight);line-height:var(--sys-font-title-sm-line-height);letter-spacing:var(--sys-font-title-sm-letter-spacing);font-family:var(--ref-typo-family-mono)}.brand svg,.brand>span:first-child{display:block;width:32px;height:32px}.count,.extension-name,.field-help{color:var(--sys-color-text-subtle);font-size:12px}main{padding:var(--sys-space-lg) var(--sys-space-xl)}.section-heading,.detail-heading,.workspace-heading,.workspace-toolbar,.dialog-heading{display:flex;align-items:center;justify-content:space-between;gap:var(--sys-space-md);margin-bottom:var(--sys-space-md)}.count{margin-left:var(--sys-space-sm);font-weight:400}.button,.icon-button{display:inline-flex;align-items:center;justify-content:center;gap:var(--sys-space-sm);padding:var(--sys-space-sm) var(--sys-space-md);border:1px solid var(--sys-color-border-base);border-radius:0;background:var(--sys-color-surface-subtle);color:var(--sys-color-text-base);text-decoration:none;cursor:pointer;font-size:var(--sys-font-label-lg-font-size);font-weight:var(--sys-font-label-lg-font-weight);line-height:var(--sys-font-label-lg-line-height);letter-spacing:var(--sys-font-label-lg-letter-spacing)}.button:hover,.icon-button:hover{background:var(--sys-color-surface-subtle-hover)}.button:disabled,.icon-button:disabled{opacity:.5;cursor:not-allowed}.icon-button{width:32px;height:32px;padding:0;border-color:rgba(0,0,0,0)}.button-primary{background:var(--sys-color-surface-primary);color:var(--sys-color-text-primary)}.button-primary:hover{background:var(--sys-color-surface-primary-hover)}input,select,textarea{width:100%;min-width:0;padding:var(--sys-space-sm);border:1px solid var(--sys-color-border-subtle);border-radius:0;background:var(--sys-color-surface-base);color:var(--sys-color-text-base)}input,select{min-height:36px}.search-form{display:flex;gap:var(--sys-space-sm);margin-bottom:var(--sys-space-md)}.search-form input{flex:1}.search-form select{width:auto;max-width:240px}.search-summary{margin-bottom:var(--sys-space-md)}.catalog-list,.version-list{list-style:none;margin:0;padding:0}.catalog-list{display:grid;gap:var(--sys-space-md)}.extension-card{display:flex;align-items:center;justify-content:space-between;gap:var(--sys-space-md);border:1px solid var(--sys-color-border-base);padding:var(--sys-space-md);text-decoration:none}.extension-card h2{font-size:var(--sys-font-label-lg-font-size);font-weight:var(--sys-font-label-lg-font-weight);line-height:var(--sys-font-label-lg-line-height);letter-spacing:var(--sys-font-label-lg-letter-spacing);margin-bottom:var(--sys-space-sm)}.extension-card:hover{background:var(--sys-color-surface-subtle)}.card-action{flex-shrink:0;font-size:12px}.back-link{display:inline-block;margin-bottom:var(--sys-space-lg)}.detail-heading{margin-bottom:var(--sys-space-lg)}.detail-heading h1{margin-bottom:var(--sys-space-sm)}.detail-layout{display:grid;grid-template-columns:minmax(0, 1fr) 280px;gap:var(--sys-space-lg)}.distribution-panel,.sidebar-panel,.release-row,.connect-card{border:1px solid var(--sys-color-border-base);padding:var(--sys-space-md);margin-bottom:var(--sys-space-md)}.metadata{margin:var(--sys-space-md) 0}.metadata>div{display:grid;grid-template-columns:120px minmax(0, 1fr);gap:var(--sys-space-md);margin-bottom:var(--sys-space-sm)}.metadata dt{color:var(--sys-color-text-subtle)}.metadata dd{margin:0;overflow-wrap:anywhere}.version-list{margin-top:var(--sys-space-md)}.version-list a{display:flex;justify-content:space-between;gap:var(--sys-space-sm);padding:var(--sys-space-sm);text-decoration:none}.version-list [aria-current],.version-list a:hover{background:var(--sys-color-surface-subtle-hover)}.version-list span{font-size:12px}.publisher-meta>div{grid-template-columns:auto 1fr}.connect-layout{max-width:480px}label{display:block;margin-bottom:var(--sys-space-sm)}form>.button{margin-top:var(--sys-space-md)}.search-form>.button{margin-top:0}.workspace-toolbar>div,.release-actions,.release-pagination{display:flex;flex-wrap:wrap;align-items:center;gap:var(--sys-space-sm)}.release-row{display:flex;align-items:center;justify-content:space-between;gap:var(--sys-space-md)}.release-row h3,.release-row p{margin-bottom:var(--sys-space-sm);overflow-wrap:anywhere}.release-row p{color:var(--sys-color-text-subtle);font-size:12px}.badge{display:inline-block;margin-bottom:var(--sys-space-sm);padding:0 var(--sys-space-xs);border:1px solid var(--sys-color-border-subtle);font-size:12px}.release-pagination{margin-bottom:var(--sys-space-md)}.form-status:not(:empty),.empty-state,noscript p{margin:var(--sys-space-md) 0}.error{color:var(--sys-color-feedback-error)}dialog{width:min(560px,100% - 32px);max-height:calc(100% - 32px);padding:var(--sys-space-lg);border:1px solid var(--sys-color-border-base);background:var(--sys-color-surface-base);color:var(--sys-color-text-base)}dialog::backdrop{background:rgba(0,0,0,.3)}.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:var(--sys-space-md);margin-top:var(--sys-space-md)}.full-width{grid-column:1/-1}.field-help{margin-top:var(--sys-space-xs)}.toast:not(:empty){position:fixed;bottom:var(--sys-space-md);right:var(--sys-space-md);max-width:calc(100% - 32px);padding:var(--sys-space-sm) var(--sys-space-md);border:1px solid var(--sys-color-border-base);background:var(--sys-color-surface-subtle)}@media(max-width: 768px){.site-header,main{padding:var(--sys-space-md)}.site-header nav{gap:var(--sys-space-sm)}.brand{font-size:18px}.search-form{flex-wrap:wrap}.search-form input{flex-basis:100%}.search-form select{flex:1;max-width:none}.detail-layout,.form-grid{grid-template-columns:minmax(0, 1fr)}.detail-heading,.workspace-toolbar,.release-row{align-items:flex-start;flex-wrap:wrap}dialog{padding:var(--sys-space-md)}.metadata>div{grid-template-columns:100px minmax(0, 1fr);gap:var(--sys-space-sm)}} diff --git a/src/inkcre_extension_registry/service/templates/base.html b/src/inkcre_extension_registry/service/templates/base.html index 3760260..fbbdd34 100644 --- a/src/inkcre_extension_registry/service/templates/base.html +++ b/src/inkcre_extension_registry/service/templates/base.html @@ -4,47 +4,28 @@ - {% if preview or noindex %} + {% if noindex %} {% endif %} {{ title }} · InKCre Registry - {% if not preview %} - {% endif %}
    {% block content %}{% endblock %}
    diff --git a/src/inkcre_extension_registry/service/templates/catalog-content.html b/src/inkcre_extension_registry/service/templates/catalog-content.html deleted file mode 100644 index 7c73c0d..0000000 --- a/src/inkcre_extension_registry/service/templates/catalog-content.html +++ /dev/null @@ -1,67 +0,0 @@ -
    -
    -

    - Extensions - {{ extensions | length }} -

    - {% if not preview %} - Publish - {% endif %} -
    - {% if not preview %} - - {% if query or namespace %} -

    Clear filters

    - {% endif %} {% endif %} {% if extensions %} - - {% else %} -

    - {% if total %}No matching extensions.{% else %}No published - extensions.{% endif %} -

    - {% endif %} -
    diff --git a/src/inkcre_extension_registry/service/templates/catalog.html b/src/inkcre_extension_registry/service/templates/catalog.html index 5b6fcaf..bf13488 100644 --- a/src/inkcre_extension_registry/service/templates/catalog.html +++ b/src/inkcre_extension_registry/service/templates/catalog.html @@ -1 +1,63 @@ -{% extends "base.html" %} {% block content %}{% include "catalog-content.html" %}{% endblock %} +{% extends "base.html" %} {% block content %} +
    +
    +

    + Extensions + {{ extensions | length }} +

    + Publish +
    + + {% if query or namespace %} +

    Clear filters

    + {% endif %} {% if extensions %} + + {% else %} +

    + {% if total %}No matching extensions.{% else %}No published + extensions.{% endif %} +

    + {% endif %} +
    +{% endblock %} diff --git a/src/inkcre_extension_registry/service/templates/detail-content.html b/src/inkcre_extension_registry/service/templates/detail-content.html deleted file mode 100644 index 8e6aa2c..0000000 --- a/src/inkcre_extension_registry/service/templates/detail-content.html +++ /dev/null @@ -1,106 +0,0 @@ -
    - - ← Extensions - -
    -
    -

    {{ extension.nickname }}

    -

    {{ extension.name }}

    -
    - {% if not preview %} - - {% endif %} -
    -
    -
    -
    -

    Distributions

    - {{ release.version }} -
    - {% if release.python %} -
    -

    Python

    - - {% if not preview %} - Package files ↗ - {% endif %} -
    - {% endif %} {% if release.module_federation %} -
    -

    Web

    - - {% if not preview %} - Manifest ↗ - {% endif %} -
    - {% endif %} -
    - -
    -
    diff --git a/src/inkcre_extension_registry/service/templates/detail.html b/src/inkcre_extension_registry/service/templates/detail.html index ee5ca15..1377731 100644 --- a/src/inkcre_extension_registry/service/templates/detail.html +++ b/src/inkcre_extension_registry/service/templates/detail.html @@ -1 +1,93 @@ -{% extends "base.html" %} {% block content %}{% include "detail-content.html" %}{% endblock %} +{% extends "base.html" %} {% block content %} +
    + ← Extensions +
    +
    +

    {{ extension.nickname }}

    +

    {{ extension.name }}

    +
    + +
    +
    +
    +
    +

    Distributions

    + {{ release.version }} +
    + {% if release.python %} +
    +

    Python

    + + Package files ↗ +
    + {% endif %} {% if release.module_federation %} +
    +

    Web

    + + Manifest ↗ +
    + {% endif %} +
    + +
    +
    +{% endblock %} diff --git a/src/inkcre_extension_registry/service/templates/preview.html b/src/inkcre_extension_registry/service/templates/preview.html deleted file mode 100644 index f746422..0000000 --- a/src/inkcre_extension_registry/service/templates/preview.html +++ /dev/null @@ -1,8 +0,0 @@ -{% extends "base.html" %} {% block content %} {% for detail in details %} -
    - {% with extension=detail.extension, release=detail.release, releases=detail.releases, publisher=detail.publisher %} {% include "detail-content.html" %} {% endwith %} -
    -{% endfor %} {% include "catalog-content.html" %} {% endblock %} diff --git a/src/inkcre_extension_registry/service/ui.py b/src/inkcre_extension_registry/service/ui.py index 99f4f74..99dbeac 100644 --- a/src/inkcre_extension_registry/service/ui.py +++ b/src/inkcre_extension_registry/service/ui.py @@ -2,7 +2,6 @@ from collections.abc import Sequence from pathlib import Path -from urllib.parse import urlsplit from fastapi.responses import HTMLResponse from jinja2 import Environment, FileSystemLoader, select_autoescape @@ -19,21 +18,6 @@ SCRIPT = (ROOT / "static" / "registry.js").read_text() -def _canonical_api_origin(value: str) -> str: - parsed = urlsplit(value) - if ( - parsed.scheme != "https" - or not parsed.hostname - or parsed.username is not None - or parsed.password is not None - or parsed.path - or parsed.query - or parsed.fragment - ): - raise ValueError("api_origin must be a canonical absolute HTTPS origin") - return value - - def page(template: str, **context: object) -> str: return TEMPLATES.get_template(template).render(styles=STYLES, **context) @@ -80,9 +64,7 @@ def extension_catalog_html( ) -def _detail_context( - extension: ExtensionRecord, version: str | None = None -) -> dict[str, object] | None: +def extension_detail_html(extension: ExtensionRecord, version: str | None = None) -> str | None: releases = sorted(extension.releases, key=lambda item: Version(item.version), reverse=True) if not releases: return None @@ -95,38 +77,12 @@ def _detail_context( release = next( (item for item in releases if not Version(item.version).prerelease), releases[0] ) - return dict( + return page( + "detail.html", + title=extension.nickname, + active="catalog", extension=extension, release=release, releases=releases, publisher=extension.name.split("/")[0], ) - - -def extension_detail_html(extension: ExtensionRecord, version: str | None = None) -> str | None: - context = _detail_context(extension, version) - if context is None: - return None - return page("detail.html", title=extension.nickname, active="catalog", **context) - - -def extension_preview_html(extensions: Sequence[ExtensionRecord], *, api_origin: str) -> str: - """Render the catalog and sample release views within the bounded Pages document.""" - _canonical_api_origin(api_origin) - details = [] - for extension in extensions: - default = _detail_context(extension) - if default is None: - raise ValueError("preview extensions require a release") - details.append({**default, "default": True}) - for release in extension.releases: - details.append({**default, "release": release, "default": False}) - return page( - "preview.html", - title="Registry preview", - active="catalog", - preview=True, - extensions=extensions, - details=details, - total=len(extensions), - ) diff --git a/tasks/registry-web-lovable/packet.md b/tasks/registry-web-lovable/packet.md index 1ac452d..01899c4 100644 --- a/tasks/registry-web-lovable/packet.md +++ b/tasks/registry-web-lovable/packet.md @@ -9,21 +9,21 @@ Sir 要求将 Registry Web 提升为可喜爱、可持续维护的产品,允 ## 当前纠正与边界 -Sir 指出上一版偏离产品风格、增加过多 hero/tips/notices 且 logo 错误,预览看不到详情。此前 minimum 判断作废,以 `client-web` 的实际产品语言为准。 +Sir 已认可修正后的产品视觉,但指出 preview/production 信息反过来塑造了产品实现。此判断已在模板、链接、样式和运行时预览函数中证实。 -- 参照 `client-web/apps/client-web/src/App.vue`、extensionCard、design InkHeader/InkButton:32px 官方几何 logo、普通字号、直角边框、紧凑列表、白色工作区。删除宣传 hero、装饰图、假 logo、重复提示和页脚 CTA。 -- 变更对象:Registry 模板/CSS 和预览生成器。From → To:营销目录 → 简洁产品界面;只读目录/API 跳转 → 同一文档内的目录、详情、版本切换与返回。 -- 继续消费 design 已发布 Sass 公共接口,保留 Python/Jinja 与单 Worker;logo 精确来自 client-web 的 public/logo/32.svg。发布 API、原生上传协议、D1/R2 语义不变。 -- Pages 的现有默认分支 controller 只交付 index.html 且禁止脚本。预览复用生产详情模板和显式样例 release,通过锚点和 CSS 显示当前详情;保持 64 KiB 文档上限,无新增预览服务、权限或部署流程。页头用 Preview 标识样例数据;不向线上 API 导航假版本。 -- 本轮不编辑 client-web、design、shared 文档或生产资源。沿用已授权的同一 PR 提交/推送。 +- 变更对象:Registry renderer、页面模板/CSS、独立静态证据生成器。From → To:产品内的环境开关/锚点导航/隐藏控件 → 同一套产品页面与原生 URL;静态样张在 scripts 层直接调用这些 renderer。 +- 删除 `extension_preview_html`、产品 `preview.html`、预览专用 CSS 与仅为预览抽出的模板分层。目录、详情、复制、搜索、Publisher 和下载链接均不再因环境不同而改变。 +- Pages 降为明确标注的不可交互页面样张:在产品外选择样张,样张内部保留原始控件和 URL。脚本负责 inert 容器、ID 命名空间、样例数据与体积上限;不进入 Worker 包。完整交互验收继续使用同一个真实 Python Worker 和隔离本地状态。 +- 既有 Pages controller 仍可交付本 PR;更新后的 workflow 移除无效 api-origin 参数并正确描述证据范围,旧默认分支参数兼容仅留在 CLI 过渡层。 +- 保留已认可的 client-web 视觉与真实 logo;发布 API、原生上传、数据库结构、Python 单 Worker 部署不变。无新增远程 Worker、权限、生产变更或 shared 编辑。 ## 验收与状态 -修正已完成,完整 `pnpm check` 通过(含 Worker dry-run 的 506 个模块与全部新模板)。浏览器验证目录→详情→版本→返回、实际 Worker 搜索→Python 详情、复制 ID、深浅色、发布者连接/新建表单/Escape/断开;390px 页面无横向溢出,控制台无错误。 +产品环境分支已删除;完整 `pnpm check` 通过,Worker dry-run 包含 503 个模块,wheel 中不含证据模板/生成器。实际 Worker 已验证搜索→详情→精确版本及 Publisher 入口,控制台无错误。独立样张保留正常产品 URL、具备唯一的元素 ID、inert 容器和独立键盘导航,生成体积约 52 KiB。 -最终静态预览保留 64 KiB 上限;已检查所有锚点存在且唯一、不包含脚本。logo 与 client-web 源 SVG 比对一致。最终提交的 CI、预览交付状态和 source_sha 以 PR checks / preview.json 为准。 +最终 CI 与 Pages 身份以 PR checks / preview.json 为准。本机真实 Worker 可在 `http://127.0.0.1:8791` 交互查看;其状态来自本任务隔离的本地 D1/R2,不是远程生产数据。 -上一轮的 Web/Python 发布、撤回恢复、私有命名空间分页和隔离验证仍是后端证据;不作为本轮视觉验收依据。 +既往 Web/Python 发布、撤回恢复、私有分页及命名空间隔离证据保留;静态样张不再被描述为交互验收。 ## 交付 diff --git a/web/registry.scss b/web/registry.scss index 0ff0f00..fc5359d 100644 --- a/web/registry.scss +++ b/web/registry.scss @@ -126,7 +126,6 @@ code, } } -.preview-label, .count, .extension-name, .field-help { @@ -445,19 +444,6 @@ dialog { background: var(--sys-color-surface-subtle); } -// The existing Pages controller delivers one HTML file with no script permission. -.preview-detail { - display: none; - - &:target { - display: block; - } -} - -main:has(> .preview-detail:target) > #catalog { - display: none; -} - @media (max-width: 768px) { .site-header, main { From 41c05854a56986eb75774412dca9f4a967cbd8b8 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Sun, 6 Sep 2026 23:03:38 +0800 Subject: [PATCH 06/16] =?UTF-8?q?feat(preview):=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=E9=9A=94=E7=A6=BB=20Python=20Worker=20=E6=8F=90=E4=BE=9B?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=20Registry=20=E9=A2=84=E8=A7=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/pages-preview-closed/_headers | 6 - .github/pages-preview-closed/index.html | 45 --- .github/pages-preview/_headers | 6 - .github/pages-preview/extensions.json | 163 ----------- .github/preview/retire.js | 19 ++ .github/preview/wrangler.json | 14 + .github/workflows/pages-preview-cleanup.yml | 165 ----------- .github/workflows/pages-preview.yml | 191 ------------- .../workflows/registry-preview-cleanup.yml | 100 +++++++ .github/workflows/registry-preview.yml | 204 +++++++++++++ docs/30-unit-tdd/registry-web.md | 8 +- docs/40-deployment/pull-request-previews.md | 119 +++++--- scripts/build_ui_preview.py | 116 -------- scripts/registry_preview.py | 270 ++++++++++++++++++ scripts/templates/ui-snapshots.html | 52 ---- tasks/registry-web-lovable/packet.md | 48 ++-- 16 files changed, 719 insertions(+), 807 deletions(-) delete mode 100644 .github/pages-preview-closed/_headers delete mode 100644 .github/pages-preview-closed/index.html delete mode 100644 .github/pages-preview/_headers delete mode 100644 .github/pages-preview/extensions.json create mode 100644 .github/preview/retire.js create mode 100644 .github/preview/wrangler.json delete mode 100644 .github/workflows/pages-preview-cleanup.yml delete mode 100644 .github/workflows/pages-preview.yml create mode 100644 .github/workflows/registry-preview-cleanup.yml create mode 100644 .github/workflows/registry-preview.yml delete mode 100644 scripts/build_ui_preview.py create mode 100644 scripts/registry_preview.py delete mode 100644 scripts/templates/ui-snapshots.html diff --git a/.github/pages-preview-closed/_headers b/.github/pages-preview-closed/_headers deleted file mode 100644 index 4d77190..0000000 --- a/.github/pages-preview-closed/_headers +++ /dev/null @@ -1,6 +0,0 @@ -/* - X-Robots-Tag: noindex, nofollow - Cache-Control: no-store - Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none' - Referrer-Policy: no-referrer - X-Content-Type-Options: nosniff diff --git a/.github/pages-preview-closed/index.html b/.github/pages-preview-closed/index.html deleted file mode 100644 index 00f666c..0000000 --- a/.github/pages-preview-closed/index.html +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - Preview closed · InKCre Extension Registry - - - -
    -

    Preview closed

    -

    This pull-request preview is no longer active.

    -
    - - diff --git a/.github/pages-preview/_headers b/.github/pages-preview/_headers deleted file mode 100644 index 4d77190..0000000 --- a/.github/pages-preview/_headers +++ /dev/null @@ -1,6 +0,0 @@ -/* - X-Robots-Tag: noindex, nofollow - Cache-Control: no-store - Content-Security-Policy: default-src 'none'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none' - Referrer-Policy: no-referrer - X-Content-Type-Options: nosniff diff --git a/.github/pages-preview/extensions.json b/.github/pages-preview/extensions.json deleted file mode 100644 index fa78cc1..0000000 --- a/.github/pages-preview/extensions.json +++ /dev/null @@ -1,163 +0,0 @@ -{ - "schema_version": 1, - "extensions": [ - { - "name": "inkcre/github", - "nickname": "GitHub", - "releases": [ - { - "name": "inkcre/github", - "nickname": "GitHub", - "version": "1.1.0", - "state": "published", - "python": { - "project": "inkcre-ext-github", - "simple_url": "https://registry.example/simple/inkcre-ext-github/", - "host_sdk": "core-py", - "host_sdk_version": "^0.1.0", - "entry_point": { - "group": "inkcre.extensions", - "name": "github", - "object": "inkcre_ext_github:extension" - } - }, - "module_federation": { - "manifest_url": "https://registry.example/extensions/inkcre/github/1.1.0/mf-manifest.json", - "host_sdk": "@inkcre/core", - "host_sdk_version": "^0.1.2" - } - }, - { - "name": "inkcre/github", - "nickname": "GitHub", - "version": "1.0.0", - "state": "published", - "python": { - "project": "inkcre-ext-github", - "simple_url": "https://registry.example/simple/inkcre-ext-github/", - "host_sdk": "core-py", - "host_sdk_version": "^0.1.0", - "entry_point": { - "group": "inkcre.extensions", - "name": "github", - "object": "inkcre_ext_github:extension" - } - }, - "module_federation": { - "manifest_url": "https://registry.example/extensions/inkcre/github/1.0.0/mf-manifest.json", - "host_sdk": "@inkcre/core", - "host_sdk_version": "^0.1.2" - } - } - ] - }, - { - "name": "inkcre/learn-english", - "nickname": "Learn English", - "releases": [ - { - "name": "inkcre/learn-english", - "nickname": "Learn English", - "version": "1.0.0", - "state": "published", - "module_federation": { - "manifest_url": "https://registry.example/extensions/inkcre/learn-english/1.0.0/mf-manifest.json", - "host_sdk": "@inkcre/core", - "host_sdk_version": "^0.1.2" - } - } - ] - }, - { - "name": "inkcre/mail", - "nickname": "Mail", - "releases": [ - { - "name": "inkcre/mail", - "nickname": "Mail", - "version": "1.0.0", - "state": "published", - "python": { - "project": "inkcre-ext-mail", - "simple_url": "https://registry.example/simple/inkcre-ext-mail/", - "host_sdk": "core-py", - "host_sdk_version": "^0.1.0", - "entry_point": { - "group": "inkcre.extensions", - "name": "mail", - "object": "inkcre_ext_mail:extension" - } - }, - "module_federation": { - "manifest_url": "https://registry.example/extensions/inkcre/mail/1.0.0/mf-manifest.json", - "host_sdk": "@inkcre/core", - "host_sdk_version": "^0.1.2" - } - } - ] - }, - { - "name": "inkcre/rss", - "nickname": "RSS/Atom Feeds", - "releases": [ - { - "name": "inkcre/rss", - "nickname": "RSS/Atom Feeds", - "version": "1.0.0", - "state": "published", - "python": { - "project": "inkcre-ext-rss", - "simple_url": "https://registry.example/simple/inkcre-ext-rss/", - "host_sdk": "core-py", - "host_sdk_version": "^0.1.0", - "entry_point": { - "group": "inkcre.extensions", - "name": "rss", - "object": "inkcre_ext_rss:extension" - } - } - } - ] - }, - { - "name": "inkcre/telegram", - "nickname": "Telegram", - "releases": [ - { - "name": "inkcre/telegram", - "nickname": "Telegram", - "version": "1.0.0", - "state": "published", - "python": { - "project": "inkcre-ext-telegram", - "simple_url": "https://registry.example/simple/inkcre-ext-telegram/", - "host_sdk": "core-py", - "host_sdk_version": "^0.1.0", - "entry_point": { - "group": "inkcre.extensions", - "name": "telegram", - "object": "inkcre_ext_telegram:extension" - } - } - } - ] - }, - { - "name": "inkcre/twitter", - "nickname": "Twitter", - "releases": [ - { - "name": "inkcre/twitter", - "nickname": "Twitter", - "version": "1.0.0", - "state": "published", - "module_federation": { - "manifest_url": "https://registry.example/extensions/inkcre/twitter/1.0.0/mf-manifest.json", - "host_sdk": "@inkcre/core", - "host_sdk_version": "^0.1.2" - } - } - ] - } - ] -} diff --git a/.github/preview/retire.js b/.github/preview/retire.js new file mode 100644 index 0000000..ba12c9e --- /dev/null +++ b/.github/preview/retire.js @@ -0,0 +1,19 @@ +// Temporarily replaces the closed PR's Worker. Never bundled with Registry. +export default { + async fetch(request, env) { + if ( + request.method !== 'POST' || + request.headers.get('Authorization') !== `Bearer ${env.RETIRE_TOKEN}` + ) { + return new Response('This pull-request preview is no longer active.', { + status: 410, + headers: { 'Cache-Control': 'no-store' }, + }) + } + const batch = await env.ARTIFACTS.list({ limit: 1000 }) + if (batch.objects.length) { + await env.ARTIFACTS.delete(batch.objects.map((object) => object.key)) + } + return Response.json({ deleted: batch.objects.length }) + }, +} diff --git a/.github/preview/wrangler.json b/.github/preview/wrangler.json new file mode 100644 index 0000000..0864401 --- /dev/null +++ b/.github/preview/wrangler.json @@ -0,0 +1,14 @@ +{ + "compatibility_date": "2026-07-28", + "compatibility_flags": ["python_workers"], + "workers_dev": true, + "preview_urls": false, + "observability": { "enabled": true }, + "rules": [ + { + "type": "Text", + "globs": ["**/service/static/registry.css", "**/service/static/registry.js"], + "fallthrough": true + } + ] +} diff --git a/.github/workflows/pages-preview-cleanup.yml b/.github/workflows/pages-preview-cleanup.yml deleted file mode 100644 index 296e970..0000000 --- a/.github/workflows/pages-preview-cleanup.yml +++ /dev/null @@ -1,165 +0,0 @@ -name: Pages preview cleanup - -on: - pull_request_target: - branches: - - main - types: - - closed - workflow_dispatch: - inputs: - pull_number: - description: Closed pull request whose preview should be retired - required: true - type: number - -permissions: - contents: read - pull-requests: read - -jobs: - cleanup: - name: ext-reg preview cleanup - if: >- - github.event_name == 'workflow_dispatch' || - (github.event.pull_request.base.ref == 'main' && - github.event.pull_request.base.repo.full_name == github.repository && - github.event.pull_request.head.repo.full_name == github.repository) - concurrency: - group: pages-preview-ext-reg-${{ github.event.pull_request.number || inputs.pull_number }} - cancel-in-progress: false - permissions: - contents: read - deployments: write - pull-requests: read - runs-on: ubuntu-latest - timeout-minutes: 15 - environment: - name: preview - url: ${{ steps.tombstone.outputs.pages-deployment-alias-url }} - steps: - - name: Resolve the closed pull request - id: identity - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - env: - REQUESTED_PULL_NUMBER: ${{ github.event.pull_request.number || inputs.pull_number }} - with: - script: | - const pullNumber = Number(process.env.REQUESTED_PULL_NUMBER) - if (!Number.isInteger(pullNumber) || pullNumber < 1) { - core.setFailed('A positive pull-request number is required.') - return - } - const { data: pull } = await github.rest.pulls.get({ - owner: context.repo.owner, - repo: context.repo.repo, - pull_number: pullNumber, - }) - if ( - pull.state !== 'closed' || - pull.base.ref !== 'main' || - pull.base.repo.full_name !== `${context.repo.owner}/${context.repo.repo}` || - pull.head.repo?.full_name !== `${context.repo.owner}/${context.repo.repo}` - ) { - core.setFailed('Cleanup only accepts a closed same-repository pull request targeting main.') - return - } - core.setOutput('branch', `preview/ext-reg/pr-${pullNumber}`) - - - name: Checkout the trusted cleanup controller - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.workflow_sha }} - path: controller - persist-credentials: false - - - name: Install pnpm - uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 - with: - version: 11.11.0 - - - name: Set up Node - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version-file: controller/package.json - cache: pnpm - cache-dependency-path: controller/pnpm-lock.yaml - - - name: Install frozen Wrangler - run: pnpm --dir controller install --frozen-lockfile - - - name: Replace the stable alias with the trusted tombstone - id: tombstone - uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 - with: - apiToken: ${{ secrets.CLOUDFLARE_PAGES_API_TOKEN }} - accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - packageManager: npm - wranglerVersion: 4.120.0 - workingDirectory: controller/.github/pages-preview-closed - command: >- - pages deploy . - --project-name=${{ vars.REGISTRY_UI_PREVIEW_PAGES_PROJECT }} - --branch=${{ steps.identity.outputs.branch }} - --commit-hash=${{ github.workflow_sha }} - --commit-dirty=false - - - name: Verify the closed Preview alias - env: - PREVIEW_URL: ${{ steps.tombstone.outputs.pages-deployment-alias-url }} - run: | - test -n "$PREVIEW_URL" - verified=0 - for attempt in {1..12}; do - if curl --fail --silent --show-error \ - --connect-timeout 5 \ - --max-time 10 \ - --dump-header .pages-preview-closed-headers \ - --output .pages-preview-closed-body \ - "$PREVIEW_URL/" && - grep -Fq 'This pull-request preview is no longer active.' \ - .pages-preview-closed-body && - grep -iq '^x-robots-tag: noindex, nofollow' \ - .pages-preview-closed-headers && - grep -iq '^cache-control: no-store' \ - .pages-preview-closed-headers; then - verified=1 - break - fi - sleep 2 - done - test "$verified" -eq 1 - - - name: Delete older exact-branch deployments - env: - CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PAGES_API_TOKEN }} - KEEP_DEPLOYMENT_ID: ${{ steps.tombstone.outputs.pages-deployment-id }} - PAGES_BRANCH: ${{ steps.identity.outputs.branch }} - PAGES_PROJECT: ${{ vars.REGISTRY_UI_PREVIEW_PAGES_PROJECT }} - run: | - test -n "$CLOUDFLARE_ACCOUNT_ID" - test -n "$CLOUDFLARE_API_TOKEN" - test -n "$KEEP_DEPLOYMENT_ID" - test -n "$PAGES_PROJECT" - pnpm --dir controller exec wrangler pages deployment list \ - --project-name "$PAGES_PROJECT" \ - --environment preview \ - --json > .pages-preview-deployments.json - jq -e \ - --arg branch "$PAGES_BRANCH" \ - --arg keep "$KEEP_DEPLOYMENT_ID" \ - 'any(.[]; .Branch == $branch and .Id == $keep)' \ - .pages-preview-deployments.json - jq -r \ - --arg branch "$PAGES_BRANCH" \ - --arg keep "$KEEP_DEPLOYMENT_ID" \ - '.[] | select(.Branch == $branch and .Id != $keep) | .Id' \ - .pages-preview-deployments.json | \ - while IFS= read -r deployment_id; do - test -n "$deployment_id" - pnpm --dir controller exec wrangler pages deployment delete \ - "$deployment_id" \ - --project-name "$PAGES_PROJECT" \ - --force - done diff --git a/.github/workflows/pages-preview.yml b/.github/workflows/pages-preview.yml deleted file mode 100644 index c75a1c5..0000000 --- a/.github/workflows/pages-preview.yml +++ /dev/null @@ -1,191 +0,0 @@ -name: Pages preview - -on: - workflow_run: - workflows: - - Registry checks - types: - - completed - -permissions: - actions: read - contents: read - pull-requests: read - -jobs: - identity: - name: Resolve trusted preview identity - if: >- - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'pull_request' && - github.event.workflow_run.head_repository.full_name == github.repository - runs-on: ubuntu-latest - outputs: - branch: ${{ steps.identity.outputs.branch }} - head_sha: ${{ steps.identity.outputs.head_sha }} - pull_number: ${{ steps.identity.outputs.pull_number }} - steps: - - name: Resolve exact internal pull request - id: identity - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 - with: - script: | - const runId = context.payload.workflow_run.id - const { data: run } = await github.rest.actions.getWorkflowRun({ - owner: context.repo.owner, - repo: context.repo.repo, - run_id: runId, - }) - if (run.name !== 'Registry checks' || run.path !== '.github/workflows/ci.yml') { - core.setFailed('Preview must be orchestrated by the trusted Registry checks workflow.') - return - } - if (run.conclusion !== 'success' || run.event !== 'pull_request') { - core.setFailed('Preview delivery requires a successful pull-request check run.') - return - } - if (run.head_repository?.full_name !== `${context.repo.owner}/${context.repo.repo}`) { - core.setFailed('Fork pull requests are not eligible for preview delivery.') - return - } - - const pulls = await github.rest.repos.listPullRequestsAssociatedWithCommit({ - owner: context.repo.owner, - repo: context.repo.repo, - commit_sha: run.head_sha, - }) - const eligible = pulls.data.filter((pull) => - pull.state === 'open' && - pull.base.ref === 'main' && - pull.head.sha === run.head_sha && - pull.head.repo?.full_name === `${context.repo.owner}/${context.repo.repo}` - ) - if (eligible.length !== 1) { - core.setFailed('Run must be the exact head of one eligible internal pull request.') - return - } - - const pull = eligible[0] - core.setOutput('branch', `preview/ext-reg/pr-${pull.number}`) - core.setOutput('head_sha', run.head_sha) - core.setOutput('pull_number', String(pull.number)) - - deploy: - name: ext-reg preview - needs: identity - concurrency: - group: pages-preview-ext-reg-${{ needs.identity.outputs.pull_number }} - cancel-in-progress: true - permissions: - contents: read - deployments: write - runs-on: ubuntu-latest - timeout-minutes: 15 - environment: - name: preview - url: ${{ steps.pages.outputs.pages-deployment-alias-url }} - steps: - - name: Checkout the trusted preview controller - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.workflow_sha }} - path: controller - persist-credentials: false - - - name: Checkout the exact pull-request head - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ needs.identity.outputs.head_sha }} - path: candidate - persist-credentials: false - - - name: Set up Python - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 - with: - python-version-file: candidate/.python-version - - - name: Install PDM - run: python -m pip install pdm==2.28.0 - - - name: Install frozen candidate dependencies - working-directory: candidate - run: pdm install --frozen-lockfile - - - name: Set up Node - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 - with: - node-version-file: controller/package.json - - - name: Build exact-head product page samples - working-directory: candidate - run: >- - pdm run python scripts/build_ui_preview.py - --fixture .github/pages-preview/extensions.json - --output "$GITHUB_WORKSPACE/.pages-preview/candidate" - - - name: Prepare the bounded static deployment - env: - PREVIEW_HEAD_SHA: ${{ needs.identity.outputs.head_sha }} - PULL_NUMBER: ${{ needs.identity.outputs.pull_number }} - run: | - mkdir .pages-preview/dist - cp .pages-preview/candidate/index.html .pages-preview/dist/index.html - cp controller/.github/pages-preview/_headers .pages-preview/dist/_headers - jq -n \ - --argjson pull_request "$PULL_NUMBER" \ - --arg source_sha "$PREVIEW_HEAD_SHA" \ - '{schema_version: 1, pull_request: $pull_request, source_sha: $source_sha}' \ - > .pages-preview/dist/preview.json - - - name: Deploy to the isolated Pages preview branch - id: pages - uses: cloudflare/wrangler-action@ebbaa1584979971c8614a24965b4405ff95890e0 # v4.0.0 - with: - apiToken: ${{ secrets.CLOUDFLARE_PAGES_API_TOKEN }} - accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - packageManager: npm - wranglerVersion: 4.120.0 - workingDirectory: .pages-preview - command: >- - pages deploy dist - --project-name=${{ vars.REGISTRY_UI_PREVIEW_PAGES_PROJECT }} - --branch=${{ needs.identity.outputs.branch }} - --commit-hash=${{ needs.identity.outputs.head_sha }} - --commit-dirty=false - - - name: Smoke exact and stable Preview URLs - env: - ALIAS_URL: ${{ steps.pages.outputs.pages-deployment-alias-url }} - DEPLOYMENT_URL: ${{ steps.pages.outputs.deployment-url }} - PREVIEW_HEAD_SHA: ${{ needs.identity.outputs.head_sha }} - PULL_NUMBER: ${{ needs.identity.outputs.pull_number }} - run: | - for preview_url in "$DEPLOYMENT_URL" "$ALIAS_URL"; do - test -n "$preview_url" - curl --fail --silent --show-error --retry 6 --retry-all-errors \ - "$preview_url/preview.json" | \ - jq -e \ - --arg source_sha "$PREVIEW_HEAD_SHA" \ - --argjson pull_request "$PULL_NUMBER" \ - '.schema_version == 1 and .source_sha == $source_sha and .pull_request == $pull_request' - curl --fail --silent --show-error --retry 6 --retry-all-errors \ - --dump-header .pages-preview/headers \ - --output .pages-preview/index.html \ - "$preview_url/" - grep -i '^content-type: text/html' .pages-preview/headers - grep -i '^x-robots-tag: noindex, nofollow' .pages-preview/headers - grep -i '^content-security-policy:' .pages-preview/headers - done - - - name: Record Preview evidence - env: - PREVIEW_URL: ${{ steps.pages.outputs.pages-deployment-alias-url }} - run: | - { - echo '## Extension Registry PR Preview' - echo - echo "- Source: \`${{ needs.identity.outputs.head_sha }}\`" - echo "- Preview: $PREVIEW_URL" - echo '- Surface: non-interactive product page samples' - echo '- Registry API and production data: unchanged' - } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/registry-preview-cleanup.yml b/.github/workflows/registry-preview-cleanup.yml new file mode 100644 index 0000000..2df0b33 --- /dev/null +++ b/.github/workflows/registry-preview-cleanup.yml @@ -0,0 +1,100 @@ +name: Registry preview cleanup + +on: + pull_request_target: + branches: + - main + types: + - closed + workflow_dispatch: + inputs: + pull_number: + description: Closed pull request whose preview should be retired + required: true + type: number + +permissions: + contents: read + pull-requests: read + +jobs: + cleanup: + name: ext-reg preview cleanup + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.pull_request.base.ref == 'main' && + github.event.pull_request.base.repo.full_name == github.repository && + github.event.pull_request.head.repo.full_name == github.repository) + concurrency: + group: registry-preview-ext-reg-${{ github.event.pull_request.number || inputs.pull_number }} + cancel-in-progress: false + permissions: + contents: read + deployments: write + pull-requests: read + runs-on: ubuntu-latest + timeout-minutes: 15 + environment: + name: preview + steps: + - name: Checkout the trusted cleanup controller + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + + - name: Install pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 11.11.0 + + - name: Set up Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: package.json + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version-file: .python-version + - name: Install frozen trusted tooling + run: | + python -m pip install pdm==2.28.0 + pdm install --frozen-lockfile + pnpm install --frozen-lockfile + - name: Resolve the closed pull request + id: identity + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + REQUESTED_PULL_NUMBER: ${{ github.event.pull_request.number || inputs.pull_number }} + with: + script: | + const pullNumber = Number(process.env.REQUESTED_PULL_NUMBER) + if (!Number.isInteger(pullNumber) || pullNumber < 1) { + core.setFailed('A positive pull-request number is required.') + return + } + const { data: pull } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pullNumber, + }) + if ( + pull.state !== 'closed' || + pull.base.ref !== 'main' || + pull.base.repo.full_name !== `${context.repo.owner}/${context.repo.repo}` || + pull.head.repo?.full_name !== `${context.repo.owner}/${context.repo.repo}` + ) { + core.setFailed('Cleanup only accepts a closed same-repository pull request targeting main.') + return + } + core.setOutput('pull_number', String(pullNumber)) + + - name: Retire the closed PR Worker and delete its D1/R2 + env: + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN }} + PULL_NUMBER: ${{ steps.identity.outputs.pull_number }} + run: pdm run python scripts/registry_preview.py retire --pull-number "$PULL_NUMBER" diff --git a/.github/workflows/registry-preview.yml b/.github/workflows/registry-preview.yml new file mode 100644 index 0000000..6b695ac --- /dev/null +++ b/.github/workflows/registry-preview.yml @@ -0,0 +1,204 @@ +name: Registry preview + +on: + workflow_run: + workflows: + - Registry checks + types: + - completed + +permissions: + actions: read + contents: read + pull-requests: read + +jobs: + identity: + name: Resolve trusted preview identity + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.head_repository.full_name == github.repository + runs-on: ubuntu-latest + outputs: + head_sha: ${{ steps.identity.outputs.head_sha }} + pull_number: ${{ steps.identity.outputs.pull_number }} + steps: + - name: Resolve exact internal pull request + id: identity + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const runId = context.payload.workflow_run.id + const { data: run } = await github.rest.actions.getWorkflowRun({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: runId, + }) + if (run.name !== 'Registry checks' || run.path !== '.github/workflows/ci.yml') { + core.setFailed('Preview must be orchestrated by the trusted Registry checks workflow.') + return + } + if (run.conclusion !== 'success' || run.event !== 'pull_request') { + core.setFailed('Preview delivery requires a successful pull-request check run.') + return + } + if (run.head_repository?.full_name !== `${context.repo.owner}/${context.repo.repo}`) { + core.setFailed('Fork pull requests are not eligible for preview delivery.') + return + } + + const pulls = await github.rest.repos.listPullRequestsAssociatedWithCommit({ + owner: context.repo.owner, + repo: context.repo.repo, + commit_sha: run.head_sha, + }) + const eligible = pulls.data.filter((pull) => + pull.state === 'open' && + pull.base.ref === 'main' && + pull.head.sha === run.head_sha && + pull.head.repo?.full_name === `${context.repo.owner}/${context.repo.repo}` + ) + if (eligible.length !== 1) { + core.setFailed('Run must be the exact head of one eligible internal pull request.') + return + } + + const pull = eligible[0] + core.setOutput('head_sha', run.head_sha) + core.setOutput('pull_number', String(pull.number)) + + build: + name: Build exact-head Python Worker + needs: identity + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - name: Checkout exact PR head without deployment authority + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.identity.outputs.head_sha }} + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version-file: .python-version + - name: Install PDM + run: python -m pip install pdm==2.28.0 + - name: Install pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 11.11.0 + - name: Set up Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: package.json + cache: pnpm + - name: Build the existing Python Worker + run: | + pdm install --frozen-lockfile + pnpm install --frozen-lockfile + pnpm build + pnpm worker:build + mkdir -p artifact/src + cp dist/worker/worker.py artifact/src/ + cp -R dist/worker/inkcre_extension_registry artifact/src/ + cp -R dist/worker/python_modules artifact/ + cp -R migrations artifact/ + - name: Transfer modules and SQL to a separate delivery runner + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: registry-worker + path: artifact/ + include-hidden-files: true + retention-days: 1 + if-no-files-found: error + + deploy: + name: ext-reg preview + needs: [identity, build] + concurrency: + group: registry-preview-ext-reg-${{ needs.identity.outputs.pull_number }} + cancel-in-progress: false + permissions: + contents: read + pull-requests: read + deployments: write + runs-on: ubuntu-latest + timeout-minutes: 20 + environment: + name: preview + url: ${{ steps.deploy.outputs.url }} + steps: + - name: Checkout only the trusted controller + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.workflow_sha }} + persist-credentials: false + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version-file: .python-version + - name: Install PDM + run: python -m pip install pdm==2.28.0 + - name: Install pnpm + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + with: + version: 11.11.0 + - name: Set up Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version-file: package.json + cache: pnpm + - name: Install frozen trusted tooling + run: | + pdm install --frozen-lockfile + pnpm install --frozen-lockfile + - name: Download this run's Python Worker + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: registry-worker + path: ${{ runner.temp }}/registry-worker + - name: Recheck PR identity after build and concurrency queue + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + PULL_NUMBER: ${{ needs.identity.outputs.pull_number }} + SOURCE_SHA: ${{ needs.identity.outputs.head_sha }} + with: + script: | + const { data: pull } = await github.rest.pulls.get({ + ...context.repo, pull_number: Number(process.env.PULL_NUMBER), + }) + if (pull.state !== 'open' || pull.base.ref !== 'main' || + pull.head.repo?.full_name !== process.env.GITHUB_REPOSITORY || + pull.head.sha !== process.env.SOURCE_SHA) { + throw new Error('PR closed or moved; no preview mutation is allowed.') + } + - name: Apply migrations, deploy isolated bindings, and verify real journeys + id: deploy + env: + CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN }} + REGISTRY_PREVIEW_PUBLISHER_TOKEN: ${{ secrets.REGISTRY_PREVIEW_PUBLISHER_TOKEN }} + PULL_NUMBER: ${{ needs.identity.outputs.pull_number }} + SOURCE_SHA: ${{ needs.identity.outputs.head_sha }} + run: >- + pdm run python scripts/registry_preview.py deploy + --pull-number "$PULL_NUMBER" + --source-sha "$SOURCE_SHA" + --artifact "$RUNNER_TEMP/registry-worker" + - name: Record interactive preview evidence + env: + PREVIEW_URL: ${{ steps.deploy.outputs.url }} + SOURCE_SHA: ${{ needs.identity.outputs.head_sha }} + run: | + { + echo '## Registry PR 预览' + echo + echo "- 来源:\`$SOURCE_SHA\`" + echo "- 完整预览:$PREVIEW_URL" + echo '- 目录、详情、Publisher 与原生分发运行同一 Python Worker。' + echo '- 每 PR 独立 D1/R2;发布测试使用 demo 命名空间和专用预览凭据。' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/30-unit-tdd/registry-web.md b/docs/30-unit-tdd/registry-web.md index a700a17..1a7536d 100644 --- a/docs/30-unit-tdd/registry-web.md +++ b/docs/30-unit-tdd/registry-web.md @@ -66,15 +66,15 @@ is no Registry copy of the token source or a competing design package. `pnpm web:build` compiles the pinned package and local Sass into the committed `service/static/registry.css`. `pnpm web:check` compares it to a fresh compilation and checks browser JavaScript syntax. Committing generated CSS makes Python wheel -and static-preview builds self-contained; it is never edited manually. Wrangler +and Worker builds self-contained; it is never edited manually. Wrangler Text rules explicitly include CSS and JavaScript in the Python module filesystem. Rendering depends on product data, selected release, and the current route. Templates, links, controls, and browser styles contain no preview/production switch. Instance origin and storage bindings belong to runtime configuration. -The external page-sample builder calls the same catalog/detail renderers. Its -navigation and static carrier live under `scripts/` and do not enter the Worker -package. The product does not know whether its HTML is used as acceptance evidence. +Remote previews run the same Python Worker with isolated D1/R2 bindings. Their +resource lifecycle and source identity belong to the delivery controller; see +[Pull-Request Previews](../40-deployment/pull-request-previews.md). Registry follows the client-web product surface: the 32px geometric InKCre logo, InkHeader spacing and typography, square InkButton-style controls, bordered diff --git a/docs/40-deployment/pull-request-previews.md b/docs/40-deployment/pull-request-previews.md index a411cb5..912e3bd 100644 --- a/docs/40-deployment/pull-request-previews.md +++ b/docs/40-deployment/pull-request-previews.md @@ -1,40 +1,83 @@ # Pull-Request Previews -Pages hosts non-interactive samples of the catalog and Extension detail pages. -The build command invokes the same Python renderers used by the Worker, with -explicit fixture data. It preserves product controls and URLs and embeds the -rendered bodies as inert samples. The external sample selector is evidence UI, -not Registry navigation. The builder namespaces element IDs solely to embed -independent documents in one carrier. - -Full product journeys run on the real Python Worker with isolated D1/R2 state, -as described in [Local Development](local-development.md). Preview limitations -must not introduce environment switches, alternative routes, hidden controls, -or preview renderers into product code. A fully interactive remote preview -would require the same Worker and isolated bindings; static Pages samples do -not establish that capability. - -Every pull request runs secret-free checks. Fork pull requests receive no remote -Preview authority. For an eligible same-repository pull request, the trusted -default-branch `workflow_run` controller verifies the successful checks run, -open PR, same-repository origin, and exact current head. The protected Preview -job checks out and builds that head without provider credentials, then exposes -the Pages credential only to the delivery step. - -The controller deploys only the bounded document to the fixed Cloudflare Pages -project `inkcre-extension-registry-ui-preview` on -`preview/ext-reg/pr-`. Protected-main code adds source identity and -noindex, no-store, CSP, nosniff, and no-referrer policy. The Pages project has -no Git provider, custom domain, Functions, Worker, D1, R2, or production token. -The fixture contains sample published release descriptors. The builder owns its -64 KiB document bound, static navigation and inert behavior under `scripts/`; -the Worker package owns none of them. Controller-added headers disable scripts -and requests in this evidence document. The default-branch controller's legacy -`--api-origin` argument remains accepted during its transition, but it no longer -rewrites product links. - -Closing an internal PR deploys the checked-in tombstone, verifies the stable -alias, and deletes older deployments only for that project and branch. The -latest tombstone remains because Pages cannot delete the latest branch -deployment. If provider delivery or authority boundaries fail, disable remote -Preview; do not create a second topology. +A PR preview runs the same built Python Worker as production: catalog, Extension +details, Publisher, Registry API, Python index/files, and Module Federation +assets. It has its own Worker, D1 database, and private R2 bucket, all named +`inkcre-ext-reg-pr-`. Its origin is the account's corresponding +`workers.dev` URL. No custom domain or production binding is inherited. + +The existing `pnpm build` / `pnpm worker:build` commands build the application. +A separate runner uploads those modules with the same pinned Wrangler used by +pywrangler; it does not rebuild Python or introduce a frontend release unit. +The deployment template keeps the production compatibility date, flags, CSS/JS +module rules, and observability. Only resource bindings and origin differ. +Product routes, renderers, styles, and controls have no environment branches. + +## Delivery authority + +`registry-preview.yml` runs from the default branch after successful Registry +checks. Its controller verifies the workflow path, same-repository origin, +exact current head, and open PR targeting main. Forks receive checks only. +The candidate build has no deployment credentials. A separate delivery runner +checks out only the trusted controller and downloads this run's built modules +and SQL migrations. The controller writes the entire deployment configuration; +candidate build hooks, configuration, and commands never run with credentials. +It checks the current PR again after the build and concurrency queue. + +The protected GitHub `preview` environment supplies: + +- `CLOUDFLARE_ACCOUNT_ID`: target account variable (may override the repository + variable when using a separate preview account). +- `CLOUDFLARE_PREVIEW_API_TOKEN`: a dedicated token with Workers Scripts Edit, + D1 Edit, and Workers R2 Storage Write for that account. Do not reuse the + production secret. Cloudflare's account-level control-plane permissions are + not a per-Worker boundary; only the trusted controller receives this token. + A separate account can provide stronger infrastructure isolation. +- `REGISTRY_PREVIEW_PUBLISHER_TOKEN`: a random token of at least 32 characters, + used only for the disposable `demo` namespace. Reviewers obtain it through + the operator, not logs, workflow summaries, or public artifacts. + +The deployed application receives only its own DB, ARTIFACTS, and PUBLIC_ORIGIN. +It never receives Cloudflare credentials. This confines candidate application +code and namespace callers to that PR's data, while the controller retains +account-level deployment authority. + +## State and retirement + +Updates apply the candidate's checked-in migrations and retain that PR's data. +The controller refreshes the hashed demo credential and creates a few real Web +releases through the normal prepare/upload/publish APIs. Existing published or +yanked samples are preserved. Demo assets are test fixtures, not production +Extensions. Reviewers can upload their own snapshots and exercise lifecycle +changes. The deployment log records the source SHA, Worker Version, URL, and +anonymous/read-after-write smoke results; identity is deployment evidence, not +an endpoint or badge added to the product. + +Deploy and cleanup share one per-PR concurrency group without interrupting +active resource operations. Closing an internal PR runs the default-branch +cleanup controller; manual cleanup accepts only a closed eligible PR. Cleanup +replaces that PR's Worker with a small trusted retirement program, stopping +application writes. Using the native R2 binding, it drains batches of objects, +deletes the bucket, then deletes the Worker and D1 database. The temporary +program is deployment tooling and never ships in the Registry package. Wrangler +has no object-list command, and R2 requires an empty bucket before deletion; +this avoids separate S3 credentials or a product maintenance endpoint. +Interrupted cleanup is rerunnable, including resources created by a partially +failed deployment. A failed cleanup remains visible in Actions and must be +rerun; there is no silent expiry or production fallback. + +The controller must first land on protected main before `workflow_run` can +activate it. During bootstrap an operator can use the same command with local +Wrangler authentication and an artifact prepared exactly as in the workflow: + +```bash +pdm run python scripts/registry_preview.py deploy \ + --pull-number 33 --source-sha --artifact /tmp/registry-worker +pdm run python scripts/registry_preview.py retire --pull-number 33 +``` + +Set the account and publisher token through the environment. These commands +create/delete only deterministic preview resources; they are not production +commands. Retire only after closing the PR (or when deliberately resetting its +disposable acceptance data). The obsolete Pages workflows, static samples, +fixture carrier, and preview-specific renderer have been removed. diff --git a/scripts/build_ui_preview.py b/scripts/build_ui_preview.py deleted file mode 100644 index 57d7ab8..0000000 --- a/scripts/build_ui_preview.py +++ /dev/null @@ -1,116 +0,0 @@ -from __future__ import annotations - -import argparse -import json -import re -from pathlib import Path -from typing import Literal - -from jinja2 import Environment, FileSystemLoader, select_autoescape -from pydantic import BaseModel, ConfigDict, field_validator - -from inkcre_extension_registry.contracts.models import ExtensionRecord, ExtensionSummary -from inkcre_extension_registry.service.ui import ( - STYLES, - extension_catalog_html, - extension_detail_html, -) - -MAX_DOCUMENT_BYTES = 64 * 1024 - - -class PreviewFixture(BaseModel): - model_config = ConfigDict(extra="forbid", frozen=True) - - schema_version: Literal[1] - extensions: tuple[ExtensionRecord, ...] - - @field_validator("extensions") - @classmethod - def releases_match_extensions( - cls, extensions: tuple[ExtensionRecord, ...] - ) -> tuple[ExtensionRecord, ...]: - names = [extension.name for extension in extensions] - if len(names) != len(set(names)): - raise ValueError("extension names must be unique") - for extension in extensions: - if not extension.releases or any( - release.name != extension.name or release.state != "published" - for release in extension.releases - ): - raise ValueError("preview details must be published releases of their extension") - versions = [release.version for release in extension.releases] - if len(versions) != len(set(versions)): - raise ValueError("preview release versions must be unique") - return extensions - - -def build_preview(*, fixture_path: Path, output_directory: Path) -> Path: - fixture = PreviewFixture.model_validate_json(fixture_path.read_bytes()) - if output_directory.exists() and any(output_directory.iterdir()): - raise ValueError("output directory must be empty") - output_directory.mkdir(parents=True, exist_ok=True) - - documents = [ - ( - "Catalog", - extension_catalog_html( - [ - ExtensionSummary(name=item.name, nickname=item.nickname) - for item in fixture.extensions - ] - ), - ) - ] - for extension in fixture.extensions: - detail = extension_detail_html(extension) - assert detail is not None # The fixture requires published releases. - documents.append((extension.nickname, detail)) - - samples = [] - for index, (label, document) in enumerate(documents): - body = document.split("", 1)[1].split("", 1)[0] - # Each inert sample keeps the product's links and controls intact. Only - # IDs are namespaced so independent documents can share the evidence page. - body = re.sub(r' id="([^"]+)"', rf' id="sample-{index}-\1"', body) - samples.append({"label": label, "body": body}) - templates = Environment( - loader=FileSystemLoader(Path(__file__).parent / "templates"), - autoescape=select_autoescape(["html"]), - ) - document = ( - templates.get_template("ui-snapshots.html").render(styles=STYLES, samples=samples).encode() - ) - if len(document) > MAX_DOCUMENT_BYTES: - raise ValueError(f"preview document exceeds {MAX_DOCUMENT_BYTES} bytes") - - output = output_directory / "index.html" - output.write_bytes(document) - return output - - -def main() -> None: - parser = argparse.ArgumentParser( - description="Build static samples of the Registry product pages" - ) - parser.add_argument("--fixture", type=Path, required=True) - parser.add_argument("--output", type=Path, required=True) - # Accept the old default-branch controller's argument until it is updated. - parser.add_argument("--api-origin", help=argparse.SUPPRESS) - arguments = parser.parse_args() - - output = build_preview( - fixture_path=arguments.fixture, - output_directory=arguments.output, - ) - print( - json.dumps( - {"document": str(output), "size": output.stat().st_size}, - separators=(",", ":"), - sort_keys=True, - ) - ) - - -if __name__ == "__main__": - main() diff --git a/scripts/registry_preview.py b/scripts/registry_preview.py new file mode 100644 index 0000000..0309039 --- /dev/null +++ b/scripts/registry_preview.py @@ -0,0 +1,270 @@ +"""Deploy/retire one isolated PR using built Python modules and native Wrangler. + +Run from the trusted controller checkout; candidate code is uploaded, never +executed here. Credentials belong only to this process, not the Worker. +""" + +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +import re +import secrets +import subprocess +import tempfile +import time +import zipfile +from pathlib import Path + +import httpx + +ROOT = Path(__file__).resolve().parents[1] +WRANGLER = ["pnpm", "exec", "wrangler"] + + +def wrangler(*args: str) -> str: + result = subprocess.run([*WRANGLER, *args], cwd=ROOT, text=True, capture_output=True) + if result.returncode: + raise RuntimeError(f"Wrangler failed: {result.stderr}") + return result.stdout + + +def api(client: httpx.Client, method: str, path: str, **kwargs): + response = client.request(method, path, **kwargs) + response.raise_for_status() + body = response.json() + if not body["success"]: + raise RuntimeError(f"Cloudflare {method} {path}: {body['errors']}") + return body.get("result") + + +def database(client: httpx.Client, name: str): + matches = api(client, "GET", "d1/database", params={"name": name, "per_page": 100}) + return next((item for item in matches if item["name"] == name), None) + + +def bucket_exists(client: httpx.Client, name: str) -> bool: + response = client.get(f"r2/buckets/{name}") + if response.status_code == 404: + return False + response.raise_for_status() + if not response.json()["success"]: + raise RuntimeError("Cannot resolve preview bucket") + return True + + +def wait_for(origin: str, path: str, expected: int = 200, **kwargs) -> httpx.Response: + for _attempt in range(12): + try: + response = httpx.request("GET", origin + path, timeout=15, **kwargs) + if response.status_code == expected: + return response + except httpx.TransportError: + pass + time.sleep(2) + raise RuntimeError(f"Preview did not become ready: {origin}{path}") + + +def seed(origin: str, token: str) -> None: + """Small real releases, admitted through the same public publisher API.""" + snapshot = io.BytesIO() + with zipfile.ZipFile(snapshot, "w") as archive: + archive.writestr( + "mf-manifest.json", + json.dumps( + { + "id": "registry-sample", + "name": "registry-sample", + "metaData": { + "publicPath": "./", + "remoteEntry": {"name": "remoteEntry.js", "path": "", "type": "module"}, + }, + "exposes": [], + "shared": [], + } + ), + ) + archive.writestr( + "remoteEntry.js", "export const get = () => {}; export const init = () => {};" + ) + with httpx.Client( + base_url=origin, headers={"Authorization": f"Bearer {token}"}, timeout=60 + ) as publisher: + for slug, nickname in [ + ("github", "GitHub"), + ("rss", "RSS / Atom Feeds"), + ("memos", "Memos"), + ]: + for version in ["1.0.0", "1.10.0", "2.0.0-rc.1"] if slug == "github" else ["1.0.0"]: + base = f"/v1/extensions/demo/{slug}/releases" + # Preserve reviewer edits on redeploy; only seed absent releases. + existing = publisher.get(base + "/" + version) + if existing.status_code == 200: + continue + if existing.status_code != 404: + existing.raise_for_status() + response = publisher.post( + base, + json={ + "nickname": nickname, + "version": version, + "module_federation": { + "host_sdk": "@inkcre/core", + "host_sdk_version": "^0.1.0", + "source_repository": "https://github.com/InKCre/ext-reg", + "source_revision": "registry-preview-fixture-v1", + }, + }, + ) + response.raise_for_status() + response = publisher.post( + base + f"/{version}/module-federation", + files={"content": ("snapshot.zip", snapshot.getvalue(), "application/zip")}, + ) + response.raise_for_status() + publisher.post(base + f"/{version}/publish").raise_for_status() + + +def deploy(client: httpx.Client, name: str, origin: str, artifact: Path, revision: str) -> None: + token = os.environ["REGISTRY_PREVIEW_PUBLISHER_TOKEN"] + if len(token) < 32: + raise ValueError("Use a dedicated random preview publisher token of at least 32 characters") + db = database(client, name) or api(client, "POST", "d1/database", json={"name": name}) + if not bucket_exists(client, name): + api(client, "POST", "r2/buckets", json={"name": name}) + config = json.loads((ROOT / ".github/preview/wrangler.json").read_text()) + config.update( + name=name, + main="src/worker.py", + vars={"PUBLIC_ORIGIN": origin}, + d1_databases=[ + { + "binding": "DB", + "database_name": name, + "database_id": db["uuid"], + "migrations_dir": "migrations", + } + ], + r2_buckets=[{"binding": "ARTIFACTS", "bucket_name": name}], + ) + config_path = artifact / "wrangler.json" + config_path.write_text(json.dumps(config)) + wrangler("d1", "migrations", "apply", "DB", "--remote", "--config", str(config_path)) + for sql, params in [ + ("INSERT OR IGNORE INTO namespaces(name) VALUES ('demo')", []), + ("DELETE FROM credentials WHERE namespace = 'demo' AND label = 'PR reviewer'", []), + ( + "INSERT INTO credentials(token_hash, namespace, label) " + "VALUES (?, 'demo', 'PR reviewer')", + [hashlib.sha256(token.encode()).hexdigest()], + ), + ]: + api(client, "POST", f"d1/database/{db['uuid']}/query", json={"sql": sql, "params": params}) + print( + wrangler( + "deploy", + "--config", + str(config_path), + "--message", + f"Registry PR at {revision}", + "--strict", + ) + ) + assert wait_for(origin, "/livez").json()["status"] == "ok" + wait_for(origin, "/v1/extensions") + seed(origin, token) + assert "Extension Registry" in wait_for(origin, "/").text + wait_for(origin, "/explore/demo/github") + wait_for(origin, "/publish") + wait_for(origin, "/v1/publisher", expected=401) + wait_for(origin, "/simple/") + manifest = wait_for(origin, "/extensions/demo/github/1.10.0/module-federation/mf-manifest.json") + assert origin in manifest.text + print(f"Preview: {origin}\nSource: {revision}\nD1/R2/Worker: {name}") + if os.environ.get("GITHUB_OUTPUT"): + with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: + output.write(f"url={origin}\n") + + +def retire(client: httpx.Client, name: str, origin: str) -> None: + # Stop new application writes before emptying R2. Wrangler has no object-list + # command; a temporary replacement uses the native list/delete binding API. + if bucket_exists(client, name): + token = secrets.token_urlsafe(32) + with tempfile.TemporaryDirectory() as temporary: + config_path = Path(temporary) / "wrangler.json" + config_path.write_text( + json.dumps( + { + "name": name, + "main": str(ROOT / ".github/preview/retire.js"), + "compatibility_date": "2026-07-28", + "workers_dev": True, + "preview_urls": False, + "observability": {"enabled": True}, + "vars": {"RETIRE_TOKEN": token}, + "r2_buckets": [{"binding": "ARTIFACTS", "bucket_name": name}], + } + ) + ) + wrangler("deploy", "--config", str(config_path), "--message", "Retire closed PR") + wait_for(origin, "/", expected=410) + # Allow pre-replacement requests (including uploads) to finish. + time.sleep(30) + with httpx.Client( + base_url=origin, headers={"Authorization": f"Bearer {token}"}, timeout=60 + ) as drain: + while True: + response = drain.post("/") + response.raise_for_status() + if response.json()["deleted"] == 0: + break + api(client, "DELETE", f"r2/buckets/{name}") + response = client.delete(f"workers/scripts/{name}") + if response.status_code != 404: + response.raise_for_status() + if not response.json()["success"]: + raise RuntimeError("Could not delete preview Worker") + db = database(client, name) + if db: + api(client, "DELETE", f"d1/database/{db['uuid']}") + print(f"Retired Worker, D1 and R2: {name}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("operation", choices=["deploy", "retire"]) + parser.add_argument("--pull-number", type=int, required=True) + parser.add_argument("--artifact", type=Path) + parser.add_argument("--source-sha") + args = parser.parse_args() + if args.pull_number < 1: + parser.error("pull-number must be positive") + if args.operation == "deploy" and ( + args.artifact is None or not re.fullmatch(r"[0-9a-f]{40}", args.source_sha or "") + ): + parser.error("deploy requires artifact and exact source-sha") + name = f"inkcre-ext-reg-pr-{args.pull_number}" + account = os.environ["CLOUDFLARE_ACCOUNT_ID"] + token = ( + os.environ.get("CLOUDFLARE_API_TOKEN") + or json.loads(wrangler("auth", "token", "--json"))["token"] + ) + with httpx.Client( + base_url=f"https://api.cloudflare.com/client/v4/accounts/{account}/", + headers={"Authorization": f"Bearer {token}"}, + timeout=120, + ) as client: + subdomain = api(client, "GET", "workers/subdomain")["subdomain"] + origin = f"https://{name}.{subdomain}.workers.dev" + if args.operation == "deploy": + deploy(client, name, origin, args.artifact.resolve(), args.source_sha) + else: + retire(client, name, origin) + + +if __name__ == "__main__": + main() diff --git a/scripts/templates/ui-snapshots.html b/scripts/templates/ui-snapshots.html deleted file mode 100644 index 223c7b1..0000000 --- a/scripts/templates/ui-snapshots.html +++ /dev/null @@ -1,52 +0,0 @@ - - - - - - - Registry UI snapshots - - - - - - - {% for sample in samples %} - - {% endfor %} - - diff --git a/tasks/registry-web-lovable/packet.md b/tasks/registry-web-lovable/packet.md index 01899c4..4695d23 100644 --- a/tasks/registry-web-lovable/packet.md +++ b/tasks/registry-web-lovable/packet.md @@ -2,32 +2,38 @@ ## 目标与授权 -Sir 要求将 Registry Web 提升为可喜爱、可持续维护的产品,允许加入 Publisher 端;复用 ../design,保持 Python 与单一部署流程。已授权独立分支/worktree、实现、提交、推送和 PR;不包含生产资源变更或合并。 +Sir 要求改善 Registry Web,可加入 Publisher;对齐 client-web 与 design,保持 Python 与单一部署流程。已授权独立分支/worktree、实现、提交、推送和 PR,不包含生产资源变更或合并。 -- 工作树:`.worktrees/registry-web/ext-reg`,分支 `feat/registry-web-lovable`,基线 `422a6cb`。 -- 本 packet 是唯一任务控制入口;原仓库分叉及未提交内容保留。 +2026-09-06 明确澄清:单一部署是避免 Vue/Vite CSR、Nuxt 等独立前端交付,已同意同一 Python Worker 的完整远程 PR 预览。 -## 当前纠正与边界 +- 工作树:`.worktrees/registry-web/ext-reg`;分支 `feat/registry-web-lovable`;基线 `422a6cb`。 +- 唯一任务控制入口为本 packet;原仓库分叉和其他任务内容保留。 +- 配套组织约定工作树:`.worktrees/registry-web/governance`,分支 `feat/registry-worker-preview`,基线 `6d91c02`;仅更新 ext-reg 预览 profile 和适用的 Pages 专指。 -Sir 已认可修正后的产品视觉,但指出 preview/production 信息反过来塑造了产品实现。此判断已在模板、链接、样式和运行时预览函数中证实。 +## 当前实现与边界 -- 变更对象:Registry renderer、页面模板/CSS、独立静态证据生成器。From → To:产品内的环境开关/锚点导航/隐藏控件 → 同一套产品页面与原生 URL;静态样张在 scripts 层直接调用这些 renderer。 -- 删除 `extension_preview_html`、产品 `preview.html`、预览专用 CSS 与仅为预览抽出的模板分层。目录、详情、复制、搜索、Publisher 和下载链接均不再因环境不同而改变。 -- Pages 降为明确标注的不可交互页面样张:在产品外选择样张,样张内部保留原始控件和 URL。脚本负责 inert 容器、ID 命名空间、样例数据与体积上限;不进入 Worker 包。完整交互验收继续使用同一个真实 Python Worker 和隔离本地状态。 -- 既有 Pages controller 仍可交付本 PR;更新后的 workflow 移除无效 api-origin 参数并正确描述证据范围,旧默认分支参数兼容仅留在 CLI 过渡层。 -- 保留已认可的 client-web 视觉与真实 logo;发布 API、原生上传、数据库结构、Python 单 Worker 部署不变。无新增远程 Worker、权限、生产变更或 shared 编辑。 +- 产品已对齐 client-web:真实几何 logo、普通字号、直角边框、紧凑目录与详情;复用 design 已有公开 Sass package,无 hero、推广或持久教程面板。 +- 目录支持搜索/发布者筛选;详情提供精确版本、原生分发和复制;Publisher 支持 Web 准备/上传/发布/撤回/恢复及私有分页,Python 上传沿用 Toolkit。 +- From → To:静态 Pages 样张 → 每 PR 同一 Python Worker + 独立 D1/R2;删除静态生成器、carrier、fixture 和 Pages workflows。产品页面、控件和 URL 不含环境分支。 +- 预览 workflow 自己使用现有构建命令;无凭据构建与有凭据部署在不同 runner。默认分支控制器验证成功 CI 与实时 PR head,只消费模块和迁移,生成完整绑定配置,不执行 candidate 部署命令。 +- 更新保留该 PR 数据;关闭先替换写入入口,用原生 R2 binding 批量清空,再删 R2/Worker/D1。维护程序只存在于部署工具,不进入产品包。清理失败显式可见并可重试。 +- 副作用仅为隔离预览资源、preview 环境专用测试发布凭据、配套治理约定。生产资源、custom domain、生产凭据、迁移结构和 shared 挂载均未修改。 -## 验收与状态 +## 验证与状态 -产品环境分支已删除;完整 `pnpm check` 通过,Worker dry-run 包含 503 个模块,wheel 中不含证据模板/生成器。实际 Worker 已验证搜索→详情→精确版本及 Publisher 入口,控制台无错误。独立样张保留正常产品 URL、具备唯一的元素 ID、inert 容器和独立键盘导航,生成体积约 52 KiB。 +- 完整 `pnpm check` 通过:样式新鲜度、合同、格式、lint、类型、wheel 和 Python Worker dry-run;本轮未修改产品源代码。 +- 远程真实 Worker 已验证目录、详情、SemVer 默认/精确版本、Publisher 登录/准备、原生 ZIP 上传;控制器通过 API 创建 5 个可读 Web Releases,manifest publicPath 指向独立预览域名。 +- 实际重复部署保留数据;带 R2 对象的完整清理已通过(Worker、D1、R2 同名 `inkcre-ext-reg-pr-33`)。重复清理也已通过;最终重建证据见部署日志与 PR 更新。 +- 首次部署后一次 release GET 返回 500,后续日志未复现;未据此修改产品。部署验收增加 D1 公开读取就绪检查,再执行样例发布。 +- 既往真实本地 Worker 已验证 Web/Python 发布、撤回恢复、私有分页与命名空间隔离。 -最终 CI 与 Pages 身份以 PR checks / preview.json 为准。本机真实 Worker 可在 `http://127.0.0.1:8791` 交互查看;其状态来自本任务隔离的本地 D1/R2,不是远程生产数据。 +## 交付与剩余启用条件 -既往 Web/Python 发布、撤回恢复、私有分页及命名空间隔离证据保留;静态样张不再被描述为交互验收。 - -## 交付 - -- [PR #33](https://github.com/InKCre/ext-reg/pull/33) -- [Registry 预览](https://preview-ext-reg-pr-33.inkcre-extension-registry-ui-preview.pages.dev) -- 长期设计归属 `docs/30-unit-tdd/registry-web.md`;预览交付归属 `docs/40-deployment/pull-request-previews.md`。 -- 回退整个 PR 恢复旧 Web 与读取合同,无数据回滚步骤。PR 未关闭前保留本 packet。 +- 产品与预览:[ext-reg PR #33](https://github.com/InKCre/ext-reg/pull/33)。 +- 组织交付约定:[.github PR #32](https://github.com/InKCre/.github/pull/32)。 +- 完整远程 URL:`https://inkcre-ext-reg-pr-33.lanzhijiang.workers.dev`;最终提交部署后再交付。 +- 已设置 preview 环境 `REGISTRY_PREVIEW_PUBLISHER_TOKEN`;原始凭据不进入日志、packet 或 PR。只有哈希写入隔离 D1。 +- `CLOUDFLARE_PREVIEW_API_TOKEN` 尚未配置,已向 Sir 询问账户/凭据;不能复用生产 token。当前真实预览由本机已有 Wrangler 登录运行同一控制器完成。 +- 已停用旧 Pages 两个 workflows,旧 PR #33 alias 转向完整 Worker;仅此 PR 开放中。 +- 新 workflow_run 控制器须先进入 main 才会启用;不能从候选 PR 自行取得默认分支信任。未擅自合并。 +- 长期设计:`docs/30-unit-tdd/registry-web.md`;预览运维:`docs/40-deployment/pull-request-previews.md`。PR 关闭前保留本 packet。 From 39ebecd760eeb99c267d1977be86b1a4c6ded489 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Sun, 6 Sep 2026 23:55:51 +0800 Subject: [PATCH 07/16] =?UTF-8?q?fix(preview):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=20demo=20=E6=95=B0=E6=8D=AE=E5=8F=8A?= =?UTF-8?q?=E6=A0=B7=E4=BE=8B=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/registry-preview.yml | 2 +- docs/40-deployment/pull-request-previews.md | 19 +++--- scripts/registry_preview.py | 66 --------------------- tasks/registry-web-lovable/packet.md | 48 +++++++-------- 4 files changed, 34 insertions(+), 101 deletions(-) diff --git a/.github/workflows/registry-preview.yml b/.github/workflows/registry-preview.yml index 6b695ac..b3d48f3 100644 --- a/.github/workflows/registry-preview.yml +++ b/.github/workflows/registry-preview.yml @@ -176,7 +176,7 @@ jobs: pull.head.sha !== process.env.SOURCE_SHA) { throw new Error('PR closed or moved; no preview mutation is allowed.') } - - name: Apply migrations, deploy isolated bindings, and verify real journeys + - name: Apply migrations, deploy isolated bindings, and verify reads id: deploy env: CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} diff --git a/docs/40-deployment/pull-request-previews.md b/docs/40-deployment/pull-request-previews.md index 912e3bd..7085258 100644 --- a/docs/40-deployment/pull-request-previews.md +++ b/docs/40-deployment/pull-request-previews.md @@ -34,7 +34,8 @@ The protected GitHub `preview` environment supplies: not a per-Worker boundary; only the trusted controller receives this token. A separate account can provide stronger infrastructure isolation. - `REGISTRY_PREVIEW_PUBLISHER_TOKEN`: a random token of at least 32 characters, - used only for the disposable `demo` namespace. Reviewers obtain it through + used only for the isolated `demo` publisher namespace; it creates no sample + Extensions or Releases. Reviewers obtain it through the operator, not logs, workflow summaries, or public artifacts. The deployed application receives only its own DB, ARTIFACTS, and PUBLIC_ORIGIN. @@ -45,13 +46,15 @@ account-level deployment authority. ## State and retirement Updates apply the candidate's checked-in migrations and retain that PR's data. -The controller refreshes the hashed demo credential and creates a few real Web -releases through the normal prepare/upload/publish APIs. Existing published or -yanked samples are preserved. Demo assets are test fixtures, not production -Extensions. Reviewers can upload their own snapshots and exercise lifecycle -changes. The deployment log records the source SHA, Worker Version, URL, and -anonymous/read-after-write smoke results; identity is deployment evidence, not -an endpoint or badge added to the product. +The controller configures the publisher credential independently of catalog data. +Deployment never creates Extensions, Releases, or sample artifacts, and its read +checks work with an empty catalog. Reviewers upload the actual Extensions they +want to evaluate through Publisher or the Toolkit. Temporary acceptance data is +removed after testing; it is not a retained preview dataset. + +The deployment log records the source SHA, Worker Version, URL, and anonymous +read checks; identity is deployment evidence, not an endpoint or badge added to +the product. Deploy and cleanup share one per-PR concurrency group without interrupting active resource operations. Closing an internal PR runs the default-branch diff --git a/scripts/registry_preview.py b/scripts/registry_preview.py index 0309039..467075c 100644 --- a/scripts/registry_preview.py +++ b/scripts/registry_preview.py @@ -8,7 +8,6 @@ import argparse import hashlib -import io import json import os import re @@ -16,7 +15,6 @@ import subprocess import tempfile import time -import zipfile from pathlib import Path import httpx @@ -68,66 +66,6 @@ def wait_for(origin: str, path: str, expected: int = 200, **kwargs) -> httpx.Res raise RuntimeError(f"Preview did not become ready: {origin}{path}") -def seed(origin: str, token: str) -> None: - """Small real releases, admitted through the same public publisher API.""" - snapshot = io.BytesIO() - with zipfile.ZipFile(snapshot, "w") as archive: - archive.writestr( - "mf-manifest.json", - json.dumps( - { - "id": "registry-sample", - "name": "registry-sample", - "metaData": { - "publicPath": "./", - "remoteEntry": {"name": "remoteEntry.js", "path": "", "type": "module"}, - }, - "exposes": [], - "shared": [], - } - ), - ) - archive.writestr( - "remoteEntry.js", "export const get = () => {}; export const init = () => {};" - ) - with httpx.Client( - base_url=origin, headers={"Authorization": f"Bearer {token}"}, timeout=60 - ) as publisher: - for slug, nickname in [ - ("github", "GitHub"), - ("rss", "RSS / Atom Feeds"), - ("memos", "Memos"), - ]: - for version in ["1.0.0", "1.10.0", "2.0.0-rc.1"] if slug == "github" else ["1.0.0"]: - base = f"/v1/extensions/demo/{slug}/releases" - # Preserve reviewer edits on redeploy; only seed absent releases. - existing = publisher.get(base + "/" + version) - if existing.status_code == 200: - continue - if existing.status_code != 404: - existing.raise_for_status() - response = publisher.post( - base, - json={ - "nickname": nickname, - "version": version, - "module_federation": { - "host_sdk": "@inkcre/core", - "host_sdk_version": "^0.1.0", - "source_repository": "https://github.com/InKCre/ext-reg", - "source_revision": "registry-preview-fixture-v1", - }, - }, - ) - response.raise_for_status() - response = publisher.post( - base + f"/{version}/module-federation", - files={"content": ("snapshot.zip", snapshot.getvalue(), "application/zip")}, - ) - response.raise_for_status() - publisher.post(base + f"/{version}/publish").raise_for_status() - - def deploy(client: httpx.Client, name: str, origin: str, artifact: Path, revision: str) -> None: token = os.environ["REGISTRY_PREVIEW_PUBLISHER_TOKEN"] if len(token) < 32: @@ -175,14 +113,10 @@ def deploy(client: httpx.Client, name: str, origin: str, artifact: Path, revisio ) assert wait_for(origin, "/livez").json()["status"] == "ok" wait_for(origin, "/v1/extensions") - seed(origin, token) assert "Extension Registry" in wait_for(origin, "/").text - wait_for(origin, "/explore/demo/github") wait_for(origin, "/publish") wait_for(origin, "/v1/publisher", expected=401) wait_for(origin, "/simple/") - manifest = wait_for(origin, "/extensions/demo/github/1.10.0/module-federation/mf-manifest.json") - assert origin in manifest.text print(f"Preview: {origin}\nSource: {revision}\nD1/R2/Worker: {name}") if os.environ.get("GITHUB_OUTPUT"): with Path(os.environ["GITHUB_OUTPUT"]).open("a") as output: diff --git a/tasks/registry-web-lovable/packet.md b/tasks/registry-web-lovable/packet.md index 4695d23..302b01f 100644 --- a/tasks/registry-web-lovable/packet.md +++ b/tasks/registry-web-lovable/packet.md @@ -4,36 +4,32 @@ Sir 要求改善 Registry Web,可加入 Publisher;对齐 client-web 与 design,保持 Python 与单一部署流程。已授权独立分支/worktree、实现、提交、推送和 PR,不包含生产资源变更或合并。 -2026-09-06 明确澄清:单一部署是避免 Vue/Vite CSR、Nuxt 等独立前端交付,已同意同一 Python Worker 的完整远程 PR 预览。 +已明确单一部署是避免 Vue/Vite CSR、Nuxt 等独立前端交付,同意同一 Python Worker 的完整远程 PR 预览。后续数据库复制与额外迁移治理方案已被 Sir 否决,不实施。 - 工作树:`.worktrees/registry-web/ext-reg`;分支 `feat/registry-web-lovable`;基线 `422a6cb`。 - 唯一任务控制入口为本 packet;原仓库分叉和其他任务内容保留。 -- 配套组织约定工作树:`.worktrees/registry-web/governance`,分支 `feat/registry-worker-preview`,基线 `6d91c02`;仅更新 ext-reg 预览 profile 和适用的 Pages 专指。 +- 配套治理工作树:`.worktrees/registry-web/governance`;分支 `feat/registry-worker-preview`;仅调整组织 ext-reg 预览交付约定。 -## 当前实现与边界 +## 当前实现 -- 产品已对齐 client-web:真实几何 logo、普通字号、直角边框、紧凑目录与详情;复用 design 已有公开 Sass package,无 hero、推广或持久教程面板。 +- 产品对齐 client-web:真实几何 logo、普通字号、直角边框、紧凑目录与详情;复用 design 已有公开 Sass package,无 hero、推广或持久教程面板。 - 目录支持搜索/发布者筛选;详情提供精确版本、原生分发和复制;Publisher 支持 Web 准备/上传/发布/撤回/恢复及私有分页,Python 上传沿用 Toolkit。 -- From → To:静态 Pages 样张 → 每 PR 同一 Python Worker + 独立 D1/R2;删除静态生成器、carrier、fixture 和 Pages workflows。产品页面、控件和 URL 不含环境分支。 -- 预览 workflow 自己使用现有构建命令;无凭据构建与有凭据部署在不同 runner。默认分支控制器验证成功 CI 与实时 PR head,只消费模块和迁移,生成完整绑定配置,不执行 candidate 部署命令。 -- 更新保留该 PR 数据;关闭先替换写入入口,用原生 R2 binding 批量清空,再删 R2/Worker/D1。维护程序只存在于部署工具,不进入产品包。清理失败显式可见并可重试。 -- 副作用仅为隔离预览资源、preview 环境专用测试发布凭据、配套治理约定。生产资源、custom domain、生产凭据、迁移结构和 shared 挂载均未修改。 - -## 验证与状态 - -- 完整 `pnpm check` 通过:样式新鲜度、合同、格式、lint、类型、wheel 和 Python Worker dry-run;本轮未修改产品源代码。 -- 远程真实 Worker 已验证目录、详情、SemVer 默认/精确版本、Publisher 登录/准备、原生 ZIP 上传;控制器通过 API 创建 5 个可读 Web Releases,manifest publicPath 指向独立预览域名。 -- 实际重复部署保留数据;带 R2 对象的完整清理已通过(Worker、D1、R2 同名 `inkcre-ext-reg-pr-33`)。重复清理也已通过;最终重建证据见部署日志与 PR 更新。 -- 首次部署后一次 release GET 返回 500,后续日志未复现;未据此修改产品。部署验收增加 D1 公开读取就绪检查,再执行样例发布。 -- 既往真实本地 Worker 已验证 Web/Python 发布、撤回恢复、私有分页与命名空间隔离。 - -## 交付与剩余启用条件 - -- 产品与预览:[ext-reg PR #33](https://github.com/InKCre/ext-reg/pull/33)。 -- 组织交付约定:[.github PR #32](https://github.com/InKCre/.github/pull/32)。 -- 完整远程 URL:`https://inkcre-ext-reg-pr-33.lanzhijiang.workers.dev`;最终提交部署后再交付。 -- 已设置 preview 环境 `REGISTRY_PREVIEW_PUBLISHER_TOKEN`;原始凭据不进入日志、packet 或 PR。只有哈希写入隔离 D1。 -- `CLOUDFLARE_PREVIEW_API_TOKEN` 尚未配置,已向 Sir 询问账户/凭据;不能复用生产 token。当前真实预览由本机已有 Wrangler 登录运行同一控制器完成。 -- 已停用旧 Pages 两个 workflows,旧 PR #33 alias 转向完整 Worker;仅此 PR 开放中。 -- 新 workflow_run 控制器须先进入 main 才会启用;不能从候选 PR 自行取得默认分支信任。未擅自合并。 +- 每 PR 使用同一 Python Worker + 独立 D1/R2;静态 Pages 样张已移除。产品页面、控件和 URL 不含环境分支。 +- 预览 workflow 自己使用现有构建命令;无凭据构建与可信部署控制器分处不同 runner。控制器验证成功 CI、实时 PR head 并生成完整绑定配置。 +- 更新保留该 PR 数据,关闭时清理对应 Worker/D1/R2;维护程序不进入产品包。 + +## 已批准并实施的 demo 修正 + +- From → To:部署自动创建样例 Releases → 部署只配置资源和 Publisher 登录,匿名读取检查支持空目录。删除 seed、样例构造代码、样例详情/manifest 专用检查,共移除脚本 66 行;未新增机制或产品修改。 +- 已核对并清理 PR #33 中 demo/github 的 1.0.0、1.10.0、2.0.0-rc.1,以及 demo/memos、demo/rss、demo/notes 各 1.0.0;共 4 个扩展、6 个版本、12 个精确 R2 对象。 +- 清理前核对 source_repository/source_revision、snapshot hash 和 asset paths;只删除上述记录及对应 keys,不重置数据库。数据库 UUID `7d67ae5e-c0b3-4fca-841c-4c4c7661dd73` 未变;命名空间、凭据和迁移记录数保持不变,外键检查通过。 +- Publisher 登录配置独立保留;没有扩展时使用现有空态,实际需要验收的扩展由 Publisher/Toolkit 上传。临时验收数据不再作为常驻预览数据。 + +## 验证与交付 + +- 本轮完整 `pnpm check` 通过;远程目录已为空,旧 demo 详情返回 404,Publisher 仍可登录且 release 列表为空。最终提交重部署及 CI 结果记录于 PR。 +- 既往真实 Worker 已验证详情/版本、Web/Python 发布、撤回恢复、私有分页、命名空间隔离、重复部署和带对象清理;验收数据现已删除。 +- [ext-reg PR #33](https://github.com/InKCre/ext-reg/pull/33);[配套 .github PR #32](https://github.com/InKCre/.github/pull/32)。 +- [完整预览](https://inkcre-ext-reg-pr-33.lanzhijiang.workers.dev/);旧 Pages alias 已转向此 URL,旧 Pages workflows 已停用。 +- GitHub preview 环境已配置 `REGISTRY_PREVIEW_PUBLISHER_TOKEN`;`CLOUDFLARE_PREVIEW_API_TOKEN` 尚未配置。当前远程预览由本机已有 Wrangler 登录运行同一控制器部署。新控制器须先进入 main 才能自动触发;未擅自合并或修改生产资源。 - 长期设计:`docs/30-unit-tdd/registry-web.md`;预览运维:`docs/40-deployment/pull-request-previews.md`。PR 关闭前保留本 packet。 From 03de36bd54b8934d97ff36c33f88b50cf4b469d7 Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Sun, 13 Sep 2026 09:44:54 +0800 Subject: [PATCH 08/16] =?UTF-8?q?refactor(registry):=20=E4=BD=BF=E7=94=A8?= =?UTF-8?q?=20SQLAlchemy=20=E6=9B=BF=E6=8D=A2=E6=89=8B=E5=86=99=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=20SQL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/30-unit-tdd/registry-control-plane.md | 17 + docs/40-deployment/local-development.md | 17 + package.json | 3 +- pdm.lock | 63 +- pylock.toml | 5 + pyproject.toml | 1 + scripts/check_registry.py | 335 ++++++++++ scripts/registry_preview.py | 21 +- .../service/database.py | 118 ++++ .../service/repository.py | 571 +++++++++--------- tasks/registry-web-lovable/packet.md | 16 +- 11 files changed, 880 insertions(+), 287 deletions(-) create mode 100644 scripts/check_registry.py create mode 100644 src/inkcre_extension_registry/service/database.py diff --git a/docs/30-unit-tdd/registry-control-plane.md b/docs/30-unit-tdd/registry-control-plane.md index b535da2..d8c317e 100644 --- a/docs/30-unit-tdd/registry-control-plane.md +++ b/docs/30-unit-tdd/registry-control-plane.md @@ -37,6 +37,23 @@ models, routes, generated contracts, and build checks are the exact executable i ## Persistence and Security +D1 queries and writes use SQLAlchemy Core table mappings and expressions in +`service/database.py` and `service/repository.py`. SQLAlchemy's SQLite compiler +generates SQL and ordered bound parameters, including expanded `IN` predicates. +The Repository passes these directly to the asynchronous D1 binding and retains +native `batch()` for atomic identity and association creation. It does not use +an ORM Session or emulate connection transactions: the evaluated +`sqlalchemy-cloudflare-d1` Worker driver implements commit and rollback as no-ops. +The binding bridge owns only compilation and execution, not a query language, +DBAPI driver, identity map, or transaction engine. + +Checked-in migrations remain the schema authority. Core mappings describe the +existing columns and foreign-key joins; the service never calls `create_all()` +or changes the schema at startup. Ordinary service and maintenance queries must +use expressions instead of handwritten SQL. Persisted text and integer columns +use native scalar values; additional database types require verifying their +binding and result conversion at the D1 boundary. + D1 owns identity, lifecycle, association metadata, and hashed namespace credentials. R2 owns immutable private bytes. Admission writes staging bytes before a conditional D1 exposure transition; unreachable staging objects are diff --git a/docs/40-deployment/local-development.md b/docs/40-deployment/local-development.md index 2a74927..c27c94e 100644 --- a/docs/40-deployment/local-development.md +++ b/docs/40-deployment/local-development.md @@ -20,6 +20,23 @@ UV_FIND_LINKS="$PWD/dist" pdm run pywrangler dev --port 8791 \ `.wrangler/`, virtual environments, `python_modules/`, dependency directories, build output, and secrets are local state and must remain uncommitted. +`pnpm check` finishes with `pnpm registry:check`. This acceptance journey runs the +built Python Worker on loopback against a temporary D1 database and R2 bucket, +applies the real migrations, and exercises authenticated preparation, native +uploads, publication, withdrawal/restoration, private pagination, and public +reads. It also injects an association-insert failure to prove the identity batch +rolls back. These persistence behaviors cannot be established by types or SQL +compilation alone, and errors can expose private releases or strand immutable +identities. This check belongs to the Registry control plane; retain it while +the D1 binding integration owns these semantics. + +Run `pnpm worker:build && pnpm registry:check` to repeat this journey in isolation. +The script accepts no remote target or credentials, creates its fixtures only +in a temporary directory, stops its local Worker, and removes all database and +object state on exit. Schema fault injection is confined to that disposable +database. SQLAlchemy 2.0.52 is locked for both normal Python and the Worker; the +Worker uses its pure-Python wheel without a native asynchronous ORM driver. + ## Package release intent The Python Toolkit and Core Runtime use the pinned Changie configuration and diff --git a/package.json b/package.json index 2db2c03..5577ec0 100644 --- a/package.json +++ b/package.json @@ -9,12 +9,13 @@ "scripts": { "build": "SOURCE_DATE_EPOCH=315532800 pdm build --no-sdist --dest \"$INIT_CWD/dist\" && SOURCE_DATE_EPOCH=315532800 pdm build --project toolkit --no-sdist --no-clean --dest \"$INIT_CWD/dist\" && SOURCE_DATE_EPOCH=315532800 pdm build --project runtimes/core-py --no-sdist --no-clean --dest \"$INIT_CWD/dist\" && pnpm --filter @inkcre/extension-runtime-client-web build", "changeset": "changeset", - "check": "pnpm web:check && pnpm contracts:check && pnpm format:check && pnpm lint && pnpm type-check && pnpm build && pnpm worker:build", + "check": "pnpm web:check && pnpm contracts:check && pnpm format:check && pnpm lint && pnpm type-check && pnpm build && pnpm worker:build && pnpm registry:check", "contracts:check": "pdm run python scripts/generate_contracts.py --check && pdm run python runtimes/core-py/scripts/generate_contracts.py --check && pnpm --filter @inkcre/extension-runtime-client-web check:bindings", "contracts:generate": "pdm run python scripts/generate_contracts.py && pdm run python runtimes/core-py/scripts/generate_contracts.py && pnpm --filter @inkcre/extension-runtime-client-web generate:bindings", "format": "pdm run ruff format . && prettier --write .", "format:check": "pdm run ruff format --check . && prettier --check .", "lint": "pdm run ruff check .", + "registry:check": "pdm run python scripts/check_registry.py", "release:version": "changeset version", "type-check": "pdm run pyright && pnpm --filter @inkcre/extension-runtime-client-web type-check", "worker:build": "lock_backup=$(mktemp) && cp pylock.toml \"$lock_backup\" && trap 'cp \"$lock_backup\" pylock.toml; rm -f \"$lock_backup\"' EXIT && SOURCE_DATE_EPOCH=315532800 pdm build --project toolkit --no-sdist --dest \"$INIT_CWD/dist\" && SOURCE_DATE_EPOCH=315532800 pdm build --no-sdist --no-clean --dest \"$INIT_CWD/dist\" && UV_FIND_LINKS=\"$INIT_CWD/dist\" pdm run pywrangler deploy --dry-run --outdir dist/worker", diff --git a/pdm.lock b/pdm.lock index 24dd824..052f5a8 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:8567468fc6bef4e8051d0184e013c914ae91fd9b9fae670a03d6524fb7765eae" +content_hash = "sha256:d8250967746dca21db6faad1a9cb4711234e9a3e38525af63c109bc8078929f3" [[metadata.targets]] requires_python = ">=3.12,<3.14" @@ -261,6 +261,37 @@ files = [ {file = "genson-1.4.0.tar.gz", hash = "sha256:bc7f1c1bae87a21ca44d81149aec95a3f4468d676de9b8b08caa064f3c50b3da"}, ] +[[package]] +name = "greenlet" +version = "3.5.5" +requires_python = ">=3.10" +summary = "Lightweight in-process concurrent programming" +groups = ["default"] +marker = "platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\"" +files = [ + {file = "greenlet-3.5.5-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:49520f0c95a48b42cf55414b8e8479beb274ea70431afc33e3f79903c71f4380"}, + {file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55272212cbc5f43d1d723725ab931f1939969b7e9523882ca58b55061769d053"}, + {file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655bca754a2ef4efcb0eb48a94d3f4593536d0f3d48f8ed44343c01d16a92f95"}, + {file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ca5d6ae0739e5764f2cfcfaa562ac5a990cbdaedca93251c5e3cf07c362371f"}, + {file = "greenlet-3.5.5-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:147b25a42e5ca5be3d42356e8f608b37af715a1c196e9bf9d1627f3341adfe1d"}, + {file = "greenlet-3.5.5-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:1b5ed9162c0c098e0bbc2cf88a94f433c1b8926f831745252e099e5d83e17759"}, + {file = "greenlet-3.5.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:27493374cff1d1b7919dc8126547f2aea582737e3046147b434b1e12de56389b"}, + {file = "greenlet-3.5.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:12e2ee66c2aba86133f10fd99d6a8856c6d351ffb7be0e4d52ef2cc5fbb705b2"}, + {file = "greenlet-3.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:49ddacd36af37735fab103846f4ee4d18a492dde72730d1699c0c8ebe30d9f18"}, + {file = "greenlet-3.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:5f1b1ff4828cdc1aba4266aff814085d04a1d07959287219af021b838b265d52"}, + {file = "greenlet-3.5.5-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:b79fd2a5bc099b5e744f34c4c9a58954a5f4cb7529fb4b6e8446057d61b6edaa"}, + {file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:634cf15a233a949136879dd388e25d3296e16f3f1e217d2456797b8579ebc6ed"}, + {file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:499adea519f748407fc6806d20eedabac2884fd73b9f38d81236e190ba20dfef"}, + {file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7278591501941bb2456af102bb9cd59aab48c6cfd6e2dd68fa1290bb0c49a42"}, + {file = "greenlet-3.5.5-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2eabb980975cba5b93a95f6f69287d05fc05ac955bfd6a320a7c083eeb52c0b0"}, + {file = "greenlet-3.5.5-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:4dfc7c4470354e7b09184d1a3a985761053a2fd694ddb5b5c80242afc2c8c90b"}, + {file = "greenlet-3.5.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03115c2e0a371999bf8ae616aa8d653f96641d4705c457aebaa187276e9f7537"}, + {file = "greenlet-3.5.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4441153ffba21b90d3ca89fe3d31f5c093ae6c0bf0cfdfc98f54cde22f95b62e"}, + {file = "greenlet-3.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:95c5b1f4b3a193f8a0c2de4bfdcb48d119f7f1063941f1de1f2168051b3e52dd"}, + {file = "greenlet-3.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:1af90aa4bc129883b340cdd6957a3bc74f60528a4993bbd1f53aaebe1d9981cc"}, + {file = "greenlet-3.5.5.tar.gz", hash = "sha256:adb4bae02e91a8e863e48b177e4014bdcac8a6b5e047ea1df687a61534b85e6c"}, +] + [[package]] name = "h11" version = "0.16.0" @@ -857,6 +888,36 @@ files = [ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] +[[package]] +name = "sqlalchemy" +version = "2.0.52" +requires_python = ">=3.7" +summary = "Database Abstraction Library" +groups = ["default"] +dependencies = [ + "greenlet>=1; platform_machine == \"win32\" or platform_machine == \"WIN32\" or platform_machine == \"AMD64\" or platform_machine == \"amd64\" or platform_machine == \"x86_64\" or platform_machine == \"ppc64le\" or platform_machine == \"aarch64\"", + "importlib-metadata; python_version < \"3.8\"", + "typing-extensions>=4.6.0", +] +files = [ + {file = "sqlalchemy-2.0.52-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:be8c49131665dfe2cc74c498aa1240ffb548d0fd901325dd11c2c7a18956f727"}, + {file = "sqlalchemy-2.0.52-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b2d9e507a458832adcfbd8af6e2036ddf069b7710b799448542ebccae2dceee"}, + {file = "sqlalchemy-2.0.52-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8738008376d22f30f411ea3efecf39b51110b6996d80bb73786f30bcfdd5fd3b"}, + {file = "sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37a4d548327b6cab9c7d8cdb4e0e82feabee0110c4d150059068e2d1cfbd99ee"}, + {file = "sqlalchemy-2.0.52-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e49f51a5d59857a7a0dcaf9469febf7197d9394bd88f00d69c2c4e848112cdbf"}, + {file = "sqlalchemy-2.0.52-cp312-cp312-win32.whl", hash = "sha256:afda3ec521d0517d0de783fc70030775841900896d832de5bbd066549290470e"}, + {file = "sqlalchemy-2.0.52-cp312-cp312-win_amd64.whl", hash = "sha256:2d5e53e36e37129fe0be8b9d08b6e4052c10a963ee6cda56c8c10dcc194b99ca"}, + {file = "sqlalchemy-2.0.52-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2eb3c6a64b1bfe6704777cfd504e7b8ad093a5f3e03ce67663a5e6742f294e43"}, + {file = "sqlalchemy-2.0.52-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:923bb183c1dc64fdf7b717965e3d59938ec4f8b8710b419a21ce403e5da9a9e1"}, + {file = "sqlalchemy-2.0.52-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:651d6d8782e80679e6151707c7b490834d46ada526328895abf567f25e63d29c"}, + {file = "sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b08cddb8989775e3c88799d86704bdfc3ee6e9846118201aa5997f16f27e3a15"}, + {file = "sqlalchemy-2.0.52-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ab66fa9618269390d4dfa222f2f2f88f7bc4bf5da13905131b818217db7e8057"}, + {file = "sqlalchemy-2.0.52-cp313-cp313-win32.whl", hash = "sha256:c63bda077685c85ca513286547a531ba57e7a68cf0a7ed3bafcc2bbd18896f4d"}, + {file = "sqlalchemy-2.0.52-cp313-cp313-win_amd64.whl", hash = "sha256:9876b09b9f1ce7398b0ffece585c0a911244c53191187341f6bcae640e133751"}, + {file = "sqlalchemy-2.0.52-py3-none-any.whl", hash = "sha256:3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89"}, + {file = "sqlalchemy-2.0.52.tar.gz", hash = "sha256:5e2d46356ac2ccb7d268ab6c2319ac6a2b42f1b8d5fd8bd3d46855cd82abee97"}, +] + [[package]] name = "starlette" version = "1.6.0" diff --git a/pylock.toml b/pylock.toml index f2a670e..0983d80 100644 --- a/pylock.toml +++ b/pylock.toml @@ -73,6 +73,11 @@ name = "semantic-version" version = "2.10.0" wheels = [{ url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl", upload-time = 2022-05-26T13:35:21Z, size = 15552, hashes = { sha256 = "de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177" } }] +[[packages]] +name = "sqlalchemy" +version = "2.0.52" +wheels = [{ url = "https://files.pythonhosted.org/packages/b3/3f/3582293d1e185e71d19d7c731c3e2ee20ba21981c4a1115c0806c1f62120/sqlalchemy-2.0.52-py3-none-any.whl", upload-time = 2026-08-11T20:47:21Z, size = 1950700, hashes = { sha256 = "3b81b8363a919ce53453591cdb93702e6bd54ade6c4fa2f468fc053baee5ed89" } }] + [[packages]] name = "starlette" version = "1.6.0" diff --git a/pyproject.toml b/pyproject.toml index daa972b..9ac2aa5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,7 @@ dependencies = [ "packaging>=25,<27", "python-multipart>=0.0.22,<0.1", "jinja2<4,>=3.1.6", + "sqlalchemy<2.1,>=2.0.39", ] [project.urls] diff --git a/scripts/check_registry.py b/scripts/check_registry.py new file mode 100644 index 0000000..8243dee --- /dev/null +++ b/scripts/check_registry.py @@ -0,0 +1,335 @@ +"""Exercise the built Worker against disposable local D1/R2, without credentials. + +Run after `pnpm worker:build`. Fixtures and injected database failure exist only +inside a TemporaryDirectory; this command cannot target a deployed Registry. +""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import secrets +import shutil +import signal +import socket +import subprocess +import tempfile +import time +import zipfile +from pathlib import Path + +import httpx +from sqlalchemy import DDL, create_engine, insert, select, update + +from inkcre_extension_registry.service import database as db + +ROOT = Path(__file__).resolve().parents[1] + + +def archive(files: dict[str, str]) -> bytes: + output = io.BytesIO() + with zipfile.ZipFile(output, "w") as bundle: + for name, content in files.items(): + bundle.writestr(name, content) + return output.getvalue() + + +def journey(client: httpx.Client, engine, token: str, other_token: str) -> None: + authorization = {"Authorization": f"Bearer {token}"} + + def request(method: str, path: str, expected: int = 200, *, private=False, **kwargs): + response = client.request(method, path, headers=authorization if private else {}, **kwargs) + assert response.status_code == expected, ( + f"{method} {path}: expected {expected}, got {response.status_code}: " + f"{response.text[:800]}" + ) + return response + + release_path = "/v1/extensions/check/extension/releases" + version_path = release_path + "/1.0.0" + source = {"source_repository": "https://example.invalid/check", "source_revision": "rev' ? --"} + association = { + "nickname": "Publisher's extension", + "version": "1.0.0", + "python": { + **source, + "project": "check-extension", + "host_sdk": "core-py", + "host_sdk_version": "^1.0.0", + "entry_point": { + "group": "inkcre.core.extensions", + "name": "check", + "object": "check:Extension", + }, + }, + "module_federation": {**source, "host_sdk": "@inkcre/core", "host_sdk_version": "^1.0.0"}, + } + assert request("GET", "/v1/extensions").json() == [] + request("GET", "/") + request("GET", "/v1/publisher", 401) + request("POST", "/v1/extensions/other/extension/releases", 403, private=True, json=association) + request("POST", release_path, private=True, json=association) + request("POST", release_path, private=True, json=association) + request("POST", release_path, 409, private=True, json={**association, "nickname": "Changed"}) + request("GET", version_path, 404) + request("POST", version_path + "/publish", 409, private=True) + workspace = request("GET", "/v1/publisher", private=True).json() + assert len(workspace["releases"]) == 1 + assert not workspace["releases"][0]["python_uploaded"] + assert not workspace["releases"][0]["web_uploaded"] + with engine.connect() as connection: + row = connection.execute(select(db.python_distributions)).mappings().one() + assert row["source_revision"] == source["source_revision"] and row["build_id"] is None + + metadata = "Metadata-Version: 2.3\nName: check-extension\nVersion: 1.0.0\n\n" + wheel = archive( + { + "check.py": "class Extension: pass\n", + "check_extension-1.0.0.dist-info/METADATA": metadata, + "check_extension-1.0.0.dist-info/entry_points.txt": ( + "[inkcre.core.extensions]\ncheck = check:Extension\n" + ), + "check_extension-1.0.0.dist-info/WHEEL": ( + "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n" + ), + "check_extension-1.0.0.dist-info/RECORD": "", + } + ) + wheel_name = "check_extension-1.0.0-py3-none-any.whl" + for _ in range(2): + request( + "POST", + "/legacy/", + private=True, + data={ + ":action": "file_upload", + "protocol_version": "1", + "name": "check-extension", + "version": "1.0.0", + "filetype": "bdist_wheel", + "metadata_version": "2.3", + "sha256_digest": hashlib.sha256(wheel).hexdigest(), + }, + files={"content": (wheel_name, wheel)}, + ) + snapshot = archive( + { + "mf-manifest.json": json.dumps( + { + "metaData": { + "publicPath": "./", + "remoteEntry": {"name": "remoteEntry.js", "path": ""}, + } + } + ), + "remoteEntry.js": "export const check = true;", + } + ) + for _ in range(2): + request( + "POST", + version_path + "/module-federation", + private=True, + files={"content": ("extension.zip", snapshot)}, + ) + uploaded = request("GET", "/v1/publisher", private=True).json()["releases"][0] + assert uploaded["python_uploaded"] and uploaded["web_uploaded"] + request("POST", version_path + "/publish", private=True) + request("POST", version_path + "/publish", private=True) + assert request("GET", "/v1/extensions").json()[0]["name"] == "check/extension" + request("GET", "/explore/check/extension?version=1.0.0") + request("GET", "/v1/extensions/check/extension") + assert request("GET", version_path).json()["state"] == "published" + request("GET", "/simple/") + request("GET", "/simple/check-extension/") + package_path = f"/packages/check-extension/1.0.0/{wheel_name}" + web_path = "/extensions/check/extension/1.0.0/module-federation/remoteEntry.js" + assert request("GET", package_path).content == wheel + assert request("GET", package_path + ".metadata").text == metadata + request("GET", web_path) + request("GET", web_path.replace("remoteEntry.js", "mf-manifest.json")) + + reason = "Publisher's withdrawal ?" + request("POST", version_path + "/yank", private=True, json={"reason": reason}) + assert request("GET", version_path).json()["state"] == "yanked" + assert request("GET", "/v1/extensions").json() == [] + request("GET", package_path) + simple = client.get( + "/simple/check-extension/", headers={"Accept": "application/vnd.pypi.simple.v1+json"} + ) + assert simple.json()["files"][0]["yanked"] == reason + request("POST", version_path + "/unyank", private=True) + assert request("GET", version_path).json()["state"] == "published" + with engine.connect() as connection: + row = connection.execute(select(db.releases)).mappings().one() + assert row["yank_reason"] is None and row["published_at"] and row["updated_at"] + + # Exercise correlated readiness expressions and the private pagination boundary. + for minor in range(1, 11): + request( + "POST", + release_path, + private=True, + json={ + "nickname": association["nickname"], + "version": f"1.{minor}.0", + "module_federation": association["module_federation"], + }, + ) + first = request("GET", "/v1/publisher", private=True).json() + last = request("GET", "/v1/publisher?offset=10", private=True).json() + assert len(first["releases"]) == 10 and first["next_offset"] == 10 + assert len(last["releases"]) == 1 and last["next_offset"] is None + entries = first["releases"] + last["releases"] + assert len({entry["version"] for entry in entries}) == 11 + assert sum(entry["python_uploaded"] for entry in entries) == 1 + assert sum(entry["web_uploaded"] for entry in entries) == 1 + other = client.get("/v1/publisher", headers={"Authorization": f"Bearer {other_token}"}) + assert other.status_code == 200 and other.json()["namespace"] == "other" + assert other.json()["releases"] == [] + + # Fail an association insert after extension and release inserts. + # Native D1 batch must roll back both identities; HTTP cannot inject this failure. + with engine.begin() as connection: + connection.execute( + DDL(""" + CREATE TRIGGER fail_association BEFORE INSERT ON python_distributions + WHEN NEW.extension_name = 'check/atomic' + BEGIN SELECT RAISE(ABORT, 'injected association failure'); END + """) + ) + request( + "POST", + "/v1/extensions/check/atomic/releases", + 409, + private=True, + json={**association, "version": "2.0.0"}, + ) + with engine.connect() as connection: + assert ( + connection.execute( + select(db.extensions).where(db.extensions.c.name == "check/atomic") + ).first() + is None + ) + assert ( + connection.execute( + select(db.releases).where(db.releases.c.extension_name == "check/atomic") + ).first() + is None + ) + + with engine.begin() as connection: + connection.execute( + update(db.releases).where(db.releases.c.version == "1.0.0").values(state="blocked") + ) + request("GET", version_path, 451) + request("GET", package_path, 451) + request("GET", web_path, 451) + assert "check-extension" not in request("GET", "/simple/").text + with engine.begin() as connection: + connection.execute(update(db.credentials).values(disabled=1)) + request("GET", "/v1/publisher", 401, private=True) + + +def main() -> None: + with tempfile.TemporaryDirectory(prefix="registry-check-") as directory: + root = Path(directory) + shutil.copytree(ROOT / "dist/worker", root / "worker") + shutil.move(root / "worker/python_modules", root / "python_modules") + shutil.copytree(ROOT / "migrations", root / "migrations") + with socket.socket() as listener: + listener.bind(("127.0.0.1", 0)) + port = listener.getsockname()[1] + origin = f"http://127.0.0.1:{port}" + config = json.loads((ROOT / ".github/preview/wrangler.json").read_text()) + config.update( + name="registry-check", + main="worker/worker.py", + vars={"PUBLIC_ORIGIN": origin}, + d1_databases=[ + { + "binding": "DB", + "database_name": "registry-check", + "database_id": "local", + "migrations_dir": "migrations", + } + ], + r2_buckets=[{"binding": "ARTIFACTS", "bucket_name": "registry-check"}], + ) + config_path = root / "wrangler.json" + config_path.write_text(json.dumps(config)) + state = root / "state" + command = ["pnpm", "exec", "wrangler"] + options = ["--config", str(config_path), "--persist-to", str(state)] + subprocess.run( + [*command, "d1", "migrations", "apply", "DB", "--local", *options], + cwd=ROOT, + capture_output=True, + text=True, + check=True, + ) + databases = [ + path for path in (state / "v3/d1").rglob("*.sqlite") if path.name != "metadata.sqlite" + ] + assert len(databases) == 1, "Expected exactly one disposable local D1 database" + engine = create_engine(f"sqlite:///{databases[0]}") + token = secrets.token_urlsafe(32) + other_token = secrets.token_urlsafe(32) + try: + with engine.begin() as connection: + connection.execute(insert(db.namespaces), [{"name": "check"}, {"name": "other"}]) + connection.execute( + insert(db.credentials).values( + token_hash=hashlib.sha256(token.encode()).hexdigest(), + namespace="check", + label="Disposable check", + ) + ) + connection.execute( + insert(db.credentials).values( + token_hash=hashlib.sha256(other_token.encode()).hexdigest(), + namespace="other", + label="Disposable isolation check", + ) + ) + with (root / "worker.log").open("w+") as log: + process = subprocess.Popen( + [*command, "dev", "--ip", "127.0.0.1", "--port", str(port), *options], + cwd=ROOT, + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + try: + with httpx.Client(base_url=origin, timeout=15) as client: + deadline = time.monotonic() + 60 + while time.monotonic() < deadline: + if process.poll() is not None: + raise RuntimeError("Local Worker exited before becoming ready") + try: + if client.get("/livez", timeout=1).status_code == 200: + break + except httpx.TransportError: + pass + time.sleep(0.5) + else: + raise RuntimeError("Local Worker did not become ready") + journey(client, engine, token, other_token) + print("Registry persistence checks passed against local Worker, D1 and R2.") + except BaseException: + log.seek(0) + print(log.read()[-12000:]) + raise + finally: + if process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + process.wait(timeout=15) + finally: + engine.dispose() + + +if __name__ == "__main__": + main() diff --git a/scripts/registry_preview.py b/scripts/registry_preview.py index 467075c..a1b1f9e 100644 --- a/scripts/registry_preview.py +++ b/scripts/registry_preview.py @@ -18,6 +18,10 @@ from pathlib import Path import httpx +from sqlalchemy import delete +from sqlalchemy.dialects.sqlite import insert + +from inkcre_extension_registry.service.database import compile_d1, credentials, namespaces ROOT = Path(__file__).resolve().parents[1] WRANGLER = ["pnpm", "exec", "wrangler"] @@ -91,15 +95,18 @@ def deploy(client: httpx.Client, name: str, origin: str, artifact: Path, revisio config_path = artifact / "wrangler.json" config_path.write_text(json.dumps(config)) wrangler("d1", "migrations", "apply", "DB", "--remote", "--config", str(config_path)) - for sql, params in [ - ("INSERT OR IGNORE INTO namespaces(name) VALUES ('demo')", []), - ("DELETE FROM credentials WHERE namespace = 'demo' AND label = 'PR reviewer'", []), - ( - "INSERT INTO credentials(token_hash, namespace, label) " - "VALUES (?, 'demo', 'PR reviewer')", - [hashlib.sha256(token.encode()).hexdigest()], + for statement in [ + insert(namespaces).values(name="demo").on_conflict_do_nothing(), + delete(credentials).where( + credentials.c.namespace == "demo", credentials.c.label == "PR reviewer" + ), + insert(credentials).values( + token_hash=hashlib.sha256(token.encode()).hexdigest(), + namespace="demo", + label="PR reviewer", ), ]: + sql, params = compile_d1(statement) api(client, "POST", f"d1/database/{db['uuid']}/query", json={"sql": sql, "params": params}) print( wrangler( diff --git a/src/inkcre_extension_registry/service/database.py b/src/inkcre_extension_registry/service/database.py new file mode 100644 index 0000000..f339deb --- /dev/null +++ b/src/inkcre_extension_registry/service/database.py @@ -0,0 +1,118 @@ +"""SQLAlchemy mappings for the existing D1 schema and its binding compiler. + +Migrations own DDL. These tables describe the columns and joins used by the +Registry; they are never used to create or alter a database at runtime. +""" + +from __future__ import annotations + +from sqlalchemy import Column, ForeignKey, ForeignKeyConstraint, Integer, MetaData, Table, Text +from sqlalchemy.dialects.sqlite import dialect +from sqlalchemy.sql import ClauseElement +from sqlalchemy.sql.compiler import SQLCompiler + +metadata = MetaData() +namespaces = Table( + "namespaces", + metadata, + Column("name", Text, primary_key=True), + Column("status", Text), + Column("created_at", Text), +) +credentials = Table( + "credentials", + metadata, + Column("token_hash", Text, primary_key=True), + Column("namespace", Text, ForeignKey("namespaces.name")), + Column("label", Text), + Column("disabled", Integer), + Column("created_at", Text), +) +extensions = Table( + "extensions", + metadata, + Column("name", Text, primary_key=True), + Column("namespace", Text, ForeignKey("namespaces.name")), + Column("nickname", Text), + Column("publisher_metadata_json", Text), + Column("created_at", Text), +) +releases = Table( + "releases", + metadata, + Column("extension_name", Text, ForeignKey("extensions.name"), primary_key=True), + Column("version", Text, primary_key=True), + Column("state", Text), + Column("yank_reason", Text), + Column("created_at", Text), + Column("published_at", Text), + Column("updated_at", Text), +) +python_distributions = Table( + "python_distributions", + metadata, + Column("extension_name", Text, primary_key=True), + Column("release_version", Text, primary_key=True), + Column("normalized_project", Text), + Column("project_version", Text), + Column("host_sdk", Text), + Column("host_sdk_range", Text), + Column("entry_group", Text), + Column("entry_name", Text), + Column("entry_object", Text), + Column("source_repository", Text), + Column("source_revision", Text), + Column("build_id", Text), + Column("created_at", Text), + ForeignKeyConstraint( + ["extension_name", "release_version"], ["releases.extension_name", "releases.version"] + ), +) +python_files = Table( + "python_files", + metadata, + Column("normalized_project", Text, primary_key=True), + Column("project_version", Text, primary_key=True), + Column("filename", Text, primary_key=True), + Column("sha256", Text), + Column("size", Integer), + Column("filetype", Text), + Column("requires_python", Text), + Column("core_metadata_sha256", Text), + Column("r2_key", Text), + Column("metadata_r2_key", Text), + Column("uploaded_at", Text), + ForeignKeyConstraint( + ["normalized_project", "project_version"], + ["python_distributions.normalized_project", "python_distributions.project_version"], + ), +) +module_federation_distributions = Table( + "module_federation_distributions", + metadata, + Column("extension_name", Text, primary_key=True), + Column("release_version", Text, primary_key=True), + Column("host_sdk", Text), + Column("host_sdk_range", Text), + Column("source_repository", Text), + Column("source_revision", Text), + Column("build_id", Text), + Column("manifest_r2_key", Text), + Column("asset_paths_json", Text), + Column("internal_snapshot_hash", Text), + Column("created_at", Text), + Column("uploaded_at", Text), + ForeignKeyConstraint( + ["extension_name", "release_version"], ["releases.extension_name", "releases.version"] + ), +) + +_DIALECT = dialect(paramstyle="qmark") + + +def compile_d1(statement: ClauseElement) -> tuple[str, list[object]]: + """Compile bound parameters in SQLite order, including expanded IN lists.""" + compiled = statement.compile(dialect=_DIALECT, compile_kwargs={"render_postcompile": True}) + assert isinstance(compiled, SQLCompiler) + assert compiled.positiontup is not None and compiled.params is not None + return str(compiled), [compiled.params[name] for name in compiled.positiontup] diff --git a/src/inkcre_extension_registry/service/repository.py b/src/inkcre_extension_registry/service/repository.py index e740789..7ad58f8 100644 --- a/src/inkcre_extension_registry/service/repository.py +++ b/src/inkcre_extension_registry/service/repository.py @@ -5,6 +5,9 @@ from typing import Any from inkcre_extension_toolkit.simple import PythonFileRecord +from sqlalchemy import case, func, or_, select, update +from sqlalchemy.dialects.sqlite import insert +from sqlalchemy.sql import ClauseElement from ..contracts.models import ( ExtensionRecord, @@ -21,6 +24,7 @@ normalize_project_name, python_project_version, ) +from . import database as db class RegistryConflictError(RuntimeError): @@ -111,68 +115,67 @@ def __init__(self, env: Any) -> None: self.db = env.DB self.artifacts = env.ARTIFACTS + def _prepare(self, statement: ClauseElement) -> Any: + sql, parameters = db.compile_d1(statement) + prepared = self.db.prepare(sql) + return prepared.bind(*parameters) if parameters else prepared + async def authenticate(self, token_hash: str) -> str | None: - row = ( - await self.db.prepare( - "SELECT c.namespace FROM credentials c JOIN namespaces n " - "ON n.name = c.namespace WHERE c.token_hash = ?1 " - "AND c.disabled = 0 AND n.status = 'active'" - ) - .bind(token_hash) - .first() - ) + row = await self._prepare( + select(db.credentials.c.namespace) + .join(db.namespaces) + .where( + db.credentials.c.token_hash == token_hash, + db.credentials.c.disabled == 0, + db.namespaces.c.status == "active", + ) + ).first() return _column(row, "namespace") async def _extension_row(self, extension_name: str) -> Any: - return ( - await self.db.prepare( - "SELECT name, namespace, nickname FROM extensions WHERE name = ?1" + return await self._prepare( + select(db.extensions.c.name, db.extensions.c.namespace, db.extensions.c.nickname).where( + db.extensions.c.name == extension_name ) - .bind(extension_name) - .first() - ) + ).first() async def _release_row(self, extension_name: str, version: str) -> Any: - return ( - await self.db.prepare( - "SELECT e.nickname, r.extension_name, r.version, r.state, r.yank_reason, " - "r.created_at " - "FROM releases r JOIN extensions e ON e.name = r.extension_name " - "WHERE r.extension_name = ?1 AND r.version = ?2" - ) - .bind(extension_name, version) - .first() - ) + return await self._prepare( + select( + db.extensions.c.nickname, + db.releases.c.extension_name, + db.releases.c.version, + db.releases.c.state, + db.releases.c.yank_reason, + db.releases.c.created_at, + ) + .select_from(db.releases.join(db.extensions)) + .where(db.releases.c.extension_name == extension_name, db.releases.c.version == version) + ).first() async def _python_row(self, extension_name: str, version: str) -> Any: - return ( - await self.db.prepare( - "SELECT * FROM python_distributions WHERE extension_name = ?1 " - "AND release_version = ?2" + return await self._prepare( + select(db.python_distributions).where( + db.python_distributions.c.extension_name == extension_name, + db.python_distributions.c.release_version == version, ) - .bind(extension_name, version) - .first() - ) + ).first() async def _module_federation_row(self, extension_name: str, version: str) -> Any: - return ( - await self.db.prepare( - "SELECT * FROM module_federation_distributions WHERE extension_name = ?1 " - "AND release_version = ?2" + return await self._prepare( + select(db.module_federation_distributions).where( + db.module_federation_distributions.c.extension_name == extension_name, + db.module_federation_distributions.c.release_version == version, ) - .bind(extension_name, version) - .first() - ) + ).first() async def _python_project_owner(self, normalized_project: str, version: str) -> str | None: - row = ( - await self.db.prepare( - "SELECT extension_name FROM python_distributions " - "WHERE normalized_project = ?1 AND project_version = ?2" + row = await self._prepare( + select(db.python_distributions.c.extension_name).where( + db.python_distributions.c.normalized_project == normalized_project, + db.python_distributions.c.project_version == version, ) - .bind(normalized_project, version) - .first() - ) + ).first() return _column(row, "extension_name") @staticmethod @@ -245,52 +248,50 @@ async def prepare_release( raise RegistryStateError(f"cannot append an association to a {state} release") statements = [ - self.db.prepare( - "INSERT INTO extensions(name, namespace, nickname) VALUES (?1, ?2, ?3) " - "ON CONFLICT(name) DO NOTHING" - ).bind(extension_name, namespace, request.nickname), - self.db.prepare( - "INSERT INTO releases(extension_name, version) VALUES (?1, ?2) " - "ON CONFLICT(extension_name, version) DO NOTHING" - ).bind(extension_name, request.version), + self._prepare( + insert(db.extensions) + .values(name=extension_name, namespace=namespace, nickname=request.nickname) + .on_conflict_do_nothing(index_elements=[db.extensions.c.name]) + ), + self._prepare( + insert(db.releases) + .values(extension_name=extension_name, version=request.version) + .on_conflict_do_nothing( + index_elements=[db.releases.c.extension_name, db.releases.c.version] + ) + ), ] if adds_python and request.python is not None: statements.append( - self.db.prepare( - "INSERT INTO python_distributions(extension_name, release_version, " - "normalized_project, project_version, host_sdk, host_sdk_range, " - "entry_group, entry_name, entry_object, source_repository, " - "source_revision, build_id) " - "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)" - ).bind( - extension_name, - request.version, - normalize_project_name(request.python.project), - python_project_version(request.version), - request.python.host_sdk, - request.python.host_sdk_version, - request.python.entry_point.group, - request.python.entry_point.name, - request.python.entry_point.object, - request.python.source_repository, - request.python.source_revision, - request.python.build_id, + self._prepare( + insert(db.python_distributions).values( + extension_name=extension_name, + release_version=request.version, + normalized_project=normalize_project_name(request.python.project), + project_version=python_project_version(request.version), + host_sdk=request.python.host_sdk, + host_sdk_range=request.python.host_sdk_version, + entry_group=request.python.entry_point.group, + entry_name=request.python.entry_point.name, + entry_object=request.python.entry_point.object, + source_repository=request.python.source_repository, + source_revision=request.python.source_revision, + build_id=request.python.build_id, + ) ) ) if adds_mf and request.module_federation is not None: statements.append( - self.db.prepare( - "INSERT INTO module_federation_distributions(extension_name, " - "release_version, host_sdk, host_sdk_range, source_repository, " - "source_revision, build_id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)" - ).bind( - extension_name, - request.version, - request.module_federation.host_sdk, - request.module_federation.host_sdk_version, - request.module_federation.source_repository, - request.module_federation.source_revision, - request.module_federation.build_id, + self._prepare( + insert(db.module_federation_distributions).values( + extension_name=extension_name, + release_version=request.version, + host_sdk=request.module_federation.host_sdk, + host_sdk_range=request.module_federation.host_sdk_version, + source_repository=request.module_federation.source_repository, + source_revision=request.module_federation.source_revision, + build_id=request.module_federation.build_id, + ) ) ) try: @@ -328,17 +329,15 @@ async def get_release( python_row = await self._python_row(extension_name, version) if public and python_row is not None: - file_row = ( - await self.db.prepare( - "SELECT 1 AS ready FROM python_files WHERE normalized_project = ?1 " - "AND project_version = ?2 LIMIT 1" - ) - .bind( - _column(python_row, "normalized_project"), - _column(python_row, "project_version"), + file_row = await self._prepare( + select(db.python_files.c.filename) + .where( + db.python_files.c.normalized_project + == _column(python_row, "normalized_project"), + db.python_files.c.project_version == _column(python_row, "project_version"), ) - .first() - ) + .limit(1) + ).first() if file_row is None: python_row = None mf_row = await self._module_federation_row(extension_name, version) @@ -379,24 +378,38 @@ async def get_release( async def publisher_workspace(self, namespace: str, offset: int) -> PublisherWorkspace: # Ten records keep the existing descriptor reads within D1's per-request query budget. - result = ( - await self.db.prepare( - "SELECT r.extension_name, r.version, " - "EXISTS (SELECT 1 FROM python_distributions pd JOIN python_files pf " - "ON pf.normalized_project = pd.normalized_project AND pf.project_version = " - "pd.project_version WHERE pd.extension_name = r.extension_name " - "AND pd.release_version = r.version) AS python_uploaded, " - "EXISTS (SELECT 1 FROM module_federation_distributions mf " - "WHERE mf.extension_name = r.extension_name AND mf.release_version = r.version " - "AND mf.manifest_r2_key IS NOT NULL) AS web_uploaded " - "FROM releases r JOIN extensions e ON e.name = r.extension_name " - "WHERE e.namespace = ?1 ORDER BY r.created_at DESC, " - "r.extension_name, r.version DESC " - "LIMIT 11 OFFSET ?2" - ) - .bind(namespace, offset) - .all() - ) + result = await self._prepare( + select( + db.releases.c.extension_name, + db.releases.c.version, + select(db.python_files.c.filename) + .select_from(db.python_distributions.join(db.python_files)) + .where( + db.python_distributions.c.extension_name == db.releases.c.extension_name, + db.python_distributions.c.release_version == db.releases.c.version, + ) + .exists() + .label("python_uploaded"), + select(db.module_federation_distributions.c.extension_name) + .where( + db.module_federation_distributions.c.extension_name + == db.releases.c.extension_name, + db.module_federation_distributions.c.release_version == db.releases.c.version, + db.module_federation_distributions.c.manifest_r2_key.is_not(None), + ) + .exists() + .label("web_uploaded"), + ) + .select_from(db.releases.join(db.extensions)) + .where(db.extensions.c.namespace == namespace) + .order_by( + db.releases.c.created_at.desc(), + db.releases.c.extension_name, + db.releases.c.version.desc(), + ) + .limit(11) + .offset(offset) + ).all() rows = _results(result) releases = [] for row in rows[:10]: @@ -419,10 +432,17 @@ async def publisher_workspace(self, namespace: str, offset: int) -> PublisherWor ) async def list_extensions(self) -> list[ExtensionSummary]: - result = await self.db.prepare( - "SELECT e.name, e.nickname FROM extensions e " - "WHERE EXISTS (SELECT 1 FROM releases r WHERE r.extension_name = e.name " - "AND r.state = 'published') ORDER BY e.name" + result = await self._prepare( + select(db.extensions.c.name, db.extensions.c.nickname) + .where( + select(db.releases.c.extension_name) + .where( + db.releases.c.extension_name == db.extensions.c.name, + db.releases.c.state == "published", + ) + .exists() + ) + .order_by(db.extensions.c.name) ).all() return [ ExtensionSummary(name=_column(row, "name"), nickname=_column(row, "nickname")) @@ -433,14 +453,14 @@ async def get_extension(self, extension_name: str) -> ExtensionRecord | None: extension = await self._extension_row(extension_name) if extension is None: return None - versions = ( - await self.db.prepare( - "SELECT version FROM releases WHERE extension_name = ?1 AND state = 'published' " - "ORDER BY created_at DESC, version DESC" + versions = await self._prepare( + select(db.releases.c.version) + .where( + db.releases.c.extension_name == extension_name, + db.releases.c.state == "published", ) - .bind(extension_name) - .all() - ) + .order_by(db.releases.c.created_at.desc(), db.releases.c.version.desc()) + ).all() releases: list[ReleaseRecord] = [] for row in _results(versions): release = await self.get_release(extension_name, _column(row, "version")) @@ -466,22 +486,36 @@ async def publish(self, extension_name: str, version: str) -> ReleaseRecord: raise RegistryStateError( "Release native Distribution objects are not completely available" ) - result = ( - await self.db.prepare( - "UPDATE releases SET state = 'published', yank_reason = NULL, " - "published_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP " - "WHERE extension_name = ?1 AND version = ?2 AND state = 'preparing' " - "AND (EXISTS (SELECT 1 FROM python_distributions pd JOIN python_files pf " - "ON pf.normalized_project = pd.normalized_project " - "AND pf.project_version = pd.project_version " - "WHERE pd.extension_name = ?1 AND pd.release_version = ?2) " - "OR EXISTS (SELECT 1 FROM module_federation_distributions mf " - "WHERE mf.extension_name = ?1 AND mf.release_version = ?2 " - "AND mf.manifest_r2_key IS NOT NULL))" + result = await self._prepare( + update(db.releases) + .values( + state="published", + yank_reason=None, + published_at=func.current_timestamp(), + updated_at=func.current_timestamp(), ) - .bind(extension_name, version) - .run() - ) + .where( + db.releases.c.extension_name == extension_name, + db.releases.c.version == version, + db.releases.c.state == "preparing", + or_( + select(db.python_files.c.filename) + .select_from(db.python_distributions.join(db.python_files)) + .where( + db.python_distributions.c.extension_name == extension_name, + db.python_distributions.c.release_version == version, + ) + .exists(), + select(db.module_federation_distributions.c.extension_name) + .where( + db.module_federation_distributions.c.extension_name == extension_name, + db.module_federation_distributions.c.release_version == version, + db.module_federation_distributions.c.manifest_r2_key.is_not(None), + ) + .exists(), + ), + ) + ).run() if int(_column(_column(result, "meta"), "changes", 0)) != 1: raise RegistryStateError( "Release requires at least one validated native Distribution" @@ -492,17 +526,16 @@ async def publish(self, extension_name: str, version: str) -> ReleaseRecord: return published async def _release_objects_available(self, extension_name: str, version: str) -> bool: - python_result = ( - await self.db.prepare( - "SELECT pf.r2_key, pf.metadata_r2_key, pf.size FROM python_files pf " - "JOIN python_distributions pd " - "ON pd.normalized_project = pf.normalized_project " - "AND pd.project_version = pf.project_version " - "WHERE pd.extension_name = ?1 AND pd.release_version = ?2" - ) - .bind(extension_name, version) - .all() - ) + python_result = await self._prepare( + select( + db.python_files.c.r2_key, db.python_files.c.metadata_r2_key, db.python_files.c.size + ) + .join(db.python_distributions) + .where( + db.python_distributions.c.extension_name == extension_name, + db.python_distributions.c.release_version == version, + ) + ).all() python_rows = _results(python_result) for row in python_rows: archive = await self.artifacts.head(_column(row, "r2_key")) @@ -532,15 +565,15 @@ async def yank(self, extension_name: str, version: str, reason: str) -> ReleaseR if _column(row, "yank_reason") != reason: raise RegistryConflictError("Yank reason conflicts with the existing yank") elif state == "published": - await ( - self.db.prepare( - "UPDATE releases SET state = 'yanked', yank_reason = ?3, " - "updated_at = CURRENT_TIMESTAMP WHERE extension_name = ?1 " - "AND version = ?2 AND state = 'published'" + await self._prepare( + update(db.releases) + .values(state="yanked", yank_reason=reason, updated_at=func.current_timestamp()) + .where( + db.releases.c.extension_name == extension_name, + db.releases.c.version == version, + db.releases.c.state == "published", ) - .bind(extension_name, version, reason) - .run() - ) + ).run() else: raise RegistryStateError(f"cannot yank a {state} Release") yanked = await self.get_release(extension_name, version) @@ -554,15 +587,15 @@ async def unyank(self, extension_name: str, version: str) -> ReleaseRecord: raise RegistryNotFoundError("Release does not exist") state = _column(row, "state") if state == "yanked": - await ( - self.db.prepare( - "UPDATE releases SET state = 'published', yank_reason = NULL, " - "updated_at = CURRENT_TIMESTAMP WHERE extension_name = ?1 " - "AND version = ?2 AND state = 'yanked'" + await self._prepare( + update(db.releases) + .values(state="published", yank_reason=None, updated_at=func.current_timestamp()) + .where( + db.releases.c.extension_name == extension_name, + db.releases.c.version == version, + db.releases.c.state == "yanked", ) - .bind(extension_name, version) - .run() - ) + ).run() elif state != "published": raise RegistryStateError(f"cannot unyank a {state} Release") published = await self.get_release(extension_name, version) @@ -573,47 +606,40 @@ async def unyank(self, extension_name: str, version: str) -> ReleaseRecord: async def prepared_python_distribution( self, namespace: str, normalized_project: str, project_version: str ) -> PreparedPythonDistribution | None: - row = ( - await self.db.prepare( - "SELECT pd.*, r.state FROM python_distributions pd " - "JOIN releases r ON r.extension_name = pd.extension_name " - "AND r.version = pd.release_version " - "JOIN extensions e ON e.name = pd.extension_name " - "WHERE e.namespace = ?1 AND pd.normalized_project = ?2 " - "AND pd.project_version = ?3" - ) - .bind(namespace, normalized_project, project_version) - .first() - ) + row = await self._prepare( + select(db.python_distributions, db.releases.c.state) + .select_from(db.python_distributions.join(db.releases).join(db.extensions)) + .where( + db.extensions.c.namespace == namespace, + db.python_distributions.c.normalized_project == normalized_project, + db.python_distributions.c.project_version == project_version, + ) + ).first() return _prepared_python(row) if row is not None else None async def prepared_python_distributions( self, namespace: str, normalized_project: str ) -> list[PreparedPythonDistribution]: - result = ( - await self.db.prepare( - "SELECT pd.*, r.state FROM python_distributions pd " - "JOIN releases r ON r.extension_name = pd.extension_name " - "AND r.version = pd.release_version " - "JOIN extensions e ON e.name = pd.extension_name " - "WHERE e.namespace = ?1 AND pd.normalized_project = ?2" - ) - .bind(namespace, normalized_project) - .all() - ) + result = await self._prepare( + select(db.python_distributions, db.releases.c.state) + .select_from(db.python_distributions.join(db.releases).join(db.extensions)) + .where( + db.extensions.c.namespace == namespace, + db.python_distributions.c.normalized_project == normalized_project, + ) + ).all() return [_prepared_python(row) for row in _results(result)] async def _python_file_row( self, normalized_project: str, project_version: str, filename: str ) -> Any: - return ( - await self.db.prepare( - "SELECT * FROM python_files WHERE normalized_project = ?1 " - "AND project_version = ?2 AND filename = ?3" + return await self._prepare( + select(db.python_files).where( + db.python_files.c.normalized_project == normalized_project, + db.python_files.c.project_version == project_version, + db.python_files.c.filename == filename, ) - .bind(normalized_project, project_version, filename) - .first() - ) + ).first() async def put_python_file( self, @@ -681,26 +707,20 @@ async def put_python_file( httpMetadata={"contentType": "application/octet-stream"}, ) try: - await ( - self.db.prepare( - "INSERT INTO python_files(normalized_project, project_version, filename, " - "sha256, size, filetype, requires_python, core_metadata_sha256, r2_key, " - "metadata_r2_key) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)" + await self._prepare( + insert(db.python_files).values( + normalized_project=distribution.normalized_project, + project_version=distribution.project_version, + filename=filename, + sha256=sha256, + size=len(content), + filetype=filetype, + requires_python=requires_python, + core_metadata_sha256=metadata_sha256, + r2_key=r2_key, + metadata_r2_key=metadata_r2_key, ) - .bind( - distribution.normalized_project, - distribution.project_version, - filename, - sha256, - len(content), - filetype, - requires_python, - metadata_sha256, - r2_key, - metadata_r2_key, - ) - .run() - ) + ).run() except Exception as error: raced = await self._python_file_row( distribution.normalized_project, distribution.project_version, filename @@ -725,30 +745,30 @@ async def put_python_file( return _python_file(row) async def simple_projects(self) -> list[str]: - result = await self.db.prepare( - "SELECT DISTINCT pd.normalized_project FROM python_distributions pd " - "JOIN releases r ON r.extension_name = pd.extension_name " - "AND r.version = pd.release_version JOIN python_files pf " - "ON pf.normalized_project = pd.normalized_project " - "AND pf.project_version = pd.project_version " - "WHERE r.state IN ('published', 'yanked') ORDER BY pd.normalized_project" + result = await self._prepare( + select(db.python_distributions.c.normalized_project) + .distinct() + .select_from(db.python_distributions.join(db.releases).join(db.python_files)) + .where(db.releases.c.state.in_(("published", "yanked"))) + .order_by(db.python_distributions.c.normalized_project) ).all() return [_column(row, "normalized_project") for row in _results(result)] async def simple_files(self, normalized_project: str) -> list[PythonFileRecord]: - result = ( - await self.db.prepare( - "SELECT pf.*, CASE WHEN r.state = 'yanked' THEN r.yank_reason ELSE NULL END " - "AS yank_reason FROM python_files pf JOIN python_distributions pd " - "ON pd.normalized_project = pf.normalized_project " - "AND pd.project_version = pf.project_version JOIN releases r " - "ON r.extension_name = pd.extension_name AND r.version = pd.release_version " - "WHERE pf.normalized_project = ?1 AND r.state IN ('published', 'yanked') " - "ORDER BY pf.filename" - ) - .bind(normalized_project) - .all() - ) + result = await self._prepare( + select( + db.python_files, + case( + (db.releases.c.state == "yanked", db.releases.c.yank_reason), else_=None + ).label("yank_reason"), + ) + .select_from(db.python_files.join(db.python_distributions).join(db.releases)) + .where( + db.python_files.c.normalized_project == normalized_project, + db.releases.c.state.in_(("published", "yanked")), + ) + .order_by(db.python_files.c.filename) + ).all() return [_python_file(row) for row in _results(result)] async def python_public_file( @@ -759,18 +779,16 @@ async def python_public_file( *, metadata: bool = False, ) -> PublicObject | None: - row = ( - await self.db.prepare( - "SELECT pf.*, r.state FROM python_files pf JOIN python_distributions pd " - "ON pd.normalized_project = pf.normalized_project " - "AND pd.project_version = pf.project_version JOIN releases r " - "ON r.extension_name = pd.extension_name AND r.version = pd.release_version " - "WHERE pf.normalized_project = ?1 AND pf.project_version = ?2 " - "AND pf.filename = ?3 AND r.state IN ('published', 'yanked', 'blocked')" - ) - .bind(normalized_project, project_version, filename) - .first() - ) + row = await self._prepare( + select(db.python_files, db.releases.c.state) + .select_from(db.python_files.join(db.python_distributions).join(db.releases)) + .where( + db.python_files.c.normalized_project == normalized_project, + db.python_files.c.project_version == project_version, + db.python_files.c.filename == filename, + db.releases.c.state.in_(("published", "yanked", "blocked")), + ) + ).first() if row is None: return None if _column(row, "state") == "blocked": @@ -840,23 +858,20 @@ async def put_module_federation_snapshot( httpMetadata={"contentType": media_types[relative_path]}, ) manifest_key = prefix + "mf-manifest.json" - result = ( - await self.db.prepare( - "UPDATE module_federation_distributions SET manifest_r2_key = ?3, " - "asset_paths_json = ?4, internal_snapshot_hash = ?5, " - "uploaded_at = CURRENT_TIMESTAMP " - "WHERE extension_name = ?1 AND release_version = ?2 " - "AND manifest_r2_key IS NULL" - ) - .bind( - extension_name, - version, - manifest_key, - json.dumps(sorted(files), separators=(",", ":")), - snapshot_hash, - ) - .run() - ) + result = await self._prepare( + update(db.module_federation_distributions) + .values( + manifest_r2_key=manifest_key, + asset_paths_json=json.dumps(sorted(files), separators=(",", ":")), + internal_snapshot_hash=snapshot_hash, + uploaded_at=func.current_timestamp(), + ) + .where( + db.module_federation_distributions.c.extension_name == extension_name, + db.module_federation_distributions.c.release_version == version, + db.module_federation_distributions.c.manifest_r2_key.is_(None), + ) + ).run() if int(_column(_column(result, "meta"), "changes", 0)) != 1: raced = await self._module_federation_row(extension_name, version) if _column(raced, "internal_snapshot_hash") != snapshot_hash: @@ -871,19 +886,21 @@ async def put_module_federation_snapshot( async def module_federation_public_file( self, extension_name: str, version: str, relative_path: str, media_type: str ) -> PublicObject | None: - row = ( - await self.db.prepare( - "SELECT mf.manifest_r2_key, mf.asset_paths_json, " - "mf.internal_snapshot_hash, r.state " - "FROM module_federation_distributions mf JOIN releases r " - "ON r.extension_name = mf.extension_name AND r.version = mf.release_version " - "WHERE mf.extension_name = ?1 AND mf.release_version = ?2 " - "AND mf.manifest_r2_key IS NOT NULL " - "AND r.state IN ('published', 'yanked', 'blocked')" - ) - .bind(extension_name, version) - .first() - ) + row = await self._prepare( + select( + db.module_federation_distributions.c.manifest_r2_key, + db.module_federation_distributions.c.asset_paths_json, + db.module_federation_distributions.c.internal_snapshot_hash, + db.releases.c.state, + ) + .select_from(db.module_federation_distributions.join(db.releases)) + .where( + db.module_federation_distributions.c.extension_name == extension_name, + db.module_federation_distributions.c.release_version == version, + db.module_federation_distributions.c.manifest_r2_key.is_not(None), + db.releases.c.state.in_(("published", "yanked", "blocked")), + ) + ).first() if row is None: return None if _column(row, "state") == "blocked": diff --git a/tasks/registry-web-lovable/packet.md b/tasks/registry-web-lovable/packet.md index 302b01f..44b9234 100644 --- a/tasks/registry-web-lovable/packet.md +++ b/tasks/registry-web-lovable/packet.md @@ -25,7 +25,21 @@ Sir 要求改善 Registry Web,可加入 Publisher;对齐 client-web 与 desi - 清理前核对 source_repository/source_revision、snapshot hash 和 asset paths;只删除上述记录及对应 keys,不重置数据库。数据库 UUID `7d67ae5e-c0b3-4fca-841c-4c4c7661dd73` 未变;命名空间、凭据和迁移记录数保持不变,外键检查通过。 - Publisher 登录配置独立保留;没有扩展时使用现有空态,实际需要验收的扩展由 Publisher/Toolkit 上传。临时验收数据不再作为常驻预览数据。 -## 验证与交付 +## 当前增量:移除手写业务 SQL + +Sir 明确不能接受手写 SQL,授权将数据库访问交给 SQLAlchemy 等专门工具;模板继续使用现有 Jinja2。 + +- From → To:Repository 的 SQL 字符串与位置参数 → SQLAlchemy Core 表定义、查询表达式及 SQLite 编译器;D1 binding 只执行编译结果,并保留原生 batch 原子性。 +- 已检查 `sqlalchemy-cloudflare-d1` 0.3.11 源码:Worker 路径使用同步 `run_sync`,且 commit/rollback 为空操作,无法承接当前批内失败回滚。采用 SQLAlchemy Core 而非在业务中模拟 ORM Session 事务。 +- 影响范围:Registry 数据访问、依赖、维护脚本及本地技术文档。保持既有表结构、数据库资源、公共契约、凭证范围、发布状态转换、R2 staging 与幂等语义;不引入数据库复制或额外迁移治理。 +- 验证:真实本地 Worker/D1/R2 的发布旅程与原子写入失败,完整 `pnpm check`,再更新 PR 和现有隔离预览。临时验收数据只存在一次性本地数据库。 +- 已替换 Repository 的 27 处 SQL 及预览凭证维护 SQL。SQLAlchemy 2.0.52 的 pure-Python wheel 已在当前 Pyodide Worker 中运行;PDM 与 Worker lock 同版本。 +- `scripts/check_registry.py` 首次成功完成真实本地 Worker/D1/R2 验收,包括关联插入故障后扩展与版本记录均回滚;已纳入 `pnpm check`。这条测试保护持久化语义,不断言 SQL 字符串或模拟数据库调用顺序。 +- 安全验证对象:发布者凭证只能操作所属 namespace,匿名调用只可读取已公开的分发;查询重写不得令其他 namespace 的 release 或 private/blocked bytes 越过 API 边界。共享安全模型已从 core-py 只读挂载核对,Registry 实现真相归本地 control-plane 文档。 +- 完整 `pnpm check` 已通过;追加另一个发布者的空工作区读取后,验收再次通过。已逐条核对重写差异并检查业务 SQL 字符串均已移除。最终 CI 与 Worker 版本统一记录于 PR。 +- 此次预览更新仅替换已配置的 Worker 代码,保留现有 D1/R2 和 Publisher 凭据;不运行凭据初始化或上传验收数据。本地测试与预览部署仍使用原有单一 Python Worker 构建。 + +## 已有交付证据 - 本轮完整 `pnpm check` 通过;远程目录已为空,旧 demo 详情返回 404,Publisher 仍可登录且 release 列表为空。最终提交重部署及 CI 结果记录于 PR。 - 既往真实 Worker 已验证详情/版本、Web/Python 发布、撤回恢复、私有分页、命名空间隔离、重复部署和带对象清理;验收数据现已删除。 From 7ea304e7a81e7bc34a16c9230843577417be866a Mon Sep 17 00:00:00 2001 From: Lan_zhijiang Date: Sun, 13 Sep 2026 11:46:23 +0800 Subject: [PATCH 09/16] =?UTF-8?q?feat(registry):=20=E8=BF=81=E7=A7=BB?= =?UTF-8?q?=E8=87=B3=20CPython=20=E4=B8=8E=20Neon=20=E5=B9=B6=E4=BD=BF?= =?UTF-8?q?=E7=94=A8=20Tortoise=20ORM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 11 + .github/preview/retire.js | 19 - .github/preview/wrangler.json | 14 - .github/workflows/ci.yml | 39 + .github/workflows/production.yml | 73 +- .../workflows/registry-preview-cleanup.yml | 17 +- .github/workflows/registry-preview.yml | 53 +- AGENTS.md | 9 +- Dockerfile | 21 + README.md | 6 +- docs/30-unit-tdd/developer-toolkit.md | 2 +- docs/30-unit-tdd/registry-control-plane.md | 36 +- docs/30-unit-tdd/registry-web.md | 12 +- docs/40-deployment/README.md | 2 +- docs/40-deployment/local-development.md | 73 +- docs/40-deployment/production-registry.md | 51 +- docs/40-deployment/pull-request-previews.md | 94 +- package.json | 7 +- pdm.lock | 1248 +++++++++++++---- pylock.toml | 99 -- pyproject.toml | 16 +- scripts/check_d1_import.py | 137 ++ scripts/check_registry.py | 356 +++-- scripts/registry_preview.py | 423 +++--- scripts/registry_production.py | 71 + src/inkcre_extension_registry/__main__.py | 13 + src/inkcre_extension_registry/admin.py | 45 + src/inkcre_extension_registry/import_d1.py | 159 +++ .../migration_config.py | 9 + .../migrations/0001_initial.py | 330 +++++ .../migrations/__init__.py | 1 + src/inkcre_extension_registry/service/app.py | 114 +- .../service/database.py | 293 ++-- .../service/repository.py | 1136 ++++++--------- .../service/settings.py | 87 ++ .../service/storage.py | 74 + src/inkcre_extension_registry/worker.py | 86 -- src/worker.py | 5 - tasks/registry-web-lovable/evidence.md | 76 + tasks/registry-web-lovable/packet.md | 67 +- tasks/registry-web-lovable/plan.md | 29 + wrangler.jsonc | 39 - 42 files changed, 3374 insertions(+), 2078 deletions(-) create mode 100644 .dockerignore delete mode 100644 .github/preview/retire.js delete mode 100644 .github/preview/wrangler.json create mode 100644 Dockerfile delete mode 100644 pylock.toml create mode 100644 scripts/check_d1_import.py create mode 100644 scripts/registry_production.py create mode 100644 src/inkcre_extension_registry/__main__.py create mode 100644 src/inkcre_extension_registry/admin.py create mode 100644 src/inkcre_extension_registry/import_d1.py create mode 100644 src/inkcre_extension_registry/migration_config.py create mode 100644 src/inkcre_extension_registry/migrations/0001_initial.py create mode 100644 src/inkcre_extension_registry/migrations/__init__.py create mode 100644 src/inkcre_extension_registry/service/settings.py create mode 100644 src/inkcre_extension_registry/service/storage.py delete mode 100644 src/inkcre_extension_registry/worker.py delete mode 100644 src/worker.py create mode 100644 tasks/registry-web-lovable/evidence.md create mode 100644 tasks/registry-web-lovable/plan.md delete mode 100644 wrangler.jsonc diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f528a76 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.venv +**/.venv +**/__pycache__ +**/node_modules +**/dist +**/.env* +.dev.vars +.wrangler +tasks +docs diff --git a/.github/preview/retire.js b/.github/preview/retire.js deleted file mode 100644 index ba12c9e..0000000 --- a/.github/preview/retire.js +++ /dev/null @@ -1,19 +0,0 @@ -// Temporarily replaces the closed PR's Worker. Never bundled with Registry. -export default { - async fetch(request, env) { - if ( - request.method !== 'POST' || - request.headers.get('Authorization') !== `Bearer ${env.RETIRE_TOKEN}` - ) { - return new Response('This pull-request preview is no longer active.', { - status: 410, - headers: { 'Cache-Control': 'no-store' }, - }) - } - const batch = await env.ARTIFACTS.list({ limit: 1000 }) - if (batch.objects.length) { - await env.ARTIFACTS.delete(batch.objects.map((object) => object.key)) - } - return Response.json({ deleted: batch.objects.length }) - }, -} diff --git a/.github/preview/wrangler.json b/.github/preview/wrangler.json deleted file mode 100644 index 0864401..0000000 --- a/.github/preview/wrangler.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compatibility_date": "2026-07-28", - "compatibility_flags": ["python_workers"], - "workers_dev": true, - "preview_urls": false, - "observability": { "enabled": true }, - "rules": [ - { - "type": "Text", - "globs": ["**/service/static/registry.css", "**/service/static/registry.js"], - "fallthrough": true - } - ] -} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3efa097..c8f6a33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,20 @@ jobs: name: ext-reg checks runs-on: ubuntu-latest timeout-minutes: 20 + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: registry + POSTGRES_PASSWORD: registry-check + POSTGRES_DB: registry_check + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U registry -d registry_check" + --health-interval 5s --health-timeout 5s --health-retries 12 + env: + REGISTRY_TEST_DATABASE_URL: postgres://registry:registry-check@localhost:5432/registry_check steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -60,3 +74,28 @@ jobs: - name: Run repository contract run: pnpm check + + - name: Enforce append-only deployed migration history + if: github.event_name == 'pull_request' + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + run: | + git fetch --no-tags origin "$BASE_SHA" + git diff --exit-code --diff-filter=DMRT "$BASE_SHA" HEAD -- migrations src/inkcre_extension_registry/migrations + + - name: Build the single CPython service image + run: docker build --build-arg SOURCE_REVISION="$GITHUB_SHA" --tag registry-check . + + - name: Verify the installed image serves HTTP and the migrated database + run: | + docker run --detach --rm --name registry-image-check --network host \ + --env DATABASE_URL="$REGISTRY_TEST_DATABASE_URL" \ + --env PUBLIC_ORIGIN=http://localhost:8000 \ + --env S3_ENDPOINT_URL=http://localhost:9000 --env S3_BUCKET=unused \ + --env AWS_ACCESS_KEY_ID=testing --env AWS_SECRET_ACCESS_KEY=testing \ + registry-check + trap 'docker logs registry-image-check; docker stop registry-image-check' EXIT + curl --fail --silent --show-error --retry 20 --retry-connrefused --retry-delay 1 \ + http://localhost:8000/livez | jq -e '.status == "ok"' + curl --fail --silent --show-error http://localhost:8000/v1/extensions | jq -e '. == []' + curl --fail --silent --show-error http://localhost:8000/publish | grep -F 'Extension Registry' diff --git a/.github/workflows/production.yml b/.github/workflows/production.yml index 34033e7..81957c1 100644 --- a/.github/workflows/production.yml +++ b/.github/workflows/production.yml @@ -31,10 +31,19 @@ jobs: environment: name: production url: https://registry.inkcre.dev + services: + postgres: + image: postgres:17 + env: + POSTGRES_USER: registry + POSTGRES_PASSWORD: registry-check + POSTGRES_DB: registry_check + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U registry -d registry_check" + --health-interval 5s --health-timeout 5s --health-retries 12 env: - CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} - REGISTRY_ORIGIN: https://registry.inkcre.dev + REGISTRY_TEST_DATABASE_URL: postgres://registry:registry-check@localhost:5432/registry_check SOURCE_SHA: ${{ inputs.source_sha }} steps: - name: Checkout verified source @@ -75,45 +84,35 @@ jobs: - name: Verify exact delivery source run: pnpm check - - name: Apply native production schema - if: inputs.operation == 'deploy' - run: pnpm exec wrangler d1 migrations apply DB --remote --config wrangler.jsonc + - name: Build the same CPython service artifact used by previews + run: docker build --build-arg SOURCE_REVISION="$SOURCE_SHA" --tag registry-production . - - name: Deploy exact Worker and bindings + - name: Install the pinned Heroku CLI if: inputs.operation == 'deploy' - run: | - pdm run pywrangler deploy \ - --config wrangler.jsonc \ - --message "Native Registry production at $SOURCE_SHA" \ - --strict + run: npm install --global heroku@11.8.1 - - name: Smoke anonymous production reads + - name: Migrate and release the authorized production app + if: inputs.operation == 'deploy' + env: + HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }} + HEROKU_APP_NAME: ${{ vars.HEROKU_APP_NAME }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + S3_ENDPOINT_URL: ${{ vars.S3_ENDPOINT_URL }} + S3_BUCKET: ${{ vars.S3_BUCKET }} + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} run: | - curl --fail --silent --show-error --retry 12 --retry-all-errors \ - "$REGISTRY_ORIGIN/livez" | jq -e '.status == "ok"' - curl --fail --silent --show-error --retry 12 --retry-all-errors \ - "$REGISTRY_ORIGIN/" | grep -F 'Extension Registry' - curl --fail --silent --show-error --retry 12 --retry-all-errors \ - "$REGISTRY_ORIGIN/v1/extensions" | jq -e 'type == "array"' - curl --fail --silent --show-error --retry 12 --retry-all-errors \ - -H 'Accept: application/vnd.pypi.simple.v1+json' \ - "$REGISTRY_ORIGIN/simple/" | jq -e '.meta["api-version"] == "1.1"' + current_main=$(git ls-remote origin refs/heads/main | cut -f1) + test "$current_main" = "$SOURCE_SHA" + pdm run python scripts/registry_production.py --image registry-production - - name: Record production evidence + - name: Record delivery boundary + env: + OPERATION: ${{ inputs.operation }} run: | { - echo '## Extension Registry production authority' - echo - echo "- Source: \`$SOURCE_SHA\`" - echo "- Operation: \`${{ inputs.operation }}\`" - echo "- Origin: $REGISTRY_ORIGIN" - echo '- D1: inkcre-extension-registry-production-v2' - echo '- R2: inkcre-extension-registry-production-v2' - echo '- Full repository contract and anonymous smoke: passed' - if [ "${{ inputs.operation }}" = 'deploy' ]; then - echo '- Checked migration and exact Worker deployment: passed' - else - echo '- Remote mutation: none (verification only)' - fi - echo '- Rollback: previously verified compatible native-v2 Worker Version' + echo '## Registry CPython 交付' + echo "- 来源:$SOURCE_SHA" + echo "- 操作:$OPERATION" + echo '- 数据导入、旧写入暂停和域名切换按 production-registry.md 单独执行。' } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/registry-preview-cleanup.yml b/.github/workflows/registry-preview-cleanup.yml index 2df0b33..c5a78f6 100644 --- a/.github/workflows/registry-preview-cleanup.yml +++ b/.github/workflows/registry-preview-cleanup.yml @@ -43,6 +43,16 @@ jobs: ref: ${{ github.workflow_sha }} persist-credentials: false + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version-file: .python-version + - name: Install trusted controller tooling + run: | + python -m pip install pdm==2.28.0 + pdm install --frozen-lockfile + npm install --global heroku@11.8.1 + - name: Install pnpm uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 with: @@ -92,9 +102,12 @@ jobs: } core.setOutput('pull_number', String(pullNumber)) - - name: Retire the closed PR Worker and delete its D1/R2 + - name: Retire the closed PR app, database and bucket env: CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN }} + CLOUDFLARE_PREVIEW_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN }} + HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }} + NEON_API_KEY: ${{ secrets.NEON_API_KEY }} + NEON_PROJECT_ID: ${{ vars.NEON_PROJECT_ID }} PULL_NUMBER: ${{ steps.identity.outputs.pull_number }} run: pdm run python scripts/registry_preview.py retire --pull-number "$PULL_NUMBER" diff --git a/.github/workflows/registry-preview.yml b/.github/workflows/registry-preview.yml index b3d48f3..8d19c64 100644 --- a/.github/workflows/registry-preview.yml +++ b/.github/workflows/registry-preview.yml @@ -69,7 +69,7 @@ jobs: core.setOutput('pull_number', String(pull.number)) build: - name: Build exact-head Python Worker + name: Build exact-head CPython image needs: identity runs-on: ubuntu-latest timeout-minutes: 20 @@ -96,23 +96,17 @@ jobs: with: node-version-file: package.json cache: pnpm - - name: Build the existing Python Worker + - name: Build the single service image without deployment credentials + env: + SOURCE_SHA: ${{ needs.identity.outputs.head_sha }} run: | - pdm install --frozen-lockfile - pnpm install --frozen-lockfile - pnpm build - pnpm worker:build - mkdir -p artifact/src - cp dist/worker/worker.py artifact/src/ - cp -R dist/worker/inkcre_extension_registry artifact/src/ - cp -R dist/worker/python_modules artifact/ - cp -R migrations artifact/ - - name: Transfer modules and SQL to a separate delivery runner + docker build --build-arg SOURCE_REVISION="$SOURCE_SHA" --tag registry-preview . + docker save registry-preview --output "$RUNNER_TEMP/registry-image.tar" + - name: Transfer the image to a separate delivery runner uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: registry-worker - path: artifact/ - include-hidden-files: true + name: registry-image + path: ${{ runner.temp }}/registry-image.tar retention-days: 1 if-no-files-found: error @@ -156,11 +150,13 @@ jobs: run: | pdm install --frozen-lockfile pnpm install --frozen-lockfile - - name: Download this run's Python Worker + - name: Install the pinned Heroku CLI + run: npm install --global heroku@11.8.1 + - name: Download this run's service image uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: registry-worker - path: ${{ runner.temp }}/registry-worker + name: registry-image + path: ${{ runner.temp }}/registry-image - name: Recheck PR identity after build and concurrency queue uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: @@ -176,19 +172,22 @@ jobs: pull.head.sha !== process.env.SOURCE_SHA) { throw new Error('PR closed or moved; no preview mutation is allowed.') } - - name: Apply migrations, deploy isolated bindings, and verify reads + - name: Apply migrations and deploy the isolated service id: deploy env: CLOUDFLARE_ACCOUNT_ID: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} - CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN }} + CLOUDFLARE_PREVIEW_API_TOKEN: ${{ secrets.CLOUDFLARE_PREVIEW_API_TOKEN }} + HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }} + NEON_API_KEY: ${{ secrets.NEON_API_KEY }} + NEON_PROJECT_ID: ${{ vars.NEON_PROJECT_ID }} + NEON_PREVIEW_PARENT_BRANCH_ID: ${{ vars.NEON_PREVIEW_PARENT_BRANCH_ID }} REGISTRY_PREVIEW_PUBLISHER_TOKEN: ${{ secrets.REGISTRY_PREVIEW_PUBLISHER_TOKEN }} PULL_NUMBER: ${{ needs.identity.outputs.pull_number }} SOURCE_SHA: ${{ needs.identity.outputs.head_sha }} - run: >- - pdm run python scripts/registry_preview.py deploy - --pull-number "$PULL_NUMBER" - --source-sha "$SOURCE_SHA" - --artifact "$RUNNER_TEMP/registry-worker" + run: | + docker load --input "$RUNNER_TEMP/registry-image/registry-image.tar" + pdm run python scripts/registry_preview.py deploy \ + --pull-number "$PULL_NUMBER" --source-sha "$SOURCE_SHA" --image registry-preview - name: Record interactive preview evidence env: PREVIEW_URL: ${{ steps.deploy.outputs.url }} @@ -199,6 +198,6 @@ jobs: echo echo "- 来源:\`$SOURCE_SHA\`" echo "- 完整预览:$PREVIEW_URL" - echo '- 目录、详情、Publisher 与原生分发运行同一 Python Worker。' - echo '- 每 PR 独立 D1/R2;发布测试使用 demo 命名空间和专用预览凭据。' + echo '- 目录、详情、Publisher 与原生分发运行同一 CPython 服务。' + echo '- 每 PR 独立 Neon 分支与 R2 桶;不生成示例扩展。' } >> "$GITHUB_STEP_SUMMARY" diff --git a/AGENTS.md b/AGENTS.md index edaa874..589dc10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,10 +5,11 @@ Distribution hosting, and the independent Extension Developer Toolkit. ## Repository Map -- `src/inkcre_extension_registry/`: Registry contracts and Worker service. +- `src/inkcre_extension_registry/`: Registry contracts and CPython service. - `toolkit/`: independently released developer and delivery tooling. - `contracts/`: generated schemas and OpenAPI; never edit by hand. -- `migrations/`: checked-in D1 schema history. +- `src/inkcre_extension_registry/migrations/`: append-only Tortoise schema history. +- `migrations/`: preserved legacy D1 schema, used only to verify and import snapshots. - `docs/`: durable local knowledge; `tasks/`: volatile task packets. ## Knowledge Owners @@ -33,7 +34,9 @@ Distribution hosting, and the independent Extension Developer Toolkit. Registry service. - Never hand-edit `contracts/*`; change its source model or route and run `pnpm contracts:generate`. -- Treat checked-in D1 migrations as append-only after deployment. Production +- Treat checked-in migrations as append-only after deployment. Use Tortoise ORM + for PostgreSQL business and maintenance queries; legacy SQLite reads belong + only to the import boundary. Production resource mutation requires a separate, explicit authorization. ## Current Checks diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9421339 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +# syntax=docker/dockerfile:1 +FROM python:3.13-slim AS builder +ENV PDM_CHECK_UPDATE=false PDM_IGNORE_SAVED_PYTHON=1 PDM_VENV_IN_PROJECT=1 +WORKDIR /app +RUN pip install --no-cache-dir pdm==2.28.0 +COPY pyproject.toml pdm.lock README.md ./ +COPY src/ src/ +COPY toolkit/ toolkit/ +COPY runtimes/core-py/ runtimes/core-py/ +RUN pdm install --prod --no-editable --frozen-lockfile + +FROM python:3.13-slim +ARG SOURCE_REVISION=unknown +LABEL org.opencontainers.image.revision="${SOURCE_REVISION}" +ENV PATH="/app/.venv/bin:${PATH}" PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 +WORKDIR /app +COPY --from=builder /app/.venv /app/.venv +RUN useradd --uid 10001 --create-home registry +USER registry +EXPOSE 8000 +CMD ["python", "-m", "inkcre_extension_registry"] diff --git a/README.md b/README.md index d701310..bb7853e 100644 --- a/README.md +++ b/README.md @@ -9,9 +9,7 @@ browser consumers use the producer's native `mf-manifest.json`. Deployment installation, per-Peer enablement, and running state remain outside this repository. -The service runs as a Python Cloudflare Worker with D1 metadata and private R2 -bytes. It does not publish a generic target manifest or a shared Runtime/API -package. +服务使用 CPython、FastAPI / Jinja、Tortoise ORM 和 Neon PostgreSQL,私有文件继续存放于 R2,并通过 boto3 的 S3 接口访问。目录、详情、Publisher、API 与文件分发由同一个容器交付;没有独立前端部署。现有生产 Worker 的切换步骤见 [生产迁移](docs/40-deployment/production-registry.md)。 This repository also releases the independent `inkcre-extension-toolkit` developer/CD distribution. Its `cli` extra exposes @@ -28,7 +26,7 @@ use the native APIs described above. ```bash pdm install --frozen-lockfile pnpm install --frozen-lockfile -pnpm check +REGISTRY_TEST_DATABASE_URL=postgres://registry:registry-check@localhost:5432/registry_check pnpm check ``` See the [documentation index](docs/index.md) for internal design and deployment diff --git a/docs/30-unit-tdd/developer-toolkit.md b/docs/30-unit-tdd/developer-toolkit.md index 86bb641..5d11e32 100644 --- a/docs/30-unit-tdd/developer-toolkit.md +++ b/docs/30-unit-tdd/developer-toolkit.md @@ -4,7 +4,7 @@ base package owns pure, Registry-compatible native association projections; the `cli` extra owns HTTP and command dependencies and exposes `inkcre-ext`. The Registry service may reuse the pure library, but the Toolkit must not -depend on Registry implementation or Worker runtime state. +depend on Registry implementation or service runtime state. The Toolkit prepares Release descriptors, uploads native Module Federation snapshots, and builds deterministic static preview facades from one explicit, diff --git a/docs/30-unit-tdd/registry-control-plane.md b/docs/30-unit-tdd/registry-control-plane.md index d8c317e..e16448f 100644 --- a/docs/30-unit-tdd/registry-control-plane.md +++ b/docs/30-unit-tdd/registry-control-plane.md @@ -24,8 +24,7 @@ The Python surface admits bounded wheel uploads through `/legacy/`, validates the declared digest, filename, normalized project, version congruence, Core Metadata, entry point, and archive safety, then exposes PEP 503/691 and PEP 658 projections through `/simple/` and `/packages/`. The request is declared-length -and capped at 20 MiB because Python Workers cannot use Starlette's normal -thread-backed spill path. +and capped at 20 MiB. CPython uses Starlette's standard spooled multipart files. The Module Federation surface admits a bounded ZIP rooted at `mf-manifest.json`, validates its relative Remote entry and referenced assets, @@ -37,31 +36,14 @@ models, routes, generated contracts, and build checks are the exact executable i ## Persistence and Security -D1 queries and writes use SQLAlchemy Core table mappings and expressions in -`service/database.py` and `service/repository.py`. SQLAlchemy's SQLite compiler -generates SQL and ordered bound parameters, including expanded `IN` predicates. -The Repository passes these directly to the asynchronous D1 binding and retains -native `batch()` for atomic identity and association creation. It does not use -an ORM Session or emulate connection transactions: the evaluated -`sqlalchemy-cloudflare-d1` Worker driver implements commit and rollback as no-ops. -The binding bridge owns only compilation and execution, not a query language, -DBAPI driver, identity map, or transaction engine. +持久化由 PostgreSQL 与 Tortoise ORM / asyncpg 承担。`service/database.py` 定义模型、业务唯一约束、外键和状态约束;`service/repository.py` 拥有查询与事务。Release、Distribution 和 File 使用独立内部主键,公开身份仍是 Extension Name / Version 或 Python Project / Version / Filename。内部 ID 不进入公开 URL 或契约。 -Checked-in migrations remain the schema authority. Core mappings describe the -existing columns and foreign-key joins; the service never calls `create_all()` -or changes the schema at startup. Ordinary service and maintenance queries must -use expressions instead of handwritten SQL. Persisted text and integer columns -use native scalar values; additional database types require verifying their -binding and result conversion at the D1 boundary. +准备关联时,namespace 行锁串行化同一发布者的身份创建,release 行锁保护关联和状态。唯一约束保证 Python Project / Version 不被另一个 release 占用;失败会回滚同一事务中的 Extension、Release 和关联创建。上传、发布、撤回和恢复均重新检查锁定后的 release 状态。关联、文件名和内容身份不可变,相同提交可以重试。模型的联合约束明确使用数据库外键列名;不依赖迁移生成器猜测关系字段的物理列名。 -D1 owns identity, lifecycle, association metadata, and hashed namespace -credentials. R2 owns immutable private bytes. Admission writes staging bytes -before a conditional D1 exposure transition; unreachable staging objects are -recoverable garbage, never public authority. Every raw-byte read first proves -a readable D1 association. +R2 的网络访问不持有数据库事务锁。boto3 的同步调用在线程中执行;上传先写内容寻址的 staging 对象,再在 PostgreSQL 事务中增加可见关联。发布前检查关联对象存在,文件大小一致。失败可能留下不可达的 staging 对象,它们不是公开状态的来源。每次文件读取先检查 release 可读性,再通过 S3 流式返回;HEAD 只读取对象元数据。 -Raw credentials never enter D1 or evidence. Anonymous reads are limited to -published or yanked state. Registry admission validates structure and -integrity, not publisher trust: Python code executes in a trusted Core process -and a Module Federation Remote receives host-page privileges. Platform Hosts -must perform their own compatibility and runtime negotiation before execution. +应用启动只初始化连接池,不建表、不运行迁移、不创建凭据或示例数据。交付在启动新版本前运行包内 Tortoise 迁移,并仅允许向前执行。数据迁移工具只在旧 SQLite 快照读取边界使用 SQL,所有 PostgreSQL 读写和运维命令使用 ORM。旧 D1 历史保留用于导入验收,迁移工具不自动访问生产 D1。 + +Publisher 持有 namespace 范围内的发布能力;原始 token 只用于请求认证和运维命令的标准输入,数据库保存 SHA-256。匿名读取只能看到公开或撤回的 release,blocked 状态拒绝描述和原始文件访问。预览服务只获得所属数据库分支和 R2 桶的凭据;云平台控制凭据只属于可信交付控制器。 + +Registry 验证文件结构与完整性,不证明发布者可信。Python 扩展在受信任 Core 进程中运行,Module Federation Remote 获得宿主页面权限;Host 继续拥有兼容性判断和运行时协商。 diff --git a/docs/30-unit-tdd/registry-web.md b/docs/30-unit-tdd/registry-web.md index 1a7536d..19d8a99 100644 --- a/docs/30-unit-tdd/registry-web.md +++ b/docs/30-unit-tdd/registry-web.md @@ -1,6 +1,6 @@ # Registry Web -Registry Web is a presentation surface of the Python Worker. FastAPI reads the +Registry Web is a presentation surface of the CPython service. FastAPI reads the existing Registry repository and renders autoescaped Jinja templates. Small browser enhancements cover theme selection, copying an Extension ID, and the publisher workspace. There is no separately deployed frontend or browser @@ -35,7 +35,7 @@ HTML, or logs. Only the theme preference is persisted in local storage. `GET /v1/publisher?offset=0` authenticates through the existing namespace credential dependency and returns only that namespace's releases, including preparing, withdrawn, and operator-blocked releases. Pages contain at most ten -records to bound the existing descriptor reads within the D1 query budget. +records. Related distribution and file data is prefetched in bounded queries. `next_offset` indicates another page; concurrent publication can shift this non-snapshot listing, and Refresh reloads current state. Private responses, including rejected credentials, are not cached. Per-distribution uploaded flags @@ -66,13 +66,13 @@ is no Registry copy of the token source or a competing design package. `pnpm web:build` compiles the pinned package and local Sass into the committed `service/static/registry.css`. `pnpm web:check` compares it to a fresh compilation and checks browser JavaScript syntax. Committing generated CSS makes Python wheel -and Worker builds self-contained; it is never edited manually. Wrangler -Text rules explicitly include CSS and JavaScript in the Python module filesystem. +and container builds self-contained; it is never edited manually. The wheel +includes templates, CSS and JavaScript in the ordinary Python package. Rendering depends on product data, selected release, and the current route. Templates, links, controls, and browser styles contain no preview/production -switch. Instance origin and storage bindings belong to runtime configuration. +switch. Instance origin and storage connections belong to runtime configuration. -Remote previews run the same Python Worker with isolated D1/R2 bindings. Their +Remote previews run the same CPython image with an isolated PostgreSQL branch and R2 bucket. Their resource lifecycle and source identity belong to the delivery controller; see [Pull-Request Previews](../40-deployment/pull-request-previews.md). diff --git a/docs/40-deployment/README.md b/docs/40-deployment/README.md index 70bddfd..e93426e 100644 --- a/docs/40-deployment/README.md +++ b/docs/40-deployment/README.md @@ -4,6 +4,6 @@ - [Production Registry](production-registry.md) - [Pull-request previews](pull-request-previews.md) -Workflow files and `wrangler.jsonc` are executable delivery authority. These +Workflow files and `Dockerfile` are executable delivery authority. These documents own the operational intent, safety boundaries, failure behavior, and recovery facts that those files cannot express clearly enough. diff --git a/docs/40-deployment/local-development.md b/docs/40-deployment/local-development.md index c27c94e..6de9a17 100644 --- a/docs/40-deployment/local-development.md +++ b/docs/40-deployment/local-development.md @@ -1,76 +1,33 @@ -# Local Development +# 本地开发 -Install frozen Python and Node dependencies and run the full contract: +Registry 使用 Python 3.13、PDM 和 Node 22 / pnpm。Node 只用于现有设计样式、契约及独立 Web Runtime 包的构建;服务进程只运行 Python。 ```bash pdm install --frozen-lockfile pnpm install --frozen-lockfile -pnpm check ``` -For the real local Worker seam, use isolated Wrangler state or a clean checkout: +配置 `DATABASE_URL`、`PUBLIC_ORIGIN`、`S3_ENDPOINT_URL`、`S3_BUCKET` 和标准 AWS 凭据 `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY`。数据库必须是 PostgreSQL;远程连接默认验证 TLS,localhost 可以不启用 TLS。`PUBLIC_ORIGIN` 是 HTTPS origin,本地允许 localhost HTTP,不含路径。文件配置是 dotenv 数据,不应当作 shell 脚本执行;应用从进程环境读取配置。 ```bash -pnpm exec wrangler d1 migrations apply DB --local --config wrangler.jsonc -pnpm build -UV_FIND_LINKS="$PWD/dist" pdm run pywrangler dev --port 8791 \ - --var PUBLIC_ORIGIN:http://127.0.0.1:8791 +pnpm db:migrate +pdm run python -m inkcre_extension_registry ``` -`.wrangler/`, virtual environments, `python_modules/`, dependency directories, -build output, and secrets are local state and must remain uncommitted. +默认监听 8000,平台通过 `PORT` 指定端口。`/`、`/explore//`、`/publish` 和全部 API 使用同一服务。新库显示真实空态;启动和迁移不会创建示例数据。运维可通过 `pdm run python -m inkcre_extension_registry.admin grant --label