From f1359f47b38e57beee6ec312a80c3928e882aaa5 Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 11:35:53 +0800 Subject: [PATCH 01/40] docs: design personal gallery architecture --- .../2026-07-21-personal-gallery-design.md | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-21-personal-gallery-design.md diff --git a/docs/superpowers/specs/2026-07-21-personal-gallery-design.md b/docs/superpowers/specs/2026-07-21-personal-gallery-design.md new file mode 100644 index 0000000..935f565 --- /dev/null +++ b/docs/superpowers/specs/2026-07-21-personal-gallery-design.md @@ -0,0 +1,226 @@ +# Faust Personal Gallery Design + +**Date:** 2026-07-21 +**Status:** Approved for specification review + +## 1. Purpose + +Faust will become a personal gallery for turning ideas into code without managing a separate repository for every experiment. It must serve two audiences equally well: + +- The owner needs a fast, predictable workflow for creating, running, and archiving ideas. +- Visitors need a coherent public site for browsing, understanding, and trying selected work. + +Projects may use unrelated technology stacks, including web applications, services, scripts, command-line tools, and AI experiments. The gallery therefore provides a common index and presentation layer without imposing a common runtime on project implementations. + +## 2. Product Principles + +1. **Independent projects, unified presentation.** Each idea is self-contained; the gallery reads only its declared metadata. +2. **Low-friction creation.** A guided command creates a valid project from a small set of templates. +3. **Progress is worth showing.** Ideas may be marked as ideas, works in progress, shipped work, or archived work. +4. **Graceful differences.** Projects with a hosted demo expose it; other projects remain useful through their description, README, and source. +5. **Quiet authorship.** The public site should feel like a considered digital archive, not a developer dashboard or generic card grid. + +## 3. Repository Architecture + +```text +faust/ +├── gallery/ # Astro static site published to GitHub Pages +├── projects/ +│ └── / +│ ├── project.json # Stable gallery contract +│ ├── README.md # Project-specific usage and implementation notes +│ └── ... # Any language, toolchain, and internal structure +├── templates/ # blank, web, and script project starters +├── tools/ # create, validate, and list commands +├── notes/ # Existing prose moved from the repository root +├── docs/ # Repository design and maintenance documentation +├── package.json # Root gallery-management commands +└── README.md # Contributor and owner guide +``` + +The root Node toolchain manages the gallery and repository workflows only. Projects are not automatically made members of a shared JavaScript workspace and are not built by the root build. A project may define its own package manager, language environment, tests, and CI as needed. + +The existing `allinone.md`, `读书笔记.md`, `随笔.md`, `项目.md`, `DONE.md`, and `code/` content will move under `notes/` without rewriting its contents. These documents remain in the repository but do not appear in the first version of the public gallery. + +## 4. Project Metadata Contract + +Every direct child of `projects/` must contain `project.json` and `README.md`. The first-version metadata shape is: + +```json +{ + "title": "Semantic Search Playground", + "slug": "semantic-search", + "summary": "用不同向量模型比较中文语义搜索效果。", + "status": "building", + "type": "ai", + "tags": ["embeddings", "search"], + "createdAt": "2026-07-21", + "updatedAt": "2026-07-21", + "featured": false, + "demo": null, + "repository": "./", + "cover": null +} +``` + +### 4.1 Required Fields + +- `title`: Human-readable project title. +- `slug`: Unique lowercase kebab-case identifier matching the project directory name. +- `summary`: Concise public description. +- `status`: One of `idea`, `building`, `shipped`, or `archived`. +- `type`: One of `web`, `service`, `cli`, `ai`, `script`, or `other`. +- `tags`: Array of lowercase display tags; duplicates are rejected. +- `createdAt` and `updatedAt`: ISO calendar dates. `updatedAt` must not precede `createdAt`. +- `featured`: Boolean used for editorial emphasis, not a separate page section. +- `demo`: Absolute HTTP(S) URL or `null`. +- `repository`: `./` for a project in this repository, with absolute HTTP(S) URLs reserved for future externally hosted source. +- `cover`: Project-relative image path or `null`. + +Unknown fields are rejected so mistakes do not silently disappear from the gallery. A local JSON Schema and runtime validator define the contract. The schema is the source of truth for tooling and documentation examples. + +### 4.2 Validation Rules + +Validation fails when a project has missing files, invalid enum values, malformed dates or URLs, duplicate slugs, a slug-directory mismatch, an absent referenced cover, or an absent README. Errors identify the project, field, and corrective action. External URLs are checked for syntax only; network availability does not block local work or deployment. + +## 5. Project Creation Workflow + +Running `pnpm create:project` starts an interactive prompt for title, type, template, and summary. The tool then: + +1. Derives a safe, unique slug and asks for another value if it conflicts. +2. Copies one of three initial templates: `blank`, `web`, or `script`. +3. Writes `project.json` with dates and safe defaults. +4. Creates a project README containing purpose, development, and status sections. +5. Runs validation for the new project. +6. Prints the created path and template-specific next steps. + +The command must leave no partially created project when validation fails. Template files use explicit placeholders rather than executing arbitrary code during creation. + +The `blank` template supports every unmodelled technology stack. The initial release will not provide dedicated Java, Python service, CLI, or AI templates; those can be added after repeated project patterns justify them. + +## 6. Gallery Experience + +### 6.1 Visual Direction + +The gallery is a quiet digital archive: low-saturation cool neutrals, strong editorial typography, fine rules, generous space, and restrained motion. It should avoid a conventional equal-sized card wall, bright developer-dashboard styling, glass effects, and decorative gradients. + +The interface uses a distinctive serif display face paired with a readable sans-serif body face. Fonts must be self-hosted or loaded with an explicit resilient fallback strategy. Color contrast must meet WCAG AA for body text and controls. + +### 6.2 Home Page + +The home page contains: + +- A concise statement of purpose. +- A project count and filters for type and status. +- A typographic project index ordered by `updatedAt` descending, with deterministic slug ordering for ties. +- Each row's title, summary, type, status, and year. +- Subtle editorial emphasis for `featured` projects without splitting them into a redundant section. +- An intentional empty state explaining how to create the first project. + +Filters update the visible index without navigation and are reflected in URL query parameters so views are shareable. The default view includes all projects except archived projects; archived projects remain available through the status filter. + +### 6.3 Project Page + +Each project receives a statically generated page at `/projects//` with: + +- Title, summary, status, type, tags, and dates. +- A short rendered excerpt from the project's README. +- The declared technology or context represented through tags. +- A primary Demo action when `demo` exists. +- A source action resolved from `repository`; `./` links to the project's directory on the configured GitHub repository. +- A cover when supplied, or a consistent typographic fallback cover. + +README rendering supports standard Markdown content but strips unsafe raw HTML. The gallery does not execute code from projects. + +### 6.4 Responsive and Accessible Behaviour + +The project index becomes a single-column record list on narrow screens while retaining filters and all actions. All interactions support keyboard navigation, visible focus states, semantic landmarks, and descriptive labels. Motion is limited to state communication and is disabled or reduced under `prefers-reduced-motion`. + +## 7. Data Flow and Failure Handling + +```text +projects/*/project.json + ↓ schema and repository validation +typed gallery content collection + ↓ Astro static generation +GitHub Pages artifact +``` + +Gallery development and production builds run the same validation first. Invalid metadata stops the build with actionable errors. Missing optional data has defined behaviour: + +- No cover: render a typographic fallback. +- No demo: omit the Demo action without leaving an empty placeholder. +- Empty result after filtering: explain that no projects match and provide a reset action. +- No projects at all: render the repository-owner-oriented creation guidance. + +The gallery consumes a generated, typed project index rather than repeatedly reading files in UI components. Generation is deterministic so local and CI output agree. + +## 8. Commands + +The root exposes these stable commands: + +- `pnpm dev`: validate and start the gallery development server. +- `pnpm build`: validate and produce the static gallery build. +- `pnpm check`: run metadata validation, automated tests, and the production build. +- `pnpm create:project`: interactively scaffold and validate a new project. +- `pnpm list:projects`: print project title, type, status, and path for owner maintenance. + +Project-specific commands are documented inside each project and are not proxied from the root. + +## 9. GitHub Pages Delivery + +A GitHub Actions workflow runs on pull requests and pushes to the default branch. Pull requests run `pnpm check`; pushes that pass additionally deploy the generated static artifact with GitHub's official Pages actions. + +Astro's site and base configuration must support project Pages at `//`. Repository identity used for source links and Pages configuration is defined once in gallery configuration, not copied into every component. The workflow uses a pinned Node major version, enables pnpm caching, and grants only the permissions required by GitHub Pages. + +## 10. Testing and Verification + +Automated tests cover: + +- Valid and invalid metadata parsing. +- Duplicate slug and directory mismatch detection. +- Date, URL, cover, and README validation. +- Deterministic ordering and type/status filtering. +- Project creation from each template, including conflict and cleanup behaviour. +- Source-link resolution for local and external repositories. + +The production build verifies static route generation and the GitHub Pages base path. A final manual pass checks desktop and mobile layouts, keyboard navigation, focus visibility, empty states, missing optional metadata, Demo/source links, and reduced-motion behaviour. + +Existing prose migration is verified by comparing the pre-move and post-move file contents, not just file counts. + +## 11. Documentation + +The repository README will explain: + +- What Faust is and how the repository is organized. +- Prerequisites and root commands. +- How to create, develop, and update a project. +- The metadata fields and lifecycle statuses. +- How the gallery is built and published. +- How projects remain isolated from the root toolchain. + +Each template README will clearly separate project purpose, local development, and current status so generated projects are useful from their first commit. + +## 12. Out of Scope for the First Version + +- Building, testing, or deploying every project from the root workflow. +- Dedicated templates for every language or framework. +- Full-text search, analytics, comments, authentication, or a content-management UI. +- Publishing notes and essays in the gallery. +- Automatically probing external Demo availability. +- Deploying individual projects; projects may add this independently. + +## 13. Acceptance Criteria + +The design is implemented when: + +1. A fresh checkout can install dependencies and run the gallery using documented commands. +2. `pnpm create:project` creates valid projects from all three templates without manual metadata repair. +3. Mixed-technology project directories coexist without being coupled to the root build. +4. The gallery automatically indexes valid metadata and generates accessible home and project pages. +5. Projects can be filtered by type and status; the default excludes archived work. +6. Projects with and without covers or demos render intentionally. +7. Invalid projects fail locally and in CI with actionable diagnostics. +8. GitHub Actions validates pull requests and deploys the correct subpath build to GitHub Pages. +9. Existing prose is preserved under `notes/` and excluded from the public gallery. +10. The repository documentation is sufficient to add and maintain a project without reading tool source code. From e0a0371cf4d5089873f604fcd7714e06d3f5f094 Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 11:36:39 +0800 Subject: [PATCH 02/40] docs: clarify gallery source and content rules --- .../superpowers/specs/2026-07-21-personal-gallery-design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-07-21-personal-gallery-design.md b/docs/superpowers/specs/2026-07-21-personal-gallery-design.md index 935f565..e2bc5fa 100644 --- a/docs/superpowers/specs/2026-07-21-personal-gallery-design.md +++ b/docs/superpowers/specs/2026-07-21-personal-gallery-design.md @@ -74,8 +74,8 @@ Every direct child of `projects/` must contain `project.json` and `README.md`. T - `createdAt` and `updatedAt`: ISO calendar dates. `updatedAt` must not precede `createdAt`. - `featured`: Boolean used for editorial emphasis, not a separate page section. - `demo`: Absolute HTTP(S) URL or `null`. -- `repository`: `./` for a project in this repository, with absolute HTTP(S) URLs reserved for future externally hosted source. -- `cover`: Project-relative image path or `null`. +- `repository`: `./` for a project in this repository, or an absolute HTTPS URL for externally hosted source. HTTP and non-web URL schemes are rejected. +- `cover`: Project-relative image path or `null`. The resolved path must remain inside the project directory; absolute paths and traversal segments such as `../` are rejected. Unknown fields are rejected so mistakes do not silently disappear from the gallery. A local JSON Schema and runtime validator define the contract. The schema is the source of truth for tooling and documentation examples. @@ -124,7 +124,7 @@ Filters update the visible index without navigation and are reflected in URL que Each project receives a statically generated page at `/projects//` with: - Title, summary, status, type, tags, and dates. -- A short rendered excerpt from the project's README. +- A short rendered excerpt from the project's README: the first non-empty paragraph after an optional leading H1, limited to 240 characters at the nearest word boundary. If no paragraph exists, the metadata summary is used. - The declared technology or context represented through tags. - A primary Demo action when `demo` exists. - A source action resolved from `repository`; `./` links to the project's directory on the configured GitHub repository. From 5682ff8effebd3f427fa159697045c04ab506e59 Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 11:51:24 +0800 Subject: [PATCH 03/40] docs: plan personal gallery implementation --- ...6-07-21-personal-gallery-implementation.md | 1128 +++++++++++++++++ 1 file changed, 1128 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-21-personal-gallery-implementation.md diff --git a/docs/superpowers/plans/2026-07-21-personal-gallery-implementation.md b/docs/superpowers/plans/2026-07-21-personal-gallery-implementation.md new file mode 100644 index 0000000..62d2deb --- /dev/null +++ b/docs/superpowers/plans/2026-07-21-personal-gallery-implementation.md @@ -0,0 +1,1128 @@ +# Faust Personal Gallery Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Turn Faust into a mixed-technology project archive with validated metadata, guided scaffolding, an accessible Astro gallery, and GitHub Pages delivery. + +**Architecture:** A root pnpm toolchain owns repository management only: Zod plus JSON Schema validate `projects/*`, CLI tools scaffold and list projects, and a deterministic generator writes one typed JSON index for Astro to consume. The gallery statically renders an editorial index and project routes under the configured GitHub Pages base path; individual projects remain isolated and are never installed or built by root commands. + +**Tech Stack:** Node.js 22, pnpm 10, TypeScript, Zod, Vitest, Astro 5, vanilla CSS and browser JavaScript, GitHub Actions/Pages + +--- + +## File Map + +### Existing content moved without rewriting + +- Move `allinone.md` to `notes/allinone.md`. +- Move `读书笔记.md` to `notes/读书笔记.md`. +- Move `随笔.md` to `notes/随笔.md`. +- Move `项目.md` to `notes/项目.md`. +- Move `DONE.md` to `notes/DONE.md`. +- Move `code/bricks.md` to `notes/code/bricks.md`; remove the empty `code/` directory. + +### Root toolchain and contract + +- Create `.gitignore` for dependencies, generated indexes, test artifacts, and Astro output. +- Create `.npmrc` to pin root-only pnpm behavior; gallery build dependencies are installed at the root and child projects are never enrolled. +- Create `package.json` with stable owner commands and root-only dependencies. +- Create `pnpm-lock.yaml` through `pnpm install`. +- Create `tsconfig.json` for Node tools and tests. +- Create `vitest.config.ts` for repository-wide unit tests. +- Create `project.schema.json` as the published metadata contract and documentation source of truth. +- Create `tools/config.ts` as the single repository identity, Pages URL, and path configuration module. +- Create `tools/project-schema.ts` as the typed Zod runtime mirror of `project.schema.json`, guarded by a comprehensive JSON-Schema/Zod parity corpus. +- Create `tools/validate-projects.ts` for repository invariants, actionable diagnostics, and CLI exit behavior. +- Create `tools/generate-project-index.ts` for deterministic typed gallery input and README excerpts. +- Create `tools/project-links.ts` for base-aware gallery, source, demo, and cover URLs. +- Create `tools/list-projects.ts` for the maintenance table command. +- Create `tools/create-project.ts` for prompt orchestration, safe staging, template rendering, validation, and atomic rename. +- Create `tools/lib/files.ts` for project discovery, safe paths, and atomic directory helpers. +- Create `tools/lib/readme.ts` for safe first-paragraph extraction. +- Create `tools/lib/templates.ts` for explicit placeholder replacement. +- Create `tools/__tests__/fixtures.ts` for isolated temporary repository fixtures. +- Create `tools/__tests__/project-schema.test.ts` for field-level contract tests. +- Create `tools/__tests__/validate-projects.test.ts` for cross-file and repository invariant tests. +- Create `tools/__tests__/generate-project-index.test.ts` for ordering, excerpts, and generated output. +- Create `tools/__tests__/project-links.test.ts` for local/external source and base-path URLs. +- Create `tools/__tests__/create-project.test.ts` for all templates, conflicts, and cleanup behavior. +- Create `tools/__tests__/list-projects.test.ts` for deterministic maintenance output. +- Create `tools/__tests__/notes-migration.test.ts` to compare moved prose against immutable pre-move hashes. + +### Templates + +- Create `templates/blank/README.md.tpl` as the universal project README template. +- Create `templates/web/README.md.tpl`, `templates/web/package.json.tpl`, `templates/web/index.html.tpl`, and `templates/web/src/main.js.tpl` as a dependency-light web starter. +- Create `templates/script/README.md.tpl`, `templates/script/package.json.tpl`, and `templates/script/src/index.mjs.tpl` as a Node script starter. + +### Astro gallery + +- Create `gallery/package.json` as metadata only; all executable gallery dependencies live in the root installation. +- Create `gallery/astro.config.mjs` using the shared site/base environment contract. +- Create `gallery/tsconfig.json` extending Astro strict settings. +- Create `gallery/src/env.d.ts` for Astro types. +- Create `gallery/src/data/projects.generated.json` as ignored generated input. +- Create `gallery/src/lib/projects.ts` for typed reads, filtering, and stable ordering. +- Create `gallery/src/layouts/BaseLayout.astro` for document metadata, landmarks, fonts, and global assets. +- Create `gallery/src/components/FilterBar.astro` for accessible type/status controls and URL state. +- Create `gallery/src/components/ProjectIndex.astro` for editorial records and empty states. +- Create `gallery/src/components/ProjectCover.astro` for real and typographic fallback covers. +- Create `gallery/src/pages/index.astro` for the gallery home page. +- Create `gallery/src/pages/projects/[slug].astro` for statically generated detail pages. +- Create `gallery/src/scripts/filters.ts` for progressive-enhancement filtering and query synchronization. +- Create `gallery/src/styles/global.css` for editorial styling, responsive behavior, focus, contrast, and reduced motion. +- Create `gallery/src/__tests__/projects.test.ts` for ordering and filtering behavior. +- Create `gallery/src/__tests__/rendering.test.ts` to build the site in a temporary output directory and inspect concrete generated HTML. + +### Delivery and documentation + +- Modify `README.md` into the owner/contributor guide. +- Create `docs/project-metadata.md` for schema-derived field guidance and lifecycle examples. +- Create `docs/gallery-maintenance.md` for validation, troubleshooting, and publishing. +- Create `.github/workflows/pages.yml` for PR checks and default-branch deployment. + +## Conventions for Every Task + +- Run all commands from the repository root unless the step says otherwise. +- Keep root scripts project-agnostic: never recurse into a project's dependency manifest or execute project code. +- Use `pnpm exec vitest run ` for a narrow red/green loop and `pnpm check` for the final gate. +- Treat generated `gallery/src/data/projects.generated.json` as build output: tests may inspect it, but it is not committed. +- Use Conventional Commit messages shown below. Each commit should contain only the files listed in its task. + +### Task 1: Preserve and migrate the existing prose + +**Files:** +- Create: `tools/__tests__/notes-migration.test.ts` +- Create: `notes/allinone.md` +- Create: `notes/读书笔记.md` +- Create: `notes/随笔.md` +- Create: `notes/项目.md` +- Create: `notes/DONE.md` +- Create: `notes/code/bricks.md` +- Delete: `allinone.md`, `读书笔记.md`, `随笔.md`, `项目.md`, `DONE.md`, `code/bricks.md` + +- [ ] **Step 1: Record the source content hashes before moving anything** + +Run: + +```bash +shasum -a 256 allinone.md 读书笔记.md 随笔.md 项目.md DONE.md code/bricks.md +``` + +Expected: six SHA-256 lines. Copy the exact hash values into the test in Step 3; do not normalize line endings or edit content. + +- [ ] **Step 2: Create the destination directories and use Git-aware moves** + +Run: + +```bash +mkdir -p notes/code tools/__tests__ +git mv allinone.md notes/allinone.md +git mv 读书笔记.md notes/读书笔记.md +git mv 随笔.md notes/随笔.md +git mv 项目.md notes/项目.md +git mv DONE.md notes/DONE.md +git mv code/bricks.md notes/code/bricks.md +rmdir code +``` + +Expected: all six files appear under `notes/`; `git status --short` reports renames and no `code/` directory remains. + +- [ ] **Step 3: Write the preservation test with the recorded hashes** + +```ts +// tools/__tests__/notes-migration.test.ts +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; +import { describe, expect, it } from 'vitest'; + +const preserved = { + 'notes/allinone.md': '', + 'notes/读书笔记.md': '', + 'notes/随笔.md': '', + 'notes/项目.md': '', + 'notes/DONE.md': '', + 'notes/code/bricks.md': '', +} as const; + +describe('notes migration', () => { + for (const [path, expected] of Object.entries(preserved)) { + it(`preserves ${path} byte-for-byte`, async () => { + const bytes = await readFile(path); + expect(createHash('sha256').update(bytes).digest('hex')).toBe(expected); + }); + } +}); +``` + +- [ ] **Step 4: Defer execution until the test runner exists, but manually re-check hashes now** + +Run: `shasum -a 256 notes/allinone.md notes/读书笔记.md notes/随笔.md notes/项目.md notes/DONE.md notes/code/bricks.md` + +Expected: each destination hash exactly matches its recorded source hash. + +- [ ] **Step 5: Commit the content-only migration** + +```bash +git add notes tools/__tests__/notes-migration.test.ts +git commit -m "chore: preserve prose under notes" +``` + +### Task 2: Establish the root management toolchain + +**Files:** +- Create: `.gitignore` +- Create: `.npmrc` +- Create: `package.json` +- Create: `pnpm-lock.yaml` +- Create: `tsconfig.json` +- Create: `vitest.config.ts` +- Create: `gallery/package.json` +- Create: `gallery/tsconfig.json` +- Create: `gallery/src/env.d.ts` + +- [ ] **Step 1: Add the root manifest with the stable public commands** + +```json +{ + "name": "faust-gallery-manager", + "private": true, + "packageManager": "pnpm@10.13.1", + "engines": { "node": ">=22 <23" }, + "scripts": { + "validate": "tsx tools/validate-projects.ts", + "generate": "tsx tools/generate-project-index.ts", + "dev": "pnpm validate && pnpm generate && astro dev --root gallery", + "build": "pnpm validate && pnpm generate && astro check --root gallery && astro build --root gallery", + "test": "vitest run", + "check": "pnpm validate && pnpm test && pnpm build", + "create:project": "tsx tools/create-project.ts", + "list:projects": "tsx tools/list-projects.ts" + }, + "dependencies": { + "@inquirer/prompts": "^7.8.0", + "markdown-it": "^14.1.0", + "sanitize-html": "^2.17.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@astrojs/check": "^0.9.4", + "@types/markdown-it": "^14.1.2", + "@types/node": "^22.16.5", + "@types/sanitize-html": "^2.16.0", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "astro": "^5.12.3", + "tsx": "^4.20.3", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + } +} +``` + +- [ ] **Step 2: Add root configuration without a pnpm workspace** + +```ini +# .npmrc +save-exact=true +strict-peer-dependencies=true +``` + +```json +// tsconfig.json +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "resolveJsonModule": true, + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["tools/**/*.ts", "vitest.config.ts"] +} +``` + +```ts +// vitest.config.ts +import { defineConfig } from 'vitest/config'; +export default defineConfig({ test: { include: ['tools/**/*.test.ts', 'gallery/src/**/*.test.ts'] } }); +``` + +Expected design constraint: do not add `pnpm-workspace.yaml`; all gallery executables resolve from root `node_modules`, while arbitrary `projects/*/package.json` files remain invisible to installation. + +- [ ] **Step 3: Add the gallery package and strict Astro TypeScript config** + +```json +// gallery/package.json +{ + "name": "@faust/gallery", + "private": true, + "type": "module" +} +``` + +```json +// gallery/tsconfig.json +{ "extends": "astro/tsconfigs/strict", "include": [".astro/types.d.ts", "**/*"] } +``` + +```ts +/// +``` + +- [ ] **Step 4: Ignore only generated and local artifacts** + +```gitignore +node_modules/ +gallery/node_modules/ +gallery/.astro/ +gallery/dist/ +gallery/src/data/projects.generated.json +gallery/public/.project-assets-* +gallery/public/project-assets +coverage/ +.DS_Store +``` + +Both project-asset patterns are regenerated output: `pnpm generate` creates immutable `.project-assets-*` versions and atomically updates the `project-assets` symlink. Neither the versions nor the symlink is committed. + +- [ ] **Step 5: Install exact dependency resolution and run the migration test** + +Run: `corepack enable && pnpm install && pnpm exec astro --version && pnpm exec vitest run tools/__tests__/notes-migration.test.ts` + +Expected: lockfile is created, the root-installed Astro executable prints its version, and the six migration tests pass. Confirm `find projects -name node_modules` prints nothing. + +- [ ] **Step 6: Commit the root toolchain** + +```bash +git add .gitignore .npmrc package.json pnpm-lock.yaml tsconfig.json vitest.config.ts gallery/package.json gallery/tsconfig.json gallery/src/env.d.ts +git commit -m "build: establish gallery management toolchain" +``` + +### Task 3: Define and test the metadata schema + +**Files:** +- Create: `project.schema.json` +- Create: `tools/project-schema.ts` +- Create: `tools/__tests__/project-schema.test.ts` + +- [ ] **Step 1: Write a shared contract corpus and failing parity tests for every constrained field** + +```ts +import { describe, expect, it } from 'vitest'; +import Ajv2020 from 'ajv/dist/2020.js'; +import addFormats from 'ajv-formats'; +import jsonSchema from '../../project.schema.json'; +import { ProjectMetadataSchema } from '../project-schema.js'; + +const valid = { + title: 'Semantic Search Playground', slug: 'semantic-search', + summary: 'Compare Chinese semantic search models.', status: 'building', type: 'ai', + tags: ['embeddings', 'search'], createdAt: '2026-07-20', updatedAt: '2026-07-21', + featured: false, demo: null, repository: './', cover: null, +}; + +describe('ProjectMetadataSchema', () => { + const invalid: Array<[string, unknown]> = [ + ['unknown key', { ...valid, surprise: true }], + ['bad slug', { ...valid, slug: 'Not Safe' }], + ['duplicate tag', { ...valid, tags: ['search', 'search'] }], + ['bad date', { ...valid, createdAt: '2026-02-30' }], + ['date reversal', { ...valid, updatedAt: '2026-07-19' }], + ['http repository', { ...valid, repository: 'http://example.com/x' }], + ['traversing cover', { ...valid, cover: '../secret.png' }], + ]; + const cases: Array<[string, unknown, boolean]> = [ + ['valid contract', valid, true], + ...invalid.map(([label, value]) => [label, value, false] as [string, unknown, boolean]), + ]; + const ajv = new Ajv2020({ allErrors: true }); + addFormats(ajv); + ajv.addKeyword({ + keyword: 'updatedAtNotBeforeCreatedAt', + schemaType: 'boolean', + type: 'object', + validate: (enabled: boolean, data: { createdAt?: string; updatedAt?: string }) => + !enabled || !data.createdAt || !data.updatedAt || data.updatedAt >= data.createdAt, + }); + const validateJson = ajv.compile(jsonSchema); + it.each(cases)('%s has identical JSON Schema and Zod results', (_label, value, expected) => { + expect(Boolean(validateJson(value))).toBe(expected); + expect(ProjectMetadataSchema.safeParse(value).success).toBe(expected); + }); +}); +``` + +Expand the corpus beyond the representative snippet: include every required field missing in turn, every enum member plus invalid values, boundary title/summary/tag values, leap/non-leap dates, both nullable branches, HTTP(S) demo rules, repository `./`/HTTPS/HTTP/non-web schemes, absolute/traversing/backslash/normalized covers, duplicate tags, unknown fields, and reversed/equal dates. Each case carries its expected result and both validators must agree. + +- [ ] **Step 2: Run the schema test and verify red** + +Run: `pnpm exec vitest run tools/__tests__/project-schema.test.ts` + +Expected: FAIL because `tools/project-schema.ts` does not exist. + +- [ ] **Step 3: Add the strict JSON Schema source of truth** + +Create `project.schema.json` using draft 2020-12, `additionalProperties: false`, all 12 required fields, exact status/type enums, kebab-case and lowercase-tag patterns, `format: date`, `uniqueItems`, and `anyOf` branches for nullable URLs/cover. Put the cross-field rule in the schema as a documented custom keyword, for example `"updatedAtNotBeforeCreatedAt": true`, and register that keyword with AJV before compilation; its implementation compares the already format-validated ISO date strings. The JSON Schema is authoritative for the complete contract because CI compiles it directly and runs the exhaustive shared corpus—including equal, ordered, and reversed dates—against both implementations; a schema or Zod edit that changes acceptance cannot pass without updating the source and parity corpus together. + +Representative definitions: + +```json +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://faust.local/project.schema.json", + "type": "object", + "updatedAtNotBeforeCreatedAt": true, + "additionalProperties": false, + "required": ["title", "slug", "summary", "status", "type", "tags", "createdAt", "updatedAt", "featured", "demo", "repository", "cover"], + "properties": { + "slug": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" }, + "status": { "enum": ["idea", "building", "shipped", "archived"] }, + "type": { "enum": ["web", "service", "cli", "ai", "script", "other"] }, + "tags": { "type": "array", "uniqueItems": true, "items": { "type": "string", "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" } }, + "repository": { "anyOf": [{ "const": "./" }, { "type": "string", "pattern": "^https://" }] } + } +} +``` + +- [ ] **Step 4: Implement the typed runtime mirror and cross-field refinements** + +```ts +import { posix } from 'node:path'; +import { z } from 'zod'; + +const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/).refine((value) => { + const [y, m, d] = value.split('-').map(Number); + const date = new Date(Date.UTC(y, m - 1, d)); + return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d; +}, 'must be a real ISO calendar date'); +const slug = z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); +const cover = z.string().refine((value) => !value.startsWith('/') && !value.split(/[\\/]/).includes('..') && posix.normalize(value) === value); + +export const ProjectMetadataSchema = z.object({ + title: z.string().trim().min(1), slug, summary: z.string().trim().min(1), + status: z.enum(['idea', 'building', 'shipped', 'archived']), + type: z.enum(['web', 'service', 'cli', 'ai', 'script', 'other']), + tags: z.array(slug).superRefine((tags, ctx) => { + if (new Set(tags).size !== tags.length) ctx.addIssue({ code: 'custom', message: 'tags must not contain duplicates' }); + }), + createdAt: isoDate, updatedAt: isoDate, featured: z.boolean(), + demo: z.string().url().refine((url) => /^https?:\/\//.test(url)).nullable(), + repository: z.union([z.literal('./'), z.string().url().refine((url) => url.startsWith('https://'))]), + cover: cover.nullable(), +}).strict().superRefine((data, ctx) => { + if (data.updatedAt < data.createdAt) ctx.addIssue({ code: 'custom', path: ['updatedAt'], message: 'must not precede createdAt' }); +}); +export type ProjectMetadata = z.infer; +``` + +- [ ] **Step 5: Run schema compilation/parity tests and type-check** + +Run: `pnpm exec vitest run tools/__tests__/project-schema.test.ts && pnpm exec tsc -p tsconfig.json` + +Expected: AJV compiles `project.schema.json` with the registered date-order keyword, every corpus case produces the same result from AJV and Zod, and TypeScript exits 0. + +- [ ] **Step 6: Commit the contract** + +```bash +git add project.schema.json tools/project-schema.ts tools/__tests__/project-schema.test.ts +git commit -m "feat: define strict project metadata contract" +``` + +### Task 4: Validate repository-level project invariants + +**Files:** +- Create: `tools/config.ts` +- Create: `tools/lib/files.ts` +- Create: `tools/validate-projects.ts` +- Create: `tools/__tests__/fixtures.ts` +- Create: `tools/__tests__/validate-projects.test.ts` + +- [ ] **Step 1: Write temporary-repository fixture helpers** + +```ts +export async function makeRepository(projects: Array<{ dir: string; metadata?: unknown; readme?: string; cover?: string }>) { + const root = await mkdtemp(join(tmpdir(), 'faust-test-')); + await mkdir(join(root, 'projects')); + for (const project of projects) { + const dir = join(root, 'projects', project.dir); await mkdir(dir); + if (project.metadata) await writeFile(join(dir, 'project.json'), JSON.stringify(project.metadata)); + if (project.readme !== undefined) await writeFile(join(dir, 'README.md'), project.readme); + if (project.cover) await writeFile(join(dir, project.cover), 'image'); + } + return root; +} +``` + +- [ ] **Step 2: Write failing tests for missing files, directory mismatch, duplicate slug, cover existence, and actionable messages** + +```ts +it('reports project, field, and corrective action', async () => { + const root = await makeRepository([{ dir: 'wrong-dir', metadata: valid, readme: '# Readme' }]); + const result = await validateProjects({ root }); + expect(result.ok).toBe(false); + expect(result.errors).toContainEqual(expect.objectContaining({ project: 'wrong-dir', field: 'slug' })); + expect(formatErrors(result.errors)).toContain('rename the directory or change slug'); +}); +``` + +Add separate cases for absent `project.json`, absent `README.md`, malformed JSON, two directories declaring the same slug, absent cover, and a cover resolving outside the project. Assert that an empty `projects/` directory is valid. + +In this same Task 4 suite, directly test the single-project primitive with a staging directory whose physical basename is random: + +```ts +it('validates a staging path against its intended final directory name', async () => { + const projectPath = join(root, 'projects', '.my-idea.stage-abc123'); + await writeValidProject(projectPath, { ...valid, slug: 'my-idea' }); + await expect(validateProjectAt({ projectPath, expectedDirectoryName: 'my-idea' })) + .resolves.toMatchObject({ ok: true, project: { slug: 'my-idea' } }); + await expect(validateProjectAt({ projectPath, expectedDirectoryName: 'different-name' })) + .resolves.toMatchObject({ ok: false, errors: [expect.objectContaining({ field: 'slug' })] }); +}); +``` + +Also prove README and cover resolution use `projectPath`, not a synthesized `projects/` path. + +- [ ] **Step 3: Run the focused suite and verify red** + +Run: `pnpm exec vitest run tools/__tests__/validate-projects.test.ts` + +Expected: FAIL because validator modules do not exist. + +- [ ] **Step 4: Add single-source repository configuration** + +```ts +export const repository = { + owner: process.env.FAUST_GITHUB_OWNER ?? 'drulu', + name: process.env.FAUST_GITHUB_REPOSITORY ?? 'faust', + branch: process.env.FAUST_GITHUB_BRANCH ?? 'main', +}; +export const pages = { + site: process.env.FAUST_SITE ?? `https://${repository.owner}.github.io`, + base: process.env.FAUST_BASE ?? `/${repository.name}`, +}; +``` + +Before committing, replace fallback owner/branch if `git remote -v` or `git symbolic-ref refs/remotes/origin/HEAD` proves different. Components must import resolved links, never copy these strings. + +- [ ] **Step 5: Implement discovery and validation as an importable function plus a thin CLI** + +Expose: + +```ts +export type ValidationError = { project: string; field: string; message: string; fix: string }; +export type ProjectValidationResult = + | { ok: true; project: ProjectMetadata } + | { ok: false; errors: ValidationError[] }; +export async function validateProjectAt(options: { + projectPath: string; + expectedDirectoryName: string; +}): Promise; +export async function validateProjects(options: { root: string; only?: string }): Promise< + | { ok: true; projects: ProjectMetadata[] } + | { ok: false; errors: ValidationError[] } +>; +``` + +Implement all per-project parsing, README checks, cover resolution, and slug comparison inside `validateProjectAt`. It reads files from `projectPath` but compares `metadata.slug` to `expectedDirectoryName`; it never derives the expected name from `basename(projectPath)`. `validateProjects` sorts direct child directories, calls `validateProjectAt({ projectPath, expectedDirectoryName: entry.name })` for each, and then adds repository-wide duplicate-slug errors. Parse JSON without executing project code. Resolve cover with `realpath`/`relative` against `projectPath` and reject paths outside it. The CLI prints one line per error as `project: field — message; fix: ...` and sets `process.exitCode = 1` without throwing a stack trace. + +- [ ] **Step 6: Run validation tests and the empty real repository** + +Run: `pnpm exec vitest run tools/__tests__/validate-projects.test.ts && pnpm validate` + +Expected: focused tests pass; the repository validator reports `Validated 0 projects` and exits 0. + +- [ ] **Step 7: Commit repository validation** + +```bash +git add tools/config.ts tools/lib/files.ts tools/validate-projects.ts tools/__tests__/fixtures.ts tools/__tests__/validate-projects.test.ts +git commit -m "feat: validate project repository invariants" +``` + +### Task 5: Generate deterministic, safe gallery data and links + +**Files:** +- Create: `tools/lib/readme.ts` +- Create: `tools/generate-project-index.ts` +- Create: `tools/project-links.ts` +- Create: `tools/__tests__/generate-project-index.test.ts` +- Create: `tools/__tests__/project-links.test.ts` + +- [ ] **Step 1: Write failing excerpt, ordering, and source-link tests** + +```ts +it('renders Markdown but removes unsafe HTML and truncates visible text', () => { + const input = '# Title\n\nFirst **useful** [paragraph](javascript:alert(1)) with enough words to truncate safely. '; + const excerpt = extractExcerptHtml(input, 'Fallback', 48); + expect(excerpt).toContain('useful'); + expect(excerpt).not.toMatch(/script|javascript:/i); + expect(visibleText(excerpt).length).toBeLessThanOrEqual(48); + expect(visibleText(excerpt).endsWith('…')).toBe(true); +}); + +it('resolves local source once from repository config', () => { + expect(sourceUrl({ slug: 'search', repository: './' }, { owner: 'a', name: 'faust', branch: 'main' })) + .toBe('https://github.com/a/faust/tree/main/projects/search'); +}); +``` + +Also assert fallback to an escaped summary when no paragraph exists, exact 240-character behavior, a single overlong word, Unicode text, inline code/emphasis/links, images removed, raw HTML removed, `javascript:`/`data:` links removed, descending `updatedAt`, slug tie-breaks, HTTPS external source passthrough, and base-prefixed project/cover paths. + +- [ ] **Step 2: Run the focused tests and verify red** + +Run: `pnpm exec vitest run tools/__tests__/generate-project-index.test.ts tools/__tests__/project-links.test.ts` + +Expected: FAIL due to missing modules. + +- [ ] **Step 3: Implement the Markdown-to-sanitized-HTML excerpt pipeline** + +Use `markdown-it` with `html: false`, `linkify: false`, and a custom renderer that drops images. Parse tokens, skip an optional leading H1, then select the first non-empty paragraph token range; never use a regex to sanitize Markdown. Convert that paragraph's inline tokens to visible Unicode text, collapse whitespace, and truncate **visible text** to at most 240 Unicode code points: if over limit, reserve one code point for `…`, cut at the last whitespace at or before 239 when one exists, otherwise cut the single long word at 239, trim, and append `…`. Re-render only the retained inline token content with allowed Markdown formatting, then run `sanitize-html` as defense in depth with tags `p`, `em`, `strong`, `code`, `a` and attributes `{ a: ['href'] }`; allow only `https`, `http`, and relative link schemes and add `rel="noreferrer"` to external links. If no paragraph exists, HTML-escape the metadata summary and wrap it in `

`. Export `extractExcerptHtml`, `visibleText`, and focused helpers so safety/truncation are directly testable. + +- [ ] **Step 4: Implement a complete generated-record interface and deterministic writer** + +```ts +export type GalleryProject = ProjectMetadata & { + excerptHtml: string; + year: string; + href: string; + sourceHref: string; + coverHref: string | null; +}; + +export async function buildProjectIndex(root: string): Promise { + const result = await validateProjects({ root }); + if (!result.ok) throw new ProjectValidationError(result.errors); + return result.projects.map(toGalleryProject).sort((a, b) => + b.updatedAt.localeCompare(a.updatedAt) || a.slug.localeCompare(b.slug)); +} +``` + +Write formatted JSON plus one trailing newline to `gallery/src/data/projects.generated.json` only after successful validation. Build covers in a new immutable version directory such as `gallery/public/.project-assets-`, copying only validated cover files to `/...`. Make `gallery/public/project-assets` a relative symlink to the active version: create a sibling temporary symlink, then `rename` that symlink over the old symlink as one atomic pointer replacement. Only after the swap succeeds, remove obsolete version directories; on failure remove only the new version and temporary symlink. Add tests that a failed copy leaves the old target and files intact and that a successful second generation removes stale covers. If the target platform cannot create symlinks, fail with an actionable message rather than falling back to a partial in-place copy. This prevents stale covers and prevents readers from observing a partial asset tree. Astro follows the public symlink but never reads arbitrary project code. + +- [ ] **Step 5: Run focused tests and generate the empty index** + +Run: `pnpm exec vitest run tools/__tests__/generate-project-index.test.ts tools/__tests__/project-links.test.ts && pnpm generate` + +Expected: tests pass; generation prints `Generated 0 projects` and writes `[]` to the ignored data file. + +- [ ] **Step 6: Commit deterministic data generation** + +```bash +git add tools/lib/readme.ts tools/generate-project-index.ts tools/project-links.ts tools/__tests__/generate-project-index.test.ts tools/__tests__/project-links.test.ts +git commit -m "feat: generate deterministic gallery project data" +``` + +### Task 6: Build safe templates and the atomic project creator + +**Files:** +- Create: `templates/blank/README.md.tpl` +- Create: `templates/web/README.md.tpl` +- Create: `templates/web/package.json.tpl` +- Create: `templates/web/index.html.tpl` +- Create: `templates/web/src/main.js.tpl` +- Create: `templates/script/README.md.tpl` +- Create: `templates/script/package.json.tpl` +- Create: `templates/script/src/index.mjs.tpl` +- Create: `tools/lib/templates.ts` +- Create: `tools/create-project.ts` +- Create: `tools/__tests__/create-project.test.ts` + +- [ ] **Step 1: Write failing API-level tests for all templates and cleanup** + +```ts +describe.each(['blank', 'web', 'script'] as const)('%s template', (template) => { + it('creates a valid, fully rendered project', async () => { + const root = await makeRepository([]); + const result = await createProject({ root, title: 'My Idea', slug: 'my-idea', type: 'other', summary: 'Try it.', template, today: '2026-07-21' }); + expect(result.path).toBe(join(root, 'projects/my-idea')); + expect(await validateProjects({ root, only: 'my-idea' })).toMatchObject({ ok: true }); + expect(await readFile(join(result.path, 'README.md'), 'utf8')).not.toContain('{{'); + }); +}); + +it('leaves no project or staging directory when validation fails', async () => { + await expect(createProject({ ...input, templateRoot: brokenTemplates })).rejects.toThrow(); + expect(await readdir(join(root, 'projects'))).toEqual([]); +}); +``` + +Add cases for slug derivation (`Crème & Search` -> `creme-search`), explicit invalid slug, existing-directory conflict, and template traversal/unknown placeholders. +Add a creator integration case where the random staging basename differs from the intended slug and spy on the Task 4 API to verify `createProject` calls `validateProjectAt({ projectPath: , expectedDirectoryName: 'my-idea' })`. Add a race test that creates the final destination immediately before publication and asserts the creator fails without overwriting it. + +- [ ] **Step 2: Run the creator suite and verify red** + +Run: `pnpm exec vitest run tools/__tests__/create-project.test.ts` + +Expected: FAIL because creator and templates do not exist. + +- [ ] **Step 3: Add explicit non-executable templates** + +Every README template must render: + +```md +# {{TITLE}} + +## Purpose + +{{SUMMARY}} + +## Development + +{{DEVELOPMENT}} + +## Status + +Idea created on {{CREATED_AT}}. +``` + +The blank template's development text says to document the chosen stack. The web starter uses plain `index.html` and `src/main.js`; the script starter uses `node src/index.mjs`. Keep dependencies empty so scaffolding never installs or executes anything. + +- [ ] **Step 4: Implement strict placeholder rendering and a two-phase creator** + +```ts +export type CreateProjectInput = { + root: string; title: string; slug?: string; type: ProjectMetadata['type']; + summary: string; template: 'blank' | 'web' | 'script'; today?: string; templateRoot?: string; +}; +export async function createProject(input: CreateProjectInput): Promise<{ path: string; metadata: ProjectMetadata }>; +``` + +Import the already committed `validateProjectAt` from `tools/validate-projects.ts`; Task 6 must not alter `tools/validate-projects.ts`, `tools/lib/files.ts`, or their Task 4 tests. Copy only regular files beneath the selected template, reject symlinks and destination traversal, replace only known `{{UPPER_SNAKE_CASE}}` placeholders, and fail if any placeholder remains. Create a sibling staging directory using `mkdtemp(join(projectsDir, `.${slug}.stage-`))`, write `project.json`, then call `validateProjectAt({ projectPath: staging, expectedDirectoryName: slug })`; slug-directory validation therefore uses the intended final basename, while cover/README checks use the physical staging path. Implement and test one `renameNoReplace(staging, final)` helper as the publication boundary: acquire `projects/.create-.lock` with exclusive `open(..., 'wx')`, reject if `final` exists, perform the same-filesystem directory `rename`, and release only this invocation's lock in `finally`. All creator calls must honor the lock, so publication is a single atomic rename and concurrent creators cannot reach an overwriting rename; `EEXIST` from either lock or destination is a conflict. Do not use copy-then-delete and do not remove/recreate the destination. In the race test, pause the first creator after locking, start a second creator, and assert exactly one final directory is published and its contents are never replaced. In `finally`, remove only the resolved staging directory and owned lock; never delete or replace an existing destination. + +- [ ] **Step 5: Add the interactive adapter without coupling prompts to core logic** + +The `isMain` path prompts for title, type, template, summary, then derived slug confirmation/edit. If the slug conflicts, re-prompt; on success print the created path and exact next command. Keep `createProject()` prompt-free for tests. + +- [ ] **Step 6: Run creator and validation suites** + +Run: `pnpm exec vitest run tools/__tests__/create-project.test.ts tools/__tests__/validate-projects.test.ts` + +Expected: all template, conflict, and cleanup tests pass. + +- [ ] **Step 7: Manually smoke-test interaction, then remove only the smoke project** + +Run: `pnpm create:project` + +Expected: prompts appear; choose `Smoke Test`, `other`, `blank`, and a short summary. Validation succeeds and prints `projects/smoke-test`. Inspect it, then run `rm -r projects/smoke-test` only after confirming the resolved path is exactly `$PWD/projects/smoke-test`. + +- [ ] **Step 8: Commit scaffolding** + +```bash +git add templates tools/lib/templates.ts tools/create-project.ts tools/__tests__/create-project.test.ts +git commit -m "feat: scaffold projects from safe templates" +``` + +### Task 7: Add the project maintenance listing + +**Files:** +- Create: `tools/list-projects.ts` +- Create: `tools/__tests__/list-projects.test.ts` + +- [ ] **Step 1: Write a failing deterministic output test** + +```ts +it('prints title, type, status, and path in gallery order', () => { + expect(formatProjectList([older, newer])).toBe([ + 'TITLE TYPE STATUS PATH', + 'Newer web shipped projects/newer', + 'Older ai building projects/older', + ].join('\n')); +}); +``` + +- [ ] **Step 2: Run the test and verify red** + +Run: `pnpm exec vitest run tools/__tests__/list-projects.test.ts` + +Expected: FAIL because `formatProjectList` does not exist. + +- [ ] **Step 3: Implement formatting over the generated domain model** + +Export `formatProjectList(projects)` for testing, validate before printing, pad columns without ANSI escapes, and print `No projects found. Run pnpm create:project.` for an empty repository. + +- [ ] **Step 4: Run test and real command** + +Run: `pnpm exec vitest run tools/__tests__/list-projects.test.ts && pnpm list:projects` + +Expected: test passes and the real command prints the intentional empty message. + +- [ ] **Step 5: Commit maintenance tooling** + +```bash +git add tools/list-projects.ts tools/__tests__/list-projects.test.ts +git commit -m "feat: list gallery projects for maintenance" +``` + +### Task 8: Build the gallery domain layer and static routes + +**Files:** +- Create: `gallery/astro.config.mjs` +- Create: `gallery/src/data/projects.generated.json` (generated, ignored) +- Create: `gallery/src/lib/projects.ts` +- Create: `gallery/src/__tests__/projects.test.ts` +- Create: `gallery/src/layouts/BaseLayout.astro` +- Create: `gallery/src/components/ProjectCover.astro` +- Create: `gallery/src/components/ProjectIndex.astro` +- Create: `gallery/src/pages/index.astro` +- Create: `gallery/src/pages/projects/[slug].astro` +- Create: `gallery/src/__tests__/rendering.test.ts` + +- [ ] **Step 1: Write failing gallery-domain tests** + +```ts +it('defaults to all non-archived projects', () => { + expect(filterProjects(projects, { type: 'all', status: 'active' }).map(p => p.slug)) + .toEqual(['newest', 'older']); +}); +it('allows an explicit archived filter', () => { + expect(filterProjects(projects, { type: 'all', status: 'archived' }).map(p => p.slug)) + .toEqual(['archive']); +}); +``` + +Also test stable tie ordering and exact type/status matching. + +- [ ] **Step 2: Run the domain suite and verify red** + +Run: `pnpm exec vitest run gallery/src/__tests__/projects.test.ts` + +Expected: FAIL because the gallery domain module does not exist. + +- [ ] **Step 3: Implement typed JSON reading, ordering, and pure filtering** + +```ts +import raw from '../data/projects.generated.json'; +import type { GalleryProject } from '../../../tools/generate-project-index.js'; +export const projects = (raw as GalleryProject[]).toSorted((a, b) => + b.updatedAt.localeCompare(a.updatedAt) || a.slug.localeCompare(b.slug)); +export function filterProjects(items: GalleryProject[], filters: { type: string; status: string }) { + return items.filter((p) => (filters.type === 'all' || p.type === filters.type) + && (filters.status === 'active' ? p.status !== 'archived' : filters.status === 'all' || p.status === filters.status)); +} +``` + +- [ ] **Step 4: Configure Astro from the shared environment contract** + +```js +import { defineConfig } from 'astro/config'; +export default defineConfig({ + site: process.env.FAUST_SITE ?? 'https://drulu.github.io', + base: process.env.FAUST_BASE ?? '/faust', + output: 'static', + trailingSlash: 'always', +}); +``` + +Keep the defaults synchronized with `tools/config.ts`; tests must override environment values to prove custom repositories work. + +- [ ] **Step 5: Create semantic home and project routes** + +The home page uses `

`, `
`, a skip link, project count, filters, and `
    ` records. When zero projects exist, render `No projects yet. Create one with pnpm create:project.` On project pages, use `getStaticPaths()` from generated data and render metadata, the already sanitized `excerptHtml` with Astro's `set:html`, conditional Demo link, source link, and cover/fallback. No component may render project README source directly; `excerptHtml` is the only reviewed HTML boundary. + +Representative static paths: + +```astro +--- +import { projects } from '../../lib/projects'; +export function getStaticPaths() { + return projects.map((project) => ({ params: { slug: project.slug }, props: { project } })); +} +const { project } = Astro.props; +--- +``` + +- [ ] **Step 6: Add concrete build-output tests for routes, optional actions, and Pages base** + +In `rendering.test.ts`, create an isolated fixture repository containing projects with/without Demo and cover plus malicious/Markdown README content. Run the real generator and `astro build --root gallery --outDir ` with `FAUST_BASE=/test-base`, then read the emitted `index.html` and `projects//index.html`. Assert: routes exist; internal links start `/test-base/`; no empty Demo placeholder; local/external source URLs are exact; fallback cover text appears; safe `` markup survives; and `script`, `javascript:`, raw event attributes, and untrusted README HTML do not appear. Parse only relevant fragments or use targeted assertions—do not snapshot whole pages. Ensure temporary generated data/assets/output are restored or removed in `afterEach`. + +- [ ] **Step 7: Run gallery tests and the first static build** + +Run: `pnpm exec vitest run gallery/src/__tests__/projects.test.ts gallery/src/__tests__/rendering.test.ts && pnpm build` + +Expected: the test itself completes a real temporary Astro build and passes all generated-HTML assertions; Astro check reports no errors; `gallery/dist/index.html` exists and empty-project build succeeds under `/faust/`. + +- [ ] **Step 8: Commit the static gallery structure** + +```bash +git add gallery/astro.config.mjs gallery/src/lib gallery/src/layouts gallery/src/components/ProjectCover.astro gallery/src/components/ProjectIndex.astro gallery/src/pages gallery/src/__tests__ +git commit -m "feat: render static gallery and project routes" +``` + +### Task 9: Add shareable accessible filters + +**Files:** +- Create: `gallery/src/components/FilterBar.astro` +- Create: `gallery/src/scripts/filters.ts` +- Modify: `gallery/src/pages/index.astro` +- Modify: `gallery/src/components/ProjectIndex.astro` +- Modify: `gallery/src/__tests__/rendering.test.ts` + +- [ ] **Step 1: Write failing tests for filter markup and state semantics** + +Assert that controls have explicit labels, active/default options exist, each record exposes `data-type`/`data-status`, the count uses `aria-live="polite"`, empty results contain a reset button, and the default active state excludes archived records. + +- [ ] **Step 2: Run the rendering suite and verify red** + +Run: `pnpm exec vitest run gallery/src/__tests__/rendering.test.ts` + +Expected: FAIL on missing filter semantics. + +- [ ] **Step 3: Implement progressively enhanced form controls** + +Use real `` controls inside a GET form. The server-rendered default list shows non-archived work; JavaScript enhances it in place, reads valid `type`/`status` values from `URLSearchParams`, ignores invalid values, toggles `hidden`, updates count/empty state, and calls `history.replaceState` with only non-default filters. + +```ts +function apply() { + let visible = 0; + for (const row of rows) { + const show = matches(row, type.value, status.value); + row.hidden = !show; + if (show) visible += 1; + } + count.textContent = `${visible} ${visible === 1 ? 'project' : 'projects'}`; + empty.hidden = visible !== 0; + syncQuery(type.value, status.value); +} +``` + +- [ ] **Step 4: Add keyboard-safe reset behavior** + +The reset button returns both fields to defaults, reapplies filters, and moves focus to the project index heading. Do not trap focus or intercept normal select keyboard behavior. + +- [ ] **Step 5: Run tests and inspect the query behavior locally** + +Run: `pnpm exec vitest run gallery/src/__tests__/rendering.test.ts && pnpm dev` + +Expected: tests pass; browser checks at `/faust/?type=web&status=shipped` retain filters on reload, invalid query values fall back safely, and Reset restores the active/non-archived view. Stop the server after checking. + +- [ ] **Step 6: Commit filtering** + +```bash +git add gallery/src/components/FilterBar.astro gallery/src/scripts/filters.ts gallery/src/pages/index.astro gallery/src/components/ProjectIndex.astro gallery/src/__tests__/rendering.test.ts +git commit -m "feat: add shareable accessible project filters" +``` + +### Task 10: Apply responsive editorial styling and accessibility safeguards + +**Files:** +- Create: `gallery/src/styles/global.css` +- Modify: `gallery/src/layouts/BaseLayout.astro` +- Modify: `gallery/src/components/FilterBar.astro` +- Modify: `gallery/src/components/ProjectIndex.astro` +- Modify: `gallery/src/components/ProjectCover.astro` +- Modify: `gallery/src/pages/projects/[slug].astro` + +- [ ] **Step 1: Establish tokens and resilient typography** + +Use a self-hosted font only if licensed font files are added; otherwise use an explicit resilient stack such as `Iowan Old Style, Palatino Linotype, Book Antiqua, Georgia, serif` for display and `Inter, ui-sans-serif, system-ui, sans-serif` for body. Define cool-neutral background/text/rule/accent tokens with verified WCAG AA pairs. + +- [ ] **Step 2: Style the index as editorial records, not equal cards** + +Use fine top rules, asymmetric title/metadata columns at wide widths, varied but restrained featured emphasis (for example a thicker leading rule and small `Featured` label), generous vertical rhythm, and no gradients/glass/shadows. Preserve source order regardless of visual layout. + +- [ ] **Step 3: Add narrow-screen single-column behavior** + +At `max-width: 48rem`, stack filters and record metadata, keep actions at least 44 CSS pixels tall, prevent long URLs/titles from overflowing, and keep the count and reset controls visible. + +- [ ] **Step 4: Add focus, motion, and semantic state rules** + +```css +:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; } +[hidden] { display: none !important; } +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { scroll-behavior: auto !important; transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; } +} +``` + +Do not remove outlines. Underline text links; do not rely on color alone for status/featured meaning. + +- [ ] **Step 5: Run build and perform the manual accessibility matrix** + +Run: `pnpm build && pnpm dev` + +Expected automated outcome: Astro check and build pass. Manual checks at 1440px, 768px, and 320px confirm no horizontal scroll and intentional fallback covers. Keyboard-only check confirms skip link, controls, reset, project links, Demo/source links, and visible focus. OS/browser reduced-motion emulation confirms transitions are suppressed. Use browser accessibility tooling to confirm landmarks, labels, heading order, and AA contrast. Stop the server. + +- [ ] **Step 6: Commit presentation and accessibility** + +```bash +git add gallery/src/styles/global.css gallery/src/layouts/BaseLayout.astro gallery/src/components gallery/src/pages/projects/'[slug].astro' +git commit -m "feat: style an accessible responsive project archive" +``` + +### Task 11: Document ownership, metadata, and maintenance + +**Files:** +- Modify: `README.md` +- Create: `docs/project-metadata.md` +- Create: `docs/gallery-maintenance.md` + +- [ ] **Step 1: Rewrite the root README as a fresh-checkout guide** + +Include purpose, exact tree, Node 22/corepack prerequisites, `pnpm install`, all six stable commands, project isolation, creation/update workflow, statuses, generated-file note, and links to both detailed documents. Explicitly state that root commands never install/build project implementations. + +- [ ] **Step 2: Document every schema field from `project.schema.json`** + +Include the canonical JSON example from the spec, enum tables, `./` source resolution, cover safety, date ordering, unknown-field rejection, tag rules, and actionable invalid examples. State that `project.schema.json` is authoritative if prose drifts. + +- [ ] **Step 3: Document gallery operations and failure recovery** + +Cover local development, empty repository behavior, validator messages, generated index/covers, Pages site/base overrides, manual accessibility matrix, and how to add future templates without coupling project runtimes. + +- [ ] **Step 4: Verify every documented command exists** + +Run: + +```bash +node -e "const p=require('./package.json'); for (const s of ['dev','build','check','create:project','list:projects']) if (!p.scripts[s]) process.exit(1)" +pnpm validate +pnpm list:projects +``` + +Expected: script check exits 0; validation and listing produce their documented empty-repository messages. + +- [ ] **Step 5: Commit documentation** + +```bash +git add README.md docs/project-metadata.md docs/gallery-maintenance.md +git commit -m "docs: explain project and gallery maintenance" +``` + +### Task 12: Validate and deploy with GitHub Pages CI + +**Files:** +- Create: `.github/workflows/pages.yml` +- Modify: `gallery/astro.config.mjs` only if verified repository defaults differ +- Modify: `tools/config.ts` only if verified repository defaults differ + +- [ ] **Step 1: Write the least-privilege pull-request check job** + +```yaml +name: Gallery +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.13.1 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm check + env: + FAUST_GITHUB_OWNER: ${{ github.repository_owner }} + FAUST_GITHUB_REPOSITORY: ${{ github.event.repository.name }} + FAUST_GITHUB_BRANCH: ${{ github.event.repository.default_branch }} + FAUST_SITE: https://${{ github.repository_owner }}.github.io + FAUST_BASE: /${{ github.event.repository.name }} +``` + +- [ ] **Step 2: Add push-only artifact upload and deployment jobs** + +Add `configure-pages` and `upload-pages-artifact` after `pnpm check` only for a push to the default branch. Add a separate `deploy` job with `needs: check`, `if: github.event_name == 'push'`, environment `github-pages`, and job-level permissions `pages: write`, `id-token: write`, `contents: read`; use `actions/deploy-pages@v4`. Upload `gallery/dist` exactly once. + +- [ ] **Step 3: Validate workflow syntax and local frozen install** + +Run: `pnpm install --frozen-lockfile && pnpm check` + +Expected: install makes no lockfile changes; metadata tests, all unit/rendering tests, Astro check, and production build pass. + +- [ ] **Step 4: Verify artifact paths and Pages base explicitly** + +Run: + +```bash +test -f gallery/dist/index.html +grep -R 'href="/faust/' gallery/dist/index.html +test ! -e gallery/dist/faust/index.html +``` + +Expected: artifact root is `gallery/dist`, generated links include `/faust/`, and Astro has not incorrectly nested output beneath another `faust/` directory. For a differently named repository, rerun with `FAUST_BASE=/test-repo pnpm build` and expect `/test-repo/` links. + +- [ ] **Step 5: Commit CI delivery** + +```bash +git add .github/workflows/pages.yml gallery/astro.config.mjs tools/config.ts +git commit -m "ci: validate and deploy gallery to GitHub Pages" +``` + +### Task 13: Run end-to-end acceptance verification + +**Files:** +- Test only: all files above +- Modify only if a failing acceptance check reveals a defect; commit fixes separately by concern + +- [ ] **Step 1: Verify a completely clean dependency install** + +Run: `pnpm install --frozen-lockfile` + +Expected: exits 0 with no `pnpm-lock.yaml` diff. + +- [ ] **Step 2: Run the full automated gate** + +Run: `pnpm check` + +Expected: metadata validation passes; all Vitest suites pass; Astro check reports 0 errors; static build exits 0. + +- [ ] **Step 3: Exercise all three templates in an isolated temporary clone** + +Create a temporary clone with `mktemp -d`, run the exported noninteractive `createProject()` for blank/web/script fixtures, then run `pnpm validate`, `pnpm list:projects`, and `pnpm build` there. Do not add acceptance fixtures to the real `projects/` directory. + +Expected: three valid mixed templates coexist, list order is deterministic, and all three project detail routes are generated without installing either child `package.json`. + +- [ ] **Step 4: Re-verify prose preservation** + +Run: `pnpm exec vitest run tools/__tests__/notes-migration.test.ts` + +Expected: all six byte-for-byte hash tests pass. + +- [ ] **Step 5: Run the final manual experience matrix** + +Serve the production build using `pnpm exec astro preview --root gallery`. Check desktop/mobile, keyboard order and focus visibility, default/archive/type filters and shareable queries, no-match Reset, no-project guidance (using an isolated generated empty index), real/fallback cover, Demo present/absent, local/external source links, unsafe README HTML omission, and reduced-motion mode. + +Expected: every acceptance criterion in the approved design has observable evidence and no console errors or broken internal links. + +- [ ] **Step 6: Inspect repository boundaries and final status** + +Run: + +```bash +git status --short +git diff --check +find projects -mindepth 2 -name node_modules -o -name dist +git log --oneline --decorate -13 +``` + +Expected: only intentional uncommitted verification artifacts (ideally none), no whitespace errors, no root-created project build/dependency directories, and the planned focused commits are visible. + +- [ ] **Step 7: Push the implementation branch and open a pull request** + +Run: `git push -u origin HEAD` + +Expected: branch uploads successfully. Open a pull request summarizing schema/tooling/gallery/CI changes and paste `pnpm check` plus manual matrix results. The PR `check` job must pass and must not deploy. + +- [ ] **Step 8: Merge through the repository's normal review process and observe deployment** + +Expected: the default-branch push runs `check`, uploads `gallery/dist`, and the `deploy` job reports the Pages URL. Visit `https://.github.io//`, one project route, and one filter query; confirm assets, source links, and trailing-slash routes work at the repository subpath. + +- [ ] **Step 9: Record deployment-only fixes as focused commits** + +If observation reveals a defect, add a regression test first, reproduce it locally with the exact `FAUST_SITE`/`FAUST_BASE`, implement the smallest fix, rerun `pnpm check`, and commit with a narrow `fix:` message. Do not bypass validation or edit generated output directly. From 3dc4316b676a1bced1b662835633f3cd17fe4e3b Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 11:52:57 +0800 Subject: [PATCH 04/40] chore: preserve prose under notes --- DONE.md => notes/DONE.md | 0 allinone.md => notes/allinone.md | 0 {code => notes/code}/bricks.md | 0 ...73\344\271\246\347\254\224\350\256\260.md" | 0 .../\351\232\217\347\254\224.md" | 0 .../\351\241\271\347\233\256.md" | 0 tools/__tests__/notes-migration.test.ts | 27 +++++++++++++++++++ 7 files changed, 27 insertions(+) rename DONE.md => notes/DONE.md (100%) rename allinone.md => notes/allinone.md (100%) rename {code => notes/code}/bricks.md (100%) rename "\350\257\273\344\271\246\347\254\224\350\256\260.md" => "notes/\350\257\273\344\271\246\347\254\224\350\256\260.md" (100%) rename "\351\232\217\347\254\224.md" => "notes/\351\232\217\347\254\224.md" (100%) rename "\351\241\271\347\233\256.md" => "notes/\351\241\271\347\233\256.md" (100%) create mode 100644 tools/__tests__/notes-migration.test.ts diff --git a/DONE.md b/notes/DONE.md similarity index 100% rename from DONE.md rename to notes/DONE.md diff --git a/allinone.md b/notes/allinone.md similarity index 100% rename from allinone.md rename to notes/allinone.md diff --git a/code/bricks.md b/notes/code/bricks.md similarity index 100% rename from code/bricks.md rename to notes/code/bricks.md diff --git "a/\350\257\273\344\271\246\347\254\224\350\256\260.md" "b/notes/\350\257\273\344\271\246\347\254\224\350\256\260.md" similarity index 100% rename from "\350\257\273\344\271\246\347\254\224\350\256\260.md" rename to "notes/\350\257\273\344\271\246\347\254\224\350\256\260.md" diff --git "a/\351\232\217\347\254\224.md" "b/notes/\351\232\217\347\254\224.md" similarity index 100% rename from "\351\232\217\347\254\224.md" rename to "notes/\351\232\217\347\254\224.md" diff --git "a/\351\241\271\347\233\256.md" "b/notes/\351\241\271\347\233\256.md" similarity index 100% rename from "\351\241\271\347\233\256.md" rename to "notes/\351\241\271\347\233\256.md" diff --git a/tools/__tests__/notes-migration.test.ts b/tools/__tests__/notes-migration.test.ts new file mode 100644 index 0000000..fd5bf9c --- /dev/null +++ b/tools/__tests__/notes-migration.test.ts @@ -0,0 +1,27 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const repositoryRoot = fileURLToPath(new URL("../../", import.meta.url)); + +const expectedHashes = { + "notes/allinone.md": "71d6e5abc20270e0bf14cc6271bddaca47b6241f390108d7bd6b0c0f14b6eec7", + "notes/读书笔记.md": "9ffa1ea8a4e97ef8fc7b7098c5c1bc6cbd857012cda33e8c3d36033b85565538", + "notes/随笔.md": "0614c813d9159b284ca86a9400783058058ea6d6ef00df033441bf66b1af014f", + "notes/项目.md": "ef5da0f6ad97d24e571d1f3d6e70b0f14d4e7bbd308ca7a08cc3dee20632632c", + "notes/DONE.md": "df46263d940594ba5c06d8ebdeb7c881f67166c939aa7dd9920a2eed1577918e", + "notes/code/bricks.md": "b204f9e57fe16a0797cccaa7a4ce2235eefd346717db30e1c7d8b11453cc9bd2", +} as const; + +describe("notes migration", () => { + for (const [relativePath, expectedHash] of Object.entries(expectedHashes)) { + it(`preserves the bytes of ${relativePath}`, async () => { + const contents = await readFile(`${repositoryRoot}${relativePath}`); + const actualHash = createHash("sha256").update(contents).digest("hex"); + + expect(actualHash).toBe(expectedHash); + }); + } +}); From 7f5677384667354f6682f0c57ca97d9aec40b296 Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 11:56:07 +0800 Subject: [PATCH 05/40] build: establish gallery management toolchain --- .gitignore | 9 + .npmrc | 2 + gallery/package.json | 5 + gallery/src/env.d.ts | 1 + gallery/tsconfig.json | 1 + package.json | 34 + pnpm-lock.yaml | 5231 +++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 12 + vitest.config.ts | 3 + 9 files changed, 5298 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 gallery/package.json create mode 100644 gallery/src/env.d.ts create mode 100644 gallery/tsconfig.json create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28b2fff --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +gallery/node_modules/ +gallery/.astro/ +gallery/dist/ +gallery/src/data/projects.generated.json +gallery/public/.project-assets-* +gallery/public/project-assets +coverage/ +.DS_Store diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..16d6d69 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +save-exact=true +strict-peer-dependencies=true diff --git a/gallery/package.json b/gallery/package.json new file mode 100644 index 0000000..553a5ae --- /dev/null +++ b/gallery/package.json @@ -0,0 +1,5 @@ +{ + "name": "@faust/gallery", + "private": true, + "type": "module" +} diff --git a/gallery/src/env.d.ts b/gallery/src/env.d.ts new file mode 100644 index 0000000..f964fe0 --- /dev/null +++ b/gallery/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/gallery/tsconfig.json b/gallery/tsconfig.json new file mode 100644 index 0000000..c22fc08 --- /dev/null +++ b/gallery/tsconfig.json @@ -0,0 +1 @@ +{ "extends": "astro/tsconfigs/strict", "include": [".astro/types.d.ts", "**/*"] } diff --git a/package.json b/package.json new file mode 100644 index 0000000..0897194 --- /dev/null +++ b/package.json @@ -0,0 +1,34 @@ +{ + "name": "faust-gallery-manager", + "private": true, + "packageManager": "pnpm@10.13.1", + "engines": { "node": ">=22 <23" }, + "scripts": { + "validate": "tsx tools/validate-projects.ts", + "generate": "tsx tools/generate-project-index.ts", + "dev": "pnpm validate && pnpm generate && astro dev --root gallery", + "build": "pnpm validate && pnpm generate && astro check --root gallery && astro build --root gallery", + "test": "vitest run", + "check": "pnpm validate && pnpm test && pnpm build", + "create:project": "tsx tools/create-project.ts", + "list:projects": "tsx tools/list-projects.ts" + }, + "dependencies": { + "@inquirer/prompts": "^7.8.0", + "markdown-it": "^14.1.0", + "sanitize-html": "^2.17.0", + "zod": "^3.25.76" + }, + "devDependencies": { + "@astrojs/check": "^0.9.4", + "@types/markdown-it": "^14.1.2", + "@types/node": "^22.16.5", + "@types/sanitize-html": "^2.16.0", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "astro": "^5.12.3", + "tsx": "^4.20.3", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..c0360f2 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,5231 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@inquirer/prompts': + specifier: ^7.8.0 + version: 7.10.1(@types/node@22.20.1) + markdown-it: + specifier: ^14.1.0 + version: 14.3.0 + sanitize-html: + specifier: ^2.17.0 + version: 2.17.6 + zod: + specifier: ^3.25.76 + version: 3.25.76 + devDependencies: + '@astrojs/check': + specifier: ^0.9.4 + version: 0.9.9(prettier@3.9.5)(typescript@5.9.3) + '@types/markdown-it': + specifier: ^14.1.2 + version: 14.1.2 + '@types/node': + specifier: ^22.16.5 + version: 22.20.1 + '@types/sanitize-html': + specifier: ^2.16.0 + version: 2.16.1 + ajv: + specifier: ^8.17.1 + version: 8.20.0 + ajv-formats: + specifier: ^3.0.1 + version: 3.0.1(ajv@8.20.0) + astro: + specifier: ^5.12.3 + version: 5.18.2(@types/node@22.20.1)(rollup@4.62.2)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0) + tsx: + specifier: ^4.20.3 + version: 4.23.1 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(tsx@4.23.1)(yaml@2.9.0) + +packages: + + '@astrojs/check@0.9.9': + resolution: {integrity: sha512-A5UW8uIuErLWEoRQvzgXpO1gTjUFtK8r7nU2Z7GewAMxUb7bPvpk11qaKKgxqXlHJWlAvaaxy+Xg28A6bmQ1Tg==} + hasBin: true + peerDependencies: + typescript: ^5.0.0 || ^6.0.0 + + '@astrojs/compiler@2.13.1': + resolution: {integrity: sha512-f3FN83d2G/v32ipNClRKgYv30onQlMZX1vCeZMjPsMMPl1mDpmbl0+N5BYo4S/ofzqJyS5hvwacEo0CCVDn/Qg==} + + '@astrojs/internal-helpers@0.7.6': + resolution: {integrity: sha512-GOle7smBWKfMSP8osUIGOlB5kaHdQLV3foCsf+5Q9Wsuu+C6Fs3Ez/ttXmhjZ1HkSgsogcM1RXSjjOVieHq16Q==} + + '@astrojs/language-server@2.16.12': + resolution: {integrity: sha512-3LpFphBCzveUgm5ZVDINB/v3YA4TgPa1EMOEFn3Zt/Ww6jojR25iN+kmzeUz7v/b9xkmq+hMACTX4hizN3VCEQ==} + hasBin: true + peerDependencies: + prettier: ^3.0.0 + prettier-plugin-astro: '>=0.11.0' + peerDependenciesMeta: + prettier: + optional: true + prettier-plugin-astro: + optional: true + + '@astrojs/markdown-remark@6.3.11': + resolution: {integrity: sha512-hcaxX/5aC6lQgHeGh1i+aauvSwIT6cfyFjKWvExYSxUhZZBBdvCliOtu06gbQyhbe0pGJNoNmqNlQZ5zYUuIyQ==} + + '@astrojs/prism@3.3.0': + resolution: {integrity: sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + + '@astrojs/telemetry@3.3.0': + resolution: {integrity: sha512-UFBgfeldP06qu6khs/yY+q1cDAaArM2/7AEIqQ9Cuvf7B1hNLq0xDrZkct+QoIGyjq56y8IaE2I3CTvG99mlhQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0} + + '@astrojs/yaml2ts@0.2.4': + resolution: {integrity: sha512-8oddpOae35pJsXPQXhTkM0ypfKPskVsh2bCxRtbf7e+/Epw2nReakFYpLKjZMEr75CsoF203PMnCocpfz0s69A==} + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@capsizecss/unpack@4.0.1': + resolution: {integrity: sha512-CuNiSqg7+e1cO/GjffyMOm5Tt2jUF9CWHHnvQ/UkqvtkGfHdgwEC0wpmq7fkN3gxwpRnrAN0WzO3vREKmNolMQ==} + engines: {node: '>=18'} + + '@emmetio/abbreviation@2.3.3': + resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} + + '@emmetio/css-abbreviation@2.1.8': + resolution: {integrity: sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==} + + '@emmetio/css-parser@0.4.1': + resolution: {integrity: sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ==} + + '@emmetio/html-matcher@1.3.0': + resolution: {integrity: sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==} + + '@emmetio/scanner@1.0.4': + resolution: {integrity: sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==} + + '@emmetio/stream-reader-utils@0.1.0': + resolution: {integrity: sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==} + + '@emmetio/stream-reader@2.2.0': + resolution: {integrity: sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==} + + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + + '@inquirer/ansi@1.0.2': + resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} + engines: {node: '>=18'} + + '@inquirer/checkbox@4.3.2': + resolution: {integrity: sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/confirm@5.1.21': + resolution: {integrity: sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/core@10.3.2': + resolution: {integrity: sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/editor@4.2.23': + resolution: {integrity: sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/expand@4.0.23': + resolution: {integrity: sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/external-editor@1.0.3': + resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/figures@1.0.15': + resolution: {integrity: sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==} + engines: {node: '>=18'} + + '@inquirer/input@4.3.1': + resolution: {integrity: sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/number@3.0.23': + resolution: {integrity: sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/password@4.0.23': + resolution: {integrity: sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/prompts@7.10.1': + resolution: {integrity: sha512-Dx/y9bCQcXLI5ooQ5KyvA4FTgeo2jYj/7plWfV5Ak5wDPKQZgudKez2ixyfz7tKXzcJciTxqLeK7R9HItwiByg==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/rawlist@4.1.11': + resolution: {integrity: sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/search@3.2.2': + resolution: {integrity: sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/select@4.4.2': + resolution: {integrity: sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@inquirer/type@3.0.10': + resolution: {integrity: sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==} + engines: {node: '>=18'} + peerDependencies: + '@types/node': '>=18' + peerDependenciesMeta: + '@types/node': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@rollup/pluginutils@5.4.0': + resolution: {integrity: sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + + '@rollup/rollup-android-arm-eabi@4.62.2': + resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.2': + resolution: {integrity: sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.2': + resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.2': + resolution: {integrity: sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.2': + resolution: {integrity: sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.2': + resolution: {integrity: sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + resolution: {integrity: sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + resolution: {integrity: sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + resolution: {integrity: sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.62.2': + resolution: {integrity: sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + resolution: {integrity: sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.62.2': + resolution: {integrity: sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + resolution: {integrity: sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + resolution: {integrity: sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + resolution: {integrity: sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + resolution: {integrity: sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + resolution: {integrity: sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.62.2': + resolution: {integrity: sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.62.2': + resolution: {integrity: sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.62.2': + resolution: {integrity: sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.2': + resolution: {integrity: sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + resolution: {integrity: sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + resolution: {integrity: sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.2': + resolution: {integrity: sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.2': + resolution: {integrity: sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==} + cpu: [x64] + os: [win32] + + '@shikijs/core@3.23.0': + resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} + + '@shikijs/engine-javascript@3.23.0': + resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} + + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + + '@types/node@22.20.1': + resolution: {integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==} + + '@types/sanitize-html@2.16.1': + resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@ungap/structured-clone@1.3.3': + resolution: {integrity: sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + '@volar/kit@2.4.28': + resolution: {integrity: sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg==} + peerDependencies: + typescript: '*' + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/language-server@2.4.28': + resolution: {integrity: sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw==} + + '@volar/language-service@2.4.28': + resolution: {integrity: sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vscode/emmet-helper@2.11.0': + resolution: {integrity: sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw==} + + '@vscode/l10n@0.0.18': + resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} + peerDependencies: + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv-i18n@4.2.0: + resolution: {integrity: sha512-v/ei2UkCEeuKNXh8RToiFsUclmU+G57LO1Oo22OagNMENIw+Yb8eMwvHu7Vn9fmkjJyv6XclhJ8TbuigSglPkg==} + peerDependencies: + ajv: ^8.0.0-beta.0 + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + + array-iterate@2.0.1: + resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + astro@5.18.2: + resolution: {integrity: sha512-TnFwLnAXty5MXKPDGuKXqK4AMBXG+FH6RUdK7Oyc3gyfNoFIthT+4eRbzOK43bdRlLaZuxgciDSjgtggZ3OtGQ==} + engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + + base-64@1.0.0: + resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} + + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + + boxen@8.0.1: + resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} + engines: {node: '>=18'} + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + camelcase@8.0.0: + resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} + engines: {node: '>=16'} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + chardet@2.2.0: + resolution: {integrity: sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + ci-info@4.4.0: + resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} + engines: {node: '>=8'} + + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + commander@11.1.0: + resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} + engines: {node: '>=16'} + + common-ancestor-path@1.0.1: + resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} + + cookie-es@1.2.3: + resolution: {integrity: sha512-lXVyvUvrNXblMqzIRrxHb57UUVmqsSWlxqt3XIjCkUP0wDAf6uicO6KMbEgYrMNtEvWgWHwe42CKxPu9MYAnWw==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + crossws@0.3.5: + resolution: {integrity: sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA==} + + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-tree@2.2.1: + resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csso@5.0.5: + resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} + + dayjs@1.11.21: + resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + destr@2.0.5: + resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + deterministic-object-hash@2.0.2: + resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==} + engines: {node: '>=18'} + + devalue@5.8.2: + resolution: {integrity: sha512-DObPPAfdtFbXjxLqK8s2Xk9ZuWz5+ZoFEhC7J76es4GU/rEiXwHTmbImoCdyoCOcBH1UF3+Cz6Z2sYD4hyl5TA==} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} + engines: {node: '>=0.3.1'} + + dlv@1.1.3: + resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + + dom-serializer@2.0.0: + resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + + dom-serializer@3.1.1: + resolution: {integrity: sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==} + engines: {node: '>=20.19.0'} + + domelementtype@2.3.0: + resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} + + domelementtype@3.0.0: + resolution: {integrity: sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==} + engines: {node: '>=20.19.0'} + + domhandler@5.0.3: + resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==} + engines: {node: '>= 4'} + + domhandler@6.0.1: + resolution: {integrity: sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==} + engines: {node: '>=20.19.0'} + + domutils@3.2.2: + resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + + domutils@4.0.2: + resolution: {integrity: sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==} + engines: {node: '>=20.19.0'} + + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + + emmet@2.4.11: + resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} + + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + + fontace@0.4.1: + resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==} + + fontkitten@1.0.3: + resolution: {integrity: sha512-Wp1zXWPVUPBmfoa3Cqc9ctaKuzKAV6uLstRqlR56kSjplf5uAce+qeyYym7F+PHbGTk+tCEdkCW6RD7DX/gBZw==} + engines: {node: '>=20'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + + h3@1.15.11: + resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.3: + resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-raw@9.1.0: + resolution: {integrity: sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-to-parse5@8.0.1: + resolution: {integrity: sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@9.0.1: + resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + + htmlparser2@12.0.0: + resolution: {integrity: sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==} + engines: {node: '>=20.19.0'} + + http-cache-semantics@4.2.0: + resolution: {integrity: sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==} + + iconv-lite@0.7.3: + resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} + engines: {node: '>=0.10.0'} + + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + + is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + jsonc-parser@2.3.1: + resolution: {integrity: sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + + kleur@3.0.3: + resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} + engines: {node: '>=6'} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + launder@1.7.1: + resolution: {integrity: sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==} + + linkify-it@5.0.2: + resolution: {integrity: sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magicast@0.5.3: + resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + + markdown-it@14.3.0: + resolution: {integrity: sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==} + hasBin: true + + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-definitions@6.0.0: + resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.2: + resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + + mdn-data@2.0.28: + resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + mdurl@2.0.0: + resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} + + micromark-core-commonmark@2.0.3: + resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.1: + resolution: {integrity: sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.0.1: + resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + micromark@4.0.2: + resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + mute-stream@2.0.0: + resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==} + engines: {node: ^18.17.0 || >=20.5.0} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + neotraverse@0.6.18: + resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} + engines: {node: '>= 10'} + + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + + node-fetch-native@1.6.7: + resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} + + node-mock-http@1.0.4: + resolution: {integrity: sha512-8DY+kFsDkNXy1sJglUfuODx1/opAGJGyrTuFqEoN90oRc2Vk0ZbD4K2qmKXBBEhZQzdKHIVfEJpDU8Ak2NJEvQ==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + + ofetch@1.5.1: + resolution: {integrity: sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA==} + + ohash@2.0.11: + resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==} + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + p-limit@6.2.0: + resolution: {integrity: sha512-kuUqqHNUqoIWp/c467RI4X6mmyuojY5jGutNU0wVTmEOOfcuwLqyMVoAi9MKi2Ak+5i9+nhmrK4ufZE8069kHA==} + engines: {node: '>=18'} + + p-queue@8.1.1: + resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==} + engines: {node: '>=18'} + + p-timeout@6.1.4: + resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} + engines: {node: '>=14.16'} + + package-manager-detector@1.7.0: + resolution: {integrity: sha512-xg1eHpwYL/D/HEdWw2goFZP6vV0FH7W+PZ5rFkGjdIDLtxq7EkzBUeT3m+lndYCt8wKbmofUu1MUdMCXkCk9ZQ==} + + parse-latin@7.0.0: + resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} + + parse-srcset@1.0.2: + resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==} + + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + piccolore@0.1.3: + resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.20: + resolution: {integrity: sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.9.5: + resolution: {integrity: sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==} + engines: {node: '>=14'} + hasBin: true + + prismjs@1.30.0: + resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==} + engines: {node: '>=6'} + + prompts@2.4.2: + resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} + engines: {node: '>= 6'} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + punycode.js@2.3.1: + resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} + engines: {node: '>=6'} + + radix3@1.1.2: + resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rehype-parse@9.0.1: + resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-stringify@10.0.1: + resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} + + rehype@13.0.2: + resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==} + + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-smartypants@3.0.2: + resolution: {integrity: sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==} + engines: {node: '>=16.0.0'} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + request-light@0.5.8: + resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==} + + request-light@0.7.0: + resolution: {integrity: sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==} + + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + retext-latin@4.0.0: + resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + retext-stringify@4.0.0: + resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==} + + retext@9.0.0: + resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + + rollup@4.62.2: + resolution: {integrity: sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + sanitize-html@2.17.6: + resolution: {integrity: sha512-M4bo9tfv1yfhQZZKkc6dL07ALrGJtfvNOuhX3hU9AVPR/uPQ+nKOJBqTYc7LfMQblTW04mtSWDJWEyLvygJsLA==} + engines: {node: '>=22.12.0'} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + + shiki@3.23.0: + resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + + smol-toml@1.7.0: + resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} + engines: {node: '>= 18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + svgo@4.0.2: + resolution: {integrity: sha512-ekx94z1rRc5LDi6oSUaeRnYhd0UOJxdtQCL2rF8xpWxD3TPAsISWOrxezqGovqS38GRZOdpDfvQe3ts6F7nsng==} + engines: {node: '>=16'} + hasBin: true + + tiny-inflate@1.0.3: + resolution: {integrity: sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyexec@1.2.4: + resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + type-fest@4.41.0: + resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} + engines: {node: '>=16'} + + typesafe-path@0.2.2: + resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==} + + typescript-auto-import-cache@0.3.6: + resolution: {integrity: sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + uc.micro@2.1.0: + resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + ultrahtml@1.7.0: + resolution: {integrity: sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==} + + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unifont@0.7.4: + resolution: {integrity: sha512-oHeis4/xl42HUIeHuNZRGEvxj5AaIKR+bHPNegRq5LV1gdc3jundpONbjglKpihmJf+dswygdMJn3eftGIMemg==} + + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-modify-children@4.0.0: + resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-children@3.0.0: + resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + unstorage@1.17.5: + resolution: {integrity: sha512-0i3iqvRfx29hkNntHyQvJTpf5W9dQ9ZadSoRU8+xVlhVtT7jAX57fazYO9EHvcRCfBCyi5YRya7XCDOsbTgkPg==} + peerDependencies: + '@azure/app-configuration': ^1.8.0 + '@azure/cosmos': ^4.2.0 + '@azure/data-tables': ^13.3.0 + '@azure/identity': ^4.6.0 + '@azure/keyvault-secrets': ^4.9.0 + '@azure/storage-blob': ^12.26.0 + '@capacitor/preferences': ^6 || ^7 || ^8 + '@deno/kv': '>=0.9.0' + '@netlify/blobs': ^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0 + '@planetscale/database': ^1.19.0 + '@upstash/redis': ^1.34.3 + '@vercel/blob': '>=0.27.1' + '@vercel/functions': ^2.2.12 || ^3.0.0 + '@vercel/kv': ^1 || ^2 || ^3 + aws4fetch: ^1.0.20 + db0: '>=0.2.1' + idb-keyval: ^6.2.1 + ioredis: ^5.4.2 + uploadthing: ^7.4.4 + peerDependenciesMeta: + '@azure/app-configuration': + optional: true + '@azure/cosmos': + optional: true + '@azure/data-tables': + optional: true + '@azure/identity': + optional: true + '@azure/keyvault-secrets': + optional: true + '@azure/storage-blob': + optional: true + '@capacitor/preferences': + optional: true + '@deno/kv': + optional: true + '@netlify/blobs': + optional: true + '@planetscale/database': + optional: true + '@upstash/redis': + optional: true + '@vercel/blob': + optional: true + '@vercel/functions': + optional: true + '@vercel/kv': + optional: true + aws4fetch: + optional: true + db0: + optional: true + idb-keyval: + optional: true + ioredis: + optional: true + uploadthing: + optional: true + + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.3: + resolution: {integrity: sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + vite: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + volar-service-css@0.0.71: + resolution: {integrity: sha512-wRRFt9BpjMKCazcgOh67MSjUjiWUCAh99DyYSDIOTuxaRjEtDC7PpB0k1Y1wbJIW/pVtMUSVbpPo3UGSm0Byxw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-emmet@0.0.71: + resolution: {integrity: sha512-zqjzt6bN95e3CUstBm0PBFAJnrfz0ZAARka87fart46/gNCLLuP3Vujy8V/J8HEziTFLnfkgIASLFYPUhonJcA==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-html@0.0.71: + resolution: {integrity: sha512-e8tHPhgQ7ooLfudAEIku+kgd9pWkq3SSz8RbnQDI1+Eb8wbenkLGHqoirLqz5ORLV6wIMr2Iv08RWBG5eOcgpw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-prettier@0.0.71: + resolution: {integrity: sha512-Rz7JVH3qD108UCdmIEiZvOBNljMt2nLFdbN8AXcDfn7xD9F5I2aCIsDVqBbXw21PsnxG0b7MfwtNF+zPS/NKUg==} + peerDependencies: + '@volar/language-service': ~2.4.0 + prettier: ^2.2 || ^3.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + prettier: + optional: true + + volar-service-typescript-twoslash-queries@0.0.71: + resolution: {integrity: sha512-9K2k72s4n7rV9s4bX0MyjbX9iBribvKZbBJKuEmTCZfeWJXs6Yh7bGpY4eoc7UufAjvpheBqwyZCOIPBvxCv0A==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-typescript@0.0.71: + resolution: {integrity: sha512-yTtM/BVT6hoyEYnDtaCyAtNhdNeS/mhTTABlBOdw3NNiRBUin3IznFJpgfjer4c6RYopiPjjQjc9VFhxVl1mLw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-yaml@0.0.71: + resolution: {integrity: sha512-qYGWGuVpUTnZGu5P/CR4KLK4aIR8RrcVnmfZ2eRcj9q/I8VZCoC5yy9FtEvfNvnDp4MU17yhdJcvpQPIqhJS2Q==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + vscode-css-languageservice@6.3.10: + resolution: {integrity: sha512-eq5N9Er3fC4vA9zd9EFhyBG90wtCCuXgRSpAndaOgXMh1Wgep5lBgRIeDgjZBW9pa+332yC9+49cZMW8jcL3MA==} + + vscode-html-languageservice@5.6.2: + resolution: {integrity: sha512-ulCrSnFnfQ16YzvwnYUgEbUEl/ZG7u2eV27YhvLObSHKkb8fw1Z9cgsnUwjTEeDIdJDoTDTDpxuhQwoenoLNMg==} + + vscode-json-languageservice@4.1.8: + resolution: {integrity: sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==} + engines: {npm: '>=7.0.0'} + + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + + vscode-jsonrpc@9.0.1: + resolution: {integrity: sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==} + engines: {node: '>=14.0.0'} + + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + + vscode-languageserver-protocol@3.18.2: + resolution: {integrity: sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==} + + vscode-languageserver-textdocument@1.0.12: + resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} + + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + + vscode-languageserver-types@3.18.0: + resolution: {integrity: sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==} + + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-nls@5.2.0: + resolution: {integrity: sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==} + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + + which-pm-runs@1.1.0: + resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} + engines: {node: '>=4'} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + widest-line@5.0.0: + resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} + engines: {node: '>=18'} + + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + + yaml-language-server@1.23.0: + resolution: {integrity: sha512-3qVyCOexLCWw06PQa5kRPwvMWMZ/eZeCRWUvgD6a0OkqL/4iCnxy2WumbWifa937Uo5xhyWJ0uxlU39ljhNh7A==} + hasBin: true + + yaml@2.8.3: + resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} + engines: {node: '>= 14.6'} + hasBin: true + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + + yocto-queue@1.2.2: + resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} + engines: {node: '>=12.20'} + + yocto-spinner@0.2.3: + resolution: {integrity: sha512-sqBChb33loEnkoXte1bLg45bEBsOP9N1kzQh5JZNKj/0rik4zAPTNSAVPj3uQAdc6slYJ0Ksc403G2XgxsJQFQ==} + engines: {node: '>=18.19'} + + yoctocolors-cjs@2.1.3: + resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==} + engines: {node: '>=18'} + + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod-to-ts@1.2.0: + resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==} + peerDependencies: + typescript: ^4.9.4 || ^5.0.2 + zod: ^3 + + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@astrojs/check@0.9.9(prettier@3.9.5)(typescript@5.9.3)': + dependencies: + '@astrojs/language-server': 2.16.12(prettier@3.9.5)(typescript@5.9.3) + chokidar: 4.0.3 + kleur: 4.1.5 + typescript: 5.9.3 + yargs: 17.7.3 + transitivePeerDependencies: + - prettier + - prettier-plugin-astro + + '@astrojs/compiler@2.13.1': {} + + '@astrojs/internal-helpers@0.7.6': {} + + '@astrojs/language-server@2.16.12(prettier@3.9.5)(typescript@5.9.3)': + dependencies: + '@astrojs/compiler': 2.13.1 + '@astrojs/yaml2ts': 0.2.4 + '@jridgewell/sourcemap-codec': 1.5.5 + '@volar/kit': 2.4.28(typescript@5.9.3) + '@volar/language-core': 2.4.28 + '@volar/language-server': 2.4.28 + '@volar/language-service': 2.4.28 + muggle-string: 0.4.1 + tinyglobby: 0.2.17 + volar-service-css: 0.0.71(@volar/language-service@2.4.28) + volar-service-emmet: 0.0.71(@volar/language-service@2.4.28) + volar-service-html: 0.0.71(@volar/language-service@2.4.28) + volar-service-prettier: 0.0.71(@volar/language-service@2.4.28)(prettier@3.9.5) + volar-service-typescript: 0.0.71(@volar/language-service@2.4.28) + volar-service-typescript-twoslash-queries: 0.0.71(@volar/language-service@2.4.28) + volar-service-yaml: 0.0.71(@volar/language-service@2.4.28) + vscode-html-languageservice: 5.6.2 + vscode-uri: 3.1.0 + optionalDependencies: + prettier: 3.9.5 + transitivePeerDependencies: + - typescript + + '@astrojs/markdown-remark@6.3.11': + dependencies: + '@astrojs/internal-helpers': 0.7.6 + '@astrojs/prism': 3.3.0 + github-slugger: 2.0.0 + hast-util-from-html: 2.0.3 + hast-util-to-text: 4.0.2 + import-meta-resolve: 4.2.0 + js-yaml: 4.3.0 + mdast-util-definitions: 6.0.0 + rehype-raw: 7.0.0 + rehype-stringify: 10.0.1 + remark-gfm: 4.0.1 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + remark-smartypants: 3.0.2 + shiki: 3.23.0 + smol-toml: 1.7.0 + unified: 11.0.5 + unist-util-remove-position: 5.0.0 + unist-util-visit: 5.1.0 + unist-util-visit-parents: 6.0.2 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/prism@3.3.0': + dependencies: + prismjs: 1.30.0 + + '@astrojs/telemetry@3.3.0': + dependencies: + ci-info: 4.4.0 + debug: 4.4.3 + dlv: 1.1.3 + dset: 3.1.4 + is-docker: 3.0.0 + is-wsl: 3.1.1 + which-pm-runs: 1.1.0 + transitivePeerDependencies: + - supports-color + + '@astrojs/yaml2ts@0.2.4': + dependencies: + yaml: 2.9.0 + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@capsizecss/unpack@4.0.1': + dependencies: + fontkitten: 1.0.3 + + '@emmetio/abbreviation@2.3.3': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-abbreviation@2.1.8': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-parser@0.4.1': + dependencies: + '@emmetio/stream-reader': 2.2.0 + '@emmetio/stream-reader-utils': 0.1.0 + + '@emmetio/html-matcher@1.3.0': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/scanner@1.0.4': {} + + '@emmetio/stream-reader-utils@0.1.0': {} + + '@emmetio/stream-reader@2.2.0': {} + + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.11.2 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + + '@inquirer/ansi@1.0.2': {} + + '@inquirer/checkbox@4.3.2(@types/node@22.20.1)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.20.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/confirm@5.1.21(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/type': 3.0.10(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/core@10.3.2(@types/node@22.20.1)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.20.1) + cli-width: 4.1.0 + mute-stream: 2.0.0 + signal-exit: 4.1.0 + wrap-ansi: 6.2.0 + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/editor@4.2.23(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/external-editor': 1.0.3(@types/node@22.20.1) + '@inquirer/type': 3.0.10(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/expand@4.0.23(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/type': 3.0.10(@types/node@22.20.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/external-editor@1.0.3(@types/node@22.20.1)': + dependencies: + chardet: 2.2.0 + iconv-lite: 0.7.3 + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/figures@1.0.15': {} + + '@inquirer/input@4.3.1(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/type': 3.0.10(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/number@3.0.23(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/type': 3.0.10(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/password@4.0.23(@types/node@22.20.1)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/type': 3.0.10(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/prompts@7.10.1(@types/node@22.20.1)': + dependencies: + '@inquirer/checkbox': 4.3.2(@types/node@22.20.1) + '@inquirer/confirm': 5.1.21(@types/node@22.20.1) + '@inquirer/editor': 4.2.23(@types/node@22.20.1) + '@inquirer/expand': 4.0.23(@types/node@22.20.1) + '@inquirer/input': 4.3.1(@types/node@22.20.1) + '@inquirer/number': 3.0.23(@types/node@22.20.1) + '@inquirer/password': 4.0.23(@types/node@22.20.1) + '@inquirer/rawlist': 4.1.11(@types/node@22.20.1) + '@inquirer/search': 3.2.2(@types/node@22.20.1) + '@inquirer/select': 4.4.2(@types/node@22.20.1) + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/rawlist@4.1.11(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/type': 3.0.10(@types/node@22.20.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/search@3.2.2(@types/node@22.20.1)': + dependencies: + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.20.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/select@4.4.2(@types/node@22.20.1)': + dependencies: + '@inquirer/ansi': 1.0.2 + '@inquirer/core': 10.3.2(@types/node@22.20.1) + '@inquirer/figures': 1.0.15 + '@inquirer/type': 3.0.10(@types/node@22.20.1) + yoctocolors-cjs: 2.1.3 + optionalDependencies: + '@types/node': 22.20.1 + + '@inquirer/type@3.0.10(@types/node@22.20.1)': + optionalDependencies: + '@types/node': 22.20.1 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@oslojs/encoding@1.1.0': {} + + '@rollup/pluginutils@5.4.0(rollup@4.62.2)': + dependencies: + '@types/estree': 1.0.9 + estree-walker: 2.0.2 + picomatch: 4.0.5 + optionalDependencies: + rollup: 4.62.2 + + '@rollup/rollup-android-arm-eabi@4.62.2': + optional: true + + '@rollup/rollup-android-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.2': + optional: true + + '@rollup/rollup-darwin-x64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.2': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.2': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.2': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.2': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.2': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.2': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.2': + optional: true + + '@shikijs/core@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + + '@types/ms@2.1.0': {} + + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + + '@types/node@22.20.1': + dependencies: + undici-types: 6.21.0 + + '@types/sanitize-html@2.16.1': + dependencies: + htmlparser2: 10.1.0 + + '@types/unist@3.0.3': {} + + '@ungap/structured-clone@1.3.3': {} + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.1)(tsx@4.23.1)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@22.20.1)(tsx@4.23.1)(yaml@2.9.0) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + '@volar/kit@2.4.28(typescript@5.9.3)': + dependencies: + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + typesafe-path: 0.2.2 + typescript: 5.9.3 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/language-server@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + '@volar/language-service': 2.4.28 + '@volar/typescript': 2.4.28 + path-browserify: 1.0.1 + request-light: 0.7.0 + vscode-languageserver: 9.0.1 + vscode-languageserver-protocol: 3.18.2 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/language-service@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + vscode-languageserver-protocol: 3.18.2 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vscode/emmet-helper@2.11.0': + dependencies: + emmet: 2.4.11 + jsonc-parser: 2.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.0 + vscode-uri: 3.1.0 + + '@vscode/l10n@0.0.18': {} + + acorn@8.17.0: {} + + ajv-draft-04@1.0.0(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv-i18n@4.2.0(ajv@8.20.0): + dependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.4 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + + argparse@2.0.1: {} + + aria-query@5.3.2: {} + + array-iterate@2.0.1: {} + + assertion-error@2.0.1: {} + + astro@5.18.2(@types/node@22.20.1)(rollup@4.62.2)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0): + dependencies: + '@astrojs/compiler': 2.13.1 + '@astrojs/internal-helpers': 0.7.6 + '@astrojs/markdown-remark': 6.3.11 + '@astrojs/telemetry': 3.3.0 + '@capsizecss/unpack': 4.0.1 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.4.0(rollup@4.62.2) + acorn: 8.17.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + boxen: 8.0.1 + ci-info: 4.4.0 + clsx: 2.1.1 + common-ancestor-path: 1.0.1 + cookie: 1.1.1 + cssesc: 3.0.0 + debug: 4.4.3 + deterministic-object-hash: 2.0.2 + devalue: 5.8.2 + diff: 8.0.4 + dlv: 1.1.3 + dset: 3.1.4 + es-module-lexer: 1.7.0 + esbuild: 0.27.7 + estree-walker: 3.0.3 + flattie: 1.1.1 + fontace: 0.4.1 + github-slugger: 2.0.0 + html-escaper: 3.0.3 + http-cache-semantics: 4.2.0 + import-meta-resolve: 4.2.0 + js-yaml: 4.3.0 + magic-string: 0.30.21 + magicast: 0.5.3 + mrmime: 2.0.1 + neotraverse: 0.6.18 + p-limit: 6.2.0 + p-queue: 8.1.1 + package-manager-detector: 1.7.0 + piccolore: 0.1.3 + picomatch: 4.0.5 + prompts: 2.4.2 + rehype: 13.0.2 + semver: 7.8.5 + shiki: 3.23.0 + smol-toml: 1.7.0 + svgo: 4.0.2 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tsconfck: 3.1.6(typescript@5.9.3) + ultrahtml: 1.7.0 + unifont: 0.7.4 + unist-util-visit: 5.1.0 + unstorage: 1.17.5 + vfile: 6.0.3 + vite: 6.4.3(@types/node@22.20.1)(tsx@4.23.1)(yaml@2.9.0) + vitefu: 1.1.3(vite@6.4.3(@types/node@22.20.1)(tsx@4.23.1)(yaml@2.9.0)) + xxhash-wasm: 1.1.0 + yargs-parser: 21.1.1 + yocto-spinner: 0.2.3 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + zod-to-ts: 1.2.0(typescript@5.9.3)(zod@3.25.76) + optionalDependencies: + sharp: 0.34.5 + transitivePeerDependencies: + - '@azure/app-configuration' + - '@azure/cosmos' + - '@azure/data-tables' + - '@azure/identity' + - '@azure/keyvault-secrets' + - '@azure/storage-blob' + - '@capacitor/preferences' + - '@deno/kv' + - '@netlify/blobs' + - '@planetscale/database' + - '@types/node' + - '@upstash/redis' + - '@vercel/blob' + - '@vercel/functions' + - '@vercel/kv' + - aws4fetch + - db0 + - idb-keyval + - ioredis + - jiti + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - typescript + - uploadthing + - yaml + + axobject-query@4.1.0: {} + + bail@2.0.2: {} + + base-64@1.0.0: {} + + boolbase@1.0.0: {} + + boxen@8.0.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 8.0.0 + chalk: 5.6.2 + cli-boxes: 3.0.0 + string-width: 7.2.0 + type-fest: 4.41.0 + widest-line: 5.0.0 + wrap-ansi: 9.0.2 + + cac@6.7.14: {} + + camelcase@8.0.0: {} + + ccount@2.0.1: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@5.6.2: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + chardet@2.2.0: {} + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.0.0 + + ci-info@4.4.0: {} + + cli-boxes@3.0.0: {} + + cli-width@4.1.0: {} + + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + + clsx@2.1.1: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + comma-separated-tokens@2.0.3: {} + + commander@11.1.0: {} + + common-ancestor-path@1.0.1: {} + + cookie-es@1.2.3: {} + + cookie@1.1.1: {} + + crossws@0.3.5: + dependencies: + uncrypto: 0.1.3 + + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-tree@2.2.1: + dependencies: + mdn-data: 2.0.28 + source-map-js: 1.2.1 + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css-what@6.2.2: {} + + cssesc@3.0.0: {} + + csso@5.0.5: + dependencies: + css-tree: 2.2.1 + + dayjs@1.11.21: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + + deep-eql@5.0.2: {} + + deepmerge@4.3.1: {} + + defu@6.1.7: {} + + dequal@2.0.3: {} + + destr@2.0.5: {} + + detect-libc@2.1.2: + optional: true + + deterministic-object-hash@2.0.2: + dependencies: + base-64: 1.0.0 + + devalue@5.8.2: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + diff@8.0.4: {} + + dlv@1.1.3: {} + + dom-serializer@2.0.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + entities: 4.5.0 + + dom-serializer@3.1.1: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + entities: 8.0.0 + + domelementtype@2.3.0: {} + + domelementtype@3.0.0: {} + + domhandler@5.0.3: + dependencies: + domelementtype: 2.3.0 + + domhandler@6.0.1: + dependencies: + domelementtype: 3.0.0 + + domutils@3.2.2: + dependencies: + dom-serializer: 2.0.0 + domelementtype: 2.3.0 + domhandler: 5.0.3 + + domutils@4.0.2: + dependencies: + dom-serializer: 3.1.1 + domelementtype: 3.0.0 + domhandler: 6.0.1 + + dset@3.1.4: {} + + emmet@2.4.11: + dependencies: + '@emmetio/abbreviation': 2.3.3 + '@emmetio/css-abbreviation': 2.1.8 + + emoji-regex@10.6.0: {} + + emoji-regex@8.0.0: {} + + entities@4.5.0: {} + + entities@6.0.1: {} + + entities@7.0.1: {} + + entities@8.0.0: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + escape-string-regexp@5.0.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + eventemitter3@5.0.4: {} + + expect-type@1.4.0: {} + + extend@3.0.2: {} + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.4: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + flattie@1.1.1: {} + + fontace@0.4.1: + dependencies: + fontkitten: 1.0.3 + + fontkitten@1.0.3: + dependencies: + tiny-inflate: 1.0.3 + + fsevents@2.3.3: + optional: true + + get-caller-file@2.0.5: {} + + get-east-asian-width@1.6.0: {} + + github-slugger@2.0.0: {} + + h3@1.15.11: + dependencies: + cookie-es: 1.2.3 + crossws: 0.3.5 + defu: 6.1.7 + destr: 2.0.5 + iron-webcrypto: 1.2.1 + node-mock-http: 1.0.4 + radix3: 1.1.2 + ufo: 1.6.4 + uncrypto: 0.1.3 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.5 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.3 + parse5: 7.3.0 + vfile: 6.0.3 + vfile-message: 4.0.3 + + hast-util-from-parse5@8.0.3: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 9.0.1 + property-information: 7.2.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.5 + + hast-util-raw@9.1.0: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.3.3 + hast-util-from-parse5: 8.0.3 + hast-util-to-parse5: 8.0.1 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + parse5: 7.3.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + + hastscript@9.0.1: + dependencies: + '@types/hast': 3.0.5 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + + html-escaper@3.0.3: {} + + html-void-elements@3.0.0: {} + + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + + htmlparser2@12.0.0: + dependencies: + domelementtype: 3.0.0 + domhandler: 6.0.1 + domutils: 4.0.2 + entities: 8.0.0 + + http-cache-semantics@4.2.0: {} + + iconv-lite@0.7.3: + dependencies: + safer-buffer: 2.1.2 + + import-meta-resolve@4.2.0: {} + + iron-webcrypto@1.2.1: {} + + is-docker@3.0.0: {} + + is-fullwidth-code-point@3.0.0: {} + + is-inside-container@1.0.0: + dependencies: + is-docker: 3.0.0 + + is-plain-obj@4.1.0: {} + + is-plain-object@5.0.0: {} + + is-wsl@3.1.1: + dependencies: + is-inside-container: 1.0.0 + + js-tokens@9.0.1: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@1.0.0: {} + + jsonc-parser@2.3.1: {} + + jsonc-parser@3.3.1: {} + + kleur@3.0.3: {} + + kleur@4.1.5: {} + + launder@1.7.1: + dependencies: + dayjs: 1.11.21 + + linkify-it@5.0.2: + dependencies: + uc.micro: 2.1.0 + + longest-streak@3.1.0: {} + + loupe@3.2.1: {} + + lru-cache@11.5.2: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + magicast@0.5.3: + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + source-map-js: 1.2.1 + + markdown-it@14.3.0: + dependencies: + argparse: 2.0.1 + entities: 4.5.0 + linkify-it: 5.0.2 + mdurl: 2.0.0 + punycode.js: 2.3.1 + uc.micro: 2.1.0 + + markdown-table@3.0.4: {} + + mdast-util-definitions@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.2 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.2 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.3 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.2: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + + mdn-data@2.0.28: {} + + mdn-data@2.27.1: {} + + mdurl@2.0.0: {} + + micromark-core-commonmark@2.0.3: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.0.1 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-table@2.1.1: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.2 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.1 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-space@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.2 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.2 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + micromark@4.0.2: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.3 + micromark-factory-space: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + transitivePeerDependencies: + - supports-color + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + mute-stream@2.0.0: {} + + nanoid@3.3.16: {} + + neotraverse@0.6.18: {} + + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + + node-fetch-native@1.6.7: {} + + node-mock-http@1.0.4: {} + + normalize-path@3.0.0: {} + + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + ofetch@1.5.1: + dependencies: + destr: 2.0.5 + node-fetch-native: 1.6.7 + ufo: 1.6.4 + + ohash@2.0.11: {} + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + p-limit@6.2.0: + dependencies: + yocto-queue: 1.2.2 + + p-queue@8.1.1: + dependencies: + eventemitter3: 5.0.4 + p-timeout: 6.1.4 + + p-timeout@6.1.4: {} + + package-manager-detector@1.7.0: {} + + parse-latin@7.0.0: + dependencies: + '@types/nlcst': 2.0.3 + '@types/unist': 3.0.3 + nlcst-to-string: 4.0.0 + unist-util-modify-children: 4.0.0 + unist-util-visit-children: 3.0.0 + vfile: 6.0.3 + + parse-srcset@1.0.2: {} + + parse5@7.3.0: + dependencies: + entities: 6.0.1 + + path-browserify@1.0.1: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + piccolore@0.1.3: {} + + picocolors@1.1.1: {} + + picomatch@2.3.2: {} + + picomatch@4.0.5: {} + + postcss@8.5.20: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.9.5: {} + + prismjs@1.30.0: {} + + prompts@2.4.2: + dependencies: + kleur: 3.0.3 + sisteransi: 1.0.5 + + property-information@7.2.0: {} + + punycode.js@2.3.1: {} + + radix3@1.1.2: {} + + readdirp@4.1.2: {} + + readdirp@5.0.0: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rehype-parse@9.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-from-html: 2.0.3 + unified: 11.0.5 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.5 + hast-util-raw: 9.1.0 + vfile: 6.0.3 + + rehype-stringify@10.0.1: + dependencies: + '@types/hast': 3.0.5 + hast-util-to-html: 9.0.5 + unified: 11.0.5 + + rehype@13.0.2: + dependencies: + '@types/hast': 3.0.5 + rehype-parse: 9.0.1 + rehype-stringify: 10.0.1 + unified: 11.0.5 + + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-smartypants@3.0.2: + dependencies: + retext: 9.0.0 + retext-smartypants: 6.2.0 + unified: 11.0.5 + unist-util-visit: 5.1.0 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.2 + unified: 11.0.5 + + request-light@0.5.8: {} + + request-light@0.7.0: {} + + require-directory@2.1.1: {} + + require-from-string@2.0.2: {} + + retext-latin@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + parse-latin: 7.0.0 + unified: 11.0.5 + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.1.0 + + retext-stringify@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unified: 11.0.5 + + retext@9.0.0: + dependencies: + '@types/nlcst': 2.0.3 + retext-latin: 4.0.0 + retext-stringify: 4.0.0 + unified: 11.0.5 + + rollup@4.62.2: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.2 + '@rollup/rollup-android-arm64': 4.62.2 + '@rollup/rollup-darwin-arm64': 4.62.2 + '@rollup/rollup-darwin-x64': 4.62.2 + '@rollup/rollup-freebsd-arm64': 4.62.2 + '@rollup/rollup-freebsd-x64': 4.62.2 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.2 + '@rollup/rollup-linux-arm-musleabihf': 4.62.2 + '@rollup/rollup-linux-arm64-gnu': 4.62.2 + '@rollup/rollup-linux-arm64-musl': 4.62.2 + '@rollup/rollup-linux-loong64-gnu': 4.62.2 + '@rollup/rollup-linux-loong64-musl': 4.62.2 + '@rollup/rollup-linux-ppc64-gnu': 4.62.2 + '@rollup/rollup-linux-ppc64-musl': 4.62.2 + '@rollup/rollup-linux-riscv64-gnu': 4.62.2 + '@rollup/rollup-linux-riscv64-musl': 4.62.2 + '@rollup/rollup-linux-s390x-gnu': 4.62.2 + '@rollup/rollup-linux-x64-gnu': 4.62.2 + '@rollup/rollup-linux-x64-musl': 4.62.2 + '@rollup/rollup-openbsd-x64': 4.62.2 + '@rollup/rollup-openharmony-arm64': 4.62.2 + '@rollup/rollup-win32-arm64-msvc': 4.62.2 + '@rollup/rollup-win32-ia32-msvc': 4.62.2 + '@rollup/rollup-win32-x64-gnu': 4.62.2 + '@rollup/rollup-win32-x64-msvc': 4.62.2 + fsevents: 2.3.3 + + safer-buffer@2.1.2: {} + + sanitize-html@2.17.6: + dependencies: + deepmerge: 4.3.1 + escape-string-regexp: 4.0.0 + htmlparser2: 12.0.0 + is-plain-object: 5.0.0 + launder: 1.7.1 + parse-srcset: 1.0.2 + postcss: 8.5.20 + + sax@1.6.0: {} + + semver@7.8.5: {} + + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + + shiki@3.23.0: + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/engine-javascript': 3.23.0 + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.5 + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + sisteransi@1.0.5: {} + + smol-toml@1.7.0: {} + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + svgo@4.0.2: + dependencies: + commander: 11.1.0 + css-select: 5.2.2 + css-tree: 3.2.1 + css-what: 6.2.2 + csso: 5.0.5 + picocolors: 1.1.1 + sax: 1.6.0 + + tiny-inflate@1.0.3: {} + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyexec@1.2.4: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + tsconfck@3.1.6(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + tslib@2.8.1: + optional: true + + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + type-fest@4.41.0: {} + + typesafe-path@0.2.2: {} + + typescript-auto-import-cache@0.3.6: + dependencies: + semver: 7.8.5 + + typescript@5.9.3: {} + + uc.micro@2.1.0: {} + + ufo@1.6.4: {} + + ultrahtml@1.7.0: {} + + uncrypto@0.1.3: {} + + undici-types@6.21.0: {} + + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unifont@0.7.4: + dependencies: + css-tree: 3.2.1 + ofetch: 1.5.1 + ohash: 2.0.11 + + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-modify-children@4.0.0: + dependencies: + '@types/unist': 3.0.3 + array-iterate: 2.0.1 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.1.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-children@3.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + unstorage@1.17.5: + dependencies: + anymatch: 3.1.3 + chokidar: 5.0.0 + destr: 2.0.5 + h3: 1.15.11 + lru-cache: 11.5.2 + node-fetch-native: 1.6.7 + ofetch: 1.5.1 + ufo: 1.6.4 + + vfile-location@5.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile: 6.0.3 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite-node@3.2.4(@types/node@22.20.1)(tsx@4.23.1)(yaml@2.9.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@22.20.1)(tsx@4.23.1)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@6.4.3(@types/node@22.20.1)(tsx@4.23.1)(yaml@2.9.0): + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.20 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + tsx: 4.23.1 + yaml: 2.9.0 + + vite@7.3.6(@types/node@22.20.1)(tsx@4.23.1)(yaml@2.9.0): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.20 + rollup: 4.62.2 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 22.20.1 + fsevents: 2.3.3 + tsx: 4.23.1 + yaml: 2.9.0 + + vitefu@1.1.3(vite@6.4.3(@types/node@22.20.1)(tsx@4.23.1)(yaml@2.9.0)): + optionalDependencies: + vite: 6.4.3(@types/node@22.20.1)(tsx@4.23.1)(yaml@2.9.0) + + vitest@3.2.7(@types/debug@4.1.13)(@types/node@22.20.1)(tsx@4.23.1)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@22.20.1)(tsx@4.23.1)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@22.20.1)(tsx@4.23.1)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@22.20.1)(tsx@4.23.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.13 + '@types/node': 22.20.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + volar-service-css@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-css-languageservice: 6.3.10 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-emmet@0.0.71(@volar/language-service@2.4.28): + dependencies: + '@emmetio/css-parser': 0.4.1 + '@emmetio/html-matcher': 1.3.0 + '@vscode/emmet-helper': 2.11.0 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-html@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-html-languageservice: 5.6.2 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-prettier@0.0.71(@volar/language-service@2.4.28)(prettier@3.9.5): + dependencies: + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + prettier: 3.9.5 + + volar-service-typescript-twoslash-queries@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-typescript@0.0.71(@volar/language-service@2.4.28): + dependencies: + path-browserify: 1.0.1 + semver: 7.8.5 + typescript-auto-import-cache: 0.3.6 + vscode-languageserver-textdocument: 1.0.12 + vscode-nls: 5.2.0 + vscode-uri: 3.1.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + volar-service-yaml@0.0.71(@volar/language-service@2.4.28): + dependencies: + vscode-uri: 3.1.0 + yaml-language-server: 1.23.0 + optionalDependencies: + '@volar/language-service': 2.4.28 + + vscode-css-languageservice@6.3.10: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.1.0 + + vscode-html-languageservice@5.6.2: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.0 + vscode-uri: 3.1.0 + + vscode-json-languageservice@4.1.8: + dependencies: + jsonc-parser: 3.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.0 + vscode-nls: 5.2.0 + vscode-uri: 3.1.0 + + vscode-jsonrpc@8.2.0: {} + + vscode-jsonrpc@9.0.1: {} + + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + + vscode-languageserver-protocol@3.18.2: + dependencies: + vscode-jsonrpc: 9.0.1 + vscode-languageserver-types: 3.18.0 + + vscode-languageserver-textdocument@1.0.12: {} + + vscode-languageserver-types@3.17.5: {} + + vscode-languageserver-types@3.18.0: {} + + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-nls@5.2.0: {} + + vscode-uri@3.1.0: {} + + web-namespaces@2.0.1: {} + + which-pm-runs@1.1.0: {} + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + widest-line@5.0.0: + dependencies: + string-width: 7.2.0 + + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.2.0 + + xxhash-wasm@1.1.0: {} + + y18n@5.0.8: {} + + yaml-language-server@1.23.0: + dependencies: + '@vscode/l10n': 0.0.18 + ajv: 8.20.0 + ajv-draft-04: 1.0.0(ajv@8.20.0) + ajv-i18n: 4.2.0(ajv@8.20.0) + prettier: 3.9.5 + request-light: 0.5.8 + vscode-json-languageservice: 4.1.8 + vscode-languageserver: 9.0.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.18.0 + vscode-uri: 3.1.0 + yaml: 2.8.3 + + yaml@2.8.3: {} + + yaml@2.9.0: {} + + yargs-parser@21.1.1: {} + + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + + yocto-queue@1.2.2: {} + + yocto-spinner@0.2.3: + dependencies: + yoctocolors: 2.1.2 + + yoctocolors-cjs@2.1.3: {} + + yoctocolors@2.1.2: {} + + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + + zod-to-ts@1.2.0(typescript@5.9.3)(zod@3.25.76): + dependencies: + typescript: 5.9.3 + zod: 3.25.76 + + zod@3.25.76: {} + + zwitch@2.0.4: {} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..fe35fc1 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "resolveJsonModule": true, + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["tools/**/*.ts", "vitest.config.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..0aa10f1 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ test: { include: ['tools/**/*.test.ts', 'gallery/src/**/*.test.ts'] } }); From e37027ec672d0a5faedc0eae2f99a370d146465c Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 12:02:21 +0800 Subject: [PATCH 06/40] fix: treat repository tools as esm --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 0897194..e50771a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "name": "faust-gallery-manager", "private": true, + "type": "module", "packageManager": "pnpm@10.13.1", "engines": { "node": ">=22 <23" }, "scripts": { From d3ea61ece5504cc65c406239c6ba34556c7b79f3 Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 12:05:35 +0800 Subject: [PATCH 07/40] feat: define strict project metadata contract --- project.schema.json | 106 +++++++++++++++ tools/__tests__/project-schema.test.ts | 172 +++++++++++++++++++++++++ tools/project-schema.ts | 107 +++++++++++++++ 3 files changed, 385 insertions(+) create mode 100644 project.schema.json create mode 100644 tools/__tests__/project-schema.test.ts create mode 100644 tools/project-schema.ts diff --git a/project.schema.json b/project.schema.json new file mode 100644 index 0000000..da144cd --- /dev/null +++ b/project.schema.json @@ -0,0 +1,106 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://faust.local/project.schema.json", + "title": "Faust project metadata", + "description": "The authoritative metadata contract for projects published in the Faust gallery.", + "type": "object", + "updatedAtNotBeforeCreatedAt": true, + "additionalProperties": false, + "required": [ + "title", + "slug", + "summary", + "status", + "type", + "tags", + "createdAt", + "updatedAt", + "featured", + "demo", + "repository", + "cover" + ], + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^(?=.*\\S)[\\s\\S]+$" + }, + "slug": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + }, + "summary": { + "type": "string", + "minLength": 1, + "maxLength": 300, + "pattern": "^(?=.*\\S)[\\s\\S]+$" + }, + "status": { + "type": "string", + "enum": ["idea", "building", "shipped", "archived"] + }, + "type": { + "type": "string", + "enum": ["web", "service", "cli", "ai", "script", "other"] + }, + "tags": { + "type": "array", + "maxItems": 10, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 32, + "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$" + } + }, + "createdAt": { + "type": "string", + "format": "date" + }, + "updatedAt": { + "type": "string", + "format": "date" + }, + "featured": { + "type": "boolean" + }, + "demo": { + "anyOf": [ + { + "type": "string", + "maxLength": 2048, + "format": "uri", + "pattern": "^https?://[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?(?::[0-9]{1,4})?(?:[/?#][^\\s\\\\]*)?$" + }, + { "type": "null" } + ] + }, + "repository": { + "anyOf": [ + { "const": "./" }, + { + "type": "string", + "maxLength": 2048, + "format": "uri", + "pattern": "^https://[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?(?::[0-9]{1,4})?(?:[/?#][^\\s\\\\]*)?$" + } + ] + }, + "cover": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^(?!/)(?!.*\\\\)(?!.*[\\u0000-\\u001F\\u007F-\\u009F])(?!.*%2[eE])(?!\\.\\.?(?:/|$))(?!.*\\/\\.\\.?(?:/|$))(?!.*//)(?!.*:)(?!.*\\/$)[^?#]+$" + }, + { "type": "null" } + ] + } + } +} diff --git a/tools/__tests__/project-schema.test.ts b/tools/__tests__/project-schema.test.ts new file mode 100644 index 0000000..e7f1351 --- /dev/null +++ b/tools/__tests__/project-schema.test.ts @@ -0,0 +1,172 @@ +import { Ajv2020 } from 'ajv/dist/2020.js'; +import addFormatsImport from 'ajv-formats'; +import { describe, expect, it } from 'vitest'; + +import jsonSchema from '../../project.schema.json' with { type: 'json' }; +import { ProjectMetadataSchema } from '../project-schema.js'; + +const valid = { + title: 'Semantic Search Playground', + slug: 'semantic-search', + summary: 'Compare Chinese semantic search models.', + status: 'building', + type: 'ai', + tags: ['embeddings', 'search'], + createdAt: '2026-07-20', + updatedAt: '2026-07-21', + featured: false, + demo: null, + repository: './', + cover: null, +}; + +type Case = readonly [label: string, value: unknown, expected: boolean]; +const addFormats = addFormatsImport as unknown as typeof import('ajv-formats').default; + +const withField = (field: keyof typeof valid, value: unknown): unknown => ({ + ...valid, + [field]: value, +}); + +const withoutField = (field: keyof typeof valid): unknown => { + const value: Record = { ...valid }; + delete value[field]; + return value; +}; + +const requiredCases: Case[] = Object.keys(valid).map((field) => [ + `requires ${field}`, + withoutField(field as keyof typeof valid), + false, +]); + +const cases: Case[] = [ + ['accepts the representative contract', valid, true], + ...requiredCases, + + ['accepts a one-character title', withField('title', 'x'), true], + ['accepts a 100-character title', withField('title', 'x'.repeat(100)), true], + ['rejects an empty title', withField('title', ''), false], + ['rejects a whitespace-only title', withField('title', ' '), false], + ['rejects a 101-character title', withField('title', 'x'.repeat(101)), false], + ['counts Unicode title limits by code point', withField('title', '😀'.repeat(100)), true], + ['rejects a title over the Unicode code-point limit', withField('title', '😀'.repeat(101)), false], + + ['accepts a one-character slug', withField('slug', 'x'), true], + ['accepts a 64-character slug', withField('slug', 'x'.repeat(64)), true], + ['rejects an empty slug', withField('slug', ''), false], + ['rejects a 65-character slug', withField('slug', 'x'.repeat(65)), false], + ['rejects uppercase slugs', withField('slug', 'Not-Safe'), false], + ['rejects slugs with spaces', withField('slug', 'not safe'), false], + ['rejects leading hyphens', withField('slug', '-not-safe'), false], + ['rejects trailing hyphens', withField('slug', 'not-safe-'), false], + ['rejects repeated hyphens', withField('slug', 'not--safe'), false], + + ['accepts a one-character summary', withField('summary', 'x'), true], + ['accepts a 300-character summary', withField('summary', 'x'.repeat(300)), true], + ['rejects an empty summary', withField('summary', ''), false], + ['rejects a whitespace-only summary', withField('summary', ' '), false], + ['rejects a 301-character summary', withField('summary', 'x'.repeat(301)), false], + + ...(['idea', 'building', 'shipped', 'archived'] as const).map( + (status): Case => [`accepts status ${status}`, withField('status', status), true], + ), + ['rejects an unknown status', withField('status', 'paused'), false], + ['rejects status with different casing', withField('status', 'Building'), false], + + ...(['web', 'service', 'cli', 'ai', 'script', 'other'] as const).map( + (type): Case => [`accepts type ${type}`, withField('type', type), true], + ), + ['rejects an unknown type', withField('type', 'mobile'), false], + ['rejects type with different casing', withField('type', 'Web'), false], + + ['accepts no tags', withField('tags', []), true], + ['accepts ten tags', withField('tags', Array.from({ length: 10 }, (_, i) => `tag-${i}`)), true], + ['rejects eleven tags', withField('tags', Array.from({ length: 11 }, (_, i) => `tag-${i}`)), false], + ['accepts a 32-character tag', withField('tags', ['x'.repeat(32)]), true], + ['rejects a 33-character tag', withField('tags', ['x'.repeat(33)]), false], + ['rejects duplicate tags', withField('tags', ['search', 'search']), false], + ['rejects uppercase tags', withField('tags', ['Semantic-search']), false], + ['rejects malformed tags', withField('tags', ['semantic_search']), false], + + ['accepts a leap-day date', withField('createdAt', '2024-02-29'), true], + ['accepts a four-digit early ISO year', { ...valid, createdAt: '0001-01-01' }, true], + ['rejects a non-leap-day date', withField('createdAt', '2025-02-29'), false], + ['rejects an impossible calendar date', withField('createdAt', '2026-02-30'), false], + ['rejects a non-padded date', withField('createdAt', '2026-7-20'), false], + ['rejects a datetime', withField('createdAt', '2026-07-20T00:00:00Z'), false], + ['accepts ordered dates', valid, true], + ['accepts equal dates', withField('updatedAt', valid.createdAt), true], + ['rejects reversed dates', withField('updatedAt', '2026-07-19'), false], + + ['accepts featured true', withField('featured', true), true], + ['accepts featured false', withField('featured', false), true], + ['rejects a truthy featured value', withField('featured', 1), false], + + ['accepts a null demo', withField('demo', null), true], + ['accepts an HTTP demo', withField('demo', 'http://example.com/demo'), true], + ['accepts an HTTPS demo', withField('demo', 'https://example.com/demo?q=1'), true], + ['rejects a relative demo', withField('demo', '/demo'), false], + ['rejects a protocol-relative demo', withField('demo', '//example.com/demo'), false], + ['rejects a non-web demo scheme', withField('demo', 'ftp://example.com/demo'), false], + ['rejects a malformed HTTP demo', withField('demo', 'https://'), false], + ['rejects an internationalized demo host', withField('demo', 'https://例え.テスト/a'), false], + ['rejects backslashes in a demo URL', withField('demo', String.raw`https://example.com\evil`), false], + ['rejects a demo port outside the canonical grammar', withField('demo', 'http://example.com:99999/'), false], + ['rejects demo user information', withField('demo', 'http://user@example.com/'), false], + ['rejects a non-string demo', withField('demo', 42), false], + + ['accepts the local repository marker', withField('repository', './'), true], + ['accepts an HTTPS repository', withField('repository', 'https://github.com/example/project'), true], + ['rejects an HTTP repository', withField('repository', 'http://github.com/example/project'), false], + ['rejects a non-web repository scheme', withField('repository', 'git://github.com/example/project'), false], + ['rejects a malformed HTTPS repository', withField('repository', 'https://'), false], + ['rejects an internationalized repository host', withField('repository', 'https://例え.テスト/a'), false], + ['rejects backslashes in a repository URL', withField('repository', String.raw`https://example.com\evil`), false], + ['rejects a relative repository path', withField('repository', '../project'), false], + ['rejects a null repository', withField('repository', null), false], + + ['accepts a null cover', withField('cover', null), true], + ['accepts a safe relative cover', withField('cover', 'images/cover.webp'), true], + ['accepts a simple cover filename', withField('cover', 'cover.png'), true], + ['rejects an empty cover', withField('cover', ''), false], + ['rejects an absolute POSIX cover', withField('cover', '/images/cover.png'), false], + ['rejects a traversing cover', withField('cover', '../secret.png'), false], + ['rejects nested cover traversal', withField('cover', 'images/../secret.png'), false], + ['rejects backslashes in a cover', withField('cover', String.raw`images\cover.png`), false], + ['rejects a Windows absolute cover', withField('cover', String.raw`C:\images\cover.png`), false], + ['rejects a dot-segment cover', withField('cover', 'images/./cover.png'), false], + ['rejects repeated separators in a cover', withField('cover', 'images//cover.png'), false], + ['rejects a trailing separator in a cover', withField('cover', 'images/'), false], + ['rejects a URL used as a cover', withField('cover', 'https://example.com/cover.png'), false], + ['rejects a NUL byte in a cover', withField('cover', 'images/\u0000cover.png'), false], + ['rejects a newline in a cover', withField('cover', 'images/\ncover.png'), false], + ['rejects encoded traversal in a cover', withField('cover', 'images/%2e%2e/secret.png'), false], + ['rejects a non-string cover', withField('cover', 42), false], + + ['rejects unknown fields', { ...valid, surprise: true }, false], + ['rejects arrays', [], false], + ['rejects null objects', null, false], +]; + +describe('project metadata contract', () => { + const ajv = new Ajv2020({ allErrors: true, strict: true }); + addFormats(ajv); + ajv.addKeyword({ + keyword: 'updatedAtNotBeforeCreatedAt', + schemaType: 'boolean', + type: 'object', + validate: (enabled: boolean, data: { createdAt?: string; updatedAt?: string }) => + !enabled || !data.createdAt || !data.updatedAt || data.updatedAt >= data.createdAt, + }); + const validateJson = ajv.compile(jsonSchema); + + it.each(cases)('%s', (_label, value, expected) => { + const jsonResult = Boolean(validateJson(value)); + const zodResult = ProjectMetadataSchema.safeParse(value).success; + + expect(jsonResult, JSON.stringify(validateJson.errors)).toBe(expected); + expect(zodResult).toBe(expected); + expect(zodResult).toBe(jsonResult); + }); +}); diff --git a/tools/project-schema.ts b/tools/project-schema.ts new file mode 100644 index 0000000..0dc1de8 --- /dev/null +++ b/tools/project-schema.ts @@ -0,0 +1,107 @@ +import { posix } from 'node:path'; + +import { z } from 'zod'; + +const kebabCase = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const canonicalWebUrl = /^https?:\/\/[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?(?::[0-9]{1,4})?(?:[/?#][^\s\\]*)?$/; + +const hasAtMostCodePoints = (value: string, maximum: number) => Array.from(value).length <= maximum; + +const isoDate = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .refine((value) => { + const [year, month, day] = value.split('-').map(Number); + const date = new Date(0); + date.setUTCHours(0, 0, 0, 0); + date.setUTCFullYear(year, month - 1, day); + + return ( + date.getUTCFullYear() === year && + date.getUTCMonth() === month - 1 && + date.getUTCDate() === day + ); + }, 'must be a real ISO calendar date'); + +const nonBlankString = (maximum: number) => + z + .string() + .min(1) + .refine((value) => hasAtMostCodePoints(value, maximum), `must contain at most ${maximum} characters`) + .refine((value) => /\S/.test(value), 'must contain non-whitespace text'); + +const webUrl = (protocols: readonly string[]) => + z + .string() + .regex(canonicalWebUrl) + .url() + .refine((value) => hasAtMostCodePoints(value, 2048), 'must contain at most 2048 characters') + .refine((value) => { + try { + const url = new URL(value); + return protocols.includes(url.protocol) && url.hostname.length > 0; + } catch { + return false; + } + }, `must use ${protocols.join(' or ')}`); + +const slug = z.string().min(1).max(64).regex(kebabCase); +const tag = z.string().min(1).max(32).regex(kebabCase); +const cover = z + .string() + .min(1) + .refine((value) => hasAtMostCodePoints(value, 255), 'must contain at most 255 characters') + .refine((value) => { + if ( + value.startsWith('/') || + value.includes('\\') || + value.includes(':') || + value.includes('?') || + value.includes('#') + || /[\u0000-\u001F\u007F-\u009F]/u.test(value) + || /%2e/i.test(value) + ) { + return false; + } + + const segments = value.split('/'); + return ( + segments.every((segment) => segment !== '' && segment !== '.' && segment !== '..') && + posix.normalize(value) === value + ); + }, 'must be a normalized, safe project-relative path'); + +export const ProjectMetadataSchema = z + .object({ + title: nonBlankString(100), + slug, + summary: nonBlankString(300), + status: z.enum(['idea', 'building', 'shipped', 'archived']), + type: z.enum(['web', 'service', 'cli', 'ai', 'script', 'other']), + tags: z + .array(tag) + .max(10) + .superRefine((tags, context) => { + if (new Set(tags).size !== tags.length) { + context.addIssue({ code: 'custom', message: 'tags must not contain duplicates' }); + } + }), + createdAt: isoDate, + updatedAt: isoDate, + featured: z.boolean(), + demo: webUrl(['http:', 'https:']).nullable(), + repository: z.union([z.literal('./'), webUrl(['https:'])]), + cover: cover.nullable(), + }) + .strict() + .superRefine((data, context) => { + if (data.updatedAt < data.createdAt) { + context.addIssue({ + code: 'custom', + path: ['updatedAt'], + message: 'must not precede createdAt', + }); + } + }); + +export type ProjectMetadata = z.infer; From bda9a1ba89c1aac5dd2a28b181a375ad1723b641 Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 12:11:56 +0800 Subject: [PATCH 08/40] fix: align metadata URL validation --- project.schema.json | 6 ++-- tools/__tests__/project-schema.test.ts | 45 ++++++++++++++++++++++---- tools/project-schema.ts | 4 --- 3 files changed, 41 insertions(+), 14 deletions(-) diff --git a/project.schema.json b/project.schema.json index da144cd..7a9f663 100644 --- a/project.schema.json +++ b/project.schema.json @@ -74,8 +74,7 @@ { "type": "string", "maxLength": 2048, - "format": "uri", - "pattern": "^https?://[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?(?::[0-9]{1,4})?(?:[/?#][^\\s\\\\]*)?$" + "format": "absolute-http-url" }, { "type": "null" } ] @@ -86,8 +85,7 @@ { "type": "string", "maxLength": 2048, - "format": "uri", - "pattern": "^https://[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?(?::[0-9]{1,4})?(?:[/?#][^\\s\\\\]*)?$" + "format": "absolute-https-url" } ] }, diff --git a/tools/__tests__/project-schema.test.ts b/tools/__tests__/project-schema.test.ts index e7f1351..abc188e 100644 --- a/tools/__tests__/project-schema.test.ts +++ b/tools/__tests__/project-schema.test.ts @@ -23,6 +23,20 @@ const valid = { type Case = readonly [label: string, value: unknown, expected: boolean]; const addFormats = addFormatsImport as unknown as typeof import('ajv-formats').default; +const isAbsoluteWebUrl = (value: string, protocols: readonly string[]): boolean => { + try { + const url = new URL(value); + return protocols.includes(url.protocol) && url.hostname.length > 0; + } catch { + return false; + } +}; + +const urlWithCodePointLength = (length: number): string => { + const prefix = 'https://example.com/'; + return `${prefix}${'😀'.repeat(length - Array.from(prefix).length)}`; +}; + const withField = (field: keyof typeof valid, value: unknown): unknown => ({ ...valid, [field]: value, @@ -106,29 +120,40 @@ const cases: Case[] = [ ['accepts a null demo', withField('demo', null), true], ['accepts an HTTP demo', withField('demo', 'http://example.com/demo'), true], ['accepts an HTTPS demo', withField('demo', 'https://example.com/demo?q=1'), true], + ['accepts an IDN demo host', withField('demo', 'https://例え.テスト/a'), true], + ['accepts an IPv6 demo host', withField('demo', 'http://[2001:db8::1]/demo'), true], + ['accepts user information in a demo URL', withField('demo', 'http://user:pass@example.com/demo'), true], + ['accepts a high valid demo port', withField('demo', 'http://example.com:65535/demo'), true], + ['accepts a 2048-code-point demo', withField('demo', urlWithCodePointLength(2048)), true], + ['rejects a 2049-code-point demo', withField('demo', urlWithCodePointLength(2049)), false], ['rejects a relative demo', withField('demo', '/demo'), false], ['rejects a protocol-relative demo', withField('demo', '//example.com/demo'), false], ['rejects a non-web demo scheme', withField('demo', 'ftp://example.com/demo'), false], ['rejects a malformed HTTP demo', withField('demo', 'https://'), false], - ['rejects an internationalized demo host', withField('demo', 'https://例え.テスト/a'), false], - ['rejects backslashes in a demo URL', withField('demo', String.raw`https://example.com\evil`), false], - ['rejects a demo port outside the canonical grammar', withField('demo', 'http://example.com:99999/'), false], - ['rejects demo user information', withField('demo', 'http://user@example.com/'), false], + ['accepts a WHATWG-normalized demo backslash', withField('demo', String.raw`https://example.com\evil`), true], + ['rejects an out-of-range demo port', withField('demo', 'http://example.com:99999/'), false], ['rejects a non-string demo', withField('demo', 42), false], ['accepts the local repository marker', withField('repository', './'), true], ['accepts an HTTPS repository', withField('repository', 'https://github.com/example/project'), true], + ['accepts an IDN repository host', withField('repository', 'https://例え.テスト/a'), true], + ['accepts an IPv6 repository host', withField('repository', 'https://[2001:db8::1]/project'), true], + ['accepts user information in a repository URL', withField('repository', 'https://user:pass@example.com/project'), true], + ['accepts a high valid repository port', withField('repository', 'https://example.com:65535/project'), true], + ['accepts a 2048-code-point repository', withField('repository', urlWithCodePointLength(2048)), true], + ['rejects a 2049-code-point repository', withField('repository', urlWithCodePointLength(2049)), false], ['rejects an HTTP repository', withField('repository', 'http://github.com/example/project'), false], ['rejects a non-web repository scheme', withField('repository', 'git://github.com/example/project'), false], ['rejects a malformed HTTPS repository', withField('repository', 'https://'), false], - ['rejects an internationalized repository host', withField('repository', 'https://例え.テスト/a'), false], - ['rejects backslashes in a repository URL', withField('repository', String.raw`https://example.com\evil`), false], + ['accepts a WHATWG-normalized repository backslash', withField('repository', String.raw`https://example.com\evil`), true], ['rejects a relative repository path', withField('repository', '../project'), false], ['rejects a null repository', withField('repository', null), false], ['accepts a null cover', withField('cover', null), true], ['accepts a safe relative cover', withField('cover', 'images/cover.webp'), true], ['accepts a simple cover filename', withField('cover', 'cover.png'), true], + ['accepts a 255-code-point Unicode cover', withField('cover', `${'😀'.repeat(251)}.png`), true], + ['rejects a 256-code-point Unicode cover', withField('cover', `${'😀'.repeat(252)}.png`), false], ['rejects an empty cover', withField('cover', ''), false], ['rejects an absolute POSIX cover', withField('cover', '/images/cover.png'), false], ['rejects a traversing cover', withField('cover', '../secret.png'), false], @@ -152,6 +177,14 @@ const cases: Case[] = [ describe('project metadata contract', () => { const ajv = new Ajv2020({ allErrors: true, strict: true }); addFormats(ajv); + ajv.addFormat('absolute-http-url', { + type: 'string', + validate: (value: string) => isAbsoluteWebUrl(value, ['http:', 'https:']), + }); + ajv.addFormat('absolute-https-url', { + type: 'string', + validate: (value: string) => isAbsoluteWebUrl(value, ['https:']), + }); ajv.addKeyword({ keyword: 'updatedAtNotBeforeCreatedAt', schemaType: 'boolean', diff --git a/tools/project-schema.ts b/tools/project-schema.ts index 0dc1de8..c1e5814 100644 --- a/tools/project-schema.ts +++ b/tools/project-schema.ts @@ -3,8 +3,6 @@ import { posix } from 'node:path'; import { z } from 'zod'; const kebabCase = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; -const canonicalWebUrl = /^https?:\/\/[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?(?::[0-9]{1,4})?(?:[/?#][^\s\\]*)?$/; - const hasAtMostCodePoints = (value: string, maximum: number) => Array.from(value).length <= maximum; const isoDate = z @@ -33,8 +31,6 @@ const nonBlankString = (maximum: number) => const webUrl = (protocols: readonly string[]) => z .string() - .regex(canonicalWebUrl) - .url() .refine((value) => hasAtMostCodePoints(value, 2048), 'must contain at most 2048 characters') .refine((value) => { try { From ab3255740b5032517be3a52358249d647f0d92ba Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 12:14:46 +0800 Subject: [PATCH 09/40] fix: expose project schema AJV extensions --- tools/__tests__/project-schema.test.ts | 34 ++++--------------- tools/project-schema.ts | 45 +++++++++++++++++++++----- 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/tools/__tests__/project-schema.test.ts b/tools/__tests__/project-schema.test.ts index abc188e..8e68f0b 100644 --- a/tools/__tests__/project-schema.test.ts +++ b/tools/__tests__/project-schema.test.ts @@ -3,7 +3,10 @@ import addFormatsImport from 'ajv-formats'; import { describe, expect, it } from 'vitest'; import jsonSchema from '../../project.schema.json' with { type: 'json' }; -import { ProjectMetadataSchema } from '../project-schema.js'; +import { + ProjectMetadataSchema, + registerProjectSchemaAjvExtensions, +} from '../project-schema.js'; const valid = { title: 'Semantic Search Playground', @@ -23,15 +26,6 @@ const valid = { type Case = readonly [label: string, value: unknown, expected: boolean]; const addFormats = addFormatsImport as unknown as typeof import('ajv-formats').default; -const isAbsoluteWebUrl = (value: string, protocols: readonly string[]): boolean => { - try { - const url = new URL(value); - return protocols.includes(url.protocol) && url.hostname.length > 0; - } catch { - return false; - } -}; - const urlWithCodePointLength = (length: number): string => { const prefix = 'https://example.com/'; return `${prefix}${'😀'.repeat(length - Array.from(prefix).length)}`; @@ -122,7 +116,7 @@ const cases: Case[] = [ ['accepts an HTTPS demo', withField('demo', 'https://example.com/demo?q=1'), true], ['accepts an IDN demo host', withField('demo', 'https://例え.テスト/a'), true], ['accepts an IPv6 demo host', withField('demo', 'http://[2001:db8::1]/demo'), true], - ['accepts user information in a demo URL', withField('demo', 'http://user:pass@example.com/demo'), true], + ['rejects user information in a demo URL', withField('demo', 'http://user:pass@example.com/demo'), false], ['accepts a high valid demo port', withField('demo', 'http://example.com:65535/demo'), true], ['accepts a 2048-code-point demo', withField('demo', urlWithCodePointLength(2048)), true], ['rejects a 2049-code-point demo', withField('demo', urlWithCodePointLength(2049)), false], @@ -138,7 +132,7 @@ const cases: Case[] = [ ['accepts an HTTPS repository', withField('repository', 'https://github.com/example/project'), true], ['accepts an IDN repository host', withField('repository', 'https://例え.テスト/a'), true], ['accepts an IPv6 repository host', withField('repository', 'https://[2001:db8::1]/project'), true], - ['accepts user information in a repository URL', withField('repository', 'https://user:pass@example.com/project'), true], + ['rejects user information in a repository URL', withField('repository', 'https://user:pass@example.com/project'), false], ['accepts a high valid repository port', withField('repository', 'https://example.com:65535/project'), true], ['accepts a 2048-code-point repository', withField('repository', urlWithCodePointLength(2048)), true], ['rejects a 2049-code-point repository', withField('repository', urlWithCodePointLength(2049)), false], @@ -177,21 +171,7 @@ const cases: Case[] = [ describe('project metadata contract', () => { const ajv = new Ajv2020({ allErrors: true, strict: true }); addFormats(ajv); - ajv.addFormat('absolute-http-url', { - type: 'string', - validate: (value: string) => isAbsoluteWebUrl(value, ['http:', 'https:']), - }); - ajv.addFormat('absolute-https-url', { - type: 'string', - validate: (value: string) => isAbsoluteWebUrl(value, ['https:']), - }); - ajv.addKeyword({ - keyword: 'updatedAtNotBeforeCreatedAt', - schemaType: 'boolean', - type: 'object', - validate: (enabled: boolean, data: { createdAt?: string; updatedAt?: string }) => - !enabled || !data.createdAt || !data.updatedAt || data.updatedAt >= data.createdAt, - }); + registerProjectSchemaAjvExtensions(ajv); const validateJson = ajv.compile(jsonSchema); it.each(cases)('%s', (_label, value, expected) => { diff --git a/tools/project-schema.ts b/tools/project-schema.ts index c1e5814..c97925f 100644 --- a/tools/project-schema.ts +++ b/tools/project-schema.ts @@ -1,10 +1,46 @@ import { posix } from 'node:path'; +import type { Ajv2020 } from 'ajv/dist/2020.js'; import { z } from 'zod'; const kebabCase = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const hasAtMostCodePoints = (value: string, maximum: number) => Array.from(value).length <= maximum; +const isAbsoluteWebUrl = (value: string, protocols: readonly string[]): boolean => { + try { + const url = new URL(value); + return ( + protocols.includes(url.protocol) && + url.hostname.length > 0 && + url.username.length === 0 && + url.password.length === 0 + ); + } catch { + return false; + } +}; + +/** Register every non-standard format and keyword required before compiling project.schema.json. */ +export const registerProjectSchemaAjvExtensions = (ajv: Ajv2020): Ajv2020 => { + ajv.addFormat('absolute-http-url', { + type: 'string', + validate: (value: string) => isAbsoluteWebUrl(value, ['http:', 'https:']), + }); + ajv.addFormat('absolute-https-url', { + type: 'string', + validate: (value: string) => isAbsoluteWebUrl(value, ['https:']), + }); + ajv.addKeyword({ + keyword: 'updatedAtNotBeforeCreatedAt', + schemaType: 'boolean', + type: 'object', + validate: (enabled: boolean, data: { createdAt?: string; updatedAt?: string }) => + !enabled || !data.createdAt || !data.updatedAt || data.updatedAt >= data.createdAt, + }); + + return ajv; +}; + const isoDate = z .string() .regex(/^\d{4}-\d{2}-\d{2}$/) @@ -32,14 +68,7 @@ const webUrl = (protocols: readonly string[]) => z .string() .refine((value) => hasAtMostCodePoints(value, 2048), 'must contain at most 2048 characters') - .refine((value) => { - try { - const url = new URL(value); - return protocols.includes(url.protocol) && url.hostname.length > 0; - } catch { - return false; - } - }, `must use ${protocols.join(' or ')}`); + .refine((value) => isAbsoluteWebUrl(value, protocols), `must use ${protocols.join(' or ')} without credentials`); const slug = z.string().min(1).max(64).regex(kebabCase); const tag = z.string().min(1).max(32).regex(kebabCase); From 88c8ce74c51b0da9420c1ed4a9d5eea980b3886d Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 12:23:01 +0800 Subject: [PATCH 10/40] feat: validate project repository invariants --- tools/__tests__/fixtures.ts | 65 ++++++ tools/__tests__/validate-projects.test.ts | 246 ++++++++++++++++++++++ tools/config.ts | 10 + tools/lib/files.ts | 32 +++ tools/validate-projects.ts | 224 ++++++++++++++++++++ 5 files changed, 577 insertions(+) create mode 100644 tools/__tests__/fixtures.ts create mode 100644 tools/__tests__/validate-projects.test.ts create mode 100644 tools/config.ts create mode 100644 tools/lib/files.ts create mode 100644 tools/validate-projects.ts diff --git a/tools/__tests__/fixtures.ts b/tools/__tests__/fixtures.ts new file mode 100644 index 0000000..182fcc0 --- /dev/null +++ b/tools/__tests__/fixtures.ts @@ -0,0 +1,65 @@ +import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +export const validProject = { + title: 'My Idea', + slug: 'my-idea', + summary: 'A small but useful experiment.', + status: 'building', + type: 'other', + tags: ['experiment'], + createdAt: '2026-07-20', + updatedAt: '2026-07-21', + featured: false, + demo: null, + repository: './', + cover: null, +} as const; + +export type FixtureProject = { + dir: string; + metadata?: unknown; + projectJson?: string; + readme?: string; + cover?: string; +}; + +export async function makeRepository(projects: FixtureProject[] = []): Promise { + const root = await mkdtemp(join(tmpdir(), 'faust-test-')); + await mkdir(join(root, 'projects')); + + for (const project of projects) { + const projectPath = join(root, 'projects', project.dir); + await mkdir(projectPath, { recursive: true }); + if (project.projectJson !== undefined) { + await writeFile(join(projectPath, 'project.json'), project.projectJson); + } else if (project.metadata !== undefined) { + await writeFile(join(projectPath, 'project.json'), JSON.stringify(project.metadata)); + } + if (project.readme !== undefined) { + await writeFile(join(projectPath, 'README.md'), project.readme); + } + if (project.cover !== undefined) { + const coverPath = join(projectPath, project.cover); + await mkdir(dirname(coverPath), { recursive: true }); + await writeFile(coverPath, 'image'); + } + } + + return root; +} + +export async function writeValidProject( + projectPath: string, + metadata: Record = validProject, +): Promise { + await mkdir(projectPath, { recursive: true }); + await writeFile(join(projectPath, 'project.json'), JSON.stringify(metadata)); + await writeFile(join(projectPath, 'README.md'), '# My Idea\n'); + if (typeof metadata.cover === 'string') { + const coverPath = join(projectPath, metadata.cover); + await mkdir(dirname(coverPath), { recursive: true }); + await writeFile(coverPath, 'image'); + } +} diff --git a/tools/__tests__/validate-projects.test.ts b/tools/__tests__/validate-projects.test.ts new file mode 100644 index 0000000..a7f7896 --- /dev/null +++ b/tools/__tests__/validate-projects.test.ts @@ -0,0 +1,246 @@ +import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { + formatErrors, + runValidationCli, + validateProjectAt, + validateProjects, +} from '../validate-projects.js'; +import { makeRepository, validProject, writeValidProject } from './fixtures.js'; + +describe('repository project validation', () => { + it('accepts an empty projects directory', async () => { + const root = await makeRepository(); + + await expect(validateProjects({ root })).resolves.toEqual({ ok: true, projects: [] }); + }); + + it('accepts a repository before its first projects directory is created', async () => { + const root = await makeRepository(); + await rm(join(root, 'projects'), { recursive: true }); + + await expect(validateProjects({ root })).resolves.toEqual({ ok: true, projects: [] }); + }); + + it('reports a missing project.json with an actionable fix', async () => { + const root = await makeRepository([{ dir: 'my-idea', readme: '# My Idea' }]); + + const result = await validateProjects({ root }); + + expect(result).toMatchObject({ + ok: false, + errors: [expect.objectContaining({ project: 'my-idea', field: 'project.json' })], + }); + if (!result.ok) expect(formatErrors(result.errors)).toContain('fix: create project.json'); + }); + + it('reports a missing README with an actionable fix', async () => { + const root = await makeRepository([{ dir: 'my-idea', metadata: validProject }]); + + const result = await validateProjects({ root }); + + expect(result).toMatchObject({ + ok: false, + errors: [expect.objectContaining({ project: 'my-idea', field: 'README.md' })], + }); + }); + + it('reports malformed JSON without executing it', async () => { + const root = await makeRepository([ + { dir: 'my-idea', projectJson: '{ "slug": "my-idea",', readme: '# My Idea' }, + ]); + + const result = await validateProjects({ root }); + + expect(result).toMatchObject({ + ok: false, + errors: [expect.objectContaining({ project: 'my-idea', field: 'project.json' })], + }); + if (!result.ok) expect(formatErrors(result.errors)).toContain('valid JSON'); + }); + + it('reports schema fields and how to correct them', async () => { + const root = await makeRepository([ + { dir: 'my-idea', metadata: { ...validProject, status: 'paused' }, readme: '# My Idea' }, + ]); + + const result = await validateProjects({ root }); + + expect(result).toMatchObject({ + ok: false, + errors: [expect.objectContaining({ project: 'my-idea', field: 'status', fix: expect.any(String) })], + }); + }); + + it('reports slug-directory mismatches with the corrective choices', async () => { + const root = await makeRepository([ + { dir: 'wrong-dir', metadata: validProject, readme: '# My Idea' }, + ]); + + const result = await validateProjects({ root }); + + expect(result).toMatchObject({ + ok: false, + errors: [expect.objectContaining({ project: 'wrong-dir', field: 'slug' })], + }); + if (!result.ok) expect(formatErrors(result.errors)).toContain('rename the directory or change slug'); + }); + + it('reports every project that declares a duplicate slug', async () => { + const root = await makeRepository([ + { dir: 'alpha', metadata: { ...validProject, slug: 'shared' }, readme: '# Alpha' }, + { dir: 'beta', metadata: { ...validProject, slug: 'shared' }, readme: '# Beta' }, + ]); + + const result = await validateProjects({ root }); + + expect(result).toMatchObject({ ok: false }); + if (!result.ok) { + expect(result.errors.filter((error) => error.field === 'slug' && error.message.includes('duplicate'))) + .toEqual([ + expect.objectContaining({ project: 'alpha' }), + expect.objectContaining({ project: 'beta' }), + ]); + } + }); + + it('reports a referenced cover that does not exist', async () => { + const root = await makeRepository([ + { + dir: 'my-idea', + metadata: { ...validProject, cover: 'images/missing.png' }, + readme: '# My Idea', + }, + ]); + + const result = await validateProjects({ root }); + + expect(result).toMatchObject({ + ok: false, + errors: [expect.objectContaining({ project: 'my-idea', field: 'cover' })], + }); + }); + + it('rejects a cover symlink that escapes the real project directory', async () => { + const root = await makeRepository([ + { dir: 'my-idea', metadata: { ...validProject, cover: 'images/cover.png' }, readme: '# My Idea' }, + ]); + const projectPath = join(root, 'projects', 'my-idea'); + const outside = join(root, 'outside'); + await mkdir(outside); + await writeFile(join(outside, 'cover.png'), 'private'); + await symlink(outside, join(projectPath, 'images')); + + const result = await validateProjects({ root }); + + expect(result).toMatchObject({ + ok: false, + errors: [expect.objectContaining({ project: 'my-idea', field: 'cover' })], + }); + if (!result.ok) expect(formatErrors(result.errors)).toContain('inside the project directory'); + }); + + it('accepts an in-project cover whose directory name begins with two dots', async () => { + const root = await makeRepository([ + { + dir: 'my-idea', + metadata: { ...validProject, cover: '..covers/cover.png' }, + readme: '# My Idea', + cover: '..covers/cover.png', + }, + ]); + + await expect(validateProjects({ root })).resolves.toMatchObject({ ok: true }); + }); + + it('rejects a directory used as a cover', async () => { + const root = await makeRepository([ + { dir: 'my-idea', metadata: { ...validProject, cover: 'images' }, readme: '# My Idea' }, + ]); + await mkdir(join(root, 'projects', 'my-idea', 'images')); + + const result = await validateProjects({ root }); + + expect(result).toMatchObject({ + ok: false, + errors: [expect.objectContaining({ project: 'my-idea', field: 'cover' })], + }); + }); + + it('rejects a README symlink that escapes the real project directory', async () => { + const root = await makeRepository([{ dir: 'my-idea', metadata: validProject }]); + await writeFile(join(root, 'outside.md'), '# Not this project'); + await symlink(join(root, 'outside.md'), join(root, 'projects', 'my-idea', 'README.md')); + + const result = await validateProjects({ root }); + + expect(result).toMatchObject({ + ok: false, + errors: [expect.objectContaining({ project: 'my-idea', field: 'README.md' })], + }); + }); + + it('does not allow only to select a directory outside projects', async () => { + const root = await makeRepository(); + await writeValidProject(join(root, 'outside'), { ...validProject, slug: 'outside' }); + + const result = await validateProjects({ root, only: '../outside' }); + + expect(result).toMatchObject({ + ok: false, + errors: [expect.objectContaining({ project: '../outside', field: 'directory' })], + }); + }); + + it('discovers only sorted direct child directories', async () => { + const root = await makeRepository([ + { dir: 'zulu', metadata: { ...validProject, slug: 'zulu' }, readme: '# Zulu' }, + { dir: 'alpha', metadata: { ...validProject, slug: 'alpha' }, readme: '# Alpha' }, + ]); + await writeFile(join(root, 'projects', 'ignored.txt'), 'not a project'); + + const result = await validateProjects({ root }); + + expect(result).toMatchObject({ ok: true }); + if (result.ok) expect(result.projects.map((project) => project.slug)).toEqual(['alpha', 'zulu']); + }); +}); + +describe('validateProjectAt', () => { + it('validates a random staging path against its intended final directory name', async () => { + const root = await makeRepository(); + const projectPath = join(root, 'projects', '.my-idea.stage-abc123'); + await writeValidProject(projectPath); + + await expect(validateProjectAt({ projectPath, expectedDirectoryName: 'my-idea' })) + .resolves.toMatchObject({ ok: true, project: { slug: 'my-idea' } }); + await expect(validateProjectAt({ projectPath, expectedDirectoryName: 'different-name' })) + .resolves.toMatchObject({ ok: false, errors: [expect.objectContaining({ field: 'slug' })] }); + }); + + it('reads README and cover from the physical staging path', async () => { + const root = await makeRepository(); + const projectPath = join(root, 'projects', '.random-stage-name'); + await writeValidProject(projectPath, { ...validProject, cover: 'assets/cover.png' }); + + await expect(validateProjectAt({ projectPath, expectedDirectoryName: 'my-idea' })) + .resolves.toMatchObject({ ok: true, project: { slug: 'my-idea' } }); + }); +}); + +describe('validation CLI', () => { + it('prints actionable errors and returns status 1 without a stack trace', async () => { + const root = await makeRepository([{ dir: 'broken', readme: '# Broken' }]); + const output: string[] = []; + + const exitCode = await runValidationCli({ root, write: (line) => output.push(line) }); + + expect(exitCode).toBe(1); + expect(output).toHaveLength(1); + expect(output[0]).toMatch(/^broken: project\.json — .+; fix: create project\.json/m); + expect(output[0]).not.toContain('\n at '); + }); +}); diff --git a/tools/config.ts b/tools/config.ts new file mode 100644 index 0000000..215ff18 --- /dev/null +++ b/tools/config.ts @@ -0,0 +1,10 @@ +export const repository = { + owner: process.env.FAUST_GITHUB_OWNER ?? 'lurui1997', + name: process.env.FAUST_GITHUB_REPOSITORY ?? 'faust', + branch: process.env.FAUST_GITHUB_BRANCH ?? 'main', +}; + +export const pages = { + site: process.env.FAUST_SITE ?? `https://${repository.owner}.github.io`, + base: process.env.FAUST_BASE ?? `/${repository.name}`, +}; diff --git a/tools/lib/files.ts b/tools/lib/files.ts new file mode 100644 index 0000000..b721a7d --- /dev/null +++ b/tools/lib/files.ts @@ -0,0 +1,32 @@ +import { readdir, realpath, stat } from 'node:fs/promises'; +import { isAbsolute, join, relative, sep } from 'node:path'; + +export type ProjectDirectory = { name: string; path: string }; + +export async function discoverProjectDirectories(projectsPath: string): Promise { + let entries; + try { + entries = await readdir(projectsPath, { withFileTypes: true }); + } catch (caught) { + if ((caught as NodeJS.ErrnoException).code === 'ENOENT') return []; + throw caught; + } + return entries + .filter((entry) => entry.isDirectory()) + .map((entry) => ({ name: entry.name, path: join(projectsPath, entry.name) })) + .sort((left, right) => left.name.localeCompare(right.name, 'en')); +} + +export async function realPathIsWithin(parentPath: string, candidatePath: string): Promise { + const [parent, candidate] = await Promise.all([realpath(parentPath), realpath(candidatePath)]); + const pathFromParent = relative(parent, candidate); + return pathFromParent === '' || ( + pathFromParent !== '..' + && !pathFromParent.startsWith(`..${sep}`) + && !isAbsolute(pathFromParent) + ); +} + +export async function isRegularFileWithin(parentPath: string, candidatePath: string): Promise { + return (await realPathIsWithin(parentPath, candidatePath)) && (await stat(candidatePath)).isFile(); +} diff --git a/tools/validate-projects.ts b/tools/validate-projects.ts new file mode 100644 index 0000000..a7edf17 --- /dev/null +++ b/tools/validate-projects.ts @@ -0,0 +1,224 @@ +import { readFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { ZodIssue } from 'zod'; + +import { ProjectMetadataSchema, type ProjectMetadata } from './project-schema.js'; +import { discoverProjectDirectories, isRegularFileWithin } from './lib/files.js'; + +export type ValidationError = { + project: string; + field: string; + message: string; + fix: string; +}; + +export type ProjectValidationResult = + | { ok: true; project: ProjectMetadata } + | { ok: false; errors: ValidationError[] }; + +export type RepositoryValidationResult = + | { ok: true; projects: ProjectMetadata[] } + | { ok: false; errors: ValidationError[] }; + +const error = ( + project: string, + field: string, + message: string, + fix: string, +): ValidationError => ({ project, field, message, fix }); + +const fieldForIssue = (issue: ZodIssue): string => + issue.path.length > 0 ? issue.path.map(String).join('.') : 'project.json'; + +async function validateProject( + projectPath: string, + expectedDirectoryName: string, +): Promise<{ result: ProjectValidationResult; metadata?: ProjectMetadata }> { + const errors: ValidationError[] = []; + const metadataPath = join(projectPath, 'project.json'); + const readmePath = join(projectPath, 'README.md'); + let rawMetadata: string | undefined; + + try { + rawMetadata = await readFile(metadataPath, 'utf8'); + } catch { + errors.push(error( + expectedDirectoryName, + 'project.json', + 'required metadata file is missing or unreadable', + 'create project.json with every required metadata field', + )); + } + + try { + if (!(await isRegularFileWithin(projectPath, readmePath))) throw new Error('not a contained file'); + } catch { + errors.push(error( + expectedDirectoryName, + 'README.md', + 'required project README is missing, unreadable, or outside the project directory', + 'create a regular README.md file inside this project directory', + )); + } + + let parsedJson: unknown; + if (rawMetadata !== undefined) { + try { + parsedJson = JSON.parse(rawMetadata) as unknown; + } catch { + errors.push(error( + expectedDirectoryName, + 'project.json', + 'metadata is malformed JSON', + 'replace project.json with valid JSON', + )); + } + } + + let metadata: ProjectMetadata | undefined; + if (parsedJson !== undefined) { + const parsed = ProjectMetadataSchema.safeParse(parsedJson); + if (parsed.success) { + metadata = parsed.data; + } else { + for (const issue of parsed.error.issues) { + const field = fieldForIssue(issue); + errors.push(error( + expectedDirectoryName, + field, + issue.message, + `correct the ${field} field in project.json`, + )); + } + } + } + + if (metadata !== undefined) { + if (metadata.slug !== expectedDirectoryName) { + errors.push(error( + expectedDirectoryName, + 'slug', + `slug "${metadata.slug}" does not match directory "${expectedDirectoryName}"`, + 'rename the directory or change slug so they match', + )); + } + + if (metadata.cover !== null) { + const coverPath = join(projectPath, metadata.cover); + try { + if (!(await isRegularFileWithin(projectPath, coverPath))) { + errors.push(error( + expectedDirectoryName, + 'cover', + `cover "${metadata.cover}" is not a regular file inside the project directory`, + 'store a regular cover file inside the project directory and update cover', + )); + } + } catch { + errors.push(error( + expectedDirectoryName, + 'cover', + `cover "${metadata.cover}" is missing or unreadable`, + 'add the referenced cover inside the project directory or set cover to null', + )); + } + } + } + + return errors.length === 0 && metadata !== undefined + ? { result: { ok: true, project: metadata }, metadata } + : { result: { ok: false, errors }, metadata }; +} + +export async function validateProjectAt(options: { + projectPath: string; + expectedDirectoryName: string; +}): Promise { + return (await validateProject(options.projectPath, options.expectedDirectoryName)).result; +} + +export async function validateProjects(options: { + root: string; + only?: string; +}): Promise { + const projectsPath = join(options.root, 'projects'); + const discoveredDirectories = await discoverProjectDirectories(projectsPath); + const directories = options.only === undefined + ? discoveredDirectories + : discoveredDirectories.filter((directory) => directory.name === options.only); + const errors: ValidationError[] = []; + const projects: ProjectMetadata[] = []; + const declarations = new Map(); + + if (options.only !== undefined && directories.length === 0) { + return { + ok: false, + errors: [error( + options.only, + 'directory', + 'selected project is not a direct child directory of projects', + 'choose the exact name of a directory directly inside projects', + )], + }; + } + + for (const directory of directories) { + const validation = await validateProject(directory.path, directory.name); + if (validation.result.ok) projects.push(validation.result.project); + else errors.push(...validation.result.errors); + if (validation.metadata !== undefined) { + declarations.set(validation.metadata.slug, [ + ...(declarations.get(validation.metadata.slug) ?? []), + directory.name, + ]); + } + } + + for (const [slug, projectNames] of declarations) { + if (projectNames.length < 2) continue; + for (const project of projectNames) { + errors.push(error( + project, + 'slug', + `duplicate slug "${slug}" is declared by ${projectNames.join(', ')}`, + 'change slug and its directory name so every project slug is unique', + )); + } + } + + return errors.length === 0 ? { ok: true, projects } : { ok: false, errors }; +} + +export const formatErrors = (errors: ValidationError[]): string => + errors + .map(({ project, field, message, fix }) => `${project}: ${field} — ${message}; fix: ${fix}`) + .join('\n'); + +export async function runValidationCli(options: { + root: string; + write?: (line: string) => void; +}): Promise<0 | 1> { + const write = options.write ?? ((line: string) => console.log(line)); + try { + const result = await validateProjects({ root: options.root }); + if (result.ok) { + write(`Validated ${result.projects.length} projects`); + return 0; + } else { + write(formatErrors(result.errors)); + return 1; + } + } catch (caught) { + const message = caught instanceof Error ? caught.message : String(caught); + write(`repository: projects — validation could not run: ${message}; fix: ensure projects/ exists and is readable`); + return 1; + } +} + +const invokedPath = process.argv[1] === undefined ? undefined : resolve(process.argv[1]); +if (invokedPath === fileURLToPath(import.meta.url)) { + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + process.exitCode = await runValidationCli({ root, write: (line) => console.error(line) }); +} From cedf7066ee7d9c635aafc9bcf43656fbea766dcb Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 12:25:40 +0800 Subject: [PATCH 11/40] fix: compose repository validation primitive --- tools/__tests__/validate-projects.test.ts | 18 +++++++++ tools/validate-projects.ts | 46 ++++++++++++++--------- 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/tools/__tests__/validate-projects.test.ts b/tools/__tests__/validate-projects.test.ts index a7f7896..424ab59 100644 --- a/tools/__tests__/validate-projects.test.ts +++ b/tools/__tests__/validate-projects.test.ts @@ -107,6 +107,24 @@ describe('repository project validation', () => { } }); + it('detects duplicate declarations even when another metadata field is invalid', async () => { + const root = await makeRepository([ + { + dir: 'alpha', + metadata: { ...validProject, slug: 'shared', status: 'paused' }, + readme: '# Alpha', + }, + { dir: 'beta', metadata: { ...validProject, slug: 'shared' }, readme: '# Beta' }, + ]); + + const result = await validateProjects({ root }); + + expect(result).toMatchObject({ ok: false }); + if (!result.ok) { + expect(result.errors.filter((item) => item.message.includes('duplicate slug'))).toHaveLength(2); + } + }); + it('reports a referenced cover that does not exist', async () => { const root = await makeRepository([ { diff --git a/tools/validate-projects.ts b/tools/validate-projects.ts index a7edf17..bd842c7 100644 --- a/tools/validate-projects.ts +++ b/tools/validate-projects.ts @@ -32,10 +32,11 @@ const error = ( const fieldForIssue = (issue: ZodIssue): string => issue.path.length > 0 ? issue.path.map(String).join('.') : 'project.json'; -async function validateProject( - projectPath: string, - expectedDirectoryName: string, -): Promise<{ result: ProjectValidationResult; metadata?: ProjectMetadata }> { +export async function validateProjectAt(options: { + projectPath: string; + expectedDirectoryName: string; +}): Promise { + const { projectPath, expectedDirectoryName } = options; const errors: ValidationError[] = []; const metadataPath = join(projectPath, 'project.json'); const readmePath = join(projectPath, 'README.md'); @@ -128,16 +129,20 @@ async function validateProject( } return errors.length === 0 && metadata !== undefined - ? { result: { ok: true, project: metadata }, metadata } - : { result: { ok: false, errors }, metadata }; + ? { ok: true, project: metadata } + : { ok: false, errors }; } -export async function validateProjectAt(options: { - projectPath: string; - expectedDirectoryName: string; -}): Promise { - return (await validateProject(options.projectPath, options.expectedDirectoryName)).result; -} +const readDeclaredSlug = async (projectPath: string): Promise => { + try { + const parsed = JSON.parse(await readFile(join(projectPath, 'project.json'), 'utf8')) as unknown; + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined; + const slug = (parsed as Record).slug; + return typeof slug === 'string' ? slug : undefined; + } catch { + return undefined; + } +}; export async function validateProjects(options: { root: string; @@ -165,12 +170,17 @@ export async function validateProjects(options: { } for (const directory of directories) { - const validation = await validateProject(directory.path, directory.name); - if (validation.result.ok) projects.push(validation.result.project); - else errors.push(...validation.result.errors); - if (validation.metadata !== undefined) { - declarations.set(validation.metadata.slug, [ - ...(declarations.get(validation.metadata.slug) ?? []), + const validation = await validateProjectAt({ + projectPath: directory.path, + expectedDirectoryName: directory.name, + }); + if (validation.ok) projects.push(validation.project); + else errors.push(...validation.errors); + + const declaredSlug = await readDeclaredSlug(directory.path); + if (declaredSlug !== undefined) { + declarations.set(declaredSlug, [ + ...(declarations.get(declaredSlug) ?? []), directory.name, ]); } From 6f9a5185298322d4dfeefc93aa778f08cc34316f Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 12:32:34 +0800 Subject: [PATCH 12/40] fix: harden project validation snapshots --- tools/__tests__/fixtures.ts | 10 ++- tools/__tests__/validate-projects.test.ts | 92 ++++++++++++++++++++- tools/lib/files.ts | 5 ++ tools/validate-projects.ts | 99 ++++++++++++++--------- 4 files changed, 165 insertions(+), 41 deletions(-) diff --git a/tools/__tests__/fixtures.ts b/tools/__tests__/fixtures.ts index 182fcc0..d2fb64f 100644 --- a/tools/__tests__/fixtures.ts +++ b/tools/__tests__/fixtures.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -25,8 +25,16 @@ export type FixtureProject = { cover?: string; }; +const repositoryRoots = new Set(); + +export async function cleanupRepositories(): Promise { + await Promise.all([...repositoryRoots].map((root) => rm(root, { recursive: true, force: true }))); + repositoryRoots.clear(); +} + export async function makeRepository(projects: FixtureProject[] = []): Promise { const root = await mkdtemp(join(tmpdir(), 'faust-test-')); + repositoryRoots.add(root); await mkdir(join(root, 'projects')); for (const project of projects) { diff --git a/tools/__tests__/validate-projects.test.ts b/tools/__tests__/validate-projects.test.ts index 424ab59..debe4f3 100644 --- a/tools/__tests__/validate-projects.test.ts +++ b/tools/__tests__/validate-projects.test.ts @@ -1,7 +1,19 @@ import { mkdir, rm, symlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const fsMock = vi.hoisted(() => ({ + actualReadFile: undefined as undefined | ((...args: any[]) => Promise), + readFile: vi.fn(), +})); + +vi.mock('node:fs/promises', async () => { + const actual = await vi.importActual('node:fs/promises'); + fsMock.actualReadFile = actual.readFile as (...args: any[]) => Promise; + fsMock.readFile.mockImplementation(fsMock.actualReadFile); + return { ...actual, readFile: fsMock.readFile }; +}); import { formatErrors, @@ -9,7 +21,18 @@ import { validateProjectAt, validateProjects, } from '../validate-projects.js'; -import { makeRepository, validProject, writeValidProject } from './fixtures.js'; +import { + cleanupRepositories, + makeRepository, + validProject, + writeValidProject, +} from './fixtures.js'; + +afterEach(async () => { + fsMock.readFile.mockReset(); + fsMock.readFile.mockImplementation(fsMock.actualReadFile!); + await cleanupRepositories(); +}); describe('repository project validation', () => { it('accepts an empty projects directory', async () => { @@ -125,6 +148,67 @@ describe('repository project validation', () => { } }); + it('checks duplicates against all projects when only one project is validated', async () => { + const root = await makeRepository([ + { dir: 'shared', metadata: { ...validProject, slug: 'shared' }, readme: '# Shared' }, + { + dir: 'beta', + metadata: { ...validProject, slug: 'shared', status: 'paused' }, + readme: '# Beta', + }, + ]); + + const result = await validateProjects({ root, only: 'shared' }); + + expect(result).toMatchObject({ ok: false }); + if (!result.ok) { + expect(result.errors).toEqual([ + expect.objectContaining({ project: 'shared', field: 'slug', message: expect.stringContaining('beta') }), + ]); + expect(result.errors.some((item) => item.project === 'beta' && item.field === 'status')).toBe(false); + } + }); + + it('rejects project.json when it is a symlink outside the project', async () => { + const root = await makeRepository([{ dir: 'my-idea', readme: '# My Idea' }]); + await writeFile(join(root, 'outside.json'), JSON.stringify(validProject)); + await symlink(join(root, 'outside.json'), join(root, 'projects', 'my-idea', 'project.json')); + + const result = await validateProjects({ root }); + + expect(result).toMatchObject({ + ok: false, + errors: [expect.objectContaining({ project: 'my-idea', field: 'project.json' })], + }); + }); + + it('uses one project.json snapshot even if the file changes after that read', async () => { + const root = await makeRepository([ + { dir: 'alpha', metadata: { ...validProject, slug: 'shared' }, readme: '# Alpha' }, + { dir: 'beta', metadata: { ...validProject, slug: 'shared' }, readme: '# Beta' }, + ]); + const alphaMetadata = join(root, 'projects', 'alpha', 'project.json'); + let alphaReads = 0; + fsMock.readFile.mockImplementation(async (path, options) => { + const contents = await fsMock.actualReadFile!(path, options); + if (String(path) === alphaMetadata) { + alphaReads += 1; + if (alphaReads === 1) { + await writeFile(alphaMetadata, JSON.stringify({ ...validProject, slug: 'changed' })); + } + } + return contents; + }); + + const result = await validateProjects({ root }); + + expect(alphaReads).toBe(1); + expect(result).toMatchObject({ ok: false }); + if (!result.ok) { + expect(result.errors.filter((item) => item.message.includes('duplicate slug'))).toHaveLength(2); + } + }); + it('reports a referenced cover that does not exist', async () => { const root = await makeRepository([ { @@ -233,8 +317,8 @@ describe('validateProjectAt', () => { const projectPath = join(root, 'projects', '.my-idea.stage-abc123'); await writeValidProject(projectPath); - await expect(validateProjectAt({ projectPath, expectedDirectoryName: 'my-idea' })) - .resolves.toMatchObject({ ok: true, project: { slug: 'my-idea' } }); + const validResult = await validateProjectAt({ projectPath, expectedDirectoryName: 'my-idea' }); + expect(validResult).toEqual({ ok: true, project: validProject }); await expect(validateProjectAt({ projectPath, expectedDirectoryName: 'different-name' })) .resolves.toMatchObject({ ok: false, errors: [expect.objectContaining({ field: 'slug' })] }); }); diff --git a/tools/lib/files.ts b/tools/lib/files.ts index b721a7d..79f90ef 100644 --- a/tools/lib/files.ts +++ b/tools/lib/files.ts @@ -28,5 +28,10 @@ export async function realPathIsWithin(parentPath: string, candidatePath: string } export async function isRegularFileWithin(parentPath: string, candidatePath: string): Promise { + // Threat model: repository tools run against a trusted, serialized local worktree. The + // realpath/stat pair prevents accidental and pre-existing symlink escapes, but does not + // claim safety against a hostile process swapping paths concurrently between these calls. + // Consumers must preserve that assumption rather than treating this as an openat-style + // race-proof sandbox boundary. return (await realPathIsWithin(parentPath, candidatePath)) && (await stat(candidatePath)).isFile(); } diff --git a/tools/validate-projects.ts b/tools/validate-projects.ts index bd842c7..65797a6 100644 --- a/tools/validate-projects.ts +++ b/tools/validate-projects.ts @@ -32,24 +32,53 @@ const error = ( const fieldForIssue = (issue: ZodIssue): string => issue.path.length > 0 ? issue.path.map(String).join('.') : 'project.json'; +type MetadataSnapshot = { + parsedJson?: unknown; + declaredSlug?: string; + fileError?: true; + jsonError?: true; +}; + +const declarationByResult = new WeakMap(); + +const readMetadataSnapshot = async (projectPath: string): Promise => { + const metadataPath = join(projectPath, 'project.json'); + try { + if (!(await isRegularFileWithin(projectPath, metadataPath))) return { fileError: true }; + // One read defines the snapshot used for both schema validation and duplicate detection. + const rawMetadata = await readFile(metadataPath, 'utf8'); + try { + const parsedJson = JSON.parse(rawMetadata) as unknown; + const declaredSlug = ( + typeof parsedJson === 'object' + && parsedJson !== null + && !Array.isArray(parsedJson) + && typeof (parsedJson as Record).slug === 'string' + ) ? (parsedJson as Record).slug : undefined; + return { parsedJson, declaredSlug }; + } catch { + return { jsonError: true }; + } + } catch { + return { fileError: true }; + } +}; + export async function validateProjectAt(options: { projectPath: string; expectedDirectoryName: string; }): Promise { const { projectPath, expectedDirectoryName } = options; const errors: ValidationError[] = []; - const metadataPath = join(projectPath, 'project.json'); const readmePath = join(projectPath, 'README.md'); - let rawMetadata: string | undefined; + const snapshot = await readMetadataSnapshot(projectPath); - try { - rawMetadata = await readFile(metadataPath, 'utf8'); - } catch { + if (snapshot.fileError) { errors.push(error( expectedDirectoryName, 'project.json', - 'required metadata file is missing or unreadable', - 'create project.json with every required metadata field', + 'required metadata file is missing, unreadable, or outside the project directory', + 'create project.json as a regular file inside this project directory', )); } @@ -64,23 +93,18 @@ export async function validateProjectAt(options: { )); } - let parsedJson: unknown; - if (rawMetadata !== undefined) { - try { - parsedJson = JSON.parse(rawMetadata) as unknown; - } catch { - errors.push(error( - expectedDirectoryName, - 'project.json', - 'metadata is malformed JSON', - 'replace project.json with valid JSON', - )); - } + if (snapshot.jsonError) { + errors.push(error( + expectedDirectoryName, + 'project.json', + 'metadata is malformed JSON', + 'replace project.json with valid JSON', + )); } let metadata: ProjectMetadata | undefined; - if (parsedJson !== undefined) { - const parsed = ProjectMetadataSchema.safeParse(parsedJson); + if (snapshot.parsedJson !== undefined) { + const parsed = ProjectMetadataSchema.safeParse(snapshot.parsedJson); if (parsed.success) { metadata = parsed.data; } else { @@ -128,22 +152,13 @@ export async function validateProjectAt(options: { } } - return errors.length === 0 && metadata !== undefined + const result: ProjectValidationResult = errors.length === 0 && metadata !== undefined ? { ok: true, project: metadata } : { ok: false, errors }; + if (snapshot.declaredSlug !== undefined) declarationByResult.set(result, snapshot.declaredSlug); + return result; } -const readDeclaredSlug = async (projectPath: string): Promise => { - try { - const parsed = JSON.parse(await readFile(join(projectPath, 'project.json'), 'utf8')) as unknown; - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return undefined; - const slug = (parsed as Record).slug; - return typeof slug === 'string' ? slug : undefined; - } catch { - return undefined; - } -}; - export async function validateProjects(options: { root: string; only?: string; @@ -169,7 +184,19 @@ export async function validateProjects(options: { }; } - for (const directory of directories) { + const selectedNames = new Set(directories.map((directory) => directory.name)); + for (const directory of discoveredDirectories) { + if (!selectedNames.has(directory.name)) { + const snapshot = await readMetadataSnapshot(directory.path); + if (snapshot.declaredSlug !== undefined) { + declarations.set(snapshot.declaredSlug, [ + ...(declarations.get(snapshot.declaredSlug) ?? []), + directory.name, + ]); + } + continue; + } + const validation = await validateProjectAt({ projectPath: directory.path, expectedDirectoryName: directory.name, @@ -177,7 +204,7 @@ export async function validateProjects(options: { if (validation.ok) projects.push(validation.project); else errors.push(...validation.errors); - const declaredSlug = await readDeclaredSlug(directory.path); + const declaredSlug = declarationByResult.get(validation); if (declaredSlug !== undefined) { declarations.set(declaredSlug, [ ...(declarations.get(declaredSlug) ?? []), @@ -188,7 +215,7 @@ export async function validateProjects(options: { for (const [slug, projectNames] of declarations) { if (projectNames.length < 2) continue; - for (const project of projectNames) { + for (const project of projectNames.filter((name) => selectedNames.has(name))) { errors.push(error( project, 'slug', From 707446981e16fb25c7c74529461f44b2d15a046f Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 12:44:00 +0800 Subject: [PATCH 13/40] feat: generate deterministic gallery project data --- .../__tests__/generate-project-index.test.ts | 142 +++++++++++++++ tools/__tests__/project-links.test.ts | 24 +++ tools/generate-project-index.ts | 157 +++++++++++++++++ tools/lib/readme.ts | 163 ++++++++++++++++++ tools/project-links.ts | 26 +++ 5 files changed, 512 insertions(+) create mode 100644 tools/__tests__/generate-project-index.test.ts create mode 100644 tools/__tests__/project-links.test.ts create mode 100644 tools/generate-project-index.ts create mode 100644 tools/lib/readme.ts create mode 100644 tools/project-links.ts diff --git a/tools/__tests__/generate-project-index.test.ts b/tools/__tests__/generate-project-index.test.ts new file mode 100644 index 0000000..04f85fb --- /dev/null +++ b/tools/__tests__/generate-project-index.test.ts @@ -0,0 +1,142 @@ +import { mkdir, readFile, readlink, stat, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { buildProjectIndex, generateProjectIndex } from '../generate-project-index.js'; +import { extractExcerptHtml, visibleText } from '../lib/readme.js'; +import { cleanupRepositories, makeRepository, validProject } from './fixtures.js'; + +afterEach(async () => cleanupRepositories()); + +describe('README excerpts', () => { + it('keeps safe inline formatting and strips active content and images', () => { + const markdown = '# Title\n\nFirst *small* **useful** `code` [good](/docs) [bad](javascript:alert(1)) ![secret](x) '; + const html = extractExcerptHtml(markdown, 'Fallback', 240); + + expect(html).toContain('small'); + expect(html).toContain('useful'); + expect(html).toContain('code'); + expect(html).toContain('good'); + expect(html).not.toMatch(/javascript:|script| { + const html = extractExcerptHtml('[external](https://example.com) [local](../readme) [bad](data:text/html,x)', 'Fallback'); + expect(html).toContain('external'); + expect(html).toContain('local'); + expect(html).not.toContain('data:'); + }); + + it('uses the first nonempty paragraph after an optional H1', () => { + expect(visibleText(extractExcerptHtml('# Heading\n\n\nFirst paragraph.\n\nSecond paragraph.', 'Fallback'))) + .toBe('First paragraph.'); + }); + + it('falls back to an escaped summary when there is no paragraph', () => { + expect(extractExcerptHtml('# Heading\n\n- only a list', 'fallback')).toBe('

    <b>fallback</b>

    '); + }); + + it('honors exact code-point bounds and truncates at a word boundary', () => { + expect(Array.from(visibleText(extractExcerptHtml('a'.repeat(240), 'x')))).toHaveLength(240); + const excerpt = visibleText(extractExcerptHtml(`${'word '.repeat(60)}tail`, 'x')); + expect(Array.from(excerpt).length).toBeLessThanOrEqual(240); + expect(excerpt.endsWith('…')).toBe(true); + expect(excerpt.at(-2)).not.toBe(' '); + }); + + it('counts Unicode code points and truncates a single long word', () => { + const excerpt = visibleText(extractExcerptHtml('😀'.repeat(241), 'x')); + expect(Array.from(excerpt)).toHaveLength(240); + expect(excerpt).toBe(`${'😀'.repeat(239)}…`); + }); + + it('reports decoded visible text rather than HTML entity source', () => { + expect(visibleText('

    & < 😀

    ')).toBe('& < 😀'); + }); + + it('preserves HTML-looking inline code and escaped entity text literally', () => { + const html = extractExcerptHtml('Use `literal` and <em>text</em> safely.', 'Fallback'); + expect(html).toContain('<strong>literal</strong>'); + expect(html).toContain('<em>text</em>'); + expect(html).not.toContain(''); + }); + + it('uses the summary fallback when README content is only a raw HTML block', () => { + const html = extractExcerptHtml('', 'Safe fallback'); + expect(html).toBe('

    Safe fallback

    '); + expect(visibleText(html)).not.toMatch(/script|alert/i); + }); +}); + +describe('project index generation', () => { + it('sorts by updatedAt descending and slug ascending for ties', async () => { + const root = await makeRepository([ + { dir: 'zulu', metadata: { ...validProject, slug: 'zulu' }, readme: '# Z\n\nZulu.' }, + { dir: 'alpha', metadata: { ...validProject, slug: 'alpha' }, readme: '# A\n\nAlpha.' }, + { dir: 'a-a', metadata: { ...validProject, slug: 'a-a' }, readme: 'Dash.' }, + { dir: 'a1', metadata: { ...validProject, slug: 'a1' }, readme: 'Digit.' }, + { dir: 'aa', metadata: { ...validProject, slug: 'aa' }, readme: 'Letters.' }, + { dir: 'old', metadata: { ...validProject, slug: 'old', updatedAt: '2026-07-20' }, readme: 'Old.' }, + ]); + expect((await buildProjectIndex(root)).map(({ slug }) => slug)).toEqual(['a-a', 'a1', 'aa', 'alpha', 'zulu', 'old']); + }); + + it('writes deterministic JSON with one newline and reports an empty repository', async () => { + const root = await makeRepository(); + const lines: string[] = []; + await generateProjectIndex({ root, write: (line) => lines.push(line) }); + + expect(await readFile(join(root, 'gallery/src/data/projects.generated.json'), 'utf8')).toBe('[]\n'); + expect(lines).toEqual(['Generated 0 projects']); + }); + + it('removes stale immutable cover versions after a successful swap', async () => { + const root = await makeRepository([{ dir: 'first', metadata: { ...validProject, slug: 'first', cover: 'cover.png' }, readme: 'One.', cover: 'cover.png' }]); + await generateProjectIndex({ root }); + const publicPath = join(root, 'gallery/public'); + const firstTarget = await readlink(join(publicPath, 'project-assets')); + + await writeFile(join(root, 'projects/first/cover.png'), 'changed'); + await generateProjectIndex({ root }); + const secondTarget = await readlink(join(publicPath, 'project-assets')); + + expect(secondTarget).not.toBe(firstTarget); + await expect(stat(join(publicPath, firstTarget))).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(readFile(join(publicPath, secondTarget, 'first/cover.png'), 'utf8')).resolves.toBe('changed'); + }); + + it('preserves the active version when copying a new cover fails', async () => { + const root = await makeRepository([{ dir: 'first', metadata: { ...validProject, slug: 'first', cover: 'cover.png' }, readme: 'One.', cover: 'cover.png' }]); + await generateProjectIndex({ root }); + const publicPath = join(root, 'gallery/public'); + const firstTarget = await readlink(join(publicPath, 'project-assets')); + await mkdir(join(root, 'projects/second'), { recursive: true }); + await writeFile(join(root, 'projects/second/project.json'), JSON.stringify({ ...validProject, slug: 'second', cover: 'cover.png' })); + await writeFile(join(root, 'projects/second/README.md'), 'Two.'); + await writeFile(join(root, 'projects/second/cover.png'), 'two'); + + await expect(generateProjectIndex({ root, copyCover: async () => { throw new Error('copy failed'); } })).rejects.toThrow('copy failed'); + expect(await readlink(join(publicPath, 'project-assets'))).toBe(firstTarget); + await expect(readFile(join(publicPath, firstTarget, 'first/cover.png'), 'utf8')).resolves.toBe('image'); + }); + + it('restores the prior pointer when writing the generated index fails after the swap', async () => { + const root = await makeRepository([{ dir: 'first', metadata: { ...validProject, slug: 'first', cover: 'cover.png' }, readme: 'One.', cover: 'cover.png' }]); + await generateProjectIndex({ root }); + const publicPath = join(root, 'gallery/public'); + const firstTarget = await readlink(join(publicPath, 'project-assets')); + await writeFile(join(root, 'projects/first/cover.png'), 'changed'); + + await expect(generateProjectIndex({ root, writeIndex: async () => { throw new Error('index failed'); } })).rejects.toThrow('index failed'); + expect(await readlink(join(publicPath, 'project-assets'))).toBe(firstTarget); + await expect(readFile(join(publicPath, firstTarget, 'first/cover.png'), 'utf8')).resolves.toBe('image'); + }); + + it('reports unsupported symlinks actionably', async () => { + const root = await makeRepository(); + await expect(generateProjectIndex({ root, createSymlink: async () => { + throw Object.assign(new Error('denied'), { code: 'EPERM' }); + } })).rejects.toThrow(/symbolic link.*enable/i); + }); +}); diff --git a/tools/__tests__/project-links.test.ts b/tools/__tests__/project-links.test.ts new file mode 100644 index 0000000..393c689 --- /dev/null +++ b/tools/__tests__/project-links.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; + +import { coverUrl, projectUrl, sourceUrl } from '../project-links.js'; + +describe('project links', () => { + const repo = { owner: 'a', name: 'faust', branch: 'feature/one' }; + + it('resolves local source from repository config', () => { + expect(sourceUrl({ slug: 'search', repository: './' }, repo)) + .toBe('https://github.com/a/faust/tree/feature%2Fone/projects/search'); + }); + + it('passes through an external HTTPS source', () => { + expect(sourceUrl({ slug: 'search', repository: 'https://example.com/repo' }, repo)) + .toBe('https://example.com/repo'); + }); + + it('prefixes project and cover paths with a normalized base', () => { + expect(projectUrl('hello', '/faust/')).toBe('/faust/projects/hello/'); + expect(coverUrl('hello', 'images/cover one.png', '/faust')).toBe('/faust/project-assets/hello/images/cover%20one.png'); + expect(projectUrl('hello', '/')).toBe('/projects/hello/'); + expect(coverUrl('hello', null, '/faust')).toBeNull(); + }); +}); diff --git a/tools/generate-project-index.ts b/tools/generate-project-index.ts new file mode 100644 index 0000000..67ddf6c --- /dev/null +++ b/tools/generate-project-index.ts @@ -0,0 +1,157 @@ +import { createHash } from 'node:crypto'; +import { copyFile, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { dirname, join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { pages, repository } from './config.js'; +import { extractExcerptHtml } from './lib/readme.js'; +import { coverUrl, projectUrl, sourceUrl } from './project-links.js'; +import type { ProjectMetadata } from './project-schema.js'; +import { formatErrors, validateProjects } from './validate-projects.js'; + +export type GalleryProject = ProjectMetadata & { + excerptHtml: string; + year: string; + href: string; + sourceHref: string; + coverHref: string | null; +}; + +export class ProjectValidationError extends Error { + constructor(public readonly errors: Parameters[0]) { + super(formatErrors(errors)); + this.name = 'ProjectValidationError'; + } +} + +export async function buildProjectIndex(root: string): Promise { + const result = await validateProjects({ root }); + if (!result.ok) throw new ProjectValidationError(result.errors); + const records = await Promise.all(result.projects.map(async (project) => { + const readme = await readFile(join(root, 'projects', project.slug, 'README.md'), 'utf8'); + return { + ...project, + excerptHtml: extractExcerptHtml(readme, project.summary), + year: project.createdAt.slice(0, 4), + href: projectUrl(project.slug, pages.base), + sourceHref: sourceUrl(project, repository), + coverHref: coverUrl(project.slug, project.cover, pages.base), + }; + })); + return records.sort((a, b) => { + const dateOrder = b.updatedAt.localeCompare(a.updatedAt); + if (dateOrder !== 0) return dateOrder; + return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0; + }); +} + +type GenerateOptions = { + root: string; + write?: (line: string) => void; + copyCover?: typeof copyFile; + createSymlink?: typeof symlink; + writeIndex?: (path: string, contents: string) => Promise; +}; + +const atomicWrite = async (path: string, contents: string): Promise => { + await mkdir(dirname(path), { recursive: true }); + const temporary = `${path}.tmp-${process.pid}-${Date.now()}`; + try { + await writeFile(temporary, contents); + await rename(temporary, path); + } catch (error) { + await rm(temporary, { force: true }); + throw error; + } +}; + +export async function generateProjectIndex(options: GenerateOptions): Promise { + const records = await buildProjectIndex(options.root); + const publicPath = join(options.root, 'gallery', 'public'); + await mkdir(publicPath, { recursive: true }); + const hash = createHash('sha256'); + const covers: Array<{ source: string; destination: string; contents: Buffer }> = []; + for (const project of records) { + if (project.cover === null) continue; + const source = join(options.root, 'projects', project.slug, project.cover); + const contents = await readFile(source); + hash.update(project.slug).update('\0').update(project.cover).update('\0').update(contents); + covers.push({ source, destination: join(project.slug, project.cover), contents }); + } + if (covers.length === 0) hash.update('empty'); + const versionName = `.project-assets-${hash.digest('hex').slice(0, 16)}`; + const versionPath = join(publicPath, versionName); + const pointerPath = join(publicPath, 'project-assets'); + const temporaryPointer = join(publicPath, `.project-assets-link-${process.pid}-${Date.now()}`); + const copy = options.copyCover ?? copyFile; + const makeSymlink = options.createSymlink ?? symlink; + const writeIndex = options.writeIndex ?? atomicWrite; + let createdVersion = false; + let swapped = false; + let oldTarget: string | undefined; + try { + oldTarget = await readlink(pointerPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + try { + try { + await lstat(versionPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + await mkdir(versionPath, { recursive: true }); + createdVersion = true; + for (const cover of covers) { + const destination = join(versionPath, cover.destination); + await mkdir(dirname(destination), { recursive: true }); + await copy(cover.source, destination); + } + } + try { + await makeSymlink(relative(publicPath, versionPath), temporaryPointer); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'EPERM' || code === 'EACCES' || code === 'ENOTSUP') { + throw new Error('Unable to create the project-assets symbolic link; enable symbolic-link support or run in an environment that permits symlinks.', { cause: error }); + } + throw error; + } + await rename(temporaryPointer, pointerPath); + swapped = true; + await writeIndex(join(options.root, 'gallery/src/data/projects.generated.json'), `${JSON.stringify(records, null, 2)}\n`); + + // Cleanup occurs only after both published pointers are committed. It is deliberately + // best-effort: inability to remove an obsolete immutable directory must not roll back or + // corrupt the newly consistent index/asset pair. + try { + const entries = await readdir(publicPath, { withFileTypes: true }); + await Promise.allSettled(entries + .filter((entry) => entry.isDirectory() && entry.name.startsWith('.project-assets-') && entry.name !== versionName) + .map((entry) => rm(join(publicPath, entry.name), { recursive: true, force: true }))); + } catch { + // A later generation retries obsolete-version cleanup. + } + } catch (error) { + await rm(temporaryPointer, { force: true }); + if (swapped) { + if (oldTarget === undefined) { + await rm(pointerPath, { force: true }); + } else { + const rollbackPointer = `${temporaryPointer}-rollback`; + await rm(rollbackPointer, { force: true }); + await makeSymlink(oldTarget, rollbackPointer); + await rename(rollbackPointer, pointerPath); + } + } + if (createdVersion) await rm(versionPath, { recursive: true, force: true }); + throw error; + } + (options.write ?? console.log)(`Generated ${records.length} projects`); + return records; +} + +const invokedPath = process.argv[1] === undefined ? undefined : resolve(process.argv[1]); +if (invokedPath === fileURLToPath(import.meta.url)) { + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + await generateProjectIndex({ root }); +} diff --git a/tools/lib/readme.ts b/tools/lib/readme.ts new file mode 100644 index 0000000..d9302f3 --- /dev/null +++ b/tools/lib/readme.ts @@ -0,0 +1,163 @@ +import MarkdownIt from 'markdown-it'; +import sanitizeHtml from 'sanitize-html'; + +import Token from 'markdown-it/lib/token.mjs'; + +const markdown = new MarkdownIt({ html: false, linkify: false }); +markdown.renderer.rules.image = () => ''; +// Parse unsafe destinations as links so the sanitizer can discard the href while retaining +// the human-readable label. Otherwise markdown-it emits the entire dangerous source as text. +markdown.validateLink = () => true; +const htmlDetector = new MarkdownIt({ html: true, linkify: false }); +htmlDetector.validateLink = () => true; + +const paragraphChildren = (tokens: Token[]): Token[] | undefined => { + let index = 0; + if (tokens[index]?.type === 'heading_open' && tokens[index]?.tag === 'h1') index += 3; + for (; index < tokens.length; index += 1) { + if (tokens[index]?.type !== 'paragraph_open' || tokens[index]?.level !== 0) continue; + const inline = tokens[index + 1]; + if (inline?.type === 'inline' && inline.children) return inline.children; + } + return undefined; +}; + +const withoutRawHtml = (tokens: Token[]): Token[] => { + let blockedTag: 'script' | 'style' | undefined; + return tokens.filter((token) => { + if (token.type !== 'html_inline') return blockedTag === undefined; + const marker = token.content.trim().toLowerCase(); + if (blockedTag !== undefined) { + if (marker.startsWith(`', ' ', '\t', '\n'].includes(marker[7] ?? '>')) blockedTag = 'script'; + if (marker.startsWith('', ' ', '\t', '\n'].includes(marker[6] ?? '>')) blockedTag = 'style'; + return false; + }); +}; + +const visibleTokenText = (token: Token): string => { + if (token.type === 'text' || token.type === 'code_inline') return token.content; + if (token.type === 'softbreak' || token.type === 'hardbreak') return ' '; + return ''; +}; + +const normalizedInlineTokens = (tokens: Token[]): Token[] => { + let hasText = false; + let previousEndsInSpace = false; + const normalized = tokens.map((token) => { + const clone = new Token(token.type, token.tag, token.nesting); + Object.assign(clone, token); + if (token.type === 'image' || token.type === 'html_inline') { + clone.content = ''; + clone.type = 'text'; + return clone; + } + const text = visibleTokenText(token); + if (text === '') return clone; + let content = text.replace(/\s+/gu, ' '); + if (!hasText) content = content.trimStart(); + else if (previousEndsInSpace) content = content.trimStart(); + if (content !== '') { + clone.content = content; + hasText = true; + previousEndsInSpace = content.endsWith(' '); + } else { + clone.content = ''; + } + if (token.type === 'softbreak' || token.type === 'hardbreak') clone.type = 'text'; + return clone; + }); + for (let index = normalized.length - 1; index >= 0; index -= 1) { + const lastVisible = normalized[index]; + if (lastVisible && visibleTokenText(lastVisible) !== '') { + lastVisible.content = lastVisible.content.trimEnd(); + break; + } + } + return normalized; +}; + +export const truncateVisibleText = (value: string, maximum = 240): string => { + const points = Array.from(value); + if (points.length <= maximum) return value; + if (maximum <= 0) return ''; + if (maximum === 1) return '…'; + const candidate = points.slice(0, maximum - 1).join(''); + const whitespace = [...candidate.matchAll(/\s/gu)].at(-1); + const cut = whitespace === undefined ? candidate : candidate.slice(0, whitespace.index); + return `${cut.trimEnd()}…`; +}; + +const truncateTokens = (tokens: Token[], maximum: number): Token[] => { + const normalized = normalizedInlineTokens(tokens); + const fullText = normalized.map(visibleTokenText).join(''); + const wanted = truncateVisibleText(fullText, maximum); + if (wanted === fullText) return normalized; + const target = Array.from(wanted.slice(0, -1)); + let remaining = target.length; + let ellipsisWritten = false; + return normalized.map((token) => { + const clone = new Token(token.type, token.tag, token.nesting); + Object.assign(clone, token); + const text = visibleTokenText(token); + if (text === '') return clone; + const points = Array.from(text); + if (remaining <= 0) { + clone.content = ellipsisWritten ? '' : '…'; + clone.type = 'text'; + ellipsisWritten = true; + return clone; + } + const used = points.slice(0, remaining).join(''); + remaining -= Array.from(used).length; + clone.content = used + (remaining === 0 && !ellipsisWritten ? '…' : ''); + if (remaining === 0) ellipsisWritten = true; + return clone; + }); +}; + +const safeExcerptHtml = (html: string): string => sanitizeHtml(html, { + allowedTags: ['p', 'em', 'strong', 'code', 'a'], + allowedAttributes: { a: ['href', 'rel'] }, + allowedSchemes: ['http', 'https'], + allowedSchemesByTag: { a: ['http', 'https'] }, + allowProtocolRelative: false, + transformTags: { + a: (_tagName, attributes) => { + const href = attributes.href; + const external = href !== undefined && /^https?:\/\//iu.test(href); + const attribs: Record = {}; + if (href !== undefined) attribs.href = href; + if (external) attribs.rel = 'noreferrer'; + return { tagName: 'a', attribs }; + }, + }, +}); + +const escapeHtml = (value: string): string => value + .replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>') + .replaceAll('"', '"').replaceAll("'", '''); + +export const visibleText = (html: string): string => markdown.utils.unescapeAll( + sanitizeHtml(html, { allowedTags: [], allowedAttributes: {} }), +); + +export function extractExcerptHtml(markdownSource: string, fallback: string, maximum = 240): string { + const blockTokens = markdown.parse(markdownSource, {}); + const safeParagraph = paragraphChildren(blockTokens); + if (safeParagraph !== undefined) { + // A second tokenization only identifies raw HTML token boundaries. Rendering still uses the + // html:false parser/renderer, while script/style bodies and all raw tags are dropped structurally. + const detectedParagraph = paragraphChildren(htmlDetector.parse(markdownSource, {})); + if (detectedParagraph === undefined) return `

    ${escapeHtml(fallback)}

    `; + const sourceTokens = withoutRawHtml(detectedParagraph); + const retained = truncateTokens(sourceTokens, maximum); + if (retained.map(visibleTokenText).join('').trim() !== '') { + const rendered = `

    ${markdown.renderer.renderInline(retained, markdown.options, {})}

    `; + return safeExcerptHtml(rendered); + } + } + return `

    ${escapeHtml(fallback)}

    `; +} diff --git a/tools/project-links.ts b/tools/project-links.ts new file mode 100644 index 0000000..0472a2d --- /dev/null +++ b/tools/project-links.ts @@ -0,0 +1,26 @@ +import { posix } from 'node:path'; + +type RepositoryConfig = { owner: string; name: string; branch: string }; +type SourceProject = { slug: string; repository: './' | string }; + +const encodePath = (...parts: string[]): string => parts + .flatMap((part) => part.split('/')) + .filter(Boolean) + .map(encodeURIComponent) + .join('/'); + +const basePath = (base: string): string => { + const normalized = posix.normalize(`/${base}/`); + return normalized === '//' ? '/' : normalized; +}; + +export const projectUrl = (slug: string, base: string): string => + `${basePath(base)}projects/${encodeURIComponent(slug)}/`; + +export const coverUrl = (slug: string, cover: string | null, base: string): string | null => + cover === null ? null : `${basePath(base)}project-assets/${encodePath(slug, cover)}`; + +export const sourceUrl = (project: SourceProject, repository: RepositoryConfig): string => + project.repository === './' + ? `https://github.com/${encodeURIComponent(repository.owner)}/${encodeURIComponent(repository.name)}/tree/${encodeURIComponent(repository.branch)}/projects/${encodeURIComponent(project.slug)}` + : project.repository; From 6d7c7b3d2bbc8999ffee499fe6f7f7f4a101c3fd Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 12:47:00 +0800 Subject: [PATCH 14/40] fix: skip empty generated readme excerpts --- .../__tests__/generate-project-index.test.ts | 5 ++++ tools/lib/readme.ts | 30 ++++++++++++------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/tools/__tests__/generate-project-index.test.ts b/tools/__tests__/generate-project-index.test.ts index 04f85fb..bf07139 100644 --- a/tools/__tests__/generate-project-index.test.ts +++ b/tools/__tests__/generate-project-index.test.ts @@ -33,6 +33,11 @@ describe('README excerpts', () => { .toBe('First paragraph.'); }); + it('continues past a paragraph that is empty after images are removed', () => { + expect(visibleText(extractExcerptHtml('![x](a)\n\nReal paragraph.', 'Fallback'))) + .toBe('Real paragraph.'); + }); + it('falls back to an escaped summary when there is no paragraph', () => { expect(extractExcerptHtml('# Heading\n\n- only a list', 'fallback')).toBe('

    <b>fallback</b>

    '); }); diff --git a/tools/lib/readme.ts b/tools/lib/readme.ts index d9302f3..a2a4cfc 100644 --- a/tools/lib/readme.ts +++ b/tools/lib/readme.ts @@ -11,15 +11,20 @@ markdown.validateLink = () => true; const htmlDetector = new MarkdownIt({ html: true, linkify: false }); htmlDetector.validateLink = () => true; -const paragraphChildren = (tokens: Token[]): Token[] | undefined => { +type ParagraphTokens = { children: Token[]; sourceRange: string }; + +const paragraphs = (tokens: Token[]): ParagraphTokens[] => { let index = 0; + const found: ParagraphTokens[] = []; if (tokens[index]?.type === 'heading_open' && tokens[index]?.tag === 'h1') index += 3; for (; index < tokens.length; index += 1) { if (tokens[index]?.type !== 'paragraph_open' || tokens[index]?.level !== 0) continue; const inline = tokens[index + 1]; - if (inline?.type === 'inline' && inline.children) return inline.children; + if (inline?.type === 'inline' && inline.children) { + found.push({ children: inline.children, sourceRange: (inline.map ?? []).join(':') }); + } } - return undefined; + return found; }; const withoutRawHtml = (tokens: Token[]): Token[] => { @@ -145,14 +150,17 @@ export const visibleText = (html: string): string => markdown.utils.unescapeAll( ); export function extractExcerptHtml(markdownSource: string, fallback: string, maximum = 240): string { - const blockTokens = markdown.parse(markdownSource, {}); - const safeParagraph = paragraphChildren(blockTokens); - if (safeParagraph !== undefined) { - // A second tokenization only identifies raw HTML token boundaries. Rendering still uses the - // html:false parser/renderer, while script/style bodies and all raw tags are dropped structurally. - const detectedParagraph = paragraphChildren(htmlDetector.parse(markdownSource, {})); - if (detectedParagraph === undefined) return `

    ${escapeHtml(fallback)}

    `; - const sourceTokens = withoutRawHtml(detectedParagraph); + const safeParagraphs = paragraphs(markdown.parse(markdownSource, {})); + // A second tokenization only identifies raw HTML token boundaries. Source ranges align it with + // the html:false parse, while rendering remains on the html:false parser/renderer. + const detectedByRange = new Map( + paragraphs(htmlDetector.parse(markdownSource, {})) + .map((paragraph) => [paragraph.sourceRange, paragraph.children]), + ); + for (const safeParagraph of safeParagraphs) { + const detected = detectedByRange.get(safeParagraph.sourceRange); + if (detected === undefined) continue; + const sourceTokens = withoutRawHtml(detected); const retained = truncateTokens(sourceTokens, maximum); if (retained.map(visibleTokenText).join('').trim() !== '') { const rendered = `

    ${markdown.renderer.renderInline(retained, markdown.options, {})}

    `; From 7b59e25248e07b8bddeedc7b06fb07d3ca5318d1 Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 12:54:46 +0800 Subject: [PATCH 15/40] fix: publish verified immutable cover versions --- .../__tests__/generate-project-index.test.ts | 29 ++++- tools/generate-project-index.ts | 117 +++++++++++++++--- 2 files changed, 130 insertions(+), 16 deletions(-) diff --git a/tools/__tests__/generate-project-index.test.ts b/tools/__tests__/generate-project-index.test.ts index bf07139..7364035 100644 --- a/tools/__tests__/generate-project-index.test.ts +++ b/tools/__tests__/generate-project-index.test.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, readlink, stat, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, readlink, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; @@ -111,6 +111,33 @@ describe('project index generation', () => { await expect(readFile(join(publicPath, secondTarget, 'first/cover.png'), 'utf8')).resolves.toBe('changed'); }); + it('rebuilds an incomplete matching immutable version before publishing it', async () => { + const root = await makeRepository([{ dir: 'first', metadata: { ...validProject, slug: 'first', cover: 'cover.png' }, readme: 'One.', cover: 'cover.png' }]); + await generateProjectIndex({ root }); + const publicPath = join(root, 'gallery/public'); + const versionTarget = await readlink(join(publicPath, 'project-assets')); + await rm(join(publicPath, 'project-assets')); + await rm(join(publicPath, versionTarget, 'first/cover.png')); + + await generateProjectIndex({ root }); + + expect(await readlink(join(publicPath, 'project-assets'))).toBe(versionTarget); + await expect(readFile(join(publicPath, versionTarget, 'first/cover.png'), 'utf8')).resolves.toBe('image'); + }); + + it('rejects replacing an incomplete active version reached through an equivalent relative target', async () => { + const root = await makeRepository([{ dir: 'first', metadata: { ...validProject, slug: 'first', cover: 'cover.png' }, readme: 'One.', cover: 'cover.png' }]); + await generateProjectIndex({ root }); + const publicPath = join(root, 'gallery/public'); + const versionTarget = await readlink(join(publicPath, 'project-assets')); + await rm(join(publicPath, 'project-assets')); + await symlink(`./${versionTarget}`, join(publicPath, 'project-assets')); + await rm(join(publicPath, versionTarget, 'first/cover.png')); + + await expect(generateProjectIndex({ root })).rejects.toThrow(/active asset version.*incomplete/i); + expect(await readlink(join(publicPath, 'project-assets'))).toBe(`./${versionTarget}`); + }); + it('preserves the active version when copying a new cover fails', async () => { const root = await makeRepository([{ dir: 'first', metadata: { ...validProject, slug: 'first', cover: 'cover.png' }, readme: 'One.', cover: 'cover.png' }]); await generateProjectIndex({ root }); diff --git a/tools/generate-project-index.ts b/tools/generate-project-index.ts index 67ddf6c..0abe1d2 100644 --- a/tools/generate-project-index.ts +++ b/tools/generate-project-index.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { copyFile, lstat, mkdir, readFile, readdir, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises'; +import { copyFile, lstat, mkdir, mkdtemp, readFile, readdir, readlink, rename, rm, symlink, writeFile } from 'node:fs/promises'; import { dirname, join, relative, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -53,6 +53,55 @@ type GenerateOptions = { writeIndex?: (path: string, contents: string) => Promise; }; +type ExpectedCover = { source: string; destination: string; contents: Buffer }; + +const expectedDirectories = (covers: ExpectedCover[]): Set => { + const directories = new Set(['']); + for (const cover of covers) { + let directory = dirname(cover.destination); + while (directory !== '.') { + directories.add(directory); + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + } + return directories; +}; + +const versionIsComplete = async (versionPath: string, covers: ExpectedCover[]): Promise => { + const expectedFiles = new Map(covers.map((cover) => [cover.destination, cover.contents])); + const expectedDirs = expectedDirectories(covers); + const seenFiles = new Set(); + const seenDirs = new Set(['']); + const visit = async (directory: string): Promise => { + const entries = await readdir(join(versionPath, directory), { withFileTypes: true }); + for (const entry of entries) { + const relativePath = join(directory, entry.name); + if (entry.isDirectory()) { + if (!expectedDirs.has(relativePath)) return false; + seenDirs.add(relativePath); + if (!(await visit(relativePath))) return false; + } else if (entry.isFile()) { + const expected = expectedFiles.get(relativePath); + if (expected === undefined || !(await readFile(join(versionPath, relativePath))).equals(expected)) return false; + seenFiles.add(relativePath); + } else { + return false; + } + } + return true; + }; + try { + if (!(await lstat(versionPath)).isDirectory()) return false; + return await visit('') + && seenFiles.size === expectedFiles.size + && seenDirs.size === expectedDirs.size; + } catch { + return false; + } +}; + const atomicWrite = async (path: string, contents: string): Promise => { await mkdir(dirname(path), { recursive: true }); const temporary = `${path}.tmp-${process.pid}-${Date.now()}`; @@ -69,8 +118,18 @@ export async function generateProjectIndex(options: GenerateOptions): Promise = []; + const covers: ExpectedCover[] = []; for (const project of records) { if (project.cover === null) continue; const source = join(options.root, 'projects', project.slug, project.cover); @@ -81,6 +140,7 @@ export async function generateProjectIndex(options: GenerateOptions): Promise entry.isDirectory() && entry.name.startsWith('.project-assets-') && entry.name !== versionName) + .filter((entry) => entry.isDirectory() && /^\.project-assets-[0-9a-f]{16}$/u.test(entry.name) && entry.name !== versionName) .map((entry) => rm(join(publicPath, entry.name), { recursive: true, force: true }))); } catch { // A later generation retries obsolete-version cleanup. } } catch (error) { await rm(temporaryPointer, { force: true }); + await rm(stagingPath, { recursive: true, force: true }); if (swapped) { if (oldTarget === undefined) { await rm(pointerPath, { force: true }); @@ -148,6 +232,9 @@ export async function generateProjectIndex(options: GenerateOptions): Promise Date: Tue, 21 Jul 2026 12:58:08 +0800 Subject: [PATCH 16/40] fix: recover abandoned asset staging --- .../__tests__/generate-project-index.test.ts | 18 +++++++++++++++ tools/generate-project-index.ts | 22 ++++++++----------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/tools/__tests__/generate-project-index.test.ts b/tools/__tests__/generate-project-index.test.ts index 7364035..5aeaf26 100644 --- a/tools/__tests__/generate-project-index.test.ts +++ b/tools/__tests__/generate-project-index.test.ts @@ -96,6 +96,24 @@ describe('project index generation', () => { expect(lines).toEqual(['Generated 0 projects']); }); + it('cleans a legacy lock and abandoned known staging directory without touching similar names', async () => { + const root = await makeRepository(); + const publicPath = join(root, 'gallery/public'); + const legacyLock = join(publicPath, '.project-assets-generate.lock'); + const abandonedStage = join(publicPath, '.project-assets-stage-abc123'); + const unrelated = join(publicPath, '.project-assets-stage-important'); + await mkdir(legacyLock, { recursive: true }); + await mkdir(abandonedStage); + await writeFile(join(abandonedStage, 'partial'), 'partial'); + await mkdir(unrelated); + + await generateProjectIndex({ root }); + + await expect(stat(legacyLock)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(stat(abandonedStage)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(stat(unrelated)).resolves.toMatchObject({ isDirectory: expect.any(Function) }); + }); + it('removes stale immutable cover versions after a successful swap', async () => { const root = await makeRepository([{ dir: 'first', metadata: { ...validProject, slug: 'first', cover: 'cover.png' }, readme: 'One.', cover: 'cover.png' }]); await generateProjectIndex({ root }); diff --git a/tools/generate-project-index.ts b/tools/generate-project-index.ts index 0abe1d2..05d44f1 100644 --- a/tools/generate-project-index.ts +++ b/tools/generate-project-index.ts @@ -118,16 +118,15 @@ export async function generateProjectIndex(options: GenerateOptions): Promise ( + entry.name === '.project-assets-generate.lock' + || (entry.isDirectory() && /^\.project-assets-stage-[A-Za-z0-9]{6}$/u.test(entry.name)) + )) + .map((entry) => rm(join(publicPath, entry.name), { recursive: true, force: true }))); const hash = createHash('sha256'); const covers: ExpectedCover[] = []; for (const project of records) { @@ -232,9 +231,6 @@ export async function generateProjectIndex(options: GenerateOptions): Promise Date: Tue, 21 Jul 2026 13:07:00 +0800 Subject: [PATCH 17/40] feat: scaffold projects from safe templates --- templates/blank/README.md.tpl | 13 ++ templates/script/README.md.tpl | 13 ++ templates/script/package.json.tpl | 8 + templates/script/src/index.mjs.tpl | 1 + templates/web/README.md.tpl | 13 ++ templates/web/index.html.tpl | 12 ++ templates/web/package.json.tpl | 6 + templates/web/src/main.js.tpl | 3 + tools/__tests__/create-project.test.ts | 201 +++++++++++++++++++++++++ tools/create-project.ts | 137 +++++++++++++++++ tools/lib/templates.ts | 62 ++++++++ 11 files changed, 469 insertions(+) create mode 100644 templates/blank/README.md.tpl create mode 100644 templates/script/README.md.tpl create mode 100644 templates/script/package.json.tpl create mode 100644 templates/script/src/index.mjs.tpl create mode 100644 templates/web/README.md.tpl create mode 100644 templates/web/index.html.tpl create mode 100644 templates/web/package.json.tpl create mode 100644 templates/web/src/main.js.tpl create mode 100644 tools/__tests__/create-project.test.ts create mode 100644 tools/create-project.ts create mode 100644 tools/lib/templates.ts diff --git a/templates/blank/README.md.tpl b/templates/blank/README.md.tpl new file mode 100644 index 0000000..8b280ea --- /dev/null +++ b/templates/blank/README.md.tpl @@ -0,0 +1,13 @@ +# {{TITLE}} + +## Purpose + +{{SUMMARY}} + +## Development + +{{DEVELOPMENT}} + +## Status + +Idea created on {{CREATED_AT}}. diff --git a/templates/script/README.md.tpl b/templates/script/README.md.tpl new file mode 100644 index 0000000..8b280ea --- /dev/null +++ b/templates/script/README.md.tpl @@ -0,0 +1,13 @@ +# {{TITLE}} + +## Purpose + +{{SUMMARY}} + +## Development + +{{DEVELOPMENT}} + +## Status + +Idea created on {{CREATED_AT}}. diff --git a/templates/script/package.json.tpl b/templates/script/package.json.tpl new file mode 100644 index 0000000..b317bdc --- /dev/null +++ b/templates/script/package.json.tpl @@ -0,0 +1,8 @@ +{ + "private": true, + "scripts": { + "start": "node src/index.mjs" + }, + "dependencies": {}, + "devDependencies": {} +} diff --git a/templates/script/src/index.mjs.tpl b/templates/script/src/index.mjs.tpl new file mode 100644 index 0000000..50349e9 --- /dev/null +++ b/templates/script/src/index.mjs.tpl @@ -0,0 +1 @@ +console.log({{TITLE_JSON}}); diff --git a/templates/web/README.md.tpl b/templates/web/README.md.tpl new file mode 100644 index 0000000..8b280ea --- /dev/null +++ b/templates/web/README.md.tpl @@ -0,0 +1,13 @@ +# {{TITLE}} + +## Purpose + +{{SUMMARY}} + +## Development + +{{DEVELOPMENT}} + +## Status + +Idea created on {{CREATED_AT}}. diff --git a/templates/web/index.html.tpl b/templates/web/index.html.tpl new file mode 100644 index 0000000..e00218f --- /dev/null +++ b/templates/web/index.html.tpl @@ -0,0 +1,12 @@ + + + + + + {{TITLE_HTML}} + + +
    + + + diff --git a/templates/web/package.json.tpl b/templates/web/package.json.tpl new file mode 100644 index 0000000..ac476f0 --- /dev/null +++ b/templates/web/package.json.tpl @@ -0,0 +1,6 @@ +{ + "private": true, + "scripts": {}, + "dependencies": {}, + "devDependencies": {} +} diff --git a/templates/web/src/main.js.tpl b/templates/web/src/main.js.tpl new file mode 100644 index 0000000..c3b450c --- /dev/null +++ b/templates/web/src/main.js.tpl @@ -0,0 +1,3 @@ +const app = document.querySelector('#app'); + +if (app) app.textContent = {{TITLE_JSON}}; diff --git a/tools/__tests__/create-project.test.ts b/tools/__tests__/create-project.test.ts new file mode 100644 index 0000000..bdb8216 --- /dev/null +++ b/tools/__tests__/create-project.test.ts @@ -0,0 +1,201 @@ +import { mkdir, readFile, readdir, stat, symlink, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const validationMock = vi.hoisted(() => ({ validateProjectAt: vi.fn() })); + +vi.mock('../validate-projects.js', async () => { + const actual = await vi.importActual('../validate-projects.js'); + validationMock.validateProjectAt.mockImplementation(actual.validateProjectAt); + return { ...actual, validateProjectAt: validationMock.validateProjectAt }; +}); + +import { createProject, deriveSlug, renameNoReplace } from '../create-project.js'; +import { validateProjects } from '../validate-projects.js'; +import { cleanupRepositories, makeRepository } from './fixtures.js'; + +afterEach(async () => { + validationMock.validateProjectAt.mockClear(); + await cleanupRepositories(); +}); + +const input = (root: string, template: 'blank' | 'web' | 'script' = 'blank') => ({ + root, + title: 'My Idea', + slug: 'my-idea', + type: 'other' as const, + summary: 'Try it.', + template, + today: '2026-07-21', +}); + +describe.each(['blank', 'web', 'script'] as const)('%s template', (template) => { + it('creates a valid, fully rendered project without executable files or dependencies', async () => { + const root = await makeRepository(); + const result = await createProject(input(root, template)); + + expect(result.path).toBe(join(root, 'projects', 'my-idea')); + await expect(validateProjects({ root, only: 'my-idea' })).resolves.toMatchObject({ ok: true }); + const readme = await readFile(join(result.path, 'README.md'), 'utf8'); + expect(readme).toBe('# My Idea\n\n## Purpose\n\nTry it.\n\n## Development\n\n' + ({ + blank: 'Document the chosen stack and local development commands here.', + web: 'Open `index.html` in a browser.', + script: 'Run `node src/index.mjs`.', + })[template] + '\n\n## Status\n\nIdea created on 2026-07-21.\n'); + expect(readme).not.toContain('{{'); + + for (const entry of await readdir(result.path, { recursive: true, withFileTypes: true })) { + if (entry.isFile()) expect((await stat(join(entry.parentPath, entry.name))).mode & 0o111).toBe(0); + } + if (template !== 'blank') { + const packageJson = JSON.parse(await readFile(join(result.path, 'package.json'), 'utf8')); + expect(packageJson.dependencies ?? {}).toEqual({}); + expect(packageJson.devDependencies ?? {}).toEqual({}); + } + }); +}); + +it('derives an ASCII kebab slug and rejects invalid explicit slugs', async () => { + expect(deriveSlug('Crème & Search')).toBe('creme-search'); + const root = await makeRepository(); + const created = await createProject({ ...input(root), slug: undefined, title: 'Crème & Search' }); + expect(created.metadata.slug).toBe('creme-search'); + await expect(createProject({ ...input(root), slug: '../escape' })).rejects.toThrow(/slug/i); +}); + +it('validates the physical staging path against the intended slug', async () => { + const root = await makeRepository(); + await createProject(input(root)); + + expect(validationMock.validateProjectAt).toHaveBeenCalledWith({ + projectPath: expect.stringMatching(/\/projects\/\.my-idea\.stage-[^/]+$/), + expectedDirectoryName: 'my-idea', + }); +}); + +it('leaves no project or staging directory when validation fails', async () => { + const root = await makeRepository(); + validationMock.validateProjectAt.mockResolvedValueOnce({ + ok: false, + errors: [{ project: 'my-idea', field: 'README.md', message: 'broken', fix: 'fix it' }], + }); + + await expect(createProject(input(root))).rejects.toThrow(/failed validation/i); + await expect(readdir(join(root, 'projects'))).resolves.toEqual([]); +}); + +it('does not overwrite an existing destination', async () => { + const root = await makeRepository([{ dir: 'my-idea', readme: 'sentinel' }]); + await expect(createProject(input(root))).rejects.toThrow(/exists|conflict/i); + await expect(readFile(join(root, 'projects/my-idea/README.md'), 'utf8')).resolves.toBe('sentinel'); +}); + +it('rejects symlinks and unknown or leftover placeholders and cleans staging', async () => { + for (const kind of ['symlink', 'unknown', 'leftover'] as const) { + const root = await makeRepository(); + const templateRoot = join(root, 'bad-templates'); + await mkdir(join(templateRoot, 'blank'), { recursive: true }); + await writeFile(join(templateRoot, 'blank/README.md.tpl'), '# {{TITLE}}\n{{SUMMARY}}\n{{DEVELOPMENT}}\n{{CREATED_AT}}'); + if (kind === 'symlink') await symlink('/etc/passwd', join(templateRoot, 'blank/escape.tpl')); + else await writeFile(join(templateRoot, 'blank/bad.tpl'), kind === 'unknown' ? '{{EVIL}}' : '{{broken'); + + await expect(createProject({ ...input(root), templateRoot })).rejects.toThrow(); + await expect(readdir(join(root, 'projects'))).resolves.toEqual([]); + } +}); + +it('rejects a symlink used as the selected template directory', async () => { + const root = await makeRepository(); + const templateRoot = join(root, 'bad-templates'); + const outside = join(root, 'outside'); + await mkdir(templateRoot); + await mkdir(outside); + await writeFile(join(outside, 'README.md.tpl'), '# {{TITLE}}'); + await symlink(outside, join(templateRoot, 'blank')); + + await expect(createProject({ ...input(root), templateRoot })).rejects.toThrow(/symlink/i); + await expect(readdir(join(root, 'projects'))).resolves.toEqual([]); +}); + +it('escapes titles for generated JavaScript and HTML contexts', async () => { + const title = `Bob's \nidea`; + for (const template of ['web', 'script'] as const) { + const root = await makeRepository(); + const result = await createProject({ ...input(root, template), title }); + const scriptPath = join(result.path, template === 'web' ? 'src/main.js' : 'src/index.mjs'); + const script = await readFile(scriptPath, 'utf8'); + expect(script).toContain(JSON.stringify(title)); + expect(script).not.toContain(`'${title}'`); + if (template === 'web') { + const html = await readFile(join(result.path, 'index.html'), 'utf8'); + expect(html).toContain("Bob's </title><script>alert(1)</script>"); + expect(html).not.toContain('\nidea`; for (const template of ['web', 'script'] as const) { @@ -160,7 +188,7 @@ it('allows only one concurrent creator to publish and never replaces the winner' expect((await readdir(join(root, 'projects'))).filter((name) => name.includes('.stage-') || name.includes('.lock'))).toEqual([]); }); -it('rejects a destination created after locking and preserves its contents', async () => { +it('atomically rejects a destination created immediately before publication and preserves it', async () => { const root = await makeRepository(); const staging = join(root, 'projects/.my-idea.stage-test'); const final = join(root, 'projects/my-idea'); @@ -168,7 +196,7 @@ it('rejects a destination created after locking and preserves its contents', asy await writeFile(join(staging, 'winner'), 'staged'); await expect(renameNoReplace(staging, final, { - afterLock: async () => { + beforeRename: async () => { await mkdir(final); await writeFile(join(final, 'sentinel'), 'do not replace'); }, @@ -177,23 +205,49 @@ it('rejects a destination created after locking and preserves its contents', asy expect((await readdir(join(root, 'projects'))).filter((name) => name.includes('.lock'))).toEqual([]); }); -it('holds the exclusive slug lock across full creators so a second creator cannot replace the winner', async () => { +it('ignores legacy stale creator locks because publication no longer relies on locks', async () => { + const root = await makeRepository(); + await writeFile(join(root, 'projects/.create-my-idea.lock'), 'stale owner'); + await expect(createProject(input(root))).resolves.toMatchObject({ metadata: { slug: 'my-idea' } }); +}); + +it('allows exactly one full creator to win atomic publication without replacement', async () => { const root = await makeRepository(); let release!: () => void; const gate = new Promise((resolve) => { release = resolve; }); let locked!: () => void; const hasLocked = new Promise((resolve) => { locked = resolve; }); - const winner = createProject( - { ...input(root), summary: 'first winner' }, - { afterPublicationLock: async () => { locked(); await gate; } }, + const delayed = createProject( + { ...input(root), summary: 'delayed loser' }, + { beforeAtomicPublication: async () => { locked(); await gate; } }, ); await hasLocked; - await expect(createProject({ ...input(root), summary: 'second loser' })).rejects.toThrow(/being created/i); + await createProject({ ...input(root), summary: 'atomic winner' }); release(); - await winner; + await expect(delayed).rejects.toThrow(/already exists/i); const final = join(root, 'projects/my-idea'); const metadata = JSON.parse(await readFile(join(final, 'project.json'), 'utf8')); - expect(metadata.summary).toBe('first winner'); + expect(metadata.summary).toBe('atomic winner'); expect((await readdir(join(root, 'projects'))).filter((name) => name.includes('.stage-') || name.includes('.lock'))).toEqual([]); }); + +it.each([ + ['blank', 'Next: cd projects/my-idea and document your development setup.'], + ['web', 'Next: cd projects/my-idea and open index.html in a browser.'], + ['script', 'Next: cd projects/my-idea && node src/index.mjs'], +] as const)('prints the %s template next step through the injected writer', async (template, expected) => { + const root = await makeRepository(); + const lines: string[] = []; + await runInteractiveCreator(root, { + promptTitle: async () => 'My Idea', + promptType: async () => 'other', + promptTemplate: async () => template, + promptSummary: async () => 'Try it.', + confirmSlug: async () => true, + promptSlug: async () => { throw new Error('unexpected slug prompt'); }, + write: (line) => lines.push(line), + writeError: (line) => lines.push(`ERROR: ${line}`), + }); + expect(lines).toEqual(['Created projects/my-idea', expected]); +}); diff --git a/tools/create-project.ts b/tools/create-project.ts index c81e560..47e9222 100644 --- a/tools/create-project.ts +++ b/tools/create-project.ts @@ -1,11 +1,12 @@ import { confirm, input, select } from '@inquirer/prompts'; -import { lstat, mkdir, mkdtemp, open, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'; -import { dirname, join, resolve } from 'node:path'; +import { mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ProjectMetadataSchema, type ProjectMetadata } from './project-schema.js'; import { validateProjectAt } from './validate-projects.js'; import { copyRenderedTemplate, type TemplateName } from './lib/templates.js'; +import { atomicRenameNoReplace } from './lib/rename-noreplace.js'; export type CreateProjectInput = { root: string; @@ -15,12 +16,24 @@ export type CreateProjectInput = { summary: string; template: TemplateName; today?: string; + /** Canonical, owner-controlled template tree that remains immutable during this call. */ templateRoot?: string; }; export type CreateProjectDependencies = { - /** Test/observability seam invoked while this creator exclusively owns the slug lock. */ - afterPublicationLock?: () => Promise; + /** Test/observability seam invoked immediately before the atomic publication syscall. */ + beforeAtomicPublication?: () => Promise; +}; + +export type InteractiveCreatorDependencies = { + promptTitle: () => Promise; + promptType: () => Promise; + promptTemplate: () => Promise; + promptSummary: () => Promise; + confirmSlug: (slug: string) => Promise; + promptSlug: (message: string, suggested: string) => Promise; + write: (line: string) => void; + writeError: (line: string) => void; }; const slugPattern = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; @@ -42,30 +55,13 @@ async function exists(path: string): Promise { export async function renameNoReplace( staging: string, final: string, - hooks: { afterLock?: () => Promise } = {}, + hooks: { beforeRename?: () => Promise } = {}, ): Promise { - const slug = final.slice(final.lastIndexOf('/') + 1); - const lockPath = join(dirname(final), `.create-${slug}.lock`); - let lock: Awaited> | undefined; - try { - try { lock = await open(lockPath, 'wx', 0o600); } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw conflict(slug); - throw error; - } - await hooks.afterLock?.(); - if (await exists(final)) throw conflict(slug); - await rename(staging, final); - } finally { - if (lock !== undefined) { - const ownedLock = await lock.stat(); - await lock.close(); - try { - const currentLock = await lstat(lockPath); - if (currentLock.dev === ownedLock.dev && currentLock.ino === ownedLock.ino) await unlink(lockPath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - } - } + const slug = basename(final); + await hooks.beforeRename?.(); + try { await atomicRenameNoReplace(staging, final); } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') throw conflict(slug); + throw error; } } @@ -110,32 +106,52 @@ export async function createProject( await writeFile(join(staging, 'project.json'), `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o644, flag: 'wx' }); const validation = await validateProjectAt({ projectPath: staging, expectedDirectoryName: slug }); if (!validation.ok) throw new Error(`Created project failed validation: ${validation.errors.map((error) => error.message).join('; ')}`); - await renameNoReplace(staging, final, { afterLock: dependencies.afterPublicationLock }); + await renameNoReplace(staging, final, { beforeRename: dependencies.beforeAtomicPublication }); return { path: final, metadata }; } finally { await rm(staging, { recursive: true, force: true }); } } -export async function runInteractiveCreator(root = resolve(dirname(fileURLToPath(import.meta.url)), '..')): Promise { - const title = await input({ message: 'Project title:' }); - const type = await select({ message: 'Project type:', choices: ['web', 'service', 'cli', 'ai', 'script', 'other'].map((value) => ({ name: value, value: value as ProjectMetadata['type'] })) }); - const template = await select({ message: 'Template:', choices: ['blank', 'web', 'script'].map((value) => ({ name: value, value: value as TemplateName })) }); - const summary = await input({ message: 'Summary:' }); +const nextStep = (template: TemplateName, relativePath: string): string => ({ + blank: `Next: cd ${relativePath} and document your development setup.`, + web: `Next: cd ${relativePath} and open index.html in a browser.`, + script: `Next: cd ${relativePath} && node src/index.mjs`, +})[template]; + +const defaultInteractiveDependencies = (): InteractiveCreatorDependencies => ({ + promptTitle: () => input({ message: 'Project title:' }), + promptType: () => select({ message: 'Project type:', choices: ['web', 'service', 'cli', 'ai', 'script', 'other'].map((value) => ({ name: value, value: value as ProjectMetadata['type'] })) }), + promptTemplate: () => select({ message: 'Template:', choices: ['blank', 'web', 'script'].map((value) => ({ name: value, value: value as TemplateName })) }), + promptSummary: () => input({ message: 'Summary:' }), + confirmSlug: (slug) => confirm({ message: `Use slug "${slug}"?`, default: true }), + promptSlug: (message, suggested) => input({ message, default: suggested }), + write: (line) => console.log(line), + writeError: (line) => console.error(line), +}); + +export async function runInteractiveCreator( + root = resolve(dirname(fileURLToPath(import.meta.url)), '..'), + dependencies: InteractiveCreatorDependencies = defaultInteractiveDependencies(), +): Promise { + const title = await dependencies.promptTitle(); + const type = await dependencies.promptType(); + const template = await dependencies.promptTemplate(); + const summary = await dependencies.promptSummary(); let suggestedSlug = deriveSlug(title); for (;;) { - const useSuggested = await confirm({ message: `Use slug "${suggestedSlug}"?`, default: true }); - const slug = useSuggested ? suggestedSlug : await input({ message: 'Slug:', default: suggestedSlug }); + const useSuggested = await dependencies.confirmSlug(suggestedSlug); + const slug = useSuggested ? suggestedSlug : await dependencies.promptSlug('Slug:', suggestedSlug); try { const result = await createProject({ root, title, type, template, summary, slug }); const relativePath = `projects/${result.metadata.slug}`; - console.log(`Created ${relativePath}`); - console.log(`Next: cd ${relativePath}`); + dependencies.write(`Created ${relativePath}`); + dependencies.write(nextStep(template, relativePath)); return; } catch (error) { if (!(error instanceof Error) || !/already exists|being created|slug/i.test(error.message)) throw error; - console.error(error.message); - suggestedSlug = await input({ message: 'Choose another slug:', default: suggestedSlug }); + dependencies.writeError(error.message); + suggestedSlug = await dependencies.promptSlug('Choose another slug:', suggestedSlug); } } } diff --git a/tools/lib/rename-noreplace.ts b/tools/lib/rename-noreplace.ts new file mode 100644 index 0000000..f7b8a1c --- /dev/null +++ b/tools/lib/rename-noreplace.ts @@ -0,0 +1,73 @@ +import { spawn } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { chmod, link, lstat, mkdir, mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const sourcePath = join(dirname(fileURLToPath(import.meta.url)), '..', 'native', 'rename-noreplace.c'); +let helperPromise: Promise | undefined; + +const run = (command: string, args: string[]): Promise<{ code: number; stderr: string }> => + new Promise((resolve, reject) => { + const child = spawn(command, args, { shell: false, stdio: ['ignore', 'ignore', 'pipe'] }); + let stderr = ''; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + child.on('error', reject); + child.on('close', (code) => resolve({ code: code ?? 1, stderr })); + }); + +async function compiledHelper(): Promise { + if (process.platform !== 'linux' && process.platform !== 'darwin') { + throw new Error(`Atomic project publication is unsupported on ${process.platform}; use Linux or macOS`); + } + const source = await readFile(sourcePath); + const key = createHash('sha256').update(source).update(process.platform).update(process.arch).digest('hex').slice(0, 20); + const uid = typeof process.getuid === 'function' ? process.getuid() : 'user'; + const cache = join(tmpdir(), `faust-native-${uid}`); + await mkdir(cache, { recursive: true, mode: 0o700 }); + const cacheStat = await lstat(cache); + if (!cacheStat.isDirectory() || (cacheStat.mode & 0o077) !== 0 || (typeof uid === 'number' && cacheStat.uid !== uid)) { + throw new Error(`Native helper cache is not private and trusted: ${cache}`); + } + const executable = join(cache, `rename-noreplace-${key}`); + try { + const existing = await lstat(executable); + if (!existing.isFile() || existing.uid !== cacheStat.uid) throw new Error('Cached native helper is not a trusted regular file'); + return executable; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + const build = await mkdtemp(join(cache, '.build-')); + const candidate = join(build, 'rename-noreplace'); + try { + const compiler = '/usr/bin/cc'; + const result = await run(compiler, ['-std=c11', '-O2', sourcePath, '-o', candidate]); + if (result.code !== 0) { + throw new Error(`Atomic publication helper could not be compiled with ${compiler}; install a C compiler. ${result.stderr.trim()}`); + } + await chmod(candidate, 0o700); + try { await link(candidate, executable); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + return executable; + } finally { + await rm(build, { recursive: true, force: true }); + } +} + +export async function atomicRenameNoReplace(source: string, destination: string): Promise { + helperPromise ??= compiledHelper(); + const helper = await helperPromise; + const result = await run(helper, [source, destination]); + if (result.code === 0) return; + if (result.code === 3) { + const error = new Error(`Destination already exists: ${destination}`) as NodeJS.ErrnoException; + error.code = 'EEXIST'; + throw error; + } + if (result.code === 4) throw new Error(`This ${process.platform} kernel does not support atomic no-replace rename`); + throw new Error(`Atomic project publication failed: ${result.stderr.trim() || `helper exited ${result.code}`}`); +} diff --git a/tools/lib/templates.ts b/tools/lib/templates.ts index 751418e..26954f7 100644 --- a/tools/lib/templates.ts +++ b/tools/lib/templates.ts @@ -1,4 +1,5 @@ -import { lstat, mkdir, readFile, readdir, writeFile } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { lstat, mkdir, open, readdir, realpath, writeFile } from 'node:fs/promises'; import { dirname, isAbsolute, relative, resolve, sep } from 'node:path'; export type TemplateName = 'blank' | 'web' | 'script'; @@ -22,21 +23,39 @@ export function renderTemplate(source: string, values: Readonly>; + beforeOpenFile?: (source: string) => Promise; }): Promise { - const templateRoot = resolve(options.templateRoot); + const requestedTemplateRoot = resolve(options.templateRoot); + const requestedRootStat = await lstat(requestedTemplateRoot); + if (requestedRootStat.isSymbolicLink()) throw new Error('Template root must not be a symlink'); + const templateRoot = await realpath(requestedTemplateRoot); const sourceRoot = resolve(templateRoot, options.template); const destinationRoot = resolve(options.destination); if (!isWithin(templateRoot, sourceRoot)) throw new Error('Template path escapes template root'); - const sourceRootStat = await lstat(sourceRoot); - if (sourceRootStat.isSymbolicLink() || !sourceRootStat.isDirectory()) { - throw new Error('Selected template must be a real directory, not a symlink'); - } + + const assertTrustedDirectory = async (directory: string): Promise => { + const directoryStat = await lstat(directory); + if (directoryStat.isSymbolicLink() || !directoryStat.isDirectory()) { + throw new Error('Template directories must be real directories, not symlinks'); + } + if (await realpath(directory) !== directory) throw new Error('Template directories must not be symlinks'); + if (typeof process.getuid === 'function' && directoryStat.uid !== process.getuid()) { + throw new Error('Template directories must be owned by the current user'); + } + if ((directoryStat.mode & 0o022) !== 0) { + throw new Error('Template directories must not be writable by group or other users'); + } + }; + await assertTrustedDirectory(templateRoot); + await assertTrustedDirectory(sourceRoot); const copyDirectory = async (sourceDirectory: string): Promise => { + await assertTrustedDirectory(sourceDirectory); const entries = await readdir(sourceDirectory, { withFileTypes: true }); for (const entry of entries) { const source = resolve(sourceDirectory, entry.name); @@ -53,7 +72,23 @@ export async function copyRenderedTemplate(options: { const destination = resolve(destinationRoot, relativeSource.slice(0, -4)); if (!isWithin(destinationRoot, destination)) throw new Error('Template destination traversal rejected'); await mkdir(dirname(destination), { recursive: true }); - const rendered = renderTemplate(await readFile(source, 'utf8'), options.values); + await options.beforeOpenFile?.(source); + const handle = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW); + let templateSource: string; + try { + const opened = await handle.stat(); + if (!opened.isFile()) throw new Error(`Template entry is not a regular file: ${entry.name}`); + if (typeof process.getuid === 'function' && opened.uid !== process.getuid()) { + throw new Error(`Template file must be owned by the current user: ${entry.name}`); + } + if ((opened.mode & 0o022) !== 0) { + throw new Error(`Template file must not be writable by group or other users: ${entry.name}`); + } + templateSource = await handle.readFile('utf8'); + } finally { + await handle.close(); + } + const rendered = renderTemplate(templateSource, options.values); await writeFile(destination, rendered, { mode: 0o644, flag: 'wx' }); } }; diff --git a/tools/native/rename-noreplace.c b/tools/native/rename-noreplace.c new file mode 100644 index 0000000..4c7f87c --- /dev/null +++ b/tools/native/rename-noreplace.c @@ -0,0 +1,35 @@ +#if defined(__linux__) +#define _GNU_SOURCE +#endif + +#include +#include + +#if defined(__linux__) +#include +#include +#include +#elif defined(__APPLE__) +#include +#else +#error "rename-noreplace supports only Linux and macOS" +#endif + +int main(int argc, char **argv) { + if (argc != 3) { + fprintf(stderr, "usage: rename-noreplace SOURCE DESTINATION\n"); + return 2; + } + +#if defined(__linux__) + int result = (int)syscall(SYS_renameat2, AT_FDCWD, argv[1], AT_FDCWD, argv[2], RENAME_NOREPLACE); +#elif defined(__APPLE__) + int result = renamex_np(argv[1], argv[2], RENAME_EXCL); +#endif + + if (result == 0) return 0; + if (errno == EEXIST || errno == ENOTEMPTY) return 3; + if (errno == ENOSYS || errno == ENOTSUP) return 4; + perror("atomic no-replace rename"); + return 1; +} From a9de121915199ed04af0631b5e4b955eaa7b0ab4 Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 13:24:24 +0800 Subject: [PATCH 20/40] docs: require atomic project publication --- .../2026-07-21-personal-gallery-implementation.md | 12 +++++++++--- .../specs/2026-07-21-personal-gallery-design.md | 2 ++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/plans/2026-07-21-personal-gallery-implementation.md b/docs/superpowers/plans/2026-07-21-personal-gallery-implementation.md index 62d2deb..a44687f 100644 --- a/docs/superpowers/plans/2026-07-21-personal-gallery-implementation.md +++ b/docs/superpowers/plans/2026-07-21-personal-gallery-implementation.md @@ -37,6 +37,8 @@ - Create `tools/project-links.ts` for base-aware gallery, source, demo, and cover URLs. - Create `tools/list-projects.ts` for the maintenance table command. - Create `tools/create-project.ts` for prompt orchestration, safe staging, template rendering, validation, and atomic rename. +- Create `tools/lib/rename-noreplace.ts` for compiling and invoking the platform no-replace rename helper. +- Create `tools/native/rename-noreplace.c` for audited Linux/macOS atomic directory publication. - Create `tools/lib/files.ts` for project discovery, safe paths, and atomic directory helpers. - Create `tools/lib/readme.ts` for safe first-paragraph extraction. - Create `tools/lib/templates.ts` for explicit placeholder replacement. @@ -626,6 +628,8 @@ git commit -m "feat: generate deterministic gallery project data" - Create: `templates/script/package.json.tpl` - Create: `templates/script/src/index.mjs.tpl` - Create: `tools/lib/templates.ts` +- Create: `tools/lib/rename-noreplace.ts` +- Create: `tools/native/rename-noreplace.c` - Create: `tools/create-project.ts` - Create: `tools/__tests__/create-project.test.ts` @@ -649,7 +653,7 @@ it('leaves no project or staging directory when validation fails', async () => { ``` Add cases for slug derivation (`Crème & Search` -> `creme-search`), explicit invalid slug, existing-directory conflict, and template traversal/unknown placeholders. -Add a creator integration case where the random staging basename differs from the intended slug and spy on the Task 4 API to verify `createProject` calls `validateProjectAt({ projectPath: , expectedDirectoryName: 'my-idea' })`. Add a race test that creates the final destination immediately before publication and asserts the creator fails without overwriting it. +Add a creator integration case where the random staging basename differs from the intended slug and spy on the Task 4 API to verify `createProject` calls `validateProjectAt({ projectPath: , expectedDirectoryName: 'my-idea' })`. Add a deterministic race test that creates the final destination immediately before the no-replace syscall and asserts the creator fails without overwriting it. Add a concurrent full-creator test that asserts exactly one final project is published. - [ ] **Step 2: Run the creator suite and verify red** @@ -689,11 +693,13 @@ export type CreateProjectInput = { export async function createProject(input: CreateProjectInput): Promise<{ path: string; metadata: ProjectMetadata }>; ``` -Import the already committed `validateProjectAt` from `tools/validate-projects.ts`; Task 6 must not alter `tools/validate-projects.ts`, `tools/lib/files.ts`, or their Task 4 tests. Copy only regular files beneath the selected template, reject symlinks and destination traversal, replace only known `{{UPPER_SNAKE_CASE}}` placeholders, and fail if any placeholder remains. Create a sibling staging directory using `mkdtemp(join(projectsDir, `.${slug}.stage-`))`, write `project.json`, then call `validateProjectAt({ projectPath: staging, expectedDirectoryName: slug })`; slug-directory validation therefore uses the intended final basename, while cover/README checks use the physical staging path. Implement and test one `renameNoReplace(staging, final)` helper as the publication boundary: acquire `projects/.create-.lock` with exclusive `open(..., 'wx')`, reject if `final` exists, perform the same-filesystem directory `rename`, and release only this invocation's lock in `finally`. All creator calls must honor the lock, so publication is a single atomic rename and concurrent creators cannot reach an overwriting rename; `EEXIST` from either lock or destination is a conflict. Do not use copy-then-delete and do not remove/recreate the destination. In the race test, pause the first creator after locking, start a second creator, and assert exactly one final directory is published and its contents are never replaced. In `finally`, remove only the resolved staging directory and owned lock; never delete or replace an existing destination. +Import the already committed `validateProjectAt` from `tools/validate-projects.ts`; Task 6 must not alter `tools/validate-projects.ts`, `tools/lib/files.ts`, or their Task 4 tests. Copy only regular files beneath the selected trusted, immutable template root, reject directory symlinks and destination traversal, read files through no-follow descriptors, replace only known `{{UPPER_SNAKE_CASE}}` placeholders with context-safe values, and fail if any placeholder remains. Create a sibling staging directory using `mkdtemp(join(projectsDir, `.${slug}.stage-`))`, write `project.json`, then call `validateProjectAt({ projectPath: staging, expectedDirectoryName: slug })`; slug-directory validation therefore uses the intended final basename, while cover/README checks use the physical staging path. + +Implement and test `renameNoReplace(staging, final)` as the publication boundary using a small audited native helper committed as C source. On Linux it calls `renameat2(..., RENAME_NOREPLACE)`; on macOS it calls `renamex_np(..., RENAME_EXCL)`. Compile the helper on demand with `/usr/bin/cc` into a private temporary cache keyed by source, platform, and architecture; invoke compiler and helper with argument arrays and no shell. Map an existing destination to a stable conflict, fail actionably for unsupported platforms or missing compiler, and never commit the binary. No persistent creator lock is used, so a killed process cannot leave a stale lock; the kernel no-replace primitive serializes competing publishers. Do not use check-then-rename, copy-then-delete, or remove/recreate the destination. In `finally`, remove only the resolved staging directory; never delete or replace an existing destination. - [ ] **Step 5: Add the interactive adapter without coupling prompts to core logic** -The `isMain` path prompts for title, type, template, summary, then derived slug confirmation/edit. If the slug conflicts, re-prompt; on success print the created path and exact next command. Keep `createProject()` prompt-free for tests. +The `isMain` path prompts for title, type, template, summary, then derived slug confirmation/edit. If the slug conflicts, re-prompt; on success print the created path and a template-specific exact next command. Keep `createProject()` prompt-free for tests and inject prompt/output adapters for deterministic coverage. - [ ] **Step 6: Run creator and validation suites** diff --git a/docs/superpowers/specs/2026-07-21-personal-gallery-design.md b/docs/superpowers/specs/2026-07-21-personal-gallery-design.md index e2bc5fa..6fc5fce 100644 --- a/docs/superpowers/specs/2026-07-21-personal-gallery-design.md +++ b/docs/superpowers/specs/2026-07-21-personal-gallery-design.md @@ -96,6 +96,8 @@ Running `pnpm create:project` starts an interactive prompt for title, type, temp The command must leave no partially created project when validation fails. Template files use explicit placeholders rather than executing arbitrary code during creation. +Publishing the validated staging directory must never replace an existing project, including one created between a preflight check and publication. Because Node.js does not expose directory-level no-replace rename semantics, the creator uses a small audited native helper: `renameat2(RENAME_NOREPLACE)` on Linux and `renamex_np(RENAME_EXCL)` on macOS. The helper is compiled on first use into a private temporary cache and invoked without a shell. Project creation therefore supports Linux and macOS and requires `/usr/bin/cc` (a C compiler toolchain) for the first helper build; unsupported platforms or a missing compiler fail with an actionable message. No native binary is committed. Templates remain non-executable and project code is never run during scaffolding. + The `blank` template supports every unmodelled technology stack. The initial release will not provide dedicated Java, Python service, CLI, or AI templates; those can be added after repeated project patterns justify them. ## 6. Gallery Experience From d4c07e84c6c6458d48bf7bad9ad2cc951a13c4ec Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 13:27:09 +0800 Subject: [PATCH 21/40] docs: detail native publication helper --- .../plans/2026-07-21-personal-gallery-implementation.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-07-21-personal-gallery-implementation.md b/docs/superpowers/plans/2026-07-21-personal-gallery-implementation.md index a44687f..ed0c5dd 100644 --- a/docs/superpowers/plans/2026-07-21-personal-gallery-implementation.md +++ b/docs/superpowers/plans/2026-07-21-personal-gallery-implementation.md @@ -655,6 +655,8 @@ it('leaves no project or staging directory when validation fails', async () => { Add cases for slug derivation (`Crème & Search` -> `creme-search`), explicit invalid slug, existing-directory conflict, and template traversal/unknown placeholders. Add a creator integration case where the random staging basename differs from the intended slug and spy on the Task 4 API to verify `createProject` calls `validateProjectAt({ projectPath: , expectedDirectoryName: 'my-idea' })`. Add a deterministic race test that creates the final destination immediately before the no-replace syscall and asserts the creator fails without overwriting it. Add a concurrent full-creator test that asserts exactly one final project is published. +Test the native boundary through injectable platform, architecture, temporary-directory, compiler runner, and helper runner adapters. Cover successful first compilation, cache reuse without recompilation, simultaneous cache misses, missing or failing `/usr/bin/cc`, unsupported platforms, untrusted cache ownership/mode, and stable mapping of native conflict/unsupported/failure exit codes. + - [ ] **Step 2: Run the creator suite and verify red** Run: `pnpm exec vitest run tools/__tests__/create-project.test.ts` @@ -697,6 +699,8 @@ Import the already committed `validateProjectAt` from `tools/validate-projects.t Implement and test `renameNoReplace(staging, final)` as the publication boundary using a small audited native helper committed as C source. On Linux it calls `renameat2(..., RENAME_NOREPLACE)`; on macOS it calls `renamex_np(..., RENAME_EXCL)`. Compile the helper on demand with `/usr/bin/cc` into a private temporary cache keyed by source, platform, and architecture; invoke compiler and helper with argument arrays and no shell. Map an existing destination to a stable conflict, fail actionably for unsupported platforms or missing compiler, and never commit the binary. No persistent creator lock is used, so a killed process cannot leave a stale lock; the kernel no-replace primitive serializes competing publishers. Do not use check-then-rename, copy-then-delete, or remove/recreate the destination. In `finally`, remove only the resolved staging directory; never delete or replace an existing destination. +The concrete cache protocol is: create a per-user directory under the OS temporary directory with mode `0700`; reject a cache not owned by the current user or writable/readable by group/other; compile into a unique `mkdtemp` child; verify compiler success and mark the candidate `0700`; atomically publish the cache entry with a hard link so simultaneous first-use losers receive `EEXIST` and reuse the already-complete regular file; remove the unique build directory in `finally`. The cache key hashes helper source, platform, and architecture. The helper ABI is `rename-noreplace SOURCE DESTINATION`: exit `0` means success, `2` means invalid invocation, `3` maps to JavaScript `EEXIST`, `4` means the kernel lacks the primitive, and every other exit includes sanitized stderr in an actionable error. All subprocesses use argument arrays with `shell: false`. + - [ ] **Step 5: Add the interactive adapter without coupling prompts to core logic** The `isMain` path prompts for title, type, template, summary, then derived slug confirmation/edit. If the slug conflicts, re-prompt; on success print the created path and a template-specific exact next command. Keep `createProject()` prompt-free for tests and inject prompt/output adapters for deterministic coverage. @@ -716,7 +720,7 @@ Expected: prompts appear; choose `Smoke Test`, `other`, `blank`, and a short sum - [ ] **Step 8: Commit scaffolding** ```bash -git add templates tools/lib/templates.ts tools/create-project.ts tools/__tests__/create-project.test.ts +git add templates tools/lib/templates.ts tools/lib/rename-noreplace.ts tools/native/rename-noreplace.c tools/create-project.ts tools/__tests__/create-project.test.ts git commit -m "feat: scaffold projects from safe templates" ``` @@ -966,7 +970,7 @@ git commit -m "feat: style an accessible responsive project archive" - [ ] **Step 1: Rewrite the root README as a fresh-checkout guide** -Include purpose, exact tree, Node 22/corepack prerequisites, `pnpm install`, all six stable commands, project isolation, creation/update workflow, statuses, generated-file note, and links to both detailed documents. Explicitly state that root commands never install/build project implementations. +Include purpose, exact tree, Node 22/corepack prerequisites, `pnpm install`, all six stable commands, project isolation, creation/update workflow, statuses, generated-file note, and links to both detailed documents. Document that first project creation supports Linux/macOS and requires `/usr/bin/cc` (Xcode Command Line Tools or a Linux C compiler toolchain) to build the private atomic-publication helper; unsupported platforms and missing compilers fail actionably. Explicitly state that root commands never install/build project implementations and that no native binary is committed. - [ ] **Step 2: Document every schema field from `project.schema.json`** From 0ae186ccc0f5fedf90b6558729f007c0db34e0e3 Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 13:31:17 +0800 Subject: [PATCH 22/40] test: harden native publication adapter --- tools/__tests__/create-project.test.ts | 120 ++++++++++++++++++++ tools/lib/rename-noreplace.ts | 145 ++++++++++++++++--------- 2 files changed, 215 insertions(+), 50 deletions(-) diff --git a/tools/__tests__/create-project.test.ts b/tools/__tests__/create-project.test.ts index 2aca322..52b6dff 100644 --- a/tools/__tests__/create-project.test.ts +++ b/tools/__tests__/create-project.test.ts @@ -14,6 +14,7 @@ vi.mock('../validate-projects.js', async () => { import { createProject, deriveSlug, renameNoReplace, runInteractiveCreator } from '../create-project.js'; import { validateProjects } from '../validate-projects.js'; import { copyRenderedTemplate } from '../lib/templates.js'; +import { createAtomicRenameNoReplaceAdapter, type NativeCommandRunner } from '../lib/rename-noreplace.js'; import { cleanupRepositories, makeRepository } from './fixtures.js'; afterEach(async () => { @@ -31,6 +32,125 @@ const input = (root: string, template: 'blank' | 'web' | 'script' = 'blank') => today: '2026-07-21', }); +const fakeNativeRunner = (nativeResult = { code: 0, stderr: '' }) => { + let compiles = 0; + const run: NativeCommandRunner = async (command, args) => { + if (command === '/test/cc') { + compiles += 1; + await writeFile(args.at(-1)!, 'fake executable'); + return { code: 0, stderr: '' }; + } + return nativeResult; + }; + return { run, compiles: () => compiles }; +}; + +it('compiles the native adapter once and reuses its validated cache', async () => { + const root = await makeRepository(); + const sourcePath = join(root, 'helper.c'); + await writeFile(sourcePath, 'source'); + const fake = fakeNativeRunner(); + const rename = createAtomicRenameNoReplaceAdapter({ + platform: 'darwin', architecture: 'test', sourcePath, + cacheRoot: join(root, 'cache'), compilerPath: '/test/cc', run: fake.run, + }); + await rename('one', 'two'); + await rename('three', 'four'); + expect(fake.compiles()).toBe(1); +}); + +it.each(['permissive mode', 'non-file'] as const)('refuses a reused executable with %s before execution', async (kind) => { + const root = await makeRepository(); + const sourcePath = join(root, 'helper.c'); + const cacheRoot = join(root, 'cache'); + await writeFile(sourcePath, 'source'); + const fake = fakeNativeRunner(); + const rename = createAtomicRenameNoReplaceAdapter({ platform: 'darwin', sourcePath, cacheRoot, compilerPath: '/test/cc', run: fake.run }); + await rename('one', 'two'); + const executable = join(cacheRoot, (await readdir(cacheRoot)).find((name) => name.startsWith('rename-noreplace-'))!); + if (kind === 'permissive mode') await chmod(executable, 0o755); + else { await rm(executable); await mkdir(executable); } + await expect(rename('three', 'four')).rejects.toThrow(/trusted restrictive regular file/i); +}); + +it('safely converges simultaneous native-helper cache misses', async () => { + const root = await makeRepository(); + const sourcePath = join(root, 'helper.c'); + const cacheRoot = join(root, 'cache'); + await writeFile(sourcePath, 'source'); + const fake = fakeNativeRunner(); + const options = { platform: 'linux', architecture: 'test', sourcePath, cacheRoot, compilerPath: '/test/cc', run: fake.run } as const; + const first = createAtomicRenameNoReplaceAdapter(options); + const second = createAtomicRenameNoReplaceAdapter(options); + await expect(Promise.all([first('a', 'b'), second('c', 'd')])).resolves.toEqual([undefined, undefined]); + expect((await readdir(cacheRoot)).filter((name) => name.startsWith('rename-noreplace-'))).toHaveLength(1); +}); + +it('reports missing and failing compilers actionably', async () => { + for (const run of [ + async () => { throw Object.assign(new Error('missing'), { code: 'ENOENT' }); }, + async () => ({ code: 2, stderr: 'compiler failed' }), + ] satisfies NativeCommandRunner[]) { + const root = await makeRepository(); + const sourcePath = join(root, 'helper.c'); + await writeFile(sourcePath, 'source'); + const rename = createAtomicRenameNoReplaceAdapter({ platform: 'linux', sourcePath, cacheRoot: join(root, 'cache'), compilerPath: '/missing/cc', run }); + await expect(rename('a', 'b')).rejects.toThrow(/compiler|C compiler/i); + } +}); + +it('rejects unsupported platforms and untrusted cache mode or ownership', async () => { + const unsupportedRoot = await makeRepository(); + const unsupportedSource = join(unsupportedRoot, 'helper.c'); + await writeFile(unsupportedSource, 'source'); + await expect(createAtomicRenameNoReplaceAdapter({ platform: 'win32', sourcePath: unsupportedSource })('a', 'b')).rejects.toThrow(/unsupported/i); + + for (const kind of ['mode', 'owner'] as const) { + const root = await makeRepository(); + const sourcePath = join(root, 'helper.c'); + const cacheRoot = join(root, 'cache'); + await writeFile(sourcePath, 'source'); + await mkdir(cacheRoot); + if (kind === 'mode') await chmod(cacheRoot, 0o777); + const uid = typeof process.getuid === 'function' ? process.getuid() : 501; + const rename = createAtomicRenameNoReplaceAdapter({ + platform: 'linux', sourcePath, cacheRoot, + uid: kind === 'owner' ? uid + 1 : uid, + }); + await expect(rename('a', 'b')).rejects.toThrow(/private and trusted/i); + } +}); + +it.each([ + [3, /already exists/i, 'EEXIST'], + [4, /kernel does not support/i, undefined], + [9, /helper exited 9/i, undefined], +] as const)('maps native helper exit %i stably', async (code, message, errorCode) => { + const root = await makeRepository(); + const sourcePath = join(root, 'helper.c'); + await writeFile(sourcePath, 'source'); + const fake = fakeNativeRunner({ code, stderr: '' }); + const rename = createAtomicRenameNoReplaceAdapter({ platform: 'linux', sourcePath, cacheRoot: join(root, 'cache'), compilerPath: '/test/cc', run: fake.run }); + try { + await rename('a', 'b'); + expect.fail('expected native mapping failure'); + } catch (error) { + expect((error as Error).message).toMatch(message); + expect((error as NodeJS.ErrnoException).code).toBe(errorCode); + } +}); + +it('runs a real freshly compiled native no-replace helper on this host', async () => { + const root = await makeRepository(); + const source = join(root, 'source'); + const destination = join(root, 'destination'); + await mkdir(source); + await writeFile(join(source, 'sentinel'), 'native'); + const rename = createAtomicRenameNoReplaceAdapter({ cacheRoot: join(root, 'native-cache') }); + await rename(source, destination); + await expect(readFile(join(destination, 'sentinel'), 'utf8')).resolves.toBe('native'); +}); + describe.each(['blank', 'web', 'script'] as const)('%s template', (template) => { it('creates a valid, fully rendered project without executable files or dependencies', async () => { const root = await makeRepository(); diff --git a/tools/lib/rename-noreplace.ts b/tools/lib/rename-noreplace.ts index f7b8a1c..d962824 100644 --- a/tools/lib/rename-noreplace.ts +++ b/tools/lib/rename-noreplace.ts @@ -5,10 +5,22 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; -const sourcePath = join(dirname(fileURLToPath(import.meta.url)), '..', 'native', 'rename-noreplace.c'); -let helperPromise: Promise | undefined; +export type NativeCommandResult = { code: number; stderr: string }; +export type NativeCommandRunner = (command: string, args: string[]) => Promise; -const run = (command: string, args: string[]): Promise<{ code: number; stderr: string }> => +export type NativeRenameAdapterOptions = { + platform?: NodeJS.Platform | string; + architecture?: string; + uid?: number | 'user'; + sourcePath?: string; + cacheRoot?: string; + compilerPath?: string; + run?: NativeCommandRunner; +}; + +const bundledSource = join(dirname(fileURLToPath(import.meta.url)), '..', 'native', 'rename-noreplace.c'); + +const spawnCommand: NativeCommandRunner = (command, args) => new Promise((resolve, reject) => { const child = spawn(command, args, { shell: false, stdio: ['ignore', 'ignore', 'pipe'] }); let stderr = ''; @@ -18,56 +30,89 @@ const run = (command: string, args: string[]): Promise<{ code: number; stderr: s child.on('close', (code) => resolve({ code: code ?? 1, stderr })); }); -async function compiledHelper(): Promise { - if (process.platform !== 'linux' && process.platform !== 'darwin') { - throw new Error(`Atomic project publication is unsupported on ${process.platform}; use Linux or macOS`); - } - const source = await readFile(sourcePath); - const key = createHash('sha256').update(source).update(process.platform).update(process.arch).digest('hex').slice(0, 20); - const uid = typeof process.getuid === 'function' ? process.getuid() : 'user'; - const cache = join(tmpdir(), `faust-native-${uid}`); - await mkdir(cache, { recursive: true, mode: 0o700 }); - const cacheStat = await lstat(cache); - if (!cacheStat.isDirectory() || (cacheStat.mode & 0o077) !== 0 || (typeof uid === 'number' && cacheStat.uid !== uid)) { - throw new Error(`Native helper cache is not private and trusted: ${cache}`); - } - const executable = join(cache, `rename-noreplace-${key}`); - try { +export function createAtomicRenameNoReplaceAdapter(options: NativeRenameAdapterOptions = {}) { + const platform = options.platform ?? process.platform; + const architecture = options.architecture ?? process.arch; + const uid = options.uid ?? (typeof process.getuid === 'function' ? process.getuid() : 'user'); + const sourcePath = options.sourcePath ?? bundledSource; + const cache = options.cacheRoot ?? join(tmpdir(), `faust-native-${uid}`); + const compiler = options.compilerPath ?? '/usr/bin/cc'; + const run = options.run ?? spawnCommand; + let helperPromise: Promise | undefined; + + const validateCache = async (): Promise => { + const cacheStat = await lstat(cache); + if (!cacheStat.isDirectory() || (cacheStat.mode & 0o077) !== 0 || (typeof uid === 'number' && cacheStat.uid !== uid)) { + throw new Error(`Native helper cache is not private and trusted: ${cache}`); + } + }; + + const validateExecutable = async (executable: string): Promise => { const existing = await lstat(executable); - if (!existing.isFile() || existing.uid !== cacheStat.uid) throw new Error('Cached native helper is not a trusted regular file'); - return executable; - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; - } + if ( + !existing.isFile() + || (typeof uid === 'number' && existing.uid !== uid) + || (existing.mode & 0o077) !== 0 + || (existing.mode & 0o100) === 0 + ) { + throw new Error(`Cached native helper is not a trusted restrictive regular file: ${executable}`); + } + }; - const build = await mkdtemp(join(cache, '.build-')); - const candidate = join(build, 'rename-noreplace'); - try { - const compiler = '/usr/bin/cc'; - const result = await run(compiler, ['-std=c11', '-O2', sourcePath, '-o', candidate]); - if (result.code !== 0) { - throw new Error(`Atomic publication helper could not be compiled with ${compiler}; install a C compiler. ${result.stderr.trim()}`); + const compiledHelper = async (): Promise => { + if (platform !== 'linux' && platform !== 'darwin') { + throw new Error(`Atomic project publication is unsupported on ${platform}; use Linux or macOS`); } - await chmod(candidate, 0o700); - try { await link(candidate, executable); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + const source = await readFile(sourcePath); + const key = createHash('sha256').update(source).update(platform).update(architecture).digest('hex').slice(0, 20); + await mkdir(cache, { recursive: true, mode: 0o700 }); + await validateCache(); + const executable = join(cache, `rename-noreplace-${key}`); + try { + await validateExecutable(executable); + return executable; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } - return executable; - } finally { - await rm(build, { recursive: true, force: true }); - } -} -export async function atomicRenameNoReplace(source: string, destination: string): Promise { - helperPromise ??= compiledHelper(); - const helper = await helperPromise; - const result = await run(helper, [source, destination]); - if (result.code === 0) return; - if (result.code === 3) { - const error = new Error(`Destination already exists: ${destination}`) as NodeJS.ErrnoException; - error.code = 'EEXIST'; - throw error; - } - if (result.code === 4) throw new Error(`This ${process.platform} kernel does not support atomic no-replace rename`); - throw new Error(`Atomic project publication failed: ${result.stderr.trim() || `helper exited ${result.code}`}`); + const build = await mkdtemp(join(cache, '.build-')); + const candidate = join(build, 'rename-noreplace'); + try { + let result: NativeCommandResult; + try { + result = await run(compiler, ['-std=c11', '-O2', sourcePath, '-o', candidate]); + } catch (error) { + throw new Error(`Atomic publication helper compiler is unavailable at ${compiler}; install a C compiler`, { cause: error }); + } + if (result.code !== 0) { + throw new Error(`Atomic publication helper could not be compiled with ${compiler}; install a C compiler. ${result.stderr.trim()}`); + } + await chmod(candidate, 0o700); + try { await link(candidate, executable); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error; + } + await validateExecutable(executable); + return executable; + } finally { + await rm(build, { recursive: true, force: true }); + } + }; + + return async (source: string, destination: string): Promise => { + helperPromise ??= compiledHelper(); + const helper = await helperPromise; + await validateCache(); + await validateExecutable(helper); + const result = await run(helper, [source, destination]); + if (result.code === 0) return; + if (result.code === 3) { + const error = new Error(`Destination already exists: ${destination}`) as NodeJS.ErrnoException; + error.code = 'EEXIST'; + throw error; + } + if (result.code === 4) throw new Error(`This ${platform} kernel does not support atomic no-replace rename`); + throw new Error(`Atomic project publication failed: ${result.stderr.trim() || `helper exited ${result.code}`}`); + }; } + +export const atomicRenameNoReplace = createAtomicRenameNoReplaceAdapter(); From 43ffca39a752dff2b7839afb48eb2b3e8ef27101 Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 13:35:00 +0800 Subject: [PATCH 23/40] fix: bound native helper diagnostics --- tools/__tests__/create-project.test.ts | 38 +++++++++++++++++++++++++- tools/lib/rename-noreplace.ts | 23 ++++++++++++++-- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/tools/__tests__/create-project.test.ts b/tools/__tests__/create-project.test.ts index 52b6dff..1d364cf 100644 --- a/tools/__tests__/create-project.test.ts +++ b/tools/__tests__/create-project.test.ts @@ -14,7 +14,12 @@ vi.mock('../validate-projects.js', async () => { import { createProject, deriveSlug, renameNoReplace, runInteractiveCreator } from '../create-project.js'; import { validateProjects } from '../validate-projects.js'; import { copyRenderedTemplate } from '../lib/templates.js'; -import { createAtomicRenameNoReplaceAdapter, type NativeCommandRunner } from '../lib/rename-noreplace.js'; +import { + appendBoundedStderr, + createAtomicRenameNoReplaceAdapter, + maximumCollectedStderrLength, + type NativeCommandRunner, +} from '../lib/rename-noreplace.js'; import { cleanupRepositories, makeRepository } from './fixtures.js'; afterEach(async () => { @@ -45,6 +50,13 @@ const fakeNativeRunner = (nativeResult = { code: 0, stderr: '' }) => { return { run, compiles: () => compiles }; }; +it('bounds stderr while collecting multiple chunks', () => { + const collected = ['first', 'x'.repeat(3_000), 'y'.repeat(3_000), 'ignored'] + .reduce(appendBoundedStderr, ''); + expect(collected).toHaveLength(maximumCollectedStderrLength); + expect(appendBoundedStderr(collected, 'z'.repeat(10_000))).toBe(collected); +}); + it('compiles the native adapter once and reuses its validated cache', async () => { const root = await makeRepository(); const sourcePath = join(root, 'helper.c'); @@ -99,6 +111,19 @@ it('reports missing and failing compilers actionably', async () => { } }); +it('sanitizes and bounds hostile compiler diagnostics', async () => { + const root = await makeRepository(); + const sourcePath = join(root, 'helper.c'); + await writeFile(sourcePath, 'source'); + const hostile = `\u001b[31mFAIL\u001b[0m\u0000\n${'x'.repeat(5_000)}`; + const rename = createAtomicRenameNoReplaceAdapter({ + platform: 'linux', sourcePath, cacheRoot: join(root, 'cache'), compilerPath: '/test/cc', + run: async () => ({ code: 2, stderr: hostile }), + }); + await expect(rename('a', 'b')).rejects.toSatisfy((error: Error) => + !error.message.includes('\u001b') && !error.message.includes('\u0000') && error.message.length < 1_200 && error.message.includes('FAIL')); +}); + it('rejects unsupported platforms and untrusted cache mode or ownership', async () => { const unsupportedRoot = await makeRepository(); const unsupportedSource = join(unsupportedRoot, 'helper.c'); @@ -122,6 +147,7 @@ it('rejects unsupported platforms and untrusted cache mode or ownership', async }); it.each([ + [2, /invoked with invalid arguments/i, undefined], [3, /already exists/i, 'EEXIST'], [4, /kernel does not support/i, undefined], [9, /helper exited 9/i, undefined], @@ -140,6 +166,16 @@ it.each([ } }); +it('sanitizes and bounds hostile native-helper diagnostics', async () => { + const root = await makeRepository(); + const sourcePath = join(root, 'helper.c'); + await writeFile(sourcePath, 'source'); + const fake = fakeNativeRunner({ code: 9, stderr: `\u001b[2Jbad\u0007\n${'z'.repeat(5_000)}` }); + const rename = createAtomicRenameNoReplaceAdapter({ platform: 'darwin', sourcePath, cacheRoot: join(root, 'cache'), compilerPath: '/test/cc', run: fake.run }); + await expect(rename('a', 'b')).rejects.toSatisfy((error: Error) => + !error.message.includes('\u001b') && !error.message.includes('\u0007') && error.message.length < 1_100 && error.message.includes('bad')); +}); + it('runs a real freshly compiled native no-replace helper on this host', async () => { const root = await makeRepository(); const source = join(root, 'source'); diff --git a/tools/lib/rename-noreplace.ts b/tools/lib/rename-noreplace.ts index d962824..261c5e3 100644 --- a/tools/lib/rename-noreplace.ts +++ b/tools/lib/rename-noreplace.ts @@ -19,13 +19,28 @@ export type NativeRenameAdapterOptions = { }; const bundledSource = join(dirname(fileURLToPath(import.meta.url)), '..', 'native', 'rename-noreplace.c'); +const maximumDiagnosticLength = 1_000; +export const maximumCollectedStderrLength = 4_096; + +export const appendBoundedStderr = (collected: string, chunk: string): string => { + if (collected.length >= maximumCollectedStderrLength) return collected; + return collected + chunk.slice(0, maximumCollectedStderrLength - collected.length); +}; + +const sanitizeDiagnostic = (value: string): string => value + .replace(/\u001B\[[0-?]*[ -/]*[@-~]/gu, '') + .replace(/[\u0000-\u001F\u007F-\u009F]/gu, ' ') + .replace(/\s+/gu, ' ') + .trim() + .slice(0, maximumDiagnosticLength); const spawnCommand: NativeCommandRunner = (command, args) => new Promise((resolve, reject) => { const child = spawn(command, args, { shell: false, stdio: ['ignore', 'ignore', 'pipe'] }); let stderr = ''; child.stderr.setEncoding('utf8'); - child.stderr.on('data', (chunk: string) => { stderr += chunk; }); + // Keep draining the pipe after the cap so the child cannot block on a full stderr buffer. + child.stderr.on('data', (chunk: string) => { stderr = appendBoundedStderr(stderr, chunk); }); child.on('error', reject); child.on('close', (code) => resolve({ code: code ?? 1, stderr })); }); @@ -85,7 +100,8 @@ export function createAtomicRenameNoReplaceAdapter(options: NativeRenameAdapterO throw new Error(`Atomic publication helper compiler is unavailable at ${compiler}; install a C compiler`, { cause: error }); } if (result.code !== 0) { - throw new Error(`Atomic publication helper could not be compiled with ${compiler}; install a C compiler. ${result.stderr.trim()}`); + const diagnostic = sanitizeDiagnostic(result.stderr); + throw new Error(`Atomic publication helper could not be compiled with ${compiler}; install a C compiler.${diagnostic ? ` ${diagnostic}` : ''}`); } await chmod(candidate, 0o700); try { await link(candidate, executable); } catch (error) { @@ -110,8 +126,9 @@ export function createAtomicRenameNoReplaceAdapter(options: NativeRenameAdapterO error.code = 'EEXIST'; throw error; } + if (result.code === 2) throw new Error('Atomic publication helper was invoked with invalid arguments; report this tooling bug'); if (result.code === 4) throw new Error(`This ${platform} kernel does not support atomic no-replace rename`); - throw new Error(`Atomic project publication failed: ${result.stderr.trim() || `helper exited ${result.code}`}`); + throw new Error(`Atomic project publication failed: ${sanitizeDiagnostic(result.stderr) || `helper exited ${result.code}`}`); }; } From 50f7f72b3e841a497dcc741038820ceef56e430d Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 13:41:07 +0800 Subject: [PATCH 24/40] feat: list gallery projects for maintenance --- tools/__tests__/list-projects.test.ts | 86 +++++++++++++++++++++++++ tools/list-projects.ts | 93 +++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 tools/__tests__/list-projects.test.ts create mode 100644 tools/list-projects.ts diff --git a/tools/__tests__/list-projects.test.ts b/tools/__tests__/list-projects.test.ts new file mode 100644 index 0000000..d8fd24a --- /dev/null +++ b/tools/__tests__/list-projects.test.ts @@ -0,0 +1,86 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import type { GalleryProject } from '../generate-project-index.js'; +import { formatProjectList, runListProjectsCli } from '../list-projects.js'; +import { cleanupRepositories, makeRepository, validProject } from './fixtures.js'; + +afterEach(async () => cleanupRepositories()); + +const galleryProject = ( + slug: string, + overrides: Partial = {}, +): GalleryProject => ({ + ...validProject, + tags: [...validProject.tags], + slug, + title: slug, + excerptHtml: '

    Example.

    ', + year: '2026', + href: `/projects/${slug}/`, + sourceHref: `https://example.com/${slug}`, + coverHref: null, + ...overrides, +}); + +describe('project maintenance listing', () => { + it('prints title, type, status, and path in gallery order', () => { + const older = galleryProject('older', { + title: 'Older', + type: 'ai', + status: 'building', + updatedAt: '2026-07-20', + }); + const newer = galleryProject('newer', { + title: 'Newer', + type: 'web', + status: 'shipped', + updatedAt: '2026-07-21', + }); + + expect(formatProjectList([older, newer])).toBe([ + 'TITLE TYPE STATUS PATH', + 'Newer web shipped projects/newer', + 'Older ai building projects/older', + ].join('\n')); + }); + + it('uses slug ascending to break equal updated-date ties', () => { + const zulu = galleryProject('zulu', { title: 'Zulu' }); + const alpha = galleryProject('alpha', { title: 'Alpha' }); + + expect(formatProjectList([zulu, alpha]).split('\n').slice(1)).toEqual([ + 'Alpha other building projects/alpha', + 'Zulu other building projects/zulu', + ]); + }); + + it('pads columns by terminal display width for Unicode titles', () => { + const cjk = galleryProject('cjk', { title: '项目', updatedAt: '2026-07-21' }); + const combining = galleryProject('accent', { title: 'e\u0301', updatedAt: '2026-07-20' }); + + expect(formatProjectList([combining, cjk]).split('\n')).toEqual([ + 'TITLE TYPE STATUS PATH', + '项目 other building projects/cjk', + 'e\u0301 other building projects/accent', + ]); + }); + + it('prints the intentional empty-repository message exactly', () => { + expect(formatProjectList([])).toBe('No projects found. Run pnpm create:project.'); + }); + + it('validates before printing and reports an actionable failure without a stack trace', async () => { + const root = await makeRepository([{ + dir: 'broken', + metadata: { ...validProject, slug: 'broken' }, + }]); + const lines: string[] = []; + + expect(await runListProjectsCli({ root, write: (line) => lines.push(line) })).toBe(1); + expect(lines).toHaveLength(1); + expect(lines[0]).toContain('broken: README.md'); + expect(lines[0]).toContain('fix:'); + expect(lines[0]).not.toContain('TITLE'); + expect(lines[0]).not.toContain(' at '); + }); +}); diff --git a/tools/list-projects.ts b/tools/list-projects.ts new file mode 100644 index 0000000..d290b99 --- /dev/null +++ b/tools/list-projects.ts @@ -0,0 +1,93 @@ +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + buildProjectIndex, + ProjectValidationError, + type GalleryProject, +} from './generate-project-index.js'; + +const EMPTY_MESSAGE = 'No projects found. Run pnpm create:project.'; +const HEADERS = ['TITLE', 'TYPE', 'STATUS', 'PATH'] as const; + +const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); +const isWideCodePoint = (codePoint: number, character: string): boolean => + /\p{Extended_Pictographic}/u.test(character) + || (codePoint >= 0x1100 && codePoint <= 0x115f) + || codePoint === 0x2329 + || codePoint === 0x232a + || (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) + || (codePoint >= 0xac00 && codePoint <= 0xd7a3) + || (codePoint >= 0xf900 && codePoint <= 0xfaff) + || (codePoint >= 0xfe10 && codePoint <= 0xfe19) + || (codePoint >= 0xfe30 && codePoint <= 0xfe6f) + || (codePoint >= 0xff00 && codePoint <= 0xff60) + || (codePoint >= 0xffe0 && codePoint <= 0xffe6) + || (codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff) + || (codePoint >= 0x1f200 && codePoint <= 0x1f251) + || (codePoint >= 0x20000 && codePoint <= 0x3fffd); +const width = (value: string): number => [...graphemes.segment(value)] + .reduce((total, { segment }) => { + const characters = Array.from(segment); + if (characters.some((character) => isWideCodePoint(character.codePointAt(0) ?? 0, character))) { + return total + 2; + } + return total + characters.filter((character) => !/[\p{Mark}\p{Control}]/u.test(character)).length; + }, 0); +const pad = (value: string, target: number): string => + `${value}${' '.repeat(Math.max(0, target - width(value)))}`; + +export function formatProjectList(projects: readonly GalleryProject[]): string { + if (projects.length === 0) return EMPTY_MESSAGE; + + const ordered = [...projects].sort((a, b) => { + const dateOrder = b.updatedAt.localeCompare(a.updatedAt); + if (dateOrder !== 0) return dateOrder; + return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0; + }); + const rows = ordered.map((project) => [ + project.title, + project.type, + project.status, + `projects/${project.slug}`, + ]); + const widths = HEADERS.map((header, column) => + Math.max(width(header), ...rows.map((row) => width(row[column])))); + const render = (row: readonly string[]): string => row + .map((cell, column) => column === row.length - 1 ? cell : pad(cell, widths[column])) + .join(' '); + + return [render(HEADERS), ...rows.map(render)].join('\n'); +} + +export async function runListProjectsCli(options: { + root: string; + write?: (line: string) => void; + writeError?: (line: string) => void; +}): Promise<0 | 1> { + const write = options.write ?? ((line: string) => console.log(line)); + const writeError = options.writeError ?? write; + try { + const projects = await buildProjectIndex(options.root); + write(formatProjectList(projects)); + return 0; + } catch (caught) { + if (caught instanceof ProjectValidationError) { + writeError(caught.message); + } else { + const message = caught instanceof Error ? caught.message : String(caught); + writeError(`repository: projects — listing could not run: ${message}; fix: ensure projects/ exists and is readable`); + } + return 1; + } +} + +const invokedPath = process.argv[1] === undefined ? undefined : resolve(process.argv[1]); +if (invokedPath === fileURLToPath(import.meta.url)) { + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + process.exitCode = await runListProjectsCli({ + root, + write: (line) => console.log(line), + writeError: (line) => console.error(line), + }); +} From c3911275b01830a86b4ff8e93fcdc3a55aac9df2 Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 13:48:58 +0800 Subject: [PATCH 25/40] fix: harden gallery project listing --- package.json | 1 + pnpm-lock.yaml | 12 ++++ tools/__tests__/list-projects.test.ts | 86 +++++++++++++++++++++++---- tools/list-projects.ts | 49 ++++++--------- 4 files changed, 107 insertions(+), 41 deletions(-) diff --git a/package.json b/package.json index e50771a..10f03ab 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@inquirer/prompts": "^7.8.0", "markdown-it": "^14.1.0", "sanitize-html": "^2.17.0", + "string-width": "8.2.2", "zod": "^3.25.76" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0360f2..539d020 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: sanitize-html: specifier: ^2.17.0 version: 2.17.6 + string-width: + specifier: 8.2.2 + version: 8.2.2 zod: specifier: ^3.25.76 version: 3.25.76 @@ -2069,6 +2072,10 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -4773,6 +4780,11 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 diff --git a/tools/__tests__/list-projects.test.ts b/tools/__tests__/list-projects.test.ts index d8fd24a..3862d3f 100644 --- a/tools/__tests__/list-projects.test.ts +++ b/tools/__tests__/list-projects.test.ts @@ -1,4 +1,8 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import stringWidth from 'string-width'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import type { GalleryProject } from '../generate-project-index.js'; import { formatProjectList, runListProjectsCli } from '../list-projects.js'; @@ -65,22 +69,82 @@ describe('project maintenance listing', () => { ]); }); + it('aligns keycap, ZWJ-family, and flag grapheme clusters', () => { + const projects = [ + galleryProject('keycap', { title: '1️⃣', updatedAt: '2026-07-23' }), + galleryProject('family', { title: '👨‍👩‍👧‍👦', updatedAt: '2026-07-22' }), + galleryProject('flag', { title: '🇨🇳', updatedAt: '2026-07-21' }), + ]; + + const rows = formatProjectList(projects).split('\n').slice(1); + const typeOffsets = rows.map((row) => stringWidth(row.slice(0, row.indexOf('other')))); + + expect(new Set(typeOffsets).size).toBe(1); + expect(rows[1]).toContain('👨\\u{200D}👩\\u{200D}👧\\u{200D}👦'); + expect(rows[1]).not.toContain('\u200d'); + }); + + it('escapes control and format characters before measuring terminal cells', () => { + const hostile = galleryProject('hostile', { title: 'safe\n\r\t\u001b\u202eend' }); + const output = formatProjectList([hostile]); + const row = output.split('\n')[1]; + + expect(row).toBe('safe\\n\\r\\t\\u{1B}\\u{202E}end other building projects/hostile'); + expect(row).not.toMatch(/[\p{Control}\p{Format}]/u); + expect(output).not.toContain('\u001b['); + }); + it('prints the intentional empty-repository message exactly', () => { expect(formatProjectList([])).toBe('No projects found. Run pnpm create:project.'); }); - it('validates before printing and reports an actionable failure without a stack trace', async () => { + it('writes successful output only to stdout', async () => { + const root = await makeRepository(); + const stdout = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const stderr = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + expect(await runListProjectsCli({ root })).toBe(0); + expect(stdout).toHaveBeenCalledOnce(); + expect(stdout).toHaveBeenCalledWith('No projects found. Run pnpm create:project.'); + expect(stderr).not.toHaveBeenCalled(); + }); + + it('validates before printing and writes actionable failures only to stderr', async () => { + const hostileName = 'broken\n\t\u001b\u202e'; const root = await makeRepository([{ - dir: 'broken', + dir: hostileName, metadata: { ...validProject, slug: 'broken' }, }]); - const lines: string[] = []; - - expect(await runListProjectsCli({ root, write: (line) => lines.push(line) })).toBe(1); - expect(lines).toHaveLength(1); - expect(lines[0]).toContain('broken: README.md'); - expect(lines[0]).toContain('fix:'); - expect(lines[0]).not.toContain('TITLE'); - expect(lines[0]).not.toContain(' at '); + const stdout = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const stderr = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + expect(await runListProjectsCli({ root })).toBe(1); + expect(stdout).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledOnce(); + const message = String(stderr.mock.calls[0][0]); + expect(message).toContain('broken\\n\\t\\u{1B}\\u{202E}: README.md'); + expect(message).toContain('fix:'); + expect(message).not.toContain('TITLE'); + expect(message).not.toContain(' at '); + expect(message).not.toMatch(/[\p{Control}\p{Format}]/u); + }); + + it('writes generic repository failures only to stderr without a stack trace', async () => { + const parent = await makeRepository(); + const root = join(parent, 'generic\n\r\t\u001b\u202e'); + await mkdir(root); + await writeFile(join(root, 'projects'), 'not a directory'); + const stdout = vi.spyOn(console, 'log').mockImplementation(() => undefined); + const stderr = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + expect(await runListProjectsCli({ root })).toBe(1); + expect(stdout).not.toHaveBeenCalled(); + expect(stderr).toHaveBeenCalledOnce(); + const message = String(stderr.mock.calls[0][0]); + expect(message).toMatch(/^repository: projects — listing could not run:/); + expect(message).toContain('fix: ensure projects/ exists and is readable'); + expect(message).not.toContain(' at '); + expect(message).toContain('generic\\n\\r\\t\\u{1B}\\u{202E}'); + expect(message).not.toMatch(/[\p{Control}\p{Format}]/u); }); }); diff --git a/tools/list-projects.ts b/tools/list-projects.ts index d290b99..c0da9bd 100644 --- a/tools/list-projects.ts +++ b/tools/list-projects.ts @@ -1,6 +1,8 @@ import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import stringWidth from 'string-width'; + import { buildProjectIndex, ProjectValidationError, @@ -10,32 +12,17 @@ import { const EMPTY_MESSAGE = 'No projects found. Run pnpm create:project.'; const HEADERS = ['TITLE', 'TYPE', 'STATUS', 'PATH'] as const; -const graphemes = new Intl.Segmenter(undefined, { granularity: 'grapheme' }); -const isWideCodePoint = (codePoint: number, character: string): boolean => - /\p{Extended_Pictographic}/u.test(character) - || (codePoint >= 0x1100 && codePoint <= 0x115f) - || codePoint === 0x2329 - || codePoint === 0x232a - || (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) - || (codePoint >= 0xac00 && codePoint <= 0xd7a3) - || (codePoint >= 0xf900 && codePoint <= 0xfaff) - || (codePoint >= 0xfe10 && codePoint <= 0xfe19) - || (codePoint >= 0xfe30 && codePoint <= 0xfe6f) - || (codePoint >= 0xff00 && codePoint <= 0xff60) - || (codePoint >= 0xffe0 && codePoint <= 0xffe6) - || (codePoint >= 0x1f1e6 && codePoint <= 0x1f1ff) - || (codePoint >= 0x1f200 && codePoint <= 0x1f251) - || (codePoint >= 0x20000 && codePoint <= 0x3fffd); -const width = (value: string): number => [...graphemes.segment(value)] - .reduce((total, { segment }) => { - const characters = Array.from(segment); - if (characters.some((character) => isWideCodePoint(character.codePointAt(0) ?? 0, character))) { - return total + 2; - } - return total + characters.filter((character) => !/[\p{Mark}\p{Control}]/u.test(character)).length; - }, 0); +const escapeTerminalText = (value: string): string => value.replace( + /[\p{Control}\p{Format}]/gu, + (character) => { + if (character === '\n') return '\\n'; + if (character === '\r') return '\\r'; + if (character === '\t') return '\\t'; + return `\\u{${character.codePointAt(0)?.toString(16).toUpperCase()}}`; + }, +); const pad = (value: string, target: number): string => - `${value}${' '.repeat(Math.max(0, target - width(value)))}`; + `${value}${' '.repeat(Math.max(0, target - stringWidth(value)))}`; export function formatProjectList(projects: readonly GalleryProject[]): string { if (projects.length === 0) return EMPTY_MESSAGE; @@ -50,9 +37,9 @@ export function formatProjectList(projects: readonly GalleryProject[]): string { project.type, project.status, `projects/${project.slug}`, - ]); + ].map(escapeTerminalText)); const widths = HEADERS.map((header, column) => - Math.max(width(header), ...rows.map((row) => width(row[column])))); + Math.max(stringWidth(header), ...rows.map((row) => stringWidth(row[column])))); const render = (row: readonly string[]): string => row .map((cell, column) => column === row.length - 1 ? cell : pad(cell, widths[column])) .join(' '); @@ -66,17 +53,19 @@ export async function runListProjectsCli(options: { writeError?: (line: string) => void; }): Promise<0 | 1> { const write = options.write ?? ((line: string) => console.log(line)); - const writeError = options.writeError ?? write; + const writeError = options.writeError ?? ((line: string) => console.error(line)); try { const projects = await buildProjectIndex(options.root); write(formatProjectList(projects)); return 0; } catch (caught) { if (caught instanceof ProjectValidationError) { - writeError(caught.message); + writeError(escapeTerminalText(caught.message)); } else { const message = caught instanceof Error ? caught.message : String(caught); - writeError(`repository: projects — listing could not run: ${message}; fix: ensure projects/ exists and is readable`); + writeError(escapeTerminalText( + `repository: projects — listing could not run: ${message}; fix: ensure projects/ exists and is readable`, + )); } return 1; } From 160a56c8c0f1ef895b4cf43162d015a76289d761 Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 14:11:35 +0800 Subject: [PATCH 26/40] feat: render static gallery and project routes --- gallery/astro.config.mjs | 8 ++ gallery/src/__tests__/projects.test.ts | 67 ++++++++++++++ gallery/src/__tests__/rendering.test.ts | 108 ++++++++++++++++++++++ gallery/src/components/ProjectCover.astro | 20 ++++ gallery/src/components/ProjectIndex.astro | 61 ++++++++++++ gallery/src/layouts/BaseLayout.astro | 42 +++++++++ gallery/src/lib/projects.ts | 26 ++++++ gallery/src/pages/index.astro | 13 +++ gallery/src/pages/projects/[slug].astro | 50 ++++++++++ 9 files changed, 395 insertions(+) create mode 100644 gallery/astro.config.mjs create mode 100644 gallery/src/__tests__/projects.test.ts create mode 100644 gallery/src/__tests__/rendering.test.ts create mode 100644 gallery/src/components/ProjectCover.astro create mode 100644 gallery/src/components/ProjectIndex.astro create mode 100644 gallery/src/layouts/BaseLayout.astro create mode 100644 gallery/src/lib/projects.ts create mode 100644 gallery/src/pages/index.astro create mode 100644 gallery/src/pages/projects/[slug].astro diff --git a/gallery/astro.config.mjs b/gallery/astro.config.mjs new file mode 100644 index 0000000..158d5ad --- /dev/null +++ b/gallery/astro.config.mjs @@ -0,0 +1,8 @@ +import { defineConfig } from 'astro/config'; + +export default defineConfig({ + site: process.env.FAUST_SITE ?? 'https://lurui1997.github.io', + base: process.env.FAUST_BASE ?? '/faust', + output: 'static', + trailingSlash: 'always', +}); diff --git a/gallery/src/__tests__/projects.test.ts b/gallery/src/__tests__/projects.test.ts new file mode 100644 index 0000000..aafd8db --- /dev/null +++ b/gallery/src/__tests__/projects.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import type { GalleryProject } from '../../../tools/generate-project-index.js'; +import { filterProjects, orderProjects } from '../lib/projects.js'; + +const project = (slug: string, overrides: Partial = {}): GalleryProject => ({ + title: slug, + slug, + summary: `${slug} summary`, + status: 'building', + type: 'web', + tags: [], + createdAt: '2026-01-01', + updatedAt: '2026-07-20', + featured: false, + demo: null, + repository: './', + cover: null, + excerptHtml: `

    ${slug}

    `, + year: '2026', + href: `/faust/projects/${slug}/`, + sourceHref: `https://github.com/lurui1997/faust/tree/main/projects/${slug}`, + coverHref: null, + ...overrides, +}); + +const projects = [ + project('archive', { status: 'archived', updatedAt: '2026-07-22' }), + project('older', { status: 'shipped', type: 'cli', updatedAt: '2026-07-19' }), + project('newest', { status: 'idea', updatedAt: '2026-07-21' }), +]; + +describe('project ordering', () => { + it('orders by updated date descending and slug ascending for ties', () => { + const items = [ + project('zulu', { updatedAt: '2026-07-21' }), + project('old', { updatedAt: '2026-07-20' }), + project('alpha', { updatedAt: '2026-07-21' }), + ]; + + expect(orderProjects(items).map(({ slug }) => slug)).toEqual(['alpha', 'zulu', 'old']); + expect(items.map(({ slug }) => slug)).toEqual(['zulu', 'old', 'alpha']); + }); +}); + +describe('project filtering', () => { + it('defaults to all non-archived projects', () => { + expect(filterProjects(projects, { type: 'all', status: 'active' }).map(({ slug }) => slug)) + .toEqual(['older', 'newest']); + }); + + it('allows an explicit archived filter', () => { + expect(filterProjects(projects, { type: 'all', status: 'archived' }).map(({ slug }) => slug)) + .toEqual(['archive']); + }); + + it('matches type and concrete status values exactly', () => { + expect(filterProjects(projects, { type: 'cli', status: 'shipped' }).map(({ slug }) => slug)) + .toEqual(['older']); + expect(filterProjects(projects, { type: 'c', status: 'ship' })).toEqual([]); + }); + + it('supports explicit all filters without changing input order', () => { + expect(filterProjects(projects, { type: 'all', status: 'all' }).map(({ slug }) => slug)) + .toEqual(['archive', 'older', 'newest']); + }); +}); diff --git a/gallery/src/__tests__/rendering.test.ts b/gallery/src/__tests__/rendering.test.ts new file mode 100644 index 0000000..83be6ed --- /dev/null +++ b/gallery/src/__tests__/rendering.test.ts @@ -0,0 +1,108 @@ +import { cp, mkdtemp, mkdir, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; +import { promisify } from 'node:util'; +import { execFile } from 'node:child_process'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const execFileAsync = promisify(execFile); +const repositoryRoot = resolve(import.meta.dirname, '../../..'); +const galleryRoot = join(repositoryRoot, 'gallery'); + +let fixtureRoot = ''; +let outputRoot = ''; + +async function writeProject( + slug: string, + overrides: Record, + readme: string, + cover?: string, +): Promise { + const projectRoot = join(fixtureRoot, 'projects', slug); + await mkdir(projectRoot, { recursive: true }); + await writeFile(join(projectRoot, 'project.json'), JSON.stringify({ + title: slug === 'covered' ? 'Covered Work' : 'Plain Work', + slug, + summary: `Summary for ${slug}.`, + status: 'building', + type: 'web', + tags: ['typescript', 'archive'], + createdAt: '2026-07-01', + updatedAt: slug === 'covered' ? '2026-07-21' : '2026-07-20', + featured: slug === 'covered', + demo: null, + repository: './', + cover: cover ?? null, + ...overrides, + })); + await writeFile(join(projectRoot, 'README.md'), readme); + if (cover !== undefined) await writeFile(join(projectRoot, cover), 'fixture image'); +} + +beforeEach(async () => { + const temporaryRoot = await realpath(tmpdir()); + fixtureRoot = await mkdtemp(join(temporaryRoot, 'faust-gallery-fixture-')); + outputRoot = await mkdtemp(join(temporaryRoot, 'faust-gallery-output-')); + await mkdir(join(fixtureRoot, 'projects')); + await cp(galleryRoot, join(fixtureRoot, 'gallery'), { + recursive: true, + verbatimSymlinks: true, + filter: (source) => !['.astro', 'dist', 'node_modules'].includes(basename(source)), + }); + await symlink(join(repositoryRoot, 'node_modules'), join(fixtureRoot, 'node_modules')); +}); + +afterEach(async () => { + await Promise.all([fixtureRoot, outputRoot].map((path) => rm(path, { recursive: true, force: true }))); + delete process.env.FAUST_BASE; + delete process.env.FAUST_SITE; + vi.resetModules(); +}); + +describe('static gallery rendering', () => { + it('builds safe project routes with base-aware links and optional actions', async () => { + await writeProject( + 'covered', + { demo: 'https://demo.example/covered', repository: 'https://code.example/covered', cover: 'cover.png' }, + '# Covered\n\nA **safe** excerpt [bad](javascript:alert(1)) .', + 'cover.png', + ); + await writeProject( + 'plain', + {}, + '# Plain\n\nA plain excerpt.', + ); + + process.env.FAUST_BASE = '/test-base'; + process.env.FAUST_SITE = 'https://example.test'; + vi.resetModules(); + const { generateProjectIndex } = await import('../../../tools/generate-project-index.js'); + await generateProjectIndex({ root: fixtureRoot, write: () => undefined }); + await execFileAsync('pnpm', [ + 'exec', 'astro', 'build', '--root', join(fixtureRoot, 'gallery'), '--outDir', outputRoot, + ], { + cwd: repositoryRoot, + env: { ...process.env, FAUST_BASE: '/test-base', FAUST_SITE: 'https://example.test' }, + }); + + const home = await readFile(join(outputRoot, 'index.html'), 'utf8'); + const covered = await readFile(join(outputRoot, 'projects/covered/index.html'), 'utf8'); + const plain = await readFile(join(outputRoot, 'projects/plain/index.html'), 'utf8'); + + expect(home).toContain('2 projects'); + expect(home).toContain('href="/test-base/projects/covered/"'); + expect(home).toContain('href="/test-base/projects/plain/"'); + expect(covered).toContain('href="https://demo.example/covered"'); + expect(covered).toContain('href="https://code.example/covered"'); + expect(covered).toContain('src="/test-base/project-assets/covered/cover.png"'); + expect(await readFile(join(outputRoot, 'project-assets/covered/cover.png'), 'utf8')).toBe('fixture image'); + expect(covered).toContain('safe'); + expect(plain).toContain('No cover for Plain Work'); + expect(plain).toContain('href="https://github.com/lurui1997/faust/tree/main/projects/plain"'); + expect(plain).not.toMatch(/>\s*Demo\s* + {coverHref + ? {`Cover + :
    No cover for {title}
    } + + + diff --git a/gallery/src/components/ProjectIndex.astro b/gallery/src/components/ProjectIndex.astro new file mode 100644 index 0000000..1bf27bd --- /dev/null +++ b/gallery/src/components/ProjectIndex.astro @@ -0,0 +1,61 @@ +--- +import type { GalleryProject } from '../../../tools/generate-project-index.js'; + +type Props = { projects: GalleryProject[] }; +const { projects } = Astro.props; +const visibleProjects = projects.filter((project) => project.status !== 'archived'); +const projectTypes = [...new Set(projects.map(({ type }) => type))].sort(); +--- + +
    +
    +

    Projects

    +

    {visibleProjects.length} {visibleProjects.length === 1 ? 'project' : 'projects'}

    +
    + +
    + + +
    + + {projects.length === 0 + ?

    No projects yet. Create one with pnpm create:project.

    + :
      + {visibleProjects.map((project) => ( +
    1. +
      +

      {project.type} · {project.status} ·

      +

      {project.title}

      +

      {project.summary}

      +
      +
    2. + ))} +
    } +
    + + diff --git a/gallery/src/layouts/BaseLayout.astro b/gallery/src/layouts/BaseLayout.astro new file mode 100644 index 0000000..58e8fa2 --- /dev/null +++ b/gallery/src/layouts/BaseLayout.astro @@ -0,0 +1,42 @@ +--- +type Props = { + title?: string; + description?: string; +}; + +const { + title = 'Faust — Project Archive', + description = 'A quiet archive of software ideas, experiments, and finished work.', +} = Astro.props; +const homeHref = import.meta.env.BASE_URL; +--- + + + + + + + + {title} + + + + + + + + diff --git a/gallery/src/lib/projects.ts b/gallery/src/lib/projects.ts new file mode 100644 index 0000000..d896497 --- /dev/null +++ b/gallery/src/lib/projects.ts @@ -0,0 +1,26 @@ +import raw from '../data/projects.generated.json'; + +import type { GalleryProject } from '../../../tools/generate-project-index.js'; + +export type ProjectFilters = { + type: string; + status: string; +}; + +export function orderProjects(items: readonly GalleryProject[]): GalleryProject[] { + return [...items].sort((a, b) => + b.updatedAt.localeCompare(a.updatedAt) || a.slug.localeCompare(b.slug)); +} + +export const projects = orderProjects(raw as GalleryProject[]); + +export function filterProjects( + items: readonly GalleryProject[], + filters: ProjectFilters, +): GalleryProject[] { + return items.filter((project) => + (filters.type === 'all' || project.type === filters.type) + && (filters.status === 'active' + ? project.status !== 'archived' + : filters.status === 'all' || project.status === filters.status)); +} diff --git a/gallery/src/pages/index.astro b/gallery/src/pages/index.astro new file mode 100644 index 0000000..ddf8818 --- /dev/null +++ b/gallery/src/pages/index.astro @@ -0,0 +1,13 @@ +--- +import ProjectIndex from '../components/ProjectIndex.astro'; +import BaseLayout from '../layouts/BaseLayout.astro'; +import { projects } from '../lib/projects.js'; +--- + + +
    +

    Ideas, kept in public.

    +

    A working archive of small tools, unfinished questions, and things worth returning to.

    + +
    +
    diff --git a/gallery/src/pages/projects/[slug].astro b/gallery/src/pages/projects/[slug].astro new file mode 100644 index 0000000..673be27 --- /dev/null +++ b/gallery/src/pages/projects/[slug].astro @@ -0,0 +1,50 @@ +--- +import type { GalleryProject } from '../../../../tools/generate-project-index.js'; +import ProjectCover from '../../components/ProjectCover.astro'; +import BaseLayout from '../../layouts/BaseLayout.astro'; +import { projects } from '../../lib/projects.js'; + +export function getStaticPaths() { + return projects.map((project) => ({ params: { slug: project.slug }, props: { project } })); +} + +type Props = { project: GalleryProject }; +const { project } = Astro.props; +--- + + +
    + +
    +
    +

    {project.type} · {project.status}

    +

    {project.title}

    +

    {project.summary}

    +
    + +
    +
    Created
    +
    Updated
    +
    Tags
    {project.tags.join(', ') || 'None'}
    +
    +
    +

    About

    +
    +
    + +
    +
    +
    + + From da2550ea3f2a64f34d2b7a92a8e2dcb7fafe3b5c Mon Sep 17 00:00:00 2001 From: drulu Date: Tue, 21 Jul 2026 14:19:32 +0800 Subject: [PATCH 27/40] feat: add shareable accessible project filters --- gallery/src/__tests__/rendering.test.ts | 44 ++++++++++++ gallery/src/components/FilterBar.astro | 40 +++++++++++ gallery/src/components/ProjectIndex.astro | 41 ++++------- gallery/src/scripts/filters.ts | 83 +++++++++++++++++++++++ 4 files changed, 180 insertions(+), 28 deletions(-) create mode 100644 gallery/src/components/FilterBar.astro create mode 100644 gallery/src/scripts/filters.ts diff --git a/gallery/src/__tests__/rendering.test.ts b/gallery/src/__tests__/rendering.test.ts index 83be6ed..4a38f71 100644 --- a/gallery/src/__tests__/rendering.test.ts +++ b/gallery/src/__tests__/rendering.test.ts @@ -73,6 +73,11 @@ describe('static gallery rendering', () => { {}, '# Plain\n\nA plain excerpt.', ); + await writeProject( + 'archived-cli', + { title: 'Archived CLI', status: 'archived', type: 'cli' }, + '# Archived\n\nAn archived excerpt.', + ); process.env.FAUST_BASE = '/test-base'; process.env.FAUST_SITE = 'https://example.test'; @@ -93,6 +98,17 @@ describe('static gallery rendering', () => { expect(home).toContain('2 projects'); expect(home).toContain('href="/test-base/projects/covered/"'); expect(home).toContain('href="/test-base/projects/plain/"'); + expect(home).toMatch(/]*method="get"[^>]*data-project-filters/); + expect(home).toMatch(/