mirror of
https://github.com/withastro/astro.git
synced 2025-03-24 23:21:57 -05:00
Actions experimental release (#10858)
* feat: port astro-actions poc * feat: basic blog example * feat: basic validationError class * feat: standard error types and safe() wrapper * refactor: move enhanceProps to astro:actions * fix: throw internal server errors * chore: refine enhance: true error message * fix: remove FormData fallback from route * refactor: clarify what enhance: true allows * feat: progressively enhanced comments * chore: changeset * refactor: enhance -> acceptFormData * wip: migrate actions to core * feat: working actions demo from astro core! * chore: changeset * chore: delete old changeset * fix: Function type lint * refactor: expose defineAction from `astro:actions` * fix: add null check to experimental * fix: export `types/actions.d.ts` * feat: more robust form data parsing * feat: support formData from rpc call * feat: remove acceptFormData flag requirement * feat: add actions.d.ts type reference on startup * refactor: actionNameProps -> getNameProps * fix: actions type import * chore: expose zod from `astro:actions` * fix: zod export path * feat: add explicit `accept` property * Use zod package instead of relative path outside of src * feat: clean up error throwing and handling flow * fix: make `accept` optional * docs: beef up actions experimental docs * fix: defineAction type narrowing on `accept` * fix: bad `getNameProps()` arg type * refactor: move to single `error` object + `isInputError()` util * fix: move res.json() parse to avoid double parse * feat: support async zod schemas * feat: serialize and expose zod properties on input error * feat: test input error in comment example * fix: remove ZodError import * fix: add actions-module to files export * fix: use workspace for test pkg versions * refactor: default export -> server export * fix: type inference for json vs. form * refactor: accept form -> defineFormAction * refactor: better callSafely signature * feat: block action calls from the server with RFC link * feat: move getActionResult to global * refactor: getNameProps -> getActionProps * refactor: body.toString() * edit: capitAl Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * edit: highlight `actions` Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * edit: add actions file name Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * edit: not you can. You DO Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * edit: declare with feeling Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * edit: clarify what the `handler` does * edit: schema -> input * edit: add FormData mdn reference * edit: add defineFormAction() explainer * refactor: inline getDotAstroTypeRefs * edit: yeah yeah maybe * fix: existsSync test mock * refactor: use callSafely in middleware * test: upgradeFormData() * chore: stray console log * refactor: extract helper functions * fix: include status in error response * fix: return `undefined` when there's no action result * fix: content-type * test: e2e like button action * test: comment e2e * fix: existsSync mock for other sync test * test: action dev server raw fetch * test: build preview * chore: fix lock * fix: add dotAstroDir to existsSync * chore: slim down e2e fixture * chore: remove unneeded disabled test * refactor: better api context error * fix: return `false` for envDts * refactor: defineFormAction -> defineAction with accept * fix: check FormData on getActionProps * edit: uppercase Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> * fix: add switch default for 500 Co-authored-by: Emanuele Stoppa <my.burning@gmail.com> * fix: add `toLowerCase()` on content-type check Co-authored-by: Emanuele Stoppa <my.burning@gmail.com> * chore: use VIRTUAL_MODULE_ID for plugin * fix: remove incorrect ts-ignore * chore: remove unneeded POST method check * refactor: route callSafely * refactor: error switch case to map * chore: add link to trpc error code table * fix: add readable error on failed json.stringify * refactor: add param -> callerParam with comment * feat: always return safe from getActionResult() * refactor: move actions module to templates/ * refactor: remove unneeded existsSync on dotAstro * fix: hasContentType util for toLowerCase() * chore: comment on 415 code * refactor: upgradeFormData -> formDataToObj * fix: avoid leaking stack in production * refactor: defineProperty with write false * fix: revert package.json back to spaces * edit: use config docs for changeset * refactor: stringifiedActionsPath -> stringifiedActionsImport * fix: avoid double-handling for route * fix: support zero arg actions * refactor: move actionHandler to helper fn * fix: restore mdast deps * docs: add `output` to config --------- Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca> Co-authored-by: Emanuele Stoppa <my.burning@gmail.com> Co-authored-by: bholmesdev <bholmesdev@gmail.com>
This commit is contained in:
parent
6382d7d238
commit
c0c509b6bf
51 changed files with 2320 additions and 52 deletions
95
.changeset/shaggy-moons-peel.md
Normal file
95
.changeset/shaggy-moons-peel.md
Normal file
|
@ -0,0 +1,95 @@
|
|||
---
|
||||
"astro": minor
|
||||
---
|
||||
|
||||
Adds experimental support for the Actions API. Actions let you define type-safe endpoints you can query from client components with progressive enhancement built in.
|
||||
|
||||
|
||||
Actions help you write type-safe backend functions you can call from anywhere. Enable server rendering [using the `output` property](https://docs.astro.build/en/basics/rendering-modes/#on-demand-rendered) and add the `actions` flag to the `experimental` object:
|
||||
|
||||
```js
|
||||
{
|
||||
output: 'hybrid', // or 'server'
|
||||
experimental: {
|
||||
actions: true,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Declare all your actions in `src/actions/index.ts`. This file is the global actions handler.
|
||||
|
||||
Define an action using the `defineAction()` utility from the `astro:actions` module. These accept the `handler` property to define your server-side request handler. If your action accepts arguments, apply the `input` property to validate parameters with Zod.
|
||||
|
||||
This example defines two actions: `like` and `comment`. The `like` action accepts a JSON object with a `postId` string, while the `comment` action accepts [FormData](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects) with `postId`, `author`, and `body` strings. Each `handler` updates your database and return a type-safe response.
|
||||
|
||||
```ts
|
||||
// src/actions/index.ts
|
||||
import { defineAction, z } from "astro:actions";
|
||||
|
||||
export const server = {
|
||||
like: defineAction({
|
||||
input: z.object({ postId: z.string() }),
|
||||
handler: async ({ postId }, context) => {
|
||||
// update likes in db
|
||||
|
||||
return likes;
|
||||
},
|
||||
}),
|
||||
comment: defineAction({
|
||||
accept: 'form',
|
||||
input: z.object({
|
||||
postId: z.string(),
|
||||
author: z.string(),
|
||||
body: z.string(),
|
||||
}),
|
||||
handler: async ({ postId }, context) => {
|
||||
// insert comments in db
|
||||
|
||||
return comment;
|
||||
},
|
||||
}),
|
||||
};
|
||||
```
|
||||
|
||||
Then, call an action from your client components using the `actions` object from `astro:actions`. You can pass a type-safe object when using JSON, or a [FormData](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects) object when using `accept: 'form'` in your action definition:
|
||||
|
||||
```tsx "actions"
|
||||
// src/components/blog.tsx
|
||||
import { actions } from "astro:actions";
|
||||
import { useState } from "preact/hooks";
|
||||
|
||||
export function Like({ postId }: { postId: string }) {
|
||||
const [likes, setLikes] = useState(0);
|
||||
return (
|
||||
<button
|
||||
onClick={async () => {
|
||||
const newLikes = await actions.like({ postId });
|
||||
setLikes(newLikes);
|
||||
}}
|
||||
>
|
||||
{likes} likes
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Comment({ postId }: { postId: string }) {
|
||||
return (
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const result = await actions.blog.comment(formData);
|
||||
// handle result
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="postId" value={postId} />
|
||||
<label for="author">Author</label>
|
||||
<input id="author" type="text" name="author" />
|
||||
<textarea rows={10} name="body"></textarea>
|
||||
<button type="submit">Post</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
For a complete overview, and to give feedback on this experimental API, see the [Actions RFC](https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md).
|
1
packages/astro/client.d.ts
vendored
1
packages/astro/client.d.ts
vendored
|
@ -1,5 +1,6 @@
|
|||
/// <reference types="vite/types/import-meta.d.ts" />
|
||||
/// <reference path="./types/content.d.ts" />
|
||||
/// <reference path="./types/actions.d.ts" />
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
declare namespace App {
|
||||
|
|
58
packages/astro/e2e/actions-blog.test.js
Normal file
58
packages/astro/e2e/actions-blog.test.js
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { expect } from '@playwright/test';
|
||||
import { testFactory } from './test-utils.js';
|
||||
|
||||
const test = testFactory({ root: './fixtures/actions-blog/' });
|
||||
|
||||
let devServer;
|
||||
|
||||
test.beforeAll(async ({ astro }) => {
|
||||
devServer = await astro.startDevServer();
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await devServer.stop();
|
||||
});
|
||||
|
||||
test.describe('Astro Actions - Blog', () => {
|
||||
test('Like action', async ({ page, astro }) => {
|
||||
await page.goto(astro.resolveUrl('/blog/first-post/'));
|
||||
|
||||
const likeButton = page.getByLabel('Like');
|
||||
await expect(likeButton, 'like button starts with 10 likes').toContainText('10');
|
||||
await likeButton.click();
|
||||
await expect(likeButton, 'like button should increment likes').toContainText('11');
|
||||
});
|
||||
|
||||
test('Comment action - validation error', async ({ page, astro }) => {
|
||||
await page.goto(astro.resolveUrl('/blog/first-post/'));
|
||||
|
||||
const authorInput = page.locator('input[name="author"]');
|
||||
const bodyInput = page.locator('textarea[name="body"]');
|
||||
|
||||
await authorInput.fill('Ben');
|
||||
await bodyInput.fill('Too short');
|
||||
|
||||
const submitButton = page.getByLabel('Post comment');
|
||||
await submitButton.click();
|
||||
|
||||
await expect(page.locator('p[data-error="body"]')).toBeVisible();
|
||||
});
|
||||
|
||||
test('Comment action - success', async ({ page, astro }) => {
|
||||
await page.goto(astro.resolveUrl('/blog/first-post/'));
|
||||
|
||||
const authorInput = page.locator('input[name="author"]');
|
||||
const bodyInput = page.locator('textarea[name="body"]');
|
||||
|
||||
const body = 'This should be long enough.';
|
||||
await authorInput.fill('Ben');
|
||||
await bodyInput.fill(body);
|
||||
|
||||
const submitButton = page.getByLabel('Post comment');
|
||||
await submitButton.click();
|
||||
|
||||
const comment = await page.getByTestId('comment');
|
||||
await expect(comment).toBeVisible();
|
||||
await expect(comment).toContainText(body);
|
||||
});
|
||||
});
|
17
packages/astro/e2e/fixtures/actions-blog/astro.config.mjs
Normal file
17
packages/astro/e2e/fixtures/actions-blog/astro.config.mjs
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
import db from '@astrojs/db';
|
||||
import react from '@astrojs/react';
|
||||
import node from '@astrojs/node';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: 'https://example.com',
|
||||
integrations: [db(), react()],
|
||||
output: 'hybrid',
|
||||
adapter: node({
|
||||
mode: 'standalone',
|
||||
}),
|
||||
experimental: {
|
||||
actions: true,
|
||||
},
|
||||
});
|
21
packages/astro/e2e/fixtures/actions-blog/db/config.ts
Normal file
21
packages/astro/e2e/fixtures/actions-blog/db/config.ts
Normal file
|
@ -0,0 +1,21 @@
|
|||
import { column, defineDb, defineTable } from "astro:db";
|
||||
|
||||
const Comment = defineTable({
|
||||
columns: {
|
||||
postId: column.text(),
|
||||
author: column.text(),
|
||||
body: column.text(),
|
||||
},
|
||||
});
|
||||
|
||||
const Likes = defineTable({
|
||||
columns: {
|
||||
postId: column.text(),
|
||||
likes: column.number(),
|
||||
},
|
||||
});
|
||||
|
||||
// https://astro.build/db/config
|
||||
export default defineDb({
|
||||
tables: { Comment, Likes },
|
||||
});
|
15
packages/astro/e2e/fixtures/actions-blog/db/seed.ts
Normal file
15
packages/astro/e2e/fixtures/actions-blog/db/seed.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { db, Likes, Comment } from "astro:db";
|
||||
|
||||
// https://astro.build/db/seed
|
||||
export default async function seed() {
|
||||
await db.insert(Likes).values({
|
||||
postId: "first-post.md",
|
||||
likes: 10,
|
||||
});
|
||||
|
||||
await db.insert(Comment).values({
|
||||
postId: "first-post.md",
|
||||
author: "Alice",
|
||||
body: "Great post!",
|
||||
});
|
||||
}
|
24
packages/astro/e2e/fixtures/actions-blog/package.json
Normal file
24
packages/astro/e2e/fixtures/actions-blog/package.json
Normal file
|
@ -0,0 +1,24 @@
|
|||
{
|
||||
"name": "@e2e/astro-actions-basics",
|
||||
"type": "module",
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"start": "astro dev",
|
||||
"build": "astro check && astro build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.5.10",
|
||||
"@astrojs/db": "workspace:*",
|
||||
"@astrojs/node": "workspace:*",
|
||||
"@astrojs/react": "workspace:*",
|
||||
"@types/react": "^18.2.79",
|
||||
"@types/react-dom": "^18.2.25",
|
||||
"astro": "workspace:*",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,45 @@
|
|||
import { db, Comment, Likes, eq, sql } from 'astro:db';
|
||||
import { defineAction, z } from 'astro:actions';
|
||||
|
||||
export const server = {
|
||||
blog: {
|
||||
like: defineAction({
|
||||
input: z.object({ postId: z.string() }),
|
||||
handler: async ({ postId }) => {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
const { likes } = await db
|
||||
.update(Likes)
|
||||
.set({
|
||||
likes: sql`likes + 1`,
|
||||
})
|
||||
.where(eq(Likes.postId, postId))
|
||||
.returning()
|
||||
.get();
|
||||
|
||||
return likes;
|
||||
},
|
||||
}),
|
||||
|
||||
comment: defineAction({
|
||||
accept: 'form',
|
||||
input: z.object({
|
||||
postId: z.string(),
|
||||
author: z.string(),
|
||||
body: z.string().min(10),
|
||||
}),
|
||||
handler: async ({ postId, author, body }) => {
|
||||
const comment = await db
|
||||
.insert(Comment)
|
||||
.values({
|
||||
postId,
|
||||
body,
|
||||
author,
|
||||
})
|
||||
.returning()
|
||||
.get();
|
||||
return comment;
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
|
@ -0,0 +1,47 @@
|
|||
---
|
||||
// Import the global.css file here so that it is included on
|
||||
// all pages through the use of the <BaseHead /> component.
|
||||
import '../styles/global.css';
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
description: string;
|
||||
image?: string;
|
||||
}
|
||||
|
||||
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
|
||||
|
||||
const { title, description, image = '/blog-placeholder-1.jpg' } = Astro.props;
|
||||
---
|
||||
|
||||
<!-- Global Metadata -->
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
|
||||
<!-- Font preloads -->
|
||||
<link rel="preload" href="/fonts/atkinson-regular.woff" as="font" type="font/woff" crossorigin />
|
||||
<link rel="preload" href="/fonts/atkinson-bold.woff" as="font" type="font/woff" crossorigin />
|
||||
|
||||
<!-- Canonical URL -->
|
||||
<link rel="canonical" href={canonicalURL} />
|
||||
|
||||
<!-- Primary Meta Tags -->
|
||||
<title>{title}</title>
|
||||
<meta name="title" content={title} />
|
||||
<meta name="description" content={description} />
|
||||
|
||||
<!-- Open Graph / Facebook -->
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content={Astro.url} />
|
||||
<meta property="og:title" content={title} />
|
||||
<meta property="og:description" content={description} />
|
||||
<meta property="og:image" content={new URL(image, Astro.url)} />
|
||||
|
||||
<!-- Twitter -->
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:url" content={Astro.url} />
|
||||
<meta property="twitter:title" content={title} />
|
||||
<meta property="twitter:description" content={description} />
|
||||
<meta property="twitter:image" content={new URL(image, Astro.url)} />
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
const today = new Date();
|
||||
---
|
||||
|
||||
<footer>
|
||||
© {today.getFullYear()} Your name here. All rights reserved.
|
||||
<div class="social-links">
|
||||
<a href="https://m.webtoo.ls/@astro" target="_blank">
|
||||
<span class="sr-only">Follow Astro on Mastodon</span>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
width="32"
|
||||
height="32"
|
||||
astro-icon="social/mastodon"
|
||||
><path
|
||||
fill="currentColor"
|
||||
d="M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765c0 0-2.285 1.017-2.285 4.488l-.002.662c-.004.64-.007 1.35.011 2.091.083 3.394.626 6.74 3.78 7.57 1.454.383 2.703.463 3.709.408 1.823-.1 2.847-.647 2.847-.647l-.06-1.317s-1.303.41-2.767.36c-1.45-.05-2.98-.156-3.215-1.928a3.614 3.614 0 0 1-.033-.496s1.424.346 3.228.428c1.103.05 2.137-.064 3.188-.189zm1.613-2.47H11.13v-4.08c0-.859-.364-1.295-1.091-1.295-.804 0-1.207.517-1.207 1.541v2.233H7.168V5.89c0-1.024-.403-1.541-1.207-1.541-.727 0-1.091.436-1.091 1.296v4.079H3.197V5.522c0-.859.22-1.541.66-2.046.456-.505 1.052-.764 1.793-.764.856 0 1.504.328 1.933.983L8 4.39l.417-.695c.429-.655 1.077-.983 1.934-.983.74 0 1.336.259 1.791.764.442.505.661 1.187.661 2.046v4.203z"
|
||||
></path></svg
|
||||
>
|
||||
</a>
|
||||
<a href="https://twitter.com/astrodotbuild" target="_blank">
|
||||
<span class="sr-only">Follow Astro on Twitter</span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32" astro-icon="social/twitter"
|
||||
><path
|
||||
fill="currentColor"
|
||||
d="M5.026 15c6.038 0 9.341-5.003 9.341-9.334 0-.14 0-.282-.006-.422A6.685 6.685 0 0 0 16 3.542a6.658 6.658 0 0 1-1.889.518 3.301 3.301 0 0 0 1.447-1.817 6.533 6.533 0 0 1-2.087.793A3.286 3.286 0 0 0 7.875 6.03a9.325 9.325 0 0 1-6.767-3.429 3.289 3.289 0 0 0 1.018 4.382A3.323 3.323 0 0 1 .64 6.575v.045a3.288 3.288 0 0 0 2.632 3.218 3.203 3.203 0 0 1-.865.115 3.23 3.23 0 0 1-.614-.057 3.283 3.283 0 0 0 3.067 2.277A6.588 6.588 0 0 1 .78 13.58a6.32 6.32 0 0 1-.78-.045A9.344 9.344 0 0 0 5.026 15z"
|
||||
></path></svg
|
||||
>
|
||||
</a>
|
||||
<a href="https://github.com/withastro/astro" target="_blank">
|
||||
<span class="sr-only">Go to Astro's GitHub repo</span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32" astro-icon="social/github"
|
||||
><path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"
|
||||
></path></svg
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
</footer>
|
||||
<style>
|
||||
footer {
|
||||
padding: 2em 1em 6em 1em;
|
||||
background: linear-gradient(var(--gray-gradient)) no-repeat;
|
||||
color: rgb(var(--gray));
|
||||
text-align: center;
|
||||
}
|
||||
.social-links {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1em;
|
||||
margin-top: 1em;
|
||||
}
|
||||
.social-links a {
|
||||
text-decoration: none;
|
||||
color: rgb(var(--gray));
|
||||
}
|
||||
.social-links a:hover {
|
||||
color: rgb(var(--gray-dark));
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,17 @@
|
|||
---
|
||||
interface Props {
|
||||
date: Date;
|
||||
}
|
||||
|
||||
const { date } = Astro.props;
|
||||
---
|
||||
|
||||
<time datetime={date.toISOString()}>
|
||||
{
|
||||
date.toLocaleDateString('en-us', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
</time>
|
|
@ -0,0 +1,83 @@
|
|||
---
|
||||
import HeaderLink from './HeaderLink.astro';
|
||||
import { SITE_TITLE } from '../consts';
|
||||
---
|
||||
|
||||
<header>
|
||||
<nav>
|
||||
<h2><a href="/">{SITE_TITLE}</a></h2>
|
||||
<div class="internal-links">
|
||||
<HeaderLink href="/blog">Blog</HeaderLink>
|
||||
</div>
|
||||
<div class="social-links">
|
||||
<a href="https://m.webtoo.ls/@astro" target="_blank">
|
||||
<span class="sr-only">Follow Astro on Mastodon</span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32"
|
||||
><path
|
||||
fill="currentColor"
|
||||
d="M11.19 12.195c2.016-.24 3.77-1.475 3.99-2.603.348-1.778.32-4.339.32-4.339 0-3.47-2.286-4.488-2.286-4.488C12.062.238 10.083.017 8.027 0h-.05C5.92.017 3.942.238 2.79.765c0 0-2.285 1.017-2.285 4.488l-.002.662c-.004.64-.007 1.35.011 2.091.083 3.394.626 6.74 3.78 7.57 1.454.383 2.703.463 3.709.408 1.823-.1 2.847-.647 2.847-.647l-.06-1.317s-1.303.41-2.767.36c-1.45-.05-2.98-.156-3.215-1.928a3.614 3.614 0 0 1-.033-.496s1.424.346 3.228.428c1.103.05 2.137-.064 3.188-.189zm1.613-2.47H11.13v-4.08c0-.859-.364-1.295-1.091-1.295-.804 0-1.207.517-1.207 1.541v2.233H7.168V5.89c0-1.024-.403-1.541-1.207-1.541-.727 0-1.091.436-1.091 1.296v4.079H3.197V5.522c0-.859.22-1.541.66-2.046.456-.505 1.052-.764 1.793-.764.856 0 1.504.328 1.933.983L8 4.39l.417-.695c.429-.655 1.077-.983 1.934-.983.74 0 1.336.259 1.791.764.442.505.661 1.187.661 2.046v4.203z"
|
||||
></path></svg
|
||||
>
|
||||
</a>
|
||||
<a href="https://twitter.com/astrodotbuild" target="_blank">
|
||||
<span class="sr-only">Follow Astro on Twitter</span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32"
|
||||
><path
|
||||
fill="currentColor"
|
||||
d="M5.026 15c6.038 0 9.341-5.003 9.341-9.334 0-.14 0-.282-.006-.422A6.685 6.685 0 0 0 16 3.542a6.658 6.658 0 0 1-1.889.518 3.301 3.301 0 0 0 1.447-1.817 6.533 6.533 0 0 1-2.087.793A3.286 3.286 0 0 0 7.875 6.03a9.325 9.325 0 0 1-6.767-3.429 3.289 3.289 0 0 0 1.018 4.382A3.323 3.323 0 0 1 .64 6.575v.045a3.288 3.288 0 0 0 2.632 3.218 3.203 3.203 0 0 1-.865.115 3.23 3.23 0 0 1-.614-.057 3.283 3.283 0 0 0 3.067 2.277A6.588 6.588 0 0 1 .78 13.58a6.32 6.32 0 0 1-.78-.045A9.344 9.344 0 0 0 5.026 15z"
|
||||
></path></svg
|
||||
>
|
||||
</a>
|
||||
<a href="https://github.com/withastro/astro" target="_blank">
|
||||
<span class="sr-only">Go to Astro's GitHub repo</span>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true" width="32" height="32"
|
||||
><path
|
||||
fill="currentColor"
|
||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.012 8.012 0 0 0 16 8c0-4.42-3.58-8-8-8z"
|
||||
></path></svg
|
||||
>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<style>
|
||||
header {
|
||||
margin: 0;
|
||||
padding: 0 1em;
|
||||
background: white;
|
||||
box-shadow: 0 2px 8px rgba(var(--black), 5%);
|
||||
}
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
h2 a,
|
||||
h2 a.active {
|
||||
text-decoration: none;
|
||||
}
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
nav a {
|
||||
padding: 1em 0.5em;
|
||||
color: var(--black);
|
||||
border-bottom: 4px solid transparent;
|
||||
text-decoration: none;
|
||||
}
|
||||
nav a.active {
|
||||
text-decoration: none;
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
.social-links,
|
||||
.social-links a {
|
||||
display: flex;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.social-links {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,25 @@
|
|||
---
|
||||
import type { HTMLAttributes } from 'astro/types';
|
||||
|
||||
type Props = HTMLAttributes<'a'>;
|
||||
|
||||
const { href, class: className, ...props } = Astro.props;
|
||||
|
||||
const { pathname } = Astro.url;
|
||||
const subpath = pathname.match(/[^\/]+/g);
|
||||
const isActive = href === pathname || href === '/' + subpath?.[0];
|
||||
---
|
||||
|
||||
<a href={href} class:list={[className, { active: isActive }]} {...props}>
|
||||
<slot />
|
||||
</a>
|
||||
<style>
|
||||
a {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
a.active {
|
||||
font-weight: bolder;
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,22 @@
|
|||
import { actions } from 'astro:actions';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function Like({ postId, initial }: { postId: string; initial: number }) {
|
||||
const [likes, setLikes] = useState(initial);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
return (
|
||||
<button
|
||||
aria-label="Like"
|
||||
disabled={pending}
|
||||
onClick={async () => {
|
||||
setPending(true);
|
||||
setLikes(await actions.blog.like({ postId }));
|
||||
setPending(false);
|
||||
}}
|
||||
type="submit"
|
||||
>
|
||||
{likes} ❤️
|
||||
</button>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,66 @@
|
|||
import { getActionProps, actions, isInputError } from 'astro:actions';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function PostComment({
|
||||
postId,
|
||||
serverBodyError,
|
||||
}: {
|
||||
postId: string;
|
||||
serverBodyError?: string;
|
||||
}) {
|
||||
const [comments, setComments] = useState<{ author: string; body: string }[]>([]);
|
||||
const [bodyError, setBodyError] = useState<string | undefined>(serverBodyError);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
method="POST"
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const form = e.target as HTMLFormElement;
|
||||
const formData = new FormData(form);
|
||||
const { data, error } = await actions.blog.comment.safe(formData);
|
||||
if (isInputError(error)) {
|
||||
return setBodyError(error.fields.body?.join(' '));
|
||||
}
|
||||
if (data) {
|
||||
setBodyError(undefined);
|
||||
setComments((c) => [data, ...c]);
|
||||
}
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
<input {...getActionProps(actions.blog.comment)} />
|
||||
<input type="hidden" name="postId" value={postId} />
|
||||
<label className="sr-only" htmlFor="author">
|
||||
Author
|
||||
</label>
|
||||
<input id="author" type="text" name="author" placeholder="Your name" />
|
||||
<textarea rows={10} name="body"></textarea>
|
||||
{bodyError && (
|
||||
<p data-error="body" style={{ color: 'red' }}>
|
||||
{bodyError}
|
||||
</p>
|
||||
)}
|
||||
<button aria-label="Post comment" type="submit">
|
||||
Post
|
||||
</button>
|
||||
</form>
|
||||
{comments.map((c) => (
|
||||
<article
|
||||
data-testid="comment"
|
||||
key={c.body}
|
||||
style={{
|
||||
border: '2px solid color-mix(in srgb, var(--accent), transparent 80%)',
|
||||
padding: '0.3rem 1rem',
|
||||
borderRadius: '0.3rem',
|
||||
marginBlock: '0.3rem',
|
||||
}}
|
||||
>
|
||||
<p>{c.body}</p>
|
||||
<p>{c.author}</p>
|
||||
</article>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
5
packages/astro/e2e/fixtures/actions-blog/src/consts.ts
Normal file
5
packages/astro/e2e/fixtures/actions-blog/src/consts.ts
Normal file
|
@ -0,0 +1,5 @@
|
|||
// Place any global data in this file.
|
||||
// You can import this data from anywhere in your site by using the `import` keyword.
|
||||
|
||||
export const SITE_TITLE = 'Astro Blog';
|
||||
export const SITE_DESCRIPTION = 'Welcome to my website!';
|
|
@ -0,0 +1,15 @@
|
|||
---
|
||||
title: 'First post'
|
||||
description: 'Lorem ipsum dolor sit amet'
|
||||
pubDate: 'Jul 08 2022'
|
||||
---
|
||||
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Vitae ultricies leo integer malesuada nunc vel risus commodo viverra. Adipiscing enim eu turpis egestas pretium. Euismod elementum nisi quis eleifend quam adipiscing. In hac habitasse platea dictumst vestibulum. Sagittis purus sit amet volutpat. Netus et malesuada fames ac turpis egestas. Eget magna fermentum iaculis eu non diam phasellus vestibulum lorem. Varius sit amet mattis vulputate enim. Habitasse platea dictumst quisque sagittis. Integer quis auctor elit sed vulputate mi. Dictumst quisque sagittis purus sit amet.
|
||||
|
||||
Morbi tristique senectus et netus. Id semper risus in hendrerit gravida rutrum quisque non tellus. Habitasse platea dictumst quisque sagittis purus sit amet. Tellus molestie nunc non blandit massa. Cursus vitae congue mauris rhoncus. Accumsan tortor posuere ac ut. Fringilla urna porttitor rhoncus dolor. Elit ullamcorper dignissim cras tincidunt lobortis. In cursus turpis massa tincidunt dui ut ornare lectus. Integer feugiat scelerisque varius morbi enim nunc. Bibendum neque egestas congue quisque egestas diam. Cras ornare arcu dui vivamus arcu felis bibendum. Dignissim suspendisse in est ante in nibh mauris. Sed tempus urna et pharetra pharetra massa massa ultricies mi.
|
||||
|
||||
Mollis nunc sed id semper risus in. Convallis a cras semper auctor neque. Diam sit amet nisl suscipit. Lacus viverra vitae congue eu consequat ac felis donec. Egestas integer eget aliquet nibh praesent tristique magna sit amet. Eget magna fermentum iaculis eu non diam. In vitae turpis massa sed elementum. Tristique et egestas quis ipsum suspendisse ultrices. Eget lorem dolor sed viverra ipsum. Vel turpis nunc eget lorem dolor sed viverra. Posuere ac ut consequat semper viverra nam. Laoreet suspendisse interdum consectetur libero id faucibus. Diam phasellus vestibulum lorem sed risus ultricies tristique. Rhoncus dolor purus non enim praesent elementum facilisis. Ultrices tincidunt arcu non sodales neque. Tempus egestas sed sed risus pretium quam vulputate. Viverra suspendisse potenti nullam ac tortor vitae purus faucibus ornare. Fringilla urna porttitor rhoncus dolor purus non. Amet dictum sit amet justo donec enim.
|
||||
|
||||
Mattis ullamcorper velit sed ullamcorper morbi tincidunt. Tortor posuere ac ut consequat semper viverra. Tellus mauris a diam maecenas sed enim ut sem viverra. Venenatis urna cursus eget nunc scelerisque viverra mauris in. Arcu ac tortor dignissim convallis aenean et tortor at. Curabitur gravida arcu ac tortor dignissim convallis aenean et tortor. Egestas tellus rutrum tellus pellentesque eu. Fusce ut placerat orci nulla pellentesque dignissim enim sit amet. Ut enim blandit volutpat maecenas volutpat blandit aliquam etiam. Id donec ultrices tincidunt arcu. Id cursus metus aliquam eleifend mi.
|
||||
|
||||
Tempus quam pellentesque nec nam aliquam sem. Risus at ultrices mi tempus imperdiet. Id porta nibh venenatis cras sed felis eget velit. Ipsum a arcu cursus vitae. Facilisis magna etiam tempor orci eu lobortis elementum. Tincidunt dui ut ornare lectus sit. Quisque non tellus orci ac. Blandit libero volutpat sed cras. Nec tincidunt praesent semper feugiat nibh sed pulvinar proin gravida. Egestas integer eget aliquet nibh praesent tristique magna.
|
|
@ -0,0 +1,16 @@
|
|||
import { defineCollection, z } from 'astro:content';
|
||||
|
||||
const blog = defineCollection({
|
||||
type: 'content',
|
||||
// Type-check frontmatter using a schema
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
description: z.string(),
|
||||
// Transform string to Date object
|
||||
pubDate: z.coerce.date(),
|
||||
updatedDate: z.coerce.date().optional(),
|
||||
heroImage: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { blog };
|
|
@ -0,0 +1,85 @@
|
|||
---
|
||||
import type { CollectionEntry } from 'astro:content';
|
||||
import BaseHead from '../components/BaseHead.astro';
|
||||
import Header from '../components/Header.astro';
|
||||
import Footer from '../components/Footer.astro';
|
||||
import FormattedDate from '../components/FormattedDate.astro';
|
||||
|
||||
type Props = CollectionEntry<'blog'>['data'];
|
||||
|
||||
const { title, description, pubDate, updatedDate, heroImage } = Astro.props;
|
||||
---
|
||||
|
||||
<html lang="en">
|
||||
<head>
|
||||
<BaseHead title={title} description={description} />
|
||||
<style>
|
||||
main {
|
||||
width: calc(100% - 2em);
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
.hero-image {
|
||||
width: 100%;
|
||||
}
|
||||
.hero-image img {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
border-radius: 12px;
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
.prose {
|
||||
width: 720px;
|
||||
max-width: calc(100% - 2em);
|
||||
margin: auto;
|
||||
padding: 1em;
|
||||
color: rgb(var(--gray-dark));
|
||||
}
|
||||
.title {
|
||||
margin-bottom: 1em;
|
||||
padding: 1em 0;
|
||||
text-align: center;
|
||||
line-height: 1;
|
||||
}
|
||||
.title h1 {
|
||||
margin: 0 0 0.5em 0;
|
||||
}
|
||||
.date {
|
||||
margin-bottom: 0.5em;
|
||||
color: rgb(var(--gray));
|
||||
}
|
||||
.last-updated-on {
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Header />
|
||||
<main>
|
||||
<article>
|
||||
<div class="hero-image">
|
||||
{heroImage && <img width={1020} height={510} src={heroImage} alt="" />}
|
||||
</div>
|
||||
<div class="prose">
|
||||
<div class="title">
|
||||
<div class="date">
|
||||
<FormattedDate date={pubDate} />
|
||||
{
|
||||
updatedDate && (
|
||||
<div class="last-updated-on">
|
||||
Last updated on <FormattedDate date={updatedDate} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<h1>{title}</h1>
|
||||
<hr />
|
||||
</div>
|
||||
<slot />
|
||||
</div>
|
||||
</article>
|
||||
</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,61 @@
|
|||
---
|
||||
import { type CollectionEntry, getCollection, getEntry } from 'astro:content';
|
||||
import BlogPost from '../../layouts/BlogPost.astro';
|
||||
import { db, eq, Comment, Likes } from 'astro:db';
|
||||
import { Like } from '../../components/Like';
|
||||
import { PostComment } from '../../components/PostComment';
|
||||
import { actions } from 'astro:actions';
|
||||
import { isInputError } from 'astro:actions';
|
||||
|
||||
export const prerender = false;
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const posts = await getCollection('blog');
|
||||
return posts.map((post) => ({
|
||||
params: { slug: post.slug },
|
||||
props: post,
|
||||
}));
|
||||
}
|
||||
|
||||
type Props = CollectionEntry<'blog'>;
|
||||
|
||||
const post = await getEntry('blog', Astro.params.slug)!;
|
||||
const { Content } = await post.render();
|
||||
|
||||
const comment = Astro.getActionResult(actions.blog.comment);
|
||||
|
||||
const comments = await db.select().from(Comment).where(eq(Comment.postId, post.id));
|
||||
|
||||
const initialLikes = await db.select().from(Likes).where(eq(Likes.postId, post.id)).get();
|
||||
---
|
||||
|
||||
<BlogPost {...post.data}>
|
||||
<Like postId={post.id} initial={initialLikes?.likes ?? 0} client:load />
|
||||
|
||||
<Content />
|
||||
|
||||
<h2>Comments</h2>
|
||||
<PostComment
|
||||
postId={post.id}
|
||||
serverBodyError={isInputError(comment?.error)
|
||||
? comment.error.fields.body?.toString()
|
||||
: undefined}
|
||||
client:load
|
||||
/>
|
||||
<div>
|
||||
{
|
||||
comments.map((c) => (
|
||||
<article>
|
||||
<p>{c.body}</p>
|
||||
<p>{c.author}</p>
|
||||
</article>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</BlogPost>
|
||||
|
||||
<style>
|
||||
.error {
|
||||
color: red;
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,111 @@
|
|||
---
|
||||
import BaseHead from '../../components/BaseHead.astro';
|
||||
import Header from '../../components/Header.astro';
|
||||
import Footer from '../../components/Footer.astro';
|
||||
import { SITE_TITLE, SITE_DESCRIPTION } from '../../consts';
|
||||
import { getCollection } from 'astro:content';
|
||||
import FormattedDate from '../../components/FormattedDate.astro';
|
||||
|
||||
const posts = (await getCollection('blog')).sort(
|
||||
(a, b) => a.data.pubDate.valueOf() - b.data.pubDate.valueOf()
|
||||
);
|
||||
---
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<BaseHead title={SITE_TITLE} description={SITE_DESCRIPTION} />
|
||||
<style>
|
||||
main {
|
||||
width: 960px;
|
||||
}
|
||||
ul {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2rem;
|
||||
list-style-type: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
ul li {
|
||||
width: calc(50% - 1rem);
|
||||
}
|
||||
ul li * {
|
||||
text-decoration: none;
|
||||
transition: 0.2s ease;
|
||||
}
|
||||
ul li:first-child {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
ul li:first-child img {
|
||||
width: 100%;
|
||||
}
|
||||
ul li:first-child .title {
|
||||
font-size: 2.369rem;
|
||||
}
|
||||
ul li img {
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
ul li a {
|
||||
display: block;
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
color: rgb(var(--black));
|
||||
line-height: 1;
|
||||
}
|
||||
.date {
|
||||
margin: 0;
|
||||
color: rgb(var(--gray));
|
||||
}
|
||||
ul li a:hover h4,
|
||||
ul li a:hover .date {
|
||||
color: rgb(var(--accent));
|
||||
}
|
||||
ul a:hover img {
|
||||
box-shadow: var(--box-shadow);
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
ul {
|
||||
gap: 0.5em;
|
||||
}
|
||||
ul li {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
ul li:first-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
ul li:first-child .title {
|
||||
font-size: 1.563em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<Header />
|
||||
<main>
|
||||
<section>
|
||||
<ul>
|
||||
{
|
||||
posts.map((post) => (
|
||||
<li>
|
||||
<a href={`/blog/${post.slug}/`}>
|
||||
<img width={720} height={360} src={post.data.heroImage} alt="" />
|
||||
<h4 class="title">{post.data.title}</h4>
|
||||
<p class="date">
|
||||
<FormattedDate date={post.data.pubDate} />
|
||||
</p>
|
||||
</a>
|
||||
</li>
|
||||
))
|
||||
}
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
140
packages/astro/e2e/fixtures/actions-blog/src/styles/global.css
Normal file
140
packages/astro/e2e/fixtures/actions-blog/src/styles/global.css
Normal file
|
@ -0,0 +1,140 @@
|
|||
/*
|
||||
The CSS in this style tag is based off of Bear Blog's default CSS.
|
||||
https://github.com/HermanMartinus/bearblog/blob/297026a877bc2ab2b3bdfbd6b9f7961c350917dd/templates/styles/blog/default.css
|
||||
License MIT: https://github.com/HermanMartinus/bearblog/blob/master/LICENSE.md
|
||||
*/
|
||||
|
||||
:root {
|
||||
--accent: #2337ff;
|
||||
--accent-dark: #000d8a;
|
||||
--black: 15, 18, 25;
|
||||
--gray: 96, 115, 159;
|
||||
--gray-light: 229, 233, 240;
|
||||
--gray-dark: 34, 41, 57;
|
||||
--gray-gradient: rgba(var(--gray-light), 50%), #fff;
|
||||
--box-shadow: 0 2px 6px rgba(var(--gray), 25%), 0 8px 24px rgba(var(--gray), 33%),
|
||||
0 16px 32px rgba(var(--gray), 33%);
|
||||
}
|
||||
body {
|
||||
font-family: sans-serif;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
background: linear-gradient(var(--gray-gradient)) no-repeat;
|
||||
background-size: 100% 600px;
|
||||
word-wrap: break-word;
|
||||
overflow-wrap: break-word;
|
||||
color: rgb(var(--gray-dark));
|
||||
font-size: 20px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
main {
|
||||
width: 720px;
|
||||
max-width: calc(100% - 2em);
|
||||
margin: auto;
|
||||
padding: 3em 1em;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: rgb(var(--black));
|
||||
line-height: 1.2;
|
||||
}
|
||||
h1 {
|
||||
font-size: 3.052em;
|
||||
}
|
||||
h2 {
|
||||
font-size: 2.441em;
|
||||
}
|
||||
h3 {
|
||||
font-size: 1.953em;
|
||||
}
|
||||
h4 {
|
||||
font-size: 1.563em;
|
||||
}
|
||||
h5 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
strong,
|
||||
b {
|
||||
font-weight: 700;
|
||||
}
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
a:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
p {
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
.prose p {
|
||||
margin-bottom: 2em;
|
||||
}
|
||||
textarea {
|
||||
width: 100%;
|
||||
font-size: 16px;
|
||||
}
|
||||
input {
|
||||
font-size: 16px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
}
|
||||
code {
|
||||
padding: 2px 5px;
|
||||
background-color: rgb(var(--gray-light));
|
||||
border-radius: 2px;
|
||||
}
|
||||
pre {
|
||||
padding: 1.5em;
|
||||
border-radius: 8px;
|
||||
}
|
||||
pre > code {
|
||||
all: unset;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 4px solid var(--accent);
|
||||
padding: 0 0 0 20px;
|
||||
margin: 0px;
|
||||
font-size: 1.333em;
|
||||
}
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid rgb(var(--gray-light));
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
body {
|
||||
font-size: 18px;
|
||||
}
|
||||
main {
|
||||
padding: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
position: absolute !important;
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
overflow: hidden;
|
||||
/* IE6, IE7 - a 0 height clip, off to the bottom right of the visible 1px box */
|
||||
clip: rect(1px 1px 1px 1px);
|
||||
/* maybe deprecated but we need to support legacy browsers */
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
/* modern browsers, clip-path works inwards from each corner */
|
||||
clip-path: inset(50%);
|
||||
/* added line to stop words getting smushed together (as they go onto seperate lines and some screen readers do not understand line feeds as a space */
|
||||
white-space: nowrap;
|
||||
}
|
8
packages/astro/e2e/fixtures/actions-blog/tsconfig.json
Normal file
8
packages/astro/e2e/fixtures/actions-blog/tsconfig.json
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"extends": "astro/tsconfigs/strict",
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react"
|
||||
}
|
||||
}
|
|
@ -54,6 +54,7 @@
|
|||
"./components": "./components/index.ts",
|
||||
"./components/*": "./components/*",
|
||||
"./toolbar": "./dist/toolbar/index.js",
|
||||
"./actions/runtime/*": "./dist/actions/runtime/*",
|
||||
"./assets": "./dist/assets/index.js",
|
||||
"./assets/utils": "./dist/assets/utils/index.js",
|
||||
"./assets/endpoint/*": "./dist/assets/endpoint/*.js",
|
||||
|
@ -85,6 +86,7 @@
|
|||
"components",
|
||||
"tsconfigs",
|
||||
"dist",
|
||||
"types",
|
||||
"astro.js",
|
||||
"index.d.ts",
|
||||
"config.d.ts",
|
||||
|
@ -96,8 +98,8 @@
|
|||
"jsx-runtime.d.ts",
|
||||
"content-types.template.d.ts",
|
||||
"content-module.template.mjs",
|
||||
"templates",
|
||||
"astro-jsx.d.ts",
|
||||
"types/content.d.ts",
|
||||
"types.d.ts",
|
||||
"README.md",
|
||||
"vendor"
|
||||
|
|
|
@ -46,6 +46,7 @@ import type {
|
|||
} from '../transitions/events.js';
|
||||
import type { DeepPartial, OmitIndexSignature, Simplify, WithRequired } from '../type-utils.js';
|
||||
import type { SUPPORTED_MARKDOWN_FILE_EXTENSIONS } from './../core/constants.js';
|
||||
import type { Accept, ActionClient, InputSchema } from '../actions/runtime/virtual/server.js';
|
||||
|
||||
export type { AstroIntegrationLogger, ToolbarServerHelpers };
|
||||
|
||||
|
@ -239,6 +240,22 @@ export interface AstroGlobal<
|
|||
response: ResponseInit & {
|
||||
readonly headers: Headers;
|
||||
};
|
||||
/**
|
||||
* Get an action result on the server when using a form POST.
|
||||
* Expects the action function as a parameter.
|
||||
* Returns a type-safe result with the action data when
|
||||
* a matching POST request is received
|
||||
* and `undefined` otherwise.
|
||||
*
|
||||
* Example usage:
|
||||
*
|
||||
* ```typescript
|
||||
* import { actions } from 'astro:actions';
|
||||
*
|
||||
* const result = await Astro.getActionResult(actions.myAction);
|
||||
* ```
|
||||
*/
|
||||
getActionResult: AstroSharedContext['getActionResult'];
|
||||
/** Redirect to another page (**SSR Only**)
|
||||
*
|
||||
* Example usage:
|
||||
|
@ -1705,6 +1722,105 @@ export interface AstroUserConfig {
|
|||
*/
|
||||
directRenderScript?: boolean;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @name experimental.actions
|
||||
* @type {boolean}
|
||||
* @default `false`
|
||||
* @version 4.7.0
|
||||
* @description
|
||||
*
|
||||
* Actions help you write type-safe backend functions you can call from anywhere. Enable server rendering [using the `output` property](https://docs.astro.build/en/basics/rendering-modes/#on-demand-rendered) and add the `actions` flag to the `experimental` object:
|
||||
*
|
||||
* ```js
|
||||
* {
|
||||
* output: 'hybrid', // or 'server'
|
||||
* experimental: {
|
||||
* actions: true,
|
||||
* },
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Declare all your actions in `src/actions/index.ts`. This file is the global actions handler.
|
||||
*
|
||||
* Define an action using the `defineAction()` utility from the `astro:actions` module. These accept the `handler` property to define your server-side request handler. If your action accepts arguments, apply the `input` property to validate parameters with Zod.
|
||||
*
|
||||
* This example defines two actions: `like` and `comment`. The `like` action accepts a JSON object with a `postId` string, while the `comment` action accepts [FormData](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects) with `postId`, `author`, and `body` strings. Each `handler` updates your database and return a type-safe response.
|
||||
*
|
||||
* ```ts
|
||||
* // src/actions/index.ts
|
||||
* import { defineAction, z } from "astro:actions";
|
||||
*
|
||||
* export const server = {
|
||||
* like: defineAction({
|
||||
* input: z.object({ postId: z.string() }),
|
||||
* handler: async ({ postId }, context) => {
|
||||
* // update likes in db
|
||||
*
|
||||
* return likes;
|
||||
* },
|
||||
* }),
|
||||
* comment: defineAction({
|
||||
* accept: 'form',
|
||||
* input: z.object({
|
||||
* postId: z.string(),
|
||||
* author: z.string(),
|
||||
* body: z.string(),
|
||||
* }),
|
||||
* handler: async ({ postId }, context) => {
|
||||
* // insert comments in db
|
||||
*
|
||||
* return comment;
|
||||
* },
|
||||
* }),
|
||||
* };
|
||||
* ```
|
||||
*
|
||||
* Then, call an action from your client components using the `actions` object from `astro:actions`. You can pass a type-safe object when using JSON, or a [FormData](https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest_API/Using_FormData_Objects) object when using `accept: 'form'` in your action definition:
|
||||
*
|
||||
* ```tsx "actions"
|
||||
* // src/components/blog.tsx
|
||||
* import { actions } from "astro:actions";
|
||||
* import { useState } from "preact/hooks";
|
||||
*
|
||||
* export function Like({ postId }: { postId: string }) {
|
||||
* const [likes, setLikes] = useState(0);
|
||||
* return (
|
||||
* <button
|
||||
* onClick={async () => {
|
||||
* const newLikes = await actions.like({ postId });
|
||||
* setLikes(newLikes);
|
||||
* }}
|
||||
* >
|
||||
* {likes} likes
|
||||
* </button>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* export function Comment({ postId }: { postId: string }) {
|
||||
* return (
|
||||
* <form
|
||||
* onSubmit={async (e) => {
|
||||
* e.preventDefault();
|
||||
* const formData = new FormData(e.target);
|
||||
* const result = await actions.blog.comment(formData);
|
||||
* // handle result
|
||||
* }}
|
||||
* >
|
||||
* <input type="hidden" name="postId" value={postId} />
|
||||
* <label for="author">Author</label>
|
||||
* <input id="author" type="text" name="author" />
|
||||
* <textarea rows={10} name="body"></textarea>
|
||||
* <button type="submit">Post</button>
|
||||
* </form>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* For a complete overview, and to give feedback on this experimental API, see the [Actions RFC](https://github.com/withastro/roadmap/blob/actions/proposals/0046-actions.md).
|
||||
*/
|
||||
actions?: boolean;
|
||||
|
||||
/**
|
||||
* @docs
|
||||
* @name experimental.contentCollectionCache
|
||||
|
@ -2548,6 +2664,16 @@ interface AstroSharedContext<
|
|||
* A full URL object of the request URL.
|
||||
*/
|
||||
url: URL;
|
||||
/**
|
||||
* Get action result on the server when using a form POST.
|
||||
*/
|
||||
getActionResult: <
|
||||
TAccept extends Accept,
|
||||
TInputSchema extends InputSchema<TAccept>,
|
||||
TAction extends ActionClient<unknown, TAccept, TInputSchema>,
|
||||
>(
|
||||
action: TAction
|
||||
) => Awaited<ReturnType<TAction['safe']>> | undefined;
|
||||
/**
|
||||
* Route parameters for this request if this is a dynamic route.
|
||||
*/
|
||||
|
|
3
packages/astro/src/actions/consts.ts
Normal file
3
packages/astro/src/actions/consts.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export const VIRTUAL_MODULE_ID = 'astro:actions';
|
||||
export const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;
|
||||
export const ACTIONS_TYPES_FILE = 'actions.d.ts';
|
81
packages/astro/src/actions/index.ts
Normal file
81
packages/astro/src/actions/index.ts
Normal file
|
@ -0,0 +1,81 @@
|
|||
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import type { AstroIntegration } from '../@types/astro.js';
|
||||
import { ACTIONS_TYPES_FILE, RESOLVED_VIRTUAL_MODULE_ID, VIRTUAL_MODULE_ID } from './consts.js';
|
||||
import type { Plugin as VitePlugin } from 'vite';
|
||||
|
||||
export default function astroActions(): AstroIntegration {
|
||||
return {
|
||||
name: VIRTUAL_MODULE_ID,
|
||||
hooks: {
|
||||
async 'astro:config:setup'(params) {
|
||||
const stringifiedActionsImport = JSON.stringify(
|
||||
new URL('actions', params.config.srcDir).pathname
|
||||
);
|
||||
params.updateConfig({
|
||||
vite: {
|
||||
define: {
|
||||
'import.meta.env.ACTIONS_PATH': stringifiedActionsImport,
|
||||
},
|
||||
plugins: [vitePluginActions],
|
||||
},
|
||||
});
|
||||
|
||||
params.injectRoute({
|
||||
pattern: '/_actions/[...path]',
|
||||
entrypoint: 'astro/actions/runtime/route.js',
|
||||
prerender: false,
|
||||
});
|
||||
|
||||
params.addMiddleware({
|
||||
entrypoint: 'astro/actions/runtime/middleware.js',
|
||||
order: 'pre',
|
||||
});
|
||||
|
||||
await typegen({
|
||||
stringifiedActionsImport,
|
||||
root: params.config.root,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const vitePluginActions: VitePlugin = {
|
||||
name: VIRTUAL_MODULE_ID,
|
||||
enforce: 'pre',
|
||||
resolveId(id) {
|
||||
if (id === VIRTUAL_MODULE_ID) {
|
||||
return RESOLVED_VIRTUAL_MODULE_ID;
|
||||
}
|
||||
},
|
||||
async load(id, opts) {
|
||||
if (id !== RESOLVED_VIRTUAL_MODULE_ID) return;
|
||||
|
||||
let code = await readFile(new URL('../../templates/actions.mjs', import.meta.url), 'utf-8');
|
||||
if (opts?.ssr) {
|
||||
code += `\nexport * from 'astro/actions/runtime/virtual/server.js';`;
|
||||
} else {
|
||||
code += `\nexport * from 'astro/actions/runtime/virtual/client.js';`;
|
||||
}
|
||||
return code;
|
||||
},
|
||||
};
|
||||
|
||||
async function typegen({
|
||||
stringifiedActionsImport,
|
||||
root,
|
||||
}: {
|
||||
stringifiedActionsImport: string;
|
||||
root: URL;
|
||||
}) {
|
||||
const content = `declare module "astro:actions" {
|
||||
type Actions = typeof import(${stringifiedActionsImport})["server"];
|
||||
|
||||
export const actions: Actions;
|
||||
}`;
|
||||
|
||||
const dotAstroDir = new URL('.astro/', root);
|
||||
|
||||
await mkdir(dotAstroDir, { recursive: true });
|
||||
await writeFile(new URL(ACTIONS_TYPES_FILE, dotAstroDir), content);
|
||||
}
|
52
packages/astro/src/actions/runtime/middleware.ts
Normal file
52
packages/astro/src/actions/runtime/middleware.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
import { defineMiddleware } from '../../core/middleware/index.js';
|
||||
import { ApiContextStorage } from './store.js';
|
||||
import { formContentTypes, getAction, hasContentType } from './utils.js';
|
||||
import { callSafely } from './virtual/shared.js';
|
||||
import type { APIContext, MiddlewareNext } from '../../@types/astro.js';
|
||||
|
||||
export type Locals = {
|
||||
_actionsInternal: {
|
||||
getActionResult: APIContext['getActionResult'];
|
||||
};
|
||||
};
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
const locals = context.locals as Locals;
|
||||
const { request, url } = context;
|
||||
const contentType = request.headers.get('Content-Type');
|
||||
|
||||
// Avoid double-handling with middleware when calling actions directly.
|
||||
if (url.pathname.startsWith('/_actions')) return nextWithLocalsStub(next, locals);
|
||||
|
||||
if (!contentType || !hasContentType(contentType, formContentTypes))
|
||||
return nextWithLocalsStub(next, locals);
|
||||
|
||||
const formData = await request.clone().formData();
|
||||
const actionPath = formData.get('_astroAction');
|
||||
if (typeof actionPath !== 'string') return nextWithLocalsStub(next, locals);
|
||||
|
||||
const actionPathKeys = actionPath.replace('/_actions/', '').split('.');
|
||||
const action = await getAction(actionPathKeys);
|
||||
const result = await ApiContextStorage.run(context, () => callSafely(() => action(formData)));
|
||||
|
||||
const actionsInternal: Locals['_actionsInternal'] = {
|
||||
getActionResult: (actionFn) => {
|
||||
if (actionFn.toString() !== actionPath) return Promise.resolve(undefined);
|
||||
// The `action` uses type `unknown` since we can't infer the user's action type.
|
||||
// Cast to `any` to satisfy `getActionResult()` type.
|
||||
return result as any;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(locals, '_actionsInternal', { writable: false, value: actionsInternal });
|
||||
return next();
|
||||
});
|
||||
|
||||
function nextWithLocalsStub(next: MiddlewareNext, locals: Locals) {
|
||||
Object.defineProperty(locals, '_actionsInternal', {
|
||||
writable: false,
|
||||
value: {
|
||||
getActionResult: () => undefined,
|
||||
},
|
||||
});
|
||||
return next();
|
||||
}
|
39
packages/astro/src/actions/runtime/route.ts
Normal file
39
packages/astro/src/actions/runtime/route.ts
Normal file
|
@ -0,0 +1,39 @@
|
|||
import type { APIRoute } from '../../@types/astro.js';
|
||||
import { ApiContextStorage } from './store.js';
|
||||
import { formContentTypes, getAction, hasContentType } from './utils.js';
|
||||
import { callSafely } from './virtual/shared.js';
|
||||
|
||||
export const POST: APIRoute = async (context) => {
|
||||
const { request, url } = context;
|
||||
const actionPathKeys = url.pathname.replace('/_actions/', '').split('.');
|
||||
const action = await getAction(actionPathKeys);
|
||||
const contentType = request.headers.get('Content-Type');
|
||||
let args: unknown;
|
||||
if (contentType && hasContentType(contentType, formContentTypes)) {
|
||||
args = await request.clone().formData();
|
||||
} else if (contentType && hasContentType(contentType, ['application/json'])) {
|
||||
args = await request.clone().json();
|
||||
} else {
|
||||
// 415: Unsupported media type
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/415
|
||||
return new Response(null, { status: 415 });
|
||||
}
|
||||
const result = await ApiContextStorage.run(context, () => callSafely(() => action(args)));
|
||||
if (result.error) {
|
||||
if (import.meta.env.PROD) {
|
||||
// Avoid leaking stack trace in production
|
||||
result.error.stack = undefined;
|
||||
}
|
||||
return new Response(JSON.stringify(result.error), {
|
||||
status: result.error.status,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
return new Response(JSON.stringify(result.data), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
};
|
18
packages/astro/src/actions/runtime/store.ts
Normal file
18
packages/astro/src/actions/runtime/store.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
import type { APIContext } from '../../@types/astro.js';
|
||||
import { AstroError } from '../../core/errors/errors.js';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
export type ActionAPIContext = Omit<APIContext, 'getActionResult' | 'props'>;
|
||||
export const ApiContextStorage = new AsyncLocalStorage<ActionAPIContext>();
|
||||
|
||||
export function getApiContext(): ActionAPIContext {
|
||||
const context = ApiContextStorage.getStore();
|
||||
if (!context) {
|
||||
throw new AstroError({
|
||||
name: 'AstroActionError',
|
||||
message: 'Unable to get API context.',
|
||||
hint: 'If you attempted to call this action from server code, trying using `Astro.getActionResult()` instead.',
|
||||
});
|
||||
}
|
||||
return context;
|
||||
}
|
27
packages/astro/src/actions/runtime/utils.ts
Normal file
27
packages/astro/src/actions/runtime/utils.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
export const formContentTypes = ['application/x-www-form-urlencoded', 'multipart/form-data'];
|
||||
|
||||
export function hasContentType(contentType: string, expected: string[]) {
|
||||
// Split off parameters like charset or boundary
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type#content-type_in_html_forms
|
||||
const type = contentType.split(';')[0].toLowerCase();
|
||||
|
||||
return expected.some((t) => type === t);
|
||||
}
|
||||
|
||||
export type MaybePromise<T> = T | Promise<T>;
|
||||
|
||||
export async function getAction(
|
||||
pathKeys: string[]
|
||||
): Promise<(param: unknown) => MaybePromise<unknown>> {
|
||||
let { server: actionLookup } = await import(import.meta.env.ACTIONS_PATH);
|
||||
for (const key of pathKeys) {
|
||||
if (!(key in actionLookup)) {
|
||||
throw new Error('Action not found');
|
||||
}
|
||||
actionLookup = actionLookup[key];
|
||||
}
|
||||
if (typeof actionLookup !== 'function') {
|
||||
throw new Error('Action not found');
|
||||
}
|
||||
return actionLookup;
|
||||
}
|
18
packages/astro/src/actions/runtime/virtual/client.ts
Normal file
18
packages/astro/src/actions/runtime/virtual/client.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
export * from './shared.js';
|
||||
|
||||
export function defineAction() {
|
||||
throw new Error('[astro:action] `defineAction()` unexpectedly used on the client.');
|
||||
}
|
||||
|
||||
export function getApiContext() {
|
||||
throw new Error('[astro:action] `getApiContext()` unexpectedly used on the client.');
|
||||
}
|
||||
|
||||
export const z = new Proxy(
|
||||
{},
|
||||
{
|
||||
get() {
|
||||
throw new Error('[astro:action] `z` unexpectedly used on the client.');
|
||||
},
|
||||
}
|
||||
);
|
172
packages/astro/src/actions/runtime/virtual/server.ts
Normal file
172
packages/astro/src/actions/runtime/virtual/server.ts
Normal file
|
@ -0,0 +1,172 @@
|
|||
import { z } from 'zod';
|
||||
import { getApiContext } from '../store.js';
|
||||
import { hasContentType, type MaybePromise } from '../utils.js';
|
||||
import {
|
||||
ActionError,
|
||||
ActionInputError,
|
||||
callSafely,
|
||||
type ErrorInferenceObject,
|
||||
type SafeResult,
|
||||
} from './shared.js';
|
||||
|
||||
export * from './shared.js';
|
||||
|
||||
export { z } from 'zod';
|
||||
|
||||
export { getApiContext } from '../store.js';
|
||||
|
||||
export type Accept = 'form' | 'json';
|
||||
export type InputSchema<T extends Accept> = T extends 'form'
|
||||
? z.AnyZodObject | z.ZodType<FormData>
|
||||
: z.ZodType;
|
||||
|
||||
type Handler<TInputSchema, TOutput> = TInputSchema extends z.ZodType
|
||||
? (input: z.infer<TInputSchema>) => MaybePromise<TOutput>
|
||||
: (input?: any) => MaybePromise<TOutput>;
|
||||
|
||||
export type ActionClient<
|
||||
TOutput,
|
||||
TAccept extends Accept,
|
||||
TInputSchema extends InputSchema<TAccept> | undefined,
|
||||
> = TInputSchema extends z.ZodType
|
||||
? ((
|
||||
input: TAccept extends 'form' ? FormData : z.input<TInputSchema>
|
||||
) => Promise<Awaited<TOutput>>) & {
|
||||
safe: (
|
||||
input: TAccept extends 'form' ? FormData : z.input<TInputSchema>
|
||||
) => Promise<
|
||||
SafeResult<
|
||||
z.input<TInputSchema> extends ErrorInferenceObject
|
||||
? z.input<TInputSchema>
|
||||
: ErrorInferenceObject,
|
||||
Awaited<TOutput>
|
||||
>
|
||||
>;
|
||||
}
|
||||
: ((input?: any) => Promise<Awaited<TOutput>>) & {
|
||||
safe: (input?: any) => Promise<SafeResult<never, Awaited<TOutput>>>;
|
||||
};
|
||||
|
||||
export function defineAction<
|
||||
TOutput,
|
||||
TAccept extends Accept = 'json',
|
||||
TInputSchema extends InputSchema<Accept> | undefined = TAccept extends 'form'
|
||||
? // If `input` is omitted, default to `FormData` for forms and `any` for JSON.
|
||||
z.ZodType<FormData>
|
||||
: undefined,
|
||||
>({
|
||||
accept,
|
||||
input: inputSchema,
|
||||
handler,
|
||||
}: {
|
||||
input?: TInputSchema;
|
||||
accept?: TAccept;
|
||||
handler: Handler<TInputSchema, TOutput>;
|
||||
}): ActionClient<TOutput, TAccept, TInputSchema> {
|
||||
const serverHandler =
|
||||
accept === 'form'
|
||||
? getFormServerHandler(handler, inputSchema)
|
||||
: getJsonServerHandler(handler, inputSchema);
|
||||
|
||||
Object.assign(serverHandler, {
|
||||
safe: async (unparsedInput: unknown) => {
|
||||
return callSafely(() => serverHandler(unparsedInput));
|
||||
},
|
||||
});
|
||||
return serverHandler as ActionClient<TOutput, TAccept, TInputSchema>;
|
||||
}
|
||||
|
||||
function getFormServerHandler<TOutput, TInputSchema extends InputSchema<'form'>>(
|
||||
handler: Handler<TInputSchema, TOutput>,
|
||||
inputSchema?: TInputSchema
|
||||
) {
|
||||
return async (unparsedInput: unknown): Promise<Awaited<TOutput>> => {
|
||||
if (!(unparsedInput instanceof FormData)) {
|
||||
throw new ActionError({
|
||||
code: 'UNSUPPORTED_MEDIA_TYPE',
|
||||
message: 'This action only accepts FormData.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!(inputSchema instanceof z.ZodObject)) return await handler(unparsedInput);
|
||||
|
||||
const parsed = await inputSchema.safeParseAsync(formDataToObject(unparsedInput, inputSchema));
|
||||
if (!parsed.success) {
|
||||
throw new ActionInputError(parsed.error.issues);
|
||||
}
|
||||
return await handler(parsed.data);
|
||||
};
|
||||
}
|
||||
|
||||
function getJsonServerHandler<TOutput, TInputSchema extends InputSchema<'json'>>(
|
||||
handler: Handler<TInputSchema, TOutput>,
|
||||
inputSchema?: TInputSchema
|
||||
) {
|
||||
return async (unparsedInput: unknown): Promise<Awaited<TOutput>> => {
|
||||
const context = getApiContext();
|
||||
const contentType = context.request.headers.get('content-type');
|
||||
if (!contentType || !hasContentType(contentType, ['application/json'])) {
|
||||
throw new ActionError({
|
||||
code: 'UNSUPPORTED_MEDIA_TYPE',
|
||||
message: 'This action only accepts JSON.',
|
||||
});
|
||||
}
|
||||
|
||||
if (!inputSchema) return await handler(unparsedInput);
|
||||
const parsed = await inputSchema.safeParseAsync(unparsedInput);
|
||||
if (!parsed.success) {
|
||||
throw new ActionInputError(parsed.error.issues);
|
||||
}
|
||||
return await handler(parsed.data);
|
||||
};
|
||||
}
|
||||
|
||||
/** Transform form data to an object based on a Zod schema. */
|
||||
export function formDataToObject<T extends z.AnyZodObject>(
|
||||
formData: FormData,
|
||||
schema: T
|
||||
): Record<string, unknown> {
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const [key, baseValidator] of Object.entries(schema.shape)) {
|
||||
let validator = baseValidator;
|
||||
if (baseValidator instanceof z.ZodOptional || baseValidator instanceof z.ZodNullable) {
|
||||
validator = baseValidator._def.innerType;
|
||||
}
|
||||
if (validator instanceof z.ZodBoolean) {
|
||||
obj[key] = formData.has(key);
|
||||
} else if (validator instanceof z.ZodArray) {
|
||||
obj[key] = handleFormDataGetAll(key, formData, validator);
|
||||
} else {
|
||||
obj[key] = handleFormDataGet(key, formData, validator, baseValidator);
|
||||
}
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
function handleFormDataGetAll(
|
||||
key: string,
|
||||
formData: FormData,
|
||||
validator: z.ZodArray<z.ZodUnknown>
|
||||
) {
|
||||
const entries = Array.from(formData.getAll(key));
|
||||
const elementValidator = validator._def.type;
|
||||
if (elementValidator instanceof z.ZodNumber) {
|
||||
return entries.map(Number);
|
||||
} else if (elementValidator instanceof z.ZodBoolean) {
|
||||
return entries.map(Boolean);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function handleFormDataGet(
|
||||
key: string,
|
||||
formData: FormData,
|
||||
validator: unknown,
|
||||
baseValidator: unknown
|
||||
) {
|
||||
const value = formData.get(key);
|
||||
if (!value) {
|
||||
return baseValidator instanceof z.ZodOptional ? undefined : null;
|
||||
}
|
||||
return validator instanceof z.ZodNumber ? Number(value) : value;
|
||||
}
|
151
packages/astro/src/actions/runtime/virtual/shared.ts
Normal file
151
packages/astro/src/actions/runtime/virtual/shared.ts
Normal file
|
@ -0,0 +1,151 @@
|
|||
import type { z } from 'zod';
|
||||
import type { MaybePromise } from '../utils.js';
|
||||
|
||||
type ActionErrorCode =
|
||||
| 'BAD_REQUEST'
|
||||
| 'UNAUTHORIZED'
|
||||
| 'FORBIDDEN'
|
||||
| 'NOT_FOUND'
|
||||
| 'TIMEOUT'
|
||||
| 'CONFLICT'
|
||||
| 'PRECONDITION_FAILED'
|
||||
| 'PAYLOAD_TOO_LARGE'
|
||||
| 'UNSUPPORTED_MEDIA_TYPE'
|
||||
| 'UNPROCESSABLE_CONTENT'
|
||||
| 'TOO_MANY_REQUESTS'
|
||||
| 'CLIENT_CLOSED_REQUEST'
|
||||
| 'INTERNAL_SERVER_ERROR';
|
||||
|
||||
const codeToStatusMap: Record<ActionErrorCode, number> = {
|
||||
// Implemented from tRPC error code table
|
||||
// https://trpc.io/docs/server/error-handling#error-codes
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
TIMEOUT: 405,
|
||||
CONFLICT: 409,
|
||||
PRECONDITION_FAILED: 412,
|
||||
PAYLOAD_TOO_LARGE: 413,
|
||||
UNSUPPORTED_MEDIA_TYPE: 415,
|
||||
UNPROCESSABLE_CONTENT: 422,
|
||||
TOO_MANY_REQUESTS: 429,
|
||||
CLIENT_CLOSED_REQUEST: 499,
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
};
|
||||
|
||||
const statusToCodeMap: Record<number, ActionErrorCode> = Object.entries(codeToStatusMap).reduce(
|
||||
// reverse the key-value pairs
|
||||
(acc, [key, value]) => ({ ...acc, [value]: key }),
|
||||
{}
|
||||
);
|
||||
|
||||
export type ErrorInferenceObject = Record<string, any>;
|
||||
|
||||
export class ActionError<T extends ErrorInferenceObject = ErrorInferenceObject> extends Error {
|
||||
type = 'AstroActionError';
|
||||
code: ActionErrorCode = 'INTERNAL_SERVER_ERROR';
|
||||
status = 500;
|
||||
|
||||
constructor(params: { message?: string; code: ActionErrorCode }) {
|
||||
super(params.message);
|
||||
this.code = params.code;
|
||||
this.status = ActionError.codeToStatus(params.code);
|
||||
}
|
||||
|
||||
static codeToStatus(code: ActionErrorCode): number {
|
||||
return codeToStatusMap[code];
|
||||
}
|
||||
|
||||
static statusToCode(status: number): ActionErrorCode {
|
||||
return statusToCodeMap[status] ?? 'INTERNAL_SERVER_ERROR';
|
||||
}
|
||||
|
||||
static async fromResponse(res: Response) {
|
||||
if (
|
||||
res.status === 400 &&
|
||||
res.headers.get('Content-Type')?.toLowerCase().startsWith('application/json')
|
||||
) {
|
||||
const body = await res.json();
|
||||
if (
|
||||
typeof body === 'object' &&
|
||||
body?.type === 'AstroActionInputError' &&
|
||||
Array.isArray(body.issues)
|
||||
) {
|
||||
return new ActionInputError(body.issues);
|
||||
}
|
||||
}
|
||||
return new ActionError({
|
||||
message: res.statusText,
|
||||
code: this.statusToCode(res.status),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function isInputError<T extends ErrorInferenceObject>(
|
||||
error?: ActionError<T>
|
||||
): error is ActionInputError<T> {
|
||||
return error instanceof ActionInputError;
|
||||
}
|
||||
|
||||
export type SafeResult<TInput extends ErrorInferenceObject, TOutput> =
|
||||
| {
|
||||
data: TOutput;
|
||||
error: undefined;
|
||||
}
|
||||
| {
|
||||
data: undefined;
|
||||
error: ActionError<TInput>;
|
||||
};
|
||||
|
||||
export class ActionInputError<T extends ErrorInferenceObject> extends ActionError {
|
||||
type = 'AstroActionInputError';
|
||||
|
||||
// We don't expose all ZodError properties.
|
||||
// Not all properties will serialize from server to client,
|
||||
// and we don't want to import the full ZodError object into the client.
|
||||
|
||||
issues: z.ZodIssue[];
|
||||
fields: z.ZodError<T>['formErrors']['fieldErrors'];
|
||||
|
||||
constructor(issues: z.ZodIssue[]) {
|
||||
super({ message: 'Failed to validate', code: 'BAD_REQUEST' });
|
||||
this.issues = issues;
|
||||
this.fields = {};
|
||||
for (const issue of issues) {
|
||||
if (issue.path.length > 0) {
|
||||
const key = issue.path[0].toString() as keyof typeof this.fields;
|
||||
this.fields[key] ??= [];
|
||||
this.fields[key]?.push(issue.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function callSafely<TOutput>(
|
||||
handler: () => MaybePromise<TOutput>
|
||||
): Promise<SafeResult<z.ZodType, TOutput>> {
|
||||
try {
|
||||
const data = await handler();
|
||||
return { data, error: undefined };
|
||||
} catch (e) {
|
||||
if (e instanceof ActionError) {
|
||||
return { data: undefined, error: e };
|
||||
}
|
||||
return {
|
||||
data: undefined,
|
||||
error: new ActionError({
|
||||
message: e instanceof Error ? e.message : 'Unknown error',
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getActionProps<T extends (args: FormData) => MaybePromise<unknown>>(action: T) {
|
||||
return {
|
||||
type: 'hidden',
|
||||
name: '_astroAction',
|
||||
value: action.toString(),
|
||||
} as const;
|
||||
}
|
20
packages/astro/src/actions/utils.ts
Normal file
20
packages/astro/src/actions/utils.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { AstroError } from '../core/errors/errors.js';
|
||||
import type { APIContext } from '../@types/astro.js';
|
||||
import type { Locals } from './runtime/middleware.js';
|
||||
|
||||
function hasActionsInternal(locals: APIContext['locals']): locals is Locals {
|
||||
return '_actionsInternal' in locals;
|
||||
}
|
||||
|
||||
export function createGetActionResult(locals: APIContext['locals']): APIContext['getActionResult'] {
|
||||
return (actionFn) => {
|
||||
if (!hasActionsInternal(locals))
|
||||
throw new AstroError({
|
||||
name: 'AstroActionError',
|
||||
message: 'Experimental actions are not enabled in your project.',
|
||||
hint: 'See https://docs.astro.build/en/reference/configuration-reference/#experimental-flags',
|
||||
});
|
||||
|
||||
return locals._actionsInternal.getActionResult(actionFn);
|
||||
};
|
||||
}
|
|
@ -1,12 +1,7 @@
|
|||
export { CONTENT_FLAG, PROPAGATED_ASSET_FLAG } from './consts.js';
|
||||
export { attachContentServerListeners } from './server-listeners.js';
|
||||
export { createContentTypesGenerator } from './types-generator.js';
|
||||
export {
|
||||
contentObservable,
|
||||
getContentPaths,
|
||||
getDotAstroTypeReference,
|
||||
hasAssetPropagationFlag,
|
||||
} from './utils.js';
|
||||
export { contentObservable, getContentPaths, hasAssetPropagationFlag } from './utils.js';
|
||||
export { astroContentAssetPropagationPlugin } from './vite-plugin-content-assets.js';
|
||||
export { astroContentImportPlugin } from './vite-plugin-content-imports.js';
|
||||
export { astroContentVirtualModPlugin } from './vite-plugin-content-virtual-mod.js';
|
||||
|
|
|
@ -14,7 +14,7 @@ import type {
|
|||
} from '../@types/astro.js';
|
||||
import { AstroError, AstroErrorData, MarkdownError, errorMap } from '../core/errors/index.js';
|
||||
import { isYAMLException } from '../core/errors/utils.js';
|
||||
import { CONTENT_FLAGS, CONTENT_TYPES_FILE, PROPAGATED_ASSET_FLAG } from './consts.js';
|
||||
import { CONTENT_FLAGS, PROPAGATED_ASSET_FLAG } from './consts.js';
|
||||
import { createImage } from './runtime-assets.js';
|
||||
|
||||
/**
|
||||
|
@ -37,15 +37,6 @@ export const collectionConfigParser = z.union([
|
|||
}),
|
||||
]);
|
||||
|
||||
export function getDotAstroTypeReference({ root, srcDir }: { root: URL; srcDir: URL }) {
|
||||
const { cacheDir } = getContentPaths({ root, srcDir });
|
||||
const contentTypesRelativeToSrcDir = normalizePath(
|
||||
path.relative(fileURLToPath(srcDir), fileURLToPath(new URL(CONTENT_TYPES_FILE, cacheDir)))
|
||||
);
|
||||
|
||||
return `/// <reference path=${JSON.stringify(contentTypesRelativeToSrcDir)} />`;
|
||||
}
|
||||
|
||||
export const contentConfigParser = z.object({
|
||||
collections: z.record(collectionConfigParser),
|
||||
});
|
||||
|
|
|
@ -80,6 +80,7 @@ const ASTRO_CONFIG_DEFAULTS = {
|
|||
legacy: {},
|
||||
redirects: {},
|
||||
experimental: {
|
||||
actions: false,
|
||||
directRenderScript: false,
|
||||
contentCollectionCache: false,
|
||||
contentCollectionJsonSchema: false,
|
||||
|
@ -494,6 +495,7 @@ export const AstroConfigSchema = z.object({
|
|||
),
|
||||
experimental: z
|
||||
.object({
|
||||
actions: z.boolean().optional().default(ASTRO_CONFIG_DEFAULTS.experimental.actions),
|
||||
directRenderScript: z
|
||||
.boolean()
|
||||
.optional()
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import type { APIContext, MiddlewareHandler, Params, RewritePayload } from '../../@types/astro.js';
|
||||
import { createGetActionResult } from '../../actions/utils.js';
|
||||
import {
|
||||
computeCurrentLocale,
|
||||
computePreferredLocale,
|
||||
|
@ -47,19 +48,18 @@ function createContext({
|
|||
const route = url.pathname;
|
||||
|
||||
// TODO verify that this function works in an edge middleware environment
|
||||
const reroute = (_reroutePayload: RewritePayload) => {
|
||||
const rewrite = (_reroutePayload: RewritePayload) => {
|
||||
// return dummy response
|
||||
return Promise.resolve(new Response(null));
|
||||
};
|
||||
|
||||
return {
|
||||
const context: Omit<APIContext, 'getActionResult'> = {
|
||||
cookies: new AstroCookies(request),
|
||||
request,
|
||||
params,
|
||||
site: undefined,
|
||||
generator: `Astro v${ASTRO_VERSION}`,
|
||||
props: {},
|
||||
rewrite: reroute,
|
||||
rewrite,
|
||||
redirect(path, status) {
|
||||
return new Response(null, {
|
||||
status: status || 302,
|
||||
|
@ -104,6 +104,9 @@ function createContext({
|
|||
}
|
||||
},
|
||||
};
|
||||
return Object.assign(context, {
|
||||
getActionResult: createGetActionResult(context.locals),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { createGetActionResult } from '../actions/utils.js';
|
||||
import type {
|
||||
APIContext,
|
||||
AstroGlobal,
|
||||
|
@ -9,6 +10,7 @@ import type {
|
|||
RouteData,
|
||||
SSRResult,
|
||||
} from '../@types/astro.js';
|
||||
import type { ActionAPIContext } from '../actions/runtime/store.js';
|
||||
import {
|
||||
computeCurrentLocale,
|
||||
computePreferredLocale,
|
||||
|
@ -193,6 +195,14 @@ export class RenderContext {
|
|||
}
|
||||
|
||||
createAPIContext(props: APIContext['props']): APIContext {
|
||||
const context = this.createActionAPIContext();
|
||||
return Object.assign(context, {
|
||||
props,
|
||||
getActionResult: createGetActionResult(context.locals),
|
||||
});
|
||||
}
|
||||
|
||||
createActionAPIContext(): ActionAPIContext {
|
||||
const renderContext = this;
|
||||
const { cookies, params, pipeline, url } = this;
|
||||
const generator = `Astro v${ASTRO_VERSION}`;
|
||||
|
@ -256,7 +266,6 @@ export class RenderContext {
|
|||
get preferredLocaleList() {
|
||||
return renderContext.computePreferredLocaleList();
|
||||
},
|
||||
props,
|
||||
redirect,
|
||||
rewrite,
|
||||
request: this.request,
|
||||
|
@ -434,6 +443,7 @@ export class RenderContext {
|
|||
redirect,
|
||||
rewrite,
|
||||
request: this.request,
|
||||
getActionResult: createGetActionResult(locals),
|
||||
response,
|
||||
site: pipeline.site,
|
||||
url,
|
||||
|
|
|
@ -115,6 +115,10 @@ export async function runHookConfigSetup({
|
|||
if (settings.config.adapter) {
|
||||
settings.config.integrations.push(settings.config.adapter);
|
||||
}
|
||||
if (settings.config.experimental?.actions) {
|
||||
const { default: actionsIntegration } = await import('../actions/index.js');
|
||||
settings.config.integrations.push(actionsIntegration());
|
||||
}
|
||||
|
||||
let updatedConfig: AstroConfig = { ...settings.config };
|
||||
let updatedSettings: AstroSettings = { ...settings, config: updatedConfig };
|
||||
|
|
|
@ -4,8 +4,10 @@ import { fileURLToPath } from 'node:url';
|
|||
import { bold } from 'kleur/colors';
|
||||
import { type Plugin, normalizePath } from 'vite';
|
||||
import type { AstroSettings } from '../@types/astro.js';
|
||||
import { getContentPaths, getDotAstroTypeReference } from '../content/index.js';
|
||||
import { getContentPaths } from '../content/index.js';
|
||||
import { type Logger } from '../core/logger/core.js';
|
||||
import { CONTENT_TYPES_FILE } from '../content/consts.js';
|
||||
import { ACTIONS_TYPES_FILE } from '../actions/consts.js';
|
||||
|
||||
export function getEnvTsPath({ srcDir }: { srcDir: URL }) {
|
||||
return new URL('env.d.ts', srcDir);
|
||||
|
@ -42,23 +44,27 @@ export async function setUpEnvTs({
|
|||
}) {
|
||||
const envTsPath = getEnvTsPath(settings.config);
|
||||
const dotAstroDir = getContentPaths(settings.config).cacheDir;
|
||||
const dotAstroTypeReference = getDotAstroTypeReference(settings.config);
|
||||
const envTsPathRelativetoRoot = normalizePath(
|
||||
const dotAstroTypeReferences = getDotAstroTypeReferences({
|
||||
root: settings.config.root,
|
||||
srcDir: settings.config.srcDir,
|
||||
fs,
|
||||
});
|
||||
const envTsPathRelativeToRoot = normalizePath(
|
||||
path.relative(fileURLToPath(settings.config.root), fileURLToPath(envTsPath))
|
||||
);
|
||||
|
||||
if (fs.existsSync(envTsPath)) {
|
||||
let typesEnvContents = await fs.promises.readFile(envTsPath, 'utf-8');
|
||||
|
||||
if (!fs.existsSync(dotAstroDir))
|
||||
// Add `.astro` types reference if none exists
|
||||
return;
|
||||
const expectedTypeReference = getDotAstroTypeReference(settings.config);
|
||||
|
||||
if (!typesEnvContents.includes(expectedTypeReference)) {
|
||||
typesEnvContents = `${expectedTypeReference}\n${typesEnvContents}`;
|
||||
let addedTypes = false;
|
||||
for (const typeReference of dotAstroTypeReferences) {
|
||||
if (typesEnvContents.includes(typeReference)) continue;
|
||||
typesEnvContents = `${typeReference}\n${typesEnvContents}`;
|
||||
await fs.promises.writeFile(envTsPath, typesEnvContents, 'utf-8');
|
||||
logger.info('types', `Added ${bold(envTsPathRelativetoRoot)} type declarations`);
|
||||
addedTypes = true;
|
||||
}
|
||||
if (addedTypes) {
|
||||
logger.info('types', `Added ${bold(envTsPathRelativeToRoot)} type declarations`);
|
||||
}
|
||||
} else {
|
||||
// Otherwise, inject the `env.d.ts` file
|
||||
|
@ -66,11 +72,35 @@ export async function setUpEnvTs({
|
|||
referenceDefs.push('/// <reference types="astro/client" />');
|
||||
|
||||
if (fs.existsSync(dotAstroDir)) {
|
||||
referenceDefs.push(dotAstroTypeReference);
|
||||
referenceDefs.push(...dotAstroTypeReferences);
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(settings.config.srcDir, { recursive: true });
|
||||
await fs.promises.writeFile(envTsPath, referenceDefs.join('\n'), 'utf-8');
|
||||
logger.info('types', `Added ${bold(envTsPathRelativetoRoot)} type declarations`);
|
||||
logger.info('types', `Added ${bold(envTsPathRelativeToRoot)} type declarations`);
|
||||
}
|
||||
}
|
||||
|
||||
function getDotAstroTypeReferences({
|
||||
fs,
|
||||
root,
|
||||
srcDir,
|
||||
}: {
|
||||
fs: typeof fsMod;
|
||||
root: URL;
|
||||
srcDir: URL;
|
||||
}) {
|
||||
const { cacheDir } = getContentPaths({ root, srcDir });
|
||||
let referenceDefs: string[] = [];
|
||||
const typesFiles = [CONTENT_TYPES_FILE, ACTIONS_TYPES_FILE];
|
||||
for (const typesFile of typesFiles) {
|
||||
const url = new URL(typesFile, cacheDir);
|
||||
if (!fs.existsSync(url)) continue;
|
||||
const typesRelativeToSrcDir = normalizePath(
|
||||
path.relative(fileURLToPath(srcDir), fileURLToPath(url))
|
||||
);
|
||||
referenceDefs.push(`/// <reference path=${JSON.stringify(typesRelativeToSrcDir)} />`);
|
||||
}
|
||||
|
||||
return referenceDefs;
|
||||
}
|
||||
|
|
61
packages/astro/templates/actions.mjs
Normal file
61
packages/astro/templates/actions.mjs
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { ActionError, callSafely } from 'astro:actions';
|
||||
|
||||
function toActionProxy(actionCallback = {}, aggregatedPath = '/_actions/') {
|
||||
return new Proxy(actionCallback, {
|
||||
get(target, objKey) {
|
||||
if (objKey in target) {
|
||||
return target[objKey];
|
||||
}
|
||||
const path = aggregatedPath + objKey.toString();
|
||||
const action = (clientParam) => actionHandler(clientParam, path);
|
||||
action.toString = () => path;
|
||||
action.safe = (input) => {
|
||||
return callSafely(() => action(input));
|
||||
};
|
||||
// recurse to construct queries for nested object paths
|
||||
// ex. actions.user.admins.auth()
|
||||
return toActionProxy(action, path + '.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {*} clientParam argument passed to the action when used on the client.
|
||||
* @param {string} path Built path to call action on the server.
|
||||
* Usage: `actions.[name](clientParam)`.
|
||||
*/
|
||||
async function actionHandler(clientParam, path) {
|
||||
if (import.meta.env.SSR) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message:
|
||||
'Action unexpectedly called on the server. If this error is unexpected, share your feedback on our RFC discussion: https://github.com/withastro/roadmap/pull/912',
|
||||
});
|
||||
}
|
||||
const headers = new Headers();
|
||||
headers.set('Accept', 'application/json');
|
||||
let body = clientParam;
|
||||
if (!(body instanceof FormData)) {
|
||||
try {
|
||||
body = clientParam ? JSON.stringify(clientParam) : undefined;
|
||||
} catch (e) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: `Failed to serialize request body to JSON. Full error: ${e.message}`,
|
||||
});
|
||||
}
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
const res = await fetch(path, {
|
||||
method: 'POST',
|
||||
body,
|
||||
headers,
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw await ActionError.fromResponse(res);
|
||||
}
|
||||
const json = await res.json();
|
||||
return json;
|
||||
}
|
||||
|
||||
export const actions = toActionProxy();
|
173
packages/astro/test/actions.test.js
Normal file
173
packages/astro/test/actions.test.js
Normal file
|
@ -0,0 +1,173 @@
|
|||
import assert from 'node:assert/strict';
|
||||
import { before, after, describe, it } from 'node:test';
|
||||
import { loadFixture } from './test-utils.js';
|
||||
import testAdapter from './test-adapter.js';
|
||||
|
||||
describe('Astro Actions', () => {
|
||||
let fixture;
|
||||
before(async () => {
|
||||
fixture = await loadFixture({
|
||||
root: './fixtures/actions/',
|
||||
adapter: testAdapter(),
|
||||
});
|
||||
});
|
||||
|
||||
describe('dev', () => {
|
||||
let devServer;
|
||||
|
||||
before(async () => {
|
||||
devServer = await fixture.startDevServer();
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await devServer.stop();
|
||||
});
|
||||
|
||||
it('Exposes subscribe action', async () => {
|
||||
const res = await fixture.fetch('/_actions/subscribe', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ channel: 'bholmesdev' }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.headers.get('Content-Type'), 'application/json');
|
||||
|
||||
const json = await res.json();
|
||||
assert.equal(json.channel, 'bholmesdev');
|
||||
assert.equal(json.subscribeButtonState, 'smashed');
|
||||
});
|
||||
|
||||
it('Exposes comment action', async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('channel', 'bholmesdev');
|
||||
formData.append('comment', 'Hello, World!');
|
||||
const res = await fixture.fetch('/_actions/comment', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.headers.get('Content-Type'), 'application/json');
|
||||
|
||||
const json = await res.json();
|
||||
assert.equal(json.channel, 'bholmesdev');
|
||||
assert.equal(json.comment, 'Hello, World!');
|
||||
});
|
||||
|
||||
it('Raises validation error on bad form data', async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('channel', 'bholmesdev');
|
||||
const res = await fixture.fetch('/_actions/comment', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.headers.get('Content-Type'), 'application/json');
|
||||
|
||||
const json = await res.json();
|
||||
assert.equal(json.type, 'AstroActionInputError');
|
||||
});
|
||||
|
||||
it('Exposes plain formData action', async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('channel', 'bholmesdev');
|
||||
formData.append('comment', 'Hello, World!');
|
||||
const res = await fixture.fetch('/_actions/commentPlainFormData', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.headers.get('Content-Type'), 'application/json');
|
||||
|
||||
const json = await res.json();
|
||||
assert.equal(json.success, true);
|
||||
assert.equal(json.isFormData, true, 'Should receive plain FormData');
|
||||
});
|
||||
});
|
||||
|
||||
describe('build', () => {
|
||||
let app;
|
||||
|
||||
before(async () => {
|
||||
await fixture.build();
|
||||
app = await fixture.loadTestAdapterApp();
|
||||
});
|
||||
|
||||
it('Exposes subscribe action', async () => {
|
||||
const req = new Request('http://example.com/_actions/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ channel: 'bholmesdev' }),
|
||||
});
|
||||
const res = await app.render(req);
|
||||
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.headers.get('Content-Type'), 'application/json');
|
||||
|
||||
const json = await res.json();
|
||||
assert.equal(json.channel, 'bholmesdev');
|
||||
assert.equal(json.subscribeButtonState, 'smashed');
|
||||
});
|
||||
|
||||
it('Exposes comment action', async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('channel', 'bholmesdev');
|
||||
formData.append('comment', 'Hello, World!');
|
||||
const req = new Request('http://example.com/_actions/comment', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const res = await app.render(req);
|
||||
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.headers.get('Content-Type'), 'application/json');
|
||||
|
||||
const json = await res.json();
|
||||
assert.equal(json.channel, 'bholmesdev');
|
||||
assert.equal(json.comment, 'Hello, World!');
|
||||
});
|
||||
|
||||
it('Raises validation error on bad form data', async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('channel', 'bholmesdev');
|
||||
const req = new Request('http://example.com/_actions/comment', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const res = await app.render(req);
|
||||
|
||||
assert.equal(res.ok, false);
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(res.headers.get('Content-Type'), 'application/json');
|
||||
|
||||
const json = await res.json();
|
||||
assert.equal(json.type, 'AstroActionInputError');
|
||||
});
|
||||
|
||||
it('Exposes plain formData action', async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('channel', 'bholmesdev');
|
||||
formData.append('comment', 'Hello, World!');
|
||||
const req = new Request('http://example.com/_actions/commentPlainFormData', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const res = await app.render(req);
|
||||
|
||||
assert.equal(res.ok, true);
|
||||
assert.equal(res.headers.get('Content-Type'), 'application/json');
|
||||
|
||||
const json = await res.json();
|
||||
assert.equal(json.success, true);
|
||||
assert.equal(json.isFormData, true, 'Should receive plain FormData');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -2,6 +2,9 @@ import assert from 'node:assert/strict';
|
|||
import * as fs from 'node:fs';
|
||||
import { before, describe, it } from 'node:test';
|
||||
import { loadFixture } from './test-utils.js';
|
||||
import { CONTENT_TYPES_FILE } from '../dist/content/consts.js';
|
||||
import { ACTIONS_TYPES_FILE } from '../dist/actions/consts.js';
|
||||
import { getContentPaths } from '../dist/content/utils.js';
|
||||
|
||||
describe('astro sync', () => {
|
||||
let fixture;
|
||||
|
@ -34,22 +37,17 @@ describe('astro sync', () => {
|
|||
|
||||
it('Adds type reference to `src/env.d.ts`', async () => {
|
||||
let writtenFiles = {};
|
||||
const typesEnvPath = new URL('env.d.ts', fixture.config.srcDir).href;
|
||||
const typesEnvPath = getTypesEnvPath(fixture);
|
||||
const fsMock = {
|
||||
...fs,
|
||||
existsSync(path, ...args) {
|
||||
if (path.toString() === typesEnvPath) {
|
||||
return true;
|
||||
}
|
||||
return fs.existsSync(path, ...args);
|
||||
},
|
||||
existsSync: createExistsSync(fixture, true),
|
||||
promises: {
|
||||
...fs.promises,
|
||||
async readFile(path, ...args) {
|
||||
async readFile(path) {
|
||||
if (path.toString() === typesEnvPath) {
|
||||
return `/// <reference path="astro/client" />`;
|
||||
} else {
|
||||
return fs.promises.readFile(path, ...args);
|
||||
throw new Error(`Tried to read unexpected path: ${path}`);
|
||||
}
|
||||
},
|
||||
async writeFile(path, contents) {
|
||||
|
@ -72,15 +70,10 @@ describe('astro sync', () => {
|
|||
|
||||
it('Writes `src/env.d.ts` if none exists', async () => {
|
||||
let writtenFiles = {};
|
||||
const typesEnvPath = new URL('env.d.ts', fixture.config.srcDir).href;
|
||||
const typesEnvPath = getTypesEnvPath(fixture);
|
||||
const fsMock = {
|
||||
...fs,
|
||||
existsSync(path, ...args) {
|
||||
if (path.toString() === typesEnvPath) {
|
||||
return false;
|
||||
}
|
||||
return fs.existsSync(path, ...args);
|
||||
},
|
||||
existsSync: createExistsSync(fixture, false),
|
||||
promises: {
|
||||
...fs.promises,
|
||||
async writeFile(path, contents) {
|
||||
|
@ -105,3 +98,21 @@ describe('astro sync', () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
function getTypesEnvPath(fixture) {
|
||||
return new URL('env.d.ts', fixture.config.srcDir).href;
|
||||
}
|
||||
|
||||
function createExistsSync(fixture, envDtsExists = false) {
|
||||
const { cacheDir } = getContentPaths(fixture.config);
|
||||
const paths = [
|
||||
new URL(CONTENT_TYPES_FILE, cacheDir).href,
|
||||
new URL(ACTIONS_TYPES_FILE, cacheDir).href,
|
||||
cacheDir.href,
|
||||
];
|
||||
if (envDtsExists) {
|
||||
paths.push(getTypesEnvPath(fixture));
|
||||
}
|
||||
|
||||
return (path) => paths.includes(path.toString());
|
||||
}
|
||||
|
|
9
packages/astro/test/fixtures/actions/astro.config.mjs
vendored
Normal file
9
packages/astro/test/fixtures/actions/astro.config.mjs
vendored
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { defineConfig } from 'astro/config';
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
output: 'server',
|
||||
experimental: {
|
||||
actions: true,
|
||||
},
|
||||
});
|
8
packages/astro/test/fixtures/actions/package.json
vendored
Normal file
8
packages/astro/test/fixtures/actions/package.json
vendored
Normal file
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "@test/actions",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"astro": "workspace:*"
|
||||
}
|
||||
}
|
32
packages/astro/test/fixtures/actions/src/actions/index.ts
vendored
Normal file
32
packages/astro/test/fixtures/actions/src/actions/index.ts
vendored
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { defineAction, z } from 'astro:actions';
|
||||
|
||||
export const server = {
|
||||
subscribe: defineAction({
|
||||
input: z.object({ channel: z.string() }),
|
||||
handler: async ({ channel }) => {
|
||||
return {
|
||||
channel,
|
||||
subscribeButtonState: 'smashed',
|
||||
};
|
||||
},
|
||||
}),
|
||||
comment: defineAction({
|
||||
accept: 'form',
|
||||
input: z.object({ channel: z.string(), comment: z.string() }),
|
||||
handler: async ({ channel, comment }) => {
|
||||
return {
|
||||
channel,
|
||||
comment,
|
||||
};
|
||||
},
|
||||
}),
|
||||
commentPlainFormData: defineAction({
|
||||
accept: 'form',
|
||||
handler: async (formData) => {
|
||||
return {
|
||||
success: true,
|
||||
isFormData: formData instanceof FormData,
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
138
packages/astro/test/units/actions/form-data-to-object.test.js
Normal file
138
packages/astro/test/units/actions/form-data-to-object.test.js
Normal file
|
@ -0,0 +1,138 @@
|
|||
import * as assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { z } from 'zod';
|
||||
import { formDataToObject } from '../../../dist/actions/runtime/virtual/server.js';
|
||||
|
||||
describe('formDataToObject', () => {
|
||||
it('should handle strings', () => {
|
||||
const formData = new FormData();
|
||||
formData.set('name', 'Ben');
|
||||
formData.set('email', 'test@test.test');
|
||||
|
||||
const input = z.object({
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
});
|
||||
|
||||
const res = formDataToObject(formData, input);
|
||||
assert.equal(res.name, 'Ben');
|
||||
assert.equal(res.email, 'test@test.test');
|
||||
});
|
||||
|
||||
it('should handle numbers', () => {
|
||||
const formData = new FormData();
|
||||
formData.set('age', '25');
|
||||
|
||||
const input = z.object({
|
||||
age: z.number(),
|
||||
});
|
||||
|
||||
const res = formDataToObject(formData, input);
|
||||
assert.equal(res.age, 25);
|
||||
});
|
||||
|
||||
it('should pass NaN for invalid numbers', () => {
|
||||
const formData = new FormData();
|
||||
formData.set('age', 'twenty-five');
|
||||
|
||||
const input = z.object({
|
||||
age: z.number(),
|
||||
});
|
||||
|
||||
const res = formDataToObject(formData, input);
|
||||
assert.ok(isNaN(res.age));
|
||||
});
|
||||
|
||||
it('should handle boolean checks', () => {
|
||||
const formData = new FormData();
|
||||
formData.set('isCool', 'yes');
|
||||
|
||||
const input = z.object({
|
||||
isCool: z.boolean(),
|
||||
isNotCool: z.boolean(),
|
||||
});
|
||||
|
||||
const res = formDataToObject(formData, input);
|
||||
assert.equal(res.isCool, true);
|
||||
assert.equal(res.isNotCool, false);
|
||||
});
|
||||
|
||||
it('should handle optional values', () => {
|
||||
const formData = new FormData();
|
||||
formData.set('name', 'Ben');
|
||||
|
||||
const input = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().optional(),
|
||||
age: z.number().optional(),
|
||||
});
|
||||
|
||||
const res = formDataToObject(formData, input);
|
||||
|
||||
assert.equal(res.name, 'Ben');
|
||||
assert.equal(res.email, undefined);
|
||||
assert.equal(res.age, undefined);
|
||||
});
|
||||
|
||||
it('should handle null values', () => {
|
||||
const formData = new FormData();
|
||||
formData.set('name', 'Ben');
|
||||
|
||||
const input = z.object({
|
||||
name: z.string().nullable(),
|
||||
email: z.string().nullable(),
|
||||
age: z.number().nullable(),
|
||||
});
|
||||
|
||||
const res = formDataToObject(formData, input);
|
||||
|
||||
assert.equal(res.name, 'Ben');
|
||||
assert.equal(res.email, null);
|
||||
assert.equal(res.age, null);
|
||||
});
|
||||
|
||||
it('should handle File objects', () => {
|
||||
const formData = new FormData();
|
||||
formData.set('file', new File([''], 'test.txt'));
|
||||
|
||||
const input = z.object({
|
||||
file: z.instanceof(File),
|
||||
});
|
||||
|
||||
const res = formDataToObject(formData, input);
|
||||
|
||||
assert.equal(res.file instanceof File, true);
|
||||
});
|
||||
|
||||
it('should handle string arrays', () => {
|
||||
const formData = new FormData();
|
||||
formData.append('contact', 'Ben');
|
||||
formData.append('contact', 'Jane');
|
||||
formData.append('contact', 'John');
|
||||
|
||||
const input = z.object({
|
||||
contact: z.array(z.string()),
|
||||
});
|
||||
|
||||
const res = formDataToObject(formData, input);
|
||||
|
||||
assert.ok(Array.isArray(res.contact), 'contact is not an array');
|
||||
assert.deepEqual(res.contact.sort(), ['Ben', 'Jane', 'John']);
|
||||
});
|
||||
|
||||
it('should handle number arrays', () => {
|
||||
const formData = new FormData();
|
||||
formData.append('age', '25');
|
||||
formData.append('age', '30');
|
||||
formData.append('age', '35');
|
||||
|
||||
const input = z.object({
|
||||
age: z.array(z.number()),
|
||||
});
|
||||
|
||||
const res = formDataToObject(formData, input);
|
||||
|
||||
assert.ok(Array.isArray(res.age), 'age is not an array');
|
||||
assert.deepEqual(res.age.sort(), [25, 30, 35]);
|
||||
});
|
||||
});
|
3
packages/astro/types/actions.d.ts
vendored
Normal file
3
packages/astro/types/actions.d.ts
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
declare module 'astro:actions' {
|
||||
export * from 'astro/actions/runtime/virtual/server.js';
|
||||
}
|
52
pnpm-lock.yaml
generated
52
pnpm-lock.yaml
generated
|
@ -856,6 +856,39 @@ importers:
|
|||
specifier: workspace:*
|
||||
version: link:../../../..
|
||||
|
||||
packages/astro/e2e/fixtures/actions-blog:
|
||||
dependencies:
|
||||
'@astrojs/check':
|
||||
specifier: ^0.5.10
|
||||
version: 0.5.10(prettier-plugin-astro@0.13.0)(prettier@3.2.5)(typescript@5.4.5)
|
||||
'@astrojs/db':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../db
|
||||
'@astrojs/node':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../integrations/node
|
||||
'@astrojs/react':
|
||||
specifier: workspace:*
|
||||
version: link:../../../../integrations/react
|
||||
'@types/react':
|
||||
specifier: ^18.2.79
|
||||
version: 18.3.1
|
||||
'@types/react-dom':
|
||||
specifier: ^18.2.25
|
||||
version: 18.3.0
|
||||
astro:
|
||||
specifier: workspace:*
|
||||
version: link:../../..
|
||||
react:
|
||||
specifier: ^18.3.0
|
||||
version: 18.3.1
|
||||
react-dom:
|
||||
specifier: ^18.3.0
|
||||
version: 18.3.1(react@18.3.1)
|
||||
typescript:
|
||||
specifier: ^5.4.5
|
||||
version: 5.4.5
|
||||
|
||||
packages/astro/e2e/fixtures/astro-component:
|
||||
dependencies:
|
||||
'@astrojs/preact':
|
||||
|
@ -1726,6 +1759,12 @@ importers:
|
|||
specifier: ^3.3.8
|
||||
version: 3.4.21(typescript@5.4.5)
|
||||
|
||||
packages/astro/test/fixtures/actions:
|
||||
dependencies:
|
||||
astro:
|
||||
specifier: workspace:*
|
||||
version: link:../../..
|
||||
|
||||
packages/astro/test/fixtures/alias:
|
||||
dependencies:
|
||||
'@astrojs/svelte':
|
||||
|
@ -8470,7 +8509,7 @@ packages:
|
|||
/@types/react-dom@18.2.25:
|
||||
resolution: {integrity: sha512-o/V48vf4MQh7juIKZU2QGDfli6p1+OOi5oXx36Hffpc9adsHeXjVp8rHuPkjd8VT8sOJ2Zp05HR7CdpGTIUFUA==}
|
||||
dependencies:
|
||||
'@types/react': 18.2.78
|
||||
'@types/react': 18.3.1
|
||||
dev: false
|
||||
|
||||
/@types/react-dom@18.3.0:
|
||||
|
@ -9618,6 +9657,7 @@ packages:
|
|||
|
||||
/base64-js@1.5.1:
|
||||
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
|
||||
/bcp-47-match@2.0.3:
|
||||
|
@ -9727,6 +9767,7 @@ packages:
|
|||
|
||||
/buffer@5.7.1:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
@ -10532,6 +10573,7 @@ packages:
|
|||
/detect-libc@2.0.3:
|
||||
resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==}
|
||||
engines: {node: '>=8'}
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
|
||||
/deterministic-object-hash@2.0.2:
|
||||
|
@ -12034,6 +12076,7 @@ packages:
|
|||
|
||||
/ieee754@1.2.1:
|
||||
resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==}
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
|
||||
/ignore@5.3.1:
|
||||
|
@ -12078,6 +12121,7 @@ packages:
|
|||
|
||||
/inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
requiresBuild: true
|
||||
|
||||
/inline-style-parser@0.1.1:
|
||||
resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==}
|
||||
|
@ -13498,6 +13542,7 @@ packages:
|
|||
|
||||
/minimist@1.2.8:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
requiresBuild: true
|
||||
dev: false
|
||||
|
||||
/minipass@3.3.6:
|
||||
|
@ -13839,6 +13884,7 @@ packages:
|
|||
|
||||
/once@1.4.0:
|
||||
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
wrappy: 1.0.2
|
||||
|
||||
|
@ -14839,6 +14885,7 @@ packages:
|
|||
/readable-stream@3.6.2:
|
||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||
engines: {node: '>= 6'}
|
||||
requiresBuild: true
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
string_decoder: 1.3.0
|
||||
|
@ -15857,6 +15904,7 @@ packages:
|
|||
/strip-json-comments@2.0.1:
|
||||
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
requiresBuild: true
|
||||
dev: true
|
||||
|
||||
/strip-json-comments@3.1.1:
|
||||
|
@ -16685,6 +16733,7 @@ packages:
|
|||
|
||||
/util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
requiresBuild: true
|
||||
|
||||
/utils-merge@1.0.1:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
|
@ -17327,6 +17376,7 @@ packages:
|
|||
|
||||
/wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
requiresBuild: true
|
||||
|
||||
/ws@8.16.0:
|
||||
resolution: {integrity: sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==}
|
||||
|
|
Loading…
Add table
Reference in a new issue