diff --git a/docs/src/content/navigation.yaml b/docs/src/content/navigation.yaml index 3f803b4fc..d85e9d25b 100644 --- a/docs/src/content/navigation.yaml +++ b/docs/src/content/navigation.yaml @@ -80,6 +80,21 @@ navGroups: discriminant: page value: content-components status: default + - label: Hooks + link: + discriminant: page + value: hooks + status: new + - label: Actions + link: + discriminant: page + value: actions + status: new + - label: Workflows + link: + discriminant: page + value: workflows + status: new - groupName: Recipes items: - label: Use Astro's Image component diff --git a/docs/src/content/pages/actions.mdoc b/docs/src/content/pages/actions.mdoc new file mode 100644 index 000000000..2a415166b --- /dev/null +++ b/docs/src/content/pages/actions.mdoc @@ -0,0 +1,142 @@ +--- +title: Actions +summary: >- + Add custom action buttons to the Keystatic editor toolbar that content editors + can trigger manually. +--- + +Actions are developer-defined operations that appear as a dropdown menu in the item editor toolbar. They let content editors trigger workflows, run validations, or perform tasks without leaving the Keystatic dashboard. + +## Registering actions + +Use `registerActions` to attach actions to a collection or singleton: + +```typescript +import { registerActions } from '@keystatic/core'; + +registerActions({ collection: 'posts' }, [ + { + label: 'Run Content Audit', + description: 'Check SEO, readability, and publishing readiness', + handler: async (ctx) => { + // Perform the action + const issues = await auditContent(ctx.data); + return { message: `Audit complete: ${issues.length} issues found` }; + }, + }, +]); +``` + +## Action definition + +Each action has the following shape: + +```typescript +type Action = { + label: string; // Button/menu label + description?: string; // Shown as secondary text in the dropdown + icon?: ReactElement; // Optional icon (from @keystar/ui/icon) + handler: (ctx) => Promise; + when?: { // Conditional visibility + match?: (ctx: { slug?: string; data: Record }) => boolean; + }; +}; +``` + +## Action context + +The handler receives a context object: + +```typescript +type ActionContext = { + trigger: 'manual'; + collection?: string; + singleton?: string; + slug?: string; + data: Record; // current field values + storage: { kind: 'local' | 'github' | 'cloud' }; + update(data: Partial>): Promise; +}; +``` + +The `update` function lets your action write back to the current entry: + +```typescript +handler: async (ctx) => { + const summary = await generateSummary(ctx.data.content); + await ctx.update({ summary }); + return { message: 'Summary generated and saved' }; +}, +``` + +## Action results + +The handler can return a result that controls the toast notification shown to the editor: + +```typescript +// Success toast +return { message: 'Action completed successfully' }; + +// Error toast +return { error: 'Something went wrong' }; + +// No toast (silent) +return; +``` + +## Conditional actions + +Use the `when.match` function to show actions only when certain conditions are met: + +```typescript +registerActions({ collection: 'posts' }, [ + { + label: 'Translate to Spanish', + when: { + match: (ctx) => (ctx.data.language as string) === 'en', + }, + handler: async (ctx) => { + // Only shown for English posts + }, + }, +]); +``` + +## UI placement + +Actions appear as a **dropdown menu** triggered by a zap icon button in the editor toolbar, next to the existing action icons (reset, delete, copy, paste) and the Save button. + +- The button only appears when at least one action is registered for the current collection or singleton +- All registered (and visible) actions appear in the dropdown +- While an action is running, the button is disabled to prevent double-execution + +## Multiple actions + +Register multiple actions in a single call: + +```typescript +registerActions({ collection: 'posts' }, [ + { + label: 'Preview on Site', + handler: async (ctx) => { + window.open(`/posts/${ctx.slug}`, '_blank'); + return { message: 'Preview opened' }; + }, + }, + { + label: 'Export as Markdown', + handler: async (ctx) => { + // Export logic + return { message: 'Exported' }; + }, + }, + { + label: 'Run SEO Check', + description: 'Validate title length, slug format, and meta fields', + handler: async (ctx) => { + // SEO validation logic + return { message: 'SEO check passed' }; + }, + }, +]); +``` diff --git a/docs/src/content/pages/hooks.mdoc b/docs/src/content/pages/hooks.mdoc new file mode 100644 index 000000000..6f1de69e5 --- /dev/null +++ b/docs/src/content/pages/hooks.mdoc @@ -0,0 +1,144 @@ +--- +title: Hooks +summary: >- + React to content lifecycle events with before and after hooks on collections + and singletons. +--- + +Hooks let you run custom logic when content is created, saved, or deleted. They are registered at runtime using the `registerHooks` function from `@keystatic/core`. + +## Hook events + +Six lifecycle events are available: + +| Event | When it fires | Can cancel? | +|---|---|---| +| `beforeCreate` | Before a new entry is created | Yes | +| `afterCreate` | After a new entry is created | No | +| `beforeSave` | Before an existing entry is saved | Yes | +| `afterSave` | After an existing entry is saved | No | +| `beforeDelete` | Before an entry is deleted | Yes | +| `afterDelete` | After an entry is deleted | No | + +## Registering hooks + +Use `registerHooks` to attach hooks to a collection or singleton: + +```typescript +import { registerHooks } from '@keystatic/core'; + +registerHooks({ collection: 'posts' }, { + beforeSave: [ + async (ctx) => { + console.log(`Saving post: ${ctx.slug}`); + }, + ], + afterSave: [ + async (ctx) => { + console.log(`Post saved: ${ctx.slug}`); + }, + ], +}); +``` + +For singletons: + +```typescript +registerHooks({ singleton: 'settings' }, { + afterSave: [ + async (ctx) => { + console.log('Settings updated'); + }, + ], +}); +``` + +## Hook context + +Every hook receives a context object with information about the operation: + +```typescript +type HookContext = { + event: HookEvent; // which event triggered this hook + trigger: 'event' | 'manual'; + collection?: string; // collection name (if applicable) + singleton?: string; // singleton name (if applicable) + slug?: string; // entry slug (collections only) + data: Record; // current field values + previousData?: Record; // previous values (for updates) + storage: { kind: 'local' | 'github' | 'cloud' }; +}; +``` + +After hooks also receive an `update` function for writing back to the entry: + +```typescript +afterSave: [ + async (ctx) => { + // Update a field on the entry after save + await ctx.update({ lastModified: new Date().toISOString() }); + }, +], +``` + +## Cancelling operations + +`before*` hooks can cancel the operation by returning `{ cancel: true }`: + +```typescript +beforeSave: [ + async (ctx) => { + const title = ctx.data.title as string; + if (title.length < 3) { + return { cancel: true, reason: 'Title must be at least 3 characters' }; + } + }, +], +``` + +When a hook cancels, subsequent hooks do not run and a toast notification displays the reason to the editor. + +## Modifying data + +`before*` hooks can also modify the data before it is saved: + +```typescript +beforeSave: [ + async (ctx) => { + return { + data: { + ...ctx.data, + updatedAt: new Date().toISOString(), + }, + }; + }, +], +``` + +Modified data is passed to subsequent hooks and to the save operation. + +## Execution order + +1. `before*` hooks run **sequentially** — first cancellation wins +2. The actual operation (create/save/delete) runs +3. `after*` hooks run **in parallel** — errors are logged but don't block + +When both global and resource-level hooks exist, global hooks run first. + +## Global hooks + +Register hooks that run for all collections and singletons using `registerGlobalHooks`: + +```typescript +import { registerGlobalHooks } from '@keystatic/core'; + +registerGlobalHooks({ + afterSave: [ + async (ctx) => { + console.log(`Content saved: ${ctx.collection || ctx.singleton}`); + }, + ], +}); +``` + +Global hooks execute before resource-level hooks. diff --git a/docs/src/content/pages/workflows.mdoc b/docs/src/content/pages/workflows.mdoc new file mode 100644 index 000000000..f70cc825c --- /dev/null +++ b/docs/src/content/pages/workflows.mdoc @@ -0,0 +1,276 @@ +--- +title: Workflows +summary: >- + Trigger GitHub Actions workflows from Keystatic using the @keystatic/workflows + adapter package. +--- + +The `@keystatic/workflows` package connects Keystatic's hooks and actions to [GitHub Actions](https://docs.github.com/en/actions) — enabling you to trigger automated workflows directly from the Keystatic dashboard when content is created, saved, or deleted. + +Since Keystatic already integrates deeply with GitHub for content storage, GitHub Actions is a natural execution engine for content workflows. + +## Installation + +```bash +npm install @keystatic/workflows +``` + +No additional build tooling or config wrappers needed — workflows are standard GitHub Actions YAML files. + +## Core concepts + +The adapter provides two functions: + +- **`useWorkflow(workflowFile, options)`** — dispatches a GitHub Actions workflow and shows the result as a toast notification. Used for manual actions and fire-and-forget after-hooks. +- **`awaitWorkflow(workflowFile, options)`** — dispatches a workflow and waits for it to complete. Used for before-hooks that need to cancel or modify the operation based on the workflow result. Supports `.then()` chaining. + +Both functions communicate with GitHub Actions through an API endpoint (default: `/api/workflows`) that calls the GitHub `workflow_dispatch` API. + +## Writing a workflow + +Workflows are standard GitHub Actions YAML files with `workflow_dispatch` triggers. The `inputs` define what data Keystatic passes to the workflow: + +```yaml +# .github/workflows/translate-post.yml +name: Translate Post + +on: + workflow_dispatch: + inputs: + slug: + description: Source post slug + required: true + type: string + title: + description: Source post title + required: true + type: string + language: + description: Target language code + required: true + type: string + +jobs: + translate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Translate and create post + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const slug = '${{ inputs.language }}-${{ inputs.slug }}'; + const title = '[${{ inputs.language }}] ${{ inputs.title }}'; + + fs.mkdirSync(`posts/${slug}`, { recursive: true }); + fs.writeFileSync(`posts/${slug}/index.yaml`, `title: ${title}\nslug: ${slug}\n`); + fs.copyFileSync( + `posts/${{ inputs.slug }}/content.mdoc`, + `posts/${slug}/content.mdoc` + ); + + - name: Commit translated post + run: | + git config user.name "keystatic-bot" + git config user.email "bot@keystatic.com" + git add posts/ + git commit -m "feat: Add ${{ inputs.language }} translation of ${{ inputs.slug }}" + git push +``` + +Key points: +- Use `workflow_dispatch` with `inputs` to receive data from Keystatic +- Input values are always strings (GitHub Actions limitation) +- Workflows can read/write files, call APIs, run scripts — anything GitHub Actions supports +- Use `actions/upload-artifact` to pass structured results back to the API route + +## Creating the API endpoint + +The API route dispatches workflows via the GitHub REST API: + +```typescript +// app/api/workflows/route.ts +import { NextResponse } from 'next/server'; + +const GITHUB_API = 'https://api.github.com'; +const REPO_OWNER = process.env.KEYSTATIC_GITHUB_OWNER!; +const REPO_NAME = process.env.KEYSTATIC_GITHUB_REPO!; +const GITHUB_TOKEN = process.env.GITHUB_TOKEN!; + +export async function POST(request: Request) { + const { workflow, input, wait, timeout, pollInterval } = await request.json(); + + // Dispatch the workflow + const res = await fetch( + `${GITHUB_API}/repos/${REPO_OWNER}/${REPO_NAME}/actions/workflows/${workflow}/dispatches`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${GITHUB_TOKEN}`, + Accept: 'application/vnd.github.v3+json', + }, + body: JSON.stringify({ ref: 'main', inputs: input }), + } + ); + + if (!res.ok) { + return NextResponse.json({ error: await res.text() }, { status: res.status }); + } + + if (!wait) { + return NextResponse.json({ status: 'dispatched', workflow }); + } + + // Poll for completion if wait=true (used by awaitWorkflow) + const result = await pollForCompletion(workflow, timeout, pollInterval); + return NextResponse.json(result); +} +``` + +See the full example with polling in `examples/api-routes/workflows-route.ts`. + +## Environment variables + +| Variable | Description | +|---|---| +| `KEYSTATIC_GITHUB_OWNER` | Repository owner (e.g. `your-org`) | +| `KEYSTATIC_GITHUB_REPO` | Repository name (e.g. `your-site`) | +| `GITHUB_TOKEN` | Personal access token or app token with `actions:write` scope | +| `GITHUB_DEFAULT_BRANCH` | Branch to dispatch workflows on (default: `main`) | + +For Keystatic GitHub storage mode, the owner and repo are already in your config. The `GITHUB_TOKEN` needs `actions:write` permission in addition to the standard repo permissions. + +## Connecting workflows to actions + +Use `useWorkflow` with the workflow **filename** (e.g. `translate-post.yml`): + +```typescript +// keystatic.config.ts +import { registerActions } from '@keystatic/core'; +import { useWorkflow } from '@keystatic/workflows'; + +registerActions({ collection: 'posts' }, [ + { + label: 'Translate to Spanish', + description: 'Duplicate and translate this post', + handler: useWorkflow('translate-post.yml', { + input: (ctx) => ({ + slug: ctx.slug ?? '', + title: ctx.data.title as string, + language: 'es', + }), + }), + }, +]); +``` + +The `input` mapper must return `Record` since GitHub Actions inputs are always strings. + +## Connecting workflows to hooks + +Use `useWorkflow` for fire-and-forget after-hooks: + +```typescript +import { registerHooks } from '@keystatic/core'; +import { useWorkflow } from '@keystatic/workflows'; + +registerHooks({ collection: 'posts' }, { + afterSave: [ + useWorkflow('generate-og-image.yml', { + input: (ctx) => ({ + slug: ctx.slug ?? '', + title: ctx.data.title as string, + }), + }), + ], +}); +``` + +Use `awaitWorkflow` with `.then()` for before-hooks that should cancel based on the result: + +```typescript +import { awaitWorkflow } from '@keystatic/workflows'; + +registerHooks({ collection: 'posts' }, { + beforeSave: [ + awaitWorkflow('content-audit.yml', { timeout: 120000 }).then((result) => { + const r = result as { conclusion: string }; + if (r.conclusion === 'failure') { + return { cancel: true, reason: 'Content audit failed — check GitHub Actions for details' }; + } + }), + ], +}); +``` + +## Options + +### `useWorkflow(workflowFile, options?)` + +| Option | Type | Default | Description | +|---|---|---|---| +| `input` | `(ctx) => Record` | `{}` | Maps hook/action context to workflow_dispatch inputs | +| `endpoint` | `string` | `'/api/workflows'` | API route that dispatches the workflow | +| `formatResult` | `(result) => ActionResult` | auto-detect | Custom formatter for the toast message | + +### `awaitWorkflow(workflowFile, options?)` + +Same options as `useWorkflow`, plus: + +| Option | Type | Default | Description | +|---|---|---|---| +| `timeout` | `number` | `120000` | Milliseconds to wait for the workflow run to complete | +| `pollInterval` | `number` | `3000` | Milliseconds between status polls | + +### `.then(onResult)` + +Chain `.then()` on `awaitWorkflow` to transform the workflow result into a `BeforeHookResult`: + +```typescript +awaitWorkflow('my-check.yml', { timeout: 60000 }).then((result) => { + // result contains: { status, conclusion, html_url, run_id, workflow } + if (result.conclusion === 'failure') { + return { cancel: true, reason: 'Workflow check failed' }; + } + // Return void to proceed normally +}); +``` + +## Example workflows + +The `@keystatic/workflows` package includes example GitHub Actions workflows: + +| Workflow | Description | +|---|---| +| `translate-post.yml` | Duplicate and translate a post to another language | +| `content-audit.yml` | SEO, quality, and publishing readiness checks | +| `generate-og-image.yml` | Generate Open Graph images using @vercel/og | +| `ai-content-assistant.yml` | AI-powered summary, title suggestions, and tag generation | + +Copy these from `node_modules/@keystatic/workflows/examples/github-actions/` to your `.github/workflows/` directory. + +## Combining with plain hooks + +You can mix GitHub Actions workflows with plain async hooks. Use plain hooks for simple client-side validations and GitHub Actions for heavy server-side operations: + +```typescript +registerHooks({ collection: 'posts' }, { + // Plain hook — runs instantly in the browser + beforeSave: [ + async (ctx) => { + const title = ctx.data.title as string; + if (title.length < 3) { + return { cancel: true, reason: 'Title too short' }; + } + }, + ], + // GitHub Actions — runs on GitHub's infrastructure + afterSave: [ + useWorkflow('generate-og-image.yml', { + input: ctx => ({ slug: ctx.slug ?? '', title: ctx.data.title as string }), + }), + ], +}); +``` diff --git a/jest.config.js b/jest.config.js index 2eb92b7af..f7f1183ca 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,6 +1,6 @@ /** @type {import('jest').Config} */ const config = { - projects: ['/design-system', '/packages/keystatic'], + projects: ['/design-system', '/packages/keystatic', '/packages/keystatic-workflows'], collectCoverageFrom: [ '**/packages/**/*.{ts,tsx}', '!**/dist/**', diff --git a/packages/keystatic-workflows/examples/api-routes/workflows-route.ts b/packages/keystatic-workflows/examples/api-routes/workflows-route.ts new file mode 100644 index 000000000..4ae7fa6bd --- /dev/null +++ b/packages/keystatic-workflows/examples/api-routes/workflows-route.ts @@ -0,0 +1,131 @@ +/** + * Example: Workflow API Route (Next.js App Router) + * + * Place at: app/api/workflows/route.ts + * + * This route receives requests from @keystatic/workflows adapters + * (useWorkflow/awaitWorkflow) and dispatches GitHub Actions workflows + * via the workflow_dispatch API. + * + * Prerequisites: + * - A GitHub personal access token or app token with `actions:write` scope + * - The token stored as GITHUB_TOKEN env var + * - GitHub Actions workflow files in .github/workflows/ + * + * For Keystatic GitHub storage, the token is already available from the + * existing OAuth flow. + */ + +import { NextResponse } from 'next/server'; + +const GITHUB_API = 'https://api.github.com'; + +// Configure these for your repo +const REPO_OWNER = process.env.KEYSTATIC_GITHUB_OWNER || ''; +const REPO_NAME = process.env.KEYSTATIC_GITHUB_REPO || ''; +const GITHUB_TOKEN = process.env.GITHUB_TOKEN || ''; +const DEFAULT_BRANCH = process.env.GITHUB_DEFAULT_BRANCH || 'main'; + +export async function POST(request: Request) { + try { + const { workflow, input, wait, timeout, pollInterval } = await request.json(); + + if (!REPO_OWNER || !REPO_NAME || !GITHUB_TOKEN) { + return NextResponse.json( + { error: 'GitHub configuration missing. Set KEYSTATIC_GITHUB_OWNER, KEYSTATIC_GITHUB_REPO, and GITHUB_TOKEN.' }, + { status: 500 } + ); + } + + // Dispatch the workflow + const dispatchUrl = `${GITHUB_API}/repos/${REPO_OWNER}/${REPO_NAME}/actions/workflows/${workflow}/dispatches`; + const dispatchRes = await fetch(dispatchUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${GITHUB_TOKEN}`, + Accept: 'application/vnd.github.v3+json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + ref: DEFAULT_BRANCH, + inputs: input || {}, + }), + }); + + if (!dispatchRes.ok) { + const text = await dispatchRes.text(); + return NextResponse.json( + { error: `GitHub API error (${dispatchRes.status}): ${text}` }, + { status: dispatchRes.status } + ); + } + + // Fire-and-forget mode — return immediately + if (!wait) { + return NextResponse.json({ + status: 'dispatched', + message: `Workflow "${workflow}" dispatched`, + workflow, + }); + } + + // Wait mode — poll for the workflow run to complete + const result = await pollForCompletion(workflow, timeout || 120000, pollInterval || 3000); + return NextResponse.json(result); + } catch (err: any) { + console.error('[workflows] error:', err); + return NextResponse.json( + { error: err.message || 'Workflow dispatch failed' }, + { status: 500 } + ); + } +} + +/** + * Poll the GitHub Actions API until the most recent run of the given + * workflow completes or the timeout is reached. + */ +async function pollForCompletion( + workflowFile: string, + timeoutMs: number, + pollIntervalMs: number +): Promise> { + const start = Date.now(); + + // Brief initial delay — GitHub needs a moment to create the run + await new Promise(r => setTimeout(r, 2000)); + + while (Date.now() - start < timeoutMs) { + const runsUrl = `${GITHUB_API}/repos/${REPO_OWNER}/${REPO_NAME}/actions/workflows/${workflowFile}/runs?per_page=1&branch=${DEFAULT_BRANCH}`; + const runsRes = await fetch(runsUrl, { + headers: { + Authorization: `Bearer ${GITHUB_TOKEN}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + + if (runsRes.ok) { + const { workflow_runs } = await runsRes.json(); + if (workflow_runs?.length > 0) { + const run = workflow_runs[0]; + if (run.status === 'completed') { + return { + status: 'completed', + conclusion: run.conclusion, // success, failure, cancelled + html_url: run.html_url, + run_id: run.id, + workflow: workflowFile, + }; + } + } + } + + await new Promise(r => setTimeout(r, pollIntervalMs)); + } + + return { + status: 'timeout', + message: `Workflow "${workflowFile}" did not complete within ${timeoutMs / 1000}s`, + workflow: workflowFile, + }; +} diff --git a/packages/keystatic-workflows/examples/github-actions/ai-content-assistant.yml b/packages/keystatic-workflows/examples/github-actions/ai-content-assistant.yml new file mode 100644 index 000000000..1d251fe5f --- /dev/null +++ b/packages/keystatic-workflows/examples/github-actions/ai-content-assistant.yml @@ -0,0 +1,145 @@ +# .github/workflows/ai-content-assistant.yml +# +# Triggered from Keystatic via useWorkflow('ai-content-assistant.yml'). +# Uses an LLM to generate a summary, suggest SEO titles, and auto-tag a post. + +name: AI Content Assistant + +on: + workflow_dispatch: + inputs: + slug: + description: Post slug + required: true + type: string + title: + description: Post title + required: true + type: string + task: + description: "Task to perform: summary, suggest-titles, generate-tags" + required: true + type: choice + options: + - summary + - suggest-titles + - generate-tags + +jobs: + ai-assist: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Read post content + id: read + run: | + POST_DIR="posts/${{ inputs.slug }}" + CONTENT="" + if [ -f "$POST_DIR/content.mdoc" ]; then + CONTENT=$(head -c 3000 "$POST_DIR/content.mdoc") + fi + echo "content<> $GITHUB_OUTPUT + echo "$CONTENT" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Generate summary + if: inputs.task == 'summary' + id: summary + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + uses: actions/github-script@v7 + with: + script: | + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + messages: [{ + role: 'user', + content: `Summarize this blog post in 2-3 sentences for a meta description.\n\nTitle: ${{ inputs.title }}\n\nContent:\n${`${{ steps.read.outputs.content }}`.slice(0, 2000)}`, + }], + max_tokens: 200, + }), + }); + const data = await response.json(); + const summary = data.choices[0].message.content; + core.setOutput('result', JSON.stringify({ task: 'summary', summary })); + + - name: Suggest SEO titles + if: inputs.task == 'suggest-titles' + id: titles + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + uses: actions/github-script@v7 + with: + script: | + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + messages: [{ + role: 'user', + content: `Suggest 3 SEO-optimized alternative titles (50-60 chars each) for:\n\nCurrent: ${{ inputs.title }}\n\nContent:\n${`${{ steps.read.outputs.content }}`.slice(0, 2000)}\n\nReturn one per line, no numbering.`, + }], + max_tokens: 200, + }), + }); + const data = await response.json(); + const suggestions = data.choices[0].message.content.split('\n').filter(Boolean); + core.setOutput('result', JSON.stringify({ task: 'suggest-titles', suggestions })); + + - name: Generate tags + if: inputs.task == 'generate-tags' + id: tags + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + uses: actions/github-script@v7 + with: + script: | + const response = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-4o-mini', + messages: [{ + role: 'user', + content: `Suggest 3-7 tags for this blog post as a comma-separated list (lowercase, hyphenated).\n\nTitle: ${{ inputs.title }}\n\nContent:\n${`${{ steps.read.outputs.content }}`.slice(0, 2000)}`, + }], + max_tokens: 100, + }), + }); + const data = await response.json(); + const tags = data.choices[0].message.content.split(',').map(t => t.trim().toLowerCase()); + core.setOutput('result', JSON.stringify({ task: 'generate-tags', tags })); + + - name: Save result artifact + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + const result = + `${{ steps.summary.outputs.result }}` || + `${{ steps.titles.outputs.result }}` || + `${{ steps.tags.outputs.result }}` || + '{}'; + fs.writeFileSync('ai-result.json', result); + + - name: Upload result + uses: actions/upload-artifact@v4 + with: + name: ai-result + path: ai-result.json + retention-days: 1 diff --git a/packages/keystatic-workflows/examples/github-actions/content-audit.yml b/packages/keystatic-workflows/examples/github-actions/content-audit.yml new file mode 100644 index 000000000..2b8c175f7 --- /dev/null +++ b/packages/keystatic-workflows/examples/github-actions/content-audit.yml @@ -0,0 +1,127 @@ +# .github/workflows/content-audit.yml +# +# Triggered from Keystatic via awaitWorkflow('content-audit.yml'). +# Runs SEO, quality, and publishing readiness checks on a post. +# Returns results as a workflow artifact that the API route reads. + +name: Content Audit + +on: + workflow_dispatch: + inputs: + slug: + description: Post slug to audit + required: true + type: string + title: + description: Post title + required: true + type: string + has_content: + description: Whether the post has content body + required: true + type: string + publish_date: + description: Publish date (ISO string or empty) + required: false + type: string + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run SEO checks + id: seo + uses: actions/github-script@v7 + with: + script: | + const title = `${{ inputs.title }}`; + const slug = `${{ inputs.slug }}`; + const issues = []; + + if (title.length < 20) { + issues.push({ check: 'SEO', severity: 'warning', message: `Title is short (${title.length} chars). Aim for 50-60.` }); + } + if (title.length > 60) { + issues.push({ check: 'SEO', severity: 'warning', message: 'Title may be truncated in search results.' }); + } + if (slug.includes('_')) { + issues.push({ check: 'SEO', severity: 'warning', message: 'Slug contains underscores — prefer hyphens.' }); + } + + core.setOutput('issues', JSON.stringify(issues)); + + - name: Run quality checks + id: quality + uses: actions/github-script@v7 + with: + script: | + const title = `${{ inputs.title }}`; + const hasContent = `${{ inputs.has_content }}` === 'true'; + const issues = []; + + if (title && title[0] !== title[0].toUpperCase()) { + issues.push({ check: 'Style', severity: 'warning', message: 'Title should start with uppercase.' }); + } + if (!hasContent) { + issues.push({ check: 'Content', severity: 'error', message: 'Post has no content body.' }); + } + + core.setOutput('issues', JSON.stringify(issues)); + + - name: Run publish readiness checks + id: publish + uses: actions/github-script@v7 + with: + script: | + const title = `${{ inputs.title }}`; + const publishDate = `${{ inputs.publish_date }}`; + const issues = []; + + if (!publishDate) { + issues.push({ check: 'Publishing', severity: 'warning', message: 'No publish date set.' }); + } + const placeholders = ['untitled', 'test', 'draft', 'new post']; + if (placeholders.some(p => title.toLowerCase().includes(p))) { + issues.push({ check: 'Publishing', severity: 'warning', message: 'Title looks like a placeholder.' }); + } + + core.setOutput('issues', JSON.stringify(issues)); + + - name: Calculate score and save report + uses: actions/github-script@v7 + with: + script: | + const seo = JSON.parse(`${{ steps.seo.outputs.issues }}`); + const quality = JSON.parse(`${{ steps.quality.outputs.issues }}`); + const publish = JSON.parse(`${{ steps.publish.outputs.issues }}`); + const issues = [...seo, ...quality, ...publish]; + + let score = 100; + for (const i of issues) { + if (i.severity === 'error') score -= 25; + if (i.severity === 'warning') score -= 10; + if (i.severity === 'info') score -= 2; + } + score = Math.max(0, score); + + const report = { + slug: `${{ inputs.slug }}`, + passed: issues.filter(i => i.severity === 'error').length === 0, + issues, + score, + }; + + const fs = require('fs'); + fs.writeFileSync('audit-report.json', JSON.stringify(report, null, 2)); + console.log(`Audit complete: score ${score}/100, ${issues.length} issues`); + + - name: Upload audit report + uses: actions/upload-artifact@v4 + with: + name: audit-report + path: audit-report.json + retention-days: 1 diff --git a/packages/keystatic-workflows/examples/github-actions/generate-og-image.yml b/packages/keystatic-workflows/examples/github-actions/generate-og-image.yml new file mode 100644 index 000000000..b9f2f88ff --- /dev/null +++ b/packages/keystatic-workflows/examples/github-actions/generate-og-image.yml @@ -0,0 +1,87 @@ +# .github/workflows/generate-og-image.yml +# +# Triggered from Keystatic via useWorkflow('generate-og-image.yml'). +# Generates an Open Graph image for a post and commits it to the repo. + +name: Generate OG Image + +on: + workflow_dispatch: + inputs: + slug: + description: Post slug + required: true + type: string + title: + description: Post title + required: true + type: string + description: + description: Post description or excerpt + required: false + type: string + +jobs: + generate: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install dependencies + run: npm install @vercel/og + + - name: Generate OG image + uses: actions/github-script@v7 + with: + script: | + const { ImageResponse } = await import('@vercel/og'); + const fs = require('fs'); + const path = require('path'); + + const title = `${{ inputs.title }}`; + const description = `${{ inputs.description }}` || ''; + + // Generate OG image using @vercel/og + const html = { + type: 'div', + props: { + style: { + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + padding: '60px', + width: '100%', + height: '100%', + background: 'linear-gradient(135deg, #1a1a2e 0%, #16213e 100%)', + color: '#e6e6e6', + fontFamily: 'sans-serif', + }, + children: [ + { type: 'h1', props: { style: { fontSize: '48px', margin: 0 }, children: title } }, + description && { type: 'p', props: { style: { fontSize: '24px', color: '#999', marginTop: '20px' }, children: description } }, + ].filter(Boolean), + }, + }; + + const response = new ImageResponse(html, { width: 1200, height: 630 }); + const buffer = Buffer.from(await response.arrayBuffer()); + + const dir = path.join('public', 'og'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `${{ inputs.slug }}.png`), buffer); + + console.log(`Generated OG image: public/og/${{ inputs.slug }}.png`); + + - name: Commit OG image + run: | + git config user.name "keystatic-bot" + git config user.email "bot@keystatic.com" + git add "public/og/${{ inputs.slug }}.png" + git commit -m "chore: Generate OG image for ${{ inputs.slug }}" || echo "No changes" + git push diff --git a/packages/keystatic-workflows/examples/github-actions/translate-post.yml b/packages/keystatic-workflows/examples/github-actions/translate-post.yml new file mode 100644 index 000000000..1e5a9c675 --- /dev/null +++ b/packages/keystatic-workflows/examples/github-actions/translate-post.yml @@ -0,0 +1,88 @@ +# .github/workflows/translate-post.yml +# +# Triggered from Keystatic via useWorkflow('translate-post.yml'). +# Duplicates a post and translates it using an LLM. + +name: Translate Post + +on: + workflow_dispatch: + inputs: + slug: + description: Source post slug + required: true + type: string + title: + description: Source post title + required: true + type: string + language: + description: Target language code (es, fr, de, ja) + required: true + type: string + +jobs: + translate: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Read source post + id: read + run: | + POST_DIR="posts/${{ inputs.slug }}" + if [ ! -d "$POST_DIR" ]; then + echo "::error::Post not found: ${{ inputs.slug }}" + exit 1 + fi + CONTENT=$(cat "$POST_DIR/content.mdoc" 2>/dev/null || echo "") + echo "content<> $GITHUB_OUTPUT + echo "$CONTENT" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Translate content + id: translate + uses: actions/github-script@v7 + env: + OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} + with: + script: | + // In production, call a real translation API here. + // This example uses a simple prefix for demonstration. + const lang = '${{ inputs.language }}'.toUpperCase(); + const title = `[${lang}] ${{ inputs.title }}`; + const slug = `${{ inputs.language }}-${{ inputs.slug }}`; + + core.setOutput('translated_title', title); + core.setOutput('translated_slug', slug); + + - name: Create translated post + run: | + SLUG="${{ steps.translate.outputs.translated_slug }}" + TITLE="${{ steps.translate.outputs.translated_title }}" + POST_DIR="posts/$SLUG" + + mkdir -p "$POST_DIR" + + # Copy and modify frontmatter + cat > "$POST_DIR/index.yaml" << YAML + title: $TITLE + slug: $SLUG + YAML + + # Copy content (in production, this would be translated) + cp "posts/${{ inputs.slug }}/content.mdoc" "$POST_DIR/content.mdoc" 2>/dev/null || true + + - name: Commit translated post + run: | + git config user.name "keystatic-bot" + git config user.email "bot@keystatic.com" + git add "posts/${{ steps.translate.outputs.translated_slug }}" + git commit -m "feat: Add ${{ inputs.language }} translation of ${{ inputs.slug }}" + git push diff --git a/packages/keystatic-workflows/examples/keystatic-config-example.ts b/packages/keystatic-workflows/examples/keystatic-config-example.ts new file mode 100644 index 000000000..8ccc2927f --- /dev/null +++ b/packages/keystatic-workflows/examples/keystatic-config-example.ts @@ -0,0 +1,150 @@ +/** + * Example: Keystatic config with GitHub Actions workflows + * + * Shows how to wire GitHub Actions workflows to Keystatic hooks and actions + * using registerActions, registerHooks, useWorkflow, and awaitWorkflow. + * + * Prerequisites: + * 1. Install: npm install @keystatic/core @keystatic/workflows + * 2. Add workflow YAML files to .github/workflows/ + * 3. Create the API route at app/api/workflows/route.ts (see api-routes/workflows-route.ts) + * 4. Set env vars: KEYSTATIC_GITHUB_OWNER, KEYSTATIC_GITHUB_REPO, GITHUB_TOKEN + */ + +import { + config, + collection, + fields, + registerActions, + registerHooks, +} from '@keystatic/core'; +import { useWorkflow, awaitWorkflow } from '@keystatic/workflows'; + +export default config({ + storage: { + kind: 'github', + repo: { owner: 'your-org', name: 'your-repo' }, + }, + collections: { + posts: collection({ + label: 'Posts', + slugField: 'title', + path: 'content/posts/*', + schema: { + title: fields.slug({ name: { label: 'Title' } }), + publishDate: fields.date({ label: 'Publish Date' }), + content: fields.markdoc({ label: 'Content' }), + }, + }), + }, +}); + +// --------------------------------------------------------------------------- +// Manual Actions — appear in the Actions (zap) dropdown on the editor toolbar +// --------------------------------------------------------------------------- + +registerActions({ collection: 'posts' }, [ + // Translate the post to Spanish via GitHub Actions + { + label: 'Translate to Spanish', + description: 'Duplicate and translate this post to Spanish', + handler: useWorkflow('translate-post.yml', { + input: ctx => ({ + slug: ctx.slug ?? '', + title: (ctx.data.title as string) ?? '', + language: 'es', + }), + }), + }, + + // Translate the post to French + { + label: 'Translate to French', + description: 'Duplicate and translate this post to French', + handler: useWorkflow('translate-post.yml', { + input: ctx => ({ + slug: ctx.slug ?? '', + title: (ctx.data.title as string) ?? '', + language: 'fr', + }), + }), + }, + + // Run a content audit manually + { + label: 'Run Content Audit', + description: 'Check SEO, quality, and publishing readiness', + handler: useWorkflow('content-audit.yml', { + input: ctx => ({ + slug: ctx.slug ?? '', + title: (ctx.data.title as string) ?? '', + has_content: ctx.data.content ? 'true' : 'false', + publish_date: (ctx.data.publishDate as string) ?? '', + }), + }), + }, + + // Generate an OG image + { + label: 'Generate OG Image', + description: 'Create a social media preview image', + handler: useWorkflow('generate-og-image.yml', { + input: ctx => ({ + slug: ctx.slug ?? '', + title: (ctx.data.title as string) ?? '', + }), + }), + }, + + // AI: Generate summary + { + label: 'AI: Generate Summary', + description: 'Create an AI-powered summary of this post', + handler: useWorkflow('ai-content-assistant.yml', { + input: ctx => ({ + slug: ctx.slug ?? '', + title: (ctx.data.title as string) ?? '', + task: 'summary', + }), + }), + }, + + // AI: Suggest SEO titles + { + label: 'AI: Suggest Titles', + description: 'Get AI-optimized title suggestions', + handler: useWorkflow('ai-content-assistant.yml', { + input: ctx => ({ + slug: ctx.slug ?? '', + title: (ctx.data.title as string) ?? '', + task: 'suggest-titles', + }), + }), + }, +]); + +// --------------------------------------------------------------------------- +// Automatic Hooks — fire on content lifecycle events +// --------------------------------------------------------------------------- + +registerHooks({ collection: 'posts' }, { + // Validate title before saving (runs client-side, no GitHub Actions needed) + beforeSave: [ + async ctx => { + const title = ctx.data.title as string | undefined; + if (title && title.trim().length < 3) { + return { cancel: true, reason: 'Title must be at least 3 characters' }; + } + }, + ], + + // After save: auto-generate OG image via GitHub Actions (fire-and-forget) + afterSave: [ + useWorkflow('generate-og-image.yml', { + input: ctx => ({ + slug: ctx.slug ?? '', + title: (ctx.data.title as string) ?? '', + }), + }), + ], +}); diff --git a/packages/keystatic-workflows/jest.config.js b/packages/keystatic-workflows/jest.config.js new file mode 100644 index 000000000..136dd8d4f --- /dev/null +++ b/packages/keystatic-workflows/jest.config.js @@ -0,0 +1,11 @@ +/** @type {import('@jest/types').Config.InitialOptions} */ +const config = { + displayName: 'keystatic-workflows', + testEnvironment: 'node', + clearMocks: true, + transform: { + '^.+\\.[tj]sx?$': ['babel-jest', { rootMode: 'upward' }], + }, +}; + +module.exports = config; diff --git a/packages/keystatic-workflows/package.json b/packages/keystatic-workflows/package.json new file mode 100644 index 000000000..546410429 --- /dev/null +++ b/packages/keystatic-workflows/package.json @@ -0,0 +1,30 @@ +{ + "name": "@keystatic/workflows", + "version": "0.1.0", + "description": "GitHub Actions workflow adapter for Keystatic hooks and actions", + "license": "MIT", + "main": "dist/keystatic-workflows.cjs.js", + "module": "dist/keystatic-workflows.esm.js", + "exports": { + ".": { + "types": { + "import": "./dist/keystatic-workflows.cjs.mjs", + "default": "./dist/keystatic-workflows.cjs.js" + }, + "module": "./dist/keystatic-workflows.esm.js", + "import": "./dist/keystatic-workflows.cjs.mjs", + "default": "./dist/keystatic-workflows.cjs.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist" + ], + "peerDependencies": { + "@keystatic/core": ">=0.5.0" + }, + "devDependencies": { + "@keystatic/core": "workspace:*", + "typescript": "^5.5.3" + } +} diff --git a/packages/keystatic-workflows/src/__tests__/index.test.ts b/packages/keystatic-workflows/src/__tests__/index.test.ts new file mode 100644 index 000000000..4b813db97 --- /dev/null +++ b/packages/keystatic-workflows/src/__tests__/index.test.ts @@ -0,0 +1,133 @@ +/** @jest-environment node */ +import { expect, test, jest, beforeEach } from '@jest/globals'; +import { useWorkflow, awaitWorkflow } from '../index'; + +type HookContext = { + event: string; + trigger: 'event' | 'manual'; + collection?: string; + singleton?: string; + slug?: string; + data: Record; + previousData?: Record; + storage: { kind: 'local' | 'github' | 'cloud' }; +}; + +function makeContext(overrides: Partial = {}): HookContext { + return { + event: 'beforeSave', + trigger: 'event', + collection: 'posts', + slug: 'hello', + data: { title: 'Hello' }, + storage: { kind: 'github' }, + ...overrides, + } as HookContext; +} + +// Mock fetch globally +const mockFetch = jest.fn(); +beforeEach(() => { + mockFetch.mockReset(); + (globalThis as any).fetch = mockFetch; +}); + +test('useWorkflow: dispatches workflow via API endpoint', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ run_url: 'https://github.com/...', status: 'queued' }), { status: 200 }) + ); + + const hook = useWorkflow('translate-post.yml', { + input: ctx => ({ slug: ctx.slug ?? '', language: 'es' }), + }); + + const result = await (hook as any)(makeContext()); + + expect(mockFetch).toHaveBeenCalledWith('/api/workflows', expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + workflow: 'translate-post.yml', + input: { slug: 'hello', language: 'es' }, + wait: false, + timeout: undefined, + pollInterval: undefined, + }), + })); + expect(result).toHaveProperty('message'); +}); + +test('useWorkflow: returns error on API failure', async () => { + mockFetch.mockResolvedValue( + new Response('Internal Server Error', { status: 500 }) + ); + + const hook = useWorkflow('bad-workflow.yml'); + const result = await (hook as any)(makeContext()); + + expect(result).toHaveProperty('error'); + expect((result as any).error).toContain('Workflow failed'); +}); + +test('useWorkflow: uses custom formatResult', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ custom: 'data' }), { status: 200 }) + ); + + const hook = useWorkflow('my-workflow.yml', { + formatResult: (r) => ({ message: `Got: ${(r as any).custom}` }), + }); + + const result = await (hook as any)(makeContext()); + expect(result).toEqual({ message: 'Got: data' }); +}); + +test('awaitWorkflow: dispatches with wait=true', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ conclusion: 'success' }), { status: 200 }) + ); + + const hook = awaitWorkflow('content-audit.yml', { timeout: 60000 }); + await (hook as any)(makeContext()); + + const body = JSON.parse(mockFetch.mock.calls[0][1]?.body as string); + expect(body.wait).toBe(true); + expect(body.timeout).toBe(60000); +}); + +test('awaitWorkflow.then: transforms result to cancel', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ passed: false, score: 40 }), { status: 200 }) + ); + + const hook = awaitWorkflow('content-audit.yml').then(result => { + if (!(result as any).passed) return { cancel: true as const, reason: 'Audit failed' }; + }); + + const hookResult = await (hook as any)(makeContext()); + expect(hookResult).toEqual({ cancel: true, reason: 'Audit failed' }); +}); + +test('awaitWorkflow.then: returns void when check passes', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({ passed: true, score: 95 }), { status: 200 }) + ); + + const hook = awaitWorkflow('content-audit.yml').then(result => { + if (!(result as any).passed) return { cancel: true as const, reason: 'Audit failed' }; + }); + + const hookResult = await (hook as any)(makeContext()); + expect(hookResult).toBeUndefined(); +}); + +test('useWorkflow: sends empty input when no mapper provided', async () => { + mockFetch.mockResolvedValue( + new Response(JSON.stringify({}), { status: 200 }) + ); + + const hook = useWorkflow('simple.yml'); + await (hook as any)(makeContext()); + + const body = JSON.parse(mockFetch.mock.calls[0][1]?.body as string); + expect(body.input).toEqual({}); +}); diff --git a/packages/keystatic-workflows/src/index.ts b/packages/keystatic-workflows/src/index.ts new file mode 100644 index 000000000..31bd3a55c --- /dev/null +++ b/packages/keystatic-workflows/src/index.ts @@ -0,0 +1,170 @@ +import type { + BeforeHook, + AfterHook, + HookContext, + AfterHookContext, + ActionContext, + ActionResult, + BeforeHookResult, +} from '@keystatic/core'; + +// -- Types ------------------------------------------------------------------- + +type WorkflowOptions = { + /** Map the hook/action context to workflow_dispatch inputs */ + input?: (ctx: HookContext | ActionContext) => Record; + /** Custom formatter for the toast notification */ + formatResult?: (result: unknown) => ActionResult; + /** + * API endpoint that dispatches the GitHub Actions workflow. + * Defaults to '/api/workflows'. + */ + endpoint?: string; +}; + +type AwaitWorkflowOptions = WorkflowOptions & { + /** Milliseconds to wait for the workflow run to complete. Default: 120000 (2 min) */ + timeout?: number; + /** Polling interval in ms when waiting for completion. Default: 3000 */ + pollInterval?: number; +}; + +// -- Internal ---------------------------------------------------------------- + +async function callEndpoint( + endpoint: string, + workflowId: string, + input: Record, + options?: { wait?: boolean; timeout?: number; pollInterval?: number } +): Promise { + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + workflow: workflowId, + input, + wait: options?.wait ?? false, + timeout: options?.timeout, + pollInterval: options?.pollInterval, + }), + }); + + if (!response.ok) { + const text = await response.text().catch(() => 'Unknown error'); + throw new Error(`Workflow "${workflowId}" failed: ${text}`); + } + + return response.json(); +} + +function defaultFormatResult(workflowId: string, result: unknown): ActionResult { + if (result && typeof result === 'object') { + const r = result as Record; + + if ('html_url' in r && 'status' in r) { + // GitHub Actions run response + const status = r.conclusion ?? r.status; + return { message: `Workflow "${workflowId}" ${status}` }; + } + if ('message' in r) { + return { message: r.message as string }; + } + if ('run_url' in r) { + return { message: `Workflow started — view run at GitHub` }; + } + } + + return { message: `Workflow "${workflowId}" dispatched` }; +} + +// -- Public API -------------------------------------------------------------- + +/** + * Dispatch a GitHub Actions workflow (fire-and-forget). + * + * Triggers the workflow via the API endpoint and returns immediately + * with a confirmation toast. Does not wait for the run to complete. + * + * @param workflowId - The workflow filename (e.g. 'translate-post.yml') + * or workflow ID. Must match a .github/workflows/ file in the repo. + * @param options - Input mapping, endpoint, and result formatting. + */ +export function useWorkflow( + workflowId: string, + options?: WorkflowOptions +): BeforeHook & AfterHook & ((ctx: ActionContext) => Promise) { + const endpoint = options?.endpoint ?? '/api/workflows'; + + const handler = async (ctx: HookContext | AfterHookContext | ActionContext) => { + const input = options?.input ? options.input(ctx) : {}; + + try { + const result = await callEndpoint(endpoint, workflowId, input); + if (options?.formatResult) { + return options.formatResult(result); + } + return defaultFormatResult(workflowId, result); + } catch (err) { + console.error('[keystatic/workflows] dispatch failed:', err); + return { error: `Workflow failed: ${(err as Error).message}` } as ActionResult; + } + }; + return handler as any; +} + +/** + * Dispatch a GitHub Actions workflow and wait for it to complete. + * + * Triggers the workflow, then polls the GitHub API until the run finishes + * or the timeout is reached. Use for before* hooks where the result + * determines whether to proceed. + * + * Chain `.then()` to transform the result into a BeforeHookResult. + * + * @param workflowId - The workflow filename (e.g. 'content-audit.yml') + * @param options - Input mapping, timeout, polling interval, endpoint. + */ +export function awaitWorkflow( + workflowId: string, + options?: AwaitWorkflowOptions +): ThenableHook { + const endpoint = options?.endpoint ?? '/api/workflows'; + const timeoutMs = options?.timeout ?? 120_000; + const pollInterval = options?.pollInterval ?? 3_000; + + const callWorkflow = async (ctx: HookContext) => { + const input = options?.input ? options.input(ctx) : {}; + + return Promise.race([ + callEndpoint(endpoint, workflowId, input, { + wait: true, + timeout: timeoutMs, + pollInterval, + }), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Workflow timed out')), timeoutMs + 5_000) + ), + ]); + }; + + const handler: BeforeHook = async (ctx: HookContext) => { + const result = await callWorkflow(ctx); + return result as BeforeHookResult | void; + }; + + (handler as ThenableHook).then = ( + onResult: (result: unknown) => BeforeHookResult | void + ): BeforeHook => { + return async (ctx: HookContext) => { + const result = await callWorkflow(ctx); + return onResult(result); + }; + }; + + return handler as ThenableHook; +} + +/** A BeforeHook that also supports .then() for result transformation */ +export type ThenableHook = BeforeHook & { + then(onResult: (result: unknown) => BeforeHookResult | void): BeforeHook; +}; diff --git a/packages/keystatic-workflows/tsconfig.json b/packages/keystatic-workflows/tsconfig.json new file mode 100644 index 000000000..2e911da16 --- /dev/null +++ b/packages/keystatic-workflows/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "react-jsx", + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/packages/keystatic/src/app/ItemPage.tsx b/packages/keystatic/src/app/ItemPage.tsx index 7da56b782..2ec16870b 100644 --- a/packages/keystatic/src/app/ItemPage.tsx +++ b/packages/keystatic/src/app/ItemPage.tsx @@ -92,6 +92,8 @@ import { ErrorBoundary } from './error-boundary'; import { copyEntryToClipboard, getPastedEntry } from './entry-clipboard'; import { setValueToPreviewProps } from '../form/get-value'; import { toastQueue } from '@keystar/ui/toast'; +import { ActionButtons } from './action-buttons'; +import { useActions } from './useActions'; type ItemPageProps = { collection: string; @@ -259,6 +261,10 @@ function ItemPageInner( onReset={props.onReset} viewHref={viewHref} previewHref={previewHref} + collection={collection} + itemSlug={itemSlug} + state={props.state} + config={config} /> } {...props} @@ -552,6 +558,10 @@ function HeaderActions(props: { onPaste: () => void; previewHref?: string; viewHref?: string; + collection: string; + itemSlug: string; + state: Record; + config: Config; }) { let { formID, @@ -564,11 +574,22 @@ function HeaderActions(props: { onPaste, previewHref, viewHref, + collection, + itemSlug, + state, + config, } = props; const isBelowDesktop = useMediaQuery(breakpointQueries.below.desktop); const stringFormatter = useLocalizedStringFormatter(l10nMessages); const [deleteAlertIsOpen, setDeleteAlertOpen] = useState(false); const [duplicateAlertIsOpen, setDuplicateAlertOpen] = useState(false); + + const visibleActions = useActions({ + collection, + slug: itemSlug, + data: state as Record, + }); + const menuActions = useMemo(() => { type ActionType = { icon: ReactElement; @@ -664,6 +685,14 @@ function HeaderActions(props: { {indicatorElement} + } + storage={{ kind: config.storage.kind }} + onUpdate={async () => {}} + /> , + }); + const formID = 'singleton-form'; const baseCommit = useBaseCommit(); @@ -227,6 +234,13 @@ function SingletonPageInner( )} + } + storage={{ kind: props.config.storage.kind }} + onUpdate={async () => {}} + /> ; + storage: StorageInfo; + onUpdate: (data: Partial>) => Promise; +}; + +export function ActionButtons(props: ActionButtonsProps) { + const { actions, collection, singleton, slug, data, storage, onUpdate } = + props; + const [isRunning, setIsRunning] = useState(false); + + if (actions.length === 0) return null; + + const menuItems = actions.map((action, index) => ({ + key: `action-${index}`, + label: action.label, + description: action.description, + })); + + return ( + + + + + + Actions + + { + const index = menuItems.findIndex(i => i.key === key); + if (index < 0) return; + const action = actions[index]; + + setIsRunning(true); + + const ctx = buildActionContext({ + collection, + singleton, + slug, + data, + storage, + update: onUpdate, + }); + + action + .handler(ctx) + .then(result => { + if (result && 'message' in result) { + toastQueue.positive(result.message, { timeout: 5000 }); + } + if (result && 'error' in result) { + toastQueue.critical(result.error, { timeout: 5000 }); + } + }) + .catch(err => { + toastQueue.critical(`Action failed: ${err.message}`, { + timeout: 5000, + }); + }) + .finally(() => { + setIsRunning(false); + }); + }} + > + {(item: (typeof menuItems)[number]) => ( + + {item.label} + {item.description && ( + {item.description} + )} + + )} + + + ); +} diff --git a/packages/keystatic/src/app/updating.tsx b/packages/keystatic/src/app/updating.tsx index 4ca1ee7b9..3aa4729da 100644 --- a/packages/keystatic/src/app/updating.tsx +++ b/packages/keystatic/src/app/updating.tsx @@ -29,6 +29,7 @@ import { createUrqlClient } from './provider'; import { serializeProps } from '../form/serialize-props'; import { scopeEntriesWithPathPrefix } from './shell/path-prefix'; import { base64Encode } from '#base64'; +import { useHookExecutor } from './useHookExecutor'; const textEncoder = new TextEncoder(); @@ -133,11 +134,26 @@ export function useUpsertItem(args: { const repoInfo = useRepoInfo(); const appSlug = useContext(AppSlugContext); const unscopedTreeData = useCurrentUnscopedTree(); + const hookExecutor = useHookExecutor(); return [ state, async (override?: { sha: string; branch: string }): Promise => { try { + const hookEvent = args.initialFiles === undefined ? 'beforeCreate' : 'beforeSave'; + const afterEvent = args.initialFiles === undefined ? 'afterCreate' : 'afterSave'; + + const beforeResult = await hookExecutor.executeBefore({ + event: hookEvent, + slug: args.slug?.value, + data: args.state as Record, + }); + + if (!beforeResult.proceed) { + setState({ kind: 'error', error: new Error(beforeResult.reason ?? 'Operation cancelled by hook') }); + return false; + } + const unscopedTree = unscopedTreeData.kind === 'loaded' ? unscopedTreeData.data.tree @@ -299,6 +315,11 @@ export function useUpsertItem(args: { } const target = result.data?.createCommitOnBranch?.ref?.target; if (target) { + hookExecutor.executeAfter({ + event: afterEvent, + slug: args.slug?.value, + data: beforeResult.data, + }); setState({ kind: 'updated' }); return true; } @@ -324,6 +345,11 @@ export function useUpsertItem(args: { const newTree: TreeEntry[] = await res.json(); const { tree } = await hydrateTreeCacheWithEntries(newTree); setTreeSha(await treeSha(tree)); + hookExecutor.executeAfter({ + event: afterEvent, + slug: args.slug?.value, + data: beforeResult.data, + }); setState({ kind: 'updated' }); return true; } @@ -380,11 +406,23 @@ export function useDeleteItem(args: { const repoInfo = useRepoInfo(); const appSlug = useContext(AppSlugContext); const unscopedTreeData = useCurrentUnscopedTree(); + const hookExecutor = useHookExecutor(); return [ state, async () => { try { + const beforeResult = await hookExecutor.executeBefore({ + event: 'beforeDelete', + slug: args.basePath.split('/').pop(), + data: {}, + }); + + if (!beforeResult.proceed) { + setState({ kind: 'error', error: new Error(beforeResult.reason ?? 'Delete cancelled by hook') }); + return false; + } + const unscopedTree = unscopedTreeData.kind === 'loaded' ? unscopedTreeData.data.tree @@ -440,6 +478,11 @@ export function useDeleteItem(args: { if (error) { throw error; } + hookExecutor.executeAfter({ + event: 'afterDelete', + slug: args.basePath.split('/').pop(), + data: {}, + }); setState({ kind: 'updated' }); return true; } else { @@ -460,6 +503,11 @@ export function useDeleteItem(args: { const newTree: TreeEntry[] = await res.json(); const { tree } = await hydrateTreeCacheWithEntries(newTree); setTreeSha(await treeSha(tree)); + hookExecutor.executeAfter({ + event: 'afterDelete', + slug: args.basePath.split('/').pop(), + data: {}, + }); setState({ kind: 'updated' }); return true; } diff --git a/packages/keystatic/src/app/useActions.ts b/packages/keystatic/src/app/useActions.ts new file mode 100644 index 000000000..9eee78274 --- /dev/null +++ b/packages/keystatic/src/app/useActions.ts @@ -0,0 +1,21 @@ +import { useMemo } from 'react'; +import { getActions } from '../hooks'; +import type { Action } from '../hooks'; + +export function useActions(params: { + collection?: string; + singleton?: string; + slug?: string; + data: Record; +}): Action[] { + return useMemo(() => { + const actions = getActions(params.collection, params.singleton); + + return actions.filter(action => { + if (action.when?.match) { + return action.when.match({ slug: params.slug, data: params.data }); + } + return true; + }); + }, [params.collection, params.singleton, params.slug, params.data]); +} diff --git a/packages/keystatic/src/app/useHookExecutor.ts b/packages/keystatic/src/app/useHookExecutor.ts new file mode 100644 index 000000000..56e0cb5b2 --- /dev/null +++ b/packages/keystatic/src/app/useHookExecutor.ts @@ -0,0 +1,94 @@ +import { useCallback } from 'react'; +import { useConfig } from './shell/context'; +import { toastQueue } from '@keystar/ui/toast'; +import { + executeBeforeHooks, + executeAfterHooks, + resolveHooks, + buildHookContext, + buildAfterHookContext, + getHooks, + getGlobalHooks, +} from '../hooks'; +import type { HookEvent, BeforeHook, AfterHook, StorageInfo } from '../hooks'; + +type ExecuteHooksParams = { + event: HookEvent; + collection?: string; + singleton?: string; + slug?: string; + data: Record; + previousData?: Record; + update?: (data: Partial>) => Promise; +}; + +const eventLabels: Record = { + beforeCreate: 'Before create', + afterCreate: 'After create', + beforeSave: 'Before save', + afterSave: 'After save', + beforeDelete: 'Before delete', + afterDelete: 'After delete', +}; + +export function useHookExecutor() { + const config = useConfig(); + const storageInfo: StorageInfo = { kind: config.storage.kind }; + + const executeBefore = useCallback( + async (params: ExecuteHooksParams) => { + const resourceHooks = getHooks(params.collection, params.singleton); + const globalHooks = getGlobalHooks(); + const hooks = resolveHooks(globalHooks, resourceHooks, params.event) as BeforeHook[]; + if (hooks.length === 0) return { proceed: true as const, data: params.data }; + + const ctx = buildHookContext({ + event: params.event, + collection: params.collection, + singleton: params.singleton, + slug: params.slug, + data: params.data, + previousData: params.previousData, + storage: storageInfo, + }); + + const result = await executeBeforeHooks(hooks, ctx); + + if (!result.proceed && result.reason) { + toastQueue.critical(result.reason, { timeout: 5000 }); + } + + return result; + }, + [storageInfo] + ); + + const executeAfter = useCallback( + async (params: ExecuteHooksParams) => { + const resourceHooks = getHooks(params.collection, params.singleton); + const globalHooks = getGlobalHooks(); + const hooks = resolveHooks(globalHooks, resourceHooks, params.event) as AfterHook[]; + if (hooks.length === 0) return; + + const label = eventLabels[params.event]; + toastQueue.info(`${label} hooks running…`, { timeout: 3000 }); + + const noopUpdate = async () => {}; + const ctx = buildAfterHookContext({ + event: params.event, + collection: params.collection, + singleton: params.singleton, + slug: params.slug, + data: params.data, + previousData: params.previousData, + storage: storageInfo, + update: params.update ?? noopUpdate, + }); + + await executeAfterHooks(hooks, ctx); + }, + [storageInfo] + ); + + return { executeBefore, executeAfter }; +} diff --git a/packages/keystatic/src/config.tsx b/packages/keystatic/src/config.tsx index 4c95368f3..903f9dc29 100644 --- a/packages/keystatic/src/config.tsx +++ b/packages/keystatic/src/config.tsx @@ -4,6 +4,7 @@ import { ReactElement } from 'react'; import { ComponentSchema, FormField, SlugFormField } from './form/api'; import type { Locale } from './app/l10n/locales'; import { RepoConfig } from './app/repo-config'; +import { HooksConfig, Action } from './hooks/types'; // Common // ---------------------------------------------------------------------------- @@ -31,6 +32,8 @@ export type Collection< parseSlugForSort?: (slug: string) => string | number; slugField: SlugField; schema: Schema; + hooks?: HooksConfig; + actions?: Action[]; }; export type Singleton> = { @@ -40,12 +43,15 @@ export type Singleton> = { format?: Format; previewUrl?: string; schema: Schema; + hooks?: HooksConfig; + actions?: Action[]; }; type CommonConfig = { locale?: Locale; cloud?: { project: string }; ui?: UserInterface; + hooks?: HooksConfig; }; type CommonRemoteStorageConfig = { @@ -193,12 +199,12 @@ export function collection< : never; }[keyof Schema][]; } -): Collection { +) { return collection; } export function singleton>( collection: Singleton -): Singleton { +) { return collection; } diff --git a/packages/keystatic/src/hooks/__tests__/context.test.ts b/packages/keystatic/src/hooks/__tests__/context.test.ts new file mode 100644 index 000000000..0122dfd75 --- /dev/null +++ b/packages/keystatic/src/hooks/__tests__/context.test.ts @@ -0,0 +1,72 @@ +/** @jest-environment node */ +import { expect, test, jest } from '@jest/globals'; +import { buildHookContext, buildAfterHookContext, buildActionContext } from '../context'; + +test('buildHookContext: creates context for collection save', () => { + const ctx = buildHookContext({ + event: 'afterSave', + collection: 'posts', + slug: 'hello', + data: { title: 'Hello' }, + storage: { kind: 'local' }, + }); + expect(ctx.event).toBe('afterSave'); + expect(ctx.trigger).toBe('event'); + expect(ctx.collection).toBe('posts'); + expect(ctx.slug).toBe('hello'); + expect(ctx.data).toEqual({ title: 'Hello' }); + expect(ctx.storage).toEqual({ kind: 'local' }); + expect(ctx.singleton).toBeUndefined(); +}); + +test('buildHookContext: creates context for singleton save', () => { + const ctx = buildHookContext({ + event: 'beforeSave', + singleton: 'settings', + data: { siteName: 'My Site' }, + storage: { kind: 'github' }, + }); + expect(ctx.singleton).toBe('settings'); + expect(ctx.collection).toBeUndefined(); + expect(ctx.slug).toBeUndefined(); +}); + +test('buildHookContext: includes previousData when provided', () => { + const ctx = buildHookContext({ + event: 'beforeSave', + collection: 'posts', + slug: 'hello', + data: { title: 'Updated' }, + previousData: { title: 'Original' }, + storage: { kind: 'local' }, + }); + expect(ctx.previousData).toEqual({ title: 'Original' }); +}); + +test('buildAfterHookContext: includes update function', () => { + const updateFn = jest.fn<(data: Partial>) => Promise>(); + const ctx = buildAfterHookContext({ + event: 'afterSave', + collection: 'posts', + slug: 'hello', + data: { title: 'Hello' }, + storage: { kind: 'local' }, + update: updateFn, + }); + expect(ctx.update).toBe(updateFn); + expect(ctx.trigger).toBe('event'); +}); + +test('buildActionContext: creates manual trigger context with update fn', () => { + const updateFn = jest.fn<(data: Partial>) => Promise>(); + const ctx = buildActionContext({ + collection: 'posts', + slug: 'hello', + data: { title: 'Hello' }, + storage: { kind: 'local' }, + update: updateFn, + }); + expect(ctx.trigger).toBe('manual'); + expect(ctx.collection).toBe('posts'); + expect(ctx.update).toBe(updateFn); +}); diff --git a/packages/keystatic/src/hooks/__tests__/executor.test.ts b/packages/keystatic/src/hooks/__tests__/executor.test.ts new file mode 100644 index 000000000..d33b52e41 --- /dev/null +++ b/packages/keystatic/src/hooks/__tests__/executor.test.ts @@ -0,0 +1,122 @@ +/** @jest-environment node */ +import { expect, test, jest } from '@jest/globals'; +import { executeBeforeHooks, executeAfterHooks, resolveHooks } from '../executor'; +import { HookContext, AfterHookContext, BeforeHook, AfterHook, HooksConfig } from '../types'; + +function makeContext(overrides: Partial = {}): HookContext { + return { + event: 'beforeSave', + trigger: 'event', + collection: 'posts', + slug: 'hello-world', + data: { title: 'Hello World' }, + storage: { kind: 'local' }, + ...overrides, + }; +} + +function makeAfterContext(overrides: Partial = {}): AfterHookContext { + return { + ...makeContext({ event: 'afterSave' }), + update: jest.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +test('executeBeforeHooks: returns proceed with original data when no hooks', async () => { + const ctx = makeContext(); + const result = await executeBeforeHooks([], ctx); + expect(result).toEqual({ proceed: true, data: { title: 'Hello World' } }); +}); + +test('executeBeforeHooks: passes through when hook returns void', async () => { + const hook: BeforeHook = async () => {}; + const ctx = makeContext(); + const result = await executeBeforeHooks([hook], ctx); + expect(result).toEqual({ proceed: true, data: { title: 'Hello World' } }); +}); + +test('executeBeforeHooks: cancels when hook returns cancel', async () => { + const hook: BeforeHook = async () => ({ cancel: true, reason: 'Not allowed' }); + const ctx = makeContext(); + const result = await executeBeforeHooks([hook], ctx); + expect(result).toEqual({ proceed: false, reason: 'Not allowed' }); +}); + +test('executeBeforeHooks: stops at first cancellation', async () => { + const hook1: BeforeHook = async () => ({ cancel: true, reason: 'First' }); + const hook2 = jest.fn(); + const ctx = makeContext(); + await executeBeforeHooks([hook1, hook2], ctx); + expect(hook2).not.toHaveBeenCalled(); +}); + +test('executeBeforeHooks: passes modified data to next hook', async () => { + const hook1: BeforeHook = async () => ({ data: { title: 'Modified' } }); + const hook2 = jest.fn().mockResolvedValue(undefined); + const ctx = makeContext(); + const result = await executeBeforeHooks([hook1, hook2], ctx); + expect(hook2).toHaveBeenCalledWith( + expect.objectContaining({ data: { title: 'Modified' } }) + ); + expect(result).toEqual({ proceed: true, data: { title: 'Modified' } }); +}); + +test('executeBeforeHooks: runs hooks sequentially', async () => { + const order: number[] = []; + const hook1: BeforeHook = async () => { order.push(1); }; + const hook2: BeforeHook = async () => { order.push(2); }; + await executeBeforeHooks([hook1, hook2], makeContext()); + expect(order).toEqual([1, 2]); +}); + +test('executeAfterHooks: runs all hooks in parallel', async () => { + const started: number[] = []; + const finished: number[] = []; + const hook1: AfterHook = async () => { + started.push(1); + await new Promise(r => setTimeout(r, 10)); + finished.push(1); + }; + const hook2: AfterHook = async () => { + started.push(2); + await new Promise(r => setTimeout(r, 10)); + finished.push(2); + }; + await executeAfterHooks([hook1, hook2], makeAfterContext()); + expect(started).toEqual([1, 2]); + expect(finished.length).toBe(2); +}); + +test('executeAfterHooks: does not throw if one hook fails', async () => { + const hook1: AfterHook = async () => { throw new Error('boom'); }; + const hook2 = jest.fn().mockResolvedValue(undefined); + await executeAfterHooks([hook1, hook2], makeAfterContext()); + expect(hook2).toHaveBeenCalled(); +}); + +test('resolveHooks: merges global and resource hooks in order', () => { + const globalHook: BeforeHook = async () => {}; + const resourceHook: BeforeHook = async () => {}; + const globalHooks: HooksConfig = { beforeSave: [globalHook] }; + const resourceHooks: HooksConfig = { beforeSave: [resourceHook] }; + const result = resolveHooks(globalHooks, resourceHooks, 'beforeSave'); + expect(result).toEqual([globalHook, resourceHook]); +}); + +test('resolveHooks: returns empty array when no hooks defined', () => { + const result = resolveHooks(undefined, undefined, 'afterSave'); + expect(result).toEqual([]); +}); + +test('resolveHooks: handles only global hooks', () => { + const hook: BeforeHook = async () => {}; + const result = resolveHooks({ beforeSave: [hook] }, undefined, 'beforeSave'); + expect(result).toEqual([hook]); +}); + +test('resolveHooks: handles only resource hooks', () => { + const hook: BeforeHook = async () => {}; + const result = resolveHooks(undefined, { beforeSave: [hook] }, 'beforeSave'); + expect(result).toEqual([hook]); +}); diff --git a/packages/keystatic/src/hooks/context.ts b/packages/keystatic/src/hooks/context.ts new file mode 100644 index 000000000..79b5547e4 --- /dev/null +++ b/packages/keystatic/src/hooks/context.ts @@ -0,0 +1,58 @@ +import { + HookContext, + AfterHookContext, + ActionContext, + HookEvent, + StorageInfo, +} from './types'; + +export function buildHookContext(params: { + event: HookEvent; + collection?: string; + singleton?: string; + slug?: string; + data: Record; + previousData?: Record; + storage: StorageInfo; +}): HookContext { + return { + event: params.event, + trigger: 'event', + collection: params.collection, + singleton: params.singleton, + slug: params.slug, + data: params.data, + previousData: params.previousData, + storage: params.storage, + }; +} + +export function buildAfterHookContext( + params: Parameters[0] & { + update: (data: Partial>) => Promise; + } +): AfterHookContext { + return { + ...buildHookContext(params), + update: params.update, + }; +} + +export function buildActionContext(params: { + collection?: string; + singleton?: string; + slug?: string; + data: Record; + storage: StorageInfo; + update: (data: Partial>) => Promise; +}): ActionContext { + return { + trigger: 'manual', + collection: params.collection, + singleton: params.singleton, + slug: params.slug, + data: params.data, + storage: params.storage, + update: params.update, + }; +} diff --git a/packages/keystatic/src/hooks/executor.ts b/packages/keystatic/src/hooks/executor.ts new file mode 100644 index 000000000..f13e65ea6 --- /dev/null +++ b/packages/keystatic/src/hooks/executor.ts @@ -0,0 +1,63 @@ +import { + BeforeHook, + AfterHook, + HookContext, + AfterHookContext, + BeforeHookResult, + HooksConfig, + HookEvent, +} from './types'; + +type BeforeHooksResult = + | { proceed: true; data: Record } + | { proceed: false; reason?: string }; + +export async function executeBeforeHooks( + hooks: BeforeHook[], + context: HookContext +): Promise { + let currentData = { ...context.data }; + + for (const hook of hooks) { + const result: BeforeHookResult | void = await hook({ + ...context, + data: currentData, + }); + + if (result === undefined || result === null) { + continue; + } + + if ('cancel' in result && result.cancel) { + return { proceed: false, reason: result.reason }; + } + + if ('data' in result) { + currentData = { ...result.data }; + } + } + + return { proceed: true, data: currentData }; +} + +export async function executeAfterHooks( + hooks: AfterHook[], + context: AfterHookContext +): Promise { + const results = hooks.map(hook => + hook(context).catch(err => { + console.error('[keystatic] after hook failed:', err); + }) + ); + await Promise.allSettled(results); +} + +export function resolveHooks( + globalHooks: HooksConfig | undefined, + resourceHooks: HooksConfig | undefined, + event: HookEvent +): BeforeHook[] | AfterHook[] { + const global = globalHooks?.[event] ?? []; + const resource = resourceHooks?.[event] ?? []; + return [...global, ...resource]; +} diff --git a/packages/keystatic/src/hooks/index.ts b/packages/keystatic/src/hooks/index.ts new file mode 100644 index 000000000..c825be6b6 --- /dev/null +++ b/packages/keystatic/src/hooks/index.ts @@ -0,0 +1,24 @@ +export type { + HookEvent, + StorageInfo, + HookContext, + AfterHookContext, + BeforeHookResult, + BeforeHook, + AfterHook, + HooksConfig, + ActionContext, + ActionResult, + Action, +} from './types'; + +export { executeBeforeHooks, executeAfterHooks, resolveHooks } from './executor'; +export { buildHookContext, buildAfterHookContext, buildActionContext } from './context'; +export { + registerActions, + registerHooks, + registerGlobalHooks, + getActions, + getHooks, + getGlobalHooks, +} from './registry'; diff --git a/packages/keystatic/src/hooks/registry.ts b/packages/keystatic/src/hooks/registry.ts new file mode 100644 index 000000000..ecbbbf30f --- /dev/null +++ b/packages/keystatic/src/hooks/registry.ts @@ -0,0 +1,58 @@ +import type { Action, HooksConfig } from './types'; + +type ResourceKey = string; // "collection:posts" or "singleton:settings" + +const actionsRegistry = new Map(); +const hooksRegistry = new Map(); +const globalHooks: { config?: HooksConfig } = {}; + +function resourceKey( + collection?: string, + singleton?: string +): ResourceKey | undefined { + if (collection) return `collection:${collection}`; + if (singleton) return `singleton:${singleton}`; + return undefined; +} + +export function registerActions( + resource: { collection?: string; singleton?: string }, + actions: Action[] +) { + const key = resourceKey(resource.collection, resource.singleton); + if (key) actionsRegistry.set(key, actions); +} + +export function registerHooks( + resource: { collection?: string; singleton?: string }, + hooks: HooksConfig +) { + const key = resourceKey(resource.collection, resource.singleton); + if (key) hooksRegistry.set(key, hooks); +} + +export function registerGlobalHooks(hooks: HooksConfig) { + globalHooks.config = hooks; +} + +export function getActions( + collection?: string, + singleton?: string +): Action[] { + const key = resourceKey(collection, singleton); + if (!key) return []; + return actionsRegistry.get(key) ?? []; +} + +export function getHooks( + collection?: string, + singleton?: string +): HooksConfig | undefined { + const key = resourceKey(collection, singleton); + if (!key) return undefined; + return hooksRegistry.get(key); +} + +export function getGlobalHooks(): HooksConfig | undefined { + return globalHooks.config; +} diff --git a/packages/keystatic/src/hooks/types.ts b/packages/keystatic/src/hooks/types.ts new file mode 100644 index 000000000..1e9abe309 --- /dev/null +++ b/packages/keystatic/src/hooks/types.ts @@ -0,0 +1,71 @@ +import { ReactElement } from 'react'; + +export type HookEvent = + | 'beforeCreate' + | 'afterCreate' + | 'beforeSave' + | 'afterSave' + | 'beforeDelete' + | 'afterDelete'; + +export type StorageInfo = { kind: 'local' | 'github' | 'cloud' }; + +export type HookContext> = { + event: HookEvent; + trigger: 'event' | 'manual'; + collection?: string; + singleton?: string; + slug?: string; + data: Schema; + previousData?: Schema; + storage: StorageInfo; +}; + +export type BeforeHookResult = + | void + | { cancel: true; reason?: string } + | { data: Record }; + +export type AfterHookContext> = + HookContext & { + update(data: Partial): Promise; + }; + +export type BeforeHook> = ( + ctx: HookContext +) => Promise; + +export type AfterHook> = ( + ctx: AfterHookContext +) => Promise; + +export type HooksConfig> = { + beforeCreate?: BeforeHook[]; + afterCreate?: AfterHook[]; + beforeSave?: BeforeHook[]; + afterSave?: AfterHook[]; + beforeDelete?: BeforeHook[]; + afterDelete?: AfterHook[]; +}; + +export type ActionContext> = { + trigger: 'manual'; + collection?: string; + singleton?: string; + slug?: string; + data: Schema; + storage: StorageInfo; + update(data: Partial): Promise; +}; + +export type ActionResult = void | { message: string } | { error: string }; + +export type Action> = { + label: string; + description?: string; + icon?: ReactElement; + handler: (ctx: ActionContext) => Promise; + when?: { + match?: (ctx: { slug?: string; data: Schema }) => boolean; + }; +}; diff --git a/packages/keystatic/src/index.ts b/packages/keystatic/src/index.ts index 6e1a5ac81..75494b111 100644 --- a/packages/keystatic/src/index.ts +++ b/packages/keystatic/src/index.ts @@ -17,3 +17,22 @@ export type { LocalConfig, Singleton, } from './config'; + +export type { + HookEvent, + HookContext, + AfterHookContext, + BeforeHookResult, + BeforeHook, + AfterHook, + HooksConfig, + ActionContext, + ActionResult, + Action, +} from './hooks'; + +export { + registerActions, + registerHooks, + registerGlobalHooks, +} from './hooks'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c36561ad..34787edc6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -615,7 +615,7 @@ importers: dependencies: '@astrojs/node': specifier: ^9.0.2 - version: 9.0.2(astro@5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.34.6)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3)) + version: 9.0.2(astro@5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.55.1)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3)) '@astrojs/react': specifier: ^4.2.0 version: 4.2.0(@types/node@25.0.3)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(terser@5.19.4)(tsx@4.8.2) @@ -636,7 +636,7 @@ importers: version: 19.0.3(@types/react@19.0.8) astro: specifier: ^5.2.5 - version: 5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.34.6)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3) + version: 5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.55.1)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3) direction: specifier: ^2.0.1 version: 2.0.1 @@ -684,10 +684,10 @@ importers: dependencies: '@astrojs/markdoc': specifier: ^0.12.9 - version: 0.12.9(@types/react@19.0.8)(astro@5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.34.6)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3))(react@19.0.0) + version: 0.12.9(@types/react@19.0.8)(astro@5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.55.1)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3))(react@19.0.0) '@astrojs/node': specifier: ^9.0.2 - version: 9.0.2(astro@5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.34.6)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3)) + version: 9.0.2(astro@5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.55.1)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3)) '@astrojs/react': specifier: ^4.2.0 version: 4.2.0(@types/node@25.0.3)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(terser@5.19.4)(tsx@4.8.2) @@ -696,7 +696,7 @@ importers: version: 3.2.1 '@astrojs/tailwind': specifier: ^6.0.0 - version: 6.0.0(astro@5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.34.6)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3))(tailwindcss@3.4.1(ts-node@10.9.1(@swc/core@1.3.82(@swc/helpers@0.5.15))(@types/node@25.0.3)(typescript@5.5.3)))(ts-node@10.9.1(@swc/core@1.3.82(@swc/helpers@0.5.15))(@types/node@25.0.3)(typescript@5.5.3)) + version: 6.0.0(astro@5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.55.1)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3))(tailwindcss@3.4.1(ts-node@10.9.1(@swc/core@1.3.82(@swc/helpers@0.5.15))(@types/node@25.0.3)(typescript@5.5.3)))(ts-node@10.9.1(@swc/core@1.3.82(@swc/helpers@0.5.15))(@types/node@25.0.3)(typescript@5.5.3)) '@keystatic/astro': specifier: workspace:^ version: link:../../packages/astro @@ -714,7 +714,7 @@ importers: version: 19.0.3(@types/react@19.0.8) astro: specifier: ^5.2.5 - version: 5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.34.6)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3) + version: 5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.55.1)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3) react: specifier: ^19.0.0 version: 19.0.0 @@ -1068,7 +1068,7 @@ importers: version: 2.4.3 astro: specifier: ^5.2.5 - version: 5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.34.6)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3) + version: 5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.55.1)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3) react: specifier: ^19.0.0 version: 19.0.0 @@ -1392,6 +1392,19 @@ importers: specifier: ^5.5.3 version: 5.5.3 + packages/keystatic-workflows: + dependencies: + workflow: + specifier: '>=0.1.0' + version: 2.0.6 + devDependencies: + '@keystatic/core': + specifier: workspace:* + version: link:../keystatic + typescript: + specifier: ^5.5.3 + version: 5.5.3 + packages/next: dependencies: '@babel/runtime': @@ -1446,7 +1459,7 @@ importers: dependencies: '@astrojs/markdoc': specifier: ^0.12.9 - version: 0.12.9(@types/react@19.0.8)(astro@5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.34.6)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3))(react@19.0.0) + version: 0.12.9(@types/react@19.0.8)(astro@5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.55.1)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3))(react@19.0.0) '@astrojs/react': specifier: ^4.2.0 version: 4.2.0(@types/node@25.0.3)(@types/react-dom@19.0.3(@types/react@19.0.8))(@types/react@19.0.8)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)(terser@5.19.4)(tsx@4.8.2) @@ -1464,7 +1477,7 @@ importers: version: 19.0.3(@types/react@19.0.8) astro: specifier: ^5.2.5 - version: 5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.34.6)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3) + version: 5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.55.1)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3) react: specifier: ^19.0.0 version: 19.0.0 @@ -6412,9 +6425,6 @@ packages: '@types/node@17.0.45': resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} - '@types/node@22.13.1': - resolution: {integrity: sha512-jK8uzQlrvXqEU91UxiK5J7pKHyzgnI1Qnl0QDHIgVGuolJhRb9EEl28Cj9b3rGR8B2lhFCtvIm5os8lFnO/1Ew==} - '@types/node@25.0.3': resolution: {integrity: sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA==} @@ -6928,6 +6938,14 @@ packages: engines: {'0': node >= 0.8.0} hasBin: true + ansi-regex@2.1.1: + resolution: {integrity: sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==} + engines: {node: '>=0.10.0'} + + ansi-regex@3.0.1: + resolution: {integrity: sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==} + engines: {node: '>=4'} + ansi-regex@4.1.1: resolution: {integrity: sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==} engines: {node: '>=6'} @@ -7105,6 +7123,9 @@ packages: async@2.6.4: resolution: {integrity: sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==} + async@3.2.3: + resolution: {integrity: sha512-spZRyzKL5l5BZQrr/6m/SqFdBN0q3OCI0f9rjfBzCMBIP4p75P620rR3gTmaksNOhmzgdxcaxdNfMy6anrbM0g==} + async@3.2.4: resolution: {integrity: sha512-iAB+JbDEGXhyIUavoDl9WP/Jj106Kz9DEn1DPgYw5ruDn0e3Wgi3sKFm55sASdGBNOQB8F59d9qQ7deqrHA8wQ==} @@ -7434,6 +7455,9 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camel-case@3.0.0: + resolution: {integrity: sha512-+MbKztAYHXPr1jNTSKQF52VpcFjwY5RkR7fxksV8Doo4KAYc5Fl4UJRgthBbTmEx8C54DqahhbLJkDwjI3PI/w==} + camel-case@4.1.2: resolution: {integrity: sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==} @@ -7488,6 +7512,9 @@ packages: resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + change-case@3.1.0: + resolution: {integrity: sha512-2AZp7uJZbYEzRPsFoa+ijKdvp9zsrnnt6+yFokfwEpeJm0xuJDVoxiRCAaTzyJND8GJkofo2IcKWaUZ/OECVzw==} + change-case@4.1.2: resolution: {integrity: sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==} @@ -7584,6 +7611,9 @@ packages: clipboard-copy@4.0.1: resolution: {integrity: sha512-wOlqdqziE/NNTUJsfSgXmBMIrYmfd5V0HCGsR8uAKHcg+h9NENWINcfRjtWGU77wDHC8B8ijV4hMTGYbrKovng==} + cliui@4.1.0: + resolution: {integrity: sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ==} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -7610,6 +7640,10 @@ packages: code-block-writer@10.1.1: resolution: {integrity: sha512-67ueh2IRGst/51p0n6FvPrnRjAGHY5F8xdjkgrYE7DDzpJe6qA07RYQ9VcoUeo5ATOjSOiWpSL3SWBRRbempMw==} + code-point-at@1.1.0: + resolution: {integrity: sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==} + engines: {node: '>=0.10.0'} + codemirror@5.65.15: resolution: {integrity: sha512-YC4EHbbwQeubZzxLl5G4nlbLc1T21QTrKGaOal/Pkm9dVDMZXMH7+ieSPEOZCtO9I68i8/oteJKOxzHC2zR+0g==} @@ -7646,6 +7680,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + colors@1.0.3: + resolution: {integrity: sha512-pFGrxThWcWQ2MsAz6RtgeWe4NK2kUE1WfsrvvlctdII745EW9I0yflqhe7++M5LEc7bV2c/9/5zc8sFcpL0Drw==} + engines: {node: '>=0.1.90'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -7731,6 +7769,9 @@ packages: console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + constant-case@2.0.0: + resolution: {integrity: sha512-eS0N9WwmjTqrOmR3o83F5vW8Z+9R1HnVz3xmzT2PMFug9ly+Au/fxRWlEBSb6LcZwspSsEn9Xs1uw9YgzAg1EQ==} + constant-case@3.0.4: resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==} @@ -7826,9 +7867,17 @@ packages: create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} + create-workflow-home@2.0.6: + resolution: {integrity: sha512-bWd/YNOkYrE66f28VcWSgfpf+vvB27+3XECq0jysmx4KzlxCTX/E1LSKgOmnVNkQgQIvgfeLByhRsGDFfZUD7g==} + hasBin: true + cross-spawn@5.1.0: resolution: {integrity: sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==} + cross-spawn@6.0.6: + resolution: {integrity: sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==} + engines: {node: '>=4.8'} + cross-spawn@7.0.3: resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} engines: {node: '>= 8'} @@ -7896,6 +7945,10 @@ packages: current-git-branch@1.1.0: resolution: {integrity: sha512-n5mwGZllLsFzxDPtTmadqGe4IIBPfqPbiIRX4xgFR9VK/Bx47U+94KiVkxSKAKN6/s43TlkztS2GZpgMKzwQ8A==} + cycle@1.0.3: + resolution: {integrity: sha512-TVF6svNzeQCOpjCqsy0/CSy8VgObG3wXusJ73xW2GbG5rGx7lC8zxDSURicsXI2UsGdi2L0QNRCi745/wUDvsA==} + engines: {node: '>=0.4.0'} + damerau-levenshtein@1.0.8: resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==} @@ -7962,6 +8015,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decimal.js@10.4.3: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} @@ -8219,6 +8276,9 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} + dot-case@2.1.1: + resolution: {integrity: sha512-HnM6ZlFqcajLsyudHq7LeeLDr2rFAVYtDv/hV5qchQEidSck8j9OPUsXY9KwJv/lHMtYlX4DjRQqwFYa+0r8Ug==} + dot-case@3.0.4: resolution: {integrity: sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==} @@ -9099,10 +9159,18 @@ packages: evp_bytestokey@1.0.3: resolution: {integrity: sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==} + execa@0.11.0: + resolution: {integrity: sha512-k5AR22vCt1DcfeiRixW46U5tMLtBg44ssdJM9PiXw3D8Bn5qyxFCSnKY/eR22y+ctFDGPqafpaXg2G4Emyua4A==} + engines: {node: '>=6'} + execa@0.6.3: resolution: {integrity: sha512-/teX3MDLFBdYUhRk8WCBYboIMUmqeizu0m9Z3YF3JWrbEh/SlZg00vLJSaAGWw3wrZ9tE0buNw79eaAPYhUuvg==} engines: {node: '>=4'} + execa@1.0.0: + resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==} + engines: {node: '>=6'} + execa@3.2.0: resolution: {integrity: sha512-kJJfVbI/lZE1PZYDI5VPxp8zXPO9rtxOkhpZ0jMKha56AI9y2gGVC6bkukStQf0ka5Rh15BA5m7cCCH4jmHqkw==} engines: {node: ^8.12.0 || >=9.7.0} @@ -9149,6 +9217,10 @@ packages: resolution: {integrity: sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==} hasBin: true + eyes@0.1.8: + resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} + engines: {node: '> 0.1.90'} + facepaint@1.2.1: resolution: {integrity: sha512-oNvBekbhsm/0PNSOWca5raHNAi6dG960Bx6LJgxDPNF59WpuspgQ17bN5MKwOr7JcFdQYc7StW3VZ28DBZLavQ==} @@ -9447,6 +9519,9 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-caller-file@1.0.3: + resolution: {integrity: sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -9485,6 +9560,10 @@ packages: resolution: {integrity: sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==} engines: {node: '>=4'} + get-stream@4.1.0: + resolution: {integrity: sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==} + engines: {node: '>=6'} + get-stream@5.2.0: resolution: {integrity: sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==} engines: {node: '>=8'} @@ -9693,6 +9772,9 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + header-case@1.0.1: + resolution: {integrity: sha512-i0q9mkOeSuhXw6bGgiQCCBgY/jlZuV/7dZXyZ9c6LcBrqwvT8eT719E9uxE5LiZftdl+z81Ugbg/VvXV4OJOeQ==} + header-case@2.0.4: resolution: {integrity: sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==} @@ -9916,6 +9998,10 @@ packages: resolution: {integrity: sha512-Y+R5hJrzs52QCG2laLn4udYVnxsfny9CpOhNhUvk/SSSVyF6T27FzRbF0sroPidSu3X8oEAkOn2K804mjpt6UQ==} engines: {node: '>= 0.4'} + interpret@1.4.0: + resolution: {integrity: sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==} + engines: {node: '>= 0.10'} + intersection-observer@0.12.2: resolution: {integrity: sha512-7m1vEcPCxXYI8HqnL8CKI6siDyD+eIWSwgB3DZA+ZTogxk9I4CDnj4wilt9x/+/QbHI4YG5YZNmC6458/e9Ktg==} @@ -9925,6 +10011,10 @@ packages: invariant@2.2.4: resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + invert-kv@2.0.0: + resolution: {integrity: sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==} + engines: {node: '>=4'} + ip@2.0.0: resolution: {integrity: sha512-WKa+XuLG1A1R0UWhl2+1XQSi+fZWMsYKffMZTTYsiZaUD8k2yDAj5atimTUD2TZkyCkNEeYE5NhFZmupOGtjYQ==} @@ -10023,6 +10113,14 @@ packages: is-finalizationregistry@1.0.2: resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==} + is-fullwidth-code-point@1.0.0: + resolution: {integrity: sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@2.0.0: + resolution: {integrity: sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==} + engines: {node: '>=4'} + is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} @@ -10068,6 +10166,9 @@ packages: resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} engines: {node: '>=8'} + is-lower-case@1.1.3: + resolution: {integrity: sha512-+5A1e/WJpLLXZEDlgz4G//WYSHyQBD32qa4Jd3Lw06qQlv3fJHnp3YIHjTQSGzHMgzmVKz2ZP3rBxTHkPw/lxA==} + is-map@2.0.2: resolution: {integrity: sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==} @@ -10165,6 +10266,9 @@ packages: resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} engines: {node: '>=10'} + is-upper-case@1.1.2: + resolution: {integrity: sha512-GQYSJMgfeAmVwh9ixyk888l7OIhNAGKtY6QA+IrWlu9MDTCaXmeozOZ2S9Knj7bQwBO/H6J2kb+pbyTUiMNbsw==} + is-weakmap@2.0.1: resolution: {integrity: sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==} @@ -10209,6 +10313,9 @@ packages: isomorphic.js@0.2.5: resolution: {integrity: sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==} + isstream@0.1.2: + resolution: {integrity: sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==} + istanbul-lib-coverage@3.2.0: resolution: {integrity: sha512-eOeJ5BHCmHYvQK7xt9GkdHuzuCGS1Y6g9Gvnx3Ym33fz/HpLRYxiS0wHNr+m/MBC8B647Xt608vCDEvhl9c6Mw==} engines: {node: '>=8'} @@ -10491,6 +10598,9 @@ packages: json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + json-parse-better-errors@1.0.2: + resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} + json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -10567,6 +10677,10 @@ packages: resolution: {integrity: sha512-aXpZJRnTkpK6gQ/z4nk+ZBLd/Qdp118cvPruLSIQzQNRhKwEcdXCOzXuF55VDqIiuAaY3UGZ10DJtvZzDcvsxg==} engines: {node: '>=14.0.0'} + lcid@2.0.0: + resolution: {integrity: sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==} + engines: {node: '>=6'} + leven@3.1.0: resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} engines: {node: '>=6'} @@ -10594,6 +10708,10 @@ packages: lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + load-json-file@4.0.0: + resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} + engines: {node: '>=4'} + load-yaml-file@0.2.0: resolution: {integrity: sha512-OfCBkGEw4nN6JLtgRidPX6QxjBQGQf72q3si2uvqyFEMbycSFFHwAZeXx6cJgFM9wmLrf9zBwCP3Ivqa+LLZPw==} engines: {node: '>=6'} @@ -10645,6 +10763,10 @@ packages: lodash.deburr@4.1.0: resolution: {integrity: sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ==} + lodash.get@4.4.2: + resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} + deprecated: This package is deprecated. Use the optional chaining (?.) operator instead. + lodash.isplainobject@4.0.6: resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} @@ -10668,6 +10790,12 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lower-case-first@1.0.2: + resolution: {integrity: sha512-UuxaYakO7XeONbKrZf5FEgkantPf5DUqDayzP5VXZrtRPdH86s4kN47I8B3TW10S4QKiE3ziHNf3kRN//okHjA==} + + lower-case@1.1.4: + resolution: {integrity: sha512-2Fgx1Ycm599x+WGpIYwJOvsjmXFzTSc34IwDWALRA/8AopUKAVPwfJ+h5+f85BCp0PWmmJcWzEpxOpoXycMpdA==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -10730,6 +10858,10 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + map-age-cleaner@0.1.3: + resolution: {integrity: sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==} + engines: {node: '>=6'} + map-or-similar@1.5.0: resolution: {integrity: sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==} @@ -10852,6 +10984,10 @@ packages: resolution: {integrity: sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g=} engines: {node: '>= 0.6'} + mem@4.3.0: + resolution: {integrity: sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==} + engines: {node: '>=6'} + memfs@3.5.3: resolution: {integrity: sha512-UERzLsxzllchadvbPs5aolHh65ISpKpM+ccLbOJ8/vvpBKmAWf+la7dXFy7Mr0ySHbdHrFv5kGFCUHHe6GFEmw==} engines: {node: '>= 4.0.0'} @@ -11254,6 +11390,9 @@ packages: resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==} hasBin: true + mute-stream@0.0.8: + resolution: {integrity: sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -11326,9 +11465,15 @@ packages: sass: optional: true + nice-try@1.0.5: + resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} + nlcst-to-string@4.0.0: resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + no-case@2.3.2: + resolution: {integrity: sha512-rmTZ9kz+f3rCvK2TD1Ue/oZlns7OGoIWP4fc3llxxRXlOkHKoWPPWJOfFYpITabSow43QJbRIoHQXtt10VldyQ==} + no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} @@ -11477,6 +11622,10 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + number-is-nan@1.0.1: + resolution: {integrity: sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==} + engines: {node: '>=0.10.0'} + nwsapi@2.2.7: resolution: {integrity: sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==} @@ -11584,6 +11733,10 @@ packages: os-browserify@0.3.0: resolution: {integrity: sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==} + os-locale@3.1.0: + resolution: {integrity: sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q==} + engines: {node: '>=6'} + os-paths@4.4.0: resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} engines: {node: '>= 6.0'} @@ -11602,6 +11755,10 @@ packages: resolution: {integrity: sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==} engines: {node: '>=12.20'} + p-defer@1.0.0: + resolution: {integrity: sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==} + engines: {node: '>=4'} + p-filter@2.1.0: resolution: {integrity: sha512-ZBxxZ5sL2HghephhpGAQdoskxplTwr7ICaehZwLIlfL6acuVgZPm8yBNuRAFBGEqtD/hmUeq9eqLg2ys9Xr/yw==} engines: {node: '>=8'} @@ -11614,6 +11771,10 @@ packages: resolution: {integrity: sha512-vpm09aKwq6H9phqRQzecoDpD8TmVyGw70qmWlyq5onxY7tqyTTFVvxMykxQSQKILBSFlbXpypIw2T1Ml7+DDtw==} engines: {node: '>=8'} + p-is-promise@2.1.0: + resolution: {integrity: sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==} + engines: {node: '>=6'} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -11687,6 +11848,9 @@ packages: pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + param-case@2.1.1: + resolution: {integrity: sha512-eQE845L6ot89sk2N8liD8HAuH4ca6Vvr7VWAWwt7+kvvG5aBcPmmphQ68JsEG2qa9n1TykS2DLeMt363AAH8/w==} + param-case@3.0.4: resolution: {integrity: sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==} @@ -11709,6 +11873,10 @@ packages: resolution: {integrity: sha512-FC5TeK0AwXzq3tUBFtH74naWkPQCEWs4K+xMxWZBlKDWu0bVHXGZa+KKqxKidd7xwhdZ19ZNuF2uO1M/r196HA==} engines: {node: '>=0.10.0'} + parse-json@4.0.0: + resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} + engines: {node: '>=4'} + parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} @@ -11739,12 +11907,18 @@ packages: partysocket@0.0.22: resolution: {integrity: sha512-HmFJoVA48vfU5VaQ539YnQt+/QncV5wdlN7vEW//m8eCnOV2PKB8X08c7hI4VLrqntajaWovHhprWHgXbXgR1A==} + pascal-case@2.0.1: + resolution: {integrity: sha512-qjS4s8rBOJa2Xm0jmxXiyh1+OFf6ekCWOvUaRgAQSktzlTbMotS0nmG9gyYAybCWBcuP4fsBeRCKNwGBnMe2OQ==} + pascal-case@3.1.2: resolution: {integrity: sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==} path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-case@2.1.1: + resolution: {integrity: sha512-Ou0N05MioItesaLr9q8TtHVWmJ6fxWdqKB2RohFmNWVyJ+2zeKIeDNWAN6B/Pe7wpzWChhZX6nONYmOnMeJQ/Q==} + path-case@3.0.4: resolution: {integrity: sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==} @@ -11801,6 +11975,10 @@ packages: path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + path-type@3.0.0: + resolution: {integrity: sha512-T2ZUsdZFHgA3u4e5PfPbjd7HDDpxPnQb5jN0SrDsjNSuVXHJqtwTnWqG0B1jZrgmJ/7lj1EmVIByWt1gxGkWvg==} + engines: {node: '>=4'} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -11857,6 +12035,10 @@ packages: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} + pify@3.0.0: + resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} + engines: {node: '>=4'} + pify@4.0.1: resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} engines: {node: '>=6'} @@ -12165,6 +12347,10 @@ packages: promisepipe@3.0.0: resolution: {integrity: sha512-V6TbZDJ/ZswevgkDNpGt/YqNCiZP9ASfgU+p83uJE6NrGtvSGoOcHLiDCqkMs2+yg7F5qHdLV8d0aS8O26G/KA==} + prompt@1.3.0: + resolution: {integrity: sha512-ZkaRWtaLBZl7KKAKndKYUL8WqNT+cQHKRZnT4RYYms48jQkFw3rrBL+/N5K/KtdEveHkxs982MX2BkDKub2ZMg==} + engines: {node: '>= 6.0.0'} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -12447,10 +12633,18 @@ packages: read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} + read-pkg-up@4.0.0: + resolution: {integrity: sha512-6etQSH7nJGsK0RbG/2TeDzZFa8shjQ1um+SwQQ5cwKy0dhSXdOncEhb1CPpvQG4h7FyOV6EB6YlV0yJvZQNAkA==} + engines: {node: '>=6'} + read-pkg-up@7.0.1: resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} engines: {node: '>=8'} + read-pkg@3.0.0: + resolution: {integrity: sha512-BLq/cCO9two+lBgiTYNqD6GdtK8s4NpaWrl6/rCO9w0TUS8oJl7cmToOZfRYllKTISY6nt1U7jQ53brmKqY6BA==} + engines: {node: '>=4'} + read-pkg@5.2.0: resolution: {integrity: sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==} engines: {node: '>=8'} @@ -12459,6 +12653,10 @@ packages: resolution: {integrity: sha512-VIMnQi/Z4HT2Fxuwg5KrY174U1VdUIASQVWXXyqtNRtxSr9IYkn1rsI6Tb6HsrHCmB7gVpNwX6JxPTHcH6IoTA==} engines: {node: '>=6'} + read@1.0.7: + resolution: {integrity: sha512-rSOKNYUmaxy0om1BNjMN4ezNT6VKK+2xF4GBhc81mkH7L60i6dp8qPYrkndNLT3QPphoII3maL9PVC9XmhHwVQ==} + engines: {node: '>=0.8'} + readable-stream@2.3.8: resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} @@ -12486,6 +12684,10 @@ packages: resolution: {integrity: sha512-qtEDqIZGVcSZCHniWwZWbRy79Dc6Wp3kT/UmDA2RJKBPg7+7k51aQBZirHmUGn5uvHf2rg8DkjizrN26k61ATw==} engines: {node: '>= 4'} + rechoir@0.6.2: + resolution: {integrity: sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw==} + engines: {node: '>= 0.10'} + redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -12621,6 +12823,9 @@ packages: require-like@0.1.2: resolution: {integrity: sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==} + require-main-filename@1.0.1: + resolution: {integrity: sha512-IqSUtOVP4ksd1C/ej5zeEh/BIP2ajqpn8c5x+q99gvcIG/Qf0cud5raVnE/Dwd0ua9TXYDoDc0RE5hBSdz22Ug==} + requireindex@1.2.0: resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==} engines: {node: '>=0.10.5'} @@ -12710,6 +12915,10 @@ packages: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + revalidator@0.1.8: + resolution: {integrity: sha512-xcBILK2pA9oh4SiinPEZfhP8HfrB/ha+a2fTMyl7Om2WjlDVrOQy99N2MXXlUHqGJz4qEu2duXxHJjDWuK/0xg==} + engines: {node: '>= 0.4.0'} + rimraf@2.6.3: resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} hasBin: true @@ -12898,6 +13107,9 @@ packages: resolution: {integrity: sha512-v67WcEouB5GxbTWL/4NeToqcZiAWEq90N888fczVArY8A79J0L4FD7vj5hm3eUMua5EpoQ59wa/oovY6TLvRUA==} engines: {node: '>= 18'} + sentence-case@2.1.1: + resolution: {integrity: sha512-ENl7cYHaK/Ktwk5OTD+aDbQ3uC8IByu/6Bkg+HDv8Mm+XnBnppVNalcfJTNsp1ibstKh030/JKQQWglDvtKwEQ==} + sentence-case@3.0.4: resolution: {integrity: sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==} @@ -12978,6 +13190,11 @@ packages: shellac@0.8.0: resolution: {integrity: sha512-M3F2vzYIM7frKOs0+kgs/ITMlXhGpgtqs9HxDPciz3bckzAqqfd4LrBn+CCmSbICyJS+Jz5UDkmkR1jE+m+g+Q==} + shelljs@0.8.5: + resolution: {integrity: sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow==} + engines: {node: '>=4'} + hasBin: true + shiki@0.14.4: resolution: {integrity: sha512-IXCRip2IQzKwxArNNq1S+On4KPML3Yyn8Zzs/xRgcgOWIr8ntIK3IKzjFPfjy/7kt9ZMjc+FItfqHRBg8b6tNQ==} @@ -13049,6 +13266,9 @@ packages: resolution: {integrity: sha512-tEYNll18pPKHroYSmLLrksq233j021G0giwW7P3D24jC54pQ5W5BXMsQ/Mvw1OJCmEYDgY+lrzT+3nNUtoNfXQ==} engines: {node: '>= 18'} + snake-case@2.1.0: + resolution: {integrity: sha512-FMR5YoPFwOLuh4rRz92dywJjyKYZNLpMn1R5ujVpIYkbA9p01fq8RMg0FkO4M+Yobt4MjHeLTJVm5xFFBHSV2Q==} + snake-case@3.0.4: resolution: {integrity: sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==} @@ -13139,6 +13359,9 @@ packages: stack-generator@2.0.10: resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} + stack-trace@0.0.10: + resolution: {integrity: sha512-KGzahc7puUKkzyMt+IqAep+TVNbKP+k2Lmwhub39m1AsTSkaDutx56aDCo+HLDzf/D26BIHTJWNiTG1KAJiQCg==} + stack-utils@2.0.6: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} @@ -13228,6 +13451,14 @@ packages: resolution: {integrity: sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==} engines: {node: '>=10'} + string-width@1.0.2: + resolution: {integrity: sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==} + engines: {node: '>=0.10.0'} + + string-width@2.1.1: + resolution: {integrity: sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==} + engines: {node: '>=4'} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -13262,6 +13493,14 @@ packages: stringify-entities@4.0.3: resolution: {integrity: sha512-BP9nNHMhhfcMbiuQKCqMjhDP5yBCAxsPu4pHFFzJ6Alo9dZgY4VLDPutXqIjpRiMoKdp7Av85Gr73Q5uH9k7+g==} + strip-ansi@3.0.1: + resolution: {integrity: sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==} + engines: {node: '>=0.10.0'} + + strip-ansi@4.0.0: + resolution: {integrity: sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==} + engines: {node: '>=4'} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -13384,6 +13623,9 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + swap-case@1.1.2: + resolution: {integrity: sha512-BAmWG6/bx8syfc6qXPprof3Mn5vQgf5dwdUNJhsNqU9WdPt5P+ES/wQ5bxfijy8zwZgZZHslC3iAsxsuQMCzJQ==} + swc-loader@0.2.3: resolution: {integrity: sha512-D1p6XXURfSPleZZA/Lipb3A8pZ17fP4NObZvFCDjK/OKljroqDpPmsBdTraWhVBqUNpcWBQY1imWdoPScRlQ7A==} peerDependencies: @@ -13522,6 +13764,9 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} + title-case@2.1.1: + resolution: {integrity: sha512-EkJoZ2O3zdCz3zJsYCsxyq2OC5hrxR9mfdd5I+w8h/tmFfeOxJ+vvkxsKxdmN0WtS9zLdHEgfgVOiMVgv+Po4Q==} + titleize@3.0.0: resolution: {integrity: sha512-KxVu8EYHDPBdUYdKZdKtU2aj2XfEx9AfjXxE/Aj0vT06w2icA09Vus1rh6eSu1y01akYg6BjIK/hxyLJINoMLQ==} engines: {node: '>=12'} @@ -13777,9 +14022,6 @@ packages: uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} - undici-types@6.20.0: - resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==} - undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -13990,9 +14232,15 @@ packages: peerDependencies: browserslist: '>= 4.21.0' + upper-case-first@1.1.2: + resolution: {integrity: sha512-wINKYvI3Db8dtjikdAqoBbZoP6Q+PZUyfMR7pmwHzjC2quzSkUq5DmPrTtPEqHaz8AGtmsB4TqwapMTM1QAQOQ==} + upper-case-first@2.0.2: resolution: {integrity: sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==} + upper-case@1.1.3: + resolution: {integrity: sha512-WRbjgmYzgXkCV7zNVpy5YgrHgbBv126rMALQQMrmzOVC4GM2waQ9x7xtm8VU+1yF2kWyPzI9zbZ48n4vSxwfSA==} + upper-case@2.0.2: resolution: {integrity: sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==} @@ -14397,6 +14645,9 @@ packages: which-collection@1.0.1: resolution: {integrity: sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-pm-runs@1.1.0: resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} engines: {node: '>=4'} @@ -14437,6 +14688,10 @@ packages: wildcard@2.0.1: resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} + winston@2.4.7: + resolution: {integrity: sha512-vLB4BqzCKDnnZH9PHGoS2ycawueX4HLqENXQitvFHczhgW2vFpSOn31LZtVr1KU8YTw7DS4tM+cqyovxo8taVg==} + engines: {node: '>= 0.10.0'} + wonka@6.3.4: resolution: {integrity: sha512-CjpbqNtBGNAeyNS/9W6q3kSkKE52+FjIj7AkFlLr11s/VWGUu6a2CdYSdGxocIhIVjaW/zchesBQUKPVU69Cqg==} @@ -14457,6 +14712,17 @@ packages: engines: {node: '>=16'} hasBin: true + workflow-cmd@2.0.1: + resolution: {integrity: sha512-PbmpVA+CmqvGRC63TDGo6t83WHjfFs1aFCUhbagGW0JJlBfu+LfmMXMAOPrrslWHdzQTajVv7xQimaej45Orpg==} + + workflow-core@2.0.0: + resolution: {integrity: sha512-6kuiDifrlgdFhMbBYFA/WbZ2erstadHhRp+6cx5zRHOuZcf+tVCcZzsCDCrob9n1P7/9DMHwGBSv4vbX+Eu2pQ==} + + workflow@2.0.6: + resolution: {integrity: sha512-3GdfQp5DSApvF7s05k3lUH8lAMupidDHvIWXkEev9+F868puaehU1LEHR00M9k9aXYpIN32ZKFqu9n2oyYQQLg==} + deprecated: Unsupported version of workflow. Upgrade to a version >=3 + hasBin: true + wrangler@3.72.2: resolution: {integrity: sha512-7nxkJ4md+KtESNJ/0DwTM7bHZP+uNRpJT5gMDT9WllP9UVzYdtXCTF+p4CHtxIReUpe6pOi7tb05hK9/Q6WaiA==} engines: {node: '>=16.17.0'} @@ -14467,6 +14733,10 @@ packages: '@cloudflare/workers-types': optional: true + wrap-ansi@2.1.0: + resolution: {integrity: sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==} + engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -14583,6 +14853,9 @@ packages: peerDependencies: yjs: ^13 + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -14604,6 +14877,9 @@ packages: resolution: {integrity: sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==} engines: {node: '>= 14'} + yargs-parser@11.1.1: + resolution: {integrity: sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ==} + yargs-parser@20.2.9: resolution: {integrity: sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==} engines: {node: '>=10'} @@ -14612,6 +14888,9 @@ packages: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@12.0.5: + resolution: {integrity: sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==} + yargs@16.2.0: resolution: {integrity: sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==} engines: {node: '>=10'} @@ -14724,13 +15003,13 @@ snapshots: '@astrojs/internal-helpers@0.5.1': {} - '@astrojs/markdoc@0.12.9(@types/react@19.0.8)(astro@5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.34.6)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3))(react@19.0.0)': + '@astrojs/markdoc@0.12.9(@types/react@19.0.8)(astro@5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.55.1)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3))(react@19.0.0)': dependencies: '@astrojs/internal-helpers': 0.5.1 '@astrojs/markdown-remark': 6.1.0 '@astrojs/prism': 3.2.0 '@markdoc/markdoc': 0.4.0(@types/react@19.0.8)(react@19.0.0) - astro: 5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.34.6)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3) + astro: 5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.55.1)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3) esbuild: 0.24.2 github-slugger: 2.0.0 htmlparser2: 10.0.0 @@ -14764,9 +15043,9 @@ snapshots: transitivePeerDependencies: - supports-color - '@astrojs/node@9.0.2(astro@5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.34.6)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3))': + '@astrojs/node@9.0.2(astro@5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.55.1)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3))': dependencies: - astro: 5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.34.6)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3) + astro: 5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.55.1)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3) send: 1.1.0 server-destroy: 1.0.1 transitivePeerDependencies: @@ -14805,9 +15084,9 @@ snapshots: stream-replace-string: 2.0.0 zod: 3.23.8 - '@astrojs/tailwind@6.0.0(astro@5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.34.6)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3))(tailwindcss@3.4.1(ts-node@10.9.1(@swc/core@1.3.82(@swc/helpers@0.5.15))(@types/node@25.0.3)(typescript@5.5.3)))(ts-node@10.9.1(@swc/core@1.3.82(@swc/helpers@0.5.15))(@types/node@25.0.3)(typescript@5.5.3))': + '@astrojs/tailwind@6.0.0(astro@5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.55.1)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3))(tailwindcss@3.4.1(ts-node@10.9.1(@swc/core@1.3.82(@swc/helpers@0.5.15))(@types/node@25.0.3)(typescript@5.5.3)))(ts-node@10.9.1(@swc/core@1.3.82(@swc/helpers@0.5.15))(@types/node@25.0.3)(typescript@5.5.3))': dependencies: - astro: 5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.34.6)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3) + astro: 5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.55.1)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3) autoprefixer: 10.4.20(postcss@8.5.1) postcss: 8.5.1 postcss-load-config: 4.0.2(postcss@8.5.1)(ts-node@10.9.1(@swc/core@1.3.82(@swc/helpers@0.5.15))(@types/node@25.0.3)(typescript@5.5.3)) @@ -17152,8 +17431,7 @@ snapshots: '@cloudflare/workers-shared@0.3.0': {} - '@colors/colors@1.5.0': - optional: true + '@colors/colors@1.5.0': {} '@cspotcode/source-map-support@0.8.1': dependencies: @@ -20018,13 +20296,13 @@ snapshots: estree-walker: 2.0.2 picomatch: 2.3.1 - '@rollup/pluginutils@5.1.4(rollup@4.34.6)': + '@rollup/pluginutils@5.1.4(rollup@4.55.1)': dependencies: '@types/estree': 1.0.5 estree-walker: 2.0.2 picomatch: 4.0.2 optionalDependencies: - rollup: 4.34.6 + rollup: 4.55.1 '@rollup/rollup-android-arm-eabi@4.18.0': optional: true @@ -21576,7 +21854,7 @@ snapshots: '@types/node-forge@1.3.14': dependencies: - '@types/node': 22.13.1 + '@types/node': 25.0.3 '@types/node@12.20.55': {} @@ -21586,10 +21864,6 @@ snapshots: '@types/node@17.0.45': {} - '@types/node@22.13.1': - dependencies: - undici-types: 6.20.0 - '@types/node@25.0.3': dependencies: undici-types: 7.16.0 @@ -22312,6 +22586,10 @@ snapshots: ansi-html-community@0.0.8: {} + ansi-regex@2.1.1: {} + + ansi-regex@3.0.1: {} + ansi-regex@4.1.1: {} ansi-regex@5.0.1: {} @@ -22472,14 +22750,14 @@ snapshots: astring@1.8.6: {} - astro@5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.34.6)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3): + astro@5.2.5(@types/node@25.0.3)(idb-keyval@6.2.1)(rollup@4.55.1)(terser@5.19.4)(tsx@4.8.2)(typescript@5.5.3): dependencies: '@astrojs/compiler': 2.10.4 '@astrojs/internal-helpers': 0.5.1 '@astrojs/markdown-remark': 6.1.0 '@astrojs/telemetry': 3.2.0 '@oslojs/encoding': 1.1.0 - '@rollup/pluginutils': 5.1.4(rollup@4.34.6) + '@rollup/pluginutils': 5.1.4(rollup@4.55.1) '@types/cookie': 0.6.0 acorn: 8.14.0 aria-query: 5.3.2 @@ -22583,6 +22861,8 @@ snapshots: dependencies: lodash: 4.17.21 + async@3.2.3: {} + async@3.2.4: {} asynciterator.prototype@1.0.0: @@ -23058,6 +23338,11 @@ snapshots: callsites@3.1.0: {} + camel-case@3.0.0: + dependencies: + no-case: 2.3.2 + upper-case: 1.1.3 + camel-case@4.1.2: dependencies: pascal-case: 3.1.2 @@ -23110,6 +23395,27 @@ snapshots: chalk@5.3.0: {} + change-case@3.1.0: + dependencies: + camel-case: 3.0.0 + constant-case: 2.0.0 + dot-case: 2.1.1 + header-case: 1.0.1 + is-lower-case: 1.1.3 + is-upper-case: 1.1.2 + lower-case: 1.1.4 + lower-case-first: 1.0.2 + no-case: 2.3.2 + param-case: 2.1.1 + pascal-case: 2.0.1 + path-case: 2.1.1 + sentence-case: 2.1.1 + snake-case: 2.1.0 + swap-case: 1.1.2 + title-case: 2.1.1 + upper-case: 1.1.3 + upper-case-first: 1.1.2 + change-case@4.1.2: dependencies: camel-case: 4.1.2 @@ -23218,6 +23524,12 @@ snapshots: clipboard-copy@4.0.1: {} + cliui@4.1.0: + dependencies: + string-width: 2.1.1 + strip-ansi: 4.0.0 + wrap-ansi: 2.1.0 + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -23244,6 +23556,8 @@ snapshots: code-block-writer@10.1.1: {} + code-point-at@1.1.0: {} + codemirror@5.65.15: {} collect-v8-coverage@1.0.2: {} @@ -23278,6 +23592,8 @@ snapshots: colorette@2.0.20: {} + colors@1.0.3: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -23358,6 +23674,11 @@ snapshots: console-control-strings@1.1.0: {} + constant-case@2.0.0: + dependencies: + snake-case: 2.1.0 + upper-case: 1.1.3 + constant-case@3.0.4: dependencies: no-case: 3.0.4 @@ -23455,12 +23776,27 @@ snapshots: create-require@1.1.1: {} + create-workflow-home@2.0.6: + dependencies: + change-case: 3.1.0 + fs-extra: 7.0.1 + lodash.get: 4.4.2 + shelljs: 0.8.5 + cross-spawn@5.1.0: dependencies: lru-cache: 4.1.5 shebang-command: 1.2.0 which: 1.3.1 + cross-spawn@6.0.6: + dependencies: + nice-try: 1.0.5 + path-key: 2.0.1 + semver: 5.7.2 + shebang-command: 1.2.0 + which: 1.3.1 + cross-spawn@7.0.3: dependencies: path-key: 3.1.1 @@ -23556,6 +23892,8 @@ snapshots: execa: 0.6.3 is-git-repository: 1.1.1 + cycle@1.0.3: {} + damerau-levenshtein@1.0.8: {} data-uri-to-buffer@2.0.2: {} @@ -23594,6 +23932,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decimal.js@10.4.3: {} decode-named-character-reference@1.0.2: @@ -23834,6 +24174,10 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 + dot-case@2.1.1: + dependencies: + no-case: 2.3.2 + dot-case@3.0.4: dependencies: no-case: 3.0.4 @@ -24950,6 +25294,16 @@ snapshots: md5.js: 1.3.5 safe-buffer: 5.2.1 + execa@0.11.0: + dependencies: + cross-spawn: 6.0.6 + get-stream: 4.1.0 + is-stream: 1.1.0 + npm-run-path: 2.0.2 + p-finally: 1.0.0 + signal-exit: 3.0.7 + strip-eof: 1.0.0 + execa@0.6.3: dependencies: cross-spawn: 5.1.0 @@ -24960,6 +25314,16 @@ snapshots: signal-exit: 3.0.7 strip-eof: 1.0.0 + execa@1.0.0: + dependencies: + cross-spawn: 6.0.6 + get-stream: 4.1.0 + is-stream: 1.1.0 + npm-run-path: 2.0.2 + p-finally: 1.0.0 + signal-exit: 3.0.7 + strip-eof: 1.0.0 + execa@3.2.0: dependencies: cross-spawn: 7.0.3 @@ -25072,6 +25436,8 @@ snapshots: transitivePeerDependencies: - supports-color + eyes@0.1.8: {} + facepaint@1.2.1: {} fast-deep-equal@2.0.1: {} @@ -25395,6 +25761,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-caller-file@1.0.3: {} + get-caller-file@2.0.5: {} get-east-asian-width@1.2.0: {} @@ -25423,6 +25791,10 @@ snapshots: get-stream@3.0.0: {} + get-stream@4.1.0: + dependencies: + pump: 3.0.0 + get-stream@5.2.0: dependencies: pump: 3.0.0 @@ -25773,6 +26145,11 @@ snapshots: he@1.2.0: {} + header-case@1.0.1: + dependencies: + no-case: 2.3.2 + upper-case: 1.1.3 + header-case@2.0.4: dependencies: capital-case: 1.0.4 @@ -26025,6 +26402,8 @@ snapshots: has: 1.0.3 side-channel: 1.0.4 + interpret@1.4.0: {} + intersection-observer@0.12.2: {} intl-messageformat@10.5.0: @@ -26038,6 +26417,8 @@ snapshots: dependencies: loose-envify: 1.4.0 + invert-kv@2.0.0: {} + ip@2.0.0: {} ipaddr.js@1.9.1: {} @@ -26120,6 +26501,12 @@ snapshots: dependencies: call-bind: 1.0.2 + is-fullwidth-code-point@1.0.0: + dependencies: + number-is-nan: 1.0.1 + + is-fullwidth-code-point@2.0.0: {} + is-fullwidth-code-point@3.0.0: {} is-generator-fn@2.1.0: {} @@ -26155,6 +26542,10 @@ snapshots: is-interactive@1.0.0: {} + is-lower-case@1.1.3: + dependencies: + lower-case: 1.1.4 + is-map@2.0.2: {} is-module@1.0.0: {} @@ -26231,6 +26622,10 @@ snapshots: is-unicode-supported@0.1.0: {} + is-upper-case@1.1.2: + dependencies: + upper-case: 1.1.3 + is-weakmap@2.0.1: {} is-weakref@1.0.2: @@ -26266,6 +26661,8 @@ snapshots: isomorphic.js@0.2.5: {} + isstream@0.1.2: {} + istanbul-lib-coverage@3.2.0: {} istanbul-lib-instrument@5.2.1: @@ -26844,6 +27241,8 @@ snapshots: json-buffer@3.0.1: {} + json-parse-better-errors@1.0.2: {} + json-parse-even-better-errors@2.3.1: {} json-parse-even-better-errors@3.0.0: {} @@ -26917,6 +27316,10 @@ snapshots: dotenv: 16.3.1 dotenv-expand: 10.0.0 + lcid@2.0.0: + dependencies: + invert-kv: 2.0.0 + leven@3.1.0: {} levn@0.4.1: @@ -26938,6 +27341,13 @@ snapshots: lines-and-columns@1.2.4: {} + load-json-file@4.0.0: + dependencies: + graceful-fs: 4.2.11 + parse-json: 4.0.0 + pify: 3.0.0 + strip-bom: 3.0.0 + load-yaml-file@0.2.0: dependencies: graceful-fs: 4.2.11 @@ -26986,6 +27396,8 @@ snapshots: lodash.deburr@4.1.0: {} + lodash.get@4.4.2: {} + lodash.isplainobject@4.0.6: {} lodash.merge@4.6.2: {} @@ -27005,6 +27417,12 @@ snapshots: dependencies: js-tokens: 4.0.0 + lower-case-first@1.0.2: + dependencies: + lower-case: 1.1.4 + + lower-case@1.1.4: {} + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -27067,6 +27485,10 @@ snapshots: dependencies: tmpl: 1.0.5 + map-age-cleaner@0.1.3: + dependencies: + p-defer: 1.0.0 + map-or-similar@1.5.0: {} markdown-extensions@1.1.1: {} @@ -27375,6 +27797,12 @@ snapshots: media-typer@0.3.0: {} + mem@4.3.0: + dependencies: + map-age-cleaner: 0.1.3 + mimic-fn: 2.1.0 + p-is-promise: 2.1.0 + memfs@3.5.3: dependencies: fs-monkey: 1.0.4 @@ -28056,6 +28484,8 @@ snapshots: mustache@4.2.0: {} + mute-stream@0.0.8: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -28147,10 +28577,16 @@ snapshots: - '@babel/core' - babel-plugin-macros + nice-try@1.0.5: {} + nlcst-to-string@4.0.0: dependencies: '@types/nlcst': 2.0.3 + no-case@2.3.2: + dependencies: + lower-case: 1.1.4 + no-case@3.0.4: dependencies: lower-case: 2.0.2 @@ -28305,6 +28741,8 @@ snapshots: dependencies: boolbase: 1.0.0 + number-is-nan@1.0.1: {} + nwsapi@2.2.7: {} object-assign@4.1.1: {} @@ -28439,6 +28877,12 @@ snapshots: os-browserify@0.3.0: {} + os-locale@3.1.0: + dependencies: + execa: 1.0.0 + lcid: 2.0.0 + mem: 4.3.0 + os-paths@4.4.0: {} os-tmpdir@1.0.2: {} @@ -28449,6 +28893,8 @@ snapshots: p-cancelable@3.0.0: {} + p-defer@1.0.0: {} + p-filter@2.1.0: dependencies: p-map: 2.1.0 @@ -28457,6 +28903,8 @@ snapshots: p-finally@2.0.1: {} + p-is-promise@2.1.0: {} + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -28533,6 +28981,10 @@ snapshots: pako@1.0.11: {} + param-case@2.1.1: + dependencies: + no-case: 2.3.2 + param-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -28570,6 +29022,11 @@ snapshots: is-extglob: 1.0.0 is-glob: 2.0.1 + parse-json@4.0.0: + dependencies: + error-ex: 1.3.2 + json-parse-better-errors: 1.0.2 + parse-json@5.2.0: dependencies: '@babel/code-frame': 7.26.2 @@ -28604,6 +29061,11 @@ snapshots: dependencies: event-target-shim: 6.0.2 + pascal-case@2.0.1: + dependencies: + camel-case: 3.0.0 + upper-case-first: 1.1.2 + pascal-case@3.1.2: dependencies: no-case: 3.0.4 @@ -28611,6 +29073,10 @@ snapshots: path-browserify@1.0.1: {} + path-case@2.1.1: + dependencies: + no-case: 2.3.2 + path-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -28654,6 +29120,10 @@ snapshots: path-to-regexp@6.3.0: {} + path-type@3.0.0: + dependencies: + pify: 3.0.0 + path-type@4.0.0: {} pathe@1.1.1: {} @@ -28700,6 +29170,8 @@ snapshots: pify@2.3.0: {} + pify@3.0.0: {} + pify@4.0.1: {} pirates@4.0.6: {} @@ -29067,6 +29539,14 @@ snapshots: promisepipe@3.0.0: {} + prompt@1.3.0: + dependencies: + '@colors/colors': 1.5.0 + async: 3.2.3 + read: 1.0.7 + revalidator: 0.1.8 + winston: 2.4.7 + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -29412,12 +29892,23 @@ snapshots: dependencies: pify: 2.3.0 + read-pkg-up@4.0.0: + dependencies: + find-up: 3.0.0 + read-pkg: 3.0.0 + read-pkg-up@7.0.1: dependencies: find-up: 4.1.0 read-pkg: 5.2.0 type-fest: 0.8.1 + read-pkg@3.0.0: + dependencies: + load-json-file: 4.0.0 + normalize-package-data: 2.5.0 + path-type: 3.0.0 + read-pkg@5.2.0: dependencies: '@types/normalize-package-data': 2.4.1 @@ -29432,6 +29923,10 @@ snapshots: pify: 4.0.1 strip-bom: 3.0.0 + read@1.0.7: + dependencies: + mute-stream: 0.0.8 + readable-stream@2.3.8: dependencies: core-util-is: 1.0.3 @@ -29479,6 +29974,10 @@ snapshots: source-map: 0.6.1 tslib: 2.8.1 + rechoir@0.6.2: + dependencies: + resolve: 1.22.11 + redent@3.0.0: dependencies: indent-string: 4.0.0 @@ -29679,6 +30178,8 @@ snapshots: require-like@0.1.2: {} + require-main-filename@1.0.1: {} + requireindex@1.2.0: {} requires-port@1.0.0: {} @@ -29773,6 +30274,8 @@ snapshots: reusify@1.0.4: {} + revalidator@0.1.8: {} + rimraf@2.6.3: dependencies: glob: 7.2.3 @@ -30042,6 +30545,11 @@ snapshots: transitivePeerDependencies: - supports-color + sentence-case@2.1.1: + dependencies: + no-case: 2.3.2 + upper-case-first: 1.1.2 + sentence-case@3.0.4: dependencies: no-case: 3.0.4 @@ -30153,6 +30661,12 @@ snapshots: dependencies: reghex: 1.0.2 + shelljs@0.8.5: + dependencies: + glob: 7.2.3 + interpret: 1.4.0 + rechoir: 0.6.2 + shiki@0.14.4: dependencies: ansi-sequence-parser: 1.1.1 @@ -30240,6 +30754,10 @@ snapshots: smol-toml@1.3.1: {} + snake-case@2.1.0: + dependencies: + no-case: 2.3.2 + snake-case@3.0.4: dependencies: dot-case: 3.0.4 @@ -30343,6 +30861,8 @@ snapshots: dependencies: stackframe: 1.3.4 + stack-trace@0.0.10: {} + stack-utils@2.0.6: dependencies: escape-string-regexp: 2.0.0 @@ -30444,6 +30964,17 @@ snapshots: char-regex: 1.0.2 strip-ansi: 6.0.1 + string-width@1.0.2: + dependencies: + code-point-at: 1.1.0 + is-fullwidth-code-point: 1.0.0 + strip-ansi: 3.0.1 + + string-width@2.1.1: + dependencies: + is-fullwidth-code-point: 2.0.0 + strip-ansi: 4.0.0 + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -30504,6 +31035,14 @@ snapshots: character-entities-html4: 2.1.0 character-entities-legacy: 3.0.0 + strip-ansi@3.0.1: + dependencies: + ansi-regex: 2.1.1 + + strip-ansi@4.0.0: + dependencies: + ansi-regex: 3.0.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -30618,6 +31157,11 @@ snapshots: csso: 5.0.5 picocolors: 1.1.1 + swap-case@1.1.2: + dependencies: + lower-case: 1.1.4 + upper-case: 1.1.3 + swc-loader@0.2.3(@swc/core@1.3.82(@swc/helpers@0.5.15))(webpack@5.88.2(@swc/core@1.3.82(@swc/helpers@0.5.15))(esbuild@0.14.54)): dependencies: '@swc/core': 1.3.82(@swc/helpers@0.5.15) @@ -30788,6 +31332,11 @@ snapshots: tinyrainbow@2.0.0: {} + title-case@2.1.1: + dependencies: + no-case: 2.3.2 + upper-case: 1.1.3 + titleize@3.0.0: {} tmp@0.0.33: @@ -31024,8 +31573,6 @@ snapshots: uncrypto@0.1.3: {} - undici-types@6.20.0: {} - undici-types@7.16.0: {} undici@5.26.5: @@ -31225,10 +31772,16 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 + upper-case-first@1.1.2: + dependencies: + upper-case: 1.1.3 + upper-case-first@2.0.2: dependencies: tslib: 2.8.1 + upper-case@1.1.3: {} + upper-case@2.0.2: dependencies: tslib: 2.8.1 @@ -31651,6 +32204,8 @@ snapshots: is-weakmap: 2.0.1 is-weakset: 2.0.2 + which-module@2.0.1: {} + which-pm-runs@1.1.0: {} which-pm@2.0.0: @@ -31692,6 +32247,15 @@ snapshots: wildcard@2.0.1: {} + winston@2.4.7: + dependencies: + async: 2.6.4 + colors: 1.0.3 + cycle: 1.0.3 + eyes: 0.1.8 + isstream: 0.1.2 + stack-trace: 0.0.10 + wonka@6.3.4: {} wordwrap@1.0.0: {} @@ -31717,6 +32281,27 @@ snapshots: '@cloudflare/workerd-linux-arm64': 1.20240821.1 '@cloudflare/workerd-windows-64': 1.20240821.1 + workflow-cmd@2.0.1: + dependencies: + cross-spawn: 6.0.6 + execa: 1.0.0 + prompt: 1.3.0 + read-pkg-up: 4.0.0 + shelljs: 0.8.5 + workflow-core: 2.0.0 + yargs: 12.0.5 + + workflow-core@2.0.0: {} + + workflow@2.0.6: + dependencies: + create-workflow-home: 2.0.6 + cross-spawn: 6.0.6 + execa: 0.11.0 + prompt: 1.3.0 + shelljs: 0.8.5 + workflow-cmd: 2.0.1 + wrangler@3.72.2: dependencies: '@cloudflare/kv-asset-handler': 0.3.4 @@ -31744,6 +32329,11 @@ snapshots: - supports-color - utf-8-validate + wrap-ansi@2.1.0: + dependencies: + string-width: 1.0.2 + strip-ansi: 3.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -31824,6 +32414,8 @@ snapshots: dependencies: yjs: 13.6.11 + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@2.1.2: {} @@ -31836,10 +32428,30 @@ snapshots: yaml@2.3.4: {} + yargs-parser@11.1.1: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@20.2.9: {} yargs-parser@21.1.1: {} + yargs@12.0.5: + dependencies: + cliui: 4.1.0 + decamelize: 1.2.0 + find-up: 3.0.0 + get-caller-file: 1.0.3 + os-locale: 3.1.0 + require-directory: 2.1.1 + require-main-filename: 1.0.1 + set-blocking: 2.0.0 + string-width: 2.1.1 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 11.1.1 + yargs@16.2.0: dependencies: cliui: 7.0.4